import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; const USER_ID_KEY = "folk-choice-userid"; const USER_NAME_KEY = "folk-choice-username"; const styles = css` :host { background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); min-width: 400px; min-height: 480px; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #059669; color: white; border-radius: 8px 8px 0 0; font-size: 12px; font-weight: 600; cursor: move; } .header-title { display: flex; align-items: center; gap: 6px; } .header-actions { display: flex; gap: 4px; } .header-actions button { background: transparent; border: none; color: white; cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 14px; } .header-actions button:hover { background: rgba(255, 255, 255, 0.2); } .body { display: flex; flex-direction: column; height: calc(100% - 36px); overflow: hidden; } .option-tabs { display: flex; border-bottom: 1px solid #e2e8f0; overflow-x: auto; } .option-tab { padding: 6px 12px; text-align: center; font-size: 11px; font-weight: 500; cursor: pointer; border: none; background: transparent; color: #64748b; white-space: nowrap; transition: all 0.15s; flex-shrink: 0; } .option-tab.active { color: #059669; border-bottom: 2px solid #059669; background: #ecfdf5; } .chart-area { display: flex; justify-content: center; padding: 8px; flex-shrink: 0; } .chart-area svg { max-width: 260px; max-height: 240px; } .sliders { padding: 4px 12px; overflow-y: auto; flex: 1; } .slider-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } .slider-label { font-size: 11px; color: #64748b; min-width: 60px; text-align: right; } .slider-input { flex: 1; height: 4px; -webkit-appearance: none; appearance: none; background: #e2e8f0; border-radius: 2px; outline: none; cursor: pointer; } .slider-input::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #059669; cursor: pointer; } .slider-val { font-size: 12px; font-weight: 600; color: #059669; min-width: 18px; text-align: center; font-variant-numeric: tabular-nums; } .legend { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 12px; border-top: 1px solid #e2e8f0; } .legend-item { display: flex; align-items: center; gap: 4px; font-size: 10px; color: #64748b; } .legend-dot { width: 8px; height: 8px; border-radius: 50%; } .score-summary { padding: 4px 12px; font-size: 11px; color: #64748b; text-align: center; border-top: 1px solid #e2e8f0; } .score-summary .best { font-weight: 600; color: #059669; } .add-forms { padding: 6px 12px; border-top: 1px solid #e2e8f0; display: flex; gap: 6px; } .add-forms .add-group { flex: 1; display: flex; gap: 4px; } .add-forms input { flex: 1; min-width: 0; border: 1px solid #e2e8f0; border-radius: 4px; padding: 4px 6px; font-size: 11px; outline: none; } .add-forms input:focus { border-color: #059669; } .add-forms button { background: #059669; color: white; border: none; border-radius: 4px; padding: 4px 8px; cursor: pointer; font-size: 10px; font-weight: 500; white-space: nowrap; } .add-forms button:hover { background: #047857; } .username-prompt { padding: 16px; text-align: center; } .username-prompt p { font-size: 13px; color: #64748b; margin: 0 0 8px; } .username-input { border: 1px solid #e2e8f0; border-radius: 6px; padding: 8px 12px; font-size: 13px; outline: none; width: 100%; box-sizing: border-box; margin-bottom: 8px; } .username-btn { background: #059669; color: white; border: none; border-radius: 6px; padding: 8px 16px; cursor: pointer; font-weight: 500; font-size: 13px; } .empty-state { text-align: center; padding: 24px 12px; color: #94a3b8; font-size: 12px; } .wrapper { position: relative; height: 100%; } .results-drawer { position: absolute; top: 0; left: 100%; width: 300px; height: 100%; background: white; border-radius: 0 8px 8px 0; box-shadow: 4px 0 12px rgba(0,0,0,0.08); overflow-y: auto; display: none; flex-direction: column; font-size: 12px; z-index: 10; } .drawer-open .results-drawer { display: flex; } .drawer-section { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; } .drawer-heading { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px; } .stat-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 11px; } .stat-label { color: #64748b; } .stat-value { font-weight: 600; color: #1e293b; font-variant-numeric: tabular-nums; } .drawer-bar-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } .drawer-bar-label { font-size: 11px; min-width: 60px; color: #1e293b; } .drawer-bar-bg { flex: 1; height: 12px; background: #f1f5f9; border-radius: 3px; overflow: hidden; } .drawer-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; } .drawer-bar-val { font-size: 10px; font-weight: 600; min-width: 24px; text-align: right; font-variant-numeric: tabular-nums; } .participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; } .participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .drawer-toggle.active { background: rgba(255,255,255,0.3); } .settings-toggle.active { background: rgba(255,255,255,0.3); } .settings-panel { display: none; flex-direction: column; gap: 12px; padding: 12px; overflow-y: auto; height: calc(100% - 36px); } .settings-open .settings-panel { display: flex; } .settings-open .body { display: none !important; } .settings-open .results-drawer { display: none !important; } .settings-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px; } .settings-input { width: 100%; box-sizing: border-box; border: 1px solid #e2e8f0; border-radius: 6px; padding: 6px 10px; font-size: 12px; outline: none; } .settings-input:focus { border-color: #059669; } .settings-item { display: flex; align-items: center; gap: 6px; padding: 4px 0; } .settings-item input[type="text"] { flex: 1; border: 1px solid #e2e8f0; border-radius: 4px; padding: 4px 6px; font-size: 11px; outline: none; min-width: 0; } .settings-item input[type="range"] { width: 60px; height: 4px; -webkit-appearance: none; appearance: none; background: #e2e8f0; border-radius: 2px; outline: none; cursor: pointer; } .settings-item input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: #059669; cursor: pointer; } .settings-item .weight-val { font-size: 10px; font-weight: 600; color: #059669; min-width: 12px; text-align: center; } .settings-item .remove-btn { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 14px; padding: 2px 4px; border-radius: 4px; flex-shrink: 0; } .settings-item .remove-btn:hover { color: #ef4444; background: #fef2f2; } .settings-danger { background: none; border: 1px solid #fca5a5; color: #ef4444; border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 11px; width: 100%; margin-top: 4px; } .settings-danger:hover { background: #fef2f2; } .settings-done { background: #059669; color: white; border: none; border-radius: 6px; padding: 8px 16px; cursor: pointer; font-size: 12px; font-weight: 500; width: 100%; margin-top: auto; } .settings-done:hover { background: #047857; } `; // -- Data types -- export interface SpiderOption { id: string; label: string; } export interface SpiderCriterion { id: string; label: string; weight: number; } export interface SpiderScore { userId: string; userName: string; optionId: string; criterionId: string; value: number; timestamp: number; } // -- Pure aggregation functions -- export function weightedMeanScore( scores: SpiderScore[], criteria: SpiderCriterion[], optionId: string, ): number { const byCriterion = new Map(); for (const s of scores) { if (s.optionId !== optionId) continue; if (!byCriterion.has(s.criterionId)) byCriterion.set(s.criterionId, []); byCriterion.get(s.criterionId)!.push(s.value); } let weightedSum = 0; let totalWeight = 0; for (const c of criteria) { const vals = byCriterion.get(c.id); if (!vals || vals.length === 0) continue; const avg = vals.reduce((a, b) => a + b, 0) / vals.length; weightedSum += avg * c.weight; totalWeight += c.weight; } return totalWeight > 0 ? weightedSum / totalWeight : 0; } export function getRadarVertices( scores: SpiderScore[], criteria: SpiderCriterion[], optionId: string, userId: string, cx: number, cy: number, radius: number, ): { x: number; y: number }[] { const n = criteria.length; if (n === 0) return []; const angleStep = (2 * Math.PI) / n; return criteria.map((c, i) => { const score = scores.find( (s) => s.optionId === optionId && s.criterionId === c.id && s.userId === userId, ); const val = score ? score.value / 10 : 0; const angle = i * angleStep - Math.PI / 2; return { x: cx + radius * val * Math.cos(angle), y: cy + radius * val * Math.sin(angle), }; }); } export function getAverageRadarVertices( scores: SpiderScore[], criteria: SpiderCriterion[], optionId: string, cx: number, cy: number, radius: number, ): { x: number; y: number }[] { const n = criteria.length; if (n === 0) return []; const angleStep = (2 * Math.PI) / n; const byCriterion = new Map(); for (const s of scores) { if (s.optionId !== optionId) continue; if (!byCriterion.has(s.criterionId)) byCriterion.set(s.criterionId, []); byCriterion.get(s.criterionId)!.push(s.value); } return criteria.map((c, i) => { const vals = byCriterion.get(c.id) || []; const avg = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; const val = avg / 10; const angle = i * angleStep - Math.PI / 2; return { x: cx + radius * val * Math.cos(angle), y: cy + radius * val * Math.sin(angle), }; }); } export function polygonArea(vertices: { x: number; y: number }[]): number { const n = vertices.length; if (n < 3) return 0; let area = 0; for (let i = 0; i < n; i++) { const j = (i + 1) % n; area += vertices[i].x * vertices[j].y; area -= vertices[j].x * vertices[i].y; } return Math.abs(area) / 2; } export function criterionStats( scores: SpiderScore[], criterionId: string, optionId: string, ): { mean: number; stdDev: number; min: number; max: number; count: number } { const vals = scores .filter((s) => s.criterionId === criterionId && s.optionId === optionId) .map((s) => s.value); if (vals.length === 0) return { mean: 0, stdDev: 0, min: 0, max: 0, count: 0 }; const mean = vals.reduce((a, b) => a + b, 0) / vals.length; const variance = vals.reduce((sum, v) => sum + (v - mean) ** 2, 0) / vals.length; return { mean, stdDev: Math.sqrt(variance), min: Math.min(...vals), max: Math.max(...vals), count: vals.length, }; } export function consensusIndex( scores: SpiderScore[], criteria: SpiderCriterion[], optionId: string, ): number { if (criteria.length === 0) return 0; let totalStdDev = 0; let counted = 0; for (const c of criteria) { const stats = criterionStats(scores, c.id, optionId); if (stats.count > 0) { totalStdDev += stats.stdDev; counted++; } } if (counted === 0) return 0; const avgStdDev = totalStdDev / counted; return Math.max(0, 1 - avgStdDev / 4.5); } // -- Component -- declare global { interface HTMLElementTagNameMap { "folk-choice-spider": FolkChoiceSpider; } } const USER_COLORS = ["#7c5bf5", "#f59e0b", "#10b981", "#ef4444", "#06b6d4", "#ec4899", "#8b5cf6", "#f97316"]; function userColor(userId: string): string { let hash = 0; for (let i = 0; i < userId.length; i++) { hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0; } return USER_COLORS[Math.abs(hash) % USER_COLORS.length]; } export class FolkChoiceSpider extends FolkShape { static override tagName = "folk-choice-spider"; static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n"); const childRules = Array.from(styles.cssRules).map((r) => r.cssText).join("\n"); sheet.replaceSync(`${parentRules}\n${childRules}`); this.styles = sheet; } #title = "Score Options"; #options: SpiderOption[] = []; #criteria: SpiderCriterion[] = []; #scores: SpiderScore[] = []; #userId = ""; #userName = ""; #selectedOptionId = ""; #drawerOpen = false; #settingsOpen = false; // DOM refs #wrapperEl: HTMLElement | null = null; #bodyEl: HTMLElement | null = null; #chartArea: HTMLElement | null = null; #slidersEl: HTMLElement | null = null; #legendEl: HTMLElement | null = null; #summaryEl: HTMLElement | null = null; #optionTabsEl: HTMLElement | null = null; #drawerEl: HTMLElement | null = null; #settingsEl: HTMLElement | null = null; get title() { return this.#title; } set title(v: string) { this.#title = v; this.requestUpdate("title"); } get options() { return this.#options; } set options(v: SpiderOption[]) { this.#options = v; if (v.length > 0 && !v.some((o) => o.id === this.#selectedOptionId)) { this.#selectedOptionId = v[0].id; } this.#render(); this.requestUpdate("options"); } get criteria() { return this.#criteria; } set criteria(v: SpiderCriterion[]) { this.#criteria = v; this.#render(); this.requestUpdate("criteria"); } get scores() { return this.#scores; } set scores(v: SpiderScore[]) { this.#scores = v; this.#render(); this.requestUpdate("scores"); } #ensureIdentity(): boolean { if (this.#userId && this.#userName) return true; this.#userId = localStorage.getItem(USER_ID_KEY) || ""; this.#userName = localStorage.getItem(USER_NAME_KEY) || localStorage.getItem("rspace-username") || ""; if (!this.#userId) { this.#userId = crypto.randomUUID().slice(0, 8); localStorage.setItem(USER_ID_KEY, this.#userId); } return !!this.#userName; } #setUserName(name: string) { this.#userName = name; localStorage.setItem(USER_NAME_KEY, name); localStorage.setItem("rspace-username", name); } #setScore(criterionId: string, value: number) { if (!this.#ensureIdentity()) return; const optionId = this.#selectedOptionId; // Remove existing score for this user/option/criterion this.#scores = this.#scores.filter( (s) => !(s.userId === this.#userId && s.optionId === optionId && s.criterionId === criterionId), ); this.#scores.push({ userId: this.#userId, userName: this.#userName, optionId, criterionId, value, timestamp: Date.now(), }); this.#render(); this.dispatchEvent(new CustomEvent("content-change")); } override createRenderRoot() { const root = super.createRenderRoot(); this.#ensureIdentity(); if (this.#options.length > 0) this.#selectedOptionId = this.#options[0].id; const wrapper = document.createElement("div"); wrapper.className = "wrapper"; wrapper.innerHTML = html`
🕸 Spider
`; const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) containerDiv.replaceWith(wrapper); this.#wrapperEl = wrapper; this.#bodyEl = wrapper.querySelector(".body") as HTMLElement; this.#chartArea = wrapper.querySelector(".chart-area") as HTMLElement; this.#slidersEl = wrapper.querySelector(".sliders") as HTMLElement; this.#legendEl = wrapper.querySelector(".legend") as HTMLElement; this.#summaryEl = wrapper.querySelector(".score-summary") as HTMLElement; this.#optionTabsEl = wrapper.querySelector(".option-tabs") as HTMLElement; this.#drawerEl = wrapper.querySelector(".results-drawer") as HTMLElement; const drawerToggle = wrapper.querySelector(".drawer-toggle") as HTMLButtonElement; drawerToggle.addEventListener("click", (e) => { e.stopPropagation(); this.#drawerOpen = !this.#drawerOpen; this.#wrapperEl!.classList.toggle("drawer-open", this.#drawerOpen); drawerToggle.classList.toggle("active", this.#drawerOpen); if (this.#drawerOpen) this.#renderDrawer(); }); this.#settingsEl = wrapper.querySelector(".settings-panel") as HTMLElement; const settingsToggle = wrapper.querySelector(".settings-toggle") as HTMLButtonElement; settingsToggle.addEventListener("click", (e) => { e.stopPropagation(); this.#settingsOpen = !this.#settingsOpen; this.#wrapperEl!.classList.toggle("settings-open", this.#settingsOpen); settingsToggle.classList.toggle("active", this.#settingsOpen); if (this.#settingsOpen) this.#renderSettings(); else this.#render(); }); const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement; const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement; const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement; const addOptInput = wrapper.querySelector(".add-opt-input") as HTMLInputElement; const addOptBtn = wrapper.querySelector(".add-opt-btn") as HTMLButtonElement; const addCritInput = wrapper.querySelector(".add-crit-input") as HTMLInputElement; const addCritBtn = wrapper.querySelector(".add-crit-btn") as HTMLButtonElement; if (!this.#userName) { this.#bodyEl.style.display = "none"; usernamePrompt.style.display = "block"; } const submitName = () => { const name = usernameInput.value.trim(); if (name) { this.#setUserName(name); this.#bodyEl!.style.display = "flex"; usernamePrompt.style.display = "none"; this.#render(); } }; usernameBtn.addEventListener("click", (e) => { e.stopPropagation(); submitName(); }); usernameInput.addEventListener("click", (e) => e.stopPropagation()); usernameInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") submitName(); }); // Add option const addOpt = () => { const label = addOptInput.value.trim(); if (!label) return; const id = `opt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; this.#options.push({ id, label }); if (!this.#selectedOptionId) this.#selectedOptionId = id; addOptInput.value = ""; this.#render(); this.dispatchEvent(new CustomEvent("content-change")); }; addOptBtn.addEventListener("click", (e) => { e.stopPropagation(); addOpt(); }); addOptInput.addEventListener("click", (e) => e.stopPropagation()); addOptInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") addOpt(); }); // Add criterion const addCrit = () => { const label = addCritInput.value.trim(); if (!label) return; const id = `crit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; this.#criteria.push({ id, label, weight: 1 }); addCritInput.value = ""; this.#render(); this.dispatchEvent(new CustomEvent("content-change")); }; addCritBtn.addEventListener("click", (e) => { e.stopPropagation(); addCrit(); }); addCritInput.addEventListener("click", (e) => e.stopPropagation()); addCritInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") addCrit(); }); wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); this.#render(); return root; } #render() { this.#renderOptionTabs(); this.#renderChart(); this.#renderSliders(); this.#renderLegend(); this.#renderSummary(); if (this.#drawerOpen) this.#renderDrawer(); } #renderOptionTabs() { if (!this.#optionTabsEl) return; this.#optionTabsEl.innerHTML = this.#options .map((opt) => ``) .join(""); this.#optionTabsEl.querySelectorAll(".option-tab").forEach((tab) => { tab.addEventListener("click", (e) => { e.stopPropagation(); this.#selectedOptionId = (tab as HTMLElement).dataset.opt!; this.#render(); }); }); } #renderChart() { if (!this.#chartArea) return; const n = this.#criteria.length; if (n < 3) { this.#chartArea.innerHTML = '
Add at least 3 criteria
'; return; } const CX = 130; const CY = 120; const R = 90; const RINGS = 5; const angleStep = (2 * Math.PI) / n; const polar = (angle: number, r: number) => { const a = angle - Math.PI / 2; return { x: CX + r * Math.cos(a), y: CY + r * Math.sin(a) }; }; let svg = ``; // Grid rings for (let ring = 1; ring <= RINGS; ring++) { const r = (R / RINGS) * ring; const pts = Array.from({ length: n }, (_, i) => { const p = polar(i * angleStep, r); return `${p.x},${p.y}`; }).join(" "); svg += ``; } // Axis lines + labels for (let i = 0; i < n; i++) { const end = polar(i * angleStep, R); const lbl = polar(i * angleStep, R + 16); svg += ``; svg += `${this.#escapeHtml(this.#criteria[i].label)}`; } // Get unique users who scored the selected option const optId = this.#selectedOptionId; const userIds = [...new Set(this.#scores.filter((s) => s.optionId === optId).map((s) => s.userId))]; // Per-user polygons for (const uid of userIds) { const verts = getRadarVertices(this.#scores, this.#criteria, optId, uid, CX, CY, R); if (verts.length >= 3) { const pts = verts.map((v) => `${v.x},${v.y}`).join(" "); const color = userColor(uid); svg += ``; for (const v of verts) { svg += ``; } } } // Average polygon (dashed) if (userIds.length > 0) { const avgVerts = getAverageRadarVertices(this.#scores, this.#criteria, optId, CX, CY, R); if (avgVerts.length >= 3) { const pts = avgVerts.map((v) => `${v.x},${v.y}`).join(" "); svg += ``; } } svg += ``; this.#chartArea.innerHTML = svg; } #renderSliders() { if (!this.#slidersEl) return; const optId = this.#selectedOptionId; if (this.#criteria.length === 0) { this.#slidersEl.innerHTML = ""; return; } this.#slidersEl.innerHTML = this.#criteria .map((c) => { const myScore = this.#scores.find( (s) => s.userId === this.#userId && s.optionId === optId && s.criterionId === c.id, ); const val = myScore ? myScore.value : 5; return `
${this.#escapeHtml(c.label)} ${val}
`; }) .join(""); this.#slidersEl.querySelectorAll(".slider-input").forEach((slider) => { const input = slider as HTMLInputElement; input.addEventListener("click", (e) => e.stopPropagation()); input.addEventListener("pointerdown", (e) => e.stopPropagation()); input.addEventListener("input", (e) => { e.stopPropagation(); const critId = input.dataset.crit!; const val = parseInt(input.value); const valEl = input.parentElement!.querySelector(".slider-val") as HTMLElement; valEl.textContent = String(val); this.#setScore(critId, val); }); }); } #renderLegend() { if (!this.#legendEl) return; const optId = this.#selectedOptionId; const users = new Map(); for (const s of this.#scores) { if (s.optionId === optId) users.set(s.userId, s.userName); } this.#legendEl.innerHTML = [...users.entries()] .map(([uid, name]) => `${this.#escapeHtml(name)}`) .join(""); } #renderSummary() { if (!this.#summaryEl) return; if (this.#options.length === 0 || this.#criteria.length === 0) { this.#summaryEl.innerHTML = ""; return; } const results = this.#options.map((opt) => ({ label: opt.label, score: weightedMeanScore(this.#scores, this.#criteria, opt.id), })); results.sort((a, b) => b.score - a.score); const best = results[0]; if (best && best.score > 0) { const summary = results.map((r) => `${this.#escapeHtml(r.label)}: ${r.score.toFixed(1)}`).join(" | "); this.#summaryEl.innerHTML = `${this.#escapeHtml(best.label)} leads — ${summary}`; } else { this.#summaryEl.innerHTML = "Score options to see results"; } } #renderDrawer() { if (!this.#drawerEl) return; // Group results: weighted mean per option const results = this.#options.map((opt) => ({ id: opt.id, label: opt.label, score: weightedMeanScore(this.#scores, this.#criteria, opt.id), })); results.sort((a, b) => b.score - a.score); const maxScore = Math.max(1, ...results.map((r) => r.score)); let resultsHtml = '
Group Results
'; for (const r of results) { const pct = (r.score / maxScore) * 100; resultsHtml += `
${this.#escapeHtml(r.label)}
${r.score.toFixed(1)}
`; } if (results.length >= 2 && results[0].score > 0) { const margin = results[0].score - results[1].score; resultsHtml += `
Margin${margin.toFixed(1)}
`; } resultsHtml += "
"; // Statistics const allUsers = new Set(this.#scores.map((s) => s.userId)); let statsHtml = '
Statistics
'; statsHtml += `
Participants${allUsers.size}
`; for (const opt of this.#options) { const ci = consensusIndex(this.#scores, this.#criteria, opt.id); const optScorers = new Set(this.#scores.filter((s) => s.optionId === opt.id).map((s) => s.userId)); statsHtml += `
${this.#escapeHtml(opt.label)} consensus${(ci * 100).toFixed(0)}%
`; statsHtml += `
${this.#escapeHtml(opt.label)} scorers${optScorers.size}
`; } for (const c of this.#criteria) { const allVals: number[] = []; for (const opt of this.#options) { const stats = criterionStats(this.#scores, c.id, opt.id); if (stats.count > 0) allVals.push(stats.mean); } if (allVals.length > 0) { const avg = allVals.reduce((a, b) => a + b, 0) / allVals.length; statsHtml += `
${this.#escapeHtml(c.label)} avg${avg.toFixed(1)}
`; } } statsHtml += "
"; // Participants const userMap = new Map(); for (const s of this.#scores) { const u = userMap.get(s.userId) || { name: s.userName, optionsScored: 0, lastActive: 0 }; u.name = s.userName; u.lastActive = Math.max(u.lastActive, s.timestamp); userMap.set(s.userId, u); } for (const [uid, u] of userMap) { const opts = new Set(this.#scores.filter((s) => s.userId === uid).map((s) => s.optionId)); u.optionsScored = opts.size; } let participantsHtml = '
Participants
'; for (const [uid, u] of userMap) { const ago = this.#timeAgo(u.lastActive); participantsHtml += `
${this.#escapeHtml(u.name)} ${u.optionsScored} opt, ${ago}
`; } participantsHtml += "
"; this.#drawerEl.innerHTML = resultsHtml + statsHtml + participantsHtml; } #timeAgo(ts: number): string { const diff = Date.now() - ts; if (diff < 60000) return "just now"; if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; return `${Math.floor(diff / 86400000)}d ago`; } #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } #renderSettings() { if (!this.#settingsEl) return; const esc = (s: string) => this.#escapeHtml(s); let h = '
Title
'; h += `
`; h += '
Options
'; for (let i = 0; i < this.#options.length; i++) { const opt = this.#options[i]; h += `
`; h += ``; h += `
`; } h += '
'; h += '
Criteria & Weights
'; for (let i = 0; i < this.#criteria.length; i++) { const c = this.#criteria[i]; h += `
`; h += ``; h += ``; h += `${c.weight}`; h += `
`; } h += '
'; const allUsers = new Set(this.#scores.map((s) => s.userId)); h += '
Danger Zone
'; h += `
`; h += ''; this.#settingsEl.innerHTML = h; const stop = (e: Event) => e.stopPropagation(); const titleInput = this.#settingsEl.querySelector(".settings-title") as HTMLInputElement; titleInput.addEventListener("click", stop); titleInput.addEventListener("input", () => { this.#title = titleInput.value; this.#wrapperEl!.querySelector(".title-text")!.textContent = this.#title; this.dispatchEvent(new CustomEvent("content-change")); }); this.#settingsEl.querySelectorAll(".opt-label").forEach((el) => { const input = el as HTMLInputElement; input.addEventListener("click", stop); input.addEventListener("input", () => { const idx = parseInt(input.closest(".settings-item")!.getAttribute("data-idx")!); this.#options[idx].label = input.value; this.dispatchEvent(new CustomEvent("content-change")); }); }); this.#settingsEl.querySelectorAll(".crit-label").forEach((el) => { const input = el as HTMLInputElement; input.addEventListener("click", stop); input.addEventListener("input", () => { const idx = parseInt(input.closest(".settings-item")!.getAttribute("data-idx")!); this.#criteria[idx].label = input.value; this.dispatchEvent(new CustomEvent("content-change")); }); }); this.#settingsEl.querySelectorAll(".crit-weight").forEach((el) => { const input = el as HTMLInputElement; input.addEventListener("click", stop); input.addEventListener("pointerdown", stop); input.addEventListener("input", () => { const idx = parseInt(input.closest(".settings-item")!.getAttribute("data-idx")!); this.#criteria[idx].weight = parseInt(input.value); const valEl = input.parentElement!.querySelector(".weight-val") as HTMLElement; valEl.textContent = input.value; this.dispatchEvent(new CustomEvent("content-change")); }); }); this.#settingsEl.querySelectorAll(".remove-btn").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); const item = (btn as HTMLElement).closest(".settings-item")!; const idx = parseInt(item.getAttribute("data-idx")!); const kind = item.getAttribute("data-kind"); if (kind === "opt") { const removedId = this.#options[idx].id; this.#options.splice(idx, 1); this.#scores = this.#scores.filter((s) => s.optionId !== removedId); if (this.#selectedOptionId === removedId) { this.#selectedOptionId = this.#options[0]?.id || ""; } } else { const removedId = this.#criteria[idx].id; this.#criteria.splice(idx, 1); this.#scores = this.#scores.filter((s) => s.criterionId !== removedId); } this.#renderSettings(); this.dispatchEvent(new CustomEvent("content-change")); }); }); this.#settingsEl.querySelector(".clear-data-btn")!.addEventListener("click", (e) => { e.stopPropagation(); this.#scores = []; this.#renderSettings(); this.dispatchEvent(new CustomEvent("content-change")); }); this.#settingsEl.querySelector(".settings-done")!.addEventListener("click", (e) => { e.stopPropagation(); this.#settingsOpen = false; this.#wrapperEl!.classList.remove("settings-open"); this.#wrapperEl!.querySelector(".settings-toggle")!.classList.remove("active"); this.#render(); }); } override toJSON() { return { ...super.toJSON(), type: "folk-choice-spider", title: this.#title, options: this.#options, criteria: this.#criteria, scores: this.#scores, }; } }