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:
parent
588a52f2cc
commit
96e1afb143
|
|
@ -174,6 +174,20 @@ const styles = css`
|
||||||
text-align: center;
|
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 {
|
.add-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
@ -344,6 +358,7 @@ export class FolkChoiceConviction extends FolkShape {
|
||||||
#weightEl: HTMLElement | null = null;
|
#weightEl: HTMLElement | null = null;
|
||||||
#votersEl: HTMLElement | null = null;
|
#votersEl: HTMLElement | null = null;
|
||||||
#drawerEl: HTMLElement | null = null;
|
#drawerEl: HTMLElement | null = null;
|
||||||
|
#chartEl: HTMLElement | null = null;
|
||||||
|
|
||||||
get title() { return this.#title; }
|
get title() { return this.#title; }
|
||||||
set title(v: string) { this.#title = v; this.requestUpdate("title"); }
|
set title(v: string) { this.#title = v; this.requestUpdate("title"); }
|
||||||
|
|
@ -432,6 +447,7 @@ export class FolkChoiceConviction extends FolkShape {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
|
<div class="conviction-chart"></div>
|
||||||
<div class="options-list"></div>
|
<div class="options-list"></div>
|
||||||
<div class="weight-bar"></div>
|
<div class="weight-bar"></div>
|
||||||
<div class="voters-count"></div>
|
<div class="voters-count"></div>
|
||||||
|
|
@ -458,6 +474,7 @@ export class FolkChoiceConviction extends FolkShape {
|
||||||
this.#weightEl = wrapper.querySelector(".weight-bar") as HTMLElement;
|
this.#weightEl = wrapper.querySelector(".weight-bar") as HTMLElement;
|
||||||
this.#votersEl = wrapper.querySelector(".voters-count") as HTMLElement;
|
this.#votersEl = wrapper.querySelector(".voters-count") as HTMLElement;
|
||||||
this.#drawerEl = wrapper.querySelector(".results-drawer") 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 titleEl = wrapper.querySelector(".title-text") as HTMLElement;
|
||||||
|
|
||||||
const drawerToggle = wrapper.querySelector(".drawer-toggle") as HTMLButtonElement;
|
const drawerToggle = wrapper.querySelector(".drawer-toggle") as HTMLButtonElement;
|
||||||
|
|
@ -534,6 +551,7 @@ export class FolkChoiceConviction extends FolkShape {
|
||||||
|
|
||||||
#render() {
|
#render() {
|
||||||
this.#renderOptions();
|
this.#renderOptions();
|
||||||
|
this.#renderChart();
|
||||||
if (this.#drawerOpen) this.#renderDrawer();
|
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 maxConv = Math.max(1, ...convictions.map((c) => c.score));
|
||||||
const uniqueParticipants = new Set(this.#stakes.map((s) => s.userId)).size;
|
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) => {
|
.map((opt) => {
|
||||||
const conv = convictions.find((c) => c.id === opt.id)!;
|
const conv = convictions.find((c) => c.id === opt.id)!;
|
||||||
const barWidth = (conv.score / maxConv) * 100;
|
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() {
|
#renderDrawer() {
|
||||||
if (!this.#drawerEl) return;
|
if (!this.#drawerEl) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue