rspace-online/lib/folk-choice-rank.ts

988 lines
28 KiB
TypeScript

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;
}
.wrapper { position: relative; height: 100%; }
.results-drawer {
position: absolute; top: 0; left: 100%; width: 300px; height: 100%;
background: white; border-radius: 0 8px 8px 0;
box-shadow: 4px 0 12px rgba(0,0,0,0.08);
overflow-y: auto; display: none; flex-direction: column;
font-size: 12px; z-index: 10;
}
.drawer-open .results-drawer { display: flex; }
.drawer-section { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; }
.drawer-heading {
font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px;
}
.stat-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 11px; }
.stat-label { color: #64748b; }
.stat-value { font-weight: 600; color: #1e293b; font-variant-numeric: tabular-nums; }
.drawer-bar-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.drawer-bar-label { font-size: 11px; min-width: 60px; color: #1e293b; }
.drawer-bar-bg { flex: 1; height: 12px; background: #f1f5f9; border-radius: 3px; overflow: hidden; }
.drawer-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.drawer-bar-val { font-size: 10px; font-weight: 600; min-width: 24px; text-align: right; font-variant-numeric: tabular-nums; }
.participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; }
.participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.drawer-toggle.active { background: rgba(255,255,255,0.3); }
`;
// -- 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<string, number>;
eliminated: string | null;
}
// -- Pure aggregation functions --
export function bordaCount(
rankings: UserRanking[],
options: RankOption[],
): Map<string, number> {
const n = options.length;
const scores = new Map<string, number>();
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<string, number> = {};
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;
}
export function kendallTauB(rankings: UserRanking[], options: RankOption[]): number {
if (rankings.length < 2) return 0;
const optIds = options.map((o) => o.id);
let totalTau = 0;
let pairs = 0;
for (let i = 0; i < rankings.length; i++) {
for (let j = i + 1; j < rankings.length; j++) {
const posA = new Map(rankings[i].ordering.map((id, idx) => [id, idx]));
const posB = new Map(rankings[j].ordering.map((id, idx) => [id, idx]));
let concordant = 0;
let discordant = 0;
for (let a = 0; a < optIds.length; a++) {
for (let b = a + 1; b < optIds.length; b++) {
const dA = (posA.get(optIds[a]) ?? 0) - (posA.get(optIds[b]) ?? 0);
const dB = (posB.get(optIds[a]) ?? 0) - (posB.get(optIds[b]) ?? 0);
if (dA * dB > 0) concordant++;
else if (dA * dB < 0) discordant++;
}
}
const total = concordant + discordant;
if (total > 0) {
totalTau += (concordant - discordant) / total;
pairs++;
}
}
}
return pairs > 0 ? totalTau / pairs : 0;
}
export function positionFrequency(
rankings: UserRanking[],
options: RankOption[],
): Map<string, number[]> {
const result = new Map<string, number[]>();
const n = options.length;
for (const opt of options) result.set(opt.id, new Array(n).fill(0));
for (const r of rankings) {
for (let i = 0; i < r.ordering.length; i++) {
const counts = result.get(r.ordering[i]);
if (counts && i < counts.length) counts[i]++;
}
}
return result;
}
export function headToHead(
rankings: UserRanking[],
options: RankOption[],
): Map<string, Map<string, number>> {
const result = new Map<string, Map<string, number>>();
for (const a of options) {
result.set(a.id, new Map());
for (const b of options) {
if (a.id !== b.id) result.get(a.id)!.set(b.id, 0);
}
}
for (const r of rankings) {
const pos = new Map(r.ordering.map((id, idx) => [id, idx]));
for (const a of options) {
for (const b of options) {
if (a.id === b.id) continue;
if ((pos.get(a.id) ?? Infinity) < (pos.get(b.id) ?? Infinity)) {
result.get(a.id)!.set(b.id, result.get(a.id)!.get(b.id)! + 1);
}
}
}
}
return result;
}
export function condorcetWinner(rankings: UserRanking[], options: RankOption[]): string | null {
const h2h = headToHead(rankings, options);
for (const a of options) {
let wins = true;
for (const b of options) {
if (a.id === b.id) continue;
if ((h2h.get(a.id)?.get(b.id) ?? 0) <= (h2h.get(b.id)?.get(a.id) ?? 0)) {
wins = false;
break;
}
}
if (wins) return a.id;
}
return null;
}
// -- 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";
#drawerOpen = false;
// Drag state
#dragIdx: number | null = null;
#myOrdering: string[] = [];
// DOM refs
#wrapperEl: HTMLElement | null = null;
#bodyEl: HTMLElement | null = null;
#rankPanel: HTMLElement | null = null;
#resultsPanel: HTMLElement | null = null;
#drawerEl: 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.className = "wrapper";
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>&#x1F4CA;</span>
<span class="title-text">Rank</span>
</span>
<div class="header-actions">
<button class="drawer-toggle" title="Results &amp; Stats">&#x1F4CA;</button>
<button class="close-btn" title="Close">&times;</button>
</div>
</div>
<div class="body">
<div class="tab-bar">
<button class="tab active" data-tab="rank">My Ranking</button>
<button class="tab" data-tab="results">Results</button>
</div>
<div class="panel rank-panel"></div>
<div class="panel results-panel hidden"></div>
<div class="add-form">
<input type="text" class="add-input" placeholder="Add option..." />
<button class="add-btn">Add</button>
</div>
</div>
<div class="results-drawer"></div>
<div class="username-prompt" style="display: none;">
<p>Enter your name to rank:</p>
<input type="text" class="username-input" placeholder="Your name..." />
<button class="username-btn">Join</button>
</div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
this.#wrapperEl = wrapper;
this.#bodyEl = wrapper.querySelector(".body") as HTMLElement;
this.#rankPanel = wrapper.querySelector(".rank-panel") as HTMLElement;
this.#resultsPanel = wrapper.querySelector(".results-panel") as HTMLElement;
this.#drawerEl = wrapper.querySelector(".results-drawer") as HTMLElement;
const drawerToggle = wrapper.querySelector(".drawer-toggle") as HTMLButtonElement;
drawerToggle.addEventListener("click", (e) => {
e.stopPropagation();
this.#drawerOpen = !this.#drawerOpen;
this.#wrapperEl!.classList.toggle("drawer-open", this.#drawerOpen);
drawerToggle.classList.toggle("active", this.#drawerOpen);
if (this.#drawerOpen) this.#renderDrawer();
});
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();
if (this.#drawerOpen) this.#renderDrawer();
}
#renderRankList() {
if (!this.#rankPanel) return;
if (this.#myOrdering.length === 0) {
this.#rankPanel.innerHTML = '<div class="empty-state">Add options below to start ranking</div>';
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 `
<div class="rank-item" data-idx="${i}">
<span class="grip">&#x2261;&#x2261;</span>
<span class="rank-num" style="color:${color}">${i + 1}</span>
<span class="rank-label">${this.#escapeHtml(opt.label)}</span>
</div>
`;
})
.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 = '<div class="empty-state">No rankings submitted yet</div>';
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 = '<div class="results-section"><div class="results-heading">Borda Count</div>';
for (const [optId, pts] of bordaSorted) {
const opt = optMap.get(optId);
if (!opt) continue;
const pct = (pts / maxBorda) * 100;
bordaHtml += `
<div class="borda-row">
<span class="borda-label">${this.#escapeHtml(opt.label)}</span>
<div class="borda-bar-bg"><div class="borda-bar-fill" style="width:${pct}%"></div></div>
<span class="borda-pts">${pts}</span>
</div>
`;
}
bordaHtml += "</div>";
// IRV
const irvRounds = instantRunoff(this.#rankings, this.#options);
let irvHtml = '<div class="results-section"><div class="results-heading">Instant-Runoff</div>';
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 += `<div class="irv-round"><span class="round-num">Round ${r.round}:</span> ${counts} &mdash; <span class="eliminated">${this.#escapeHtml(elimLabel)} eliminated</span></div>`;
} else {
// Winner round
const winnerId = Object.keys(r.counts)[0];
const winLabel = optMap.get(winnerId)?.label || winnerId;
irvHtml += `<div class="irv-round"><span class="round-num">Round ${r.round}:</span> ${counts} &mdash; <span class="winner">${this.#escapeHtml(winLabel)} wins!</span></div>`;
}
}
irvHtml += "</div>";
this.#resultsPanel.innerHTML = bordaHtml + irvHtml +
`<div class="voters-count">${uniqueVoters} voter${uniqueVoters !== 1 ? "s" : ""}</div>`;
}
#renderDrawer() {
if (!this.#drawerEl) 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 resultsHtml = '<div class="drawer-section"><div class="drawer-heading">Group Results</div>';
for (const [optId, pts] of bordaSorted) {
const opt = optMap.get(optId);
if (!opt) continue;
const pct = (pts / maxBorda) * 100;
resultsHtml += `<div class="drawer-bar-row">
<span class="drawer-bar-label">${this.#escapeHtml(opt.label)}</span>
<div class="drawer-bar-bg"><div class="drawer-bar-fill" style="width:${pct}%;background:#4f46e5"></div></div>
<span class="drawer-bar-val">${pts}</span>
</div>`;
}
// IRV winner
const irvRounds = instantRunoff(this.#rankings, this.#options);
if (irvRounds.length > 0) {
const lastRound = irvRounds[irvRounds.length - 1];
const winnerId = Object.keys(lastRound.counts)[0];
const winLabel = optMap.get(winnerId)?.label || winnerId;
resultsHtml += `<div class="stat-row"><span class="stat-label">IRV Winner</span><span class="stat-value">${this.#escapeHtml(winLabel)}</span></div>`;
}
// Condorcet winner
const cw = condorcetWinner(this.#rankings, this.#options);
if (cw) {
const cwLabel = optMap.get(cw)?.label || cw;
resultsHtml += `<div class="stat-row"><span class="stat-label">Condorcet Winner</span><span class="stat-value" style="color:#22c55e">${this.#escapeHtml(cwLabel)}</span></div>`;
} else {
resultsHtml += `<div class="stat-row"><span class="stat-label">Condorcet Winner</span><span class="stat-value" style="color:#94a3b8">None</span></div>`;
}
resultsHtml += "</div>";
// Statistics
const uniqueVoters = new Set(this.#rankings.map((r) => r.userId)).size;
const tau = kendallTauB(this.#rankings, this.#options);
let statsHtml = '<div class="drawer-section"><div class="drawer-heading">Statistics</div>';
statsHtml += `<div class="stat-row"><span class="stat-label">Voters</span><span class="stat-value">${uniqueVoters}</span></div>`;
statsHtml += `<div class="stat-row"><span class="stat-label">Agreement (Kendall)</span><span class="stat-value">${tau.toFixed(2)}</span></div>`;
// Head-to-head
const h2h = headToHead(this.#rankings, this.#options);
for (const a of this.#options) {
for (const b of this.#options) {
if (a.id >= b.id) continue;
const aWins = h2h.get(a.id)?.get(b.id) ?? 0;
const bWins = h2h.get(b.id)?.get(a.id) ?? 0;
statsHtml += `<div class="stat-row"><span class="stat-label">${this.#escapeHtml(a.label)} v ${this.#escapeHtml(b.label)}</span><span class="stat-value">${aWins}-${bWins}</span></div>`;
}
}
statsHtml += "</div>";
// Participants
let participantsHtml = '<div class="drawer-section"><div class="drawer-heading">Participants</div>';
for (const r of this.#rankings) {
const ago = this.#timeAgo(r.timestamp);
participantsHtml += `<div class="participant-row">
<span class="participant-dot" style="background:#4f46e5"></span>
<span>${this.#escapeHtml(r.userName)}</span>
<span style="margin-left:auto;color:#94a3b8">${ago}</span>
</div>`;
}
participantsHtml += "</div>";
this.#drawerEl.innerHTML = resultsHtml + statsHtml + participantsHtml;
}
#timeAgo(ts: number): string {
const diff = Date.now() - ts;
if (diff < 60000) return "just now";
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return `${Math.floor(diff / 86400000)}d ago`;
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-choice-rank",
title: this.#title,
options: this.#options,
rankings: this.#rankings,
};
}
}