1 import { utcToLocal
, createOption
, linkSelects
, localToUTC
, setURL
, dragElement
, darkenHex
, debounce
, buildCheckBox
, IS_MOBILE
} from '../util.js';
2 import { controllers
} from '../components/Controller.js';
3 import { simVars
} from '../simVars.js';
7 * 2. Initialization block
9 * 4. LegendActions block
10 * 5. ZoomIntoData block
13 export class TimeSeriesChart
extends HTMLElement
{
14 /** ===== Constructor block ===== */
18 <div id='fullContainer' class='hidden'>
20 <div id='addLayers' class='popout-layer-box'>
21 <span class='interactive-button'>Added Layers</span>
23 <div id='layers-to-add' class='popout-layer-box hidden'></div>
25 <div id='timeSeriesChartContainer'>
26 <div id='legendOptions' class='hidden'>
27 <div class='interactive-button close-panel' id='closeLegendOptions'>x</div>
28 <label class='legendItem' for'openMarker'>Open Marker Info</label>
29 <input class='legendItem' type='checkbox' id='openMarker'/>
30 <label class='legendItem' for='hideData'>Hide Data: </label>
31 <input class='legendItem' type='checkbox' id='hideData'/>
32 <label class='legendItem' for='timeseriesColorCode'>Change Color: </label>
33 <input class='legendItem' type='color' id='timeseriesColorCode'></input>
34 <label class='legendItem' for='addChangeName'>Add Name:</label>
35 <input class='legendItem' id='addChangeName'></input>
37 <div id='zoomBox'></div>
38 <div class='interactive-button close-panel' id='closeTimeSeriesChart'>x</div>
39 <button id='drag-container' class='interactive-button'>
40 <svg class='interactive-button' height=15 width=15>
41 <use href='#open_with_black_24dp'></use>
44 <button id='undo-zoom' class='interactive-button hidden'>
45 <svg class='interactive-button' height=15 width=15>
46 <use href='#undo_black_24dp'></use>
49 <canvas id='timeSeriesChart' width='400px' height='400px'></canvas>
50 <div id='break' class='section-break'></div>
51 <div id='add-threshold'>
52 <label class='legendItem' for='threshold-setter'>y-axis threshold: </label>
53 <input class='legendInput' id='threshold-setter'></input>
54 <label class='legendItem' for='threshold-label'>threshold label: </label>
55 <input class='legendInput' id='threshold-label'></input>
57 <div id='zoomIn' style='display: inline-block; margin-top: 10px'>
58 <label class='legendItem' for='zoom-start'>zoom in start: </label>
59 <select class='legendSelect' id='zoom-start'></select>
60 <label class='legendItem' for='zoom-end'>zoom in end: </label>
61 <select class='legendSelect' id='zoom-end'></select>
68 this.thresholdValues
= {};
69 this.thresholdLabels
= {};
76 /** ===== Initialization block ===== */
78 this.initializeChartUI();
79 const timeSeries
= this.querySelector('#timeSeriesChart');
80 this.ctx
= timeSeries
.getContext('2d');
82 this.updateDataOnRemove();
83 this.setThresholdOptions();
84 this.setZoomOptions(timeSeries
);
85 this.setDataClicking(timeSeries
);
86 this.setLayerSelection();
88 this.debouncedPopulateChart
= debounce((chartArgs
) => {
89 this.populateChartCallback(chartArgs
);
94 const timeSeriesChart
= this.querySelector('#timeSeriesChartContainer');
95 const fullContainer
= this.querySelector('#fullContainer');
97 L
.DomEvent
.disableScrollPropagation(timeSeriesChart
);
98 L
.DomEvent
.disableClickPropagation(timeSeriesChart
);
100 dragElement(fullContainer
, 'drag-container');
101 this.querySelector('#closeTimeSeriesChart').onclick
= () => {
102 this.thresholdLabels
= {};
103 this.thresholdValues
= {};
104 fullContainer
.classList
.add('hidden');
107 this.querySelector('#closeTimeSeriesChart').onclick
= () => {
108 this.thresholdLabels
= {};
109 this.thresholdValues
= {};
110 fullContainer
.classList
.add('hidden');
114 setThresholdOptions() {
115 const thresholdSetter
= this.querySelector('#threshold-setter');
116 const labelSetter
= this.querySelector('#threshold-label');
118 thresholdSetter
.value
= '';
119 labelSetter
.value
= '';
120 thresholdSetter
.oninput
= () => {
121 this.thresholdValues
[this.activeLayer
] = thresholdSetter
.value
;
122 this.populateChart(this.allData
, this.startDate
, this.endDate
, this.activeLayer
);
124 labelSetter
.oninput
= () => {
125 this.thresholdLabels
[this.activeLayer
] = labelSetter
.value
;
126 this.populateChart(this.allData
, this.startDate
, this.endDate
, this.activeLayer
);
130 setZoomOptions(timeSeries
) {
131 const zoomStart
= this.querySelector('#zoom-start');
132 const zoomEnd
= this.querySelector('#zoom-end');
133 const undoZoom
= this.querySelector('#undo-zoom');
135 timeSeries
.addEventListener('pointerdown', (e
) => {
138 const zoomChange
= () => {
141 zoomStart
.onchange
= zoomChange
;
142 zoomEnd
.onchange
= zoomChange
;
143 undoZoom
.onclick
= () => {
144 undoZoom
.classList
.add('none');
145 this.populateChart(this.allData
, '', '', this.activeLayer
);
149 setDataClicking(timeSeries
) {
150 timeSeries
.addEventListener('pointerdown', (evt
) => {
151 const points
= this.chart
.getElementsAtEventForMode(evt
, 'nearest', { intersect
: true }, true);
153 const firstPoint
= points
[0];
154 let label
= this.chart
.data
.labels
[firstPoint
.index
];
155 let timestamp
= localToUTC(label
);
156 controllers
.currentTimestamp
.setValue(timestamp
);
162 setLayerSelection() {
163 const addLayers
= this.querySelector('#addLayers');
164 const layersToAdd
= this.querySelector('#layers-to-add');
165 addLayers
.onpointerdown
= (e
) => {
167 if (layersToAdd
.classList
.contains('hidden')) {
168 layersToAdd
.classList
.remove('hidden');
170 addLayers
.style
.left
= '-330px';
173 layersToAdd
.classList
.add('hidden');
175 addLayers
.style
.left
= '-80px';
181 updateDataOnRemove() {
182 const legendOptions
= this.querySelector('#legendOptions');
183 const chart
= this.querySelector('#fullContainer');
184 const updateData
= (index
) => {
185 if (chart
.classList
.contains('hidden')) {
188 legendOptions
.classList
.add('hidden');
189 for (let layerName
in this.allData
) {
190 let data
= this.allData
[layerName
];
191 data
.splice(index
, 1);
193 this.populateChart(this.allData
, this.startDate
, this.endDate
, this.activeLayer
);
195 let markerController
= controllers
.timeSeriesMarkers
;
196 markerController
.subscribe(updateData
, markerController
.removeEvent
);
199 /** ===== CreateChart block ===== */
200 populateChart(data
, startDate
='', endDate
='', activeLayer
=simVars
.displayedColorbar
) {
201 this.debouncedPopulateChart([data
, startDate
, endDate
, activeLayer
]);
204 populateChartCallback([allData
, startDate
='', endDate
='', activeLayer
=simVars
.displayedColorbar
]) {
205 const fullContainer
= this.querySelector('#fullContainer');
207 this.activeLayer
= activeLayer
;
208 this.startDate
= startDate
;
209 this.endDate
= endDate
;
210 this.allData
= allData
;
211 this.populateLayers();
212 this.setThresholdValues();
213 let data
= allData
[activeLayer
];
214 if (data
.length
== 0) {
215 fullContainer
.classList
.add('hidden');
219 let labels
= this.createLabels(data
, startDate
, endDate
);
222 this.chart
.destroy();
225 let dataset
= this.createChartDataset(data
);
226 this.chart
= new Chart(this.ctx
, {
232 options
: this.getOptions(startDate
, endDate
)
234 fullContainer
.classList
.remove('hidden');
238 const selectLayers
= this.querySelector('#layers-to-add');
239 selectLayers
.innerHTML
= '';
240 const selectCallback
= (layerName
) => {
241 this.activeLayer
= layerName
;
242 this.populateChart(this.allData
, this.startDate
, this.endDate
, this.activeLayer
);
244 for (let layerName
in this.allData
) {
245 let checked
= layerName
== this.activeLayer
;
246 let checkbox
= buildCheckBox(layerName
, 'checkbox', 'chartLayer',
247 checked
, selectCallback
, layerName
);
248 checkbox
.className
= 'layerCheckbox';
249 selectLayers
.appendChild(checkbox
);
253 createLabels(data
, startDate
, endDate
) {
254 let labels
= Object
.keys(data
[0].dataset
).map(timeStamp
=> {
255 return utcToLocal(timeStamp
);
257 this.labels
= labels
;
258 this.populateZoomSelectors(labels
, startDate
, endDate
);
263 setThresholdValues() {
264 const thresholdSetter
= this.querySelector('#threshold-setter');
265 const labelSetter
= this.querySelector('#threshold-label');
267 let thresholdLabel
= this.thresholdLabels
[this.activeLayer
];
268 let thresholdValue
= this.thresholdValues
[this.activeLayer
];
269 labelSetter
.value
= (thresholdLabel
== null) ? '' : thresholdLabel
;
270 thresholdSetter
.value
= (thresholdValue
== null) ? '' : thresholdValue
;
273 createChartDataset(data
) {
274 const roundLatLon
= (num
) => Math
.round(num
*100) / 100;
276 for (let timeSeriesDataset
of data
) {
277 let color
= timeSeriesDataset
.color
; // use let here to create block scope
278 let timeSeriesData
= {
279 label
: timeSeriesDataset
.label
+ ' values at lat: ' + roundLatLon(timeSeriesDataset
.latLon
.lat
) + ' lon: ' + roundLatLon(timeSeriesDataset
.latLon
.lng
),
281 data
: Object
.entries(timeSeriesDataset
.dataset
).map(entry
=> entry
[1]),
283 hidden
: timeSeriesDataset
.hidden
,
285 backgroundColor
: color
,
286 pointBackgroundColor
: (context
) => {
287 let index
= context
.dataIndex
;
288 let timestamp
= this.labels
[index
];
289 let currentDomain
= controllers
.currentDomain
.getValue();
290 if (simVars
.noLevels
.has(simVars
.displayedColorbar
, currentDomain
, timestamp
)) {
291 return `rgb(256, 256, 256)`
293 let thresholdValue
= this.thresholdValues
[this.activeLayer
];
294 let value
= context
.dataset
.data
[index
];
295 if (thresholdValue
=== '' || isNaN(thresholdValue
) || value
> thresholdValue
) {
298 return darkenHex(color
);
303 dataset
.push(timeSeriesData
);
308 getOptions(startDate
, endDate
) {
309 let thresholdLabel
= this.thresholdLabels
[this.activeLayer
];
310 if (thresholdLabel
== null) {
313 let thresholdValue
= this.thresholdValues
[this.activeLayer
];
321 xAxisOptions
.min
= startDate
;
324 xAxisOptions
.max
= endDate
;
335 text
: this.activeLayer
343 display
: thresholdValue
!== null && !isNaN(thresholdValue
),
347 value
: thresholdValue
,
348 borderColor
: 'rgb(255, 99, 132)',
351 enabled
: thresholdLabel
!= '',
352 content
: thresholdLabel
,
353 xAdjust
: this.xAdjust
- 2*thresholdLabel
.length
359 onClick
: (e
, legendItem
, legend
) => {
360 this.legendClick(legendItem
);
367 /** ===== LegendActions block ===== */
368 legendClick(legendItem
) {
369 let index
= legendItem
.datasetIndex
;
370 let timeSeriesMarkers
= controllers
.timeSeriesMarkers
.getValue();
371 let timeSeriesMarker
= timeSeriesMarkers
[index
].getContent();
373 this.setOpeningMarker(index
, timeSeriesMarker
);
374 this.setHidingDataOnChart(index
, timeSeriesMarker
);
375 this.setDataColor(index
, timeSeriesMarker
);
376 this.setAddingName(index
, timeSeriesMarker
);
378 const legendOptions
= this.querySelector('#legendOptions');
379 const closeLegendOptions
= this.querySelector('#closeLegendOptions');
380 closeLegendOptions
.onclick
= () => {
381 legendOptions
.classList
.add('hidden');
383 legendOptions
.classList
.remove('hidden');
386 setOpeningMarker(index
, timeSeriesMarker
) {
387 let timeSeriesMarkers
= controllers
.timeSeriesMarkers
.getValue();
388 const openMarker
= this.querySelector('#openMarker');
389 openMarker
.checked
= timeSeriesMarker
.infoOpen
;
390 openMarker
.oninput
= () => {
391 let open
= openMarker
.checked
;
393 timeSeriesMarkers
[index
].showMarkerInfo();
395 timeSeriesMarkers
[index
].hideMarkerInfo();
400 setHidingDataOnChart(index
, timeSeriesMarker
) {
401 const hideData
= this.querySelector('#hideData');
402 let dataPoint
= this.allData
[this.activeLayer
][index
];
403 hideData
.checked
= dataPoint
.hidden
;
404 hideData
.oninput
= () => {
405 let hidden
= hideData
.checked
;
406 for (let layerName
in this.allData
) {
407 let data
= this.allData
[layerName
];
408 data
[index
].hidden
= hidden
;
410 timeSeriesMarker
.hideOnChart
= hidden
;
411 this.populateChart(this.allData
, this.startDate
, this.endDate
, this.activeLayer
);
415 setDataColor(index
, timeSeriesMarker
) {
416 const colorInput
= this.querySelector('#timeseriesColorCode');
417 colorInput
.value
= this.allData
[this.activeLayer
][index
].color
;
419 colorInput
.oninput
= () => {
420 for (let layerName
in this.allData
) {
421 let data
= this.allData
[layerName
];
422 data
[index
].color
= colorInput
.value
;
424 timeSeriesMarker
.setChartColor(colorInput
.value
);
425 this.populateChart(this.allData
, this.startDate
, this.endDate
, this.activeLayer
);
429 setAddingName(index
, timeSeriesMarker
) {
430 const addChangeName
= this.querySelector('#addChangeName');
431 addChangeName
.value
= timeSeriesMarker
.getName();
432 addChangeName
.oninput
= () => {
433 timeSeriesMarker
.setName(addChangeName
.value
);
434 for (let layerName
in this.allData
) {
435 let data
= this.allData
[layerName
];
436 data
[index
].label
= addChangeName
.value
;
439 this.populateChart(this.allData
, this.startDate
, this.endDate
, this.activeLayer
);
443 /** ===== ZoomIntoData block ===== */
444 populateZoomSelectors(timeStamps
, startDate
, endDate
) {
445 if (startDate
== '') {
446 startDate
= timeStamps
[0]
449 endDate
= timeStamps
[timeStamps
.length
- 1];
451 const zoomStart
= this.querySelector('#zoom-start');
452 const zoomEnd
= this.querySelector('#zoom-end');
453 zoomStart
.innerHTML
= '';
454 zoomEnd
.innerHTML
= '';
455 for (let timeStamp
of timeStamps
) {
456 zoomStart
.appendChild(createOption(timeStamp
, false));
457 zoomEnd
.appendChild(createOption(timeStamp
, false));
459 zoomStart
.value
= startDate
;
460 zoomEnd
.value
= endDate
;
461 linkSelects(zoomStart
, zoomEnd
);
465 // get the mouse cursor position at startup:
466 e
= e
|| window
.event
;
469 if (e
.layerY
< this.chart
.legend
.bottom
) {
472 this.initializeZoomBox(e
.clientX
, e
.clientY
);
474 let [zoomLeft
, zoomRight
, zoomTop
, zoomBottom
] = [e
.clientX
, e
.clientX
, e
.clientY
, e
.clientY
];
475 let zoomCoords
= {zoomLeft
: zoomLeft
, zoomRight
: zoomRight
, zoomTop
: zoomTop
, zoomBottom
: zoomBottom
};
476 this.setDrawingBoxCompletion(zoomCoords
);
477 this.setDrawingBoxUpdate(zoomCoords
);
480 initializeZoomBox(startX
, startY
) {
481 const zoomBoxArea
= this.querySelector('#zoomBox');
483 // position the drawn box
484 zoomBoxArea
.style
.width
= '0px';
485 zoomBoxArea
.style
.height
= '0px';
486 zoomBoxArea
.style
.display
= 'block';
487 zoomBoxArea
.style
.left
= startX
+ 'px';
488 zoomBoxArea
.style
.top
= startY
+ 'px';
491 setDrawingBoxCompletion(zoomCoords
) {
492 const zoomBoxArea
= this.querySelector('#zoomBox');
494 document
.onpointerup
= () => {
495 document
.onpointerup
= null;
496 document
.onpointermove
= null;
497 zoomBoxArea
.style
.display
= 'none';
499 let zoomData
= this.getDataInZoomBox(zoomCoords
);
500 let labelIndices
= zoomData
.map(dataset
=> dataset
.map(data
=> data
[0]));
501 let yValues
= zoomData
.map(dataset
=> dataset
.map(data
=> data
[1]));
502 // get the min/max indices and values to set the bound of the chart
503 const minValue
= (values
) => Math
.min(...values
.map(dataValues
=> Math
.min(...dataValues
)));
504 const maxValue
= (values
) => Math
.max(...values
.map(dataValues
=> Math
.max(...dataValues
)));
505 let [minIndex
, maxIndex
, yMin
, yMax
] = [minValue(labelIndices
), maxValue(labelIndices
), minValue(yValues
), maxValue(yValues
)];
506 // if there are selected points zoom the chart to them
507 if (yMax
> -Infinity
) {
508 minIndex
= Math
.max(0, minIndex
- 1);
509 maxIndex
= Math
.min(maxIndex
+ 1, this.labels
.length
- 1);
510 yMin
= yMin
- .01*yMin
;
511 yMax
= yMax
+ .01*yMax
;
512 this.zoomDate(this.labels
[minIndex
], this.labels
[maxIndex
], yMin
, yMax
);
513 this.chart
.update(this.allData
[this.activeLayer
]);
518 getDataInZoomBox(zoomCoords
) {
519 const canvas
= this.querySelector('#timeSeriesChart');
520 let boundingRect
= canvas
.getBoundingClientRect();
521 let {zoomLeft
, zoomRight
, zoomTop
, zoomBottom
} = zoomCoords
;
524 let dataLength
= this.allData
[this.activeLayer
].length
;
525 for (let i
= 0; i
< dataLength
; i
++) {
526 dataset
.push(this.chart
.getDatasetMeta(i
).data
);
529 // get the index and y value of each data point that is inside the drawn box
530 let zoomData
= dataset
.map(data
=> data
.filter(datapoint
=> {
531 let xCheck
= datapoint
.x
>= zoomLeft
- boundingRect
.left
&& datapoint
.x
<= zoomRight
- boundingRect
.left
;
532 let yCheck
= datapoint
.y
>= zoomTop
- boundingRect
.top
&& datapoint
.y
<= zoomBottom
- boundingRect
.top
;
533 return xCheck
&& yCheck
;
534 }).map(datapoint
=> {
535 return [datapoint
.parsed
.x
, datapoint
.parsed
.y
];
541 setDrawingBoxUpdate(zoomCoords
) {
542 const canvas
= this.querySelector('#timeSeriesChart');
543 const zoomBoxArea
= this.querySelector('#zoomBox');
544 let boundingRect
= canvas
.getBoundingClientRect();
545 let {zoomLeft
, zoomTop
} = zoomCoords
;
547 // call a function whenever the cursor moves: draws a zoombox
548 document
.onpointermove
= (e2
) => {
549 e2
= e2
|| window
.event
;
551 e2
.stopPropagation();
552 // calculate the new cursor position:
553 if (e2
.clientX
> boundingRect
.right
|| e2
.clientY
> boundingRect
.bottom
) {
556 let xDiff
= e2
.clientX
- zoomLeft
;
557 let yDiff
= e2
.clientY
- zoomTop
;
559 zoomCoords
.zoomRight
= zoomLeft
+ xDiff
;
560 zoomCoords
.zoomBottom
= zoomTop
+ yDiff
;
561 zoomBoxArea
.style
.width
= xDiff
+ 'px';
562 zoomBoxArea
.style
.height
= yDiff
+ 'px';
566 zoomDate(startDate
= '', endDate
= '', yMin
= NaN
, yMax
= NaN
) {
567 const zoomStart
= this.querySelector('#zoom-start');
568 const zoomEnd
= this.querySelector('#zoom-end');
569 const undoZoom
= this.querySelector('#undo-zoom');
571 zoomStart
.value
= startDate
;
574 zoomEnd
.value
= endDate
;
576 this.startDate
= startDate
;
577 this.endDate
= endDate
;
578 linkSelects(zoomStart
, zoomEnd
);
579 let startCheck
= zoomStart
.value
== this.labels
[0];
580 let endCheck
= zoomEnd
.value
== this.labels
[this.labels
.length
- 1];
581 let yAxisCheck
= isNaN(yMin
);
582 if (startCheck
&& endCheck
&& yAxisCheck
) {
583 undoZoomD
.classList
.add('hidden');
584 undoZoomDisplay
= 'none';
586 undoZoom
.classList
.remove('hidden');
588 this.chart
.options
.scales
.xAxes
.min
= zoomStart
.value
;
589 this.chart
.options
.scales
.xAxes
.max
= zoomEnd
.value
;
590 delete this.chart
.options
.scales
.yAxes
.min
;
591 delete this.chart
.options
.scales
.yAxes
.max
;
593 this.chart
.options
.scales
.yAxes
.min
= yMin
;
594 this.chart
.options
.scales
.yAxes
.max
= yMax
;
596 this.chart
.update(this.allData
[this.activeLayer
]);
600 window
.customElements
.define('timeseries-chart', TimeSeriesChart
);