@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.
@@ -7,16 +7,81 @@ export function downloadBlob(blob: Blob, name: string) {
7
7
  URL.revokeObjectURL(url);
8
8
  }
9
9
 
10
- export function saveSvg(svg: SVGSVGElement, filename: string) {
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.cloneNode(true) as SVGSVGElement;
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
- pointerPath = buildPointerPath(
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: !isRule && (a.arrow ?? true),
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
- ): string {
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
- return `M${sx},${sy} L${ex},${eyClamped}`;
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
- // Nudge the start horizontally toward the label so the line doesn't
266
- // sit on top of axis/grid lines passing through the anchor.
267
- const sx = ax + xDir * START_NUDGE_PX;
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 nudged
272
- // start tangent vertically and lands on the label horizontally —
273
- // a clean quarter-arc shape.
274
- return `M${sx},${sy} Q${sx},${targetY} ${ex},${ey}`;
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
- :marker-start="item.arrow ? 'url(#chart-annotation-arrow)' : undefined"
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
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.6",
2
+ "version": "0.4.7",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {