@wil-works/evoke-charts 0.5.0 → 0.7.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/dist/3d.mjs ADDED
@@ -0,0 +1,4992 @@
1
+ import { a as staggerProgress, i as prefersReducedMotion, n as clearSeriesPalette, r as _plugin_vue_export_helper_default, t as applySeriesPalette } from "./palette-lwVAltky.js";
2
+ import { _ as shadeColor, a as CHART3D_RAMPS, b as resolveChartPalette, c as CHART_COLORS, d as resolveEasing, f as resolveI18n, g as mixHue, h as lighten, i as CHART3D_PALETTE_IDS, l as easings, m as gradientAt, n as validateOptions3d, o as resolveChartRamp, p as darken, r as CHART3D_PALETTES, s as CHART3D_TYPES, t as chart3dOptionsSchema, u as getTheme, v as toRgba } from "./schema-CM4hKlpc.js";
3
+ import { Fragment, Transition, computed, createCommentVNode, createElementBlock, createElementVNode, createVNode, getCurrentInstance, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onMounted, openBlock, reactive, ref, renderList, renderSlot, shallowRef, toDisplayString, watch, withCtx } from "vue";
4
+ //#region src/3d/core/math3d.js
5
+ function v3add(a, b) {
6
+ return [
7
+ a[0] + b[0],
8
+ a[1] + b[1],
9
+ a[2] + b[2]
10
+ ];
11
+ }
12
+ function v3sub(a, b) {
13
+ return [
14
+ a[0] - b[0],
15
+ a[1] - b[1],
16
+ a[2] - b[2]
17
+ ];
18
+ }
19
+ function v3scale(a, s) {
20
+ return [
21
+ a[0] * s,
22
+ a[1] * s,
23
+ a[2] * s
24
+ ];
25
+ }
26
+ function v3dot(a, b) {
27
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
28
+ }
29
+ function v3cross(a, b) {
30
+ return [
31
+ a[1] * b[2] - a[2] * b[1],
32
+ a[2] * b[0] - a[0] * b[2],
33
+ a[0] * b[1] - a[1] * b[0]
34
+ ];
35
+ }
36
+ function v3len(a) {
37
+ return Math.sqrt(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]);
38
+ }
39
+ function v3norm(a) {
40
+ const l = v3len(a);
41
+ if (l < 1e-12) return [
42
+ 0,
43
+ 0,
44
+ 0
45
+ ];
46
+ return [
47
+ a[0] / l,
48
+ a[1] / l,
49
+ a[2] / l
50
+ ];
51
+ }
52
+ /**
53
+ * 矩阵乘法 out = a · b(几何语义:先施加 b 的变换,再施加 a)
54
+ * 列主序下 out[c*4+r] = Σ a[k*4+r] * b[c*4+k]
55
+ */
56
+ function mat4Multiply(a, b) {
57
+ const out = new Array(16);
58
+ for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) out[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
59
+ return out;
60
+ }
61
+ /**
62
+ * 透视投影矩阵(右手系,相机看向 -Z)
63
+ * @param {number} fovY 垂直视场角(弧度)
64
+ * @param {number} aspect 宽高比
65
+ */
66
+ function mat4Perspective(fovY, aspect, near, far) {
67
+ const f = 1 / Math.tan(fovY / 2);
68
+ const nf = 1 / (near - far);
69
+ return [
70
+ f / aspect,
71
+ 0,
72
+ 0,
73
+ 0,
74
+ 0,
75
+ f,
76
+ 0,
77
+ 0,
78
+ 0,
79
+ 0,
80
+ (far + near) * nf,
81
+ -1,
82
+ 0,
83
+ 0,
84
+ 2 * far * near * nf,
85
+ 0
86
+ ];
87
+ }
88
+ /**
89
+ * 视图矩阵 — 把世界变换到相机空间(相机在原点看向 -Z)
90
+ */
91
+ function mat4LookAt(eye, center, up) {
92
+ let z = v3sub(eye, center);
93
+ z = v3norm(z);
94
+ if (v3len(z) < 1e-12) z = [
95
+ 0,
96
+ 0,
97
+ 1
98
+ ];
99
+ let x = v3cross(up, z);
100
+ if (v3len(x) < 1e-12) {
101
+ x = v3cross([
102
+ 0,
103
+ 1,
104
+ 0
105
+ ], z);
106
+ if (v3len(x) < 1e-12) x = v3cross([
107
+ 1,
108
+ 0,
109
+ 0
110
+ ], z);
111
+ }
112
+ x = v3norm(x);
113
+ const y = v3cross(z, x);
114
+ return [
115
+ x[0],
116
+ y[0],
117
+ z[0],
118
+ 0,
119
+ x[1],
120
+ y[1],
121
+ z[1],
122
+ 0,
123
+ x[2],
124
+ y[2],
125
+ z[2],
126
+ 0,
127
+ -v3dot(x, eye),
128
+ -v3dot(y, eye),
129
+ -v3dot(z, eye),
130
+ 1
131
+ ];
132
+ }
133
+ /** 齐次变换 — 返回未做透视除法的 [x, y, z, w] */
134
+ function transformPoint4(m, p) {
135
+ return [
136
+ m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
137
+ m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
138
+ m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
139
+ m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15]
140
+ ];
141
+ }
142
+ /**
143
+ * 裁剪空间 → 屏幕坐标
144
+ *
145
+ * depth 取齐次 w:透视投影下 w 恒等于「相机轴向上的视深」,
146
+ * 这正是画家算法需要的排序键(近处小、远处大),比欧氏距离更适合排序,
147
+ * 且天然处理了透视缩放。
148
+ *
149
+ * w ≤ 0 表示顶点落在相机身后,直接判为不可见 —— 若照常除法则坐标会翻折到
150
+ * 画面另一侧,出现「穿透」的鬼影线条。
151
+ */
152
+ function projectPoint(mvp, viewport, p) {
153
+ const c = transformPoint4(mvp, p);
154
+ const w = c[3];
155
+ if (!(w > 1e-6) || !Number.isFinite(w)) return {
156
+ x: NaN,
157
+ y: NaN,
158
+ z: NaN,
159
+ depth: Infinity,
160
+ visible: false
161
+ };
162
+ const inv = 1 / w;
163
+ const ndcX = c[0] * inv;
164
+ const ndcY = c[1] * inv;
165
+ const ndcZ = c[2] * inv;
166
+ return {
167
+ x: viewport.x + (ndcX * .5 + .5) * viewport.width,
168
+ y: viewport.y + (1 - (ndcY * .5 + .5)) * viewport.height,
169
+ z: ndcZ,
170
+ depth: w,
171
+ visible: Number.isFinite(ndcX) && Number.isFinite(ndcY)
172
+ };
173
+ }
174
+ /** 多边形质心(顶点算术平均,用于深度排序键与法线取点) */
175
+ function polygonCentroid(points) {
176
+ if (!points.length) return [
177
+ 0,
178
+ 0,
179
+ 0
180
+ ];
181
+ let x = 0;
182
+ let y = 0;
183
+ let z = 0;
184
+ for (const p of points) {
185
+ x += p[0];
186
+ y += p[1];
187
+ z += p[2];
188
+ }
189
+ const n = points.length;
190
+ return [
191
+ x / n,
192
+ y / n,
193
+ z / n
194
+ ];
195
+ }
196
+ /**
197
+ * 多边形法线(Newell 法)
198
+ * 相比任取两条边的叉乘,Newell 法对非严格平面多边形与顶点缠绕误差更稳,
199
+ * 且顺带给出面积加权的方向 —— 曲面上大量小面片时这点很关键。
200
+ */
201
+ function polygonNormal(points) {
202
+ let nx = 0;
203
+ let ny = 0;
204
+ let nz = 0;
205
+ const n = points.length;
206
+ for (let i = 0; i < n; i++) {
207
+ const a = points[i];
208
+ const b = points[(i + 1) % n];
209
+ nx += (a[1] - b[1]) * (a[2] + b[2]);
210
+ ny += (a[2] - b[2]) * (a[0] + b[0]);
211
+ nz += (a[0] - b[0]) * (a[1] + b[1]);
212
+ }
213
+ return v3norm([
214
+ nx,
215
+ ny,
216
+ nz
217
+ ]);
218
+ }
219
+ //#endregion
220
+ //#region src/3d/core/camera.js
221
+ /**
222
+ * 轨道相机 — 球坐标环绕 + 拖拽/滚轮/平移/自动旋转
223
+ *
224
+ * 世界为 Z 轴向上,相机始终看向 target,位置由三个量决定:
225
+ * yaw 方位角(度),从 +X 轴起算、绕 +Z 逆时针为正,取模到 (-180, 180]
226
+ * pitch 仰角(度),0 为与地面平齐、90 为正上方俯视
227
+ * distance 相机到 target 的距离(世界单位)
228
+ *
229
+ * 全部控制函数都是**纯函数**:接收相机对象、返回新对象,不改写入参。
230
+ * 组件侧把相机放在 ref 里整体替换即可自然触发响应式,测试也可以直接断言前后快照。
231
+ */
232
+ var DEG$1 = Math.PI / 180;
233
+ /** 默认相机 — 经典的「左前上方俯视」三轴透视构图 */
234
+ var DEFAULT_CAMERA = {
235
+ yaw: -52,
236
+ pitch: 27,
237
+ distance: 3.6,
238
+ target: [
239
+ 0,
240
+ 0,
241
+ .3
242
+ ],
243
+ fov: 42,
244
+ minPitch: 2,
245
+ maxPitch: 88,
246
+ minDistance: 1.4,
247
+ maxDistance: 10,
248
+ autoRotate: false,
249
+ /** 自动旋转角速度(度/秒),正负决定方向 */
250
+ autoRotateSpeed: 10,
251
+ /** 惯性衰减系数:每帧速度乘以 (1 - damping),0 表示无惯性 */
252
+ damping: .14
253
+ };
254
+ function clamp(v, min, max) {
255
+ if (!Number.isFinite(v)) return min;
256
+ if (v < min) return min;
257
+ if (v > max) return max;
258
+ return v;
259
+ }
260
+ /** 角度归一化到 (-180, 180],避免长时间拖拽后 yaw 无限增长 */
261
+ function normalizeAngle(deg) {
262
+ if (!Number.isFinite(deg)) return 0;
263
+ let a = ((deg + 180) % 360 + 360) % 360 - 180;
264
+ if (a === -180) a = 180;
265
+ return a;
266
+ }
267
+ function num(value, fallback) {
268
+ return Number.isFinite(value) ? value : fallback;
269
+ }
270
+ /**
271
+ * 合并用户配置为完整相机对象,并做一次钳制
272
+ * 非法值(NaN / 字符串 / 越界)一律回落默认,保证下游投影不会拿到脏数据
273
+ */
274
+ function resolveCamera(config) {
275
+ const c = config && typeof config === "object" ? config : {};
276
+ const base = { ...DEFAULT_CAMERA };
277
+ const minPitch = num(c.minPitch, base.minPitch);
278
+ const maxPitch = num(c.maxPitch, base.maxPitch);
279
+ const minDistance = num(c.minDistance, base.minDistance);
280
+ const maxDistance = num(c.maxDistance, base.maxDistance);
281
+ const target = Array.isArray(c.target) && c.target.length >= 3 ? [
282
+ num(c.target[0], 0),
283
+ num(c.target[1], 0),
284
+ num(c.target[2], 0)
285
+ ] : base.target.slice();
286
+ return {
287
+ ...base,
288
+ ...c,
289
+ target,
290
+ minPitch: Math.min(minPitch, maxPitch),
291
+ maxPitch: Math.max(minPitch, maxPitch),
292
+ minDistance: Math.min(minDistance, maxDistance),
293
+ maxDistance: Math.max(minDistance, maxDistance),
294
+ yaw: normalizeAngle(num(c.yaw, base.yaw)),
295
+ pitch: clamp(num(c.pitch, base.pitch), Math.min(minPitch, maxPitch), Math.max(minPitch, maxPitch)),
296
+ distance: clamp(num(c.distance, base.distance), Math.min(minDistance, maxDistance), Math.max(minDistance, maxDistance)),
297
+ fov: clamp(num(c.fov, base.fov), 10, 120),
298
+ autoRotateSpeed: num(c.autoRotateSpeed, base.autoRotateSpeed),
299
+ damping: clamp(num(c.damping, base.damping), 0, 1)
300
+ };
301
+ }
302
+ /** 相机在世界空间的位置 */
303
+ function cameraEye(camera) {
304
+ const yaw = camera.yaw * DEG$1;
305
+ const pitch = camera.pitch * DEG$1;
306
+ const cp = Math.cos(pitch);
307
+ return [
308
+ camera.target[0] + camera.distance * cp * Math.cos(yaw),
309
+ camera.target[1] + camera.distance * cp * Math.sin(yaw),
310
+ camera.target[2] + camera.distance * Math.sin(pitch)
311
+ ];
312
+ }
313
+ /** 相机基向量:right / up 取自视图矩阵的旋转行,forward 为视线方向 */
314
+ function cameraBasis(camera) {
315
+ const eye = cameraEye(camera);
316
+ const view = mat4LookAt(eye, camera.target, [
317
+ 0,
318
+ 0,
319
+ 1
320
+ ]);
321
+ return {
322
+ eye,
323
+ right: [
324
+ view[0],
325
+ view[4],
326
+ view[8]
327
+ ],
328
+ up: [
329
+ view[1],
330
+ view[5],
331
+ view[9]
332
+ ],
333
+ forward: [
334
+ -view[2],
335
+ -view[6],
336
+ -view[10]
337
+ ]
338
+ };
339
+ }
340
+ /**
341
+ * 视图 × 投影矩阵
342
+ * near/far 随 distance 联动:距离拉远时近平面同步外推,避免远处几何被近平面裁掉;
343
+ * 贴太近时又不至于让 near 退化成 0 造成深度精度塌陷。
344
+ */
345
+ function cameraMatrices(camera, viewport) {
346
+ const eye = cameraEye(camera);
347
+ const view = mat4LookAt(eye, camera.target, [
348
+ 0,
349
+ 0,
350
+ 1
351
+ ]);
352
+ const aspect = viewport.height > 0 ? viewport.width / viewport.height : 1;
353
+ const near = Math.max(.02, camera.distance * .02);
354
+ const far = camera.distance * 12 + 10;
355
+ const projection = mat4Perspective(camera.fov * DEG$1, aspect, near, far);
356
+ return {
357
+ view,
358
+ projection,
359
+ mvp: mat4Multiply(projection, view),
360
+ eye
361
+ };
362
+ }
363
+ /** 屏幕一个像素在 target 所在平面上对应的世界长度 — 平移与拾取容差共用 */
364
+ function worldPerPixel(camera, viewport) {
365
+ if (!(viewport.height > 0)) return 0;
366
+ return 2 * camera.distance * Math.tan(camera.fov * DEG$1 / 2) / viewport.height;
367
+ }
368
+ /**
369
+ * 拖拽 → 新相机
370
+ * 灵敏度按视口尺寸归一:窄容器里同样的手势不该转得更猛。
371
+ */
372
+ function orbitBy(camera, dxPixels, dyPixels, viewport) {
373
+ const perPx = 170 / Math.max(240, Math.min(viewport?.width || 0, viewport?.height || 0) || 480);
374
+ return resolveCamera({
375
+ ...camera,
376
+ yaw: camera.yaw - dxPixels * perPx,
377
+ pitch: camera.pitch + dyPixels * perPx * .8
378
+ });
379
+ }
380
+ /** 滚轮 / 捏合 → 新相机;factor < 1 拉近 */
381
+ function zoomBy(camera, factor) {
382
+ const f = Number.isFinite(factor) && factor > 0 ? factor : 1;
383
+ return resolveCamera({
384
+ ...camera,
385
+ distance: camera.distance * f
386
+ });
387
+ }
388
+ /** 滚轮增量 → 缩放因子(指数映射,保证缩放手感线性且永不越界) */
389
+ function wheelZoomFactor(deltaY, speed = .0012) {
390
+ return Math.exp(clamp(Number.isFinite(deltaY) ? deltaY : 0, -600, 600) * speed);
391
+ }
392
+ /** 平移 → 新相机;target 沿相机的 right/up 平面移动 */
393
+ function panBy(camera, dxPixels, dyPixels, viewport) {
394
+ const { right, up } = cameraBasis(camera);
395
+ const scale = worldPerPixel(camera, viewport);
396
+ const offset = v3add(v3scale(right, -dxPixels * scale), v3scale(up, dyPixels * scale));
397
+ return resolveCamera({
398
+ ...camera,
399
+ target: v3add(camera.target, offset)
400
+ });
401
+ }
402
+ /** 自动旋转一帧;dtMs 为真实经过毫秒,保证不同刷新率下转速一致 */
403
+ function autoRotateBy(camera, dtMs) {
404
+ const dt = Number.isFinite(dtMs) ? dtMs : 0;
405
+ return {
406
+ ...camera,
407
+ yaw: normalizeAngle(camera.yaw + camera.autoRotateSpeed * dt / 1e3)
408
+ };
409
+ }
410
+ /** 事件载荷:只暴露用户关心的字段,不外泄内部派生量 */
411
+ function cameraPayload(camera) {
412
+ return {
413
+ yaw: camera.yaw,
414
+ pitch: camera.pitch,
415
+ distance: camera.distance,
416
+ target: camera.target.slice()
417
+ };
418
+ }
419
+ /**
420
+ * 依据场景包围盒反解相机距离,让几何刚好落在视口内(autoFit)
421
+ * 用垂直视场角与水平视场角中较小的那个来约束,窄容器里也不会溢出。
422
+ */
423
+ function fitDistance(bounds, camera, viewport) {
424
+ if (!bounds || !viewport || !(viewport.width > 0) || !(viewport.height > 0)) return camera.distance;
425
+ const size = v3sub(bounds.max, bounds.min);
426
+ const radius = Math.max(.001, .5 * Math.sqrt(size[0] ** 2 + size[1] ** 2 + size[2] ** 2));
427
+ const fovY = camera.fov * DEG$1;
428
+ const fovX = 2 * Math.atan(Math.tan(fovY / 2) * (viewport.width / viewport.height));
429
+ return clamp(radius / Math.sin(Math.min(fovY, fovX) / 2) * 1.05, camera.minDistance, camera.maxDistance);
430
+ }
431
+ /** 立方体 6 个面(顶点按逆时针缠绕,法线朝外)— 柱体着色与背面剔除依赖缠绕方向 */
432
+ function boxFaces(min, max) {
433
+ const [x0, y0, z0] = min;
434
+ const [x1, y1, z1] = max;
435
+ return {
436
+ bottom: [
437
+ [
438
+ x0,
439
+ y0,
440
+ z0
441
+ ],
442
+ [
443
+ x0,
444
+ y1,
445
+ z0
446
+ ],
447
+ [
448
+ x1,
449
+ y1,
450
+ z0
451
+ ],
452
+ [
453
+ x1,
454
+ y0,
455
+ z0
456
+ ]
457
+ ],
458
+ top: [
459
+ [
460
+ x0,
461
+ y0,
462
+ z1
463
+ ],
464
+ [
465
+ x1,
466
+ y0,
467
+ z1
468
+ ],
469
+ [
470
+ x1,
471
+ y1,
472
+ z1
473
+ ],
474
+ [
475
+ x0,
476
+ y1,
477
+ z1
478
+ ]
479
+ ],
480
+ front: [
481
+ [
482
+ x0,
483
+ y0,
484
+ z0
485
+ ],
486
+ [
487
+ x1,
488
+ y0,
489
+ z0
490
+ ],
491
+ [
492
+ x1,
493
+ y0,
494
+ z1
495
+ ],
496
+ [
497
+ x0,
498
+ y0,
499
+ z1
500
+ ]
501
+ ],
502
+ back: [
503
+ [
504
+ x1,
505
+ y1,
506
+ z0
507
+ ],
508
+ [
509
+ x0,
510
+ y1,
511
+ z0
512
+ ],
513
+ [
514
+ x0,
515
+ y1,
516
+ z1
517
+ ],
518
+ [
519
+ x1,
520
+ y1,
521
+ z1
522
+ ]
523
+ ],
524
+ left: [
525
+ [
526
+ x0,
527
+ y1,
528
+ z0
529
+ ],
530
+ [
531
+ x0,
532
+ y0,
533
+ z0
534
+ ],
535
+ [
536
+ x0,
537
+ y0,
538
+ z1
539
+ ],
540
+ [
541
+ x0,
542
+ y1,
543
+ z1
544
+ ]
545
+ ],
546
+ right: [
547
+ [
548
+ x1,
549
+ y0,
550
+ z0
551
+ ],
552
+ [
553
+ x1,
554
+ y1,
555
+ z0
556
+ ],
557
+ [
558
+ x1,
559
+ y1,
560
+ z1
561
+ ],
562
+ [
563
+ x1,
564
+ y0,
565
+ z1
566
+ ]
567
+ ]
568
+ };
569
+ }
570
+ //#endregion
571
+ //#region src/3d/core/tween.js
572
+ /**
573
+ * 数据补间 — 结构不变、仅数值变化时逐帧插值,避免整图重放进场动画
574
+ *
575
+ * 覆盖四种数据形态:series[].data、pieData[].value、scatterData 三元组、surfaceData.z(或 matrix)。
576
+ * 结构(图型 / 类目 / 系列名 / 数组形状)任何一处变了就不再补间,返回 null 由调用方回退到进场动画。
577
+ * 补间按类目序错峰(stagger > 0 时),与进场动画的错峰同向,数据更新看起来是一道波推过去。
578
+ */
579
+ /** 取数值(非有限值记 null,插值时空缺侧原样保留) */
580
+ function toNum(v) {
581
+ const n = Number(v);
582
+ return Number.isFinite(n) ? n : null;
583
+ }
584
+ function mapDeep(matrix) {
585
+ return Array.isArray(matrix) ? matrix.map((row) => Array.isArray(row) ? row.map(toNum) : []) : null;
586
+ }
587
+ /** 数据快照 — 只留参与补间的数值字段,避免把 options 整棵拷下来 */
588
+ function captureChart3dData(options) {
589
+ const o = options && typeof options === "object" ? options : {};
590
+ const sd = o.surfaceData && typeof o.surfaceData === "object" ? o.surfaceData : null;
591
+ return {
592
+ type: o.type,
593
+ labels: Array.isArray(o.labels) ? o.labels.map(String) : [],
594
+ series: (Array.isArray(o.series) ? o.series : []).map((s) => ({
595
+ name: s && s.name !== void 0 ? String(s.name) : "",
596
+ data: Array.isArray(s && s.data) ? s.data.map(toNum) : []
597
+ })),
598
+ pieData: (Array.isArray(o.pieData) ? o.pieData : []).map((d) => ({
599
+ name: d && d.name !== void 0 ? String(d.name) : "",
600
+ value: toNum(d && d.value)
601
+ })),
602
+ scatter: Array.isArray(o.scatterData) ? o.scatterData.map((p) => Array.isArray(p) ? p.map(toNum) : [toNum(p)]) : null,
603
+ surface: sd ? mapDeep(sd.z) : mapDeep(o.matrix),
604
+ surfaceKey: Array.isArray(sd && sd.z) ? "surfaceData" : Array.isArray(o.matrix) ? "matrix" : null
605
+ };
606
+ }
607
+ function sameList(a, b) {
608
+ return a.length === b.length && a.every((v, i) => v === b[i]);
609
+ }
610
+ /** 二维矩阵只比形状(数值可以不同,正是补间要抹平的差异) */
611
+ function sameDims(a, b) {
612
+ if (a === null || b === null) return a === b;
613
+ if (a.length !== b.length) return false;
614
+ return a.every((row, i) => Array.isArray(row) && Array.isArray(b[i]) && row.length === b[i].length);
615
+ }
616
+ /** 结构是否一致(一致才允许补间) */
617
+ function sameChart3dShape(a, b) {
618
+ if (!a || !b || a.type !== b.type) return false;
619
+ if (!sameList(a.labels, b.labels)) return false;
620
+ if (a.series.length !== b.series.length) return false;
621
+ if (!a.series.every((s, i) => s.name === b.series[i].name && s.data.length === b.series[i].data.length)) return false;
622
+ if (a.pieData.length !== b.pieData.length) return false;
623
+ if (!a.pieData.every((d, i) => d.name === b.pieData[i].name)) return false;
624
+ const sa = a.scatter;
625
+ const sb = b.scatter;
626
+ if (sa === null !== (sb === null)) return false;
627
+ if (sa && sb && (sa.length !== sb.length || !sa.every((p, i) => p.length === sb[i].length))) return false;
628
+ return a.surfaceKey === b.surfaceKey && sameDims(a.surface, b.surface);
629
+ }
630
+ function lerp(from, to, t) {
631
+ if (from === null || to === null) return to;
632
+ return from + (to - from) * t;
633
+ }
634
+ /**
635
+ * 逐帧插值出「中间态 options」——以目标 options 为骨架,只替换数值字段
636
+ * @param {object} from captureChart3dData 的快照
637
+ * @param {object} to 目标 options(props.options)
638
+ * @param {number} progress 全局进度(0..1,通常已按 easing 缓动过)
639
+ * @param {number} stagger 错峰占比(沿类目序)
640
+ */
641
+ function interpolateChart3dOptions(from, to, progress, stagger = 0) {
642
+ if (!sameChart3dShape(from, captureChart3dData(to))) return null;
643
+ const cols = Math.max(1, from.labels.length);
644
+ const itemT = (i) => staggerProgress(progress, i, cols, stagger);
645
+ const out = { ...to };
646
+ if (Array.isArray(to.series)) out.series = to.series.map((s, si) => {
647
+ const old = from.series[si];
648
+ return {
649
+ ...s,
650
+ data: (Array.isArray(s.data) ? s.data : []).map((v, i) => lerp(old.data[i], toNum(v), itemT(i)))
651
+ };
652
+ });
653
+ if (Array.isArray(to.pieData)) out.pieData = to.pieData.map((d, i) => {
654
+ const old = from.pieData[i];
655
+ return {
656
+ ...d,
657
+ value: lerp(old.value, toNum(d && d.value), itemT(i))
658
+ };
659
+ });
660
+ if (Array.isArray(to.scatterData) && from.scatter) out.scatterData = to.scatterData.map((p, i) => {
661
+ const old = from.scatter[i];
662
+ const t = itemT(i);
663
+ return (Array.isArray(p) ? p : [p]).map((v, k) => lerp(old[k], toNum(v), t));
664
+ });
665
+ if (from.surface && from.surfaceKey) {
666
+ const target = from.surfaceKey === "surfaceData" ? to.surfaceData : to.matrix;
667
+ const z = (Array.isArray(target && target.z) ? target.z : Array.isArray(target) ? target : []).map((row, r) => {
668
+ const oldRow = from.surface[r] || [];
669
+ const t = itemT(r);
670
+ return (Array.isArray(row) ? row : []).map((v, c) => lerp(oldRow[c], toNum(v), t));
671
+ });
672
+ out[from.surfaceKey] = from.surfaceKey === "surfaceData" ? {
673
+ ...to.surfaceData,
674
+ z
675
+ } : z;
676
+ }
677
+ return out;
678
+ }
679
+ /** 默认光照:跟随相机的肩后主光(左上前方),保证任意视角都有稳定的立体感 */
680
+ var DEFAULT_LIGHT = {
681
+ ambient: .56,
682
+ intensity: .88,
683
+ follow: true,
684
+ /** 相机相对光照方向(right, up, back 三分量) */
685
+ offset: [
686
+ -.45,
687
+ .6,
688
+ .66
689
+ ]
690
+ };
691
+ function createScene() {
692
+ return {
693
+ items: [],
694
+ order: 0
695
+ };
696
+ }
697
+ function push(scene, item) {
698
+ item.order = scene.order++;
699
+ scene.items.push(item);
700
+ return item;
701
+ }
702
+ /**
703
+ * 面图元
704
+ * @param {Array<Array<number>>} points 世界坐标顶点,逆时针缠绕时法线朝外
705
+ */
706
+ function addFace(scene, points, options = {}) {
707
+ if (!Array.isArray(points) || points.length < 3) return null;
708
+ return push(scene, {
709
+ kind: "face",
710
+ layer: options.layer || "data",
711
+ points,
712
+ color: options.color || "#888888",
713
+ alpha: options.alpha,
714
+ stroke: options.stroke,
715
+ strokeWidth: options.strokeWidth,
716
+ /** 关闭光照,用于需要绝对准确色值的面(如语义色标) */
717
+ flat: !!options.flat,
718
+ /** 允许剔除背向相机的面;仅在顶点缠绕方向可信时开启 */
719
+ cull: !!options.cull,
720
+ /** 双面可见:曲面、带状面必须开,否则从下方看会整片消失 */
721
+ doubleSided: options.doubleSided !== false ? true : false,
722
+ /** 实体面:柱体/饼体这类有体积的面,纵深增强(描边、面内渐变)只作用于它们 */
723
+ solid: !!options.solid,
724
+ /** 细分曲面面(饼体外弧面等):光照明暗已足够,不加面内渐变、不描细分缝 */
725
+ curved: !!options.curved,
726
+ pickable: options.pickable !== false,
727
+ meta: options.meta || null,
728
+ visible: true,
729
+ depth: 0,
730
+ screen: null,
731
+ normal: null,
732
+ fill: options.color || "#888888"
733
+ });
734
+ }
735
+ /** 线图元(折线;closed 时首尾相连) */
736
+ function addLine(scene, points, options = {}) {
737
+ if (!Array.isArray(points) || points.length < 2) return null;
738
+ return push(scene, {
739
+ kind: "line",
740
+ layer: options.layer || "data",
741
+ points,
742
+ color: options.color || "#888888",
743
+ alpha: options.alpha,
744
+ lineWidth: Number.isFinite(options.lineWidth) ? options.lineWidth : 1.5,
745
+ dash: options.dash || null,
746
+ closed: !!options.closed,
747
+ smooth: !!options.smooth,
748
+ cull: false,
749
+ pickable: options.pickable !== false,
750
+ tolerance: Number.isFinite(options.tolerance) ? options.tolerance : 6,
751
+ meta: options.meta || null,
752
+ visible: true,
753
+ depth: 0,
754
+ screen: null
755
+ });
756
+ }
757
+ /** 点图元 */
758
+ function addPoint(scene, position, options = {}) {
759
+ return push(scene, {
760
+ kind: "point",
761
+ layer: options.layer || "data",
762
+ position,
763
+ color: options.color || "#888888",
764
+ alpha: options.alpha,
765
+ radius: Number.isFinite(options.radius) ? options.radius : 4,
766
+ /** 拾取半径(像素):细点/细线需要比视觉更大的命中目标 */
767
+ pickRadius: Number.isFinite(options.pickRadius) ? options.pickRadius : null,
768
+ symbol: options.symbol || "circle",
769
+ stroke: options.stroke,
770
+ strokeWidth: options.strokeWidth,
771
+ /** 按视深缩放点半径(透视感),关闭后所有点等大 */
772
+ depthScale: !!options.depthScale,
773
+ pickable: options.pickable !== false,
774
+ meta: options.meta || null,
775
+ visible: true,
776
+ depth: 0,
777
+ screen: null
778
+ });
779
+ }
780
+ /** 文字图元 — 位置锚定在三维空间,但绘制时永远正向、永远在最上层 */
781
+ function addText(scene, position, text, options = {}) {
782
+ if (text === null || text === void 0 || text === "") return null;
783
+ return push(scene, {
784
+ kind: "text",
785
+ layer: options.layer || "front",
786
+ position,
787
+ text: String(text),
788
+ color: options.color || "#666666",
789
+ alpha: options.alpha,
790
+ fontSize: Number.isFinite(options.fontSize) ? options.fontSize : 11,
791
+ fontWeight: options.fontWeight || "normal",
792
+ fontFamily: options.fontFamily || null,
793
+ align: options.align || "center",
794
+ baseline: options.baseline || "middle",
795
+ /** 文字底衬(保证压在网格上也可读) */
796
+ background: options.background || null,
797
+ /** 相对锚点的屏幕像素偏移,用于刻度标签外推 */
798
+ offset: Array.isArray(options.offset) ? options.offset : [0, 0],
799
+ pickable: false,
800
+ meta: options.meta || null,
801
+ visible: true,
802
+ depth: 0,
803
+ screen: null
804
+ });
805
+ }
806
+ /** 立方体六面 — 顶点缠绕已在 camera.boxFaces 中校验为法线朝外,故开启背面剔除 */
807
+ function addBox(scene, faces, options = {}) {
808
+ const out = [];
809
+ for (const key of Object.keys(faces)) {
810
+ const f = addFace(scene, faces[key], {
811
+ ...options,
812
+ cull: options.cull !== false
813
+ });
814
+ if (f) out.push(f);
815
+ }
816
+ return out;
817
+ }
818
+ /** 世界空间线段(网格、轴线、投影辅助线共用) */
819
+ function addSegment(scene, a, b, options = {}) {
820
+ return addLine(scene, [a, b], options);
821
+ }
822
+ /**
823
+ * 解析光照方向 — 返回指向光源的单位向量
824
+ * follow=true 时按相机基向量构造,视角旋转时光影跟着转,形体永远处于受光面;
825
+ * 显式给 direction 时固定在世界上,旋转物体能看到明暗变化(更"物理",但可能转到暗面)。
826
+ */
827
+ function resolveLight(lighting, basis) {
828
+ const cfg = {
829
+ ...DEFAULT_LIGHT,
830
+ ...lighting && typeof lighting === "object" ? lighting : {}
831
+ };
832
+ let dir;
833
+ if (Array.isArray(cfg.direction) && cfg.direction.length >= 3 && v3len(cfg.direction) > 1e-9) dir = v3norm([
834
+ cfg.direction[0],
835
+ cfg.direction[1],
836
+ cfg.direction[2]
837
+ ]);
838
+ else {
839
+ const off = Array.isArray(cfg.offset) && cfg.offset.length >= 3 ? cfg.offset : DEFAULT_LIGHT.offset;
840
+ dir = v3norm(v3add(v3add(v3scale(basis.right, off[0]), v3scale(basis.up, off[1])), v3scale(basis.forward, -off[2])));
841
+ }
842
+ return {
843
+ direction: dir,
844
+ ambient: Number.isFinite(cfg.ambient) ? cfg.ambient : DEFAULT_LIGHT.ambient,
845
+ intensity: Number.isFinite(cfg.intensity) ? cfg.intensity : DEFAULT_LIGHT.intensity,
846
+ flat: !!cfg.flat
847
+ };
848
+ }
849
+ /**
850
+ * 投影整个场景
851
+ * @param {{items:Array}} scene
852
+ * @param {object} camera 已 resolve 的相机
853
+ * @param {{x:number,y:number,width:number,height:number}} viewport 绘图区
854
+ * @param {{lighting?:object, mvp?:Array, eye?:Array, basis?:object}} options
855
+ * @returns {{items:Array, mvp:Array, eye:Array, light:object, dropped:number}}
856
+ */
857
+ function projectScene(scene, camera, viewport, options = {}) {
858
+ const { mvp, eye } = options.mvp && options.eye ? {
859
+ mvp: options.mvp,
860
+ eye: options.eye
861
+ } : cameraMatrices(camera, viewport);
862
+ const basis = options.basis || {
863
+ eye,
864
+ right: [
865
+ 1,
866
+ 0,
867
+ 0
868
+ ],
869
+ up: [
870
+ 0,
871
+ 1,
872
+ 0
873
+ ],
874
+ forward: [
875
+ 0,
876
+ 0,
877
+ -1
878
+ ]
879
+ };
880
+ const light = resolveLight(options.lighting, basis);
881
+ const shade = options.shading !== "flat" && !light.flat;
882
+ const items = [];
883
+ let dropped = 0;
884
+ for (const item of scene.items) {
885
+ if (item.visible === false) {
886
+ dropped++;
887
+ continue;
888
+ }
889
+ if (item.kind === "text" || item.kind === "point") {
890
+ const p = projectPoint(mvp, viewport, item.position);
891
+ if (!p.visible) {
892
+ dropped++;
893
+ continue;
894
+ }
895
+ const out = {
896
+ ...item,
897
+ screen: {
898
+ x: p.x,
899
+ y: p.y
900
+ },
901
+ depth: p.depth
902
+ };
903
+ if (item.kind === "point") out.radius = item.depthScale ? item.radius * Math.max(.35, Math.min(2.4, 3.6 / Math.max(.35, p.depth))) : item.radius;
904
+ items.push(out);
905
+ continue;
906
+ }
907
+ const screen = [];
908
+ let sumDepth = 0;
909
+ let ok = true;
910
+ for (const p of item.points) {
911
+ const proj = projectPoint(mvp, viewport, p);
912
+ if (!proj.visible) {
913
+ ok = false;
914
+ break;
915
+ }
916
+ screen.push([proj.x, proj.y]);
917
+ sumDepth += proj.depth;
918
+ }
919
+ if (!ok || !screen.length) {
920
+ dropped++;
921
+ continue;
922
+ }
923
+ const depth = sumDepth / screen.length;
924
+ const out = {
925
+ ...item,
926
+ screen,
927
+ depth
928
+ };
929
+ if (item.kind === "face") {
930
+ const normal = polygonNormal(item.points);
931
+ const centroid = polygonCentroid(item.points);
932
+ out.normal = normal;
933
+ out.centroid = centroid;
934
+ const toEye = v3norm(v3sub(eye, centroid));
935
+ out.facing = normal[0] * toEye[0] + normal[1] * toEye[1] + normal[2] * toEye[2];
936
+ if (item.cull && out.facing <= 0) {
937
+ out.visible = false;
938
+ items.push(out);
939
+ continue;
940
+ }
941
+ if (shade) {
942
+ const lambert = normal[0] * light.direction[0] + normal[1] * light.direction[1] + normal[2] * light.direction[2];
943
+ const effective = out.facing < 0 ? -lambert : lambert;
944
+ out.fill = shadeColor(item.color, effective, light);
945
+ } else out.fill = item.color;
946
+ }
947
+ items.push(out);
948
+ }
949
+ if (options.depth && typeof options.depth === "object") applyDepthCues(items, options.depth);
950
+ return {
951
+ items,
952
+ mvp,
953
+ eye,
954
+ light,
955
+ dropped
956
+ };
957
+ }
958
+ /**
959
+ * 纵深增强 — 投影后按视深与「实体面」标记施加三道立体感线索
960
+ * haze 按数据层视深把颜色向背景色混合(近深远浅的空气透视),文字不参与;
961
+ * edge 给实体面补一圈同色深描边,形体边界更利落、相邻形体不糊在一起;
962
+ * gradient 实体面面内顶亮底暗(假 AO),面片更「实心」。
963
+ * 雾化基准取数据层视深范围而非全场景:坐标框/墙体会把范围拉长,压掉数据自身的纵深差。
964
+ */
965
+ function applyDepthCues(items, depth) {
966
+ const clamp01 = (v) => Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0;
967
+ const haze = clamp01(depth.haze);
968
+ const edge = clamp01(depth.edge);
969
+ const gradient = clamp01(depth.gradient);
970
+ if (haze > 0 && depth.hazeColor) {
971
+ let minD = Infinity;
972
+ let maxD = -Infinity;
973
+ for (const it of items) {
974
+ if (it.visible === false || it.kind === "text" || it.layer !== "data") continue;
975
+ if (it.depth < minD) minD = it.depth;
976
+ if (it.depth > maxD) maxD = it.depth;
977
+ }
978
+ const span = maxD - minD;
979
+ for (const it of items) {
980
+ if (it.visible === false || it.kind === "text") continue;
981
+ const t = span > 1e-6 ? (it.depth - minD) / span : 0;
982
+ if (!(t > 0)) continue;
983
+ const k = haze * Math.min(1, t);
984
+ if (it.kind === "face") it.fill = mixHue(it.fill, depth.hazeColor, k);
985
+ else it.color = mixHue(it.color, depth.hazeColor, k);
986
+ }
987
+ }
988
+ for (const it of items) {
989
+ if (it.visible === false || it.kind !== "face" || !it.solid || it.curved) continue;
990
+ if (edge > 0 && !it.stroke) it.stroke = darken(it.fill, edge);
991
+ if (gradient > 0) it.gradient = gradient;
992
+ }
993
+ }
994
+ /** 深度排序 — 远 → 近(画家算法);深度相同时保持建景顺序,避免共面闪烁 */
995
+ function sortByDepth(items) {
996
+ return items.slice().sort((a, b) => b.depth - a.depth || a.order - b.order);
997
+ }
998
+ /** 按层切分:back 与 front 保持建景顺序,只有 data 层参与深度排序 */
999
+ function partitionByLayer(items) {
1000
+ const back = [];
1001
+ const data = [];
1002
+ const front = [];
1003
+ for (const item of items) {
1004
+ if (item.visible === false) continue;
1005
+ if (item.layer === "back") back.push(item);
1006
+ else if (item.layer === "front") front.push(item);
1007
+ else data.push(item);
1008
+ }
1009
+ return {
1010
+ back,
1011
+ data: sortByDepth(data),
1012
+ front
1013
+ };
1014
+ }
1015
+ /** 射线法判断点是否在多边形内(屏幕坐标) */
1016
+ function pointInPolygon(poly, x, y) {
1017
+ if (!Array.isArray(poly) || poly.length < 3) return false;
1018
+ let inside = false;
1019
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
1020
+ const xi = poly[i][0];
1021
+ const yi = poly[i][1];
1022
+ const xj = poly[j][0];
1023
+ const yj = poly[j][1];
1024
+ if (yi === yj) continue;
1025
+ if (yi > y !== yj > y && x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside;
1026
+ }
1027
+ return inside;
1028
+ }
1029
+ /** 点到线段的距离 */
1030
+ function distanceToSegment(px, py, ax, ay, bx, by) {
1031
+ const dx = bx - ax;
1032
+ const dy = by - ay;
1033
+ const lenSq = dx * dx + dy * dy;
1034
+ if (lenSq < 1e-12) return Math.hypot(px - ax, py - ay);
1035
+ let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
1036
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
1037
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
1038
+ }
1039
+ /** 折线到点的最近距离 */
1040
+ function distanceToPolyline(screen, x, y) {
1041
+ let best = Infinity;
1042
+ for (let i = 0; i < screen.length - 1; i++) {
1043
+ const d = distanceToSegment(x, y, screen[i][0], screen[i][1], screen[i + 1][0], screen[i + 1][1]);
1044
+ if (d < best) best = d;
1045
+ }
1046
+ return best;
1047
+ }
1048
+ /**
1049
+ * 拾取 — 返回离相机最近(视深最小)的命中图元
1050
+ *
1051
+ * 命中优先级刻意做成「先按视深取最近」而非「先看类型」:
1052
+ * 柱林里前排柱子的顶面与后排柱子的侧面在屏幕上可能重叠,
1053
+ * 只有视深能给出符合直觉的答案(挡住你的那个)。
1054
+ *
1055
+ * @returns {{meta:any, item:object, distance:number}|null}
1056
+ */
1057
+ function pickScene(projected, x, y, options = {}) {
1058
+ if (!projected || !Array.isArray(projected.items)) return null;
1059
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
1060
+ const tolerance = Number.isFinite(options.tolerance) ? options.tolerance : 6;
1061
+ let best = null;
1062
+ for (const item of projected.items) {
1063
+ if (item.visible === false || !item.pickable || !item.meta) continue;
1064
+ let distance = Infinity;
1065
+ if (item.kind === "face") {
1066
+ if (!item.screen || !pointInPolygon(item.screen, x, y)) continue;
1067
+ distance = 0;
1068
+ } else if (item.kind === "line") {
1069
+ if (!item.screen || item.screen.length < 2) continue;
1070
+ const d = distanceToPolyline(item.screen, x, y);
1071
+ if (d > tolerance + (Number.isFinite(item.tolerance) ? item.tolerance : 0)) continue;
1072
+ distance = d;
1073
+ } else if (item.kind === "point") {
1074
+ if (!item.screen) continue;
1075
+ const d = Math.hypot(x - item.screen.x, y - item.screen.y);
1076
+ if (d > (Number.isFinite(item.pickRadius) ? item.pickRadius : item.radius) + tolerance) continue;
1077
+ distance = d;
1078
+ } else continue;
1079
+ if (!best || item.depth < best.item.depth - 1e-6 || Math.abs(item.depth - best.item.depth) <= 1e-6 && distance < best.distance) best = {
1080
+ meta: item.meta,
1081
+ item,
1082
+ distance
1083
+ };
1084
+ }
1085
+ return best;
1086
+ }
1087
+ //#endregion
1088
+ //#region src/3d/renderer/draw.js
1089
+ /**
1090
+ * 绘制层 — 把投影后的图元画到 Canvas 2D 上
1091
+ *
1092
+ * 三趟绘制顺序(见 scene.partitionByLayer):
1093
+ * back 网格 / 包围框 / 墙体 —— 建景顺序,不排序(永远垫底)
1094
+ * data 数据几何 —— 视深远 → 近(画家算法)
1095
+ * front 轴线 / 刻度 / 标签 —— 建景顺序,永远压顶
1096
+ *
1097
+ * 远侧的坐标框棱线与 Z 轴走 back 层:它们位于数据后方,压在顶层会「透」在前排柱体上;
1098
+ * 近侧棱线与轴线只有在相机确实落在该棱面外侧时才压顶(见 axes3d 的取边判据),
1099
+ * 中轴视角下它只是条侧棱,同样垫底。
1100
+ *
1101
+ * hover 强调走「描边 + 提亮」而非压暗其余图元:三维场景里图元互相遮挡,
1102
+ * 全局压暗会同时压到被遮挡者,视觉归属反而混乱。
1103
+ */
1104
+ /** 估算文本宽度(CJK 全宽按字号计,其余按 0.62 折算),与 evoke-charts 同口径 */
1105
+ function estimateTextWidth(text, fontSize = 12) {
1106
+ const s = String(text ?? "");
1107
+ let w = 0;
1108
+ for (const ch of s) w += ch.charCodeAt(0) > 255 ? fontSize : fontSize * .62;
1109
+ return w;
1110
+ }
1111
+ function roundRectPath(ctx, x, y, width, height, radius) {
1112
+ const r = Math.max(0, Math.min(radius, Math.abs(width) / 2, Math.abs(height) / 2));
1113
+ ctx.beginPath();
1114
+ ctx.moveTo(x + r, y);
1115
+ ctx.lineTo(x + width - r, y);
1116
+ ctx.quadraticCurveTo(x + width, y, x + width, y + r);
1117
+ ctx.lineTo(x + width, y + height - r);
1118
+ ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
1119
+ ctx.lineTo(x + r, y + height);
1120
+ ctx.quadraticCurveTo(x, y + height, x, y + height - r);
1121
+ ctx.lineTo(x, y + r);
1122
+ ctx.quadraticCurveTo(x, y, x + r, y);
1123
+ ctx.closePath();
1124
+ }
1125
+ function applyAlpha(ctx, item, base) {
1126
+ const a = Number.isFinite(item.alpha) ? item.alpha : base;
1127
+ ctx.globalAlpha = Math.max(0, Math.min(1, a));
1128
+ }
1129
+ function resetAlpha(ctx) {
1130
+ ctx.globalAlpha = 1;
1131
+ }
1132
+ function fontString(item, theme) {
1133
+ const family = item.fontFamily || theme.fontFamily || "sans-serif";
1134
+ return `${item.fontWeight === "bold" ? "600" : "400"} ${item.fontSize}px ${family}`;
1135
+ }
1136
+ /** 面填充色:实体面带面内渐变(顶亮底暗的假 AO),其余纯色 */
1137
+ function faceFill(ctx, item, emphasized) {
1138
+ const base = emphasized ? lighten(item.fill, .08) : item.fill;
1139
+ const g = Number.isFinite(item.gradient) ? item.gradient : 0;
1140
+ if (!(g > 0) || !item.screen || item.screen.length < 3) return base;
1141
+ let top = Infinity;
1142
+ let bottom = -Infinity;
1143
+ for (const p of item.screen) {
1144
+ if (p[1] < top) top = p[1];
1145
+ if (p[1] > bottom) bottom = p[1];
1146
+ }
1147
+ if (!(bottom - top > 1)) return base;
1148
+ const grad = ctx.createLinearGradient(0, top, 0, bottom);
1149
+ grad.addColorStop(0, lighten(base, g));
1150
+ grad.addColorStop(1, darken(base, g));
1151
+ return grad;
1152
+ }
1153
+ function drawFace(ctx, item, emphasized) {
1154
+ if (!item.screen || item.screen.length < 3) return;
1155
+ ctx.beginPath();
1156
+ ctx.moveTo(item.screen[0][0], item.screen[0][1]);
1157
+ for (let i = 1; i < item.screen.length; i++) ctx.lineTo(item.screen[i][0], item.screen[i][1]);
1158
+ ctx.closePath();
1159
+ ctx.fillStyle = faceFill(ctx, item, emphasized);
1160
+ applyAlpha(ctx, item, 1);
1161
+ ctx.fill();
1162
+ if (item.stroke || emphasized) {
1163
+ ctx.strokeStyle = item.stroke || lighten(item.fill, -.02);
1164
+ ctx.lineWidth = emphasized ? 1.6 : Number.isFinite(item.strokeWidth) ? item.strokeWidth : 1;
1165
+ ctx.setLineDash([]);
1166
+ ctx.stroke();
1167
+ } else if (item.curved) {
1168
+ ctx.strokeStyle = typeof item.fill === "string" ? item.fill : item.color;
1169
+ ctx.lineWidth = 1;
1170
+ ctx.setLineDash([]);
1171
+ ctx.stroke();
1172
+ }
1173
+ resetAlpha(ctx);
1174
+ }
1175
+ function drawLine(ctx, item, emphasized) {
1176
+ if (!item.screen || item.screen.length < 2) return;
1177
+ ctx.beginPath();
1178
+ ctx.moveTo(item.screen[0][0], item.screen[0][1]);
1179
+ for (let i = 1; i < item.screen.length; i++) ctx.lineTo(item.screen[i][0], item.screen[i][1]);
1180
+ if (item.closed) ctx.closePath();
1181
+ ctx.strokeStyle = item.color;
1182
+ ctx.lineWidth = emphasized ? item.lineWidth * 1.4 : item.lineWidth;
1183
+ ctx.lineJoin = "round";
1184
+ ctx.lineCap = "round";
1185
+ ctx.setLineDash(Array.isArray(item.dash) ? item.dash : []);
1186
+ applyAlpha(ctx, item, 1);
1187
+ ctx.stroke();
1188
+ ctx.setLineDash([]);
1189
+ resetAlpha(ctx);
1190
+ }
1191
+ function drawPoint(ctx, item, emphasized) {
1192
+ if (!item.screen) return;
1193
+ const r = emphasized ? item.radius * 1.25 : item.radius;
1194
+ const { x, y } = item.screen;
1195
+ ctx.beginPath();
1196
+ if (item.symbol === "rect") ctx.rect(x - r, y - r, r * 2, r * 2);
1197
+ else if (item.symbol === "diamond") {
1198
+ ctx.moveTo(x, y - r);
1199
+ ctx.lineTo(x + r, y);
1200
+ ctx.lineTo(x, y + r);
1201
+ ctx.lineTo(x - r, y);
1202
+ ctx.closePath();
1203
+ } else if (item.symbol === "triangle") {
1204
+ ctx.moveTo(x, y - r);
1205
+ ctx.lineTo(x + r * .87, y + r * .5);
1206
+ ctx.lineTo(x - r * .87, y + r * .5);
1207
+ ctx.closePath();
1208
+ } else ctx.arc(x, y, r, 0, Math.PI * 2);
1209
+ ctx.fillStyle = item.color;
1210
+ applyAlpha(ctx, item, 1);
1211
+ ctx.fill();
1212
+ if (item.stroke || emphasized) {
1213
+ ctx.strokeStyle = item.stroke || "#ffffff";
1214
+ ctx.lineWidth = emphasized ? 2 : Number.isFinite(item.strokeWidth) ? item.strokeWidth : 1;
1215
+ ctx.setLineDash([]);
1216
+ ctx.stroke();
1217
+ }
1218
+ resetAlpha(ctx);
1219
+ }
1220
+ function drawText(ctx, item, theme, emphasized) {
1221
+ if (!item.screen) return;
1222
+ const x = item.screen.x + (item.offset ? item.offset[0] : 0);
1223
+ const y = item.screen.y + (item.offset ? item.offset[1] : 0);
1224
+ ctx.font = fontString(item, theme);
1225
+ ctx.textAlign = item.align || "center";
1226
+ ctx.textBaseline = item.baseline || "middle";
1227
+ ctx.setLineDash([]);
1228
+ if (item.background) {
1229
+ const w = estimateTextWidth(item.text, item.fontSize) + 8;
1230
+ const h = item.fontSize + 6;
1231
+ ctx.fillStyle = item.background;
1232
+ roundRectPath(ctx, x - w / 2, y - h / 2, w, h, 3);
1233
+ applyAlpha(ctx, item, 1);
1234
+ ctx.fill();
1235
+ resetAlpha(ctx);
1236
+ }
1237
+ ctx.fillStyle = emphasized ? lighten(item.color, .25) : item.color;
1238
+ applyAlpha(ctx, item, 1);
1239
+ ctx.fillText(item.text, x, y);
1240
+ resetAlpha(ctx);
1241
+ }
1242
+ function drawItem(ctx, item, emphasized, theme) {
1243
+ if (item.visible === false) return;
1244
+ if (item.kind === "face") drawFace(ctx, item, emphasized);
1245
+ else if (item.kind === "line") drawLine(ctx, item, emphasized);
1246
+ else if (item.kind === "point") drawPoint(ctx, item, emphasized);
1247
+ else if (item.kind === "text") drawText(ctx, item, theme, emphasized);
1248
+ }
1249
+ /**
1250
+ * 绘制整个投影结果
1251
+ * @param {CanvasRenderingContext2D} ctx
1252
+ * @param {object} projected projectScene 的返回值
1253
+ * @param {{hoverKey?:string|null, theme?:object}} options
1254
+ */
1255
+ function drawScene(ctx, projected, options = {}) {
1256
+ const theme = options.theme || {};
1257
+ const hoverKey = options.hoverKey || null;
1258
+ const { back, data, front } = partitionByLayer(projected.items);
1259
+ for (const item of back) drawItem(ctx, item, false, theme);
1260
+ for (const item of data) {
1261
+ const key = item.meta && item.meta.key;
1262
+ drawItem(ctx, item, !!key && key === hoverKey, theme);
1263
+ }
1264
+ for (const item of front) drawItem(ctx, item, false, theme);
1265
+ }
1266
+ /** 背景清屏 — 画布复用帧间必须显式擦除,否则残影叠在半透明面上 */
1267
+ function clearCanvas(ctx, width, height, color) {
1268
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1269
+ ctx.clearRect(0, 0, width, height);
1270
+ if (color) {
1271
+ ctx.fillStyle = color;
1272
+ ctx.fillRect(0, 0, width, height);
1273
+ }
1274
+ }
1275
+ //#endregion
1276
+ //#region src/3d/renderer/shared.js
1277
+ function seriesName(s, i) {
1278
+ if (s && s.name !== void 0 && s.name !== null && String(s.name).length) return String(s.name);
1279
+ return `系列 ${i + 1}`;
1280
+ }
1281
+ /**
1282
+ * 归一化 series:补名 / 定色 / 标隐藏,剔除隐藏系列
1283
+ * @returns {Array<{__index:number, name:string, data:Array, color:string, raw:object}>}
1284
+ */
1285
+ function normalizeSeries(options, theme, hiddenSeries) {
1286
+ const list = Array.isArray(options && options.series) ? options.series : [];
1287
+ const colors = Array.isArray(theme.colors) && theme.colors.length ? theme.colors : ["#888888"];
1288
+ const out = [];
1289
+ for (let i = 0; i < list.length; i++) {
1290
+ const s = list[i] && typeof list[i] === "object" ? list[i] : {};
1291
+ const name = seriesName(s, i);
1292
+ if (hiddenSeries && hiddenSeries.has(name)) continue;
1293
+ out.push({
1294
+ __index: i,
1295
+ name,
1296
+ data: Array.isArray(s.data) ? s.data : [],
1297
+ color: typeof s.color === "string" && s.color ? s.color : colors[i % colors.length],
1298
+ raw: s
1299
+ });
1300
+ }
1301
+ return out;
1302
+ }
1303
+ /** 饼族数据归一化:剔除非法值、补名、定色、算占比 */
1304
+ function normalizePie(options, theme, hiddenSeries) {
1305
+ const raw = Array.isArray(options && options.pieData) ? options.pieData : [];
1306
+ const colors = Array.isArray(theme.colors) && theme.colors.length ? theme.colors : ["#888888"];
1307
+ const items = [];
1308
+ for (let i = 0; i < raw.length; i++) {
1309
+ const d = raw[i] && typeof raw[i] === "object" ? raw[i] : { value: raw[i] };
1310
+ const value = Number(d.value);
1311
+ if (!Number.isFinite(value)) continue;
1312
+ const name = d.name !== void 0 && d.name !== null && String(d.name).length ? String(d.name) : `分项 ${i + 1}`;
1313
+ if (hiddenSeries && hiddenSeries.has(name)) continue;
1314
+ items.push({
1315
+ __index: i,
1316
+ name,
1317
+ value,
1318
+ color: typeof d.color === "string" && d.color ? d.color : colors[i % colors.length],
1319
+ raw: d
1320
+ });
1321
+ }
1322
+ const total = items.reduce((s, d) => s + Math.abs(d.value), 0);
1323
+ for (const d of items) d.percent = total > 0 ? Math.abs(d.value) / total : 0;
1324
+ return {
1325
+ items,
1326
+ total
1327
+ };
1328
+ }
1329
+ /** 曲面矩阵归一化:支持 {x,y,z} 与裸 matrix 两种入参 */
1330
+ function normalizeSurface(options) {
1331
+ const sd = options && typeof options.surfaceData === "object" && options.surfaceData ? options.surfaceData : null;
1332
+ const matrix = sd && Array.isArray(sd.z) ? sd.z : Array.isArray(options && options.matrix) ? options.matrix : [];
1333
+ const rows = matrix.length;
1334
+ let cols = 0;
1335
+ for (const row of matrix) if (Array.isArray(row)) cols = Math.max(cols, row.length);
1336
+ const xValues = sd && Array.isArray(sd.x) && sd.x.length === cols ? sd.x.map((v) => Number(v)) : Array.from({ length: cols }, (_, i) => i);
1337
+ const yValues = sd && Array.isArray(sd.y) && sd.y.length === rows ? sd.y.map((v) => Number(v)) : Array.from({ length: rows }, (_, i) => i);
1338
+ const values = [];
1339
+ for (const row of matrix) {
1340
+ if (!Array.isArray(row)) continue;
1341
+ for (const v of row) {
1342
+ const n = Number(v);
1343
+ if (Number.isFinite(n)) values.push(n);
1344
+ }
1345
+ }
1346
+ return {
1347
+ matrix,
1348
+ rows,
1349
+ cols,
1350
+ xValues,
1351
+ yValues,
1352
+ values
1353
+ };
1354
+ }
1355
+ /**
1356
+ * 纵深增强默认值 — 立体感三件套,0 即关闭
1357
+ * haze 景深雾化(远景向背景色混合)/ edge 实体面描边 / gradient 面内渐变(假 AO)
1358
+ */
1359
+ var DEPTH_DEFAULTS = {
1360
+ haze: .3,
1361
+ edge: .06,
1362
+ gradient: .08
1363
+ };
1364
+ /** 进场错峰占比默认值 — 0 即全部同步(柱林/扇区依次生长的幅度按条目数自动摊开) */
1365
+ var STAGGER_DEFAULT = .25;
1366
+ /** 解析 animation.stagger(0..1),缺省用 STAGGER_DEFAULT */
1367
+ function resolveStagger(options) {
1368
+ const cfg = options && typeof options.animation === "object" ? options.animation : {};
1369
+ return Number.isFinite(cfg.stagger) ? Math.max(0, Math.min(1, cfg.stagger)) : STAGGER_DEFAULT;
1370
+ }
1371
+ /** 解析 depth 配置,缺省即 DEPTH_DEFAULTS,越界钳到 [0,1] */
1372
+ function resolveDepth(options) {
1373
+ const cfg = options && typeof options.depth === "object" ? options.depth : {};
1374
+ const pick = (v, fallback) => Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : fallback;
1375
+ return {
1376
+ haze: pick(cfg.haze, DEPTH_DEFAULTS.haze),
1377
+ edge: pick(cfg.edge, DEPTH_DEFAULTS.edge),
1378
+ gradient: pick(cfg.gradient, DEPTH_DEFAULTS.gradient)
1379
+ };
1380
+ }
1381
+ /** HTML 转义 — tooltip 默认模板必须转义,formatter 返回值由使用方自负 */
1382
+ function escapeHtml(s) {
1383
+ return String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1384
+ }
1385
+ /** 图例项 — 按图型取名字清单 */
1386
+ function computeLegendItems(options, theme, hiddenSeries) {
1387
+ const type = options && options.type;
1388
+ if (type === "surface3d") return [];
1389
+ if (type === "pie3d") {
1390
+ const { items } = normalizePie(options, theme, hiddenSeries);
1391
+ return items.map((d) => ({
1392
+ name: d.name,
1393
+ color: d.color,
1394
+ hidden: false
1395
+ }));
1396
+ }
1397
+ normalizeSeries(options, theme, hiddenSeries);
1398
+ const all = Array.isArray(options && options.series) ? options.series : [];
1399
+ const colors = Array.isArray(theme.colors) && theme.colors.length ? theme.colors : ["#888888"];
1400
+ return all.map((s, i) => {
1401
+ const name = s && s.name !== void 0 && s.name !== null && String(s.name).length ? String(s.name) : `系列 ${i + 1}`;
1402
+ return {
1403
+ name,
1404
+ color: s && typeof s.color === "string" && s.color || colors[i % colors.length],
1405
+ hidden: hiddenSeries ? hiddenSeries.has(name) : false
1406
+ };
1407
+ });
1408
+ }
1409
+ /**
1410
+ * 图例布局
1411
+ * @returns {{items:Array, rows:number, height:number, y:number}}
1412
+ */
1413
+ function computeLegendLayout(items, canvasWidth, options, topOffset = 0, bottomOffset = 0) {
1414
+ const cfg = options && options.legend && typeof options.legend === "object" ? options.legend : {};
1415
+ if (cfg.show === false || !items.length) return {
1416
+ items: [],
1417
+ rows: 0,
1418
+ height: 0,
1419
+ y: 0
1420
+ };
1421
+ const gap = Number.isFinite(cfg.itemGap) ? cfg.itemGap : 18;
1422
+ const padding = 12;
1423
+ const avail = Math.max(80, canvasWidth - 24);
1424
+ const measured = items.map((it) => {
1425
+ const textWidth = estimateTextWidth(it.name, 12);
1426
+ return {
1427
+ ...it,
1428
+ width: 16 + textWidth
1429
+ };
1430
+ });
1431
+ const rows = [];
1432
+ let row = [];
1433
+ let rowWidth = 0;
1434
+ for (const it of measured) {
1435
+ const add = row.length ? gap + it.width : it.width;
1436
+ if (row.length && rowWidth + add > avail) {
1437
+ rows.push({
1438
+ items: row,
1439
+ width: rowWidth
1440
+ });
1441
+ row = [];
1442
+ rowWidth = 0;
1443
+ }
1444
+ const inc = row.length ? gap + it.width : it.width;
1445
+ row.push(it);
1446
+ rowWidth += inc;
1447
+ }
1448
+ if (row.length) rows.push({
1449
+ items: row,
1450
+ width: rowWidth
1451
+ });
1452
+ const align = cfg.align || "center";
1453
+ const height = rows.length * 22 + (rows.length ? 6 : 0);
1454
+ const position = cfg.position === "bottom" ? "bottom" : "top";
1455
+ const y = position === "top" ? topOffset + 2 : Math.max(topOffset + 2, bottomOffset - height - 2);
1456
+ let index = 0;
1457
+ const laid = [];
1458
+ for (const r of rows) {
1459
+ let x = align === "start" ? padding : align === "end" ? canvasWidth - padding - r.width : (canvasWidth - r.width) / 2;
1460
+ for (const it of r.items) {
1461
+ laid.push({
1462
+ ...it,
1463
+ x,
1464
+ y: y + index * 22,
1465
+ width: it.width,
1466
+ height: 16
1467
+ });
1468
+ x += it.width + gap;
1469
+ }
1470
+ index++;
1471
+ }
1472
+ return {
1473
+ items: laid,
1474
+ rows: rows.length,
1475
+ height,
1476
+ y,
1477
+ position
1478
+ };
1479
+ }
1480
+ /** 命中图例项 */
1481
+ function hitLegend(layout, x, y) {
1482
+ if (!layout || !Array.isArray(layout.items)) return null;
1483
+ for (const it of layout.items) if (x >= it.x && x <= it.x + it.width && y >= it.y && y <= it.y + it.height + 6) return it;
1484
+ return null;
1485
+ }
1486
+ /** 绘制图例(swatch + 文本;隐藏项半透明) */
1487
+ function drawLegend(ctx, layout, theme) {
1488
+ if (!layout || !layout.items.length) return;
1489
+ ctx.save();
1490
+ ctx.font = `400 12px ${theme.fontFamily || "sans-serif"}`;
1491
+ ctx.textBaseline = "middle";
1492
+ ctx.textAlign = "left";
1493
+ for (const it of layout.items) {
1494
+ ctx.globalAlpha = it.hidden ? .35 : 1;
1495
+ ctx.fillStyle = it.color;
1496
+ const sy = it.y + it.height / 2 - 5;
1497
+ ctx.beginPath();
1498
+ const r = 2;
1499
+ const sx = it.x;
1500
+ ctx.moveTo(sx + r, sy);
1501
+ ctx.lineTo(sx + 10 - r, sy);
1502
+ ctx.quadraticCurveTo(sx + 10, sy, sx + 10, sy + r);
1503
+ ctx.lineTo(sx + 10, sy + 10 - r);
1504
+ ctx.quadraticCurveTo(sx + 10, sy + 10, sx + 10 - r, sy + 10);
1505
+ ctx.lineTo(sx + r, sy + 10);
1506
+ ctx.quadraticCurveTo(sx, sy + 10, sx, sy + 10 - r);
1507
+ ctx.lineTo(sx, sy + r);
1508
+ ctx.quadraticCurveTo(sx, sy, sx + r, sy);
1509
+ ctx.closePath();
1510
+ ctx.fill();
1511
+ ctx.fillStyle = theme.textColor;
1512
+ ctx.fillText(it.name, it.x + 10 + 6, it.y + it.height / 2 + 1);
1513
+ }
1514
+ ctx.restore();
1515
+ }
1516
+ /**
1517
+ * 连续色标(surface / scatter colorScale 用)
1518
+ * 离散色阶取样而非 canvas 渐变 —— 录制 SVG 时纯色矩形可以无损还原
1519
+ */
1520
+ function drawColorScale(ctx, spec, theme, canvasWidth, viewport) {
1521
+ if (!spec || !Array.isArray(spec.colors) || spec.colors.length < 2) return;
1522
+ const height = Math.max(60, Math.min(150, viewport.height * .44));
1523
+ const width = 10;
1524
+ const x = canvasWidth - 52;
1525
+ const y = viewport.y + Math.max(0, (viewport.height - height) / 2);
1526
+ const steps = spec.colors.length * 4;
1527
+ const fmt = (v) => {
1528
+ const abs = Math.abs(v);
1529
+ if (abs >= 1e3) return `${Math.round(v / 100) / 10}k`;
1530
+ if (abs >= 10) return String(Math.round(v));
1531
+ return String(Math.round(v * 100) / 100);
1532
+ };
1533
+ ctx.save();
1534
+ ctx.globalAlpha = .92;
1535
+ for (let i = 0; i < steps; i++) {
1536
+ const t = 1 - i / (steps - 1);
1537
+ ctx.fillStyle = gradientAt(spec.colors, t);
1538
+ ctx.fillRect(x, y + i * height / steps, width, height / steps + .6);
1539
+ }
1540
+ ctx.globalAlpha = 1;
1541
+ ctx.strokeStyle = theme.borderColor;
1542
+ ctx.lineWidth = 1;
1543
+ ctx.strokeRect(x + .5, y + .5, width, height);
1544
+ ctx.font = `400 10.5px ${theme.fontFamily || "sans-serif"}`;
1545
+ ctx.fillStyle = theme.textColorSecondary;
1546
+ ctx.textAlign = "left";
1547
+ ctx.textBaseline = "middle";
1548
+ ctx.fillText(fmt(spec.max), x + width + 6, y + 5);
1549
+ ctx.fillText(fmt(spec.min), x + width + 6, y + height - 4);
1550
+ if (spec.label) ctx.fillText(spec.label, x + width + 6, y + height / 2);
1551
+ ctx.restore();
1552
+ return {
1553
+ x,
1554
+ y,
1555
+ width,
1556
+ height
1557
+ };
1558
+ }
1559
+ //#endregion
1560
+ //#region src/3d/core/scale3d.js
1561
+ /**
1562
+ * 刻度与映射 — nice 刻度生成、线性/带状映射、数值格式化
1563
+ *
1564
+ * 刻度算法与 evoke-charts 保持同一口径(1 / 2 / 5 / 10 阶梯),
1565
+ * 这样同一个数据集在二维图与三维图里落到的刻度值是一致的。
1566
+ */
1567
+ /** 过滤出可参与统计的有限数值 */
1568
+ function finiteValues(values) {
1569
+ const out = [];
1570
+ for (const v of values || []) if (Number.isFinite(v)) out.push(v);
1571
+ return out;
1572
+ }
1573
+ /**
1574
+ * 数值域
1575
+ * @param {boolean} includeZero 柱状语义必须从 0 起算,否则柱高失真
1576
+ */
1577
+ function extentOf(values, options = {}) {
1578
+ const nums = finiteValues(values);
1579
+ if (!nums.length) return [0, 1];
1580
+ let min = Math.min(...nums);
1581
+ let max = Math.max(...nums);
1582
+ if (options.includeZero) {
1583
+ min = Math.min(0, min);
1584
+ max = Math.max(0, max);
1585
+ }
1586
+ if (min === max) {
1587
+ if (min === 0) return [0, 1];
1588
+ const pad = Math.abs(min) * .5;
1589
+ return [min - pad, max + pad];
1590
+ }
1591
+ const headroom = Number.isFinite(options.headroom) ? options.headroom : 0;
1592
+ if (headroom > 0) {
1593
+ const span = max - min;
1594
+ min -= span * headroom;
1595
+ max += span * headroom;
1596
+ }
1597
+ return [min, max];
1598
+ }
1599
+ /** 取整到 1 / 2 / 5 / 10 阶梯 */
1600
+ function niceNumber(range, round) {
1601
+ if (!(range > 0) || !Number.isFinite(range)) return 1;
1602
+ const pow = 10 ** Math.floor(Math.log10(range));
1603
+ const f = range / pow;
1604
+ let nf;
1605
+ if (round) {
1606
+ if (f < 1.5) nf = 1;
1607
+ else if (f < 3) nf = 2;
1608
+ else if (f < 7) nf = 5;
1609
+ else nf = 10;
1610
+ } else if (f <= 1) nf = 1;
1611
+ else if (f <= 2) nf = 2;
1612
+ else if (f <= 5) nf = 5;
1613
+ else nf = 10;
1614
+ return nf * pow;
1615
+ }
1616
+ /**
1617
+ * nice 刻度 — 返回覆盖数据域且落在整步长上的刻度序列
1618
+ * @returns {{min:number, max:number, step:number, values:number[]}}
1619
+ */
1620
+ function niceTicks(min, max, count = 5) {
1621
+ const target = Number.isFinite(count) ? Math.max(2, Math.min(20, Math.round(count))) : 5;
1622
+ let lo = Number.isFinite(min) ? min : 0;
1623
+ let hi = Number.isFinite(max) ? max : 1;
1624
+ if (lo > hi) [lo, hi] = [hi, lo];
1625
+ if (lo === hi) {
1626
+ const pad = Math.abs(lo) * .5 || 1;
1627
+ lo -= pad;
1628
+ hi += pad;
1629
+ }
1630
+ const step = niceNumber(niceNumber(hi - lo, false) / (target - 1), true);
1631
+ const niceMin = Math.floor(lo / step) * step;
1632
+ const niceMax = Math.ceil(hi / step) * step;
1633
+ const span = niceMax - niceMin;
1634
+ const guard = Math.min(400, Math.round(span / step) + 1);
1635
+ const decimals = Math.max(0, Math.min(12, -Math.floor(Math.log10(step))));
1636
+ const values = [];
1637
+ for (let i = 0; i < guard; i++) {
1638
+ const v = niceMin + i * step;
1639
+ if (v > niceMax + step * 1e-9) break;
1640
+ values.push(Number(v.toFixed(decimals)));
1641
+ }
1642
+ if (!values.length) values.push(niceMin, niceMax);
1643
+ return {
1644
+ min: niceMin,
1645
+ max: niceMax,
1646
+ step,
1647
+ values
1648
+ };
1649
+ }
1650
+ /**
1651
+ * 线性映射
1652
+ * domain → range,domain 退化为单点时返回常量映射,避免除零把坐标打成 NaN
1653
+ */
1654
+ function makeLinearScale(domain, range) {
1655
+ const [d0, d1] = domain;
1656
+ const [r0, r1] = range;
1657
+ const span = d1 - d0;
1658
+ const degenerate = Math.abs(span) < 1e-12;
1659
+ const k = degenerate ? 0 : (r1 - r0) / span;
1660
+ return {
1661
+ domain: [d0, d1],
1662
+ range: [r0, r1],
1663
+ degenerate,
1664
+ map: (v) => Number.isFinite(v) ? r0 + (v - d0) * k : r0,
1665
+ invert: (t) => degenerate ? d0 : d0 + (t - r0) / k
1666
+ };
1667
+ }
1668
+ /**
1669
+ * 带状映射 — 类目轴专用,返回类目中心点与可用带宽
1670
+ * paddingRatio 为类目间距占整步长的比例(0 = 紧贴,0.9 = 极稀疏)
1671
+ */
1672
+ function makeBandScale(count, range, paddingRatio = .24) {
1673
+ const n = Math.max(0, Math.floor(count) || 0);
1674
+ const [r0, r1] = range;
1675
+ const span = r1 - r0;
1676
+ const step = n > 0 ? span / n : 0;
1677
+ const bandwidth = step * (1 - Math.max(0, Math.min(.95, paddingRatio)));
1678
+ return {
1679
+ count: n,
1680
+ step,
1681
+ bandwidth,
1682
+ range: [r0, r1],
1683
+ center: (i) => r0 + step * (i + .5),
1684
+ /** 反查:给定坐标落在第几个类目;越界返回 -1 */
1685
+ indexAt: (t) => {
1686
+ if (!(step > 0)) return -1;
1687
+ const i = Math.floor((t - r0) / step);
1688
+ return i >= 0 && i < n ? i : -1;
1689
+ },
1690
+ start: (i) => r0 + step * i + (step - bandwidth) / 2
1691
+ };
1692
+ }
1693
+ /**
1694
+ * 数值格式化 — 与 evoke-charts 的 formatValue 同口径
1695
+ * @param {{decimals?:number, thousandSeparator?:string|false, prefix?:string, suffix?:string, abbreviate?:boolean}} options
1696
+ */
1697
+ function formatNumber(value, options = {}) {
1698
+ if (value === null || value === void 0 || Number.isNaN(value)) return "-";
1699
+ const { decimals = 0, thousandSeparator = ",", prefix = "", suffix = "", abbreviate = false } = options;
1700
+ const num = Number(value);
1701
+ if (!Number.isFinite(num)) return "-";
1702
+ let body;
1703
+ if (abbreviate) {
1704
+ const abs = Math.abs(num);
1705
+ if (abs >= 1e8) body = `${(num / 1e8).toFixed(2)}亿`;
1706
+ else if (abs >= 1e4) body = `${(num / 1e4).toFixed(2)}万`;
1707
+ else if (abs >= 1e3) body = `${(num / 1e3).toFixed(2)}K`;
1708
+ else body = num.toFixed(decimals);
1709
+ } else {
1710
+ const fixed = num.toFixed(decimals);
1711
+ if (!thousandSeparator) body = fixed;
1712
+ else if (decimals === 0) body = fixed.replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeparator);
1713
+ else {
1714
+ const [intPart, decPart] = fixed.split(".");
1715
+ body = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeparator) + (decPart ? `.${decPart}` : "");
1716
+ }
1717
+ }
1718
+ return `${prefix}${body}${suffix}`;
1719
+ }
1720
+ /** 依据刻度步长推断合适的小数位,供轴标签使用 */
1721
+ function decimalsForStep(step) {
1722
+ if (!(step > 0) || !Number.isFinite(step)) return 0;
1723
+ if (step >= 1) return 0;
1724
+ return Math.min(4, Math.ceil(-Math.log10(step)));
1725
+ }
1726
+ /** 稀疏化刻度:标签过密时按固定步长抽稀,保证首末刻度始终保留 */
1727
+ function thinTicks(values, maxCount) {
1728
+ if (!Array.isArray(values) || values.length <= maxCount) return values;
1729
+ const stride = Math.ceil(values.length / maxCount);
1730
+ const out = values.filter((_, i) => i % stride === 0);
1731
+ const last = values[values.length - 1];
1732
+ if (out[out.length - 1] !== last) out.push(last);
1733
+ return out;
1734
+ }
1735
+ //#endregion
1736
+ //#region src/3d/frame.js
1737
+ /**
1738
+ * 世界标架 — 数据域到三维世界坐标的归一化
1739
+ *
1740
+ * 所有笛卡尔类图型共用一个标架:地面是 1×1 的方格,高度上限 Z_HEIGHT。
1741
+ * 相机参数因此与数据规模解耦 —— 换一套数据不用重新调视角,
1742
+ * 拖拽/缩放的手感在 bar3d / line3d / scatter3d / surface3d 之间完全一致。
1743
+ *
1744
+ * 世界为 Z 轴向上:X 类目、Y 系列(深度)、Z 数值(高度)。
1745
+ */
1746
+ /** 世界地面半宽 */
1747
+ var WORLD_HALF = .5;
1748
+ /** 世界高度(数据最大刻度映射到这里) */
1749
+ var Z_HEIGHT = .62;
1750
+ /** 世界包围盒(无顶:盒体只有地面与两片背墙,柱子顶到上刻度线是自然形态) */
1751
+ function worldBox() {
1752
+ return {
1753
+ min: [
1754
+ -.5,
1755
+ -.5,
1756
+ 0
1757
+ ],
1758
+ max: [
1759
+ WORLD_HALF,
1760
+ WORLD_HALF,
1761
+ Z_HEIGHT
1762
+ ]
1763
+ };
1764
+ }
1765
+ /** 数值轴配置解析(min/max 显式指定优先) */
1766
+ function valueDomain(values, cfg) {
1767
+ const explicitMin = cfg && Number.isFinite(cfg.min) ? cfg.min : null;
1768
+ const explicitMax = cfg && Number.isFinite(cfg.max) ? cfg.max : null;
1769
+ if (explicitMin !== null && explicitMax !== null) return [explicitMin, explicitMax];
1770
+ const [dMin, dMax] = extentOf(values, { includeZero: !!(cfg && cfg.includeZero) });
1771
+ return [explicitMin !== null ? explicitMin : dMin, explicitMax !== null ? explicitMax : dMax];
1772
+ }
1773
+ /**
1774
+ * 类目轴
1775
+ * @returns {{type:'category', labels:string[], scale:object, name:string}}
1776
+ */
1777
+ function categoryAxis(labels, worldRange, cfg = {}) {
1778
+ const list = Array.isArray(labels) ? labels.map((l, i) => l == null ? String(i + 1) : String(l)) : [];
1779
+ return {
1780
+ type: "category",
1781
+ labels: list,
1782
+ scale: makeBandScale(list.length, worldRange, Number.isFinite(cfg.padding) ? cfg.padding : .24),
1783
+ name: cfg.name || "",
1784
+ formatter: typeof cfg.formatter === "function" ? cfg.formatter : null
1785
+ };
1786
+ }
1787
+ /**
1788
+ * 数值轴 — 域对齐到 nice 刻度,世界区间首尾对应刻度域首尾
1789
+ * @returns {{type:'value', scale:object, ticks:{value:number,label:string}[], min:number, max:number}}
1790
+ */
1791
+ function valueAxis(values, worldRange, cfg = {}) {
1792
+ const [dMin, dMax] = valueDomain(values, cfg);
1793
+ const nice = niceTicks(dMin, dMax, Number.isFinite(cfg.ticks) ? cfg.ticks : 5);
1794
+ const scale = makeLinearScale([nice.min, nice.max], worldRange);
1795
+ const decimals = Number.isFinite(cfg.decimals) ? cfg.decimals : decimalsForStep(nice.step);
1796
+ const fmt = typeof cfg.formatter === "function" ? cfg.formatter : (v) => formatNumber(v, {
1797
+ decimals,
1798
+ thousandSeparator: ","
1799
+ });
1800
+ const all = nice.values.map((v) => ({
1801
+ value: v,
1802
+ label: fmt(v)
1803
+ }));
1804
+ return {
1805
+ type: "value",
1806
+ scale,
1807
+ ticks: thinTicks(all, Number.isFinite(cfg.maxTicks) ? cfg.maxTicks : 9),
1808
+ allTicks: all,
1809
+ min: nice.min,
1810
+ max: nice.max,
1811
+ step: nice.step,
1812
+ name: cfg.name || "",
1813
+ formatter: fmt
1814
+ };
1815
+ }
1816
+ /** 组装笛卡尔标架 */
1817
+ function buildFrame(xAxis, yAxis, zAxis, box = worldBox()) {
1818
+ return {
1819
+ x: xAxis,
1820
+ y: yAxis,
1821
+ z: zAxis,
1822
+ box
1823
+ };
1824
+ }
1825
+ /** 判断值是否缺失(缺失值跳过绘制但保留占位语义) */
1826
+ function isMissing(v) {
1827
+ return v === null || v === void 0 || typeof v === "number" && Number.isNaN(v);
1828
+ }
1829
+ //#endregion
1830
+ //#region src/3d/renderer/axes3d.js
1831
+ /**
1832
+ * 三维坐标框 — 地面网格 / 背墙 / 轴线 / 刻度标签
1833
+ *
1834
+ * 关键决策:轴线取边跟随相机,背景面则钉死在固定侧。
1835
+ * · 背景面固定在 +Y(背墙)/ -X(侧墙)——跟随相机会让它在旋转中整面跳边;
1836
+ * 相机绕到墙背面时整组(面 + 网格 + 顶棱)短距离淡出,避免挡在数据前面
1837
+ * · X/Y 轴线与刻度贴在离相机近的那条底边(标签不与数据重叠)
1838
+ * · Z 轴立在离相机最远的立柱上(不遮挡柱体)
1839
+ * 底边棱线与 Z 轴按远近分层:近侧压顶保证轴线清晰,远侧的垫底交还给数据遮挡;
1840
+ * 相机不在该棱面外侧时(中轴视角 / 俯视入盒),近侧一样垫底——压顶的前提是它真的在前方。
1841
+ * 因此任意视角下坐标框都在「衬托」数据而不是「遮挡」数据,
1842
+ * 这是二维轴渲染器照搬不过来、必须重做的一层。
1843
+ *
1844
+ * 标签防碰撞在屏幕空间做:先把刻度锚点投到屏幕,再贪心丢弃与前一个
1845
+ * 标签包围盒重叠的候选。三维里标签间距随视角变化,世界空间的等步长
1846
+ * 抽稀无法保证不叠字。
1847
+ */
1848
+ /** 按 maxCount 求 stride,保证至少 1 */
1849
+ function strideFor$1(count, maxCount) {
1850
+ if (!(count > maxCount) || maxCount < 1) return 1;
1851
+ return Math.ceil(count / maxCount);
1852
+ }
1853
+ /** 背景面淡出跨度(相机方向余弦)——转过约 20° 即完全退场,避免贴墙时突然消失 */
1854
+ var WALL_FADE_SPAN = .35;
1855
+ /** 世界方向 → 屏幕像素方向(用于把标签沿「远离盒体」的方向外推) */
1856
+ function screenDir(mvp, viewport, from, worldDir, pixelDist) {
1857
+ const eps = .03;
1858
+ const a = projectPoint(mvp, viewport, from);
1859
+ const b = projectPoint(mvp, viewport, [
1860
+ from[0] + worldDir[0] * eps,
1861
+ from[1] + worldDir[1] * eps,
1862
+ from[2] + worldDir[2] * eps
1863
+ ]);
1864
+ if (!a.visible || !b.visible) return [0, 0];
1865
+ const dx = b.x - a.x;
1866
+ const dy = b.y - a.y;
1867
+ const len = Math.hypot(dx, dy);
1868
+ if (len < 1e-9) return [0, 0];
1869
+ return [dx / len * pixelDist, dy / len * pixelDist];
1870
+ }
1871
+ function rectsOverlap(a, b, pad) {
1872
+ return a.x0 - pad < b.x1 && a.x1 + pad > b.x0 && a.y0 - pad < b.y1 && a.y1 + pad > b.y0;
1873
+ }
1874
+ /**
1875
+ * 带防碰撞的刻度标签装配
1876
+ * ticks: [{ world:[x,y,z], label:string }];逐个投影,贪心丢弃与前一个保留标签重叠者
1877
+ */
1878
+ function addAxisLabels(scene, ticks, outDir, options) {
1879
+ const { mvp, viewport, color, fontSize = 11, align, pad = 2, dist = 15, anchor } = options;
1880
+ let last = null;
1881
+ let kept = 0;
1882
+ const rects = [];
1883
+ for (const t of ticks) {
1884
+ const base = anchor ? anchor(t.world) : t.world;
1885
+ const off = screenDir(mvp, viewport, base, outDir, dist);
1886
+ const proj = projectPoint(mvp, viewport, base);
1887
+ if (!proj.visible) continue;
1888
+ const lx = proj.x + off[0];
1889
+ const ly = proj.y + off[1];
1890
+ const w = estimateTextWidth(t.label, fontSize);
1891
+ const rect = {
1892
+ x0: lx - (align === "right" ? w : align === "left" ? 0 : w / 2),
1893
+ x1: lx + (align === "right" ? 0 : align === "left" ? w : w / 2),
1894
+ y0: ly - fontSize * .7,
1895
+ y1: ly + fontSize * .7
1896
+ };
1897
+ if (last && rectsOverlap(rect, last, pad)) continue;
1898
+ last = rect;
1899
+ rects.push(rect);
1900
+ addText(scene, base, t.label, {
1901
+ color,
1902
+ layer: "front",
1903
+ fontSize,
1904
+ offset: [off[0], off[1] - 1],
1905
+ align
1906
+ });
1907
+ kept++;
1908
+ }
1909
+ return rects;
1910
+ }
1911
+ /**
1912
+ * 轴名放置 — 沿外推方向逐步升级距离,与已保留的刻度标签碰撞则退避,
1913
+ * 全部撞就放弃(宁可少一个轴名也不叠字)
1914
+ */
1915
+ function addAxisName(scene, base, name, outDir, keptRects, options) {
1916
+ if (!name) return;
1917
+ const { mvp, viewport, color, align = "center", dists = [
1918
+ 38,
1919
+ 54,
1920
+ 70
1921
+ ] } = options;
1922
+ const proj = projectPoint(mvp, viewport, base);
1923
+ if (!proj.visible) return;
1924
+ const w = estimateTextWidth(name, 11);
1925
+ for (const dist of dists) {
1926
+ const off = screenDir(mvp, viewport, base, outDir, dist);
1927
+ const lx = proj.x + off[0];
1928
+ const ly = proj.y + off[1];
1929
+ const rect = {
1930
+ x0: lx - w / 2,
1931
+ x1: lx + w / 2,
1932
+ y0: ly - 9,
1933
+ y1: ly + 9
1934
+ };
1935
+ if (keptRects.some((r) => rectsOverlap(rect, r, 2))) continue;
1936
+ addText(scene, base, name, {
1937
+ color,
1938
+ layer: "front",
1939
+ fontSize: 11,
1940
+ offset: [off[0], off[1]],
1941
+ align
1942
+ });
1943
+ return;
1944
+ }
1945
+ }
1946
+ /**
1947
+ * 装配三维坐标框(往场景里加 back/front 图元)
1948
+ * @param {object} scene 目标场景
1949
+ * @param {object} frame buildFrame 的标架
1950
+ * @param {object} rc 渲染上下文 { theme, options, mvp, eye, viewport }
1951
+ */
1952
+ function buildAxes3d(scene, frame, rc) {
1953
+ const { theme, options, mvp, eye, viewport } = rc;
1954
+ const box = frame.box;
1955
+ const [x0, y0, z0] = box.min;
1956
+ const [x1, y1, z1] = box.max;
1957
+ const cx = (x0 + x1) / 2;
1958
+ const cy = (y0 + y1) / 2;
1959
+ const gridShow = (options.grid && typeof options.grid === "object" ? options.grid : {}).show !== false;
1960
+ const boxCfg = options.box && typeof options.box === "object" ? options.box : {};
1961
+ const boxShow = boxCfg.show !== false;
1962
+ const walls = boxCfg.walls === false ? "none" : boxCfg.walls || "back";
1963
+ const axisCfg = (axis, fallbackName) => {
1964
+ const cfg = axis && typeof axis === "object" ? axis : {};
1965
+ return {
1966
+ show: cfg.show !== false,
1967
+ labels: cfg.labels !== false,
1968
+ grid: cfg.grid !== false,
1969
+ line: cfg.line !== false,
1970
+ name: cfg.name !== void 0 ? cfg.name : fallbackName,
1971
+ titleShow: cfg.title !== void 0 ? !!cfg.title : true
1972
+ };
1973
+ };
1974
+ const xc = axisCfg(options.xAxis, frame.x.name);
1975
+ const yc = axisCfg(options.yAxis, frame.y.name);
1976
+ const zc = axisCfg(options.zAxis, frame.z.name);
1977
+ const nearY = eye[1] > cy ? y1 : y0;
1978
+ const farY = nearY === y1 ? y0 : y1;
1979
+ const nearX = eye[0] > cx ? x1 : x0;
1980
+ const farX = nearX === x1 ? x0 : x1;
1981
+ const nearXFront = nearX === x1 ? eye[0] > x1 : eye[0] < x0;
1982
+ const nearYFront = nearY === y1 ? eye[1] > y1 : eye[1] < y0;
1983
+ const wallY = y1;
1984
+ const wallX = x0;
1985
+ const eyeDir = [
1986
+ eye[0] - cx,
1987
+ eye[1] - cy,
1988
+ eye[2] - (z0 + z1) / 2
1989
+ ];
1990
+ const eyeLen = Math.hypot(eyeDir[0], eyeDir[1], eyeDir[2]) || 1;
1991
+ /** 可见度 0..1:frontCos 为相机相对墙面的正视程度(墙面外法线方向的投影距离/视距)。 */
1992
+ const wallVis = (frontCos) => Math.max(0, Math.min(1, frontCos / WALL_FADE_SPAN));
1993
+ const backWallVis = wallVis((wallY - eye[1]) / eyeLen);
1994
+ const sideWallVis = wallVis((eye[0] - wallX) / eyeLen);
1995
+ const gridColor = theme.gridColor;
1996
+ const wallColor = theme.wallColor;
1997
+ const lineColor = theme.borderColor;
1998
+ const tickColor = theme.textColorSecondary;
1999
+ if (boxShow && walls !== "none" && gridShow) {
2000
+ const wantBack = walls === "all" || walls === "back" || walls === true;
2001
+ const wantSide = walls === "all" || walls === "side";
2002
+ if (wantBack && backWallVis > 0) addFace(scene, [
2003
+ [
2004
+ x0,
2005
+ wallY,
2006
+ z0
2007
+ ],
2008
+ [
2009
+ x1,
2010
+ wallY,
2011
+ z0
2012
+ ],
2013
+ [
2014
+ x1,
2015
+ wallY,
2016
+ z1
2017
+ ],
2018
+ [
2019
+ x0,
2020
+ wallY,
2021
+ z1
2022
+ ]
2023
+ ], {
2024
+ color: wallColor,
2025
+ layer: "back",
2026
+ flat: true,
2027
+ pickable: false,
2028
+ alpha: backWallVis
2029
+ });
2030
+ if (wantSide && sideWallVis > 0) addFace(scene, [
2031
+ [
2032
+ wallX,
2033
+ y0,
2034
+ z0
2035
+ ],
2036
+ [
2037
+ wallX,
2038
+ y1,
2039
+ z0
2040
+ ],
2041
+ [
2042
+ wallX,
2043
+ y1,
2044
+ z1
2045
+ ],
2046
+ [
2047
+ wallX,
2048
+ y0,
2049
+ z1
2050
+ ]
2051
+ ], {
2052
+ color: wallColor,
2053
+ layer: "back",
2054
+ flat: true,
2055
+ pickable: false,
2056
+ alpha: sideWallVis
2057
+ });
2058
+ }
2059
+ if (gridShow && xc.grid && Array.isArray(frame.x.ticks)) {
2060
+ const ticks = frame.x.ticks;
2061
+ const stride = strideFor$1(ticks.length, 30);
2062
+ for (let i = 0; i < ticks.length; i += stride) {
2063
+ const gx = ticks[i].world;
2064
+ addSegment(scene, [
2065
+ gx,
2066
+ y0,
2067
+ z0
2068
+ ], [
2069
+ gx,
2070
+ y1,
2071
+ z0
2072
+ ], {
2073
+ color: gridColor,
2074
+ layer: "back",
2075
+ lineWidth: 1,
2076
+ pickable: false
2077
+ });
2078
+ }
2079
+ }
2080
+ if (gridShow && yc.grid && Array.isArray(frame.y.ticks)) {
2081
+ const ticks = frame.y.ticks;
2082
+ const stride = strideFor$1(ticks.length, 30);
2083
+ for (let i = 0; i < ticks.length; i += stride) {
2084
+ const gy = ticks[i].world;
2085
+ addSegment(scene, [
2086
+ x0,
2087
+ gy,
2088
+ z0
2089
+ ], [
2090
+ x1,
2091
+ gy,
2092
+ z0
2093
+ ], {
2094
+ color: gridColor,
2095
+ layer: "back",
2096
+ lineWidth: 1,
2097
+ pickable: false
2098
+ });
2099
+ }
2100
+ }
2101
+ if (gridShow && zc.grid && Array.isArray(frame.z.ticks) && boxShow && walls !== "none") {
2102
+ const wantBack = walls === "all" || walls === "back" || walls === true;
2103
+ const wantSide = walls === "all" || walls === "side";
2104
+ const backLine = {
2105
+ color: gridColor,
2106
+ layer: "back",
2107
+ lineWidth: 1,
2108
+ pickable: false,
2109
+ alpha: backWallVis
2110
+ };
2111
+ const sideLine = {
2112
+ color: gridColor,
2113
+ layer: "back",
2114
+ lineWidth: 1,
2115
+ pickable: false,
2116
+ alpha: sideWallVis
2117
+ };
2118
+ for (const t of frame.z.ticks) {
2119
+ const gz = t.world;
2120
+ if (wantBack && backWallVis > 0) addSegment(scene, [
2121
+ x0,
2122
+ wallY,
2123
+ gz
2124
+ ], [
2125
+ x1,
2126
+ wallY,
2127
+ gz
2128
+ ], backLine);
2129
+ if (wantSide && sideWallVis > 0) addSegment(scene, [
2130
+ wallX,
2131
+ y0,
2132
+ gz
2133
+ ], [
2134
+ wallX,
2135
+ y1,
2136
+ gz
2137
+ ], sideLine);
2138
+ }
2139
+ if (wantBack && backWallVis > 0 && Array.isArray(frame.x.ticks)) {
2140
+ const stride = strideFor$1(frame.x.ticks.length, 30);
2141
+ for (let i = 0; i < frame.x.ticks.length; i += stride) {
2142
+ const gx = frame.x.ticks[i].world;
2143
+ addSegment(scene, [
2144
+ gx,
2145
+ wallY,
2146
+ z0
2147
+ ], [
2148
+ gx,
2149
+ wallY,
2150
+ z1
2151
+ ], backLine);
2152
+ }
2153
+ }
2154
+ if (wantSide && sideWallVis > 0 && Array.isArray(frame.y.ticks)) {
2155
+ const stride = strideFor$1(frame.y.ticks.length, 30);
2156
+ for (let i = 0; i < frame.y.ticks.length; i += stride) {
2157
+ const gy = frame.y.ticks[i].world;
2158
+ addSegment(scene, [
2159
+ wallX,
2160
+ gy,
2161
+ z0
2162
+ ], [
2163
+ wallX,
2164
+ gy,
2165
+ z1
2166
+ ], sideLine);
2167
+ }
2168
+ }
2169
+ }
2170
+ if (boxShow && boxCfg.frame !== false) {
2171
+ const nearEdges = [{
2172
+ a: [
2173
+ nearX,
2174
+ y0,
2175
+ z0
2176
+ ],
2177
+ b: [
2178
+ nearX,
2179
+ y1,
2180
+ z0
2181
+ ],
2182
+ front: nearXFront
2183
+ }, {
2184
+ a: [
2185
+ x0,
2186
+ nearY,
2187
+ z0
2188
+ ],
2189
+ b: [
2190
+ x1,
2191
+ nearY,
2192
+ z0
2193
+ ],
2194
+ front: nearYFront
2195
+ }];
2196
+ const wallRims = [{
2197
+ a: [
2198
+ wallX,
2199
+ y0,
2200
+ z1
2201
+ ],
2202
+ b: [
2203
+ wallX,
2204
+ y1,
2205
+ z1
2206
+ ],
2207
+ vis: sideWallVis
2208
+ }, {
2209
+ a: [
2210
+ x0,
2211
+ wallY,
2212
+ z1
2213
+ ],
2214
+ b: [
2215
+ x1,
2216
+ wallY,
2217
+ z1
2218
+ ],
2219
+ vis: backWallVis
2220
+ }];
2221
+ const farEdges = [
2222
+ [[
2223
+ farX,
2224
+ y0,
2225
+ z0
2226
+ ], [
2227
+ farX,
2228
+ y1,
2229
+ z0
2230
+ ]],
2231
+ [[
2232
+ x0,
2233
+ farY,
2234
+ z0
2235
+ ], [
2236
+ x1,
2237
+ farY,
2238
+ z0
2239
+ ]],
2240
+ [[
2241
+ farX,
2242
+ farY,
2243
+ z0
2244
+ ], [
2245
+ farX,
2246
+ farY,
2247
+ z1
2248
+ ]]
2249
+ ];
2250
+ for (const { a, b, front } of nearEdges) addSegment(scene, a, b, {
2251
+ color: lineColor,
2252
+ layer: front ? "front" : "back",
2253
+ lineWidth: 1,
2254
+ pickable: false
2255
+ });
2256
+ for (const { a, b, vis } of wallRims) if (vis > 0) addSegment(scene, a, b, {
2257
+ color: lineColor,
2258
+ layer: "back",
2259
+ lineWidth: 1,
2260
+ pickable: false,
2261
+ alpha: vis
2262
+ });
2263
+ for (const [a, b] of farEdges) addSegment(scene, a, b, {
2264
+ color: lineColor,
2265
+ layer: "back",
2266
+ lineWidth: 1,
2267
+ pickable: false
2268
+ });
2269
+ }
2270
+ const labelColor = tickColor;
2271
+ const axisLineColor = darken(theme.textColorSecondary, .1);
2272
+ if (xc.show && Array.isArray(frame.x.ticks)) {
2273
+ if (xc.line) addSegment(scene, [
2274
+ x0,
2275
+ nearY,
2276
+ z0
2277
+ ], [
2278
+ x1,
2279
+ nearY,
2280
+ z0
2281
+ ], {
2282
+ color: axisLineColor,
2283
+ layer: nearYFront ? "front" : "back",
2284
+ lineWidth: 1.2,
2285
+ pickable: false
2286
+ });
2287
+ const outDir = [
2288
+ 0,
2289
+ nearY === y1 ? 1 : -1,
2290
+ 0
2291
+ ];
2292
+ let xRects = [];
2293
+ if (xc.labels) xRects = addAxisLabels(scene, frame.x.ticks, outDir, {
2294
+ mvp,
2295
+ viewport,
2296
+ color: labelColor,
2297
+ anchor: (w) => [
2298
+ w,
2299
+ nearY,
2300
+ z0
2301
+ ]
2302
+ });
2303
+ if (xc.name && xc.titleShow) addAxisName(scene, [
2304
+ (x0 + x1) / 2,
2305
+ nearY,
2306
+ z0
2307
+ ], xc.name, outDir, xRects, {
2308
+ mvp,
2309
+ viewport,
2310
+ color: theme.textColorSecondary
2311
+ });
2312
+ }
2313
+ if (yc.show && Array.isArray(frame.y.ticks)) {
2314
+ if (yc.line) addSegment(scene, [
2315
+ nearX,
2316
+ y0,
2317
+ z0
2318
+ ], [
2319
+ nearX,
2320
+ y1,
2321
+ z0
2322
+ ], {
2323
+ color: axisLineColor,
2324
+ layer: nearXFront ? "front" : "back",
2325
+ lineWidth: 1.2,
2326
+ pickable: false
2327
+ });
2328
+ const outDir = [
2329
+ nearX === x1 ? 1 : -1,
2330
+ 0,
2331
+ 0
2332
+ ];
2333
+ let yRects = [];
2334
+ if (yc.labels) yRects = addAxisLabels(scene, frame.y.ticks, outDir, {
2335
+ mvp,
2336
+ viewport,
2337
+ color: labelColor,
2338
+ dist: 17,
2339
+ align: outDir[0] < 0 ? "right" : "left",
2340
+ anchor: (w) => [
2341
+ nearX,
2342
+ w,
2343
+ z0
2344
+ ]
2345
+ });
2346
+ if (yc.name && yc.titleShow) addAxisName(scene, [
2347
+ nearX,
2348
+ (y0 + y1) / 2,
2349
+ z0
2350
+ ], yc.name, outDir, yRects, {
2351
+ mvp,
2352
+ viewport,
2353
+ color: theme.textColorSecondary,
2354
+ align: outDir[0] < 0 ? "right" : "left",
2355
+ dists: [
2356
+ 42,
2357
+ 58,
2358
+ 74
2359
+ ]
2360
+ });
2361
+ }
2362
+ if (zc.show && Array.isArray(frame.z.ticks)) {
2363
+ if (zc.line) addSegment(scene, [
2364
+ farX,
2365
+ farY,
2366
+ z0
2367
+ ], [
2368
+ farX,
2369
+ farY,
2370
+ z1
2371
+ ], {
2372
+ color: axisLineColor,
2373
+ layer: "back",
2374
+ lineWidth: 1.2,
2375
+ pickable: false
2376
+ });
2377
+ const outDir = [
2378
+ farX === x0 ? -1 : 1,
2379
+ farY === y0 ? -1 : 1,
2380
+ 0
2381
+ ];
2382
+ let zRects = [];
2383
+ if (zc.labels) zRects = addAxisLabels(scene, frame.z.ticks, outDir, {
2384
+ mvp,
2385
+ viewport,
2386
+ color: labelColor,
2387
+ fontSize: 10.5,
2388
+ dist: 18,
2389
+ align: "right",
2390
+ anchor: (w) => [
2391
+ farX,
2392
+ farY,
2393
+ w
2394
+ ]
2395
+ });
2396
+ if (zc.name && zc.titleShow) addAxisName(scene, [
2397
+ farX,
2398
+ farY,
2399
+ (z0 + z1) / 2
2400
+ ], zc.name, outDir, zRects, {
2401
+ mvp,
2402
+ viewport,
2403
+ color: theme.textColorSecondary,
2404
+ align: "right",
2405
+ dists: [
2406
+ 44,
2407
+ 60,
2408
+ 76
2409
+ ]
2410
+ });
2411
+ }
2412
+ }
2413
+ /**
2414
+ * 类目刻度装配 — world 为世界坐标,label 为显示文本
2415
+ * 网格与标签共用一份 ticks,保证线与字严格对齐
2416
+ */
2417
+ function categoryTicks(axis, maxLabels = 16) {
2418
+ const { scale, labels, formatter } = axis;
2419
+ const out = [];
2420
+ if (!scale || !scale.count) return out;
2421
+ const stride = strideFor$1(scale.count, maxLabels);
2422
+ for (let i = 0; i < scale.count; i += stride) {
2423
+ const raw = labels[i] !== void 0 ? labels[i] : String(i + 1);
2424
+ out.push({
2425
+ index: i,
2426
+ world: scale.center(i),
2427
+ label: formatter ? formatter(raw, i) : raw
2428
+ });
2429
+ }
2430
+ return out;
2431
+ }
2432
+ /** 数值刻度装配 — 刻度值直接映射到世界坐标 */
2433
+ function valueTicks(axis) {
2434
+ if (!axis || !Array.isArray(axis.ticks)) return [];
2435
+ return axis.ticks.map((t) => ({
2436
+ value: t.value,
2437
+ world: axis.scale.map(t.value),
2438
+ label: t.label
2439
+ }));
2440
+ }
2441
+ //#endregion
2442
+ //#region src/3d/renderer/charts/bar3d.js
2443
+ /**
2444
+ * 三维柱状图 — 类目 × 系列 的柱林
2445
+ *
2446
+ * X 类目、Y 系列(进深)、Z 数值。柱体是封闭长方体:顶面受主光最亮、
2447
+ * 侧面随法线背光渐暗;柱体标记为 solid,纵深增强(描边 / 面内渐变 / 远景雾化)
2448
+ * 只作用于实体面,斜切面与曲面不参与。
2449
+ * 缺失值跳过(不占位、不出零高柱),零值柱保留(高度为 0 时仍画顶面封口,
2450
+ * 避免出现「看穿地面」的破面)。
2451
+ */
2452
+ function clamp01(v) {
2453
+ return Number.isFinite(v) ? Math.max(.02, Math.min(1, v)) : .62;
2454
+ }
2455
+ function buildBar3dScene(rc) {
2456
+ const { options, theme, hiddenSeries, progress } = rc;
2457
+ const scene = createScene();
2458
+ const labels = Array.isArray(options.labels) ? options.labels.map(String) : [];
2459
+ const series = normalizeSeries(options, theme, hiddenSeries);
2460
+ const values = [];
2461
+ for (const s of series) for (const v of s.data) {
2462
+ const n = Number(v);
2463
+ if (Number.isFinite(n)) values.push(n);
2464
+ }
2465
+ const bar = options.bar && typeof options.bar === "object" ? options.bar : {};
2466
+ const frame = buildFrame(categoryAxis(labels, [-WORLD_HALF, WORLD_HALF], {
2467
+ ...options.xAxis || {},
2468
+ padding: .24
2469
+ }), categoryAxis(series.map((s) => s.name), [-WORLD_HALF, WORLD_HALF], {
2470
+ ...options.yAxis || {},
2471
+ padding: .24
2472
+ }), valueAxis(values, [0, Z_HEIGHT], {
2473
+ includeZero: true,
2474
+ ...options.zAxis || {}
2475
+ }));
2476
+ frame.x.ticks = categoryTicks(frame.x);
2477
+ frame.y.ticks = categoryTicks(frame.y);
2478
+ frame.z.ticks = valueTicks(frame.z);
2479
+ buildAxes3d(scene, frame, rc);
2480
+ const xBand = frame.x.scale.bandwidth || .1;
2481
+ const yBand = frame.y.scale.bandwidth || .1;
2482
+ const w = xBand * clamp01(bar.width);
2483
+ const d = yBand * clamp01(bar.depth);
2484
+ const shadow = bar.shadow !== false;
2485
+ const shadowStrength = Number.isFinite(bar.shadowStrength) ? Math.max(0, Math.min(1, bar.shadowStrength)) : .1;
2486
+ const shadowOffset = Array.isArray(bar.shadowOffset) ? [Number(bar.shadowOffset[0]) || 0, Number(bar.shadowOffset[1]) || 0] : [0, 0];
2487
+ const stagger = resolveStagger(options);
2488
+ const cols = frame.x.scale.count;
2489
+ series.forEach((s, si) => {
2490
+ const y0 = frame.y.scale.start(s.__index) + (yBand - d) / 2;
2491
+ const y1 = y0 + d;
2492
+ for (let i = 0; i < frame.x.scale.count; i++) {
2493
+ const v = Number(s.data[i]);
2494
+ if (isMissing(v)) continue;
2495
+ const p = staggerProgress(progress, i, cols, stagger);
2496
+ const x0 = frame.x.scale.start(i) + (xBand - w) / 2;
2497
+ const x1 = x0 + w;
2498
+ const h = Math.max(4e-4, frame.z.scale.map(v) * p);
2499
+ if (shadow && shadowStrength > 0) {
2500
+ const [ox, oy] = shadowOffset;
2501
+ addFace(scene, [
2502
+ [
2503
+ x0 - d * .12 + ox,
2504
+ y0 - d * .12 + oy,
2505
+ .0012
2506
+ ],
2507
+ [
2508
+ x1 + d * .12 + ox,
2509
+ y0 - d * .12 + oy,
2510
+ .0012
2511
+ ],
2512
+ [
2513
+ x1 + d * .12 + ox,
2514
+ y1 + d * .12 + oy,
2515
+ .0012
2516
+ ],
2517
+ [
2518
+ x0 - d * .12 + ox,
2519
+ y1 + d * .12 + oy,
2520
+ .0012
2521
+ ]
2522
+ ], {
2523
+ color: "#000000",
2524
+ alpha: shadowStrength * p,
2525
+ layer: "back",
2526
+ flat: true,
2527
+ pickable: false
2528
+ });
2529
+ }
2530
+ addBox(scene, boxFaces([
2531
+ x0,
2532
+ y0,
2533
+ 0
2534
+ ], [
2535
+ x1,
2536
+ y1,
2537
+ h
2538
+ ]), {
2539
+ color: s.color,
2540
+ cull: true,
2541
+ solid: true,
2542
+ meta: {
2543
+ key: `bar:${s.__index}:${i}`,
2544
+ type: "bar3d",
2545
+ seriesName: s.name,
2546
+ seriesIndex: s.__index,
2547
+ dataIndex: i,
2548
+ name: labels[i] !== void 0 ? labels[i] : String(i + 1),
2549
+ value: v,
2550
+ color: s.color
2551
+ }
2552
+ });
2553
+ }
2554
+ });
2555
+ return {
2556
+ scene,
2557
+ frame
2558
+ };
2559
+ }
2560
+ //#endregion
2561
+ //#region src/3d/renderer/charts/line3d.js
2562
+ /**
2563
+ * 三维折线图 — 空间折线 + 落地投影
2564
+ *
2565
+ * 每条系列是一条悬浮在各自进深层上的空间折线;向下落到地面的虚线投影
2566
+ * 与地面投影线解决「一眼看不出高度」的读数问题 —— 三维折线没有共同的
2567
+ * 基线参照,投影参照线不是装饰而是必需的读数辅助。
2568
+ */
2569
+ function buildLine3dScene(rc) {
2570
+ const { options, theme, hiddenSeries, progress } = rc;
2571
+ const scene = createScene();
2572
+ const labels = Array.isArray(options.labels) ? options.labels.map(String) : [];
2573
+ const series = normalizeSeries(options, theme, hiddenSeries);
2574
+ const values = [];
2575
+ for (const s of series) for (const v of s.data) {
2576
+ const n = Number(v);
2577
+ if (Number.isFinite(n)) values.push(n);
2578
+ }
2579
+ const frame = buildFrame(categoryAxis(labels, [-WORLD_HALF, WORLD_HALF], {
2580
+ ...options.xAxis || {},
2581
+ padding: .24
2582
+ }), categoryAxis(series.map((s) => s.name), [-WORLD_HALF, WORLD_HALF], {
2583
+ ...options.yAxis || {},
2584
+ padding: .24
2585
+ }), valueAxis(values, [0, Z_HEIGHT], {
2586
+ includeZero: true,
2587
+ ...options.zAxis || {}
2588
+ }));
2589
+ frame.x.ticks = categoryTicks(frame.x);
2590
+ frame.y.ticks = categoryTicks(frame.y);
2591
+ frame.z.ticks = valueTicks(frame.z);
2592
+ buildAxes3d(scene, frame, rc);
2593
+ const cfg = options.line && typeof options.line === "object" ? options.line : {};
2594
+ const area = !!cfg.area;
2595
+ const dropLines = cfg.dropLines !== false;
2596
+ const lineWidth = Number.isFinite(cfg.width) ? cfg.width : 2.4;
2597
+ const pointRadius = Number.isFinite(cfg.pointRadius) ? cfg.pointRadius : 3.6;
2598
+ const showPoints = cfg.points !== false;
2599
+ const stagger = resolveStagger(options);
2600
+ const cols = frame.x.scale.count;
2601
+ series.forEach((s) => {
2602
+ const y = frame.y.scale.center(s.__index);
2603
+ const pts = [];
2604
+ for (let i = 0; i < frame.x.scale.count; i++) {
2605
+ const v = Number(s.data[i]);
2606
+ if (isMissing(v)) continue;
2607
+ const p = staggerProgress(progress, i, cols, stagger);
2608
+ pts.push({
2609
+ i,
2610
+ v,
2611
+ prog: p,
2612
+ world: [
2613
+ frame.x.scale.center(i),
2614
+ y,
2615
+ frame.z.scale.map(v) * p
2616
+ ]
2617
+ });
2618
+ }
2619
+ if (!pts.length) return;
2620
+ if (dropLines) {
2621
+ for (const p of pts) addSegment(scene, p.world, [
2622
+ p.world[0],
2623
+ p.world[1],
2624
+ .0015
2625
+ ], {
2626
+ color: toRgba(s.color, .28 * p.prog),
2627
+ layer: "back",
2628
+ lineWidth: 1,
2629
+ dash: [3, 4],
2630
+ pickable: false
2631
+ });
2632
+ addLine(scene, pts.map((p) => [
2633
+ p.world[0],
2634
+ p.world[1],
2635
+ .0015
2636
+ ]), {
2637
+ color: toRgba(s.color, .22),
2638
+ layer: "back",
2639
+ lineWidth: 1.2,
2640
+ pickable: false
2641
+ });
2642
+ }
2643
+ if (area) for (let k = 0; k < pts.length - 1; k++) {
2644
+ const a = pts[k].world;
2645
+ const b = pts[k + 1].world;
2646
+ addFace(scene, [
2647
+ [
2648
+ a[0],
2649
+ a[1],
2650
+ a[2]
2651
+ ],
2652
+ [
2653
+ b[0],
2654
+ b[1],
2655
+ b[2]
2656
+ ],
2657
+ [
2658
+ b[0],
2659
+ b[1],
2660
+ .002
2661
+ ],
2662
+ [
2663
+ a[0],
2664
+ a[1],
2665
+ .002
2666
+ ]
2667
+ ], {
2668
+ color: s.color,
2669
+ alpha: .2 * progress,
2670
+ layer: "back",
2671
+ flat: true,
2672
+ doubleSided: true,
2673
+ pickable: false
2674
+ });
2675
+ }
2676
+ addLine(scene, pts.map((p) => p.world), {
2677
+ color: s.color,
2678
+ lineWidth,
2679
+ alpha: progress < 1 ? Math.min(1, .25 + progress) : 1,
2680
+ meta: {
2681
+ key: `line:${s.__index}`,
2682
+ type: "line3d",
2683
+ seriesName: s.name,
2684
+ seriesIndex: s.__index,
2685
+ color: s.color,
2686
+ name: s.name,
2687
+ value: null,
2688
+ dataIndex: -1
2689
+ },
2690
+ pickable: false
2691
+ });
2692
+ if (showPoints) pts.forEach((p, k) => {
2693
+ addPoint(scene, p.world, {
2694
+ color: s.color,
2695
+ radius: pointRadius,
2696
+ pickRadius: Math.max(9, pointRadius * 2.4),
2697
+ stroke: theme.isDark ? "rgba(0,0,0,0.35)" : "#ffffff",
2698
+ strokeWidth: 1.2,
2699
+ alpha: Math.min(1, .3 + p.prog),
2700
+ meta: {
2701
+ key: `line:${s.__index}:${p.i}`,
2702
+ type: "line3d",
2703
+ seriesName: s.name,
2704
+ seriesIndex: s.__index,
2705
+ dataIndex: p.i,
2706
+ name: labels[p.i] !== void 0 ? labels[p.i] : String(p.i + 1),
2707
+ value: p.v,
2708
+ color: s.color
2709
+ }
2710
+ });
2711
+ });
2712
+ });
2713
+ return {
2714
+ scene,
2715
+ frame
2716
+ };
2717
+ }
2718
+ //#endregion
2719
+ //#region src/3d/renderer/charts/scatter3d.js
2720
+ /**
2721
+ * 三维散点图 — 空间点云
2722
+ *
2723
+ * 两种数据形态自动识别:
2724
+ * · 三元组模式 scatterData / series[].data 为 [x,y,z] —— 三轴全数值
2725
+ * · 类目模式 labels + series[].data 为标量 —— X 类目、Y 系列、Z 数值
2726
+ *
2727
+ * colorScale 开启后把 Z 值映射到连续色带上(第四维信息编码),
2728
+ * 图例区相应换成色标。
2729
+ */
2730
+ function isTriple(v) {
2731
+ return Array.isArray(v) && v.length >= 3 && Number.isFinite(Number(v[0])) && Number.isFinite(Number(v[2]));
2732
+ }
2733
+ function buildScatter3dScene(rc) {
2734
+ const { options, theme, hiddenSeries, progress } = rc;
2735
+ const scene = createScene();
2736
+ const labels = Array.isArray(options.labels) ? options.labels.map(String) : [];
2737
+ const series = normalizeSeries(options, theme, hiddenSeries);
2738
+ const freeData = Array.isArray(options.scatterData) ? options.scatterData.filter(isTriple) : [];
2739
+ const tripleMode = freeData.length > 0 || series.some((s) => s.data.some(isTriple));
2740
+ const cfg = options.scatter && typeof options.scatter === "object" ? options.scatter : {};
2741
+ const baseSize = Number.isFinite(cfg.size) ? cfg.size : 5;
2742
+ const dropLines = cfg.dropLines !== false;
2743
+ const sizeAttenuation = cfg.depthScale !== false;
2744
+ const stagger = resolveStagger(options);
2745
+ let ramp = null;
2746
+ let zDomain = null;
2747
+ let colorScaleOn = false;
2748
+ if (cfg.colorScale) {
2749
+ const rampId = typeof cfg.colorScale === "string" ? cfg.colorScale : "classic";
2750
+ ramp = resolveChartRamp(rampId, theme.isDark) || resolveChartRamp("classic", theme.isDark);
2751
+ colorScaleOn = !!ramp;
2752
+ }
2753
+ let frame;
2754
+ const points = [];
2755
+ if (tripleMode) {
2756
+ const triples = [];
2757
+ series.forEach((s) => {
2758
+ for (const raw of s.data) {
2759
+ if (!isTriple(raw)) continue;
2760
+ triples.push({
2761
+ s,
2762
+ v: [
2763
+ Number(raw[0]),
2764
+ Number(raw[1]),
2765
+ Number(raw[2])
2766
+ ]
2767
+ });
2768
+ }
2769
+ });
2770
+ for (const d of freeData) triples.push({
2771
+ s: null,
2772
+ v: d.map(Number)
2773
+ });
2774
+ const xs = triples.map((t) => t.v[0]);
2775
+ const ys = triples.map((t) => t.v[1]);
2776
+ const zs = triples.map((t) => t.v[2]);
2777
+ frame = buildFrame(valueAxis(xs, [-WORLD_HALF, WORLD_HALF], {
2778
+ ...options.xAxis || {},
2779
+ headroom: .06
2780
+ }), valueAxis(ys, [-WORLD_HALF, WORLD_HALF], {
2781
+ ...options.yAxis || {},
2782
+ headroom: .06
2783
+ }), valueAxis(zs, [0, Z_HEIGHT], {
2784
+ includeZero: false,
2785
+ ...options.zAxis || {},
2786
+ headroom: .06
2787
+ }));
2788
+ frame.x.ticks = valueTicks(frame.x);
2789
+ frame.y.ticks = valueTicks(frame.y);
2790
+ frame.z.ticks = valueTicks(frame.z);
2791
+ if (colorScaleOn) zDomain = [Math.min(...zs), Math.max(...zs)];
2792
+ triples.forEach((t, idx) => {
2793
+ const s = t.s;
2794
+ const p = staggerProgress(progress, idx, triples.length, stagger);
2795
+ const zNorm = zDomain && zDomain[1] > zDomain[0] ? (t.v[2] - zDomain[0]) / (zDomain[1] - zDomain[0]) : .5;
2796
+ points.push({
2797
+ prog: p,
2798
+ world: [
2799
+ frame.x.scale.map(t.v[0]),
2800
+ frame.y.scale.map(t.v[1]),
2801
+ frame.z.scale.map(t.v[2]) * p
2802
+ ],
2803
+ color: colorScaleOn ? gradientAt(ramp, zNorm) : s ? s.color : theme.colors[0],
2804
+ size: baseSize,
2805
+ meta: {
2806
+ key: `pt:${idx}`,
2807
+ type: "scatter3d",
2808
+ seriesName: s ? s.name : "散点",
2809
+ seriesIndex: s ? s.__index : -1,
2810
+ dataIndex: idx,
2811
+ x: t.v[0],
2812
+ y: t.v[1],
2813
+ z: t.v[2],
2814
+ value: t.v[2],
2815
+ name: null,
2816
+ color: colorScaleOn ? gradientAt(ramp, zNorm) : s ? s.color : theme.colors[0]
2817
+ }
2818
+ });
2819
+ });
2820
+ } else {
2821
+ const values = [];
2822
+ for (const s of series) for (const v of s.data) {
2823
+ const n = Number(v);
2824
+ if (Number.isFinite(n)) values.push(n);
2825
+ }
2826
+ frame = buildFrame(categoryAxis(labels, [-WORLD_HALF, WORLD_HALF], {
2827
+ ...options.xAxis || {},
2828
+ padding: .24
2829
+ }), categoryAxis(series.map((s) => s.name), [-WORLD_HALF, WORLD_HALF], {
2830
+ ...options.yAxis || {},
2831
+ padding: .24
2832
+ }), valueAxis(values, [0, Z_HEIGHT], {
2833
+ includeZero: true,
2834
+ ...options.zAxis || {}
2835
+ }));
2836
+ frame.x.ticks = categoryTicks(frame.x);
2837
+ frame.y.ticks = categoryTicks(frame.y);
2838
+ frame.z.ticks = valueTicks(frame.z);
2839
+ if (colorScaleOn && values.length) zDomain = [Math.min(...values), Math.max(...values)];
2840
+ series.forEach((s) => {
2841
+ const y = frame.y.scale.center(s.__index);
2842
+ for (let i = 0; i < frame.x.scale.count; i++) {
2843
+ const v = Number(s.data[i]);
2844
+ if (isMissing(v)) continue;
2845
+ const p = staggerProgress(progress, i, frame.x.scale.count, stagger);
2846
+ const zNorm = zDomain && zDomain[1] > zDomain[0] ? (v - zDomain[0]) / (zDomain[1] - zDomain[0]) : .5;
2847
+ const color = colorScaleOn ? gradientAt(ramp, zNorm) : s.color;
2848
+ points.push({
2849
+ prog: p,
2850
+ world: [
2851
+ frame.x.scale.center(i),
2852
+ y,
2853
+ frame.z.scale.map(v) * p
2854
+ ],
2855
+ color,
2856
+ size: baseSize,
2857
+ meta: {
2858
+ key: `pt:${s.__index}:${i}`,
2859
+ type: "scatter3d",
2860
+ seriesName: s.name,
2861
+ seriesIndex: s.__index,
2862
+ dataIndex: i,
2863
+ name: labels[i] !== void 0 ? labels[i] : String(i + 1),
2864
+ value: v,
2865
+ color
2866
+ }
2867
+ });
2868
+ }
2869
+ });
2870
+ }
2871
+ buildAxes3d(scene, frame, rc);
2872
+ for (const p of points) {
2873
+ if (dropLines) addSegment(scene, p.world, [
2874
+ p.world[0],
2875
+ p.world[1],
2876
+ .0015
2877
+ ], {
2878
+ color: toRgba(p.color, .22 * p.prog),
2879
+ layer: "back",
2880
+ lineWidth: 1,
2881
+ dash: [2, 4],
2882
+ pickable: false
2883
+ });
2884
+ addPoint(scene, p.world, {
2885
+ color: p.color,
2886
+ radius: p.size,
2887
+ pickRadius: Math.max(10, p.size * 2.4),
2888
+ depthScale: sizeAttenuation,
2889
+ stroke: theme.isDark ? "rgba(0,0,0,0.3)" : "rgba(255,255,255,0.85)",
2890
+ strokeWidth: 1,
2891
+ alpha: Math.min(1, .35 + p.prog * .65),
2892
+ meta: p.meta
2893
+ });
2894
+ }
2895
+ return {
2896
+ scene,
2897
+ frame,
2898
+ colorScale: colorScaleOn && zDomain ? {
2899
+ min: zDomain[0],
2900
+ max: zDomain[1],
2901
+ colors: ramp,
2902
+ label: options.zAxis && options.zAxis.name || ""
2903
+ } : null
2904
+ };
2905
+ }
2906
+ //#endregion
2907
+ //#region src/3d/renderer/charts/surface3d.js
2908
+ /**
2909
+ * 三维曲面图 — z = f(x, y) 的高度场
2910
+ *
2911
+ * 数据为矩阵:行对应 Y、列对应 X,X/Y 走数值轴。
2912
+ * 着色按高度走连续色带;面片双面可见,从下方看不会消失。
2913
+ *
2914
+ * 两个刻意的实现取舍:
2915
+ * · 面片数 O(n²),矩阵按 MAX_GRID 抽稀,否则几万顶点会拖死交互
2916
+ * · 线框不画整条横/纵线 —— 跨全图的线只有一个质心深度,画家算法会把它
2917
+ * 整条画到前景或背景,穿帮明显;拆成逐格短段才能正确参与深度排序
2918
+ */
2919
+ /** 单边最大面片数 */
2920
+ var MAX_GRID = 48;
2921
+ /** 线框抽样上限(单边格数) */
2922
+ var MAX_WIRE = 32;
2923
+ function strideFor(count, max) {
2924
+ if (!(count > max) || max < 2) return 1;
2925
+ return Math.ceil(count / max);
2926
+ }
2927
+ function buildSurface3dScene(rc) {
2928
+ const { options, theme, progress } = rc;
2929
+ const scene = createScene();
2930
+ const data = normalizeSurface(options);
2931
+ const cfg = options.surface && typeof options.surface === "object" ? options.surface : {};
2932
+ const rampId = typeof cfg.ramp === "string" ? cfg.ramp : "classic";
2933
+ const ramp = resolveChartRamp(rampId, theme.isDark) || resolveChartRamp("classic", theme.isDark);
2934
+ const wireframe = cfg.wireframe !== false;
2935
+ const opacity = Number.isFinite(cfg.opacity) ? Math.max(.15, Math.min(1, cfg.opacity)) : 1;
2936
+ const rows = data.rows;
2937
+ const cols = data.cols;
2938
+ const empty = !rows || !cols;
2939
+ const rowIdx = decimatedIndices(rows, MAX_GRID);
2940
+ const colIdx = decimatedIndices(cols, MAX_GRID);
2941
+ const cellValues = [];
2942
+ for (const row of data.matrix) {
2943
+ if (!Array.isArray(row)) continue;
2944
+ for (const v of row) {
2945
+ const n = Number(v);
2946
+ if (Number.isFinite(n)) cellValues.push(n);
2947
+ }
2948
+ }
2949
+ const zMin = cellValues.length ? Math.min(...cellValues) : 0;
2950
+ const zMax = cellValues.length ? Math.max(...cellValues) : 1;
2951
+ const frame = buildFrame(valueAxis(data.xValues, [-WORLD_HALF, WORLD_HALF], {
2952
+ ...options.xAxis || {},
2953
+ headroom: .01,
2954
+ ticks: 6
2955
+ }), valueAxis(data.yValues, [-WORLD_HALF, WORLD_HALF], {
2956
+ ...options.yAxis || {},
2957
+ headroom: .01,
2958
+ ticks: 6
2959
+ }), valueAxis(cellValues, [0, Z_HEIGHT], {
2960
+ includeZero: false,
2961
+ ...options.zAxis || {},
2962
+ headroom: .01
2963
+ }));
2964
+ frame.x.ticks = valueTicks(frame.x);
2965
+ frame.y.ticks = valueTicks(frame.y);
2966
+ frame.z.ticks = valueTicks(frame.z);
2967
+ buildAxes3d(scene, frame, rc);
2968
+ if (empty) return {
2969
+ scene,
2970
+ frame,
2971
+ colorScale: null
2972
+ };
2973
+ const xWorld = (i) => frame.x.scale.map(data.xValues[i]);
2974
+ const yWorld = (j) => frame.y.scale.map(data.yValues[j]);
2975
+ const stagger = resolveStagger(options);
2976
+ const grid = [];
2977
+ rowIdx.forEach((j, rj) => {
2978
+ const p = staggerProgress(progress, rj, rowIdx.length, stagger);
2979
+ const line = [];
2980
+ for (const i of colIdx) {
2981
+ const raw = data.matrix[j] ? data.matrix[j][i] : void 0;
2982
+ const v = Number(raw);
2983
+ line.push({
2984
+ x: xWorld(i),
2985
+ y: yWorld(j),
2986
+ z: Number.isFinite(v) ? frame.z.scale.map(v) * p : 0,
2987
+ value: Number.isFinite(v) ? v : null
2988
+ });
2989
+ }
2990
+ grid.push(line);
2991
+ });
2992
+ const norm = (v) => zMax > zMin ? (v - zMin) / (zMax - zMin) : .5;
2993
+ for (let r = 0; r < grid.length - 1; r++) for (let c = 0; c < grid[r].length - 1; c++) {
2994
+ const a = grid[r][c];
2995
+ const b = grid[r][c + 1];
2996
+ const d = grid[r + 1][c + 1];
2997
+ const e = grid[r + 1][c];
2998
+ const vals = [
2999
+ a.value,
3000
+ b.value,
3001
+ d.value,
3002
+ e.value
3003
+ ].filter((v) => v !== null);
3004
+ const avg = vals.length ? vals.reduce((s, v) => s + v, 0) / vals.length : zMin;
3005
+ const color = gradientAt(ramp, norm(avg));
3006
+ addFace(scene, [
3007
+ [
3008
+ a.x,
3009
+ a.y,
3010
+ a.z
3011
+ ],
3012
+ [
3013
+ b.x,
3014
+ b.y,
3015
+ b.z
3016
+ ],
3017
+ [
3018
+ d.x,
3019
+ d.y,
3020
+ d.z
3021
+ ],
3022
+ [
3023
+ e.x,
3024
+ e.y,
3025
+ e.z
3026
+ ]
3027
+ ], {
3028
+ color,
3029
+ flat: true,
3030
+ alpha: opacity,
3031
+ cull: false,
3032
+ doubleSided: true,
3033
+ meta: {
3034
+ key: `sf:${rowIdx[r]}:${colIdx[c]}`,
3035
+ type: "surface3d",
3036
+ seriesName: "曲面",
3037
+ seriesIndex: 0,
3038
+ dataIndex: rowIdx[r] * cols + colIdx[c],
3039
+ x: data.xValues[colIdx[c]],
3040
+ y: data.yValues[rowIdx[r]],
3041
+ value: avg,
3042
+ z: avg,
3043
+ color
3044
+ }
3045
+ });
3046
+ }
3047
+ if (wireframe) {
3048
+ const lineColor = theme.isDark ? "rgba(255,255,255,0.14)" : "rgba(255,255,255,0.45)";
3049
+ const wireRows = decimatedIndices(grid.length, MAX_WIRE);
3050
+ const wireCols = decimatedIndices(grid[0].length, MAX_WIRE);
3051
+ const seg = (p, q) => {
3052
+ addLine(scene, [[
3053
+ p.x,
3054
+ p.y,
3055
+ p.z + .0012
3056
+ ], [
3057
+ q.x,
3058
+ q.y,
3059
+ q.z + .0012
3060
+ ]], {
3061
+ color: lineColor,
3062
+ lineWidth: .7,
3063
+ alpha: .85,
3064
+ pickable: false
3065
+ });
3066
+ };
3067
+ for (const r of wireRows) {
3068
+ if (r >= grid.length - 1) continue;
3069
+ for (let c = 0; c < grid[r].length - 1; c++) seg(grid[r][c], grid[r][c + 1]);
3070
+ }
3071
+ for (const c of wireCols) {
3072
+ if (c >= grid[0].length - 1) continue;
3073
+ for (let r = 0; r < grid.length - 1; r++) seg(grid[r][c], grid[r + 1][c]);
3074
+ }
3075
+ }
3076
+ return {
3077
+ scene,
3078
+ frame,
3079
+ colorScale: {
3080
+ min: zMin,
3081
+ max: zMax,
3082
+ colors: ramp,
3083
+ label: options.zAxis && options.zAxis.name || ""
3084
+ }
3085
+ };
3086
+ }
3087
+ /** 0..count-1 的等距下标(首末必含),用于矩阵抽稀 */
3088
+ function decimatedIndices(count, max) {
3089
+ const out = [];
3090
+ if (count <= 0) return out;
3091
+ const stride = strideFor(count, max);
3092
+ for (let i = 0; i < count; i += stride) out.push(i);
3093
+ if (out[out.length - 1] !== count - 1) out.push(count - 1);
3094
+ return out;
3095
+ }
3096
+ //#endregion
3097
+ //#region src/3d/renderer/charts/pie3d.js
3098
+ /**
3099
+ * 三维饼图 / 环形图 — 平放在地面上的厚片圆盘
3100
+ *
3101
+ * 扇区由五类面构成:顶环面、底环面、外侧弧面、内侧弧面(环形时)、两片径向端面。
3102
+ * 所有面的顶点缠绕都已校验为法线朝外,因此整块开启背面剔除。
3103
+ * 悬浮扇区整体上浮(z 平移)—— 三维图里「强调」用位移比用变色更可读,
3104
+ * 因为面片颜色已被光照调制过,再叠强调色容易失真。
3105
+ */
3106
+ var TAU = Math.PI * 2;
3107
+ var DEG = Math.PI / 180;
3108
+ function clampNum(v, min, max, fallback) {
3109
+ return Number.isFinite(v) ? Math.max(min, Math.min(max, v)) : fallback;
3110
+ }
3111
+ /** 圆弧采样点 */
3112
+ function arcPoints(cx, cy, r, a0, a1, segments, z) {
3113
+ const pts = [];
3114
+ for (let k = 0; k <= segments; k++) {
3115
+ const a = a0 + (a1 - a0) * k / segments;
3116
+ pts.push([
3117
+ cx + Math.cos(a) * r,
3118
+ cy + Math.sin(a) * r,
3119
+ z
3120
+ ]);
3121
+ }
3122
+ return pts;
3123
+ }
3124
+ /** 环形面(外圈逆时针 + 内圈顺时针 → 法线 +Z);innerRadius 为 0 时退化为整圆 */
3125
+ function annulusTop(cx, cy, rOut, rIn, a0, a1, segments, z) {
3126
+ const outer = arcPoints(cx, cy, rOut, a0, a1, segments, z);
3127
+ if (rIn <= 1e-6) return [[
3128
+ cx,
3129
+ cy,
3130
+ z
3131
+ ], ...outer];
3132
+ const inner = arcPoints(cx, cy, rIn, a1, a0, segments, z);
3133
+ return [...outer, ...inner];
3134
+ }
3135
+ function buildPie3dScene(rc) {
3136
+ const { options, theme, hiddenSeries, progress, hoverKey } = rc;
3137
+ const scene = createScene();
3138
+ const { items, total } = normalizePie(options, theme, hiddenSeries);
3139
+ const pie = options.pie && typeof options.pie === "object" ? options.pie : {};
3140
+ const radius = clampNum(pie.radius, .12, .5, .42);
3141
+ const innerRadius = clampNum(pie.innerRadius, 0, radius * .82, 0);
3142
+ const thickness = clampNum(pie.thickness, .02, .5, .18);
3143
+ const startAngle = (Number.isFinite(pie.startAngle) ? pie.startAngle : -90) * DEG;
3144
+ const padAngle = clampNum(pie.padAngle, 0, .12, .01) * DEG;
3145
+ const lift = .055;
3146
+ const shadowOn = pie.shadow !== false;
3147
+ const shadowStrength = Number.isFinite(pie.shadowStrength) ? Math.max(0, Math.min(1, pie.shadowStrength)) : .09;
3148
+ const showLabel = !!(options.label && typeof options.label === "object" ? options.label : {}).show;
3149
+ options.i18n && options.i18n.tooltip && options.i18n.tooltip.percent;
3150
+ const stagger = resolveStagger(options);
3151
+ const sliceCount = items.length;
3152
+ let acc = 0;
3153
+ items.forEach((d, di) => {
3154
+ const p = staggerProgress(progress, di, sliceCount, stagger);
3155
+ const sweepProgress = Math.min(1, p / .85);
3156
+ const thickProgress = Math.max(0, Math.min(1, (p - .1) / .8));
3157
+ const segAngle = d.percent * TAU * sweepProgress;
3158
+ const a0 = startAngle + acc;
3159
+ acc += segAngle;
3160
+ const a1 = startAngle + acc;
3161
+ if (a1 - a0 < 1e-4) return;
3162
+ const gap = Math.min(padAngle, (a1 - a0) * .18);
3163
+ const s0 = a0 + gap / 2;
3164
+ const s1 = a1 - gap / 2;
3165
+ if (s1 - s0 < 1e-4) return;
3166
+ const segments = Math.max(2, Math.ceil((s1 - s0) / (TAU / 72)));
3167
+ const zBase = .0016 + (hoverKey === `pie:${d.__index}` ? lift : 0);
3168
+ const zTop = zBase + thickness * thickProgress;
3169
+ const top = annulusTop(0, 0, radius, innerRadius, s0, s1, segments, zTop);
3170
+ const bottom = annulusTop(0, 0, radius, innerRadius, s1, s0, segments, zBase);
3171
+ const faceOpts = {
3172
+ color: d.color,
3173
+ cull: true,
3174
+ solid: true,
3175
+ doubleSided: false,
3176
+ alpha: Math.min(1, .4 + p * .6),
3177
+ meta: {
3178
+ key: `pie:${d.__index}`,
3179
+ type: "pie3d",
3180
+ seriesName: d.name,
3181
+ seriesIndex: d.__index,
3182
+ dataIndex: d.__index,
3183
+ name: d.name,
3184
+ value: d.value,
3185
+ percent: d.percent,
3186
+ total,
3187
+ color: d.color
3188
+ }
3189
+ };
3190
+ addFace(scene, top, faceOpts);
3191
+ addFace(scene, bottom, faceOpts);
3192
+ const curvedOpts = {
3193
+ ...faceOpts,
3194
+ curved: true
3195
+ };
3196
+ const outerA = arcPoints(0, 0, radius, s0, s1, segments, zBase);
3197
+ const outerB = arcPoints(0, 0, radius, s0, s1, segments, zTop);
3198
+ for (let k = 0; k < segments; k++) addFace(scene, [
3199
+ outerA[k],
3200
+ outerA[k + 1],
3201
+ outerB[k + 1],
3202
+ outerB[k]
3203
+ ], curvedOpts);
3204
+ if (innerRadius > 1e-6) {
3205
+ const innerA = arcPoints(0, 0, innerRadius, s0, s1, segments, zBase);
3206
+ const innerB = arcPoints(0, 0, innerRadius, s0, s1, segments, zTop);
3207
+ for (let k = 0; k < segments; k++) addFace(scene, [
3208
+ innerB[k],
3209
+ innerB[k + 1],
3210
+ innerA[k + 1],
3211
+ innerA[k]
3212
+ ], curvedOpts);
3213
+ }
3214
+ const capStart = [
3215
+ [
3216
+ Math.cos(s0) * innerRadius,
3217
+ Math.sin(s0) * innerRadius,
3218
+ zBase
3219
+ ],
3220
+ [
3221
+ Math.cos(s0) * radius,
3222
+ Math.sin(s0) * radius,
3223
+ zBase
3224
+ ],
3225
+ [
3226
+ Math.cos(s0) * radius,
3227
+ Math.sin(s0) * radius,
3228
+ zTop
3229
+ ],
3230
+ [
3231
+ Math.cos(s0) * innerRadius,
3232
+ Math.sin(s0) * innerRadius,
3233
+ zTop
3234
+ ]
3235
+ ];
3236
+ const capEnd = [
3237
+ [
3238
+ Math.cos(s1) * innerRadius,
3239
+ Math.sin(s1) * innerRadius,
3240
+ zTop
3241
+ ],
3242
+ [
3243
+ Math.cos(s1) * radius,
3244
+ Math.sin(s1) * radius,
3245
+ zTop
3246
+ ],
3247
+ [
3248
+ Math.cos(s1) * radius,
3249
+ Math.sin(s1) * radius,
3250
+ zBase
3251
+ ],
3252
+ [
3253
+ Math.cos(s1) * innerRadius,
3254
+ Math.sin(s1) * innerRadius,
3255
+ zBase
3256
+ ]
3257
+ ];
3258
+ addFace(scene, capStart, faceOpts);
3259
+ addFace(scene, capEnd, faceOpts);
3260
+ if (showLabel) {
3261
+ const mid = (s0 + s1) / 2;
3262
+ addText(scene, [
3263
+ Math.cos(mid) * radius * 1.24,
3264
+ Math.sin(mid) * radius * 1.24,
3265
+ zTop
3266
+ ], `${d.name} ${(d.percent * 100).toFixed(1)}%`, {
3267
+ layer: "front",
3268
+ fontSize: 11,
3269
+ color: theme.textColorSecondary,
3270
+ align: Math.cos(mid) >= 0 ? "left" : "right",
3271
+ meta: faceOpts.meta,
3272
+ pickable: false
3273
+ });
3274
+ }
3275
+ });
3276
+ const grownH = thickness * Math.max(0, Math.min(1, (progress - .1) / .8));
3277
+ if (shadowOn && shadowStrength > 0 && grownH > .005) addFace(scene, annulusTop(0, 0, radius * 1.05, 0, 0, TAU, 48, 8e-4), {
3278
+ color: "#000000",
3279
+ alpha: shadowStrength,
3280
+ layer: "back",
3281
+ flat: true,
3282
+ pickable: false
3283
+ });
3284
+ return {
3285
+ scene,
3286
+ frame: null,
3287
+ pie: {
3288
+ radius,
3289
+ innerRadius,
3290
+ thickness: grownH
3291
+ }
3292
+ };
3293
+ }
3294
+ //#endregion
3295
+ //#region src/3d/renderer/index.js
3296
+ /**
3297
+ * 渲染入口 — 装配主题/图例/相机,按 type 分发到图型建景器,投影后绘制
3298
+ *
3299
+ * 与 evoke-charts 同构的外部契约:render3d(canvas, params) 纯函数式渲染一帧,
3300
+ * 返回投影结果与拾取闭包;params.ctx 可注入录制器实现 SVG 导出。
3301
+ *
3302
+ * 相机决策顺序:resolveCamera(默认 ← options.camera ← params.camera) → autoFit 时按
3303
+ * 世界包围盒反解 distance(只调距离不动方位,用户一旦手动交互即由组件关掉 autoFit)。
3304
+ * params.camera 是组件实时相机(拖拽/滚轮的结果),优先级最高,否则交互无法出画。
3305
+ */
3306
+ var CHART3D_BUILDERS = {
3307
+ bar3d: buildBar3dScene,
3308
+ line3d: buildLine3dScene,
3309
+ scatter3d: buildScatter3dScene,
3310
+ surface3d: buildSurface3dScene,
3311
+ pie3d: buildPie3dScene
3312
+ };
3313
+ /** 归一化标题配置 */
3314
+ function titleInfo(options) {
3315
+ const t = options && options.title;
3316
+ if (!t) return {
3317
+ text: "",
3318
+ subtitle: "",
3319
+ show: false
3320
+ };
3321
+ if (typeof t === "string") return {
3322
+ text: t,
3323
+ subtitle: "",
3324
+ show: true
3325
+ };
3326
+ if (typeof t === "object") return {
3327
+ text: t.text ? String(t.text) : "",
3328
+ subtitle: t.subtitle ? String(t.subtitle) : "",
3329
+ show: t.show !== false && !!(t.text || t.subtitle)
3330
+ };
3331
+ return {
3332
+ text: "",
3333
+ subtitle: "",
3334
+ show: false
3335
+ };
3336
+ }
3337
+ function titleBlockHeight(options) {
3338
+ const info = titleInfo(options);
3339
+ if (!info.show) return 0;
3340
+ return info.subtitle ? 40 : 26;
3341
+ }
3342
+ /** 世界包围盒(建景前的确定性估算,供 autoFit) */
3343
+ function worldBoundsFor(options) {
3344
+ if (options && options.type === "pie3d") {
3345
+ const pie = options.pie && typeof options.pie === "object" ? options.pie : {};
3346
+ const r = Number.isFinite(pie.radius) ? Math.max(.12, Math.min(.5, pie.radius)) : .42;
3347
+ const t = Number.isFinite(pie.thickness) ? Math.max(.02, Math.min(.5, pie.thickness)) : .18;
3348
+ return {
3349
+ min: [
3350
+ -r,
3351
+ -r,
3352
+ 0
3353
+ ],
3354
+ max: [
3355
+ r,
3356
+ r,
3357
+ t
3358
+ ]
3359
+ };
3360
+ }
3361
+ return {
3362
+ min: [
3363
+ -.5,
3364
+ -.5,
3365
+ 0
3366
+ ],
3367
+ max: [
3368
+ .5,
3369
+ .5,
3370
+ .62
3371
+ ]
3372
+ };
3373
+ }
3374
+ /** 图型默认相机目标(饼图盘心低、柱体类略高于地面) */
3375
+ function defaultTargetFor(type) {
3376
+ return type === "pie3d" ? [
3377
+ 0,
3378
+ 0,
3379
+ .1
3380
+ ] : [
3381
+ 0,
3382
+ 0,
3383
+ .31
3384
+ ];
3385
+ }
3386
+ /**
3387
+ * 预计算 Chrome(标题/图例/内边距/绘图区)—— 建景与绘制共用同一份布局
3388
+ */
3389
+ function computeChrome(options, width, height, theme, hiddenSeries) {
3390
+ const legendCfg = options.legend && typeof options.legend === "object" ? options.legend : {};
3391
+ const items = legendCfg.show === false ? [] : computeLegendItems(options, theme, hiddenSeries);
3392
+ const titleH = titleBlockHeight(options);
3393
+ const provisional = computeLegendLayout(items, width, options, titleH + 6, height);
3394
+ const pad = {
3395
+ left: legendCfg.show === false ? 16 : 18,
3396
+ right: 20,
3397
+ top: 6 + titleH,
3398
+ bottom: 14
3399
+ };
3400
+ if (options.type === "surface3d" || options.type === "scatter3d" && !!(options.scatter && options.scatter.colorScale)) pad.right += 48;
3401
+ if (provisional.rows) {
3402
+ if (provisional.position === "bottom") pad.bottom += provisional.height + 6;
3403
+ else pad.top += provisional.height + 4;
3404
+ }
3405
+ return {
3406
+ legend: provisional,
3407
+ legendItems: items,
3408
+ padding: pad,
3409
+ viewport: {
3410
+ x: pad.left,
3411
+ y: pad.top,
3412
+ width: Math.max(20, width - pad.left - pad.right),
3413
+ height: Math.max(20, height - pad.top - pad.bottom)
3414
+ },
3415
+ titleH
3416
+ };
3417
+ }
3418
+ function drawTitle(ctx, options, theme, width) {
3419
+ const info = titleInfo(options);
3420
+ if (!info.show) return;
3421
+ const cfg = options.title && typeof options.title === "object" ? options.title : {};
3422
+ const x = Number.isFinite(cfg.left) ? cfg.left : 16;
3423
+ ctx.save();
3424
+ ctx.textAlign = "left";
3425
+ ctx.textBaseline = "alphabetic";
3426
+ if (info.text) {
3427
+ ctx.font = `600 13px ${theme.fontFamily || "sans-serif"}`;
3428
+ ctx.fillStyle = theme.textColor;
3429
+ ctx.fillText(info.text, x, 18);
3430
+ }
3431
+ if (info.subtitle) {
3432
+ ctx.font = `400 11px ${theme.fontFamily || "sans-serif"}`;
3433
+ ctx.fillStyle = theme.textColorSecondary;
3434
+ ctx.fillText(info.subtitle, x, info.text ? 34 : 18);
3435
+ }
3436
+ ctx.restore();
3437
+ }
3438
+ /**
3439
+ * 渲染一帧
3440
+ * @param {HTMLCanvasElement} canvas
3441
+ * @param {object} params { options, dpr, progress, camera, hiddenSeries, hoverKey, theme, ctx, autoFit }
3442
+ * @returns {{viewport, camera, projected, legend, colorScale, pickAt, stats, theme}}
3443
+ */
3444
+ function render3d(canvas, params) {
3445
+ const options = params && params.options || {};
3446
+ const dpr = params && Number.isFinite(params.dpr) && params.dpr > 0 ? params.dpr : 1;
3447
+ const ctx = params && params.ctx || canvas.getContext("2d");
3448
+ const width = canvas.width / dpr;
3449
+ const height = canvas.height / dpr;
3450
+ const progress = params && Number.isFinite(params.progress) ? Math.max(0, Math.min(1, params.progress)) : 1;
3451
+ const hiddenSeries = params && params.hiddenSeries || /* @__PURE__ */ new Set();
3452
+ const hoverKey = params && params.hoverKey || null;
3453
+ const theme = params && params.theme ? params.theme : getTheme(options, options.theme, resolveChartPalette(options.palette, false));
3454
+ clearCanvas(ctx, canvas.width, canvas.height, theme.backgroundColor);
3455
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3456
+ const chrome = computeChrome(options, width, height, theme, hiddenSeries);
3457
+ const viewport = chrome.viewport;
3458
+ const type = options.type;
3459
+ const builder = CHART3D_BUILDERS[type];
3460
+ if (!builder) return {
3461
+ viewport,
3462
+ camera: null,
3463
+ projected: {
3464
+ items: [],
3465
+ mvp: null,
3466
+ eye: [
3467
+ 0,
3468
+ 0,
3469
+ 0
3470
+ ],
3471
+ light: null,
3472
+ dropped: 0
3473
+ },
3474
+ legend: chrome.legend,
3475
+ colorScale: null,
3476
+ pickAt: () => null,
3477
+ stats: {
3478
+ total: 0,
3479
+ unknownType: type
3480
+ },
3481
+ theme
3482
+ };
3483
+ let camera = resolveCamera({
3484
+ target: defaultTargetFor(type),
3485
+ ...options.camera && typeof options.camera === "object" ? options.camera : {},
3486
+ ...params && params.camera && typeof params.camera === "object" ? params.camera : {}
3487
+ });
3488
+ if (params && params.autoFit) camera = {
3489
+ ...camera,
3490
+ distance: fitDistance(worldBoundsFor(options), camera, viewport)
3491
+ };
3492
+ const matrices = cameraMatrices(camera, viewport);
3493
+ const basis = cameraBasis(camera);
3494
+ const built = builder({
3495
+ options,
3496
+ theme,
3497
+ hiddenSeries,
3498
+ progress,
3499
+ hoverKey,
3500
+ mvp: matrices.mvp,
3501
+ eye: matrices.eye,
3502
+ basis,
3503
+ viewport,
3504
+ width,
3505
+ height
3506
+ });
3507
+ const projected = projectScene(built.scene, camera, viewport, {
3508
+ mvp: matrices.mvp,
3509
+ eye: matrices.eye,
3510
+ basis,
3511
+ lighting: options.lighting,
3512
+ depth: {
3513
+ ...resolveDepth(options),
3514
+ hazeColor: theme.backdropColor
3515
+ }
3516
+ });
3517
+ drawScene(ctx, projected, {
3518
+ hoverKey,
3519
+ theme
3520
+ });
3521
+ drawTitle(ctx, options, theme, width);
3522
+ drawLegend(ctx, chrome.legend, theme);
3523
+ const scaleRect = built.colorScale ? drawColorScale(ctx, built.colorScale, theme, width, viewport) : null;
3524
+ const stats = {
3525
+ total: projected.items.length,
3526
+ dropped: projected.dropped,
3527
+ pickable: new Set(projected.items.filter((i) => i.pickable && i.meta).map((i) => i.meta.key)).size
3528
+ };
3529
+ return {
3530
+ viewport,
3531
+ camera,
3532
+ projected,
3533
+ legend: chrome.legend,
3534
+ colorScale: built.colorScale ? {
3535
+ ...built.colorScale,
3536
+ rect: scaleRect
3537
+ } : null,
3538
+ pickAt: (x, y, tolerance) => pickScene(projected, x, y, { tolerance }),
3539
+ stats,
3540
+ theme
3541
+ };
3542
+ }
3543
+ //#endregion
3544
+ //#region src/3d/chart3d.vue
3545
+ var _hoisted_1 = ["tabindex", "aria-label"];
3546
+ var _hoisted_2 = {
3547
+ key: 1,
3548
+ class: "ev-chart3d__overlay"
3549
+ };
3550
+ var _hoisted_3 = {
3551
+ key: 0,
3552
+ class: "ev-chart3d__empty"
3553
+ };
3554
+ var _hoisted_4 = { class: "ev-chart3d__empty-text" };
3555
+ var _hoisted_5 = {
3556
+ key: 0,
3557
+ class: "ev-chart3d__loading"
3558
+ };
3559
+ var _hoisted_6 = { class: "ev-chart3d__loading-bars" };
3560
+ var _hoisted_7 = {
3561
+ key: 0,
3562
+ class: "ev-chart3d__error"
3563
+ };
3564
+ var _hoisted_8 = { class: "ev-chart3d__error-text" };
3565
+ var _hoisted_9 = ["innerHTML"];
3566
+ var _hoisted_10 = {
3567
+ class: "ev-chart3d__sr-only",
3568
+ role: "status",
3569
+ "aria-live": "polite"
3570
+ };
3571
+ /**
3572
+ * 三维图表组件 — options 驱动,与 EvChart 同一套使用心智:
3573
+ * 同为 options 单 prop、同四态(空/载/错/成)、同令牌与换肤事件、同 BEM 命名族。
3574
+ * 差异仅在:交互从「hover 数据」扩展为「轨道视角 + hover 数据」双层。
3575
+ */
3576
+ var DRAG_THRESHOLD = 4;
3577
+ /** 数据补间:结构不变、仅数值变化时逐帧插值,避免整图重放进场 */
3578
+ var INERTIA_MIN_SPEED = .15;
3579
+ /** 甩动速度上限(px/ms):按 damping 0.14 约等于一次 130px 的拖拽余量,再快也不至于甩飞 */
3580
+ var INERTIA_MAX_SPEED = 1.2;
3581
+ var chart3d_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__ */ Object.assign({ name: "EvChart3d" }, {
3582
+ __name: "chart3d",
3583
+ props: {
3584
+ options: {
3585
+ type: Object,
3586
+ required: true
3587
+ },
3588
+ width: {
3589
+ type: [String, Number],
3590
+ default: "100%"
3591
+ },
3592
+ height: {
3593
+ type: [String, Number],
3594
+ default: 400
3595
+ },
3596
+ responsive: {
3597
+ type: Boolean,
3598
+ default: true
3599
+ },
3600
+ devicePixelRatio: {
3601
+ type: Number,
3602
+ default: void 0
3603
+ }
3604
+ },
3605
+ emits: [
3606
+ "ready",
3607
+ "click",
3608
+ "hover",
3609
+ "unhover",
3610
+ "legend-click",
3611
+ "animation-end",
3612
+ "data-update",
3613
+ "camera-change"
3614
+ ],
3615
+ setup(__props, { expose: __expose, emit: __emit }) {
3616
+ const props = __props;
3617
+ const emit = __emit;
3618
+ const containerRef = ref(null);
3619
+ const canvasRef = ref(null);
3620
+ const tooltipRef = ref(null);
3621
+ const internalError = ref("");
3622
+ const tooltipVisible = ref(false);
3623
+ const tooltipContent = ref("");
3624
+ const tooltipX = ref(0);
3625
+ const tooltipY = ref(0);
3626
+ const tooltipPlacement = ref("top");
3627
+ const ariaLiveText = ref("");
3628
+ const overlayInfo = reactive({
3629
+ viewport: null,
3630
+ camera: null
3631
+ });
3632
+ const cameraState = shallowRef(null);
3633
+ const lastResult = shallowRef(null);
3634
+ const hiddenSeries = ref(/* @__PURE__ */ new Set());
3635
+ const hoverKey = ref(null);
3636
+ const dpr = computed(() => {
3637
+ if (Number.isFinite(props.devicePixelRatio) && props.devicePixelRatio > 0) return props.devicePixelRatio;
3638
+ try {
3639
+ return typeof window !== "undefined" && window.devicePixelRatio || 1;
3640
+ } catch {
3641
+ return 1;
3642
+ }
3643
+ });
3644
+ const containerStyle = computed(() => {
3645
+ return {
3646
+ width: typeof props.width === "number" ? `${props.width}px` : props.width,
3647
+ height: typeof props.height === "number" ? `${props.height}px` : props.height
3648
+ };
3649
+ });
3650
+ const isEmpty = computed(() => {
3651
+ if (props.options.loading) return false;
3652
+ const o = props.options;
3653
+ const type = o.type;
3654
+ if (type === "pie3d") return !(Array.isArray(o.pieData) ? o.pieData : []).some((d) => {
3655
+ const v = Number(d && d.value);
3656
+ return Number.isFinite(v) && v !== 0;
3657
+ });
3658
+ if (type === "surface3d") {
3659
+ const sd = o.surfaceData && typeof o.surfaceData === "object" ? o.surfaceData : {};
3660
+ return !(Array.isArray(sd.z) ? sd.z : Array.isArray(o.matrix) ? o.matrix : []).some((row) => Array.isArray(row) && row.some((v) => Number.isFinite(Number(v))));
3661
+ }
3662
+ if (type === "scatter3d") {
3663
+ const free = Array.isArray(o.scatterData) && o.scatterData.length > 0;
3664
+ const series = Array.isArray(o.series) ? o.series : [];
3665
+ return !free && !series.some((s) => Array.isArray(s && s.data) && s.data.length > 0);
3666
+ }
3667
+ const labels = Array.isArray(o.labels) ? o.labels : [];
3668
+ return !(Array.isArray(o.series) ? o.series : []).some((s) => Array.isArray(s && s.data) && s.data.length > 0) && !labels.length;
3669
+ });
3670
+ const ariaLabelText = computed(() => `3d-${props.options.type || "chart"}-${props.options.title || ""}`);
3671
+ const emptyText = computed(() => resolveI18n(props.options).noData);
3672
+ function currentTheme() {
3673
+ return getTheme(props.options, props.options.theme, resolveChartPalette(props.options.palette, void 0));
3674
+ }
3675
+ function currentCamera() {
3676
+ return resolveCamera({
3677
+ target: props.options.type === "pie3d" ? [
3678
+ 0,
3679
+ 0,
3680
+ .1
3681
+ ] : [
3682
+ 0,
3683
+ 0,
3684
+ .31
3685
+ ],
3686
+ ...props.options.camera || {}
3687
+ });
3688
+ }
3689
+ let renderTimer = null;
3690
+ let animState = null;
3691
+ let animRafId = 0;
3692
+ let autoRafId = 0;
3693
+ let lastAutoTs = 0;
3694
+ let userTouchedCamera = false;
3695
+ let dragState = null;
3696
+ const pointers = /* @__PURE__ */ new Map();
3697
+ let pinchDist = 0;
3698
+ /** 拖拽判定阈值(px):阈值内的位移算点击手抖,既不转相机也不吞掉点选 */
3699
+ let tweenState = null;
3700
+ let tweenRafId = 0;
3701
+ /** 最近一次出图的数据快照 —— 下一轮更新据此判断能否补间 */
3702
+ let lastData = null;
3703
+ /** 拖拽惯性:松手时记录的速度(px/ms)与拖拽模式,按 damping 衰减滑行 */
3704
+ let inertiaRafId = 0;
3705
+ let inertiaVel = null;
3706
+ let inertiaPan = false;
3707
+ function scheduleRender(delay = 16) {
3708
+ if (renderTimer) clearTimeout(renderTimer);
3709
+ renderTimer = setTimeout(() => {
3710
+ renderTimer = null;
3711
+ render();
3712
+ }, delay);
3713
+ }
3714
+ function sizeCanvas() {
3715
+ const canvas = canvasRef.value;
3716
+ if (!canvas) return null;
3717
+ const rect = containerRef.value ? containerRef.value.getBoundingClientRect() : {
3718
+ width: props.width,
3719
+ height: props.height
3720
+ };
3721
+ const cssW = Math.max(1, Math.round(rect.width || 0));
3722
+ const cssH = Math.max(1, Math.round(rect.height || 0));
3723
+ const deviceW = Math.round(cssW * dpr.value);
3724
+ const deviceH = Math.round(cssH * dpr.value);
3725
+ if (canvas.width !== deviceW || canvas.height !== deviceH) {
3726
+ canvas.width = deviceW;
3727
+ canvas.height = deviceH;
3728
+ }
3729
+ return {
3730
+ cssW,
3731
+ cssH
3732
+ };
3733
+ }
3734
+ function render(optionsOverride) {
3735
+ const canvas = canvasRef.value;
3736
+ if (!canvas || isEmpty.value || internalError.value) return;
3737
+ const options = optionsOverride || tweenState && tweenState.options || props.options;
3738
+ try {
3739
+ if (!sizeCanvas()) return;
3740
+ if (!cameraState.value) cameraState.value = currentCamera();
3741
+ const autoFit = !userTouchedCamera;
3742
+ const result = render3d(canvas, {
3743
+ options,
3744
+ dpr: dpr.value,
3745
+ progress: animState ? animState.progress : 1,
3746
+ camera: cameraState.value,
3747
+ hiddenSeries: hiddenSeries.value,
3748
+ hoverKey: hoverKey.value,
3749
+ theme: currentTheme(),
3750
+ autoFit
3751
+ });
3752
+ if (autoFit && result.camera) cameraState.value = result.camera;
3753
+ lastResult.value = result;
3754
+ overlayInfo.viewport = result.viewport;
3755
+ overlayInfo.camera = result.camera;
3756
+ if (!optionsOverride && !tweenState) lastData = captureChart3dData(props.options);
3757
+ } catch (err) {
3758
+ internalError.value = "渲染失败";
3759
+ if (typeof console !== "undefined") console.error("[EvChart3d] render error:", err);
3760
+ }
3761
+ }
3762
+ function animationConfig() {
3763
+ return props.options.animation && typeof props.options.animation === "object" ? props.options.animation : {};
3764
+ }
3765
+ function stopTween() {
3766
+ if (tweenRafId) {
3767
+ cancelAnimationFrame(tweenRafId);
3768
+ tweenRafId = 0;
3769
+ }
3770
+ tweenState = null;
3771
+ }
3772
+ /** 返回 true 表示这一轮更新由补间接管 */
3773
+ function startTweenIfNeeded() {
3774
+ const cfg = animationConfig();
3775
+ if (cfg.enabled === false || prefersReducedMotion()) return false;
3776
+ if (!lastData) return false;
3777
+ const next = captureChart3dData(props.options);
3778
+ if (!sameChart3dShape(lastData, next)) return false;
3779
+ const from = lastData;
3780
+ const duration = Number.isFinite(cfg.duration) ? Math.max(0, cfg.duration) : 600;
3781
+ const easing = resolveEasing(cfg.easing);
3782
+ const stagger = resolveStagger(props.options);
3783
+ const startTime = Date.now();
3784
+ tweenState = { options: interpolateChart3dOptions(from, props.options, 0, stagger) || props.options };
3785
+ const step = () => {
3786
+ if (!tweenState) return;
3787
+ const t = Math.min(1, (Date.now() - startTime) / Math.max(1, duration));
3788
+ tweenState.options = interpolateChart3dOptions(from, props.options, easing(t), stagger) || props.options;
3789
+ render(tweenState.options);
3790
+ lastData = captureChart3dData(tweenState.options);
3791
+ if (t < 1) tweenRafId = requestAnimationFrame(step);
3792
+ else {
3793
+ tweenRafId = 0;
3794
+ tweenState = null;
3795
+ lastData = captureChart3dData(props.options);
3796
+ emit("animation-end");
3797
+ }
3798
+ };
3799
+ tweenRafId = requestAnimationFrame(step);
3800
+ return true;
3801
+ }
3802
+ function stopInertia() {
3803
+ if (inertiaRafId) {
3804
+ cancelAnimationFrame(inertiaRafId);
3805
+ inertiaRafId = 0;
3806
+ }
3807
+ inertiaVel = null;
3808
+ }
3809
+ function startInertia() {
3810
+ const cam = cameraState.value;
3811
+ const vel = inertiaVel;
3812
+ const panMode = inertiaPan;
3813
+ inertiaVel = null;
3814
+ const damping = cam && Number.isFinite(cam.damping) ? cam.damping : .14;
3815
+ if (!cam || !vel || prefersReducedMotion() || !(damping > 0) || Math.hypot(vel[0], vel[1]) < INERTIA_MIN_SPEED) {
3816
+ ensureAutoRotate();
3817
+ return;
3818
+ }
3819
+ let [vx, vy] = vel;
3820
+ let lastTs = 0;
3821
+ const step = (ts) => {
3822
+ const dt = lastTs ? Math.min(32, ts - lastTs) : 16;
3823
+ lastTs = ts;
3824
+ const decay = Math.pow(1 - damping, dt / 16);
3825
+ vx *= decay;
3826
+ vy *= decay;
3827
+ if (Math.hypot(vx, vy) < .02) {
3828
+ inertiaRafId = 0;
3829
+ if (cameraState.value) emit("camera-change", cameraPayload(cameraState.value));
3830
+ ensureAutoRotate();
3831
+ return;
3832
+ }
3833
+ const viewport = lastResult.value ? lastResult.value.viewport : null;
3834
+ if (cameraState.value) applyCamera(panMode && viewport ? panBy(cameraState.value, vx * dt, vy * dt, viewport) : orbitBy(cameraState.value, vx * dt, vy * dt, viewport), { emitEvent: false });
3835
+ inertiaRafId = requestAnimationFrame(step);
3836
+ };
3837
+ inertiaRafId = requestAnimationFrame(step);
3838
+ }
3839
+ function startEnterAnimation() {
3840
+ stopTween();
3841
+ const cfg = animationConfig();
3842
+ if (cfg.enabled === false || prefersReducedMotion()) {
3843
+ animState = null;
3844
+ render();
3845
+ emit("ready");
3846
+ return;
3847
+ }
3848
+ const duration = Number.isFinite(cfg.duration) ? Math.max(0, cfg.duration) : 600;
3849
+ const easing = resolveEasing(cfg.easing);
3850
+ animState = {
3851
+ startTime: Date.now(),
3852
+ duration,
3853
+ easing,
3854
+ progress: 0
3855
+ };
3856
+ const step = () => {
3857
+ if (!animState) return;
3858
+ const t = Math.min(1, (Date.now() - animState.startTime) / Math.max(1, animState.duration));
3859
+ animState.progress = easing(t);
3860
+ render();
3861
+ if (t < 1) animRafId = requestAnimationFrame(step);
3862
+ else {
3863
+ animState = null;
3864
+ emit("animation-end");
3865
+ emit("ready");
3866
+ }
3867
+ };
3868
+ if (animRafId) cancelAnimationFrame(animRafId);
3869
+ animRafId = requestAnimationFrame(step);
3870
+ }
3871
+ function autoRotateLoop(ts) {
3872
+ autoRafId = 0;
3873
+ const cam = cameraState.value;
3874
+ if (!cam || !cam.autoRotate || dragState) {
3875
+ lastAutoTs = 0;
3876
+ return;
3877
+ }
3878
+ const dt = lastAutoTs ? ts - lastAutoTs : 16;
3879
+ lastAutoTs = ts;
3880
+ applyCamera(autoRotateBy(cam, Math.min(64, dt)), { emitEvent: false });
3881
+ autoRafId = requestAnimationFrame(autoRotateLoop);
3882
+ }
3883
+ function ensureAutoRotate() {
3884
+ if (autoRafId) return;
3885
+ lastAutoTs = 0;
3886
+ autoRafId = requestAnimationFrame(autoRotateLoop);
3887
+ }
3888
+ function stopAutoRotate() {
3889
+ if (autoRafId) {
3890
+ cancelAnimationFrame(autoRafId);
3891
+ autoRafId = 0;
3892
+ }
3893
+ }
3894
+ function applyCamera(next, options = {}) {
3895
+ cameraState.value = next;
3896
+ if (options.emitEvent !== false) emit("camera-change", cameraPayload(next));
3897
+ if (options.autoRotate) ensureAutoRotate();
3898
+ render();
3899
+ }
3900
+ function resetCamera(options = {}) {
3901
+ stopInertia();
3902
+ const cfg = props.options.camera && typeof props.options.camera === "object" ? props.options.camera : {};
3903
+ userTouchedCamera = options.keepAutoFit !== true && Number.isFinite(cfg.distance);
3904
+ applyCamera(currentCamera());
3905
+ }
3906
+ function canvasPoint(evt) {
3907
+ const canvas = canvasRef.value;
3908
+ if (!canvas) return null;
3909
+ const rect = canvas.getBoundingClientRect();
3910
+ return {
3911
+ x: evt.clientX - rect.left,
3912
+ y: evt.clientY - rect.top
3913
+ };
3914
+ }
3915
+ function formatTooltipValue(v) {
3916
+ const vf = props.options.tooltip && props.options.tooltip.valueFormatter;
3917
+ if (typeof vf === "function") return vf(v);
3918
+ const abs = Math.abs(Number(v));
3919
+ if (Number.isFinite(abs) && abs >= 1e4) return formatNumber(Number(v), { abbreviate: true });
3920
+ return formatNumber(Number(v), { decimals: Number.isFinite(Number(v)) && Number(v) % 1 !== 0 ? 2 : 0 });
3921
+ }
3922
+ function buildTooltipRows(meta) {
3923
+ const i18n = resolveI18n(props.options);
3924
+ const rows = [];
3925
+ const push = (name, value, color) => rows.push({
3926
+ name,
3927
+ value,
3928
+ color
3929
+ });
3930
+ if (meta.type === "scatter3d" && meta.name === null) {
3931
+ push(i18n.tooltip.x, formatTooltipValue(meta.x), meta.color);
3932
+ push(i18n.tooltip.y, formatTooltipValue(meta.y), meta.color);
3933
+ push(i18n.tooltip.z, formatTooltipValue(meta.z), meta.color);
3934
+ return rows;
3935
+ }
3936
+ if (meta.type === "surface3d") {
3937
+ push(i18n.tooltip.x, String(meta.x), meta.color);
3938
+ push(i18n.tooltip.y, String(meta.y), meta.color);
3939
+ push(i18n.tooltip.value, formatTooltipValue(meta.value), meta.color);
3940
+ return rows;
3941
+ }
3942
+ if (meta.type === "pie3d") {
3943
+ push(meta.name, formatTooltipValue(meta.value), meta.color);
3944
+ push(i18n.tooltip.percent, `${(meta.percent * 100).toFixed(1)}%`, meta.color);
3945
+ return rows;
3946
+ }
3947
+ push(meta.seriesName, formatTooltipValue(meta.value), meta.color);
3948
+ return rows;
3949
+ }
3950
+ function updateTooltip(hit, cx, cy) {
3951
+ const cfg = props.options.tooltip && typeof props.options.tooltip === "object" ? props.options.tooltip : {};
3952
+ if (cfg.show === false || !hit) {
3953
+ tooltipVisible.value = false;
3954
+ tooltipContent.value = "";
3955
+ return;
3956
+ }
3957
+ const formatter = cfg.formatter;
3958
+ tooltipContent.value = typeof formatter === "function" ? String(formatter(hit.meta) ?? "") : buildTooltipHtml(hit.meta);
3959
+ tooltipVisible.value = !!tooltipContent.value;
3960
+ positionTooltip(cx, cy);
3961
+ }
3962
+ function buildTooltipHtml(meta) {
3963
+ const rows = buildTooltipRows(meta);
3964
+ const title = meta.name !== null && meta.name !== void 0 ? meta.name : meta.seriesName;
3965
+ let html = "";
3966
+ if (title) html += `<div class="ev-chart3d__tooltip-title">${escapeHtml(title)}</div>`;
3967
+ for (const row of rows) html += `<div class="ev-chart3d__tooltip-item"><span class="ev-chart3d__tooltip-dot" style="background:${escapeHtml(row.color || "#888")}"></span><span class="ev-chart3d__tooltip-name">${escapeHtml(row.name)}</span><span class="ev-chart3d__tooltip-value">${escapeHtml(row.value)}</span></div>`;
3968
+ return html;
3969
+ }
3970
+ function positionTooltip(cx, cy) {
3971
+ const container = containerRef.value;
3972
+ if (!container) return;
3973
+ const rect = container.getBoundingClientRect();
3974
+ const tip = tooltipRef.value;
3975
+ const tipW = tip ? tip.offsetWidth : 120;
3976
+ const tipH = tip ? tip.offsetHeight : 40;
3977
+ const gap = 12;
3978
+ let x = cx + gap;
3979
+ let y = cy - tipH - gap;
3980
+ if (x + tipW > rect.width - 4) x = cx - tipW - gap;
3981
+ if (x < 4) x = 4;
3982
+ if (y < 4) y = cy + gap;
3983
+ if (y + tipH > rect.height - 4 && y - tipH - 24 >= 4) y = rect.height - tipH - 4;
3984
+ tooltipX.value = x;
3985
+ tooltipY.value = y;
3986
+ tooltipPlacement.value = "top";
3987
+ }
3988
+ const tooltipStyle = computed(() => ({
3989
+ left: `${tooltipX.value}px`,
3990
+ top: `${tooltipY.value}px`
3991
+ }));
3992
+ function setHover(key, hit, cx, cy) {
3993
+ const changed = key !== hoverKey.value;
3994
+ if (!hit && hoverKey.value) {
3995
+ hoverKey.value = null;
3996
+ tooltipVisible.value = false;
3997
+ ariaLiveText.value = "";
3998
+ emit("unhover");
3999
+ render();
4000
+ return;
4001
+ }
4002
+ if (!hit) return;
4003
+ hoverKey.value = key;
4004
+ if (changed) {
4005
+ ariaLiveText.value = describeHit(hit.meta);
4006
+ emit("hover", {
4007
+ ...hit.meta,
4008
+ x: cx,
4009
+ y: cy
4010
+ });
4011
+ }
4012
+ updateTooltip(hit, cx, cy);
4013
+ if (changed) render();
4014
+ }
4015
+ function describeHit(meta) {
4016
+ const rows = buildTooltipRows(meta);
4017
+ return `${meta.name || meta.seriesName},${rows.map((r) => `${r.name} ${r.value}`).join(",")}`;
4018
+ }
4019
+ function handlePointerDown(evt) {
4020
+ if (isEmpty.value || internalError.value) return;
4021
+ pointers.set(evt.pointerId, {
4022
+ x: evt.clientX,
4023
+ y: evt.clientY
4024
+ });
4025
+ if (pointers.size === 2) {
4026
+ const [a, b] = [...pointers.values()];
4027
+ pinchDist = Math.hypot(a.x - b.x, a.y - b.y);
4028
+ dragState = null;
4029
+ return;
4030
+ }
4031
+ const p = canvasPoint(evt);
4032
+ if (!p) return;
4033
+ stopAutoRotate();
4034
+ stopInertia();
4035
+ try {
4036
+ canvasRef.value.setPointerCapture(evt.pointerId);
4037
+ } catch {}
4038
+ dragState = {
4039
+ id: evt.pointerId,
4040
+ x: p.x,
4041
+ y: p.y,
4042
+ startX: p.x,
4043
+ startY: p.y,
4044
+ moved: false,
4045
+ pan: !!evt.shiftKey || props.options.interaction?.pan === true,
4046
+ vx: 0,
4047
+ vy: 0,
4048
+ lastTs: 0
4049
+ };
4050
+ }
4051
+ function handlePointerMove(evt) {
4052
+ if (isEmpty.value || internalError.value) return;
4053
+ if (pointers.has(evt.pointerId)) pointers.set(evt.pointerId, {
4054
+ x: evt.clientX,
4055
+ y: evt.clientY
4056
+ });
4057
+ if (pointers.size === 2) {
4058
+ const [a, b] = [...pointers.values()];
4059
+ const dist = Math.hypot(a.x - b.x, a.y - b.y);
4060
+ if (pinchDist > 0 && dist > 0 && cameraState.value) {
4061
+ userTouchedCamera = true;
4062
+ applyCamera(zoomBy(cameraState.value, pinchDist / dist), { emitEvent: false });
4063
+ }
4064
+ pinchDist = dist;
4065
+ return;
4066
+ }
4067
+ const p = canvasPoint(evt);
4068
+ if (!p) return;
4069
+ if (dragState && dragState.id === evt.pointerId) {
4070
+ const dx = p.x - dragState.x;
4071
+ const dy = p.y - dragState.y;
4072
+ if (!dragState.moved && Math.hypot(p.x - dragState.startX, p.y - dragState.startY) < DRAG_THRESHOLD) return;
4073
+ if (dx !== 0 || dy !== 0) {
4074
+ dragState.moved = true;
4075
+ dragState.x = p.x;
4076
+ dragState.y = p.y;
4077
+ const ts = Number.isFinite(evt.timeStamp) ? evt.timeStamp : Date.now();
4078
+ const dt = Math.max(1, ts - (dragState.lastTs || ts - 16));
4079
+ dragState.lastTs = ts;
4080
+ const vx = dx / dt * .75 + dragState.vx * .25;
4081
+ const vy = dy / dt * .75 + dragState.vy * .25;
4082
+ dragState.vx = Math.max(-1.2, Math.min(INERTIA_MAX_SPEED, vx));
4083
+ dragState.vy = Math.max(-1.2, Math.min(INERTIA_MAX_SPEED, vy));
4084
+ userTouchedCamera = true;
4085
+ const viewport = lastResult.value ? lastResult.value.viewport : null;
4086
+ applyCamera(dragState.pan && viewport ? panBy(cameraState.value, dx, dy, viewport) : orbitBy(cameraState.value, dx, dy, viewport), { emitEvent: false });
4087
+ }
4088
+ tooltipVisible.value = false;
4089
+ return;
4090
+ }
4091
+ const result = lastResult.value;
4092
+ if (!result) return;
4093
+ if (hitLegend(result.legend, p.x, p.y)) {
4094
+ canvasRef.value.style.cursor = "pointer";
4095
+ setHover(null, null);
4096
+ return;
4097
+ }
4098
+ const hit = result.pickAt(p.x, p.y);
4099
+ canvasRef.value.style.cursor = hit ? "pointer" : "grab";
4100
+ setHover(hit ? hit.meta.key : null, hit, p.x, p.y);
4101
+ }
4102
+ function handlePointerUp(evt) {
4103
+ pointers.delete(evt.pointerId);
4104
+ if (pointers.size < 2) pinchDist = 0;
4105
+ const wasDrag = dragState && dragState.id === evt.pointerId;
4106
+ const hadMoved = wasDrag && dragState.moved;
4107
+ const vel = wasDrag ? [dragState.vx, dragState.vy] : null;
4108
+ const panMode = wasDrag ? dragState.pan : false;
4109
+ dragState = null;
4110
+ if (wasDrag && hadMoved && cameraState.value) emit("camera-change", cameraPayload(cameraState.value));
4111
+ if (wasDrag && !hadMoved) {
4112
+ const p = canvasPoint(evt);
4113
+ if (p) handleClickAt(p);
4114
+ }
4115
+ if (hadMoved && vel) {
4116
+ inertiaVel = vel;
4117
+ inertiaPan = panMode;
4118
+ startInertia();
4119
+ return;
4120
+ }
4121
+ ensureAutoRotate();
4122
+ }
4123
+ function handleClickAt(p) {
4124
+ const result = lastResult.value;
4125
+ if (!result) return;
4126
+ const legendHit = hitLegend(result.legend, p.x, p.y);
4127
+ if (legendHit) {
4128
+ toggleSeries(legendHit.name);
4129
+ emit("legend-click", legendHit.name, hiddenSeries.value.has(legendHit.name));
4130
+ render();
4131
+ return;
4132
+ }
4133
+ const hit = result.pickAt(p.x, p.y);
4134
+ if (hit) {
4135
+ emit("click", {
4136
+ ...hit.meta,
4137
+ x: p.x,
4138
+ y: p.y
4139
+ });
4140
+ hoverKey.value = hit.meta.key;
4141
+ render();
4142
+ }
4143
+ }
4144
+ function handleDblClick(evt) {
4145
+ if ((props.options.interaction && typeof props.options.interaction === "object" ? props.options.interaction : {}).resetOnDblClick === false) return;
4146
+ evt.preventDefault();
4147
+ resetCamera();
4148
+ }
4149
+ function handlePointerLeave() {
4150
+ pointers.clear();
4151
+ pinchDist = 0;
4152
+ dragState = null;
4153
+ setHover(null, null);
4154
+ tooltipVisible.value = false;
4155
+ ensureAutoRotate();
4156
+ }
4157
+ function handleWheel(evt) {
4158
+ if ((props.options.interaction && typeof props.options.interaction === "object" ? props.options.interaction : {}).zoom === false) return;
4159
+ evt.preventDefault();
4160
+ if (!cameraState.value) return;
4161
+ stopInertia();
4162
+ userTouchedCamera = true;
4163
+ applyCamera(zoomBy(cameraState.value, wheelZoomFactor(evt.deltaY)), { emitEvent: false });
4164
+ }
4165
+ function handleKeydown(evt) {
4166
+ if (isEmpty.value || internalError.value) return;
4167
+ const cam = cameraState.value;
4168
+ if (!cam) return;
4169
+ stopInertia();
4170
+ const step = 8;
4171
+ let handled = true;
4172
+ switch (evt.key) {
4173
+ case "ArrowLeft":
4174
+ userTouchedCamera = true;
4175
+ applyCamera({
4176
+ ...cam,
4177
+ yaw: cam.yaw - step
4178
+ });
4179
+ break;
4180
+ case "ArrowRight":
4181
+ userTouchedCamera = true;
4182
+ applyCamera({
4183
+ ...cam,
4184
+ yaw: cam.yaw + step
4185
+ });
4186
+ break;
4187
+ case "ArrowUp":
4188
+ userTouchedCamera = true;
4189
+ applyCamera({
4190
+ ...cam,
4191
+ pitch: cam.pitch + step * .7
4192
+ });
4193
+ break;
4194
+ case "ArrowDown":
4195
+ userTouchedCamera = true;
4196
+ applyCamera({
4197
+ ...cam,
4198
+ pitch: cam.pitch - step * .7
4199
+ });
4200
+ break;
4201
+ case "+":
4202
+ case "=":
4203
+ userTouchedCamera = true;
4204
+ applyCamera(zoomBy(cam, .88));
4205
+ break;
4206
+ case "-":
4207
+ case "_":
4208
+ userTouchedCamera = true;
4209
+ applyCamera(zoomBy(cam, 1.14));
4210
+ break;
4211
+ case "Home":
4212
+ resetCamera();
4213
+ break;
4214
+ case "Escape":
4215
+ setHover(null, null);
4216
+ tooltipVisible.value = false;
4217
+ break;
4218
+ case "Enter":
4219
+ case " ": {
4220
+ const hit = hoverKey.value && lastResult.value ? lastResult.value.projected.items.find((i) => i.meta && i.meta.key === hoverKey.value) : null;
4221
+ if (hit) emit("click", { ...hit.meta });
4222
+ break;
4223
+ }
4224
+ default: handled = false;
4225
+ }
4226
+ if (handled) evt.preventDefault();
4227
+ }
4228
+ function handleFocusOut() {
4229
+ setHover(null, null);
4230
+ tooltipVisible.value = false;
4231
+ }
4232
+ let resizeObserver = null;
4233
+ let themeObserver = null;
4234
+ function handleThemeChange() {
4235
+ render();
4236
+ }
4237
+ function setupWatchers() {
4238
+ if (props.responsive && typeof ResizeObserver !== "undefined" && containerRef.value) {
4239
+ resizeObserver = new ResizeObserver(() => scheduleRender(16));
4240
+ resizeObserver.observe(containerRef.value);
4241
+ }
4242
+ if (typeof MutationObserver !== "undefined" && document.documentElement) {
4243
+ let lastDark = document.documentElement.classList.contains("dark");
4244
+ themeObserver = new MutationObserver(() => {
4245
+ const nowDark = document.documentElement.classList.contains("dark");
4246
+ if (nowDark !== lastDark) {
4247
+ lastDark = nowDark;
4248
+ handleThemeChange();
4249
+ }
4250
+ });
4251
+ themeObserver.observe(document.documentElement, {
4252
+ attributes: true,
4253
+ attributeFilter: ["class"]
4254
+ });
4255
+ }
4256
+ document.addEventListener("ev-theme-change", handleThemeChange);
4257
+ }
4258
+ watch(canvasRef, (el, prev) => {
4259
+ if (prev) prev.removeEventListener("wheel", handleWheel);
4260
+ if (el) el.addEventListener("wheel", handleWheel, { passive: false });
4261
+ }, { flush: "post" });
4262
+ function teardownWatchers() {
4263
+ if (resizeObserver) {
4264
+ resizeObserver.disconnect();
4265
+ resizeObserver = null;
4266
+ }
4267
+ if (themeObserver) {
4268
+ themeObserver.disconnect();
4269
+ themeObserver = null;
4270
+ }
4271
+ document.removeEventListener("ev-theme-change", handleThemeChange);
4272
+ const wheelTarget = canvasRef.value;
4273
+ if (wheelTarget) wheelTarget.removeEventListener("wheel", handleWheel);
4274
+ }
4275
+ let validatedOptionsRef = null;
4276
+ function runValidation() {
4277
+ if (validatedOptionsRef === props.options) return;
4278
+ validatedOptionsRef = props.options;
4279
+ const { ok, warnings } = validateOptions3d(props.options);
4280
+ if (ok || typeof console === "undefined") return;
4281
+ const seen = /* @__PURE__ */ new Set();
4282
+ for (const w of warnings) {
4283
+ const sig = `${w.path}:${w.message}`;
4284
+ if (seen.has(sig) || seen.size >= 8) break;
4285
+ seen.add(sig);
4286
+ console.warn(`[EvChart3d] ${w.path}: ${w.message}`);
4287
+ }
4288
+ }
4289
+ onMounted(() => {
4290
+ runValidation();
4291
+ cameraState.value = currentCamera();
4292
+ setupWatchers();
4293
+ nextTick(() => {
4294
+ render();
4295
+ startEnterAnimation();
4296
+ });
4297
+ });
4298
+ onBeforeUnmount(destroy);
4299
+ watch(() => props.options, () => {
4300
+ runValidation();
4301
+ internalError.value = "";
4302
+ cameraState.value = currentCamera();
4303
+ if (cameraState.value.autoRotate) ensureAutoRotate();
4304
+ userTouchedCamera = false;
4305
+ hoverKey.value = null;
4306
+ emit("data-update", {
4307
+ from: "options",
4308
+ to: "options"
4309
+ });
4310
+ if (startTweenIfNeeded()) return;
4311
+ scheduleRender(16);
4312
+ startEnterAnimation();
4313
+ }, { deep: true });
4314
+ watch(() => props.options.camera, () => {
4315
+ cameraState.value = currentCamera();
4316
+ if (cameraState.value.autoRotate) ensureAutoRotate();
4317
+ else stopAutoRotate();
4318
+ render();
4319
+ }, { deep: true });
4320
+ watch(() => [props.width, props.height], () => {
4321
+ nextTick(() => scheduleRender(0));
4322
+ });
4323
+ function toggleSeries(name) {
4324
+ const next = new Set(hiddenSeries.value);
4325
+ if (next.has(name)) next.delete(name);
4326
+ else next.add(name);
4327
+ hiddenSeries.value = next;
4328
+ render();
4329
+ }
4330
+ function destroy() {
4331
+ stopAutoRotate();
4332
+ stopInertia();
4333
+ stopTween();
4334
+ if (animRafId) {
4335
+ cancelAnimationFrame(animRafId);
4336
+ animRafId = 0;
4337
+ }
4338
+ animState = null;
4339
+ if (renderTimer) {
4340
+ clearTimeout(renderTimer);
4341
+ renderTimer = null;
4342
+ }
4343
+ teardownWatchers();
4344
+ lastResult.value = null;
4345
+ }
4346
+ function exportSVG(options = {}) {
4347
+ const canvas = document.createElement("canvas");
4348
+ const size = sizeCanvas();
4349
+ const w = size ? size.cssW : 600;
4350
+ const h = size ? size.cssH : 400;
4351
+ canvas.width = w;
4352
+ canvas.height = h;
4353
+ render3d(canvas, {
4354
+ options: props.options,
4355
+ dpr: 1,
4356
+ progress: 1,
4357
+ camera: cameraState.value || currentCamera(),
4358
+ hiddenSeries: hiddenSeries.value,
4359
+ hoverKey: null,
4360
+ theme: currentTheme(),
4361
+ autoFit: !userTouchedCamera,
4362
+ ctx: options.ctx
4363
+ });
4364
+ if (options.ctx && typeof options.toSvg === "function") return options.toSvg(w, h, options.backgroundColor || "#ffffff");
4365
+ return null;
4366
+ }
4367
+ __expose({
4368
+ refresh: () => render(),
4369
+ update(newOptions) {
4370
+ if (newOptions && typeof newOptions === "object") {
4371
+ for (const key of Object.keys(props.options)) delete props.options[key];
4372
+ Object.assign(props.options, newOptions);
4373
+ }
4374
+ internalError.value = "";
4375
+ cameraState.value = currentCamera();
4376
+ emit("data-update", {
4377
+ from: "update",
4378
+ to: "options"
4379
+ });
4380
+ scheduleRender(0);
4381
+ startEnterAnimation();
4382
+ },
4383
+ toDataURL(type = "image/png", quality = 1) {
4384
+ const canvas = canvasRef.value;
4385
+ if (!canvas) return null;
4386
+ try {
4387
+ return canvas.toDataURL(type, quality);
4388
+ } catch {
4389
+ return null;
4390
+ }
4391
+ },
4392
+ destroy,
4393
+ getCanvas: () => canvasRef.value,
4394
+ resize: () => {
4395
+ render();
4396
+ },
4397
+ getCamera: () => cameraState.value ? cameraPayload(cameraState.value) : null,
4398
+ setCamera(next) {
4399
+ stopInertia();
4400
+ userTouchedCamera = true;
4401
+ applyCamera(resolveCamera({
4402
+ ...currentCamera(),
4403
+ ...next || {}
4404
+ }));
4405
+ },
4406
+ resetCamera,
4407
+ toggleSeries,
4408
+ getHiddenSeries: () => new Set(hiddenSeries.value),
4409
+ getProjected: () => lastResult.value,
4410
+ exportSVG
4411
+ });
4412
+ const instance = getCurrentInstance();
4413
+ if (instance) instance.appContext.config.globalProperties.$evChart3dVersion = "0.1.0";
4414
+ return (_ctx, _cache) => {
4415
+ return openBlock(), createElementBlock("div", {
4416
+ ref_key: "containerRef",
4417
+ ref: containerRef,
4418
+ class: "ev-chart3d",
4419
+ style: normalizeStyle(containerStyle.value),
4420
+ role: "img",
4421
+ tabindex: isEmpty.value || internalError.value ? -1 : 0,
4422
+ "aria-label": props.options.ariaLabel || ariaLabelText.value,
4423
+ onKeydown: handleKeydown,
4424
+ onFocusout: handleFocusOut
4425
+ }, [
4426
+ !isEmpty.value && !internalError.value ? (openBlock(), createElementBlock("canvas", {
4427
+ key: 0,
4428
+ ref_key: "canvasRef",
4429
+ ref: canvasRef,
4430
+ class: "ev-chart3d__canvas",
4431
+ onPointerdown: handlePointerDown,
4432
+ onPointermove: handlePointerMove,
4433
+ onPointerup: handlePointerUp,
4434
+ onPointercancel: handlePointerUp,
4435
+ onDblclick: handleDblClick,
4436
+ onPointerleave: handlePointerLeave
4437
+ }, null, 544)) : createCommentVNode("", true),
4438
+ !isEmpty.value && !props.options.loading && !internalError.value && _ctx.$slots.overlay ? (openBlock(), createElementBlock("div", _hoisted_2, [renderSlot(_ctx.$slots, "overlay", {
4439
+ plotArea: overlayInfo.viewport,
4440
+ camera: overlayInfo.camera,
4441
+ options: props.options
4442
+ }, void 0, true)])) : createCommentVNode("", true),
4443
+ createVNode(Transition, { name: "ev-chart3d-fade" }, {
4444
+ default: withCtx(() => [isEmpty.value && !props.options.loading && !internalError.value ? (openBlock(), createElementBlock("div", _hoisted_3, [_cache[0] || (_cache[0] = createElementVNode("div", { class: "ev-chart3d__empty-icon-wrap" }, [createElementVNode("svg", {
4445
+ class: "ev-chart3d__empty-img",
4446
+ viewBox: "0 0 48 48",
4447
+ fill: "none",
4448
+ "aria-hidden": "true"
4449
+ }, [createElementVNode("path", {
4450
+ d: "M8 34 24 12l16 22",
4451
+ stroke: "currentColor",
4452
+ "stroke-width": "2.5",
4453
+ "stroke-linecap": "round",
4454
+ "stroke-linejoin": "round"
4455
+ }), createElementVNode("path", {
4456
+ d: "M8 34h32",
4457
+ stroke: "currentColor",
4458
+ "stroke-width": "2.5",
4459
+ "stroke-linecap": "round"
4460
+ })])], -1)), createElementVNode("span", _hoisted_4, toDisplayString(emptyText.value), 1)])) : createCommentVNode("", true)]),
4461
+ _: 1
4462
+ }),
4463
+ createVNode(Transition, { name: "ev-chart3d-fade" }, {
4464
+ default: withCtx(() => [props.options.loading && !internalError.value ? (openBlock(), createElementBlock("div", _hoisted_5, [createElementVNode("div", _hoisted_6, [(openBlock(), createElementBlock(Fragment, null, renderList(6, (i) => {
4465
+ return createElementVNode("div", {
4466
+ key: i,
4467
+ class: "ev-chart3d__loading-bar",
4468
+ style: normalizeStyle({ animationDelay: `${i * 80}ms` })
4469
+ }, null, 4);
4470
+ }), 64))])])) : createCommentVNode("", true)]),
4471
+ _: 1
4472
+ }),
4473
+ createVNode(Transition, { name: "ev-chart3d-fade" }, {
4474
+ default: withCtx(() => [internalError.value ? (openBlock(), createElementBlock("div", _hoisted_7, [_cache[1] || (_cache[1] = createElementVNode("span", { class: "ev-chart3d__error-icon" }, [createElementVNode("svg", {
4475
+ viewBox: "0 0 24 24",
4476
+ width: "1em",
4477
+ height: "1em",
4478
+ "aria-hidden": "true",
4479
+ fill: "none",
4480
+ stroke: "currentColor",
4481
+ "stroke-width": "2",
4482
+ "stroke-linecap": "round",
4483
+ "stroke-linejoin": "round"
4484
+ }, [
4485
+ createElementVNode("circle", {
4486
+ cx: "12",
4487
+ cy: "12",
4488
+ r: "9"
4489
+ }),
4490
+ createElementVNode("line", {
4491
+ x1: "12",
4492
+ y1: "7.5",
4493
+ x2: "12",
4494
+ y2: "13"
4495
+ }),
4496
+ createElementVNode("line", {
4497
+ x1: "12",
4498
+ y1: "16.5",
4499
+ x2: "12",
4500
+ y2: "16.51"
4501
+ })
4502
+ ])], -1)), createElementVNode("span", _hoisted_8, toDisplayString(internalError.value), 1)])) : createCommentVNode("", true)]),
4503
+ _: 1
4504
+ }),
4505
+ createVNode(Transition, { name: "ev-chart3d-tooltip" }, {
4506
+ default: withCtx(() => [tooltipVisible.value && tooltipContent.value ? (openBlock(), createElementBlock("div", {
4507
+ key: 0,
4508
+ ref_key: "tooltipRef",
4509
+ ref: tooltipRef,
4510
+ class: normalizeClass(["ev-chart3d__tooltip", `ev-chart3d__tooltip--${tooltipPlacement.value}`]),
4511
+ style: normalizeStyle(tooltipStyle.value),
4512
+ role: "tooltip",
4513
+ innerHTML: tooltipContent.value
4514
+ }, null, 14, _hoisted_9)) : createCommentVNode("", true)]),
4515
+ _: 1
4516
+ }),
4517
+ createElementVNode("div", _hoisted_10, toDisplayString(ariaLiveText.value), 1)
4518
+ ], 44, _hoisted_1);
4519
+ };
4520
+ }
4521
+ }), [["__scopeId", "data-v-36ccea38"]]);
4522
+ //#endregion
4523
+ //#region src/3d/useChart3d.js
4524
+ /**
4525
+ * useChart3d — 命令式使用三维图表的组合式 API
4526
+ * 模板里仍用 <EvChart3d ref="chartRef">,本 composable 只是把常用实例方法
4527
+ * 收拢成解构友好的形式,并提供相机快捷操作。
4528
+ */
4529
+ function useChart3d() {
4530
+ const containerRef = ref(null);
4531
+ const chartRef = shallowRef(null);
4532
+ const disposed = ref(false);
4533
+ const invoke = (method, ...args) => {
4534
+ const inst = chartRef.value;
4535
+ if (!inst || typeof inst[method] !== "function") return void 0;
4536
+ return inst[method](...args);
4537
+ };
4538
+ onBeforeUnmount(() => {
4539
+ disposed.value = true;
4540
+ invoke("destroy");
4541
+ });
4542
+ return {
4543
+ containerRef,
4544
+ chartRef,
4545
+ /** 相机读取/写入(自动触发重绘并派发 camera-change) */
4546
+ getCamera: () => invoke("getCamera"),
4547
+ setCamera: (next) => invoke("setCamera", next),
4548
+ resetCamera: () => invoke("resetCamera"),
4549
+ /** 相机快捷操作:环绕(度)与缩放(倍率) */
4550
+ orbit: (dYaw, dPitch) => {
4551
+ const cam = invoke("getCamera");
4552
+ if (!cam) return null;
4553
+ return invoke("setCamera", {
4554
+ yaw: cam.yaw + (dYaw || 0),
4555
+ pitch: cam.pitch + (dPitch || 0)
4556
+ });
4557
+ },
4558
+ zoom: (factor) => {
4559
+ const cam = invoke("getCamera");
4560
+ if (!cam) return null;
4561
+ return invoke("setCamera", { distance: cam.distance * (factor || 1) });
4562
+ },
4563
+ toggleSeries: (name) => invoke("toggleSeries", name),
4564
+ getHiddenSeries: () => invoke("getHiddenSeries"),
4565
+ toDataURL: (type, quality) => invoke("toDataURL", type, quality),
4566
+ exportSVG: (options) => invoke("exportSVG", options),
4567
+ refresh: () => invoke("refresh"),
4568
+ resize: () => invoke("resize"),
4569
+ getProjected: () => invoke("getProjected"),
4570
+ isDisposed: () => disposed.value
4571
+ };
4572
+ }
4573
+ //#endregion
4574
+ //#region src/3d/renderer/svgRecorder.js
4575
+ /**
4576
+ * SVG 导出录制器 — 用一个实现 Canvas 2D 接口的代理替换真 ctx,
4577
+ * 把绘制调用重放为 SVG 元素,实现三维图的矢量导出。
4578
+ *
4579
+ * 与 evoke-charts 的 svgRecorder 同思路、同 API 形状({ ctx, toSvg }),
4580
+ * 但按三维管线的实际用量裁剪:无裁剪、无位图;线性渐变(面内渐变)转 <linearGradient>。
4581
+ * 面是凸多边形 → 路径直出;文字按当前 CTM 反推锚点坐标。
4582
+ *
4583
+ * 注意:禁止使用 RegExp.exec(安全扫描误报),统一用 match。
4584
+ */
4585
+ function applyMatrix(m, x, y) {
4586
+ return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
4587
+ }
4588
+ function multiply(m1, m2) {
4589
+ return [
4590
+ m1[0] * m2[0] + m1[2] * m2[1],
4591
+ m1[1] * m2[0] + m1[3] * m2[1],
4592
+ m1[0] * m2[2] + m1[2] * m2[3],
4593
+ m1[1] * m2[2] + m1[3] * m2[3],
4594
+ m1[0] * m2[4] + m1[2] * m2[5] + m1[4],
4595
+ m1[1] * m2[4] + m1[3] * m2[5] + m1[5]
4596
+ ];
4597
+ }
4598
+ function escapeXml(s) {
4599
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4600
+ }
4601
+ /** 解析 canvas font 字符串 */
4602
+ function parseFont(font) {
4603
+ const s = typeof font === "string" ? font : "12px sans-serif";
4604
+ const sizeMatch = s.match(/(\d+(?:\.\d+)?)px/);
4605
+ const size = sizeMatch ? parseFloat(sizeMatch[1]) : 12;
4606
+ const weight = /bold/.test(s) ? "bold" : /[67]00/.test(s) ? "600" : "normal";
4607
+ const familyMatch = s.match(/px\s+(.*)$/);
4608
+ return {
4609
+ size,
4610
+ weight,
4611
+ family: (familyMatch ? familyMatch[1] : "sans-serif").replace(/["']/g, "")
4612
+ };
4613
+ }
4614
+ /** canvas 对齐值 → SVG text-anchor */
4615
+ var ANCHOR = {
4616
+ left: "start",
4617
+ start: "start",
4618
+ center: "middle",
4619
+ right: "end",
4620
+ end: "end"
4621
+ };
4622
+ /** canvas 基线 → SVG dominant-baseline */
4623
+ var BASELINE = {
4624
+ alphabetic: "alphabetic",
4625
+ middle: "central",
4626
+ middle_alias: "central",
4627
+ top: "text-before-edge",
4628
+ hanging: "text-before-edge",
4629
+ bottom: "text-after-edge",
4630
+ ideographic: "text-after-edge"
4631
+ };
4632
+ function colorToSvg(value) {
4633
+ if (typeof value === "string") return value;
4634
+ if (value && typeof value === "object" && typeof value.toString === "function") return value.toString();
4635
+ return "#000000";
4636
+ }
4637
+ /**
4638
+ * @param {CanvasRenderingContext2D} real 真实 ctx,仅用于 measureText 等测量能力
4639
+ * @returns {{ctx: object, toSvg: (width:number, height:number, backgroundColor?:string) => string}}
4640
+ */
4641
+ function createSvgRecorder(real) {
4642
+ const elements = [];
4643
+ const gradients = [];
4644
+ let matrix = [
4645
+ 1,
4646
+ 0,
4647
+ 0,
4648
+ 1,
4649
+ 0,
4650
+ 0
4651
+ ];
4652
+ const matrixStack = [];
4653
+ const styleStack = [];
4654
+ let path = [];
4655
+ let hasPath = false;
4656
+ const style = {
4657
+ fillStyle: "#000000",
4658
+ strokeStyle: "#000000",
4659
+ lineWidth: 1,
4660
+ globalAlpha: 1,
4661
+ font: "12px sans-serif",
4662
+ textAlign: "start",
4663
+ textBaseline: "alphabetic",
4664
+ lineDash: [],
4665
+ lineCap: "butt",
4666
+ lineJoin: "miter"
4667
+ };
4668
+ /** 填充样式 → SVG 值:渐变登记为 <linearGradient> 并引用 */
4669
+ function resolveFill() {
4670
+ if (typeof style.fillStyle === "string") return style.fillStyle;
4671
+ const g = style.fillStyle;
4672
+ if (!g || !g.__gradient) return colorToSvg(g);
4673
+ gradients.push(g);
4674
+ return `url(#evg${gradients.length - 1})`;
4675
+ }
4676
+ function pushPath(fill, stroke) {
4677
+ if (!hasPath || !path.length) return;
4678
+ let d = "";
4679
+ for (const cmd of path) {
4680
+ const pts = [];
4681
+ for (let i = 0; i < cmd.pts.length; i += 2) {
4682
+ const [x, y] = applyMatrix(matrix, cmd.pts[i], cmd.pts[i + 1]);
4683
+ pts.push(x.toFixed(2), y.toFixed(2));
4684
+ }
4685
+ if (cmd.op === "M") d += `M${pts[0]},${pts[1]}`;
4686
+ else if (cmd.op === "L") d += `L${pts[0]},${pts[1]}`;
4687
+ else if (cmd.op === "Q") d += `Q${pts.join(",")}`;
4688
+ else if (cmd.op === "C") d += `C${pts.join(",")}`;
4689
+ else if (cmd.op === "A") {
4690
+ const cx = cmd.pts[0];
4691
+ const cy = cmd.pts[1];
4692
+ const r = cmd.pts[2];
4693
+ const a0 = cmd.pts[3];
4694
+ const a1 = cmd.pts[4];
4695
+ const [tsx, tsy] = applyMatrix(matrix, cx + Math.cos(a0) * r, cy + Math.sin(a0) * r);
4696
+ const [tex, tey] = applyMatrix(matrix, cx + Math.cos(a1) * r, cy + Math.sin(a1) * r);
4697
+ const scale = (Math.hypot(matrix[0], matrix[1]) + Math.hypot(matrix[2], matrix[3])) / 2;
4698
+ d += `M${tsx.toFixed(2)},${tsy.toFixed(2)}A${(r * scale).toFixed(2)},${(r * scale).toFixed(2)} 0 0 ${cmd.pts[5] ? 0 : 1} ${tex.toFixed(2)},${tey.toFixed(2)}`;
4699
+ }
4700
+ if (cmd.closed) d += "Z";
4701
+ }
4702
+ const attrs = {
4703
+ d,
4704
+ fill: fill ? resolveFill() : "none",
4705
+ "fill-opacity": fill && style.globalAlpha < 1 ? style.globalAlpha.toFixed(3) : void 0
4706
+ };
4707
+ if (stroke) {
4708
+ attrs.stroke = colorToSvg(style.strokeStyle);
4709
+ attrs["stroke-width"] = style.lineWidth;
4710
+ attrs["stroke-opacity"] = style.globalAlpha < 1 ? style.globalAlpha.toFixed(3) : void 0;
4711
+ attrs["stroke-linecap"] = style.lineCap;
4712
+ attrs["stroke-linejoin"] = style.lineJoin;
4713
+ if (style.lineDash && style.lineDash.length) attrs["stroke-dasharray"] = style.lineDash.join(" ");
4714
+ }
4715
+ elements.push({
4716
+ tag: "path",
4717
+ attrs
4718
+ });
4719
+ }
4720
+ const proxy = {
4721
+ save() {
4722
+ matrixStack.push(matrix.slice());
4723
+ styleStack.push({
4724
+ ...style,
4725
+ lineDash: style.lineDash.slice()
4726
+ });
4727
+ },
4728
+ restore() {
4729
+ const m = matrixStack.pop();
4730
+ const s = styleStack.pop();
4731
+ if (m) matrix = m;
4732
+ if (s) Object.assign(style, s);
4733
+ },
4734
+ setTransform(a, b, c, d, e, f) {
4735
+ matrix = [
4736
+ a,
4737
+ b,
4738
+ c,
4739
+ d,
4740
+ e,
4741
+ f
4742
+ ];
4743
+ },
4744
+ transform(a, b, c, d, e, f) {
4745
+ matrix = multiply(matrix, [
4746
+ a,
4747
+ b,
4748
+ c,
4749
+ d,
4750
+ e,
4751
+ f
4752
+ ]);
4753
+ },
4754
+ translate(x, y) {
4755
+ matrix = multiply(matrix, [
4756
+ 1,
4757
+ 0,
4758
+ 0,
4759
+ 1,
4760
+ x,
4761
+ y
4762
+ ]);
4763
+ },
4764
+ scale(x, y) {
4765
+ matrix = multiply(matrix, [
4766
+ x,
4767
+ 0,
4768
+ 0,
4769
+ y,
4770
+ 0,
4771
+ 0
4772
+ ]);
4773
+ },
4774
+ rotate(rad) {
4775
+ const c = Math.cos(rad);
4776
+ const s = Math.sin(rad);
4777
+ matrix = multiply(matrix, [
4778
+ c,
4779
+ s,
4780
+ -s,
4781
+ c,
4782
+ 0,
4783
+ 0
4784
+ ]);
4785
+ },
4786
+ beginPath() {
4787
+ path = [];
4788
+ hasPath = true;
4789
+ },
4790
+ closePath() {
4791
+ if (path.length) path[path.length - 1].closed = true;
4792
+ },
4793
+ moveTo(x, y) {
4794
+ path.push({
4795
+ op: "M",
4796
+ pts: [x, y]
4797
+ });
4798
+ },
4799
+ lineTo(x, y) {
4800
+ path.push({
4801
+ op: "L",
4802
+ pts: [x, y]
4803
+ });
4804
+ },
4805
+ quadraticCurveTo(cx, cy, x, y) {
4806
+ path.push({
4807
+ op: "Q",
4808
+ pts: [
4809
+ cx,
4810
+ cy,
4811
+ x,
4812
+ y
4813
+ ]
4814
+ });
4815
+ },
4816
+ bezierCurveTo(c1x, c1y, c2x, c2y, x, y) {
4817
+ path.push({
4818
+ op: "C",
4819
+ pts: [
4820
+ c1x,
4821
+ c1y,
4822
+ c2x,
4823
+ c2y,
4824
+ x,
4825
+ y
4826
+ ]
4827
+ });
4828
+ },
4829
+ arc(x, y, r, a0, a1, ccw) {
4830
+ path.push({
4831
+ op: "A",
4832
+ pts: [
4833
+ x,
4834
+ y,
4835
+ r,
4836
+ a0,
4837
+ a1,
4838
+ !!ccw
4839
+ ]
4840
+ });
4841
+ },
4842
+ rect(x, y, w, h) {
4843
+ path.push({
4844
+ op: "M",
4845
+ pts: [x, y]
4846
+ });
4847
+ path.push({
4848
+ op: "L",
4849
+ pts: [x + w, y]
4850
+ });
4851
+ path.push({
4852
+ op: "L",
4853
+ pts: [x + w, y + h]
4854
+ });
4855
+ path.push({
4856
+ op: "L",
4857
+ pts: [x, y + h]
4858
+ });
4859
+ path[path.length - 1].closed = true;
4860
+ },
4861
+ fill() {
4862
+ pushPath(true, false);
4863
+ },
4864
+ stroke() {
4865
+ pushPath(false, true);
4866
+ },
4867
+ fillRect(x, y, w, h) {
4868
+ proxy.beginPath();
4869
+ proxy.rect(x, y, w, h);
4870
+ pushPath(true, false);
4871
+ path = [];
4872
+ },
4873
+ strokeRect(x, y, w, h) {
4874
+ proxy.beginPath();
4875
+ proxy.rect(x, y, w, h);
4876
+ pushPath(false, true);
4877
+ path = [];
4878
+ },
4879
+ clearRect() {},
4880
+ clip() {},
4881
+ fillText(text, x, y) {
4882
+ const [tx, ty] = applyMatrix(matrix, x, y);
4883
+ const f = parseFont(style.font);
4884
+ const baselineOffset = style.textBaseline === "alphabetic" ? 0 : 0;
4885
+ elements.push({
4886
+ tag: "text",
4887
+ attrs: {
4888
+ x: (tx + baselineOffset).toFixed(2),
4889
+ y: ty.toFixed(2),
4890
+ fill: colorToSvg(style.fillStyle),
4891
+ "font-size": f.size,
4892
+ "font-family": f.family,
4893
+ "font-weight": f.weight,
4894
+ "text-anchor": ANCHOR[style.textAlign] || "start",
4895
+ "dominant-baseline": BASELINE[style.textBaseline] || "alphabetic",
4896
+ ...style.globalAlpha < 1 ? { "fill-opacity": style.globalAlpha.toFixed(3) } : {}
4897
+ },
4898
+ text: String(text)
4899
+ });
4900
+ },
4901
+ strokeText() {},
4902
+ measureText(text) {
4903
+ if (real && typeof real.measureText === "function") return real.measureText(text);
4904
+ return { width: String(text).length * 7 };
4905
+ },
4906
+ createLinearGradient(x0, y0, x1, y1) {
4907
+ const [tx0, ty0] = applyMatrix(matrix, x0, y0);
4908
+ const [tx1, ty1] = applyMatrix(matrix, x1, y1);
4909
+ return {
4910
+ __gradient: true,
4911
+ x0: tx0,
4912
+ y0: ty0,
4913
+ x1: tx1,
4914
+ y1: ty1,
4915
+ stops: [],
4916
+ addColorStop(offset, color) {
4917
+ this.stops.push({
4918
+ offset,
4919
+ color
4920
+ });
4921
+ }
4922
+ };
4923
+ },
4924
+ createRadialGradient() {
4925
+ return {
4926
+ __gradient: false,
4927
+ addColorStop() {}
4928
+ };
4929
+ },
4930
+ setLineDash(dash) {
4931
+ style.lineDash = Array.isArray(dash) ? dash.slice() : [];
4932
+ },
4933
+ getLineDash() {
4934
+ return style.lineDash.slice();
4935
+ },
4936
+ drawImage() {},
4937
+ isPointInPath() {
4938
+ return false;
4939
+ }
4940
+ };
4941
+ const ctx = new Proxy(proxy, {
4942
+ get(target, prop) {
4943
+ if (prop in style) return style[prop];
4944
+ if (prop in target) return target[prop];
4945
+ },
4946
+ set(target, prop, value) {
4947
+ if (prop in style) {
4948
+ style[prop] = value;
4949
+ return true;
4950
+ }
4951
+ target[prop] = value;
4952
+ return true;
4953
+ }
4954
+ });
4955
+ /** 渐变定义:坐标已随 CTM 落到最终画布空间,用 userSpaceOnUse 直出 */
4956
+ function gradientDefs() {
4957
+ return gradients.map((g, i) => {
4958
+ const stops = g.stops.map((s) => `<stop offset="${(s.offset * 100).toFixed(1)}%" stop-color="${escapeXml(String(s.color))}"/>`).join("");
4959
+ return `<linearGradient id="evg${i}" gradientUnits="userSpaceOnUse" x1="${g.x0.toFixed(1)}" y1="${g.y0.toFixed(1)}" x2="${g.x1.toFixed(1)}" y2="${g.y1.toFixed(1)}">${stops}</linearGradient>`;
4960
+ }).join("");
4961
+ }
4962
+ function toSvg(width, height, backgroundColor = "#ffffff") {
4963
+ const body = elements.map((el) => {
4964
+ const attrs = Object.entries(el.attrs).filter(([, v]) => v !== void 0).map(([k, v]) => `${k}="${escapeXml(v)}"`).join(" ");
4965
+ if (el.tag === "text") return `<text ${attrs}>${escapeXml(el.text)}</text>`;
4966
+ return `<${el.tag} ${attrs}/>`;
4967
+ }).join("\n");
4968
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${gradients.length ? `<defs>${gradientDefs()}</defs>` : ""}<rect width="${width}" height="${height}" fill="${colorToSvg(backgroundColor)}"/>${body}</svg>`;
4969
+ }
4970
+ return {
4971
+ ctx,
4972
+ toSvg
4973
+ };
4974
+ }
4975
+ //#endregion
4976
+ //#region src/3d/index.js
4977
+ /**
4978
+ * Evoke Charts · 三维篇章(@wil-works/evoke-charts/3d)
4979
+ *
4980
+ * 与二维主入口(./index.js)平行的独立篇章:自绘透视投影管线
4981
+ * (向量/矩阵数学 → 轨道相机 → 场景图元 → 画家算法深度排序 → Canvas 2D),
4982
+ * 不依赖 WebGL 封装库;色系 / 令牌 / 换肤事件与二维同源。
4983
+ * 独立子入口保证只用二维的消费方不背三维的包体。
4984
+ */
4985
+ var components = { EvChart3d: chart3d_default };
4986
+ function install(app, _options = {}) {
4987
+ for (const [name, component] of Object.entries(components)) app.component(name, component);
4988
+ }
4989
+ var Charts3d = { install };
4990
+ var _3d_default = { install };
4991
+ //#endregion
4992
+ export { CHART3D_PALETTES, CHART3D_PALETTE_IDS, CHART3D_RAMPS, CHART3D_TYPES, CHART_COLORS, Charts3d, DEFAULT_CAMERA, chart3d_default as EvChart3d, applySeriesPalette, chart3dOptionsSchema, clearSeriesPalette, components, createSvgRecorder, _3d_default as default, easings, getTheme, install, pickScene, projectScene, render3d, resolveCamera, resolveChartPalette, resolveChartRamp, useChart3d, validateOptions3d };