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; } `; // -- 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; } // -- 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 = ""; // DOM refs #bodyEl: HTMLElement | null = null; #chartArea: HTMLElement | null = null; #slidersEl: HTMLElement | null = null; #legendEl: HTMLElement | null = null; #summaryEl: HTMLElement | null = null; #optionTabsEl: 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.innerHTML = html`
🕸 Spider
`; const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) containerDiv.replaceWith(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; 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(); } #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"; } } #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } override toJSON() { return { ...super.toJSON(), type: "folk-choice-spider", title: this.#title, options: this.#options, criteria: this.#criteria, scores: this.#scores, }; } }