From 43291d9720754e4439c3e2b6e718494794b36dc0 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:10:12 +0500 Subject: [PATCH 1/4] Add opt-in linked facet interactions --- docs/guides/dashboards-and-linked-views.md | 8 +++- js/src/50_chartview.js | 19 ++++++++- js/src/53_interaction.js | 41 ++++++++++++-------- python/xy/_figure.py | 8 +++- python/xy/components.py | 45 +++++++++++++++++++--- python/xy/static/index.js | 33 ++++++++++++++-- python/xy/static/standalone.js | 33 ++++++++++++++-- tests/test_facets.py | 31 ++++++++++----- 8 files changed, 173 insertions(+), 45 deletions(-) diff --git a/docs/guides/dashboards-and-linked-views.md b/docs/guides/dashboards-and-linked-views.md index 98364008..7ef1ed07 100644 --- a/docs/guides/dashboards-and-linked-views.md +++ b/docs/guides/dashboards-and-linked-views.md @@ -26,8 +26,12 @@ detail = xy.line_chart( ~~~ Use unique group names for independent dashboard regions. Link only `x` when -panels share time but use different y units. `facet_chart(..., share_x=True, -share_y=True)` configures the corresponding links for small multiples. +panels share time but use different y units. Facet domains remain independent +at runtime by default; opt into synchronized navigation with +`facet_chart(..., link="x")`, `link="y"`, or `link="both"`. A linked axis also +shares its initial domain, even when its matching `share_x`/`share_y` flag is +false. Set `link_select=True` to echo the same data-space brush predicate and +selection highlighting across the facet panels. ## Application-Driven Coordination diff --git a/js/src/50_chartview.js b/js/src/50_chartview.js index e1f44290..99a6c162 100644 --- a/js/src/50_chartview.js +++ b/js/src/50_chartview.js @@ -428,10 +428,22 @@ class ChartView { this._linkAxes = Array.isArray(this.interaction.link_axes) ? this.interaction.link_axes.filter((axis) => axis === "x" || axis === "y") : ["x", "y"]; - if (!this._linkAxes.length) this._linkAxes = ["x", "y"]; this._linkChannel = new BroadcastChannel(`xy:${group}`); this._linkChannel.onmessage = (event) => { const msg = event.data || {}; + if (msg.source === this._linkedSource) return; + if (this._interactionFlag("link_select") && msg.selection) { + const selection = msg.selection; + if (selection.clear) this._clearSelection({ broadcast: false, dispatch: false }); + else if (selection.polygon) this._selectLocalPolygon(selection.polygon, { dispatch: false }); + else if (selection.range) { + const { x0, x1, y0, y1 } = selection.range; + if ([x0, x1, y0, y1].every(Number.isFinite)) { + this._selectLocal(x0, x1, y0, y1, { dispatch: false }); + } + } + return; + } if (!msg.view || msg.source === this._linkedSource) return; const next = { ...this.view }; if (this._linkAxes.includes("x")) { @@ -452,6 +464,11 @@ class ChartView { this._linkChannel.postMessage({ source: this._linkedSource, view: detail }); } + _broadcastLinkedSelection(selection) { + if (!this._linkChannel || !this._interactionFlag("link_select")) return; + this._linkChannel.postMessage({ source: this._linkedSource, selection }); + } + _applyClass(el, className) { if (typeof className !== "string") return; for (const token of className.split(/\s+/).filter(Boolean)) { diff --git a/js/src/53_interaction.js b/js/src/53_interaction.js index dc6d53a9..f30d92b3 100644 --- a/js/src/53_interaction.js +++ b/js/src/53_interaction.js @@ -560,6 +560,7 @@ Object.assign(ChartView.prototype, { const x0 = Math.min(d0[0], d1[0]), x1 = Math.max(d0[0], d1[0]); const y0 = Math.min(d0[1], d1[1]), y1 = Math.max(d0[1], d1[1]); const range = { x0, x1, y0, y1 }; + this._broadcastLinkedSelection({ range }); this._dispatchChartEvent("brush", { range, view: this._eventView("brush") }); if (this.comm) { this.comm.send({ type: "select", x0, x1, y0, y1 }); @@ -573,6 +574,7 @@ Object.assign(ChartView.prototype, { const polygon = points.map((point) => [point[0], point[1]]); if (!polygon.every((point) => point.every(Number.isFinite))) return; this._lassoPolygon = polygon; + this._broadcastLinkedSelection({ polygon }); this._renderLassoSelection(); this._dispatchChartEvent("brush", { polygon, @@ -585,7 +587,7 @@ Object.assign(ChartView.prototype, { } }, - _selectLocalPolygon(points) { + _selectLocalPolygon(points, opts = {}) { const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); const minX = Math.min(...xs), maxX = Math.max(...xs); @@ -621,15 +623,17 @@ Object.assign(ChartView.prototype, { } this._selectionCount = total; this.draw(); - this._dispatchChartEvent("select", { - total, - polygon: points, - view: this._eventView("select"), - }); + if (opts.dispatch !== false) { + this._dispatchChartEvent("select", { + total, + polygon: points, + view: this._eventView("select"), + }); + } }, // Standalone selection (no kernel): mask the retained CPU f32 columns (§37). - _selectLocal(x0, x1, y0, y1) { + _selectLocal(x0, x1, y0, y1, opts = {}) { let total = 0; for (const g of this.gpuTraces) { // _cpu only exists where the standalone entry retained copies (retainCpu). @@ -650,11 +654,13 @@ Object.assign(ChartView.prototype, { } this._selectionCount = total; this.draw(); - this._dispatchChartEvent("select", { - total, - range: { x0, x1, y0, y1 }, - view: this._eventView("select"), - }); + if (opts.dispatch !== false) { + this._dispatchChartEvent("select", { + total, + range: { x0, x1, y0, y1 }, + view: this._eventView("select"), + }); + } }, _applySelMask(g, maskF32) { @@ -665,16 +671,19 @@ Object.assign(ChartView.prototype, { g.selActive = true; }, - _clearSelection() { + _clearSelection(opts = {}) { this._clearLassoOverlay(); for (const g of this.gpuTraces) { g.selActive = false; if (g.drill) g.drill.selActive = false; } this._selectionCount = 0; - if (this._interactionFlag("select", true)) { - if (this.comm) this.comm.send({ type: "select_clear" }); - this._dispatchChartEvent("select", { total: 0, view: this._eventView("select_clear") }); + if (opts.broadcast !== false) this._broadcastLinkedSelection({ clear: true }); + if (opts.dispatch !== false) { + if (this._interactionFlag("select", true)) { + if (this.comm) this.comm.send({ type: "select_clear" }); + this._dispatchChartEvent("select", { total: 0, view: this._eventView("select_clear") }); + } } }, diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 7ffa8ed6..6a79848b 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -239,6 +239,7 @@ def set_interaction( view_change: Optional[bool] = None, link_group: Optional[str] = None, link_axes: tuple[str, ...] = ("x", "y"), + link_select: Optional[bool] = None, ) -> "Figure": updates: dict[str, Any] = {} for name, value in ( @@ -248,6 +249,7 @@ def set_interaction( ("brush", brush), ("crosshair", crosshair), ("view_change", view_change), + ("link_select", link_select), ): normalized = self._optional_bool(value, f"interaction {name}") if normalized is not None: @@ -469,7 +471,7 @@ def _link_axes(value: Any) -> list[str]: if not isinstance(value, (tuple, list)): raise ValueError("interaction link_axes must be a tuple/list containing 'x' and/or 'y'") axes = list(value) - if not axes or any(axis not in {"x", "y"} for axis in axes): + if any(axis not in {"x", "y"} for axis in axes): raise ValueError("interaction link_axes must contain only 'x' and/or 'y'") out: list[str] = [] for axis in axes: @@ -1018,7 +1020,9 @@ def _range_columns(self, t: Trace, axis_id: str) -> list[Column]: def _interaction_spec(self) -> dict[str, Any]: spec: dict[str, Any] = {} - for name in ("hover", "click", "select", "brush", "crosshair", "view_change"): + for name in ( + "hover", "click", "select", "brush", "crosshair", "view_change", "link_select" + ): if name in self.interaction: spec[name] = self._bool_param(self.interaction[name], f"interaction {name}") link_group = self.interaction.get("link_group") diff --git a/python/xy/components.py b/python/xy/components.py index e82e48c0..683842e2 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -3219,6 +3219,8 @@ def __init__( cols: int = 3, share_x: bool = True, share_y: bool = True, + link: Optional[str] = None, + link_select: bool = False, gap: int = 12, **props: Any, ) -> None: @@ -3243,6 +3245,10 @@ def __init__( self.cols = int(cols) self.share_x = _strict_bool(share_x, "facet_chart share_x") self.share_y = _strict_bool(share_y, "facet_chart share_y") + if link not in (None, "x", "y", "both"): + raise ValueError("facet_chart link must be None, 'x', 'y', or 'both'") + self.link = link + self.link_select = _strict_bool(link_select, "facet_chart link_select") self.gap = int(gap) self.props = dict(props) self._grid: Any = None @@ -3295,7 +3301,14 @@ def build_panels(preseed: dict[str, list[str]]) -> list[Figure]: return figures figures = build_panels({}) - shared_dims = [dim for dim, shared in (("x", self.share_x), ("y", self.share_y)) if shared] + linked_dims = [] if self.link is None else [self.link] if self.link != "both" else ["x", "y"] + # A linked dimension must start from the same domain; otherwise the + # first interaction would make panels jump from incomparable views. + shared_dims = [ + dim + for dim, shared in (("x", self.share_x), ("y", self.share_y)) + if shared or dim in linked_dims + ] shared_axis_ids = [ axis_id for dim in shared_dims @@ -3347,11 +3360,19 @@ def build_panels(preseed: dict[str, list[str]]) -> list[Figure]: hi = max(max(pair) for pair in ranges) for fig in figures: fig._set_axis_domain(axis_id, (lo, hi)) - if shared_dims and not any("link_group" in fig.interaction for fig in figures): - # Shared-axis panels pan/zoom together in live outputs. - group = f"xy-facet-{uuid.uuid4().hex[:8]}" + if linked_dims or self.link_select: + group = next( + ( + fig.interaction["link_group"] + for fig in figures + if "link_group" in fig.interaction + ), + f"xy-facet-{uuid.uuid4().hex[:8]}", + ) for fig in figures: - fig.set_interaction(link_group=group, link_axes=tuple(shared_dims)) + fig.set_interaction(link_group=group, link_axes=tuple(linked_dims)) + if self.link_select: + fig.interaction["link_select"] = True self._grid = FacetGrid( figures, unique_labels, @@ -4092,6 +4113,8 @@ def facet_chart( cols: int = 3, share_x: bool = True, share_y: bool = True, + link: Optional[str] = None, + link_select: bool = False, gap: int = 12, **props: Any, ) -> FacetChart: @@ -4103,9 +4126,19 @@ def facet_chart( cols: Maximum number of panel columns. share_x: Whether panels share an x domain. share_y: Whether panels share a y domain. + link: Runtime-linked axes: ``None``, ``"x"``, ``"y"``, or ``"both"``. + link_select: Whether data-space selections are echoed across panels. gap: Gap between panels in pixels. **props: Additional shared chart properties. """ return FacetChart( - children, by=by, cols=cols, share_x=share_x, share_y=share_y, gap=gap, **props + children, + by=by, + cols=cols, + share_x=share_x, + share_y=share_y, + link=link, + link_select=link_select, + gap=gap, + **props, ) diff --git a/python/xy/static/index.js b/python/xy/static/index.js index 2fef1684..27a1aa9c 100644 --- a/python/xy/static/index.js +++ b/python/xy/static/index.js @@ -2054,10 +2054,22 @@ if (!group || typeof BroadcastChannel !== "function") return; this._linkAxes = Array.isArray(this.interaction.link_axes) ? this.interaction.link_axes.filter((axis) => axis === "x" || axis === "y") : ["x", "y"]; -if (!this._linkAxes.length) this._linkAxes = ["x", "y"]; this._linkChannel = new BroadcastChannel(`xy:${group}`); this._linkChannel.onmessage = (event) => { const msg = event.data || {}; +if (msg.source === this._linkedSource) return; +if (this._interactionFlag("link_select") && msg.selection) { +const selection = msg.selection; +if (selection.clear) this._clearSelection({ broadcast: false, dispatch: false }); +else if (selection.polygon) this._selectLocalPolygon(selection.polygon, { dispatch: false }); +else if (selection.range) { +const { x0, x1, y0, y1 } = selection.range; +if ([x0, x1, y0, y1].every(Number.isFinite)) { +this._selectLocal(x0, x1, y0, y1, { dispatch: false }); +} +} +return; +} if (!msg.view || msg.source === this._linkedSource) return; const next = { ...this.view }; if (this._linkAxes.includes("x")) { @@ -2076,6 +2088,10 @@ _broadcastLinkedView(detail) { if (!this._linkChannel) return; this._linkChannel.postMessage({ source: this._linkedSource, view: detail }); } +_broadcastLinkedSelection(selection) { +if (!this._linkChannel || !this._interactionFlag("link_select")) return; +this._linkChannel.postMessage({ source: this._linkedSource, selection }); +} _applyClass(el, className) { if (typeof className !== "string") return; for (const token of className.split(/\s+/).filter(Boolean)) { @@ -6303,6 +6319,7 @@ this._clearLassoOverlay(); const x0 = Math.min(d0[0], d1[0]), x1 = Math.max(d0[0], d1[0]); const y0 = Math.min(d0[1], d1[1]), y1 = Math.max(d0[1], d1[1]); const range = { x0, x1, y0, y1 }; +this._broadcastLinkedSelection({ range }); this._dispatchChartEvent("brush", { range, view: this._eventView("brush") }); if (this.comm) { this.comm.send({ type: "select", x0, x1, y0, y1 }); @@ -6315,6 +6332,7 @@ if (!Array.isArray(points) || points.length < 3) return; const polygon = points.map((point) => [point[0], point[1]]); if (!polygon.every((point) => point.every(Number.isFinite))) return; this._lassoPolygon = polygon; +this._broadcastLinkedSelection({ polygon }); this._renderLassoSelection(); this._dispatchChartEvent("brush", { polygon, @@ -6326,7 +6344,7 @@ this.comm.send({ type: "select_polygon", points: polygon }); this._selectLocalPolygon(polygon); } }, -_selectLocalPolygon(points) { +_selectLocalPolygon(points, opts = {}) { const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); const minX = Math.min(...xs), maxX = Math.max(...xs); @@ -6362,13 +6380,15 @@ total += count; } this._selectionCount = total; this.draw(); +if (opts.dispatch !== false) { this._dispatchChartEvent("select", { total, polygon: points, view: this._eventView("select"), }); +} }, -_selectLocal(x0, x1, y0, y1) { +_selectLocal(x0, x1, y0, y1, opts = {}) { let total = 0; for (const g of this.gpuTraces) { if (!g._cpu || g.tier === "density") continue; @@ -6388,11 +6408,13 @@ total += cnt; } this._selectionCount = total; this.draw(); +if (opts.dispatch !== false) { this._dispatchChartEvent("select", { total, range: { x0, x1, y0, y1 }, view: this._eventView("select"), }); +} }, _applySelMask(g, maskF32) { const gl = this.gl; @@ -6401,17 +6423,20 @@ gl.bindBuffer(gl.ARRAY_BUFFER, g.selBuf); gl.bufferData(gl.ARRAY_BUFFER, maskF32, gl.STATIC_DRAW); g.selActive = true; }, -_clearSelection() { +_clearSelection(opts = {}) { this._clearLassoOverlay(); for (const g of this.gpuTraces) { g.selActive = false; if (g.drill) g.drill.selActive = false; } this._selectionCount = 0; +if (opts.broadcast !== false) this._broadcastLinkedSelection({ clear: true }); +if (opts.dispatch !== false) { if (this._interactionFlag("select", true)) { if (this.comm) this.comm.send({ type: "select_clear" }); this._dispatchChartEvent("select", { total: 0, view: this._eventView("select_clear") }); } +} }, _clampModebar(left, top) { const bar = this._modebar; diff --git a/python/xy/static/standalone.js b/python/xy/static/standalone.js index 1db67384..52216e25 100644 --- a/python/xy/static/standalone.js +++ b/python/xy/static/standalone.js @@ -2055,10 +2055,22 @@ if (!group || typeof BroadcastChannel !== "function") return; this._linkAxes = Array.isArray(this.interaction.link_axes) ? this.interaction.link_axes.filter((axis) => axis === "x" || axis === "y") : ["x", "y"]; -if (!this._linkAxes.length) this._linkAxes = ["x", "y"]; this._linkChannel = new BroadcastChannel(`xy:${group}`); this._linkChannel.onmessage = (event) => { const msg = event.data || {}; +if (msg.source === this._linkedSource) return; +if (this._interactionFlag("link_select") && msg.selection) { +const selection = msg.selection; +if (selection.clear) this._clearSelection({ broadcast: false, dispatch: false }); +else if (selection.polygon) this._selectLocalPolygon(selection.polygon, { dispatch: false }); +else if (selection.range) { +const { x0, x1, y0, y1 } = selection.range; +if ([x0, x1, y0, y1].every(Number.isFinite)) { +this._selectLocal(x0, x1, y0, y1, { dispatch: false }); +} +} +return; +} if (!msg.view || msg.source === this._linkedSource) return; const next = { ...this.view }; if (this._linkAxes.includes("x")) { @@ -2077,6 +2089,10 @@ _broadcastLinkedView(detail) { if (!this._linkChannel) return; this._linkChannel.postMessage({ source: this._linkedSource, view: detail }); } +_broadcastLinkedSelection(selection) { +if (!this._linkChannel || !this._interactionFlag("link_select")) return; +this._linkChannel.postMessage({ source: this._linkedSource, selection }); +} _applyClass(el, className) { if (typeof className !== "string") return; for (const token of className.split(/\s+/).filter(Boolean)) { @@ -6304,6 +6320,7 @@ this._clearLassoOverlay(); const x0 = Math.min(d0[0], d1[0]), x1 = Math.max(d0[0], d1[0]); const y0 = Math.min(d0[1], d1[1]), y1 = Math.max(d0[1], d1[1]); const range = { x0, x1, y0, y1 }; +this._broadcastLinkedSelection({ range }); this._dispatchChartEvent("brush", { range, view: this._eventView("brush") }); if (this.comm) { this.comm.send({ type: "select", x0, x1, y0, y1 }); @@ -6316,6 +6333,7 @@ if (!Array.isArray(points) || points.length < 3) return; const polygon = points.map((point) => [point[0], point[1]]); if (!polygon.every((point) => point.every(Number.isFinite))) return; this._lassoPolygon = polygon; +this._broadcastLinkedSelection({ polygon }); this._renderLassoSelection(); this._dispatchChartEvent("brush", { polygon, @@ -6327,7 +6345,7 @@ this.comm.send({ type: "select_polygon", points: polygon }); this._selectLocalPolygon(polygon); } }, -_selectLocalPolygon(points) { +_selectLocalPolygon(points, opts = {}) { const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); const minX = Math.min(...xs), maxX = Math.max(...xs); @@ -6363,13 +6381,15 @@ total += count; } this._selectionCount = total; this.draw(); +if (opts.dispatch !== false) { this._dispatchChartEvent("select", { total, polygon: points, view: this._eventView("select"), }); +} }, -_selectLocal(x0, x1, y0, y1) { +_selectLocal(x0, x1, y0, y1, opts = {}) { let total = 0; for (const g of this.gpuTraces) { if (!g._cpu || g.tier === "density") continue; @@ -6389,11 +6409,13 @@ total += cnt; } this._selectionCount = total; this.draw(); +if (opts.dispatch !== false) { this._dispatchChartEvent("select", { total, range: { x0, x1, y0, y1 }, view: this._eventView("select"), }); +} }, _applySelMask(g, maskF32) { const gl = this.gl; @@ -6402,17 +6424,20 @@ gl.bindBuffer(gl.ARRAY_BUFFER, g.selBuf); gl.bufferData(gl.ARRAY_BUFFER, maskF32, gl.STATIC_DRAW); g.selActive = true; }, -_clearSelection() { +_clearSelection(opts = {}) { this._clearLassoOverlay(); for (const g of this.gpuTraces) { g.selActive = false; if (g.drill) g.drill.selActive = false; } this._selectionCount = 0; +if (opts.broadcast !== false) this._broadcastLinkedSelection({ clear: true }); +if (opts.dispatch !== false) { if (this._interactionFlag("select", true)) { if (this.comm) this.comm.send({ type: "select_clear" }); this._dispatchChartEvent("select", { total: 0, view: this._eventView("select_clear") }); } +} }, _clampModebar(left, top) { const bar = this._modebar; diff --git a/tests/test_facets.py b/tests/test_facets.py index dda75335..20f0b0e1 100644 --- a/tests/test_facets.py +++ b/tests/test_facets.py @@ -276,20 +276,31 @@ def test_shared_named_categorical_axis_uses_its_own_union_category_order() -> No # -- interaction linking ------------------------------------------------------ -def test_shared_axes_link_panels() -> None: +def test_shared_axes_stay_independent_by_default() -> None: grid = xy.facet_chart(xy.line(x="x", y="y"), by="g", data=_table()).figure() - groups = {fig.interaction.get("link_group") for fig in grid.figures} - assert len(groups) == 1 and None not in groups - assert all(fig.interaction["link_axes"] == ["x", "y"] for fig in grid.figures) + assert all("link_group" not in fig.interaction for fig in grid.figures) -def test_link_axes_follow_share_flags() -> None: - grid = xy.facet_chart(xy.line(x="x", y="y"), by="g", data=_table(), share_y=False).figure() - assert all(fig.interaction["link_axes"] == ["x"] for fig in grid.figures) - unlinked = xy.facet_chart( - xy.line(x="x", y="y"), by="g", data=_table(), share_x=False, share_y=False +@pytest.mark.parametrize(("link", "axes"), [("x", ["x"]), ("y", ["y"]), ("both", ["x", "y"])]) +def test_link_is_explicit_and_selectable(link: str, axes: list[str]) -> None: + grid = xy.facet_chart( + xy.line(x="x", y="y"), by="g", data=_table(), link=link, link_select=True ).figure() - assert all("link_group" not in fig.interaction for fig in unlinked.figures) + assert len({fig.interaction["link_group"] for fig in grid.figures}) == 1 + assert all(fig.interaction["link_axes"] == axes for fig in grid.figures) + assert all(fig.interaction["link_select"] is True for fig in grid.figures) + + +def test_link_implies_initial_domain_sharing() -> None: + grid = xy.facet_chart( + xy.line(x="x", y="y"), by="g", data=_table(), share_x=False, link="x" + ).figure() + assert len({fig.x_range() for fig in grid.figures}) == 1 + + +def test_invalid_link_rejected() -> None: + with pytest.raises(ValueError, match="link must"): + xy.facet_chart(xy.line(x="x", y="y"), by="g", data=_table(), link="z") def test_user_link_group_is_not_overridden() -> None: From deb14b50d29e8ec507d97dcb5e35b881106c4c33 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:02:20 +0500 Subject: [PATCH 2/4] Apply Ruff formatting for linked facets --- python/xy/_figure.py | 8 +++++++- python/xy/components.py | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 6a79848b..ed237c9a 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -1021,7 +1021,13 @@ def _range_columns(self, t: Trace, axis_id: str) -> list[Column]: def _interaction_spec(self) -> dict[str, Any]: spec: dict[str, Any] = {} for name in ( - "hover", "click", "select", "brush", "crosshair", "view_change", "link_select" + "hover", + "click", + "select", + "brush", + "crosshair", + "view_change", + "link_select", ): if name in self.interaction: spec[name] = self._bool_param(self.interaction[name], f"interaction {name}") diff --git a/python/xy/components.py b/python/xy/components.py index 683842e2..51f4f74d 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -3301,7 +3301,9 @@ def build_panels(preseed: dict[str, list[str]]) -> list[Figure]: return figures figures = build_panels({}) - linked_dims = [] if self.link is None else [self.link] if self.link != "both" else ["x", "y"] + linked_dims = ( + [] if self.link is None else [self.link] if self.link != "both" else ["x", "y"] + ) # A linked dimension must start from the same domain; otherwise the # first interaction would make panels jump from incomparable views. shared_dims = [ From b8c4711fa7e1ff1159ae98df0120924cb4b73007 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:05:40 +0500 Subject: [PATCH 3/4] Allow boolean facet interaction linking --- docs/guides/dashboards-and-linked-views.md | 9 ++--- examples/demo-advance.ipynb | 39 ++++++++++++++++++++++ python/xy/components.py | 13 +++++--- tests/test_facets.py | 12 +++++-- 4 files changed, 62 insertions(+), 11 deletions(-) diff --git a/docs/guides/dashboards-and-linked-views.md b/docs/guides/dashboards-and-linked-views.md index 7ef1ed07..9b0db28c 100644 --- a/docs/guides/dashboards-and-linked-views.md +++ b/docs/guides/dashboards-and-linked-views.md @@ -28,10 +28,11 @@ detail = xy.line_chart( Use unique group names for independent dashboard regions. Link only `x` when panels share time but use different y units. Facet domains remain independent at runtime by default; opt into synchronized navigation with -`facet_chart(..., link="x")`, `link="y"`, or `link="both"`. A linked axis also -shares its initial domain, even when its matching `share_x`/`share_y` flag is -false. Set `link_select=True` to echo the same data-space brush predicate and -selection highlighting across the facet panels. +`facet_chart(..., link="x")`, `link="y"`, or `link="both"`. `link=True` is a +convenient alias for `link="both"`, while `False` and `None` disable runtime axis +linking. A linked axis also shares its initial domain, even when its matching +`share_x`/`share_y` flag is false. Set `link_select=True` to echo the same +data-space brush predicate and selection highlighting across the facet panels. ## Application-Driven Coordination diff --git a/examples/demo-advance.ipynb b/examples/demo-advance.ipynb index 211d2fe6..717e2da3 100644 --- a/examples/demo-advance.ipynb +++ b/examples/demo-advance.ipynb @@ -270,6 +270,45 @@ ")\n", "chart" ] + }, + { + "cell_type": "markdown", + "id": "3a2e7fb3", + "metadata": {}, + "source": [ + "## Regression check — linked facet interactions\n", + "\n", + "This exercises the linked navigation and selection added by this branch. Drag or wheel-zoom inside one panel: every panel should follow on both axes. Shift-drag a box around points in one panel: the same data-space region should be selected in every panel. Double-click to reset the view and clear the linked selection." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bed0a1fc", + "metadata": {}, + "outputs": [], + "source": [ + "linked_x = np.linspace(0, 10, 50)\n", + "linked_facet_data = {\n", + " \"x\": np.tile(linked_x, 3),\n", + " \"y\": np.concatenate(\n", + " [np.sin(linked_x + phase) + 0.15 * phase for phase in (0.0, 0.6, 1.2)]\n", + " ),\n", + " \"group\": np.repeat([\"alpha\", \"beta\", \"gamma\"], linked_x.size),\n", + "}\n", + "\n", + "xy.facet_chart(\n", + " xy.scatter(x=\"x\", y=\"y\", color=\"#5477c4\", size=8),\n", + " by=\"group\",\n", + " data=linked_facet_data,\n", + " cols=3,\n", + " link=True,\n", + " link_select=True,\n", + " title=\"Pan, zoom, and Shift-drag selection are linked\",\n", + " width=1080,\n", + " height=320,\n", + ")" + ] } ], "metadata": { diff --git a/python/xy/components.py b/python/xy/components.py index 51f4f74d..ec3764ad 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -3219,7 +3219,7 @@ def __init__( cols: int = 3, share_x: bool = True, share_y: bool = True, - link: Optional[str] = None, + link: Optional[Union[str, bool]] = None, link_select: bool = False, gap: int = 12, **props: Any, @@ -3245,8 +3245,10 @@ def __init__( self.cols = int(cols) self.share_x = _strict_bool(share_x, "facet_chart share_x") self.share_y = _strict_bool(share_y, "facet_chart share_y") - if link not in (None, "x", "y", "both"): - raise ValueError("facet_chart link must be None, 'x', 'y', or 'both'") + if isinstance(link, (bool, np.bool_)): + link = "both" if bool(link) else None + elif link not in (None, "x", "y", "both"): + raise ValueError("facet_chart link must be True, False, None, 'x', 'y', or 'both'") self.link = link self.link_select = _strict_bool(link_select, "facet_chart link_select") self.gap = int(gap) @@ -4115,7 +4117,7 @@ def facet_chart( cols: int = 3, share_x: bool = True, share_y: bool = True, - link: Optional[str] = None, + link: Optional[Union[str, bool]] = None, link_select: bool = False, gap: int = 12, **props: Any, @@ -4128,7 +4130,8 @@ def facet_chart( cols: Maximum number of panel columns. share_x: Whether panels share an x domain. share_y: Whether panels share a y domain. - link: Runtime-linked axes: ``None``, ``"x"``, ``"y"``, or ``"both"``. + link: Runtime-linked axes: ``True``/``"both"`` for both axes, + ``"x"`` or ``"y"`` for one axis, and ``False``/``None`` to disable. link_select: Whether data-space selections are echoed across panels. gap: Gap between panels in pixels. **props: Additional shared chart properties. diff --git a/tests/test_facets.py b/tests/test_facets.py index 20f0b0e1..99502f24 100644 --- a/tests/test_facets.py +++ b/tests/test_facets.py @@ -281,8 +281,11 @@ def test_shared_axes_stay_independent_by_default() -> None: assert all("link_group" not in fig.interaction for fig in grid.figures) -@pytest.mark.parametrize(("link", "axes"), [("x", ["x"]), ("y", ["y"]), ("both", ["x", "y"])]) -def test_link_is_explicit_and_selectable(link: str, axes: list[str]) -> None: +@pytest.mark.parametrize( + ("link", "axes"), + [("x", ["x"]), ("y", ["y"]), ("both", ["x", "y"]), (True, ["x", "y"])], +) +def test_link_is_explicit_and_selectable(link: str | bool, axes: list[str]) -> None: grid = xy.facet_chart( xy.line(x="x", y="y"), by="g", data=_table(), link=link, link_select=True ).figure() @@ -291,6 +294,11 @@ def test_link_is_explicit_and_selectable(link: str, axes: list[str]) -> None: assert all(fig.interaction["link_select"] is True for fig in grid.figures) +def test_link_false_disables_runtime_axis_linking() -> None: + grid = xy.facet_chart(xy.line(x="x", y="y"), by="g", data=_table(), link=False).figure() + assert all("link_group" not in fig.interaction for fig in grid.figures) + + def test_link_implies_initial_domain_sharing() -> None: grid = xy.facet_chart( xy.line(x="x", y="y"), by="g", data=_table(), share_x=False, link="x" From 0d1342baa1e3cbbc3186abd80ca92d5d331d30dd Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:09:22 +0500 Subject: [PATCH 4/4] Format linked facets notebook example --- examples/demo-advance.ipynb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/demo-advance.ipynb b/examples/demo-advance.ipynb index 717e2da3..b5558ebc 100644 --- a/examples/demo-advance.ipynb +++ b/examples/demo-advance.ipynb @@ -291,9 +291,7 @@ "linked_x = np.linspace(0, 10, 50)\n", "linked_facet_data = {\n", " \"x\": np.tile(linked_x, 3),\n", - " \"y\": np.concatenate(\n", - " [np.sin(linked_x + phase) + 0.15 * phase for phase in (0.0, 0.6, 1.2)]\n", - " ),\n", + " \"y\": np.concatenate([np.sin(linked_x + phase) + 0.15 * phase for phase in (0.0, 0.6, 1.2)]),\n", " \"group\": np.repeat([\"alpha\", \"beta\", \"gamma\"], linked_x.size),\n", "}\n", "\n",