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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ grep -nE '^\s*(function|const|let) [A-Za-z_]' anyplotlib/figure_esm.js
```

and reconcile against the two numbered tables (the section map near the top and
the 2-D function table). Both were last verified at 9,285 lines.
the 2-D function table). Both were last verified at 10,119 lines.

Changelog entries: add a fragment file to `upcoming_changes/` (e.g.
`123.new_feature.rst`) — towncrier assembles `CHANGELOG.rst` at release time.
Expand Down
59 changes: 30 additions & 29 deletions anyplotlib/FIGURE_ESM.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,20 +59,20 @@ Rule 5 – Text never clips. Optional gutters earn real layout space:
| `_createInsetDOM` / `_applyAllInsetStates` | 1129 / 1512 |
| `_resizePanelDOM` | 2225 |
| **2D drawing**: `_imgFitRect` | 2384 |
| `draw2d` | 2692 |
| `drawScaleBar2d` / `drawColorbar2d` | 2887 / 3125 |
| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 2986 / 3009 / 3022 |
| `_drawAxes2d` (ticks, labels, title) | 3180 |
| `drawOverlay2d` / `drawMarkers2d` | 3333 / 3497 |
| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2512 / 2536 / 2597 |
| `draw2d` | 2713 |
| `drawScaleBar2d` / `drawColorbar2d` | 2908 / 3188 |
| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 3007 / 3030 / 3043 |
| `_drawAxes2d` (ticks, labels, title) | 3242 |
| `drawOverlay2d` / `drawMarkers2d` | 3395 / 3559 |
| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2533 / 2557 / 2618 |
| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 730 / 761 |
| **3D drawing**: `draw3d` | 5236 |
| Event emission `_emitEvent` | 6073 |
| 3D event handlers `_attachEvents3d` | 6125 |
| **1D drawing**: `draw1d` | 6346 |
| `_drawLine` (1D series + markers) | 6499 |
| `drawOverlay1d` / `drawMarkers1d` | 6792 / 6876 |
| Marker hit-test `_markerHitTest2d` | 7144 |
| **3D drawing**: `draw3d` | 5298 |
| Event emission `_emitEvent` | 6135 |
| 3D event handlers `_attachEvents3d` | 6187 |
| **1D drawing**: `draw1d` | 6408 |
| `_drawLine` (1D series + markers) | 6561 |
| `drawOverlay1d` / `drawMarkers1d` | 6854 / 6938 |
| Marker hit-test `_markerHitTest2d` | 7206 |

> **`raster` marker (1D/PlotXY)** — `drawMarkers1d` has a `type==='raster'`
> branch that blits a single RGBA image across data-coord `extent` (the fast
Expand All @@ -81,16 +81,16 @@ Rule 5 – Text never clips. Optional gutters earn real layout space:
> redraws never re-transmit them; the decoded `OffscreenCanvas` is cached on
> the marker set (`ms._rasterBmp`/`_rasterKey`). The shared `clip_path` block
> clips it to a curved sector.
| Panel event dispatch `_attachPanelEvents` | 7401 |
| 2D events `_attachEvents2d` | 7443 |
| 1D events `_attachEvents1d` | 7827 |
| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8099 / 8372 |
| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8285 / 8299 / 8328 / 8363 |
| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 8495 / 8568 |
| Shared-axis propagation `_getShareGroups` | 8639 |
| Figure resize `_applyFigResizeDOM` | 8703 |
| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 8894 / 8957 / 9333 |
| Generic redraw `_redrawPanel` | 9523 |
| Panel event dispatch `_attachPanelEvents` | 7463 |
| 2D events `_attachEvents2d` | 7505 |
| 1D events `_attachEvents1d` | 7889 |
| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8161 / 8440 |
| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8353 / 8367 / 8396 / 8431 |
| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 8565 / 8638 |
| Shared-axis propagation `_getShareGroups` | 8709 |
| Figure resize `_applyFigResizeDOM` | 8773 |
| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 8964 / 9027 / 9403 |
| Generic redraw `_redrawPanel` | 9593 |

> **`brush` widget (2-D)** — the one widget whose drag is *modal*, and the one
> that must NOT write the model per tick. `_ovHitTest2d` takes an extra `mods`
Expand Down Expand Up @@ -269,12 +269,13 @@ st.colorbar_label_size (label font sizes; optional)

| Function | Line | Purpose |
|----------|------|---------|
| **`_imgFitRect(iw,ih,cw,ch)`** | **2372** | Largest rect of aspect `iw:ih` centred in `cw×ch`; all 2-D coordinate functions derive from this |
| `draw2d(p)` | 2680 | Main render: `_resizePanelDOM` → decode → LUT → ImageBitmap → blit; then mask, axes, scale bar, colorbar, overlay, markers |
| `drawScaleBar2d(p)` | 2875 | Physical scale bar |
| `drawColorbar2d(p)` | 2961 | Gradient strip + min/max marks + rotated label centred in the `_cbWidth` gutter |
| `_drawAxes2d(p)` | 3016 | Ticks (edge labels nudged inward both axes), axis labels + title via `_drawTex` |
| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 3169 / 3333 | Widgets / marker groups |
| **`_imgFitRect(iw,ih,cw,ch)`** | **2384** | Largest rect of aspect `iw:ih` centred in `cw×ch`; all 2-D coordinate functions derive from this |
| `draw2d(p)` | 2713 | Main render: `_resizePanelDOM` → decode → LUT → ImageBitmap → blit; then mask, axes, scale bar, colorbar, overlay, markers |
| `drawScaleBar2d(p)` | 2908 | Physical scale bar |
| `_rawBand(st)` / `_buildLut32(st)` | 2433 / 2440 | The quantisation band the u8 bytes were encoded over, then the 256-entry LUT built from it. `_rawBand` mirrors Python `_tile_quant_clim`: a DEGENERATE band (`raw_max <= raw_min`) is UNSET and falls back to `display_min/max`. Both render paths and the colorbar go through it — honouring a `(0, 0)` band paints solid black |
| `drawColorbar2d(p)` | 3188 | Gradient strip + min/max marks (band-relative, via `_rawBand`) + rotated label centred in the `_cbWidth` gutter |
| `_drawAxes2d(p)` | 3242 | Ticks (edge labels nudged inward both axes), axis labels + title via `_drawTex` |
| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 3395 / 3559 | Widgets / marker groups |

Zoom model: at `zoom=1` the whole image fills the fit-rect; at `zoom=Z>1` a
`1/Z` region fills it. `_imgToCanvas2d` / `_canvasToImg2d` must stay exact
Expand Down
28 changes: 24 additions & 4 deletions anyplotlib/figure_esm.js
Original file line number Diff line number Diff line change
Expand Up @@ -2415,10 +2415,31 @@ function render({ model, el, onResize }) {
};
}

// The quantisation band the uint8 image bytes were encoded over, as [lo, hi].
// A DEGENERATE band (raw_max <= raw_min) means DIFFERENT things on the two
// encoder paths, and the LUT has to read it the way the bytes were written:
//
// plain (_normalize_image): a constant frame quantises to ALL-ZERO bytes and
// reports (c, c) — the band NAMES the single value code 0 stands for, and
// hMin + raw/255 * (range||1) reconstructs it. Keep honouring it.
// tile (_tile_quant_clim): a degenerate band is treated as UNSET and the
// bytes are quantised over the DISPLAY window instead — so they are already
// display-mapped and the LUT must be the identity window over them.
//
// Reading a tiled degenerate band the plain way is GH #60: a plot that entered
// tile mode on a flat placeholder carries the band (0, 0), every code maps to
// hMin + raw/255 * 1 in [0, 1], lands below any sane display floor, and the
// panel renders solid black beside perfectly healthy stats.
function _rawBand(st) {
const lo=st.raw_min, hi=st.raw_max;
if(lo==null||hi==null) return [st.display_min, st.display_max];
if(hi>lo) return [lo,hi];
return st.tile_enabled ? [st.display_min, st.display_max] : [lo,hi];
}

function _buildLut32(st) {
const dMin=st.display_min, dMax=st.display_max;
const hMin=st.raw_min!=null?st.raw_min:dMin;
const hMax=st.raw_max!=null?st.raw_max:dMax;
const [hMin,hMax]=_rawBand(st);
const mode=st.scale_mode||'linear';
const range=hMax-hMin||1;
const cmapData=st.colormap_data||[];
Expand Down Expand Up @@ -3198,8 +3219,7 @@ function render({ model, el, onResize }) {

// display_min / display_max tick marks
const dMin=st.display_min, dMax=st.display_max;
const hMin=st.raw_min!=null?st.raw_min:dMin;
const hMax=st.raw_max!=null?st.raw_max:dMax;
const [hMin,hMax]=_rawBand(st);
const vRange=(hMax-hMin)||1;
function _vToY(v){return imgH-1-((v-hMin)/vRange)*(imgH-1);}
ctx.strokeStyle='rgba(255,255,255,0.85)'; ctx.lineWidth=1.5;
Expand Down
77 changes: 72 additions & 5 deletions anyplotlib/plot2d/_plot2d.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,11 +507,7 @@ def enable_tile(self, backend=None, integration_method: str = "mean",
# (raw_min/raw_max fixed, display_min/display_max move) with NO pixel re-encode
# or re-transfer — set_clim just moves the display window (see set_clim). Set
# once from the full-res range; keep any existing band on a re-enable.
if (self._state.get("raw_min") is None
or not (self._state.get("raw_max", 0) > self._state.get("raw_min", 0))):
rng = self._backend_display_range(self._tile_backend)
if rng is not None:
self._state["raw_min"], self._state["raw_max"] = rng
self._ensure_tile_band()
_was_on = self._tile_on
if not self._tile_on:
self._tile_on = True
Expand Down Expand Up @@ -561,6 +557,13 @@ def update_tile_source(self, array=None) -> None:
setter = getattr(self._tile_backend, "set_array", None)
if setter is not None:
setter(array)
# The quantisation band is derived ONCE (enable_tile) and then held fixed, so
# a contrast change re-windows in the LUT with no pixel re-encode. But if it
# was never VALIDLY derived — tile mode started on a flat placeholder, so the
# probe refused and the band stayed degenerate — the new data in the backend
# is the first chance to get it right. Must run BEFORE the overview/detail are
# re-sampled below so they quantise over the corrected band in the SAME push.
self._ensure_tile_band()
reg = self._state.get("detail_region") or []
has_detail = len(reg) == 4
# Refresh the overview base ONLY when no detail tile is shown (zoomed out) —
Expand Down Expand Up @@ -622,6 +625,70 @@ def _refresh_overview(self) -> None:
except Exception as e:
_TLOG.warning("[TILEDBG] overview refresh FAILED: %s", e)

def _ensure_tile_band(self) -> bool:
"""Derive the fixed quantisation band (raw_min/raw_max) from the CURRENT tile
backend when it is unset or DEGENERATE, by the same rule _tile_quant_clim
uses to read it back. Returns True iff the band was (re-)derived.

The degenerate case is the one that bites: a plot that entered tile mode on a
flat placeholder (e.g. zeros before real data exists) probes a range of
(0, 0) — _backend_display_range refuses it, so the band keeps the
constructor's raw min/max, which for zeros is also (0, 0). Every later
set_data then re-sampled the overview against a band the encoder treated as
unset (quantising over the display window) while the frontend LUT honoured
(0, 0) literally and painted the panel solid black. Calling this on each data
swap re-derives the band as soon as a frame with real range arrives, so the
two ends of the protocol agree on what the bytes mean.
"""
lo, hi = self._state.get("raw_min"), self._state.get("raw_max")
if lo is not None and hi is not None and hi > lo:
return False # a valid band is kept, never re-derived
if self._tile_backend is None:
return False
rng = self._backend_display_range(self._tile_backend)
if rng is None:
# Still flat (an all-constant frame): leave the band unset/degenerate —
# _tile_quant_clim and the JS LUT both fall back to the display window.
return False
self._state["raw_min"], self._state["raw_max"] = rng
_TLOG.debug("[TILEDBG] tile band (re-)derived from backend → raw=(%s,%s)",
*rng)
return True

def set_tile_band(self, vmin: float, vmax: float) -> None:
"""PIN the fixed quantisation band the tile bytes are encoded over.

Tile mode normally derives this band once, from the full-res data (see
``enable_tile``), and then holds it: a contrast change re-windows in the LUT
with no pixel re-encode or re-transfer. A live consumer that already KNOWS the
honest range — a camera's bit depth, a detector's saturation point — can pin
it here instead of letting the first frame decide, so the contrast doesn't
shift when a later frame happens to contain a brighter pixel.

Pinning also survives what auto-derivation cannot: a source whose first frames
are flat (a placeholder, a closed shutter) has no range to derive from, and
the band stays unset until a frame with real content arrives.

Re-samples the overview (and any active detail tile) over the new band in a
single push, so the pixels and the band the renderer reads them with never
disagree. Raises if the plot is not in tile mode or the range is degenerate."""
if not self._tile_on:
raise RuntimeError(
"set_tile_band requires tile mode — the band is the quantisation "
"range for the TILE bytes. Call enable_tile() first (or use "
"set_clim() to change the display window on a plain plot).")
lo, hi = float(vmin), float(vmax)
if not (hi > lo):
raise ValueError(
f"set_tile_band requires vmax > vmin, got ({lo}, {hi}). A degenerate "
f"band is what 'unset' means here — both the encoder and the "
f"renderer then fall back to the display window.")
self._state["raw_min"], self._state["raw_max"] = lo, hi
# Re-encode over the new band. update_tile_source() with no array re-samples
# from the backend in place (keeping zoom/center and the detail region) and
# pushes — so the band and the bytes it describes ship together.
self.update_tile_source()

def _tile_quant_clim(self):
"""The FIXED quantisation band (raw_min/raw_max) the tile bytes are encoded
over, so a contrast change re-windows in the LUT without re-encoding pixels.
Expand Down
Loading
Loading