@@ -10,8 +10,8 @@ const stateBgPlugin = {
1010 const xScale = chart . scales . x ;
1111 ctx . save ( ) ;
1212 bands . forEach ( band => {
13- const x1 = xScale . getPixelForValue ( band . startIdx ) ;
14- const x2 = xScale . getPixelForValue ( band . endIdx ) ;
13+ const x1 = xScale . getPixelForValue ( band . xStart ) ;
14+ const x2 = xScale . getPixelForValue ( band . xEnd ) ;
1515 if ( x1 === undefined || x2 === undefined ) return ;
1616 ctx . fillStyle = band . state === 'running'
1717 ? 'rgba(0, 201, 167, 0.07)'
@@ -46,23 +46,25 @@ function renderMeta(data) {
4646
4747function renderTimeline ( data ) {
4848 destroyTimeline ( ) ;
49+ hideSpinner ( ) ;
4950 document . getElementById ( 'dropZone' ) . style . display = 'none' ;
5051 renderMeta ( data ) ;
5152 const n = data . carbon_intensity . length ;
5253 if ( n === 0 ) return ;
5354
54- const labels = data . timestamps . map ( t => {
55- const d = new Date ( t ) ;
56- return d . toLocaleString ( undefined , { month :'short' , day :'numeric' , hour :'2-digit' , minute :'2-digit' } ) ;
55+ const ms = data . timestamps . map ( t => new Date ( t ) . getTime ( ) ) ;
56+ let offset = 0 ;
57+ const elapsedH = ms . map ( ( t , i ) => {
58+ if ( i > 0 && t < ms [ i - 1 ] ) offset += 365 * 86400000 ;
59+ return ( t + offset - ms [ 0 ] ) / 3600000 ;
5760 } ) ;
58- const idx = data . carbon_intensity . map ( ( _ , i ) => i ) ;
5961
6062 const stateBands = [ ] ;
6163 if ( data . state && data . state . length ) {
6264 let start = 0 ;
6365 for ( let i = 1 ; i <= data . state . length ; i ++ ) {
6466 if ( i === data . state . length || data . state [ i ] !== data . state [ start ] ) {
65- stateBands . push ( { startIdx : start , endIdx : i - 1 , state : data . state [ start ] } ) ;
67+ stateBands . push ( { xStart : elapsedH [ start ] , xEnd : elapsedH [ i - 1 ] , state : data . state [ start ] } ) ;
6668 start = i ;
6769 }
6870 }
@@ -80,17 +82,20 @@ function renderTimeline(data) {
8082 const thetaResume = data . theta_resume ;
8183
8284 document . getElementById ( 'timelineTitle' ) . textContent =
83- ' \u03B8\u209A=' + thetaPause + ' \u03B8\u1D63=' + thetaResume + ' \u2022 ' + n + ' steps' ;
85+ data . region + ' \u03B8\u209A=' + thetaPause + ' \u03B8\u1D63=' + thetaResume ;
8486
8587 const canvas = document . getElementById ( 'chartTimeline' ) ;
88+ const fmtHHMM = h => { const m = Math . round ( h * 60 ) ; return String ( Math . floor ( m / 60 ) ) . padStart ( 2 , '0' ) + ':' + String ( m % 60 ) . padStart ( 2 , '0' ) ; } ;
89+
90+ const xy = ( y , i ) => ( { x : elapsedH [ i ] , y } ) ;
91+
8692 timelineChart = new Chart ( canvas , {
8793 type : 'line' ,
8894 data : {
89- labels : idx ,
9095 datasets : [
9196 {
9297 label : 'Carbon Intensity' ,
93- data : data . carbon_intensity ,
98+ data : data . carbon_intensity . map ( xy ) ,
9499 borderColor : '#2e7dff' ,
95100 backgroundColor : 'rgba(46,125,255,0.1)' ,
96101 fill : true ,
@@ -101,7 +106,7 @@ function renderTimeline(data) {
101106 } ,
102107 {
103108 label : '\u03B8_pause' ,
104- data : idx . map ( ( ) => thetaPause ) ,
109+ data : [ { x : elapsedH [ 0 ] , y : thetaPause } , { x : elapsedH [ elapsedH . length - 1 ] , y : thetaPause } ] ,
105110 borderColor : '#ff5e7a' ,
106111 borderDash : [ 4 , 4 ] ,
107112 pointRadius : 0 ,
@@ -111,7 +116,7 @@ function renderTimeline(data) {
111116 } ,
112117 {
113118 label : '\u03B8_resume' ,
114- data : idx . map ( ( ) => thetaResume ) ,
119+ data : [ { x : elapsedH [ 0 ] , y : thetaResume } , { x : elapsedH [ elapsedH . length - 1 ] , y : thetaResume } ] ,
115120 borderColor : '#00c9a7' ,
116121 borderDash : [ 4 , 4 ] ,
117122 pointRadius : 0 ,
@@ -121,7 +126,7 @@ function renderTimeline(data) {
121126 } ,
122127 {
123128 label : 'Cumulative Emissions' ,
124- data : data . emissions_kg ,
129+ data : data . emissions_kg . map ( xy ) ,
125130 borderColor : '#ff9f43' ,
126131 pointRadius : 0 ,
127132 borderWidth : 1.5 ,
@@ -134,15 +139,15 @@ function renderTimeline(data) {
134139 options : {
135140 responsive : true ,
136141 maintainAspectRatio : false ,
137- interaction : { mode : 'index ' , intersect : false } ,
142+ interaction : { mode : 'nearest' , axis : 'x ', intersect : false } ,
138143 plugins : {
139144 legend : {
140145 position : 'top' ,
141146 labels : { boxWidth : 14 , padding : 14 , font : { size : 11 } } ,
142147 } ,
143148 tooltip : {
144149 callbacks : {
145- title : ctx => labels [ ctx [ 0 ] . dataIndex ] || '' ,
150+ title : ctx => { const x = ctx [ 0 ] . parsed . x ; if ( x == null ) return '' ; const m = Math . round ( x * 60 ) ; return String ( Math . floor ( m / 60 ) ) . padStart ( 2 , '0' ) + ':' + String ( m % 60 ) . padStart ( 2 , '0' ) ; } ,
146151 afterBody : ctx => {
147152 const i = ctx [ 0 ] . dataIndex ;
148153 const s = data . state && data . state [ i ] ? data . state [ i ] : '?' ;
@@ -154,8 +159,11 @@ function renderTimeline(data) {
154159 scales : {
155160 x : {
156161 type : 'linear' ,
157- title : { display : true , text : 'Step' } ,
158- ticks : { maxTicksLimit : 20 } ,
162+ title : { display : true , text : 'Elapsed Time' } ,
163+ ticks : {
164+ maxTicksLimit : 15 ,
165+ callback : v => fmtHHMM ( v ) ,
166+ } ,
159167 } ,
160168 y : {
161169 type : 'linear' ,
@@ -177,6 +185,33 @@ function renderTimeline(data) {
177185 } ) ;
178186}
179187
188+ function showSpinner ( label ) {
189+ document . getElementById ( 'spinner' ) . style . display = 'flex' ;
190+ document . querySelector ( '#spinner .spinner-label' ) . textContent = label || 'Loading...' ;
191+ }
192+ function hideSpinner ( ) {
193+ document . getElementById ( 'spinner' ) . style . display = 'none' ;
194+ }
195+
196+ function downsample ( arr , maxPoints ) {
197+ if ( arr . length <= maxPoints ) return arr ;
198+ const step = arr . length / maxPoints ;
199+ return Array . from ( { length : maxPoints } , ( _ , i ) => arr [ Math . floor ( i * step ) ] ) ;
200+ }
201+
202+ function downsampleData ( data , maxPoints ) {
203+ if ( ! data . carbon_intensity || data . carbon_intensity . length <= maxPoints ) return data ;
204+ const indices = Array . from ( { length : maxPoints } , ( _ , i ) => Math . floor ( i * data . carbon_intensity . length / maxPoints ) ) ;
205+ return {
206+ ...data ,
207+ carbon_intensity : indices . map ( i => data . carbon_intensity [ i ] ) ,
208+ state : indices . map ( i => data . state [ i ] ) ,
209+ emissions_kg : indices . map ( i => data . emissions_kg [ i ] ) ,
210+ tokens_remaining : indices . map ( i => data . tokens_remaining [ i ] ) ,
211+ timestamps : indices . map ( i => data . timestamps [ i ] ) ,
212+ } ;
213+ }
214+
180215function esc ( s ) {
181216 const d = document . createElement ( 'div' ) ;
182217 d . textContent = s ;
@@ -189,17 +224,25 @@ function loadData(data) {
189224 document . getElementById ( 'timelineTitle' ) . textContent = 'Invalid or empty timeseries file' ;
190225 return ;
191226 }
227+ const n = data . carbon_intensity . length ;
228+ const MAX = 3200 ;
229+ if ( n > MAX ) {
230+ data = downsampleData ( data , MAX ) ;
231+ }
192232 renderTimeline ( data ) ;
193233}
194234
195235// ── File drop (local file:// usage, or fallback) ─────────────────
196236function loadFile ( file ) {
237+ showSpinner ( 'Parsing ' + file . name + '...' ) ;
197238 const reader = new FileReader ( ) ;
198239 reader . onload = e => {
199240 document . getElementById ( 'fileLabel' ) . textContent = file . name ;
241+ hideSpinner ( ) ;
200242 try {
201243 loadData ( JSON . parse ( e . target . result ) ) ;
202244 } catch ( err ) {
245+ hideSpinner ( ) ;
203246 alert ( 'Error parsing JSON: ' + err . message ) ;
204247 }
205248 } ;
@@ -224,12 +267,15 @@ const params = new URLSearchParams(window.location.search);
224267const id = params . get ( 'id' ) ;
225268
226269if ( window . location . protocol . startsWith ( 'http' ) && id != null ) {
270+ showSpinner ( 'Loading timeseries for run ' + id + '...' ) ;
227271 fetch ( 'timeseries/' + id + '.json' ) . then ( r => {
228272 if ( ! r . ok ) throw new Error ( 'Not found' ) ;
229273 return r . json ( ) ;
230274 } ) . then ( data => {
275+ hideSpinner ( ) ;
231276 loadData ( data ) ;
232277 } ) . catch ( ( ) => {
278+ hideSpinner ( ) ;
233279 document . getElementById ( 'dropZone' ) . style . display = 'flex' ;
234280 document . getElementById ( 'timelineTitle' ) . textContent = 'No timeseries data for id=' + id + ' — drop a .json file' ;
235281 } ) ;
0 commit comments