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.
- plotpress/__init__.py +108 -0
- plotpress/_interactive.py +2849 -0
- plotpress/_spectral.py +154 -0
- plotpress/_version.py +1 -0
- plotpress/artists.py +1382 -0
- plotpress/axes.py +3221 -0
- plotpress/colors.py +498 -0
- plotpress/figure.py +3084 -0
- plotpress/fonts/__init__.py +51 -0
- plotpress/fonts/families.py +192 -0
- plotpress/fonts/installed.py +82 -0
- plotpress/fonts/metrics.py +265 -0
- plotpress/png.py +93 -0
- plotpress/polar.py +240 -0
- plotpress/primitives.py +335 -0
- plotpress/qt.py +427 -0
- plotpress/raster.py +1316 -0
- plotpress/style.py +91 -0
- plotpress/svg.py +2589 -0
- plotpress/ticker.py +212 -0
- plotpress/transform.py +85 -0
- plotpress/vega.py +1324 -0
- plotpress/vega_lite.py +1199 -0
- plotpress-0.23.2.dist-info/METADATA +378 -0
- plotpress-0.23.2.dist-info/RECORD +28 -0
- plotpress-0.23.2.dist-info/WHEEL +5 -0
- plotpress-0.23.2.dist-info/licenses/LICENSE +21 -0
- plotpress-0.23.2.dist-info/top_level.txt +1 -0
|
@@ -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)
|