775 lines
19 KiB
TypeScript
775 lines
19 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;
|
|
}
|
|
`;
|
|
|
|
// -- 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;
|
|
}
|
|
|
|
// -- 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`
|
|
<div class="header">
|
|
<span class="header-title">
|
|
<span>📊</span>
|
|
<span class="title-text">Rank</span>
|
|
</span>
|
|
<div class="header-actions">
|
|
<button class="close-btn" title="Close">×</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="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.#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 = '<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">≡≡</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} — <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} — <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>`;
|
|
}
|
|
|
|
#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,
|
|
};
|
|
}
|
|
}
|