phoria2 1.0.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 (76) hide show
  1. package/README.md +71 -13
  2. package/dist/bloom.js +1 -1
  3. package/dist/charts.js +397 -384
  4. package/dist/charts.js.map +1 -1
  5. package/dist/core.js +4451 -3568
  6. package/dist/core.js.map +1 -1
  7. package/dist/edit.js +67 -67
  8. package/dist/edit.js.map +1 -1
  9. package/dist/explain.js +6 -2
  10. package/dist/explain.js.map +1 -1
  11. package/dist/gltf.js +211 -166
  12. package/dist/gltf.js.map +1 -1
  13. package/dist/motion.js +26 -26
  14. package/dist/motion.js.map +1 -1
  15. package/dist/particles.js +49 -49
  16. package/dist/particles.js.map +1 -1
  17. package/dist/passes.js +188 -86
  18. package/dist/passes.js.map +1 -1
  19. package/dist/phoria2.js +1 -1
  20. package/dist/picking.js +8 -13
  21. package/dist/picking.js.map +1 -1
  22. package/dist/snapshot.js +1 -1
  23. package/dist/tooltip.js +3 -3
  24. package/dist/tooltip.js.map +1 -1
  25. package/dist/types/OrbitControl.d.ts +6 -2
  26. package/dist/types/Raycaster.d.ts +10 -0
  27. package/dist/types/View.d.ts +64 -1
  28. package/dist/types/charts/Chart.d.ts +7 -0
  29. package/dist/types/explain.d.ts +3 -0
  30. package/dist/types/frame.d.ts +7 -0
  31. package/dist/types/geometry/Geometry.d.ts +14 -2
  32. package/dist/types/keyboard.d.ts +48 -0
  33. package/dist/types/labels.d.ts +4 -0
  34. package/dist/types/material/Material.d.ts +125 -0
  35. package/dist/types/material/Texture.d.ts +37 -5
  36. package/dist/types/scene/Node.d.ts +19 -14
  37. package/dist/types/scene/drawables.d.ts +32 -14
  38. package/dist/types/scene/lights.d.ts +16 -2
  39. package/dist/types/shadow.d.ts +2 -1
  40. package/dist/types/types.d.ts +14 -0
  41. package/dist/types/value/Color.d.ts +0 -4
  42. package/dist/types/value/Quat.d.ts +4 -2
  43. package/dist/types/webgl/WebGLRenderer.d.ts +6 -0
  44. package/dist/types/webgl/passes.d.ts +37 -13
  45. package/dist/types/webgl/resources.d.ts +24 -2
  46. package/dist/types/webgl/shaders.d.ts +4 -2
  47. package/llms-full.txt +153 -15
  48. package/package.json +4 -1
  49. package/schema/scene.schema.json +146 -1
  50. package/src/OrbitControl.js +7 -3
  51. package/src/Raycaster.js +58 -0
  52. package/src/View.js +207 -42
  53. package/src/charts/Chart.js +20 -2
  54. package/src/explain.js +6 -2
  55. package/src/frame.js +29 -9
  56. package/src/geometry/Geometry.js +24 -4
  57. package/src/gltf/load.js +50 -6
  58. package/src/keyboard.js +298 -0
  59. package/src/labels.js +4 -1
  60. package/src/material/Material.js +104 -19
  61. package/src/material/Texture.js +116 -11
  62. package/src/scene/Node.js +20 -9
  63. package/src/scene/Scene.js +2 -0
  64. package/src/scene/cameras.js +4 -1
  65. package/src/scene/drawables.js +48 -12
  66. package/src/scene/lights.js +40 -6
  67. package/src/shadow.js +27 -4
  68. package/src/types.js +6 -0
  69. package/src/value/Color.js +21 -0
  70. package/src/value/Quat.js +15 -6
  71. package/src/watch.js +25 -1
  72. package/src/webgl/WebGLRenderer.js +51 -12
  73. package/src/webgl/passes.js +190 -32
  74. package/src/webgl/picking.js +0 -1
  75. package/src/webgl/resources.js +89 -8
  76. package/src/webgl/shaders.js +350 -48
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
@@ -53,6 +54,8 @@ import { checkOneOf, checkOptions, checkRange, describe, fail, warnOnce } from '
53
54
  * @property {(hit: Hit) => TooltipContent} [tooltip] what to show for the thing under the pointer - see tooltip()
54
55
  * @property {number} [pixelRatio] default min(devicePixelRatio, 2)
55
56
  * @property {boolean} [antialias] (default true)
57
+ * @property {boolean} [outlineAntialias] soft-edged outlines with round corners (default: as antialias); change it
58
+ * any time with view.renderer.outlineAntialias
56
59
  * @property {'auto' | 'own' | 'shared'} [context] which WebGL context draws it: 'own' gives the canvas one of
57
60
  * its own; 'shared' draws with the page's one shared context and copies the picture over, so a page can hold
58
61
  * any number of views (browsers allow about 16 live contexts). 'auto' (default): own for the first four
@@ -63,13 +66,23 @@ import { checkOneOf, checkOptions, checkRange, describe, fail, warnOnce } from '
63
66
  * additive points and lines (default off). true for the defaults, or { strength, radius, threshold }
64
67
  * @property {boolean} [autoLight] light a scene that has no lights, with one console note (default true)
65
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)
66
78
  */
67
79
 
68
80
  /** @typedef {'click' | 'hover'} ViewEvent */
69
81
  /** @typedef {(hit: Hit | null, event: PointerEvent | MouseEvent | null) => void} ViewHandler */
70
82
 
71
83
  const OPTIONS = ['canvas', 'scene', 'camera', 'orbit', 'background', 'renderMode', 'framing', 'highlight', 'tooltip', 'pixelRatio',
72
- 'antialias', 'context', 'toneMapping', 'exposure', 'bloom', 'autoLight', 'maxDelta']
84
+ 'antialias', 'outlineAntialias', 'context', 'toneMapping', 'exposure', 'bloom', 'autoLight', 'maxDelta', 'reducedMotion',
85
+ 'keyboard', 'description']
73
86
  const FRAMING_OPTIONS = ['keep', 'aspect']
74
87
  const HINTS = {
75
88
  renderer: 'the View makes its renderer - read it from view.renderer',
@@ -88,6 +101,8 @@ const EVENT_HINTS = {
88
101
  }
89
102
  // how far the highlight colour is mixed in
90
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')
91
106
  // views that get a WebGL context of their own before 'auto' starts sharing one
92
107
  const OWN_CONTEXTS = 4
93
108
  // above this many things to test (instances, triangles, points, segments), picking goes to the
@@ -159,6 +174,15 @@ export class View {
159
174
  #tooltipClass = ''
160
175
  #tooltipLoading = false
161
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
162
186
  /** @type {{ x: number, y: number, clientX: number, clientY: number, event: PointerEvent } | null} */ #pointer = null
163
187
  #pickPending = false
164
188
  // a hover pick is out on the GPU
@@ -213,7 +237,7 @@ export class View {
213
237
  // the renderer checks these too; checked here, the error names the View
214
238
  if (options.exposure !== undefined) checkRange('View', 'exposure', options.exposure, 0)
215
239
  const shared = context === 'shared' || (context === 'auto' && ownContexts >= OWN_CONTEXTS)
216
- this.renderer = new WebGLRenderer({ canvas: this.canvas, shared, antialias: options.antialias, toneMapping: options.toneMapping, exposure: options.exposure, bloom: options.bloom })
240
+ this.renderer = new WebGLRenderer({ canvas: this.canvas, shared, antialias: options.antialias, outlineAntialias: options.outlineAntialias, toneMapping: options.toneMapping, exposure: options.exposure, bloom: options.bloom })
217
241
  if (!shared) {
218
242
  this.#ownContext = true
219
243
  ownContexts++
@@ -225,6 +249,7 @@ export class View {
225
249
  this.invalidate()
226
250
  }
227
251
  if (options.autoLight !== undefined) this.autoLight = !!options.autoLight
252
+ if (options.reducedMotion !== undefined) this.#setMotion(options.reducedMotion)
228
253
  if (options.maxDelta !== undefined) this.maxDelta = checkRange('View', 'maxDelta', options.maxDelta, 0, Infinity, { above: true })
229
254
  if (options.renderMode !== undefined) this.renderMode = checkOneOf('View', 'renderMode', options.renderMode, ['auto', 'always', 'manual'])
230
255
  if (options.pixelRatio !== undefined) {
@@ -245,6 +270,8 @@ export class View {
245
270
  }
246
271
  if (options.highlight !== undefined) this.#setHighlight(options.highlight)
247
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)
248
275
  this.#labels = new LabelLayer(this.canvas, {
249
276
  interactive: (label) => labelHandlers(label, 'click').length > 0 || labelHandlers(label, 'hover').length > 0,
250
277
  on: (type, label, e) => this.#labelPointer(type, label, e),
@@ -322,6 +349,51 @@ export class View {
322
349
  this.invalidate()
323
350
  }
324
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
+
325
397
  /** Canvas width in CSS pixels. */
326
398
  get width() { return this.#width }
327
399
  /** Canvas height in CSS pixels. */
@@ -395,6 +467,7 @@ export class View {
395
467
  this.time += dt
396
468
  this.#onFrame?.(dt, this.time)
397
469
  // the same order as the live loop, so a stepped frame is the frame a user sees
470
+ this.scene.motionReduced = this.reducedMotion
398
471
  this.scene.update(dt, this.time)
399
472
  this.#updateControls(dt)
400
473
  return this.#render(true)
@@ -409,7 +482,7 @@ export class View {
409
482
  if (this.#flight) return this.#fly(dt)
410
483
  if (!this.controls) return
411
484
  const waiting = this.#autoFrame && samePlace(cameraPlace(this.camera), this.#autoFrameFrom)
412
- this.controls.update(dt)
485
+ this.controls.update(dt, this.reducedMotion)
413
486
  if (waiting) this.#autoFrameFrom = cameraPlace(this.camera)
414
487
  }
415
488
 
@@ -448,8 +521,8 @@ export class View {
448
521
  // remember what was drawn - including what drawing changed, such as fitting the camera - so
449
522
  // the next frame compares against it and doesn't draw the same picture again
450
523
  if (this.renderMode === 'auto') this.#watch.changed(this.scene, this.camera)
451
- // things may have moved under a still pointer
452
- 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
453
526
  return frame
454
527
  }
455
528
 
@@ -491,43 +564,26 @@ export class View {
491
564
  * @param {Node | Box3} [target] (default the scene)
492
565
  * @param {{ padding?: number, direction?: CameraPreset | Vec3Input, duration?: number }} [options] padding is
493
566
  * the margin around it, as a fraction of its size: 0 is a tight fit, 0.2 (the default) leaves a fifth.
494
- * 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
495
569
  * @returns {this}
496
570
  */
497
571
  frame(target = this.scene, { padding = 0.2, direction: towards, duration = 0 } = {}) {
498
- if (!(padding >= 0)) fail('View', `frame() padding is the margin around, as a fraction of the size (0.2 = 20%), got ${describe(padding)}`)
499
- 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%)`)
500
572
  checkRange('View', 'frame() duration', duration, 0)
501
573
  const cam = /** @type {any} */ (this.camera)
502
574
  const from = placeOf(cam)
503
575
  this.#flight = null
504
- const direction = towards === undefined ? cam.position.clone().sub(cam.target ?? new Vec3()).normalize()
505
- : typeof towards === 'string' ? presetDirection(/** @type {CameraPreset} */ (towards)) : Vec3.fromInput(towards, 'View').normalize()
506
- 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)}`)
507
- if (direction.len() === 0) direction.set(0, 0.3, 1).normalize()
508
- const points = target instanceof Box3 ? corners(target) : this.#drawnPoints(target, direction)
576
+ const fit = this.#fit('frame', target, padding, towards)
509
577
  // an explicit frame replaces the preset camera's fit on the first frame
510
578
  this.#autoFrame = false
511
- if (!points.length) return this
512
- const ortho = cam.constructor.type === 'orthographic-camera'
513
- const { center, size } = fitPoints(points, direction, { aspect: cam.aspect, fov: ortho ? undefined : cam.fov, padding })
514
- const radius = Math.max(1e-3, ...points.map((p) => Math.hypot(p[0] - center[0], p[1] - center[1], p[2] - center[2])))
515
- if (ortho) {
516
- cam.height = size
517
- cam.position.copy(center).add(direction.clone().multiplyScalar(radius * 4))
518
- cam.far = Math.max(cam.far, radius * 10)
519
- } else {
520
- const distance = size
521
- cam.position.copy(center).add(direction.clone().multiplyScalar(distance))
522
- // keep the near/far range sensible for the model's size
523
- cam.near = Math.max(1e-3, distance / 1000)
524
- cam.far = Math.max(cam.far, distance * 10)
525
- }
526
- 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)
527
583
  // framed for the canvas as it is now
528
584
  this.#designed = cam.aspect
529
585
  this.#applied = 1
530
- if (duration > 0) {
586
+ if (duration > 0 && !this.reducedMotion) {
531
587
  // the camera starts where it was and glides there, frame by frame
532
588
  this.#flight = { from, to: placeOf(cam), time: 0, duration }
533
589
  setPlace(cam, from)
@@ -536,6 +592,56 @@ export class View {
536
592
  return this
537
593
  }
538
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
+
539
645
  /**
540
646
  * One frame of a glide: round the target as it moves, closing the distance evenly, so the picture
541
647
  * turns and zooms rather than cutting through what is in view.
@@ -772,6 +878,39 @@ export class View {
772
878
  })
773
879
  }
774
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
+
775
914
  /** The nearest node tooltip above the hit, or the View's. @param {Hit} hit */
776
915
  #tooltipFor(hit) {
777
916
  for (let n = /** @type {any} */ (hit.node); n; n = n.parent) if (n.tooltip) return n.tooltip
@@ -820,7 +959,8 @@ export class View {
820
959
  sharedContext: this.renderer.shared,
821
960
  frame, labels: this.#labelStats, bounds: drawn, nodes, autoLight: this.autoLight,
822
961
  contextLost: !!this.renderer.gl?.isContextLost?.(),
823
- 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 },
824
964
  })
825
965
  }
826
966
 
@@ -837,6 +977,8 @@ export class View {
837
977
  this.controls?.dispose()
838
978
  this.#labels.dispose()
839
979
  this.#tooltip?.dispose()
980
+ this.#keyboard?.dispose()
981
+ if (this.#description !== null && !this.#keyboard) this.description = null
840
982
  this.renderer.dispose(this.scene)
841
983
  if (this.#ownContext) ownContexts--
842
984
  if (this.#ownCanvas) this.canvas.remove()
@@ -877,6 +1019,7 @@ export class View {
877
1019
  if (this.#running) {
878
1020
  this.time += dt
879
1021
  this.#onFrame?.(dt, this.time)
1022
+ this.scene.motionReduced = this.reducedMotion
880
1023
  this.scene.update(dt, this.time)
881
1024
  }
882
1025
  this.#updateControls(dt)
@@ -902,6 +1045,8 @@ export class View {
902
1045
  this.#pickPending = false
903
1046
  if (!this.#needsHover()) return
904
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))
905
1050
  if (this.#labelHover) return this.#setHovered(this.#labelHover)
906
1051
  if (!p || this.#dragging) return this.#setHovered(null)
907
1052
  const frame = this.#gpuFrame()
@@ -922,9 +1067,11 @@ export class View {
922
1067
  })
923
1068
  }
924
1069
 
925
- /** @param {Hit | null} hit */
926
- #setHovered(hit) {
927
- 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) {
928
1075
  const before = this.#hovered
929
1076
  // the same thing under the pointer: the same node, instance and row of data - a line's segments
930
1077
  // and a surface's rows are told apart by their data, without a plain mesh changing per triangle
@@ -978,13 +1125,16 @@ export class View {
978
1125
  /** @param {Hit | null} hit @returns {FrameHighlight | null} */
979
1126
  #highlightFor(hit) {
980
1127
  if (!hit) return null
981
- // 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
982
1130
  let color = null, whole = null
983
1131
  for (let n = /** @type {any} */ (hit.node); n; n = n.parent) {
984
1132
  color ??= n.highlight
985
1133
  whole ??= n.highlightWhole ? n : null
986
1134
  }
987
1135
  color ??= this.#highlight
1136
+ // the keyboard's focus always shows
1137
+ if (!color && hit === this.#keyFocus) color = FOCUS_COLOR
988
1138
  if (!color) return null
989
1139
  const node = /** @type {any} */ (hit.node)
990
1140
  if (whole) {
@@ -1001,17 +1151,19 @@ export class View {
1001
1151
  /** @param {FrameDescription} frame */
1002
1152
  #occlude(frame) {
1003
1153
  const labels = /** @type {(import('./frame.js').FrameLabel & { occluded?: boolean })[]} */ (frame.labels)
1004
- if (!labels.some((l) => l.style.occlude && l.visible)) return
1005
- // 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])
1006
1161
  const raycaster = new Raycaster()
1007
- const drawn = [...frame.opaque, ...frame.transparent]
1008
- for (const label of labels) {
1009
- if (!label.style.occlude || !label.visible) continue
1162
+ for (const label of testing) {
1010
1163
  raycaster.setFromCamera(label.ndc, this.camera)
1011
1164
  const distance = label.position.distanceTo(raycaster.ray.origin)
1012
1165
  // only solid surfaces hide labels, and not the instance or point being labelled
1013
- const blocker = raycaster.intersectDrawn(drawn).find((h) => !(h.node === label.node && (label.index === null || h.instance === label.index)))
1014
- label.occluded = !!blocker && blocker.distance < distance * 0.995
1166
+ label.occluded = raycaster.blocked(solids, distance * 0.995, label.node, label.index)
1015
1167
  }
1016
1168
  }
1017
1169
 
@@ -1022,6 +1174,8 @@ export class View {
1022
1174
  return { x: e.clientX - rect.left, y: e.clientY - rect.top, clientX: e.clientX, clientY: e.clientY, event: e }
1023
1175
  }
1024
1176
  const move = (e) => {
1177
+ // the pointer takes over from the keyboard
1178
+ if (this.#keyFocus) this.#keyboard?.clear()
1025
1179
  this.#pointer = at(e)
1026
1180
  const press = this.#press
1027
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
@@ -1108,6 +1262,17 @@ export class View {
1108
1262
  resize.observe(canvas)
1109
1263
  this.#cleanup.push(() => resize.disconnect())
1110
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
+ }
1111
1276
  // an off-screen view (a dashboard tile scrolled away) does no work at all
1112
1277
  if (typeof IntersectionObserver !== 'undefined') {
1113
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')
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.