phoria2 1.1.0 → 1.2.0

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.
Files changed (60) hide show
  1. package/README.md +71 -13
  2. package/dist/bloom.js +4 -4
  3. package/dist/bloom.js.map +1 -1
  4. package/dist/charts.js +358 -345
  5. package/dist/charts.js.map +1 -1
  6. package/dist/core.js +3989 -3600
  7. package/dist/core.js.map +1 -1
  8. package/dist/edit.js +43 -43
  9. package/dist/edit.js.map +1 -1
  10. package/dist/explain.js +5 -1
  11. package/dist/explain.js.map +1 -1
  12. package/dist/gltf.js +96 -96
  13. package/dist/gltf.js.map +1 -1
  14. package/dist/motion.js +3 -3
  15. package/dist/motion.js.map +1 -1
  16. package/dist/particles.js +16 -16
  17. package/dist/particles.js.map +1 -1
  18. package/dist/passes.js +26 -26
  19. package/dist/passes.js.map +1 -1
  20. package/dist/phoria2.js +1 -1
  21. package/dist/picking.js +16 -16
  22. package/dist/picking.js.map +1 -1
  23. package/dist/types/OrbitControl.d.ts +6 -2
  24. package/dist/types/Raycaster.d.ts +10 -0
  25. package/dist/types/View.d.ts +59 -1
  26. package/dist/types/charts/Chart.d.ts +7 -0
  27. package/dist/types/explain.d.ts +3 -0
  28. package/dist/types/frame.d.ts +7 -0
  29. package/dist/types/geometry/Geometry.d.ts +8 -2
  30. package/dist/types/keyboard.d.ts +48 -0
  31. package/dist/types/labels.d.ts +4 -0
  32. package/dist/types/scene/Node.d.ts +19 -14
  33. package/dist/types/scene/drawables.d.ts +32 -14
  34. package/dist/types/scene/lights.d.ts +16 -2
  35. package/dist/types/shadow.d.ts +2 -1
  36. package/dist/types/types.d.ts +14 -0
  37. package/dist/types/value/Color.d.ts +0 -4
  38. package/dist/types/value/Quat.d.ts +4 -2
  39. package/llms-full.txt +58 -9
  40. package/package.json +4 -1
  41. package/schema/scene.schema.json +19 -1
  42. package/src/OrbitControl.js +7 -3
  43. package/src/Raycaster.js +58 -0
  44. package/src/View.js +204 -41
  45. package/src/charts/Chart.js +20 -2
  46. package/src/explain.js +6 -2
  47. package/src/frame.js +29 -9
  48. package/src/geometry/Geometry.js +16 -3
  49. package/src/keyboard.js +298 -0
  50. package/src/labels.js +4 -1
  51. package/src/scene/Node.js +20 -9
  52. package/src/scene/Scene.js +2 -0
  53. package/src/scene/cameras.js +4 -1
  54. package/src/scene/drawables.js +48 -12
  55. package/src/scene/lights.js +40 -6
  56. package/src/shadow.js +27 -4
  57. package/src/types.js +6 -0
  58. package/src/value/Color.js +21 -0
  59. package/src/value/Quat.js +15 -6
  60. package/src/watch.js +6 -1
package/src/frame.js CHANGED
@@ -6,6 +6,7 @@ import { Box3 } from './value/Box3.js'
6
6
  import { warnOnce } from './validate.js'
7
7
  import { fitPointShadow, fitShadow, fitSpotShadow, SPOT_TILES } from './shadow.js'
8
8
  import { worldPlane } from './clip.js'
9
+ import { hiddenInstance } from './scene/drawables.js'
9
10
 
10
11
  /** @import { Scene } from './scene/Scene.js' */
11
12
  /** @import { Camera } from './scene/cameras.js' */
@@ -89,6 +90,8 @@ export const LIGHT_LIMITS = { punctual: 4, directional: 2 }
89
90
  * @property {[number, number]} ndc [x, y] in -1..1
90
91
  * @property {number} depth
91
92
  * @property {boolean} visible in front of the camera and inside the view
93
+ * @property {number} scale how far zoomed in at the point: half the view's height, in world units, is 1 / scale -
94
+ * the layer turns it into pixels to one world unit, for minScale
92
95
  */
93
96
 
94
97
  /**
@@ -239,13 +242,13 @@ export function collect(scene, camera) {
239
242
  else capped.set(item.clip, [item])
240
243
  }
241
244
  frame.stats.drawn++
242
- if (node.labels) instanceLabels(node, viewProjection, view, frame.labels)
245
+ if (node.labels) instanceLabels(node, viewProjection, view, projection[5], frame.labels)
243
246
  } else {
244
247
  frame.stats.culled++
245
248
  }
246
249
  } else if (node.isLabel) {
247
250
  const position = node.worldPosition
248
- frame.labels.push({ key: node, node, index: null, text: node.text, style: node, position, ...project(position, viewProjection, view) })
251
+ frame.labels.push({ key: node, node, index: null, text: node.text, style: node, position, ...project(position, viewProjection, view, projection[5]) })
249
252
  } else if (node.lightKind) {
250
253
  addLight(/** @type {Light} */ (node))
251
254
  }
@@ -323,7 +326,7 @@ export function collect(scene, camera) {
323
326
  if (items.length && sun >= 0) {
324
327
  const light = frame.lights[sun]
325
328
  const bounds = (/** @type {any} */ n) => n.expandBounds(new Box3())
326
- const fit = fitShadow(/** @type {Vec3} */ (light.direction), items.map((c) => bounds(c.node)), receivers.map(bounds))
329
+ const fit = fitShadow(/** @type {Vec3} */ (light.direction), items.map((c) => bounds(c.node)), receivers.map(bounds), undefined, /** @type {any} */ (light.node).shadowBounds)
327
330
  if (fit) {
328
331
  frame.lights.splice(sun, 1)
329
332
  frame.lights.unshift(light)
@@ -392,10 +395,22 @@ let nextId = 1
392
395
 
393
396
  /**
394
397
  * Labels for every instance or point that has text, from the node's `labels` option.
395
- * @param {any} node InstancedMesh or Points @param {Mat4} viewProjection @param {Mat4} view @param {FrameLabel[]} out
398
+ * @param {any} node InstancedMesh or Points @param {Mat4} viewProjection @param {Mat4} view
399
+ * @param {number} yScale the projection's vertical scale @param {FrameLabel[]} out
396
400
  */
397
- function instanceLabels(node, viewProjection, view, out) {
398
- const { labels, worldMatrix: w } = node
401
+ function instanceLabels(node, viewProjection, view, yScale, out) {
402
+ // several labels per instance: the first keeps the plain key, so a single set's labels don't change identity
403
+ if (Array.isArray(node.labels)) {
404
+ node.labels.forEach((labels, layer) => instanceLabelSet(node, labels, layer, viewProjection, view, yScale, out))
405
+ } else instanceLabelSet(node, node.labels, 0, viewProjection, view, yScale, out)
406
+ }
407
+
408
+ /**
409
+ * @param {any} node @param {any} labels one set of instance labels @param {number} layer its place in the list
410
+ * @param {Mat4} viewProjection @param {Mat4} view @param {number} yScale @param {FrameLabel[]} out
411
+ */
412
+ function instanceLabelSet(node, labels, layer, viewProjection, view, yScale, out) {
413
+ const { worldMatrix: w } = node
399
414
  let id = ids.get(node)
400
415
  if (id === undefined) ids.set(node, id = nextId++)
401
416
  const instanced = 'instanceMatrices' in node
@@ -409,6 +424,8 @@ function instanceLabels(node, viewProjection, view, out) {
409
424
  const data = Array.isArray(node.data) ? node.data : null
410
425
  const byRow = typeof labels.priority === 'function'
411
426
  for (let i = 0; i < n; i++) {
427
+ // an instance scaled to nothing is hidden, and so is its label
428
+ if (instanced && hiddenInstance(node.instanceMatrices, i)) continue
412
429
  const row = data ? data[i] : null
413
430
  const text = labels.text(row, i)
414
431
  if (text === null || text === undefined || text === '') continue
@@ -427,7 +444,7 @@ function instanceLabels(node, viewProjection, view, out) {
427
444
  w[1] * x + w[5] * y + w[9] * z + w[13],
428
445
  w[2] * x + w[6] * y + w[10] * z + w[14])
429
446
  const style = byRow ? { ...labels, priority: labels.priority(row, i) } : labels
430
- out.push({ key: `${id}:${i}`, node, index: i, text: String(text), style, position, ...project(position, viewProjection, view) })
447
+ out.push({ key: layer ? `${id}:${i}:${layer}` : `${id}:${i}`, node, index: i, text: String(text), style, position, ...project(position, viewProjection, view, yScale) })
431
448
  }
432
449
  }
433
450
 
@@ -453,10 +470,13 @@ export function toScreen(position, viewProjection) {
453
470
  * units, for both kinds of camera (an orthographic one's clip w is always 1).
454
471
  * @param {Vec3} position @param {Mat4} viewProjection @param {Mat4} view
455
472
  */
456
- function project(position, viewProjection, view) {
473
+ function project(position, viewProjection, view, yScale) {
457
474
  const { ndc, visible } = toScreen(position, viewProjection)
458
475
  const depth = -(view[2] * position[0] + view[6] * position[1] + view[10] * position[2] + view[14])
459
- return { ndc: /** @type {[number, number]} */ ([ndc[0], ndc[1]]), depth, visible }
476
+ // clip w at the point: its depth for a perspective camera, 1 for an orthographic one
477
+ const m = viewProjection
478
+ const w = m[3] * position[0] + m[7] * position[1] + m[11] * position[2] + m[15]
479
+ return { ndc: /** @type {[number, number]} */ ([ndc[0], ndc[1]]), depth, visible, scale: w > 0 ? yScale / w : 0 }
460
480
  }
461
481
 
462
482
  // The six planes (a, b, c, d with the normal pointing inwards) of a view-projection matrix.
@@ -45,6 +45,8 @@ export class Geometry {
45
45
 
46
46
  /** @type {Float32Array | null} */
47
47
  #normals = null
48
+ /** The normals were worked out on first read, rather than given or asked for: they follow the shape. */
49
+ #autoNormals = false
48
50
  /** @type {Box3 | null} */
49
51
  #bounds = null
50
52
  /** @type {Set<() => void>} */
@@ -75,16 +77,22 @@ export class Geometry {
75
77
 
76
78
  /**
77
79
  * xyz per vertex. Computed (smooth) from the triangles on first read when not supplied, so
78
- * points and lines - whose geometry has no triangles - never pay for them.
80
+ * points and lines - whose geometry has no triangles - never pay for them - and computed again after
81
+ * needsUpdate(), so they follow positions edited in place. Normals that were supplied, assigned or
82
+ * made by calling computeNormals() are kept as they are.
79
83
  * @type {Float32Array}
80
84
  */
81
85
  get normals() {
82
- if (!this.#normals) this.computeNormals()
86
+ if (!this.#normals) {
87
+ this.computeNormals()
88
+ this.#autoNormals = true
89
+ }
83
90
  return /** @type {Float32Array} */ (this.#normals)
84
91
  }
85
92
 
86
93
  set normals(v) {
87
94
  this.#normals = v
95
+ this.#autoNormals = false
88
96
  }
89
97
 
90
98
  /** True once normals exist, supplied or computed. */
@@ -121,9 +129,14 @@ export class Geometry {
121
129
  return this.#bounds ??= new Box3().expandByPositions(this.positions)
122
130
  }
123
131
 
124
- /** Call after editing the arrays in place. It is no longer what its generator made, so toJSON() saves the arrays. @returns {this} */
132
+ /**
133
+ * Call after editing the arrays in place. It is no longer what its generator made, so toJSON() saves
134
+ * the arrays; normals that were worked out on first read are worked out again on the next.
135
+ * @returns {this}
136
+ */
125
137
  needsUpdate() {
126
138
  this.source = null
139
+ if (this.#autoNormals) this.#normals = null
127
140
  return this.#changed()
128
141
  }
129
142
 
@@ -0,0 +1,298 @@
1
+ // Keyboard use of a View, for people who can't use a pointer and for screen readers: the canvas
2
+ // becomes a stop in the page's tab order, and the arrow keys step through what you can act on - each
3
+ // instance, point or node with a tooltip, a handler or data - as hovering would: the hover tint, the
4
+ // tooltip and hover handlers, with Enter or Space as a click. A live region says what each one is.
5
+ // Loaded when a View is given keyboard: true, so apps without it don't carry it.
6
+ import { hiddenInstance } from './scene/drawables.js'
7
+ import { Vec3 } from './value/Vec3.js'
8
+
9
+ /** @import { Hit } from './Raycaster.js' */
10
+ /** @import { TooltipContent } from './tooltip.js' */
11
+ /** @import { Node } from './scene/Node.js' */
12
+ /** @import { View } from './View.js' */
13
+
14
+ /**
15
+ * @typedef {object} KeyboardOptions
16
+ * @property {(hit: Hit) => string} [label] what a screen reader says for each thing stepped to (default: the
17
+ * tooltip's text, then its data, then the node's name)
18
+ */
19
+
20
+ /**
21
+ * What the View lends the keyboard: the hover it shows, the click it runs, and what it says about a hit.
22
+ * @typedef {object} KeyboardHooks
23
+ * @property {(hit: Hit | null) => void} focus show this as hovered, or nothing
24
+ * @property {(hit: Hit) => void} press click it
25
+ * @property {(hit: Hit) => TooltipContent} tooltip the tooltip's content for it, if there is a tooltip
26
+ * @property {() => boolean} viewWide does the View itself answer hovers or clicks (a tooltip, handlers)?
27
+ */
28
+
29
+ /** @typedef {{ node: Node, index: number | null, whole: Node | null }} Item */
30
+
31
+ const STYLE = `:where(.phoria-sr-only) { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
32
+ clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }`
33
+ // how far PageUp and PageDown jump
34
+ const PAGE = 10
35
+ let ids = 0
36
+
37
+ export class Keyboard {
38
+ /** @type {View} */ #view
39
+ /** @type {KeyboardHooks} */ #hooks
40
+ /** @type {KeyboardOptions} */ #options
41
+ /** The item stepped to, or null. @type {Item | null} */ #at = null
42
+ /** @type {HTMLDivElement} */ #say
43
+ /** @type {HTMLDivElement} */ #help
44
+ /** Attributes the canvas had before, put back on dispose. @type {[string, string | null][]} */ #before
45
+ #description = ''
46
+ #auto = true
47
+ /** @type {(() => void)[]} */ #cleanup = []
48
+
49
+ /** @param {View} view @param {KeyboardHooks} hooks @param {KeyboardOptions} options */
50
+ constructor(view, hooks, options) {
51
+ this.#view = view
52
+ this.#hooks = hooks
53
+ this.#options = options
54
+ if (!document.getElementById('phoria-sr-style')) {
55
+ const style = document.createElement('style')
56
+ style.id = 'phoria-sr-style'
57
+ style.textContent = STYLE
58
+ document.head.prepend(style)
59
+ }
60
+ const canvas = view.canvas, id = `phoria-keys-${++ids}`
61
+ // what a screen reader says as you step: politely, after what it's saying already
62
+ this.#say = document.createElement('div')
63
+ this.#say.className = 'phoria-sr-only'
64
+ this.#say.setAttribute('aria-live', 'polite')
65
+ this.#say.setAttribute('aria-atomic', 'true')
66
+ // how to use it, read once the canvas has focus
67
+ this.#help = document.createElement('div')
68
+ this.#help.className = 'phoria-sr-only'
69
+ this.#help.id = id
70
+ canvas.after(this.#say, this.#help)
71
+ this.#before = ['tabindex', 'role', 'aria-roledescription', 'aria-label', 'aria-describedby'].map((a) => [a, canvas.getAttribute(a)])
72
+ if (!canvas.hasAttribute('tabindex')) canvas.tabIndex = 0
73
+ canvas.setAttribute('role', 'application')
74
+ canvas.setAttribute('aria-roledescription', '3D view')
75
+ canvas.setAttribute('aria-describedby', id)
76
+
77
+ const keydown = (/** @type {KeyboardEvent} */ e) => this.#key(e)
78
+ const blur = () => this.clear()
79
+ const focus = () => this.#describe()
80
+ canvas.addEventListener('keydown', keydown)
81
+ canvas.addEventListener('blur', blur)
82
+ canvas.addEventListener('focus', focus)
83
+ this.#cleanup.push(() => {
84
+ canvas.removeEventListener('keydown', keydown)
85
+ canvas.removeEventListener('blur', blur)
86
+ canvas.removeEventListener('focus', focus)
87
+ })
88
+ this.#describe()
89
+ }
90
+
91
+ /**
92
+ * The canvas's label for screen readers: the View's description, or - when it has none - one made
93
+ * from the scene: what its charts show, and how many things there are to step through.
94
+ * @param {string | null} text
95
+ */
96
+ setDescription(text) {
97
+ this.#auto = text === null
98
+ this.#description = text ?? ''
99
+ this.#describe()
100
+ }
101
+
102
+ #describe() {
103
+ const count = this.#items().length
104
+ const label = this.#auto ? autoDescription(this.#view.scene, count) : this.#description
105
+ this.#view.canvas.setAttribute('aria-label', label)
106
+ this.#help.textContent = count
107
+ ? `Use the arrow keys to move between ${count === 1 ? 'the one item' : `${count} items`}, Enter to select, Escape to leave.`
108
+ : 'Nothing here can be selected.'
109
+ }
110
+
111
+ /** Step off whatever was stepped to: the pointer has taken over, or focus has gone. */
112
+ clear() {
113
+ if (!this.#at) return
114
+ this.#at = null
115
+ this.#hooks.focus(null)
116
+ }
117
+
118
+ /** @param {KeyboardEvent} e */
119
+ #key(e) {
120
+ if (e.altKey || e.ctrlKey || e.metaKey) return
121
+ const items = this.#items()
122
+ let i = this.#at ? items.findIndex((it) => same(it, /** @type {Item} */ (this.#at))) : -1
123
+ switch (e.key) {
124
+ case 'ArrowRight': case 'ArrowDown': i = i < 0 ? 0 : Math.min(items.length - 1, i + 1); break
125
+ case 'ArrowLeft': case 'ArrowUp': i = i < 0 ? items.length - 1 : Math.max(0, i - 1); break
126
+ case 'PageDown': i = Math.min(items.length - 1, Math.max(0, i) + PAGE); break
127
+ case 'PageUp': i = Math.max(0, i - PAGE); break
128
+ case 'Home': i = 0; break
129
+ case 'End': i = items.length - 1; break
130
+ case 'Enter': case ' ':
131
+ if (!this.#at || i < 0) return
132
+ e.preventDefault()
133
+ this.#hooks.press(hitOf(items[i]))
134
+ return
135
+ case 'Escape':
136
+ if (!this.#at) return
137
+ e.preventDefault()
138
+ this.clear()
139
+ this.#say.textContent = ''
140
+ return
141
+ default: return
142
+ }
143
+ // the arrows and paging keys are the view's while it has focus, not the page's to scroll
144
+ e.preventDefault()
145
+ if (!items.length) {
146
+ this.#say.textContent = 'Nothing here can be selected.'
147
+ return
148
+ }
149
+ this.#at = items[i]
150
+ const hit = hitOf(items[i])
151
+ this.#hooks.focus(hit)
152
+ this.#say.textContent = `${this.#options.label?.(hit) ?? this.#textOf(hit)}. ${i + 1} of ${items.length}`
153
+ }
154
+
155
+ /** What to say about a hit: the tooltip's text, its data, or its node's name. @param {Hit} hit @returns {string} */
156
+ #textOf(hit) {
157
+ const tip = textOfContent(this.#hooks.tooltip(hit))
158
+ if (tip) return tip
159
+ const datum = hit.datum
160
+ if (datum !== null && datum !== undefined) {
161
+ if (typeof datum !== 'object') return String(datum)
162
+ const parts = Object.entries(datum).filter(([, v]) => v === null || typeof v !== 'object').slice(0, 4).map(([k, v]) => `${k}: ${format(v)}`)
163
+ if (parts.length) return parts.join(', ')
164
+ }
165
+ for (let n = /** @type {Node | null} */ (hit.node); n; n = n.parent) if (n.name) return n.name
166
+ return 'Item'
167
+ }
168
+
169
+ /**
170
+ * What can be stepped to, in scene order and each mesh's instances in order: everything visible and
171
+ * pickable in a subtree with its own tooltip, handlers, drag or tint - and, when the View answers
172
+ * hovers or clicks itself, each thing with data or a name. A highlightWhole subtree is one item, and
173
+ * a surface - a mesh with a row of data per vertex - is a stop per vertex, as picking gives its rows.
174
+ * Lines with a row per segment - a network's links - are a stop per segment, after everything else:
175
+ * the things first, then what joins them. Something with no row and no name of its own is a stop
176
+ * only when it has a handler or tooltip itself, not one it shares with the subtree it's in.
177
+ * @returns {Item[]}
178
+ */
179
+ #items() {
180
+ /** @type {Item[]} */
181
+ const items = [], links = []
182
+ const viewWide = this.#hooks.viewWide()
183
+ /** @type {Set<Node>} */
184
+ const wholes = new Set()
185
+ /** @param {any} n @param {boolean} own @param {Node | null} whole */
186
+ const walk = (n, own, whole) => {
187
+ if (!n.visible || n.pickable === false) return
188
+ const direct = !!(n.tooltip || n.onDrag || n.highlight || n.handlersFor('click').length || n.handlersFor('hover').length)
189
+ own ||= direct
190
+ whole = n.highlightWhole ? n : whole
191
+ if (n.isDrawable) {
192
+ if (whole) {
193
+ if ((own || viewWide) && !wholes.has(whole)) {
194
+ wholes.add(whole)
195
+ items.push({ node: n, index: null, whole })
196
+ }
197
+ } else if ('instanceMatrices' in n) {
198
+ for (let i = 0; i < n.count; i++) {
199
+ if (!hiddenInstance(n.instanceMatrices, i) && (own || (viewWide && n.datumAt(i) !== null))) items.push({ node: n, index: i, whole: null })
200
+ }
201
+ } else if ((n.drawMode === 'points' && Array.isArray(n.data)) || perVertex(n)) {
202
+ for (let i = 0; i < n.data.length; i++) if (own || viewWide) items.push({ node: n, index: i, whole: null })
203
+ } else if (n.drawMode === 'lines' && Array.isArray(n.data)) {
204
+ for (let i = 0; i < n.data.length; i++) if (own || viewWide) links.push({ node: n, index: i, whole: null })
205
+ } else if (direct || ((own || viewWide) && (n.name || n.datumAt(null) !== null))) items.push({ node: n, index: null, whole: null })
206
+ }
207
+ for (const c of n.children) walk(c, own, whole)
208
+ }
209
+ walk(this.#view.scene, false, null)
210
+ return items.concat(links)
211
+ }
212
+
213
+ dispose() {
214
+ for (const fn of this.#cleanup) fn()
215
+ const canvas = this.#view.canvas
216
+ for (const [name, value] of this.#before) {
217
+ if (value === null) canvas.removeAttribute(name)
218
+ else canvas.setAttribute(name, value)
219
+ }
220
+ this.#say.remove()
221
+ this.#help.remove()
222
+ }
223
+ }
224
+
225
+ /** @param {Item} a @param {Item} b */
226
+ const same = (a, b) => a.node === b.node && a.index === b.index
227
+
228
+ /** A surface: a mesh of triangles with a row of data for each vertex. @param {any} n */
229
+ const perVertex = (n) => n.drawMode === 'triangles' && !('instanceMatrices' in n) && Array.isArray(n.data) && n.data.length === n.geometry.vertexCount
230
+
231
+ /**
232
+ * A hit on an item, as the pointer would make over its middle.
233
+ * @param {Item} item @returns {Hit}
234
+ */
235
+ function hitOf({ node, index, whole }) {
236
+ const d = /** @type {any} */ (node)
237
+ const points = index !== null && d.drawMode === 'points', vertex = index !== null && perVertex(d)
238
+ const segment = index !== null && d.drawMode === 'lines'
239
+ /** @type {Vec3} */
240
+ let point
241
+ if (whole) point = whole.bounds.getCenter()
242
+ else if (index !== null && 'instanceMatrices' in d) {
243
+ // the middle of the geometry, through the instance's matrix and then the mesh's
244
+ node.updateWorld()
245
+ const local = d.geometry.bounds.getCenter()
246
+ point = through(d.worldMatrix, through(d.instanceMatrices.subarray(index * 16, index * 16 + 16), local))
247
+ } else if (points || vertex) {
248
+ const p = d.geometry.positions
249
+ node.updateWorld()
250
+ point = through(d.worldMatrix, new Vec3(p[index * 3], p[index * 3 + 1], p[index * 3 + 2]))
251
+ } else if (segment) {
252
+ // the middle of the segment: its two ends, by index when the lines have them
253
+ const p = d.geometry.positions, idx = d.geometry.indices
254
+ const a = idx ? idx[index * 2] : index * 2, b = idx ? idx[index * 2 + 1] : index * 2 + 1
255
+ node.updateWorld()
256
+ point = through(d.worldMatrix, new Vec3((p[a * 3] + p[b * 3]) / 2, (p[a * 3 + 1] + p[b * 3 + 1]) / 2, (p[a * 3 + 2] + p[b * 3 + 2]) / 2))
257
+ } else point = node.bounds.getCenter()
258
+ // a surface's vertex is no face or instance - its row is the datum, as a pointer over it finds
259
+ return { node, point, distance: 0, normal: null, face: points || segment ? index : null, instance: index !== null && !points && !vertex && !segment ? index : null,
260
+ uv: null, datum: vertex ? d.data[index] ?? null : d.datumAt?.(index) ?? null }
261
+ }
262
+
263
+ /** A point through a 4x4 matrix (column-major). @param {ArrayLike<number>} m @param {Vec3} p @returns {Vec3} */
264
+ function through(m, p) {
265
+ const [x, y, z] = p
266
+ return new Vec3(m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14])
267
+ }
268
+
269
+ /** The text of a tooltip's content, for a screen reader. @param {TooltipContent} content @returns {string} */
270
+ function textOfContent(content) {
271
+ if (content === null || content === undefined || content === false || content === '') return ''
272
+ if (typeof content === 'string' || typeof content === 'number') return String(content).replace(/\s*\n\s*/g, ', ')
273
+ if (typeof globalThis.Node !== 'undefined' && content instanceof globalThis.Node) return (content.textContent ?? '').trim()
274
+ if (typeof content === 'object') {
275
+ const c = /** @type {{ title?: string, rows?: Record<string, unknown> | Array<[string, unknown]> }} */ (content)
276
+ const rows = c.rows ? (Array.isArray(c.rows) ? c.rows : Object.entries(c.rows)).map(([k, v]) => `${k}: ${format(v)}`) : []
277
+ return [c.title, ...rows].filter((s) => s !== undefined && s !== '').join(', ')
278
+ }
279
+ return ''
280
+ }
281
+
282
+ /** @param {unknown} v */
283
+ const format = (v) => (typeof v === 'number' ? v.toLocaleString() : String(v))
284
+
285
+ /**
286
+ * A label for the canvas made from the scene: each chart's summary, or how many things there are.
287
+ * @param {Node} scene @param {number} count @returns {string}
288
+ */
289
+ function autoDescription(scene, count) {
290
+ /** @type {string[]} */
291
+ const parts = []
292
+ scene.traverse((n) => {
293
+ const summary = /** @type {any} */ (n).summary
294
+ if (n !== scene && n.visible && typeof summary === 'function') parts.push(summary.call(n))
295
+ })
296
+ if (!parts.length) parts.push(count ? `3D view of ${count === 1 ? 'one item' : `${count} items`}` : '3D view')
297
+ return parts.join('. ')
298
+ }
package/src/labels.js CHANGED
@@ -14,6 +14,7 @@ import { alignColumns, layoutLabels, leaderEnd, placeBox, stackColumns } from '.
14
14
  * @property {number} dimmed drawn faint because a higher-priority label or geometry was in front (overlap: 'dim')
15
15
  * @property {number} occluded hidden because geometry was in front (overlap: 'hide')
16
16
  * @property {number} offscreen behind the camera or outside the view
17
+ * @property {number} zoomedOut hidden because the view isn't zoomed in to their minScale
17
18
  */
18
19
 
19
20
  /** @typedef {{ el: HTMLDivElement, text: string, className: string, width: number, height: number, showing: boolean, leader: SVGGElement | null, color: string, rule: number, label: FrameLabel | null, interactive: boolean }} Entry */
@@ -67,7 +68,7 @@ export class LabelLayer {
67
68
  */
68
69
  update(labels, overlays = []) {
69
70
  /** @type {LabelStats} */
70
- const stats = { total: labels.length, shown: 0, overlapped: 0, dimmed: 0, occluded: 0, offscreen: 0 }
71
+ const stats = { total: labels.length, shown: 0, overlapped: 0, dimmed: 0, occluded: 0, offscreen: 0, zoomedOut: 0 }
71
72
  this.shown = []
72
73
  this.leaders = []
73
74
  if (!labels.length && !this.#elements.size && !overlays.length && !this.#overlays.size && !this.#root) return stats
@@ -83,6 +84,8 @@ export class LabelLayer {
83
84
  for (const label of labels) {
84
85
  seen.add(label.key)
85
86
  if (!label.visible) stats.offscreen++
87
+ // details for closer up: until then they take no part, so they can't push other labels out
88
+ else if (label.style.minScale && label.scale * rect.height / 2 < label.style.minScale) stats.zoomedOut++
86
89
  else if (label.occluded && label.style.overlap === 'hide') stats.occluded++
87
90
  else active.push({ label, entry: this.#entry(label, root) })
88
91
  }
package/src/scene/Node.js CHANGED
@@ -10,7 +10,7 @@ import { Vec3 } from '../value/Vec3.js'
10
10
  import { checkOptions, describe, fail } from '../validate.js'
11
11
  import { Clip } from '../clip.js'
12
12
 
13
- /** @import { ColorInput, Vec3Input } from '../types.js' */
13
+ /** @import { ColorInput, QuatInput, Vec3Input } from '../types.js' */
14
14
  /** @import { Hit } from '../Raycaster.js' */
15
15
  /** @import { Camera } from './cameras.js' */
16
16
  /** @import { ClipOptions } from '../clip.js' */
@@ -44,7 +44,7 @@ import { Clip } from '../clip.js'
44
44
  * @property {string} [name]
45
45
  * @property {Vec3Input} [position]
46
46
  * @property {Vec3Input | Euler} [rotation] Euler angles in radians, XYZ order
47
- * @property {ArrayLike<number>} [quaternion] [x, y, z, w] - instead of rotation
47
+ * @property {QuatInput} [quaternion] [x, y, z, w] or { x, y, z, w } - instead of rotation
48
48
  * @property {Vec3Input | number} [scale] a number scales uniformly
49
49
  * @property {boolean} [visible]
50
50
  * @property {boolean} [pickable] include in view.pick() (default true)
@@ -54,7 +54,8 @@ import { Clip } from '../clip.js'
54
54
  * @property {UpdateHandler | UpdateHandler[]} [onUpdate]
55
55
  * @property {((hit: Hit) => any) | null} [tooltip] tooltip for anything picked in this subtree; nearer nodes win, and
56
56
  * all win over the View's
57
- * @property {ColorInput | null} [highlight] hover tint for this subtree, over the View's
57
+ * @property {ColorInput | null | false} [highlight] hover tint for this subtree, over the View's; false for none - a
58
+ * floor or wall left plain under a View that tints everything else (null: the View's)
58
59
  * @property {boolean} [highlightWhole] hovering any part of this subtree tints all of it, as one thing - a
59
60
  * piece of furniture, a machine - rather than just the part under the pointer (default false)
60
61
  * @property {((hit: Hit | null) => void) | null} [onHover] called as the thing under the pointer in this subtree changes
@@ -136,7 +137,7 @@ export class Node {
136
137
  onDrag = null
137
138
  /** @type {Outline | null} */
138
139
  #outline = null
139
- /** @type {Color | null} */
140
+ /** @type {Color | null | false} */
140
141
  #highlight = null
141
142
  /** Hovering any part of this subtree tints all of it, not just the part under the pointer. */
142
143
  highlightWhole = false
@@ -164,7 +165,7 @@ export class Node {
164
165
  set rotation(v) { this.#rotation.copy(/** @type {any} */ (v), this.constructor.name) }
165
166
  /** Kept in sync with rotation. Assigning copies, normalised. @returns {Quat} */
166
167
  get quaternion() { return this.#quaternion }
167
- /** @param {ArrayLike<number>} v */
168
+ /** @param {QuatInput} v */
168
169
  set quaternion(v) { this.#quaternion.copy(v, this.constructor.name).normalize() }
169
170
 
170
171
  /** @param {NodeOptions} [options] */
@@ -191,7 +192,7 @@ export class Node {
191
192
  if (typeof fn !== 'function') fail(owner, `'${key}' must be a function, got ${describe(fn)}`)
192
193
  this[key] = fn
193
194
  }
194
- if (options.highlight != null) this.#highlight = Color.fromInput(options.highlight, owner)
195
+ if (options.highlight != null) this.#setHighlight(options.highlight, owner)
195
196
  if (options.highlightWhole !== undefined) {
196
197
  if (typeof options.highlightWhole !== 'boolean') fail(owner, `'highlightWhole' must be true or false, got ${describe(options.highlightWhole)}`)
197
198
  this.highlightWhole = options.highlightWhole
@@ -201,14 +202,23 @@ export class Node {
201
202
  if (options.children) for (const child of options.children) this.add(child)
202
203
  }
203
204
 
204
- /** Hover tint for this subtree, over the View's; set any colour, or null. @returns {Color | null} */
205
+ /**
206
+ * Hover tint for this subtree, over the View's: a colour, false for none, or null to use the View's.
207
+ * @returns {Color | null | false}
208
+ */
205
209
  get highlight() {
206
210
  return this.#highlight
207
211
  }
208
212
 
209
- /** @param {ColorInput | null} value */
213
+ /** @param {ColorInput | null | false} value */
210
214
  set highlight(value) {
211
- this.#highlight = value == null ? null : Color.fromInput(value, this.constructor.name)
215
+ this.#setHighlight(value, this.constructor.name)
216
+ }
217
+
218
+ /** @param {ColorInput | null | false | true} value @param {string} owner */
219
+ #setHighlight(value, owner) {
220
+ if (value === true) fail(owner, "'highlight' is a colour, false for no tint, or null to use the View's - got true")
221
+ this.#highlight = value == null ? null : value === false ? false : Color.fromInput(value, owner)
212
222
  }
213
223
 
214
224
  /**
@@ -612,6 +622,7 @@ export class Node {
612
622
  if (!this.pickable) json.pickable = false
613
623
  if (!this.matrixAutoUpdate) json.matrixAutoUpdate = false
614
624
  if (this.highlight) json.highlight = this.highlight.getStyle()
625
+ else if (this.highlight === false) json.highlight = false
615
626
  if (this.highlightWhole) json.highlightWhole = true
616
627
  if (this.#clip) json.clip = this.#clip.toJSON()
617
628
  if (this.#outline) json.outline = { color: this.#outline.color.getStyle(), width: this.#outline.width }
@@ -44,6 +44,8 @@ export class Scene extends Node {
44
44
  environmentIntensity = 1
45
45
  /** 0-1: how blurred a panorama background is drawn - a soft backdrop that doesn't compete. */
46
46
  backgroundBlur = 0
47
+ /** @internal Set by the View drawing it, from its reducedMotion: charts' transitions and flows stand still. */
48
+ motionReduced = false
47
49
  /** @type {Fog | null} */
48
50
  #fog = null
49
51
 
@@ -20,7 +20,10 @@ const tmp3 = new Float32Array(9)
20
20
  export function aimAt(node, target) {
21
21
  if (node.position.equals(target, 1e-9)) return
22
22
  const dir = target.clone().sub(node.position).normalize()
23
- const up = Math.abs(dir[1]) > 0.9999 ? [0, 0, -1] : UP
23
+ // world up keeps the picture upright, and turns it smoothly with the view right up to straight up
24
+ // or down - the orbit stops a thousandth of a radian short. Only within a hair of exactly vertical
25
+ // (the 'top' preset's view), where up would no longer say which way the screen faces, is north up
26
+ const up = Math.hypot(dir[0], dir[2]) < 5e-4 ? [0, 0, -1] : UP
24
27
  mat4.targetTo(tmp, node.position, target, up)
25
28
  const q = quat.normalize(new Float32Array(4), quat.fromMat3(new Float32Array(4), mat3.fromMat4(tmp3, tmp)))
26
29
  node.quaternion.copy(q)