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/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..ff454d6d08 --- /dev/null +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -0,0 +1,1240 @@ +/** + * 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 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, 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"; + +// 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 = 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"]; + +// One chart instance per container element +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) { + 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); + } + + 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 labels; +} + +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, + (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). + * + * 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 — 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 numericRows(data)) { + 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: [], + 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. + ser.points.push([row.event_start, row.event_value, row.belief_horizon]); + ser.eventStarts.push(row.event_start); + } + + // 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; + + // 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. + 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); + } + 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, + nameBySensor: nameBySensor, + series: series, + }; + }); +} + +// 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 = [ + "#4c78a8", "#f58518", "#e45756", "#72b7b2", "#54a24b", + "#eeca3b", "#b279a2", "#ff9da6", "#9d755d", "#bab0ac", +]; + +// 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")) { + 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(); + for (const row of rows) { + const sid = row.source ? row.source.id : null; + counts.set(sid, (counts.get(sid) || 0) + 1); + } + let bestSid = null; + let bestCount = -1; + for (const [sid, count] of counts) { + if (count > bestCount) { + bestSid = sid; + bestCount = count; + } + } + return rows.filter((row) => (row.source ? row.source.id : null) === bestSid); +} + +// 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; + return Math.max(res, 60 * 1000); // at least 1 minute, to bound the number of cells +} + +/* ============================== chart parts ============================== */ + +function yAxisTitle(sensorType, units) { + const unitLabel = units.join(", "); + return sensorType + ? capFirst(sensorType) + (unitLabel ? " (" + unitLabel + ")" : "") + : unitLabel; +} + +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) { + 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); +} + +function exportCSV(elementId, datasetName) { + const instance = instances[elementId]; + if (!instance || !instance.lastArgs) { + return; + } + 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"); +} + +// 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" })); + // 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, + }); +} + +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(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: { + 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 seriesTooltipFormatter(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; + return tooltipTable([ + ["Sensor", meta.sensorDescription], + [capFirst(meta.sensorType), formatQuantity(value[1], meta.unit)], + ["Time and date", formatFullDate(value[0])], + ["Horizon", formatTimedelta(value[2])], + ["Source", meta.source.name + " (ID: " + meta.source.id + ")"], + ["Type", meta.source.display_type || ""], + ["Model", meta.source.model || ""], + ["Version", meta.source.version || ""], + ]); + }; +} + +/* ============================== 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; + const gridRight = legendsBelow ? 30 : LEGEND_WIDTH + 40; + const containerWidth = container.clientWidth || 800; + const plotCenter = (GRID_LEFT + containerWidth - gridRight) / 2; + + // 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 + // 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: 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 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; + // 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; + + // 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 + verticalPadding + "px" + : topOffset + groups.length * (GRID_HEIGHT + gridGap) + BOTTOM_OFFSET + verticalPadding + "px"; + + const grids = []; + const xAxes = []; + const yAxes = []; + const titles = []; + const legends = []; + const series = []; + const seriesMeta = []; + const sensorColor = new Map(); + + groups.forEach((group, i) => { + const top = topOffset + i * (GRID_HEIGHT + gridGap); + grids.push({ + top: top, + height: GRID_HEIGHT, + left: GRID_LEFT, + right: gridRight, + containLabel: false, + }); + titles.push({ + text: group.title, + left: plotCenter, + textAlign: "center", + top: top - 42, + 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))); + // 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: legendNames, + type: "scroll", + tooltip: { show: true }, // hover reveals truncated names in full + orient: "vertical", + right: 8, + top: legendTop, + height: GRID_HEIGHT, + align: "left", + itemWidth: 18, + itemGap: 6, + 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({ + 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 } }, + // 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, + color: "#222", + formatter: (value) => { + const d = new Date(value); + const h = d.getHours(), m = d.getMinutes(); + if (h === 0 && m === 0) { + const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + if (d.getDate() === 1) return MONTHS[d.getMonth()]; + return DAYS[d.getDay()] + "\n" + String(d.getDate()).padStart(2, "0"); + } + return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"); + }, + }, + }); + yAxes.push({ + type: "value", + gridIndex: i, + name: yAxisTitle(group.sensorType, group.units), // e.g. "Power (kW)" + nameLocation: "end", + nameTextStyle: { + fontSize: FONT_SIZE, + fontWeight: "bold", + color: "#222", + align: "left", + padding: [0, 0, 4, -GRID_LEFT + 16], + }, + 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"; + const entry = { + name: s.name, + type: isBar ? "bar" : "line", + xAxisIndex: i, + yAxisIndex: i, + data: s.points, + 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 + large: true, + itemStyle: { opacity: 0.7 }, + }); + } else { + // Match Vega-Lite: use step-after for sensors with a fixed event duration + // (resolution > 0), and linear for instantaneous sensors (resolution ≈ 0) + // so that ramps / gradual curves are visible, just as in the old charts. + const seriesResMs = inferResolutionMs(s.eventStarts || []); + const isInstantaneous = seriesResMs <= 60 * 1000; // ≤ 1 min → treat as instantaneous + Object.assign(entry, { + ...(isInstantaneous ? {} : { step: "end" }), // step-after for interval data + 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) }, + }); + } + // 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 }, + }; + } + // 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); + }); + }); + + // 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: "plain", + orient: bottomLegendVertical ? "vertical" : "horizontal", + left: GRID_LEFT, + right: bottomLegendVertical ? undefined : 30, + top: bottomLegendTop, + itemWidth: 18, + itemGap: bottomLegendVertical ? 6 : 12, + textStyle: { fontSize: FONT_SIZE }, + tooltip: { show: true }, + }); + } + + // 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, opts.isSensorPage); + toolbox.feature.dataZoom.xAxisIndex = allAxisIndices; + + return { + textStyle: { fontFamily: CHART_FONT, fontSize: FONT_SIZE }, + graphic: [sourceKey, bottomLegendTitle].filter(Boolean), + 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: seriesTooltipFormatter(seriesMeta), + }, + toolbox: toolbox, + dataZoom: [ + // Mouse-wheel zoom and drag-to-pan. throttle coalesces rapid wheel/drag + // ticks so we redraw at most every ~80 ms instead of on every event. + { type: "inside", xAxisIndex: allAxisIndices, throttle: 80 }, + // Range slider: realtime:false redraws only on drag-release (no mid-drag + // re-render of all series); a light gray ghost shows the pending window. + legendsBelow + ? { type: "slider", xAxisIndex: allAxisIndices, top: sliderTop, height: 28, realtime: false, throttle: 80 } + : { type: "slider", xAxisIndex: allAxisIndices, bottom: 14, height: 28, realtime: false, throttle: 80 }, + ], + }; +} + +/* ============================== 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 + 400 + 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, + 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 + 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: 40, + nameTextStyle: { fontSize: FONT_SIZE, fontWeight: "bold", color: "#222" }, + axisLabel: { fontSize: FONT_SIZE }, + }, + yAxis: { + type: "value", + name: "Count", + nameLocation: "end", + 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: { + 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 + 395 : TOP_OFFSET + 60, + textStyle: { fontSize: FONT_SIZE }, + 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], + ]); + }, + }, + textStyle: { fontFamily: CHART_FONT, fontSize: FONT_SIZE }, + toolbox: toolboxFeatures(elementId, opts.datasetName, opts.isSensorPage), + }; +} + +/* ============================== 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: FONT_SIZE, 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 || ""], + ]); + }, + }, + textStyle: { fontFamily: CHART_FONT, fontSize: FONT_SIZE }, + toolbox: toolboxFeatures(elementId, opts.datasetName, opts.isSensorPage), + series: [ + { + 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 }); + + 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); +} + +/** + * 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; + } +} + +/** + * 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..bde5e20a3c 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, 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) 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,44 @@ 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: 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 | shared_sensor_type %} + {% 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 +122,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 +152,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 +217,11 @@ item.classList.remove('active'); } }); + // In fast mode, re-render with the newly selected chart type + if (fastMode) { + updateFastChart(); + 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 +360,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 +374,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 +414,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 +428,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 +439,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 +612,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 +620,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 +630,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 +642,78 @@ {% 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 }); + } + } + + // 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' }}, + // 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 : [], + }; + } + + // 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, fastChartOptions()); + } + 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) { @@ -641,6 +797,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 = ''; @@ -674,6 +838,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(); }) @@ -725,21 +890,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; @@ -764,6 +934,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 +953,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/utils/config_defaults.py b/flexmeasures/utils/config_defaults.py index e4d869054b..7e39b19bdd 100644 --- a/flexmeasures/utils/config_defaults.py +++ b/flexmeasures/utils/config_defaults.py @@ -179,6 +179,7 @@ class Config(object): 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", leaflet="1.9.4",