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/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
@@ -27,6 +27,7 @@ import { checkOneOf, checkOptions, checkRange, describe, fail, warnOnce } from '
27
27
  /** @import { CameraOptions } from './scene/cameras.js' */
28
28
  /** @import { ColorInput, Vec3Input } from './types.js' */
29
29
  /** @import { Node } from './scene/Node.js' */
30
+ /** @import { Keyboard, KeyboardOptions } from './keyboard.js' */
30
31
 
31
32
  /**
32
33
  * @typedef {'auto' | 'always' | 'manual'} RenderMode
@@ -65,13 +66,23 @@ import { checkOneOf, checkOptions, checkRange, describe, fail, warnOnce } from '
65
66
  * additive points and lines (default off). true for the defaults, or { strength, radius, threshold }
66
67
  * @property {boolean} [autoLight] light a scene that has no lights, with one console note (default true)
67
68
  * @property {number} [maxDelta] longest frame step in seconds, so a background tab doesn't jump (default 0.1)
69
+ * @property {'auto' | boolean} [reducedMotion] cut motion short, for people it makes unwell: frame() jumps rather
70
+ * than glides, auto-rotation holds, and charts change at once with their flows still. Default 'auto': as the
71
+ * system's setting (prefers-reduced-motion), followed as it changes
72
+ * @property {boolean | KeyboardOptions} [keyboard] use without a pointer: the canvas joins the page's tab order, and
73
+ * the arrow keys step through what can be hovered or clicked - each with the hover tint, the tooltip and hover
74
+ * handlers - with Enter or Space to click and Escape to leave. A screen reader hears each one. { label: (hit) =>
75
+ * text } says what it hears (default: the tooltip's text, the data, or the name). Default false
76
+ * @property {string | null} [description] what the view shows, for screen readers: the canvas's label. With keyboard
77
+ * on and no description, one is made from the scene (charts describe themselves)
68
78
  */
69
79
 
70
80
  /** @typedef {'click' | 'hover'} ViewEvent */
71
81
  /** @typedef {(hit: Hit | null, event: PointerEvent | MouseEvent | null) => void} ViewHandler */
72
82
 
73
83
  const OPTIONS = ['canvas', 'scene', 'camera', 'orbit', 'background', 'renderMode', 'framing', 'highlight', 'tooltip', 'pixelRatio',
74
- 'antialias', 'outlineAntialias', 'context', 'toneMapping', 'exposure', 'bloom', 'autoLight', 'maxDelta']
84
+ 'antialias', 'outlineAntialias', 'context', 'toneMapping', 'exposure', 'bloom', 'autoLight', 'maxDelta', 'reducedMotion',
85
+ 'keyboard', 'description']
75
86
  const FRAMING_OPTIONS = ['keep', 'aspect']
76
87
  const HINTS = {
77
88
  renderer: 'the View makes its renderer - read it from view.renderer',
@@ -90,6 +101,8 @@ const EVENT_HINTS = {
90
101
  }
91
102
  // how far the highlight colour is mixed in
92
103
  const HIGHLIGHT_AMOUNT = 0.45
104
+ // what the keyboard's focus is tinted with when nothing says otherwise - focus must always show
105
+ const FOCUS_COLOR = Color.fromInput('#ffcc00')
93
106
  // views that get a WebGL context of their own before 'auto' starts sharing one
94
107
  const OWN_CONTEXTS = 4
95
108
  // above this many things to test (instances, triangles, points, segments), picking goes to the
@@ -161,6 +174,15 @@ export class View {
161
174
  #tooltipClass = ''
162
175
  #tooltipLoading = false
163
176
  /** @type {Color | null} */ #highlight = null
177
+ /** @type {'auto' | boolean} */ #motion = 'auto'
178
+ // keyboard use: its code loads when asked for; the thing it has stepped to shows as hovered
179
+ /** @type {Keyboard | null} */ #keyboard = null
180
+ /** @type {Hit | null} */ #keyFocus = null
181
+ /** @type {string | null} */ #description = null
182
+ // the canvas had no role, so describing it gave it one - taken away again with the description
183
+ #gaveRole = false
184
+ // the system's prefers-reduced-motion, kept up to date
185
+ #systemReduced = false
164
186
  /** @type {{ x: number, y: number, clientX: number, clientY: number, event: PointerEvent } | null} */ #pointer = null
165
187
  #pickPending = false
166
188
  // a hover pick is out on the GPU
@@ -227,6 +249,7 @@ export class View {
227
249
  this.invalidate()
228
250
  }
229
251
  if (options.autoLight !== undefined) this.autoLight = !!options.autoLight
252
+ if (options.reducedMotion !== undefined) this.#setMotion(options.reducedMotion)
230
253
  if (options.maxDelta !== undefined) this.maxDelta = checkRange('View', 'maxDelta', options.maxDelta, 0, Infinity, { above: true })
231
254
  if (options.renderMode !== undefined) this.renderMode = checkOneOf('View', 'renderMode', options.renderMode, ['auto', 'always', 'manual'])
232
255
  if (options.pixelRatio !== undefined) {
@@ -247,6 +270,8 @@ export class View {
247
270
  }
248
271
  if (options.highlight !== undefined) this.#setHighlight(options.highlight)
249
272
  if (options.tooltip !== undefined) this.tooltip(options.tooltip)
273
+ if (options.description !== undefined) this.description = options.description
274
+ if (options.keyboard !== undefined && options.keyboard !== false) this.#loadKeyboard(options.keyboard)
250
275
  this.#labels = new LabelLayer(this.canvas, {
251
276
  interactive: (label) => labelHandlers(label, 'click').length > 0 || labelHandlers(label, 'hover').length > 0,
252
277
  on: (type, label, e) => this.#labelPointer(type, label, e),
@@ -324,6 +349,51 @@ export class View {
324
349
  this.invalidate()
325
350
  }
326
351
 
352
+ /**
353
+ * Is motion cut short - glides, auto-rotation, chart transitions? Set true or false, or 'auto' (the
354
+ * default) to follow the system's setting. Read it for motion of your own, such as a timeline.
355
+ * @returns {boolean}
356
+ */
357
+ get reducedMotion() { return this.#motion === 'auto' ? this.#systemReduced : this.#motion }
358
+ /** @param {'auto' | boolean} v */
359
+ set reducedMotion(v) { this.#setMotion(v) }
360
+
361
+ /** @param {'auto' | boolean} v */
362
+ #setMotion(v) {
363
+ if (v !== 'auto' && typeof v !== 'boolean') fail('View', `'reducedMotion' is 'auto' (as the system's setting), true or false, got ${describe(v)}`)
364
+ this.#motion = v
365
+ this.invalidate()
366
+ }
367
+
368
+ /**
369
+ * What the view shows, for screen readers - the canvas's label - or null. With keyboard on and no
370
+ * description, one is made from the scene.
371
+ * @returns {string | null}
372
+ */
373
+ get description() { return this.#description }
374
+ /** @param {string | null} v */
375
+ set description(v) {
376
+ if (v !== null && typeof v !== 'string') fail('View', `'description' is text for screen readers, or null, got ${describe(v)}`)
377
+ this.#description = v
378
+ if (this.#keyboard) {
379
+ this.#keyboard.setDescription(v)
380
+ return
381
+ }
382
+ const canvas = this.canvas
383
+ if (v === null) {
384
+ canvas.removeAttribute('aria-label')
385
+ if (this.#gaveRole) canvas.removeAttribute('role')
386
+ this.#gaveRole = false
387
+ return
388
+ }
389
+ canvas.setAttribute('aria-label', v)
390
+ // a picture, to a screen reader, unless the page has said otherwise
391
+ if (!canvas.hasAttribute('role')) {
392
+ canvas.setAttribute('role', 'img')
393
+ this.#gaveRole = true
394
+ }
395
+ }
396
+
327
397
  /** Canvas width in CSS pixels. */
328
398
  get width() { return this.#width }
329
399
  /** Canvas height in CSS pixels. */
@@ -397,6 +467,7 @@ export class View {
397
467
  this.time += dt
398
468
  this.#onFrame?.(dt, this.time)
399
469
  // the same order as the live loop, so a stepped frame is the frame a user sees
470
+ this.scene.motionReduced = this.reducedMotion
400
471
  this.scene.update(dt, this.time)
401
472
  this.#updateControls(dt)
402
473
  return this.#render(true)
@@ -411,7 +482,7 @@ export class View {
411
482
  if (this.#flight) return this.#fly(dt)
412
483
  if (!this.controls) return
413
484
  const waiting = this.#autoFrame && samePlace(cameraPlace(this.camera), this.#autoFrameFrom)
414
- this.controls.update(dt)
485
+ this.controls.update(dt, this.reducedMotion)
415
486
  if (waiting) this.#autoFrameFrom = cameraPlace(this.camera)
416
487
  }
417
488
 
@@ -450,8 +521,8 @@ export class View {
450
521
  // remember what was drawn - including what drawing changed, such as fitting the camera - so
451
522
  // the next frame compares against it and doesn't draw the same picture again
452
523
  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
524
+ // things may have moved under a still pointer - or the keyboard's focus moved on screen
525
+ if ((this.#pointer || this.#keyFocus) && this.#needsHover()) this.#pickPending = true
455
526
  return frame
456
527
  }
457
528
 
@@ -493,43 +564,26 @@ export class View {
493
564
  * @param {Node | Box3} [target] (default the scene)
494
565
  * @param {{ padding?: number, direction?: CameraPreset | Vec3Input, duration?: number }} [options] padding is
495
566
  * 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
567
+ * duration is in seconds (default 0: straight there); a press or the wheel stops the glide, and with
568
+ * reducedMotion it goes straight there
497
569
  * @returns {this}
498
570
  */
499
571
  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
572
  checkRange('View', 'frame() duration', duration, 0)
503
573
  const cam = /** @type {any} */ (this.camera)
504
574
  const from = placeOf(cam)
505
575
  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)
576
+ const fit = this.#fit('frame', target, padding, towards)
511
577
  // an explicit frame replaces the preset camera's fit on the first frame
512
578
  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
579
+ if (!fit) return this
580
+ setPlace(cam, fit.place)
581
+ cam.near = fit.near ?? cam.near
582
+ cam.far = Math.max(cam.far, fit.far)
529
583
  // framed for the canvas as it is now
530
584
  this.#designed = cam.aspect
531
585
  this.#applied = 1
532
- if (duration > 0) {
586
+ if (duration > 0 && !this.reducedMotion) {
533
587
  // the camera starts where it was and glides there, frame by frame
534
588
  this.#flight = { from, to: placeOf(cam), time: 0, duration }
535
589
  setPlace(cam, from)
@@ -538,6 +592,56 @@ export class View {
538
592
  return this
539
593
  }
540
594
 
595
+ /**
596
+ * Where frame() would put the camera, without moving it: its position, target and - for an
597
+ * orthographic camera - height, or null when there is nothing to frame. For a camera of your own
598
+ * that eases after something moving rather than jumping each frame:
599
+ *
600
+ * const aim = view.framing(debris)
601
+ * if (aim) {
602
+ * view.camera.position.lerp(aim.position, 0.1)
603
+ * view.camera.target.lerp(aim.target, 0.1)
604
+ * }
605
+ * @param {Node | Box3} [target] (default the scene)
606
+ * @param {{ padding?: number, direction?: CameraPreset | Vec3Input }} [options] as frame()
607
+ * @returns {{ position: Vec3, target: Vec3, height?: number } | null}
608
+ */
609
+ framing(target = this.scene, { padding = 0.2, direction } = {}) {
610
+ const fit = this.#fit('framing', target, padding, direction)
611
+ if (!fit) return null
612
+ const { position, target: at, height } = fit.place
613
+ return height === undefined ? { position, target: at } : { position, target: at, height }
614
+ }
615
+
616
+ /**
617
+ * The camera's place to frame something, worked out without moving it - for frame() and framing().
618
+ * @param {string} method its caller, for errors @param {Node | Box3} target @param {number} padding
619
+ * @param {CameraPreset | Vec3Input | undefined} towards
620
+ * @returns {{ place: Place, near: number | undefined, far: number } | null}
621
+ */
622
+ #fit(method, target, padding, towards) {
623
+ if (!(padding >= 0)) fail('View', `${method}() padding is the margin around, as a fraction of the size (0.2 = 20%), got ${describe(padding)}`)
624
+ 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%)`)
625
+ const cam = /** @type {any} */ (this.camera)
626
+ const direction = towards === undefined ? cam.position.clone().sub(cam.target ?? new Vec3()).normalize()
627
+ : typeof towards === 'string' ? presetDirection(/** @type {CameraPreset} */ (towards)) : Vec3.fromInput(towards, 'View').normalize()
628
+ 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)}`)
629
+ if (direction.len() === 0) direction.set(0, 0.3, 1).normalize()
630
+ const points = target instanceof Box3 ? corners(target) : this.#drawnPoints(target, direction)
631
+ if (!points.length) return null
632
+ const ortho = cam.constructor.type === 'orthographic-camera'
633
+ const { center, size } = fitPoints(points, direction, { aspect: cam.aspect, fov: ortho ? undefined : cam.fov, padding })
634
+ const radius = Math.max(1e-3, ...points.map((p) => Math.hypot(p[0] - center[0], p[1] - center[1], p[2] - center[2])))
635
+ if (ortho) {
636
+ return { place: { position: center.clone().add(direction.clone().multiplyScalar(radius * 4)), target: center, height: size },
637
+ near: undefined, far: radius * 10 }
638
+ }
639
+ // keep the near/far range sensible for the model's size
640
+ const distance = size
641
+ return { place: { position: center.clone().add(direction.clone().multiplyScalar(distance)), target: center },
642
+ near: Math.max(1e-3, distance / 1000), far: distance * 10 }
643
+ }
644
+
541
645
  /**
542
646
  * One frame of a glide: round the target as it moves, closing the distance evenly, so the picture
543
647
  * turns and zooms rather than cutting through what is in view.
@@ -774,6 +878,39 @@ export class View {
774
878
  })
775
879
  }
776
880
 
881
+ /** @param {true | KeyboardOptions} options */
882
+ #loadKeyboard(options) {
883
+ if (options !== true && (typeof options !== 'object' || options === null || Array.isArray(options))) {
884
+ fail('View', `'keyboard' is true, false or { label: (hit) => text }, got ${describe(options)}`)
885
+ }
886
+ const o = options === true ? {} : options
887
+ checkOptions('View', o, ['label'], { describe: "use 'label: (hit) => text'", text: "use 'label: (hit) => text'" }, 'keyboard')
888
+ if (o.label !== undefined && typeof o.label !== 'function') fail('View', `keyboard.label is a function (hit) => text, got ${describe(o.label)}`)
889
+ import('./keyboard.js').then(({ Keyboard }) => {
890
+ if (this.#disposed) return
891
+ this.#keyboard = new Keyboard(this, {
892
+ focus: (hit) => {
893
+ this.#keyFocus = hit
894
+ this.#pickPending = true
895
+ this.invalidate()
896
+ },
897
+ press: (hit) => {
898
+ for (const fn of handlersAbove(hit.node, 'click')) fn(hit)
899
+ for (const fn of this.#handlers.click) fn(hit, null)
900
+ },
901
+ tooltip: (hit) => this.#tooltipFor(hit)?.(hit),
902
+ viewWide: () => this.#handlers.hover.size > 0 || this.#handlers.click.size > 0 || !!this.#tooltipContent,
903
+ }, o)
904
+ this.#keyboard.setDescription(this.#description)
905
+ })
906
+ }
907
+
908
+ /** Where the keyboard's focus shows - its middle, on screen - in place of the pointer. @param {Hit} hit */
909
+ #keyAt(hit) {
910
+ const { x, y } = this.project(hit.point), rect = this.canvas.getBoundingClientRect()
911
+ return { x, y, clientX: rect.left + x, clientY: rect.top + y, event: null }
912
+ }
913
+
777
914
  /** The nearest node tooltip above the hit, or the View's. @param {Hit} hit */
778
915
  #tooltipFor(hit) {
779
916
  for (let n = /** @type {any} */ (hit.node); n; n = n.parent) if (n.tooltip) return n.tooltip
@@ -822,7 +959,8 @@ export class View {
822
959
  sharedContext: this.renderer.shared,
823
960
  frame, labels: this.#labelStats, bounds: drawn, nodes, autoLight: this.autoLight,
824
961
  contextLost: !!this.renderer.gl?.isContextLost?.(),
825
- interaction: { hover: this.#handlers.hover.size, click: this.#handlers.click.size, tooltip: !!this.#tooltipContent, highlight: !!this.#highlight },
962
+ interaction: { hover: this.#handlers.hover.size, click: this.#handlers.click.size, tooltip: !!this.#tooltipContent, highlight: !!this.#highlight,
963
+ keyboard: !!this.#keyboard, reducedMotion: this.reducedMotion, description: this.canvas.getAttribute?.('aria-label') ?? this.#description },
826
964
  })
827
965
  }
828
966
 
@@ -839,6 +977,8 @@ export class View {
839
977
  this.controls?.dispose()
840
978
  this.#labels.dispose()
841
979
  this.#tooltip?.dispose()
980
+ this.#keyboard?.dispose()
981
+ if (this.#description !== null && !this.#keyboard) this.description = null
842
982
  this.renderer.dispose(this.scene)
843
983
  if (this.#ownContext) ownContexts--
844
984
  if (this.#ownCanvas) this.canvas.remove()
@@ -879,6 +1019,7 @@ export class View {
879
1019
  if (this.#running) {
880
1020
  this.time += dt
881
1021
  this.#onFrame?.(dt, this.time)
1022
+ this.scene.motionReduced = this.reducedMotion
882
1023
  this.scene.update(dt, this.time)
883
1024
  }
884
1025
  this.#updateControls(dt)
@@ -904,6 +1045,8 @@ export class View {
904
1045
  this.#pickPending = false
905
1046
  if (!this.#needsHover()) return
906
1047
  const p = this.#pointer
1048
+ // what the keyboard stepped to is shown until the pointer moves
1049
+ if (this.#keyFocus) return this.#setHovered(this.#keyFocus, this.#keyAt(this.#keyFocus))
907
1050
  if (this.#labelHover) return this.#setHovered(this.#labelHover)
908
1051
  if (!p || this.#dragging) return this.#setHovered(null)
909
1052
  const frame = this.#gpuFrame()
@@ -924,9 +1067,11 @@ export class View {
924
1067
  })
925
1068
  }
926
1069
 
927
- /** @param {Hit | null} hit */
928
- #setHovered(hit) {
929
- const p = this.#pointer
1070
+ /**
1071
+ * @param {Hit | null} hit
1072
+ * @param {{ clientX: number, clientY: number, event: PointerEvent | null } | null} [p] where it is shown - the pointer, or the keyboard's focus
1073
+ */
1074
+ #setHovered(hit, p = this.#pointer) {
930
1075
  const before = this.#hovered
931
1076
  // the same thing under the pointer: the same node, instance and row of data - a line's segments
932
1077
  // and a surface's rows are told apart by their data, without a plain mesh changing per triangle
@@ -980,13 +1125,16 @@ export class View {
980
1125
  /** @param {Hit | null} hit @returns {FrameHighlight | null} */
981
1126
  #highlightFor(hit) {
982
1127
  if (!hit) return null
983
- // the nearest colour wins, and the nearest highlightWhole is what is tinted as one
1128
+ // the nearest colour wins - or the nearest false, which tints nothing - and the nearest
1129
+ // highlightWhole is what is tinted as one
984
1130
  let color = null, whole = null
985
1131
  for (let n = /** @type {any} */ (hit.node); n; n = n.parent) {
986
1132
  color ??= n.highlight
987
1133
  whole ??= n.highlightWhole ? n : null
988
1134
  }
989
1135
  color ??= this.#highlight
1136
+ // the keyboard's focus always shows
1137
+ if (!color && hit === this.#keyFocus) color = FOCUS_COLOR
990
1138
  if (!color) return null
991
1139
  const node = /** @type {any} */ (hit.node)
992
1140
  if (whole) {
@@ -1003,17 +1151,19 @@ export class View {
1003
1151
  /** @param {FrameDescription} frame */
1004
1152
  #occlude(frame) {
1005
1153
  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
1154
+ // a label waiting to be zoomed in to won't show either way, so it needs no ray
1155
+ const halfHeight = this.height / 2
1156
+ const testing = labels.filter((l) => l.style.occlude && l.visible && !(l.style.minScale && l.scale * halfHeight < l.style.minScale))
1157
+ if (!testing.length) return
1158
+ // what this frame drew hides what is behind it - the same surfaces the picture shows, each placed
1159
+ // once for all the rays, which ask only whether anything is nearer than their label
1160
+ const solids = Raycaster.solids([...frame.opaque, ...frame.transparent])
1008
1161
  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
1162
+ for (const label of testing) {
1012
1163
  raycaster.setFromCamera(label.ndc, this.camera)
1013
1164
  const distance = label.position.distanceTo(raycaster.ray.origin)
1014
1165
  // 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
1166
+ label.occluded = raycaster.blocked(solids, distance * 0.995, label.node, label.index)
1017
1167
  }
1018
1168
  }
1019
1169
 
@@ -1024,6 +1174,8 @@ export class View {
1024
1174
  return { x: e.clientX - rect.left, y: e.clientY - rect.top, clientX: e.clientX, clientY: e.clientY, event: e }
1025
1175
  }
1026
1176
  const move = (e) => {
1177
+ // the pointer takes over from the keyboard
1178
+ if (this.#keyFocus) this.#keyboard?.clear()
1027
1179
  this.#pointer = at(e)
1028
1180
  const press = this.#press
1029
1181
  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 +1262,17 @@ export class View {
1110
1262
  resize.observe(canvas)
1111
1263
  this.#cleanup.push(() => resize.disconnect())
1112
1264
  }
1265
+ // the system's reduced-motion setting, followed as it changes
1266
+ const motion = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')
1267
+ if (motion) {
1268
+ const follow = () => {
1269
+ this.#systemReduced = motion.matches
1270
+ this.invalidate()
1271
+ }
1272
+ this.#systemReduced = motion.matches
1273
+ motion.addEventListener?.('change', follow)
1274
+ this.#cleanup.push(() => motion.removeEventListener?.('change', follow))
1275
+ }
1113
1276
  // an off-screen view (a dashboard tile scrolled away) does no work at all
1114
1277
  if (typeof IntersectionObserver !== 'undefined') {
1115
1278
  const visible = new IntersectionObserver(([entry]) => {
@@ -118,6 +118,22 @@ export class Chart extends Group {
118
118
  return `Chart (${s.mark}): ${shown} of ${s.rows.length} rows shown${axes.length ? `; ${axes.join(', ')}` : ''}${s.links.length ? `; ${s.links.length} links` : ''}`
119
119
  }
120
120
 
121
+ /**
122
+ * One sentence about this chart for screen readers - the spec's description, or its title and what
123
+ * it shows: "Sales by month: bar chart of 12 rows by Month, Sales". A View with keyboard: true labels
124
+ * its canvas with this.
125
+ * @returns {string}
126
+ */
127
+ summary() {
128
+ const s = /** @type {ReturnType<typeof normalise>} */ (this.#spec), o = /** @type {any} */ (s.options)
129
+ if (o.description) return String(o.description)
130
+ // each field once, though it may set more than one channel (z and colour by region)
131
+ const by = [...new Set(['x', 'y', 'z', 'color', 'size'].map((k) => /** @type {any} */ (s.channels)[k]).filter((c) => c?.field).map((c) => c.title))]
132
+ const count = (n, one) => `${n} ${n === 1 ? one : `${one}s`}`
133
+ const what = `${s.mark} chart of ${count(s.rows.length, 'row')}${by.length ? ` by ${by.join(', ')}` : ''}${s.links.length ? ` and ${count(s.links.length, 'link')}` : ''}`
134
+ return o.title ? `${o.title}: ${what}` : what
135
+ }
136
+
121
137
  /** @internal @returns {Record<string, unknown>} */
122
138
  jsonOptions() {
123
139
  return { ...this.#input }
@@ -305,14 +321,16 @@ export class Chart extends Group {
305
321
 
306
322
  /** @param {number} dt */
307
323
  #step(dt) {
308
- this.#mark?.tick?.(dt)
324
+ // with motion reduced (the View's reducedMotion), flows stand still and a change shows at once
325
+ const still = /** @type {any} */ (this.root).motionReduced === true
326
+ if (!still) this.#mark?.tick?.(dt)
309
327
  const tw = this.#tween
310
328
  if (!tw) return
311
329
  const o = this.#input.transition ?? {}
312
330
  const duration = o.duration ?? 0.5
313
331
  const ease = EASES[o.ease ?? 'out']
314
332
  if (!ease) fail('chart()', `transition ease '${o.ease}' - expected ${Object.keys(EASES).map((e) => `'${e}'`).join(', ')}`)
315
- tw.t = Math.min(1, tw.t + dt / duration)
333
+ tw.t = still ? 1 : Math.min(1, tw.t + dt / duration)
316
334
  const k = ease(tw.t)
317
335
  const state = new Float32Array(tw.from.length)
318
336
  for (let i = 0; i < state.length; i++) state[i] = tw.from[i] + (tw.to[i] - tw.from[i]) * k
package/src/explain.js CHANGED
@@ -22,7 +22,7 @@ import { Box3 } from './value/Box3.js'
22
22
  * @property {boolean} autoLight
23
23
  * @property {boolean} contextLost
24
24
  * @property {boolean} [sharedContext] drawn with the page's shared WebGL context
25
- * @property {{ hover: number, click: number, tooltip: boolean, highlight: boolean }} interaction
25
+ * @property {{ hover: number, click: number, tooltip: boolean, highlight: boolean, keyboard?: boolean, reducedMotion?: boolean, description?: string | null }} interaction
26
26
  * @property {string[]} [notes] lines from nodes that describe themselves, such as charts
27
27
  */
28
28
 
@@ -115,7 +115,7 @@ export function explain(info) {
115
115
 
116
116
  if (info.labels && info.labels.total) {
117
117
  const s = info.labels
118
- lines.push(`Labels: ${s.shown} shown, ${s.dimmed ? `${s.dimmed} dimmed (covered by labels or geometry), ` : ''}${s.overlapped} hidden by overlap, ${s.occluded} hidden behind geometry, ${s.offscreen} off screen`)
118
+ lines.push(`Labels: ${s.shown} shown, ${s.dimmed ? `${s.dimmed} dimmed (covered by labels or geometry), ` : ''}${s.overlapped} hidden by overlap, ${s.occluded} hidden behind geometry, ${s.offscreen} off screen${s.zoomedOut ? `, ${s.zoomedOut} waiting to be zoomed in to (minScale)` : ''}`)
119
119
  if (s.overlapped > s.shown * 3) warnings.push(`${s.overlapped} labels are hidden by overlap - give the important ones a higher priority, label fewer things, or use overlap: 'dim' or 'show'`)
120
120
  if (s.offscreen === s.total) warnings.push('every label is off screen - check their positions, or call view.frame()')
121
121
  }
@@ -125,6 +125,10 @@ export function explain(info) {
125
125
  lines.push(`Interaction: ${[ix.hover && plural(ix.hover, 'hover handler'), ix.click && plural(ix.click, 'click handler'),
126
126
  ix.tooltip && 'tooltip', ix.highlight && 'hover highlight'].filter(Boolean).join(', ')}`)
127
127
  }
128
+ if (ix.keyboard || ix.reducedMotion || ix.description) {
129
+ lines.push(`Accessibility: ${[ix.keyboard && 'keyboard (arrow keys step through what can be hovered)', ix.reducedMotion && 'reduced motion',
130
+ ix.description && `described as "${ix.description}"`].filter(Boolean).join(', ')}`)
131
+ }
128
132
 
129
133
  if (info.width < 2 || info.height < 2) warnings.unshift(`the canvas is ${info.width} x ${info.height} - give it a size in the page (CSS width and height)`)
130
134
  if (info.contextLost) warnings.unshift('the WebGL context is lost - nothing draws until the browser restores it')