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, }; } }