Skip to content
Open
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
37 changes: 37 additions & 0 deletions docs/components/legends.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,43 @@ using safe built-in chrome, and the same object is also available through
Exact parameters and defaults are in
[Marks and components reference](/docs/xy/api-reference/marks-and-components/).

## Toggle and Isolate Series

Legend rows backed by live series are interactive in the browser; static
exports are unaffected. Hovering a row emphasizes its series by dimming the
others. A single click hides or shows that series or category. A double-click
isolates it — every other row goes hidden — and double-clicking the same row
again brings everything back. With many data-driven categories, isolating is the
one-gesture way to inspect a single series instead of clicking the rest off one
by one.

Each behavior has its own opt-out: `highlight=False`, `toggle=False`, and
`isolate=False`. They are independent — `toggle=False` with the default
`isolate=True` leaves single clicks inert while double-click still isolates.

~~~python demo exec
import reflex_xy
import xy

interactive_legend_chart = xy.line_chart(
xy.line([0, 1, 2, 3, 4], [3, 6, 5, 9, 12], name="Alpha", color="#6e56cf"),
xy.line([0, 1, 2, 3, 4], [2, 4, 7, 8, 10], name="Beta", color="#2563eb"),
xy.line([0, 1, 2, 3, 4], [1, 3, 2, 5, 7], name="Gamma", color="#16a34a"),
xy.line([0, 1, 2, 3, 4], [4, 2, 6, 3, 8], name="Delta", color="#d97706"),
xy.legend(loc="upper left", title="Click to toggle, double-click to isolate"),
xy.x_axis(label="sprint"),
xy.y_axis(label="features shipped"),
title="Interactive legend",
)


def interactive_legend_demo():
return reflex_xy.chart(interactive_legend_chart, height="320px")
~~~

In the browser the chart root dispatches `xy:legendtoggle` for every row whose
visibility changed and `xy:legendisolate` once per double-click gesture.

## FAQ

### How do I add a legend to a chart in Python?
Expand Down
109 changes: 99 additions & 10 deletions js/src/50_chartview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3079,9 +3079,28 @@ export class ChartView {
}
// Click-to-toggle (interaction spec §10): hide/show what the row
// stands for. Same trace-linkage rule keeps extra_legends rows inert.
if (options.toggle !== false && it.traces && it.traces.length) {
const linked = !!(it.traces && it.traces.length);
const toggle = linked && options.toggle !== false;
const isolate = linked && options.isolate !== false;
if (toggle || isolate) {
row.style.cursor = "pointer";
row.addEventListener("click", () => this._legendToggle(it, row));
// A double-click would also select the label text. Suppress only the
// second press, so a single click keeps native focus/selection rules.
row.addEventListener("mousedown", (e) => {
if (e.detail > 1) e.preventDefault();
});
}
if (toggle) {
row.addEventListener("click", (e) => {
// The second click of a double-click belongs to the isolate gesture
// (§10): the first click already committed its toggle, and the
// dblclick handler decides against the pre-gesture state.
if (isolate && e.detail >= 2) return;
this._legendToggle(it, row);
});
}
if (isolate) {
Comment thread
Alek99 marked this conversation as resolved.
row.addEventListener("dblclick", () => this._legendIsolate(it, row, toggle));
}
this._syncLegendRow(row, it);
rows.push({ row, it });
Expand Down Expand Up @@ -3425,11 +3444,75 @@ export class ChartView {
// aggregates and re-request a kernel re-bin computed under the mask.
// Either way the kernel records the state so selections stay truthful.
_legendToggle(it, row) {
const off = !it.off;
it.off = off;
this._clearLegendHover();
this._syncLegendRow(row, it);
this._hideTooltip?.();
const batch = { cats: new Set(), traces: false };
this._legendSetOff(it, row, !it.off, batch);
this._legendApplyBatch(batch);
this._dispatchLegendToggle(it);
this._markBestLegendsDirty();
this.draw();
}

// Legend double-click isolate (interaction spec §10, Plotly's
// `legenddoubleclick` model): show only the double-clicked entry, or —
// when it is already the only entry showing — bring everything back.
// Single clicks commit immediately (no disambiguation delay), so by the
// time `dblclick` fires the gesture's first click has already toggled
// this row; the decision is therefore made against the PRE-gesture state,
// and the end state is exactly what a fresh isolate would have produced.
// Rows change through the same per-row path as a single toggle (kernel
// notified per row, `xy:legendtoggle` per changed row), then one
// `xy:legendisolate` names the gesture. Per-trace category re-filtering
// runs once per affected trace after the whole batch, not once per row.
_legendIsolate(it, row, toggleEnabled) {
const rows = this._legendLinkedRows();
if (!rows.length) return;
const preOff = toggleEnabled ? !it.off : !!it.off;
const othersOff = rows.every((r) => r.it === it || r.it.off);
const restoreAll = !preOff && othersOff;
this._clearLegendHover();
this._hideTooltip?.();
const batch = { cats: new Set(), traces: false };
const changed = [];
for (const r of rows) {
const off = restoreAll ? false : r.it !== it;
if (off === !!r.it.off) continue;
this._legendSetOff(r.it, r.row, off, batch);
changed.push(r.it);
}
if (!changed.length) return;
this._legendApplyBatch(batch);
for (const c of changed) this._dispatchLegendToggle(c);
this._dispatchChartEvent("legendisolate", {
name: it.name,
isolated: !restoreAll,
traces: it.traces.map((ti) => this.spec.traces[ti].id),
...(it.cat != null ? { category: it.cat } : {}),
});
this._markBestLegendsDirty();
this.draw();
}

// Every legend row that stands for live traces, across all legend boxes.
// extra_legends rows carry no trace linkage and never take part.
_legendLinkedRows() {
const out = [];
for (const lg of this._legends || []) {
for (const r of lg._xyItemRows || []) {
if (r.it.traces && r.it.traces.length) out.push(r);
}
}
return out;
}

// One row's visibility: row chrome, the view-held off-sets (survive chrome
// and GPU rebuilds), the per-trace draw/pick flag, and the fire-and-forget
// kernel notification. Category rows only record which trace's predicate
// changed in `batch.cats`; `_legendApplyBatch` re-filters each once.
_legendSetOff(it, row, off, batch) {
it.off = off;
this._syncLegendRow(row, it);
if (it.cat != null) {
const ti = it.traces[0];
let set = this._legendOffCats.get(ti);
Expand All @@ -3441,7 +3524,7 @@ export class ChartView {
type: "legend_toggle", trace: this.spec.traces[ti].id, category: it.cat, hidden: off,
});
}
this._applyCategoryVisibility(ti);
batch.cats.add(ti);
} else {
for (const ti of it.traces) {
if (off) this._legendOffTraces.add(ti);
Expand All @@ -3452,18 +3535,24 @@ export class ChartView {
this.comm.send({ type: "legend_toggle", trace: this.spec.traces[ti].id, hidden: off });
}
}
this._refreshReductionBadges();
batch.traces = true;
}
}

_legendApplyBatch(batch) {
for (const ti of batch.cats) this._applyCategoryVisibility(ti);
if (batch.traces) this._refreshReductionBadges();
this._pickDirty = true;
this._updatePickable();
}

_dispatchLegendToggle(it) {
this._dispatchChartEvent("legendtoggle", {
name: it.name,
hidden: off,
hidden: !!it.off,
traces: it.traces.map((ti) => this.spec.traces[ti].id),
...(it.cat != null ? { category: it.cat } : {}),
});
this._markBestLegendsDirty();
this.draw();
}

_applyCategoryVisibility(ti) {
Expand Down
10 changes: 10 additions & 0 deletions news/506.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Double-clicking a legend entry now isolates its series or category — every
other entry goes hidden — and double-clicking the entry that is already the
only one showing restores them all, matching Plotly's `legenddoubleclick`
model. Single click keeps toggling one entry as before, and `xy.legend(
isolate=False)` opts out independently of `toggle`. Single clicks stay
instant (no disambiguation delay): the client ignores the second click of the
burst and decides against the pre-gesture state, so the result is exactly what
a fresh isolate would give. Each changed row still ships its own
`legend_toggle` message and `xy:legendtoggle` event, and one new
`xy:legendisolate` event names the gesture.
10 changes: 10 additions & 0 deletions python/xy/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ class Legend(Component):
highlight: bool = True
toggle: bool = True
anchor: Optional[tuple[float, ...]] = None
isolate: bool = True


@dataclass
Expand Down Expand Up @@ -3129,6 +3130,7 @@ def legend(
title: Optional[str] = None,
highlight: bool = True,
toggle: bool = True,
isolate: bool = True,
render: Any = None,
class_name: Optional[str] = None,
style: Optional[dict[str, StyleValue]] = None,
Expand All @@ -3148,6 +3150,10 @@ def legend(
dimming the others (live client only; exports are static).
toggle: Whether clicking a legend entry hides/shows its series or
category (live client only; exports are static).
isolate: Whether double-clicking a legend entry isolates its series
or category — hiding every other entry, or restoring all of them
when it is already the only one showing (live client only;
exports are static). Independent of ``toggle``.
render: Opaque renderer supplied by an adapter.
class_name: DOM class name applied to the legend.
style: Legend style overrides.
Expand All @@ -3167,6 +3173,7 @@ def legend(
title=_optional_string(title, "legend title"),
highlight=_strict_bool(highlight, "legend highlight"),
toggle=_strict_bool(toggle, "legend toggle"),
isolate=_strict_bool(isolate, "legend isolate"),
class_name=_optional_string(class_name, "legend class_name"),
style=_style_dict(style, "legend style"),
render=render,
Expand Down Expand Up @@ -4025,6 +4032,9 @@ def figure(self) -> Figure:
if not _strict_bool(node.toggle, "legend toggle"):
# Click-to-toggle likewise defaults on, opt-out only.
fig.legend_options["toggle"] = False
if not _strict_bool(node.isolate, "legend isolate"):
# Double-click isolate: same default-on, opt-out-only rule.
fig.legend_options["isolate"] = False
if node.style:
# Carry the frame/frameon styling into the static-export spec so
# the raster/SVG legend can honor frameon=False (transparent bg).
Expand Down
26 changes: 24 additions & 2 deletions spec/api/interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ aliases for `ranges.x`/`ranges.y` (`50_chartview.ts`, `_eventView`).
| `xy:brush` | `{range: {x0, x1, y0, y1}, view}` for box/axis-range drags, or `{polygon: [[x, y], …], view}` for lasso. |
| `xy:select` | `{total, view}` — the resolved count after the kernel replies, or `total: 0` on clear. |
| `xy:view_change` | `{ranges, source, axes, phase, interaction_id}` plus `x0`/`x1`/`y0`/`y1` aliases, coalesced to one dispatch per animation frame. Fields explained below. |
| `xy:legendtoggle` | `{name, hidden, traces, category?}` — a legend row hid or showed what it stands for (§10). One per changed row, including every row a double-click isolate changes. |
| `xy:legendisolate` | `{name, isolated, traces, category?}` — a legend double-click isolated one entry (`isolated: true`) or restored every entry (`false`); dispatched after that gesture's per-row `xy:legendtoggle` events (§10). |

A view event carries four fields beyond `ranges`/aliases. `source` names the
input that caused the change — exactly one of `pan_drag`, `wheel_zoom`,
Expand Down Expand Up @@ -205,8 +207,8 @@ They carry no `view`, and no interaction switch gates them:
| `xy:context_restored` | `{loss_count, restore_count}` — a live frame is back (`:775`). |
| `xy:context_restore_failed` | `{loss_count, message}` — recovery gave up; the root is replaced with an error string (`:761`). |

Those nine are the whole `xy:` surface — every one goes through
`_dispatchChartEvent` (`50_chartview.ts:451`), and there is no other
Those eleven are the whole `xy:` surface — every one goes through
`_dispatchChartEvent` (`50_chartview.ts`), and there is no other
`CustomEvent` dispatch in `js/src/`.

Kernel-side callbacks (`python/xy/channel.py`), wired through
Expand Down Expand Up @@ -471,6 +473,26 @@ linkage). A toggled-off row fades (35%, grayscale, `data-xy-legend-off`
attribute for author styling) and is inert for §9 hover emphasis. The DOM
event `xy:legendtoggle` fires with `{name, hidden, traces, category?}`.

**Double-click isolate** (Plotly's `legenddoubleclick` model). Double-clicking
a linked row shows only that entry — every other linked row, across all
legend boxes, goes hidden; double-clicking the row that is already the only
one showing restores every entry. Gating: `xy.legend(isolate=False)`, same
default-on, opt-out-only wire rule, and independent of `toggle`: with
`toggle=False` single clicks are inert while double-click still isolates.
Single clicks commit immediately — there is no Plotly-style 300 ms
disambiguation delay — so the gesture's first click has already toggled the
row when `dblclick` fires; the client ignores the second click of the burst
(`event.detail >= 2`) and decides isolate-vs-restore against the
*pre-gesture* state, so the end state is exactly what a fresh isolate
produces. The transient first-click frame is the recorded cost of keeping
single clicks instant. Each row that changes goes through the ordinary
toggle path — one `legend_toggle` message and one `xy:legendtoggle` per
row — and category re-filtering (below) runs once per affected trace after
the whole batch. One `xy:legendisolate {name, isolated, traces, category?}`
then names the gesture; it does not fire when nothing changed. The second
press of a double-click is `preventDefault`ed on `mousedown` so the label
text is not selected; single presses keep native focus and selection rules.

**State sync.** Every toggle sends `legend_toggle {trace, category?,
hidden}` — fire-and-forget, no reply. The kernel records `Trace.hidden` /
`Trace.hidden_categories`; from then on `select`/`select_polygon` exclude
Expand Down
5 changes: 4 additions & 1 deletion spec/design/wire-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Every request is a dict with a `type`. Coordinate fields are JSON numbers in
| `select` | `x0`, `x1`, `y0`, `y1` | `selection` |
| `select_polygon` | `points` | `selection` |
| `select_clear` | — | `selection` (empty) |
| `legend_toggle` | `trace`, `hidden`, `category?` | none (fire-and-forget; see below) |
| `animation_start` | `phase` | none (`on_animation_start`) |
| `animation_end` | `phase`, `cancelled?` | none (`on_animation_end`) |

Expand Down Expand Up @@ -209,7 +210,9 @@ a masked reply's `binning` gains a `-masked` suffix and the trace entry
carries `filter: {hidden_categories: [...]}` — the filter state it was
computed under (§37 filter_hash-lite) — which the client compares against
its own current set, dropping stale-predicate replies (interaction spec
§10). So that category rows exist to click at all, a categorical
§10). A legend double-click isolate (interaction spec §10) is no message of
its own: it ships one `legend_toggle` per row whose visibility changed, in
legend order. So that category rows exist to click at all, a categorical
density-tier first-paint entry carries a **slim** `color` spec —
`{mode: "categorical", categories, palette}`, no per-point `buf` (the codes
aggregated into the mean-color plane) — which legend chrome consumes and
Expand Down
Loading
Loading