Use the newly util constants for determining if mobile.
[wrfxweb.git] / fdds / js / components / timeSeriesChart.js
blobd14bbc88b45052c204e3876503d4b8b45963f8e9
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';
5 /** Contents
6 * 1. Constructor
7 * 2. Initialization block
8 * 3. CreateChart block
9 * 4. LegendActions block
10 * 5. ZoomIntoData block
13 export class TimeSeriesChart extends HTMLElement {
14 /** ===== Constructor block ===== */
15 constructor() {
16 super();
17 this.innerHTML = `
18 <div id='fullContainer' class='hidden'>
19 <div>
20 <div id='addLayers' class='popout-layer-box'>
21 <span class='interactive-button'>Added Layers</span>
22 </div>
23 <div id='layers-to-add' class='popout-layer-box hidden'></div>
24 </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>
36 </div>
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>
42 </svg>
43 </button>
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>
47 </svg>
48 </button>
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>
56 </div>
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>
62 </div>
63 </div>
64 </div>
66 this.ctx = null;
67 this.chart = null;
68 this.thresholdValues = {};
69 this.thresholdLabels = {};
70 this.labels = '';
71 this.xAdjust = null;
72 this.startDate = '';
73 this.endDate = '';
76 /** ===== Initialization block ===== */
77 connectedCallback() {
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);
90 }, 100);
93 initializeChartUI() {
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) => {
136 this.zoomBox(e);
138 const zoomChange = () => {
139 this.zoomDate();
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);
152 if (points.length) {
153 const firstPoint = points[0];
154 let label = this.chart.data.labels[firstPoint.index];
155 let timestamp = localToUTC(label);
156 controllers.currentTimestamp.setValue(timestamp);
157 setURL();
162 setLayerSelection() {
163 const addLayers = this.querySelector('#addLayers');
164 const layersToAdd = this.querySelector('#layers-to-add');
165 addLayers.onpointerdown = (e) => {
166 e.stopPropagation();
167 if (layersToAdd.classList.contains('hidden')) {
168 layersToAdd.classList.remove('hidden');
169 if (!IS_MOBILE) {
170 addLayers.style.left = '-330px';
172 } else {
173 layersToAdd.classList.add('hidden');
174 if (!IS_MOBILE) {
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')) {
186 return;
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');
216 return;
219 let labels = this.createLabels(data, startDate, endDate);
221 if (this.chart) {
222 this.chart.destroy();
225 let dataset = this.createChartDataset(data);
226 this.chart = new Chart(this.ctx, {
227 type: 'line',
228 data: {
229 labels: labels,
230 datasets: dataset
232 options: this.getOptions(startDate, endDate)
234 fullContainer.classList.remove('hidden');
237 populateLayers() {
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);
260 return labels;
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;
275 let dataset = [];
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),
280 fill: false,
281 data: Object.entries(timeSeriesDataset.dataset).map(entry => entry[1]),
282 borderColor: color,
283 hidden: timeSeriesDataset.hidden,
284 spanGaps: true,
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) {
296 return color;
298 return darkenHex(color);
300 lineTension: 0,
301 borderWidth: 1,
303 dataset.push(timeSeriesData);
305 return dataset;
308 getOptions(startDate, endDate) {
309 let thresholdLabel = this.thresholdLabels[this.activeLayer];
310 if (thresholdLabel == null) {
311 thresholdLabel = '';
313 let thresholdValue = this.thresholdValues[this.activeLayer];
314 let xAxisOptions = {
315 title: {
316 display: true,
317 text: 'Timestamp'
320 if (startDate) {
321 xAxisOptions.min = startDate;
323 if (endDate) {
324 xAxisOptions.max = endDate;
327 return ({
328 animation: {
329 duration: 0
331 scales: {
332 yAxes: {
333 title: {
334 display: true,
335 text: this.activeLayer
338 xAxes: xAxisOptions
340 plugins: {
341 annotation: {
342 annotations: [{
343 display: thresholdValue !== null && !isNaN(thresholdValue),
344 type: 'line',
345 mode: 'horizontal',
346 scaleID: 'yAxes',
347 value: thresholdValue,
348 borderColor: 'rgb(255, 99, 132)',
349 borderWidth: 2,
350 label: {
351 enabled: thresholdLabel != '',
352 content: thresholdLabel,
353 xAdjust: this.xAdjust - 2*thresholdLabel.length
357 legend: {
358 display: true,
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;
392 if (open) {
393 timeSeriesMarkers[index].showMarkerInfo();
394 } else {
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]
448 if (endDate == '') {
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);
464 zoomBox(e) {
465 // get the mouse cursor position at startup:
466 e = e || window.event;
467 e.stopPropagation();
468 e.preventDefault();
469 if (e.layerY < this.chart.legend.bottom) {
470 return;
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;
523 let dataset = [];
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];
536 }));
538 return zoomData;
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;
550 e2.preventDefault();
551 e2.stopPropagation();
552 // calculate the new cursor position:
553 if (e2.clientX > boundingRect.right || e2.clientY > boundingRect.bottom) {
554 return;
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');
570 if (startDate) {
571 zoomStart.value = startDate;
573 if (endDate) {
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';
585 } else {
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;
592 if (!isNaN(yMin)) {
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);