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.
- package/README.md +71 -13
- package/dist/bloom.js +1 -1
- package/dist/charts.js +397 -384
- package/dist/charts.js.map +1 -1
- package/dist/core.js +4451 -3568
- package/dist/core.js.map +1 -1
- package/dist/edit.js +67 -67
- package/dist/edit.js.map +1 -1
- package/dist/explain.js +6 -2
- package/dist/explain.js.map +1 -1
- package/dist/gltf.js +211 -166
- package/dist/gltf.js.map +1 -1
- package/dist/motion.js +26 -26
- package/dist/motion.js.map +1 -1
- package/dist/particles.js +49 -49
- package/dist/particles.js.map +1 -1
- package/dist/passes.js +188 -86
- package/dist/passes.js.map +1 -1
- package/dist/phoria2.js +1 -1
- package/dist/picking.js +8 -13
- package/dist/picking.js.map +1 -1
- package/dist/snapshot.js +1 -1
- package/dist/tooltip.js +3 -3
- package/dist/tooltip.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 +64 -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 +14 -2
- package/dist/types/keyboard.d.ts +48 -0
- package/dist/types/labels.d.ts +4 -0
- package/dist/types/material/Material.d.ts +125 -0
- package/dist/types/material/Texture.d.ts +37 -5
- 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/dist/types/webgl/WebGLRenderer.d.ts +6 -0
- package/dist/types/webgl/passes.d.ts +37 -13
- package/dist/types/webgl/resources.d.ts +24 -2
- package/dist/types/webgl/shaders.d.ts +4 -2
- package/llms-full.txt +153 -15
- package/package.json +4 -1
- package/schema/scene.schema.json +146 -1
- package/src/OrbitControl.js +7 -3
- package/src/Raycaster.js +58 -0
- package/src/View.js +207 -42
- package/src/charts/Chart.js +20 -2
- package/src/explain.js +6 -2
- package/src/frame.js +29 -9
- package/src/geometry/Geometry.js +24 -4
- package/src/gltf/load.js +50 -6
- package/src/keyboard.js +298 -0
- package/src/labels.js +4 -1
- package/src/material/Material.js +104 -19
- package/src/material/Texture.js +116 -11
- 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 +25 -1
- package/src/webgl/WebGLRenderer.js +51 -12
- package/src/webgl/passes.js +190 -32
- package/src/webgl/picking.js +0 -1
- package/src/webgl/resources.js +89 -8
- package/src/webgl/shaders.js +350 -48
package/src/geometry/Geometry.js
CHANGED
|
@@ -6,11 +6,13 @@ import { checkOptions, describe, fail } from '../validate.js'
|
|
|
6
6
|
* @property {ArrayLike<number>} positions xyz per vertex
|
|
7
7
|
* @property {ArrayLike<number>} [normals] xyz per vertex; computed (smooth) when missing
|
|
8
8
|
* @property {ArrayLike<number>} [uvs] uv per vertex - glTF convention, (0, 0) is the image's top left
|
|
9
|
+
* @property {ArrayLike<number>} [uvs2] a second uv per vertex, for maps that need every surface on its own
|
|
10
|
+
* patch of texture - baked occlusion and light maps (a texture's uvSet: 1 reads it)
|
|
9
11
|
* @property {ArrayLike<number>} [colors] rgb per vertex, sRGB 0-1 like every colour input
|
|
10
12
|
* @property {ArrayLike<number>} [indices] vertex indices; triangles for meshes, pairs for lines
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
|
-
const OPTIONS = ['positions', 'normals', 'uvs', 'colors', 'indices']
|
|
15
|
+
const OPTIONS = ['positions', 'normals', 'uvs', 'uvs2', 'colors', 'indices']
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Vertex data: flat typed arrays plus an optional index. Generators such as box() and sphere()
|
|
@@ -21,6 +23,7 @@ export class Geometry {
|
|
|
21
23
|
static OPTIONS = OPTIONS
|
|
22
24
|
/** @type {Float32Array} */ positions
|
|
23
25
|
/** @type {Float32Array | null} */ uvs = null
|
|
26
|
+
/** A second UV set, for baked occlusion and light maps. @type {Float32Array | null} */ uvs2 = null
|
|
24
27
|
/** @type {Float32Array | null} */ colors = null
|
|
25
28
|
/** @type {Uint16Array | Uint32Array | null} */ indices = null
|
|
26
29
|
|
|
@@ -42,6 +45,8 @@ export class Geometry {
|
|
|
42
45
|
|
|
43
46
|
/** @type {Float32Array | null} */
|
|
44
47
|
#normals = null
|
|
48
|
+
/** The normals were worked out on first read, rather than given or asked for: they follow the shape. */
|
|
49
|
+
#autoNormals = false
|
|
45
50
|
/** @type {Box3 | null} */
|
|
46
51
|
#bounds = null
|
|
47
52
|
/** @type {Set<() => void>} */
|
|
@@ -55,6 +60,7 @@ export class Geometry {
|
|
|
55
60
|
const n = this.vertexCount
|
|
56
61
|
if (options.normals) this.#normals = toFloat32('normals', options.normals, 3, n)
|
|
57
62
|
if (options.uvs) this.uvs = toFloat32('uvs', options.uvs, 2, n)
|
|
63
|
+
if (options.uvs2) this.uvs2 = toFloat32('uvs2', options.uvs2, 2, n)
|
|
58
64
|
if (options.colors) this.colors = toFloat32('colors', options.colors, 3, n)
|
|
59
65
|
if (options.indices) {
|
|
60
66
|
// -1: no indices at all refer to no vertex, so an empty geometry can say so
|
|
@@ -71,16 +77,22 @@ export class Geometry {
|
|
|
71
77
|
|
|
72
78
|
/**
|
|
73
79
|
* xyz per vertex. Computed (smooth) from the triangles on first read when not supplied, so
|
|
74
|
-
* points and lines - whose geometry has no triangles - never pay for them
|
|
80
|
+
* points and lines - whose geometry has no triangles - never pay for them - and computed again after
|
|
81
|
+
* needsUpdate(), so they follow positions edited in place. Normals that were supplied, assigned or
|
|
82
|
+
* made by calling computeNormals() are kept as they are.
|
|
75
83
|
* @type {Float32Array}
|
|
76
84
|
*/
|
|
77
85
|
get normals() {
|
|
78
|
-
if (!this.#normals)
|
|
86
|
+
if (!this.#normals) {
|
|
87
|
+
this.computeNormals()
|
|
88
|
+
this.#autoNormals = true
|
|
89
|
+
}
|
|
79
90
|
return /** @type {Float32Array} */ (this.#normals)
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
set normals(v) {
|
|
83
94
|
this.#normals = v
|
|
95
|
+
this.#autoNormals = false
|
|
84
96
|
}
|
|
85
97
|
|
|
86
98
|
/** True once normals exist, supplied or computed. */
|
|
@@ -117,9 +129,14 @@ export class Geometry {
|
|
|
117
129
|
return this.#bounds ??= new Box3().expandByPositions(this.positions)
|
|
118
130
|
}
|
|
119
131
|
|
|
120
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Call after editing the arrays in place. It is no longer what its generator made, so toJSON() saves
|
|
134
|
+
* the arrays; normals that were worked out on first read are worked out again on the next.
|
|
135
|
+
* @returns {this}
|
|
136
|
+
*/
|
|
121
137
|
needsUpdate() {
|
|
122
138
|
this.source = null
|
|
139
|
+
if (this.#autoNormals) this.#normals = null
|
|
123
140
|
return this.#changed()
|
|
124
141
|
}
|
|
125
142
|
|
|
@@ -172,6 +189,7 @@ export class Geometry {
|
|
|
172
189
|
positions: this.positions,
|
|
173
190
|
normals: this.#normals ?? undefined,
|
|
174
191
|
uvs: this.uvs ?? undefined,
|
|
192
|
+
uvs2: this.uvs2 ?? undefined,
|
|
175
193
|
colors: this.colors ?? undefined,
|
|
176
194
|
indices: this.indices ?? undefined,
|
|
177
195
|
})
|
|
@@ -198,6 +216,7 @@ export class Geometry {
|
|
|
198
216
|
const json = { positions: Array.from(this.positions) }
|
|
199
217
|
if (this.#normals) json.normals = Array.from(this.#normals)
|
|
200
218
|
if (this.uvs) json.uvs = Array.from(this.uvs)
|
|
219
|
+
if (this.uvs2) json.uvs2 = Array.from(this.uvs2)
|
|
201
220
|
if (this.colors) json.colors = Array.from(this.colors)
|
|
202
221
|
if (this.indices) json.indices = Array.from(this.indices)
|
|
203
222
|
return json
|
|
@@ -221,6 +240,7 @@ export class Geometry {
|
|
|
221
240
|
}
|
|
222
241
|
this.positions = /** @type {Float32Array} */ (expand(this.positions, 3))
|
|
223
242
|
this.uvs = expand(this.uvs, 2)
|
|
243
|
+
this.uvs2 = expand(this.uvs2, 2)
|
|
224
244
|
this.colors = expand(this.colors, 3)
|
|
225
245
|
this.indices = null
|
|
226
246
|
this.source = null
|
package/src/gltf/load.js
CHANGED
|
@@ -25,7 +25,8 @@ import { Animation } from './Animation.js'
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
const SUPPORTED = new Set(['KHR_materials_emissive_strength', 'KHR_texture_transform', 'KHR_lights_punctual',
|
|
28
|
-
'KHR_materials_unlit', 'KHR_mesh_quantization'
|
|
28
|
+
'KHR_materials_unlit', 'KHR_mesh_quantization', 'KHR_materials_transmission', 'KHR_materials_clearcoat', 'KHR_materials_sheen',
|
|
29
|
+
'KHR_materials_ior', 'KHR_materials_specular', 'KHR_materials_anisotropy', 'KHR_materials_diffuse_transmission'])
|
|
29
30
|
const COMPRESSION = new Set(['KHR_draco_mesh_compression', 'EXT_meshopt_compression', 'KHR_texture_basisu'])
|
|
30
31
|
|
|
31
32
|
const COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 }
|
|
@@ -266,6 +267,7 @@ class Builder {
|
|
|
266
267
|
const options = { positions: this.#accessor(a.POSITION) }
|
|
267
268
|
if (a.NORMAL !== undefined) options.normals = this.#accessor(a.NORMAL)
|
|
268
269
|
if (a.TEXCOORD_0 !== undefined) options.uvs = this.#accessor(a.TEXCOORD_0)
|
|
270
|
+
if (a.TEXCOORD_1 !== undefined) options.uvs2 = this.#accessor(a.TEXCOORD_1)
|
|
269
271
|
if (a.COLOR_0 !== undefined) options.colors = srgbColors(/** @type {Float32Array} */ (this.#accessor(a.COLOR_0)), COMPONENTS[this.json.accessors[a.COLOR_0].type])
|
|
270
272
|
if (p.indices !== undefined) options.indices = this.#accessor(p.indices, true)
|
|
271
273
|
const geometry = new Geometry(options)
|
|
@@ -364,6 +366,43 @@ class Builder {
|
|
|
364
366
|
}
|
|
365
367
|
material.emissiveIntensity = m.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? 1
|
|
366
368
|
if (m.extensions?.KHR_materials_unlit) material.unlit = true
|
|
369
|
+
// glass, clear coat, cloth, reflectance, brushed metal and translucency, by their factors - their
|
|
370
|
+
// own textures aren't read
|
|
371
|
+
const clamp = (/** @type {number} */ v) => Math.min(Math.max(v, 0), 1)
|
|
372
|
+
const x = m.extensions ?? {}
|
|
373
|
+
const transmission = x.KHR_materials_transmission, coat = x.KHR_materials_clearcoat, sheen = x.KHR_materials_sheen
|
|
374
|
+
if (transmission) material.transmission = clamp(transmission.transmissionFactor ?? 0)
|
|
375
|
+
if (coat) {
|
|
376
|
+
material.clearcoat = clamp(coat.clearcoatFactor ?? 0)
|
|
377
|
+
material.clearcoatRoughness = clamp(coat.clearcoatRoughnessFactor ?? 0)
|
|
378
|
+
}
|
|
379
|
+
if (sheen) {
|
|
380
|
+
const c = sheen.sheenColorFactor ?? [0, 0, 0]
|
|
381
|
+
// glTF's sheen is its colour alone: black is none
|
|
382
|
+
if (Math.max(c[0], c[1], c[2]) > 0) {
|
|
383
|
+
material.sheen = 1
|
|
384
|
+
material.sheenColor.setLinear(c[0], c[1], c[2])
|
|
385
|
+
}
|
|
386
|
+
material.sheenRoughness = clamp(sheen.sheenRoughnessFactor ?? 0)
|
|
387
|
+
}
|
|
388
|
+
if (x.KHR_materials_ior?.ior >= 1) material.ior = x.KHR_materials_ior.ior
|
|
389
|
+
const specular = x.KHR_materials_specular
|
|
390
|
+
if (specular) {
|
|
391
|
+
material.specularIntensity = clamp(specular.specularFactor ?? 1)
|
|
392
|
+
const c = specular.specularColorFactor ?? [1, 1, 1]
|
|
393
|
+
material.specularColor.setLinear(c[0], c[1], c[2])
|
|
394
|
+
}
|
|
395
|
+
const brushed = x.KHR_materials_anisotropy
|
|
396
|
+
if (brushed) {
|
|
397
|
+
material.anisotropy = clamp(brushed.anisotropyStrength ?? 0)
|
|
398
|
+
material.anisotropyRotation = brushed.anisotropyRotation ?? 0
|
|
399
|
+
}
|
|
400
|
+
const translucent = x.KHR_materials_diffuse_transmission
|
|
401
|
+
if (translucent) {
|
|
402
|
+
material.translucency = clamp(translucent.diffuseTransmissionFactor ?? 0)
|
|
403
|
+
const c = translucent.diffuseTransmissionColorFactor ?? [1, 1, 1]
|
|
404
|
+
material.translucencyColor.setLinear(c[0], c[1], c[2])
|
|
405
|
+
}
|
|
367
406
|
if (m.alphaMode === 'BLEND') {
|
|
368
407
|
material.opacity = base[3]
|
|
369
408
|
material.transparent = true
|
|
@@ -390,10 +429,12 @@ class Builder {
|
|
|
390
429
|
*/
|
|
391
430
|
#texture(info, colorSpace) {
|
|
392
431
|
const transform = info.extensions?.KHR_texture_transform
|
|
393
|
-
|
|
394
|
-
if (texCoord
|
|
395
|
-
|
|
396
|
-
|
|
432
|
+
let texCoord = transform?.texCoord ?? info.texCoord ?? 0
|
|
433
|
+
if (texCoord > 1) {
|
|
434
|
+
warnOnce(this.model, `textures on a third UV set or later (TEXCOORD_${texCoord}) are drawn with the first`)
|
|
435
|
+
texCoord = 0
|
|
436
|
+
}
|
|
437
|
+
const key = JSON.stringify([info.index, colorSpace, transform?.offset, transform?.scale, transform?.rotation, texCoord])
|
|
397
438
|
const cached = this.#textures.get(key)
|
|
398
439
|
if (cached) return cached
|
|
399
440
|
|
|
@@ -420,6 +461,8 @@ class Builder {
|
|
|
420
461
|
flipY: false,
|
|
421
462
|
repeat: transform?.scale ?? [1, 1],
|
|
422
463
|
offset: transform?.offset ?? [0, 0],
|
|
464
|
+
rotation: transform?.rotation ?? 0,
|
|
465
|
+
uvSet: texCoord,
|
|
423
466
|
})
|
|
424
467
|
this.#textures.set(key, texture)
|
|
425
468
|
return texture
|
|
@@ -538,5 +581,6 @@ function lineList(g, mode) {
|
|
|
538
581
|
function withIndices(g, indices, triangles) {
|
|
539
582
|
// only normals the file gave - reading g.normals would work them out from the strip's own indices,
|
|
540
583
|
// taken as a triangle list; the new geometry works them out from its real triangles instead
|
|
541
|
-
return new Geometry({ positions: g.positions, normals: triangles && g.hasNormals ? g.normals : undefined, uvs: g.uvs ?? undefined,
|
|
584
|
+
return new Geometry({ positions: g.positions, normals: triangles && g.hasNormals ? g.normals : undefined, uvs: g.uvs ?? undefined, uvs2: g.uvs2 ?? undefined,
|
|
585
|
+
colors: g.colors ?? undefined, indices })
|
|
542
586
|
}
|
package/src/keyboard.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// Keyboard use of a View, for people who can't use a pointer and for screen readers: the canvas
|
|
2
|
+
// becomes a stop in the page's tab order, and the arrow keys step through what you can act on - each
|
|
3
|
+
// instance, point or node with a tooltip, a handler or data - as hovering would: the hover tint, the
|
|
4
|
+
// tooltip and hover handlers, with Enter or Space as a click. A live region says what each one is.
|
|
5
|
+
// Loaded when a View is given keyboard: true, so apps without it don't carry it.
|
|
6
|
+
import { hiddenInstance } from './scene/drawables.js'
|
|
7
|
+
import { Vec3 } from './value/Vec3.js'
|
|
8
|
+
|
|
9
|
+
/** @import { Hit } from './Raycaster.js' */
|
|
10
|
+
/** @import { TooltipContent } from './tooltip.js' */
|
|
11
|
+
/** @import { Node } from './scene/Node.js' */
|
|
12
|
+
/** @import { View } from './View.js' */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} KeyboardOptions
|
|
16
|
+
* @property {(hit: Hit) => string} [label] what a screen reader says for each thing stepped to (default: the
|
|
17
|
+
* tooltip's text, then its data, then the node's name)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What the View lends the keyboard: the hover it shows, the click it runs, and what it says about a hit.
|
|
22
|
+
* @typedef {object} KeyboardHooks
|
|
23
|
+
* @property {(hit: Hit | null) => void} focus show this as hovered, or nothing
|
|
24
|
+
* @property {(hit: Hit) => void} press click it
|
|
25
|
+
* @property {(hit: Hit) => TooltipContent} tooltip the tooltip's content for it, if there is a tooltip
|
|
26
|
+
* @property {() => boolean} viewWide does the View itself answer hovers or clicks (a tooltip, handlers)?
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** @typedef {{ node: Node, index: number | null, whole: Node | null }} Item */
|
|
30
|
+
|
|
31
|
+
const STYLE = `:where(.phoria-sr-only) { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
|
32
|
+
clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }`
|
|
33
|
+
// how far PageUp and PageDown jump
|
|
34
|
+
const PAGE = 10
|
|
35
|
+
let ids = 0
|
|
36
|
+
|
|
37
|
+
export class Keyboard {
|
|
38
|
+
/** @type {View} */ #view
|
|
39
|
+
/** @type {KeyboardHooks} */ #hooks
|
|
40
|
+
/** @type {KeyboardOptions} */ #options
|
|
41
|
+
/** The item stepped to, or null. @type {Item | null} */ #at = null
|
|
42
|
+
/** @type {HTMLDivElement} */ #say
|
|
43
|
+
/** @type {HTMLDivElement} */ #help
|
|
44
|
+
/** Attributes the canvas had before, put back on dispose. @type {[string, string | null][]} */ #before
|
|
45
|
+
#description = ''
|
|
46
|
+
#auto = true
|
|
47
|
+
/** @type {(() => void)[]} */ #cleanup = []
|
|
48
|
+
|
|
49
|
+
/** @param {View} view @param {KeyboardHooks} hooks @param {KeyboardOptions} options */
|
|
50
|
+
constructor(view, hooks, options) {
|
|
51
|
+
this.#view = view
|
|
52
|
+
this.#hooks = hooks
|
|
53
|
+
this.#options = options
|
|
54
|
+
if (!document.getElementById('phoria-sr-style')) {
|
|
55
|
+
const style = document.createElement('style')
|
|
56
|
+
style.id = 'phoria-sr-style'
|
|
57
|
+
style.textContent = STYLE
|
|
58
|
+
document.head.prepend(style)
|
|
59
|
+
}
|
|
60
|
+
const canvas = view.canvas, id = `phoria-keys-${++ids}`
|
|
61
|
+
// what a screen reader says as you step: politely, after what it's saying already
|
|
62
|
+
this.#say = document.createElement('div')
|
|
63
|
+
this.#say.className = 'phoria-sr-only'
|
|
64
|
+
this.#say.setAttribute('aria-live', 'polite')
|
|
65
|
+
this.#say.setAttribute('aria-atomic', 'true')
|
|
66
|
+
// how to use it, read once the canvas has focus
|
|
67
|
+
this.#help = document.createElement('div')
|
|
68
|
+
this.#help.className = 'phoria-sr-only'
|
|
69
|
+
this.#help.id = id
|
|
70
|
+
canvas.after(this.#say, this.#help)
|
|
71
|
+
this.#before = ['tabindex', 'role', 'aria-roledescription', 'aria-label', 'aria-describedby'].map((a) => [a, canvas.getAttribute(a)])
|
|
72
|
+
if (!canvas.hasAttribute('tabindex')) canvas.tabIndex = 0
|
|
73
|
+
canvas.setAttribute('role', 'application')
|
|
74
|
+
canvas.setAttribute('aria-roledescription', '3D view')
|
|
75
|
+
canvas.setAttribute('aria-describedby', id)
|
|
76
|
+
|
|
77
|
+
const keydown = (/** @type {KeyboardEvent} */ e) => this.#key(e)
|
|
78
|
+
const blur = () => this.clear()
|
|
79
|
+
const focus = () => this.#describe()
|
|
80
|
+
canvas.addEventListener('keydown', keydown)
|
|
81
|
+
canvas.addEventListener('blur', blur)
|
|
82
|
+
canvas.addEventListener('focus', focus)
|
|
83
|
+
this.#cleanup.push(() => {
|
|
84
|
+
canvas.removeEventListener('keydown', keydown)
|
|
85
|
+
canvas.removeEventListener('blur', blur)
|
|
86
|
+
canvas.removeEventListener('focus', focus)
|
|
87
|
+
})
|
|
88
|
+
this.#describe()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The canvas's label for screen readers: the View's description, or - when it has none - one made
|
|
93
|
+
* from the scene: what its charts show, and how many things there are to step through.
|
|
94
|
+
* @param {string | null} text
|
|
95
|
+
*/
|
|
96
|
+
setDescription(text) {
|
|
97
|
+
this.#auto = text === null
|
|
98
|
+
this.#description = text ?? ''
|
|
99
|
+
this.#describe()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#describe() {
|
|
103
|
+
const count = this.#items().length
|
|
104
|
+
const label = this.#auto ? autoDescription(this.#view.scene, count) : this.#description
|
|
105
|
+
this.#view.canvas.setAttribute('aria-label', label)
|
|
106
|
+
this.#help.textContent = count
|
|
107
|
+
? `Use the arrow keys to move between ${count === 1 ? 'the one item' : `${count} items`}, Enter to select, Escape to leave.`
|
|
108
|
+
: 'Nothing here can be selected.'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Step off whatever was stepped to: the pointer has taken over, or focus has gone. */
|
|
112
|
+
clear() {
|
|
113
|
+
if (!this.#at) return
|
|
114
|
+
this.#at = null
|
|
115
|
+
this.#hooks.focus(null)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @param {KeyboardEvent} e */
|
|
119
|
+
#key(e) {
|
|
120
|
+
if (e.altKey || e.ctrlKey || e.metaKey) return
|
|
121
|
+
const items = this.#items()
|
|
122
|
+
let i = this.#at ? items.findIndex((it) => same(it, /** @type {Item} */ (this.#at))) : -1
|
|
123
|
+
switch (e.key) {
|
|
124
|
+
case 'ArrowRight': case 'ArrowDown': i = i < 0 ? 0 : Math.min(items.length - 1, i + 1); break
|
|
125
|
+
case 'ArrowLeft': case 'ArrowUp': i = i < 0 ? items.length - 1 : Math.max(0, i - 1); break
|
|
126
|
+
case 'PageDown': i = Math.min(items.length - 1, Math.max(0, i) + PAGE); break
|
|
127
|
+
case 'PageUp': i = Math.max(0, i - PAGE); break
|
|
128
|
+
case 'Home': i = 0; break
|
|
129
|
+
case 'End': i = items.length - 1; break
|
|
130
|
+
case 'Enter': case ' ':
|
|
131
|
+
if (!this.#at || i < 0) return
|
|
132
|
+
e.preventDefault()
|
|
133
|
+
this.#hooks.press(hitOf(items[i]))
|
|
134
|
+
return
|
|
135
|
+
case 'Escape':
|
|
136
|
+
if (!this.#at) return
|
|
137
|
+
e.preventDefault()
|
|
138
|
+
this.clear()
|
|
139
|
+
this.#say.textContent = ''
|
|
140
|
+
return
|
|
141
|
+
default: return
|
|
142
|
+
}
|
|
143
|
+
// the arrows and paging keys are the view's while it has focus, not the page's to scroll
|
|
144
|
+
e.preventDefault()
|
|
145
|
+
if (!items.length) {
|
|
146
|
+
this.#say.textContent = 'Nothing here can be selected.'
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
this.#at = items[i]
|
|
150
|
+
const hit = hitOf(items[i])
|
|
151
|
+
this.#hooks.focus(hit)
|
|
152
|
+
this.#say.textContent = `${this.#options.label?.(hit) ?? this.#textOf(hit)}. ${i + 1} of ${items.length}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** What to say about a hit: the tooltip's text, its data, or its node's name. @param {Hit} hit @returns {string} */
|
|
156
|
+
#textOf(hit) {
|
|
157
|
+
const tip = textOfContent(this.#hooks.tooltip(hit))
|
|
158
|
+
if (tip) return tip
|
|
159
|
+
const datum = hit.datum
|
|
160
|
+
if (datum !== null && datum !== undefined) {
|
|
161
|
+
if (typeof datum !== 'object') return String(datum)
|
|
162
|
+
const parts = Object.entries(datum).filter(([, v]) => v === null || typeof v !== 'object').slice(0, 4).map(([k, v]) => `${k}: ${format(v)}`)
|
|
163
|
+
if (parts.length) return parts.join(', ')
|
|
164
|
+
}
|
|
165
|
+
for (let n = /** @type {Node | null} */ (hit.node); n; n = n.parent) if (n.name) return n.name
|
|
166
|
+
return 'Item'
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* What can be stepped to, in scene order and each mesh's instances in order: everything visible and
|
|
171
|
+
* pickable in a subtree with its own tooltip, handlers, drag or tint - and, when the View answers
|
|
172
|
+
* hovers or clicks itself, each thing with data or a name. A highlightWhole subtree is one item, and
|
|
173
|
+
* a surface - a mesh with a row of data per vertex - is a stop per vertex, as picking gives its rows.
|
|
174
|
+
* Lines with a row per segment - a network's links - are a stop per segment, after everything else:
|
|
175
|
+
* the things first, then what joins them. Something with no row and no name of its own is a stop
|
|
176
|
+
* only when it has a handler or tooltip itself, not one it shares with the subtree it's in.
|
|
177
|
+
* @returns {Item[]}
|
|
178
|
+
*/
|
|
179
|
+
#items() {
|
|
180
|
+
/** @type {Item[]} */
|
|
181
|
+
const items = [], links = []
|
|
182
|
+
const viewWide = this.#hooks.viewWide()
|
|
183
|
+
/** @type {Set<Node>} */
|
|
184
|
+
const wholes = new Set()
|
|
185
|
+
/** @param {any} n @param {boolean} own @param {Node | null} whole */
|
|
186
|
+
const walk = (n, own, whole) => {
|
|
187
|
+
if (!n.visible || n.pickable === false) return
|
|
188
|
+
const direct = !!(n.tooltip || n.onDrag || n.highlight || n.handlersFor('click').length || n.handlersFor('hover').length)
|
|
189
|
+
own ||= direct
|
|
190
|
+
whole = n.highlightWhole ? n : whole
|
|
191
|
+
if (n.isDrawable) {
|
|
192
|
+
if (whole) {
|
|
193
|
+
if ((own || viewWide) && !wholes.has(whole)) {
|
|
194
|
+
wholes.add(whole)
|
|
195
|
+
items.push({ node: n, index: null, whole })
|
|
196
|
+
}
|
|
197
|
+
} else if ('instanceMatrices' in n) {
|
|
198
|
+
for (let i = 0; i < n.count; i++) {
|
|
199
|
+
if (!hiddenInstance(n.instanceMatrices, i) && (own || (viewWide && n.datumAt(i) !== null))) items.push({ node: n, index: i, whole: null })
|
|
200
|
+
}
|
|
201
|
+
} else if ((n.drawMode === 'points' && Array.isArray(n.data)) || perVertex(n)) {
|
|
202
|
+
for (let i = 0; i < n.data.length; i++) if (own || viewWide) items.push({ node: n, index: i, whole: null })
|
|
203
|
+
} else if (n.drawMode === 'lines' && Array.isArray(n.data)) {
|
|
204
|
+
for (let i = 0; i < n.data.length; i++) if (own || viewWide) links.push({ node: n, index: i, whole: null })
|
|
205
|
+
} else if (direct || ((own || viewWide) && (n.name || n.datumAt(null) !== null))) items.push({ node: n, index: null, whole: null })
|
|
206
|
+
}
|
|
207
|
+
for (const c of n.children) walk(c, own, whole)
|
|
208
|
+
}
|
|
209
|
+
walk(this.#view.scene, false, null)
|
|
210
|
+
return items.concat(links)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
dispose() {
|
|
214
|
+
for (const fn of this.#cleanup) fn()
|
|
215
|
+
const canvas = this.#view.canvas
|
|
216
|
+
for (const [name, value] of this.#before) {
|
|
217
|
+
if (value === null) canvas.removeAttribute(name)
|
|
218
|
+
else canvas.setAttribute(name, value)
|
|
219
|
+
}
|
|
220
|
+
this.#say.remove()
|
|
221
|
+
this.#help.remove()
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** @param {Item} a @param {Item} b */
|
|
226
|
+
const same = (a, b) => a.node === b.node && a.index === b.index
|
|
227
|
+
|
|
228
|
+
/** A surface: a mesh of triangles with a row of data for each vertex. @param {any} n */
|
|
229
|
+
const perVertex = (n) => n.drawMode === 'triangles' && !('instanceMatrices' in n) && Array.isArray(n.data) && n.data.length === n.geometry.vertexCount
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A hit on an item, as the pointer would make over its middle.
|
|
233
|
+
* @param {Item} item @returns {Hit}
|
|
234
|
+
*/
|
|
235
|
+
function hitOf({ node, index, whole }) {
|
|
236
|
+
const d = /** @type {any} */ (node)
|
|
237
|
+
const points = index !== null && d.drawMode === 'points', vertex = index !== null && perVertex(d)
|
|
238
|
+
const segment = index !== null && d.drawMode === 'lines'
|
|
239
|
+
/** @type {Vec3} */
|
|
240
|
+
let point
|
|
241
|
+
if (whole) point = whole.bounds.getCenter()
|
|
242
|
+
else if (index !== null && 'instanceMatrices' in d) {
|
|
243
|
+
// the middle of the geometry, through the instance's matrix and then the mesh's
|
|
244
|
+
node.updateWorld()
|
|
245
|
+
const local = d.geometry.bounds.getCenter()
|
|
246
|
+
point = through(d.worldMatrix, through(d.instanceMatrices.subarray(index * 16, index * 16 + 16), local))
|
|
247
|
+
} else if (points || vertex) {
|
|
248
|
+
const p = d.geometry.positions
|
|
249
|
+
node.updateWorld()
|
|
250
|
+
point = through(d.worldMatrix, new Vec3(p[index * 3], p[index * 3 + 1], p[index * 3 + 2]))
|
|
251
|
+
} else if (segment) {
|
|
252
|
+
// the middle of the segment: its two ends, by index when the lines have them
|
|
253
|
+
const p = d.geometry.positions, idx = d.geometry.indices
|
|
254
|
+
const a = idx ? idx[index * 2] : index * 2, b = idx ? idx[index * 2 + 1] : index * 2 + 1
|
|
255
|
+
node.updateWorld()
|
|
256
|
+
point = through(d.worldMatrix, new Vec3((p[a * 3] + p[b * 3]) / 2, (p[a * 3 + 1] + p[b * 3 + 1]) / 2, (p[a * 3 + 2] + p[b * 3 + 2]) / 2))
|
|
257
|
+
} else point = node.bounds.getCenter()
|
|
258
|
+
// a surface's vertex is no face or instance - its row is the datum, as a pointer over it finds
|
|
259
|
+
return { node, point, distance: 0, normal: null, face: points || segment ? index : null, instance: index !== null && !points && !vertex && !segment ? index : null,
|
|
260
|
+
uv: null, datum: vertex ? d.data[index] ?? null : d.datumAt?.(index) ?? null }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A point through a 4x4 matrix (column-major). @param {ArrayLike<number>} m @param {Vec3} p @returns {Vec3} */
|
|
264
|
+
function through(m, p) {
|
|
265
|
+
const [x, y, z] = p
|
|
266
|
+
return new Vec3(m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14])
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The text of a tooltip's content, for a screen reader. @param {TooltipContent} content @returns {string} */
|
|
270
|
+
function textOfContent(content) {
|
|
271
|
+
if (content === null || content === undefined || content === false || content === '') return ''
|
|
272
|
+
if (typeof content === 'string' || typeof content === 'number') return String(content).replace(/\s*\n\s*/g, ', ')
|
|
273
|
+
if (typeof globalThis.Node !== 'undefined' && content instanceof globalThis.Node) return (content.textContent ?? '').trim()
|
|
274
|
+
if (typeof content === 'object') {
|
|
275
|
+
const c = /** @type {{ title?: string, rows?: Record<string, unknown> | Array<[string, unknown]> }} */ (content)
|
|
276
|
+
const rows = c.rows ? (Array.isArray(c.rows) ? c.rows : Object.entries(c.rows)).map(([k, v]) => `${k}: ${format(v)}`) : []
|
|
277
|
+
return [c.title, ...rows].filter((s) => s !== undefined && s !== '').join(', ')
|
|
278
|
+
}
|
|
279
|
+
return ''
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** @param {unknown} v */
|
|
283
|
+
const format = (v) => (typeof v === 'number' ? v.toLocaleString() : String(v))
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* A label for the canvas made from the scene: each chart's summary, or how many things there are.
|
|
287
|
+
* @param {Node} scene @param {number} count @returns {string}
|
|
288
|
+
*/
|
|
289
|
+
function autoDescription(scene, count) {
|
|
290
|
+
/** @type {string[]} */
|
|
291
|
+
const parts = []
|
|
292
|
+
scene.traverse((n) => {
|
|
293
|
+
const summary = /** @type {any} */ (n).summary
|
|
294
|
+
if (n !== scene && n.visible && typeof summary === 'function') parts.push(summary.call(n))
|
|
295
|
+
})
|
|
296
|
+
if (!parts.length) parts.push(count ? `3D view of ${count === 1 ? 'one item' : `${count} items`}` : '3D view')
|
|
297
|
+
return parts.join('. ')
|
|
298
|
+
}
|
package/src/labels.js
CHANGED
|
@@ -14,6 +14,7 @@ import { alignColumns, layoutLabels, leaderEnd, placeBox, stackColumns } from '.
|
|
|
14
14
|
* @property {number} dimmed drawn faint because a higher-priority label or geometry was in front (overlap: 'dim')
|
|
15
15
|
* @property {number} occluded hidden because geometry was in front (overlap: 'hide')
|
|
16
16
|
* @property {number} offscreen behind the camera or outside the view
|
|
17
|
+
* @property {number} zoomedOut hidden because the view isn't zoomed in to their minScale
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
/** @typedef {{ el: HTMLDivElement, text: string, className: string, width: number, height: number, showing: boolean, leader: SVGGElement | null, color: string, rule: number, label: FrameLabel | null, interactive: boolean }} Entry */
|
|
@@ -67,7 +68,7 @@ export class LabelLayer {
|
|
|
67
68
|
*/
|
|
68
69
|
update(labels, overlays = []) {
|
|
69
70
|
/** @type {LabelStats} */
|
|
70
|
-
const stats = { total: labels.length, shown: 0, overlapped: 0, dimmed: 0, occluded: 0, offscreen: 0 }
|
|
71
|
+
const stats = { total: labels.length, shown: 0, overlapped: 0, dimmed: 0, occluded: 0, offscreen: 0, zoomedOut: 0 }
|
|
71
72
|
this.shown = []
|
|
72
73
|
this.leaders = []
|
|
73
74
|
if (!labels.length && !this.#elements.size && !overlays.length && !this.#overlays.size && !this.#root) return stats
|
|
@@ -83,6 +84,8 @@ export class LabelLayer {
|
|
|
83
84
|
for (const label of labels) {
|
|
84
85
|
seen.add(label.key)
|
|
85
86
|
if (!label.visible) stats.offscreen++
|
|
87
|
+
// details for closer up: until then they take no part, so they can't push other labels out
|
|
88
|
+
else if (label.style.minScale && label.scale * rect.height / 2 < label.style.minScale) stats.zoomedOut++
|
|
86
89
|
else if (label.occluded && label.style.overlap === 'hide') stats.occluded++
|
|
87
90
|
else active.push({ label, entry: this.#entry(label, root) })
|
|
88
91
|
}
|