@fusion.dev/helix-plot 0.198.0 → 0.199.1
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 +104 -5
- package/dist/connect-plot/connect-plot.d.ts +31 -0
- package/dist/connect-plot/connect-plot.js +34 -2
- package/dist/connect-spatial-bar-chart/connect-spatial-bar-chart.d.ts +39 -0
- package/dist/connect-spatial-bar-chart/connect-spatial-bar-chart.js +6 -0
- package/dist/connect-spatial-ribbon-chart/connect-spatial-ribbon-chart.d.ts +39 -0
- package/dist/connect-spatial-ribbon-chart/connect-spatial-ribbon-chart.js +6 -0
- package/dist/connect-spatial-surface-chart/connect-spatial-surface-chart.d.ts +8 -0
- package/dist/connect-spatial-treemap/connect-spatial-treemap.d.ts +47 -0
- package/dist/connect-spatial-treemap/connect-spatial-treemap.js +6 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/three/bar-driver.d.ts +35 -0
- package/dist/three/bar-driver.js +104 -0
- package/dist/three/heatmap-driver.js +9 -8
- package/dist/three/index.d.ts +3 -0
- package/dist/three/index.js +3 -0
- package/dist/three/ribbon-driver.d.ts +35 -0
- package/dist/three/ribbon-driver.js +128 -0
- package/dist/three/scatter-driver.js +45 -5
- package/dist/three/stage.d.ts +15 -2
- package/dist/three/stage.js +97 -17
- package/dist/three/surface-driver.js +66 -4
- package/dist/three/treemap-driver.d.ts +36 -0
- package/dist/three/treemap-driver.js +105 -0
- package/package.json +5 -5
|
@@ -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 =
|
|
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),
|
package/dist/three/stage.d.ts
CHANGED
|
@@ -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;
|
package/dist/three/stage.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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) => ({
|
|
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
|
|
228
|
-
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
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({
|
|
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
|
-
|
|
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;
|