@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,198 @@
|
|
|
1
|
+
import { InjectionToken, type Provider } from '@angular/core';
|
|
2
|
+
import type { ChartTheme } from '@tanstack/charts/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Theme for `<ui-chart>`.
|
|
6
|
+
*
|
|
7
|
+
* ## Why colors live in TypeScript as well as CSS
|
|
8
|
+
* A chart's marks are drawn by Lynx's native `<svg>` element, which parses an
|
|
9
|
+
* SVG string by itself: it never sees the page's CSS, so `var(--primary)` means
|
|
10
|
+
* nothing to it, and Lynx has no computed-style API to read the variable's value
|
|
11
|
+
* from JS. So the scene keeps CSS-shaped paints (`var(--primary)`, the palette's
|
|
12
|
+
* `var(--chart-1)`), and the chart swaps each for the value in the active color
|
|
13
|
+
* table below just before it writes the SVG.
|
|
14
|
+
*
|
|
15
|
+
* The tables mirror `default.css` and `dark.css` (a test in the theme folder
|
|
16
|
+
* keeps them in step). If you change a color there, change it here too, or pass
|
|
17
|
+
* your own with {@link provideChartTheme}. Axis labels are native `<text>`, so
|
|
18
|
+
* they read the CSS variables directly and always match the page.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Theme token name (without `--`) to a complete CSS color. */
|
|
22
|
+
export type ChartColorTokens = Readonly<Record<string, string>>;
|
|
23
|
+
|
|
24
|
+
export type ChartColorScheme = 'light' | 'dark';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Which colors a chart draws with: a fixed scheme, `'system'` to follow the
|
|
28
|
+
* device appearance (`LynxTheme`), or a function reading the app's own theme
|
|
29
|
+
* state.
|
|
30
|
+
*
|
|
31
|
+
* It has to match how the app switches its CSS theme, because axis labels use
|
|
32
|
+
* the CSS variables while the marks use the tables below. The ui theme's dark
|
|
33
|
+
* mode is opt-in (the app toggles the `.dark` class), so the default is
|
|
34
|
+
* `'light'`: a chart in an app without dark mode stays readable when the
|
|
35
|
+
* device is dark. An app that toggles `.dark` from `LynxTheme` (see the dark
|
|
36
|
+
* mode guide) uses `'system'`.
|
|
37
|
+
*/
|
|
38
|
+
export type ChartColorSchemeOption =
|
|
39
|
+
| ChartColorScheme
|
|
40
|
+
| 'system'
|
|
41
|
+
| (() => ChartColorScheme);
|
|
42
|
+
|
|
43
|
+
export type UiChartTheme = {
|
|
44
|
+
/** Token values for light mode; mirrors `default.css`. */
|
|
45
|
+
readonly light: ChartColorTokens;
|
|
46
|
+
/** Token values for dark mode; mirrors `dark.css`. */
|
|
47
|
+
readonly dark: ChartColorTokens;
|
|
48
|
+
/** See {@link ChartColorSchemeOption}; `'light'` when unset. */
|
|
49
|
+
readonly colorScheme?: ChartColorSchemeOption;
|
|
50
|
+
/**
|
|
51
|
+
* The theme TanStack Charts builds scenes with. Its paints should be
|
|
52
|
+
* `var(--token)` strings, so one scene serves both color schemes.
|
|
53
|
+
*/
|
|
54
|
+
readonly chart: Partial<ChartTheme>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const CHART_LIGHT_COLORS: ChartColorTokens = {
|
|
58
|
+
background: 'rgba(255, 255, 255, 1)',
|
|
59
|
+
foreground: 'rgba(9, 9, 11, 1)',
|
|
60
|
+
card: 'rgba(255, 255, 255, 1)',
|
|
61
|
+
'card-foreground': 'rgba(9, 9, 11, 1)',
|
|
62
|
+
primary: 'rgba(24, 24, 27, 1)',
|
|
63
|
+
'primary-foreground': 'rgba(250, 250, 250, 1)',
|
|
64
|
+
secondary: 'rgba(244, 244, 245, 1)',
|
|
65
|
+
'secondary-foreground': 'rgba(24, 24, 27, 1)',
|
|
66
|
+
muted: 'rgba(244, 244, 245, 1)',
|
|
67
|
+
'muted-foreground': 'rgba(113, 113, 122, 1)',
|
|
68
|
+
accent: 'rgba(244, 244, 245, 1)',
|
|
69
|
+
'accent-foreground': 'rgba(24, 24, 27, 1)',
|
|
70
|
+
destructive: 'rgba(239, 68, 68, 1)',
|
|
71
|
+
'destructive-foreground': 'rgba(250, 250, 250, 1)',
|
|
72
|
+
'destructive-subtle': 'rgba(239, 68, 68, 0.1)',
|
|
73
|
+
border: 'rgba(228, 228, 231, 1)',
|
|
74
|
+
input: 'rgba(228, 228, 231, 1)',
|
|
75
|
+
ring: 'rgba(24, 24, 27, 1)',
|
|
76
|
+
'ring-subtle': 'rgba(24, 24, 27, 0.4)',
|
|
77
|
+
'chart-1': 'rgba(231, 110, 80, 1)',
|
|
78
|
+
'chart-2': 'rgba(42, 157, 144, 1)',
|
|
79
|
+
'chart-3': 'rgba(39, 71, 84, 1)',
|
|
80
|
+
'chart-4': 'rgba(232, 196, 104, 1)',
|
|
81
|
+
'chart-5': 'rgba(244, 164, 98, 1)',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const CHART_DARK_COLORS: ChartColorTokens = {
|
|
85
|
+
background: 'rgba(9, 9, 11, 1)',
|
|
86
|
+
foreground: 'rgba(250, 250, 250, 1)',
|
|
87
|
+
card: 'rgba(9, 9, 11, 1)',
|
|
88
|
+
'card-foreground': 'rgba(250, 250, 250, 1)',
|
|
89
|
+
primary: 'rgba(250, 250, 250, 1)',
|
|
90
|
+
'primary-foreground': 'rgba(24, 24, 27, 1)',
|
|
91
|
+
secondary: 'rgba(39, 39, 42, 1)',
|
|
92
|
+
'secondary-foreground': 'rgba(250, 250, 250, 1)',
|
|
93
|
+
muted: 'rgba(39, 39, 42, 1)',
|
|
94
|
+
'muted-foreground': 'rgba(161, 161, 170, 1)',
|
|
95
|
+
accent: 'rgba(39, 39, 42, 1)',
|
|
96
|
+
'accent-foreground': 'rgba(250, 250, 250, 1)',
|
|
97
|
+
destructive: 'rgba(127, 29, 29, 1)',
|
|
98
|
+
'destructive-foreground': 'rgba(250, 250, 250, 1)',
|
|
99
|
+
'destructive-subtle': 'rgba(127, 29, 29, 0.1)',
|
|
100
|
+
border: 'rgba(39, 39, 42, 1)',
|
|
101
|
+
input: 'rgba(39, 39, 42, 1)',
|
|
102
|
+
ring: 'rgba(212, 212, 216, 1)',
|
|
103
|
+
'ring-subtle': 'rgba(212, 212, 216, 0.4)',
|
|
104
|
+
'chart-1': 'rgba(38, 98, 217, 1)',
|
|
105
|
+
'chart-2': 'rgba(46, 184, 138, 1)',
|
|
106
|
+
'chart-3': 'rgba(232, 140, 48, 1)',
|
|
107
|
+
'chart-4': 'rgba(175, 87, 219, 1)',
|
|
108
|
+
'chart-5': 'rgba(226, 54, 112, 1)',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* TanStack's own defaults paint everything `currentColor` at reduced opacity
|
|
113
|
+
* (grid lines at 0.11, tick labels at 0.72), which reads well on any background.
|
|
114
|
+
* The same roles map onto theme tokens: text and grid follow the foreground,
|
|
115
|
+
* secondary text the muted foreground, and series cycle through `--chart-*`.
|
|
116
|
+
*/
|
|
117
|
+
export const DEFAULT_CHART_THEME: Partial<ChartTheme> = {
|
|
118
|
+
foreground: 'var(--foreground)',
|
|
119
|
+
muted: 'var(--muted-foreground)',
|
|
120
|
+
grid: 'var(--foreground)',
|
|
121
|
+
background: 'transparent',
|
|
122
|
+
palette: [
|
|
123
|
+
'var(--chart-1)',
|
|
124
|
+
'var(--chart-2)',
|
|
125
|
+
'var(--chart-3)',
|
|
126
|
+
'var(--chart-4)',
|
|
127
|
+
'var(--chart-5)',
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const UI_CHART_THEME = new InjectionToken<UiChartTheme>(
|
|
132
|
+
'UI_CHART_THEME',
|
|
133
|
+
{
|
|
134
|
+
providedIn: 'root',
|
|
135
|
+
factory: () => ({
|
|
136
|
+
light: CHART_LIGHT_COLORS,
|
|
137
|
+
dark: CHART_DARK_COLORS,
|
|
138
|
+
chart: DEFAULT_CHART_THEME,
|
|
139
|
+
}),
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Overrides chart colors, merged over the defaults: pass only the tokens you
|
|
145
|
+
* changed in your theme CSS, or a different TanStack theme (e.g. a palette).
|
|
146
|
+
*
|
|
147
|
+
* ```ts
|
|
148
|
+
* providers: [provideChartTheme({ light: { 'chart-1': 'rgba(37, 99, 235, 1)' } })]
|
|
149
|
+
* // An app that switches .dark with the device appearance:
|
|
150
|
+
* providers: [provideChartTheme({ colorScheme: 'system' })]
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export const provideChartTheme = (theme: {
|
|
154
|
+
light?: ChartColorTokens;
|
|
155
|
+
dark?: ChartColorTokens;
|
|
156
|
+
chart?: Partial<ChartTheme>;
|
|
157
|
+
colorScheme?: ChartColorSchemeOption;
|
|
158
|
+
}): Provider => ({
|
|
159
|
+
provide: UI_CHART_THEME,
|
|
160
|
+
useValue: {
|
|
161
|
+
light: { ...CHART_LIGHT_COLORS, ...theme.light },
|
|
162
|
+
dark: { ...CHART_DARK_COLORS, ...theme.dark },
|
|
163
|
+
chart: { ...DEFAULT_CHART_THEME, ...theme.chart },
|
|
164
|
+
colorScheme: theme.colorScheme,
|
|
165
|
+
} satisfies UiChartTheme,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Turns a CSS-shaped paint into a concrete color an SVG parser can draw:
|
|
170
|
+
* `var(--token)` (with or without a fallback) becomes the token's value,
|
|
171
|
+
* `currentColor` the foreground. Anything else (`#2563eb`, `rgba(…)`, a
|
|
172
|
+
* gradient's `url(#id)`) is already concrete and passes through.
|
|
173
|
+
*/
|
|
174
|
+
export const resolveChartPaint = (
|
|
175
|
+
paint: string,
|
|
176
|
+
colors: ChartColorTokens,
|
|
177
|
+
): string => {
|
|
178
|
+
const value = paint.trim();
|
|
179
|
+
if (value === 'currentColor' || value === 'CanvasText') {
|
|
180
|
+
return colors['foreground'] ?? value;
|
|
181
|
+
}
|
|
182
|
+
if (value === 'Canvas') return colors['background'] ?? value;
|
|
183
|
+
if (!value.startsWith('var(') || !value.endsWith(')')) return value;
|
|
184
|
+
|
|
185
|
+
const inner = value.slice(4, -1);
|
|
186
|
+
// A custom property name can't contain a comma or parenthesis, so the first
|
|
187
|
+
// comma always separates the name from the fallback.
|
|
188
|
+
const comma = inner.indexOf(',');
|
|
189
|
+
const name = (comma < 0 ? inner : inner.slice(0, comma)).trim();
|
|
190
|
+
const token = colors[name.replace(/^--/, '')];
|
|
191
|
+
if (token !== undefined) return token;
|
|
192
|
+
// An unknown token falls back the way CSS does: to the var()'s fallback when
|
|
193
|
+
// it has one (TanStack's own palette is `var(--ts-chart-1, #2563eb)`), and
|
|
194
|
+
// otherwise to the foreground so the mark stays visible.
|
|
195
|
+
return comma < 0
|
|
196
|
+
? (colors['foreground'] ?? value)
|
|
197
|
+
: resolveChartPaint(inner.slice(comma + 1), colors);
|
|
198
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { ResolvedScale } from '@tanstack/charts/types';
|
|
3
|
+
import {
|
|
4
|
+
axisContentWindow,
|
|
5
|
+
axisVisibleWindow,
|
|
6
|
+
clampWindow,
|
|
7
|
+
isFullWindow,
|
|
8
|
+
panWindow,
|
|
9
|
+
toViewportDomain,
|
|
10
|
+
withViewport,
|
|
11
|
+
zoomWindow,
|
|
12
|
+
} from './chart-zoom';
|
|
13
|
+
|
|
14
|
+
const scale = (
|
|
15
|
+
domain: readonly unknown[],
|
|
16
|
+
extra: Partial<ResolvedScale> = {},
|
|
17
|
+
): ResolvedScale =>
|
|
18
|
+
({
|
|
19
|
+
id: 'x',
|
|
20
|
+
type: 'linear',
|
|
21
|
+
domain,
|
|
22
|
+
map: () => 0,
|
|
23
|
+
ticks: [],
|
|
24
|
+
bandwidth: 0,
|
|
25
|
+
...extra,
|
|
26
|
+
}) as ResolvedScale;
|
|
27
|
+
|
|
28
|
+
describe('window math', () => {
|
|
29
|
+
it('clamps a window inside the base, keeping its span', () => {
|
|
30
|
+
expect(clampWindow([0, 10], [8, 12])).toEqual([6, 10]);
|
|
31
|
+
expect(clampWindow([0, 10], [-3, 1])).toEqual([0, 4]);
|
|
32
|
+
expect(clampWindow([0, 10], [-5, 20])).toEqual([0, 10]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('pans by a data delta, stopping at the edges', () => {
|
|
36
|
+
expect(panWindow([0, 10], [2, 4], 3)).toEqual([5, 7]);
|
|
37
|
+
expect(panWindow([0, 10], [2, 4], 100)).toEqual([8, 10]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('zooms around a focal value within the zoom limits', () => {
|
|
41
|
+
expect(zoomWindow([0, 10], [0, 10], 2, 5, 8)).toEqual([2.5, 7.5]);
|
|
42
|
+
// Never past maxZoom…
|
|
43
|
+
expect(zoomWindow([0, 10], [4, 6], 100, 5, 4)).toEqual([3.75, 6.25]);
|
|
44
|
+
// …nor out past the whole base.
|
|
45
|
+
expect(zoomWindow([0, 10], [4, 6], 0.01, 5, 8)).toEqual([0, 10]);
|
|
46
|
+
// A zero factor counts as 1, and zero limits mean the base span.
|
|
47
|
+
expect(zoomWindow([0, 10], [0, 10], 0, 5, 0, 0)).toEqual([0, 10]);
|
|
48
|
+
// A zero-width window zooms about its middle.
|
|
49
|
+
expect(zoomWindow([0, 10], [5, 5], 1, 5, 8)).toEqual([4.375, 5.625]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('knows a window covering the base is at rest', () => {
|
|
53
|
+
expect(isFullWindow([0, 10], [0, 10])).toBe(true);
|
|
54
|
+
expect(isFullWindow([0, 10], [0, 9])).toBe(false);
|
|
55
|
+
expect(isFullWindow([3, 3], [3, 3])).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('axis windows', () => {
|
|
60
|
+
it('reads a continuous axis as numbers, ordered', () => {
|
|
61
|
+
expect(axisContentWindow(scale([0, 10]))).toEqual([0, 10]);
|
|
62
|
+
expect(axisContentWindow(scale([10, 0]))).toEqual([0, 10]);
|
|
63
|
+
expect(axisContentWindow(scale([new Date(1), new Date(5)]))).toEqual([
|
|
64
|
+
1, 5,
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("prefers a viewport's complete content domain", () => {
|
|
69
|
+
expect(
|
|
70
|
+
axisContentWindow(
|
|
71
|
+
scale([2, 4], {
|
|
72
|
+
viewport: {
|
|
73
|
+
contentDomain: [0, 10],
|
|
74
|
+
domain: [2, 4],
|
|
75
|
+
translate: 0,
|
|
76
|
+
map: () => 0,
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
).toEqual([0, 10]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects axes that can't zoom", () => {
|
|
84
|
+
expect(axisContentWindow(undefined)).toBeNull();
|
|
85
|
+
expect(axisContentWindow(scale(['a', 'b'], { bandwidth: 20 }))).toBeNull();
|
|
86
|
+
expect(axisContentWindow(scale([1, 2, 3]))).toBeNull();
|
|
87
|
+
expect(axisContentWindow(scale([1, 1]))).toBeNull();
|
|
88
|
+
expect(axisContentWindow(scale(['a', 'b']))).toBeNull();
|
|
89
|
+
expect(axisContentWindow(scale([0, Number.NaN]))).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('reads the visible window from the viewport, else the whole axis', () => {
|
|
93
|
+
const viewport = (domain: readonly unknown[]) =>
|
|
94
|
+
scale([0, 10], {
|
|
95
|
+
viewport: {
|
|
96
|
+
contentDomain: [0, 10],
|
|
97
|
+
domain: domain as never,
|
|
98
|
+
translate: 0,
|
|
99
|
+
map: () => 0,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
expect(axisVisibleWindow(viewport([6, 2]))).toEqual([2, 6]);
|
|
103
|
+
expect(axisVisibleWindow(viewport(['a', 'b']))).toEqual([0, 10]);
|
|
104
|
+
expect(axisVisibleWindow(scale([0, 10]))).toEqual([0, 10]);
|
|
105
|
+
expect(axisVisibleWindow(undefined)).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("converts a window to the axis's value type", () => {
|
|
109
|
+
expect(toViewportDomain([1, 2], scale([0, 10]))).toEqual([1, 2]);
|
|
110
|
+
expect(
|
|
111
|
+
toViewportDomain([1, 2], scale([new Date(0), new Date(10)])),
|
|
112
|
+
).toEqual([new Date(1), new Date(2)]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('withViewport', () => {
|
|
117
|
+
const definition = {
|
|
118
|
+
marks: [],
|
|
119
|
+
scales: { x: { scale: 'x-scale' }, y: { scale: 'y-scale' } },
|
|
120
|
+
} as never;
|
|
121
|
+
|
|
122
|
+
it('returns the definition untouched without a window', () => {
|
|
123
|
+
expect(withViewport(definition, {})).toBe(definition);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('sets the viewport on the windowed, configured axes', () => {
|
|
127
|
+
const result = withViewport(definition, { x: [1, 2] }) as {
|
|
128
|
+
scales: Record<string, unknown>;
|
|
129
|
+
};
|
|
130
|
+
expect(result.scales['x']).toEqual({
|
|
131
|
+
scale: 'x-scale',
|
|
132
|
+
viewport: { domain: [1, 2] },
|
|
133
|
+
});
|
|
134
|
+
expect(result.scales['y']).toEqual({ scale: 'y-scale' });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('skips axes the definition leaves to inference', () => {
|
|
138
|
+
const partial = { marks: [], scales: { y: { scale: 'y' } } } as never;
|
|
139
|
+
expect(withViewport(partial, { x: [1, 2] })).toEqual(partial);
|
|
140
|
+
const none = { marks: [] } as never;
|
|
141
|
+
expect(withViewport(none, { x: [1, 2] })).toBe(none);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("applies the viewport to a responsive definition's spec", () => {
|
|
145
|
+
const responsive = {
|
|
146
|
+
chart: () => ({ marks: [], scales: { x: { scale: 's' } } }),
|
|
147
|
+
tooltip: false,
|
|
148
|
+
} as never;
|
|
149
|
+
const result = withViewport(responsive, { x: [3, 4] }) as {
|
|
150
|
+
chart: (context: unknown) => { scales: Record<string, unknown> };
|
|
151
|
+
tooltip: boolean;
|
|
152
|
+
};
|
|
153
|
+
expect(result.tooltip).toBe(false);
|
|
154
|
+
expect(result.chart({}).scales['x']).toEqual({
|
|
155
|
+
scale: 's',
|
|
156
|
+
viewport: { domain: [3, 4] },
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { ChartDefinition, ResolvedScale } from '@tanstack/charts/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pan and zoom for `<ui-chart>`, as a visible window over each continuous
|
|
5
|
+
* axis. TanStack Charts already has the concept: a scale's `viewport.domain` is
|
|
6
|
+
* "a semantic window over the scale's complete domain", which it maps into the
|
|
7
|
+
* plot and clips to. So zooming never touches the data or the marks; the chart
|
|
8
|
+
* only rewrites that one option and re-renders. Windows are plain numbers; a
|
|
9
|
+
* time axis converts to and from epoch milliseconds at the edges.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** An inclusive `[low, high]` window in data units. */
|
|
13
|
+
export type ChartWindow = readonly [number, number];
|
|
14
|
+
|
|
15
|
+
export type ChartZoomAxes = 'x' | 'y' | 'xy';
|
|
16
|
+
|
|
17
|
+
/** A viewport domain as TanStack takes it: numbers, or `Date`s on a time axis. */
|
|
18
|
+
export type ChartViewportDomain =
|
|
19
|
+
| readonly [number, number]
|
|
20
|
+
| readonly [Date, Date];
|
|
21
|
+
|
|
22
|
+
/** The visible window per axis; a missing axis is fully zoomed out. */
|
|
23
|
+
export type ChartViewport = {
|
|
24
|
+
readonly x?: ChartViewportDomain;
|
|
25
|
+
readonly y?: ChartViewportDomain;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const clampValue = (value: number, lo: number, hi: number): number =>
|
|
29
|
+
Math.min(hi, Math.max(lo, value));
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Keeps `window` inside `base`: never wider than it, never past either edge.
|
|
33
|
+
* Span is preserved where possible (the window slides rather than resizes), so
|
|
34
|
+
* panning into an edge stops instead of secretly zooming.
|
|
35
|
+
*/
|
|
36
|
+
export const clampWindow = (
|
|
37
|
+
base: ChartWindow,
|
|
38
|
+
window: ChartWindow,
|
|
39
|
+
): ChartWindow => {
|
|
40
|
+
const span = Math.min(window[1] - window[0], base[1] - base[0]);
|
|
41
|
+
const lo = clampValue(window[0], base[0], base[1] - span);
|
|
42
|
+
return [lo, lo + span];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Slides `current` by `delta` data units, stopping at the edges of `base`. */
|
|
46
|
+
export const panWindow = (
|
|
47
|
+
base: ChartWindow,
|
|
48
|
+
current: ChartWindow,
|
|
49
|
+
delta: number,
|
|
50
|
+
): ChartWindow => clampWindow(base, [current[0] + delta, current[1] + delta]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Zooms `current` by `factor` (>1 in, <1 out) around the data value `focal`,
|
|
54
|
+
* which keeps its place on screen. The span stays within
|
|
55
|
+
* `[baseSpan / maxZoom, baseSpan / minZoom]`.
|
|
56
|
+
*/
|
|
57
|
+
export const zoomWindow = (
|
|
58
|
+
base: ChartWindow,
|
|
59
|
+
current: ChartWindow,
|
|
60
|
+
factor: number,
|
|
61
|
+
focal: number,
|
|
62
|
+
maxZoom: number,
|
|
63
|
+
minZoom = 1,
|
|
64
|
+
): ChartWindow => {
|
|
65
|
+
const baseSpan = base[1] - base[0];
|
|
66
|
+
const span = current[1] - current[0];
|
|
67
|
+
const minSpan = maxZoom > 0 ? baseSpan / maxZoom : baseSpan;
|
|
68
|
+
const maxSpan = minZoom > 0 ? baseSpan / minZoom : baseSpan;
|
|
69
|
+
const nextSpan = clampValue(span / (factor || 1), minSpan, maxSpan);
|
|
70
|
+
const fraction = span === 0 ? 0.5 : (focal - current[0]) / span;
|
|
71
|
+
const lo = focal - fraction * nextSpan;
|
|
72
|
+
return clampWindow(base, [lo, lo + nextSpan]);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Whether `window` covers (practically) all of `base`, i.e. is at rest. */
|
|
76
|
+
export const isFullWindow = (
|
|
77
|
+
base: ChartWindow,
|
|
78
|
+
window: ChartWindow,
|
|
79
|
+
): boolean => {
|
|
80
|
+
const baseSpan = base[1] - base[0];
|
|
81
|
+
return baseSpan === 0 || window[1] - window[0] >= baseSpan * (1 - 1e-6);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The complete domain of a rendered continuous axis, as numbers, or `null` for
|
|
86
|
+
* an axis that can't zoom: a band/point scale, a missing scale, or a degenerate
|
|
87
|
+
* domain. With a viewport applied, the scene still reports the full domain as
|
|
88
|
+
* `viewport.contentDomain`.
|
|
89
|
+
*/
|
|
90
|
+
export const axisContentWindow = (
|
|
91
|
+
scale: ResolvedScale | undefined,
|
|
92
|
+
): ChartWindow | null => {
|
|
93
|
+
if (!scale || scale.bandwidth > 0) return null;
|
|
94
|
+
const domain = scale.viewport?.contentDomain ?? scale.domain;
|
|
95
|
+
if (domain.length !== 2) return null;
|
|
96
|
+
const lo = toNumber(domain[0]);
|
|
97
|
+
const hi = toNumber(domain[1]);
|
|
98
|
+
if (lo === null || hi === null || lo === hi) return null;
|
|
99
|
+
return lo < hi ? [lo, hi] : [hi, lo];
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** The window currently shown on a zoomable axis: its viewport, else all of it. */
|
|
103
|
+
export const axisVisibleWindow = (
|
|
104
|
+
scale: ResolvedScale | undefined,
|
|
105
|
+
): ChartWindow | null => {
|
|
106
|
+
const content = axisContentWindow(scale);
|
|
107
|
+
const viewport = scale?.viewport?.domain;
|
|
108
|
+
if (!content || !viewport) return content;
|
|
109
|
+
const lo = toNumber(viewport[0]);
|
|
110
|
+
const hi = toNumber(viewport[1]);
|
|
111
|
+
return lo === null || hi === null
|
|
112
|
+
? content
|
|
113
|
+
: [Math.min(lo, hi), Math.max(lo, hi)];
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `window` in the axis's own value type: `Date`s for a time scale, so the
|
|
118
|
+
* viewport compares like with like against its domain.
|
|
119
|
+
*/
|
|
120
|
+
export const toViewportDomain = (
|
|
121
|
+
window: ChartWindow,
|
|
122
|
+
scale: ResolvedScale,
|
|
123
|
+
): ChartViewportDomain =>
|
|
124
|
+
scale.domain[0] instanceof Date
|
|
125
|
+
? [new Date(window[0]), new Date(window[1])]
|
|
126
|
+
: [window[0], window[1]];
|
|
127
|
+
|
|
128
|
+
const toNumber = (value: unknown): number | null => {
|
|
129
|
+
const number = value instanceof Date ? value.getTime() : value;
|
|
130
|
+
return typeof number === 'number' && Number.isFinite(number) ? number : null;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* `definition` with each windowed axis's `viewport` set. Only axes the
|
|
135
|
+
* definition configures (`scales.x` / `scales.y`) can carry a viewport, which
|
|
136
|
+
* every zoomable preset does. A responsive definition (`chart: (size) => …`)
|
|
137
|
+
* gets the viewport applied to the spec its builder returns.
|
|
138
|
+
*/
|
|
139
|
+
export const withViewport = <
|
|
140
|
+
TDefinition extends ChartDefinition<any, any, any>,
|
|
141
|
+
>(
|
|
142
|
+
definition: TDefinition,
|
|
143
|
+
viewport: ChartViewport,
|
|
144
|
+
): TDefinition => {
|
|
145
|
+
if (!viewport.x && !viewport.y) return definition;
|
|
146
|
+
if ('chart' in definition && typeof definition.chart === 'function') {
|
|
147
|
+
const build = definition.chart as (context: unknown) => TDefinition;
|
|
148
|
+
return {
|
|
149
|
+
...definition,
|
|
150
|
+
chart: (context: unknown) => withViewport(build(context), viewport),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const scales = (definition as { scales?: Record<string, object> }).scales;
|
|
154
|
+
if (!scales) return definition;
|
|
155
|
+
const next: Record<string, object> = { ...scales };
|
|
156
|
+
for (const axis of ['x', 'y'] as const) {
|
|
157
|
+
const domain = viewport[axis];
|
|
158
|
+
const options = scales[axis];
|
|
159
|
+
if (domain && options) next[axis] = { ...options, viewport: { domain } };
|
|
160
|
+
}
|
|
161
|
+
return { ...definition, scales: next };
|
|
162
|
+
};
|