From e7375ee7c0299816950ef1a43f4750bf4df1a19b Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 5 Nov 2026 15:59:36 +0100 Subject: [PATCH 1/7] Show the Charge Point sessions tooltip again after the move to ECharts A line series only answers hover through its symbols, which carry the data index the item tooltip needs. The session bars were bare 2-point lines whose invisible hit-area series had no symbols and a disabled tooltip, so nothing ever fired on hover, and the redirect that was meant to cover for it never ran. The hit-area series now has large invisible symbols and its own tooltip, and sits above the visible line, which would otherwise be the element under the cursor. The redirect is gone. The new test drives the real ECharts library with mouse events, which the formatter-only test could not. Signed-off-by: Ahmad-Wahid --- documentation/changelog.rst | 1 + flexmeasures/ui/static/js/fast-chart.js | 63 +++++---------------- flexmeasures/ui/tests/js/conftest.py | 15 +++++ flexmeasures/ui/tests/js/test_fast_chart.py | 62 ++++++++++++++++++++ 4 files changed, 92 insertions(+), 49 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index eadc90abe1..ae5c869601 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -209,6 +209,7 @@ Bugfixes **UI** +* Hovering a session in the Charge Points chart shows its tooltip again, as it did before the move to ECharts [see `issue #2828 `_] * Remember the selected graph date range when navigating between sensors and assets, including when using fast charts or cached data [see `PR #2774 `_] * Keep the current-time bar visible during sensor chart playback when no data is visible yet [see `PR #2722 `_] * The sensor upload form now reports whether a queued upload succeeded, and a refused replacement of existing values is explained rather than shown as a database error [see `PR #2722 `_] diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index ac67517625..b2ad61f8ff 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -2096,41 +2096,32 @@ function buildSessionSegmentSeries(sessions, fromKey, toKey, categoryIndex, line ["Asset", s.assetLabel], ]), }; - // Regular time-series lines (e.g. the companion subplots) have a data point - // every few pixels, so ECharts' "nearest point" hover snap always finds one - // close to the cursor anywhere along the line. A session segment only has 2 - // points — its start and end, often hours apart — so that snap logic finds - // nothing nearby over most of a long bar. Densely interpolate the invisible - // hit-area line (kept off the visible line) so hovering anywhere along the - // bar's length has a point within range, not just right at its endpoints. - const hitPointCount = 40; + // A line series only answers hover through its symbols (ECharts attaches a data index, which the item tooltip needs, to symbols alone), + // and a session segment has just 2 points, its start and end, often hours apart. + // So draw an invisible companion series of densely interpolated points, each with a large invisible symbol, + // and put it above the visible line: the visible line would otherwise be the element under the cursor, and it carries no data index. + // The hit series shares the visible line's name, so it adds no legend entry and is not toggled independently. + const hitPointCount = 100; const hitData = []; for (let i = 0; i <= hitPointCount; i++) { hitData.push([from + ((to - from) * i) / hitPointCount, catIdx]); } - // Invisible companion line, much wider than the visible stroke, purely to - // widen the hoverable hit area: thin session bars (down to 1.5px) are - // otherwise very hard to hover precisely. Shares the visible line's name - // so it doesn't add its own legend entry or get toggled independently. - // Its OWN tooltip is disabled (tooltip.show: false): ECharts' tooltip box - // renders at zero size for a near-transparent series even though hover/hit - // -test work fine on it, so hovering it instead redirects (via the - // __hitAreaFor marker + the mouseover listener in wireSessionTooltipRedirect) - // to dispatch the visible sibling series' tooltip. series.push({ name: s.assetLabel, type: "line", xAxisIndex: 0, yAxisIndex: 0, - symbol: "none", - showSymbol: false, + symbol: "circle", + showSymbol: true, + symbolSize: Math.max(lineWidth, 16), animation: false, legendHoverLink: false, - lineStyle: { width: Math.max(lineWidth, 16), opacity: 0.001 }, + lineStyle: { opacity: 0 }, + itemStyle: { opacity: 0.001 }, + emphasis: { disabled: true }, data: hitData, - tooltip: { show: false }, - __hitAreaFor: true, - z: 1, + tooltip: tooltip, + z: 3, }); series.push({ name: s.assetLabel, @@ -2484,7 +2475,6 @@ export function renderFastChart(elementId, data, options) { instance.chart.setOption(option, { notMerge: true }); wireAnnotationHover(instance); - wireSessionTooltipRedirect(instance, opts); wirePointerTracking(instance); wireZoomInteractions(instance, opts); wireZoomRefresh(instance); @@ -2861,31 +2851,6 @@ function wirePointerTracking(instance) { zr.on("globalout", instance.onPointerOut); } -// The wide invisible hit-area series added per session segment (see -// buildSessionSegmentSeries) has its own tooltip disabled, because ECharts -// renders a broken zero-size tooltip box for a near-transparent line series -// even though hover/hit-testing on it work fine. Redirect hover on those -// series to dispatch the visible sibling series' (real) tooltip instead. -function wireSessionTooltipRedirect(instance, opts) { - const chart = instance.chart; - if (instance.onSessionHitHover) { - chart.off("mouseover", instance.onSessionHitHover); - instance.onSessionHitHover = null; - } - if (opts.chartType !== "chart_for_chargepoint_sessions") { - return; - } - instance.onSessionHitHover = (ev) => { - if (ev.componentType !== "series") return; - const series = chart.getOption().series || []; - const hovered = series[ev.seriesIndex]; - if (!hovered || !hovered.__hitAreaFor) return; - // The visible sibling is always pushed immediately after its hit-area series. - chart.dispatchAction({ type: "showTip", seriesIndex: ev.seriesIndex + 1, dataIndex: 0 }); - }; - chart.on("mouseover", instance.onSessionHitHover); -} - // Highlight the annotation band/rule under the cursor and show its text below the // hovered subplot ONLY (the other subplots keep the light shading), matching the // Vega-Lite annotation layers. Clicking (or tapping, on touch) pins the highlight; diff --git a/flexmeasures/ui/tests/js/conftest.py b/flexmeasures/ui/tests/js/conftest.py index 648a700997..845f6be495 100644 --- a/flexmeasures/ui/tests/js/conftest.py +++ b/flexmeasures/ui/tests/js/conftest.py @@ -22,6 +22,9 @@ import pytest STATIC_JS = Path(__file__).resolve().parents[3] / "ui" / "static" / "js" +# The vendored libraries are downloaded on demand and not checked in, +# so tests that need one skip themselves when it is absent (see the `echarts_available` fixture). +STATIC_VENDOR = Path(__file__).resolve().parents[3] / "ui" / "static" / "vendor" # Layered so that whichever budget a slow machine exhausts, the page has already said why: # the page's own watchdog fires first, then the wait for it, then the HTTP client. @@ -93,6 +96,11 @@ def do_GET(self): # noqa: N802 "text/html", ) return + if self.path.startswith("/vendor/echarts.min.js"): + found = sorted(STATIC_VENDOR.glob("echarts/*/dist/echarts.min.js")) + if found: + self._send(found[-1].read_bytes(), "text/javascript") + return if self.path.startswith("/js/"): target = STATIC_JS / self.path[len("/js/") :].split("?")[0] if target.is_file() and STATIC_JS in target.resolve().parents: @@ -204,6 +212,13 @@ def run( server.server_close() +@pytest.fixture(scope="session") +def echarts_available() -> None: + """Skip a test that needs the real ECharts library, when it has not been downloaded.""" + if not list(STATIC_VENDOR.glob("echarts/*/dist/echarts.min.js")): + pytest.skip("the vendored ECharts library is not downloaded") + + @pytest.fixture(scope="session") def js_budgets() -> dict[str, float]: """The budgets the harness runs to. diff --git a/flexmeasures/ui/tests/js/test_fast_chart.py b/flexmeasures/ui/tests/js/test_fast_chart.py index a8da9896b0..54f6cacfd4 100644 --- a/flexmeasures/ui/tests/js/test_fast_chart.py +++ b/flexmeasures/ui/tests/js/test_fast_chart.py @@ -217,6 +217,68 @@ def test_charge_point_sessions_keep_their_own_tooltip(assert_js): """) +def test_hovering_a_session_bar_shows_its_tooltip(assert_js, echarts_available): + """Hovering anywhere along a session bar, on each of its layers, shows that segment's tooltip. + + A line series answers hover only through its symbols, + so a bar drawn as a bare 2-point line shows no tooltip at all; + this drives the real ECharts library with real mouse events to catch that. + """ + assert_js(""" + await new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = "/vendor/echarts.min.js"; + script.onload = resolve; + script.onerror = () => reject(new Error("could not load ECharts")); + document.head.appendChild(script); + }); + const { renderFastChart } = await import("/js/fast-chart.js?sessions-hover"); + + const container = document.createElement("div"); + container.id = "sessions-hover"; + container.style.cssText = "width: 1000px; height: 500px;"; + document.body.appendChild(container); + const start = Date.parse("2026-09-21T10:00:00Z"); + const hour = 3600000; + const sensor = (name, id) => ({id, name, unit: "s", asset_id: 4, asset_description: "Charger 4"}); + const rows = [ + ["arrival", 0], ["departure", 5], ["plug in", 0.5], ["plug out", 4.5], ["start charging", 1], ["stop charging", 4], + ].map(([name, offset], i) => ({sensor: sensor(name, i + 1), event_start: start, event_value: start + offset * hour})); + renderFastChart("sessions-hover", rows, { + groupSpec: [], datasetName: "sessions", isSensorPage: false, + chartType: "chart_for_chargepoint_sessions", chargePointChart: {sessionsTitle: "Sessions"}, + }); + const chart = echarts.getInstanceByDom(container); + + const hoverAt = async (hours) => { + const [x, y] = chart.convertToPixel({xAxisIndex: 0, yAxisIndex: 0}, [start + hours * hour, 0]); + const rect = container.getBoundingClientRect(); + const init = {clientX: rect.left + x, clientY: rect.top + y, bubbles: true}; + const target = container.querySelector("canvas"); + target.dispatchEvent(new MouseEvent("mousemove", init)); + target.dispatchEvent(new MouseEvent("mousemove", {...init, clientX: init.clientX + 1})); + await new Promise((resolve) => setTimeout(resolve, 300)); + const shown = [...container.querySelectorAll("div")].filter((el) => el.style.display === "block"); + return shown.map((el) => el.textContent).join(" "); + }; + const clear = async () => { + chart.dispatchAction({type: "hideTip"}); + await new Promise((resolve) => setTimeout(resolve, 300)); + }; + + // Inside the thick start/stop charging bar, only reachable through it. + const charging = await hoverAt(2.5); + check("the charging bar names its start", charging.includes("Start charging"), charging); + check("the charging bar names its end", charging.includes("Stop charging"), charging); + await clear(); + // Outside the charging bar and the plug-in bar, on the thin dashed arrival/departure bar only. + const arrival = await hoverAt(4.8); + check("the arrival bar names its start", arrival.includes("Arrival"), arrival); + check("the arrival bar names its end", arrival.includes("Departure"), arrival); + check("the tooltip names the asset", arrival.includes("Charger 4"), arrival); + """) + + def test_annotation_hover_survives_canvas_exit_and_pin(assert_js): """Pinned text stays stable while hovered text occupies a separate row.""" assert_js(""" From 4a093d04e4699cdf0adcfa8e810104001bb0f277 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 5 Nov 2026 16:02:59 +0100 Subject: [PATCH 2/7] Link the changelog entry to its PR Signed-off-by: Ahmad-Wahid --- documentation/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index ae5c869601..24445711a7 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -209,7 +209,7 @@ Bugfixes **UI** -* Hovering a session in the Charge Points chart shows its tooltip again, as it did before the move to ECharts [see `issue #2828 `_] +* Hovering a session in the Charge Points chart shows its tooltip again, as it did before the move to ECharts [see `PR #2829 `_] * Remember the selected graph date range when navigating between sensors and assets, including when using fast charts or cached data [see `PR #2774 `_] * Keep the current-time bar visible during sensor chart playback when no data is visible yet [see `PR #2722 `_] * The sensor upload form now reports whether a queued upload succeeded, and a refused replacement of existing values is explained rather than shown as a database error [see `PR #2722 `_] From 04fbaccb2a8d81b1ef67cb65370e7ae12a2348d7 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 5 Nov 2026 16:20:42 +0100 Subject: [PATCH 3/7] Keep a chart's tooltip to the subplot under the pointer The ruler is linked across subplots, so ECharts also reports the points of the other subplots at the ruler's time. The formatter picked the nearest of all of them, so hovering empty space in one subplot could show another subplot's point. It now ignores every point outside the subplot the pointer is in. Signed-off-by: Ahmad-Wahid --- documentation/changelog.rst | 2 +- flexmeasures/ui/static/js/fast-chart.js | 20 +++++++ flexmeasures/ui/tests/js/test_fast_chart.py | 61 +++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 24445711a7..1a1146a193 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -209,7 +209,7 @@ Bugfixes **UI** -* Hovering a session in the Charge Points chart shows its tooltip again, as it did before the move to ECharts [see `PR #2829 `_] +* Hovering a session in the Charge Points chart shows its tooltip again, as it did before the move to ECharts, and a chart's tooltip no longer shows a point from a neighbouring subplot [see `PR #2829 `_] * Remember the selected graph date range when navigating between sensors and assets, including when using fast charts or cached data [see `PR #2774 `_] * Keep the current-time bar visible during sensor chart playback when no data is visible yet [see `PR #2722 `_] * The sensor upload form now reports whether a queued upload succeeded, and a refused replacement of existing values is explained rather than shown as a database error [see `PR #2722 `_] diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index b2ad61f8ff..394a361d40 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -1189,6 +1189,22 @@ function syncEmphasis(instance, seriesIndex, dataIndex) { }); } +// Whether a point belongs to the subplot the pointer is in. +// The ruler is linked across subplots, so ECharts also reports the points of the other subplots at the ruler's time, +// and a tooltip must describe the subplot being hovered, not a neighbour. +// With no known pointer (or nothing to say which subplot a series is in), the point is kept. +function paramInPointerSubplot(param, instance) { + const pointer = instance && instance._pointerPixel; + const chart = instance && instance.chart; + const lastOption = (instance && instance.lastOption) || {}; + const xAxisIndex = ((lastOption.series || [])[param.seriesIndex] || {}).xAxisIndex; + const gridIndex = ((lastOption.xAxis || [])[xAxisIndex] || {}).gridIndex; + if (!pointer || !chart || gridIndex == null) { + return true; + } + return chart.containPixel({ gridIndex: gridIndex }, pointer); +} + function seriesTooltipFormatter(seriesMeta, instance) { const tooltipOptions = () => ({ showSensor: !(instance && instance.isSensorPage), @@ -1199,6 +1215,7 @@ function seriesTooltipFormatter(seriesMeta, instance) { // trigger passes a single point. In both cases we show just the nearest one, // as in the Vega-Lite charts (which snap to the nearest data point on hover). if (Array.isArray(params)) { + params = params.filter((p) => paramInPointerSubplot(p, instance)); if (params.length === 0) { return ""; } @@ -1210,6 +1227,9 @@ function seriesTooltipFormatter(seriesMeta, instance) { if (params.componentType === "legend") { return escapeHtml(params.name); // legend hover: just reveal the full series name } + if (!paramInPointerSubplot(params, instance)) { + return ""; + } const meta = seriesMeta[params.seriesIndex]; return singlePointTooltip(meta, nearestRealPoint(meta, params.value), tooltipOptions()); }; diff --git a/flexmeasures/ui/tests/js/test_fast_chart.py b/flexmeasures/ui/tests/js/test_fast_chart.py index 54f6cacfd4..9d28827707 100644 --- a/flexmeasures/ui/tests/js/test_fast_chart.py +++ b/flexmeasures/ui/tests/js/test_fast_chart.py @@ -279,6 +279,67 @@ def test_hovering_a_session_bar_shows_its_tooltip(assert_js, echarts_available): """) +def test_a_tooltip_describes_only_the_subplot_under_the_pointer( + assert_js, echarts_available +): + """The ruler is linked across subplots, but a tooltip never shows another subplot's point. + + ECharts reports the points of every linked subplot at the ruler's time, + so with the pointer in the sessions subplot the power subplot's point arrives at the formatter too. + """ + assert_js(""" + await new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = "/vendor/echarts.min.js"; + script.onload = resolve; + script.onerror = () => reject(new Error("could not load ECharts")); + document.head.appendChild(script); + }); + const { renderFastChart } = await import("/js/fast-chart.js?subplot-tooltip"); + + const container = document.createElement("div"); + container.id = "subplot-tooltip"; + container.style.cssText = "width: 1000px; height: 700px;"; + document.body.appendChild(container); + const start = Date.parse("2026-09-21T10:00:00Z"); + const hour = 3600000; + const sessionSensor = (name, id) => ({id, name, unit: "s", asset_id: 4, asset_description: "Charger 4"}); + const power = {id: 99, name: "charge points power", unit: "kW", asset_id: 1}; + const rows = [ + ["arrival", 0], ["departure", 5], + ].map(([name, offset], i) => ({sensor: sessionSensor(name, i + 1), event_start: start, event_value: start + offset * hour})); + for (let i = 0; i < 12; i++) { + rows.push({sensor: power, source: {id: 1, name: "s"}, event_start: start + i * hour / 2, event_value: 50 + i, belief_horizon: 0}); + } + const names = {sessionsTitle: "Sessions", companionTitles: ["Power flow by type"], powerFlowTitle: "Power flow by type", powerSensorName: "charge points power"}; + renderFastChart("subplot-tooltip", rows, { + groupSpec: [{title: "Power flow by type", sensorIds: [99]}], datasetName: "sessions", isSensorPage: false, + chartType: "chart_for_chargepoint_sessions", chargePointChart: names, + }); + const chart = echarts.getInstanceByDom(container); + const formatter = chart.getOption().tooltip[0].formatter; + const powerIndex = chart.getOption().series.findIndex((series) => series.xAxisIndex === 1); + check("the power subplot has a series", powerIndex >= 0); + const powerPoint = [{seriesIndex: powerIndex, dataIndex: 3, value: [start + 1.5 * hour, 53], axisValue: start + 1.5 * hour}]; + + const movePointerTo = async (axisIndex, y) => { + const [x] = chart.convertToPixel({xAxisIndex: axisIndex, yAxisIndex: axisIndex}, [start + 1.5 * hour, y]); + const [, py] = chart.convertToPixel({xAxisIndex: axisIndex, yAxisIndex: axisIndex}, [start + 1.5 * hour, y]); + const rect = container.getBoundingClientRect(); + const canvas = container.querySelector("canvas"); + for (const dx of [0, 1]) { + canvas.dispatchEvent(new MouseEvent("mousemove", {clientX: rect.left + x + dx, clientY: rect.top + py, bubbles: true})); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + }; + + await movePointerTo(0, 0); + eq("with the pointer in the sessions subplot, the power point stays out", formatter(powerPoint), ""); + await movePointerTo(1, 53); + check("with the pointer in the power subplot, its point is described", formatter(powerPoint).includes("charge points power"), formatter(powerPoint)); + """) + + def test_annotation_hover_survives_canvas_exit_and_pin(assert_js): """Pinned text stays stable while hovered text occupies a separate row.""" assert_js(""" From 8abdf67d850767adfdfb9d89d6cc9b3d18f08845 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 5 Nov 2026 16:47:10 +0100 Subject: [PATCH 4/7] Show a session's tooltip anywhere over the session, at any zoom Each session now has one invisible hit rectangle spanning its earliest to its latest timestamp, drawn by a custom series in pixel space, instead of a row of symbols spaced in time that left gaps once zoomed in. Its tooltip lists all of the session's timestamps. Zooming also no longer drops the points outside the window on this chart, which made a session vanish when zoomed into its middle. Signed-off-by: Ahmad-Wahid --- documentation/changelog.rst | 2 +- flexmeasures/ui/static/js/fast-chart.js | 104 ++++++++++++-------- flexmeasures/ui/tests/js/test_fast_chart.py | 50 +++++----- 3 files changed, 91 insertions(+), 65 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 1a1146a193..027d4b6da5 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -209,7 +209,7 @@ Bugfixes **UI** -* Hovering a session in the Charge Points chart shows its tooltip again, as it did before the move to ECharts, and a chart's tooltip no longer shows a point from a neighbouring subplot [see `PR #2829 `_] +* Hovering anywhere over a session in the Charge Points chart shows its tooltip again, listing all of the session's timestamps, as it did before the move to ECharts, and zooming into the middle of a long session no longer makes it disappear; also, a chart's tooltip no longer shows a point from a neighbouring subplot [see `PR #2829 `_] * Remember the selected graph date range when navigating between sensors and assets, including when using fast charts or cached data [see `PR #2774 `_] * Keep the current-time bar visible during sensor chart playback when no data is visible yet [see `PR #2722 `_] * The sensor upload form now reports whether a queued upload succeeded, and a refused replacement of existing values is explained rather than shown as a database error [see `PR #2722 `_] diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index 394a361d40..3535e3745f 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -2099,7 +2099,7 @@ function computeAssetLabels(sessions) { // asset's category row), named by asset so ECharts' native legend groups every // layer of the same asset under one clickable, color-coded entry — matching // Vega-Lite's per-asset color legend and its "click legend to filter" behavior. -function buildSessionSegmentSeries(sessions, fromKey, toKey, categoryIndex, lineWidth, lineDash, fromLabel, toLabel) { +function buildSessionSegmentSeries(sessions, fromKey, toKey, categoryIndex, lineWidth, lineDash) { const series = []; for (const s of sessions) { const from = s[fromKey]; @@ -2108,41 +2108,6 @@ function buildSessionSegmentSeries(sessions, fromKey, toKey, categoryIndex, line const catIdx = categoryIndex.get(s.assetId); const color = SENSOR_COLORS[catIdx % SENSOR_COLORS.length]; const data = [[from, catIdx], [to, catIdx]]; - const tooltip = { - formatter: () => - tooltipTable([ - [fromLabel, formatFullDate(from)], - [toLabel, formatFullDate(to)], - ["Asset", s.assetLabel], - ]), - }; - // A line series only answers hover through its symbols (ECharts attaches a data index, which the item tooltip needs, to symbols alone), - // and a session segment has just 2 points, its start and end, often hours apart. - // So draw an invisible companion series of densely interpolated points, each with a large invisible symbol, - // and put it above the visible line: the visible line would otherwise be the element under the cursor, and it carries no data index. - // The hit series shares the visible line's name, so it adds no legend entry and is not toggled independently. - const hitPointCount = 100; - const hitData = []; - for (let i = 0; i <= hitPointCount; i++) { - hitData.push([from + ((to - from) * i) / hitPointCount, catIdx]); - } - series.push({ - name: s.assetLabel, - type: "line", - xAxisIndex: 0, - yAxisIndex: 0, - symbol: "circle", - showSymbol: true, - symbolSize: Math.max(lineWidth, 16), - animation: false, - legendHoverLink: false, - lineStyle: { opacity: 0 }, - itemStyle: { opacity: 0.001 }, - emphasis: { disabled: true }, - data: hitData, - tooltip: tooltip, - z: 3, - }); series.push({ name: s.assetLabel, type: "line", @@ -2153,15 +2118,67 @@ function buildSessionSegmentSeries(sessions, fromKey, toKey, categoryIndex, line animation: false, color: color, lineStyle: { width: lineWidth, type: lineDash || "solid" }, - emphasis: { focus: "series", lineStyle: { width: lineWidth + 2 } }, data: data, - tooltip: tooltip, + silent: true, // hover is handled by the session's own hit area (see buildSessionHitSeries) z: 2, }); } return series; } +// The timestamps a session tooltip lists, in the order a session runs through them. +const SESSION_TOOLTIP_ROWS = [ + ["arrival", "Arrival"], ["plug in", "Plug-in"], ["start charging", "Start charging"], + ["stop charging", "Stop charging"], ["plug out", "Plug-out"], ["departure", "Departure"], +]; + +// One invisible hit area per session, spanning the whole session (its earliest to its latest timestamp), +// so hovering anywhere along it, including between its layers, shows one tooltip with all of its timestamps. +// A custom series draws a rectangle in pixel space, so the area stays hoverable at every zoom level +// (a line's symbols are spaced in time, and leave gaps once zoomed in), and its items carry the data index the item tooltip needs. +// It is named after the asset, like the visible lines, so it adds no legend entry and follows the legend's toggling. +function buildSessionHitSeries(sessions, categoryIndex) { + const series = []; + for (const s of sessions) { + const times = SESSION_TOOLTIP_ROWS.map(([key]) => s[key]).filter((t) => typeof t === "number"); + if (times.length === 0) continue; + const from = Math.min(...times); + const to = Math.max(...times); + const catIdx = categoryIndex.get(s.assetId); + const rows = SESSION_TOOLTIP_ROWS.filter(([key]) => typeof s[key] === "number").map(([key, label]) => [label, formatFullDate(s[key])]); + rows.push(["Asset", s.assetLabel]); + series.push({ + name: s.assetLabel, + type: "custom", + xAxisIndex: 0, + yAxisIndex: 0, + color: SENSOR_COLORS[catIdx % SENSOR_COLORS.length], + animation: false, + legendHoverLink: false, + clip: true, + emphasis: { disabled: true }, + encode: { x: [0, 1], y: 2 }, + data: [[from, to, catIdx]], + tooltip: { formatter: () => tooltipTable(rows) }, + renderItem: (params, api) => { + const start = api.coord([api.value(0), api.value(2)]); + const end = api.coord([api.value(1), api.value(2)]); + const half = SESSION_HIT_HEIGHT / 2; + const shape = echarts.graphic.clipRectByRect( + { x: start[0], y: start[1] - half, width: Math.max(end[0] - start[0], 1), height: SESSION_HIT_HEIGHT }, + { x: params.coordSys.x, y: params.coordSys.y, width: params.coordSys.width, height: params.coordSys.height } + ); + return shape ? { type: "rect", shape: shape, style: { fill: "rgba(0,0,0,0.001)" } } : null; + }, + z: 3, + }); + } + return series; +} + +// Height in pixels of a session's hover area, much taller than its thinnest line so that the lines are easy to hover. +const SESSION_HIT_HEIGHT = 16; + // The graph titles and sensor name that the Charge Points chart goes by come in as opts.chargePointChart, // from the same constants the Python chart builder uses (CHARGE_POINT_CHART_NAMES in belief_charts.py): // - sessionsTitle: the dashboard group holding the raw session-marker sensors (arrival, departure, plug in/out, start/stop charging). @@ -2210,9 +2227,10 @@ export function buildChargePointSessionsOption(elementId, data, opts) { } const sessionSeries = [ - ...buildSessionSegmentSeries(sessions, "arrival", "departure", categoryIndex, 1.5, "dashed", "Arrival", "Departure"), - ...buildSessionSegmentSeries(sessions, "plug in", "plug out", categoryIndex, 2.5, "solid", "Plug-in", "Plug-out"), - ...buildSessionSegmentSeries(sessions, "start charging", "stop charging", categoryIndex, 6, "solid", "Start charging", "Stop charging"), + ...buildSessionSegmentSeries(sessions, "arrival", "departure", categoryIndex, 1.5, "dashed"), + ...buildSessionSegmentSeries(sessions, "plug in", "plug out", categoryIndex, 2.5, "solid"), + ...buildSessionSegmentSeries(sessions, "start charging", "stop charging", categoryIndex, 6, "solid"), + ...buildSessionHitSeries(sessions, categoryIndex), ]; // Same two companion subplots Vega-Lite stacks below the sessions chart, built @@ -2374,6 +2392,9 @@ export function buildChargePointSessionsOption(elementId, data, opts) { const allAxisIndices = xAxes.map((_, i) => i); const toolbox = toolboxFeatures(elementId, opts.datasetName, opts.isSensorPage, true); if (toolbox.feature.dataZoom) toolbox.feature.dataZoom.xAxisIndex = allAxisIndices; // absent on touch + // By default zooming drops every point outside the window, and a session line is only its two end points, + // so zooming into the middle of a session would make the whole session vanish. Keep the points, and let the grid clip them. + if (toolbox.feature.dataZoom) toolbox.feature.dataZoom.filterMode = "none"; toolbox.top = TOOLBOX_TOP; // drop the buttons below the subplot title return { @@ -2400,6 +2421,7 @@ export function buildChargePointSessionsOption(elementId, data, opts) { { type: "inside", xAxisIndex: allAxisIndices, + filterMode: "none", // see the toolbox zoom above throttle: 80, zoomOnMouseWheel: true, // desktop wheel zoom; touch pinch zoom is always on moveOnMouseWheel: false, diff --git a/flexmeasures/ui/tests/js/test_fast_chart.py b/flexmeasures/ui/tests/js/test_fast_chart.py index 9d28827707..80d38589c1 100644 --- a/flexmeasures/ui/tests/js/test_fast_chart.py +++ b/flexmeasures/ui/tests/js/test_fast_chart.py @@ -209,20 +209,21 @@ def test_charge_point_sessions_keep_their_own_tooltip(assert_js): ], {groupSpec: [], datasetName: "sessions", isSensorPage: false}); check("the session chart renders", option.series.length >= 2); - const visibleSession = option.series.find((series) => series.tooltip && series.tooltip.formatter); - const tooltip = visibleSession.tooltip.formatter(); + const session = option.series.find((series) => series.tooltip && series.tooltip.formatter); + const tooltip = session.tooltip.formatter(); check("session start stays visible", tooltip.includes("Arrival")); check("session end stays visible", tooltip.includes("Departure")); check("session asset stays visible", tooltip.includes("Charger 4")); """) -def test_hovering_a_session_bar_shows_its_tooltip(assert_js, echarts_available): - """Hovering anywhere along a session bar, on each of its layers, shows that segment's tooltip. +def test_hovering_anywhere_over_a_session_shows_its_tooltip( + assert_js, echarts_available +): + """Hovering anywhere over a session, on any of its layers or just beside them, shows one tooltip for the whole session. - A line series answers hover only through its symbols, - so a bar drawn as a bare 2-point line shows no tooltip at all; - this drives the real ECharts library with real mouse events to catch that. + A line series answers hover only through its symbols, and those are spaced in time, + so this drives the real ECharts library with real mouse events, including after zooming in. """ assert_js(""" await new Promise((resolve, reject) => { @@ -250,32 +251,35 @@ def test_hovering_a_session_bar_shows_its_tooltip(assert_js, echarts_available): }); const chart = echarts.getInstanceByDom(container); - const hoverAt = async (hours) => { + const hoverAt = async (hours, pixelsBelow) => { const [x, y] = chart.convertToPixel({xAxisIndex: 0, yAxisIndex: 0}, [start + hours * hour, 0]); const rect = container.getBoundingClientRect(); - const init = {clientX: rect.left + x, clientY: rect.top + y, bubbles: true}; + const init = {clientX: rect.left + x, clientY: rect.top + y + pixelsBelow, bubbles: true}; const target = container.querySelector("canvas"); target.dispatchEvent(new MouseEvent("mousemove", init)); target.dispatchEvent(new MouseEvent("mousemove", {...init, clientX: init.clientX + 1})); await new Promise((resolve) => setTimeout(resolve, 300)); const shown = [...container.querySelectorAll("div")].filter((el) => el.style.display === "block"); - return shown.map((el) => el.textContent).join(" "); - }; - const clear = async () => { + const text = shown.map((el) => el.textContent).join(" "); chart.dispatchAction({type: "hideTip"}); - await new Promise((resolve) => setTimeout(resolve, 300)); + await new Promise((resolve) => setTimeout(resolve, 200)); + return text; }; + const lists = (label, text) => { + for (const name of ["Arrival", "Plug-in", "Start charging", "Stop charging", "Plug-out", "Departure", "Charger 4"]) { + check(label + ": lists " + name, text.includes(name), text); + } + }; + + lists("on the charging bar", await hoverAt(2.5, 0)); + lists("on the arrival bar, outside the charging bar", await hoverAt(4.8, 0)); + lists("beside the bars", await hoverAt(2.5, 6)); + check("well off the session, no tooltip", (await hoverAt(2.5, 60)) === ""); - // Inside the thick start/stop charging bar, only reachable through it. - const charging = await hoverAt(2.5); - check("the charging bar names its start", charging.includes("Start charging"), charging); - check("the charging bar names its end", charging.includes("Stop charging"), charging); - await clear(); - // Outside the charging bar and the plug-in bar, on the thin dashed arrival/departure bar only. - const arrival = await hoverAt(4.8); - check("the arrival bar names its start", arrival.includes("Arrival"), arrival); - check("the arrival bar names its end", arrival.includes("Departure"), arrival); - check("the tooltip names the asset", arrival.includes("Charger 4"), arrival); + // Zoomed in, a long session spans many times the chart's width. + chart.dispatchAction({type: "dataZoom", dataZoomIndex: 0, startValue: start + 2.4 * hour, endValue: start + 2.6 * hour}); + await new Promise((resolve) => setTimeout(resolve, 300)); + lists("zoomed in", await hoverAt(2.5, 0)); """) From 844a9111dd4b563f18e55ba0849c9b31decb9968 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 6 Nov 2026 20:56:17 +0100 Subject: [PATCH 5/7] Move the changelog entry to v1.2.0, as v1.1.0 is released Signed-off-by: Ahmad-Wahid --- documentation/changelog.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index f4f6be7b3f..0447045b6b 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -16,6 +16,8 @@ Infrastructure / Support Bugfixes ----------- +* Hovering anywhere over a session in the Charge Points chart shows its tooltip again, listing all of the session's timestamps, as it did before the move to ECharts, and a chart's tooltip no longer shows a point from a neighbouring subplot [see `PR #2829 `_] + v1.1.0 | November 05, 2026 ============================ @@ -263,7 +265,6 @@ Bugfixes **UI** -* Hovering anywhere over a session in the Charge Points chart shows its tooltip again, listing all of the session's timestamps, as it did before the move to ECharts, and zooming into the middle of a long session no longer makes it disappear; also, a chart's tooltip no longer shows a point from a neighbouring subplot [see `PR #2829 `_] * Remember the selected graph date range when navigating between sensors and assets, including when using fast charts or cached data [see `PR #2774 `_] * Keep the current-time bar visible during sensor chart playback when no data is visible yet [see `PR #2722 `_] * The sensor upload form now reports whether a queued upload succeeded, and a refused replacement of existing values is explained rather than shown as a database error [see `PR #2722 `_] From ac707b6871fa9dbfc78af80ada05506f278c2e2e Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 6 Nov 2026 20:56:19 +0100 Subject: [PATCH 6/7] Filter zooming by weakFilter, so companion subplots rescale and sessions survive The 'none' filter mode kept every point on zoom, which stopped the companion subplots' y-axes rescaling to the visible data. weakFilter keeps that rescaling, and keeps a session segment whose two ends are both out of view because each of its points now carries both ends of the segment as x-dimensions. The session lines are native line series again, so the legend's hover highlight and dimming work as they did. Also: define the hit height before its use, and tidy the new tests (one convertToPixel call, a shared ECharts loader). Signed-off-by: Ahmad-Wahid --- flexmeasures/ui/static/js/fast-chart.js | 25 +++--- flexmeasures/ui/tests/js/test_fast_chart.py | 89 ++++++++++++++++----- 2 files changed, 86 insertions(+), 28 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index 3535e3745f..cf4ada78d9 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -2099,6 +2099,10 @@ function computeAssetLabels(sessions) { // asset's category row), named by asset so ECharts' native legend groups every // layer of the same asset under one clickable, color-coded entry — matching // Vega-Lite's per-asset color legend and its "click legend to filter" behavior. +// Every point carries both ends of its segment as x-dimensions (encode: x [0, 2]), +// and the zoom's weakFilter drops a point only when all of its x-dimensions are out of the window on the same side, +// so zooming into the middle of a long segment keeps it, where plain points would both be dropped and the segment vanish. +// The segments are silent: hover is handled by the session's own hit area (see buildSessionHitSeries). function buildSessionSegmentSeries(sessions, fromKey, toKey, categoryIndex, lineWidth, lineDash) { const series = []; for (const s of sessions) { @@ -2107,7 +2111,6 @@ function buildSessionSegmentSeries(sessions, fromKey, toKey, categoryIndex, line if (typeof from !== "number" || typeof to !== "number") continue; const catIdx = categoryIndex.get(s.assetId); const color = SENSOR_COLORS[catIdx % SENSOR_COLORS.length]; - const data = [[from, catIdx], [to, catIdx]]; series.push({ name: s.assetLabel, type: "line", @@ -2118,14 +2121,19 @@ function buildSessionSegmentSeries(sessions, fromKey, toKey, categoryIndex, line animation: false, color: color, lineStyle: { width: lineWidth, type: lineDash || "solid" }, - data: data, - silent: true, // hover is handled by the session's own hit area (see buildSessionHitSeries) + emphasis: { focus: "series", lineStyle: { width: lineWidth + 2 } }, + encode: { x: [0, 2], y: 1 }, + data: [[from, catIdx, to], [to, catIdx, from]], + silent: true, z: 2, }); } return series; } +// Height in pixels of a session's hover area, much taller than its thinnest line so that the lines are easy to hover. +const SESSION_HIT_HEIGHT = 16; + // The timestamps a session tooltip lists, in the order a session runs through them. const SESSION_TOOLTIP_ROWS = [ ["arrival", "Arrival"], ["plug in", "Plug-in"], ["start charging", "Start charging"], @@ -2176,9 +2184,6 @@ function buildSessionHitSeries(sessions, categoryIndex) { return series; } -// Height in pixels of a session's hover area, much taller than its thinnest line so that the lines are easy to hover. -const SESSION_HIT_HEIGHT = 16; - // The graph titles and sensor name that the Charge Points chart goes by come in as opts.chargePointChart, // from the same constants the Python chart builder uses (CHARGE_POINT_CHART_NAMES in belief_charts.py): // - sessionsTitle: the dashboard group holding the raw session-marker sensors (arrival, departure, plug in/out, start/stop charging). @@ -2392,9 +2397,9 @@ export function buildChargePointSessionsOption(elementId, data, opts) { const allAxisIndices = xAxes.map((_, i) => i); const toolbox = toolboxFeatures(elementId, opts.datasetName, opts.isSensorPage, true); if (toolbox.feature.dataZoom) toolbox.feature.dataZoom.xAxisIndex = allAxisIndices; // absent on touch - // By default zooming drops every point outside the window, and a session line is only its two end points, - // so zooming into the middle of a session would make the whole session vanish. Keep the points, and let the grid clip them. - if (toolbox.feature.dataZoom) toolbox.feature.dataZoom.filterMode = "none"; + // A session is one item spanning its start to its end, which the default filter would drop once both ends are out of the window. + // weakFilter keeps an item that overlaps the window, and still rescales the companion subplots' y-axes to the visible data. + if (toolbox.feature.dataZoom) toolbox.feature.dataZoom.filterMode = "weakFilter"; toolbox.top = TOOLBOX_TOP; // drop the buttons below the subplot title return { @@ -2421,7 +2426,7 @@ export function buildChargePointSessionsOption(elementId, data, opts) { { type: "inside", xAxisIndex: allAxisIndices, - filterMode: "none", // see the toolbox zoom above + filterMode: "weakFilter", // see the toolbox zoom above throttle: 80, zoomOnMouseWheel: true, // desktop wheel zoom; touch pinch zoom is always on moveOnMouseWheel: false, diff --git a/flexmeasures/ui/tests/js/test_fast_chart.py b/flexmeasures/ui/tests/js/test_fast_chart.py index 80d38589c1..f662a3055a 100644 --- a/flexmeasures/ui/tests/js/test_fast_chart.py +++ b/flexmeasures/ui/tests/js/test_fast_chart.py @@ -217,6 +217,18 @@ def test_charge_point_sessions_keep_their_own_tooltip(assert_js): """) +# Loads the vendored ECharts library into a JS test page, as the app does before fast-chart.js renders. +LOAD_ECHARTS = """ + await new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = "/vendor/echarts.min.js"; + script.onload = resolve; + script.onerror = () => reject(new Error("could not load ECharts")); + document.head.appendChild(script); + }); +""" + + def test_hovering_anywhere_over_a_session_shows_its_tooltip( assert_js, echarts_available ): @@ -225,14 +237,7 @@ def test_hovering_anywhere_over_a_session_shows_its_tooltip( A line series answers hover only through its symbols, and those are spaced in time, so this drives the real ECharts library with real mouse events, including after zooming in. """ - assert_js(""" - await new Promise((resolve, reject) => { - const script = document.createElement("script"); - script.src = "/vendor/echarts.min.js"; - script.onload = resolve; - script.onerror = () => reject(new Error("could not load ECharts")); - document.head.appendChild(script); - }); + assert_js(LOAD_ECHARTS + """ const { renderFastChart } = await import("/js/fast-chart.js?sessions-hover"); const container = document.createElement("div"); @@ -283,6 +288,62 @@ def test_hovering_anywhere_over_a_session_shows_its_tooltip( """) +def test_zooming_keeps_sessions_and_rescales_the_companion_subplots( + assert_js, echarts_available +): + """Zooming into the middle of a session keeps it, and zooming still rescales the companion subplots' y-axes. + + Dropping nothing on zoom keeps the sessions but leaves a companion subplot's axis fitted to data that is no longer shown, + so its line lies flat along the bottom; the zoom has to filter, while still keeping a segment whose two ends are both out of view. + """ + assert_js(LOAD_ECHARTS + """ + const { renderFastChart } = await import("/js/fast-chart.js?sessions-zoom"); + + const container = document.createElement("div"); + container.id = "sessions-zoom"; + container.style.cssText = "width: 1000px; height: 700px;"; + document.body.appendChild(container); + const start = Date.parse("2026-09-21T10:00:00Z"); + const hour = 3600000; + const sessionSensor = (name, id) => ({id, name, unit: "s", asset_id: 4, asset_description: "Charger 4"}); + const power = {id: 99, name: "charge points power", unit: "kW", asset_id: 1}; + const rows = [ + ["arrival", 0], ["departure", 10], + ].map(([name, offset], i) => ({sensor: sessionSensor(name, i + 1), event_start: start, event_value: start + offset * hour})); + // 1000 kW in the first half of the day, about 30 kW in the second half. + for (let i = 0; i < 24; i++) { + rows.push({sensor: power, source: {id: 1, name: "s"}, event_start: start + i * hour, event_value: i < 12 ? 1000 : 30 + (i % 5), belief_horizon: 0}); + } + const names = {sessionsTitle: "Sessions", companionTitles: ["Power flow by type"], powerFlowTitle: "Power flow by type", powerSensorName: "charge points power"}; + renderFastChart("sessions-zoom", rows, { + groupSpec: [{title: "Power flow by type", sensorIds: [99]}], datasetName: "sessions", isSensorPage: false, + chartType: "chart_for_chargepoint_sessions", chargePointChart: names, + }); + const chart = echarts.getInstanceByDom(container); + const zoomTo = async (fromHour, toHour) => { + chart.dispatchAction({type: "dataZoom", dataZoomIndex: 0, startValue: start + fromHour * hour, endValue: start + toHour * hour}); + await new Promise((resolve) => setTimeout(resolve, 300)); + }; + const sessionPoints = () => { + const counts = []; + chart.getModel().eachSeries((series) => { + if (series.subType === "line" && series.get("name") === "Charger 4") counts.push(series.getData().count()); + }); + return counts; + }; + const powerAxisMax = () => chart.getModel().getComponent("yAxis", 1).axis.scale.getExtent()[1]; + + await zoomTo(4, 5); + check("zoomed into the middle of the session, it is still drawn", sessionPoints().length > 0 && sessionPoints().every((n) => n === 2), JSON.stringify(sessionPoints())); + await zoomTo(14, 22); + check("zoomed into the low second half, the power axis rescales to it", powerAxisMax() < 100, "axis max " + powerAxisMax()); + + // Highlighting an asset from the legend thickens its sessions and dims the others. + const visibleLine = chart.getOption().series.find((series) => series.type === "line" && series.name === "Charger 4"); + eq("a session line takes part in highlighting", visibleLine.emphasis.focus, "series"); + """) + + def test_a_tooltip_describes_only_the_subplot_under_the_pointer( assert_js, echarts_available ): @@ -291,14 +352,7 @@ def test_a_tooltip_describes_only_the_subplot_under_the_pointer( ECharts reports the points of every linked subplot at the ruler's time, so with the pointer in the sessions subplot the power subplot's point arrives at the formatter too. """ - assert_js(""" - await new Promise((resolve, reject) => { - const script = document.createElement("script"); - script.src = "/vendor/echarts.min.js"; - script.onload = resolve; - script.onerror = () => reject(new Error("could not load ECharts")); - document.head.appendChild(script); - }); + assert_js(LOAD_ECHARTS + """ const { renderFastChart } = await import("/js/fast-chart.js?subplot-tooltip"); const container = document.createElement("div"); @@ -327,8 +381,7 @@ def test_a_tooltip_describes_only_the_subplot_under_the_pointer( const powerPoint = [{seriesIndex: powerIndex, dataIndex: 3, value: [start + 1.5 * hour, 53], axisValue: start + 1.5 * hour}]; const movePointerTo = async (axisIndex, y) => { - const [x] = chart.convertToPixel({xAxisIndex: axisIndex, yAxisIndex: axisIndex}, [start + 1.5 * hour, y]); - const [, py] = chart.convertToPixel({xAxisIndex: axisIndex, yAxisIndex: axisIndex}, [start + 1.5 * hour, y]); + const [x, py] = chart.convertToPixel({xAxisIndex: axisIndex, yAxisIndex: axisIndex}, [start + 1.5 * hour, y]); const rect = container.getBoundingClientRect(); const canvas = container.querySelector("canvas"); for (const dx of [0, 1]) { From 03cdb916da951090e55d99edca9b123997220c85 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sun, 8 Nov 2026 15:20:29 +0100 Subject: [PATCH 7/7] Keep a session hover from highlighting another charger's session The ruler highlights the point nearest in time on every linked subplot, and in the sessions subplot that is often a point of another charger's session, which then thickened while the hovered session stayed as it was. The sessions axis no longer lets its ruler highlight anything; hover over a session is answered by the session's own hit area. The ruler on the companion subplots still highlights their points. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DWv6v4u9asHS25hW25jdvV Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 2 +- flexmeasures/ui/static/js/fast-chart.js | 4 +- flexmeasures/ui/tests/js/test_fast_chart.py | 56 +++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 0447045b6b..aefb8cf83d 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -16,7 +16,7 @@ Infrastructure / Support Bugfixes ----------- -* Hovering anywhere over a session in the Charge Points chart shows its tooltip again, listing all of the session's timestamps, as it did before the move to ECharts, and a chart's tooltip no longer shows a point from a neighbouring subplot [see `PR #2829 `_] +* Hovering anywhere over a session in the Charge Points chart shows its tooltip again, listing all of the session's timestamps, as it did before the move to ECharts, without highlighting another charger's session; also, a chart's tooltip no longer shows a point from a neighbouring subplot [see `PR #2829 `_] v1.1.0 | November 05, 2026 diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index cf4ada78d9..0e5ea58b12 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -2306,7 +2306,9 @@ export function buildChargePointSessionsOption(elementId, data, opts) { type: "time", // nice-aligned clock-time ticks (see line chart); minInterval biases granularity gridIndex: 0, axisLine: { onZero: false }, - axisPointer: { show: true }, + // The ruler would highlight the session point nearest in time, which is often another charger's session. + // Hover over a session is answered by its own hit area instead (see buildSessionHitSeries). + axisPointer: { show: true, triggerEmphasis: false }, splitLine: { show: true, lineStyle: { opacity: 0.5 } }, axisLabel: { fontSize: FONT_SIZE, color: "#222", formatter: xAxisTimeFormatter, hideOverlap: true }, }, sharedTimeDomain)]; diff --git a/flexmeasures/ui/tests/js/test_fast_chart.py b/flexmeasures/ui/tests/js/test_fast_chart.py index f662a3055a..53a7f618c5 100644 --- a/flexmeasures/ui/tests/js/test_fast_chart.py +++ b/flexmeasures/ui/tests/js/test_fast_chart.py @@ -344,6 +344,62 @@ def test_zooming_keeps_sessions_and_rescales_the_companion_subplots( """) +def test_hovering_a_session_highlights_no_other_charger(assert_js, echarts_available): + """Hovering a session leaves the sessions of other chargers alone, while the ruler still highlights the power point below it. + + The ruler highlights the point nearest in time on every linked subplot, + and in the sessions subplot that is often a point of another charger's session. + """ + assert_js(LOAD_ECHARTS + """ + const { renderFastChart } = await import("/js/fast-chart.js?sessions-emphasis"); + + const container = document.createElement("div"); + container.id = "sessions-emphasis"; + container.style.cssText = "width: 1000px; height: 700px;"; + document.body.appendChild(container); + const start = Date.parse("2026-09-21T10:00:00Z"); + const hour = 3600000; + const rows = []; + let id = 1; + // Charger 5's session starts an hour later, so its points are the nearest in time while hovering Charger 4's. + for (const [asset, description, delay] of [[4, "Charger 4", 0], [5, "Charger 5", 1]]) { + for (const [name, offset] of [["arrival", 0], ["departure", 5], ["plug in", 0.5], ["plug out", 4.5], ["start charging", 1], ["stop charging", 4]]) { + const sensor = {id: id++, name, unit: "s", asset_id: asset, asset_description: description}; + rows.push({sensor, event_start: start, event_value: start + (offset + delay) * hour}); + } + } + const power = {id: 99, name: "charge points power", unit: "kW", asset_id: 1}; + for (let i = 0; i < 24; i++) { + rows.push({sensor: power, source: {id: 1, name: "s"}, event_start: start + i * hour / 2, event_value: 50 + i, belief_horizon: 0}); + } + const names = {sessionsTitle: "Sessions", companionTitles: ["Power flow by type"], powerFlowTitle: "Power flow by type", powerSensorName: "charge points power"}; + renderFastChart("sessions-emphasis", rows, { + groupSpec: [{title: "Power flow by type", sensorIds: [99]}], datasetName: "sessions", isSensorPage: false, + chartType: "chart_for_chargepoint_sessions", chargePointChart: names, + }); + const chart = echarts.getInstanceByDom(container); + const series = chart.getOption().series; + const highlighted = new Set(); + chart.on("highlight", (event) => { + for (const item of event.batch || [event]) { + if (item.seriesIndex != null) highlighted.add(series[item.seriesIndex].name); + } + }); + + const [x, y] = chart.convertToPixel({xAxisIndex: 0, yAxisIndex: 0}, [start + 2.5 * hour, 0]); + const rect = container.getBoundingClientRect(); + const canvas = container.querySelector("canvas"); + for (const dx of [0, 1]) { + canvas.dispatchEvent(new MouseEvent("mousemove", {clientX: rect.left + x + dx, clientY: rect.top + y, bubbles: true})); + } + await new Promise((resolve) => setTimeout(resolve, 400)); + + const highlightedNames = [...highlighted].join(", "); + check("hovering Charger 4's session leaves Charger 5's alone", !highlighted.has("Charger 5"), highlightedNames); + check("the ruler still highlights the power point below", highlighted.has("charge points power"), highlightedNames); + """) + + def test_a_tooltip_describes_only_the_subplot_under_the_pointer( assert_js, echarts_available ):