plotpress 0.23.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2849 @@
1
+ """Vanilla-JS interactivity injected into interactive HTML/pop-up output.
2
+
3
+ Fully self-contained (no external requests) so it works under strict CSPs such
4
+ as Jupyter and sandboxed webviews.
5
+
6
+ A menu bar docked above the figure selects one **mode** at a time; nothing is
7
+ interactive until a mode is chosen (single selection -- picking one cancels the
8
+ others). A single click on a mode item selects it without closing its own
9
+ menu (a checkable item, not a one-shot action); double-click the active one
10
+ to deselect it back to no tool active -- a persistent mode indicator to the
11
+ menu bar's own right always shows which, if any, is active, with no menu
12
+ needing to be open. Text on the figure is left unselectable for as long as
13
+ any mode is active -- every mode's own drag (a pan, a rubber-band box,
14
+ dragging a pin's own label box) can sweep across tick labels, titles, or
15
+ another pin's text the same way Pan/Zoom's whole-figure pan always
16
+ could, so the selection guard isn't scoped to any one of them.
17
+
18
+ * **Pan/Zoom** (zoom-in cursor, internal mode ``magnify``) -- the
19
+ same whole-figure wheel zoom as Ctrl+wheel under Axis Zoom, but a *plain*
20
+ wheel, no Ctrl needed -- for wherever holding Ctrl is awkward, or a
21
+ browser/OS extension already claims it. Deliberately its own mode rather
22
+ than folded into Axis Zoom: selecting it is an explicit choice to have
23
+ this figure capture the page's scroll, so it never surprises a reader who
24
+ just wanted Axis Zoom's rubber-band drag. Drag pans the same whole-figure
25
+ view (native scroll under the hood) in any direction, so a zoomed-in
26
+ figure stays fully reachable without switching to Axis Span -- always the
27
+ figure's view, never an axes' own data range, isolating it completely
28
+ from per-axes zoom/pan. Double-click resets that view (there is no
29
+ per-axes zoom here to reset the way Axis Span/Zoom's double-click does).
30
+ Sits standalone at the toolbar's far left, not behind a menu -- the one
31
+ whole-figure-level navigation tool, reached for often enough to be worth
32
+ skipping a menu's extra click (see below).
33
+ * **Axis Span** (internal mode ``span``) -- drag to pan (grab cursor).
34
+ * **Axis Zoom** (internal mode ``zoom``) -- two distinct gestures (crosshair
35
+ cursor). Drag a rubber-band box to zoom *one axes* into it, in data space
36
+ (ticks recompute). Ctrl+wheel (or a trackpad pinch, which the browser
37
+ reports the same way) zooms the *whole figure* instead, centered on the
38
+ cursor, regardless of which axes (if any) is under it -- the useful
39
+ gesture on a figure with many small axes, where "zoom whatever tiny panel
40
+ the cursor happens to be over" wouldn't be. It grows the SVG's own
41
+ rendered size rather than cropping its viewBox, so it never touches any
42
+ axes' data range, ticks, or pick data -- and the overflow past the
43
+ viewport is real, native-scrollable page content, not merely a cropped
44
+ coordinate system with nothing for a scrollbar to reach. A plain wheel
45
+ (no Ctrl) is left alone to scroll the page as it would over any other
46
+ content.
47
+ * **Reset All Axes** -- restores every axes' own pan/zoom (Axis Span/Zoom's
48
+ per-axes data range) back to its original view; leaves whole-figure
49
+ magnification and every pin/annotation untouched. A one-shot action, not
50
+ a mode. In Axis Span/Zoom mode, double-clicking a single plot resets only
51
+ that plot, the same as this does for all of them at once. Sits right
52
+ after Axis Span/Zoom -- the pair of tools it undoes.
53
+ * **Home** -- restores whole-figure magnification (Pan/Zoom
54
+ or Ctrl+wheel-under-Axis-Zoom) back to its natural size; leaves every
55
+ axes' own pan/zoom and every pin/annotation untouched. A figure-level
56
+ action, not a mode -- it doesn't select/deselect anything, just fires
57
+ once. Sits standalone right after Pan/Zoom, the tool it undoes -- neither
58
+ Reset button (this one or Reset All Axes, in the Axes menu) clears
59
+ pins/annotations -- a view reset repositions them (they already track
60
+ pan/zoom live, the same machinery an Axis Span drag uses), it doesn't
61
+ delete them; that's what Clear Points/Point Picking's own
62
+ click-a-pin-to-remove-it are for.
63
+ * **Point Picking** (internal mode ``pick``) -- click a plot to pin an
64
+ annotation of the value there; snaps to the nearest data point, else a
65
+ free coordinate readout (arrow cursor). Click a pin to remove it, or use
66
+ the **Clear Points** button/Escape to remove all of them at once. A
67
+ marker's own dot scales with the axes it lands on, so it never dwarfs a
68
+ tiny panel in a large grid, and stays that same on-screen size at any
69
+ whole-figure zoom level (Pan/Zoom or Axis Zoom's Ctrl+wheel) -- growing
70
+ right along with the rest of the figure would otherwise turn a readable
71
+ dot into a blob covering the very cell it's pointing at a few zoom ticks
72
+ later, defeating the point of zooming in to see it more clearly. Its
73
+ label box sits
74
+ offset from the dot by default; a thin leader line (arrowhead on the dot
75
+ end) connects the two whenever the box isn't already touching the dot,
76
+ and the box itself is draggable -- grab it (not the dot) and move it
77
+ wherever reads best, while Point Picking is the active mode -- without
78
+ moving the dot off the data point it represents.
79
+ A dragged position sticks through every later pan/zoom/arrow-key step and
80
+ a Save/Save As round trip, the same as everything else about the pin.
81
+ * **Hide Points** -- hides every Point Picking pin without deleting any of
82
+ them; toggling it back to "Show Points" brings them back exactly as they
83
+ were. Independent of Hide Annotations below -- an Annotation note stays
84
+ visible while Hide Points is on, and vice versa.
85
+ * **Clear Points** -- removes every Point Picking pin, and *only* those --
86
+ an Annotation note survives a Clear Points click untouched. Sits right
87
+ after Point Picking, the tool it clears. A one-shot action, not a mode.
88
+ * **Annotation** (internal mode ``note-free``) -- drop a user-written note
89
+ anywhere on the figure, not locked to any datum -- including the margins
90
+ or the gap between subplots. Inside an axes it still tracks that axes'
91
+ data coordinate; outside one it just stays at its fixed figure position.
92
+ (A separate snap-to-nearest-datum variant, "Annotate Point," existed
93
+ briefly here and was removed -- Point Picking already covers snapping to
94
+ a datum; Annotation covers everything else, including a note dropped near
95
+ but not exactly on a point.) Its own box is draggable the same way a
96
+ Point Picking pin's is -- see above -- while Annotation is the active
97
+ mode, independent of a Point Picking pin's own dragging (see
98
+ boxDraggableNow: each kind of pin only drags under the mode that would
99
+ have created it).
100
+ * **Hide Annotations** -- the mirror of Hide Points: hides every Annotation
101
+ note without deleting any of them, *plus* every boxed
102
+ ``ax.text()``/``ax.annotate(bbox=...)`` callout the figure itself drew (a
103
+ plain, unboxed label is not a callout in this sense and always stays
104
+ visible) -- a static callout reads the same way on screen as a note, and
105
+ is closer in spirit to one than to a picked data point. Toggling it back
106
+ to "Show Annotations" brings everything back exactly as it was, including
107
+ any text or selection state -- it only ever flips a CSS display rule,
108
+ never touches the underlying marker/text data.
109
+ * **Clear Annotations** -- the mirror of Clear Points: removes every
110
+ Annotation note, and *only* those -- a Point Picking pin survives
111
+ untouched. Sits right after Annotation, the tool it clears. A one-shot
112
+ action, not a mode. (Escape still clears everything at once, both kinds
113
+ -- the one place "clear all" still means literally all -- and, unlike
114
+ either Clear button, also deselects the active tool, back to no tool
115
+ active; see below.)
116
+
117
+ Pan/Zoom and Home sit standalone at the bar's far left, not
118
+ behind a menu -- the whole-figure-scoped tool reached for most, and the
119
+ reset that undoes it, close enough at hand that a menu's extra click to get
120
+ to them isn't worth paying every time. Everything else groups into four
121
+ menus by what it does, not the order features were added in --
122
+ **Axes**: Axis Span/Zoom, then Reset All Axes, the pair it undoes.
123
+ **Point Picking**: the tool, Hide Points, Clear Points, and Extract --
124
+ Extract lives here, not in its own menu or under Annotate, because it only
125
+ ever returns Point Picking markers (see below). **Annotate**: the tool,
126
+ Hide Annotations, then Clear Annotations. **File**: Save, Save As. A
127
+ caller's own custom tools get a fifth **Custom** menu, created lazily on
128
+ first ``plotpressAddTool()`` call -- never folded into a built-in one.
129
+
130
+ **Extract** opens a panel to copy out picked points (not annotations -- see
131
+ ``doExtract()``) as CSV.
132
+
133
+ **Save As** downloads the current page -- pan/zoom, every pin/annotation,
134
+ hidden-legend-series toggles, and Hide Points/Hide Annotations -- as a new,
135
+ equally self-contained HTML file: reopening it resumes exactly where this
136
+ session left off, not just what was originally plotted. **Save** does the same but
137
+ tries to overwrite the file this page was opened from instead of downloading
138
+ a new one; that needs the File System Access API (Chromium, a secure
139
+ context), so elsewhere it falls back to the same download Save As does.
140
+ Both work the same way inside a :class:`~plotpress.Report`'s embedded
141
+ figure -- each panel is its own independent document, so saving from one
142
+ saves only that panel, not the whole report.
143
+
144
+ Legend entries remain clickable to toggle series regardless of mode.
145
+ """
146
+
147
+ _JS_SOURCE = r"""
148
+ (function () {
149
+ var svg = document.getElementById('plotpress-svg');
150
+ if (!svg) return;
151
+ // Captured before anything below mutates the DOM (the toolbar, its
152
+ // injected <style>, sliders, ...) -- Save/Save As (far below) rebuild a
153
+ // fresh copy of the page from this, plus one new payload script tag, so
154
+ // the saved file's own toolbar script starts from the same clean slate
155
+ // this one did rather than duplicating whatever this session has already
156
+ // added to the live document.
157
+ var ORIGINAL_DOC_HTML = document.documentElement.outerHTML;
158
+ var SVGNS = 'http://www.w3.org/2000/svg';
159
+ // The arrowhead every pin's box-to-dot leader line ends in (see
160
+ // layoutPinArrow) -- one shared <marker> def, not one per pin, the same
161
+ // "define once" reasoning as the injected <style> block below. Added
162
+ // after ORIGINAL_DOC_HTML above is captured, so a Save/Save As copy's own
163
+ // script (re-run fresh on that copy's own load) inserts its own rather
164
+ // than inheriting two.
165
+ var defs = document.createElementNS(SVGNS, 'defs');
166
+ var arrowMarker = document.createElementNS(SVGNS, 'marker');
167
+ arrowMarker.setAttribute('id', 'plotpress-pin-arrow');
168
+ arrowMarker.setAttribute('viewBox', '0 0 8 8');
169
+ arrowMarker.setAttribute('refX', '7'); arrowMarker.setAttribute('refY', '4');
170
+ arrowMarker.setAttribute('markerWidth', '6'); arrowMarker.setAttribute('markerHeight', '6');
171
+ arrowMarker.setAttribute('orient', 'auto');
172
+ var arrowHead = document.createElementNS(SVGNS, 'path');
173
+ arrowHead.setAttribute('d', 'M0,0 L8,4 L0,8 Z'); arrowHead.setAttribute('fill', '#666');
174
+ arrowMarker.appendChild(arrowHead);
175
+ defs.appendChild(arrowMarker);
176
+ svg.insertBefore(defs, svg.firstChild);
177
+ var vb = svg.getAttribute('viewBox').split(/\s+/).map(Number);
178
+ var home = vb.slice();
179
+ // `view` itself never changes any more -- kept only so pxPerUser() below
180
+ // (unchanged) keeps reading a correct px-per-user-unit ratio, since it
181
+ // divides the SVG's *rendered* CSS width by this. Whole-figure zoom now
182
+ // grows/shrinks that rendered width directly (see zoomTo/applyZoomSize)
183
+ // instead of cropping the viewBox, so real content overflows the page for
184
+ // the browser's own scrollbars to reach -- cropping left nothing for a
185
+ // scrollbar to scroll, since the SVG's on-page size never changed; only
186
+ // custom drag-to-pan could reach the rest of a zoomed-in figure.
187
+ var view = vb.slice();
188
+ var zoomScale = 1;
189
+ // The SVG's own on-page CSS size at zoomScale 1 -- the baseline zoomTo()
190
+ // scales from, and what a pin's own 1/zoomScale compensation (see
191
+ // layoutPin) assumes applyZoomSize is scaling up from. Read once, now: by
192
+ // the time this script runs (placed right after the SVG in the
193
+ // document), the browser has already laid it out, so this reflects its
194
+ // true natural size (fixed pixels in a standalone file; whatever its
195
+ // container currently resolves width:100% to, embedded) -- inserting the
196
+ // menu bar later doesn't change it, since the bar is position:fixed and
197
+ // so never participates in document flow/layout at all.
198
+ var naturalW = svg.getBoundingClientRect().width;
199
+ var naturalH = svg.getBoundingClientRect().height;
200
+ var wrap = null; // container holding the svg (for docked sliders)
201
+ var dockedSliders = []; // [{box, axesKey}] repositioned on pan/zoom
202
+ var CURRENT_FRAME = {}; // slider unit -> current frame index
203
+ var FRAME_INDEX = {}; // frame series id -> {entry, axesKey}
204
+ var selectedPin = null; // pin currently selected for arrow-key movement
205
+ function apply() {
206
+ svg.setAttribute('viewBox', view.join(' '));
207
+ positionDocked();
208
+ }
209
+
210
+ // Re-measure the natural size on a window resize, but only while at 1x --
211
+ // svg.style.width/height are unset there, so getBoundingClientRect() still
212
+ // reflects the page's own current sizing rather than a stale zoomed value.
213
+ // (A resize *while* zoomed is left as a known gap: rare enough, and there
214
+ // is no natural size to re-derive from at that point anyway.)
215
+ window.addEventListener('resize', function () {
216
+ if (zoomScale === 1) {
217
+ naturalW = svg.getBoundingClientRect().width;
218
+ naturalH = svg.getBoundingClientRect().height;
219
+ }
220
+ });
221
+
222
+ function applyZoomSize() {
223
+ var zoomed = zoomScale > 1;
224
+ document.body.classList.toggle('plotpress-zoomed', zoomed);
225
+ if (zoomed) {
226
+ svg.style.width = (naturalW * zoomScale) + 'px';
227
+ svg.style.height = (naturalH * zoomScale) + 'px';
228
+ } else {
229
+ svg.style.width = ''; svg.style.height = '';
230
+ }
231
+ // Every pin's own 1/zoomScale compensation (see layoutPin) has to be
232
+ // refreshed here too, not just when a pin is first dropped or moved --
233
+ // otherwise a pin placed *before* this zoom change keeps whatever
234
+ // scale factor it was born with, drifting out of sync with pins
235
+ // dropped after it.
236
+ document.querySelectorAll('.plotpress-pin').forEach(updatePinTransform);
237
+ positionDocked();
238
+ }
239
+
240
+ // Whole-figure zoom, centered on the cursor. Grows/shrinks the SVG's own
241
+ // rendered CSS size (never its viewBox or any axes' data range), so it's
242
+ // the gesture that works uniformly across a figure with many small axes,
243
+ // unlike a per-axes data zoom that only affects whichever panel happens to
244
+ // be under the cursor -- and so the browser's native scrollbars, not a
245
+ // custom drag, are what reach the rest of a zoomed-in figure. Clamped to
246
+ // never shrink below the figure's own natural size (zooming "out" past
247
+ // that has nothing left to reveal) or grow past a point where scrolling
248
+ // further would gain nothing but more of it.
249
+ // The compensating scroll below can only re-anchor the cursor's point once
250
+ // there is somewhere to scroll *to* -- while the zoomed-in figure still
251
+ // fits inside the viewport with room to spare, there is no overflow yet
252
+ // for scrollBy() to spend, and the point under the cursor drifts slightly
253
+ // for these first few ticks (layout alone decides where the bigger SVG
254
+ // lands). Self-corrects the moment real overflow exists, which is also
255
+ // the moment "did the point stay under the cursor" starts to matter --
256
+ // nothing is scrolled out of view yet at this stage regardless.
257
+ function zoomTo(clientX, clientY, factor) {
258
+ var newScale = Math.max(1, Math.min(20, zoomScale * factor));
259
+ if (newScale === zoomScale) return;
260
+ var before = svg.getBoundingClientRect();
261
+ var fx = (clientX - before.left) / before.width;
262
+ var fy = (clientY - before.top) / before.height;
263
+ zoomScale = newScale;
264
+ applyZoomSize();
265
+ var after = svg.getBoundingClientRect();
266
+ window.scrollBy(
267
+ (after.left + fx * after.width) - clientX,
268
+ (after.top + fy * after.height) - clientY
269
+ );
270
+ }
271
+
272
+ // Map an svg user-space point to pixels within the svg wrapper (honors the
273
+ // current viewBox, so docked sliders track their axes during pan/zoom).
274
+ function positionDocked() {
275
+ if (!wrap || !dockedSliders.length) return;
276
+ var wr = wrap.getBoundingClientRect();
277
+ var ctm = svg.getScreenCTM();
278
+ dockedSliders.forEach(function (ds) {
279
+ var m = META[ds.axesKey];
280
+ if (!m) return;
281
+ var pt = svg.createSVGPoint();
282
+ pt.x = m.x + m.w / 2; pt.y = m.y + m.h;
283
+ var s = pt.matrixTransform(ctm);
284
+ ds.box.style.left = Math.round(s.x - wr.left - ds.box.offsetWidth / 2) + 'px';
285
+ ds.box.style.top = Math.round(s.y - wr.top + 30) + 'px';
286
+ });
287
+ }
288
+
289
+ // IEEE 754 half-precision (float16) -> plain JS number. There's no native
290
+ // Float16Array yet, so a mesh/series array narrow enough to fit in
291
+ // float16 (see figure._fits_float16) decodes through this instead of a
292
+ // free typed-array view.
293
+ function halfToFloat(h) {
294
+ var s = (h & 0x8000) ? -1 : 1, e = (h & 0x7C00) >> 10, f = h & 0x03FF;
295
+ if (e === 0) return s * Math.pow(2, -14) * (f / 1024);
296
+ if (e === 0x1F) return f ? NaN : s * Infinity;
297
+ return s * Math.pow(2, e - 15) * (1 + f / 1024);
298
+ }
299
+
300
+ function b64ToBytes(b64) {
301
+ var bin = atob(b64);
302
+ var bytes = new Uint8Array(bin.length);
303
+ for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
304
+ return bytes;
305
+ }
306
+
307
+ // A long numeric array (a mesh z grid, an animated line's per-frame Y)
308
+ // embeds as {"__f32__": "<base64>"} or {"__f16__": "<base64>"} rather than
309
+ // JSON number text -- see figure._encode_binary_arrays for why, and which
310
+ // width. Reverse it in place: a Float32Array indexes and iterates just
311
+ // like the plain Array it replaces, so nothing downstream needs to know
312
+ // which one it got.
313
+ function reviveBinary(obj) {
314
+ if (obj && typeof obj === 'object') {
315
+ if (typeof obj.__f32__ === 'string') {
316
+ return new Float32Array(b64ToBytes(obj.__f32__).buffer);
317
+ }
318
+ if (typeof obj.__f16__ === 'string') {
319
+ var u16 = new Uint16Array(b64ToBytes(obj.__f16__).buffer);
320
+ var out = new Float32Array(u16.length);
321
+ for (var h = 0; h < u16.length; h++) out[h] = halfToFloat(u16[h]);
322
+ return out;
323
+ }
324
+ if (Array.isArray(obj)) {
325
+ for (var j = 0; j < obj.length; j++) obj[j] = reviveBinary(obj[j]);
326
+ } else {
327
+ for (var k in obj) obj[k] = reviveBinary(obj[k]);
328
+ }
329
+ }
330
+ return obj;
331
+ }
332
+
333
+ // meta embeds column-wise (one array per field, one key list total) when
334
+ // binary_pick_data=True -- see figure._columnarize_meta -- or the plain
335
+ // {axesIndex: {field: value}} shape when it's False. Detect which (a
336
+ // legitimate per-axes object never has literal "cols"/"index"/"keys"
337
+ // properties, since axes indices are plain integers) and always return
338
+ // the latter, so everything downstream keeps reading
339
+ // META[axesIndex].field exactly as before either way.
340
+ function expandColumnarMeta(payload) {
341
+ if (!payload || !payload.cols || !payload.index || !payload.keys) return payload;
342
+ var out = {};
343
+ for (var i = 0; i < payload.index.length; i++) {
344
+ var entry = {};
345
+ for (var k = 0; k < payload.keys.length; k++) {
346
+ var key = payload.keys[k];
347
+ entry[key] = payload.cols[key][i];
348
+ }
349
+ out[payload.index[i]] = entry;
350
+ }
351
+ return out;
352
+ }
353
+
354
+ var metaEl = document.getElementById('plotpress-meta');
355
+ var META = metaEl ? expandColumnarMeta(reviveBinary(JSON.parse(metaEl.textContent))) : {};
356
+ var styleEl = document.getElementById('plotpress-style');
357
+ var STYLE = styleEl ? JSON.parse(styleEl.textContent) : {};
358
+
359
+ // CUR holds each axes' *current* limits (mutated by per-axes data zoom);
360
+ // META stays the original. All data<->pixel math reads CUR; the artist zoom
361
+ // group is remapped by an affine from META (original) to CUR (current).
362
+ var CUR = {};
363
+ Object.keys(META).forEach(function (k) {
364
+ CUR[k] = {}; for (var f in META[k]) CUR[k][f] = META[k][f];
365
+ });
366
+
367
+ var mode = null; // null => inert (no interaction) by default
368
+ var down = null, moved = false, panV = null, rubber = null, panAxes = null;
369
+
370
+ // ---- toolbar -----------------------------------------------------------
371
+ // A docked menu bar (Axes / Point Picking / Annotate / File) -- docked in
372
+ // the sense that it's a real, single row spanning the figure's own
373
+ // width, not the sense that it takes up layout space of its own:
374
+ // position:fixed, pinned to the viewport's top-left corner, exactly like
375
+ // the flat toolbar this design replaces. That's deliberate, not an
376
+ // oversight -- an in-flow bar (this design's first attempt) scrolls away
377
+ // with the rest of the page the moment Pan/Zoom's whole-figure
378
+ // Magnify makes the figure bigger than the window and the user pans or
379
+ // scrolls to reach the rest of it, and position:sticky (the natural next
380
+ // attempt, "in flow until you'd scroll past it, then pinned") turned out
381
+ // not to reliably track a *dynamically* resized ancestor's bounds across
382
+ // browsers either -- so plotpress/figure.py's _toolbar_clearance is back
383
+ // to reserving real padding for it (standalone=False's body, and
384
+ // Report.save's <iframe> height guess), the same job it always did.
385
+ var style = document.createElement('style');
386
+ style.textContent =
387
+ // width:100% -- not a JS-computed pin to the figure's own width -- so
388
+ // the bar always spans the entire window, independent of how wide any
389
+ // one figure on the page happens to be (a position:fixed element's
390
+ // percentage width resolves against the viewport itself, the initial
391
+ // containing block, not against any narrower ancestor). The mode
392
+ // indicator's own margin-left:auto (see .plotpress-mode-indicator
393
+ // below) then rides the far right edge of that full-width bar.
394
+ // overflow: default (visible) is deliberate, not an oversight -- a
395
+ // dropdown (.plotpress-menu-dropdown below) is an absolutely
396
+ // positioned descendant that pops open *below* this row's own box, and
397
+ // setting overflow-x to anything but visible here (even leaving
398
+ // overflow-y itself unset) computes overflow-y to auto too, silently
399
+ // clipping every open dropdown out of view. white-space:nowrap on the
400
+ // label/button rules below still keeps every label from wrapping onto
401
+ // a second line even on a figure narrower than the bar's full content
402
+ // needs; on a genuinely narrow window the rightmost items (the mode
403
+ // indicator especially) can render past the visible edge with no way
404
+ // to scroll to them, a real but much rarer tradeoff than dropdowns
405
+ // that never show at all.
406
+ '.plotpress-menubar{display:flex;align-items:center;gap:2px;' +
407
+ 'position:fixed;top:0;left:0;z-index:1500;width:100%;box-sizing:border-box;' +
408
+ 'padding:5px 8px;background:#fafbfc;' +
409
+ 'border-bottom:1px solid #d5d9e0;font:12px system-ui,sans-serif}' +
410
+ '.plotpress-menu{position:relative}' +
411
+ '.plotpress-menu-label{display:flex;align-items:center;gap:5px;' +
412
+ 'padding:5px 10px;border:1px solid transparent;background:transparent;' +
413
+ 'color:#222;border-radius:6px;cursor:pointer;white-space:nowrap;' +
414
+ 'font:600 12px system-ui,sans-serif}' +
415
+ '.plotpress-menu-label:hover{background:#eef0f3}' +
416
+ '.plotpress-menu.open .plotpress-menu-label{background:#e8eeff;' +
417
+ 'color:#2b5bd7}' +
418
+ '.plotpress-chev{font-size:9px;opacity:.6}' +
419
+ '.plotpress-menu-dropdown{position:absolute;top:calc(100% + 5px);' +
420
+ 'left:0;min-width:170px;background:#fff;border:1px solid #b8b8b8;' +
421
+ 'border-radius:8px;box-shadow:0 6px 18px rgba(0,0,0,.16);padding:5px;' +
422
+ 'display:none;flex-direction:column;gap:1px;z-index:1000}' +
423
+ '.plotpress-menu.open .plotpress-menu-dropdown{display:flex}' +
424
+ // .plotpress-toolbar now names a dropdown's own item list -- kept as
425
+ // the class every button-styling rule below keys off, and what
426
+ // tests/test_pick_interactive.py's _click_mode() selects buttons by,
427
+ // stable across the redesign on purpose.
428
+ // No display: here on purpose -- a dropdown carries both
429
+ // .plotpress-menu-dropdown (display:none by default, display:flex only
430
+ // while .open, see above) and .plotpress-toolbar (kept as the stable
431
+ // class tests/test_pick_interactive.py's _click_mode() selects buttons
432
+ // by); giving this rule its own display:flex would tie its specificity
433
+ // with .plotpress-menu-dropdown's, and being the later rule, silently
434
+ // win, keeping every dropdown visible regardless of .open.
435
+ '.plotpress-toolbar{flex-direction:column;gap:1px}' +
436
+ '.plotpress-toolbar button{display:flex;align-items:center;gap:8px;' +
437
+ 'width:100%;text-align:left;padding:7px 9px;border:none;white-space:nowrap;' +
438
+ 'background:transparent;color:#222;border-radius:5px;cursor:pointer;' +
439
+ 'font:12px system-ui,sans-serif}' +
440
+ '.plotpress-toolbar button:hover{background:#f1f1f1}' +
441
+ '.plotpress-toolbar button.active{background:#2b8cff;color:#fff}' +
442
+ '.plotpress-toolbar button.toggled{background:#e8eeff;color:#2b5bd7}' +
443
+ // Pan/Zoom and Home sit directly on the bar, not behind a
444
+ // menu -- the whole-figure tool reached for constantly, and the reset
445
+ // that undoes it, close enough to be worth skipping a menu's extra
446
+ // click every time (see the standaloneGroup comment below). Still
447
+ // .plotpress-toolbar (the stable test-selector class, see above) so
448
+ // button/.active/.toggled styling and every existing click-by-label
449
+ // test helper keep working unchanged. Needs its own explicit
450
+ // display:flex, unlike a real dropdown -- .plotpress-toolbar itself
451
+ // carries none (see the comment on it above: a dropdown's display
452
+ // toggles via .plotpress-menu-dropdown/.open instead), but this group
453
+ // is never a .plotpress-menu-dropdown, so nothing else would ever give
454
+ // it one; flex-direction/width also overridden back to a normal
455
+ // horizontal bar group here, later in source than the column-flex
456
+ // .plotpress-toolbar rule above so it actually wins (same tied-
457
+ // specificity trap noted there).
458
+ '.plotpress-standalone-group{display:flex;flex-direction:row;gap:2px}' +
459
+ '.plotpress-standalone-group button{width:auto;font-weight:600}' +
460
+ '.plotpress-menubar-divider{width:1px;align-self:stretch;' +
461
+ 'background:#d5d9e0;margin:0 4px}' +
462
+ '.plotpress-menu-divider{height:1px;background:#e4e6ea;margin:4px 2px}' +
463
+ '.plotpress-mode-indicator{display:flex;align-items:center;gap:6px;' +
464
+ 'margin-left:auto;padding:4px 10px 4px 8px;background:#eef2ff;' +
465
+ 'border-radius:999px;font:500 11px system-ui,sans-serif;color:#2b5bd7;' +
466
+ 'white-space:nowrap}' +
467
+ '.plotpress-mode-dot{width:6px;height:6px;border-radius:50%;' +
468
+ 'background:#2b6cff;flex:none}' +
469
+ '.plotpress-sliders{position:fixed;bottom:12px;left:50%;' +
470
+ 'transform:translateX(-50%);display:flex;flex-direction:column;' +
471
+ 'gap:6px;z-index:1000}' +
472
+ '.plotpress-slider{display:flex;align-items:center;gap:12px;' +
473
+ 'background:#fff;padding:8px 16px;border:1px solid #b8b8b8;' +
474
+ 'border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.2);' +
475
+ 'font:12px system-ui,sans-serif}' +
476
+ '.plotpress-slider input[type=range]{width:240px}' +
477
+ '.plotpress-slider .val{min-width:90px;font-variant-numeric:tabular-nums}' +
478
+ '.plotpress-slider button{padding:3px 8px;border:1px solid #b8b8b8;' +
479
+ 'background:#fff;border-radius:5px;cursor:pointer;font-size:13px;' +
480
+ 'line-height:1.1}' +
481
+ '.plotpress-slider button:hover{background:#f1f1f1}' +
482
+ '.plotpress-slider .link{display:flex;align-items:center;gap:4px;' +
483
+ 'font-size:11px;color:#555;cursor:pointer;user-select:none}' +
484
+ '.plotpress-slider .idx{background:#e8eeff;border:1px solid #b9c6ef;' +
485
+ 'border-radius:4px;padding:0 5px;font-weight:600;color:#2b5bd7}' +
486
+ '.plotpress-pin.selected circle{fill:#2b8cff}' + /* r itself: selectPin(), scaled per-pin */
487
+ '.plotpress-pin.plotpress-note rect{fill:#b45309}' + /* user notes: amber */
488
+ // Hide Points/Hide Annotations toggle independently -- one class per
489
+ // kind, keyed the same way Clear Points/Clear Annotations and
490
+ // isAnnotationPin() already split .plotpress-pin by .plotpress-note.
491
+ '.plotpress-hide-points .plotpress-pin:not(.plotpress-note){display:none}' +
492
+ '.plotpress-hide-annotations .plotpress-pin.plotpress-note{display:none}' +
493
+ // "move" only on the box itself (not the dot, which stays a plain
494
+ // click target -- see contextmenu/click above) and only while the
495
+ // mode that would let a drag actually happen is active -- see
496
+ // boxDraggableNow/refreshDragReady.
497
+ '.plotpress-pin.plotpress-drag-ready rect,' +
498
+ '.plotpress-pin.plotpress-drag-ready text{cursor:move}' +
499
+ // A boxed ax.text()/ax.annotate() call -- see svg._render_text's
500
+ // plotpress-textbox group -- is a *static* callout the figure itself
501
+ // drew, not an interactive pin, but it reads the same way on screen and
502
+ // is closer in spirit to a user-written note than a picked data point,
503
+ // so Hide Annotations takes it too.
504
+ '.plotpress-hide-annotations .plotpress-textbox{display:none}' +
505
+ '.plotpress-zoom line,.plotpress-zoom path{vector-effect:non-scaling-stroke}' +
506
+ // Markers are the one exception: a marker's size represents a footprint
507
+ // on the data (scatter's `s=`, plot's `markersize=`), so a per-axes
508
+ // rubber-band zoom should grow/shrink it right along with the axis --
509
+ // unlike a line's stroke width, which stays a constant screen size on
510
+ // purpose. Without this, a marker sized for the full view stays exactly
511
+ // that many screen pixels after zooming into a small region and can
512
+ // swallow the entire (now much smaller) visible axis. Higher-specificity
513
+ // selector wins over the rule above regardless of source order.
514
+ '.plotpress-zoom .plotpress-marker path{vector-effect:none}' +
515
+ // Standalone's body centers the figure with flex, which clips (rather
516
+ // than making scrollable) any child that grows past it -- a zoomed-in
517
+ // SVG would be reachable on only one side, never both. Switching to
518
+ // block + overflow:auto for as long as the figure is actually zoomed
519
+ // (see applyZoomSize) restores real, both-directions scrolling; the
520
+ // default centered layout returns the moment zoomScale is back to 1.
521
+ 'body.plotpress-zoomed{display:block;overflow:auto}' +
522
+ '.plotpress-extract{position:fixed;top:44px;right:10px;width:360px;' +
523
+ 'max-height:72vh;overflow:auto;background:#fff;border:1px solid #b8b8b8;' +
524
+ 'border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.25);padding:10px;' +
525
+ 'z-index:2000;font:12px system-ui,sans-serif}' +
526
+ '.plotpress-extract textarea{width:100%;height:180px;box-sizing:border-box;' +
527
+ 'font:11px ui-monospace,monospace;resize:vertical}' +
528
+ '.plotpress-extract button{padding:4px 8px;border:1px solid #b8b8b8;' +
529
+ 'background:#fff;border-radius:5px;cursor:pointer}';
530
+ document.head.appendChild(style);
531
+
532
+ var menubar = document.createElement('div');
533
+ menubar.className = 'plotpress-menubar';
534
+ var menuNodes = [];
535
+ function closeAllMenus() {
536
+ menuNodes.forEach(function (m) { m.classList.remove('open'); });
537
+ }
538
+ function buildMenu(label) {
539
+ var menu = document.createElement('div');
540
+ menu.className = 'plotpress-menu';
541
+ var labelBtn = document.createElement('button');
542
+ labelBtn.className = 'plotpress-menu-label';
543
+ labelBtn.appendChild(document.createTextNode(label + ' '));
544
+ var chev = document.createElement('span');
545
+ chev.className = 'plotpress-chev';
546
+ chev.textContent = '▾';
547
+ labelBtn.appendChild(chev);
548
+ labelBtn.addEventListener('click', function (e) {
549
+ e.stopPropagation();
550
+ var willOpen = !menu.classList.contains('open');
551
+ closeAllMenus();
552
+ if (willOpen) menu.classList.add('open');
553
+ });
554
+ var dropdown = document.createElement('div');
555
+ dropdown.className = 'plotpress-toolbar plotpress-menu-dropdown';
556
+ menu.appendChild(labelBtn);
557
+ menu.appendChild(dropdown);
558
+ menubar.appendChild(menu);
559
+ menuNodes.push(menu);
560
+ return dropdown;
561
+ }
562
+ // Closing on any click outside every menu -- not just the menubar --
563
+ // covers interacting with the SVG itself (picking a point, panning) the
564
+ // same way a real desktop app's menu would: doing something with an open
565
+ // dropdown still showing dismisses it. Escape closes one too (see the
566
+ // keydown handler far below) -- but only that, when one is open: Escape
567
+ // otherwise means "clear every pin/annotation" (clearAllPins), and a menu
568
+ // being open must not silently reroute a plain "close this menu" press
569
+ // into wiping every pin instead.
570
+ document.addEventListener('click', function () { closeAllMenus(); });
571
+
572
+ // Pan/Zoom and Home sit standalone at the far left (see
573
+ // standaloneGroup below), not behind their own "Figure" menu -- an
574
+ // earlier version of this design tucked them (and Hide All) into one,
575
+ // but a menu just to hold the one tool reached for most, plus the reset
576
+ // that undoes it, cost a click every single time for no real grouping
577
+ // benefit; Hide All itself later split into the per-kind Hide
578
+ // Points/Hide Annotations that now live in the Point Picking/Annotate
579
+ // menus below, alongside Clear Points/Clear Annotations. Four menus for
580
+ // everything else, split by scope: whole-figure-vs-per-axes vs. Point
581
+ // Picking pulled onto its own, since it's the next most-reached-for
582
+ // tool. `row` from the old two-row layout becomes `menu` below; the
583
+ // reasoning for each button's own position otherwise carries over
584
+ // unchanged from the two-row design this replaces -- Axis Span/Zoom lead
585
+ // Axes, Reset All Axes after them -- the pair it undoes. Point Picking,
586
+ // Hide Points, Clear Points, and Extract share a menu now: Extract only
587
+ // ever returns Point Picking markers (see doExtract() below), so it
588
+ // moved out of a standalone spot in the old Annotation row to sit with
589
+ // the tool it actually reads from -- Annotate has no export of its own.
590
+ // Save/Save As get their own File menu instead of trailing Figure the
591
+ // way they trailed Navigation before -- persisting pan/zoom/pins/toggles
592
+ // is squarely "do something with the view", the reasoning that kept them
593
+ // off a row of their own previously, but a third or fourth *menu* costs
594
+ // nothing a third *row* wouldn't have.
595
+ var TOOLS = [
596
+ { mode: 'magnify', label: 'Pan/Zoom', standalone: true },
597
+ { action: 'reset-figure', label: 'Home', standalone: true },
598
+ { mode: 'span', label: 'Axis Span', menu: 'Axes' },
599
+ { mode: 'zoom', label: 'Axis Zoom', menu: 'Axes' },
600
+ { action: 'reset-axes', label: 'Reset All Axes', menu: 'Axes', divider: true },
601
+ { mode: 'pick', label: 'Point Picking', menu: 'Point Picking' },
602
+ { action: 'toggle-points', label: 'Hide Points', menu: 'Point Picking', divider: true },
603
+ { action: 'clear-points', label: 'Clear Points', menu: 'Point Picking', divider: true },
604
+ { action: 'extract', label: 'Extract', menu: 'Point Picking', divider: true },
605
+ { mode: 'note-free', label: 'Annotation', menu: 'Annotate' },
606
+ { action: 'toggle-annotations', label: 'Hide Annotations', menu: 'Annotate', divider: true },
607
+ { action: 'clear-annotations', label: 'Clear Annotations', menu: 'Annotate', divider: true },
608
+ { action: 'save', label: 'Save', menu: 'File' },
609
+ { action: 'save-as', label: 'Save As', menu: 'File' },
610
+ ];
611
+ var pointsHidden = false;
612
+ var annotationsHidden = false;
613
+ // Hide Points/Hide Annotations toggle independently -- one class per kind
614
+ // (see the .plotpress-hide-points/.plotpress-hide-annotations CSS rules
615
+ // above), the same split Clear Points/Clear Annotations already use.
616
+ // Hide Annotations additionally takes every figure-drawn boxed callout --
617
+ // not scoped to "annotations" specifically despite the CSS class name,
618
+ // see the CSS comment above for why it landed here rather than under
619
+ // Hide Points.
620
+ function togglePointsHidden(b) {
621
+ pointsHidden = !pointsHidden;
622
+ svg.classList.toggle('plotpress-hide-points', pointsHidden);
623
+ b.textContent = pointsHidden ? 'Show Points' : 'Hide Points';
624
+ b.classList.toggle('toggled', pointsHidden);
625
+ }
626
+ function toggleAnnotationsHidden(b) {
627
+ annotationsHidden = !annotationsHidden;
628
+ svg.classList.toggle('plotpress-hide-annotations', annotationsHidden);
629
+ b.textContent = annotationsHidden ? 'Show Annotations' : 'Hide Annotations';
630
+ b.classList.toggle('toggled', annotationsHidden);
631
+ }
632
+ // Pan/Zoom and Home sit directly on the bar, at the far
633
+ // left, rather than behind their own "Figure" menu -- reached for often
634
+ // enough (Pan/Zoom especially) that the extra click to open a
635
+ // menu first isn't worth paying every time, unlike everything else,
636
+ // which stays menu-grouped. Still .plotpress-toolbar (see the CSS
637
+ // comment above) so every button/.active/.toggled style and the existing
638
+ // click-by-label test helpers keep finding them the same way.
639
+ var standaloneGroup = document.createElement('div');
640
+ standaloneGroup.className = 'plotpress-toolbar plotpress-standalone-group';
641
+ menubar.appendChild(standaloneGroup);
642
+ var standaloneDivider = document.createElement('div');
643
+ standaloneDivider.className = 'plotpress-menubar-divider';
644
+ menubar.appendChild(standaloneDivider);
645
+
646
+ var DROPDOWN_FOR_MENU = {};
647
+ ['Axes', 'Point Picking', 'Annotate', 'File'].forEach(function (name) {
648
+ DROPDOWN_FOR_MENU[name] = buildMenu(name);
649
+ });
650
+
651
+ // A mode item is checkable, not a one-shot action: a single click selects
652
+ // it without closing its own menu, so picking a different tool from the
653
+ // same menu -- or double-clicking this one to clear it -- doesn't need
654
+ // reopening it first. A single click can't double as "click the active
655
+ // one again to turn it off" the way the old flat toolbar row's buttons
656
+ // could: in a menu, one click already means "choose this", so reusing it
657
+ // for "and now un-choose it" would be ambiguous. Double-click is
658
+ // unambiguous instead -- but naively wiring that as its own 'dblclick'
659
+ // listener (fired strictly after both 'click' events, per the DOM spec)
660
+ // deselects unconditionally: double-clicking a tool that was *not* yet
661
+ // active would still select-then-immediately-deselect it, since by the
662
+ // time 'dblclick' runs, this tool's own first click has already made it
663
+ // the active one. `e.detail` (the browser's own same-target click count)
664
+ // sidesteps that: captured only on a sequence's first click, so a second
665
+ // click can tell "was this already active before *this* gesture" apart
666
+ // from "just became active because of this gesture's own first click".
667
+ // `alwaysClose` is for the standalone group above -- it has no dropdown
668
+ // of its own to keep open, so selecting one of its tools should still
669
+ // close whatever *other* menu happens to be open, the same as any
670
+ // one-shot action does.
671
+ function attachModeButton(b, m, alwaysClose) {
672
+ var startedActive = false;
673
+ b.addEventListener('click', function (e) {
674
+ e.stopPropagation(); // else the document-level listener (below)
675
+ // sees the same click bubble up and closes
676
+ // this menu right back -- a mode selection is
677
+ // meant to stay open.
678
+ if (e.detail < 2) startedActive = (mode === m);
679
+ if (e.detail >= 2 && startedActive) { setMode(null); closeAllMenus(); return; }
680
+ setMode(m);
681
+ if (alwaysClose) closeAllMenus();
682
+ });
683
+ // Swallow the native dblclick too -- otherwise a fast double-click
684
+ // falls through to the browser's own default double-click text
685
+ // selection on whatever's nearby.
686
+ b.addEventListener('dblclick', function (e) { e.preventDefault(); });
687
+ }
688
+ var buttons = TOOLS.map(function (t) {
689
+ var container = t.standalone ? standaloneGroup : DROPDOWN_FOR_MENU[t.menu];
690
+ if (t.divider) {
691
+ var div = document.createElement('div');
692
+ div.className = 'plotpress-menu-divider';
693
+ container.appendChild(div);
694
+ }
695
+ var b = document.createElement('button');
696
+ b.textContent = t.label;
697
+ if (t.mode) {
698
+ b.dataset.mode = t.mode;
699
+ attachModeButton(b, t.mode, t.standalone);
700
+ } else {
701
+ b.addEventListener('click', function (e) {
702
+ e.stopPropagation(); // else the document-level listener (below)
703
+ // sees the same click bubble up and closes
704
+ // this menu right back.
705
+ if (t.action === 'extract') doExtract();
706
+ else if (t.action === 'toggle-points') togglePointsHidden(b);
707
+ else if (t.action === 'toggle-annotations') toggleAnnotationsHidden(b);
708
+ else if (t.action === 'save') overwriteCurrentPage();
709
+ else if (t.action === 'save-as') saveAsNewPage();
710
+ else if (t.action === 'reset-figure') { zoomScale = 1; applyZoomSize(); }
711
+ else if (t.action === 'reset-axes') resetAxes();
712
+ else if (t.action === 'clear-points') clearPointPins();
713
+ else if (t.action === 'clear-annotations') clearAnnotationPins();
714
+ closeAllMenus();
715
+ });
716
+ }
717
+ container.appendChild(b);
718
+ return b;
719
+ });
720
+
721
+ // Public extension point for a caller's own extra_js= (see Figure.to_html):
722
+ // add a tool to its own menu, created lazily on first call -- a page with
723
+ // no custom tools gets no empty extra menu to explain. Two shapes,
724
+ // mirroring TOOLS above -- {label, onClick}: an always-available action,
725
+ // firing immediately on click, like Extract/Save. {label, mode, onClick,
726
+ // onEnter, onExit, cursor}: a real *mode*, joining the same
727
+ // single-selection group as Pan/Zoom, Axis Span/Zoom, Point
728
+ // Picking, or Annotation -- picking it deselects whatever else was active,
729
+ // and vice versa (see setMode below, and the `buttons` array/dataset.mode
730
+ // CSS-'active' sync inside it, both of which already work for any button
731
+ // in `buttons` generically, custom or not). Selected, a click on the SVG
732
+ // that no built-in mode already claims (`note-free`/`pick` -- see the
733
+ // top-level click listener's own custom-mode fallback) calls
734
+ // onClick(event, toUser(event)) -- the same svg-event-to-user-space-point
735
+ // helper Span/Zoom/pick already build on, so a custom tool gets a real
736
+ // data-space point for free rather than raw client pixels. onEnter/onExit
737
+ // fire when the mode is selected/deselected (setMode's own prevMode
738
+ // bookkeeping below), and `cursor` sets svg.style.cursor while it's
739
+ // active, the same as a built-in mode's own fixed cursor choice does.
740
+ // One side effect a custom mode inherits with no opt-out: selecting it
741
+ // also disables text selection on the figure (setMode's own
742
+ // svg.style.userSelect line), the same as every built-in mode -- a
743
+ // custom tool whose own interaction depends on letting the user select
744
+ // text will need to restore it manually from its own onEnter/onExit.
745
+ var CUSTOM_MODES = {};
746
+ var customDropdown = null; // created on first addTool() call, not up front
747
+ function addTool(opts) {
748
+ var b = document.createElement('button');
749
+ b.textContent = opts.label;
750
+ if (opts.mode) {
751
+ b.dataset.mode = opts.mode;
752
+ CUSTOM_MODES[opts.mode] = opts;
753
+ attachModeButton(b, opts.mode);
754
+ } else {
755
+ b.addEventListener('click', function (ev) {
756
+ ev.stopPropagation();
757
+ if (opts.onClick) opts.onClick(ev);
758
+ closeAllMenus();
759
+ });
760
+ }
761
+ if (!customDropdown) {
762
+ customDropdown = buildMenu('Custom');
763
+ // buildMenu() appends to menubar's end, which -- since the mode
764
+ // indicator is already there by the time any addTool() call can run
765
+ // -- would otherwise land the Custom menu after it instead of
766
+ // alongside the five built-in ones.
767
+ menubar.insertBefore(customDropdown.parentElement, modeIndicator);
768
+ }
769
+ customDropdown.appendChild(b);
770
+ buttons.push(b);
771
+ return b;
772
+ }
773
+ window.plotpressAddTool = addTool;
774
+
775
+ var modeIndicator = document.createElement('span');
776
+ modeIndicator.className = 'plotpress-mode-indicator';
777
+ var modeDot = document.createElement('span');
778
+ modeDot.className = 'plotpress-mode-dot';
779
+ var modeText = document.createElement('span');
780
+ modeIndicator.appendChild(modeDot);
781
+ modeIndicator.appendChild(modeText);
782
+ menubar.appendChild(modeIndicator);
783
+
784
+ function modeLabel(m) {
785
+ if (!m) return 'No tool active';
786
+ for (var i = 0; i < TOOLS.length; i++) {
787
+ if (TOOLS[i].mode === m) return TOOLS[i].label;
788
+ }
789
+ return (CUSTOM_MODES[m] && CUSTOM_MODES[m].label) || m;
790
+ }
791
+
792
+ // Inserted as the very first thing in the body, same corner the old flat
793
+ // toolbar row always occupied -- harmless regardless of exactly where it
794
+ // sits in the DOM, since position:fixed ignores document flow/layout
795
+ // entirely and z-index (see the CSS above) settles any stacking order
796
+ // question on its own; first-child just keeps a reader's tab order
797
+ // matching what's visually first. Its width:100% (see the CSS above)
798
+ // needs no JS help spanning the window -- unlike the figure it sits
799
+ // above, which really is only ever as wide as naturalW/zoomScale say.
800
+ document.body.insertBefore(menubar, document.body.firstChild);
801
+
802
+ function setMode(m) {
803
+ // Cancel anything in progress and clear transient state.
804
+ down = null; removeRubber();
805
+ var prevMode = mode;
806
+ // Home and Reset All Axes are one-shot actions dispatched directly by
807
+ // their own button handler (see `buttons` above), not modes -- this
808
+ // only ever sees a real mode name now. Always sets the target mode
809
+ // directly, never toggles: a click on a menu item always selects it
810
+ // (see the buttons.map click handler above); double-click is the only
811
+ // way to deselect (see attachDeselect above), called with m=null.
812
+ mode = m;
813
+ // A custom tool's own onEnter/onExit (see addTool/plotpressAddTool) --
814
+ // fired after the mode itself has already changed, so either callback
815
+ // can safely read the new `mode`/call setMode() again without racing
816
+ // its own transition.
817
+ if (prevMode && CUSTOM_MODES[prevMode] && CUSTOM_MODES[prevMode].onExit) {
818
+ CUSTOM_MODES[prevMode].onExit();
819
+ }
820
+ if (mode && CUSTOM_MODES[mode] && CUSTOM_MODES[mode].onEnter) {
821
+ CUSTOM_MODES[mode].onEnter();
822
+ }
823
+ buttons.forEach(function (b) {
824
+ b.classList.toggle('active', b.dataset.mode === mode);
825
+ });
826
+ modeText.textContent = modeLabel(mode);
827
+ var custom = mode && CUSTOM_MODES[mode];
828
+ svg.style.cursor =
829
+ mode === 'span' ? 'grab' :
830
+ mode === 'zoom' ? 'crosshair' :
831
+ mode === 'magnify' ? 'zoom-in' :
832
+ mode === 'note-free' ? 'text' :
833
+ (custom && custom.cursor) ? custom.cursor : 'default';
834
+ // Any active mode's own drag can sweep across text the same way
835
+ // Magnify's whole-figure pan always could -- Span/Zoom drag across tick
836
+ // labels and titles, Point Picking/Annotation drag a pin's own text box
837
+ // across other pins' labels -- so disabling selection is scoped to
838
+ // "some tool is selected" generally, not just Magnify specifically.
839
+ // Inert (no mode) leaves normal text selection alone.
840
+ svg.style.userSelect = mode ? 'none' : '';
841
+ refreshDragReady();
842
+ }
843
+ setMode(null); // start inert with an arrow cursor
844
+
845
+ // ---- helpers -----------------------------------------------------------
846
+ function toUser(e) {
847
+ var pt = svg.createSVGPoint();
848
+ pt.x = e.clientX; pt.y = e.clientY;
849
+ return pt.matrixTransform(svg.getScreenCTM().inverse());
850
+ }
851
+ function pxPerUser() {
852
+ return svg.getBoundingClientRect().width / view[2];
853
+ }
854
+
855
+ // ---- rubber-band box (zoom mode) --------------------------------------
856
+ function startRubber(e) {
857
+ var p = toUser(e);
858
+ var el = document.createElementNS(SVGNS, 'rect');
859
+ el.setAttribute('class', 'plotpress-rubber');
860
+ el.setAttribute('fill', '#2b8cff'); el.setAttribute('fill-opacity', 0.15);
861
+ el.setAttribute('stroke', '#2b8cff');
862
+ el.setAttribute('stroke-width', 1 / pxPerUser());
863
+ svg.appendChild(el);
864
+ rubber = { x0: p.x, y0: p.y, el: el };
865
+ }
866
+ function updateRubber(e) {
867
+ var p = toUser(e), el = rubber.el;
868
+ el.setAttribute('x', Math.min(rubber.x0, p.x));
869
+ el.setAttribute('y', Math.min(rubber.y0, p.y));
870
+ el.setAttribute('width', Math.abs(p.x - rubber.x0));
871
+ el.setAttribute('height', Math.abs(p.y - rubber.y0));
872
+ }
873
+ function finishRubber(e) {
874
+ var p = toUser(e);
875
+ var x0 = Math.min(rubber.x0, p.x), y0 = Math.min(rubber.y0, p.y);
876
+ var x1 = Math.max(rubber.x0, p.x), y1 = Math.max(rubber.y0, p.y);
877
+ removeRubber();
878
+ if (x1 - x0 < 4 || y1 - y0 < 4) return;
879
+ var a = axesAt({ x: (x0 + x1) / 2, y: (y0 + y1) / 2 });
880
+ if (!a) return; // box-zoom the axes under the box
881
+ var c = CUR[a.i], d0 = toData(c, x0, y0), d1 = toData(c, x1, y1);
882
+ c.xmin = Math.min(d0.x, d1.x); c.xmax = Math.max(d0.x, d1.x);
883
+ c.ymin = Math.min(d0.y, d1.y); c.ymax = Math.max(d0.y, d1.y);
884
+ refreshAxes(a.i);
885
+ }
886
+ function removeRubber() {
887
+ if (rubber && rubber.el && rubber.el.parentNode) {
888
+ rubber.el.parentNode.removeChild(rubber.el);
889
+ }
890
+ rubber = null;
891
+ }
892
+
893
+ // ---- pan / zoom drivers ------------------------------------------------
894
+ svg.addEventListener('wheel', function (e) {
895
+ // Under Zoom, only Ctrl+wheel (or a trackpad pinch, which the browser
896
+ // reports as a wheel event with ctrlKey already set) zooms -- a plain
897
+ // scroll must fall through to the page's own scrolling untouched, the
898
+ // same as it would over any other content, rather than this figure
899
+ // hijacking it just because Zoom happens to be the active tool. Magnify
900
+ // is the explicit opt-in past that: selecting it says a plain wheel
901
+ // here should zoom, Ctrl or not -- for wherever holding Ctrl is awkward
902
+ // or already claimed by the browser/OS.
903
+ var zooming = mode === 'magnify' || (mode === 'zoom' && e.ctrlKey);
904
+ if (!zooming) return;
905
+ e.preventDefault();
906
+ zoomTo(e.clientX, e.clientY, e.deltaY < 0 ? 1.25 : 0.8);
907
+ }, { passive: false });
908
+
909
+ // Whole-figure pan: scrolls the page, exactly what the wheel now does
910
+ // under Magnify (or Ctrl+wheel under Zoom) by growing the SVG's own
911
+ // rendered size -- never an individual axes' own data range/ticks.
912
+ // Shared by Span's "over the margins" drag and Magnify's drag, so a
913
+ // zoomed-in view stays reachable in every direction without leaving the
914
+ // tool that zoomed it.
915
+ function panWholeFigureTo(e) {
916
+ window.scrollTo(panV.x - (e.clientX - down.x), panV.y - (e.clientY - down.y));
917
+ }
918
+
919
+ svg.addEventListener('mousedown', function (e) {
920
+ if (e.button !== 0) return; // ignore right/middle button (right = delete pin)
921
+ if (!mode || e.target.closest('.plotpress-pin')) return;
922
+ down = { x: e.clientX, y: e.clientY }; moved = false;
923
+ if (mode === 'span') {
924
+ var pdn = toUser(e), a = axesAt(pdn);
925
+ if (a) {
926
+ // per-axes data pan over a plot (directed edges: honors inverted axes)
927
+ panAxes = { key: a.i, downUser: pdn, start: edges(CUR[a.i]) };
928
+ } else {
929
+ panV = { x: window.scrollX, y: window.scrollY }; // over margins: whole-figure pan
930
+ }
931
+ svg.style.cursor = 'grabbing';
932
+ } else if (mode === 'zoom') { startRubber(e); }
933
+ else if (mode === 'magnify') {
934
+ // Always the whole-figure view, regardless of what's under the
935
+ // cursor -- Magnify never touches axes data, only what part of the
936
+ // rendered figure is currently visible (see the wheel handler above).
937
+ panV = { x: window.scrollX, y: window.scrollY };
938
+ svg.style.cursor = 'grabbing';
939
+ }
940
+ });
941
+ window.addEventListener('mousemove', function (e) {
942
+ if (!down) return;
943
+ if (Math.abs(e.clientX - down.x) + Math.abs(e.clientY - down.y) > 3) moved = true;
944
+ if (mode === 'span' && panAxes) {
945
+ var m = CUR[panAxes.key], s = panAxes.start, pc = toUser(e);
946
+ var dfx = (pc.x - panAxes.downUser.x) / m.w * (s.fx1 - s.fx0);
947
+ var dfy = (pc.y - panAxes.downUser.y) / m.h * (s.fy1 - s.fy0);
948
+ setXLim(m, s.fx0 - dfx, s.fx1 - dfx);
949
+ setYLim(m, s.fy0 + dfy, s.fy1 + dfy);
950
+ refreshAxes(panAxes.key);
951
+ } else if (mode === 'span' || mode === 'magnify') {
952
+ panWholeFigureTo(e);
953
+ } else if (mode === 'zoom' && rubber) {
954
+ updateRubber(e);
955
+ }
956
+ });
957
+ window.addEventListener('mouseup', function (e) {
958
+ if (!down) return;
959
+ if (mode === 'span') svg.style.cursor = 'grab';
960
+ else if (mode === 'magnify') svg.style.cursor = 'zoom-in';
961
+ else if (mode === 'zoom' && rubber) finishRubber(e);
962
+ down = null; panAxes = null;
963
+ });
964
+
965
+ // Double-click a plot (while panning/zooming) resets just that plot's view.
966
+ // Under Magnify, there is no per-axes view to reset -- only the whole
967
+ // figure's, exactly what its wheel zoom and drag pan both operate on (see
968
+ // above), so double-click resets that instead of doing nothing.
969
+ svg.addEventListener('dblclick', function (e) {
970
+ if (mode === 'magnify') {
971
+ e.preventDefault();
972
+ zoomScale = 1; applyZoomSize();
973
+ return;
974
+ }
975
+ if (mode !== 'span' && mode !== 'zoom') return;
976
+ e.preventDefault();
977
+ var a = axesAt(toUser(e));
978
+ if (a) resetAxesOne(a.i);
979
+ });
980
+
981
+ // ---- legend toggle (always available) ---------------------------------
982
+ document.querySelectorAll('.plotpress-legend text').forEach(function (t) {
983
+ var label = t.textContent;
984
+ t.style.cursor = 'pointer';
985
+ t.addEventListener('click', function (e) {
986
+ e.stopPropagation();
987
+ document.querySelectorAll('.plotpress-series').forEach(function (s) {
988
+ if (s.getAttribute('data-label') === label) {
989
+ var hidden = s.style.display === 'none';
990
+ s.style.display = hidden ? '' : 'none';
991
+ t.style.opacity = hidden ? '1' : '0.4';
992
+ }
993
+ });
994
+ });
995
+ });
996
+
997
+ // ---- point picking (pick mode) ----------------------------------------
998
+ var pickEl = document.getElementById('plotpress-pick');
999
+ var PICK = pickEl ? reviveBinary(JSON.parse(pickEl.textContent)) : {};
1000
+ var POINT_THRESHOLD = 28; // px: snap to an embedded point within this radius
1001
+ // A much tighter radius for a line/scatter point to win over a mesh cell
1002
+ // the click also landed inside (see resolvePickTarget) -- a deliberate,
1003
+ // precise click on a small marker should still win, but the loose 28px
1004
+ // snap radius above is generous enough that a line merely drawn near or
1005
+ // across a mesh (a threshold marker, a boundary trace) would otherwise
1006
+ // "steal" clicks plainly aimed at a mesh cell well away from the line
1007
+ // itself, just because one of the line's vertices happened to be within
1008
+ // 28px of it.
1009
+ var MESH_OVERRIDE_THRESHOLD = 10;
1010
+
1011
+ // PICK is a closure over this IIFE -- unreachable from outside, which is
1012
+ // exactly right for the embedded payload itself, but a live-updating
1013
+ // caller (plotpress.qt.LiveArtist) that patches the SVG in place via
1014
+ // page().runJavaScript() has no other way to keep picking in sync with
1015
+ // what's now on screen. axesIndex/entryJson mirror pick_data()'s own
1016
+ // per-axes shape ({"series":[...],"meshes":[...],"pies":[...]}), so this
1017
+ // is a straight swap, not a merge.
1018
+ window.plotpressUpdatePick = function (axesIndex, entryJson) {
1019
+ PICK[axesIndex] = JSON.parse(entryJson);
1020
+ };
1021
+
1022
+ // Highest index (most recently added) first, so an axes nested inside a
1023
+ // larger one -- an inset, or a twin/secondary overlaid on its parent --
1024
+ // wins the hit test. Ascending order always resolved to whichever axes was
1025
+ // created first, which for an inset meant its *parent*, making the inset
1026
+ // itself permanently unreachable by click, wheel, or drag.
1027
+ function axesAt(p) {
1028
+ var keys = Object.keys(CUR).map(Number).sort(function (a, b) { return b - a; });
1029
+ for (var idx = 0; idx < keys.length; idx++) {
1030
+ var k = String(keys[idx]), m = CUR[k];
1031
+ if (p.x >= m.x && p.x <= m.x + m.w && p.y >= m.y && p.y <= m.y + m.h) {
1032
+ return { i: k, m: m };
1033
+ }
1034
+ }
1035
+ return null;
1036
+ }
1037
+ // Point Picking only -- an axes with pickable=false (see
1038
+ // Axes.set_pickable) is treated as if the click missed every axes, so a
1039
+ // figure can restrict that tool to a single panel by disabling the rest.
1040
+ // Axis Span, Axis Zoom, Pan/Zoom, and Annotation go through
1041
+ // axesAt() directly and ignore this flag.
1042
+ function pickableAxesAt(p) {
1043
+ var a = axesAt(p);
1044
+ return (a && a.m.pickable === false) ? null : a;
1045
+ }
1046
+ function fwd(v, s) { return s === 'log' ? Math.log10(v) : v; }
1047
+ function inv(u, s) { return s === 'log' ? Math.pow(10, u) : u; }
1048
+
1049
+ // An axes' limits in transformed (log-aware) space, *directed*: on an
1050
+ // inverted axis they come back swapped, exactly as _render_axes swaps the
1051
+ // limits it hands LinearTransform. Everything that maps between data and
1052
+ // pixels goes through this, so inverted axes behave like normal ones.
1053
+ function edges(m) {
1054
+ var fx0 = fwd(m.xmin, m.xscale), fx1 = fwd(m.xmax, m.xscale);
1055
+ var fy0 = fwd(m.ymin, m.yscale), fy1 = fwd(m.ymax, m.yscale);
1056
+ if (m.xinv) { var tx = fx0; fx0 = fx1; fx1 = tx; }
1057
+ if (m.yinv) { var ty = fy0; fy0 = fy1; fy1 = ty; }
1058
+ return { fx0: fx0, fx1: fx1, fy0: fy0, fy1: fy1 };
1059
+ }
1060
+ // Directed transformed edges -> data limits (min/max, since inv is monotonic).
1061
+ function setXLim(m, a, b) {
1062
+ m.xmin = inv(Math.min(a, b), m.xscale); m.xmax = inv(Math.max(a, b), m.xscale);
1063
+ }
1064
+ function setYLim(m, a, b) {
1065
+ m.ymin = inv(Math.min(a, b), m.yscale); m.ymax = inv(Math.max(a, b), m.yscale);
1066
+ }
1067
+ function toPixel(m, dx, dy) {
1068
+ var e = edges(m);
1069
+ return { x: m.x + (fwd(dx, m.xscale) - e.fx0) / (e.fx1 - e.fx0) * m.w,
1070
+ y: m.y + (e.fy1 - fwd(dy, m.yscale)) / (e.fy1 - e.fy0) * m.h };
1071
+ }
1072
+ function toData(m, px, py) {
1073
+ var e = edges(m);
1074
+ return { x: inv(e.fx0 + (px - m.x) / m.w * (e.fx1 - e.fx0), m.xscale),
1075
+ y: inv(e.fy1 - (py - m.y) / m.h * (e.fy1 - e.fy0), m.yscale) };
1076
+ }
1077
+
1078
+ // ---- per-axes data zoom (client-side re-render) -----------------------
1079
+ function jsNiceTicks(lo, hi, n) {
1080
+ if (lo === hi) { lo -= 0.5; hi += 0.5; }
1081
+ var raw = (hi - lo) / (n || 5);
1082
+ var mag = Math.pow(10, Math.floor(Math.log10(raw))), norm = raw / mag, step;
1083
+ if (norm < 1.5) step = mag; else if (norm < 3) step = 2 * mag;
1084
+ else if (norm < 7) step = 5 * mag; else step = 10 * mag;
1085
+ var out = [];
1086
+ for (var v = Math.ceil(lo / step) * step; v <= hi + step * 1e-6; v += step) out.push(v);
1087
+ return { ticks: out, step: step };
1088
+ }
1089
+ // Match the Python renderer's exponential style: "1e5", "1.2e-4".
1090
+ function expFmt(v, digits) {
1091
+ var p = v.toExponential(digits).split('e');
1092
+ return p[0].replace(/\.?0+$/, '') + 'e' + parseInt(p[1], 10);
1093
+ }
1094
+ function fmtTick(v, step) {
1095
+ if (Math.abs(v) < step * 1e-6) return '0';
1096
+ var a = Math.abs(v);
1097
+ if (a >= 1e5 || a < 1e-3) return expFmt(v, 1);
1098
+ var dec = step >= 1 ? 0 : Math.min(6, Math.ceil(-Math.log10(step)));
1099
+ var out = v.toFixed(dec);
1100
+ return out.indexOf('.') >= 0 ? out.replace(/0+$/, '').replace(/\.$/, '') : out;
1101
+ }
1102
+ function fmtNum(v) {
1103
+ var a = Math.abs(v);
1104
+ if (a !== 0 && (a < 1e-3 || a >= 1e5)) return expFmt(v, 0);
1105
+ return (Math.round(v * 1e6) / 1e6).toString();
1106
+ }
1107
+ // Mirrors ticker.log_ticks: decades *inside* [lo, hi] only -- a tick outside
1108
+ // the limits lands outside the axes box and is not clipped -- falling back to
1109
+ // 1-2-5 and then linear ticks for ranges narrower than a decade (which a
1110
+ // zoom reaches almost immediately).
1111
+ function jsLogTicks(lo, hi) {
1112
+ if (lo <= 0) lo = hi > 0 ? hi / 1000 : 1e-3;
1113
+ var e0 = Math.floor(Math.log10(lo)), e1 = Math.ceil(Math.log10(hi)), e, i;
1114
+ function inside(vals) {
1115
+ var keep = [];
1116
+ for (var k = 0; k < vals.length; k++)
1117
+ if (vals[k] >= lo * (1 - 1e-9) && vals[k] <= hi * (1 + 1e-9)) keep.push(vals[k]);
1118
+ return keep;
1119
+ }
1120
+ var decades = [];
1121
+ for (e = e0; e <= e1; e++) decades.push(Math.pow(10, e));
1122
+ var out = inside(decades);
1123
+ if (out.length >= 3) return out;
1124
+ var fine = [], mant = [1, 2, 5];
1125
+ for (e = e0; e <= e1; e++)
1126
+ for (i = 0; i < 3; i++) fine.push(mant[i] * Math.pow(10, e));
1127
+ fine.sort(function (a, b) { return a - b; });
1128
+ out = inside(fine);
1129
+ return out.length >= 2 ? out : jsNiceTicks(lo, hi, 5).ticks;
1130
+ }
1131
+ function allDistinct(a) {
1132
+ for (var i = 0; i < a.length; i++)
1133
+ for (var j = i + 1; j < a.length; j++) if (a[i] === a[j]) return false;
1134
+ return true;
1135
+ }
1136
+ // Format v against a shared exponent: "1.002e5". Mirrors ticker._sci_tick.
1137
+ function sciShared(v, exp, dec) {
1138
+ var mant = (v / Math.pow(10, exp)).toFixed(dec);
1139
+ if (mant.indexOf('.') >= 0) mant = mant.replace(/0+$/, '').replace(/\.$/, '');
1140
+ return mant + 'e' + exp;
1141
+ }
1142
+ // Mirrors ticker.format_ticks. Per-value formatting rounds to one mantissa
1143
+ // digit, so zooming into a narrow band at high magnitude labels every tick
1144
+ // "1e5". When that collides, share one exponent across the set and carry
1145
+ // enough mantissa digits to resolve the step.
1146
+ function fmtTickSet(ticks, step) {
1147
+ var labels = ticks.map(function (v) { return fmtTick(v, step); });
1148
+ if (allDistinct(labels)) return labels;
1149
+ var peak = 0;
1150
+ for (var i = 0; i < ticks.length; i++) peak = Math.max(peak, Math.abs(ticks[i]));
1151
+ if (!step || !peak || !isFinite(peak)) return labels;
1152
+ var exp = Math.floor(Math.log10(peak));
1153
+ var dec = Math.max(0, Math.min(12, Math.ceil(exp - Math.log10(step) - 1e-9)));
1154
+ var shared = ticks.map(function (v) { return sciShared(v, exp, dec); });
1155
+ return allDistinct(shared) ? shared : labels;
1156
+ }
1157
+ // Ticks + their rendered labels for one axis, respecting its scale.
1158
+ function axisTicks(lo, hi, scale) {
1159
+ if (scale === 'log') {
1160
+ var lt = jsLogTicks(lo, hi);
1161
+ return { ticks: lt, labels: lt.map(function (v) { return fmtNum(v); }), step: null };
1162
+ }
1163
+ var r = jsNiceTicks(lo, hi, 5);
1164
+ return { ticks: r.ticks, labels: fmtTickSet(r.ticks, r.step), step: r.step };
1165
+ }
1166
+
1167
+ // Mirrors ticker.minor_ticks: unlabeled subdivisions within [lo, hi]. Log
1168
+ // is the 2..9 sub-decade marks per decade the range spans; linear
1169
+ // subdivides the major step by a count keyed off its leading digit
1170
+ // (1->5, 2->4, 5->5, matching nice_ticks' own 1-2-5 convention) and walks
1171
+ // outward from the first major tick, so minor ticks land on round
1172
+ // subdivisions of the major grid rather than an independent one that may
1173
+ // not line up with it.
1174
+ function jsMinorTicks(majorTicks, step, lo, hi, scale) {
1175
+ if (scale === 'log') {
1176
+ if (lo <= 0) lo = hi > 0 ? hi / 1000 : 1e-3;
1177
+ var e0 = Math.floor(Math.log10(lo)), e1 = Math.ceil(Math.log10(hi)), out = [];
1178
+ for (var e = e0; e <= e1; e++)
1179
+ for (var d = 2; d <= 9; d++) {
1180
+ var v = d * Math.pow(10, e);
1181
+ if (v >= lo && v <= hi) out.push(v);
1182
+ }
1183
+ return out;
1184
+ }
1185
+ if (majorTicks.length < 2 || !step) return [];
1186
+ var mag = Math.pow(10, Math.floor(Math.log10(Math.abs(step))));
1187
+ var lead = Math.round(Math.abs(step) / mag);
1188
+ var n = lead === 2 ? 4 : 5; // 1->5, 2->4, 5->5 (and any other lead->5)
1189
+ var substep = step / n;
1190
+ var ticks = [];
1191
+ var k0 = Math.floor((lo - majorTicks[0]) / substep) - 1;
1192
+ var k1 = Math.ceil((hi - majorTicks[0]) / substep) + 1;
1193
+ for (var k = k0; k <= k1; k++) {
1194
+ var v2 = majorTicks[0] + k * substep;
1195
+ if (v2 < lo - substep * 1e-6 || v2 > hi + substep * 1e-6) continue;
1196
+ var onMajor = false;
1197
+ for (var m = 0; m < majorTicks.length; m++) {
1198
+ if (Math.abs(majorTicks[m] - v2) < Math.abs(substep) * 1e-6) { onMajor = true; break; }
1199
+ }
1200
+ if (!onMajor) ticks.push(v2);
1201
+ }
1202
+ return ticks;
1203
+ }
1204
+
1205
+ // Effective tick style for one axis: `ov` (a raw tick_params() override, or
1206
+ // null/undefined) layered onto `base` field-by-field -- mirrors Python's
1207
+ // `Style.copy(**overrides)`. `base` is the figure-wide STYLE for a major
1208
+ // axis, or the already-resolved major style for that axis' minor ticks
1209
+ // (Axes.tick_params(which='minor') itself layers onto the major override,
1210
+ // not the figure default -- see svg._render_axes).
1211
+ function effTickStyle(base, ov) {
1212
+ if (!ov) return base;
1213
+ return {
1214
+ ts: ov.tick_size !== undefined ? ov.tick_size : base.ts,
1215
+ tw: ov.tick_width !== undefined ? ov.tick_width : base.tw,
1216
+ fs: ov.tick_label_size !== undefined ? ov.tick_label_size : base.fs,
1217
+ col: ov.spine_color !== undefined ? ov.spine_color : base.col,
1218
+ text: ov.text_color !== undefined ? ov.text_color : base.text,
1219
+ };
1220
+ }
1221
+
1222
+ // Rebuild an axes' grid + ticks + numeric labels from its current limits.
1223
+ function rebuildTicks(key) {
1224
+ var om = META[key];
1225
+ if (!om || om.axis_off || om.xfixed || om.yfixed) return; // leave as rendered
1226
+ var g = document.getElementById('ticks' + key);
1227
+ if (!g) return;
1228
+ var m = CUR[key];
1229
+ var xr = axisTicks(m.xmin, m.xmax, m.xscale);
1230
+ var yr = axisTicks(m.ymin, m.ymax, m.yscale);
1231
+ var parts = [];
1232
+ var xTop = om.xside === 'top', yRight = om.yside === 'right';
1233
+ var xAxis = xTop ? m.y : m.y + m.h, xSign = xTop ? -1 : 1;
1234
+ var yAxis = yRight ? m.x + m.w : m.x, ySign = yRight ? 1 : -1;
1235
+
1236
+ // Per-axis effective style -- tick_params(axis='x'/'y', ...) overrides
1237
+ // (see Axes.tick_params) survive this rebuild instead of always falling
1238
+ // back to the figure-wide default the moment a styled axes is panned or
1239
+ // zoomed.
1240
+ var globalStyle = { ts: STYLE.tick_size, tw: STYLE.tick_width,
1241
+ fs: STYLE.tick_label_size, col: STYLE.spine, text: STYLE.text };
1242
+ var tso = om.tick_style || {};
1243
+ var xStyle = effTickStyle(globalStyle, tso.x);
1244
+ var yStyle = effTickStyle(globalStyle, tso.y);
1245
+
1246
+ if (om.grid) {
1247
+ var gl = [];
1248
+ xr.ticks.forEach(function (xt) { var px = toPixel(m, xt, m.ymin).x;
1249
+ gl.push('<line x1="' + px.toFixed(2) + '" y1="' + m.y.toFixed(2) + '" x2="' + px.toFixed(2) + '" y2="' + (m.y + m.h).toFixed(2) + '"/>'); });
1250
+ yr.ticks.forEach(function (yt) { var py = toPixel(m, m.xmin, yt).y;
1251
+ gl.push('<line x1="' + m.x.toFixed(2) + '" y1="' + py.toFixed(2) + '" x2="' + (m.x + m.w).toFixed(2) + '" y2="' + py.toFixed(2) + '"/>'); });
1252
+ // om.grid_alpha is null unless grid(alpha=...) actually overrode the
1253
+ // figure default -- a plain `||` would also treat a real alpha=0
1254
+ // override as falsy and silently revert to the figure default.
1255
+ var gridAlpha = (om.grid_alpha == null) ? STYLE.grid_alpha : om.grid_alpha;
1256
+ parts.push('<g stroke="' + STYLE.grid_color + '" stroke-width="' + STYLE.grid_width + '" stroke-opacity="' + gridAlpha + '">' + gl.join('') + '</g>');
1257
+ }
1258
+ var xmarks = [], ymarks = [], labels = [];
1259
+ xr.ticks.forEach(function (xt, i) {
1260
+ var px = toPixel(m, xt, m.ymin).x;
1261
+ var ly = xAxis + xSign * xStyle.ts + (xTop ? -3 : xStyle.fs);
1262
+ xmarks.push('<line x1="' + px.toFixed(2) + '" y1="' + xAxis.toFixed(2) + '" x2="' + px.toFixed(2) + '" y2="' + (xAxis + xSign * xStyle.ts).toFixed(2) + '"/>');
1263
+ labels.push('<text x="' + px.toFixed(2) + '" y="' + ly.toFixed(2) + '" text-anchor="middle" font-size="' + xStyle.fs + '" fill="' + xStyle.text + '">' + xr.labels[i] + '</text>');
1264
+ });
1265
+ yr.ticks.forEach(function (yt, i) {
1266
+ var py = toPixel(m, m.xmin, yt).y;
1267
+ var lx = yAxis + ySign * yStyle.ts + (yRight ? 2 : -2);
1268
+ ymarks.push('<line x1="' + yAxis.toFixed(2) + '" y1="' + py.toFixed(2) + '" x2="' + (yAxis + ySign * yStyle.ts).toFixed(2) + '" y2="' + py.toFixed(2) + '"/>');
1269
+ labels.push('<text x="' + lx.toFixed(2) + '" y="' + (py + yStyle.fs * 0.35).toFixed(2) + '" text-anchor="' + (yRight ? 'start' : 'end') + '" font-size="' + yStyle.fs + '" fill="' + yStyle.text + '">' + yr.labels[i] + '</text>');
1270
+ });
1271
+ parts.push('<g stroke="' + xStyle.col + '" stroke-width="' + xStyle.tw + '">' + xmarks.join('') + '</g>');
1272
+ parts.push('<g stroke="' + yStyle.col + '" stroke-width="' + yStyle.tw + '">' + ymarks.join('') + '</g>');
1273
+ parts.push(labels.join(''));
1274
+ if (om.minor) {
1275
+ // Unlabeled, drawn shorter than the major marks -- mirrors
1276
+ // svg._render_minor_ticks exactly (same 0.6x length convention, and
1277
+ // the same "minor override layers onto the resolved major style").
1278
+ var xMinorStyle = effTickStyle(xStyle, tso.xminor);
1279
+ var yMinorStyle = effTickStyle(yStyle, tso.yminor);
1280
+ var xmts = xMinorStyle.ts * 0.6, ymts = yMinorStyle.ts * 0.6;
1281
+ var xmmarks = [], ymmarks = [];
1282
+ jsMinorTicks(xr.ticks, xr.step, m.xmin, m.xmax, m.xscale).forEach(function (xt) {
1283
+ var px = toPixel(m, xt, m.ymin).x;
1284
+ xmmarks.push('<line x1="' + px.toFixed(2) + '" y1="' + xAxis.toFixed(2) + '" x2="' + px.toFixed(2) + '" y2="' + (xAxis + xSign * xmts).toFixed(2) + '"/>');
1285
+ });
1286
+ jsMinorTicks(yr.ticks, yr.step, m.ymin, m.ymax, m.yscale).forEach(function (yt) {
1287
+ var py = toPixel(m, m.xmin, yt).y;
1288
+ ymmarks.push('<line x1="' + yAxis.toFixed(2) + '" y1="' + py.toFixed(2) + '" x2="' + (yAxis + ySign * ymts).toFixed(2) + '" y2="' + py.toFixed(2) + '"/>');
1289
+ });
1290
+ parts.push('<g stroke="' + xMinorStyle.col + '" stroke-width="' + xMinorStyle.tw + '">' + xmmarks.join('') + '</g>');
1291
+ parts.push('<g stroke="' + yMinorStyle.col + '" stroke-width="' + yMinorStyle.tw + '">' + ymmarks.join('') + '</g>');
1292
+ }
1293
+ g.innerHTML = parts.join('');
1294
+ }
1295
+
1296
+ // The affine that remaps the artist group from its original limits (META)
1297
+ // to the current ones (CUR) -- i.e. exactly the CSS matrix(...) transform
1298
+ // applyAxesTransform() puts on <g id="zoom{key}">. Factored out so
1299
+ // nearestVertex() can invert it too (see there for why that matters).
1300
+ function zoomAffine(key) {
1301
+ var o = META[key], c = CUR[key];
1302
+ // Work in transformed (log-aware), direction-aware space so the remap
1303
+ // stays affine. Both sets carry the same inversion flags, so an inverted
1304
+ // axis simply zooms/pans in its own direction.
1305
+ var oe = edges(o), ce = edges(c);
1306
+ var ofx0 = oe.fx0, ofx1 = oe.fx1, cfx0 = ce.fx0, cfx1 = ce.fx1;
1307
+ var ofy0 = oe.fy0, ofy1 = oe.fy1, cfy0 = ce.fy0, cfy1 = ce.fy1;
1308
+ var sx = (ofx1 - ofx0) / (cfx1 - cfx0);
1309
+ var sy = (ofy1 - ofy0) / (cfy1 - cfy0);
1310
+ var tx = o.x * (1 - sx) + (ofx0 - cfx0) / (cfx1 - cfx0) * o.w;
1311
+ var ty = o.y * (1 - sy) + (cfy1 - ofy1) / (cfy1 - cfy0) * o.h;
1312
+ return { sx: sx, sy: sy, tx: tx, ty: ty };
1313
+ }
1314
+
1315
+ // Remap the artist group from original limits (META) to current (CUR).
1316
+ function applyAxesTransform(key) {
1317
+ var g = document.getElementById('zoom' + key);
1318
+ if (!g) return;
1319
+ var t = zoomAffine(key);
1320
+ if (Math.abs(t.sx - 1) < 1e-9 && Math.abs(t.sy - 1) < 1e-9 &&
1321
+ Math.abs(t.tx) < 1e-6 && Math.abs(t.ty) < 1e-6) {
1322
+ g.removeAttribute('transform');
1323
+ } else {
1324
+ g.setAttribute('transform', 'matrix(' + t.sx + ',0,0,' + t.sy + ',' + t.tx + ',' + t.ty + ')');
1325
+ }
1326
+ }
1327
+
1328
+ function pinAxesKey(pin) {
1329
+ if (pin.dataset.axes !== undefined) return pin.dataset.axes;
1330
+ if (pin.dataset.frameId && FRAME_INDEX[pin.dataset.frameId])
1331
+ return String(FRAME_INDEX[pin.dataset.frameId].axesKey);
1332
+ return null;
1333
+ }
1334
+ function relayoutPins(key) {
1335
+ document.querySelectorAll('.plotpress-pin').forEach(function (pin) {
1336
+ if (pinAxesKey(pin) !== String(key)) return;
1337
+ var anchor = pinAnchor(pin);
1338
+ if (anchor) {
1339
+ var a = resolve(anchor, +pin.dataset.index);
1340
+ if (a) layoutPin(pin, a.px, a.py, pinLabel(pin, a.label));
1341
+ } else if (pin.dataset.x !== undefined && CUR[key]) {
1342
+ var q = toPixel(CUR[key], +pin.dataset.x, +pin.dataset.y);
1343
+ layoutPin(pin, q.x, q.y, pin.querySelector('text').textContent);
1344
+ }
1345
+ });
1346
+ }
1347
+
1348
+ // A data-anchored text()/annotate() label -- svg.py's plotpress-cscale
1349
+ // group, opened around its glyphs (and bbox, if any) with data-x0/data-y0
1350
+ // holding the anchor point zoomAffine() itself already maps correctly.
1351
+ // Composing this group's counter-scale with the zoom{key} group's own
1352
+ // matrix(sx,0,0,sy,...) leaves that anchor point exactly where plain
1353
+ // ancestor scaling already puts it (so it still tracks the data), while
1354
+ // canceling the *local* stretch around it -- the label keeps a constant
1355
+ // screen size instead of growing or shrinking with the zoom level, the
1356
+ // same as a title, tick label, or point-pick pin already does. Unlike a
1357
+ // marker (a footprint *on* the data, deliberately scaling with the axis --
1358
+ // see the marker-scaling fix), a label exists to be read.
1359
+ function relayoutTextCounterScale(key) {
1360
+ var t = zoomAffine(key);
1361
+ document.querySelectorAll('.plotpress-cscale').forEach(function (g) {
1362
+ if (g.dataset.axes !== String(key)) return;
1363
+ var x0 = +g.dataset.x0, y0 = +g.dataset.y0;
1364
+ var isx = t.sx ? 1 / t.sx : 1, isy = t.sy ? 1 / t.sy : 1;
1365
+ g.setAttribute('transform',
1366
+ 'translate(' + x0 + ',' + y0 + ') scale(' + isx + ',' + isy + ') ' +
1367
+ 'translate(' + (-x0) + ',' + (-y0) + ')');
1368
+ });
1369
+ }
1370
+
1371
+ // A twin/secondary axes occupies the exact same pixel rect as its parent,
1372
+ // so only one of them is ever the axesAt() hit -- whichever one changed
1373
+ // must push its new limits onto the other(s), or the pair visually comes
1374
+ // apart: one moves under the drag, the other stays frozen at its initial
1375
+ // view. A twin shares only its `twin_shared` dimension (its other axis is
1376
+ // independent, real data); a secondary axis has no data of its own and
1377
+ // mirrors both dimensions unconditionally. Normalizing to a single "root"
1378
+ // axes first (the plain axes a twin/secondary is attached to) means a drag
1379
+ // that happens to hit the twin/secondary itself -- possible now that
1380
+ // axesAt() prefers the most-recently-added match -- still fans out to every
1381
+ // sibling instead of only updating one leg of the link.
1382
+ function syncLinked(key) {
1383
+ var m = META[key];
1384
+ if (!m) return;
1385
+ var root = key, rc = CUR[key];
1386
+ if (m.twin_of !== null && m.twin_of !== undefined && CUR[String(m.twin_of)]) {
1387
+ root = String(m.twin_of);
1388
+ var pc = CUR[root];
1389
+ if (m.twin_shared === 'x') { pc.xmin = rc.xmin; pc.xmax = rc.xmax; }
1390
+ else if (m.twin_shared === 'y') { pc.ymin = rc.ymin; pc.ymax = rc.ymax; }
1391
+ applyAxesTransform(root); rebuildTicks(root); relayoutPins(root); relayoutTextCounterScale(root);
1392
+ rc = pc;
1393
+ } else if (m.secondary_of !== null && m.secondary_of !== undefined &&
1394
+ CUR[String(m.secondary_of)]) {
1395
+ root = String(m.secondary_of);
1396
+ var pc2 = CUR[root];
1397
+ pc2.xmin = rc.xmin; pc2.xmax = rc.xmax; pc2.ymin = rc.ymin; pc2.ymax = rc.ymax;
1398
+ applyAxesTransform(root); rebuildTicks(root); relayoutPins(root); relayoutTextCounterScale(root);
1399
+ rc = pc2;
1400
+ }
1401
+ for (var k in META) {
1402
+ if (k === key || k === root) continue;
1403
+ var mo = META[k], dst = CUR[k];
1404
+ if (String(mo.twin_of) === root) {
1405
+ if (mo.twin_shared === 'x') { dst.xmin = rc.xmin; dst.xmax = rc.xmax; }
1406
+ else if (mo.twin_shared === 'y') { dst.ymin = rc.ymin; dst.ymax = rc.ymax; }
1407
+ applyAxesTransform(k); rebuildTicks(k); relayoutPins(k); relayoutTextCounterScale(k);
1408
+ } else if (String(mo.secondary_of) === root) {
1409
+ dst.xmin = rc.xmin; dst.xmax = rc.xmax; dst.ymin = rc.ymin; dst.ymax = rc.ymax;
1410
+ applyAxesTransform(k); rebuildTicks(k); relayoutPins(k); relayoutTextCounterScale(k);
1411
+ }
1412
+ }
1413
+ }
1414
+
1415
+ function refreshAxes(key) {
1416
+ applyAxesTransform(key); rebuildTicks(key); relayoutPins(key); relayoutTextCounterScale(key);
1417
+ syncLinked(key);
1418
+ }
1419
+ function resetAxesOne(key) {
1420
+ for (var f in META[key]) CUR[key][f] = META[key][f];
1421
+ var g = document.getElementById('zoom' + key);
1422
+ if (g) g.removeAttribute('transform');
1423
+ rebuildTicks(key);
1424
+ relayoutPins(key);
1425
+ relayoutTextCounterScale(key);
1426
+ // Otherwise double-clicking just the parent of a pan-desynced twin/
1427
+ // secondary snaps the parent back but leaves the other one stranded at
1428
+ // whatever view it last drifted to.
1429
+ syncLinked(key);
1430
+ }
1431
+ function resetAxes() { Object.keys(META).forEach(resetAxesOne); }
1432
+
1433
+ // Nearest embedded data point (carries any extra dims: c, z, ...).
1434
+ function nearestPoint(key, m, p) {
1435
+ var pd = PICK[key];
1436
+ if (!pd) return null;
1437
+ var best = null;
1438
+ pd.series.forEach(function (s, si) {
1439
+ for (var j = 0; j < s.x.length; j++) {
1440
+ var q = toPixel(m, s.x[j], s.y[j]);
1441
+ var d = (q.x - p.x) * (q.x - p.x) + (q.y - p.y) * (q.y - p.y);
1442
+ if (!best || d < best.d) {
1443
+ best = { d: d, ref: { kind: 'points', axes: key, series: si,
1444
+ index: j, ptype: s.kind } };
1445
+ }
1446
+ }
1447
+ });
1448
+ return best;
1449
+ }
1450
+
1451
+ // Index of the edge bucket containing v (edges.length - 1 buckets, i.e. one
1452
+ // per cell) -- a plain linear scan, since a capped mesh has at most a few
1453
+ // hundred edges per axis. Dividing the extent evenly instead of searching
1454
+ // the real edges is only correct for a uniform grid; pcolormesh/contour
1455
+ // both explicitly allow non-uniform spacing.
1456
+ function bucketIndex(edges, v) {
1457
+ var n = edges.length - 1;
1458
+ if (v <= edges[0]) return 0;
1459
+ if (v >= edges[n]) return n - 1;
1460
+ for (var i = 0; i < n; i++) {
1461
+ if (v >= edges[i] && v <= edges[i + 1]) return i;
1462
+ }
1463
+ return n - 1;
1464
+ }
1465
+
1466
+ // A cell's center in data space, for placing a marker / reading it back.
1467
+ // A curvilinear mesh has no separable edges -- xc/yc give every cell's
1468
+ // center directly (see plotpress.svg._curvilinear_centers). A contour's
1469
+ // "cells" are really point samples: xcoord/ycoord (when present) are the
1470
+ // exact sample coordinates, which for non-uniform spacing generally isn't
1471
+ // the same as the midpoint of its implied edges.
1472
+ function meshCellCenter(mesh, idx) {
1473
+ var nx = mesh.shape[1];
1474
+ if (mesh.curvilinear) return { x: mesh.xc[idx], y: mesh.yc[idx] };
1475
+ var row = Math.floor(idx / nx), col = idx % nx;
1476
+ if (mesh.xcoord) return { x: mesh.xcoord[col], y: mesh.ycoord[row] };
1477
+ var xe = mesh.xedges, ye = mesh.yedges;
1478
+ return { x: (xe[col] + xe[col + 1]) / 2, y: (ye[row] + ye[row + 1]) / 2 };
1479
+ }
1480
+
1481
+ // Mesh cell under a data coordinate -> anchor ref (steppable by cell).
1482
+ // `p` (pixel point) is only needed for a curvilinear mesh's nearest-center
1483
+ // search, which has to compare in pixel space the same way nearestPoint()
1484
+ // does for a scatter series -- data-space distance would be meaningless
1485
+ // whenever x and y are in different units/scales.
1486
+ function meshAt(key, dx, dy, p) {
1487
+ var pd = PICK[key];
1488
+ if (!pd) return null;
1489
+ var m = CUR[key];
1490
+ for (var t = 0; t < pd.meshes.length; t++) {
1491
+ var mesh = pd.meshes[t], e = mesh.extent;
1492
+ // Test containment in *pixel* space, with a couple pixels of slack: a
1493
+ // click aimed at the mesh's boundary (its edge is exactly where a user
1494
+ // would click to hit the outermost cell) can round-trip through
1495
+ // toData() landing a hair outside the extent in data space -- fine
1496
+ // there, since a data-space epsilon that's meaningful for a [0, 1]
1497
+ // axis is meaningless for a [0, 1e6] one, but wrong in pixel space,
1498
+ // where "a hair" is the same couple of pixels regardless of scale.
1499
+ var c0 = toPixel(m, e[0], e[2]), c1 = toPixel(m, e[1], e[3]);
1500
+ var px0 = Math.min(c0.x, c1.x) - 2, px1 = Math.max(c0.x, c1.x) + 2;
1501
+ var py0 = Math.min(c0.y, c1.y) - 2, py1 = Math.max(c0.y, c1.y) + 2;
1502
+ if (p.x < px0 || p.x > px1 || p.y < py0 || p.y > py1) continue;
1503
+ dx = Math.min(e[1], Math.max(e[0], dx));
1504
+ dy = Math.min(e[3], Math.max(e[2], dy));
1505
+ if (mesh.curvilinear) {
1506
+ var best = -1, bd = Infinity;
1507
+ for (var c = 0; c < mesh.xc.length; c++) {
1508
+ var q = toPixel(m, mesh.xc[c], mesh.yc[c]);
1509
+ var dd = (q.x - p.x) * (q.x - p.x) + (q.y - p.y) * (q.y - p.y);
1510
+ if (dd < bd) { bd = dd; best = c; }
1511
+ }
1512
+ if (best < 0) continue;
1513
+ return { kind: 'mesh', axes: key, mesh: t, index: best };
1514
+ }
1515
+ var nx = mesh.shape[1];
1516
+ var col = bucketIndex(mesh.xedges, dx), row = bucketIndex(mesh.yedges, dy);
1517
+ return { kind: 'mesh', axes: key, mesh: t, index: row * nx + col };
1518
+ }
1519
+ return null;
1520
+ }
1521
+
1522
+ // A pcolormesh_frames() mesh under a data coordinate -> anchor ref, exactly
1523
+ // like meshAt() above but sourced from FRAMES (per-frame z, geometry shared
1524
+ // across frames) instead of the static PICK payload -- a slider-driven mesh
1525
+ // has no entry in PICK at all (see frame_data()), so a click on one used to
1526
+ // find nothing to pick, however close to a cell center.
1527
+ function meshFrameAt(key, dx, dy, p) {
1528
+ var entries = FRAMES && FRAMES[key];
1529
+ if (!entries) return null;
1530
+ var m = CUR[key];
1531
+ for (var t = 0; t < entries.length; t++) {
1532
+ var mesh = entries[t];
1533
+ if (!mesh.z) continue; // a frame-line entry, not a frame-mesh one
1534
+ var e = mesh.extent;
1535
+ var c0 = toPixel(m, e[0], e[2]), c1 = toPixel(m, e[1], e[3]);
1536
+ var px0 = Math.min(c0.x, c1.x) - 2, px1 = Math.max(c0.x, c1.x) + 2;
1537
+ var py0 = Math.min(c0.y, c1.y) - 2, py1 = Math.max(c0.y, c1.y) + 2;
1538
+ if (p.x < px0 || p.x > px1 || p.y < py0 || p.y > py1) continue;
1539
+ dx = Math.min(e[1], Math.max(e[0], dx));
1540
+ dy = Math.min(e[3], Math.max(e[2], dy));
1541
+ if (mesh.curvilinear) {
1542
+ var best = -1, bd = Infinity;
1543
+ for (var c = 0; c < mesh.xc.length; c++) {
1544
+ var q = toPixel(m, mesh.xc[c], mesh.yc[c]);
1545
+ var dd = (q.x - p.x) * (q.x - p.x) + (q.y - p.y) * (q.y - p.y);
1546
+ if (dd < bd) { bd = dd; best = c; }
1547
+ }
1548
+ if (best < 0) continue;
1549
+ return { kind: 'meshframe', axes: key, id: mesh.id, unit: mesh.unit, index: best };
1550
+ }
1551
+ var nx = mesh.shape[1];
1552
+ var col = bucketIndex(mesh.xedges, dx), row = bucketIndex(mesh.yedges, dy);
1553
+ return { kind: 'meshframe', axes: key, id: mesh.id, unit: mesh.unit,
1554
+ index: row * nx + col };
1555
+ }
1556
+ return null;
1557
+ }
1558
+
1559
+ // Pie wedge under a pixel point -> anchor ref (steppable by wedge).
1560
+ function pieCenter(m, pie) {
1561
+ return { cx: m.x + m.w / 2, cy: m.y + m.h / 2,
1562
+ R: 0.42 * Math.min(m.w, m.h) * (pie.radius || 1) };
1563
+ }
1564
+ function pieAt(key, p) {
1565
+ var pd = PICK[key];
1566
+ if (!pd || !pd.pies) return null;
1567
+ var m = META[key];
1568
+ for (var t = 0; t < pd.pies.length; t++) {
1569
+ var pie = pd.pies[t], c = pieCenter(m, pie);
1570
+ var dx = p.x - c.cx, dy = p.y - c.cy;
1571
+ if (dx * dx + dy * dy > c.R * c.R) continue;
1572
+ var ang = Math.atan2(-dy, dx); // math angle (y up)
1573
+ var start = pie.startangle * Math.PI / 180, cum = 0;
1574
+ for (var wi = 0; wi < pie.fracs.length; wi++) {
1575
+ var a0 = start - 2 * Math.PI * cum;
1576
+ var span = 2 * Math.PI * pie.fracs[wi];
1577
+ var d = (a0 - ang) % (2 * Math.PI); if (d < 0) d += 2 * Math.PI;
1578
+ if (d <= span + 1e-9) return { kind: 'pie', axes: key, pie: t, index: wi };
1579
+ cum += pie.fracs[wi];
1580
+ }
1581
+ }
1582
+ return null;
1583
+ }
1584
+
1585
+ // Geometry fallback for series too large to embed (x/y only). The raw
1586
+ // d/cx/cy attributes queried here are whatever svg.py wrote at export time
1587
+ // -- pixel positions in the *original* (pre-pan/zoom) axes limits, i.e. the
1588
+ // zoom{i} group's local space before applyAxesTransform() puts a CSS
1589
+ // matrix(...) on it. `p` (from toUser(e)) is root/current-view space, the
1590
+ // space that matrix maps *into* -- comparing them directly, as this used
1591
+ // to, silently returned the nearest vertex in the wrong space the moment
1592
+ // the axes had been panned or zoomed. Map p through the inverse of that
1593
+ // same affine first, then map the winning point back, so both the search
1594
+ // and the returned pixel position agree with what's actually on screen.
1595
+ function nearestVertex(i, p) {
1596
+ var t = zoomAffine(i);
1597
+ var lp = { x: (p.x - t.tx) / t.sx, y: (p.y - t.ty) / t.sy };
1598
+ var best = null, bd = Infinity;
1599
+ document.querySelectorAll('[id^="s' + i + '_"]').forEach(function (el) {
1600
+ var tag = el.tagName.toLowerCase(), pts = [];
1601
+ if (tag === 'line') return;
1602
+ if (tag === 'path') {
1603
+ var nums = (el.getAttribute('d') || '').match(/-?\d+(?:\.\d+)?/g) || [];
1604
+ for (var j = 0; j + 1 < nums.length; j += 2) pts.push({ x: +nums[j], y: +nums[j + 1] });
1605
+ } else if (tag === 'circle') {
1606
+ pts.push({ x: +el.getAttribute('cx'), y: +el.getAttribute('cy') });
1607
+ } else {
1608
+ el.querySelectorAll('circle').forEach(function (c) {
1609
+ pts.push({ x: +c.getAttribute('cx'), y: +c.getAttribute('cy') });
1610
+ });
1611
+ }
1612
+ for (var q = 0; q < pts.length; q++) {
1613
+ var d = (pts[q].x - lp.x) * (pts[q].x - lp.x) + (pts[q].y - lp.y) * (pts[q].y - lp.y);
1614
+ if (d < bd) { bd = d; best = pts[q]; }
1615
+ }
1616
+ });
1617
+ return best ? { x: best.x * t.sx + t.tx, y: best.y * t.sy + t.ty } : null;
1618
+ }
1619
+
1620
+ function fmt(v) {
1621
+ var a = Math.abs(v);
1622
+ if (a !== 0 && (a < 1e-3 || a >= 1e5)) return v.toExponential(2);
1623
+ return (Math.round(v * 1000) / 1000).toString();
1624
+ }
1625
+
1626
+ // Local, origin-relative coordinates -- (0,0) is the pin's own anchor --
1627
+ // plus a group-level transform (translate to the anchor, scale by
1628
+ // 1/zoomScale) instead of baking px/py straight into each child. Whole-
1629
+ // figure zoom (see applyZoomSize) grows the *entire* SVG's rendered CSS
1630
+ // size uniformly, which would otherwise carry a pin's fixed viewBox-unit
1631
+ // radius up right along with the data -- readable as "8px" at rest and a
1632
+ // 50px+ blob covering the very mesh cell it's pointing at eight ticks of
1633
+ // Magnify later, the opposite of what zooming in is for. The 1/zoomScale
1634
+ // factor cancels that growth out, so a pin renders at the same on-screen
1635
+ // size at any zoom level; updatePinTransform() (called from
1636
+ // applyZoomSize() for every existing pin, not just the one being laid
1637
+ // out here) is what keeps that true as zoomScale changes after the pin
1638
+ // already exists.
1639
+ function layoutPin(g, px, py, label) {
1640
+ var fs = 11, padx = 5, pady = 3;
1641
+ var bw = label.length * fs * 0.55 + padx * 2, bh = fs + pady * 2;
1642
+ // A user-dragged box (see startBoxDrag) keeps its own chosen offset
1643
+ // from the dot across every later re-layout (pan, zoom, arrow-key
1644
+ // step) -- the default top-right placement only applies until the box
1645
+ // is actually moved once.
1646
+ var bx = g.dataset.boxDx !== undefined ? +g.dataset.boxDx : 8;
1647
+ var by = g.dataset.boxDy !== undefined ? +g.dataset.boxDy : -bh - 4;
1648
+ var dot = g.querySelector('circle'), rect = g.querySelector('rect'),
1649
+ text = g.querySelector('text'), arrow = g.querySelector('.plotpress-pin-arrow');
1650
+ dot.setAttribute('cx', 0); dot.setAttribute('cy', 0);
1651
+ rect.setAttribute('x', bx); rect.setAttribute('y', by);
1652
+ rect.setAttribute('width', bw); rect.setAttribute('height', bh);
1653
+ text.setAttribute('x', bx + padx); text.setAttribute('y', by + fs + pady - 2);
1654
+ text.textContent = label;
1655
+ syncPinArrow(g);
1656
+ g.dataset.anchorX = px; g.dataset.anchorY = py;
1657
+ updatePinTransform(g);
1658
+ }
1659
+
1660
+ // Re-lays-out just the leader line, from whatever the box/dot's own
1661
+ // current attributes already are -- called after layoutPin() has just
1662
+ // set the box (dot/rect already current), and again from selectPin()
1663
+ // when a dot's rendered radius itself changes (selecting/deselecting)
1664
+ // without anything else about the pin moving. Reads the dot's *rendered*
1665
+ // `r` attribute, not the resting dataset.pinR: selectPin() enlarges a
1666
+ // selected dot to 1.4x that resting size without going through
1667
+ // layoutPin() again, and a freshly dropped pin starts selected (see
1668
+ // addPin()) -- feeding the arrow the resting radius while the dot itself
1669
+ // was already bigger left the arrowhead visibly short of the dot's own
1670
+ // edge on every single pin, not just an edge case.
1671
+ function syncPinArrow(g) {
1672
+ var rect = g.querySelector('rect'), dot = g.querySelector('circle'),
1673
+ arrow = g.querySelector('.plotpress-pin-arrow');
1674
+ if (!rect || !dot || !arrow) return;
1675
+ layoutPinArrow(arrow, +rect.getAttribute('x'), +rect.getAttribute('y'),
1676
+ +rect.getAttribute('width'), +rect.getAttribute('height'),
1677
+ +dot.getAttribute('r'));
1678
+ }
1679
+
1680
+ // The leader line from the box's edge to the dot's own edge (not its
1681
+ // center -- the arrowhead should land on the dot, not point past it).
1682
+ // "Nearest point on the box's rectangle to the origin" (clamp 0 into the
1683
+ // box's own x/y bounds) is the box-end regardless of which side/corner of
1684
+ // the dot the box currently sits on, including after an arbitrary drag.
1685
+ function layoutPinArrow(arrow, bx, by, bw, bh, r) {
1686
+ if (!arrow) return;
1687
+ var lx = Math.max(bx, Math.min(bx + bw, 0));
1688
+ var ly = Math.max(by, Math.min(by + bh, 0));
1689
+ var d = Math.hypot(lx, ly);
1690
+ // The box already overlaps/touches the dot -- no line to draw (and
1691
+ // dividing by d below would be undefined at d===0).
1692
+ if (d <= r) {
1693
+ arrow.setAttribute('x1', 0); arrow.setAttribute('y1', 0);
1694
+ arrow.setAttribute('x2', 0); arrow.setAttribute('y2', 0);
1695
+ return;
1696
+ }
1697
+ // The point on the (lx,ly)->(0,0) segment exactly r from the origin --
1698
+ // scaling (lx,ly) by r/d (not 1 - r/d, which barely moves it at all
1699
+ // once the box is far from the dot) lands there regardless of d.
1700
+ var k = r / d;
1701
+ arrow.setAttribute('x1', lx); arrow.setAttribute('y1', ly);
1702
+ arrow.setAttribute('x2', lx * k); arrow.setAttribute('y2', ly * k);
1703
+ }
1704
+
1705
+ function updatePinTransform(g) {
1706
+ g.setAttribute('transform', 'translate(' + g.dataset.anchorX + ',' +
1707
+ g.dataset.anchorY + ') scale(' + (1 / zoomScale) + ')');
1708
+ }
1709
+
1710
+ // The selected dot draws a bit larger than its resting size -- scaled from
1711
+ // that pin's *own* radius (see pinRadius), not a flat bump, so a selected
1712
+ // marker on a tiny panel still reads as "this one, bigger" rather than
1713
+ // ballooning back up to the fixed size pinRadius was added to avoid. Each
1714
+ // dot whose radius actually changes here gets its own leader arrow synced
1715
+ // right after -- see syncPinArrow's own comment for why this can't wait
1716
+ // for the next ordinary re-layout.
1717
+ function selectPin(g) {
1718
+ if (selectedPin && selectedPin !== g) {
1719
+ selectedPin.classList.remove('selected');
1720
+ var prevDot = selectedPin.querySelector('circle');
1721
+ if (prevDot) prevDot.setAttribute('r', selectedPin.dataset.pinR || 3.5);
1722
+ syncPinArrow(selectedPin);
1723
+ }
1724
+ selectedPin = g;
1725
+ if (g) {
1726
+ g.classList.add('selected');
1727
+ var dot = g.querySelector('circle');
1728
+ if (dot) dot.setAttribute('r', (parseFloat(g.dataset.pinR) || 3.5) * 1.4);
1729
+ syncPinArrow(g);
1730
+ }
1731
+ }
1732
+
1733
+ // A marker sized for a huge grid's tiny panels would be a fixed 3.5px dot
1734
+ // sitting like a boulder on an axes 40px across -- scale it to the axes
1735
+ // it actually belongs to instead, clamped so it never shrinks below
1736
+ // comfortably clickable or grows past the size that already looked right
1737
+ // on a normal, single-axes figure. `axesKey` is left out (undefined) for
1738
+ // a free annotation, which belongs to no axes at all -- falls back to
1739
+ // that same normal-figure default.
1740
+ function pinRadius(axesKey) {
1741
+ var m = axesKey !== undefined && axesKey !== null ? CUR[axesKey] : null;
1742
+ if (!m) return 3.5;
1743
+ return Math.max(2.0, Math.min(3.5, Math.min(m.w, m.h) * 0.045));
1744
+ }
1745
+
1746
+ function addPin(px, py, label, axesKey) {
1747
+ var g = document.createElementNS(SVGNS, 'g');
1748
+ g.setAttribute('class', 'plotpress-pin'); g.style.cursor = 'pointer';
1749
+ var r = pinRadius(axesKey);
1750
+ g.dataset.pinR = r;
1751
+ var dot = document.createElementNS(SVGNS, 'circle');
1752
+ dot.setAttribute('r', r); dot.setAttribute('fill', '#111');
1753
+ dot.setAttribute('stroke', '#fff'); dot.setAttribute('stroke-width', 1);
1754
+ var arrow = document.createElementNS(SVGNS, 'line');
1755
+ arrow.setAttribute('class', 'plotpress-pin-arrow');
1756
+ arrow.setAttribute('stroke', '#666'); arrow.setAttribute('stroke-width', 1);
1757
+ arrow.setAttribute('marker-end', 'url(#plotpress-pin-arrow)');
1758
+ var rect = document.createElementNS(SVGNS, 'rect');
1759
+ rect.setAttribute('rx', 3); rect.setAttribute('fill', '#111');
1760
+ rect.setAttribute('fill-opacity', 0.85);
1761
+ var text = document.createElementNS(SVGNS, 'text');
1762
+ text.setAttribute('font-size', 11); text.setAttribute('fill', '#fff');
1763
+ g.appendChild(dot); g.appendChild(arrow); g.appendChild(rect); g.appendChild(text);
1764
+ layoutPin(g, px, py, label);
1765
+ // Left-click selects (arrow keys then step it); right-click deletes;
1766
+ // a left-click/drag specifically on the box (not the dot) repositions
1767
+ // its label -- see startBoxDrag -- while the mode that would have
1768
+ // created this kind of pin is the active one (boxDraggableNow).
1769
+ g.addEventListener('click', function (ev) { ev.stopPropagation(); selectPin(g); });
1770
+ g.addEventListener('contextmenu', function (ev) {
1771
+ ev.preventDefault(); ev.stopPropagation();
1772
+ if (selectedPin === g) selectedPin = null;
1773
+ g.remove();
1774
+ });
1775
+ g.addEventListener('mousedown', function (ev) {
1776
+ if (ev.button !== 0 || (ev.target !== rect && ev.target !== text)) return;
1777
+ if (!boxDraggableNow(g)) return;
1778
+ startBoxDrag(g, ev);
1779
+ });
1780
+ svg.appendChild(g);
1781
+ selectPin(g); // a freshly dropped marker starts selected
1782
+ refreshOneDragReady(g);
1783
+ return g;
1784
+ }
1785
+
1786
+ // Draggable exactly when the mode that would have created this kind of
1787
+ // pin is the active one -- a plain Point Picking pin under 'pick', every
1788
+ // Annotation-classed pin (a free note, or a legacy "Annotate Point"
1789
+ // restore -- see restorePins) under 'note-free'. The same split Clear
1790
+ // Points/Clear Annotations already use (see isAnnotationPin above).
1791
+ function boxDraggableNow(g) {
1792
+ return isAnnotationPin(g) ? mode === 'note-free' : mode === 'pick';
1793
+ }
1794
+
1795
+ // Just this one pin -- O(1), not a full document sweep -- for the common
1796
+ // case of a single pin's own draggability possibly changing (created,
1797
+ // just gained/lost .plotpress-note). A mode CHANGE (see setMode) still
1798
+ // needs the full sweep below, since every existing pin's answer can flip
1799
+ // at once; creating/restoring pins one at a time never needs more than
1800
+ // this, and restorePins() replaying a whole saved file through addPin()/
1801
+ // addAnchoredPin() one call at a time is exactly the case where an O(n)
1802
+ // sweep *per pin created* would make loading n saved pins O(n^2).
1803
+ function refreshOneDragReady(g) {
1804
+ g.classList.toggle('plotpress-drag-ready', boxDraggableNow(g));
1805
+ }
1806
+
1807
+ // Refreshed on every mode change (every existing pin's answer can flip at
1808
+ // once) and once after a whole restorePins() replay -- purely a cursor
1809
+ // hint (the actual drag gate is boxDraggableNow(), checked fresh at
1810
+ // mousedown regardless of this class), but a "move" cursor over a box
1811
+ // that's about to not respond to a drag would be its own small bug.
1812
+ function refreshDragReady() {
1813
+ document.querySelectorAll('.plotpress-pin').forEach(refreshOneDragReady);
1814
+ }
1815
+
1816
+ // Repositions a pin's own label box independent of its anchor (the dot
1817
+ // stays exactly on the data point/cell it represents) -- converts the
1818
+ // mouse's on-screen pixel delta into the box's *local* coordinate space
1819
+ // (see layoutPin's own comment on that space): a local unit there is
1820
+ // 1/zoomScale user-space units (the group's own scale(1/zoomScale)
1821
+ // keeps a pin's on-screen size constant under whole-figure zoom), and a
1822
+ // user-space unit is 1/pxPerUser() screen pixels -- so a screen delta
1823
+ // needs multiplying by zoomScale and dividing by pxPerUser() to land in
1824
+ // local units, the exact inverse of what rendering does to local
1825
+ // coordinates to put them on screen.
1826
+ function startBoxDrag(g, ev) {
1827
+ ev.stopPropagation(); ev.preventDefault();
1828
+ selectPin(g);
1829
+ var startX = ev.clientX, startY = ev.clientY;
1830
+ var rect = g.querySelector('rect');
1831
+ var bx0 = +rect.getAttribute('x'), by0 = +rect.getAttribute('y');
1832
+ var text = g.querySelector('text');
1833
+ function onMove(e) {
1834
+ var k = zoomScale / pxPerUser();
1835
+ g.dataset.boxDx = bx0 + (e.clientX - startX) * k;
1836
+ g.dataset.boxDy = by0 + (e.clientY - startY) * k;
1837
+ layoutPin(g, +g.dataset.anchorX, +g.dataset.anchorY, text.textContent);
1838
+ }
1839
+ function onUp() {
1840
+ window.removeEventListener('mousemove', onMove);
1841
+ window.removeEventListener('mouseup', onUp);
1842
+ }
1843
+ window.addEventListener('mousemove', onMove);
1844
+ window.addEventListener('mouseup', onUp);
1845
+ }
1846
+
1847
+ // The (x, y, vals) arrays a point/frame anchor refers to, at the live frame.
1848
+ function seriesOf(anchor) {
1849
+ if (anchor.kind === 'frame') {
1850
+ var rec = FRAME_INDEX[anchor.id];
1851
+ if (!rec) return null;
1852
+ var f = CURRENT_FRAME[anchor.unit] || 0, e = rec.entry;
1853
+ return { axes: rec.axesKey, x: e.shared_x ? e.x : e.x[f], y: e.Y[f], vals: null };
1854
+ }
1855
+ var s = PICK[anchor.axes] && PICK[anchor.axes].series[anchor.series];
1856
+ return s ? { axes: anchor.axes, x: s.x, y: s.y, vals: s.vals } : null;
1857
+ }
1858
+
1859
+ // Resolve a marker anchor at an index/cell -> pixel position + label.
1860
+ function resolve(anchor, index) {
1861
+ if (anchor.kind === 'pie') {
1862
+ var pd = PICK[anchor.axes], pie = pd.pies[anchor.pie], m = META[anchor.axes];
1863
+ var n = pie.fracs.length, idx = ((index % n) + n) % n;
1864
+ var c = pieCenter(m, pie), cum = 0;
1865
+ for (var w = 0; w < idx; w++) cum += pie.fracs[w];
1866
+ var a0 = pie.startangle * Math.PI / 180 - 2 * Math.PI * cum;
1867
+ var am = a0 - Math.PI * pie.fracs[idx]; // wedge bisector
1868
+ var lbl = (pie.labels ? pie.labels[idx] + ': ' : '') + fmt(pie.values[idx]) +
1869
+ ' (' + (pie.fracs[idx] * 100).toFixed(1) + '%)';
1870
+ return { px: c.cx + 0.6 * c.R * Math.cos(am),
1871
+ py: c.cy - 0.6 * c.R * Math.sin(am), index: idx, label: lbl };
1872
+ }
1873
+ if (anchor.kind === 'mesh') {
1874
+ var mesh = PICK[anchor.axes] && PICK[anchor.axes].meshes[anchor.mesh];
1875
+ if (!mesh) return null;
1876
+ var nx = mesh.shape[1], ny = mesh.shape[0];
1877
+ var idx = Math.max(0, Math.min(nx * ny - 1, index));
1878
+ var cc = meshCellCenter(mesh, idx);
1879
+ var q = toPixel(CUR[anchor.axes], cc.x, cc.y);
1880
+ return { px: q.x, py: q.y, index: idx, label: 'x=' + fmt(cc.x) + ', y=' +
1881
+ fmt(cc.y) + ', ' + (mesh.name || 'z') + '=' + fmt(mesh.z[idx]) };
1882
+ }
1883
+ if (anchor.kind === 'meshframe') {
1884
+ var rec = FRAME_INDEX[anchor.id];
1885
+ if (!rec) return null;
1886
+ var mesh = rec.entry, f = CURRENT_FRAME[mesh.unit] || 0;
1887
+ var nx = mesh.shape[1], ny = mesh.shape[0];
1888
+ var idx = Math.max(0, Math.min(nx * ny - 1, index));
1889
+ var cc = meshCellCenter(mesh, idx);
1890
+ // The cell's own position never changes frame to frame (mesh_frames'
1891
+ // X/Y are shared, only C animates) -- only the value in its label does.
1892
+ var q = toPixel(CUR[rec.axesKey], cc.x, cc.y);
1893
+ return { px: q.x, py: q.y, index: idx, label: 'x=' + fmt(cc.x) + ', y=' +
1894
+ fmt(cc.y) + ', ' + (mesh.name || 'z') + '=' + fmt(mesh.z[f][idx]) };
1895
+ }
1896
+ var s = seriesOf(anchor);
1897
+ if (!s) return null;
1898
+ var j = Math.max(0, Math.min(s.x.length - 1, index));
1899
+ var q2 = toPixel(CUR[s.axes], s.x[j], s.y[j]);
1900
+ var lbl = 'x=' + fmt(s.x[j]) + ', y=' + fmt(s.y[j]);
1901
+ if (s.vals) for (var k in s.vals) lbl += ', ' + k + '=' + fmt(s.vals[k][j]);
1902
+ return { px: q2.x, py: q2.y, index: j, label: lbl };
1903
+ }
1904
+
1905
+ // Directional nearest neighbour (pixel space) for scatter clouds.
1906
+ function scatterNeighbor(anchor, cur, dir) {
1907
+ var s = seriesOf(anchor), m = CUR[anchor.axes];
1908
+ var c = toPixel(m, s.x[cur], s.y[cur]);
1909
+ var best = cur, bd = Infinity;
1910
+ for (var j = 0; j < s.x.length; j++) {
1911
+ if (j === cur) continue;
1912
+ var q = toPixel(m, s.x[j], s.y[j]);
1913
+ var dx = q.x - c.x, dy = q.y - c.y; // pixel y grows downward
1914
+ var ok = dir === 'right' ? dx > 0.5 : dir === 'left' ? dx < -0.5 :
1915
+ dir === 'up' ? dy < -0.5 : dy > 0.5;
1916
+ if (!ok) continue;
1917
+ var dist = dx * dx + dy * dy;
1918
+ if (dist < bd) { bd = dist; best = j; }
1919
+ }
1920
+ return best;
1921
+ }
1922
+
1923
+ // Next index/cell for a marker given an arrow direction.
1924
+ function neighbor(anchor, index, dir) {
1925
+ if (anchor.kind === 'pie') {
1926
+ var n = PICK[anchor.axes].pies[anchor.pie].fracs.length;
1927
+ return index + ((dir === 'right' || dir === 'up') ? 1 : -1) + n; // resolve wraps
1928
+ }
1929
+ if (anchor.kind === 'mesh' || anchor.kind === 'meshframe') {
1930
+ var mesh = anchor.kind === 'mesh' ? PICK[anchor.axes].meshes[anchor.mesh]
1931
+ : FRAME_INDEX[anchor.id].entry;
1932
+ var nx = mesh.shape[1], ny = mesh.shape[0];
1933
+ var row = Math.floor(index / nx), col = index % nx;
1934
+ // Row/col grow with data value, not screen position -- on an inverted
1935
+ // axis, larger data value is drawn toward the *start* of the screen
1936
+ // (left/top), so the arrow key's screen-space meaning flips too.
1937
+ var am = META[anchor.axes] || {};
1938
+ var right = am.xinv ? -1 : 1, up = am.yinv ? -1 : 1;
1939
+ if (dir === 'right') col = Math.min(nx - 1, Math.max(0, col + right));
1940
+ else if (dir === 'left') col = Math.min(nx - 1, Math.max(0, col - right));
1941
+ else if (dir === 'up') row = Math.min(ny - 1, Math.max(0, row + up));
1942
+ else row = Math.min(ny - 1, Math.max(0, row - up));
1943
+ return row * nx + col;
1944
+ }
1945
+ if (anchor.kind === 'points' && anchor.ptype === 'scatter') {
1946
+ return scatterNeighbor(anchor, index, dir);
1947
+ }
1948
+ // line / frame: step in array order
1949
+ return index + ((dir === 'right' || dir === 'up') ? 1 : -1);
1950
+ }
1951
+
1952
+ function pinAnchor(pin) {
1953
+ var k = pin.dataset.kind;
1954
+ if (!k) return null; // mesh-less fallback pins aren't steppable
1955
+ if (k === 'frame') return { kind: 'frame', id: pin.dataset.frameId, unit: pin.dataset.frameUnit };
1956
+ if (k === 'meshframe') return { kind: 'meshframe', axes: pin.dataset.axes,
1957
+ id: pin.dataset.frameId, unit: pin.dataset.frameUnit };
1958
+ if (k === 'mesh') return { kind: 'mesh', axes: pin.dataset.axes, mesh: +pin.dataset.mesh };
1959
+ if (k === 'pie') return { kind: 'pie', axes: pin.dataset.axes, pie: +pin.dataset.pie };
1960
+ return { kind: 'points', axes: pin.dataset.axes, series: +pin.dataset.series,
1961
+ ptype: pin.dataset.ptype };
1962
+ }
1963
+
1964
+ // `text`, when given, overrides the auto-generated "x=.., y=.." readout --
1965
+ // an "Annotate Point" note locked to this anchor. It has to be threaded
1966
+ // through stepPin/relayoutPins too, or the very first re-layout (a step, a
1967
+ // pan, a zoom) would stomp the user's text back to the plain readout.
1968
+ function addAnchoredPin(anchor, index, text) {
1969
+ var a = resolve(anchor, index);
1970
+ if (!a) return;
1971
+ var g = addPin(a.px, a.py, text !== undefined ? text : a.label, anchor.axes);
1972
+ g.dataset.kind = anchor.kind;
1973
+ g.dataset.index = a.index;
1974
+ if (text !== undefined) { g.dataset.customLabel = text; g.classList.add('plotpress-note'); }
1975
+ if (anchor.kind === 'frame') {
1976
+ g.dataset.frameId = anchor.id; g.dataset.frameUnit = anchor.unit;
1977
+ } else if (anchor.kind === 'meshframe') {
1978
+ g.dataset.axes = anchor.axes;
1979
+ g.dataset.frameId = anchor.id; g.dataset.frameUnit = anchor.unit;
1980
+ } else if (anchor.kind === 'mesh') {
1981
+ g.dataset.axes = anchor.axes; g.dataset.mesh = anchor.mesh;
1982
+ } else if (anchor.kind === 'pie') {
1983
+ g.dataset.axes = anchor.axes; g.dataset.pie = anchor.pie;
1984
+ } else {
1985
+ g.dataset.axes = anchor.axes; g.dataset.series = anchor.series;
1986
+ g.dataset.ptype = anchor.ptype;
1987
+ }
1988
+ // addPin() already refreshed drag-readiness once, before the
1989
+ // .plotpress-note class above (a legacy "Annotate Point" restore) was
1990
+ // applied -- that class is what boxDraggableNow() itself keys its
1991
+ // point-picking-vs-annotation split on, so it has to run again now
1992
+ // that it's actually set.
1993
+ if (text !== undefined) refreshOneDragReady(g);
1994
+ return g;
1995
+ }
1996
+
1997
+ function pinLabel(pin, autoLabel) {
1998
+ return pin.dataset.customLabel !== undefined ? pin.dataset.customLabel : autoLabel;
1999
+ }
2000
+
2001
+ // Move a marker to a neighbouring point/cell (arrow keys).
2002
+ function stepPin(pin, dir) {
2003
+ var anchor = pinAnchor(pin);
2004
+ if (!anchor) return;
2005
+ var a = resolve(anchor, neighbor(anchor, +pin.dataset.index, dir));
2006
+ if (!a) return;
2007
+ pin.dataset.index = a.index;
2008
+ layoutPin(pin, a.px, a.py, pinLabel(pin, a.label));
2009
+ }
2010
+
2011
+ // ---- extract markers --------------------------------------------------
2012
+ // Structured values for one marker (numbers, incl. any extra dims).
2013
+ function markerRecord(pin) {
2014
+ var anchor = pinAnchor(pin), rec = {};
2015
+ if (anchor && anchor.kind === 'pie') {
2016
+ var pie = PICK[anchor.axes].pies[anchor.pie], idx = +pin.dataset.index;
2017
+ rec.axes = +anchor.axes; rec.kind = 'pie'; rec.index = idx;
2018
+ rec.value = pie.values[idx]; rec.fraction = pie.fracs[idx];
2019
+ if (pie.labels) rec.label = pie.labels[idx];
2020
+ } else if (anchor && anchor.kind === 'mesh') {
2021
+ var mesh = PICK[anchor.axes].meshes[anchor.mesh];
2022
+ var idx = +pin.dataset.index;
2023
+ var cc = meshCellCenter(mesh, idx);
2024
+ rec.axes = +anchor.axes; rec.kind = 'mesh'; rec.index = idx;
2025
+ rec.x = cc.x; rec.y = cc.y;
2026
+ rec[mesh.name || 'z'] = mesh.z[idx];
2027
+ } else if (anchor && anchor.kind === 'meshframe') {
2028
+ var mesh = FRAME_INDEX[anchor.id].entry, idx = +pin.dataset.index;
2029
+ var f = CURRENT_FRAME[mesh.unit] || 0;
2030
+ var cc = meshCellCenter(mesh, idx);
2031
+ rec.axes = +anchor.axes; rec.kind = 'meshframe'; rec.index = idx;
2032
+ rec.x = cc.x; rec.y = cc.y;
2033
+ rec[mesh.name || 'z'] = mesh.z[f][idx];
2034
+ } else if (anchor) {
2035
+ var s = seriesOf(anchor), j = +pin.dataset.index;
2036
+ rec.axes = +s.axes; rec.kind = anchor.kind; rec.index = j;
2037
+ rec.x = s.x[j]; rec.y = s.y[j];
2038
+ // s.vals comes from the plotting call's own pick_values={...} -- an
2039
+ // arbitrary, user-chosen key (e.g. "kind") must not clobber the
2040
+ // structured fields just set above, same rule set_pick_context
2041
+ // follows below for its own axes-level context.
2042
+ if (s.vals) for (var k in s.vals) if (!(k in rec)) rec[k] = s.vals[k][j];
2043
+ } else if (pin.dataset.annotation) {
2044
+ rec.kind = 'annotation';
2045
+ rec.text = pin.querySelector('text').textContent;
2046
+ if (pin.dataset.axes !== undefined) {
2047
+ rec.axes = +pin.dataset.axes;
2048
+ rec.x = +pin.dataset.x; rec.y = +pin.dataset.y;
2049
+ } else {
2050
+ // Dropped outside any axes (an Annotation note in the figure's
2051
+ // margins or between panels) -- there is no data coordinate to give
2052
+ // it, only a fixed figure pixel position.
2053
+ rec.px = +pin.dataset.px; rec.py = +pin.dataset.py;
2054
+ }
2055
+ } else {
2056
+ rec.kind = 'free';
2057
+ if (pin.dataset.axes !== undefined) rec.axes = +pin.dataset.axes;
2058
+ rec.x = +pin.dataset.x; rec.y = +pin.dataset.y;
2059
+ }
2060
+ // A legacy "Annotate Point" note's user text (see the restore-a-pin
2061
+ // comment above -- that mode no longer creates new pins, only old saved
2062
+ // files can still carry one) rides alongside its anchor's own
2063
+ // structured fields (x/y/z/...) set above, rather than replacing them.
2064
+ if (pin.dataset.customLabel !== undefined) rec.text = pin.dataset.customLabel;
2065
+ // Identify the source panel by name, not just its bare index -- falls
2066
+ // back to a generated name when that axes has no title set, so every
2067
+ // record carries one. xlabel/ylabel/zlabel (zlabel from any colorbar
2068
+ // attached to this axes, shared or not) ride along too, so a value
2069
+ // pulled out of context still says what it means, not just a bare
2070
+ // number. group is the title of whichever fig.group() box this axes
2071
+ // sits in (joined with ", " if it's in more than one, empty if none),
2072
+ // so a marker from a clustered panel says which cluster it came from.
2073
+ // Any per-axes context (Axes.set_pick_context) rides along as well,
2074
+ // without clobbering a structured field of the same name (x, y,
2075
+ // kind, ...) that the picked data itself already set.
2076
+ if (rec.axes !== undefined) {
2077
+ var am = META[rec.axes];
2078
+ rec.axes_title = (am && am.title) ? am.title : ('axes ' + rec.axes);
2079
+ rec.xlabel = am ? am.xlabel : '';
2080
+ rec.ylabel = am ? am.ylabel : '';
2081
+ rec.zlabel = am ? am.zlabel : '';
2082
+ rec.group = am ? am.group : '';
2083
+ if (am && am.context) {
2084
+ for (var ck in am.context) if (!(ck in rec)) rec[ck] = am.context[ck];
2085
+ }
2086
+ }
2087
+ return rec;
2088
+ }
2089
+
2090
+ // Every pin, Point Picking and Annotation alike -- this is the general
2091
+ // public query (window.plotpressGetMarkers, qt.py's LiveArtist marker
2092
+ // sync, a custom tool's own onClick logging its progress), not Extract's.
2093
+ // Extract itself is narrower -- see doExtract below.
2094
+ function getMarkers() {
2095
+ return Array.prototype.map.call(
2096
+ document.querySelectorAll('.plotpress-pin'), markerRecord);
2097
+ }
2098
+ window.plotpressGetMarkers = getMarkers; // programmatic access
2099
+
2100
+ // For a custom tool (see addTool/plotpressAddTool): the same axes-lookup
2101
+ // + pixel-to-data conversion Point Picking itself uses, minus dropping a
2102
+ // pin -- so a custom onClick can work in real data units, not just the
2103
+ // raw SVG-space point it's already handed, without reimplementing the
2104
+ // per-axes log-scale/inverted-axis-aware transform. Returns null off any
2105
+ // (pickable) axes, same as a Point Picking click there does nothing.
2106
+ window.plotpressToData = function (p) {
2107
+ var a = pickableAxesAt(p);
2108
+ if (!a) return null;
2109
+ var d = toData(a.m, p.x, p.y);
2110
+ return { axes: a.i, x: d.x, y: d.y };
2111
+ };
2112
+
2113
+ // RFC 4180 field quoting -- a bare comma/quote/newline (annotation text,
2114
+ // an axes_title, a pie label, a set_pick_context() string) otherwise
2115
+ // shifts every column after it in that row.
2116
+ function csvField(v) {
2117
+ var s = v === undefined || v === null ? '' : String(v);
2118
+ return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
2119
+ }
2120
+
2121
+ function toCSV(recs) {
2122
+ if (!recs.length) return '';
2123
+ var keys = [];
2124
+ recs.forEach(function (r) {
2125
+ for (var k in r) if (keys.indexOf(k) < 0) keys.push(k);
2126
+ });
2127
+ var lines = [keys.map(csvField).join(',')];
2128
+ recs.forEach(function (r) {
2129
+ lines.push(keys.map(function (k) { return csvField(r[k]); }).join(','));
2130
+ });
2131
+ return lines.join('\n');
2132
+ }
2133
+
2134
+ function download(name, text, type) {
2135
+ var blob = new Blob([text], { type: type });
2136
+ var url = URL.createObjectURL(blob);
2137
+ var a = document.createElement('a');
2138
+ a.href = url; a.download = name;
2139
+ document.body.appendChild(a); a.click();
2140
+ setTimeout(function () { document.body.removeChild(a); URL.revokeObjectURL(url); }, 0);
2141
+ }
2142
+
2143
+ function showExtractPanel(records, csv, json) {
2144
+ var old = document.querySelector('.plotpress-extract');
2145
+ if (old) old.remove();
2146
+ var panel = document.createElement('div');
2147
+ panel.className = 'plotpress-extract';
2148
+ var head = document.createElement('div');
2149
+ head.style.cssText = 'font-weight:600;margin-bottom:6px';
2150
+ head.textContent = records.length + ' marker' + (records.length === 1 ? '' : 's');
2151
+ var ta = document.createElement('textarea');
2152
+ ta.readOnly = true; ta.value = csv || '(no markers)';
2153
+ var btns = document.createElement('div');
2154
+ btns.style.cssText = 'display:flex;gap:6px;margin-top:6px;flex-wrap:wrap';
2155
+ function mk(txt, fn) {
2156
+ var b = document.createElement('button');
2157
+ b.textContent = txt; b.addEventListener('click', fn); return b;
2158
+ }
2159
+ var copy = mk('Copy CSV', function () {
2160
+ ta.select();
2161
+ var done = function () {
2162
+ copy.textContent = 'Copied!';
2163
+ setTimeout(function () { copy.textContent = 'Copy CSV'; }, 1200);
2164
+ };
2165
+ if (navigator.clipboard) navigator.clipboard.writeText(csv).then(done, function () {
2166
+ try { document.execCommand('copy'); done(); } catch (e) {}
2167
+ });
2168
+ else { try { document.execCommand('copy'); done(); } catch (e) {} }
2169
+ });
2170
+ btns.appendChild(copy);
2171
+ btns.appendChild(mk('Download CSV', function () { download('markers.csv', csv, 'text/csv'); }));
2172
+ btns.appendChild(mk('Download JSON', function () { download('markers.json', json, 'application/json'); }));
2173
+ btns.appendChild(mk('Close', function () { panel.remove(); }));
2174
+ panel.appendChild(head); panel.appendChild(ta); panel.appendChild(btns);
2175
+ document.body.appendChild(panel);
2176
+ ta.focus(); ta.select();
2177
+ }
2178
+
2179
+ // Point Picking markers only, not Annotation notes -- Extract now lives
2180
+ // solely under the Point Picking menu (see TOOLS above), so its own
2181
+ // output scopes to match; an Annotation note has nothing to "extract" in
2182
+ // the same sense a picked data value does. :not(.plotpress-note) is the
2183
+ // one line doing that filtering -- getMarkers() above (and every other
2184
+ // .plotpress-pin selector in this file that isn't already kind-specific,
2185
+ // like drag-ready and clearAllPins) deliberately still covers both kinds.
2186
+ function doExtract() {
2187
+ var records = Array.prototype.map.call(
2188
+ document.querySelectorAll('.plotpress-pin:not(.plotpress-note)'), markerRecord);
2189
+ // Hand off to Python when running inside the native (pywebview) window.
2190
+ try {
2191
+ if (window.pywebview && window.pywebview.api && window.pywebview.api.extract) {
2192
+ window.pywebview.api.extract(records);
2193
+ }
2194
+ } catch (e) {}
2195
+ // In wait-for-extract mode the kernel closes the window on receipt, so skip
2196
+ // the panel; otherwise show it for copy/download.
2197
+ if (!window.PLOTPRESS_WAIT_EXTRACT) {
2198
+ showExtractPanel(records, toCSV(records), JSON.stringify(records, null, 2));
2199
+ }
2200
+ }
2201
+ window.plotpressExtract = doExtract;
2202
+
2203
+ // ---- save/save as: persist the current pan/zoom, pins, and toggles ----
2204
+ // A plain data-only re-serve (Extract) is not "resume where I left off" --
2205
+ // this rebuilds the whole page instead, from the same clean pre-mutation
2206
+ // snapshot (ORIGINAL_DOC_HTML) every save starts from, plus one new
2207
+ // payload script tag the bootstrap below reads back on the saved file's
2208
+ // own next load.
2209
+ function serializePins() {
2210
+ var out = [];
2211
+ document.querySelectorAll('.plotpress-pin').forEach(function (pin) {
2212
+ var d = {};
2213
+ for (var k in pin.dataset) d[k] = pin.dataset[k];
2214
+ out.push({
2215
+ data: d, note: pin.classList.contains('plotpress-note'),
2216
+ selected: pin === selectedPin,
2217
+ text: pin.querySelector('text').textContent,
2218
+ });
2219
+ });
2220
+ return out;
2221
+ }
2222
+
2223
+ // Rebuilds each pin the same way it was first created: an anchored pin
2224
+ // (data.kind set -- Point Picking today, or a legacy Annotate Point pin
2225
+ // restored from a page saved before that mode was removed) through
2226
+ // addAnchoredPin(),
2227
+ // which re-resolves its position from the *current* (already-restored)
2228
+ // view via pinAnchor()/resolve() exactly as a fresh click would; a free
2229
+ // annotation or the large-series geometry fallback (no data.kind) directly,
2230
+ // converting its own saved data-space x/y through the current view when it
2231
+ // has one, or at its saved fixed figure position when it doesn't.
2232
+ function restorePins(saved) {
2233
+ var toSelect = null;
2234
+ saved.forEach(function (rec) {
2235
+ var g;
2236
+ if (rec.data.kind) {
2237
+ var scratch = document.createElementNS(SVGNS, 'g');
2238
+ for (var k in rec.data) scratch.dataset[k] = rec.data[k];
2239
+ var anchor = pinAnchor(scratch);
2240
+ if (!anchor) return;
2241
+ g = addAnchoredPin(anchor, +rec.data.index, rec.data.customLabel);
2242
+ // addAnchoredPin() only copies the specific fields it knows about
2243
+ // onto the fresh pin it creates -- a dragged box's own offset (see
2244
+ // startBoxDrag) isn't one of them, so it's carried over here
2245
+ // explicitly, the same as the free-note branch's full dataset copy
2246
+ // below already does for its own pins.
2247
+ if (g && rec.data.boxDx !== undefined) {
2248
+ g.dataset.boxDx = rec.data.boxDx; g.dataset.boxDy = rec.data.boxDy;
2249
+ }
2250
+ } else {
2251
+ var px, py;
2252
+ if (rec.data.axes !== undefined && CUR[rec.data.axes]) {
2253
+ var q = toPixel(CUR[rec.data.axes], +rec.data.x, +rec.data.y);
2254
+ px = q.x; py = q.y;
2255
+ } else {
2256
+ px = +rec.data.px; py = +rec.data.py;
2257
+ }
2258
+ g = addPin(px, py, rec.text, rec.data.axes);
2259
+ for (var k2 in rec.data) g.dataset[k2] = rec.data[k2];
2260
+ }
2261
+ if (!g) return;
2262
+ if (rec.note) g.classList.add('plotpress-note');
2263
+ if (rec.selected) toSelect = g;
2264
+ // Both branches above set g.dataset.boxDx *after* addPin()'s own
2265
+ // initial layoutPin() call already ran (with the default offset,
2266
+ // since the dataset didn't have it yet at that point) -- applying a
2267
+ // restored custom offset needs one more explicit re-layout.
2268
+ if (g.dataset.boxDx !== undefined) {
2269
+ layoutPin(g, +g.dataset.anchorX, +g.dataset.anchorY, g.querySelector('text').textContent);
2270
+ }
2271
+ });
2272
+ selectPin(toSelect);
2273
+ refreshDragReady();
2274
+ }
2275
+
2276
+ function buildSaveState() {
2277
+ var axesView = {};
2278
+ Object.keys(CUR).forEach(function (k) {
2279
+ var c = CUR[k];
2280
+ axesView[k] = { xmin: c.xmin, xmax: c.xmax, ymin: c.ymin, ymax: c.ymax };
2281
+ });
2282
+ var hiddenLabels = [];
2283
+ document.querySelectorAll('.plotpress-series').forEach(function (s) {
2284
+ if (s.style.display !== 'none') return;
2285
+ var label = s.getAttribute('data-label');
2286
+ if (hiddenLabels.indexOf(label) < 0) hiddenLabels.push(label);
2287
+ });
2288
+ return {
2289
+ zoomScale: zoomScale, scrollX: window.scrollX, scrollY: window.scrollY,
2290
+ axes: axesView, pins: serializePins(),
2291
+ pointsHidden: pointsHidden, annotationsHidden: annotationsHidden,
2292
+ hiddenLegendLabels: hiddenLabels,
2293
+ };
2294
+ }
2295
+
2296
+ function applySavedState(state) {
2297
+ if (!state) return;
2298
+ // Zoom before scroll: scrolling to a saved position only lands right if
2299
+ // the page is already the size that position was saved from.
2300
+ if (state.zoomScale) { zoomScale = state.zoomScale; applyZoomSize(); }
2301
+ if (state.scrollX !== undefined) window.scrollTo(state.scrollX, state.scrollY);
2302
+ if (state.axes) {
2303
+ Object.keys(state.axes).forEach(function (k) {
2304
+ if (!CUR[k]) return;
2305
+ var a = state.axes[k], c = CUR[k];
2306
+ c.xmin = a.xmin; c.xmax = a.xmax; c.ymin = a.ymin; c.ymax = a.ymax;
2307
+ refreshAxes(k);
2308
+ });
2309
+ }
2310
+ if (state.pointsHidden) {
2311
+ var pointsBtn = buttons.filter(function (b) {
2312
+ return b.textContent === 'Hide Points' || b.textContent === 'Show Points';
2313
+ })[0];
2314
+ if (pointsBtn) togglePointsHidden(pointsBtn);
2315
+ }
2316
+ if (state.annotationsHidden) {
2317
+ var annotBtn = buttons.filter(function (b) {
2318
+ return b.textContent === 'Hide Annotations' || b.textContent === 'Show Annotations';
2319
+ })[0];
2320
+ if (annotBtn) toggleAnnotationsHidden(annotBtn);
2321
+ }
2322
+ (state.hiddenLegendLabels || []).forEach(function (label) {
2323
+ document.querySelectorAll('.plotpress-legend text').forEach(function (t) {
2324
+ if (t.textContent === label) t.style.opacity = '0.4';
2325
+ });
2326
+ document.querySelectorAll('.plotpress-series').forEach(function (s) {
2327
+ if (s.getAttribute('data-label') === label) s.style.display = 'none';
2328
+ });
2329
+ });
2330
+ if (state.pins) restorePins(state.pins); // last: needs the view above already in place
2331
+ }
2332
+
2333
+ function buildSaveHTML() {
2334
+ // A stale payload from an earlier save has to come out through a real
2335
+ // DOM (DOMParser), not a raw string/regex replace against
2336
+ // ORIGINAL_DOC_HTML: that string is the *whole* page, which includes
2337
+ // this very script's own source -- and this function's source text
2338
+ // necessarily spells out id="plotpress-saved-state" itself (to build
2339
+ // and to look for that same tag). A regex scanning raw text can't tell
2340
+ // that occurrence apart from a genuine tag and matches from there
2341
+ // instead, non-greedily eating everything up to the real </script> that
2342
+ // ends the whole interactive script -- silently truncating every saved
2343
+ // copy's own toolbar script mid-function. A parsed DOM has no such
2344
+ // ambiguity: getElementById only ever matches a real element, never
2345
+ // text sitting inside another element's own content.
2346
+ var doc = new DOMParser().parseFromString(ORIGINAL_DOC_HTML, 'text/html');
2347
+ var stale = doc.getElementById('plotpress-saved-state');
2348
+ if (stale) stale.remove();
2349
+ var script = doc.createElement('script');
2350
+ script.type = 'application/json';
2351
+ script.id = 'plotpress-saved-state';
2352
+ // Escaped the same way figure._json_payload() escapes every other
2353
+ // embedded payload: raw text elements serialize verbatim, so a saved
2354
+ // annotation whose text happened to contain "</script>" would otherwise
2355
+ // round-trip into literally invalid, unparseable markup.
2356
+ script.textContent = JSON.stringify(buildSaveState())
2357
+ .replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
2358
+ // As the *first* child of body, not appended at the end: the toolbar
2359
+ // script that reads this back runs synchronously the moment the parser
2360
+ // reaches its own closing tag, before it has seen any later sibling --
2361
+ // appended after it, this element would not exist in the DOM yet at the
2362
+ // point document.getElementById('plotpress-saved-state') looks for it.
2363
+ doc.body.insertBefore(script, doc.body.firstChild);
2364
+ return '<!doctype html>' + doc.documentElement.outerHTML;
2365
+ }
2366
+
2367
+ function suggestedFilename() {
2368
+ var t = (document.title || 'plotpress-figure').replace(/[^\w.-]+/g, '_').toLowerCase();
2369
+ return /\.html?$/.test(t) ? t : t + '.html';
2370
+ }
2371
+
2372
+ function downloadHTML(htmlText, filename) {
2373
+ var blob = new Blob([htmlText], { type: 'text/html' });
2374
+ var url = URL.createObjectURL(blob);
2375
+ var a = document.createElement('a');
2376
+ a.href = url; a.download = filename;
2377
+ document.body.appendChild(a); a.click(); a.remove();
2378
+ setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
2379
+ }
2380
+
2381
+ // Both Save and Save As need a way to let the user choose where the file
2382
+ // goes and what it's called -- the File System Access API's picker
2383
+ // (Chromium, a secure context only) is the only thing in a browser that
2384
+ // can show that dialog at all; a plain download never does; the browser's
2385
+ // own "always ask where to save" setting is outside the page's control
2386
+ // either way. A page can also never be handed a writable handle to the
2387
+ // exact file it was itself opened from (file:// has no such API), so even
2388
+ // Save's "overwrite in place" is really "pick a destination, defaulting to
2389
+ // this file's own name" rather than a silent, prompt-free write. Anywhere
2390
+ // the picker API is unavailable (Firefox, Safari, a non-secure origin),
2391
+ // both fall back to the same plain download -- always a new file there,
2392
+ // with no dialog, since nothing in the page can produce one.
2393
+ function saveViaPicker(htmlText) {
2394
+ if (!window.showSaveFilePicker) { downloadHTML(htmlText, suggestedFilename()); return; }
2395
+ window.showSaveFilePicker({
2396
+ suggestedName: suggestedFilename(),
2397
+ types: [{ description: 'HTML', accept: { 'text/html': ['.html'] } }],
2398
+ }).then(function (handle) {
2399
+ return handle.createWritable().then(function (w) {
2400
+ return w.write(htmlText).then(function () { return w.close(); });
2401
+ });
2402
+ }).catch(function (err) {
2403
+ if (err && err.name === 'AbortError') return; // user cancelled the picker
2404
+ downloadHTML(htmlText, suggestedFilename());
2405
+ });
2406
+ }
2407
+
2408
+ function saveAsNewPage() {
2409
+ saveViaPicker(buildSaveHTML());
2410
+ }
2411
+
2412
+ function overwriteCurrentPage() {
2413
+ saveViaPicker(buildSaveHTML());
2414
+ }
2415
+
2416
+ // Nearest vertex of an animated (frame) series at its current frame.
2417
+ function nearestFrameVertex(axesKey, m, p) {
2418
+ if (!FRAMES || !FRAMES[axesKey]) return null;
2419
+ var best = null;
2420
+ FRAMES[axesKey].forEach(function (e) {
2421
+ if (!e.Y) return; // a frame-mesh entry, not a frame-line one -- see meshFrameAt()
2422
+ var f = CURRENT_FRAME[e.unit] || 0;
2423
+ var xs = e.shared_x ? e.x : e.x[f], ys = e.Y[f];
2424
+ for (var j = 0; j < ys.length; j++) {
2425
+ var q = toPixel(m, xs[j], ys[j]);
2426
+ var d = (q.x - p.x) * (q.x - p.x) + (q.y - p.y) * (q.y - p.y);
2427
+ if (!best || d < best.d) {
2428
+ best = { d: d, ref: { kind: 'frame', id: e.id, unit: e.unit, index: j } };
2429
+ }
2430
+ }
2431
+ });
2432
+ return best;
2433
+ }
2434
+
2435
+ // The same target Point Picking resolves a click to (a point/frame vertex
2436
+ // within MESH_OVERRIDE_THRESHOLD px if the click also landed inside a mesh
2437
+ // cell -- see below -- else within POINT_THRESHOLD px, else a mesh cell,
2438
+ // else a pie wedge, else a point regardless of distance) -- kept as its
2439
+ // own function for the pick-mode click handler's own readability (it used
2440
+ // to also serve the now-removed "Annotate Point" mode, a second caller
2441
+ // wanting the exact same resolution logic). Returns
2442
+ // a steppable anchor ref; ``null`` if there's simply nothing pickable
2443
+ // there (the caller may fall back to a geometric readout); or the string
2444
+ // ``'blocked'`` for the one case that must produce nothing at all, not a
2445
+ // fallback -- a pie axes only has its wedges to pick, so a click that
2446
+ // misses every one of them is a genuine miss, not "no data nearby".
2447
+ function resolvePickTarget(e) {
2448
+ var p = toUser(e), a = pickableAxesAt(p);
2449
+ if (!a) return null;
2450
+ var m = a.m;
2451
+ var np = nearestPoint(a.i, m, p);
2452
+ var fp = nearestFrameVertex(a.i, m, p);
2453
+ var d = toData(m, p.x, p.y);
2454
+ var mesh = meshAt(a.i, d.x, d.y, p) || meshFrameAt(a.i, d.x, d.y, p);
2455
+ var pieHit = pieAt(a.i, p);
2456
+
2457
+ var cand = null;
2458
+ if (np) cand = { d: np.d, ref: np.ref };
2459
+ if (fp && (!cand || fp.d < cand.d)) cand = { d: fp.d, ref: fp.ref };
2460
+ // np.d/fp.d are squared distances in root SVG user-space units (the
2461
+ // same space toPixel()/toUser() both work in) -- a constant number of
2462
+ // *those* is not a constant number of actual screen pixels once the
2463
+ // whole figure is magnified (Pan/Zoom grows the SVG's own rendered CSS
2464
+ // size while its viewBox, and so this space, stays fixed -- see
2465
+ // pxPerUser()); an axes' own Axis Span/Zoom, by contrast, already
2466
+ // reshapes this same space when the view changes, so it needs no
2467
+ // separate handling here. Converting the *distance* to real screen px
2468
+ // (rather than the threshold to user-space units) keeps every
2469
+ // POINT_THRESHOLD/MESH_OVERRIDE_THRESHOLD comparison below meaning what
2470
+ // it says regardless of Pan/Zoom level, the same "constant on-screen
2471
+ // size" guarantee a pin's own marker already gets (see layoutPin).
2472
+ var scale = pxPerUser();
2473
+ var candPx = cand ? Math.sqrt(cand.d) * scale : Infinity;
2474
+
2475
+ var pd = PICK[a.i];
2476
+ if (pd && pd.pies && pd.pies.length && !pieHit && !mesh && candPx > POINT_THRESHOLD) {
2477
+ return 'blocked';
2478
+ }
2479
+ // Inside a mesh cell's own bounds, only a genuinely precise click on a
2480
+ // line/scatter point overrides it -- see MESH_OVERRIDE_THRESHOLD above.
2481
+ var pointThreshold = mesh ? MESH_OVERRIDE_THRESHOLD : POINT_THRESHOLD;
2482
+ if (cand && candPx <= pointThreshold) return cand.ref;
2483
+ if (mesh) return mesh;
2484
+ if (pieHit) return pieHit;
2485
+ if (cand) return cand.ref;
2486
+ return null;
2487
+ }
2488
+
2489
+ // Annotation (internal mode note-free): drop a note anywhere on the whole
2490
+ // figure, including the margins or the gap between subplots -- not locked
2491
+ // to any datum. Inside an axes it still tracks that axes' data coordinate
2492
+ // (so it pans/zooms with the plot it was drawn over); outside any axes
2493
+ // there is no data coordinate, so it just stays put at its figure pixel
2494
+ // position, which nothing in the interactive view moves. A separate
2495
+ // snap-to-nearest-datum variant ("Annotate Point") existed briefly here
2496
+ // and was removed -- Point Picking already covers snapping to a datum.
2497
+ function addFreeNote(e) {
2498
+ var p = toUser(e);
2499
+ // A cross-origin-equivalent embedding (an <iframe srcdoc=...>, as
2500
+ // Report.save() uses for every entry) has its own opaque origin, and
2501
+ // browsers silently block alert/confirm/prompt from such a frame --
2502
+ // window.prompt() there can either return null (treated as "cancelled"
2503
+ // below, same as a real one) or throw outright, depending on browser.
2504
+ // Catching it keeps the latter from surfacing as an uncaught error on
2505
+ // every click while this mode is active -- console.warn (once, not per
2506
+ // click) gives a developer debugging "Annotation does nothing here" an
2507
+ // actual lead, since the click itself otherwise looks identical to a
2508
+ // user simply cancelling the prompt.
2509
+ var text;
2510
+ try { text = window.prompt('Annotation text:'); }
2511
+ catch (err) {
2512
+ if (!addFreeNote._warned) {
2513
+ addFreeNote._warned = true;
2514
+ console.warn('plotpress: window.prompt() is blocked in this frame '
2515
+ + '(a cross-origin-equivalent embedding, e.g. Report.save()\'s '
2516
+ + '<iframe srcdoc>) -- the Annotation tool cannot ask for text '
2517
+ + 'here and will do nothing when clicked.');
2518
+ }
2519
+ return;
2520
+ }
2521
+ if (!text) return;
2522
+ var a = axesAt(p);
2523
+ var g = addPin(p.x, p.y, text, a ? a.i : undefined);
2524
+ g.classList.add('plotpress-note');
2525
+ g.dataset.annotation = '1';
2526
+ if (a) {
2527
+ var d = toData(a.m, p.x, p.y);
2528
+ g.dataset.x = d.x; g.dataset.y = d.y; g.dataset.axes = a.i;
2529
+ } else {
2530
+ g.dataset.px = p.x; g.dataset.py = p.y;
2531
+ }
2532
+ // addPin() already refreshed drag-readiness once, before the
2533
+ // .plotpress-note class above was applied -- see the same note in
2534
+ // addAnchoredPin().
2535
+ refreshOneDragReady(g);
2536
+ }
2537
+
2538
+ svg.addEventListener('click', function (e) {
2539
+ if (moved) return;
2540
+ if (e.target.closest('.plotpress-legend') || e.target.closest('.plotpress-pin')) return;
2541
+ if (mode === 'note-free') { addFreeNote(e); return; }
2542
+ if (mode === 'pick') {
2543
+ var ref = resolvePickTarget(e);
2544
+ if (ref === 'blocked') return;
2545
+ if (ref) { addAnchoredPin(ref, ref.index); return; }
2546
+ var p = toUser(e), a = pickableAxesAt(p);
2547
+ if (!a) return;
2548
+ var v = nearestVertex(a.i, p) || p; // large-series fallback
2549
+ var dd = toData(a.m, v.x, v.y);
2550
+ var g = addPin(v.x, v.y, 'x=' + fmt(dd.x) + ', y=' + fmt(dd.y), a.i);
2551
+ g.dataset.x = dd.x; g.dataset.y = dd.y; g.dataset.axes = a.i;
2552
+ return;
2553
+ }
2554
+ // A custom tool's own mode (see addTool/plotpressAddTool) -- every
2555
+ // built-in click-driven mode above has already had first refusal, so
2556
+ // this only ever fires for a mode this build doesn't know about itself.
2557
+ var custom = mode && CUSTOM_MODES[mode];
2558
+ if (custom && custom.onClick) custom.onClick(e, toUser(e));
2559
+ });
2560
+
2561
+ // ---- slider(s) over extra data dimensions -----------------------------
2562
+ // Each "unit" is one control bar. The global unit ("main") is a single bar
2563
+ // driving all shared series; a docked unit sits under its axes. Docked units
2564
+ // that share a connection index show an index badge + a checkbox to link them
2565
+ // so they scrub together on demand.
2566
+ var framesEl = document.getElementById('plotpress-frames');
2567
+ var unitsEl = document.getElementById('plotpress-sliders');
2568
+ var FRAMES = framesEl ? reviveBinary(JSON.parse(framesEl.textContent)) : null;
2569
+ var UNITS = unitsEl ? JSON.parse(unitsEl.textContent) : null;
2570
+ var LINKS = {}; // connection index -> [slider api]
2571
+ if (FRAMES) {
2572
+ for (var fk in FRAMES) {
2573
+ FRAMES[fk].forEach(function (e) { FRAME_INDEX[e.id] = { entry: e, axesKey: fk }; });
2574
+ }
2575
+ }
2576
+
2577
+ // Move any pins attached to this unit's series to the new frame's vertex.
2578
+ function updateFramePins(unit, f) {
2579
+ var pins = document.querySelectorAll('.plotpress-pin[data-frame-unit="' + unit + '"]');
2580
+ for (var i = 0; i < pins.length; i++) {
2581
+ var pin = pins[i];
2582
+ var a = resolve(pinAnchor(pin), +pin.dataset.index); // uses current frame
2583
+ // pinLabel(), not a.label directly -- an Annotate Point note's
2584
+ // customLabel has to survive a frame-slider scrub the same way it
2585
+ // already survives pan/zoom (relayoutPins) and arrow-key stepping
2586
+ // (stepPin), or scrubbing silently stomps the user's text back to the
2587
+ // auto-generated readout.
2588
+ if (a) layoutPin(pin, a.px, a.py, pinLabel(pin, a.label));
2589
+ }
2590
+ }
2591
+
2592
+ function drawFrame(unit, f) {
2593
+ if (!FRAMES) return;
2594
+ for (var key in FRAMES) {
2595
+ var m = CUR[key];
2596
+ if (!m) continue;
2597
+ FRAMES[key].forEach(function (e) {
2598
+ if (e.unit !== unit) return;
2599
+ var el = document.getElementById(e.id);
2600
+ if (!el) return;
2601
+ if (e.hrefs) {
2602
+ // A mesh: every frame shares one X/Y grid, so only the pixel
2603
+ // content changes -- swap the image, not its position.
2604
+ el.setAttribute('href', e.hrefs[f]);
2605
+ return;
2606
+ }
2607
+ var xs = e.shared_x ? e.x : e.x[f];
2608
+ var ys = e.Y[f], d = '';
2609
+ for (var j = 0; j < ys.length; j++) {
2610
+ var q = toPixel(m, xs[j], ys[j]);
2611
+ d += (j === 0 ? 'M' : 'L') + q.x.toFixed(2) + ',' + q.y.toFixed(2);
2612
+ }
2613
+ el.setAttribute('d', d);
2614
+ });
2615
+ }
2616
+ updateFramePins(unit, f);
2617
+ }
2618
+
2619
+ function buildSlider(unit, spec, opts) {
2620
+ var box = document.createElement('div');
2621
+ box.className = 'plotpress-slider';
2622
+ var api = { index: spec.index, checkbox: null, external: null, frame: 0 };
2623
+
2624
+ // Index badge + link checkbox (only when this index is shared by 2+ units).
2625
+ if (opts.showLink) {
2626
+ var link = document.createElement('label');
2627
+ link.className = 'link';
2628
+ link.title = 'link all "' + spec.index + '" sliders to scrub together';
2629
+ api.checkbox = document.createElement('input');
2630
+ api.checkbox.type = 'checkbox';
2631
+ var idx = document.createElement('span');
2632
+ idx.className = 'idx'; idx.textContent = spec.index;
2633
+ link.appendChild(api.checkbox); link.appendChild(idx);
2634
+ box.appendChild(link);
2635
+ }
2636
+
2637
+ var input = document.createElement('input');
2638
+ input.type = 'range'; input.min = 0; input.max = spec.n - 1;
2639
+ input.step = 1; input.value = 0;
2640
+ if (opts.inputWidth) input.style.width = opts.inputWidth + 'px';
2641
+ var val = document.createElement('span'); val.className = 'val';
2642
+
2643
+ var timer = null;
2644
+ var applyFrame = function (f) {
2645
+ api.frame = (f % spec.n + spec.n) % spec.n;
2646
+ CURRENT_FRAME[unit] = api.frame;
2647
+ input.value = api.frame;
2648
+ drawFrame(unit, api.frame);
2649
+ val.textContent = spec.label + ' = ' + fmt(spec.values[api.frame]);
2650
+ };
2651
+ api.external = applyFrame; // set from a linked peer, no re-propagation
2652
+ var setFrame = function (f) {
2653
+ applyFrame(f);
2654
+ if (api.checkbox && api.checkbox.checked) {
2655
+ (LINKS[spec.index] || []).forEach(function (o) {
2656
+ if (o !== api && o.checkbox && o.checkbox.checked) o.external(api.frame);
2657
+ });
2658
+ }
2659
+ };
2660
+
2661
+ var sbtn = function (txt, title) {
2662
+ var b = document.createElement('button');
2663
+ b.textContent = txt; b.title = title;
2664
+ return b;
2665
+ };
2666
+ var back = sbtn('⏮', 'step back');
2667
+ var playBtn = sbtn('▶', 'play');
2668
+ var fwd = sbtn('⏭', 'step forward');
2669
+ var pause = function () {
2670
+ if (timer) { clearInterval(timer); timer = null; }
2671
+ playBtn.textContent = '▶'; playBtn.title = 'play';
2672
+ };
2673
+ var play = function () {
2674
+ if (timer) return;
2675
+ playBtn.textContent = '⏸'; playBtn.title = 'pause';
2676
+ timer = setInterval(function () { setFrame(api.frame + 1); }, 80);
2677
+ };
2678
+ back.addEventListener('click', function () { pause(); setFrame(api.frame - 1); });
2679
+ fwd.addEventListener('click', function () { pause(); setFrame(api.frame + 1); });
2680
+ playBtn.addEventListener('click', function () { timer ? pause() : play(); });
2681
+ input.addEventListener('input', function () { pause(); setFrame(+input.value); });
2682
+
2683
+ // When linking is switched on, snap to an already-linked peer's frame.
2684
+ if (api.checkbox) {
2685
+ api.checkbox.addEventListener('change', function () {
2686
+ if (!api.checkbox.checked) return;
2687
+ var peer = (LINKS[spec.index] || []).find(function (o) {
2688
+ return o !== api && o.checkbox && o.checkbox.checked;
2689
+ });
2690
+ if (peer) setFrame(peer.frame);
2691
+ });
2692
+ (LINKS[spec.index] = LINKS[spec.index] || []).push(api);
2693
+ }
2694
+
2695
+ box.appendChild(back); box.appendChild(playBtn); box.appendChild(fwd);
2696
+ box.appendChild(input); box.appendChild(val);
2697
+ return { box: box, setFrame: setFrame };
2698
+ }
2699
+
2700
+ if (UNITS && FRAMES) {
2701
+ // Wrap the SVG so docked sliders can be positioned over it. Sized by the
2702
+ // .plotpress-svg-wrap rule in the page's own <style> (see Figure.to_html),
2703
+ // not inline here -- standalone shrink-wraps it to the SVG's natural size
2704
+ // for flex-centering; embedded (standalone=False) stretches it to the
2705
+ // container's width so #plotpress-svg's own width:100% has a definite,
2706
+ // non-circular size to resolve against instead of falling back to the
2707
+ // SVG's fixed width/height attributes.
2708
+ wrap = document.createElement('div');
2709
+ wrap.className = 'plotpress-svg-wrap';
2710
+ svg.parentNode.insertBefore(wrap, svg);
2711
+ wrap.appendChild(svg);
2712
+
2713
+ // How many units share each connection index (>=2 => offer linking).
2714
+ var indexCount = {};
2715
+ Object.keys(UNITS).forEach(function (u) {
2716
+ var ix = UNITS[u].index;
2717
+ if (ix != null) indexCount[ix] = (indexCount[ix] || 0) + 1;
2718
+ });
2719
+
2720
+ var globalBar = null;
2721
+ var order = Object.keys(UNITS).sort(function (a, b) {
2722
+ if (a === 'main') return -1;
2723
+ if (b === 'main') return 1;
2724
+ return a < b ? -1 : 1;
2725
+ });
2726
+ order.forEach(function (u) {
2727
+ var spec = UNITS[u];
2728
+ if (spec.global) {
2729
+ if (!globalBar) {
2730
+ globalBar = document.createElement('div');
2731
+ globalBar.className = 'plotpress-sliders';
2732
+ document.body.appendChild(globalBar);
2733
+ }
2734
+ var g = buildSlider(u, spec, { inputWidth: 240, showLink: false });
2735
+ globalBar.appendChild(g.box); g.setFrame(0);
2736
+ } else {
2737
+ var m = META[spec.axes] || { x: 0, y: 0, w: home[2], h: home[3] };
2738
+ var showLink = indexCount[spec.index] >= 2;
2739
+ var iw = Math.max(80, Math.min(240, m.w - (showLink ? 210 : 175)));
2740
+ var r = buildSlider(u, spec, { inputWidth: iw, showLink: showLink });
2741
+ r.box.style.position = 'absolute';
2742
+ r.box.style.whiteSpace = 'nowrap';
2743
+ wrap.appendChild(r.box);
2744
+ dockedSliders.push({ box: r.box, axesKey: spec.axes });
2745
+ r.setFrame(0);
2746
+ }
2747
+ });
2748
+ positionDocked();
2749
+ window.addEventListener('resize', positionDocked);
2750
+ }
2751
+
2752
+ // Three flavors, all deselecting selectedPin only when it's actually one
2753
+ // of the pins being removed -- clearing points shouldn't drop the user's
2754
+ // in-progress arrow-key selection of an annotation they were just
2755
+ // stepping through, and vice versa. .plotpress-note is the same class
2756
+ // addFreeNote() (Annotation mode) tags every note with -- see the TOOLS
2757
+ // comment above for why that's the reliable point/annotation split, not
2758
+ // e.g. a pin's `kind`.
2759
+ function isAnnotationPin(p) { return p.classList.contains('plotpress-note'); }
2760
+
2761
+ function clearPointPins() {
2762
+ document.querySelectorAll('.plotpress-pin').forEach(function (p) {
2763
+ if (!isAnnotationPin(p)) p.remove();
2764
+ });
2765
+ if (selectedPin && !isAnnotationPin(selectedPin)) selectedPin = null;
2766
+ }
2767
+
2768
+ function clearAnnotationPins() {
2769
+ document.querySelectorAll('.plotpress-pin').forEach(function (p) {
2770
+ if (isAnnotationPin(p)) p.remove();
2771
+ });
2772
+ if (selectedPin && isAnnotationPin(selectedPin)) selectedPin = null;
2773
+ }
2774
+
2775
+ // Escape's own "clear everything" -- the one place that still removes
2776
+ // every pin/annotation in one shot, now that Clear Points/Clear
2777
+ // Annotations are each scoped to just one kind. Composed from those two
2778
+ // rather than a third independent removal loop: every .plotpress-pin is
2779
+ // either an annotation or not, so the pair together is exhaustive, and
2780
+ // selectedPin ends up null either way (whichever of the two actually
2781
+ // held it clears it -- the other is a no-op against an already-cleared
2782
+ // selectedPin).
2783
+ function clearAllPins() {
2784
+ clearPointPins();
2785
+ clearAnnotationPins();
2786
+ }
2787
+
2788
+ window.addEventListener('keydown', function (e) {
2789
+ if (e.key === 'Escape') {
2790
+ // An open menu eats Escape first -- closing it, the way any dropdown
2791
+ // would, rather than falling through to clearAllPins()/setMode(null)
2792
+ // underneath: a user pressing Escape just to dismiss a menu they
2793
+ // opened to look around must never silently lose every
2794
+ // pin/annotation (or the tool they had selected) as a side effect of
2795
+ // that.
2796
+ if (menuNodes.some(function (m) { return m.classList.contains('open'); })) {
2797
+ closeAllMenus();
2798
+ } else {
2799
+ // Also deselects the active tool (if any), not just clearAllPins()
2800
+ // -- the only way back to "no tool active" for a keyboard-only
2801
+ // user, who has no double-click to deselect with (see
2802
+ // attachModeButton above: a keyboard-triggered click can't be told
2803
+ // apart from a mouse single-click, so it always selects, never
2804
+ // deselects).
2805
+ clearAllPins();
2806
+ setMode(null);
2807
+ }
2808
+ return;
2809
+ }
2810
+ if (!selectedPin) return;
2811
+ var dir = e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowLeft' ? 'left' :
2812
+ e.key === 'ArrowUp' ? 'up' : e.key === 'ArrowDown' ? 'down' : null;
2813
+ if (dir) { e.preventDefault(); stepPin(selectedPin, dir); }
2814
+ });
2815
+
2816
+ // Applied last: replays a Save/Save As from an earlier session (view,
2817
+ // pins, toggles) now that every function/data structure above exists to
2818
+ // do it with -- see buildSaveState()/applySavedState() above.
2819
+ var savedStateEl = document.getElementById('plotpress-saved-state');
2820
+ if (savedStateEl) applySavedState(JSON.parse(savedStateEl.textContent));
2821
+ })();
2822
+ """
2823
+
2824
+
2825
+ def _strip(source: str) -> str:
2826
+ """Drop comment-only lines, blank lines, and leading indentation.
2827
+
2828
+ The whole toolbar is inlined into *every* interactive figure, so these
2829
+ bytes are paid once per figure rather than once per page -- 47 KiB of
2830
+ source became the single largest fixed component of an interactive HTML
2831
+ file.
2832
+
2833
+ Deliberately conservative: newlines are kept, because JavaScript's
2834
+ automatic semicolon insertion makes joining lines unsafe, and a line is
2835
+ only treated as a comment when its *stripped* form begins with ``//``,
2836
+ which cannot occur inside a string here -- the source contains no template
2837
+ literals and no line-continued strings, so no string spans a line break.
2838
+ Identifier renaming is left to a real minifier if it is ever wanted.
2839
+ """
2840
+ out = []
2841
+ for line in source.splitlines():
2842
+ s = line.strip()
2843
+ if not s or s.startswith("//"):
2844
+ continue
2845
+ out.append(s)
2846
+ return "\n".join(out)
2847
+
2848
+
2849
+ INTERACTIVE_JS = _strip(_JS_SOURCE)