@wil-works/evoke-charts 0.3.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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Evoke Charts
2
2
 
3
+ > **⚠️ 本包正在开发迭代中,尚未发布正式版:API、图表类型与视觉细节可能随版本调整,请勿用于生产环境。**
4
+
3
5
  `@wil-works/evoke-charts` — 零依赖 Canvas 自绘图表库(Vue 3)。Evoke 生态的独立图表包,不依赖任何第三方图表引擎,也不依赖 Evoke 其他组件库。
4
6
 
5
7
  **在线文档**:[evoke-charts.wil-works.com](https://evoke-charts.wil-works.com) — 全部图表类型、完整配置项与在线示例。
@@ -1,5 +1,5 @@
1
1
 
2
- .ev-chart[data-v-ce034a2a] {
2
+ .ev-chart[data-v-0c5d944e] {
3
3
  position: relative;
4
4
  /* 容器不带边框:是否加框、加多粗由宿主决定,图表只负责内容;
5
5
  圆角裁剪下沉到 canvas 与浮层自身(border-radius: inherit) */
@@ -9,7 +9,7 @@
9
9
  }
10
10
 
11
11
  /* 关键:canvas 必须能接收鼠标事件 */
12
- .ev-chart__canvas[data-v-ce034a2a] {
12
+ .ev-chart__canvas[data-v-0c5d944e] {
13
13
  position: absolute;
14
14
  top: 0;
15
15
  left: 0;
@@ -23,14 +23,14 @@
23
23
 
24
24
  /* HTML 覆盖层:只在内容层,不与 canvas 抢交互(子元素可自行 pointer-events:auto),
25
25
  也不压过 tooltip(z-index 100) */
26
- .ev-chart__overlay[data-v-ce034a2a] {
26
+ .ev-chart__overlay[data-v-0c5d944e] {
27
27
  position: absolute;
28
28
  inset: 0;
29
29
  z-index: 1;
30
30
  pointer-events: none;
31
31
  border-radius: inherit;
32
32
  }
33
- .ev-chart__tooltip[data-v-ce034a2a] {
33
+ .ev-chart__tooltip[data-v-0c5d944e] {
34
34
  position: absolute;
35
35
  pointer-events: none;
36
36
  z-index: 100;
@@ -51,47 +51,47 @@
51
51
  }
52
52
 
53
53
  /* tooltip 淡入淡出(仅 opacity,位置由 left/top transition 负责) */
54
- .ev-chart-tooltip-enter-active[data-v-ce034a2a],
55
- .ev-chart-tooltip-leave-active[data-v-ce034a2a] {
54
+ .ev-chart-tooltip-enter-active[data-v-0c5d944e],
55
+ .ev-chart-tooltip-leave-active[data-v-0c5d944e] {
56
56
  transition: opacity 0.18s ease;
57
57
  }
58
58
 
59
59
  /* 进入期间禁用位置 transition,避免首次出现时从旧位置滑入 */
60
- .ev-chart-tooltip-enter-active[data-v-ce034a2a] {
60
+ .ev-chart-tooltip-enter-active[data-v-0c5d944e] {
61
61
  transition:
62
62
  opacity 0.18s ease,
63
63
  left 0s,
64
64
  top 0s;
65
65
  }
66
- .ev-chart-tooltip-enter-from[data-v-ce034a2a],
67
- .ev-chart-tooltip-leave-to[data-v-ce034a2a] {
66
+ .ev-chart-tooltip-enter-from[data-v-0c5d944e],
67
+ .ev-chart-tooltip-leave-to[data-v-0c5d944e] {
68
68
  opacity: 0;
69
69
  }
70
70
 
71
71
  /* v-html 注入的内容不带 scoped 属性,需用 :deep() 穿透 */
72
- [data-v-ce034a2a] .ev-chart__tooltip-title {
72
+ [data-v-0c5d944e] .ev-chart__tooltip-title {
73
73
  color: var(--ev-text-color-secondary, #6b7280);
74
74
  font-size: 12px;
75
75
  margin-bottom: 6px;
76
76
  font-weight: 500;
77
77
  }
78
- [data-v-ce034a2a] .ev-chart__tooltip-item {
78
+ [data-v-0c5d944e] .ev-chart__tooltip-item {
79
79
  display: flex;
80
80
  align-items: center;
81
81
  gap: 8px;
82
82
  padding: 2px 0;
83
83
  }
84
- [data-v-ce034a2a] .ev-chart__tooltip-dot {
84
+ [data-v-0c5d944e] .ev-chart__tooltip-dot {
85
85
  width: 8px;
86
86
  height: 8px;
87
87
  border-radius: 50%;
88
88
  flex-shrink: 0;
89
89
  display: inline-block;
90
90
  }
91
- [data-v-ce034a2a] .ev-chart__tooltip-name {
91
+ [data-v-0c5d944e] .ev-chart__tooltip-name {
92
92
  color: var(--ev-text-color-primary, #111827);
93
93
  }
94
- [data-v-ce034a2a] .ev-chart__tooltip-value {
94
+ [data-v-0c5d944e] .ev-chart__tooltip-value {
95
95
  color: var(--ev-text-color-primary, #111827);
96
96
  font-weight: 600;
97
97
  margin-left: auto;
@@ -100,7 +100,7 @@
100
100
  }
101
101
 
102
102
  /* ══════ 空数据占位 ══════ */
103
- .ev-chart__empty[data-v-ce034a2a] {
103
+ .ev-chart__empty[data-v-0c5d944e] {
104
104
  position: absolute;
105
105
  inset: 0;
106
106
  border-radius: inherit;
@@ -113,7 +113,7 @@
113
113
  gap: 14px;
114
114
  user-select: none;
115
115
  }
116
- .ev-chart__empty-icon-wrap[data-v-ce034a2a] {
116
+ .ev-chart__empty-icon-wrap[data-v-0c5d944e] {
117
117
  display: flex;
118
118
  align-items: center;
119
119
  justify-content: center;
@@ -122,19 +122,19 @@
122
122
  border-radius: 16px;
123
123
  background: var(--ev-fill-color-light, #f5f7fa);
124
124
  }
125
- .ev-chart__empty-img[data-v-ce034a2a] {
125
+ .ev-chart__empty-img[data-v-0c5d944e] {
126
126
  width: 32px;
127
127
  height: 32px;
128
128
  opacity: 0.4;
129
129
  }
130
- .ev-chart__empty-text[data-v-ce034a2a] {
130
+ .ev-chart__empty-text[data-v-0c5d944e] {
131
131
  letter-spacing: 0.04em;
132
132
  font-weight: 500;
133
133
  font-size: 13px;
134
134
  }
135
135
 
136
136
  /* ══════ 加载中骨架 ══════ */
137
- .ev-chart__loading[data-v-ce034a2a] {
137
+ .ev-chart__loading[data-v-0c5d944e] {
138
138
  position: absolute;
139
139
  inset: 0;
140
140
  border-radius: inherit;
@@ -143,20 +143,20 @@
143
143
  justify-content: center;
144
144
  background: var(--ev-bg-color, #ffffff);
145
145
  }
146
- .ev-chart__loading-bars[data-v-ce034a2a] {
146
+ .ev-chart__loading-bars[data-v-0c5d944e] {
147
147
  display: flex;
148
148
  align-items: flex-end;
149
149
  gap: 6px;
150
150
  height: 60px;
151
151
  }
152
- .ev-chart__loading-bar[data-v-ce034a2a] {
152
+ .ev-chart__loading-bar[data-v-0c5d944e] {
153
153
  width: 10px;
154
154
  height: 40px;
155
155
  border-radius: 4px;
156
156
  background: var(--ev-border-color, #e2e8f0);
157
- animation: ev-chart-shimmer-ce034a2a 1.2s ease-in-out infinite;
157
+ animation: ev-chart-shimmer-0c5d944e 1.2s ease-in-out infinite;
158
158
  }
159
- @keyframes ev-chart-shimmer-ce034a2a {
159
+ @keyframes ev-chart-shimmer-0c5d944e {
160
160
  0%,
161
161
  100% {
162
162
  transform: scaleY(0.4);
@@ -169,7 +169,7 @@
169
169
  }
170
170
 
171
171
  /* ══════ 错误边界 ══════ */
172
- .ev-chart__error[data-v-ce034a2a] {
172
+ .ev-chart__error[data-v-0c5d944e] {
173
173
  position: absolute;
174
174
  inset: 0;
175
175
  border-radius: inherit;
@@ -183,13 +183,13 @@
183
183
  padding: 20px;
184
184
  text-align: center;
185
185
  }
186
- .ev-chart__error-icon[data-v-ce034a2a] {
186
+ .ev-chart__error-icon[data-v-0c5d944e] {
187
187
  display: flex;
188
188
  align-items: center;
189
189
  justify-content: center;
190
190
  font-size: 30px;
191
191
  }
192
- .ev-chart__error-text[data-v-ce034a2a] {
192
+ .ev-chart__error-text[data-v-0c5d944e] {
193
193
  font-size: 12px;
194
194
  line-height: 1.5;
195
195
  max-width: 80%;
@@ -197,17 +197,17 @@
197
197
  }
198
198
 
199
199
  /* ══════ 进入/离开淡入淡出 ══════ */
200
- .ev-chart-fade-enter-active[data-v-ce034a2a],
201
- .ev-chart-fade-leave-active[data-v-ce034a2a] {
200
+ .ev-chart-fade-enter-active[data-v-0c5d944e],
201
+ .ev-chart-fade-leave-active[data-v-0c5d944e] {
202
202
  transition: opacity 0.2s ease;
203
203
  }
204
- .ev-chart-fade-enter-from[data-v-ce034a2a],
205
- .ev-chart-fade-leave-to[data-v-ce034a2a] {
204
+ .ev-chart-fade-enter-from[data-v-0c5d944e],
205
+ .ev-chart-fade-leave-to[data-v-0c5d944e] {
206
206
  opacity: 0;
207
207
  }
208
208
 
209
209
  /* ══════ 无障碍:sr-only ══════ */
210
- .ev-chart__sr-only[data-v-ce034a2a] {
210
+ .ev-chart__sr-only[data-v-0c5d944e] {
211
211
  position: absolute;
212
212
  width: 1px;
213
213
  height: 1px;
package/dist/index.mjs CHANGED
@@ -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);
@@ -1493,6 +1536,12 @@ function collectLegendItems(options, theme, hiddenSeries) {
1493
1536
  color: d.color || theme.colors[i % theme.colors.length],
1494
1537
  hidden: hiddenSeries.has(d.label || "")
1495
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
+ }));
1496
1545
  return (options.series || []).map((s, i) => ({
1497
1546
  name: s.name,
1498
1547
  label: fmt(s.name),
@@ -1671,12 +1720,69 @@ function renderToolbox(ctx, hoveredFeature) {
1671
1720
  canvasCtx.restore();
1672
1721
  });
1673
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
+ }
1674
1784
  //#endregion
1675
1785
  //#region src/renderer/charts-basic.js
1676
- var PIE_RADIAL_LEN = 14;
1677
- var PIE_STUB_LEN = 12;
1678
- var PIE_TEXT_GAP = 5;
1679
- var PIE_MAX_SHIFT = 10;
1680
1786
  function computePieMaxRadius(pieData, options, plotArea) {
1681
1787
  const total = pieData.reduce((sum, d) => sum + d.value, 0);
1682
1788
  if (total <= 0) return 30;
@@ -2295,70 +2401,21 @@ function renderPieChart(ctx, isDoughnut, isRose = false) {
2295
2401
  if (progress > .9) {
2296
2402
  const percentage = (data.value / total * 100).toFixed(1);
2297
2403
  const text = percentMode ? `${percentage}%` : `${data.label || ""} (${percentage}%)`;
2298
- const r = isRose ? roseRadius : maxRadius;
2299
2404
  labelItems.push({
2300
2405
  angle: midAngle,
2301
- r,
2302
- color,
2303
- text,
2304
- y: centerY + Math.sin(midAngle) * (r + PIE_RADIAL_LEN),
2305
- visible: true
2406
+ r: isRose ? roseRadius : maxRadius,
2407
+ text
2306
2408
  });
2307
2409
  }
2308
2410
  startAngle = endAngle;
2309
2411
  });
2310
- if (labelItems.length > 0 && progress > .9) {
2311
- const minGap = 16;
2312
- const topLimit = plotArea.y + 4;
2313
- const bottomLimit = plotArea.y + plotArea.height - 4;
2314
- const layoutSide = (items, sign) => {
2315
- const sorted = [...items].sort((a, b) => a.y - b.y);
2316
- let lastY = -Infinity;
2317
- sorted.forEach((it) => {
2318
- let y = it.y;
2319
- if (y < lastY + minGap) {
2320
- const shifted = lastY + minGap;
2321
- if (shifted - it.y <= PIE_MAX_SHIFT && shifted <= bottomLimit) y = shifted;
2322
- else {
2323
- it.visible = false;
2324
- return;
2325
- }
2326
- }
2327
- if (y > bottomLimit || y < topLimit) {
2328
- it.visible = false;
2329
- return;
2330
- }
2331
- it.y = y;
2332
- lastY = y;
2333
- });
2334
- sorted.forEach((it) => {
2335
- if (!it.visible) return;
2336
- const dirX = Math.cos(it.angle);
2337
- const dirY = Math.sin(it.angle);
2338
- const p1x = centerX + dirX * (it.r + PIE_RADIAL_LEN);
2339
- const p1y = centerY + dirY * (it.r + PIE_RADIAL_LEN);
2340
- const p2x = p1x + sign * PIE_STUB_LEN;
2341
- canvasCtx.save();
2342
- canvasCtx.strokeStyle = theme.textColorSecondary;
2343
- canvasCtx.lineWidth = 1;
2344
- canvasCtx.beginPath();
2345
- canvasCtx.moveTo(centerX + dirX * it.r, centerY + dirY * it.r);
2346
- canvasCtx.lineTo(p1x, p1y);
2347
- canvasCtx.lineTo(p2x, it.y);
2348
- canvasCtx.stroke();
2349
- canvasCtx.restore();
2350
- canvasCtx.save();
2351
- canvasCtx.fillStyle = theme.textColor;
2352
- canvasCtx.font = "12px Inter, sans-serif";
2353
- canvasCtx.textAlign = sign === 1 ? "left" : "right";
2354
- canvasCtx.textBaseline = "middle";
2355
- canvasCtx.fillText(it.text, p2x + sign * PIE_TEXT_GAP, it.y);
2356
- canvasCtx.restore();
2357
- });
2358
- };
2359
- layoutSide(labelItems.filter((it) => Math.cos(it.angle) >= 0), 1);
2360
- layoutSide(labelItems.filter((it) => Math.cos(it.angle) < 0), -1);
2361
- }
2412
+ if (labelItems.length > 0 && progress > .9) drawCalloutLabels(labelItems, {
2413
+ canvasCtx,
2414
+ centerX,
2415
+ centerY,
2416
+ plotArea,
2417
+ theme
2418
+ });
2362
2419
  if (isDoughnut && options.gauge?.value !== void 0) {
2363
2420
  canvasCtx.save();
2364
2421
  canvasCtx.fillStyle = theme.textColor;
@@ -2401,6 +2458,10 @@ function renderScatterChart(ctx, yRange) {
2401
2458
  const points = [];
2402
2459
  const yFactor = Math.min(ctx.progress * 1.2, 1);
2403
2460
  scatterData.forEach((point, i) => {
2461
+ if (ctx.hiddenSeries.has(point.label || `P${i + 1}`)) {
2462
+ points.push([NaN, NaN]);
2463
+ return;
2464
+ }
2404
2465
  const x = scale.toX(point.x);
2405
2466
  const finalY = scale.toY(point.y);
2406
2467
  const animatedY = plotArea.y + plotArea.height - (plotArea.y + plotArea.height - finalY) * yFactor;
@@ -2553,6 +2614,20 @@ function renderRadarChart(ctx) {
2553
2614
  canvasCtx.stroke();
2554
2615
  }
2555
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
+ }
2556
2631
  canvasCtx.save();
2557
2632
  canvasCtx.fillStyle = theme.textColorSecondary;
2558
2633
  canvasCtx.font = "12px Inter, sans-serif";
@@ -2606,7 +2681,15 @@ function renderRadarChart(ctx) {
2606
2681
  else canvasCtx.lineTo(p[0], p[1]);
2607
2682
  });
2608
2683
  canvasCtx.closePath();
2609
- 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
+ }
2610
2693
  canvasCtx.fill();
2611
2694
  canvasCtx.restore();
2612
2695
  }
@@ -2619,15 +2702,17 @@ function renderRadarChart(ctx) {
2619
2702
  });
2620
2703
  canvasCtx.closePath();
2621
2704
  canvasCtx.strokeStyle = color;
2622
- canvasCtx.lineWidth = hoverIndex >= 0 ? 2.5 : 2;
2705
+ canvasCtx.lineWidth = hoverIndex >= 0 ? 2.5 : 1.5;
2623
2706
  canvasCtx.stroke();
2624
2707
  canvasCtx.restore();
2625
2708
  canvasCtx.save();
2626
2709
  canvasCtx.globalAlpha = alpha;
2710
+ const showSymbol = s.showSymbol === true;
2627
2711
  points.forEach((p, i) => {
2628
2712
  const isHover = i === hoverIndex;
2713
+ if (!showSymbol && !isHover) return;
2629
2714
  canvasCtx.beginPath();
2630
- 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);
2631
2716
  canvasCtx.fillStyle = isHover ? theme.backgroundColor : color;
2632
2717
  canvasCtx.fill();
2633
2718
  canvasCtx.strokeStyle = color;
@@ -2662,11 +2747,14 @@ function renderFunnelChart(ctx) {
2662
2747
  const stepHeight = (plotArea.y + plotArea.height - 8 - topY) / funnelData.length;
2663
2748
  const maxValue = Math.max(...funnelData.map((d) => d.value));
2664
2749
  const drawData = options.pyramid === true ? [...funnelData].reverse() : funnelData;
2750
+ const labelAlpha = Math.min(1, Math.max(0, (progress - .72) / .28));
2751
+ const outsideLabels = [];
2665
2752
  drawData.forEach((data, i) => {
2666
2753
  const color = data.color || theme.colors[funnelData.indexOf(data) % theme.colors.length];
2667
2754
  const isHover = i === hoverIndex;
2668
- const currentWidth = data.value / maxValue * maxWidth * progress;
2669
- 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;
2670
2758
  const y = topY + i * stepHeight;
2671
2759
  const yOffset = isHover ? -3 : 0;
2672
2760
  canvasCtx.save();
@@ -2681,48 +2769,67 @@ function renderFunnelChart(ctx) {
2681
2769
  canvasCtx.strokeStyle = theme.backgroundColor;
2682
2770
  canvasCtx.lineWidth = 2;
2683
2771
  canvasCtx.stroke();
2684
- if (progress > .8) {
2685
- const labelY = y + stepHeight / 2 + yOffset;
2686
- const percentage = (data.value / drawData[0].value * 100).toFixed(1);
2687
- const label = `${data.label}`;
2688
- 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;
2689
2787
  canvasCtx.font = "bold 13px Inter, sans-serif";
2690
- const labelWidth = canvasCtx.measureText(label).width;
2788
+ canvasCtx.textAlign = "center";
2789
+ canvasCtx.textBaseline = "middle";
2790
+ canvasCtx.fillText(label, centerX, labelY - 9);
2691
2791
  canvasCtx.font = "11px Inter, sans-serif";
2692
- const valueWidth = canvasCtx.measureText(valueText).width;
2693
- const minTextWidth = Math.max(labelWidth, valueWidth) + 16;
2694
- if ((currentWidth + nextWidth) / 2 >= minTextWidth) {
2695
- canvasCtx.fillStyle = theme.backgroundColor;
2696
- canvasCtx.font = "bold 13px Inter, sans-serif";
2697
- canvasCtx.textAlign = "center";
2698
- canvasCtx.textBaseline = "middle";
2699
- canvasCtx.fillText(label, centerX, labelY - 8);
2700
- canvasCtx.font = "11px Inter, sans-serif";
2701
- canvasCtx.fillText(valueText, centerX, labelY + 8);
2702
- } else {
2703
- const lineEndX = centerX + currentWidth / 2;
2704
- const textX = plotArea.x + plotArea.width - 4;
2705
- canvasCtx.save();
2706
- canvasCtx.strokeStyle = theme.textColorSecondary;
2707
- canvasCtx.lineWidth = 1;
2708
- canvasCtx.setLineDash([3, 3]);
2709
- canvasCtx.beginPath();
2710
- canvasCtx.moveTo(lineEndX, labelY);
2711
- canvasCtx.lineTo(textX - 4, labelY);
2712
- canvasCtx.stroke();
2713
- canvasCtx.setLineDash([]);
2714
- canvasCtx.restore();
2715
- canvasCtx.fillStyle = theme.textColor;
2716
- canvasCtx.font = "bold 12px Inter, sans-serif";
2717
- canvasCtx.textAlign = "right";
2718
- canvasCtx.textBaseline = "bottom";
2719
- canvasCtx.fillText(label, textX, labelY - 1);
2720
- canvasCtx.font = "11px Inter, sans-serif";
2721
- canvasCtx.fillStyle = theme.textColorSecondary;
2722
- canvasCtx.textBaseline = "top";
2723
- canvasCtx.fillText(valueText, textX, labelY + 1);
2724
- }
2725
- }
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);
2726
2833
  canvasCtx.restore();
2727
2834
  });
2728
2835
  }
@@ -3206,42 +3313,40 @@ function squarifyTreemap(nodes, x, y, w, h, depth, result) {
3206
3313
  });
3207
3314
  return result;
3208
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
+ }
3209
3319
  function renderTreemapChart(ctx) {
3210
- const { ctx: canvasCtx, theme, plotArea, options, progress, hoverIndex, valueFormatter } = ctx;
3320
+ const { ctx: canvasCtx, theme, plotArea, options, progress, hoverIndex, valueFormatter, hoverAnimProgress = 1 } = ctx;
3211
3321
  const rootData = options.treemapData || [];
3212
3322
  if (rootData.length === 0) return;
3213
3323
  const rects = [];
3214
3324
  squarifyTreemap(rootData, plotArea.x, plotArea.y, plotArea.width, plotArea.height, 0, rects);
3215
3325
  const totalValue = rootData.reduce((s, n) => s + treemapValueOf(n), 0);
3216
3326
  const scale = progress;
3217
- 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) {
3218
3330
  const cx = rect.x + rect.w / 2;
3219
3331
  const cy = rect.y + rect.h / 2;
3220
3332
  const aw = rect.w * scale;
3221
3333
  const ah = rect.h * scale;
3222
3334
  const ax = cx - aw / 2;
3223
3335
  const ay = cy - ah / 2;
3224
- 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;
3225
3338
  canvasCtx.save();
3226
- 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");
3227
3341
  roundRect(canvasCtx, ax, ay, Math.max(0, aw), Math.max(0, ah), 3);
3228
3342
  canvasCtx.fill();
3229
- if (!isHover) {
3230
- canvasCtx.strokeStyle = theme.backgroundColor;
3231
- canvasCtx.lineWidth = 2;
3232
- canvasCtx.stroke();
3233
- } else {
3234
- canvasCtx.shadowColor = color;
3235
- canvasCtx.shadowBlur = 10;
3236
- canvasCtx.strokeStyle = theme.textColor;
3237
- canvasCtx.lineWidth = 2.5;
3238
- canvasCtx.stroke();
3239
- canvasCtx.shadowBlur = 0;
3240
- canvasCtx.stroke();
3241
- }
3343
+ canvasCtx.strokeStyle = theme.backgroundColor;
3344
+ canvasCtx.lineWidth = 2;
3345
+ canvasCtx.stroke();
3242
3346
  canvasCtx.restore();
3243
3347
  if (progress > .8 && aw > 50 && ah > 24) {
3244
3348
  canvasCtx.save();
3349
+ canvasCtx.globalAlpha = !focus || isRectWithinFocus(rect, focus) ? 1 : dimAlpha;
3245
3350
  canvasCtx.fillStyle = getContrastText(color);
3246
3351
  if (rect.node.children && rect.node.children.length > 0) {
3247
3352
  canvasCtx.font = "bold 11px Inter, sans-serif";
@@ -3262,10 +3367,7 @@ function renderTreemapChart(ctx) {
3262
3367
  canvasCtx.restore();
3263
3368
  }
3264
3369
  }
3265
- rects.forEach((rect, i) => {
3266
- if (i !== hoverIndex) drawRect(rect, false);
3267
- });
3268
- if (hoverIndex >= 0 && hoverIndex < rects.length) drawRect(rects[hoverIndex], true);
3370
+ rects.forEach((rect) => drawRect(rect, focus === rect));
3269
3371
  }
3270
3372
  function renderSparklineChart(ctx) {
3271
3373
  const { ctx: canvasCtx, theme, plotArea, options, progress, hoverIndex } = ctx;
@@ -3545,12 +3647,26 @@ function computeSunburstDepth(nodes) {
3545
3647
  });
3546
3648
  return max;
3547
3649
  }
3548
- function layoutSunburst(nodes, r0, ringWidth, depth, startAngle, colorOffset, result) {
3549
- 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);
3550
3664
  if (total <= 0) return result;
3551
3665
  let a = startAngle;
3552
3666
  nodes.forEach((n, i) => {
3553
- 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;
3554
3670
  result.push({
3555
3671
  startAngle: a,
3556
3672
  endAngle: a + span,
@@ -3558,25 +3674,72 @@ function layoutSunburst(nodes, r0, ringWidth, depth, startAngle, colorOffset, re
3558
3674
  r0,
3559
3675
  r1: r0 + ringWidth,
3560
3676
  node: n,
3677
+ value: sunburstValue(n),
3561
3678
  depth,
3562
- colorIndex: (colorOffset + i) % 8
3679
+ colorIndex: slot,
3680
+ parentIndex
3563
3681
  });
3564
- 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);
3565
3683
  a += span;
3566
3684
  });
3567
3685
  return result;
3568
3686
  }
3569
- function renderSunburstChart(ctx) {
3570
- 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) {
3571
3708
  const data = options.sunburstData || [];
3572
- if (data.length === 0) return;
3709
+ const depthCount = computeSunburstDepth(data);
3710
+ if (data.length === 0 || depthCount === 0) return null;
3573
3711
  const centerX = plotArea.x + plotArea.width / 2;
3574
3712
  const centerY = plotArea.y + plotArea.height / 2;
3575
- const maxR = Math.max(40, Math.min(plotArea.width, plotArea.height) / 2 - 10);
3576
- const innerHole = Math.max(0, maxR * .18);
3577
- const depthCount = computeSunburstDepth(data);
3578
- 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;
3579
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 = [];
3580
3743
  canvasCtx.save();
3581
3744
  canvasCtx.beginPath();
3582
3745
  canvasCtx.moveTo(centerX, centerY);
@@ -3584,45 +3747,58 @@ function renderSunburstChart(ctx) {
3584
3747
  canvasCtx.closePath();
3585
3748
  canvasCtx.clip();
3586
3749
  segments.forEach((seg, i) => {
3587
- const color = seg.node.color || theme.colors[seg.colorIndex % theme.colors.length];
3588
- const isHover = i === hoverIndex;
3750
+ const color = i === focusIndex ? mixColor(seg.color, .08, "#000000") : seg.color;
3589
3751
  canvasCtx.save();
3752
+ canvasCtx.globalAlpha = alphaAt(i);
3590
3753
  canvasCtx.beginPath();
3591
- canvasCtx.arc(centerX, centerY, seg.r1, seg.startAngle, seg.endAngle);
3592
- 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);
3593
3756
  canvasCtx.closePath();
3594
- canvasCtx.fillStyle = isHover ? color : color + (seg.depth === 0 ? "" : "dd");
3757
+ canvasCtx.fillStyle = color;
3595
3758
  canvasCtx.fill();
3596
3759
  canvasCtx.strokeStyle = theme.backgroundColor;
3597
3760
  canvasCtx.lineWidth = 2;
3598
3761
  canvasCtx.stroke();
3599
- if (isHover) {
3600
- canvasCtx.strokeStyle = theme.textColor;
3601
- canvasCtx.lineWidth = 2;
3602
- canvasCtx.stroke();
3603
- }
3604
3762
  canvasCtx.restore();
3763
+ if (progress <= .9) return;
3764
+ const name = seg.node.name || "";
3765
+ if (!name) return;
3605
3766
  const span = seg.endAngle - seg.startAngle;
3606
3767
  const midR = (seg.r0 + seg.r1) / 2;
3607
3768
  const chord = midR * span;
3608
- const name = seg.node.name || "";
3609
- if (progress > .9 && span > .12 && chord > estimateTextWidth(name, 11) + 8 && seg.r1 - seg.r0 > 13) {
3610
- const lx = centerX + Math.cos(seg.midAngle) * midR;
3611
- const ly = centerY + Math.sin(seg.midAngle) * midR;
3612
- canvasCtx.save();
3613
- canvasCtx.fillStyle = getContrastText(color);
3614
- canvasCtx.font = seg.depth === 0 ? "bold 12px Inter, sans-serif" : "11px Inter, sans-serif";
3615
- canvasCtx.textAlign = "center";
3616
- canvasCtx.textBaseline = "middle";
3617
- canvasCtx.fillText(name, lx, ly - (options.showValues ? 6 : 0));
3618
- if (options.showValues && seg.r1 - seg.r0 > 26) {
3619
- canvasCtx.font = "10px Inter, sans-serif";
3620
- canvasCtx.fillText(valueFormatter(seg.node.value), lx, ly + 8);
3621
- }
3622
- canvasCtx.restore();
3623
- }
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
+ });
3624
3782
  });
3625
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
+ });
3626
3802
  }
3627
3803
  function renderMixedChart(ctx, leftRange, rightRange) {
3628
3804
  const { ctx: canvasCtx, theme, plotArea, options, progress, hoverIndex, hiddenSeries } = ctx;
@@ -4480,6 +4656,10 @@ var chartOptionsSchema = {
4480
4656
  enum: ["left", "right"]
4481
4657
  },
4482
4658
  type: { type: "string" },
4659
+ chartType: {
4660
+ type: "string",
4661
+ enum: ["bar", "line"]
4662
+ },
4483
4663
  area: { type: "boolean" }
4484
4664
  }
4485
4665
  }
@@ -5334,9 +5514,24 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5334
5514
  hoverAnimFrameId = null;
5335
5515
  }
5336
5516
  }
5337
- function startHoverAnimation(dir) {
5517
+ function usesHoverAnimation() {
5518
+ const t = props.options.type;
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;
5338
5524
  const t = props.options.type;
5339
- if (t !== "pie" && t !== "doughnut" && t !== "rose") return;
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;
5340
5535
  if (props.options.animation?.enabled === false) {
5341
5536
  hoverAnimProgress = dir > 0 ? 1 : 0;
5342
5537
  redraw();
@@ -5377,7 +5572,8 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5377
5572
  "doughnut",
5378
5573
  "rose",
5379
5574
  "radar",
5380
- "funnel"
5575
+ "funnel",
5576
+ "scatter"
5381
5577
  ];
5382
5578
  function checkLegendHit(x, y) {
5383
5579
  const canvas = canvasRef.value;
@@ -5570,20 +5766,14 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5570
5766
  };
5571
5767
  }
5572
5768
  if (options.type === "sunburst") {
5573
- const data = options.sunburstData || [];
5574
- if (data.length === 0) return null;
5575
- const centerX = plotArea.x + plotArea.width / 2;
5576
- const centerY = plotArea.y + plotArea.height / 2;
5577
- const maxR = Math.max(40, Math.min(plotArea.width, plotArea.height) / 2 - 10);
5578
- const innerHole = Math.max(0, maxR * .18);
5579
- const depthCount = computeSunburstDepth(data);
5580
- const ringWidth = (maxR - innerHole) / depthCount;
5769
+ const geo = computeSunburstGeometry(plotArea, options, theme, formatTooltipValue);
5770
+ if (!geo) return null;
5771
+ const { centerX, centerY, segments } = geo;
5581
5772
  const dx = canvasX - centerX;
5582
5773
  const dy = canvasY - centerY;
5583
5774
  const distance = Math.sqrt(dx * dx + dy * dy);
5584
5775
  let hoverAngle = Math.atan2(dy, dx);
5585
5776
  if (hoverAngle < -Math.PI / 2) hoverAngle += Math.PI * 2;
5586
- const segments = layoutSunburst(data, innerHole, ringWidth, 0, -Math.PI / 2, 0, []);
5587
5777
  for (let i = 0; i < segments.length; i++) {
5588
5778
  const seg = segments[i];
5589
5779
  if (distance >= seg.r0 && distance <= seg.r1 && hoverAngle >= seg.startAngle && hoverAngle < seg.endAngle) return {
@@ -5591,8 +5781,8 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5591
5781
  params: {
5592
5782
  seriesName: seg.node.name,
5593
5783
  name: seg.node.name,
5594
- value: seg.node.value,
5595
- color: seg.node.color || theme.colors[seg.colorIndex % theme.colors.length],
5784
+ value: sunburstValue(seg.node),
5785
+ color: seg.color,
5596
5786
  dataIndex: i,
5597
5787
  seriesIndex: 0
5598
5788
  }
@@ -6211,12 +6401,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
6211
6401
  }
6212
6402
  }
6213
6403
  if (legendName) {
6214
- if (hoverIndex !== -1) {
6215
- hoverIndex = -1;
6216
- const t = props.options.type;
6217
- if (t === "pie" || t === "doughnut" || t === "rose") startHoverAnimation(-1);
6218
- else redraw();
6219
- }
6404
+ clearHover();
6220
6405
  tooltipVisible.value = false;
6221
6406
  return;
6222
6407
  }
@@ -6235,6 +6420,10 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
6235
6420
  hoverAnimProgress = .35;
6236
6421
  startHoverAnimation(1);
6237
6422
  }
6423
+ } else if (usesHoverAnimation()) {
6424
+ stopHoverAnimation();
6425
+ hoverAnimProgress = 1;
6426
+ redraw();
6238
6427
  } else redraw();
6239
6428
  if (prevIndex === -1) {
6240
6429
  const emitParams = Array.isArray(result.params) ? result.params[0] : result.params;
@@ -6259,10 +6448,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
6259
6448
  });
6260
6449
  tooltipVisible.value = true;
6261
6450
  } else {
6262
- if (hoverIndex !== -1) {
6263
- hoverIndex = -1;
6264
- if (isPieLike) startHoverAnimation(-1);
6265
- else redraw();
6451
+ if (clearHover()) {
6266
6452
  emit("unhover");
6267
6453
  ariaLiveText.value = "";
6268
6454
  }
@@ -6344,11 +6530,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
6344
6530
  redraw();
6345
6531
  }
6346
6532
  dragMode = "none";
6347
- if (hoverIndex !== -1) {
6348
- hoverIndex = -1;
6349
- const t = props.options.type;
6350
- if (t === "pie" || t === "doughnut" || t === "rose") startHoverAnimation(-1);
6351
- else redraw();
6533
+ if (clearHover()) {
6352
6534
  emit("unhover");
6353
6535
  ariaLiveText.value = "";
6354
6536
  }
@@ -6821,7 +7003,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
6821
7003
  ], 12, _hoisted_1);
6822
7004
  };
6823
7005
  }
6824
- }), [["__scopeId", "data-v-ce034a2a"]]);
7006
+ }), [["__scopeId", "data-v-0c5d944e"]]);
6825
7007
  //#endregion
6826
7008
  //#region src/useChart.js
6827
7009
  var USE_CHART_KEY = /* @__PURE__ */ Symbol("ev-chart-context");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wil-works/evoke-charts",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Evoke Charts — 零依赖 Canvas 自绘图表库(Vue3),Evoke 生态独立图表包",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",