1- export interface ChartPoint {
2- x : number ;
3- y : number ;
4- label : string ;
5- }
1+ import { type ChartPoint , formatTick , niceStep } from "./chartUtils" ;
62
7- const MIN_TICK_SPACING_PX = 48 ;
3+ const MIN_TICK_SPACING_EM = 4 ;
84const Y_GRID_LINES = 5 ;
9- /** 10% padding above the max value so the line doesn't hug the top edge. */
5+ /** 10% headroom above the max so the line doesn't hug the top edge. */
106const Y_HEADROOM = 1.1 ;
11-
12- /** Candidate x-axis tick step sizes in seconds (1s, 2s, 5s, ..., 30m, 1h). */
13- const TICK_STEP_SECONDS = [
14- 1 , 2 , 5 , 10 , 15 , 20 , 30 , 60 , 120 , 300 , 600 , 900 , 1800 , 3600 ,
15- ] ;
16-
17- export function niceStep ( raw : number ) : number {
18- return (
19- TICK_STEP_SECONDS . find ( ( s ) => s >= raw ) ?? Math . ceil ( raw / 3600 ) * 3600
20- ) ;
21- }
22-
23- export function formatTick ( t : number , step : number ) : string {
24- if ( step >= 3600 ) {
25- const h = t / 3600 ;
26- return `${ Number . isInteger ( h ) ? h : h . toFixed ( 1 ) } h` ;
27- }
28- if ( step >= 60 ) {
29- const m = t / 60 ;
30- return `${ Number . isInteger ( m ) ? m : m . toFixed ( 1 ) } m` ;
31- }
32- return `${ t } s` ;
33- }
7+ const DOT_RADIUS_PX = 4 ;
8+ const LINE_WIDTH_PX = 2 ;
9+
10+ const PLOT_PAD_EM = { top : 2 , right : 2 , bottom : 3.5 } ;
11+ const Y_LABEL_GAP_EM = 1 ;
12+ const X_LABEL_GAP_EM = 1.5 ;
13+ const X_AXIS_TITLE_GAP_EM = 0.25 ;
14+ const Y_AXIS_TITLE_GAP_EM = 1 ;
15+ /** Room reserved for the rotated "Mbps" title. */
16+ const Y_AXIS_TITLE_ROOM_EM = 1.5 ;
17+ const LEFT_PAD_EM = Y_AXIS_TITLE_GAP_EM + Y_AXIS_TITLE_ROOM_EM + Y_LABEL_GAP_EM ;
3418
3519interface Theme {
3620 fg : string ;
@@ -39,27 +23,27 @@ interface Theme {
3923 family : string ;
4024}
4125
42- /**
43- * Read VS Code theme colors from CSS custom properties on <html>. Canvas
44- * pixels don't inherit CSS vars, so we re-read on each render to pick up
45- * theme switches.
46- */
26+ /** Canvas pixels don't inherit CSS vars, so re-read on every render. */
4727function readTheme ( ) : Theme {
4828 const s = getComputedStyle ( document . documentElement ) ;
4929 const css = ( prop : string ) => s . getPropertyValue ( prop ) . trim ( ) ;
5030 return {
5131 fg :
32+ css ( "--vscode-charts-foreground" ) ||
5233 css ( "--vscode-descriptionForeground" ) ||
5334 css ( "--vscode-editor-foreground" ) ||
5435 "#888" ,
55- // Use the button color so the accent tracks the theme; charts-* vars
56- // are fixed hues by design .
36+ // focusBorder tracks the theme's accent ; charts.blue is a fixed hue
37+ // kept as a late fallback .
5738 accent :
58- css ( "--vscode-button-background " ) ||
39+ css ( "--vscode-chart-line " ) ||
5940 css ( "--vscode-focusBorder" ) ||
6041 css ( "--vscode-charts-blue" ) ||
6142 "#3794ff" ,
62- grid : css ( "--vscode-editorWidget-border" ) || "rgba(128,128,128,0.15)" ,
43+ grid :
44+ css ( "--vscode-chart-guide" ) ||
45+ css ( "--vscode-charts-lines" ) ||
46+ "rgba(127, 127, 127, 0.35)" ,
6347 family : css ( "--vscode-font-family" ) || "sans-serif" ,
6448 } ;
6549}
@@ -69,18 +53,21 @@ function layoutChart(
6953 samples : ChartPoint [ ] ,
7054 width : number ,
7155 height : number ,
56+ pxPerEm : number ,
7257 family : string ,
7358) {
7459 const maxVal = samples . reduce ( ( m , s ) => Math . max ( m , s . y ) , 1 ) * Y_HEADROOM ;
7560 const maxX = samples . at ( - 1 ) ?. x ?? 1 ;
76- const xRange = maxX || 1 ;
7761 ctx . font = `1em ${ family } ` ;
7862 const yLabelWidth = ctx . measureText ( maxVal . toFixed ( 0 ) ) . width ;
7963 const pad = {
80- top : 24 ,
81- right : 24 ,
82- bottom : 52 ,
83- left : Math . max ( 48 , yLabelWidth + 24 ) ,
64+ top : PLOT_PAD_EM . top * pxPerEm ,
65+ right : PLOT_PAD_EM . right * pxPerEm ,
66+ bottom : PLOT_PAD_EM . bottom * pxPerEm ,
67+ left : Math . max (
68+ PLOT_PAD_EM . right * pxPerEm ,
69+ yLabelWidth + LEFT_PAD_EM * pxPerEm ,
70+ ) ,
8471 } ;
8572 const plotW = width - pad . left - pad . right ;
8673 const plotH = height - pad . top - pad . bottom ;
@@ -90,9 +77,8 @@ function layoutChart(
9077 plotH,
9178 maxVal,
9279 maxX,
93- xRange,
9480 height,
95- tAt : ( t : number ) => pad . left + ( t / xRange ) * plotW ,
81+ tAt : ( t : number ) => pad . left + ( t / maxX ) * plotW ,
9682 yAt : ( v : number ) => pad . top + plotH - ( v / maxVal ) * plotH ,
9783 } ;
9884}
@@ -103,8 +89,9 @@ function drawAxes(
10389 ctx : CanvasRenderingContext2D ,
10490 layout : Layout ,
10591 theme : Theme ,
92+ pxPerEm : number ,
10693) : void {
107- const { pad, plotW, plotH, maxVal, maxX, xRange , height, tAt, yAt } = layout ;
94+ const { pad, plotW, plotH, maxVal, maxX, height, tAt, yAt } = layout ;
10895
10996 ctx . strokeStyle = theme . grid ;
11097 ctx . lineWidth = 1 ;
@@ -117,7 +104,11 @@ function drawAxes(
117104 ctx . moveTo ( pad . left , y ) ;
118105 ctx . lineTo ( pad . left + plotW , y ) ;
119106 ctx . stroke ( ) ;
120- ctx . fillText ( v . toFixed ( 0 ) , pad . left - 12 , y + 5 ) ;
107+ ctx . fillText (
108+ v . toFixed ( 0 ) ,
109+ pad . left - Y_LABEL_GAP_EM * pxPerEm ,
110+ y + pxPerEm / 3 ,
111+ ) ;
121112 }
122113
123114 ctx . strokeStyle = theme . fg ;
@@ -128,16 +119,24 @@ function drawAxes(
128119
129120 ctx . textAlign = "center" ;
130121 const step = niceStep (
131- xRange / Math . max ( 1 , Math . floor ( plotW / MIN_TICK_SPACING_PX ) ) ,
122+ maxX / Math . max ( 1 , Math . floor ( plotW / ( MIN_TICK_SPACING_EM * pxPerEm ) ) ) ,
132123 ) ;
133124 for ( let t = 0 ; t <= maxX ; t += step ) {
134- ctx . fillText ( formatTick ( t , step ) , tAt ( t ) , height - pad . bottom + 24 ) ;
125+ ctx . fillText (
126+ formatTick ( t , step ) ,
127+ tAt ( t ) ,
128+ height - pad . bottom + X_LABEL_GAP_EM * pxPerEm ,
129+ ) ;
135130 }
136131
137132 ctx . font = `0.95em ${ theme . family } ` ;
138- ctx . fillText ( "Time" , pad . left + plotW / 2 , height - 4 ) ;
133+ ctx . fillText (
134+ "Time" ,
135+ pad . left + plotW / 2 ,
136+ height - X_AXIS_TITLE_GAP_EM * pxPerEm ,
137+ ) ;
139138 ctx . save ( ) ;
140- ctx . translate ( 14 , pad . top + plotH / 2 ) ;
139+ ctx . translate ( Y_AXIS_TITLE_GAP_EM * pxPerEm , pad . top + plotH / 2 ) ;
141140 ctx . rotate ( - Math . PI / 2 ) ;
142141 ctx . fillText ( "Mbps" , 0 , 0 ) ;
143142 ctx . restore ( ) ;
@@ -187,32 +186,31 @@ function drawSeries(
187186 ctx . lineTo ( tAt ( samples [ i ] . x ) , yAt ( samples [ i ] . y ) ) ;
188187 }
189188 ctx . strokeStyle = theme . accent ;
190- ctx . lineWidth = 2 ;
189+ ctx . lineWidth = LINE_WIDTH_PX ;
191190 ctx . stroke ( ) ;
192191
193192 return samples . map ( ( s ) => {
194193 const x = tAt ( s . x ) ;
195194 const y = yAt ( s . y ) ;
196195 if ( showDots ) {
197196 ctx . beginPath ( ) ;
198- ctx . arc ( x , y , 4 , 0 , Math . PI * 2 ) ;
197+ ctx . arc ( x , y , DOT_RADIUS_PX , 0 , Math . PI * 2 ) ;
199198 ctx . fillStyle = theme . accent ;
200199 ctx . fill ( ) ;
201200 }
202201 return { x, y, label : s . label } ;
203202 } ) ;
204203}
205204
205+ /** Render the speedtest chart. Caller must ensure `samples` is non-empty. */
206206export function renderLineChart (
207207 canvas : HTMLCanvasElement ,
208208 samples : ChartPoint [ ] ,
209209 showDots : boolean ,
210210) : ChartPoint [ ] {
211- // Scale the backing store by DPR for crisp rendering on high-DPI
212- // displays. ctx.scale lets draw calls keep using CSS pixels.
213- const { width, height } = (
214- canvas . parentElement ?? canvas
215- ) . getBoundingClientRect ( ) ;
211+ // Scale backing store by DPR so drawing stays crisp on high-DPI screens.
212+ const parent = canvas . parentElement ?? canvas ;
213+ const { width, height } = parent . getBoundingClientRect ( ) ;
216214 const dpr = window . devicePixelRatio || 1 ;
217215 canvas . width = width * dpr ;
218216 canvas . height = height * dpr ;
@@ -222,10 +220,16 @@ export function renderLineChart(
222220 }
223221 ctx . scale ( dpr , dpr ) ;
224222
223+ const pxPerEm = parseFloat ( getComputedStyle ( parent ) . fontSize ) || 14 ;
225224 const theme = readTheme ( ) ;
226- const layout = layoutChart ( ctx , samples , width , height , theme . family ) ;
227- drawAxes ( ctx , layout , theme ) ;
228- return samples . length > 0
229- ? drawSeries ( ctx , samples , layout , theme , showDots )
230- : [ ] ;
225+ const layout = layoutChart (
226+ ctx ,
227+ samples ,
228+ width ,
229+ height ,
230+ pxPerEm ,
231+ theme . family ,
232+ ) ;
233+ drawAxes ( ctx , layout , theme , pxPerEm ) ;
234+ return drawSeries ( ctx , samples , layout , theme , showDots ) ;
231235}
0 commit comments