@@ -10,11 +10,47 @@ export interface ChartPoint {
1010}
1111
1212export interface ChartData {
13- labels : string [ ] ;
13+ /** Time positions for each data point (used for proportional x-spacing) */
14+ xValues : number [ ] ;
1415 values : number [ ] ;
1516 pointLabels : string [ ] ;
1617}
1718
19+ /** Points above this count are drawn as a line only (no dots). */
20+ export const DOT_THRESHOLD = 20 ;
21+
22+ const DOT_RADIUS = 4 ;
23+ const MIN_TICK_SPACING = 48 ;
24+ const LEADER_OPACITY = 0.4 ;
25+ const Y_GRID_LINES = 5 ;
26+ const Y_HEADROOM = 1.1 ;
27+
28+ /** Pick a "nice" step size that is >= the raw step. */
29+ const NICE_STEPS = [
30+ 1 , 2 , 5 , 10 , 15 , 20 , 30 , 60 , 120 , 300 , 600 , 900 , 1800 , 3600 ,
31+ ] ;
32+
33+ function niceStep ( raw : number ) : number {
34+ return NICE_STEPS . find ( ( s ) => s >= raw ) ?? Math . ceil ( raw / 3600 ) * 3600 ;
35+ }
36+
37+ /** Build a tick formatter that uses a single unit for the entire axis. */
38+ function tickFormatter ( step : number ) : ( t : number ) => string {
39+ if ( step >= 3600 ) {
40+ return ( t ) => {
41+ const h = t / 3600 ;
42+ return `${ Number . isInteger ( h ) ? h : h . toFixed ( 1 ) } h` ;
43+ } ;
44+ }
45+ if ( step >= 60 ) {
46+ return ( t ) => {
47+ const m = t / 60 ;
48+ return `${ Number . isInteger ( m ) ? m : m . toFixed ( 1 ) } m` ;
49+ } ;
50+ }
51+ return ( t ) => `${ t } s` ;
52+ }
53+
1854/**
1955 * Draw a line chart on the given canvas and return hit-test positions.
2056 */
@@ -36,17 +72,11 @@ export function renderLineChart(
3672 }
3773 ctx . scale ( dpr , dpr ) ;
3874
39- const pad = { top : 24 , right : 24 , bottom : 52 , left : 72 } ;
40- const plotW = width - pad . left - pad . right ;
41- const plotH = height - pad . top - pad . bottom ;
42- const maxVal = Math . max ( ...data . values , 1 ) * 1.1 ;
4375 const n = data . values . length ;
76+ const maxVal = Math . max ( ...data . values , 1 ) * Y_HEADROOM ;
77+ const maxX = n > 0 ? data . xValues [ n - 1 ] : 1 ;
78+ const xRange = maxX || 1 ;
4479
45- // Coordinate helpers
46- const xAt = ( i : number ) => pad . left + ( i / Math . max ( n - 1 , 1 ) ) * plotW ;
47- const yAt = ( v : number ) => pad . top + plotH - ( v / maxVal ) * plotH ;
48-
49- // Read VS Code theme
5080 const s = getComputedStyle ( document . documentElement ) ;
5181 const css = ( prop : string ) => s . getPropertyValue ( prop ) . trim ( ) ;
5282 const fg =
@@ -60,55 +90,55 @@ export function renderLineChart(
6090 const grid = css ( "--vscode-editorWidget-border" ) || "rgba(128,128,128,0.15)" ;
6191 const family = css ( "--vscode-font-family" ) || "sans-serif" ;
6292
93+ ctx . font = `1em ${ family } ` ;
94+ const yLabelWidth = ctx . measureText ( maxVal . toFixed ( 0 ) ) . width ;
95+ const pad = {
96+ top : 24 ,
97+ right : 24 ,
98+ bottom : 52 ,
99+ left : Math . max ( 48 , yLabelWidth + 24 ) ,
100+ } ;
101+ const plotW = width - pad . left - pad . right ;
102+ const plotH = height - pad . top - pad . bottom ;
103+
104+ const tAt = ( t : number ) => pad . left + ( t / xRange ) * plotW ;
105+ const xAt = ( i : number ) => tAt ( data . xValues [ i ] ) ;
106+ const yAt = ( v : number ) => pad . top + plotH - ( v / maxVal ) * plotH ;
107+
63108 // ── Axes ──
64109
65- // Y-axis grid lines and labels
66110 ctx . strokeStyle = grid ;
67111 ctx . lineWidth = 1 ;
68112 ctx . fillStyle = fg ;
69- ctx . font = `1em ${ family } ` ;
70113 ctx . textAlign = "right" ;
71- for ( let i = 0 ; i <= 5 ; i ++ ) {
72- const y = yAt ( ( i / 5 ) * maxVal ) ;
114+ for ( let i = 0 ; i <= Y_GRID_LINES ; i ++ ) {
115+ const y = yAt ( ( i / Y_GRID_LINES ) * maxVal ) ;
73116 ctx . beginPath ( ) ;
74117 ctx . moveTo ( pad . left , y ) ;
75118 ctx . lineTo ( pad . left + plotW , y ) ;
76119 ctx . stroke ( ) ;
77- ctx . fillText ( ( ( i / 5 ) * maxVal ) . toFixed ( 0 ) , pad . left - 12 , y + 5 ) ;
120+ ctx . fillText (
121+ ( ( i / Y_GRID_LINES ) * maxVal ) . toFixed ( 0 ) ,
122+ pad . left - 12 ,
123+ y + 5 ,
124+ ) ;
78125 }
79126
80- // Bottom axis line
81127 ctx . strokeStyle = fg ;
82128 ctx . beginPath ( ) ;
83129 ctx . moveTo ( pad . left , pad . top + plotH ) ;
84130 ctx . lineTo ( pad . left + plotW , pad . top + plotH ) ;
85131 ctx . stroke ( ) ;
86132
87- // X-axis labels (auto-thinned, deduped)
88133 ctx . textAlign = "center" ;
89134 ctx . fillStyle = fg ;
90- const maxLabels = Math . floor ( plotW / 60 ) ;
91- const step = Math . max ( 1 , Math . ceil ( n / maxLabels ) ) ;
92- let lastDrawnLabel = "" ;
93- let lastDrawnX = - Infinity ;
94- for ( let i = 0 ; i < n ; i += step ) {
95- if ( data . labels [ i ] !== lastDrawnLabel ) {
96- ctx . fillText ( data . labels [ i ] , xAt ( i ) , height - pad . bottom + 24 ) ;
97- lastDrawnLabel = data . labels [ i ] ;
98- lastDrawnX = xAt ( i ) ;
99- }
100- }
101- const last = n - 1 ;
102- if (
103- last > 0 &&
104- last % step !== 0 &&
105- data . labels [ last ] !== lastDrawnLabel &&
106- xAt ( last ) - lastDrawnX > 50
107- ) {
108- ctx . fillText ( data . labels [ last ] , xAt ( last ) , height - pad . bottom + 24 ) ;
135+ const maxTicks = Math . max ( 1 , Math . floor ( plotW / MIN_TICK_SPACING ) ) ;
136+ const tickStep = niceStep ( xRange / maxTicks ) ;
137+ const formatTick = tickFormatter ( tickStep ) ;
138+ for ( let t = 0 ; t <= maxX ; t += tickStep ) {
139+ ctx . fillText ( formatTick ( t ) , tAt ( t ) , height - pad . bottom + 24 ) ;
109140 }
110141
111- // Axis titles
112142 ctx . font = `0.95em ${ family } ` ;
113143 ctx . fillText ( "Time" , pad . left + plotW / 2 , height - 4 ) ;
114144 ctx . save ( ) ;
@@ -124,10 +154,23 @@ export function renderLineChart(
124154 // ── Series ──
125155
126156 const baseline = pad . top + plotH ;
157+ const firstPx = xAt ( 0 ) ;
158+
159+ if ( data . xValues [ 0 ] > 0 ) {
160+ ctx . beginPath ( ) ;
161+ ctx . moveTo ( tAt ( 0 ) , baseline ) ;
162+ ctx . lineTo ( firstPx , yAt ( data . values [ 0 ] ) ) ;
163+ ctx . setLineDash ( [ 4 , 4 ] ) ;
164+ ctx . strokeStyle = accent ;
165+ ctx . lineWidth = 1 ;
166+ ctx . globalAlpha = LEADER_OPACITY ;
167+ ctx . stroke ( ) ;
168+ ctx . setLineDash ( [ ] ) ;
169+ ctx . globalAlpha = 1 ;
170+ }
127171
128- // Fill area
129172 ctx . beginPath ( ) ;
130- ctx . moveTo ( xAt ( 0 ) , baseline ) ;
173+ ctx . moveTo ( firstPx , baseline ) ;
131174 for ( let i = 0 ; i < n ; i ++ ) {
132175 ctx . lineTo ( xAt ( i ) , yAt ( data . values [ i ] ) ) ;
133176 }
@@ -139,7 +182,6 @@ export function renderLineChart(
139182 ctx . fillStyle = gradient ;
140183 ctx . fill ( ) ;
141184
142- // Line
143185 ctx . beginPath ( ) ;
144186 ctx . moveTo ( xAt ( 0 ) , yAt ( data . values [ 0 ] ) ) ;
145187 for ( let i = 1 ; i < n ; i ++ ) {
@@ -149,15 +191,14 @@ export function renderLineChart(
149191 ctx . lineWidth = 2 ;
150192 ctx . stroke ( ) ;
151193
152- // Dots and hit-test positions
153- const showDots = n <= 50 ;
194+ const showDots = n <= DOT_THRESHOLD ;
154195 const points : ChartPoint [ ] = [ ] ;
155196 for ( let i = 0 ; i < n ; i ++ ) {
156197 const x = xAt ( i ) ;
157198 const y = yAt ( data . values [ i ] ) ;
158199 if ( showDots ) {
159200 ctx . beginPath ( ) ;
160- ctx . arc ( x , y , 4 , 0 , Math . PI * 2 ) ;
201+ ctx . arc ( x , y , DOT_RADIUS , 0 , Math . PI * 2 ) ;
161202 ctx . fillStyle = accent ;
162203 ctx . fill ( ) ;
163204 }
0 commit comments