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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-03 14:03:03 -08:00
parent 588a52f2cc
commit 96e1afb143
1 changed files with 130 additions and 1 deletions

View File

@ -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 {
</div>
</div>
<div class="body">
<div class="conviction-chart"></div>
<div class="options-list"></div>
<div class="weight-bar"></div>
<div class="voters-count"></div>
@ -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<number>();
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 = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">`;
// Grid
svg += `<line x1="${PAD.left}" y1="${y(0)}" x2="${W - PAD.right}" y2="${y(0)}" stroke="#e2e8f0" stroke-width="0.5"/>`;
svg += `<line x1="${PAD.left}" y1="${y(maxV)}" x2="${W - PAD.right}" y2="${y(maxV)}" stroke="#e2e8f0" stroke-width="0.5" stroke-dasharray="3,3"/>`;
if (maxV > 2) {
const mid = maxV / 2;
svg += `<line x1="${PAD.left}" y1="${y(mid)}" x2="${W - PAD.right}" y2="${y(mid)}" stroke="#f1f5f9" stroke-width="0.5" stroke-dasharray="2,4"/>`;
}
// 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 += `<path d="${areaD}" fill="${curve.color}" opacity="0.1"/>`;
// Line
const lineD = curve.points.map((p, i) => `${i === 0 ? "M" : "L"}${x(p.t)},${y(p.v)}`).join(" ");
svg += `<path d="${lineD}" fill="none" stroke="${curve.color}" stroke-width="1.5" stroke-linejoin="round"/>`;
// End dot
const last = curve.points[curve.points.length - 1];
svg += `<circle cx="${x(last.t)}" cy="${y(last.v)}" r="2.5" fill="${curve.color}"/>`;
}
// Y axis labels
svg += `<text x="${PAD.left - 4}" y="${PAD.top + 4}" text-anchor="end" font-size="8" fill="#94a3b8" font-family="system-ui">${this.#formatConviction(maxV)}</text>`;
svg += `<text x="${PAD.left - 4}" y="${y(0)}" text-anchor="end" font-size="8" fill="#94a3b8" font-family="system-ui">0</text>`;
// 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 += `<text x="${PAD.left}" y="${H - 3}" font-size="8" fill="#94a3b8" font-family="system-ui">${fmtRange(timeRange)}</text>`;
svg += `<text x="${W - PAD.right}" y="${H - 3}" text-anchor="end" font-size="8" fill="#94a3b8" font-family="system-ui">now</text>`;
svg += "</svg>";
this.#chartEl.innerHTML = svg;
}
#renderDrawer() {
if (!this.#drawerEl) return;
const now = Date.now();