@blotch/dolan 22.2.1 → 22.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/add.d.ts +6 -2
- package/dist/commands/add.js +32 -10
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/add.test.js +129 -9
- package/dist/commands/add.test.js.map +1 -1
- package/dist/commands/diff.d.ts +2 -1
- package/dist/commands/diff.js +38 -25
- package/dist/commands/diff.js.map +1 -1
- package/dist/commands/diff.test.js +72 -11
- package/dist/commands/diff.test.js.map +1 -1
- package/dist/commands/doctor.js +41 -27
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +93 -9
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/eject.js +7 -0
- package/dist/commands/eject.js.map +1 -1
- package/dist/commands/eject.test.js +39 -0
- package/dist/commands/eject.test.js.map +1 -1
- package/dist/commands/info.js +3 -2
- package/dist/commands/info.js.map +1 -1
- package/dist/commands/init.d.ts +37 -1
- package/dist/commands/init.js +149 -40
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +285 -26
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/install-packages.d.ts +6 -0
- package/dist/commands/install-packages.js +20 -0
- package/dist/commands/install-packages.js.map +1 -0
- package/dist/commands/install-packages.test.d.ts +1 -0
- package/dist/commands/install-packages.test.js +55 -0
- package/dist/commands/install-packages.test.js.map +1 -0
- package/dist/commands/list.js +21 -14
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/list.test.js +62 -5
- package/dist/commands/list.test.js.map +1 -1
- package/dist/commands/outdated.js +11 -10
- package/dist/commands/outdated.js.map +1 -1
- package/dist/commands/outdated.test.js +20 -5
- package/dist/commands/outdated.test.js.map +1 -1
- package/dist/commands/remove.d.ts +4 -0
- package/dist/commands/remove.js +17 -4
- package/dist/commands/remove.js.map +1 -1
- package/dist/commands/remove.test.js +53 -2
- package/dist/commands/remove.test.js.map +1 -1
- package/dist/commands/update.d.ts +6 -0
- package/dist/commands/update.js +59 -24
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/update.test.js +273 -31
- package/dist/commands/update.test.js.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +8 -0
- package/dist/registry.js +32 -24
- package/dist/registry.js.map +1 -1
- package/dist/registry.test.js +76 -37
- package/dist/registry.test.js.map +1 -1
- package/dist/testing/set-input-signal.d.ts +13 -0
- package/dist/testing/set-input-signal.js +20 -0
- package/dist/testing/set-input-signal.js.map +1 -0
- package/dist/testing/set-input-signal.test.d.ts +1 -0
- package/dist/testing/set-input-signal.test.js +20 -0
- package/dist/testing/set-input-signal.test.js.map +1 -0
- package/dist/ui/components/action-sheet/action-sheet.test.ts +48 -1
- package/dist/ui/components/action-sheet/action-sheet.ts +37 -9
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +49 -2
- package/dist/ui/components/alert-dialog/alert-dialog.ts +43 -12
- package/dist/ui/components/area-chart/area-chart-render.test.ts +52 -110
- package/dist/ui/components/area-chart/area-chart.test.ts +59 -146
- package/dist/ui/components/area-chart/area-chart.ts +100 -315
- package/dist/ui/components/area-chart/index.ts +2 -3
- package/dist/ui/components/bar-chart/bar-chart-render.test.ts +47 -163
- package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -58
- package/dist/ui/components/bar-chart/bar-chart.ts +140 -294
- package/dist/ui/components/bar-chart/index.ts +3 -3
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +191 -9
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +136 -20
- package/dist/ui/components/button/button-render.test.ts +19 -0
- package/dist/ui/components/button/button.test.ts +2 -15
- package/dist/ui/components/button/button.ts +18 -2
- package/dist/ui/components/button-group/button-group.test.ts +2 -21
- package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +48 -163
- package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +107 -85
- package/dist/ui/components/candlestick-chart/candlestick-chart.ts +150 -323
- package/dist/ui/components/candlestick-chart/index.ts +3 -4
- package/dist/ui/components/chart/chart-focus.test.ts +132 -0
- package/dist/ui/components/chart/chart-focus.ts +115 -0
- package/dist/ui/components/chart/chart-presets.test.ts +139 -0
- package/dist/ui/components/chart/chart-presets.ts +271 -0
- package/dist/ui/components/chart/chart-render.test.ts +730 -0
- package/dist/ui/components/chart/chart-scene.test.ts +589 -0
- package/dist/ui/components/chart/chart-scene.ts +483 -0
- package/dist/ui/components/chart/chart-theme.test.ts +85 -0
- package/dist/ui/components/chart/chart-theme.ts +198 -0
- package/dist/ui/components/chart/chart-zoom.test.ts +159 -0
- package/dist/ui/components/chart/chart-zoom.ts +162 -0
- package/dist/ui/components/chart/chart.ts +921 -0
- package/dist/ui/components/chart/index.ts +24 -0
- package/dist/ui/components/dialog/dialog.test.ts +49 -2
- package/dist/ui/components/dialog/dialog.ts +43 -12
- package/dist/ui/components/icon/icon-theme.test.ts +133 -0
- package/dist/ui/components/icon/icon-theme.ts +204 -0
- package/dist/ui/components/icon/icon.test.ts +51 -2
- package/dist/ui/components/icon/icon.ts +38 -21
- package/dist/ui/components/line-chart/index.ts +2 -3
- package/dist/ui/components/line-chart/line-chart-render.test.ts +69 -107
- package/dist/ui/components/line-chart/line-chart.test.ts +63 -67
- package/dist/ui/components/line-chart/line-chart.ts +83 -241
- package/dist/ui/components/list/list.test.ts +69 -2
- package/dist/ui/components/list/list.ts +21 -15
- package/dist/ui/components/nav-drawer/nav-drawer.test.ts +80 -1
- package/dist/ui/components/nav-drawer/nav-drawer.ts +66 -11
- package/dist/ui/components/pagination/pagination.test.ts +62 -0
- package/dist/ui/components/pagination/pagination.ts +55 -8
- package/dist/ui/components/scatter-chart/index.ts +2 -3
- package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +51 -144
- package/dist/ui/components/scatter-chart/scatter-chart.test.ts +57 -47
- package/dist/ui/components/scatter-chart/scatter-chart.ts +104 -250
- package/dist/ui/components/scroll-area/scroll-area.test.ts +37 -2
- package/dist/ui/components/scroll-area/scroll-area.ts +8 -8
- package/dist/ui/components/spinner/spinner.test.ts +27 -0
- package/dist/ui/components/spinner/spinner.ts +33 -23
- package/dist/ui/components/toast/toast.test.ts +37 -0
- package/dist/ui/components/toast/toast.ts +20 -4
- package/dist/ui/theme/chart-colors.test.ts +30 -0
- package/dist/ui/theme/dark.css +8 -0
- package/dist/ui/theme/default.css +8 -0
- package/dist/ui/theme/icon-colors.test.ts +30 -0
- package/dist/ui/theme/tailwind-plugin.ts +10 -1
- package/dist/utils/installed.d.ts +17 -0
- package/dist/utils/installed.js +27 -0
- package/dist/utils/installed.js.map +1 -0
- package/dist/utils/installed.test.d.ts +1 -0
- package/dist/utils/installed.test.js +41 -0
- package/dist/utils/installed.test.js.map +1 -0
- package/dist/utils/interactive.d.ts +9 -0
- package/dist/utils/interactive.js +10 -0
- package/dist/utils/interactive.js.map +1 -0
- package/dist/utils/interactive.test.d.ts +1 -0
- package/dist/utils/interactive.test.js +24 -0
- package/dist/utils/interactive.test.js.map +1 -0
- package/dist/utils/packages.d.ts +36 -0
- package/dist/utils/packages.js +70 -0
- package/dist/utils/packages.js.map +1 -0
- package/dist/utils/packages.test.d.ts +1 -0
- package/dist/utils/packages.test.js +89 -0
- package/dist/utils/packages.test.js.map +1 -0
- package/dist/utils/resolve-paths.d.ts +17 -1
- package/dist/utils/resolve-paths.js +23 -3
- package/dist/utils/resolve-paths.js.map +1 -1
- package/dist/utils/resolve-paths.test.js +34 -2
- package/dist/utils/resolve-paths.test.js.map +1 -1
- package/package.json +5 -1
- package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +0 -188
- package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +0 -549
- package/dist/ui/components/cartesian-chart/cartesian-chart.ts +0 -1140
- package/dist/ui/components/cartesian-chart/index.ts +0 -18
|
@@ -0,0 +1,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);
|