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/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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/src/geometry/Geometry.js
CHANGED
|
@@ -45,6 +45,8 @@ export class Geometry {
|
|
|
45
45
|
|
|
46
46
|
/** @type {Float32Array | null} */
|
|
47
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
|
|
48
50
|
/** @type {Box3 | null} */
|
|
49
51
|
#bounds = null
|
|
50
52
|
/** @type {Set<() => void>} */
|
|
@@ -75,16 +77,22 @@ export class Geometry {
|
|
|
75
77
|
|
|
76
78
|
/**
|
|
77
79
|
* xyz per vertex. Computed (smooth) from the triangles on first read when not supplied, so
|
|
78
|
-
* 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.
|
|
79
83
|
* @type {Float32Array}
|
|
80
84
|
*/
|
|
81
85
|
get normals() {
|
|
82
|
-
if (!this.#normals)
|
|
86
|
+
if (!this.#normals) {
|
|
87
|
+
this.computeNormals()
|
|
88
|
+
this.#autoNormals = true
|
|
89
|
+
}
|
|
83
90
|
return /** @type {Float32Array} */ (this.#normals)
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
set normals(v) {
|
|
87
94
|
this.#normals = v
|
|
95
|
+
this.#autoNormals = false
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
/** True once normals exist, supplied or computed. */
|
|
@@ -121,9 +129,14 @@ export class Geometry {
|
|
|
121
129
|
return this.#bounds ??= new Box3().expandByPositions(this.positions)
|
|
122
130
|
}
|
|
123
131
|
|
|
124
|
-
/**
|
|
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
|
+
*/
|
|
125
137
|
needsUpdate() {
|
|
126
138
|
this.source = null
|
|
139
|
+
if (this.#autoNormals) this.#normals = null
|
|
127
140
|
return this.#changed()
|
|
128
141
|
}
|
|
129
142
|
|
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
|
}
|
package/src/scene/Node.js
CHANGED
|
@@ -10,7 +10,7 @@ import { Vec3 } from '../value/Vec3.js'
|
|
|
10
10
|
import { checkOptions, describe, fail } from '../validate.js'
|
|
11
11
|
import { Clip } from '../clip.js'
|
|
12
12
|
|
|
13
|
-
/** @import { ColorInput, Vec3Input } from '../types.js' */
|
|
13
|
+
/** @import { ColorInput, QuatInput, Vec3Input } from '../types.js' */
|
|
14
14
|
/** @import { Hit } from '../Raycaster.js' */
|
|
15
15
|
/** @import { Camera } from './cameras.js' */
|
|
16
16
|
/** @import { ClipOptions } from '../clip.js' */
|
|
@@ -44,7 +44,7 @@ import { Clip } from '../clip.js'
|
|
|
44
44
|
* @property {string} [name]
|
|
45
45
|
* @property {Vec3Input} [position]
|
|
46
46
|
* @property {Vec3Input | Euler} [rotation] Euler angles in radians, XYZ order
|
|
47
|
-
* @property {
|
|
47
|
+
* @property {QuatInput} [quaternion] [x, y, z, w] or { x, y, z, w } - instead of rotation
|
|
48
48
|
* @property {Vec3Input | number} [scale] a number scales uniformly
|
|
49
49
|
* @property {boolean} [visible]
|
|
50
50
|
* @property {boolean} [pickable] include in view.pick() (default true)
|
|
@@ -54,7 +54,8 @@ import { Clip } from '../clip.js'
|
|
|
54
54
|
* @property {UpdateHandler | UpdateHandler[]} [onUpdate]
|
|
55
55
|
* @property {((hit: Hit) => any) | null} [tooltip] tooltip for anything picked in this subtree; nearer nodes win, and
|
|
56
56
|
* all win over the View's
|
|
57
|
-
* @property {ColorInput | null} [highlight] hover tint for this subtree, over the View's
|
|
57
|
+
* @property {ColorInput | null | false} [highlight] hover tint for this subtree, over the View's; false for none - a
|
|
58
|
+
* floor or wall left plain under a View that tints everything else (null: the View's)
|
|
58
59
|
* @property {boolean} [highlightWhole] hovering any part of this subtree tints all of it, as one thing - a
|
|
59
60
|
* piece of furniture, a machine - rather than just the part under the pointer (default false)
|
|
60
61
|
* @property {((hit: Hit | null) => void) | null} [onHover] called as the thing under the pointer in this subtree changes
|
|
@@ -136,7 +137,7 @@ export class Node {
|
|
|
136
137
|
onDrag = null
|
|
137
138
|
/** @type {Outline | null} */
|
|
138
139
|
#outline = null
|
|
139
|
-
/** @type {Color | null} */
|
|
140
|
+
/** @type {Color | null | false} */
|
|
140
141
|
#highlight = null
|
|
141
142
|
/** Hovering any part of this subtree tints all of it, not just the part under the pointer. */
|
|
142
143
|
highlightWhole = false
|
|
@@ -164,7 +165,7 @@ export class Node {
|
|
|
164
165
|
set rotation(v) { this.#rotation.copy(/** @type {any} */ (v), this.constructor.name) }
|
|
165
166
|
/** Kept in sync with rotation. Assigning copies, normalised. @returns {Quat} */
|
|
166
167
|
get quaternion() { return this.#quaternion }
|
|
167
|
-
/** @param {
|
|
168
|
+
/** @param {QuatInput} v */
|
|
168
169
|
set quaternion(v) { this.#quaternion.copy(v, this.constructor.name).normalize() }
|
|
169
170
|
|
|
170
171
|
/** @param {NodeOptions} [options] */
|
|
@@ -191,7 +192,7 @@ export class Node {
|
|
|
191
192
|
if (typeof fn !== 'function') fail(owner, `'${key}' must be a function, got ${describe(fn)}`)
|
|
192
193
|
this[key] = fn
|
|
193
194
|
}
|
|
194
|
-
if (options.highlight != null) this.#
|
|
195
|
+
if (options.highlight != null) this.#setHighlight(options.highlight, owner)
|
|
195
196
|
if (options.highlightWhole !== undefined) {
|
|
196
197
|
if (typeof options.highlightWhole !== 'boolean') fail(owner, `'highlightWhole' must be true or false, got ${describe(options.highlightWhole)}`)
|
|
197
198
|
this.highlightWhole = options.highlightWhole
|
|
@@ -201,14 +202,23 @@ export class Node {
|
|
|
201
202
|
if (options.children) for (const child of options.children) this.add(child)
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* Hover tint for this subtree, over the View's: a colour, false for none, or null to use the View's.
|
|
207
|
+
* @returns {Color | null | false}
|
|
208
|
+
*/
|
|
205
209
|
get highlight() {
|
|
206
210
|
return this.#highlight
|
|
207
211
|
}
|
|
208
212
|
|
|
209
|
-
/** @param {ColorInput | null} value */
|
|
213
|
+
/** @param {ColorInput | null | false} value */
|
|
210
214
|
set highlight(value) {
|
|
211
|
-
this.#
|
|
215
|
+
this.#setHighlight(value, this.constructor.name)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** @param {ColorInput | null | false | true} value @param {string} owner */
|
|
219
|
+
#setHighlight(value, owner) {
|
|
220
|
+
if (value === true) fail(owner, "'highlight' is a colour, false for no tint, or null to use the View's - got true")
|
|
221
|
+
this.#highlight = value == null ? null : value === false ? false : Color.fromInput(value, owner)
|
|
212
222
|
}
|
|
213
223
|
|
|
214
224
|
/**
|
|
@@ -612,6 +622,7 @@ export class Node {
|
|
|
612
622
|
if (!this.pickable) json.pickable = false
|
|
613
623
|
if (!this.matrixAutoUpdate) json.matrixAutoUpdate = false
|
|
614
624
|
if (this.highlight) json.highlight = this.highlight.getStyle()
|
|
625
|
+
else if (this.highlight === false) json.highlight = false
|
|
615
626
|
if (this.highlightWhole) json.highlightWhole = true
|
|
616
627
|
if (this.#clip) json.clip = this.#clip.toJSON()
|
|
617
628
|
if (this.#outline) json.outline = { color: this.#outline.color.getStyle(), width: this.#outline.width }
|
package/src/scene/Scene.js
CHANGED
|
@@ -44,6 +44,8 @@ export class Scene extends Node {
|
|
|
44
44
|
environmentIntensity = 1
|
|
45
45
|
/** 0-1: how blurred a panorama background is drawn - a soft backdrop that doesn't compete. */
|
|
46
46
|
backgroundBlur = 0
|
|
47
|
+
/** @internal Set by the View drawing it, from its reducedMotion: charts' transitions and flows stand still. */
|
|
48
|
+
motionReduced = false
|
|
47
49
|
/** @type {Fog | null} */
|
|
48
50
|
#fog = null
|
|
49
51
|
|
package/src/scene/cameras.js
CHANGED
|
@@ -20,7 +20,10 @@ const tmp3 = new Float32Array(9)
|
|
|
20
20
|
export function aimAt(node, target) {
|
|
21
21
|
if (node.position.equals(target, 1e-9)) return
|
|
22
22
|
const dir = target.clone().sub(node.position).normalize()
|
|
23
|
-
|
|
23
|
+
// world up keeps the picture upright, and turns it smoothly with the view right up to straight up
|
|
24
|
+
// or down - the orbit stops a thousandth of a radian short. Only within a hair of exactly vertical
|
|
25
|
+
// (the 'top' preset's view), where up would no longer say which way the screen faces, is north up
|
|
26
|
+
const up = Math.hypot(dir[0], dir[2]) < 5e-4 ? [0, 0, -1] : UP
|
|
24
27
|
mat4.targetTo(tmp, node.position, target, up)
|
|
25
28
|
const q = quat.normalize(new Float32Array(4), quat.fromMat3(new Float32Array(4), mat3.fromMat4(tmp3, tmp)))
|
|
26
29
|
node.quaternion.copy(q)
|