From 24e8435ea17b176a3ff4075e24ab28c4aa6cbdf9 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 12 Jun 2026 09:58:13 +0200 Subject: [PATCH 01/28] feat: use ECharts to make the asset charts faster and more interactive Signed-off-by: Ahmad-Wahid --- flexmeasures/ui/static/js/chart-perf.js | 172 +++++++ flexmeasures/ui/static/js/fast-chart.js | 427 ++++++++++++++++++ .../ui/templates/assets/asset_graph.html | 6 + flexmeasures/ui/templates/base.html | 1 + .../ui/templates/includes/graphs.html | 165 ++++++- flexmeasures/ui/templates/sensors/index.html | 6 + flexmeasures/ui/utils/chart_defaults.py | 6 +- 7 files changed, 777 insertions(+), 6 deletions(-) create mode 100644 flexmeasures/ui/static/js/chart-perf.js create mode 100644 flexmeasures/ui/static/js/fast-chart.js diff --git a/flexmeasures/ui/static/js/chart-perf.js b/flexmeasures/ui/static/js/chart-perf.js new file mode 100644 index 0000000000..560affb6f0 --- /dev/null +++ b/flexmeasures/ui/static/js/chart-perf.js @@ -0,0 +1,172 @@ +/** + * Chart load performance reporting. + * + * Measures every chart load (initial page load, date range changes, data + * refreshes, renderer switches) and produces a report with: + * - per-request network timings and payload sizes (via the Resource Timing API) + * - chart render time (measured by the caller around the render calls) + * - number of data rows and total wall-clock time + * + * Reports are shown in a small floating panel (only on pages that load a + * chart), logged with console.table, and downloadable as JSON. + * + * Usage: + * const report = beginChartLoad("initial page load", "standard (Vega-Lite)"); + * ... fetch data and render, wrapping render calls with recordRender ... + * finishChartLoad(report, { rows: data.length }); + */ + +const history = []; +const MAX_HISTORY = 20; + +// Endpoints that count as chart traffic +const CHART_ENDPOINT_PATTERNS = [ + "/chart_data", + "/chart?", + "/chart_annotations", + "/kpis", +]; + +/** + * Start measuring a chart load. + * + * @param {string} label - What triggered the load (e.g. "initial page load"). + * @param {string} mode - Which renderer is active (e.g. "fast (ECharts)"). + * @returns {Object} - The report object to pass to recordRender/finishChartLoad. + */ +export function beginChartLoad(label, mode) { + return { + label: label, + mode: mode, + startedAt: new Date().toISOString(), + t0: performance.now(), + renderMs: 0, + rows: null, + }; +} + +/** + * Add chart render time (call with performance.now() deltas around render calls). + */ +export function recordRender(report, ms) { + report.renderMs += ms; +} + +/** + * Finalize the report: collect network timings, log and display it. + * + * @param {Object} report - The report from beginChartLoad. + * @param {Object} [extra] - Extra fields, e.g. { rows: 1234 }. + */ +export function finishChartLoad(report, extra) { + Object.assign(report, extra || {}); + report.totalMs = round(performance.now() - report.t0); + report.renderMs = round(report.renderMs); + report.requests = performance + .getEntriesByType("resource") + .filter( + (e) => + e.startTime >= report.t0 - 1 && + CHART_ENDPOINT_PATTERNS.some((p) => e.name.includes(p)) + ) + .map((e) => ({ + endpoint: shortEndpoint(e.name), + durationMs: round(e.duration), + transferKB: round((e.transferSize || 0) / 1024), + decodedKB: round((e.decodedBodySize || 0) / 1024), + fromCache: e.transferSize === 0 && e.decodedBodySize > 0, + })); + report.networkMs = round( + report.requests.reduce((sum, r) => Math.max(sum, r.durationMs), 0) + ); + delete report.t0; + + history.push(report); + if (history.length > MAX_HISTORY) { + history.shift(); + } + + console.groupCollapsed( + "[chart-perf] " + + report.label + + " | " + + report.mode + + " | total " + + report.totalMs + + " ms" + ); + console.table(report.requests); + console.log(report); + console.groupEnd(); + + renderPanel(report); +} + +function round(x) { + return Math.round((x + Number.EPSILON) * 10) / 10; +} + +function shortEndpoint(url) { + try { + const u = new URL(url, window.location.origin); + return u.pathname; + } catch (e) { + return url; + } +} + +function renderPanel(report) { + let panel = document.getElementById("chart-perf-panel"); + if (!panel) { + panel = document.createElement("div"); + panel.id = "chart-perf-panel"; + panel.style.cssText = + "position:fixed;bottom:16px;right:16px;z-index:10000;max-width:340px;" + + "background:#fff;color:#222;border:1px solid #ccc;border-radius:8px;" + + "box-shadow:0 2px 12px rgba(0,0,0,.25);font:12px/1.5 monospace;padding:10px 12px;"; + document.body.appendChild(panel); + } + const requestRows = report.requests + .map( + (r) => + "" + + r.endpoint + + "" + + r.durationMs + + " ms" + + (r.fromCache ? "cache" : r.transferKB + " KB") + + "" + ) + .join(""); + panel.innerHTML = + "
" + + "Chart load report #" + history.length + "" + + "×" + + "
" + + "
" + report.label + " — " + report.mode + "
" + + "" + + "" + + "" + + "" + + "" + + "
data rows" + (report.rows === null ? "n/a" : report.rows) + "
network (longest request)" + report.networkMs + " ms
chart render" + report.renderMs + " ms
total" + report.totalMs + " ms
" + + "
" + + report.requests.length + " request(s)" + + "" + requestRows + "
" + + "Download history (JSON)"; + + panel.querySelector("#chart-perf-close").addEventListener("click", () => { + panel.remove(); // reappears on the next chart load + }); + panel.querySelector("#chart-perf-download").addEventListener("click", (e) => { + e.preventDefault(); + const blob = new Blob([JSON.stringify(history, null, 2)], { + type: "application/json", + }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "chart-load-reports.json"; + a.click(); + URL.revokeObjectURL(a.href); + }); +} diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js new file mode 100644 index 0000000000..57b23e8b1b --- /dev/null +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -0,0 +1,427 @@ +/** + * Fast (canvas-based) time series rendering with Apache ECharts. + * + * This module offers an alternative to the Vega-Lite charts for users who want + * snappier rendering and interaction on dense time series: + * - canvas rendering (no per-mark DOM nodes) + * - built-in LTTB downsampling per series (`sampling: "lttb"`) + * - mouse-wheel zoom, drag-to-pan and a range slider, synced across subplots + * + * It consumes the same rows as the Vega-Lite charts (the decompressed output + * of the /chart_data endpoints): objects with `event_start` (ms epoch), + * `event_value`, `belief_horizon` (ms), and nested `sensor` and `source` + * objects. Layout and tooltips mirror the Vega-Lite charts: centered subplot + * titles, "Sensor-type (unit)" y-axis titles, and per-point tooltips listing + * sensor, value, time, horizon and source details. + * + * Dependencies: the global `echarts` object (loaded in base.html). + */ + +const GRID_HEIGHT = 220; // height of each subplot in px +const GRID_GAP = 56; // vertical space between subplots (axis labels + titles) +const TOP_OFFSET = 48; // room for the toolbox and the first subplot title +const BOTTOM_OFFSET = 78; // room for the slider and the last x-axis labels +const GRID_LEFT = 70; // room for the y-axis labels +const LEGEND_WIDTH = 190; // width of the legend column beside each subplot + +// One chart instance per container element +const instances = {}; + +/** + * Group chart data rows into subplots and series (one series per + * sensor+source combination). + * + * When a group spec is given (the asset's sensors_to_show structure), the + * subplots mirror the Vega-Lite chart: one subplot per spec entry, in order, + * each with its title and sensors. Spec entries without data still get an + * (empty) subplot, so no chart goes missing. Rows from sensors not covered + * by the spec — and all rows when there is no spec, as on the sensor page — + * are grouped by unit. + * + * @param {Object[]} data - Decompressed chart data rows. + * @param {Object[]} [groupSpec] - Optional subplot structure: [{ title, sensorIds, sensorType }]. + * @returns {Object[]} - List of groups: { title, units, sensorType, multiSensor, series }. + */ +function groupData(data, groupSpec) { + const groups = []; + const groupBySensorId = new Map(); + const groupByUnit = new Map(); // fallback for rows not covered by the spec + + function newGroup(title, sensorType) { + const group = { + title: title, + sensorType: sensorType || "", + units: new Set(), + sensorNames: new Set(), + series: new Map(), + }; + groups.push(group); + return group; + } + + if (Array.isArray(groupSpec)) { + for (const entry of groupSpec) { + const group = newGroup(entry.title || "", entry.sensorType || ""); + for (const sensorId of entry.sensorIds || []) { + groupBySensorId.set(sensorId, group); + } + } + } + + for (const row of data) { + if (typeof row.event_value !== "number" || row.event_value === null) { + continue; // fast charts only render numeric series + } + const sensor = row.sensor || {}; + const source = row.source || {}; + const unit = sensor.unit || row.sensor_unit || ""; + let group = groupBySensorId.get(sensor.id); + if (!group) { + group = groupByUnit.get(unit) || groupByUnit.set(unit, newGroup("", "")).get(unit); + } + group.units.add(unit); + group.sensorNames.add(sensor.name || "sensor " + sensor.id); + const seriesKey = sensor.id + "|" + source.id; + if (!group.series.has(seriesKey)) { + group.series.set(seriesKey, { + sensorName: sensor.name || "sensor " + sensor.id, + sensorDescription: sensor.description || sensor.name || "", + sourceLabel: sourceLabel(source), + source: source, + unit: unit, + points: [], + }); + } + // Third dimension carries the belief horizon (ms) for the tooltip; + // LTTB sampling selects original points, so it survives downsampling. + group.series + .get(seriesKey) + .points.push([row.event_start, row.event_value, row.belief_horizon]); + } + + return groups.map((group) => { + const series = []; + for (const s of group.series.values()) { + s.points.sort((a, b) => a[0] - b[0]); + // Series names must be globally unique, so that each subplot's legend + // only toggles its own series (names are shared state across legends). + s.name = s.sensorName + " · " + s.sourceLabel; + s.sensorType = group.sensorType || s.sensorName; + series.push(s); + } + const sensorNames = Array.from(group.sensorNames); + return { + title: group.title || sensorNames.join(", "), + units: Array.from(group.units), + sensorType: group.sensorType, + multiSensor: group.sensorNames.size > 1, + series: series, + }; + }); +} + +function sourceLabel(source) { + if (source.description) { + return source.description; + } + let label = source.name || "source " + source.id; + if (source.model) { + label += " (" + source.model + ")"; + } + return label; +} + +function capFirst(s) { + return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; +} + +function escapeHtml(s) { + return String(s == null ? "" : s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]) + ); +} + +// Quantity formatting as in the Vega-Lite charts: space as thousands separator +function formatQuantity(value, unit) { + const formatted = (+value.toFixed(4)) + .toLocaleString("en-US", { maximumFractionDigits: 4 }) + .replace(/,/g, " "); + return unit ? formatted + " " + unit : formatted; +} + +// Same as the Vega-Lite charts' TIME_FORMAT: "%H:%M on %A %b %e, %Y" +function formatFullDate(ms) { + const d = new Date(ms); + const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const pad = (n) => String(n).padStart(2, "0"); + return ( + pad(d.getHours()) + ":" + pad(d.getMinutes()) + + " on " + days[d.getDay()] + " " + months[d.getMonth()] + + " " + d.getDate() + ", " + d.getFullYear() + ); +} + +// Port of the timedeltaFormat used by the Vega-Lite tooltips (breakpoint 4) +function formatTimedelta(ms) { + if (typeof ms !== "number" || isNaN(ms)) { + return ""; + } + const Y = 1000 * 60 * 60 * 24 * 365.2425; + const D = 1000 * 60 * 60 * 24; + const H = 1000 * 60 * 60; + const M = 1000 * 60; + const S = 1000; + const abs = Math.abs(ms); + return abs > 4 * Y ? Math.round(ms / Y) + " years" + : abs > 4 * D ? Math.round(ms / D) + " days" + : abs > 4 * H ? Math.round(ms / H) + " hours" + : abs > 4 * M ? Math.round(ms / M) + " minutes" + : abs > 4 * S ? Math.round(ms / S) + " seconds" + : Math.round(ms) + " milliseconds"; +} + +// Tooltip matching the Vega-Lite charts: a two-column table per data point +function tooltipFormatter(seriesMeta) { + return function (params) { + if (params.componentType === "legend") { + return escapeHtml(params.name); // legend hover: just reveal the full series name + } + const meta = seriesMeta[params.seriesIndex]; + if (!meta) { + return ""; + } + const value = params.value; + const rows = [ + ["Sensor", meta.sensorDescription], + [capFirst(meta.sensorType), formatQuantity(value[1], meta.unit)], + ["Time and date", formatFullDate(value[0])], + ["Horizon", formatTimedelta(value[2])], + ["Source", meta.source.name + " (ID: " + meta.source.id + ")"], + ["Type", meta.source.display_type || ""], + ["Model", meta.source.model || ""], + ["Version", meta.source.version || ""], + ]; + return ( + '' + + rows + .map( + (r) => + '" + ) + .join("") + + "
' + + escapeHtml(r[0]) + + '' + + escapeHtml(r[1]) + + "
" + ); + }; +} + +/** + * Render (or re-render) the fast chart into the given container element. + * + * @param {string} elementId - The id of the container div. + * @param {Object[]} data - Decompressed chart data rows. + * @param {Object} [options] - Optional settings: { groupSpec } (see groupData). + */ +export function renderFastChart(elementId, data, options) { + const container = document.getElementById(elementId); + if (!container || typeof echarts === "undefined") { + return; + } + const groups = groupData(data || [], options && options.groupSpec); + + // Size the container to fit all subplots before initializing the chart + const numGrids = Math.max(groups.length, 1); + container.style.height = + TOP_OFFSET + numGrids * (GRID_HEIGHT + GRID_GAP) + BOTTOM_OFFSET + "px"; + + let instance = instances[elementId]; + if (!instance || instance.chart.isDisposed()) { + const chart = echarts.init(container, null, { renderer: "canvas" }); + instance = { chart: chart, resizeTimer: null, lastArgs: null }; + // Re-render on resize (debounced), so centered titles stay centered + instance.onResize = () => { + clearTimeout(instance.resizeTimer); + instance.resizeTimer = setTimeout(() => { + if (!instance.chart.isDisposed() && instance.lastArgs) { + renderFastChart(elementId, instance.lastArgs.data, instance.lastArgs.options); + } + }, 150); + }; + window.addEventListener("resize", instance.onResize); + instances[elementId] = instance; + } else { + instance.chart.resize(); // container height may have changed with the number of subplots + } + instance.lastArgs = { data: data, options: options }; + const chart = instance.chart; + + if (groups.length === 0) { + chart.clear(); + chart.setOption({ + title: { + text: "No data to show for this time range", + left: "center", + top: "middle", + textStyle: { fontWeight: "normal", color: "#888" }, + }, + }); + return; + } + + // Center each subplot title over its plot area (not over the legend column) + const containerWidth = container.clientWidth || 800; + const plotCenter = (GRID_LEFT + containerWidth - LEGEND_WIDTH - 40) / 2; + + const grids = []; + const xAxes = []; + const yAxes = []; + const titles = []; + const legends = []; + const series = []; + const seriesMeta = []; + + groups.forEach((group, i) => { + const top = TOP_OFFSET + i * (GRID_HEIGHT + GRID_GAP); + grids.push({ + top: top, + height: GRID_HEIGHT, + left: GRID_LEFT, + right: LEGEND_WIDTH + 40, // leave room for the legend beside the subplot + containLabel: false, + }); + titles.push({ + text: group.title, + left: plotCenter, + textAlign: "center", + top: top - 42, + textStyle: { fontSize: 15, color: "#222" }, + }); + legends.push({ + // One vertical legend beside each subplot, listing only its own series + data: group.series.map((s) => s.name), + orient: "vertical", + type: "scroll", + right: 8, + top: top, + height: GRID_HEIGHT, + align: "left", + itemWidth: 18, + itemGap: 6, + // For single-sensor subplots the sensor is already in the title, so only show the source + formatter: group.multiSensor + ? undefined + : (name) => name.split(" · ").slice(1).join(" · ") || name, + textStyle: { + width: LEGEND_WIDTH - 40, + overflow: "truncate", + fontSize: 11, + }, + tooltip: { show: true }, // hover reveals truncated names in full + }); + xAxes.push({ + type: "time", + gridIndex: i, + axisLine: { onZero: false }, + axisPointer: { show: true }, // vertical ruler, as in the Vega-Lite charts + splitLine: { show: true, lineStyle: { opacity: 0.5 } }, + }); + // Y-axis title as in the Vega-Lite charts, e.g. "Power (kW)" + const unitLabel = group.units.join(", "); + const yTitle = group.sensorType + ? capFirst(group.sensorType) + (unitLabel ? " (" + unitLabel + ")" : "") + : unitLabel; + yAxes.push({ + type: "value", + gridIndex: i, + name: yTitle, + nameLocation: "end", + nameTextStyle: { + fontSize: 12, + fontWeight: "bold", + color: "#222", + align: "left", + padding: [0, 0, 4, -GRID_LEFT + 16], + }, + scale: true, + splitLine: { show: true, lineStyle: { opacity: 0.7 } }, + }); + for (const s of group.series) { + series.push({ + name: s.name, + type: "line", + xAxisIndex: i, + yAxisIndex: i, + data: s.points, + step: "start", // events hold their value for the duration of the event + showSymbol: false, + sampling: "lttb", // downsample to the available pixels, preserving peaks + lineStyle: { width: 2.2 }, + emphasis: { focus: "series" }, + animation: false, + }); + seriesMeta.push(s); + } + }); + + const allAxisIndices = xAxes.map((_, i) => i); + + chart.clear(); + chart.setOption({ + grid: grids, + title: titles, + xAxis: xAxes, + yAxis: yAxes, + series: series, + legend: legends, + axisPointer: { + link: [{ xAxisIndex: "all" }], // sync the ruler across subplots + }, + tooltip: { + trigger: "item", // per-point details, as in the Vega-Lite charts + confine: true, + formatter: tooltipFormatter(seriesMeta), + }, + toolbox: { + right: 16, + feature: { + dataZoom: { xAxisIndex: allAxisIndices, yAxisIndex: false }, + restore: {}, + saveAsImage: { name: "flexmeasures-chart" }, + }, + }, + dataZoom: [ + { + type: "inside", // mouse-wheel zoom and drag-to-pan + xAxisIndex: allAxisIndices, + }, + { + type: "slider", + xAxisIndex: allAxisIndices, + bottom: 14, + height: 28, + }, + ], + }); +} + +/** + * Dispose the fast chart instance for the given container, freeing its canvas. + * + * @param {string} elementId - The id of the container div. + */ +export function disposeFastChart(elementId) { + const instance = instances[elementId]; + if (instance) { + window.removeEventListener("resize", instance.onResize); + clearTimeout(instance.resizeTimer); + if (!instance.chart.isDisposed()) { + instance.chart.dispose(); + } + } + delete instances[elementId]; +} diff --git a/flexmeasures/ui/templates/assets/asset_graph.html b/flexmeasures/ui/templates/assets/asset_graph.html index 07db17bcb0..a0c068a238 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -100,6 +100,12 @@
{{ kpi.title }}
{% endif %} +
+ + +
+
diff --git a/flexmeasures/ui/templates/base.html b/flexmeasures/ui/templates/base.html index 4cbddafa9d..37ce123b83 100644 --- a/flexmeasures/ui/templates/base.html +++ b/flexmeasures/ui/templates/base.html @@ -246,6 +246,7 @@ + {# Workaround for loading a NodeJS module without NodeJS #} diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index cde70a3344..fb18fe30ec 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -27,6 +27,8 @@ import { subtract, computeSimulationRanges, lastNMonths, encodeUrlQuery, getOffsetBetweenTimezonesForDate, toIsoStringWithOffset } from "{{ url_for('flexmeasures_ui.static', filename='js/daterange-utils.js') }}?v={{ flexmeasures_version }}"; import { partition, updateBeliefs, beliefTimedelta, setAbortableTimeout } from "{{ url_for('flexmeasures_ui.static', filename='js/replay-utils.js') }}?v={{ flexmeasures_version }}"; import { decompressChartData, checkDSTTransitions, checkSourceMasking } from "{{ url_for('flexmeasures_ui.static', filename='js/chart-data-utils.js') }}?v={{ flexmeasures_version }}"; + import { renderFastChart, disposeFastChart } from "{{ url_for('flexmeasures_ui.static', filename='js/fast-chart.js') }}?v={{ flexmeasures_version }}"; + import { beginChartLoad, recordRender, finishChartLoad } from "{{ url_for('flexmeasures_ui.static', filename='js/chart-perf.js') }}?v={{ flexmeasures_version }}"; // Global variables (Module scoped) let picker; @@ -53,6 +55,9 @@ let minRes; let toggle; let stepTriggered = false; + let fastMode = localStorage.getItem('fm-fast-charts') === '1'; // render with the canvas-based fast chart instead of Vega-Lite + const fastChartElementId = 'fast-chart-container'; + let fastChartGroupSpec = null; // subplot structure (titles + sensor groupings), mirroring the Vega-Lite chart if ('{{ active_page }}' == 'assets') { chartType = 'chart_for_multiple_sensors'; @@ -63,6 +68,41 @@ dataPath = '/api/v3_0/assets/' + {{ asset.id }}; dataDevPath = '/api/dev/asset/' + {{ asset.id }}; datasetName = 'asset_' + {{ asset.id }}; + // Mirror the Vega-Lite chart's subplot structure (one entry per subplot, in order). + // Entries from validate_sensors_to_show() hold their sensors in a "plots" list, + // except the default-suggestion path, which uses top-level "sensor(s)" keys. + fastChartGroupSpec = [ + {% for item in asset.validate_sensors_to_show() %} + {% set entry = namespace(sensors=[]) %} + {% if item.plots is defined and item.plots %} + {% for plot in item.plots %} + {% if plot.sensors is defined and plot.sensors %} + {% set entry.sensors = entry.sensors + plot.sensors|list %} + {% elif plot.sensor is defined and plot.sensor %} + {% set entry.sensors = entry.sensors + [plot.sensor] %} + {% endif %} + {% endfor %} + {% elif item.sensors is defined and item.sensors %} + {% set entry.sensors = item.sensors|list %} + {% elif item.sensor is defined and item.sensor %} + {% set entry.sensors = [item.sensor] %} + {% endif %} + {# y-axis title source: sensor_type of the first real (DB-backed) sensor; fixed-value sensors (negative ids) only have a name #} + {% set real_sensors = entry.sensors | selectattr("id", "gt", 0) | list %} + {% if real_sensors %} + {% set entry_sensor_type = real_sensors[0].get_attribute("sensor_type", real_sensors[0].name) %} + {% elif entry.sensors %} + {% set entry_sensor_type = entry.sensors[0].name %} + {% else %} + {% set entry_sensor_type = "" %} + {% endif %} + { + title: {{ (item.title or "") | tojson }}, + sensorIds: {{ entry.sensors | map(attribute="id") | list | tojson }}, + sensorType: {{ entry_sensor_type | tojson }} + }{% if not loop.last %},{% endif %} + {% endfor %} + ]; {% set total_sensors = asset.sensors_to_show | map(attribute = 'sensors') | map('length') | sum %} {% if total_sensors > 7 %} {% if session.get("keep_legends_below_graphs") %} @@ -79,6 +119,13 @@ dataPath = '/api/dev/sensor/' + {{ sensor.id }}; dataDevPath = '/api/dev/sensor/' + {{ sensor.id }}; datasetName = 'sensor_' + {{ sensor.id }}; + fastChartGroupSpec = [ + { + title: "", + sensorIds: [{{ sensor.id }}], + sensorType: {{ sensor.get_attribute("sensor_type", sensor.name) | tojson }} + } + ]; {% endif %} chartSpecsPath = dataPath + '/chart?'; @@ -102,6 +149,25 @@ const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); tooltipTriggerList.forEach(el => new bootstrap.Tooltip(el)); + // Set up the fast chart (canvas-based) toggle + const fastChartToggle = document.getElementById('fastChartToggle'); + if (fastChartToggle) { + fastChartToggle.checked = fastMode; + if (fastMode) { + applyFastMode(); + } + fastChartToggle.addEventListener('change', async function () { + fastMode = this.checked; + localStorage.setItem('fm-fast-charts', fastMode ? '1' : '0'); + if (fastMode) { + stopReplay(); + } + await applyFastMode(); + }); + } else { + fastMode = false; // no toggle on this page, so always use Vega-Lite + } + // Set up play/pause button for replay, incl. the complete replay logic toggle = document.querySelector('#replay'); toggle.addEventListener('click', function (e) { @@ -148,6 +214,10 @@ item.classList.remove('active'); } }); + // Chart types only apply to the Vega-Lite chart + if (fastMode) { + return; + } // Reload daterange embedAndLoad(chartSpecsPath + 'event_starts_after=' + storeStartDate.toISOString() + '&event_ends_before=' + storeEndDate.toISOString() + '&', elementId, datasetName, previousResult, storeStartDate, storeEndDate) if (!vegaView || !previousResult) { @@ -286,9 +356,11 @@ async function reloadChartData() { if (!storeStartDate || !storeEndDate) { return; } $("#spinner").show(); + const reloadPerf = beginChartLoad('data refresh', currentChartMode()); let newData = fetch(dataPath + '/chart_data?event_starts_after=' + storeStartDate.toISOString() + '&event_ends_before=' + storeEndDate.toISOString() + '&compress_json=true', { method: "GET", headers: { "Content-Type": "application/json" }, + cache: "no-store", signal: signal, }) .then(function (response) { return response.json(); }) @@ -298,12 +370,18 @@ embedAndLoad(chartSpecsPath + 'event_starts_after=' + storeStartDate.toISOString() + '&event_ends_before=' + storeEndDate.toISOString() + '&', elementId, datasetName, previousResult, storeStartDate, storeEndDate), ]).then(function (result) { return result[0] }).catch(console.error); $("#spinner").hide(); - vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(newData)).resize().run(); + const renderStart = performance.now(); + if (!fastMode && vegaView) { + vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(newData)).resize().run(); + } previousResult = { start: storeStartDate, end: storeEndDate, - data: fetchedInitialData + data: newData }; + updateFastChart(); + recordRender(reloadPerf, performance.now() - renderStart); + finishChartLoad(reloadPerf, { rows: newData ? newData.length : 0 }); } document.addEventListener('sensorsToShowUpdated', reloadChartData); {% if active_page == "sensors" %} @@ -332,9 +410,11 @@ {% if active_subpage == "asset_graph" and has_kpis %} getAssetKPIs(storeStartDate, storeEndDate); {% endif %} + const initialPerf = beginChartLoad('initial page load', currentChartMode()); const initialData = fetch(dataPath + '/chart_data?event_starts_after=' + '{{ event_starts_after }}' + '&event_ends_before=' + '{{ event_ends_before }}' + '&compress_json=true', { method: "GET", headers: { "Content-Type": "application/json" }, + cache: "no-store", signal: signal, }) .then(function (response) { return response.json(); }) @@ -344,7 +424,10 @@ embedAndLoad(chartSpecsPath + 'event_starts_after=' + '{{ event_starts_after }}' + '&event_ends_before=' + '{{ event_ends_before }}' + '&', elementId, datasetName, previousResult, sessionStart, sessionEnd), ]).then(function (result) { return result[0] }).catch(console.error); $("#spinner").hide(); - vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(fetchedInitialData)).resize().run(); + var initialRenderStart = performance.now(); + if (!fastMode && vegaView) { + vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(fetchedInitialData)).resize().run(); + } var sessionStart = new Date('{{ event_starts_after }}'); var sessionEnd = new Date('{{ event_ends_before }}'); previousResult = { @@ -352,6 +435,9 @@ end: sessionEnd, data: fetchedInitialData }; + updateFastChart(); + recordRender(initialPerf, performance.now() - initialRenderStart); + finishChartLoad(initialPerf, { rows: fetchedInitialData ? fetchedInitialData.length : 0 }); checkSourceMasking(previousResult.data); var timerangeNotSetYet = false; {% else %} @@ -522,6 +608,7 @@ $("#spinner").show(); checkDSTTransitions(startDate, endDate) + const rangePerf = beginChartLoad('date range change', currentChartMode()); Promise.all([ fetchGraphDataAndKPIs(previousResult, startDate, endDate, queryStartDate, queryEndDate), {% if active_page == "sensors" %} @@ -529,6 +616,7 @@ fetch(dataPath + '/chart_annotations?event_starts_after=' + queryStartDate + '&event_ends_before=' + queryEndDate, { method: "GET", headers: {"Content-Type": "application/json"}, + cache: "no-store", signal: signal, }) .then(function(response) { return response.json(); }), @@ -538,7 +626,10 @@ embedAndLoad(chartSpecsPath + 'event_starts_after=' + queryStartDate + '&event_ends_before=' + queryEndDate + '&', elementId, datasetName, previousResult, startDate, endDate), ]).then(function (result) { $("#spinner").hide(); - vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(result[0])).resize().run(); + const renderStart = performance.now(); + if (!fastMode && vegaView) { + vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(result[0])).resize().run(); + } previousResult = { start: startDate, end: endDate, @@ -547,17 +638,64 @@ {% if active_page == "sensors" %} previousResult["annotations"] = result[1] {% endif %} + updateFastChart(); checkSourceMasking(previousResult.data); playBackDataLoadedForKnownDateRange = false; - if (chartType === "bar_chart") { + if (!fastMode && vegaView && chartType === "bar_chart") { vegaView.change(datasetName + '_annotations', vega.changeset().remove(vega.truthy).insert(result[1])).resize().run(); } + recordRender(rangePerf, performance.now() - renderStart); + finishChartLoad(rangePerf, { rows: result[0] ? result[0].length : 0 }); }).catch(console.error); }); return picker } + function currentChartMode() { + return fastMode ? 'fast (ECharts)' : 'standard (Vega-Lite)'; + } + + // Show either the fast (canvas-based) chart or the Vega-Lite chart, and render it + async function applyFastMode() { + const vegaDiv = document.getElementById(elementId); + const fastDiv = document.getElementById(fastChartElementId); + if (!fastDiv) { return; } + const switchPerf = previousResult ? beginChartLoad('renderer switch', currentChartMode()) : null; + const switchRenderStart = performance.now(); + if (fastMode) { + vegaDiv.style.display = 'none'; + fastDiv.style.display = 'block'; + updateFastChart(); + } else { + fastDiv.style.display = 'none'; + vegaDiv.style.display = ''; + disposeFastChart(fastChartElementId); + // Re-embed the Vega-Lite chart, which was skipped while fast mode was on + if (storeStartDate && storeEndDate) { + $("#spinner").show(); + await embedAndLoad(chartSpecsPath + 'event_starts_after=' + storeStartDate.toISOString() + '&event_ends_before=' + storeEndDate.toISOString() + '&', elementId, datasetName, previousResult, storeStartDate, storeEndDate); + $("#spinner").hide(); + } + } + if (switchPerf) { + recordRender(switchPerf, performance.now() - switchRenderStart); + finishChartLoad(switchPerf, { rows: previousResult && previousResult.data ? previousResult.data.length : 0 }); + } + } + + // Re-render the fast chart from the most recently fetched data (no-op outside fast mode) + function updateFastChart() { + if (!fastMode || !previousResult || !previousResult.data) { return; } + // Re-assert visibility, as other scripts (e.g. saving sensors_to_show) unhide the Vega-Lite chart + const vegaDiv = document.getElementById(elementId); + const fastDiv = document.getElementById(fastChartElementId); + if (vegaDiv) { vegaDiv.style.display = 'none'; } + if (fastDiv) { fastDiv.style.display = 'block'; } + renderFastChart(fastChartElementId, previousResult.data, { groupSpec: fastChartGroupSpec }); + } + async function embedAndLoad(chartSpecsPath, elementId, datasetName, previousResult, startDate, endDate) { + if (fastMode) { return; } // in fast mode, data is rendered by updateFastChart instead const url = encodeUrlQuery(chartSpecsPath + 'dataset_name=' + datasetName + '&combine_legend=' + combineLegend + '&width=container&include_sensor_annotations=true&include_asset_annotations=true&chart_type=' + chartType); await vegaEmbed('#' + elementId, url, {{ chart_options | safe }}) .then(function (result) { @@ -599,7 +737,18 @@ }); } + function replayUnavailableInFastMode() { + if (fastMode) { + showToast("Replay needs the standard chart. Switch off the fast chart to use replay.", "info"); + return true; + } + return false; + } + function stepReplay() { + if (replayUnavailableInFastMode()) { + return; + } if (toggle.classList.contains('stopped')) { startReplay(); pauseReplay(); @@ -612,6 +761,9 @@ } function toggleReplay() { + if (replayUnavailableInFastMode()) { + return; + } if (toggle.classList.contains('stopped')) { startReplay(); } else if (toggle.classList.contains('playing')) { @@ -674,6 +826,7 @@ fetch(dataPath + '/chart_data?event_starts_after=' + queryStartDate + '&event_ends_before=' + queryEndDate + '&most_recent_beliefs_only=false&compress_json=true', { method: "GET", headers: { "Content-Type": "application/json" }, + cache: "no-store", signal: signal, }) .then(function (response) { return response.json(); }) @@ -764,6 +917,7 @@ return fetch(dataPath + '/chart_data?event_starts_after=' + queryStartDate + '&event_ends_before=' + queryEndDate + '&compress_json=true', { method: "GET", headers: { "Content-Type": "application/json" }, + cache: "no-store", signal: signal, }) .then(response => response.json()) @@ -782,6 +936,7 @@ headers: { "Content-Type": "application/json", }, + cache: "no-store", }) .then(response => response.json()) .then(response => { diff --git a/flexmeasures/ui/templates/sensors/index.html b/flexmeasures/ui/templates/sensors/index.html index 7473bf3e83..07ca7220ef 100644 --- a/flexmeasures/ui/templates/sensors/index.html +++ b/flexmeasures/ui/templates/sensors/index.html @@ -267,6 +267,12 @@

Upload {{ sensor.name }} data

+
+ + +
+
diff --git a/flexmeasures/ui/utils/chart_defaults.py b/flexmeasures/ui/utils/chart_defaults.py index 088b6e971e..5783d4d9d7 100644 --- a/flexmeasures/ui/utils/chart_defaults.py +++ b/flexmeasures/ui/utils/chart_defaults.py @@ -1,6 +1,10 @@ chart_options = dict( mode="vega-lite", - renderer="svg", + # canvas renders dense time series much faster than svg (no per-mark DOM nodes) + renderer="canvas", + # fetch chart specs (and any data the spec references) fresh on every load, + # so performance reports measure real network calls rather than cache hits + loader={"http": {"cache": "no-store"}}, actions={"export": True, "source": False, "compiled": False, "editor": False}, theme="light", tooltip={"theme": "light"}, From a22650a9f39ed5ba1d6a146ac31fa01dbfae2c75 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 12 Jun 2026 09:58:45 +0200 Subject: [PATCH 02/28] feat: use ECharts to make the asset charts faster and more interactive Signed-off-by: Ahmad-Wahid --- flexmeasures/utils/config_defaults.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/flexmeasures/utils/config_defaults.py b/flexmeasures/utils/config_defaults.py index 4cbcc26cd3..3f68f415e6 100644 --- a/flexmeasures/utils/config_defaults.py +++ b/flexmeasures/utils/config_defaults.py @@ -175,9 +175,10 @@ class Config(object): FLEXMEASURES_REDIS_DB_NR: int = 0 # Redis per default has 16 databases, [0-15] FLEXMEASURES_REDIS_PASSWORD: str | None = None FLEXMEASURES_JS_VERSIONS: dict = dict( - vega="5.22.1", - vegaembed="6.21.0", - vegalite="5.5.0", # "5.6.0" has a problematic bar chart: see our sensor page and https://github.com/vega/vega-lite/issues/8496 + vega="5.33.1", + vegaembed="6.29.0", + vegalite="5.23.0", # the bar chart issue we had with 5.6.0 (https://github.com/vega/vega-lite/issues/8496) was fixed in 5.6.1 + echarts="5.6.0", # used for the fast (canvas-based) chart mode currencysymbolmap="5.1.0", jsoneditor="2.15.2", leaflet="1.9.4", From 27e32b88b81bb6dd6043a30b5d382af50eeae46d Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 12 Jun 2026 09:59:20 +0200 Subject: [PATCH 03/28] add changelog Signed-off-by: Ahmad-Wahid --- documentation/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 6b59c5447e..8a76b8d25b 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -11,11 +11,14 @@ New features ------------- * Floor off-clock API datetimes to a non-instantaneous sensor's resolution by default when ingesting sensor data, uploading sensor data, and handling scheduler flex-model timed events; configurable with the ``floor_datetimes_to_resolution`` sensor attribute [see `PR #2146 `_] * Sensor references in flex-model and flex-context support various ways of filtering by source [see `PR #2209 `_] +* New opt-in "Fast chart" mode on asset and sensor pages: a canvas-based chart (powered by Apache ECharts) with LTTB downsampling, mouse-wheel zooming, panning and a range slider, for much snappier interaction on dense time series Infrastructure / Support ---------------------- * Upgraded dependencies [see `PR #1485 `_ and `PR #2215 `_] +* Faster chart rendering: Vega-Lite charts now render to canvas instead of SVG, and the bundled vega (5.22.1 → 5.33.1), vega-lite (5.5.0 → 5.23.0) and vega-embed (6.21.0 → 6.29.0) versions were upgraded, picking up several years of performance improvements +* Chart load performance reports: pages that load a chart now show a small report panel (and console log) with network, render and total timings per load; chart spec and data requests bypass the browser HTTP cache so every load measures a real call * Prepare the ``device_scheduler`` to deal with commitments per device group [see `PR #1934 `_] * Documentation section on the modelling choice for recording measurements, forecasts and schedules under one or multiple sensors [see `PR #2217 `_] From 9ad09bd8ca9289397289a0fe8dbe36ee1ed19f34 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 12 Jun 2026 15:12:34 +0200 Subject: [PATCH 04/28] improve the EChart by adding missing features as we have in the existing charts Signed-off-by: Ahmad-Wahid --- flexmeasures/ui/static/js/fast-chart.js | 756 +++++++++++++----- .../ui/templates/includes/graphs.html | 73 +- 2 files changed, 619 insertions(+), 210 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index 57b23e8b1b..36d7f53b5f 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -1,32 +1,135 @@ /** - * Fast (canvas-based) time series rendering with Apache ECharts. + * Fast (canvas-based) chart rendering with Apache ECharts. * * This module offers an alternative to the Vega-Lite charts for users who want * snappier rendering and interaction on dense time series: * - canvas rendering (no per-mark DOM nodes) - * - built-in LTTB downsampling per series (`sampling: "lttb"`) + * - built-in LTTB downsampling per line series (`sampling: "lttb"`) * - mouse-wheel zoom, drag-to-pan and a range slider, synced across subplots * * It consumes the same rows as the Vega-Lite charts (the decompressed output * of the /chart_data endpoints): objects with `event_start` (ms epoch), * `event_value`, `belief_horizon` (ms), and nested `sensor` and `source` - * objects. Layout and tooltips mirror the Vega-Lite charts: centered subplot - * titles, "Sensor-type (unit)" y-axis titles, and per-point tooltips listing - * sensor, value, time, horizon and source details. + * objects. Layout, chart types and tooltips mirror the Vega-Lite charts: + * - line/bar charts with centered subplot titles and "Sensor-type (unit)" y-axis titles + * - histogram (binned values per source) and daily/weekly heatmaps + * (most prevalent source, diverging color scale centered at 0) + * - per-point tooltips listing sensor, value, time, horizon and source details + * - replay support (belief-time ruler), legends beside or below each subplot, + * and CSV/SVG/PNG export from the toolbox * * Dependencies: the global `echarts` object (loaded in base.html). */ +import { convertToCSV } from "./data-utils.js"; + const GRID_HEIGHT = 220; // height of each subplot in px -const GRID_GAP = 56; // vertical space between subplots (axis labels + titles) +const SIDE_GRID_GAP = 56; // vertical space between subplots (axis labels + titles) +const BELOW_GRID_GAP = 110; // extra space when legends go below each subplot const TOP_OFFSET = 48; // room for the toolbox and the first subplot title const BOTTOM_OFFSET = 78; // room for the slider and the last x-axis labels const GRID_LEFT = 70; // room for the y-axis labels const LEGEND_WIDTH = 190; // width of the legend column beside each subplot +// Diverging color scale approximating Vega's "blueorange" scheme (centered at 0) +const BLUE_ORANGE = ["#2166ac", "#67a9cf", "#d1e5f0", "#f7f7f7", "#fee0b6", "#f1a340", "#b35806"]; + // One chart instance per container element const instances = {}; +/* ============================== formatting ============================== */ + +function sourceLabel(source) { + if (source.description) { + return source.description; + } + let label = source.name || "source " + source.id; + if (source.model) { + label += " (" + source.model + ")"; + } + return label; +} + +function capFirst(s) { + return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; +} + +function escapeHtml(s) { + return String(s == null ? "" : s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]) + ); +} + +// Quantity formatting as in the Vega-Lite charts: space as thousands separator +function formatQuantity(value, unit) { + const formatted = (+value.toFixed(4)) + .toLocaleString("en-US", { maximumFractionDigits: 4 }) + .replace(/,/g, " "); + return unit ? formatted + " " + unit : formatted; +} + +// Same as the Vega-Lite charts' TIME_FORMAT: "%H:%M on %A %b %e, %Y" +const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; +const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +function formatFullDate(ms) { + const d = new Date(ms); + const pad = (n) => String(n).padStart(2, "0"); + return ( + pad(d.getHours()) + ":" + pad(d.getMinutes()) + + " on " + DAY_NAMES[d.getDay()] + " " + MONTH_NAMES[d.getMonth()] + + " " + d.getDate() + ", " + d.getFullYear() + ); +} + +function formatDate(ms) { + const d = new Date(ms); + return MONTH_NAMES[d.getMonth()] + " " + d.getDate() + ", " + d.getFullYear(); +} + +// Port of the timedeltaFormat used by the Vega-Lite tooltips (breakpoint 4) +function formatTimedelta(ms) { + if (typeof ms !== "number" || isNaN(ms)) { + return ""; + } + const Y = 1000 * 60 * 60 * 24 * 365.2425; + const D = 1000 * 60 * 60 * 24; + const H = 1000 * 60 * 60; + const M = 1000 * 60; + const S = 1000; + const abs = Math.abs(ms); + return abs > 4 * Y ? Math.round(ms / Y) + " years" + : abs > 4 * D ? Math.round(ms / D) + " days" + : abs > 4 * H ? Math.round(ms / H) + " hours" + : abs > 4 * M ? Math.round(ms / M) + " minutes" + : abs > 4 * S ? Math.round(ms / S) + " seconds" + : Math.round(ms) + " milliseconds"; +} + +function tooltipTable(rows) { + return ( + '' + + rows + .map( + (r) => + '" + ) + .join("") + + "
' + + escapeHtml(r[0]) + + '' + + escapeHtml(r[1]) + + "
" + ); +} + +/* ============================== data grouping ============================== */ + +function numericRows(data) { + return (data || []).filter((row) => typeof row.event_value === "number"); +} + /** * Group chart data rows into subplots and series (one series per * sensor+source combination). @@ -35,8 +138,7 @@ const instances = {}; * subplots mirror the Vega-Lite chart: one subplot per spec entry, in order, * each with its title and sensors. Spec entries without data still get an * (empty) subplot, so no chart goes missing. Rows from sensors not covered - * by the spec — and all rows when there is no spec, as on the sensor page — - * are grouped by unit. + * by the spec — and all rows when there is no spec — are grouped by unit. * * @param {Object[]} data - Decompressed chart data rows. * @param {Object[]} [groupSpec] - Optional subplot structure: [{ title, sensorIds, sensorType }]. @@ -68,10 +170,7 @@ function groupData(data, groupSpec) { } } - for (const row of data) { - if (typeof row.event_value !== "number" || row.event_value === null) { - continue; // fast charts only render numeric series - } + for (const row of numericRows(data)) { const sensor = row.sensor || {}; const source = row.source || {}; const unit = sensor.unit || row.sensor_unit || ""; @@ -120,70 +219,126 @@ function groupData(data, groupSpec) { }); } -function sourceLabel(source) { - if (source.description) { - return source.description; +// Keep only the rows of the source with the most data (as the Vega-Lite heatmaps do) +function mostPrevalentSourceRows(rows) { + const counts = new Map(); + for (const row of rows) { + const sid = row.source ? row.source.id : null; + counts.set(sid, (counts.get(sid) || 0) + 1); } - let label = source.name || "source " + source.id; - if (source.model) { - label += " (" + source.model + ")"; + let bestSid = null; + let bestCount = -1; + for (const [sid, count] of counts) { + if (count > bestCount) { + bestSid = sid; + bestCount = count; + } } - return label; + return rows.filter((row) => (row.source ? row.source.id : null) === bestSid); } -function capFirst(s) { - return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; +// Smallest positive gap between consecutive event starts (the sensor resolution) +function inferResolutionMs(rows) { + const starts = Array.from(new Set(rows.map((r) => r.event_start))).sort((a, b) => a - b); + let res = Infinity; + for (let i = 1; i < starts.length; i++) { + const gap = starts[i] - starts[i - 1]; + if (gap > 0 && gap < res) { + res = gap; + } + } + if (!isFinite(res)) { + res = 60 * 60 * 1000; + } + return Math.max(res, 60 * 1000); // at least 1 minute, to bound the number of cells } -function escapeHtml(s) { - return String(s == null ? "" : s).replace( - /[&<>"]/g, - (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]) - ); +/* ============================== chart parts ============================== */ + +function yAxisTitle(sensorType, units) { + const unitLabel = units.join(", "); + return sensorType + ? capFirst(sensorType) + (unitLabel ? " (" + unitLabel + ")" : "") + : unitLabel; } -// Quantity formatting as in the Vega-Lite charts: space as thousands separator -function formatQuantity(value, unit) { - const formatted = (+value.toFixed(4)) - .toLocaleString("en-US", { maximumFractionDigits: 4 }) - .replace(/,/g, " "); - return unit ? formatted + " " + unit : formatted; +function toolboxFeatures(elementId, datasetName) { + return { + right: 16, + feature: { + dataZoom: { yAxisIndex: false }, + restore: {}, + saveAsImage: { name: datasetName || "flexmeasures-chart", title: "Save as PNG" }, + mySaveCSV: { + show: true, + title: "Save as CSV", + icon: "path://M5 2 L13 2 L17 6 L17 20 L5 20 Z M7 11 L15 11 M7 14 L15 14 M7 17 L15 17", + onclick: () => exportCSV(elementId, datasetName), + }, + mySaveSVG: { + show: true, + title: "Save as SVG", + icon: "path://M5 2 L13 2 L17 6 L17 20 L5 20 Z M7 12 L11 17 L15 9", + onclick: () => exportSVG(elementId, datasetName), + }, + }, + }; } -// Same as the Vega-Lite charts' TIME_FORMAT: "%H:%M on %A %b %e, %Y" -function formatFullDate(ms) { - const d = new Date(ms); - const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - const pad = (n) => String(n).padStart(2, "0"); - return ( - pad(d.getHours()) + ":" + pad(d.getMinutes()) + - " on " + days[d.getDay()] + " " + months[d.getMonth()] + - " " + d.getDate() + ", " + d.getFullYear() - ); +function downloadBlob(content, mimeType, filename) { + const blob = new Blob([content], { type: mimeType }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + URL.revokeObjectURL(link.href); } -// Port of the timedeltaFormat used by the Vega-Lite tooltips (breakpoint 4) -function formatTimedelta(ms) { - if (typeof ms !== "number" || isNaN(ms)) { - return ""; +function exportCSV(elementId, datasetName) { + const instance = instances[elementId]; + if (!instance || !instance.lastArgs) { + return; } - const Y = 1000 * 60 * 60 * 24 * 365.2425; - const D = 1000 * 60 * 60 * 24; - const H = 1000 * 60 * 60; - const M = 1000 * 60; - const S = 1000; - const abs = Math.abs(ms); - return abs > 4 * Y ? Math.round(ms / Y) + " years" - : abs > 4 * D ? Math.round(ms / D) + " days" - : abs > 4 * H ? Math.round(ms / H) + " hours" - : abs > 4 * M ? Math.round(ms / M) + " minutes" - : abs > 4 * S ? Math.round(ms / S) + " seconds" - : Math.round(ms) + " milliseconds"; + const csv = convertToCSV(instance.lastArgs.data || []); + // convertToCSV prefixes the (unencoded) CSV content with a data-URI scheme + const prefix = "data:text/csv;charset=utf-8,"; + const content = csv.startsWith(prefix) ? csv.slice(prefix.length) : csv; + downloadBlob(content, "text/csv;charset=utf-8", (datasetName || "chart") + ".csv"); +} + +function exportSVG(elementId, datasetName) { + const instance = instances[elementId]; + if (!instance || !instance.lastOption || typeof echarts === "undefined") { + return; + } + // Render the current option to SVG with a temporary server-side-rendering instance + const svgChart = echarts.init(null, null, { + renderer: "svg", + ssr: true, + width: instance.chart.getWidth(), + height: instance.chart.getHeight(), + }); + try { + svgChart.setOption(Object.assign({}, instance.lastOption, { animation: false, backgroundColor: "#fff" })); + downloadBlob(svgChart.renderToSVGString(), "image/svg+xml", (datasetName || "chart") + ".svg"); + } finally { + svgChart.dispose(); + } +} + +function noDataOption(message) { + return { + title: { + text: message || "No data to show for this time range", + left: "center", + top: "middle", + textStyle: { fontWeight: "normal", color: "#888" }, + }, + }; } // Tooltip matching the Vega-Lite charts: a two-column table per data point -function tooltipFormatter(seriesMeta) { +function seriesTooltipFormatter(seriesMeta) { return function (params) { if (params.componentType === "legend") { return escapeHtml(params.name); // legend hover: just reveal the full series name @@ -193,7 +348,7 @@ function tooltipFormatter(seriesMeta) { return ""; } const value = params.value; - const rows = [ + return tooltipTable([ ["Sensor", meta.sensorDescription], [capFirst(meta.sensorType), formatQuantity(value[1], meta.unit)], ["Time and date", formatFullDate(value[0])], @@ -202,80 +357,23 @@ function tooltipFormatter(seriesMeta) { ["Type", meta.source.display_type || ""], ["Model", meta.source.model || ""], ["Version", meta.source.version || ""], - ]; - return ( - '' + - rows - .map( - (r) => - '" - ) - .join("") + - "
' + - escapeHtml(r[0]) + - '' + - escapeHtml(r[1]) + - "
" - ); + ]); }; } -/** - * Render (or re-render) the fast chart into the given container element. - * - * @param {string} elementId - The id of the container div. - * @param {Object[]} data - Decompressed chart data rows. - * @param {Object} [options] - Optional settings: { groupSpec } (see groupData). - */ -export function renderFastChart(elementId, data, options) { +/* ============================== line / bar charts ============================== */ + +function buildLineBarOption(elementId, groups, opts) { + const instance = instances[elementId]; const container = document.getElementById(elementId); - if (!container || typeof echarts === "undefined") { - return; - } - const groups = groupData(data || [], options && options.groupSpec); + const legendsBelow = !!opts.legendsBelow; + const gridGap = legendsBelow ? BELOW_GRID_GAP : SIDE_GRID_GAP; + const gridRight = legendsBelow ? 30 : LEGEND_WIDTH + 40; + const containerWidth = container.clientWidth || 800; + const plotCenter = (GRID_LEFT + containerWidth - gridRight) / 2; - // Size the container to fit all subplots before initializing the chart - const numGrids = Math.max(groups.length, 1); container.style.height = - TOP_OFFSET + numGrids * (GRID_HEIGHT + GRID_GAP) + BOTTOM_OFFSET + "px"; - - let instance = instances[elementId]; - if (!instance || instance.chart.isDisposed()) { - const chart = echarts.init(container, null, { renderer: "canvas" }); - instance = { chart: chart, resizeTimer: null, lastArgs: null }; - // Re-render on resize (debounced), so centered titles stay centered - instance.onResize = () => { - clearTimeout(instance.resizeTimer); - instance.resizeTimer = setTimeout(() => { - if (!instance.chart.isDisposed() && instance.lastArgs) { - renderFastChart(elementId, instance.lastArgs.data, instance.lastArgs.options); - } - }, 150); - }; - window.addEventListener("resize", instance.onResize); - instances[elementId] = instance; - } else { - instance.chart.resize(); // container height may have changed with the number of subplots - } - instance.lastArgs = { data: data, options: options }; - const chart = instance.chart; - - if (groups.length === 0) { - chart.clear(); - chart.setOption({ - title: { - text: "No data to show for this time range", - left: "center", - top: "middle", - textStyle: { fontWeight: "normal", color: "#888" }, - }, - }); - return; - } - - // Center each subplot title over its plot area (not over the legend column) - const containerWidth = container.clientWidth || 800; - const plotCenter = (GRID_LEFT + containerWidth - LEGEND_WIDTH - 40) / 2; + TOP_OFFSET + groups.length * (GRID_HEIGHT + gridGap) + BOTTOM_OFFSET + "px"; const grids = []; const xAxes = []; @@ -286,12 +384,12 @@ export function renderFastChart(elementId, data, options) { const seriesMeta = []; groups.forEach((group, i) => { - const top = TOP_OFFSET + i * (GRID_HEIGHT + GRID_GAP); + const top = TOP_OFFSET + i * (GRID_HEIGHT + gridGap); grids.push({ top: top, height: GRID_HEIGHT, left: GRID_LEFT, - right: LEGEND_WIDTH + 40, // leave room for the legend beside the subplot + right: gridRight, containLabel: false, }); titles.push({ @@ -301,28 +399,39 @@ export function renderFastChart(elementId, data, options) { top: top - 42, textStyle: { fontSize: 15, color: "#222" }, }); - legends.push({ - // One vertical legend beside each subplot, listing only its own series + const legend = { + // One legend per subplot, listing only its own series data: group.series.map((s) => s.name), - orient: "vertical", type: "scroll", - right: 8, - top: top, - height: GRID_HEIGHT, - align: "left", - itemWidth: 18, - itemGap: 6, // For single-sensor subplots the sensor is already in the title, so only show the source formatter: group.multiSensor ? undefined : (name) => name.split(" · ").slice(1).join(" · ") || name, - textStyle: { - width: LEGEND_WIDTH - 40, - overflow: "truncate", - fontSize: 11, - }, tooltip: { show: true }, // hover reveals truncated names in full - }); + }; + if (legendsBelow) { + Object.assign(legend, { + orient: "horizontal", + left: GRID_LEFT, + right: gridRight, + top: top + GRID_HEIGHT + 32, + itemWidth: 18, + itemGap: 10, + textStyle: { fontSize: 11 }, + }); + } else { + Object.assign(legend, { + orient: "vertical", + right: 8, + top: top, + height: GRID_HEIGHT, + align: "left", + itemWidth: 18, + itemGap: 6, + textStyle: { width: LEGEND_WIDTH - 40, overflow: "truncate", fontSize: 11 }, + }); + } + legends.push(legend); xAxes.push({ type: "time", gridIndex: i, @@ -330,15 +439,10 @@ export function renderFastChart(elementId, data, options) { axisPointer: { show: true }, // vertical ruler, as in the Vega-Lite charts splitLine: { show: true, lineStyle: { opacity: 0.5 } }, }); - // Y-axis title as in the Vega-Lite charts, e.g. "Power (kW)" - const unitLabel = group.units.join(", "); - const yTitle = group.sensorType - ? capFirst(group.sensorType) + (unitLabel ? " (" + unitLabel + ")" : "") - : unitLabel; yAxes.push({ type: "value", gridIndex: i, - name: yTitle, + name: yAxisTitle(group.sensorType, group.units), // e.g. "Power (kW)" nameLocation: "end", nameTextStyle: { fontSize: 12, @@ -350,28 +454,52 @@ export function renderFastChart(elementId, data, options) { scale: true, splitLine: { show: true, lineStyle: { opacity: 0.7 } }, }); - for (const s of group.series) { - series.push({ + group.series.forEach((s, j) => { + const isBar = opts.chartType === "bar_chart"; + const entry = { name: s.name, - type: "line", + type: isBar ? "bar" : "line", xAxisIndex: i, yAxisIndex: i, data: s.points, - step: "start", // events hold their value for the duration of the event - showSymbol: false, - sampling: "lttb", // downsample to the available pixels, preserving peaks - lineStyle: { width: 2.2 }, emphasis: { focus: "series" }, animation: false, - }); + }; + if (isBar) { + Object.assign(entry, { + barGap: "-100%", // overlay sources, as in the Vega-Lite bar chart + large: true, + itemStyle: { opacity: 0.7 }, + }); + } else { + Object.assign(entry, { + step: "start", // events hold their value for the duration of the event + showSymbol: false, + sampling: "lttb", // downsample to the available pixels, preserving peaks + lineStyle: { width: 2.2 }, + }); + } + // Replay ruler: a vertical line at the current belief time + if (instance.replayTime != null && j === 0) { + entry.markLine = { + silent: true, + symbol: "none", + animation: false, + data: [{ xAxis: instance.replayTime }], + lineStyle: { color: "#555", width: 1.5, type: "solid" }, + label: { show: false }, + }; + } + series.push(entry); seriesMeta.push(s); - } + }); }); const allAxisIndices = xAxes.map((_, i) => i); + const toolbox = toolboxFeatures(elementId, opts.datasetName); + toolbox.feature.dataZoom.xAxisIndex = allAxisIndices; - chart.clear(); - chart.setOption({ + return { grid: grids, title: titles, xAxis: xAxes, @@ -384,29 +512,299 @@ export function renderFastChart(elementId, data, options) { tooltip: { trigger: "item", // per-point details, as in the Vega-Lite charts confine: true, - formatter: tooltipFormatter(seriesMeta), + formatter: seriesTooltipFormatter(seriesMeta), }, - toolbox: { - right: 16, - feature: { - dataZoom: { xAxisIndex: allAxisIndices, yAxisIndex: false }, - restore: {}, - saveAsImage: { name: "flexmeasures-chart" }, + toolbox: toolbox, + dataZoom: [ + { type: "inside", xAxisIndex: allAxisIndices }, // mouse-wheel zoom and drag-to-pan + { type: "slider", xAxisIndex: allAxisIndices, bottom: 14, height: 28 }, + ], + }; +} + +/* ============================== histogram ============================== */ + +// Nice bin width (1/2/5 × 10^k), aiming for ~10 bins as Vega-Lite does +function niceBinWidth(span, targetBins) { + const rawStep = span / targetBins; + const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep))); + for (const m of [1, 2, 5, 10]) { + if (rawStep <= m * magnitude) { + return m * magnitude; + } + } + return 10 * magnitude; +} + +function buildHistogramOption(elementId, data, opts) { + const rows = numericRows(data); + if (rows.length === 0) { + return null; + } + const container = document.getElementById(elementId); + container.style.height = TOP_OFFSET + 360 + BOTTOM_OFFSET + "px"; + + const values = rows.map((r) => r.event_value); + const lo = Math.min(...values); + const hi = Math.max(...values); + const width = niceBinWidth(hi - lo || 1, 10); + const start = Math.floor(lo / width) * width; + const numBins = Math.max(Math.ceil((hi - start) / width), 1); + const unit = rows[0].sensor ? rows[0].sensor.unit || "" : ""; + const sensorType = (opts.groupSpec && opts.groupSpec[0] && opts.groupSpec[0].sensorType) || ""; + + // Count values per bin, per source + const sources = new Map(); // label -> counts array + for (const row of rows) { + const label = sourceLabel(row.source || {}); + if (!sources.has(label)) { + sources.set(label, new Array(numBins).fill(0)); + } + let bin = Math.floor((row.event_value - start) / width); + bin = Math.min(Math.max(bin, 0), numBins - 1); + sources.get(label)[bin] += 1; + } + const binLabels = []; + for (let b = 0; b < numBins; b++) { + binLabels.push(formatQuantity(start + b * width, "") + " – " + formatQuantity(start + (b + 1) * width, "")); + } + + const series = []; + for (const [label, counts] of sources) { + series.push({ + name: label, + type: "bar", + data: counts, + barGap: "-100%", // overlay sources, as in the Vega-Lite histogram + itemStyle: { opacity: 0.7 }, + animation: false, + }); + } + + return { + grid: { top: TOP_OFFSET + 20, height: 320, left: GRID_LEFT, right: opts.legendsBelow ? 30 : LEGEND_WIDTH + 40 }, + series: series, + xAxis: { + type: "category", + data: binLabels, + name: yAxisTitle(sensorType, [unit]), + nameLocation: "middle", + nameGap: 50, + axisLabel: { rotate: 30, fontSize: 10 }, + }, + yAxis: { + type: "value", + name: "Count", + nameLocation: "end", + nameTextStyle: { fontSize: 12, fontWeight: "bold", color: "#222", align: "left", padding: [0, 0, 4, -GRID_LEFT + 16] }, + splitLine: { show: true, lineStyle: { opacity: 0.7 } }, + }, + legend: { + data: Array.from(sources.keys()), + type: "scroll", + orient: opts.legendsBelow ? "horizontal" : "vertical", + right: opts.legendsBelow ? undefined : 8, + left: opts.legendsBelow ? GRID_LEFT : undefined, + top: opts.legendsBelow ? TOP_OFFSET + 360 : TOP_OFFSET + 20, + textStyle: { width: LEGEND_WIDTH - 40, overflow: "truncate", fontSize: 11 }, + tooltip: { show: true }, + }, + tooltip: { + trigger: "item", + confine: true, + formatter: (params) => { + if (params.componentType === "legend") { + return escapeHtml(params.name); + } + return tooltipTable([ + [capFirst(sensorType || "value") + (unit ? " (" + unit + ")" : ""), binLabels[params.dataIndex]], + ["Count", params.value], + ["Source", params.seriesName], + ]); }, }, - dataZoom: [ - { - type: "inside", // mouse-wheel zoom and drag-to-pan - xAxisIndex: allAxisIndices, + toolbox: toolboxFeatures(elementId, opts.datasetName), + }; +} + +/* ============================== heatmaps ============================== */ + +function buildHeatmapOption(elementId, data, opts) { + const split = opts.chartType === "weekly_heatmap" ? "weekly" : "daily"; + const rows = mostPrevalentSourceRows(numericRows(data)); + if (rows.length === 0) { + return null; + } + const resMs = inferResolutionMs(rows); + const slotsPerDay = Math.max(Math.round((24 * 60 * 60 * 1000) / resMs), 1); + const numSlots = split === "daily" ? slotsPerDay : 7 * slotsPerDay; + const unit = rows[0].sensor ? rows[0].sensor.unit || "" : ""; + const sensorType = (opts.groupSpec && opts.groupSpec[0] && opts.groupSpec[0].sensorType) || ""; + const source = rows[0].source || {}; + + // y categories: one row per day (daily) or per week starting Sunday (weekly) + const yKey = (d) => { + const day = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + if (split === "weekly") { + day.setDate(day.getDate() - day.getDay()); // back to Sunday + } + return day.getTime(); + }; + const yKeys = Array.from(new Set(rows.map((r) => yKey(new Date(r.event_start))))).sort((a, b) => a - b); + const yIndex = new Map(yKeys.map((k, i) => [k, i])); + + const cells = []; + let minVal = 0; + let maxVal = 0; + for (const row of rows) { + const d = new Date(row.event_start); + const minutesOfDay = d.getHours() * 60 + d.getMinutes(); + const slotOfDay = Math.floor((minutesOfDay * 60 * 1000) / resMs); + const x = split === "daily" ? slotOfDay : d.getDay() * slotsPerDay + slotOfDay; + const y = yIndex.get(yKey(d)); + cells.push({ value: [x, y, row.event_value], row: row }); + minVal = Math.min(minVal, row.event_value); + maxVal = Math.max(maxVal, row.event_value); + } + // Symmetric range so that 0 sits at the white center of the diverging scale + const absMax = Math.max(Math.abs(minVal), Math.abs(maxVal)) || 1; + + const xLabels = []; + for (let i = 0; i < numSlots; i++) { + if (split === "daily") { + const minutes = Math.round((i * resMs) / 60000); + xLabels.push(String(Math.floor(minutes / 60)).padStart(2, "0") + ":" + String(minutes % 60).padStart(2, "0")); + } else { + xLabels.push(DAY_NAMES[Math.floor(i / slotsPerDay)]); + } + } + const labelEvery = split === "daily" ? Math.max(Math.round(slotsPerDay / 12), 1) : slotsPerDay; + + const gridHeight = Math.min(Math.max(yKeys.length * 26, 120), 460); + const container = document.getElementById(elementId); + container.style.height = TOP_OFFSET + gridHeight + BOTTOM_OFFSET + 40 + "px"; + + return { + grid: { top: TOP_OFFSET + 20, height: gridHeight, left: 110, right: LEGEND_WIDTH + 40 }, + xAxis: { + type: "category", + data: xLabels, + splitArea: { show: false }, + axisLabel: { + interval: (idx) => idx % labelEvery === 0, + fontSize: 10, }, + }, + yAxis: { + type: "category", + data: yKeys.map(formatDate), + inverse: true, // earliest at the top, as in the Vega-Lite heatmaps + name: yAxisTitle(sensorType, [unit]), + nameLocation: "end", + nameTextStyle: { fontSize: 12, fontWeight: "bold", color: "#222", align: "left", padding: [0, 0, 4, -94] }, + }, + visualMap: { + type: "continuous", + min: -absMax, + max: absMax, + calculable: true, // draggable handles to filter the value range + orient: "vertical", + right: 30, + top: TOP_OFFSET + 20, + inRange: { color: BLUE_ORANGE }, + text: [formatQuantity(absMax, unit), formatQuantity(-absMax, unit)], + textStyle: { fontSize: 10 }, + }, + tooltip: { + confine: true, + formatter: (params) => { + const row = params.data && params.data.row; + if (!row) { + return ""; + } + return tooltipTable([ + ["Time and date", formatFullDate(row.event_start)], + [capFirst(sensorType || "value"), formatQuantity(row.event_value, unit)], + ["Source", source.name + " (ID: " + source.id + ")"], + ["Model", source.model || ""], + ["Version", source.version || ""], + ]); + }, + }, + toolbox: toolboxFeatures(elementId, opts.datasetName), + series: [ { - type: "slider", - xAxisIndex: allAxisIndices, - bottom: 14, - height: 28, + type: "heatmap", + data: cells, + emphasis: { itemStyle: { borderColor: "#333", borderWidth: 1 } }, + progressive: 5000, + animation: false, }, ], - }); + }; +} + +/* ============================== main entry points ============================== */ + +/** + * Render (or re-render) the fast chart into the given container element. + * + * @param {string} elementId - The id of the container div. + * @param {Object[]} data - Decompressed chart data rows. + * @param {Object} [options] - { groupSpec, chartType, legendsBelow, datasetName }. + * chartType: "line" (default), "bar_chart", "histogram", "daily_heatmap" or "weekly_heatmap". + */ +export function renderFastChart(elementId, data, options) { + const container = document.getElementById(elementId); + if (!container || typeof echarts === "undefined") { + return; + } + const opts = options || {}; + + let instance = instances[elementId]; + if (!instance || instance.chart.isDisposed()) { + const chart = echarts.init(container, null, { renderer: "canvas" }); + instance = { chart: chart, resizeTimer: null, lastArgs: null, lastOption: null, replayTime: null }; + // Re-render on resize (debounced), so centered titles stay centered + instance.onResize = () => { + clearTimeout(instance.resizeTimer); + instance.resizeTimer = setTimeout(() => { + if (!instance.chart.isDisposed() && instance.lastArgs) { + renderFastChart(elementId, instance.lastArgs.data, instance.lastArgs.options); + } + }, 150); + }; + window.addEventListener("resize", instance.onResize); + instances[elementId] = instance; + } + instance.lastArgs = { data: data, options: options }; + + let option; + if (opts.chartType === "histogram") { + option = buildHistogramOption(elementId, data, opts); + } else if (opts.chartType === "daily_heatmap" || opts.chartType === "weekly_heatmap") { + option = buildHeatmapOption(elementId, data, opts); + } else { + const groups = groupData(data, opts.groupSpec); + option = groups.length > 0 ? buildLineBarOption(elementId, groups, opts) : null; + } + if (!option) { + option = noDataOption(); + } + instance.lastOption = option; + instance.chart.resize(); // pick up container size changes before drawing + instance.chart.setOption(option, { notMerge: true }); +} + +/** + * Set (or clear, with null) the replay belief time, shown as a vertical ruler. + * Takes effect on the next renderFastChart call. + */ +export function setFastChartReplayTime(elementId, beliefTimeMs) { + const instance = instances[elementId]; + if (instance) { + instance.replayTime = beliefTimeMs; + } } /** diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index fb18fe30ec..8f22549724 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -27,7 +27,7 @@ import { subtract, computeSimulationRanges, lastNMonths, encodeUrlQuery, getOffsetBetweenTimezonesForDate, toIsoStringWithOffset } from "{{ url_for('flexmeasures_ui.static', filename='js/daterange-utils.js') }}?v={{ flexmeasures_version }}"; import { partition, updateBeliefs, beliefTimedelta, setAbortableTimeout } from "{{ url_for('flexmeasures_ui.static', filename='js/replay-utils.js') }}?v={{ flexmeasures_version }}"; import { decompressChartData, checkDSTTransitions, checkSourceMasking } from "{{ url_for('flexmeasures_ui.static', filename='js/chart-data-utils.js') }}?v={{ flexmeasures_version }}"; - import { renderFastChart, disposeFastChart } from "{{ url_for('flexmeasures_ui.static', filename='js/fast-chart.js') }}?v={{ flexmeasures_version }}"; + import { renderFastChart, disposeFastChart, setFastChartReplayTime } from "{{ url_for('flexmeasures_ui.static', filename='js/fast-chart.js') }}?v={{ flexmeasures_version }}"; import { beginChartLoad, recordRender, finishChartLoad } from "{{ url_for('flexmeasures_ui.static', filename='js/chart-perf.js') }}?v={{ flexmeasures_version }}"; // Global variables (Module scoped) @@ -214,8 +214,9 @@ item.classList.remove('active'); } }); - // Chart types only apply to the Vega-Lite chart + // In fast mode, re-render with the newly selected chart type if (fastMode) { + updateFastChart(); return; } // Reload daterange @@ -683,6 +684,17 @@ } } + // Options for the fast chart renderer, mirroring the Vega-Lite chart settings + function fastChartOptions() { + return { + groupSpec: fastChartGroupSpec, + // chart types (bar/histogram/heatmap) only apply on the sensor page + chartType: ('{{ active_page }}' === 'sensors') ? chartType : 'line', + legendsBelow: {{ 'true' if session.get("keep_legends_below_graphs") else 'false' }}, + datasetName: datasetName, + }; + } + // Re-render the fast chart from the most recently fetched data (no-op outside fast mode) function updateFastChart() { if (!fastMode || !previousResult || !previousResult.data) { return; } @@ -691,7 +703,7 @@ const fastDiv = document.getElementById(fastChartElementId); if (vegaDiv) { vegaDiv.style.display = 'none'; } if (fastDiv) { fastDiv.style.display = 'block'; } - renderFastChart(fastChartElementId, previousResult.data, { groupSpec: fastChartGroupSpec }); + renderFastChart(fastChartElementId, previousResult.data, fastChartOptions()); } async function embedAndLoad(chartSpecsPath, elementId, datasetName, previousResult, startDate, endDate) { @@ -737,18 +749,7 @@ }); } - function replayUnavailableInFastMode() { - if (fastMode) { - showToast("Replay needs the standard chart. Switch off the fast chart to use replay.", "info"); - return true; - } - return false; - } - function stepReplay() { - if (replayUnavailableInFastMode()) { - return; - } if (toggle.classList.contains('stopped')) { startReplay(); pauseReplay(); @@ -761,9 +762,6 @@ } function toggleReplay() { - if (replayUnavailableInFastMode()) { - return; - } if (toggle.classList.contains('stopped')) { startReplay(); } else if (toggle.classList.contains('playing')) { @@ -793,6 +791,14 @@ controller = new AbortController(); signal = controller.signal; + // In fast mode: clear the replay ruler and re-render the most recent data + if (fastMode) { + setFastChartReplayTime(fastChartElementId, null); + updateFastChart(); + document.getElementById('replay-time').innerHTML = ''; + return; + } + // Remove replay ruler and replay time if (!vegaView) { document.getElementById('replay-time').innerHTML = ''; @@ -878,21 +884,26 @@ // Update beliefs in the replayed data given the new data replayedData = updateBeliefs(replayedData, newData); - if (!vegaView) { - break; - } + if (fastMode) { + setFastChartReplayTime(fastChartElementId, beliefTime.getTime()); + renderFastChart(fastChartElementId, replayedData, fastChartOptions()); + } else { + if (!vegaView) { + break; + } - /** When selecting a longer time period (more than a week), the replay slows down a bit. This - * seems to be mainly from reloading the data into the graph. Slicing the data takes 10-30 ms, and - * loading that data into the graph takes 30-200 ms, depending on how much data is shown in the - * graph. After trying different approaches, we fell back to the original approach of telling vega - * to remove all previous data and to insert a completely new dataset at each iteration. Updating - * the view with removing only a few data points (representing obsolete beliefs) and inserting only - * a few data points (representing the most recent new beliefs) actually made it slower. - */ - vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(replayedData)); - vegaView.change('replay', vega.changeset().remove(vega.truthy).insert({ 'belief_time': beliefTime })); - vegaView.run().finalize(); + /** When selecting a longer time period (more than a week), the replay slows down a bit. This + * seems to be mainly from reloading the data into the graph. Slicing the data takes 10-30 ms, and + * loading that data into the graph takes 30-200 ms, depending on how much data is shown in the + * graph. After trying different approaches, we fell back to the original approach of telling vega + * to remove all previous data and to insert a completely new dataset at each iteration. Updating + * the view with removing only a few data points (representing obsolete beliefs) and inserting only + * a few data points (representing the most recent new beliefs) actually made it slower. + */ + vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(replayedData)); + vegaView.change('replay', vega.changeset().remove(vega.truthy).insert({ 'belief_time': beliefTime })); + vegaView.run().finalize(); + } document.getElementById('replay-time').innerHTML = beliefTime; From 0b5955c0479b918d22b93c4ec61485b6e3c77c45 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 12 Jun 2026 15:29:54 +0200 Subject: [PATCH 05/28] drop the changes that were made for speeding the vegalite Signed-off-by: Ahmad-Wahid --- flexmeasures/ui/utils/chart_defaults.py | 6 +----- flexmeasures/utils/config_defaults.py | 6 +++--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/flexmeasures/ui/utils/chart_defaults.py b/flexmeasures/ui/utils/chart_defaults.py index 5783d4d9d7..088b6e971e 100644 --- a/flexmeasures/ui/utils/chart_defaults.py +++ b/flexmeasures/ui/utils/chart_defaults.py @@ -1,10 +1,6 @@ chart_options = dict( mode="vega-lite", - # canvas renders dense time series much faster than svg (no per-mark DOM nodes) - renderer="canvas", - # fetch chart specs (and any data the spec references) fresh on every load, - # so performance reports measure real network calls rather than cache hits - loader={"http": {"cache": "no-store"}}, + renderer="svg", actions={"export": True, "source": False, "compiled": False, "editor": False}, theme="light", tooltip={"theme": "light"}, diff --git a/flexmeasures/utils/config_defaults.py b/flexmeasures/utils/config_defaults.py index 3f68f415e6..e917f46505 100644 --- a/flexmeasures/utils/config_defaults.py +++ b/flexmeasures/utils/config_defaults.py @@ -175,9 +175,9 @@ class Config(object): FLEXMEASURES_REDIS_DB_NR: int = 0 # Redis per default has 16 databases, [0-15] FLEXMEASURES_REDIS_PASSWORD: str | None = None FLEXMEASURES_JS_VERSIONS: dict = dict( - vega="5.33.1", - vegaembed="6.29.0", - vegalite="5.23.0", # the bar chart issue we had with 5.6.0 (https://github.com/vega/vega-lite/issues/8496) was fixed in 5.6.1 + vega="5.22.1", + vegaembed="6.21.0", + vegalite="5.5.0", # "5.6.0" has a problematic bar chart: see our sensor page and https://github.com/vega/vega-lite/issues/8496 echarts="5.6.0", # used for the fast (canvas-based) chart mode currencysymbolmap="5.1.0", jsoneditor="2.15.2", From ee2d36707a2b9b464f84e184eaf7401f20c33c76 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 12 Jun 2026 15:53:02 +0200 Subject: [PATCH 06/28] connect histgram horizentally and move x-axis to the top of the chart Signed-off-by: Ahmad-Wahid --- flexmeasures/ui/static/js/fast-chart.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index 36d7f53b5f..67ff49417c 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -542,7 +542,7 @@ function buildHistogramOption(elementId, data, opts) { return null; } const container = document.getElementById(elementId); - container.style.height = TOP_OFFSET + 360 + BOTTOM_OFFSET + "px"; + container.style.height = TOP_OFFSET + 400 + BOTTOM_OFFSET + "px"; const values = rows.map((r) => r.event_value); const lo = Math.min(...values); @@ -575,22 +575,25 @@ function buildHistogramOption(elementId, data, opts) { name: label, type: "bar", data: counts, - barGap: "-100%", // overlay sources, as in the Vega-Lite histogram + stack: "counts", // stack sources, as in the Vega-Lite histogram + barCategoryGap: "0%", // contiguous bins, as in the Vega-Lite histogram itemStyle: { opacity: 0.7 }, animation: false, }); } return { - grid: { top: TOP_OFFSET + 20, height: 320, left: GRID_LEFT, right: opts.legendsBelow ? 30 : LEGEND_WIDTH + 40 }, + grid: { top: TOP_OFFSET + 60, height: 320, left: GRID_LEFT, right: opts.legendsBelow ? 30 : LEGEND_WIDTH + 40 }, series: series, xAxis: { type: "category", data: binLabels, + position: "top", // bin labels at the top, as in the Vega-Lite histogram name: yAxisTitle(sensorType, [unit]), nameLocation: "middle", - nameGap: 50, - axisLabel: { rotate: 30, fontSize: 10 }, + nameGap: 40, + nameTextStyle: { fontSize: 12, fontWeight: "bold", color: "#222" }, + axisLabel: { fontSize: 11 }, }, yAxis: { type: "value", @@ -605,7 +608,7 @@ function buildHistogramOption(elementId, data, opts) { orient: opts.legendsBelow ? "horizontal" : "vertical", right: opts.legendsBelow ? undefined : 8, left: opts.legendsBelow ? GRID_LEFT : undefined, - top: opts.legendsBelow ? TOP_OFFSET + 360 : TOP_OFFSET + 20, + top: opts.legendsBelow ? TOP_OFFSET + 395 : TOP_OFFSET + 60, textStyle: { width: LEGEND_WIDTH - 40, overflow: "truncate", fontSize: 11 }, tooltip: { show: true }, }, From 828415dd23b2a70cd4bb431f6c0dd750b3706a47 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 12 Jun 2026 16:05:49 +0200 Subject: [PATCH 07/28] stack the legends below the subplots if option is chosen Signed-off-by: Ahmad-Wahid --- flexmeasures/ui/static/js/fast-chart.js | 71 +++++++++++++++---------- 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index 67ff49417c..42f18ebc50 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -25,7 +25,6 @@ import { convertToCSV } from "./data-utils.js"; const GRID_HEIGHT = 220; // height of each subplot in px const SIDE_GRID_GAP = 56; // vertical space between subplots (axis labels + titles) -const BELOW_GRID_GAP = 110; // extra space when legends go below each subplot const TOP_OFFSET = 48; // room for the toolbox and the first subplot title const BOTTOM_OFFSET = 78; // room for the slider and the last x-axis labels const GRID_LEFT = 70; // room for the y-axis labels @@ -367,13 +366,24 @@ function buildLineBarOption(elementId, groups, opts) { const instance = instances[elementId]; const container = document.getElementById(elementId); const legendsBelow = !!opts.legendsBelow; - const gridGap = legendsBelow ? BELOW_GRID_GAP : SIDE_GRID_GAP; + const gridGap = SIDE_GRID_GAP; const gridRight = legendsBelow ? 30 : LEGEND_WIDTH + 40; const containerWidth = container.clientWidth || 800; const plotCenter = (GRID_LEFT + containerWidth - gridRight) / 2; - container.style.height = - TOP_OFFSET + groups.length * (GRID_HEIGHT + gridGap) + BOTTOM_OFFSET + "px"; + // Vertical layout: subplots, then (in legends-below mode) the slider and + // one combined legend at the very bottom, as in the Vega-Lite charts + const lastGridBottom = + TOP_OFFSET + groups.length * GRID_HEIGHT + (groups.length - 1) * gridGap; + const numSeries = groups.reduce((sum, g) => sum + g.series.length, 0); + const itemsPerRow = Math.max(Math.floor((containerWidth - GRID_LEFT - 30) / 220), 1); + const legendHeight = Math.ceil(numSeries / itemsPerRow) * 24 + 8; + const sliderTop = lastGridBottom + 36; + const bottomLegendTop = sliderTop + 28 + 18; + + container.style.height = legendsBelow + ? bottomLegendTop + legendHeight + 12 + "px" + : TOP_OFFSET + groups.length * (GRID_HEIGHT + gridGap) + BOTTOM_OFFSET + "px"; const grids = []; const xAxes = []; @@ -399,28 +409,16 @@ function buildLineBarOption(elementId, groups, opts) { top: top - 42, textStyle: { fontSize: 15, color: "#222" }, }); - const legend = { - // One legend per subplot, listing only its own series - data: group.series.map((s) => s.name), - type: "scroll", - // For single-sensor subplots the sensor is already in the title, so only show the source - formatter: group.multiSensor - ? undefined - : (name) => name.split(" · ").slice(1).join(" · ") || name, - tooltip: { show: true }, // hover reveals truncated names in full - }; - if (legendsBelow) { - Object.assign(legend, { - orient: "horizontal", - left: GRID_LEFT, - right: gridRight, - top: top + GRID_HEIGHT + 32, - itemWidth: 18, - itemGap: 10, - textStyle: { fontSize: 11 }, - }); - } else { - Object.assign(legend, { + if (!legendsBelow) { + legends.push({ + // One vertical legend beside each subplot, listing only its own series + data: group.series.map((s) => s.name), + type: "scroll", + // For single-sensor subplots the sensor is already in the title, so only show the source + formatter: group.multiSensor + ? undefined + : (name) => name.split(" · ").slice(1).join(" · ") || name, + tooltip: { show: true }, // hover reveals truncated names in full orient: "vertical", right: 8, top: top, @@ -431,7 +429,6 @@ function buildLineBarOption(elementId, groups, opts) { textStyle: { width: LEGEND_WIDTH - 40, overflow: "truncate", fontSize: 11 }, }); } - legends.push(legend); xAxes.push({ type: "time", gridIndex: i, @@ -495,6 +492,22 @@ function buildLineBarOption(elementId, groups, opts) { }); }); + if (legendsBelow) { + // One combined legend below all subplots, as in the Vega-Lite charts + legends.push({ + data: seriesMeta.map((s) => s.name), + type: "plain", // wraps into multiple rows + orient: "horizontal", + left: GRID_LEFT, + right: 30, + top: bottomLegendTop, + itemWidth: 18, + itemGap: 12, + textStyle: { fontSize: 11 }, + tooltip: { show: true }, + }); + } + const allAxisIndices = xAxes.map((_, i) => i); const toolbox = toolboxFeatures(elementId, opts.datasetName); toolbox.feature.dataZoom.xAxisIndex = allAxisIndices; @@ -517,7 +530,9 @@ function buildLineBarOption(elementId, groups, opts) { toolbox: toolbox, dataZoom: [ { type: "inside", xAxisIndex: allAxisIndices }, // mouse-wheel zoom and drag-to-pan - { type: "slider", xAxisIndex: allAxisIndices, bottom: 14, height: 28 }, + legendsBelow + ? { type: "slider", xAxisIndex: allAxisIndices, top: sliderTop, height: 28 } + : { type: "slider", xAxisIndex: allAxisIndices, bottom: 14, height: 28 }, ], }; } From 6ed06f0bd237e0db33c2b8a069be331ad5b1d30f Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 12 Jun 2026 16:18:18 +0200 Subject: [PATCH 08/28] fix: use asset name with sensor name in the chart legends Signed-off-by: Ahmad-Wahid --- flexmeasures/ui/static/js/fast-chart.js | 80 +++++++++++++++++++++---- 1 file changed, 70 insertions(+), 10 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index 42f18ebc50..5b485acf78 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -197,13 +197,25 @@ function groupData(data, groupSpec) { .points.push([row.event_start, row.event_value, row.belief_horizon]); } + // Mirror the Vega-Lite legend encoding: + // - multi-sensor (asset) charts: one legend entry per sensor, labeled + // "sensor (asset)", with sources distinguished by line style; + // - single-sensor (sensor page) charts: one legend entry per source. + const uniqueSensorKeys = new Set(); + for (const group of groups) { + for (const s of group.series.values()) { + uniqueSensorKeys.add(s.sensorDescription || s.sensorName); + } + } + const nameBySensor = uniqueSensorKeys.size > 1; + return groups.map((group) => { const series = []; for (const s of group.series.values()) { s.points.sort((a, b) => a[0] - b[0]); - // Series names must be globally unique, so that each subplot's legend - // only toggles its own series (names are shared state across legends). - s.name = s.sensorName + " · " + s.sourceLabel; + // Series sharing a name (same sensor, several sources) toggle together + // via their shared legend entry, as in the Vega-Lite charts. + s.name = nameBySensor ? s.sensorDescription || s.sensorName : s.sourceLabel; s.sensorType = group.sensorType || s.sensorName; series.push(s); } @@ -213,11 +225,32 @@ function groupData(data, groupSpec) { units: Array.from(group.units), sensorType: group.sensorType, multiSensor: group.sensorNames.size > 1, + nameBySensor: nameBySensor, series: series, }; }); } +// Colors per sensor, matching the Vega-Lite charts' category scheme +const SENSOR_COLORS = [ + "#1f77b4", "#ff7f0e", "#d62728", "#76b7b2", "#2ca02c", + "#bcbd22", "#9467bd", "#f7b6d2", "#8c564b", "#c7c7c7", + "#17becf", "#e377c2", "#aec7e8", "#ffbb78", "#98df8a", + "#ff9896", "#c5b0d5", "#c49c94", "#dbdb8d", "#9edae5", +]; + +// Line styles per source type, as in the Vega-Lite charts' "Source" legend +function lineTypeForSource(source) { + const type = String(source.display_type || source.type || "").toLowerCase(); + if (type.includes("forecast")) { + return "dotted"; + } + if (type.includes("schedul")) { + return "dashed"; + } + return "solid"; +} + // Keep only the rows of the source with the most data (as the Vega-Lite heatmaps do) function mostPrevalentSourceRows(rows) { const counts = new Map(); @@ -392,6 +425,7 @@ function buildLineBarOption(elementId, groups, opts) { const legends = []; const series = []; const seriesMeta = []; + const sensorColor = new Map(); groups.forEach((group, i) => { const top = TOP_OFFSET + i * (GRID_HEIGHT + gridGap); @@ -412,12 +446,8 @@ function buildLineBarOption(elementId, groups, opts) { if (!legendsBelow) { legends.push({ // One vertical legend beside each subplot, listing only its own series - data: group.series.map((s) => s.name), + data: Array.from(new Set(group.series.map((s) => s.name))), type: "scroll", - // For single-sensor subplots the sensor is already in the title, so only show the source - formatter: group.multiSensor - ? undefined - : (name) => name.split(" · ").slice(1).join(" · ") || name, tooltip: { show: true }, // hover reveals truncated names in full orient: "vertical", right: 8, @@ -462,6 +492,14 @@ function buildLineBarOption(elementId, groups, opts) { emphasis: { focus: "series" }, animation: false, }; + // One color per sensor: series of the same sensor share their legend + // entry's color, and sources are told apart by line style instead + if (group.nameBySensor) { + if (!sensorColor.has(s.name)) { + sensorColor.set(s.name, SENSOR_COLORS[sensorColor.size % SENSOR_COLORS.length]); + } + entry.color = sensorColor.get(s.name); + } if (isBar) { Object.assign(entry, { barGap: "-100%", // overlay sources, as in the Vega-Lite bar chart @@ -473,7 +511,7 @@ function buildLineBarOption(elementId, groups, opts) { step: "start", // events hold their value for the duration of the event showSymbol: false, sampling: "lttb", // downsample to the available pixels, preserving peaks - lineStyle: { width: 2.2 }, + lineStyle: { width: 2.2, type: lineTypeForSource(s.source) }, }); } // Replay ruler: a vertical line at the current belief time @@ -495,7 +533,7 @@ function buildLineBarOption(elementId, groups, opts) { if (legendsBelow) { // One combined legend below all subplots, as in the Vega-Lite charts legends.push({ - data: seriesMeta.map((s) => s.name), + data: Array.from(new Set(seriesMeta.map((s) => s.name))), type: "plain", // wraps into multiple rows orient: "horizontal", left: GRID_LEFT, @@ -508,11 +546,33 @@ function buildLineBarOption(elementId, groups, opts) { }); } + // Source line-style key, as in the Vega-Lite charts' "Source" legend + let sourceKey = null; + if (opts.chartType !== "bar_chart" && groups[0].nameBySensor) { + const lineTypes = new Set(seriesMeta.map((s) => lineTypeForSource(s.source))); + if (lineTypes.size > 1) { + const parts = []; + if (lineTypes.has("dotted")) parts.push("┄ forecaster"); + if (lineTypes.has("dashed")) parts.push("╌ scheduler"); + if (lineTypes.has("solid")) parts.push("─ other"); + sourceKey = { + type: "text", + left: GRID_LEFT, + top: legendsBelow ? bottomLegendTop + legendHeight + 4 : 16, + style: { text: "Source: " + parts.join(" "), fontSize: 11, fill: "#555" }, + }; + if (legendsBelow) { + container.style.height = bottomLegendTop + legendHeight + 36 + "px"; + } + } + } + const allAxisIndices = xAxes.map((_, i) => i); const toolbox = toolboxFeatures(elementId, opts.datasetName); toolbox.feature.dataZoom.xAxisIndex = allAxisIndices; return { + graphic: sourceKey ? [sourceKey] : undefined, grid: grids, title: titles, xAxis: xAxes, From 3a508835e1cf8c0a460152341ed34910ad7ade9d Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 12 Jun 2026 16:29:16 +0200 Subject: [PATCH 09/28] fix: show source chart lines Signed-off-by: Ahmad-Wahid --- flexmeasures/ui/static/js/fast-chart.js | 76 +++++++++++++++++-------- 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index 5b485acf78..daf6b8c250 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -53,6 +53,38 @@ function capFirst(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; } +// "Source" key with little line samples (dotted/dashed/solid + label per type), +// mirroring the Vega-Lite charts' Source legend. Returns one ECharts graphic +// group laid out as a single horizontal strip. Reserve SOURCE_KEY_HEIGHT for it. +const SOURCE_KEY_HEIGHT = 22; + +function buildSourceKey(left, top) { + const children = [ + { + type: "text", + left: 0, + top: 3, + style: { text: "Source", font: "bold 12px sans-serif", fill: "#222" }, + }, + ]; + let x = 56; + for (const row of SOURCE_KEY_ROWS) { + children.push({ + type: "line", + shape: { x1: x, y1: 9, x2: x + 26, y2: 9 }, + style: { stroke: "#555", lineWidth: 1.5, lineDash: row.dash || null }, + }); + children.push({ + type: "text", + left: x + 32, + top: 3, + style: { text: row.label, fontSize: 11, fill: "#222" }, + }); + x += 32 + row.label.length * 6.5 + 22; + } + return { type: "group", left: left, top: top, children: children }; +} + function escapeHtml(s) { return String(s == null ? "" : s).replace( /[&<>"]/g, @@ -239,7 +271,14 @@ const SENSOR_COLORS = [ "#ff9896", "#c5b0d5", "#c49c94", "#dbdb8d", "#9edae5", ]; -// Line styles per source type, as in the Vega-Lite charts' "Source" legend +// Line styles per source type, as in the Vega-Lite charts' "Source" legend. +// The three rows of the source key below must list these same dash patterns. +const SOURCE_KEY_ROWS = [ + { label: "forecaster", dash: [2, 3] }, + { label: "scheduler", dash: [7, 4] }, + { label: "other", dash: null }, // solid +]; + function lineTypeForSource(source) { const type = String(source.display_type || source.type || "").toLowerCase(); if (type.includes("forecast")) { @@ -404,10 +443,15 @@ function buildLineBarOption(elementId, groups, opts) { const containerWidth = container.clientWidth || 800; const plotCenter = (GRID_LEFT + containerWidth - gridRight) / 2; + // The Source key (line-style legend) is shown on sensor-colored line charts, + // where sources are told apart by line style; it sits in a strip at the top. + const showSourceKey = opts.chartType !== "bar_chart" && groups[0].nameBySensor; + const topOffset = TOP_OFFSET + (showSourceKey ? SOURCE_KEY_HEIGHT : 0); + // Vertical layout: subplots, then (in legends-below mode) the slider and // one combined legend at the very bottom, as in the Vega-Lite charts const lastGridBottom = - TOP_OFFSET + groups.length * GRID_HEIGHT + (groups.length - 1) * gridGap; + topOffset + groups.length * GRID_HEIGHT + (groups.length - 1) * gridGap; const numSeries = groups.reduce((sum, g) => sum + g.series.length, 0); const itemsPerRow = Math.max(Math.floor((containerWidth - GRID_LEFT - 30) / 220), 1); const legendHeight = Math.ceil(numSeries / itemsPerRow) * 24 + 8; @@ -416,7 +460,7 @@ function buildLineBarOption(elementId, groups, opts) { container.style.height = legendsBelow ? bottomLegendTop + legendHeight + 12 + "px" - : TOP_OFFSET + groups.length * (GRID_HEIGHT + gridGap) + BOTTOM_OFFSET + "px"; + : topOffset + groups.length * (GRID_HEIGHT + gridGap) + BOTTOM_OFFSET + "px"; const grids = []; const xAxes = []; @@ -428,7 +472,7 @@ function buildLineBarOption(elementId, groups, opts) { const sensorColor = new Map(); groups.forEach((group, i) => { - const top = TOP_OFFSET + i * (GRID_HEIGHT + gridGap); + const top = topOffset + i * (GRID_HEIGHT + gridGap); grids.push({ top: top, height: GRID_HEIGHT, @@ -546,26 +590,10 @@ function buildLineBarOption(elementId, groups, opts) { }); } - // Source line-style key, as in the Vega-Lite charts' "Source" legend - let sourceKey = null; - if (opts.chartType !== "bar_chart" && groups[0].nameBySensor) { - const lineTypes = new Set(seriesMeta.map((s) => lineTypeForSource(s.source))); - if (lineTypes.size > 1) { - const parts = []; - if (lineTypes.has("dotted")) parts.push("┄ forecaster"); - if (lineTypes.has("dashed")) parts.push("╌ scheduler"); - if (lineTypes.has("solid")) parts.push("─ other"); - sourceKey = { - type: "text", - left: GRID_LEFT, - top: legendsBelow ? bottomLegendTop + legendHeight + 4 : 16, - style: { text: "Source: " + parts.join(" "), fontSize: 11, fill: "#555" }, - }; - if (legendsBelow) { - container.style.height = bottomLegendTop + legendHeight + 36 + "px"; - } - } - } + // Source line-style key, as in the Vega-Lite charts' "Source" legend. + // Always shown (all three types) on the sensor-colored line charts, in the + // strip reserved at the top-left, clear of the toolbox and the side legends. + const sourceKey = showSourceKey ? buildSourceKey(GRID_LEFT, 6) : null; const allAxisIndices = xAxes.map((_, i) => i); const toolbox = toolboxFeatures(elementId, opts.datasetName); From 9572d442606b99396fde226c88da8cdb755f3627 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 12 Jun 2026 20:09:54 +0200 Subject: [PATCH 10/28] fix: vertical concatination of subplots Signed-off-by: Ahmad-Wahid --- flexmeasures/ui/static/js/fast-chart.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index daf6b8c250..1e59d1b321 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -24,9 +24,9 @@ import { convertToCSV } from "./data-utils.js"; const GRID_HEIGHT = 220; // height of each subplot in px -const SIDE_GRID_GAP = 56; // vertical space between subplots (axis labels + titles) +const SIDE_GRID_GAP = 82; // vertical space between subplots (two-line x-axis labels + next title) const TOP_OFFSET = 48; // room for the toolbox and the first subplot title -const BOTTOM_OFFSET = 78; // room for the slider and the last x-axis labels +const BOTTOM_OFFSET = 92; // room for the slider and the last two-line x-axis labels const GRID_LEFT = 70; // room for the y-axis labels const LEGEND_WIDTH = 190; // width of the legend column beside each subplot @@ -509,6 +509,21 @@ function buildLineBarOption(elementId, groups, opts) { axisLine: { onZero: false }, axisPointer: { show: true }, // vertical ruler, as in the Vega-Lite charts splitLine: { show: true, lineStyle: { opacity: 0.5 } }, + minInterval: 6 * 3600 * 1000, // at most one tick per 6h so 12:00 labels appear + axisLabel: { + formatter: (value) => { + const d = new Date(value); + const h = d.getHours(), m = d.getMinutes(); + if (h === 0 && m === 0) { + const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + if (d.getDate() === 1) return MONTHS[d.getMonth()]; + return DAYS[d.getDay()] + "\n" + String(d.getDate()).padStart(2, "0"); + } + return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"); + }, + }, }); yAxes.push({ type: "value", From 1f56d009fc375755a7192f8e1679d8cad2d9322c Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sat, 13 Jun 2026 23:45:22 +0200 Subject: [PATCH 11/28] fix: ECharts Signed-off-by: Ahmad-Wahid --- flexmeasures/ui/static/js/fast-chart.js | 78 +++++++++++-------- .../ui/templates/includes/graphs.html | 2 + 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index 1e59d1b321..a7e2fa9e0e 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -23,12 +23,16 @@ import { convertToCSV } from "./data-utils.js"; +// Global text style matching Vega-Lite: Poppins font, 16 px labels (Vega's FONT_SIZE = 16) +const CHART_FONT = "Poppins, sans-serif"; +const FONT_SIZE = 16; + const GRID_HEIGHT = 220; // height of each subplot in px const SIDE_GRID_GAP = 82; // vertical space between subplots (two-line x-axis labels + next title) const TOP_OFFSET = 48; // room for the toolbox and the first subplot title const BOTTOM_OFFSET = 92; // room for the slider and the last two-line x-axis labels const GRID_LEFT = 70; // room for the y-axis labels -const LEGEND_WIDTH = 190; // width of the legend column beside each subplot +const LEGEND_WIDTH = 220; // width of the legend column beside each subplot // Diverging color scale approximating Vega's "blueorange" scheme (centered at 0) const BLUE_ORANGE = ["#2166ac", "#67a9cf", "#d1e5f0", "#f7f7f7", "#fee0b6", "#f1a340", "#b35806"]; @@ -220,13 +224,14 @@ function groupData(data, groupSpec) { source: source, unit: unit, points: [], + eventStarts: [], // kept for resolution inference (not sent to ECharts) }); } + const ser = group.series.get(seriesKey); // Third dimension carries the belief horizon (ms) for the tooltip; // LTTB sampling selects original points, so it survives downsampling. - group.series - .get(seriesKey) - .points.push([row.event_start, row.event_value, row.belief_horizon]); + ser.points.push([row.event_start, row.event_value, row.belief_horizon]); + ser.eventStarts.push(row.event_start); } // Mirror the Vega-Lite legend encoding: @@ -264,11 +269,10 @@ function groupData(data, groupSpec) { } // Colors per sensor, matching the Vega-Lite charts' category scheme +// Vega-Lite's default categorical color scheme (tableau10), so sensor colors match the old charts const SENSOR_COLORS = [ - "#1f77b4", "#ff7f0e", "#d62728", "#76b7b2", "#2ca02c", - "#bcbd22", "#9467bd", "#f7b6d2", "#8c564b", "#c7c7c7", - "#17becf", "#e377c2", "#aec7e8", "#ffbb78", "#98df8a", - "#ff9896", "#c5b0d5", "#c49c94", "#dbdb8d", "#9edae5", + "#4c78a8", "#f58518", "#e45756", "#72b7b2", "#54a24b", + "#eeca3b", "#b279a2", "#ff9da6", "#9d755d", "#bab0ac", ]; // Line styles per source type, as in the Vega-Lite charts' "Source" legend. @@ -308,19 +312,20 @@ function mostPrevalentSourceRows(rows) { return rows.filter((row) => (row.source ? row.source.id : null) === bestSid); } -// Smallest positive gap between consecutive event starts (the sensor resolution) -function inferResolutionMs(rows) { - const starts = Array.from(new Set(rows.map((r) => r.event_start))).sort((a, b) => a - b); +// Smallest positive gap between consecutive event starts (the sensor resolution). +// Accepts either raw data rows (with .event_start) or a plain array of timestamps. +function inferResolutionMs(rowsOrTimestamps) { + const starts = Array.from( + new Set( + rowsOrTimestamps.map((x) => (typeof x === "number" ? x : x.event_start)) + ) + ).sort((a, b) => a - b); let res = Infinity; for (let i = 1; i < starts.length; i++) { const gap = starts[i] - starts[i - 1]; - if (gap > 0 && gap < res) { - res = gap; - } - } - if (!isFinite(res)) { - res = 60 * 60 * 1000; + if (gap > 0 && gap < res) res = gap; } + if (!isFinite(res)) res = 60 * 60 * 1000; return Math.max(res, 60 * 1000); // at least 1 minute, to bound the number of cells } @@ -437,15 +442,19 @@ function seriesTooltipFormatter(seriesMeta) { function buildLineBarOption(elementId, groups, opts) { const instance = instances[elementId]; const container = document.getElementById(elementId); - const legendsBelow = !!opts.legendsBelow; + // Sensor page: legend always below (single sensor, sources are the legend entries) + const legendsBelow = !!opts.legendsBelow || opts.isSensorPage; const gridGap = SIDE_GRID_GAP; const gridRight = legendsBelow ? 30 : LEGEND_WIDTH + 40; const containerWidth = container.clientWidth || 800; const plotCenter = (GRID_LEFT + containerWidth - gridRight) / 2; - // The Source key (line-style legend) is shown on sensor-colored line charts, - // where sources are told apart by line style; it sits in a strip at the top. - const showSourceKey = opts.chartType !== "bar_chart" && groups[0].nameBySensor; + // The Source key is only shown when multiple source types are actually present, + // matching Vega-Lite which only adds the strokeDash legend when needed. + const allSourceTypes = new Set( + groups.flatMap((g) => g.series.map((s) => lineTypeForSource(s.source))) + ); + const showSourceKey = opts.chartType !== "bar_chart" && groups[0].nameBySensor && allSourceTypes.size > 1; const topOffset = TOP_OFFSET + (showSourceKey ? SOURCE_KEY_HEIGHT : 0); // Vertical layout: subplots, then (in legends-below mode) the slider and @@ -500,7 +509,7 @@ function buildLineBarOption(elementId, groups, opts) { align: "left", itemWidth: 18, itemGap: 6, - textStyle: { width: LEGEND_WIDTH - 40, overflow: "truncate", fontSize: 11 }, + textStyle: { fontSize: FONT_SIZE }, }); } xAxes.push({ @@ -531,13 +540,12 @@ function buildLineBarOption(elementId, groups, opts) { name: yAxisTitle(group.sensorType, group.units), // e.g. "Power (kW)" nameLocation: "end", nameTextStyle: { - fontSize: 12, + fontSize: FONT_SIZE, fontWeight: "bold", color: "#222", align: "left", padding: [0, 0, 4, -GRID_LEFT + 16], }, - scale: true, splitLine: { show: true, lineStyle: { opacity: 0.7 } }, }); group.series.forEach((s, j) => { @@ -566,8 +574,13 @@ function buildLineBarOption(elementId, groups, opts) { itemStyle: { opacity: 0.7 }, }); } else { + // Match Vega-Lite: use step-after for sensors with a fixed event duration + // (resolution > 0), and linear for instantaneous sensors (resolution ≈ 0) + // so that ramps / gradual curves are visible, just as in the old charts. + const seriesResMs = inferResolutionMs(s.eventStarts || []); + const isInstantaneous = seriesResMs <= 60 * 1000; // ≤ 1 min → treat as instantaneous Object.assign(entry, { - step: "start", // events hold their value for the duration of the event + ...(isInstantaneous ? {} : { step: "end" }), // step-after for interval data showSymbol: false, sampling: "lttb", // downsample to the available pixels, preserving peaks lineStyle: { width: 2.2, type: lineTypeForSource(s.source) }, @@ -600,7 +613,7 @@ function buildLineBarOption(elementId, groups, opts) { top: bottomLegendTop, itemWidth: 18, itemGap: 12, - textStyle: { fontSize: 11 }, + textStyle: { fontSize: FONT_SIZE }, tooltip: { show: true }, }); } @@ -615,6 +628,7 @@ function buildLineBarOption(elementId, groups, opts) { toolbox.feature.dataZoom.xAxisIndex = allAxisIndices; return { + textStyle: { fontFamily: CHART_FONT, fontSize: FONT_SIZE }, graphic: sourceKey ? [sourceKey] : undefined, grid: grids, title: titles, @@ -710,14 +724,14 @@ function buildHistogramOption(elementId, data, opts) { name: yAxisTitle(sensorType, [unit]), nameLocation: "middle", nameGap: 40, - nameTextStyle: { fontSize: 12, fontWeight: "bold", color: "#222" }, - axisLabel: { fontSize: 11 }, + nameTextStyle: { fontSize: FONT_SIZE, fontWeight: "bold", color: "#222" }, + axisLabel: { fontSize: FONT_SIZE }, }, yAxis: { type: "value", name: "Count", nameLocation: "end", - nameTextStyle: { fontSize: 12, fontWeight: "bold", color: "#222", align: "left", padding: [0, 0, 4, -GRID_LEFT + 16] }, + nameTextStyle: { fontSize: FONT_SIZE, fontWeight: "bold", color: "#222", align: "left", padding: [0, 0, 4, -GRID_LEFT + 16] }, splitLine: { show: true, lineStyle: { opacity: 0.7 } }, }, legend: { @@ -727,7 +741,7 @@ function buildHistogramOption(elementId, data, opts) { right: opts.legendsBelow ? undefined : 8, left: opts.legendsBelow ? GRID_LEFT : undefined, top: opts.legendsBelow ? TOP_OFFSET + 395 : TOP_OFFSET + 60, - textStyle: { width: LEGEND_WIDTH - 40, overflow: "truncate", fontSize: 11 }, + textStyle: { fontSize: FONT_SIZE }, tooltip: { show: true }, }, tooltip: { @@ -744,6 +758,7 @@ function buildHistogramOption(elementId, data, opts) { ]); }, }, + textStyle: { fontFamily: CHART_FONT, fontSize: FONT_SIZE }, toolbox: toolboxFeatures(elementId, opts.datasetName), }; } @@ -822,7 +837,7 @@ function buildHeatmapOption(elementId, data, opts) { inverse: true, // earliest at the top, as in the Vega-Lite heatmaps name: yAxisTitle(sensorType, [unit]), nameLocation: "end", - nameTextStyle: { fontSize: 12, fontWeight: "bold", color: "#222", align: "left", padding: [0, 0, 4, -94] }, + nameTextStyle: { fontSize: FONT_SIZE, fontWeight: "bold", color: "#222", align: "left", padding: [0, 0, 4, -94] }, }, visualMap: { type: "continuous", @@ -852,6 +867,7 @@ function buildHeatmapOption(elementId, data, opts) { ]); }, }, + textStyle: { fontFamily: CHART_FONT, fontSize: FONT_SIZE }, toolbox: toolboxFeatures(elementId, opts.datasetName), series: [ { diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index 8f22549724..1a889f40cb 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -691,6 +691,8 @@ // chart types (bar/histogram/heatmap) only apply on the sensor page chartType: ('{{ active_page }}' === 'sensors') ? chartType : 'line', legendsBelow: {{ 'true' if session.get("keep_legends_below_graphs") else 'false' }}, + // sensor page always shows legend below (single sensor, sources are the legend entries) + isSensorPage: ('{{ active_page }}' === 'sensors'), datasetName: datasetName, }; } From af2a6e1bc1c344921b7255dd50644fb007969eae Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 23 Jun 2026 13:52:52 +0200 Subject: [PATCH 12/28] fix: derive sensor-type from shared unit and improve ECharts legends Co-Authored-By: Claude Opus 4.8 --- flexmeasures/ui/__init__.py | 18 +++++++++ flexmeasures/ui/static/js/fast-chart.js | 38 +++++++++++++------ .../ui/templates/includes/graphs.html | 7 +++- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/flexmeasures/ui/__init__.py b/flexmeasures/ui/__init__.py index 08218f6da9..95c248acde 100644 --- a/flexmeasures/ui/__init__.py +++ b/flexmeasures/ui/__init__.py @@ -131,6 +131,23 @@ def basic_admin_auth(): def add_jinja_filters(app): from flexmeasures.ui.utils.view_utils import asset_icon_name, username, accountname + from flexmeasures.data.models.charts.belief_charts import ( + determine_shared_sensor_type, + ) + + def shared_sensor_type(sensors): + """Mirror the Vega-Lite y-axis title logic: if all sensors share a + sensor type use it, otherwise derive the dimension from the shared unit + (e.g. a group of kW sensors becomes "power"). + + ``sensor_type`` is normally set lazily inside the chart methods, so we + populate it here (as ``get_attribute("sensor_type", name)``) before + delegating to the shared helper. + """ + sensors = list(sensors) + for sensor in sensors: + sensor.sensor_type = sensor.get_attribute("sensor_type", sensor.name) + return determine_shared_sensor_type(sensors) app.jinja_env.filters["zip"] = zip # Allow zip function in templates app.jinja_env.add_extension( @@ -143,6 +160,7 @@ def add_jinja_filters(app): app.jinja_env.filters["capitalize"] = capitalize app.jinja_env.filters["pluralize"] = pluralize app.jinja_env.filters["parameterize"] = parameterize + app.jinja_env.filters["shared_sensor_type"] = shared_sensor_type app.jinja_env.filters["isnull"] = pd.isnull app.jinja_env.filters["hide_nan_if_desired"] = lambda x: ( "" diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index a7e2fa9e0e..8632c6cc5e 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -461,9 +461,12 @@ function buildLineBarOption(elementId, groups, opts) { // one combined legend at the very bottom, as in the Vega-Lite charts const lastGridBottom = topOffset + groups.length * GRID_HEIGHT + (groups.length - 1) * gridGap; - const numSeries = groups.reduce((sum, g) => sum + g.series.length, 0); - const itemsPerRow = Math.max(Math.floor((containerWidth - GRID_LEFT - 30) / 220), 1); - const legendHeight = Math.ceil(numSeries / itemsPerRow) * 24 + 8; + // Bottom legend stacks vertically (one entry per row) as in the Vega-Lite + // charts, so its height grows with the number of distinct sensor entries. + const numLegendEntries = new Set( + groups.flatMap((g) => g.series.map((s) => s.name)) + ).size; + const legendHeight = numLegendEntries * 24 + 8; const sliderTop = lastGridBottom + 36; const bottomLegendTop = sliderTop + 28 + 18; @@ -497,19 +500,32 @@ function buildLineBarOption(elementId, groups, opts) { textStyle: { fontSize: 15, color: "#222" }, }); if (!legendsBelow) { + const legendNames = Array.from(new Set(group.series.map((s) => s.name))); + // Vertically center the legend beside its subplot rather than pinning it + // to the top of the chart. Estimate the rendered height (one row per entry) + // and offset from the grid top by half the leftover space. + const rowHeight = FONT_SIZE + 6; // font size + itemGap between rows + const legendContentHeight = legendNames.length * rowHeight; + const legendTop = top + Math.max(0, (GRID_HEIGHT - legendContentHeight) / 2); legends.push({ // One vertical legend beside each subplot, listing only its own series - data: Array.from(new Set(group.series.map((s) => s.name))), + data: legendNames, type: "scroll", tooltip: { show: true }, // hover reveals truncated names in full orient: "vertical", right: 8, - top: top, + top: legendTop, height: GRID_HEIGHT, align: "left", itemWidth: 18, itemGap: 6, - textStyle: { fontSize: FONT_SIZE }, + textStyle: { + fontSize: FONT_SIZE, + // Truncate long names with an ellipsis, e.g. "sensor-007 (bulk...)". + // The full name is still revealed on hover via the legend tooltip. + width: LEGEND_WIDTH - 30, + overflow: "truncate", + }, }); } xAxes.push({ @@ -603,16 +619,16 @@ function buildLineBarOption(elementId, groups, opts) { }); if (legendsBelow) { - // One combined legend below all subplots, as in the Vega-Lite charts + // One combined legend below all subplots, stacked vertically (one entry per + // row) as in the Vega-Lite charts rather than wrapping horizontally. legends.push({ data: Array.from(new Set(seriesMeta.map((s) => s.name))), - type: "plain", // wraps into multiple rows - orient: "horizontal", + type: "scroll", + orient: "vertical", left: GRID_LEFT, - right: 30, top: bottomLegendTop, itemWidth: 18, - itemGap: 12, + itemGap: 6, textStyle: { fontSize: FONT_SIZE }, tooltip: { show: true }, }); diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index 1a889f40cb..000ba9d346 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -87,10 +87,13 @@ {% elif item.sensor is defined and item.sensor %} {% set entry.sensors = [item.sensor] %} {% endif %} - {# y-axis title source: sensor_type of the first real (DB-backed) sensor; fixed-value sensors (negative ids) only have a name #} + {# y-axis title source: shared sensor type of the real (DB-backed) sensors. #} + {# If they don't share one type, derive the dimension from the shared unit #} + {# (e.g. a group of kW sensors becomes "power"), matching the Vega-Lite charts. #} + {# Fixed-value sensors (negative ids) only have a name. #} {% set real_sensors = entry.sensors | selectattr("id", "gt", 0) | list %} {% if real_sensors %} - {% set entry_sensor_type = real_sensors[0].get_attribute("sensor_type", real_sensors[0].name) %} + {% set entry_sensor_type = real_sensors | shared_sensor_type %} {% elif entry.sensors %} {% set entry_sensor_type = entry.sensors[0].name %} {% else %} From 383993a3da6126c8fcb708f878fbf7829b9ff970 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 23 Jun 2026 14:07:04 +0200 Subject: [PATCH 13/28] fix: export full legends without toolbox and drop restore button --- flexmeasures/ui/static/js/fast-chart.js | 55 +++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index 8632c6cc5e..8f3ca4df77 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -343,8 +343,12 @@ function toolboxFeatures(elementId, datasetName) { right: 16, feature: { dataZoom: { yAxisIndex: false }, - restore: {}, - saveAsImage: { name: datasetName || "flexmeasures-chart", title: "Save as PNG" }, + mySavePNG: { + show: true, + title: "Save as PNG", + icon: "path://M4 5 L20 5 L20 19 L4 19 Z M8 14 L11 10 L13 13 L15 11 L18 15 L8 15 Z", + onclick: () => exportPNG(elementId, datasetName), + }, mySaveCSV: { show: true, title: "Save as CSV", @@ -382,6 +386,24 @@ function exportCSV(elementId, datasetName) { downloadBlob(content, "text/csv;charset=utf-8", (datasetName || "chart") + ".csv"); } +// Build the option used for image exports: no toolbox buttons, and "scroll" +// legends switched to "plain" so every entry renders instead of just one page. +function buildExportOption(lastOption) { + const legend = ( + Array.isArray(lastOption.legend) + ? lastOption.legend + : lastOption.legend + ? [lastOption.legend] + : [] + ).map((l) => Object.assign({}, l, { type: "plain" })); + return Object.assign({}, lastOption, { + animation: false, + backgroundColor: "#fff", + toolbox: { show: false }, + legend: legend, + }); +} + function exportSVG(elementId, datasetName) { const instance = instances[elementId]; if (!instance || !instance.lastOption || typeof echarts === "undefined") { @@ -395,13 +417,40 @@ function exportSVG(elementId, datasetName) { height: instance.chart.getHeight(), }); try { - svgChart.setOption(Object.assign({}, instance.lastOption, { animation: false, backgroundColor: "#fff" })); + svgChart.setOption(buildExportOption(instance.lastOption)); downloadBlob(svgChart.renderToSVGString(), "image/svg+xml", (datasetName || "chart") + ".svg"); } finally { svgChart.dispose(); } } +function exportPNG(elementId, datasetName) { + const instance = instances[elementId]; + if (!instance || !instance.lastOption || typeof echarts === "undefined") { + return; + } + // Render to a detached canvas instance so the saved PNG shows all legend + // entries and no toolbox, instead of snapshotting the paginated on-screen chart. + const holder = document.createElement("div"); + holder.style.width = instance.chart.getWidth() + "px"; + holder.style.height = instance.chart.getHeight() + "px"; + const pngChart = echarts.init(holder, null, { renderer: "canvas" }); + try { + pngChart.setOption(buildExportOption(instance.lastOption)); + const url = pngChart.getDataURL({ + type: "png", + pixelRatio: 2, + backgroundColor: "#fff", + }); + const link = document.createElement("a"); + link.href = url; + link.download = (datasetName || "chart") + ".png"; + link.click(); + } finally { + pngChart.dispose(); + } +} + function noDataOption(message) { return { title: { From db29e6a29cb2ded147af103f9585b5d83ee20ee9 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 23 Jun 2026 14:17:09 +0200 Subject: [PATCH 14/28] fix: match axis and title font size and color to Vega-Lite --- flexmeasures/ui/static/js/fast-chart.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index 8f3ca4df77..801c6ab1f8 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -546,7 +546,7 @@ function buildLineBarOption(elementId, groups, opts) { left: plotCenter, textAlign: "center", top: top - 42, - textStyle: { fontSize: 15, color: "#222" }, + textStyle: { fontSize: Math.round(FONT_SIZE * 1.25), color: "#222" }, // matches Vega-Lite title size (20 px) }); if (!legendsBelow) { const legendNames = Array.from(new Set(group.series.map((s) => s.name))); @@ -585,6 +585,8 @@ function buildLineBarOption(elementId, groups, opts) { splitLine: { show: true, lineStyle: { opacity: 0.5 } }, minInterval: 6 * 3600 * 1000, // at most one tick per 6h so 12:00 labels appear axisLabel: { + fontSize: FONT_SIZE, + color: "#222", formatter: (value) => { const d = new Date(value); const h = d.getHours(), m = d.getMinutes(); @@ -611,6 +613,7 @@ function buildLineBarOption(elementId, groups, opts) { align: "left", padding: [0, 0, 4, -GRID_LEFT + 16], }, + axisLabel: { fontSize: FONT_SIZE, color: "#222" }, splitLine: { show: true, lineStyle: { opacity: 0.7 } }, }); group.series.forEach((s, j) => { From cecdd8648afb3230295d586ac6e5be87f673aa87 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 23 Jun 2026 14:44:34 +0200 Subject: [PATCH 15/28] fix: show sensor-page legend with Source title and Vega-Lite-style labels --- flexmeasures/ui/static/js/fast-chart.js | 105 ++++++++++++++++++++---- 1 file changed, 87 insertions(+), 18 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index 801c6ab1f8..d59565535b 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -42,15 +42,43 @@ const instances = {}; /* ============================== formatting ============================== */ +// Build a label for a single source. Mirrors the Vega-Lite "source_legend_label" +// transform: keep source.name visible, and only when sources share a name do we +// fall back (handled in computeSourceLabels). On its own this returns the name. function sourceLabel(source) { - if (source.description) { - return source.description; + return source.name || "source " + source.id; +} + +// Compute legend labels for a set of sources, mirroring the Vega-Lite chart: +// show only source.name when names are unique, and append the shortest +// distinguishing detail (type / model / version, else the id) when they collide. +// Returns a Map keyed by source.id. +function computeSourceLabels(sources) { + const byId = new Map(); + for (const s of sources) { + if (s && s.id != null) byId.set(s.id, s); + } + const uniqueSources = Array.from(byId.values()); + + const nameCount = new Map(); + for (const s of uniqueSources) { + const name = s.name || "source " + s.id; + nameCount.set(name, (nameCount.get(name) || 0) + 1); } - let label = source.name || "source " + source.id; - if (source.model) { - label += " (" + source.model + ")"; + + const labels = new Map(); + for (const s of uniqueSources) { + const name = s.name || "source " + s.id; + if (nameCount.get(name) === 1) { + labels.set(s.id, name); // unique name → just the name, as in Vega-Lite + continue; + } + // Duplicate names: append the shortest available distinguishing detail. + const detail = + s.display_type || s.type || s.model || (s.version ? "v" + s.version : ""); + labels.set(s.id, detail ? name + " (" + detail + ")" : name + " (ID: " + s.id + ")"); } - return label; + return labels; } function capFirst(s) { @@ -246,13 +274,22 @@ function groupData(data, groupSpec) { } const nameBySensor = uniqueSensorKeys.size > 1; + // Source legend labels, disambiguated only when names collide (as in Vega-Lite). + const allSources = []; + for (const group of groups) { + for (const s of group.series.values()) allSources.push(s.source); + } + const sourceLabels = computeSourceLabels(allSources); + return groups.map((group) => { const series = []; for (const s of group.series.values()) { s.points.sort((a, b) => a[0] - b[0]); // Series sharing a name (same sensor, several sources) toggle together // via their shared legend entry, as in the Vega-Lite charts. - s.name = nameBySensor ? s.sensorDescription || s.sensorName : s.sourceLabel; + const srcLabel = + (s.source && sourceLabels.get(s.source.id)) || s.sourceLabel; + s.name = nameBySensor ? s.sensorDescription || s.sensorName : srcLabel; s.sensorType = group.sensorType || s.sensorName; series.push(s); } @@ -510,18 +547,31 @@ function buildLineBarOption(elementId, groups, opts) { // one combined legend at the very bottom, as in the Vega-Lite charts const lastGridBottom = topOffset + groups.length * GRID_HEIGHT + (groups.length - 1) * gridGap; - // Bottom legend stacks vertically (one entry per row) as in the Vega-Lite - // charts, so its height grows with the number of distinct sensor entries. + // Bottom legend: stack vertically (one entry per row) when there are many + // entries (asset charts with several sensors), as in the Vega-Lite charts; + // keep it horizontal for a few entries (e.g. the sensor page's sources). const numLegendEntries = new Set( groups.flatMap((g) => g.series.map((s) => s.name)) ).size; - const legendHeight = numLegendEntries * 24 + 8; + const bottomLegendVertical = numLegendEntries > 4; + const itemsPerRow = Math.max(Math.floor((containerWidth - GRID_LEFT - 30) / 220), 1); + const legendHeight = bottomLegendVertical + ? numLegendEntries * 24 + 8 + : Math.ceil(numLegendEntries / itemsPerRow) * 24 + 8; const sliderTop = lastGridBottom + 36; - const bottomLegendTop = sliderTop + 28 + 18; - + const bottomLegendTitleTop = sliderTop + 28 + 14; // "Source"/"Sensor" heading + const legendTitleHeight = 24; + const bottomLegendTop = bottomLegendTitleTop + legendTitleHeight; + + // The container is a ".card" with vertical padding; under border-box sizing + // that padding shrinks the usable canvas, which would clip the bottom legend + // on short single-plot (sensor-page) charts. Add it back so nothing is cut off. + const cs = window.getComputedStyle(container); + const verticalPadding = + (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0); container.style.height = legendsBelow - ? bottomLegendTop + legendHeight + 12 + "px" - : topOffset + groups.length * (GRID_HEIGHT + gridGap) + BOTTOM_OFFSET + "px"; + ? bottomLegendTop + legendHeight + 12 + verticalPadding + "px" + : topOffset + groups.length * (GRID_HEIGHT + gridGap) + BOTTOM_OFFSET + verticalPadding + "px"; const grids = []; const xAxes = []; @@ -670,17 +720,36 @@ function buildLineBarOption(elementId, groups, opts) { }); }); + // Title above the bottom legend: "Sensor" for multi-sensor (asset) charts, + // "Source" for single-sensor (sensor page) charts, as in the Vega-Lite charts. + const bottomLegendTitle = legendsBelow + ? { + type: "text", + left: GRID_LEFT, + top: bottomLegendTitleTop, + style: { + text: groups[0].nameBySensor ? "Sensor" : "Source", + font: "bold " + FONT_SIZE + "px " + CHART_FONT, + fill: "#222", + }, + } + : null; + if (legendsBelow) { // One combined legend below all subplots, stacked vertically (one entry per // row) as in the Vega-Lite charts rather than wrapping horizontally. legends.push({ + // "plain" (not "scroll"): the container height already grows to fit every + // entry (see legendHeight), so all entries show without pagination + // controls that would otherwise eat the space and hide the items. data: Array.from(new Set(seriesMeta.map((s) => s.name))), - type: "scroll", - orient: "vertical", + type: "plain", + orient: bottomLegendVertical ? "vertical" : "horizontal", left: GRID_LEFT, + right: bottomLegendVertical ? undefined : 30, top: bottomLegendTop, itemWidth: 18, - itemGap: 6, + itemGap: bottomLegendVertical ? 6 : 12, textStyle: { fontSize: FONT_SIZE }, tooltip: { show: true }, }); @@ -697,7 +766,7 @@ function buildLineBarOption(elementId, groups, opts) { return { textStyle: { fontFamily: CHART_FONT, fontSize: FONT_SIZE }, - graphic: sourceKey ? [sourceKey] : undefined, + graphic: [sourceKey, bottomLegendTitle].filter(Boolean), grid: grids, title: titles, xAxis: xAxes, From 49558ad914b63e5e19b791aa8c1f07cc2c1162dc Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Tue, 23 Jun 2026 14:54:21 +0200 Subject: [PATCH 16/28] fix: add minor gridlines to line and bar charts like Vega-Lite --- flexmeasures/ui/static/js/fast-chart.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index d59565535b..a2fc07b981 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -633,6 +633,9 @@ function buildLineBarOption(elementId, groups, opts) { axisLine: { onZero: false }, axisPointer: { show: true }, // vertical ruler, as in the Vega-Lite charts splitLine: { show: true, lineStyle: { opacity: 0.5 } }, + // Finer (hourly) gridlines between the 6-hour major lines, as in Vega-Lite. + minorTick: { show: true, splitNumber: 6 }, + minorSplitLine: { show: true, lineStyle: { color: "#e0e0e0", width: 1 } }, minInterval: 6 * 3600 * 1000, // at most one tick per 6h so 12:00 labels appear axisLabel: { fontSize: FONT_SIZE, @@ -665,6 +668,9 @@ function buildLineBarOption(elementId, groups, opts) { }, axisLabel: { fontSize: FONT_SIZE, color: "#222" }, splitLine: { show: true, lineStyle: { opacity: 0.7 } }, + // Finer gridlines between the major value lines, as in Vega-Lite. + minorTick: { show: true, splitNumber: 2 }, + minorSplitLine: { show: true, lineStyle: { color: "#e0e0e0", width: 1 } }, }); group.series.forEach((s, j) => { const isBar = opts.chartType === "bar_chart"; From e07f66c160075bf3f46a6943640cdbc25494e535 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Wed, 24 Jun 2026 11:54:23 +0200 Subject: [PATCH 17/28] feat: match fast ECharts charts to Vega-Lite (annotations, colors, ramps, fonts) - show hover annotations on the sensor page, matching the Vega-Lite SHADE_LAYER/TEXT_LAYER (gray bands, highlight + label on hover) - use tableau10 colors and zero-based y-axis like Vega-Lite - use linear interpolation for instantaneous sensors so ramps are visible - match Poppins font and Vega-Lite font sizes - always show the legend below on the sensor page - mixed date/time x-axis labels and extra inter-subplot spacing --- flexmeasures/ui/static/js/fast-chart.js | 118 +++++++++++++++++- .../ui/templates/includes/graphs.html | 1 + 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index a2fc07b981..b60ce5d87a 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -523,11 +523,62 @@ function seriesTooltipFormatter(seriesMeta) { }; } +/* ============================== annotations ============================== */ + +// Parse the annotation records (start, end, content) fetched for the sensor page +// into sorted {start, end, label} entries with epoch-ms bounds. +function normalizeAnnotations(raw) { + if (!Array.isArray(raw) || raw.length === 0) return []; + return raw + .map((a) => ({ + start: new Date(a.start).getTime(), + end: new Date(a.end).getTime(), + label: Array.isArray(a.content) ? a.content.join("\n") : (a.content || ""), + })) + .filter((a) => isFinite(a.start) && isFinite(a.end)) + .sort((a, b) => a.start - b.start); +} + +// Build the markArea config for the annotation shades. The band at hoverIdx is +// highlighted (secondary-hover color, label shown); the rest are gray at 0.3 opacity, +// matching Vega-Lite's SHADE_LAYER (default --gray) and TEXT_LAYER (label on hover). +function buildAnnotationMarkArea(annotations, hoverIdx) { + const cs = getComputedStyle(document.documentElement); + const grayColor = cs.getPropertyValue("--gray").trim() || "#bbb"; + const hoverColor = cs.getPropertyValue("--secondary-hover-color").trim() || "#f5a623"; + return { + silent: true, // hover is handled via the updateAxisPointer listener instead + animation: false, + data: annotations.map((a, idx) => { + const hovered = idx === hoverIdx; + return [ + { + xAxis: a.start, + itemStyle: { color: hovered ? hoverColor : grayColor, opacity: hovered ? 0.7 : 0.3 }, + label: { + show: hovered, + position: ["50%", "100%"], // centered, at the bottom of the band + offset: [0, 34], // push below the x-axis labels, like Vega's text layer + align: "center", + verticalAlign: "top", + fontSize: FONT_SIZE, + fontStyle: "italic", + color: "#333", + formatter: () => a.label, + }, + }, + { xAxis: a.end }, + ]; + }), + }; +} + /* ============================== line / bar charts ============================== */ function buildLineBarOption(elementId, groups, opts) { const instance = instances[elementId]; const container = document.getElementById(elementId); + const annotations = normalizeAnnotations(opts.annotations); // Sensor page: legend always below (single sensor, sources are the legend entries) const legendsBelow = !!opts.legendsBelow || opts.isSensorPage; const gridGap = SIDE_GRID_GAP; @@ -558,7 +609,9 @@ function buildLineBarOption(elementId, groups, opts) { const legendHeight = bottomLegendVertical ? numLegendEntries * 24 + 8 : Math.ceil(numLegendEntries / itemsPerRow) * 24 + 8; - const sliderTop = lastGridBottom + 36; + // Reserve a strip below the x-axis labels for the hovered annotation's name. + const annotGap = annotations.length > 0 ? 28 : 0; + const sliderTop = lastGridBottom + 36 + annotGap; const bottomLegendTitleTop = sliderTop + 28 + 14; // "Source"/"Sensor" heading const legendTitleHeight = 24; const bottomLegendTop = bottomLegendTitleTop + legendTitleHeight; @@ -721,6 +774,12 @@ function buildLineBarOption(elementId, groups, opts) { label: { show: false }, }; } + // Annotation shades on the first series of each subplot. Gray at 0.3 opacity by + // default; the zrender mousemove handler in renderFastChart recolors the hovered + // band and reveals its label, matching Vega-Lite's SHADE_LAYER/TEXT_LAYER. + if (j === 0 && annotations.length > 0) { + entry.markArea = buildAnnotationMarkArea(annotations, -1); + } series.push(entry); seriesMeta.push(s); }); @@ -1074,6 +1133,63 @@ export function renderFastChart(elementId, data, options) { instance.lastOption = option; instance.chart.resize(); // pick up container size changes before drawing instance.chart.setOption(option, { notMerge: true }); + + wireAnnotationHover(instance, opts); +} + +// Highlight the annotation band under the cursor (color + label), matching Vega-Lite. +// markArea.emphasis does not fire because axisPointer intercepts mouse events, so we +// react to ECharts' updateAxisPointer event (which provides the x-axis value directly) +// and recolor the band that contains it. globalout on the canvas clears the highlight. +function wireAnnotationHover(instance, opts) { + const annotations = normalizeAnnotations(opts.annotations); + const chart = instance.chart; + const zr = chart.getZr(); + + // Drop any handlers from a previous render before deciding whether to add new ones. + if (instance.onAnnotPointer) { + chart.off("updateAxisPointer", instance.onAnnotPointer); + zr.off("globalout", instance.onAnnotOut); + instance.onAnnotPointer = null; + instance.onAnnotOut = null; + } + if (annotations.length === 0) return; + + // The markArea lives on the first series of each subplot; collect their indices. + const seriesList = chart.getOption().series || []; + const markAreaSeriesIdx = seriesList.reduce((acc, s, idx) => { + if (s.markArea) acc.push(idx); + return acc; + }, []); + if (markAreaSeriesIdx.length === 0) return; + + let activeIdx = -1; + const lastSeriesIdx = markAreaSeriesIdx[markAreaSeriesIdx.length - 1]; + const setHover = (newIdx) => { + if (newIdx === activeIdx) return; + activeIdx = newIdx; + // setOption merges series by position, so build an array up to the last + // markArea-bearing series; only those carry a new markArea, the rest pass through. + const seriesPatch = []; + for (let i = 0; i <= lastSeriesIdx; i++) { + seriesPatch.push( + markAreaSeriesIdx.includes(i) + ? { markArea: buildAnnotationMarkArea(annotations, newIdx) } + : {} + ); + } + chart.setOption({ series: seriesPatch }); + }; + + instance.onAnnotPointer = (ev) => { + const axisInfo = (ev.axesInfo || []).find((a) => a.axisDim === "x") || (ev.axesInfo || [])[0]; + if (!axisInfo) { setHover(-1); return; } + const xVal = axisInfo.value; + setHover(annotations.findIndex((a) => xVal >= a.start && xVal <= a.end)); + }; + instance.onAnnotOut = () => setHover(-1); + chart.on("updateAxisPointer", instance.onAnnotPointer); + zr.on("globalout", instance.onAnnotOut); } /** diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index 000ba9d346..bde5e20a3c 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -697,6 +697,7 @@ // sensor page always shows legend below (single sensor, sources are the legend entries) isSensorPage: ('{{ active_page }}' === 'sensors'), datasetName: datasetName, + annotations: (previousResult && previousResult.annotations) ? previousResult.annotations : [], }; } From ddca605b3d6b3e7ba7327f29e4c588278928c444 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Wed, 24 Jun 2026 12:42:59 +0200 Subject: [PATCH 18/28] perf: smooth fast-chart pan/zoom and speed up rendering - dataZoom realtime:false + throttle:80 so a slider drag coalesces into a single redraw on release instead of one per tick (removes mid-drag jank) - large mode on line series for batched canvas drawing - measured on asset 77 (7,728 rows): initial render 134ms -> 71ms, slider drag 25 re-renders -> 1 --- flexmeasures/ui/static/js/fast-chart.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index b60ce5d87a..14d98e772e 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -760,6 +760,8 @@ function buildLineBarOption(elementId, groups, opts) { ...(isInstantaneous ? {} : { step: "end" }), // step-after for interval data showSymbol: false, sampling: "lttb", // downsample to the available pixels, preserving peaks + large: true, // batched canvas path: faster redraws while panning/zooming + largeThreshold: 1000, lineStyle: { width: 2.2, type: lineTypeForSource(s.source) }, }); } @@ -848,10 +850,14 @@ function buildLineBarOption(elementId, groups, opts) { }, toolbox: toolbox, dataZoom: [ - { type: "inside", xAxisIndex: allAxisIndices }, // mouse-wheel zoom and drag-to-pan + // Mouse-wheel zoom and drag-to-pan. throttle coalesces rapid wheel/drag + // ticks so we redraw at most every ~80 ms instead of on every event. + { type: "inside", xAxisIndex: allAxisIndices, throttle: 80 }, + // Range slider: realtime:false redraws only on drag-release (no mid-drag + // re-render of all series); a light gray ghost shows the pending window. legendsBelow - ? { type: "slider", xAxisIndex: allAxisIndices, top: sliderTop, height: 28 } - : { type: "slider", xAxisIndex: allAxisIndices, bottom: 14, height: 28 }, + ? { type: "slider", xAxisIndex: allAxisIndices, top: sliderTop, height: 28, realtime: false, throttle: 80 } + : { type: "slider", xAxisIndex: allAxisIndices, bottom: 14, height: 28, realtime: false, throttle: 80 }, ], }; } From 5fa86d1300af4488d635da690d50d0e23071ea83 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sat, 27 Jun 2026 20:32:40 +0200 Subject: [PATCH 19/28] fix: complete heatmap PNG/SVG export and reorder sensor-page toolbox Export read the canvas/SVG synchronously right after setOption, but heatmaps render cells progressively across animation frames, so a year-long heatmap (>5000 cells) lost every cell past the first chunk. buildExportOption now forces progressive:0 on every series so the full chart renders in one pass. CSV is unaffected (it serializes the raw rows). Also place the SVG export icon between Reset Zoom and PNG on the sensor page only, leaving the asset-page toolbox order unchanged. --- flexmeasures/ui/static/js/fast-chart.js | 67 +++++++++++++++---------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index 14d98e772e..ff454d6d08 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -375,31 +375,32 @@ function yAxisTitle(sensorType, units) { : unitLabel; } -function toolboxFeatures(elementId, datasetName) { - return { - right: 16, - feature: { - dataZoom: { yAxisIndex: false }, - mySavePNG: { - show: true, - title: "Save as PNG", - icon: "path://M4 5 L20 5 L20 19 L4 19 Z M8 14 L11 10 L13 13 L15 11 L18 15 L8 15 Z", - onclick: () => exportPNG(elementId, datasetName), - }, - mySaveCSV: { - show: true, - title: "Save as CSV", - icon: "path://M5 2 L13 2 L17 6 L17 20 L5 20 Z M7 11 L15 11 M7 14 L15 14 M7 17 L15 17", - onclick: () => exportCSV(elementId, datasetName), - }, - mySaveSVG: { - show: true, - title: "Save as SVG", - icon: "path://M5 2 L13 2 L17 6 L17 20 L5 20 Z M7 12 L11 17 L15 9", - onclick: () => exportSVG(elementId, datasetName), - }, - }, +function toolboxFeatures(elementId, datasetName, isSensorPage) { + const dataZoom = { yAxisIndex: false }; + const savePNG = { + show: true, + title: "Save as PNG", + icon: "path://M4 5 L20 5 L20 19 L4 19 Z M8 14 L11 10 L13 13 L15 11 L18 15 L8 15 Z", + onclick: () => exportPNG(elementId, datasetName), }; + const saveCSV = { + show: true, + title: "Save as CSV", + icon: "path://M5 2 L13 2 L17 6 L17 20 L5 20 Z M7 11 L15 11 M7 14 L15 14 M7 17 L15 17", + onclick: () => exportCSV(elementId, datasetName), + }; + const saveSVG = { + show: true, + title: "Save as SVG", + icon: "path://M5 2 L13 2 L17 6 L17 20 L5 20 Z M7 12 L11 17 L15 9", + onclick: () => exportSVG(elementId, datasetName), + }; + // Toolbox icons render in key order. On the sensor page, place SVG between the + // dataZoom (zoom/reset) icons and PNG; elsewhere keep PNG, CSV, SVG. + const feature = isSensorPage + ? { dataZoom: dataZoom, mySaveSVG: saveSVG, mySavePNG: savePNG, mySaveCSV: saveCSV } + : { dataZoom: dataZoom, mySavePNG: savePNG, mySaveCSV: saveCSV, mySaveSVG: saveSVG }; + return { right: 16, feature: feature }; } function downloadBlob(content, mimeType, filename) { @@ -433,11 +434,23 @@ function buildExportOption(lastOption) { ? [lastOption.legend] : [] ).map((l) => Object.assign({}, l, { type: "plain" })); + // Render every series in a single synchronous pass. Heatmaps use progressive + // rendering (cells drawn across animation frames); the export reads the + // canvas/SVG immediately after setOption, so without this a large heatmap + // (e.g. a year of data, > 5000 cells) loses every cell past the first chunk. + const series = ( + Array.isArray(lastOption.series) + ? lastOption.series + : lastOption.series + ? [lastOption.series] + : [] + ).map((s) => Object.assign({}, s, { progressive: 0, animation: false })); return Object.assign({}, lastOption, { animation: false, backgroundColor: "#fff", toolbox: { show: false }, legend: legend, + series: series, }); } @@ -828,7 +841,7 @@ function buildLineBarOption(elementId, groups, opts) { const sourceKey = showSourceKey ? buildSourceKey(GRID_LEFT, 6) : null; const allAxisIndices = xAxes.map((_, i) => i); - const toolbox = toolboxFeatures(elementId, opts.datasetName); + const toolbox = toolboxFeatures(elementId, opts.datasetName, opts.isSensorPage); toolbox.feature.dataZoom.xAxisIndex = allAxisIndices; return { @@ -967,7 +980,7 @@ function buildHistogramOption(elementId, data, opts) { }, }, textStyle: { fontFamily: CHART_FONT, fontSize: FONT_SIZE }, - toolbox: toolboxFeatures(elementId, opts.datasetName), + toolbox: toolboxFeatures(elementId, opts.datasetName, opts.isSensorPage), }; } @@ -1076,7 +1089,7 @@ function buildHeatmapOption(elementId, data, opts) { }, }, textStyle: { fontFamily: CHART_FONT, fontSize: FONT_SIZE }, - toolbox: toolboxFeatures(elementId, opts.datasetName), + toolbox: toolboxFeatures(elementId, opts.datasetName, opts.isSensorPage), series: [ { type: "heatmap", From b4b08ec6a7e14f9577dd15d65508bf075719ca60 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sat, 4 Jul 2026 18:30:47 +0200 Subject: [PATCH 20/28] Improve fast (ECharts) chart parity with Vega-Lite - Native ECharts Charge Point sessions chart with companion subplots and working per-session tooltips; charge-points type switchable in fast mode - Do not render the Charge Point sessions panel in the default view - Align companion subplots on a shared time domain - Remove the always-visible range-slider zoom bar (keep wheel/drag zoom) - Move the Source line-style key to the bottom, matching Vega-Lite: a vertical column at the bottom-left with side legends, or a second column beside the Sensor legend in the legends-below layout --- flexmeasures/ui/static/js/fast-chart.js | 493 ++++++++++++++++-- .../ui/templates/includes/graphs.html | 50 +- 2 files changed, 493 insertions(+), 50 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index ff454d6d08..c7dff46fc2 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -87,32 +87,36 @@ function capFirst(s) { // "Source" key with little line samples (dotted/dashed/solid + label per type), // mirroring the Vega-Lite charts' Source legend. Returns one ECharts graphic -// group laid out as a single horizontal strip. Reserve SOURCE_KEY_HEIGHT for it. -const SOURCE_KEY_HEIGHT = 22; +// group laid out as a vertical column ("Source" title, then one row per source +// type) placed at the bottom-left, exactly as the Vega-Lite charts render it. +const SOURCE_KEY_ROW_HEIGHT = 20; +// Title row + one row per source type (forecaster, scheduler, other — see +// SOURCE_KEY_ROWS below); reserve this height at the bottom for the key. +const SOURCE_KEY_HEIGHT = 24 + 3 * SOURCE_KEY_ROW_HEIGHT; function buildSourceKey(left, top) { const children = [ { type: "text", left: 0, - top: 3, + top: 0, style: { text: "Source", font: "bold 12px sans-serif", fill: "#222" }, }, ]; - let x = 56; + let y = 24; for (const row of SOURCE_KEY_ROWS) { children.push({ type: "line", - shape: { x1: x, y1: 9, x2: x + 26, y2: 9 }, + shape: { x1: 0, y1: y + 6, x2: 30, y2: y + 6 }, style: { stroke: "#555", lineWidth: 1.5, lineDash: row.dash || null }, }); children.push({ type: "text", - left: x + 32, - top: 3, + left: 38, + top: y, style: { text: row.label, fontSize: 11, fill: "#222" }, }); - x += 32 + row.label.length * 6.5 + 22; + y += SOURCE_KEY_ROW_HEIGHT; } return { type: "group", left: left, top: top, children: children }; } @@ -375,6 +379,21 @@ function yAxisTitle(sensorType, units) { : unitLabel; } +// Mixed date/time x-axis labels shared by every time-axis chart: "HH:MM" normally, +// falling back to the day name + date at midnight, and the month name on the 1st. +function xAxisTimeFormatter(value) { + const d = new Date(value); + const h = d.getHours(), m = d.getMinutes(); + if (h === 0 && m === 0) { + const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + if (d.getDate() === 1) return MONTHS[d.getMonth()]; + return DAYS[d.getDay()] + "\n" + String(d.getDate()).padStart(2, "0"); + } + return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"); +} + function toolboxFeatures(elementId, datasetName, isSensorPage) { const dataZoom = { yAxisIndex: false }; const savePNG = { @@ -605,7 +624,7 @@ function buildLineBarOption(elementId, groups, opts) { groups.flatMap((g) => g.series.map((s) => lineTypeForSource(s.source))) ); const showSourceKey = opts.chartType !== "bar_chart" && groups[0].nameBySensor && allSourceTypes.size > 1; - const topOffset = TOP_OFFSET + (showSourceKey ? SOURCE_KEY_HEIGHT : 0); + const topOffset = TOP_OFFSET; // Vertical layout: subplots, then (in legends-below mode) the slider and // one combined legend at the very bottom, as in the Vega-Lite charts @@ -624,19 +643,34 @@ function buildLineBarOption(elementId, groups, opts) { : Math.ceil(numLegendEntries / itemsPerRow) * 24 + 8; // Reserve a strip below the x-axis labels for the hovered annotation's name. const annotGap = annotations.length > 0 ? 28 : 0; - const sliderTop = lastGridBottom + 36 + annotGap; - const bottomLegendTitleTop = sliderTop + 28 + 14; // "Source"/"Sensor" heading + const legendZoneTop = lastGridBottom + 36 + annotGap; + const bottomLegendTitleTop = legendZoneTop + 14; // "Source"/"Sensor" heading const legendTitleHeight = 24; const bottomLegendTop = bottomLegendTitleTop + legendTitleHeight; + // The "Source" line-style key placement, matching the Vega-Lite charts: + // - side-legend layout: bottom-left, below the last subplot's x-axis labels. + // - legends-below layout: a second column to the right of, and top-aligned + // with, the "Sensor" legend (not stacked below it). + const sourceKeyLeft = legendsBelow ? GRID_LEFT + 400 : GRID_LEFT; + const sourceKeyTop = legendsBelow + ? bottomLegendTitleTop + : lastGridBottom + 44; + // The container is a ".card" with vertical padding; under border-box sizing // that padding shrinks the usable canvas, which would clip the bottom legend // on short single-plot (sensor-page) charts. Add it back so nothing is cut off. const cs = window.getComputedStyle(container); const verticalPadding = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0); + // The bottom zone must fit whichever is taller: the Sensor legend column or + // the (side-by-side) Source key column. + const bottomLegendBottom = Math.max( + bottomLegendTop + legendHeight + 12, + showSourceKey && legendsBelow ? sourceKeyTop + SOURCE_KEY_HEIGHT + 12 : 0 + ); container.style.height = legendsBelow - ? bottomLegendTop + legendHeight + 12 + verticalPadding + "px" + ? bottomLegendBottom + verticalPadding + "px" : topOffset + groups.length * (GRID_HEIGHT + gridGap) + BOTTOM_OFFSET + verticalPadding + "px"; const grids = []; @@ -706,18 +740,7 @@ function buildLineBarOption(elementId, groups, opts) { axisLabel: { fontSize: FONT_SIZE, color: "#222", - formatter: (value) => { - const d = new Date(value); - const h = d.getHours(), m = d.getMinutes(); - if (h === 0 && m === 0) { - const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - if (d.getDate() === 1) return MONTHS[d.getMonth()]; - return DAYS[d.getDay()] + "\n" + String(d.getDate()).padStart(2, "0"); - } - return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"); - }, + formatter: xAxisTimeFormatter, }, }); yAxes.push({ @@ -835,10 +858,9 @@ function buildLineBarOption(elementId, groups, opts) { }); } - // Source line-style key, as in the Vega-Lite charts' "Source" legend. - // Always shown (all three types) on the sensor-colored line charts, in the - // strip reserved at the top-left, clear of the toolbox and the side legends. - const sourceKey = showSourceKey ? buildSourceKey(GRID_LEFT, 6) : null; + // Source line-style key, as in the Vega-Lite charts' "Source" legend: + // a vertical column at the bottom-left, below the chart / bottom legend. + const sourceKey = showSourceKey ? buildSourceKey(sourceKeyLeft, sourceKeyTop) : null; const allAxisIndices = xAxes.map((_, i) => i); const toolbox = toolboxFeatures(elementId, opts.datasetName, opts.isSensorPage); @@ -866,11 +888,6 @@ function buildLineBarOption(elementId, groups, opts) { // Mouse-wheel zoom and drag-to-pan. throttle coalesces rapid wheel/drag // ticks so we redraw at most every ~80 ms instead of on every event. { type: "inside", xAxisIndex: allAxisIndices, throttle: 80 }, - // Range slider: realtime:false redraws only on drag-release (no mid-drag - // re-render of all series); a light gray ghost shows the pending window. - legendsBelow - ? { type: "slider", xAxisIndex: allAxisIndices, top: sliderTop, height: 28, realtime: false, throttle: 80 } - : { type: "slider", xAxisIndex: allAxisIndices, bottom: 14, height: 28, realtime: false, throttle: 80 }, ], }; } @@ -1102,6 +1119,382 @@ function buildHeatmapOption(elementId, data, opts) { }; } +/* ============================== charge point sessions ============================== */ + +// Sensor names that report Charge Point session timestamps (unit "s", the value +// is itself an epoch time). Pivoted per (asset, event_start) into session bars, +// mirroring the Vega-Lite "Charge Point sessions" chart's arrival/departure, +// plug-in/plug-out and start/stop-charging layers. +const SESSION_SENSOR_NAMES = [ + "arrival", "departure", "plug in", "plug out", "start charging", "stop charging", +]; + +// Pivot session-marker rows into one record per (asset, session): each holds up +// to three timestamp pairs (in ms) reported for that session. Rows for the same +// session share both their asset and their event_start (the belief time at which +// the session's timestamps were reported), which is how Vega-Lite groups them too. +function pivotChargePointSessions(data) { + const groups = new Map(); + for (const row of data || []) { + const sensor = row.sensor || {}; + if (sensor.unit !== "s" || !SESSION_SENSOR_NAMES.includes(sensor.name)) { + continue; + } + const ms = row.event_value instanceof Date ? row.event_value.getTime() : row.event_value; + if (typeof ms !== "number" || !isFinite(ms) || ms <= 0) { + continue; + } + const assetId = sensor.asset_id; + const key = assetId + "_" + row.event_start; + if (!groups.has(key)) { + groups.set(key, { + assetId: assetId, + assetLabel: sensor.asset_description || "Asset " + assetId, + }); + } + groups.get(key)[sensor.name] = ms; + } + return Array.from(groups.values()); +} + +// Distinguish charge point assets that share the same description, mirroring +// computeSourceLabels: unique descriptions are shown as-is, duplicates get their id appended. +function computeAssetLabels(sessions) { + const byId = new Map(); + for (const s of sessions) { + if (!byId.has(s.assetId)) byId.set(s.assetId, s.assetLabel); + } + const nameCount = new Map(); + for (const label of byId.values()) { + nameCount.set(label, (nameCount.get(label) || 0) + 1); + } + const labels = new Map(); + for (const [id, label] of byId) { + labels.set(id, nameCount.get(label) === 1 ? label : label + " (ID: " + id + ")"); + } + return labels; +} + +// "Session type" line-style key (dashed/thin, solid/medium, solid/thick), explaining +// what each layer means, mirroring the info tooltip next to the chart-type dropdown. +const CHARGEPOINT_KEY_ROWS = [ + { label: "Arrival → Departure", dash: [4, 4], width: 1.5 }, + { label: "Plug-in → Plug-out", dash: null, width: 2.5 }, + { label: "Charging", dash: null, width: 4 }, +]; + +function buildChargePointKey(left, top) { + const children = []; + let x = 0; + for (const row of CHARGEPOINT_KEY_ROWS) { + children.push({ + type: "line", + shape: { x1: x, y1: 9, x2: x + 26, y2: 9 }, + style: { stroke: "#555", lineWidth: row.width, lineDash: row.dash }, + }); + children.push({ + type: "text", + left: x + 32, + top: 3, + style: { text: row.label, fontSize: 11, fill: "#222" }, + }); + x += 32 + row.label.length * 6.2 + 26; + } + return { type: "group", left: left, top: top, children: children }; +} + +// Build one real "line" series per session segment (2 points: from -> to on the +// asset's category row), named by asset so ECharts' native legend groups every +// layer of the same asset under one clickable, color-coded entry — matching +// Vega-Lite's per-asset color legend and its "click legend to filter" behavior. +function buildSessionSegmentSeries(sessions, fromKey, toKey, categoryIndex, lineWidth, lineDash, fromLabel, toLabel) { + const series = []; + for (const s of sessions) { + const from = s[fromKey]; + const to = s[toKey]; + if (typeof from !== "number" || typeof to !== "number") continue; + const catIdx = categoryIndex.get(s.assetId); + const color = SENSOR_COLORS[catIdx % SENSOR_COLORS.length]; + const data = [[from, catIdx], [to, catIdx]]; + const tooltip = { + formatter: () => + tooltipTable([ + [fromLabel, formatFullDate(from)], + [toLabel, formatFullDate(to)], + ["Asset", s.assetLabel], + ]), + }; + // Regular time-series lines (e.g. the companion subplots) have a data point + // every few pixels, so ECharts' "nearest point" hover snap always finds one + // close to the cursor anywhere along the line. A session segment only has 2 + // points — its start and end, often hours apart — so that snap logic finds + // nothing nearby over most of a long bar. Densely interpolate the invisible + // hit-area line (kept off the visible line) so hovering anywhere along the + // bar's length has a point within range, not just right at its endpoints. + const hitPointCount = 40; + const hitData = []; + for (let i = 0; i <= hitPointCount; i++) { + hitData.push([from + ((to - from) * i) / hitPointCount, catIdx]); + } + // Invisible companion line, much wider than the visible stroke, purely to + // widen the hoverable hit area: thin session bars (down to 1.5px) are + // otherwise very hard to hover precisely. Shares the visible line's name + // so it doesn't add its own legend entry or get toggled independently. + // Its OWN tooltip is disabled (tooltip.show: false): ECharts' tooltip box + // renders at zero size for a near-transparent series even though hover/hit + // -test work fine on it, so hovering it instead redirects (via the + // __hitAreaFor marker + the mouseover listener in wireSessionTooltipRedirect) + // to dispatch the visible sibling series' tooltip. + series.push({ + name: s.assetLabel, + type: "line", + xAxisIndex: 0, + yAxisIndex: 0, + symbol: "none", + showSymbol: false, + animation: false, + legendHoverLink: false, + lineStyle: { width: Math.max(lineWidth, 16), opacity: 0.001 }, + data: hitData, + tooltip: { show: false }, + __hitAreaFor: true, + z: 1, + }); + series.push({ + name: s.assetLabel, + type: "line", + xAxisIndex: 0, + yAxisIndex: 0, + symbol: "none", + showSymbol: false, + animation: false, + color: color, + lineStyle: { width: lineWidth, type: lineDash || "solid" }, + emphasis: { focus: "series", lineStyle: { width: lineWidth + 2 } }, + data: data, + tooltip: tooltip, + z: 2, + }); + } + return series; +} + +// The two companion subplots Vega-Lite shows alongside the sessions chart (when +// present in the dashboard config), restricted the same way the Python chart +// builder restricts them (see chart_for_chargepoint_sessions in belief_charts.py). +const CHARGEPOINT_COMPANION_TITLES = ["Power flow by type", "Prices"]; +const CHARGEPOINT_POWER_SENSOR_NAME = "charge points power"; +// Title of the dashboard group holding the raw session-marker sensors (arrival, +// departure, plug in/out, start/stop charging). Their values are epoch timestamps, +// not plottable quantities, so — like Vega-Lite — only the dedicated Charge Point +// sessions chart type shows them; the default multi-sensor view hides this group. +const CHARGE_POINT_SESSIONS_GROUP_TITLE = "Charge Point sessions"; + +function buildChargePointSessionsOption(elementId, data, opts) { + const sessions = pivotChargePointSessions(data); + if (sessions.length === 0) { + return null; + } + const assetLabels = computeAssetLabels(sessions); + for (const s of sessions) { + s.assetLabel = assetLabels.get(s.assetId); + } + + // y categories: one row per charge point asset, in order of first appearance + const categoryOrder = []; + const categoryIndex = new Map(); + for (const s of sessions) { + if (!categoryIndex.has(s.assetId)) { + categoryIndex.set(s.assetId, categoryOrder.length); + categoryOrder.push(s.assetLabel); + } + } + + const sessionSeries = [ + ...buildSessionSegmentSeries(sessions, "arrival", "departure", categoryIndex, 1.5, "dashed", "Arrival", "Departure"), + ...buildSessionSegmentSeries(sessions, "plug in", "plug out", categoryIndex, 2.5, "solid", "Plug-in", "Plug-out"), + ...buildSessionSegmentSeries(sessions, "start charging", "stop charging", categoryIndex, 6, "solid", "Start charging", "Stop charging"), + ]; + + // Same two companion subplots Vega-Lite stacks below the sessions chart, built + // via the normal multi-sensor line-chart renderer and spliced in underneath. + const companionSpec = (opts.groupSpec || []).filter((g) => CHARGEPOINT_COMPANION_TITLES.includes(g.title)); + let companionGroups = companionSpec.length > 0 ? groupData(data, companionSpec) : []; + // groupData() falls back to grouping any row not covered by companionSpec's + // sensorIds by unit, producing extra ad-hoc groups for the asset's other + // sensors (soc, power, ...) — drop everything except the two intended titles. + companionGroups = companionGroups + .filter((g) => CHARGEPOINT_COMPANION_TITLES.includes(g.title)) + .map((g) => + g.title === "Power flow by type" + ? Object.assign({}, g, { series: g.series.filter((s) => s.sensorName === CHARGEPOINT_POWER_SENSOR_NAME) }) + : g + ) + .filter((g) => g.series.length > 0); + + // Give every subplot's x-axis the same explicit time domain, spanning both the + // session timestamps (arrival/departure/etc., which can fall outside the belief + // report window) and the companion sensors' event times. Without this, each + // "time" axis auto-scales to its own series' range independently, so the same + // clock time lands at a different x position in each subplot — unlike Vega-Lite, + // which shares one scale across all layered/concatenated charts. + let minTime = Infinity; + let maxTime = -Infinity; + for (const row of data || []) { + if (typeof row.event_start === "number") { + if (row.event_start < minTime) minTime = row.event_start; + if (row.event_start > maxTime) maxTime = row.event_start; + } + } + for (const s of sessions) { + for (const key of SESSION_SENSOR_NAMES) { + const t = s[key]; + if (typeof t === "number") { + if (t < minTime) minTime = t; + if (t > maxTime) maxTime = t; + } + } + } + const sharedTimeDomain = isFinite(minTime) && isFinite(maxTime) ? { min: minTime, max: maxTime } : {}; + + const container = document.getElementById(elementId); + const gridGap = SIDE_GRID_GAP; + const containerWidth = container.clientWidth || 800; + const plotCenter = (GRID_LEFT + containerWidth - 30) / 2; + + const sessionsTop = TOP_OFFSET; + const sessionsGridHeight = Math.min(Math.max(categoryOrder.length * 32, 160), 460); + const sessionsBottom = sessionsTop + sessionsGridHeight; + + // Side legend of asset colors, one entry per asset, matching the per-subplot + // side legend style used by the other charts; clicking an entry toggles every + // layer of that asset (they all share its name), just like Vega-Lite. + const legendRowHeight = FONT_SIZE + 6; + const legendContentHeight = categoryOrder.length * legendRowHeight; + const legendTop = sessionsTop + Math.max(0, (sessionsGridHeight - legendContentHeight) / 2); + + const grids = [{ top: sessionsTop, height: sessionsGridHeight, left: GRID_LEFT, right: LEGEND_WIDTH + 40, containLabel: false }]; + const xAxes = [Object.assign({ + type: "time", + gridIndex: 0, + axisLine: { onZero: false }, + axisPointer: { show: true }, + splitLine: { show: true, lineStyle: { opacity: 0.5 } }, + minorTick: { show: true, splitNumber: 6 }, + minorSplitLine: { show: true, lineStyle: { color: "#e0e0e0", width: 1 } }, + minInterval: 6 * 3600 * 1000, + axisLabel: { fontSize: FONT_SIZE, color: "#222", formatter: xAxisTimeFormatter }, + }, sharedTimeDomain)]; + const yAxes = [{ + type: "category", + gridIndex: 0, + data: categoryOrder, + name: "Sessions", + nameLocation: "end", + nameTextStyle: { fontSize: FONT_SIZE, fontWeight: "bold", color: "#222", align: "left", padding: [0, 0, 4, -GRID_LEFT + 16] }, + axisLabel: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + }]; + const titles = [{ + text: "Charge Point sessions", + left: plotCenter, + textAlign: "center", + top: sessionsTop - 42, + textStyle: { fontSize: Math.round(FONT_SIZE * 1.25), color: "#222" }, + }]; + const legends = [{ + data: categoryOrder, + type: "scroll", + tooltip: { show: true }, + orient: "vertical", + right: 8, + top: legendTop, + height: sessionsGridHeight, + align: "left", + itemWidth: 18, + itemGap: 6, + textStyle: { fontSize: FONT_SIZE, width: LEGEND_WIDTH - 30, overflow: "truncate" }, + }]; + const graphics = [ + { type: "text", left: containerWidth - LEGEND_WIDTH, top: sessionsTop - 20, style: { text: "Asset", font: "bold " + FONT_SIZE + "px " + CHART_FONT, fill: "#222" } }, + buildChargePointKey(GRID_LEFT, 6), + ]; + const series = sessionSeries.slice(); + // Aligned 1:1 with the final `series` array: null for the session segment + // series (they carry their own per-series tooltip), real metadata for the + // companion series, which rely on the shared seriesTooltipFormatter below. + const seriesMeta = new Array(sessionSeries.length).fill(null); + + // Splice the companion subplots (each built independently by the normal + // line-chart renderer, one group at a time so its own Source key — e.g. for + // Prices' forecaster/scheduler/other sources — lands right above its own + // subplot instead of a single misplaced key shared across both) in below the + // sessions chart, re-indexed onto the shared grid/axis/dataZoom set. + let runningTop = sessionsBottom + gridGap; + let indexShift = 1; + companionGroups.forEach((group) => { + const companionOption = buildLineBarOption(elementId, [group], Object.assign({}, opts, { + chartType: "line", + legendsBelow: false, + isSensorPage: false, + annotations: [], + })); + const deltaTop = runningTop - companionOption.grid[0].top; + const shiftedTop = runningTop; // capture before the mutations below shift grid[0].top in place + companionOption.grid.forEach((g) => { g.top += deltaTop; grids.push(g); }); + companionOption.title.forEach((t) => { t.top += deltaTop; titles.push(t); }); + companionOption.xAxis.forEach((x) => { x.gridIndex = indexShift; Object.assign(x, sharedTimeDomain); xAxes.push(x); }); + companionOption.yAxis.forEach((y) => { y.gridIndex = indexShift; yAxes.push(y); }); + companionOption.series.forEach((s) => { + s.xAxisIndex = indexShift; + s.yAxisIndex = indexShift; + series.push(s); + }); + seriesMeta.push(...group.series); + companionOption.legend.forEach((l) => { if (typeof l.top === "number") l.top += deltaTop; legends.push(l); }); + (companionOption.graphic || []).forEach((g) => { if (g && typeof g.top === "number") g.top += deltaTop; if (g) graphics.push(g); }); + // This subplot's own Source key (when present) now sits below its x-axis + // labels, so reserve an extra SOURCE_KEY_HEIGHT strip before the next subplot + // to keep it clear. Subplots without a Source key need no extra room. + const hasSourceKey = (companionOption.graphic || []).some((g) => g && g.type === "group"); + runningTop = shiftedTop + GRID_HEIGHT + gridGap + (hasSourceKey ? SOURCE_KEY_HEIGHT + 12 : 0); + indexShift += 1; + }); + const bottomOffset = companionGroups.length > 0 + ? runningTop - gridGap + BOTTOM_OFFSET + : sessionsBottom + BOTTOM_OFFSET; + + const cs = window.getComputedStyle(container); + const verticalPadding = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0); + container.style.height = bottomOffset + verticalPadding + "px"; + + const allAxisIndices = xAxes.map((_, i) => i); + const toolbox = toolboxFeatures(elementId, opts.datasetName, opts.isSensorPage); + toolbox.feature.dataZoom.xAxisIndex = allAxisIndices; + + return { + textStyle: { fontFamily: CHART_FONT, fontSize: FONT_SIZE }, + graphic: graphics, + grid: grids, + title: titles, + xAxis: xAxes, + yAxis: yAxes, + series: series, + legend: legends, + axisPointer: { link: [{ xAxisIndex: "all" }] }, + tooltip: { + trigger: "item", + confine: true, + formatter: seriesTooltipFormatter(seriesMeta), + }, + toolbox: toolbox, + dataZoom: [ + { type: "inside", xAxisIndex: allAxisIndices, throttle: 80 }, + ], + }; +} + /* ============================== main entry points ============================== */ /** @@ -1110,7 +1503,8 @@ function buildHeatmapOption(elementId, data, opts) { * @param {string} elementId - The id of the container div. * @param {Object[]} data - Decompressed chart data rows. * @param {Object} [options] - { groupSpec, chartType, legendsBelow, datasetName }. - * chartType: "line" (default), "bar_chart", "histogram", "daily_heatmap" or "weekly_heatmap". + * chartType: "line" (default), "bar_chart", "histogram", "daily_heatmap", + * "weekly_heatmap" or "chart_for_chargepoint_sessions". */ export function renderFastChart(elementId, data, options) { const container = document.getElementById(elementId); @@ -1142,8 +1536,11 @@ export function renderFastChart(elementId, data, options) { option = buildHistogramOption(elementId, data, opts); } else if (opts.chartType === "daily_heatmap" || opts.chartType === "weekly_heatmap") { option = buildHeatmapOption(elementId, data, opts); + } else if (opts.chartType === "chart_for_chargepoint_sessions") { + option = buildChargePointSessionsOption(elementId, data, opts); } else { - const groups = groupData(data, opts.groupSpec); + const groupSpec = (opts.groupSpec || []).filter((g) => g.title !== CHARGE_POINT_SESSIONS_GROUP_TITLE); + const groups = groupData(data, groupSpec); option = groups.length > 0 ? buildLineBarOption(elementId, groups, opts) : null; } if (!option) { @@ -1154,6 +1551,32 @@ export function renderFastChart(elementId, data, options) { instance.chart.setOption(option, { notMerge: true }); wireAnnotationHover(instance, opts); + wireSessionTooltipRedirect(instance, opts); +} + +// The wide invisible hit-area series added per session segment (see +// buildSessionSegmentSeries) has its own tooltip disabled, because ECharts +// renders a broken zero-size tooltip box for a near-transparent line series +// even though hover/hit-testing on it work fine. Redirect hover on those +// series to dispatch the visible sibling series' (real) tooltip instead. +function wireSessionTooltipRedirect(instance, opts) { + const chart = instance.chart; + if (instance.onSessionHitHover) { + chart.off("mouseover", instance.onSessionHitHover); + instance.onSessionHitHover = null; + } + if (opts.chartType !== "chart_for_chargepoint_sessions") { + return; + } + instance.onSessionHitHover = (ev) => { + if (ev.componentType !== "series") return; + const series = chart.getOption().series || []; + const hovered = series[ev.seriesIndex]; + if (!hovered || !hovered.__hitAreaFor) return; + // The visible sibling is always pushed immediately after its hit-area series. + chart.dispatchAction({ type: "showTip", seriesIndex: ev.seriesIndex + 1, dataIndex: 0 }); + }; + chart.on("mouseover", instance.onSessionHitHover); } // Highlight the annotation band under the cursor (color + label), matching Vega-Lite. diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index bde5e20a3c..73629a05e3 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -217,9 +217,12 @@ item.classList.remove('active'); } }); - // In fast mode, re-render with the newly selected chart type + // Switch to the right renderer for the newly selected chart type: the + // fast chart if fast mode is on and this type supports it (updateFastChart), + // otherwise Vega-Lite (some chart types, like Charge Point sessions, are + // Vega-only and aren't renderable by the fast chart). if (fastMode) { - updateFastChart(); + applyFastMode(); return; } // Reload daterange @@ -375,7 +378,7 @@ ]).then(function (result) { return result[0] }).catch(console.error); $("#spinner").hide(); const renderStart = performance.now(); - if (!fastMode && vegaView) { + if (!fastRenderActive() && vegaView) { vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(newData)).resize().run(); } previousResult = { @@ -429,7 +432,7 @@ ]).then(function (result) { return result[0] }).catch(console.error); $("#spinner").hide(); var initialRenderStart = performance.now(); - if (!fastMode && vegaView) { + if (!fastRenderActive() && vegaView) { vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(fetchedInitialData)).resize().run(); } var sessionStart = new Date('{{ event_starts_after }}'); @@ -631,7 +634,7 @@ ]).then(function (result) { $("#spinner").hide(); const renderStart = performance.now(); - if (!fastMode && vegaView) { + if (!fastRenderActive() && vegaView) { vegaView.change(datasetName, vega.changeset().remove(vega.truthy).insert(result[0])).resize().run(); } previousResult = { @@ -645,7 +648,7 @@ updateFastChart(); checkSourceMasking(previousResult.data); playBackDataLoadedForKnownDateRange = false; - if (!fastMode && vegaView && chartType === "bar_chart") { + if (!fastRenderActive() && vegaView && chartType === "bar_chart") { vegaView.change(datasetName + '_annotations', vega.changeset().remove(vega.truthy).insert(result[1])).resize().run(); } recordRender(rangePerf, performance.now() - renderStart); @@ -655,8 +658,20 @@ return picker } + // Every chart type available on the asset/sensor pages (including the Charge + // Point sessions Gantt-style view) has a fast-chart.js renderer. + function chartTypeSupportedInFastMode() { + return true; + } + + // True when the fast (canvas) chart should actually be shown & rendered right + // now, i.e. the toggle is on AND the current chart type supports it. + function fastRenderActive() { + return fastMode && chartTypeSupportedInFastMode(); + } + function currentChartMode() { - return fastMode ? 'fast (ECharts)' : 'standard (Vega-Lite)'; + return fastRenderActive() ? 'fast (ECharts)' : 'standard (Vega-Lite)'; } // Show either the fast (canvas-based) chart or the Vega-Lite chart, and render it @@ -666,7 +681,7 @@ if (!fastDiv) { return; } const switchPerf = previousResult ? beginChartLoad('renderer switch', currentChartMode()) : null; const switchRenderStart = performance.now(); - if (fastMode) { + if (fastRenderActive()) { vegaDiv.style.display = 'none'; fastDiv.style.display = 'block'; updateFastChart(); @@ -691,8 +706,12 @@ function fastChartOptions() { return { groupSpec: fastChartGroupSpec, - // chart types (bar/histogram/heatmap) only apply on the sensor page - chartType: ('{{ active_page }}' === 'sensors') ? chartType : 'line', + // chart types (bar/histogram/heatmap) only apply on the sensor page; on + // the asset page it's either the Charge Point sessions chart or the + // default multi-sensor line chart. + chartType: ('{{ active_page }}' === 'sensors') ? chartType + : (chartType === 'chart_for_chargepoint_sessions') ? chartType + : 'line', legendsBelow: {{ 'true' if session.get("keep_legends_below_graphs") else 'false' }}, // sensor page always shows legend below (single sensor, sources are the legend entries) isSensorPage: ('{{ active_page }}' === 'sensors'), @@ -701,9 +720,10 @@ }; } - // Re-render the fast chart from the most recently fetched data (no-op outside fast mode) + // Re-render the fast chart from the most recently fetched data + // (no-op outside fast mode, or when the current chart type isn't supported in fast mode) function updateFastChart() { - if (!fastMode || !previousResult || !previousResult.data) { return; } + if (!fastRenderActive() || !previousResult || !previousResult.data) { return; } // Re-assert visibility, as other scripts (e.g. saving sensors_to_show) unhide the Vega-Lite chart const vegaDiv = document.getElementById(elementId); const fastDiv = document.getElementById(fastChartElementId); @@ -713,7 +733,7 @@ } async function embedAndLoad(chartSpecsPath, elementId, datasetName, previousResult, startDate, endDate) { - if (fastMode) { return; } // in fast mode, data is rendered by updateFastChart instead + if (fastRenderActive()) { return; } // in fast mode, data is rendered by updateFastChart instead const url = encodeUrlQuery(chartSpecsPath + 'dataset_name=' + datasetName + '&combine_legend=' + combineLegend + '&width=container&include_sensor_annotations=true&include_asset_annotations=true&chart_type=' + chartType); await vegaEmbed('#' + elementId, url, {{ chart_options | safe }}) .then(function (result) { @@ -798,7 +818,7 @@ signal = controller.signal; // In fast mode: clear the replay ruler and re-render the most recent data - if (fastMode) { + if (fastRenderActive()) { setFastChartReplayTime(fastChartElementId, null); updateFastChart(); document.getElementById('replay-time').innerHTML = ''; @@ -890,7 +910,7 @@ // Update beliefs in the replayed data given the new data replayedData = updateBeliefs(replayedData, newData); - if (fastMode) { + if (fastRenderActive()) { setFastChartReplayTime(fastChartElementId, beliefTime.getTime()); renderFastChart(fastChartElementId, replayedData, fastChartOptions()); } else { From 943cd589e79c959cff1c2b1c2795f5465d9e8167 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sat, 4 Jul 2026 19:11:19 +0200 Subject: [PATCH 21/28] Remove the Charge Point sessions line-style legend Drop the Arrival/Plug-in/Charging key from the sessions chart along with the now-unused buildChargePointKey helper and CHARGEPOINT_KEY_ROWS constant. --- flexmeasures/ui/static/js/fast-chart.js | 29 ------------------------- 1 file changed, 29 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index c7dff46fc2..837fb59273 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -1175,34 +1175,6 @@ function computeAssetLabels(sessions) { return labels; } -// "Session type" line-style key (dashed/thin, solid/medium, solid/thick), explaining -// what each layer means, mirroring the info tooltip next to the chart-type dropdown. -const CHARGEPOINT_KEY_ROWS = [ - { label: "Arrival → Departure", dash: [4, 4], width: 1.5 }, - { label: "Plug-in → Plug-out", dash: null, width: 2.5 }, - { label: "Charging", dash: null, width: 4 }, -]; - -function buildChargePointKey(left, top) { - const children = []; - let x = 0; - for (const row of CHARGEPOINT_KEY_ROWS) { - children.push({ - type: "line", - shape: { x1: x, y1: 9, x2: x + 26, y2: 9 }, - style: { stroke: "#555", lineWidth: row.width, lineDash: row.dash }, - }); - children.push({ - type: "text", - left: x + 32, - top: 3, - style: { text: row.label, fontSize: 11, fill: "#222" }, - }); - x += 32 + row.label.length * 6.2 + 26; - } - return { type: "group", left: left, top: top, children: children }; -} - // Build one real "line" series per session segment (2 points: from -> to on the // asset's category row), named by asset so ECharts' native legend groups every // layer of the same asset under one clickable, color-coded entry — matching @@ -1418,7 +1390,6 @@ function buildChargePointSessionsOption(elementId, data, opts) { }]; const graphics = [ { type: "text", left: containerWidth - LEGEND_WIDTH, top: sessionsTop - 20, style: { text: "Asset", font: "bold " + FONT_SIZE + "px " + CHART_FONT, fill: "#222" } }, - buildChargePointKey(GRID_LEFT, 6), ]; const series = sessionSeries.slice(); // Aligned 1:1 with the final `series` array: null for the session segment From 31d9264b030b31dac1965a60875a1ce735aad91f Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sat, 4 Jul 2026 20:15:52 +0200 Subject: [PATCH 22/28] Match fast (ECharts) chart interpolation to Vega-Lite Vega uses linear interpolation for a subplot row when any of its sensors is instantaneous (event_resolution 0), and step-after otherwise. The fast chart had to guess resolution from event spacing because the chart data never carried the real event_resolution, which mislabeled instantaneous sensors as interval data (stepped lines where Vega draws smooth ones). Serialize event_resolution (seconds) through every chart-data path the UI uses: Sensor.as_dict, Sensor.search_beliefs, GenericAsset.search_beliefs, and the fixed-value flex-sensor metadata in GenericAsset.chart_data_json. Carry it through decompressChartData, then decide interpolation per subplot in fast-chart.js exactly as chart_for_multiple_sensors does, with a spacing-based fallback for legacy data. --- flexmeasures/data/models/generic_assets.py | 10 +++++++++ flexmeasures/data/models/time_series.py | 4 ++++ flexmeasures/ui/static/js/chart-data-utils.js | 1 + flexmeasures/ui/static/js/fast-chart.js | 21 +++++++++++++------ 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index ad64be981d..7237d28860 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -640,6 +640,7 @@ def _create_fixed_value_sensors( id=fixed_value_sensor.id, name=sensor_name, sensor_unit=ref["unit"], + event_resolution=fixed_value_sensor.event_resolution.total_seconds(), description=sensor_name, asset_id=parent_asset.id, asset_description=parent_asset.name, @@ -1083,6 +1084,12 @@ def chart_data_json( sensors_meta[str(sensor.id)] = { "name": as_dict.get("name", sensor.name), "unit": as_dict.get("sensor_unit", sensor.unit), + # Fixed-value sensors are instantaneous (0), so the chart + # renders their row with linear interpolation like Vega-Lite. + "event_resolution": as_dict.get( + "event_resolution", + sensor.event_resolution.total_seconds(), + ), "description": as_dict.get("description", sensor.name), "asset_id": as_dict.get("asset_id"), "asset_description": as_dict.get("asset_description", ""), @@ -1290,6 +1297,9 @@ def search_beliefs( # noqa C901 sensors_metadata[sensor.id] = { "name": sensor_dict.get("name", ""), "unit": sensor_dict.get("unit", sensor.unit), + "event_resolution": sensor_dict.get( + "event_resolution", sensor.event_resolution.total_seconds() + ), "description": sensor_dict.get("description", ""), "asset_id": sensor_dict.get( "asset_id", getattr(sensor, "generic_asset_id", None) diff --git a/flexmeasures/data/models/time_series.py b/flexmeasures/data/models/time_series.py index 0ea7fda6a3..6eb913f845 100644 --- a/flexmeasures/data/models/time_series.py +++ b/flexmeasures/data/models/time_series.py @@ -474,6 +474,9 @@ def search_beliefs( # noqa: C901 sensors_metadata[self.id] = { "name": sensor_dict.get("name", ""), "unit": sensor_dict.get("sensor_unit", self.unit), + "event_resolution": sensor_dict.get( + "event_resolution", self.event_resolution.total_seconds() + ), "description": sensor_dict.get("description", ""), "asset_id": sensor_dict.get( "asset_id", getattr(self, "generic_asset_id", None) @@ -703,6 +706,7 @@ def as_dict(self) -> dict: id=self.id, name=self.name, sensor_unit=self.unit, + event_resolution=self.event_resolution.total_seconds(), description=f"{self.name} ({self.generic_asset.name})", asset_description=f"{self.generic_asset.name}" + (f" ({parent_asset.name})" if parent_asset is not None else ""), diff --git a/flexmeasures/ui/static/js/chart-data-utils.js b/flexmeasures/ui/static/js/chart-data-utils.js index b423cf8586..f0a9f44c97 100644 --- a/flexmeasures/ui/static/js/chart-data-utils.js +++ b/flexmeasures/ui/static/js/chart-data-utils.js @@ -46,6 +46,7 @@ export function decompressChartData(responseData) { name: sensor.name || "", sensor_unit: sensor.unit || "", unit: sensor.unit || "", + event_resolution: sensor.event_resolution, description: sensor.description || "", asset_id: sensor.asset_id, asset_description: sensor.asset_description || "", diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index 837fb59273..c52dd72bfc 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -255,6 +255,11 @@ function groupData(data, groupSpec) { sourceLabel: sourceLabel(source), source: source, unit: unit, + // The sensor's real event resolution in seconds (0 = instantaneous), + // as sent by Sensor.as_dict; undefined for legacy data, in which case we + // fall back to inferring it from the event spacing. + eventResolutionSec: + typeof sensor.event_resolution === "number" ? sensor.event_resolution : null, points: [], eventStarts: [], // kept for resolution inference (not sent to ECharts) }); @@ -761,6 +766,15 @@ function buildLineBarOption(elementId, groups, opts) { minorTick: { show: true, splitNumber: 2 }, minorSplitLine: { show: true, lineStyle: { color: "#e0e0e0", width: 1 } }, }); + // Match Vega-Lite (chart_for_multiple_sensors): use linear interpolation for + // the whole row if ANY of its sensors is instantaneous (event_resolution 0), + // otherwise step-after. Prefer the sensor's real resolution; fall back to + // inferring it from event spacing only for legacy data without it. + const groupIsInstantaneous = group.series.some((s) => + typeof s.eventResolutionSec === "number" + ? s.eventResolutionSec === 0 + : inferResolutionMs(s.eventStarts || []) <= 60 * 1000 + ); group.series.forEach((s, j) => { const isBar = opts.chartType === "bar_chart"; const entry = { @@ -787,13 +801,8 @@ function buildLineBarOption(elementId, groups, opts) { itemStyle: { opacity: 0.7 }, }); } else { - // Match Vega-Lite: use step-after for sensors with a fixed event duration - // (resolution > 0), and linear for instantaneous sensors (resolution ≈ 0) - // so that ramps / gradual curves are visible, just as in the old charts. - const seriesResMs = inferResolutionMs(s.eventStarts || []); - const isInstantaneous = seriesResMs <= 60 * 1000; // ≤ 1 min → treat as instantaneous Object.assign(entry, { - ...(isInstantaneous ? {} : { step: "end" }), // step-after for interval data + ...(groupIsInstantaneous ? {} : { step: "end" }), // step-after for interval data showSymbol: false, sampling: "lttb", // downsample to the available pixels, preserving peaks large: true, // batched canvas path: faster redraws while panning/zooming From 0cd573f3ba574af11629a911f93e7e394d40048c Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sun, 5 Jul 2026 13:31:31 +0200 Subject: [PATCH 23/28] Snap fast (ECharts) chart tooltip to the nearest data point Use axis-trigger tooltips on the line/bar charts so hovering anywhere over a subplot reveals the nearest data point (the ruler), as in the Vega-Lite charts, instead of requiring a direct hit on the thin line. The formatter picks the single point nearest to the ruler and shows its details, scoped to the hovered subplot. --- flexmeasures/ui/static/js/fast-chart.js | 60 ++++++++++++++++++------- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index c52dd72bfc..a156233cbd 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -537,26 +537,50 @@ function noDataOption(message) { } // Tooltip matching the Vega-Lite charts: a two-column table per data point +// Render the rich single-point table for one series' data point. +function singlePointTooltip(meta, value) { + if (!meta || !value) { + return ""; + } + return tooltipTable([ + ["Sensor", meta.sensorDescription], + [capFirst(meta.sensorType), formatQuantity(value[1], meta.unit)], + ["Time and date", formatFullDate(value[0])], + ["Horizon", formatTimedelta(value[2])], + ["Source", meta.source.name + " (ID: " + meta.source.id + ")"], + ["Type", meta.source.display_type || ""], + ["Model", meta.source.model || ""], + ["Version", meta.source.version || ""], + ]); +} + function seriesTooltipFormatter(seriesMeta) { return function (params) { + // Axis trigger passes an array of the series' points near the ruler; item + // trigger passes a single point. In both cases we show just the nearest one, + // as in the Vega-Lite charts (which snap to the nearest data point on hover). + if (Array.isArray(params)) { + if (params.length === 0) { + return ""; + } + // Pick the point closest to the ruler position (params[i].axisValue). + const ref = params[0].axisValue; + let best = params[0]; + let bestDist = Infinity; + for (const p of params) { + const x = p.value && p.value[0]; + const dist = typeof x === "number" && typeof ref === "number" ? Math.abs(x - ref) : 0; + if (dist < bestDist) { + bestDist = dist; + best = p; + } + } + return singlePointTooltip(seriesMeta[best.seriesIndex], best.value); + } if (params.componentType === "legend") { return escapeHtml(params.name); // legend hover: just reveal the full series name } - const meta = seriesMeta[params.seriesIndex]; - if (!meta) { - return ""; - } - const value = params.value; - return tooltipTable([ - ["Sensor", meta.sensorDescription], - [capFirst(meta.sensorType), formatQuantity(value[1], meta.unit)], - ["Time and date", formatFullDate(value[0])], - ["Horizon", formatTimedelta(value[2])], - ["Source", meta.source.name + " (ID: " + meta.source.id + ")"], - ["Type", meta.source.display_type || ""], - ["Model", meta.source.model || ""], - ["Version", meta.source.version || ""], - ]); + return singlePointTooltip(seriesMeta[params.seriesIndex], params.value); }; } @@ -888,8 +912,12 @@ function buildLineBarOption(elementId, groups, opts) { link: [{ xAxisIndex: "all" }], // sync the ruler across subplots }, tooltip: { - trigger: "item", // per-point details, as in the Vega-Lite charts + // Axis trigger so hovering anywhere snaps to the nearest data point (the + // ruler), as in the Vega-Lite charts, instead of requiring a direct hit on + // the thin line. The formatter then shows just that nearest point. + trigger: "axis", confine: true, + axisPointer: { type: "line" }, formatter: seriesTooltipFormatter(seriesMeta), }, toolbox: toolbox, From 2c565c1de274cc1c062bc496b8306a8690227a9b Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Wed, 8 Jul 2026 15:46:18 +0200 Subject: [PATCH 24/28] Align fast (ECharts) chart subplots on a shared x domain Each subplot's time axis auto-scaled to its own data extent, so subplots whose data spans a wider range (e.g. Site capacity's capacity sensors) rendered at a different horizontal scale than the rest, most visibly misaligning the replay ruler. Compute one shared time domain from the union of all subplots' data and apply it to every time axis, mirroring Vega-Lite's resolve.scale.x = shared. --- flexmeasures/ui/static/js/fast-chart.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index a156233cbd..5946f18399 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -711,6 +711,27 @@ function buildLineBarOption(elementId, groups, opts) { const seriesMeta = []; const sensorColor = new Map(); + // Share one x domain across all subplots, as Vega-Lite does (resolve.scale.x = + // "shared"), so the time axes stay aligned even when some subplots' data (e.g. + // fixed-value lines spanning the full window) reach further than others'. + let sharedMinTime = Infinity; + let sharedMaxTime = -Infinity; + groups.forEach((g) => + g.series.forEach((s) => + s.points.forEach((p) => { + const x = typeof p[0] === "number" ? p[0] : +new Date(p[0]); + if (isFinite(x)) { + if (x < sharedMinTime) sharedMinTime = x; + if (x > sharedMaxTime) sharedMaxTime = x; + } + }) + ) + ); + const sharedXDomain = + isFinite(sharedMinTime) && isFinite(sharedMaxTime) + ? { min: sharedMinTime, max: sharedMaxTime } + : {}; + groups.forEach((group, i) => { const top = topOffset + i * (GRID_HEIGHT + gridGap); grids.push({ @@ -759,6 +780,7 @@ function buildLineBarOption(elementId, groups, opts) { xAxes.push({ type: "time", gridIndex: i, + ...sharedXDomain, // keep every subplot on the same time domain axisLine: { onZero: false }, axisPointer: { show: true }, // vertical ruler, as in the Vega-Lite charts splitLine: { show: true, lineStyle: { opacity: 0.5 } }, From 130ba8359b75cd315d55d0d1ac69ff51911cd45c Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Wed, 8 Jul 2026 15:55:47 +0200 Subject: [PATCH 25/28] apply pre-commit Signed-off-by: Ahmad-Wahid --- flexmeasures/ui/static/openapi-specs.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 599e06d878..bdc1d16db1 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -7,7 +7,7 @@ }, "termsOfService": null, "title": "FlexMeasures", - "version": "1.0.0" + "version": "0.31.0" }, "externalDocs": { "description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.", From e3a70ca269ca58427d3f2992397247d1c990abdd Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Wed, 8 Jul 2026 15:57:24 +0200 Subject: [PATCH 26/28] Revert "apply pre-commit" This reverts commit 130ba8359b75cd315d55d0d1ac69ff51911cd45c. --- flexmeasures/ui/static/openapi-specs.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index bdc1d16db1..599e06d878 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -7,7 +7,7 @@ }, "termsOfService": null, "title": "FlexMeasures", - "version": "0.31.0" + "version": "1.0.0" }, "externalDocs": { "description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.", From 216b398b8088c563fad299a20b96256fdf8dbc67 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Wed, 8 Jul 2026 16:48:26 +0200 Subject: [PATCH 27/28] docs: add changelog entry for the fast (ECharts) chart mode --- documentation/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 4e5f93e1bc..ecfff71823 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -12,6 +12,7 @@ v1.0.0 | July XX, 2026 New features ------------- +* In the UI, an opt-in "Fast chart" mode renders asset and sensor charts with Apache ECharts (canvas) for much faster drawing and interaction on dense time series, while staying visually and functionally equivalent to the existing Vega-Lite charts [see `PR #2234 `_] * In the UI, asset and sensor lists can be filtered by ID prefix through API-backed search fields [see `PR #2231 `_] * Floor off-clock API datetimes to a non-instantaneous sensor's resolution by default when ingesting sensor data, uploading sensor data, and handling scheduler flex-model timed events; configurable with the ``floor_datetimes_to_resolution`` sensor attribute [see `PR #2146 `_] * Sensor references in flex-model and flex-context support various ways of filtering by source [see `PR #2209 `_] From 7acee9d2d0d81e34d0d7aa7bbfb5ec1e4ac2a96d Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Wed, 8 Jul 2026 17:02:23 +0200 Subject: [PATCH 28/28] fix: use cache as main Signed-off-by: Ahmad-Wahid --- flexmeasures/ui/templates/includes/graphs.html | 6 ------ 1 file changed, 6 deletions(-) diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index 73629a05e3..f6a1810718 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -367,7 +367,6 @@ let newData = fetch(dataPath + '/chart_data?event_starts_after=' + storeStartDate.toISOString() + '&event_ends_before=' + storeEndDate.toISOString() + '&compress_json=true', { method: "GET", headers: { "Content-Type": "application/json" }, - cache: "no-store", signal: signal, }) .then(function (response) { return response.json(); }) @@ -421,7 +420,6 @@ const initialData = fetch(dataPath + '/chart_data?event_starts_after=' + '{{ event_starts_after }}' + '&event_ends_before=' + '{{ event_ends_before }}' + '&compress_json=true', { method: "GET", headers: { "Content-Type": "application/json" }, - cache: "no-store", signal: signal, }) .then(function (response) { return response.json(); }) @@ -623,7 +621,6 @@ fetch(dataPath + '/chart_annotations?event_starts_after=' + queryStartDate + '&event_ends_before=' + queryEndDate, { method: "GET", headers: {"Content-Type": "application/json"}, - cache: "no-store", signal: signal, }) .then(function(response) { return response.json(); }), @@ -858,7 +855,6 @@ fetch(dataPath + '/chart_data?event_starts_after=' + queryStartDate + '&event_ends_before=' + queryEndDate + '&most_recent_beliefs_only=false&compress_json=true', { method: "GET", headers: { "Content-Type": "application/json" }, - cache: "no-store", signal: signal, }) .then(function (response) { return response.json(); }) @@ -954,7 +950,6 @@ return fetch(dataPath + '/chart_data?event_starts_after=' + queryStartDate + '&event_ends_before=' + queryEndDate + '&compress_json=true', { method: "GET", headers: { "Content-Type": "application/json" }, - cache: "no-store", signal: signal, }) .then(response => response.json()) @@ -973,7 +968,6 @@ headers: { "Content-Type": "application/json", }, - cache: "no-store", }) .then(response => response.json()) .then(response => {