@blotch/dolan 22.2.1 → 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.
- package/dist/commands/add.d.ts +6 -2
- package/dist/commands/add.js +32 -10
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/add.test.js +129 -9
- package/dist/commands/add.test.js.map +1 -1
- package/dist/commands/diff.d.ts +2 -1
- package/dist/commands/diff.js +38 -25
- package/dist/commands/diff.js.map +1 -1
- package/dist/commands/diff.test.js +72 -11
- package/dist/commands/diff.test.js.map +1 -1
- package/dist/commands/doctor.js +41 -27
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +93 -9
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/eject.js +7 -0
- package/dist/commands/eject.js.map +1 -1
- package/dist/commands/eject.test.js +39 -0
- package/dist/commands/eject.test.js.map +1 -1
- package/dist/commands/info.js +3 -2
- package/dist/commands/info.js.map +1 -1
- package/dist/commands/init.d.ts +37 -1
- package/dist/commands/init.js +149 -40
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +285 -26
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/install-packages.d.ts +6 -0
- package/dist/commands/install-packages.js +20 -0
- package/dist/commands/install-packages.js.map +1 -0
- package/dist/commands/install-packages.test.d.ts +1 -0
- package/dist/commands/install-packages.test.js +55 -0
- package/dist/commands/install-packages.test.js.map +1 -0
- package/dist/commands/list.js +21 -14
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/list.test.js +62 -5
- package/dist/commands/list.test.js.map +1 -1
- package/dist/commands/outdated.js +11 -10
- package/dist/commands/outdated.js.map +1 -1
- package/dist/commands/outdated.test.js +20 -5
- package/dist/commands/outdated.test.js.map +1 -1
- package/dist/commands/remove.d.ts +4 -0
- package/dist/commands/remove.js +17 -4
- package/dist/commands/remove.js.map +1 -1
- package/dist/commands/remove.test.js +53 -2
- package/dist/commands/remove.test.js.map +1 -1
- package/dist/commands/update.d.ts +6 -0
- package/dist/commands/update.js +59 -24
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/update.test.js +273 -31
- package/dist/commands/update.test.js.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +8 -0
- package/dist/registry.js +32 -24
- package/dist/registry.js.map +1 -1
- package/dist/registry.test.js +76 -37
- package/dist/registry.test.js.map +1 -1
- package/dist/testing/set-input-signal.d.ts +13 -0
- package/dist/testing/set-input-signal.js +20 -0
- package/dist/testing/set-input-signal.js.map +1 -0
- package/dist/testing/set-input-signal.test.d.ts +1 -0
- package/dist/testing/set-input-signal.test.js +20 -0
- package/dist/testing/set-input-signal.test.js.map +1 -0
- package/dist/ui/components/action-sheet/action-sheet.test.ts +48 -1
- package/dist/ui/components/action-sheet/action-sheet.ts +37 -9
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +49 -2
- package/dist/ui/components/alert-dialog/alert-dialog.ts +43 -12
- package/dist/ui/components/area-chart/area-chart-render.test.ts +52 -110
- package/dist/ui/components/area-chart/area-chart.test.ts +59 -146
- package/dist/ui/components/area-chart/area-chart.ts +100 -315
- package/dist/ui/components/area-chart/index.ts +2 -3
- package/dist/ui/components/bar-chart/bar-chart-render.test.ts +47 -163
- package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -58
- package/dist/ui/components/bar-chart/bar-chart.ts +140 -294
- package/dist/ui/components/bar-chart/index.ts +3 -3
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +191 -9
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +136 -20
- package/dist/ui/components/button/button-render.test.ts +19 -0
- package/dist/ui/components/button/button.test.ts +2 -15
- package/dist/ui/components/button/button.ts +18 -2
- package/dist/ui/components/button-group/button-group.test.ts +2 -21
- package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +48 -163
- package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +107 -85
- package/dist/ui/components/candlestick-chart/candlestick-chart.ts +150 -323
- package/dist/ui/components/candlestick-chart/index.ts +3 -4
- package/dist/ui/components/chart/chart-focus.test.ts +132 -0
- package/dist/ui/components/chart/chart-focus.ts +115 -0
- package/dist/ui/components/chart/chart-presets.test.ts +139 -0
- package/dist/ui/components/chart/chart-presets.ts +271 -0
- package/dist/ui/components/chart/chart-render.test.ts +730 -0
- package/dist/ui/components/chart/chart-scene.test.ts +589 -0
- package/dist/ui/components/chart/chart-scene.ts +483 -0
- package/dist/ui/components/chart/chart-theme.test.ts +85 -0
- package/dist/ui/components/chart/chart-theme.ts +198 -0
- package/dist/ui/components/chart/chart-zoom.test.ts +159 -0
- package/dist/ui/components/chart/chart-zoom.ts +162 -0
- package/dist/ui/components/chart/chart.ts +921 -0
- package/dist/ui/components/chart/index.ts +24 -0
- package/dist/ui/components/dialog/dialog.test.ts +49 -2
- package/dist/ui/components/dialog/dialog.ts +43 -12
- package/dist/ui/components/icon/icon-theme.test.ts +133 -0
- package/dist/ui/components/icon/icon-theme.ts +204 -0
- package/dist/ui/components/icon/icon.test.ts +51 -2
- package/dist/ui/components/icon/icon.ts +38 -21
- package/dist/ui/components/line-chart/index.ts +2 -3
- package/dist/ui/components/line-chart/line-chart-render.test.ts +69 -107
- package/dist/ui/components/line-chart/line-chart.test.ts +63 -67
- package/dist/ui/components/line-chart/line-chart.ts +83 -241
- package/dist/ui/components/list/list.test.ts +69 -2
- package/dist/ui/components/list/list.ts +21 -15
- package/dist/ui/components/nav-drawer/nav-drawer.test.ts +80 -1
- package/dist/ui/components/nav-drawer/nav-drawer.ts +66 -11
- package/dist/ui/components/pagination/pagination.test.ts +62 -0
- package/dist/ui/components/pagination/pagination.ts +55 -8
- package/dist/ui/components/scatter-chart/index.ts +2 -3
- package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +51 -144
- package/dist/ui/components/scatter-chart/scatter-chart.test.ts +57 -47
- package/dist/ui/components/scatter-chart/scatter-chart.ts +104 -250
- package/dist/ui/components/scroll-area/scroll-area.test.ts +37 -2
- package/dist/ui/components/scroll-area/scroll-area.ts +8 -8
- package/dist/ui/components/spinner/spinner.test.ts +27 -0
- package/dist/ui/components/spinner/spinner.ts +33 -23
- package/dist/ui/components/toast/toast.test.ts +37 -0
- package/dist/ui/components/toast/toast.ts +20 -4
- package/dist/ui/theme/chart-colors.test.ts +30 -0
- package/dist/ui/theme/dark.css +8 -0
- package/dist/ui/theme/default.css +8 -0
- package/dist/ui/theme/icon-colors.test.ts +30 -0
- package/dist/ui/theme/tailwind-plugin.ts +10 -1
- package/dist/utils/installed.d.ts +17 -0
- package/dist/utils/installed.js +27 -0
- package/dist/utils/installed.js.map +1 -0
- package/dist/utils/installed.test.d.ts +1 -0
- package/dist/utils/installed.test.js +41 -0
- package/dist/utils/installed.test.js.map +1 -0
- package/dist/utils/interactive.d.ts +9 -0
- package/dist/utils/interactive.js +10 -0
- package/dist/utils/interactive.js.map +1 -0
- package/dist/utils/interactive.test.d.ts +1 -0
- package/dist/utils/interactive.test.js +24 -0
- package/dist/utils/interactive.test.js.map +1 -0
- package/dist/utils/packages.d.ts +36 -0
- package/dist/utils/packages.js +70 -0
- package/dist/utils/packages.js.map +1 -0
- package/dist/utils/packages.test.d.ts +1 -0
- package/dist/utils/packages.test.js +89 -0
- package/dist/utils/packages.test.js.map +1 -0
- package/dist/utils/resolve-paths.d.ts +17 -1
- package/dist/utils/resolve-paths.js +23 -3
- package/dist/utils/resolve-paths.js.map +1 -1
- package/dist/utils/resolve-paths.test.js +34 -2
- package/dist/utils/resolve-paths.test.js.map +1 -1
- package/package.json +5 -1
- package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +0 -188
- package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +0 -549
- package/dist/ui/components/cartesian-chart/cartesian-chart.ts +0 -1140
- 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, '&')
|
|
276
|
+
.replace(/"/g, '"')
|
|
277
|
+
.replace(/</g, '<')
|
|
278
|
+
.replace(/>/g, '>');
|
|
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
|
+
});
|