@blotch/dolan 0.0.0-stage → 22.2.1
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 +22 -0
- package/dist/commands/add.js +132 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/add.test.d.ts +1 -0
- package/dist/commands/add.test.js +209 -0
- package/dist/commands/add.test.js.map +1 -0
- package/dist/commands/diff.d.ts +13 -0
- package/dist/commands/diff.js +155 -0
- package/dist/commands/diff.js.map +1 -0
- package/dist/commands/diff.test.d.ts +1 -0
- package/dist/commands/diff.test.js +327 -0
- package/dist/commands/diff.test.js.map +1 -0
- package/dist/commands/doctor.d.ts +15 -0
- package/dist/commands/doctor.js +248 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/doctor.test.d.ts +1 -0
- package/dist/commands/doctor.test.js +301 -0
- package/dist/commands/doctor.test.js.map +1 -0
- package/dist/commands/eject.d.ts +11 -0
- package/dist/commands/eject.js +55 -0
- package/dist/commands/eject.js.map +1 -0
- package/dist/commands/eject.test.d.ts +1 -0
- package/dist/commands/eject.test.js +151 -0
- package/dist/commands/eject.test.js.map +1 -0
- package/dist/commands/info.d.ts +1 -0
- package/dist/commands/info.js +73 -0
- package/dist/commands/info.js.map +1 -0
- package/dist/commands/info.test.d.ts +1 -0
- package/dist/commands/info.test.js +143 -0
- package/dist/commands/info.test.js.map +1 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +98 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/init.test.d.ts +1 -0
- package/dist/commands/init.test.js +154 -0
- package/dist/commands/init.test.js.map +1 -0
- package/dist/commands/list.d.ts +16 -0
- package/dist/commands/list.js +124 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/list.test.d.ts +1 -0
- package/dist/commands/list.test.js +285 -0
- package/dist/commands/list.test.js.map +1 -0
- package/dist/commands/outdated.d.ts +13 -0
- package/dist/commands/outdated.js +116 -0
- package/dist/commands/outdated.js.map +1 -0
- package/dist/commands/outdated.test.d.ts +1 -0
- package/dist/commands/outdated.test.js +208 -0
- package/dist/commands/outdated.test.js.map +1 -0
- package/dist/commands/remove.d.ts +18 -0
- package/dist/commands/remove.js +161 -0
- package/dist/commands/remove.js.map +1 -0
- package/dist/commands/remove.test.d.ts +1 -0
- package/dist/commands/remove.test.js +361 -0
- package/dist/commands/remove.test.js.map +1 -0
- package/dist/commands/theme.d.ts +1 -0
- package/dist/commands/theme.js +66 -0
- package/dist/commands/theme.js.map +1 -0
- package/dist/commands/theme.test.d.ts +1 -0
- package/dist/commands/theme.test.js +133 -0
- package/dist/commands/theme.test.js.map +1 -0
- package/dist/commands/update.d.ts +31 -0
- package/dist/commands/update.js +601 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/update.test.d.ts +1 -0
- package/dist/commands/update.test.js +1233 -0
- package/dist/commands/update.test.js.map +1 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.js +19 -0
- package/dist/config.js.map +1 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +48 -0
- package/dist/config.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -0
- package/dist/lockfile.d.ts +21 -0
- package/dist/lockfile.js +39 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/registry.d.ts +14 -0
- package/dist/registry.js +85 -0
- package/dist/registry.js.map +1 -0
- package/dist/registry.test.d.ts +1 -0
- package/dist/registry.test.js +95 -0
- package/dist/registry.test.js.map +1 -0
- package/dist/test-utils.d.ts +16 -0
- package/dist/test-utils.js +65 -0
- package/dist/test-utils.js.map +1 -0
- package/dist/ui/components/accordion/accordion.test.ts +194 -0
- package/dist/ui/components/accordion/accordion.ts +173 -0
- package/dist/ui/components/accordion/index.ts +6 -0
- package/dist/ui/components/action-sheet/action-sheet.test.ts +211 -0
- package/dist/ui/components/action-sheet/action-sheet.ts +353 -0
- package/dist/ui/components/action-sheet/index.ts +6 -0
- package/dist/ui/components/alert/alert.test.ts +48 -0
- package/dist/ui/components/alert/alert.ts +104 -0
- package/dist/ui/components/alert/index.ts +1 -0
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +141 -0
- package/dist/ui/components/alert-dialog/alert-dialog.ts +243 -0
- package/dist/ui/components/alert-dialog/index.ts +7 -0
- package/dist/ui/components/area-chart/area-chart-render.test.ts +121 -0
- package/dist/ui/components/area-chart/area-chart.test.ts +158 -0
- package/dist/ui/components/area-chart/area-chart.ts +349 -0
- package/dist/ui/components/area-chart/index.ts +6 -0
- package/dist/ui/components/aspect-ratio/aspect-ratio.test.ts +10 -0
- package/dist/ui/components/aspect-ratio/aspect-ratio.ts +28 -0
- package/dist/ui/components/aspect-ratio/index.ts +1 -0
- package/dist/ui/components/avatar/avatar.test.ts +69 -0
- package/dist/ui/components/avatar/avatar.ts +131 -0
- package/dist/ui/components/avatar/index.ts +1 -0
- package/dist/ui/components/badge/badge.test.ts +49 -0
- package/dist/ui/components/badge/badge.ts +97 -0
- package/dist/ui/components/badge/index.ts +1 -0
- package/dist/ui/components/bar-chart/bar-chart-render.test.ts +174 -0
- package/dist/ui/components/bar-chart/bar-chart.test.ts +73 -0
- package/dist/ui/components/bar-chart/bar-chart.ts +327 -0
- package/dist/ui/components/bar-chart/index.ts +6 -0
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +163 -0
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +339 -0
- package/dist/ui/components/bottom-sheet/index.ts +1 -0
- package/dist/ui/components/button/button-render.test.ts +113 -0
- package/dist/ui/components/button/button.test.ts +70 -0
- package/dist/ui/components/button/button.ts +170 -0
- package/dist/ui/components/button/index.ts +6 -0
- package/dist/ui/components/button-group/button-group-render.test.ts +146 -0
- package/dist/ui/components/button-group/button-group.test.ts +54 -0
- package/dist/ui/components/button-group/button-group.ts +209 -0
- package/dist/ui/components/button-group/index.ts +8 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +174 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +96 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart.ts +355 -0
- package/dist/ui/components/candlestick-chart/index.ts +7 -0
- package/dist/ui/components/card/card.test.ts +110 -0
- package/dist/ui/components/card/card.ts +162 -0
- package/dist/ui/components/card/index.ts +8 -0
- package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +188 -0
- package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +549 -0
- package/dist/ui/components/cartesian-chart/cartesian-chart.ts +1140 -0
- package/dist/ui/components/cartesian-chart/index.ts +18 -0
- package/dist/ui/components/checkbox/checkbox.test.ts +59 -0
- package/dist/ui/components/checkbox/checkbox.ts +96 -0
- package/dist/ui/components/checkbox/index.ts +1 -0
- package/dist/ui/components/collapsible/collapsible.test.ts +112 -0
- package/dist/ui/components/collapsible/collapsible.ts +122 -0
- package/dist/ui/components/collapsible/index.ts +5 -0
- package/dist/ui/components/dialog/dialog.test.ts +157 -0
- package/dist/ui/components/dialog/dialog.ts +248 -0
- package/dist/ui/components/dialog/index.ts +7 -0
- package/dist/ui/components/empty-state/empty-state.test.ts +60 -0
- package/dist/ui/components/empty-state/empty-state.ts +46 -0
- package/dist/ui/components/empty-state/index.ts +1 -0
- package/dist/ui/components/icon/icon.test.ts +52 -0
- package/dist/ui/components/icon/icon.ts +54 -0
- package/dist/ui/components/icon/icons.ts +569 -0
- package/dist/ui/components/icon/index.ts +2 -0
- package/dist/ui/components/input/index.ts +1 -0
- package/dist/ui/components/input/input.test.ts +134 -0
- package/dist/ui/components/input/input.ts +189 -0
- package/dist/ui/components/label/index.ts +1 -0
- package/dist/ui/components/label/label.test.ts +38 -0
- package/dist/ui/components/label/label.ts +24 -0
- package/dist/ui/components/line-chart/index.ts +6 -0
- package/dist/ui/components/line-chart/line-chart-render.test.ts +121 -0
- package/dist/ui/components/line-chart/line-chart.test.ts +81 -0
- package/dist/ui/components/line-chart/line-chart.ts +273 -0
- package/dist/ui/components/list/index.ts +1 -0
- package/dist/ui/components/list/list.test.ts +35 -0
- package/dist/ui/components/list/list.ts +71 -0
- package/dist/ui/components/nav-drawer/index.ts +8 -0
- package/dist/ui/components/nav-drawer/nav-drawer.test.ts +267 -0
- package/dist/ui/components/nav-drawer/nav-drawer.ts +504 -0
- package/dist/ui/components/pagination/index.ts +1 -0
- package/dist/ui/components/pagination/pagination.test.ts +96 -0
- package/dist/ui/components/pagination/pagination.ts +147 -0
- package/dist/ui/components/progress/index.ts +1 -0
- package/dist/ui/components/progress/progress.test.ts +71 -0
- package/dist/ui/components/progress/progress.ts +118 -0
- package/dist/ui/components/radio-group/index.ts +1 -0
- package/dist/ui/components/radio-group/radio-group.test.ts +115 -0
- package/dist/ui/components/radio-group/radio-group.ts +129 -0
- package/dist/ui/components/scatter-chart/index.ts +6 -0
- package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +157 -0
- package/dist/ui/components/scatter-chart/scatter-chart.test.ts +57 -0
- package/dist/ui/components/scatter-chart/scatter-chart.ts +277 -0
- package/dist/ui/components/scroll-area/index.ts +1 -0
- package/dist/ui/components/scroll-area/scroll-area.test.ts +40 -0
- package/dist/ui/components/scroll-area/scroll-area.ts +73 -0
- package/dist/ui/components/select/index.ts +1 -0
- package/dist/ui/components/select/select.test.ts +107 -0
- package/dist/ui/components/select/select.ts +146 -0
- package/dist/ui/components/separator/index.ts +1 -0
- package/dist/ui/components/separator/separator.test.ts +26 -0
- package/dist/ui/components/separator/separator.ts +45 -0
- package/dist/ui/components/sheet/index.ts +7 -0
- package/dist/ui/components/sheet/sheet.test.ts +44 -0
- package/dist/ui/components/sheet/sheet.ts +114 -0
- package/dist/ui/components/skeleton/index.ts +1 -0
- package/dist/ui/components/skeleton/skeleton.test.ts +13 -0
- package/dist/ui/components/skeleton/skeleton.ts +45 -0
- package/dist/ui/components/spinner/index.ts +1 -0
- package/dist/ui/components/spinner/spinner.test.ts +39 -0
- package/dist/ui/components/spinner/spinner.ts +83 -0
- package/dist/ui/components/switch/index.ts +1 -0
- package/dist/ui/components/switch/switch.test.ts +63 -0
- package/dist/ui/components/switch/switch.ts +98 -0
- package/dist/ui/components/tabs/index.ts +1 -0
- package/dist/ui/components/tabs/tabs.test.ts +199 -0
- package/dist/ui/components/tabs/tabs.ts +317 -0
- package/dist/ui/components/textarea/index.ts +1 -0
- package/dist/ui/components/textarea/textarea.test.ts +138 -0
- package/dist/ui/components/textarea/textarea.ts +221 -0
- package/dist/ui/components/toast/index.ts +9 -0
- package/dist/ui/components/toast/toast-state.test.ts +54 -0
- package/dist/ui/components/toast/toast-state.ts +46 -0
- package/dist/ui/components/toast/toast.test.ts +342 -0
- package/dist/ui/components/toast/toast.ts +527 -0
- package/dist/ui/components/toggle/index.ts +7 -0
- package/dist/ui/components/toggle/toggle.test.ts +186 -0
- package/dist/ui/components/toggle/toggle.ts +218 -0
- package/dist/ui/components/typography/index.ts +5 -0
- package/dist/ui/components/typography/typography.test.ts +55 -0
- package/dist/ui/components/typography/typography.ts +44 -0
- package/dist/ui/theme/dark.css +41 -0
- package/dist/ui/theme/default.css +56 -0
- package/dist/ui/theme/tailwind-plugin.test.ts +124 -0
- package/dist/ui/theme/tailwind-plugin.ts +151 -0
- package/dist/ui/utils/animate.test.ts +324 -0
- package/dist/ui/utils/animate.ts +544 -0
- package/dist/ui/utils/cn.test.ts +27 -0
- package/dist/ui/utils/cn.ts +6 -0
- package/dist/utils/analyze.d.ts +32 -0
- package/dist/utils/analyze.js +109 -0
- package/dist/utils/analyze.js.map +1 -0
- package/dist/utils/analyze.test.d.ts +1 -0
- package/dist/utils/analyze.test.js +146 -0
- package/dist/utils/analyze.test.js.map +1 -0
- package/dist/utils/animate.d.ts +210 -0
- package/dist/utils/animate.js +423 -0
- package/dist/utils/animate.js.map +1 -0
- package/dist/utils/animate.test.d.ts +1 -0
- package/dist/utils/animate.test.js +365 -0
- package/dist/utils/animate.test.js.map +1 -0
- package/dist/utils/banner.d.ts +1 -0
- package/dist/utils/banner.js +15 -0
- package/dist/utils/banner.js.map +1 -0
- package/dist/utils/banner.test.d.ts +1 -0
- package/dist/utils/banner.test.js +29 -0
- package/dist/utils/banner.test.js.map +1 -0
- package/dist/utils/cn.d.ts +2 -0
- package/dist/utils/cn.js +6 -0
- package/dist/utils/cn.js.map +1 -0
- package/dist/utils/cn.test.d.ts +1 -0
- package/dist/utils/cn.test.js +21 -0
- package/dist/utils/cn.test.js.map +1 -0
- package/dist/utils/format.d.ts +31 -0
- package/dist/utils/format.js +95 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/format.test.d.ts +1 -0
- package/dist/utils/format.test.js +71 -0
- package/dist/utils/format.test.js.map +1 -0
- package/dist/utils/logger.d.ts +6 -0
- package/dist/utils/logger.js +16 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/logger.test.d.ts +1 -0
- package/dist/utils/logger.test.js +36 -0
- package/dist/utils/logger.test.js.map +1 -0
- package/dist/utils/resolve-paths.d.ts +12 -0
- package/dist/utils/resolve-paths.js +29 -0
- package/dist/utils/resolve-paths.js.map +1 -0
- package/dist/utils/resolve-paths.test.d.ts +1 -0
- package/dist/utils/resolve-paths.test.js +52 -0
- package/dist/utils/resolve-paths.test.js.map +1 -0
- package/package.json +56 -4
- package/README.md +0 -3
|
@@ -0,0 +1,1140 @@
|
|
|
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
|
+
}
|