Highcharts 係最好用嘅 plot graph javascript library 有好多款式,但當去到過萬隻 record 時就會變得好慢,而且想 zoom in 某一小段時間再 pan 咁睇唔方便;經 copilot 介紹之後,我轉左有 range selector 嘅 HighCharts Stock 唔單只可以用 mouse wheel zoom 同 drag 咁 pan 夠晒方便,過萬隻 record 都明顯無問題,所以我覺得 plot graph 盡可能用針對 x-axis 造時間軸嘅 HighCharts Stock。由線型圖嘅 Highcharts 轉用 HighCharts Stock 主要由 highcharts.js 改為用 highstock.js,setup 只係有小小唔同。
我啲 data 主要來自 8266 配啲 sensor 例如電量計或者温濕度計, 用 ArduinoJson 每 40ms generate 一條 JSON,但大部份 data 只係 1 秒 update 一次,同時方便將每 1 秒嘅 JSON log 低變成 JSON Lines format,或者叫 jsonl,比 csv 更好用,亦有 converter 可以轉番造 csv。雖然 .jsonl 有普遍運用,但未係標準 file extension,MIME type 建議用 application/x-ndjson
。

要 log data 之後方便睇返嘅話,最好就用 Arduino 係寫個 web page 放喺 8266 嘅 ESPAsyncWebServer,但因為 update flash 好麻煩,所以 html 中 body 空部係空白,只需要寫好唔會經常改嘅 head include 晒 jQuery 之類 library, 加上外掛 js 同 css 條 URL 放喺 local 嘅 Apache web server,寫嘅時候就同平時一樣只係不断改掛両個 file,用 js 加 element 入 body,再加 css style 即時睇倒效果點改變。
'use strict'; jQuery.noConflict(); /******************************************************************** setup ********************************************************************/ const mdns = window.location.hostname; let ws = {}; const jsonURL = 'log.jsonl'; const excludeKeys = ['pwm', 'heap', 'uptime', 'now', 'log']; let plotting_key = localStorage.getItem("plotting_key") || 'rssi'; let plotKeys = []; let jsonl = []; let jsonObj = {}; let highStock = {}; const htmlBody = ` <div id="container"> <div id="sidebar"> <input type="radio" name="chart" value="live" id="radio_chart_live" hidden checked><label for="radio_chart_live"><i class="fa-solid fa-chart-line"></i>Live Chart</label> <input type="radio" name="chart" value="history" id="radio_chart_history" hidden><label for="radio_chart_history"><i class="fa-solid fa-chart-area"></i>History Chart</label> <label id="btn_refresh"><i class="fa-solid fa-rotate-right"></i>Refresh</label> <label id="btn_restart"><i class="fa-solid fa-circle-notch"></i>Restart</label> <label id="btn_ap_mode"><i class="fa-solid fa-wifi"></i>AP mode</label> <label id="btn_startlog"><i class="fa-solid fa-square-caret-right"></i>Start Log</label> <label id="btn_stoplog"><i class="fa-solid fa-stop"></i>Stop Log</label> <label id="btn_clearlog"><i class="fa-solid fa-square-minus"></i>Clear log</label> <input id="upload" type="file" accept=".bin,.jsonl" name="filesystem"><label id="btn_upload" for="upload"><i class="fa-solid fa-upload"></i>Upload</label> <div id="display-jsonl-data"></div> <select id="menu-plotkey"></select> </div> <div id="chart"></div> </div> </div>`; /******************************************************************** Highcharts setup //https://www.highcharts.com/demo/stock/dynamic-update ********************************************************************/ Highcharts.setOptions({ chart: { width: 640, height: 540, backgroundColor: '#000', style: { fontFamily: 'hoi1115e', fontSize: '16', }, }, title: { enabled: false }, plotOptions: { series: { states: { hover: { enabled: false }, inactive: { opacity: 1 }, }, }, }, xAxis: { type: 'datetime', gridLineColor: '#333', tickColor: '#AAA', labels: { style: { color: '#FFF', fontSize: '1em', }, formatter: function () { return Highcharts.dateFormat('%H:%M:%S', this.value); }, }, }, yAxis: { gridLineColor: '#333', labels: { style: { color: '#FFF', fontSize: '1.5em', opacity: 0.6, }, }, }, lang: { thousandsSep: '', decimalPoint: '.', }, scrollbar: { enabled: false }, credits: { enabled: false }, rangeSelector: { enabled: false }, accessibility: { enabled: false }, }); /******************************************************************** document ready ********************************************************************/ jQuery(function () { jQuery("body").append(htmlBody); fn_ws_mdns_json(mdns, "#display-jsonl-data"); //button jQuery('#menu-plotkey').on('change', function () { const type = jQuery(this).val(); localStorage.setItem('plotting_key', type); plotting_key = type; if (jQuery('input[name="chart"]:checked').val() == 'live') { highStock.series[0].setData([]); // clear data } else if (jQuery('input[name="chart"]:checked').val() == 'history') { fn_highstock_changekey(type); } }); jQuery('input[name="chart"]').on('change', function () { //live || history if (jQuery('input[name="chart"]:checked').val() == 'live') { fn_highstock_live(); } else if (jQuery('input[name="chart"]:checked').val() == 'history') { fn_fetch_json(jsonURL); } }); jQuery('#btn_refresh').on('click', function () { location.reload(); }); jQuery('#btn_restart').on('click', function () { fn_ws_send({ WSRX: 'RESTART' }); }); jQuery('#btn_startlog').on('click', function () { fn_ws_send({ WSRX: 'STARTLOG' }); }); jQuery('#btn_stoplog').on('click', function () { fn_ws_send({ WSRX: 'STOPLOG' }); }); jQuery('#btn_clearlog').on('click', function () { fn_ws_send({ WSRX: 'CLEARLOG' }); }); jQuery('#upload').on('change', function (e) { const file = e.target.files[0]; if (!file) return; const ext = file.name.split('.').pop().toLowerCase(); if (ext == 'jsonl') { fn_read_jsonl(file); } else if (ext == 'bin') { // esp littlefs flash update const formData = new FormData(); formData.append('file', file); fetch('/update', { method: 'POST', body: formData }); } }); jQuery('#display-jsonl-data').on('click', 'li.plotkey', function () { const type = jQuery(this).data('type'); console.log(type); //no effect when live but not when static? }); setInterval(loop_40ms, 40); setInterval(loop_1000ms, 1000); }); // end of document ready /******************************************************************** loop ********************************************************************/ function loop_40ms() { if (jQuery('#radio_chart_live').is(':checked')) { if (ws.readyState == 1) { fn_list_json(jsonObj); } } } function loop_1000ms() { if (jQuery('#radio_chart_live').is(':checked')) { if (ws.readyState == 1) { const x = jsonObj["now"] * 1000; const y = jsonObj[plotting_key]; highStock.series[0].addPoint([x, y], true, false); //display all live data } else { fn_ws_mdns_json(mdns); // Reconnect after 1 second } } } /******************************************************************** functions ********************************************************************/ function fn_ws_mdns_json(mdns) { ws = new WebSocket('ws://' + mdns + '/ws'); ws.onopen = function () { console.log('WebSocket connected'); fn_highstock_live(); }; ws.onmessage = function (event) { const msg = event.data; if (typeof msg == 'string' && msg.trim().startsWith('{') && msg.trim().endsWith('}')) { try { jsonObj = JSON.parse(msg); } catch (err) { console.warn("Invalid JSON:", msg); } } else { console.log("WebSocket message:", msg); } }; ws.onclose = function () { console.log('WebSocket closed'); }; ws.onerror = function (error) { console.log('WebSocket error: ' + error); }; } function fn_list_json(json) { if (!json) return; jsonObj = json; //global jsonObj const items = Object.entries(json).map(([key, val]) => { return `<li data-type='${key}' data-val='${val}'>${key}: ${val}</li>`; }); jQuery('#display-jsonl-data').html(`<ul>${items.join('')}</ul>`); jQuery('#display-jsonl-data ul li').each(function () { const type = jQuery(this).data('type'); const keys = Object.keys(json); plotKeys = keys.filter((k) => typeof json[k] == 'number' && !excludeKeys.includes(k)); if (plotKeys.includes(type)) { jQuery(this).addClass('plotkey'); } }); jQuery('#menu-plotkey').empty(); plotKeys.forEach((key) => { jQuery('#menu-plotkey').append(`<option value="${key}">${key}</option>`); }); jQuery('#menu-plotkey').val(plotting_key); } function fn_highstock_live() { highStock = Highcharts.stockChart('chart', { series: [{ data: [], color: 'cyan', lineWidth: 1 }], tooltip: { enabled: false, shared: false, split: false, crosshairs: false }, navigator: { enabled: false }, }); } function fn_fetch_json(jsonURL) { jQuery.get(jsonURL, function (text) { try { const obj = JSON.parse(text); jsonObj = obj; jsonl = [obj]; } catch (err) { const lines = text.split('\n'); lines.forEach(function (line) { if (line.trim() !== '') { try { const obj = JSON.parse(line); jsonl.push(obj); } catch (e) { console.log('Parse error:', e); } } }); jsonObj = jsonl.at(-1); } fn_list_json(jsonObj); fn_highstock_jsonl(jsonl); }); //end of fetch } function fn_read_jsonl(file) { const reader = new FileReader(); reader.onload = function (evt) { const text = evt.target.result; const lines = text.split('\n'); jsonl = []; lines.forEach(line => { if (line.trim()) { try { const obj = JSON.parse(line); jsonl.push(obj); } catch (e) { console.warn('JSON parse error:', e); } } }); jsonObj = jsonl.at(-1); fn_list_json(jsonObj); fn_highstock_jsonl(jsonl); }; reader.readAsText(file); } function fn_highstock_jsonl(jsonl, key) { if (key == null) key = plotting_key; plotting_key = key; // Update global plotting key if (jsonl.length > 0) { const data1 = jsonl.map((obj) => { if (typeof obj[key] == 'number' && typeof obj.now == 'number') { return [obj.now * 1000, obj[key]]; } return null; }).filter(Boolean); highStock = Highcharts.stockChart('chart', { navigator: { height: 16, series: { fillOpacity: 0.3, lineWidth: 1, }, xAxis: { gridLineColor: '#999', labels: { enabled: false, }, }, outlineColor: '#333', maskFill: 'rgba(0, 255, 255, 0.3)', }, tooltip: { shared: true, formatter: function () { jsonObj = jsonl.find((obj) => obj.now == this.x / 1000); //global jsonObj fn_list_json(jsonObj); return false; }, }, series: [{ name: key, data: data1, color: 'cyan', lineWidth: 1 }] }); } } function fn_highstock_changekey(key) { const xMin = highStock.xAxis[0].min; const xMax = highStock.xAxis[0].max; const newData = jsonl.map(obj => { if (typeof obj[key] == 'number' && typeof obj.now == 'number') { return [obj.now * 1000, obj[key]]; } return null; }).filter(Boolean); highStock.series[0].update({ name: key, data: newData }, false); // false to prevent redrawing highStock.xAxis[0].setExtremes(xMin, xMax); highStock.redraw(); plotting_key = key; // Update global plotting key } function fn_ajax_post(URL) { jQuery.ajax({ url: URL, type: 'POST', }); } function fn_ws_send(data) { if (ws.readyState == 1) { ws.send(JSON.stringify(data)); console.log('WebSocket: ' + JSON.stringify(data)); } }
個 UI 有制需要同 8266 双向溝通,我用左 websocket 可以去到 poll rate 25Hz update 啲 data,可以加埋每 1秒 update 嘅 live chart。websocket server 用 Arduino 寫,websocket client 即係 broswer 用 javascript,但概念係一樣都係 setup 先閑一次之後再閑 40ms 同 1000ms 両個 loop。

我認為造設計唔可能只限識畫圖而唔識寫曲,唔了解有乜 library 可以用同造倒啲乜或者有乜特質嘅話根本唔會造得好,分工只適合以 frontend 同 backend 去分,frontend 或者 wordpress theme 啲曲唔會太難。以我寫曲嘅程度需要逐句問 copliot 咁慢慢寫,取代睇 API reference 或者 example 學用 library 坑番大量時間,尤其是用 Edge 內置嘅 copliot 有權根了解 broswer 內容解決大要溝通問題,特別寫 web 嘅 frontend 相當受惠,;同樣道理, vscode 嘅話就裝 github copliot 比佢了解寫梗段曲嘅內容。但 copliot 啲曲唔係最簡單最容易理解,對於唔係專業寫曲嘅人,我覺得最梗要寫到方便自己將來睇番都睇得明,靠 broswer 或者 compiler 驗證自己啲諗法有無問題,䀆量整理好啲簡單啲方便重復運用。
Live demo:
Reference: