/** * — lists choice shapes (polls, rankings, spider charts) * from the current space and links to the canvas to create/interact with them. */ class FolkChoicesDashboard extends HTMLElement { private shadow: ShadowRoot; private choices: any[] = []; private loading = true; private space: string; /* Demo state */ private demoTab: "spider" | "ranking" | "voting" = "spider"; private hoveredPerson: string | null = null; private rankItems: { id: number; name: string; emoji: string }[] = []; private rankDragging: number | null = null; private voteOptions: { id: string; name: string; color: string; votes: number }[] = []; private voted = false; private votedId: string | null = null; private simTimer: number | null = null; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); this.space = this.getAttribute("space") || "demo"; } connectedCallback() { if (this.space === "demo") { this.loadDemoData(); return; } this.render(); this.loadChoices(); } disconnectedCallback() { if (this.simTimer !== null) { clearInterval(this.simTimer); this.simTimer = null; } } private getApiBase(): string { const path = window.location.pathname; const parts = path.split("/").filter(Boolean); return parts.length >= 2 ? `/${parts[0]}/choices` : "/demo/choices"; } private async loadChoices() { this.loading = true; this.render(); try { const res = await fetch(`${this.getApiBase()}/api/choices`); const data = await res.json(); this.choices = data.choices || []; } catch (e) { console.error("Failed to load choices:", e); } this.loading = false; this.render(); } private render() { const typeIcons: Record = { "folk-choice-vote": "☑", "folk-choice-rank": "📊", "folk-choice-spider": "🕸", }; const typeLabels: Record = { "folk-choice-vote": "Poll", "folk-choice-rank": "Ranking", "folk-choice-spider": "Spider Chart", }; this.shadow.innerHTML = `
Choice tools (Polls, Rankings, Spider Charts) live on the collaborative canvas. Create them there and they'll appear here for quick access.
${this.loading ? `
⏳ Loading choices...
` : this.choices.length === 0 ? this.renderEmpty() : this.renderGrid(typeIcons, typeLabels)} `; } private renderEmpty(): string { return `

No choices in this space yet.

Open the canvas and use the Poll, Rank, or Spider buttons to create one.

`; } private renderGrid(icons: Record, labels: Record): string { return ``; } /* ===== Demo mode ===== */ private loadDemoData() { this.rankItems = [ { id: 1, name: "Thai Place", emoji: "🍜" }, { id: 2, name: "Pizza", emoji: "🍕" }, { id: 3, name: "Sushi Bar", emoji: "🍣" }, { id: 4, name: "Tacos", emoji: "🌮" }, { id: 5, name: "Burgers", emoji: "🍔" }, ]; this.voteOptions = [ { id: "action", name: "Action Movie", color: "#ef4444", votes: 2 }, { id: "comedy", name: "Comedy", color: "#f59e0b", votes: 3 }, { id: "horror", name: "Horror", color: "#8b5cf6", votes: 1 }, { id: "scifi", name: "Sci-Fi", color: "#06b6d4", votes: 2 }, ]; this.voted = false; this.votedId = null; this.startVoteSim(); this.renderDemo(); } private startVoteSim() { if (this.simTimer !== null) clearInterval(this.simTimer); const tick = () => { if (this.voted) return; const idx = Math.floor(Math.random() * this.voteOptions.length); this.voteOptions[idx].votes += 1; if (this.demoTab === "voting") this.renderDemo(); }; const scheduleNext = () => { const delay = 1200 + Math.random() * 2000; this.simTimer = window.setTimeout(() => { tick(); scheduleNext(); }, delay) as unknown as number; }; scheduleNext(); } private renderDemo() { const tabs: { key: "spider" | "ranking" | "voting"; label: string; icon: string }[] = [ { key: "spider", label: "Spider Chart", icon: "🕸" }, { key: "ranking", label: "Ranking", icon: "📊" }, { key: "voting", label: "Live Voting", icon: "☑" }, ]; let content = ""; if (this.demoTab === "spider") content = this.renderSpider(); else if (this.demoTab === "ranking") content = this.renderRanking(); else content = this.renderVoting(); this.shadow.innerHTML = `
Choices DEMO
${tabs.map((t) => ``).join("")}
${content}
`; this.bindDemoEvents(); } /* -- Spider Chart -- */ private polarToXY(cx: number, cy: number, radius: number, angleDeg: number): { x: number; y: number } { const rad = ((angleDeg - 90) * Math.PI) / 180; return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) }; } private renderSpider(): string { const cx = 200, cy = 200, maxR = 150; const axes = ["Taste", "Price", "Speed", "Healthy", "Distance"]; const people: { name: string; color: string; values: number[] }[] = [ { name: "Alice", color: "#7c5bf5", values: [0.9, 0.6, 0.8, 0.4, 0.7] }, { name: "Bob", color: "#f59e0b", values: [0.5, 0.9, 0.6, 0.7, 0.8] }, { name: "Carol", color: "#10b981", values: [0.7, 0.4, 0.9, 0.8, 0.3] }, ]; const angleStep = 360 / axes.length; // Grid rings let gridLines = ""; for (let ring = 1; ring <= 5; ring++) { const r = (ring / 5) * maxR; const pts = axes.map((_, i) => { const p = this.polarToXY(cx, cy, r, i * angleStep); return `${p.x},${p.y}`; }).join(" "); gridLines += ``; } // Axis lines + labels let axisLines = ""; const labelOffset = 18; axes.forEach((label, i) => { const angle = i * angleStep; const tip = this.polarToXY(cx, cy, maxR, angle); axisLines += ``; const lp = this.polarToXY(cx, cy, maxR + labelOffset, angle); axisLines += `${this.esc(label)}`; }); // Data polygons let polygons = ""; people.forEach((person) => { const dimmed = this.hoveredPerson !== null && this.hoveredPerson !== person.name; const opacity = dimmed ? 0.12 : 0.25; const strokeOpacity = dimmed ? 0.2 : 1; const strokeWidth = dimmed ? 1 : 2; const pts = person.values.map((v, i) => { const p = this.polarToXY(cx, cy, v * maxR, i * angleStep); return `${p.x},${p.y}`; }).join(" "); polygons += ``; // Dots at each vertex person.values.forEach((v, i) => { const p = this.polarToXY(cx, cy, v * maxR, i * angleStep); const dotOpacity = dimmed ? 0.2 : 1; polygons += ``; }); }); const legend = people.map((p) => `
${this.esc(p.name)}
` ).join(""); return `
${gridLines} ${axisLines} ${polygons}
${legend}
`; } /* -- Ranking -- */ private renderRanking(): string { const medalClass = (i: number) => i === 0 ? "gold" : i === 1 ? "silver" : i === 2 ? "bronze" : "plain"; const items = this.rankItems.map((item, i) => `
  • ${i + 1} ${item.emoji} ${this.esc(item.name)}
  • ` ).join(""); return `
      ${items}
    `; } /* -- Live Voting -- */ private renderVoting(): string { const sorted = [...this.voteOptions].sort((a, b) => b.votes - a.votes); const total = sorted.reduce((s, o) => s + o.votes, 0); const maxVotes = Math.max(...sorted.map((o) => o.votes), 1); const items = sorted.map((opt) => { const pct = total > 0 ? (opt.votes / total) * 100 : 0; const isLeader = opt.votes === maxVotes && total > 4; return `
    ${this.esc(opt.name)}${isLeader ? `leading` : ""} ${opt.votes} ${pct.toFixed(0)}%
    `; }).join(""); const status = this.voted ? "Results are in!" : "Pick a movie \u2014 votes update live"; return `
    ${status}
    ${items} ${this.voted ? `
    ` : ""}
    `; } /* -- Demo event binding -- */ private bindDemoEvents() { // Tab switching this.shadow.querySelectorAll(".demo-tab").forEach((btn) => { btn.addEventListener("click", () => { const tab = btn.dataset.tab as "spider" | "ranking" | "voting"; if (tab && tab !== this.demoTab) { this.demoTab = tab; this.renderDemo(); } }); }); // Spider legend hover this.shadow.querySelectorAll(".spider-legend-item").forEach((el) => { el.addEventListener("mouseenter", () => { this.hoveredPerson = el.dataset.person || null; this.renderDemo(); }); el.addEventListener("mouseleave", () => { this.hoveredPerson = null; this.renderDemo(); }); }); // Ranking drag-and-drop const rankList = this.shadow.querySelector(".rank-list"); if (rankList) { const items = rankList.querySelectorAll(".rank-item"); items.forEach((li) => { li.addEventListener("dragstart", (e: DragEvent) => { const id = parseInt(li.dataset.rankId || "0", 10); this.rankDragging = id; li.classList.add("dragging"); if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(id)); } }); li.addEventListener("dragover", (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; li.classList.add("drag-over"); }); li.addEventListener("dragleave", () => { li.classList.remove("drag-over"); }); li.addEventListener("drop", (e: DragEvent) => { e.preventDefault(); li.classList.remove("drag-over"); const targetId = parseInt(li.dataset.rankId || "0", 10); if (this.rankDragging !== null && this.rankDragging !== targetId) { const fromIdx = this.rankItems.findIndex((r) => r.id === this.rankDragging); const toIdx = this.rankItems.findIndex((r) => r.id === targetId); if (fromIdx !== -1 && toIdx !== -1) { const [moved] = this.rankItems.splice(fromIdx, 1); this.rankItems.splice(toIdx, 0, moved); this.renderDemo(); } } }); li.addEventListener("dragend", () => { this.rankDragging = null; li.classList.remove("dragging"); }); }); } // Voting click this.shadow.querySelectorAll(".vote-option").forEach((el) => { el.addEventListener("click", () => { if (this.voted) return; const id = el.dataset.voteId || ""; const opt = this.voteOptions.find((o) => o.id === id); if (opt) { opt.votes += 1; this.voted = true; this.votedId = id; this.renderDemo(); } }); }); // Vote reset const resetBtn = this.shadow.querySelector(".vote-reset"); if (resetBtn) { resetBtn.addEventListener("click", () => { this.voteOptions = [ { id: "action", name: "Action Movie", color: "#ef4444", votes: 2 }, { id: "comedy", name: "Comedy", color: "#f59e0b", votes: 3 }, { id: "horror", name: "Horror", color: "#8b5cf6", votes: 1 }, { id: "scifi", name: "Sci-Fi", color: "#06b6d4", votes: 2 }, ]; this.voted = false; this.votedId = null; this.startVoteSim(); this.renderDemo(); }); } } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; } } customElements.define("folk-choices-dashboard", FolkChoicesDashboard);