@cfasim-ui/charts 0.7.7 → 0.8.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/BarChart/BarChart.d.ts +9 -26
- package/dist/ChartMenu/ChartMenu.d.ts +3 -2
- package/dist/ChartTooltip/ChartTooltip.d.ts +9 -12
- package/dist/ChoroplethMap/ChoroplethMap.d.ts +28 -189
- package/dist/ChoroplethMap/ChoroplethTooltip.d.ts +20 -27
- package/dist/ChoroplethMap/canvasLayer.d.ts +27 -3
- package/dist/DataTable/DataTable.d.ts +3 -2
- package/dist/LineChart/LineChart.d.ts +9 -26
- package/dist/_shared/ChartAnnotations.d.ts +3 -2
- package/dist/_shared/ChartZoomControls.d.ts +3 -2
- package/dist/_shared/index.d.ts +2 -1
- package/dist/_shared/mapTheme.d.ts +159 -0
- package/dist/_shared/mapTheme.test.d.ts +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1710 -1432
- package/docs/BarChart.md +776 -0
- package/docs/ChoroplethMap.md +1267 -0
- package/docs/DataTable.md +386 -0
- package/docs/LineChart.md +1267 -0
- package/docs/index.json +56 -0
- package/package.json +25 -21
- package/src/BarChart/BarChart.md +743 -0
- package/src/BarChart/BarChart.vue +1901 -0
- package/src/ChartMenu/ChartMenu.vue +220 -0
- package/src/ChartMenu/download.ts +120 -0
- package/src/ChartTooltip/ChartTooltip.vue +97 -0
- package/src/ChoroplethMap/ChoroplethMap.md +1227 -0
- package/src/ChoroplethMap/ChoroplethMap.vue +3676 -0
- package/src/ChoroplethMap/ChoroplethTooltip.vue +103 -0
- package/src/ChoroplethMap/canvasLayer.ts +373 -0
- package/src/ChoroplethMap/cityLayout.ts +261 -0
- package/src/ChoroplethMap/hsaMapping.ts +4116 -0
- package/src/DataTable/DataTable.md +372 -0
- package/src/DataTable/DataTable.vue +406 -0
- package/src/LineChart/LineChart.md +1225 -0
- package/src/LineChart/LineChart.vue +1555 -0
- package/src/_shared/ChartAnnotations.vue +420 -0
- package/src/_shared/ChartZoomControls.vue +138 -0
- package/src/_shared/annotations.ts +106 -0
- package/src/_shared/axes.ts +69 -0
- package/src/_shared/chartProps.ts +201 -0
- package/src/_shared/computeTicks.ts +42 -0
- package/src/_shared/contrast.ts +100 -0
- package/src/_shared/dateAxis.ts +501 -0
- package/src/_shared/index.ts +78 -0
- package/src/_shared/mapTheme.ts +551 -0
- package/src/_shared/scale.ts +86 -0
- package/src/_shared/seriesCsv.ts +68 -0
- package/src/_shared/touch.ts +8 -0
- package/src/_shared/useChartFoundation.ts +175 -0
- package/src/_shared/useChartFullscreen.ts +254 -0
- package/src/_shared/useChartMenu.ts +111 -0
- package/src/_shared/useChartPadding.ts +235 -0
- package/src/_shared/useChartSize.ts +58 -0
- package/src/_shared/useChartTooltip.ts +205 -0
- package/src/env.d.ts +4 -0
- package/src/hsa-mapping.ts +1 -0
- package/src/index.ts +41 -0
- package/src/tooltip-position.ts +55 -0
- package/src/us-cities/data.ts +1371 -0
- package/src/us-cities/index.ts +122 -0
- package/src/us-cities.ts +7 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import type { ChartAnnotation } from "./annotations.js";
|
|
4
|
+
import type { ChartBounds } from "./useChartPadding.js";
|
|
5
|
+
|
|
6
|
+
const props = withDefaults(
|
|
7
|
+
defineProps<{
|
|
8
|
+
annotations?: readonly ChartAnnotation[];
|
|
9
|
+
/**
|
|
10
|
+
* Project an annotation's `(x, y)` (data coordinates) to pixel
|
|
11
|
+
* coordinates on the chart canvas. Return `null` to drop the
|
|
12
|
+
* annotation (e.g. an off-projection point on a map).
|
|
13
|
+
*/
|
|
14
|
+
project: (x: number, y: number) => { x: number; y: number } | null;
|
|
15
|
+
/**
|
|
16
|
+
* Pixel-space bounds of the plot area. Required for rule annotations
|
|
17
|
+
* so the line can span the full plot. When omitted, rule annotations
|
|
18
|
+
* are skipped.
|
|
19
|
+
*/
|
|
20
|
+
bounds?: ChartBounds;
|
|
21
|
+
}>(),
|
|
22
|
+
{
|
|
23
|
+
annotations: () => [],
|
|
24
|
+
bounds: undefined,
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// Match the x/y axis label styling so annotations blend in by default.
|
|
29
|
+
const DEFAULT_FONT_SIZE = 13;
|
|
30
|
+
const DEFAULT_FONT_WEIGHT = "normal";
|
|
31
|
+
const BOLD_FONT_WEIGHT = 700;
|
|
32
|
+
const DEFAULT_OUTLINE_COLOR = "var(--color-bg-0, #fff)";
|
|
33
|
+
const DEFAULT_OUTLINE_WIDTH = 3;
|
|
34
|
+
const DEFAULT_LINE_WIDTH = 1;
|
|
35
|
+
const ANCHOR_GAP_PX = 4;
|
|
36
|
+
const LABEL_GAP_PX = 6;
|
|
37
|
+
const LINE_HEIGHT_RATIO = 1.2;
|
|
38
|
+
// Ratio of font-size that puts the pointer endpoint at the visual center
|
|
39
|
+
// of a text line (between baseline and cap-height). Lands on the x-height
|
|
40
|
+
// middle for most fonts.
|
|
41
|
+
const LINE_CENTER_RATIO = 0.35;
|
|
42
|
+
|
|
43
|
+
interface TextRun {
|
|
44
|
+
text: string;
|
|
45
|
+
bold: boolean;
|
|
46
|
+
italic: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface RenderedAnnotation {
|
|
50
|
+
lines: TextRun[][];
|
|
51
|
+
textX: number;
|
|
52
|
+
textY: number;
|
|
53
|
+
textAnchor: "start" | "middle" | "end";
|
|
54
|
+
dy: number;
|
|
55
|
+
fontSize: number;
|
|
56
|
+
fontWeight: string | number;
|
|
57
|
+
color: string;
|
|
58
|
+
outlineColor: string;
|
|
59
|
+
outlineWidth: number;
|
|
60
|
+
pointerPath: string;
|
|
61
|
+
lineColor: string;
|
|
62
|
+
lineWidth: number;
|
|
63
|
+
lineDash?: string;
|
|
64
|
+
arrow: boolean;
|
|
65
|
+
// Inline arrow geometry. Rendered as a triangle with explicit fill so it
|
|
66
|
+
// renders correctly in Safari (which doesn't support `context-stroke` on
|
|
67
|
+
// SVG markers). Present only when an arrow should be drawn.
|
|
68
|
+
arrowTip?: { x: number; y: number; angle: number };
|
|
69
|
+
arrowTransform: string;
|
|
70
|
+
rule?: { x1: number; y1: number; x2: number; y2: number };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveDash(
|
|
74
|
+
dash: string | number | readonly number[] | undefined,
|
|
75
|
+
): string | undefined {
|
|
76
|
+
if (dash === undefined) return undefined;
|
|
77
|
+
if (typeof dash === "number") return `${dash} ${dash}`;
|
|
78
|
+
if (typeof dash === "string") return dash;
|
|
79
|
+
return dash.join(" ");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse a single line for `**bold**` and `_italic_` runs. Markers
|
|
84
|
+
* compose (`**_both_**`) and are forgiving — an unclosed marker carries
|
|
85
|
+
* its state through the rest of the line.
|
|
86
|
+
*/
|
|
87
|
+
function parseInline(line: string): TextRun[] {
|
|
88
|
+
const out: TextRun[] = [];
|
|
89
|
+
let bold = false;
|
|
90
|
+
let italic = false;
|
|
91
|
+
let buf = "";
|
|
92
|
+
const flush = () => {
|
|
93
|
+
if (buf) out.push({ text: buf, bold, italic });
|
|
94
|
+
buf = "";
|
|
95
|
+
};
|
|
96
|
+
for (let i = 0; i < line.length; i++) {
|
|
97
|
+
const ch = line[i];
|
|
98
|
+
if (ch === "*" && line[i + 1] === "*") {
|
|
99
|
+
flush();
|
|
100
|
+
bold = !bold;
|
|
101
|
+
i++;
|
|
102
|
+
} else if (ch === "_") {
|
|
103
|
+
flush();
|
|
104
|
+
italic = !italic;
|
|
105
|
+
} else {
|
|
106
|
+
buf += ch;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
flush();
|
|
110
|
+
return out.length === 0 ? [{ text: "", bold: false, italic: false }] : out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const items = computed<RenderedAnnotation[]>(() => {
|
|
114
|
+
const out: RenderedAnnotation[] = [];
|
|
115
|
+
for (const a of props.annotations) {
|
|
116
|
+
const projected = props.project(a.x, a.y);
|
|
117
|
+
if (!projected) continue;
|
|
118
|
+
if (!isFinite(projected.x) || !isFinite(projected.y)) continue;
|
|
119
|
+
|
|
120
|
+
const pointer = a.pointer ?? "curved";
|
|
121
|
+
const isRule = pointer.startsWith("rule");
|
|
122
|
+
|
|
123
|
+
// Rule pointers require known plot bounds.
|
|
124
|
+
if (isRule && !props.bounds) continue;
|
|
125
|
+
|
|
126
|
+
const { x: offsetX, y: offsetY } = a.offset;
|
|
127
|
+
const labelX = projected.x + offsetX;
|
|
128
|
+
const labelY = projected.y + offsetY;
|
|
129
|
+
const color = a.color ?? "currentColor";
|
|
130
|
+
const fontSize = a.fontSize ?? DEFAULT_FONT_SIZE;
|
|
131
|
+
const fontWeight = a.fontWeight ?? DEFAULT_FONT_WEIGHT;
|
|
132
|
+
const outlineColor = a.outlineColor ?? DEFAULT_OUTLINE_COLOR;
|
|
133
|
+
const outlineWidth = a.outlineWidth ?? DEFAULT_OUTLINE_WIDTH;
|
|
134
|
+
const lineColor = a.lineColor ?? color;
|
|
135
|
+
const lineWidth = a.lineWidth ?? DEFAULT_LINE_WIDTH;
|
|
136
|
+
const lineDash = resolveDash(a.lineDash);
|
|
137
|
+
const alignMap = { left: "start", center: "middle", right: "end" } as const;
|
|
138
|
+
const align =
|
|
139
|
+
a.align ?? (offsetX > 0 ? "left" : offsetX < 0 ? "right" : "center");
|
|
140
|
+
const textAnchor = alignMap[align];
|
|
141
|
+
|
|
142
|
+
const lines = a.text.split("\n").map(parseInline);
|
|
143
|
+
|
|
144
|
+
let rule: RenderedAnnotation["rule"];
|
|
145
|
+
let pointerPath = "";
|
|
146
|
+
let arrowTip: RenderedAnnotation["arrowTip"];
|
|
147
|
+
const wantArrow = !isRule && (a.arrow ?? true);
|
|
148
|
+
if (isRule && props.bounds) {
|
|
149
|
+
rule = computeRule(pointer, projected.x, projected.y, props.bounds);
|
|
150
|
+
} else {
|
|
151
|
+
const built = buildPointerPath(
|
|
152
|
+
projected.x,
|
|
153
|
+
projected.y,
|
|
154
|
+
labelX,
|
|
155
|
+
labelY,
|
|
156
|
+
fontSize,
|
|
157
|
+
lines.length,
|
|
158
|
+
pointer as "curved" | "straight" | "none",
|
|
159
|
+
);
|
|
160
|
+
pointerPath = built.path;
|
|
161
|
+
if (wantArrow && built.arrow) arrowTip = built.arrow;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
out.push({
|
|
165
|
+
lines,
|
|
166
|
+
textX: labelX,
|
|
167
|
+
textY: labelY,
|
|
168
|
+
textAnchor,
|
|
169
|
+
dy: fontSize * LINE_HEIGHT_RATIO,
|
|
170
|
+
fontSize,
|
|
171
|
+
fontWeight,
|
|
172
|
+
color,
|
|
173
|
+
outlineColor,
|
|
174
|
+
outlineWidth,
|
|
175
|
+
pointerPath,
|
|
176
|
+
lineColor,
|
|
177
|
+
lineWidth,
|
|
178
|
+
lineDash,
|
|
179
|
+
arrow: wantArrow,
|
|
180
|
+
arrowTip,
|
|
181
|
+
arrowTransform: arrowTip
|
|
182
|
+
? `translate(${arrowTip.x} ${arrowTip.y}) rotate(${arrowTip.angle})`
|
|
183
|
+
: "",
|
|
184
|
+
rule,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Endpoints for a rule line through the anchor.
|
|
192
|
+
* - `ruleX` / `ruleY`: full plot span on the named axis.
|
|
193
|
+
* - `ruleUp` / `ruleDown` / `ruleFromLeft` / `ruleFromRight`: partial
|
|
194
|
+
* rule from one plot edge in to the anchor.
|
|
195
|
+
*/
|
|
196
|
+
function computeRule(
|
|
197
|
+
pointer: string,
|
|
198
|
+
ax: number,
|
|
199
|
+
ay: number,
|
|
200
|
+
b: ChartBounds,
|
|
201
|
+
): { x1: number; y1: number; x2: number; y2: number } {
|
|
202
|
+
switch (pointer) {
|
|
203
|
+
case "ruleX":
|
|
204
|
+
return { x1: ax, y1: b.top, x2: ax, y2: b.bottom };
|
|
205
|
+
case "ruleY":
|
|
206
|
+
return { x1: b.left, y1: ay, x2: b.right, y2: ay };
|
|
207
|
+
case "ruleUp":
|
|
208
|
+
return { x1: ax, y1: b.bottom, x2: ax, y2: ay };
|
|
209
|
+
case "ruleDown":
|
|
210
|
+
return { x1: ax, y1: b.top, x2: ax, y2: ay };
|
|
211
|
+
case "ruleFromLeft":
|
|
212
|
+
return { x1: b.left, y1: ay, x2: ax, y2: ay };
|
|
213
|
+
case "ruleFromRight":
|
|
214
|
+
return { x1: b.right, y1: ay, x2: ax, y2: ay };
|
|
215
|
+
default:
|
|
216
|
+
return { x1: ax, y1: ay, x2: ax, y2: ay };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Build the pointer line from the anchor to the label.
|
|
222
|
+
*
|
|
223
|
+
* - When the label has offset in only one dimension, the line is
|
|
224
|
+
* straight (vertical or horizontal) ending at the label's baseline.
|
|
225
|
+
* - When both dimensions have offset, the line is a quarter-arc:
|
|
226
|
+
* a quadratic Bezier with the control point at `(anchorX, targetY)`
|
|
227
|
+
* where `targetY` is the visual center of the text line nearest the
|
|
228
|
+
* anchor (slightly above its baseline). The curve emerges from the
|
|
229
|
+
* anchor vertically toward the label's row, then bends horizontally
|
|
230
|
+
* into the label.
|
|
231
|
+
*
|
|
232
|
+
* `targetY` tracks the *near* edge of the text block: the first line
|
|
233
|
+
* when the label sits below the anchor (connector points up at the top
|
|
234
|
+
* line), the last line when the label sits above the anchor (connector
|
|
235
|
+
* points up at the bottom line) — so multi-line text above the point
|
|
236
|
+
* connects to its bottom edge instead of having the line cut through it.
|
|
237
|
+
*/
|
|
238
|
+
interface PointerGeom {
|
|
239
|
+
path: string;
|
|
240
|
+
// Tip position and rotation (degrees) for the inline arrow head. The
|
|
241
|
+
// arrow points opposite the path's start tangent — matching the look of
|
|
242
|
+
// `marker-start` with `orient="auto-start-reverse"`, but rendered as a
|
|
243
|
+
// plain triangle so the color works in Safari.
|
|
244
|
+
arrow?: { x: number; y: number; angle: number };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function buildPointerPath(
|
|
248
|
+
ax: number,
|
|
249
|
+
ay: number,
|
|
250
|
+
lx: number,
|
|
251
|
+
ly: number,
|
|
252
|
+
fontSize: number,
|
|
253
|
+
lineCount: number,
|
|
254
|
+
pointer: "curved" | "straight" | "none",
|
|
255
|
+
): PointerGeom {
|
|
256
|
+
if (pointer === "none") return { path: "" };
|
|
257
|
+
const dx = lx - ax;
|
|
258
|
+
const dy = ly - ay;
|
|
259
|
+
|
|
260
|
+
// Aim at the visual center of the text line nearest the anchor: the
|
|
261
|
+
// first (top) line when the label is below the anchor, the last
|
|
262
|
+
// (bottom) line when the label is above it. That keeps the connector
|
|
263
|
+
// attached to the block's near edge instead of cutting through the
|
|
264
|
+
// lower lines of text that sits above the point.
|
|
265
|
+
const lineHeight = fontSize * LINE_HEIGHT_RATIO;
|
|
266
|
+
const nearestBaseline = dy < 0 ? ly + (lineCount - 1) * lineHeight : ly;
|
|
267
|
+
const targetY = nearestBaseline - fontSize * LINE_CENTER_RATIO;
|
|
268
|
+
|
|
269
|
+
// Pure horizontal or vertical → straight line at baseline, no curve.
|
|
270
|
+
// Force straight when explicitly requested.
|
|
271
|
+
if (dx === 0 || dy === 0 || pointer === "straight") {
|
|
272
|
+
// For straight pointers, aim at the near-line center (so multi-line
|
|
273
|
+
// text above the point connects to its bottom edge) — but only when
|
|
274
|
+
// the anchor isn't already at exactly the same Y (pure horizontal
|
|
275
|
+
// offset). The pure horizontal case stays on the baseline so it's a
|
|
276
|
+
// real horizontal line.
|
|
277
|
+
const ey = dy === 0 ? ly : targetY;
|
|
278
|
+
const segDx = lx - ax;
|
|
279
|
+
const segDy = ey - ay;
|
|
280
|
+
const len = Math.hypot(segDx, segDy);
|
|
281
|
+
if (len <= ANCHOR_GAP_PX + LABEL_GAP_PX) return { path: "" };
|
|
282
|
+
const ux = segDx / len;
|
|
283
|
+
const uy = segDy / len;
|
|
284
|
+
const sx = ax + ux * ANCHOR_GAP_PX;
|
|
285
|
+
const sy = ay + uy * ANCHOR_GAP_PX;
|
|
286
|
+
const ex = lx - ux * LABEL_GAP_PX;
|
|
287
|
+
const eyClamped = ey - uy * LABEL_GAP_PX;
|
|
288
|
+
// Arrow points back toward the anchor (opposite of (ux, uy)).
|
|
289
|
+
const angle = (Math.atan2(-uy, -ux) * 180) / Math.PI;
|
|
290
|
+
return {
|
|
291
|
+
path: `M${sx},${sy} L${ex},${eyClamped}`,
|
|
292
|
+
arrow: { x: sx, y: sy, angle },
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const adjDy = targetY - ay;
|
|
297
|
+
|
|
298
|
+
// Skip the curve if one dimension is too small to clear its gap.
|
|
299
|
+
if (Math.abs(adjDy) <= ANCHOR_GAP_PX || Math.abs(dx) <= LABEL_GAP_PX) {
|
|
300
|
+
return { path: "" };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const xDir = Math.sign(dx);
|
|
304
|
+
const yDir = Math.sign(adjDy);
|
|
305
|
+
// Start the curve directly above/below the anchor so the arrow head
|
|
306
|
+
// lines up with the data point rather than sitting off to one side.
|
|
307
|
+
const sx = ax;
|
|
308
|
+
const sy = ay + yDir * ANCHOR_GAP_PX;
|
|
309
|
+
const ex = lx - xDir * LABEL_GAP_PX;
|
|
310
|
+
const ey = targetY;
|
|
311
|
+
// Control sits at (sx, targetY) so the curve emerges from the start
|
|
312
|
+
// tangent vertically and lands on the label horizontally —
|
|
313
|
+
// a clean quarter-arc shape. The start tangent is (0, yDir), so the
|
|
314
|
+
// arrow points (0, -yDir) — straight up when yDir=1, down when yDir=-1.
|
|
315
|
+
const angle = yDir > 0 ? -90 : 90;
|
|
316
|
+
return {
|
|
317
|
+
path: `M${sx},${sy} Q${sx},${targetY} ${ex},${ey}`,
|
|
318
|
+
arrow: { x: sx, y: sy, angle },
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
</script>
|
|
322
|
+
|
|
323
|
+
<template>
|
|
324
|
+
<g class="chart-annotations" pointer-events="none">
|
|
325
|
+
<template v-for="(item, i) in items" :key="i">
|
|
326
|
+
<!--
|
|
327
|
+
Outline pass. Each shape is rendered first in `outlineColor` at a
|
|
328
|
+
wider stroke so the rule line, pointer, and arrow tip stay legible
|
|
329
|
+
over busy series. Outline elements carry `class="annotation-outline"`
|
|
330
|
+
so consumers (and tests) can target the "real" shape directly.
|
|
331
|
+
Outlines deliberately drop `stroke-dasharray` — a dashed outline
|
|
332
|
+
would expose gaps where legibility matters most.
|
|
333
|
+
-->
|
|
334
|
+
<line
|
|
335
|
+
v-if="item.rule && item.outlineWidth > 0"
|
|
336
|
+
v-bind="item.rule"
|
|
337
|
+
class="annotation-outline"
|
|
338
|
+
:stroke="item.outlineColor"
|
|
339
|
+
:stroke-width="item.lineWidth + item.outlineWidth"
|
|
340
|
+
stroke-linecap="round"
|
|
341
|
+
/>
|
|
342
|
+
<path
|
|
343
|
+
v-if="item.pointerPath && item.outlineWidth > 0"
|
|
344
|
+
:d="item.pointerPath"
|
|
345
|
+
class="annotation-outline"
|
|
346
|
+
fill="none"
|
|
347
|
+
:stroke="item.outlineColor"
|
|
348
|
+
:stroke-width="item.lineWidth + item.outlineWidth"
|
|
349
|
+
stroke-linecap="round"
|
|
350
|
+
/>
|
|
351
|
+
<polygon
|
|
352
|
+
v-if="item.arrowTip && item.outlineWidth > 0"
|
|
353
|
+
points="0,0 -6,-3 -6,3"
|
|
354
|
+
class="annotation-outline"
|
|
355
|
+
:fill="item.outlineColor"
|
|
356
|
+
:stroke="item.outlineColor"
|
|
357
|
+
:stroke-width="item.outlineWidth"
|
|
358
|
+
stroke-linejoin="round"
|
|
359
|
+
:transform="item.arrowTransform"
|
|
360
|
+
/>
|
|
361
|
+
<!-- Actual shapes. -->
|
|
362
|
+
<line
|
|
363
|
+
v-if="item.rule"
|
|
364
|
+
v-bind="item.rule"
|
|
365
|
+
:stroke="item.lineColor"
|
|
366
|
+
:stroke-width="item.lineWidth"
|
|
367
|
+
:stroke-dasharray="item.lineDash"
|
|
368
|
+
stroke-linecap="round"
|
|
369
|
+
/>
|
|
370
|
+
<path
|
|
371
|
+
v-if="item.pointerPath"
|
|
372
|
+
:d="item.pointerPath"
|
|
373
|
+
fill="none"
|
|
374
|
+
:stroke="item.lineColor"
|
|
375
|
+
:stroke-width="item.lineWidth"
|
|
376
|
+
:stroke-dasharray="item.lineDash"
|
|
377
|
+
stroke-linecap="round"
|
|
378
|
+
/>
|
|
379
|
+
<!--
|
|
380
|
+
Inline arrow head. Drawn as an explicit triangle (not via
|
|
381
|
+
`<marker>`) because Safari does not implement `context-stroke` on
|
|
382
|
+
marker fills, so a shared marker rendered as black instead of the
|
|
383
|
+
line color.
|
|
384
|
+
-->
|
|
385
|
+
<polygon
|
|
386
|
+
v-if="item.arrowTip"
|
|
387
|
+
points="0,0 -6,-3 -6,3"
|
|
388
|
+
:fill="item.lineColor"
|
|
389
|
+
:transform="item.arrowTransform"
|
|
390
|
+
/>
|
|
391
|
+
<text
|
|
392
|
+
:x="item.textX"
|
|
393
|
+
:y="item.textY"
|
|
394
|
+
:text-anchor="item.textAnchor"
|
|
395
|
+
:font-size="item.fontSize"
|
|
396
|
+
:font-weight="item.fontWeight"
|
|
397
|
+
:fill="item.color"
|
|
398
|
+
:stroke="item.outlineColor"
|
|
399
|
+
:stroke-width="item.outlineWidth"
|
|
400
|
+
stroke-linejoin="round"
|
|
401
|
+
paint-order="stroke fill"
|
|
402
|
+
>
|
|
403
|
+
<tspan
|
|
404
|
+
v-for="(line, li) in item.lines"
|
|
405
|
+
:key="li"
|
|
406
|
+
:x="item.textX"
|
|
407
|
+
:dy="li === 0 ? 0 : item.dy"
|
|
408
|
+
>
|
|
409
|
+
<tspan
|
|
410
|
+
v-for="(run, ri) in line"
|
|
411
|
+
:key="ri"
|
|
412
|
+
:font-weight="run.bold ? BOLD_FONT_WEIGHT : undefined"
|
|
413
|
+
:font-style="run.italic ? 'italic' : undefined"
|
|
414
|
+
>{{ run.text }}</tspan
|
|
415
|
+
>
|
|
416
|
+
</tspan>
|
|
417
|
+
</text>
|
|
418
|
+
</template>
|
|
419
|
+
</g>
|
|
420
|
+
</template>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
withDefaults(
|
|
3
|
+
defineProps<{
|
|
4
|
+
canZoomIn?: boolean;
|
|
5
|
+
canZoomOut?: boolean;
|
|
6
|
+
/** Reset is a no-op at the identity transform — disable it there. */
|
|
7
|
+
canReset?: boolean;
|
|
8
|
+
/** Insets the stack further from the corner while the chart fills the
|
|
9
|
+
* window, matching the ChartMenu trigger area. */
|
|
10
|
+
isFullscreen?: boolean;
|
|
11
|
+
}>(),
|
|
12
|
+
{ canZoomIn: true, canZoomOut: true, canReset: true, isFullscreen: false },
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const emit = defineEmits<{
|
|
16
|
+
(e: "zoomIn"): void;
|
|
17
|
+
(e: "zoomOut"): void;
|
|
18
|
+
(e: "reset"): void;
|
|
19
|
+
}>();
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<div
|
|
24
|
+
class="chart-zoom-controls"
|
|
25
|
+
:class="{ 'chart-zoom-controls--expanded': isFullscreen }"
|
|
26
|
+
>
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
class="chart-zoom-button"
|
|
30
|
+
aria-label="Zoom in"
|
|
31
|
+
:disabled="!canZoomIn"
|
|
32
|
+
@click="emit('zoomIn')"
|
|
33
|
+
>
|
|
34
|
+
<svg
|
|
35
|
+
width="14"
|
|
36
|
+
height="14"
|
|
37
|
+
viewBox="0 0 14 14"
|
|
38
|
+
fill="none"
|
|
39
|
+
stroke="currentColor"
|
|
40
|
+
stroke-width="1.5"
|
|
41
|
+
stroke-linecap="round"
|
|
42
|
+
aria-hidden="true"
|
|
43
|
+
>
|
|
44
|
+
<path d="M7 2v10M2 7h10" />
|
|
45
|
+
</svg>
|
|
46
|
+
</button>
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
class="chart-zoom-button"
|
|
50
|
+
aria-label="Zoom out"
|
|
51
|
+
:disabled="!canZoomOut"
|
|
52
|
+
@click="emit('zoomOut')"
|
|
53
|
+
>
|
|
54
|
+
<svg
|
|
55
|
+
width="14"
|
|
56
|
+
height="14"
|
|
57
|
+
viewBox="0 0 14 14"
|
|
58
|
+
fill="none"
|
|
59
|
+
stroke="currentColor"
|
|
60
|
+
stroke-width="1.5"
|
|
61
|
+
stroke-linecap="round"
|
|
62
|
+
aria-hidden="true"
|
|
63
|
+
>
|
|
64
|
+
<path d="M2 7h10" />
|
|
65
|
+
</svg>
|
|
66
|
+
</button>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
class="chart-zoom-button"
|
|
70
|
+
aria-label="Reset view"
|
|
71
|
+
:disabled="!canReset"
|
|
72
|
+
@click="emit('reset')"
|
|
73
|
+
>
|
|
74
|
+
<!-- Counterclockwise "reset" arrow (rotate-ccw). -->
|
|
75
|
+
<svg
|
|
76
|
+
width="14"
|
|
77
|
+
height="14"
|
|
78
|
+
viewBox="0 0 14 14"
|
|
79
|
+
fill="none"
|
|
80
|
+
stroke="currentColor"
|
|
81
|
+
stroke-width="1.5"
|
|
82
|
+
stroke-linecap="round"
|
|
83
|
+
stroke-linejoin="round"
|
|
84
|
+
aria-hidden="true"
|
|
85
|
+
>
|
|
86
|
+
<path
|
|
87
|
+
d="M1.75 7a5.25 5.25 0 1 0 5.25-5.25c-1.48 0-2.9.6-3.94 1.6L1.75 4.7"
|
|
88
|
+
/>
|
|
89
|
+
<path d="M1.75 1.75v2.95h2.95" />
|
|
90
|
+
</svg>
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
</template>
|
|
94
|
+
|
|
95
|
+
<style scoped>
|
|
96
|
+
.chart-zoom-controls {
|
|
97
|
+
position: absolute;
|
|
98
|
+
top: 0.5em;
|
|
99
|
+
left: 0.5em;
|
|
100
|
+
z-index: 1;
|
|
101
|
+
display: flex;
|
|
102
|
+
flex-direction: column;
|
|
103
|
+
gap: 4px;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* Mirror .chart-menu-trigger-area--expanded: while the chart fills the
|
|
107
|
+
window the wrapper's padding doesn't move absolute children, so inset
|
|
108
|
+
further for modal-style breathing room. */
|
|
109
|
+
.chart-zoom-controls--expanded {
|
|
110
|
+
top: 1.25em;
|
|
111
|
+
left: 1.25em;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* Same look as .chart-menu-button, but always visible — no hover reveal. */
|
|
115
|
+
.chart-zoom-button {
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
justify-content: center;
|
|
119
|
+
width: 28px;
|
|
120
|
+
height: 28px;
|
|
121
|
+
padding: 0;
|
|
122
|
+
border: 1px solid var(--color-border, #e5e7eb);
|
|
123
|
+
border-radius: 0.25em;
|
|
124
|
+
background: var(--color-bg-0, #fff);
|
|
125
|
+
color: var(--color-text-secondary, #555);
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.chart-zoom-button:hover:not(:disabled) {
|
|
130
|
+
background: var(--color-bg-1, rgba(0, 0, 0, 0.05));
|
|
131
|
+
color: var(--color-text);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.chart-zoom-button:disabled {
|
|
135
|
+
opacity: 0.4;
|
|
136
|
+
cursor: default;
|
|
137
|
+
}
|
|
138
|
+
</style>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared annotation API for charts. Each chart projects an annotation's
|
|
3
|
+
* data-space `(x, y)` to pixels with its own scales and hands the resolved
|
|
4
|
+
* positions to `ChartAnnotations.vue` for rendering.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface ChartAnnotation {
|
|
8
|
+
/**
|
|
9
|
+
* Anchor x position in data coordinates. For `LineChart` this is the
|
|
10
|
+
* same x-space as the series data; for `BarChart` it's the category
|
|
11
|
+
* index (fractional values land between categories).
|
|
12
|
+
*/
|
|
13
|
+
x: number;
|
|
14
|
+
/** Anchor y position in data coordinates (value axis). */
|
|
15
|
+
y: number;
|
|
16
|
+
/**
|
|
17
|
+
* Label text.
|
|
18
|
+
* - `\n` produces a line break.
|
|
19
|
+
* - `**bold**` renders a run in bold.
|
|
20
|
+
* - `_italic_` renders a run in italic.
|
|
21
|
+
* - The two compose: `**_both_**`.
|
|
22
|
+
*/
|
|
23
|
+
text: string;
|
|
24
|
+
/**
|
|
25
|
+
* Pixel offset from the anchor to the label's reference position.
|
|
26
|
+
* Positive `x` = right, positive `y` = down (screen-space).
|
|
27
|
+
*/
|
|
28
|
+
offset: { x: number; y: number };
|
|
29
|
+
/** Text and pointer-line color. Defaults to `currentColor`. */
|
|
30
|
+
color?: string;
|
|
31
|
+
/** Font size in pixels. Default: 13 (matches axis labels). */
|
|
32
|
+
fontSize?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Base font weight applied to all non-bold runs. Default: `"normal"`
|
|
35
|
+
* (matches axis labels). `**bold**` runs in `text` always render at
|
|
36
|
+
* weight 700.
|
|
37
|
+
*/
|
|
38
|
+
fontWeight?: string | number;
|
|
39
|
+
/**
|
|
40
|
+
* Color of the legibility outline drawn behind the text, pointer/rule
|
|
41
|
+
* line, and arrow tip. Defaults to `var(--color-bg-0, #fff)` so it
|
|
42
|
+
* matches the page background out of the box.
|
|
43
|
+
*/
|
|
44
|
+
outlineColor?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Outline stroke width in pixels. Applied to the text (via paint-order
|
|
47
|
+
* stroke), and added to `lineWidth` for the pointer/rule line and
|
|
48
|
+
* arrow tip outline pass. Set to `0` to disable the outline. Default: 3.
|
|
49
|
+
*/
|
|
50
|
+
outlineWidth?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Horizontal alignment of the label relative to its anchor position.
|
|
53
|
+
* When omitted, derived from the sign of `offset.x`: positive →
|
|
54
|
+
* `"left"`, negative → `"right"`, zero → `"center"`.
|
|
55
|
+
*/
|
|
56
|
+
align?: "left" | "center" | "right";
|
|
57
|
+
/** Pointer- or rule-line color override. Defaults to `color`. */
|
|
58
|
+
lineColor?: string;
|
|
59
|
+
/** Pointer- or rule-line width in pixels. Default: 1. */
|
|
60
|
+
lineWidth?: number;
|
|
61
|
+
/**
|
|
62
|
+
* SVG `stroke-dasharray` for the pointer or rule line. Accepts the
|
|
63
|
+
* raw string form (`"4 4"`), a single number (uniform dash/gap), or
|
|
64
|
+
* an array of numbers. Default: solid line.
|
|
65
|
+
*/
|
|
66
|
+
lineDash?: string | number | readonly number[];
|
|
67
|
+
/**
|
|
68
|
+
* Connector shape between anchor and label.
|
|
69
|
+
* - `"curved"` (default): quarter-arc emerging vertically from the
|
|
70
|
+
* anchor, landing horizontally at the label.
|
|
71
|
+
* - `"straight"`: single straight line from anchor to label.
|
|
72
|
+
* - `"none"`: no connector — just the text label is rendered.
|
|
73
|
+
* - `"ruleX"`: vertical rule at the annotation's `x` value spanning
|
|
74
|
+
* the full plot height. Label still positions via `offset`.
|
|
75
|
+
* - `"ruleY"`: horizontal rule at the annotation's `y` value spanning
|
|
76
|
+
* the full plot width.
|
|
77
|
+
* - `"ruleUp"`: vertical rule from the plot's bottom edge up to the
|
|
78
|
+
* anchor.
|
|
79
|
+
* - `"ruleDown"`: vertical rule from the plot's top edge down to the
|
|
80
|
+
* anchor.
|
|
81
|
+
* - `"ruleFromLeft"`: horizontal rule from the plot's left edge in to
|
|
82
|
+
* the anchor.
|
|
83
|
+
* - `"ruleFromRight"`: horizontal rule from the plot's right edge in
|
|
84
|
+
* to the anchor.
|
|
85
|
+
*
|
|
86
|
+
* When the offset is purely horizontal or vertical (and `pointer`
|
|
87
|
+
* isn't `"none"` or a rule), the pointer is always straight regardless
|
|
88
|
+
* of this setting. Rule pointers ignore `arrow`.
|
|
89
|
+
*/
|
|
90
|
+
pointer?:
|
|
91
|
+
| "curved"
|
|
92
|
+
| "straight"
|
|
93
|
+
| "none"
|
|
94
|
+
| "ruleX"
|
|
95
|
+
| "ruleY"
|
|
96
|
+
| "ruleUp"
|
|
97
|
+
| "ruleDown"
|
|
98
|
+
| "ruleFromLeft"
|
|
99
|
+
| "ruleFromRight";
|
|
100
|
+
/**
|
|
101
|
+
* Whether to draw a small filled triangle at the anchor end of the
|
|
102
|
+
* connector line. Defaults to `true`. Set to `false` for an
|
|
103
|
+
* uncapped line. Ignored for rule pointers.
|
|
104
|
+
*/
|
|
105
|
+
arrow?: boolean;
|
|
106
|
+
}
|