Skip to content

Commit 2b2081c

Browse files
committed
fix(compare-per-dollar): clearer axis labels and curve-extension distinction in PNG
The indexed performance-per-dollar PNG had two readability problems: 1. Y-axis labels were inconsistent ($0.000 / $9.01 / $18.0 / $27.0) because the per-tick formatter picked precision from each tick's magnitude. Switched to a 1/2/5-step nice-axis with one precision chosen from the step size, applied uniformly to every tick. 2. The curve continued past the rightmost labeled dot with no visual distinction, leaving readers unsure whether the trailing values were real data or extrapolation. The curve is now split at the matched- interactivity bounds: the segment between the labeled dots stays solid, and the portion extending toward each SKU's operating envelope edge renders as dashed, semi-translucent. Faint italic endpoint labels mark the actual x-range, and a one-line caption under the axis explains the convention. `computeCompareImageRows` now accepts an optional `includeTargets` array so the dot targets are guaranteed to be exact samples in the curve — the partition into solid / dashed segments connects cleanly at the boundary without per-segment boundary interpolation in the renderer.
1 parent 7fbbd12 commit 2b2081c

3 files changed

Lines changed: 320 additions & 50 deletions

File tree

packages/app/src/app/compare-per-dollar/[slug]/performance-per-dollar.png/route.tsx

Lines changed: 163 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
computeCompareImageRows,
99
computeCompareTableData,
1010
getCachedBenchmarks,
11+
type SsrInterpolatedRow,
1112
} from '@/lib/compare-ssr';
1213

1314
export const dynamic = 'force-dynamic';
@@ -20,12 +21,13 @@ const SIZE = {
2021
height: DISPLAY_SIZE.height * IMAGE_SCALE,
2122
};
2223
const CHART_FRAME = { left: 0, top: 18, width: 746, height: 382 };
23-
const CHART = { left: 96, top: 42, width: 630, height: 272 };
24+
const CHART = { left: 96, top: 42, width: 630, height: 260 };
2425
const COLORS = {
2526
background: '#0d1117',
2627
panel: '#121a23',
2728
border: '#23303d',
2829
muted: '#9aa7b5',
30+
faint: '#5f6e7d',
2931
text: '#f3f7fb',
3032
a: '#38d9a9',
3133
b: '#f7b041',
@@ -38,12 +40,54 @@ interface Point {
3840
y: number;
3941
}
4042

43+
interface TargetedPoint extends Point {
44+
target: number;
45+
}
46+
4147
function money(value: number): string {
4248
if (value >= 10) return `$${value.toFixed(1)}`;
4349
if (value >= 1) return `$${value.toFixed(2)}`;
4450
return `$${value.toFixed(3)}`;
4551
}
4652

53+
/** Decimals chosen from the tick step so every label in the axis prints with
54+
* the same precision (no $0.000/$9.01/$18.0 mix). */
55+
function decimalsForStep(step: number): number {
56+
if (step >= 1) return 0;
57+
return Math.max(0, Math.ceil(-Math.log10(step)));
58+
}
59+
60+
function moneyForStep(value: number, step: number): string {
61+
return `$${value.toFixed(decimalsForStep(step))}`;
62+
}
63+
64+
/** "Nice" step in the 1/2/5 × 10ⁿ family, the same convention d3 uses. */
65+
function niceStep(span: number, targetCount: number): number {
66+
const rawStep = span / Math.max(1, targetCount - 1);
67+
const mag = 10 ** Math.floor(Math.log10(rawStep));
68+
const normalized = rawStep / mag;
69+
if (normalized < 1.5) return mag;
70+
if (normalized < 3) return 2 * mag;
71+
if (normalized < 7) return 5 * mag;
72+
return 10 * mag;
73+
}
74+
75+
function niceAxis(
76+
min: number,
77+
max: number,
78+
targetCount = 5,
79+
): { min: number; max: number; step: number; ticks: number[] } {
80+
if (max <= min) return { min, max: min + 1, step: 1, ticks: [min] };
81+
const step = niceStep(max - min, targetCount);
82+
const niceMin = Math.floor(min / step) * step;
83+
const niceMax = Math.ceil(max / step) * step;
84+
const ticks: number[] = [];
85+
for (let t = niceMin; t <= niceMax + step * 1e-6; t += step) {
86+
ticks.push(Number(t.toFixed(10)));
87+
}
88+
return { min: niceMin, max: niceMax, step, ticks };
89+
}
90+
4791
function pointsPath(points: Point[]): string {
4892
return points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' ');
4993
}
@@ -75,6 +119,7 @@ export async function GET(
75119
sequence,
76120
precision,
77121
interactivityRange,
122+
plottedRows.map((r) => r.target),
78123
).filter((row) => row.a || row.b);
79124
const curveRows = imageRows.length > 0 ? imageRows : plottedRows;
80125

@@ -85,32 +130,64 @@ export async function GET(
85130
.filter((cost): cost is number => typeof cost === 'number' && Number.isFinite(cost));
86131
const costMin = costs.length > 0 ? Math.min(...costs) : 0;
87132
const costMax = costs.length > 0 ? Math.max(...costs) : 1;
88-
const costPadding = Math.max((costMax - costMin) * 0.18, costMax * 0.08, 0.02);
89-
const yMin = Math.max(0, costMin - costPadding);
90-
const yMax = costMax + costPadding;
133+
const yAxis = niceAxis(Math.min(0, costMin), costMax);
134+
const yMin = yAxis.min;
135+
const yMax = yAxis.max;
136+
const yStep = yAxis.step;
91137
const xMin = curveRows.at(0)?.target ?? 0;
92138
const xMax = curveRows.at(-1)?.target ?? 100;
139+
const matchedMin = plottedRows.at(0)?.target ?? xMin;
140+
const matchedMax = plottedRows.at(-1)?.target ?? xMax;
141+
const hasLeftExtension = matchedMin - xMin >= 0.5;
142+
const hasRightExtension = xMax - matchedMax >= 0.5;
93143
const scaleX = (value: number) =>
94144
CHART.left + (xMax === xMin ? CHART.width / 2 : ((value - xMin) / (xMax - xMin)) * CHART.width);
95145
const scaleY = (value: number) =>
96146
CHART.top +
97147
CHART.height -
98148
(yMax === yMin ? CHART.height / 2 : ((value - yMin) / (yMax - yMin)) * CHART.height);
99149

100-
const aPoints = curveRows
101-
.filter((row) => row.a)
102-
.map((row) => ({ x: scaleX(row.target), y: scaleY(row.a!.cost) }));
103-
const bPoints = curveRows
104-
.filter((row) => row.b)
105-
.map((row) => ({ x: scaleX(row.target), y: scaleY(row.b!.cost) }));
150+
function buildSeriesPoints(getCost: (row: SsrInterpolatedRow) => number | null): TargetedPoint[] {
151+
return curveRows
152+
.map((row) => ({ target: row.target, cost: getCost(row) }))
153+
.filter((p): p is { target: number; cost: number } => p.cost !== null)
154+
.map((p) => ({ x: scaleX(p.target), y: scaleY(p.cost), target: p.target }));
155+
}
156+
157+
function splitByMatchRange(points: TargetedPoint[]) {
158+
return {
159+
matched: points.filter((p) => p.target >= matchedMin && p.target <= matchedMax),
160+
leftExt: points.filter((p) => p.target <= matchedMin),
161+
rightExt: points.filter((p) => p.target >= matchedMax),
162+
};
163+
}
164+
165+
const aSeries = splitByMatchRange(buildSeriesPoints((r) => r.a?.cost ?? null));
166+
const bSeries = splitByMatchRange(buildSeriesPoints((r) => r.b?.cost ?? null));
106167
const aHighlightPoints = plottedRows
107168
.filter((row) => row.a)
108169
.map((row) => ({ x: scaleX(row.target), y: scaleY(row.a!.cost) }));
109170
const bHighlightPoints = plottedRows
110171
.filter((row) => row.b)
111172
.map((row) => ({ x: scaleX(row.target), y: scaleY(row.b!.cost) }));
112-
const yTicks = Array.from({ length: 4 }, (_, index) => yMin + ((yMax - yMin) * index) / 3);
113173
const workload = [sequence, precision?.toUpperCase()].filter(Boolean).join(' / ');
174+
const showRangeEndpoints = hasLeftExtension || hasRightExtension;
175+
176+
function renderSeriesPath(points: Point[], stroke: string, dashed: boolean) {
177+
if (points.length < 2) return null;
178+
return (
179+
<path
180+
d={pointsPath(points)}
181+
fill="none"
182+
stroke={stroke}
183+
strokeWidth="9"
184+
strokeOpacity={dashed ? 0.55 : 1}
185+
strokeDasharray={dashed ? '14 10' : undefined}
186+
strokeLinejoin="round"
187+
strokeLinecap="round"
188+
/>
189+
);
190+
}
114191

115192
return new ImageResponse(
116193
<div
@@ -185,7 +262,7 @@ export async function GET(
185262
fill={COLORS.panel}
186263
stroke={COLORS.border}
187264
/>
188-
{yTicks.map((tick) => {
265+
{yAxis.ticks.map((tick) => {
189266
const y = scaleY(tick);
190267
return (
191268
<line
@@ -199,26 +276,26 @@ export async function GET(
199276
/>
200277
);
201278
})}
202-
{aPoints.length > 1 && (
203-
<path
204-
d={pointsPath(aPoints)}
205-
fill="none"
206-
stroke={COLORS.a}
207-
strokeWidth="9"
208-
strokeLinejoin="round"
209-
strokeLinecap="round"
210-
/>
211-
)}
212-
{bPoints.length > 1 && (
213-
<path
214-
d={pointsPath(bPoints)}
215-
fill="none"
216-
stroke={COLORS.b}
217-
strokeWidth="9"
218-
strokeLinejoin="round"
219-
strokeLinecap="round"
220-
/>
221-
)}
279+
{plottedRows.map((row) => {
280+
const x = scaleX(row.target);
281+
return (
282+
<line
283+
key={`mark-${row.target}`}
284+
x1={x}
285+
x2={x}
286+
y1={CHART.top + CHART.height}
287+
y2={CHART.top + CHART.height + 6}
288+
stroke={COLORS.muted}
289+
strokeWidth="2"
290+
/>
291+
);
292+
})}
293+
{renderSeriesPath(aSeries.leftExt, COLORS.a, true)}
294+
{renderSeriesPath(aSeries.rightExt, COLORS.a, true)}
295+
{renderSeriesPath(aSeries.matched, COLORS.a, false)}
296+
{renderSeriesPath(bSeries.leftExt, COLORS.b, true)}
297+
{renderSeriesPath(bSeries.rightExt, COLORS.b, true)}
298+
{renderSeriesPath(bSeries.matched, COLORS.b, false)}
222299
{aHighlightPoints.map((point, index) => (
223300
<circle
224301
key={`a-${index}`}
@@ -242,7 +319,7 @@ export async function GET(
242319
/>
243320
))}
244321
</svg>
245-
{yTicks.map((tick) => (
322+
{yAxis.ticks.map((tick) => (
246323
<div
247324
key={`y-label-${tick}`}
248325
style={{
@@ -256,7 +333,7 @@ export async function GET(
256333
fontSize: 15,
257334
}}
258335
>
259-
{money(tick)}
336+
{moneyForStep(tick, yStep)}
260337
</div>
261338
))}
262339
{plottedRows.map((row) => (
@@ -277,12 +354,46 @@ export async function GET(
277354
{row.target}
278355
</div>
279356
))}
357+
{showRangeEndpoints && hasLeftExtension && (
358+
<div
359+
style={{
360+
display: 'flex',
361+
position: 'absolute',
362+
left: scaleX(xMin) - 4,
363+
top: CHART.top + CHART.height + 16,
364+
width: 56,
365+
justifyContent: 'flex-start',
366+
color: COLORS.faint,
367+
fontSize: 13,
368+
fontStyle: 'italic',
369+
}}
370+
>
371+
{Math.round(xMin)}
372+
</div>
373+
)}
374+
{showRangeEndpoints && hasRightExtension && (
375+
<div
376+
style={{
377+
display: 'flex',
378+
position: 'absolute',
379+
left: scaleX(xMax) - 52,
380+
top: CHART.top + CHART.height + 16,
381+
width: 56,
382+
justifyContent: 'flex-end',
383+
color: COLORS.faint,
384+
fontSize: 13,
385+
fontStyle: 'italic',
386+
}}
387+
>
388+
{Math.round(xMax)}
389+
</div>
390+
)}
280391
<div
281392
style={{
282393
display: 'flex',
283394
position: 'absolute',
284395
left: CHART.left,
285-
top: CHART.top + CHART.height + 43,
396+
top: CHART.top + CHART.height + 38,
286397
width: CHART.width,
287398
justifyContent: 'center',
288399
color: COLORS.muted,
@@ -292,6 +403,23 @@ export async function GET(
292403
>
293404
Interactivity (tok/s/user)
294405
</div>
406+
{showRangeEndpoints && (
407+
<div
408+
style={{
409+
display: 'flex',
410+
position: 'absolute',
411+
left: CHART.left,
412+
top: CHART.top + CHART.height + 62,
413+
width: CHART.width,
414+
justifyContent: 'center',
415+
color: COLORS.faint,
416+
fontSize: 13,
417+
fontStyle: 'italic',
418+
}}
419+
>
420+
Dashed segments extend to each SKU's operating envelope, where cost rises steeply
421+
</div>
422+
)}
295423
</div>
296424

297425
<div

0 commit comments

Comments
 (0)