let app = { data() { return { rawData: "Topic 1: 60min\nTopic 2: 20min\nTopic 3: 50min\nTopic 4: 20min\nTopic 5:10min\nTopic 6: 10min\nTopic 7: 10min", data: [], wheelPosition: 0, weighted: false, timeoutId: 0, showSelected: false, selected: 0, timerEnd: new Date(), timerStarted: false, date: new Date(), meetingStart: new Date(), elapsedTime: 0, initialSpin: true, initialColor: Math.random() * 360, rid: 0, beepTimer: undefined, sound: undefined, }; }, watch: { rawData() { this.data = this.getData(); }, }, computed: { selectedData() { return ( this.data[this.selected] ?? { text: "???", time: 1, disabled: false, } ); }, filteredData() { return this.data.filter((item) => !item.disabled); }, totalTime() { return this.elapsedTime + this.data.map((item) => item.time).reduce((a, b) => a + b, 0); }, totalRemainingTime() { return this.filteredData .map((item) => item.time) .reduce((a, b) => a + b, 0); }, overtimeTime() { return this.totalTime - this.totalRemainingTime; }, estimatedEnd() { const end = new Date(this.meetingStart.getTime()); const timerDelta = (this.timerEnd - this.date) / (1000 * 60); end.setMinutes(end.getMinutes() + this.totalTime - (this.timerStarted ? timerDelta : 0)); return end.getHours() * 60 + end.getMinutes(); }, svgData() { let totalAngle = 0; return this.filteredData.map((item, index) => { const ratio = this.weighted ? item.time / this.totalRemainingTime : 1 / this.filteredData.length; const angleRad = 2 * Math.PI * ratio; const angleDeg = 360 * ratio; const textScale = this.textScale(item.text, angleRad); totalAngle += angleDeg; return { id: item.id, text: item.text, textPosition: textScale * 0.95, textTransform: `scale(${1 / textScale})`, path: `M 0 0 L ${Math.cos(-angleRad / 2)} ${Math.sin( -angleRad / 2 )} A 1 1 0 ${ratio > 0.5 ? 1 : 0} 1 ${Math.cos( angleRad / 2 )} ${Math.sin(angleRad / 2)} z`, transform: `rotate(${totalAngle - angleDeg / 2}, 0, 0)`, color: `hsl(${this.initialColor + 100 * index} 80% 50%)`, from: totalAngle - angleDeg, to: totalAngle, }; }); }, }, methods: { textScale(text, angleRad) { const r = 1.2; const n = text.length; const k = n + r / (2 * Math.tan(angleRad / 2)); return k / r; }, overtime() { return this.timerStarted && this.timerEnd - new Date() <= 0; }, timerText() { const delta = this.timerStarted ? Math.floor((this.timerEnd - new Date()) / 1000) : (this.showSelected ? this.selectedData.time * 60 : 0); return `${delta < 0 ? "-" : ""}${String( Math.floor(Math.abs(delta) / 60) ).padStart(2, "0")}:${String(Math.abs(delta) % 60).padStart(2, "0")}`; }, beep() { this.sound.play(); }, timeText(minutes) { if (minutes >= 60) { return `${Math.floor(minutes / 60).toFixed(0)}h${(minutes % 60).toFixed(0).padStart( 2, "0" )}`; } else { return `${(minutes % 60).toFixed(0).padStart(2, "0")}min`; } }, spin() { if (this.timerStarted) return; this.showSelected = false; if (this.initialSpin) { this.meetingStart = new Date(); } this.initialSpin = false; this.wheelPosition += 360 * 10 + Math.random() * 360; clearTimeout(this.timeoutId); this.selected = this.getSelected(); this.timeoutId = setTimeout(() => { this.showSelected = true; }, 5000); }, getSelected() { const angle = 360 - (this.wheelPosition % 360); for (let index = 0; index < this.data.length; index++) { const element = this.svgData[index]; if (angle >= element.from && angle < element.to) { return element.id; } } return 0; }, getData() { const re = /:\s?(?:(?:(\d+)\s?h)?(\d+)?(?:\s?m(?:in)?)?)\s?$/i; this.setCookie("rawData", btoa(this.rawData)); return this.rawData .split("\n") .map((line) => line.trim()) .filter((line) => line.length) .map((line, index) => { const result = re.exec(line); if (result === null) { return { id: index, text: line, time: 1, disabled: line.substring(0, 1) === "-", }; } else { return { id: index, text: line.substring(0, line.indexOf(result[0])), time: parseInt(result[1] ?? 0) * 60 + parseInt(result[2] ?? 0), disabled: line.substring(0, 1) === "-", }; } }); }, showApp() { document.getElementById("app").setAttribute("style", ""); }, removeTopic() { let i = 0; this.rawData = this.rawData .split("\n") .map((line) => { if (line.trim().length) { if (i === this.selected) { line = "-" + line; } i += 1; } return line; }) .join("\n"); this.showSelected = false; this.initialSpin = true; }, timerFunction() { this.timerStarted = !this.timerStarted; clearTimeout(this.beepTimer); if (this.timerStarted) { this.timerEnd = new Date( new Date().getTime() + this.selectedData.time * 60 * 1000 ); this.beepTimer = setTimeout(() => { this.beep(); }, this.selectedData.time * 60 * 1000); } else { document.title = "Meeting Roulette"; } }, setCookie(cname, cvalue, exdays) { const d = new Date(); d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000)); let expires = "expires=" + d.toUTCString(); console.log(cname + "=" + cvalue + "; path=/; " + expires); document.cookie = cname + "=" + cvalue + "; path=/; " + expires; }, getCookie(cname, defaultValue) { let name = cname + "="; let decodedCookie = decodeURIComponent(document.cookie); let ca = decodedCookie.split(';'); for (let i = 0; i < ca.length; i++) { let c = ca[i]; while (c.charAt(0) == " ") { c = c.substring(1); } if (c.indexOf(name) == 0) { return c.substring(name.length, c.length); } } return defaultValue; }, }, mounted: function () { console.log("app mounted"); this.sound = new Audio("./sound.wav"); this.rawData = atob(this.getCookie("rawData", btoa(this.rawData))); this.data = this.getData(); setTimeout(this.showApp); setInterval(() => { this.rid = Math.random(); if (this.timerStarted) { document.title = this.timerText(); } this.elapsedTime = (new Date() - this.meetingStart) / (1000 * 60); this.date = new Date(); }, 200); }, }; window.onload = () => { app = Vue.createApp(app); app.mount("#app"); };