@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/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
- * 系列色令牌表 — 图表分类色板从 CSS 令牌实时读取(换主题/暗色即跟随),
19
+ * 系列色令牌槽位 — 每槽按顺序尝试读取专用数据色令牌(--ev-color-series-N),
20
+ * 槽 1 回退 --ev-color-primary(品牌主色跟随);
21
+ * 任一槽无令牌时该槽回落内置成套色板(与语义状态色解耦的数据色板)。
20
22
  * CHART_COLORS 仅作为 SSR / 无 DOM 环境的静态兜底。
21
23
  */
22
- var SERIES_COLOR_TOKENS = [
23
- "--ev-color-primary",
24
- "--ev-color-success",
25
- "--ev-color-warning",
26
- "--ev-color-ext-violet",
27
- "--ev-color-ext-cyan",
28
- "--ev-color-danger",
29
- "--ev-color-ext-magenta",
30
- "--ev-color-info"
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
- const live = SERIES_COLOR_TOKENS.map((t) => readChartToken(t, ""));
42
- return live.some((v) => !v) ? [...fallbackColors] : live;
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
- * 主色板 — 对齐 evoke 设计令牌:
47
- * 前位使用功能色(primary/success/warning/danger/info),
48
- * 扩展色选取与深蓝体系协调的色相;相邻色相拉开对比度。
53
+ * 主色板 — 品牌蓝锚定的成套数据色板(与语义状态色解耦):
54
+ * 色相按取色顺序相邻差 ≥ 30°,明度带 44–68,青绿/金黄/天蓝/珊瑚/紫/石板
55
+ * 取材 AntV 经典系并经品牌蓝调协调;暗色板同色相提亮。
49
56
  */
50
57
  primary: [
51
58
  "#175DFF",
52
- "#16a34a",
53
- "#d97706",
54
- "#8b5cf6",
55
- "#0891b2",
56
- "#dc2626",
57
- "#db2777",
58
- "#64748b"
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
- /** 暗色模式系列 — 对齐 evoke 暗色功能色令牌 */
73
+ /** 暗色模式系列 — 同色相提亮(对齐 evoke 暗色主色) */
67
74
  dark: [
68
75
  "#4d8bff",
69
- "#4ade80",
70
- "#fbbf24",
71
- "#a78bfa",
72
- "#22d3ee",
73
- "#f87171",
74
- "#f472b6",
75
- "#94a3b8"
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") return {
315
- top: options.title ? 32 : 6,
316
- right: 6,
317
- bottom: 6,
318
- left: 6
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
- let top2 = 18 + titleBlockHeight(options);
339
- let bottom2 = 18;
340
- let left2 = 20;
341
- let right2 = 20;
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
- let top = 18 + titleBlockHeight(options) + (legendPosition === "top" ? LEGEND_BAND + legendRowsExtra : 0);
375
- let bottom = 46 + (legendPosition === "bottom" ? LEGEND_BAND + legendRowsExtra : 0);
376
- let left = 65;
377
- let right = hasYAxisRight ? 65 : 40;
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) bottom += 15;
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
- bottom += Math.min(90, 12 + maxLabelWidth * Math.sin(Math.min(60, Math.abs(xRotate)) * Math.PI / 180));
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") top += zoomHeight;
388
- else bottom += zoomHeight;
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 ticks = axisConfig.ticks || 5;
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, ticks);
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 = "bold 16px Inter, sans-serif";
1162
- canvasCtx.textAlign = "center";
1464
+ canvasCtx.font = "600 13px Inter, sans-serif";
1465
+ canvasCtx.textAlign = "left";
1163
1466
  canvasCtx.textBaseline = "top";
1164
- canvasCtx.fillText(options.title, plotArea.x + plotArea.width / 2, paddingTop);
1467
+ canvasCtx.fillText(options.title, plotArea.x, paddingTop);
1165
1468
  if (options.subtitle) {
1166
- canvasCtx.font = "12px Inter, sans-serif";
1469
+ canvasCtx.font = "11px Inter, sans-serif";
1167
1470
  canvasCtx.fillStyle = theme.textColorSecondary;
1168
- canvasCtx.fillText(options.subtitle, plotArea.x + plotArea.width / 2, paddingTop + 22);
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, 12, 2);
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 = isHidden ? theme.textColorSecondary : theme.textColor;
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 (series.smooth && pts.length > 2 && !step) {
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 || 2;
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 !== false && series.symbol !== "none") {
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 = 8;
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, 3);
3402
- canvasCtx.fillStyle = theme.crosshairColor;
3752
+ roundRect(canvasCtx, hx, handleY, handleW, handleH, 4);
3753
+ canvasCtx.fillStyle = theme.backgroundColor;
3403
3754
  canvasCtx.fill();
3404
- canvasCtx.strokeStyle = theme.backgroundColor;
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 + 5);
3408
- canvasCtx.lineTo(hx + handleW / 2 - 2, handleY + handleH - 5);
3409
- canvasCtx.moveTo(hx + handleW / 2 + 2, handleY + 5);
3410
- canvasCtx.lineTo(hx + handleW / 2 + 2, handleY + handleH - 5);
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
- if (leftRange && options.annotation) renderAnnotations(renderCtx, leftRange);
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 _hoisted_3 = { class: "ev-chart__empty-icon-wrap" };
4079
- var _hoisted_4 = { class: "ev-chart__empty-text" };
4080
- var _hoisted_5 = {
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 _hoisted_6 = { class: "ev-chart__loading-bars" };
4085
- var _hoisted_7 = {
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 _hoisted_8 = { class: "ev-chart__error-text" };
4090
- var _hoisted_9 = ["innerHTML"];
4091
- var _hoisted_10 = {
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
- if (!getDataZoomConfig(opt)) return opt;
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 opt;
4233
- const sliced = { ...opt };
4234
- const n = (opt.labels || []).length;
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 = (opt.labels || []).slice(startIdx, endIdx);
4238
- if (opt.series) sliced.series = opt.series.map((s) => ({
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 (opt.candleData) sliced.candleData = opt.candleData.slice(startIdx, endIdx);
5002
+ if (base.candleData) sliced.candleData = base.candleData.slice(startIdx, endIdx);
4243
5003
  sliced.__sliceStart = startIdx;
4244
- } else if (opt.scatterData && opt.scatterData.length > 0) {
4245
- const xs = opt.scatterData.map((d) => d.x);
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 = opt.scatterData.filter((d) => d.x >= lo && d.x <= hi);
4251
- sliced.scatterData = filtered.length > 0 ? filtered : opt.scatterData;
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 (spaceBelow >= th) top = relY + gap;
4301
- else if (spaceAbove >= th) top = relY - gap - th;
4302
- else top = spaceBelow >= spaceAbove ? containerRect.height - th - margin : legendBottom;
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 animEnabled = animate && props.options.animation?.enabled !== false;
4354
- animationState = createAnimation(props.options.animation);
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
- const nextFocus = props.options.legend?.hoverEmphasis !== false && legendName ? legendName : null;
5376
- if (nextFocus !== focusSeries.value) {
5377
- focusSeries.value = nextFocus;
5378
- redraw();
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", _hoisted_2, [createElementVNode("div", _hoisted_3, [(openBlock(), createBlock(resolveDynamicComponent(DataLine), { class: "ev-chart__empty-img" }))]), createElementVNode("span", _hoisted_4, toDisplayString(emptyTextI18n.value), 1)])) : createCommentVNode("", true)]),
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", _hoisted_5, [createElementVNode("div", _hoisted_6, [(openBlock(), createElementBlock(Fragment, null, renderList(6, (i) => {
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", _hoisted_7, [_cache[0] || (_cache[0] = createElementVNode("span", { class: "ev-chart__error-icon" }, "⚠", -1)), createElementVNode("span", _hoisted_8, toDisplayString(internalError.value), 1)])) : createCommentVNode("", true)]),
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, _hoisted_9)) : createCommentVNode("", true)]),
6817
+ }, null, 14, _hoisted_10)) : createCommentVNode("", true)]),
5870
6818
  _: 1
5871
6819
  }),
5872
- createElementVNode("div", _hoisted_10, toDisplayString(ariaLiveText.value), 1)
6820
+ createElementVNode("div", _hoisted_11, toDisplayString(ariaLiveText.value), 1)
5873
6821
  ], 12, _hoisted_1);
5874
6822
  };
5875
6823
  }
5876
- }), [["__scopeId", "data-v-4ea20430"]]);
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 };