@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,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
+ };