@wil-works/evoke-charts 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -6
- package/dist/evoke-charts.css +54 -37
- package/dist/index.mjs +1894 -121
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createVNode, h, inject, nextTick, normalizeClass, normalizeStyle, onMounted, onUnmounted, openBlock, provide, ref, renderList, resolveDynamicComponent, toDisplayString, watch, withCtx } from "vue";
|
|
1
|
+
import { Fragment, Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createVNode, h, inject, nextTick, normalizeClass, normalizeStyle, onMounted, onUnmounted, openBlock, provide, ref, renderList, renderSlot, resolveDynamicComponent, toDisplayString, watch, withCtx } from "vue";
|
|
2
2
|
//#region src/types.js
|
|
3
3
|
var DEFAULT_I18N_ZH = {
|
|
4
4
|
tooltip: {
|
|
@@ -16,18 +16,20 @@ var DEFAULT_I18N_ZH = {
|
|
|
16
16
|
noData: "暂无数据"
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
19
|
+
* 系列色令牌槽位 — 每槽按顺序尝试读取专用数据色令牌(--ev-color-series-N),
|
|
20
|
+
* 槽 1 回退 --ev-color-primary(品牌主色跟随);
|
|
21
|
+
* 任一槽无令牌时该槽回落内置成套色板(与语义状态色解耦的数据色板)。
|
|
20
22
|
* CHART_COLORS 仅作为 SSR / 无 DOM 环境的静态兜底。
|
|
21
23
|
*/
|
|
22
|
-
var
|
|
23
|
-
"--ev-color-primary",
|
|
24
|
-
"--ev-color-
|
|
25
|
-
"--ev-color-
|
|
26
|
-
"--ev-color-
|
|
27
|
-
"--ev-color-
|
|
28
|
-
"--ev-color-
|
|
29
|
-
"--ev-color-
|
|
30
|
-
"--ev-color-
|
|
24
|
+
var SERIES_COLOR_SLOTS = [
|
|
25
|
+
["--ev-color-series-1", "--ev-color-primary"],
|
|
26
|
+
["--ev-color-series-2"],
|
|
27
|
+
["--ev-color-series-3"],
|
|
28
|
+
["--ev-color-series-4"],
|
|
29
|
+
["--ev-color-series-5"],
|
|
30
|
+
["--ev-color-series-6"],
|
|
31
|
+
["--ev-color-series-7"],
|
|
32
|
+
["--ev-color-series-8"]
|
|
31
33
|
];
|
|
32
34
|
function readChartToken(name, fallback) {
|
|
33
35
|
try {
|
|
@@ -38,24 +40,29 @@ function readChartToken(name, fallback) {
|
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
function getSeriesColors(fallbackColors) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
return SERIES_COLOR_SLOTS.map((tokens, i) => {
|
|
44
|
+
for (const t of tokens) {
|
|
45
|
+
const v = readChartToken(t, "");
|
|
46
|
+
if (v) return v;
|
|
47
|
+
}
|
|
48
|
+
return fallbackColors[i];
|
|
49
|
+
});
|
|
43
50
|
}
|
|
44
51
|
var CHART_COLORS = {
|
|
45
52
|
/**
|
|
46
|
-
* 主色板 —
|
|
47
|
-
*
|
|
48
|
-
*
|
|
53
|
+
* 主色板 — 品牌蓝锚定的成套数据色板(与语义状态色解耦):
|
|
54
|
+
* 色相按取色顺序相邻差 ≥ 30°,明度带 44–68,青绿/金黄/天蓝/珊瑚/紫/石板
|
|
55
|
+
* 取材 AntV 经典系并经品牌蓝调协调;暗色板同色相提亮。
|
|
49
56
|
*/
|
|
50
57
|
primary: [
|
|
51
58
|
"#175DFF",
|
|
52
|
-
"#
|
|
53
|
-
"#
|
|
54
|
-
"#
|
|
55
|
-
"#
|
|
56
|
-
"#
|
|
57
|
-
"#
|
|
58
|
-
"#
|
|
59
|
+
"#5AD8A6",
|
|
60
|
+
"#F6BD16",
|
|
61
|
+
"#6DC8EC",
|
|
62
|
+
"#E8684A",
|
|
63
|
+
"#9270CA",
|
|
64
|
+
"#FF9D4D",
|
|
65
|
+
"#5D7092"
|
|
59
66
|
],
|
|
60
67
|
/** 浅色系列 — primary 的 light-9/light-8/light-7 阶梯 */
|
|
61
68
|
light: [
|
|
@@ -63,16 +70,16 @@ var CHART_COLORS = {
|
|
|
63
70
|
"#d0e1ff",
|
|
64
71
|
"#b9d2ff"
|
|
65
72
|
],
|
|
66
|
-
/** 暗色模式系列 —
|
|
73
|
+
/** 暗色模式系列 — 同色相提亮(对齐 evoke 暗色主色) */
|
|
67
74
|
dark: [
|
|
68
75
|
"#4d8bff",
|
|
69
|
-
"#
|
|
70
|
-
"#
|
|
71
|
-
"#
|
|
72
|
-
"#
|
|
73
|
-
"#
|
|
74
|
-
"#
|
|
75
|
-
"#
|
|
76
|
+
"#5ad8a6",
|
|
77
|
+
"#f6bd16",
|
|
78
|
+
"#6dc8ec",
|
|
79
|
+
"#f08568",
|
|
80
|
+
"#a585e8",
|
|
81
|
+
"#ff9d4d",
|
|
82
|
+
"#8da3bf"
|
|
76
83
|
],
|
|
77
84
|
/** 热力图默认色阶(天亮蓝由浅到深) */
|
|
78
85
|
heatmapScale: [
|
|
@@ -292,6 +299,51 @@ function hasLegendContent(options) {
|
|
|
292
299
|
function titleBlockHeight(options) {
|
|
293
300
|
return (options.title ? 26 : 0) + (options.subtitle ? 18 : 0);
|
|
294
301
|
}
|
|
302
|
+
/** options.padding 覆写:数字(四边一致)或 { top, right, bottom, left }(未提供的边回落默认值) */
|
|
303
|
+
function resolveUserPadding(options) {
|
|
304
|
+
const p = options.padding;
|
|
305
|
+
if (!p) return {};
|
|
306
|
+
if (typeof p === "number") return {
|
|
307
|
+
top: p,
|
|
308
|
+
right: p,
|
|
309
|
+
bottom: p,
|
|
310
|
+
left: p
|
|
311
|
+
};
|
|
312
|
+
return p;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* y 轴左侧留白按刻度标签实测宽度自适应(替代固定 65):
|
|
316
|
+
* 取数据范围 → nice 刻度值 → 逐个测宽,留 26px(10 间隙 + 刻度短横 + 余量)。
|
|
317
|
+
* 对数轴/自定义 formatter 场景不可预估时回落 65;结果夹在 [40, 140]。
|
|
318
|
+
*/
|
|
319
|
+
function estimateYAxisLeft(options) {
|
|
320
|
+
const axisConfig = options.yAxis || {};
|
|
321
|
+
if (axisConfig.type === "log") return 65;
|
|
322
|
+
try {
|
|
323
|
+
if (options.type === "horizontal-bar") {
|
|
324
|
+
const formatter = axisConfig.formatter;
|
|
325
|
+
const maxW = (options.labels || []).reduce((m, l) => {
|
|
326
|
+
const label = formatter ? String(formatter(l)) : String(l);
|
|
327
|
+
return Math.max(m, estimateTextWidth(label, 12));
|
|
328
|
+
}, 0);
|
|
329
|
+
if (!maxW) return 65;
|
|
330
|
+
return Math.max(40, Math.min(140, Math.round(maxW + 26)));
|
|
331
|
+
}
|
|
332
|
+
const range = calculateRange(options, /* @__PURE__ */ new Set(), false);
|
|
333
|
+
const min = axisConfig.min ?? range?.min;
|
|
334
|
+
const max = axisConfig.max ?? range?.max;
|
|
335
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) return 65;
|
|
336
|
+
const { tickValues } = resolveTickExtendedRange(min, max, axisConfig.ticks || 5);
|
|
337
|
+
const maxW = tickValues.reduce((m, t) => {
|
|
338
|
+
const label = axisConfig.formatter ? axisConfig.formatter(t) : Number.isInteger(t) ? t.toString() : parseFloat(t.toFixed(6)).toString();
|
|
339
|
+
return Math.max(m, estimateTextWidth(String(label), 12));
|
|
340
|
+
}, 0);
|
|
341
|
+
if (!maxW) return 65;
|
|
342
|
+
return Math.max(40, Math.min(140, Math.round(maxW + 26)));
|
|
343
|
+
} catch {
|
|
344
|
+
return 65;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
295
347
|
function getPadding(options, containerWidth = 600) {
|
|
296
348
|
const legend = options.legend || {};
|
|
297
349
|
const legendPosition = legend.show !== false && hasLegendContent(options) ? legend.position || "bottom" : "none";
|
|
@@ -311,12 +363,15 @@ function getPadding(options, containerWidth = 600) {
|
|
|
311
363
|
const zoom = options.dataZoom;
|
|
312
364
|
const hasDataZoom = !!zoom?.enabled && hasXAxis;
|
|
313
365
|
const zoomHeight = (zoom?.height || 40) + 14;
|
|
314
|
-
if (options.type === "sparkline")
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
366
|
+
if (options.type === "sparkline") {
|
|
367
|
+
const sparkPad = resolveUserPadding(options);
|
|
368
|
+
return {
|
|
369
|
+
top: sparkPad.top ?? (options.title ? 32 : 6),
|
|
370
|
+
right: sparkPad.right ?? 6,
|
|
371
|
+
bottom: sparkPad.bottom ?? 6,
|
|
372
|
+
left: sparkPad.left ?? 6
|
|
373
|
+
};
|
|
374
|
+
}
|
|
320
375
|
const legendRowsExtra = (() => {
|
|
321
376
|
if (legendPosition !== "top" && legendPosition !== "bottom") return 0;
|
|
322
377
|
return (estimateLegendRows(options, containerWidth) - 1) * 20;
|
|
@@ -335,13 +390,14 @@ function getPadding(options, containerWidth = 600) {
|
|
|
335
390
|
"bin",
|
|
336
391
|
"sunburst"
|
|
337
392
|
].includes(options.type)) {
|
|
338
|
-
|
|
339
|
-
let
|
|
340
|
-
let
|
|
341
|
-
let
|
|
393
|
+
const pad = resolveUserPadding(options);
|
|
394
|
+
let top2 = (pad.top ?? 18) + titleBlockHeight(options);
|
|
395
|
+
let bottom2 = pad.bottom ?? 18;
|
|
396
|
+
let left2 = pad.left ?? 20;
|
|
397
|
+
let right2 = pad.right ?? 20;
|
|
342
398
|
if (options.type === "heatmap") {
|
|
343
399
|
const maxYLabel = Array.from(new Set((options.heatmapData || []).map((d) => d.y))).reduce((m, s) => Math.max(m, estimateTextWidth(s, 11)), 0);
|
|
344
|
-
left2 = Math.min(130, Math.max(24, maxYLabel + 14));
|
|
400
|
+
left2 = pad.left ?? Math.min(130, Math.max(24, maxYLabel + 14));
|
|
345
401
|
bottom2 = Math.max(bottom2, 26);
|
|
346
402
|
if (options.heatmap?.colorBar === true || options.heatmapColorBar === true) right2 += 44;
|
|
347
403
|
}
|
|
@@ -350,7 +406,7 @@ function getPadding(options, containerWidth = 600) {
|
|
|
350
406
|
(options.series || []).forEach((s) => allValues.push(...s.data.filter((v) => !isMissingValue(v))));
|
|
351
407
|
const maxFreq = allValues.length;
|
|
352
408
|
const tickLabelWidth = String(maxFreq).length * 7;
|
|
353
|
-
left2 = Math.min(90, Math.max(36, tickLabelWidth + 16));
|
|
409
|
+
left2 = pad.left ?? Math.min(90, Math.max(36, tickLabelWidth + 16));
|
|
354
410
|
bottom2 = Math.max(bottom2, 30);
|
|
355
411
|
}
|
|
356
412
|
if (options.type === "bullet") {
|
|
@@ -371,21 +427,45 @@ function getPadding(options, containerWidth = 600) {
|
|
|
371
427
|
left: left2
|
|
372
428
|
};
|
|
373
429
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
430
|
+
const pad = resolveUserPadding(options);
|
|
431
|
+
const xAxisHidden = options.xAxis?.show === false;
|
|
432
|
+
const yAxisWidth = options.yAxis?.width;
|
|
433
|
+
const yAxisRightWidth = options.yAxisRight?.width;
|
|
434
|
+
let top = (pad.top ?? 18) + titleBlockHeight(options) + (legendPosition === "top" ? LEGEND_BAND + legendRowsExtra : 0);
|
|
435
|
+
let bottom = (pad.bottom ?? (xAxisHidden ? 12 : 46)) + (legendPosition === "bottom" ? LEGEND_BAND + legendRowsExtra : 0);
|
|
436
|
+
let left = pad.left ?? (typeof yAxisWidth === "number" && yAxisWidth > 0 ? yAxisWidth : estimateYAxisLeft(options));
|
|
437
|
+
let right = pad.right ?? (typeof yAxisRightWidth === "number" && yAxisRightWidth > 0 ? yAxisRightWidth : hasYAxisRight ? 65 : 24);
|
|
378
438
|
if (legendPosition === "left") left += 80;
|
|
379
439
|
if (legendPosition === "right") right += 80;
|
|
380
|
-
if (hasXAxis && hasXAxisTitle)
|
|
440
|
+
if (hasXAxis && hasXAxisTitle) return {
|
|
441
|
+
top,
|
|
442
|
+
right,
|
|
443
|
+
bottom: bottom + 15,
|
|
444
|
+
left
|
|
445
|
+
};
|
|
381
446
|
const xRotate = options.xAxis?.rotate || 0;
|
|
382
447
|
if (hasXAxis && xRotate) {
|
|
383
448
|
const maxLabelWidth = (options.labels || []).reduce((m, s) => Math.max(m, estimateTextWidth(String(s), 12)), 0);
|
|
384
|
-
|
|
449
|
+
return {
|
|
450
|
+
top,
|
|
451
|
+
right,
|
|
452
|
+
bottom: bottom + Math.min(90, 12 + maxLabelWidth * Math.sin(Math.min(60, Math.abs(xRotate)) * Math.PI / 180)),
|
|
453
|
+
left
|
|
454
|
+
};
|
|
385
455
|
}
|
|
386
456
|
if (hasDataZoom) {
|
|
387
|
-
if ((zoom?.position || "bottom") === "top")
|
|
388
|
-
|
|
457
|
+
if ((zoom?.position || "bottom") === "top") return {
|
|
458
|
+
top: top + zoomHeight,
|
|
459
|
+
right,
|
|
460
|
+
bottom,
|
|
461
|
+
left
|
|
462
|
+
};
|
|
463
|
+
return {
|
|
464
|
+
top,
|
|
465
|
+
right,
|
|
466
|
+
bottom: bottom + zoomHeight,
|
|
467
|
+
left
|
|
468
|
+
};
|
|
389
469
|
}
|
|
390
470
|
return {
|
|
391
471
|
top,
|
|
@@ -570,12 +650,15 @@ function calculateRange(options, hiddenSeries, useRightAxis) {
|
|
|
570
650
|
const range = max - min || 1;
|
|
571
651
|
if (min >= 0) return {
|
|
572
652
|
min: 0,
|
|
573
|
-
max: max + range * .1
|
|
574
|
-
};
|
|
575
|
-
else return {
|
|
576
|
-
min: min - range * .1,
|
|
577
|
-
max: max + range * .1
|
|
653
|
+
max: max + Math.max(range * .1, max * .05)
|
|
578
654
|
};
|
|
655
|
+
else {
|
|
656
|
+
const pad = Math.max(range * .1, Math.max(Math.abs(max), Math.abs(min)) * .05);
|
|
657
|
+
return {
|
|
658
|
+
min: min - pad,
|
|
659
|
+
max: max + pad
|
|
660
|
+
};
|
|
661
|
+
}
|
|
579
662
|
}
|
|
580
663
|
function applyLogTransform(options) {
|
|
581
664
|
if (options.yAxis?.type !== "log") return options;
|
|
@@ -700,6 +783,20 @@ function truncateLabel(canvasCtx, label, maxWidth) {
|
|
|
700
783
|
}
|
|
701
784
|
return label.slice(0, lo) + "…";
|
|
702
785
|
}
|
|
786
|
+
/**
|
|
787
|
+
* 抽稀刻度:保留首末两端覆盖,步长取整数倍使数量 ≤ maxCount。
|
|
788
|
+
* nice 算法的 step 候选会让实际刻度数超出请求数(如 ticks:3 得到 5 档),
|
|
789
|
+
* 显式 ticks 视为硬上限,超出的中间档按均匀步长抽稀(round 值不丢)。
|
|
790
|
+
*/
|
|
791
|
+
function thinTickValues(values, maxCount) {
|
|
792
|
+
const limit = Math.max(2, maxCount);
|
|
793
|
+
if (values.length <= limit) return values;
|
|
794
|
+
let stride = Math.ceil((values.length - 1) / (limit - 1));
|
|
795
|
+
while (stride < values.length - 1 && (values.length - 1) % stride !== 0) stride += 1;
|
|
796
|
+
const thinned = [];
|
|
797
|
+
for (let i = 0; i < values.length; i += stride) thinned.push(values[i]);
|
|
798
|
+
return thinned;
|
|
799
|
+
}
|
|
703
800
|
function drawAxisLine(canvasCtx, cfg, theme, x1, y1, x2, y2) {
|
|
704
801
|
if (cfg?.axisLine?.show === false) return;
|
|
705
802
|
canvasCtx.save();
|
|
@@ -726,7 +823,10 @@ function drawAxisTick(canvasCtx, cfg, theme, x, y, dx, dy) {
|
|
|
726
823
|
function renderYAxis(ctx, side = "left", dataRange) {
|
|
727
824
|
const { ctx: canvasCtx, theme, plotArea, options, width } = ctx;
|
|
728
825
|
const axisConfig = side === "left" ? options.yAxis || {} : options.yAxisRight || {};
|
|
729
|
-
const
|
|
826
|
+
const requestedTicks = axisConfig.ticks || 5;
|
|
827
|
+
const maxByHeight = Math.max(2, Math.floor(plotArea.height / 24) + 1);
|
|
828
|
+
const tickCap = axisConfig.ticks != null ? Math.min(requestedTicks, maxByHeight) : maxByHeight;
|
|
829
|
+
const needsThin = axisConfig.ticks != null || maxByHeight < requestedTicks;
|
|
730
830
|
const isLog = side === "left" && axisConfig.type === "log";
|
|
731
831
|
let computedMax;
|
|
732
832
|
let computedMin;
|
|
@@ -741,7 +841,8 @@ function renderYAxis(ctx, side = "left", dataRange) {
|
|
|
741
841
|
}
|
|
742
842
|
}
|
|
743
843
|
const max = axisConfig.max ?? computedMax;
|
|
744
|
-
const { min: actualMin, max: actualMax, tickValues } = resolveTickExtendedRange(axisConfig.min ?? computedMin, max,
|
|
844
|
+
const { min: actualMin, max: actualMax, tickValues: niceTicks } = resolveTickExtendedRange(axisConfig.min ?? computedMin, max, requestedTicks);
|
|
845
|
+
const tickValues = needsThin ? thinTickValues(niceTicks, tickCap) : niceTicks;
|
|
745
846
|
canvasCtx.save();
|
|
746
847
|
canvasCtx.strokeStyle = theme.gridColor;
|
|
747
848
|
canvasCtx.fillStyle = theme.textColorSecondary;
|
|
@@ -774,6 +875,14 @@ function renderYAxis(ctx, side = "left", dataRange) {
|
|
|
774
875
|
else label = Number.isInteger(tick) ? tick.toString() : parseFloat(tick.toFixed(6)).toString();
|
|
775
876
|
label = truncateLabel(canvasCtx, label, maxLabelWidth);
|
|
776
877
|
canvasCtx.fillText(label, xPos, y);
|
|
878
|
+
if (side === "left") {
|
|
879
|
+
canvasCtx.strokeStyle = theme.gridColor;
|
|
880
|
+
canvasCtx.lineWidth = 1;
|
|
881
|
+
canvasCtx.beginPath();
|
|
882
|
+
canvasCtx.moveTo(plotArea.x - 6, y);
|
|
883
|
+
canvasCtx.lineTo(plotArea.x, y);
|
|
884
|
+
canvasCtx.stroke();
|
|
885
|
+
}
|
|
777
886
|
});
|
|
778
887
|
drawAxisLine(canvasCtx, axisConfig, theme, side === "left" ? plotArea.x : plotArea.x + plotArea.width, plotArea.y, side === "left" ? plotArea.x : plotArea.x + plotArea.width, plotArea.y + plotArea.height);
|
|
779
888
|
if (axisConfig.title) {
|
|
@@ -964,6 +1073,10 @@ function xToCategoryIndex(options, plotArea, labels, x) {
|
|
|
964
1073
|
return Math.round((x - plotArea.x) / step);
|
|
965
1074
|
}
|
|
966
1075
|
function renderAnnotations(ctx, yRange) {
|
|
1076
|
+
renderLegacyAnnotation(ctx, yRange);
|
|
1077
|
+
renderNarrativeAnnotations(ctx, yRange);
|
|
1078
|
+
}
|
|
1079
|
+
function renderLegacyAnnotation(ctx, yRange) {
|
|
967
1080
|
const { ctx: canvasCtx, theme, plotArea, options } = ctx;
|
|
968
1081
|
const annotation = options.annotation;
|
|
969
1082
|
if (!annotation) return;
|
|
@@ -1026,6 +1139,196 @@ function renderAnnotations(ctx, yRange) {
|
|
|
1026
1139
|
canvasCtx.restore();
|
|
1027
1140
|
});
|
|
1028
1141
|
}
|
|
1142
|
+
var ANNOTATION_ANCHORS = {
|
|
1143
|
+
top: [
|
|
1144
|
+
0,
|
|
1145
|
+
-1,
|
|
1146
|
+
"center",
|
|
1147
|
+
"bottom"
|
|
1148
|
+
],
|
|
1149
|
+
bottom: [
|
|
1150
|
+
0,
|
|
1151
|
+
1,
|
|
1152
|
+
"center",
|
|
1153
|
+
"top"
|
|
1154
|
+
],
|
|
1155
|
+
left: [
|
|
1156
|
+
-1,
|
|
1157
|
+
0,
|
|
1158
|
+
"end",
|
|
1159
|
+
"middle"
|
|
1160
|
+
],
|
|
1161
|
+
right: [
|
|
1162
|
+
1,
|
|
1163
|
+
0,
|
|
1164
|
+
"start",
|
|
1165
|
+
"middle"
|
|
1166
|
+
],
|
|
1167
|
+
"top-left": [
|
|
1168
|
+
-1,
|
|
1169
|
+
-1,
|
|
1170
|
+
"end",
|
|
1171
|
+
"bottom"
|
|
1172
|
+
],
|
|
1173
|
+
"top-right": [
|
|
1174
|
+
1,
|
|
1175
|
+
-1,
|
|
1176
|
+
"start",
|
|
1177
|
+
"bottom"
|
|
1178
|
+
],
|
|
1179
|
+
"bottom-left": [
|
|
1180
|
+
-1,
|
|
1181
|
+
1,
|
|
1182
|
+
"end",
|
|
1183
|
+
"top"
|
|
1184
|
+
],
|
|
1185
|
+
"bottom-right": [
|
|
1186
|
+
1,
|
|
1187
|
+
1,
|
|
1188
|
+
"start",
|
|
1189
|
+
"top"
|
|
1190
|
+
]
|
|
1191
|
+
};
|
|
1192
|
+
function resolveAnnotationX(ctx, x, xPx) {
|
|
1193
|
+
if (typeof xPx === "number") return xPx;
|
|
1194
|
+
const { plotArea, options } = ctx;
|
|
1195
|
+
const labels = options.labels || [];
|
|
1196
|
+
const idx = typeof x === "number" ? x : labels.indexOf(x);
|
|
1197
|
+
if (idx < 0) return NaN;
|
|
1198
|
+
return categoryToX(options, plotArea, labels, idx);
|
|
1199
|
+
}
|
|
1200
|
+
function resolveAnnotationY(ctx, y, yPx, yRange) {
|
|
1201
|
+
if (typeof yPx === "number") return yPx;
|
|
1202
|
+
if (typeof y !== "number" || !yRange) return NaN;
|
|
1203
|
+
const { plotArea } = ctx;
|
|
1204
|
+
return plotArea.y + plotArea.height - (y - yRange.min) / (yRange.max - yRange.min) * plotArea.height;
|
|
1205
|
+
}
|
|
1206
|
+
function annotationSeriesColor(ctx, ref) {
|
|
1207
|
+
const { theme, options } = ctx;
|
|
1208
|
+
const series = options.series || [];
|
|
1209
|
+
if (typeof ref === "number") return series[ref]?.color || theme.colors[ref] || theme.colors[0] || "#175DFF";
|
|
1210
|
+
if (typeof ref === "string") {
|
|
1211
|
+
const i = series.findIndex((s) => s.name === ref);
|
|
1212
|
+
if (i >= 0) return series[i].color || theme.colors[i] || theme.colors[0] || "#175DFF";
|
|
1213
|
+
}
|
|
1214
|
+
return theme.colors[0] || "#175DFF";
|
|
1215
|
+
}
|
|
1216
|
+
function renderNarrativeAnnotations(ctx, yRange) {
|
|
1217
|
+
const { ctx: canvasCtx, theme, plotArea, options } = ctx;
|
|
1218
|
+
const list = options.annotations || [];
|
|
1219
|
+
if (list.length === 0) return;
|
|
1220
|
+
const secondary = theme.textColorSecondary;
|
|
1221
|
+
list.forEach((a) => {
|
|
1222
|
+
if (!a || !a.type) return;
|
|
1223
|
+
if (a.type === "region") {
|
|
1224
|
+
const x1 = resolveAnnotationX(ctx, a.from);
|
|
1225
|
+
const x2 = resolveAnnotationX(ctx, a.to);
|
|
1226
|
+
if (!Number.isFinite(x1) || !Number.isFinite(x2)) return;
|
|
1227
|
+
const labels = options.labels || [];
|
|
1228
|
+
const band = labels.length > 0 ? plotArea.width / labels.length : 0;
|
|
1229
|
+
const left = Math.min(x1, x2) - band / 2;
|
|
1230
|
+
const width = Math.abs(x2 - x1) + band;
|
|
1231
|
+
canvasCtx.save();
|
|
1232
|
+
canvasCtx.globalAlpha = .06;
|
|
1233
|
+
canvasCtx.fillStyle = a.color || theme.colors[0] || "#175DFF";
|
|
1234
|
+
canvasCtx.fillRect(left, plotArea.y, width, plotArea.height);
|
|
1235
|
+
canvasCtx.restore();
|
|
1236
|
+
if (a.label) {
|
|
1237
|
+
canvasCtx.save();
|
|
1238
|
+
canvasCtx.fillStyle = secondary;
|
|
1239
|
+
canvasCtx.font = "italic 400 11px Inter, sans-serif";
|
|
1240
|
+
canvasCtx.textAlign = "left";
|
|
1241
|
+
canvasCtx.textBaseline = "top";
|
|
1242
|
+
canvasCtx.fillText(a.label, left + 4, plotArea.y + 6);
|
|
1243
|
+
canvasCtx.restore();
|
|
1244
|
+
}
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
const px = resolveAnnotationX(ctx, a.x, a.xPx);
|
|
1248
|
+
const py = resolveAnnotationY(ctx, a.y, a.yPx, yRange);
|
|
1249
|
+
if (!Number.isFinite(px) || !Number.isFinite(py)) return;
|
|
1250
|
+
const color = a.color || annotationSeriesColor(ctx, a.series);
|
|
1251
|
+
canvasCtx.save();
|
|
1252
|
+
if (a.type === "point") {
|
|
1253
|
+
canvasCtx.fillStyle = color;
|
|
1254
|
+
canvasCtx.beginPath();
|
|
1255
|
+
canvasCtx.arc(px, py, 3, 0, Math.PI * 2);
|
|
1256
|
+
canvasCtx.fill();
|
|
1257
|
+
canvasCtx.globalAlpha = .85;
|
|
1258
|
+
canvasCtx.strokeStyle = color;
|
|
1259
|
+
canvasCtx.lineWidth = 1.5;
|
|
1260
|
+
canvasCtx.beginPath();
|
|
1261
|
+
canvasCtx.arc(px, py, 6, 0, Math.PI * 2);
|
|
1262
|
+
canvasCtx.stroke();
|
|
1263
|
+
canvasCtx.restore();
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
if (a.type === "delta") {
|
|
1267
|
+
const up = a.direction !== "down";
|
|
1268
|
+
const deltaColor = a.color || (up ? options.candleUpColor || "#dc2626" : options.candleDownColor || "#16a34a");
|
|
1269
|
+
const text = a.text || a.label || "";
|
|
1270
|
+
canvasCtx.fillStyle = deltaColor;
|
|
1271
|
+
canvasCtx.beginPath();
|
|
1272
|
+
if (up) {
|
|
1273
|
+
canvasCtx.moveTo(px, py - 16);
|
|
1274
|
+
canvasCtx.lineTo(px - 5, py - 9);
|
|
1275
|
+
canvasCtx.lineTo(px + 5, py - 9);
|
|
1276
|
+
} else {
|
|
1277
|
+
canvasCtx.moveTo(px, py + 16);
|
|
1278
|
+
canvasCtx.lineTo(px - 5, py + 9);
|
|
1279
|
+
canvasCtx.lineTo(px + 5, py + 9);
|
|
1280
|
+
}
|
|
1281
|
+
canvasCtx.closePath();
|
|
1282
|
+
canvasCtx.fill();
|
|
1283
|
+
if (text) {
|
|
1284
|
+
canvasCtx.fillStyle = deltaColor;
|
|
1285
|
+
canvasCtx.font = "600 11px Inter, sans-serif";
|
|
1286
|
+
canvasCtx.textAlign = "center";
|
|
1287
|
+
if (up) {
|
|
1288
|
+
canvasCtx.textBaseline = "bottom";
|
|
1289
|
+
canvasCtx.fillText(text, px, py - 20);
|
|
1290
|
+
} else {
|
|
1291
|
+
canvasCtx.textBaseline = "top";
|
|
1292
|
+
canvasCtx.fillText(text, px, py + 20);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
canvasCtx.restore();
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
if (a.type === "callout") {
|
|
1299
|
+
const [dx, dy, align, baseline] = ANNOTATION_ANCHORS[a.anchor] || ANNOTATION_ANCHORS["top-right"];
|
|
1300
|
+
const lx = px + dx * 16 + (a.offsetX || 0);
|
|
1301
|
+
const ly = py + dy * 16 + (a.offsetY || 0);
|
|
1302
|
+
canvasCtx.fillStyle = color;
|
|
1303
|
+
canvasCtx.strokeStyle = color;
|
|
1304
|
+
canvasCtx.lineWidth = 1;
|
|
1305
|
+
canvasCtx.setLineDash([3, 3]);
|
|
1306
|
+
canvasCtx.beginPath();
|
|
1307
|
+
canvasCtx.arc(px, py, 2, 0, Math.PI * 2);
|
|
1308
|
+
canvasCtx.fill();
|
|
1309
|
+
canvasCtx.beginPath();
|
|
1310
|
+
canvasCtx.moveTo(px + dx * 2, py + dy * 2);
|
|
1311
|
+
canvasCtx.lineTo(lx - dx * 2, ly - dy * 2);
|
|
1312
|
+
canvasCtx.stroke();
|
|
1313
|
+
canvasCtx.setLineDash([]);
|
|
1314
|
+
if (a.label) {
|
|
1315
|
+
canvasCtx.fillStyle = a.textColor || secondary;
|
|
1316
|
+
canvasCtx.font = "italic 400 11px Inter, sans-serif";
|
|
1317
|
+
canvasCtx.textAlign = align;
|
|
1318
|
+
canvasCtx.textBaseline = baseline;
|
|
1319
|
+
canvasCtx.fillText(a.label, lx + dx * 2, ly + dy * 2);
|
|
1320
|
+
}
|
|
1321
|
+
canvasCtx.restore();
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
canvasCtx.fillStyle = a.color || secondary;
|
|
1325
|
+
canvasCtx.font = `italic ${a.fontWeight || 400} 11px Inter, sans-serif`;
|
|
1326
|
+
canvasCtx.textAlign = "center";
|
|
1327
|
+
canvasCtx.textBaseline = "bottom";
|
|
1328
|
+
canvasCtx.fillText(a.text || a.label || "", px + (a.offsetX || 0), py + (a.offsetY !== void 0 ? a.offsetY : -6));
|
|
1329
|
+
canvasCtx.restore();
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1029
1332
|
function renderHoverHighlight(ctx, points) {
|
|
1030
1333
|
if (ctx.hoverIndex < 0) return;
|
|
1031
1334
|
const { ctx: canvasCtx, theme, plotArea } = ctx;
|
|
@@ -1158,14 +1461,14 @@ function renderTitle(ctx, paddingTop) {
|
|
|
1158
1461
|
if (!options.title) return;
|
|
1159
1462
|
canvasCtx.save();
|
|
1160
1463
|
canvasCtx.fillStyle = theme.textColor;
|
|
1161
|
-
canvasCtx.font = "
|
|
1162
|
-
canvasCtx.textAlign = "
|
|
1464
|
+
canvasCtx.font = "600 13px Inter, sans-serif";
|
|
1465
|
+
canvasCtx.textAlign = "left";
|
|
1163
1466
|
canvasCtx.textBaseline = "top";
|
|
1164
|
-
canvasCtx.fillText(options.title, plotArea.x
|
|
1467
|
+
canvasCtx.fillText(options.title, plotArea.x, paddingTop);
|
|
1165
1468
|
if (options.subtitle) {
|
|
1166
|
-
canvasCtx.font = "
|
|
1469
|
+
canvasCtx.font = "11px Inter, sans-serif";
|
|
1167
1470
|
canvasCtx.fillStyle = theme.textColorSecondary;
|
|
1168
|
-
canvasCtx.fillText(options.subtitle, plotArea.x
|
|
1471
|
+
canvasCtx.fillText(options.subtitle, plotArea.x, paddingTop + 20);
|
|
1169
1472
|
}
|
|
1170
1473
|
canvasCtx.restore();
|
|
1171
1474
|
}
|
|
@@ -1284,12 +1587,12 @@ function renderLegend(ctx) {
|
|
|
1284
1587
|
canvasCtx.fill();
|
|
1285
1588
|
} else if (icon === "rect") canvasCtx.fillRect(bound.x, bound.y + 5, 12, 12);
|
|
1286
1589
|
else {
|
|
1287
|
-
roundRect(canvasCtx, bound.x, bound.y + 5, 12,
|
|
1590
|
+
roundRect(canvasCtx, bound.x, bound.y + 9.5, 12, 3, 1.5);
|
|
1288
1591
|
canvasCtx.fill();
|
|
1289
1592
|
}
|
|
1290
1593
|
canvasCtx.restore();
|
|
1291
1594
|
canvasCtx.save();
|
|
1292
|
-
canvasCtx.fillStyle =
|
|
1595
|
+
canvasCtx.fillStyle = theme.textColorSecondary;
|
|
1293
1596
|
canvasCtx.globalAlpha = isHidden ? .6 : 1;
|
|
1294
1597
|
canvasCtx.textAlign = "left";
|
|
1295
1598
|
canvasCtx.textBaseline = "middle";
|
|
@@ -1388,6 +1691,32 @@ function computePieMaxRadius(pieData, options, plotArea) {
|
|
|
1388
1691
|
function alphaHex(opacity) {
|
|
1389
1692
|
return Math.round(Math.max(0, Math.min(1, opacity)) * 255).toString(16).padStart(2, "0");
|
|
1390
1693
|
}
|
|
1694
|
+
/** 单调三次插值切线(Fritsch–Carlson 限幅):平滑但不过冲,曲线必过每个数据点 */
|
|
1695
|
+
function monotoneTangents(pts) {
|
|
1696
|
+
const n = pts.length;
|
|
1697
|
+
const m = new Array(n).fill(0);
|
|
1698
|
+
const d = [];
|
|
1699
|
+
for (let i = 0; i < n - 1; i++) d.push((pts[i + 1][1] - pts[i][1]) / (pts[i + 1][0] - pts[i][0]));
|
|
1700
|
+
m[0] = d[0];
|
|
1701
|
+
m[n - 1] = d[n - 2];
|
|
1702
|
+
for (let i = 1; i < n - 1; i++) m[i] = d[i - 1] * d[i] <= 0 ? 0 : (d[i - 1] + d[i]) / 2;
|
|
1703
|
+
for (let i = 0; i < n - 1; i++) {
|
|
1704
|
+
if (d[i] === 0) {
|
|
1705
|
+
m[i] = 0;
|
|
1706
|
+
m[i + 1] = 0;
|
|
1707
|
+
continue;
|
|
1708
|
+
}
|
|
1709
|
+
const a = m[i] / d[i];
|
|
1710
|
+
const b = m[i + 1] / d[i];
|
|
1711
|
+
const s = a * a + b * b;
|
|
1712
|
+
if (s > 9) {
|
|
1713
|
+
const tk = 3 / Math.sqrt(s);
|
|
1714
|
+
m[i] = tk * a * d[i];
|
|
1715
|
+
m[i + 1] = tk * b * d[i];
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
return m;
|
|
1719
|
+
}
|
|
1391
1720
|
function renderLineChart(ctx, yRange, side = "left") {
|
|
1392
1721
|
const { ctx: canvasCtx, theme, plotArea, options, progress, hoverIndex, hiddenSeries, valueFormatter } = ctx;
|
|
1393
1722
|
const allPoints = [];
|
|
@@ -1443,16 +1772,25 @@ function renderLineChart(ctx, yRange, side = "left") {
|
|
|
1443
1772
|
});
|
|
1444
1773
|
if (segments.length === 0) return;
|
|
1445
1774
|
const step = series.step || options.step;
|
|
1775
|
+
const smooth = !!(series.smooth ?? options.smooth);
|
|
1446
1776
|
const tracePath = (pts, startMove) => {
|
|
1447
1777
|
startMove(canvasCtx, pts[0]);
|
|
1778
|
+
if (smooth && pts.length > 2) {
|
|
1779
|
+
const m = monotoneTangents(pts);
|
|
1780
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
1781
|
+
const x0 = pts[i][0];
|
|
1782
|
+
const y0 = pts[i][1];
|
|
1783
|
+
const x1 = pts[i + 1][0];
|
|
1784
|
+
const y1 = pts[i + 1][1];
|
|
1785
|
+
const h = (x1 - x0) / 3;
|
|
1786
|
+
canvasCtx.bezierCurveTo(x0 + h, y0 + h * m[i], x1 - h, y1 - h * m[i + 1], x1, y1);
|
|
1787
|
+
}
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1448
1790
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
1449
1791
|
const curr = pts[i];
|
|
1450
1792
|
const next = pts[i + 1];
|
|
1451
|
-
if (
|
|
1452
|
-
const cp1x = curr[0] + (next[0] - curr[0]) / 3;
|
|
1453
|
-
const cp2x = next[0] - (next[0] - curr[0]) / 3;
|
|
1454
|
-
canvasCtx.bezierCurveTo(cp1x, curr[1], cp2x, next[1], next[0], next[1]);
|
|
1455
|
-
} else if (step) {
|
|
1793
|
+
if (step) {
|
|
1456
1794
|
if (step === "start") {
|
|
1457
1795
|
canvasCtx.lineTo(next[0], curr[1]);
|
|
1458
1796
|
canvasCtx.lineTo(next[0], next[1]);
|
|
@@ -1512,7 +1850,7 @@ function renderLineChart(ctx, yRange, side = "left") {
|
|
|
1512
1850
|
}
|
|
1513
1851
|
canvasCtx.beginPath();
|
|
1514
1852
|
canvasCtx.strokeStyle = color;
|
|
1515
|
-
canvasCtx.lineWidth = series.lineWidth ||
|
|
1853
|
+
canvasCtx.lineWidth = series.lineWidth || 1.5;
|
|
1516
1854
|
canvasCtx.lineJoin = "round";
|
|
1517
1855
|
canvasCtx.lineCap = "round";
|
|
1518
1856
|
canvasCtx.setLineDash(resolveLineDash(series));
|
|
@@ -1520,7 +1858,7 @@ function renderLineChart(ctx, yRange, side = "left") {
|
|
|
1520
1858
|
canvasCtx.stroke();
|
|
1521
1859
|
canvasCtx.setLineDash([]);
|
|
1522
1860
|
if (!series.area && options.type !== "area") canvasCtx.restore();
|
|
1523
|
-
if (series.showSymbol
|
|
1861
|
+
if (series.showSymbol === true && series.symbol !== "none") {
|
|
1524
1862
|
const baseR = series.symbolSize ?? 4;
|
|
1525
1863
|
points.forEach((p, i) => {
|
|
1526
1864
|
if (!p) return;
|
|
@@ -1541,6 +1879,15 @@ function renderLineChart(ctx, yRange, side = "left") {
|
|
|
1541
1879
|
canvasCtx.stroke();
|
|
1542
1880
|
}
|
|
1543
1881
|
});
|
|
1882
|
+
} else if (hoverIndex >= 0 && points[hoverIndex]) {
|
|
1883
|
+
const p = points[hoverIndex];
|
|
1884
|
+
canvasCtx.beginPath();
|
|
1885
|
+
canvasCtx.arc(p[0], p[1], 4.5, 0, Math.PI * 2);
|
|
1886
|
+
canvasCtx.fillStyle = theme.backgroundColor;
|
|
1887
|
+
canvasCtx.fill();
|
|
1888
|
+
canvasCtx.strokeStyle = color;
|
|
1889
|
+
canvasCtx.lineWidth = 2;
|
|
1890
|
+
canvasCtx.stroke();
|
|
1544
1891
|
}
|
|
1545
1892
|
if ((series.showValues !== void 0 ? series.showValues : options.showValues) && progress > .9) {
|
|
1546
1893
|
canvasCtx.save();
|
|
@@ -3380,9 +3727,13 @@ function renderDataZoomSlider(ctx, zoom) {
|
|
|
3380
3727
|
}
|
|
3381
3728
|
const { xStart, xEnd } = windowToX(zoom, geo);
|
|
3382
3729
|
const windowFill = theme.crosshairColor.includes("rgba") ? theme.crosshairColor.replace(/[\d.]+\)$/, "0.12)") : theme.crosshairColor + "1f";
|
|
3730
|
+
canvasCtx.save();
|
|
3731
|
+
roundRect(canvasCtx, geo.x, geo.y, geo.width, geo.height, 4);
|
|
3732
|
+
canvasCtx.clip();
|
|
3383
3733
|
canvasCtx.fillStyle = theme.backgroundColor + "aa";
|
|
3384
3734
|
canvasCtx.fillRect(geo.x, geo.y, xStart - geo.x, geo.height);
|
|
3385
3735
|
canvasCtx.fillRect(xEnd, geo.y, geo.x + geo.width - xEnd, geo.height);
|
|
3736
|
+
canvasCtx.restore();
|
|
3386
3737
|
canvasCtx.strokeStyle = theme.gridColor;
|
|
3387
3738
|
canvasCtx.lineWidth = 1;
|
|
3388
3739
|
roundRect(canvasCtx, geo.x, geo.y, geo.width, geo.height, 4);
|
|
@@ -3394,21 +3745,26 @@ function renderDataZoomSlider(ctx, zoom) {
|
|
|
3394
3745
|
canvasCtx.lineWidth = 1;
|
|
3395
3746
|
roundRect(canvasCtx, xStart, geo.y, Math.max(2, xEnd - xStart), geo.height, 4);
|
|
3396
3747
|
canvasCtx.stroke();
|
|
3397
|
-
const handleW =
|
|
3748
|
+
const handleW = 9;
|
|
3398
3749
|
const handleH = 18;
|
|
3399
3750
|
const handleY = geo.y + (geo.height - handleH) / 2;
|
|
3400
3751
|
[xStart - handleW / 2, xEnd - handleW / 2].forEach((hx) => {
|
|
3401
|
-
roundRect(canvasCtx, hx, handleY, handleW, handleH,
|
|
3402
|
-
canvasCtx.fillStyle = theme.
|
|
3752
|
+
roundRect(canvasCtx, hx, handleY, handleW, handleH, 4);
|
|
3753
|
+
canvasCtx.fillStyle = theme.backgroundColor;
|
|
3403
3754
|
canvasCtx.fill();
|
|
3404
|
-
canvasCtx.strokeStyle = theme.
|
|
3405
|
-
canvasCtx.lineWidth = 1;
|
|
3755
|
+
canvasCtx.strokeStyle = theme.crosshairColor;
|
|
3756
|
+
canvasCtx.lineWidth = 1.5;
|
|
3757
|
+
canvasCtx.stroke();
|
|
3758
|
+
canvasCtx.strokeStyle = theme.crosshairColor;
|
|
3759
|
+
canvasCtx.lineWidth = 1.5;
|
|
3760
|
+
canvasCtx.lineCap = "round";
|
|
3406
3761
|
canvasCtx.beginPath();
|
|
3407
|
-
canvasCtx.moveTo(hx + handleW / 2 - 2, handleY +
|
|
3408
|
-
canvasCtx.lineTo(hx + handleW / 2 - 2, handleY + handleH -
|
|
3409
|
-
canvasCtx.moveTo(hx + handleW / 2 + 2, handleY +
|
|
3410
|
-
canvasCtx.lineTo(hx + handleW / 2 + 2, handleY + handleH -
|
|
3762
|
+
canvasCtx.moveTo(hx + handleW / 2 - 2, handleY + 6);
|
|
3763
|
+
canvasCtx.lineTo(hx + handleW / 2 - 2, handleY + handleH - 6);
|
|
3764
|
+
canvasCtx.moveTo(hx + handleW / 2 + 2, handleY + 6);
|
|
3765
|
+
canvasCtx.lineTo(hx + handleW / 2 + 2, handleY + handleH - 6);
|
|
3411
3766
|
canvasCtx.stroke();
|
|
3767
|
+
canvasCtx.lineCap = "butt";
|
|
3412
3768
|
});
|
|
3413
3769
|
canvasCtx.restore();
|
|
3414
3770
|
}
|
|
@@ -3836,6 +4192,13 @@ function createSvgRecorder(real) {
|
|
|
3836
4192
|
}
|
|
3837
4193
|
//#endregion
|
|
3838
4194
|
//#region src/renderer/index.js
|
|
4195
|
+
function runLayers(renderCtx, at) {
|
|
4196
|
+
const layers = renderCtx.options.layers;
|
|
4197
|
+
if (!Array.isArray(layers)) return;
|
|
4198
|
+
layers.forEach((layer) => {
|
|
4199
|
+
if (layer && typeof layer.draw === "function" && (layer.at || "front") === at) layer.draw(renderCtx.ctx, renderCtx);
|
|
4200
|
+
});
|
|
4201
|
+
}
|
|
3839
4202
|
function renderChart(canvas, params) {
|
|
3840
4203
|
const { options: rawOptions, ctx: externalCtx, dpr = window.devicePixelRatio || 1, progress = 1, hoverIndex = -1, hiddenSeries = /* @__PURE__ */ new Set(), mouseX = -1, mouseY = -1, showCrosshair = false, hoverAnimProgress = 1, zoomRange, brushRect, focusSeries = null, hoveredToolbox = null } = params;
|
|
3841
4204
|
const options = applyLogTransform(rawOptions);
|
|
@@ -3873,6 +4236,7 @@ function renderChart(canvas, params) {
|
|
|
3873
4236
|
focusSeries
|
|
3874
4237
|
};
|
|
3875
4238
|
renderTitle(renderCtx, options.subtitle ? 12 : 15);
|
|
4239
|
+
runLayers(renderCtx, "back");
|
|
3876
4240
|
let points = [];
|
|
3877
4241
|
let leftRange;
|
|
3878
4242
|
switch (options.type) {
|
|
@@ -4016,7 +4380,8 @@ function renderChart(canvas, params) {
|
|
|
4016
4380
|
break;
|
|
4017
4381
|
}
|
|
4018
4382
|
}
|
|
4019
|
-
|
|
4383
|
+
runLayers(renderCtx, "after-series");
|
|
4384
|
+
if (leftRange && (options.annotation || options.annotations && options.annotations.length > 0)) renderAnnotations(renderCtx, leftRange);
|
|
4020
4385
|
if ((options.type === "line" || options.type === "area") && points.length > 0) renderHoverHighlight(renderCtx, points);
|
|
4021
4386
|
if (points.length > 0) renderCrosshair(renderCtx, points);
|
|
4022
4387
|
if (zoomRange) renderDataZoomSlider(renderCtx, zoomRange);
|
|
@@ -4034,6 +4399,7 @@ function renderChart(canvas, params) {
|
|
|
4034
4399
|
ctx.strokeRect(x, y, w, h);
|
|
4035
4400
|
ctx.restore();
|
|
4036
4401
|
}
|
|
4402
|
+
runLayers(renderCtx, "front");
|
|
4037
4403
|
if (options.type !== "sparkline") renderLegend(renderCtx);
|
|
4038
4404
|
if (options.type !== "sparkline") renderToolbox(renderCtx, hoveredToolbox);
|
|
4039
4405
|
ctx.restore();
|
|
@@ -4061,6 +4427,372 @@ function broadcastConnect(group, source, apply) {
|
|
|
4061
4427
|
if (target !== source) apply(target);
|
|
4062
4428
|
});
|
|
4063
4429
|
}
|
|
4430
|
+
var chartOptionsSchema = {
|
|
4431
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
4432
|
+
title: "EvChart Options",
|
|
4433
|
+
type: "object",
|
|
4434
|
+
required: ["type"],
|
|
4435
|
+
properties: {
|
|
4436
|
+
type: {
|
|
4437
|
+
type: "string",
|
|
4438
|
+
enum: [
|
|
4439
|
+
"line",
|
|
4440
|
+
"area",
|
|
4441
|
+
"bar",
|
|
4442
|
+
"stacked-bar",
|
|
4443
|
+
"horizontal-bar",
|
|
4444
|
+
"pie",
|
|
4445
|
+
"doughnut",
|
|
4446
|
+
"rose",
|
|
4447
|
+
"radar",
|
|
4448
|
+
"scatter",
|
|
4449
|
+
"funnel",
|
|
4450
|
+
"gauge",
|
|
4451
|
+
"heatmap",
|
|
4452
|
+
"candle",
|
|
4453
|
+
"bin",
|
|
4454
|
+
"bullet",
|
|
4455
|
+
"treemap",
|
|
4456
|
+
"sparkline",
|
|
4457
|
+
"waterfall",
|
|
4458
|
+
"boxplot",
|
|
4459
|
+
"sunburst",
|
|
4460
|
+
"mixed"
|
|
4461
|
+
]
|
|
4462
|
+
},
|
|
4463
|
+
title: { type: "string" },
|
|
4464
|
+
subtitle: { type: "string" },
|
|
4465
|
+
labels: { type: "array" },
|
|
4466
|
+
series: {
|
|
4467
|
+
type: "array",
|
|
4468
|
+
items: {
|
|
4469
|
+
type: "object",
|
|
4470
|
+
required: ["name", "data"],
|
|
4471
|
+
properties: {
|
|
4472
|
+
name: { type: "string" },
|
|
4473
|
+
data: { type: "array" },
|
|
4474
|
+
color: { type: "string" },
|
|
4475
|
+
showSymbol: { type: "boolean" },
|
|
4476
|
+
lineWidth: { type: "number" },
|
|
4477
|
+
smooth: { type: "boolean" },
|
|
4478
|
+
yAxis: {
|
|
4479
|
+
type: "string",
|
|
4480
|
+
enum: ["left", "right"]
|
|
4481
|
+
},
|
|
4482
|
+
type: { type: "string" },
|
|
4483
|
+
area: { type: "boolean" }
|
|
4484
|
+
}
|
|
4485
|
+
}
|
|
4486
|
+
},
|
|
4487
|
+
legend: {
|
|
4488
|
+
type: "object",
|
|
4489
|
+
properties: {
|
|
4490
|
+
show: { type: "boolean" },
|
|
4491
|
+
position: {
|
|
4492
|
+
type: "string",
|
|
4493
|
+
enum: ["top", "bottom"]
|
|
4494
|
+
},
|
|
4495
|
+
interactive: { type: "boolean" },
|
|
4496
|
+
hoverEmphasis: { type: "boolean" }
|
|
4497
|
+
}
|
|
4498
|
+
},
|
|
4499
|
+
tooltip: {
|
|
4500
|
+
type: "object",
|
|
4501
|
+
properties: {
|
|
4502
|
+
show: { type: "boolean" },
|
|
4503
|
+
trigger: {
|
|
4504
|
+
type: "string",
|
|
4505
|
+
enum: ["hover", "click"]
|
|
4506
|
+
},
|
|
4507
|
+
showAllSeries: { type: "boolean" },
|
|
4508
|
+
formatter: { type: "function" }
|
|
4509
|
+
}
|
|
4510
|
+
},
|
|
4511
|
+
animation: {
|
|
4512
|
+
type: "object",
|
|
4513
|
+
properties: {
|
|
4514
|
+
enabled: { type: "boolean" },
|
|
4515
|
+
duration: { type: "number" },
|
|
4516
|
+
easing: { type: "string" }
|
|
4517
|
+
}
|
|
4518
|
+
},
|
|
4519
|
+
xAxis: {
|
|
4520
|
+
type: "object",
|
|
4521
|
+
properties: {
|
|
4522
|
+
show: { type: "boolean" },
|
|
4523
|
+
min: { type: "number" },
|
|
4524
|
+
max: { type: "number" },
|
|
4525
|
+
ticks: { type: "integer" },
|
|
4526
|
+
grid: {
|
|
4527
|
+
type: "object",
|
|
4528
|
+
properties: {
|
|
4529
|
+
show: { type: "boolean" },
|
|
4530
|
+
style: {
|
|
4531
|
+
type: "string",
|
|
4532
|
+
enum: ["solid", "dashed"]
|
|
4533
|
+
}
|
|
4534
|
+
}
|
|
4535
|
+
}
|
|
4536
|
+
}
|
|
4537
|
+
},
|
|
4538
|
+
yAxis: {
|
|
4539
|
+
type: "object",
|
|
4540
|
+
properties: {
|
|
4541
|
+
show: { type: "boolean" },
|
|
4542
|
+
min: { type: "number" },
|
|
4543
|
+
max: { type: "number" },
|
|
4544
|
+
ticks: { type: "integer" },
|
|
4545
|
+
width: { type: "number" },
|
|
4546
|
+
grid: {
|
|
4547
|
+
type: "object",
|
|
4548
|
+
properties: {
|
|
4549
|
+
show: { type: "boolean" },
|
|
4550
|
+
style: {
|
|
4551
|
+
type: "string",
|
|
4552
|
+
enum: ["solid", "dashed"]
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
}
|
|
4556
|
+
}
|
|
4557
|
+
},
|
|
4558
|
+
yAxisRight: { $ref: "#/properties/yAxis" },
|
|
4559
|
+
padding: { oneOf: [{ type: "number" }, {
|
|
4560
|
+
type: "object",
|
|
4561
|
+
properties: {
|
|
4562
|
+
top: { type: "number" },
|
|
4563
|
+
right: { type: "number" },
|
|
4564
|
+
bottom: { type: "number" },
|
|
4565
|
+
left: { type: "number" }
|
|
4566
|
+
}
|
|
4567
|
+
}] },
|
|
4568
|
+
dataZoom: {
|
|
4569
|
+
type: "object",
|
|
4570
|
+
properties: {
|
|
4571
|
+
enabled: { type: "boolean" },
|
|
4572
|
+
start: { type: "number" },
|
|
4573
|
+
end: { type: "number" },
|
|
4574
|
+
position: {
|
|
4575
|
+
type: "string",
|
|
4576
|
+
enum: ["bottom", "top"]
|
|
4577
|
+
},
|
|
4578
|
+
height: { type: "number" },
|
|
4579
|
+
mouseWheel: { type: "boolean" }
|
|
4580
|
+
}
|
|
4581
|
+
},
|
|
4582
|
+
toolbox: {
|
|
4583
|
+
type: "object",
|
|
4584
|
+
properties: {
|
|
4585
|
+
show: { type: "boolean" },
|
|
4586
|
+
filename: { type: "string" }
|
|
4587
|
+
}
|
|
4588
|
+
},
|
|
4589
|
+
theme: { type: "object" },
|
|
4590
|
+
layers: {
|
|
4591
|
+
type: "array",
|
|
4592
|
+
items: {
|
|
4593
|
+
type: "object",
|
|
4594
|
+
properties: {
|
|
4595
|
+
at: {
|
|
4596
|
+
type: "string",
|
|
4597
|
+
enum: [
|
|
4598
|
+
"back",
|
|
4599
|
+
"after-series",
|
|
4600
|
+
"front"
|
|
4601
|
+
]
|
|
4602
|
+
},
|
|
4603
|
+
draw: { type: "function" }
|
|
4604
|
+
}
|
|
4605
|
+
}
|
|
4606
|
+
},
|
|
4607
|
+
emphasis: {
|
|
4608
|
+
type: "object",
|
|
4609
|
+
properties: {
|
|
4610
|
+
series: { oneOf: [{ type: "string" }, { type: "integer" }] },
|
|
4611
|
+
dimOthers: { type: "boolean" }
|
|
4612
|
+
}
|
|
4613
|
+
},
|
|
4614
|
+
annotations: {
|
|
4615
|
+
type: "array",
|
|
4616
|
+
items: {
|
|
4617
|
+
type: "object",
|
|
4618
|
+
oneOf: [{
|
|
4619
|
+
required: [
|
|
4620
|
+
"type",
|
|
4621
|
+
"x",
|
|
4622
|
+
"y"
|
|
4623
|
+
],
|
|
4624
|
+
properties: { type: { enum: [
|
|
4625
|
+
"text",
|
|
4626
|
+
"callout",
|
|
4627
|
+
"delta",
|
|
4628
|
+
"point"
|
|
4629
|
+
] } }
|
|
4630
|
+
}, {
|
|
4631
|
+
required: [
|
|
4632
|
+
"type",
|
|
4633
|
+
"from",
|
|
4634
|
+
"to"
|
|
4635
|
+
],
|
|
4636
|
+
properties: { type: { enum: ["region"] } }
|
|
4637
|
+
}],
|
|
4638
|
+
properties: {
|
|
4639
|
+
type: {
|
|
4640
|
+
type: "string",
|
|
4641
|
+
enum: [
|
|
4642
|
+
"text",
|
|
4643
|
+
"callout",
|
|
4644
|
+
"region",
|
|
4645
|
+
"delta",
|
|
4646
|
+
"point"
|
|
4647
|
+
]
|
|
4648
|
+
},
|
|
4649
|
+
x: {},
|
|
4650
|
+
y: { type: "number" },
|
|
4651
|
+
from: {},
|
|
4652
|
+
to: {},
|
|
4653
|
+
at: {},
|
|
4654
|
+
label: { type: "string" },
|
|
4655
|
+
text: { type: "string" },
|
|
4656
|
+
anchor: {
|
|
4657
|
+
type: "string",
|
|
4658
|
+
enum: [
|
|
4659
|
+
"top",
|
|
4660
|
+
"bottom",
|
|
4661
|
+
"left",
|
|
4662
|
+
"right",
|
|
4663
|
+
"top-left",
|
|
4664
|
+
"top-right",
|
|
4665
|
+
"bottom-left",
|
|
4666
|
+
"bottom-right"
|
|
4667
|
+
]
|
|
4668
|
+
},
|
|
4669
|
+
color: { type: "string" },
|
|
4670
|
+
offsetX: { type: "number" },
|
|
4671
|
+
offsetY: { type: "number" },
|
|
4672
|
+
xPx: { type: "number" },
|
|
4673
|
+
yPx: { type: "number" }
|
|
4674
|
+
}
|
|
4675
|
+
}
|
|
4676
|
+
},
|
|
4677
|
+
annotation: { type: "object" },
|
|
4678
|
+
markLine: { type: "array" },
|
|
4679
|
+
markArea: { type: "array" },
|
|
4680
|
+
scenes: {
|
|
4681
|
+
type: "object",
|
|
4682
|
+
properties: {
|
|
4683
|
+
autoplay: { type: "boolean" },
|
|
4684
|
+
loop: { type: "boolean" },
|
|
4685
|
+
items: {
|
|
4686
|
+
type: "array",
|
|
4687
|
+
items: {
|
|
4688
|
+
type: "object",
|
|
4689
|
+
required: ["patch"],
|
|
4690
|
+
properties: {
|
|
4691
|
+
patch: { type: "object" },
|
|
4692
|
+
duration: { type: "number" },
|
|
4693
|
+
hold: { type: "number" }
|
|
4694
|
+
}
|
|
4695
|
+
}
|
|
4696
|
+
}
|
|
4697
|
+
}
|
|
4698
|
+
},
|
|
4699
|
+
pieData: { type: "array" },
|
|
4700
|
+
radarSeries: { type: "array" },
|
|
4701
|
+
radarIndicators: { type: "array" },
|
|
4702
|
+
scatterData: { type: "array" },
|
|
4703
|
+
funnelData: { type: "array" },
|
|
4704
|
+
gauge: { oneOf: [{ type: "number" }, { type: "object" }] },
|
|
4705
|
+
heatmapData: { type: "array" },
|
|
4706
|
+
candleData: { type: "array" },
|
|
4707
|
+
bulletData: { type: "array" },
|
|
4708
|
+
boxData: { type: "array" },
|
|
4709
|
+
loading: { type: "boolean" },
|
|
4710
|
+
emptyText: { type: "string" },
|
|
4711
|
+
ariaLabel: { type: "string" },
|
|
4712
|
+
i18n: { type: "object" },
|
|
4713
|
+
connectGroup: { type: "string" },
|
|
4714
|
+
brush: {
|
|
4715
|
+
type: "object",
|
|
4716
|
+
properties: { enabled: { type: "boolean" } }
|
|
4717
|
+
},
|
|
4718
|
+
logAxis: { type: "boolean" },
|
|
4719
|
+
valueFormatter: { type: "function" },
|
|
4720
|
+
timeAxis: { type: "boolean" }
|
|
4721
|
+
}
|
|
4722
|
+
};
|
|
4723
|
+
var TYPE_CHECKS = {
|
|
4724
|
+
string: (v) => typeof v === "string",
|
|
4725
|
+
number: (v) => typeof v === "number" && Number.isFinite(v),
|
|
4726
|
+
integer: (v) => typeof v === "number" && Number.isInteger(v),
|
|
4727
|
+
boolean: (v) => typeof v === "boolean",
|
|
4728
|
+
object: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
|
|
4729
|
+
array: (v) => Array.isArray(v),
|
|
4730
|
+
function: (v) => typeof v === "function"
|
|
4731
|
+
};
|
|
4732
|
+
function typeMatches(node, value) {
|
|
4733
|
+
if (Array.isArray(node.type)) return node.type.some((t) => TYPE_CHECKS[t]?.(value));
|
|
4734
|
+
const check = TYPE_CHECKS[node.type];
|
|
4735
|
+
return check ? check(value) : true;
|
|
4736
|
+
}
|
|
4737
|
+
function validateNode(node, value, path, warnings) {
|
|
4738
|
+
if (node == null || value === void 0 || value === null) return;
|
|
4739
|
+
if (node.oneOf) {
|
|
4740
|
+
if (!node.oneOf.some((branch) => {
|
|
4741
|
+
const before = warnings.length;
|
|
4742
|
+
validateNode(branch, value, path, warnings);
|
|
4743
|
+
const ok = warnings.length === before;
|
|
4744
|
+
warnings.length = before;
|
|
4745
|
+
return ok;
|
|
4746
|
+
})) {
|
|
4747
|
+
warnings.push({
|
|
4748
|
+
path,
|
|
4749
|
+
message: "类型不匹配(应为 oneOf 之一)"
|
|
4750
|
+
});
|
|
4751
|
+
return;
|
|
4752
|
+
}
|
|
4753
|
+
}
|
|
4754
|
+
if (node.type && !typeMatches(node, value)) {
|
|
4755
|
+
warnings.push({
|
|
4756
|
+
path,
|
|
4757
|
+
message: `应为 ${node.type},实际为 ${Array.isArray(value) ? "array" : typeof value}`
|
|
4758
|
+
});
|
|
4759
|
+
return;
|
|
4760
|
+
}
|
|
4761
|
+
if (node.enum && !node.enum.includes(value)) {
|
|
4762
|
+
warnings.push({
|
|
4763
|
+
path,
|
|
4764
|
+
message: `应为 ${node.enum.join(" / ")} 之一,实际为 ${String(value)}`
|
|
4765
|
+
});
|
|
4766
|
+
return;
|
|
4767
|
+
}
|
|
4768
|
+
if (node.type === "object") {
|
|
4769
|
+
(node.required || []).forEach((key) => {
|
|
4770
|
+
if (value[key] === void 0) warnings.push({
|
|
4771
|
+
path: `${path}.${key}`,
|
|
4772
|
+
message: "缺少必填字段"
|
|
4773
|
+
});
|
|
4774
|
+
});
|
|
4775
|
+
if (node.properties) Object.entries(node.properties).forEach(([key, sub]) => {
|
|
4776
|
+
if (value[key] !== void 0) validateNode(sub, value[key], `${path}.${key}`, warnings);
|
|
4777
|
+
});
|
|
4778
|
+
}
|
|
4779
|
+
if (node.type === "array" && node.items && Array.isArray(value)) value.forEach((item, i) => validateNode(node.items, item, `${path}[${i}]`, warnings));
|
|
4780
|
+
}
|
|
4781
|
+
function validateOptions(options) {
|
|
4782
|
+
const warnings = [];
|
|
4783
|
+
if (options === null || typeof options !== "object" || Array.isArray(options)) return {
|
|
4784
|
+
ok: false,
|
|
4785
|
+
warnings: [{
|
|
4786
|
+
path: "options",
|
|
4787
|
+
message: "应为对象"
|
|
4788
|
+
}]
|
|
4789
|
+
};
|
|
4790
|
+
validateNode(chartOptionsSchema, options, "options", warnings);
|
|
4791
|
+
return {
|
|
4792
|
+
ok: warnings.length === 0,
|
|
4793
|
+
warnings
|
|
4794
|
+
};
|
|
4795
|
+
}
|
|
4064
4796
|
//#endregion
|
|
4065
4797
|
//#region \0plugin-vue:export-helper
|
|
4066
4798
|
var _plugin_vue_export_helper_default = (sfc, props) => {
|
|
@@ -4072,23 +4804,27 @@ var _plugin_vue_export_helper_default = (sfc, props) => {
|
|
|
4072
4804
|
//#region src/chart.vue
|
|
4073
4805
|
var _hoisted_1 = ["aria-label"];
|
|
4074
4806
|
var _hoisted_2 = {
|
|
4807
|
+
key: 1,
|
|
4808
|
+
class: "ev-chart__overlay"
|
|
4809
|
+
};
|
|
4810
|
+
var _hoisted_3 = {
|
|
4075
4811
|
key: 0,
|
|
4076
4812
|
class: "ev-chart__empty"
|
|
4077
4813
|
};
|
|
4078
|
-
var
|
|
4079
|
-
var
|
|
4080
|
-
var
|
|
4814
|
+
var _hoisted_4 = { class: "ev-chart__empty-icon-wrap" };
|
|
4815
|
+
var _hoisted_5 = { class: "ev-chart__empty-text" };
|
|
4816
|
+
var _hoisted_6 = {
|
|
4081
4817
|
key: 0,
|
|
4082
4818
|
class: "ev-chart__loading"
|
|
4083
4819
|
};
|
|
4084
|
-
var
|
|
4085
|
-
var
|
|
4820
|
+
var _hoisted_7 = { class: "ev-chart__loading-bars" };
|
|
4821
|
+
var _hoisted_8 = {
|
|
4086
4822
|
key: 0,
|
|
4087
4823
|
class: "ev-chart__error"
|
|
4088
4824
|
};
|
|
4089
|
-
var
|
|
4090
|
-
var
|
|
4091
|
-
var
|
|
4825
|
+
var _hoisted_9 = { class: "ev-chart__error-text" };
|
|
4826
|
+
var _hoisted_10 = ["innerHTML"];
|
|
4827
|
+
var _hoisted_11 = {
|
|
4092
4828
|
class: "ev-chart__sr-only",
|
|
4093
4829
|
role: "status",
|
|
4094
4830
|
"aria-live": "polite"
|
|
@@ -4126,7 +4862,8 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
4126
4862
|
"animation-end",
|
|
4127
4863
|
"data-update",
|
|
4128
4864
|
"brush-select",
|
|
4129
|
-
"zoom"
|
|
4865
|
+
"zoom",
|
|
4866
|
+
"scene-change"
|
|
4130
4867
|
],
|
|
4131
4868
|
setup(__props, { expose: __expose, emit: __emit }) {
|
|
4132
4869
|
const DataLine = () => h("svg", {
|
|
@@ -4196,6 +4933,12 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
4196
4933
|
let hoverAnimFrameId = null;
|
|
4197
4934
|
const hiddenSeries = ref(/* @__PURE__ */ new Set());
|
|
4198
4935
|
const focusSeries = ref(null);
|
|
4936
|
+
const emphasisSeriesName = computed(() => {
|
|
4937
|
+
const em = props.options.emphasis;
|
|
4938
|
+
if (!em || em.series === void 0 || em.dimOthers === false) return null;
|
|
4939
|
+
if (typeof em.series === "number") return props.options.series?.[em.series]?.name ?? null;
|
|
4940
|
+
return em.series || null;
|
|
4941
|
+
});
|
|
4199
4942
|
const hoveredToolbox = ref(null);
|
|
4200
4943
|
let mouseX = -1;
|
|
4201
4944
|
let mouseY = -1;
|
|
@@ -4225,33 +4968,102 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
4225
4968
|
const brushRect = ref(null);
|
|
4226
4969
|
let suppressClick = false;
|
|
4227
4970
|
let brushDragged = false;
|
|
4971
|
+
const specVersion = ref(0);
|
|
4972
|
+
const overlayInfo = ref({
|
|
4973
|
+
plotArea: {
|
|
4974
|
+
x: 0,
|
|
4975
|
+
y: 0,
|
|
4976
|
+
width: 0,
|
|
4977
|
+
height: 0
|
|
4978
|
+
},
|
|
4979
|
+
theme: getTheme(false, void 0),
|
|
4980
|
+
options: null
|
|
4981
|
+
});
|
|
4228
4982
|
const effectiveOptions = computed(() => {
|
|
4983
|
+
specVersion.value;
|
|
4229
4984
|
const opt = props.options;
|
|
4230
|
-
|
|
4985
|
+
const scene = opt.scenes?.items?.[sceneIndex.value];
|
|
4986
|
+
const base = scene?.patch ? {
|
|
4987
|
+
...opt,
|
|
4988
|
+
...scene.patch
|
|
4989
|
+
} : opt;
|
|
4990
|
+
if (!getDataZoomConfig(base)) return base;
|
|
4231
4991
|
const range = zoomRange.value;
|
|
4232
|
-
if (range.start <= 0 && range.end >= 100) return
|
|
4233
|
-
const sliced = { ...
|
|
4234
|
-
const n = (
|
|
4992
|
+
if (range.start <= 0 && range.end >= 100) return base;
|
|
4993
|
+
const sliced = { ...base };
|
|
4994
|
+
const n = (base.labels || []).length;
|
|
4235
4995
|
if (n > 0) {
|
|
4236
4996
|
const { startIdx, endIdx } = zoomToSlice(range, n);
|
|
4237
|
-
sliced.labels = (
|
|
4238
|
-
if (
|
|
4997
|
+
sliced.labels = (base.labels || []).slice(startIdx, endIdx);
|
|
4998
|
+
if (base.series) sliced.series = base.series.map((s) => ({
|
|
4239
4999
|
...s,
|
|
4240
5000
|
data: s.data.slice(startIdx, endIdx)
|
|
4241
5001
|
}));
|
|
4242
|
-
if (
|
|
5002
|
+
if (base.candleData) sliced.candleData = base.candleData.slice(startIdx, endIdx);
|
|
4243
5003
|
sliced.__sliceStart = startIdx;
|
|
4244
|
-
} else if (
|
|
4245
|
-
const xs =
|
|
5004
|
+
} else if (base.scatterData && base.scatterData.length > 0) {
|
|
5005
|
+
const xs = base.scatterData.map((d) => d.x);
|
|
4246
5006
|
const xMin = Math.min(...xs);
|
|
4247
5007
|
const span = Math.max(...xs) - xMin || 1;
|
|
4248
5008
|
const lo = xMin + range.start / 100 * span;
|
|
4249
5009
|
const hi = xMin + range.end / 100 * span;
|
|
4250
|
-
const filtered =
|
|
4251
|
-
sliced.scatterData = filtered.length > 0 ? filtered :
|
|
5010
|
+
const filtered = base.scatterData.filter((d) => d.x >= lo && d.x <= hi);
|
|
5011
|
+
sliced.scatterData = filtered.length > 0 ? filtered : base.scatterData;
|
|
4252
5012
|
}
|
|
4253
5013
|
return sliced;
|
|
4254
5014
|
});
|
|
5015
|
+
const sceneIndex = ref(0);
|
|
5016
|
+
let sceneTimer = null;
|
|
5017
|
+
let sceneAnimOverride = null;
|
|
5018
|
+
function stopSceneTimer() {
|
|
5019
|
+
if (sceneTimer !== null) {
|
|
5020
|
+
clearTimeout(sceneTimer);
|
|
5021
|
+
sceneTimer = null;
|
|
5022
|
+
}
|
|
5023
|
+
}
|
|
5024
|
+
function applyScene(i, opts = {}) {
|
|
5025
|
+
const items = props.options.scenes?.items || [];
|
|
5026
|
+
if (items.length === 0) return;
|
|
5027
|
+
const next = Math.max(0, Math.min(i, items.length - 1));
|
|
5028
|
+
if (next === sceneIndex.value && !opts.force) return;
|
|
5029
|
+
sceneIndex.value = next;
|
|
5030
|
+
const item = items[next];
|
|
5031
|
+
sceneAnimOverride = item?.duration ? {
|
|
5032
|
+
...props.options.animation || {},
|
|
5033
|
+
duration: item.duration
|
|
5034
|
+
} : null;
|
|
5035
|
+
emit("scene-change", {
|
|
5036
|
+
index: next,
|
|
5037
|
+
total: items.length
|
|
5038
|
+
});
|
|
5039
|
+
render(true, sceneAnimOverride);
|
|
5040
|
+
scheduleSceneAdvance();
|
|
5041
|
+
}
|
|
5042
|
+
function scheduleSceneAdvance() {
|
|
5043
|
+
stopSceneTimer();
|
|
5044
|
+
const scenes = props.options.scenes;
|
|
5045
|
+
if (!scenes?.autoplay) return;
|
|
5046
|
+
const item = scenes.items?.[sceneIndex.value];
|
|
5047
|
+
if (!item) return;
|
|
5048
|
+
const wait = Math.max(0, (item.duration ?? 1200) + (item.hold ?? 0));
|
|
5049
|
+
sceneTimer = setTimeout(() => {
|
|
5050
|
+
const total = props.options.scenes?.items?.length ?? 0;
|
|
5051
|
+
if (total === 0) return;
|
|
5052
|
+
if (sceneIndex.value >= total - 1) {
|
|
5053
|
+
if (props.options.scenes?.loop) applyScene(0, { force: true });
|
|
5054
|
+
} else applyScene(sceneIndex.value + 1);
|
|
5055
|
+
}, wait);
|
|
5056
|
+
}
|
|
5057
|
+
function syncScenes() {
|
|
5058
|
+
const total = props.options.scenes?.items?.length ?? 0;
|
|
5059
|
+
if (total === 0) {
|
|
5060
|
+
stopSceneTimer();
|
|
5061
|
+
if (sceneIndex.value !== 0) sceneIndex.value = 0;
|
|
5062
|
+
return;
|
|
5063
|
+
}
|
|
5064
|
+
if (sceneIndex.value >= total) sceneIndex.value = 0;
|
|
5065
|
+
scheduleSceneAdvance();
|
|
5066
|
+
}
|
|
4255
5067
|
function sliceStartOffset() {
|
|
4256
5068
|
return effectiveOptions.value.__sliceStart || 0;
|
|
4257
5069
|
}
|
|
@@ -4297,17 +5109,24 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
4297
5109
|
const spaceBelow = containerRect.height - relY - gap;
|
|
4298
5110
|
const spaceAbove = relY - gap - legendBottom;
|
|
4299
5111
|
let top;
|
|
4300
|
-
if (
|
|
4301
|
-
|
|
4302
|
-
|
|
5112
|
+
if (th + 4 <= containerRect.height) {
|
|
5113
|
+
if (spaceBelow >= th) top = relY + gap;
|
|
5114
|
+
else if (spaceAbove >= th) top = relY - gap - th;
|
|
5115
|
+
else top = spaceBelow >= spaceAbove ? containerRect.height - th - margin : legendBottom;
|
|
5116
|
+
top = Math.max(legendBottom, Math.min(top, containerRect.height - th - margin));
|
|
5117
|
+
} else {
|
|
5118
|
+
const pointViewportY = containerRect.top + relY;
|
|
5119
|
+
const belowInViewport = pointViewportY + gap + th <= window.innerHeight - 8;
|
|
5120
|
+
const aboveInViewport = pointViewportY - gap - th >= 8;
|
|
5121
|
+
top = belowInViewport || !aboveInViewport ? relY + gap : relY - gap - th;
|
|
5122
|
+
}
|
|
4303
5123
|
left = Math.max(margin, Math.min(left, containerRect.width - tw - margin));
|
|
4304
|
-
top = Math.max(legendBottom, Math.min(top, containerRect.height - th - margin));
|
|
4305
5124
|
tooltipPlacement.value = "top";
|
|
4306
5125
|
tooltipX.value = left;
|
|
4307
5126
|
tooltipY.value = top;
|
|
4308
5127
|
}
|
|
4309
5128
|
const canvasStyle = computed(() => ({ cursor: hoverIndex >= 0 ? "pointer" : "default" }));
|
|
4310
|
-
function render(animate = true) {
|
|
5129
|
+
function render(animate = true, animOverride = null) {
|
|
4311
5130
|
const canvas = canvasRef.value;
|
|
4312
5131
|
if (!canvas) return;
|
|
4313
5132
|
const container = containerRef.value;
|
|
@@ -4350,8 +5169,20 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
4350
5169
|
lastHeight = height;
|
|
4351
5170
|
canvas.width = width * dpr.value;
|
|
4352
5171
|
canvas.height = height * dpr.value;
|
|
4353
|
-
const
|
|
4354
|
-
|
|
5172
|
+
const overlayPadding = getPadding(effectiveOptions.value, width);
|
|
5173
|
+
overlayInfo.value = {
|
|
5174
|
+
plotArea: {
|
|
5175
|
+
x: overlayPadding.left,
|
|
5176
|
+
y: overlayPadding.top,
|
|
5177
|
+
width: width - overlayPadding.left - overlayPadding.right,
|
|
5178
|
+
height: height - overlayPadding.top - overlayPadding.bottom
|
|
5179
|
+
},
|
|
5180
|
+
theme: getTheme(document.documentElement.classList.contains("dark"), effectiveOptions.value.theme),
|
|
5181
|
+
options: props.options
|
|
5182
|
+
};
|
|
5183
|
+
const animCfg = animOverride || props.options.animation;
|
|
5184
|
+
const animEnabled = animate && animCfg?.enabled !== false;
|
|
5185
|
+
animationState = createAnimation(animCfg);
|
|
4355
5186
|
animationState.isAnimating = animEnabled;
|
|
4356
5187
|
if (animEnabled) {
|
|
4357
5188
|
isEnterAnimating = true;
|
|
@@ -5372,10 +6203,12 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
5372
6203
|
redraw();
|
|
5373
6204
|
}
|
|
5374
6205
|
const legendName = checkLegendHit(e.clientX, e.clientY);
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
focusSeries.value
|
|
5378
|
-
|
|
6206
|
+
if (emphasisSeriesName.value) {} else {
|
|
6207
|
+
const nextFocus = props.options.legend?.hoverEmphasis !== false && legendName ? legendName : null;
|
|
6208
|
+
if (nextFocus !== focusSeries.value) {
|
|
6209
|
+
focusSeries.value = nextFocus;
|
|
6210
|
+
redraw();
|
|
6211
|
+
}
|
|
5379
6212
|
}
|
|
5380
6213
|
if (legendName) {
|
|
5381
6214
|
if (hoverIndex !== -1) {
|
|
@@ -5504,7 +6337,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
5504
6337
|
tooltipVisible.value = false;
|
|
5505
6338
|
mouseX = -1;
|
|
5506
6339
|
mouseY = -1;
|
|
5507
|
-
if (focusSeries.value !== null) focusSeries.value = null;
|
|
6340
|
+
if (focusSeries.value !== null && !emphasisSeriesName.value) focusSeries.value = null;
|
|
5508
6341
|
if (hoveredToolbox.value !== null) hoveredToolbox.value = null;
|
|
5509
6342
|
if (dragMode === "brush") {
|
|
5510
6343
|
brushRect.value = null;
|
|
@@ -5571,11 +6404,35 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
5571
6404
|
}))
|
|
5572
6405
|
};
|
|
5573
6406
|
}
|
|
6407
|
+
function cloneSpec(value) {
|
|
6408
|
+
if (value === null || typeof value !== "object") return value;
|
|
6409
|
+
if (Array.isArray(value)) return value.map(cloneSpec);
|
|
6410
|
+
const out = {};
|
|
6411
|
+
Object.keys(value).forEach((k) => {
|
|
6412
|
+
out[k] = cloneSpec(value[k]);
|
|
6413
|
+
});
|
|
6414
|
+
return out;
|
|
6415
|
+
}
|
|
6416
|
+
const IS_DEV = typeof import.meta !== "undefined" && false;
|
|
6417
|
+
const devWarned = /* @__PURE__ */ new Set();
|
|
6418
|
+
function runDevValidation() {
|
|
6419
|
+
if (!IS_DEV) return;
|
|
6420
|
+
const { warnings } = validateOptions(props.options);
|
|
6421
|
+
warnings.slice(0, 8).forEach((w) => {
|
|
6422
|
+
const key = `${w.path}:${w.message}`;
|
|
6423
|
+
if (devWarned.has(key)) return;
|
|
6424
|
+
devWarned.add(key);
|
|
6425
|
+
console.warn(`[EvChart] ${w.path} ${w.message}`);
|
|
6426
|
+
});
|
|
6427
|
+
}
|
|
5574
6428
|
let prevOptionsSnapshot = null;
|
|
5575
6429
|
watch(() => props.options, (next) => {
|
|
5576
6430
|
cachedDataExtent = null;
|
|
5577
6431
|
cachedPlotArea = null;
|
|
5578
6432
|
internalError.value = null;
|
|
6433
|
+
runDevValidation();
|
|
6434
|
+
focusSeries.value = emphasisSeriesName.value;
|
|
6435
|
+
syncScenes();
|
|
5579
6436
|
const zoom = getDataZoomConfig(props.options);
|
|
5580
6437
|
const zoomKey = zoom ? JSON.stringify({
|
|
5581
6438
|
e: zoom.enabled,
|
|
@@ -5629,6 +6486,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
5629
6486
|
let unregisterConnector = null;
|
|
5630
6487
|
onMounted(() => {
|
|
5631
6488
|
render(true);
|
|
6489
|
+
syncScenes();
|
|
5632
6490
|
setupResizeObserver();
|
|
5633
6491
|
setupDarkModeObserver();
|
|
5634
6492
|
window.addEventListener("pointerup", onWindowPointerUp);
|
|
@@ -5648,6 +6506,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
5648
6506
|
stopAnimation();
|
|
5649
6507
|
stopHoverAnimation();
|
|
5650
6508
|
stopTweenAnimation();
|
|
6509
|
+
stopSceneTimer();
|
|
5651
6510
|
if (tooltipRafId !== null) {
|
|
5652
6511
|
cancelAnimationFrame(tooltipRafId);
|
|
5653
6512
|
tooltipRafId = null;
|
|
@@ -5661,6 +6520,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
5661
6520
|
update(newOptions) {
|
|
5662
6521
|
Object.assign(props.options, newOptions);
|
|
5663
6522
|
cachedDataExtent = null;
|
|
6523
|
+
specVersion.value++;
|
|
5664
6524
|
debouncedRender(true);
|
|
5665
6525
|
},
|
|
5666
6526
|
toDataURL(type = "image/png", quality = 1) {
|
|
@@ -5671,6 +6531,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
5671
6531
|
cleanupDarkModeObserver();
|
|
5672
6532
|
stopAnimation();
|
|
5673
6533
|
stopHoverAnimation();
|
|
6534
|
+
stopSceneTimer();
|
|
5674
6535
|
if (tooltipRafId !== null) {
|
|
5675
6536
|
cancelAnimationFrame(tooltipRafId);
|
|
5676
6537
|
tooltipRafId = null;
|
|
@@ -5760,6 +6621,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
5760
6621
|
...props.options.theme,
|
|
5761
6622
|
...theme
|
|
5762
6623
|
};
|
|
6624
|
+
specVersion.value++;
|
|
5763
6625
|
redraw();
|
|
5764
6626
|
},
|
|
5765
6627
|
exportSVG(options = {}) {
|
|
@@ -5802,6 +6664,59 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
5802
6664
|
getOption() {
|
|
5803
6665
|
return props.options;
|
|
5804
6666
|
},
|
|
6667
|
+
getSpec() {
|
|
6668
|
+
return cloneSpec(props.options);
|
|
6669
|
+
},
|
|
6670
|
+
setSpec(spec) {
|
|
6671
|
+
if (spec === null || typeof spec !== "object") return;
|
|
6672
|
+
const next = cloneSpec(spec);
|
|
6673
|
+
Object.keys(props.options).forEach((k) => delete props.options[k]);
|
|
6674
|
+
Object.assign(props.options, next);
|
|
6675
|
+
hiddenSeries.value = /* @__PURE__ */ new Set();
|
|
6676
|
+
focusSeries.value = null;
|
|
6677
|
+
brushRect.value = null;
|
|
6678
|
+
hoverIndex = -1;
|
|
6679
|
+
mouseX = -1;
|
|
6680
|
+
mouseY = -1;
|
|
6681
|
+
stopSceneTimer();
|
|
6682
|
+
sceneIndex.value = 0;
|
|
6683
|
+
sceneAnimOverride = null;
|
|
6684
|
+
const zoom = getDataZoomConfig(next);
|
|
6685
|
+
zoomRange.value = {
|
|
6686
|
+
start: zoom?.start ?? 0,
|
|
6687
|
+
end: zoom?.end ?? 100
|
|
6688
|
+
};
|
|
6689
|
+
lastZoomKey = zoom ? JSON.stringify({
|
|
6690
|
+
e: zoom.enabled,
|
|
6691
|
+
s: zoom.start,
|
|
6692
|
+
e2: zoom.end,
|
|
6693
|
+
p: zoom.position,
|
|
6694
|
+
h: zoom.height
|
|
6695
|
+
}) : "";
|
|
6696
|
+
cachedDataExtent = null;
|
|
6697
|
+
cachedPlotArea = null;
|
|
6698
|
+
specVersion.value++;
|
|
6699
|
+
debouncedRender(true);
|
|
6700
|
+
},
|
|
6701
|
+
nextScene() {
|
|
6702
|
+
applyScene(sceneIndex.value + 1);
|
|
6703
|
+
},
|
|
6704
|
+
prevScene() {
|
|
6705
|
+
applyScene(sceneIndex.value - 1);
|
|
6706
|
+
},
|
|
6707
|
+
gotoScene(i) {
|
|
6708
|
+
applyScene(i);
|
|
6709
|
+
},
|
|
6710
|
+
getSceneIndex() {
|
|
6711
|
+
return sceneIndex.value;
|
|
6712
|
+
},
|
|
6713
|
+
getEffectiveSpec() {
|
|
6714
|
+
const spec = cloneSpec(effectiveOptions.value);
|
|
6715
|
+
Object.keys(spec).forEach((k) => {
|
|
6716
|
+
if (k.startsWith("__")) delete spec[k];
|
|
6717
|
+
});
|
|
6718
|
+
return spec;
|
|
6719
|
+
},
|
|
5805
6720
|
setDataZoomRange(start, end) {
|
|
5806
6721
|
if (!isZoomEnabled()) {
|
|
5807
6722
|
console.warn("[EvChart] setDataZoomRange: dataZoom is not enabled");
|
|
@@ -5839,12 +6754,17 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
5839
6754
|
onDblclick: handleDblClick,
|
|
5840
6755
|
onPointerleave: handlePointerLeave
|
|
5841
6756
|
}, null, 36)) : createCommentVNode("", true),
|
|
6757
|
+
!isEmpty.value && !props.options.loading && !internalError.value && _ctx.$slots.overlay ? (openBlock(), createElementBlock("div", _hoisted_2, [renderSlot(_ctx.$slots, "overlay", {
|
|
6758
|
+
plotArea: overlayInfo.value.plotArea,
|
|
6759
|
+
theme: overlayInfo.value.theme,
|
|
6760
|
+
options: props.options
|
|
6761
|
+
}, void 0, true)])) : createCommentVNode("", true),
|
|
5842
6762
|
createVNode(Transition, { name: "ev-chart-fade" }, {
|
|
5843
|
-
default: withCtx(() => [isEmpty.value && !props.options.loading && !internalError.value ? (openBlock(), createElementBlock("div",
|
|
6763
|
+
default: withCtx(() => [isEmpty.value && !props.options.loading && !internalError.value ? (openBlock(), createElementBlock("div", _hoisted_3, [createElementVNode("div", _hoisted_4, [(openBlock(), createBlock(resolveDynamicComponent(DataLine), { class: "ev-chart__empty-img" }))]), createElementVNode("span", _hoisted_5, toDisplayString(emptyTextI18n.value), 1)])) : createCommentVNode("", true)]),
|
|
5844
6764
|
_: 1
|
|
5845
6765
|
}),
|
|
5846
6766
|
createVNode(Transition, { name: "ev-chart-fade" }, {
|
|
5847
|
-
default: withCtx(() => [props.options.loading && !internalError.value ? (openBlock(), createElementBlock("div",
|
|
6767
|
+
default: withCtx(() => [props.options.loading && !internalError.value ? (openBlock(), createElementBlock("div", _hoisted_6, [createElementVNode("div", _hoisted_7, [(openBlock(), createElementBlock(Fragment, null, renderList(6, (i) => {
|
|
5848
6768
|
return createElementVNode("div", {
|
|
5849
6769
|
key: i,
|
|
5850
6770
|
class: "ev-chart__loading-bar",
|
|
@@ -5854,7 +6774,35 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
5854
6774
|
_: 1
|
|
5855
6775
|
}),
|
|
5856
6776
|
createVNode(Transition, { name: "ev-chart-fade" }, {
|
|
5857
|
-
default: withCtx(() => [internalError.value ? (openBlock(), createElementBlock("div",
|
|
6777
|
+
default: withCtx(() => [internalError.value ? (openBlock(), createElementBlock("div", _hoisted_8, [_cache[0] || (_cache[0] = createElementVNode("span", { class: "ev-chart__error-icon" }, [createElementVNode("svg", {
|
|
6778
|
+
viewBox: "0 0 24 24",
|
|
6779
|
+
width: "1em",
|
|
6780
|
+
height: "1em",
|
|
6781
|
+
"aria-hidden": "true",
|
|
6782
|
+
fill: "none",
|
|
6783
|
+
stroke: "currentColor",
|
|
6784
|
+
"stroke-width": "2",
|
|
6785
|
+
"stroke-linecap": "round",
|
|
6786
|
+
"stroke-linejoin": "round"
|
|
6787
|
+
}, [
|
|
6788
|
+
createElementVNode("circle", {
|
|
6789
|
+
cx: "12",
|
|
6790
|
+
cy: "12",
|
|
6791
|
+
r: "9"
|
|
6792
|
+
}),
|
|
6793
|
+
createElementVNode("line", {
|
|
6794
|
+
x1: "12",
|
|
6795
|
+
y1: "7.5",
|
|
6796
|
+
x2: "12",
|
|
6797
|
+
y2: "13"
|
|
6798
|
+
}),
|
|
6799
|
+
createElementVNode("line", {
|
|
6800
|
+
x1: "12",
|
|
6801
|
+
y1: "16.5",
|
|
6802
|
+
x2: "12",
|
|
6803
|
+
y2: "16.51"
|
|
6804
|
+
})
|
|
6805
|
+
])], -1)), createElementVNode("span", _hoisted_9, toDisplayString(internalError.value), 1)])) : createCommentVNode("", true)]),
|
|
5858
6806
|
_: 1
|
|
5859
6807
|
}),
|
|
5860
6808
|
createVNode(Transition, { name: "ev-chart-tooltip" }, {
|
|
@@ -5866,14 +6814,14 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
|
|
|
5866
6814
|
style: normalizeStyle(tooltipStyle.value),
|
|
5867
6815
|
role: "tooltip",
|
|
5868
6816
|
innerHTML: tooltipContent.value
|
|
5869
|
-
}, null, 14,
|
|
6817
|
+
}, null, 14, _hoisted_10)) : createCommentVNode("", true)]),
|
|
5870
6818
|
_: 1
|
|
5871
6819
|
}),
|
|
5872
|
-
createElementVNode("div",
|
|
6820
|
+
createElementVNode("div", _hoisted_11, toDisplayString(ariaLiveText.value), 1)
|
|
5873
6821
|
], 12, _hoisted_1);
|
|
5874
6822
|
};
|
|
5875
6823
|
}
|
|
5876
|
-
}), [["__scopeId", "data-v-
|
|
6824
|
+
}), [["__scopeId", "data-v-ce034a2a"]]);
|
|
5877
6825
|
//#endregion
|
|
5878
6826
|
//#region src/useChart.js
|
|
5879
6827
|
var USE_CHART_KEY = /* @__PURE__ */ Symbol("ev-chart-context");
|
|
@@ -5937,6 +6885,831 @@ function injectChartContext() {
|
|
|
5937
6885
|
return inject(USE_CHART_KEY, null);
|
|
5938
6886
|
}
|
|
5939
6887
|
//#endregion
|
|
6888
|
+
//#region src/palette.js
|
|
6889
|
+
/**
|
|
6890
|
+
* 配色方案应用工具 — 中性契约的官方实现
|
|
6891
|
+
*
|
|
6892
|
+
* 把一套数据色板写入 --ev-color-series-1..8 槽位令牌并派发 'ev-theme-change',
|
|
6893
|
+
* 页面上的全部图表即时重绘。任何宿主(组件库 / 站点 / 独立页面)都可直接调用,
|
|
6894
|
+
* 不必各自实现令牌写入与事件广播。
|
|
6895
|
+
*
|
|
6896
|
+
* palette 两种形态:
|
|
6897
|
+
* - string[] 单套色值,明暗模式共用;
|
|
6898
|
+
* - { light, dark } 浅/暗两组,随 html.dark 自动换挡(经注入样式表实现)。
|
|
6899
|
+
*
|
|
6900
|
+
* clearSeriesPalette() 移除覆写(内联属性 + 注入样式表),回到内置成套色板。
|
|
6901
|
+
*/
|
|
6902
|
+
var SLOT_COUNT = 8;
|
|
6903
|
+
var PALETTE_EVENT = "ev-theme-change";
|
|
6904
|
+
var STYLE_ID = "data-ev-series-palette";
|
|
6905
|
+
var paletteStyleEl = null;
|
|
6906
|
+
function dispatchPaletteChange(dark) {
|
|
6907
|
+
if (typeof document === "undefined") return;
|
|
6908
|
+
const isDark = typeof dark === "boolean" ? dark : document.documentElement.classList.contains("dark");
|
|
6909
|
+
document.documentElement.dispatchEvent(new CustomEvent(PALETTE_EVENT, {
|
|
6910
|
+
bubbles: true,
|
|
6911
|
+
detail: { dark: isDark }
|
|
6912
|
+
}));
|
|
6913
|
+
}
|
|
6914
|
+
function removeInlineSlots() {
|
|
6915
|
+
if (typeof document === "undefined") return;
|
|
6916
|
+
for (let i = 1; i <= SLOT_COUNT; i++) document.documentElement.style.removeProperty(`--ev-color-series-${i}`);
|
|
6917
|
+
}
|
|
6918
|
+
function ensurePaletteStyleEl() {
|
|
6919
|
+
if (typeof document === "undefined") return null;
|
|
6920
|
+
if (!paletteStyleEl || !paletteStyleEl.isConnected) {
|
|
6921
|
+
paletteStyleEl = document.createElement("style");
|
|
6922
|
+
paletteStyleEl.setAttribute(STYLE_ID, "");
|
|
6923
|
+
document.head.appendChild(paletteStyleEl);
|
|
6924
|
+
}
|
|
6925
|
+
return paletteStyleEl;
|
|
6926
|
+
}
|
|
6927
|
+
function normalizeSlots(colors) {
|
|
6928
|
+
const out = {};
|
|
6929
|
+
if (!Array.isArray(colors)) return out;
|
|
6930
|
+
colors.slice(0, SLOT_COUNT).forEach((color, i) => {
|
|
6931
|
+
if (color) out[`--ev-color-series-${i + 1}`] = color;
|
|
6932
|
+
});
|
|
6933
|
+
return out;
|
|
6934
|
+
}
|
|
6935
|
+
/**
|
|
6936
|
+
* 应用一套数据色板
|
|
6937
|
+
* @param {string[] | { light: string[], dark: string[] }} palette
|
|
6938
|
+
* 数组:写入内联槽位(明暗共用);{ light, dark }:注入样式表,随 html.dark 自动换挡
|
|
6939
|
+
* @param {{ dark?: boolean, target?: HTMLElement }} [options]
|
|
6940
|
+
* dark 手动指定当前暗色态(默认探测 html.dark);target 改写注入目标
|
|
6941
|
+
* @returns {boolean} 是否写入成功
|
|
6942
|
+
*/
|
|
6943
|
+
function applySeriesPalette(palette, options = {}) {
|
|
6944
|
+
if (typeof document === "undefined") return false;
|
|
6945
|
+
const el = options.target ?? document.documentElement;
|
|
6946
|
+
if (!el || !palette) return false;
|
|
6947
|
+
if (Array.isArray(palette)) {
|
|
6948
|
+
paletteStyleEl?.remove();
|
|
6949
|
+
paletteStyleEl = null;
|
|
6950
|
+
for (const [name, value] of Object.entries(normalizeSlots(palette))) el.style.setProperty(name, value);
|
|
6951
|
+
dispatchPaletteChange(options.dark);
|
|
6952
|
+
return true;
|
|
6953
|
+
}
|
|
6954
|
+
const { light, dark } = palette;
|
|
6955
|
+
if (!Array.isArray(light) && !Array.isArray(dark)) return false;
|
|
6956
|
+
removeInlineSlots();
|
|
6957
|
+
const styleEl = ensurePaletteStyleEl();
|
|
6958
|
+
if (styleEl) {
|
|
6959
|
+
const vars = (colors) => colors.map((c, i) => `--ev-color-series-${i + 1}: ${c}`).join(";");
|
|
6960
|
+
styleEl.textContent = `${light ? `:root { ${vars(light)}; }` : ""}${dark ? ` html.dark { ${vars(dark)}; }` : ""}`;
|
|
6961
|
+
}
|
|
6962
|
+
dispatchPaletteChange(options.dark);
|
|
6963
|
+
return true;
|
|
6964
|
+
}
|
|
6965
|
+
/**
|
|
6966
|
+
* 清除配色方案覆写(内联槽位 + 注入样式表),回到内置成套色板
|
|
6967
|
+
*/
|
|
6968
|
+
function clearSeriesPalette(options = {}) {
|
|
6969
|
+
if (typeof document === "undefined") return false;
|
|
6970
|
+
paletteStyleEl?.remove();
|
|
6971
|
+
paletteStyleEl = null;
|
|
6972
|
+
removeInlineSlots();
|
|
6973
|
+
dispatchPaletteChange(options.dark);
|
|
6974
|
+
return true;
|
|
6975
|
+
}
|
|
6976
|
+
//#endregion
|
|
6977
|
+
//#region src/ai/table.js
|
|
6978
|
+
var NUM_CLEAN = /[,\s_]/g;
|
|
6979
|
+
function parseNumeric(raw) {
|
|
6980
|
+
if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
|
|
6981
|
+
if (typeof raw !== "string") return null;
|
|
6982
|
+
let s = raw.trim();
|
|
6983
|
+
if (!s) return null;
|
|
6984
|
+
let sign = 1;
|
|
6985
|
+
if (/^\(.*\)$/.test(s)) {
|
|
6986
|
+
sign = -1;
|
|
6987
|
+
s = s.slice(1, -1);
|
|
6988
|
+
}
|
|
6989
|
+
s = s.replace(NUM_CLEAN, "").replace(/^[¥$€£]/, "").replace(/[¥$€£]$/, "");
|
|
6990
|
+
let mult = 1;
|
|
6991
|
+
if (/亿$/.test(s)) {
|
|
6992
|
+
mult = 1e8;
|
|
6993
|
+
s = s.slice(0, -1);
|
|
6994
|
+
} else if (/万$/.test(s)) {
|
|
6995
|
+
mult = 1e4;
|
|
6996
|
+
s = s.slice(0, -1);
|
|
6997
|
+
} else if (/[Kk]$/.test(s)) {
|
|
6998
|
+
mult = 1e3;
|
|
6999
|
+
s = s.slice(0, -1);
|
|
7000
|
+
}
|
|
7001
|
+
if (/%$/.test(s)) s = s.slice(0, -1);
|
|
7002
|
+
if (!/^[-+]?\d+(\.\d+)?$/.test(s)) return null;
|
|
7003
|
+
return sign * parseFloat(s) * mult;
|
|
7004
|
+
}
|
|
7005
|
+
function looksLikeTime(v) {
|
|
7006
|
+
if (typeof v !== "string") return false;
|
|
7007
|
+
const s = v.trim();
|
|
7008
|
+
if (/^\d{4}[-/年.]\d{1,2}月?(?:[-/.]\d{1,2}日?)?$/.test(s)) return true;
|
|
7009
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)) return true;
|
|
7010
|
+
if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(s)) return true;
|
|
7011
|
+
return false;
|
|
7012
|
+
}
|
|
7013
|
+
function splitLine(line, delim) {
|
|
7014
|
+
const out = [];
|
|
7015
|
+
let cur = "";
|
|
7016
|
+
let inQuotes = false;
|
|
7017
|
+
for (let i = 0; i < line.length; i++) {
|
|
7018
|
+
const ch = line[i];
|
|
7019
|
+
if (ch === "\"") {
|
|
7020
|
+
if (inQuotes && line[i + 1] === "\"") {
|
|
7021
|
+
cur += "\"";
|
|
7022
|
+
i++;
|
|
7023
|
+
} else inQuotes = !inQuotes;
|
|
7024
|
+
} else if (ch === delim && !inQuotes) {
|
|
7025
|
+
out.push(cur.trim());
|
|
7026
|
+
cur = "";
|
|
7027
|
+
} else cur += ch;
|
|
7028
|
+
}
|
|
7029
|
+
out.push(cur.trim());
|
|
7030
|
+
return out;
|
|
7031
|
+
}
|
|
7032
|
+
function pickDelimiter(line) {
|
|
7033
|
+
const candidates = [
|
|
7034
|
+
" ",
|
|
7035
|
+
";",
|
|
7036
|
+
",",
|
|
7037
|
+
","
|
|
7038
|
+
];
|
|
7039
|
+
let best = ",";
|
|
7040
|
+
let bestCount = 0;
|
|
7041
|
+
candidates.forEach((d) => {
|
|
7042
|
+
const n = splitLine(line, d).length;
|
|
7043
|
+
if (n > bestCount) {
|
|
7044
|
+
bestCount = n;
|
|
7045
|
+
best = d;
|
|
7046
|
+
}
|
|
7047
|
+
});
|
|
7048
|
+
return best;
|
|
7049
|
+
}
|
|
7050
|
+
function rowToObj(headers, row) {
|
|
7051
|
+
const obj = {};
|
|
7052
|
+
headers.forEach((h, i) => {
|
|
7053
|
+
obj[h] = row[i] === void 0 ? null : row[i];
|
|
7054
|
+
});
|
|
7055
|
+
return obj;
|
|
7056
|
+
}
|
|
7057
|
+
function parseDataTable(input) {
|
|
7058
|
+
if (Array.isArray(input)) {
|
|
7059
|
+
if (input.length === 0) return null;
|
|
7060
|
+
const first = input[0];
|
|
7061
|
+
if (first !== null && typeof first === "object" && !Array.isArray(first)) {
|
|
7062
|
+
const headers = [];
|
|
7063
|
+
input.forEach((row) => {
|
|
7064
|
+
Object.keys(row).forEach((k) => {
|
|
7065
|
+
if (!headers.includes(k)) headers.push(k);
|
|
7066
|
+
});
|
|
7067
|
+
});
|
|
7068
|
+
return {
|
|
7069
|
+
headers,
|
|
7070
|
+
rows: input
|
|
7071
|
+
};
|
|
7072
|
+
}
|
|
7073
|
+
if (Array.isArray(first)) {
|
|
7074
|
+
if (input.length < 1) return null;
|
|
7075
|
+
const width = input.reduce((m, r) => Math.max(m, r.length), 0);
|
|
7076
|
+
if (width < 2) return null;
|
|
7077
|
+
const head = input[0].map((v) => v === null || v === void 0 ? "" : String(v).trim());
|
|
7078
|
+
if (head.filter((v) => parseNumeric(v) !== null).length === 0) return {
|
|
7079
|
+
headers: head,
|
|
7080
|
+
rows: input.slice(1).map((r) => rowToObj(head, r))
|
|
7081
|
+
};
|
|
7082
|
+
const headers = Array.from({ length: width }, (_, i) => `列${i + 1}`);
|
|
7083
|
+
return {
|
|
7084
|
+
headers,
|
|
7085
|
+
rows: input.map((r) => rowToObj(headers, r))
|
|
7086
|
+
};
|
|
7087
|
+
}
|
|
7088
|
+
return null;
|
|
7089
|
+
}
|
|
7090
|
+
if (typeof input === "string") {
|
|
7091
|
+
const lines = input.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
7092
|
+
if (lines.length < 2) return null;
|
|
7093
|
+
const delim = pickDelimiter(lines[0]);
|
|
7094
|
+
const grid = lines.map((l) => splitLine(l, delim));
|
|
7095
|
+
const head = grid[0];
|
|
7096
|
+
if (head.filter((v) => parseNumeric(v) !== null).length === 0 && head.length >= 2) return {
|
|
7097
|
+
headers: head,
|
|
7098
|
+
rows: grid.slice(1).map((r) => rowToObj(head, r))
|
|
7099
|
+
};
|
|
7100
|
+
const width = grid.reduce((m, r) => Math.max(m, r.length), 0);
|
|
7101
|
+
const headers = Array.from({ length: width }, (_, i) => `列${i + 1}`);
|
|
7102
|
+
return {
|
|
7103
|
+
headers,
|
|
7104
|
+
rows: grid.map((r) => rowToObj(headers, r))
|
|
7105
|
+
};
|
|
7106
|
+
}
|
|
7107
|
+
return null;
|
|
7108
|
+
}
|
|
7109
|
+
function inferColumns(table) {
|
|
7110
|
+
const { headers, rows } = table;
|
|
7111
|
+
return headers.map((name) => {
|
|
7112
|
+
const values = rows.map((r) => r[name] === void 0 ? null : r[name]);
|
|
7113
|
+
const present = values.filter((v) => v !== null && v !== "").length;
|
|
7114
|
+
const numericHits = values.filter((v) => parseNumeric(v) !== null).length;
|
|
7115
|
+
if (present > 0 && numericHits / present >= .6) return {
|
|
7116
|
+
name,
|
|
7117
|
+
type: "number",
|
|
7118
|
+
values: values.map((v) => parseNumeric(v))
|
|
7119
|
+
};
|
|
7120
|
+
const timeHits = values.filter((v) => looksLikeTime(v)).length;
|
|
7121
|
+
if (present > 0 && timeHits / present >= .6) return {
|
|
7122
|
+
name,
|
|
7123
|
+
type: "time",
|
|
7124
|
+
values
|
|
7125
|
+
};
|
|
7126
|
+
return {
|
|
7127
|
+
name,
|
|
7128
|
+
type: "category",
|
|
7129
|
+
values
|
|
7130
|
+
};
|
|
7131
|
+
});
|
|
7132
|
+
}
|
|
7133
|
+
function distinctCount(values) {
|
|
7134
|
+
return new Set(values.filter((v) => v !== null && v !== "")).size;
|
|
7135
|
+
}
|
|
7136
|
+
//#endregion
|
|
7137
|
+
//#region src/ai/spec.js
|
|
7138
|
+
var INTENT_KEYWORDS = {
|
|
7139
|
+
trend: [
|
|
7140
|
+
"趋势",
|
|
7141
|
+
"走势",
|
|
7142
|
+
"变化",
|
|
7143
|
+
"随时间",
|
|
7144
|
+
"增长",
|
|
7145
|
+
"trend"
|
|
7146
|
+
],
|
|
7147
|
+
share: [
|
|
7148
|
+
"占比",
|
|
7149
|
+
"构成",
|
|
7150
|
+
"份额",
|
|
7151
|
+
"比例",
|
|
7152
|
+
"组成",
|
|
7153
|
+
"share"
|
|
7154
|
+
],
|
|
7155
|
+
rank: [
|
|
7156
|
+
"排行",
|
|
7157
|
+
"排名",
|
|
7158
|
+
"前几",
|
|
7159
|
+
"highest",
|
|
7160
|
+
"rank"
|
|
7161
|
+
],
|
|
7162
|
+
compare: [
|
|
7163
|
+
"对比",
|
|
7164
|
+
"比较",
|
|
7165
|
+
"对照",
|
|
7166
|
+
"相比",
|
|
7167
|
+
"compare"
|
|
7168
|
+
],
|
|
7169
|
+
relation: [
|
|
7170
|
+
"关系",
|
|
7171
|
+
"相关",
|
|
7172
|
+
"散点",
|
|
7173
|
+
"分布",
|
|
7174
|
+
"relation",
|
|
7175
|
+
"scatter"
|
|
7176
|
+
],
|
|
7177
|
+
stack: [
|
|
7178
|
+
"叠加",
|
|
7179
|
+
"堆叠",
|
|
7180
|
+
"累计",
|
|
7181
|
+
"stack"
|
|
7182
|
+
]
|
|
7183
|
+
};
|
|
7184
|
+
function detectIntent(text) {
|
|
7185
|
+
if (!text || typeof text !== "string") return null;
|
|
7186
|
+
for (const intent of [
|
|
7187
|
+
"trend",
|
|
7188
|
+
"stack",
|
|
7189
|
+
"share",
|
|
7190
|
+
"rank",
|
|
7191
|
+
"relation",
|
|
7192
|
+
"compare"
|
|
7193
|
+
]) if (INTENT_KEYWORDS[intent].some((kw) => text.includes(kw))) return intent;
|
|
7194
|
+
return null;
|
|
7195
|
+
}
|
|
7196
|
+
function fmtValue(v) {
|
|
7197
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return String(v);
|
|
7198
|
+
if (Math.abs(v) >= 1e8) return `${(v / 1e8).toFixed(1)}亿`;
|
|
7199
|
+
if (Math.abs(v) >= 1e4) return `${(v / 1e4).toFixed(1)}万`;
|
|
7200
|
+
return `${Math.round(v * 100) / 100}`;
|
|
7201
|
+
}
|
|
7202
|
+
function buildNarrative(spec, labels, seriesList) {
|
|
7203
|
+
const annotations = [];
|
|
7204
|
+
let best = null;
|
|
7205
|
+
seriesList.forEach((s, si) => {
|
|
7206
|
+
s.data.forEach((v, di) => {
|
|
7207
|
+
if (typeof v === "number" && Number.isFinite(v) && (!best || v > best.value)) best = {
|
|
7208
|
+
value: v,
|
|
7209
|
+
label: labels[di],
|
|
7210
|
+
series: s.name,
|
|
7211
|
+
si,
|
|
7212
|
+
di
|
|
7213
|
+
};
|
|
7214
|
+
});
|
|
7215
|
+
});
|
|
7216
|
+
if (best) annotations.push({
|
|
7217
|
+
type: "callout",
|
|
7218
|
+
x: best.label,
|
|
7219
|
+
y: best.value,
|
|
7220
|
+
series: best.series,
|
|
7221
|
+
label: `峰值 ${fmtValue(best.value)}`,
|
|
7222
|
+
anchor: "top-right"
|
|
7223
|
+
});
|
|
7224
|
+
const first = seriesList[0];
|
|
7225
|
+
if (first && first.data.length >= 2) {
|
|
7226
|
+
const prev = first.data[first.data.length - 2];
|
|
7227
|
+
const last = first.data[first.data.length - 1];
|
|
7228
|
+
if (typeof prev === "number" && typeof last === "number" && prev !== 0) {
|
|
7229
|
+
const pct = (last - prev) / Math.abs(prev);
|
|
7230
|
+
if (Math.abs(pct) >= .01) annotations.push({
|
|
7231
|
+
type: "delta",
|
|
7232
|
+
x: labels[labels.length - 1],
|
|
7233
|
+
y: last,
|
|
7234
|
+
series: first.name,
|
|
7235
|
+
direction: pct >= 0 ? "up" : "down",
|
|
7236
|
+
text: `环比 ${pct >= 0 ? "+" : ""}${(pct * 100).toFixed(0)}%`
|
|
7237
|
+
});
|
|
7238
|
+
}
|
|
7239
|
+
}
|
|
7240
|
+
if (annotations.length > 0) spec.annotations = annotations.slice(0, 3);
|
|
7241
|
+
}
|
|
7242
|
+
function generateChartSpec(data, hint = {}) {
|
|
7243
|
+
const table = parseDataTable(data);
|
|
7244
|
+
if (!table) return {
|
|
7245
|
+
spec: null,
|
|
7246
|
+
report: [{
|
|
7247
|
+
level: "error",
|
|
7248
|
+
message: "无法识别的数据格式:支持 CSV/TSV 文本、对象数组、二维数组"
|
|
7249
|
+
}]
|
|
7250
|
+
};
|
|
7251
|
+
const cols = inferColumns(table);
|
|
7252
|
+
const numbers = cols.filter((c) => c.type === "number");
|
|
7253
|
+
const times = cols.filter((c) => c.type === "time");
|
|
7254
|
+
const cats = cols.filter((c) => c.type === "category" && distinctCount(c.values) > 0);
|
|
7255
|
+
const dimension = times[0] || cats[0] || null;
|
|
7256
|
+
const labels = dimension ? dimension.values.map((v) => String(v)) : table.rows.map((_, i) => String(i + 1));
|
|
7257
|
+
const intent = hint.intent || detectIntent([hint.title, hint.hint].filter(Boolean).join(" ")) || defaultIntent({
|
|
7258
|
+
times: times.length,
|
|
7259
|
+
numbers: numbers.length,
|
|
7260
|
+
cats: cats.length,
|
|
7261
|
+
labels: labels.length
|
|
7262
|
+
});
|
|
7263
|
+
const report = [{
|
|
7264
|
+
level: "info",
|
|
7265
|
+
message: `识别到 ${numbers.length} 个数值列、${times.length} 个时间列、${cats.length} 个类目列;意图「${intent}」`
|
|
7266
|
+
}];
|
|
7267
|
+
const spec = {};
|
|
7268
|
+
if (hint.title) spec.title = hint.title;
|
|
7269
|
+
if (intent === "relation" && numbers.length >= 2) {
|
|
7270
|
+
const cat = cats[0];
|
|
7271
|
+
spec.type = "scatter";
|
|
7272
|
+
spec.scatterData = table.rows.map((_, i) => ({
|
|
7273
|
+
x: numbers[0].values[i],
|
|
7274
|
+
y: numbers[1].values[i],
|
|
7275
|
+
label: cat ? String(cat.values[i]) : String(i + 1)
|
|
7276
|
+
}));
|
|
7277
|
+
report.push({
|
|
7278
|
+
level: "info",
|
|
7279
|
+
message: `散点图:${numbers[0].name} → x,${numbers[1].name} → y`
|
|
7280
|
+
});
|
|
7281
|
+
return {
|
|
7282
|
+
spec,
|
|
7283
|
+
report
|
|
7284
|
+
};
|
|
7285
|
+
}
|
|
7286
|
+
if (numbers.length === 0) return {
|
|
7287
|
+
spec: null,
|
|
7288
|
+
report: [{
|
|
7289
|
+
level: "error",
|
|
7290
|
+
message: "数据中没有可用的数值列(列需 ≥60% 为可解析数字)"
|
|
7291
|
+
}]
|
|
7292
|
+
};
|
|
7293
|
+
const seriesList = numbers.slice(0, 8).map((c) => ({
|
|
7294
|
+
name: c.name,
|
|
7295
|
+
data: c.values
|
|
7296
|
+
}));
|
|
7297
|
+
if (numbers.length > 8) report.push({
|
|
7298
|
+
level: "warn",
|
|
7299
|
+
message: `数值列超过 8 个,仅取前 8 列(${seriesList.map((s) => s.name).join("、")})`
|
|
7300
|
+
});
|
|
7301
|
+
if (intent === "share") {
|
|
7302
|
+
if (seriesList.length === 1 && labels.length <= 8) {
|
|
7303
|
+
const pieData = labels.map((name, i) => ({
|
|
7304
|
+
name,
|
|
7305
|
+
value: seriesList[0].data[i]
|
|
7306
|
+
})).filter((d) => typeof d.value === "number" && d.value > 0);
|
|
7307
|
+
spec.type = "pie";
|
|
7308
|
+
spec.pieData = pieData;
|
|
7309
|
+
report.push({
|
|
7310
|
+
level: "info",
|
|
7311
|
+
message: `饼图:${seriesList[0].name} 按类目占比(${pieData.length} 项)`
|
|
7312
|
+
});
|
|
7313
|
+
return {
|
|
7314
|
+
spec,
|
|
7315
|
+
report
|
|
7316
|
+
};
|
|
7317
|
+
}
|
|
7318
|
+
if (seriesList.length >= 2 && seriesList.length <= 8) {
|
|
7319
|
+
const sums = seriesList.map((s) => s.data.reduce((acc, v) => typeof v === "number" && Number.isFinite(v) ? acc + v : acc, 0));
|
|
7320
|
+
if (sums.every((v) => v > 0)) {
|
|
7321
|
+
spec.type = "pie";
|
|
7322
|
+
spec.pieData = seriesList.map((s, i) => ({
|
|
7323
|
+
name: s.name,
|
|
7324
|
+
value: sums[i]
|
|
7325
|
+
}));
|
|
7326
|
+
report.push({
|
|
7327
|
+
level: "info",
|
|
7328
|
+
message: `饼图:按度量合计占比(${sums.length} 项)`
|
|
7329
|
+
});
|
|
7330
|
+
return {
|
|
7331
|
+
spec,
|
|
7332
|
+
report
|
|
7333
|
+
};
|
|
7334
|
+
}
|
|
7335
|
+
}
|
|
7336
|
+
}
|
|
7337
|
+
const labelWidth = labels.reduce((m, l) => Math.max(m, l.length), 0);
|
|
7338
|
+
const wantRank = intent === "rank" || intent === "compare" && seriesList.length === 1 && labels.length >= 9;
|
|
7339
|
+
if (seriesList.length === 1 && (wantRank || labels.length >= 13 || labelWidth >= 5 && labels.length >= 7)) {
|
|
7340
|
+
spec.type = "horizontal-bar";
|
|
7341
|
+
spec.labels = labels;
|
|
7342
|
+
spec.series = seriesList;
|
|
7343
|
+
report.push({
|
|
7344
|
+
level: "info",
|
|
7345
|
+
message: "横向条形图:单度量分类对比,类目名走纵列更易读"
|
|
7346
|
+
});
|
|
7347
|
+
finishCartesian(spec, hint, labels, seriesList, report);
|
|
7348
|
+
return {
|
|
7349
|
+
spec,
|
|
7350
|
+
report
|
|
7351
|
+
};
|
|
7352
|
+
}
|
|
7353
|
+
if (intent === "stack" && seriesList.length >= 2) {
|
|
7354
|
+
spec.type = "stacked-bar";
|
|
7355
|
+
report.push({
|
|
7356
|
+
level: "info",
|
|
7357
|
+
message: "堆叠柱状图:多度量叠加看构成"
|
|
7358
|
+
});
|
|
7359
|
+
} else if ((intent === "compare" || intent === "rank") && seriesList.length <= 3 && labels.length <= 12) {
|
|
7360
|
+
spec.type = "bar";
|
|
7361
|
+
report.push({
|
|
7362
|
+
level: "info",
|
|
7363
|
+
message: "柱状图:类目间量值对比"
|
|
7364
|
+
});
|
|
7365
|
+
} else {
|
|
7366
|
+
spec.type = "line";
|
|
7367
|
+
report.push({
|
|
7368
|
+
level: "info",
|
|
7369
|
+
message: "折线图:维度有序(时间/类目),看走势与多系列对比"
|
|
7370
|
+
});
|
|
7371
|
+
}
|
|
7372
|
+
spec.labels = labels;
|
|
7373
|
+
spec.series = seriesList;
|
|
7374
|
+
if (seriesList.length > 1) spec.legend = { show: true };
|
|
7375
|
+
finishCartesian(spec, hint, labels, seriesList, report);
|
|
7376
|
+
return {
|
|
7377
|
+
spec,
|
|
7378
|
+
report
|
|
7379
|
+
};
|
|
7380
|
+
}
|
|
7381
|
+
function finishCartesian(spec, hint, labels, seriesList, report) {
|
|
7382
|
+
if (hint.narrative) {
|
|
7383
|
+
buildNarrative(spec, labels, seriesList);
|
|
7384
|
+
if (spec.annotations) report.push({
|
|
7385
|
+
level: "info",
|
|
7386
|
+
message: `叙述注解 ${spec.annotations.length} 处(峰值 callout / 末点环比 delta)`
|
|
7387
|
+
});
|
|
7388
|
+
}
|
|
7389
|
+
}
|
|
7390
|
+
function defaultIntent({ times, numbers, cats, labels }) {
|
|
7391
|
+
if (times > 0 && numbers > 0) return "trend";
|
|
7392
|
+
if (numbers >= 2 && cats === 0) return "relation";
|
|
7393
|
+
if (numbers === 1 && (cats > 0 || labels > 0)) return "compare";
|
|
7394
|
+
return "trend";
|
|
7395
|
+
}
|
|
7396
|
+
var SPEC_RULES = [
|
|
7397
|
+
"type 必填,取值必须是 schema enum 中的图表类型",
|
|
7398
|
+
"series[].data 长度必须与 labels 一致,缺失值用 null",
|
|
7399
|
+
"叙述注解 annotations 单图不超过 3 处,emphasis 焦点最多 1 个系列",
|
|
7400
|
+
"不使用 schema 之外的字段",
|
|
7401
|
+
`支持的图表类型:${chartOptionsSchema.properties.type.enum.join(" / ")}`
|
|
7402
|
+
];
|
|
7403
|
+
//#endregion
|
|
7404
|
+
//#region src/ai/examples.js
|
|
7405
|
+
var SPEC_EXAMPLES = [
|
|
7406
|
+
{
|
|
7407
|
+
requirement: "看上半年各渠道销售额走势",
|
|
7408
|
+
spec: {
|
|
7409
|
+
type: "line",
|
|
7410
|
+
title: "渠道月度销售额",
|
|
7411
|
+
labels: [
|
|
7412
|
+
"1月",
|
|
7413
|
+
"2月",
|
|
7414
|
+
"3月",
|
|
7415
|
+
"4月",
|
|
7416
|
+
"5月",
|
|
7417
|
+
"6月"
|
|
7418
|
+
],
|
|
7419
|
+
series: [{
|
|
7420
|
+
name: "线上商城",
|
|
7421
|
+
data: [
|
|
7422
|
+
320,
|
|
7423
|
+
356,
|
|
7424
|
+
401,
|
|
7425
|
+
388,
|
|
7426
|
+
520,
|
|
7427
|
+
560
|
|
7428
|
+
]
|
|
7429
|
+
}, {
|
|
7430
|
+
name: "线下门店",
|
|
7431
|
+
data: [
|
|
7432
|
+
280,
|
|
7433
|
+
302,
|
|
7434
|
+
346,
|
|
7435
|
+
331,
|
|
7436
|
+
352,
|
|
7437
|
+
428
|
|
7438
|
+
]
|
|
7439
|
+
}],
|
|
7440
|
+
legend: { show: true }
|
|
7441
|
+
}
|
|
7442
|
+
},
|
|
7443
|
+
{
|
|
7444
|
+
requirement: "各渠道销售额占比",
|
|
7445
|
+
spec: {
|
|
7446
|
+
type: "pie",
|
|
7447
|
+
title: "渠道销售额占比",
|
|
7448
|
+
pieData: [
|
|
7449
|
+
{
|
|
7450
|
+
name: "线上商城",
|
|
7451
|
+
value: 2545
|
|
7452
|
+
},
|
|
7453
|
+
{
|
|
7454
|
+
name: "线下门店",
|
|
7455
|
+
value: 2039
|
|
7456
|
+
},
|
|
7457
|
+
{
|
|
7458
|
+
name: "社群团购",
|
|
7459
|
+
value: 844
|
|
7460
|
+
}
|
|
7461
|
+
]
|
|
7462
|
+
}
|
|
7463
|
+
},
|
|
7464
|
+
{
|
|
7465
|
+
requirement: "各门店销量排行",
|
|
7466
|
+
spec: {
|
|
7467
|
+
type: "horizontal-bar",
|
|
7468
|
+
title: "门店销量排行",
|
|
7469
|
+
labels: [
|
|
7470
|
+
"线下体验店",
|
|
7471
|
+
"线上旗舰店",
|
|
7472
|
+
"社群团购点",
|
|
7473
|
+
"仓储会员店",
|
|
7474
|
+
"社区便利店"
|
|
7475
|
+
],
|
|
7476
|
+
series: [{
|
|
7477
|
+
name: "销量",
|
|
7478
|
+
data: [
|
|
7479
|
+
120,
|
|
7480
|
+
98,
|
|
7481
|
+
80,
|
|
7482
|
+
60,
|
|
7483
|
+
52
|
|
7484
|
+
]
|
|
7485
|
+
}]
|
|
7486
|
+
}
|
|
7487
|
+
},
|
|
7488
|
+
{
|
|
7489
|
+
requirement: "广告投放与成交金额的关系",
|
|
7490
|
+
spec: {
|
|
7491
|
+
type: "scatter",
|
|
7492
|
+
title: "投放-成交关系",
|
|
7493
|
+
scatterData: [
|
|
7494
|
+
{
|
|
7495
|
+
x: 100,
|
|
7496
|
+
y: 12,
|
|
7497
|
+
label: "华东"
|
|
7498
|
+
},
|
|
7499
|
+
{
|
|
7500
|
+
x: 200,
|
|
7501
|
+
y: 26,
|
|
7502
|
+
label: "华南"
|
|
7503
|
+
},
|
|
7504
|
+
{
|
|
7505
|
+
x: 300,
|
|
7506
|
+
y: 31,
|
|
7507
|
+
label: "华北"
|
|
7508
|
+
}
|
|
7509
|
+
]
|
|
7510
|
+
}
|
|
7511
|
+
}
|
|
7512
|
+
];
|
|
7513
|
+
function formatExamples(maxCount = SPEC_EXAMPLES.length) {
|
|
7514
|
+
return SPEC_EXAMPLES.slice(0, maxCount).map((e) => `需求:${e.requirement}\nSpec:${JSON.stringify(e.spec)}`).join("\n\n");
|
|
7515
|
+
}
|
|
7516
|
+
//#endregion
|
|
7517
|
+
//#region src/ai/prompt.js
|
|
7518
|
+
var PREVIEW_ROWS = 8;
|
|
7519
|
+
function buildChartPrompt({ data, requirement, extraRules = [], examples = true } = {}) {
|
|
7520
|
+
const table = parseDataTable(data);
|
|
7521
|
+
let digest;
|
|
7522
|
+
if (table) digest = `${table.headers.join(" | ")}\n${table.rows.slice(0, PREVIEW_ROWS).map((r) => table.headers.map((h) => r[h] === null || r[h] === void 0 ? "" : String(r[h])).join(" | ")).join("\n")}${table.rows.length > PREVIEW_ROWS ? `\n…共 ${table.rows.length} 行` : ""}`;
|
|
7523
|
+
else if (data !== void 0 && data !== null) digest = typeof data === "string" ? data : JSON.stringify(data);
|
|
7524
|
+
else digest = "(未提供数据,请基于需求虚构合理示例数据)";
|
|
7525
|
+
const rules = [...SPEC_RULES, ...extraRules];
|
|
7526
|
+
const sections = [
|
|
7527
|
+
"你是图表配置生成器。根据下面的数据与需求,产出 EvChart 的 options JSON(下称 Spec),它将被 <ev-chart :options> 直接渲染。",
|
|
7528
|
+
`## Options Schema\n${JSON.stringify(chartOptionsSchema)}`,
|
|
7529
|
+
`## 数据预览\n${digest}`
|
|
7530
|
+
];
|
|
7531
|
+
if (examples) sections.push(`## 示例\n${formatExamples()}`);
|
|
7532
|
+
sections.push(requirement ? `## 需求\n${requirement}` : "## 需求\n根据数据形状自行选择最合适的图表类型,并拟一个不超过 12 字的标题。");
|
|
7533
|
+
sections.push(`## 硬性规则\n${rules.map((r, i) => `${i + 1}. ${r}`).join("\n")}`);
|
|
7534
|
+
return sections.join("\n\n");
|
|
7535
|
+
}
|
|
7536
|
+
//#endregion
|
|
7537
|
+
//#region src/ai/lint.js
|
|
7538
|
+
var DEFAULT_SIZE = {
|
|
7539
|
+
width: 800,
|
|
7540
|
+
height: 450
|
|
7541
|
+
};
|
|
7542
|
+
function checkTextOverflow(svg, width, height) {
|
|
7543
|
+
const issues = [];
|
|
7544
|
+
let count = 0;
|
|
7545
|
+
for (const m of svg.matchAll(/<text x="([-\d.]+)" y="([-\d.]+)"/g)) {
|
|
7546
|
+
const x = parseFloat(m[1]);
|
|
7547
|
+
const y = parseFloat(m[2]);
|
|
7548
|
+
if (x < -1 || x > width + 1 || y < 0 || y > height + 1) count++;
|
|
7549
|
+
}
|
|
7550
|
+
if (count > 0) issues.push({
|
|
7551
|
+
level: "warn",
|
|
7552
|
+
message: `${count} 处文本超出画布边界(可能被裁剪)`,
|
|
7553
|
+
rule: "text-overflow"
|
|
7554
|
+
});
|
|
7555
|
+
return issues;
|
|
7556
|
+
}
|
|
7557
|
+
function checkTextOverlap(svg) {
|
|
7558
|
+
const issues = [];
|
|
7559
|
+
const texts = [];
|
|
7560
|
+
for (const m of svg.matchAll(/<text x="([-\d.]+)" y="([-\d.]+)" font-size="([\d.]+)"([^>]*)>([^<]*)<\/text>/g)) {
|
|
7561
|
+
const x = parseFloat(m[1]);
|
|
7562
|
+
const y = parseFloat(m[2]);
|
|
7563
|
+
const fs = parseFloat(m[3]);
|
|
7564
|
+
const attrs = m[4];
|
|
7565
|
+
const content = m[5].trim();
|
|
7566
|
+
if (!content) continue;
|
|
7567
|
+
const w = estimateTextWidth(content, fs);
|
|
7568
|
+
let left = x;
|
|
7569
|
+
if (/text-anchor="middle"/.test(attrs)) left = x - w / 2;
|
|
7570
|
+
else if (/text-anchor="end"/.test(attrs)) left = x - w;
|
|
7571
|
+
texts.push({
|
|
7572
|
+
left,
|
|
7573
|
+
right: left + w,
|
|
7574
|
+
y,
|
|
7575
|
+
fs
|
|
7576
|
+
});
|
|
7577
|
+
}
|
|
7578
|
+
let count = 0;
|
|
7579
|
+
for (let i = 0; i < texts.length; i++) for (let j = i + 1; j < texts.length; j++) {
|
|
7580
|
+
const a = texts[i];
|
|
7581
|
+
const b = texts[j];
|
|
7582
|
+
if (Math.abs(a.y - b.y) >= Math.max(a.fs, b.fs) * .8) continue;
|
|
7583
|
+
if (Math.min(a.right, b.right) - Math.max(a.left, b.left) > Math.min(a.right - a.left, b.right - b.left) * .3) count++;
|
|
7584
|
+
}
|
|
7585
|
+
if (count > 0) issues.push({
|
|
7586
|
+
level: "warn",
|
|
7587
|
+
message: `${count} 处文本相互重叠(标签或注解可能互相遮挡)`,
|
|
7588
|
+
rule: "text-overlap"
|
|
7589
|
+
});
|
|
7590
|
+
return issues;
|
|
7591
|
+
}
|
|
7592
|
+
function luminance(color) {
|
|
7593
|
+
let s = String(color).trim();
|
|
7594
|
+
if (/^#[0-9a-fA-F]{6}$/.test(s)) s = s.slice(1);
|
|
7595
|
+
else if (/^#[0-9a-fA-F]{3}$/.test(s)) s = s.slice(1).split("").map((c) => c + c).join("");
|
|
7596
|
+
else return null;
|
|
7597
|
+
const n = parseInt(s, 16);
|
|
7598
|
+
const chan = [
|
|
7599
|
+
n >> 16 & 255,
|
|
7600
|
+
n >> 8 & 255,
|
|
7601
|
+
n & 255
|
|
7602
|
+
].map((v) => {
|
|
7603
|
+
const c = v / 255;
|
|
7604
|
+
return c <= .03928 ? c / 12.92 : Math.pow((c + .055) / 1.055, 2.4);
|
|
7605
|
+
});
|
|
7606
|
+
return .2126 * chan[0] + .7152 * chan[1] + .0722 * chan[2];
|
|
7607
|
+
}
|
|
7608
|
+
function contrastRatio(a, b) {
|
|
7609
|
+
const la = luminance(a);
|
|
7610
|
+
const lb = luminance(b);
|
|
7611
|
+
if (la === null || lb === null) return null;
|
|
7612
|
+
return (Math.max(la, lb) + .05) / (Math.min(la, lb) + .05);
|
|
7613
|
+
}
|
|
7614
|
+
function checkPaletteContrast(spec) {
|
|
7615
|
+
const issues = [];
|
|
7616
|
+
if (!spec.theme || !Array.isArray(spec.theme.colors)) return issues;
|
|
7617
|
+
[false, true].forEach((dark) => {
|
|
7618
|
+
const theme = getTheme(dark, spec.theme);
|
|
7619
|
+
spec.theme.colors.forEach((c) => {
|
|
7620
|
+
const ratio = contrastRatio(c, theme.backgroundColor);
|
|
7621
|
+
if (ratio !== null && ratio < 3) issues.push({
|
|
7622
|
+
level: "warn",
|
|
7623
|
+
message: `自定义色板${dark ? "暗色" : "浅色"}模式对比度不足 3:1:${c}(${ratio.toFixed(1)})`,
|
|
7624
|
+
rule: "low-contrast"
|
|
7625
|
+
});
|
|
7626
|
+
});
|
|
7627
|
+
});
|
|
7628
|
+
return issues;
|
|
7629
|
+
}
|
|
7630
|
+
function lintChartSpec(spec, opts = {}) {
|
|
7631
|
+
const issues = [];
|
|
7632
|
+
const fixed = { ...spec };
|
|
7633
|
+
if (!fixed || typeof fixed !== "object") return {
|
|
7634
|
+
issues: [{
|
|
7635
|
+
level: "error",
|
|
7636
|
+
message: "Spec 必须是对象"
|
|
7637
|
+
}],
|
|
7638
|
+
spec: fixed
|
|
7639
|
+
};
|
|
7640
|
+
const { warnings } = validateOptions(fixed);
|
|
7641
|
+
warnings.forEach((w) => issues.push({
|
|
7642
|
+
level: "error",
|
|
7643
|
+
message: `${w.path} ${w.message}`,
|
|
7644
|
+
path: w.path
|
|
7645
|
+
}));
|
|
7646
|
+
if (issues.some((i) => i.level === "error" && i.path === "options.type")) return {
|
|
7647
|
+
issues,
|
|
7648
|
+
spec: fixed
|
|
7649
|
+
};
|
|
7650
|
+
if (Array.isArray(fixed.annotations) && fixed.annotations.length > 3) {
|
|
7651
|
+
fixed.annotations = fixed.annotations.slice(0, 3);
|
|
7652
|
+
issues.push({
|
|
7653
|
+
level: "info",
|
|
7654
|
+
message: "注解超过 3 处,已按焦点预算裁剪",
|
|
7655
|
+
rule: "annotation-budget"
|
|
7656
|
+
});
|
|
7657
|
+
}
|
|
7658
|
+
if ((fixed.type === "pie" || fixed.type === "doughnut") && Array.isArray(fixed.pieData) && fixed.pieData.length > 8) issues.push({
|
|
7659
|
+
level: "warn",
|
|
7660
|
+
message: `饼图 ${fixed.pieData.length} 个扇区过多(>8),建议改用横向条形图`,
|
|
7661
|
+
rule: "pie-slices"
|
|
7662
|
+
});
|
|
7663
|
+
issues.push(...checkPaletteContrast(fixed));
|
|
7664
|
+
if (fixed.type === "bar" && Array.isArray(fixed.series) && fixed.series.length === 1 && Array.isArray(fixed.labels)) {
|
|
7665
|
+
const width = opts.width ?? DEFAULT_SIZE.width;
|
|
7666
|
+
const labels = fixed.labels.map(String);
|
|
7667
|
+
const maxLabelWidth = Math.max(...labels.map((l) => estimateTextWidth(l, 12)), 0);
|
|
7668
|
+
const slots = Math.max(1, (width - 80) / Math.max(1, labels.length));
|
|
7669
|
+
if (labels.length >= 7 && maxLabelWidth > slots * .9) {
|
|
7670
|
+
fixed.type = "horizontal-bar";
|
|
7671
|
+
issues.push({
|
|
7672
|
+
level: "info",
|
|
7673
|
+
message: "类目名过宽拥挤,已自动改为横向条形图",
|
|
7674
|
+
rule: "bar-to-horizontal"
|
|
7675
|
+
});
|
|
7676
|
+
}
|
|
7677
|
+
}
|
|
7678
|
+
if (typeof document !== "undefined" && fixed.type) try {
|
|
7679
|
+
const width = opts.width ?? DEFAULT_SIZE.width;
|
|
7680
|
+
const height = opts.height ?? DEFAULT_SIZE.height;
|
|
7681
|
+
const canvas = document.createElement("canvas");
|
|
7682
|
+
canvas.width = width;
|
|
7683
|
+
canvas.height = height;
|
|
7684
|
+
const real = canvas.getContext("2d");
|
|
7685
|
+
if (real) {
|
|
7686
|
+
const recorder = createSvgRecorder(real);
|
|
7687
|
+
renderChart(canvas, {
|
|
7688
|
+
options: fixed,
|
|
7689
|
+
ctx: recorder.ctx,
|
|
7690
|
+
dpr: 1,
|
|
7691
|
+
progress: 1,
|
|
7692
|
+
hoverIndex: -1,
|
|
7693
|
+
mouseX: -1,
|
|
7694
|
+
mouseY: -1,
|
|
7695
|
+
showCrosshair: false
|
|
7696
|
+
});
|
|
7697
|
+
issues.push(...checkTextOverflow(recorder.toSvg(width, height, "#ffffff"), width, height));
|
|
7698
|
+
issues.push(...checkTextOverlap(recorder.toSvg(width, height, "#ffffff")));
|
|
7699
|
+
}
|
|
7700
|
+
} catch {
|
|
7701
|
+
issues.push({
|
|
7702
|
+
level: "info",
|
|
7703
|
+
message: "无头渲染自检不可用,已跳过几何检查",
|
|
7704
|
+
rule: "headless-skipped"
|
|
7705
|
+
});
|
|
7706
|
+
}
|
|
7707
|
+
return {
|
|
7708
|
+
issues,
|
|
7709
|
+
spec: fixed
|
|
7710
|
+
};
|
|
7711
|
+
}
|
|
7712
|
+
//#endregion
|
|
5940
7713
|
//#region src/index.js
|
|
5941
7714
|
var components = { EvChart: chart_default };
|
|
5942
7715
|
function install(app, _options = {}) {
|
|
@@ -5945,4 +7718,4 @@ function install(app, _options = {}) {
|
|
|
5945
7718
|
var EvokeCharts = { install };
|
|
5946
7719
|
var src_default = { install };
|
|
5947
7720
|
//#endregion
|
|
5948
|
-
export { chart_default as EvChart, EvokeCharts, components, src_default as default, injectChartContext, install, provideChartContext, useChart };
|
|
7721
|
+
export { chart_default as EvChart, EvokeCharts, applySeriesPalette, buildChartPrompt, chartOptionsSchema, clearSeriesPalette, components, src_default as default, generateChartSpec, injectChartContext, install, lintChartSpec, parseDataTable, provideChartContext, useChart, validateOptions };
|