@fusion.dev/helix-plot 0.197.1 → 0.199.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.
@@ -0,0 +1,35 @@
1
+ import type { SpatialRibbonChartDriver } from '../connect-spatial-ribbon-chart/connect-spatial-ribbon-chart.js';
2
+ /** How the three.js ribbon driver behaves where the page does not say. */
3
+ export interface ThreeRibbonDriverOptions {
4
+ /**
5
+ * How many pixels the drawing has for each of the page's. By default, on a
6
+ * screen worked with a mouse, half as many again as the screen has, at
7
+ * least two and at most three; on a touch screen, as many as the screen
8
+ * has, at most two.
9
+ */
10
+ density?: number | undefined;
11
+ }
12
+ /**
13
+ * Draws a Spatial Ribbon Chart's series as ribbons in three dimensions with
14
+ * three.js. Pass it to `connectSpatialRibbonChart` with the element to draw
15
+ * in, which the chart holds in its plot:
16
+ *
17
+ * ```js
18
+ * import { connectSpatialRibbonChart } from '@fusion.dev/helix-plot'
19
+ * import { threeRibbonDriver } from '@fusion.dev/helix-plot/three'
20
+ *
21
+ * connectSpatialRibbonChart(chart, threeRibbonDriver(chart.querySelector('div')))
22
+ * ```
23
+ *
24
+ * **Each series is a ribbon in its own depth**, its top following the
25
+ * values and its sides falling to zero, in a box long across and shallow in
26
+ * depth, lit from above. Each series takes a series color from the theme.
27
+ *
28
+ * **Dragging turns the box, and the wheel and a pinch bring it nearer.**
29
+ * Shift with an arrow on the chart turns it a step. It never turns by itself.
30
+ * It draws only while something moves, asks for no frames at rest, and
31
+ * shares the page's WebGL contexts with every three.js drawing Helix makes.
32
+ *
33
+ * The renderer is the driver's own, and `disconnect()` frees it.
34
+ */
35
+ export declare const threeRibbonDriver: (container: HTMLElement, options?: ThreeRibbonDriverOptions) => SpatialRibbonChartDriver;
@@ -0,0 +1,128 @@
1
+ import { BufferGeometry, Color, DirectionalLight, DoubleSide, Float32BufferAttribute, HemisphereLight, LineBasicMaterial, LineSegments, Mesh, MeshLambertMaterial, Vector3, } from 'three';
2
+ import { createStage, edgeOf, HALF, inBox, lookThrough } from './stage.js';
3
+ const THICKNESS = 0.45;
4
+ const ROOM = 0.12;
5
+ export const threeRibbonDriver = (container, options = {}) => {
6
+ let field;
7
+ let readings = new Map();
8
+ const ribbonMaterial = new MeshLambertMaterial({
9
+ polygonOffset: true,
10
+ polygonOffsetFactor: 1,
11
+ polygonOffsetUnits: 1,
12
+ side: DoubleSide,
13
+ vertexColors: true,
14
+ });
15
+ const ribbons = new Mesh(new BufferGeometry(), ribbonMaterial);
16
+ const edgeMaterial = new LineBasicMaterial({ vertexColors: true });
17
+ const edges = new LineSegments(new BufferGeometry(), edgeMaterial);
18
+ const stage = createStage(container, {
19
+ density: options.density,
20
+ pick: (ray) => {
21
+ const hit = ray.intersectObject(ribbons)[0];
22
+ if (!hit) {
23
+ return -1;
24
+ }
25
+ let found = -1;
26
+ let nearest = Number.POSITIVE_INFINITY;
27
+ for (const [index, at] of readings) {
28
+ const away = at.distanceTo(hit.point);
29
+ if (away < nearest) {
30
+ nearest = away;
31
+ found = index;
32
+ }
33
+ }
34
+ return found;
35
+ },
36
+ });
37
+ const sky = new HemisphereLight(0xffffff, 0xffffff, 2.2);
38
+ const sun = new DirectionalLight(0xffffff, 1);
39
+ sun.position.set(-2, 4, 1);
40
+ stage.scene.add(ribbons, edges, sky, sun);
41
+ return {
42
+ draw: (next, colors, font) => {
43
+ field = next;
44
+ const [ex, ey, ez] = next.axes.extent ?? [1, 1, 1];
45
+ const count = Math.max(next.series.length, 1);
46
+ const half = ((THICKNESS * 2) / count / 2) * HALF * ez;
47
+ const zero = inBox(next.axes.y, Math.max(next.axes.y.range[0], 0)) * ey;
48
+ const page = new Color(colors.page);
49
+ const { emphasis, through } = colors.look;
50
+ const positions = [];
51
+ const tints = [];
52
+ const lines = [];
53
+ const lineTints = [];
54
+ readings = new Map();
55
+ next.series.forEach((_, series) => {
56
+ const tone = new Color(colors.groups[series % colors.groups.length] ?? colors.ink);
57
+ const color = emphasis === 'outlined' ? tone.clone().lerp(page, 0.4) : tone;
58
+ const edge = emphasis === 'solid' ? edgeOf(color) : tone;
59
+ const z = inBox(next.axes.z, series) * ez;
60
+ const points = [];
61
+ next.categories.forEach((__, across) => {
62
+ const index = across * next.series.length + series;
63
+ const value = next.cells[index]?.value;
64
+ if (value !== undefined) {
65
+ const at = new Vector3(inBox(next.axes.x, across) * ex, inBox(next.axes.y, value) * ey, z);
66
+ points.push(at);
67
+ readings.set(index, at.clone().setZ(z + half));
68
+ }
69
+ });
70
+ const quad = (a, b, c, d) => {
71
+ for (const one of [a, b, c, a, c, d]) {
72
+ positions.push(one.x, one.y, one.z);
73
+ tints.push(color.r, color.g, color.b);
74
+ }
75
+ };
76
+ const line = (a, b) => {
77
+ lines.push(a.x, a.y, a.z, b.x, b.y, b.z);
78
+ lineTints.push(edge.r, edge.g, edge.b, edge.r, edge.g, edge.b);
79
+ };
80
+ const near = (point, y = point.y) => new Vector3(point.x, y, z + half);
81
+ const far = (point, y = point.y) => new Vector3(point.x, y, z - half);
82
+ for (let at = 0; at + 1 < points.length; at++) {
83
+ const [a, b] = [points[at], points[at + 1]];
84
+ quad(near(a), near(b), far(b), far(a));
85
+ quad(near(a, zero), near(b, zero), near(b), near(a));
86
+ quad(far(a, zero), far(b, zero), far(b), far(a));
87
+ line(near(a), near(b));
88
+ line(far(a), far(b));
89
+ }
90
+ const [first, last] = [points[0], points.at(-1)];
91
+ for (const end of [first, last]) {
92
+ if (end) {
93
+ quad(near(end, zero), near(end), far(end), far(end, zero));
94
+ }
95
+ }
96
+ });
97
+ ribbons.geometry.dispose();
98
+ const geometry = new BufferGeometry();
99
+ geometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
100
+ geometry.setAttribute('color', new Float32BufferAttribute(tints, 3));
101
+ geometry.computeVertexNormals();
102
+ ribbons.geometry = geometry;
103
+ edges.geometry.dispose();
104
+ const lined = new BufferGeometry();
105
+ lined.setAttribute('position', new Float32BufferAttribute(lines, 3));
106
+ lined.setAttribute('color', new Float32BufferAttribute(lineTints, 3));
107
+ edges.geometry = lined;
108
+ lookThrough(ribbonMaterial, (emphasis === 'subtle' ? 0.2 : 1) * through);
109
+ lookThrough(edgeMaterial, through);
110
+ stage.setAxes(next.axes, colors, font);
111
+ stage.frame(new Vector3(-HALF * ex - ROOM, -HALF * ey - ROOM, -HALF * ez - ROOM), new Vector3(HALF * ex + ROOM, HALF * ey, HALF * ez + ROOM));
112
+ },
113
+ focus: (index) => {
114
+ stage.setMarker(field ? readings.get(index) : undefined);
115
+ },
116
+ onHover: (listener) => stage.onHover(listener),
117
+ onPlace: (listener) => stage.onPlace(listener),
118
+ onPress: (listener) => stage.onPress(listener),
119
+ stop: () => {
120
+ ribbons.geometry.dispose();
121
+ ribbonMaterial.dispose();
122
+ edges.geometry.dispose();
123
+ edgeMaterial.dispose();
124
+ stage.stop();
125
+ },
126
+ turnBy: (across, up, animate) => stage.turnBy(across, up, animate),
127
+ };
128
+ };
@@ -1,15 +1,21 @@
1
- import { BufferGeometry, CanvasTexture, Color, Float32BufferAttribute, Points, PointsMaterial, Vector3, } from 'three';
2
- import { createStage, inBox } from './stage.js';
1
+ import { BufferGeometry, CanvasTexture, Color, Float32BufferAttribute, LineBasicMaterial, LineSegments, Points, PointsMaterial, Vector3, } from 'three';
2
+ import { createStage, HALF, inBox, lookThrough } from './stage.js';
3
3
  const REACH = 0.03;
4
- const dot = () => {
4
+ const dot = () => ringed(1);
5
+ const ringed = (inside) => {
5
6
  const canvas = document.createElement('canvas');
6
7
  canvas.width = canvas.height = 64;
7
8
  const context = canvas.getContext('2d');
8
9
  if (context) {
9
10
  context.beginPath();
10
11
  context.arc(32, 32, 28, 0, Math.PI * 2);
11
- context.fillStyle = '#fff';
12
+ context.fillStyle = `rgba(255, 255, 255, ${inside})`;
12
13
  context.fill();
14
+ if (inside < 1) {
15
+ context.lineWidth = 7;
16
+ context.strokeStyle = '#fff';
17
+ context.stroke();
18
+ }
13
19
  }
14
20
  return new CanvasTexture(canvas);
15
21
  };
@@ -23,6 +29,7 @@ export const threeScatterDriver = (container, options = {}) => {
23
29
  return new Vector3(inBox(field.axes.x, point.x), inBox(field.axes.y, point.y), inBox(field.axes.z, point.z));
24
30
  };
25
31
  const pointTexture = dot();
32
+ const rings = new Map();
26
33
  const pointMaterial = new PointsMaterial({
27
34
  alphaTest: 0.5,
28
35
  map: pointTexture,
@@ -31,6 +38,12 @@ export const threeScatterDriver = (container, options = {}) => {
31
38
  vertexColors: true,
32
39
  });
33
40
  const points = new Points(new BufferGeometry(), pointMaterial);
41
+ const dropMaterial = new LineBasicMaterial({
42
+ opacity: 0.45,
43
+ transparent: true,
44
+ vertexColors: true,
45
+ });
46
+ const drops = new LineSegments(new BufferGeometry(), dropMaterial);
34
47
  const stage = createStage(container, {
35
48
  density: options.density,
36
49
  pick: (ray) => {
@@ -47,7 +60,7 @@ export const threeScatterDriver = (container, options = {}) => {
47
60
  return found;
48
61
  },
49
62
  });
50
- stage.scene.add(points);
63
+ stage.scene.add(drops, points);
51
64
  return {
52
65
  draw: (next, colors, font) => {
53
66
  field = next;
@@ -65,6 +78,28 @@ export const threeScatterDriver = (container, options = {}) => {
65
78
  geometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
66
79
  geometry.setAttribute('color', new Float32BufferAttribute(tints, 3));
67
80
  points.geometry = geometry;
81
+ drops.geometry.dispose();
82
+ const lines = new BufferGeometry();
83
+ const feet = [];
84
+ for (let at = 0; at < positions.length; at += 3) {
85
+ feet.push(positions[at], positions[at + 1], positions[at + 2], positions[at], -HALF, positions[at + 2]);
86
+ }
87
+ lines.setAttribute('position', new Float32BufferAttribute(feet, 3));
88
+ lines.setAttribute('color', new Float32BufferAttribute(tints.flatMap((_, at) => at % 3 === 0 ? [...tints.slice(at, at + 3), ...tints.slice(at, at + 3)] : []), 3));
89
+ drops.geometry = lines;
90
+ drops.visible = colors.look.drops;
91
+ const { emphasis, through } = colors.look;
92
+ const inside = emphasis === 'outlined' ? 0.6 : emphasis === 'subtle' ? 0.2 : 1;
93
+ if (inside < 1 && !rings.has(inside)) {
94
+ rings.set(inside, ringed(inside));
95
+ }
96
+ pointMaterial.map = rings.get(inside) ?? pointTexture;
97
+ pointMaterial.alphaTest = inside < 1 ? 0.05 : 0.5;
98
+ lookThrough(pointMaterial, through);
99
+ if (inside < 1) {
100
+ pointMaterial.transparent = true;
101
+ pointMaterial.depthWrite = false;
102
+ }
68
103
  stage.setAxes(next.axes, colors, font);
69
104
  },
70
105
  focus: (index) => {
@@ -77,6 +112,11 @@ export const threeScatterDriver = (container, options = {}) => {
77
112
  points.geometry.dispose();
78
113
  pointMaterial.dispose();
79
114
  pointTexture.dispose();
115
+ drops.geometry.dispose();
116
+ dropMaterial.dispose();
117
+ for (const texture of rings.values()) {
118
+ texture.dispose();
119
+ }
80
120
  stage.stop();
81
121
  },
82
122
  turnBy: (across, up, animate) => stage.turnBy(across, up, animate),
@@ -1,13 +1,26 @@
1
- import { Raycaster, Scene, Vector3 } from 'three';
1
+ import { Color, type Material, Raycaster, Scene, Vector3 } from 'three';
2
2
  import type { Axis, PlotColors } from '../connect-plot/connect-plot.js';
3
3
  /** The box's half edge, in three.js's units: what is plotted stands between -1 and 1 on each axis. */
4
4
  export declare const HALF = 1;
5
+ /**
6
+ * Lets `amount` of a mark's material through, as `material` and a `subtle`
7
+ * emphasis ask: opaque at 1, and see-through below it, where a mark writes
8
+ * no depth so the marks behind it still draw.
9
+ */
10
+ export declare const lookThrough: (material: Material, amount: number) => void;
11
+ /**
12
+ * An edge a little lighter than a dark face and a little darker than a light
13
+ * one, as the charts' own drawings draw them, so every block keeps its form
14
+ * on every step of a ramp.
15
+ */
16
+ export declare const edgeOf: (face: Color) => Color;
5
17
  /** A number on an axis, written short enough for a tick. */
6
18
  export declare const tickText: (value: number) => string;
7
19
  /** Where a number stands in the box, from -1 at its axis' low end to 1 at its high end. */
8
20
  export declare const inBox: (axis: Axis, value: number) => number;
9
- /** The plot's three axes: across, up, and in depth. */
21
+ /** The plot's three axes: across, up, and in depth, and how long the box is along each, where not a cube. */
10
22
  export interface Axes {
23
+ extent?: readonly [number, number, number] | undefined;
11
24
  x: Axis;
12
25
  y: Axis;
13
26
  z: Axis;
@@ -1,10 +1,22 @@
1
1
  import { boxLabels, pageBudget, } from '@fusion.dev/helix-core';
2
- import { BufferGeometry, CanvasTexture, Float32BufferAttribute, LineBasicMaterial, LineSegments, PerspectiveCamera, Raycaster, Scene, Spherical, Sprite, SpriteMaterial, Vector2, Vector3, WebGLRenderer, } from 'three';
2
+ import { BufferGeometry, CanvasTexture, Color, Float32BufferAttribute, LineBasicMaterial, LineSegments, PerspectiveCamera, Raycaster, Scene, Spherical, Sprite, SpriteMaterial, Vector2, Vector3, WebGLRenderer, } from 'three';
3
3
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
4
4
  const STEP = Math.PI / 12;
5
5
  const DURATION = 250;
6
6
  const DRAG = 4;
7
7
  export const HALF = 1;
8
+ export const lookThrough = (material, amount) => {
9
+ const clear = amount < 1;
10
+ material.opacity = amount;
11
+ material.transparent = clear;
12
+ material.depthWrite = !clear;
13
+ material.needsUpdate = true;
14
+ };
15
+ export const edgeOf = (face) => {
16
+ const hsl = { h: 0, l: 0, s: 0 };
17
+ face.getHSL(hsl);
18
+ return face.clone().lerp(new Color(hsl.l < 0.5 ? 0xffffff : 0x000000), hsl.l < 0.5 ? 0.22 : 0.18);
19
+ };
8
20
  const ease = (at) => (at < 0.5 ? 2 * at * at : 1 - (-2 * at + 2) ** 2 / 2);
9
21
  export const tickText = (value) => String(Number(value.toPrecision(6)));
10
22
  export const inBox = (axis, value) => {
@@ -26,8 +38,9 @@ const ring = () => {
26
38
  };
27
39
  const gridLines = (axes) => {
28
40
  const lines = [];
41
+ const [ex, ey, ez] = axes.extent ?? [1, 1, 1];
29
42
  const line = (from, to) => {
30
- lines.push(...from, ...to);
43
+ lines.push(from[0] * ex, from[1] * ey, from[2] * ez, to[0] * ex, to[1] * ey, to[2] * ez);
31
44
  };
32
45
  const h = HALF;
33
46
  for (const a of [-h, h]) {
@@ -37,11 +50,11 @@ const gridLines = (axes) => {
37
50
  line([a, b, -h], [a, b, h]);
38
51
  }
39
52
  }
40
- for (const x of axes.x.ticks.map((tick) => inBox(axes.x, tick))) {
53
+ for (const x of (axes.x.rules ?? axes.x.ticks).map((tick) => inBox(axes.x, tick))) {
41
54
  line([x, -h, -h], [x, -h, h]);
42
55
  line([x, -h, -h], [x, h, -h]);
43
56
  }
44
- for (const z of axes.z.ticks.map((tick) => inBox(axes.z, tick))) {
57
+ for (const z of (axes.z.rules ?? axes.z.ticks).map((tick) => inBox(axes.z, tick))) {
45
58
  line([-h, -h, z], [h, -h, z]);
46
59
  line([-h, -h, z], [-h, h, z]);
47
60
  }
@@ -134,7 +147,7 @@ export const createStage = (container, options) => {
134
147
  }
135
148
  };
136
149
  const drawLabels = () => {
137
- if (!axes || !colors) {
150
+ if (!axes || !colors?.look.box) {
138
151
  labels.replaceChildren();
139
152
  return;
140
153
  }
@@ -153,14 +166,18 @@ export const createStage = (container, options) => {
153
166
  };
154
167
  const place = new Vector3();
155
168
  const seen = ([x, y, z]) => {
156
- place.set(x * HALF, y * HALF, z * HALF);
169
+ const [ex, ey, ez] = axes?.extent ?? [1, 1, 1];
170
+ place.set(x * HALF * ex, y * HALF * ey, z * HALF * ez);
157
171
  const depth = -place.distanceTo(camera.position);
158
172
  const on = place.project(camera);
159
173
  return { depth, x: ((on.x + 1) / 2) * width, y: ((1 - on.y) / 2) * height };
160
174
  };
161
175
  const text = (axis) => ({
162
176
  name: axis.name,
163
- ticks: axis.ticks.map((tick) => ({ at: inBox(axis, tick) / HALF, text: tickText(tick) })),
177
+ ticks: axis.ticks.map((tick, index) => ({
178
+ at: inBox(axis, tick) / HALF,
179
+ text: axis.labels?.[index] ?? tickText(tick),
180
+ })),
164
181
  });
165
182
  for (const one of boxLabels(seen, { x: text(axes.x), y: text(axes.y), z: text(axes.z) }, { gap: 6, name: 13, tick: 11, wide: 0.58 })) {
166
183
  label(one.x, one.y, one.text, one.name);
@@ -199,6 +216,21 @@ export const createStage = (container, options) => {
199
216
  dirty = true;
200
217
  if (through >= 1) {
201
218
  turn = undefined;
219
+ turned();
220
+ }
221
+ }
222
+ if (settle) {
223
+ const through = Math.min((now - settle.start) / DURATION, 1);
224
+ const eased = ease(through);
225
+ const toward = camera.position.clone().sub(controls.target).normalize();
226
+ const [near, far] = settle.distance;
227
+ controls.target.lerpVectors(settle.from, settle.to, eased);
228
+ camera.position.copy(controls.target).add(toward.multiplyScalar(near + (far - near) * eased));
229
+ camera.lookAt(controls.target);
230
+ dirty = true;
231
+ if (through >= 1) {
232
+ settle = undefined;
233
+ controls.update();
202
234
  }
203
235
  }
204
236
  if (!dirty) {
@@ -209,7 +241,7 @@ export const createStage = (container, options) => {
209
241
  drawLabels();
210
242
  tellPlace();
211
243
  tellTracked();
212
- if (turn && !pending) {
244
+ if ((turn || settle) && !pending) {
213
245
  pending = requestAnimationFrame(tick);
214
246
  }
215
247
  };
@@ -224,12 +256,8 @@ export const createStage = (container, options) => {
224
256
  : undefined;
225
257
  seen?.observe(container);
226
258
  let framed;
227
- const fit = () => {
228
- if (!framed) {
229
- return;
230
- }
231
- const { high, low } = framed;
232
- const toward = camera.position.clone().sub(controls.target).normalize();
259
+ const fitFrom = (toward, box) => {
260
+ const { high, low } = box;
233
261
  const right = new Vector3(0, 1, 0).cross(toward).normalize();
234
262
  const up = toward.clone().cross(right).normalize();
235
263
  const tanUp = Math.tan((camera.fov * Math.PI) / 360);
@@ -270,14 +298,61 @@ export const createStage = (container, options) => {
270
298
  .add(up.clone().multiplyScalar(((bottom + top) / 2) * tanUp * distance));
271
299
  distance = distanceFrom(center);
272
300
  }
273
- distance *= 1.06;
274
- controls.target.copy(center);
275
- camera.position.copy(center).add(toward.multiplyScalar(distance));
301
+ return { center, distance: distance * 1.06 };
302
+ };
303
+ const reach = (distance) => {
276
304
  controls.minDistance = distance * 0.35;
277
305
  controls.maxDistance = distance * 3;
306
+ };
307
+ const fit = () => {
308
+ if (!framed) {
309
+ return;
310
+ }
311
+ const toward = camera.position.clone().sub(controls.target).normalize();
312
+ const { center, distance } = fitFrom(toward, framed);
313
+ settle = undefined;
314
+ controls.target.copy(center);
315
+ camera.position.copy(center).add(toward.multiplyScalar(distance));
316
+ reach(distance);
278
317
  camera.lookAt(controls.target);
279
318
  controls.update();
280
319
  };
320
+ let settle;
321
+ let began = 0;
322
+ const turning = () => {
323
+ settle = undefined;
324
+ if (framed) {
325
+ const toward = camera.position.clone().sub(controls.target).normalize();
326
+ began = fitFrom(toward, framed).distance;
327
+ }
328
+ };
329
+ const turned = () => {
330
+ if (!framed || began <= 0) {
331
+ return;
332
+ }
333
+ const toward = camera.position.clone().sub(controls.target).normalize();
334
+ const { center, distance } = fitFrom(toward, framed);
335
+ reach(distance);
336
+ const zoomed = camera.position.distanceTo(controls.target) / began;
337
+ const to = { center, distance: distance * zoomed };
338
+ if (still()) {
339
+ controls.target.copy(to.center);
340
+ camera.position.copy(to.center).add(toward.multiplyScalar(to.distance));
341
+ camera.lookAt(controls.target);
342
+ controls.update();
343
+ invalidate();
344
+ return;
345
+ }
346
+ settle = {
347
+ distance: [camera.position.distanceTo(controls.target), to.distance],
348
+ from: controls.target.clone(),
349
+ start: performance.now(),
350
+ to: to.center,
351
+ };
352
+ invalidate();
353
+ };
354
+ controls.addEventListener('start', turning);
355
+ controls.addEventListener('end', turned);
281
356
  const resize = () => {
282
357
  const width = Math.max(container.clientWidth, 1);
283
358
  const height = Math.max(container.clientHeight, 1);
@@ -388,6 +463,7 @@ export const createStage = (container, options) => {
388
463
  const lines = new BufferGeometry();
389
464
  lines.setAttribute('position', new Float32BufferAttribute(gridLines(next), 3));
390
465
  grid.geometry = lines;
466
+ grid.visible = nextColors.look.box;
391
467
  gridMaterial.color.setStyle(nextColors.quiet);
392
468
  marker.material.color.setStyle(nextColors.ink);
393
469
  invalidate();
@@ -412,6 +488,8 @@ export const createStage = (container, options) => {
412
488
  canvas.removeEventListener('pointerup', onUp);
413
489
  canvas.removeEventListener('pointermove', onMove);
414
490
  canvas.removeEventListener('pointerleave', onLeave);
491
+ controls.removeEventListener('start', turning);
492
+ controls.removeEventListener('end', turned);
415
493
  controls.dispose();
416
494
  grid.geometry.dispose();
417
495
  gridMaterial.dispose();
@@ -429,6 +507,7 @@ export const createStage = (container, options) => {
429
507
  };
430
508
  },
431
509
  turnBy: (across, up, animate) => {
510
+ turning();
432
511
  const from = new Spherical().setFromVector3(camera.position.clone().sub(controls.target));
433
512
  const to = from.clone();
434
513
  to.theta += across * STEP;
@@ -436,6 +515,7 @@ export const createStage = (container, options) => {
436
515
  if (!animate || still()) {
437
516
  camera.position.setFromSpherical(to).add(controls.target);
438
517
  camera.lookAt(controls.target);
518
+ turned();
439
519
  invalidate();
440
520
  return;
441
521
  }
@@ -1,5 +1,5 @@
1
1
  import { BufferGeometry, Color, DirectionalLight, DoubleSide, Float32BufferAttribute, HemisphereLight, LineBasicMaterial, LineSegments, Mesh, MeshLambertMaterial, Vector3, } from 'three';
2
- import { createStage, HALF, inBox } from './stage.js';
2
+ import { createStage, edgeOf, HALF, inBox, lookThrough } from './stage.js';
3
3
  const REACH = 0.03;
4
4
  export const threeSurfaceDriver = (container, options = {}) => {
5
5
  let field;
@@ -18,8 +18,14 @@ export const threeSurfaceDriver = (container, options = {}) => {
18
18
  vertexColors: true,
19
19
  });
20
20
  const surface = new Mesh(new BufferGeometry(), surfaceMaterial);
21
- const ruleMaterial = new LineBasicMaterial({ opacity: 0.3, transparent: true });
21
+ const ruleMaterial = new LineBasicMaterial({
22
+ opacity: 0.3,
23
+ transparent: true,
24
+ vertexColors: true,
25
+ });
22
26
  const rules = new LineSegments(new BufferGeometry(), ruleMaterial);
27
+ const contourMaterial = new LineBasicMaterial({ vertexColors: true });
28
+ const contours = new LineSegments(new BufferGeometry(), contourMaterial);
23
29
  const stage = createStage(container, {
24
30
  density: options.density,
25
31
  pick: (ray) => {
@@ -68,7 +74,7 @@ export const threeSurfaceDriver = (container, options = {}) => {
68
74
  const sky = new HemisphereLight(0xffffff, 0xffffff, 2.2);
69
75
  const sun = new DirectionalLight(0xffffff, 1);
70
76
  sun.position.set(-2, 4, 1);
71
- stage.scene.add(surface, rules, sky, sun);
77
+ stage.scene.add(contours, surface, rules, sky, sun);
72
78
  return {
73
79
  draw: (next, colors, font) => {
74
80
  field = next;
@@ -77,7 +83,11 @@ export const threeSurfaceDriver = (container, options = {}) => {
77
83
  const tints = [];
78
84
  const low = new Color(colors.ramp.low);
79
85
  const high = new Color(colors.ramp.high);
86
+ const page = new Color(colors.page);
87
+ const ink = new Color(colors.ink);
88
+ const { emphasis, through } = colors.look;
80
89
  const tint = new Color();
90
+ const ramp = [];
81
91
  const values = next.cells.flatMap((cell) => (cell.value === undefined ? [] : [cell.value]));
82
92
  const [least, most] = [Math.min(...values), Math.max(...values)];
83
93
  next.cells.forEach((cell, index) => {
@@ -85,14 +95,23 @@ export const threeSurfaceDriver = (container, options = {}) => {
85
95
  positions.push(at.x, at.y, at.z);
86
96
  const level = cell.value === undefined || most === least ? 0.5 : (cell.value - least) / (most - least);
87
97
  tint.lerpColors(low, high, level);
98
+ ramp.push(tint.r, tint.g, tint.b);
99
+ if (emphasis === 'outlined') {
100
+ tint.lerp(page, 0.4);
101
+ }
88
102
  tints.push(tint.r, tint.g, tint.b);
89
103
  });
90
104
  const has = (index) => next.cells[index]?.value !== undefined;
91
105
  const faces = [];
92
106
  const lines = [];
107
+ const lineTints = [];
108
+ const wire = emphasis !== 'solid';
93
109
  const line = (from, to) => {
94
110
  if (has(from) && has(to)) {
95
111
  lines.push(...positions.slice(from * 3, from * 3 + 3), ...positions.slice(to * 3, to * 3 + 3));
112
+ for (const at of [from, to]) {
113
+ lineTints.push(...(wire ? ramp.slice(at * 3, at * 3 + 3) : [ink.r, ink.g, ink.b]));
114
+ }
96
115
  }
97
116
  };
98
117
  for (let across = 0; across < next.acrosses.length; across++) {
@@ -127,8 +146,49 @@ export const threeSurfaceDriver = (container, options = {}) => {
127
146
  rules.geometry.dispose();
128
147
  const ruled = new BufferGeometry();
129
148
  ruled.setAttribute('position', new Float32BufferAttribute(lines, 3));
149
+ ruled.setAttribute('color', new Float32BufferAttribute(lineTints, 3));
130
150
  rules.geometry = ruled;
131
- ruleMaterial.color.setStyle(colors.ink);
151
+ const floor = [];
152
+ const floorTints = [];
153
+ const [bottom, top] = [next.lowest ?? least, next.highest ?? most];
154
+ for (const contour of next.contours ?? []) {
155
+ tint.lerpColors(low, high, top === bottom ? 0.5 : (contour.level - bottom) / (top - bottom));
156
+ tint.copy(edgeOf(tint));
157
+ for (let at = 0; at + 2 < faces.length; at += 3) {
158
+ const corners = faces.slice(at, at + 3);
159
+ const crossings = [];
160
+ for (const [from, to] of [
161
+ [corners[0], corners[1]],
162
+ [corners[1], corners[2]],
163
+ [corners[2], corners[0]],
164
+ ]) {
165
+ const [one, other] = [next.cells[from ?? 0]?.value, next.cells[to ?? 0]?.value];
166
+ if (one === undefined ||
167
+ other === undefined ||
168
+ one >= contour.level === other >= contour.level) {
169
+ continue;
170
+ }
171
+ const along = (contour.level - one) / (other - one);
172
+ for (let axis = 0; axis < 3; axis++) {
173
+ const start = positions[(from ?? 0) * 3 + axis] ?? 0;
174
+ crossings.push(start + ((positions[(to ?? 0) * 3 + axis] ?? 0) - start) * along);
175
+ }
176
+ }
177
+ if (crossings.length === 6) {
178
+ floor.push(...crossings);
179
+ floorTints.push(tint.r, tint.g, tint.b, tint.r, tint.g, tint.b);
180
+ }
181
+ }
182
+ }
183
+ contours.geometry.dispose();
184
+ const lined = new BufferGeometry();
185
+ lined.setAttribute('position', new Float32BufferAttribute(floor, 3));
186
+ lined.setAttribute('color', new Float32BufferAttribute(floorTints, 3));
187
+ contours.geometry = lined;
188
+ contours.visible = colors.look.contours;
189
+ lookThrough(surfaceMaterial, (emphasis === 'subtle' ? 0.2 : 1) * through);
190
+ lookThrough(ruleMaterial, (wire ? 1 : 0.3) * through);
191
+ ruleMaterial.transparent = true;
132
192
  stage.setAxes(next.axes, colors, font);
133
193
  },
134
194
  focus: (index) => {
@@ -142,6 +202,8 @@ export const threeSurfaceDriver = (container, options = {}) => {
142
202
  surfaceMaterial.dispose();
143
203
  rules.geometry.dispose();
144
204
  ruleMaterial.dispose();
205
+ contours.geometry.dispose();
206
+ contourMaterial.dispose();
145
207
  stage.stop();
146
208
  },
147
209
  turnBy: (across, up, animate) => stage.turnBy(across, up, animate),
@@ -0,0 +1,36 @@
1
+ import type { SpatialTreemapDriver } from '../connect-spatial-treemap/connect-spatial-treemap.js';
2
+ /** How the three.js treemap driver behaves where the page does not say. */
3
+ export interface ThreeTreemapDriverOptions {
4
+ /**
5
+ * How many pixels the drawing has for each of the page's. By default, on a
6
+ * screen worked with a mouse, half as many again as the screen has, at
7
+ * least two and at most three; on a touch screen, as many as the screen
8
+ * has, at most two.
9
+ */
10
+ density?: number | undefined;
11
+ }
12
+ /**
13
+ * Draws a Spatial Treemap's blocks in three dimensions with three.js. Pass it
14
+ * to `connectSpatialTreemap` with the element to draw in, which the chart
15
+ * holds in its plot:
16
+ *
17
+ * ```js
18
+ * import { connectSpatialTreemap } from '@fusion.dev/helix-plot'
19
+ * import { threeTreemapDriver } from '@fusion.dev/helix-plot/three'
20
+ *
21
+ * connectSpatialTreemap(chart, threeTreemapDriver(chart.querySelector('div')))
22
+ * ```
23
+ *
24
+ * **Each block stands on its part of the floor, from zero to its height**,
25
+ * or as one slab where the table gives no heights, in a box with the
26
+ * height's grid on two of its walls, lit from above. Each group takes a
27
+ * series color from the theme.
28
+ *
29
+ * **Dragging turns the box, and the wheel and a pinch bring it nearer.**
30
+ * Shift with an arrow on the chart turns it a step. It never turns by itself.
31
+ * It draws only while something moves, asks for no frames at rest, and
32
+ * shares the page's WebGL contexts with every three.js drawing Helix makes.
33
+ *
34
+ * The renderer is the driver's own, and `disconnect()` frees it.
35
+ */
36
+ export declare const threeTreemapDriver: (container: HTMLElement, options?: ThreeTreemapDriverOptions) => SpatialTreemapDriver;