From 2e494c384e92bd6023635d8d181a70a2be5b98f2 Mon Sep 17 00:00:00 2001 From: Jeroen Van Der Donckt Date: Mon, 28 Sep 2026 00:27:42 +0200 Subject: [PATCH 1/3] fix(adapters): aggregate linked members at the range a lock pins --- Architecture.md | 2 +- docs/guides/cross-filtering.md | 3 +- flexviz/adapters/js/toolbar.js | 31 ++++++++++--- tests/test_browser.py | 79 ++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 8 deletions(-) diff --git a/Architecture.md b/Architecture.md index d11b8f2..5891421 100644 --- a/Architecture.md +++ b/Architecture.md @@ -876,7 +876,7 @@ FlexEngine Because the engine keys its recompute on event type, `fvOnResetPanel` derives the emitted type from the resulting state: `selection` when filters remain, `deselect` when none remain, `viewport` when only the viewport changed. Every variant names the cleared keys in `viewport_keys`, so a viewport-only reset re-aggregates only this figure. The global toolbar **Reset** (`fvOnReset`) instead clears all unlocked viewports + selections and emits `init`; resets and double-click autorange never clear a locked axis's viewport key, because the lock pins the displayed range and the engine aggregates at the state range; the global **Deselect** clears selections only and **keeps zoom** (emitting `deselect`). -**Linked axes (`ClientState.axis_links`):** each group lists viewport keys that always hold one range. The client keeps them equal with one writer, `fvWriteViewport` (`runtime/state.js`): a zoom, pan, double-click autorange or panel reset writes (or deletes) every key of the group. `fvCommitViewportChange` then redraws the other moved figures from state (the relayout handler ignores what `Plotly.react` emits, so the redraws post nothing) and sends one viewport event naming every written key, only when a changed axis binds a trace somewhere. The engine needs no link knowledge: the event's `viewport_keys` pick the figures, and the partition rule keeps each figure unfiltered by its own selection. A lock toggle reaches every member and pins all of them at the range the clicked figure shows (`fvLockAxisGroup`; Lock All skips an axis a group member already locked), so a locked member pins the group. The `DashboardSpec` validator rejects a link the client cannot keep: fewer than two keys, a key in two groups, two axes of one figure, an axis other than x/y, an axis without a data column (count axes, bars, maps), an axis type other than unset, `-`, `linear` and `date` (a `category` range is in positions, and a `log` axis cannot show a range at or below 0, which a linear member can zoom to), mixed reversed and normal axes (any `reversed` autorange variant or a descending fixed range), unequal viewport values (`None` = absent), mixed locks or unequal lock ranges. Every entry point (builder, `/share`, `/view`, import, `flexvizApply`, each request) runs it. The column types need the source schema, so `check_axis_link_types` runs in the builder and, with the registered sources, in `/share`, `/view` (400) and `/dashboard/update` (422): only numeric, `Date` and `Datetime` axes link (`Time` and `Duration` render as category axes, issue #79), and a numeric column on a `date` axis is refused. All axes of a group then have one kind: numeric, or temporal with one time zone and one effective Plotly axis type (`date` reports strings, `linear` numbers). Limits: after a double-click autorange each member autoranges on its own data (in update mode a figure filtered by a selection fits its filtered rows); ECharts (deprecated) does not write linked keys. +**Linked axes (`ClientState.axis_links`):** each group lists viewport keys that always hold one range. The client keeps them equal with one writer, `fvWriteViewport` (`runtime/state.js`): a zoom, pan, double-click autorange or panel reset writes (or deletes) every key of the group. `fvCommitViewportChange` then redraws the other moved figures from state (the relayout handler ignores what `Plotly.react` emits, so the redraws post nothing) and sends one viewport event naming every written key, only when a changed axis binds a trace somewhere. The engine needs no link knowledge: the event's `viewport_keys` pick the figures, and the partition rule keeps each figure unfiltered by its own selection. A lock toggle reaches every member and pins all of them at the range the clicked figure shows (`fvLockAxisGroup`; Lock All skips an axis a group member already locked), so a locked member pins the group. When a member shows another range, the server aggregated it for that other range, so the lock also writes the group range with `fvWriteViewport`, and the toggle commits it with `fvCommitViewportChange` (Lock All sends one commit for all groups). A group whose members already show one range stays display-only: a viewport at Plotly's padded autorange can change the bins, because a zoomed grid snaps to a lattice. When members differ, the clicked figure gets the group viewport too (the validator requires equal viewport values), so a heatmap there can re-bin, for example from 7 to 8 rows on integer data. The `DashboardSpec` validator rejects a link the client cannot keep: fewer than two keys, a key in two groups, two axes of one figure, an axis other than x/y, an axis without a data column (count axes, bars, maps), an axis type other than unset, `-`, `linear` and `date` (a `category` range is in positions, and a `log` axis cannot show a range at or below 0, which a linear member can zoom to), mixed reversed and normal axes (any `reversed` autorange variant or a descending fixed range), unequal viewport values (`None` = absent), mixed locks or unequal lock ranges. Every entry point (builder, `/share`, `/view`, import, `flexvizApply`, each request) runs it. The column types need the source schema, so `check_axis_link_types` runs in the builder and, with the registered sources, in `/share`, `/view` (400) and `/dashboard/update` (422): only numeric, `Date` and `Datetime` axes link (`Time` and `Duration` render as category axes, issue #79), and a numeric column on a `date` axis is refused. All axes of a group then have one kind: numeric, or temporal with one time zone and one effective Plotly axis type (`date` reports strings, `linear` numbers). Limits: after a double-click autorange each member autoranges on its own data (in update mode a figure filtered by a selection fits its filtered rows); ECharts (deprecated) does not write linked keys. **Treemap / pie multi-click:** successive clicks on the same figure append OR predicates via `fvUpsertPathPredicate` in the shared runtime (Plotly and ECharts). Re-clicking the same node toggles that predicate off; refining along one branch (parent → child or child → parent) replaces the broader/narrower predicate instead of accumulating redundant filters. *UX note:* this follows common additive-filter BI patterns; we should periodically reassess whether modifier keys or explicit multi-select mode would better match natural visual exploration for hierarchical charts. diff --git a/docs/guides/cross-filtering.md b/docs/guides/cross-filtering.md index 7828c29..dba9cb5 100644 --- a/docs/guides/cross-filtering.md +++ b/docs/guides/cross-filtering.md @@ -106,7 +106,8 @@ Rules and limits: numeric column on an axis set to `type="date"` cannot be linked, and date and linear axis types do not mix. A reversed axis links only to reversed axes. - Locking the axes of one figure locks every axis linked to them, at the range - that figure shows. + that figure shows. A linked figure that showed another range re-aggregates at + the locked range. - After a double-click autorange, each figure autoranges to its own data, so linked ranges can differ until the next zoom. In update mode a figure filtered by another figure's selection fits its filtered rows, while the diff --git a/flexviz/adapters/js/toolbar.js b/flexviz/adapters/js/toolbar.js index 91d378f..f9d29ae 100644 --- a/flexviz/adapters/js/toolbar.js +++ b/flexviz/adapters/js/toolbar.js @@ -147,21 +147,31 @@ function fvTryLockAxis(figUid, axisId) { } // Lock an axis and every axis linked to it at the range this figure shows. A // group keeps one range (the spec validator rejects unequal lock ranges), so a -// member that autoranged to other data takes this figure's range. Returns the -// other figures whose display changed. +// member that autoranged to other data takes this figure's range. The server +// aggregated that member for its own range, so the group range also becomes +// the viewport of the group. A group whose members already show one range +// stays display-only: a viewport at Plotly's padded autorange can change the +// bins. Returns the other figures whose display changed and the viewport keys +// written. function fvLockAxisGroup(figUid, axisId) { const ranges = fvTryLockAxis(figUid, axisId); - if (!ranges) return []; + if (!ranges) return { others: [], written: [] }; const range = ranges[axisId]; const others = []; + let apart = false; for (const key of fvLinkedKeys(figUid + '/' + axisId)) { const [memberFigUid, memberAxisId] = key.split('/'); if (memberFigUid === figUid) continue; + const shown = (window.fvCaptureAxisDisplayRanges?.(memberFigUid, memberAxisId) || {})[memberAxisId]; + if (JSON.stringify(shown) !== JSON.stringify(range)) apart = true; window.fvSetAxisLocked(memberFigUid, memberAxisId, true); if (range) window.fvStoreAxisLockRanges(memberFigUid, { [memberAxisId]: range }); others.push(memberFigUid); } - return others; + const written = apart && range + ? fvWriteViewport(figUid + '/' + axisId, { min: range[0], max: range[1] }) + : []; + return { others, written }; } window.fvOnToggleAxisLocks = async function(figUid) { if (!figUid) return; @@ -178,9 +188,12 @@ window.fvOnToggleAxisLocks = async function(figUid) { )]; const touchedFigUids = fvFiguresOfKeys(keys); const moved = new Set(); + const written = []; if (shouldLock) { for (const axisId of availableAxes) { - fvLockAxisGroup(figUid, axisId).forEach(other => moved.add(other)); + const locked = fvLockAxisGroup(figUid, axisId); + locked.others.forEach(other => moved.add(other)); + written.push(...locked.written); } } else { for (const key of keys) { @@ -195,6 +208,7 @@ window.fvOnToggleAxisLocks = async function(figUid) { } await Promise.all([...moved].map(touched => window.fvApplyAxisLocks?.(touched))); window.fvUpdateLockAllAxesButton?.(); + await fvCommitViewportChange(figUid, written); }; // ── Global "Lock All Axes" toolbar button ────────────────────────────────── // Returns true only when every figure with lockable axes is fully locked. @@ -226,6 +240,7 @@ window.fvOnLockAllAxes = async function() { if (!uids.length) return; const shouldLock = !window.fvAreAllFiguresLocked(); const applyPromises = []; + const written = []; for (const figUid of uids) { const availableAxes = fvCurrentLockableAxes(figUid); if (!availableAxes.length) continue; @@ -233,8 +248,10 @@ window.fvOnLockAllAxes = async function() { for (const axisId of availableAxes) { // An earlier group member already locked this axis at the group range. if (window.fvIsAxisLocked(figUid, axisId)) continue; - fvLockAxisGroup(figUid, axisId) + const locked = fvLockAxisGroup(figUid, axisId); + locked.others .forEach(other => applyPromises.push(Promise.resolve(window.fvApplyAxisLocks?.(other)))); + written.push(...locked.written); } window.fvUpdateAxisLockButtons?.(figUid); window.fvSyncFigureModeForAxisLocks?.(figUid); @@ -249,6 +266,8 @@ window.fvOnLockAllAxes = async function() { } await Promise.all(applyPromises); window.fvUpdateLockAllAxesButton?.(); + // One request for every group the locks wrote. + await fvCommitViewportChange(null, written); }; // Per-figure reset. Resets ONLY this figure: clears its viewport (zoom) and diff --git a/tests/test_browser.py b/tests/test_browser.py index 73297b6..52c72ee 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -6916,6 +6916,33 @@ def _dashboard_url_linked( }))""" +def _dashboard_url_linked_pairs( + port: int, pairs: list[tuple[str, str]] +) -> tuple[str, list[str]]: + """Pairs of lines, each pair linked on x. ``short`` spans 0..999 and + ``long`` 0..9999, so a pair on both autoranges apart.""" + from flexviz.dashboard import Dashboard + from flexviz.server import register_source + from flexviz.spec import encode_spec + + df = pl.DataFrame({"long": range(10_000)}).with_columns( + short=pl.col("long") // 10, val=pl.col("long").cast(pl.Float64) + ) + register_source("_browser_linked_pairs", df) + dash = Dashboard(df) + for x_a, x_b in pairs: + a = dash.add_figure() + a.add_line(x=x_a, y="val", n_points=200) + b = dash.add_figure() + b.add_line(x=x_b, y="val", n_points=200) + dash.link_axes(a, b, axis="x") + spec = dash.to_spec(source_name="_browser_linked_pairs") + return ( + f"http://127.0.0.1:{port}/view?spec={encode_spec(spec)}&renderer=plotly", + [f.uid for f in spec.figures], + ) + + @pytest.mark.browser class TestLinkedAxesBrowser: @staticmethod @@ -7149,6 +7176,58 @@ def test_lock_all_keeps_linked_groups_valid(self, page: Page, server_port: int): page.wait_for_timeout(1_000) assert statuses == [200] + def test_lock_aggregates_a_member_at_the_range_it_shows( + self, page: Page, server_port: int + ): + url, uids = _dashboard_url_linked_pairs(server_port, [("short", "long")]) + posts = self._open(page, url) + + page.click("#fv-bar-0 .fv-mode-action-btn[data-action='lock-axes']") + page.wait_for_timeout(1_500) + + # B takes A's range, and the server re-aggregates B for that range. + window = page.evaluate("""() => { + const gd = divs[1]; + const [lo, hi] = gd._fullLayout.xaxis.range; + const xs = gd.data[0].x; + return {hi, n: xs.length, inside: xs.filter(x => lo <= x && x <= hi).length}; + }""") + assert window["hi"] < 1_000, window + assert window["n"] > 100 and window["inside"] == window["n"], window + assert len(posts) == 1, [p["event"] for p in posts] + assert posts[0]["event"]["viewport_keys"] == [f"{uids[0]}/x", f"{uids[1]}/x"] + viewport = page.evaluate("DASHBOARD_SPEC.state.viewport") + ranges = page.evaluate("DASHBOARD_SPEC.client_state.axis_lock_ranges") + assert viewport[f"{uids[1]}/x"] == ranges[f"{uids[1]}/x"], (viewport, ranges) + + def test_lock_all_sends_one_request_for_every_group( + self, page: Page, server_port: int + ): + url, uids = _dashboard_url_linked_pairs( + server_port, [("short", "long"), ("short", "long")] + ) + posts = self._open(page, url) + + page.click("#fv-btn-lock-all") + page.wait_for_timeout(1_500) + + assert len(posts) == 1, [p["event"] for p in posts] + assert sorted(posts[0]["event"]["viewport_keys"]) == sorted( + f"{uid}/x" for uid in uids + ) + + def test_lock_on_members_that_show_one_range_posts_nothing( + self, page: Page, server_port: int + ): + url, _ = _dashboard_url_linked_pairs(server_port, [("long", "long")]) + posts = self._open(page, url) + + page.click("#fv-bar-0 .fv-mode-action-btn[data-action='lock-axes']") + page.wait_for_timeout(1_000) + + assert posts == [] + assert page.evaluate("DASHBOARD_SPEC.state.viewport") == {} + def test_overlay_owner_in_the_group_gets_its_background( self, page: Page, server_port: int ): From 01dc68bb2b6d091bd1f3f2d60a78b95eb40e9224 Mon Sep 17 00:00:00 2001 From: Jeroen Van Der Donckt Date: Mon, 28 Sep 2026 20:52:10 +0200 Subject: [PATCH 2/3] fix(adapters): treat linked ranges within 1e-9 of the span as equal on lock --- flexviz/adapters/js/toolbar.js | 11 ++++++++++- tests/test_browser.py | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/flexviz/adapters/js/toolbar.js b/flexviz/adapters/js/toolbar.js index f9d29ae..2041420 100644 --- a/flexviz/adapters/js/toolbar.js +++ b/flexviz/adapters/js/toolbar.js @@ -145,6 +145,15 @@ function fvTryLockAxis(figUid, axisId) { window.fvStoreAxisLockRanges(figUid, ranges); return ranges; } +// Figures linked on one column rarely autorange to bit-identical bounds (a line +// shows [3e-13, 9999.0000000001] where a histogram shows [0, 9999]), so numeric +// bounds within 1e-9 of the span count as equal. Date strings compare exactly. +function fvSameRange(a, b) { + if (JSON.stringify(a) === JSON.stringify(b)) return true; + if (!a || !b || ![...a, ...b].every(v => typeof v === 'number')) return false; + const tolerance = 1e-9 * Math.abs(b[1] - b[0]); + return Math.abs(a[0] - b[0]) < tolerance && Math.abs(a[1] - b[1]) < tolerance; +} // Lock an axis and every axis linked to it at the range this figure shows. A // group keeps one range (the spec validator rejects unequal lock ranges), so a // member that autoranged to other data takes this figure's range. The server @@ -163,7 +172,7 @@ function fvLockAxisGroup(figUid, axisId) { const [memberFigUid, memberAxisId] = key.split('/'); if (memberFigUid === figUid) continue; const shown = (window.fvCaptureAxisDisplayRanges?.(memberFigUid, memberAxisId) || {})[memberAxisId]; - if (JSON.stringify(shown) !== JSON.stringify(range)) apart = true; + if (!fvSameRange(shown, range)) apart = true; window.fvSetAxisLocked(memberFigUid, memberAxisId, true); if (range) window.fvStoreAxisLockRanges(memberFigUid, { [memberAxisId]: range }); others.push(memberFigUid); diff --git a/tests/test_browser.py b/tests/test_browser.py index 52c72ee..270b797 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -7228,6 +7228,41 @@ def test_lock_on_members_that_show_one_range_posts_nothing( assert posts == [] assert page.evaluate("DASHBOARD_SPEC.state.viewport") == {} + def test_lock_on_a_line_and_histogram_of_one_column_posts_nothing( + self, page: Page, server_port: int + ): + from flexviz.dashboard import Dashboard + from flexviz.server import register_source + from flexviz.spec import encode_spec + + # The line and the histogram autorange to ranges that differ by float + # noise only, so the lock has nothing to fix. + df = pl.DataFrame({"long": range(10_000)}).with_columns( + val=pl.col("long").cast(pl.Float64) + ) + register_source("_browser_linked_hist", df) + dash = Dashboard(df) + dash.add_figure().add_line(x="long", y="val", n_points=200) + dash.add_figure().add_histogram(x="long", bins=50) + dash.link_axes(on="long") + spec = dash.to_spec(source_name="_browser_linked_hist") + posts = self._open( + page, + f"http://127.0.0.1:{server_port}/view?spec={encode_spec(spec)}" + "&renderer=plotly", + ) + shown = page.evaluate( + "[...document.querySelectorAll('.js-plotly-plot')]" + ".map(gd => gd._fullLayout.xaxis.range)" + ) + assert shown[0] != shown[1], shown + + page.click("#fv-bar-0 .fv-mode-action-btn[data-action='lock-axes']") + page.wait_for_timeout(1_000) + + assert posts == [] + assert page.evaluate("DASHBOARD_SPEC.state.viewport") == {} + def test_overlay_owner_in_the_group_gets_its_background( self, page: Page, server_port: int ): From 43877219a3b6fa25c899a1d8c8ca3669be9e2e65 Mon Sep 17 00:00:00 2001 From: Jeroen Van Der Donckt Date: Mon, 28 Sep 2026 20:52:18 +0200 Subject: [PATCH 3/3] docs(architecture): state the lock tolerance and which link groups keep viewport keys after an unlock --- Architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Architecture.md b/Architecture.md index 5891421..65d2a2a 100644 --- a/Architecture.md +++ b/Architecture.md @@ -876,7 +876,7 @@ FlexEngine Because the engine keys its recompute on event type, `fvOnResetPanel` derives the emitted type from the resulting state: `selection` when filters remain, `deselect` when none remain, `viewport` when only the viewport changed. Every variant names the cleared keys in `viewport_keys`, so a viewport-only reset re-aggregates only this figure. The global toolbar **Reset** (`fvOnReset`) instead clears all unlocked viewports + selections and emits `init`; resets and double-click autorange never clear a locked axis's viewport key, because the lock pins the displayed range and the engine aggregates at the state range; the global **Deselect** clears selections only and **keeps zoom** (emitting `deselect`). -**Linked axes (`ClientState.axis_links`):** each group lists viewport keys that always hold one range. The client keeps them equal with one writer, `fvWriteViewport` (`runtime/state.js`): a zoom, pan, double-click autorange or panel reset writes (or deletes) every key of the group. `fvCommitViewportChange` then redraws the other moved figures from state (the relayout handler ignores what `Plotly.react` emits, so the redraws post nothing) and sends one viewport event naming every written key, only when a changed axis binds a trace somewhere. The engine needs no link knowledge: the event's `viewport_keys` pick the figures, and the partition rule keeps each figure unfiltered by its own selection. A lock toggle reaches every member and pins all of them at the range the clicked figure shows (`fvLockAxisGroup`; Lock All skips an axis a group member already locked), so a locked member pins the group. When a member shows another range, the server aggregated it for that other range, so the lock also writes the group range with `fvWriteViewport`, and the toggle commits it with `fvCommitViewportChange` (Lock All sends one commit for all groups). A group whose members already show one range stays display-only: a viewport at Plotly's padded autorange can change the bins, because a zoomed grid snaps to a lattice. When members differ, the clicked figure gets the group viewport too (the validator requires equal viewport values), so a heatmap there can re-bin, for example from 7 to 8 rows on integer data. The `DashboardSpec` validator rejects a link the client cannot keep: fewer than two keys, a key in two groups, two axes of one figure, an axis other than x/y, an axis without a data column (count axes, bars, maps), an axis type other than unset, `-`, `linear` and `date` (a `category` range is in positions, and a `log` axis cannot show a range at or below 0, which a linear member can zoom to), mixed reversed and normal axes (any `reversed` autorange variant or a descending fixed range), unequal viewport values (`None` = absent), mixed locks or unequal lock ranges. Every entry point (builder, `/share`, `/view`, import, `flexvizApply`, each request) runs it. The column types need the source schema, so `check_axis_link_types` runs in the builder and, with the registered sources, in `/share`, `/view` (400) and `/dashboard/update` (422): only numeric, `Date` and `Datetime` axes link (`Time` and `Duration` render as category axes, issue #79), and a numeric column on a `date` axis is refused. All axes of a group then have one kind: numeric, or temporal with one time zone and one effective Plotly axis type (`date` reports strings, `linear` numbers). Limits: after a double-click autorange each member autoranges on its own data (in update mode a figure filtered by a selection fits its filtered rows); ECharts (deprecated) does not write linked keys. +**Linked axes (`ClientState.axis_links`):** each group lists viewport keys that always hold one range. The client keeps them equal with one writer, `fvWriteViewport` (`runtime/state.js`): a zoom, pan, double-click autorange or panel reset writes (or deletes) every key of the group. `fvCommitViewportChange` then redraws the other moved figures from state (the relayout handler ignores what `Plotly.react` emits, so the redraws post nothing) and sends one viewport event naming every written key, only when a changed axis binds a trace somewhere. The engine needs no link knowledge: the event's `viewport_keys` pick the figures, and the partition rule keeps each figure unfiltered by its own selection. A lock toggle reaches every member and pins all of them at the range the clicked figure shows (`fvLockAxisGroup`; Lock All skips an axis a group member already locked), so a locked member pins the group. When a member shows another range, the server aggregated it for that other range, so the lock also writes the group range with `fvWriteViewport`, and the toggle commits it with `fvCommitViewportChange` (Lock All sends one commit for all groups). A group whose members already show one range (each bound within 1e-9 of the span) stays display-only: a viewport at Plotly's padded autorange can change the bins, because a zoomed grid snaps to a lattice. When members differ, the clicked figure gets the group viewport too (the validator requires equal viewport values), so a heatmap there can re-bin, for example from 7 to 8 rows on integer data. An unlock writes no viewport: a group whose members showed other ranges keeps its viewport keys, and a display-only group has none. The `DashboardSpec` validator rejects a link the client cannot keep: fewer than two keys, a key in two groups, two axes of one figure, an axis other than x/y, an axis without a data column (count axes, bars, maps), an axis type other than unset, `-`, `linear` and `date` (a `category` range is in positions, and a `log` axis cannot show a range at or below 0, which a linear member can zoom to), mixed reversed and normal axes (any `reversed` autorange variant or a descending fixed range), unequal viewport values (`None` = absent), mixed locks or unequal lock ranges. Every entry point (builder, `/share`, `/view`, import, `flexvizApply`, each request) runs it. The column types need the source schema, so `check_axis_link_types` runs in the builder and, with the registered sources, in `/share`, `/view` (400) and `/dashboard/update` (422): only numeric, `Date` and `Datetime` axes link (`Time` and `Duration` render as category axes, issue #79), and a numeric column on a `date` axis is refused. All axes of a group then have one kind: numeric, or temporal with one time zone and one effective Plotly axis type (`date` reports strings, `linear` numbers). Limits: after a double-click autorange each member autoranges on its own data (in update mode a figure filtered by a selection fits its filtered rows); ECharts (deprecated) does not write linked keys. **Treemap / pie multi-click:** successive clicks on the same figure append OR predicates via `fvUpsertPathPredicate` in the shared runtime (Plotly and ECharts). Re-clicking the same node toggles that predicate off; refining along one branch (parent → child or child → parent) replaces the broader/narrower predicate instead of accumulating redundant filters. *UX note:* this follows common additive-filter BI patterns; we should periodically reassess whether modifier keys or explicit multi-select mode would better match natural visual exploration for hierarchical charts.