phoria2 1.1.0 → 1.3.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 (81) hide show
  1. package/README.md +70 -13
  2. package/dist/bloom.js +4 -4
  3. package/dist/bloom.js.map +1 -1
  4. package/dist/charts.js +538 -508
  5. package/dist/charts.js.map +1 -1
  6. package/dist/core.js +4270 -3652
  7. package/dist/core.js.map +1 -1
  8. package/dist/edit.js +65 -65
  9. package/dist/edit.js.map +1 -1
  10. package/dist/explain.js +6 -2
  11. package/dist/explain.js.map +1 -1
  12. package/dist/gltf.js +116 -116
  13. package/dist/gltf.js.map +1 -1
  14. package/dist/keyboard.js +188 -0
  15. package/dist/keyboard.js.map +1 -0
  16. package/dist/motion.js +35 -35
  17. package/dist/motion.js.map +1 -1
  18. package/dist/particles.js +188 -115
  19. package/dist/particles.js.map +1 -1
  20. package/dist/passes.js +29 -29
  21. package/dist/passes.js.map +1 -1
  22. package/dist/phoria2.js +1 -1
  23. package/dist/picking.js +32 -16
  24. package/dist/picking.js.map +1 -1
  25. package/dist/snapshot.js +1 -1
  26. package/dist/tooltip.js +1 -1
  27. package/dist/types/OrbitControl.d.ts +6 -2
  28. package/dist/types/Raycaster.d.ts +10 -0
  29. package/dist/types/View.d.ts +70 -1
  30. package/dist/types/area.d.ts +18 -0
  31. package/dist/types/charts/Chart.d.ts +7 -0
  32. package/dist/types/explain.d.ts +3 -0
  33. package/dist/types/frame.d.ts +7 -0
  34. package/dist/types/geometry/Geometry.d.ts +24 -2
  35. package/dist/types/index.d.ts +2 -0
  36. package/dist/types/keyboard.d.ts +48 -0
  37. package/dist/types/labels.d.ts +4 -0
  38. package/dist/types/material/Material.d.ts +15 -0
  39. package/dist/types/material/Texture.d.ts +18 -2
  40. package/dist/types/particles/Emitter.d.ts +39 -2
  41. package/dist/types/scene/Node.d.ts +19 -14
  42. package/dist/types/scene/drawables.d.ts +54 -15
  43. package/dist/types/scene/lights.d.ts +16 -2
  44. package/dist/types/shadow.d.ts +2 -1
  45. package/dist/types/types.d.ts +14 -0
  46. package/dist/types/value/Color.d.ts +0 -4
  47. package/dist/types/value/Quat.d.ts +4 -2
  48. package/dist/types/webgl/resources.d.ts +12 -2
  49. package/dist/types/webgl/shaders.d.ts +7 -2
  50. package/llms-full.txt +106 -10
  51. package/package.json +4 -1
  52. package/schema/scene.schema.json +67 -1
  53. package/src/OrbitControl.js +7 -3
  54. package/src/Raycaster.js +58 -0
  55. package/src/View.js +220 -41
  56. package/src/area.js +131 -0
  57. package/src/charts/Chart.js +20 -2
  58. package/src/charts/marks.js +29 -2
  59. package/src/explain.js +6 -2
  60. package/src/frame.js +29 -9
  61. package/src/geometry/Geometry.js +32 -4
  62. package/src/index.js +1 -0
  63. package/src/keyboard.js +298 -0
  64. package/src/labels.js +4 -1
  65. package/src/material/Material.js +9 -2
  66. package/src/material/Texture.js +55 -2
  67. package/src/particles/Emitter.js +121 -10
  68. package/src/scene/Node.js +20 -9
  69. package/src/scene/Scene.js +2 -0
  70. package/src/scene/cameras.js +4 -1
  71. package/src/scene/drawables.js +115 -15
  72. package/src/scene/lights.js +40 -6
  73. package/src/shadow.js +27 -4
  74. package/src/types.js +6 -0
  75. package/src/value/Color.js +21 -0
  76. package/src/value/Quat.js +15 -6
  77. package/src/watch.js +14 -2
  78. package/src/webgl/WebGLRenderer.js +36 -5
  79. package/src/webgl/picking.js +22 -1
  80. package/src/webgl/resources.js +168 -14
  81. package/src/webgl/shaders.js +110 -11
@@ -102,6 +102,10 @@
102
102
  "properties": {
103
103
  "type": {
104
104
  "const": "points"
105
+ },
106
+ "sort": {
107
+ "type": "boolean",
108
+ "description": "Draw the points furthest first each frame, for soft points that cover what is behind them."
105
109
  }
106
110
  },
107
111
  "unevaluatedProperties": false
@@ -113,6 +117,15 @@
113
117
  "properties": {
114
118
  "type": {
115
119
  "const": "lines"
120
+ },
121
+ "arrows": {
122
+ "enum": ["none", "start", "end", "both"],
123
+ "description": "Arrowheads at the ends of each chain of joined segments."
124
+ },
125
+ "arrowSize": {
126
+ "type": "number",
127
+ "description": "Arrowhead length in pixels; 0 sizes them from the material's width.",
128
+ "minimum": 0
116
129
  }
117
130
  },
118
131
  "unevaluatedProperties": false
@@ -202,6 +215,11 @@
202
215
  "type": "null"
203
216
  }
204
217
  ]
218
+ },
219
+ "minScale": {
220
+ "type": "number",
221
+ "description": "Show the label only when zoomed in this far: CSS pixels to one world unit at its point (0, always).",
222
+ "minimum": 0
205
223
  }
206
224
  },
207
225
  "unevaluatedProperties": false
@@ -327,6 +345,16 @@
327
345
  },
328
346
  "castShadow": {
329
347
  "type": "boolean"
348
+ },
349
+ "shadowBounds": {
350
+ "type": ["object", "null"],
351
+ "description": "The part of the world, in world units, that has shadows; null (the default) follows what casts.",
352
+ "required": ["min", "max"],
353
+ "properties": {
354
+ "min": { "$ref": "#/$defs/vec3" },
355
+ "max": { "$ref": "#/$defs/vec3" }
356
+ },
357
+ "additionalProperties": false
330
358
  }
331
359
  },
332
360
  "unevaluatedProperties": false
@@ -462,13 +490,16 @@
462
490
  "description": "Anything of your own."
463
491
  },
464
492
  "highlight": {
465
- "description": "Hover tint for this subtree, or null for none.",
493
+ "description": "Hover tint for this subtree; false for none, null to use the View's.",
466
494
  "anyOf": [
467
495
  {
468
496
  "$ref": "#/$defs/color"
469
497
  },
470
498
  {
471
499
  "type": "null"
500
+ },
501
+ {
502
+ "const": false
472
503
  }
473
504
  ]
474
505
  },
@@ -898,6 +929,20 @@
898
929
  "description": "Lines: width in pixels.",
899
930
  "minimum": 0
900
931
  },
932
+ "dash": {
933
+ "type": "number",
934
+ "description": "Lines: dashed, with dashes this long in the model's units; 0 is solid.",
935
+ "minimum": 0
936
+ },
937
+ "gap": {
938
+ "type": "number",
939
+ "description": "Lines: the gaps between dashes; 0 is as long as the dashes.",
940
+ "minimum": 0
941
+ },
942
+ "dashOffset": {
943
+ "type": "number",
944
+ "description": "Lines: moves the dashes along the lines - animate it for flow."
945
+ },
901
946
  "alphaTest": {
902
947
  "type": "number",
903
948
  "description": "Cut-outs: drop pixels with less alpha.",
@@ -1262,6 +1307,27 @@
1262
1307
  "type": "number"
1263
1308
  }
1264
1309
  },
1310
+ "sizes": {
1311
+ "type": "array",
1312
+ "description": "Points: one per vertex, a multiple of the material's size.",
1313
+ "items": {
1314
+ "type": "number"
1315
+ }
1316
+ },
1317
+ "opacities": {
1318
+ "type": "array",
1319
+ "description": "Points: one per vertex, 0-1, times the material's opacity.",
1320
+ "items": {
1321
+ "type": "number"
1322
+ }
1323
+ },
1324
+ "angles": {
1325
+ "type": "array",
1326
+ "description": "Points: one per vertex, radians anticlockwise on screen.",
1327
+ "items": {
1328
+ "type": "number"
1329
+ }
1330
+ },
1265
1331
  "indices": {
1266
1332
  "type": "array",
1267
1333
  "items": {
@@ -255,8 +255,12 @@ export class OrbitControl {
255
255
  this.target.add(move)
256
256
  }
257
257
 
258
- /** Apply inertia and auto-rotate; the View calls this each frame. @param {number} dt seconds */
259
- update(dt) {
258
+ /**
259
+ * Apply inertia and auto-rotate; the View calls this each frame.
260
+ * @param {number} dt seconds
261
+ * @param {boolean} [still] hold auto-rotation - the View passes its reducedMotion
262
+ */
263
+ update(dt, still = false) {
260
264
  if (!this.enabled) return
261
265
  if (this.#pointers.size === 0) {
262
266
  const v = this.#velocity
@@ -267,7 +271,7 @@ export class OrbitControl {
267
271
  v.yaw *= keep
268
272
  v.pitch *= keep
269
273
  }
270
- if (this.autoRotate) this.rotate(-(this.autoRotate === true ? 0.5 : this.autoRotate) * dt, 0)
274
+ if (this.autoRotate && !still) this.rotate(-(this.autoRotate === true ? 0.5 : this.autoRotate) * dt, 0)
271
275
  }
272
276
  }
273
277
 
package/src/Raycaster.js CHANGED
@@ -10,6 +10,7 @@ import { BVH_MIN_TRIANGLES, bvhOf, walk } from './geometry/bvh.js'
10
10
  /** @import { InstancedMesh, Lines, Mesh, Points } from './scene/drawables.js' */
11
11
 
12
12
  /** The section plane cutting what is being tested: world plane, and whether its cut faces are filled. @typedef {{ plane: Float32Array, cap: boolean }} Cut */
13
+ /** @internal A surface ready for many rays: a mesh or one instance, with its world matrix and bounding sphere. @typedef {{ node: any, world: Float32Array, instance: number | null, cut: Cut | null, x: number, y: number, z: number, radius: number }} Solid */
13
14
 
14
15
  // Nodes are told apart by markers (drawMode, instanceMatrices) rather than instanceof, so picking
15
16
  // doesn't pull every node class into an app's bundle.
@@ -119,6 +120,63 @@ export class Raycaster {
119
120
  return hits.filter((h) => h.distance >= this.near && h.distance <= this.far).sort((x, y) => x.distance - y.distance)
120
121
  }
121
122
 
123
+ /** @internal
124
+ * The solid surfaces of a frame's draw list, ready for many rays: each mesh and each instance with its
125
+ * world matrix and bounding sphere worked out once, rather than once per ray - for label occlusion,
126
+ * which casts a ray per label.
127
+ * @param {{ node: Node, worldMatrix: Float32Array, instances: { count: number, instanceMatrices: Float32Array } | null, clip: { plane: Float32Array, cap: unknown } | null }[]} items
128
+ * @returns {Solid[]}
129
+ */
130
+ static solids(items) {
131
+ /** @type {Solid[]} */
132
+ const out = []
133
+ for (const item of items) {
134
+ const n = /** @type {any} */ (item.node)
135
+ if (n.drawMode !== 'triangles' || !n.geometry.vertexCount) continue
136
+ const box = n.geometry.bounds, center = box.getCenter(), radius = box.getRadius()
137
+ const cut = item.clip ? { plane: item.clip.plane, cap: !!item.clip.cap } : null
138
+ /** @param {Float32Array} world @param {number | null} instance */
139
+ const add = (world, instance) => {
140
+ const c = center.clone().applyMatrix4(world)
141
+ const scale = Math.max(Math.hypot(world[0], world[1], world[2]), Math.hypot(world[4], world[5], world[6]), Math.hypot(world[8], world[9], world[10]))
142
+ out.push({ node: n, world, instance, cut, x: c[0], y: c[1], z: c[2], radius: radius * scale })
143
+ }
144
+ if (item.instances) {
145
+ for (let i = 0; i < item.instances.count; i++) {
146
+ add(mat4.multiply(new Float32Array(16), item.worldMatrix, item.instances.instanceMatrices.subarray(i * 16, i * 16 + 16)), i)
147
+ }
148
+ } else add(Float32Array.from(item.worldMatrix), null)
149
+ }
150
+ return out
151
+ }
152
+
153
+ /** @internal
154
+ * Whether the ray meets any of these solids nearer than `distance` - the first one found settles it,
155
+ * so nothing is sorted. One node, or one instance of it, can be left out: what a label is on.
156
+ * @param {Solid[]} solids from Raycaster.solids() @param {number} distance
157
+ * @param {Node | null} [skip] @param {number | null} [skipInstance] null for the whole node
158
+ * @returns {boolean}
159
+ */
160
+ blocked(solids, distance, skip = null, skipInstance = null) {
161
+ const o = this.ray.origin, d = this.ray.direction
162
+ /** @type {Hit[]} */
163
+ const hits = []
164
+ for (const s of solids) {
165
+ if (s.node === skip && (skipInstance === null || s.instance === skipInstance)) continue
166
+ // its bounding sphere first: missed, or wholly beyond the distance or behind the ray, it can't block
167
+ const ox = s.x - o[0], oy = s.y - o[1], oz = s.z - o[2]
168
+ const along = ox * d[0] + oy * d[1] + oz * d[2]
169
+ const off = ox * ox + oy * oy + oz * oz - along * along, r2 = s.radius * s.radius
170
+ if (off > r2) continue
171
+ const half = Math.sqrt(r2 - off)
172
+ if (along - half > distance || along + half < 0) continue
173
+ hits.length = 0
174
+ this.#mesh(s.node, s.world, s.instance, hits, s.cut)
175
+ if (hits.length && hits[0].distance < distance) return true
176
+ }
177
+ return false
178
+ }
179
+
122
180
  /** @internal
123
181
  * Where the ray meets one instance, point or segment of a drawable - the CPU half of GPU picking,
124
182
  * which finds what is under the pointer and leaves the exact point, face and uv to this.
package/src/View.js CHANGED
@@ -4,6 +4,7 @@ import { DRAG_START, OrbitControl } from './OrbitControl.js'
4
4
  import { cameraPreset, fitPoints, framingScale, presetDirection } from './presets.js'
5
5
  import { EASES } from './ease.js'
6
6
  import { Raycaster } from './Raycaster.js'
7
+ import { pickArea } from './area.js'
7
8
  import { ChangeWatch } from './watch.js'
8
9
  import { Box3 } from './value/Box3.js'
9
10
  import { Color } from './value/Color.js'
@@ -27,6 +28,7 @@ import { checkOneOf, checkOptions, checkRange, describe, fail, warnOnce } from '
27
28
  /** @import { CameraOptions } from './scene/cameras.js' */
28
29
  /** @import { ColorInput, Vec3Input } from './types.js' */
29
30
  /** @import { Node } from './scene/Node.js' */
31
+ /** @import { Keyboard, KeyboardOptions } from './keyboard.js' */
30
32
 
31
33
  /**
32
34
  * @typedef {'auto' | 'always' | 'manual'} RenderMode
@@ -65,13 +67,23 @@ import { checkOneOf, checkOptions, checkRange, describe, fail, warnOnce } from '
65
67
  * additive points and lines (default off). true for the defaults, or { strength, radius, threshold }
66
68
  * @property {boolean} [autoLight] light a scene that has no lights, with one console note (default true)
67
69
  * @property {number} [maxDelta] longest frame step in seconds, so a background tab doesn't jump (default 0.1)
70
+ * @property {'auto' | boolean} [reducedMotion] cut motion short, for people it makes unwell: frame() jumps rather
71
+ * than glides, auto-rotation holds, and charts change at once with their flows still. Default 'auto': as the
72
+ * system's setting (prefers-reduced-motion), followed as it changes
73
+ * @property {boolean | KeyboardOptions} [keyboard] use without a pointer: the canvas joins the page's tab order, and
74
+ * the arrow keys step through what can be hovered or clicked - each with the hover tint, the tooltip and hover
75
+ * handlers - with Enter or Space to click and Escape to leave. A screen reader hears each one. { label: (hit) =>
76
+ * text } says what it hears (default: the tooltip's text, the data, or the name). Default false
77
+ * @property {string | null} [description] what the view shows, for screen readers: the canvas's label. With keyboard
78
+ * on and no description, one is made from the scene (charts describe themselves)
68
79
  */
69
80
 
70
81
  /** @typedef {'click' | 'hover'} ViewEvent */
71
82
  /** @typedef {(hit: Hit | null, event: PointerEvent | MouseEvent | null) => void} ViewHandler */
72
83
 
73
84
  const OPTIONS = ['canvas', 'scene', 'camera', 'orbit', 'background', 'renderMode', 'framing', 'highlight', 'tooltip', 'pixelRatio',
74
- 'antialias', 'outlineAntialias', 'context', 'toneMapping', 'exposure', 'bloom', 'autoLight', 'maxDelta']
85
+ 'antialias', 'outlineAntialias', 'context', 'toneMapping', 'exposure', 'bloom', 'autoLight', 'maxDelta', 'reducedMotion',
86
+ 'keyboard', 'description']
75
87
  const FRAMING_OPTIONS = ['keep', 'aspect']
76
88
  const HINTS = {
77
89
  renderer: 'the View makes its renderer - read it from view.renderer',
@@ -90,6 +102,8 @@ const EVENT_HINTS = {
90
102
  }
91
103
  // how far the highlight colour is mixed in
92
104
  const HIGHLIGHT_AMOUNT = 0.45
105
+ // what the keyboard's focus is tinted with when nothing says otherwise - focus must always show
106
+ const FOCUS_COLOR = Color.fromInput('#ffcc00')
93
107
  // views that get a WebGL context of their own before 'auto' starts sharing one
94
108
  const OWN_CONTEXTS = 4
95
109
  // above this many things to test (instances, triangles, points, segments), picking goes to the
@@ -161,6 +175,15 @@ export class View {
161
175
  #tooltipClass = ''
162
176
  #tooltipLoading = false
163
177
  /** @type {Color | null} */ #highlight = null
178
+ /** @type {'auto' | boolean} */ #motion = 'auto'
179
+ // keyboard use: its code loads when asked for; the thing it has stepped to shows as hovered
180
+ /** @type {Keyboard | null} */ #keyboard = null
181
+ /** @type {Hit | null} */ #keyFocus = null
182
+ /** @type {string | null} */ #description = null
183
+ // the canvas had no role, so describing it gave it one - taken away again with the description
184
+ #gaveRole = false
185
+ // the system's prefers-reduced-motion, kept up to date
186
+ #systemReduced = false
164
187
  /** @type {{ x: number, y: number, clientX: number, clientY: number, event: PointerEvent } | null} */ #pointer = null
165
188
  #pickPending = false
166
189
  // a hover pick is out on the GPU
@@ -227,6 +250,7 @@ export class View {
227
250
  this.invalidate()
228
251
  }
229
252
  if (options.autoLight !== undefined) this.autoLight = !!options.autoLight
253
+ if (options.reducedMotion !== undefined) this.#setMotion(options.reducedMotion)
230
254
  if (options.maxDelta !== undefined) this.maxDelta = checkRange('View', 'maxDelta', options.maxDelta, 0, Infinity, { above: true })
231
255
  if (options.renderMode !== undefined) this.renderMode = checkOneOf('View', 'renderMode', options.renderMode, ['auto', 'always', 'manual'])
232
256
  if (options.pixelRatio !== undefined) {
@@ -247,6 +271,8 @@ export class View {
247
271
  }
248
272
  if (options.highlight !== undefined) this.#setHighlight(options.highlight)
249
273
  if (options.tooltip !== undefined) this.tooltip(options.tooltip)
274
+ if (options.description !== undefined) this.description = options.description
275
+ if (options.keyboard !== undefined && options.keyboard !== false) this.#loadKeyboard(options.keyboard)
250
276
  this.#labels = new LabelLayer(this.canvas, {
251
277
  interactive: (label) => labelHandlers(label, 'click').length > 0 || labelHandlers(label, 'hover').length > 0,
252
278
  on: (type, label, e) => this.#labelPointer(type, label, e),
@@ -324,6 +350,51 @@ export class View {
324
350
  this.invalidate()
325
351
  }
326
352
 
353
+ /**
354
+ * Is motion cut short - glides, auto-rotation, chart transitions? Set true or false, or 'auto' (the
355
+ * default) to follow the system's setting. Read it for motion of your own, such as a timeline.
356
+ * @returns {boolean}
357
+ */
358
+ get reducedMotion() { return this.#motion === 'auto' ? this.#systemReduced : this.#motion }
359
+ /** @param {'auto' | boolean} v */
360
+ set reducedMotion(v) { this.#setMotion(v) }
361
+
362
+ /** @param {'auto' | boolean} v */
363
+ #setMotion(v) {
364
+ if (v !== 'auto' && typeof v !== 'boolean') fail('View', `'reducedMotion' is 'auto' (as the system's setting), true or false, got ${describe(v)}`)
365
+ this.#motion = v
366
+ this.invalidate()
367
+ }
368
+
369
+ /**
370
+ * What the view shows, for screen readers - the canvas's label - or null. With keyboard on and no
371
+ * description, one is made from the scene.
372
+ * @returns {string | null}
373
+ */
374
+ get description() { return this.#description }
375
+ /** @param {string | null} v */
376
+ set description(v) {
377
+ if (v !== null && typeof v !== 'string') fail('View', `'description' is text for screen readers, or null, got ${describe(v)}`)
378
+ this.#description = v
379
+ if (this.#keyboard) {
380
+ this.#keyboard.setDescription(v)
381
+ return
382
+ }
383
+ const canvas = this.canvas
384
+ if (v === null) {
385
+ canvas.removeAttribute('aria-label')
386
+ if (this.#gaveRole) canvas.removeAttribute('role')
387
+ this.#gaveRole = false
388
+ return
389
+ }
390
+ canvas.setAttribute('aria-label', v)
391
+ // a picture, to a screen reader, unless the page has said otherwise
392
+ if (!canvas.hasAttribute('role')) {
393
+ canvas.setAttribute('role', 'img')
394
+ this.#gaveRole = true
395
+ }
396
+ }
397
+
327
398
  /** Canvas width in CSS pixels. */
328
399
  get width() { return this.#width }
329
400
  /** Canvas height in CSS pixels. */
@@ -397,6 +468,7 @@ export class View {
397
468
  this.time += dt
398
469
  this.#onFrame?.(dt, this.time)
399
470
  // the same order as the live loop, so a stepped frame is the frame a user sees
471
+ this.scene.motionReduced = this.reducedMotion
400
472
  this.scene.update(dt, this.time)
401
473
  this.#updateControls(dt)
402
474
  return this.#render(true)
@@ -411,7 +483,7 @@ export class View {
411
483
  if (this.#flight) return this.#fly(dt)
412
484
  if (!this.controls) return
413
485
  const waiting = this.#autoFrame && samePlace(cameraPlace(this.camera), this.#autoFrameFrom)
414
- this.controls.update(dt)
486
+ this.controls.update(dt, this.reducedMotion)
415
487
  if (waiting) this.#autoFrameFrom = cameraPlace(this.camera)
416
488
  }
417
489
 
@@ -450,8 +522,8 @@ export class View {
450
522
  // remember what was drawn - including what drawing changed, such as fitting the camera - so
451
523
  // the next frame compares against it and doesn't draw the same picture again
452
524
  if (this.renderMode === 'auto') this.#watch.changed(this.scene, this.camera)
453
- // things may have moved under a still pointer
454
- if (this.#pointer && this.#needsHover()) this.#pickPending = true
525
+ // things may have moved under a still pointer - or the keyboard's focus moved on screen
526
+ if ((this.#pointer || this.#keyFocus) && this.#needsHover()) this.#pickPending = true
455
527
  return frame
456
528
  }
457
529
 
@@ -493,43 +565,26 @@ export class View {
493
565
  * @param {Node | Box3} [target] (default the scene)
494
566
  * @param {{ padding?: number, direction?: CameraPreset | Vec3Input, duration?: number }} [options] padding is
495
567
  * the margin around it, as a fraction of its size: 0 is a tight fit, 0.2 (the default) leaves a fifth.
496
- * duration is in seconds (default 0: straight there); a press or the wheel stops the glide
568
+ * duration is in seconds (default 0: straight there); a press or the wheel stops the glide, and with
569
+ * reducedMotion it goes straight there
497
570
  * @returns {this}
498
571
  */
499
572
  frame(target = this.scene, { padding = 0.2, direction: towards, duration = 0 } = {}) {
500
- if (!(padding >= 0)) fail('View', `frame() padding is the margin around, as a fraction of the size (0.2 = 20%), got ${describe(padding)}`)
501
- if (padding >= 1) warnOnce(this, `frame() padding ${padding} leaves a margin of ${Math.round(padding * 100)}% - it is a fraction of the size (0.2 = 20%)`)
502
573
  checkRange('View', 'frame() duration', duration, 0)
503
574
  const cam = /** @type {any} */ (this.camera)
504
575
  const from = placeOf(cam)
505
576
  this.#flight = null
506
- const direction = towards === undefined ? cam.position.clone().sub(cam.target ?? new Vec3()).normalize()
507
- : typeof towards === 'string' ? presetDirection(/** @type {CameraPreset} */ (towards)) : Vec3.fromInput(towards, 'View').normalize()
508
- if (towards !== undefined && !(direction.len() > 0)) fail('View', `frame() direction is a preset name or a vector from the target towards the camera, got ${describe(towards)}`)
509
- if (direction.len() === 0) direction.set(0, 0.3, 1).normalize()
510
- const points = target instanceof Box3 ? corners(target) : this.#drawnPoints(target, direction)
577
+ const fit = this.#fit('frame', target, padding, towards)
511
578
  // an explicit frame replaces the preset camera's fit on the first frame
512
579
  this.#autoFrame = false
513
- if (!points.length) return this
514
- const ortho = cam.constructor.type === 'orthographic-camera'
515
- const { center, size } = fitPoints(points, direction, { aspect: cam.aspect, fov: ortho ? undefined : cam.fov, padding })
516
- const radius = Math.max(1e-3, ...points.map((p) => Math.hypot(p[0] - center[0], p[1] - center[1], p[2] - center[2])))
517
- if (ortho) {
518
- cam.height = size
519
- cam.position.copy(center).add(direction.clone().multiplyScalar(radius * 4))
520
- cam.far = Math.max(cam.far, radius * 10)
521
- } else {
522
- const distance = size
523
- cam.position.copy(center).add(direction.clone().multiplyScalar(distance))
524
- // keep the near/far range sensible for the model's size
525
- cam.near = Math.max(1e-3, distance / 1000)
526
- cam.far = Math.max(cam.far, distance * 10)
527
- }
528
- cam.target = center
580
+ if (!fit) return this
581
+ setPlace(cam, fit.place)
582
+ cam.near = fit.near ?? cam.near
583
+ cam.far = Math.max(cam.far, fit.far)
529
584
  // framed for the canvas as it is now
530
585
  this.#designed = cam.aspect
531
586
  this.#applied = 1
532
- if (duration > 0) {
587
+ if (duration > 0 && !this.reducedMotion) {
533
588
  // the camera starts where it was and glides there, frame by frame
534
589
  this.#flight = { from, to: placeOf(cam), time: 0, duration }
535
590
  setPlace(cam, from)
@@ -538,6 +593,56 @@ export class View {
538
593
  return this
539
594
  }
540
595
 
596
+ /**
597
+ * Where frame() would put the camera, without moving it: its position, target and - for an
598
+ * orthographic camera - height, or null when there is nothing to frame. For a camera of your own
599
+ * that eases after something moving rather than jumping each frame:
600
+ *
601
+ * const aim = view.framing(debris)
602
+ * if (aim) {
603
+ * view.camera.position.lerp(aim.position, 0.1)
604
+ * view.camera.target.lerp(aim.target, 0.1)
605
+ * }
606
+ * @param {Node | Box3} [target] (default the scene)
607
+ * @param {{ padding?: number, direction?: CameraPreset | Vec3Input }} [options] as frame()
608
+ * @returns {{ position: Vec3, target: Vec3, height?: number } | null}
609
+ */
610
+ framing(target = this.scene, { padding = 0.2, direction } = {}) {
611
+ const fit = this.#fit('framing', target, padding, direction)
612
+ if (!fit) return null
613
+ const { position, target: at, height } = fit.place
614
+ return height === undefined ? { position, target: at } : { position, target: at, height }
615
+ }
616
+
617
+ /**
618
+ * The camera's place to frame something, worked out without moving it - for frame() and framing().
619
+ * @param {string} method its caller, for errors @param {Node | Box3} target @param {number} padding
620
+ * @param {CameraPreset | Vec3Input | undefined} towards
621
+ * @returns {{ place: Place, near: number | undefined, far: number } | null}
622
+ */
623
+ #fit(method, target, padding, towards) {
624
+ if (!(padding >= 0)) fail('View', `${method}() padding is the margin around, as a fraction of the size (0.2 = 20%), got ${describe(padding)}`)
625
+ if (padding >= 1) warnOnce(this, `${method}() padding ${padding} leaves a margin of ${Math.round(padding * 100)}% - it is a fraction of the size (0.2 = 20%)`)
626
+ const cam = /** @type {any} */ (this.camera)
627
+ const direction = towards === undefined ? cam.position.clone().sub(cam.target ?? new Vec3()).normalize()
628
+ : typeof towards === 'string' ? presetDirection(/** @type {CameraPreset} */ (towards)) : Vec3.fromInput(towards, 'View').normalize()
629
+ if (towards !== undefined && !(direction.len() > 0)) fail('View', `${method}() direction is a preset name or a vector from the target towards the camera, got ${describe(towards)}`)
630
+ if (direction.len() === 0) direction.set(0, 0.3, 1).normalize()
631
+ const points = target instanceof Box3 ? corners(target) : this.#drawnPoints(target, direction)
632
+ if (!points.length) return null
633
+ const ortho = cam.constructor.type === 'orthographic-camera'
634
+ const { center, size } = fitPoints(points, direction, { aspect: cam.aspect, fov: ortho ? undefined : cam.fov, padding })
635
+ const radius = Math.max(1e-3, ...points.map((p) => Math.hypot(p[0] - center[0], p[1] - center[1], p[2] - center[2])))
636
+ if (ortho) {
637
+ return { place: { position: center.clone().add(direction.clone().multiplyScalar(radius * 4)), target: center, height: size },
638
+ near: undefined, far: radius * 10 }
639
+ }
640
+ // keep the near/far range sensible for the model's size
641
+ const distance = size
642
+ return { place: { position: center.clone().add(direction.clone().multiplyScalar(distance)), target: center },
643
+ near: Math.max(1e-3, distance / 1000), far: distance * 10 }
644
+ }
645
+
541
646
  /**
542
647
  * One frame of a glide: round the target as it moves, closing the distance evenly, so the picture
543
648
  * turns and zooms rather than cutting through what is in view.
@@ -699,6 +804,21 @@ export class View {
699
804
  return new Mat4().multiplyMatrices(camera.projectionMatrix, camera.viewMatrix)
700
805
  }
701
806
 
807
+ /**
808
+ * Everything inside an area of the canvas, nearest first: a box `{ x, y, width, height }` or a
809
+ * lasso of `[x, y]` points, CSS pixels from the canvas's top left. Each point, instance and line
810
+ * segment is its own hit, found by where it is (a mesh by its middle) - hidden behind others or
811
+ * not - so a box dragged over a scatter selects that region of it.
812
+ *
813
+ * const picked = view.pickArea({ x: 40, y: 30, width: 200, height: 120 }).map((h) => h.datum)
814
+ * @param {import('./area.js').PickArea} area
815
+ * @returns {Hit[]}
816
+ */
817
+ pickArea(area) {
818
+ this.scene.updateWorldTree()
819
+ return pickArea(this.scene, this.camera, this.#viewProjection(), this.#width, this.#height, area)
820
+ }
821
+
702
822
  /** Every hit under a canvas position, nearest first. @param {number | MouseEvent} x @param {number} [y] @returns {Hit[]} */
703
823
  pickAll(x, y) {
704
824
  if (typeof x === 'object') {
@@ -774,6 +894,39 @@ export class View {
774
894
  })
775
895
  }
776
896
 
897
+ /** @param {true | KeyboardOptions} options */
898
+ #loadKeyboard(options) {
899
+ if (options !== true && (typeof options !== 'object' || options === null || Array.isArray(options))) {
900
+ fail('View', `'keyboard' is true, false or { label: (hit) => text }, got ${describe(options)}`)
901
+ }
902
+ const o = options === true ? {} : options
903
+ checkOptions('View', o, ['label'], { describe: "use 'label: (hit) => text'", text: "use 'label: (hit) => text'" }, 'keyboard')
904
+ if (o.label !== undefined && typeof o.label !== 'function') fail('View', `keyboard.label is a function (hit) => text, got ${describe(o.label)}`)
905
+ import('./keyboard.js').then(({ Keyboard }) => {
906
+ if (this.#disposed) return
907
+ this.#keyboard = new Keyboard(this, {
908
+ focus: (hit) => {
909
+ this.#keyFocus = hit
910
+ this.#pickPending = true
911
+ this.invalidate()
912
+ },
913
+ press: (hit) => {
914
+ for (const fn of handlersAbove(hit.node, 'click')) fn(hit)
915
+ for (const fn of this.#handlers.click) fn(hit, null)
916
+ },
917
+ tooltip: (hit) => this.#tooltipFor(hit)?.(hit),
918
+ viewWide: () => this.#handlers.hover.size > 0 || this.#handlers.click.size > 0 || !!this.#tooltipContent,
919
+ }, o)
920
+ this.#keyboard.setDescription(this.#description)
921
+ })
922
+ }
923
+
924
+ /** Where the keyboard's focus shows - its middle, on screen - in place of the pointer. @param {Hit} hit */
925
+ #keyAt(hit) {
926
+ const { x, y } = this.project(hit.point), rect = this.canvas.getBoundingClientRect()
927
+ return { x, y, clientX: rect.left + x, clientY: rect.top + y, event: null }
928
+ }
929
+
777
930
  /** The nearest node tooltip above the hit, or the View's. @param {Hit} hit */
778
931
  #tooltipFor(hit) {
779
932
  for (let n = /** @type {any} */ (hit.node); n; n = n.parent) if (n.tooltip) return n.tooltip
@@ -822,7 +975,8 @@ export class View {
822
975
  sharedContext: this.renderer.shared,
823
976
  frame, labels: this.#labelStats, bounds: drawn, nodes, autoLight: this.autoLight,
824
977
  contextLost: !!this.renderer.gl?.isContextLost?.(),
825
- interaction: { hover: this.#handlers.hover.size, click: this.#handlers.click.size, tooltip: !!this.#tooltipContent, highlight: !!this.#highlight },
978
+ interaction: { hover: this.#handlers.hover.size, click: this.#handlers.click.size, tooltip: !!this.#tooltipContent, highlight: !!this.#highlight,
979
+ keyboard: !!this.#keyboard, reducedMotion: this.reducedMotion, description: this.canvas.getAttribute?.('aria-label') ?? this.#description },
826
980
  })
827
981
  }
828
982
 
@@ -839,6 +993,8 @@ export class View {
839
993
  this.controls?.dispose()
840
994
  this.#labels.dispose()
841
995
  this.#tooltip?.dispose()
996
+ this.#keyboard?.dispose()
997
+ if (this.#description !== null && !this.#keyboard) this.description = null
842
998
  this.renderer.dispose(this.scene)
843
999
  if (this.#ownContext) ownContexts--
844
1000
  if (this.#ownCanvas) this.canvas.remove()
@@ -879,6 +1035,7 @@ export class View {
879
1035
  if (this.#running) {
880
1036
  this.time += dt
881
1037
  this.#onFrame?.(dt, this.time)
1038
+ this.scene.motionReduced = this.reducedMotion
882
1039
  this.scene.update(dt, this.time)
883
1040
  }
884
1041
  this.#updateControls(dt)
@@ -904,6 +1061,8 @@ export class View {
904
1061
  this.#pickPending = false
905
1062
  if (!this.#needsHover()) return
906
1063
  const p = this.#pointer
1064
+ // what the keyboard stepped to is shown until the pointer moves
1065
+ if (this.#keyFocus) return this.#setHovered(this.#keyFocus, this.#keyAt(this.#keyFocus))
907
1066
  if (this.#labelHover) return this.#setHovered(this.#labelHover)
908
1067
  if (!p || this.#dragging) return this.#setHovered(null)
909
1068
  const frame = this.#gpuFrame()
@@ -924,9 +1083,11 @@ export class View {
924
1083
  })
925
1084
  }
926
1085
 
927
- /** @param {Hit | null} hit */
928
- #setHovered(hit) {
929
- const p = this.#pointer
1086
+ /**
1087
+ * @param {Hit | null} hit
1088
+ * @param {{ clientX: number, clientY: number, event: PointerEvent | null } | null} [p] where it is shown - the pointer, or the keyboard's focus
1089
+ */
1090
+ #setHovered(hit, p = this.#pointer) {
930
1091
  const before = this.#hovered
931
1092
  // the same thing under the pointer: the same node, instance and row of data - a line's segments
932
1093
  // and a surface's rows are told apart by their data, without a plain mesh changing per triangle
@@ -980,13 +1141,16 @@ export class View {
980
1141
  /** @param {Hit | null} hit @returns {FrameHighlight | null} */
981
1142
  #highlightFor(hit) {
982
1143
  if (!hit) return null
983
- // the nearest colour wins, and the nearest highlightWhole is what is tinted as one
1144
+ // the nearest colour wins - or the nearest false, which tints nothing - and the nearest
1145
+ // highlightWhole is what is tinted as one
984
1146
  let color = null, whole = null
985
1147
  for (let n = /** @type {any} */ (hit.node); n; n = n.parent) {
986
1148
  color ??= n.highlight
987
1149
  whole ??= n.highlightWhole ? n : null
988
1150
  }
989
1151
  color ??= this.#highlight
1152
+ // the keyboard's focus always shows
1153
+ if (!color && hit === this.#keyFocus) color = FOCUS_COLOR
990
1154
  if (!color) return null
991
1155
  const node = /** @type {any} */ (hit.node)
992
1156
  if (whole) {
@@ -1003,17 +1167,19 @@ export class View {
1003
1167
  /** @param {FrameDescription} frame */
1004
1168
  #occlude(frame) {
1005
1169
  const labels = /** @type {(import('./frame.js').FrameLabel & { occluded?: boolean })[]} */ (frame.labels)
1006
- if (!labels.some((l) => l.style.occlude && l.visible)) return
1007
- // what this frame drew hides what is behind it - the same surfaces the picture shows
1170
+ // a label waiting to be zoomed in to won't show either way, so it needs no ray
1171
+ const halfHeight = this.height / 2
1172
+ const testing = labels.filter((l) => l.style.occlude && l.visible && !(l.style.minScale && l.scale * halfHeight < l.style.minScale))
1173
+ if (!testing.length) return
1174
+ // what this frame drew hides what is behind it - the same surfaces the picture shows, each placed
1175
+ // once for all the rays, which ask only whether anything is nearer than their label
1176
+ const solids = Raycaster.solids([...frame.opaque, ...frame.transparent])
1008
1177
  const raycaster = new Raycaster()
1009
- const drawn = [...frame.opaque, ...frame.transparent]
1010
- for (const label of labels) {
1011
- if (!label.style.occlude || !label.visible) continue
1178
+ for (const label of testing) {
1012
1179
  raycaster.setFromCamera(label.ndc, this.camera)
1013
1180
  const distance = label.position.distanceTo(raycaster.ray.origin)
1014
1181
  // only solid surfaces hide labels, and not the instance or point being labelled
1015
- const blocker = raycaster.intersectDrawn(drawn).find((h) => !(h.node === label.node && (label.index === null || h.instance === label.index)))
1016
- label.occluded = !!blocker && blocker.distance < distance * 0.995
1182
+ label.occluded = raycaster.blocked(solids, distance * 0.995, label.node, label.index)
1017
1183
  }
1018
1184
  }
1019
1185
 
@@ -1024,6 +1190,8 @@ export class View {
1024
1190
  return { x: e.clientX - rect.left, y: e.clientY - rect.top, clientX: e.clientX, clientY: e.clientY, event: e }
1025
1191
  }
1026
1192
  const move = (e) => {
1193
+ // the pointer takes over from the keyboard
1194
+ if (this.#keyFocus) this.#keyboard?.clear()
1027
1195
  this.#pointer = at(e)
1028
1196
  const press = this.#press
1029
1197
  if (press && press.id === e.pointerId && !press.moved && Math.hypot(this.#pointer.x - press.x, this.#pointer.y - press.y) >= DRAG_START) press.moved = true
@@ -1110,6 +1278,17 @@ export class View {
1110
1278
  resize.observe(canvas)
1111
1279
  this.#cleanup.push(() => resize.disconnect())
1112
1280
  }
1281
+ // the system's reduced-motion setting, followed as it changes
1282
+ const motion = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')
1283
+ if (motion) {
1284
+ const follow = () => {
1285
+ this.#systemReduced = motion.matches
1286
+ this.invalidate()
1287
+ }
1288
+ this.#systemReduced = motion.matches
1289
+ motion.addEventListener?.('change', follow)
1290
+ this.#cleanup.push(() => motion.removeEventListener?.('change', follow))
1291
+ }
1113
1292
  // an off-screen view (a dashboard tile scrolled away) does no work at all
1114
1293
  if (typeof IntersectionObserver !== 'undefined') {
1115
1294
  const visible = new IntersectionObserver(([entry]) => {