@astryx-vue/charts 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2667 @@
1
+ import { Fragment, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createSlots, createTextVNode, createVNode, defineComponent, getCurrentInstance, guardReactiveProps, h, inject, mergeProps, nextTick, normalizeClass, normalizeProps, normalizeStyle, onMounted, onScopeDispose, openBlock, provide, ref, render, renderList, renderSlot, resolveDynamicComponent, toDisplayString, unref, useId, useSlots, watch, watchEffect, watchPostEffect, withCtx } from "vue";
2
+ import { HStack, Text, VStack, VisuallyHidden, characterCount, truncateCharacters, useLayer, useLocale, useTranslator } from "@astryx-vue/core";
3
+ import "../styles/chart-swatch.css";
4
+ import "../styles/chart-legend.css";
5
+ import "../styles/chart-tooltip.css";
6
+ import { scaleBand, scaleLinear } from "d3-scale";
7
+ import { area as area$1, curveLinear, curveMonotoneX, curveNatural, curveStep, line as line$1, stack, stackOffsetNone, stackOrderNone } from "d3-shape";
8
+ import "../styles/chart.css";
9
+ import "../styles/chart-axis.css";
10
+ import "../styles/chart-grid.css";
11
+ //#region src/color.ts
12
+ const NAMED_COLORS = {
13
+ transparent: {
14
+ r: 0,
15
+ g: 0,
16
+ b: 0,
17
+ a: 0
18
+ },
19
+ black: {
20
+ r: 0,
21
+ g: 0,
22
+ b: 0,
23
+ a: 1
24
+ },
25
+ white: {
26
+ r: 255,
27
+ g: 255,
28
+ b: 255,
29
+ a: 1
30
+ }
31
+ };
32
+ function clamp$1(value, min, max) {
33
+ return Math.max(min, Math.min(max, value));
34
+ }
35
+ /** 把简写 hex 的每个字符重复一遍:`rgb` → `rrggbb`。 */
36
+ function expandShorthand(body) {
37
+ return body.split("").map((c) => c + c).join("");
38
+ }
39
+ /**
40
+ * 解析 hex 颜色。支持 `#rgb`、`#rgba`、`#rrggbb`、`#rrggbbaa`,`#` 可有可无。
41
+ * 其它输入返回 null。
42
+ */
43
+ function parseHex(hex) {
44
+ if (typeof hex !== "string") return null;
45
+ const body = hex.trim().replace(/^#/, "");
46
+ const normalized = body.length === 3 || body.length === 4 ? expandShorthand(body) : body;
47
+ if (normalized.length !== 6 && normalized.length !== 8 || !/^[0-9a-f]+$/i.test(normalized)) return null;
48
+ return {
49
+ r: Number.parseInt(normalized.slice(0, 2), 16),
50
+ g: Number.parseInt(normalized.slice(2, 4), 16),
51
+ b: Number.parseInt(normalized.slice(4, 6), 16),
52
+ a: normalized.length === 8 ? Number.parseInt(normalized.slice(6, 8), 16) / 255 : 1
53
+ };
54
+ }
55
+ /**
56
+ * 解析 `rgb()` / `rgba()`。通道可以用逗号或空格分隔,可以用 `/ alpha`,
57
+ * 也可以写百分比。通道和透明度都会夹到合法范围。
58
+ */
59
+ function parseRgb(value) {
60
+ const open = value.indexOf("(");
61
+ if (open === -1 || !value.trim().endsWith(")")) return null;
62
+ const parts = value.slice(open + 1, value.lastIndexOf(")")).replace(/\//g, " ").split(/[\s,]+/).map((p) => p.trim()).filter(Boolean);
63
+ if (parts.length < 3) return null;
64
+ function channel(p) {
65
+ return clamp$1(p.endsWith("%") ? Number.parseFloat(p) / 100 * 255 : Number.parseFloat(p), 0, 255);
66
+ }
67
+ const r = channel(parts[0]);
68
+ const g = channel(parts[1]);
69
+ const b = channel(parts[2]);
70
+ if ([
71
+ r,
72
+ g,
73
+ b
74
+ ].some(Number.isNaN)) return null;
75
+ let a = 1;
76
+ if (parts.length >= 4) {
77
+ const raw = parts[3];
78
+ a = raw.endsWith("%") ? Number.parseFloat(raw) / 100 : Number.parseFloat(raw);
79
+ if (Number.isNaN(a)) return null;
80
+ a = clamp$1(a, 0, 1);
81
+ }
82
+ return {
83
+ r,
84
+ g,
85
+ b,
86
+ a
87
+ };
88
+ }
89
+ /**
90
+ * 解析一个具体的 CSS 颜色字符串:hex、`rgb()` / `rgba()`,以及 token 表达式里
91
+ * 用到的几个颜色名。解析不了返回 null。
92
+ */
93
+ function parseColor(value) {
94
+ const trimmed = value.trim();
95
+ const named = NAMED_COLORS[trimmed.toLowerCase()];
96
+ if (named) return { ...named };
97
+ if (trimmed.startsWith("#")) return parseHex(trimmed);
98
+ if (/^rgba?\(/i.test(trimmed)) return parseRgb(trimmed);
99
+ return null;
100
+ }
101
+ /** 把 0-255 的三个通道拼成大写 `#RRGGBB`。 */
102
+ function formatHex(r, g, b) {
103
+ function channel(c) {
104
+ return clamp$1(Math.round(c), 0, 255).toString(16).padStart(2, "0").toUpperCase();
105
+ }
106
+ return `#${channel(r)}${channel(g)}${channel(b)}`;
107
+ }
108
+ /** 完全不透明时输出 `#RRGGBB`,否则输出 `rgba(r, g, b, a)`。 */
109
+ function formatColor({ r, g, b, a }) {
110
+ if (a >= 1) return formatHex(r, g, b);
111
+ const round = (n) => clamp$1(Math.round(n), 0, 255);
112
+ return `rgba(${round(r)}, ${round(g)}, ${round(b)}, ${Number.parseFloat(a.toFixed(4))})`;
113
+ }
114
+ /** 解析不了的颜色(null)和非有限通道用这个中性灰兜底,让 GPU 不会拿到 NaN。 */
115
+ const GL_FALLBACK = [
116
+ .5,
117
+ .5,
118
+ .5
119
+ ];
120
+ /**
121
+ * 把解析后的颜色转成 0-1 的 `[r, g, b]` 浮点数,给 WebGL 的 uniform 和顶点属性用。
122
+ *
123
+ * 参数是解析后的结果,所以每个调用方都走同一条校验路径:`toGLFloats(parseHex(color))`。
124
+ * 传 null 或通道非有限时返回中性灰。透明度故意丢掉,GL 那边自己混合。
125
+ */
126
+ function toGLFloats(color) {
127
+ if (color === null || ![
128
+ color.r,
129
+ color.g,
130
+ color.b
131
+ ].every(Number.isFinite)) return GL_FALLBACK;
132
+ const channel = (c) => clamp$1(c, 0, 255) / 255;
133
+ return [
134
+ channel(color.r),
135
+ channel(color.g),
136
+ channel(color.b)
137
+ ];
138
+ }
139
+ //#endregion
140
+ //#region src/ChartSwatch/ChartSwatch.vue
141
+ const _sfc_main$4 = /*@__PURE__*/ defineComponent({
142
+ name: "AstryxChartSwatch",
143
+ __name: "ChartSwatch",
144
+ props: {
145
+ color: {},
146
+ variant: { default: "square" }
147
+ },
148
+ setup(__props) {
149
+ const props = __props;
150
+ const classes = computed(() => ["astryx-chart-swatch", `astryx-chart-swatch--${props.variant}`]);
151
+ return (_ctx, _cache) => {
152
+ return openBlock(), createElementBlock(Fragment, null, [createCommentVNode(" 颜色是运行时值,只能走内联样式;形状和尺寸在样式表里。 "), createElementVNode("div", {
153
+ "aria-hidden": "true",
154
+ class: normalizeClass(classes.value),
155
+ style: normalizeStyle({ backgroundColor: __props.color })
156
+ }, null, 6)], 2112);
157
+ };
158
+ }
159
+ });
160
+ //#endregion
161
+ //#region src/ChartSwatch/swatch.ts
162
+ /**
163
+ * 标记类型对应的色块形态:柱状是方块,其余(折线、散点、面积……)都是一小段线。
164
+ *
165
+ * 图例和提示框都走这一条规则,同一个系列在两处长得一样。
166
+ */
167
+ function swatchVariantForType(type) {
168
+ return type === "bar" ? "square" : "line";
169
+ }
170
+ //#endregion
171
+ //#region src/messages.ts
172
+ /** 和上游 `locales/en.json`、`locales/zh-CN.json` 里的文案一字不差。 */
173
+ const FALLBACKS = {
174
+ "@astryx.chart.label": {
175
+ "en": "Chart",
176
+ "zh-CN": "图表"
177
+ },
178
+ "@astryx.chart.labelWithSeries": {
179
+ "en": "Chart of {series} by {xKey}",
180
+ "zh-CN": "按 {xKey} 显示 {series} 的图表"
181
+ },
182
+ "@astryx.chart.dataTableCaption": {
183
+ "en": "{label} data",
184
+ "zh-CN": "{label}的数据"
185
+ },
186
+ "@astryx.chartLegend.label": {
187
+ "en": "Chart legend",
188
+ "zh-CN": "图表图例"
189
+ }
190
+ };
191
+ /** 把 `{name}` 占位符换成实际值,认不出的占位符原样留着。 */
192
+ function fillPlaceholders(template, values) {
193
+ return template.replace(/\{(\w+)\}/g, (whole, name) => {
194
+ const value = values?.[name];
195
+ return value === void 0 ? whole : String(value);
196
+ });
197
+ }
198
+ /** 取图表文案的翻译函数,优先用 core 的译文,没有就用包内的兜底。 */
199
+ function useChartMessages() {
200
+ const { t } = useTranslator();
201
+ const { locale } = useLocale();
202
+ return (key, values) => {
203
+ const translated = t(key, values);
204
+ if (translated !== key) return translated;
205
+ const table = FALLBACKS[key];
206
+ return fillPlaceholders(table[locale.value === "zh-CN" ? "zh-CN" : "en"], values);
207
+ };
208
+ }
209
+ //#endregion
210
+ //#region src/ChartLegend/ChartLegend.vue
211
+ const _sfc_main$3 = /*@__PURE__*/ defineComponent({
212
+ name: "AstryxChartLegend",
213
+ __name: "ChartLegend",
214
+ props: {
215
+ items: { default: () => [] },
216
+ position: { default: "bottom" },
217
+ alignment: { default: "start" }
218
+ },
219
+ setup(__props) {
220
+ const props = __props;
221
+ const t = useChartMessages();
222
+ const isVertical = computed(() => props.position === "start" || props.position === "end");
223
+ /** `start` / `end` 摆在图表两侧,用纵向列表;`top` / `bottom` 用可换行的横向列表。 */
224
+ const listComponent = computed(() => isVertical.value ? VStack : HStack);
225
+ const listProps = computed(() => isVertical.value ? {
226
+ gap: 2,
227
+ hAlign: props.alignment,
228
+ class: ["astryx-chart-legend", "astryx-chart-legend--vertical"]
229
+ } : {
230
+ gap: 4,
231
+ justify: props.alignment,
232
+ vAlign: "center",
233
+ wrap: "wrap",
234
+ class: "astryx-chart-legend"
235
+ });
236
+ return (_ctx, _cache) => {
237
+ return __props.items.length > 0 ? (openBlock(), createBlock(resolveDynamicComponent(listComponent.value), mergeProps({
238
+ key: 0,
239
+ role: "list",
240
+ "aria-label": unref(t)("@astryx.chartLegend.label")
241
+ }, listProps.value), {
242
+ default: withCtx(() => [createCommentVNode(" 文案不保证不重(两个系列可以共用一个 label),所以键里带上序号。 "), (openBlock(true), createElementBlock(Fragment, null, renderList(__props.items, (item, index) => {
243
+ return openBlock(), createBlock(unref(HStack), {
244
+ key: `${item.label}-${index}`,
245
+ align: "center",
246
+ gap: 2,
247
+ role: "listitem"
248
+ }, {
249
+ default: withCtx(() => [createVNode(unref(_sfc_main$4), {
250
+ color: item.color,
251
+ variant: unref(swatchVariantForType)(item.type)
252
+ }, null, 8, ["color", "variant"]), createVNode(unref(Text), { type: "supporting" }, {
253
+ default: withCtx(() => [createTextVNode(toDisplayString(item.label), 1)]),
254
+ _: 2
255
+ }, 1024)]),
256
+ _: 2
257
+ }, 1024);
258
+ }), 128))]),
259
+ _: 1
260
+ }, 16, ["aria-label"])) : createCommentVNode("v-if", true);
261
+ };
262
+ }
263
+ });
264
+ //#endregion
265
+ //#region src/context.ts
266
+ /** 图表根把上下文挂在这个键上,子组件(坐标轴、网格、提示框)从它读比例尺和尺寸。 */
267
+ const chartContextKey = Symbol("astryx-chart");
268
+ /**
269
+ * 读当前图表的上下文。只能在 `<Chart>` 里用。
270
+ *
271
+ * 返回的对象身份不变,字段是活的:图表重新量到宽度、重新布局之后,
272
+ * 同样的对象读出来的比例尺和尺寸就是新的。所以取值要写在 `computed` 或渲染里,
273
+ * 不要在 `setup` 里一次性解构出来。
274
+ */
275
+ function useChart() {
276
+ const ctx = inject(chartContextKey, null);
277
+ if (ctx === null) throw new Error("useChart() 必须在 <Chart> 里调用");
278
+ return ctx;
279
+ }
280
+ //#endregion
281
+ //#region src/types.ts
282
+ /**
283
+ * 既没有显式颜色、也没有被图表根分配调色板颜色时,最后兜底用的颜色。
284
+ *
285
+ * 用 core 真正输出的 `--color-accent`:数据配色的 `--color-data-*` 是 JS 里算出来的
286
+ * 具体颜色值,不是 CSS 变量,写 `var(--color-data-*)` 什么也画不出来。正常情况图表根
287
+ * 会给每个系列分配颜色,所以这只是最后一道兜底。
288
+ */
289
+ const DEFAULT_SERIES_COLOR = "var(--color-accent)";
290
+ /**
291
+ * 只做标注、不承载数据的标记类型。它们不参与图例和提示框,因为用户不是拿它们比大小。
292
+ */
293
+ const UTILITY_MARK_TYPES = /* @__PURE__ */ new Set([
294
+ "referenceLine",
295
+ "errorBar",
296
+ "band"
297
+ ]);
298
+ /** 这个标记类型是不是纯标注类。 */
299
+ function isUtilityMarkType(type) {
300
+ return UTILITY_MARK_TYPES.has(type);
301
+ }
302
+ //#endregion
303
+ //#region src/tooltip.ts
304
+ /**
305
+ * 生成提示框要显示的行。跳过标注类标记,也跳过布局没能算出点位的系列。
306
+ *
307
+ * 纯函数、同步,调用方可以放心缓存结果。
308
+ */
309
+ function deriveTooltipSeriesValues(series, datum, resolvedKeys) {
310
+ if (!datum) return [];
311
+ const out = [];
312
+ for (const s of series) {
313
+ if (isUtilityMarkType(s.type)) continue;
314
+ if (!s._uid || !resolvedKeys.has(s._uid)) continue;
315
+ out.push({
316
+ key: s.key,
317
+ label: s.label ?? s.key,
318
+ color: s.color ?? s._resolvedColor ?? "var(--color-accent)",
319
+ type: s.type,
320
+ value: datum[s.dataKeys[0]]
321
+ });
322
+ }
323
+ return out;
324
+ }
325
+ /**
326
+ * 鼠标停在某个下标上时,这个系列要不要画一个悬停圆点。
327
+ * 柱状改成高亮整条「带」,标注类标记没有数据点可高亮。
328
+ */
329
+ function shouldRenderHoverDot(seriesType) {
330
+ return seriesType !== "bar" && !isUtilityMarkType(seriesType);
331
+ }
332
+ //#endregion
333
+ //#region src/utils.ts
334
+ /** 判断一个比例尺是不是 d3 的 band 比例尺(分类轴上每个类占一条「带」)。 */
335
+ function isBandScale(scale) {
336
+ return typeof scale === "function" && "bandwidth" in scale;
337
+ }
338
+ /**
339
+ * 算出一条数据在 x 方向的像素位置。band 比例尺返回这条「带」的中心。
340
+ *
341
+ * 非 band 比例尺走数值路径:有限数字直接用,Date 和数字字符串也照 d3 的规则转换。
342
+ * 转出来不是有限值的(读不成数字、或者原始值就是 ±Infinity)一律给 NaN——标记看到
343
+ * NaN 会跳过这个点(画在画布外),而不是照 d3 的换算画到某个错位置。
344
+ */
345
+ function xPixel(d, xKey, xScale) {
346
+ const raw = d[xKey];
347
+ if (isBandScale(xScale)) return (xScale(String(raw)) ?? 0) + xScale.bandwidth() / 2;
348
+ const value = typeof raw === "number" ? raw : Number(raw);
349
+ return Number.isFinite(value) ? xScale(value) : NaN;
350
+ }
351
+ //#endregion
352
+ //#region src/ChartTooltip/ChartTooltipContent.vue
353
+ /**
354
+ * 提示框的默认内容:上面一行是悬停的 x 值,下面每个系列一行。
355
+ * 只有一个系列时省掉系列名——图里只有一个东西,写出来是多余的。
356
+ */
357
+ const _sfc_main$6 = /*@__PURE__*/ defineComponent({
358
+ name: "AstryxChartTooltipContent",
359
+ __name: "ChartTooltipContent",
360
+ props: {
361
+ xValue: {},
362
+ rows: {}
363
+ },
364
+ setup(__props) {
365
+ return (_ctx, _cache) => {
366
+ return openBlock(), createBlock(unref(VStack), { gap: 1 }, {
367
+ default: withCtx(() => [createVNode(unref(Text), {
368
+ type: "body",
369
+ weight: "semibold"
370
+ }, {
371
+ default: withCtx(() => [createTextVNode(toDisplayString(String(__props.xValue)), 1)]),
372
+ _: 1
373
+ }), __props.rows.length === 1 ? (openBlock(), createBlock(unref(Text), {
374
+ key: 0,
375
+ type: "supporting"
376
+ }, {
377
+ default: withCtx(() => [createTextVNode(toDisplayString(String(__props.rows[0].value)), 1)]),
378
+ _: 1
379
+ })) : (openBlock(), createBlock(unref(VStack), {
380
+ key: 1,
381
+ gap: 1
382
+ }, {
383
+ default: withCtx(() => [createCommentVNode(" 文案不保证不重,键里带上序号。 "), (openBlock(true), createElementBlock(Fragment, null, renderList(__props.rows, (row, index) => {
384
+ return openBlock(), createBlock(unref(HStack), {
385
+ key: `${row.key}-${index}`,
386
+ align: "center",
387
+ gap: 2
388
+ }, {
389
+ default: withCtx(() => [
390
+ createVNode(unref(_sfc_main$4), {
391
+ color: row.color,
392
+ variant: unref(swatchVariantForType)(row.type)
393
+ }, null, 8, ["color", "variant"]),
394
+ createVNode(unref(Text), { type: "supporting" }, {
395
+ default: withCtx(() => [createTextVNode(toDisplayString(row.label), 1)]),
396
+ _: 2
397
+ }, 1024),
398
+ createVNode(unref(Text), {
399
+ type: "supporting",
400
+ weight: "semibold"
401
+ }, {
402
+ default: withCtx(() => [createTextVNode(toDisplayString(String(row.value)), 1)]),
403
+ _: 2
404
+ }, 1024)
405
+ ]),
406
+ _: 2
407
+ }, 1024);
408
+ }), 128))]),
409
+ _: 1
410
+ }))]),
411
+ _: 1
412
+ });
413
+ };
414
+ }
415
+ });
416
+ /**
417
+ * 算提示卡片在视口里的坐标。`auto` 默认放右边,右边放不下就翻到左边;
418
+ * 最后四边都夹进视口内,免得卡片被裁掉。
419
+ */
420
+ /** 量出来的尺寸读不成有限数时(比如非浏览器环境)当 0,否则坐标会算成 NaN。 */
421
+ function finiteSize(value) {
422
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
423
+ }
424
+ function computeCardPosition(input, placement) {
425
+ const { anchorLeft, anchorTop, viewportWidth, viewportHeight } = input;
426
+ const width = finiteSize(input.width);
427
+ const height = finiteSize(input.height);
428
+ let x;
429
+ let y = anchorTop;
430
+ switch (placement) {
431
+ case "left":
432
+ x = anchorLeft - width - 8;
433
+ break;
434
+ case "right":
435
+ x = anchorLeft + 8;
436
+ break;
437
+ case "top":
438
+ x = anchorLeft - width / 2;
439
+ y = anchorTop - height - 8;
440
+ break;
441
+ default: {
442
+ const right = anchorLeft + 8;
443
+ x = right + width > viewportWidth ? anchorLeft - width - 8 : right;
444
+ }
445
+ }
446
+ return {
447
+ x: Math.max(8, Math.min(x, viewportWidth - width - 8)),
448
+ y: Math.max(8, Math.min(y, viewportHeight - height - 8))
449
+ };
450
+ }
451
+ //#endregion
452
+ //#region src/ChartTooltip/useTooltipCard.ts
453
+ /** 卡片内容渲染完以后还要再等一帧,`v-show` 才把元素显示出来、量得到尺寸。 */
454
+ const MEASURE_TICKS = 2;
455
+ /**
456
+ * 提示卡片的状态机:订阅图表的指针事件、算出该显示哪一行、量出卡片尺寸再定位置。
457
+ *
458
+ * 只在下标的「行」变化时才更新状态,同一行里挪动鼠标不会引起重排。
459
+ * 卡片内容为空(自定义内容故意不返回东西)时按隐藏处理,但高亮和圆点照画。
460
+ */
461
+ function useTooltipCard(options) {
462
+ const chart = useChart();
463
+ const hoveredIndex = ref(null);
464
+ const cardHidden = ref(true);
465
+ const cardPosition = ref({
466
+ x: 0,
467
+ y: 0
468
+ });
469
+ const cardRef = ref(null);
470
+ let lastEvent = null;
471
+ const layer = useLayer({
472
+ mode: "fixed",
473
+ x: () => cardPosition.value.x,
474
+ y: () => cardPosition.value.y
475
+ });
476
+ /** 卡片挂到图表所在的 HTML 容器里,这样外层主题(含深浅色)对它同样生效。 */
477
+ const portalTarget = ref(null);
478
+ watchEffect(() => {
479
+ portalTarget.value = chart.svgRef.value?.parentElement ?? null;
480
+ });
481
+ const datum = computed(() => hoveredIndex.value == null ? void 0 : chart.data[hoveredIndex.value]);
482
+ const xValue = computed(() => datum.value?.[chart.xKey]);
483
+ const resolvedKeys = computed(() => new Set(chart.resolved.keys()));
484
+ const seriesValues = computed(() => deriveTooltipSeriesValues(options.series(), datum.value, resolvedKeys.value));
485
+ /** 自定义内容渲染出来是空的,就等于上游 `render()` 返回 `null`:不显示卡片。 */
486
+ function hasRenderedContent(element) {
487
+ if ((element.textContent ?? "").trim() !== "") return true;
488
+ return element.children.length > 0;
489
+ }
490
+ function resolveHidden() {
491
+ if (hoveredIndex.value == null || datum.value == null) {
492
+ cardHidden.value = true;
493
+ return;
494
+ }
495
+ if (!options.hasCustomContent()) {
496
+ cardHidden.value = false;
497
+ return;
498
+ }
499
+ const card = cardRef.value;
500
+ cardHidden.value = card == null || !hasRenderedContent(card);
501
+ }
502
+ function place(event) {
503
+ const card = cardRef.value;
504
+ const svg = chart.svgRef.value;
505
+ if (!card || !svg || !event.nearest) return;
506
+ const svgRect = svg.getBoundingClientRect();
507
+ cardPosition.value = computeCardPosition({
508
+ anchorLeft: svgRect.left + chart.margin.left + event.nearest.px,
509
+ anchorTop: svgRect.top + chart.margin.top,
510
+ width: card.offsetWidth,
511
+ height: card.offsetHeight,
512
+ viewportWidth: window.innerWidth,
513
+ viewportHeight: window.innerHeight
514
+ }, options.placement());
515
+ }
516
+ /** 等内容和显隐都落到 DOM 上,再量尺寸定位置,否则量到的是上一帧的卡片。 */
517
+ async function sync() {
518
+ for (let i = 0; i < MEASURE_TICKS; i++) await nextTick();
519
+ resolveHidden();
520
+ if (cardHidden.value || !lastEvent || !portalTarget.value) {
521
+ layer.hide();
522
+ return;
523
+ }
524
+ layer.show();
525
+ await nextTick();
526
+ place(lastEvent);
527
+ }
528
+ let pending = false;
529
+ async function scheduleSync() {
530
+ if (pending) return;
531
+ pending = true;
532
+ try {
533
+ await sync();
534
+ } finally {
535
+ pending = false;
536
+ }
537
+ }
538
+ const unsubscribe = chart.onPointer((event) => {
539
+ lastEvent = event;
540
+ const index = event.nearest?.dataIndex ?? null;
541
+ if (index !== hoveredIndex.value) {
542
+ hoveredIndex.value = index;
543
+ if (index == null) cardHidden.value = true;
544
+ }
545
+ scheduleSync();
546
+ });
547
+ onScopeDispose(unsubscribe);
548
+ watch([
549
+ hoveredIndex,
550
+ seriesValues,
551
+ portalTarget
552
+ ], () => {
553
+ scheduleSync();
554
+ });
555
+ const isVisible = computed(() => !cardHidden.value && layer.isOpen.value);
556
+ return {
557
+ cardRef,
558
+ cardStyle: computed(() => layer.contentStyle.value),
559
+ isVisible,
560
+ portalTarget,
561
+ hoveredIndex,
562
+ xValue,
563
+ hasDatum: computed(() => datum.value != null),
564
+ seriesValues
565
+ };
566
+ }
567
+ //#endregion
568
+ //#region src/ChartTooltip/ChartTooltip.vue
569
+ const _hoisted_1$3 = [
570
+ "x",
571
+ "y",
572
+ "width",
573
+ "height"
574
+ ];
575
+ const _hoisted_2$3 = [
576
+ "x1",
577
+ "x2",
578
+ "y2"
579
+ ];
580
+ const _hoisted_3$2 = [
581
+ "cx",
582
+ "cy",
583
+ "stroke"
584
+ ];
585
+ const _sfc_main$5 = /*@__PURE__*/ defineComponent({
586
+ name: "AstryxChartTooltip",
587
+ __name: "ChartTooltip",
588
+ props: {
589
+ series: { default: () => [] },
590
+ hoverIndicator: {
591
+ type: Boolean,
592
+ default: true
593
+ },
594
+ showHoverDots: {
595
+ type: Boolean,
596
+ default: true
597
+ },
598
+ placement: { default: "auto" }
599
+ },
600
+ setup(__props) {
601
+ const props = __props;
602
+ const slots = useSlots();
603
+ /** 调用方给了自定义内容插槽,就不再用内置样式。 */
604
+ const hasCustomContent = computed(() => slots.content != null);
605
+ const chart = useChart();
606
+ const { cardRef, cardStyle, isVisible, portalTarget, hoveredIndex, xValue, hasDatum, seriesValues } = useTooltipCard({
607
+ series: () => props.series,
608
+ placement: () => props.placement,
609
+ hasCustomContent: () => hasCustomContent.value
610
+ });
611
+ /**
612
+ * 卡片是 HTML,不能跟着 `<svg>` 里的元素一起创建——那样元素会落到 SVG 命名空间里,
613
+ * 浏览器不认。所以卡片容器用 `document.createElement` 造出来,再挂到图表所在的 HTML 容器里,
614
+ * 内容用 `render()` 单独渲染一遍。
615
+ */
616
+ const cardHost = ref(null);
617
+ const currentInstance = getCurrentInstance();
618
+ /**
619
+ * 内容的渲染外壳。它是一次独立渲染的根组件,负责把当前组件的 provide
620
+ * (语言、主题……)转给卡片内容,用户自己写的插槽内容也就能读同一套上下文。
621
+ */
622
+ const CardBody = defineComponent({
623
+ name: "AstryxChartTooltipBody",
624
+ setup(_, { slots: cardSlots }) {
625
+ return () => cardSlots.default?.() ?? null;
626
+ }
627
+ });
628
+ function cardBody() {
629
+ if (!hasDatum.value) return null;
630
+ if (hasCustomContent.value) return slots.content?.({
631
+ xValue: xValue.value,
632
+ rows: seriesValues.value
633
+ });
634
+ return h(_sfc_main$6, {
635
+ xValue: xValue.value,
636
+ rows: seriesValues.value
637
+ });
638
+ }
639
+ function renderCard() {
640
+ const host = cardHost.value;
641
+ if (!host) return;
642
+ const vnode = h(CardBody, null, { default: cardBody });
643
+ const provides = currentInstance?.provides;
644
+ if (currentInstance) vnode.appContext = {
645
+ ...currentInstance.appContext,
646
+ provides: provides ?? currentInstance.appContext.provides
647
+ };
648
+ render(vnode, host);
649
+ }
650
+ function removeCard() {
651
+ const host = cardHost.value;
652
+ if (!host) return;
653
+ render(null, host);
654
+ host.remove();
655
+ cardHost.value = null;
656
+ cardRef.value = null;
657
+ }
658
+ watch(portalTarget, (target) => {
659
+ removeCard();
660
+ if (!target || typeof document === "undefined") return;
661
+ const host = document.createElement("div");
662
+ host.className = "astryx-chart-tooltip";
663
+ host.setAttribute("role", "tooltip");
664
+ host.style.display = "none";
665
+ target.appendChild(host);
666
+ cardHost.value = host;
667
+ cardRef.value = host;
668
+ renderCard();
669
+ }, { immediate: true });
670
+ onScopeDispose(removeCard);
671
+ /** 位置和显隐直接写在卡片元素上,不再经过模板。 */
672
+ watchPostEffect(() => {
673
+ const host = cardHost.value;
674
+ if (!host) return;
675
+ for (const [name, value] of Object.entries(cardStyle.value)) host.style.setProperty(name, value);
676
+ host.style.display = isVisible.value ? "" : "none";
677
+ });
678
+ /**
679
+ * 悬停指示:图里有柱状系列时在分类轴上高亮整列,其余情况画一条竖线。
680
+ * 两种画法共用同一个「悬停在哪一行」的状态,只是视觉惯例不同。
681
+ */
682
+ const bandHighlight = computed(() => {
683
+ if (!props.hoverIndicator || hoveredIndex.value == null) return null;
684
+ const hoveredDatum = chart.data[hoveredIndex.value];
685
+ const xv = hoveredDatum?.[chart.xKey];
686
+ if (hoveredDatum == null || xv == null) return null;
687
+ if (!props.series.some((s) => s.type === "bar") || !isBandScale(chart.xScale)) return null;
688
+ const x = chart.xScale(String(xv)) ?? 0;
689
+ const bandwidth = chart.xScale.bandwidth();
690
+ const pad = 8;
691
+ const rectX = Math.max(0, x - pad);
692
+ const rectRight = Math.min(chart.width, x + bandwidth + pad);
693
+ const topPad = Math.min(pad, chart.margin.top);
694
+ return {
695
+ x: rectX,
696
+ y: -topPad,
697
+ width: rectRight - rectX,
698
+ height: chart.height + topPad
699
+ };
700
+ });
701
+ /** 折线、面积、散点的悬停位置画竖线;x 读不成有限值时跳过,免得画出 NaN 坐标。 */
702
+ const crosshairX = computed(() => {
703
+ if (!props.hoverIndicator || hoveredIndex.value == null || bandHighlight.value) return null;
704
+ const hoveredDatum = chart.data[hoveredIndex.value];
705
+ if (!hoveredDatum) return null;
706
+ const px = xPixel(hoveredDatum, chart.xKey, chart.xScale);
707
+ return Number.isFinite(px) ? px : null;
708
+ });
709
+ /**
710
+ * 每个系列的悬停圆点。这一点在这里没值(坐标是 NaN)时跳过——
711
+ * 标记自己也会跳过这种点,圆点跟着一致。
712
+ */
713
+ const hoverDots = computed(() => {
714
+ if (!props.showHoverDots || hoveredIndex.value == null) return [];
715
+ const dots = [];
716
+ for (const series of props.series) {
717
+ if (!shouldRenderHoverDot(series.type)) continue;
718
+ const point = (series._uid ? chart.resolved.get(series._uid) : void 0)?.find((p) => p.dataIndex === hoveredIndex.value);
719
+ if (!point || !Number.isFinite(point.px) || !Number.isFinite(point.py)) continue;
720
+ dots.push({
721
+ key: series._uid ?? `${series.key}-${dots.length}`,
722
+ px: point.px,
723
+ py: point.py,
724
+ color: series.color ?? series._resolvedColor ?? "var(--astryx-color-border-emphasized)"
725
+ });
726
+ }
727
+ return dots;
728
+ });
729
+ return (_ctx, _cache) => {
730
+ return openBlock(), createElementBlock(Fragment, null, [bandHighlight.value ? (openBlock(), createElementBlock("rect", {
731
+ key: 0,
732
+ x: bandHighlight.value.x,
733
+ y: bandHighlight.value.y,
734
+ width: bandHighlight.value.width,
735
+ height: bandHighlight.value.height,
736
+ fill: "currentColor",
737
+ opacity: .06,
738
+ rx: 4,
739
+ "pointer-events": "none"
740
+ }, null, 8, _hoisted_1$3)) : crosshairX.value !== null ? (openBlock(), createElementBlock("line", {
741
+ key: 1,
742
+ x1: crosshairX.value,
743
+ x2: crosshairX.value,
744
+ y1: "0",
745
+ y2: unref(chart).height,
746
+ class: "astryx-chart-tooltip__crosshair",
747
+ "pointer-events": "none"
748
+ }, null, 8, _hoisted_2$3)) : createCommentVNode("v-if", true), (openBlock(true), createElementBlock(Fragment, null, renderList(hoverDots.value, (dot) => {
749
+ return openBlock(), createElementBlock("circle", {
750
+ key: dot.key,
751
+ cx: dot.px,
752
+ cy: dot.py,
753
+ r: 4,
754
+ fill: "var(--astryx-color-background-surface)",
755
+ stroke: dot.color,
756
+ "stroke-width": 2,
757
+ "pointer-events": "none"
758
+ }, null, 8, _hoisted_3$2);
759
+ }), 128))], 64);
760
+ };
761
+ }
762
+ });
763
+ //#endregion
764
+ //#region src/formatters.ts
765
+ /** 每个语言只建一次格式化器,之后复用。 */
766
+ function byLocale(create) {
767
+ const cache = /* @__PURE__ */ new Map();
768
+ return (locale) => {
769
+ let value = cache.get(locale);
770
+ if (value === void 0) {
771
+ value = create(locale);
772
+ cache.set(locale, value);
773
+ }
774
+ return value;
775
+ };
776
+ }
777
+ const getCompactFormat = byLocale((locale) => new Intl.NumberFormat(locale, {
778
+ notation: "compact",
779
+ maximumFractionDigits: 1
780
+ }));
781
+ const getPercentFormat = byLocale((locale) => new Intl.NumberFormat(locale, {
782
+ style: "percent",
783
+ maximumFractionDigits: 1
784
+ }));
785
+ const getShortDateFormat = byLocale((locale) => new Intl.DateTimeFormat(locale, {
786
+ month: "short",
787
+ day: "numeric",
788
+ calendar: "gregory"
789
+ }));
790
+ const getMonthYearFormat = byLocale((locale) => new Intl.DateTimeFormat(locale, {
791
+ month: "short",
792
+ year: "numeric",
793
+ calendar: "gregory"
794
+ }));
795
+ const getListFormat = byLocale((locale) => new Intl.ListFormat(locale, {
796
+ style: "long",
797
+ type: "conjunction"
798
+ }));
799
+ /** 按语言把几个词连成「甲、乙和丙」这样的并列短语。 */
800
+ function formatList(values, locale) {
801
+ return getListFormat(locale).format(values);
802
+ }
803
+ /**
804
+ * 缩写数字:`1200` → 「1.2K」,`1500000` → 「1.5M」,`1e12` → 「1T」。
805
+ *
806
+ * 读不成有限数的值原样返回,所以可以直接拿它当坐标轴刻度格式化器。
807
+ */
808
+ function compactNumber(value, locale) {
809
+ const n = Number(value);
810
+ if (!Number.isFinite(n)) return String(value);
811
+ return getCompactFormat(locale).format(n);
812
+ }
813
+ /**
814
+ * 生成货币格式化函数。
815
+ *
816
+ * 负号的顺序是「符号在前、货币符号在后」,所以负数读作「-$1.5K」而不是「$-1.5K」。
817
+ * 一千以上用缩写,一千以内用带千分位的普通数字。
818
+ *
819
+ * @example
820
+ * ```
821
+ * currency('$', locale)(1500) // $1.5K
822
+ * currency('€', locale)(12) // €12
823
+ * ```
824
+ */
825
+ function currency(symbol = "$", locale) {
826
+ return (value) => {
827
+ const n = Number(value);
828
+ if (!Number.isFinite(n)) return String(value);
829
+ const sign = n < 0 ? "-" : "";
830
+ const abs = Math.abs(n);
831
+ return `${sign}${symbol}${abs >= 1e3 ? getCompactFormat(locale).format(abs) : abs.toLocaleString(locale)}`;
832
+ };
833
+ }
834
+ /**
835
+ * 百分比格式化:`0.45` → 「45%」,`1.2` → 「120%」,`0.005` → 「0.5%」。
836
+ *
837
+ * 入参是比例,不是百分数。整数百分比不显示小数位,其余最多一位。
838
+ */
839
+ function percent(value, locale) {
840
+ const n = Number(value);
841
+ if (!Number.isFinite(n)) return String(value);
842
+ return getPercentFormat(locale).format(n);
843
+ }
844
+ /**
845
+ * 把值转成 Date,转不了就返回一个无效 Date,让调用方原样返回原值。
846
+ *
847
+ * 接受 `Date`、毫秒时间戳(数字或纯数字字符串)、日期字符串(ISO 8601、RFC 2822 等)。
848
+ *
849
+ * 只有年月日的字符串(`YYYY-MM-DD`)按本地零点构造,而不是规范里的 UTC 零点,
850
+ * 这样在负时区里格式化出来还是同一天(`"2024-01-05"` 不会变成 1 月 4 日)。
851
+ */
852
+ function toDate(value) {
853
+ if (value instanceof Date) return value;
854
+ if (typeof value === "number") return new Date(value);
855
+ if (typeof value === "string") {
856
+ const s = value.trim();
857
+ if (s === "") return /* @__PURE__ */ new Date(NaN);
858
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
859
+ if (dateOnly) {
860
+ const year = Number(dateOnly[1]);
861
+ const month = Number(dateOnly[2]);
862
+ const day = Number(dateOnly[3]);
863
+ const local = new Date(year, month - 1, day);
864
+ if (local.getFullYear() !== year || local.getMonth() !== month - 1 || local.getDate() !== day) return /* @__PURE__ */ new Date(NaN);
865
+ return local;
866
+ }
867
+ const asNumber = Number(s);
868
+ return new Date(Number.isFinite(asNumber) ? asNumber : s);
869
+ }
870
+ return /* @__PURE__ */ new Date(NaN);
871
+ }
872
+ /** 短日期,例如「Jan 5」「Mar 12」。 */
873
+ function shortDate(value, locale) {
874
+ const d = toDate(value);
875
+ if (Number.isNaN(d.getTime())) return String(value);
876
+ return getShortDateFormat(locale).format(d);
877
+ }
878
+ /** 年月,例如「Jan 2024」「Mar 2025」。 */
879
+ function monthYear(value, locale) {
880
+ const d = toDate(value);
881
+ if (Number.isNaN(d.getTime())) return String(value);
882
+ return getMonthYearFormat(locale).format(d);
883
+ }
884
+ //#endregion
885
+ //#region src/legend.ts
886
+ /**
887
+ * 一个系列要不要进图例:只有承载数据的主标记才进,标注类标记不进。
888
+ * 代表色依次取:写死的颜色、图表根分配的调色板颜色、兜底 token。
889
+ * 所以自动配色的系列和按点取色的系列都会出现在图例里。
890
+ */
891
+ function legendColor(series) {
892
+ if (isUtilityMarkType(series.type)) return void 0;
893
+ return series.color ?? series._resolvedColor ?? "var(--color-accent)";
894
+ }
895
+ /** 从系列列表推出图例项,跳过标注类标记。 */
896
+ function deriveLegendItems(series) {
897
+ const items = [];
898
+ for (const s of series) {
899
+ const color = legendColor(s);
900
+ if (color == null) continue;
901
+ items.push({
902
+ label: s.label ?? s.key,
903
+ color,
904
+ type: s.type
905
+ });
906
+ }
907
+ return items;
908
+ }
909
+ //#endregion
910
+ //#region src/Chart/ChartMarks.ts
911
+ /**
912
+ * 把每个系列的画法装进一个按绘图区裁切的组里。
913
+ *
914
+ * 裁切是为了让曲线过冲(monotone / natural 会在端点外拱出去一点)跑不到内边距里。
915
+ * 每个系列一个 `<g>`,键用布局分配的 `_uid`(`key` 可以重名,不能当键)。
916
+ */
917
+ var ChartMarks_default = defineComponent({
918
+ name: "AstryxChartMarks",
919
+ props: {
920
+ series: {
921
+ type: Array,
922
+ required: true
923
+ },
924
+ resolved: {
925
+ type: Object,
926
+ required: true
927
+ },
928
+ seriesCtx: {
929
+ type: Object,
930
+ required: true
931
+ },
932
+ clipId: {
933
+ type: String,
934
+ required: true
935
+ }
936
+ },
937
+ setup(props) {
938
+ return () => h("g", { "clip-path": `url(#${props.clipId})` }, props.series.map((series) => {
939
+ const points = series._uid ? props.resolved.get(series._uid) : void 0;
940
+ if (!points || !series.render) return null;
941
+ const content = series.render(points, props.seriesCtx);
942
+ return h("g", { key: series._uid }, [content]);
943
+ }));
944
+ }
945
+ });
946
+ //#endregion
947
+ //#region src/Chart/types.ts
948
+ /** 不写 `margin` 时绘图区四周留多少像素。 */
949
+ const DEFAULT_MARGIN = {
950
+ top: 24,
951
+ right: 24,
952
+ bottom: 32,
953
+ left: 48
954
+ };
955
+ /**
956
+ * 数据点(行数 × 数据列数)不超过这个数时,额外输出一张视觉隐藏的数据表。
957
+ * 再多的表对读屏用户只是噪音,所以大图表只保留名字。
958
+ */
959
+ const MAX_TABLE_POINTS = 100;
960
+ //#endregion
961
+ //#region src/layout.ts
962
+ /**
963
+ * 一次布局算出所有系列共用的东西:x/y 比例尺、堆叠、分组,然后挨个调用系列的
964
+ * `resolve()`。布局本身不认识任何具体标记类型。
965
+ */
966
+ /**
967
+ * 只有连续点的图表(没有柱和面积那种零基线)上下各留数据范围的这个比例当余量,
968
+ * 免得端点和曲线过冲贴到绘图区边缘。
969
+ */
970
+ const CONTINUOUS_HEADROOM = .08;
971
+ /** 只有显式范围的两头都是可用的有限数时才算数。 */
972
+ function isFiniteDomain(domain) {
973
+ return Number.isFinite(domain[0]) && Number.isFinite(domain[1]);
974
+ }
975
+ /**
976
+ * 一列数字的 `[min, max]`,避开 `Math.min(...arr)` / `Math.max(...arr)` 的两个坑:
977
+ * 一个 NaN/Infinity 把结果污染掉,以及数组太大时展开参数把调用栈撑爆
978
+ * (GL 标记一次几十万个点)。空的范围或全都相等时会把范围撑开,
979
+ * 免得比例尺塌到一个像素上。
980
+ */
981
+ function finiteExtent(nums) {
982
+ let lo = Number.POSITIVE_INFINITY;
983
+ let hi = Number.NEGATIVE_INFINITY;
984
+ for (const v of nums) if (Number.isFinite(v)) {
985
+ if (v < lo) lo = v;
986
+ if (v > hi) hi = v;
987
+ }
988
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [0, 1];
989
+ if (lo === hi) {
990
+ const pad = Math.abs(lo) * .5 || 1;
991
+ return [lo - pad, hi + pad];
992
+ }
993
+ return [lo, hi];
994
+ }
995
+ function computeLayout({ data, xKey, series, width, height, yBaseline, yDomain, xDomain }) {
996
+ const yBandKey = series.find((s) => s.layout.yBandKey != null)?.layout.yBandKey;
997
+ const isCategoricalGrid = yBandKey != null;
998
+ const xValues = data.map((d) => d[xKey]);
999
+ const isNumericX = xValues.length > 0 && xValues.every((v) => typeof v === "number");
1000
+ let xScale;
1001
+ if (xDomain && isFiniteDomain(xDomain)) xScale = scaleLinear().domain(xDomain).range([0, width]);
1002
+ else if (isNumericX) {
1003
+ const [lo, hi] = finiteExtent(xValues);
1004
+ xScale = scaleLinear().domain([lo, hi]).range([0, width]).nice();
1005
+ } else xScale = scaleBand().domain(xValues.map(String)).range([0, width]).padding(isCategoricalGrid ? 0 : .2);
1006
+ const allKeys = /* @__PURE__ */ new Set();
1007
+ let includeZero = false;
1008
+ const stackGroups = /* @__PURE__ */ new Map();
1009
+ for (const s of series) {
1010
+ for (const k of s.dataKeys) allKeys.add(k);
1011
+ if (s.layout.includeZero) includeZero = true;
1012
+ const primaryKey = s.dataKeys[0];
1013
+ if (s.layout.stack && primaryKey != null) {
1014
+ const group = stackGroups.get(s.layout.stack) ?? [];
1015
+ group.push(primaryKey);
1016
+ stackGroups.set(s.layout.stack, group);
1017
+ }
1018
+ }
1019
+ let yMin = Number.POSITIVE_INFINITY;
1020
+ let yMax = Number.NEGATIVE_INFINITY;
1021
+ const stackedKeys = /* @__PURE__ */ new Set();
1022
+ for (const [, keys] of stackGroups) {
1023
+ for (const k of keys) stackedKeys.add(k);
1024
+ for (const d of data) {
1025
+ let acc = 0;
1026
+ if (acc < yMin) yMin = acc;
1027
+ if (acc > yMax) yMax = acc;
1028
+ for (const k of keys) {
1029
+ const v = d[k];
1030
+ if (Number.isFinite(v)) {
1031
+ acc += v;
1032
+ if (acc < yMin) yMin = acc;
1033
+ if (acc > yMax) yMax = acc;
1034
+ }
1035
+ }
1036
+ }
1037
+ }
1038
+ for (const d of data) for (const k of allKeys) {
1039
+ if (stackedKeys.has(k)) continue;
1040
+ const v = d[k];
1041
+ if (Number.isFinite(v)) {
1042
+ const n = v;
1043
+ if (n < yMin) yMin = n;
1044
+ if (n > yMax) yMax = n;
1045
+ }
1046
+ }
1047
+ let yDomainFinal;
1048
+ let applyNice = true;
1049
+ if (yDomain && isFiniteDomain(yDomain)) {
1050
+ yDomainFinal = yDomain;
1051
+ applyNice = false;
1052
+ } else {
1053
+ if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) {
1054
+ yMin = 0;
1055
+ yMax = 1;
1056
+ }
1057
+ if (yMin === yMax) {
1058
+ const pad = Math.abs(yMin) * .5 || 1;
1059
+ yMin -= pad;
1060
+ yMax += pad;
1061
+ }
1062
+ const mode = yBaseline ?? "auto";
1063
+ if (mode === "zero") {
1064
+ const abs = Math.max(Math.abs(yMin), Math.abs(yMax));
1065
+ yMin = -abs;
1066
+ yMax = abs;
1067
+ } else if (mode === "data") {} else if (includeZero) {
1068
+ if (yMin > 0) yMin = 0;
1069
+ if (yMax < 0) yMax = 0;
1070
+ } else {
1071
+ const pad = (yMax - yMin || 1) * CONTINUOUS_HEADROOM;
1072
+ yMin -= pad;
1073
+ yMax += pad;
1074
+ }
1075
+ yDomainFinal = [yMin, yMax];
1076
+ }
1077
+ const yScaleBase = scaleLinear().domain(yDomainFinal).range([height, 0]);
1078
+ const yScale = applyNice ? yScaleBase.nice() : yScaleBase;
1079
+ let yBandScale;
1080
+ if (yBandKey != null) {
1081
+ const cats = [...new Set(data.map((d) => String(d[yBandKey])))];
1082
+ yBandScale = scaleBand().domain(cats).range([0, height]).padding(0);
1083
+ }
1084
+ const stackedData = /* @__PURE__ */ new Map();
1085
+ for (const [, keys] of stackGroups) {
1086
+ const stacked = stack().keys(keys).order(stackOrderNone).offset(stackOffsetNone)(data);
1087
+ for (const layer of stacked) stackedData.set(layer.key, layer.map((d) => ({
1088
+ y0: d[0],
1089
+ y1: d[1]
1090
+ })));
1091
+ }
1092
+ const barGroups = /* @__PURE__ */ new Map();
1093
+ for (const s of series) if (s.type === "bar" && s.layout.group) {
1094
+ const group = barGroups.get(s.layout.group) ?? [];
1095
+ const slotKey = s.layout.stack ?? s.key;
1096
+ if (!group.includes(slotKey)) group.push(slotKey);
1097
+ barGroups.set(s.layout.group, group);
1098
+ }
1099
+ const ctx = {
1100
+ data,
1101
+ xKey,
1102
+ xScale,
1103
+ yScale,
1104
+ yBandScale,
1105
+ width,
1106
+ height
1107
+ };
1108
+ const resolved = /* @__PURE__ */ new Map();
1109
+ const stackTopKeys = /* @__PURE__ */ new Set();
1110
+ for (const [, keys] of stackGroups) if (keys.length > 0) stackTopKeys.add(keys[keys.length - 1]);
1111
+ for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex++) {
1112
+ const s = series[seriesIndex];
1113
+ s._uid = `${seriesIndex}:${s.key}`;
1114
+ const stackOffsets = stackedData.get(s.dataKeys[0]);
1115
+ if (s.layout.stack) s._isTopOfStack = stackTopKeys.has(s.dataKeys[0]);
1116
+ else s._isTopOfStack = true;
1117
+ let groupInfo;
1118
+ if (s.layout.group) {
1119
+ const groupKeys = barGroups.get(s.layout.group);
1120
+ if (groupKeys) {
1121
+ const slotKey = s.layout.stack ?? s.key;
1122
+ groupInfo = {
1123
+ index: groupKeys.indexOf(slotKey),
1124
+ count: groupKeys.length
1125
+ };
1126
+ }
1127
+ }
1128
+ const points = s.resolve(ctx, stackOffsets, groupInfo);
1129
+ resolved.set(s._uid, points);
1130
+ }
1131
+ return {
1132
+ xScale,
1133
+ yScale,
1134
+ yBandScale,
1135
+ resolved
1136
+ };
1137
+ }
1138
+ //#endregion
1139
+ //#region src/getChartColors.ts
1140
+ const CATEGORICAL_TOKENS = [
1141
+ "--color-data-categorical-blue",
1142
+ "--color-data-categorical-orange",
1143
+ "--color-data-categorical-purple",
1144
+ "--color-data-categorical-green",
1145
+ "--color-data-categorical-pink",
1146
+ "--color-data-categorical-cyan",
1147
+ "--color-data-categorical-red",
1148
+ "--color-data-categorical-teal",
1149
+ "--color-data-categorical-brown",
1150
+ "--color-data-categorical-indigo"
1151
+ ];
1152
+ const SEQUENTIAL_HUES = [
1153
+ "blue",
1154
+ "shamrock",
1155
+ "orange",
1156
+ "pink",
1157
+ "purple",
1158
+ "red",
1159
+ "teal",
1160
+ "yellow",
1161
+ "gray"
1162
+ ];
1163
+ /** 在 sRGB 空间里对两个 hex 颜色插值。读不出 hex 时退到更近的那个端点。 */
1164
+ function lerpHex(a, b, t) {
1165
+ const ca = parseHex(a);
1166
+ const cb = parseHex(b);
1167
+ if (ca === null || cb === null) return t < .5 ? a : b;
1168
+ function mix(x, y) {
1169
+ return x + (y - x) * t;
1170
+ }
1171
+ return formatHex(mix(ca.r, cb.r), mix(ca.g, cb.g), mix(ca.b, cb.b));
1172
+ }
1173
+ /**
1174
+ * 从一串有序的停靠点里取 `n` 个颜色。
1175
+ * - `n` 不超过停靠点个数:挑最接近的那几个,保留设计好的色值。
1176
+ * - `n` 更多:在色阶上插值,要多少个都有,而不是悄悄卡在停靠点个数上。
1177
+ * - `n === 1`:取中间那档,当作这一阶的代表色。
1178
+ */
1179
+ function sampleRamp(stops, n) {
1180
+ const len = stops.length;
1181
+ if (!Number.isFinite(n) || n <= 0 || len === 0) return [];
1182
+ if (n === 1) return [stops[Math.floor((len - 1) / 2)]];
1183
+ if (len === 1) return Array.from({ length: n }).fill(stops[0]);
1184
+ if (n <= len) return Array.from({ length: n }, (_, i) => stops[Math.round(i * (len - 1) / (n - 1))]);
1185
+ return Array.from({ length: n }, (_, i) => {
1186
+ const t = i * (len - 1) / (n - 1);
1187
+ const lo = Math.floor(t);
1188
+ const frac = t - lo;
1189
+ return frac === 0 ? stops[lo] : lerpHex(stops[lo], stops[lo + 1], frac);
1190
+ });
1191
+ }
1192
+ /**
1193
+ * 给一个具体颜色加上透明度。hex(带不带 `#`)、`rgb()` / `rgba()`、颜色名都行。
1194
+ * 传进来的 `opacity` 总是覆盖颜色里原有的透明度。读不出来(比如 `var()`)就原样返回。
1195
+ */
1196
+ function hexAlpha(hex, opacity) {
1197
+ const rgba = parseColor(hex) ?? parseHex(hex);
1198
+ if (rgba === null) return hex;
1199
+ const a = Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1;
1200
+ return formatColor({
1201
+ ...rgba,
1202
+ a
1203
+ });
1204
+ }
1205
+ /** 从一个取变量值的函数算出整套配色。这是最底层的入口。 */
1206
+ function getChartColorsFromResolver(resolve) {
1207
+ const categorical = CATEGORICAL_TOKENS.map((t) => resolve(t));
1208
+ function ramp(hue) {
1209
+ return [
1210
+ 5,
1211
+ 4,
1212
+ 3,
1213
+ 2,
1214
+ 1
1215
+ ].map((step) => resolve(`--color-data-${hue}-${step}`));
1216
+ }
1217
+ const gray1 = resolve("--color-data-gray-1");
1218
+ function buildDiverging(negHue, posHue, n, midpoint = gray1) {
1219
+ if (n <= 0) return [];
1220
+ if (n === 1) return [midpoint];
1221
+ const half = Math.floor(n / 2);
1222
+ const hasCenter = n % 2 === 1;
1223
+ const negSide = sampleRamp(ramp(negHue), half);
1224
+ const posSide = sampleRamp(ramp(posHue), half).reverse();
1225
+ if (hasCenter) return [
1226
+ ...negSide,
1227
+ midpoint,
1228
+ ...posSide
1229
+ ];
1230
+ return [...negSide, ...posSide];
1231
+ }
1232
+ return {
1233
+ categorical(n) {
1234
+ if (!Number.isFinite(n) || n <= 0 || categorical.length === 0) return [];
1235
+ return Array.from({ length: n }, (_, i) => categorical[i % categorical.length]);
1236
+ },
1237
+ sequential: Object.fromEntries(SEQUENTIAL_HUES.map((hue) => [hue, (n) => sampleRamp(ramp(hue), n)])),
1238
+ diverging: {
1239
+ positiveNegative: (n) => buildDiverging("shamrock", "red", n),
1240
+ coldHot: (n) => buildDiverging("blue", "red", n),
1241
+ custom: (neg, pos, n, mid) => buildDiverging(neg, pos, n, mid)
1242
+ },
1243
+ semantic: {
1244
+ positive: resolve("--color-data-categorical-green"),
1245
+ negative: resolve("--color-data-categorical-red"),
1246
+ warning: resolve("--color-data-categorical-orange"),
1247
+ neutral: resolve("--color-data-neutral")
1248
+ },
1249
+ structural: {
1250
+ axis: resolve("--color-border-emphasized"),
1251
+ grid: resolve("--color-border"),
1252
+ tick: resolve("--color-border-emphasized"),
1253
+ label: resolve("--color-text-secondary")
1254
+ },
1255
+ alpha: hexAlpha
1256
+ };
1257
+ }
1258
+ //#endregion
1259
+ //#region src/useChartColors.ts
1260
+ /**
1261
+ * 图表配色。上游从当前主题对象里读出变量的具体值,我们在变量名前面加一层
1262
+ * `--astryx-` 之后直接返回 `var(...)` 字符串:SVG 的 `fill` / `stroke` 认 CSS 变量,
1263
+ * 浏览器解析出来的颜色和上游一模一样,而且换主题(或者深浅色切换)时不用重新渲染 ——
1264
+ * 变量跟着所在元素重新求值。服务端没有 DOM,这条路同样走得通。
1265
+ * `alpha()` 收到 `var(...)` 时改用 `color-mix()` 混透明,效果和上游拼出来的 `rgba()` 相同。
1266
+ */
1267
+ /** 变量名映射:上游读 `--color-data-*`,我们的主题里叫 `--astryx-color-data-*`。 */
1268
+ function cssVariable(name) {
1269
+ return `var(--astryx-${name.replace(/^--/, "")})`;
1270
+ }
1271
+ const BASE_COLORS = getChartColorsFromResolver(cssVariable);
1272
+ /** 颜色是 `var(...)` 时按比例混透明;其余照旧交给按 hex 解析的实现。 */
1273
+ function alpha(color, opacity) {
1274
+ if (!color.trimStart().startsWith("var(")) return BASE_COLORS.alpha(color, opacity);
1275
+ return `color-mix(in srgb, ${color} ${Math.round((Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1) * 1e3) / 10}%, transparent)`;
1276
+ }
1277
+ const CHART_COLORS = {
1278
+ ...BASE_COLORS,
1279
+ alpha
1280
+ };
1281
+ /**
1282
+ * 当前主题下的图表配色。
1283
+ *
1284
+ * ```ts
1285
+ * const colors = useChartColors()
1286
+ * colors.categorical(5)
1287
+ * colors.semantic.positive
1288
+ * ```
1289
+ *
1290
+ * 返回的对象整个包共用一份,身份不变,可以直接放进 `watch` / `computed` 的依赖里。
1291
+ */
1292
+ function useChartColors() {
1293
+ return CHART_COLORS;
1294
+ }
1295
+ //#endregion
1296
+ //#region src/Chart/useChartFrame.ts
1297
+ /** 把 `[0, ∞)` 之外的值(NaN、负数)压成 0,比例尺的取值域不能是负数。 */
1298
+ function nonNegative$1(value) {
1299
+ return Number.isFinite(value) ? Math.max(0, value) : 0;
1300
+ }
1301
+ /**
1302
+ * 图表根的全部计算:量宽度、定尺寸、分配调色板、跑布局、分发指针事件。
1303
+ *
1304
+ * 宽度有两个来源:浏览器里量容器(`ResizeObserver`,量到之后一直用),
1305
+ * 量不到时用 `width` prop。服务端没有 DOM,只有 `width` 这一条路。
1306
+ */
1307
+ function useChartFrame(props) {
1308
+ const containerRef = ref(null);
1309
+ const svgRef = ref(null);
1310
+ const measuredWidth = ref(null);
1311
+ onMounted(() => {
1312
+ const element = containerRef.value;
1313
+ if (!element || typeof ResizeObserver === "undefined") return;
1314
+ const observer = new ResizeObserver((entries) => {
1315
+ const entry = entries[0];
1316
+ if (entry) measuredWidth.value = entry.contentRect.width;
1317
+ });
1318
+ observer.observe(element);
1319
+ onScopeDispose(() => observer.disconnect());
1320
+ });
1321
+ const margin = computed(() => ({
1322
+ ...DEFAULT_MARGIN,
1323
+ ...props.margin
1324
+ }));
1325
+ const safeHeight = computed(() => nonNegative$1(props.height));
1326
+ const containerWidth = computed(() => measuredWidth.value ?? props.width ?? 0);
1327
+ const innerWidth = computed(() => nonNegative$1(containerWidth.value - margin.value.left - margin.value.right));
1328
+ const innerHeight = computed(() => nonNegative$1(safeHeight.value - margin.value.top - margin.value.bottom));
1329
+ const chartColors = useChartColors();
1330
+ watch(() => props.series, (series) => {
1331
+ const needsColor = series.filter((s) => !isUtilityMarkType(s.type) && s.color == null);
1332
+ const palette = chartColors.categorical(Math.max(needsColor.length, 1));
1333
+ for (let i = 0; i < needsColor.length; i++) needsColor[i]._resolvedColor = palette[i % palette.length];
1334
+ }, { immediate: true });
1335
+ const layout = computed(() => computeLayout({
1336
+ data: props.data,
1337
+ xKey: props.xKey,
1338
+ series: props.series,
1339
+ width: innerWidth.value,
1340
+ height: innerHeight.value,
1341
+ yBaseline: props.yBaseline,
1342
+ yDomain: props.yDomain,
1343
+ xDomain: props.xDomain
1344
+ }));
1345
+ const xScale = computed(() => layout.value.xScale);
1346
+ const yScale = computed(() => layout.value.yScale);
1347
+ const yBandScale = computed(() => layout.value.yBandScale);
1348
+ const resolved = computed(() => layout.value.resolved);
1349
+ const seriesCtx = computed(() => ({
1350
+ data: props.data,
1351
+ xKey: props.xKey,
1352
+ xScale: layout.value.xScale,
1353
+ yScale: layout.value.yScale,
1354
+ yBandScale: layout.value.yBandScale,
1355
+ width: innerWidth.value,
1356
+ height: innerHeight.value
1357
+ }));
1358
+ const pointerHandlers = /* @__PURE__ */ new Set();
1359
+ function onPointer(handler) {
1360
+ pointerHandlers.add(handler);
1361
+ return () => {
1362
+ pointerHandlers.delete(handler);
1363
+ };
1364
+ }
1365
+ function dispatch(event) {
1366
+ for (const handler of pointerHandlers) handler(event);
1367
+ }
1368
+ function handlePointerMove(event) {
1369
+ const svg = svgRef.value;
1370
+ const target = event.currentTarget;
1371
+ if (!svg || !target) return;
1372
+ const point = svg.createSVGPoint();
1373
+ point.x = event.clientX;
1374
+ point.y = event.clientY;
1375
+ const matrix = target.getScreenCTM()?.inverse();
1376
+ if (!matrix) return;
1377
+ const cursor = point.matrixTransform(matrix);
1378
+ let nearest = null;
1379
+ let bestXDistance = Number.POSITIVE_INFINITY;
1380
+ for (const [seriesKey, points] of layout.value.resolved) for (const p of points) {
1381
+ const distance = Math.abs(p.px - cursor.x);
1382
+ if (distance < bestXDistance) {
1383
+ bestXDistance = distance;
1384
+ nearest = {
1385
+ ...p,
1386
+ seriesKey
1387
+ };
1388
+ }
1389
+ }
1390
+ dispatch({
1391
+ x: cursor.x,
1392
+ y: cursor.y,
1393
+ nearest,
1394
+ active: event.buttons > 0
1395
+ });
1396
+ }
1397
+ function handlePointerLeave() {
1398
+ dispatch({
1399
+ x: -1,
1400
+ y: -1,
1401
+ nearest: null,
1402
+ active: false
1403
+ });
1404
+ }
1405
+ return {
1406
+ containerRef,
1407
+ svgRef,
1408
+ containerWidth,
1409
+ safeHeight,
1410
+ margin,
1411
+ innerWidth,
1412
+ innerHeight,
1413
+ xScale,
1414
+ yScale,
1415
+ yBandScale,
1416
+ resolved,
1417
+ seriesCtx,
1418
+ chart: {
1419
+ get width() {
1420
+ return innerWidth.value;
1421
+ },
1422
+ get height() {
1423
+ return innerHeight.value;
1424
+ },
1425
+ get margin() {
1426
+ return margin.value;
1427
+ },
1428
+ get data() {
1429
+ return props.data;
1430
+ },
1431
+ get xKey() {
1432
+ return props.xKey;
1433
+ },
1434
+ get xScale() {
1435
+ return layout.value.xScale;
1436
+ },
1437
+ get yScale() {
1438
+ return layout.value.yScale;
1439
+ },
1440
+ get yBandScale() {
1441
+ return layout.value.yBandScale;
1442
+ },
1443
+ get resolved() {
1444
+ return layout.value.resolved;
1445
+ },
1446
+ onPointer,
1447
+ svgRef
1448
+ },
1449
+ handlePointerMove,
1450
+ handlePointerLeave
1451
+ };
1452
+ }
1453
+ /** 一次渲染里唯一的 id,给 clipPath 和无障碍描述引用。 */
1454
+ function useChartIds() {
1455
+ const chartId = useId();
1456
+ return {
1457
+ clipId: `plot-${chartId.replace(/:/g, "")}`,
1458
+ descId: `${chartId}-desc`
1459
+ };
1460
+ }
1461
+ //#endregion
1462
+ //#region src/Chart/Chart.vue
1463
+ const _hoisted_1$2 = {
1464
+ key: 0,
1465
+ class: "astryx-chart__header"
1466
+ };
1467
+ const _hoisted_2$2 = ["data-legend-position"];
1468
+ const _hoisted_3$1 = { class: "astryx-chart__plot" };
1469
+ const _hoisted_4$1 = [
1470
+ "height",
1471
+ "aria-label",
1472
+ "aria-describedby"
1473
+ ];
1474
+ const _hoisted_5$1 = { key: 0 };
1475
+ const _hoisted_6$1 = ["id"];
1476
+ const _hoisted_7 = ["id"];
1477
+ const _hoisted_8 = ["width", "height"];
1478
+ const _hoisted_9 = ["transform"];
1479
+ const _hoisted_10 = ["width", "height"];
1480
+ const _hoisted_11 = { scope: "col" };
1481
+ const _hoisted_12 = { scope: "row" };
1482
+ const _sfc_main = /*@__PURE__*/ defineComponent({
1483
+ name: "AstryxChart",
1484
+ inheritAttrs: false,
1485
+ __name: "Chart",
1486
+ props: {
1487
+ data: {},
1488
+ xKey: {},
1489
+ series: {},
1490
+ height: { default: 300 },
1491
+ width: { default: 0 },
1492
+ margin: { default: void 0 },
1493
+ yBaseline: { default: "auto" },
1494
+ yDomain: { default: void 0 },
1495
+ xDomain: { default: void 0 },
1496
+ legend: {
1497
+ type: [Boolean, Object],
1498
+ default: false
1499
+ },
1500
+ tooltip: {
1501
+ type: [Boolean, Object],
1502
+ default: false
1503
+ },
1504
+ title: { default: void 0 },
1505
+ subtitle: { default: void 0 }
1506
+ },
1507
+ setup(__props, { expose: __expose }) {
1508
+ const props = __props;
1509
+ const { containerRef, svgRef, containerWidth, safeHeight, margin, innerWidth, innerHeight, resolved, seriesCtx, chart, handlePointerMove, handlePointerLeave } = useChartFrame(props);
1510
+ const { clipId, descId } = useChartIds();
1511
+ /** 模板里写变量名的 ref 读不到类型,绑定统一用函数式 ref 显式赋值。 */
1512
+ function setSvgRef(element) {
1513
+ svgRef.value = element ?? null;
1514
+ }
1515
+ function setContainerRef(element) {
1516
+ containerRef.value = element ?? null;
1517
+ }
1518
+ provide(chartContextKey, chart);
1519
+ const t = useChartMessages();
1520
+ const { locale } = useLocale();
1521
+ const primarySeries = computed(() => props.series.filter((s) => !isUtilityMarkType(s.type)));
1522
+ /**
1523
+ * SVG 的无障碍名字。给了 `title` 就用它,否则按主系列和 x 字段生成一句,
1524
+ * 没有主系列(全是标注)时退到通用的「图表」。
1525
+ */
1526
+ const accessibleLabel = computed(() => {
1527
+ if (props.title) return props.title;
1528
+ if (primarySeries.value.length === 0) return t("@astryx.chart.label");
1529
+ return t("@astryx.chart.labelWithSeries", {
1530
+ series: formatList(primarySeries.value.map((s) => s.label ?? s.key), locale.value),
1531
+ xKey: props.xKey
1532
+ });
1533
+ });
1534
+ const tableKeys = computed(() => Array.from(new Set(primarySeries.value.flatMap((s) => s.dataKeys))));
1535
+ /** 数据量小的时候额外给一张表;点太多就成了噪音,只留名字。 */
1536
+ const showDataTable = computed(() => props.data.length > 0 && tableKeys.value.length > 0 && props.data.length * tableKeys.value.length <= 100);
1537
+ /** 表格单元格里怎么显示一个值。 */
1538
+ function tableCell(value) {
1539
+ return value == null ? "" : String(value);
1540
+ }
1541
+ const legendConfig = computed(() => props.legend === true ? {} : props.legend || null);
1542
+ const legendItems = computed(() => legendConfig.value?.items ?? deriveLegendItems(props.series));
1543
+ const legendPosition = computed(() => legendConfig.value?.position ?? "bottom");
1544
+ const legendAlignment = computed(() => legendConfig.value?.alignment ?? "start");
1545
+ const tooltipConfig = computed(() => props.tooltip === true ? {} : props.tooltip || {});
1546
+ /** 量到宽度之前只用高度撑住占位;量到之后由标题、绘图区、图例自然撑开。 */
1547
+ const chartStyle = computed(() => containerWidth.value === 0 ? { height: `${safeHeight.value}px` } : void 0);
1548
+ __expose({ element: containerRef });
1549
+ return (_ctx, _cache) => {
1550
+ return openBlock(), createElementBlock(Fragment, null, [createCommentVNode("\n 只有一个根元素:量宽度的 `ResizeObserver` 盯着它,换元素就会跟丢。\n 还没量到宽度时它只撑住高度、不画内容,免得首帧画在 0 宽上。\n "), createElementVNode("div", mergeProps({ ref: setContainerRef }, _ctx.$attrs, {
1551
+ class: "astryx-chart",
1552
+ style: chartStyle.value
1553
+ }), [unref(containerWidth) !== 0 ? (openBlock(), createElementBlock(Fragment, { key: 0 }, [
1554
+ __props.title || __props.subtitle ? (openBlock(), createElementBlock("div", _hoisted_1$2, [__props.title ? (openBlock(), createBlock(unref(Text), {
1555
+ key: 0,
1556
+ type: "body",
1557
+ weight: "semibold",
1558
+ display: "block"
1559
+ }, {
1560
+ default: withCtx(() => [createTextVNode(toDisplayString(__props.title), 1)]),
1561
+ _: 1
1562
+ })) : createCommentVNode("v-if", true), __props.subtitle ? (openBlock(), createBlock(unref(Text), {
1563
+ key: 1,
1564
+ type: "supporting",
1565
+ display: "block"
1566
+ }, {
1567
+ default: withCtx(() => [createTextVNode(toDisplayString(__props.subtitle), 1)]),
1568
+ _: 1
1569
+ })) : createCommentVNode("v-if", true)])) : createCommentVNode("v-if", true),
1570
+ createElementVNode("div", {
1571
+ class: "astryx-chart__body",
1572
+ "data-legend-position": legendConfig.value ? legendPosition.value : void 0
1573
+ }, [legendConfig.value ? (openBlock(), createBlock(_sfc_main$3, {
1574
+ key: 0,
1575
+ class: "astryx-chart__legend",
1576
+ items: legendItems.value,
1577
+ position: legendPosition.value,
1578
+ alignment: legendAlignment.value
1579
+ }, null, 8, [
1580
+ "items",
1581
+ "position",
1582
+ "alignment"
1583
+ ])) : createCommentVNode("v-if", true), createElementVNode("div", _hoisted_3$1, [(openBlock(), createElementBlock("svg", {
1584
+ ref: setSvgRef,
1585
+ role: "img",
1586
+ width: "100%",
1587
+ height: unref(safeHeight),
1588
+ "aria-label": accessibleLabel.value,
1589
+ "aria-describedby": __props.subtitle ? unref(descId) : void 0
1590
+ }, [
1591
+ __props.title ? (openBlock(), createElementBlock("title", _hoisted_5$1, toDisplayString(__props.title), 1)) : createCommentVNode("v-if", true),
1592
+ __props.subtitle ? (openBlock(), createElementBlock("desc", {
1593
+ key: 1,
1594
+ id: unref(descId)
1595
+ }, toDisplayString(__props.subtitle), 9, _hoisted_6$1)) : createCommentVNode("v-if", true),
1596
+ createElementVNode("defs", null, [createElementVNode("clipPath", { id: unref(clipId) }, [createElementVNode("rect", {
1597
+ x: "0",
1598
+ y: "0",
1599
+ width: unref(innerWidth),
1600
+ height: unref(innerHeight)
1601
+ }, null, 8, _hoisted_8)], 8, _hoisted_7)]),
1602
+ createElementVNode("g", { transform: `translate(${unref(margin).left},${unref(margin).top})` }, [
1603
+ createCommentVNode(" 1. 网格 "),
1604
+ renderSlot(_ctx.$slots, "grid"),
1605
+ createCommentVNode(" 2. 系列:每个系列自己画,整体按绘图区裁切 "),
1606
+ createVNode(unref(ChartMarks_default), {
1607
+ series: __props.series,
1608
+ resolved: unref(resolved),
1609
+ "series-ctx": unref(seriesCtx),
1610
+ "clip-id": unref(clipId)
1611
+ }, null, 8, [
1612
+ "series",
1613
+ "resolved",
1614
+ "series-ctx",
1615
+ "clip-id"
1616
+ ]),
1617
+ createCommentVNode(" 3. 坐标轴 "),
1618
+ renderSlot(_ctx.$slots, "axes"),
1619
+ createCommentVNode(" 4. 指针捕获层和交互层 "),
1620
+ createElementVNode("rect", {
1621
+ x: "0",
1622
+ y: "0",
1623
+ width: unref(innerWidth),
1624
+ height: unref(innerHeight),
1625
+ fill: "transparent",
1626
+ onPointermove: _cache[0] || (_cache[0] = (...args) => unref(handlePointerMove) && unref(handlePointerMove)(...args)),
1627
+ onPointerleave: _cache[1] || (_cache[1] = (...args) => unref(handlePointerLeave) && unref(handlePointerLeave)(...args))
1628
+ }, null, 40, _hoisted_10),
1629
+ renderSlot(_ctx.$slots, "interactions"),
1630
+ createCommentVNode(" 5. 提示框:声明式开关;要完全控制就自己写一个 <ChartTooltip> 放进默认插槽 "),
1631
+ __props.tooltip ? (openBlock(), createBlock(_sfc_main$5, mergeProps({
1632
+ key: 0,
1633
+ series: __props.series
1634
+ }, tooltipConfig.value), createSlots({ _: 2 }, [_ctx.$slots.tooltip ? {
1635
+ name: "content",
1636
+ fn: withCtx((scope) => [renderSlot(_ctx.$slots, "tooltip", normalizeProps(guardReactiveProps(scope)))]),
1637
+ key: "0"
1638
+ } : void 0]), 1040, ["series"])) : createCommentVNode("v-if", true),
1639
+ createCommentVNode(" 6. 逃生口 "),
1640
+ renderSlot(_ctx.$slots, "default")
1641
+ ], 8, _hoisted_9)
1642
+ ], 8, _hoisted_4$1))])], 8, _hoisted_2$2),
1643
+ showDataTable.value ? (openBlock(), createBlock(unref(VisuallyHidden), {
1644
+ key: 1,
1645
+ as: "div"
1646
+ }, {
1647
+ default: withCtx(() => [createElementVNode("table", null, [
1648
+ createElementVNode("caption", null, toDisplayString(unref(t)("@astryx.chart.dataTableCaption", { label: accessibleLabel.value })), 1),
1649
+ createElementVNode("thead", null, [createElementVNode("tr", null, [createElementVNode("th", _hoisted_11, toDisplayString(__props.xKey), 1), (openBlock(true), createElementBlock(Fragment, null, renderList(tableKeys.value, (key) => {
1650
+ return openBlock(), createElementBlock("th", {
1651
+ key,
1652
+ scope: "col"
1653
+ }, toDisplayString(key), 1);
1654
+ }), 128))])]),
1655
+ createElementVNode("tbody", null, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.data, (row, index) => {
1656
+ return openBlock(), createElementBlock("tr", { key: index }, [createElementVNode("th", _hoisted_12, toDisplayString(tableCell(row[__props.xKey])), 1), (openBlock(true), createElementBlock(Fragment, null, renderList(tableKeys.value, (key) => {
1657
+ return openBlock(), createElementBlock("td", { key }, toDisplayString(tableCell(row[key])), 1);
1658
+ }), 128))]);
1659
+ }), 128))])
1660
+ ])]),
1661
+ _: 1
1662
+ })) : createCommentVNode("v-if", true)
1663
+ ], 64)) : createCommentVNode("v-if", true)], 16)], 2112);
1664
+ };
1665
+ }
1666
+ });
1667
+ //#endregion
1668
+ //#region src/ChartAxis/ChartAxis.vue
1669
+ const _hoisted_1$1 = ["transform", "aria-label"];
1670
+ const _hoisted_2$1 = [
1671
+ "x2",
1672
+ "y1",
1673
+ "y2"
1674
+ ];
1675
+ const _hoisted_3 = ["y2"];
1676
+ const _hoisted_4 = ["y"];
1677
+ const _hoisted_5 = ["x2"];
1678
+ const _hoisted_6 = ["x", "text-anchor"];
1679
+ const TICK_SIZE = 6;
1680
+ /**
1681
+ * 生成刻度:分类轴一个类一个刻度、对齐到带中心,连续轴用 d3 的 `ticks()`。
1682
+ *
1683
+ * 标签会按可用空间自动抽稀:横向按「宽度 ÷ 最宽标签」、纵向按「高度 ÷ 行高」估计上限,
1684
+ * 再按等间隔跳过。`maxTicks` 由调用方指定时优先用它。
1685
+ */
1686
+ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1687
+ name: "AstryxChartAxis",
1688
+ __name: "ChartAxis",
1689
+ props: {
1690
+ position: {},
1691
+ tickCount: { default: 5 },
1692
+ maxTicks: { default: void 0 },
1693
+ tickFormat: {
1694
+ type: Function,
1695
+ default: void 0
1696
+ },
1697
+ truncate: { default: void 0 },
1698
+ animated: {
1699
+ type: Boolean,
1700
+ default: true
1701
+ },
1702
+ showAxisLine: {
1703
+ type: Boolean,
1704
+ default: void 0
1705
+ },
1706
+ showTicks: {
1707
+ type: Boolean,
1708
+ default: false
1709
+ }
1710
+ },
1711
+ setup(__props) {
1712
+ const props = __props;
1713
+ /** 刻度线伸出轴线的长度。文字再往外让 4px,两者不重叠。 */
1714
+ const chart = useChart();
1715
+ const width = computed(() => chart.width);
1716
+ const height = computed(() => chart.height);
1717
+ const isHorizontal = computed(() => props.position === "top" || props.position === "bottom");
1718
+ /**
1719
+ * 分类 y 轴(比如热力图的每一行)走 `yBandScale`,要优先于线性 `yScale`:
1720
+ * 否则左轴会画一堆没有意义的数值刻度,而不是对齐到每一行的分类。
1721
+ */
1722
+ const scale = computed(() => isHorizontal.value ? chart.xScale : chart.yBandScale ?? chart.yScale);
1723
+ /** 开了 `showTicks` 就必须有轴线,不然短刻度线悬在空中。 */
1724
+ const renderAxisLine = computed(() => (props.showAxisLine ?? props.position === "bottom") || props.showTicks);
1725
+ /**
1726
+ * 交给 d3 之前先收拾刻度数量:`.ticks(n)` 会按 n 分配数组,
1727
+ * 特别大的 n 直接抛 `RangeError`,非有限值或负数则画不出刻度。
1728
+ * 所以夹到合理的整数(0 仍然表示不要刻度),非法输入退回默认值。
1729
+ */
1730
+ const safeTickCount = computed(() => Number.isFinite(props.tickCount) && props.tickCount >= 0 ? Math.min(Math.floor(props.tickCount), 1e3) : 5);
1731
+ /**
1732
+ * 连续比例尺(数值、时间)默认用 d3 自己的刻度格式化:数值不会出现
1733
+ * `0.30000000000000004` 这类浮点尘埃,时间也按日历写法输出。
1734
+ * 分类比例尺保留类名本身。
1735
+ */
1736
+ const autoFormat = computed(() => {
1737
+ const current = scale.value;
1738
+ if (props.tickFormat || isBandScale(current)) return null;
1739
+ const tickFormat = current.tickFormat(safeTickCount.value);
1740
+ return (value) => tickFormat(value);
1741
+ });
1742
+ function format(value) {
1743
+ const str = (props.tickFormat ?? autoFormat.value ?? String)(value);
1744
+ if (!props.truncate) return str;
1745
+ const shortened = truncateCharacters(str, props.truncate, "");
1746
+ return shortened === str ? str : `${shortened}\u2026`;
1747
+ }
1748
+ const ticks = computed(() => {
1749
+ const current = scale.value;
1750
+ let allTicks;
1751
+ if (isBandScale(current)) {
1752
+ const band = current;
1753
+ allTicks = band.domain().map((d) => ({
1754
+ value: d,
1755
+ offset: (band(d) ?? 0) + band.bandwidth() / 2,
1756
+ label: format(d)
1757
+ }));
1758
+ } else {
1759
+ const linear = current;
1760
+ allTicks = linear.ticks(safeTickCount.value).map((d) => ({
1761
+ value: d,
1762
+ offset: linear(d),
1763
+ label: format(d)
1764
+ }));
1765
+ }
1766
+ let cap = props.maxTicks;
1767
+ if (cap == null && allTicks.length > 1) {
1768
+ if (isHorizontal.value && chart.width > 0) {
1769
+ const approxLabelPx = allTicks.reduce((max, tick) => Math.max(max, characterCount(tick.label)), 1) * 7 + 16;
1770
+ cap = Math.max(1, Math.floor(chart.width / approxLabelPx));
1771
+ } else if (!isHorizontal.value && chart.height > 0) cap = Math.max(1, Math.floor(chart.height / 18));
1772
+ }
1773
+ if (cap && allTicks.length > cap) {
1774
+ const step = Math.ceil(allTicks.length / cap);
1775
+ allTicks = allTicks.filter((_, index) => index % step === 0);
1776
+ }
1777
+ return allTicks;
1778
+ });
1779
+ const transform = computed(() => {
1780
+ if (props.position === "bottom") return `translate(0,${chart.height})`;
1781
+ if (props.position === "right") return `translate(${chart.width},0)`;
1782
+ });
1783
+ /**
1784
+ * 底轴的轴线默认压在绘图区底边;但 y 轴范围跨过 0 时,轴线要落在 0 的位置,
1785
+ * 才能代表「零」而不是图表边缘。分类 y 轴没有零可言,仍贴底边。
1786
+ */
1787
+ const axisLineY = computed(() => {
1788
+ if (props.position !== "bottom" || chart.yBandScale) return 0;
1789
+ const domain = chart.yScale.domain();
1790
+ if (domain[0] < 0 && domain[1] > 0) return chart.yScale(0) - chart.height;
1791
+ return 0;
1792
+ });
1793
+ const tickTransition = computed(() => props.animated ? "transform 150ms linear, opacity 150ms ease" : void 0);
1794
+ /** 刻度在绘图区坐标里的位置。超出绘图区一截的刻度淡出,滑动过程中不会突然跳出来。 */
1795
+ function tickStyle(tick) {
1796
+ const axisLength = isHorizontal.value ? chart.width : chart.height;
1797
+ const isVisible = tick.offset >= -10 && tick.offset <= axisLength + 10;
1798
+ return {
1799
+ transform: isHorizontal.value ? `translateX(${tick.offset}px)` : `translateY(${tick.offset}px)`,
1800
+ ...tickTransition.value ? { transition: tickTransition.value } : {},
1801
+ opacity: isVisible ? 1 : 0
1802
+ };
1803
+ }
1804
+ const tickLineOffset = computed(() => props.position === "bottom" || props.position === "right" ? TICK_SIZE : -6);
1805
+ const labelY = computed(() => props.position === "bottom" ? 18 : -10);
1806
+ const labelX = computed(() => props.position === "left" ? -10 : 10);
1807
+ return (_ctx, _cache) => {
1808
+ return openBlock(), createElementBlock("g", {
1809
+ transform: transform.value,
1810
+ role: "group",
1811
+ "aria-label": `${__props.position} axis`
1812
+ }, [renderAxisLine.value ? (openBlock(), createElementBlock("line", {
1813
+ key: 0,
1814
+ x1: 0,
1815
+ x2: isHorizontal.value ? width.value : 0,
1816
+ y1: isHorizontal.value ? axisLineY.value : 0,
1817
+ y2: isHorizontal.value ? axisLineY.value : height.value,
1818
+ class: "astryx-chart-axis__line"
1819
+ }, null, 8, _hoisted_2$1)) : createCommentVNode("v-if", true), (openBlock(true), createElementBlock(Fragment, null, renderList(ticks.value, (tick) => {
1820
+ return openBlock(), createElementBlock("g", {
1821
+ key: String(tick.value),
1822
+ style: normalizeStyle(tickStyle(tick))
1823
+ }, [isHorizontal.value ? (openBlock(), createElementBlock(Fragment, { key: 0 }, [__props.showTicks ? (openBlock(), createElementBlock("line", {
1824
+ key: 0,
1825
+ y2: tickLineOffset.value,
1826
+ class: "astryx-chart-axis__mark"
1827
+ }, null, 8, _hoisted_3)) : createCommentVNode("v-if", true), createElementVNode("text", {
1828
+ y: labelY.value,
1829
+ "text-anchor": "middle",
1830
+ class: "astryx-chart-axis__label"
1831
+ }, toDisplayString(tick.label), 9, _hoisted_4)], 64)) : (openBlock(), createElementBlock(Fragment, { key: 1 }, [__props.showTicks ? (openBlock(), createElementBlock("line", {
1832
+ key: 0,
1833
+ x2: tickLineOffset.value,
1834
+ class: "astryx-chart-axis__mark"
1835
+ }, null, 8, _hoisted_5)) : createCommentVNode("v-if", true), createElementVNode("text", {
1836
+ x: labelX.value,
1837
+ dy: "0.32em",
1838
+ "text-anchor": __props.position === "left" ? "end" : "start",
1839
+ class: "astryx-chart-axis__label"
1840
+ }, toDisplayString(tick.label), 9, _hoisted_6)], 64))], 4);
1841
+ }), 128))], 8, _hoisted_1$1);
1842
+ };
1843
+ }
1844
+ });
1845
+ //#endregion
1846
+ //#region src/ChartGrid/ChartGrid.vue
1847
+ const _hoisted_1 = [
1848
+ "x2",
1849
+ "y1",
1850
+ "y2"
1851
+ ];
1852
+ const _hoisted_2 = [
1853
+ "x1",
1854
+ "x2",
1855
+ "y2"
1856
+ ];
1857
+ /**
1858
+ * 横向网格线。跳过 y=0 那一条:零线由坐标轴自己强调,画两遍会叠出一条更深的线。
1859
+ * 键用刻度的值而不是像素位置——退化比例尺(零宽、还没量到宽度)会算出重复或 NaN 的位置。
1860
+ */
1861
+ const _sfc_main$2 = /*@__PURE__*/ defineComponent({
1862
+ name: "AstryxChartGrid",
1863
+ __name: "ChartGrid",
1864
+ props: {
1865
+ horizontal: {
1866
+ type: Boolean,
1867
+ default: true
1868
+ },
1869
+ vertical: {
1870
+ type: Boolean,
1871
+ default: false
1872
+ },
1873
+ tickCount: { default: 5 }
1874
+ },
1875
+ setup(__props) {
1876
+ const props = __props;
1877
+ const chart = useChart();
1878
+ const width = computed(() => chart.width);
1879
+ const height = computed(() => chart.height);
1880
+ /**
1881
+ * 交给 d3 之前先收拾刻度数量:`.ticks(n)` 会按 n 分配数组,特别大的 n 直接抛
1882
+ * `RangeError`,非有限值或负数则画不出线。夹到合理整数(0 表示不要线),非法输入退回默认值。
1883
+ */
1884
+ const safeTickCount = computed(() => Number.isFinite(props.tickCount) && props.tickCount >= 0 ? Math.min(Math.floor(props.tickCount), 1e3) : 5);
1885
+ const horizontalLines = computed(() => {
1886
+ if (!props.horizontal) return [];
1887
+ return chart.yScale.ticks(safeTickCount.value).filter((tick) => tick !== 0).map((tick) => ({
1888
+ value: String(tick),
1889
+ pos: chart.yScale(tick)
1890
+ }));
1891
+ });
1892
+ /** 纵向网格线。分类轴对齐到每条带的中心,连续轴用 d3 的刻度。 */
1893
+ const verticalLines = computed(() => {
1894
+ if (!props.vertical) return [];
1895
+ const scale = chart.xScale;
1896
+ if (isBandScale(scale)) return scale.domain().map((d) => ({
1897
+ value: String(d),
1898
+ pos: (scale(d) ?? 0) + scale.bandwidth() / 2
1899
+ }));
1900
+ const linear = scale;
1901
+ return linear.ticks(safeTickCount.value).map((d) => ({
1902
+ value: String(d),
1903
+ pos: linear(d)
1904
+ }));
1905
+ });
1906
+ return (_ctx, _cache) => {
1907
+ return openBlock(), createElementBlock("g", null, [(openBlock(true), createElementBlock(Fragment, null, renderList(horizontalLines.value, (line) => {
1908
+ return openBlock(), createElementBlock("line", {
1909
+ key: `h-${line.value}`,
1910
+ x1: 0,
1911
+ x2: width.value,
1912
+ y1: line.pos,
1913
+ y2: line.pos,
1914
+ class: "astryx-chart-grid__line"
1915
+ }, null, 8, _hoisted_1);
1916
+ }), 128)), (openBlock(true), createElementBlock(Fragment, null, renderList(verticalLines.value, (line) => {
1917
+ return openBlock(), createElementBlock("line", {
1918
+ key: `v-${line.value}`,
1919
+ x1: line.pos,
1920
+ x2: line.pos,
1921
+ y1: 0,
1922
+ y2: height.value,
1923
+ class: "astryx-chart-grid__line"
1924
+ }, null, 8, _hoisted_2);
1925
+ }), 128))]);
1926
+ };
1927
+ }
1928
+ });
1929
+ //#endregion
1930
+ //#region src/markColor.ts
1931
+ /**
1932
+ * 决定一个标记最终用什么颜色,各个标记共用。
1933
+ *
1934
+ * 优先级:显式给的颜色(固定串或按点取色的函数)最高;没给就用图表根分配的调色板
1935
+ * 颜色(`_resolvedColor`);都没有才用兜底 token。所以一个没写颜色的
1936
+ * `bar('revenue')` 也能画出真颜色。
1937
+ *
1938
+ * 空字符串在每一层都当作「没设」——空的 `fill` / `stroke` 什么也画不出来。按点取色的
1939
+ * 函数返回非字符串或空串时也退回调色板,免得把非法颜色写进属性。
1940
+ */
1941
+ /** 判断一个值能不能当 CSS 颜色用:非空白字符串。 */
1942
+ function isPaint(value) {
1943
+ return typeof value === "string" && value.trim().length > 0;
1944
+ }
1945
+ /** 图表根分配的颜色,没有就用最后兜底的 token。 */
1946
+ function fallbackFill(def) {
1947
+ return isPaint(def._resolvedColor) ? def._resolvedColor : DEFAULT_SERIES_COLOR;
1948
+ }
1949
+ /** 整个系列共用一种颜色时(line、area、band 等)的取色。 */
1950
+ function seriesFill(def, color) {
1951
+ if (isPaint(color)) return color;
1952
+ return fallbackFill(def);
1953
+ }
1954
+ /** 每个数据点各取一色:取色函数优先,其次固定串,最后调色板。 */
1955
+ function pointFill(def, color, datum, index) {
1956
+ if (typeof color === "function") try {
1957
+ const resolved = color(datum, index);
1958
+ if (isPaint(resolved)) return resolved;
1959
+ } catch {}
1960
+ else if (isPaint(color)) return color;
1961
+ return fallbackFill(def);
1962
+ }
1963
+ //#endregion
1964
+ //#region src/marks/area.ts
1965
+ const CURVES$1 = {
1966
+ linear: curveLinear,
1967
+ monotone: curveMonotoneX,
1968
+ natural: curveNatural,
1969
+ step: curveStep
1970
+ };
1971
+ /** 面积的两条边界都有限,这段才画得出来。 */
1972
+ function isFinitePoint$1(p) {
1973
+ return Number.isFinite(p.px) && Number.isFinite(p.py) && Number.isFinite(p.py0);
1974
+ }
1975
+ /**
1976
+ * 面积系列:线下面填色,支持堆叠和渐变。
1977
+ *
1978
+ * 缺失值、非有限值(包括堆叠基线断开的位置)给 NaN,画的时候留空洞,
1979
+ * 而不是掉到 0 画成一片假的面积。
1980
+ */
1981
+ function area(dataKey, options = {}) {
1982
+ const color = options.color;
1983
+ const opacity = typeof options.opacity === "number" && Number.isFinite(options.opacity) ? Math.min(1, Math.max(0, options.opacity)) : .3;
1984
+ const curve = options.curve ?? "monotone";
1985
+ const gradient = options.gradient ?? false;
1986
+ const stroke = options.stroke ?? true;
1987
+ const seriesDef = {
1988
+ type: "area",
1989
+ key: dataKey,
1990
+ dataKeys: [dataKey],
1991
+ color,
1992
+ label: options.label ?? dataKey,
1993
+ layout: {
1994
+ stack: options.stack,
1995
+ includeZero: true
1996
+ },
1997
+ resolve(ctx, stackOffsets) {
1998
+ const { data, xKey, xScale, yScale } = ctx;
1999
+ const points = [];
2000
+ for (let i = 0; i < data.length; i++) {
2001
+ const d = data[i];
2002
+ let py;
2003
+ let py0;
2004
+ if (stackOffsets) {
2005
+ const offset = stackOffsets[i];
2006
+ py = offset ? yScale(offset.y1) : NaN;
2007
+ py0 = offset ? yScale(offset.y0) : NaN;
2008
+ } else {
2009
+ const raw = d[dataKey];
2010
+ py = yScale(typeof raw === "number" && Number.isFinite(raw) ? raw : NaN);
2011
+ py0 = yScale(0);
2012
+ }
2013
+ points.push({
2014
+ px: xPixel(d, xKey, xScale),
2015
+ py,
2016
+ py0,
2017
+ dataIndex: i
2018
+ });
2019
+ }
2020
+ return points;
2021
+ },
2022
+ render(resolved) {
2023
+ if (resolved.length === 0) return null;
2024
+ const curveFactory = CURVES$1[curve] ?? curveMonotoneX;
2025
+ const pathD = area$1().defined(isFinitePoint$1).x((d) => d.px).y0((d) => d.py0).y1((d) => d.py).curve(curveFactory)(resolved) ?? "";
2026
+ const strokeD = line$1().defined(isFinitePoint$1).x((d) => d.px).y((d) => d.py).curve(curveFactory)(resolved) ?? "";
2027
+ const gradientId = `area-grad-${(seriesDef._uid ?? dataKey).replace(/[^\w-]/g, "-")}`;
2028
+ const fillColor = seriesFill(seriesDef, color);
2029
+ return h("g", [
2030
+ gradient ? h("defs", [h("linearGradient", {
2031
+ id: gradientId,
2032
+ x1: 0,
2033
+ y1: 0,
2034
+ x2: 0,
2035
+ y2: 1
2036
+ }, [h("stop", {
2037
+ "offset": "0%",
2038
+ "stop-color": fillColor,
2039
+ "stop-opacity": opacity
2040
+ }), h("stop", {
2041
+ "offset": "100%",
2042
+ "stop-color": fillColor,
2043
+ "stop-opacity": 0
2044
+ })])]) : null,
2045
+ h("path", {
2046
+ "d": pathD,
2047
+ "fill": gradient ? `url(#${gradientId})` : fillColor,
2048
+ "fill-opacity": gradient ? 1 : opacity,
2049
+ "stroke": "none"
2050
+ }),
2051
+ stroke ? h("path", {
2052
+ "d": strokeD,
2053
+ "fill": "none",
2054
+ "stroke": fillColor,
2055
+ "stroke-width": 2
2056
+ }) : null
2057
+ ]);
2058
+ }
2059
+ };
2060
+ return seriesDef;
2061
+ }
2062
+ //#endregion
2063
+ //#region src/marks/band.ts
2064
+ /**
2065
+ * 置信区间带:上下界之间的那条面积。
2066
+ *
2067
+ * 上下界缺失或不是数字时给 NaN,画的时候断成空洞,而不是塌成一条零基线。
2068
+ * 属于标注类标记,不进图例和提示框。
2069
+ */
2070
+ function band(options) {
2071
+ const color = options.color ?? "var(--color-accent)";
2072
+ const opacity = options.opacity ?? .15;
2073
+ return {
2074
+ type: "band",
2075
+ key: `band-${options.upper}-${options.lower}`,
2076
+ dataKeys: [options.upper, options.lower],
2077
+ color: options.color ?? "var(--color-accent)",
2078
+ layout: {},
2079
+ resolve(ctx) {
2080
+ const { data, xKey, xScale, yScale } = ctx;
2081
+ const points = [];
2082
+ for (let i = 0; i < data.length; i++) {
2083
+ const d = data[i];
2084
+ const upperVal = d[options.upper];
2085
+ const lowerVal = d[options.lower];
2086
+ const upper = typeof upperVal === "number" && Number.isFinite(upperVal) ? upperVal : NaN;
2087
+ const lower = typeof lowerVal === "number" && Number.isFinite(lowerVal) ? lowerVal : NaN;
2088
+ points.push({
2089
+ px: xPixel(d, xKey, xScale),
2090
+ py: yScale(upper),
2091
+ py0: yScale(lower),
2092
+ dataIndex: i
2093
+ });
2094
+ }
2095
+ return points;
2096
+ },
2097
+ render(resolved) {
2098
+ if (resolved.length === 0) return null;
2099
+ const pathD = area$1().defined((d) => Number.isFinite(d.px) && Number.isFinite(d.py) && Number.isFinite(d.py0)).x((d) => d.px).y0((d) => d.py0).y1((d) => d.py).curve(curveMonotoneX)(resolved) ?? "";
2100
+ if (!pathD) return null;
2101
+ return h("path", {
2102
+ "d": pathD,
2103
+ "fill": color,
2104
+ "fill-opacity": opacity,
2105
+ "stroke": "none"
2106
+ });
2107
+ }
2108
+ };
2109
+ }
2110
+ //#endregion
2111
+ //#region src/marks/bar.ts
2112
+ /** 把用户给的透明度夹进 [0,1],不是有限数就用兜底值。 */
2113
+ function clampOpacity(value, fallback) {
2114
+ return Math.min(1, Math.max(0, typeof value === "number" && Number.isFinite(value) ? value : fallback));
2115
+ }
2116
+ /** 把用户给的长度(圆角、宽度)变成有限非负数。 */
2117
+ function nonNegative(value, fallback) {
2118
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : fallback;
2119
+ }
2120
+ /** 四个坐标都有限才算得出一条能画的矩形——非有限值会画出坏路径。 */
2121
+ function rectIsFinite(x, y, w, h) {
2122
+ return Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(w) && Number.isFinite(h);
2123
+ }
2124
+ /**
2125
+ * 只圆上两个角的矩形路径。半径为 0 时画直角矩形;
2126
+ * 坐标非有限时返回空路径(也就是什么都不画)。
2127
+ */
2128
+ function roundedTopRect(x, y, w, h, r) {
2129
+ if (!rectIsFinite(x, y, w, h)) return "";
2130
+ const cr = Math.min(r, w / 2, h / 2);
2131
+ if (!(cr > 0)) return `M${x},${y + h}V${y}H${x + w}V${y + h}Z`;
2132
+ return `M${x},${y + h}V${y + cr}Q${x},${y} ${x + cr},${y}H${x + w - cr}Q${x + w},${y} ${x + w},${y + cr}V${y + h}Z`;
2133
+ }
2134
+ /** 只圆下两个角的矩形路径。坐标非有限时返回空路径。 */
2135
+ function roundedBottomRect(x, y, w, h, r) {
2136
+ if (!rectIsFinite(x, y, w, h)) return "";
2137
+ const cr = Math.min(r, w / 2, h / 2);
2138
+ if (!(cr > 0)) return `M${x},${y}V${y + h}H${x + w}V${y}Z`;
2139
+ return `M${x},${y}V${y + h - cr}Q${x},${y + h} ${x + cr},${y + h}H${x + w - cr}Q${x + w},${y + h} ${x + w},${y + h - cr}V${y}Z`;
2140
+ }
2141
+ /**
2142
+ * 柱状图系列。y 轴按 `includeZero` 把 0 算进范围,负值从 0 往下长。
2143
+ *
2144
+ * 每条柱的像素宽度和偏移取决于分组情况,由 `resolve()` 现算并记在 `slot` 里,
2145
+ * 画的时候从同一份状态里取,所以两条路径看到的是同一个宽度。
2146
+ */
2147
+ function bar(dataKey, options = {}) {
2148
+ const color = options.color;
2149
+ const opacity = clampOpacity(options.opacity, 1);
2150
+ const radius = nonNegative(options.radius, 4);
2151
+ /** 一条柱的像素宽度,以及它相对所在「带」左边的偏移。 */
2152
+ const slot = {
2153
+ width: 0,
2154
+ offset: 0
2155
+ };
2156
+ const seriesDef = {
2157
+ type: "bar",
2158
+ key: dataKey,
2159
+ dataKeys: [dataKey],
2160
+ color: typeof color === "string" ? color : void 0,
2161
+ label: options.label ?? dataKey,
2162
+ layout: {
2163
+ stack: options.stack,
2164
+ group: options.group,
2165
+ includeZero: true
2166
+ },
2167
+ resolve(ctx, stackOffsets, groupInfo) {
2168
+ const { data, xKey, xScale, yScale } = ctx;
2169
+ if (!("bandwidth" in xScale)) return [];
2170
+ const bandScale = xScale;
2171
+ const bw = bandScale.bandwidth();
2172
+ if (groupInfo && groupInfo.count > 0) {
2173
+ const gutter = bw * .05;
2174
+ const totalGutters = gutter * (groupInfo.count - 1);
2175
+ slot.width = Math.max(0, (bw - totalGutters) / groupInfo.count);
2176
+ slot.offset = groupInfo.index * (slot.width + gutter);
2177
+ } else {
2178
+ slot.width = bw;
2179
+ slot.offset = 0;
2180
+ }
2181
+ const points = [];
2182
+ for (let i = 0; i < data.length; i++) {
2183
+ const d = data[i];
2184
+ const xBase = bandScale(String(d[xKey]));
2185
+ const px = xBase == null ? NaN : xBase + slot.offset + slot.width / 2;
2186
+ let py;
2187
+ let py0;
2188
+ if (stackOffsets) {
2189
+ const offset = stackOffsets[i];
2190
+ py = offset ? yScale(offset.y1) : NaN;
2191
+ py0 = offset ? yScale(offset.y0) : NaN;
2192
+ } else {
2193
+ const raw = d[dataKey];
2194
+ py = yScale(typeof raw === "number" && Number.isFinite(raw) ? raw : NaN);
2195
+ py0 = yScale(0);
2196
+ }
2197
+ points.push({
2198
+ px,
2199
+ py,
2200
+ py0,
2201
+ dataIndex: i
2202
+ });
2203
+ }
2204
+ return points;
2205
+ },
2206
+ render(resolved, ctx) {
2207
+ const { data } = ctx;
2208
+ const r = seriesDef._isTopOfStack !== false ? radius : 0;
2209
+ if (!(slot.width > 0)) return null;
2210
+ return h("g", { opacity }, resolved.map((p) => {
2211
+ const d = data[p.dataIndex];
2212
+ if (!d || !Number.isFinite(p.px) || !Number.isFinite(p.py) || !Number.isFinite(p.py0)) return null;
2213
+ const fill = pointFill(seriesDef, color, d, p.dataIndex);
2214
+ const x = p.px - slot.width / 2;
2215
+ const y = Math.min(p.py, p.py0);
2216
+ const barHeight = Math.max(0, Math.abs(p.py0 - p.py));
2217
+ const pathData = p.py <= p.py0 ? roundedTopRect(x, y, slot.width, barHeight, r) : roundedBottomRect(x, y, slot.width, barHeight, r);
2218
+ return h("path", {
2219
+ key: p.dataIndex,
2220
+ d: pathData,
2221
+ fill
2222
+ });
2223
+ }));
2224
+ }
2225
+ };
2226
+ return seriesDef;
2227
+ }
2228
+ //#endregion
2229
+ //#region src/marks/candlestick.ts
2230
+ /** 读一个数据列的有限数字,缺失或不是数字时给 NaN。 */
2231
+ function readNumber(d, key) {
2232
+ const v = d[key];
2233
+ return typeof v === "number" && Number.isFinite(v) ? v : NaN;
2234
+ }
2235
+ /**
2236
+ * 一根蜡烛实体的像素宽度。band 比例尺就是带宽;线性/时间比例尺按相邻点的
2237
+ * 最小间距推,只有一个点时退到一个不大的固定宽度。
2238
+ */
2239
+ function candleSlot(resolved, ctx) {
2240
+ const { xScale, width } = ctx;
2241
+ if (isBandScale(xScale)) return xScale.bandwidth();
2242
+ const xs = resolved.map((p) => p.px).filter((x) => Number.isFinite(x)).sort((a, b) => a - b);
2243
+ let minGap = Number.POSITIVE_INFINITY;
2244
+ for (let i = 1; i < xs.length; i++) {
2245
+ const gap = xs[i] - xs[i - 1];
2246
+ if (gap > 0 && gap < minGap) minGap = gap;
2247
+ }
2248
+ return Number.isFinite(minGap) ? minGap : Math.min(width, 40);
2249
+ }
2250
+ /**
2251
+ * K 线(OHLC)系列。band 比例尺和线性/时间比例尺都能用。
2252
+ * 点位的 y 取收盘价;缺任何一个 OHLC 字段的那根蜡烛画的时候跳过,而不是画到 0。
2253
+ * 属于数据系列,会进图例和提示框。
2254
+ */
2255
+ function candlestick(options) {
2256
+ const upColor = options.upColor ?? "var(--astryx-color-success)";
2257
+ const downColor = options.downColor ?? "var(--astryx-color-error)";
2258
+ return {
2259
+ type: "candlestick",
2260
+ key: `ohlc-${options.close}`,
2261
+ dataKeys: [
2262
+ options.open,
2263
+ options.high,
2264
+ options.low,
2265
+ options.close
2266
+ ],
2267
+ layout: {},
2268
+ resolve(ctx) {
2269
+ const { data, xKey, xScale, yScale } = ctx;
2270
+ const points = [];
2271
+ for (let i = 0; i < data.length; i++) {
2272
+ const d = data[i];
2273
+ const close = readNumber(d, options.close);
2274
+ points.push({
2275
+ px: xPixel(d, xKey, xScale),
2276
+ py: yScale(close),
2277
+ py0: yScale(0),
2278
+ dataIndex: i
2279
+ });
2280
+ }
2281
+ return points;
2282
+ },
2283
+ render(resolved, ctx) {
2284
+ const { data, yScale } = ctx;
2285
+ const bodyWidth = Math.max(1, candleSlot(resolved, ctx) * .6);
2286
+ return h("g", resolved.map((p) => {
2287
+ const d = data[p.dataIndex];
2288
+ const o = readNumber(d, options.open);
2289
+ const c = readNumber(d, options.close);
2290
+ const hi = readNumber(d, options.high);
2291
+ const lo = readNumber(d, options.low);
2292
+ if (!Number.isFinite(p.px) || !Number.isFinite(o) || !Number.isFinite(c) || !Number.isFinite(hi) || !Number.isFinite(lo)) return null;
2293
+ const col = c >= o ? upColor : downColor;
2294
+ const bodyTop = yScale(Math.max(o, c));
2295
+ const bodyHeight = Math.max(1, Math.abs(yScale(o) - yScale(c)));
2296
+ return h("g", { key: p.dataIndex }, [h("line", {
2297
+ "x1": p.px,
2298
+ "x2": p.px,
2299
+ "y1": yScale(hi),
2300
+ "y2": yScale(lo),
2301
+ "stroke": col,
2302
+ "stroke-width": 1
2303
+ }), h("rect", {
2304
+ x: p.px - bodyWidth / 2,
2305
+ y: bodyTop,
2306
+ width: bodyWidth,
2307
+ height: bodyHeight,
2308
+ fill: col
2309
+ })]);
2310
+ }));
2311
+ }
2312
+ };
2313
+ }
2314
+ //#endregion
2315
+ //#region src/marks/dot.ts
2316
+ /**
2317
+ * 散点系列。
2318
+ *
2319
+ * 缺失值和非有限值给 NaN,画的时候跳过,不会落到基线上。开了 `dodge` 会把落在
2320
+ * 同一 x 上的点横向错开,错开时不管非有限的点,一个缺失值不会把整组挤歪。
2321
+ */
2322
+ function dot(dataKey, options = {}) {
2323
+ const color = options.color;
2324
+ const radius = typeof options.radius === "number" && Number.isFinite(options.radius) ? Math.max(0, options.radius) : 4;
2325
+ const opacity = typeof options.opacity === "number" && Number.isFinite(options.opacity) ? Math.min(1, Math.max(0, options.opacity)) : .8;
2326
+ const dodge = options.dodge ?? false;
2327
+ const seriesDef = {
2328
+ type: "dot",
2329
+ key: dataKey,
2330
+ dataKeys: [dataKey],
2331
+ color: typeof color === "string" ? color : void 0,
2332
+ label: options.label ?? dataKey,
2333
+ layout: {},
2334
+ resolve(ctx) {
2335
+ const { data, xKey, xScale, yScale } = ctx;
2336
+ const points = [];
2337
+ for (let i = 0; i < data.length; i++) {
2338
+ const d = data[i];
2339
+ const raw = d[dataKey];
2340
+ const v = typeof raw === "number" && Number.isFinite(raw) ? raw : NaN;
2341
+ points.push({
2342
+ px: xPixel(d, xKey, xScale),
2343
+ py: yScale(v),
2344
+ py0: yScale(0),
2345
+ dataIndex: i
2346
+ });
2347
+ }
2348
+ if (dodge) {
2349
+ const groups = /* @__PURE__ */ new Map();
2350
+ for (const p of points) {
2351
+ if (!Number.isFinite(p.px) || !Number.isFinite(p.py)) continue;
2352
+ const key = Math.round(p.px);
2353
+ const group = groups.get(key) ?? [];
2354
+ group.push(p);
2355
+ groups.set(key, group);
2356
+ }
2357
+ const spacing = radius * 2 + 1;
2358
+ for (const group of groups.values()) {
2359
+ if (group.length < 2) continue;
2360
+ const mid = (group.length - 1) / 2;
2361
+ group.forEach((p, idx) => {
2362
+ p.px += (idx - mid) * spacing;
2363
+ });
2364
+ }
2365
+ }
2366
+ return points;
2367
+ },
2368
+ render(resolved, ctx) {
2369
+ const { data } = ctx;
2370
+ return h("g", resolved.map((p) => {
2371
+ const d = data[p.dataIndex];
2372
+ if (!d || !Number.isFinite(p.px) || !Number.isFinite(p.py)) return null;
2373
+ const fill = pointFill(seriesDef, color, d, p.dataIndex);
2374
+ return h("circle", {
2375
+ key: p.dataIndex,
2376
+ cx: p.px,
2377
+ cy: p.py,
2378
+ r: radius,
2379
+ fill,
2380
+ opacity
2381
+ });
2382
+ }));
2383
+ }
2384
+ };
2385
+ return seriesDef;
2386
+ }
2387
+ //#endregion
2388
+ //#region src/marks/errorBar.ts
2389
+ /**
2390
+ * 误差条(须线)系列:上下界是绝对值,所以不对称的区间也能画。
2391
+ * 配对 `bar()` 或 `dot()` 用。缺任何一侧上下界的点画的时候跳过,
2392
+ * 而不是贴着坐标轴画一根误导人的零高度须线。
2393
+ * 属于标注类标记,不进图例和提示框。
2394
+ *
2395
+ * @example
2396
+ * ```
2397
+ * series={[bar('mean'), errorBar({high: 'upper95', low: 'lower95'})]}
2398
+ * ```
2399
+ */
2400
+ function errorBar(options) {
2401
+ const { high, low } = options;
2402
+ const color = options.color ?? "var(--astryx-color-text-primary)";
2403
+ const strokeWidth = options.strokeWidth ?? 1.5;
2404
+ const capWidth = options.capWidth ?? 8;
2405
+ return {
2406
+ type: "errorBar",
2407
+ key: `errorBar-${high}-${low}`,
2408
+ dataKeys: [high, low],
2409
+ layout: {},
2410
+ resolve(ctx) {
2411
+ const { data, xKey, xScale, yScale } = ctx;
2412
+ const points = [];
2413
+ for (let i = 0; i < data.length; i++) {
2414
+ const d = data[i];
2415
+ const hiVal = d[high];
2416
+ const loVal = d[low];
2417
+ const upper = typeof hiVal === "number" && Number.isFinite(hiVal) ? hiVal : NaN;
2418
+ const lower = typeof loVal === "number" && Number.isFinite(loVal) ? loVal : NaN;
2419
+ points.push({
2420
+ px: xPixel(d, xKey, xScale),
2421
+ py: yScale(upper),
2422
+ py0: yScale(lower),
2423
+ dataIndex: i
2424
+ });
2425
+ }
2426
+ return points;
2427
+ },
2428
+ render(resolved) {
2429
+ const half = Number.isFinite(capWidth) ? Math.max(0, capWidth) / 2 : 0;
2430
+ return h("g", resolved.map((p) => {
2431
+ if (!Number.isFinite(p.px) || !Number.isFinite(p.py) || !Number.isFinite(p.py0)) return null;
2432
+ return h("g", { key: p.dataIndex }, [
2433
+ h("line", {
2434
+ "x1": p.px,
2435
+ "x2": p.px,
2436
+ "y1": p.py,
2437
+ "y2": p.py0,
2438
+ "stroke": color,
2439
+ "stroke-width": strokeWidth
2440
+ }),
2441
+ h("line", {
2442
+ "x1": p.px - half,
2443
+ "x2": p.px + half,
2444
+ "y1": p.py,
2445
+ "y2": p.py,
2446
+ "stroke": color,
2447
+ "stroke-width": strokeWidth
2448
+ }),
2449
+ h("line", {
2450
+ "x1": p.px - half,
2451
+ "x2": p.px + half,
2452
+ "y1": p.py0,
2453
+ "y2": p.py0,
2454
+ "stroke": color,
2455
+ "stroke-width": strokeWidth
2456
+ })
2457
+ ]);
2458
+ }));
2459
+ }
2460
+ };
2461
+ }
2462
+ //#endregion
2463
+ //#region src/marks/line.ts
2464
+ const CURVES = {
2465
+ linear: curveLinear,
2466
+ monotone: curveMonotoneX,
2467
+ natural: curveNatural,
2468
+ step: curveStep
2469
+ };
2470
+ /** 两个坐标都有限的点才画得出来。 */
2471
+ function isFinitePoint(p) {
2472
+ return Number.isFinite(p.px) && Number.isFinite(p.py);
2473
+ }
2474
+ /**
2475
+ * 折线系列。
2476
+ *
2477
+ * 缺失值和非有限值给 NaN,画的时候会断开(而不是掉到基线画出一条假线),
2478
+ * 逐点的小圆点也只画在有限的位置上。
2479
+ */
2480
+ function line(dataKey, options = {}) {
2481
+ const color = options.color;
2482
+ const curve = options.curve ?? "monotone";
2483
+ const strokeWidth = typeof options.strokeWidth === "number" && Number.isFinite(options.strokeWidth) ? Math.max(0, options.strokeWidth) : 2;
2484
+ const dots = options.dots ?? false;
2485
+ const seriesDef = {
2486
+ type: "line",
2487
+ key: dataKey,
2488
+ dataKeys: [dataKey],
2489
+ color,
2490
+ label: options.label ?? dataKey,
2491
+ layout: {},
2492
+ resolve(ctx) {
2493
+ const { data, xKey, xScale, yScale } = ctx;
2494
+ const points = [];
2495
+ for (let i = 0; i < data.length; i++) {
2496
+ const d = data[i];
2497
+ const raw = d[dataKey];
2498
+ const v = typeof raw === "number" && Number.isFinite(raw) ? raw : NaN;
2499
+ points.push({
2500
+ px: xPixel(d, xKey, xScale),
2501
+ py: yScale(v),
2502
+ py0: yScale(0),
2503
+ dataIndex: i
2504
+ });
2505
+ }
2506
+ return points;
2507
+ },
2508
+ render(resolved) {
2509
+ if (resolved.length === 0) return null;
2510
+ const pathD = line$1().defined(isFinitePoint).x((d) => d.px).y((d) => d.py).curve(CURVES[curve] ?? curveMonotoneX)(resolved) ?? "";
2511
+ const strokeColor = seriesFill(seriesDef, color);
2512
+ return h("g", [h("path", {
2513
+ "d": pathD,
2514
+ "fill": "none",
2515
+ "stroke": strokeColor,
2516
+ "stroke-width": strokeWidth
2517
+ }), dots ? resolved.filter(isFinitePoint).map((p) => h("circle", {
2518
+ key: p.dataIndex,
2519
+ cx: p.px,
2520
+ cy: p.py,
2521
+ r: 3,
2522
+ fill: strokeColor
2523
+ })) : null]);
2524
+ }
2525
+ };
2526
+ return seriesDef;
2527
+ }
2528
+ //#endregion
2529
+ //#region src/marks/referenceLine.ts
2530
+ const BADGE_H = 14;
2531
+ const BADGE_RX = 3;
2532
+ const LABEL_FONT_SIZE = 10;
2533
+ const LABEL_CHAR_W = 6.5;
2534
+ const LABEL_PAD = 12;
2535
+ function clamp(v, lo, hi) {
2536
+ return Math.max(lo, Math.min(v, hi));
2537
+ }
2538
+ /** 标签牌的估算宽度;没有标签就是 0。 */
2539
+ function labelWidth(label) {
2540
+ return label ? label.length * LABEL_CHAR_W + LABEL_PAD : 0;
2541
+ }
2542
+ /**
2543
+ * 标签牌,按左上角定位。调用方先把左上角夹进绘图区,所以横线、竖线两个方向
2544
+ * 都能用同一套代码摆正。
2545
+ */
2546
+ function referenceLabel(x, topY, width, color, text) {
2547
+ return h("g", {
2548
+ "transform": `translate(${x},${topY})`,
2549
+ "pointer-events": "none"
2550
+ }, [h("rect", {
2551
+ "x": 0,
2552
+ "y": 0,
2553
+ width,
2554
+ "height": BADGE_H,
2555
+ "rx": BADGE_RX,
2556
+ "fill": "var(--astryx-color-background-popover)",
2557
+ "fill-opacity": .85,
2558
+ "stroke": color,
2559
+ "stroke-width": .5
2560
+ }), h("text", {
2561
+ "x": width / 2,
2562
+ "y": BADGE_H / 2,
2563
+ "dy": "0.35em",
2564
+ "text-anchor": "middle",
2565
+ "font-size": LABEL_FONT_SIZE,
2566
+ "font-weight": 500,
2567
+ "fill": color
2568
+ }, text)]);
2569
+ }
2570
+ /**
2571
+ * 横向或纵向的参考线、参考带。它不是数据系列,画在固定的数值上。
2572
+ *
2573
+ * 画的位置要现算(取决于比例尺、绘图区大小和标签宽度),所以这个工厂没有点位可算,
2574
+ * `resolve()` 永远返回空数组——所有几何都在 `render()` 里。
2575
+ * 标签牌会被夹在绘图区内(绘图区本身是裁切的),横竖两个方向都一样。
2576
+ */
2577
+ function referenceLine(options) {
2578
+ const color = options.color ?? "var(--astryx-color-border-emphasized)";
2579
+ const strokeWidth = options.strokeWidth ?? 1;
2580
+ const strokeDasharray = options.strokeDasharray ?? "6 3";
2581
+ const label = options.label;
2582
+ const labelPosition = options.labelPosition ?? "end";
2583
+ const bandOpacity = options.bandOpacity ?? .1;
2584
+ const textW = labelWidth(label);
2585
+ return {
2586
+ type: "referenceLine",
2587
+ key: `ref-${options.y ?? options.x ?? "none"}`,
2588
+ dataKeys: [],
2589
+ layout: {},
2590
+ resolve() {
2591
+ return [];
2592
+ },
2593
+ render(_resolved, ctx) {
2594
+ const { width, height, xScale, yScale } = ctx;
2595
+ const bxMax = width - textW - 2;
2596
+ if (options.y != null) {
2597
+ if (!Number.isFinite(options.y)) return null;
2598
+ const py = yScale(options.y);
2599
+ if (!Number.isFinite(py)) return null;
2600
+ const bx = clamp(labelPosition === "end" ? bxMax : 2, 2, bxMax);
2601
+ const labelTopY = clamp(py - BADGE_H / 2, 0, height - BADGE_H);
2602
+ if (options.y2 != null && Number.isFinite(options.y2)) {
2603
+ const py2 = yScale(options.y2);
2604
+ const top = Math.min(py, py2);
2605
+ const bandHeight = Math.abs(py2 - py);
2606
+ return h("g", [
2607
+ h("rect", {
2608
+ x: 0,
2609
+ y: top,
2610
+ width,
2611
+ height: bandHeight,
2612
+ fill: color,
2613
+ opacity: bandOpacity
2614
+ }),
2615
+ h("line", {
2616
+ "x1": 0,
2617
+ "x2": width,
2618
+ "y1": py,
2619
+ "y2": py,
2620
+ "stroke": color,
2621
+ "stroke-width": strokeWidth,
2622
+ "stroke-dasharray": strokeDasharray
2623
+ }),
2624
+ h("line", {
2625
+ "x1": 0,
2626
+ "x2": width,
2627
+ "y1": py2,
2628
+ "y2": py2,
2629
+ "stroke": color,
2630
+ "stroke-width": strokeWidth,
2631
+ "stroke-dasharray": strokeDasharray
2632
+ }),
2633
+ label ? referenceLabel(bx, labelTopY, textW, color, label) : null
2634
+ ]);
2635
+ }
2636
+ return h("g", [h("line", {
2637
+ "x1": 0,
2638
+ "x2": width,
2639
+ "y1": py,
2640
+ "y2": py,
2641
+ "stroke": color,
2642
+ "stroke-width": strokeWidth,
2643
+ "stroke-dasharray": strokeDasharray
2644
+ }), label ? referenceLabel(bx, labelTopY, textW, color, label) : null]);
2645
+ }
2646
+ if (options.x != null && !isBandScale(xScale)) {
2647
+ if (!Number.isFinite(options.x)) return null;
2648
+ const px = xScale(options.x);
2649
+ if (!Number.isFinite(px)) return null;
2650
+ const labelTopY = clamp(labelPosition === "end" ? 4 : height - BADGE_H - 4, 0, height - BADGE_H);
2651
+ const bx = clamp(px - textW / 2, 2, bxMax);
2652
+ return h("g", [h("line", {
2653
+ "x1": px,
2654
+ "x2": px,
2655
+ "y1": 0,
2656
+ "y2": height,
2657
+ "stroke": color,
2658
+ "stroke-width": strokeWidth,
2659
+ "stroke-dasharray": strokeDasharray
2660
+ }), label ? referenceLabel(bx, labelTopY, textW, color, label) : null]);
2661
+ }
2662
+ return null;
2663
+ }
2664
+ };
2665
+ }
2666
+ //#endregion
2667
+ export { _sfc_main as Chart, _sfc_main$1 as ChartAxis, _sfc_main$2 as ChartGrid, _sfc_main$3 as ChartLegend, _sfc_main$4 as ChartSwatch, _sfc_main$5 as ChartTooltip, DEFAULT_MARGIN, MAX_TABLE_POINTS, area, band, bar, candlestick, compactNumber, computeCardPosition, computeLayout, currency, deriveLegendItems, deriveTooltipSeriesValues, dot, errorBar, formatColor, formatHex, formatList, getChartColorsFromResolver, isBandScale, isUtilityMarkType, line, monthYear, parseColor, parseHex, parseRgb, percent, pointFill, referenceLine, seriesFill, shortDate, shouldRenderHoverDot, swatchVariantForType, toGLFloats, useChart, useChartColors, xPixel };