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 12,386 lines.
the 2-D function table). Both were last verified at 12,407 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
112 changes: 60 additions & 52 deletions anyplotlib/FIGURE_ESM.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,18 @@ Rule 5 – Text never clips. Optional gutters earn real layout space:
| `drawScaleBar2d` / `drawColorbar2d` | 3076 / 3356 |
| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 3175 / 3198 / 3211 |
| `_drawAxes2d` (ticks, labels, title) | 3468 |
| `drawOverlay2d` / `drawMarkers2d` | 3621 / 3785 |
| `drawOverlay2d` / `drawMarkers2d` | 3623 / 3787 |
| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2701 / 2725 / 2786 |
| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 849 / 880 |
| **Hover readout**: `_pixelValue2d` / `_readoutInfo2d` | 4629 / 4714 |
| `_notifyReadout` / `_updateStatus2d` / `_armValueProbe` | 4753 / 4768 / 4789 |
| **3D drawing**: `draw3d` | 5765 |
| Event emission `_emitEvent` | 6602 |
| 3D event handlers `_attachEvents3d` | 6659 |
| **1D drawing**: `draw1d` | 6883 |
| `_drawLine` (1D series + markers) | 7036 |
| `drawOverlay1d` / `drawMarkers1d` | 7329 / 7413 |
| Marker hit-test `_markerHitTest2d` | 7680 |
| **Hover readout**: `_pixelValue2d` / `_readoutInfo2d` | 4631 / 4716 |
| `_notifyReadout` / `_updateStatus2d` / `_armValueProbe` | 4755 / 4770 / 4791 |
| **3D drawing**: `draw3d` | 5767 |
| Event emission `_emitEvent` | 6604 |
| 3D event handlers `_attachEvents3d` | 6661 |
| **1D drawing**: `draw1d` | 6885 |
| `_drawLine` (1D series + markers) | 7038 |
| `drawOverlay1d` / `drawMarkers1d` | 7331 / 7415 |
| Marker hit-test `_markerHitTest2d` | 7682 |

> **`raster` marker (1D/PlotXY)** — `drawMarkers1d` has a `type==='raster'`
> branch that blits a single RGBA image across data-coord `extent` (the fast
Expand All @@ -83,22 +83,22 @@ 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` | 7937 |
| 2D events `_attachEvents2d` | 8004 |
| 1D events `_attachEvents1d` | 8390 |
| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8665 / 8944 |
| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8857 / 8871 / 8900 / 8935 |
| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 9069 / 9142 |
| Shared-axis propagation `_getShareGroups` | 9213 |
| Figure resize `_applyFigResizeDOM` | 9277 |
| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 9471 / 9534 / 9910 |
| Generic redraw `_redrawPanel` | 10100 |
| **PNG export**: `_compositeCanvas` / `exportCanvas` / `exportPNG` | 10263 / 10459 / 10518 |
| Native-resolution render `_withNativeSize` | 10239 |
| **Export UI**: `_toast` / `_downloadCanvas` / `_openMenu` | 10552 / 10661 / 10840 |
| Export registry `registerExportAction` | 10717 |
| **Embedding API**: `createLocalModel` / `mount` | 11231 / 11287 |
| **Navigated embed**: `decodeBlocks` / `mountNavigated` | 11542 / 11929 |
| Panel event dispatch `_attachPanelEvents` | 7939 |
| 2D events `_attachEvents2d` | 8025 |
| 1D events `_attachEvents1d` | 8411 |
| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8686 / 8965 |
| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8878 / 8892 / 8921 / 8956 |
| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 9090 / 9163 |
| Shared-axis propagation `_getShareGroups` | 9234 |
| Figure resize `_applyFigResizeDOM` | 9298 |
| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 9492 / 9555 / 9931 |
| Generic redraw `_redrawPanel` | 10121 |
| **PNG export**: `_compositeCanvas` / `exportCanvas` / `exportPNG` | 10284 / 10480 / 10539 |
| Native-resolution render `_withNativeSize` | 10260 |
| **Export UI**: `_toast` / `_downloadCanvas` / `_openMenu` | 10573 / 10682 / 10861 |
| Export registry `registerExportAction` | 10738 |
| **Embedding API**: `createLocalModel` / `mount` | 11252 / 11308 |
| **Navigated embed**: `decodeBlocks` / `mountNavigated` | 11563 / 11950 |

> **`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 @@ -287,8 +287,8 @@ st.colorbar_label_size (label font sizes; optional)
| `drawScaleBar2d(p)` | 3076 | Physical scale bar |
| `_rawBand(st)` / `_displayFrac(st, val)` / `_buildLut32(st)` | 2596 / 2606 / 2614 | The quantisation band the u8 bytes were encoded over, the ONE rule mapping a value through the display window and scale mode to a colormap fraction (shared by the LUT and the colorbar strip, so the strip's colours are the image's), then the 256-entry LUT built from both. `_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)` | 3356 | Gradient strip coloured through the display window (`_displayFrac` — saturated beyond it, like the image) + min/max marks (band-relative, via `_rawBand`) + the two values written beside them (`fmtRange`; kept inside the strip by the glyphs' measured extent, held apart on a tiny range, the maximum alone at its own place on a strip too short for both; omitted when the layout dropped the gutter, `p._cbTickW == 0`) + rotated label centred right after the values' measured width (never past the reserved gutter) |
| `_drawAxes2d(p)` | 3468 | Ticks (edge labels nudged inward both axes), axis labels + title via `_drawTex` |
| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 3621 / 3785 | Widgets / marker groups |
| `_drawAxes2d(p)` | 3468 | Ticks placed through `_imgToCanvas2d` (pixel-centre axes, letterbox-aware; edge labels nudged inward both axes), axis labels + title via `_drawTex` |
| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 3623 / 3787 | 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 All @@ -301,6 +301,14 @@ coordinate to `xdata`/`ydata` through `_imgToAxisVal2d` (imshow axes are
per-pixel centres, pcolormesh axes cell edges). Code that addresses texels or
the detail region by pixel EDGE (`_pixelValue2d`) adds the `0.5` back itself.

The tick gutters (`_drawAxes2d`) use the same chain in reverse: tick value →
`_axisValToImg2d` (imshow; pcolormesh goes through `_axisValToFrac` × n − ½) →
`_imgToCanvas2d`, so ticks honour the letterboxed fit rect, zoom < 1, pan
clamping and descending (`origin='lower'`) axes for free. The visible value
range is the gutter's two ends through `_canvasToImg2d`, clamped to the outer
pixel edges. Python's `Plot2D._axis_extent_frac` (behind `set_view` /
`set_xlim`) mirrors `_axisValToImg2d` — keep the two in step.

`texts` markers (1-D and 2-D) set their font and optional outline through
`_markerTextStyle(ctx, ms)` (`fontweight`, `outline_color`, `outline_width`)
and draw via `_drawMarkerText`, which strokes the halo under the fill.
Expand Down Expand Up @@ -652,13 +660,13 @@ exportCanvas(same opts) → {canvas, width, height} // synchronous, throws

| Function | Line | Purpose |
|----------|------|---------|
| `_cssScale` | 10135 | inverse of `_applyScale`'s `transform:scale()` |
| `_panelBox` | 10146 | the element whose rect bounds one panel |
| `_neutralizeView` / `_restoreView` | 10155 / 10180 | transient whole-extent view |
| `_nativeGeom` / `_nativeGuard` | 10195 / 10214 | native size + why-not message |
| `_withNativeSize` | 10239 | resize → redraw → run → restore |
| `_compositeCanvas` | 10263 | the compositor (`_drawEl` / `_drawPanel` …) |
| `exportCanvas` / `exportPNG` | 10459 / 10518 | orchestrator / data-URL wrapper |
| `_cssScale` | 10156 | inverse of `_applyScale`'s `transform:scale()` |
| `_panelBox` | 10167 | the element whose rect bounds one panel |
| `_neutralizeView` / `_restoreView` | 10176 / 10201 | transient whole-extent view |
| `_nativeGeom` / `_nativeGuard` | 10216 / 10235 | native size + why-not message |
| `_withNativeSize` | 10260 | resize → redraw → run → restore |
| `_compositeCanvas` | 10284 | the compositor (`_drawEl` / `_drawPanel` …) |
| `exportCanvas` / `exportPNG` | 10480 / 10539 | orchestrator / data-URL wrapper |

**The whole pipeline is ONE synchronous task** — theme swap, view reset, native
resize, composite, restore — so the browser never paints an intermediate state
Expand Down Expand Up @@ -754,13 +762,13 @@ leaders that cross into the panel included. Pinned by

| Function | Line | Purpose |
|----------|------|---------|
| `_toast` | 10552 | transient bottom-centre message |
| `_copyCanvas` | 10587 | clipboard write + feature detection |
| `_showPngPreview` | 10611 | framed-document download fallback |
| `_downloadCanvas` | 10661 | `<a download>` or the preview |
| `registerExportAction` | 10717 | downstream extension point |
| `_menuRows` / `_openMenu` | 10771 / 10840 | menu model / DOM |
| `_panelAtPoint` | 10952 | hit test (insets first — they sit on top) |
| `_toast` | 10573 | transient bottom-centre message |
| `_copyCanvas` | 10608 | clipboard write + feature detection |
| `_showPngPreview` | 10632 | framed-document download fallback |
| `_downloadCanvas` | 10682 | `<a download>` or the preview |
| `registerExportAction` | 10738 | downstream extension point |
| `_menuRows` / `_openMenu` | 10792 / 10861 | menu model / DOM |
| `_panelAtPoint` | 10973 | hit test (insets first — they sit on top) |

- **An `exportBtn` badge (⤓, beside the help badge) opens the same menu on an
ordinary left click.** It is a `role="button"` with `tabIndex=0` and
Expand Down Expand Up @@ -844,16 +852,16 @@ bindings, let it dispatch", rather than a hand-written program per result kind.

| Function | Line | Purpose |
|----------|------|---------|
| `decodeBlocks` | 11542 | one base64 `fetch` → one ArrayBuffer → a typed-array view per manifest entry |
| `dense` | 11568 | `at` / `gather` / `reduce` over a block whose leading axes are the nav axes |
| `ragged` | 11633 | the same three, over a row-pointer block (`offsets` + one array per column) |
| `maskFromWidget` | 11716 | rectangle / circle / annulus widget dict → `Uint8Array` (carries `width`/`height`) |
| `rasterDisks` | 11756 | splat `{x, y, intensity}` rows as filled disks — the base image of a vectors panel |
| `robustLevels` / `toU8` | 11785 / 11826 | the percentile window and the 8-bit code map, one implementation |
| `panelAxis` | 11904 | a 1-D panel's decoded x axis (`_1dXArr`, else `x_axis_b64`) |
| `installTouchShim` / `reportEmbedHeight` | 11841 / 11860 | page chrome: touch → mouse, `postMessage({aplEmbedHeight})` |
| `encodeBase64` / `typedArrayBytes` | 11880 / 11888 | a 3-D cloud's geometry channel is base64, not the binary side table |
| `mountNavigated` | 11929 | mount + bind + dispatch; resolves to the mount handle plus `dispatch`/`index`/`blocks` |
| `decodeBlocks` | 11563 | one base64 `fetch` → one ArrayBuffer → a typed-array view per manifest entry |
| `dense` | 11589 | `at` / `gather` / `reduce` over a block whose leading axes are the nav axes |
| `ragged` | 11654 | the same three, over a row-pointer block (`offsets` + one array per column) |
| `maskFromWidget` | 11737 | rectangle / circle / annulus widget dict → `Uint8Array` (carries `width`/`height`) |
| `rasterDisks` | 11777 | splat `{x, y, intensity}` rows as filled disks — the base image of a vectors panel |
| `robustLevels` / `toU8` | 11806 / 11847 | the percentile window and the 8-bit code map, one implementation |
| `panelAxis` | 11925 | a 1-D panel's decoded x axis (`_1dXArr`, else `x_axis_b64`) |
| `installTouchShim` / `reportEmbedHeight` | 11862 / 11881 | page chrome: touch → mouse, `postMessage({aplEmbedHeight})` |
| `encodeBase64` / `typedArrayBytes` | 11901 / 11909 | a 3-D cloud's geometry channel is base64, not the binary side table |
| `mountNavigated` | 11950 | mount + bind + dispatch; resolves to the mount handle plus `dispatch`/`index`/`blocks` |

`mountNavigated(el, page, opts)` is **async** — the blob decode is a `fetch` of
a `data:` URL — so a host `await`s it. `page` is `{state, blocks, bindings,
Expand Down
65 changes: 43 additions & 22 deletions anyplotlib/figure_esm.js
Original file line number Diff line number Diff line change
Expand Up @@ -3472,7 +3472,6 @@ function render({ model, el, onResize, onReadout }) {
const imgH = p.imgH||Math.max(1, ph - PAD_T - PAD_B);
const xArr=st.x_axis||[], yArr=st.y_axis||[];
const TICK=6;
const zoom=st.zoom, cx=st.center_x, cy=st.center_y;
const units=st.units||'px';
const hasPhysAxis = (st.is_mesh || st.has_axes) && xArr.length>=2 && yArr.length>=2;
if(st.axis_visible===false){
Expand All @@ -3485,16 +3484,22 @@ function render({ model, el, onResize, onReadout }) {
const hasX=hasPhysAxis&&st.axis_visible!==false&&st.x_ticks_visible!==false&&p.xCtx&&p.xAxisCanvas&&p.xAxisCanvas.style.display!=='none';
const hasY=hasPhysAxis&&st.axis_visible!==false&&st.y_ticks_visible!==false&&p.yCtx&&p.yAxisCanvas&&p.yAxisCanvas.style.display!=='none';

function _visFrac(z,c){
if(z>=1.0){const h=0.5/z;const cc=Math.max(h,Math.min(1-h,c));return[cc-h,cc+h];}
return[0,1];
}
function _fracToPx(frac, z, center, span) {
if(z>=1.0){
const h=0.5/z, cc=Math.max(h,Math.min(1-h,center));
return (frac-(cc-h))/(2*h)*span;
}
return (span-span*z)/2+frac*span*z;
// Ticks go through the SAME transform markers, widgets and pointer events
// use — axis value → centre-convention image coordinate → _imgToCanvas2d —
// so a tick sits on the pixels it labels at any zoom and pan, and on the
// letterboxed fit rect rather than the whole gutter. (They used to take the
// value's fraction of the axis array as a fraction of the gutter: imshow's
// first and last pixel CENTRES landed on the gutter's ends, stretching every
// label outward, and a pillarboxed image's ticks ignored its offset.)
const iw=st.image_width||1, ih=st.image_height||1;
const _valToImg=(arr,v,n)=>st.is_mesh ? _axisValToFrac(arr,v)*n-0.5 : _axisValToImg2d(arr,v,n);
// [lo, hi] axis values over the visible stretch of the image along axis `ax`
// (0 = x, 1 = y): the gutter's two ends, clamped to the outer pixel edges.
function _visRange(arr, n, ax){
const a=_canvasToImg2d(0,0,st,imgW,imgH)[ax], b=_canvasToImg2d(imgW,imgH,st,imgW,imgH)[ax];
const va=_imgToAxisVal2d(st,arr,Math.max(-0.5,Math.min(a,b)),n);
const vb=_imgToAxisVal2d(st,arr,Math.min(n-0.5,Math.max(a,b)),n);
return [Math.min(va,vb), Math.max(va,vb)];
}

// ── X axis canvas: imgW × PAD_B, origin at top-left ─────────────────
Expand All @@ -3505,8 +3510,7 @@ function render({ model, el, onResize, onReadout }) {
p.xCtx.fillStyle=theme.axisBg; p.xCtx.fillRect(0,0,aw,ah);
p.xCtx.strokeStyle=theme.axisStroke; p.xCtx.lineWidth=1;
p.xCtx.beginPath(); p.xCtx.moveTo(0,0); p.xCtx.lineTo(aw,0); p.xCtx.stroke();
const [xF0,xF1]=_visFrac(zoom,cx);
const xVMin=_axisFracToVal(xArr,xF0), xVMax=_axisFracToVal(xArr,xF1);
const [xVMin,xVMax]=_visRange(xArr,iw,0);
const step=findNice((xVMax-xVMin)/Math.max(3,Math.floor(imgW/60)));
p.xCtx.strokeStyle=theme.tickStroke;
p.xCtx.fillStyle=theme.tickText; p.xCtx.font=(st.tick_size||10)+'px sans-serif';
Expand All @@ -3521,12 +3525,12 @@ function render({ model, el, onResize, onReadout }) {
let lastPx=-Infinity;
for(let ti=0;ti<xTicks.length;ti++){
const v=xTicks[ti];
const frac=_axisValToFrac(xArr,v);
const px2=_fracToPx(frac,zoom,cx,imgW);
const px2=_imgToCanvas2d(_valToImg(xArr,v,iw),0,st,imgW,imgH)[0];
if(px2<0||px2>imgW) continue;
p.xCtx.beginPath(); p.xCtx.moveTo(px2,0); p.xCtx.lineTo(px2,TICK); p.xCtx.stroke();
// Skip label if too close to the previous one
if(px2-lastPx>=minLabelGap){
// Skip label if too close to the previous one (either direction: a
// descending axis walks the gutter right to left)
if(Math.abs(px2-lastPx)>=minLabelGap){
const txt=fmtVal(v);
// Nudge edge labels inward so they are never clipped by the canvas
const hw=p.xCtx.measureText(txt).width/2;
Expand All @@ -3549,8 +3553,7 @@ function render({ model, el, onResize, onReadout }) {
p.yCtx.fillStyle=theme.axisBg; p.yCtx.fillRect(0,0,aw,ah);
p.yCtx.strokeStyle=theme.axisStroke; p.yCtx.lineWidth=1;
p.yCtx.beginPath(); p.yCtx.moveTo(aw,0); p.yCtx.lineTo(aw,ah); p.yCtx.stroke();
const [yF0,yF1]=_visFrac(zoom,cy);
const yVMin=_axisFracToVal(yArr,yF0), yVMax=_axisFracToVal(yArr,yF1);
const [yVMin,yVMax]=_visRange(yArr,ih,1);
const step=findNice((yVMax-yVMin)/Math.max(3,Math.floor(imgH/60)));
p.yCtx.strokeStyle=theme.tickStroke;
p.yCtx.fillStyle=theme.tickText; p.yCtx.font=(st.tick_size||10)+'px sans-serif';
Expand All @@ -3561,11 +3564,10 @@ function render({ model, el, onResize, onReadout }) {
let lastPy=-Infinity;
for(let ti=0;ti<yTicks.length;ti++){
const v=yTicks[ti];
const frac=_axisValToFrac(yArr,v);
const py2=_fracToPx(frac,zoom,cy,imgH);
const py2=_imgToCanvas2d(0,_valToImg(yArr,v,ih),st,imgW,imgH)[1];
if(py2<0||py2>imgH) continue;
p.yCtx.beginPath(); p.yCtx.moveTo(aw,py2); p.yCtx.lineTo(aw-TICK,py2); p.yCtx.stroke();
if(py2-lastPy>=minLabelGapY){
if(Math.abs(py2-lastPy)>=minLabelGapY){
// Nudge edge labels inward so digits are never cut by the canvas
const vh=(st.tick_size||10)*0.5+1;
p.yCtx.fillText(fmtVal(v), aw-TICK-2, Math.min(Math.max(py2,vh), ah-vh));
Expand Down Expand Up @@ -8001,6 +8003,25 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
return arr[lo] + (pos - lo) * (arr[lo + 1] - arr[lo]);
}

// The inverse for imshow's centre arrays: an axis value → the centre-convention
// image coordinate it sits at, continued linearly past either end (the tick
// gutters place labels in the outer half of the edge pixels through this).
// `arr` is monotonic, ascending or descending (origin='lower' reverses y).
function _axisValToImg2d(arr, v, n) {
const m = arr.length - 1;
if (m < 1) return 0;
const asc = arr[m] >= arr[0];
// The last segment start at or before v — segment 0 below the range, m-1 above.
let lo = 0, hi = m - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (asc ? arr[mid] <= v : arr[mid] >= v) lo = mid; else hi = mid - 1;
}
const d = arr[lo + 1] - arr[lo];
const pos = lo + (d ? (v - arr[lo]) / d : 0);
return n > 1 ? pos * (n - 1) / m : 0;
}

function _attachEvents2d(p) {
const { overlayCanvas } = p;
let localOnly = false;
Expand Down
Loading
Loading