Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/guides/dashboards-and-linked-views.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,13 @@ 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"`. `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

Expand Down
37 changes: 37 additions & 0 deletions examples/demo-advance.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,43 @@
" height=520,\n",
")"
]
},
{
"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([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",
"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": {
Expand Down
19 changes: 18 additions & 1 deletion js/src/50_chartview.js
Original file line number Diff line number Diff line change
Expand Up @@ -484,10 +484,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")) {
Expand All @@ -509,6 +521,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)) {
Expand Down
41 changes: 25 additions & 16 deletions js/src/53_interaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,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 });
Expand All @@ -580,6 +581,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,
Expand All @@ -592,7 +594,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);
Expand Down Expand Up @@ -628,15 +630,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).
Expand All @@ -657,11 +661,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) {
Expand All @@ -672,16 +678,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") });
}
}
},

Expand Down
5 changes: 4 additions & 1 deletion python/xy/_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,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 (
Expand All @@ -266,6 +267,7 @@ def set_interaction(
("pan", pan),
("zoom", zoom),
("view_change", view_change),
("link_select", link_select),
):
normalized = self._optional_bool(value, f"interaction {name}")
if normalized is not None:
Expand Down Expand Up @@ -487,7 +489,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:
Expand Down Expand Up @@ -1053,6 +1055,7 @@ def _interaction_spec(self) -> dict[str, Any]:
"pan",
"zoom",
"view_change",
"link_select",
):
if name in self.interaction:
spec[name] = self._bool_param(self.interaction[name], f"interaction {name}")
Expand Down
50 changes: 44 additions & 6 deletions python/xy/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -3519,6 +3519,8 @@ def __init__(
cols: int = 3,
share_x: bool = True,
share_y: bool = True,
link: Optional[Union[str, bool]] = None,
link_select: bool = False,
gap: int = 12,
**props: Any,
) -> None:
Expand All @@ -3543,6 +3545,12 @@ 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 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)
self.props = dict(props)
self._grid: Any = None
Expand Down Expand Up @@ -3595,7 +3603,16 @@ 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
Expand Down Expand Up @@ -3647,11 +3664,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,
Expand Down Expand Up @@ -4458,6 +4483,8 @@ def facet_chart(
cols: int = 3,
share_x: bool = True,
share_y: bool = True,
link: Optional[Union[str, bool]] = None,
link_select: bool = False,
gap: int = 12,
**props: Any,
) -> FacetChart:
Expand All @@ -4469,9 +4496,20 @@ 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: ``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.
"""
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,
)
Loading
Loading