pptx-angular-viewer 4.7.0 → 4.8.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/CHANGELOG.md +65 -0
- package/fesm2022/pptx-angular-viewer-chart-3d-view-scene-CM71j9A9.mjs +1703 -0
- package/fesm2022/pptx-angular-viewer-chart-3d-view-scene-CM71j9A9.mjs.map +1 -0
- package/fesm2022/{pptx-angular-viewer-chat-history-idb-D_SZnX0q.mjs → pptx-angular-viewer-chat-history-idb-7dyn_ZbD.mjs} +2 -2
- package/fesm2022/{pptx-angular-viewer-chat-history-idb-D_SZnX0q.mjs.map → pptx-angular-viewer-chat-history-idb-7dyn_ZbD.mjs.map} +1 -1
- package/fesm2022/{pptx-angular-viewer-pptx-angular-viewer-D3TADvW3.mjs → pptx-angular-viewer-pptx-angular-viewer-Cf08ueRA.mjs} +3769 -1247
- package/fesm2022/{pptx-angular-viewer-pptx-angular-viewer-D3TADvW3.mjs.map → pptx-angular-viewer-pptx-angular-viewer-Cf08ueRA.mjs.map} +1 -1
- package/fesm2022/pptx-angular-viewer-view-scene-Bn9SJZk9.mjs +862 -0
- package/fesm2022/pptx-angular-viewer-view-scene-Bn9SJZk9.mjs.map +1 -0
- package/fesm2022/pptx-angular-viewer.mjs +1 -1
- package/i18n/de.cjs +64 -96
- package/i18n/de.mjs +64 -96
- package/i18n/es.cjs +64 -96
- package/i18n/es.mjs +64 -96
- package/i18n/fr.cjs +64 -96
- package/i18n/fr.mjs +64 -96
- package/i18n/zh-CN.cjs +63 -95
- package/i18n/zh-CN.mjs +63 -95
- package/package.json +2 -2
- package/types/pptx-angular-viewer.d.ts +508 -77
- package/types/pptx-angular-viewer.d.ts.map +1 -1
- package/fesm2022/pptx-angular-viewer-chart-3d-view-scene-CFWKnfML.mjs +0 -470
- package/fesm2022/pptx-angular-viewer-chart-3d-view-scene-CFWKnfML.mjs.map +0 -1
- package/fesm2022/pptx-angular-viewer-view-scene-C5oUG9yl.mjs +0 -235
- package/fesm2022/pptx-angular-viewer-view-scene-C5oUG9yl.mjs.map +0 -1
|
@@ -0,0 +1,1703 @@
|
|
|
1
|
+
import { s as shade, c as createSvgEl, a as appendChartLegendSvg, r as renderChartLineSvg, b as renderChartTextSvg, C as CHART_3D_DRAG_THRESHOLD_PX, d as buildBarChart3DHoverTooltip, i as isChartInteractionArmed, e as roundDragValue, o as obliqueBarTaper, p as perspBoxMatrix, D as DEFAULT_PALETTE, f as buildPerspCamera, g as perspToScreen, h as pieRimPoint, j as computePieChartLayout, k as perspScreenToPlaneY, l as resolvePieSliceShareValue, m as createSurfaceChart3DScene, n as createPieChart3DScene, q as createAreaChart3DScene, u as createLineChart3DScene, v as createBarChart3DScene, w as widenPieViewModel } from './pptx-angular-viewer-pptx-angular-viewer-Cf08ueRA.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* chart-3d-shading.ts: PowerPoint's flat chart-space shading for a 3D chart's
|
|
5
|
+
* box faces, as one colour multiplier per named face.
|
|
6
|
+
*
|
|
7
|
+
* PowerPoint's 3D charts (bar3D, pie3D, line3D ribbons, area3D slabs, surface
|
|
8
|
+
* bands) are flat-shaded in "chart space": every face of a given orientation
|
|
9
|
+
* gets the SAME multiplier applied to the mark's base colour, regardless of
|
|
10
|
+
* the chart's `c:view3D` tilt. Measured directly off the pixels of
|
|
11
|
+
* `gt/chart-01.webp` (a 3-D Clustered Column, series 1 = theme accent1
|
|
12
|
+
* `#156082`): the front (camera-facing) rect samples the base colour exactly
|
|
13
|
+
* (`front = base`), the top face samples ~0.75x, and the visible side face
|
|
14
|
+
* ~0.64x. `back`/`left` (mirrors of `right`) and `bottom` (rarely visible)
|
|
15
|
+
* are not independently measured; `back`/`left` reuse the measured side
|
|
16
|
+
* multiplier and `bottom` is a reasonable approximation between side and a
|
|
17
|
+
* fully unlit face.
|
|
18
|
+
*
|
|
19
|
+
* @module chart-3d-shading
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Darken amount (0-1, `chart-palette.ts#shade`'s `amount`) per face,
|
|
23
|
+
* derived from the measured multipliers: `amount = 1 - multiplier`.
|
|
24
|
+
*/
|
|
25
|
+
const FACE_SHADE_AMOUNT = {
|
|
26
|
+
front: 0,
|
|
27
|
+
top: 0.25,
|
|
28
|
+
right: 0.36,
|
|
29
|
+
left: 0.36,
|
|
30
|
+
back: 0.36,
|
|
31
|
+
bottom: 0.5,
|
|
32
|
+
};
|
|
33
|
+
/** Apply PowerPoint's measured per-face flat shading to a mark's base colour. */
|
|
34
|
+
function shadeChart3DFace(baseColor, face) {
|
|
35
|
+
const amount = FACE_SHADE_AMOUNT[face];
|
|
36
|
+
return amount === 0 ? baseColor : shade(baseColor, amount);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The six face colours for a box mesh, in the order three.js `BoxGeometry`
|
|
40
|
+
* expects its `material` array (`[+x, -x, +y, -y, +z, -z]`): right, left,
|
|
41
|
+
* top, bottom, front, back. `front`/`back` name whichever world axis the
|
|
42
|
+
* scene treats as the camera-facing depth axis; the caller picks that
|
|
43
|
+
* mapping (see `chart-3d-view-scene.ts`).
|
|
44
|
+
*/
|
|
45
|
+
function buildChart3DBoxFaceColors(baseColor) {
|
|
46
|
+
return [
|
|
47
|
+
shadeChart3DFace(baseColor, 'right'),
|
|
48
|
+
shadeChart3DFace(baseColor, 'left'),
|
|
49
|
+
shadeChart3DFace(baseColor, 'top'),
|
|
50
|
+
shadeChart3DFace(baseColor, 'bottom'),
|
|
51
|
+
shadeChart3DFace(baseColor, 'front'),
|
|
52
|
+
shadeChart3DFace(baseColor, 'back'),
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Per-shape shade coefficients `[x, y, z]` for {@link chart3DNormalShade}.
|
|
57
|
+
* `box` reproduces the face multipliers above (right 0.64, top 0.75, front
|
|
58
|
+
* 1.0); the others are fitted to `gt/chart-07..09.webp` (accent1 columns):
|
|
59
|
+
* a cylinder runs 1.07x at its centre line to ~0.64x at the silhouette, a
|
|
60
|
+
* cone 1.16x to ~0.7x, a pyramid's front face 1.03x and its side 0.68x. Tops
|
|
61
|
+
* (cylinder cap, box top) sample 0.75x throughout.
|
|
62
|
+
*/
|
|
63
|
+
const NORMAL_SHADE = {
|
|
64
|
+
box: [0.64, 0.75, 1],
|
|
65
|
+
cylinder: [0.62, 0.75, 1.07],
|
|
66
|
+
cone: [0.7, 0.75, 1.16],
|
|
67
|
+
pyramid: [0.68, 0.75, 1.03],
|
|
68
|
+
};
|
|
69
|
+
/** Multiplier for a downward-facing surface (a bar's underside), rarely visible. */
|
|
70
|
+
const UNDERSIDE_SHADE = 0.5;
|
|
71
|
+
function shadeFamily(shape) {
|
|
72
|
+
switch (shape) {
|
|
73
|
+
case 'cylinder':
|
|
74
|
+
return 'cylinder';
|
|
75
|
+
case 'cone':
|
|
76
|
+
case 'coneToMax':
|
|
77
|
+
return 'cone';
|
|
78
|
+
case 'pyramid':
|
|
79
|
+
case 'pyramidToMax':
|
|
80
|
+
return 'pyramid';
|
|
81
|
+
default:
|
|
82
|
+
return 'box';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* PowerPoint's flat chart-space shade for a surface of a `c:shape` bar with
|
|
87
|
+
* unit world normal `(nx, ny, nz)` (x right, y up, z toward the viewer), as
|
|
88
|
+
* a multiplier of the base colour: the quadratic form
|
|
89
|
+
* `cx nx^2 + cy ny^2 + cz nz^2`, which for a box's axis-aligned faces is
|
|
90
|
+
* exactly the per-face table above.
|
|
91
|
+
*/
|
|
92
|
+
function chart3DNormalShade(shape, nx, ny, nz) {
|
|
93
|
+
const [cx, cy, cz] = NORMAL_SHADE[shadeFamily(shape)];
|
|
94
|
+
return cx * nx * nx + (ny >= 0 ? cy : UNDERSIDE_SHADE) * ny * ny + cz * nz * nz;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Radial segments of a round shape: smooth at any chart size. */
|
|
98
|
+
const ROUND_SEGMENTS = 48;
|
|
99
|
+
/** Whether a bar is drawn with this module rather than as a box. */
|
|
100
|
+
function isShapedObliqueBar(bar) {
|
|
101
|
+
return bar.shape !== 'box';
|
|
102
|
+
}
|
|
103
|
+
/** Cache key of a bar's unit geometry (shape, taper and orientation). */
|
|
104
|
+
function obliqueShapeKey(bar, horizontal) {
|
|
105
|
+
return `${bar.shape}:${bar.taper.bottom.toFixed(4)}:${bar.taper.top.toFixed(4)}:${horizontal ? 'h' : 'v'}`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The unit geometry of a shaped bar, its axis along +Y (or +X for a
|
|
109
|
+
* horizontal chart, the base at the value-axis baseline end).
|
|
110
|
+
*/
|
|
111
|
+
function buildObliqueShapeGeometry(three, bar, horizontal) {
|
|
112
|
+
const pointed = bar.shape === 'pyramid' || bar.shape === 'pyramidToMax';
|
|
113
|
+
let geometry;
|
|
114
|
+
if (pointed) {
|
|
115
|
+
// A 4-segment cylinder is a diamond of "radius" r; turned 45 degrees and
|
|
116
|
+
// widened by sqrt(2) its base is the full unit square.
|
|
117
|
+
const r = Math.SQRT1_2;
|
|
118
|
+
const faceted = new three.CylinderGeometry(r * bar.taper.top, r * bar.taper.bottom, 1, 4).rotateY(Math.PI / 4);
|
|
119
|
+
geometry = faceted.toNonIndexed();
|
|
120
|
+
faceted.dispose();
|
|
121
|
+
geometry.computeVertexNormals();
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
geometry = new three.CylinderGeometry(0.5 * bar.taper.top, 0.5 * bar.taper.bottom, 1, ROUND_SEGMENTS);
|
|
125
|
+
}
|
|
126
|
+
if (horizontal) {
|
|
127
|
+
geometry.rotateZ(-Math.PI / 2);
|
|
128
|
+
}
|
|
129
|
+
return geometry;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* (Re)write a shaped bar's vertex colours for its current world `scale`, so
|
|
133
|
+
* each vertex gets the shade of its world-space normal.
|
|
134
|
+
*/
|
|
135
|
+
function shadeObliqueShapeGeometry(three, geometry, bar, scale, viewYaw) {
|
|
136
|
+
const round = bar.shape !== 'pyramid' && bar.shape !== 'pyramidToMax';
|
|
137
|
+
const cos = Math.cos(round ? viewYaw : 0);
|
|
138
|
+
const sin = Math.sin(round ? viewYaw : 0);
|
|
139
|
+
const normals = geometry.getAttribute('normal');
|
|
140
|
+
const count = normals.count;
|
|
141
|
+
let colors = geometry.getAttribute('color');
|
|
142
|
+
if (!colors || colors.count !== count) {
|
|
143
|
+
colors = new three.Float32BufferAttribute(new Float32Array(count * 3), 3);
|
|
144
|
+
geometry.setAttribute('color', colors);
|
|
145
|
+
}
|
|
146
|
+
const base = new three.Color(bar.color);
|
|
147
|
+
const srgb = base.getRGB(new three.Color(), three.SRGBColorSpace);
|
|
148
|
+
const out = new three.Color();
|
|
149
|
+
for (let i = 0; i < count; i++) {
|
|
150
|
+
// Normals transform by the inverse scale.
|
|
151
|
+
let nx = normals.getX(i) / Math.max(scale.x, 1e-6);
|
|
152
|
+
let ny = normals.getY(i) / Math.max(scale.y, 1e-6);
|
|
153
|
+
let nz = normals.getZ(i) / Math.max(scale.z, 1e-6);
|
|
154
|
+
const len = Math.hypot(nx, ny, nz) || 1;
|
|
155
|
+
nx /= len;
|
|
156
|
+
ny /= len;
|
|
157
|
+
nz /= len;
|
|
158
|
+
const f = chart3DNormalShade(bar.shape, nx * cos - nz * sin, ny, nx * sin + nz * cos);
|
|
159
|
+
out.setRGB(Math.min(1, srgb.r * f), Math.min(1, srgb.g * f), Math.min(1, srgb.b * f), three.SRGBColorSpace);
|
|
160
|
+
colors.setXYZ(i, out.r, out.g, out.b);
|
|
161
|
+
}
|
|
162
|
+
colors.needsUpdate = true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** PowerPoint's default major gridline colour (tx1 at 15% tint, `#D9D9D9`). */
|
|
166
|
+
const GRIDLINE_COLOR$1 = 0xd9d9d9;
|
|
167
|
+
/** Scene position of a layout world point. */
|
|
168
|
+
function obliqueToScene(layout, svgWidth, svgHeight, p) {
|
|
169
|
+
return [layout.origin.x + p[0] - svgWidth / 2, svgHeight / 2 - layout.origin.y + p[1], -p[2]];
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Place (and for a `c:shape` bar, reshape and reshade) a bar's mesh at a
|
|
173
|
+
* bar's current world box. Used at build time and for the drag preview.
|
|
174
|
+
*/
|
|
175
|
+
function placeObliqueBarMesh(three, mesh, layout, svgWidth, svgHeight, bar) {
|
|
176
|
+
mesh.position.set(...obliqueToScene(layout, svgWidth, svgHeight, [
|
|
177
|
+
bar.x + bar.w / 2,
|
|
178
|
+
bar.y + bar.h / 2,
|
|
179
|
+
bar.z + bar.d / 2,
|
|
180
|
+
]));
|
|
181
|
+
mesh.scale.set(Math.max(bar.w, 0.001), Math.max(bar.h, 0.001), bar.d);
|
|
182
|
+
if (!isShapedObliqueBar(bar)) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const key = obliqueShapeKey(bar, layout.horizontal);
|
|
186
|
+
const data = mesh.userData;
|
|
187
|
+
if (data.shapeKey !== key) {
|
|
188
|
+
const previous = mesh.geometry;
|
|
189
|
+
mesh.geometry = buildObliqueShapeGeometry(three, bar, layout.horizontal);
|
|
190
|
+
previous.dispose();
|
|
191
|
+
data.shapeKey = key;
|
|
192
|
+
}
|
|
193
|
+
shadeObliqueShapeGeometry(three, mesh.geometry, bar, mesh.scale, Math.atan(layout.shear.x));
|
|
194
|
+
}
|
|
195
|
+
/** Build the bar boxes and gridlines of an oblique layout into a new group. */
|
|
196
|
+
function buildChart3DBarMeshes(three, layout, svgWidth, svgHeight) {
|
|
197
|
+
const group = new three.Group();
|
|
198
|
+
const meshes = [];
|
|
199
|
+
const lineGeometries = [];
|
|
200
|
+
const materials = [];
|
|
201
|
+
const points = [];
|
|
202
|
+
for (const line of layout.gridlines) {
|
|
203
|
+
points.push(...obliqueToScene(layout, svgWidth, svgHeight, line.from), ...obliqueToScene(layout, svgWidth, svgHeight, line.to));
|
|
204
|
+
}
|
|
205
|
+
if (points.length > 0) {
|
|
206
|
+
const lineGeometry = new three.BufferGeometry();
|
|
207
|
+
lineGeometry.setAttribute('position', new three.Float32BufferAttribute(points, 3));
|
|
208
|
+
const lineMaterial = new three.LineBasicMaterial({ color: GRIDLINE_COLOR$1 });
|
|
209
|
+
lineGeometries.push(lineGeometry);
|
|
210
|
+
materials.push(lineMaterial);
|
|
211
|
+
group.add(new three.LineSegments(lineGeometry, lineMaterial));
|
|
212
|
+
}
|
|
213
|
+
for (const bar of layout.bars) {
|
|
214
|
+
let mesh;
|
|
215
|
+
if (isShapedObliqueBar(bar)) {
|
|
216
|
+
const material = new three.MeshBasicMaterial({ vertexColors: true });
|
|
217
|
+
materials.push(material);
|
|
218
|
+
mesh = new three.Mesh(new three.BufferGeometry(), material);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const faceMaterials = buildChart3DBoxFaceColors(bar.color).map((color) => new three.MeshBasicMaterial({ color }));
|
|
222
|
+
materials.push(...faceMaterials);
|
|
223
|
+
mesh = new three.Mesh(new three.BoxGeometry(1, 1, 1), faceMaterials);
|
|
224
|
+
}
|
|
225
|
+
mesh.userData = { seriesIndex: bar.seriesIndex, categoryIndex: bar.categoryIndex };
|
|
226
|
+
placeObliqueBarMesh(three, mesh, layout, svgWidth, svgHeight, bar);
|
|
227
|
+
group.add(mesh);
|
|
228
|
+
meshes.push(mesh);
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
group,
|
|
232
|
+
meshes,
|
|
233
|
+
dispose: () => {
|
|
234
|
+
// A drag preview may have swapped a shaped bar's geometry, so free
|
|
235
|
+
// whatever each mesh holds now.
|
|
236
|
+
for (const geometry of [...lineGeometries, ...meshes.map((m) => m.geometry)]) {
|
|
237
|
+
geometry.dispose();
|
|
238
|
+
}
|
|
239
|
+
for (const material of materials) {
|
|
240
|
+
material.dispose();
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* chart-3d-chrome-overlay.ts: the SVG "chrome" (chart-area fill/border,
|
|
248
|
+
* title, legend, value-axis gridlines/ticks, category labels) a 3D chart
|
|
249
|
+
* scene draws OVER its WebGL canvas via `ctx.overlay`.
|
|
250
|
+
*
|
|
251
|
+
* PowerPoint's own 3D charts always show this chrome exactly like the 2D
|
|
252
|
+
* chart does (see the parity brief's "hybrid rendering" requirement); for a
|
|
253
|
+
* chart in the oblique (`rAngAx=1`) projection family this chrome is
|
|
254
|
+
* ALREADY pixel-correct in the flat 2D `ChartViewModel` (verified against
|
|
255
|
+
* `gt/chart-01.webp`: axes/gridlines/title/legend sit exactly where a plain
|
|
256
|
+
* 2D chart would put them, perfectly undistorted), so this module reuses the
|
|
257
|
+
* SAME `chart-view-model-dom.ts` piece-renderers the 2D/vanilla projector
|
|
258
|
+
* uses, just WITHOUT `vm.primitives`/`vm.dataLabels` (the flat oblique
|
|
259
|
+
* bars/extrusion polygons, replaced by real WebGL geometry).
|
|
260
|
+
*
|
|
261
|
+
* @module chart-3d-chrome-overlay
|
|
262
|
+
*/
|
|
263
|
+
/**
|
|
264
|
+
* Render a `ChartViewModel`'s chrome (everything except data marks) to an
|
|
265
|
+
* `<svg>` sized to `vm.svgWidth` x `vm.svgHeight`, matching the WebGL
|
|
266
|
+
* scene's own world-unit convention (1 world unit = 1 authored chart px) so
|
|
267
|
+
* an element positioned from the same view-model's rects lines up exactly.
|
|
268
|
+
*/
|
|
269
|
+
function renderChart3DChromeOverlaySvg(doc, source, box) {
|
|
270
|
+
const vm = box ? withBoxChromePositions(source) : source;
|
|
271
|
+
const svg = createSvgEl(doc, 'svg', {
|
|
272
|
+
class: 'pptxv-chart-3d-chrome',
|
|
273
|
+
viewBox: `0 0 ${vm.svgWidth} ${vm.svgHeight}`,
|
|
274
|
+
preserveAspectRatio: 'none',
|
|
275
|
+
});
|
|
276
|
+
svg.style.setProperty('width', '100%');
|
|
277
|
+
svg.style.setProperty('height', '100%');
|
|
278
|
+
svg.style.setProperty('display', 'block');
|
|
279
|
+
svg.style.setProperty('position', 'absolute');
|
|
280
|
+
svg.style.setProperty('inset', '0');
|
|
281
|
+
// The WebGL canvas paints the marks; this overlay only adds chrome on top
|
|
282
|
+
// of them, so pointer events must fall through to the canvas underneath.
|
|
283
|
+
svg.style.setProperty('pointer-events', 'none');
|
|
284
|
+
if (vm.areaFill) {
|
|
285
|
+
svg.appendChild(createSvgEl(doc, 'rect', {
|
|
286
|
+
x: 0,
|
|
287
|
+
y: 0,
|
|
288
|
+
width: vm.svgWidth,
|
|
289
|
+
height: vm.svgHeight,
|
|
290
|
+
rx: vm.areaRadius,
|
|
291
|
+
fill: vm.areaFill,
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
if (vm.title) {
|
|
295
|
+
const title = createSvgEl(doc, 'text', {
|
|
296
|
+
x: vm.titleX,
|
|
297
|
+
y: vm.titleY,
|
|
298
|
+
'text-anchor': 'middle',
|
|
299
|
+
'font-size': vm.titleStyle?.fontSize ?? 12,
|
|
300
|
+
'font-weight': vm.titleStyle?.fontWeight ?? 600,
|
|
301
|
+
'font-family': vm.titleStyle?.fontFamily,
|
|
302
|
+
fill: vm.titleStyle?.fill ?? '#1e293b',
|
|
303
|
+
'data-chart-part': 'title',
|
|
304
|
+
});
|
|
305
|
+
if (vm.titleRunSpans && vm.titleRunSpans.length > 0) {
|
|
306
|
+
for (const run of vm.titleRunSpans) {
|
|
307
|
+
const tspan = createSvgEl(doc, 'tspan', {
|
|
308
|
+
'font-size': run.fontSize,
|
|
309
|
+
'font-weight': run.fontWeight,
|
|
310
|
+
'font-style': run.fontStyle,
|
|
311
|
+
'font-family': run.fontFamily,
|
|
312
|
+
fill: run.fill,
|
|
313
|
+
});
|
|
314
|
+
tspan.textContent = run.text;
|
|
315
|
+
title.appendChild(tspan);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
title.textContent = vm.title;
|
|
320
|
+
}
|
|
321
|
+
svg.appendChild(title);
|
|
322
|
+
}
|
|
323
|
+
if (box) {
|
|
324
|
+
// The box's gridlines are drawn in the scene (marks hide the back
|
|
325
|
+
// wall); only its axis labels go here.
|
|
326
|
+
appendBoxLabels(doc, svg, vm, box.labels);
|
|
327
|
+
// A 3D chart keys every series with a plain swatch, lines included.
|
|
328
|
+
const legend = box.legend ?? vm.legend.map((item) => ({ ...item, lineSwatch: undefined }));
|
|
329
|
+
const legendVm = { ...vm, legend: box.reverseLegend ? legend.reverse() : legend };
|
|
330
|
+
appendChartLegendSvg(doc, svg, legendVm);
|
|
331
|
+
return svg;
|
|
332
|
+
}
|
|
333
|
+
for (const gl of vm.gridlines) {
|
|
334
|
+
svg.appendChild(renderChartLineSvg(doc, gl));
|
|
335
|
+
}
|
|
336
|
+
for (const gl of vm.secondaryGridlines ?? []) {
|
|
337
|
+
svg.appendChild(renderChartLineSvg(doc, gl));
|
|
338
|
+
}
|
|
339
|
+
for (const lbl of vm.axisLabels) {
|
|
340
|
+
svg.appendChild(renderChartTextSvg(doc, lbl));
|
|
341
|
+
}
|
|
342
|
+
for (const lbl of vm.secondaryAxisLabels ?? []) {
|
|
343
|
+
svg.appendChild(renderChartTextSvg(doc, lbl));
|
|
344
|
+
}
|
|
345
|
+
if (vm.zeroLine) {
|
|
346
|
+
svg.appendChild(renderChartLineSvg(doc, vm.zeroLine));
|
|
347
|
+
}
|
|
348
|
+
for (const lbl of vm.categoryLabels) {
|
|
349
|
+
svg.appendChild(renderChartTextSvg(doc, lbl));
|
|
350
|
+
}
|
|
351
|
+
appendChartLegendSvg(doc, svg, vm);
|
|
352
|
+
return svg;
|
|
353
|
+
}
|
|
354
|
+
/** Points to chart px (96 dpi). */
|
|
355
|
+
const PT = 4 / 3;
|
|
356
|
+
/** Title baseline below the chart top, and legend baseline above its bottom, measured on `gt/chart-01`. */
|
|
357
|
+
const OBLIQUE_TITLE_BASELINE = 24 * PT;
|
|
358
|
+
const OBLIQUE_LEGEND_BASELINE = 14 * PT;
|
|
359
|
+
/** The flat view model with its title and legend moved to where PowerPoint puts them on a 3D chart. */
|
|
360
|
+
function withBoxChromePositions(vm) {
|
|
361
|
+
const legendBottom = (vm.legendY ?? vm.svgHeight - 8) >= vm.svgHeight / 2;
|
|
362
|
+
return {
|
|
363
|
+
...vm,
|
|
364
|
+
titleY: OBLIQUE_TITLE_BASELINE,
|
|
365
|
+
// Legend text sits 3px below `legendY` (see `appendChartLegendSvg`).
|
|
366
|
+
legendY: legendBottom ? vm.svgHeight - OBLIQUE_LEGEND_BASELINE - 3 : vm.legendY,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function appendBoxLabels(doc, svg, vm, labels) {
|
|
370
|
+
const style = vm.axisLabels[0];
|
|
371
|
+
for (const label of labels) {
|
|
372
|
+
svg.appendChild(renderChartTextSvg(doc, {
|
|
373
|
+
kind: 'text',
|
|
374
|
+
x: label.x,
|
|
375
|
+
y: label.y,
|
|
376
|
+
text: label.text,
|
|
377
|
+
fontSize: label.fontSize,
|
|
378
|
+
fill: style?.fill ?? '#595959',
|
|
379
|
+
fontFamily: style?.fontFamily,
|
|
380
|
+
textAnchor: label.anchor,
|
|
381
|
+
dominantBaseline: label.baseline ?? 'central',
|
|
382
|
+
}));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function partOf(point) {
|
|
387
|
+
return point
|
|
388
|
+
? { role: 'dataPoint', seriesIndex: point.seriesIndex, pointIndex: point.pointIndex }
|
|
389
|
+
: null;
|
|
390
|
+
}
|
|
391
|
+
/** Attach hover/select/drag to a 3D chart scene's marks. */
|
|
392
|
+
function attachChart3DMarkInteraction(options) {
|
|
393
|
+
const { ctx, camera, adapter, chartData } = options;
|
|
394
|
+
const three = ctx.three;
|
|
395
|
+
const target = ctx.eventTarget;
|
|
396
|
+
const raycaster = new three.Raycaster();
|
|
397
|
+
const far = new three.Vector3();
|
|
398
|
+
let interactive = ctx.interactive;
|
|
399
|
+
let press = null;
|
|
400
|
+
let hoveredTooltip;
|
|
401
|
+
function pointAt(clientX, clientY) {
|
|
402
|
+
const rect = target.getBoundingClientRect();
|
|
403
|
+
if (rect.width <= 0 || rect.height <= 0) {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
options.scene.updateMatrixWorld();
|
|
407
|
+
camera.updateMatrixWorld();
|
|
408
|
+
const x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
|
409
|
+
const y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
|
410
|
+
raycaster.ray.origin.set(x, y, -1).unproject(camera);
|
|
411
|
+
far.set(x, y, 1).unproject(camera);
|
|
412
|
+
raycaster.ray.direction.copy(far).sub(raycaster.ray.origin).normalize();
|
|
413
|
+
const hit = raycaster.intersectObjects(adapter.targets, false)[0];
|
|
414
|
+
return hit ? adapter.pointAt(hit) : null;
|
|
415
|
+
}
|
|
416
|
+
const onPointerMove = (event) => {
|
|
417
|
+
if (press?.drag) {
|
|
418
|
+
const dx = event.clientX - press.startX;
|
|
419
|
+
const dy = event.clientY - press.startY;
|
|
420
|
+
if (!press.drag.moved && Math.hypot(dx, dy) < CHART_3D_DRAG_THRESHOLD_PX) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
press.drag.moved = true;
|
|
424
|
+
const k = press.chartPxPerClientPx;
|
|
425
|
+
const rect = target.getBoundingClientRect();
|
|
426
|
+
const value = press.drag.session.preview(dx * k, dy * k, {
|
|
427
|
+
x: (event.clientX - rect.left) * k,
|
|
428
|
+
y: (event.clientY - rect.top) * k,
|
|
429
|
+
});
|
|
430
|
+
press.lastValue = value;
|
|
431
|
+
ctx.requestRender();
|
|
432
|
+
const part = partOf(press.point);
|
|
433
|
+
if (part) {
|
|
434
|
+
ctx.emit({ type: 'drag', detail: { part, value, phase: 'move' } });
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const point = pointAt(event.clientX, event.clientY);
|
|
439
|
+
const value = point ? adapter.valueOf(point) : undefined;
|
|
440
|
+
const tooltip = point && value !== undefined
|
|
441
|
+
? buildBarChart3DHoverTooltip({ seriesIndex: point.seriesIndex, categoryIndex: point.pointIndex, value }, {
|
|
442
|
+
categoryLabels: options.categoryLabels,
|
|
443
|
+
seriesNames: options.seriesNames,
|
|
444
|
+
numberFormats: chartData.series.map((s) => s.numberFormat),
|
|
445
|
+
})
|
|
446
|
+
: undefined;
|
|
447
|
+
if (tooltip !== hoveredTooltip) {
|
|
448
|
+
hoveredTooltip = tooltip;
|
|
449
|
+
target.title = tooltip ?? '';
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
const onPointerDown = (event) => {
|
|
453
|
+
if (!interactive) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const point = pointAt(event.clientX, event.clientY);
|
|
457
|
+
const armed = point !== null && isChartInteractionArmed(target);
|
|
458
|
+
if (armed) {
|
|
459
|
+
event.stopPropagation();
|
|
460
|
+
}
|
|
461
|
+
const session = armed && point ? adapter.beginDrag(point) : null;
|
|
462
|
+
if (session) {
|
|
463
|
+
event.preventDefault();
|
|
464
|
+
target.setPointerCapture?.(event.pointerId);
|
|
465
|
+
}
|
|
466
|
+
const rect = target.getBoundingClientRect();
|
|
467
|
+
press = {
|
|
468
|
+
point,
|
|
469
|
+
drag: session ? { session, moved: false } : null,
|
|
470
|
+
startX: event.clientX,
|
|
471
|
+
startY: event.clientY,
|
|
472
|
+
chartPxPerClientPx: rect.height > 0 ? options.svgHeight / rect.height : 1,
|
|
473
|
+
lastValue: null,
|
|
474
|
+
};
|
|
475
|
+
};
|
|
476
|
+
const onPointerUp = (event) => {
|
|
477
|
+
const current = press;
|
|
478
|
+
press = null;
|
|
479
|
+
if (!current || !interactive) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const part = partOf(current.point);
|
|
483
|
+
if (current.drag?.moved && current.lastValue !== null && part) {
|
|
484
|
+
ctx.emit({ type: 'drag', detail: { part, value: current.lastValue, phase: 'commit' } });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const travel = Math.hypot(event.clientX - current.startX, event.clientY - current.startY);
|
|
488
|
+
if (travel < CHART_3D_DRAG_THRESHOLD_PX) {
|
|
489
|
+
ctx.emit({ type: 'select', part });
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
const onPointerLeave = () => {
|
|
493
|
+
if (hoveredTooltip !== undefined) {
|
|
494
|
+
hoveredTooltip = undefined;
|
|
495
|
+
target.title = '';
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
target.addEventListener('pointermove', onPointerMove);
|
|
499
|
+
target.addEventListener('pointerdown', onPointerDown);
|
|
500
|
+
target.addEventListener('pointerup', onPointerUp);
|
|
501
|
+
target.addEventListener('pointerleave', onPointerLeave);
|
|
502
|
+
return {
|
|
503
|
+
setInteractive(on) {
|
|
504
|
+
interactive = on;
|
|
505
|
+
if (!on) {
|
|
506
|
+
press = null;
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
setSelectedPart(part) {
|
|
510
|
+
adapter.highlight(part?.role === 'dataPoint' && part.pointIndex !== undefined
|
|
511
|
+
? { seriesIndex: part.seriesIndex, pointIndex: part.pointIndex }
|
|
512
|
+
: null);
|
|
513
|
+
ctx.requestRender();
|
|
514
|
+
},
|
|
515
|
+
dispose() {
|
|
516
|
+
target.removeEventListener('pointermove', onPointerMove);
|
|
517
|
+
target.removeEventListener('pointerdown', onPointerDown);
|
|
518
|
+
target.removeEventListener('pointerup', onPointerUp);
|
|
519
|
+
target.removeEventListener('pointerleave', onPointerLeave);
|
|
520
|
+
adapter.dispose?.();
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Value-drag maths for the right-angle-axes 3D bar chart
|
|
527
|
+
* (`chart-3d-oblique-layout.ts`), kept pure so it is testable without WebGL.
|
|
528
|
+
*
|
|
529
|
+
* A bar's front face is drawn flat, so dragging along the value axis moves
|
|
530
|
+
* its value by `chart px / valueScale`, rounded like every other chart drag
|
|
531
|
+
* (`roundDragValue`). Columns drag vertically (up = larger), horizontal bars
|
|
532
|
+
* horizontally (right = larger). Like the 2D chart, only `clustered` and
|
|
533
|
+
* `standard` bars are value-draggable: a stacked segment sits on a running
|
|
534
|
+
* sum, so dragging one would not track the pointer.
|
|
535
|
+
*
|
|
536
|
+
* @module chart-3d-oblique-drag
|
|
537
|
+
*/
|
|
538
|
+
/** Whether a bar in this layout can be value-dragged. */
|
|
539
|
+
function isObliqueBarDraggable(layout) {
|
|
540
|
+
return layout.grouping === 'clustered' || layout.grouping === 'standard';
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* The value after dragging by a pointer delta, in chart px (`dx` right,
|
|
544
|
+
* `dy` down).
|
|
545
|
+
*/
|
|
546
|
+
function obliqueDragValue(layout, startValue, dx, dy) {
|
|
547
|
+
const along = layout.horizontal ? dx : -dy;
|
|
548
|
+
const raw = startValue + along / layout.valueScale;
|
|
549
|
+
const span = layout.range.max - layout.range.min;
|
|
550
|
+
return roundDragValue(raw, { min: layout.range.min, max: layout.range.max, span });
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* A bar's extent along the value axis after a drag to `value` (the baseline
|
|
554
|
+
* end stays put), clipped to the axis, in the layout's world units.
|
|
555
|
+
*/
|
|
556
|
+
function obliqueDraggedExtent(layout, value) {
|
|
557
|
+
const { min, max } = layout.range;
|
|
558
|
+
const base = Math.min(Math.max(0, min), max);
|
|
559
|
+
const clamp = (v) => Math.min(Math.max(v, min), max);
|
|
560
|
+
const lo = (clamp(Math.min(base, value)) - min) * layout.valueScale;
|
|
561
|
+
const hi = (clamp(Math.max(base, value)) - min) * layout.valueScale;
|
|
562
|
+
return { from: lo, length: hi - lo };
|
|
563
|
+
}
|
|
564
|
+
/** `bar` with its value-axis extent (and taper) replaced: a live drag preview. */
|
|
565
|
+
function obliqueBarAtValue(layout, bar, value) {
|
|
566
|
+
const { from, length } = obliqueDraggedExtent(layout, value);
|
|
567
|
+
const extent = (layout.range.max - layout.range.min) * layout.valueScale;
|
|
568
|
+
const taper = obliqueBarTaper(bar.shape, from, from + length, extent, value);
|
|
569
|
+
return layout.horizontal
|
|
570
|
+
? { ...bar, x: from, w: length, value, taper }
|
|
571
|
+
: { ...bar, y: from, h: length, value, taper };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Colour of the selected box's outline (matches the 2D selected-mark accent). */
|
|
575
|
+
const SELECTED_OUTLINE_COLOR = 0x2563eb;
|
|
576
|
+
/** Attach hover/select/drag to an oblique bar scene's boxes. */
|
|
577
|
+
function attachObliqueBarInteraction(options) {
|
|
578
|
+
const { ctx, meshes, layout } = options;
|
|
579
|
+
const bars = layout.bars;
|
|
580
|
+
const three = ctx.three;
|
|
581
|
+
const outline = new three.LineSegments(new three.EdgesGeometry(new three.BoxGeometry(1, 1, 1)), new three.LineBasicMaterial({ color: SELECTED_OUTLINE_COLOR }));
|
|
582
|
+
outline.visible = false;
|
|
583
|
+
options.scene.add(outline);
|
|
584
|
+
let selected = -1;
|
|
585
|
+
const indexOf = (point) => bars.findIndex((b) => b.seriesIndex === point.seriesIndex && b.categoryIndex === point.pointIndex);
|
|
586
|
+
function syncOutline() {
|
|
587
|
+
outline.visible = selected >= 0;
|
|
588
|
+
if (selected >= 0) {
|
|
589
|
+
outline.position.copy(meshes[selected].position);
|
|
590
|
+
outline.scale.copy(meshes[selected].scale);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return attachChart3DMarkInteraction({
|
|
594
|
+
ctx,
|
|
595
|
+
camera: options.camera,
|
|
596
|
+
scene: options.scene,
|
|
597
|
+
svgHeight: options.svgHeight,
|
|
598
|
+
chartData: options.chartData,
|
|
599
|
+
categoryLabels: options.categoryLabels,
|
|
600
|
+
seriesNames: options.seriesNames,
|
|
601
|
+
adapter: {
|
|
602
|
+
targets: meshes,
|
|
603
|
+
pointAt(hit) {
|
|
604
|
+
const data = hit.object.userData;
|
|
605
|
+
if (data.seriesIndex === undefined || data.categoryIndex === undefined) {
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
return { seriesIndex: data.seriesIndex, pointIndex: data.categoryIndex };
|
|
609
|
+
},
|
|
610
|
+
valueOf(point) {
|
|
611
|
+
return bars[indexOf(point)]?.value;
|
|
612
|
+
},
|
|
613
|
+
beginDrag(point) {
|
|
614
|
+
const index = indexOf(point);
|
|
615
|
+
if (index < 0 || !isObliqueBarDraggable(layout)) {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
const start = bars[index];
|
|
619
|
+
return {
|
|
620
|
+
preview(dx, dy) {
|
|
621
|
+
const value = obliqueDragValue(layout, start.value, dx, dy);
|
|
622
|
+
placeObliqueBarMesh(three, meshes[index], layout, options.svgWidth, options.svgHeight, obliqueBarAtValue(layout, start, value));
|
|
623
|
+
syncOutline();
|
|
624
|
+
return value;
|
|
625
|
+
},
|
|
626
|
+
};
|
|
627
|
+
},
|
|
628
|
+
highlight(point) {
|
|
629
|
+
selected = point ? indexOf(point) : -1;
|
|
630
|
+
syncOutline();
|
|
631
|
+
},
|
|
632
|
+
dispose() {
|
|
633
|
+
outline.geometry.dispose();
|
|
634
|
+
outline.material.dispose();
|
|
635
|
+
outline.removeFromParent();
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const DEFAULT_GAP_WIDTH = 150;
|
|
642
|
+
function valueY(layout, v) {
|
|
643
|
+
const clamped = Math.min(Math.max(v, layout.range.min), layout.range.max);
|
|
644
|
+
return (clamped - layout.range.min) * layout.valueScale;
|
|
645
|
+
}
|
|
646
|
+
/** One prism per bar; `override` replaces one point's value (a drag preview). */
|
|
647
|
+
function buildPerspBarPrisms(chartData, layout, override) {
|
|
648
|
+
const nCat = layout.categoryX.length;
|
|
649
|
+
const nSer = chartData.series.length;
|
|
650
|
+
const slot = nCat > 0 ? 1 / nCat : 1;
|
|
651
|
+
const gapWidth = (chartData.barGapWidth ?? DEFAULT_GAP_WIDTH) / 100;
|
|
652
|
+
const slots = layout.grouping === 'clustered' ? nSer : 1;
|
|
653
|
+
const barW = slot / (slots + gapWidth);
|
|
654
|
+
const depth = Math.min(barW, layout.rowDepth);
|
|
655
|
+
const stacked = layout.grouping === 'stacked' || layout.grouping === 'percentStacked';
|
|
656
|
+
const baseline = Math.min(Math.max(0, layout.range.min), layout.range.max);
|
|
657
|
+
const prisms = [];
|
|
658
|
+
for (let c = 0; c < nCat; c++) {
|
|
659
|
+
const valueOf = (s) => {
|
|
660
|
+
if (override && override.seriesIndex === s && override.pointIndex === c) {
|
|
661
|
+
return override.value;
|
|
662
|
+
}
|
|
663
|
+
const v = chartData.series[s]?.values[c];
|
|
664
|
+
return v !== undefined && Number.isFinite(v) ? v : 0;
|
|
665
|
+
};
|
|
666
|
+
const total = chartData.series.reduce((t, _s, s) => t + Math.abs(valueOf(s)), 0);
|
|
667
|
+
let pos = baseline;
|
|
668
|
+
let neg = baseline;
|
|
669
|
+
for (let s = 0; s < nSer; s++) {
|
|
670
|
+
const raw = valueOf(s);
|
|
671
|
+
const value = layout.grouping === 'percentStacked' ? (total ? raw / total : 0) : raw;
|
|
672
|
+
let from = baseline;
|
|
673
|
+
let to = value;
|
|
674
|
+
if (stacked) {
|
|
675
|
+
from = value >= 0 ? pos : neg;
|
|
676
|
+
to = from + value;
|
|
677
|
+
if (value >= 0) {
|
|
678
|
+
pos = to;
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
neg = to;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const slotIndex = layout.grouping === 'clustered' ? s : 0;
|
|
685
|
+
const row = layout.grouping === 'standard' ? s : 0;
|
|
686
|
+
const x0 = c * slot + (gapWidth * barW) / 2 + slotIndex * barW;
|
|
687
|
+
const y0 = valueY(layout, Math.min(from, to));
|
|
688
|
+
const y1 = Math.max(valueY(layout, Math.max(from, to)), y0 + 1e-6);
|
|
689
|
+
const z0 = row * layout.rowDepth + (layout.rowDepth - depth) / 2;
|
|
690
|
+
prisms.push({
|
|
691
|
+
seriesIndex: s,
|
|
692
|
+
pointIndex: c,
|
|
693
|
+
color: layout.colors[s],
|
|
694
|
+
outline: [
|
|
695
|
+
[x0, y0],
|
|
696
|
+
[x0 + barW, y0],
|
|
697
|
+
[x0 + barW, y1],
|
|
698
|
+
[x0, y1],
|
|
699
|
+
],
|
|
700
|
+
z0,
|
|
701
|
+
z1: z0 + depth,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return prisms;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Ribbon thickness, as a fraction of the box height. */
|
|
709
|
+
const RIBBON_THICKNESS = 0.012;
|
|
710
|
+
/** Box y of a value, clipped to the axis. */
|
|
711
|
+
function perspValueY(layout, v) {
|
|
712
|
+
const clamped = Math.min(Math.max(v, layout.range.min), layout.range.max);
|
|
713
|
+
return (clamped - layout.range.min) * layout.valueScale;
|
|
714
|
+
}
|
|
715
|
+
/** Depth range of a row's mark. */
|
|
716
|
+
function perspRowSpan(layout, row) {
|
|
717
|
+
const z0 = row * layout.rowDepth + (layout.rowDepth - layout.markDepth) / 2;
|
|
718
|
+
return [z0, z0 + layout.markDepth];
|
|
719
|
+
}
|
|
720
|
+
function finite(v) {
|
|
721
|
+
return v !== undefined && Number.isFinite(v) ? v : 0;
|
|
722
|
+
}
|
|
723
|
+
/** Values series by series, `percentStacked` normalised to category totals. */
|
|
724
|
+
function plottedValues(chartData, layout, nCat) {
|
|
725
|
+
const raw = chartData.series.map((s) => Array.from({ length: nCat }, (_, c) => finite(s.values[c])));
|
|
726
|
+
if (layout.grouping !== 'percentStacked') {
|
|
727
|
+
return raw;
|
|
728
|
+
}
|
|
729
|
+
const totals = Array.from({ length: nCat }, (_, c) => raw.reduce((t, s) => t + Math.abs(s[c]), 0));
|
|
730
|
+
return raw.map((s) => s.map((v, c) => (totals[c] ? v / totals[c] : 0)));
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* The prisms for a line or area chart; `override` replaces one point's value
|
|
734
|
+
* (a live drag preview).
|
|
735
|
+
*/
|
|
736
|
+
function buildPerspPrisms(chartData, layout, override) {
|
|
737
|
+
if (layout.kind === 'bar') {
|
|
738
|
+
return buildPerspBarPrisms(chartData, layout, override);
|
|
739
|
+
}
|
|
740
|
+
const nCat = layout.categoryX.length;
|
|
741
|
+
const values = plottedValues(chartData, layout, nCat);
|
|
742
|
+
if (override) {
|
|
743
|
+
const row = values[override.seriesIndex];
|
|
744
|
+
if (row && override.pointIndex < nCat) {
|
|
745
|
+
row[override.pointIndex] = override.value;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
const xs = layout.categoryX;
|
|
749
|
+
const baseY = perspValueY(layout, Math.max(0, layout.range.min));
|
|
750
|
+
const prisms = [];
|
|
751
|
+
if (layout.kind === 'line') {
|
|
752
|
+
const t = (layout.view.box.h * RIBBON_THICKNESS) / 2;
|
|
753
|
+
values.forEach((series, s) => {
|
|
754
|
+
const ys = series.map((v) => perspValueY(layout, v));
|
|
755
|
+
const [z0, z1] = perspRowSpan(layout, s);
|
|
756
|
+
prisms.push({
|
|
757
|
+
seriesIndex: s,
|
|
758
|
+
color: layout.colors[s],
|
|
759
|
+
outline: [
|
|
760
|
+
...xs.map((x, c) => [x, ys[c] - t]),
|
|
761
|
+
...xs.map((x, c) => [x, ys[c] + t]).reverse(),
|
|
762
|
+
],
|
|
763
|
+
z0,
|
|
764
|
+
z1,
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
return prisms;
|
|
768
|
+
}
|
|
769
|
+
const stacked = layout.grouping !== 'standard';
|
|
770
|
+
const running = new Array(nCat).fill(0);
|
|
771
|
+
values.forEach((series, s) => {
|
|
772
|
+
const lower = stacked ? running.map((v) => perspValueY(layout, v)) : xs.map(() => baseY);
|
|
773
|
+
const upper = series.map((v, c) => perspValueY(layout, stacked ? running[c] + v : v));
|
|
774
|
+
if (stacked) {
|
|
775
|
+
series.forEach((v, c) => {
|
|
776
|
+
running[c] += v;
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
const [z0, z1] = perspRowSpan(layout, stacked ? 0 : s);
|
|
780
|
+
prisms.push({
|
|
781
|
+
seriesIndex: s,
|
|
782
|
+
color: layout.colors[s],
|
|
783
|
+
outline: [
|
|
784
|
+
...xs.map((x, c) => [x, lower[c]]),
|
|
785
|
+
...xs.map((x, c) => [x, upper[c]]).reverse(),
|
|
786
|
+
],
|
|
787
|
+
z0,
|
|
788
|
+
z1,
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
return prisms;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** PowerPoint's default major gridline colour (tx1 at 15% tint, `#D9D9D9`). */
|
|
795
|
+
const GRIDLINE_COLOR = 0xd9d9d9;
|
|
796
|
+
/** Bake face shading into a geometry's vertex colours. */
|
|
797
|
+
function shadePerspGeometry(three, geometry, color) {
|
|
798
|
+
const normals = geometry.getAttribute('normal');
|
|
799
|
+
const colors = new Float32Array(normals.count * 3);
|
|
800
|
+
const base = new three.Color(color).getRGB(new three.Color(), three.SRGBColorSpace);
|
|
801
|
+
const out = new three.Color();
|
|
802
|
+
for (let i = 0; i < normals.count; i++) {
|
|
803
|
+
// Box-space -z faces the viewer.
|
|
804
|
+
const f = chart3DNormalShade('box', normals.getX(i), normals.getY(i), -normals.getZ(i));
|
|
805
|
+
out.setRGB(Math.min(1, base.r * f), Math.min(1, base.g * f), Math.min(1, base.b * f), three.SRGBColorSpace);
|
|
806
|
+
colors[i * 3] = out.r;
|
|
807
|
+
colors[i * 3 + 1] = out.g;
|
|
808
|
+
colors[i * 3 + 2] = out.b;
|
|
809
|
+
}
|
|
810
|
+
geometry.setAttribute('color', new three.Float32BufferAttribute(colors, 3));
|
|
811
|
+
}
|
|
812
|
+
/** An extruded, shaded prism geometry in box space. */
|
|
813
|
+
function buildPrismGeometry(three, prism) {
|
|
814
|
+
const shape = new three.Shape(prism.outline.map(([x, y]) => new three.Vector2(x, y)));
|
|
815
|
+
const extruded = new three.ExtrudeGeometry(shape, {
|
|
816
|
+
depth: Math.max(prism.z1 - prism.z0, 1e-6),
|
|
817
|
+
bevelEnabled: false,
|
|
818
|
+
steps: 1,
|
|
819
|
+
});
|
|
820
|
+
const geometry = extruded.index ? extruded.toNonIndexed() : extruded;
|
|
821
|
+
if (geometry !== extruded) {
|
|
822
|
+
extruded.dispose();
|
|
823
|
+
}
|
|
824
|
+
geometry.translate(0, 0, prism.z0);
|
|
825
|
+
geometry.computeVertexNormals();
|
|
826
|
+
shadePerspGeometry(three, geometry, prism.color);
|
|
827
|
+
return geometry;
|
|
828
|
+
}
|
|
829
|
+
/** The wall gridlines and floor edges of a perspective layout, in box space. */
|
|
830
|
+
function buildPerspGridlines(three, layout) {
|
|
831
|
+
const points = [];
|
|
832
|
+
for (const line of layout.gridlines) {
|
|
833
|
+
points.push(...line.from, ...line.to);
|
|
834
|
+
}
|
|
835
|
+
const geometry = new three.BufferGeometry();
|
|
836
|
+
geometry.setAttribute('position', new three.Float32BufferAttribute(points, 3));
|
|
837
|
+
return new three.LineSegments(geometry, new three.LineBasicMaterial({ color: GRIDLINE_COLOR }));
|
|
838
|
+
}
|
|
839
|
+
/** Build the gridlines and prism meshes of a perspective layout. */
|
|
840
|
+
function buildPerspMeshes(three, layout, prisms) {
|
|
841
|
+
const group = new three.Group();
|
|
842
|
+
group.matrixAutoUpdate = false;
|
|
843
|
+
group.matrix.copy(perspBoxMatrix(three, layout.view));
|
|
844
|
+
const gridlines = buildPerspGridlines(three, layout);
|
|
845
|
+
group.add(gridlines);
|
|
846
|
+
const material = new three.MeshBasicMaterial({ vertexColors: true, side: three.DoubleSide });
|
|
847
|
+
const meshes = prisms.map((prism) => {
|
|
848
|
+
const mesh = new three.Mesh(buildPrismGeometry(three, prism), material);
|
|
849
|
+
mesh.userData = { seriesIndex: prism.seriesIndex, pointIndex: prism.pointIndex };
|
|
850
|
+
group.add(mesh);
|
|
851
|
+
return mesh;
|
|
852
|
+
});
|
|
853
|
+
return {
|
|
854
|
+
group,
|
|
855
|
+
meshes,
|
|
856
|
+
setPrism(i, prism) {
|
|
857
|
+
const mesh = meshes[i];
|
|
858
|
+
if (!mesh) {
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const previous = mesh.geometry;
|
|
862
|
+
mesh.geometry = buildPrismGeometry(three, prism);
|
|
863
|
+
previous.dispose();
|
|
864
|
+
},
|
|
865
|
+
dispose() {
|
|
866
|
+
gridlines.geometry.dispose();
|
|
867
|
+
gridlines.material.dispose();
|
|
868
|
+
material.dispose();
|
|
869
|
+
for (const mesh of meshes) {
|
|
870
|
+
mesh.geometry.dispose();
|
|
871
|
+
}
|
|
872
|
+
},
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** Box z of series row `s` of `n` (front edge to back wall). */
|
|
877
|
+
function surfaceRowZ(layout, s, n) {
|
|
878
|
+
return n > 1 ? (s / (n - 1)) * layout.view.box.d : layout.view.box.d / 2;
|
|
879
|
+
}
|
|
880
|
+
/** The value band edges (min, min + unit, ..., max). */
|
|
881
|
+
function bandEdges(range) {
|
|
882
|
+
const unit = range.majorUnit > 0 ? range.majorUnit : range.max - range.min || 1;
|
|
883
|
+
const edges = [];
|
|
884
|
+
for (let v = range.min; v < range.max - unit * 1e-9; v += unit) {
|
|
885
|
+
edges.push(Number(v.toPrecision(12)));
|
|
886
|
+
}
|
|
887
|
+
edges.push(range.max);
|
|
888
|
+
return edges;
|
|
889
|
+
}
|
|
890
|
+
function formatEdge(v) {
|
|
891
|
+
return String(Number(v.toPrecision(10)));
|
|
892
|
+
}
|
|
893
|
+
/** Colours and legend labels for each band. */
|
|
894
|
+
function surfaceBands(chartData, range, palette) {
|
|
895
|
+
const edges = bandEdges(range);
|
|
896
|
+
return edges.slice(0, -1).map((lo, i) => {
|
|
897
|
+
const fmt = chartData.bandFmts?.find((b) => b.index === i)?.spPr;
|
|
898
|
+
const cycle = palette.length > 0 ? palette : ['#4472C4'];
|
|
899
|
+
const base = cycle[i % cycle.length];
|
|
900
|
+
const fallback = Math.floor(i / cycle.length) % 2 === 1 ? shade(base, 0.4) : base;
|
|
901
|
+
return {
|
|
902
|
+
label: `${formatEdge(lo)}-${formatEdge(edges[i + 1])}`,
|
|
903
|
+
color: fmt?.fillColor ?? fmt?.strokeColor ?? fallback,
|
|
904
|
+
};
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
/** Clip a polygon to `lo <= y <= hi` (box y). */
|
|
908
|
+
function clipY(poly, lo, hi) {
|
|
909
|
+
const clip = (pts, keep, edge) => {
|
|
910
|
+
const out = [];
|
|
911
|
+
for (let i = 0; i < pts.length; i++) {
|
|
912
|
+
const a = pts[i];
|
|
913
|
+
const b = pts[(i + 1) % pts.length];
|
|
914
|
+
const ina = keep(a);
|
|
915
|
+
const inb = keep(b);
|
|
916
|
+
if (ina) {
|
|
917
|
+
out.push(a);
|
|
918
|
+
}
|
|
919
|
+
if (ina !== inb) {
|
|
920
|
+
const t = (edge - a[1]) / (b[1] - a[1]);
|
|
921
|
+
out.push([a[0] + (b[0] - a[0]) * t, edge, a[2] + (b[2] - a[2]) * t]);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
return out;
|
|
925
|
+
};
|
|
926
|
+
return clip(clip(poly, (p) => p[1] >= lo, lo), (p) => p[1] <= hi, hi);
|
|
927
|
+
}
|
|
928
|
+
/** A grid cell's two triangles. */
|
|
929
|
+
function cellTriangles(point, s, c) {
|
|
930
|
+
return [
|
|
931
|
+
[point(s, c), point(s, c + 1), point(s + 1, c + 1)],
|
|
932
|
+
[point(s, c), point(s + 1, c + 1), point(s + 1, c)],
|
|
933
|
+
];
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* A grid edge drawn once per band with the surface clamped into that band
|
|
937
|
+
* (PowerPoint's wireframe: each band is its own layer, flat where the
|
|
938
|
+
* surface runs above or below it). Only the front series line keeps its
|
|
939
|
+
* flat stretches: that is all `gt/chart-17` shows of the lower layers.
|
|
940
|
+
*/
|
|
941
|
+
function clampedBandSegments(a, b, edgesY, out, keepFlat) {
|
|
942
|
+
for (let band = 0; band + 1 < edgesY.length; band++) {
|
|
943
|
+
const lo = edgesY[band];
|
|
944
|
+
const hi = edgesY[band + 1];
|
|
945
|
+
const cuts = [0, 1];
|
|
946
|
+
for (const e of [lo, hi]) {
|
|
947
|
+
const t = (e - a[1]) / (b[1] - a[1]);
|
|
948
|
+
if (t > 0 && t < 1) {
|
|
949
|
+
cuts.push(t);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
cuts.sort((x, y) => x - y);
|
|
953
|
+
const at = (t) => [
|
|
954
|
+
a[0] + (b[0] - a[0]) * t,
|
|
955
|
+
Math.min(hi, Math.max(lo, a[1] + (b[1] - a[1]) * t)),
|
|
956
|
+
a[2] + (b[2] - a[2]) * t,
|
|
957
|
+
];
|
|
958
|
+
for (let i = 0; i + 1 < cuts.length; i++) {
|
|
959
|
+
const from = at(cuts[i]);
|
|
960
|
+
const to = at(cuts[i + 1]);
|
|
961
|
+
const flat = from[1] === to[1] && (from[1] === lo || from[1] === hi);
|
|
962
|
+
if (keepFlat || !flat) {
|
|
963
|
+
out.push({ from, to, band });
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* The surface's triangles (filled) or segments (wireframe) in box space;
|
|
970
|
+
* `override` replaces one point's value (a drag preview).
|
|
971
|
+
*/
|
|
972
|
+
function buildSurfaceGeometry(chartData, layout, palette, override) {
|
|
973
|
+
const nSer = chartData.series.length;
|
|
974
|
+
const nCat = layout.categoryX.length;
|
|
975
|
+
const bands = surfaceBands(chartData, layout.range, palette);
|
|
976
|
+
const edgesY = bandEdges(layout.range).map((v) => perspValueY(layout, v));
|
|
977
|
+
const point = (s, c) => {
|
|
978
|
+
let v = chartData.series[s]?.values[c];
|
|
979
|
+
if (override && override.seriesIndex === s && override.pointIndex === c) {
|
|
980
|
+
v = override.value;
|
|
981
|
+
}
|
|
982
|
+
const value = v !== undefined && Number.isFinite(v) ? v : layout.range.min;
|
|
983
|
+
return [layout.categoryX[c], perspValueY(layout, value), surfaceRowZ(layout, s, nSer)];
|
|
984
|
+
};
|
|
985
|
+
const geometry = { bands, triangles: [], segments: [] };
|
|
986
|
+
if (chartData.wireframe) {
|
|
987
|
+
for (let s = 0; s < nSer; s++) {
|
|
988
|
+
for (let c = 0; c < nCat; c++) {
|
|
989
|
+
if (c + 1 < nCat) {
|
|
990
|
+
clampedBandSegments(point(s, c), point(s, c + 1), edgesY, geometry.segments, s === 0);
|
|
991
|
+
}
|
|
992
|
+
if (s + 1 < nSer) {
|
|
993
|
+
clampedBandSegments(point(s, c), point(s + 1, c), edgesY, geometry.segments, false);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return geometry;
|
|
998
|
+
}
|
|
999
|
+
for (let s = 0; s + 1 < nSer; s++) {
|
|
1000
|
+
for (let c = 0; c + 1 < nCat; c++) {
|
|
1001
|
+
for (const tri of cellTriangles(point, s, c)) {
|
|
1002
|
+
for (let b = 0; b < bands.length; b++) {
|
|
1003
|
+
const piece = clipY(tri, edgesY[b], edgesY[b + 1]);
|
|
1004
|
+
for (let i = 1; i + 1 < piece.length; i++) {
|
|
1005
|
+
geometry.triangles.push({ points: [piece[0], piece[i], piece[i + 1]], band: b });
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return geometry;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function srgb(three, hex, f) {
|
|
1015
|
+
const base = new three.Color(hex).getRGB(new three.Color(), three.SRGBColorSpace);
|
|
1016
|
+
return new three.Color().setRGB(Math.min(1, base.r * f), Math.min(1, base.g * f), Math.min(1, base.b * f), three.SRGBColorSpace);
|
|
1017
|
+
}
|
|
1018
|
+
function buildVisual(three, chartData, layout, palette, override) {
|
|
1019
|
+
const geo = buildSurfaceGeometry(chartData, layout, palette, override);
|
|
1020
|
+
const positions = [];
|
|
1021
|
+
const colors = [];
|
|
1022
|
+
if (chartData.wireframe) {
|
|
1023
|
+
for (const seg of geo.segments) {
|
|
1024
|
+
const c = srgb(three, geo.bands[seg.band].color, 1);
|
|
1025
|
+
positions.push(...seg.from, ...seg.to);
|
|
1026
|
+
colors.push(c.r, c.g, c.b, c.r, c.g, c.b);
|
|
1027
|
+
}
|
|
1028
|
+
const g = new three.BufferGeometry();
|
|
1029
|
+
g.setAttribute('position', new three.Float32BufferAttribute(positions, 3));
|
|
1030
|
+
g.setAttribute('color', new three.Float32BufferAttribute(colors, 3));
|
|
1031
|
+
return new three.LineSegments(g, new three.LineBasicMaterial({ vertexColors: true }));
|
|
1032
|
+
}
|
|
1033
|
+
const a = new three.Vector3();
|
|
1034
|
+
const b = new three.Vector3();
|
|
1035
|
+
const n = new three.Vector3();
|
|
1036
|
+
for (const tri of geo.triangles) {
|
|
1037
|
+
const [p0, p1, p2] = tri.points;
|
|
1038
|
+
a.set(p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]);
|
|
1039
|
+
b.set(p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]);
|
|
1040
|
+
n.crossVectors(a, b).normalize();
|
|
1041
|
+
if (n.y < 0) {
|
|
1042
|
+
n.negate();
|
|
1043
|
+
}
|
|
1044
|
+
// Box-space -z faces the viewer.
|
|
1045
|
+
const c = srgb(three, geo.bands[tri.band].color, chart3DNormalShade('box', n.x, n.y, -n.z));
|
|
1046
|
+
positions.push(...p0, ...p1, ...p2);
|
|
1047
|
+
colors.push(c.r, c.g, c.b, c.r, c.g, c.b, c.r, c.g, c.b);
|
|
1048
|
+
}
|
|
1049
|
+
const g = new three.BufferGeometry();
|
|
1050
|
+
g.setAttribute('position', new three.Float32BufferAttribute(positions, 3));
|
|
1051
|
+
g.setAttribute('color', new three.Float32BufferAttribute(colors, 3));
|
|
1052
|
+
return new three.Mesh(g, new three.MeshBasicMaterial({ vertexColors: true, side: three.DoubleSide }));
|
|
1053
|
+
}
|
|
1054
|
+
function buildPickGeometry(three, chartData, layout) {
|
|
1055
|
+
const nSer = chartData.series.length;
|
|
1056
|
+
const nCat = layout.categoryX.length;
|
|
1057
|
+
const at = (s, c) => {
|
|
1058
|
+
const v = chartData.series[s]?.values[c];
|
|
1059
|
+
return [
|
|
1060
|
+
layout.categoryX[c],
|
|
1061
|
+
perspValueY(layout, v !== undefined && Number.isFinite(v) ? v : layout.range.min),
|
|
1062
|
+
surfaceRowZ(layout, s, nSer),
|
|
1063
|
+
];
|
|
1064
|
+
};
|
|
1065
|
+
const positions = [];
|
|
1066
|
+
for (let s = 0; s + 1 < nSer; s++) {
|
|
1067
|
+
for (let c = 0; c + 1 < nCat; c++) {
|
|
1068
|
+
positions.push(...at(s, c), ...at(s, c + 1), ...at(s + 1, c + 1));
|
|
1069
|
+
positions.push(...at(s, c), ...at(s + 1, c + 1), ...at(s + 1, c));
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
const g = new three.BufferGeometry();
|
|
1073
|
+
g.setAttribute('position', new three.Float32BufferAttribute(positions, 3));
|
|
1074
|
+
return g;
|
|
1075
|
+
}
|
|
1076
|
+
function disposeObject(object) {
|
|
1077
|
+
const withGeometry = object;
|
|
1078
|
+
withGeometry.geometry?.dispose();
|
|
1079
|
+
withGeometry.material?.dispose();
|
|
1080
|
+
}
|
|
1081
|
+
/** Build the surface's objects into a box-space group. */
|
|
1082
|
+
function buildSurfaceMarks(three, chartData, layout, palette) {
|
|
1083
|
+
const group = new three.Group();
|
|
1084
|
+
group.matrixAutoUpdate = false;
|
|
1085
|
+
group.matrix.copy(perspBoxMatrix(three, layout.view));
|
|
1086
|
+
const gridlines = buildPerspGridlines(three, layout);
|
|
1087
|
+
group.add(gridlines);
|
|
1088
|
+
let visual = buildVisual(three, chartData, layout, palette);
|
|
1089
|
+
group.add(visual);
|
|
1090
|
+
const pickMaterial = new three.MeshBasicMaterial({ side: three.DoubleSide, visible: false });
|
|
1091
|
+
const pickTarget = new three.Mesh(buildPickGeometry(three, chartData, layout), pickMaterial);
|
|
1092
|
+
group.add(pickTarget);
|
|
1093
|
+
return {
|
|
1094
|
+
group,
|
|
1095
|
+
pickTarget,
|
|
1096
|
+
rebuild(override) {
|
|
1097
|
+
group.remove(visual);
|
|
1098
|
+
disposeObject(visual);
|
|
1099
|
+
visual = buildVisual(three, chartData, layout, palette, override);
|
|
1100
|
+
group.add(visual);
|
|
1101
|
+
},
|
|
1102
|
+
dispose() {
|
|
1103
|
+
disposeObject(visual);
|
|
1104
|
+
disposeObject(gridlines);
|
|
1105
|
+
pickTarget.geometry.dispose();
|
|
1106
|
+
pickMaterial.dispose();
|
|
1107
|
+
},
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function nearestIndex(xs, x) {
|
|
1112
|
+
let best = 0;
|
|
1113
|
+
xs.forEach((v, i) => {
|
|
1114
|
+
if (Math.abs(v - x) < Math.abs(xs[best] - x)) {
|
|
1115
|
+
best = i;
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
return best;
|
|
1119
|
+
}
|
|
1120
|
+
/** Build the mark set for a laid-out perspective chart. */
|
|
1121
|
+
function buildPerspMarkSet(three, chartData, layout, palette) {
|
|
1122
|
+
if (layout.kind === 'surface') {
|
|
1123
|
+
const nSer = chartData.series.length;
|
|
1124
|
+
const zs = Array.from({ length: nSer }, (_, s) => surfaceRowZ(layout, s, nSer));
|
|
1125
|
+
const surface = buildSurfaceMarks(three, chartData, layout, palette);
|
|
1126
|
+
return {
|
|
1127
|
+
group: surface.group,
|
|
1128
|
+
targets: [surface.pickTarget],
|
|
1129
|
+
pointAt: (_object, local) => ({
|
|
1130
|
+
seriesIndex: nearestIndex(zs, local.z),
|
|
1131
|
+
pointIndex: nearestIndex(layout.categoryX, local.x),
|
|
1132
|
+
}),
|
|
1133
|
+
anchor: (point, value) => [
|
|
1134
|
+
layout.categoryX[point.pointIndex],
|
|
1135
|
+
perspValueY(layout, value),
|
|
1136
|
+
zs[point.seriesIndex],
|
|
1137
|
+
],
|
|
1138
|
+
draggable: true,
|
|
1139
|
+
preview: (point, value) => surface.rebuild({ ...point, value }),
|
|
1140
|
+
dispose: () => surface.dispose(),
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
if (layout.kind === 'bar') {
|
|
1144
|
+
return buildBarMarkSet(three, chartData, layout);
|
|
1145
|
+
}
|
|
1146
|
+
const stacked = layout.grouping !== 'standard' && layout.grouping !== 'clustered';
|
|
1147
|
+
const meshes = buildPerspMeshes(three, layout, buildPerspPrisms(chartData, layout));
|
|
1148
|
+
return {
|
|
1149
|
+
group: meshes.group,
|
|
1150
|
+
targets: meshes.meshes,
|
|
1151
|
+
pointAt(object, local) {
|
|
1152
|
+
const seriesIndex = object.userData.seriesIndex;
|
|
1153
|
+
return seriesIndex === undefined
|
|
1154
|
+
? null
|
|
1155
|
+
: { seriesIndex, pointIndex: nearestIndex(layout.categoryX, local.x) };
|
|
1156
|
+
},
|
|
1157
|
+
anchor(point, value) {
|
|
1158
|
+
const [z0, z1] = perspRowSpan(layout, stacked ? 0 : point.seriesIndex);
|
|
1159
|
+
return [layout.categoryX[point.pointIndex], perspValueY(layout, value), (z0 + z1) / 2];
|
|
1160
|
+
},
|
|
1161
|
+
draggable: !stacked,
|
|
1162
|
+
preview(point, value) {
|
|
1163
|
+
meshes.setPrism(point.seriesIndex, buildPerspPrisms(chartData, layout, { ...point, value })[point.seriesIndex]);
|
|
1164
|
+
},
|
|
1165
|
+
dispose: () => meshes.dispose(),
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
/** Bars: one prism per point, dragged one at a time (not stacked ones, as in the 2D chart). */
|
|
1169
|
+
function buildBarMarkSet(three, chartData, layout) {
|
|
1170
|
+
const prisms = buildPerspPrisms(chartData, layout);
|
|
1171
|
+
const meshes = buildPerspMeshes(three, layout, prisms);
|
|
1172
|
+
const indexOf = (point) => prisms.findIndex((p) => p.seriesIndex === point.seriesIndex && p.pointIndex === point.pointIndex);
|
|
1173
|
+
return {
|
|
1174
|
+
group: meshes.group,
|
|
1175
|
+
targets: meshes.meshes,
|
|
1176
|
+
pointAt(object) {
|
|
1177
|
+
const data = object.userData;
|
|
1178
|
+
return data.seriesIndex === undefined || data.pointIndex === undefined
|
|
1179
|
+
? null
|
|
1180
|
+
: { seriesIndex: data.seriesIndex, pointIndex: data.pointIndex };
|
|
1181
|
+
},
|
|
1182
|
+
anchor(point, value) {
|
|
1183
|
+
const prism = prisms[indexOf(point)];
|
|
1184
|
+
if (!prism) {
|
|
1185
|
+
return [0, perspValueY(layout, value), 0];
|
|
1186
|
+
}
|
|
1187
|
+
const xs = prism.outline.map(([x]) => x);
|
|
1188
|
+
return [
|
|
1189
|
+
(Math.min(...xs) + Math.max(...xs)) / 2,
|
|
1190
|
+
perspValueY(layout, value),
|
|
1191
|
+
(prism.z0 + prism.z1) / 2,
|
|
1192
|
+
];
|
|
1193
|
+
},
|
|
1194
|
+
draggable: layout.grouping === 'clustered' || layout.grouping === 'standard',
|
|
1195
|
+
preview(point, value) {
|
|
1196
|
+
const i = indexOf(point);
|
|
1197
|
+
const next = buildPerspPrisms(chartData, layout, { ...point, value })[i];
|
|
1198
|
+
if (next) {
|
|
1199
|
+
meshes.setPrism(i, next);
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
dispose: () => meshes.dispose(),
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/** Selection marker colour (the 2D selected-mark accent). */
|
|
1207
|
+
const SELECTED_COLOR$1 = 0x2563eb;
|
|
1208
|
+
/** A surface's legend: one entry per value band (hollow keys for a wireframe). */
|
|
1209
|
+
function surfaceLegend(chartData, layout, palette, background, textStyle) {
|
|
1210
|
+
return surfaceBands(chartData, layout.range, palette).map((band) => ({
|
|
1211
|
+
label: band.label,
|
|
1212
|
+
color: band.color,
|
|
1213
|
+
...(textStyle ? { textStyle } : {}),
|
|
1214
|
+
...(chartData.wireframe
|
|
1215
|
+
? {
|
|
1216
|
+
lineSwatch: {
|
|
1217
|
+
primitives: [
|
|
1218
|
+
{ kind: 'rect', x: 0, y: -7, w: 10, h: 10, fill: band.color },
|
|
1219
|
+
{ kind: 'rect', x: 1, y: -6, w: 8, h: 8, fill: background },
|
|
1220
|
+
],
|
|
1221
|
+
},
|
|
1222
|
+
}
|
|
1223
|
+
: {}),
|
|
1224
|
+
}));
|
|
1225
|
+
}
|
|
1226
|
+
function mountPerspChartView(input, ctx) {
|
|
1227
|
+
const three = ctx.three;
|
|
1228
|
+
const { vm, layout, chartData } = input;
|
|
1229
|
+
const palette = chartData.colorPalette?.length ? chartData.colorPalette : DEFAULT_PALETTE;
|
|
1230
|
+
const camera = buildPerspCamera(three, layout.view, vm.svgWidth, vm.svgHeight);
|
|
1231
|
+
const scene = new three.Scene();
|
|
1232
|
+
const marks = buildPerspMarkSet(three, chartData, layout, palette);
|
|
1233
|
+
scene.add(marks.group);
|
|
1234
|
+
const markerGeometry = new three.OctahedronGeometry(layout.view.box.w * 0.012);
|
|
1235
|
+
const markerMaterial = new three.MeshBasicMaterial({ color: SELECTED_COLOR$1 });
|
|
1236
|
+
const marker = new three.Mesh(markerGeometry, markerMaterial);
|
|
1237
|
+
marker.visible = false;
|
|
1238
|
+
marks.group.add(marker);
|
|
1239
|
+
const overlay = renderChart3DChromeOverlaySvg(ctx.document, vm, {
|
|
1240
|
+
labels: layout.labels,
|
|
1241
|
+
reverseLegend: false,
|
|
1242
|
+
legend: layout.kind === 'surface'
|
|
1243
|
+
? surfaceLegend(chartData, layout, palette, vm.areaFill ?? '#ffffff', vm.legend[0]?.textStyle)
|
|
1244
|
+
: undefined,
|
|
1245
|
+
});
|
|
1246
|
+
ctx.overlay.appendChild(overlay);
|
|
1247
|
+
const valueAt = (point) => {
|
|
1248
|
+
const v = chartData.series[point.seriesIndex]?.values[point.pointIndex];
|
|
1249
|
+
return v !== undefined && Number.isFinite(v) ? v : undefined;
|
|
1250
|
+
};
|
|
1251
|
+
const placeMarker = (point, value) => {
|
|
1252
|
+
marker.position.set(...marks.anchor(point, value));
|
|
1253
|
+
};
|
|
1254
|
+
const interaction = attachChart3DMarkInteraction({
|
|
1255
|
+
ctx,
|
|
1256
|
+
camera,
|
|
1257
|
+
scene,
|
|
1258
|
+
svgHeight: vm.svgHeight,
|
|
1259
|
+
chartData,
|
|
1260
|
+
categoryLabels: input.categoryLabels,
|
|
1261
|
+
seriesNames: chartData.series.map((s) => s.name),
|
|
1262
|
+
adapter: {
|
|
1263
|
+
targets: marks.targets,
|
|
1264
|
+
pointAt: (hit) => marks.pointAt(hit.object, marks.group.worldToLocal(hit.point.clone())),
|
|
1265
|
+
valueOf: valueAt,
|
|
1266
|
+
beginDrag(point) {
|
|
1267
|
+
const start = valueAt(point);
|
|
1268
|
+
if (!marks.draggable || start === undefined) {
|
|
1269
|
+
return null;
|
|
1270
|
+
}
|
|
1271
|
+
const unit = layout.range.majorUnit || 1;
|
|
1272
|
+
const [x, y, z] = marks.anchor(point, start);
|
|
1273
|
+
const y0 = perspToScreen(layout.view, [x, y, z]).y;
|
|
1274
|
+
const y1 = perspToScreen(layout.view, [x, y + unit * layout.valueScale, z]).y;
|
|
1275
|
+
const pxPerValue = (y0 - y1) / unit || 1;
|
|
1276
|
+
const span = layout.range.max - layout.range.min;
|
|
1277
|
+
return {
|
|
1278
|
+
preview(_dx, dy) {
|
|
1279
|
+
const value = roundDragValue(start - dy / pxPerValue, {
|
|
1280
|
+
min: layout.range.min,
|
|
1281
|
+
max: layout.range.max,
|
|
1282
|
+
span,
|
|
1283
|
+
});
|
|
1284
|
+
marks.preview(point, value);
|
|
1285
|
+
if (marker.visible) {
|
|
1286
|
+
placeMarker(point, value);
|
|
1287
|
+
}
|
|
1288
|
+
return value;
|
|
1289
|
+
},
|
|
1290
|
+
};
|
|
1291
|
+
},
|
|
1292
|
+
highlight(point) {
|
|
1293
|
+
const value = point ? valueAt(point) : undefined;
|
|
1294
|
+
marker.visible = point !== null && value !== undefined;
|
|
1295
|
+
if (point && value !== undefined) {
|
|
1296
|
+
placeMarker(point, value);
|
|
1297
|
+
}
|
|
1298
|
+
},
|
|
1299
|
+
dispose() {
|
|
1300
|
+
markerGeometry.dispose();
|
|
1301
|
+
markerMaterial.dispose();
|
|
1302
|
+
},
|
|
1303
|
+
},
|
|
1304
|
+
});
|
|
1305
|
+
let disposed = false;
|
|
1306
|
+
return {
|
|
1307
|
+
render(renderer) {
|
|
1308
|
+
renderer.render(scene, camera);
|
|
1309
|
+
},
|
|
1310
|
+
resize() {
|
|
1311
|
+
// The projection is fixed in authored chart px, like the overlay.
|
|
1312
|
+
},
|
|
1313
|
+
isAnimating: () => false,
|
|
1314
|
+
setInteractive: (on) => interaction.setInteractive(on),
|
|
1315
|
+
setSelectedPart: (part) => interaction.setSelectedPart(part),
|
|
1316
|
+
dispose() {
|
|
1317
|
+
if (disposed) {
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
disposed = true;
|
|
1321
|
+
interaction.dispose();
|
|
1322
|
+
marks.dispose();
|
|
1323
|
+
overlay.remove();
|
|
1324
|
+
},
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const SEAM_COLOR = 0xffffff;
|
|
1329
|
+
/** Arc segments per full turn. */
|
|
1330
|
+
const SEGMENTS_PER_TURN = 144;
|
|
1331
|
+
/** Shade multiplier for a box-space normal (box -z faces the viewer); `cut` for a slice's flat cut face. */
|
|
1332
|
+
function pieFaceShade(nx, ny, nz, cut = false) {
|
|
1333
|
+
if (ny > 0.5) {
|
|
1334
|
+
return 1;
|
|
1335
|
+
}
|
|
1336
|
+
if (ny < -0.5) {
|
|
1337
|
+
return 0.3;
|
|
1338
|
+
}
|
|
1339
|
+
const phi = Math.atan2(nx, -nz);
|
|
1340
|
+
if (cut) {
|
|
1341
|
+
return 0.5 + 0.2 * Math.sin(phi);
|
|
1342
|
+
}
|
|
1343
|
+
return Math.max(0.385, 0.57 - 0.275 * phi);
|
|
1344
|
+
}
|
|
1345
|
+
function sliceBuffers(three, layout, slice) {
|
|
1346
|
+
const base = new three.Color(slice.color).getRGB(new three.Color(), three.SRGBColorSpace);
|
|
1347
|
+
const out = new three.Color();
|
|
1348
|
+
const positions = [];
|
|
1349
|
+
const colors = [];
|
|
1350
|
+
const cx = layout.center.x + slice.offset.x;
|
|
1351
|
+
const cz = layout.center.z + slice.offset.z;
|
|
1352
|
+
const top = layout.center.y;
|
|
1353
|
+
const r = layout.radius;
|
|
1354
|
+
const push = (p, n, cut = false) => {
|
|
1355
|
+
const f = pieFaceShade(n[0], n[1], n[2], cut);
|
|
1356
|
+
out.setRGB(Math.min(1, base.r * f), Math.min(1, base.g * f), Math.min(1, base.b * f), three.SRGBColorSpace);
|
|
1357
|
+
positions.push(...p);
|
|
1358
|
+
colors.push(out.r, out.g, out.b);
|
|
1359
|
+
};
|
|
1360
|
+
const span = slice.endAngle - slice.startAngle;
|
|
1361
|
+
const steps = Math.max(2, Math.ceil((span / (Math.PI * 2)) * SEGMENTS_PER_TURN));
|
|
1362
|
+
const angles = Array.from({ length: steps + 1 }, (_, i) => slice.startAngle + (span * i) / steps);
|
|
1363
|
+
const rim = (a, y) => {
|
|
1364
|
+
const p = pieRimPoint(cx, cz, r, a);
|
|
1365
|
+
return [p.x, y, p.z];
|
|
1366
|
+
};
|
|
1367
|
+
const radial = (a) => [Math.sin(a), 0, Math.cos(a)];
|
|
1368
|
+
for (let i = 0; i < steps; i++) {
|
|
1369
|
+
const a0 = angles[i];
|
|
1370
|
+
const a1 = angles[i + 1];
|
|
1371
|
+
// Top and bottom fans.
|
|
1372
|
+
push([cx, top, cz], [0, 1, 0]);
|
|
1373
|
+
push(rim(a0, top), [0, 1, 0]);
|
|
1374
|
+
push(rim(a1, top), [0, 1, 0]);
|
|
1375
|
+
push([cx, 0, cz], [0, -1, 0]);
|
|
1376
|
+
push(rim(a1, 0), [0, -1, 0]);
|
|
1377
|
+
push(rim(a0, 0), [0, -1, 0]);
|
|
1378
|
+
// Rim, smooth-shaded by the radial normal.
|
|
1379
|
+
push(rim(a0, top), radial(a0));
|
|
1380
|
+
push(rim(a0, 0), radial(a0));
|
|
1381
|
+
push(rim(a1, 0), radial(a1));
|
|
1382
|
+
push(rim(a0, top), radial(a0));
|
|
1383
|
+
push(rim(a1, 0), radial(a1));
|
|
1384
|
+
push(rim(a1, top), radial(a1));
|
|
1385
|
+
}
|
|
1386
|
+
// The two cut faces, normals perpendicular to their radius.
|
|
1387
|
+
for (const [a, sign] of [
|
|
1388
|
+
[slice.startAngle, -1],
|
|
1389
|
+
[slice.endAngle, 1],
|
|
1390
|
+
]) {
|
|
1391
|
+
const n = [sign * Math.cos(a), 0, -sign * Math.sin(a)];
|
|
1392
|
+
push([cx, top, cz], n, true);
|
|
1393
|
+
push([cx, 0, cz], n, true);
|
|
1394
|
+
push(rim(a, 0), n, true);
|
|
1395
|
+
push([cx, top, cz], n, true);
|
|
1396
|
+
push(rim(a, 0), n, true);
|
|
1397
|
+
push(rim(a, top), n, true);
|
|
1398
|
+
}
|
|
1399
|
+
const seams = [];
|
|
1400
|
+
for (let i = 0; i < steps; i++) {
|
|
1401
|
+
seams.push(...rim(angles[i], top), ...rim(angles[i + 1], top));
|
|
1402
|
+
}
|
|
1403
|
+
for (const a of [slice.startAngle, slice.endAngle]) {
|
|
1404
|
+
seams.push(cx, top, cz, ...rim(a, top), ...rim(a, top), ...rim(a, 0));
|
|
1405
|
+
}
|
|
1406
|
+
return { positions, colors, seams };
|
|
1407
|
+
}
|
|
1408
|
+
/** Build the slice meshes and seams of a pie layout. */
|
|
1409
|
+
function buildPieMarks(three, layout) {
|
|
1410
|
+
const group = new three.Group();
|
|
1411
|
+
group.matrixAutoUpdate = false;
|
|
1412
|
+
group.matrix.copy(perspBoxMatrix(three, layout.view));
|
|
1413
|
+
const material = new three.MeshBasicMaterial({
|
|
1414
|
+
vertexColors: true,
|
|
1415
|
+
side: three.DoubleSide,
|
|
1416
|
+
polygonOffset: true,
|
|
1417
|
+
polygonOffsetFactor: 1,
|
|
1418
|
+
polygonOffsetUnits: 1,
|
|
1419
|
+
});
|
|
1420
|
+
const seamMaterial = new three.LineBasicMaterial({ color: SEAM_COLOR });
|
|
1421
|
+
// Stable array: the scene's pick targets keep pointing at it across rebuilds.
|
|
1422
|
+
const meshes = [];
|
|
1423
|
+
let seams = null;
|
|
1424
|
+
const clear = () => {
|
|
1425
|
+
for (const mesh of meshes) {
|
|
1426
|
+
group.remove(mesh);
|
|
1427
|
+
mesh.geometry.dispose();
|
|
1428
|
+
}
|
|
1429
|
+
if (seams) {
|
|
1430
|
+
group.remove(seams);
|
|
1431
|
+
seams.geometry.dispose();
|
|
1432
|
+
}
|
|
1433
|
+
};
|
|
1434
|
+
const build = (slices) => {
|
|
1435
|
+
clear();
|
|
1436
|
+
const seamPoints = [];
|
|
1437
|
+
const built = slices.map((slice) => {
|
|
1438
|
+
const b = sliceBuffers(three, layout, slice);
|
|
1439
|
+
const geometry = new three.BufferGeometry();
|
|
1440
|
+
geometry.setAttribute('position', new three.Float32BufferAttribute(b.positions, 3));
|
|
1441
|
+
geometry.setAttribute('color', new three.Float32BufferAttribute(b.colors, 3));
|
|
1442
|
+
const mesh = new three.Mesh(geometry, material);
|
|
1443
|
+
mesh.userData = { seriesIndex: 0, pointIndex: slice.pointIndex };
|
|
1444
|
+
group.add(mesh);
|
|
1445
|
+
seamPoints.push(...b.seams);
|
|
1446
|
+
return mesh;
|
|
1447
|
+
});
|
|
1448
|
+
meshes.splice(0, meshes.length, ...built);
|
|
1449
|
+
const seamGeometry = new three.BufferGeometry();
|
|
1450
|
+
seamGeometry.setAttribute('position', new three.Float32BufferAttribute(seamPoints, 3));
|
|
1451
|
+
seams = new three.LineSegments(seamGeometry, seamMaterial);
|
|
1452
|
+
group.add(seams);
|
|
1453
|
+
};
|
|
1454
|
+
build(layout.slices);
|
|
1455
|
+
return {
|
|
1456
|
+
group,
|
|
1457
|
+
meshes,
|
|
1458
|
+
setSlices: build,
|
|
1459
|
+
dispose() {
|
|
1460
|
+
clear();
|
|
1461
|
+
material.dispose();
|
|
1462
|
+
seamMaterial.dispose();
|
|
1463
|
+
},
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/** Selection marker colour (the 2D selected-mark accent). */
|
|
1468
|
+
const SELECTED_COLOR = 0x2563eb;
|
|
1469
|
+
/** The layout's slices with one value replaced (a drag preview). */
|
|
1470
|
+
function slicesWith(input, pointIndex, value) {
|
|
1471
|
+
const series = input.chartData.series.map((s, i) => i === 0 ? { ...s, values: s.values.map((v, j) => (j === pointIndex ? value : v)) } : s);
|
|
1472
|
+
const element = {
|
|
1473
|
+
...input.element,
|
|
1474
|
+
chartData: { ...input.chartData, series },
|
|
1475
|
+
};
|
|
1476
|
+
return computePieChartLayout(element, input.vm)?.slices ?? input.layout.slices;
|
|
1477
|
+
}
|
|
1478
|
+
function mountPieChartView(input, ctx) {
|
|
1479
|
+
const three = ctx.three;
|
|
1480
|
+
const { vm, layout, chartData } = input;
|
|
1481
|
+
const camera = buildPerspCamera(three, layout.view, vm.svgWidth, vm.svgHeight);
|
|
1482
|
+
const scene = new three.Scene();
|
|
1483
|
+
const marks = buildPieMarks(three, layout);
|
|
1484
|
+
scene.add(marks.group);
|
|
1485
|
+
const markerGeometry = new three.OctahedronGeometry(0.03);
|
|
1486
|
+
const markerMaterial = new three.MeshBasicMaterial({ color: SELECTED_COLOR });
|
|
1487
|
+
const marker = new three.Mesh(markerGeometry, markerMaterial);
|
|
1488
|
+
marker.visible = false;
|
|
1489
|
+
marks.group.add(marker);
|
|
1490
|
+
const overlay = renderChart3DChromeOverlaySvg(ctx.document, vm, {
|
|
1491
|
+
labels: [],
|
|
1492
|
+
reverseLegend: false,
|
|
1493
|
+
});
|
|
1494
|
+
ctx.overlay.appendChild(overlay);
|
|
1495
|
+
const values = () => chartData.series[0]?.values.slice() ?? [];
|
|
1496
|
+
const interaction = attachChart3DMarkInteraction({
|
|
1497
|
+
ctx,
|
|
1498
|
+
camera,
|
|
1499
|
+
scene,
|
|
1500
|
+
svgHeight: vm.svgHeight,
|
|
1501
|
+
chartData,
|
|
1502
|
+
categoryLabels: input.categoryLabels,
|
|
1503
|
+
seriesNames: chartData.series.map((s) => s.name),
|
|
1504
|
+
adapter: {
|
|
1505
|
+
targets: marks.meshes,
|
|
1506
|
+
pointAt(hit) {
|
|
1507
|
+
const pointIndex = hit.object.userData.pointIndex;
|
|
1508
|
+
return pointIndex === undefined ? null : { seriesIndex: 0, pointIndex };
|
|
1509
|
+
},
|
|
1510
|
+
valueOf(point) {
|
|
1511
|
+
const v = chartData.series[0]?.values[point.pointIndex];
|
|
1512
|
+
return v !== undefined && Number.isFinite(v) ? v : undefined;
|
|
1513
|
+
},
|
|
1514
|
+
beginDrag(point) {
|
|
1515
|
+
const slice = layout.slices[point.pointIndex];
|
|
1516
|
+
if (!slice) {
|
|
1517
|
+
return null;
|
|
1518
|
+
}
|
|
1519
|
+
const start = values();
|
|
1520
|
+
return {
|
|
1521
|
+
preview(_dx, _dy, at) {
|
|
1522
|
+
const hit = perspScreenToPlaneY(layout.view, at.x, at.y, layout.center.y);
|
|
1523
|
+
if (!hit) {
|
|
1524
|
+
return start[point.pointIndex];
|
|
1525
|
+
}
|
|
1526
|
+
const angle = Math.atan2(hit.x - layout.center.x - slice.offset.x, hit.z - layout.center.z - slice.offset.z);
|
|
1527
|
+
const value = resolvePieSliceShareValue(angle, slice.startAngle, start, point.pointIndex);
|
|
1528
|
+
marks.setSlices(slicesWith(input, point.pointIndex, value));
|
|
1529
|
+
return value;
|
|
1530
|
+
},
|
|
1531
|
+
};
|
|
1532
|
+
},
|
|
1533
|
+
highlight(point) {
|
|
1534
|
+
const slice = point ? layout.slices[point.pointIndex] : undefined;
|
|
1535
|
+
marker.visible = slice !== undefined;
|
|
1536
|
+
if (slice) {
|
|
1537
|
+
const mid = (slice.startAngle + slice.endAngle) / 2;
|
|
1538
|
+
const p = pieRimPoint(layout.center.x + slice.offset.x, layout.center.z + slice.offset.z, layout.radius * 0.6, mid);
|
|
1539
|
+
marker.position.set(p.x, layout.center.y, p.z);
|
|
1540
|
+
}
|
|
1541
|
+
},
|
|
1542
|
+
dispose() {
|
|
1543
|
+
markerGeometry.dispose();
|
|
1544
|
+
markerMaterial.dispose();
|
|
1545
|
+
},
|
|
1546
|
+
},
|
|
1547
|
+
});
|
|
1548
|
+
let disposed = false;
|
|
1549
|
+
return {
|
|
1550
|
+
render(renderer) {
|
|
1551
|
+
renderer.render(scene, camera);
|
|
1552
|
+
},
|
|
1553
|
+
resize() {
|
|
1554
|
+
// The projection is fixed in authored chart px, like the overlay.
|
|
1555
|
+
},
|
|
1556
|
+
isAnimating: () => false,
|
|
1557
|
+
setInteractive: (on) => interaction.setInteractive(on),
|
|
1558
|
+
setSelectedPart: (part) => interaction.setSelectedPart(part),
|
|
1559
|
+
dispose() {
|
|
1560
|
+
if (disposed) {
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
disposed = true;
|
|
1564
|
+
interaction.dispose();
|
|
1565
|
+
marks.dispose();
|
|
1566
|
+
overlay.remove();
|
|
1567
|
+
},
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* The oblique chart camera. Camera distance is chosen so `NEAR`/`FAR` stay
|
|
1573
|
+
* tight around the scene's own (tiny) depth extent. Exported for tests.
|
|
1574
|
+
*/
|
|
1575
|
+
function buildObliqueCamera(three, svgWidth, svgHeight, shearX, shearY) {
|
|
1576
|
+
const halfW = svgWidth / 2;
|
|
1577
|
+
const halfH = svgHeight / 2;
|
|
1578
|
+
const distance = halfW + halfH + 500;
|
|
1579
|
+
const camera = new three.OrthographicCamera(-halfW, halfW, halfH, -halfH, 1, distance + 2000);
|
|
1580
|
+
camera.position.set(0, 0, distance);
|
|
1581
|
+
camera.lookAt(0, 0, 0);
|
|
1582
|
+
camera.updateProjectionMatrix();
|
|
1583
|
+
// Hand-insert the oblique shear: clip_x += kX * ndcScaleX * world_z,
|
|
1584
|
+
// clip_y += kY * ndcScaleY * world_z (see the module doc + the chart
|
|
1585
|
+
// track's progress log for the sign derivation between the flat 2D
|
|
1586
|
+
// fallback's SVG-Y-down depth vector and this scene's world-Y-up frame:
|
|
1587
|
+
// X needs no flip (kX = -shearX), Y does (kY = +shearY)).
|
|
1588
|
+
const m = camera.projectionMatrix.elements;
|
|
1589
|
+
const ndcScaleX = m[0];
|
|
1590
|
+
const ndcScaleY = m[5];
|
|
1591
|
+
m[8] = -shearX * ndcScaleX;
|
|
1592
|
+
m[9] = shearY * ndcScaleY;
|
|
1593
|
+
// The shear multiplies VIEW-space z, which is `-distance` at the chart's
|
|
1594
|
+
// front plane (world z = 0). Offset it so the front plane is unsheared
|
|
1595
|
+
// and lands exactly on the 2D layout; only depth behind it shifts.
|
|
1596
|
+
m[12] += m[8] * distance;
|
|
1597
|
+
m[13] += m[9] * distance;
|
|
1598
|
+
camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
|
|
1599
|
+
return camera;
|
|
1600
|
+
}
|
|
1601
|
+
/** The hosted perspective scene for a spec whose oblique geometry is `null`. */
|
|
1602
|
+
function mountPerspectiveScene(perspective, ctx) {
|
|
1603
|
+
switch (perspective.kind) {
|
|
1604
|
+
case 'bar':
|
|
1605
|
+
return createBarChart3DScene(ctx, perspective.options);
|
|
1606
|
+
case 'line':
|
|
1607
|
+
return createLineChart3DScene(ctx, perspective.options);
|
|
1608
|
+
case 'area':
|
|
1609
|
+
return createAreaChart3DScene(ctx, perspective.options);
|
|
1610
|
+
case 'pie':
|
|
1611
|
+
return createPieChart3DScene(ctx, perspective.options);
|
|
1612
|
+
case 'surface':
|
|
1613
|
+
return createSurfaceChart3DScene(ctx, perspective.options);
|
|
1614
|
+
default: {
|
|
1615
|
+
const exhaustive = perspective;
|
|
1616
|
+
return exhaustive;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
async function mountChart3DView(spec, ctx) {
|
|
1621
|
+
if (spec.geometry?.kind === 'pie') {
|
|
1622
|
+
const chartData = spec.element.chartData;
|
|
1623
|
+
if (!chartData) {
|
|
1624
|
+
throw new Error('3D chart spec without chart data');
|
|
1625
|
+
}
|
|
1626
|
+
return mountPieChartView({
|
|
1627
|
+
element: spec.element,
|
|
1628
|
+
vm: widenPieViewModel(spec.vm, spec.element),
|
|
1629
|
+
layout: spec.geometry.layout,
|
|
1630
|
+
chartData,
|
|
1631
|
+
categoryLabels: spec.categoryLabels,
|
|
1632
|
+
}, ctx);
|
|
1633
|
+
}
|
|
1634
|
+
if (spec.geometry?.kind === 'perspective') {
|
|
1635
|
+
const chartData = spec.element.chartData;
|
|
1636
|
+
if (!chartData) {
|
|
1637
|
+
throw new Error('3D chart spec without chart data');
|
|
1638
|
+
}
|
|
1639
|
+
return mountPerspChartView({ vm: spec.vm, layout: spec.geometry.layout, chartData, categoryLabels: spec.categoryLabels }, ctx);
|
|
1640
|
+
}
|
|
1641
|
+
if (spec.projection.mode !== 'oblique' || spec.geometry?.kind !== 'oblique') {
|
|
1642
|
+
if (spec.perspective) {
|
|
1643
|
+
return mountPerspectiveScene(spec.perspective, ctx);
|
|
1644
|
+
}
|
|
1645
|
+
throw new Error(`no 3D chart scene for chartType=${spec.chartType}`);
|
|
1646
|
+
}
|
|
1647
|
+
const three = ctx.three;
|
|
1648
|
+
const { vm, geometry } = spec;
|
|
1649
|
+
const chartData = spec.element.chartData;
|
|
1650
|
+
if (!chartData) {
|
|
1651
|
+
throw new Error('3D chart spec without chart data');
|
|
1652
|
+
}
|
|
1653
|
+
const camera = buildObliqueCamera(three, vm.svgWidth, vm.svgHeight, spec.projection.shearX, spec.projection.shearY);
|
|
1654
|
+
const scene = new three.Scene();
|
|
1655
|
+
const { layout } = geometry;
|
|
1656
|
+
const bars = buildChart3DBarMeshes(three, layout, vm.svgWidth, vm.svgHeight);
|
|
1657
|
+
scene.add(bars.group);
|
|
1658
|
+
const overlay = renderChart3DChromeOverlaySvg(ctx.document, vm, {
|
|
1659
|
+
labels: layout.labels,
|
|
1660
|
+
// PowerPoint lists a clustered horizontal bar chart's legend bottom-up,
|
|
1661
|
+
// matching the order the series stack up the category axis.
|
|
1662
|
+
reverseLegend: layout.horizontal && layout.grouping === 'clustered',
|
|
1663
|
+
});
|
|
1664
|
+
ctx.overlay.appendChild(overlay);
|
|
1665
|
+
const interaction = attachObliqueBarInteraction({
|
|
1666
|
+
ctx,
|
|
1667
|
+
camera,
|
|
1668
|
+
meshes: bars.meshes,
|
|
1669
|
+
layout,
|
|
1670
|
+
svgWidth: vm.svgWidth,
|
|
1671
|
+
svgHeight: vm.svgHeight,
|
|
1672
|
+
chartData,
|
|
1673
|
+
categoryLabels: spec.categoryLabels,
|
|
1674
|
+
seriesNames: chartData.series.map((series) => series.name),
|
|
1675
|
+
scene,
|
|
1676
|
+
});
|
|
1677
|
+
let disposed = false;
|
|
1678
|
+
return {
|
|
1679
|
+
render(renderer) {
|
|
1680
|
+
renderer.render(scene, camera);
|
|
1681
|
+
},
|
|
1682
|
+
resize() {
|
|
1683
|
+
// The orthographic frustum is fixed in authored chart-px units
|
|
1684
|
+
// (svgWidth/svgHeight), independent of the element's on-screen CSS
|
|
1685
|
+
// size, so nothing needs recomputing here.
|
|
1686
|
+
},
|
|
1687
|
+
isAnimating: () => false,
|
|
1688
|
+
setInteractive: (on) => interaction.setInteractive(on),
|
|
1689
|
+
setSelectedPart: (part) => interaction.setSelectedPart(part),
|
|
1690
|
+
dispose() {
|
|
1691
|
+
if (disposed) {
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
disposed = true;
|
|
1695
|
+
interaction.dispose();
|
|
1696
|
+
bars.dispose();
|
|
1697
|
+
overlay.remove();
|
|
1698
|
+
},
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
export { buildObliqueCamera, mountChart3DView };
|
|
1703
|
+
//# sourceMappingURL=pptx-angular-viewer-chart-3d-view-scene-CM71j9A9.mjs.map
|