diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 946c314..1442bb8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,6 +6,8 @@ on: branches: [main] # … and to a versioned directory on every release tag. tags: ["v*.*.*"] + pull_request: + branches: [main] # Allow manual re-builds from the Actions tab. workflow_dispatch: @@ -19,25 +21,27 @@ permissions: contents: write # needed to push to gh-pages jobs: - build-and-deploy: - name: Build & deploy docs + # ── Build ────────────────────────────────────────────────────────────────── + # Runs on every push and every pull request. Treats warnings as errors so + # broken cross-references and bad docstrings are caught before merge. + build: + name: Build docs runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 with: - # Full history lets sphinx-gallery / git tools see tags correctly. fetch-depth: 0 - # ── uv + Python ──────────────────────────────────────────────────────── + # ── uv + Python ────────────────────────────────────────────────────── - name: Set up uv uses: astral-sh/setup-uv@v5 with: python-version: "3.13" enable-cache: true - # ── Dependencies ─────────────────────────────────────────────────────── + # ── Dependencies ───────────────────────────────────────────────────── # Install the package itself plus the [docs] optional-dependency group # (sphinx, pydata-sphinx-theme, sphinx-gallery, pillow, playwright). - name: Install dependencies (with docs extras) @@ -49,7 +53,51 @@ jobs: - name: Install Playwright browser run: uv run playwright install chromium --with-deps - # ── Determine deployment target ───────────────────────────────────────── + # ── Sphinx build ───────────────────────────────────────────────────── + # -W turns warnings into errors; --keep-going collects all of them. + - name: Build HTML documentation + run: | + uv run sphinx-build -b html docs build/html -W --keep-going + + # ── Upload built HTML as an artifact so it can be inspected on PRs ── + - name: Upload HTML artifact + uses: actions/upload-artifact@v4 + with: + name: docs-html + path: build/html + retention-days: 7 + + # ── Deploy ───────────────────────────────────────────────────────────────── + # Only runs after a successful build on pushes to main or release tags. + # Pull requests skip this job entirely. + deploy: + name: Deploy docs + needs: build + runs-on: ubuntu-latest + # Skip deployment for pull requests. + if: github.event_name != 'pull_request' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ── uv + Python ────────────────────────────────────────────────────── + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.13" + enable-cache: true + + # ── Dependencies ───────────────────────────────────────────────────── + - name: Install dependencies (with docs extras) + run: uv sync --extra docs + + - name: Install Playwright browser + run: uv run playwright install chromium --with-deps + + # ── Determine deployment target ────────────────────────────────────── # Release tag (refs/tags/v1.2.3) → destination = "v1.2.3" # Everything else (push to main, manual dispatch) → destination = "dev" - name: Determine deployment directory @@ -62,19 +110,16 @@ jobs: echo "dest_dir=dev" >> "$GITHUB_OUTPUT" fi - # ── Sphinx build ─────────────────────────────────────────────────────── - # -W turns warnings into errors so broken cross-references are caught. - # Remove -W if the gallery examples produce unavoidable warnings. + # ── Sphinx build ───────────────────────────────────────────────────── - name: Build HTML documentation env: DOCS_VERSION: ${{ steps.target.outputs.dest_dir }} run: | uv run sphinx-build -b html docs build/html -W --keep-going - # ── Deploy to gh-pages ──────────────────────────────────────────────── + # ── Deploy to gh-pages ─────────────────────────────────────────────── # keep_files: true preserves all existing directories on the branch so # versioned releases accumulate rather than overwriting each other. - # Only the target destination_dir is replaced on each run. - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 with: @@ -82,11 +127,10 @@ jobs: publish_dir: ./build/html destination_dir: ${{ steps.target.outputs.dest_dir }} keep_files: true - # Commit message makes the deployment easy to identify in the branch log. commit_message: | docs: deploy ${{ steps.target.outputs.dest_dir }} @ ${{ github.sha }} - # ── Deploy root files (redirect + switcher) ──────────────────────────── + # ── Deploy root files (redirect + switcher) ────────────────────────── # Places index.html and switcher.json at the root of gh-pages so the # bare URL redirects to dev/ and the version switcher is always reachable. - name: Deploy root redirect and switcher diff --git a/Examples/Interactive/plot_interactive_fitting.py b/Examples/Interactive/plot_interactive_fitting.py new file mode 100644 index 0000000..06edc41 --- /dev/null +++ b/Examples/Interactive/plot_interactive_fitting.py @@ -0,0 +1,292 @@ +""" +Interactive 1-D Gaussian Fitting +================================= + +A noisy composite signal built from two Gaussians is displayed. Two +additional overlay lines show the individual **component** curves and a +white **sum** curve that always equals the current manual model. + +**Interaction** + +Click any coloured component line to reveal its control widgets: + +* **Circular handle** — drag to move the peak centre (μ) and amplitude (A). +* **Shaded range** — drag either edge to widen or narrow the width (σ). + +The sum curve updates on every drag frame. +Press **f** (with the plot canvas focused) to run a least-squares fit. +The components — and all active widgets — will snap to the fitted values, +and the sum curve will jump to the optimal fit. +Click a component line again to hide its widgets. +""" +import numpy as np +from scipy.optimize import curve_fit +import anyplotlib as apl + +# ── Gaussian helpers ─────────────────────────────────────────────────────── + +def gaussian(x, amp, mu, sigma): + return amp * np.exp(-0.5 * ((x - mu) / sigma) ** 2) + +# Half-width at half-maximum = sigma * _FWHM_K (full FWHM = 2 * sigma * _FWHM_K) +_FWHM_K = np.sqrt(2.0 * np.log(2.0)) + +# ── Data ─────────────────────────────────────────────────────────────────── + +x = np.linspace(0, 10, 500) + +TRUE_P = [ + dict(amp=1.0, mu=3.2, sigma=0.55), + dict(amp=0.75, mu=6.8, sigma=0.80), +] +COLORS = ["#ff6b6b", "#69db7c"] + +rng = np.random.default_rng(42) +signal = sum(gaussian(x, **p) for p in TRUE_P) + rng.normal(0, 0.03, len(x)) + +# Initial component guesses (slightly off from truth) +INIT_P = [ + dict(amp=1.0, mu=3.0, sigma=0.6), + dict(amp=0.7, mu=7.0, sigma=0.9), +] + +# ── Figure ───────────────────────────────────────────────────────────────── + +fig, ax = apl.subplots(1, 1, figsize=(720, 380), + help="Click a coloured line → show/hide its widgets\n" + "Drag circle handle → move peak center (μ) and amplitude (A)\n" + "Drag range edge → widen / narrow the width (σ)\n" + "press: f → run least-squares fit") +plot = ax.plot(signal, axes=[x], color="#adb5bd", linewidth=1.5, + alpha=0.6, label="data") +# +# Live sum of all components — this IS the fit after pressing 'f' +sum_line = plot.add_line( + sum(gaussian(x, **p) for p in INIT_P), x_axis=x, + color="#e0e0e0", linewidth=1.5, linestyle="dashed", label="sum", +) + +comp_lines = [ + plot.add_line(gaussian(x, **p), x_axis=x, + color=c, linewidth=2.0, + label=f"comp {i+1}") + for i, (p, c) in enumerate(zip(INIT_P, COLORS)) +] + + +# ── GaussianComponent ────────────────────────────────────────────────────── + +class GaussianComponent: + """Manages a PointWidget (peak) + RangeWidget (σ) for one component. + + Assign ``.model`` after constructing the ``Model`` so the component + can notify it on every drag frame. + """ + + def __init__(self, line, p, color): + self.line = line + self.amp = p["amp"] + self.mu = p["mu"] + self.sigma = p["sigma"] + self.color = color + self.model = None # injected after Model is constructed + self._active = False + self._syncing = False # guard against callback loops + self._pt = None # PointWidget — created once on first toggle + self._rng_w = None # RangeWidget + + def component_y(self): + return gaussian(x, self.amp, self.mu, self.sigma) + + def toggle(self): + if self._active: + self._pt.hide() + self._rng_w.hide() + self._active = False + else: + if self._pt is None: + self._pt = plot.add_point_widget(self.mu, self.amp, + color=self.color, + show_crosshair=False) + self._rng_w = plot.add_range_widget( + self.mu - self.sigma * _FWHM_K, + self.mu + self.sigma * _FWHM_K, + y=self.amp / 2.0, + color=self.color, + style="fwhm", + ) + self._wire() + else: + self._pt.show() + self._rng_w.show() + self._active = True + + def _wire(self): + @self._pt.on_changed + def _peak_moved(event): + if self._syncing: + return + self._syncing = True + try: + self.amp = event.data["y"] + self.mu = event.data["x"] + self._rng_w.set(x0=self.mu - self.sigma * _FWHM_K, + x1=self.mu + self.sigma * _FWHM_K, + y=self.amp / 2.0) + self.line.set_data(self.component_y()) + if self.model: + self.model.update() + finally: + self._syncing = False + + @self._rng_w.on_changed + def _range_moved(event): + if self._syncing: + return + self._syncing = True + try: + x0, x1 = event.data["x0"], event.data["x1"] + self.mu = (x0 + x1) / 2.0 + self.sigma = abs(x1 - x0) / (2.0 * _FWHM_K) + self._pt.set(x=self.mu) + self.line.set_data(self.component_y()) + if self.model: + self.model.update() + finally: + self._syncing = False + + def snap(self, amp: float, mu: float, sigma: float) -> None: + """Update parameters and snap **all** widgets to the new values. + + Creates and shows the point and FWHM range widgets if they do not + exist yet (so pressing **f** always reveals the fitted widths), then + updates their positions. Uses the ``_syncing`` guard so widget + callbacks do not fire during the programmatic update. + """ + self._syncing = True + try: + self.amp = amp + self.mu = mu + self.sigma = sigma + self.line.set_data(self.component_y()) + if self._pt is None: + # First fit — create widgets at the fitted position and show them. + self._pt = plot.add_point_widget(self.mu, self.amp, + color=self.color, + show_crosshair=False) + self._rng_w = plot.add_range_widget( + self.mu - self.sigma * _FWHM_K, + self.mu + self.sigma * _FWHM_K, + y=self.amp / 2.0, + color=self.color, + style="fwhm", + ) + self._wire() + self._active = True + else: + # Widgets already exist — move them to the new fitted position. + self._pt.set(x=self.mu, y=self.amp) + self._rng_w.set(x0=self.mu - self.sigma * _FWHM_K, + x1=self.mu + self.sigma * _FWHM_K, + y=self.amp / 2.0) + # If the user had hidden the widgets, bring them back. + if not self._active: + self._pt.show() + self._rng_w.show() + self._active = True + finally: + self._syncing = False + +# ── Model ────────────────────────────────────────────────────────────────── + +class Model: + """A list of GaussianComponents with a live sum line. + + ``update()`` redraws the sum line from the current component state and + is called on every drag frame. + + ``fit()`` runs a least-squares fit, snaps every component (and its + widgets) to the optimal parameters, then calls ``update()`` so the sum + line jumps to the best fit. It is also triggered by pressing **f**. + + Parameters + ---------- + components : list[GaussianComponent] + sum_line : Line1D + Always-live manual-sum / fit-result overlay. + x_data, y_data : ndarray + Observed signal to fit against. + """ + + def __init__(self, components, sum_line, x_data, y_data): + self.components = list(components) + self.sum_line = sum_line + self.x_data = x_data + self.y_data = y_data + + def update(self): + """Redraw the sum line as the manual sum of all components.""" + self.sum_line.set_data( + sum(c.component_y() for c in self.components) + ) + + def fit(self): + """Least-squares fit; snaps components and FWHM widgets to the result. + + Builds a generic n-Gaussian model from the component list and uses + their current state as the initial guess. On success every component + snaps to the fitted (amp, μ, σ): the component line, the peak handle, + **and** the FWHM range widget are all moved to the optimal values. + If a component's widgets have not been shown yet they are created and + revealed automatically. The sum line redraws as the best fit. + On failure the components are left unchanged. + """ + n = len(self.components) + p0 = [v for c in self.components for v in (c.amp, c.mu, c.sigma)] + lo = [v for c in self.components for v in (0, self.x_data[0], 1e-3)] + hi = [v for c in self.components + for v in (np.inf, self.x_data[-1], + self.x_data[-1] - self.x_data[0])] + + def _model_fn(x, *params): + return sum( + gaussian(x, params[3 * i], params[3 * i + 1], params[3 * i + 2]) + for i in range(n) + ) + + try: + popt, _ = curve_fit( + _model_fn, self.x_data, self.y_data, + p0=p0, bounds=(lo, hi), maxfev=3000 * n, + ) + for i, comp in enumerate(self.components): + comp.snap(popt[3 * i], popt[3 * i + 1], popt[3 * i + 2]) + self.update() + except RuntimeError: + pass # leave components unchanged if fit did not converge + +# ── Assemble ─────────────────────────────────────────────────────────────── + +components = [ + GaussianComponent(comp_lines[i], INIT_P[i], COLORS[i]) + for i in range(2) +] + +model = Model(components, sum_line, x, signal) +for comp in components: + comp.model = model + +# ── Key binding — press 'f' to fit ───────────────────────────────────────── + +@plot.on_key('f') +def _on_fit(event): + model.fit() + +# ── Click handlers — toggle widgets per component ───────────────────────── + +for comp, line in zip(components, comp_lines): + @line.on_click + def _clicked(event, c=comp): + c.toggle() + +fig \ No newline at end of file diff --git a/anyplotlib/__init__.py b/anyplotlib/__init__.py index 93abdc0..b5f0f74 100644 --- a/anyplotlib/__init__.py +++ b/anyplotlib/__init__.py @@ -7,6 +7,11 @@ VLineWidget, HLineWidget, RangeWidget, ) +# ── Global help flag ────────────────────────────────────────────────────── +# Set to False to suppress help badges on all figures in this session. +# Default True: badges appear whenever a figure has help text set. +show_help: bool = True + __all__ = [ "Figure", "GridSpec", "SubplotSpec", "subplots", "Axes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar", @@ -14,4 +19,5 @@ "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget", "CrosshairWidget", "PolygonWidget", "LabelWidget", "VLineWidget", "HLineWidget", "RangeWidget", + "show_help", ] diff --git a/anyplotlib/figure.py b/anyplotlib/figure.py index 0d24886..1e94563 100644 --- a/anyplotlib/figure.py +++ b/anyplotlib/figure.py @@ -74,6 +74,9 @@ class Figure(anywidget.AnyWidget): event_json = traitlets.Unicode("{}").tag(sync=True) # When True the JS renderer shows a per-panel FPS / frame-time overlay. display_stats = traitlets.Bool(False).tag(sync=True) + # Figure-level help text shown in a '?' badge overlay in JS. + # Empty string means no badge. Gated by apl.show_help at the Python level. + help_text = traitlets.Unicode("").tag(sync=True) _esm = _ESM_SOURCE # Static CSS injected by anywidget alongside _esm. # .apl-scale-wrap — outer container; width:100% means it always fills @@ -111,7 +114,7 @@ class Figure(anywidget.AnyWidget): def __init__(self, nrows=1, ncols=1, figsize=(640, 480), width_ratios=None, height_ratios=None, sharex=False, sharey=False, - display_stats=False, **kwargs): + display_stats=False, help="", **kwargs): super().__init__(**kwargs) self._nrows = nrows self._ncols = ncols @@ -125,8 +128,37 @@ def __init__(self, nrows=1, ncols=1, figsize=(640, 480), self.fig_width = figsize[0] self.fig_height = figsize[1] self.display_stats = display_stats + self.help_text = self._resolve_help(help) self._push_layout() + @staticmethod + def _resolve_help(text: str) -> str: + """Return *text* if ``apl.show_help`` is True (default), else ``""``.""" + try: + import anyplotlib as _apl + if not getattr(_apl, "show_help", True): + return "" + except ImportError: + pass + return text or "" + + def set_help(self, text: str) -> None: + """Set (or clear) the figure-level help text shown in the **?** badge. + + Parameters + ---------- + text : str + Help string displayed when the user clicks the **?** badge. + Pass an empty string (or ``""`` ) to remove the badge entirely. + Newlines (``\\n``) are respected in the card. + + Examples + -------- + >>> fig.set_help("Drag peak: move μ/A\\nPress f: least-squares fit") + >>> fig.set_help("") # hide the badge + """ + self.help_text = self._resolve_help(text) + # ── subplot creation ────────────────────────────────────────────────────── def add_subplot(self, spec) -> Axes: """Add a subplot cell and return its :class:`Axes`. @@ -347,7 +379,8 @@ def subplots(nrows=1, ncols=1, *, width_ratios=None, height_ratios=None, gridspec_kw=None, - display_stats=False): + display_stats=False, + help=""): """Create a :class:`Figure` and a grid of :class:`~anyplotlib.figure_plots.Axes`. Mirrors :func:`matplotlib.pyplot.subplots`. @@ -369,6 +402,13 @@ def subplots(nrows=1, ncols=1, *, gridspec_kw : dict, optional Extra keyword arguments forwarded to :class:`GridSpec`. Recognised keys: ``width_ratios``, ``height_ratios``. + display_stats : bool, optional + Show per-panel FPS / frame-time overlay. Default False. + help : str, optional + Help text shown when the user clicks the **?** badge on the figure. + Newlines (``\\n``) create separate lines in the card. The badge is + hidden when *help* is empty (default). Suppressed globally when + ``apl.show_help = False``. Returns ------- @@ -398,6 +438,7 @@ def subplots(nrows=1, ncols=1, *, width_ratios=width_ratios, height_ratios=height_ratios, sharex=sharex, sharey=sharey, display_stats=display_stats, + help=help, ) # Build the GridSpec from the Figure's own stored ratios so there is # exactly one source of truth. diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 33e12fa..2ce1ca5 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -81,6 +81,16 @@ function render({ model, el }) { const n=arr.length, pos=Math.max(0,Math.min(1,frac))*(n-1), lo=Math.min(Math.floor(pos),n-2), t=pos-lo; return arr[lo]+t*(arr[lo+1]-arr[lo]); } + // Blend a #rrggbb / #rgb colour toward white by `amt` (0=unchanged, 1=white). + function _brightenColor(hex, amt=0.45) { + if(!hex||hex[0]!=='#') return hex; + let r,g,b; + if(hex.length===4){r=parseInt(hex[1]+hex[1],16);g=parseInt(hex[2]+hex[2],16);b=parseInt(hex[3]+hex[3],16);} + else{r=parseInt(hex.slice(1,3),16);g=parseInt(hex.slice(3,5),16);b=parseInt(hex.slice(5,7),16);} + if(isNaN(r)||isNaN(g)||isNaN(b)) return hex; + r=Math.min(255,Math.round(r+(255-r)*amt));g=Math.min(255,Math.round(g+(255-g)*amt));b=Math.min(255,Math.round(b+(255-b)*amt)); + return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`; + } // ── b64 array decode helpers ───────────────────────────────────────────── // Convert a base-64 string (little-endian raw bytes) to a JS TypedArray. @@ -179,7 +189,81 @@ function render({ model, el }) { 'color:white;font-size:11px;border-radius:4px;display:none;pointer-events:none;z-index:21;'; outerDiv.appendChild(sizeLabel); - // Tooltip (shared across all panels) + // ── Help badge (figure-level) ───────────────────────────────────────────── + // A small '?' button in the top-right corner of the figure. + // • Hidden until the mouse enters outerDiv (plot "active"). + // • Stays visible while the help card is open, even after mouse-leave. + // • Rounded square, tucked into the right padding band so it never + // overlaps plot content. + // • Clicking toggles the help card; click again (or mouse-leave with + // card closed) hides the button again. + const _BTN_BG = 'rgba(100,100,120,0.72)'; + const _BTN_BG_ACTIVE = 'rgba(75,120,210,0.92)'; + + const helpBtn = document.createElement('div'); + helpBtn.style.cssText = + 'position:absolute;top:9px;right:6px;width:20px;height:20px;' + + 'border-radius:4px;background:' + _BTN_BG + ';color:#fff;' + + 'font-size:12px;font-weight:bold;font-family:sans-serif;' + + 'display:none;align-items:center;justify-content:center;' + + 'cursor:pointer;z-index:50;user-select:none;line-height:1;' + + 'box-shadow:0 1px 4px rgba(0,0,0,0.35);'; + helpBtn.textContent = '?'; + helpBtn.title = 'Show help'; + outerDiv.appendChild(helpBtn); + + const helpCard = document.createElement('div'); + helpCard.style.cssText = + 'position:absolute;top:33px;right:6px;padding:10px 14px;' + + 'background:rgba(28,28,38,0.95);color:#e0e0e8;font-size:12px;' + + 'font-family:sans-serif;border-radius:6px;line-height:1.7;' + + 'white-space:pre-wrap;max-width:300px;display:none;z-index:51;' + + 'box-shadow:0 4px 14px rgba(0,0,0,0.55);pointer-events:none;' + + 'border:1px solid rgba(120,120,160,0.3);'; + outerDiv.appendChild(helpCard); + + let _helpExists = false; // true when help_text is non-empty + let _helpHovered = false; // true while mouse is inside outerDiv + let _helpOpen = false; // true while the card is shown + + function _updateHelp() { + const txt = model.get('help_text') || ''; + _helpExists = !!txt; + helpCard.textContent = txt; + if (!txt) { + // Help removed — hide everything immediately. + helpBtn.style.display = 'none'; + helpCard.style.display = 'none'; + helpBtn.style.background = _BTN_BG; + _helpOpen = false; + } else if (_helpHovered || _helpOpen) { + // Already hovered or card open — make badge visible. + helpBtn.style.display = 'flex'; + } + } + _updateHelp(); + + outerDiv.addEventListener('mouseenter', () => { + _helpHovered = true; + if (_helpExists) helpBtn.style.display = 'flex'; + }); + + outerDiv.addEventListener('mouseleave', () => { + _helpHovered = false; + // Only hide the button if the card is also closed. + if (!_helpOpen) helpBtn.style.display = 'none'; + }); + + helpBtn.addEventListener('click', (e) => { + e.stopPropagation(); + _helpOpen = !_helpOpen; + helpCard.style.display = _helpOpen ? 'block' : 'none'; + helpBtn.style.background = _helpOpen ? _BTN_BG_ACTIVE : _BTN_BG; + // If closing the card while the mouse has already left, hide the button too. + if (!_helpOpen && !_helpHovered) helpBtn.style.display = 'none'; + }); + + model.on('change:help_text', _updateHelp); const tooltip = document.createElement('div'); tooltip.style.cssText = 'position:fixed;padding:5px 9px;font-size:12px;font-family:sans-serif;' + @@ -934,6 +1018,7 @@ function render({ model, el }) { const widgets=st.overlay_widgets||[]; const scale=_imgScale2d(st,imgW,imgH); for(const w of widgets){ + if(w.visible === false) continue; ovCtx.save(); ovCtx.strokeStyle=w.color||'#00e5ff'; ovCtx.lineWidth=2; if(w.type==='circle'){ const [ccx,ccy]=_imgToCanvas2d(w.cx,w.cy,st,imgW,imgH); @@ -1606,6 +1691,28 @@ function render({ model, el }) { ex.alpha != null ? ex.alpha : 1.0, ex.marker || 'none', ex.markersize || 4); } + // ── hovered-line highlight: redraw on top with brightened colour + thicker stroke ── + const _hovId = p._lineHoverId; + if (_hovId !== undefined && _hovId !== '__none__') { + if (_hovId === null) { + _drawLine(yData, xArr, + _brightenColor(st.line_color||'#4fc3f7'), (st.line_linewidth||1.5)+1, + st.line_linestyle||'solid', st.line_alpha!=null?st.line_alpha:1.0, + st.line_marker||'none', st.line_markersize||4); + } else { + for (const ex of (st.extra_lines||[])) { + if (ex.id === _hovId) { + const exY = ex.data_b64 ? _decodeF64(ex.data_b64) : (ex.data||[]); + const exX = ex.x_axis_b64 ? _decodeF64(ex.x_axis_b64) : (ex.x_axis?ex.x_axis:xArr); + _drawLine(exY, exX, + _brightenColor(ex.color||(theme.dark?'#fff':'#333')), (ex.linewidth||1.5)+1, + ex.linestyle||'solid', ex.alpha!=null?ex.alpha:1.0, + ex.marker||'none', ex.markersize||4); + break; + } + } + } + } ctx.restore(); // Axes @@ -1693,6 +1800,7 @@ function render({ model, el }) { if(!widgets.length) return; for(const w of widgets){ + if(w.visible === false) continue; const color=w.color||'#00e5ff'; ovCtx.save();ovCtx.strokeStyle=color;ovCtx.lineWidth=2; if(w.type==='vline'){ @@ -1706,23 +1814,36 @@ function render({ model, el }) { } else if(w.type==='range'){ const px0=_fracToPx1d(_xToFrac1d(xArr,w.x0),x0,x1,r); const px1b=_fracToPx1d(_xToFrac1d(xArr,w.x1),x0,x1,r); - const left=Math.min(px0,px1b), right=Math.max(px0,px1b); - ovCtx.save();ovCtx.globalAlpha=0.15;ovCtx.fillStyle=color;ovCtx.fillRect(left,r.y,right-left,r.h);ovCtx.restore(); - ovCtx.setLineDash([5,3]); - ovCtx.beginPath();ovCtx.moveTo(px0,r.y);ovCtx.lineTo(px0,r.y+r.h);ovCtx.stroke(); - ovCtx.beginPath();ovCtx.moveTo(px1b,r.y);ovCtx.lineTo(px1b,r.y+r.h);ovCtx.stroke(); - ovCtx.setLineDash([]); - _ovHandle1d(ovCtx,px0,r.y+7,color);_ovHandle1d(ovCtx,px1b,r.y+7,color); + if(w.style==='fwhm'){ + // FWHM style: o-------o two handles joined by a dashed horizontal line + const pyHalf=_valToPy1d(w.y||0,dMin,dMax,r); + ovCtx.setLineDash([5,4]); + ovCtx.beginPath();ovCtx.moveTo(px0,pyHalf);ovCtx.lineTo(px1b,pyHalf);ovCtx.stroke(); + ovCtx.setLineDash([]); + _ovHandle1d(ovCtx,px0,pyHalf,color); + _ovHandle1d(ovCtx,px1b,pyHalf,color); + } else { + // band style (default) + const left=Math.min(px0,px1b), right=Math.max(px0,px1b); + ovCtx.save();ovCtx.globalAlpha=0.15;ovCtx.fillStyle=color;ovCtx.fillRect(left,r.y,right-left,r.h);ovCtx.restore(); + ovCtx.setLineDash([5,3]); + ovCtx.beginPath();ovCtx.moveTo(px0,r.y);ovCtx.lineTo(px0,r.y+r.h);ovCtx.stroke(); + ovCtx.beginPath();ovCtx.moveTo(px1b,r.y);ovCtx.lineTo(px1b,r.y+r.h);ovCtx.stroke(); + ovCtx.setLineDash([]); + _ovHandle1d(ovCtx,px0,r.y+7,color);_ovHandle1d(ovCtx,px1b,r.y+7,color); + } } else if(w.type==='point'){ const px=_fracToPx1d(_xToFrac1d(xArr,w.x),x0,x1,r); const py=_valToPy1d(w.y,dMin,dMax,r); - // Clip dashed crosshair guides to the plot rectangle - ovCtx.save();ovCtx.beginPath();ovCtx.rect(r.x,r.y,r.w,r.h);ovCtx.clip(); - ovCtx.setLineDash([4,3]); - ovCtx.beginPath();ovCtx.moveTo(px,r.y);ovCtx.lineTo(px,r.y+r.h);ovCtx.stroke(); - ovCtx.beginPath();ovCtx.moveTo(r.x,py);ovCtx.lineTo(r.x+r.w,py);ovCtx.stroke(); - ovCtx.setLineDash([]); - ovCtx.restore(); + // Dashed crosshair guide lines (skipped when show_crosshair is false) + if(w.show_crosshair!==false){ + ovCtx.save();ovCtx.beginPath();ovCtx.rect(r.x,r.y,r.w,r.h);ovCtx.clip(); + ovCtx.setLineDash([4,3]); + ovCtx.beginPath();ovCtx.moveTo(px,r.y);ovCtx.lineTo(px,r.y+r.h);ovCtx.stroke(); + ovCtx.beginPath();ovCtx.moveTo(r.x,py);ovCtx.lineTo(r.x+r.w,py);ovCtx.stroke(); + ovCtx.setLineDash([]); + ovCtx.restore(); + } // Draw the draggable handle (larger than _ovHandle1d for easy grab) ovCtx.save();ovCtx.fillStyle=color;ovCtx.strokeStyle='rgba(0,0,0,0.5)';ovCtx.lineWidth=1.5; ovCtx.beginPath();ovCtx.arc(px,py,7,0,Math.PI*2);ovCtx.fill();ovCtx.stroke(); @@ -1745,6 +1866,7 @@ function render({ model, el }) { const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); const yData = p._1dDArr || (st.data_b64 ? _decodeF64(st.data_b64) : (st.data||[])); const x0=st.view_x0||0, x1=st.view_x1||1; + const dMin=st.data_min, dMax=st.data_max; mkCtx.clearRect(0,0,pw,ph); const sets=st.markers||[]; if(!sets.length) return; @@ -2262,7 +2384,8 @@ function render({ model, el }) { model.set(`panel_${p.id}_json`,JSON.stringify(st));_scheduleCommit();e.preventDefault(); }); document.addEventListener('mouseup',(e)=>{ - const wasDragging=!!p.ovDrag||!!p.isPanning; + const wasWidgetDragging=!!p.ovDrag; // capture BEFORE clearing + const wasDragging=wasWidgetDragging||!!p.isPanning; if(p.ovDrag){ const _idx=p.ovDrag.idx; const _dw=(p.state.overlay_widgets||[])[_idx]||{}; @@ -2276,8 +2399,11 @@ function render({ model, el }) { const st=p.state; if(st) _emitEvent(p.id,'on_release',null,{view_x0:st.view_x0,view_x1:st.view_x1}); } - // Line click: only when no drag/pan occurred and mouse barely moved - if(!wasDragging && p._mousedownX!=null){ + // Line click: fire when no widget was being dragged and mouse barely moved. + // NOTE: p.isPanning is always set true on mousedown (pan start), so we + // deliberately only block on wasWidgetDragging here — the distance + // threshold below already excludes real pan gestures. + if(!wasWidgetDragging && p._mousedownX!=null){ const mdx=e.clientX-p._mousedownX, mdy=e.clientY-p._mousedownY; if(Math.hypot(mdx,mdy)<5){ const {mx,my}=_clientPos(e,overlayCanvas,p.pw,p.ph); @@ -2339,7 +2465,9 @@ function render({ model, el }) { const newLid=lhit?lhit.lineId:'__none__'; if(newLid!==p._lineHoverId){ p._lineHoverId=newLid; + draw1d(p); // redraw so hovered line is brightened drawOverlay1d(p); + overlayCanvas.style.cursor=lhit?'pointer':'crosshair'; if(lhit){ p.ovCtx.save();p.ovCtx.fillStyle='rgba(255,255,255,0.9)'; p.ovCtx.strokeStyle='rgba(0,0,0,0.5)';p.ovCtx.lineWidth=1.5; @@ -2352,7 +2480,7 @@ function render({ model, el }) { }); overlayCanvas.addEventListener('mouseleave',()=>{p.statusBar.style.display='none';tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers1d(p,null);} - if(p._lineHoverId!=='__none__'){p._lineHoverId='__none__';drawOverlay1d(p);} + if(p._lineHoverId!=='__none__'){p._lineHoverId='__none__';draw1d(p);drawOverlay1d(p);overlayCanvas.style.cursor='crosshair';} }); } @@ -2378,6 +2506,7 @@ function render({ model, el }) { // iterate top-to-bottom (last drawn = topmost) for (let i = widgets.length - 1; i >= 0; i--) { const w = widgets[i]; + if(w.visible === false) continue; if (w.type === 'circle') { const [ccx, ccy] = _imgToCanvas2d(w.cx, w.cy, st, imgW, imgH); const cr = w.r * scale; @@ -2531,8 +2660,22 @@ function render({ model, el }) { const x0=st.view_x0||0,x1=st.view_x1||1; const widgets=st.overlay_widgets||[]; const HR=7; + // First pass: point widgets have highest drag priority so that a point + // handle sitting inside a range band is always reachable. + for(let i=widgets.length-1;i>=0;i--){ + const w=widgets[i]; + if(w.visible===false) continue; + if(w.type==='point'){ + const px=_fracToPx1d(_xToFrac1d(xArr,w.x),x0,x1,r); + const py=_valToPy1d(w.y,st.data_min,st.data_max,r); + if(Math.hypot(mx-px,my-py)<=HR+4) + return{idx:i,mode:'move',wtype:'point',startMX:mx,startMY:my,snapW:{...w}}; + } + } + // Second pass: everything else for(let i=widgets.length-1;i>=0;i--){ const w=widgets[i]; + if(w.visible===false) continue; if(w.type==='vline'){ const px=_fracToPx1d(_xToFrac1d(xArr,w.x),x0,x1,r); if(Math.sqrt((mx-px)**2+(my-(r.y+7))**2)<=HR||Math.abs(mx-px)<=5) @@ -2543,15 +2686,19 @@ function render({ model, el }) { } else if(w.type==='range'){ const px0=_fracToPx1d(_xToFrac1d(xArr,w.x0),x0,x1,r); const px1b=_fracToPx1d(_xToFrac1d(xArr,w.x1),x0,x1,r); - if(Math.abs(mx-px0)<=HR+5) return{idx:i,mode:'edge0',wtype:'range',startMX:mx,snapW:{...w}}; - if(Math.abs(mx-px1b)<=HR+5) return{idx:i,mode:'edge1',wtype:'range',startMX:mx,snapW:{...w}}; - const left=Math.min(px0,px1b),right=Math.max(px0,px1b); - if(mx>=left&&mx<=right&&my>=r.y&&my<=r.y+r.h) return{idx:i,mode:'move',wtype:'range',startMX:mx,snapW:{...w}}; - } else if(w.type==='point'){ - const px=_fracToPx1d(_xToFrac1d(xArr,w.x),x0,x1,r); - const py=_valToPy1d(w.y,st.data_min,st.data_max,r); - if(Math.hypot(mx-px,my-py)<=HR+4) - return{idx:i,mode:'move',wtype:'point',startMX:mx,startMY:my,snapW:{...w}}; + if(w.style==='fwhm'){ + // FWHM style: hit-test the two circular handles + const pyHalf=_valToPy1d(w.y||0,st.data_min,st.data_max,r); + if(Math.hypot(mx-px0,my-pyHalf)<=HR+5) + return{idx:i,mode:'edge0',wtype:'range',startMX:mx,snapW:{...w}}; + if(Math.hypot(mx-px1b,my-pyHalf)<=HR+5) + return{idx:i,mode:'edge1',wtype:'range',startMX:mx,snapW:{...w}}; + } else { + if(Math.abs(mx-px0)<=HR+5) return{idx:i,mode:'edge0',wtype:'range',startMX:mx,snapW:{...w}}; + if(Math.abs(mx-px1b)<=HR+5) return{idx:i,mode:'edge1',wtype:'range',startMX:mx,snapW:{...w}}; + const left=Math.min(px0,px1b),right=Math.max(px0,px1b); + if(mx>=left&&mx<=right&&my>=r.y&&my<=r.y+r.h) return{idx:i,mode:'move',wtype:'range',startMX:mx,snapW:{...w}}; + } } } return null; diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index b867625..3581828 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -1215,12 +1215,30 @@ def add_texts(self, offsets, texts, name=None, *, labels=labels, label=label) def remove_marker(self, marker_type: str, name: str) -> None: + """Remove a named marker collection by type and name. + + Parameters + ---------- + marker_type : str + Collection type, e.g. ``"points"``, ``"vlines"``. + name : str + The name used when the collection was created. + """ self.markers.remove(marker_type, name) def clear_markers(self) -> None: + """Remove all marker collections from this panel.""" self.markers.clear() def list_markers(self) -> list: + """Return a summary list of all marker collections on this panel. + + Returns + ------- + list of dict + Each dict has keys ``"type"``, ``"name"``, and ``"n"`` + (number of markers in the collection). + """ out = [] for mtype, td in self.markers._types.items(): for name, g in td.items(): @@ -1607,8 +1625,9 @@ def __repr__(self) -> str: class Line1D: """Handle to a single line on a :class:`Plot1D` panel. - Returned by :meth:`Plot1D.add_line`. Use it to register hover/click - callbacks scoped to just that line, or to remove it later. + Returned by :meth:`Plot1D.add_line`. Use it to update the line data, + register hover/click callbacks scoped to just that line, or to remove + it later. Attributes ---------- @@ -1664,9 +1683,44 @@ def _filtered(event): fn._cid = cid return fn - def disconnect(self, cid: int) -> None: - """Remove a callback registered by :meth:`on_hover` or :meth:`on_click`.""" - self._plot.callbacks.disconnect(cid) + def set_data(self, y: "np.ndarray", x_axis=None) -> None: + """Update the y-data (and optionally x-axis) of this overlay line. + + The y-axis range is recomputed and the panel re-renders immediately. + + Parameters + ---------- + y : array-like, shape (N,) + New y values. Must be 1-D. + x_axis : array-like, shape (N,), optional + New x coordinates. If omitted the existing x-axis is kept. + + Raises + ------ + ValueError + If called on the primary line (use :meth:`Plot1D.set_data` + instead), or if *y* is not 1-D. + KeyError + If this line has already been removed. + """ + if self._lid is None: + raise ValueError( + "Cannot call set_data() on the primary line; " + "use plot.set_data() instead." + ) + y = np.asarray(y, dtype=float) + if y.ndim != 1: + raise ValueError("y must be 1-D") + for entry in self._plot._state["extra_lines"]: + if entry["id"] == self._lid: + entry["data"] = y + if x_axis is not None: + entry["x_axis"] = np.asarray(x_axis, dtype=float) + break + else: + raise KeyError(self._lid) + self._plot._recompute_data_range() + self._plot._push() def remove(self) -> None: """Remove this overlay line from its parent plot.""" @@ -2163,8 +2217,11 @@ def _tp(): return widget def add_range_widget(self, x0: float, x1: float, - color: str = "#00e5ff") -> _RangeWidget: - """Add a draggable range (two vertical lines + shaded fill) overlay. + color: str = "#00e5ff", + style: str = "band", + y: float = 0.0, + _push: bool = True) -> _RangeWidget: + """Add a draggable range overlay to this panel. Parameters ---------- @@ -2172,6 +2229,17 @@ def add_range_widget(self, x0: float, x1: float, Initial left and right edges in data coordinates. color : str, optional CSS colour string. Default ``"#00e5ff"``. + style : {'band', 'fwhm'}, optional + Visual style. ``'band'`` (default) draws two vertical lines with + a translucent fill. ``'fwhm'`` draws two draggable circles + connected by a dashed horizontal line at *y* (the half-maximum + level), giving an ``o-------o`` FWHM indicator. + y : float, optional + Y-coordinate (data space) for the connecting line when + ``style='fwhm'``. Ignored when ``style='band'``. Default 0. + _push : bool, optional + Push state to JS immediately. Set to ``False`` when adding + several widgets at once; call :meth:`_push` manually afterward. Returns ------- @@ -2179,7 +2247,8 @@ def add_range_widget(self, x0: float, x1: float, Widget object. Register position callbacks with :meth:`on_changed` / :meth:`on_release`. """ - widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), color=color) + widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), + color=color, style=style, y=float(y)) plot_ref, wid_id = self, widget._id def _tp(): if plot_ref._fig is not None: @@ -2187,11 +2256,14 @@ def _tp(): plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) widget._push_fn = _tp self._widgets[widget.id] = widget - self._push() + if _push: + self._push() return widget def add_point_widget(self, x: float, y: float, - color: str = "#00e5ff") -> _PointWidget: + color: str = "#00e5ff", + show_crosshair: bool = True, + _push: bool = True) -> _PointWidget: """Add a freely-draggable control point to this panel. Parameters @@ -2202,12 +2274,19 @@ def add_point_widget(self, x: float, y: float, Initial y position in data coordinates (value axis). color : str, optional CSS colour string. Default ``"#00e5ff"``. + show_crosshair : bool, optional + Draw dashed guide lines through the handle. Default ``True``. + Pass ``False`` for a plain dot with no guide lines. + _push : bool, optional + Push state to JS immediately. Set to ``False`` when adding + several widgets at once; call :meth:`_push` manually afterward. Returns ------- PointWidget """ - widget = _PointWidget(lambda: None, x=float(x), y=float(y), color=color) + widget = _PointWidget(lambda: None, x=float(x), y=float(y), color=color, + show_crosshair=show_crosshair) plot_ref, wid_id = self, widget._id def _tp_point(): if plot_ref._fig is not None: @@ -2215,7 +2294,8 @@ def _tp_point(): plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) widget._push_fn = _tp_point self._widgets[widget.id] = widget - self._push() + if _push: + self._push() return widget def get_widget(self, wid) -> Widget: @@ -3209,9 +3289,13 @@ def _tp(): return widget def add_range_widget(self, x0: float, x1: float, - color: str = "#00e5ff") -> _RangeWidget: - """Add a draggable range (two vertical lines + shaded fill) overlay.""" - widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), color=color) + color: str = "#00e5ff", + style: str = "band", + y: float = 0.0, + _push: bool = True) -> _RangeWidget: + """Add a draggable range overlay. See :meth:`Plot1D.add_range_widget` for full docs.""" + widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), + color=color, style=style, y=float(y)) plot_ref, wid_id = self, widget._id def _tp(): if plot_ref._fig is not None: @@ -3219,13 +3303,17 @@ def _tp(): plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) widget._push_fn = _tp self._widgets[widget.id] = widget - self._push() + if _push: + self._push() return widget def add_point_widget(self, x: float, y: float, - color: str = "#00e5ff") -> _PointWidget: + color: str = "#00e5ff", + show_crosshair: bool = True, + _push: bool = True) -> _PointWidget: """Add a freely-draggable control point to this panel.""" - widget = _PointWidget(lambda: None, x=float(x), y=float(y), color=color) + widget = _PointWidget(lambda: None, x=float(x), y=float(y), color=color, + show_crosshair=show_crosshair) plot_ref, wid_id = self, widget._id def _tp(): if plot_ref._fig is not None: @@ -3233,7 +3321,8 @@ def _tp(): plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) widget._push_fn = _tp self._widgets[widget.id] = widget - self._push() + if _push: + self._push() return widget def get_widget(self, wid) -> Widget: diff --git a/anyplotlib/widgets.py b/anyplotlib/widgets.py index 08d150d..f81385f 100644 --- a/anyplotlib/widgets.py +++ b/anyplotlib/widgets.py @@ -213,6 +213,31 @@ def disconnect(self, cid) -> None: cid = cid._cid self.callbacks.disconnect(cid) + # ── visibility ──────────────────────────────────────────────────────── + + @property + def visible(self) -> bool: + """``True`` if the widget is rendered; ``False`` if hidden.""" + return self._data.get("visible", True) + + @visible.setter + def visible(self, value: bool) -> None: + self.show() if value else self.hide() + + def show(self) -> None: + """Show the widget. Does not fire ``on_changed`` callbacks.""" + self._data["visible"] = True + self._push_fn() + + def hide(self) -> None: + """Hide the widget without removing it or its callbacks. + + Call :meth:`show` to make it visible again. + Does not fire ``on_changed`` callbacks. + """ + self._data["visible"] = False + self._push_fn() + # ── JS → Python sync ────────────────────────────────────────────── def _update_from_js(self, new_data: dict, event_type: str = "on_changed") -> bool: @@ -443,11 +468,20 @@ def __init__(self, push_fn, *, y, color="#00e5ff"): class RangeWidget(Widget): - """Draggable range selection widget (two connected vertical lines). + """Draggable range selection widget. + + Two display styles are available: + + ``style='band'`` (default) + Two connected vertical lines with a translucent fill band. Either + line can be dragged independently; the whole band can be dragged by + clicking inside it. - Allows interactive selection of a range on the x-axis. Both lines - move together when dragging, maintaining the range width. Either end - can be dragged independently to resize the range. + ``style='fwhm'`` + Two circular handles joined by a dashed horizontal line drawn at + height *y* (the half-maximum level). Only the x-positions of the + handles are draggable. Use this to show/edit a FWHM interval on a + peak. Parameters ---------- @@ -456,10 +490,18 @@ class RangeWidget(Widget): x0, x1 : float Initial left and right positions in data coordinates. color : str, optional - CSS colour for both lines. Default ``"#00e5ff"``. + CSS colour. Default ``"#00e5ff"``. + style : {'band', 'fwhm'}, optional + Visual style. Default ``"band"``. + y : float, optional + Y-position (data coordinates) for the connecting line when + ``style='fwhm'``. Ignored for ``style='band'``. Default ``0.0``. """ - def __init__(self, push_fn, *, x0, x1, color="#00e5ff"): - super().__init__("range", push_fn, x0=float(x0), x1=float(x1), color=color) + def __init__(self, push_fn, *, x0, x1, color="#00e5ff", + style: str = "band", y: float = 0.0): + super().__init__("range", push_fn, + x0=float(x0), x1=float(x1), color=color, + style=str(style), y=float(y)) class PointWidget(Widget): @@ -479,6 +521,10 @@ class PointWidget(Widget): Initial y position in data coordinates (value axis). color : str, optional CSS colour for the handle. Default ``"#00e5ff"``. + show_crosshair : bool, optional + If ``True`` (default), draw dashed crosshair guide lines through the + handle. Set to ``False`` for a bare draggable dot with no guides. """ - def __init__(self, push_fn, *, x, y, color="#00e5ff"): - super().__init__("point", push_fn, x=float(x), y=float(y), color=color) + def __init__(self, push_fn, *, x, y, color="#00e5ff", show_crosshair=True): + super().__init__("point", push_fn, x=float(x), y=float(y), color=color, + show_crosshair=bool(show_crosshair)) diff --git a/pyproject.toml b/pyproject.toml index 2780238..4cde065 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ docs = [ "playwright>=1.58.0", "plotly>=5.0", "bokeh>=3.0", + "scipy>=1.15.3", ] jupyter = [ "jupyterlab>=4.5.5", @@ -37,6 +38,7 @@ jupyter = [ dev = [ "playwright>=1.58.0", "pytest>=9.0.2", + "scipy>=1.15.3", ] diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 5453bf4..05ee6ae 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -432,6 +432,40 @@ def test_on_click_fires(self): assert len(results) == 1 assert results[0] == pytest.approx(16.0) + def test_on_click_line1d_overlay_fires(self): + """Line1D.on_click fires when JS sends on_line_click with the matching line_id.""" + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + line = v.add_line(np.ones(64), color="#ff0000") + results = [] + line.on_click(lambda event: results.append(event.line_id)) + + _simulate_js_event(fig, v, "on_line_click", line_id=line.id) + assert len(results) == 1 + assert results[0] == line.id + + def test_on_click_line1d_primary_fires(self): + """Line1D.on_click on the primary line fires when JS sends on_line_click with no line_id.""" + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + results = [] + v.line.on_click(lambda event: results.append(1)) + + # No line_id in payload → event.data.get("line_id") is None → matches primary + _simulate_js_event(fig, v, "on_line_click") + assert len(results) == 1 + + def test_on_click_line1d_wrong_id_no_fire(self): + """Line1D.on_click does NOT fire when the JS event carries a different line_id.""" + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + line = v.add_line(np.ones(64), color="#00ff00") + results = [] + line.on_click(lambda event: results.append(1)) + + _simulate_js_event(fig, v, "on_line_click", line_id="completely-wrong-id") + assert results == [] + def test_circle_drag(self): fig, ax = apl.subplots(1, 1) v = ax.imshow(np.zeros((32, 32))) @@ -646,3 +680,697 @@ def test_on_release_after_drags(self): assert drag_count[0] == 5 assert release_count[0] == 1 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 6. Widget visibility (hide / show) +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestWidgetVisibility: + """Unit tests for Widget.hide(), Widget.show(), and Widget.visible.""" + + def test_visible_default_true(self): + """A freshly created widget is visible by default.""" + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) + assert w.visible is True + + def test_hide_sets_visible_false(self): + """hide() marks the widget as not visible.""" + w = CircleWidget(lambda: None, cx=5, cy=5, r=3) + w.hide() + assert w.visible is False + + def test_show_restores_visible(self): + """show() after hide() restores visibility.""" + w = CircleWidget(lambda: None, cx=5, cy=5, r=3) + w.hide() + w.show() + assert w.visible is True + + def test_hide_calls_push(self): + """hide() must call push_fn exactly once.""" + pushed = [] + w = RectangleWidget(lambda: pushed.append(1), x=0, y=0, w=10, h=10) + pushed.clear() + w.hide() + assert len(pushed) == 1 + + def test_show_calls_push(self): + """show() must call push_fn exactly once.""" + pushed = [] + w = RectangleWidget(lambda: pushed.append(1), x=0, y=0, w=10, h=10) + pushed.clear() + w.show() + assert len(pushed) == 1 + + def test_hide_does_not_fire_on_changed(self): + """hide() must NOT fire on_changed callbacks.""" + w = CircleWidget(lambda: None, cx=0, cy=0, r=5) + fired = [] + w.on_changed(lambda e: fired.append(1)) + w.hide() + assert fired == [] + + def test_show_does_not_fire_on_changed(self): + """show() must NOT fire on_changed callbacks.""" + w = CircleWidget(lambda: None, cx=0, cy=0, r=5) + fired = [] + w.on_changed(lambda e: fired.append(1)) + w.hide() + w.show() + assert fired == [] + + def test_visible_in_to_dict_after_hide(self): + """to_dict() reflects visible=False after hide().""" + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) + w.hide() + assert w.to_dict()["visible"] is False + + def test_visible_in_to_dict_after_show(self): + """to_dict() reflects visible=True after show().""" + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) + w.hide() + w.show() + assert w.to_dict()["visible"] is True + + def test_visible_in_state_dict_after_hide(self): + """The panel state dict propagates visible=False for a hidden widget.""" + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + w = v.add_vline_widget(x=5.0) + w.hide() + widgets = v.to_state_dict()["overlay_widgets"] + entry = next(e for e in widgets if e["id"] == w.id) + assert entry["visible"] is False + + def test_visible_in_state_dict_after_show(self): + """The panel state dict propagates visible=True after show().""" + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + w = v.add_vline_widget(x=5.0) + w.hide() + w.show() + widgets = v.to_state_dict()["overlay_widgets"] + entry = next(e for e in widgets if e["id"] == w.id) + assert entry["visible"] is True + + def test_hide_then_show_widget_still_draggable(self): + """After show(), a JS drag event fires callbacks as normal.""" + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("circle", cx=10, cy=10, r=5) + fired = [] + w.on_changed(lambda e: fired.append(e.cx)) + w.hide() + w.show() + _simulate_js_event(fig, v, "on_changed", widget_id=w, cx=20.0) + assert fired == [20.0] + + def test_hide_show_1d_range_widget(self): + """hide/show round-trip works for a RangeWidget.""" + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + w = v.add_range_widget(x0=10, x1=20) + w.hide() + assert w.visible is False + w.show() + assert w.visible is True + + def test_multiple_hide_calls_idempotent(self): + """Calling hide() twice leaves visible=False, pushes twice.""" + pushed = [] + w = CircleWidget(lambda: pushed.append(1), cx=0, cy=0, r=5) + pushed.clear() + w.hide() + w.hide() + assert w.visible is False + assert len(pushed) == 2 # each hide() pushes once + + def test_multiple_show_calls_idempotent(self): + """Calling show() twice leaves visible=True, pushes twice.""" + pushed = [] + w = CircleWidget(lambda: pushed.append(1), cx=0, cy=0, r=5) + pushed.clear() + w.show() + w.show() + assert w.visible is True + assert len(pushed) == 2 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 7. Interactive Fitting — plot_interactive_fitting.py scenario +# ═══════════════════════════════════════════════════════════════════════════════ + +from anyplotlib.widgets import RangeWidget as _RangeWidget2, PointWidget as _PointWidget2 + + +def _gaussian(x, amp, mu, sigma): + return amp * np.exp(-0.5 * ((x - mu) / sigma) ** 2) + + +def _two_gaussians(x, a1, mu1, s1, a2, mu2, s2): + return _gaussian(x, a1, mu1, s1) + _gaussian(x, a2, mu2, s2) + + +class _GaussianController: + """Mirror of GaussianController from plot_interactive_fitting.py.""" + + def __init__(self, plot, line, p, color, x, fit_callback): + self._plot = plot + self.line = line + self.amp = p["amp"] + self.mu = p["mu"] + self.sigma = p["sigma"] + self.color = color + self._x = x + self._refit = fit_callback + self._active = False + self._syncing = False + self._pt = None + self._rng_w = None + + def component_y(self): + return _gaussian(self._x, self.amp, self.mu, self.sigma) + + def toggle(self): + if self._active: + self._pt.hide() + self._rng_w.hide() + self._active = False + else: + if self._pt is None: + self._pt = self._plot.add_point_widget(self.mu, self.amp, + color=self.color) + self._rng_w = self._plot.add_range_widget( + self.mu - self.sigma, self.mu + self.sigma, + color=self.color, + ) + self._wire() + else: + self._pt.show() + self._rng_w.show() + self._active = True + + def _wire(self): + @self._pt.on_changed + def _peak_moved(event): + if self._syncing: + return + self._syncing = True + try: + self.amp = event.data["y"] + self.mu = event.data["x"] + self._rng_w.set(x0=self.mu - self.sigma, + x1=self.mu + self.sigma) + self.line.set_data(self.component_y()) + self._refit() + finally: + self._syncing = False + + @self._rng_w.on_changed + def _range_moved(event): + if self._syncing: + return + self._syncing = True + try: + x0, x1 = event.data["x0"], event.data["x1"] + self.mu = (x0 + x1) / 2.0 + self.sigma = abs(x1 - x0) / 2.0 + self._pt.set(x=self.mu) + self.line.set_data(self.component_y()) + self._refit() + finally: + self._syncing = False + + +class TestInteractiveFitting: + """End-to-end tests mirroring plot_interactive_fitting.py. + + Validates widget hide/show toggle, PointWidget and RangeWidget drag + callbacks, and the live refit flow — all without a browser. + """ + + def _build(self): + """Return (fig, plot, controllers, fit_line, x, signal).""" + from scipy.optimize import curve_fit + + x = np.linspace(0, 10, 200) + TRUE_P = [ + dict(amp=1.0, mu=3.2, sigma=0.55), + dict(amp=0.75, mu=6.8, sigma=0.80), + ] + COLORS = ["#ff6b6b", "#69db7c"] + rng = np.random.default_rng(0) + signal = sum(_gaussian(x, **p) for p in TRUE_P) + rng.normal(0, 0.03, len(x)) + + INIT_P = [ + dict(amp=1.0, mu=3.0, sigma=0.6), + dict(amp=0.7, mu=7.0, sigma=0.9), + ] + + fig, ax = apl.subplots(1, 1, figsize=(600, 300)) + plot = ax.plot(signal, axes=[x], color="#adb5bd") + + comp_lines = [ + plot.add_line(_gaussian(x, **p), x_axis=x, color=c) + for i, (p, c) in enumerate(zip(INIT_P, COLORS)) + ] + + fit_line = plot.add_line( + sum(_gaussian(x, **p) for p in INIT_P), x_axis=x, + color="#ffd43b", linestyle="dashed", + ) + + refit_calls = [0] + + def _refit(): + c0, c1 = controllers[0], controllers[1] + p0 = [c0.amp, c0.mu, c0.sigma, c1.amp, c1.mu, c1.sigma] + lo = [0, x[0], 1e-3, 0, x[0], 1e-3] + hi = [np.inf, x[-1], x[-1]-x[0], np.inf, x[-1], x[-1]-x[0]] + try: + popt, _ = curve_fit(_two_gaussians, x, signal, p0=p0, + bounds=(lo, hi), maxfev=3000) + fit_line.set_data(_two_gaussians(x, *popt)) + except RuntimeError: + fit_line.set_data(sum(c.component_y() for c in controllers)) + refit_calls[0] += 1 + + controllers = [ + _GaussianController(plot, comp_lines[i], INIT_P[i], COLORS[i], + x, _refit) + for i in range(2) + ] + + return fig, plot, controllers, fit_line, x, signal, refit_calls + + # ── toggle creates widgets ──────────────────────────────────────────────── + + def test_toggle_once_creates_point_and_range_widgets(self): + """First toggle creates a PointWidget and a RangeWidget.""" + _, plot, ctrls, *_ = self._build() + ctrl = ctrls[0] + assert ctrl._pt is None and ctrl._rng_w is None + ctrl.toggle() + assert ctrl._pt is not None + assert ctrl._rng_w is not None + assert ctrl._active is True + + def test_toggle_once_adds_two_widgets_to_plot(self): + """After first toggle, the plot has exactly 2 new widgets.""" + _, plot, ctrls, *_ = self._build() + ctrl = ctrls[0] + ctrl.toggle() + assert len(plot.list_widgets()) == 2 + + def test_widgets_visible_after_first_toggle(self): + """Widgets created on first toggle are visible.""" + _, plot, ctrls, *_ = self._build() + ctrl = ctrls[0] + ctrl.toggle() + assert ctrl._pt.visible is True + assert ctrl._rng_w.visible is True + + # ── toggle hides widgets ────────────────────────────────────────────────── + + def test_toggle_twice_hides_widgets(self): + """Second toggle hides the point and range widgets.""" + _, plot, ctrls, *_ = self._build() + ctrl = ctrls[0] + ctrl.toggle() # activate + ctrl.toggle() # deactivate + assert ctrl._active is False + assert ctrl._pt.visible is False + assert ctrl._rng_w.visible is False + + def test_toggle_twice_widgets_still_in_plot(self): + """Hidden widgets are NOT removed from the plot — they stay but are hidden.""" + _, plot, ctrls, *_ = self._build() + ctrl = ctrls[0] + ctrl.toggle() + ctrl.toggle() + # Still registered — just hidden + assert len(plot.list_widgets()) == 2 + + # ── toggle shows widgets again ──────────────────────────────────────────── + + def test_toggle_three_times_reshows_widgets(self): + """Third toggle re-shows the existing widgets without creating new ones.""" + _, plot, ctrls, *_ = self._build() + ctrl = ctrls[0] + ctrl.toggle() # create + show + pt_id = ctrl._pt.id + rng_id = ctrl._rng_w.id + ctrl.toggle() # hide + ctrl.toggle() # re-show + assert ctrl._active is True + assert ctrl._pt.visible is True + assert ctrl._rng_w.visible is True + # Same objects — not recreated + assert ctrl._pt.id == pt_id + assert ctrl._rng_w.id == rng_id + assert len(plot.list_widgets()) == 2 + + # ── PointWidget drag updates component line ─────────────────────────────── + + def test_point_drag_updates_component_amp_and_mu(self): + """Simulating a PointWidget drag updates amp and mu on the controller.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() + ctrl = ctrls[0] + ctrl.toggle() + + _simulate_js_event(fig, plot, "on_changed", + widget_id=ctrl._pt, x=3.5, y=0.9) + + assert ctrl.mu == pytest.approx(3.5) + assert ctrl.amp == pytest.approx(0.9) + + def test_point_drag_updates_range_widget_position(self): + """Dragging the point recentres the range widget around new mu.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() + ctrl = ctrls[0] + ctrl.toggle() + original_sigma = ctrl.sigma + + _simulate_js_event(fig, plot, "on_changed", + widget_id=ctrl._pt, x=4.0, y=1.0) + + expected_x0 = 4.0 - original_sigma + expected_x1 = 4.0 + original_sigma + assert ctrl._rng_w.x0 == pytest.approx(expected_x0) + assert ctrl._rng_w.x1 == pytest.approx(expected_x1) + + def test_point_drag_updates_component_line_data(self): + """After a PointWidget drag, the component line data reflects new params.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() + ctrl = ctrls[0] + ctrl.toggle() + + old_data = _gaussian(x, ctrl.amp, ctrl.mu, ctrl.sigma).copy() + _simulate_js_event(fig, plot, "on_changed", + widget_id=ctrl._pt, x=4.0, y=0.8) + + # Find the extra_line entry for comp_lines[0] + lid = ctrl.line.id + entry = next(e for e in plot._state["extra_lines"] if e["id"] == lid) + new_y = entry["data"] + expected_y = _gaussian(x, 0.8, 4.0, ctrl.sigma) + np.testing.assert_allclose(new_y, expected_y, rtol=1e-10) + + def test_point_drag_triggers_refit(self): + """Dragging the PointWidget calls the refit callback.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() + ctrl = ctrls[0] + ctrl.toggle() + + _simulate_js_event(fig, plot, "on_changed", + widget_id=ctrl._pt, x=3.5, y=0.9) + + assert refit_calls[0] >= 1 + + def test_point_drag_updates_fit_line(self): + """After a point drag, the fit line data changes.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() + ctrl = ctrls[0] + ctrl.toggle() + + lid = fit_line.id + entry_before = next(e for e in plot._state["extra_lines"] if e["id"] == lid) + old_fit = entry_before["data"].copy() + + _simulate_js_event(fig, plot, "on_changed", + widget_id=ctrl._pt, x=4.5, y=0.5) + + entry_after = next(e for e in plot._state["extra_lines"] if e["id"] == lid) + assert not np.array_equal(entry_after["data"], old_fit) + + # ── RangeWidget drag updates component line ─────────────────────────────── + + def test_range_drag_updates_mu_and_sigma(self): + """Simulating a RangeWidget drag updates mu and sigma on the controller.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() + ctrl = ctrls[0] + ctrl.toggle() + + _simulate_js_event(fig, plot, "on_changed", + widget_id=ctrl._rng_w, x0=2.5, x1=4.5) + + assert ctrl.mu == pytest.approx(3.5) + assert ctrl.sigma == pytest.approx(1.0) + + def test_range_drag_recentres_point_widget(self): + """Dragging the range widget moves the point widget to the new centre.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() + ctrl = ctrls[0] + ctrl.toggle() + + _simulate_js_event(fig, plot, "on_changed", + widget_id=ctrl._rng_w, x0=2.0, x1=5.0) + + assert ctrl._pt.x == pytest.approx(3.5) + + def test_range_drag_updates_component_line_data(self): + """After a RangeWidget drag, the component line reflects the new sigma.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() + ctrl = ctrls[0] + ctrl.toggle() + + _simulate_js_event(fig, plot, "on_changed", + widget_id=ctrl._rng_w, x0=2.5, x1=4.5) + + lid = ctrl.line.id + entry = next(e for e in plot._state["extra_lines"] if e["id"] == lid) + expected_y = _gaussian(x, ctrl.amp, 3.5, 1.0) + np.testing.assert_allclose(entry["data"], expected_y, rtol=1e-10) + + def test_range_drag_triggers_refit(self): + """Dragging the RangeWidget calls the refit callback.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() + ctrl = ctrls[0] + ctrl.toggle() + + _simulate_js_event(fig, plot, "on_changed", + widget_id=ctrl._rng_w, x0=2.5, x1=4.5) + + assert refit_calls[0] >= 1 + + # ── both controllers independent ───────────────────────────────────────── + + def test_two_controllers_independent(self): + """Dragging ctrl[0] does not affect ctrl[1] state.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() + ctrls[0].toggle() + ctrls[1].toggle() + + old_mu1 = ctrls[1].mu + + _simulate_js_event(fig, plot, "on_changed", + widget_id=ctrls[0]._pt, x=3.8, y=1.1) + + assert ctrls[1].mu == pytest.approx(old_mu1) + + def test_both_controllers_active_at_same_time(self): + """Both controllers can be active simultaneously with no crosstalk.""" + _, plot, ctrls, *_ = self._build() + ctrls[0].toggle() + ctrls[1].toggle() + assert len(plot.list_widgets()) == 4 + assert ctrls[0]._active and ctrls[1]._active + + def test_hide_one_leaves_other_visible(self): + """Hiding ctrl[0] does not affect ctrl[1] visibility.""" + _, plot, ctrls, *_ = self._build() + ctrls[0].toggle() # activate + ctrls[1].toggle() # activate + ctrls[0].toggle() # hide + assert ctrls[0]._pt.visible is False + assert ctrls[1]._pt.visible is True + + # ── line click toggles controller ───────────────────────────────────────── + + def test_line_click_activates_controller(self): + """Simulating a click on a component line activates its controller.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() + ctrl = ctrls[0] + + # Wire up the line.on_click handler (same as the example) + @ctrl.line.on_click + def _clicked(event, c=ctrl): + c.toggle() + + # Simulate JS sending an on_line_click event for comp_lines[0] + fig._on_event({"new": __import__("json").dumps({ + "source": "js", + "panel_id": plot._id, + "event_type": "on_line_click", + "line_id": ctrl.line.id, + })}) + + assert ctrl._active is True + assert ctrl._pt is not None + + def test_line_click_twice_hides_widgets(self): + """Two clicks on the same component line toggle it off again.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() + ctrl = ctrls[0] + + @ctrl.line.on_click + def _clicked(event, c=ctrl): + c.toggle() + + import json as _json + + def _click(): + fig._on_event({"new": _json.dumps({ + "source": "js", + "panel_id": plot._id, + "event_type": "on_line_click", + "line_id": ctrl.line.id, + })}) + + _click() # → active + _click() # → hidden + + assert ctrl._active is False + assert ctrl._pt.visible is False + + def test_line_click_wrong_line_id_no_toggle(self): + """A click on a different line ID does NOT toggle this controller.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() + ctrl = ctrls[0] + + @ctrl.line.on_click + def _clicked(event, c=ctrl): + c.toggle() + + import json as _json + fig._on_event({"new": _json.dumps({ + "source": "js", + "panel_id": plot._id, + "event_type": "on_line_click", + "line_id": "completely-wrong-id", + })}) + + assert ctrl._active is False # was never toggled + + # ── example-mirroring tests ─────────────────────────────────────────────── + + def _build_with_click_handlers(self): + """Same as _build() but wires line.on_click → ctrl.toggle() for both + components, exactly as the for-loop in plot_interactive_fitting.py.""" + result = self._build() + _, _, controllers, *_ = result + for ctrl in controllers: + @ctrl.line.on_click + def _clicked(event, c=ctrl): + c.toggle() + return result + + def test_example_both_lines_clickable(self): + """Clicking each component line activates its controller and makes + the widgets visible — mirrors the click-handler loop in the example.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = \ + self._build_with_click_handlers() + + # Click component 0 + _simulate_js_event(fig, plot, "on_line_click", line_id=ctrls[0].line.id) + assert ctrls[0]._active is True + assert ctrls[0]._pt is not None + assert ctrls[0]._rng_w is not None + assert ctrls[0]._pt.visible is True + assert ctrls[0]._rng_w.visible is True + assert ctrls[1]._active is False # other controller untouched + + # Click component 1 + _simulate_js_event(fig, plot, "on_line_click", line_id=ctrls[1].line.id) + assert ctrls[1]._active is True + assert ctrls[1]._pt.visible is True + assert ctrls[1]._rng_w.visible is True + + def test_example_click_shows_widgets_registered_in_plot(self): + """After clicking a component line its widgets appear in list_widgets().""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = \ + self._build_with_click_handlers() + + assert len(plot.list_widgets()) == 0 + + _simulate_js_event(fig, plot, "on_line_click", line_id=ctrls[0].line.id) + assert len(plot.list_widgets()) == 2 # PointWidget + RangeWidget + + _simulate_js_event(fig, plot, "on_line_click", line_id=ctrls[1].line.id) + assert len(plot.list_widgets()) == 4 # +2 for ctrl[1] + + def test_example_second_click_hides_widgets(self): + """Second click hides widgets but keeps them registered in the plot.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = \ + self._build_with_click_handlers() + + def _click(ctrl): + _simulate_js_event(fig, plot, "on_line_click", + line_id=ctrl.line.id) + + _click(ctrls[0]) # show + assert ctrls[0]._active is True and ctrls[0]._pt.visible is True + + _click(ctrls[0]) # hide + assert ctrls[0]._active is False + assert ctrls[0]._pt.visible is False + assert ctrls[0]._rng_w.visible is False + assert len(plot.list_widgets()) == 2 # still registered, just hidden + + def test_example_third_click_reshows_same_widgets(self): + """Third click re-shows the same widget objects without recreating them.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = \ + self._build_with_click_handlers() + + def _click(ctrl): + _simulate_js_event(fig, plot, "on_line_click", + line_id=ctrl.line.id) + + _click(ctrls[0]) + pt_id = ctrls[0]._pt.id + rng_id = ctrls[0]._rng_w.id + + _click(ctrls[0]) # hide + _click(ctrls[0]) # re-show + + assert ctrls[0]._active is True + assert ctrls[0]._pt.visible is True + assert ctrls[0]._rng_w.visible is True + assert ctrls[0]._pt.id == pt_id # same objects, not recreated + assert ctrls[0]._rng_w.id == rng_id + assert len(plot.list_widgets()) == 2 + + def test_example_click_then_drag_updates_fit(self): + """Full flow: click to activate → drag PointWidget → fit line changes.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = \ + self._build_with_click_handlers() + + _simulate_js_event(fig, plot, "on_line_click", line_id=ctrls[0].line.id) + assert ctrls[0]._active is True + + lid = fit_line.id + fit_before = next( + e for e in plot._state["extra_lines"] if e["id"] == lid + )["data"].copy() + + _simulate_js_event(fig, plot, "on_changed", + widget_id=ctrls[0]._pt, x=4.0, y=0.8) + + fit_after = next( + e for e in plot._state["extra_lines"] if e["id"] == lid + )["data"] + assert not np.array_equal(fit_after, fit_before) + assert refit_calls[0] >= 1 + + def test_example_wrong_line_id_not_clickable(self): + """A click event for an unknown line ID activates no controller.""" + fig, plot, ctrls, fit_line, x, signal, refit_calls = \ + self._build_with_click_handlers() + + _simulate_js_event(fig, plot, "on_line_click", line_id="no-such-line") + assert ctrls[0]._active is False + assert ctrls[1]._active is False + + +