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