88 computeCompareImageRows ,
99 computeCompareTableData ,
1010 getCachedBenchmarks ,
11+ type SsrInterpolatedRow ,
1112} from '@/lib/compare-ssr' ;
1213
1314export const dynamic = 'force-dynamic' ;
@@ -20,12 +21,13 @@ const SIZE = {
2021 height : DISPLAY_SIZE . height * IMAGE_SCALE ,
2122} ;
2223const 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 } ;
2425const 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+
4147function 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+
4791function 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