@cfasim-ui/docs 0.4.6 → 0.4.7
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/charts/ChartMenu/download.ts +68 -3
- package/charts/_shared/ChartAnnotations.vue +54 -34
- package/index.json +1 -1
- package/package.json +1 -1
|
@@ -7,16 +7,81 @@ export function downloadBlob(blob: Blob, name: string) {
|
|
|
7
7
|
URL.revokeObjectURL(url);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
/** Inherited CSS properties propagated onto the cloned SVG root so text and
|
|
11
|
+
* `currentColor` strokes render with the same color/typography as the
|
|
12
|
+
* live chart when the SVG is opened standalone or rasterized to PNG. */
|
|
13
|
+
const ROOT_INHERITED_PROPS = [
|
|
14
|
+
"color",
|
|
15
|
+
"font-family",
|
|
16
|
+
"font-size",
|
|
17
|
+
"font-weight",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
/** Presentation attributes that may contain `var(--…)` references. The
|
|
21
|
+
* fallback inside `var(--name, #fff)` is the only thing that renders when
|
|
22
|
+
* the custom property is undefined outside the page context — so we
|
|
23
|
+
* resolve them against the document's CSS custom properties before
|
|
24
|
+
* serializing. */
|
|
25
|
+
const VAR_RESOLVABLE_ATTRS = ["fill", "stroke"] as const;
|
|
26
|
+
|
|
27
|
+
/** Resolve a single `var(--name)` or `var(--name, fallback)` expression.
|
|
28
|
+
* Looks the name up on `document.documentElement` (where theme tokens are
|
|
29
|
+
* declared); falls back to the in-expression fallback, then the original
|
|
30
|
+
* expression if neither resolves. */
|
|
31
|
+
function resolveVarExpression(expr: string): string {
|
|
32
|
+
const match = expr.match(
|
|
33
|
+
/^\s*var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*?)\s*)?\)\s*$/,
|
|
34
|
+
);
|
|
35
|
+
if (!match) return expr;
|
|
36
|
+
const [, name, fallback] = match;
|
|
37
|
+
const value = window
|
|
38
|
+
.getComputedStyle(document.documentElement)
|
|
39
|
+
.getPropertyValue(name)
|
|
40
|
+
.trim();
|
|
41
|
+
if (value) return value;
|
|
42
|
+
if (fallback) return fallback.trim();
|
|
43
|
+
return expr;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Clone `svg` and inline the styles that don't survive a standalone render:
|
|
47
|
+
* inherited `color`/`font-*` on the root (so `currentColor` and unset
|
|
48
|
+
* font-family resolve), and any `var(--…)` references in fill/stroke
|
|
49
|
+
* attributes resolved against the document. */
|
|
50
|
+
export function prepareSvgForExport(svg: SVGSVGElement): SVGSVGElement {
|
|
11
51
|
const clone = svg.cloneNode(true) as SVGSVGElement;
|
|
12
52
|
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
|
53
|
+
|
|
54
|
+
const rootComputed = window.getComputedStyle(svg);
|
|
55
|
+
const rootStyleParts: string[] = [];
|
|
56
|
+
for (const prop of ROOT_INHERITED_PROPS) {
|
|
57
|
+
const value = rootComputed.getPropertyValue(prop);
|
|
58
|
+
if (value) rootStyleParts.push(`${prop}: ${value}`);
|
|
59
|
+
}
|
|
60
|
+
const existingRootStyle = clone.getAttribute("style") ?? "";
|
|
61
|
+
clone.setAttribute(
|
|
62
|
+
"style",
|
|
63
|
+
[existingRootStyle, ...rootStyleParts].filter(Boolean).join("; "),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
for (const target of clone.querySelectorAll<SVGElement>("*")) {
|
|
67
|
+
for (const attr of VAR_RESOLVABLE_ATTRS) {
|
|
68
|
+
const raw = target.getAttribute(attr);
|
|
69
|
+
if (!raw || !raw.includes("var(")) continue;
|
|
70
|
+
target.setAttribute(attr, resolveVarExpression(raw));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return clone;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function saveSvg(svg: SVGSVGElement, filename: string) {
|
|
78
|
+
const clone = prepareSvgForExport(svg);
|
|
13
79
|
const xml = new XMLSerializer().serializeToString(clone);
|
|
14
80
|
downloadBlob(new Blob([xml], { type: "image/svg+xml" }), `${filename}.svg`);
|
|
15
81
|
}
|
|
16
82
|
|
|
17
83
|
export function savePng(svg: SVGSVGElement, filename: string) {
|
|
18
|
-
const clone = svg
|
|
19
|
-
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
|
84
|
+
const clone = prepareSvgForExport(svg);
|
|
20
85
|
const xml = new XMLSerializer().serializeToString(clone);
|
|
21
86
|
const svgBlob = new Blob([xml], { type: "image/svg+xml;charset=utf-8" });
|
|
22
87
|
const url = URL.createObjectURL(svgBlob);
|
|
@@ -39,10 +39,6 @@ const LINE_HEIGHT_RATIO = 1.2;
|
|
|
39
39
|
// of the first text line (between baseline and cap-height). Lands on the
|
|
40
40
|
// x-height middle for most fonts.
|
|
41
41
|
const FIRST_LINE_CENTER_RATIO = 0.35;
|
|
42
|
-
// Nudge the start of the curve a few pixels in the offset direction so
|
|
43
|
-
// it doesn't sit directly on top of axis lines or gridlines at the
|
|
44
|
-
// anchor.
|
|
45
|
-
const START_NUDGE_PX = 3;
|
|
46
42
|
|
|
47
43
|
interface TextRun {
|
|
48
44
|
text: string;
|
|
@@ -66,6 +62,10 @@ interface RenderedAnnotation {
|
|
|
66
62
|
lineWidth: number;
|
|
67
63
|
lineDash?: string;
|
|
68
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
69
|
rule?: { x1: number; y1: number; x2: number; y2: number };
|
|
70
70
|
}
|
|
71
71
|
|
|
@@ -138,10 +138,12 @@ const items = computed<RenderedAnnotation[]>(() => {
|
|
|
138
138
|
|
|
139
139
|
let rule: RenderedAnnotation["rule"];
|
|
140
140
|
let pointerPath = "";
|
|
141
|
+
let arrowTip: RenderedAnnotation["arrowTip"];
|
|
142
|
+
const wantArrow = !isRule && (a.arrow ?? true);
|
|
141
143
|
if (isRule && props.bounds) {
|
|
142
144
|
rule = computeRule(pointer, projected.x, projected.y, props.bounds);
|
|
143
145
|
} else {
|
|
144
|
-
|
|
146
|
+
const built = buildPointerPath(
|
|
145
147
|
projected.x,
|
|
146
148
|
projected.y,
|
|
147
149
|
labelX,
|
|
@@ -149,6 +151,8 @@ const items = computed<RenderedAnnotation[]>(() => {
|
|
|
149
151
|
fontSize,
|
|
150
152
|
pointer as "curved" | "straight" | "none",
|
|
151
153
|
);
|
|
154
|
+
pointerPath = built.path;
|
|
155
|
+
if (wantArrow && built.arrow) arrowTip = built.arrow;
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
out.push({
|
|
@@ -166,7 +170,8 @@ const items = computed<RenderedAnnotation[]>(() => {
|
|
|
166
170
|
lineColor,
|
|
167
171
|
lineWidth,
|
|
168
172
|
lineDash,
|
|
169
|
-
arrow:
|
|
173
|
+
arrow: wantArrow,
|
|
174
|
+
arrowTip,
|
|
170
175
|
rule,
|
|
171
176
|
});
|
|
172
177
|
}
|
|
@@ -216,6 +221,15 @@ function computeRule(
|
|
|
216
221
|
* label so the endpoint reads as pointing at the first line — not at
|
|
217
222
|
* the bottom of a multi-line block.
|
|
218
223
|
*/
|
|
224
|
+
interface PointerGeom {
|
|
225
|
+
path: string;
|
|
226
|
+
// Tip position and rotation (degrees) for the inline arrow head. The
|
|
227
|
+
// arrow points opposite the path's start tangent — matching the look of
|
|
228
|
+
// `marker-start` with `orient="auto-start-reverse"`, but rendered as a
|
|
229
|
+
// plain triangle so the color works in Safari.
|
|
230
|
+
arrow?: { x: number; y: number; angle: number };
|
|
231
|
+
}
|
|
232
|
+
|
|
219
233
|
function buildPointerPath(
|
|
220
234
|
ax: number,
|
|
221
235
|
ay: number,
|
|
@@ -223,8 +237,8 @@ function buildPointerPath(
|
|
|
223
237
|
ly: number,
|
|
224
238
|
fontSize: number,
|
|
225
239
|
pointer: "curved" | "straight" | "none",
|
|
226
|
-
):
|
|
227
|
-
if (pointer === "none") return "";
|
|
240
|
+
): PointerGeom {
|
|
241
|
+
if (pointer === "none") return { path: "" };
|
|
228
242
|
const dx = lx - ax;
|
|
229
243
|
const dy = ly - ay;
|
|
230
244
|
|
|
@@ -243,53 +257,49 @@ function buildPointerPath(
|
|
|
243
257
|
const segDx = lx - ax;
|
|
244
258
|
const segDy = ey - ay;
|
|
245
259
|
const len = Math.hypot(segDx, segDy);
|
|
246
|
-
if (len <= ANCHOR_GAP_PX + LABEL_GAP_PX) return "";
|
|
260
|
+
if (len <= ANCHOR_GAP_PX + LABEL_GAP_PX) return { path: "" };
|
|
247
261
|
const ux = segDx / len;
|
|
248
262
|
const uy = segDy / len;
|
|
249
263
|
const sx = ax + ux * ANCHOR_GAP_PX;
|
|
250
264
|
const sy = ay + uy * ANCHOR_GAP_PX;
|
|
251
265
|
const ex = lx - ux * LABEL_GAP_PX;
|
|
252
266
|
const eyClamped = ey - uy * LABEL_GAP_PX;
|
|
253
|
-
|
|
267
|
+
// Arrow points back toward the anchor (opposite of (ux, uy)).
|
|
268
|
+
const angle = (Math.atan2(-uy, -ux) * 180) / Math.PI;
|
|
269
|
+
return {
|
|
270
|
+
path: `M${sx},${sy} L${ex},${eyClamped}`,
|
|
271
|
+
arrow: { x: sx, y: sy, angle },
|
|
272
|
+
};
|
|
254
273
|
}
|
|
255
274
|
|
|
256
275
|
const adjDy = targetY - ay;
|
|
257
276
|
|
|
258
277
|
// Skip the curve if one dimension is too small to clear its gap.
|
|
259
278
|
if (Math.abs(adjDy) <= ANCHOR_GAP_PX || Math.abs(dx) <= LABEL_GAP_PX) {
|
|
260
|
-
return "";
|
|
279
|
+
return { path: "" };
|
|
261
280
|
}
|
|
262
281
|
|
|
263
282
|
const xDir = Math.sign(dx);
|
|
264
283
|
const yDir = Math.sign(adjDy);
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
const sx = ax
|
|
284
|
+
// Start the curve directly above/below the anchor so the arrow head
|
|
285
|
+
// lines up with the data point rather than sitting off to one side.
|
|
286
|
+
const sx = ax;
|
|
268
287
|
const sy = ay + yDir * ANCHOR_GAP_PX;
|
|
269
288
|
const ex = lx - xDir * LABEL_GAP_PX;
|
|
270
289
|
const ey = targetY;
|
|
271
|
-
// Control sits at (sx, targetY) so the curve emerges from the
|
|
272
|
-
//
|
|
273
|
-
// a clean quarter-arc shape.
|
|
274
|
-
|
|
290
|
+
// Control sits at (sx, targetY) so the curve emerges from the start
|
|
291
|
+
// tangent vertically and lands on the label horizontally —
|
|
292
|
+
// a clean quarter-arc shape. The start tangent is (0, yDir), so the
|
|
293
|
+
// arrow points (0, -yDir) — straight up when yDir=1, down when yDir=-1.
|
|
294
|
+
const angle = yDir > 0 ? -90 : 90;
|
|
295
|
+
return {
|
|
296
|
+
path: `M${sx},${sy} Q${sx},${targetY} ${ex},${ey}`,
|
|
297
|
+
arrow: { x: sx, y: sy, angle },
|
|
298
|
+
};
|
|
275
299
|
}
|
|
276
300
|
</script>
|
|
277
301
|
|
|
278
302
|
<template>
|
|
279
|
-
<defs>
|
|
280
|
-
<marker
|
|
281
|
-
id="chart-annotation-arrow"
|
|
282
|
-
viewBox="0 0 8 8"
|
|
283
|
-
refX="7"
|
|
284
|
-
refY="4"
|
|
285
|
-
markerWidth="6"
|
|
286
|
-
markerHeight="6"
|
|
287
|
-
orient="auto-start-reverse"
|
|
288
|
-
markerUnits="userSpaceOnUse"
|
|
289
|
-
>
|
|
290
|
-
<path d="M0,0 L8,4 L0,8 Z" fill="context-stroke" />
|
|
291
|
-
</marker>
|
|
292
|
-
</defs>
|
|
293
303
|
<g class="chart-annotations" pointer-events="none">
|
|
294
304
|
<template v-for="(item, i) in items" :key="i">
|
|
295
305
|
<line
|
|
@@ -308,11 +318,21 @@ function buildPointerPath(
|
|
|
308
318
|
:d="item.pointerPath"
|
|
309
319
|
fill="none"
|
|
310
320
|
:stroke="item.lineColor"
|
|
311
|
-
:style="{ color: item.lineColor }"
|
|
312
321
|
:stroke-width="item.lineWidth"
|
|
313
322
|
:stroke-dasharray="item.lineDash"
|
|
314
323
|
stroke-linecap="round"
|
|
315
|
-
|
|
324
|
+
/>
|
|
325
|
+
<!--
|
|
326
|
+
Inline arrow head. Drawn as an explicit triangle (not via
|
|
327
|
+
`<marker>`) because Safari does not implement `context-stroke` on
|
|
328
|
+
marker fills, so a shared marker rendered as black instead of the
|
|
329
|
+
line color.
|
|
330
|
+
-->
|
|
331
|
+
<polygon
|
|
332
|
+
v-if="item.arrowTip"
|
|
333
|
+
points="0,0 -6,-3 -6,3"
|
|
334
|
+
:fill="item.lineColor"
|
|
335
|
+
:transform="`translate(${item.arrowTip.x} ${item.arrowTip.y}) rotate(${item.arrowTip.angle})`"
|
|
316
336
|
/>
|
|
317
337
|
<text
|
|
318
338
|
:x="item.textX"
|
package/index.json
CHANGED