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();