@wil-works/evoke-charts 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Evoke Charts
2
2
 
3
- `@wil-works/evoke-charts` — 零依赖 Canvas 自绘图表库(Vue 3)。Evoke 生态的独立图表包,不依赖任何第三方图表引擎,也不依赖 Evoke 其他组件库;与 `@wil-works/evoke-business-ui` 同仓(monorepo)管理。
3
+ `@wil-works/evoke-charts` — 零依赖 Canvas 自绘图表库(Vue 3)。Evoke 生态的独立图表包,不依赖任何第三方图表引擎,也不依赖 Evoke 其他组件库。
4
+
5
+ **在线文档**:[evoke-charts.wil-works.com](https://evoke-charts.wil-works.com) — 全部图表类型、完整配置项与在线示例。
4
6
 
5
7
  ## 特性
6
8
 
@@ -56,9 +58,9 @@ const options = {
56
58
 
57
59
  ## 主题接入
58
60
 
59
- 图表颜色按以下 `--ev-*` 令牌实时读取(`document.documentElement` 上取值),任一缺失即整组回落到内置色板:
61
+ 图表颜色按以下 `--ev-*` 令牌实时读取(`document.documentElement` 上取值),未定义的槽位逐槽回落到内置成套色板:
60
62
 
61
- - 系列色:`--ev-color-primary / -success / -warning / -danger / -info`、`--ev-color-ext-violet / -cyan / -magenta`
63
+ - 系列色:`--ev-color-series-1` … `--ev-color-series-8`(数据系列专用色板,与状态语义色解耦;槽 1 未定义时回读 `--ev-color-primary`,主题跟随不受影响)
62
64
  - 表面:`--ev-bg-color`、`--ev-bg-color-overlay`、`--ev-app-card-border`
63
65
  - 文字:`--ev-text-color-primary / -secondary / -danger`
64
66
  - 边线:`--ev-border-color / -light / -dark`
@@ -67,12 +69,12 @@ const options = {
67
69
 
68
70
  暗色跟随约定:在 `html.dark` 选择器下重映射上述令牌(组件内部监听 `<html>` 的 `class` 变化自动重绘)。
69
71
 
70
- ### 与 evoke-business-ui 搭配
71
-
72
- `evoke-business-ui` 依赖本包并以 `EbChart`(模板 `<eb-chart>`)提供同一组件,其样式包内置了 `--eb-*` 令牌到 `--ev-*`(本包读取面)的映射,两库同用时图表自动跟随其主题、暗色与运行时换肤(其换肤时会派发 `ev-theme-change` 事件),无需额外配置。
72
+ 图表引擎对宿主保持中立:不为任何组件库做特殊配置;组件库或站点需要个性化时,走通用扩展接口——槽位令牌、`padding` / `theme` / `animation` 配置,以及 `applySeriesPalette` 配色方案工具。
73
73
 
74
74
  ## 组件 API(摘要)
75
75
 
76
+ 以下为常用项摘要,各图表类型的独立文档页与完整配置项见[在线文档](https://evoke-charts.wil-works.com/chart)。
77
+
76
78
  ### Props
77
79
 
78
80
  | 名称 | 类型 | 默认值 | 说明 |
@@ -103,13 +105,6 @@ const { options, chartProps, chartRef, resize, exportPNG, setTheme } = useChart(
103
105
  <EvChart v-bind="chartProps" ref="chartRef" />
104
106
  ```
105
107
 
106
- ## 开发(monorepo 内)
107
-
108
- ```bash
109
- pnpm --filter @wil-works/evoke-charts build # 铁律检查 + vite 库模式构建
110
- pnpm test # 仓库根统一 vitest
111
- ```
112
-
113
108
  ## License
114
109
 
115
110
  MIT
@@ -1,5 +1,5 @@
1
1
 
2
- .ev-chart[data-v-4ea20430] {
2
+ .ev-chart[data-v-a07b68e8] {
3
3
  position: relative;
4
4
  overflow: hidden;
5
5
  border-radius: 8px;
@@ -8,7 +8,7 @@
8
8
  }
9
9
 
10
10
  /* 关键:canvas 必须能接收鼠标事件 */
11
- .ev-chart__canvas[data-v-4ea20430] {
11
+ .ev-chart__canvas[data-v-a07b68e8] {
12
12
  position: absolute;
13
13
  top: 0;
14
14
  left: 0;
@@ -18,15 +18,15 @@
18
18
  /* 触摸:纵向留给页面滚动,横向拖拽由图表接管(dataZoom 平移) */
19
19
  touch-action: pan-y;
20
20
  }
21
- .ev-chart__tooltip[data-v-4ea20430] {
21
+ .ev-chart__tooltip[data-v-a07b68e8] {
22
22
  position: absolute;
23
23
  pointer-events: none;
24
24
  z-index: 100;
25
- padding: 10px 14px;
25
+ padding: 9px 12px;
26
26
  background: var(--ev-bg-color-overlay, #ffffff);
27
27
  border: 1px solid var(--ev-border-color, #e2e8f0);
28
- border-radius: 8px;
29
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
28
+ border-radius: 10px;
29
+ box-shadow: 0 6px 20px rgba(15, 23, 42, 0.12);
30
30
  font-size: 13px;
31
31
  line-height: 1.6;
32
32
  white-space: nowrap;
@@ -39,47 +39,47 @@
39
39
  }
40
40
 
41
41
  /* tooltip 淡入淡出(仅 opacity,位置由 left/top transition 负责) */
42
- .ev-chart-tooltip-enter-active[data-v-4ea20430],
43
- .ev-chart-tooltip-leave-active[data-v-4ea20430] {
42
+ .ev-chart-tooltip-enter-active[data-v-a07b68e8],
43
+ .ev-chart-tooltip-leave-active[data-v-a07b68e8] {
44
44
  transition: opacity 0.18s ease;
45
45
  }
46
46
 
47
47
  /* 进入期间禁用位置 transition,避免首次出现时从旧位置滑入 */
48
- .ev-chart-tooltip-enter-active[data-v-4ea20430] {
48
+ .ev-chart-tooltip-enter-active[data-v-a07b68e8] {
49
49
  transition:
50
50
  opacity 0.18s ease,
51
51
  left 0s,
52
52
  top 0s;
53
53
  }
54
- .ev-chart-tooltip-enter-from[data-v-4ea20430],
55
- .ev-chart-tooltip-leave-to[data-v-4ea20430] {
54
+ .ev-chart-tooltip-enter-from[data-v-a07b68e8],
55
+ .ev-chart-tooltip-leave-to[data-v-a07b68e8] {
56
56
  opacity: 0;
57
57
  }
58
58
 
59
59
  /* v-html 注入的内容不带 scoped 属性,需用 :deep() 穿透 */
60
- [data-v-4ea20430] .ev-chart__tooltip-title {
60
+ [data-v-a07b68e8] .ev-chart__tooltip-title {
61
61
  color: var(--ev-text-color-secondary, #6b7280);
62
62
  font-size: 12px;
63
63
  margin-bottom: 6px;
64
64
  font-weight: 500;
65
65
  }
66
- [data-v-4ea20430] .ev-chart__tooltip-item {
66
+ [data-v-a07b68e8] .ev-chart__tooltip-item {
67
67
  display: flex;
68
68
  align-items: center;
69
69
  gap: 8px;
70
70
  padding: 2px 0;
71
71
  }
72
- [data-v-4ea20430] .ev-chart__tooltip-dot {
72
+ [data-v-a07b68e8] .ev-chart__tooltip-dot {
73
73
  width: 8px;
74
74
  height: 8px;
75
75
  border-radius: 50%;
76
76
  flex-shrink: 0;
77
77
  display: inline-block;
78
78
  }
79
- [data-v-4ea20430] .ev-chart__tooltip-name {
79
+ [data-v-a07b68e8] .ev-chart__tooltip-name {
80
80
  color: var(--ev-text-color-primary, #111827);
81
81
  }
82
- [data-v-4ea20430] .ev-chart__tooltip-value {
82
+ [data-v-a07b68e8] .ev-chart__tooltip-value {
83
83
  color: var(--ev-text-color-primary, #111827);
84
84
  font-weight: 600;
85
85
  margin-left: auto;
@@ -88,7 +88,7 @@
88
88
  }
89
89
 
90
90
  /* ══════ 空数据占位 ══════ */
91
- .ev-chart__empty[data-v-4ea20430] {
91
+ .ev-chart__empty[data-v-a07b68e8] {
92
92
  position: absolute;
93
93
  inset: 0;
94
94
  display: flex;
@@ -100,7 +100,7 @@
100
100
  gap: 14px;
101
101
  user-select: none;
102
102
  }
103
- .ev-chart__empty-icon-wrap[data-v-4ea20430] {
103
+ .ev-chart__empty-icon-wrap[data-v-a07b68e8] {
104
104
  display: flex;
105
105
  align-items: center;
106
106
  justify-content: center;
@@ -109,19 +109,19 @@
109
109
  border-radius: 16px;
110
110
  background: var(--ev-fill-color-light, #f5f7fa);
111
111
  }
112
- .ev-chart__empty-img[data-v-4ea20430] {
112
+ .ev-chart__empty-img[data-v-a07b68e8] {
113
113
  width: 32px;
114
114
  height: 32px;
115
115
  opacity: 0.4;
116
116
  }
117
- .ev-chart__empty-text[data-v-4ea20430] {
117
+ .ev-chart__empty-text[data-v-a07b68e8] {
118
118
  letter-spacing: 0.04em;
119
119
  font-weight: 500;
120
120
  font-size: 13px;
121
121
  }
122
122
 
123
123
  /* ══════ 加载中骨架 ══════ */
124
- .ev-chart__loading[data-v-4ea20430] {
124
+ .ev-chart__loading[data-v-a07b68e8] {
125
125
  position: absolute;
126
126
  inset: 0;
127
127
  display: flex;
@@ -129,20 +129,20 @@
129
129
  justify-content: center;
130
130
  background: var(--ev-bg-color, #ffffff);
131
131
  }
132
- .ev-chart__loading-bars[data-v-4ea20430] {
132
+ .ev-chart__loading-bars[data-v-a07b68e8] {
133
133
  display: flex;
134
134
  align-items: flex-end;
135
135
  gap: 6px;
136
136
  height: 60px;
137
137
  }
138
- .ev-chart__loading-bar[data-v-4ea20430] {
138
+ .ev-chart__loading-bar[data-v-a07b68e8] {
139
139
  width: 10px;
140
140
  height: 40px;
141
141
  border-radius: 4px;
142
142
  background: var(--ev-border-color, #e2e8f0);
143
- animation: ev-chart-shimmer-4ea20430 1.2s ease-in-out infinite;
143
+ animation: ev-chart-shimmer-a07b68e8 1.2s ease-in-out infinite;
144
144
  }
145
- @keyframes ev-chart-shimmer-4ea20430 {
145
+ @keyframes ev-chart-shimmer-a07b68e8 {
146
146
  0%,
147
147
  100% {
148
148
  transform: scaleY(0.4);
@@ -155,7 +155,7 @@
155
155
  }
156
156
 
157
157
  /* ══════ 错误边界 ══════ */
158
- .ev-chart__error[data-v-4ea20430] {
158
+ .ev-chart__error[data-v-a07b68e8] {
159
159
  position: absolute;
160
160
  inset: 0;
161
161
  display: flex;
@@ -168,11 +168,13 @@
168
168
  padding: 20px;
169
169
  text-align: center;
170
170
  }
171
- .ev-chart__error-icon[data-v-4ea20430] {
172
- font-size: 28px;
173
- line-height: 1;
171
+ .ev-chart__error-icon[data-v-a07b68e8] {
172
+ display: flex;
173
+ align-items: center;
174
+ justify-content: center;
175
+ font-size: 30px;
174
176
  }
175
- .ev-chart__error-text[data-v-4ea20430] {
177
+ .ev-chart__error-text[data-v-a07b68e8] {
176
178
  font-size: 12px;
177
179
  line-height: 1.5;
178
180
  max-width: 80%;
@@ -180,17 +182,17 @@
180
182
  }
181
183
 
182
184
  /* ══════ 进入/离开淡入淡出 ══════ */
183
- .ev-chart-fade-enter-active[data-v-4ea20430],
184
- .ev-chart-fade-leave-active[data-v-4ea20430] {
185
+ .ev-chart-fade-enter-active[data-v-a07b68e8],
186
+ .ev-chart-fade-leave-active[data-v-a07b68e8] {
185
187
  transition: opacity 0.2s ease;
186
188
  }
187
- .ev-chart-fade-enter-from[data-v-4ea20430],
188
- .ev-chart-fade-leave-to[data-v-4ea20430] {
189
+ .ev-chart-fade-enter-from[data-v-a07b68e8],
190
+ .ev-chart-fade-leave-to[data-v-a07b68e8] {
189
191
  opacity: 0;
190
192
  }
191
193
 
192
194
  /* ══════ 无障碍:sr-only ══════ */
193
- .ev-chart__sr-only[data-v-4ea20430] {
195
+ .ev-chart__sr-only[data-v-a07b68e8] {
194
196
  position: absolute;
195
197
  width: 1px;
196
198
  height: 1px;
package/dist/index.mjs CHANGED
@@ -16,18 +16,20 @@ var DEFAULT_I18N_ZH = {
16
16
  noData: "暂无数据"
17
17
  };
18
18
  /**
19
- * 系列色令牌表 — 图表分类色板从 CSS 令牌实时读取(换主题/暗色即跟随),
19
+ * 系列色令牌槽位 — 每槽按顺序尝试读取专用数据色令牌(--ev-color-series-N),
20
+ * 槽 1 回退 --ev-color-primary(品牌主色跟随);
21
+ * 任一槽无令牌时该槽回落内置成套色板(与语义状态色解耦的数据色板)。
20
22
  * CHART_COLORS 仅作为 SSR / 无 DOM 环境的静态兜底。
21
23
  */
22
- var SERIES_COLOR_TOKENS = [
23
- "--ev-color-primary",
24
- "--ev-color-success",
25
- "--ev-color-warning",
26
- "--ev-color-ext-violet",
27
- "--ev-color-ext-cyan",
28
- "--ev-color-danger",
29
- "--ev-color-ext-magenta",
30
- "--ev-color-info"
24
+ var SERIES_COLOR_SLOTS = [
25
+ ["--ev-color-series-1", "--ev-color-primary"],
26
+ ["--ev-color-series-2"],
27
+ ["--ev-color-series-3"],
28
+ ["--ev-color-series-4"],
29
+ ["--ev-color-series-5"],
30
+ ["--ev-color-series-6"],
31
+ ["--ev-color-series-7"],
32
+ ["--ev-color-series-8"]
31
33
  ];
32
34
  function readChartToken(name, fallback) {
33
35
  try {
@@ -38,24 +40,29 @@ function readChartToken(name, fallback) {
38
40
  }
39
41
  }
40
42
  function getSeriesColors(fallbackColors) {
41
- const live = SERIES_COLOR_TOKENS.map((t) => readChartToken(t, ""));
42
- return live.some((v) => !v) ? [...fallbackColors] : live;
43
+ return SERIES_COLOR_SLOTS.map((tokens, i) => {
44
+ for (const t of tokens) {
45
+ const v = readChartToken(t, "");
46
+ if (v) return v;
47
+ }
48
+ return fallbackColors[i];
49
+ });
43
50
  }
44
51
  var CHART_COLORS = {
45
52
  /**
46
- * 主色板 — 对齐 evoke 设计令牌:
47
- * 前位使用功能色(primary/success/warning/danger/info),
48
- * 扩展色选取与深蓝体系协调的色相;相邻色相拉开对比度。
53
+ * 主色板 — 品牌蓝锚定的成套数据色板(与语义状态色解耦):
54
+ * 色相按取色顺序相邻差 ≥ 30°,明度带 44–68,青绿/金黄/天蓝/珊瑚/紫/石板
55
+ * 取材 AntV 经典系并经品牌蓝调协调;暗色板同色相提亮。
49
56
  */
50
57
  primary: [
51
58
  "#175DFF",
52
- "#16a34a",
53
- "#d97706",
54
- "#8b5cf6",
55
- "#0891b2",
56
- "#dc2626",
57
- "#db2777",
58
- "#64748b"
59
+ "#5AD8A6",
60
+ "#F6BD16",
61
+ "#6DC8EC",
62
+ "#E8684A",
63
+ "#9270CA",
64
+ "#FF9D4D",
65
+ "#5D7092"
59
66
  ],
60
67
  /** 浅色系列 — primary 的 light-9/light-8/light-7 阶梯 */
61
68
  light: [
@@ -63,16 +70,16 @@ var CHART_COLORS = {
63
70
  "#d0e1ff",
64
71
  "#b9d2ff"
65
72
  ],
66
- /** 暗色模式系列 — 对齐 evoke 暗色功能色令牌 */
73
+ /** 暗色模式系列 — 同色相提亮(对齐 evoke 暗色主色) */
67
74
  dark: [
68
75
  "#4d8bff",
69
- "#4ade80",
70
- "#fbbf24",
71
- "#a78bfa",
72
- "#22d3ee",
73
- "#f87171",
74
- "#f472b6",
75
- "#94a3b8"
76
+ "#5ad8a6",
77
+ "#f6bd16",
78
+ "#6dc8ec",
79
+ "#f08568",
80
+ "#a585e8",
81
+ "#ff9d4d",
82
+ "#8da3bf"
76
83
  ],
77
84
  /** 热力图默认色阶(天亮蓝由浅到深) */
78
85
  heatmapScale: [
@@ -292,6 +299,42 @@ function hasLegendContent(options) {
292
299
  function titleBlockHeight(options) {
293
300
  return (options.title ? 26 : 0) + (options.subtitle ? 18 : 0);
294
301
  }
302
+ /** options.padding 覆写:数字(四边一致)或 { top, right, bottom, left }(未提供的边回落默认值) */
303
+ function resolveUserPadding(options) {
304
+ const p = options.padding;
305
+ if (!p) return {};
306
+ if (typeof p === "number") return {
307
+ top: p,
308
+ right: p,
309
+ bottom: p,
310
+ left: p
311
+ };
312
+ return p;
313
+ }
314
+ /**
315
+ * y 轴左侧留白按刻度标签实测宽度自适应(替代固定 65):
316
+ * 取数据范围 → nice 刻度值 → 逐个测宽,留 26px(10 间隙 + 刻度短横 + 余量)。
317
+ * 对数轴/自定义 formatter 场景不可预估时回落 65;结果夹在 [40, 140]。
318
+ */
319
+ function estimateYAxisLeft(options) {
320
+ const axisConfig = options.yAxis || {};
321
+ if (axisConfig.type === "log") return 65;
322
+ try {
323
+ const range = calculateRange(options, /* @__PURE__ */ new Set(), false);
324
+ const min = axisConfig.min ?? range?.min;
325
+ const max = axisConfig.max ?? range?.max;
326
+ if (!Number.isFinite(min) || !Number.isFinite(max)) return 65;
327
+ const { tickValues } = resolveTickExtendedRange(min, max, axisConfig.ticks || 5);
328
+ const maxW = tickValues.reduce((m, t) => {
329
+ const label = axisConfig.formatter ? axisConfig.formatter(t) : Number.isInteger(t) ? t.toString() : parseFloat(t.toFixed(6)).toString();
330
+ return Math.max(m, estimateTextWidth(String(label), 12));
331
+ }, 0);
332
+ if (!maxW) return 65;
333
+ return Math.max(40, Math.min(140, Math.round(maxW + 26)));
334
+ } catch {
335
+ return 65;
336
+ }
337
+ }
295
338
  function getPadding(options, containerWidth = 600) {
296
339
  const legend = options.legend || {};
297
340
  const legendPosition = legend.show !== false && hasLegendContent(options) ? legend.position || "bottom" : "none";
@@ -311,12 +354,15 @@ function getPadding(options, containerWidth = 600) {
311
354
  const zoom = options.dataZoom;
312
355
  const hasDataZoom = !!zoom?.enabled && hasXAxis;
313
356
  const zoomHeight = (zoom?.height || 40) + 14;
314
- if (options.type === "sparkline") return {
315
- top: options.title ? 32 : 6,
316
- right: 6,
317
- bottom: 6,
318
- left: 6
319
- };
357
+ if (options.type === "sparkline") {
358
+ const sparkPad = resolveUserPadding(options);
359
+ return {
360
+ top: sparkPad.top ?? (options.title ? 32 : 6),
361
+ right: sparkPad.right ?? 6,
362
+ bottom: sparkPad.bottom ?? 6,
363
+ left: sparkPad.left ?? 6
364
+ };
365
+ }
320
366
  const legendRowsExtra = (() => {
321
367
  if (legendPosition !== "top" && legendPosition !== "bottom") return 0;
322
368
  return (estimateLegendRows(options, containerWidth) - 1) * 20;
@@ -335,13 +381,14 @@ function getPadding(options, containerWidth = 600) {
335
381
  "bin",
336
382
  "sunburst"
337
383
  ].includes(options.type)) {
338
- let top2 = 18 + titleBlockHeight(options);
339
- let bottom2 = 18;
340
- let left2 = 20;
341
- let right2 = 20;
384
+ const pad = resolveUserPadding(options);
385
+ let top2 = (pad.top ?? 18) + titleBlockHeight(options);
386
+ let bottom2 = pad.bottom ?? 18;
387
+ let left2 = pad.left ?? 20;
388
+ let right2 = pad.right ?? 20;
342
389
  if (options.type === "heatmap") {
343
390
  const maxYLabel = Array.from(new Set((options.heatmapData || []).map((d) => d.y))).reduce((m, s) => Math.max(m, estimateTextWidth(s, 11)), 0);
344
- left2 = Math.min(130, Math.max(24, maxYLabel + 14));
391
+ left2 = pad.left ?? Math.min(130, Math.max(24, maxYLabel + 14));
345
392
  bottom2 = Math.max(bottom2, 26);
346
393
  if (options.heatmap?.colorBar === true || options.heatmapColorBar === true) right2 += 44;
347
394
  }
@@ -350,7 +397,7 @@ function getPadding(options, containerWidth = 600) {
350
397
  (options.series || []).forEach((s) => allValues.push(...s.data.filter((v) => !isMissingValue(v))));
351
398
  const maxFreq = allValues.length;
352
399
  const tickLabelWidth = String(maxFreq).length * 7;
353
- left2 = Math.min(90, Math.max(36, tickLabelWidth + 16));
400
+ left2 = pad.left ?? Math.min(90, Math.max(36, tickLabelWidth + 16));
354
401
  bottom2 = Math.max(bottom2, 30);
355
402
  }
356
403
  if (options.type === "bullet") {
@@ -371,21 +418,43 @@ function getPadding(options, containerWidth = 600) {
371
418
  left: left2
372
419
  };
373
420
  }
374
- let top = 18 + titleBlockHeight(options) + (legendPosition === "top" ? LEGEND_BAND + legendRowsExtra : 0);
375
- let bottom = 46 + (legendPosition === "bottom" ? LEGEND_BAND + legendRowsExtra : 0);
376
- let left = 65;
377
- let right = hasYAxisRight ? 65 : 40;
421
+ const pad = resolveUserPadding(options);
422
+ const xAxisHidden = options.xAxis?.show === false;
423
+ let top = (pad.top ?? 18) + titleBlockHeight(options) + (legendPosition === "top" ? LEGEND_BAND + legendRowsExtra : 0);
424
+ 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);
378
427
  if (legendPosition === "left") left += 80;
379
428
  if (legendPosition === "right") right += 80;
380
- if (hasXAxis && hasXAxisTitle) bottom += 15;
429
+ if (hasXAxis && hasXAxisTitle) return {
430
+ top,
431
+ right,
432
+ bottom: bottom + 15,
433
+ left
434
+ };
381
435
  const xRotate = options.xAxis?.rotate || 0;
382
436
  if (hasXAxis && xRotate) {
383
437
  const maxLabelWidth = (options.labels || []).reduce((m, s) => Math.max(m, estimateTextWidth(String(s), 12)), 0);
384
- bottom += Math.min(90, 12 + maxLabelWidth * Math.sin(Math.min(60, Math.abs(xRotate)) * Math.PI / 180));
438
+ return {
439
+ top,
440
+ right,
441
+ bottom: bottom + Math.min(90, 12 + maxLabelWidth * Math.sin(Math.min(60, Math.abs(xRotate)) * Math.PI / 180)),
442
+ left
443
+ };
385
444
  }
386
445
  if (hasDataZoom) {
387
- if ((zoom?.position || "bottom") === "top") top += zoomHeight;
388
- else bottom += zoomHeight;
446
+ if ((zoom?.position || "bottom") === "top") return {
447
+ top: top + zoomHeight,
448
+ right,
449
+ bottom,
450
+ left
451
+ };
452
+ return {
453
+ top,
454
+ right,
455
+ bottom: bottom + zoomHeight,
456
+ left
457
+ };
389
458
  }
390
459
  return {
391
460
  top,
@@ -774,6 +843,14 @@ function renderYAxis(ctx, side = "left", dataRange) {
774
843
  else label = Number.isInteger(tick) ? tick.toString() : parseFloat(tick.toFixed(6)).toString();
775
844
  label = truncateLabel(canvasCtx, label, maxLabelWidth);
776
845
  canvasCtx.fillText(label, xPos, y);
846
+ if (side === "left") {
847
+ canvasCtx.strokeStyle = theme.gridColor;
848
+ canvasCtx.lineWidth = 1;
849
+ canvasCtx.beginPath();
850
+ canvasCtx.moveTo(plotArea.x - 6, y);
851
+ canvasCtx.lineTo(plotArea.x, y);
852
+ canvasCtx.stroke();
853
+ }
777
854
  });
778
855
  drawAxisLine(canvasCtx, axisConfig, theme, side === "left" ? plotArea.x : plotArea.x + plotArea.width, plotArea.y, side === "left" ? plotArea.x : plotArea.x + plotArea.width, plotArea.y + plotArea.height);
779
856
  if (axisConfig.title) {
@@ -1158,14 +1235,14 @@ function renderTitle(ctx, paddingTop) {
1158
1235
  if (!options.title) return;
1159
1236
  canvasCtx.save();
1160
1237
  canvasCtx.fillStyle = theme.textColor;
1161
- canvasCtx.font = "bold 16px Inter, sans-serif";
1162
- canvasCtx.textAlign = "center";
1238
+ canvasCtx.font = "600 13px Inter, sans-serif";
1239
+ canvasCtx.textAlign = "left";
1163
1240
  canvasCtx.textBaseline = "top";
1164
- canvasCtx.fillText(options.title, plotArea.x + plotArea.width / 2, paddingTop);
1241
+ canvasCtx.fillText(options.title, plotArea.x, paddingTop);
1165
1242
  if (options.subtitle) {
1166
- canvasCtx.font = "12px Inter, sans-serif";
1243
+ canvasCtx.font = "11px Inter, sans-serif";
1167
1244
  canvasCtx.fillStyle = theme.textColorSecondary;
1168
- canvasCtx.fillText(options.subtitle, plotArea.x + plotArea.width / 2, paddingTop + 22);
1245
+ canvasCtx.fillText(options.subtitle, plotArea.x, paddingTop + 20);
1169
1246
  }
1170
1247
  canvasCtx.restore();
1171
1248
  }
@@ -1284,12 +1361,12 @@ function renderLegend(ctx) {
1284
1361
  canvasCtx.fill();
1285
1362
  } else if (icon === "rect") canvasCtx.fillRect(bound.x, bound.y + 5, 12, 12);
1286
1363
  else {
1287
- roundRect(canvasCtx, bound.x, bound.y + 5, 12, 12, 2);
1364
+ roundRect(canvasCtx, bound.x, bound.y + 9.5, 12, 3, 1.5);
1288
1365
  canvasCtx.fill();
1289
1366
  }
1290
1367
  canvasCtx.restore();
1291
1368
  canvasCtx.save();
1292
- canvasCtx.fillStyle = isHidden ? theme.textColorSecondary : theme.textColor;
1369
+ canvasCtx.fillStyle = theme.textColorSecondary;
1293
1370
  canvasCtx.globalAlpha = isHidden ? .6 : 1;
1294
1371
  canvasCtx.textAlign = "left";
1295
1372
  canvasCtx.textBaseline = "middle";
@@ -1388,6 +1465,32 @@ function computePieMaxRadius(pieData, options, plotArea) {
1388
1465
  function alphaHex(opacity) {
1389
1466
  return Math.round(Math.max(0, Math.min(1, opacity)) * 255).toString(16).padStart(2, "0");
1390
1467
  }
1468
+ /** 单调三次插值切线(Fritsch–Carlson 限幅):平滑但不过冲,曲线必过每个数据点 */
1469
+ function monotoneTangents(pts) {
1470
+ const n = pts.length;
1471
+ const m = new Array(n).fill(0);
1472
+ const d = [];
1473
+ for (let i = 0; i < n - 1; i++) d.push((pts[i + 1][1] - pts[i][1]) / (pts[i + 1][0] - pts[i][0]));
1474
+ m[0] = d[0];
1475
+ m[n - 1] = d[n - 2];
1476
+ for (let i = 1; i < n - 1; i++) m[i] = d[i - 1] * d[i] <= 0 ? 0 : (d[i - 1] + d[i]) / 2;
1477
+ for (let i = 0; i < n - 1; i++) {
1478
+ if (d[i] === 0) {
1479
+ m[i] = 0;
1480
+ m[i + 1] = 0;
1481
+ continue;
1482
+ }
1483
+ const a = m[i] / d[i];
1484
+ const b = m[i + 1] / d[i];
1485
+ const s = a * a + b * b;
1486
+ if (s > 9) {
1487
+ const tk = 3 / Math.sqrt(s);
1488
+ m[i] = tk * a * d[i];
1489
+ m[i + 1] = tk * b * d[i];
1490
+ }
1491
+ }
1492
+ return m;
1493
+ }
1391
1494
  function renderLineChart(ctx, yRange, side = "left") {
1392
1495
  const { ctx: canvasCtx, theme, plotArea, options, progress, hoverIndex, hiddenSeries, valueFormatter } = ctx;
1393
1496
  const allPoints = [];
@@ -1443,16 +1546,25 @@ function renderLineChart(ctx, yRange, side = "left") {
1443
1546
  });
1444
1547
  if (segments.length === 0) return;
1445
1548
  const step = series.step || options.step;
1549
+ const smooth = !!(series.smooth ?? options.smooth);
1446
1550
  const tracePath = (pts, startMove) => {
1447
1551
  startMove(canvasCtx, pts[0]);
1552
+ if (smooth && pts.length > 2) {
1553
+ const m = monotoneTangents(pts);
1554
+ for (let i = 0; i < pts.length - 1; i++) {
1555
+ const x0 = pts[i][0];
1556
+ const y0 = pts[i][1];
1557
+ const x1 = pts[i + 1][0];
1558
+ const y1 = pts[i + 1][1];
1559
+ const h = (x1 - x0) / 3;
1560
+ canvasCtx.bezierCurveTo(x0 + h, y0 + h * m[i], x1 - h, y1 - h * m[i + 1], x1, y1);
1561
+ }
1562
+ return;
1563
+ }
1448
1564
  for (let i = 0; i < pts.length - 1; i++) {
1449
1565
  const curr = pts[i];
1450
1566
  const next = pts[i + 1];
1451
- if (series.smooth && pts.length > 2 && !step) {
1452
- const cp1x = curr[0] + (next[0] - curr[0]) / 3;
1453
- const cp2x = next[0] - (next[0] - curr[0]) / 3;
1454
- canvasCtx.bezierCurveTo(cp1x, curr[1], cp2x, next[1], next[0], next[1]);
1455
- } else if (step) {
1567
+ if (step) {
1456
1568
  if (step === "start") {
1457
1569
  canvasCtx.lineTo(next[0], curr[1]);
1458
1570
  canvasCtx.lineTo(next[0], next[1]);
@@ -1512,7 +1624,7 @@ function renderLineChart(ctx, yRange, side = "left") {
1512
1624
  }
1513
1625
  canvasCtx.beginPath();
1514
1626
  canvasCtx.strokeStyle = color;
1515
- canvasCtx.lineWidth = series.lineWidth || 2;
1627
+ canvasCtx.lineWidth = series.lineWidth || 1.5;
1516
1628
  canvasCtx.lineJoin = "round";
1517
1629
  canvasCtx.lineCap = "round";
1518
1630
  canvasCtx.setLineDash(resolveLineDash(series));
@@ -1520,7 +1632,7 @@ function renderLineChart(ctx, yRange, side = "left") {
1520
1632
  canvasCtx.stroke();
1521
1633
  canvasCtx.setLineDash([]);
1522
1634
  if (!series.area && options.type !== "area") canvasCtx.restore();
1523
- if (series.showSymbol !== false && series.symbol !== "none") {
1635
+ if (series.showSymbol === true && series.symbol !== "none") {
1524
1636
  const baseR = series.symbolSize ?? 4;
1525
1637
  points.forEach((p, i) => {
1526
1638
  if (!p) return;
@@ -1541,6 +1653,15 @@ function renderLineChart(ctx, yRange, side = "left") {
1541
1653
  canvasCtx.stroke();
1542
1654
  }
1543
1655
  });
1656
+ } else if (hoverIndex >= 0 && points[hoverIndex]) {
1657
+ const p = points[hoverIndex];
1658
+ canvasCtx.beginPath();
1659
+ canvasCtx.arc(p[0], p[1], 4.5, 0, Math.PI * 2);
1660
+ canvasCtx.fillStyle = theme.backgroundColor;
1661
+ canvasCtx.fill();
1662
+ canvasCtx.strokeStyle = color;
1663
+ canvasCtx.lineWidth = 2;
1664
+ canvasCtx.stroke();
1544
1665
  }
1545
1666
  if ((series.showValues !== void 0 ? series.showValues : options.showValues) && progress > .9) {
1546
1667
  canvasCtx.save();
@@ -3380,9 +3501,13 @@ function renderDataZoomSlider(ctx, zoom) {
3380
3501
  }
3381
3502
  const { xStart, xEnd } = windowToX(zoom, geo);
3382
3503
  const windowFill = theme.crosshairColor.includes("rgba") ? theme.crosshairColor.replace(/[\d.]+\)$/, "0.12)") : theme.crosshairColor + "1f";
3504
+ canvasCtx.save();
3505
+ roundRect(canvasCtx, geo.x, geo.y, geo.width, geo.height, 4);
3506
+ canvasCtx.clip();
3383
3507
  canvasCtx.fillStyle = theme.backgroundColor + "aa";
3384
3508
  canvasCtx.fillRect(geo.x, geo.y, xStart - geo.x, geo.height);
3385
3509
  canvasCtx.fillRect(xEnd, geo.y, geo.x + geo.width - xEnd, geo.height);
3510
+ canvasCtx.restore();
3386
3511
  canvasCtx.strokeStyle = theme.gridColor;
3387
3512
  canvasCtx.lineWidth = 1;
3388
3513
  roundRect(canvasCtx, geo.x, geo.y, geo.width, geo.height, 4);
@@ -3394,21 +3519,26 @@ function renderDataZoomSlider(ctx, zoom) {
3394
3519
  canvasCtx.lineWidth = 1;
3395
3520
  roundRect(canvasCtx, xStart, geo.y, Math.max(2, xEnd - xStart), geo.height, 4);
3396
3521
  canvasCtx.stroke();
3397
- const handleW = 8;
3522
+ const handleW = 9;
3398
3523
  const handleH = 18;
3399
3524
  const handleY = geo.y + (geo.height - handleH) / 2;
3400
3525
  [xStart - handleW / 2, xEnd - handleW / 2].forEach((hx) => {
3401
- roundRect(canvasCtx, hx, handleY, handleW, handleH, 3);
3402
- canvasCtx.fillStyle = theme.crosshairColor;
3526
+ roundRect(canvasCtx, hx, handleY, handleW, handleH, 4);
3527
+ canvasCtx.fillStyle = theme.backgroundColor;
3403
3528
  canvasCtx.fill();
3404
- canvasCtx.strokeStyle = theme.backgroundColor;
3405
- canvasCtx.lineWidth = 1;
3529
+ canvasCtx.strokeStyle = theme.crosshairColor;
3530
+ canvasCtx.lineWidth = 1.5;
3531
+ canvasCtx.stroke();
3532
+ canvasCtx.strokeStyle = theme.crosshairColor;
3533
+ canvasCtx.lineWidth = 1.5;
3534
+ canvasCtx.lineCap = "round";
3406
3535
  canvasCtx.beginPath();
3407
- canvasCtx.moveTo(hx + handleW / 2 - 2, handleY + 5);
3408
- canvasCtx.lineTo(hx + handleW / 2 - 2, handleY + handleH - 5);
3409
- canvasCtx.moveTo(hx + handleW / 2 + 2, handleY + 5);
3410
- canvasCtx.lineTo(hx + handleW / 2 + 2, handleY + handleH - 5);
3536
+ canvasCtx.moveTo(hx + handleW / 2 - 2, handleY + 6);
3537
+ canvasCtx.lineTo(hx + handleW / 2 - 2, handleY + handleH - 6);
3538
+ canvasCtx.moveTo(hx + handleW / 2 + 2, handleY + 6);
3539
+ canvasCtx.lineTo(hx + handleW / 2 + 2, handleY + handleH - 6);
3411
3540
  canvasCtx.stroke();
3541
+ canvasCtx.lineCap = "butt";
3412
3542
  });
3413
3543
  canvasCtx.restore();
3414
3544
  }
@@ -5854,7 +5984,35 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5854
5984
  _: 1
5855
5985
  }),
5856
5986
  createVNode(Transition, { name: "ev-chart-fade" }, {
5857
- default: withCtx(() => [internalError.value ? (openBlock(), createElementBlock("div", _hoisted_7, [_cache[0] || (_cache[0] = createElementVNode("span", { class: "ev-chart__error-icon" }, "⚠", -1)), createElementVNode("span", _hoisted_8, toDisplayString(internalError.value), 1)])) : createCommentVNode("", true)]),
5987
+ default: withCtx(() => [internalError.value ? (openBlock(), createElementBlock("div", _hoisted_7, [_cache[0] || (_cache[0] = createElementVNode("span", { class: "ev-chart__error-icon" }, [createElementVNode("svg", {
5988
+ viewBox: "0 0 24 24",
5989
+ width: "1em",
5990
+ height: "1em",
5991
+ "aria-hidden": "true",
5992
+ fill: "none",
5993
+ stroke: "currentColor",
5994
+ "stroke-width": "2",
5995
+ "stroke-linecap": "round",
5996
+ "stroke-linejoin": "round"
5997
+ }, [
5998
+ createElementVNode("circle", {
5999
+ cx: "12",
6000
+ cy: "12",
6001
+ r: "9"
6002
+ }),
6003
+ createElementVNode("line", {
6004
+ x1: "12",
6005
+ y1: "7.5",
6006
+ x2: "12",
6007
+ y2: "13"
6008
+ }),
6009
+ createElementVNode("line", {
6010
+ x1: "12",
6011
+ y1: "16.5",
6012
+ x2: "12",
6013
+ y2: "16.51"
6014
+ })
6015
+ ])], -1)), createElementVNode("span", _hoisted_8, toDisplayString(internalError.value), 1)])) : createCommentVNode("", true)]),
5858
6016
  _: 1
5859
6017
  }),
5860
6018
  createVNode(Transition, { name: "ev-chart-tooltip" }, {
@@ -5873,7 +6031,7 @@ var chart_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__
5873
6031
  ], 12, _hoisted_1);
5874
6032
  };
5875
6033
  }
5876
- }), [["__scopeId", "data-v-4ea20430"]]);
6034
+ }), [["__scopeId", "data-v-a07b68e8"]]);
5877
6035
  //#endregion
5878
6036
  //#region src/useChart.js
5879
6037
  var USE_CHART_KEY = /* @__PURE__ */ Symbol("ev-chart-context");
@@ -5937,6 +6095,95 @@ function injectChartContext() {
5937
6095
  return inject(USE_CHART_KEY, null);
5938
6096
  }
5939
6097
  //#endregion
6098
+ //#region src/palette.js
6099
+ /**
6100
+ * 配色方案应用工具 — 中性契约的官方实现
6101
+ *
6102
+ * 把一套数据色板写入 --ev-color-series-1..8 槽位令牌并派发 'ev-theme-change',
6103
+ * 页面上的全部图表即时重绘。任何宿主(组件库 / 站点 / 独立页面)都可直接调用,
6104
+ * 不必各自实现令牌写入与事件广播。
6105
+ *
6106
+ * palette 两种形态:
6107
+ * - string[] 单套色值,明暗模式共用;
6108
+ * - { light, dark } 浅/暗两组,随 html.dark 自动换挡(经注入样式表实现)。
6109
+ *
6110
+ * clearSeriesPalette() 移除覆写(内联属性 + 注入样式表),回到内置成套色板。
6111
+ */
6112
+ var SLOT_COUNT = 8;
6113
+ var PALETTE_EVENT = "ev-theme-change";
6114
+ var STYLE_ID = "data-ev-series-palette";
6115
+ var paletteStyleEl = null;
6116
+ function dispatchPaletteChange(dark) {
6117
+ if (typeof document === "undefined") return;
6118
+ const isDark = typeof dark === "boolean" ? dark : document.documentElement.classList.contains("dark");
6119
+ document.documentElement.dispatchEvent(new CustomEvent(PALETTE_EVENT, {
6120
+ bubbles: true,
6121
+ detail: { dark: isDark }
6122
+ }));
6123
+ }
6124
+ function removeInlineSlots() {
6125
+ if (typeof document === "undefined") return;
6126
+ for (let i = 1; i <= SLOT_COUNT; i++) document.documentElement.style.removeProperty(`--ev-color-series-${i}`);
6127
+ }
6128
+ function ensurePaletteStyleEl() {
6129
+ if (typeof document === "undefined") return null;
6130
+ if (!paletteStyleEl || !paletteStyleEl.isConnected) {
6131
+ paletteStyleEl = document.createElement("style");
6132
+ paletteStyleEl.setAttribute(STYLE_ID, "");
6133
+ document.head.appendChild(paletteStyleEl);
6134
+ }
6135
+ return paletteStyleEl;
6136
+ }
6137
+ function normalizeSlots(colors) {
6138
+ const out = {};
6139
+ if (!Array.isArray(colors)) return out;
6140
+ colors.slice(0, SLOT_COUNT).forEach((color, i) => {
6141
+ if (color) out[`--ev-color-series-${i + 1}`] = color;
6142
+ });
6143
+ return out;
6144
+ }
6145
+ /**
6146
+ * 应用一套数据色板
6147
+ * @param {string[] | { light: string[], dark: string[] }} palette
6148
+ * 数组:写入内联槽位(明暗共用);{ light, dark }:注入样式表,随 html.dark 自动换挡
6149
+ * @param {{ dark?: boolean, target?: HTMLElement }} [options]
6150
+ * dark 手动指定当前暗色态(默认探测 html.dark);target 改写注入目标
6151
+ * @returns {boolean} 是否写入成功
6152
+ */
6153
+ function applySeriesPalette(palette, options = {}) {
6154
+ if (typeof document === "undefined") return false;
6155
+ const el = options.target ?? document.documentElement;
6156
+ if (!el || !palette) return false;
6157
+ if (Array.isArray(palette)) {
6158
+ paletteStyleEl?.remove();
6159
+ paletteStyleEl = null;
6160
+ for (const [name, value] of Object.entries(normalizeSlots(palette))) el.style.setProperty(name, value);
6161
+ dispatchPaletteChange(options.dark);
6162
+ return true;
6163
+ }
6164
+ const { light, dark } = palette;
6165
+ if (!Array.isArray(light) && !Array.isArray(dark)) return false;
6166
+ removeInlineSlots();
6167
+ const styleEl = ensurePaletteStyleEl();
6168
+ if (styleEl) {
6169
+ const vars = (colors) => colors.map((c, i) => `--ev-color-series-${i + 1}: ${c}`).join(";");
6170
+ styleEl.textContent = `${light ? `:root { ${vars(light)}; }` : ""}${dark ? ` html.dark { ${vars(dark)}; }` : ""}`;
6171
+ }
6172
+ dispatchPaletteChange(options.dark);
6173
+ return true;
6174
+ }
6175
+ /**
6176
+ * 清除配色方案覆写(内联槽位 + 注入样式表),回到内置成套色板
6177
+ */
6178
+ function clearSeriesPalette(options = {}) {
6179
+ if (typeof document === "undefined") return false;
6180
+ paletteStyleEl?.remove();
6181
+ paletteStyleEl = null;
6182
+ removeInlineSlots();
6183
+ dispatchPaletteChange(options.dark);
6184
+ return true;
6185
+ }
6186
+ //#endregion
5940
6187
  //#region src/index.js
5941
6188
  var components = { EvChart: chart_default };
5942
6189
  function install(app, _options = {}) {
@@ -5945,4 +6192,4 @@ function install(app, _options = {}) {
5945
6192
  var EvokeCharts = { install };
5946
6193
  var src_default = { install };
5947
6194
  //#endregion
5948
- export { chart_default as EvChart, EvokeCharts, components, src_default as default, injectChartContext, install, provideChartContext, useChart };
6195
+ export { chart_default as EvChart, EvokeCharts, applySeriesPalette, clearSeriesPalette, components, src_default as default, injectChartContext, install, provideChartContext, useChart };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wil-works/evoke-charts",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Evoke Charts — 零依赖 Canvas 自绘图表库(Vue3),Evoke 生态独立图表包",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",