@blotch/dolan 22.2.2 → 22.2.3

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.
Files changed (154) hide show
  1. package/dist/commands/add.d.ts +6 -2
  2. package/dist/commands/add.js +32 -10
  3. package/dist/commands/add.js.map +1 -1
  4. package/dist/commands/add.test.js +129 -9
  5. package/dist/commands/add.test.js.map +1 -1
  6. package/dist/commands/diff.d.ts +2 -1
  7. package/dist/commands/diff.js +38 -25
  8. package/dist/commands/diff.js.map +1 -1
  9. package/dist/commands/diff.test.js +72 -11
  10. package/dist/commands/diff.test.js.map +1 -1
  11. package/dist/commands/doctor.js +41 -27
  12. package/dist/commands/doctor.js.map +1 -1
  13. package/dist/commands/doctor.test.js +93 -9
  14. package/dist/commands/doctor.test.js.map +1 -1
  15. package/dist/commands/eject.js +7 -0
  16. package/dist/commands/eject.js.map +1 -1
  17. package/dist/commands/eject.test.js +39 -0
  18. package/dist/commands/eject.test.js.map +1 -1
  19. package/dist/commands/info.js +3 -2
  20. package/dist/commands/info.js.map +1 -1
  21. package/dist/commands/init.d.ts +37 -1
  22. package/dist/commands/init.js +149 -40
  23. package/dist/commands/init.js.map +1 -1
  24. package/dist/commands/init.test.js +285 -26
  25. package/dist/commands/init.test.js.map +1 -1
  26. package/dist/commands/install-packages.d.ts +6 -0
  27. package/dist/commands/install-packages.js +20 -0
  28. package/dist/commands/install-packages.js.map +1 -0
  29. package/dist/commands/install-packages.test.d.ts +1 -0
  30. package/dist/commands/install-packages.test.js +55 -0
  31. package/dist/commands/install-packages.test.js.map +1 -0
  32. package/dist/commands/list.js +21 -14
  33. package/dist/commands/list.js.map +1 -1
  34. package/dist/commands/list.test.js +62 -5
  35. package/dist/commands/list.test.js.map +1 -1
  36. package/dist/commands/outdated.js +11 -10
  37. package/dist/commands/outdated.js.map +1 -1
  38. package/dist/commands/outdated.test.js +20 -5
  39. package/dist/commands/outdated.test.js.map +1 -1
  40. package/dist/commands/remove.d.ts +4 -0
  41. package/dist/commands/remove.js +17 -4
  42. package/dist/commands/remove.js.map +1 -1
  43. package/dist/commands/remove.test.js +53 -2
  44. package/dist/commands/remove.test.js.map +1 -1
  45. package/dist/commands/update.d.ts +6 -0
  46. package/dist/commands/update.js +59 -24
  47. package/dist/commands/update.js.map +1 -1
  48. package/dist/commands/update.test.js +273 -31
  49. package/dist/commands/update.test.js.map +1 -1
  50. package/dist/index.js +7 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/registry.d.ts +8 -0
  53. package/dist/registry.js +32 -24
  54. package/dist/registry.js.map +1 -1
  55. package/dist/registry.test.js +76 -37
  56. package/dist/registry.test.js.map +1 -1
  57. package/dist/testing/set-input-signal.d.ts +13 -0
  58. package/dist/testing/set-input-signal.js +20 -0
  59. package/dist/testing/set-input-signal.js.map +1 -0
  60. package/dist/testing/set-input-signal.test.d.ts +1 -0
  61. package/dist/testing/set-input-signal.test.js +20 -0
  62. package/dist/testing/set-input-signal.test.js.map +1 -0
  63. package/dist/ui/components/action-sheet/action-sheet.test.ts +48 -1
  64. package/dist/ui/components/action-sheet/action-sheet.ts +37 -9
  65. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +49 -2
  66. package/dist/ui/components/alert-dialog/alert-dialog.ts +43 -12
  67. package/dist/ui/components/area-chart/area-chart-render.test.ts +52 -110
  68. package/dist/ui/components/area-chart/area-chart.test.ts +58 -123
  69. package/dist/ui/components/area-chart/area-chart.ts +100 -315
  70. package/dist/ui/components/area-chart/index.ts +2 -3
  71. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +47 -163
  72. package/dist/ui/components/bar-chart/bar-chart.test.ts +83 -37
  73. package/dist/ui/components/bar-chart/bar-chart.ts +140 -294
  74. package/dist/ui/components/bar-chart/index.ts +3 -3
  75. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +191 -9
  76. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +136 -20
  77. package/dist/ui/components/button/button-render.test.ts +19 -0
  78. package/dist/ui/components/button/button.ts +18 -2
  79. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +48 -163
  80. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +106 -62
  81. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +150 -323
  82. package/dist/ui/components/candlestick-chart/index.ts +3 -4
  83. package/dist/ui/components/chart/chart-focus.test.ts +132 -0
  84. package/dist/ui/components/chart/chart-focus.ts +115 -0
  85. package/dist/ui/components/chart/chart-presets.test.ts +139 -0
  86. package/dist/ui/components/chart/chart-presets.ts +271 -0
  87. package/dist/ui/components/chart/chart-render.test.ts +730 -0
  88. package/dist/ui/components/chart/chart-scene.test.ts +589 -0
  89. package/dist/ui/components/chart/chart-scene.ts +483 -0
  90. package/dist/ui/components/chart/chart-theme.test.ts +85 -0
  91. package/dist/ui/components/chart/chart-theme.ts +198 -0
  92. package/dist/ui/components/chart/chart-zoom.test.ts +159 -0
  93. package/dist/ui/components/chart/chart-zoom.ts +162 -0
  94. package/dist/ui/components/chart/chart.ts +921 -0
  95. package/dist/ui/components/chart/index.ts +24 -0
  96. package/dist/ui/components/dialog/dialog.test.ts +49 -2
  97. package/dist/ui/components/dialog/dialog.ts +43 -12
  98. package/dist/ui/components/icon/icon-theme.test.ts +133 -0
  99. package/dist/ui/components/icon/icon-theme.ts +204 -0
  100. package/dist/ui/components/icon/icon.test.ts +51 -2
  101. package/dist/ui/components/icon/icon.ts +38 -21
  102. package/dist/ui/components/line-chart/index.ts +2 -3
  103. package/dist/ui/components/line-chart/line-chart-render.test.ts +69 -107
  104. package/dist/ui/components/line-chart/line-chart.test.ts +63 -45
  105. package/dist/ui/components/line-chart/line-chart.ts +83 -241
  106. package/dist/ui/components/list/list.test.ts +69 -2
  107. package/dist/ui/components/list/list.ts +21 -15
  108. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +80 -1
  109. package/dist/ui/components/nav-drawer/nav-drawer.ts +66 -11
  110. package/dist/ui/components/pagination/pagination.test.ts +62 -0
  111. package/dist/ui/components/pagination/pagination.ts +55 -8
  112. package/dist/ui/components/scatter-chart/index.ts +2 -3
  113. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +51 -144
  114. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +57 -25
  115. package/dist/ui/components/scatter-chart/scatter-chart.ts +104 -250
  116. package/dist/ui/components/scroll-area/scroll-area.test.ts +37 -2
  117. package/dist/ui/components/scroll-area/scroll-area.ts +8 -8
  118. package/dist/ui/components/spinner/spinner.test.ts +27 -0
  119. package/dist/ui/components/spinner/spinner.ts +33 -23
  120. package/dist/ui/components/toast/toast.test.ts +37 -0
  121. package/dist/ui/components/toast/toast.ts +20 -4
  122. package/dist/ui/theme/chart-colors.test.ts +30 -0
  123. package/dist/ui/theme/dark.css +8 -0
  124. package/dist/ui/theme/default.css +8 -0
  125. package/dist/ui/theme/icon-colors.test.ts +30 -0
  126. package/dist/ui/theme/tailwind-plugin.ts +10 -1
  127. package/dist/utils/installed.d.ts +17 -0
  128. package/dist/utils/installed.js +27 -0
  129. package/dist/utils/installed.js.map +1 -0
  130. package/dist/utils/installed.test.d.ts +1 -0
  131. package/dist/utils/installed.test.js +41 -0
  132. package/dist/utils/installed.test.js.map +1 -0
  133. package/dist/utils/interactive.d.ts +9 -0
  134. package/dist/utils/interactive.js +10 -0
  135. package/dist/utils/interactive.js.map +1 -0
  136. package/dist/utils/interactive.test.d.ts +1 -0
  137. package/dist/utils/interactive.test.js +24 -0
  138. package/dist/utils/interactive.test.js.map +1 -0
  139. package/dist/utils/packages.d.ts +36 -0
  140. package/dist/utils/packages.js +70 -0
  141. package/dist/utils/packages.js.map +1 -0
  142. package/dist/utils/packages.test.d.ts +1 -0
  143. package/dist/utils/packages.test.js +89 -0
  144. package/dist/utils/packages.test.js.map +1 -0
  145. package/dist/utils/resolve-paths.d.ts +17 -1
  146. package/dist/utils/resolve-paths.js +23 -3
  147. package/dist/utils/resolve-paths.js.map +1 -1
  148. package/dist/utils/resolve-paths.test.js +34 -2
  149. package/dist/utils/resolve-paths.test.js.map +1 -1
  150. package/package.json +5 -1
  151. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +0 -188
  152. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +0 -525
  153. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +0 -1140
  154. package/dist/ui/components/cartesian-chart/index.ts +0 -18
@@ -0,0 +1,483 @@
1
+ import { rectCornerRadiiPath } from '@tanstack/charts/renderer/rect';
2
+ import type {
3
+ ChartBounds,
4
+ ChartFocusState,
5
+ ChartGradient,
6
+ ChartTextMeasurer,
7
+ ChartTooltipPosition,
8
+ SceneLabel,
9
+ SceneNode,
10
+ SceneStyle,
11
+ } from '@tanstack/charts/types';
12
+
13
+ /**
14
+ * Turns a TanStack Charts scene into what Lynx can draw: SVG strings for the
15
+ * shapes, and a list of positioned labels for native `<text>`.
16
+ *
17
+ * ## Why not TanStack's own `renderChartSvg`
18
+ * Its output targets a browser's DOM: it hides focus layers with
19
+ * `visibility="hidden"` and CSS classes, relies on paints inheriting through
20
+ * `<g>`, writes `<line>`, `stroke-dasharray="none"` and `font-family="inherit"`,
21
+ * and omits `xmlns`. Lynx's native SVG engine (ServalSVG) ignores visibility and
22
+ * CSS, skips a `<line>` whose stroke is inherited, hangs on a non-numeric dash
23
+ * array, and the web preview can't load SVG without `xmlns` as an image. So this
24
+ * serializer writes the same geometry in the subset every Lynx platform draws
25
+ * the same way: explicit paints on every shape, paths instead of lines, focus
26
+ * subtrees left out (the host paints the active one separately), and resolved
27
+ * colors.
28
+ *
29
+ * ## Why labels are native text
30
+ * Text drawn inside the SVG uses the SVG engine's own fonts and metrics, which
31
+ * differ per platform and from the app's. Native `<text>` uses the app's fonts,
32
+ * reads theme CSS variables directly, and matches the `lynx.getTextInfo`
33
+ * measurements the layout was made with (see {@link createLynxTextMeasurer}).
34
+ */
35
+
36
+ export type ChartPaintResolver = (paint: string) => string;
37
+
38
+ export type ChartSvgOptions = {
39
+ readonly width: number;
40
+ readonly height: number;
41
+ /** Concrete color for a CSS-shaped paint (see `resolveChartPaint`). */
42
+ readonly resolvePaint: ChartPaintResolver;
43
+ /** Gradients the nodes may reference as `url(#id)`. */
44
+ readonly gradients?: readonly ChartGradient[];
45
+ /** Prefix for gradient and clip ids, so two charts' ids never collide. */
46
+ readonly idPrefix?: string;
47
+ };
48
+
49
+ /** The paint properties SVG inherits from a group (opacity isn't one). */
50
+ const INHERITED_PAINT = [
51
+ 'fill',
52
+ 'fillOpacity',
53
+ 'stroke',
54
+ 'strokeOpacity',
55
+ 'strokeWidth',
56
+ 'lineCap',
57
+ 'lineJoin',
58
+ 'strokeDasharray',
59
+ ] as const satisfies readonly (keyof SceneStyle)[];
60
+
61
+ type InheritedPaint = Pick<SceneStyle, (typeof INHERITED_PAINT)[number]>;
62
+
63
+ type SvgContext = {
64
+ readonly options: ChartSvgOptions;
65
+ readonly gradientIds: ReadonlySet<string>;
66
+ readonly defs: string[];
67
+ clipCount: number;
68
+ };
69
+
70
+ /**
71
+ * Serializes `nodes` (labels excluded) into one `<svg>` the size of the chart.
72
+ * Returns `''` when nothing would be drawn, so the host can skip the element:
73
+ * the focus layers are empty until something is focused.
74
+ */
75
+ export const renderChartSvg = (
76
+ nodes: readonly SceneNode[],
77
+ options: ChartSvgOptions,
78
+ ): string => {
79
+ const context: SvgContext = {
80
+ options,
81
+ gradientIds: new Set((options.gradients ?? []).map((g) => g.id)),
82
+ defs: [],
83
+ clipCount: 0,
84
+ };
85
+ const body = renderNodes(nodes, {}, context);
86
+ if (!body) return '';
87
+ const gradients = (options.gradients ?? [])
88
+ .map((gradient) => renderGradient(gradient, context))
89
+ .join('');
90
+ const defs = gradients + context.defs.join('');
91
+ const { width, height } = options;
92
+ return (
93
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${num(width)}" height="${num(height)}" viewBox="0 0 ${num(width)} ${num(height)}">` +
94
+ (defs ? `<defs>${defs}</defs>` : '') +
95
+ body +
96
+ '</svg>'
97
+ );
98
+ };
99
+
100
+ const renderNodes = (
101
+ nodes: readonly SceneNode[],
102
+ inherited: InheritedPaint,
103
+ context: SvgContext,
104
+ ): string => {
105
+ let markup = '';
106
+ for (const node of nodes) markup += renderNode(node, inherited, context);
107
+ return markup;
108
+ };
109
+
110
+ const renderNode = (
111
+ node: SceneNode,
112
+ inherited: InheritedPaint,
113
+ context: SvgContext,
114
+ ): string => {
115
+ if (node.kind === 'label') return '';
116
+ const paint = inheritPaint(inherited, node.style);
117
+
118
+ if (node.kind === 'group') {
119
+ // A focus subtree holds the candidates for every point; the browser
120
+ // renderer hides them with visibility, which ServalSVG ignores, so they'd
121
+ // all paint. The host draws the active one from resolveFocusPresentation.
122
+ if (node.focus) return '';
123
+ const children = renderNodes(node.children, paint, context);
124
+ if (!children) return '';
125
+ const translate =
126
+ node.translateX || node.translateY
127
+ ? ` transform="translate(${num(node.translateX ?? 0)} ${num(node.translateY ?? 0)})"`
128
+ : '';
129
+ const clip = node.clip ? clipAttribute(node.clip, context) : '';
130
+ return `<g${translate}${clip}${opacityAttribute(node.style)}>${children}</g>`;
131
+ }
132
+
133
+ const attributes =
134
+ paintAttributes(paint, context) + opacityAttribute(node.style);
135
+ switch (node.kind) {
136
+ case 'rule':
137
+ // A path, not <line>: ServalSVG skips a line unless its stroke is set on
138
+ // the line element itself.
139
+ return `<path d="M${num(node.x1)} ${num(node.y1)}L${num(node.x2)} ${num(node.y2)}"${attributes}/>`;
140
+ case 'polyline':
141
+ return `<path d="${escapeAttribute(node.path ?? pointsPath(node.points, false))}"${attributes}/>`;
142
+ case 'area': {
143
+ if (node.polygons !== undefined) {
144
+ const rings = node.polygons
145
+ .flatMap((polygon) => polygon)
146
+ .filter((ring) => ring.length > 0)
147
+ .map((ring) => pointsPath(ring, true))
148
+ .join('');
149
+ return `<path d="${rings}" fill-rule="evenodd"${attributes}/>`;
150
+ }
151
+ return `<path d="${escapeAttribute(node.path ?? pointsPath(node.points, true))}"${attributes}/>`;
152
+ }
153
+ case 'dot':
154
+ return `<circle cx="${num(node.x)}" cy="${num(node.y)}" r="${num(node.radius)}"${attributes}/>`;
155
+ case 'rect':
156
+ return node.cornerRadii === undefined
157
+ ? `<rect x="${num(node.x)}" y="${num(node.y)}" width="${num(node.width)}" height="${num(node.height)}"${node.radius ? ` rx="${num(node.radius)}"` : ''}${attributes}/>`
158
+ : `<path d="${rectCornerRadiiPath(node.x, node.y, node.width, node.height, node.cornerRadii)}"${attributes}/>`;
159
+ }
160
+ };
161
+
162
+ const inheritPaint = (
163
+ inherited: InheritedPaint,
164
+ style: SceneStyle | undefined,
165
+ ): InheritedPaint => {
166
+ if (!style) return inherited;
167
+ const merged: InheritedPaint = { ...inherited };
168
+ for (const key of INHERITED_PAINT) {
169
+ if (style[key] !== undefined) {
170
+ (merged as Record<string, unknown>)[key] = style[key];
171
+ }
172
+ }
173
+ return merged;
174
+ };
175
+
176
+ const paintAttributes = (paint: InheritedPaint, context: SvgContext): string =>
177
+ attribute('fill', resolvePaint(paint.fill, context)) +
178
+ attribute('fill-opacity', paint.fillOpacity) +
179
+ attribute('stroke', resolvePaint(paint.stroke, context)) +
180
+ attribute('stroke-opacity', paint.strokeOpacity) +
181
+ attribute('stroke-width', paint.strokeWidth) +
182
+ attribute('stroke-linecap', paint.lineCap) +
183
+ attribute('stroke-linejoin', lineJoin(paint.lineJoin)) +
184
+ // `none` is the default; anything else that isn't a number list is dropped
185
+ // by the runtime before it reaches ServalSVG (see svg-content.ts there).
186
+ attribute(
187
+ 'stroke-dasharray',
188
+ paint.strokeDasharray === 'none' ? undefined : paint.strokeDasharray,
189
+ );
190
+
191
+ const opacityAttribute = (style: SceneStyle | undefined): string =>
192
+ attribute('opacity', style?.opacity);
193
+
194
+ /**
195
+ * SVG 2's `arcs` and `miter-clip` aren't in the SVG 1.1 subset Lynx draws.
196
+ */
197
+ const lineJoin = (value: SceneStyle['lineJoin']): string | undefined =>
198
+ value === 'arcs' ? 'round' : value === 'miter-clip' ? 'miter' : value;
199
+
200
+ const resolvePaint = (
201
+ paint: string | undefined,
202
+ context: SvgContext,
203
+ ): string | undefined => {
204
+ if (paint === undefined) return undefined;
205
+ const gradient = /^url\(#(.+)\)$/.exec(paint.trim())?.[1];
206
+ if (gradient !== undefined && context.gradientIds.has(gradient)) {
207
+ return `url(#${scopedId(context, `gradient-${gradient}`)})`;
208
+ }
209
+ return context.options.resolvePaint(paint);
210
+ };
211
+
212
+ const clipAttribute = (clip: ChartBounds, context: SvgContext): string => {
213
+ const id = scopedId(context, `clip-${context.clipCount++}`);
214
+ // Clip paths go in the root <defs> rather than next to their group, the
215
+ // placement every SVG engine agrees on.
216
+ context.defs.push(
217
+ `<clipPath id="${id}"><rect x="${num(clip.x)}" y="${num(clip.y)}" width="${num(clip.width)}" height="${num(clip.height)}"/></clipPath>`,
218
+ );
219
+ return ` clip-path="url(#${id})"`;
220
+ };
221
+
222
+ const renderGradient = (
223
+ gradient: ChartGradient,
224
+ context: SvgContext,
225
+ ): string => {
226
+ let minimumOffset = 0;
227
+ const stops = gradient.stops
228
+ .map((stop) => {
229
+ // Offsets must not decrease; clamp like browsers do so every engine
230
+ // agrees on the result.
231
+ const offset = Math.max(minimumOffset, clampUnit(stop.offset));
232
+ minimumOffset = offset;
233
+ return `<stop offset="${percent(offset)}" stop-color="${escapeAttribute(context.options.resolvePaint(stop.color))}"${attribute('stop-opacity', stop.opacity)}/>`;
234
+ })
235
+ .join('');
236
+ const id = scopedId(context, `gradient-${gradient.id}`);
237
+ if (gradient.type === 'radial') {
238
+ const cx = gradient.cx ?? 0.5;
239
+ const cy = gradient.cy ?? 0.5;
240
+ return `<radialGradient id="${id}" cx="${percent(cx)}" cy="${percent(cy)}" r="${percent(gradient.r ?? 0.5)}" fx="${percent(gradient.fx ?? cx)}" fy="${percent(gradient.fy ?? cy)}">${stops}</radialGradient>`;
241
+ }
242
+ return `<linearGradient id="${id}" x1="${percent(gradient.x1 ?? 0)}" y1="${percent(gradient.y1 ?? 1)}" x2="${percent(gradient.x2 ?? 0)}" y2="${percent(gradient.y2 ?? 0)}">${stops}</linearGradient>`;
243
+ };
244
+
245
+ const scopedId = (context: SvgContext, id: string): string =>
246
+ `${context.options.idPrefix ?? 'chart'}-${id.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
247
+
248
+ const pointsPath = (
249
+ points: readonly (readonly [number, number])[],
250
+ close: boolean,
251
+ ): string =>
252
+ points
253
+ .map(([x, y], index) => `${index === 0 ? 'M' : 'L'}${num(x)} ${num(y)}`)
254
+ .join('') + (close && points.length > 0 ? 'Z' : '');
255
+
256
+ const attribute = (name: string, value: string | number | undefined): string =>
257
+ value === undefined
258
+ ? ''
259
+ : ` ${name}="${typeof value === 'number' ? num(value) : escapeAttribute(value)}"`;
260
+
261
+ /**
262
+ * Two decimals are well under a device pixel at any scale, and keep the SVG
263
+ * short. `+` turns `-0` and trailing zeros into their shortest form.
264
+ */
265
+ const num = (value: number): string =>
266
+ Number.isFinite(value) ? String(+value.toFixed(2) || 0) : '0';
267
+
268
+ const percent = (value: number): string => `${num(clampUnit(value) * 100)}%`;
269
+
270
+ const clampUnit = (value: number): number =>
271
+ Number.isNaN(value) ? 0 : Math.max(0, Math.min(1, value));
272
+
273
+ const escapeAttribute = (value: string): string =>
274
+ value
275
+ .replace(/&/g, '&amp;')
276
+ .replace(/"/g, '&quot;')
277
+ .replace(/</g, '&lt;')
278
+ .replace(/>/g, '&gt;');
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Labels
282
+ // ---------------------------------------------------------------------------
283
+
284
+ /** A scene label resolved to absolute chart coordinates and final paint. */
285
+ export type ChartLabel = {
286
+ readonly key: string;
287
+ readonly text: string;
288
+ readonly x: number;
289
+ readonly y: number;
290
+ readonly anchor: 'start' | 'middle' | 'end';
291
+ readonly baseline: 'auto' | 'middle' | 'hanging';
292
+ readonly rotate: number;
293
+ readonly fontSize: number;
294
+ readonly fontWeight: number;
295
+ /** A CSS color; `var(--token)` is fine, native text resolves it. */
296
+ readonly color: string;
297
+ readonly opacity: number;
298
+ };
299
+
300
+ export type ChartLabelOptions = {
301
+ /** Color for a label with no fill of its own or inherited. */
302
+ readonly defaultColor: string;
303
+ /** Feeds labels whose opacity follows focus (e.g. dimmed axis ticks). */
304
+ readonly focus?: ChartFocusState | null;
305
+ readonly pointer?: ChartTooltipPosition | null;
306
+ };
307
+
308
+ type LabelFrame = {
309
+ readonly x: number;
310
+ readonly y: number;
311
+ readonly opacity: number;
312
+ readonly fill: string | undefined;
313
+ readonly fillOpacity: number;
314
+ /** Absolute clip bounds of the nearest clipping group. */
315
+ readonly clip: ChartBounds | undefined;
316
+ };
317
+
318
+ /** TanStack's own default when a label sets no size (see estimateSceneText). */
319
+ const DEFAULT_FONT_SIZE = 16;
320
+
321
+ export const collectChartLabels = (
322
+ nodes: readonly SceneNode[],
323
+ options: ChartLabelOptions,
324
+ ): ChartLabel[] => {
325
+ const labels: ChartLabel[] = [];
326
+ const visit = (children: readonly SceneNode[], frame: LabelFrame) => {
327
+ for (const node of children) {
328
+ if (node.kind === 'group') {
329
+ if (node.focus) continue;
330
+ const x = frame.x + (node.translateX ?? 0);
331
+ const y = frame.y + (node.translateY ?? 0);
332
+ visit(node.children, {
333
+ x,
334
+ y,
335
+ opacity: frame.opacity * (node.style?.opacity ?? 1),
336
+ fill: node.style?.fill ?? frame.fill,
337
+ fillOpacity: node.style?.fillOpacity ?? frame.fillOpacity,
338
+ clip: node.clip
339
+ ? { ...node.clip, x: node.clip.x + x, y: node.clip.y + y }
340
+ : frame.clip,
341
+ });
342
+ } else if (node.kind === 'label') {
343
+ const label = resolveLabel(node, frame, options);
344
+ if (label) labels.push(label);
345
+ }
346
+ }
347
+ };
348
+ visit(nodes, {
349
+ x: 0,
350
+ y: 0,
351
+ opacity: 1,
352
+ fill: undefined,
353
+ fillOpacity: 1,
354
+ clip: undefined,
355
+ });
356
+ return labels;
357
+ };
358
+
359
+ const resolveLabel = (
360
+ node: SceneLabel,
361
+ frame: LabelFrame,
362
+ options: ChartLabelOptions,
363
+ ): ChartLabel | null => {
364
+ const x = frame.x + node.x;
365
+ const y = frame.y + node.y;
366
+ // A native <text> can't be clipped to part of its parent the way an SVG
367
+ // group is, so a label whose anchor falls outside its clip (a value label
368
+ // scrolled out of a zoomed viewport) is left out instead.
369
+ const { clip } = frame;
370
+ if (
371
+ clip &&
372
+ (x < clip.x ||
373
+ x > clip.x + clip.width ||
374
+ y < clip.y ||
375
+ y > clip.y + clip.height)
376
+ ) {
377
+ return null;
378
+ }
379
+ const focusOpacity =
380
+ node.focusOpacity?.({
381
+ focus: options.focus ?? null,
382
+ pointer: options.pointer ?? null,
383
+ }) ?? 1;
384
+ const opacity =
385
+ frame.opacity *
386
+ (node.style?.opacity ?? 1) *
387
+ (node.style?.fillOpacity ?? frame.fillOpacity) *
388
+ focusOpacity;
389
+ return {
390
+ key: node.key,
391
+ text: node.text,
392
+ x,
393
+ y,
394
+ anchor: node.anchor ?? 'start',
395
+ baseline: node.baseline ?? 'auto',
396
+ rotate: node.rotate ?? 0,
397
+ fontSize: node.fontSize ?? DEFAULT_FONT_SIZE,
398
+ fontWeight: node.fontWeight ?? 400,
399
+ color: nativeColor(node.style?.fill ?? frame.fill, options.defaultColor),
400
+ opacity,
401
+ };
402
+ };
403
+
404
+ /**
405
+ * `currentColor` means nothing to a native `<text>`; use the theme's text color.
406
+ */
407
+ const nativeColor = (paint: string | undefined, fallback: string): string =>
408
+ paint === undefined || paint === 'currentColor' ? fallback : paint;
409
+
410
+ /**
411
+ * Inline style placing a native `<text>` so its anchor point lands where the
412
+ * SVG label's would.
413
+ *
414
+ * The text box is as tall as one line at `line-height: font-size`, and
415
+ * `translate` percentages are relative to the text's own box, so the anchor
416
+ * shifts need no measurement: half the width for `middle`, all of it for `end`;
417
+ * half the height for a `middle` baseline, and 80% for an alphabetic one (the
418
+ * same ascent TanStack assumes when it lays out guides). Rotation turns about
419
+ * the anchor point, which `transform-origin: 0 0` plus applying the translate
420
+ * first gives.
421
+ */
422
+ export const chartLabelStyle = (label: ChartLabel): string => {
423
+ const tx =
424
+ label.anchor === 'middle' ? -50 : label.anchor === 'end' ? -100 : 0;
425
+ const ty =
426
+ label.baseline === 'middle' ? -50 : label.baseline === 'hanging' ? 0 : -80;
427
+ const rotate = label.rotate ? `rotate(${num(label.rotate)}deg) ` : '';
428
+ return (
429
+ `position: absolute; left: ${num(label.x)}px; top: ${num(label.y)}px; ` +
430
+ `font-size: ${num(label.fontSize)}px; line-height: ${num(label.fontSize)}px; ` +
431
+ `font-weight: ${label.fontWeight}; color: ${label.color}; opacity: ${num(label.opacity)}; ` +
432
+ `white-space: nowrap; transform-origin: 0 0; transform: ${rotate}translate(${tx}%, ${ty}%);`
433
+ );
434
+ };
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // Text measurement
438
+ // ---------------------------------------------------------------------------
439
+
440
+ /** `lynx.getTextInfo`'s shape: width in px for text at a CSS font size. */
441
+ export type LynxGetTextInfo = (
442
+ text: string,
443
+ info: { fontSize: string },
444
+ ) => { width: number };
445
+
446
+ /**
447
+ * A TanStack text measurer backed by `lynx.getTextInfo`, so axis margins fit
448
+ * the labels as the platform will actually draw them. Heights and the
449
+ * anchor/baseline offsets follow TanStack's estimator, which the label style
450
+ * above mirrors. `getTextInfo` takes no weight, so bold text is widened with the
451
+ * estimator's weight factor. Results are cached: guide layout measures every
452
+ * tick label on every render.
453
+ */
454
+ export const createLynxTextMeasurer = (
455
+ getTextInfo: LynxGetTextInfo,
456
+ ): ChartTextMeasurer => {
457
+ const cache = new Map<string, number>();
458
+ return (text, options) => {
459
+ const fontSize = options.fontSize * (options.fontScale || 1);
460
+ if (!text || !fontSize) return { x: 0, y: 0, width: 0, height: 0 };
461
+ const key = `${fontSize}\u0000${text}`;
462
+ let measured = cache.get(key);
463
+ if (measured === undefined) {
464
+ measured = getTextInfo(text, { fontSize: `${fontSize}px` }).width;
465
+ cache.set(key, measured);
466
+ }
467
+ const weight = Math.min(900, Math.max(100, options.fontWeight ?? 400));
468
+ const width = measured * (1 + (weight - 400) / 12_500);
469
+ const x =
470
+ options.anchor === 'middle'
471
+ ? -width / 2
472
+ : options.anchor === 'end'
473
+ ? -width
474
+ : 0;
475
+ const y =
476
+ options.baseline === 'middle'
477
+ ? -fontSize / 2
478
+ : options.baseline === 'hanging'
479
+ ? 0
480
+ : -fontSize * 0.8;
481
+ return { x, y, width, height: fontSize };
482
+ };
483
+ };
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ CHART_DARK_COLORS,
4
+ CHART_LIGHT_COLORS,
5
+ DEFAULT_CHART_THEME,
6
+ UI_CHART_THEME,
7
+ provideChartTheme,
8
+ resolveChartPaint,
9
+ } from './chart-theme';
10
+
11
+ const colors = {
12
+ foreground: 'rgba(1, 1, 1, 1)',
13
+ background: 'rgba(2, 2, 2, 1)',
14
+ primary: 'rgba(3, 3, 3, 1)',
15
+ };
16
+
17
+ describe('resolveChartPaint', () => {
18
+ it('replaces a theme token with its value', () => {
19
+ expect(resolveChartPaint('var(--primary)', colors)).toBe(colors.primary);
20
+ expect(resolveChartPaint(' var(--primary) ', colors)).toBe(colors.primary);
21
+ });
22
+
23
+ it('uses the fallback of an unknown token, resolving it too', () => {
24
+ expect(resolveChartPaint('var(--ts-chart-1, #2563eb)', colors)).toBe(
25
+ '#2563eb',
26
+ );
27
+ expect(resolveChartPaint('var(--missing, var(--primary))', colors)).toBe(
28
+ colors.primary,
29
+ );
30
+ // A comma inside the fallback's own parentheses isn't a separator.
31
+ expect(
32
+ resolveChartPaint('var(--missing, rgba(0, 0, 0, 0.5))', colors),
33
+ ).toBe('rgba(0, 0, 0, 0.5)');
34
+ });
35
+
36
+ it('falls back to the foreground for an unknown token without a fallback', () => {
37
+ expect(resolveChartPaint('var(--missing)', colors)).toBe(colors.foreground);
38
+ expect(resolveChartPaint('var(--missing)', {})).toBe('var(--missing)');
39
+ });
40
+
41
+ it('maps CSS system colors to the theme', () => {
42
+ expect(resolveChartPaint('currentColor', colors)).toBe(colors.foreground);
43
+ expect(resolveChartPaint('CanvasText', colors)).toBe(colors.foreground);
44
+ expect(resolveChartPaint('Canvas', colors)).toBe(colors.background);
45
+ expect(resolveChartPaint('currentColor', {})).toBe('currentColor');
46
+ expect(resolveChartPaint('Canvas', {})).toBe('Canvas');
47
+ });
48
+
49
+ it('passes concrete colors through', () => {
50
+ for (const paint of ['#fff', 'rgba(0, 0, 0, 1)', 'url(#g)', 'none']) {
51
+ expect(resolveChartPaint(paint, colors)).toBe(paint);
52
+ }
53
+ });
54
+ });
55
+
56
+ describe('chart theme providers', () => {
57
+ it('defaults to the light and dark tables and the token-based TanStack theme', () => {
58
+ const factory = (
59
+ UI_CHART_THEME as unknown as { ɵprov: { factory: () => unknown } }
60
+ ).ɵprov.factory;
61
+ expect(factory()).toEqual({
62
+ light: CHART_LIGHT_COLORS,
63
+ dark: CHART_DARK_COLORS,
64
+ chart: DEFAULT_CHART_THEME,
65
+ });
66
+ });
67
+
68
+ it('merges overrides over the defaults', () => {
69
+ const provider = provideChartTheme({
70
+ light: { 'chart-1': 'red' },
71
+ chart: { palette: ['blue'] },
72
+ }) as {
73
+ provide: unknown;
74
+ useValue: Record<string, Record<string, unknown>>;
75
+ };
76
+ expect(provider.provide).toBe(UI_CHART_THEME);
77
+ expect(provider.useValue['light']!['chart-1']).toBe('red');
78
+ expect(provider.useValue['light']!['primary']).toBe(
79
+ CHART_LIGHT_COLORS['primary'],
80
+ );
81
+ expect(provider.useValue['dark']).toEqual(CHART_DARK_COLORS);
82
+ expect(provider.useValue['chart']!['palette']).toEqual(['blue']);
83
+ expect(provider.useValue['chart']!['foreground']).toBe('var(--foreground)');
84
+ });
85
+ });