@blotch/dolan 22.2.2 → 22.2.4

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 (259) hide show
  1. package/dist/commands/add.d.ts +6 -2
  2. package/dist/commands/add.js +41 -11
  3. package/dist/commands/add.js.map +1 -1
  4. package/dist/commands/add.test.js +162 -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 +228 -34
  12. package/dist/commands/doctor.js.map +1 -1
  13. package/dist/commands/doctor.test.js +367 -10
  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 +169 -40
  23. package/dist/commands/init.js.map +1 -1
  24. package/dist/commands/init.test.js +392 -27
  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/theme.js +1 -1
  46. package/dist/commands/theme.js.map +1 -1
  47. package/dist/commands/update.d.ts +11 -0
  48. package/dist/commands/update.js +174 -58
  49. package/dist/commands/update.js.map +1 -1
  50. package/dist/commands/update.test.js +587 -33
  51. package/dist/commands/update.test.js.map +1 -1
  52. package/dist/index.js +7 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/lockfile.d.ts +7 -0
  55. package/dist/lockfile.js +36 -0
  56. package/dist/lockfile.js.map +1 -1
  57. package/dist/lockfile.test.d.ts +1 -0
  58. package/dist/lockfile.test.js +25 -0
  59. package/dist/lockfile.test.js.map +1 -0
  60. package/dist/registry.d.ts +14 -0
  61. package/dist/registry.js +78 -24
  62. package/dist/registry.js.map +1 -1
  63. package/dist/registry.test.js +119 -38
  64. package/dist/registry.test.js.map +1 -1
  65. package/dist/test-utils.d.ts +5 -0
  66. package/dist/test-utils.js +6 -1
  67. package/dist/test-utils.js.map +1 -1
  68. package/dist/testing/fire-lynx-event.d.ts +14 -0
  69. package/dist/testing/fire-lynx-event.js +19 -0
  70. package/dist/testing/fire-lynx-event.js.map +1 -0
  71. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  72. package/dist/testing/fire-lynx-event.test.js +44 -0
  73. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  74. package/dist/testing/lynx-main-thread.d.ts +22 -0
  75. package/dist/testing/lynx-main-thread.js +11 -0
  76. package/dist/testing/lynx-main-thread.js.map +1 -0
  77. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  78. package/dist/testing/lynx-main-thread.test.js +24 -0
  79. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  80. package/dist/testing/set-input-signal.d.ts +14 -0
  81. package/dist/testing/set-input-signal.js +21 -0
  82. package/dist/testing/set-input-signal.js.map +1 -0
  83. package/dist/testing/set-input-signal.test.d.ts +1 -0
  84. package/dist/testing/set-input-signal.test.js +20 -0
  85. package/dist/testing/set-input-signal.test.js.map +1 -0
  86. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  87. package/dist/ui/components/accordion/accordion.ts +19 -14
  88. package/dist/ui/components/action-sheet/action-sheet.test.ts +283 -151
  89. package/dist/ui/components/action-sheet/action-sheet.ts +77 -43
  90. package/dist/ui/components/alert/alert.test.ts +67 -19
  91. package/dist/ui/components/alert/alert.ts +14 -16
  92. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +208 -94
  93. package/dist/ui/components/alert-dialog/alert-dialog.ts +75 -38
  94. package/dist/ui/components/area-chart/area-chart.test.ts +134 -119
  95. package/dist/ui/components/area-chart/area-chart.ts +100 -315
  96. package/dist/ui/components/area-chart/index.ts +2 -3
  97. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  98. package/dist/ui/components/badge/badge.test.ts +57 -23
  99. package/dist/ui/components/badge/badge.ts +12 -14
  100. package/dist/ui/components/bar-chart/bar-chart.test.ts +172 -45
  101. package/dist/ui/components/bar-chart/bar-chart.ts +140 -294
  102. package/dist/ui/components/bar-chart/index.ts +3 -3
  103. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +386 -128
  104. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +170 -53
  105. package/dist/ui/components/button/button.test.ts +611 -2
  106. package/dist/ui/components/button/button.ts +434 -49
  107. package/dist/ui/components/button/index.ts +2 -0
  108. package/dist/ui/components/button-group/button-group.test.ts +484 -2
  109. package/dist/ui/components/button-group/button-group.ts +380 -68
  110. package/dist/ui/components/button-group/index.ts +3 -0
  111. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +183 -58
  112. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +150 -323
  113. package/dist/ui/components/candlestick-chart/index.ts +3 -4
  114. package/dist/ui/components/card/card.test.ts +82 -62
  115. package/dist/ui/components/chart/chart-focus.test.ts +132 -0
  116. package/dist/ui/components/chart/chart-focus.ts +115 -0
  117. package/dist/ui/components/chart/chart-presets.test.ts +335 -0
  118. package/dist/ui/components/chart/chart-presets.ts +278 -0
  119. package/dist/ui/components/chart/chart-scene.test.ts +589 -0
  120. package/dist/ui/components/chart/chart-scene.ts +483 -0
  121. package/dist/ui/components/chart/chart-theme.test.ts +85 -0
  122. package/dist/ui/components/chart/chart-theme.ts +210 -0
  123. package/dist/ui/components/chart/chart-zoom.test.ts +159 -0
  124. package/dist/ui/components/chart/chart-zoom.ts +162 -0
  125. package/dist/ui/components/chart/chart.test.ts +1017 -0
  126. package/dist/ui/components/chart/chart.ts +934 -0
  127. package/dist/ui/components/chart/index.ts +24 -0
  128. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  129. package/dist/ui/components/checkbox/checkbox.ts +23 -23
  130. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  131. package/dist/ui/components/collapsible/collapsible.ts +19 -14
  132. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  133. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  134. package/dist/ui/components/context-menu/index.ts +1 -0
  135. package/dist/ui/components/dialog/dialog.test.ts +213 -108
  136. package/dist/ui/components/dialog/dialog.ts +72 -35
  137. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  138. package/dist/ui/components/dock/dock-scroll.ts +133 -0
  139. package/dist/ui/components/dock/dock.test.ts +749 -0
  140. package/dist/ui/components/dock/dock.ts +960 -0
  141. package/dist/ui/components/dock/index.ts +2 -0
  142. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  143. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  144. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  145. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  146. package/dist/ui/components/glass/glass-design.ts +119 -0
  147. package/dist/ui/components/glass/glass.test.ts +338 -0
  148. package/dist/ui/components/glass/glass.ts +360 -0
  149. package/dist/ui/components/glass/index.ts +18 -0
  150. package/dist/ui/components/icon/icon-theme.test.ts +169 -0
  151. package/dist/ui/components/icon/icon-theme.ts +227 -0
  152. package/dist/ui/components/icon/icon.test.ts +85 -25
  153. package/dist/ui/components/icon/icon.ts +38 -21
  154. package/dist/ui/components/input/input.test.ts +178 -101
  155. package/dist/ui/components/input/input.ts +15 -12
  156. package/dist/ui/components/label/label.test.ts +11 -24
  157. package/dist/ui/components/line-chart/index.ts +2 -3
  158. package/dist/ui/components/line-chart/line-chart.test.ts +149 -56
  159. package/dist/ui/components/line-chart/line-chart.ts +83 -241
  160. package/dist/ui/components/list/list.test.ts +119 -21
  161. package/dist/ui/components/list/list.ts +21 -15
  162. package/dist/ui/components/menu/index.ts +25 -0
  163. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  164. package/dist/ui/components/menu/menu-placement.ts +159 -0
  165. package/dist/ui/components/menu/menu-popup.test.ts +632 -0
  166. package/dist/ui/components/menu/menu-popup.ts +544 -0
  167. package/dist/ui/components/menu/menu.test.ts +429 -0
  168. package/dist/ui/components/menu/menu.ts +415 -0
  169. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +435 -191
  170. package/dist/ui/components/nav-drawer/nav-drawer.ts +127 -56
  171. package/dist/ui/components/pagination/pagination.test.ts +177 -65
  172. package/dist/ui/components/pagination/pagination.ts +55 -8
  173. package/dist/ui/components/progress/progress.test.ts +102 -46
  174. package/dist/ui/components/progress/progress.ts +52 -54
  175. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  176. package/dist/ui/components/radio-group/radio-group.ts +24 -24
  177. package/dist/ui/components/scatter-chart/index.ts +2 -3
  178. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +131 -26
  179. package/dist/ui/components/scatter-chart/scatter-chart.ts +104 -250
  180. package/dist/ui/components/scroll-area/scroll-area.test.ts +95 -24
  181. package/dist/ui/components/scroll-area/scroll-area.ts +8 -8
  182. package/dist/ui/components/select/select.test.ts +138 -78
  183. package/dist/ui/components/select/select.ts +17 -11
  184. package/dist/ui/components/separator/separator.test.ts +18 -17
  185. package/dist/ui/components/sheet/sheet.test.ts +64 -11
  186. package/dist/ui/components/sheet/sheet.ts +9 -1
  187. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  188. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  189. package/dist/ui/components/spinner/spinner.test.ts +67 -22
  190. package/dist/ui/components/spinner/spinner.ts +48 -41
  191. package/dist/ui/components/switch/switch.test.ts +116 -46
  192. package/dist/ui/components/switch/switch.ts +8 -13
  193. package/dist/ui/components/tab-bar/index.ts +1 -0
  194. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  195. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  196. package/dist/ui/components/tabs/tabs.test.ts +215 -157
  197. package/dist/ui/components/tabs/tabs.ts +110 -118
  198. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  199. package/dist/ui/components/textarea/textarea.ts +14 -12
  200. package/dist/ui/components/toast/toast.test.ts +436 -315
  201. package/dist/ui/components/toast/toast.ts +62 -55
  202. package/dist/ui/components/toggle/toggle.test.ts +247 -140
  203. package/dist/ui/theme/chart-colors.test.ts +35 -0
  204. package/dist/ui/theme/dark.css +46 -2
  205. package/dist/ui/theme/default.css +63 -7
  206. package/dist/ui/theme/icon-colors.test.ts +35 -0
  207. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  208. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  209. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  210. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  211. package/dist/ui/theme/tailwind-plugin.ts +98 -25
  212. package/dist/ui/theme/tailwind.css +113 -0
  213. package/dist/ui/utils/animate.test.ts +13 -1
  214. package/dist/utils/analyze.d.ts +14 -0
  215. package/dist/utils/analyze.js +15 -0
  216. package/dist/utils/analyze.js.map +1 -1
  217. package/dist/utils/analyze.test.js +14 -1
  218. package/dist/utils/analyze.test.js.map +1 -1
  219. package/dist/utils/installed.d.ts +29 -0
  220. package/dist/utils/installed.js +43 -0
  221. package/dist/utils/installed.js.map +1 -0
  222. package/dist/utils/installed.test.d.ts +1 -0
  223. package/dist/utils/installed.test.js +57 -0
  224. package/dist/utils/installed.test.js.map +1 -0
  225. package/dist/utils/interactive.d.ts +9 -0
  226. package/dist/utils/interactive.js +10 -0
  227. package/dist/utils/interactive.js.map +1 -0
  228. package/dist/utils/interactive.test.d.ts +1 -0
  229. package/dist/utils/interactive.test.js +24 -0
  230. package/dist/utils/interactive.test.js.map +1 -0
  231. package/dist/utils/packages.d.ts +36 -0
  232. package/dist/utils/packages.js +70 -0
  233. package/dist/utils/packages.js.map +1 -0
  234. package/dist/utils/packages.test.d.ts +1 -0
  235. package/dist/utils/packages.test.js +89 -0
  236. package/dist/utils/packages.test.js.map +1 -0
  237. package/dist/utils/resolve-paths.d.ts +17 -1
  238. package/dist/utils/resolve-paths.js +21 -3
  239. package/dist/utils/resolve-paths.js.map +1 -1
  240. package/dist/utils/resolve-paths.test.js +38 -2
  241. package/dist/utils/resolve-paths.test.js.map +1 -1
  242. package/dist/utils/tailwind.d.ts +25 -0
  243. package/dist/utils/tailwind.js +51 -0
  244. package/dist/utils/tailwind.js.map +1 -0
  245. package/dist/utils/tailwind.test.d.ts +1 -0
  246. package/dist/utils/tailwind.test.js +71 -0
  247. package/dist/utils/tailwind.test.js.map +1 -0
  248. package/package.json +13 -1
  249. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -121
  250. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -174
  251. package/dist/ui/components/button/button-render.test.ts +0 -113
  252. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  253. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -174
  254. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +0 -188
  255. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +0 -525
  256. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +0 -1140
  257. package/dist/ui/components/cartesian-chart/index.ts +0 -18
  258. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -121
  259. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -157
@@ -0,0 +1,934 @@
1
+ import { NgTemplateOutlet } from '@angular/common';
2
+ import {
3
+ Component,
4
+ Directive,
5
+ TemplateRef,
6
+ ViewEncapsulation,
7
+ computed,
8
+ contentChild,
9
+ effect,
10
+ inject,
11
+ input,
12
+ output,
13
+ signal,
14
+ untracked,
15
+ } from '@angular/core';
16
+ import {
17
+ LYNX_ELEMENTS,
18
+ LynxGestureDetector,
19
+ LynxTheme,
20
+ PanGesture,
21
+ type PanGestureEvent,
22
+ } from '@blotch/angular-lynx';
23
+ import {
24
+ resolveFocusPresentation,
25
+ resolveMarkStateScene,
26
+ } from '@tanstack/charts/cursor/host';
27
+ import { createChartRuntime } from '@tanstack/charts/runtime';
28
+ import {
29
+ createChartTooltipContent,
30
+ orderChartTooltipPoints,
31
+ resolveChartTooltipAnchor,
32
+ resolveChartTooltipPlacement,
33
+ } from '@tanstack/charts/tooltip/model';
34
+ import type {
35
+ ChartDefinition,
36
+ ChartFocusSource,
37
+ ChartFocusState,
38
+ ChartPoint,
39
+ ChartScene,
40
+ ChartTextMeasurer,
41
+ ChartTooltipContent,
42
+ ChartTooltipExtensionToken,
43
+ ChartTooltipOptions,
44
+ ChartTooltipPosition,
45
+ ChartValue,
46
+ } from '@tanstack/charts/types';
47
+
48
+ import { cn } from '../../utils/cn';
49
+ import { adjacentFocusPoint, createChartFocusModel } from './chart-focus';
50
+ import {
51
+ type ChartLabel,
52
+ type LynxGetTextInfo,
53
+ chartLabelStyle,
54
+ collectChartLabels,
55
+ createLynxTextMeasurer,
56
+ renderChartSvg,
57
+ } from './chart-scene';
58
+ import {
59
+ type ChartColorSchemeOption,
60
+ UI_CHART_THEME,
61
+ resolveChartPaint,
62
+ } from './chart-theme';
63
+ import {
64
+ type ChartViewport,
65
+ type ChartWindow,
66
+ type ChartZoomAxes,
67
+ axisContentWindow,
68
+ axisVisibleWindow,
69
+ isFullWindow,
70
+ panWindow,
71
+ toViewportDomain,
72
+ withViewport,
73
+ zoomWindow,
74
+ } from './chart-zoom';
75
+
76
+ /**
77
+ * The tooltip extension for `<ui-chart>`: put it in a definition the way
78
+ * TanStack's web tooltip goes in, alone or with options.
79
+ *
80
+ * ```ts
81
+ * defineChart({ marks, tooltip })
82
+ * defineChart({ marks, tooltip: { use: tooltip, sticky: false } })
83
+ * ```
84
+ *
85
+ * It's a marker only: `<ui-chart>` reads the options and draws the tooltip as
86
+ * native views. TanStack's `@tanstack/charts/tooltip` is the browser's DOM
87
+ * tooltip; a definition shared with a web app can keep it, and `<ui-chart>`
88
+ * still draws its own.
89
+ */
90
+ export const tooltip: ChartTooltipExtensionToken<'lynx'> = {
91
+ id: 'lynx-tooltip',
92
+ create: () => undefined,
93
+ __chartExtensionType: 'tooltip',
94
+ __chartTooltipHost: 'lynx',
95
+ };
96
+
97
+ /** What a custom tooltip template receives (as `let-tooltip`). */
98
+ export type UiChartTooltipContext = {
99
+ /** Points in the tooltip's display order. */
100
+ readonly points: readonly ChartPoint[];
101
+ /** The touched point, whatever the row order. */
102
+ readonly primaryPoint: ChartPoint;
103
+ /** The default rows and title, already formatted. */
104
+ readonly content: ChartTooltipContent | string;
105
+ readonly pinned: boolean;
106
+ /** Clears focus and unpins, for a close button in a pinned tooltip. */
107
+ readonly dismiss: () => void;
108
+ };
109
+
110
+ /**
111
+ * Replaces the default tooltip body:
112
+ *
113
+ * ```html
114
+ * <ui-chart [definition]="definition">
115
+ * <ng-template uiChartTooltip let-tooltip>
116
+ * <text>{{ tooltip.primaryPoint.yValue }}</text>
117
+ * </ng-template>
118
+ * </ui-chart>
119
+ * ```
120
+ */
121
+ @Directive({ selector: 'ng-template[uiChartTooltip]' })
122
+ export class UiChartTooltip {
123
+ readonly template =
124
+ inject<TemplateRef<{ $implicit: UiChartTooltipContext }>>(TemplateRef);
125
+
126
+ static ngTemplateContextGuard(
127
+ _directive: UiChartTooltip,
128
+ _context: unknown,
129
+ ): _context is { $implicit: UiChartTooltipContext } {
130
+ return true;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * `UiChartTooltip`, for `contentChild()`. A call rather than the class itself:
136
+ * for a class, Angular's test build keeps an arrow function that nothing calls,
137
+ * which an app's coverage report would show as uncovered.
138
+ */
139
+ const tooltipDirectiveType = () => UiChartTooltip;
140
+
141
+ /** Default height, like the browser's 150px replaced-element default, but roomier. */
142
+ const DEFAULT_HEIGHT = 200;
143
+ /** A touch that moves less than this is a tap, not a drag. */
144
+ const TAP_SLOP = 8;
145
+ /**
146
+ * Empty layers get a blank drawing rather than `content=""`: Lynx for Web keeps
147
+ * showing the last image when the content is cleared.
148
+ */
149
+ const EMPTY_SVG =
150
+ '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1" viewBox="0 0 1 1"></svg>';
151
+ const NEXT_POINT = 'Next point';
152
+ const PREVIOUS_POINT = 'Previous point';
153
+ const EMPTY_POINTS: readonly ChartPoint[] = [];
154
+
155
+ let nextChartId = 0;
156
+
157
+ /**
158
+ * Reads `lynx.getTextInfo` where this thread has it (native and web both do).
159
+ */
160
+ const lynxTextMeasurer = (): ChartTextMeasurer | undefined => {
161
+ const lynxGlobal = (
162
+ globalThis as { lynx?: { getTextInfo?: LynxGetTextInfo } }
163
+ ).lynx;
164
+ const getTextInfo = lynxGlobal?.getTextInfo;
165
+ return typeof getTextInfo === 'function'
166
+ ? createLynxTextMeasurer(getTextInfo.bind(lynxGlobal))
167
+ : undefined;
168
+ };
169
+
170
+ type TouchPoint = {
171
+ readonly x: number;
172
+ readonly y: number;
173
+ };
174
+
175
+ /**
176
+ * Renders a TanStack Charts definition with Lynx elements: build it with
177
+ * `defineChart` from `@tanstack/charts` and any of its marks, scales and
178
+ * transforms, then pass it in. The line, area, bar, scatter and candlestick
179
+ * charts in this library are presets built the same way.
180
+ *
181
+ * ```ts
182
+ * readonly definition = defineChart({
183
+ * marks: [barY(revenue, { x: 'month', y: 'value' })],
184
+ * scales: { x: { scale: () => scaleBand().padding(0.2) }, y: { scale: scaleLinear, nice: true, grid: true } },
185
+ * tooltip,
186
+ * });
187
+ * ```
188
+ * ```html
189
+ * <ui-chart [definition]="definition" label="Monthly revenue" />
190
+ * ```
191
+ *
192
+ * ## How it draws
193
+ * TanStack compiles the definition into a scene of plain shapes. The shapes are
194
+ * serialized into Lynx `<svg>` elements, one each for the focus highlight
195
+ * under the marks, the marks, and the highlight over them, and the labels are
196
+ * native `<text>` (see chart-scene.ts for why). A touch is mapped to data
197
+ * points by TanStack's focus strategy; the tooltip is native views.
198
+ *
199
+ * ## Sizing
200
+ * Without `width`/`height` the chart fills its container's width and is 200px
201
+ * tall (override with a height class), and re-renders when its layout changes.
202
+ *
203
+ * ## Pan and zoom
204
+ * `zoomable` adds drag-to-pan and +/−/reset controls on the continuous axes the
205
+ * definition configures (`scales.x`, `scales.y`), through TanStack's scale
206
+ * `viewport`. Pinch isn't offered: Lynx has no native pinch handler yet.
207
+ */
208
+ @Component({
209
+ selector: 'ui-chart',
210
+ imports: [LYNX_ELEMENTS, LynxGestureDetector, NgTemplateOutlet],
211
+ encapsulation: ViewEncapsulation.None,
212
+ template: `
213
+ <view
214
+ [class]="rootClass()"
215
+ [style]="rootStyle()"
216
+ [lynxGesture]="zoomable() ? panGesture : noGesture"
217
+ [accessibility-element]="true"
218
+ [accessibility-label]="label()"
219
+ [accessibility-value]="accessibilityValue()"
220
+ [accessibility-actions]="accessibilityActions"
221
+ (bindaccessibilityaction)="onAccessibilityAction($event)"
222
+ (bindlayoutchange)="onLayout($event)"
223
+ (bindtouchstart)="onTouchStart($event)"
224
+ (bindtouchmove)="onTouchMove($event)"
225
+ (bindtouchend)="onTouchEnd($event)"
226
+ (bindtouchcancel)="onTouchCancel()"
227
+ >
228
+ @if (scene()) {
229
+ <!--
230
+ The layers ignore touches so every touch targets this root view, which
231
+ keeps the touch's x/y relative to the chart. They always exist (an empty
232
+ layer draws a blank SVG) so focusing never adds or removes elements.
233
+ -->
234
+ <svg
235
+ [user-interaction-enabled]="false"
236
+ [style]="layerStyle()"
237
+ [attr.content]="underSvg()"
238
+ />
239
+ <svg
240
+ [user-interaction-enabled]="false"
241
+ [style]="layerStyle()"
242
+ [attr.content]="marksSvg()"
243
+ />
244
+ <svg
245
+ [user-interaction-enabled]="false"
246
+ [style]="layerStyle()"
247
+ [attr.content]="overSvg()"
248
+ />
249
+ @for (label of labelSlots(); track $index) {
250
+ <text [user-interaction-enabled]="false" [style]="label.style">{{
251
+ label.text
252
+ }}</text>
253
+ }
254
+ @if (tooltipView(); as view) {
255
+ <!--
256
+ A pinned tooltip takes touches, so a custom template can hold a
257
+ button (calling dismiss); the tap still reaches the chart, which
258
+ unpins. While scrubbing it ignores them, so the touch stays on the
259
+ chart and its coordinates stay chart-relative.
260
+ -->
261
+ <view
262
+ [user-interaction-enabled]="view.context.pinned"
263
+ class="flex-col gap-1 rounded-md border border-border bg-card px-2 py-1.5 flex"
264
+ [style]="view.style"
265
+ (bindlayoutchange)="onTooltipLayout($event)"
266
+ >
267
+ @if (tooltipTemplate(); as custom) {
268
+ <ng-container
269
+ *ngTemplateOutlet="
270
+ custom.template;
271
+ context: { $implicit: view.context }
272
+ "
273
+ />
274
+ } @else {
275
+ @if (view.title) {
276
+ <view class="flex-row items-center gap-1.5 flex">
277
+ @if (view.titleColor) {
278
+ <view
279
+ class="h-2 w-2 rounded-sm"
280
+ [style]="'background-color: ' + view.titleColor"
281
+ ></view>
282
+ }
283
+ <text class="text-xs font-medium text-card-foreground">{{
284
+ view.title
285
+ }}</text>
286
+ </view>
287
+ }
288
+ @for (row of view.rows; track $index) {
289
+ <view class="flex-row items-center gap-1.5 flex">
290
+ @if (row.color) {
291
+ <view
292
+ class="h-2 w-2 rounded-sm"
293
+ [style]="'background-color: ' + row.color"
294
+ ></view>
295
+ }
296
+ <text class="text-xs text-muted-foreground">{{
297
+ row.label
298
+ }}</text>
299
+ <text class="text-xs font-medium text-card-foreground">{{
300
+ row.value
301
+ }}</text>
302
+ </view>
303
+ }
304
+ }
305
+ </view>
306
+ }
307
+ @if (zoomable() && showZoomControls()) {
308
+ <view class="flex-col gap-1 flex absolute right-1.5 top-1.5">
309
+ <view [class]="zoomButtonClass" (bindtap)="zoomIn()">
310
+ <text class="text-base font-medium leading-none text-foreground"
311
+ >+</text
312
+ >
313
+ </view>
314
+ <view [class]="zoomButtonClass" (bindtap)="zoomOut()">
315
+ <text class="text-base font-medium leading-none text-foreground"
316
+ >−</text
317
+ >
318
+ </view>
319
+ <view [class]="zoomButtonClass" (bindtap)="resetZoom()">
320
+ <text class="text-[9px] font-medium leading-none text-foreground"
321
+ >1:1</text
322
+ >
323
+ </view>
324
+ </view>
325
+ }
326
+ }
327
+ </view>
328
+ `,
329
+ })
330
+ export class UiChart {
331
+ /** The chart to draw: what `defineChart` from `@tanstack/charts` returns. */
332
+ readonly definition = input.required<ChartDefinition<any, any, any>>();
333
+ /** Fixed width in px. Omit to fill the container. */
334
+ readonly width = input<number>();
335
+ /** Fixed height in px. Omit for 200px, or set one with a height class. */
336
+ readonly height = input<number>();
337
+ /** What a screen reader announces for the chart. */
338
+ readonly label = input('Chart');
339
+ /**
340
+ * Light or dark colors, or `'system'` to follow the device appearance.
341
+ * Defaults to the chart theme's setting (`provideChartTheme`), which is
342
+ * light unless the app says otherwise; see `ChartColorSchemeOption`.
343
+ */
344
+ readonly colorScheme = input<Exclude<ChartColorSchemeOption, Function>>();
345
+ readonly zoomable = input(false);
346
+ readonly zoomAxes = input<ChartZoomAxes>('xy');
347
+ readonly showZoomControls = input(true);
348
+ readonly minZoom = input(1);
349
+ readonly maxZoom = input(8);
350
+ /** Factor each zoom button press zooms by. */
351
+ readonly zoomStep = input(1.4);
352
+ readonly userClass = input<string>('', { alias: 'class' });
353
+
354
+ /**
355
+ * The focused point (the first of the focused group) each time focus moves,
356
+ * as a touch scrubs or a screen reader steps; `null` when it clears.
357
+ */
358
+ readonly focusChange = output<ChartPoint | null>();
359
+ /** The point a tap landed on, or `null` for a tap on empty space. */
360
+ readonly pointSelect = output<ChartPoint | null>();
361
+
362
+ protected readonly tooltipTemplate = contentChild(tooltipDirectiveType());
363
+
364
+ readonly #theme = inject(UI_CHART_THEME);
365
+ readonly #appearance = inject(LynxTheme);
366
+ readonly #idPrefix = `chart${nextChartId++}`;
367
+ readonly #runtime = createChartRuntime({ defaultTheme: this.#theme.chart });
368
+ readonly #measureText = lynxTextMeasurer();
369
+
370
+ readonly #layout = signal<{ width: number; height: number } | null>(null);
371
+ readonly #viewport = signal<ChartViewport>({});
372
+ readonly #focused = signal<readonly ChartPoint[]>(EMPTY_POINTS);
373
+ readonly #pinned = signal(false);
374
+ readonly #pointer = signal<ChartTooltipPosition | null>(null);
375
+ readonly #focusSource = signal<ChartFocusSource>('pointer');
376
+ readonly #tooltipSize = signal<{ width: number; height: number } | null>(
377
+ null,
378
+ );
379
+ /** True from a pan's first frame until just after it ends. */
380
+ readonly #gesturing = signal(false);
381
+ #frozenLabelCount = 0;
382
+ #touchStart: TouchPoint | null = null;
383
+
384
+ protected readonly rootClass = computed(() =>
385
+ cn('h-[200px] w-full flex-shrink-0 relative', this.userClass()),
386
+ );
387
+
388
+ protected readonly rootStyle = computed(() => {
389
+ const width = this.width();
390
+ const height = this.height();
391
+ return (
392
+ (width === undefined ? '' : `width: ${width}px; `) +
393
+ (height === undefined ? '' : `height: ${height}px;`)
394
+ );
395
+ });
396
+
397
+ /** The size the scene is laid out at: the inputs, else the measured box. */
398
+ readonly #size = computed(() => {
399
+ const layout = this.#layout();
400
+ const width = this.width() ?? layout?.width;
401
+ const height = this.height() ?? layout?.height ?? DEFAULT_HEIGHT;
402
+ return width && width > 0 && height > 0 ? { width, height } : null;
403
+ });
404
+
405
+ /** The compiled scene, with any zoom window applied. */
406
+ readonly scene = computed<ChartScene<any, any, any> | null>(() => {
407
+ const size = this.#size();
408
+ if (!size) return null;
409
+ const definition = withViewport(this.definition(), this.#viewport());
410
+ return this.#runtime.render(definition, size, {
411
+ measureText: this.#measureText,
412
+ });
413
+ });
414
+
415
+ /**
416
+ * The scene, for what the template draws inside `@if (scene())`: those
417
+ * bindings, and the values below derived for them, are only read while there
418
+ * is one, so they need no fallback for a chart without a size.
419
+ */
420
+ #drawnScene(): ChartScene<any, any, any> {
421
+ return this.scene()!;
422
+ }
423
+
424
+ readonly #focusModel = computed(() => {
425
+ const scene = this.scene();
426
+ return scene ? createChartFocusModel(scene, this.definition()) : null;
427
+ });
428
+
429
+ readonly #focusState = computed<ChartFocusState | null>(() => {
430
+ const group = this.#focused();
431
+ const primary = group[0];
432
+ return primary
433
+ ? {
434
+ primary,
435
+ group,
436
+ source: this.#focusSource(),
437
+ pinned: this.#pinned(),
438
+ }
439
+ : null;
440
+ });
441
+
442
+ /** The scene with mark states (e.g. dim the unfocused bars) resolved. */
443
+ readonly #presented = computed(
444
+ () =>
445
+ resolveMarkStateScene(
446
+ this.#drawnScene(),
447
+ this.#focusState(),
448
+ this.#pointer(),
449
+ ).scene,
450
+ );
451
+
452
+ readonly #focusPresentation = computed(() =>
453
+ resolveFocusPresentation(
454
+ this.#presented(),
455
+ this.#focusState(),
456
+ this.#pointer(),
457
+ null,
458
+ ),
459
+ );
460
+
461
+ readonly #colors = computed(() => {
462
+ const option = this.colorScheme() ?? this.#theme.colorScheme ?? 'light';
463
+ const scheme =
464
+ option === 'system'
465
+ ? this.#appearance.isDarkMode()
466
+ ? 'dark'
467
+ : 'light'
468
+ : typeof option === 'function'
469
+ ? option()
470
+ : option;
471
+ return scheme === 'dark' ? this.#theme.dark : this.#theme.light;
472
+ });
473
+
474
+ readonly #svgOptions = computed(() => {
475
+ const scene = this.#drawnScene();
476
+ const colors = this.#colors();
477
+ return {
478
+ width: scene.width,
479
+ height: scene.height,
480
+ gradients: scene.gradients,
481
+ idPrefix: this.#idPrefix,
482
+ resolvePaint: (paint: string) => resolveChartPaint(paint, colors),
483
+ };
484
+ });
485
+
486
+ protected readonly underSvg = computed(
487
+ () =>
488
+ renderChartSvg(this.#focusPresentation().under, this.#svgOptions()) ||
489
+ EMPTY_SVG,
490
+ );
491
+ protected readonly marksSvg = computed(
492
+ () =>
493
+ renderChartSvg(this.#presented().nodes, this.#svgOptions()) || EMPTY_SVG,
494
+ );
495
+ protected readonly overSvg = computed(
496
+ () =>
497
+ renderChartSvg(this.#focusPresentation().over, this.#svgOptions()) ||
498
+ EMPTY_SVG,
499
+ );
500
+
501
+ protected readonly layerStyle = computed(() => {
502
+ const scene = this.#drawnScene();
503
+ return `position: absolute; left: 0px; top: 0px; width: ${scene.width}px; height: ${scene.height}px;`;
504
+ });
505
+
506
+ readonly #labels = computed<readonly ChartLabel[]>(() => {
507
+ const presentation = this.#focusPresentation();
508
+ return collectChartLabels(
509
+ [...presentation.under, ...this.#presented().nodes, ...presentation.over],
510
+ {
511
+ defaultColor: this.#theme.chart.foreground ?? 'var(--foreground)',
512
+ focus: this.#focusState(),
513
+ pointer: this.#pointer(),
514
+ },
515
+ );
516
+ });
517
+
518
+ /**
519
+ * The labels as a pool of `<text>` slots. Outside a pan it's exactly the
520
+ * labels. During one, its length is frozen at the start: TanStack picks nicer
521
+ * ticks as the window moves, so the label count changes, and adding or
522
+ * removing elements from a gesture callback can crash Lynx natively. Extra
523
+ * labels wait until the pan ends; spare slots are hidden.
524
+ */
525
+ protected readonly labelSlots = computed(() => {
526
+ const labels = this.#labels();
527
+ const count = this.#gesturing() ? this.#frozenLabelCount : labels.length;
528
+ return Array.from({ length: count }, (_, index) => {
529
+ const label = labels[index];
530
+ return label
531
+ ? { text: label.text, style: chartLabelStyle(label) }
532
+ : { text: '', style: 'position: absolute; display: none;' };
533
+ });
534
+ });
535
+
536
+ readonly #tooltipOptions = computed<ChartTooltipOptions | null>(() => {
537
+ const input = this.definition().tooltip;
538
+ if (!input) return null;
539
+ return 'create' in input ? {} : (input as ChartTooltipOptions);
540
+ });
541
+
542
+ /** Tooltips pin on tap unless the definition says `sticky: false`. */
543
+ readonly #sticky = computed(() => {
544
+ const options = this.#tooltipOptions();
545
+ return options !== null && options.sticky !== false;
546
+ });
547
+
548
+ protected readonly tooltipView = computed(() => {
549
+ const scene = this.scene();
550
+ const focus = this.#focusState();
551
+ // Hidden while panning: its rows could change mid-gesture, which would
552
+ // add or remove elements in a gesture callback.
553
+ if (!scene || !focus || this.#gesturing()) return null;
554
+ const options = this.#tooltipOptions();
555
+ if (!options) return null;
556
+ if (options.visibility === 'pinned' && !focus.pinned) return null;
557
+
558
+ const points = orderChartTooltipPoints(focus.group, scene, options.sort);
559
+ const content = createChartTooltipContent(
560
+ points,
561
+ scene,
562
+ focus.pinned,
563
+ options,
564
+ focus.primary,
565
+ );
566
+ const anchor = resolveChartTooltipAnchor(
567
+ focus.primary,
568
+ points,
569
+ scene,
570
+ this.#pointer(),
571
+ options,
572
+ focus,
573
+ );
574
+ // Until the tooltip has been laid out once its size is unknown, so it's
575
+ // placed with a guess and kept invisible for that one frame. The layout
576
+ // event then sets the size, which re-places it.
577
+ const size = this.#tooltipSize();
578
+ const placed = resolveChartTooltipPlacement(
579
+ anchor,
580
+ size ?? { width: 0, height: 0 },
581
+ { left: 0, top: 0, right: scene.width, bottom: scene.height },
582
+ options.placement,
583
+ options.offset,
584
+ );
585
+ const context: UiChartTooltipContext = {
586
+ points,
587
+ primaryPoint: focus.primary,
588
+ content,
589
+ pinned: focus.pinned,
590
+ dismiss: () => this.dismiss(),
591
+ };
592
+ const structured = typeof content === 'string' ? null : content;
593
+ return {
594
+ style:
595
+ `position: absolute; left: ${placed.left}px; top: ${placed.top}px;` +
596
+ (size ? '' : ' opacity: 0;'),
597
+ context,
598
+ title: structured ? (structured.title ?? null) : content,
599
+ titleColor: structured?.color,
600
+ rows: structured?.rows ?? [],
601
+ };
602
+ });
603
+
604
+ protected readonly accessibilityValue = computed(() => {
605
+ const point = this.#focused()[0];
606
+ return point ? describePoint(point) : '';
607
+ });
608
+ protected readonly accessibilityActions = [NEXT_POINT, PREVIOUS_POINT];
609
+
610
+ protected readonly zoomButtonClass =
611
+ 'flex h-7 w-7 items-center justify-center rounded-md border border-border bg-card';
612
+
613
+ /**
614
+ * Keeps the focus on the same datum when a new scene (data, size or zoom
615
+ * changed) brings new point objects, as TanStack's hosts do. A field rather
616
+ * than a constructor: Angular's test build keeps a function for a
617
+ * constructor that nothing calls, which an app's coverage report would show
618
+ * as uncovered.
619
+ */
620
+ protected readonly restoringFocus = effect(() => {
621
+ const model = this.#focusModel();
622
+ const previous = untracked(this.#focused);
623
+ if (!model || previous.length === 0) return;
624
+ const restored = model.restore(previous[0]!);
625
+ this.#focused.set(restored ? model.group(restored) : EMPTY_POINTS);
626
+ if (!restored) this.#pinned.set(false);
627
+ });
628
+
629
+ /**
630
+ * ---------------------------------------------------------------------------
631
+ * Layout
632
+ * ---------------------------------------------------------------------------
633
+ */
634
+
635
+ protected onLayout(event: unknown): void {
636
+ const size = layoutSize(event);
637
+ if (!size) return;
638
+ const current = this.#layout();
639
+ if (current?.width !== size.width || current?.height !== size.height) {
640
+ this.#layout.set(size);
641
+ }
642
+ }
643
+
644
+ protected onTooltipLayout(event: unknown): void {
645
+ const size = layoutSize(event);
646
+ const current = this.#tooltipSize();
647
+ if (
648
+ size &&
649
+ (current?.width !== size.width || current?.height !== size.height)
650
+ ) {
651
+ this.#tooltipSize.set(size);
652
+ }
653
+ }
654
+
655
+ /**
656
+ * ---------------------------------------------------------------------------
657
+ * Touch: scrub to focus, tap to select (and pin a sticky tooltip)
658
+ * ---------------------------------------------------------------------------
659
+ */
660
+
661
+ protected onTouchStart(event: unknown): void {
662
+ const touch = touchPoint(event);
663
+ this.#touchStart = touch;
664
+ // A pinned tooltip stays put until the next tap; a zoomable chart's drag
665
+ // pans instead of scrubbing.
666
+ if (!touch || this.#pinned() || this.zoomable()) return;
667
+ this.#focusAt(touch);
668
+ }
669
+
670
+ protected onTouchMove(event: unknown): void {
671
+ const touch = touchPoint(event);
672
+ if (!touch || this.#pinned() || this.zoomable()) return;
673
+ this.#focusAt(touch);
674
+ }
675
+
676
+ protected onTouchEnd(event: unknown): void {
677
+ const touch = touchPoint(event) ?? this.#touchStart;
678
+ const start = this.#touchStart;
679
+ this.#touchStart = null;
680
+ const model = this.#focusModel();
681
+ if (!touch || !model) return;
682
+ // Only a tap selects. A drag was a scrub, a pan on a zoomable chart, or a
683
+ // scroll of the page that started on the chart (Android doesn't always
684
+ // cancel the touch when a parent scroll-view takes it); either way the
685
+ // focus it showed goes when the finger lifts, unless a tooltip is pinned.
686
+ if (start && Math.hypot(touch.x - start.x, touch.y - start.y) > TAP_SLOP) {
687
+ if (!this.#pinned()) this.dismiss();
688
+ return;
689
+ }
690
+ const points = model.resolve(touch.x, touch.y);
691
+ const point = points[0] ?? null;
692
+ this.definition().selection?.change(point, 'pointer');
693
+ this.pointSelect.emit(point);
694
+ if (this.#sticky()) {
695
+ if (this.#pinned()) {
696
+ this.dismiss();
697
+ return;
698
+ }
699
+ this.#pinned.set(point !== null);
700
+ }
701
+ this.#pointer.set(touch);
702
+ this.#setFocus(points, 'pointer');
703
+ }
704
+
705
+ /**
706
+ * The touch was taken over, typically by a scrolling parent.
707
+ */
708
+ protected onTouchCancel(): void {
709
+ this.#touchStart = null;
710
+ if (this.#pinned()) return;
711
+ this.#pointer.set(null);
712
+ this.#setFocus(EMPTY_POINTS, 'pointer');
713
+ }
714
+
715
+ #focusAt(touch: TouchPoint): void {
716
+ const model = this.#focusModel();
717
+ if (!model) return;
718
+ this.#pointer.set(touch);
719
+ this.#setFocus(model.resolve(touch.x, touch.y), 'pointer');
720
+ }
721
+
722
+ #setFocus(points: readonly ChartPoint[], source: ChartFocusSource): void {
723
+ const previous = this.#focused();
724
+ this.#focusSource.set(source);
725
+ if (
726
+ previous.length === points.length &&
727
+ previous.every((point, index) => point === points[index])
728
+ ) {
729
+ return;
730
+ }
731
+ this.#focused.set(points);
732
+ this.focusChange.emit(points[0] ?? null);
733
+ }
734
+
735
+ /**
736
+ * Clears focus and unpins the tooltip.
737
+ */
738
+ dismiss(): void {
739
+ this.#pinned.set(false);
740
+ this.#pointer.set(null);
741
+ this.#setFocus(EMPTY_POINTS, 'pointer');
742
+ }
743
+
744
+ /**
745
+ * ---------------------------------------------------------------------------
746
+ * Accessibility: step through the points with the screen reader's actions
747
+ * ---------------------------------------------------------------------------
748
+ */
749
+
750
+ protected onAccessibilityAction(event: unknown): void {
751
+ const name = (event as { detail?: { name?: string } }).detail?.name;
752
+ const model = this.#focusModel();
753
+ if (!model || (name !== NEXT_POINT && name !== PREVIOUS_POINT)) return;
754
+ const point = adjacentFocusPoint(
755
+ model,
756
+ this.#focused()[0] ?? null,
757
+ name === NEXT_POINT ? 1 : -1,
758
+ );
759
+ this.#pointer.set(null);
760
+ this.#setFocus(point ? model.group(point) : EMPTY_POINTS, 'keyboard');
761
+ }
762
+
763
+ // ---------------------------------------------------------------------------
764
+ // Pan / zoom
765
+ // ---------------------------------------------------------------------------
766
+
767
+ /**
768
+ * Zooms in by `zoomStep` around the center of the visible window.
769
+ */
770
+ zoomIn(): void {
771
+ this.#zoomBy(this.zoomStep());
772
+ }
773
+
774
+ /**
775
+ * Zooms out by `zoomStep` around the center of the visible window.
776
+ */
777
+ zoomOut(): void {
778
+ this.#zoomBy(1 / this.zoomStep());
779
+ }
780
+
781
+ /**
782
+ * Back to the whole chart.
783
+ */
784
+ resetZoom(): void {
785
+ this.#viewport.set({});
786
+ }
787
+
788
+ #zoomBy(factor: number): void {
789
+ const scene = this.scene();
790
+ if (!scene) return;
791
+ this.#updateViewport(scene, (axis) => {
792
+ const base = axisContentWindow(scene.scales[axis]);
793
+ const current = axisVisibleWindow(scene.scales[axis]);
794
+ if (!base || !current) return null;
795
+ return zoomWindow(
796
+ base,
797
+ current,
798
+ factor,
799
+ (current[0] + current[1]) / 2,
800
+ this.maxZoom(),
801
+ this.minZoom(),
802
+ );
803
+ });
804
+ }
805
+
806
+ /**
807
+ * Writes a new window for each zoomable axis. A window covering the whole
808
+ * axis is dropped, so a fully zoomed-out chart renders exactly as one that
809
+ * was never zoomed.
810
+ */
811
+ #updateViewport(
812
+ scene: ChartScene<any, any, any>,
813
+ next: (axis: 'x' | 'y') => ChartWindow | null,
814
+ ): void {
815
+ const axes = this.zoomAxes();
816
+ const viewport: { x?: ChartViewport['x']; y?: ChartViewport['y'] } = {
817
+ ...this.#viewport(),
818
+ };
819
+ for (const axis of ['x', 'y'] as const) {
820
+ if (!axes.includes(axis)) continue;
821
+ const scale = scene.scales[axis];
822
+ const base = axisContentWindow(scale);
823
+ const window = next(axis);
824
+ if (!scale || !base || !window) continue;
825
+ viewport[axis] = isFullWindow(base, window)
826
+ ? undefined
827
+ : toViewportDomain(window, scale);
828
+ }
829
+ this.#viewport.set(viewport);
830
+ }
831
+
832
+ // The window and scene at the start of the active pan; plain fields since
833
+ // nothing renders from them.
834
+ #panStart: { scene: ChartScene<any, any, any> } | null = null;
835
+
836
+ /**
837
+ * Drag to pan. `onBegin` snapshots the scene at touch-down, where the
838
+ * gesture's translation is measured from, so the pan works from that scene
839
+ * and the cumulative translation and tracks the finger 1:1 without drift.
840
+ * `onStart` and `onUpdate` anchor too, in case a host skips `onBegin`.
841
+ */
842
+ protected readonly panGesture = new PanGesture()
843
+ .maxPointers(1)
844
+ .onBegin(() => this.#beginPan())
845
+ .onStart(() => {
846
+ if (!this.#panStart) this.#beginPan();
847
+ })
848
+ .onUpdate((event) => this.#onPan(event))
849
+ .onEnd(() => this.#endPan());
850
+
851
+ #beginPan(): void {
852
+ const scene = this.scene();
853
+ if (!scene) return;
854
+ this.#panStart = { scene };
855
+ this.#frozenLabelCount = this.#labels().length;
856
+ this.#gesturing.set(true);
857
+ }
858
+
859
+ #onPan(event: PanGestureEvent): void {
860
+ if (!this.#panStart) this.#beginPan();
861
+ const start = this.#panStart?.scene;
862
+ if (!start) return;
863
+ this.#updateViewport(start, (axis) => {
864
+ const base = axisContentWindow(start.scales[axis]);
865
+ const current = axisVisibleWindow(start.scales[axis]);
866
+ if (!base || !current) return null;
867
+ const extent = axis === 'x' ? start.chart.width : start.chart.height;
868
+ // A collapsed plot has nothing to pan; max(1, …) just avoids 0/0.
869
+ const perPixel = (current[1] - current[0]) / Math.max(1, extent);
870
+ // Dragging right reveals lower x values; the y axis points down the
871
+ // screen, so dragging down reveals higher y values.
872
+ const delta =
873
+ axis === 'x'
874
+ ? -event.translationX * perPixel
875
+ : event.translationY * perPixel;
876
+ return panWindow(base, current, delta);
877
+ });
878
+ }
879
+
880
+ #endPan(): void {
881
+ this.#panStart = null;
882
+ // Released on the next tick, outside the gesture callback, since it lets
883
+ // the label pool and tooltip add or remove elements again.
884
+ setTimeout(() => this.#gesturing.set(false), 0);
885
+ }
886
+
887
+ /** A stable empty list for `[lynxGesture]` when not zoomable. */
888
+ protected readonly noGesture: never[] = [];
889
+ }
890
+
891
+ /**
892
+ * A Lynx layoutchange event's size (iOS/web put it in `detail`, older Android in `params`).
893
+ */
894
+ const layoutSize = (
895
+ event: unknown,
896
+ ): { width: number; height: number } | null => {
897
+ const source = event as {
898
+ detail?: { width?: number; height?: number };
899
+ params?: { width?: number; height?: number };
900
+ };
901
+ const box = source.detail ?? source.params;
902
+ const width = box?.width;
903
+ const height = box?.height;
904
+ return typeof width === 'number' && typeof height === 'number'
905
+ ? { width, height }
906
+ : null;
907
+ };
908
+
909
+ /**
910
+ * The first touch's position relative to the chart: Lynx reports `x`/`y`
911
+ * relative to the touched element, and every layer above the root ignores
912
+ * touches, so that element is the chart itself.
913
+ */
914
+ const touchPoint = (event: unknown): TouchPoint | null => {
915
+ const source = event as {
916
+ touches?: readonly { x?: number; y?: number }[];
917
+ changedTouches?: readonly { x?: number; y?: number }[];
918
+ };
919
+ const touch = source.touches?.[0] ?? source.changedTouches?.[0];
920
+ return typeof touch?.x === 'number' && typeof touch.y === 'number'
921
+ ? { x: touch.x, y: touch.y }
922
+ : null;
923
+ };
924
+
925
+ /**
926
+ * What a screen reader says for the focused point: series, x, y.
927
+ */
928
+ const describePoint = (point: ChartPoint): string =>
929
+ [point.groupLabel, formatValue(point.xValue), formatValue(point.yValue)]
930
+ .filter((part) => part !== undefined && part !== '')
931
+ .join(', ');
932
+
933
+ const formatValue = (value: ChartValue): string =>
934
+ value instanceof Date ? value.toISOString().slice(0, 10) : String(value);