From 24e8435ea17b176a3ff4075e24ab28c4aa6cbdf9 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 12 Jun 2026 09:58:13 +0200 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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",