From a03e606f08a4e00990b34cc09dbb44690dab322d Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Sat, 17 Oct 2026 09:35:33 +0800 Subject: [PATCH] fix(client): paint the DNA trend bars on the shared token scale DnaBar's gradient stops were percentages of each bar's own total, so the browser rasterized common strands differently per bar (sub-pixel bands flipping visible/merged between steps) and inherited bands read thicker and thinner across the chart. Stops now ride the shared per-token pixel scale (CHART_H/maxTotal) and are byte-identical in every bar; the bar height reuses the same scale. Live-measured on a 99-bar session with the duration curve off: adjacent-bar common-row pixel diffs dropped 589 -> 11, the remainder being genuinely new top-of-bar strands. --- src/client/components/trendChart.tsx | 12 ++++++++---- tests/client/components/trendChartDna.spec.ts | 14 +++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/client/components/trendChart.tsx b/src/client/components/trendChart.tsx index 787cf0f..b6f884a 100644 --- a/src/client/components/trendChart.tsx +++ b/src/client/components/trendChart.tsx @@ -275,10 +275,14 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE const visible: TrendBand[] = [] for (const b of props.bands) if (b.tokens > 0) visible.push(b) if (visible.length === 0) return null + // Shared per-token pixel scale (CHART_H/maxTotal), not per-bar percentages: the same band's stops are + // identical numbers in every bar, so the browser rasterizes common strands identically (per-bar % + // stops quantized against each bar's own total and made sub-pixel strands flip visible/merged). + const pxPerToken = CHART_H / props.maxTotal const runs: { color: string; from: number; to: number }[] = [] for (const b of visible) { - const from = Math.round(b.off / props.total * 10000) / 100 - const to = Math.round((b.off + b.tokens) / props.total * 10000) / 100 + const from = Math.round(b.off * pxPerToken * 100) / 100 + const to = Math.round((b.off + b.tokens) * pxPerToken * 100) / 100 const last = runs.length > 0 ? runs[runs.length - 1] : null if (last !== null && last.color === b.color && last.to === from) last.to = to else runs.push({ color: b.color, from, to }) @@ -299,8 +303,8 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
`${r.color} ${r.from}%, ${r.color} ${r.to}%`).join(', ') + ')', + height: `${Math.max(1, Math.round(props.total * pxPerToken))}px`, + background: 'linear-gradient(to top, ' + runs.map(r => `${r.color} ${r.from}px, ${r.color} ${r.to}px`).join(', ') + ')', '--lc-i': Math.min(props.enterIndex, STAGGER_CAP), } as CSSProperties} onMouseMove={(e) => { props.onHit(hitAt(e).key) }} diff --git a/tests/client/components/trendChartDna.spec.ts b/tests/client/components/trendChartDna.spec.ts index 868c0ce..e8e0dea 100644 --- a/tests/client/components/trendChartDna.spec.ts +++ b/tests/client/components/trendChartDna.spec.ts @@ -52,17 +52,17 @@ describe('TrendChart DNA mode', () => { assert.equal(dnaDivs.length, 2) assert.equal(dnaDivs[0].style.height, `${Math.round(300 / 600 * CHART_H)}px`) assert.equal(dnaDivs[1].style.height, `${CHART_H}px`) - // d1: the two adjacent assistant bands coalesce into ONE blue run (50% → 100%). + // d1: the two adjacent assistant bands coalesce into ONE blue run (28px → 56px on the shared scale). const g1 = dnaDivs[0].style.background assert.ok(g1.startsWith('linear-gradient(to top, '), g1) - assert.ok(g1.includes('var(--color-indigo-500) 0%, var(--color-indigo-500) 33.33%'), g1) - assert.ok(g1.includes('var(--color-amber-500) 33.33%, var(--color-amber-500) 50%'), g1) - assert.ok(g1.includes('var(--color-blue-500) 50%, var(--color-blue-500) 100%'), g1) + assert.ok(g1.includes('var(--color-indigo-500) 0px, var(--color-indigo-500) 18.67px'), g1) + assert.ok(g1.includes('var(--color-amber-500) 18.67px, var(--color-amber-500) 28px'), g1) + assert.ok(g1.includes('var(--color-blue-500) 28px, var(--color-blue-500) 56px'), g1) // d2: the zero-token user band leaves no run of its own; the teal run starts at its offset. const g2 = dnaDivs[1].style.background - assert.ok(g2.includes('var(--color-blue-500) 33.33%, var(--color-blue-500) 46.67%'), g2) - assert.ok(g2.includes('var(--color-green-500) 46.67%, var(--color-green-500) 63.33%'), g2) - assert.ok(g2.includes('var(--color-teal-500) 63.33%, var(--color-teal-500) 100%'), g2) + assert.ok(g2.includes('var(--color-blue-500) 37.33px, var(--color-blue-500) 52.27px'), g2) + assert.ok(g2.includes('var(--color-green-500) 52.27px, var(--color-green-500) 70.93px'), g2) + assert.ok(g2.includes('var(--color-teal-500) 70.93px, var(--color-teal-500) 112px'), g2) assert.equal(queryAll(m.container, '.lc-bar-stack').length, 0) assert.equal(query(m.container, '.lc-axis-top').textContent, '600') // Without an onPickBand handler a band click is a quiet no-op (the pick guard's undefined arm).