plot3 0.4.0__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.
plot3/viewer.py ADDED
@@ -0,0 +1,3354 @@
1
+ """Embedded three.js viewer HTML template."""
2
+
3
+ from __future__ import annotations
4
+
5
+ _DOC_TEMPLATE = """<!doctype html>
6
+ <html><head><meta charset="utf-8">
7
+ <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.165.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.165.0/examples/jsm/"}}</script>
8
+ <style>
9
+ html,body{margin:0;height:100%;overflow:hidden;
10
+ font:12px system-ui,-apple-system,"Segoe UI",sans-serif}
11
+ body{display:flex;flex-direction:column}
12
+ #fig{position:relative;width:100%;flex:1;min-height:0;z-index:1}
13
+ #note{display:none;flex:none;padding:2px 14px 8px;font-size:11px;line-height:1.4;white-space:pre-line}
14
+ #title{position:absolute;left:14px;top:8px;max-width:calc(100% - 120px);
15
+ font-size:14px;font-weight:600;z-index:4}
16
+ #canvas-host{position:absolute;z-index:1}
17
+ #year{display:none;position:absolute;left:50%;top:46%;transform:translate(-50%,-50%);
18
+ z-index:0;font-weight:700;line-height:1;letter-spacing:-0.04em;opacity:0.13;
19
+ pointer-events:none;user-select:none}
20
+ #axes{position:absolute;inset:0;pointer-events:none;z-index:2}
21
+ #grid{position:absolute;inset:0;pointer-events:none;z-index:0}
22
+ #legend{position:absolute;right:10px;top:36px;z-index:4;padding:6px 9px;
23
+ border-radius:6px;font-size:11px;line-height:1.7;max-width:min(46%,280px);
24
+ box-sizing:border-box}
25
+ #legend .lg-formula{display:block}
26
+ #legend .lg-vals{display:flex;flex-wrap:wrap;column-gap:8px}
27
+ #legend .lg-kv{white-space:nowrap}
28
+ #modebar{position:absolute;top:6px;right:6px;z-index:6;
29
+ opacity:0;transition:opacity .12s ease;user-select:none}
30
+ #fig:hover #modebar,#modebar:focus-within,#modebar.open{opacity:1}
31
+ @media (hover:none){#modebar{opacity:1}}
32
+ #save-btn{font:600 12px/1.2 system-ui,-apple-system,"Segoe UI",sans-serif;
33
+ padding:4px 8px;border-radius:4px;cursor:pointer;color:inherit;
34
+ box-shadow:0 1px 2px rgba(0,0,0,.25)}
35
+ #save-menu{position:absolute;right:0;top:calc(100% + 4px);min-width:232px;
36
+ padding:4px;border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,.35)}
37
+ #modebar.open-up #save-menu{top:auto;bottom:calc(100% + 4px)}
38
+ #save-menu[hidden]{display:none}
39
+ #save-menu button{display:flex;align-items:baseline;justify-content:space-between;
40
+ gap:12px;width:100%;text-align:left;padding:6px 8px;border:0;border-radius:4px;
41
+ background:transparent;color:inherit;cursor:pointer;white-space:nowrap;
42
+ font:12px/1.35 system-ui,-apple-system,"Segoe UI",sans-serif}
43
+ #save-menu button[hidden]{display:none}
44
+ #save-menu .save-k{font-weight:650;min-width:4.2em}
45
+ #save-menu .save-d{opacity:.72;font-weight:400}
46
+ #save-menu .save-sep{height:1px;margin:4px 6px;background:currentColor;opacity:.25}
47
+ #save-menu button.save-status{display:block;white-space:normal;font-weight:500;text-align:left}
48
+ #save-btn:hover,#save-menu button:hover,#save-menu button:focus{background:rgba(128,128,128,.18)}
49
+ #save-btn:focus-visible,#save-menu button:focus-visible{outline:2px solid currentColor;outline-offset:-2px}
50
+ #legend .sw{display:inline-block;width:9px;height:9px;border-radius:5px;
51
+ margin-right:6px;vertical-align:-1px}
52
+ #legend .lg-e{cursor:pointer;user-select:none}
53
+ #legend .sz{display:flex;align-items:center;gap:8px;line-height:1.2;margin:3px 0}
54
+ #legend .sz-block{clear:both;padding-top:6px}
55
+ #legend .bub{display:inline-block;border-radius:50%;box-sizing:border-box;flex:none;
56
+ border:1px solid rgba(0,0,0,.35)}
57
+ #player{display:none;flex:none;align-items:center;flex-wrap:wrap;gap:8px;padding:4px 10px 8px;max-width:100%;box-sizing:border-box}
58
+ #player button{font:inherit;padding:3px 10px;border-radius:5px;cursor:pointer}
59
+ #player #play-range{flex:1;min-width:80px}
60
+ #player .plot3-slider{display:flex;align-items:center;gap:6px;flex:1 1 240px;min-width:0;max-width:100%}
61
+ #player .plot3-slider input[type=range]{flex:1;min-width:0}
62
+ #player .plot3-slider-readout{font-variant-numeric:tabular-nums;white-space:nowrap}
63
+ .plot3-math{white-space:nowrap}
64
+ #tip{position:absolute;display:none;z-index:5;pointer-events:none;
65
+ padding:4px 8px;border-radius:5px;font-size:11px;white-space:nowrap;
66
+ line-height:1.45}
67
+ #hint{position:absolute;left:50%;bottom:46px;transform:translateX(-50%);
68
+ z-index:5;pointer-events:none;padding:5px 10px;border-radius:5px;
69
+ font-size:11px;opacity:0;transition:opacity .25s}
70
+ #ramp{height:8px;width:110px;border-radius:4px;margin-top:3px}
71
+ </style></head><body>
72
+ <div id="fig">
73
+ <div id="title"></div>
74
+ <div id="year"></div>
75
+ <svg id="grid"></svg>
76
+ <div id="canvas-host"></div>
77
+ <svg id="axes"></svg>
78
+ <div id="legend" style="display:none"></div>
79
+ <div id="tip"></div>
80
+ <div id="hint"></div>
81
+ <div id="modebar">
82
+ <button type="button" id="save-btn" aria-haspopup="menu" aria-expanded="false" aria-controls="save-menu">⤓ Save</button>
83
+ <div id="save-menu" role="menu" aria-label="Save" hidden>
84
+ <button type="button" role="menuitem" data-act="html"><span class="save-k">HTML</span><span class="save-d">Interactive figure</span></button>
85
+ <button type="button" role="menuitem" data-act="svg"><span class="save-k">SVG</span><span class="save-d">Vector, for papers</span></button>
86
+ <button type="button" role="menuitem" data-act="png"><span class="save-k">PNG</span><span class="save-d">Image (2× sharp)</span></button>
87
+ <div class="save-sep" role="separator"></div>
88
+ <button type="button" role="menuitem" data-act="video" hidden><span class="save-k">Video</span><span class="save-d">WebM (animations)</span></button>
89
+ <button type="button" role="menuitem" data-act="copy">Copy PNG to clipboard</button>
90
+ </div>
91
+ </div>
92
+ </div>
93
+ <div id="player">
94
+ <button type="button" id="play-btn">Play</button>
95
+ <input id="play-range" type="range" min="0" max="1000" value="1000" aria-label="Frame">
96
+ <span id="play-readout"></span>
97
+ <label>Speed <input id="play-speed" type="range" min="0.25" max="4" step="0.25" value="1" aria-label="Speed"></label>
98
+ </div>
99
+ <div id="note"></div>
100
+ __PAYLOADS__
101
+ <script type="module">
102
+ import * as THREE from 'three';
103
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
104
+ import { Line2 } from 'three/addons/lines/Line2.js';
105
+ import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
106
+ import { LineGeometry } from 'three/addons/lines/LineGeometry.js';
107
+ import { LineSegments2 } from 'three/addons/lines/LineSegments2.js';
108
+ import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js';
109
+
110
+ // Captured before the viewer touches the DOM. Saving the live tree would
111
+ // duplicate the canvas and the legend when the file is opened again.
112
+ const PRISTINE = '<!doctype html>' + document.documentElement.outerHTML;
113
+ function readSavedState() {
114
+ const node = document.getElementById('plot3-state');
115
+ if (!node) return null;
116
+ try {
117
+ const data = JSON.parse(node.textContent || '');
118
+ if (!data || typeof data !== 'object') return null;
119
+ return data;
120
+ } catch (err) {
121
+ return null;
122
+ }
123
+ }
124
+ const SAVED = readSavedState();
125
+ const S = __SPEC__;
126
+ const T = S.theme;
127
+ document.body.style.background = T.surface;
128
+ document.body.style.color = T.ink;
129
+ const noteEl = document.getElementById('note');
130
+ if (S.notes && S.notes.length) {
131
+ noteEl.style.display = 'block';
132
+ noteEl.style.color = T.muted;
133
+ noteEl.textContent = S.notes.join('\\n');
134
+ }
135
+
136
+ async function decode(id, dtype) {
137
+ const node = document.getElementById(id);
138
+ if (!node) return null;
139
+ const s = atob(node.textContent.trim());
140
+ let a = new Uint8Array(s.length);
141
+ for (let i = 0; i < s.length; i++) a[i] = s.charCodeAt(i);
142
+ if (S.gz) {
143
+ const ds = new DecompressionStream('gzip');
144
+ a = new Uint8Array(
145
+ await new Response(new Blob([a]).stream().pipeThrough(ds)).arrayBuffer());
146
+ }
147
+ if (dtype === 'f32') return new Float32Array(a.buffer);
148
+ if (dtype === 'u32') return new Uint32Array(a.buffer);
149
+ if (dtype === 'u8') return a;
150
+ let u;
151
+ if (S.gz) { // undo byte planes + delta
152
+ const m = a.length >> 1;
153
+ u = new Uint16Array(m);
154
+ for (let i = 0; i < m; i++) u[i] = a[i] | (a[m + i] << 8);
155
+ for (let i = 1; i < m; i++) u[i] = (u[i] + u[i - 1]) & 0xffff;
156
+ } else {
157
+ u = new Uint16Array(a.buffer);
158
+ }
159
+ return u;
160
+ }
161
+ function toNorm(arr) { // u16 -> [0,1] f32 (f32 passes through)
162
+ if (arr instanceof Float32Array) return arr;
163
+ const f = new Float32Array(arr.length);
164
+ for (let i = 0; i < arr.length; i++) f[i] = arr[i] / 65535;
165
+ return f;
166
+ }
167
+ function hex2rgb(h) {
168
+ return [parseInt(h.slice(1,3),16)/255, parseInt(h.slice(3,5),16)/255,
169
+ parseInt(h.slice(5,7),16)/255];
170
+ }
171
+ const RAMP = (S.color.ramp || []).map(hex2rgb);
172
+ function rampAt(t) {
173
+ const k = Math.min(RAMP.length - 1.001, Math.max(0, t * (RAMP.length - 1)));
174
+ const i = Math.floor(k), f = k - i;
175
+ const a = RAMP[i], b = RAMP[i + 1];
176
+ return [a[0]+(b[0]-a[0])*f, a[1]+(b[1]-a[1])*f, a[2]+(b[2]-a[2])*f];
177
+ }
178
+ const PAL = (S.color.palette || []).map(hex2rgb);
179
+
180
+ // ── payload decode for every layer ──────────────────────────────────────────
181
+ const axesList = S.is3d ? ['x','y','z'] : ['x','y'];
182
+ const extraChans = ['ymin','lower','middle','upper','ymax','ox','oy','opacity'];
183
+ for (const L of S.layers) {
184
+ for (const a of axesList) {
185
+ if (L[a] && L[a].id) L[a].data = toNorm(await decode(L[a].id, L[a].dtype));
186
+ }
187
+ for (const a of extraChans) {
188
+ if (L[a] && L[a].id) L[a].data = toNorm(await decode(L[a].id, L[a].dtype));
189
+ }
190
+ if (L.color) L.color.data = await decode(L.color.id, 'u16');
191
+ if (L.ocolor) L.ocolor.data = await decode(L.ocolor.id, 'u16');
192
+ if (L.indices && L.indices.id) {
193
+ L.indices.data = await decode(L.indices.id, 'u32');
194
+ }
195
+ if (L.size && L.size.id) L.size.data = toNorm(await decode(L.size.id, L.size.dtype));
196
+ if (L.shape && L.shape.id) L.shape.data = await decode(L.shape.id, 'u16');
197
+ if (L.frames) {
198
+ const F = L.frames;
199
+ for (const key of ['x', 'y', 'z', 'size']) {
200
+ if (!F[key] || !F[key].id) continue;
201
+ F[key].data = toNorm(await decode(F[key].id, F[key].dtype));
202
+ if (F[key].mask) F[key].maskData = await decode(F[key].mask, 'u8');
203
+ }
204
+ if (F.color && F.color.id) {
205
+ const raw = await decode(F.color.id, 'u16');
206
+ F.color.data = F.color.kind === 'num' ? toNorm(raw) : raw;
207
+ if (F.color.mask) F.color.maskData = await decode(F.color.mask, 'u8');
208
+ }
209
+ }
210
+ }
211
+
212
+ // per-layer vertex colors (normalized cube space is built per-branch)
213
+ function layerColors(L, defRGB) {
214
+ const n = L.n, c = new Float32Array(n * 3);
215
+ if (L.constColor) defRGB = hex2rgb(L.constColor);
216
+ if (!L.color) { for (let i=0;i<n;i++){c[i*3]=defRGB[0];c[i*3+1]=defRGB[1];c[i*3+2]=defRGB[2];} return c; }
217
+ const d = L.color.data;
218
+ if (L.color.kind === 'cat') {
219
+ for (let i = 0; i < n; i++) { const p = PAL[d[i] % PAL.length];
220
+ c[i*3]=p[0]; c[i*3+1]=p[1]; c[i*3+2]=p[2]; }
221
+ } else {
222
+ for (let i = 0; i < n; i++) { const p = rampAt(d[i] / 65535);
223
+ c[i*3]=p[0]; c[i*3+1]=p[1]; c[i*3+2]=p[2]; }
224
+ }
225
+ return c;
226
+ }
227
+
228
+ // ── chrome: title + legend ──────────────────────────────────────────────────
229
+ function plot3Esc(s) {
230
+ return String(s == null ? '' : s).replace(/[&<>"']/g, ch => ({
231
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
232
+ })[ch]);
233
+ }
234
+ function plot3MathHTML(segs, plain) {
235
+ if (!segs || !segs.length) return plot3Esc(plain);
236
+ return segs.map(p => p.latex
237
+ ? '<span class="plot3-math" data-latex="' + plot3Esc(p.latex) + '">' + plot3Esc(p.text) + '</span>'
238
+ : plot3Esc(p.text)).join('');
239
+ }
240
+ function plot3Typeset(root) {
241
+ const katex = window.katex;
242
+ if (!katex) return;
243
+ (root || document).querySelectorAll('.plot3-math').forEach(el => {
244
+ if (el.dataset.katexDone) return;
245
+ const latex = el.getAttribute('data-latex');
246
+ if (!latex) return;
247
+ katex.render(latex, el, {throwOnError: false, trust: false, displayMode: false});
248
+ el.dataset.katexDone = '1';
249
+ });
250
+ }
251
+ function formulaHead(L) {
252
+ if (!L || !L.tip) return '';
253
+ if (!L.tip.latex) return plot3Esc(L.tip.pretty) + '<br>';
254
+ return '<span class="plot3-math" data-latex="' + plot3Esc(L.tip.latex) + '">'
255
+ + plot3Esc(L.tip.pretty) + '</span><br>';
256
+ }
257
+ function axisPair(ax, text) {
258
+ const name = (S.labs && S.labs[ax]) || ax;
259
+ return plot3Esc(name) + ' = ' + plot3Esc(String(text).replace(/-/g, '\u2212'));
260
+ }
261
+ function sizeLegendHTML() {
262
+ const sl = S.sizeLegend;
263
+ if (!sl || !sl.breaks || !sl.breaks.length) return '';
264
+ const rows = sl.breaks.map(b => {
265
+ const d = Math.max(6, Math.round((b.t || 0) * 26));
266
+ return '<div class="sz"><span class="bub" style="width:' + d + 'px;height:' + d
267
+ + 'px;background:' + T.ink2 + (b.alpha != null ? ';opacity:' + b.alpha : '') +
268
+ '"></span><span>' + plot3Esc(b.label) + '</span></div>';
269
+ }).join('');
270
+ const title = sl.label
271
+ ? '<b style="color:' + T.ink + '">' + plot3Esc(sl.label) + '</b>' : '';
272
+ return '<div class="sz-block">' + title + rows + '</div>';
273
+ }
274
+ function legendLabel(e) {
275
+ if (e.math) return plot3MathHTML(e.math, e.label);
276
+ const plain = String(e.label || '');
277
+ const cut = plain.indexOf(' (');
278
+ // A parameter tail stays in whole "a = 2" pieces so a narrow key cannot
279
+ // split 0.03333. The formula above it wraps on spaces.
280
+ if (cut >= 0) {
281
+ const formula = plain.slice(0, cut);
282
+ let body = plain.slice(cut + 3);
283
+ if (body.endsWith(')')) body = body.slice(0, -1);
284
+ const vals = body.split(', ').filter(Boolean).map(part =>
285
+ '<span class="lg-kv">' + plot3Esc(part) + '</span>').join('');
286
+ return '<span class="lg-formula">' + plot3Esc(formula) + '</span>'
287
+ + '<span class="lg-vals">' + vals + '</span>';
288
+ }
289
+ if (e.latex) return '<span class="plot3-math" data-latex="' + plot3Esc(e.latex) + '">'
290
+ + plot3Esc(plain) + '</span>';
291
+ return plot3Esc(plain);
292
+ }
293
+ function richLabel(key, plain) {
294
+ if (S.labsMath && S.labsMath[key]) return plot3MathHTML(S.labsMath[key], plain || '');
295
+ return plot3Esc(plain || '');
296
+ }
297
+ const figEl = document.getElementById('fig');
298
+ const titleEl = document.getElementById('title');
299
+ if (S.labs.title) {
300
+ if (S.labsMath && S.labsMath.title) titleEl.innerHTML = plot3MathHTML(S.labsMath.title, S.labs.title);
301
+ else titleEl.textContent = S.labs.title;
302
+ }
303
+ const THEME_OPTS = S.themeOpts || {};
304
+ // labs(tag=, subtitle=, caption=) and theme(plot_title_hjust=).
305
+ if (S.labs.tag) {
306
+ const tagEl = document.createElement('b');
307
+ tagEl.style.marginRight = '8px';
308
+ tagEl.textContent = S.labs.tag;
309
+ titleEl.insertBefore(tagEl, titleEl.firstChild);
310
+ }
311
+ if (S.labs.subtitle) {
312
+ const subEl = document.createElement('div');
313
+ subEl.style.cssText = 'font-weight:400;font-size:12px;margin-top:2px;color:' + T.ink2;
314
+ subEl.innerHTML = richLabel('subtitle', S.labs.subtitle);
315
+ titleEl.appendChild(subEl);
316
+ }
317
+ if (THEME_OPTS.titleHjust != null && THEME_OPTS.titleHjust > 0.25) {
318
+ titleEl.style.left = '14px';
319
+ titleEl.style.right = '14px';
320
+ titleEl.style.maxWidth = 'none';
321
+ titleEl.style.textAlign = THEME_OPTS.titleHjust > 0.75 ? 'right' : 'center';
322
+ }
323
+ if (S.labs.caption) {
324
+ const capEl = document.createElement('div');
325
+ capEl.style.cssText = 'flex:none;padding:0 14px 6px;text-align:right;font-size:11px;color:' + T.muted;
326
+ capEl.innerHTML = richLabel('caption', S.labs.caption);
327
+ figEl.insertAdjacentElement('afterend', capEl);
328
+ }
329
+ const legEl = document.getElementById('legend');
330
+ // legend click-filtering: category index -> three.js objects
331
+ const hiddenCats = new Set();
332
+ const catObjs = new Map();
333
+ function regCat(ci, obj) {
334
+ if (!catObjs.has(ci)) catObjs.set(ci, []);
335
+ catObjs.get(ci).push(obj);
336
+ }
337
+ let redraw = () => {}; // 2D assigns its draw(); 3D renders continuously
338
+ window.__plot3 = { hiddenCats, catObjs };
339
+ function showLegendBox() {
340
+ // theme(legend_position="none"), and all but one panel of a facet_grid.
341
+ if (S.legendPosition === 'none') return;
342
+ legEl.style.display = 'block';
343
+ legEl.style.background = T.surface + 'e6';
344
+ legEl.style.border = '1px solid ' + T.grid;
345
+ legEl.style.color = T.ink2;
346
+ }
347
+ function placeLegend() {
348
+ if (!legEl || legEl.style.display === 'none' || legEl.dataset.docked === '1') return;
349
+ const figH = figEl.clientHeight || 0;
350
+ if (figH < 40) return;
351
+ // A key taller than the panel covers the curve. Sit it under the axes.
352
+ if (legEl.offsetHeight > figH * 0.40) {
353
+ legEl.dataset.docked = '1';
354
+ legEl.style.top = 'auto';
355
+ legEl.style.bottom = '8px';
356
+ legEl.style.left = '8px';
357
+ legEl.style.right = '8px';
358
+ legEl.style.maxWidth = 'none';
359
+ }
360
+ }
361
+ const LEGEND_GLYPH = { circle: '●', triangle: '▲', square: '■', diamond: '◆', plus: '+', cross: '×' };
362
+ // Legend keys: a square, the point's symbol, or a short (dashed) line.
363
+ function keyHTML(e, color) {
364
+ if (e.shape) {
365
+ return '<span class="sw" style="background:none;border-radius:0;width:auto;color:' + color +
366
+ ';font-size:11px;line-height:9px">' + (LEGEND_GLYPH[e.shape] || '●') + '</span>';
367
+ }
368
+ if (e.dash !== undefined && e.dash !== null) {
369
+ const da = (e.dash && e.dash.length) ? ' stroke-dasharray="' + e.dash.map(d => d * 1.6).join(' ') + '"' : '';
370
+ return '<svg width="14" height="9" style="margin-right:4px;vertical-align:middle"><line x1="0" y1="4.5" x2="14" y2="4.5" stroke="' +
371
+ color + '" stroke-width="1.6"' + da + '/></svg>';
372
+ }
373
+ return '<span class="sw" style="background:' + color + '"></span>';
374
+ }
375
+ // aes(shape=) / aes(linetype=) on a column other than colour: own block.
376
+ function keyLegendsHTML() {
377
+ let h = '';
378
+ for (const lg of [S.shapeLegend, S.linetypeLegend]) {
379
+ if (!lg) continue;
380
+ h += '<div style="margin-top:4px"><b style="color:' + T.ink + '">' + plot3Esc(lg.label) + '</b></div>' +
381
+ lg.entries.map(e => '<div>' + keyHTML(e, T.ink) + plot3Esc(e.label) + '</div>').join('');
382
+ }
383
+ return h;
384
+ }
385
+ // aes(alpha=): a dot at each break's opacity.
386
+ function alphaLegendHTML() {
387
+ const al = S.alphaLegend;
388
+ if (!al || !al.breaks || !al.breaks.length) return '';
389
+ const rows = al.breaks.map(b => '<div class="sz"><span class="bub" style="width:10px;height:10px;background:' +
390
+ T.ink + ';opacity:' + b.alpha + '"></span><span>' + plot3Esc(b.label) + '</span></div>').join('');
391
+ return '<div class="sz-block"><b style="color:' + T.ink + '">' + plot3Esc(al.label) + '</b>' + rows + '</div>';
392
+ }
393
+ const szHTML = sizeLegendHTML() + alphaLegendHTML() + keyLegendsHTML();
394
+ // Class entries and a height colour bar together (geom_box3d on a cloud).
395
+ function barHTML() {
396
+ if (S.labs.colorBar == null || !S.color || S.color.kind !== 'num' || S.color.guide === false) return '';
397
+ return '<div class="sz-block"><b style="color:' + T.ink + '">' + plot3Esc(S.labs.colorBar) +
398
+ '</b><div id="ramp" style="background:linear-gradient(90deg,' + S.color.ramp.join(',') +
399
+ ')"></div><span style="float:left">' + (+S.color.lo.toPrecision(3)) +
400
+ '</span><span style="float:right">' + (+S.color.hi.toPrecision(3)) + '</span></div>';
401
+ }
402
+ if (S.legend) {
403
+ showLegendBox();
404
+ legEl.innerHTML = (S.labs.color ? '<b style="color:'+T.ink+'">' +
405
+ richLabel('color', S.labs.color) + '</b>' : '') +
406
+ S.legend.map((e, i) => '<div class="lg-e" data-ci="' + (e.ci != null ? e.ci : i) +
407
+ '">' + keyHTML(e, e.color) +
408
+ legendLabel(e) + '</div>').join('') + barHTML() + szHTML;
409
+ legEl.addEventListener('click', ev => {
410
+ const row = ev.target.closest('.lg-e');
411
+ if (!row) return;
412
+ const ci = +row.dataset.ci;
413
+ if (hiddenCats.has(ci)) hiddenCats.delete(ci); else hiddenCats.add(ci);
414
+ row.style.opacity = hiddenCats.has(ci) ? 0.35 : 1;
415
+ for (const o of (catObjs.get(ci) || [])) o.visible = !hiddenCats.has(ci);
416
+ if (window.__plot3.afterLegend) window.__plot3.afterLegend();
417
+ tip.style.display = 'none';
418
+ redraw();
419
+ });
420
+ } else if (S.color.kind === 'num' && S.color.guide !== false) {
421
+ showLegendBox();
422
+ legEl.innerHTML = '<b style="color:'+T.ink+'">' + richLabel('color', S.labs.color||'') +
423
+ '</b><div id="ramp" style="background:linear-gradient(90deg,' +
424
+ S.color.ramp.join(',') + ')"></div>' +
425
+ '<span style="float:left">' + (+S.color.lo.toPrecision(3)) + '</span>' +
426
+ '<span style="float:right">' + (+S.color.hi.toPrecision(3)) + '</span>' + szHTML;
427
+ } else if (szHTML) {
428
+ showLegendBox();
429
+ legEl.innerHTML = szHTML;
430
+ }
431
+ __KATEX__
432
+
433
+ function fmt(v) {
434
+ if (v === 0) return '0';
435
+ const a = Math.abs(v);
436
+ if (a >= 1e6 || a < 1e-4) return v.toPrecision(3);
437
+ return String(+v.toFixed(6));
438
+ }
439
+ // Tick labels that stay apart: 1000000000001, not five "1.00e+12".
440
+ function fmtTicks(vals) {
441
+ let labels = vals.map(fmt);
442
+ if (new Set(labels).size === labels.length) return labels;
443
+ if (vals.every(v => Number.isInteger(v) && Math.abs(v) < 1e15)) return vals.map(v => String(v));
444
+ for (let d = 4; d <= 15; d++) {
445
+ labels = vals.map(v => String(+v.toPrecision(d)));
446
+ if (new Set(labels).size === labels.length) return labels;
447
+ }
448
+ return labels;
449
+ }
450
+ // numeric colour: normalized ramp position -> data value (inverse transform)
451
+ function cval(t) {
452
+ const tr = S.color.trans || 'linear';
453
+ const f = tr === 'sqrt' ? Math.sqrt
454
+ : tr === 'log10' ? (v => Math.log10(Math.max(v, 1e-12))) : (v => v);
455
+ const inv = tr === 'sqrt' ? (v => v * v)
456
+ : tr === 'log10' ? (v => Math.pow(10, v)) : (v => v);
457
+ return inv(f(S.color.lo) + t * (f(S.color.hi) - f(S.color.lo)));
458
+ }
459
+ function fmtAxis(ax, v) { // v in data units
460
+ const sc = S.scales[ax];
461
+ if (sc.kind === 'cat') {
462
+ const i = Math.round(v);
463
+ return (i >= 0 && i < sc.cats.length && Math.abs(v - i) < 0.26) ? sc.cats[i] : '';
464
+ }
465
+ if (sc.kind === 'dt') return new Date(v * 1000).toLocaleString();
466
+ return fmt(v);
467
+ }
468
+
469
+ // nice numeric ticks (JS side for pan/zoom), the same rule as scales.nice_ticks.
470
+ function niceTicks(lo, hi, n) {
471
+ // About n ticks on a 1-2-2.5-5 grid inside the range, as ggplot2 (and
472
+ // plot3's Python side) choose them: 0, 2, 4, 6, 8 for 0 to 9.2.
473
+ if (!(hi > lo) || !isFinite(lo) || !isFinite(hi)) return [lo];
474
+ const span = hi - lo, target = Math.max(3, n || 5), pad = 0.05 * span;
475
+ const exp = Math.floor(Math.log10(Math.max(span / Math.max(1, target - 1), 1e-300)));
476
+ let best = null, bestScore = Infinity, bestStep = -1;
477
+ for (let shift = -1; shift <= 1; shift++) {
478
+ const base = Math.pow(10, exp + shift);
479
+ for (const mult of [1, 2, 2.5, 5]) {
480
+ const step = mult * base;
481
+ const first = Math.ceil((lo - pad) / step - 1e-9);
482
+ const last = Math.floor((hi + pad) / step + 1e-9);
483
+ const count = last - first + 1;
484
+ if (count < 2 || count > 40) continue;
485
+ let score = Math.abs(count - target);
486
+ if (count < 4) score += 3;
487
+ if (count > target + 2) score += 2 * (count - target - 2);
488
+ score += 2 * (Math.max(0, first * step - lo) + Math.max(0, hi - last * step)) / span;
489
+ if (mult === 2.5) score += 0.25;
490
+ if (score < bestScore - 1e-9 || (Math.abs(score - bestScore) <= 1e-9 && step > bestStep)) {
491
+ const digits = Math.max(0, 3 - Math.floor(Math.log10(step)));
492
+ best = [];
493
+ for (let k = first; k <= last; k++) {
494
+ const tick = k * step;
495
+ best.push(Math.abs(tick) < step * 1e-8 ? 0 : +tick.toFixed(Math.min(digits, 20)));
496
+ }
497
+ bestScore = score; bestStep = step;
498
+ }
499
+ }
500
+ }
501
+ return best || [lo, hi];
502
+ }
503
+ // ticks for any scale over a visible data range -> [[pos_data, label], ...]
504
+ function thin(vis, maxN) {
505
+ // Every k-th tick, evenly spaced, on round values (0, 10, 20) when the
506
+ // ticks allow: never a lone last tick at an odd distance.
507
+ if (vis.length <= maxN) return vis;
508
+ const numeric = vis.length > 1 && typeof vis[0][0] === 'number' && typeof vis[1][0] === 'number';
509
+ for (let k = 2; k <= vis.length; k++) {
510
+ if (Math.ceil(vis.length / k) > Math.max(2, maxN)) continue;
511
+ let offset = 0;
512
+ if (numeric) {
513
+ const step = Math.abs(vis[1][0] - vis[0][0]) * k;
514
+ for (let o = 0; o < k; o++) {
515
+ const r = vis[o][0] / step;
516
+ if (step > 0 && Math.abs(r - Math.round(r)) < 1e-6) { offset = o; break; }
517
+ }
518
+ }
519
+ return vis.filter((_, i) => i >= offset && (i - offset) % k === 0);
520
+ }
521
+ return vis.slice(0, 1);
522
+ }
523
+ function ticksFor(ax, lo, hi) {
524
+ const sc = S.scales[ax];
525
+ if (lo > hi) { const t = lo; lo = hi; hi = t; } // scale_*_reverse()
526
+ // scale_*_continuous(breaks=, labels=): keep the chosen ticks when zooming.
527
+ if (sc.fixed && sc.ticks) return sc.ticks.filter(t => t[0] >= lo && t[0] <= hi);
528
+ if (sc.kind === 'cat')
529
+ return thin(sc.cats.map((c, i) => [i, c])
530
+ .filter(t => t[0] >= lo && t[0] <= hi), 12);
531
+ if (sc.kind === 'dt') {
532
+ for (const level of sc.ladder) {
533
+ const vis = level.filter(t => t[0] >= lo && t[0] <= hi);
534
+ if (vis.length >= 3 && vis.length <= 14) return vis;
535
+ }
536
+ let vis = sc.ladder[0].filter(t => t[0] >= lo && t[0] <= hi);
537
+ if (vis.length < 3)
538
+ vis = sc.ladder[sc.ladder.length - 1]
539
+ .filter(t => t[0] >= lo && t[0] <= hi);
540
+ return thin(vis, 10);
541
+ }
542
+ if (sc.trans === 'log10') return logTicks(lo, hi);
543
+ const vals = niceTicks(lo, hi, 5);
544
+ return vals.map((v, i) => [v, fmtTicks(vals)[i]]);
545
+ }
546
+ function logTicks(lo, hi) {
547
+ if (!(hi > lo)) return [[lo, fmt(Math.pow(10, lo))]];
548
+ const span = hi - lo;
549
+ const mults = span <= 3 ? [1, 2, 5] : [1];
550
+ const out = [];
551
+ const e0 = Math.floor(lo), e1 = Math.ceil(hi);
552
+ for (let e = e0; e <= e1; e++) {
553
+ for (const m of mults) {
554
+ const val = m * Math.pow(10, e);
555
+ const lv = Math.log10(val);
556
+ if (lv < lo - 1e-9 || lv > hi + 1e-9) continue;
557
+ out.push([lv, fmt(val)]);
558
+ }
559
+ }
560
+ if (!out.length) out.push([lo, fmt(Math.pow(10, lo))]);
561
+ return out;
562
+ }
563
+ // hover value: enough digits to resolve ~1/300 of the visible span
564
+ function fmtSpan(ax, v, spanData) {
565
+ const sc = S.scales[ax];
566
+ if (sc.kind !== 'num') return fmtAxis(ax, v);
567
+ if (sc.trans === 'log10') return fmt(v);
568
+ const d = Math.max(0, Math.min(6,
569
+ Math.ceil(-Math.log10(Math.max(1e-12, spanData / 300)))));
570
+ return v.toFixed(d);
571
+ }
572
+ const dataLo = ax => S.scales[ax].lo, dataHi = ax => S.scales[ax].hi;
573
+ const spanOf = ax => (dataHi(ax) - dataLo(ax)) || 1;
574
+ function fromScale(ax, norm) {
575
+ const sc = S.scales[ax];
576
+ const v = sc.lo + norm * ((sc.hi - sc.lo) || 1);
577
+ if (sc.trans === 'log10') return Math.pow(10, v);
578
+ return v;
579
+ }
580
+
581
+ const host = document.getElementById('canvas-host');
582
+ const svg = document.getElementById('axes');
583
+ // The 2D grid sits behind the data, as in ggplot2; axes and labels above it.
584
+ const gridSvg = document.getElementById('grid');
585
+ const tip = document.getElementById('tip');
586
+ tip.style.background = T.surface;
587
+ tip.style.border = '1px solid ' + T.axis;
588
+ tip.style.color = T.ink;
589
+
590
+ // preserveDrawingBuffer keeps the frame readable for the Save menu.
591
+ // The spec's colours are exact sRGB values. Without this, three.js reads
592
+ // vertex colours as linear and brightens them on output (a washed viridis).
593
+ THREE.ColorManagement.enabled = false;
594
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
595
+ renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
596
+ renderer.setPixelRatio(window.devicePixelRatio);
597
+ renderer.setClearColor(0x000000, 0);
598
+ host.appendChild(renderer.domElement);
599
+ const scene = new THREE.Scene();
600
+ const lineMats = [];
601
+
602
+ // Point symbols (aes(shape=) / geom_point(shape=)) as white sprites tinted
603
+ // by the point colour.
604
+ const shapeMaps = {};
605
+ function shapeTexture(name) {
606
+ if (shapeMaps[name]) return shapeMaps[name];
607
+ const c = document.createElement('canvas');
608
+ c.width = 64; c.height = 64;
609
+ const g = c.getContext('2d');
610
+ g.fillStyle = '#ffffff'; g.strokeStyle = '#ffffff';
611
+ g.beginPath();
612
+ if (name === 'triangle') { g.moveTo(32, 6); g.lineTo(60, 54); g.lineTo(4, 54); g.closePath(); g.fill(); }
613
+ else if (name === 'square') { g.fillRect(9, 9, 46, 46); }
614
+ else if (name === 'diamond') { g.moveTo(32, 2); g.lineTo(62, 32); g.lineTo(32, 62); g.lineTo(2, 32); g.closePath(); g.fill(); }
615
+ else if (name === 'plus') { g.lineWidth = 12; g.moveTo(32, 4); g.lineTo(32, 60); g.moveTo(4, 32); g.lineTo(60, 32); g.stroke(); }
616
+ else if (name === 'cross') { g.lineWidth = 12; g.moveTo(10, 10); g.lineTo(54, 54); g.moveTo(54, 10); g.lineTo(10, 54); g.stroke(); }
617
+ else { g.arc(32, 32, 28, 0, Math.PI * 2); g.fill(); }
618
+ const t = new THREE.CanvasTexture(c);
619
+ t.needsUpdate = true;
620
+ shapeMaps[name] = t;
621
+ return t;
622
+ }
623
+ function shapeOf(L, i) {
624
+ if (!L.shape) return null;
625
+ if (typeof L.shape === 'string') return L.shape;
626
+ const names = L.shape.names || ['circle'];
627
+ return names[L.shape.data[i] % names.length];
628
+ }
629
+
630
+ // Bubbles: per-point size (area) and, when a transition is set, a frame tween.
631
+ let circleMap = null;
632
+ function circleTexture() {
633
+ if (circleMap) return circleMap;
634
+ const c = document.createElement('canvas');
635
+ c.width = 64; c.height = 64;
636
+ const g = c.getContext('2d');
637
+ g.clearRect(0, 0, 64, 64);
638
+ g.beginPath();
639
+ g.arc(32, 32, 28, 0, Math.PI * 2);
640
+ g.fillStyle = '#ffffff';
641
+ g.fill();
642
+ g.lineWidth = 4;
643
+ g.strokeStyle = 'rgba(0,0,0,0.7)';
644
+ g.stroke();
645
+ circleMap = new THREE.CanvasTexture(c);
646
+ circleMap.needsUpdate = true;
647
+ return circleMap;
648
+ }
649
+ function bubbleMaterial(opacity, sizeAtten) {
650
+ // size 1: three.js sets the size uniform to the pixel ratio, so aSize is
651
+ // in CSS pixels on any screen, as the saved files measure it.
652
+ const m = new THREE.PointsMaterial({
653
+ size: 1,
654
+ map: circleTexture(),
655
+ vertexColors: true,
656
+ transparent: true,
657
+ opacity: opacity == null ? 0.9 : opacity,
658
+ depthWrite: false,
659
+ depthTest: !!sizeAtten,
660
+ sizeAttenuation: !!sizeAtten,
661
+ alphaTest: 0.01,
662
+ });
663
+ // Cache key so this patched program is not reused for plain points.
664
+ m.customProgramCacheKey = () => 'plot3-bubble';
665
+ m.onBeforeCompile = (shader) => {
666
+ shader.vertexShader = shader.vertexShader
667
+ .replace(
668
+ 'uniform float size;',
669
+ 'uniform float size;\\nattribute float aSize;\\nattribute float aAlpha;\\nvarying float vAlpha;'
670
+ )
671
+ .replace(
672
+ '#include <color_vertex>',
673
+ '#include <color_vertex>\\nvAlpha = aAlpha;'
674
+ )
675
+ .replace('gl_PointSize = size;', 'gl_PointSize = max(aSize, 0.0) * size;');
676
+ shader.fragmentShader = shader.fragmentShader
677
+ .replace(
678
+ 'uniform float opacity;',
679
+ 'uniform float opacity;\\nvarying float vAlpha;'
680
+ )
681
+ .replace(
682
+ 'vec4 diffuseColor = vec4( diffuse, opacity );',
683
+ 'vec4 diffuseColor = vec4( diffuse, opacity * vAlpha );'
684
+ );
685
+ };
686
+ return m;
687
+ }
688
+ const bubbleEntries = [];
689
+ const lineEntries = [];
690
+ const surfaceEntries = [];
691
+ const stepEntries = [];
692
+ let hoverHold = false;
693
+ let trail = null;
694
+ let trailId = null;
695
+ function framePresent(F, i) {
696
+ if (!F.x || !F.x.maskData || !F.x.maskData[i]) return false;
697
+ if (!F.y || !F.y.maskData || !F.y.maskData[i]) return false;
698
+ if (F.z && F.z.maskData && !F.z.maskData[i]) return false;
699
+ if (F.size && F.size.maskData && !F.size.maskData[i]) return false;
700
+ return true;
701
+ }
702
+ function applyBubbleHide(entry) {
703
+ const L = entry.L;
704
+ const nCats = (S.color && S.color.cats) ? S.color.cats.length : 0;
705
+ for (let i = 0; i < L.n; i++) {
706
+ let a = entry.baseAlpha[i];
707
+ if (L._ci && nCats && hiddenCats.has(L._ci[i] % nCats)) a = 0;
708
+ entry.aAlpha[i] = a;
709
+ }
710
+ entry.geom.attributes.aAlpha.needsUpdate = true;
711
+ }
712
+ function addBubbleLayer(L, ext, sizeAtten) {
713
+ const n = L.n;
714
+ const cols = layerColors(L, hex2rgb(T.cat[0]));
715
+ const pos = new Float32Array(n * 3);
716
+ const aSize = new Float32Array(n);
717
+ const aAlpha = new Float32Array(n);
718
+ const baseAlpha = new Float32Array(n);
719
+ L._x = new Float32Array(n);
720
+ L._y = new Float32Array(n);
721
+ L._z = L.z ? new Float32Array(n) : null;
722
+ L._size = aSize;
723
+ L._alpha = aAlpha;
724
+ L._ci = (L.color && L.color.kind === 'cat' && L.color.data)
725
+ ? new Uint16Array(L.color.data) : null;
726
+ L._cnorm = (L.color && L.color.kind === 'num' && L.color.data)
727
+ ? Float32Array.from(L.color.data, v => v / 65535) : null;
728
+ for (let i = 0; i < n; i++) {
729
+ const x = L.x.data[i], y = L.y.data[i];
730
+ const z = L.z ? L.z.data[i] : 0;
731
+ L._x[i] = x; L._y[i] = y;
732
+ if (L._z) L._z[i] = z;
733
+ pos[i * 3] = x * ext[0];
734
+ pos[i * 3 + 1] = y * ext[1];
735
+ pos[i * 3 + 2] = z * ext[2];
736
+ let sz = (typeof L.size === 'number') ? L.size : 6;
737
+ if (L.size && L.size.data) sz = L.size.data[i] * L.size.max;
738
+ // The circle fills 28/32 of its sprite: widen it so the dot's diameter
739
+ // is the size, as in saved files.
740
+ if (!sizeAtten) sz /= 0.875;
741
+ aSize[i] = sz;
742
+ // aes(alpha=): each point's own opacity.
743
+ const op = (L.opacity && L.opacity.data) ? L.opacity.data[i] : 1;
744
+ baseAlpha[i] = op;
745
+ aAlpha[i] = op;
746
+ }
747
+ const g = new THREE.BufferGeometry();
748
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
749
+ g.setAttribute('color', new THREE.BufferAttribute(cols, 3));
750
+ g.setAttribute('aSize', new THREE.BufferAttribute(aSize, 1));
751
+ g.setAttribute('aAlpha', new THREE.BufferAttribute(aAlpha, 1));
752
+ const pt = new THREE.Points(g, bubbleMaterial((L.opacity && L.opacity.data) ? 1 : L.alpha, sizeAtten));
753
+ scene.add(pt);
754
+ const entry = { L, ext, pos, cols, aSize, aAlpha, baseAlpha, geom: g };
755
+ bubbleEntries.push(entry);
756
+ applyBubbleHide(entry);
757
+ return entry;
758
+ }
759
+ // playT is 0 at the first keyframe and nFrames-1 at the last. For a time
760
+ // column that position is linear in the column value, not in the keyframe index.
761
+ function frameSample(t) {
762
+ const tr = S.transition;
763
+ const nF = tr.nFrames;
764
+ const times = tr.times || [];
765
+ if (tr.type === 'time' && times.length === nF && nF > 1) {
766
+ const t0 = +times[0];
767
+ const t1 = +times[nF - 1];
768
+ const span = t1 - t0;
769
+ const T = span === 0 ? t0 : t0 + (t / (nF - 1)) * span;
770
+ let f0 = 0;
771
+ for (let i = 0; i < nF - 1; i++) {
772
+ if (+times[i + 1] <= T + 1e-9) f0 = i + 1;
773
+ else break;
774
+ }
775
+ if (f0 >= nF - 1) return { f0: nF - 1, f1: nF - 1, u: 0, T: t1 };
776
+ const a = +times[f0], b = +times[f0 + 1];
777
+ const u = b === a ? 0 : Math.max(0, Math.min(1, (T - a) / (b - a)));
778
+ return { f0: f0, f1: f0 + 1, u: u, T: T };
779
+ }
780
+ let f0 = Math.floor(t);
781
+ if (f0 < 0) f0 = 0;
782
+ if (f0 >= nF) f0 = nF - 1;
783
+ const f1 = Math.min(nF - 1, f0 + 1);
784
+ const u = f0 === f1 ? 0 : (t - f0);
785
+ return { f0: f0, f1: f1, u: u, T: null };
786
+ }
787
+ function writeBubbleFrame(entry, t) {
788
+ const L = entry.L;
789
+ const F = L.frames;
790
+ if (!F || !S.transition) return;
791
+ const nF = F.nFrames;
792
+ const n = L.n;
793
+ const sample = frameSample(t);
794
+ const f0 = sample.f0;
795
+ const f1 = sample.f1;
796
+ let u = sample.u;
797
+ if (S.transition.ease === 'smooth') u = u * u * (3 - 2 * u);
798
+ const ext = entry.ext;
799
+ const pos = entry.pos;
800
+ const col = entry.cols;
801
+ let colorDirty = false;
802
+ for (let obj = 0; obj < n; obj++) {
803
+ const i0 = obj * nF + f0;
804
+ const i1 = obj * nF + f1;
805
+ const p0 = framePresent(F, i0);
806
+ const p1 = framePresent(F, i1);
807
+ let x = 0, y = 0, z = 0, alpha = 0;
808
+ let sz = (typeof L.size === 'number') ? L.size : 6;
809
+ if (p0 && p1) {
810
+ x = F.x.data[i0] * (1 - u) + F.x.data[i1] * u;
811
+ y = F.y.data[i0] * (1 - u) + F.y.data[i1] * u;
812
+ if (F.z) z = F.z.data[i0] * (1 - u) + F.z.data[i1] * u;
813
+ alpha = 1;
814
+ if (F.size) sz = (F.size.data[i0] * (1 - u) + F.size.data[i1] * u) * F.size.max;
815
+ } else if (p0 || p1) {
816
+ const i = p0 ? i0 : i1;
817
+ x = F.x.data[i];
818
+ y = F.y.data[i];
819
+ if (F.z) z = F.z.data[i];
820
+ alpha = p0 ? (1 - u) : u;
821
+ if (F.size) sz = F.size.data[i] * F.size.max;
822
+ }
823
+ L._x[obj] = x; L._y[obj] = y;
824
+ if (L._z) L._z[obj] = z;
825
+ pos[obj * 3] = x * ext[0];
826
+ pos[obj * 3 + 1] = y * ext[1];
827
+ pos[obj * 3 + 2] = z * ext[2];
828
+ entry.aSize[obj] = alpha > 0 ? sz : 0;
829
+ entry.baseAlpha[obj] = alpha;
830
+ if (F.color && F.color.kind === 'cat' && F.color.data) {
831
+ const miss = 65535;
832
+ const c0 = F.color.data[i0], c1 = F.color.data[i1];
833
+ let code = miss;
834
+ if (c0 !== miss && c1 !== miss) code = u < 0.5 ? c0 : c1;
835
+ else if (c0 !== miss) code = c0;
836
+ else if (c1 !== miss) code = c1;
837
+ if (code === miss) entry.baseAlpha[obj] = 0;
838
+ else if (L._ci) L._ci[obj] = code;
839
+ if (code !== miss && PAL.length) {
840
+ const p = PAL[code % PAL.length];
841
+ col[obj * 3] = p[0]; col[obj * 3 + 1] = p[1]; col[obj * 3 + 2] = p[2];
842
+ colorDirty = true;
843
+ }
844
+ } else if (F.color && F.color.kind === 'num' && F.color.data) {
845
+ const m0 = !F.color.maskData || F.color.maskData[i0];
846
+ const m1 = !F.color.maskData || F.color.maskData[i1];
847
+ let tcol = null;
848
+ if (m0 && m1) tcol = F.color.data[i0] * (1 - u) + F.color.data[i1] * u;
849
+ else if (m0) tcol = F.color.data[i0];
850
+ else if (m1) tcol = F.color.data[i1];
851
+ if (tcol != null && L._cnorm) L._cnorm[obj] = tcol;
852
+ if (tcol != null && RAMP.length) {
853
+ const p = rampAt(tcol);
854
+ col[obj * 3] = p[0]; col[obj * 3 + 1] = p[1]; col[obj * 3 + 2] = p[2];
855
+ colorDirty = true;
856
+ }
857
+ }
858
+ }
859
+ entry.geom.attributes.position.needsUpdate = true;
860
+ entry.geom.attributes.aSize.needsUpdate = true;
861
+ if (colorDirty) entry.geom.attributes.color.needsUpdate = true;
862
+ applyBubbleHide(entry);
863
+ }
864
+ function frameBlend(t) {
865
+ const sample = frameSample(t);
866
+ let u = sample.u;
867
+ if (S.transition && S.transition.ease === 'smooth') u = u * u * (3 - 2 * u);
868
+ return { f0: sample.f0, f1: sample.f1, u: u };
869
+ }
870
+ function writeLineFrame(entry, t) {
871
+ const L = entry.L;
872
+ const F = L.frames;
873
+ if (!F || !F.x || !S.transition) return;
874
+ const nF = F.nFrames;
875
+ const n = L.n;
876
+ const blend = frameBlend(t);
877
+ const f0 = blend.f0, f1 = blend.f1, u = blend.u;
878
+ const ext = entry.ext;
879
+ const hasZ = !!entry.hasZ;
880
+ for (let i = 0; i < n; i++) {
881
+ const i0 = i * nF + f0;
882
+ const i1 = i * nF + f1;
883
+ L._x[i] = F.x.data[i0] * (1 - u) + F.x.data[i1] * u;
884
+ L._y[i] = F.y ? (F.y.data[i0] * (1 - u) + F.y.data[i1] * u) : L.y.data[i];
885
+ if (hasZ && F.z) L._z[i] = F.z.data[i0] * (1 - u) + F.z.data[i1] * u;
886
+ }
887
+ for (let s = 0; s < entry.segs.length; s++) {
888
+ const seg = entry.segs[s];
889
+ const flat = new Float32Array(seg.cnt * 3);
890
+ for (let k = 0; k < seg.cnt; k++) {
891
+ const i = seg.s0 + k;
892
+ flat[k * 3] = L._x[i] * ext[0];
893
+ flat[k * 3 + 1] = L._y[i] * ext[1];
894
+ flat[k * 3 + 2] = hasZ ? L._z[i] * ext[2] : 0;
895
+ }
896
+ seg.geom.setPositions(Array.from(flat));
897
+ }
898
+ }
899
+ function writeSurfaceFrame(entry, t) {
900
+ const L = entry.L;
901
+ const F = L.frames;
902
+ if (!F || !S.transition) return;
903
+ const nF = F.nFrames;
904
+ const n = L.n;
905
+ const blend = frameBlend(t);
906
+ const f0 = blend.f0, f1 = blend.f1, u = blend.u;
907
+ const ext = entry.ext;
908
+ const pos = entry.pos;
909
+ for (let i = 0; i < n; i++) {
910
+ const i0 = i * nF + f0;
911
+ const i1 = i * nF + f1;
912
+ const x = F.x ? F.x.data[i0] * (1 - u) + F.x.data[i1] * u : L.x.data[i];
913
+ const y = F.y ? F.y.data[i0] * (1 - u) + F.y.data[i1] * u : L.y.data[i];
914
+ const z = F.z ? F.z.data[i0] * (1 - u) + F.z.data[i1] * u : L.z.data[i];
915
+ L._x[i] = x;
916
+ L._y[i] = y;
917
+ L._z[i] = z;
918
+ pos[i * 3] = x * ext[0];
919
+ pos[i * 3 + 1] = y * ext[1];
920
+ pos[i * 3 + 2] = z * ext[2];
921
+ }
922
+ entry.geom.attributes.position.needsUpdate = true;
923
+ entry.geom.computeVertexNormals();
924
+ if (entry.wire) {
925
+ const next = new THREE.WireframeGeometry(entry.geom);
926
+ entry.wire.geometry.dispose();
927
+ entry.wire.geometry = next;
928
+ }
929
+ }
930
+ function writeStepIndex(entry, f) {
931
+ const bucket = entry.bucket;
932
+ for (let i = 0; i < bucket.length; i++) {
933
+ const on = i === f;
934
+ for (let s = 0; s < bucket[i].length; s++) bucket[i][s].visible = on;
935
+ }
936
+ const L = entry.L;
937
+ const F = L.frames;
938
+ const span = F.spans[f];
939
+ const start = span[0], count = span[1];
940
+ L._stepN = count;
941
+ if (!L._x || L._x.length !== count) {
942
+ L._x = new Float32Array(count);
943
+ L._y = new Float32Array(count);
944
+ }
945
+ for (let i = 0; i < count; i++) {
946
+ L._x[i] = F.x.data[start + i];
947
+ L._y[i] = F.y.data[start + i];
948
+ }
949
+ }
950
+ function writeStepFrame(entry, t) {
951
+ const sample = frameSample(t);
952
+ let f = sample.u >= 0.5 ? sample.f1 : sample.f0;
953
+ if (sample.f0 === sample.f1) f = sample.f0;
954
+ writeStepIndex(entry, f);
955
+ }
956
+ function sliderAxes() {
957
+ const params = (S.slider && S.slider.params) || [];
958
+ const axes = [];
959
+ for (let p = 0; p < params.length; p++) {
960
+ const spec = params[p];
961
+ const node = document.getElementById('slider-' + spec.name);
962
+ const u = node ? (+node.value) / 1000 : 0;
963
+ const n = spec.n;
964
+ const span = Math.max(0, n - 1);
965
+ const t = u * span;
966
+ let i0 = Math.floor(t);
967
+ if (i0 >= n - 1) i0 = n - 1;
968
+ if (i0 < 0) i0 = 0;
969
+ const i1 = Math.min(n - 1, i0 + 1);
970
+ const f = (i0 === i1) ? 0 : (t - i0);
971
+ const value = (+spec.lo) + u * ((+spec.hi) - (+spec.lo));
972
+ axes.push({ spec: spec, i0: i0, i1: i1, f: f, value: value });
973
+ }
974
+ return axes;
975
+ }
976
+ function sliderStepIndex(axes) {
977
+ let index = 0;
978
+ for (let b = 0; b < axes.length; b++) {
979
+ const ax = axes[b];
980
+ const i = ax.f >= 0.5 ? ax.i1 : ax.i0;
981
+ index += i * ax.spec.stride;
982
+ }
983
+ return index;
984
+ }
985
+ function sliderCorners(axes) {
986
+ const n = axes.length;
987
+ const corners = [];
988
+ const limit = 1 << n;
989
+ for (let mask = 0; mask < limit; mask++) {
990
+ let index = 0;
991
+ let w = 1;
992
+ for (let b = 0; b < n; b++) {
993
+ const bit = (mask >> b) & 1;
994
+ const ax = axes[b];
995
+ index += (bit ? ax.i1 : ax.i0) * ax.spec.stride;
996
+ w *= bit ? ax.f : (1 - ax.f);
997
+ }
998
+ if (w < 1e-8) continue;
999
+ corners.push({ index: index, w: w });
1000
+ }
1001
+ if (!corners.length) corners.push({ index: sliderStepIndex(axes), w: 1 });
1002
+ return corners;
1003
+ }
1004
+ function writeLineCorners(entry, corners) {
1005
+ const L = entry.L;
1006
+ const F = L.frames;
1007
+ if (!F || !F.x) return;
1008
+ const nF = F.nFrames;
1009
+ const n = L.n;
1010
+ const ext = entry.ext;
1011
+ const hasZ = !!entry.hasZ;
1012
+ for (let i = 0; i < n; i++) {
1013
+ let x = 0, y = 0, z = 0;
1014
+ const base = i * nF;
1015
+ for (let c = 0; c < corners.length; c++) {
1016
+ const w = corners[c].w;
1017
+ const idx = base + corners[c].index;
1018
+ x += w * F.x.data[idx];
1019
+ if (F.y) y += w * F.y.data[idx];
1020
+ if (hasZ && F.z) z += w * F.z.data[idx];
1021
+ }
1022
+ L._x[i] = x;
1023
+ L._y[i] = F.y ? y : L.y.data[i];
1024
+ if (hasZ && F.z) L._z[i] = z;
1025
+ }
1026
+ for (let s = 0; s < entry.segs.length; s++) {
1027
+ const seg = entry.segs[s];
1028
+ const flat = new Float32Array(seg.cnt * 3);
1029
+ for (let k = 0; k < seg.cnt; k++) {
1030
+ const i = seg.s0 + k;
1031
+ flat[k * 3] = L._x[i] * ext[0];
1032
+ flat[k * 3 + 1] = L._y[i] * ext[1];
1033
+ flat[k * 3 + 2] = hasZ ? L._z[i] * ext[2] : 0;
1034
+ }
1035
+ seg.geom.setPositions(Array.from(flat));
1036
+ }
1037
+ }
1038
+ function writeSurfaceCorners(entry, corners) {
1039
+ const L = entry.L;
1040
+ const F = L.frames;
1041
+ if (!F) return;
1042
+ const nF = F.nFrames;
1043
+ const n = L.n;
1044
+ const ext = entry.ext;
1045
+ const pos = entry.pos;
1046
+ for (let i = 0; i < n; i++) {
1047
+ let x = 0, y = 0, z = 0;
1048
+ let wx = 0, wy = 0, wz = 0;
1049
+ const base = i * nF;
1050
+ for (let c = 0; c < corners.length; c++) {
1051
+ const w = corners[c].w;
1052
+ const idx = base + corners[c].index;
1053
+ if (F.x) { x += w * F.x.data[idx]; wx += w; }
1054
+ if (F.y) { y += w * F.y.data[idx]; wy += w; }
1055
+ if (F.z) { z += w * F.z.data[idx]; wz += w; }
1056
+ }
1057
+ if (wx) L._x[i] = x; else L._x[i] = L.x.data[i];
1058
+ if (wy) L._y[i] = y; else L._y[i] = L.y.data[i];
1059
+ if (wz) L._z[i] = z; else L._z[i] = L.z.data[i];
1060
+ pos[i * 3] = L._x[i] * ext[0];
1061
+ pos[i * 3 + 1] = L._y[i] * ext[1];
1062
+ pos[i * 3 + 2] = L._z[i] * ext[2];
1063
+ }
1064
+ entry.geom.attributes.position.needsUpdate = true;
1065
+ entry.geom.computeVertexNormals();
1066
+ if (entry.wire) {
1067
+ const next = new THREE.WireframeGeometry(entry.geom);
1068
+ entry.wire.geometry.dispose();
1069
+ entry.wire.geometry = next;
1070
+ }
1071
+ }
1072
+ function addTweenLines(L, ext, cols) {
1073
+ const n = L.n;
1074
+ const hasZ = !!(L.z && L.z.data);
1075
+ L._x = new Float32Array(L.x.data);
1076
+ L._y = new Float32Array(L.y.data);
1077
+ if (hasZ) L._z = new Float32Array(L.z.data);
1078
+ const segs = [];
1079
+ const groups = L.groups || [[0, n]];
1080
+ for (let g = 0; g < groups.length; g++) {
1081
+ const s0 = groups[g][0], cnt = groups[g][1];
1082
+ if (cnt < 2) continue;
1083
+ const flat = new Float32Array(cnt * 3);
1084
+ for (let i = 0; i < cnt; i++) {
1085
+ const j = s0 + i;
1086
+ flat[i * 3] = L.x.data[j] * ext[0];
1087
+ flat[i * 3 + 1] = L.y.data[j] * ext[1];
1088
+ flat[i * 3 + 2] = hasZ ? L.z.data[j] * ext[2] : 0;
1089
+ }
1090
+ const lg = new LineGeometry();
1091
+ lg.setPositions(Array.from(flat));
1092
+ const lm = new LineMaterial({
1093
+ color: new THREE.Color(cols[s0 * 3], cols[s0 * 3 + 1], cols[s0 * 3 + 2]).getHex(),
1094
+ linewidth: L.linewidth || 2,
1095
+ worldUnits: false,
1096
+ transparent: true,
1097
+ opacity: L.alpha == null ? 1 : L.alpha
1098
+ });
1099
+ lineMats.push(lm);
1100
+ scene.add(new Line2(lg, lm));
1101
+ segs.push({ geom: lg, s0: s0, cnt: cnt });
1102
+ }
1103
+ lineEntries.push({ L: L, ext: ext, segs: segs, hasZ: hasZ });
1104
+ }
1105
+ function addStepLayer(L, ext, cols) {
1106
+ const F = L.frames;
1107
+ const rgb = [cols[0], cols[1], cols[2]];
1108
+ const bucket = [];
1109
+ for (let f = 0; f < F.nFrames; f++) {
1110
+ const span = F.spans[f];
1111
+ const start = span[0], count = span[1];
1112
+ const groups = (F.groups && F.groups[f]) || [[0, count]];
1113
+ const segs = [];
1114
+ for (let g = 0; g < groups.length; g++) {
1115
+ const gs = groups[g][0], gc = groups[g][1];
1116
+ if (gc < 2) continue;
1117
+ const flat = new Float32Array(gc * 3);
1118
+ for (let i = 0; i < gc; i++) {
1119
+ const j = start + gs + i;
1120
+ flat[i * 3] = F.x.data[j] * ext[0];
1121
+ flat[i * 3 + 1] = F.y.data[j] * ext[1];
1122
+ flat[i * 3 + 2] = 0;
1123
+ }
1124
+ const lg = new LineGeometry();
1125
+ lg.setPositions(Array.from(flat));
1126
+ const lm = new LineMaterial({
1127
+ color: new THREE.Color(rgb[0], rgb[1], rgb[2]).getHex(),
1128
+ linewidth: L.linewidth || 2,
1129
+ worldUnits: false,
1130
+ transparent: true,
1131
+ opacity: L.alpha == null ? 1 : L.alpha
1132
+ });
1133
+ lineMats.push(lm);
1134
+ const ln = new Line2(lg, lm);
1135
+ ln.visible = false;
1136
+ scene.add(ln);
1137
+ segs.push(ln);
1138
+ }
1139
+ bucket.push(segs);
1140
+ }
1141
+ stepEntries.push({ L: L, bucket: bucket, ext: ext });
1142
+ }
1143
+ function registerSurface(L, ext, geom, pos, wire) {
1144
+ L._x = new Float32Array(L.x.data);
1145
+ L._y = new Float32Array(L.y.data);
1146
+ L._z = new Float32Array(L.z.data);
1147
+ surfaceEntries.push({ L: L, ext: ext, geom: geom, pos: pos, wire: wire });
1148
+ }
1149
+ function clearTrail() {
1150
+ if (trail) {
1151
+ scene.remove(trail);
1152
+ const mat = trail.userData ? trail.userData.mat : null;
1153
+ if (mat) {
1154
+ const idx = lineMats.indexOf(mat);
1155
+ if (idx >= 0) lineMats.splice(idx, 1);
1156
+ }
1157
+ trail = null;
1158
+ }
1159
+ trailId = null;
1160
+ }
1161
+ function setTrail(L, obj) {
1162
+ clearTrail();
1163
+ if (!L || obj == null || !L.frames) return;
1164
+ const F = L.frames;
1165
+ const nF = F.nFrames;
1166
+ const entry = bubbleEntries.find(e => e.L === L);
1167
+ const ext = entry ? entry.ext : [1, 1, 1];
1168
+ const runs = [];
1169
+ let run = [];
1170
+ for (let f = 0; f < nF; f++) {
1171
+ const i = obj * nF + f;
1172
+ if (!framePresent(F, i)) {
1173
+ if (run.length >= 6) runs.push(run);
1174
+ run = [];
1175
+ continue;
1176
+ }
1177
+ const z = F.z ? F.z.data[i] : 0;
1178
+ run.push(F.x.data[i] * ext[0], F.y.data[i] * ext[1], z * ext[2]);
1179
+ }
1180
+ if (run.length >= 6) runs.push(run);
1181
+ if (!runs.length) return;
1182
+ let rgb = hex2rgb(T.ink);
1183
+ if (entry) rgb = [entry.cols[obj * 3], entry.cols[obj * 3 + 1], entry.cols[obj * 3 + 2]];
1184
+ const lm = new LineMaterial({
1185
+ color: new THREE.Color(rgb[0], rgb[1], rgb[2]).getHex(),
1186
+ linewidth: 2, worldUnits: false, transparent: true, opacity: 0.85,
1187
+ });
1188
+ const w = renderer.domElement.width || 800;
1189
+ const h = renderer.domElement.height || 600;
1190
+ lm.resolution.set(w, h);
1191
+ lineMats.push(lm);
1192
+ const group = new THREE.Group();
1193
+ group.userData.mat = lm;
1194
+ for (let r = 0; r < runs.length; r++) {
1195
+ const lg = new LineGeometry();
1196
+ lg.setPositions(runs[r]);
1197
+ group.add(new Line2(lg, lm));
1198
+ }
1199
+ scene.add(group);
1200
+ trail = group;
1201
+ trailId = String(obj) + '|' + (L.ids ? L.ids[obj] : '');
1202
+ }
1203
+ let frameLabel = '';
1204
+ const axisTitleSprites = [];
1205
+ let refreshFrameLabels = () => {};
1206
+ function withFrame(text) {
1207
+ const s = text == null ? '' : String(text);
1208
+ if (s.indexOf('{frame_time}') < 0) return s;
1209
+ return s.split('{frame_time}').join(frameLabel);
1210
+ }
1211
+ function fmtParam(v) {
1212
+ if (!isFinite(v)) return String(v);
1213
+ const a = Math.abs(v);
1214
+ if (a >= 1e6 || (a > 0 && a < 1e-3)) return v.toExponential(2);
1215
+ return v.toFixed(2);
1216
+ }
1217
+ function frameText(t) {
1218
+ const tr = S.transition;
1219
+ if (tr.params && tr.params.length) {
1220
+ const nF = tr.nFrames;
1221
+ const u = nF <= 1 ? 0 : t / (nF - 1);
1222
+ const parts = [];
1223
+ for (let i = 0; i < tr.params.length; i++) {
1224
+ const p = tr.params[i];
1225
+ const v = (+p.lo) + u * ((+p.hi) - (+p.lo));
1226
+ parts.push(p.name + ' = ' + fmtParam(v));
1227
+ }
1228
+ return parts.join(', ');
1229
+ }
1230
+ const sample = frameSample(t);
1231
+ if (tr.type === 'states') {
1232
+ return String(sample.u < 0.5 ? tr.times[sample.f0] : tr.times[sample.f1]);
1233
+ }
1234
+ if (sample.T != null) {
1235
+ if (tr.integer) return String(Math.round(sample.T));
1236
+ return fmt(sample.T);
1237
+ }
1238
+ const a = +tr.times[sample.f0], b = +tr.times[sample.f1];
1239
+ const v = a + (b - a) * sample.u;
1240
+ if (tr.integer) return String(Math.round(v));
1241
+ return fmt(v);
1242
+ }
1243
+ let playT = 0;
1244
+ let playing = false;
1245
+ let playSpeed = 1;
1246
+ let recording = false;
1247
+ let mediaRec = null;
1248
+ function installSliders(renderFrame) {
1249
+ const spec = S.slider;
1250
+ const params = (spec && spec.params) || [];
1251
+ const player = document.getElementById('player');
1252
+ const yearEl = document.getElementById('year');
1253
+ player.style.display = 'flex';
1254
+ player.style.color = T.ink2;
1255
+ yearEl.style.display = 'none';
1256
+ document.getElementById('play-btn').style.display = 'none';
1257
+ document.getElementById('play-range').style.display = 'none';
1258
+ document.getElementById('play-readout').style.display = 'none';
1259
+ const speed = document.getElementById('play-speed');
1260
+ if (speed && speed.parentElement) speed.parentElement.style.display = 'none';
1261
+ const readouts = [];
1262
+ for (let i = 0; i < params.length; i++) {
1263
+ const p = params[i];
1264
+ const lab = document.createElement('label');
1265
+ lab.className = 'plot3-slider';
1266
+ const input = document.createElement('input');
1267
+ input.type = 'range';
1268
+ input.min = '0';
1269
+ input.max = '1000';
1270
+ const savedU = SAVED && SAVED.sliders ? SAVED.sliders[p.name] : null;
1271
+ input.value = (savedU == null || !isFinite(+savedU)) ? '0' : String(savedU);
1272
+ input.id = 'slider-' + p.name;
1273
+ input.setAttribute('aria-label', p.name);
1274
+ const out = document.createElement('span');
1275
+ out.className = 'plot3-slider-readout';
1276
+ lab.appendChild(input);
1277
+ lab.appendChild(out);
1278
+ player.appendChild(lab);
1279
+ readouts.push(out);
1280
+ input.addEventListener('input', () => paint());
1281
+ }
1282
+ let shownLab = null;
1283
+ function paint() {
1284
+ const axes = sliderAxes();
1285
+ const corners = sliderCorners(axes);
1286
+ for (const e of lineEntries) writeLineCorners(e, corners);
1287
+ for (const e of surfaceEntries) writeSurfaceCorners(e, corners);
1288
+ const stepIndex = sliderStepIndex(axes);
1289
+ for (const e of stepEntries) writeStepIndex(e, stepIndex);
1290
+ const parts = [];
1291
+ for (let i = 0; i < axes.length; i++) {
1292
+ const text = axes[i].spec.name + ' = ' + fmtParam(axes[i].value);
1293
+ parts.push(text);
1294
+ if (readouts[i]) readouts[i].textContent = text;
1295
+ }
1296
+ const lab = parts.join(', ');
1297
+ frameLabel = lab;
1298
+ if (lab !== shownLab) {
1299
+ shownLab = lab;
1300
+ refreshFrameLabels();
1301
+ const titleTemplate = (S.labs && S.labs.title) || '';
1302
+ if (titleTemplate.indexOf('{frame_time}') >= 0) {
1303
+ const next = titleTemplate.split('{frame_time}').join(lab);
1304
+ if (S.labsMath && S.labsMath.title) {
1305
+ const segs = S.labsMath.title.map(p => ({
1306
+ latex: p.latex,
1307
+ text: String(p.text == null ? '' : p.text).split('{frame_time}').join(lab),
1308
+ }));
1309
+ titleEl.innerHTML = plot3MathHTML(segs, next);
1310
+ plot3Typeset(titleEl);
1311
+ } else {
1312
+ titleEl.textContent = next;
1313
+ }
1314
+ }
1315
+ }
1316
+ renderFrame();
1317
+ }
1318
+ paint();
1319
+ }
1320
+ function installPlayer(renderFrame) {
1321
+ window.__plot3.afterLegend = () => {
1322
+ for (const e of bubbleEntries) applyBubbleHide(e);
1323
+ };
1324
+ if (S.slider) { installSliders(renderFrame); return; }
1325
+ if (!S.transition) return;
1326
+ const tr = S.transition;
1327
+ const nF = tr.nFrames;
1328
+ playT = Math.max(0, nF - 1);
1329
+ const reduced = window.matchMedia
1330
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
1331
+ playing = !reduced && nF > 1;
1332
+ // A paused chart shows the last keyframe. Playback starts at the first one,
1333
+ // so the opening frame is not immediately wrapped away.
1334
+ if (playing) playT = 0;
1335
+ if (SAVED && typeof SAVED.playT === 'number' && isFinite(SAVED.playT)) {
1336
+ playT = Math.max(0, Math.min(nF - 1, SAVED.playT));
1337
+ playing = false;
1338
+ }
1339
+ const player = document.getElementById('player');
1340
+ const btn = document.getElementById('play-btn');
1341
+ const range = document.getElementById('play-range');
1342
+ const readout = document.getElementById('play-readout');
1343
+ const speed = document.getElementById('play-speed');
1344
+ const yearEl = document.getElementById('year');
1345
+ player.style.display = 'flex';
1346
+ player.style.color = T.ink2;
1347
+ btn.style.background = T.surface;
1348
+ btn.style.color = T.ink;
1349
+ btn.style.border = '1px solid ' + T.axis;
1350
+ yearEl.style.display = 'block';
1351
+ yearEl.style.color = T.ink;
1352
+ const titleTemplate = (S.labs && S.labs.title) || '';
1353
+ let shownLab = null;
1354
+ function syncChrome() {
1355
+ const lab = frameText(playT);
1356
+ frameLabel = lab;
1357
+ readout.textContent = lab;
1358
+ yearEl.textContent = lab;
1359
+ yearEl.style.fontSize = lab.length > 8 ? '56px' : 'min(22vw, 148px)';
1360
+ range.value = (nF <= 1) ? '1000' : String(Math.round(playT / (nF - 1) * 1000));
1361
+ btn.textContent = playing ? 'Pause' : 'Play';
1362
+ btn.setAttribute('aria-label', playing ? 'Pause' : 'Play');
1363
+ if (lab === shownLab) return;
1364
+ shownLab = lab;
1365
+ refreshFrameLabels();
1366
+ if (titleTemplate.indexOf('{frame_time}') < 0) return;
1367
+ const next = titleTemplate.split('{frame_time}').join(lab);
1368
+ if (S.labsMath && S.labsMath.title) {
1369
+ const segs = S.labsMath.title.map(p => ({
1370
+ latex: p.latex,
1371
+ text: String(p.text == null ? '' : p.text).split('{frame_time}').join(lab),
1372
+ }));
1373
+ titleEl.innerHTML = plot3MathHTML(segs, next);
1374
+ plot3Typeset(titleEl);
1375
+ } else {
1376
+ titleEl.textContent = next;
1377
+ }
1378
+ }
1379
+ function paint() {
1380
+ for (const e of bubbleEntries) if (e.L.frames) writeBubbleFrame(e, playT);
1381
+ for (const e of lineEntries) writeLineFrame(e, playT);
1382
+ for (const e of surfaceEntries) writeSurfaceFrame(e, playT);
1383
+ for (const e of stepEntries) writeStepFrame(e, playT);
1384
+ syncChrome();
1385
+ renderFrame();
1386
+ }
1387
+ paint();
1388
+ btn.addEventListener('click', () => {
1389
+ playing = !playing;
1390
+ if (playing && nF > 1 && playT >= nF - 1) playT = 0;
1391
+ syncChrome();
1392
+ });
1393
+ range.addEventListener('input', () => {
1394
+ playing = false;
1395
+ const u = (+range.value) / 1000;
1396
+ playT = (nF <= 1) ? 0 : u * (nF - 1);
1397
+ paint();
1398
+ });
1399
+ speed.addEventListener('input', () => { playSpeed = +speed.value || 1; });
1400
+ window.addEventListener('keydown', (e) => {
1401
+ const tag = (e.target && e.target.tagName) || '';
1402
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'BUTTON') return;
1403
+ if (e.code === 'Space') {
1404
+ e.preventDefault();
1405
+ playing = !playing;
1406
+ syncChrome();
1407
+ } else if (e.code === 'ArrowRight' || e.code === 'ArrowLeft') {
1408
+ e.preventDefault();
1409
+ playing = false;
1410
+ const dir = e.code === 'ArrowRight' ? 1 : -1;
1411
+ const trTimes = tr.times || [];
1412
+ if (tr.type === 'time' && trTimes.length === nF && nF > 1) {
1413
+ const s = frameSample(playT);
1414
+ let idx = s.f0;
1415
+ if (dir > 0) idx = s.u > 1e-4 ? s.f1 : Math.min(nF - 1, s.f0 + 1);
1416
+ else idx = s.u > 1e-4 ? s.f0 : Math.max(0, s.f0 - 1);
1417
+ const t0 = +trTimes[0], t1 = +trTimes[nF - 1];
1418
+ const span = t1 - t0;
1419
+ playT = span === 0 ? 0 : ((+trTimes[idx] - t0) / span) * (nF - 1);
1420
+ } else {
1421
+ playT = Math.max(0, Math.min(nF - 1, Math.round(playT) + dir));
1422
+ }
1423
+ paint();
1424
+ }
1425
+ });
1426
+ window.__plot3.startRecording = () => new Promise((resolve, reject) => {
1427
+ if (recording) { reject(new Error('Already recording')); return; }
1428
+ if (typeof MediaRecorder === 'undefined' || !renderer.domElement.captureStream) {
1429
+ reject(new Error('Video recording is not available in this browser'));
1430
+ return;
1431
+ }
1432
+ let mime = 'video/webm';
1433
+ if (MediaRecorder.isTypeSupported('video/webm;codecs=vp9')) mime = 'video/webm;codecs=vp9';
1434
+ else if (!MediaRecorder.isTypeSupported('video/webm')) {
1435
+ reject(new Error('WebM recording is not available in this browser'));
1436
+ return;
1437
+ }
1438
+ const stream = renderer.domElement.captureStream(30);
1439
+ const rec = new MediaRecorder(stream, { mimeType: mime });
1440
+ const chunks = [];
1441
+ rec.ondataavailable = (ev) => { if (ev.data && ev.data.size) chunks.push(ev.data); };
1442
+ rec.onstop = () => {
1443
+ recording = false;
1444
+ resolve(new Blob(chunks, { type: 'video/webm' }));
1445
+ };
1446
+ rec.onerror = () => {
1447
+ recording = false;
1448
+ reject(new Error('Video recording failed'));
1449
+ };
1450
+ mediaRec = rec;
1451
+ recording = true;
1452
+ playing = true;
1453
+ playT = 0;
1454
+ rec.start();
1455
+ paint();
1456
+ });
1457
+ let last = 0;
1458
+ function loop(now) {
1459
+ if (!last) last = now;
1460
+ const dt = Math.min(0.05, (now - last) / 1000);
1461
+ last = now;
1462
+ if (playing && !hoverHold && nF > 1) {
1463
+ const dur = (tr.duration || 12) / (playSpeed || 1);
1464
+ playT += dt * (nF - 1) / dur;
1465
+ if (playT >= nF - 1) {
1466
+ if (recording) {
1467
+ playT = nF - 1;
1468
+ playing = false;
1469
+ if (mediaRec && mediaRec.state === 'recording') mediaRec.stop();
1470
+ } else {
1471
+ playT = playT % (nF - 1);
1472
+ }
1473
+ }
1474
+ paint();
1475
+ }
1476
+ requestAnimationFrame(loop);
1477
+ }
1478
+ requestAnimationFrame(loop);
1479
+ }
1480
+ window.__plot3.afterLegend = () => {
1481
+ for (const e of bubbleEntries) applyBubbleHide(e);
1482
+ };
1483
+
1484
+ let renderNow = () => {};
1485
+
1486
+ if (!S.is3d) {
1487
+ // ═════════════════════════ 2D: ortho + pan/zoom ═════════════════════════
1488
+ // theme_void: no tick labels or axis titles to make room for.
1489
+ const VOID2 = !!T.void;
1490
+ const M = VOID2 ? { l: 12, r: 12, t: 30, b: 12 } : { l: 58, r: 12, t: 30, b: 40 };
1491
+ if (S.labs.subtitle) M.t += 16;
1492
+ const X_ANGLE = THEME_OPTS.xAngle || 0;
1493
+ if (X_ANGLE > 0 && S.scales.x) {
1494
+ // Room for turned x labels (about 6.5 px per character at 11 px).
1495
+ const labels = S.scales.x.kind === 'cat' ? S.scales.x.cats : (S.scales.x.ticks || []).map(t => t[1]);
1496
+ const longest = Math.max(0, ...labels.map(l => String(l).length)) * 6.5;
1497
+ M.b += Math.max(0, longest * Math.sin(X_ANGLE * Math.PI / 180) - 8);
1498
+ }
1499
+ let W = 100, H = 100;
1500
+ // coord_cartesian(expand=False): the limits are the panel's edges.
1501
+ const PAD2 = (S.coord && S.coord.expand === false) ? 0 : 0.03;
1502
+ const cam = new THREE.OrthographicCamera(-PAD2, 1 + PAD2, 1 + PAD2, -PAD2, -10, 10);
1503
+ // Equal aspect: one data unit has the same length on x and y. The panel
1504
+ // stays the cell's shape; the camera shows extra range on the looser axis.
1505
+ const coord2d = S.coord || { aspect: 'data', ratio: 1 };
1506
+ const equalAspect = coord2d.aspect === 'equal';
1507
+ const equalRatio = coord2d.ratio > 0 ? coord2d.ratio : 1;
1508
+ let equalZoom = 1;
1509
+ let equalCx = 0.5;
1510
+ let equalCy = 0.5;
1511
+
1512
+ function equalBase() {
1513
+ // Nx / Ny so pixels per x unit = ratio * pixels per y unit.
1514
+ const target = (W / Math.max(H, 1)) * (spanOf('y') / spanOf('x')) / equalRatio;
1515
+ const pad = PAD2;
1516
+ const need = 1 + 2 * pad;
1517
+ let Nx = need, Ny = need;
1518
+ if (Nx / Ny < target) Nx = Ny * target;
1519
+ else Ny = Nx / target;
1520
+ return { Nx: Nx, Ny: Ny };
1521
+ }
1522
+ function applyEqual() {
1523
+ const base = equalBase();
1524
+ const Nx = base.Nx * equalZoom;
1525
+ const Ny = base.Ny * equalZoom;
1526
+ cam.left = equalCx - Nx / 2;
1527
+ cam.right = equalCx + Nx / 2;
1528
+ cam.bottom = equalCy - Ny / 2;
1529
+ cam.top = equalCy + Ny / 2;
1530
+ }
1531
+ function readEqualFromCam() {
1532
+ const base = equalBase();
1533
+ equalCx = (cam.left + cam.right) / 2;
1534
+ equalCy = (cam.bottom + cam.top) / 2;
1535
+ equalZoom = base.Nx > 0 ? (cam.right - cam.left) / base.Nx : 1;
1536
+ }
1537
+
1538
+ // Paint layers in the order they were added, like ggplot2. three.js draws
1539
+ // opaque objects before transparent ones, which would put an error bar
1540
+ // (opaque line) underneath the translucent bar it belongs on.
1541
+ let paintOrder = 0;
1542
+ const addToScene = scene.add.bind(scene);
1543
+ scene.add = (...objs) => {
1544
+ for (const o of objs) {
1545
+ o.traverse(c => {
1546
+ c.renderOrder = paintOrder;
1547
+ const mats = c.material ? (Array.isArray(c.material) ? c.material : [c.material]) : [];
1548
+ for (const m of mats) m.transparent = true;
1549
+ });
1550
+ }
1551
+ return addToScene(...objs);
1552
+ };
1553
+ for (const L of S.layers) {
1554
+ if (L.blank) continue; // geom_blank: on the scales only
1555
+ paintOrder += 1;
1556
+ const n = L.n;
1557
+ const cols = layerColors(L, hex2rgb(T.cat[0]));
1558
+ const isCat = L.color && L.color.kind === 'cat';
1559
+ if (L.kind === 'point' && (L.frames || (L.size && L.size.id) || (L.opacity && L.opacity.id))) {
1560
+ addBubbleLayer(L, [1, 1, 1], false);
1561
+ } else if (L.kind === 'point') {
1562
+ if (L.shape) {
1563
+ // One Points object per (category, symbol), each with its sprite.
1564
+ const k = isCat ? S.color.cats.length : 1;
1565
+ const buckets = new Map();
1566
+ for (let i = 0; i < n; i++) {
1567
+ const ci = isCat ? L.color.data[i] % k : 0;
1568
+ const key = ci + '|' + shapeOf(L, i);
1569
+ if (!buckets.has(key)) buckets.set(key, []);
1570
+ buckets.get(key).push(i);
1571
+ }
1572
+ for (const [key, idx] of buckets) {
1573
+ const [ciText, shape] = key.split('|');
1574
+ const ci = Number(ciText);
1575
+ const pos = new Float32Array(idx.length * 3);
1576
+ const col = new Float32Array(idx.length * 3);
1577
+ for (let j = 0; j < idx.length; j++) {
1578
+ const i = idx[j];
1579
+ pos[j*3] = L.x.data[i]; pos[j*3+1] = L.y.data[i]; pos[j*3+2] = 0;
1580
+ col[j*3] = cols[i*3]; col[j*3+1] = cols[i*3+1]; col[j*3+2] = cols[i*3+2];
1581
+ }
1582
+ const g = new THREE.BufferGeometry();
1583
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
1584
+ g.setAttribute('color', new THREE.BufferAttribute(col, 3));
1585
+ const pt = new THREE.Points(g, new THREE.PointsMaterial({
1586
+ size: (L.size || 6) * 1.25, sizeAttenuation: false, vertexColors: true,
1587
+ map: shapeTexture(shape), alphaTest: 0.3,
1588
+ transparent: true, opacity: L.alpha }));
1589
+ scene.add(pt);
1590
+ if (isCat) regCat(ci, pt);
1591
+ }
1592
+ } else if (isCat) {
1593
+ // one Points object per category -> legend click-filtering
1594
+ const k = S.color.cats.length;
1595
+ const buckets = Array.from({ length: k }, () => []);
1596
+ for (let i = 0; i < n; i++) buckets[L.color.data[i] % k].push(i);
1597
+ buckets.forEach((idx, ci) => {
1598
+ if (!idx.length) return;
1599
+ const pos = new Float32Array(idx.length * 3);
1600
+ for (let j = 0; j < idx.length; j++) {
1601
+ const i = idx[j];
1602
+ pos[j*3] = L.x.data[i]; pos[j*3+1] = L.y.data[i]; pos[j*3+2] = 0;
1603
+ }
1604
+ const g = new THREE.BufferGeometry();
1605
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
1606
+ // Round dots, as saved files draw them (the circle fills 7/8 of
1607
+ // the sprite, so the sprite is a little larger than the size).
1608
+ const pt = new THREE.Points(g, new THREE.PointsMaterial({
1609
+ color: S.color.palette[ci], size: L.size / 0.875, sizeAttenuation: false,
1610
+ map: shapeTexture('circle'), alphaTest: 0.3,
1611
+ transparent: true, opacity: L.alpha }));
1612
+ scene.add(pt);
1613
+ regCat(ci, pt);
1614
+ });
1615
+ } else {
1616
+ const pos = new Float32Array(n * 3);
1617
+ for (let i = 0; i < n; i++) {
1618
+ pos[i*3] = L.x.data[i]; pos[i*3+1] = L.y.data[i]; pos[i*3+2] = 0;
1619
+ }
1620
+ const g = new THREE.BufferGeometry();
1621
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
1622
+ g.setAttribute('color', new THREE.BufferAttribute(cols, 3));
1623
+ scene.add(new THREE.Points(g, new THREE.PointsMaterial({
1624
+ size: L.size / 0.875, sizeAttenuation: false, vertexColors: true,
1625
+ map: shapeTexture('circle'), alphaTest: 0.3,
1626
+ transparent: true, opacity: L.alpha })));
1627
+ }
1628
+ } else if (L.kind === 'col') {
1629
+ // Axis-aligned bars from baseline y0 to y=height (normalized coords).
1630
+ const hw = (L.width || 0.08) * 0.5;
1631
+ const y0 = (L.y0 != null) ? L.y0 : 0;
1632
+ const pos = new Float32Array(n * 6 * 3);
1633
+ const col = new Float32Array(n * 6 * 3);
1634
+ let p = 0, c = 0;
1635
+ for (let i = 0; i < n; i++) {
1636
+ const x = L.x.data[i], y = L.y.data[i];
1637
+ const x0 = x - hw, x1 = x + hw;
1638
+ // two triangles: (x0,y0)-(x1,y0)-(x1,y) and (x0,y0)-(x1,y)-(x0,y)
1639
+ const tri = [x0,y0,0, x1,y0,0, x1,y,0, x0,y0,0, x1,y,0, x0,y,0];
1640
+ for (let k = 0; k < 18; k++) pos[p++] = tri[k];
1641
+ for (let k = 0; k < 6; k++) {
1642
+ col[c++] = cols[i*3]; col[c++] = cols[i*3+1]; col[c++] = cols[i*3+2];
1643
+ }
1644
+ }
1645
+ const g = new THREE.BufferGeometry();
1646
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
1647
+ g.setAttribute('color', new THREE.BufferAttribute(col, 3));
1648
+ scene.add(new THREE.Mesh(g, new THREE.MeshBasicMaterial({
1649
+ vertexColors: true, transparent: true, opacity: L.alpha,
1650
+ side: THREE.DoubleSide, depthWrite: false })));
1651
+ } else if (L.kind === 'box') {
1652
+ // Tukey boxplot: body [lower,upper], median, whiskers [ymin,ymax], outliers.
1653
+ const hw = (L.width || 0.08) * 0.5;
1654
+ const cap = hw * 0.55;
1655
+ const pos = new Float32Array(n * 6 * 3);
1656
+ const col = new Float32Array(n * 6 * 3);
1657
+ // 9 segments × 2 endpoints × 3 = 54 floats per box
1658
+ const linePos = new Float32Array(n * 54);
1659
+ const lineCol = new Float32Array(n * 54);
1660
+ // aes(fill=): a filled box with a dark outline, whiskers, and median.
1661
+ const filled = !!L.fillMapped;
1662
+ const inkRGB = hex2rgb(T.ink2 || '#333333');
1663
+ let p = 0, c = 0, lp = 0, lc = 0;
1664
+ function pushSeg(x0,y0,x1,y1,r,g,b) {
1665
+ linePos[lp++]=x0; linePos[lp++]=y0; linePos[lp++]=0;
1666
+ linePos[lp++]=x1; linePos[lp++]=y1; linePos[lp++]=0;
1667
+ for (let k=0;k<2;k++){ lineCol[lc++]=r; lineCol[lc++]=g; lineCol[lc++]=b; }
1668
+ }
1669
+ for (let i = 0; i < n; i++) {
1670
+ const x = L.x.data[i];
1671
+ const ymin = L.ymin.data[i], lower = L.lower.data[i];
1672
+ const middle = L.middle.data[i], upper = L.upper.data[i];
1673
+ const ymax = L.ymax.data[i];
1674
+ const r = cols[i*3], gch = cols[i*3+1], b = cols[i*3+2];
1675
+ const x0 = x - hw, x1 = x + hw;
1676
+ // box body (white inside when the box has no colour of its own)
1677
+ const tri = [x0,lower,0, x1,lower,0, x1,upper,0, x0,lower,0, x1,upper,0, x0,upper,0];
1678
+ for (let k = 0; k < 18; k++) pos[p++] = tri[k];
1679
+ const [fr, fg, fb] = L.plainFill ? hex2rgb(T.surface) : [r, gch, b];
1680
+ for (let k = 0; k < 6; k++) { col[c++]=fr; col[c++]=fg; col[c++]=fb; }
1681
+ // whisker stem + caps + median, and the box outline
1682
+ const [lr, lg2, lb] = filled ? inkRGB : [r, gch, b];
1683
+ pushSeg(x, ymin, x, lower, lr, lg2, lb);
1684
+ pushSeg(x, upper, x, ymax, lr, lg2, lb);
1685
+ pushSeg(x - cap, ymin, x + cap, ymin, lr, lg2, lb);
1686
+ pushSeg(x - cap, ymax, x + cap, ymax, lr, lg2, lb);
1687
+ pushSeg(x0, middle, x1, middle, lr, lg2, lb);
1688
+ pushSeg(x0, lower, x0, upper, lr, lg2, lb);
1689
+ pushSeg(x1, lower, x1, upper, lr, lg2, lb);
1690
+ pushSeg(x0, lower, x1, lower, lr, lg2, lb);
1691
+ pushSeg(x0, upper, x1, upper, lr, lg2, lb);
1692
+ }
1693
+ const bg = new THREE.BufferGeometry();
1694
+ bg.setAttribute('position', new THREE.BufferAttribute(pos, 3));
1695
+ bg.setAttribute('color', new THREE.BufferAttribute(col, 3));
1696
+ scene.add(new THREE.Mesh(bg, new THREE.MeshBasicMaterial({
1697
+ vertexColors: true, transparent: true,
1698
+ opacity: L.plainFill ? 1 : filled ? Math.min(1, (L.alpha || 0.9) * 0.9) : Math.min(1, (L.alpha||0.9)*0.35),
1699
+ side: THREE.DoubleSide, depthWrite: false })));
1700
+ // Fat lines: WebGL draws plain lines 1 px wide whatever is asked.
1701
+ const lg = new LineSegmentsGeometry();
1702
+ lg.setPositions(linePos);
1703
+ lg.setColors(lineCol);
1704
+ const boxLine = new LineMaterial({
1705
+ vertexColors: true, linewidth: 1.4, worldUnits: false,
1706
+ transparent: true, opacity: L.alpha || 0.95 });
1707
+ lineMats.push(boxLine);
1708
+ scene.add(new LineSegments2(lg, boxLine));
1709
+ // outliers
1710
+ const nOut = L.nOut || 0;
1711
+ if (nOut > 0 && L.ox && L.oy) {
1712
+ const opos = new Float32Array(nOut * 3);
1713
+ const ocol = new Float32Array(nOut * 3);
1714
+ for (let i = 0; i < nOut; i++) {
1715
+ opos[i*3] = L.ox.data[i]; opos[i*3+1] = L.oy.data[i]; opos[i*3+2] = 0;
1716
+ if (L.ocolor) {
1717
+ if (L.ocolor.kind === 'cat') {
1718
+ const p = PAL[L.ocolor.data[i] % PAL.length];
1719
+ ocol[i*3]=p[0]; ocol[i*3+1]=p[1]; ocol[i*3+2]=p[2];
1720
+ } else {
1721
+ const p = rampAt(L.ocolor.data[i] / 65535);
1722
+ ocol[i*3]=p[0]; ocol[i*3+1]=p[1]; ocol[i*3+2]=p[2];
1723
+ }
1724
+ } else if (L.constColor) {
1725
+ const p = hex2rgb(L.constColor);
1726
+ ocol[i*3]=p[0]; ocol[i*3+1]=p[1]; ocol[i*3+2]=p[2];
1727
+ } else {
1728
+ ocol[i*3]=cols[0]; ocol[i*3+1]=cols[1]; ocol[i*3+2]=cols[2];
1729
+ }
1730
+ }
1731
+ const og = new THREE.BufferGeometry();
1732
+ og.setAttribute('position', new THREE.BufferAttribute(opos, 3));
1733
+ og.setAttribute('color', new THREE.BufferAttribute(ocol, 3));
1734
+ scene.add(new THREE.Points(og, new THREE.PointsMaterial({
1735
+ size: L.outlierSize || 3, sizeAttenuation: false, vertexColors: true,
1736
+ transparent: true, opacity: L.alpha || 0.9 })));
1737
+ }
1738
+ } else if (L.kind === 'area' || L.kind === 'poly') {
1739
+ // Filled ribbons (density) or closed polygons (violin).
1740
+ const y0 = (L.y0 != null) ? L.y0 : 0;
1741
+ for (const [s0, cnt] of (L.groups || [[0, n]])) {
1742
+ if (cnt < 2) continue;
1743
+ const r = cols[s0*3], gch = cols[s0*3+1], b = cols[s0*3+2];
1744
+ if (L.polygon) {
1745
+ // geom_polygon / crossbar bodies: any simple shape, concave too,
1746
+ // triangulated by three.js (earcut).
1747
+ if (cnt < 3) continue;
1748
+ const pts = [];
1749
+ for (let i = 0; i < cnt; i++) pts.push(new THREE.Vector2(L.x.data[s0+i], L.y.data[s0+i]));
1750
+ const sg = new THREE.ShapeGeometry(new THREE.Shape(pts));
1751
+ const mesh = new THREE.Mesh(sg, new THREE.MeshBasicMaterial({
1752
+ color: new THREE.Color(r, gch, b), transparent: true,
1753
+ opacity: L.alpha == null ? 1 : L.alpha, side: THREE.DoubleSide, depthWrite: false }));
1754
+ scene.add(mesh);
1755
+ if (isCat) regCat(L.color.data[s0] % S.color.cats.length, mesh);
1756
+ continue;
1757
+ }
1758
+ if (L.kind === 'area') {
1759
+ // Triangle strip under the curve down to baseline y0.
1760
+ const pos = new Float32Array((cnt - 1) * 6 * 3);
1761
+ const col = new Float32Array((cnt - 1) * 6 * 3);
1762
+ let p = 0, c = 0;
1763
+ for (let i = 0; i < cnt - 1; i++) {
1764
+ const i0 = s0 + i, i1 = s0 + i + 1;
1765
+ const x0 = L.x.data[i0], yA = L.y.data[i0];
1766
+ const x1 = L.x.data[i1], yB = L.y.data[i1];
1767
+ const tri = [x0,y0,0, x1,y0,0, x1,yB,0, x0,y0,0, x1,yB,0, x0,yA,0];
1768
+ for (let k = 0; k < 18; k++) pos[p++] = tri[k];
1769
+ for (let k = 0; k < 6; k++) { col[c++]=r; col[c++]=gch; col[c++]=b; }
1770
+ }
1771
+ const g = new THREE.BufferGeometry();
1772
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
1773
+ g.setAttribute('color', new THREE.BufferAttribute(col, 3));
1774
+ const mesh = new THREE.Mesh(g, new THREE.MeshBasicMaterial({
1775
+ vertexColors: true, transparent: true, opacity: L.alpha || 0.35,
1776
+ side: THREE.DoubleSide, depthWrite: false }));
1777
+ scene.add(mesh);
1778
+ if (isCat) regCat(L.color.data[s0] % S.color.cats.length, mesh);
1779
+ } else {
1780
+ // Violin (and closed polys): authoring order is left bottom→top then
1781
+ // right top→bottom. Fan-from-first folds that contour into leaf-like
1782
+ // fragments; pair left/right halves into a triangle strip instead.
1783
+ const half = cnt >> 1;
1784
+ const paired = half >= 2 && half * 2 === cnt;
1785
+ const triCount = paired
1786
+ ? Math.max(0, half - 1) * 2
1787
+ : Math.max(0, cnt - 2);
1788
+ const pos = new Float32Array(triCount * 9);
1789
+ const col = new Float32Array(triCount * 9);
1790
+ // A violin with no colour of its own is white inside (ggplot2).
1791
+ const [pr, pg, pb] = L.plainFill ? hex2rgb(T.surface) : [r, gch, b];
1792
+ let p = 0, c = 0;
1793
+ function pushTri(iA, iB, iC) {
1794
+ const tri = [
1795
+ L.x.data[iA], L.y.data[iA], 0,
1796
+ L.x.data[iB], L.y.data[iB], 0,
1797
+ L.x.data[iC], L.y.data[iC], 0,
1798
+ ];
1799
+ for (let k = 0; k < 9; k++) pos[p++] = tri[k];
1800
+ for (let k = 0; k < 3; k++) { col[c++]=pr; col[c++]=pg; col[c++]=pb; }
1801
+ }
1802
+ if (paired) {
1803
+ for (let i = 0; i < half - 1; i++) {
1804
+ const l0 = s0 + i, l1 = s0 + i + 1;
1805
+ // right side is stored top→bottom, so same-y pair is mirrored
1806
+ const r0 = s0 + cnt - 1 - i, r1 = s0 + cnt - 2 - i;
1807
+ pushTri(l0, r0, l1);
1808
+ pushTri(r0, r1, l1);
1809
+ }
1810
+ } else {
1811
+ for (let i = 1; i < cnt - 1; i++) pushTri(s0, s0 + i, s0 + i + 1);
1812
+ }
1813
+ const g = new THREE.BufferGeometry();
1814
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
1815
+ g.setAttribute('color', new THREE.BufferAttribute(col, 3));
1816
+ const mesh = new THREE.Mesh(g, new THREE.MeshBasicMaterial({
1817
+ vertexColors: true, transparent: true, opacity: L.plainFill ? 1 : (L.alpha || 0.45),
1818
+ side: THREE.DoubleSide, depthWrite: false }));
1819
+ scene.add(mesh);
1820
+ if (isCat) regCat(L.color.data[s0] % S.color.cats.length, mesh);
1821
+ }
1822
+ // Outline stroke
1823
+ const flat = new Float32Array(cnt * 3);
1824
+ for (let i = 0; i < cnt; i++) {
1825
+ flat[i*3] = L.x.data[s0+i];
1826
+ flat[i*3+1] = L.y.data[s0+i];
1827
+ flat[i*3+2] = 0;
1828
+ }
1829
+ const lg = new LineGeometry();
1830
+ lg.setPositions(Array.from(flat));
1831
+ const lm = new LineMaterial({
1832
+ color: new THREE.Color(r, gch, b).getHex(),
1833
+ linewidth: L.linewidth || 1.5, worldUnits: false,
1834
+ transparent: true, opacity: Math.min(1, (L.alpha || 0.9) + 0.3) });
1835
+ lineMats.push(lm);
1836
+ const ln = new Line2(lg, lm);
1837
+ scene.add(ln);
1838
+ if (isCat) regCat(L.color.data[s0] % S.color.cats.length, ln);
1839
+ }
1840
+ } else if (L.frames && L.frames.mode === 'step') {
1841
+ addStepLayer(L, [1, 1, 1], cols);
1842
+ } else if (L.frames && L.frames.x) {
1843
+ addTweenLines(L, [1, 1, 1], cols);
1844
+ } else {
1845
+ L.groups.forEach(([s0, cnt], gi) => {
1846
+ if (cnt < 2) return;
1847
+ const flat = new Float32Array(cnt * 3);
1848
+ for (let i = 0; i < cnt; i++) {
1849
+ flat[i*3] = L.x.data[s0+i]; flat[i*3+1] = L.y.data[s0+i]; flat[i*3+2]=0;
1850
+ }
1851
+ const lg = new LineGeometry();
1852
+ lg.setPositions(Array.from(flat));
1853
+ const rgb = [cols[s0*3], cols[s0*3+1], cols[s0*3+2]];
1854
+ const lm = new LineMaterial({
1855
+ color: new THREE.Color(rgb[0], rgb[1], rgb[2]).getHex(),
1856
+ linewidth: L.linewidth, worldUnits: false,
1857
+ transparent: true, opacity: L.alpha });
1858
+ lineMats.push(lm);
1859
+ const ln = new Line2(lg, lm);
1860
+ const dash = (L.dashes && L.dashes[gi]) || L.dash;
1861
+ if (dash && dash.length >= 2) {
1862
+ // Dash lengths are in world units: size them from the pixels the
1863
+ // panel shows now (they scale with zoom).
1864
+ const wpp = (cam.right - cam.left) / Math.max(1, renderer.domElement.clientWidth || 800);
1865
+ const unit = Math.max(L.linewidth || 1, 1) * wpp;
1866
+ lm.dashed = true;
1867
+ lm.dashSize = dash[0] * unit;
1868
+ lm.gapSize = dash[1] * unit;
1869
+ lm.dashScale = 1;
1870
+ ln.computeLineDistances();
1871
+ }
1872
+ scene.add(ln);
1873
+ if (isCat) regCat(L.color.data[s0] % S.color.cats.length, ln);
1874
+ });
1875
+ }
1876
+ }
1877
+ scene.add = addToScene;
1878
+
1879
+ function drawAxes() {
1880
+ const x0 = dataLo('x') + cam.left * spanOf('x');
1881
+ const x1 = dataLo('x') + cam.right * spanOf('x');
1882
+ const y0 = dataLo('y') + cam.bottom * spanOf('y');
1883
+ const y1 = dataLo('y') + cam.top * spanOf('y');
1884
+ const px = v => M.l + (v - x0) / (x1 - x0) * W;
1885
+ const py = v => M.t + H - (v - y0) / (y1 - y0) * H;
1886
+ let s = '', grid = '';
1887
+ // cap tick count by panel size so labels never collide
1888
+ const xt = VOID2 ? [] : thin(ticksFor('x', x0, x1), Math.max(5, Math.floor(W / 80)));
1889
+ const yt = VOID2 ? [] : thin(ticksFor('y', y0, y1), Math.max(5, Math.floor(H / 40)));
1890
+ const panelFill = T.panel || T.surface;
1891
+ // theme_grey's grey panel: behind the grid and the data.
1892
+ if (String(panelFill).toLowerCase() !== String(T.surface).toLowerCase())
1893
+ grid += `<rect x="${M.l}" y="${M.t}" width="${W}" height="${H}" fill="${panelFill}"/>`;
1894
+ const showGrid = THEME_OPTS.panelGrid !== false &&
1895
+ String(T.grid).toLowerCase() !== String(panelFill).toLowerCase();
1896
+ const xText = THEME_OPTS.xText !== false, yText = THEME_OPTS.yText !== false;
1897
+ for (const [t, lab] of xt) {
1898
+ const X = px(t);
1899
+ if (X < M.l - 1 || X > M.l + W + 1) continue;
1900
+ if (showGrid) grid += `<line x1="${X}" y1="${M.t}" x2="${X}" y2="${M.t+H}" stroke="${T.grid}"/>`;
1901
+ if (!xText) continue;
1902
+ if (X_ANGLE > 0) {
1903
+ const ty = M.t + H + 8;
1904
+ s += `<text x="${X}" y="${ty}" fill="${T.muted}" text-anchor="end" dominant-baseline="middle" transform="rotate(${-X_ANGLE} ${X} ${ty})">${plot3Esc(lab)}</text>`;
1905
+ } else {
1906
+ s += `<text x="${X}" y="${M.t+H+14}" fill="${T.muted}" text-anchor="middle">${plot3Esc(lab)}</text>`;
1907
+ }
1908
+ }
1909
+ for (const [t, lab] of yt) {
1910
+ const Y = py(t);
1911
+ if (Y < M.t - 1 || Y > M.t + H + 1) continue;
1912
+ if (showGrid) grid += `<line x1="${M.l}" y1="${Y}" x2="${M.l+W}" y2="${Y}" stroke="${T.grid}"/>`;
1913
+ if (yText) s += `<text x="${M.l-7}" y="${Y+4}" fill="${T.muted}" text-anchor="end">${plot3Esc(lab)}</text>`;
1914
+ }
1915
+ if (!VOID2) s += `<rect x="${M.l}" y="${M.t}" width="${W}" height="${H}" fill="none" stroke="${T.axis}"/>`;
1916
+ // geom_rug: a short tick at the panel edge for every value.
1917
+ const rugs = S.rugs || [];
1918
+ if (rugs.length) {
1919
+ s += `<clipPath id="p3rugclip"><rect x="${M.l}" y="${M.t}" width="${W}" height="${H}"/></clipPath><g clip-path="url(#p3rugclip)">`;
1920
+ for (const r of rugs) {
1921
+ const onX = r.side === 'b' || r.side === 't';
1922
+ const reach = (r.length || 0.03) * (onX ? H : W);
1923
+ const w = r.width || 0.75, a = r.alpha == null ? 1 : r.alpha;
1924
+ (r.values || []).forEach((v, i) => {
1925
+ const c = r.colors ? r.colors[i] : r.color;
1926
+ if (onX) {
1927
+ const X = px(v);
1928
+ if (X < M.l || X > M.l + W) return;
1929
+ const Y0 = r.side === 'b' ? M.t + H : M.t, Y1 = r.side === 'b' ? Y0 - reach : Y0 + reach;
1930
+ s += `<line x1="${X}" y1="${Y0}" x2="${X}" y2="${Y1}" stroke="${c}" stroke-width="${w}" stroke-opacity="${a}"/>`;
1931
+ } else {
1932
+ const Y = py(v);
1933
+ if (Y < M.t || Y > M.t + H) return;
1934
+ const X0 = r.side === 'l' ? M.l : M.l + W, X1 = r.side === 'l' ? X0 + reach : X0 - reach;
1935
+ s += `<line x1="${X0}" y1="${Y}" x2="${X1}" y2="${Y}" stroke="${c}" stroke-width="${w}" stroke-opacity="${a}"/>`;
1936
+ }
1937
+ });
1938
+ }
1939
+ s += '</g>';
1940
+ }
1941
+ // geom_hline / geom_vline / geom_abline, clipped to the panel so they
1942
+ // follow pan and zoom without spilling into the margins.
1943
+ const refs = S.refs || [];
1944
+ if (refs.length) {
1945
+ s += `<clipPath id="p3refclip"><rect x="${M.l}" y="${M.t}" width="${W}" height="${H}"/></clipPath><g clip-path="url(#p3refclip)">`;
1946
+ for (const r of refs) {
1947
+ let X1, Y1, X2, Y2;
1948
+ if (r.kind === 'hline') { Y1 = Y2 = py(r.value); X1 = M.l; X2 = M.l + W; }
1949
+ else if (r.kind === 'vline') { X1 = X2 = px(r.value); Y1 = M.t; Y2 = M.t + H; }
1950
+ else { X1 = px(x0); X2 = px(x1); Y1 = py(r.intercept + r.slope * x0); Y2 = py(r.intercept + r.slope * x1); }
1951
+ const w = r.width || 1;
1952
+ const dash = (r.dash && r.dash.length) ? ` stroke-dasharray="${r.dash.map(d => d * Math.max(w, 1)).join(' ')}"` : '';
1953
+ s += `<line x1="${X1}" y1="${Y1}" x2="${X2}" y2="${Y2}" stroke="${r.color}" stroke-width="${w}" stroke-opacity="${r.alpha == null ? 1 : r.alpha}"${dash}/>`;
1954
+ }
1955
+ s += '</g>';
1956
+ }
1957
+ // arrow() heads, in screen space so they keep their shape when zooming.
1958
+ for (const r of (S.arrows || [])) {
1959
+ const tx = px(r.x1), ty = py(r.y1), bx = px(r.x0), by = py(r.y0);
1960
+ const d = Math.hypot(tx - bx, ty - by);
1961
+ if (!(d > 1e-6)) continue;
1962
+ const ux = (tx - bx) / d, uy = (ty - by) / d, L = (r.length || 24);
1963
+ const pts = [1, -1].map(sg => {
1964
+ const a = (r.angle || 30) * Math.PI / 180, c = Math.cos(a), sn = sg * Math.sin(a);
1965
+ return [tx - (ux * c - uy * sn) * L, ty - (ux * sn + uy * c) * L];
1966
+ });
1967
+ const w = r.width || 1;
1968
+ if (r.type === 'closed') {
1969
+ s += `<polygon points="${pts[0].join(',')} ${tx},${ty} ${pts[1].join(',')}" fill="${r.color}" stroke="${r.color}" stroke-width="${w}"/>`;
1970
+ } else {
1971
+ for (const p of pts) s += `<line x1="${tx}" y1="${ty}" x2="${p[0]}" y2="${p[1]}" stroke="${r.color}" stroke-width="${w}" stroke-linecap="round"/>`;
1972
+ }
1973
+ }
1974
+ if (!S.facetChild && !VOID2) {
1975
+ // A facet panel leaves the shared axis titles to the figure around it.
1976
+ s += `<text x="${M.l+W/2}" y="${M.t+H+M.b-10}" fill="${T.ink2}" text-anchor="middle">${plot3Esc(withFrame(S.labs.x))}</text>`;
1977
+ s += `<text x="14" y="${M.t+H/2}" fill="${T.ink2}" text-anchor="middle" transform="rotate(-90 14 ${M.t+H/2})">${plot3Esc(withFrame(S.labs.y))}</text>`;
1978
+ }
1979
+ const anns = S.ann || [];
1980
+ const textBoxes = [];
1981
+ for (let i = 0; i < anns.length; i++) {
1982
+ const ann = anns[i];
1983
+ const X = px(ann.x), Y = py(ann.y);
1984
+ if (X < M.l - 2 || X > M.l + W + 2 || Y < M.t - 2 || Y > M.t + H + 2) continue;
1985
+ if (ann.style === 'text' || ann.style === 'label') {
1986
+ // geom_text / geom_label, anchored by hjust/vjust like ggplot2.
1987
+ // 3.88 mm (ggplot2's default) matches this viewer's base font.
1988
+ const size = (ann.size || 14.7) * (13.75 / 14.67);
1989
+ const w = 0.55 * size * String(ann.text).length, h = 1.2 * size;
1990
+ const hj = ann.hjust == null ? 0.5 : ann.hjust, vj = ann.vjust == null ? 0.5 : ann.vjust;
1991
+ const left = X - hj * w, top = Y - (1 - vj) * h;
1992
+ if (ann.overlap === false && textBoxes.some(b =>
1993
+ left < b[0] + b[2] && b[0] < left + w && top < b[1] + b[3] && b[1] < top + h)) continue;
1994
+ textBoxes.push([left, top, w, h]);
1995
+ const col = ann.color || T.ink;
1996
+ if (ann.style === 'label') {
1997
+ const pad = 0.25 * size;
1998
+ s += `<rect x="${left - pad}" y="${top - pad * 0.6}" width="${w + 2 * pad}" height="${h + pad * 1.2}" rx="3" fill="${T.surface}" stroke="${col}" stroke-width="0.8"/>`;
1999
+ }
2000
+ s += `<text x="${left + w / 2}" y="${top + h / 2}" fill="${col}" fill-opacity="${ann.alpha == null ? 1 : ann.alpha}" text-anchor="middle" dominant-baseline="middle" font-size="${size}" font-weight="${ann.weight || 400}"${ann.italic ? ' font-style="italic"' : ''}>${plot3Esc(ann.text)}</text>`;
2001
+ continue;
2002
+ }
2003
+ // Inside the panel, with a halo in the page colour so the label reads
2004
+ // where it crosses the curve (P(X >= 1.96) sits on a thin tail).
2005
+ const halfW = 0.29 * 12 * String(ann.text).length + 3;
2006
+ const AX = Math.min(Math.max(X, M.l + halfW + 2), M.l + W - halfW - 2);
2007
+ const AY = Math.min(Math.max(Y, M.t + 12), M.t + H - 10);
2008
+ s += '<text x="' + AX + '" y="' + AY + '" fill="' + T.ink + '" text-anchor="middle" dominant-baseline="middle" font-size="12" font-weight="600" paint-order="stroke" stroke="' + T.surface + '" stroke-width="4" stroke-linejoin="round">' + plot3Esc(ann.text) + '</text>';
2009
+ }
2010
+ svg.innerHTML = s;
2011
+ if (gridSvg) gridSvg.innerHTML = grid;
2012
+ }
2013
+
2014
+ function layout() {
2015
+ placeLegend();
2016
+ W = Math.max(50, figEl.clientWidth - M.l - M.r);
2017
+ H = Math.max(50, figEl.clientHeight - M.t - M.b);
2018
+ if (equalAspect) applyEqual();
2019
+ host.style.left = M.l + 'px'; host.style.top = M.t + 'px';
2020
+ renderer.setSize(W, H);
2021
+ for (const layer of [svg, gridSvg]) {
2022
+ if (!layer) continue;
2023
+ layer.setAttribute('width', figEl.clientWidth);
2024
+ layer.setAttribute('height', figEl.clientHeight);
2025
+ }
2026
+ for (const lm of lineMats) lm.resolution.set(W, H);
2027
+ draw();
2028
+ }
2029
+ let rafPending = false;
2030
+ function draw() {
2031
+ if (rafPending) return;
2032
+ rafPending = true;
2033
+ requestAnimationFrame(() => {
2034
+ rafPending = false;
2035
+ cam.updateProjectionMatrix();
2036
+ renderer.render(scene, cam);
2037
+ drawAxes();
2038
+ });
2039
+ }
2040
+ redraw = draw;
2041
+
2042
+ // pan / zoom / hover
2043
+ const el = renderer.domElement;
2044
+ el.style.touchAction = 'none';
2045
+ function clampView() {
2046
+ if (equalAspect) {
2047
+ readEqualFromCam();
2048
+ if (equalZoom > 2.4) equalZoom = 2.4;
2049
+ if (equalZoom < 0.01) equalZoom = 0.01;
2050
+ const base = equalBase();
2051
+ const Nx = base.Nx * equalZoom;
2052
+ const Ny = base.Ny * equalZoom;
2053
+ // Keep a slice of the data range on screen. Spans stay locked together.
2054
+ const slack = 0.15;
2055
+ equalCx = Math.min((1 - slack) + Nx / 2, Math.max(slack - Nx / 2, equalCx));
2056
+ equalCy = Math.min((1 - slack) + Ny / 2, Math.max(slack - Ny / 2, equalCy));
2057
+ applyEqual();
2058
+ return;
2059
+ }
2060
+ const MAX = 2.4, LO = -0.7, HI = 1.7;
2061
+ for (const [a, b] of [['left', 'right'], ['bottom', 'top']]) {
2062
+ let span = cam[b] - cam[a];
2063
+ if (span > MAX) {
2064
+ const c = (cam[a] + cam[b]) / 2;
2065
+ cam[a] = c - MAX / 2; cam[b] = c + MAX / 2;
2066
+ }
2067
+ if (cam[a] < LO) { cam[b] += LO - cam[a]; cam[a] = LO; }
2068
+ if (cam[b] > HI) { cam[a] -= cam[b] - HI; cam[b] = HI; }
2069
+ }
2070
+ }
2071
+ let dragging = null;
2072
+ let pointerMoved = false;
2073
+ el.addEventListener('pointerdown', e => {
2074
+ pointerMoved = false;
2075
+ dragging = { x: e.clientX, y: e.clientY,
2076
+ l: cam.left, r: cam.right, t: cam.top, b: cam.bottom };
2077
+ el.setPointerCapture(e.pointerId);
2078
+ });
2079
+ el.addEventListener('pointerup', () => dragging = null);
2080
+ el.addEventListener('pointermove', e => {
2081
+ if (dragging) {
2082
+ if (Math.abs(e.clientX - dragging.x) + Math.abs(e.clientY - dragging.y) > 4)
2083
+ pointerMoved = true;
2084
+ const dx = (e.clientX - dragging.x) / W * (dragging.r - dragging.l);
2085
+ const dy = (e.clientY - dragging.y) / H * (dragging.t - dragging.b);
2086
+ cam.left = dragging.l - dx; cam.right = dragging.r - dx;
2087
+ cam.top = dragging.t + dy; cam.bottom = dragging.b + dy;
2088
+ clampView();
2089
+ tip.style.display = 'none';
2090
+ draw();
2091
+ } else hover(e);
2092
+ });
2093
+ const hintEl = document.getElementById('hint');
2094
+ hintEl.style.background = T.surface + 'e6';
2095
+ hintEl.style.border = '1px solid ' + T.axis;
2096
+ hintEl.style.color = T.ink2;
2097
+ let hintT = 0, hintOff = 0;
2098
+ el.addEventListener('wheel', e => {
2099
+ if (!e.ctrlKey && !e.metaKey) {
2100
+ // let the page scroll; nudge toward the modifier
2101
+ const now = performance.now();
2102
+ if (now - hintT > 1500) {
2103
+ hintT = now;
2104
+ hintEl.textContent = (navigator.platform || '').includes('Mac')
2105
+ ? 'Use \\u2318 + scroll to zoom' : 'Use Ctrl + scroll to zoom';
2106
+ hintEl.style.opacity = '1';
2107
+ clearTimeout(hintOff);
2108
+ hintOff = setTimeout(() => hintEl.style.opacity = '0', 1200);
2109
+ }
2110
+ return;
2111
+ }
2112
+ e.preventDefault();
2113
+ const f = Math.exp(e.deltaY * 0.0015);
2114
+ const r = el.getBoundingClientRect();
2115
+ const cx = cam.left + (e.clientX - r.left) / W * (cam.right - cam.left);
2116
+ const cy = cam.top - (e.clientY - r.top) / H * (cam.top - cam.bottom);
2117
+ cam.left = cx + (cam.left - cx) * f; cam.right = cx + (cam.right - cx) * f;
2118
+ cam.top = cy + (cam.top - cy) * f; cam.bottom = cy + (cam.bottom - cy) * f;
2119
+ clampView();
2120
+ tip.style.display = 'none';
2121
+ draw();
2122
+ }, { passive: false });
2123
+ el.addEventListener('dblclick', () => {
2124
+ if (equalAspect) {
2125
+ equalZoom = 1; equalCx = 0.5; equalCy = 0.5;
2126
+ applyEqual();
2127
+ } else {
2128
+ cam.left = -PAD2; cam.right = 1 + PAD2; cam.bottom = -PAD2; cam.top = 1 + PAD2;
2129
+ }
2130
+ draw();
2131
+ });
2132
+
2133
+ let hoverTick = 0;
2134
+ function dataY(norm) { return fromScale('y', norm); }
2135
+ function dataX(norm) { return fromScale('x', norm); }
2136
+ function screenX(norm) {
2137
+ return (norm - cam.left) / (cam.right - cam.left) * W;
2138
+ }
2139
+ function screenY(norm) {
2140
+ return (cam.top - norm) / (cam.top - cam.bottom) * H;
2141
+ }
2142
+ function catHidden(L, i) {
2143
+ return L.color && L.color.kind === 'cat' &&
2144
+ hiddenCats.has(L.color.data[i] % S.color.cats.length);
2145
+ }
2146
+ function colorHead(L, i) {
2147
+ if (L.color && L.color.kind === 'cat') {
2148
+ const codes = L._ci || L.color.data;
2149
+ return '<b>' + plot3Esc(S.color.cats[codes[i] % S.color.cats.length]) + '</b><br>';
2150
+ }
2151
+ if (L.color && L.color.kind === 'num') {
2152
+ const t = L._cnorm ? L._cnorm[i] : (L.color.data[i] / 65535);
2153
+ return '<b>' + fmt(cval(t)) + '</b><br>';
2154
+ }
2155
+ return '';
2156
+ }
2157
+ function hover(e) {
2158
+ const now = performance.now();
2159
+ if (!S.transition && now - hoverTick < 33) return;
2160
+ hoverTick = now;
2161
+ const r = el.getBoundingClientRect();
2162
+ const mx = e.clientX - r.left, my = e.clientY - r.top;
2163
+ // best: {score, sx, sy, html} — lower score wins; 0 = solid hit
2164
+ let best = null;
2165
+ function consider(score, sx, sy, html, hold) {
2166
+ if (!best || score < best.score) best = { score, sx, sy, html, hold: !!hold };
2167
+ }
2168
+ for (const L of S.layers) {
2169
+ if (L.kind === 'col') {
2170
+ // Full bar rectangle (not just the top-center point).
2171
+ const hw = (L.width || 0.08) * 0.5;
2172
+ const y0 = (L.y0 != null) ? L.y0 : 0;
2173
+ for (let i = 0; i < L.n; i++) {
2174
+ if (catHidden(L, i)) continue;
2175
+ const x = L.x.data[i], y = L.y.data[i];
2176
+ const sx0 = screenX(x - hw), sx1 = screenX(x + hw);
2177
+ const yLo = Math.min(y, y0), yHi = Math.max(y, y0);
2178
+ const sy0 = screenY(yHi), sy1 = screenY(yLo);
2179
+ const left = Math.min(sx0, sx1), right = Math.max(sx0, sx1);
2180
+ const top = Math.min(sy0, sy1), bot = Math.max(sy0, sy1);
2181
+ if (mx < left || mx > right || my < top || my > bot) continue;
2182
+ const sx = (left + right) * 0.5, sy = (top + bot) * 0.5;
2183
+ const xv = dataX(x), yv = dataY(y);
2184
+ consider(0, sx, sy, colorHead(L, i)
2185
+ + fmtSpan('x', xv, (cam.right - cam.left) * spanOf('x')) + ', '
2186
+ + fmtSpan('y', yv, (cam.top - cam.bottom) * spanOf('y')));
2187
+ }
2188
+ } else if (L.kind === 'box') {
2189
+ // Hit full whisker band; tooltip shows all Tukey stats.
2190
+ const hw = (L.width || 0.08) * 0.5;
2191
+ for (let i = 0; i < L.n; i++) {
2192
+ if (catHidden(L, i)) continue;
2193
+ const x = L.x.data[i];
2194
+ const ymin = L.ymin.data[i], lower = L.lower.data[i];
2195
+ const middle = L.middle.data[i], upper = L.upper.data[i];
2196
+ const ymax = L.ymax.data[i];
2197
+ const sx0 = screenX(x - hw), sx1 = screenX(x + hw);
2198
+ const sy0 = screenY(Math.max(ymin, ymax));
2199
+ const sy1 = screenY(Math.min(ymin, ymax));
2200
+ const left = Math.min(sx0, sx1), right = Math.max(sx0, sx1);
2201
+ const top = Math.min(sy0, sy1), bot = Math.max(sy0, sy1);
2202
+ // slight pad so whisker caps are easy to hit
2203
+ if (mx < left - 4 || mx > right + 4 || my < top - 4 || my > bot + 4)
2204
+ continue;
2205
+ const sx = (left + right) * 0.5, sy = (top + bot) * 0.5;
2206
+ const ySpan = (cam.top - cam.bottom) * spanOf('y');
2207
+ const xv = dataX(x);
2208
+ const html = colorHead(L, i)
2209
+ + fmtSpan('x', xv, (cam.right - cam.left) * spanOf('x')) + '<br>'
2210
+ + 'min ' + fmtSpan('y', dataY(ymin), ySpan) + '<br>'
2211
+ + 'q1 ' + fmtSpan('y', dataY(lower), ySpan) + '<br>'
2212
+ + 'median ' + fmtSpan('y', dataY(middle), ySpan) + '<br>'
2213
+ + 'q3 ' + fmtSpan('y', dataY(upper), ySpan) + '<br>'
2214
+ + 'max ' + fmtSpan('y', dataY(ymax), ySpan);
2215
+ consider(0, sx, sy, html);
2216
+ }
2217
+ } else {
2218
+ // Points / line samples / poly vertices — nearest point within its radius.
2219
+ const xs = L._x || (L.x && L.x.data);
2220
+ const ys = L._y || (L.y && L.y.data);
2221
+ if (!xs || !ys) continue;
2222
+ let bestD = Infinity;
2223
+ const nHover = L._stepN != null ? L._stepN : L.n;
2224
+ for (let i = 0; i < nHover; i++) {
2225
+ if (L._alpha && L._alpha[i] <= 0.04) continue;
2226
+ if (L._ci && S.color.cats && hiddenCats.has(L._ci[i] % S.color.cats.length)) continue;
2227
+ if (!L._ci && catHidden(L, i)) continue;
2228
+ const sx = screenX(xs[i]);
2229
+ const sy = screenY(ys[i]);
2230
+ const rad = L._size ? Math.max(10, L._size[i] * 0.55) : 12;
2231
+ const limit = rad * rad;
2232
+ const d = (sx - mx) * (sx - mx) + (sy - my) * (sy - my);
2233
+ if (d <= limit && d < bestD) {
2234
+ bestD = d;
2235
+ const xv = dataX(xs[i]), yv = dataY(ys[i]);
2236
+ const xSpan = (cam.right - cam.left) * spanOf('x');
2237
+ const ySpan = (cam.top - cam.bottom) * spanOf('y');
2238
+ let head = '';
2239
+ if (L.ids && L.ids[i]) head += '<b>' + plot3Esc(L.ids[i]) + '</b><br>';
2240
+ head += colorHead(L, i);
2241
+ let sizeBit = '';
2242
+ if (L._size && L.size && L.size.vmax && L.size.max) {
2243
+ const frac = L._size[i] / L.size.max;
2244
+ const value = frac * frac * L.size.vmax;
2245
+ const name = (S.sizeLegend && S.sizeLegend.label) || 'size';
2246
+ sizeBit = '<br>' + plot3Esc(name) + ' = ' + fmt(value);
2247
+ }
2248
+ const html = L.tip
2249
+ ? head + formulaHead(L)
2250
+ + axisPair('x', fmtSpan('x', xv, xSpan)) + ', '
2251
+ + axisPair('y', fmtSpan('y', yv, ySpan)) + sizeBit
2252
+ : head
2253
+ + fmtSpan('x', xv, xSpan) + ', '
2254
+ + fmtSpan('y', yv, ySpan) + sizeBit;
2255
+ consider(d, sx, sy, html, !!(L.kind === 'point' && L.frames && S.transition));
2256
+ }
2257
+ }
2258
+ }
2259
+ }
2260
+ hoverHold = !!(best && best.hold);
2261
+ if (!best) { tip.style.display = 'none'; return; }
2262
+ tip.innerHTML = best.html;
2263
+ plot3Typeset(tip);
2264
+ tip.style.left = (M.l + best.sx + 12) + 'px';
2265
+ tip.style.top = (M.t + best.sy - 10) + 'px';
2266
+ tip.style.display = 'block';
2267
+ }
2268
+ el.addEventListener('pointerleave', () => {
2269
+ tip.style.display = 'none';
2270
+ hoverHold = false;
2271
+ });
2272
+ el.addEventListener('click', (e) => {
2273
+ if (!S.transition) return;
2274
+ if (pointerMoved) return;
2275
+ const r = el.getBoundingClientRect();
2276
+ const mx = e.clientX - r.left, my = e.clientY - r.top;
2277
+ let hit = null, hitD = Infinity;
2278
+ for (const L of S.layers) {
2279
+ if (L.kind !== 'point' || !L.frames || !L._x) continue;
2280
+ for (let i = 0; i < L.n; i++) {
2281
+ if (L._alpha && L._alpha[i] <= 0.04) continue;
2282
+ const sx = screenX(L._x[i]), sy = screenY(L._y[i]);
2283
+ const rad = L._size ? Math.max(10, L._size[i] * 0.55) : 12;
2284
+ const d = (sx - mx) * (sx - mx) + (sy - my) * (sy - my);
2285
+ if (d <= rad * rad && d < hitD) { hitD = d; hit = { L, i }; }
2286
+ }
2287
+ }
2288
+ if (!hit) { clearTrail(); draw(); return; }
2289
+ const key = String(hit.i) + '|' + (hit.L.ids ? hit.L.ids[hit.i] : '');
2290
+ if (trailId === key) { clearTrail(); draw(); return; }
2291
+ setTrail(hit.L, hit.i);
2292
+ draw();
2293
+ });
2294
+
2295
+ installPlayer(() => draw());
2296
+ new ResizeObserver(layout).observe(figEl);
2297
+ layout();
2298
+ renderNow = () => {
2299
+ cam.updateProjectionMatrix();
2300
+ renderer.render(scene, cam);
2301
+ drawAxes();
2302
+ };
2303
+
2304
+ } else {
2305
+ // ═════════════════════════ 3D: orbit viewer ═════════════════════════════
2306
+ host.style.left = '0'; host.style.top = '0';
2307
+ // FOV 60 matches pcviz; near/far refit after we know the scene radius.
2308
+ const cam = new THREE.PerspectiveCamera(60, 1, 0.01, 100);
2309
+ cam.up.set(0, 0, 1);
2310
+ const coord = S.coord || { aspect: 'data', sizeMode: 'scene' };
2311
+ // proportional cube: preserve data aspect, or equal axes
2312
+ const spans = axesList.map(a => spanOf(a));
2313
+ const maxSpan = Math.max(...spans, 1e-12);
2314
+ // aspect "auto" sends its box sides (a tall cloud is shortened).
2315
+ const ext = (coord.ext && coord.ext.length === 3)
2316
+ ? coord.ext.map(Number)
2317
+ : (coord.aspect === 'equal')
2318
+ ? axesList.map(() => 1)
2319
+ : axesList.map((a, i) => spans[i] / maxSpan);
2320
+ const sizeAtten = coord.sizeMode !== 'screen';
2321
+ // Soft lighting for surface meshes (added once).
2322
+ // Physical light units divide by pi; colours are exact sRGB, so a face
2323
+ // toward the light shows its own colour and a face away about 80% of it.
2324
+ scene.add(new THREE.AmbientLight(0xffffff, 0.8 * Math.PI));
2325
+ const dirLight = new THREE.DirectionalLight(0xffffff, 0.3 * Math.PI);
2326
+ dirLight.position.set(1.2, 0.8, 1.5);
2327
+ scene.add(dirLight);
2328
+
2329
+ function pointMaterial(opts) {
2330
+ // depthWrite on opaque marks keeps dense lidar crisp; transparent when alpha<1.
2331
+ const a = opts.opacity != null ? opts.opacity : 1;
2332
+ return new THREE.PointsMaterial({
2333
+ ...opts,
2334
+ // Round points, as saved files draw them, not WebGL's squares.
2335
+ size: opts.size / 0.875,
2336
+ map: shapeTexture('circle'),
2337
+ alphaTest: 0.3,
2338
+ sizeAttenuation: sizeAtten,
2339
+ transparent: a < 0.999,
2340
+ depthWrite: a >= 0.999,
2341
+ });
2342
+ }
2343
+
2344
+ for (const L of S.layers) {
2345
+ if (L.blank) continue; // geom_blank: on the scales only
2346
+ const n = L.n;
2347
+ const cols = layerColors(L, hex2rgb(T.cat[0]));
2348
+ const isCat = L.color && L.color.kind === 'cat';
2349
+ const pos = new Float32Array(n * 3);
2350
+ for (let i = 0; i < n; i++) {
2351
+ pos[i*3] = L.x.data[i] * ext[0];
2352
+ pos[i*3+1] = L.y.data[i] * ext[1];
2353
+ pos[i*3+2] = L.z.data[i] * ext[2];
2354
+ }
2355
+ if (L.kind === 'point' && (L.frames || (L.size && L.size.id) || (L.opacity && L.opacity.id))) {
2356
+ addBubbleLayer(L, ext, sizeAtten);
2357
+ } else if (L.kind === 'point') {
2358
+ // Default size is set in Python for unit-cube scene (pcviz-relative).
2359
+ const psz = (typeof L.size === 'number') ? L.size : 0.001;
2360
+ if (isCat) {
2361
+ const k = S.color.cats.length;
2362
+ const buckets = Array.from({ length: k }, () => []);
2363
+ for (let i = 0; i < n; i++) buckets[L.color.data[i] % k].push(i);
2364
+ buckets.forEach((idx, ci) => {
2365
+ if (!idx.length) return;
2366
+ const sub = new Float32Array(idx.length * 3);
2367
+ for (let j = 0; j < idx.length; j++) {
2368
+ const i = idx[j];
2369
+ sub[j*3] = pos[i*3]; sub[j*3+1] = pos[i*3+1]; sub[j*3+2] = pos[i*3+2];
2370
+ }
2371
+ const g = new THREE.BufferGeometry();
2372
+ g.setAttribute('position', new THREE.BufferAttribute(sub, 3));
2373
+ const pt = new THREE.Points(g, pointMaterial({
2374
+ color: S.color.palette[ci], size: psz, opacity: L.alpha }));
2375
+ scene.add(pt);
2376
+ regCat(ci, pt);
2377
+ });
2378
+ } else {
2379
+ const g = new THREE.BufferGeometry();
2380
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
2381
+ g.setAttribute('color', new THREE.BufferAttribute(cols, 3));
2382
+ scene.add(new THREE.Points(g, pointMaterial({
2383
+ size: psz, vertexColors: true, opacity: L.alpha })));
2384
+ }
2385
+ } else if (L.kind === 'surface' || L.kind === 'isosurface') {
2386
+ const g = new THREE.BufferGeometry();
2387
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
2388
+ g.setAttribute('color', new THREE.BufferAttribute(cols, 3));
2389
+ if (L.indices && L.indices.data) {
2390
+ g.setIndex(new THREE.BufferAttribute(L.indices.data, 1));
2391
+ }
2392
+ g.computeVertexNormals();
2393
+ const baseAlpha = L.alpha != null ? L.alpha : (L.kind === 'isosurface' ? 0.55 : 0.95);
2394
+ if (L.wireframe) {
2395
+ const rgb = L.constColor ? hex2rgb(L.constColor) : hex2rgb(T.ink2 || '#c3c2b7');
2396
+ const wf = new THREE.WireframeGeometry(g);
2397
+ const wire = new THREE.LineSegments(wf, new THREE.LineBasicMaterial({
2398
+ color: new THREE.Color(rgb[0], rgb[1], rgb[2]),
2399
+ transparent: true, opacity: baseAlpha }));
2400
+ scene.add(wire);
2401
+ if (L.frames && L.kind === 'surface') registerSurface(L, ext, g, pos, wire);
2402
+ } else {
2403
+ scene.add(new THREE.Mesh(g, new THREE.MeshLambertMaterial({
2404
+ vertexColors: true, transparent: true, opacity: baseAlpha,
2405
+ side: THREE.DoubleSide, depthWrite: L.kind !== 'isosurface' })));
2406
+ if (L.frames && L.kind === 'surface') registerSurface(L, ext, g, pos, null);
2407
+ }
2408
+ } else if (L.frames && L.frames.mode === 'step') {
2409
+ addStepLayer(L, ext, cols);
2410
+ } else if (L.frames && L.frames.x) {
2411
+ addTweenLines(L, ext, cols);
2412
+ } else {
2413
+ for (const [s0, cnt] of (L.groups || [[0, n]])) {
2414
+ if (cnt < 2) continue;
2415
+ const lg = new LineGeometry();
2416
+ lg.setPositions(Array.from(pos.subarray(s0*3, (s0+cnt)*3)));
2417
+ const lm = new LineMaterial({
2418
+ color: new THREE.Color(cols[s0*3], cols[s0*3+1], cols[s0*3+2]).getHex(),
2419
+ linewidth: L.linewidth, worldUnits: false,
2420
+ transparent: true, opacity: L.alpha });
2421
+ lineMats.push(lm);
2422
+ const ln = new Line2(lg, lm);
2423
+ scene.add(ln);
2424
+ if (isCat) regCat(L.color.data[s0] % S.color.cats.length, ln);
2425
+ }
2426
+ }
2427
+ }
2428
+
2429
+ // Axes box as matplotlib and plotly draw it: the three far walls with
2430
+ // grid lines, refreshed as the camera orbits. The edges at the near
2431
+ // corner are hidden so they never cross the data.
2432
+ const boxMat = new THREE.LineBasicMaterial({ color: T.axis });
2433
+ const gridMat = new THREE.LineBasicMaterial({ color: T.grid });
2434
+ const cornerAt = (i) => new THREE.Vector3(
2435
+ (i & 1) * ext[0], ((i >> 1) & 1) * ext[1], ((i >> 2) & 1) * ext[2]);
2436
+ // theme_lidar (void): the data alone, no box, grid, or labels.
2437
+ const VOID = !!T.void;
2438
+ const boxEdges = (VOID ? [] : [[0,1],[1,3],[3,2],[2,0],[4,5],[5,7],[7,6],[6,4],
2439
+ [0,4],[1,5],[2,6],[3,7]]).map(([a, b]) => {
2440
+ const o = new THREE.LineSegments(
2441
+ new THREE.BufferGeometry().setFromPoints([cornerAt(a), cornerAt(b)]), boxMat);
2442
+ scene.add(o);
2443
+ return { a, b, axis: Math.round(Math.log2(a ^ b)), o };
2444
+ });
2445
+ function axisTicks(ax) {
2446
+ const sc = S.scales[ax];
2447
+ return sc.kind === 'cat'
2448
+ ? sc.cats.map((c, i) => [i, c])
2449
+ : (sc.ticks || (sc.ladder ? sc.ladder[0] : []));
2450
+ }
2451
+ const showGrid = !VOID && !(S.themeOpts && S.themeOpts.panelGrid === false) &&
2452
+ String(T.grid).toLowerCase() !== String(T.surface).toLowerCase();
2453
+ const walls = [0, 1, 2].map(wall => [0, 1].map(side => {
2454
+ const pts = [];
2455
+ if (showGrid) {
2456
+ for (let k = 0; k < 3; k++) {
2457
+ if (k === wall) continue;
2458
+ const other = 3 - wall - k;
2459
+ for (const [t] of axisTicks(axesList[k])) {
2460
+ const u = (t - dataLo(axesList[k])) / spanOf(axesList[k]);
2461
+ if (!(u > 0.001 && u < 0.999)) continue;
2462
+ const a = [0, 0, 0];
2463
+ a[wall] = side * ext[wall];
2464
+ a[k] = u * ext[k];
2465
+ const b = a.slice();
2466
+ b[other] = ext[other];
2467
+ pts.push(new THREE.Vector3(a[0], a[1], a[2]), new THREE.Vector3(b[0], b[1], b[2]));
2468
+ }
2469
+ }
2470
+ }
2471
+ const o = new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(pts), gridMat);
2472
+ scene.add(o);
2473
+ return o;
2474
+ }));
2475
+ function updateBox() {
2476
+ // A wall is a far wall when the camera is on the box side of its plane.
2477
+ const back = [0, 1, 2].map(k => cam.position.getComponent(k) > ext[k] / 2 ? 0 : 1);
2478
+ walls.forEach((pair, k) => pair.forEach((o, side) => { o.visible = side === back[k]; }));
2479
+ for (const e of boxEdges) {
2480
+ let far = false;
2481
+ for (let k = 0; k < 3; k++) {
2482
+ if (k !== e.axis && ((e.a >> k) & 1) === back[k]) far = true;
2483
+ }
2484
+ e.o.visible = far;
2485
+ }
2486
+ }
2487
+ const tickSprites = [[], [], []];
2488
+
2489
+ function sprite(text, small) {
2490
+ const c = document.createElement('canvas');
2491
+ const ctx = c.getContext('2d');
2492
+ const fs = small ? 22 : 26;
2493
+ ctx.font = fs + 'px system-ui';
2494
+ c.width = Math.max(2, Math.ceil(ctx.measureText(text).width) + 8);
2495
+ c.height = fs + 10;
2496
+ const ctx2 = c.getContext('2d');
2497
+ ctx2.font = fs + 'px system-ui';
2498
+ ctx2.fillStyle = small ? T.muted : T.ink2;
2499
+ ctx2.textBaseline = 'middle';
2500
+ ctx2.fillText(text, 4, c.height / 2);
2501
+ const tex = new THREE.CanvasTexture(c);
2502
+ tex.colorSpace = THREE.NoColorSpace;
2503
+ const sp = new THREE.Sprite(new THREE.SpriteMaterial({
2504
+ map: tex, depthTest: false, transparent: true }));
2505
+ const k = small ? 0.0016 : 0.0019;
2506
+ sp.scale.set(c.width * k, c.height * k, 1);
2507
+ return sp;
2508
+ }
2509
+ function tickPos(ax, t) { // data -> cube coords on min edges
2510
+ return (t - dataLo(ax)) / spanOf(ax);
2511
+ }
2512
+ const off = 0.055;
2513
+ (VOID ? [] : axesList).forEach((ax, ai) => {
2514
+ const sc = S.scales[ax];
2515
+ const ticks = sc.kind === 'cat'
2516
+ ? sc.cats.map((c, i) => [i, c])
2517
+ : (sc.ticks || (sc.ladder ? sc.ladder[0] : []));
2518
+ for (const [t, lab] of ticks) {
2519
+ const u = tickPos(ax, t);
2520
+ if (u < -0.001 || u > 1.001) continue;
2521
+ const p = [[u*ext[0], -off*ext[1], 0], [-off*ext[0], u*ext[1], 0],
2522
+ [-off*ext[0], 0, u*ext[2]]][ai];
2523
+ const sp = sprite(String(lab), true);
2524
+ sp.position.set(p[0], p[1], p[2]);
2525
+ scene.add(sp);
2526
+ tickSprites[ai].push({ sp, t: +t });
2527
+ }
2528
+ const lp = [[0.5*ext[0], -2.6*off*ext[1], 0],
2529
+ [-2.6*off*ext[0], 0.5*ext[1], 0],
2530
+ [-2.6*off*ext[0], 0, 0.55*ext[2]]][ai];
2531
+ const rawLab = S.labs[ax] == null ? '' : String(S.labs[ax]);
2532
+ const tl = sprite(withFrame(rawLab), false);
2533
+ tl.position.set(lp[0], lp[1], lp[2]);
2534
+ scene.add(tl);
2535
+ if (rawLab.indexOf('{frame_time}') >= 0) {
2536
+ axisTitleSprites.push({ sprite: tl, raw: rawLab, shown: withFrame(rawLab) });
2537
+ }
2538
+ });
2539
+ refreshFrameLabels = () => {
2540
+ for (const item of axisTitleSprites) {
2541
+ const text = withFrame(item.raw);
2542
+ if (text === item.shown) continue;
2543
+ item.shown = text;
2544
+ const next = sprite(text, false);
2545
+ item.sprite.material.map.dispose();
2546
+ item.sprite.material.map = next.material.map;
2547
+ item.sprite.scale.copy(next.scale);
2548
+ next.material.map = null;
2549
+ next.material.dispose();
2550
+ }
2551
+ };
2552
+
2553
+ // Fit like pcviz: bounding sphere of the scene cube, camera on a soft orbit.
2554
+ const ctr = new THREE.Vector3(ext[0]/2, ext[1]/2, ext[2]/2);
2555
+ const rad = Math.max(
2556
+ Math.sqrt(ext[0]*ext[0] + ext[1]*ext[1] + ext[2]*ext[2]) / 2, 1e-3);
2557
+ // pcviz: position at ~1.4R from centre on a diagonal; far = 20R
2558
+ // coord_3d(elev=, azim=, zoom=) sends the opening direction and zoom.
2559
+ const camSpec = coord.camera || {};
2560
+ const dist = rad * 1.25 / Math.tan((cam.fov * Math.PI / 180) / 2) / (camSpec.zoom > 0 ? camSpec.zoom : 1);
2561
+ const dir = (camSpec.dir && camSpec.dir.length === 3)
2562
+ ? new THREE.Vector3(camSpec.dir[0], camSpec.dir[1], camSpec.dir[2]).normalize()
2563
+ : new THREE.Vector3(0.55, -0.85, 0.5).normalize();
2564
+ cam.position.copy(ctr.clone().add(dir.multiplyScalar(dist)));
2565
+ cam.near = Math.max(rad / 200, 1e-4);
2566
+ cam.far = rad * 40;
2567
+ cam.updateProjectionMatrix();
2568
+ const controls = new OrbitControls(cam, renderer.domElement);
2569
+ controls.target.copy(ctr);
2570
+ controls.enableDamping = true;
2571
+ controls.update();
2572
+ window.__plot3.camera = cam;
2573
+ window.__plot3.controls = controls;
2574
+ if (SAVED && SAVED.camera) {
2575
+ const shot = SAVED.camera;
2576
+ if (shot.position && shot.position.length === 3)
2577
+ cam.position.set(+shot.position[0], +shot.position[1], +shot.position[2]);
2578
+ if (shot.target && shot.target.length === 3)
2579
+ controls.target.set(+shot.target[0], +shot.target[1], +shot.target[2]);
2580
+ cam.updateProjectionMatrix();
2581
+ controls.update();
2582
+ }
2583
+
2584
+ // ── 3D hover: nearest point (screen-space) for point layers ────────────
2585
+ const tip3 = document.getElementById('tip');
2586
+ tip3.style.background = T.surface;
2587
+ tip3.style.border = '1px solid ' + T.axis;
2588
+ tip3.style.color = T.ink;
2589
+ const pickLayers = S.layers
2590
+ .map((L, li) => ({ L, li }))
2591
+ .filter(({ L }) => L.n > 0 && !L.blank && L.x && L.y && L.z &&
2592
+ (L.kind === 'point' || L.kind === 'surface' || L.tip));
2593
+ let hover3Tick = 0;
2594
+ function hover3d(e) {
2595
+ const now = performance.now();
2596
+ if (!S.transition && now - hover3Tick < 40) return;
2597
+ hover3Tick = now;
2598
+ if (!pickLayers.length) { tip3.style.display = 'none'; return; }
2599
+ const rect = renderer.domElement.getBoundingClientRect();
2600
+ const mx = e.clientX - rect.left, my = e.clientY - rect.top;
2601
+ const w = Math.max(rect.width, 1), h = Math.max(rect.height, 1);
2602
+ // Project each point; find nearest in screen px (cap search for big clouds).
2603
+ let best = null, bestD = Infinity;
2604
+ const maxScan = 80000;
2605
+ for (const { L } of pickLayers) {
2606
+ const n = L._stepN != null ? L._stepN : L.n;
2607
+ const step = n > maxScan ? Math.ceil(n / maxScan) : 1;
2608
+ const xs = L._x || L.x.data;
2609
+ const ys = L._y || L.y.data;
2610
+ const zs = L._z || L.z.data;
2611
+ for (let i = 0; i < n; i += step) {
2612
+ if (L._alpha && L._alpha[i] <= 0.04) continue;
2613
+ const codes = L._ci || (L.color && L.color.kind === 'cat' ? L.color.data : null);
2614
+ if (codes && S.color.cats && hiddenCats.has(codes[i] % S.color.cats.length)) continue;
2615
+ const v = new THREE.Vector3(
2616
+ xs[i] * ext[0],
2617
+ ys[i] * ext[1],
2618
+ zs[i] * ext[2]
2619
+ );
2620
+ v.project(cam);
2621
+ if (v.z < -1 || v.z > 1) continue;
2622
+ const sx = (v.x * 0.5 + 0.5) * w;
2623
+ const sy = (-v.y * 0.5 + 0.5) * h;
2624
+ const rad = (L._size && !sizeAtten) ? Math.max(14, L._size[i] * 0.55) : 14;
2625
+ const d = (sx - mx) * (sx - mx) + (sy - my) * (sy - my);
2626
+ if (d <= rad * rad && d < bestD) {
2627
+ bestD = d; best = [L, i, sx, sy, xs[i], ys[i], zs[i]];
2628
+ }
2629
+ }
2630
+ }
2631
+ hoverHold = !!(best && best[0].kind === 'point' && best[0].frames && S.transition);
2632
+ if (!best) { tip3.style.display = 'none'; return; }
2633
+ const [L, i, sx, sy, xn, yn, zn] = best;
2634
+ const xv = fromScale('x', xn);
2635
+ const yv = fromScale('y', yn);
2636
+ const zv = fromScale('z', zn);
2637
+ let head = '';
2638
+ if (L.ids && L.ids[i]) head += '<b>' + plot3Esc(L.ids[i]) + '</b><br>';
2639
+ const codes = L._ci || (L.color && L.color.kind === 'cat' ? L.color.data : null);
2640
+ if (codes && S.color.cats)
2641
+ head += '<b>' + plot3Esc(S.color.cats[codes[i] % S.color.cats.length]) + '</b><br>';
2642
+ else if (L.color && L.color.kind === 'num') {
2643
+ const t = L._cnorm ? L._cnorm[i] : (L.color.data[i] / 65535);
2644
+ head += '<b>' + fmt(cval(t)) + '</b><br>';
2645
+ }
2646
+ let sizeBit = '';
2647
+ if (L._size && L.size && L.size.vmax && L.size.max) {
2648
+ const frac = L._size[i] / L.size.max;
2649
+ const value = frac * frac * L.size.vmax;
2650
+ const name = (S.sizeLegend && S.sizeLegend.label) || 'size';
2651
+ sizeBit = '<br>' + plot3Esc(name) + ' = ' + fmt(value);
2652
+ }
2653
+ tip3.innerHTML = L.tip
2654
+ ? head + formulaHead(L)
2655
+ + axisPair('x', fmt(xv)) + ', '
2656
+ + axisPair('y', fmt(yv)) + ', '
2657
+ + axisPair('z', fmt(zv)) + sizeBit
2658
+ : head + fmt(xv) + ', ' + fmt(yv) + ', ' + fmt(zv) + sizeBit;
2659
+ plot3Typeset(tip3);
2660
+ tip3.style.left = Math.min(w - 8, sx + 12) + 'px';
2661
+ tip3.style.top = Math.max(8, sy - 10) + 'px';
2662
+ tip3.style.display = 'block';
2663
+ }
2664
+ function fmt(v) {
2665
+ if (!Number.isFinite(v)) return String(v);
2666
+ const a = Math.abs(v);
2667
+ if (a >= 1e6 || (a > 0 && a < 1e-3)) return v.toExponential(2);
2668
+ return (Math.round(v * 1e4) / 1e4).toString();
2669
+ }
2670
+ // colour value for continuous ramp (same helper as 2D tip when present)
2671
+ function cval(t) {
2672
+ if (S.color && S.color.kind === 'num') {
2673
+ const lo = S.color.lo, hi = S.color.hi;
2674
+ return lo + t * (hi - lo);
2675
+ }
2676
+ return t;
2677
+ }
2678
+ renderer.domElement.addEventListener('pointermove', hover3d);
2679
+ renderer.domElement.addEventListener('pointerleave', () => {
2680
+ tip3.style.display = 'none';
2681
+ hoverHold = false;
2682
+ });
2683
+ let pointerMoved3 = false;
2684
+ let down3 = null;
2685
+ renderer.domElement.addEventListener('pointerdown', (e) => {
2686
+ pointerMoved3 = false;
2687
+ down3 = { x: e.clientX, y: e.clientY };
2688
+ });
2689
+ renderer.domElement.addEventListener('pointermove', (e) => {
2690
+ if (!down3) return;
2691
+ if (Math.abs(e.clientX - down3.x) + Math.abs(e.clientY - down3.y) > 4)
2692
+ pointerMoved3 = true;
2693
+ });
2694
+ renderer.domElement.addEventListener('pointerup', () => { down3 = null; });
2695
+ renderer.domElement.addEventListener('click', (e) => {
2696
+ if (!S.transition || pointerMoved3) return;
2697
+ const rect = renderer.domElement.getBoundingClientRect();
2698
+ const mx = e.clientX - rect.left, my = e.clientY - rect.top;
2699
+ const w = Math.max(rect.width, 1), h = Math.max(rect.height, 1);
2700
+ let hit = null, hitD = Infinity;
2701
+ for (const L of S.layers) {
2702
+ if (L.kind !== 'point' || !L.frames || !L._x || !L._z) continue;
2703
+ for (let i = 0; i < L.n; i++) {
2704
+ if (L._alpha && L._alpha[i] <= 0.04) continue;
2705
+ const v = new THREE.Vector3(L._x[i] * ext[0], L._y[i] * ext[1], L._z[i] * ext[2]);
2706
+ v.project(cam);
2707
+ const sx = (v.x * 0.5 + 0.5) * w;
2708
+ const sy = (-v.y * 0.5 + 0.5) * h;
2709
+ const rad = (L._size && !sizeAtten) ? Math.max(16, L._size[i] * 0.55) : 16;
2710
+ const d = (sx - mx) * (sx - mx) + (sy - my) * (sy - my);
2711
+ if (d <= rad * rad && d < hitD) { hitD = d; hit = { L, i }; }
2712
+ }
2713
+ }
2714
+ if (!hit) { clearTrail(); return; }
2715
+ const key = String(hit.i) + '|' + (hit.L.ids ? hit.L.ids[hit.i] : '');
2716
+ if (trailId === key) { clearTrail(); return; }
2717
+ setTrail(hit.L, hit.i);
2718
+ });
2719
+
2720
+ function layout() {
2721
+ placeLegend();
2722
+ const w = Math.max(figEl.clientWidth, 1), h = Math.max(figEl.clientHeight, 1);
2723
+ renderer.setSize(w, h);
2724
+ cam.aspect = w / h;
2725
+ cam.updateProjectionMatrix();
2726
+ for (const lm of lineMats) lm.resolution.set(w, h);
2727
+ }
2728
+ // Tick labels on a foreshortened edge crowd together: keep every k-th,
2729
+ // the smallest k whose labels do not touch, on round values when the
2730
+ // ticks allow, and drop any that land on another axis's labels.
2731
+ const tickTmp = new THREE.Vector3();
2732
+ function tickRect(sp, w, h) {
2733
+ tickTmp.copy(sp.position).project(cam);
2734
+ const sx = (tickTmp.x * 0.5 + 0.5) * w, sy = (-tickTmp.y * 0.5 + 0.5) * h;
2735
+ tickTmp.copy(sp.position).applyMatrix4(cam.matrixWorldInverse);
2736
+ const depth = Math.max(-tickTmp.z, 1e-6);
2737
+ const perUnit = h / (2 * Math.tan(cam.fov * Math.PI / 360) * depth);
2738
+ const pw = sp.scale.x * perUnit * 0.8, ph = sp.scale.y * perUnit * 0.75;
2739
+ return [sx - pw / 2, sy - ph / 2, pw, ph];
2740
+ }
2741
+ const touches = (a, b, air) => a[0] - air < b[0] + b[2] && b[0] - air < a[0] + a[2] &&
2742
+ a[1] - air < b[1] + b[3] && b[1] - air < a[1] + a[3];
2743
+ function declutterTicks() {
2744
+ const w = Math.max(figEl.clientWidth, 1), h = Math.max(figEl.clientHeight, 1);
2745
+ const placed = [];
2746
+ for (const marks of tickSprites) {
2747
+ if (!marks.length) continue;
2748
+ const rects = marks.map(m => tickRect(m.sp, w, h));
2749
+ const air = Math.max(3, 0.5 * (rects[0] ? rects[0][3] : 0));
2750
+ let keep = null;
2751
+ for (let k = 1; k <= marks.length && !keep; k++) {
2752
+ const offsets = [];
2753
+ const step = marks.length > 1 ? Math.abs(marks[1].t - marks[0].t) * k : 0;
2754
+ for (let o = 0; o < k; o++) {
2755
+ const r = step > 0 ? Math.abs(marks[o].t / step - Math.round(marks[o].t / step)) : 1;
2756
+ if (r < 1e-6) offsets.unshift(o); else offsets.push(o);
2757
+ }
2758
+ for (const o of offsets) {
2759
+ let ok = true;
2760
+ for (let i = o; i + k < marks.length; i += k) {
2761
+ if (touches(rects[i], rects[i + k], air)) { ok = false; break; }
2762
+ }
2763
+ if (ok) { keep = [o, k]; break; }
2764
+ }
2765
+ }
2766
+ if (!keep) keep = [0, marks.length];
2767
+ marks.forEach((m, i) => {
2768
+ let on = i >= keep[0] && (i - keep[0]) % keep[1] === 0;
2769
+ if (on && placed.some(r => touches(rects[i], r, 0))) on = false;
2770
+ if (on) placed.push(rects[i]);
2771
+ m.sp.visible = on;
2772
+ });
2773
+ }
2774
+ }
2775
+ function frame3d() {
2776
+ controls.update();
2777
+ updateBox();
2778
+ declutterTicks();
2779
+ renderer.render(scene, cam);
2780
+ }
2781
+ installPlayer(() => {});
2782
+ new ResizeObserver(layout).observe(figEl);
2783
+ layout();
2784
+ (function loop() {
2785
+ frame3d();
2786
+ requestAnimationFrame(loop);
2787
+ })();
2788
+ renderNow = frame3d;
2789
+ }
2790
+
2791
+ // Save menu. PNG is the picture on screen. SVG keeps the axis overlay as
2792
+ // vectors and embeds the WebGL layer, which is already a raster.
2793
+ function installSave() {
2794
+ const bar = document.getElementById('modebar');
2795
+ const btn = document.getElementById('save-btn');
2796
+ const menu = document.getElementById('save-menu');
2797
+ if (!bar || !btn || !menu) return;
2798
+ btn.style.background = T.surface;
2799
+ btn.style.color = T.ink;
2800
+ btn.style.border = '1px solid ' + T.axis;
2801
+ menu.style.background = T.surface;
2802
+ menu.style.color = T.ink;
2803
+ menu.style.border = '1px solid ' + T.axis;
2804
+ const svgHint = menu.querySelector('[data-act="svg"] .save-d');
2805
+ if (svgHint && S.is3d) svgHint.textContent = 'Vector axes';
2806
+ const videoItem = menu.querySelector('[data-act="video"]');
2807
+ const canRecord = !!(S.transition && S.transition.nFrames > 1
2808
+ && typeof MediaRecorder !== 'undefined' && renderer.domElement.captureStream);
2809
+ if (videoItem) videoItem.hidden = !canRecord;
2810
+ bar.addEventListener('pointerdown', (ev) => ev.stopPropagation());
2811
+
2812
+ function slug(raw, limit) {
2813
+ let stem = '';
2814
+ const cap = limit || 60;
2815
+ for (let i = 0; i < raw.length && stem.length < cap; i++) {
2816
+ const c = raw.charAt(i);
2817
+ const ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
2818
+ (c >= '0' && c <= '9') || c === '.' || c === '_' || c === '-';
2819
+ if (ok) stem += c;
2820
+ else if (stem && stem.charAt(stem.length - 1) !== '_') stem += '_';
2821
+ }
2822
+ while (stem.charAt(0) === '_') stem = stem.slice(1);
2823
+ while (stem && stem.charAt(stem.length - 1) === '_') stem = stem.slice(0, -1);
2824
+ return stem;
2825
+ }
2826
+ function fileStem() {
2827
+ let raw = (S.labs && S.labs.title) ? String(S.labs.title) : '';
2828
+ const hasToken = raw.indexOf('{frame_time}') >= 0;
2829
+ if (frameLabel && hasToken) raw = raw.split('{frame_time}').join(frameLabel);
2830
+ let stem = slug(raw, 60) || 'plot3';
2831
+ if (frameLabel && !hasToken && (S.transition || S.slider)) {
2832
+ const extra = slug(String(frameLabel), 40);
2833
+ if (extra) stem = slug(stem + '_' + extra, 80) || stem;
2834
+ }
2835
+ return stem || 'plot3';
2836
+ }
2837
+ function num(v) { return String(Math.round(Number(v) * 100) / 100); }
2838
+ function visibleColor(c) {
2839
+ if (!c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)') return '';
2840
+ return c;
2841
+ }
2842
+ function traceRound(ctx, x, y, w, h, rad) {
2843
+ const r = Math.max(0, Math.min(rad || 0, w / 2, h / 2));
2844
+ ctx.beginPath();
2845
+ if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, w, h, r);
2846
+ else ctx.rect(x, y, w, h);
2847
+ }
2848
+ function paintBox(ctx, el, st, ox, oy, alpha) {
2849
+ const r = el.getBoundingClientRect();
2850
+ const w = r.width, h = r.height;
2851
+ if (w < 0.5 || h < 0.5) return;
2852
+ const bg = visibleColor(st.backgroundColor);
2853
+ const ramp = el.id === 'ramp';
2854
+ const bw = parseFloat(st.borderTopWidth) || 0;
2855
+ const bc = bw > 0 ? visibleColor(st.borderTopColor) : '';
2856
+ if (!bg && !ramp && !bc) return;
2857
+ const x = r.left - ox, y = r.top - oy;
2858
+ const rad = parseFloat(st.borderRadius) || 0;
2859
+ ctx.save();
2860
+ ctx.globalAlpha = alpha;
2861
+ traceRound(ctx, x, y, w, h, rad);
2862
+ if (ramp && S.color && S.color.ramp && S.color.ramp.length) {
2863
+ const g = ctx.createLinearGradient(x, y, x + w, y);
2864
+ const stops = S.color.ramp;
2865
+ for (let i = 0; i < stops.length; i++)
2866
+ g.addColorStop(stops.length === 1 ? 0 : i / (stops.length - 1), stops[i]);
2867
+ ctx.fillStyle = g;
2868
+ ctx.fill();
2869
+ } else if (bg) {
2870
+ ctx.fillStyle = bg;
2871
+ ctx.fill();
2872
+ }
2873
+ if (bc) {
2874
+ ctx.lineWidth = bw;
2875
+ ctx.strokeStyle = bc;
2876
+ ctx.stroke();
2877
+ }
2878
+ ctx.restore();
2879
+ }
2880
+ function fillWrapped(ctx, text, x, y, maxW, lineH) {
2881
+ const words = text.split(' ');
2882
+ let line = '', cy = y;
2883
+ for (let i = 0; i < words.length; i++) {
2884
+ const trial = line ? line + ' ' + words[i] : words[i];
2885
+ if (line && ctx.measureText(trial).width > maxW) {
2886
+ ctx.fillText(line, x, cy);
2887
+ line = words[i];
2888
+ cy += lineH;
2889
+ } else line = trial;
2890
+ }
2891
+ if (line) ctx.fillText(line, x, cy);
2892
+ }
2893
+ function paintText(ctx, node, ox, oy, alpha) {
2894
+ const text = node.textContent;
2895
+ if (!text || !text.trim()) return;
2896
+ const parent = node.parentElement;
2897
+ if (!parent) return;
2898
+ const st = getComputedStyle(parent);
2899
+ const range = document.createRange();
2900
+ range.selectNodeContents(node);
2901
+ const rects = range.getClientRects();
2902
+ range.detach();
2903
+ if (!rects.length) return;
2904
+ ctx.save();
2905
+ ctx.globalAlpha = alpha;
2906
+ ctx.fillStyle = st.color;
2907
+ ctx.font = (st.fontStyle || 'normal') + ' ' + (st.fontWeight || '400') + ' '
2908
+ + (st.fontSize || '12px') + ' ' + (st.fontFamily || 'sans-serif');
2909
+ ctx.textBaseline = 'top';
2910
+ ctx.textAlign = 'left';
2911
+ if ('letterSpacing' in ctx && st.letterSpacing && st.letterSpacing !== 'normal')
2912
+ ctx.letterSpacing = st.letterSpacing;
2913
+ if (rects.length === 1) {
2914
+ ctx.fillText(text, rects[0].left - ox, rects[0].top - oy);
2915
+ } else {
2916
+ const pr = parent.getBoundingClientRect();
2917
+ const padL = parseFloat(st.paddingLeft) || 0;
2918
+ const padR = parseFloat(st.paddingRight) || 0;
2919
+ const maxW = Math.max(1, pr.width - padL - padR);
2920
+ let lineH = parseFloat(st.lineHeight);
2921
+ if (!isFinite(lineH)) lineH = rects[0].height || 14;
2922
+ fillWrapped(ctx, text.trim(), pr.left + padL - ox, rects[0].top - oy, maxW, lineH);
2923
+ }
2924
+ ctx.restore();
2925
+ }
2926
+ function paintNode(ctx, node, ox, oy, alpha) {
2927
+ if (!node) return;
2928
+ if (node.nodeType === 3) { paintText(ctx, node, ox, oy, alpha); return; }
2929
+ if (node.nodeType !== 1) return;
2930
+ const el = node;
2931
+ if (el.id === 'modebar' || el.id === 'tip' || el.id === 'hint' || el.id === 'canvas-host' || el.id === 'axes')
2932
+ return;
2933
+ const st = getComputedStyle(el);
2934
+ if (st.display === 'none' || st.visibility === 'hidden') return;
2935
+ const own = parseFloat(st.opacity);
2936
+ const next = alpha * (isFinite(own) ? own : 1);
2937
+ if (next <= 0.001) return;
2938
+ paintBox(ctx, el, st, ox, oy, next);
2939
+ for (let i = 0; i < el.childNodes.length; i++)
2940
+ paintNode(ctx, el.childNodes[i], ox, oy, next);
2941
+ }
2942
+ function svgRotate(transform) {
2943
+ if (!transform) return null;
2944
+ const i = transform.indexOf('rotate(');
2945
+ if (i < 0) return null;
2946
+ const inner = transform.slice(i + 7, transform.indexOf(')', i));
2947
+ const parts = inner.trim().split(/[ ,]+/).map(Number);
2948
+ if (parts.length < 3 || parts.some(n => !isFinite(n))) return null;
2949
+ return parts;
2950
+ }
2951
+ function paintAxes(ctx, from) {
2952
+ const root = from || svg;
2953
+ if (!root) return;
2954
+ const font = '12px ' + (getComputedStyle(document.body).fontFamily || 'sans-serif');
2955
+ const nodes = root.querySelectorAll('line, rect, text');
2956
+ for (let i = 0; i < nodes.length; i++) {
2957
+ const el = nodes[i];
2958
+ const tag = el.tagName.toLowerCase();
2959
+ ctx.save();
2960
+ if (tag === 'line') {
2961
+ ctx.beginPath();
2962
+ ctx.strokeStyle = el.getAttribute('stroke') || T.grid;
2963
+ ctx.lineWidth = 1;
2964
+ ctx.moveTo(+el.getAttribute('x1'), +el.getAttribute('y1'));
2965
+ ctx.lineTo(+el.getAttribute('x2'), +el.getAttribute('y2'));
2966
+ ctx.stroke();
2967
+ } else if (tag === 'rect') {
2968
+ const rx = +el.getAttribute('x'), ry = +el.getAttribute('y');
2969
+ const rw = +el.getAttribute('width'), rh = +el.getAttribute('height');
2970
+ const fill = el.getAttribute('fill');
2971
+ if (fill && fill !== 'none') { ctx.fillStyle = fill; ctx.fillRect(rx, ry, rw, rh); }
2972
+ if (el.getAttribute('stroke') || !fill) {
2973
+ ctx.strokeStyle = el.getAttribute('stroke') || T.axis;
2974
+ ctx.lineWidth = 1;
2975
+ ctx.strokeRect(rx, ry, rw, rh);
2976
+ }
2977
+ } else if (tag === 'text') {
2978
+ const x = +el.getAttribute('x'), y = +el.getAttribute('y');
2979
+ const anchor = el.getAttribute('text-anchor') || 'start';
2980
+ ctx.fillStyle = el.getAttribute('fill') || T.ink2;
2981
+ ctx.font = font;
2982
+ ctx.textAlign = anchor === 'middle' ? 'center' : anchor === 'end' ? 'right' : 'left';
2983
+ ctx.textBaseline = 'alphabetic';
2984
+ const rot = svgRotate(el.getAttribute('transform'));
2985
+ if (rot) {
2986
+ ctx.translate(rot[1], rot[2]);
2987
+ ctx.rotate(rot[0] * Math.PI / 180);
2988
+ ctx.translate(-rot[1], -rot[2]);
2989
+ }
2990
+ ctx.fillText(el.textContent || '', x, y);
2991
+ }
2992
+ ctx.restore();
2993
+ }
2994
+ }
2995
+ function pickDpr(w, h, prefer) {
2996
+ let dpr = prefer == null ? Math.min(window.devicePixelRatio || 1, 3) : prefer;
2997
+ const maxSide = 4096;
2998
+ if (w * dpr > maxSide) dpr = maxSide / w;
2999
+ if (h * dpr > maxSide) dpr = Math.min(dpr, maxSide / h);
3000
+ return Math.max(0.5, dpr);
3001
+ }
3002
+ function layerCanvas(w, h, dpr) {
3003
+ const c = document.createElement('canvas');
3004
+ c.width = Math.max(1, Math.round(w * dpr));
3005
+ c.height = Math.max(1, Math.round(h * dpr));
3006
+ const ctx = c.getContext('2d');
3007
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3008
+ return { canvas: c, ctx: ctx };
3009
+ }
3010
+ function compose(kind) {
3011
+ const gl = renderer.domElement;
3012
+ const bufW = Math.max(1, gl.clientWidth);
3013
+ const bufH = Math.max(1, gl.clientHeight);
3014
+ const prevRatio = renderer.getPixelRatio();
3015
+ const boost = kind === 'png' && bufW > 1 && bufH > 1;
3016
+ if (boost) {
3017
+ renderer.setPixelRatio(2);
3018
+ renderer.setSize(bufW, bufH, false);
3019
+ }
3020
+ try {
3021
+ return composeView(kind);
3022
+ } finally {
3023
+ if (boost) {
3024
+ renderer.setPixelRatio(prevRatio);
3025
+ renderer.setSize(bufW, bufH, false);
3026
+ renderNow();
3027
+ }
3028
+ }
3029
+ }
3030
+ function composeView(kind) {
3031
+ renderNow();
3032
+ const figR = figEl.getBoundingClientRect();
3033
+ const cssW = Math.max(1, figR.width);
3034
+ const cssH = Math.max(1, figR.height);
3035
+ const noteEl = document.getElementById('note');
3036
+ const yearEl = document.getElementById('year');
3037
+ let noteH = 0;
3038
+ if (noteEl && getComputedStyle(noteEl).display !== 'none' && (noteEl.textContent || '').trim())
3039
+ noteH = noteEl.offsetHeight || 0;
3040
+ const totalH = cssH + noteH;
3041
+ const dpr = pickDpr(cssW, totalH, kind === 'png' ? 2 : null);
3042
+ const hostR = host.getBoundingClientRect();
3043
+ const hx = hostR.left - figR.left, hy = hostR.top - figR.top;
3044
+ const shot = layerCanvas(cssW, totalH, dpr);
3045
+ const ctx = shot.ctx;
3046
+ ctx.fillStyle = T.surface;
3047
+ ctx.fillRect(0, 0, cssW, totalH);
3048
+ const yearOn = yearEl && getComputedStyle(yearEl).display !== 'none' && (yearEl.textContent || '').trim();
3049
+ let yearURL = '', yearBox = null;
3050
+ if (yearOn) {
3051
+ const yr = yearEl.getBoundingClientRect();
3052
+ const pad = 8;
3053
+ const yw = yr.width + pad * 2, yh = yr.height + pad * 2;
3054
+ const layer = layerCanvas(yw, yh, dpr);
3055
+ paintNode(layer.ctx, yearEl, yr.left - pad, yr.top - pad, 1);
3056
+ yearBox = { x: yr.left - figR.left - pad, y: yr.top - figR.top - pad, w: yw, h: yh };
3057
+ ctx.drawImage(layer.canvas, yearBox.x, yearBox.y, yw, yh);
3058
+ if (kind === 'svg') yearURL = layer.canvas.toDataURL('image/png');
3059
+ }
3060
+ paintAxes(ctx, gridSvg);
3061
+ if (hostR.width > 1 && hostR.height > 1)
3062
+ ctx.drawImage(renderer.domElement, hx, hy, hostR.width, hostR.height);
3063
+ paintAxes(ctx);
3064
+ const chrome = layerCanvas(cssW, totalH, dpr);
3065
+ paintNode(chrome.ctx, titleEl, figR.left, figR.top, 1);
3066
+ if (legEl && legEl.style.display !== 'none')
3067
+ paintNode(chrome.ctx, legEl, figR.left, figR.top, 1);
3068
+ if (noteH > 0) {
3069
+ const nr = noteEl.getBoundingClientRect();
3070
+ paintNode(chrome.ctx, noteEl, figR.left, nr.top - cssH, 1);
3071
+ }
3072
+ ctx.drawImage(chrome.canvas, 0, 0, cssW, totalH);
3073
+ if (kind === 'svg') {
3074
+ const W = Math.max(1, Math.round(cssW)), H = Math.max(1, Math.round(totalH));
3075
+ const glURL = renderer.domElement.toDataURL('image/png');
3076
+ const chromeURL = chrome.canvas.toDataURL('image/png');
3077
+ const axesInner = (svg && svg.innerHTML.trim()) ? svg.innerHTML : '';
3078
+ const titleText = plot3Esc(withFrame((S.labs && S.labs.title) || 'plot3'));
3079
+ let body = '<rect width="100%" height="100%" fill="' + T.surface + '"/>';
3080
+ if (yearURL && yearBox) {
3081
+ body += '<image x="' + num(yearBox.x) + '" y="' + num(yearBox.y)
3082
+ + '" width="' + num(yearBox.w) + '" height="' + num(yearBox.h)
3083
+ + '" href="' + yearURL + '"/>';
3084
+ }
3085
+ if (gridSvg && gridSvg.innerHTML.trim()) body += '<g>' + gridSvg.innerHTML + '</g>';
3086
+ body += '<image x="' + num(hx) + '" y="' + num(hy)
3087
+ + '" width="' + num(hostR.width) + '" height="' + num(hostR.height)
3088
+ + '" href="' + glURL + '"/>';
3089
+ if (axesInner) {
3090
+ body += '<g font-family="system-ui, -apple-system, Segoe UI, sans-serif" font-size="12">'
3091
+ + axesInner + '</g>';
3092
+ }
3093
+ body += '<image x="0" y="0" width="' + W + '" height="' + H + '" href="' + chromeURL + '"/>';
3094
+ return '<?xml version="1.0" encoding="UTF-8"?>'
3095
+ + '<svg xmlns="http://www.w3.org/2000/svg" width="' + W + '" height="' + H
3096
+ + '" viewBox="0 0 ' + W + ' ' + H + '"><title>' + titleText + '</title>'
3097
+ + body + '</svg>';
3098
+ }
3099
+ return shot.canvas.toDataURL('image/png');
3100
+ }
3101
+ function dataURLToBlob(url) {
3102
+ const comma = url.indexOf(',');
3103
+ const head = url.slice(0, comma);
3104
+ const body = url.slice(comma + 1);
3105
+ const mimeEnd = head.indexOf(';');
3106
+ const mime = head.slice(head.indexOf(':') + 1, mimeEnd < 0 ? head.length : mimeEnd)
3107
+ || 'application/octet-stream';
3108
+ const bin = atob(body);
3109
+ const bytes = new Uint8Array(bin.length);
3110
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
3111
+ return new Blob([bytes], { type: mime });
3112
+ }
3113
+ function downloadBlob(blob, name) {
3114
+ const a = document.createElement('a');
3115
+ const url = URL.createObjectURL(blob);
3116
+ a.href = url;
3117
+ a.download = name;
3118
+ document.body.appendChild(a);
3119
+ a.click();
3120
+ a.remove();
3121
+ setTimeout(() => URL.revokeObjectURL(url), 2000);
3122
+ }
3123
+ function downloadsAllowed() {
3124
+ try {
3125
+ const sb = window.frameElement && window.frameElement.sandbox;
3126
+ return !sb || sb.length === 0 || sb.contains('allow-downloads');
3127
+ } catch (err) { return true; }
3128
+ }
3129
+ async function saveFile(blob, name, mime, ext) {
3130
+ if (window.showSaveFilePicker) {
3131
+ try {
3132
+ const handle = await window.showSaveFilePicker({
3133
+ suggestedName: name,
3134
+ types: [{ description: ext.toUpperCase() + ' file', accept: { [mime]: ['.' + ext] } }],
3135
+ });
3136
+ const out = await handle.createWritable();
3137
+ await out.write(blob);
3138
+ await out.close();
3139
+ return 'saved';
3140
+ } catch (err) {
3141
+ if (err.name === 'AbortError') return 'cancelled';
3142
+ }
3143
+ }
3144
+ if (downloadsAllowed()) { downloadBlob(blob, name); return 'downloaded'; }
3145
+ throw new Error('downloads blocked');
3146
+ }
3147
+ function stateJSON() {
3148
+ const state = {};
3149
+ if (S.transition) state.playT = playT;
3150
+ if (S.slider && S.slider.params) {
3151
+ const sliders = {};
3152
+ for (let i = 0; i < S.slider.params.length; i++) {
3153
+ const name = S.slider.params[i].name;
3154
+ const node = document.getElementById('slider-' + name);
3155
+ if (node) sliders[name] = +node.value;
3156
+ }
3157
+ state.sliders = sliders;
3158
+ }
3159
+ const cam = window.__plot3.camera;
3160
+ const controls = window.__plot3.controls;
3161
+ if (cam && controls) {
3162
+ state.camera = {
3163
+ position: [cam.position.x, cam.position.y, cam.position.z],
3164
+ target: [controls.target.x, controls.target.y, controls.target.z],
3165
+ };
3166
+ }
3167
+ return JSON.stringify(state).split('<').join('\\\\' + 'u003c');
3168
+ }
3169
+ function htmlDocument() {
3170
+ // Split the tags so the source itself does not contain them. PRISTINE
3171
+ // includes this script, and a contiguous match would rewrite the module.
3172
+ const open = '<script type="application/json" id="' + 'plot3-state">';
3173
+ const close = '<' + '/script>';
3174
+ const tag = open + stateJSON() + close;
3175
+ let doc = PRISTINE;
3176
+ const at = doc.indexOf(open);
3177
+ if (at >= 0) {
3178
+ const end = doc.indexOf(close, at);
3179
+ if (end >= 0) return doc.slice(0, at) + tag + doc.slice(end + close.length);
3180
+ }
3181
+ const closeBody = '<' + '/body>';
3182
+ const bodyAt = doc.lastIndexOf(closeBody);
3183
+ if (bodyAt >= 0) return doc.slice(0, bodyAt) + tag + doc.slice(bodyAt);
3184
+ return doc + tag;
3185
+ }
3186
+ const BLOCKED = "Downloads are blocked here. Use Copy PNG, or in Python: ggsave('plot.png', fig).";
3187
+ function remember(item) {
3188
+ if (!item.dataset.labelHtml) item.dataset.labelHtml = item.innerHTML;
3189
+ }
3190
+ function restoreItem(item) {
3191
+ if (item._saveTimer) { clearTimeout(item._saveTimer); item._saveTimer = 0; }
3192
+ if (item.dataset.labelHtml) item.innerHTML = item.dataset.labelHtml;
3193
+ item.classList.remove('save-status');
3194
+ item.disabled = false;
3195
+ }
3196
+ function showStatus(item, text) {
3197
+ if (item._saveTimer) { clearTimeout(item._saveTimer); item._saveTimer = 0; }
3198
+ remember(item);
3199
+ item.textContent = text;
3200
+ item.classList.add('save-status');
3201
+ }
3202
+ function flashSaved(item) {
3203
+ showStatus(item, 'Saved ✓');
3204
+ item._saveTimer = setTimeout(() => restoreItem(item), 1600);
3205
+ }
3206
+ function menuItems() {
3207
+ return Array.prototype.filter.call(
3208
+ menu.querySelectorAll('[role="menuitem"]'),
3209
+ (el) => !el.hidden);
3210
+ }
3211
+ function placeMenu() {
3212
+ // The button is at the top of the figure. Opening upward when the plot
3213
+ // is short clips the menu off the iframe. Pick the side that shows more
3214
+ // of it, which is upward only when the button sits low in the window.
3215
+ const btnR = btn.getBoundingClientRect();
3216
+ const menuH = menu.offsetHeight || 220;
3217
+ const spaceBelow = window.innerHeight - btnR.bottom;
3218
+ const spaceAbove = btnR.top;
3219
+ const visibleBelow = Math.min(menuH, Math.max(0, spaceBelow));
3220
+ const visibleAbove = Math.min(menuH, Math.max(0, spaceAbove));
3221
+ bar.classList.toggle('open-up', visibleAbove > visibleBelow);
3222
+ }
3223
+ function closeMenu(focusBtn) {
3224
+ menu.hidden = true;
3225
+ btn.setAttribute('aria-expanded', 'false');
3226
+ bar.classList.remove('open');
3227
+ menuItems().forEach(restoreItem);
3228
+ if (focusBtn) btn.focus();
3229
+ }
3230
+ function openMenu(focusIndex) {
3231
+ menu.hidden = false;
3232
+ btn.setAttribute('aria-expanded', 'true');
3233
+ bar.classList.add('open');
3234
+ placeMenu();
3235
+ const items = menuItems();
3236
+ if (!items.length) return;
3237
+ const idx = focusIndex == null ? 0 : Math.max(0, Math.min(items.length - 1, focusIndex));
3238
+ items[idx].focus();
3239
+ }
3240
+ async function runSave(item) {
3241
+ if (!item || item.dataset.busy === '1') return;
3242
+ const act = item.getAttribute('data-act');
3243
+ item.dataset.busy = '1';
3244
+ item.disabled = true;
3245
+ showStatus(item, act === 'video' ? 'Recording…' : 'Saving…');
3246
+ try {
3247
+ if (act === 'video') {
3248
+ const blob = await window.__plot3.startRecording();
3249
+ showStatus(item, 'Saving…');
3250
+ const how = await saveFile(blob, fileStem() + '.webm', 'video/webm', 'webm');
3251
+ window.__plot3.saveError = '';
3252
+ if (how === 'cancelled') restoreItem(item);
3253
+ else flashSaved(item);
3254
+ return;
3255
+ }
3256
+ const stem = fileStem();
3257
+ let blob, name, mime, ext;
3258
+ if (act === 'png' || act === 'copy') {
3259
+ blob = dataURLToBlob(compose('png'));
3260
+ name = stem + '.png'; mime = 'image/png'; ext = 'png';
3261
+ } else if (act === 'svg') {
3262
+ blob = new Blob([compose('svg')], { type: 'image/svg+xml;charset=utf-8' });
3263
+ name = stem + '.svg'; mime = 'image/svg+xml'; ext = 'svg';
3264
+ } else {
3265
+ blob = new Blob([htmlDocument()], { type: 'text/html;charset=utf-8' });
3266
+ name = stem + '.html'; mime = 'text/html'; ext = 'html';
3267
+ }
3268
+ if (act === 'copy') {
3269
+ if (!navigator.clipboard || !navigator.clipboard.write || typeof ClipboardItem === 'undefined')
3270
+ throw new Error('Clipboard image copy is not available in this browser');
3271
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
3272
+ window.__plot3.saveError = '';
3273
+ flashSaved(item);
3274
+ return;
3275
+ }
3276
+ const how = await saveFile(blob, name, mime, ext);
3277
+ window.__plot3.saveError = '';
3278
+ if (how === 'cancelled') restoreItem(item);
3279
+ else flashSaved(item);
3280
+ } catch (err) {
3281
+ const blocked = err && err.message === 'downloads blocked';
3282
+ const msg = blocked ? BLOCKED : String(err && err.message ? err.message : err);
3283
+ window.__plot3.saveError = msg;
3284
+ showStatus(item, msg);
3285
+ item.disabled = false;
3286
+ console.error(err);
3287
+ } finally {
3288
+ item.dataset.busy = '0';
3289
+ }
3290
+ }
3291
+ btn.addEventListener('click', () => {
3292
+ if (menu.hidden) openMenu(0);
3293
+ else closeMenu(false);
3294
+ });
3295
+ menu.querySelectorAll('[role="menuitem"]').forEach((item) => {
3296
+ item.addEventListener('click', () => runSave(item));
3297
+ });
3298
+ document.addEventListener('pointerdown', (ev) => {
3299
+ if (menu.hidden || bar.contains(ev.target)) return;
3300
+ closeMenu(false);
3301
+ });
3302
+ document.addEventListener('keydown', (ev) => {
3303
+ if (ev.key === 'Escape' && !menu.hidden) {
3304
+ ev.preventDefault();
3305
+ ev.stopPropagation();
3306
+ closeMenu(true);
3307
+ return;
3308
+ }
3309
+ const items = menuItems();
3310
+ if (menu.hidden) {
3311
+ if (!bar.contains(ev.target)) return;
3312
+ if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
3313
+ ev.preventDefault();
3314
+ openMenu(ev.key === 'ArrowUp' ? items.length - 1 : 0);
3315
+ }
3316
+ return;
3317
+ }
3318
+ if (!bar.contains(ev.target)) return;
3319
+ const cur = items.indexOf(document.activeElement);
3320
+ if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
3321
+ ev.preventDefault();
3322
+ ev.stopPropagation();
3323
+ if (!items.length) return;
3324
+ const dir = ev.key === 'ArrowDown' ? 1 : -1;
3325
+ const next = items[(cur + dir + items.length) % items.length];
3326
+ next.focus();
3327
+ } else if (ev.key === 'Home' && items.length) {
3328
+ ev.preventDefault();
3329
+ items[0].focus();
3330
+ } else if (ev.key === 'End' && items.length) {
3331
+ ev.preventDefault();
3332
+ items[items.length - 1].focus();
3333
+ }
3334
+ });
3335
+ window.__plot3.saveError = '';
3336
+ window.__plot3.snapshot = (kind) => compose(kind || 'png');
3337
+ }
3338
+ installSave();
3339
+ </script>
3340
+ </body></html>"""
3341
+
3342
+ # Inserted only when the figure has a formula or a ``$...$`` label. Data
3343
+ # plots keep the portable HTML free of the KaTeX download.
3344
+ _KATEX_BOOT = """if (S.math) {
3345
+ const katexCss = document.createElement('link');
3346
+ katexCss.rel = 'stylesheet';
3347
+ katexCss.href = 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css';
3348
+ document.head.appendChild(katexCss);
3349
+ const katexJs = document.createElement('script');
3350
+ katexJs.src = 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js';
3351
+ katexJs.onload = () => plot3Typeset(document);
3352
+ document.head.appendChild(katexJs);
3353
+ }
3354
+ """