@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
@@ -2,354 +2,181 @@ import {
2
2
  Component,
3
3
  ViewEncapsulation,
4
4
  computed,
5
- inject,
6
5
  input,
7
6
  output,
8
7
  } from '@angular/core';
9
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
10
-
8
+ import { defineChart, link, rect } from '@tanstack/charts/universal';
9
+ import type {
10
+ ChartPoint,
11
+ ChartTooltipContent,
12
+ ChartTooltipContentContext,
13
+ } from '@tanstack/charts/types';
14
+
15
+ import { UiChart, tooltip } from '../chart/chart';
11
16
  import {
12
- type ChartPadding,
13
- UiCartesianChart,
14
- niceScale,
15
- } from '../cartesian-chart/cartesian-chart';
16
-
17
- // ---------------------------------------------------------------------------
18
- // Types
19
- // ---------------------------------------------------------------------------
20
-
21
- /**
22
- * One OHLC period: the `open`, `high`, `low`, and `close` prices at position
23
- * `x`. `x` is a plain number (a period index), not a `Date` — Lynx has no `Intl`
24
- * to format dates, so the whole chart system keeps x numeric (see {@link
25
- * ChartPoint}). Format the axis with `xTickFormat` if you need labels.
26
- */
27
- export type CandlestickPoint = {
28
- x: number;
29
- open: number;
30
- high: number;
31
- low: number;
32
- close: number;
33
- };
34
-
35
- /** One candle after projection into plot-local pixel space. */
36
- type ProjectedCandle = {
37
- /** Center x of the candle, in px. */
38
- cx: number;
39
- openY: number;
40
- highY: number;
41
- lowY: number;
42
- closeY: number;
43
- /** `true` when the period closed at or above its open (a rising candle). */
44
- up: boolean;
17
+ type CartesianPresetOptions,
18
+ PRESET_CHART_BINDINGS,
19
+ UiCartesianPreset,
20
+ minSpacing,
21
+ presetAxes,
22
+ } from '../chart/chart-presets';
23
+
24
+ /** One period's open, high, low and close (OHLC) at x. */
25
+ export type CandlestickDatum = {
26
+ readonly x: number;
27
+ readonly open: number;
28
+ readonly high: number;
29
+ readonly low: number;
30
+ readonly close: number;
45
31
  };
46
32
 
47
- /** An axis-aligned rectangle in plot-local pixel space. */
48
- type Rect = { left: number; top: number; width: number; height: number };
33
+ export type CandlestickChartOptions = {
34
+ readonly data: readonly CandlestickDatum[];
35
+ /** Color of a candle that closed at or above its open. */
36
+ readonly upColor?: string;
37
+ /** Color of a candle that closed below its open. */
38
+ readonly downColor?: string;
39
+ /** Width of the high–low wick in px. */
40
+ readonly wickWidth?: number;
41
+ /** Body width as a fraction of the space each candle has, `0`–`1`. */
42
+ readonly bodyWidthRatio?: number;
43
+ } & CartesianPresetOptions;
49
44
 
50
- /**
51
- * Render geometry for one candle: a thin vertical **wick** (high→low) and a
52
- * **body** (open↔close), both plain axis-aligned rectangles. `up` is echoed from
53
- * the input so the caller can pick the fill color.
54
- */
55
- export type CandleShape = {
56
- wick: Rect;
57
- body: Rect;
58
- up: boolean;
59
- };
45
+ const isUp = (d: CandlestickDatum): boolean => d.close >= d.open;
60
46
 
61
- // ---------------------------------------------------------------------------
62
- // Pure geometry (exported so it can be unit-tested without Angular)
63
- // ---------------------------------------------------------------------------
47
+ const isCandle = (datum: unknown): datum is CandlestickDatum =>
48
+ typeof datum === 'object' &&
49
+ datum !== null &&
50
+ 'open' in datum &&
51
+ 'close' in datum;
64
52
 
65
53
  /**
66
- * Turns projected candles — already in plot-local pixel space — into wick/body
67
- * rectangles ready to render as `<view>`s. Unlike the line and area series, a
68
- * candlestick needs no rotation or fill rasterization: each mark is a plain
69
- * axis-aligned box, so this is a straight geometric mapping.
70
- *
71
- * Screen y grows *downward*, so a higher price projects to a *smaller* y. Hence
72
- * the wick's top is `min(highY, lowY)` and the body's top is `min(openY,
73
- * closeY)` — taking the pixel min picks the visually-higher (larger-value) edge.
74
- *
75
- * A doji (open === close) would give a zero-height body that renders invisibly,
76
- * so the body height is clamped to `minBodyHeight` (a thin line, like a bar).
77
- * Wick and body are both centered on the candle's `cx`.
78
- *
79
- * @param bodyWidth width of the body rectangle in px (the wick stays `wickWidth`).
80
- * @param minBodyHeight floor for the body height so a doji is still visible.
54
+ * The definition `<ui-candlestick-chart>` renders: a wick from low to high and
55
+ * a body from open to close per period, colored by direction. The tooltip lists
56
+ * all four prices.
81
57
  */
82
- export const computeCandles = (
83
- candles: readonly ProjectedCandle[],
84
- bodyWidth: number,
85
- wickWidth = 1,
86
- minBodyHeight = 1,
87
- ): CandleShape[] =>
88
- candles.map((c) => {
89
- const bodyTop = Math.min(c.openY, c.closeY);
90
- const bodyHeight = Math.max(Math.abs(c.closeY - c.openY), minBodyHeight);
91
- const wickTop = Math.min(c.highY, c.lowY);
92
- const wickHeight = Math.abs(c.lowY - c.highY);
93
- return {
94
- wick: {
95
- left: c.cx - wickWidth / 2,
96
- top: wickTop,
97
- width: wickWidth,
98
- height: wickHeight,
99
- },
100
- body: {
101
- left: c.cx - bodyWidth / 2,
102
- top: bodyTop,
103
- width: bodyWidth,
104
- height: bodyHeight,
105
- },
106
- up: c.up,
107
- };
58
+ export const candlestickChartDefinition = (
59
+ options: CandlestickChartOptions,
60
+ ) => {
61
+ const {
62
+ data,
63
+ upColor = 'rgba(34, 197, 94, 1)',
64
+ downColor = 'rgba(239, 68, 68, 1)',
65
+ wickWidth = 1,
66
+ bodyWidthRatio = 0.6,
67
+ } = options;
68
+ const xs = data.map((d) => d.x);
69
+ const spacing = minSpacing(xs);
70
+ const half = (Math.min(1, Math.max(0, bodyWidthRatio)) * spacing) / 2;
71
+ return defineChart({
72
+ marks: [
73
+ link(data, {
74
+ x1: 'x',
75
+ y1: 'low',
76
+ x2: 'x',
77
+ y2: 'high',
78
+ stroke: (d) => (isUp(d) ? upColor : downColor),
79
+ strokeWidth: wickWidth,
80
+ }),
81
+ rect(data, {
82
+ x1: (d) => d.x - half,
83
+ x2: (d) => d.x + half,
84
+ y1: 'open',
85
+ y2: 'close',
86
+ // rect's fill is a single color, so direction goes through the color
87
+ // scale below.
88
+ color: (d) => (isUp(d) ? 'up' : 'down'),
89
+ }),
90
+ ],
91
+ scales: presetAxes(options, {
92
+ x: xs,
93
+ y: data.flatMap((d) => [d.low, d.high]),
94
+ // Half a slot of room either side so the edge candles fit.
95
+ xMargin: spacing / 2,
96
+ }),
97
+ color: { domain: ['up', 'down'], range: [upColor, downColor] },
98
+ tooltip:
99
+ options.tooltip === false
100
+ ? false
101
+ : {
102
+ use: tooltip,
103
+ // Typed for any point, not just candles: a definition built on
104
+ // this one can add marks (a moving average), whose points get a
105
+ // plain x/y tooltip.
106
+ content: (
107
+ points: readonly ChartPoint[],
108
+ context: ChartTooltipContentContext,
109
+ ): ChartTooltipContent => {
110
+ const point = points[0];
111
+ const d = point?.datum;
112
+ if (!point || !isCandle(d)) {
113
+ return {
114
+ title: point ? context.formatX(point.xValue) : undefined,
115
+ rows: points.map((p) => ({
116
+ label: context.yLabel,
117
+ value: context.formatY(p.yValue),
118
+ })),
119
+ };
120
+ }
121
+ return {
122
+ title: String(d.x),
123
+ color: isUp(d) ? upColor : downColor,
124
+ rows: [
125
+ { label: 'Open', value: String(d.open) },
126
+ { label: 'High', value: String(d.high) },
127
+ { label: 'Low', value: String(d.low) },
128
+ { label: 'Close', value: String(d.close) },
129
+ ],
130
+ };
131
+ },
132
+ },
108
133
  });
109
-
110
- // ---------------------------------------------------------------------------
111
- // Rendering helpers (mirror the equivalents in line-chart.ts / area-chart.ts —
112
- // kept local so this module stays self-contained when scaffolded by `dolan add`)
113
- // ---------------------------------------------------------------------------
114
-
115
- /**
116
- * Formats a pixel value for an inline style, avoiding scientific notation.
117
- */
118
- const px = (n: number): string => `${n.toFixed(2)}px`;
119
-
120
- /**
121
- * Default axis label formatter — plain JS only (no `Intl`, absent in Lynx).
122
- */
123
- const defaultTickFormat = (value: number): string =>
124
- String(Math.round(value * 100) / 100);
125
-
126
- // Fallback body width (px) when there is only a single candle, so there is no
127
- // neighbor to derive a spacing from. Matches the React source's `font(8)`.
128
- const SINGLE_CANDLE_BODY_WIDTH = 8;
134
+ };
129
135
 
130
136
  /**
131
- * A single candlestick series drawn inside a `<ui-cartesian-chart>`. Reads the
132
- * shared scales from the parent chart via DI and renders, per period, a wick
133
- * `<view>` (high→low) and a body `<view>` (open↔close), colored by direction.
137
+ * An OHLC candlestick chart. For a moving average or volume bars over the
138
+ * candles, build a definition with `link`, `rect` and `lineY` and render it
139
+ * with `<ui-chart>`.
134
140
  *
135
- * ## Why the host is `position: absolute; width/height: 100%`
136
- * Identical to `UiLineSeries`: Lynx resolves an absolute child against its
137
- * **direct parent** (there is no `position: static`), so the host itself must be
138
- * the plot-sized containing block. Every wick and body is a direct child of it.
139
- *
140
- * ## Differentiating up vs down
141
- * On a color screen, the fill color carries direction — up candles use
142
- * `upColor`, down candles `downColor`. (The React original also supported SVG
143
- * *fill patterns* for e-ink displays where color can't be relied on; Lynx has
144
- * no SVG patterns, so that is dropped in favor of solid color.)
145
- */
146
- @Component({
147
- selector: 'ui-candlestick-series',
148
- standalone: true,
149
- imports: [LYNX_ELEMENTS],
150
- encapsulation: ViewEncapsulation.None,
151
- host: {
152
- style:
153
- 'position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;',
154
- '[class]': 'userClass()',
155
- },
156
- template: `
157
- <!-- Wick first so the body paints on top of it (Lynx paint order follows
158
- source order; z-index is unnecessary). The body is the tap target. -->
159
- @for (candle of candles(); track $index) {
160
- <view [style]="candle.wickStyle"></view>
161
- <view
162
- [style]="candle.bodyStyle"
163
- (bindtap)="onCandleTap(candle.point)"
164
- ></view>
165
- }
166
- `,
167
- })
168
- export class UiCandlestickSeries {
169
- readonly #chart = inject(UiCartesianChart);
170
-
171
- readonly data = input.required<CandlestickPoint[]>();
172
- /** Fill for rising candles (close ≥ open). Fixed data hue, not theme chrome. */
173
- readonly upColor = input('rgba(34, 197, 94, 1)');
174
- /** Fill for falling candles (close < open). */
175
- readonly downColor = input('rgba(239, 68, 68, 1)');
176
- /** Width of the high–low wick in px. */
177
- readonly wickWidth = input(1);
178
- /** Body width as a fraction of the spacing between adjacent candles. 0.6
179
- * leaves a ~40% gap between bodies, the conventional candlestick look. */
180
- readonly bodyWidthRatio = input(0.6);
181
- readonly userClass = input<string>('', { alias: 'class' });
182
-
183
- readonly candleTap = output<CandlestickPoint>();
184
-
185
- // Project each OHLC point into plot-local pixel space via the shared scales.
186
- // Reading the chart's scale signals here makes this recompute whenever the
187
- // domain or plot size changes.
188
- readonly #projected = computed(() => {
189
- const scaleX = this.#chart.xScale();
190
- const scaleY = this.#chart.yScale();
191
- return this.data().map((point) => ({
192
- cx: scaleX(point.x),
193
- openY: scaleY(point.open),
194
- highY: scaleY(point.high),
195
- lowY: scaleY(point.low),
196
- closeY: scaleY(point.close),
197
- up: point.close >= point.open,
198
- point,
199
- }));
200
- });
201
-
202
- // Body width = a fraction of the pixel spacing between candle centers, so
203
- // bodies scale with the plot and never overlap. With one candle there is no
204
- // spacing to measure, so fall back to a fixed width.
205
- readonly #bodyWidth = computed(() => {
206
- const projected = this.#projected();
207
- const n = projected.length;
208
- if (n < 2) return SINGLE_CANDLE_BODY_WIDTH;
209
- const span = Math.abs(projected[n - 1].cx - projected[0].cx);
210
- return (span / (n - 1)) * this.bodyWidthRatio();
211
- });
212
-
213
- protected readonly candles = computed(() => {
214
- const projected = this.#projected();
215
- const up = this.upColor();
216
- const down = this.downColor();
217
- // computeCandles works purely from the projected pixel fields; zip its output
218
- // back with each source point so the body's tap can emit the original data.
219
- return computeCandles(projected, this.#bodyWidth(), this.wickWidth()).map(
220
- (shape, i) => {
221
- const fill = shape.up ? up : down;
222
- return {
223
- point: projected[i].point,
224
- wickStyle:
225
- `position: absolute; left: ${px(shape.wick.left)}; top: ${px(shape.wick.top)}; ` +
226
- `width: ${px(shape.wick.width)}; height: ${px(shape.wick.height)}; ` +
227
- `background-color: ${fill};`,
228
- bodyStyle:
229
- `position: absolute; left: ${px(shape.body.left)}; top: ${px(shape.body.top)}; ` +
230
- `width: ${px(shape.body.width)}; height: ${px(shape.body.height)}; ` +
231
- `background-color: ${fill};`,
232
- };
233
- },
234
- );
235
- });
236
-
237
- protected onCandleTap(point: CandlestickPoint): void {
238
- this.candleTap.emit(point);
239
- }
240
- }
241
-
242
- /**
243
- * A ready-to-use candlestick (OHLC) chart for the common single-series case.
244
- * Composes a `<ui-cartesian-chart>` with one `<ui-candlestick-series>` and
245
- * auto-derives rounded axis domains + ticks from the data. For a moving-average
246
- * line on top, or full control over the axes, use `<ui-cartesian-chart>` with a
247
- * `<ui-candlestick-series>` (and optionally a `<ui-line-series>`) directly.
141
+ * ```html
142
+ * <ui-candlestick-chart [data]="prices" [zoomable]="true" zoomAxes="x" />
143
+ * ```
248
144
  */
249
145
  @Component({
250
146
  selector: 'ui-candlestick-chart',
251
- standalone: true,
252
- imports: [LYNX_ELEMENTS, UiCartesianChart, UiCandlestickSeries],
147
+ imports: [UiChart],
253
148
  encapsulation: ViewEncapsulation.None,
254
149
  template: `
255
- <ui-cartesian-chart
256
- [xDomain]="xAxis().domain"
257
- [yDomain]="yAxis().domain"
258
- [xTicks]="xAxis().ticks"
259
- [yTicks]="yAxis().ticks"
260
- [width]="width()"
261
- [height]="height()"
262
- [tickCount]="tickCount()"
263
- [showXGrid]="showXGrid()"
264
- [xAxisLabel]="xAxisLabel()"
265
- [yAxisLabel]="yAxisLabel()"
266
- [padding]="effectivePadding()"
267
- [xTickFormat]="xTickFormat()"
268
- [yTickFormat]="yTickFormat()"
269
- [zoomable]="zoomable()"
270
- [zoomAxes]="zoomAxes()"
271
- [showZoomControls]="showZoomControls()"
272
- [minZoom]="minZoom()"
273
- [maxZoom]="maxZoom()"
274
- [zoomStep]="zoomStep()"
275
- [class]="userClass()"
276
- >
277
- <ui-candlestick-series
278
- [data]="data()"
279
- [upColor]="upColor()"
280
- [downColor]="downColor()"
281
- [wickWidth]="wickWidth()"
282
- [bodyWidthRatio]="bodyWidthRatio()"
283
- (candleTap)="candleTap.emit($event)"
284
- />
285
- </ui-cartesian-chart>
150
+ <ui-chart
151
+ [definition]="definition()"
152
+ ${PRESET_CHART_BINDINGS}
153
+ (pointSelect)="onSelect($event)"
154
+ />
286
155
  `,
287
156
  })
288
- export class UiCandlestickChart {
289
- readonly data = input.required<CandlestickPoint[]>();
157
+ export class UiCandlestickChart extends UiCartesianPreset<CandlestickDatum> {
158
+ readonly data = input.required<readonly CandlestickDatum[]>();
290
159
  readonly upColor = input('rgba(34, 197, 94, 1)');
291
160
  readonly downColor = input('rgba(239, 68, 68, 1)');
292
- readonly width = input(320);
293
- readonly height = input(200);
294
- readonly tickCount = input(5);
295
161
  readonly wickWidth = input(1);
296
162
  readonly bodyWidthRatio = input(0.6);
297
- /** Draw vertical gridlines at each x tick. */
298
- readonly showXGrid = input(false);
299
- /** Title for the x-axis (centered below the tick labels). */
300
- readonly xAxisLabel = input<string>('');
301
- /** Title for the y-axis (rotated in the left gutter). */
302
- readonly yAxisLabel = input<string>('');
303
- /** Inner inset of the plot area — merged over the auto half-candle padding
304
- * (below), so any side you set wins. See {@link ChartPadding}. */
305
- readonly padding = input<ChartPadding>({});
306
- /** Force the x-axis lower/upper bound instead of deriving it from the data.
307
- * Omitted bounds fall back to the data extremes. */
308
- readonly xMin = input<number | undefined>(undefined);
309
- readonly xMax = input<number | undefined>(undefined);
310
- /** Force the y-axis lower/upper bound (e.g. to compare two charts on one scale). */
311
- readonly yMin = input<number | undefined>(undefined);
312
- readonly yMax = input<number | undefined>(undefined);
313
- readonly xTickFormat = input<(value: number) => string>(defaultTickFormat);
314
- readonly yTickFormat = input<(value: number) => string>(defaultTickFormat);
315
- readonly userClass = input<string>('', { alias: 'class' });
316
-
317
- // Pan/zoom — forwarded to the underlying <ui-cartesian-chart>. Off by default
318
- // so a candlestick chart stays static unless opted in.
319
- readonly zoomable = input(false);
320
- readonly zoomAxes = input<'x' | 'y' | 'xy'>('xy');
321
- readonly showZoomControls = input(true);
322
- readonly minZoom = input(1);
323
- readonly maxZoom = input(8);
324
- readonly zoomStep = input(1.4);
325
163
 
326
- readonly candleTap = output<CandlestickPoint>();
327
-
328
- // Auto-compute rounded axes from the data extremes, letting an explicit
329
- // min/max override either end. `Math.min/max(...[])` yield ±Infinity for empty
330
- // data, which `niceScale` handles by padding.
331
- protected readonly xAxis = computed(() => {
332
- const xs = this.data().map((p) => p.x);
333
- const lo = this.xMin() ?? Math.min(...xs);
334
- const hi = this.xMax() ?? Math.max(...xs);
335
- return niceScale(lo, hi, this.tickCount());
336
- });
337
- // The y-extent spans every wick: the lowest low to the highest high. Open and
338
- // close always sit inside that range, so they need no separate consideration.
339
- protected readonly yAxis = computed(() => {
340
- const lows = this.data().map((p) => p.low);
341
- const highs = this.data().map((p) => p.high);
342
- const lo = this.yMin() ?? Math.min(...lows);
343
- const hi = this.yMax() ?? Math.max(...highs);
344
- return niceScale(lo, hi, this.tickCount());
345
- });
346
-
347
- // A candle body is centered on its x, so the first and last bodies would spill
348
- // over the plot edges. Insetting the scales by half a candle's spacing keeps
349
- // them fully inside. User `padding` is spread last, so an explicit side wins.
350
- protected readonly effectivePadding = computed<ChartPadding>(() => {
351
- const n = this.data().length;
352
- const half = n > 1 ? 0.5 / (n - 1) : 0;
353
- return { left: half, right: half, ...this.padding() };
354
- });
164
+ /** The tapped candle's datum. */
165
+ readonly candleTap = output<CandlestickDatum>();
166
+
167
+ protected readonly definition = computed(() =>
168
+ candlestickChartDefinition({
169
+ ...this.commonOptions(),
170
+ data: this.data(),
171
+ upColor: this.upColor(),
172
+ downColor: this.downColor(),
173
+ wickWidth: this.wickWidth(),
174
+ bodyWidthRatio: this.bodyWidthRatio(),
175
+ }),
176
+ );
177
+
178
+ protected onSelect(point: ChartPoint | null): void {
179
+ const datum = this.datumOf(point);
180
+ if (datum) this.candleTap.emit(datum);
181
+ }
355
182
  }
@@ -1,7 +1,6 @@
1
1
  export {
2
+ type CandlestickChartOptions,
3
+ type CandlestickDatum,
2
4
  UiCandlestickChart,
3
- UiCandlestickSeries,
4
- computeCandles,
5
- type CandlestickPoint,
6
- type CandleShape,
5
+ candlestickChartDefinition,
7
6
  } from './candlestick-chart';
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createChartRuntime } from '@tanstack/charts/runtime';
3
+ import { scaleLinear } from '@tanstack/charts/scales/linear';
4
+ import type { ChartDefinition, ChartPoint } from '@tanstack/charts/types';
5
+ import { defineChart, dot, lineY } from '@tanstack/charts/universal';
6
+ import { adjacentFocusPoint, createChartFocusModel } from './chart-focus';
7
+
8
+ const data = [
9
+ { x: 0, y: 1 },
10
+ { x: 1, y: 3 },
11
+ { x: 2, y: 2 },
12
+ ];
13
+ const scales = {
14
+ x: { scale: () => scaleLinear().domain([0, 2]) },
15
+ y: { scale: () => scaleLinear().domain([0, 4]) },
16
+ };
17
+
18
+ const modelFor = (definition: ChartDefinition<any, any, any>) => {
19
+ const scene = createChartRuntime().render(definition, {
20
+ width: 200,
21
+ height: 100,
22
+ });
23
+ return { scene, model: createChartFocusModel(scene, definition) };
24
+ };
25
+
26
+ describe('createChartFocusModel', () => {
27
+ it('resolves a touch to the nearest point', () => {
28
+ const definition = defineChart({
29
+ marks: [dot(data, { x: 'x', y: 'y' })],
30
+ scales,
31
+ });
32
+ const { scene, model } = modelFor(definition);
33
+ const target = scene.points[1]!;
34
+ expect(model.resolve(target.x + 2, target.y - 2)).toEqual([target]);
35
+ });
36
+
37
+ it('resolves nothing out of reach', () => {
38
+ const definition = defineChart({
39
+ marks: [dot(data, { x: 'x', y: 'y' })],
40
+ scales,
41
+ maxFocusDistance: 1,
42
+ });
43
+ const { model } = modelFor(definition);
44
+ expect(model.resolve(-500, -500)).toEqual([]);
45
+ });
46
+
47
+ it("groups a mark's series at one x with the group-x preset", () => {
48
+ const series = [
49
+ ...data.map((d) => ({ ...d, s: 'a' })),
50
+ ...data.map((d) => ({ ...d, y: d.y + 1, s: 'b' })),
51
+ ];
52
+ const definition = defineChart({
53
+ marks: [lineY(series, { x: 'x', y: 'y', z: 's' })],
54
+ scales,
55
+ focus: 'group-x',
56
+ });
57
+ const { scene, model } = modelFor(definition);
58
+ const point = scene.points.find((p) => p.xValue === 1)!;
59
+ const group = model.resolve(point.x, point.y);
60
+ expect(group.length).toBe(2);
61
+ expect(model.group(point).length).toBe(2);
62
+ });
63
+
64
+ it('turns focus off with focus: false', () => {
65
+ const definition = defineChart({
66
+ marks: [dot(data, { x: 'x', y: 'y' })],
67
+ scales,
68
+ focus: false,
69
+ });
70
+ const { scene, model } = modelFor(definition);
71
+ expect(model.resolve(scene.points[0]!.x, scene.points[0]!.y)).toEqual([]);
72
+ expect(model.navigation).toEqual([]);
73
+ });
74
+
75
+ it("uses the definition's spatial index", () => {
76
+ // Typed loosely: the index returns points of whatever datum the chart has.
77
+ const findNearest = vi.fn((): ChartPoint<any, any, any> | null => null);
78
+ const definition = defineChart({
79
+ marks: [dot(data, { x: 'x', y: 'y' })],
80
+ scales,
81
+ spatialIndex: () => ({ findNearest }),
82
+ });
83
+ const { model } = modelFor(definition);
84
+ expect(model.resolve(-500, -500)).toEqual([]);
85
+ expect(findNearest).toHaveBeenCalledWith(-500, -500, 48);
86
+ });
87
+
88
+ it('restores a point from an older scene by identity', () => {
89
+ const definition = defineChart({
90
+ marks: [dot(data, { x: 'x', y: 'y' })],
91
+ scales,
92
+ });
93
+ const first = modelFor(definition);
94
+ const second = modelFor(definition);
95
+ const restored = second.model.restore(first.scene.points[2]!);
96
+ expect(restored).toBe(second.scene.points[2]);
97
+ });
98
+
99
+ it('orders points left to right for stepping through them', () => {
100
+ const definition = defineChart({
101
+ marks: [dot([data[2]!, data[0]!, data[1]!], { x: 'x', y: 'y' })],
102
+ scales,
103
+ });
104
+ const { model } = modelFor(definition);
105
+ expect(model.navigation.map((p) => p.xValue)).toEqual([0, 1, 2]);
106
+ });
107
+ });
108
+
109
+ describe('adjacentFocusPoint', () => {
110
+ const definition = defineChart({
111
+ marks: [dot(data, { x: 'x', y: 'y' })],
112
+ scales,
113
+ });
114
+ const { model } = modelFor(definition);
115
+ const [first, second, third] = model.navigation;
116
+
117
+ it('starts at either end without a current point', () => {
118
+ expect(adjacentFocusPoint(model, null, 1)).toBe(first);
119
+ expect(adjacentFocusPoint(model, null, -1)).toBe(third);
120
+ });
121
+
122
+ it('steps and stops at the ends', () => {
123
+ expect(adjacentFocusPoint(model, first!, 1)).toBe(second);
124
+ expect(adjacentFocusPoint(model, first!, -1)).toBe(first);
125
+ expect(adjacentFocusPoint(model, third!, 1)).toBe(third);
126
+ });
127
+
128
+ it('returns null when there is nothing to step to', () => {
129
+ const empty = { ...model, navigation: [] };
130
+ expect(adjacentFocusPoint(empty, null, 1)).toBeNull();
131
+ });
132
+ });