diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 2880c99c44..ee807d6ec5 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -18,6 +18,8 @@ Bugfixes ----------- * Apply storage losses to the starting stock of a state of charge shared by several devices, so its scheduled state of charge no longer drifts above the one the scheduler planned with [see `PR #2838 `_] +* 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 5, 2026 ============================ diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index ac67517625..0e5ea58b12 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()); }; @@ -2079,7 +2099,11 @@ 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) { +// 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) { const from = s[fromKey]; @@ -2087,36 +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]]; - const tooltip = { - formatter: () => - tooltipTable([ - [fromLabel, formatFullDate(from)], - [toLabel, formatFullDate(to)], - ["Asset", s.assetLabel], - ]), - }; - // Regular time-series lines (e.g. the companion subplots) have a data point - // every few pixels, so ECharts' "nearest point" hover snap always finds one - // close to the cursor anywhere along the line. A session segment only has 2 - // points — its start and end, often hours apart — so that snap logic finds - // nothing nearby over most of a long bar. Densely interpolate the invisible - // hit-area line (kept off the visible line) so hovering anywhere along the - // bar's length has a point within range, not just right at its endpoints. - const hitPointCount = 40; - const hitData = []; - for (let i = 0; i <= hitPointCount; i++) { - hitData.push([from + ((to - from) * i) / hitPointCount, catIdx]); - } - // Invisible companion line, much wider than the visible stroke, purely to - // widen the hoverable hit area: thin session bars (down to 1.5px) are - // otherwise very hard to hover precisely. Shares the visible line's name - // so it doesn't add its own legend entry or get toggled independently. - // Its OWN tooltip is disabled (tooltip.show: false): ECharts' tooltip box - // renders at zero size for a near-transparent series even though hover/hit - // -test work fine on it, so hovering it instead redirects (via the - // __hitAreaFor marker + the mouseover listener in wireSessionTooltipRedirect) - // to dispatch the visible sibling series' tooltip. series.push({ name: s.assetLabel, type: "line", @@ -2125,27 +2119,66 @@ function buildSessionSegmentSeries(sessions, fromKey, toKey, categoryIndex, line symbol: "none", showSymbol: false, animation: false, - legendHoverLink: false, - lineStyle: { width: Math.max(lineWidth, 16), opacity: 0.001 }, - data: hitData, - tooltip: { show: false }, - __hitAreaFor: true, - z: 1, + color: color, + lineStyle: { width: lineWidth, type: lineDash || "solid" }, + 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"], + ["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: "line", + type: "custom", xAxisIndex: 0, yAxisIndex: 0, - symbol: "none", - showSymbol: false, + color: SENSOR_COLORS[catIdx % SENSOR_COLORS.length], animation: false, - color: color, - lineStyle: { width: lineWidth, type: lineDash || "solid" }, - emphasis: { focus: "series", lineStyle: { width: lineWidth + 2 } }, - data: data, - tooltip: tooltip, - z: 2, + 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; @@ -2199,9 +2232,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 @@ -2272,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)]; @@ -2363,6 +2399,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 + // 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 { @@ -2389,6 +2428,7 @@ export function buildChargePointSessionsOption(elementId, data, opts) { { type: "inside", xAxisIndex: allAxisIndices, + filterMode: "weakFilter", // see the toolbox zoom above throttle: 80, zoomOnMouseWheel: true, // desktop wheel zoom; touch pinch zoom is always on moveOnMouseWheel: false, @@ -2484,7 +2524,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 +2900,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..53a7f618c5 100644 --- a/flexmeasures/ui/tests/js/test_fast_chart.py +++ b/flexmeasures/ui/tests/js/test_fast_chart.py @@ -209,14 +209,250 @@ 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")); """) +# 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 +): + """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, and those are spaced in time, + so this drives the real ECharts library with real mouse events, including after zooming in. + """ + assert_js(LOAD_ECHARTS + """ + 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, 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 + 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"); + const text = shown.map((el) => el.textContent).join(" "); + chart.dispatchAction({type: "hideTip"}); + 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)) === ""); + + // 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)); + """) + + +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_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 +): + """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(LOAD_ECHARTS + """ + 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, 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("""