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.
- package/README.md +71 -13
- package/dist/bloom.js +4 -4
- package/dist/bloom.js.map +1 -1
- package/dist/charts.js +358 -345
- package/dist/charts.js.map +1 -1
- package/dist/core.js +3989 -3600
- package/dist/core.js.map +1 -1
- package/dist/edit.js +43 -43
- package/dist/edit.js.map +1 -1
- package/dist/explain.js +5 -1
- package/dist/explain.js.map +1 -1
- package/dist/gltf.js +96 -96
- package/dist/gltf.js.map +1 -1
- package/dist/motion.js +3 -3
- package/dist/motion.js.map +1 -1
- package/dist/particles.js +16 -16
- package/dist/particles.js.map +1 -1
- package/dist/passes.js +26 -26
- package/dist/passes.js.map +1 -1
- package/dist/phoria2.js +1 -1
- package/dist/picking.js +16 -16
- package/dist/picking.js.map +1 -1
- package/dist/types/OrbitControl.d.ts +6 -2
- package/dist/types/Raycaster.d.ts +10 -0
- package/dist/types/View.d.ts +59 -1
- package/dist/types/charts/Chart.d.ts +7 -0
- package/dist/types/explain.d.ts +3 -0
- package/dist/types/frame.d.ts +7 -0
- package/dist/types/geometry/Geometry.d.ts +8 -2
- package/dist/types/keyboard.d.ts +48 -0
- package/dist/types/labels.d.ts +4 -0
- package/dist/types/scene/Node.d.ts +19 -14
- package/dist/types/scene/drawables.d.ts +32 -14
- package/dist/types/scene/lights.d.ts +16 -2
- package/dist/types/shadow.d.ts +2 -1
- package/dist/types/types.d.ts +14 -0
- package/dist/types/value/Color.d.ts +0 -4
- package/dist/types/value/Quat.d.ts +4 -2
- package/llms-full.txt +58 -9
- package/package.json +4 -1
- package/schema/scene.schema.json +19 -1
- package/src/OrbitControl.js +7 -3
- package/src/Raycaster.js +58 -0
- package/src/View.js +204 -41
- package/src/charts/Chart.js +20 -2
- package/src/explain.js +6 -2
- package/src/frame.js +29 -9
- package/src/geometry/Geometry.js +16 -3
- package/src/keyboard.js +298 -0
- package/src/labels.js +4 -1
- package/src/scene/Node.js +20 -9
- package/src/scene/Scene.js +2 -0
- package/src/scene/cameras.js +4 -1
- package/src/scene/drawables.js +48 -12
- package/src/scene/lights.js +40 -6
- package/src/shadow.js +27 -4
- package/src/types.js +6 -0
- package/src/value/Color.js +21 -0
- package/src/value/Quat.js +15 -6
- 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
|
|
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 (!
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
/**
|
|
928
|
-
|
|
929
|
-
|
|
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
|
|
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
|
-
|
|
1007
|
-
|
|
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
|
|
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
|
-
|
|
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]) => {
|
package/src/charts/Chart.js
CHANGED
|
@@ -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
|
-
|
|
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')
|