diff --git a/lib/community-sync.ts b/lib/community-sync.ts index db80ec0..0f8f155 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -533,6 +533,33 @@ export class CommunitySync extends EventTarget { if (data.mintId !== undefined && ledger.mintId !== data.mintId) ledger.mintId = data.mintId; if (data.entries !== undefined) ledger.entries = data.entries; } + + // Update choice-vote properties + if (data.type === "folk-choice-vote") { + const vote = shape as any; + if (data.title !== undefined && vote.title !== data.title) vote.title = data.title; + if (data.options !== undefined) vote.options = data.options; + if (data.mode !== undefined && vote.mode !== data.mode) vote.mode = data.mode; + if (data.budget !== undefined && vote.budget !== data.budget) vote.budget = data.budget; + if (data.votes !== undefined) vote.votes = data.votes; + } + + // Update choice-rank properties + if (data.type === "folk-choice-rank") { + const rank = shape as any; + if (data.title !== undefined && rank.title !== data.title) rank.title = data.title; + if (data.options !== undefined) rank.options = data.options; + if (data.rankings !== undefined) rank.rankings = data.rankings; + } + + // Update choice-spider properties + if (data.type === "folk-choice-spider") { + const spider = shape as any; + if (data.title !== undefined && spider.title !== data.title) spider.title = data.title; + if (data.options !== undefined) spider.options = data.options; + if (data.criteria !== undefined) spider.criteria = data.criteria; + if (data.scores !== undefined) spider.scores = data.scores; + } } /** diff --git a/lib/folk-choice-rank.ts b/lib/folk-choice-rank.ts new file mode 100644 index 0000000..3da1d96 --- /dev/null +++ b/lib/folk-choice-rank.ts @@ -0,0 +1,774 @@ +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: 340px; + min-height: 380px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #4f46e5; + 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; + } + + .tab-bar { + display: flex; + border-bottom: 1px solid #e2e8f0; + } + + .tab { + flex: 1; + padding: 6px 8px; + text-align: center; + font-size: 11px; + font-weight: 500; + cursor: pointer; + border: none; + background: transparent; + color: #64748b; + transition: all 0.15s; + } + + .tab.active { + color: #4f46e5; + border-bottom: 2px solid #4f46e5; + background: #eef2ff; + } + + .panel { + flex: 1; + overflow-y: auto; + padding: 8px 12px; + } + + .panel.hidden { display: none; } + + /* Rank list */ + .rank-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border-radius: 6px; + margin-bottom: 4px; + background: #f8fafc; + border: 1px solid #e2e8f0; + cursor: grab; + user-select: none; + transition: transform 0.15s, box-shadow 0.15s; + } + + .rank-item:active { cursor: grabbing; } + + .rank-item.dragging { + opacity: 0.5; + background: #eef2ff; + } + + .rank-item.drag-over { + border-color: #4f46e5; + transform: scale(1.02); + box-shadow: 0 2px 8px rgba(79, 70, 229, 0.15); + } + + .grip { + color: #94a3b8; + font-size: 10px; + line-height: 1; + flex-shrink: 0; + } + + .rank-num { + font-size: 13px; + font-weight: 700; + min-width: 18px; + text-align: center; + flex-shrink: 0; + } + + .rank-label { + flex: 1; + font-size: 13px; + color: #1e293b; + } + + /* Results */ + .results-section { + margin-bottom: 12px; + } + + .results-heading { + font-size: 11px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; + } + + .borda-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; + } + + .borda-label { + font-size: 12px; + min-width: 80px; + color: #1e293b; + } + + .borda-bar-bg { + flex: 1; + height: 14px; + background: #f1f5f9; + border-radius: 3px; + overflow: hidden; + } + + .borda-bar-fill { + height: 100%; + background: #4f46e5; + border-radius: 3px; + transition: width 0.4s ease; + } + + .borda-pts { + font-size: 11px; + font-weight: 600; + color: #4f46e5; + min-width: 24px; + text-align: right; + font-variant-numeric: tabular-nums; + } + + .irv-round { + font-size: 11px; + padding: 4px 0; + border-bottom: 1px solid #f1f5f9; + color: #475569; + } + + .irv-round .round-num { + font-weight: 600; + color: #4f46e5; + } + + .irv-round .eliminated { + color: #ef4444; + font-weight: 500; + } + + .irv-round .winner { + color: #22c55e; + font-weight: 600; + } + + .voters-count { + padding: 4px 0; + font-size: 11px; + color: #94a3b8; + text-align: center; + } + + .add-form { + display: flex; + gap: 6px; + padding: 8px 12px; + border-top: 1px solid #e2e8f0; + } + + .add-input { + flex: 1; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 6px 10px; + font-size: 12px; + outline: none; + } + + .add-input:focus { border-color: #4f46e5; } + + .add-btn { + background: #4f46e5; + color: white; + border: none; + border-radius: 6px; + padding: 6px 12px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + } + + .add-btn:hover { background: #4338ca; } + + .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: #4f46e5; + 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 RankOption { + id: string; + label: string; +} + +export interface UserRanking { + userId: string; + userName: string; + ordering: string[]; + timestamp: number; +} + +export interface IRVRound { + round: number; + counts: Record; + eliminated: string | null; +} + +// -- Pure aggregation functions -- + +export function bordaCount( + rankings: UserRanking[], + options: RankOption[], +): Map { + const n = options.length; + const scores = new Map(); + for (const opt of options) scores.set(opt.id, 0); + for (const r of rankings) { + for (let i = 0; i < r.ordering.length; i++) { + const points = n - 1 - i; + const optId = r.ordering[i]; + if (scores.has(optId)) { + scores.set(optId, scores.get(optId)! + points); + } + } + } + return scores; +} + +export function instantRunoff( + rankings: UserRanking[], + options: RankOption[], +): IRVRound[] { + if (rankings.length === 0 || options.length === 0) return []; + + const rounds: IRVRound[] = []; + const remaining = new Set(options.map((o) => o.id)); + let round = 1; + + while (remaining.size > 1) { + // Count first-place votes among remaining options + const counts: Record = {}; + for (const id of remaining) counts[id] = 0; + + for (const r of rankings) { + const top = r.ordering.find((id) => remaining.has(id)); + if (top) counts[top]++; + } + + const totalVotes = rankings.length; + + // Check for majority + let majorityWinner: string | null = null; + for (const [id, count] of Object.entries(counts)) { + if (count > totalVotes / 2) { + majorityWinner = id; + break; + } + } + + if (majorityWinner) { + rounds.push({ round, counts, eliminated: null }); + break; + } + + // Find minimum vote count + let minCount = Infinity; + for (const count of Object.values(counts)) { + if (count < minCount) minCount = count; + } + + // Eliminate first option with minimum count + let eliminated: string | null = null; + for (const [id, count] of Object.entries(counts)) { + if (count === minCount) { + eliminated = id; + break; + } + } + + rounds.push({ round, counts, eliminated }); + if (eliminated) remaining.delete(eliminated); + round++; + } + + // If eliminated down to 1 + if (remaining.size === 1 && (rounds.length === 0 || rounds[rounds.length - 1].eliminated !== null)) { + const lastId = [...remaining][0]; + rounds.push({ round, counts: { [lastId]: rankings.length }, eliminated: null }); + } + + return rounds; +} + +// -- Component -- + +declare global { + interface HTMLElementTagNameMap { + "folk-choice-rank": FolkChoiceRank; + } +} + +const MEDAL_COLORS = ["#f59e0b", "#94a3b8", "#cd7f32", "#64748b", "#64748b"]; + +export class FolkChoiceRank extends FolkShape { + static override tagName = "folk-choice-rank"; + + 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 = "Rank Choices"; + #options: RankOption[] = []; + #rankings: UserRanking[] = []; + #userId = ""; + #userName = ""; + #activeTab: "rank" | "results" = "rank"; + + // Drag state + #dragIdx: number | null = null; + #myOrdering: string[] = []; + + // DOM refs + #bodyEl: HTMLElement | null = null; + #rankPanel: HTMLElement | null = null; + #resultsPanel: 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: RankOption[]) { + this.#options = v; + this.#syncMyOrdering(); + this.#render(); + this.requestUpdate("options"); + } + + get rankings() { return this.#rankings; } + set rankings(v: UserRanking[]) { + this.#rankings = v; + this.#syncMyOrdering(); + this.#render(); + this.requestUpdate("rankings"); + } + + #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); + } + + #syncMyOrdering() { + const mine = this.#rankings.find((r) => r.userId === this.#userId); + if (mine) { + // Keep existing ordering, add new options at end + const existing = new Set(mine.ordering); + const extra = this.#options.filter((o) => !existing.has(o.id)).map((o) => o.id); + this.#myOrdering = [...mine.ordering.filter((id) => this.#options.some((o) => o.id === id)), ...extra]; + } else { + this.#myOrdering = this.#options.map((o) => o.id); + } + } + + #saveMyRanking() { + if (!this.#ensureIdentity()) return; + const idx = this.#rankings.findIndex((r) => r.userId === this.#userId); + const entry: UserRanking = { + userId: this.#userId, + userName: this.#userName, + ordering: [...this.#myOrdering], + timestamp: Date.now(), + }; + if (idx >= 0) { + this.#rankings[idx] = entry; + } else { + this.#rankings.push(entry); + } + this.dispatchEvent(new CustomEvent("content-change")); + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + this.#ensureIdentity(); + this.#syncMyOrdering(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + 📊 + Rank + +
+ +
+
+
+
+ + +
+
+ +
+ + +
+
+ + `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) containerDiv.replaceWith(wrapper); + + this.#bodyEl = wrapper.querySelector(".body") as HTMLElement; + this.#rankPanel = wrapper.querySelector(".rank-panel") as HTMLElement; + this.#resultsPanel = wrapper.querySelector(".results-panel") 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 addInput = wrapper.querySelector(".add-input") as HTMLInputElement; + const addBtn = wrapper.querySelector(".add-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.#saveMyRanking(); + 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(); }); + + // Tabs + wrapper.querySelectorAll(".tab").forEach((tab) => { + tab.addEventListener("click", (e) => { + e.stopPropagation(); + const t = (tab as HTMLElement).dataset.tab as "rank" | "results"; + this.#activeTab = t; + wrapper.querySelectorAll(".tab").forEach((tb) => tb.classList.remove("active")); + tab.classList.add("active"); + this.#rankPanel!.classList.toggle("hidden", t !== "rank"); + this.#resultsPanel!.classList.toggle("hidden", t !== "results"); + this.#render(); + }); + }); + + // Add option + const addOption = () => { + const label = addInput.value.trim(); + if (!label) return; + const id = `opt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + this.#options.push({ id, label }); + this.#myOrdering.push(id); + addInput.value = ""; + this.#saveMyRanking(); + this.#render(); + }; + addBtn.addEventListener("click", (e) => { e.stopPropagation(); addOption(); }); + addInput.addEventListener("click", (e) => e.stopPropagation()); + addInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") addOption(); }); + + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + this.#render(); + return root; + } + + #render() { + if (this.#activeTab === "rank") this.#renderRankList(); + else this.#renderResults(); + } + + #renderRankList() { + if (!this.#rankPanel) return; + + if (this.#myOrdering.length === 0) { + this.#rankPanel.innerHTML = '
Add options below to start ranking
'; + return; + } + + const optMap = new Map(this.#options.map((o) => [o.id, o])); + + this.#rankPanel.innerHTML = this.#myOrdering + .map((id, i) => { + const opt = optMap.get(id); + if (!opt) return ""; + const color = MEDAL_COLORS[Math.min(i, MEDAL_COLORS.length - 1)]; + return ` +
+ ≡≡ + ${i + 1} + ${this.#escapeHtml(opt.label)} +
+ `; + }) + .join(""); + + // Wire pointer-based drag + this.#rankPanel.querySelectorAll(".rank-item").forEach((item) => { + const el = item as HTMLElement; + el.addEventListener("pointerdown", (e) => { + e.stopPropagation(); + this.#dragIdx = parseInt(el.dataset.idx!); + el.classList.add("dragging"); + el.setPointerCapture(e.pointerId); + + const onMove = (me: PointerEvent) => { + me.stopPropagation(); + const target = this.#rankPanel!.querySelector( + `.rank-item:not(.dragging):hover` + ) as HTMLElement; + // Clear previous drag-over states + this.#rankPanel!.querySelectorAll(".drag-over").forEach((d) => d.classList.remove("drag-over")); + + if (target) target.classList.add("drag-over"); + }; + + const onUp = (ue: PointerEvent) => { + ue.stopPropagation(); + el.classList.remove("dragging"); + el.releasePointerCapture(ue.pointerId); + el.removeEventListener("pointermove", onMove); + el.removeEventListener("pointerup", onUp); + + // Find drop target + const overEl = this.#rankPanel!.querySelector(".drag-over") as HTMLElement; + if (overEl && this.#dragIdx !== null) { + const targetIdx = parseInt(overEl.dataset.idx!); + if (targetIdx !== this.#dragIdx) { + const [moved] = this.#myOrdering.splice(this.#dragIdx, 1); + this.#myOrdering.splice(targetIdx, 0, moved); + this.#saveMyRanking(); + } + } + + this.#rankPanel!.querySelectorAll(".drag-over").forEach((d) => d.classList.remove("drag-over")); + this.#dragIdx = null; + this.#renderRankList(); + }; + + el.addEventListener("pointermove", onMove); + el.addEventListener("pointerup", onUp); + }); + + // Keyboard reorder + el.tabIndex = 0; + el.addEventListener("keydown", (e) => { + e.stopPropagation(); + const idx = parseInt(el.dataset.idx!); + if (e.key === "ArrowUp" && idx > 0) { + [this.#myOrdering[idx - 1], this.#myOrdering[idx]] = [this.#myOrdering[idx], this.#myOrdering[idx - 1]]; + this.#saveMyRanking(); + this.#renderRankList(); + (this.#rankPanel!.querySelector(`[data-idx="${idx - 1}"]`) as HTMLElement)?.focus(); + } else if (e.key === "ArrowDown" && idx < this.#myOrdering.length - 1) { + [this.#myOrdering[idx], this.#myOrdering[idx + 1]] = [this.#myOrdering[idx + 1], this.#myOrdering[idx]]; + this.#saveMyRanking(); + this.#renderRankList(); + (this.#rankPanel!.querySelector(`[data-idx="${idx + 1}"]`) as HTMLElement)?.focus(); + } + }); + }); + } + + #renderResults() { + if (!this.#resultsPanel) return; + + const uniqueVoters = new Set(this.#rankings.map((r) => r.userId)).size; + + if (this.#rankings.length === 0) { + this.#resultsPanel.innerHTML = '
No rankings submitted yet
'; + return; + } + + const optMap = new Map(this.#options.map((o) => [o.id, o])); + + // Borda count + const borda = bordaCount(this.#rankings, this.#options); + const maxBorda = Math.max(1, ...borda.values()); + const bordaSorted = [...borda.entries()].sort((a, b) => b[1] - a[1]); + + let bordaHtml = '
Borda Count
'; + for (const [optId, pts] of bordaSorted) { + const opt = optMap.get(optId); + if (!opt) continue; + const pct = (pts / maxBorda) * 100; + bordaHtml += ` +
+ ${this.#escapeHtml(opt.label)} +
+ ${pts} +
+ `; + } + bordaHtml += "
"; + + // IRV + const irvRounds = instantRunoff(this.#rankings, this.#options); + let irvHtml = '
Instant-Runoff
'; + for (const r of irvRounds) { + const counts = Object.entries(r.counts) + .map(([id, c]) => `${optMap.get(id)?.label || id}: ${c}`) + .join(", "); + if (r.eliminated) { + const elimLabel = optMap.get(r.eliminated)?.label || r.eliminated; + irvHtml += `
Round ${r.round}: ${counts} — ${this.#escapeHtml(elimLabel)} eliminated
`; + } else { + // Winner round + const winnerId = Object.keys(r.counts)[0]; + const winLabel = optMap.get(winnerId)?.label || winnerId; + irvHtml += `
Round ${r.round}: ${counts} — ${this.#escapeHtml(winLabel)} wins!
`; + } + } + irvHtml += "
"; + + this.#resultsPanel.innerHTML = bordaHtml + irvHtml + + `
${uniqueVoters} voter${uniqueVoters !== 1 ? "s" : ""}
`; + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-choice-rank", + title: this.#title, + options: this.#options, + rankings: this.#rankings, + }; + } +} diff --git a/lib/folk-choice-spider.ts b/lib/folk-choice-spider.ts new file mode 100644 index 0000000..8142e75 --- /dev/null +++ b/lib/folk-choice-spider.ts @@ -0,0 +1,790 @@ +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, + }; + } +} diff --git a/lib/folk-choice-vote.ts b/lib/folk-choice-vote.ts new file mode 100644 index 0000000..d695944 --- /dev/null +++ b/lib/folk-choice-vote.ts @@ -0,0 +1,652 @@ +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: 320px; + min-height: 300px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #0d9488; + 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; + } + + .mode-tabs { + display: flex; + border-bottom: 1px solid #e2e8f0; + } + + .mode-tab { + flex: 1; + padding: 6px 8px; + text-align: center; + font-size: 11px; + font-weight: 500; + cursor: pointer; + border: none; + background: transparent; + color: #64748b; + transition: all 0.15s; + } + + .mode-tab.active { + color: #0d9488; + border-bottom: 2px solid #0d9488; + background: #f0fdfa; + } + + .options-list { + flex: 1; + overflow-y: auto; + padding: 8px 12px; + } + + .option-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border-radius: 6px; + margin-bottom: 6px; + cursor: pointer; + transition: background 0.15s; + position: relative; + overflow: hidden; + } + + .option-row:hover { + background: #f8fafc; + } + + .option-row.voted { + background: #f0fdfa; + } + + .bar-bg { + position: absolute; + left: 0; + top: 0; + bottom: 0; + border-radius: 6px; + opacity: 0.12; + transition: width 0.4s ease; + } + + .option-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + z-index: 1; + } + + .option-label { + flex: 1; + font-size: 13px; + z-index: 1; + color: #1e293b; + } + + .option-count { + font-size: 12px; + color: #64748b; + font-variant-numeric: tabular-nums; + z-index: 1; + min-width: 20px; + text-align: right; + } + + .option-pct { + font-size: 11px; + font-weight: 600; + font-variant-numeric: tabular-nums; + z-index: 1; + min-width: 32px; + text-align: right; + } + + .qv-controls { + display: flex; + align-items: center; + gap: 4px; + z-index: 1; + } + + .qv-btn { + width: 22px; + height: 22px; + border: 1px solid #cbd5e1; + border-radius: 4px; + background: white; + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + color: #475569; + } + + .qv-btn:hover { background: #f1f5f9; } + .qv-btn:disabled { opacity: 0.3; cursor: default; } + + .qv-count { + font-size: 12px; + font-variant-numeric: tabular-nums; + min-width: 14px; + text-align: center; + color: #1e293b; + } + + .budget-bar { + padding: 4px 12px; + font-size: 11px; + color: #64748b; + border-top: 1px solid #e2e8f0; + text-align: center; + } + + .budget-bar .used { + font-weight: 600; + color: #0d9488; + } + + .voters-count { + padding: 4px 12px; + font-size: 11px; + color: #94a3b8; + text-align: center; + } + + .add-form { + display: flex; + gap: 6px; + padding: 8px 12px; + border-top: 1px solid #e2e8f0; + } + + .add-input { + flex: 1; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 6px 10px; + font-size: 12px; + outline: none; + } + + .add-input:focus { border-color: #0d9488; } + + .add-btn { + background: #0d9488; + color: white; + border: none; + border-radius: 6px; + padding: 6px 12px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + } + + .add-btn:hover { background: #0f766e; } + + .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: #0d9488; + color: white; + border: none; + border-radius: 6px; + padding: 8px 16px; + cursor: pointer; + font-weight: 500; + font-size: 13px; + } +`; + +// -- Data types -- + +export interface VoteOption { + id: string; + label: string; + color: string; +} + +export interface UserVote { + userId: string; + userName: string; + allocations: Record; + timestamp: number; +} + +export type VoteMode = "plurality" | "approval" | "quadratic"; + +// -- Pure aggregation functions -- + +export function tallyVotes( + votes: UserVote[], + options: VoteOption[], +): Map { + const tally = new Map(); + for (const opt of options) tally.set(opt.id, 0); + for (const v of votes) { + for (const [optId, count] of Object.entries(v.allocations)) { + if (tally.has(optId)) { + tally.set(optId, tally.get(optId)! + count); + } + } + } + return tally; +} + +export function quadraticCost(allocations: Record): number { + let total = 0; + for (const k of Object.values(allocations)) { + total += k * k; + } + return total; +} + +// -- Component -- + +declare global { + interface HTMLElementTagNameMap { + "folk-choice-vote": FolkChoiceVote; + } +} + +const DEFAULT_COLORS = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"]; + +export class FolkChoiceVote extends FolkShape { + static override tagName = "folk-choice-vote"; + + 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 = "Quick Poll"; + #options: VoteOption[] = []; + #mode: VoteMode = "plurality"; + #budget = 100; + #votes: UserVote[] = []; + #userId = ""; + #userName = ""; + + // DOM refs + #bodyEl: HTMLElement | null = null; + #optionsEl: HTMLElement | null = null; + #budgetEl: HTMLElement | null = null; + #votersEl: 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: VoteOption[]) { + this.#options = v; + this.#render(); + this.requestUpdate("options"); + } + + get mode() { return this.#mode; } + set mode(v: VoteMode) { + this.#mode = v; + this.#render(); + this.requestUpdate("mode"); + } + + get budget() { return this.#budget; } + set budget(v: number) { + this.#budget = v; + this.#render(); + this.requestUpdate("budget"); + } + + get votes() { return this.#votes; } + set votes(v: UserVote[]) { + this.#votes = v; + this.#render(); + this.requestUpdate("votes"); + } + + #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); + } + + #getUserVote(): UserVote | undefined { + return this.#votes.find((v) => v.userId === this.#userId); + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + this.#ensureIdentity(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + + Poll + +
+ +
+
+
+
+ + + +
+
+ +
+
+ + +
+
+ + `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) containerDiv.replaceWith(wrapper); + + this.#bodyEl = wrapper.querySelector(".body") as HTMLElement; + this.#optionsEl = wrapper.querySelector(".options-list") as HTMLElement; + this.#budgetEl = wrapper.querySelector(".budget-bar") as HTMLElement; + this.#votersEl = wrapper.querySelector(".voters-count") as HTMLElement; + const titleEl = wrapper.querySelector(".title-text") 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 addInput = wrapper.querySelector(".add-input") as HTMLInputElement; + const addBtn = wrapper.querySelector(".add-btn") as HTMLButtonElement; + + // Show username prompt if needed + 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(); }); + + // Mode tabs + wrapper.querySelectorAll(".mode-tab").forEach((tab) => { + tab.addEventListener("click", (e) => { + e.stopPropagation(); + const m = (tab as HTMLElement).dataset.mode as VoteMode; + this.#mode = m; + wrapper.querySelectorAll(".mode-tab").forEach((t) => t.classList.remove("active")); + tab.classList.add("active"); + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + }); + + // Add option + const addOption = () => { + const label = addInput.value.trim(); + if (!label) return; + this.#options.push({ + id: `opt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + label, + color: DEFAULT_COLORS[this.#options.length % DEFAULT_COLORS.length], + }); + addInput.value = ""; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + }; + addBtn.addEventListener("click", (e) => { e.stopPropagation(); addOption(); }); + addInput.addEventListener("click", (e) => e.stopPropagation()); + addInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") addOption(); }); + + // Close + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Title + if (this.#title) titleEl.textContent = this.#title; + + this.#render(); + return root; + } + + #castVote(optionId: string, delta: number) { + if (!this.#ensureIdentity()) return; + + let vote = this.#getUserVote(); + if (!vote) { + vote = { userId: this.#userId, userName: this.#userName, allocations: {}, timestamp: Date.now() }; + this.#votes.push(vote); + } + + const current = vote.allocations[optionId] || 0; + + if (this.#mode === "plurality") { + // Toggle: clear all, then set this one (or clear if already voted) + const wasVoted = current > 0; + for (const key of Object.keys(vote.allocations)) vote.allocations[key] = 0; + if (!wasVoted) vote.allocations[optionId] = 1; + } else if (this.#mode === "approval") { + // Toggle this option + vote.allocations[optionId] = current > 0 ? 0 : 1; + } else { + // Quadratic: increment/decrement + const next = Math.max(0, current + delta); + const testAlloc = { ...vote.allocations, [optionId]: next }; + if (quadraticCost(testAlloc) <= this.#budget) { + vote.allocations[optionId] = next; + } + } + + vote.timestamp = Date.now(); + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + #render() { + if (!this.#optionsEl) return; + + const tally = tallyVotes(this.#votes, this.#options); + const maxVotes = Math.max(1, ...tally.values()); + const totalVotes = [...tally.values()].reduce((a, b) => a + b, 0); + const myVote = this.#getUserVote(); + const uniqueVoters = new Set(this.#votes.map((v) => v.userId)).size; + + this.#optionsEl.innerHTML = this.#options + .map((opt) => { + const count = tally.get(opt.id) || 0; + const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0; + const barWidth = (count / maxVotes) * 100; + const myAlloc = myVote?.allocations[opt.id] || 0; + const isVoted = myAlloc > 0; + + let controls = ""; + if (this.#mode === "quadratic") { + controls = ` +
+ + ${myAlloc} + +
+ `; + } + + return ` +
+
+ + ${this.#escapeHtml(opt.label)} + ${controls} + ${count} + ${pct.toFixed(0)}% +
+ `; + }) + .join(""); + + // Budget display for quadratic + if (this.#budgetEl) { + if (this.#mode === "quadratic") { + const used = myVote ? quadraticCost(myVote.allocations) : 0; + this.#budgetEl.style.display = "block"; + this.#budgetEl.innerHTML = `Credits: ${used} / ${this.#budget}`; + } else { + this.#budgetEl.style.display = "none"; + } + } + + // Voter count + if (this.#votersEl) { + this.#votersEl.textContent = uniqueVoters === 0 + ? "No votes yet" + : `${uniqueVoters} voter${uniqueVoters !== 1 ? "s" : ""}`; + } + + // Wire click events + if (this.#mode !== "quadratic") { + this.#optionsEl.querySelectorAll(".option-row").forEach((row) => { + row.addEventListener("click", (e) => { + e.stopPropagation(); + const optId = (row as HTMLElement).dataset.opt!; + this.#castVote(optId, 1); + }); + }); + } else { + this.#optionsEl.querySelectorAll(".qv-plus").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + this.#castVote((btn as HTMLElement).dataset.opt!, 1); + }); + }); + this.#optionsEl.querySelectorAll(".qv-minus").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + this.#castVote((btn as HTMLElement).dataset.opt!, -1); + }); + }); + } + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-choice-vote", + title: this.#title, + options: this.#options, + mode: this.#mode, + budget: this.#budget, + votes: this.#votes, + }; + } +} diff --git a/lib/index.ts b/lib/index.ts index 1e11a0c..e9bb6b9 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -55,6 +55,11 @@ export * from "./folk-booking"; export * from "./folk-token-mint"; export * from "./folk-token-ledger"; +// Decision/Choice Shapes +export * from "./folk-choice-vote"; +export * from "./folk-choice-rank"; +export * from "./folk-choice-spider"; + // Sync export * from "./community-sync"; export * from "./presence"; diff --git a/website/canvas.html b/website/canvas.html index cf38e95..fe8ee84 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -169,7 +169,10 @@ folk-packing-list, folk-booking, folk-token-mint, - folk-token-ledger { + folk-token-ledger, + folk-choice-vote, + folk-choice-rank, + folk-choice-spider { position: absolute; } @@ -178,7 +181,8 @@ folk-image-gen, folk-video-gen, folk-prompt, folk-transcription, folk-video-chat, folk-obs-note, folk-workflow-block, folk-itinerary, folk-destination, folk-budget, folk-packing-list, - folk-booking, folk-token-mint, folk-token-ledger) { + folk-booking, folk-token-mint, folk-token-ledger, + folk-choice-vote, folk-choice-rank, folk-choice-spider) { cursor: crosshair; } @@ -187,7 +191,8 @@ folk-image-gen, folk-video-gen, folk-prompt, folk-transcription, folk-video-chat, folk-obs-note, folk-workflow-block, folk-itinerary, folk-destination, folk-budget, folk-packing-list, - folk-booking, folk-token-mint, folk-token-ledger):hover { + folk-booking, folk-token-mint, folk-token-ledger, + folk-choice-vote, folk-choice-rank, folk-choice-spider):hover { outline: 2px dashed #3b82f6; outline-offset: 4px; } @@ -227,6 +232,9 @@ + + + @@ -267,6 +275,9 @@ FolkBooking, FolkTokenMint, FolkTokenLedger, + FolkChoiceVote, + FolkChoiceRank, + FolkChoiceSpider, CommunitySync, PresenceManager, generatePeerId @@ -302,6 +313,9 @@ FolkBooking.define(); FolkTokenMint.define(); FolkTokenLedger.define(); + FolkChoiceVote.define(); + FolkChoiceRank.define(); + FolkChoiceSpider.define(); // Get community info from URL const hostname = window.location.hostname; @@ -326,7 +340,8 @@ "folk-transcription", "folk-video-chat", "folk-obs-note", "folk-workflow-block", "folk-itinerary", "folk-destination", "folk-budget", "folk-packing-list", "folk-booking", - "folk-token-mint", "folk-token-ledger" + "folk-token-mint", "folk-token-ledger", + "folk-choice-vote", "folk-choice-rank", "folk-choice-spider" ].join(", "); // Initialize CommunitySync @@ -578,6 +593,27 @@ if (data.mintId) shape.mintId = data.mintId; if (data.entries) shape.entries = data.entries; break; + case "folk-choice-vote": + shape = document.createElement("folk-choice-vote"); + if (data.title) shape.title = data.title; + if (data.options) shape.options = data.options; + if (data.mode) shape.mode = data.mode; + if (data.budget != null) shape.budget = data.budget; + if (data.votes) shape.votes = data.votes; + break; + case "folk-choice-rank": + shape = document.createElement("folk-choice-rank"); + if (data.title) shape.title = data.title; + if (data.options) shape.options = data.options; + if (data.rankings) shape.rankings = data.rankings; + break; + case "folk-choice-spider": + shape = document.createElement("folk-choice-spider"); + if (data.title) shape.title = data.title; + if (data.options) shape.options = data.options; + if (data.criteria) shape.criteria = data.criteria; + if (data.scores) shape.scores = data.scores; + break; case "folk-markdown": default: shape = document.createElement("folk-markdown"); @@ -643,6 +679,9 @@ "folk-booking": { width: 300, height: 240 }, "folk-token-mint": { width: 320, height: 280 }, "folk-token-ledger": { width: 380, height: 400 }, + "folk-choice-vote": { width: 360, height: 400 }, + "folk-choice-rank": { width: 380, height: 480 }, + "folk-choice-spider": { width: 440, height: 540 }, }; // Get the center of the current viewport in canvas coordinates @@ -776,6 +815,50 @@ } }); + // Decision/choice components + document.getElementById("add-choice-vote").addEventListener("click", () => { + createAndAddShape("folk-choice-vote", { + title: "Quick Poll", + options: [ + { id: "opt-1", label: "Option A", color: "#3b82f6" }, + { id: "opt-2", label: "Option B", color: "#22c55e" }, + { id: "opt-3", label: "Option C", color: "#f59e0b" }, + ], + mode: "plurality", + budget: 100, + votes: [], + }); + }); + + document.getElementById("add-choice-rank").addEventListener("click", () => { + createAndAddShape("folk-choice-rank", { + title: "Rank These", + options: [ + { id: "opt-1", label: "Option A" }, + { id: "opt-2", label: "Option B" }, + { id: "opt-3", label: "Option C" }, + ], + rankings: [], + }); + }); + + document.getElementById("add-choice-spider").addEventListener("click", () => { + createAndAddShape("folk-choice-spider", { + title: "Evaluate Options", + options: [ + { id: "opt-1", label: "Option A" }, + { id: "opt-2", label: "Option B" }, + ], + criteria: [ + { id: "crit-1", label: "Quality", weight: 1 }, + { id: "crit-2", label: "Cost", weight: 1 }, + { id: "crit-3", label: "Speed", weight: 1 }, + { id: "crit-4", label: "Risk", weight: 1 }, + ], + scores: [], + }); + }); + // Arrow connection mode let connectMode = false; let connectSource = null;