phoria2 1.1.0 → 1.3.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 +70 -13
- package/dist/bloom.js +4 -4
- package/dist/bloom.js.map +1 -1
- package/dist/charts.js +538 -508
- package/dist/charts.js.map +1 -1
- package/dist/core.js +4270 -3652
- package/dist/core.js.map +1 -1
- package/dist/edit.js +65 -65
- package/dist/edit.js.map +1 -1
- package/dist/explain.js +6 -2
- package/dist/explain.js.map +1 -1
- package/dist/gltf.js +116 -116
- package/dist/gltf.js.map +1 -1
- package/dist/keyboard.js +188 -0
- package/dist/keyboard.js.map +1 -0
- package/dist/motion.js +35 -35
- package/dist/motion.js.map +1 -1
- package/dist/particles.js +188 -115
- package/dist/particles.js.map +1 -1
- package/dist/passes.js +29 -29
- package/dist/passes.js.map +1 -1
- package/dist/phoria2.js +1 -1
- package/dist/picking.js +32 -16
- package/dist/picking.js.map +1 -1
- package/dist/snapshot.js +1 -1
- package/dist/tooltip.js +1 -1
- package/dist/types/OrbitControl.d.ts +6 -2
- package/dist/types/Raycaster.d.ts +10 -0
- package/dist/types/View.d.ts +70 -1
- package/dist/types/area.d.ts +18 -0
- 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 +24 -2
- package/dist/types/index.d.ts +2 -0
- package/dist/types/keyboard.d.ts +48 -0
- package/dist/types/labels.d.ts +4 -0
- package/dist/types/material/Material.d.ts +15 -0
- package/dist/types/material/Texture.d.ts +18 -2
- package/dist/types/particles/Emitter.d.ts +39 -2
- package/dist/types/scene/Node.d.ts +19 -14
- package/dist/types/scene/drawables.d.ts +54 -15
- 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/resources.d.ts +12 -2
- package/dist/types/webgl/shaders.d.ts +7 -2
- package/llms-full.txt +106 -10
- package/package.json +4 -1
- package/schema/scene.schema.json +67 -1
- package/src/OrbitControl.js +7 -3
- package/src/Raycaster.js +58 -0
- package/src/View.js +220 -41
- package/src/area.js +131 -0
- package/src/charts/Chart.js +20 -2
- package/src/charts/marks.js +29 -2
- package/src/explain.js +6 -2
- package/src/frame.js +29 -9
- package/src/geometry/Geometry.js +32 -4
- package/src/index.js +1 -0
- package/src/keyboard.js +298 -0
- package/src/labels.js +4 -1
- package/src/material/Material.js +9 -2
- package/src/material/Texture.js +55 -2
- package/src/particles/Emitter.js +121 -10
- 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 +115 -15
- 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 +14 -2
- package/src/webgl/WebGLRenderer.js +36 -5
- package/src/webgl/picking.js +22 -1
- package/src/webgl/resources.js +168 -14
- package/src/webgl/shaders.js +110 -11
package/schema/scene.schema.json
CHANGED
|
@@ -102,6 +102,10 @@
|
|
|
102
102
|
"properties": {
|
|
103
103
|
"type": {
|
|
104
104
|
"const": "points"
|
|
105
|
+
},
|
|
106
|
+
"sort": {
|
|
107
|
+
"type": "boolean",
|
|
108
|
+
"description": "Draw the points furthest first each frame, for soft points that cover what is behind them."
|
|
105
109
|
}
|
|
106
110
|
},
|
|
107
111
|
"unevaluatedProperties": false
|
|
@@ -113,6 +117,15 @@
|
|
|
113
117
|
"properties": {
|
|
114
118
|
"type": {
|
|
115
119
|
"const": "lines"
|
|
120
|
+
},
|
|
121
|
+
"arrows": {
|
|
122
|
+
"enum": ["none", "start", "end", "both"],
|
|
123
|
+
"description": "Arrowheads at the ends of each chain of joined segments."
|
|
124
|
+
},
|
|
125
|
+
"arrowSize": {
|
|
126
|
+
"type": "number",
|
|
127
|
+
"description": "Arrowhead length in pixels; 0 sizes them from the material's width.",
|
|
128
|
+
"minimum": 0
|
|
116
129
|
}
|
|
117
130
|
},
|
|
118
131
|
"unevaluatedProperties": false
|
|
@@ -202,6 +215,11 @@
|
|
|
202
215
|
"type": "null"
|
|
203
216
|
}
|
|
204
217
|
]
|
|
218
|
+
},
|
|
219
|
+
"minScale": {
|
|
220
|
+
"type": "number",
|
|
221
|
+
"description": "Show the label only when zoomed in this far: CSS pixels to one world unit at its point (0, always).",
|
|
222
|
+
"minimum": 0
|
|
205
223
|
}
|
|
206
224
|
},
|
|
207
225
|
"unevaluatedProperties": false
|
|
@@ -327,6 +345,16 @@
|
|
|
327
345
|
},
|
|
328
346
|
"castShadow": {
|
|
329
347
|
"type": "boolean"
|
|
348
|
+
},
|
|
349
|
+
"shadowBounds": {
|
|
350
|
+
"type": ["object", "null"],
|
|
351
|
+
"description": "The part of the world, in world units, that has shadows; null (the default) follows what casts.",
|
|
352
|
+
"required": ["min", "max"],
|
|
353
|
+
"properties": {
|
|
354
|
+
"min": { "$ref": "#/$defs/vec3" },
|
|
355
|
+
"max": { "$ref": "#/$defs/vec3" }
|
|
356
|
+
},
|
|
357
|
+
"additionalProperties": false
|
|
330
358
|
}
|
|
331
359
|
},
|
|
332
360
|
"unevaluatedProperties": false
|
|
@@ -462,13 +490,16 @@
|
|
|
462
490
|
"description": "Anything of your own."
|
|
463
491
|
},
|
|
464
492
|
"highlight": {
|
|
465
|
-
"description": "Hover tint for this subtree,
|
|
493
|
+
"description": "Hover tint for this subtree; false for none, null to use the View's.",
|
|
466
494
|
"anyOf": [
|
|
467
495
|
{
|
|
468
496
|
"$ref": "#/$defs/color"
|
|
469
497
|
},
|
|
470
498
|
{
|
|
471
499
|
"type": "null"
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
"const": false
|
|
472
503
|
}
|
|
473
504
|
]
|
|
474
505
|
},
|
|
@@ -898,6 +929,20 @@
|
|
|
898
929
|
"description": "Lines: width in pixels.",
|
|
899
930
|
"minimum": 0
|
|
900
931
|
},
|
|
932
|
+
"dash": {
|
|
933
|
+
"type": "number",
|
|
934
|
+
"description": "Lines: dashed, with dashes this long in the model's units; 0 is solid.",
|
|
935
|
+
"minimum": 0
|
|
936
|
+
},
|
|
937
|
+
"gap": {
|
|
938
|
+
"type": "number",
|
|
939
|
+
"description": "Lines: the gaps between dashes; 0 is as long as the dashes.",
|
|
940
|
+
"minimum": 0
|
|
941
|
+
},
|
|
942
|
+
"dashOffset": {
|
|
943
|
+
"type": "number",
|
|
944
|
+
"description": "Lines: moves the dashes along the lines - animate it for flow."
|
|
945
|
+
},
|
|
901
946
|
"alphaTest": {
|
|
902
947
|
"type": "number",
|
|
903
948
|
"description": "Cut-outs: drop pixels with less alpha.",
|
|
@@ -1262,6 +1307,27 @@
|
|
|
1262
1307
|
"type": "number"
|
|
1263
1308
|
}
|
|
1264
1309
|
},
|
|
1310
|
+
"sizes": {
|
|
1311
|
+
"type": "array",
|
|
1312
|
+
"description": "Points: one per vertex, a multiple of the material's size.",
|
|
1313
|
+
"items": {
|
|
1314
|
+
"type": "number"
|
|
1315
|
+
}
|
|
1316
|
+
},
|
|
1317
|
+
"opacities": {
|
|
1318
|
+
"type": "array",
|
|
1319
|
+
"description": "Points: one per vertex, 0-1, times the material's opacity.",
|
|
1320
|
+
"items": {
|
|
1321
|
+
"type": "number"
|
|
1322
|
+
}
|
|
1323
|
+
},
|
|
1324
|
+
"angles": {
|
|
1325
|
+
"type": "array",
|
|
1326
|
+
"description": "Points: one per vertex, radians anticlockwise on screen.",
|
|
1327
|
+
"items": {
|
|
1328
|
+
"type": "number"
|
|
1329
|
+
}
|
|
1330
|
+
},
|
|
1265
1331
|
"indices": {
|
|
1266
1332
|
"type": "array",
|
|
1267
1333
|
"items": {
|
package/src/OrbitControl.js
CHANGED
|
@@ -255,8 +255,12 @@ export class OrbitControl {
|
|
|
255
255
|
this.target.add(move)
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
/**
|
|
259
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Apply inertia and auto-rotate; the View calls this each frame.
|
|
260
|
+
* @param {number} dt seconds
|
|
261
|
+
* @param {boolean} [still] hold auto-rotation - the View passes its reducedMotion
|
|
262
|
+
*/
|
|
263
|
+
update(dt, still = false) {
|
|
260
264
|
if (!this.enabled) return
|
|
261
265
|
if (this.#pointers.size === 0) {
|
|
262
266
|
const v = this.#velocity
|
|
@@ -267,7 +271,7 @@ export class OrbitControl {
|
|
|
267
271
|
v.yaw *= keep
|
|
268
272
|
v.pitch *= keep
|
|
269
273
|
}
|
|
270
|
-
if (this.autoRotate) this.rotate(-(this.autoRotate === true ? 0.5 : this.autoRotate) * dt, 0)
|
|
274
|
+
if (this.autoRotate && !still) this.rotate(-(this.autoRotate === true ? 0.5 : this.autoRotate) * dt, 0)
|
|
271
275
|
}
|
|
272
276
|
}
|
|
273
277
|
|
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
|
@@ -4,6 +4,7 @@ import { DRAG_START, OrbitControl } from './OrbitControl.js'
|
|
|
4
4
|
import { cameraPreset, fitPoints, framingScale, presetDirection } from './presets.js'
|
|
5
5
|
import { EASES } from './ease.js'
|
|
6
6
|
import { Raycaster } from './Raycaster.js'
|
|
7
|
+
import { pickArea } from './area.js'
|
|
7
8
|
import { ChangeWatch } from './watch.js'
|
|
8
9
|
import { Box3 } from './value/Box3.js'
|
|
9
10
|
import { Color } from './value/Color.js'
|
|
@@ -27,6 +28,7 @@ import { checkOneOf, checkOptions, checkRange, describe, fail, warnOnce } from '
|
|
|
27
28
|
/** @import { CameraOptions } from './scene/cameras.js' */
|
|
28
29
|
/** @import { ColorInput, Vec3Input } from './types.js' */
|
|
29
30
|
/** @import { Node } from './scene/Node.js' */
|
|
31
|
+
/** @import { Keyboard, KeyboardOptions } from './keyboard.js' */
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
34
|
* @typedef {'auto' | 'always' | 'manual'} RenderMode
|
|
@@ -65,13 +67,23 @@ import { checkOneOf, checkOptions, checkRange, describe, fail, warnOnce } from '
|
|
|
65
67
|
* additive points and lines (default off). true for the defaults, or { strength, radius, threshold }
|
|
66
68
|
* @property {boolean} [autoLight] light a scene that has no lights, with one console note (default true)
|
|
67
69
|
* @property {number} [maxDelta] longest frame step in seconds, so a background tab doesn't jump (default 0.1)
|
|
70
|
+
* @property {'auto' | boolean} [reducedMotion] cut motion short, for people it makes unwell: frame() jumps rather
|
|
71
|
+
* than glides, auto-rotation holds, and charts change at once with their flows still. Default 'auto': as the
|
|
72
|
+
* system's setting (prefers-reduced-motion), followed as it changes
|
|
73
|
+
* @property {boolean | KeyboardOptions} [keyboard] use without a pointer: the canvas joins the page's tab order, and
|
|
74
|
+
* the arrow keys step through what can be hovered or clicked - each with the hover tint, the tooltip and hover
|
|
75
|
+
* handlers - with Enter or Space to click and Escape to leave. A screen reader hears each one. { label: (hit) =>
|
|
76
|
+
* text } says what it hears (default: the tooltip's text, the data, or the name). Default false
|
|
77
|
+
* @property {string | null} [description] what the view shows, for screen readers: the canvas's label. With keyboard
|
|
78
|
+
* on and no description, one is made from the scene (charts describe themselves)
|
|
68
79
|
*/
|
|
69
80
|
|
|
70
81
|
/** @typedef {'click' | 'hover'} ViewEvent */
|
|
71
82
|
/** @typedef {(hit: Hit | null, event: PointerEvent | MouseEvent | null) => void} ViewHandler */
|
|
72
83
|
|
|
73
84
|
const OPTIONS = ['canvas', 'scene', 'camera', 'orbit', 'background', 'renderMode', 'framing', 'highlight', 'tooltip', 'pixelRatio',
|
|
74
|
-
'antialias', 'outlineAntialias', 'context', 'toneMapping', 'exposure', 'bloom', 'autoLight', 'maxDelta'
|
|
85
|
+
'antialias', 'outlineAntialias', 'context', 'toneMapping', 'exposure', 'bloom', 'autoLight', 'maxDelta', 'reducedMotion',
|
|
86
|
+
'keyboard', 'description']
|
|
75
87
|
const FRAMING_OPTIONS = ['keep', 'aspect']
|
|
76
88
|
const HINTS = {
|
|
77
89
|
renderer: 'the View makes its renderer - read it from view.renderer',
|
|
@@ -90,6 +102,8 @@ const EVENT_HINTS = {
|
|
|
90
102
|
}
|
|
91
103
|
// how far the highlight colour is mixed in
|
|
92
104
|
const HIGHLIGHT_AMOUNT = 0.45
|
|
105
|
+
// what the keyboard's focus is tinted with when nothing says otherwise - focus must always show
|
|
106
|
+
const FOCUS_COLOR = Color.fromInput('#ffcc00')
|
|
93
107
|
// views that get a WebGL context of their own before 'auto' starts sharing one
|
|
94
108
|
const OWN_CONTEXTS = 4
|
|
95
109
|
// above this many things to test (instances, triangles, points, segments), picking goes to the
|
|
@@ -161,6 +175,15 @@ export class View {
|
|
|
161
175
|
#tooltipClass = ''
|
|
162
176
|
#tooltipLoading = false
|
|
163
177
|
/** @type {Color | null} */ #highlight = null
|
|
178
|
+
/** @type {'auto' | boolean} */ #motion = 'auto'
|
|
179
|
+
// keyboard use: its code loads when asked for; the thing it has stepped to shows as hovered
|
|
180
|
+
/** @type {Keyboard | null} */ #keyboard = null
|
|
181
|
+
/** @type {Hit | null} */ #keyFocus = null
|
|
182
|
+
/** @type {string | null} */ #description = null
|
|
183
|
+
// the canvas had no role, so describing it gave it one - taken away again with the description
|
|
184
|
+
#gaveRole = false
|
|
185
|
+
// the system's prefers-reduced-motion, kept up to date
|
|
186
|
+
#systemReduced = false
|
|
164
187
|
/** @type {{ x: number, y: number, clientX: number, clientY: number, event: PointerEvent } | null} */ #pointer = null
|
|
165
188
|
#pickPending = false
|
|
166
189
|
// a hover pick is out on the GPU
|
|
@@ -227,6 +250,7 @@ export class View {
|
|
|
227
250
|
this.invalidate()
|
|
228
251
|
}
|
|
229
252
|
if (options.autoLight !== undefined) this.autoLight = !!options.autoLight
|
|
253
|
+
if (options.reducedMotion !== undefined) this.#setMotion(options.reducedMotion)
|
|
230
254
|
if (options.maxDelta !== undefined) this.maxDelta = checkRange('View', 'maxDelta', options.maxDelta, 0, Infinity, { above: true })
|
|
231
255
|
if (options.renderMode !== undefined) this.renderMode = checkOneOf('View', 'renderMode', options.renderMode, ['auto', 'always', 'manual'])
|
|
232
256
|
if (options.pixelRatio !== undefined) {
|
|
@@ -247,6 +271,8 @@ export class View {
|
|
|
247
271
|
}
|
|
248
272
|
if (options.highlight !== undefined) this.#setHighlight(options.highlight)
|
|
249
273
|
if (options.tooltip !== undefined) this.tooltip(options.tooltip)
|
|
274
|
+
if (options.description !== undefined) this.description = options.description
|
|
275
|
+
if (options.keyboard !== undefined && options.keyboard !== false) this.#loadKeyboard(options.keyboard)
|
|
250
276
|
this.#labels = new LabelLayer(this.canvas, {
|
|
251
277
|
interactive: (label) => labelHandlers(label, 'click').length > 0 || labelHandlers(label, 'hover').length > 0,
|
|
252
278
|
on: (type, label, e) => this.#labelPointer(type, label, e),
|
|
@@ -324,6 +350,51 @@ export class View {
|
|
|
324
350
|
this.invalidate()
|
|
325
351
|
}
|
|
326
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Is motion cut short - glides, auto-rotation, chart transitions? Set true or false, or 'auto' (the
|
|
355
|
+
* default) to follow the system's setting. Read it for motion of your own, such as a timeline.
|
|
356
|
+
* @returns {boolean}
|
|
357
|
+
*/
|
|
358
|
+
get reducedMotion() { return this.#motion === 'auto' ? this.#systemReduced : this.#motion }
|
|
359
|
+
/** @param {'auto' | boolean} v */
|
|
360
|
+
set reducedMotion(v) { this.#setMotion(v) }
|
|
361
|
+
|
|
362
|
+
/** @param {'auto' | boolean} v */
|
|
363
|
+
#setMotion(v) {
|
|
364
|
+
if (v !== 'auto' && typeof v !== 'boolean') fail('View', `'reducedMotion' is 'auto' (as the system's setting), true or false, got ${describe(v)}`)
|
|
365
|
+
this.#motion = v
|
|
366
|
+
this.invalidate()
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* What the view shows, for screen readers - the canvas's label - or null. With keyboard on and no
|
|
371
|
+
* description, one is made from the scene.
|
|
372
|
+
* @returns {string | null}
|
|
373
|
+
*/
|
|
374
|
+
get description() { return this.#description }
|
|
375
|
+
/** @param {string | null} v */
|
|
376
|
+
set description(v) {
|
|
377
|
+
if (v !== null && typeof v !== 'string') fail('View', `'description' is text for screen readers, or null, got ${describe(v)}`)
|
|
378
|
+
this.#description = v
|
|
379
|
+
if (this.#keyboard) {
|
|
380
|
+
this.#keyboard.setDescription(v)
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
const canvas = this.canvas
|
|
384
|
+
if (v === null) {
|
|
385
|
+
canvas.removeAttribute('aria-label')
|
|
386
|
+
if (this.#gaveRole) canvas.removeAttribute('role')
|
|
387
|
+
this.#gaveRole = false
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
canvas.setAttribute('aria-label', v)
|
|
391
|
+
// a picture, to a screen reader, unless the page has said otherwise
|
|
392
|
+
if (!canvas.hasAttribute('role')) {
|
|
393
|
+
canvas.setAttribute('role', 'img')
|
|
394
|
+
this.#gaveRole = true
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
327
398
|
/** Canvas width in CSS pixels. */
|
|
328
399
|
get width() { return this.#width }
|
|
329
400
|
/** Canvas height in CSS pixels. */
|
|
@@ -397,6 +468,7 @@ export class View {
|
|
|
397
468
|
this.time += dt
|
|
398
469
|
this.#onFrame?.(dt, this.time)
|
|
399
470
|
// the same order as the live loop, so a stepped frame is the frame a user sees
|
|
471
|
+
this.scene.motionReduced = this.reducedMotion
|
|
400
472
|
this.scene.update(dt, this.time)
|
|
401
473
|
this.#updateControls(dt)
|
|
402
474
|
return this.#render(true)
|
|
@@ -411,7 +483,7 @@ export class View {
|
|
|
411
483
|
if (this.#flight) return this.#fly(dt)
|
|
412
484
|
if (!this.controls) return
|
|
413
485
|
const waiting = this.#autoFrame && samePlace(cameraPlace(this.camera), this.#autoFrameFrom)
|
|
414
|
-
this.controls.update(dt)
|
|
486
|
+
this.controls.update(dt, this.reducedMotion)
|
|
415
487
|
if (waiting) this.#autoFrameFrom = cameraPlace(this.camera)
|
|
416
488
|
}
|
|
417
489
|
|
|
@@ -450,8 +522,8 @@ export class View {
|
|
|
450
522
|
// remember what was drawn - including what drawing changed, such as fitting the camera - so
|
|
451
523
|
// the next frame compares against it and doesn't draw the same picture again
|
|
452
524
|
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
|
|
525
|
+
// things may have moved under a still pointer - or the keyboard's focus moved on screen
|
|
526
|
+
if ((this.#pointer || this.#keyFocus) && this.#needsHover()) this.#pickPending = true
|
|
455
527
|
return frame
|
|
456
528
|
}
|
|
457
529
|
|
|
@@ -493,43 +565,26 @@ export class View {
|
|
|
493
565
|
* @param {Node | Box3} [target] (default the scene)
|
|
494
566
|
* @param {{ padding?: number, direction?: CameraPreset | Vec3Input, duration?: number }} [options] padding is
|
|
495
567
|
* 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
|
|
568
|
+
* duration is in seconds (default 0: straight there); a press or the wheel stops the glide, and with
|
|
569
|
+
* reducedMotion it goes straight there
|
|
497
570
|
* @returns {this}
|
|
498
571
|
*/
|
|
499
572
|
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
573
|
checkRange('View', 'frame() duration', duration, 0)
|
|
503
574
|
const cam = /** @type {any} */ (this.camera)
|
|
504
575
|
const from = placeOf(cam)
|
|
505
576
|
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)
|
|
577
|
+
const fit = this.#fit('frame', target, padding, towards)
|
|
511
578
|
// an explicit frame replaces the preset camera's fit on the first frame
|
|
512
579
|
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
|
|
580
|
+
if (!fit) return this
|
|
581
|
+
setPlace(cam, fit.place)
|
|
582
|
+
cam.near = fit.near ?? cam.near
|
|
583
|
+
cam.far = Math.max(cam.far, fit.far)
|
|
529
584
|
// framed for the canvas as it is now
|
|
530
585
|
this.#designed = cam.aspect
|
|
531
586
|
this.#applied = 1
|
|
532
|
-
if (duration > 0) {
|
|
587
|
+
if (duration > 0 && !this.reducedMotion) {
|
|
533
588
|
// the camera starts where it was and glides there, frame by frame
|
|
534
589
|
this.#flight = { from, to: placeOf(cam), time: 0, duration }
|
|
535
590
|
setPlace(cam, from)
|
|
@@ -538,6 +593,56 @@ export class View {
|
|
|
538
593
|
return this
|
|
539
594
|
}
|
|
540
595
|
|
|
596
|
+
/**
|
|
597
|
+
* Where frame() would put the camera, without moving it: its position, target and - for an
|
|
598
|
+
* orthographic camera - height, or null when there is nothing to frame. For a camera of your own
|
|
599
|
+
* that eases after something moving rather than jumping each frame:
|
|
600
|
+
*
|
|
601
|
+
* const aim = view.framing(debris)
|
|
602
|
+
* if (aim) {
|
|
603
|
+
* view.camera.position.lerp(aim.position, 0.1)
|
|
604
|
+
* view.camera.target.lerp(aim.target, 0.1)
|
|
605
|
+
* }
|
|
606
|
+
* @param {Node | Box3} [target] (default the scene)
|
|
607
|
+
* @param {{ padding?: number, direction?: CameraPreset | Vec3Input }} [options] as frame()
|
|
608
|
+
* @returns {{ position: Vec3, target: Vec3, height?: number } | null}
|
|
609
|
+
*/
|
|
610
|
+
framing(target = this.scene, { padding = 0.2, direction } = {}) {
|
|
611
|
+
const fit = this.#fit('framing', target, padding, direction)
|
|
612
|
+
if (!fit) return null
|
|
613
|
+
const { position, target: at, height } = fit.place
|
|
614
|
+
return height === undefined ? { position, target: at } : { position, target: at, height }
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* The camera's place to frame something, worked out without moving it - for frame() and framing().
|
|
619
|
+
* @param {string} method its caller, for errors @param {Node | Box3} target @param {number} padding
|
|
620
|
+
* @param {CameraPreset | Vec3Input | undefined} towards
|
|
621
|
+
* @returns {{ place: Place, near: number | undefined, far: number } | null}
|
|
622
|
+
*/
|
|
623
|
+
#fit(method, target, padding, towards) {
|
|
624
|
+
if (!(padding >= 0)) fail('View', `${method}() padding is the margin around, as a fraction of the size (0.2 = 20%), got ${describe(padding)}`)
|
|
625
|
+
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%)`)
|
|
626
|
+
const cam = /** @type {any} */ (this.camera)
|
|
627
|
+
const direction = towards === undefined ? cam.position.clone().sub(cam.target ?? new Vec3()).normalize()
|
|
628
|
+
: typeof towards === 'string' ? presetDirection(/** @type {CameraPreset} */ (towards)) : Vec3.fromInput(towards, 'View').normalize()
|
|
629
|
+
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)}`)
|
|
630
|
+
if (direction.len() === 0) direction.set(0, 0.3, 1).normalize()
|
|
631
|
+
const points = target instanceof Box3 ? corners(target) : this.#drawnPoints(target, direction)
|
|
632
|
+
if (!points.length) return null
|
|
633
|
+
const ortho = cam.constructor.type === 'orthographic-camera'
|
|
634
|
+
const { center, size } = fitPoints(points, direction, { aspect: cam.aspect, fov: ortho ? undefined : cam.fov, padding })
|
|
635
|
+
const radius = Math.max(1e-3, ...points.map((p) => Math.hypot(p[0] - center[0], p[1] - center[1], p[2] - center[2])))
|
|
636
|
+
if (ortho) {
|
|
637
|
+
return { place: { position: center.clone().add(direction.clone().multiplyScalar(radius * 4)), target: center, height: size },
|
|
638
|
+
near: undefined, far: radius * 10 }
|
|
639
|
+
}
|
|
640
|
+
// keep the near/far range sensible for the model's size
|
|
641
|
+
const distance = size
|
|
642
|
+
return { place: { position: center.clone().add(direction.clone().multiplyScalar(distance)), target: center },
|
|
643
|
+
near: Math.max(1e-3, distance / 1000), far: distance * 10 }
|
|
644
|
+
}
|
|
645
|
+
|
|
541
646
|
/**
|
|
542
647
|
* One frame of a glide: round the target as it moves, closing the distance evenly, so the picture
|
|
543
648
|
* turns and zooms rather than cutting through what is in view.
|
|
@@ -699,6 +804,21 @@ export class View {
|
|
|
699
804
|
return new Mat4().multiplyMatrices(camera.projectionMatrix, camera.viewMatrix)
|
|
700
805
|
}
|
|
701
806
|
|
|
807
|
+
/**
|
|
808
|
+
* Everything inside an area of the canvas, nearest first: a box `{ x, y, width, height }` or a
|
|
809
|
+
* lasso of `[x, y]` points, CSS pixels from the canvas's top left. Each point, instance and line
|
|
810
|
+
* segment is its own hit, found by where it is (a mesh by its middle) - hidden behind others or
|
|
811
|
+
* not - so a box dragged over a scatter selects that region of it.
|
|
812
|
+
*
|
|
813
|
+
* const picked = view.pickArea({ x: 40, y: 30, width: 200, height: 120 }).map((h) => h.datum)
|
|
814
|
+
* @param {import('./area.js').PickArea} area
|
|
815
|
+
* @returns {Hit[]}
|
|
816
|
+
*/
|
|
817
|
+
pickArea(area) {
|
|
818
|
+
this.scene.updateWorldTree()
|
|
819
|
+
return pickArea(this.scene, this.camera, this.#viewProjection(), this.#width, this.#height, area)
|
|
820
|
+
}
|
|
821
|
+
|
|
702
822
|
/** Every hit under a canvas position, nearest first. @param {number | MouseEvent} x @param {number} [y] @returns {Hit[]} */
|
|
703
823
|
pickAll(x, y) {
|
|
704
824
|
if (typeof x === 'object') {
|
|
@@ -774,6 +894,39 @@ export class View {
|
|
|
774
894
|
})
|
|
775
895
|
}
|
|
776
896
|
|
|
897
|
+
/** @param {true | KeyboardOptions} options */
|
|
898
|
+
#loadKeyboard(options) {
|
|
899
|
+
if (options !== true && (typeof options !== 'object' || options === null || Array.isArray(options))) {
|
|
900
|
+
fail('View', `'keyboard' is true, false or { label: (hit) => text }, got ${describe(options)}`)
|
|
901
|
+
}
|
|
902
|
+
const o = options === true ? {} : options
|
|
903
|
+
checkOptions('View', o, ['label'], { describe: "use 'label: (hit) => text'", text: "use 'label: (hit) => text'" }, 'keyboard')
|
|
904
|
+
if (o.label !== undefined && typeof o.label !== 'function') fail('View', `keyboard.label is a function (hit) => text, got ${describe(o.label)}`)
|
|
905
|
+
import('./keyboard.js').then(({ Keyboard }) => {
|
|
906
|
+
if (this.#disposed) return
|
|
907
|
+
this.#keyboard = new Keyboard(this, {
|
|
908
|
+
focus: (hit) => {
|
|
909
|
+
this.#keyFocus = hit
|
|
910
|
+
this.#pickPending = true
|
|
911
|
+
this.invalidate()
|
|
912
|
+
},
|
|
913
|
+
press: (hit) => {
|
|
914
|
+
for (const fn of handlersAbove(hit.node, 'click')) fn(hit)
|
|
915
|
+
for (const fn of this.#handlers.click) fn(hit, null)
|
|
916
|
+
},
|
|
917
|
+
tooltip: (hit) => this.#tooltipFor(hit)?.(hit),
|
|
918
|
+
viewWide: () => this.#handlers.hover.size > 0 || this.#handlers.click.size > 0 || !!this.#tooltipContent,
|
|
919
|
+
}, o)
|
|
920
|
+
this.#keyboard.setDescription(this.#description)
|
|
921
|
+
})
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** Where the keyboard's focus shows - its middle, on screen - in place of the pointer. @param {Hit} hit */
|
|
925
|
+
#keyAt(hit) {
|
|
926
|
+
const { x, y } = this.project(hit.point), rect = this.canvas.getBoundingClientRect()
|
|
927
|
+
return { x, y, clientX: rect.left + x, clientY: rect.top + y, event: null }
|
|
928
|
+
}
|
|
929
|
+
|
|
777
930
|
/** The nearest node tooltip above the hit, or the View's. @param {Hit} hit */
|
|
778
931
|
#tooltipFor(hit) {
|
|
779
932
|
for (let n = /** @type {any} */ (hit.node); n; n = n.parent) if (n.tooltip) return n.tooltip
|
|
@@ -822,7 +975,8 @@ export class View {
|
|
|
822
975
|
sharedContext: this.renderer.shared,
|
|
823
976
|
frame, labels: this.#labelStats, bounds: drawn, nodes, autoLight: this.autoLight,
|
|
824
977
|
contextLost: !!this.renderer.gl?.isContextLost?.(),
|
|
825
|
-
interaction: { hover: this.#handlers.hover.size, click: this.#handlers.click.size, tooltip: !!this.#tooltipContent, highlight: !!this.#highlight
|
|
978
|
+
interaction: { hover: this.#handlers.hover.size, click: this.#handlers.click.size, tooltip: !!this.#tooltipContent, highlight: !!this.#highlight,
|
|
979
|
+
keyboard: !!this.#keyboard, reducedMotion: this.reducedMotion, description: this.canvas.getAttribute?.('aria-label') ?? this.#description },
|
|
826
980
|
})
|
|
827
981
|
}
|
|
828
982
|
|
|
@@ -839,6 +993,8 @@ export class View {
|
|
|
839
993
|
this.controls?.dispose()
|
|
840
994
|
this.#labels.dispose()
|
|
841
995
|
this.#tooltip?.dispose()
|
|
996
|
+
this.#keyboard?.dispose()
|
|
997
|
+
if (this.#description !== null && !this.#keyboard) this.description = null
|
|
842
998
|
this.renderer.dispose(this.scene)
|
|
843
999
|
if (this.#ownContext) ownContexts--
|
|
844
1000
|
if (this.#ownCanvas) this.canvas.remove()
|
|
@@ -879,6 +1035,7 @@ export class View {
|
|
|
879
1035
|
if (this.#running) {
|
|
880
1036
|
this.time += dt
|
|
881
1037
|
this.#onFrame?.(dt, this.time)
|
|
1038
|
+
this.scene.motionReduced = this.reducedMotion
|
|
882
1039
|
this.scene.update(dt, this.time)
|
|
883
1040
|
}
|
|
884
1041
|
this.#updateControls(dt)
|
|
@@ -904,6 +1061,8 @@ export class View {
|
|
|
904
1061
|
this.#pickPending = false
|
|
905
1062
|
if (!this.#needsHover()) return
|
|
906
1063
|
const p = this.#pointer
|
|
1064
|
+
// what the keyboard stepped to is shown until the pointer moves
|
|
1065
|
+
if (this.#keyFocus) return this.#setHovered(this.#keyFocus, this.#keyAt(this.#keyFocus))
|
|
907
1066
|
if (this.#labelHover) return this.#setHovered(this.#labelHover)
|
|
908
1067
|
if (!p || this.#dragging) return this.#setHovered(null)
|
|
909
1068
|
const frame = this.#gpuFrame()
|
|
@@ -924,9 +1083,11 @@ export class View {
|
|
|
924
1083
|
})
|
|
925
1084
|
}
|
|
926
1085
|
|
|
927
|
-
/**
|
|
928
|
-
|
|
929
|
-
|
|
1086
|
+
/**
|
|
1087
|
+
* @param {Hit | null} hit
|
|
1088
|
+
* @param {{ clientX: number, clientY: number, event: PointerEvent | null } | null} [p] where it is shown - the pointer, or the keyboard's focus
|
|
1089
|
+
*/
|
|
1090
|
+
#setHovered(hit, p = this.#pointer) {
|
|
930
1091
|
const before = this.#hovered
|
|
931
1092
|
// the same thing under the pointer: the same node, instance and row of data - a line's segments
|
|
932
1093
|
// and a surface's rows are told apart by their data, without a plain mesh changing per triangle
|
|
@@ -980,13 +1141,16 @@ export class View {
|
|
|
980
1141
|
/** @param {Hit | null} hit @returns {FrameHighlight | null} */
|
|
981
1142
|
#highlightFor(hit) {
|
|
982
1143
|
if (!hit) return null
|
|
983
|
-
// the nearest colour wins
|
|
1144
|
+
// the nearest colour wins - or the nearest false, which tints nothing - and the nearest
|
|
1145
|
+
// highlightWhole is what is tinted as one
|
|
984
1146
|
let color = null, whole = null
|
|
985
1147
|
for (let n = /** @type {any} */ (hit.node); n; n = n.parent) {
|
|
986
1148
|
color ??= n.highlight
|
|
987
1149
|
whole ??= n.highlightWhole ? n : null
|
|
988
1150
|
}
|
|
989
1151
|
color ??= this.#highlight
|
|
1152
|
+
// the keyboard's focus always shows
|
|
1153
|
+
if (!color && hit === this.#keyFocus) color = FOCUS_COLOR
|
|
990
1154
|
if (!color) return null
|
|
991
1155
|
const node = /** @type {any} */ (hit.node)
|
|
992
1156
|
if (whole) {
|
|
@@ -1003,17 +1167,19 @@ export class View {
|
|
|
1003
1167
|
/** @param {FrameDescription} frame */
|
|
1004
1168
|
#occlude(frame) {
|
|
1005
1169
|
const labels = /** @type {(import('./frame.js').FrameLabel & { occluded?: boolean })[]} */ (frame.labels)
|
|
1006
|
-
|
|
1007
|
-
|
|
1170
|
+
// a label waiting to be zoomed in to won't show either way, so it needs no ray
|
|
1171
|
+
const halfHeight = this.height / 2
|
|
1172
|
+
const testing = labels.filter((l) => l.style.occlude && l.visible && !(l.style.minScale && l.scale * halfHeight < l.style.minScale))
|
|
1173
|
+
if (!testing.length) return
|
|
1174
|
+
// what this frame drew hides what is behind it - the same surfaces the picture shows, each placed
|
|
1175
|
+
// once for all the rays, which ask only whether anything is nearer than their label
|
|
1176
|
+
const solids = Raycaster.solids([...frame.opaque, ...frame.transparent])
|
|
1008
1177
|
const raycaster = new Raycaster()
|
|
1009
|
-
const
|
|
1010
|
-
for (const label of labels) {
|
|
1011
|
-
if (!label.style.occlude || !label.visible) continue
|
|
1178
|
+
for (const label of testing) {
|
|
1012
1179
|
raycaster.setFromCamera(label.ndc, this.camera)
|
|
1013
1180
|
const distance = label.position.distanceTo(raycaster.ray.origin)
|
|
1014
1181
|
// only solid surfaces hide labels, and not the instance or point being labelled
|
|
1015
|
-
|
|
1016
|
-
label.occluded = !!blocker && blocker.distance < distance * 0.995
|
|
1182
|
+
label.occluded = raycaster.blocked(solids, distance * 0.995, label.node, label.index)
|
|
1017
1183
|
}
|
|
1018
1184
|
}
|
|
1019
1185
|
|
|
@@ -1024,6 +1190,8 @@ export class View {
|
|
|
1024
1190
|
return { x: e.clientX - rect.left, y: e.clientY - rect.top, clientX: e.clientX, clientY: e.clientY, event: e }
|
|
1025
1191
|
}
|
|
1026
1192
|
const move = (e) => {
|
|
1193
|
+
// the pointer takes over from the keyboard
|
|
1194
|
+
if (this.#keyFocus) this.#keyboard?.clear()
|
|
1027
1195
|
this.#pointer = at(e)
|
|
1028
1196
|
const press = this.#press
|
|
1029
1197
|
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 +1278,17 @@ export class View {
|
|
|
1110
1278
|
resize.observe(canvas)
|
|
1111
1279
|
this.#cleanup.push(() => resize.disconnect())
|
|
1112
1280
|
}
|
|
1281
|
+
// the system's reduced-motion setting, followed as it changes
|
|
1282
|
+
const motion = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')
|
|
1283
|
+
if (motion) {
|
|
1284
|
+
const follow = () => {
|
|
1285
|
+
this.#systemReduced = motion.matches
|
|
1286
|
+
this.invalidate()
|
|
1287
|
+
}
|
|
1288
|
+
this.#systemReduced = motion.matches
|
|
1289
|
+
motion.addEventListener?.('change', follow)
|
|
1290
|
+
this.#cleanup.push(() => motion.removeEventListener?.('change', follow))
|
|
1291
|
+
}
|
|
1113
1292
|
// an off-screen view (a dashboard tile scrolled away) does no work at all
|
|
1114
1293
|
if (typeof IntersectionObserver !== 'undefined') {
|
|
1115
1294
|
const visible = new IntersectionObserver(([entry]) => {
|