From 775ee46d268b6ca240a17568ebf692da6f4a2f3d Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 1 Sep 2026 21:13:29 -0700 Subject: [PATCH 1/2] Isolate a series on legend double-click (Plotly-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Double-clicking a legend row now shows only that series or category; a second double-click on the row that is already alone restores every entry (Plotly's legenddoubleclick model). Single click keeps toggling exactly as before. `xy.legend(isolate=False)` opts out, following the same default-on, opt-out-only wire rule as `toggle` and `highlight`, and is independent of `toggle`: with `toggle=False` single clicks stay inert while double-click still isolates. Single clicks commit immediately -- no Plotly-style 300 ms disambiguation delay -- so by the time `dblclick` fires the gesture's first click has already toggled the row. 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 would produce. Each changed row goes through the ordinary toggle path (one `legend_toggle` message and one `xy:legendtoggle` per row, in legend order); category re-filtering runs once per affected trace after the whole batch; one new `xy:legendisolate {name, isolated, traces, category?}` event names the gesture. The second press is preventDefault'ed on mousedown so the label text is not selected. Spec: interaction.md §10 documents the gesture and its recorded limits, and the §3 event table now lists `xy:legendtoggle` (it was dispatched but missing from the "whole surface" list) and `xy:legendisolate`; wire-protocol.md notes isolate is not a message of its own. Docs gain a "Toggle and Isolate Series" section. Browser probes cover the default, `isolate=False` (byte-identical pre-existing toggle behavior) and `toggle=False` paths, asserting buffers, row state, wire messages and events. --- docs/components/legends.md | 37 ++++++ js/src/50_chartview.ts | 109 ++++++++++++++++-- python/xy/components.py | 10 ++ spec/api/interaction.md | 26 ++++- spec/design/wire-protocol.md | 5 +- tests/test_legend_toggle.py | 216 ++++++++++++++++++++++++++++++++++- 6 files changed, 389 insertions(+), 14 deletions(-) diff --git a/docs/components/legends.md b/docs/components/legends.md index 923e948a..36d9c965 100644 --- a/docs/components/legends.md +++ b/docs/components/legends.md @@ -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? diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 2c6df776..0a5932fa 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -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) { + row.addEventListener("dblclick", () => this._legendIsolate(it, row, toggle)); } this._syncLegendRow(row, it); rows.push({ row, it }); @@ -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); @@ -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); @@ -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) { diff --git a/python/xy/components.py b/python/xy/components.py index f365432d..13726732 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -278,6 +278,7 @@ class Legend(Component): highlight: bool = True toggle: bool = True anchor: Optional[tuple[float, ...]] = None + isolate: bool = True @dataclass @@ -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, @@ -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. @@ -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, @@ -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). diff --git a/spec/api/interaction.md b/spec/api/interaction.md index 33870d82..8a92442e 100644 --- a/spec/api/interaction.md +++ b/spec/api/interaction.md @@ -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`, @@ -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 @@ -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 diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index c44fd406..8f67b6bd 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -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`) | @@ -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 diff --git a/tests/test_legend_toggle.py b/tests/test_legend_toggle.py index 1bd2435b..4b720144 100644 --- a/tests/test_legend_toggle.py +++ b/tests/test_legend_toggle.py @@ -10,7 +10,11 @@ Client side: clicking a legend row hides the series — direct-tier categorical traces re-filter their vertex buffers locally from retained CPU columns (`_visMap` translates picks back to shipped rows), whole -traces skip draw+pick, and the kernel is notified either way. +traces skip draw+pick, and the kernel is notified either way. Double- +clicking a row isolates it (Plotly's `legenddoubleclick`): every other +linked row goes hidden, or all come back when it was already alone — +decided against the pre-gesture state, since the burst's first click has +already committed a toggle (`xy.legend(isolate=False)` opts out). Browser probes skip (never fail) without Chromium, like the repo's others. """ @@ -176,6 +180,26 @@ def test_legend_toggle_option() -> None: xy.legend(toggle="yes") +def test_legend_isolate_option() -> None: + """`isolate` rides the wire the way `toggle`/`highlight` do: default on, + only the opt-out is shipped, so existing specs stay byte-identical.""" + data = {"x": np.arange(8.0), "y": np.arange(8.0)} + disabled = xy.scatter_chart(xy.scatter("x", "y", data=data), xy.legend(isolate=False)) + assert disabled.figure().legend_options["isolate"] is False + default = xy.scatter_chart(xy.scatter("x", "y", data=data), xy.legend()) + assert "isolate" not in default.figure().legend_options + # The knobs are independent: opting out of one leaves the others alone. + only_toggle_off = xy.scatter_chart( + xy.scatter("x", "y", data=data), xy.legend(toggle=False) + ).figure() + assert only_toggle_off.legend_options["toggle"] is False + assert "isolate" not in only_toggle_off.legend_options + with pytest.raises(ValueError): + xy.legend(isolate="yes") + # Public dataclass: the new field appends after the released order. + assert xy.Legend(False, "upper right", 2, "Title", "c", {}, None, False, False).isolate is True + + _TOGGLE_PROBE = """ +""" + + +def _isolate_chart(**legend_kwargs): + codes = np.array(["A", "A", "A", "A", "A", "B", "B", "B"]) + data = {"x": np.arange(8.0), "y": np.arange(8.0)} + return xy.scatter_chart( + xy.scatter("x", "y", data=data, name="alpha"), + xy.scatter("x", "y", data=data, color=codes), + xy.legend(**legend_kwargs), + width=520, + height=340, + ) + + +def _run_isolate_probe(chart, label: str) -> dict: + chromium = find_chromium() + if not chromium: + pytest.skip("no chromium available for the legend isolate probe") + document = probe_document(chart, _ISOLATE_PROBE) + with tempfile.TemporaryDirectory() as td: + page = Path(td) / "legend_isolate.html" + return run_browser_probe(chromium, document, page, "data-xy-legend-isolate", label=label) + + +def _kinds(sent: list[dict]) -> list[tuple]: + return [(m.get("trace"), m.get("category"), m.get("hidden")) for m in sent] + + +def test_browser_legend_double_click_isolates_series() -> None: + payload = _run_isolate_probe(_isolate_chart(), "legend isolate") + steps = payload["steps"] + assert steps["start"]["offRows"] == [] and steps["start"]["catN"] == 8, steps + + # Double-click A: only category A stays — alpha and B hide, A's buffers + # hold its five rows. The burst's first click had hidden A; the isolate + # brought it back, so A itself is one of the changed rows. + s = steps["isolateA"] + assert s["alphaHidden"] is True and s["offRows"] == ["alpha", "B"], s + assert s["catN"] == 5, s + assert s["isolateEvents"] == 1, s + # Double-click A again while it is the only one showing: everything back. + s = steps["restore"] + assert s["alphaHidden"] is False and s["offRows"] == [] and s["catN"] == 8, s + assert s["isolateEvents"] == 2, s + + # A partly-hidden chart isolates the same way, and restores alpha too. + assert steps["alphaOff"]["offRows"] == ["alpha"], steps["alphaOff"] + s = steps["isolateB"] + assert s["alphaHidden"] is True and s["offRows"] == ["alpha", "A"] and s["catN"] == 3, s + s = steps["restore2"] + assert s["alphaHidden"] is False and s["offRows"] == [] and s["catN"] == 8, s + assert s["isolateEvents"] == 4, s + + # Kernel sync, message by message. Trace 0 = alpha; trace 1 categories + # 0 = A, 1 = B. The second click of each burst ships nothing. + assert _kinds(payload["sent"]) == [ + (1, 0, True), # dbl(A): first click toggles A off + (0, None, True), # isolate, legend order: alpha off, A back on, B off + (1, 0, False), + (1, 1, True), + (1, 0, True), # dbl(A) again: first click toggles A off + (0, None, False), # restore all, legend order + (1, 0, False), + (1, 1, False), + (0, None, True), # click(alpha) + (1, 1, True), # dbl(B): first click toggles B off + (1, 0, True), # isolate B: A off, B back on (alpha already off) + (1, 1, False), + (1, 1, True), # dbl(B) again: first click toggles B off + (0, None, False), # restore all + (1, 0, False), + (1, 1, False), + ], payload["sent"] + # One xy:legendtoggle per changed row (matches the wire), one + # xy:legendisolate per gesture naming the row and the direction. + assert len(payload["toggles"]) == len(payload["sent"]), payload["toggles"] + assert [(e["name"], e["isolated"], e.get("category")) for e in payload["isolates"]] == [ + ("A", True, 0), + ("A", False, 0), + ("B", True, 1), + ("B", False, 1), + ], payload["isolates"] + assert payload["isolates"][0]["traces"] == [1], payload["isolates"][0] + + +def test_browser_legend_isolate_opt_out_keeps_plain_toggles() -> None: + """`isolate=False`: two rapid clicks are two toggles (net no change) and + the dblclick itself does nothing — the pre-#505 behavior, byte for byte + on the wire.""" + payload = _run_isolate_probe(_isolate_chart(isolate=False), "legend isolate off") + steps = payload["steps"] + s = steps["isolateA"] + assert s["alphaHidden"] is False and s["offRows"] == [] and s["catN"] == 8, s + assert s["isolateEvents"] == 0, s + assert _kinds(payload["sent"])[:2] == [(1, 0, True), (1, 0, False)], payload["sent"] + assert steps["restore2"]["isolateEvents"] == 0, steps["restore2"] + assert steps["restore2"]["offRows"] == ["alpha"], steps["restore2"] # the lone single click + + +def test_browser_legend_isolate_without_toggle() -> None: + """`toggle=False` leaves single clicks inert but double-click still + isolates, deciding against the (unchanged) current state.""" + payload = _run_isolate_probe(_isolate_chart(toggle=False), "legend isolate no toggle") + steps = payload["steps"] + s = steps["isolateA"] + assert s["alphaHidden"] is True and s["offRows"] == ["alpha", "B"] and s["catN"] == 5, s + s = steps["restore"] + assert s["offRows"] == [] and s["catN"] == 8 and s["isolateEvents"] == 2, s + # The single click on alpha was inert, so isolate B hides alpha itself. + assert steps["alphaOff"]["offRows"] == [], steps["alphaOff"] + assert _kinds(payload["sent"]) == [ + (0, None, True), + (1, 1, True), + (0, None, False), + (1, 1, False), + (0, None, True), + (1, 0, True), + (0, None, False), + (1, 0, False), + ], payload["sent"] + + # --- the visible-row cache is bounded, and it is reported (§27) -------------- From a668818c35cfe50c012f5e6e986f584e9a674988 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 1 Sep 2026 21:18:24 -0700 Subject: [PATCH 2/2] Add news fragment for legend double-click isolate (#506) --- news/506.feature.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 news/506.feature.md diff --git a/news/506.feature.md b/news/506.feature.md new file mode 100644 index 00000000..84fbd73c --- /dev/null +++ b/news/506.feature.md @@ -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.