From 96e1afb143724c044dcd90f6e0b6048c02aadc32 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 3 Mar 2026 14:03:03 -0800 Subject: [PATCH] feat: add conviction timeline chart + dynamic option reordering SVG chart plots each option's conviction growth over time with colored lines and area fills. Options now sort by conviction score (highest first) and reorder every 10s as conviction accumulates. Co-Authored-By: Claude Opus 4.6 --- lib/folk-choice-conviction.ts | 131 +++++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 1 deletion(-) diff --git a/lib/folk-choice-conviction.ts b/lib/folk-choice-conviction.ts index fb23ac4..36d2497 100644 --- a/lib/folk-choice-conviction.ts +++ b/lib/folk-choice-conviction.ts @@ -174,6 +174,20 @@ const styles = css` text-align: center; } + .conviction-chart { + padding: 4px 8px 0; + border-bottom: 1px solid #e2e8f0; + } + + .conviction-chart svg { + width: 100%; + display: block; + } + + .conviction-chart:empty { + display: none; + } + .add-form { display: flex; gap: 6px; @@ -344,6 +358,7 @@ export class FolkChoiceConviction extends FolkShape { #weightEl: HTMLElement | null = null; #votersEl: HTMLElement | null = null; #drawerEl: HTMLElement | null = null; + #chartEl: HTMLElement | null = null; get title() { return this.#title; } set title(v: string) { this.#title = v; this.requestUpdate("title"); } @@ -432,6 +447,7 @@ export class FolkChoiceConviction extends FolkShape {
+
@@ -458,6 +474,7 @@ export class FolkChoiceConviction extends FolkShape { this.#weightEl = wrapper.querySelector(".weight-bar") as HTMLElement; this.#votersEl = wrapper.querySelector(".voters-count") as HTMLElement; this.#drawerEl = wrapper.querySelector(".results-drawer") as HTMLElement; + this.#chartEl = wrapper.querySelector(".conviction-chart") as HTMLElement; const titleEl = wrapper.querySelector(".title-text") as HTMLElement; const drawerToggle = wrapper.querySelector(".drawer-toggle") as HTMLButtonElement; @@ -534,6 +551,7 @@ export class FolkChoiceConviction extends FolkShape { #render() { this.#renderOptions(); + this.#renderChart(); if (this.#drawerOpen) this.#renderDrawer(); } @@ -547,7 +565,14 @@ export class FolkChoiceConviction extends FolkShape { const maxConv = Math.max(1, ...convictions.map((c) => c.score)); const uniqueParticipants = new Set(this.#stakes.map((s) => s.userId)).size; - this.#optionsEl.innerHTML = this.#options + // Sort options by conviction score (highest first) + const sortedOptions = [...this.#options].sort((a, b) => { + const scoreA = convictions.find((c) => c.id === a.id)!.score; + const scoreB = convictions.find((c) => c.id === b.id)!.score; + return scoreB - scoreA; + }); + + this.#optionsEl.innerHTML = sortedOptions .map((opt) => { const conv = convictions.find((c) => c.id === opt.id)!; const barWidth = (conv.score / maxConv) * 100; @@ -602,6 +627,110 @@ export class FolkChoiceConviction extends FolkShape { } } + #renderChart() { + if (!this.#chartEl) return; + if (this.#options.length === 0 || this.#stakes.length === 0) { + this.#chartEl.innerHTML = ""; + return; + } + + const now = Date.now(); + const W = 280; + const H = 100; + const PAD = { top: 10, right: 10, bottom: 18, left: 34 }; + const plotW = W - PAD.left - PAD.right; + const plotH = H - PAD.top - PAD.bottom; + + // Collect all inflection time points from stakes + const timeSet = new Set(); + for (const s of this.#stakes) timeSet.add(s.since); + timeSet.add(now); + const sortedTimes = [...timeSet].sort((a, b) => a - b); + + const earliest = sortedTimes[0]; + const timeRange = Math.max(now - earliest, 60000); // at least 1 minute + + // Compute conviction curve for each option + const curves: { id: string; color: string; points: { t: number; v: number }[] }[] = []; + let maxV = 0; + + for (const opt of this.#options) { + const optStakes = this.#stakes.filter((s) => s.optionId === opt.id); + if (optStakes.length === 0) continue; + + const points: { t: number; v: number }[] = []; + const optEarliest = Math.min(...optStakes.map((s) => s.since)); + + // Start at zero + if (optEarliest > earliest) points.push({ t: earliest, v: 0 }); + points.push({ t: optEarliest, v: 0 }); + + // Compute conviction at each time point after this option's first stake + for (const t of sortedTimes) { + if (t <= optEarliest) continue; + let score = 0; + for (const s of optStakes) { + if (s.since <= t) score += s.weight * (t - s.since) / 3600000; + } + points.push({ t, v: score }); + maxV = Math.max(maxV, score); + } + + curves.push({ id: opt.id, color: opt.color, points }); + } + + if (maxV === 0) maxV = 1; + + const x = (t: number) => PAD.left + ((t - earliest) / timeRange) * plotW; + const y = (v: number) => PAD.top + (1 - v / maxV) * plotH; + + let svg = ``; + + // Grid + svg += ``; + svg += ``; + if (maxV > 2) { + const mid = maxV / 2; + svg += ``; + } + + // Area fill + line + end dot for each option + for (const curve of curves) { + if (curve.points.length < 2) continue; + + // Area + const areaD = `M${x(curve.points[0].t)},${y(0)} ` + + curve.points.map((p) => `L${x(p.t)},${y(p.v)}`).join(" ") + + ` L${x(curve.points[curve.points.length - 1].t)},${y(0)} Z`; + svg += ``; + + // Line + const lineD = curve.points.map((p, i) => `${i === 0 ? "M" : "L"}${x(p.t)},${y(p.v)}`).join(" "); + svg += ``; + + // End dot + const last = curve.points[curve.points.length - 1]; + svg += ``; + } + + // Y axis labels + svg += `${this.#formatConviction(maxV)}`; + svg += `0`; + + // X axis labels + const fmtRange = (ms: number) => { + if (ms < 60000) return "<1m ago"; + if (ms < 3600000) return `${Math.floor(ms / 60000)}m ago`; + if (ms < 86400000) return `${Math.floor(ms / 3600000)}h ago`; + return `${Math.floor(ms / 86400000)}d ago`; + }; + svg += `${fmtRange(timeRange)}`; + svg += `now`; + + svg += ""; + this.#chartEl.innerHTML = svg; + } + #renderDrawer() { if (!this.#drawerEl) return; const now = Date.now();