@wil-works/evoke-charts 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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: {
@@ -121,19 +121,62 @@ function estimateTextWidth(text, fontSize = 12) {
121
121
  }
122
122
  return width;
123
123
  }
124
+ /** 解析 `#rgb` / `#rrggbb` / `rgb()` / `rgba()` 为通道数组;无法识别返回 null */
125
+ function parseColorChannels(color) {
126
+ if (typeof color !== "string") return null;
127
+ const text = color.trim();
128
+ const hex = text.replace(/^#/, "");
129
+ if (/^[0-9a-fA-F]{3}$/.test(hex)) return [
130
+ 0,
131
+ 1,
132
+ 2
133
+ ].map((i) => parseInt(hex[i] + hex[i], 16));
134
+ if (/^[0-9a-fA-F]{6}$/.test(hex)) return [
135
+ 0,
136
+ 2,
137
+ 4
138
+ ].map((i) => parseInt(hex.slice(i, i + 2), 16));
139
+ const rgbMatch = text.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i);
140
+ if (rgbMatch) return [
141
+ 1,
142
+ 2,
143
+ 3
144
+ ].map((i) => Math.round(parseFloat(rgbMatch[i])));
145
+ return null;
146
+ }
147
+ /** 浅色底判断(YIQ):底色可能来自令牌、混合出的 `rgb()` 或自定义主题 */
148
+ function isLightColor(color) {
149
+ const ch = parseColorChannels(color);
150
+ if (!ch) return true;
151
+ return (ch[0] * 299 + ch[1] * 587 + ch[2] * 114) / 1e3 >= 150;
152
+ }
153
+ function relativeLuminance(ch) {
154
+ const [r, g, b] = ch.map((v) => {
155
+ const s = v / 255;
156
+ return s <= .03928 ? s / 12.92 : Math.pow((s + .055) / 1.055, 2.4);
157
+ });
158
+ return .2126 * r + .7152 * g + .0722 * b;
159
+ }
160
+ /** 底色上的文字取反色:白字/深字谁对比度高用谁——中间调底色(如逐层提亮后的蓝)
161
+ * 用亮度阈值会误判成白字,导致浅底白字看不清 */
124
162
  function getContrastText(bgColor) {
125
- const hex = bgColor.replace("#", "");
126
- let r = 0, g = 0, b = 0;
127
- if (hex.length === 3) {
128
- r = parseInt(hex[0] + hex[0], 16);
129
- g = parseInt(hex[1] + hex[1], 16);
130
- b = parseInt(hex[2] + hex[2], 16);
131
- } else if (hex.length >= 6) {
132
- r = parseInt(hex.slice(0, 2), 16);
133
- g = parseInt(hex.slice(2, 4), 16);
134
- b = parseInt(hex.slice(4, 6), 16);
135
- }
136
- return (r * 299 + g * 587 + b * 114) / 1e3 >= 150 ? "#111827" : "#ffffff";
163
+ const ch = parseColorChannels(bgColor);
164
+ if (!ch) return "#111827";
165
+ const luminance = relativeLuminance(ch);
166
+ const againstWhite = 1.05 / (luminance + .05);
167
+ return (luminance + .05) / (relativeLuminance([
168
+ 17,
169
+ 24,
170
+ 39
171
+ ]) + .05) >= againstWhite ? "#111827" : "#ffffff";
172
+ }
173
+ /** 颜色向 target 混合(ratio 0-1),返回 `#rrggbb`:同色系逐层提亮/压暗用 */
174
+ function mixColor(color, ratio, target) {
175
+ const from = parseColorChannels(color);
176
+ const to = parseColorChannels(target);
177
+ if (!from || !to) return color;
178
+ const r = Math.max(0, Math.min(1, ratio));
179
+ return `#${from.map((v, i) => Math.round(v + (to[i] - v) * r)).map((v) => v.toString(16).padStart(2, "0")).join("")}`;
137
180
  }
138
181
  function isMissingValue(v) {
139
182
  return v === null || v === void 0 || Number.isNaN(v);
@@ -320,6 +363,15 @@ function estimateYAxisLeft(options) {
320
363
  const axisConfig = options.yAxis || {};
321
364
  if (axisConfig.type === "log") return 65;
322
365
  try {
366
+ if (options.type === "horizontal-bar") {
367
+ const formatter = axisConfig.formatter;
368
+ const maxW = (options.labels || []).reduce((m, l) => {
369
+ const label = formatter ? String(formatter(l)) : String(l);
370
+ return Math.max(m, estimateTextWidth(label, 12));
371
+ }, 0);
372
+ if (!maxW) return 65;
373
+ return Math.max(40, Math.min(140, Math.round(maxW + 26)));
374
+ }
323
375
  const range = calculateRange(options, /* @__PURE__ */ new Set(), false);
324
376
  const min = axisConfig.min ?? range?.min;
325
377
  const max = axisConfig.max ?? range?.max;
@@ -420,10 +472,12 @@ function getPadding(options, containerWidth = 600) {
420
472
  }
421
473
  const pad = resolveUserPadding(options);
422
474
  const xAxisHidden = options.xAxis?.show === false;
475
+ const yAxisWidth = options.yAxis?.width;
476
+ const yAxisRightWidth = options.yAxisRight?.width;
423
477
  let top = (pad.top ?? 18) + titleBlockHeight(options) + (legendPosition === "top" ? LEGEND_BAND + legendRowsExtra : 0);
424
478
  let bottom = (pad.bottom ?? (xAxisHidden ? 12 : 46)) + (legendPosition === "bottom" ? LEGEND_BAND + legendRowsExtra : 0);
425
- let left = pad.left ?? estimateYAxisLeft(options);
426
- let right = pad.right ?? (hasYAxisRight ? 65 : 24);
479
+ let left = pad.left ?? (typeof yAxisWidth === "number" && yAxisWidth > 0 ? yAxisWidth : estimateYAxisLeft(options));
480
+ let right = pad.right ?? (typeof yAxisRightWidth === "number" && yAxisRightWidth > 0 ? yAxisRightWidth : hasYAxisRight ? 65 : 24);
427
481
  if (legendPosition === "left") left += 80;
428
482
  if (legendPosition === "right") right += 80;
429
483
  if (hasXAxis && hasXAxisTitle) return {
@@ -639,12 +693,15 @@ function calculateRange(options, hiddenSeries, useRightAxis) {
639
693
  const range = max - min || 1;
640
694
  if (min >= 0) return {
641
695
  min: 0,
642
- max: max + range * .1
643
- };
644
- else return {
645
- min: min - range * .1,
646
- max: max + range * .1
696
+ max: max + Math.max(range * .1, max * .05)
647
697
  };
698
+ else {
699
+ const pad = Math.max(range * .1, Math.max(Math.abs(max), Math.abs(min)) * .05);
700
+ return {
701
+ min: min - pad,
702
+ max: max + pad
703
+ };
704
+ }
648
705
  }
649
706
  function applyLogTransform(options) {
650
707
  if (options.yAxis?.type !== "log") return options;
@@ -769,6 +826,20 @@ function truncateLabel(canvasCtx, label, maxWidth) {
769
826
  }
770
827
  return label.slice(0, lo) + "…";
771
828
  }
829
+ /**
830
+ * 抽稀刻度:保留首末两端覆盖,步长取整数倍使数量 ≤ maxCount。
831
+ * nice 算法的 step 候选会让实际刻度数超出请求数(如 ticks:3 得到 5 档),
832
+ * 显式 ticks 视为硬上限,超出的中间档按均匀步长抽稀(round 值不丢)。
833
+ */
834
+ function thinTickValues(values, maxCount) {
835
+ const limit = Math.max(2, maxCount);
836
+ if (values.length <= limit) return values;
837
+ let stride = Math.ceil((values.length - 1) / (limit - 1));
838
+ while (stride < values.length - 1 && (values.length - 1) % stride !== 0) stride += 1;
839
+ const thinned = [];
840
+ for (let i = 0; i < values.length; i += stride) thinned.push(values[i]);
841
+ return thinned;
842
+ }
772
843
  function drawAxisLine(canvasCtx, cfg, theme, x1, y1, x2, y2) {
773
844
  if (cfg?.axisLine?.show === false) return;
774
845
  canvasCtx.save();
@@ -795,7 +866,10 @@ function drawAxisTick(canvasCtx, cfg, theme, x, y, dx, dy) {
795
866
  function renderYAxis(ctx, side = "left", dataRange) {
796
867
  const { ctx: canvasCtx, theme, plotArea, options, width } = ctx;
797
868
  const axisConfig = side === "left" ? options.yAxis || {} : options.yAxisRight || {};
798
- const ticks = axisConfig.ticks || 5;
869
+ const requestedTicks = axisConfig.ticks || 5;
870
+ const maxByHeight = Math.max(2, Math.floor(plotArea.height / 24) + 1);
871
+ const tickCap = axisConfig.ticks != null ? Math.min(requestedTicks, maxByHeight) : maxByHeight;
872
+ const needsThin = axisConfig.ticks != null || maxByHeight < requestedTicks;
799
873
  const isLog = side === "left" && axisConfig.type === "log";
800
874
  let computedMax;
801
875
  let computedMin;
@@ -810,7 +884,8 @@ function renderYAxis(ctx, side = "left", dataRange) {
810
884
  }
811
885
  }
812
886
  const max = axisConfig.max ?? computedMax;
813
- const { min: actualMin, max: actualMax, tickValues } = resolveTickExtendedRange(axisConfig.min ?? computedMin, max, ticks);
887
+ const { min: actualMin, max: actualMax, tickValues: niceTicks } = resolveTickExtendedRange(axisConfig.min ?? computedMin, max, requestedTicks);
888
+ const tickValues = needsThin ? thinTickValues(niceTicks, tickCap) : niceTicks;
814
889
  canvasCtx.save();
815
890
  canvasCtx.strokeStyle = theme.gridColor;
816
891
  canvasCtx.fillStyle = theme.textColorSecondary;
@@ -1041,6 +1116,10 @@ function xToCategoryIndex(options, plotArea, labels, x) {
1041
1116
  return Math.round((x - plotArea.x) / step);
1042
1117
  }
1043
1118
  function renderAnnotations(ctx, yRange) {
1119
+ renderLegacyAnnotation(ctx, yRange);
1120
+ renderNarrativeAnnotations(ctx, yRange);
1121
+ }
1122
+ function renderLegacyAnnotation(ctx, yRange) {
1044
1123
  const { ctx: canvasCtx, theme, plotArea, options } = ctx;
1045
1124
  const annotation = options.annotation;
1046
1125
  if (!annotation) return;
@@ -1103,6 +1182,196 @@ function renderAnnotations(ctx, yRange) {
1103
1182
  canvasCtx.restore();
1104
1183
  });
1105
1184
  }
1185
+ var ANNOTATION_ANCHORS = {
1186
+ top: [
1187
+ 0,
1188
+ -1,
1189
+ "center",
1190
+ "bottom"
1191
+ ],
1192
+ bottom: [
1193
+ 0,
1194
+ 1,
1195
+ "center",
1196
+ "top"
1197
+ ],
1198
+ left: [
1199
+ -1,
1200
+ 0,
1201
+ "end",
1202
+ "middle"
1203
+ ],
1204
+ right: [
1205
+ 1,
1206
+ 0,
1207
+ "start",
1208
+ "middle"
1209
+ ],
1210
+ "top-left": [
1211
+ -1,
1212
+ -1,
1213
+ "end",
1214
+ "bottom"
1215
+ ],
1216
+ "top-right": [
1217
+ 1,
1218
+ -1,
1219
+ "start",
1220
+ "bottom"
1221
+ ],
1222
+ "bottom-left": [
1223
+ -1,
1224
+ 1,
1225
+ "end",
1226
+ "top"
1227
+ ],
1228
+ "bottom-right": [
1229
+ 1,
1230
+ 1,
1231
+ "start",
1232
+ "top"
1233
+ ]
1234
+ };
1235
+ function resolveAnnotationX(ctx, x, xPx) {
1236
+ if (typeof xPx === "number") return xPx;
1237
+ const { plotArea, options } = ctx;
1238
+ const labels = options.labels || [];
1239
+ const idx = typeof x === "number" ? x : labels.indexOf(x);
1240
+ if (idx < 0) return NaN;
1241
+ return categoryToX(options, plotArea, labels, idx);
1242
+ }
1243
+ function resolveAnnotationY(ctx, y, yPx, yRange) {
1244
+ if (typeof yPx === "number") return yPx;
1245
+ if (typeof y !== "number" || !yRange) return NaN;
1246
+ const { plotArea } = ctx;
1247
+ return plotArea.y + plotArea.height - (y - yRange.min) / (yRange.max - yRange.min) * plotArea.height;
1248
+ }
1249
+ function annotationSeriesColor(ctx, ref) {
1250
+ const { theme, options } = ctx;
1251
+ const series = options.series || [];
1252
+ if (typeof ref === "number") return series[ref]?.color || theme.colors[ref] || theme.colors[0] || "#175DFF";
1253
+ if (typeof ref === "string") {
1254
+ const i = series.findIndex((s) => s.name === ref);
1255
+ if (i >= 0) return series[i].color || theme.colors[i] || theme.colors[0] || "#175DFF";
1256
+ }
1257
+ return theme.colors[0] || "#175DFF";
1258
+ }
1259
+ function renderNarrativeAnnotations(ctx, yRange) {
1260
+ const { ctx: canvasCtx, theme, plotArea, options } = ctx;
1261
+ const list = options.annotations || [];
1262
+ if (list.length === 0) return;
1263
+ const secondary = theme.textColorSecondary;
1264
+ list.forEach((a) => {
1265
+ if (!a || !a.type) return;
1266
+ if (a.type === "region") {
1267
+ const x1 = resolveAnnotationX(ctx, a.from);
1268
+ const x2 = resolveAnnotationX(ctx, a.to);
1269
+ if (!Number.isFinite(x1) || !Number.isFinite(x2)) return;
1270
+ const labels = options.labels || [];
1271
+ const band = labels.length > 0 ? plotArea.width / labels.length : 0;
1272
+ const left = Math.min(x1, x2) - band / 2;
1273
+ const width = Math.abs(x2 - x1) + band;
1274
+ canvasCtx.save();
1275
+ canvasCtx.globalAlpha = .06;
1276
+ canvasCtx.fillStyle = a.color || theme.colors[0] || "#175DFF";
1277
+ canvasCtx.fillRect(left, plotArea.y, width, plotArea.height);
1278
+ canvasCtx.restore();
1279
+ if (a.label) {
1280
+ canvasCtx.save();
1281
+ canvasCtx.fillStyle = secondary;
1282
+ canvasCtx.font = "italic 400 11px Inter, sans-serif";
1283
+ canvasCtx.textAlign = "left";
1284
+ canvasCtx.textBaseline = "top";
1285
+ canvasCtx.fillText(a.label, left + 4, plotArea.y + 6);
1286
+ canvasCtx.restore();
1287
+ }
1288
+ return;
1289
+ }
1290
+ const px = resolveAnnotationX(ctx, a.x, a.xPx);
1291
+ const py = resolveAnnotationY(ctx, a.y, a.yPx, yRange);
1292
+ if (!Number.isFinite(px) || !Number.isFinite(py)) return;
1293
+ const color = a.color || annotationSeriesColor(ctx, a.series);
1294
+ canvasCtx.save();
1295
+ if (a.type === "point") {
1296
+ canvasCtx.fillStyle = color;
1297
+ canvasCtx.beginPath();
1298
+ canvasCtx.arc(px, py, 3, 0, Math.PI * 2);
1299
+ canvasCtx.fill();
1300
+ canvasCtx.globalAlpha = .85;
1301
+ canvasCtx.strokeStyle = color;
1302
+ canvasCtx.lineWidth = 1.5;
1303
+ canvasCtx.beginPath();
1304
+ canvasCtx.arc(px, py, 6, 0, Math.PI * 2);
1305
+ canvasCtx.stroke();
1306
+ canvasCtx.restore();
1307
+ return;
1308
+ }
1309
+ if (a.type === "delta") {
1310
+ const up = a.direction !== "down";
1311
+ const deltaColor = a.color || (up ? options.candleUpColor || "#dc2626" : options.candleDownColor || "#16a34a");
1312
+ const text = a.text || a.label || "";
1313
+ canvasCtx.fillStyle = deltaColor;
1314
+ canvasCtx.beginPath();
1315
+ if (up) {
1316
+ canvasCtx.moveTo(px, py - 16);
1317
+ canvasCtx.lineTo(px - 5, py - 9);
1318
+ canvasCtx.lineTo(px + 5, py - 9);
1319
+ } else {
1320
+ canvasCtx.moveTo(px, py + 16);
1321
+ canvasCtx.lineTo(px - 5, py + 9);
1322
+ canvasCtx.lineTo(px + 5, py + 9);
1323
+ }
1324
+ canvasCtx.closePath();
1325
+ canvasCtx.fill();
1326
+ if (text) {
1327
+ canvasCtx.fillStyle = deltaColor;
1328
+ canvasCtx.font = "600 11px Inter, sans-serif";
1329
+ canvasCtx.textAlign = "center";
1330
+ if (up) {
1331
+ canvasCtx.textBaseline = "bottom";
1332
+ canvasCtx.fillText(text, px, py - 20);
1333
+ } else {
1334
+ canvasCtx.textBaseline = "top";
1335
+ canvasCtx.fillText(text, px, py + 20);
1336
+ }
1337
+ }
1338
+ canvasCtx.restore();
1339
+ return;
1340
+ }
1341
+ if (a.type === "callout") {
1342
+ const [dx, dy, align, baseline] = ANNOTATION_ANCHORS[a.anchor] || ANNOTATION_ANCHORS["top-right"];
1343
+ const lx = px + dx * 16 + (a.offsetX || 0);
1344
+ const ly = py + dy * 16 + (a.offsetY || 0);
1345
+ canvasCtx.fillStyle = color;
1346
+ canvasCtx.strokeStyle = color;
1347
+ canvasCtx.lineWidth = 1;
1348
+ canvasCtx.setLineDash([3, 3]);
1349
+ canvasCtx.beginPath();
1350
+ canvasCtx.arc(px, py, 2, 0, Math.PI * 2);
1351
+ canvasCtx.fill();
1352
+ canvasCtx.beginPath();
1353
+ canvasCtx.moveTo(px + dx * 2, py + dy * 2);
1354
+ canvasCtx.lineTo(lx - dx * 2, ly - dy * 2);
1355
+ canvasCtx.stroke();
1356
+ canvasCtx.setLineDash([]);
1357
+ if (a.label) {
1358
+ canvasCtx.fillStyle = a.textColor || secondary;
1359
+ canvasCtx.font = "italic 400 11px Inter, sans-serif";
1360
+ canvasCtx.textAlign = align;
1361
+ canvasCtx.textBaseline = baseline;
1362
+ canvasCtx.fillText(a.label, lx + dx * 2, ly + dy * 2);
1363
+ }
1364
+ canvasCtx.restore();
1365
+ return;
1366
+ }
1367
+ canvasCtx.fillStyle = a.color || secondary;
1368
+ canvasCtx.font = `italic ${a.fontWeight || 400} 11px Inter, sans-serif`;
1369
+ canvasCtx.textAlign = "center";
1370
+ canvasCtx.textBaseline = "bottom";
1371
+ canvasCtx.fillText(a.text || a.label || "", px + (a.offsetX || 0), py + (a.offsetY !== void 0 ? a.offsetY : -6));
1372
+ canvasCtx.restore();
1373
+ });
1374
+ }
1106
1375
  function renderHoverHighlight(ctx, points) {
1107
1376
  if (ctx.hoverIndex < 0) return;
1108
1377
  const { ctx: canvasCtx, theme, plotArea } = ctx;
@@ -1267,6 +1536,12 @@ function collectLegendItems(options, theme, hiddenSeries) {
1267
1536
  color: d.color || theme.colors[i % theme.colors.length],
1268
1537
  hidden: hiddenSeries.has(d.label || "")
1269
1538
  }));
1539
+ if (options.type === "scatter") return (options.scatterData || []).map((d, i) => ({
1540
+ name: d.label || `P${i + 1}`,
1541
+ label: fmt(d.label || `P${i + 1}`),
1542
+ color: d.color || theme.colors[i % theme.colors.length],
1543
+ hidden: hiddenSeries.has(d.label || `P${i + 1}`)
1544
+ }));
1270
1545
  return (options.series || []).map((s, i) => ({
1271
1546
  name: s.name,
1272
1547
  label: fmt(s.name),
@@ -1445,12 +1720,69 @@ function renderToolbox(ctx, hoveredFeature) {
1445
1720
  canvasCtx.restore();
1446
1721
  });
1447
1722
  }
1723
+ /**
1724
+ * items: [{ angle, r, text }](angle 扇区中角,r 引线锚点半径)
1725
+ * cfg: { canvasCtx, centerX, centerY, plotArea, theme } + 可选覆写
1726
+ */
1727
+ function drawCalloutLabels(items, cfg) {
1728
+ const { canvasCtx, centerX, centerY, plotArea, theme, radialLen = 14, stubLen = 12, textGap = 5, minGap = 16, maxShift = 10, font = "12px Inter, sans-serif", textColor = theme.textColor, lineColor = theme.textColorSecondary } = cfg;
1729
+ if (!canvasCtx || items.length === 0) return;
1730
+ const topLimit = plotArea.y + 4;
1731
+ const bottomLimit = plotArea.y + plotArea.height - 4;
1732
+ const layoutSide = (side, sign) => {
1733
+ const sorted = side.map((it) => ({
1734
+ ...it,
1735
+ y: centerY + Math.sin(it.angle) * (it.r + radialLen),
1736
+ visible: true
1737
+ })).sort((a, b) => a.y - b.y);
1738
+ let lastY = -Infinity;
1739
+ sorted.forEach((it) => {
1740
+ let y = it.y;
1741
+ if (y < lastY + minGap) {
1742
+ const shifted = lastY + minGap;
1743
+ if (shifted - it.y <= maxShift && shifted <= bottomLimit) y = shifted;
1744
+ else {
1745
+ it.visible = false;
1746
+ return;
1747
+ }
1748
+ }
1749
+ if (y > bottomLimit || y < topLimit) {
1750
+ it.visible = false;
1751
+ return;
1752
+ }
1753
+ it.y = y;
1754
+ lastY = y;
1755
+ });
1756
+ sorted.forEach((it) => {
1757
+ if (!it.visible) return;
1758
+ const dirX = Math.cos(it.angle);
1759
+ const dirY = Math.sin(it.angle);
1760
+ const p1x = centerX + dirX * (it.r + radialLen);
1761
+ const p1y = centerY + dirY * (it.r + radialLen);
1762
+ const p2x = p1x + sign * stubLen;
1763
+ canvasCtx.save();
1764
+ canvasCtx.strokeStyle = lineColor;
1765
+ canvasCtx.lineWidth = 1;
1766
+ canvasCtx.beginPath();
1767
+ canvasCtx.moveTo(centerX + dirX * it.r, centerY + dirY * it.r);
1768
+ canvasCtx.lineTo(p1x, p1y);
1769
+ canvasCtx.lineTo(p2x, it.y);
1770
+ canvasCtx.stroke();
1771
+ canvasCtx.restore();
1772
+ canvasCtx.save();
1773
+ canvasCtx.fillStyle = textColor;
1774
+ canvasCtx.font = font;
1775
+ canvasCtx.textAlign = sign === 1 ? "left" : "right";
1776
+ canvasCtx.textBaseline = "middle";
1777
+ canvasCtx.fillText(it.text, p2x + sign * textGap, it.y);
1778
+ canvasCtx.restore();
1779
+ });
1780
+ };
1781
+ layoutSide(items.filter((it) => Math.cos(it.angle) >= 0), 1);
1782
+ layoutSide(items.filter((it) => Math.cos(it.angle) < 0), -1);
1783
+ }
1448
1784
  //#endregion
1449
1785
  //#region src/renderer/charts-basic.js
1450
- var PIE_RADIAL_LEN = 14;
1451
- var PIE_STUB_LEN = 12;
1452
- var PIE_TEXT_GAP = 5;
1453
- var PIE_MAX_SHIFT = 10;
1454
1786
  function computePieMaxRadius(pieData, options, plotArea) {
1455
1787
  const total = pieData.reduce((sum, d) => sum + d.value, 0);
1456
1788
  if (total <= 0) return 30;
@@ -2069,70 +2401,21 @@ function renderPieChart(ctx, isDoughnut, isRose = false) {
2069
2401
  if (progress > .9) {
2070
2402
  const percentage = (data.value / total * 100).toFixed(1);
2071
2403
  const text = percentMode ? `${percentage}%` : `${data.label || ""} (${percentage}%)`;
2072
- const r = isRose ? roseRadius : maxRadius;
2073
2404
  labelItems.push({
2074
2405
  angle: midAngle,
2075
- r,
2076
- color,
2077
- text,
2078
- y: centerY + Math.sin(midAngle) * (r + PIE_RADIAL_LEN),
2079
- visible: true
2406
+ r: isRose ? roseRadius : maxRadius,
2407
+ text
2080
2408
  });
2081
2409
  }
2082
2410
  startAngle = endAngle;
2083
2411
  });
2084
- if (labelItems.length > 0 && progress > .9) {
2085
- const minGap = 16;
2086
- const topLimit = plotArea.y + 4;
2087
- const bottomLimit = plotArea.y + plotArea.height - 4;
2088
- const layoutSide = (items, sign) => {
2089
- const sorted = [...items].sort((a, b) => a.y - b.y);
2090
- let lastY = -Infinity;
2091
- sorted.forEach((it) => {
2092
- let y = it.y;
2093
- if (y < lastY + minGap) {
2094
- const shifted = lastY + minGap;
2095
- if (shifted - it.y <= PIE_MAX_SHIFT && shifted <= bottomLimit) y = shifted;
2096
- else {
2097
- it.visible = false;
2098
- return;
2099
- }
2100
- }
2101
- if (y > bottomLimit || y < topLimit) {
2102
- it.visible = false;
2103
- return;
2104
- }
2105
- it.y = y;
2106
- lastY = y;
2107
- });
2108
- sorted.forEach((it) => {
2109
- if (!it.visible) return;
2110
- const dirX = Math.cos(it.angle);
2111
- const dirY = Math.sin(it.angle);
2112
- const p1x = centerX + dirX * (it.r + PIE_RADIAL_LEN);
2113
- const p1y = centerY + dirY * (it.r + PIE_RADIAL_LEN);
2114
- const p2x = p1x + sign * PIE_STUB_LEN;
2115
- canvasCtx.save();
2116
- canvasCtx.strokeStyle = theme.textColorSecondary;
2117
- canvasCtx.lineWidth = 1;
2118
- canvasCtx.beginPath();
2119
- canvasCtx.moveTo(centerX + dirX * it.r, centerY + dirY * it.r);
2120
- canvasCtx.lineTo(p1x, p1y);
2121
- canvasCtx.lineTo(p2x, it.y);
2122
- canvasCtx.stroke();
2123
- canvasCtx.restore();
2124
- canvasCtx.save();
2125
- canvasCtx.fillStyle = theme.textColor;
2126
- canvasCtx.font = "12px Inter, sans-serif";
2127
- canvasCtx.textAlign = sign === 1 ? "left" : "right";
2128
- canvasCtx.textBaseline = "middle";
2129
- canvasCtx.fillText(it.text, p2x + sign * PIE_TEXT_GAP, it.y);
2130
- canvasCtx.restore();
2131
- });
2132
- };
2133
- layoutSide(labelItems.filter((it) => Math.cos(it.angle) >= 0), 1);
2134
- layoutSide(labelItems.filter((it) => Math.cos(it.angle) < 0), -1);
2135
- }
2412
+ if (labelItems.length > 0 && progress > .9) drawCalloutLabels(labelItems, {
2413
+ canvasCtx,
2414
+ centerX,
2415
+ centerY,
2416
+ plotArea,
2417
+ theme
2418
+ });
2136
2419
  if (isDoughnut && options.gauge?.value !== void 0) {
2137
2420
  canvasCtx.save();
2138
2421
  canvasCtx.fillStyle = theme.textColor;
@@ -2175,6 +2458,10 @@ function renderScatterChart(ctx, yRange) {
2175
2458
  const points = [];
2176
2459
  const yFactor = Math.min(ctx.progress * 1.2, 1);
2177
2460
  scatterData.forEach((point, i) => {
2461
+ if (ctx.hiddenSeries.has(point.label || `P${i + 1}`)) {
2462
+ points.push([NaN, NaN]);
2463
+ return;
2464
+ }
2178
2465
  const x = scale.toX(point.x);
2179
2466
  const finalY = scale.toY(point.y);
2180
2467
  const animatedY = plotArea.y + plotArea.height - (plotArea.y + plotArea.height - finalY) * yFactor;
@@ -2327,6 +2614,20 @@ function renderRadarChart(ctx) {
2327
2614
  canvasCtx.stroke();
2328
2615
  }
2329
2616
  canvasCtx.restore();
2617
+ const sharedMax = indicators[0].max;
2618
+ const sharedMin = indicators[0].min || 0;
2619
+ if (sharedMax != null && indicators.every((ind) => (ind.max ?? sharedMax) === sharedMax && (ind.min || 0) === sharedMin)) {
2620
+ canvasCtx.save();
2621
+ canvasCtx.fillStyle = theme.textColorSecondary;
2622
+ canvasCtx.font = "10px Inter, sans-serif";
2623
+ canvasCtx.textAlign = "left";
2624
+ canvasCtx.textBaseline = "middle";
2625
+ for (let level = 1; level <= 5; level++) {
2626
+ const v = sharedMin + (sharedMax - sharedMin) * level / 5;
2627
+ canvasCtx.fillText(Number.isInteger(sharedMax) ? String(Math.round(v)) : v.toFixed(1), centerX + 5, centerY - radius * level / 5);
2628
+ }
2629
+ canvasCtx.restore();
2630
+ }
2330
2631
  canvasCtx.save();
2331
2632
  canvasCtx.fillStyle = theme.textColorSecondary;
2332
2633
  canvasCtx.font = "12px Inter, sans-serif";
@@ -2380,7 +2681,15 @@ function renderRadarChart(ctx) {
2380
2681
  else canvasCtx.lineTo(p[0], p[1]);
2381
2682
  });
2382
2683
  canvasCtx.closePath();
2383
- canvasCtx.fillStyle = color + (hoverIndex >= 0 ? "50" : "30");
2684
+ if (/^#[0-9a-fA-F]{6}$/.test(color)) {
2685
+ const grad = canvasCtx.createLinearGradient(0, centerY - radius, 0, centerY + radius);
2686
+ grad.addColorStop(0, color + (hoverIndex >= 0 ? "59" : "40"));
2687
+ grad.addColorStop(1, color + "08");
2688
+ canvasCtx.fillStyle = grad;
2689
+ } else {
2690
+ canvasCtx.fillStyle = color;
2691
+ canvasCtx.globalAlpha = alpha * .18;
2692
+ }
2384
2693
  canvasCtx.fill();
2385
2694
  canvasCtx.restore();
2386
2695
  }
@@ -2393,15 +2702,17 @@ function renderRadarChart(ctx) {
2393
2702
  });
2394
2703
  canvasCtx.closePath();
2395
2704
  canvasCtx.strokeStyle = color;
2396
- canvasCtx.lineWidth = hoverIndex >= 0 ? 2.5 : 2;
2705
+ canvasCtx.lineWidth = hoverIndex >= 0 ? 2.5 : 1.5;
2397
2706
  canvasCtx.stroke();
2398
2707
  canvasCtx.restore();
2399
2708
  canvasCtx.save();
2400
2709
  canvasCtx.globalAlpha = alpha;
2710
+ const showSymbol = s.showSymbol === true;
2401
2711
  points.forEach((p, i) => {
2402
2712
  const isHover = i === hoverIndex;
2713
+ if (!showSymbol && !isHover) return;
2403
2714
  canvasCtx.beginPath();
2404
- canvasCtx.arc(p[0], p[1], isHover ? 6 : 4, 0, Math.PI * 2);
2715
+ canvasCtx.arc(p[0], p[1], isHover ? 6 : 3, 0, Math.PI * 2);
2405
2716
  canvasCtx.fillStyle = isHover ? theme.backgroundColor : color;
2406
2717
  canvasCtx.fill();
2407
2718
  canvasCtx.strokeStyle = color;
@@ -2436,11 +2747,14 @@ function renderFunnelChart(ctx) {
2436
2747
  const stepHeight = (plotArea.y + plotArea.height - 8 - topY) / funnelData.length;
2437
2748
  const maxValue = Math.max(...funnelData.map((d) => d.value));
2438
2749
  const drawData = options.pyramid === true ? [...funnelData].reverse() : funnelData;
2750
+ const labelAlpha = Math.min(1, Math.max(0, (progress - .72) / .28));
2751
+ const outsideLabels = [];
2439
2752
  drawData.forEach((data, i) => {
2440
2753
  const color = data.color || theme.colors[funnelData.indexOf(data) % theme.colors.length];
2441
2754
  const isHover = i === hoverIndex;
2442
- const currentWidth = data.value / maxValue * maxWidth * progress;
2443
- const nextWidth = (drawData[i + 1]?.value ?? data.value * .5) / maxValue * maxWidth * progress;
2755
+ const easedWidth = 1 - Math.pow(1 - progress, 3);
2756
+ const currentWidth = data.value / maxValue * maxWidth * easedWidth;
2757
+ const nextWidth = (drawData[i + 1]?.value ?? data.value * .5) / maxValue * maxWidth * easedWidth;
2444
2758
  const y = topY + i * stepHeight;
2445
2759
  const yOffset = isHover ? -3 : 0;
2446
2760
  canvasCtx.save();
@@ -2455,48 +2769,67 @@ function renderFunnelChart(ctx) {
2455
2769
  canvasCtx.strokeStyle = theme.backgroundColor;
2456
2770
  canvasCtx.lineWidth = 2;
2457
2771
  canvasCtx.stroke();
2458
- if (progress > .8) {
2459
- const labelY = y + stepHeight / 2 + yOffset;
2460
- const percentage = (data.value / drawData[0].value * 100).toFixed(1);
2461
- const label = `${data.label}`;
2462
- const valueText = `${data.value} (${percentage}%)`;
2772
+ canvasCtx.restore();
2773
+ if (labelAlpha <= 0) return;
2774
+ const labelY = y + stepHeight / 2 + yOffset;
2775
+ const percentage = (data.value / drawData[0].value * 100).toFixed(1);
2776
+ const label = `${data.label}`;
2777
+ const valueText = `${data.value} (${percentage}%)`;
2778
+ canvasCtx.save();
2779
+ canvasCtx.font = "bold 13px Inter, sans-serif";
2780
+ const labelWidth = canvasCtx.measureText(label).width;
2781
+ canvasCtx.font = "11px Inter, sans-serif";
2782
+ const valueWidth = canvasCtx.measureText(valueText).width;
2783
+ const minTextWidth = Math.max(labelWidth, valueWidth) + 28;
2784
+ if ((currentWidth + nextWidth) / 2 >= minTextWidth && stepHeight >= 36) {
2785
+ canvasCtx.globalAlpha = labelAlpha;
2786
+ canvasCtx.fillStyle = theme.backgroundColor;
2463
2787
  canvasCtx.font = "bold 13px Inter, sans-serif";
2464
- const labelWidth = canvasCtx.measureText(label).width;
2788
+ canvasCtx.textAlign = "center";
2789
+ canvasCtx.textBaseline = "middle";
2790
+ canvasCtx.fillText(label, centerX, labelY - 9);
2465
2791
  canvasCtx.font = "11px Inter, sans-serif";
2466
- const valueWidth = canvasCtx.measureText(valueText).width;
2467
- const minTextWidth = Math.max(labelWidth, valueWidth) + 16;
2468
- if ((currentWidth + nextWidth) / 2 >= minTextWidth) {
2469
- canvasCtx.fillStyle = theme.backgroundColor;
2470
- canvasCtx.font = "bold 13px Inter, sans-serif";
2471
- canvasCtx.textAlign = "center";
2472
- canvasCtx.textBaseline = "middle";
2473
- canvasCtx.fillText(label, centerX, labelY - 8);
2474
- canvasCtx.font = "11px Inter, sans-serif";
2475
- canvasCtx.fillText(valueText, centerX, labelY + 8);
2476
- } else {
2477
- const lineEndX = centerX + currentWidth / 2;
2478
- const textX = plotArea.x + plotArea.width - 4;
2479
- canvasCtx.save();
2480
- canvasCtx.strokeStyle = theme.textColorSecondary;
2481
- canvasCtx.lineWidth = 1;
2482
- canvasCtx.setLineDash([3, 3]);
2483
- canvasCtx.beginPath();
2484
- canvasCtx.moveTo(lineEndX, labelY);
2485
- canvasCtx.lineTo(textX - 4, labelY);
2486
- canvasCtx.stroke();
2487
- canvasCtx.setLineDash([]);
2488
- canvasCtx.restore();
2489
- canvasCtx.fillStyle = theme.textColor;
2490
- canvasCtx.font = "bold 12px Inter, sans-serif";
2491
- canvasCtx.textAlign = "right";
2492
- canvasCtx.textBaseline = "bottom";
2493
- canvasCtx.fillText(label, textX, labelY - 1);
2494
- canvasCtx.font = "11px Inter, sans-serif";
2495
- canvasCtx.fillStyle = theme.textColorSecondary;
2496
- canvasCtx.textBaseline = "top";
2497
- canvasCtx.fillText(valueText, textX, labelY + 1);
2498
- }
2499
- }
2792
+ canvasCtx.fillText(valueText, centerX, labelY + 9);
2793
+ } else outsideLabels.push({
2794
+ labelY,
2795
+ lineEndX: centerX + currentWidth / 2,
2796
+ label,
2797
+ valueText,
2798
+ labelTextWidth: Math.max(labelWidth, valueWidth)
2799
+ });
2800
+ canvasCtx.restore();
2801
+ });
2802
+ outsideLabels.sort((a, b) => a.labelY - b.labelY);
2803
+ const textX = plotArea.x + plotArea.width - 4;
2804
+ let lastBottom = -Infinity;
2805
+ outsideLabels.map((o) => {
2806
+ const top = Math.max(o.labelY - 13, lastBottom + 2);
2807
+ lastBottom = top + 36;
2808
+ return {
2809
+ ...o,
2810
+ top
2811
+ };
2812
+ }).forEach((o) => {
2813
+ canvasCtx.save();
2814
+ canvasCtx.globalAlpha = labelAlpha;
2815
+ canvasCtx.strokeStyle = theme.textColorSecondary;
2816
+ canvasCtx.lineWidth = 1;
2817
+ canvasCtx.setLineDash([3, 3]);
2818
+ canvasCtx.beginPath();
2819
+ canvasCtx.moveTo(o.lineEndX, o.labelY);
2820
+ canvasCtx.lineTo(textX - o.labelTextWidth - 10, o.top + 12);
2821
+ canvasCtx.stroke();
2822
+ canvasCtx.setLineDash([]);
2823
+ const nameY = o.top + 12;
2824
+ canvasCtx.fillStyle = theme.textColor;
2825
+ canvasCtx.font = "bold 12px Inter, sans-serif";
2826
+ canvasCtx.textAlign = "right";
2827
+ canvasCtx.textBaseline = "bottom";
2828
+ canvasCtx.fillText(o.label, textX, nameY);
2829
+ canvasCtx.font = "11px Inter, sans-serif";
2830
+ canvasCtx.fillStyle = theme.textColorSecondary;
2831
+ canvasCtx.textBaseline = "top";
2832
+ canvasCtx.fillText(o.valueText, textX, nameY + 2);
2500
2833
  canvasCtx.restore();
2501
2834
  });
2502
2835
  }
@@ -2980,42 +3313,40 @@ function squarifyTreemap(nodes, x, y, w, h, depth, result) {
2980
3313
  });
2981
3314
  return result;
2982
3315
  }
3316
+ function isRectWithinFocus(rect, focus) {
3317
+ return rect.depth >= focus.depth && rect.x >= focus.x - .5 && rect.y >= focus.y - .5 && rect.x + rect.w <= focus.x + focus.w + .5 && rect.y + rect.h <= focus.y + focus.h + .5;
3318
+ }
2983
3319
  function renderTreemapChart(ctx) {
2984
- const { ctx: canvasCtx, theme, plotArea, options, progress, hoverIndex, valueFormatter } = ctx;
3320
+ const { ctx: canvasCtx, theme, plotArea, options, progress, hoverIndex, valueFormatter, hoverAnimProgress = 1 } = ctx;
2985
3321
  const rootData = options.treemapData || [];
2986
3322
  if (rootData.length === 0) return;
2987
3323
  const rects = [];
2988
3324
  squarifyTreemap(rootData, plotArea.x, plotArea.y, plotArea.width, plotArea.height, 0, rects);
2989
3325
  const totalValue = rootData.reduce((s, n) => s + treemapValueOf(n), 0);
2990
3326
  const scale = progress;
2991
- function drawRect(rect, isHover) {
3327
+ const focus = hoverIndex >= 0 && hoverIndex < rects.length ? rects[hoverIndex] : null;
3328
+ const dimAlpha = 1 - .75 * hoverAnimProgress;
3329
+ function drawRect(rect, isFocusRoot) {
2992
3330
  const cx = rect.x + rect.w / 2;
2993
3331
  const cy = rect.y + rect.h / 2;
2994
3332
  const aw = rect.w * scale;
2995
3333
  const ah = rect.h * scale;
2996
3334
  const ax = cx - aw / 2;
2997
3335
  const ay = cy - ah / 2;
2998
- const color = rect.node.color || theme.colors[(rect.colorSeed + rect.depth) % theme.colors.length];
3336
+ const base = rect.node.color || theme.colors[(rect.colorSeed + rect.depth) % theme.colors.length];
3337
+ const color = isFocusRoot ? mixColor(base, .08, "#000000") : base;
2999
3338
  canvasCtx.save();
3000
- canvasCtx.fillStyle = isHover ? color : color + (rect.depth === 0 ? "" : "cc");
3339
+ canvasCtx.globalAlpha = !focus || isRectWithinFocus(rect, focus) ? 1 : dimAlpha;
3340
+ canvasCtx.fillStyle = isFocusRoot ? color : color + (rect.depth === 0 ? "" : "cc");
3001
3341
  roundRect(canvasCtx, ax, ay, Math.max(0, aw), Math.max(0, ah), 3);
3002
3342
  canvasCtx.fill();
3003
- if (!isHover) {
3004
- canvasCtx.strokeStyle = theme.backgroundColor;
3005
- canvasCtx.lineWidth = 2;
3006
- canvasCtx.stroke();
3007
- } else {
3008
- canvasCtx.shadowColor = color;
3009
- canvasCtx.shadowBlur = 10;
3010
- canvasCtx.strokeStyle = theme.textColor;
3011
- canvasCtx.lineWidth = 2.5;
3012
- canvasCtx.stroke();
3013
- canvasCtx.shadowBlur = 0;
3014
- canvasCtx.stroke();
3015
- }
3343
+ canvasCtx.strokeStyle = theme.backgroundColor;
3344
+ canvasCtx.lineWidth = 2;
3345
+ canvasCtx.stroke();
3016
3346
  canvasCtx.restore();
3017
3347
  if (progress > .8 && aw > 50 && ah > 24) {
3018
3348
  canvasCtx.save();
3349
+ canvasCtx.globalAlpha = !focus || isRectWithinFocus(rect, focus) ? 1 : dimAlpha;
3019
3350
  canvasCtx.fillStyle = getContrastText(color);
3020
3351
  if (rect.node.children && rect.node.children.length > 0) {
3021
3352
  canvasCtx.font = "bold 11px Inter, sans-serif";
@@ -3036,10 +3367,7 @@ function renderTreemapChart(ctx) {
3036
3367
  canvasCtx.restore();
3037
3368
  }
3038
3369
  }
3039
- rects.forEach((rect, i) => {
3040
- if (i !== hoverIndex) drawRect(rect, false);
3041
- });
3042
- if (hoverIndex >= 0 && hoverIndex < rects.length) drawRect(rects[hoverIndex], true);
3370
+ rects.forEach((rect) => drawRect(rect, focus === rect));
3043
3371
  }
3044
3372
  function renderSparklineChart(ctx) {
3045
3373
  const { ctx: canvasCtx, theme, plotArea, options, progress, hoverIndex } = ctx;
@@ -3319,12 +3647,26 @@ function computeSunburstDepth(nodes) {
3319
3647
  });
3320
3648
  return max;
3321
3649
  }
3322
- function layoutSunburst(nodes, r0, ringWidth, depth, startAngle, colorOffset, result) {
3323
- const total = nodes.reduce((s, n) => s + n.value, 0);
3650
+ var SUNBURST_HOLE_RATIO = .22;
3651
+ var SUNBURST_LIGHT_STEP = .26;
3652
+ var SUNBURST_LIGHT_MAX = .6;
3653
+ var SUNBURST_DARK_STEP = .14;
3654
+ var SUNBURST_DARK_MAX = .34;
3655
+ /** 父节点可省略 value(由子孙汇总);直接读 n.value 会得到 NaN,
3656
+ * 让后续兄弟分支的角度全部失效、只剩第一个分支可见 */
3657
+ function sunburstValue(node) {
3658
+ if (typeof node.value === "number" && Number.isFinite(node.value)) return node.value;
3659
+ if (node.children && node.children.length > 0) return node.children.reduce((s, c) => s + sunburstValue(c), 0);
3660
+ return 0;
3661
+ }
3662
+ function layoutSunburst(nodes, r0, ringWidth, depth, startAngle, colorOffset, result, sweep = Math.PI * 2, parentIndex = -1) {
3663
+ const total = nodes.reduce((s, n) => s + sunburstValue(n), 0);
3324
3664
  if (total <= 0) return result;
3325
3665
  let a = startAngle;
3326
3666
  nodes.forEach((n, i) => {
3327
- const span = n.value / total * Math.PI * 2;
3667
+ const slot = depth === 0 ? (colorOffset + i) % 8 : colorOffset % 8;
3668
+ const span = sweep * sunburstValue(n) / total;
3669
+ const selfIndex = result.length;
3328
3670
  result.push({
3329
3671
  startAngle: a,
3330
3672
  endAngle: a + span,
@@ -3332,25 +3674,72 @@ function layoutSunburst(nodes, r0, ringWidth, depth, startAngle, colorOffset, re
3332
3674
  r0,
3333
3675
  r1: r0 + ringWidth,
3334
3676
  node: n,
3677
+ value: sunburstValue(n),
3335
3678
  depth,
3336
- colorIndex: (colorOffset + i) % 8
3679
+ colorIndex: slot,
3680
+ parentIndex
3337
3681
  });
3338
- if (n.children && n.children.length > 0) layoutSunburst(n.children, r0 + ringWidth, ringWidth, depth + 1, a, colorOffset + i, result);
3682
+ if (n.children && n.children.length > 0) layoutSunburst(n.children, r0 + ringWidth, ringWidth, depth + 1, a, slot, result, span, selfIndex);
3339
3683
  a += span;
3340
3684
  });
3341
3685
  return result;
3342
3686
  }
3343
- function renderSunburstChart(ctx) {
3344
- const { ctx: canvasCtx, theme, plotArea, options, progress, hoverIndex, valueFormatter } = ctx;
3687
+ function isSunburstDescendant(segments, index, ancestorIndex) {
3688
+ let cursor = index;
3689
+ while (cursor >= 0) {
3690
+ if (cursor === ancestorIndex) return true;
3691
+ cursor = segments[cursor].parentIndex;
3692
+ }
3693
+ return false;
3694
+ }
3695
+ /** 环带颜色:同色系按深度向背景方向混合,外圈大面积不刺眼 */
3696
+ function sunburstDepthColor(base, depth, theme) {
3697
+ if (depth <= 0) return base;
3698
+ const darker = !isLightColor(theme.backgroundColor);
3699
+ return mixColor(base, Math.min(darker ? SUNBURST_DARK_MAX : SUNBURST_LIGHT_MAX, depth * (darker ? SUNBURST_DARK_STEP : SUNBURST_LIGHT_STEP)), darker ? "#000000" : "#ffffff");
3700
+ }
3701
+ function sunburstNodeColor(seg, theme) {
3702
+ const base = seg.node.color || theme.colors[seg.colorIndex % theme.colors.length];
3703
+ if (seg.node.color && seg.depth > 0) return seg.node.color;
3704
+ return sunburstDepthColor(base, seg.depth, theme);
3705
+ }
3706
+ /** 旭日图几何:渲染与悬浮命中共用一份口径(环厚、留白、半径预算) */
3707
+ function computeSunburstGeometry(plotArea, options, theme, valueFormatter) {
3345
3708
  const data = options.sunburstData || [];
3346
- if (data.length === 0) return;
3709
+ const depthCount = computeSunburstDepth(data);
3710
+ if (data.length === 0 || depthCount === 0) return null;
3347
3711
  const centerX = plotArea.x + plotArea.width / 2;
3348
3712
  const centerY = plotArea.y + plotArea.height / 2;
3349
- const maxR = Math.max(40, Math.min(plotArea.width, plotArea.height) / 2 - 10);
3350
- const innerHole = Math.max(0, maxR * .18);
3351
- const depthCount = computeSunburstDepth(data);
3352
- const segments = layoutSunburst(data, innerHole, (maxR - innerHole) / depthCount, 0, -Math.PI / 2, 0, []);
3713
+ const maxR = Math.max(40, Math.min(plotArea.width, plotArea.height) / 2 - 20);
3714
+ const innerHole = Math.max(0, maxR * SUNBURST_HOLE_RATIO);
3715
+ const ringWidth = (maxR - innerHole) / depthCount;
3716
+ const segments = layoutSunburst(data, innerHole, ringWidth, 0, -Math.PI / 2, 0, []);
3717
+ segments.forEach((seg) => {
3718
+ seg.color = sunburstNodeColor(seg, theme);
3719
+ });
3720
+ return {
3721
+ centerX,
3722
+ centerY,
3723
+ maxR,
3724
+ innerHole,
3725
+ ringWidth,
3726
+ depthCount,
3727
+ segments,
3728
+ showValues: options.showValues === true,
3729
+ valueFormatter
3730
+ };
3731
+ }
3732
+ function renderSunburstChart(ctx) {
3733
+ const { ctx: canvasCtx, theme, plotArea, options, progress, hoverIndex, valueFormatter, hoverAnimProgress = 1 } = ctx;
3734
+ const geo = computeSunburstGeometry(plotArea, options, theme, valueFormatter);
3735
+ if (!geo) return;
3736
+ const { centerX, centerY, maxR, ringWidth, depthCount, segments, showValues } = geo;
3353
3737
  const sweep = -Math.PI / 2 + Math.PI * 2 * progress;
3738
+ const grow = .94 + .06 * progress;
3739
+ const focusIndex = hoverIndex >= 0 && hoverIndex < segments.length ? hoverIndex : -1;
3740
+ const dimAlpha = 1 - .75 * hoverAnimProgress;
3741
+ const alphaAt = (i) => focusIndex < 0 || isSunburstDescendant(segments, i, focusIndex) ? 1 : dimAlpha;
3742
+ const labels = [];
3354
3743
  canvasCtx.save();
3355
3744
  canvasCtx.beginPath();
3356
3745
  canvasCtx.moveTo(centerX, centerY);
@@ -3358,45 +3747,58 @@ function renderSunburstChart(ctx) {
3358
3747
  canvasCtx.closePath();
3359
3748
  canvasCtx.clip();
3360
3749
  segments.forEach((seg, i) => {
3361
- const color = seg.node.color || theme.colors[seg.colorIndex % theme.colors.length];
3362
- const isHover = i === hoverIndex;
3750
+ const color = i === focusIndex ? mixColor(seg.color, .08, "#000000") : seg.color;
3363
3751
  canvasCtx.save();
3752
+ canvasCtx.globalAlpha = alphaAt(i);
3364
3753
  canvasCtx.beginPath();
3365
- canvasCtx.arc(centerX, centerY, seg.r1, seg.startAngle, seg.endAngle);
3366
- canvasCtx.arc(centerX, centerY, seg.r0, seg.endAngle, seg.startAngle, true);
3754
+ canvasCtx.arc(centerX, centerY, seg.r1 * grow, seg.startAngle, seg.endAngle);
3755
+ canvasCtx.arc(centerX, centerY, seg.r0 * grow, seg.endAngle, seg.startAngle, true);
3367
3756
  canvasCtx.closePath();
3368
- canvasCtx.fillStyle = isHover ? color : color + (seg.depth === 0 ? "" : "dd");
3757
+ canvasCtx.fillStyle = color;
3369
3758
  canvasCtx.fill();
3370
3759
  canvasCtx.strokeStyle = theme.backgroundColor;
3371
3760
  canvasCtx.lineWidth = 2;
3372
3761
  canvasCtx.stroke();
3373
- if (isHover) {
3374
- canvasCtx.strokeStyle = theme.textColor;
3375
- canvasCtx.lineWidth = 2;
3376
- canvasCtx.stroke();
3377
- }
3378
3762
  canvasCtx.restore();
3763
+ if (progress <= .9) return;
3764
+ const name = seg.node.name || "";
3765
+ if (!name) return;
3379
3766
  const span = seg.endAngle - seg.startAngle;
3380
3767
  const midR = (seg.r0 + seg.r1) / 2;
3381
3768
  const chord = midR * span;
3382
- const name = seg.node.name || "";
3383
- if (progress > .9 && span > .12 && chord > estimateTextWidth(name, 11) + 8 && seg.r1 - seg.r0 > 13) {
3384
- const lx = centerX + Math.cos(seg.midAngle) * midR;
3385
- const ly = centerY + Math.sin(seg.midAngle) * midR;
3386
- canvasCtx.save();
3387
- canvasCtx.fillStyle = getContrastText(color);
3388
- canvasCtx.font = seg.depth === 0 ? "bold 12px Inter, sans-serif" : "11px Inter, sans-serif";
3389
- canvasCtx.textAlign = "center";
3390
- canvasCtx.textBaseline = "middle";
3391
- canvasCtx.fillText(name, lx, ly - (options.showValues ? 6 : 0));
3392
- if (options.showValues && seg.r1 - seg.r0 > 26) {
3393
- canvasCtx.font = "10px Inter, sans-serif";
3394
- canvasCtx.fillText(valueFormatter(seg.node.value), lx, ly + 8);
3395
- }
3396
- canvasCtx.restore();
3397
- }
3769
+ if (ringWidth < 12 || chord < 11) return;
3770
+ const value = showValues && ringWidth >= 26 ? valueFormatter(seg.value) : null;
3771
+ if (Math.max(estimateTextWidth(name, 11), value ? estimateTextWidth(value, 10) : 0) > ringWidth + 20) return;
3772
+ labels.push({
3773
+ x: centerX + Math.cos(seg.midAngle) * midR,
3774
+ y: centerY + Math.sin(seg.midAngle) * midR,
3775
+ midAngle: seg.midAngle,
3776
+ name,
3777
+ value,
3778
+ bold: seg.depth === 0,
3779
+ fill: getContrastText(color),
3780
+ alpha: alphaAt(i)
3781
+ });
3398
3782
  });
3399
3783
  canvasCtx.restore();
3784
+ labels.forEach((l) => {
3785
+ canvasCtx.save();
3786
+ canvasCtx.globalAlpha = l.alpha;
3787
+ canvasCtx.fillStyle = l.fill;
3788
+ canvasCtx.font = l.bold ? "bold 11px Inter, sans-serif" : "11px Inter, sans-serif";
3789
+ canvasCtx.textAlign = "center";
3790
+ canvasCtx.textBaseline = "middle";
3791
+ canvasCtx.translate(l.x, l.y);
3792
+ let rot = l.midAngle;
3793
+ if (rot > Math.PI / 2 || rot < -Math.PI / 2) rot += Math.PI;
3794
+ canvasCtx.rotate(rot);
3795
+ canvasCtx.fillText(l.name, 0, l.value ? -7 : 0);
3796
+ if (l.value) {
3797
+ canvasCtx.font = "10px Inter, sans-serif";
3798
+ canvasCtx.fillText(l.value, 0, 7);
3799
+ }
3800
+ canvasCtx.restore();
3801
+ });
3400
3802
  }
3401
3803
  function renderMixedChart(ctx, leftRange, rightRange) {
3402
3804
  const { ctx: canvasCtx, theme, plotArea, options, progress, hoverIndex, hiddenSeries } = ctx;
@@ -3966,6 +4368,13 @@ function createSvgRecorder(real) {
3966
4368
  }
3967
4369
  //#endregion
3968
4370
  //#region src/renderer/index.js
4371
+ function runLayers(renderCtx, at) {
4372
+ const layers = renderCtx.options.layers;
4373
+ if (!Array.isArray(layers)) return;
4374
+ layers.forEach((layer) => {
4375
+ if (layer && typeof layer.draw === "function" && (layer.at || "front") === at) layer.draw(renderCtx.ctx, renderCtx);
4376
+ });
4377
+ }
3969
4378
  function renderChart(canvas, params) {
3970
4379
  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;
3971
4380
  const options = applyLogTransform(rawOptions);
@@ -4003,6 +4412,7 @@ function renderChart(canvas, params) {
4003
4412
  focusSeries
4004
4413
  };
4005
4414
  renderTitle(renderCtx, options.subtitle ? 12 : 15);
4415
+ runLayers(renderCtx, "back");
4006
4416
  let points = [];
4007
4417
  let leftRange;
4008
4418
  switch (options.type) {
@@ -4146,7 +4556,8 @@ function renderChart(canvas, params) {
4146
4556
  break;
4147
4557
  }
4148
4558
  }
4149
- if (leftRange && options.annotation) renderAnnotations(renderCtx, leftRange);
4559
+ runLayers(renderCtx, "after-series");
4560
+ if (leftRange && (options.annotation || options.annotations && options.annotations.length > 0)) renderAnnotations(renderCtx, leftRange);
4150
4561
  if ((options.type === "line" || options.type === "area") && points.length > 0) renderHoverHighlight(renderCtx, points);
4151
4562
  if (points.length > 0) renderCrosshair(renderCtx, points);
4152
4563
  if (zoomRange) renderDataZoomSlider(renderCtx, zoomRange);
@@ -4164,6 +4575,7 @@ function renderChart(canvas, params) {
4164
4575
  ctx.strokeRect(x, y, w, h);
4165
4576
  ctx.restore();
4166
4577
  }
4578
+ runLayers(renderCtx, "front");
4167
4579
  if (options.type !== "sparkline") renderLegend(renderCtx);
4168
4580
  if (options.type !== "sparkline") renderToolbox(renderCtx, hoveredToolbox);
4169
4581
  ctx.restore();
@@ -4191,6 +4603,376 @@ function broadcastConnect(group, source, apply) {
4191
4603
  if (target !== source) apply(target);
4192
4604
  });
4193
4605
  }
4606
+ var chartOptionsSchema = {
4607
+ $schema: "http://json-schema.org/draft-07/schema#",
4608
+ title: "EvChart Options",
4609
+ type: "object",
4610
+ required: ["type"],
4611
+ properties: {
4612
+ type: {
4613
+ type: "string",
4614
+ enum: [
4615
+ "line",
4616
+ "area",
4617
+ "bar",
4618
+ "stacked-bar",
4619
+ "horizontal-bar",
4620
+ "pie",
4621
+ "doughnut",
4622
+ "rose",
4623
+ "radar",
4624
+ "scatter",
4625
+ "funnel",
4626
+ "gauge",
4627
+ "heatmap",
4628
+ "candle",
4629
+ "bin",
4630
+ "bullet",
4631
+ "treemap",
4632
+ "sparkline",
4633
+ "waterfall",
4634
+ "boxplot",
4635
+ "sunburst",
4636
+ "mixed"
4637
+ ]
4638
+ },
4639
+ title: { type: "string" },
4640
+ subtitle: { type: "string" },
4641
+ labels: { type: "array" },
4642
+ series: {
4643
+ type: "array",
4644
+ items: {
4645
+ type: "object",
4646
+ required: ["name", "data"],
4647
+ properties: {
4648
+ name: { type: "string" },
4649
+ data: { type: "array" },
4650
+ color: { type: "string" },
4651
+ showSymbol: { type: "boolean" },
4652
+ lineWidth: { type: "number" },
4653
+ smooth: { type: "boolean" },
4654
+ yAxis: {
4655
+ type: "string",
4656
+ enum: ["left", "right"]
4657
+ },
4658
+ type: { type: "string" },
4659
+ chartType: {
4660
+ type: "string",
4661
+ enum: ["bar", "line"]
4662
+ },
4663
+ area: { type: "boolean" }
4664
+ }
4665
+ }
4666
+ },
4667
+ legend: {
4668
+ type: "object",
4669
+ properties: {
4670
+ show: { type: "boolean" },
4671
+ position: {
4672
+ type: "string",
4673
+ enum: ["top", "bottom"]
4674
+ },
4675
+ interactive: { type: "boolean" },
4676
+ hoverEmphasis: { type: "boolean" }
4677
+ }
4678
+ },
4679
+ tooltip: {
4680
+ type: "object",
4681
+ properties: {
4682
+ show: { type: "boolean" },
4683
+ trigger: {
4684
+ type: "string",
4685
+ enum: ["hover", "click"]
4686
+ },
4687
+ showAllSeries: { type: "boolean" },
4688
+ formatter: { type: "function" }
4689
+ }
4690
+ },
4691
+ animation: {
4692
+ type: "object",
4693
+ properties: {
4694
+ enabled: { type: "boolean" },
4695
+ duration: { type: "number" },
4696
+ easing: { type: "string" }
4697
+ }
4698
+ },
4699
+ xAxis: {
4700
+ type: "object",
4701
+ properties: {
4702
+ show: { type: "boolean" },
4703
+ min: { type: "number" },
4704
+ max: { type: "number" },
4705
+ ticks: { type: "integer" },
4706
+ grid: {
4707
+ type: "object",
4708
+ properties: {
4709
+ show: { type: "boolean" },
4710
+ style: {
4711
+ type: "string",
4712
+ enum: ["solid", "dashed"]
4713
+ }
4714
+ }
4715
+ }
4716
+ }
4717
+ },
4718
+ yAxis: {
4719
+ type: "object",
4720
+ properties: {
4721
+ show: { type: "boolean" },
4722
+ min: { type: "number" },
4723
+ max: { type: "number" },
4724
+ ticks: { type: "integer" },
4725
+ width: { type: "number" },
4726
+ grid: {
4727
+ type: "object",
4728
+ properties: {
4729
+ show: { type: "boolean" },
4730
+ style: {
4731
+ type: "string",
4732
+ enum: ["solid", "dashed"]
4733
+ }
4734
+ }
4735
+ }
4736
+ }
4737
+ },
4738
+ yAxisRight: { $ref: "#/properties/yAxis" },
4739
+ padding: { oneOf: [{ type: "number" }, {
4740
+ type: "object",
4741
+ properties: {
4742
+ top: { type: "number" },
4743
+ right: { type: "number" },
4744
+ bottom: { type: "number" },
4745
+ left: { type: "number" }
4746
+ }
4747
+ }] },
4748
+ dataZoom: {
4749
+ type: "object",
4750
+ properties: {
4751
+ enabled: { type: "boolean" },
4752
+ start: { type: "number" },
4753
+ end: { type: "number" },
4754
+ position: {
4755
+ type: "string",
4756
+ enum: ["bottom", "top"]
4757
+ },
4758
+ height: { type: "number" },
4759
+ mouseWheel: { type: "boolean" }
4760
+ }
4761
+ },
4762
+ toolbox: {
4763
+ type: "object",
4764
+ properties: {
4765
+ show: { type: "boolean" },
4766
+ filename: { type: "string" }
4767
+ }
4768
+ },
4769
+ theme: { type: "object" },
4770
+ layers: {
4771
+ type: "array",
4772
+ items: {
4773
+ type: "object",
4774
+ properties: {
4775
+ at: {
4776
+ type: "string",
4777
+ enum: [
4778
+ "back",
4779
+ "after-series",
4780
+ "front"
4781
+ ]
4782
+ },
4783
+ draw: { type: "function" }
4784
+ }
4785
+ }
4786
+ },
4787
+ emphasis: {
4788
+ type: "object",
4789
+ properties: {
4790
+ series: { oneOf: [{ type: "string" }, { type: "integer" }] },
4791
+ dimOthers: { type: "boolean" }
4792
+ }
4793
+ },
4794
+ annotations: {
4795
+ type: "array",
4796
+ items: {
4797
+ type: "object",
4798
+ oneOf: [{
4799
+ required: [
4800
+ "type",
4801
+ "x",
4802
+ "y"
4803
+ ],
4804
+ properties: { type: { enum: [
4805
+ "text",
4806
+ "callout",
4807
+ "delta",
4808
+ "point"
4809
+ ] } }
4810
+ }, {
4811
+ required: [
4812
+ "type",
4813
+ "from",
4814
+ "to"
4815
+ ],
4816
+ properties: { type: { enum: ["region"] } }
4817
+ }],
4818
+ properties: {
4819
+ type: {
4820
+ type: "string",
4821
+ enum: [
4822
+ "text",
4823
+ "callout",
4824
+ "region",
4825
+ "delta",
4826
+ "point"
4827
+ ]
4828
+ },
4829
+ x: {},
4830
+ y: { type: "number" },
4831
+ from: {},
4832
+ to: {},
4833
+ at: {},
4834
+ label: { type: "string" },
4835
+ text: { type: "string" },
4836
+ anchor: {
4837
+ type: "string",
4838
+ enum: [
4839
+ "top",
4840
+ "bottom",
4841
+ "left",
4842
+ "right",
4843
+ "top-left",
4844
+ "top-right",
4845
+ "bottom-left",
4846
+ "bottom-right"
4847
+ ]
4848
+ },
4849
+ color: { type: "string" },
4850
+ offsetX: { type: "number" },
4851
+ offsetY: { type: "number" },
4852
+ xPx: { type: "number" },
4853
+ yPx: { type: "number" }
4854
+ }
4855
+ }
4856
+ },
4857
+ annotation: { type: "object" },
4858
+ markLine: { type: "array" },
4859
+ markArea: { type: "array" },
4860
+ scenes: {
4861
+ type: "object",
4862
+ properties: {
4863
+ autoplay: { type: "boolean" },
4864
+ loop: { type: "boolean" },
4865
+ items: {
4866
+ type: "array",
4867
+ items: {
4868
+ type: "object",
4869
+ required: ["patch"],
4870
+ properties: {
4871
+ patch: { type: "object" },
4872
+ duration: { type: "number" },
4873
+ hold: { type: "number" }
4874
+ }
4875
+ }
4876
+ }
4877
+ }
4878
+ },
4879
+ pieData: { type: "array" },
4880
+ radarSeries: { type: "array" },
4881
+ radarIndicators: { type: "array" },
4882
+ scatterData: { type: "array" },
4883
+ funnelData: { type: "array" },
4884
+ gauge: { oneOf: [{ type: "number" }, { type: "object" }] },
4885
+ heatmapData: { type: "array" },
4886
+ candleData: { type: "array" },
4887
+ bulletData: { type: "array" },
4888
+ boxData: { type: "array" },
4889
+ loading: { type: "boolean" },
4890
+ emptyText: { type: "string" },
4891
+ ariaLabel: { type: "string" },
4892
+ i18n: { type: "object" },
4893
+ connectGroup: { type: "string" },
4894
+ brush: {
4895
+ type: "object",
4896
+ properties: { enabled: { type: "boolean" } }
4897
+ },
4898
+ logAxis: { type: "boolean" },
4899
+ valueFormatter: { type: "function" },
4900
+ timeAxis: { type: "boolean" }
4901
+ }
4902
+ };
4903
+ var TYPE_CHECKS = {
4904
+ string: (v) => typeof v === "string",
4905
+ number: (v) => typeof v === "number" && Number.isFinite(v),
4906
+ integer: (v) => typeof v === "number" && Number.isInteger(v),
4907
+ boolean: (v) => typeof v === "boolean",
4908
+ object: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
4909
+ array: (v) => Array.isArray(v),
4910
+ function: (v) => typeof v === "function"
4911
+ };
4912
+ function typeMatches(node, value) {
4913
+ if (Array.isArray(node.type)) return node.type.some((t) => TYPE_CHECKS[t]?.(value));
4914
+ const check = TYPE_CHECKS[node.type];
4915
+ return check ? check(value) : true;
4916
+ }
4917
+ function validateNode(node, value, path, warnings) {
4918
+ if (node == null || value === void 0 || value === null) return;
4919
+ if (node.oneOf) {
4920
+ if (!node.oneOf.some((branch) => {
4921
+ const before = warnings.length;
4922
+ validateNode(branch, value, path, warnings);
4923
+ const ok = warnings.length === before;
4924
+ warnings.length = before;
4925
+ return ok;
4926
+ })) {
4927
+ warnings.push({
4928
+ path,
4929
+ message: "类型不匹配(应为 oneOf 之一)"
4930
+ });
4931
+ return;
4932
+ }
4933
+ }
4934
+ if (node.type && !typeMatches(node, value)) {
4935
+ warnings.push({
4936
+ path,
4937
+ message: `应为 ${node.type},实际为 ${Array.isArray(value) ? "array" : typeof value}`
4938
+ });
4939
+ return;
4940
+ }
4941
+ if (node.enum && !node.enum.includes(value)) {
4942
+ warnings.push({
4943
+ path,
4944
+ message: `应为 ${node.enum.join(" / ")} 之一,实际为 ${String(value)}`
4945
+ });
4946
+ return;
4947
+ }
4948
+ if (node.type === "object") {
4949
+ (node.required || []).forEach((key) => {
4950
+ if (value[key] === void 0) warnings.push({
4951
+ path: `${path}.${key}`,
4952
+ message: "缺少必填字段"
4953
+ });
4954
+ });
4955
+ if (node.properties) Object.entries(node.properties).forEach(([key, sub]) => {
4956
+ if (value[key] !== void 0) validateNode(sub, value[key], `${path}.${key}`, warnings);
4957
+ });
4958
+ }
4959
+ if (node.type === "array" && node.items && Array.isArray(value)) value.forEach((item, i) => validateNode(node.items, item, `${path}[${i}]`, warnings));
4960
+ }
4961
+ function validateOptions(options) {
4962
+ const warnings = [];
4963
+ if (options === null || typeof options !== "object" || Array.isArray(options)) return {
4964
+ ok: false,
4965
+ warnings: [{
4966
+ path: "options",
4967
+ message: "应为对象"
4968
+ }]
4969
+ };
4970
+ validateNode(chartOptionsSchema, options, "options", warnings);
4971
+ return {
4972
+ ok: warnings.length === 0,
4973
+ warnings
4974
+ };
4975
+ }
4194
4976
  //#endregion
4195
4977
  //#region \0plugin-vue:export-helper
4196
4978
  var _plugin_vue_export_helper_default = (sfc, props) => {
@@ -4202,23 +4984,27 @@ var _plugin_vue_export_helper_default = (sfc, props) => {
4202
4984
  //#region src/chart.vue
4203
4985
  var _hoisted_1 = ["aria-label"];
4204
4986
  var _hoisted_2 = {
4987
+ key: 1,
4988
+ class: "ev-chart__overlay"
4989
+ };
4990
+ var _hoisted_3 = {
4205
4991
  key: 0,
4206
4992
  class: "ev-chart__empty"
4207
4993
  };
4208
- var _hoisted_3 = { class: "ev-chart__empty-icon-wrap" };
4209
- var _hoisted_4 = { class: "ev-chart__empty-text" };
4210
- var _hoisted_5 = {
4994
+ var _hoisted_4 = { class: "ev-chart__empty-icon-wrap" };
4995
+ var _hoisted_5 = { class: "ev-chart__empty-text" };
4996
+ var _hoisted_6 = {
4211
4997
  key: 0,
4212
4998
  class: "ev-chart__loading"
4213
4999
  };
4214
- var _hoisted_6 = { class: "ev-chart__loading-bars" };
4215
- var _hoisted_7 = {
5000
+ var _hoisted_7 = { class: "ev-chart__loading-bars" };
5001
+ var _hoisted_8 = {
4216
5002
  key: 0,
4217
5003
  class: "ev-chart__error"
4218
5004
  };
4219
- var _hoisted_8 = { class: "ev-chart__error-text" };
4220
- var _hoisted_9 = ["innerHTML"];
4221
- var _hoisted_10 = {
5005
+ var _hoisted_9 = { class: "ev-chart__error-text" };
5006
+ var _hoisted_10 = ["innerHTML"];
5007
+ var _hoisted_11 = {
4222
5008
  class: "ev-chart__sr-only",
4223
5009
  role: "status",
4224
5010
  "aria-live": "polite"
@@ -4256,7 +5042,8 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
4256
5042
  "animation-end",
4257
5043
  "data-update",
4258
5044
  "brush-select",
4259
- "zoom"
5045
+ "zoom",
5046
+ "scene-change"
4260
5047
  ],
4261
5048
  setup(__props, { expose: __expose, emit: __emit }) {
4262
5049
  const DataLine = () => h("svg", {
@@ -4326,6 +5113,12 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
4326
5113
  let hoverAnimFrameId = null;
4327
5114
  const hiddenSeries = ref(/* @__PURE__ */ new Set());
4328
5115
  const focusSeries = ref(null);
5116
+ const emphasisSeriesName = computed(() => {
5117
+ const em = props.options.emphasis;
5118
+ if (!em || em.series === void 0 || em.dimOthers === false) return null;
5119
+ if (typeof em.series === "number") return props.options.series?.[em.series]?.name ?? null;
5120
+ return em.series || null;
5121
+ });
4329
5122
  const hoveredToolbox = ref(null);
4330
5123
  let mouseX = -1;
4331
5124
  let mouseY = -1;
@@ -4355,33 +5148,102 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
4355
5148
  const brushRect = ref(null);
4356
5149
  let suppressClick = false;
4357
5150
  let brushDragged = false;
5151
+ const specVersion = ref(0);
5152
+ const overlayInfo = ref({
5153
+ plotArea: {
5154
+ x: 0,
5155
+ y: 0,
5156
+ width: 0,
5157
+ height: 0
5158
+ },
5159
+ theme: getTheme(false, void 0),
5160
+ options: null
5161
+ });
4358
5162
  const effectiveOptions = computed(() => {
5163
+ specVersion.value;
4359
5164
  const opt = props.options;
4360
- if (!getDataZoomConfig(opt)) return opt;
5165
+ const scene = opt.scenes?.items?.[sceneIndex.value];
5166
+ const base = scene?.patch ? {
5167
+ ...opt,
5168
+ ...scene.patch
5169
+ } : opt;
5170
+ if (!getDataZoomConfig(base)) return base;
4361
5171
  const range = zoomRange.value;
4362
- if (range.start <= 0 && range.end >= 100) return opt;
4363
- const sliced = { ...opt };
4364
- const n = (opt.labels || []).length;
5172
+ if (range.start <= 0 && range.end >= 100) return base;
5173
+ const sliced = { ...base };
5174
+ const n = (base.labels || []).length;
4365
5175
  if (n > 0) {
4366
5176
  const { startIdx, endIdx } = zoomToSlice(range, n);
4367
- sliced.labels = (opt.labels || []).slice(startIdx, endIdx);
4368
- if (opt.series) sliced.series = opt.series.map((s) => ({
5177
+ sliced.labels = (base.labels || []).slice(startIdx, endIdx);
5178
+ if (base.series) sliced.series = base.series.map((s) => ({
4369
5179
  ...s,
4370
5180
  data: s.data.slice(startIdx, endIdx)
4371
5181
  }));
4372
- if (opt.candleData) sliced.candleData = opt.candleData.slice(startIdx, endIdx);
5182
+ if (base.candleData) sliced.candleData = base.candleData.slice(startIdx, endIdx);
4373
5183
  sliced.__sliceStart = startIdx;
4374
- } else if (opt.scatterData && opt.scatterData.length > 0) {
4375
- const xs = opt.scatterData.map((d) => d.x);
5184
+ } else if (base.scatterData && base.scatterData.length > 0) {
5185
+ const xs = base.scatterData.map((d) => d.x);
4376
5186
  const xMin = Math.min(...xs);
4377
5187
  const span = Math.max(...xs) - xMin || 1;
4378
5188
  const lo = xMin + range.start / 100 * span;
4379
5189
  const hi = xMin + range.end / 100 * span;
4380
- const filtered = opt.scatterData.filter((d) => d.x >= lo && d.x <= hi);
4381
- sliced.scatterData = filtered.length > 0 ? filtered : opt.scatterData;
5190
+ const filtered = base.scatterData.filter((d) => d.x >= lo && d.x <= hi);
5191
+ sliced.scatterData = filtered.length > 0 ? filtered : base.scatterData;
4382
5192
  }
4383
5193
  return sliced;
4384
5194
  });
5195
+ const sceneIndex = ref(0);
5196
+ let sceneTimer = null;
5197
+ let sceneAnimOverride = null;
5198
+ function stopSceneTimer() {
5199
+ if (sceneTimer !== null) {
5200
+ clearTimeout(sceneTimer);
5201
+ sceneTimer = null;
5202
+ }
5203
+ }
5204
+ function applyScene(i, opts = {}) {
5205
+ const items = props.options.scenes?.items || [];
5206
+ if (items.length === 0) return;
5207
+ const next = Math.max(0, Math.min(i, items.length - 1));
5208
+ if (next === sceneIndex.value && !opts.force) return;
5209
+ sceneIndex.value = next;
5210
+ const item = items[next];
5211
+ sceneAnimOverride = item?.duration ? {
5212
+ ...props.options.animation || {},
5213
+ duration: item.duration
5214
+ } : null;
5215
+ emit("scene-change", {
5216
+ index: next,
5217
+ total: items.length
5218
+ });
5219
+ render(true, sceneAnimOverride);
5220
+ scheduleSceneAdvance();
5221
+ }
5222
+ function scheduleSceneAdvance() {
5223
+ stopSceneTimer();
5224
+ const scenes = props.options.scenes;
5225
+ if (!scenes?.autoplay) return;
5226
+ const item = scenes.items?.[sceneIndex.value];
5227
+ if (!item) return;
5228
+ const wait = Math.max(0, (item.duration ?? 1200) + (item.hold ?? 0));
5229
+ sceneTimer = setTimeout(() => {
5230
+ const total = props.options.scenes?.items?.length ?? 0;
5231
+ if (total === 0) return;
5232
+ if (sceneIndex.value >= total - 1) {
5233
+ if (props.options.scenes?.loop) applyScene(0, { force: true });
5234
+ } else applyScene(sceneIndex.value + 1);
5235
+ }, wait);
5236
+ }
5237
+ function syncScenes() {
5238
+ const total = props.options.scenes?.items?.length ?? 0;
5239
+ if (total === 0) {
5240
+ stopSceneTimer();
5241
+ if (sceneIndex.value !== 0) sceneIndex.value = 0;
5242
+ return;
5243
+ }
5244
+ if (sceneIndex.value >= total) sceneIndex.value = 0;
5245
+ scheduleSceneAdvance();
5246
+ }
4385
5247
  function sliceStartOffset() {
4386
5248
  return effectiveOptions.value.__sliceStart || 0;
4387
5249
  }
@@ -4427,17 +5289,24 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
4427
5289
  const spaceBelow = containerRect.height - relY - gap;
4428
5290
  const spaceAbove = relY - gap - legendBottom;
4429
5291
  let top;
4430
- if (spaceBelow >= th) top = relY + gap;
4431
- else if (spaceAbove >= th) top = relY - gap - th;
4432
- else top = spaceBelow >= spaceAbove ? containerRect.height - th - margin : legendBottom;
5292
+ if (th + 4 <= containerRect.height) {
5293
+ if (spaceBelow >= th) top = relY + gap;
5294
+ else if (spaceAbove >= th) top = relY - gap - th;
5295
+ else top = spaceBelow >= spaceAbove ? containerRect.height - th - margin : legendBottom;
5296
+ top = Math.max(legendBottom, Math.min(top, containerRect.height - th - margin));
5297
+ } else {
5298
+ const pointViewportY = containerRect.top + relY;
5299
+ const belowInViewport = pointViewportY + gap + th <= window.innerHeight - 8;
5300
+ const aboveInViewport = pointViewportY - gap - th >= 8;
5301
+ top = belowInViewport || !aboveInViewport ? relY + gap : relY - gap - th;
5302
+ }
4433
5303
  left = Math.max(margin, Math.min(left, containerRect.width - tw - margin));
4434
- top = Math.max(legendBottom, Math.min(top, containerRect.height - th - margin));
4435
5304
  tooltipPlacement.value = "top";
4436
5305
  tooltipX.value = left;
4437
5306
  tooltipY.value = top;
4438
5307
  }
4439
5308
  const canvasStyle = computed(() => ({ cursor: hoverIndex >= 0 ? "pointer" : "default" }));
4440
- function render(animate = true) {
5309
+ function render(animate = true, animOverride = null) {
4441
5310
  const canvas = canvasRef.value;
4442
5311
  if (!canvas) return;
4443
5312
  const container = containerRef.value;
@@ -4480,8 +5349,20 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
4480
5349
  lastHeight = height;
4481
5350
  canvas.width = width * dpr.value;
4482
5351
  canvas.height = height * dpr.value;
4483
- const animEnabled = animate && props.options.animation?.enabled !== false;
4484
- animationState = createAnimation(props.options.animation);
5352
+ const overlayPadding = getPadding(effectiveOptions.value, width);
5353
+ overlayInfo.value = {
5354
+ plotArea: {
5355
+ x: overlayPadding.left,
5356
+ y: overlayPadding.top,
5357
+ width: width - overlayPadding.left - overlayPadding.right,
5358
+ height: height - overlayPadding.top - overlayPadding.bottom
5359
+ },
5360
+ theme: getTheme(document.documentElement.classList.contains("dark"), effectiveOptions.value.theme),
5361
+ options: props.options
5362
+ };
5363
+ const animCfg = animOverride || props.options.animation;
5364
+ const animEnabled = animate && animCfg?.enabled !== false;
5365
+ animationState = createAnimation(animCfg);
4485
5366
  animationState.isAnimating = animEnabled;
4486
5367
  if (animEnabled) {
4487
5368
  isEnterAnimating = true;
@@ -4633,9 +5514,24 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
4633
5514
  hoverAnimFrameId = null;
4634
5515
  }
4635
5516
  }
4636
- function startHoverAnimation(dir) {
5517
+ function usesHoverAnimation() {
4637
5518
  const t = props.options.type;
4638
- if (t !== "pie" && t !== "doughnut" && t !== "rose") return;
5519
+ return t === "pie" || t === "doughnut" || t === "rose" || t === "sunburst" || t === "treemap";
5520
+ }
5521
+ function clearHover() {
5522
+ if (hoverIndex === -1) return false;
5523
+ hoverIndex = -1;
5524
+ const t = props.options.type;
5525
+ if (t === "pie" || t === "doughnut" || t === "rose") startHoverAnimation(-1);
5526
+ else {
5527
+ stopHoverAnimation();
5528
+ hoverAnimProgress = 0;
5529
+ redraw();
5530
+ }
5531
+ return true;
5532
+ }
5533
+ function startHoverAnimation(dir) {
5534
+ if (!usesHoverAnimation()) return;
4639
5535
  if (props.options.animation?.enabled === false) {
4640
5536
  hoverAnimProgress = dir > 0 ? 1 : 0;
4641
5537
  redraw();
@@ -4676,7 +5572,8 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
4676
5572
  "doughnut",
4677
5573
  "rose",
4678
5574
  "radar",
4679
- "funnel"
5575
+ "funnel",
5576
+ "scatter"
4680
5577
  ];
4681
5578
  function checkLegendHit(x, y) {
4682
5579
  const canvas = canvasRef.value;
@@ -4869,20 +5766,14 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
4869
5766
  };
4870
5767
  }
4871
5768
  if (options.type === "sunburst") {
4872
- const data = options.sunburstData || [];
4873
- if (data.length === 0) return null;
4874
- const centerX = plotArea.x + plotArea.width / 2;
4875
- const centerY = plotArea.y + plotArea.height / 2;
4876
- const maxR = Math.max(40, Math.min(plotArea.width, plotArea.height) / 2 - 10);
4877
- const innerHole = Math.max(0, maxR * .18);
4878
- const depthCount = computeSunburstDepth(data);
4879
- const ringWidth = (maxR - innerHole) / depthCount;
5769
+ const geo = computeSunburstGeometry(plotArea, options, theme, formatTooltipValue);
5770
+ if (!geo) return null;
5771
+ const { centerX, centerY, segments } = geo;
4880
5772
  const dx = canvasX - centerX;
4881
5773
  const dy = canvasY - centerY;
4882
5774
  const distance = Math.sqrt(dx * dx + dy * dy);
4883
5775
  let hoverAngle = Math.atan2(dy, dx);
4884
5776
  if (hoverAngle < -Math.PI / 2) hoverAngle += Math.PI * 2;
4885
- const segments = layoutSunburst(data, innerHole, ringWidth, 0, -Math.PI / 2, 0, []);
4886
5777
  for (let i = 0; i < segments.length; i++) {
4887
5778
  const seg = segments[i];
4888
5779
  if (distance >= seg.r0 && distance <= seg.r1 && hoverAngle >= seg.startAngle && hoverAngle < seg.endAngle) return {
@@ -4890,8 +5781,8 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
4890
5781
  params: {
4891
5782
  seriesName: seg.node.name,
4892
5783
  name: seg.node.name,
4893
- value: seg.node.value,
4894
- color: seg.node.color || theme.colors[seg.colorIndex % theme.colors.length],
5784
+ value: sunburstValue(seg.node),
5785
+ color: seg.color,
4895
5786
  dataIndex: i,
4896
5787
  seriesIndex: 0
4897
5788
  }
@@ -5502,18 +6393,15 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5502
6393
  redraw();
5503
6394
  }
5504
6395
  const legendName = checkLegendHit(e.clientX, e.clientY);
5505
- const nextFocus = props.options.legend?.hoverEmphasis !== false && legendName ? legendName : null;
5506
- if (nextFocus !== focusSeries.value) {
5507
- focusSeries.value = nextFocus;
5508
- redraw();
6396
+ if (emphasisSeriesName.value) {} else {
6397
+ const nextFocus = props.options.legend?.hoverEmphasis !== false && legendName ? legendName : null;
6398
+ if (nextFocus !== focusSeries.value) {
6399
+ focusSeries.value = nextFocus;
6400
+ redraw();
6401
+ }
5509
6402
  }
5510
6403
  if (legendName) {
5511
- if (hoverIndex !== -1) {
5512
- hoverIndex = -1;
5513
- const t = props.options.type;
5514
- if (t === "pie" || t === "doughnut" || t === "rose") startHoverAnimation(-1);
5515
- else redraw();
5516
- }
6404
+ clearHover();
5517
6405
  tooltipVisible.value = false;
5518
6406
  return;
5519
6407
  }
@@ -5532,6 +6420,10 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5532
6420
  hoverAnimProgress = .35;
5533
6421
  startHoverAnimation(1);
5534
6422
  }
6423
+ } else if (usesHoverAnimation()) {
6424
+ stopHoverAnimation();
6425
+ hoverAnimProgress = 1;
6426
+ redraw();
5535
6427
  } else redraw();
5536
6428
  if (prevIndex === -1) {
5537
6429
  const emitParams = Array.isArray(result.params) ? result.params[0] : result.params;
@@ -5556,10 +6448,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5556
6448
  });
5557
6449
  tooltipVisible.value = true;
5558
6450
  } else {
5559
- if (hoverIndex !== -1) {
5560
- hoverIndex = -1;
5561
- if (isPieLike) startHoverAnimation(-1);
5562
- else redraw();
6451
+ if (clearHover()) {
5563
6452
  emit("unhover");
5564
6453
  ariaLiveText.value = "";
5565
6454
  }
@@ -5634,18 +6523,14 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5634
6523
  tooltipVisible.value = false;
5635
6524
  mouseX = -1;
5636
6525
  mouseY = -1;
5637
- if (focusSeries.value !== null) focusSeries.value = null;
6526
+ if (focusSeries.value !== null && !emphasisSeriesName.value) focusSeries.value = null;
5638
6527
  if (hoveredToolbox.value !== null) hoveredToolbox.value = null;
5639
6528
  if (dragMode === "brush") {
5640
6529
  brushRect.value = null;
5641
6530
  redraw();
5642
6531
  }
5643
6532
  dragMode = "none";
5644
- if (hoverIndex !== -1) {
5645
- hoverIndex = -1;
5646
- const t = props.options.type;
5647
- if (t === "pie" || t === "doughnut" || t === "rose") startHoverAnimation(-1);
5648
- else redraw();
6533
+ if (clearHover()) {
5649
6534
  emit("unhover");
5650
6535
  ariaLiveText.value = "";
5651
6536
  }
@@ -5701,11 +6586,35 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5701
6586
  }))
5702
6587
  };
5703
6588
  }
6589
+ function cloneSpec(value) {
6590
+ if (value === null || typeof value !== "object") return value;
6591
+ if (Array.isArray(value)) return value.map(cloneSpec);
6592
+ const out = {};
6593
+ Object.keys(value).forEach((k) => {
6594
+ out[k] = cloneSpec(value[k]);
6595
+ });
6596
+ return out;
6597
+ }
6598
+ const IS_DEV = typeof import.meta !== "undefined" && false;
6599
+ const devWarned = /* @__PURE__ */ new Set();
6600
+ function runDevValidation() {
6601
+ if (!IS_DEV) return;
6602
+ const { warnings } = validateOptions(props.options);
6603
+ warnings.slice(0, 8).forEach((w) => {
6604
+ const key = `${w.path}:${w.message}`;
6605
+ if (devWarned.has(key)) return;
6606
+ devWarned.add(key);
6607
+ console.warn(`[EvChart] ${w.path} ${w.message}`);
6608
+ });
6609
+ }
5704
6610
  let prevOptionsSnapshot = null;
5705
6611
  watch(() => props.options, (next) => {
5706
6612
  cachedDataExtent = null;
5707
6613
  cachedPlotArea = null;
5708
6614
  internalError.value = null;
6615
+ runDevValidation();
6616
+ focusSeries.value = emphasisSeriesName.value;
6617
+ syncScenes();
5709
6618
  const zoom = getDataZoomConfig(props.options);
5710
6619
  const zoomKey = zoom ? JSON.stringify({
5711
6620
  e: zoom.enabled,
@@ -5759,6 +6668,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5759
6668
  let unregisterConnector = null;
5760
6669
  onMounted(() => {
5761
6670
  render(true);
6671
+ syncScenes();
5762
6672
  setupResizeObserver();
5763
6673
  setupDarkModeObserver();
5764
6674
  window.addEventListener("pointerup", onWindowPointerUp);
@@ -5778,6 +6688,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5778
6688
  stopAnimation();
5779
6689
  stopHoverAnimation();
5780
6690
  stopTweenAnimation();
6691
+ stopSceneTimer();
5781
6692
  if (tooltipRafId !== null) {
5782
6693
  cancelAnimationFrame(tooltipRafId);
5783
6694
  tooltipRafId = null;
@@ -5791,6 +6702,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5791
6702
  update(newOptions) {
5792
6703
  Object.assign(props.options, newOptions);
5793
6704
  cachedDataExtent = null;
6705
+ specVersion.value++;
5794
6706
  debouncedRender(true);
5795
6707
  },
5796
6708
  toDataURL(type = "image/png", quality = 1) {
@@ -5801,6 +6713,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5801
6713
  cleanupDarkModeObserver();
5802
6714
  stopAnimation();
5803
6715
  stopHoverAnimation();
6716
+ stopSceneTimer();
5804
6717
  if (tooltipRafId !== null) {
5805
6718
  cancelAnimationFrame(tooltipRafId);
5806
6719
  tooltipRafId = null;
@@ -5890,6 +6803,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5890
6803
  ...props.options.theme,
5891
6804
  ...theme
5892
6805
  };
6806
+ specVersion.value++;
5893
6807
  redraw();
5894
6808
  },
5895
6809
  exportSVG(options = {}) {
@@ -5932,6 +6846,59 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5932
6846
  getOption() {
5933
6847
  return props.options;
5934
6848
  },
6849
+ getSpec() {
6850
+ return cloneSpec(props.options);
6851
+ },
6852
+ setSpec(spec) {
6853
+ if (spec === null || typeof spec !== "object") return;
6854
+ const next = cloneSpec(spec);
6855
+ Object.keys(props.options).forEach((k) => delete props.options[k]);
6856
+ Object.assign(props.options, next);
6857
+ hiddenSeries.value = /* @__PURE__ */ new Set();
6858
+ focusSeries.value = null;
6859
+ brushRect.value = null;
6860
+ hoverIndex = -1;
6861
+ mouseX = -1;
6862
+ mouseY = -1;
6863
+ stopSceneTimer();
6864
+ sceneIndex.value = 0;
6865
+ sceneAnimOverride = null;
6866
+ const zoom = getDataZoomConfig(next);
6867
+ zoomRange.value = {
6868
+ start: zoom?.start ?? 0,
6869
+ end: zoom?.end ?? 100
6870
+ };
6871
+ lastZoomKey = zoom ? JSON.stringify({
6872
+ e: zoom.enabled,
6873
+ s: zoom.start,
6874
+ e2: zoom.end,
6875
+ p: zoom.position,
6876
+ h: zoom.height
6877
+ }) : "";
6878
+ cachedDataExtent = null;
6879
+ cachedPlotArea = null;
6880
+ specVersion.value++;
6881
+ debouncedRender(true);
6882
+ },
6883
+ nextScene() {
6884
+ applyScene(sceneIndex.value + 1);
6885
+ },
6886
+ prevScene() {
6887
+ applyScene(sceneIndex.value - 1);
6888
+ },
6889
+ gotoScene(i) {
6890
+ applyScene(i);
6891
+ },
6892
+ getSceneIndex() {
6893
+ return sceneIndex.value;
6894
+ },
6895
+ getEffectiveSpec() {
6896
+ const spec = cloneSpec(effectiveOptions.value);
6897
+ Object.keys(spec).forEach((k) => {
6898
+ if (k.startsWith("__")) delete spec[k];
6899
+ });
6900
+ return spec;
6901
+ },
5935
6902
  setDataZoomRange(start, end) {
5936
6903
  if (!isZoomEnabled()) {
5937
6904
  console.warn("[EvChart] setDataZoomRange: dataZoom is not enabled");
@@ -5969,12 +6936,17 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5969
6936
  onDblclick: handleDblClick,
5970
6937
  onPointerleave: handlePointerLeave
5971
6938
  }, null, 36)) : createCommentVNode("", true),
6939
+ !isEmpty.value && !props.options.loading && !internalError.value && _ctx.$slots.overlay ? (openBlock(), createElementBlock("div", _hoisted_2, [renderSlot(_ctx.$slots, "overlay", {
6940
+ plotArea: overlayInfo.value.plotArea,
6941
+ theme: overlayInfo.value.theme,
6942
+ options: props.options
6943
+ }, void 0, true)])) : createCommentVNode("", true),
5972
6944
  createVNode(Transition, { name: "ev-chart-fade" }, {
5973
- 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)]),
6945
+ 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)]),
5974
6946
  _: 1
5975
6947
  }),
5976
6948
  createVNode(Transition, { name: "ev-chart-fade" }, {
5977
- default: withCtx(() => [props.options.loading && !internalError.value ? (openBlock(), createElementBlock("div", _hoisted_5, [createElementVNode("div", _hoisted_6, [(openBlock(), createElementBlock(Fragment, null, renderList(6, (i) => {
6949
+ default: withCtx(() => [props.options.loading && !internalError.value ? (openBlock(), createElementBlock("div", _hoisted_6, [createElementVNode("div", _hoisted_7, [(openBlock(), createElementBlock(Fragment, null, renderList(6, (i) => {
5978
6950
  return createElementVNode("div", {
5979
6951
  key: i,
5980
6952
  class: "ev-chart__loading-bar",
@@ -5984,7 +6956,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5984
6956
  _: 1
5985
6957
  }),
5986
6958
  createVNode(Transition, { name: "ev-chart-fade" }, {
5987
- default: withCtx(() => [internalError.value ? (openBlock(), createElementBlock("div", _hoisted_7, [_cache[0] || (_cache[0] = createElementVNode("span", { class: "ev-chart__error-icon" }, [createElementVNode("svg", {
6959
+ default: withCtx(() => [internalError.value ? (openBlock(), createElementBlock("div", _hoisted_8, [_cache[0] || (_cache[0] = createElementVNode("span", { class: "ev-chart__error-icon" }, [createElementVNode("svg", {
5988
6960
  viewBox: "0 0 24 24",
5989
6961
  width: "1em",
5990
6962
  height: "1em",
@@ -6012,7 +6984,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
6012
6984
  x2: "12",
6013
6985
  y2: "16.51"
6014
6986
  })
6015
- ])], -1)), createElementVNode("span", _hoisted_8, toDisplayString(internalError.value), 1)])) : createCommentVNode("", true)]),
6987
+ ])], -1)), createElementVNode("span", _hoisted_9, toDisplayString(internalError.value), 1)])) : createCommentVNode("", true)]),
6016
6988
  _: 1
6017
6989
  }),
6018
6990
  createVNode(Transition, { name: "ev-chart-tooltip" }, {
@@ -6024,14 +6996,14 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
6024
6996
  style: normalizeStyle(tooltipStyle.value),
6025
6997
  role: "tooltip",
6026
6998
  innerHTML: tooltipContent.value
6027
- }, null, 14, _hoisted_9)) : createCommentVNode("", true)]),
6999
+ }, null, 14, _hoisted_10)) : createCommentVNode("", true)]),
6028
7000
  _: 1
6029
7001
  }),
6030
- createElementVNode("div", _hoisted_10, toDisplayString(ariaLiveText.value), 1)
7002
+ createElementVNode("div", _hoisted_11, toDisplayString(ariaLiveText.value), 1)
6031
7003
  ], 12, _hoisted_1);
6032
7004
  };
6033
7005
  }
6034
- }), [["__scopeId", "data-v-a07b68e8"]]);
7006
+ }), [["__scopeId", "data-v-0c5d944e"]]);
6035
7007
  //#endregion
6036
7008
  //#region src/useChart.js
6037
7009
  var USE_CHART_KEY = /* @__PURE__ */ Symbol("ev-chart-context");
@@ -6184,6 +7156,742 @@ function clearSeriesPalette(options = {}) {
6184
7156
  return true;
6185
7157
  }
6186
7158
  //#endregion
7159
+ //#region src/ai/table.js
7160
+ var NUM_CLEAN = /[,\s_]/g;
7161
+ function parseNumeric(raw) {
7162
+ if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
7163
+ if (typeof raw !== "string") return null;
7164
+ let s = raw.trim();
7165
+ if (!s) return null;
7166
+ let sign = 1;
7167
+ if (/^\(.*\)$/.test(s)) {
7168
+ sign = -1;
7169
+ s = s.slice(1, -1);
7170
+ }
7171
+ s = s.replace(NUM_CLEAN, "").replace(/^[¥$€£]/, "").replace(/[¥$€£]$/, "");
7172
+ let mult = 1;
7173
+ if (/亿$/.test(s)) {
7174
+ mult = 1e8;
7175
+ s = s.slice(0, -1);
7176
+ } else if (/万$/.test(s)) {
7177
+ mult = 1e4;
7178
+ s = s.slice(0, -1);
7179
+ } else if (/[Kk]$/.test(s)) {
7180
+ mult = 1e3;
7181
+ s = s.slice(0, -1);
7182
+ }
7183
+ if (/%$/.test(s)) s = s.slice(0, -1);
7184
+ if (!/^[-+]?\d+(\.\d+)?$/.test(s)) return null;
7185
+ return sign * parseFloat(s) * mult;
7186
+ }
7187
+ function looksLikeTime(v) {
7188
+ if (typeof v !== "string") return false;
7189
+ const s = v.trim();
7190
+ if (/^\d{4}[-/年.]\d{1,2}月?(?:[-/.]\d{1,2}日?)?$/.test(s)) return true;
7191
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)) return true;
7192
+ if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(s)) return true;
7193
+ return false;
7194
+ }
7195
+ function splitLine(line, delim) {
7196
+ const out = [];
7197
+ let cur = "";
7198
+ let inQuotes = false;
7199
+ for (let i = 0; i < line.length; i++) {
7200
+ const ch = line[i];
7201
+ if (ch === "\"") {
7202
+ if (inQuotes && line[i + 1] === "\"") {
7203
+ cur += "\"";
7204
+ i++;
7205
+ } else inQuotes = !inQuotes;
7206
+ } else if (ch === delim && !inQuotes) {
7207
+ out.push(cur.trim());
7208
+ cur = "";
7209
+ } else cur += ch;
7210
+ }
7211
+ out.push(cur.trim());
7212
+ return out;
7213
+ }
7214
+ function pickDelimiter(line) {
7215
+ const candidates = [
7216
+ " ",
7217
+ ";",
7218
+ ",",
7219
+ ","
7220
+ ];
7221
+ let best = ",";
7222
+ let bestCount = 0;
7223
+ candidates.forEach((d) => {
7224
+ const n = splitLine(line, d).length;
7225
+ if (n > bestCount) {
7226
+ bestCount = n;
7227
+ best = d;
7228
+ }
7229
+ });
7230
+ return best;
7231
+ }
7232
+ function rowToObj(headers, row) {
7233
+ const obj = {};
7234
+ headers.forEach((h, i) => {
7235
+ obj[h] = row[i] === void 0 ? null : row[i];
7236
+ });
7237
+ return obj;
7238
+ }
7239
+ function parseDataTable(input) {
7240
+ if (Array.isArray(input)) {
7241
+ if (input.length === 0) return null;
7242
+ const first = input[0];
7243
+ if (first !== null && typeof first === "object" && !Array.isArray(first)) {
7244
+ const headers = [];
7245
+ input.forEach((row) => {
7246
+ Object.keys(row).forEach((k) => {
7247
+ if (!headers.includes(k)) headers.push(k);
7248
+ });
7249
+ });
7250
+ return {
7251
+ headers,
7252
+ rows: input
7253
+ };
7254
+ }
7255
+ if (Array.isArray(first)) {
7256
+ if (input.length < 1) return null;
7257
+ const width = input.reduce((m, r) => Math.max(m, r.length), 0);
7258
+ if (width < 2) return null;
7259
+ const head = input[0].map((v) => v === null || v === void 0 ? "" : String(v).trim());
7260
+ if (head.filter((v) => parseNumeric(v) !== null).length === 0) return {
7261
+ headers: head,
7262
+ rows: input.slice(1).map((r) => rowToObj(head, r))
7263
+ };
7264
+ const headers = Array.from({ length: width }, (_, i) => `列${i + 1}`);
7265
+ return {
7266
+ headers,
7267
+ rows: input.map((r) => rowToObj(headers, r))
7268
+ };
7269
+ }
7270
+ return null;
7271
+ }
7272
+ if (typeof input === "string") {
7273
+ const lines = input.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
7274
+ if (lines.length < 2) return null;
7275
+ const delim = pickDelimiter(lines[0]);
7276
+ const grid = lines.map((l) => splitLine(l, delim));
7277
+ const head = grid[0];
7278
+ if (head.filter((v) => parseNumeric(v) !== null).length === 0 && head.length >= 2) return {
7279
+ headers: head,
7280
+ rows: grid.slice(1).map((r) => rowToObj(head, r))
7281
+ };
7282
+ const width = grid.reduce((m, r) => Math.max(m, r.length), 0);
7283
+ const headers = Array.from({ length: width }, (_, i) => `列${i + 1}`);
7284
+ return {
7285
+ headers,
7286
+ rows: grid.map((r) => rowToObj(headers, r))
7287
+ };
7288
+ }
7289
+ return null;
7290
+ }
7291
+ function inferColumns(table) {
7292
+ const { headers, rows } = table;
7293
+ return headers.map((name) => {
7294
+ const values = rows.map((r) => r[name] === void 0 ? null : r[name]);
7295
+ const present = values.filter((v) => v !== null && v !== "").length;
7296
+ const numericHits = values.filter((v) => parseNumeric(v) !== null).length;
7297
+ if (present > 0 && numericHits / present >= .6) return {
7298
+ name,
7299
+ type: "number",
7300
+ values: values.map((v) => parseNumeric(v))
7301
+ };
7302
+ const timeHits = values.filter((v) => looksLikeTime(v)).length;
7303
+ if (present > 0 && timeHits / present >= .6) return {
7304
+ name,
7305
+ type: "time",
7306
+ values
7307
+ };
7308
+ return {
7309
+ name,
7310
+ type: "category",
7311
+ values
7312
+ };
7313
+ });
7314
+ }
7315
+ function distinctCount(values) {
7316
+ return new Set(values.filter((v) => v !== null && v !== "")).size;
7317
+ }
7318
+ //#endregion
7319
+ //#region src/ai/spec.js
7320
+ var INTENT_KEYWORDS = {
7321
+ trend: [
7322
+ "趋势",
7323
+ "走势",
7324
+ "变化",
7325
+ "随时间",
7326
+ "增长",
7327
+ "trend"
7328
+ ],
7329
+ share: [
7330
+ "占比",
7331
+ "构成",
7332
+ "份额",
7333
+ "比例",
7334
+ "组成",
7335
+ "share"
7336
+ ],
7337
+ rank: [
7338
+ "排行",
7339
+ "排名",
7340
+ "前几",
7341
+ "highest",
7342
+ "rank"
7343
+ ],
7344
+ compare: [
7345
+ "对比",
7346
+ "比较",
7347
+ "对照",
7348
+ "相比",
7349
+ "compare"
7350
+ ],
7351
+ relation: [
7352
+ "关系",
7353
+ "相关",
7354
+ "散点",
7355
+ "分布",
7356
+ "relation",
7357
+ "scatter"
7358
+ ],
7359
+ stack: [
7360
+ "叠加",
7361
+ "堆叠",
7362
+ "累计",
7363
+ "stack"
7364
+ ]
7365
+ };
7366
+ function detectIntent(text) {
7367
+ if (!text || typeof text !== "string") return null;
7368
+ for (const intent of [
7369
+ "trend",
7370
+ "stack",
7371
+ "share",
7372
+ "rank",
7373
+ "relation",
7374
+ "compare"
7375
+ ]) if (INTENT_KEYWORDS[intent].some((kw) => text.includes(kw))) return intent;
7376
+ return null;
7377
+ }
7378
+ function fmtValue(v) {
7379
+ if (typeof v !== "number" || !Number.isFinite(v)) return String(v);
7380
+ if (Math.abs(v) >= 1e8) return `${(v / 1e8).toFixed(1)}亿`;
7381
+ if (Math.abs(v) >= 1e4) return `${(v / 1e4).toFixed(1)}万`;
7382
+ return `${Math.round(v * 100) / 100}`;
7383
+ }
7384
+ function buildNarrative(spec, labels, seriesList) {
7385
+ const annotations = [];
7386
+ let best = null;
7387
+ seriesList.forEach((s, si) => {
7388
+ s.data.forEach((v, di) => {
7389
+ if (typeof v === "number" && Number.isFinite(v) && (!best || v > best.value)) best = {
7390
+ value: v,
7391
+ label: labels[di],
7392
+ series: s.name,
7393
+ si,
7394
+ di
7395
+ };
7396
+ });
7397
+ });
7398
+ if (best) annotations.push({
7399
+ type: "callout",
7400
+ x: best.label,
7401
+ y: best.value,
7402
+ series: best.series,
7403
+ label: `峰值 ${fmtValue(best.value)}`,
7404
+ anchor: "top-right"
7405
+ });
7406
+ const first = seriesList[0];
7407
+ if (first && first.data.length >= 2) {
7408
+ const prev = first.data[first.data.length - 2];
7409
+ const last = first.data[first.data.length - 1];
7410
+ if (typeof prev === "number" && typeof last === "number" && prev !== 0) {
7411
+ const pct = (last - prev) / Math.abs(prev);
7412
+ if (Math.abs(pct) >= .01) annotations.push({
7413
+ type: "delta",
7414
+ x: labels[labels.length - 1],
7415
+ y: last,
7416
+ series: first.name,
7417
+ direction: pct >= 0 ? "up" : "down",
7418
+ text: `环比 ${pct >= 0 ? "+" : ""}${(pct * 100).toFixed(0)}%`
7419
+ });
7420
+ }
7421
+ }
7422
+ if (annotations.length > 0) spec.annotations = annotations.slice(0, 3);
7423
+ }
7424
+ function generateChartSpec(data, hint = {}) {
7425
+ const table = parseDataTable(data);
7426
+ if (!table) return {
7427
+ spec: null,
7428
+ report: [{
7429
+ level: "error",
7430
+ message: "无法识别的数据格式:支持 CSV/TSV 文本、对象数组、二维数组"
7431
+ }]
7432
+ };
7433
+ const cols = inferColumns(table);
7434
+ const numbers = cols.filter((c) => c.type === "number");
7435
+ const times = cols.filter((c) => c.type === "time");
7436
+ const cats = cols.filter((c) => c.type === "category" && distinctCount(c.values) > 0);
7437
+ const dimension = times[0] || cats[0] || null;
7438
+ const labels = dimension ? dimension.values.map((v) => String(v)) : table.rows.map((_, i) => String(i + 1));
7439
+ const intent = hint.intent || detectIntent([hint.title, hint.hint].filter(Boolean).join(" ")) || defaultIntent({
7440
+ times: times.length,
7441
+ numbers: numbers.length,
7442
+ cats: cats.length,
7443
+ labels: labels.length
7444
+ });
7445
+ const report = [{
7446
+ level: "info",
7447
+ message: `识别到 ${numbers.length} 个数值列、${times.length} 个时间列、${cats.length} 个类目列;意图「${intent}」`
7448
+ }];
7449
+ const spec = {};
7450
+ if (hint.title) spec.title = hint.title;
7451
+ if (intent === "relation" && numbers.length >= 2) {
7452
+ const cat = cats[0];
7453
+ spec.type = "scatter";
7454
+ spec.scatterData = table.rows.map((_, i) => ({
7455
+ x: numbers[0].values[i],
7456
+ y: numbers[1].values[i],
7457
+ label: cat ? String(cat.values[i]) : String(i + 1)
7458
+ }));
7459
+ report.push({
7460
+ level: "info",
7461
+ message: `散点图:${numbers[0].name} → x,${numbers[1].name} → y`
7462
+ });
7463
+ return {
7464
+ spec,
7465
+ report
7466
+ };
7467
+ }
7468
+ if (numbers.length === 0) return {
7469
+ spec: null,
7470
+ report: [{
7471
+ level: "error",
7472
+ message: "数据中没有可用的数值列(列需 ≥60% 为可解析数字)"
7473
+ }]
7474
+ };
7475
+ const seriesList = numbers.slice(0, 8).map((c) => ({
7476
+ name: c.name,
7477
+ data: c.values
7478
+ }));
7479
+ if (numbers.length > 8) report.push({
7480
+ level: "warn",
7481
+ message: `数值列超过 8 个,仅取前 8 列(${seriesList.map((s) => s.name).join("、")})`
7482
+ });
7483
+ if (intent === "share") {
7484
+ if (seriesList.length === 1 && labels.length <= 8) {
7485
+ const pieData = labels.map((name, i) => ({
7486
+ name,
7487
+ value: seriesList[0].data[i]
7488
+ })).filter((d) => typeof d.value === "number" && d.value > 0);
7489
+ spec.type = "pie";
7490
+ spec.pieData = pieData;
7491
+ report.push({
7492
+ level: "info",
7493
+ message: `饼图:${seriesList[0].name} 按类目占比(${pieData.length} 项)`
7494
+ });
7495
+ return {
7496
+ spec,
7497
+ report
7498
+ };
7499
+ }
7500
+ if (seriesList.length >= 2 && seriesList.length <= 8) {
7501
+ const sums = seriesList.map((s) => s.data.reduce((acc, v) => typeof v === "number" && Number.isFinite(v) ? acc + v : acc, 0));
7502
+ if (sums.every((v) => v > 0)) {
7503
+ spec.type = "pie";
7504
+ spec.pieData = seriesList.map((s, i) => ({
7505
+ name: s.name,
7506
+ value: sums[i]
7507
+ }));
7508
+ report.push({
7509
+ level: "info",
7510
+ message: `饼图:按度量合计占比(${sums.length} 项)`
7511
+ });
7512
+ return {
7513
+ spec,
7514
+ report
7515
+ };
7516
+ }
7517
+ }
7518
+ }
7519
+ const labelWidth = labels.reduce((m, l) => Math.max(m, l.length), 0);
7520
+ const wantRank = intent === "rank" || intent === "compare" && seriesList.length === 1 && labels.length >= 9;
7521
+ if (seriesList.length === 1 && (wantRank || labels.length >= 13 || labelWidth >= 5 && labels.length >= 7)) {
7522
+ spec.type = "horizontal-bar";
7523
+ spec.labels = labels;
7524
+ spec.series = seriesList;
7525
+ report.push({
7526
+ level: "info",
7527
+ message: "横向条形图:单度量分类对比,类目名走纵列更易读"
7528
+ });
7529
+ finishCartesian(spec, hint, labels, seriesList, report);
7530
+ return {
7531
+ spec,
7532
+ report
7533
+ };
7534
+ }
7535
+ if (intent === "stack" && seriesList.length >= 2) {
7536
+ spec.type = "stacked-bar";
7537
+ report.push({
7538
+ level: "info",
7539
+ message: "堆叠柱状图:多度量叠加看构成"
7540
+ });
7541
+ } else if ((intent === "compare" || intent === "rank") && seriesList.length <= 3 && labels.length <= 12) {
7542
+ spec.type = "bar";
7543
+ report.push({
7544
+ level: "info",
7545
+ message: "柱状图:类目间量值对比"
7546
+ });
7547
+ } else {
7548
+ spec.type = "line";
7549
+ report.push({
7550
+ level: "info",
7551
+ message: "折线图:维度有序(时间/类目),看走势与多系列对比"
7552
+ });
7553
+ }
7554
+ spec.labels = labels;
7555
+ spec.series = seriesList;
7556
+ if (seriesList.length > 1) spec.legend = { show: true };
7557
+ finishCartesian(spec, hint, labels, seriesList, report);
7558
+ return {
7559
+ spec,
7560
+ report
7561
+ };
7562
+ }
7563
+ function finishCartesian(spec, hint, labels, seriesList, report) {
7564
+ if (hint.narrative) {
7565
+ buildNarrative(spec, labels, seriesList);
7566
+ if (spec.annotations) report.push({
7567
+ level: "info",
7568
+ message: `叙述注解 ${spec.annotations.length} 处(峰值 callout / 末点环比 delta)`
7569
+ });
7570
+ }
7571
+ }
7572
+ function defaultIntent({ times, numbers, cats, labels }) {
7573
+ if (times > 0 && numbers > 0) return "trend";
7574
+ if (numbers >= 2 && cats === 0) return "relation";
7575
+ if (numbers === 1 && (cats > 0 || labels > 0)) return "compare";
7576
+ return "trend";
7577
+ }
7578
+ var SPEC_RULES = [
7579
+ "type 必填,取值必须是 schema enum 中的图表类型",
7580
+ "series[].data 长度必须与 labels 一致,缺失值用 null",
7581
+ "叙述注解 annotations 单图不超过 3 处,emphasis 焦点最多 1 个系列",
7582
+ "不使用 schema 之外的字段",
7583
+ `支持的图表类型:${chartOptionsSchema.properties.type.enum.join(" / ")}`
7584
+ ];
7585
+ //#endregion
7586
+ //#region src/ai/examples.js
7587
+ var SPEC_EXAMPLES = [
7588
+ {
7589
+ requirement: "看上半年各渠道销售额走势",
7590
+ spec: {
7591
+ type: "line",
7592
+ title: "渠道月度销售额",
7593
+ labels: [
7594
+ "1月",
7595
+ "2月",
7596
+ "3月",
7597
+ "4月",
7598
+ "5月",
7599
+ "6月"
7600
+ ],
7601
+ series: [{
7602
+ name: "线上商城",
7603
+ data: [
7604
+ 320,
7605
+ 356,
7606
+ 401,
7607
+ 388,
7608
+ 520,
7609
+ 560
7610
+ ]
7611
+ }, {
7612
+ name: "线下门店",
7613
+ data: [
7614
+ 280,
7615
+ 302,
7616
+ 346,
7617
+ 331,
7618
+ 352,
7619
+ 428
7620
+ ]
7621
+ }],
7622
+ legend: { show: true }
7623
+ }
7624
+ },
7625
+ {
7626
+ requirement: "各渠道销售额占比",
7627
+ spec: {
7628
+ type: "pie",
7629
+ title: "渠道销售额占比",
7630
+ pieData: [
7631
+ {
7632
+ name: "线上商城",
7633
+ value: 2545
7634
+ },
7635
+ {
7636
+ name: "线下门店",
7637
+ value: 2039
7638
+ },
7639
+ {
7640
+ name: "社群团购",
7641
+ value: 844
7642
+ }
7643
+ ]
7644
+ }
7645
+ },
7646
+ {
7647
+ requirement: "各门店销量排行",
7648
+ spec: {
7649
+ type: "horizontal-bar",
7650
+ title: "门店销量排行",
7651
+ labels: [
7652
+ "线下体验店",
7653
+ "线上旗舰店",
7654
+ "社群团购点",
7655
+ "仓储会员店",
7656
+ "社区便利店"
7657
+ ],
7658
+ series: [{
7659
+ name: "销量",
7660
+ data: [
7661
+ 120,
7662
+ 98,
7663
+ 80,
7664
+ 60,
7665
+ 52
7666
+ ]
7667
+ }]
7668
+ }
7669
+ },
7670
+ {
7671
+ requirement: "广告投放与成交金额的关系",
7672
+ spec: {
7673
+ type: "scatter",
7674
+ title: "投放-成交关系",
7675
+ scatterData: [
7676
+ {
7677
+ x: 100,
7678
+ y: 12,
7679
+ label: "华东"
7680
+ },
7681
+ {
7682
+ x: 200,
7683
+ y: 26,
7684
+ label: "华南"
7685
+ },
7686
+ {
7687
+ x: 300,
7688
+ y: 31,
7689
+ label: "华北"
7690
+ }
7691
+ ]
7692
+ }
7693
+ }
7694
+ ];
7695
+ function formatExamples(maxCount = SPEC_EXAMPLES.length) {
7696
+ return SPEC_EXAMPLES.slice(0, maxCount).map((e) => `需求:${e.requirement}\nSpec:${JSON.stringify(e.spec)}`).join("\n\n");
7697
+ }
7698
+ //#endregion
7699
+ //#region src/ai/prompt.js
7700
+ var PREVIEW_ROWS = 8;
7701
+ function buildChartPrompt({ data, requirement, extraRules = [], examples = true } = {}) {
7702
+ const table = parseDataTable(data);
7703
+ let digest;
7704
+ 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} 行` : ""}`;
7705
+ else if (data !== void 0 && data !== null) digest = typeof data === "string" ? data : JSON.stringify(data);
7706
+ else digest = "(未提供数据,请基于需求虚构合理示例数据)";
7707
+ const rules = [...SPEC_RULES, ...extraRules];
7708
+ const sections = [
7709
+ "你是图表配置生成器。根据下面的数据与需求,产出 EvChart 的 options JSON(下称 Spec),它将被 <ev-chart :options> 直接渲染。",
7710
+ `## Options Schema\n${JSON.stringify(chartOptionsSchema)}`,
7711
+ `## 数据预览\n${digest}`
7712
+ ];
7713
+ if (examples) sections.push(`## 示例\n${formatExamples()}`);
7714
+ sections.push(requirement ? `## 需求\n${requirement}` : "## 需求\n根据数据形状自行选择最合适的图表类型,并拟一个不超过 12 字的标题。");
7715
+ sections.push(`## 硬性规则\n${rules.map((r, i) => `${i + 1}. ${r}`).join("\n")}`);
7716
+ return sections.join("\n\n");
7717
+ }
7718
+ //#endregion
7719
+ //#region src/ai/lint.js
7720
+ var DEFAULT_SIZE = {
7721
+ width: 800,
7722
+ height: 450
7723
+ };
7724
+ function checkTextOverflow(svg, width, height) {
7725
+ const issues = [];
7726
+ let count = 0;
7727
+ for (const m of svg.matchAll(/<text x="([-\d.]+)" y="([-\d.]+)"/g)) {
7728
+ const x = parseFloat(m[1]);
7729
+ const y = parseFloat(m[2]);
7730
+ if (x < -1 || x > width + 1 || y < 0 || y > height + 1) count++;
7731
+ }
7732
+ if (count > 0) issues.push({
7733
+ level: "warn",
7734
+ message: `${count} 处文本超出画布边界(可能被裁剪)`,
7735
+ rule: "text-overflow"
7736
+ });
7737
+ return issues;
7738
+ }
7739
+ function checkTextOverlap(svg) {
7740
+ const issues = [];
7741
+ const texts = [];
7742
+ for (const m of svg.matchAll(/<text x="([-\d.]+)" y="([-\d.]+)" font-size="([\d.]+)"([^>]*)>([^<]*)<\/text>/g)) {
7743
+ const x = parseFloat(m[1]);
7744
+ const y = parseFloat(m[2]);
7745
+ const fs = parseFloat(m[3]);
7746
+ const attrs = m[4];
7747
+ const content = m[5].trim();
7748
+ if (!content) continue;
7749
+ const w = estimateTextWidth(content, fs);
7750
+ let left = x;
7751
+ if (/text-anchor="middle"/.test(attrs)) left = x - w / 2;
7752
+ else if (/text-anchor="end"/.test(attrs)) left = x - w;
7753
+ texts.push({
7754
+ left,
7755
+ right: left + w,
7756
+ y,
7757
+ fs
7758
+ });
7759
+ }
7760
+ let count = 0;
7761
+ for (let i = 0; i < texts.length; i++) for (let j = i + 1; j < texts.length; j++) {
7762
+ const a = texts[i];
7763
+ const b = texts[j];
7764
+ if (Math.abs(a.y - b.y) >= Math.max(a.fs, b.fs) * .8) continue;
7765
+ 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++;
7766
+ }
7767
+ if (count > 0) issues.push({
7768
+ level: "warn",
7769
+ message: `${count} 处文本相互重叠(标签或注解可能互相遮挡)`,
7770
+ rule: "text-overlap"
7771
+ });
7772
+ return issues;
7773
+ }
7774
+ function luminance(color) {
7775
+ let s = String(color).trim();
7776
+ if (/^#[0-9a-fA-F]{6}$/.test(s)) s = s.slice(1);
7777
+ else if (/^#[0-9a-fA-F]{3}$/.test(s)) s = s.slice(1).split("").map((c) => c + c).join("");
7778
+ else return null;
7779
+ const n = parseInt(s, 16);
7780
+ const chan = [
7781
+ n >> 16 & 255,
7782
+ n >> 8 & 255,
7783
+ n & 255
7784
+ ].map((v) => {
7785
+ const c = v / 255;
7786
+ return c <= .03928 ? c / 12.92 : Math.pow((c + .055) / 1.055, 2.4);
7787
+ });
7788
+ return .2126 * chan[0] + .7152 * chan[1] + .0722 * chan[2];
7789
+ }
7790
+ function contrastRatio(a, b) {
7791
+ const la = luminance(a);
7792
+ const lb = luminance(b);
7793
+ if (la === null || lb === null) return null;
7794
+ return (Math.max(la, lb) + .05) / (Math.min(la, lb) + .05);
7795
+ }
7796
+ function checkPaletteContrast(spec) {
7797
+ const issues = [];
7798
+ if (!spec.theme || !Array.isArray(spec.theme.colors)) return issues;
7799
+ [false, true].forEach((dark) => {
7800
+ const theme = getTheme(dark, spec.theme);
7801
+ spec.theme.colors.forEach((c) => {
7802
+ const ratio = contrastRatio(c, theme.backgroundColor);
7803
+ if (ratio !== null && ratio < 3) issues.push({
7804
+ level: "warn",
7805
+ message: `自定义色板${dark ? "暗色" : "浅色"}模式对比度不足 3:1:${c}(${ratio.toFixed(1)})`,
7806
+ rule: "low-contrast"
7807
+ });
7808
+ });
7809
+ });
7810
+ return issues;
7811
+ }
7812
+ function lintChartSpec(spec, opts = {}) {
7813
+ const issues = [];
7814
+ const fixed = { ...spec };
7815
+ if (!fixed || typeof fixed !== "object") return {
7816
+ issues: [{
7817
+ level: "error",
7818
+ message: "Spec 必须是对象"
7819
+ }],
7820
+ spec: fixed
7821
+ };
7822
+ const { warnings } = validateOptions(fixed);
7823
+ warnings.forEach((w) => issues.push({
7824
+ level: "error",
7825
+ message: `${w.path} ${w.message}`,
7826
+ path: w.path
7827
+ }));
7828
+ if (issues.some((i) => i.level === "error" && i.path === "options.type")) return {
7829
+ issues,
7830
+ spec: fixed
7831
+ };
7832
+ if (Array.isArray(fixed.annotations) && fixed.annotations.length > 3) {
7833
+ fixed.annotations = fixed.annotations.slice(0, 3);
7834
+ issues.push({
7835
+ level: "info",
7836
+ message: "注解超过 3 处,已按焦点预算裁剪",
7837
+ rule: "annotation-budget"
7838
+ });
7839
+ }
7840
+ if ((fixed.type === "pie" || fixed.type === "doughnut") && Array.isArray(fixed.pieData) && fixed.pieData.length > 8) issues.push({
7841
+ level: "warn",
7842
+ message: `饼图 ${fixed.pieData.length} 个扇区过多(>8),建议改用横向条形图`,
7843
+ rule: "pie-slices"
7844
+ });
7845
+ issues.push(...checkPaletteContrast(fixed));
7846
+ if (fixed.type === "bar" && Array.isArray(fixed.series) && fixed.series.length === 1 && Array.isArray(fixed.labels)) {
7847
+ const width = opts.width ?? DEFAULT_SIZE.width;
7848
+ const labels = fixed.labels.map(String);
7849
+ const maxLabelWidth = Math.max(...labels.map((l) => estimateTextWidth(l, 12)), 0);
7850
+ const slots = Math.max(1, (width - 80) / Math.max(1, labels.length));
7851
+ if (labels.length >= 7 && maxLabelWidth > slots * .9) {
7852
+ fixed.type = "horizontal-bar";
7853
+ issues.push({
7854
+ level: "info",
7855
+ message: "类目名过宽拥挤,已自动改为横向条形图",
7856
+ rule: "bar-to-horizontal"
7857
+ });
7858
+ }
7859
+ }
7860
+ if (typeof document !== "undefined" && fixed.type) try {
7861
+ const width = opts.width ?? DEFAULT_SIZE.width;
7862
+ const height = opts.height ?? DEFAULT_SIZE.height;
7863
+ const canvas = document.createElement("canvas");
7864
+ canvas.width = width;
7865
+ canvas.height = height;
7866
+ const real = canvas.getContext("2d");
7867
+ if (real) {
7868
+ const recorder = createSvgRecorder(real);
7869
+ renderChart(canvas, {
7870
+ options: fixed,
7871
+ ctx: recorder.ctx,
7872
+ dpr: 1,
7873
+ progress: 1,
7874
+ hoverIndex: -1,
7875
+ mouseX: -1,
7876
+ mouseY: -1,
7877
+ showCrosshair: false
7878
+ });
7879
+ issues.push(...checkTextOverflow(recorder.toSvg(width, height, "#ffffff"), width, height));
7880
+ issues.push(...checkTextOverlap(recorder.toSvg(width, height, "#ffffff")));
7881
+ }
7882
+ } catch {
7883
+ issues.push({
7884
+ level: "info",
7885
+ message: "无头渲染自检不可用,已跳过几何检查",
7886
+ rule: "headless-skipped"
7887
+ });
7888
+ }
7889
+ return {
7890
+ issues,
7891
+ spec: fixed
7892
+ };
7893
+ }
7894
+ //#endregion
6187
7895
  //#region src/index.js
6188
7896
  var components = { EvChart: chart_default };
6189
7897
  function install(app, _options = {}) {
@@ -6192,4 +7900,4 @@ function install(app, _options = {}) {
6192
7900
  var EvokeCharts = { install };
6193
7901
  var src_default = { install };
6194
7902
  //#endregion
6195
- export { chart_default as EvChart, EvokeCharts, applySeriesPalette, clearSeriesPalette, components, src_default as default, injectChartContext, install, provideChartContext, useChart };
7903
+ export { chart_default as EvChart, EvokeCharts, applySeriesPalette, buildChartPrompt, chartOptionsSchema, clearSeriesPalette, components, src_default as default, generateChartSpec, injectChartContext, install, lintChartSpec, parseDataTable, provideChartContext, useChart, validateOptions };