Greetings,
I have a Figure object with many traces such that the legend is quite unruly. Is there (or could there eventually be) any way to introduce a search bar for the legends without using Dash? Thanks!
Greetings,
I have a Figure object with many traces such that the legend is quite unruly. Is there (or could there eventually be) any way to introduce a search bar for the legends without using Dash? Thanks!
Agree this would be extremely useful! (have not found this functionality to date)
The temporary workaround I use for this w/ a >~1500 item legend involves “serializing the legend with a prefix”, combined with using hover to display that column. It allows you to get to a specific row in the legend easily/quickly/ repeatably
unq_legend_df = pd.DataFrame(your_df['col_choice'].value_counts()).reset_index().sort_values('col_choice').reset_index(drop=True)
unq_legend_df['col_choice_pre'] = unq_legend_df.index.astype(str)+'_'+unq_legend_df['col_choice']
new_df = pd.merge(your_df, unq_legend_df, how='left',on='col_choice').sort_values('col_choice')
col_choice_pre
#aka this column ==== new_df['col_choice_pre']
Do you have an example of what this looks like?
It looks like someone brighter than me could probably code something up fairly easily in BeautifulSoup that adds the right JavaScript functionality for this. I, myself am able to play with the div elements’ attributes and toggle traces on/off. The question would be, what is the easiest way to accomplish the goal i.e. does the search bar itself toggle the traces or does it change what is displayed in the legend and the user toggles normally. This would be a fun little project
See below. Though the example only shows 5 lines, you can imagine with 1500+ lines/ scatter series representing lines, allows you to:
import pandas as pd
import plotly.express as px
#build the dummy data list
import itertools
import numpy as np
val = list(range(11))
x_lst = list(itertools.chain(val,val,val,val,val))
y_lst = list(itertools.chain(val,list(np.array(val)*-1),list(np.array(val)*2),reversed(val), reversed(list(np.array(val)*2))))
col_lst = list(itertools.chain(['aaa']*11,['bbb']*11,['ccc']*11,['ddd']*11,['eee']*11))
your_df = pd.DataFrame({'x_val':x_lst,'y_val':y_lst,'col_choice':col_lst})
#the useful part from above
unq_legend_df = pd.DataFrame(your_df['col_choice'].value_counts()).reset_index().sort_values('col_choice').reset_index(drop=True)
unq_legend_df['col_choice_pre'] = unq_legend_df.index.astype(str)+'_'+unq_legend_df['col_choice']
new_df = pd.merge(your_df, unq_legend_df, how='left',on='col_choice').sort_values('col_choice')
#the figure
fig = px.scatter(new_df, x='x_val',y='y_val',title='Serializing the Legend - Example', color = 'col_choice_pre')
fig.show()
I just had Claude do it for me:
"""
plotly_legend_search
====================
Adds a search box to the legend of a Plotly figure exported to HTML. No Dash,
no server: the search is a small piece of JavaScript injected through Plotly's
``post_script`` hook, so it ships inside the HTML file you distribute.
Quick start
-----------
import plotly.graph_objects as go
from plotly_legend_search import legend_search
fig = go.Figure(...)
legend_search(fig) # tags the figure in place (also returns it)
fig.write_html("plot.html") # search box is baked in
fig.show() # also works with HTML-based renderers
Other entry points
------------------
* ``legend_search_script(**opts)`` -> the raw JS, for
``fig.write_html(..., post_script=legend_search_script())`` or ``pio.to_html``.
* ``install(**opts)`` / ``uninstall()`` -> patch *every* ``go.Figure`` so all
``to_html`` / ``write_html`` / ``show`` calls get the search box.
What the box does
-----------------
* Typing filters which traces are listed in the legend (traces stay plotted).
Matching is case-insensitive substring by default; space-separated terms are
ANDed, ``-term`` excludes, and ``/regex/i`` switches to a regular expression.
* Enter -> show only the matching traces (others become "legendonly")
* Shift+Enter -> hide the matching traces
* Esc -> clear the search
* Buttons (optional) do the same as Enter / Shift+Enter, plus "show all".
Pie / funnelarea traces (whose legend entries are slices, not traces) are left
alone. Requires plotly.js >= 2.0 (plotly.py >= 5).
"""
from __future__ import annotations
import inspect
import json
import math
from contextlib import contextmanager
from typing import Optional, Sequence, Union
import plotly.graph_objects as go
import plotly.io as pio
__all__ = [
"legend_search",
"legend_search_script",
"LegendSearchFigure",
"install",
"uninstall",
]
_SENTINEL_ATTR = "_legend_search_opts"
_EM = "\u2003" # em space: one em wide, survives SVG whitespace collapsing
_DEFAULTS = dict(
placeholder="Search traces…",
width=200, # px, total width of the search row (input + buttons)
height=None, # px, row height; None -> derived from the legend font size
buttons=True, # show "only / hide / all" buttons next to the input
legend="legend", # which legend hosts the box ("legend", "legend2", ...)
scope="all", # "all" -> filter traces in every legend; "legend" -> host legend only
match_on=("name", "legendgroup", "legendgrouptitle"),
case_sensitive=False,
debounce=120, # ms between the last keystroke and the legend update
query="", # optional initial search text
)
# --------------------------------------------------------------------------- #
# JavaScript (runs once per plot, after Plotly.newPlot resolves)
# --------------------------------------------------------------------------- #
_JS_TEMPLATE = r"""
(function () {
"use strict";
var OPTS = __OPTS__;
var gd = document.getElementById("{plot_id}");
var P = window.Plotly;
if (!gd || !P || gd.__legendSearch) return;
var fl = gd._fullLayout || {};
var LEG = OPTS.legend || "legend";
var legOpts = fl[LEG];
if (!fl.showlegend || !legOpts || legOpts.visible === false) return;
/* ---------------- helpers ---------------- */
var decoder = document.createElement("textarea");
function plain(s) {
if (s == null) return "";
decoder.innerHTML = String(s).replace(/<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, "");
return decoder.value;
}
var cv = document.createElement("canvas").getContext("2d");
function rgba(color, a) {
cv.fillStyle = "#000"; cv.fillStyle = color || "#000";
var c = cv.fillStyle, r, g, b, m;
if (c.charAt(0) === "#") {
r = parseInt(c.substr(1, 2), 16); g = parseInt(c.substr(3, 2), 16); b = parseInt(c.substr(5, 2), 16);
} else if ((m = c.match(/[\d.]+/g))) { r = +m[0]; g = +m[1]; b = +m[2]; }
return "rgba(" + r + "," + g + "," + b + "," + a + ")";
}
function isTransparent(color) {
cv.fillStyle = "#000"; cv.fillStyle = color || "#000";
var m = String(cv.fillStyle).match(/rgba\([^)]*,\s*([\d.]+)\)/);
return !!m && +m[1] === 0;
}
/* ---------------- collect searchable traces ---------------- */
var PER_LABEL = { pie: 1, funnelarea: 1 };
var items = [], seen = {};
(gd._fullData || []).forEach(function (fd) {
var i = fd.index;
if (i == null || seen[i] || i >= gd.data.length) return;
if (PER_LABEL[fd.type] || fd.visible === false || !fd.showlegend) return;
if (OPTS.scope !== "all" && (fd.legend || "legend") !== LEG) return;
seen[i] = 1;
var parts = [];
if (OPTS.match_on.indexOf("name") >= 0) parts.push(plain(fd.name));
if (OPTS.match_on.indexOf("legendgroup") >= 0) parts.push(plain(fd.legendgroup));
if (OPTS.match_on.indexOf("legendgrouptitle") >= 0 && fd.legendgrouptitle)
parts.push(plain(fd.legendgrouptitle.text));
var text = parts.join("\u0001");
items.push({ i: i, text: OPTS.case_sensitive ? text : text.toLowerCase(), shown: true });
});
if (!items.length) return;
gd.__legendSearch = true;
/* ---------------- reserve room in the legend title ---------------- */
var fontSize = (legOpts.font && legOpts.font.size) || 12;
var H = OPTS.height || Math.max(20, Math.round(fontSize * 1.85));
var EM = " ";
var nEm = Math.ceil(OPTS.width / H);
var spacer = '<span style="font-size:' + H + 'px">' + new Array(nEm + 1).join(EM) + "</span>";
var origTitle = (legOpts.title && legOpts.title.text) || "";
// The Python wrapper writes the spacer into the figure before export, so it
// is drawn by the very first render. Only the raw-script path needs a relayout.
var prepared = origTitle.indexOf(EM) >= 0;
var upd = null;
if (!prepared) {
upd = { showlegend: true };
upd[LEG + ".title.text"] = origTitle ? spacer + "<br>" + origTitle : spacer;
}
// Legends with a single entry auto-hide; force it on with the first filter.
var legendForced = !!upd || (gd.layout && gd.layout.showlegend === true);
/* ---------------- build the widget ---------------- */
if (!document.getElementById("pls-style")) {
var st = document.createElement("style");
st.id = "pls-style";
st.textContent = [
".pls-box{position:absolute;z-index:3;display:flex;color:var(--pls-fg);align-items:center;gap:3px;box-sizing:border-box;line-height:normal}",
".pls-pinned{background:var(--pls-bg);box-shadow:0 0 0 3px var(--pls-bg),0 4px 6px -2px var(--pls-ring)}",
".pls-field{position:relative;flex:1 1 auto;min-width:0;height:100%}",
".pls-input{width:100%;height:100%;box-sizing:border-box;margin:0;padding:0 .5em 0 1.9em;border:1px solid var(--pls-border);",
"border-radius:4px;background:var(--pls-bg);color:var(--pls-fg);font:inherit;outline:none;-webkit-appearance:none;appearance:none}",
".pls-has-count .pls-input{padding-right:4.2em}",
".pls-input::placeholder{color:var(--pls-fg);opacity:.5}",
".pls-input:focus{border-color:var(--pls-focus);box-shadow:0 0 0 2px var(--pls-ring)}",
".pls-input::-webkit-search-cancel-button{display:none}",
".pls-ico{position:absolute;left:.55em;top:50%;width:1em;height:1em;transform:translateY(-50%);opacity:.55;pointer-events:none}",
".pls-count{position:absolute;right:.55em;top:50%;transform:translateY(-50%);font-size:.85em;opacity:.6;pointer-events:none;white-space:nowrap}",
".pls-btn{flex:0 0 auto;height:100%;padding:0;box-sizing:border-box;display:flex;align-items:center;justify-content:center;",
"border:1px solid var(--pls-border);border-radius:4px;background:var(--pls-bg);color:var(--pls-fg);cursor:pointer;opacity:.8}",
".pls-btn:hover{opacity:1;border-color:var(--pls-focus)}",
".pls-btn svg{width:58%;height:58%}",
".pls-nomatch .pls-input{border-color:#d62728}.pls-nomatch .pls-count{color:#d62728;opacity:1}",
".pls-empty .infolayer .traces,.pls-empty .infolayer .scrollbar{visibility:hidden}",
"@media print{.pls-box{display:none}}"
].join("");
document.head.appendChild(st);
}
var fg = (legOpts.font && legOpts.font.color) || (fl.font && fl.font.color) || "#444";
var bg = legOpts.bgcolor && !isTransparent(legOpts.bgcolor) ? legOpts.bgcolor : fl.paper_bgcolor || "#fff";
var box = document.createElement("div");
box.className = "pls-box";
box.style.cssText =
"--pls-fg:" + fg + ";--pls-bg:" + bg + ";--pls-border:" + rgba(fg, 0.3) +
";--pls-focus:" + rgba(fg, 0.6) + ";--pls-ring:" + rgba(fg, 0.15) +
";font-family:" + ((legOpts.font && legOpts.font.family) || "sans-serif").replace(/"/g, "'") +
";font-size:" + fontSize + "px;height:" + H + "px;visibility:hidden;";
var SVG = 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"';
var field = document.createElement("div");
field.className = "pls-field";
field.innerHTML = '<svg class="pls-ico" ' + SVG + '><circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.5 15.5L21 21"/></svg>';
var input = document.createElement("input");
input.type = "search";
input.className = "pls-input";
input.placeholder = OPTS.placeholder;
input.spellcheck = false;
input.autocomplete = "off";
input.setAttribute("aria-label", "Search legend");
var count = document.createElement("span");
count.className = "pls-count";
field.appendChild(input);
field.appendChild(count);
box.appendChild(field);
function button(title, path, fn) {
var b = document.createElement("button");
b.type = "button";
b.className = "pls-btn";
b.title = title;
b.setAttribute("aria-label", title);
b.style.width = H + "px";
b.innerHTML = "<svg " + SVG + ">" + path + "</svg>";
b.addEventListener("click", function (e) { e.preventDefault(); fn(); });
box.appendChild(b);
}
var EYE = '<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>';
if (OPTS.buttons) {
button("Show only matches (Enter)", EYE, function () { isolate(); });
button("Hide matches (Shift+Enter)", EYE + '<path d="M4 4l16 16"/>', function () { hideMatches(); });
button("Show all traces", '<path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/>', function () { showAll(); });
}
["mousedown", "pointerdown", "click", "dblclick", "wheel", "keydown"].forEach(function (ev) {
box.addEventListener(ev, function (e) { e.stopPropagation(); }, { passive: ev === "wheel" });
});
if (getComputedStyle(gd).position === "static") gd.style.position = "relative";
gd.appendChild(box);
/* ---------------- positioning ---------------- */
function place() {
var leg = gd.querySelector("g." + LEG);
var title = leg && leg.querySelector(".legendtitletext");
if (!title) return;
var anchor = null, spans = title.querySelectorAll("tspan");
for (var k = 0; k < spans.length; k++) {
if (spans[k].textContent.indexOf(EM) >= 0 && !spans[k].querySelector("tspan")) { anchor = spans[k]; break; }
}
var r = (anchor || title).getBoundingClientRect(), g = gd.getBoundingClientRect();
if (!r.width) return;
var top = r.top - g.top + (r.height - H) / 2;
// The title scrolls with long legends: pin the box to the legend's top edge.
var bg = leg.querySelector("rect.bg");
var bgr = bg && bg.getBoundingClientRect();
var pinned = false;
if (bgr && bgr.height) {
var minTop = bgr.top - g.top + 2;
if (top < minTop) { top = minTop; pinned = true; }
}
box.classList.toggle("pls-pinned", pinned);
box.style.left = Math.round(r.left - g.left) + "px";
box.style.top = Math.round(top) + "px";
box.style.width = Math.max(H * 3, Math.round(r.width)) + "px";
box.style.visibility = "visible";
}
var raf = 0;
function schedulePlace() { cancelAnimationFrame(raf); raf = requestAnimationFrame(place); }
gd.on("plotly_afterplot", schedulePlace);
window.addEventListener("resize", schedulePlace);
// legends that overflow scroll their contents; follow the title if it moves
if (window.MutationObserver) {
new MutationObserver(schedulePlace).observe(gd, { attributes: true, subtree: true, attributeFilter: ["transform"] });
}
/* ---------------- matching ---------------- */
var matcher = function () { return true; }, query = "";
function compile(q) {
query = q;
if (!q) return function () { return true; };
var rx = q.match(/^\/(.+)\/([a-z]*)$/);
if (rx) {
var flags = rx[2].replace(/[gy]/g, "");
var re = new RegExp(rx[1], flags); // may throw -> caller shows error
return function (t) { return re.test(t); };
}
var terms = (OPTS.case_sensitive ? q : q.toLowerCase()).split(/\s+/).filter(Boolean);
var inc = [], exc = [];
terms.forEach(function (t) { if (t.length > 1 && t.charAt(0) === "-") exc.push(t.slice(1)); else inc.push(t); });
return function (t) {
for (var a = 0; a < inc.length; a++) if (t.indexOf(inc[a]) < 0) return false;
for (var b = 0; b < exc.length; b++) if (t.indexOf(exc[b]) >= 0) return false;
return true;
};
}
function apply() {
var q = input.value.trim();
try { matcher = compile(q); }
catch (err) { box.classList.add("pls-nomatch", "pls-has-count"); count.textContent = "bad regex"; return; }
var n = 0;
items.forEach(function (it) { it.match = matcher(it.text); if (it.match) n++; });
count.textContent = q ? n + "/" + items.length : "";
box.classList.toggle("pls-has-count", !!q);
var none = !!q && n === 0;
box.classList.toggle("pls-nomatch", none);
gd.classList.toggle("pls-empty", none); // keep the legend (and the box) alive
if (none) return;
var idx = [], vals = [];
items.forEach(function (it) {
if (it.shown !== it.match) { idx.push(it.i); vals.push(it.match); it.shown = it.match; }
});
if (!idx.length) return;
// scroll a long legend back to the top so the first match is in view
if (gd.layout[LEG]) delete gd.layout[LEG]._scrollY;
if (gd._fullLayout[LEG]) delete gd._fullLayout[LEG]._scrollY;
if (legendForced) P.restyle(gd, { showlegend: vals }, idx);
else { legendForced = true; P.update(gd, { showlegend: vals }, { showlegend: true }, idx); }
}
function setVisible(fn) {
var idx = [], vals = [];
items.forEach(function (it) {
var v = fn(it);
if (v !== undefined) { idx.push(it.i); vals.push(v); }
});
if (idx.length) P.restyle(gd, { visible: vals }, idx);
}
function current() { apply(); return query; }
function isolate() {
if (!current()) return showAll();
setVisible(function (it) { return it.match ? true : "legendonly"; });
}
function hideMatches() {
if (!current()) return;
setVisible(function (it) { return it.match ? "legendonly" : undefined; });
}
function showAll() { setVisible(function () { return true; }); }
/* ---------------- legend double-click (isolate) ---------------- */
// Plotly's isolate leaves traces with showlegend:false visible, treating them
// as non-legend decorations. Filtered-out traces are hidden via showlegend, so
// Plotly would wrongly keep them on. The event fires synchronously just before
// Plotly's handler runs, so briefly show it their real legend state.
gd.on("plotly_legenddoubleclick", function () {
var hidden = {}, any = false;
items.forEach(function (it) { if (!it.shown) { hidden[it.i] = 1; any = true; } });
if (!any) return;
var patched = [];
(gd._fullData || []).forEach(function (fd) {
if (hidden[fd.index] && fd.showlegend === false) { fd.showlegend = true; patched.push(fd); }
});
// Plotly's restyle rebuilds _fullData from gd.data (where the filter still
// lives); restoring these objects only matters if nothing was redrawn.
setTimeout(function () { patched.forEach(function (fd) { fd.showlegend = false; }); }, 0);
});
var timer = 0;
input.addEventListener("input", function () {
clearTimeout(timer);
timer = setTimeout(apply, items.length > 300 ? Math.max(OPTS.debounce, 250) : OPTS.debounce);
});
input.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); clearTimeout(timer); e.shiftKey ? hideMatches() : isolate(); }
else if (e.key === "Escape") {
if (input.value) { input.value = ""; clearTimeout(timer); apply(); } else input.blur();
}
});
input.addEventListener("search", function () { if (!input.value) { clearTimeout(timer); apply(); } });
/* ---------------- go ---------------- */
function start() {
place();
if (OPTS.query) { input.value = OPTS.query; apply(); }
}
if (upd) P.relayout(gd, upd).then(start); else start();
})();
"""
def _normalize_opts(**opts) -> dict:
unknown = set(opts) - set(_DEFAULTS)
if unknown:
raise TypeError(f"Unknown legend_search option(s): {', '.join(sorted(unknown))}")
out = dict(_DEFAULTS, **opts)
if isinstance(out["match_on"], str):
out["match_on"] = [out["match_on"]]
out["match_on"] = list(out["match_on"])
bad = set(out["match_on"]) - {"name", "legendgroup", "legendgrouptitle"}
if bad:
raise ValueError(f"match_on accepts 'name', 'legendgroup', 'legendgrouptitle'; got {sorted(bad)}")
if out["scope"] not in ("all", "legend"):
raise ValueError("scope must be 'all' or 'legend'")
out["width"] = int(out["width"])
out["height"] = int(out["height"]) if out["height"] else None
out["debounce"] = int(out["debounce"])
return out
def legend_search_script(
placeholder: str = _DEFAULTS["placeholder"],
width: int = _DEFAULTS["width"],
height: Optional[int] = _DEFAULTS["height"],
buttons: bool = _DEFAULTS["buttons"],
legend: str = _DEFAULTS["legend"],
scope: str = _DEFAULTS["scope"],
match_on: Union[str, Sequence[str]] = _DEFAULTS["match_on"],
case_sensitive: bool = _DEFAULTS["case_sensitive"],
debounce: int = _DEFAULTS["debounce"],
query: str = _DEFAULTS["query"],
) -> str:
"""Return the JavaScript to pass as ``post_script`` to plotly's HTML export."""
opts = _normalize_opts(
placeholder=placeholder, width=width, height=height, buttons=buttons,
legend=legend, scope=scope, match_on=match_on,
case_sensitive=case_sensitive, debounce=debounce, query=query,
)
# "</" would terminate the surrounding <script> tag; "{plot_id}" is reserved.
blob = json.dumps(opts).replace("</", "<\\/").replace("{plot_id}", "{plot\\u005fid}")
return _JS_TEMPLATE.replace("__OPTS__", blob)
# --------------------------------------------------------------------------- #
# Figure integration
# --------------------------------------------------------------------------- #
def _merge_post_script(existing, ours: str):
if not existing:
return ours
if isinstance(existing, (list, tuple)):
return [*existing, ours]
return [existing, ours]
def _legend_font_size(fig: go.Figure, leg: str) -> float:
"""Best-effort guess of the legend font size plotly.js will use."""
lay = fig.layout
tl = lay.template.layout if lay.template is not None else None
candidates = []
for src in (lay, tl):
if src is None:
continue
try:
candidates.append(src[leg].font.size)
except (KeyError, ValueError, AttributeError):
pass
candidates += [lay.font.size, tl.font.size if tl is not None else None]
return next((c for c in candidates if c), 12)
@contextmanager
def _prepared(fig: go.Figure, opts: dict):
"""
Temporarily write the spacer into the legend title so plotly.js reserves
room for the search box in its first render, and yield the matching script.
The figure is restored afterwards.
"""
opts = _normalize_opts(**opts)
leg = opts["legend"]
height = opts["height"] or max(20, int(_legend_font_size(fig, leg) * 1.85 + 0.5))
opts["height"] = height
script = legend_search_script(**opts)
try:
title = fig.layout[leg].title
except (KeyError, ValueError, AttributeError):
yield script # unknown legend id: let the JS fallback deal with it
return
orig = title.text
if orig and _EM in orig: # already prepared
yield script
return
spacer = f'<span style="font-size:{height}px">{_EM * math.ceil(opts["width"] / height)}</span>'
title.text = f"{spacer}<br>{orig}" if orig else spacer
try:
yield script
finally:
title.text = orig
def _export(func, fig, args, kwargs, opts):
"""Call a plotly.io export function with our post_script merged in."""
with _prepared(fig, opts) as script:
if func is pio.show:
kwargs = dict(kwargs)
kwargs["post_script"] = _merge_post_script(kwargs.get("post_script"), script)
return func(fig, *args, **kwargs)
# Bind to plotly.io's signature so a positional post_script also merges.
bound = inspect.signature(func).bind(fig, *args, **kwargs)
bound.arguments["post_script"] = _merge_post_script(bound.arguments.get("post_script"), script)
return func(*bound.args, **bound.kwargs)
class LegendSearchFigure(go.Figure):
"""A ``go.Figure`` whose HTML output includes a legend search box."""
def _ls_opts(self) -> dict:
return getattr(self, _SENTINEL_ATTR, None) or {}
def to_html(self, *args, **kwargs):
return _export(pio.to_html, self, args, kwargs, self._ls_opts())
def write_html(self, *args, **kwargs):
return _export(pio.write_html, self, args, kwargs, self._ls_opts())
def show(self, *args, **kwargs):
# HTML-based renderers ("notebook", "browser", "iframe", ...) honour
# post_script; the JupyterLab/VS Code "plotly_mimetype" renderer ignores it.
return _export(pio.show, self, args, kwargs, self._ls_opts())
def legend_search(fig: go.Figure, **opts) -> LegendSearchFigure:
"""
Enable the legend search box on ``fig`` (modified in place, also returned).
After this, ``fig.write_html``, ``fig.to_html`` and ``fig.show`` include the
search box. Accepts the same keyword options as ``legend_search_script``:
placeholder, width, height, buttons, legend, scope, match_on,
case_sensitive, debounce, query.
"""
if not isinstance(fig, go.Figure):
raise TypeError("legend_search expects a plotly.graph_objects.Figure")
_normalize_opts(**opts) # validate early
object.__setattr__(fig, _SENTINEL_ATTR, dict(opts))
if type(fig) is go.Figure:
fig.__class__ = LegendSearchFigure
elif not isinstance(fig, LegendSearchFigure):
# A user subclass of go.Figure: graft our methods onto a combined class.
fig.__class__ = type(f"LegendSearch{type(fig).__name__}", (LegendSearchFigure, type(fig)), {})
return fig
# --------------------------------------------------------------------------- #
# Optional: global switch
# --------------------------------------------------------------------------- #
_ORIGINALS: dict = {}
def install(**opts) -> None:
"""Give every ``go.Figure`` a legend search box in to_html/write_html/show."""
defaults = _normalize_opts(**opts)
if not _ORIGINALS:
_ORIGINALS.update(to_html=go.Figure.to_html, write_html=go.Figure.write_html, show=go.Figure.show)
def opts_for(fig):
own = getattr(fig, _SENTINEL_ATTR, None)
return own if own is not None else defaults
def to_html(self, *args, **kwargs):
return _export(pio.to_html, self, args, kwargs, opts_for(self))
def write_html(self, *args, **kwargs):
return _export(pio.write_html, self, args, kwargs, opts_for(self))
def show(self, *args, **kwargs):
return _export(pio.show, self, args, kwargs, opts_for(self))
go.Figure.to_html, go.Figure.write_html, go.Figure.show = to_html, write_html, show
def uninstall() -> None:
"""Undo ``install()``."""
if _ORIGINALS:
go.Figure.to_html = _ORIGINALS["to_html"]
go.Figure.write_html = _ORIGINALS["write_html"]
go.Figure.show = _ORIGINALS["show"]
_ORIGINALS.clear()