@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
@@ -1,1140 +0,0 @@
1
- import {
2
- Component,
3
- ViewEncapsulation,
4
- computed,
5
- input,
6
- signal,
7
- } from '@angular/core';
8
- import {
9
- Gesture,
10
- LYNX_ELEMENTS,
11
- LynxGestureDetector,
12
- PanGesture,
13
- type PanGestureEvent,
14
- PinchGesture,
15
- type PinchGestureEvent,
16
- } from '@blotch/angular-lynx';
17
-
18
- import { cn } from '../../utils/cn';
19
-
20
- // ---------------------------------------------------------------------------
21
- // Types
22
- // ---------------------------------------------------------------------------
23
-
24
- /** A single data point in cartesian (x, y) space. */
25
- export type ChartPoint = { x: number; y: number };
26
-
27
- /** An inclusive `[min, max]` value range for one axis. */
28
- export type ChartDomain = readonly [number, number];
29
-
30
- /** Maps a data value on one axis to a pixel offset inside the plot area. */
31
- export type ChartScale = (value: number) => number;
32
-
33
- /**
34
- * Inner inset applied to the plot area, one value per side as a **fraction**
35
- * (`0`–`1`) of the plot's width/height. It shrinks the range the scales map into,
36
- * so every projected series is pushed clear of the frame at once — the common use
37
- * is keeping the first/last dot from sitting flush against the axis. Fractions
38
- * (not pixels) keep the inset proportional as the chart is resized. Omitted sides
39
- * default to `0`.
40
- */
41
- export type ChartPadding = {
42
- top?: number;
43
- right?: number;
44
- bottom?: number;
45
- left?: number;
46
- };
47
-
48
- // ---------------------------------------------------------------------------
49
- // Pure geometry (exported so it can be unit-tested without Angular)
50
- // ---------------------------------------------------------------------------
51
-
52
- /**
53
- * Builds a linear interpolation function from a data `domain` to a pixel
54
- * `range`. The range is allowed to be *descending* (`[height, 0]`) — that is how
55
- * the y-axis is flipped so that larger data values render higher up the screen
56
- * (screen y grows downward).
57
- *
58
- * A zero-width domain (all values equal) would divide by zero, so it collapses
59
- * to the range midpoint — the flat line then sits centered in the plot.
60
- */
61
- export const linearScale = (
62
- domain: ChartDomain,
63
- range: readonly [number, number],
64
- ): ChartScale => {
65
- const [d0, d1] = domain;
66
- const [r0, r1] = range;
67
- const span = d1 - d0;
68
- if (span === 0) {
69
- const mid = (r0 + r1) / 2;
70
- return () => mid;
71
- }
72
- return (value: number) => r0 + ((value - d0) / span) * (r1 - r0);
73
- };
74
-
75
- /**
76
- * Rounds a number to a "nice" one — a `1`, `2`, `5`, or `10` times a power of
77
- * ten. This is the Heckbert "nice numbers for graph labels" primitive: it makes
78
- * axis steps read as `0, 25, 50, 75, 100` rather than `0, 24.25, 48.5, …`.
79
- *
80
- * We compute the magnitude with `Math.log(x) / Math.LN10` rather than
81
- * `Math.log10` — the latter is a runtime method the Lynx build pipeline does
82
- * NOT polyfill (same class of gap as `String.prototype.replaceAll`), whereas
83
- * `Math.log`/`Math.LN10` are ES5 and always present.
84
- *
85
- * @param round when `true`, snap to the nearest nice number; when `false`, take
86
- * the smallest nice number ≥ `value` (used to size the whole range).
87
- */
88
- export const niceNum = (value: number, round: boolean): number => {
89
- if (value <= 0) return 0;
90
- const exponent = Math.floor(Math.log(value) / Math.LN10);
91
- const fraction = value / Math.pow(10, exponent); // in [1, 10)
92
- let niceFraction: number;
93
- if (round) {
94
- if (fraction < 1.5) niceFraction = 1;
95
- else if (fraction < 3) niceFraction = 2;
96
- else if (fraction < 7) niceFraction = 5;
97
- else niceFraction = 10;
98
- } else {
99
- if (fraction <= 1) niceFraction = 1;
100
- else if (fraction <= 2) niceFraction = 2;
101
- else if (fraction <= 5) niceFraction = 5;
102
- else niceFraction = 10;
103
- }
104
- return niceFraction * Math.pow(10, exponent);
105
- };
106
-
107
- /**
108
- * Derives a rounded axis from raw data extremes: a padded `[lo, hi]` domain
109
- * plus evenly stepped, human-readable `ticks`. `tickCount` is a *target* — the
110
- * actual tick count can differ by one because the step is snapped to a nice
111
- * value (this is standard axis behavior, e.g. D3's `ticks`).
112
- *
113
- * Flat data (`min === max`) or non-finite input can't yield a range, so we pad
114
- * around the single value to keep the line visible mid-plot.
115
- */
116
- export const niceScale = (
117
- min: number,
118
- max: number,
119
- tickCount = 5,
120
- ): { domain: ChartDomain; ticks: number[]; step: number } => {
121
- let lo0 = min;
122
- let hi0 = max;
123
- if (!Number.isFinite(lo0) || !Number.isFinite(hi0) || lo0 === hi0) {
124
- const base = Number.isFinite(lo0) ? lo0 : 0;
125
- const pad = base === 0 ? 1 : Math.abs(base) * 0.5;
126
- lo0 = base - pad;
127
- hi0 = base + pad;
128
- }
129
-
130
- const range = niceNum(hi0 - lo0, false);
131
- const step = niceNum(range / Math.max(1, tickCount - 1), true);
132
- const lo = Math.floor(lo0 / step) * step;
133
- const hi = Math.ceil(hi0 / step) * step;
134
-
135
- const ticks: number[] = [];
136
- // Step to `hi` inclusive; the half-step slack absorbs floating-point drift so
137
- // the final tick isn't dropped. Snapping each value to the step grid clears
138
- // accumulated error (e.g. 0.30000000000000004 → 0.3).
139
- for (let v = lo; v <= hi + step * 0.5; v += step) {
140
- ticks.push(Math.round(v / step) * step);
141
- }
142
-
143
- return { domain: [lo, hi], ticks, step };
144
- };
145
-
146
- /**
147
- * Evenly divides a `domain` into exactly `count` tick values (endpoints
148
- * included). Used as the fallback when a caller hasn't supplied explicit ticks
149
- * — `niceScale` is preferred when human-readable labels matter.
150
- */
151
- export const generateTicks = (domain: ChartDomain, count: number): number[] => {
152
- const [d0, d1] = domain;
153
- if (count <= 1) return [d0];
154
- const ticks: number[] = [];
155
- for (let i = 0; i < count; i++) {
156
- ticks.push(d0 + ((d1 - d0) * i) / (count - 1));
157
- }
158
- return ticks;
159
- };
160
-
161
- // Extra ticks generated beyond the target count so a full "screen" of grid
162
- // stays covered while a pan is mid-slide. The extras land just past either
163
- // plot edge, clipped by `overflow: hidden` — never visible, just inert
164
- // elements reserved so the `@for` never needs to grow.
165
- const ALIGNED_TICK_BUFFER = 2;
166
-
167
- /**
168
- * Computes ZERO-aligned tick values spanning a `window` at a fixed "nice" step
169
- * derived from the window's span (see {@link niceNum}). Unlike `generateTicks`
170
- * — which divides a window into a fixed *fraction* of its own span, so every
171
- * tick re-lands on the exact same pixel position the instant the window
172
- * shifts (the axis *numbers* change but the *gridlines* visually stay put,
173
- * which is what made a pan look like it was translating nothing) — each tick
174
- * here sits on a fixed multiple of `step` in DATA space. Panning changes which
175
- * multiples fall inside the window, but never an individual tick's data
176
- * value, so its projected pixel position slides across the screen exactly in
177
- * step with the content it labels — real X/Y scroll behavior.
178
- *
179
- * The returned array always has `targetCount + ALIGNED_TICK_BUFFER` entries —
180
- * a count that depends only on `targetCount` (an input, stable for the whole
181
- * gesture), never on the window's position — so it can back a Lynx
182
- * gesture-driven `@for` without ever adding/removing nodes mid-drag.
183
- */
184
- export const generateAlignedTicks = (
185
- window: ChartDomain,
186
- targetCount: number,
187
- ): number[] => {
188
- const span = window[1] - window[0];
189
- const step = niceNum(span / Math.max(1, targetCount - 1), true) || 1;
190
- const firstIndex = Math.floor(window[0] / step) - 1;
191
- const count = Math.max(1, targetCount) + ALIGNED_TICK_BUFFER;
192
- const ticks: number[] = [];
193
- for (let i = 0; i < count; i++) {
194
- ticks.push((firstIndex + i) * step);
195
- }
196
- return ticks;
197
- };
198
-
199
- /**
200
- * True when `value` sits outside `domain`, treating the bounds as inclusive: a
201
- * tick exactly on a bound reads as inside, protected by a tiny relative epsilon
202
- * so float drift in the window math can't exclude a legitimate edge tick.
203
- *
204
- * Drives per-label hiding for zoomable charts — {@link generateAlignedTicks}
205
- * intentionally emits a couple of tick values just past each edge of the
206
- * visible window (so the grid stays covered while panning without changing the
207
- * element count), and this predicate is what suppresses those buffer ticks'
208
- * labels while every in-window label renders in full. Testing by value rather
209
- * than projected pixel keeps it independent of axis padding.
210
- */
211
- export const isValueOutsideDomain = (
212
- value: number,
213
- domain: ChartDomain,
214
- ): boolean => {
215
- const eps = Math.abs(domain[1] - domain[0]) * 1e-6;
216
- return value < domain[0] - eps || value > domain[1] + eps;
217
- };
218
-
219
- /**
220
- * ---------------------------------------------------------------------------
221
- * Pan / zoom window math (pure — exported for unit tests)
222
- *
223
- * Pan and zoom are modeled as a *visible window*: a sub-range of the axis
224
- * domain that the scales map from. Shrinking the window zooms in (data spreads
225
- * across the same pixels), sliding it pans. Because every gridline, axis label,
226
- * and series mark projects through the scales, replacing the window re-projects
227
- * the whole chart — and bar/candle widths (derived from projected pixel spacing)
228
- * stretch for free. These helpers are the pure geometry behind that model.
229
- * ---------------------------------------------------------------------------
230
- */
231
-
232
- const clampValue = (value: number, lo: number, hi: number): number =>
233
- Math.max(lo, Math.min(hi, value));
234
-
235
- // Gridlines are 1px thick. A line whose data value sits exactly on the plot's
236
- // FAR edge — the bottom (y = domain min) or the right (x = domain max) —
237
- // projects to pixel `plotHeight` / `plotWidth`: the first row/column just
238
- // OUTSIDE the plot's content box. The plot needs `overflow: hidden` to clip
239
- // panned/zoomed marks, so that 1px line is clipped away and the edge gridline
240
- // silently vanishes (reported: the y-axis "0" gridline isn't visible; its label
241
- // still shows because labels live in the un-clipped container). This is ordinary
242
- // box geometry, not a Lynx quirk — a child at `top: 100%` height 1px is outside
243
- // an overflow-hidden parent on the web too.
244
- const GRIDLINE_THICKNESS = 1;
245
-
246
- /**
247
- * The pixel offset at which to draw a gridline of {@link GRIDLINE_THICKNESS},
248
- * nudged so an in-window line at the plot's far edge stays visible instead of
249
- * being clipped by the plot's `overflow: hidden`. An in-domain line is clamped
250
- * into `[0, extentPx - thickness]` so its full thickness lands inside the box; an
251
- * out-of-domain line — the buffer ticks {@link generateAlignedTicks} emits just
252
- * past each edge while panning — is returned unclamped so it clips away
253
- * naturally, because clamping those would instead pile them onto the visible edge.
254
- */
255
- export const gridlineOffset = (
256
- value: number,
257
- offsetPx: number,
258
- domain: ChartDomain,
259
- extentPx: number,
260
- thickness = GRIDLINE_THICKNESS,
261
- ): number =>
262
- isValueOutsideDomain(value, domain)
263
- ? offsetPx
264
- : clampValue(offsetPx, 0, extentPx - thickness);
265
-
266
- /**
267
- * The inverse of {@link linearScale}: maps a pixel offset back to its data
268
- * value. Used to turn a pinch's focal *pixel* into the data value under the
269
- * fingers, so the zoom can hold that value fixed on screen. A zero-width range
270
- * (all pixels equal) can't be inverted, so it collapses to the domain's lower
271
- * bound.
272
- */
273
- export const invertLinear = (
274
- domain: ChartDomain,
275
- range: readonly [number, number],
276
- pixel: number,
277
- ): number => {
278
- const [d0, d1] = domain;
279
- const [r0, r1] = range;
280
- const span = r1 - r0;
281
- if (span === 0) return d0;
282
- return d0 + ((pixel - r0) / span) * (d1 - d0);
283
- };
284
-
285
- /**
286
- * Clamps a visible `window` so it always sits inside the full data `base`
287
- * domain: its span can't exceed the base span, and it can't slide past either
288
- * edge. Span is preserved where possible (the window slides rather than resizes)
289
- * so panning against an edge doesn't secretly zoom. Keeps a zoomed view valid
290
- * even if the underlying data domain later shrinks.
291
- */
292
- export const clampWindow = (
293
- base: ChartDomain,
294
- window: ChartDomain,
295
- ): ChartDomain => {
296
- const [bLo, bHi] = base;
297
- const baseSpan = bHi - bLo;
298
- const span = Math.min(window[1] - window[0], baseSpan);
299
- const lo = clampValue(window[0], bLo, bHi - span);
300
- return [lo, lo + span];
301
- };
302
-
303
- /**
304
- * Pans a visible `window` by `dataDelta` (in data units), clamped inside `base`.
305
- * The span is preserved, so hitting an edge stops the pan rather than resizing
306
- * the window. Positive `dataDelta` moves the window toward higher values.
307
- */
308
- export const panWindow = (
309
- base: ChartDomain,
310
- current: ChartDomain,
311
- dataDelta: number,
312
- ): ChartDomain =>
313
- clampWindow(base, [current[0] + dataDelta, current[1] + dataDelta]);
314
-
315
- /**
316
- * Zooms a visible `window` by `factor` (>1 zooms in, <1 zooms out), holding the
317
- * data value `focal` fixed on screen so a pinch stays centered on the fingers.
318
- * The resulting span is clamped to `[baseSpan / maxZoom, baseSpan / minZoom]` —
319
- * you can never zoom out past the full data range, nor in past `maxZoom`× — then
320
- * the window is clamped inside `base`.
321
- */
322
- export const zoomWindow = (
323
- base: ChartDomain,
324
- current: ChartDomain,
325
- factor: number,
326
- focal: number,
327
- maxZoom: number,
328
- minZoom = 1,
329
- ): ChartDomain => {
330
- const baseSpan = base[1] - base[0];
331
- const [lo, hi] = current;
332
- const span = hi - lo;
333
- const minSpan = maxZoom > 0 ? baseSpan / maxZoom : baseSpan;
334
- const maxSpan = minZoom > 0 ? baseSpan / minZoom : baseSpan;
335
- const newSpan = clampValue(span / (factor || 1), minSpan, maxSpan);
336
- // Keep the focal value at the same fractional position within the window.
337
- const frac = span === 0 ? 0.5 : (focal - lo) / span;
338
- const newLo = focal - frac * newSpan;
339
- return clampWindow(base, [newLo, newLo + newSpan]);
340
- };
341
-
342
- /**
343
- * Resamples a polyline into a dense run of points that trace a smooth
344
- * **monotone cubic** curve through the originals — the shared smoothing
345
- * primitive both the line and the area series build on so that "smooth" behaves
346
- * identically for either.
347
- *
348
- * ## Why monotone cubic (Fritsch–Carlson), not Catmull-Rom
349
- * A monotone cubic never overshoots its data: between two points the curve stays
350
- * inside their y-range, so a smoothed line never bulges past a peak and a
351
- * smoothed area fill never pokes above the plot or dips below its baseline. That
352
- * matters here because the axis domain is derived from the *raw* data extremes —
353
- * an overshooting curve (which Catmull-Rom produces) would render outside the
354
- * plot and clip. This is the same curve D3 draws for `curveMonotoneX` and the
355
- * default Recharts `type="monotone"`.
356
- *
357
- * ## Why resample instead of emitting a path
358
- * Lynx has no SVG path: a line is drawn as many short rotated `<view>` segments
359
- * and an area as sampled column heights. So rather than describe the curve, we
360
- * return closely-spaced points *on* it, and each caller rasterizes them with the
361
- * exact same code it already uses for raw data (`computeLineSegments` /
362
- * `computeAreaColumns`). "Smooth" then costs nothing but a denser point set.
363
- *
364
- * Points must be sorted ascending by x (as cartesian data always is). Fewer than
365
- * three points can't define a curve — two already draw a single straight
366
- * segment — so they're returned unchanged. `samplesPerSegment` is how many
367
- * straight chords approximate each original segment; higher is smoother but emits
368
- * proportionally more native views.
369
- */
370
- export const sampleSmoothLine = (
371
- points: readonly ChartPoint[],
372
- samplesPerSegment = 12,
373
- ): ChartPoint[] => {
374
- const n = points.length;
375
- if (n < 3) return points.map((p) => ({ x: p.x, y: p.y }));
376
-
377
- // Secant slope between each consecutive pair. A zero-width x-gap (duplicate x)
378
- // has no defined slope, so treat it as flat to avoid dividing by zero — the
379
- // sampler then draws a near-vertical chord across the repeated x.
380
- const secants: number[] = [];
381
- for (let i = 0; i < n - 1; i++) {
382
- const dx = points[i + 1].x - points[i].x;
383
- secants.push(dx === 0 ? 0 : (points[i + 1].y - points[i].y) / dx);
384
- }
385
-
386
- // Tangent at each point. Endpoints borrow their single adjacent secant;
387
- // interior points average their two neighbors, but a sign change (a local
388
- // peak or trough) forces a flat tangent so the curve turns without overshoot.
389
- const tangents: number[] = Array.from({ length: n });
390
- tangents[0] = secants[0];
391
- tangents[n - 1] = secants[n - 2];
392
- for (let i = 1; i < n - 1; i++) {
393
- const prev = secants[i - 1];
394
- const next = secants[i];
395
- tangents[i] = prev * next <= 0 ? 0 : (prev + next) / 2;
396
- }
397
-
398
- // Fritsch–Carlson monotonicity fix: keep each Hermite segment monotone by
399
- // clamping the tangents. Normalize them by the secant into (alpha, beta); if
400
- // that point falls outside a circle of radius 3, scale both back onto it. A
401
- // flat secant (equal endpoints) can't be normalized, so its tangents are
402
- // pinned to 0 instead.
403
- for (let i = 0; i < n - 1; i++) {
404
- const secant = secants[i];
405
- if (secant === 0) {
406
- tangents[i] = 0;
407
- tangents[i + 1] = 0;
408
- continue;
409
- }
410
- const alpha = tangents[i] / secant;
411
- const beta = tangents[i + 1] / secant;
412
- const magnitude = alpha * alpha + beta * beta;
413
- if (magnitude > 9) {
414
- const scale = 3 / Math.sqrt(magnitude);
415
- tangents[i] = scale * alpha * secant;
416
- tangents[i + 1] = scale * beta * secant;
417
- }
418
- }
419
-
420
- // Evaluate the cubic Hermite spline on each segment. Sampling `t` from 0
421
- // (inclusive) up to — but not including — 1 hits every original point exactly
422
- // once via the next segment's t=0; the very last point is appended at the end.
423
- const samples = Math.max(1, samplesPerSegment);
424
- const out: ChartPoint[] = [];
425
- for (let i = 0; i < n - 1; i++) {
426
- const p0 = points[i];
427
- const p1 = points[i + 1];
428
- const h = p1.x - p0.x;
429
- const m0 = tangents[i];
430
- const m1 = tangents[i + 1];
431
- for (let s = 0; s < samples; s++) {
432
- const t = s / samples;
433
- const t2 = t * t;
434
- const t3 = t2 * t;
435
- // Hermite basis functions for value (h00, h01) and tangent (h10, h11).
436
- const h00 = 2 * t3 - 3 * t2 + 1;
437
- const h10 = t3 - 2 * t2 + t;
438
- const h01 = -2 * t3 + 3 * t2;
439
- const h11 = t3 - t2;
440
- out.push({
441
- x: p0.x + t * h,
442
- y: h00 * p0.y + h10 * h * m0 + h01 * p1.y + h11 * h * m1,
443
- });
444
- }
445
- }
446
- out.push({ x: points[n - 1].x, y: points[n - 1].y });
447
- return out;
448
- };
449
-
450
- // ---------------------------------------------------------------------------
451
- // Rendering helpers
452
- // ---------------------------------------------------------------------------
453
-
454
- /**
455
- * Formats a pixel value for an inline style string. Two decimals keeps
456
- * sub-pixel precision for rotated segments while avoiding scientific notation
457
- * (`1e-7px`), which Lynx's CSS parser would reject.
458
- */
459
- const px = (n: number): string => `${n.toFixed(2)}px`;
460
-
461
- /**
462
- * Default axis label formatter — plain JS only (no `Intl`, absent in Lynx).
463
- */
464
- const defaultTickFormat = (value: number): string =>
465
- String(Math.round(value * 100) / 100);
466
-
467
- // Small pixel constants for label placement. Font is 10px; offsetting a y-label
468
- // by half that vertically-centers it on its gridline.
469
- const LABEL_FONT_HALF = 5;
470
- const LABEL_GUTTER_GAP = 6;
471
- const X_LABEL_WIDTH = 44;
472
- const X_LABEL_GAP = 4;
473
-
474
- // Extra gutter reserved when an axis *title* is set: a strip to the left of the
475
- // y tick-labels for the (rotated) y-title, and a band below the x tick-labels for
476
- // the x-title. 16px comfortably fits the 10px title font plus its line box.
477
- const Y_AXIS_LABEL_SPACE = 16;
478
- const X_AXIS_LABEL_SPACE = 16;
479
- // Approximate line box of the 10px title font — used to size the rotated y-title.
480
- const AXIS_LABEL_LINE_HEIGHT = 12;
481
-
482
- /**
483
- * The reusable foundation for cartesian (x/y) charts — line, bar, scatter, …
484
- *
485
- * It draws the *frame* — horizontal (and optional vertical) gridlines, axis tick
486
- * labels, and optional axis titles — and hosts a definite-size **plot area** into
487
- * which chart marks are projected via `<ng-content>`. Inner `padding` insets the
488
- * shared scales so every series clears the frame at once. It carries no marks of
489
- * its own; series components
490
- * (`<ui-line-series>`, and future `<ui-bar-series>` etc.) `inject()` this class
491
- * to read the shared `xScale`/`yScale`/`plotWidth`/`plotHeight` and draw
492
- * themselves. This mirrors the `UiToggleGroup` / `UiToggleGroupItem` DI pattern.
493
- *
494
- * ## Why explicit pixel `width`/`height`
495
- * A line is drawn as rotated `<view>` segments (Lynx has no SVG/`<line>`), and
496
- * the rotation angle/length can only be computed from *pixel* coordinates. So
497
- * the chart is sized in explicit px rather than stretching responsively.
498
- *
499
- * ## Why absolute positioning works here
500
- * Lynx has no `position: static` — every element is `position: relative` and an
501
- * absolute child resolves against its **direct parent**, not a distant
502
- * ancestor. So the plot area is a definite-size box and every mark must be a
503
- * direct child of it (or of a series host sized to fill it — see UiLineSeries).
504
- */
505
- @Component({
506
- selector: 'ui-cartesian-chart',
507
- standalone: true,
508
- imports: [LYNX_ELEMENTS, LynxGestureDetector],
509
- encapsulation: ViewEncapsulation.None,
510
- template: `
511
- <view [class]="containerClass()" [style]="containerStyle()">
512
- <!--
513
- Plot area: a definite-size box, offset by the y-axis gutter. It is the
514
- containing block for both the gridlines and the projected series marks.
515
- Gridlines render BEFORE <ng-content> so the line paints on top of them
516
- (Lynx paint order follows source order; z-index is not needed).
517
-
518
- When zoomable, the plot view is the gesture target: a 1-finger drag pans
519
- and a pinch zooms (both write the visible window). overflow:hidden on the
520
- plot keeps zoomed marks clipped to this box.
521
- -->
522
- <view
523
- [style]="plotStyle()"
524
- [lynxGesture]="zoomable() ? interactionGesture : noGesture"
525
- >
526
- @if (showGrid()) {
527
- @for (line of gridlines(); track $index) {
528
- <view class="bg-border" [style]="line.style"></view>
529
- }
530
- }
531
- @if (showXGrid()) {
532
- @for (line of verticalGridlines(); track $index) {
533
- <view class="bg-border" [style]="line.style"></view>
534
- }
535
- }
536
- <ng-content />
537
- </view>
538
-
539
- <!-- Y-axis tick labels, right-aligned in the gutter beside the plot. -->
540
- @for (label of yLabels(); track $index) {
541
- <text
542
- class="text-[10px] leading-none text-muted-foreground text-right"
543
- [style]="label.style"
544
- >
545
- {{ label.text }}
546
- </text>
547
- }
548
-
549
- <!-- X-axis tick labels, centered under each tick below the plot. -->
550
- @for (label of xLabels(); track $index) {
551
- <text
552
- class="text-[10px] leading-none text-muted-foreground text-center"
553
- [style]="label.style"
554
- >
555
- {{ label.text }}
556
- </text>
557
- }
558
-
559
- <!-- Y-axis title: rotated -90° in the reserved left strip, centered on the
560
- plot height. Rendered only when set so it costs no gutter otherwise. -->
561
- @if (yAxisLabel()) {
562
- <text
563
- class="text-[10px] font-medium leading-none text-muted-foreground text-center"
564
- [style]="yAxisLabelStyle()"
565
- >
566
- {{ yAxisLabel() }}
567
- </text>
568
- }
569
-
570
- <!-- X-axis title: centered across the plot, below the x tick labels. -->
571
- @if (xAxisLabel()) {
572
- <text
573
- class="text-[10px] font-medium leading-none text-muted-foreground text-center"
574
- [style]="xAxisLabelStyle()"
575
- >
576
- {{ xAxisLabel() }}
577
- </text>
578
- }
579
-
580
- <!-- Zoom controls: a small +/−/reset cluster at the plot's top-right.
581
- Rendered LAST so it paints over the plot, and kept in the container
582
- (not inside the clipped, gesture-capturing plot view) so it is neither
583
- clipped by overflow:hidden nor swallowed by the pan gesture. Each is a
584
- plain (bindtap) view — a normal Angular event, safe to mutate state
585
- from, unlike the gesture worklet. -->
586
- @if (zoomable() && showZoomControls()) {
587
- <view [style]="zoomControlsStyle()" class="flex-col gap-1 flex">
588
- <view
589
- class="h-7 w-7 items-center rounded-md border border-border bg-card flex justify-center"
590
- (bindtap)="zoomIn()"
591
- >
592
- <text class="text-base font-medium leading-none text-foreground"
593
- >+</text
594
- >
595
- </view>
596
- <view
597
- class="h-7 w-7 items-center rounded-md border border-border bg-card flex justify-center"
598
- (bindtap)="zoomOut()"
599
- >
600
- <text class="text-base font-medium leading-none text-foreground"
601
- >-</text
602
- >
603
- </view>
604
- <view
605
- class="h-7 w-7 items-center rounded-md border border-border bg-card flex justify-center"
606
- (bindtap)="resetZoom()"
607
- >
608
- <text class="text-[9px] font-medium leading-none text-foreground"
609
- >1:1</text
610
- >
611
- </view>
612
- </view>
613
- }
614
- </view>
615
- `,
616
- })
617
- export class UiCartesianChart {
618
- readonly xDomain = input<ChartDomain>([0, 1]);
619
- readonly yDomain = input<ChartDomain>([0, 1]);
620
- /** Explicit tick values. When omitted, ticks are evenly generated. */
621
- readonly xTicks = input<number[] | undefined>(undefined);
622
- readonly yTicks = input<number[] | undefined>(undefined);
623
- readonly width = input(320);
624
- readonly height = input(200);
625
- readonly yAxisWidth = input(32);
626
- readonly xAxisHeight = input(20);
627
- readonly tickCount = input(5);
628
- /** Draw horizontal gridlines at each y tick. */
629
- readonly showGrid = input(true);
630
- /** Draw vertical gridlines at each x tick (off by default — the y gridlines
631
- * above are the usual value reference for line/area charts). */
632
- readonly showXGrid = input(false);
633
- /** Title drawn along the x-axis, centered below the tick labels. */
634
- readonly xAxisLabel = input<string>('');
635
- /** Title drawn along the y-axis, rotated in the left gutter. */
636
- readonly yAxisLabel = input<string>('');
637
- /** Inner inset of the plot area, each side a fraction (0–1) — see {@link ChartPadding}. */
638
- readonly padding = input<ChartPadding>({});
639
- readonly xTickFormat = input<(value: number) => string>(defaultTickFormat);
640
- readonly yTickFormat = input<(value: number) => string>(defaultTickFormat);
641
- readonly userClass = input<string>('', { alias: 'class' });
642
-
643
- // --- Pan / zoom (opt-in) ---
644
- /** Enable pan (1-finger drag) + zoom (pinch and the on-screen controls). Off
645
- * by default so existing charts — which often sit inside scroll-views — keep
646
- * their static behavior and don't fight the native scroll gesture. */
647
- readonly zoomable = input(false);
648
- /** Which axes pan/zoom affect. `'xy'` (default) zooms both; `'x'` or `'y'`
649
- * locks the other axis (e.g. `'x'` for a time-series where only the horizontal
650
- * stretches). */
651
- readonly zoomAxes = input<'x' | 'y' | 'xy'>('xy');
652
- /** Show the +/−/reset control cluster (top-right of the plot) when zoomable. */
653
- readonly showZoomControls = input(true);
654
- /** Lower zoom bound (1 = fully zoomed out; you can't zoom out past the data). */
655
- readonly minZoom = input(1);
656
- /** Upper zoom bound — the visible window can shrink to `baseSpan / maxZoom`. */
657
- readonly maxZoom = input(8);
658
- /** Multiplier applied per +/− control press. 1.4 ≈ a comfortable step. */
659
- readonly zoomStep = input(1.4);
660
-
661
- // --- Shared coordinate system (read by projected series via DI) ---
662
-
663
- // A set axis title steals a strip of the gutter (left for y, bottom for x);
664
- // absorbing it here shrinks the plot so titles never overlap the tick labels.
665
- readonly #yGutter = computed(
666
- () => this.yAxisWidth() + (this.yAxisLabel() ? Y_AXIS_LABEL_SPACE : 0),
667
- );
668
- readonly #xGutter = computed(
669
- () => this.xAxisHeight() + (this.xAxisLabel() ? X_AXIS_LABEL_SPACE : 0),
670
- );
671
-
672
- readonly plotWidth = computed(() => this.width() - this.#yGutter());
673
- readonly plotHeight = computed(() => this.height() - this.#xGutter());
674
-
675
- // Padding as pixels, resolved against the plot size. Omitted sides read as 0.
676
- readonly #padLeft = computed(
677
- () => (this.padding().left ?? 0) * this.plotWidth(),
678
- );
679
- readonly #padRight = computed(
680
- () => (this.padding().right ?? 0) * this.plotWidth(),
681
- );
682
- readonly #padTop = computed(
683
- () => (this.padding().top ?? 0) * this.plotHeight(),
684
- );
685
- readonly #padBottom = computed(
686
- () => (this.padding().bottom ?? 0) * this.plotHeight(),
687
- );
688
-
689
- // --- Pan/zoom view state ---
690
- // The currently-visible sub-window of each axis domain, or `null` when the
691
- // chart is at rest (fully zoomed out). Gestures and the zoom controls write
692
- // these; everything downstream (scales, gridlines, labels, series marks) reads
693
- // through the effective domain below, so a single signal write re-projects the
694
- // whole chart. `null` (rather than the base domain) marks "at rest" so a
695
- // non-zoomable chart keeps its exact original tick behavior (see
696
- // `#resolvedXTicks`).
697
- readonly #viewXDomain = signal<ChartDomain | null>(null);
698
- readonly #viewYDomain = signal<ChartDomain | null>(null);
699
-
700
- // The domain the scales actually map from: the visible window when zoomed,
701
- // else the input domain. Clamped to the input domain so a data change while
702
- // zoomed can never strand the window outside the data.
703
- readonly #effectiveXDomain = computed<ChartDomain>(() => {
704
- const view = this.#viewXDomain();
705
- return view ? clampWindow(this.xDomain(), view) : this.xDomain();
706
- });
707
- readonly #effectiveYDomain = computed<ChartDomain>(() => {
708
- const view = this.#viewYDomain();
709
- return view ? clampWindow(this.yDomain(), view) : this.yDomain();
710
- });
711
-
712
- // Scales map the *effective* domain into the padded range, so every projected
713
- // series is inset from the frame together — no per-series padding needed. When
714
- // zoomed, the effective domain is the visible window, so the same scale drives
715
- // gridlines, labels, and every mark (bars/candles stretch because their width
716
- // is derived from projected pixel spacing).
717
- readonly xScale = computed<ChartScale>(() =>
718
- linearScale(this.#effectiveXDomain(), [
719
- this.#padLeft(),
720
- this.plotWidth() - this.#padRight(),
721
- ]),
722
- );
723
- // Range is flipped (high px → low px) so larger y-values sit higher up the plot.
724
- readonly yScale = computed<ChartScale>(() =>
725
- linearScale(this.#effectiveYDomain(), [
726
- this.plotHeight() - this.#padBottom(),
727
- this.#padTop(),
728
- ]),
729
- );
730
-
731
- // --- Internal rendering state ---
732
-
733
- // When zoomable, ticks are pinned to fixed DATA-space multiples of a "nice"
734
- // step (generateAlignedTicks) rather than evenly dividing the *current*
735
- // window — the latter re-lands every tick on the same pixel position the
736
- // instant the window shifts (numbers change, gridlines visually don't),
737
- // which is why a pan used to look like it was translating around a fixed
738
- // center instead of tracking the finger. Anchoring to fixed data values
739
- // makes every gridline slide across the screen exactly like the content it
740
- // labels. The array length still depends only on `tickCount` (never the
741
- // window), so the gridline/label `@for` never adds or removes nodes
742
- // mid-gesture — mutating the element tree inside a Lynx gesture worklet can
743
- // crash natively. A non-zoomable chart keeps its original behavior exactly
744
- // (explicit ticks, else evenly generated over the full domain).
745
- readonly #resolvedYTicks = computed(() =>
746
- this.zoomable()
747
- ? generateAlignedTicks(this.#effectiveYDomain(), this.tickCount())
748
- : (this.yTicks() ?? generateTicks(this.yDomain(), this.tickCount())),
749
- );
750
- readonly #resolvedXTicks = computed(() =>
751
- this.zoomable()
752
- ? generateAlignedTicks(this.#effectiveXDomain(), this.tickCount())
753
- : (this.xTicks() ?? generateTicks(this.xDomain(), this.tickCount())),
754
- );
755
-
756
- protected readonly containerClass = computed(() => cn(this.userClass()));
757
-
758
- // `flex-shrink: 0` stops a narrow parent squeezing the chart below its
759
- // explicit pixel width (Lynx lets flex items shrink past their content).
760
- //
761
- // Deliberately NOT `overflow: hidden` here. Tick labels are positioned to
762
- // STRADDLE the plot edges on purpose — the top y-label is centered on the top
763
- // gridline, so its text box overhangs the container's top by half the font
764
- // height (LABEL_FONT_HALF); the last x-label overhangs the right edge by half
765
- // its width. A container clip would slice those legitimate edge labels in
766
- // half (reported: "the top 100 gets cut in half at 1:1"). The only labels
767
- // that actually need suppressing are a zoomable chart's out-of-window buffer
768
- // ticks, and those are hidden individually by value in `yLabels`/`xLabels`
769
- // (see `#labelVisibility`) — precise enough to drop the strays while letting
770
- // every in-range label render in full. Gridlines still clip correctly: they
771
- // live inside the plot view, which keeps its own `overflow: hidden`.
772
- protected readonly containerStyle = computed(
773
- () =>
774
- `position: relative; width: ${px(this.width())}; height: ${px(this.height())}; flex-shrink: 0;`,
775
- );
776
-
777
- protected readonly plotStyle = computed(
778
- () =>
779
- // `overflow: hidden` clips zoomed/panned marks — and the gridlines, which
780
- // are also children of this view — to the plot rectangle. The axis labels
781
- // live in the container (siblings of this plot view), so they are NOT
782
- // clipped here; out-of-window buffer labels are hidden individually
783
- // instead (see `#labelVisibility`). Harmless at rest — every series
784
- // already draws inside the plot bounds.
785
- `position: absolute; left: ${px(this.#yGutter())}; top: 0px; width: ${px(this.plotWidth())}; height: ${px(this.plotHeight())}; overflow: hidden;`,
786
- );
787
-
788
- /**
789
- * A zoomable chart emits tick VALUES just past each edge of the visible
790
- * window (ALIGNED_TICK_BUFFER) so the grid stays covered mid-pan without the
791
- * element count ever changing. Their gridlines are clipped by the plot's own
792
- * `overflow: hidden`, but their labels live outside the plot — so we hide the
793
- * strays here. Testing by VALUE (is the tick outside the visible domain?)
794
- * rather than by pixel keeps this independent of axis padding and guarantees
795
- * a legitimate edge label (value exactly on the domain bound) is never caught
796
- * by float drift. Returns a style fragment appended to the label; `visibility`
797
- * keeps the element in the layout (fixed count) and is a pure paint toggle, so
798
- * it is safe to update from a gesture-driven signal write (no tree mutation).
799
- *
800
- * Only zoomable charts generate buffer ticks, so this is a no-op otherwise —
801
- * gated on `zoomable()` so a non-zoomable chart's labels (including any
802
- * explicit `xTicks`/`yTicks` a caller placed outside the domain on purpose)
803
- * are never touched, byte-for-byte matching the pre-pan/zoom behavior.
804
- */
805
- #labelVisibility(value: number, domain: ChartDomain): string {
806
- if (!this.zoomable()) return '';
807
- return isValueOutsideDomain(value, domain) ? ' visibility: hidden;' : '';
808
- }
809
-
810
- // Horizontal gridlines: full-width rules at each y tick's pixel height. The
811
- // bottom-most in-window line is nudged up by its thickness so the plot's
812
- // `overflow: hidden` doesn't clip it (see {@link gridlineOffset}).
813
- protected readonly gridlines = computed(() => {
814
- const scale = this.yScale();
815
- const domain = this.#effectiveYDomain();
816
- const extent = this.plotHeight();
817
- return this.#resolvedYTicks().map((value) => ({
818
- style: `position: absolute; left: 0px; top: ${px(gridlineOffset(value, scale(value), domain, extent))}; width: 100%; height: ${GRIDLINE_THICKNESS}px;`,
819
- }));
820
- });
821
-
822
- // Vertical gridlines: full-height rules at each x tick's pixel position. The
823
- // right-most in-window line is nudged left by its thickness for the same
824
- // clip reason as the horizontal ones above.
825
- protected readonly verticalGridlines = computed(() => {
826
- const scale = this.xScale();
827
- const domain = this.#effectiveXDomain();
828
- const extent = this.plotWidth();
829
- return this.#resolvedXTicks().map((value) => ({
830
- style: `position: absolute; top: 0px; left: ${px(gridlineOffset(value, scale(value), domain, extent))}; width: ${GRIDLINE_THICKNESS}px; height: 100%;`,
831
- }));
832
- });
833
-
834
- protected readonly yLabels = computed(() => {
835
- const scale = this.yScale();
836
- const format = this.yTickFormat();
837
- const domain = this.#effectiveYDomain();
838
- // A y-title pushes the tick labels right, past its reserved strip.
839
- const left = this.yAxisLabel() ? Y_AXIS_LABEL_SPACE : 0;
840
- const width = this.yAxisWidth() - LABEL_GUTTER_GAP;
841
- return this.#resolvedYTicks().map((value) => ({
842
- text: format(value),
843
- style: `position: absolute; left: ${px(left)}; top: ${px(scale(value) - LABEL_FONT_HALF)}; width: ${px(width)};${this.#labelVisibility(value, domain)}`,
844
- }));
845
- });
846
-
847
- protected readonly xLabels = computed(() => {
848
- const scale = this.xScale();
849
- const format = this.xTickFormat();
850
- const domain = this.#effectiveXDomain();
851
- const gutter = this.#yGutter();
852
- const top = this.plotHeight() + X_LABEL_GAP;
853
- return this.#resolvedXTicks().map((value) => ({
854
- text: format(value),
855
- style: `position: absolute; top: ${px(top)}; left: ${px(gutter + scale(value) - X_LABEL_WIDTH / 2)}; width: ${px(X_LABEL_WIDTH)};${this.#labelVisibility(value, domain)}`,
856
- }));
857
- });
858
-
859
- // Y-title: a horizontal text box `plotHeight` wide, rotated -90° about its
860
- // center so it runs vertically. Rotation is visual only (it doesn't change the
861
- // layout box), so we position the *unrotated* box centered on the reserved left
862
- // strip and the plot's mid-height; after the turn it spans the full plot height.
863
- protected readonly yAxisLabelStyle = computed(() => {
864
- const h = this.plotHeight();
865
- const centerX = Y_AXIS_LABEL_SPACE / 2;
866
- const centerY = h / 2;
867
- const left = centerX - h / 2;
868
- const top = centerY - AXIS_LABEL_LINE_HEIGHT / 2;
869
- return (
870
- `position: absolute; left: ${px(left)}; top: ${px(top)}; ` +
871
- `width: ${px(h)}; height: ${px(AXIS_LABEL_LINE_HEIGHT)}; ` +
872
- `transform-origin: center; transform: rotate(-90deg);`
873
- );
874
- });
875
-
876
- // X-title: centered across the plot, in the band below the x tick labels.
877
- protected readonly xAxisLabelStyle = computed(
878
- () =>
879
- `position: absolute; left: ${px(this.#yGutter())}; top: ${px(this.plotHeight() + this.xAxisHeight())}; width: ${px(this.plotWidth())};`,
880
- );
881
-
882
- // The control cluster sits just inside the container's top-right corner, over
883
- // the plot. `CONTROL_INSET` = button width (28px) + a 6px margin.
884
- protected readonly zoomControlsStyle = computed(
885
- () => `position: absolute; top: 6px; left: ${px(this.width() - 34)};`,
886
- );
887
-
888
- // ---------------------------------------------------------------------------
889
- // Pan / zoom interaction
890
- //
891
- // A 1-finger drag pans and a 2-finger pinch zooms. `maxPointers(1)` on the pan
892
- // keeps it from firing during a pinch, so the two never fight for the touch.
893
- // Both gestures snapshot the visible window at their *start* and derive the new
894
- // window from the gesture's cumulative value (translation / scale), so there is
895
- // no per-frame drift. Callbacks only ever write the view signals — that
896
- // restyles existing nodes (the tick count is fixed while zoomable), never
897
- // mutates the element tree, which is unsafe inside a Lynx gesture worklet.
898
- // ---------------------------------------------------------------------------
899
-
900
- // Visible window captured at the start of the active pan/pinch (null between
901
- // gestures). Plain fields, not signals — they seed the math, nothing renders
902
- // from them directly.
903
- #panStartX: ChartDomain | null = null;
904
- #panStartY: ChartDomain | null = null;
905
- #pinchStartX: ChartDomain | null = null;
906
- #pinchStartY: ChartDomain | null = null;
907
- // Data value under the pinch focal point, held fixed on screen as the zoom runs.
908
- #pinchFocalX = 0;
909
- #pinchFocalY = 0;
910
-
911
- readonly #pan = new PanGesture()
912
- .maxPointers(1)
913
- // Registering onBegin is REQUIRED, not decorative. On BOTH iOS and Android the
914
- // native pan handler gates onStart *and* onEnd behind onBegin having fired
915
- // (`!_isInvokedBegin` / `!mIsInvokedBegin` early-return in
916
- // LynxPanGestureHandler.m / PanGestureHandler.java), and onBegin itself
917
- // no-ops unless a JS onBegin callback was registered (the enable flag defaults
918
- // off). Wire only onStart/onUpdate/onEnd and, on-device, onStart/onEnd NEVER
919
- // fire — so the start-of-gesture window snapshot below is never taken, #onPan
920
- // re-reads the *current* (already-moved) window every frame, and the pan
921
- // compounds into a runaway slide instead of tracking the finger 1:1 (the
922
- // reported "doesn't move the same distance / anchored to the middle" bug).
923
- // onUpdate has no such gate, which is why the pan fired at all but drifted.
924
- // Snapshotting here at touch-down also anchors the window at the exact zero
925
- // point of translationX — mapGestureEvent anchors its own origin on onBegin —
926
- // so the two stay in lockstep for precise 1:1 panning.
927
- /* v8 ignore start -- native pan/pinch callbacks: exercised via device gestures, not reachable in JSDOM */
928
- .onBegin(() => {
929
- // Unconditional: marks a fresh gesture start. Overwrites any stale snapshot
930
- // left behind if a previous gesture's onEnd was dropped (e.g. a cancel path).
931
- this.#panStartX = this.#effectiveXDomain();
932
- this.#panStartY = this.#effectiveYDomain();
933
- })
934
- // Fallback anchor for any platform that fires onStart without onBegin; guarded
935
- // so it keeps the earlier onBegin snapshot when both fire (nothing has panned
936
- // in between, so the value is identical either way).
937
- .onStart(() => {
938
- this.#panStartX ??= this.#effectiveXDomain();
939
- this.#panStartY ??= this.#effectiveYDomain();
940
- })
941
- .onUpdate((event) => this.#onPan(event))
942
- .onEnd(() => {
943
- this.#panStartX = null;
944
- this.#panStartY = null;
945
- });
946
-
947
- readonly #pinch = new PinchGesture()
948
- // Same begin-gating as pan (see above) — register onBegin so onStart can fire.
949
- // NOTE: Lynx's new-gesture system currently ships NO native pinch handler on
950
- // ANY platform (iOS/Android/Harmony all omit it from convertToGestureHandler),
951
- // so pinch never fires on-device today; zoom is driven by drag-to-pan and the
952
- // +/−/1:1 controls. This wiring is kept correct so pinch-to-zoom lights up the
953
- // moment a Lynx runtime adds pinch support.
954
- .onBegin(() => {
955
- // Intentionally empty: only needed to flip the native onBegin-enabled flag
956
- // so onStart is allowed to fire. The real focal snapshot happens in onStart.
957
- })
958
- .onStart((event) => this.#onPinchStart(event))
959
- .onUpdate((event) => this.#onPinch(event));
960
- /* v8 ignore stop */
961
-
962
- // Bound on the plot view when `zoomable()` is true. Pan and pinch recognize
963
- // simultaneously; `maxPointers(1)` keeps them from actually overlapping.
964
- protected readonly interactionGesture = Gesture.Simultaneous(
965
- this.#pan,
966
- this.#pinch,
967
- );
968
-
969
- // A stable empty gesture list bound when NOT zoomable. The directive treats an
970
- // empty array as "no gestures" (it detaches), and sharing one reference keeps
971
- // the binding from churning ngOnChanges every change detection (a fresh `[]`
972
- // literal would). Using this instead of `null` also satisfies the directive's
973
- // non-nullable GestureInput type.
974
- protected readonly noGesture: never[] = [];
975
-
976
- /**
977
- * v8 ignore start -- gesture-only path: driven by native pan/pinch events on-device
978
- */
979
- #onPan(event: PanGestureEvent): void {
980
- // Defensive anchor: onBegin/onStart normally snapshot the start window, but if
981
- // a platform ever delivered onUpdate without either, anchor on this first
982
- // update and hold it (the `??=` won't overwrite on later frames). Without a
983
- // fixed anchor the window would be re-read every frame and the pan would
984
- // compound — the exact failure the onBegin registration above prevents.
985
- this.#panStartX ??= this.#effectiveXDomain();
986
- this.#panStartY ??= this.#effectiveYDomain();
987
- const axes = this.zoomAxes();
988
- if (axes === 'x' || axes === 'xy') {
989
- const start = this.#panStartX ?? this.#effectiveXDomain();
990
- const rangePx = this.plotWidth() - this.#padLeft() - this.#padRight();
991
- const dataPerPx = rangePx !== 0 ? (start[1] - start[0]) / rangePx : 0;
992
- // Dragging right (translationX > 0) reveals earlier (lower) x, so the
993
- // window slides toward lower values.
994
- this.#setViewX(
995
- panWindow(this.xDomain(), start, -event.translationX * dataPerPx),
996
- );
997
- }
998
- if (axes === 'y' || axes === 'xy') {
999
- const start = this.#panStartY ?? this.#effectiveYDomain();
1000
- const rangePx = this.plotHeight() - this.#padTop() - this.#padBottom();
1001
- const dataPerPx = rangePx !== 0 ? (start[1] - start[0]) / rangePx : 0;
1002
- // The y-scale is flipped (screen y grows downward), so dragging DOWN
1003
- // (translationY > 0) reveals higher values → window slides up.
1004
- this.#setViewY(
1005
- panWindow(this.yDomain(), start, event.translationY * dataPerPx),
1006
- );
1007
- }
1008
- }
1009
-
1010
- #onPinchStart(event: PinchGestureEvent): void {
1011
- const startX = this.#effectiveXDomain();
1012
- const startY = this.#effectiveYDomain();
1013
- this.#pinchStartX = startX;
1014
- this.#pinchStartY = startY;
1015
- // The focal point is element-relative (to the plot view) so it is already in
1016
- // plot-local pixels; fall back to the plot center if the native payload omits
1017
- // it. Invert through the start scale to get the data value to pin.
1018
- const focalPx = event.params?.['x'];
1019
- const focalPy = event.params?.['y'];
1020
- const fx = typeof focalPx === 'number' ? focalPx : this.plotWidth() / 2;
1021
- const fy = typeof focalPy === 'number' ? focalPy : this.plotHeight() / 2;
1022
- this.#pinchFocalX = invertLinear(
1023
- startX,
1024
- [this.#padLeft(), this.plotWidth() - this.#padRight()],
1025
- fx,
1026
- );
1027
- this.#pinchFocalY = invertLinear(
1028
- startY,
1029
- [this.plotHeight() - this.#padBottom(), this.#padTop()],
1030
- fy,
1031
- );
1032
- }
1033
-
1034
- #onPinch(event: PinchGestureEvent): void {
1035
- const factor = event.scale || 1;
1036
- const axes = this.zoomAxes();
1037
- if (axes === 'x' || axes === 'xy') {
1038
- const start = this.#pinchStartX ?? this.#effectiveXDomain();
1039
- this.#setViewX(
1040
- zoomWindow(
1041
- this.xDomain(),
1042
- start,
1043
- factor,
1044
- this.#pinchFocalX,
1045
- this.maxZoom(),
1046
- this.minZoom(),
1047
- ),
1048
- );
1049
- }
1050
- if (axes === 'y' || axes === 'xy') {
1051
- const start = this.#pinchStartY ?? this.#effectiveYDomain();
1052
- this.#setViewY(
1053
- zoomWindow(
1054
- this.yDomain(),
1055
- start,
1056
- factor,
1057
- this.#pinchFocalY,
1058
- this.maxZoom(),
1059
- this.minZoom(),
1060
- ),
1061
- );
1062
- }
1063
- }
1064
- /* v8 ignore stop */
1065
-
1066
- /**
1067
- * Write a new visible window, collapsing it back to `null` (rest) when it spans
1068
- * essentially the whole domain, so a fully zoomed-out chart reverts to its base
1069
- * tick behavior.
1070
- */
1071
- #setViewX(window: ChartDomain): void {
1072
- this.#viewXDomain.set(
1073
- this.#isFullSpan(window, this.xDomain()) ? null : window,
1074
- );
1075
- }
1076
- #setViewY(window: ChartDomain): void {
1077
- this.#viewYDomain.set(
1078
- this.#isFullSpan(window, this.yDomain()) ? null : window,
1079
- );
1080
- }
1081
- #isFullSpan(window: ChartDomain, base: ChartDomain): boolean {
1082
- const baseSpan = base[1] - base[0];
1083
- if (baseSpan === 0) return true;
1084
- return window[1] - window[0] >= baseSpan * (1 - 1e-6);
1085
- }
1086
-
1087
- // --- Public zoom API (drives the controls; also callable via a template ref) ---
1088
-
1089
- /**
1090
- * Zoom in by `zoomStep`, centered on the plot.
1091
- */
1092
- zoomIn(): void {
1093
- this.#zoomByControls(this.zoomStep());
1094
- }
1095
- /**
1096
- * Zoom out by `zoomStep`, centered on the plot.
1097
- */
1098
- zoomOut(): void {
1099
- this.#zoomByControls(1 / this.zoomStep());
1100
- }
1101
- /**
1102
- * Reset to the fully zoomed-out view.
1103
- */
1104
- resetZoom(): void {
1105
- this.#viewXDomain.set(null);
1106
- this.#viewYDomain.set(null);
1107
- }
1108
-
1109
- #zoomByControls(factor: number): void {
1110
- const axes = this.zoomAxes();
1111
- if (axes === 'x' || axes === 'xy') {
1112
- const current = this.#effectiveXDomain();
1113
- const focal = (current[0] + current[1]) / 2;
1114
- this.#setViewX(
1115
- zoomWindow(
1116
- this.xDomain(),
1117
- current,
1118
- factor,
1119
- focal,
1120
- this.maxZoom(),
1121
- this.minZoom(),
1122
- ),
1123
- );
1124
- }
1125
- if (axes === 'y' || axes === 'xy') {
1126
- const current = this.#effectiveYDomain();
1127
- const focal = (current[0] + current[1]) / 2;
1128
- this.#setViewY(
1129
- zoomWindow(
1130
- this.yDomain(),
1131
- current,
1132
- factor,
1133
- focal,
1134
- this.maxZoom(),
1135
- this.minZoom(),
1136
- ),
1137
- );
1138
- }
1139
- }
1140
- }