@blotch/dolan 22.2.2 → 22.2.4

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 (259) hide show
  1. package/dist/commands/add.d.ts +6 -2
  2. package/dist/commands/add.js +41 -11
  3. package/dist/commands/add.js.map +1 -1
  4. package/dist/commands/add.test.js +162 -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 +228 -34
  12. package/dist/commands/doctor.js.map +1 -1
  13. package/dist/commands/doctor.test.js +367 -10
  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 +169 -40
  23. package/dist/commands/init.js.map +1 -1
  24. package/dist/commands/init.test.js +392 -27
  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/theme.js +1 -1
  46. package/dist/commands/theme.js.map +1 -1
  47. package/dist/commands/update.d.ts +11 -0
  48. package/dist/commands/update.js +174 -58
  49. package/dist/commands/update.js.map +1 -1
  50. package/dist/commands/update.test.js +587 -33
  51. package/dist/commands/update.test.js.map +1 -1
  52. package/dist/index.js +7 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/lockfile.d.ts +7 -0
  55. package/dist/lockfile.js +36 -0
  56. package/dist/lockfile.js.map +1 -1
  57. package/dist/lockfile.test.d.ts +1 -0
  58. package/dist/lockfile.test.js +25 -0
  59. package/dist/lockfile.test.js.map +1 -0
  60. package/dist/registry.d.ts +14 -0
  61. package/dist/registry.js +78 -24
  62. package/dist/registry.js.map +1 -1
  63. package/dist/registry.test.js +119 -38
  64. package/dist/registry.test.js.map +1 -1
  65. package/dist/test-utils.d.ts +5 -0
  66. package/dist/test-utils.js +6 -1
  67. package/dist/test-utils.js.map +1 -1
  68. package/dist/testing/fire-lynx-event.d.ts +14 -0
  69. package/dist/testing/fire-lynx-event.js +19 -0
  70. package/dist/testing/fire-lynx-event.js.map +1 -0
  71. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  72. package/dist/testing/fire-lynx-event.test.js +44 -0
  73. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  74. package/dist/testing/lynx-main-thread.d.ts +22 -0
  75. package/dist/testing/lynx-main-thread.js +11 -0
  76. package/dist/testing/lynx-main-thread.js.map +1 -0
  77. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  78. package/dist/testing/lynx-main-thread.test.js +24 -0
  79. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  80. package/dist/testing/set-input-signal.d.ts +14 -0
  81. package/dist/testing/set-input-signal.js +21 -0
  82. package/dist/testing/set-input-signal.js.map +1 -0
  83. package/dist/testing/set-input-signal.test.d.ts +1 -0
  84. package/dist/testing/set-input-signal.test.js +20 -0
  85. package/dist/testing/set-input-signal.test.js.map +1 -0
  86. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  87. package/dist/ui/components/accordion/accordion.ts +19 -14
  88. package/dist/ui/components/action-sheet/action-sheet.test.ts +283 -151
  89. package/dist/ui/components/action-sheet/action-sheet.ts +77 -43
  90. package/dist/ui/components/alert/alert.test.ts +67 -19
  91. package/dist/ui/components/alert/alert.ts +14 -16
  92. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +208 -94
  93. package/dist/ui/components/alert-dialog/alert-dialog.ts +75 -38
  94. package/dist/ui/components/area-chart/area-chart.test.ts +134 -119
  95. package/dist/ui/components/area-chart/area-chart.ts +100 -315
  96. package/dist/ui/components/area-chart/index.ts +2 -3
  97. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  98. package/dist/ui/components/badge/badge.test.ts +57 -23
  99. package/dist/ui/components/badge/badge.ts +12 -14
  100. package/dist/ui/components/bar-chart/bar-chart.test.ts +172 -45
  101. package/dist/ui/components/bar-chart/bar-chart.ts +140 -294
  102. package/dist/ui/components/bar-chart/index.ts +3 -3
  103. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +386 -128
  104. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +170 -53
  105. package/dist/ui/components/button/button.test.ts +611 -2
  106. package/dist/ui/components/button/button.ts +434 -49
  107. package/dist/ui/components/button/index.ts +2 -0
  108. package/dist/ui/components/button-group/button-group.test.ts +484 -2
  109. package/dist/ui/components/button-group/button-group.ts +380 -68
  110. package/dist/ui/components/button-group/index.ts +3 -0
  111. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +183 -58
  112. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +150 -323
  113. package/dist/ui/components/candlestick-chart/index.ts +3 -4
  114. package/dist/ui/components/card/card.test.ts +82 -62
  115. package/dist/ui/components/chart/chart-focus.test.ts +132 -0
  116. package/dist/ui/components/chart/chart-focus.ts +115 -0
  117. package/dist/ui/components/chart/chart-presets.test.ts +335 -0
  118. package/dist/ui/components/chart/chart-presets.ts +278 -0
  119. package/dist/ui/components/chart/chart-scene.test.ts +589 -0
  120. package/dist/ui/components/chart/chart-scene.ts +483 -0
  121. package/dist/ui/components/chart/chart-theme.test.ts +85 -0
  122. package/dist/ui/components/chart/chart-theme.ts +210 -0
  123. package/dist/ui/components/chart/chart-zoom.test.ts +159 -0
  124. package/dist/ui/components/chart/chart-zoom.ts +162 -0
  125. package/dist/ui/components/chart/chart.test.ts +1017 -0
  126. package/dist/ui/components/chart/chart.ts +934 -0
  127. package/dist/ui/components/chart/index.ts +24 -0
  128. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  129. package/dist/ui/components/checkbox/checkbox.ts +23 -23
  130. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  131. package/dist/ui/components/collapsible/collapsible.ts +19 -14
  132. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  133. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  134. package/dist/ui/components/context-menu/index.ts +1 -0
  135. package/dist/ui/components/dialog/dialog.test.ts +213 -108
  136. package/dist/ui/components/dialog/dialog.ts +72 -35
  137. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  138. package/dist/ui/components/dock/dock-scroll.ts +133 -0
  139. package/dist/ui/components/dock/dock.test.ts +749 -0
  140. package/dist/ui/components/dock/dock.ts +960 -0
  141. package/dist/ui/components/dock/index.ts +2 -0
  142. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  143. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  144. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  145. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  146. package/dist/ui/components/glass/glass-design.ts +119 -0
  147. package/dist/ui/components/glass/glass.test.ts +338 -0
  148. package/dist/ui/components/glass/glass.ts +360 -0
  149. package/dist/ui/components/glass/index.ts +18 -0
  150. package/dist/ui/components/icon/icon-theme.test.ts +169 -0
  151. package/dist/ui/components/icon/icon-theme.ts +227 -0
  152. package/dist/ui/components/icon/icon.test.ts +85 -25
  153. package/dist/ui/components/icon/icon.ts +38 -21
  154. package/dist/ui/components/input/input.test.ts +178 -101
  155. package/dist/ui/components/input/input.ts +15 -12
  156. package/dist/ui/components/label/label.test.ts +11 -24
  157. package/dist/ui/components/line-chart/index.ts +2 -3
  158. package/dist/ui/components/line-chart/line-chart.test.ts +149 -56
  159. package/dist/ui/components/line-chart/line-chart.ts +83 -241
  160. package/dist/ui/components/list/list.test.ts +119 -21
  161. package/dist/ui/components/list/list.ts +21 -15
  162. package/dist/ui/components/menu/index.ts +25 -0
  163. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  164. package/dist/ui/components/menu/menu-placement.ts +159 -0
  165. package/dist/ui/components/menu/menu-popup.test.ts +632 -0
  166. package/dist/ui/components/menu/menu-popup.ts +544 -0
  167. package/dist/ui/components/menu/menu.test.ts +429 -0
  168. package/dist/ui/components/menu/menu.ts +415 -0
  169. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +435 -191
  170. package/dist/ui/components/nav-drawer/nav-drawer.ts +127 -56
  171. package/dist/ui/components/pagination/pagination.test.ts +177 -65
  172. package/dist/ui/components/pagination/pagination.ts +55 -8
  173. package/dist/ui/components/progress/progress.test.ts +102 -46
  174. package/dist/ui/components/progress/progress.ts +52 -54
  175. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  176. package/dist/ui/components/radio-group/radio-group.ts +24 -24
  177. package/dist/ui/components/scatter-chart/index.ts +2 -3
  178. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +131 -26
  179. package/dist/ui/components/scatter-chart/scatter-chart.ts +104 -250
  180. package/dist/ui/components/scroll-area/scroll-area.test.ts +95 -24
  181. package/dist/ui/components/scroll-area/scroll-area.ts +8 -8
  182. package/dist/ui/components/select/select.test.ts +138 -78
  183. package/dist/ui/components/select/select.ts +17 -11
  184. package/dist/ui/components/separator/separator.test.ts +18 -17
  185. package/dist/ui/components/sheet/sheet.test.ts +64 -11
  186. package/dist/ui/components/sheet/sheet.ts +9 -1
  187. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  188. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  189. package/dist/ui/components/spinner/spinner.test.ts +67 -22
  190. package/dist/ui/components/spinner/spinner.ts +48 -41
  191. package/dist/ui/components/switch/switch.test.ts +116 -46
  192. package/dist/ui/components/switch/switch.ts +8 -13
  193. package/dist/ui/components/tab-bar/index.ts +1 -0
  194. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  195. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  196. package/dist/ui/components/tabs/tabs.test.ts +215 -157
  197. package/dist/ui/components/tabs/tabs.ts +110 -118
  198. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  199. package/dist/ui/components/textarea/textarea.ts +14 -12
  200. package/dist/ui/components/toast/toast.test.ts +436 -315
  201. package/dist/ui/components/toast/toast.ts +62 -55
  202. package/dist/ui/components/toggle/toggle.test.ts +247 -140
  203. package/dist/ui/theme/chart-colors.test.ts +35 -0
  204. package/dist/ui/theme/dark.css +46 -2
  205. package/dist/ui/theme/default.css +63 -7
  206. package/dist/ui/theme/icon-colors.test.ts +35 -0
  207. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  208. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  209. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  210. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  211. package/dist/ui/theme/tailwind-plugin.ts +98 -25
  212. package/dist/ui/theme/tailwind.css +113 -0
  213. package/dist/ui/utils/animate.test.ts +13 -1
  214. package/dist/utils/analyze.d.ts +14 -0
  215. package/dist/utils/analyze.js +15 -0
  216. package/dist/utils/analyze.js.map +1 -1
  217. package/dist/utils/analyze.test.js +14 -1
  218. package/dist/utils/analyze.test.js.map +1 -1
  219. package/dist/utils/installed.d.ts +29 -0
  220. package/dist/utils/installed.js +43 -0
  221. package/dist/utils/installed.js.map +1 -0
  222. package/dist/utils/installed.test.d.ts +1 -0
  223. package/dist/utils/installed.test.js +57 -0
  224. package/dist/utils/installed.test.js.map +1 -0
  225. package/dist/utils/interactive.d.ts +9 -0
  226. package/dist/utils/interactive.js +10 -0
  227. package/dist/utils/interactive.js.map +1 -0
  228. package/dist/utils/interactive.test.d.ts +1 -0
  229. package/dist/utils/interactive.test.js +24 -0
  230. package/dist/utils/interactive.test.js.map +1 -0
  231. package/dist/utils/packages.d.ts +36 -0
  232. package/dist/utils/packages.js +70 -0
  233. package/dist/utils/packages.js.map +1 -0
  234. package/dist/utils/packages.test.d.ts +1 -0
  235. package/dist/utils/packages.test.js +89 -0
  236. package/dist/utils/packages.test.js.map +1 -0
  237. package/dist/utils/resolve-paths.d.ts +17 -1
  238. package/dist/utils/resolve-paths.js +21 -3
  239. package/dist/utils/resolve-paths.js.map +1 -1
  240. package/dist/utils/resolve-paths.test.js +38 -2
  241. package/dist/utils/resolve-paths.test.js.map +1 -1
  242. package/dist/utils/tailwind.d.ts +25 -0
  243. package/dist/utils/tailwind.js +51 -0
  244. package/dist/utils/tailwind.js.map +1 -0
  245. package/dist/utils/tailwind.test.d.ts +1 -0
  246. package/dist/utils/tailwind.test.js +71 -0
  247. package/dist/utils/tailwind.test.js.map +1 -0
  248. package/package.json +13 -1
  249. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -121
  250. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -174
  251. package/dist/ui/components/button/button-render.test.ts +0 -113
  252. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  253. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -174
  254. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +0 -188
  255. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +0 -525
  256. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +0 -1140
  257. package/dist/ui/components/cartesian-chart/index.ts +0 -18
  258. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -121
  259. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -157
@@ -2,348 +2,133 @@ 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
-
11
8
  import {
12
- type ChartPadding,
13
- type ChartPoint,
14
- UiCartesianChart,
15
- niceScale,
16
- sampleSmoothLine,
17
- } from '../cartesian-chart/cartesian-chart';
18
- import { UiLineSeries } from '../line-chart/line-chart';
19
-
20
- // ---------------------------------------------------------------------------
21
- // Pure geometry (exported so it can be unit-tested without Angular)
22
- // ---------------------------------------------------------------------------
23
-
24
- /** One vertical fill column: a rectangle `height` px tall starting at (`x`, `top`). */
25
- export type AreaColumn = {
26
- x: number;
27
- width: number;
28
- top: number;
29
- height: number;
30
- };
31
-
32
- /**
33
- * Linearly interpolates the polyline's y at pixel `x`. `points` must be sorted
34
- * ascending by x (as cartesian-chart data always is). Returns `null` when `x`
35
- * falls outside the data's x-range, so the fill is only drawn *under the actual
36
- * series* — never extrapolated flat past the first or last point.
37
- */
38
- const interpolateY = (
39
- points: readonly ChartPoint[],
40
- x: number,
41
- ): number | null => {
42
- const first = points[0];
43
- const last = points[points.length - 1];
44
- if (x < first.x || x > last.x) return null;
45
- for (let i = 0; i < points.length - 1; i++) {
46
- const p1 = points[i];
47
- const p2 = points[i + 1];
48
- if (x >= p1.x && x <= p2.x) {
49
- const span = p2.x - p1.x;
50
- if (span === 0) return p1.y; // duplicate x — avoid divide-by-zero
51
- return p1.y + ((x - p1.x) / span) * (p2.y - p1.y);
52
- }
53
- }
54
- /* v8 ignore next 1 */
55
- return last.y; // defensive: x === last.x but the loop missed it
56
- };
9
+ areaY,
10
+ d3Curve,
11
+ defineChart,
12
+ dot,
13
+ lineY,
14
+ } from '@tanstack/charts/universal';
15
+ import type { ChartMark, ChartPoint } from '@tanstack/charts/types';
16
+ import { curveMonotoneX } from 'd3-shape';
17
+
18
+ import { UiChart, tooltip } from '../chart/chart';
19
+ import {
20
+ type CartesianPresetOptions,
21
+ PRESET_CHART_BINDINGS,
22
+ UiCartesianPreset,
23
+ type XYDatum,
24
+ presetAxes,
25
+ } from '../chart/chart-presets';
26
+
27
+ export type AreaChartOptions = {
28
+ readonly data: readonly XYDatum[];
29
+ /** Any CSS color; theme tokens like `var(--primary)` follow dark mode. */
30
+ readonly color?: string;
31
+ readonly strokeWidth?: number;
32
+ readonly dotRadius?: number;
33
+ readonly showDots?: boolean;
34
+ /** Opacity of the fill under the line, `0`–`1`. */
35
+ readonly fillOpacity?: number;
36
+ /** The value the area fills to; it's always kept in view. */
37
+ readonly baseline?: number;
38
+ readonly smooth?: boolean;
39
+ } & CartesianPresetOptions;
57
40
 
58
41
  /**
59
- * Rasterizes the region between a polyline and a horizontal baseline into thin
60
- * vertical `<view>` columns — the Lynx substitute for a filled area.
61
- *
62
- * Lynx has no filled-polygon primitive: `clip-path` supports no `polygon()` and
63
- * its inline `<svg>` is unreliable, so an area is approximated as a run of
64
- * adjacent rectangles, one per `stripWidth` band, each reaching from the
65
- * interpolated line height down to `baseY`. The crisp line is drawn on top (see
66
- * `UiAreaSeries`), which hides the columns' stepped upper edge. Sampling at each
67
- * strip's center keeps that step within about `stripWidth / 2 × slope` px.
68
- *
69
- * Guards: fewer than two points, or a non-positive `plotWidth`/`stripWidth`,
70
- * yield no columns; near-zero-height columns (line meeting the baseline) are
71
- * skipped so we never emit a degenerate 0-height view.
42
+ * The definition `<ui-area-chart>` renders: a filled area down to the
43
+ * baseline, the line on top, and optional dots. Values below the baseline fill
44
+ * upward to it.
72
45
  */
73
- export const computeAreaColumns = (
74
- points: readonly ChartPoint[],
75
- baseY: number,
76
- plotWidth: number,
77
- stripWidth: number,
78
- ): AreaColumn[] => {
79
- const columns: AreaColumn[] = [];
80
- if (points.length < 2 || plotWidth <= 0 || stripWidth <= 0) return columns;
81
-
82
- for (let x = 0; x < plotWidth; x += stripWidth) {
83
- // Sample the line at the strip's center (clamped to the plot's right edge).
84
- const center = Math.min(x + stripWidth / 2, plotWidth);
85
- const lineY = interpolateY(points, center);
86
- if (lineY === null) continue;
87
-
88
- const top = Math.min(lineY, baseY);
89
- const height = Math.abs(lineY - baseY);
90
- if (height < 0.01) continue; // line rests on the baseline here — nothing to fill
91
-
92
- // A hair of overlap (stripWidth + 1) closes sub-pixel seams between adjacent
93
- // columns. Harmless: the shared `opacity` layer composites them once, so the
94
- // overlaps never double-darken.
95
- columns.push({ x, width: stripWidth + 1, top, height });
46
+ export const areaChartDefinition = (options: AreaChartOptions) => {
47
+ const {
48
+ data,
49
+ color = 'var(--primary)',
50
+ strokeWidth = 2,
51
+ dotRadius = 3,
52
+ showDots = true,
53
+ fillOpacity = 0.2,
54
+ baseline = 0,
55
+ smooth = false,
56
+ } = options;
57
+ const curve = smooth ? d3Curve(curveMonotoneX) : undefined;
58
+ const marks: ChartMark<XYDatum, any, any, any, any, any, any>[] = [
59
+ areaY(data, {
60
+ x: 'x',
61
+ y: 'y',
62
+ y1: baseline,
63
+ fill: color,
64
+ fillOpacity,
65
+ curve,
66
+ }),
67
+ lineY(data, { x: 'x', y: 'y', stroke: color, strokeWidth, curve }),
68
+ ];
69
+ if (showDots && dotRadius > 0) {
70
+ marks.push(dot(data, { x: 'x', y: 'y', r: dotRadius, fill: color }));
96
71
  }
97
- return columns;
72
+ return defineChart({
73
+ marks,
74
+ scales: presetAxes(options, {
75
+ x: data.map((d) => d.x),
76
+ y: data.map((d) => d.y),
77
+ yInclude: [baseline],
78
+ }),
79
+ tooltip: options.tooltip === false ? false : tooltip,
80
+ });
98
81
  };
99
82
 
100
- // ---------------------------------------------------------------------------
101
- // Rendering helpers (mirror the equivalents in line-chart.ts — kept local so
102
- // this module stays self-contained when scaffolded by `dolan add`)
103
- // ---------------------------------------------------------------------------
104
-
105
- /**
106
- * Formats a pixel value for an inline style, avoiding scientific notation.
107
- */
108
- const px = (n: number): string => `${n.toFixed(2)}px`;
109
-
110
- /**
111
- * Default axis label formatter — plain JS only (no `Intl`, absent in Lynx).
112
- */
113
- const defaultTickFormat = (value: number): string =>
114
- String(Math.round(value * 100) / 100);
115
-
116
- const clamp = (value: number, lo: number, hi: number): number =>
117
- Math.max(lo, Math.min(hi, value));
118
-
119
83
  /**
120
- * Width of each fill strip in px. Smaller means a smoother top edge but more
121
- * native views (roughly `plotWidth / STRIP_WIDTH` per series); 3px keeps a
122
- * ~250px plot near ~83 views, and the crisp line drawn on top hides the residual
123
- * stepping. Lynx offers no polygon `clip-path` or reliable `<svg>` fill, so the
124
- * area is rasterized this way rather than drawn as a single shape.
125
- */
126
- const STRIP_WIDTH = 3;
127
-
128
- /**
129
- * A single area series drawn inside a `<ui-cartesian-chart>`: a translucent fill
130
- * beneath a line. It reuses `<ui-line-series>` for the crisp line + dots and adds
131
- * only the fill — an area chart is, structurally, a line chart with the region
132
- * down to a baseline filled in.
133
- *
134
- * ## Why the host is `position: absolute; width/height: 100%`
135
- * Identical to `UiLineSeries`: Lynx resolves an absolute child against its
136
- * **direct parent** (there is no `position: static`), so the host itself must be
137
- * the plot-sized containing block. Both the fill layer and the nested
138
- * `<ui-line-series>` are direct children of this host and fill it exactly.
84
+ * A single-series area chart. For stacked or several areas, build a
85
+ * definition with `areaY` and render it with `<ui-chart>`.
139
86
  *
140
- * ## Why the nested `<ui-line-series>` still finds the chart
141
- * Element injectors are parented by *template nesting*, not physical projection.
142
- * `<ui-line-series>` sits in this component's template, so its injector chains up
143
- * through this host to the `UiCartesianChart` that `<ui-area-series>` is projected
144
- * into — the same `inject(UiCartesianChart)` that `UiLineSeries` already relies
145
- * on, one level deeper.
87
+ * ```html
88
+ * <ui-area-chart [data]="visitors" [smooth]="true" [fillOpacity]="0.3" />
89
+ * ```
146
90
  */
147
91
  @Component({
148
- selector: 'ui-area-series',
149
- standalone: true,
150
- imports: [LYNX_ELEMENTS, UiLineSeries],
92
+ selector: 'ui-area-chart',
93
+ imports: [UiChart],
151
94
  encapsulation: ViewEncapsulation.None,
152
- host: {
153
- style:
154
- 'position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;',
155
- '[class]': 'userClass()',
156
- },
157
95
  template: `
158
- <!-- Fill layer first so the line + dots (below) paint on top of it. Its own
159
- opacity makes the whole fill translucent in a single composite —
160
- independent of the color's format (rgba/var()/named), so there's no need
161
- to inject an alpha channel into an arbitrary CSS color string. -->
162
- <view [style]="fillLayerStyle()">
163
- @for (column of columns(); track $index) {
164
- <view [style]="column.style"></view>
165
- }
166
- </view>
167
- <ui-line-series
168
- [data]="data()"
169
- [color]="color()"
170
- [strokeWidth]="strokeWidth()"
171
- [dotRadius]="dotRadius()"
172
- [showDots]="showDots()"
173
- [smooth]="smooth()"
174
- (pointTap)="pointTap.emit($event)"
96
+ <ui-chart
97
+ [definition]="definition()"
98
+ ${PRESET_CHART_BINDINGS}
99
+ (pointSelect)="onSelect($event)"
175
100
  />
176
101
  `,
177
102
  })
178
- export class UiAreaSeries {
179
- readonly #chart = inject(UiCartesianChart);
180
-
181
- readonly data = input.required<ChartPoint[]>();
103
+ export class UiAreaChart extends UiCartesianPreset<XYDatum> {
104
+ readonly data = input.required<readonly XYDatum[]>();
182
105
  readonly color = input('var(--primary)');
183
106
  readonly strokeWidth = input(2);
184
107
  readonly dotRadius = input(3);
185
108
  readonly showDots = input(true);
186
109
  readonly fillOpacity = input(0.2);
187
- /** Data-space y the fill descends to (the flat edge of the area). */
188
110
  readonly baseline = input(0);
189
- /** Fill under a smooth (monotone-cubic) curve instead of straight segments.
190
- * Forwarded to the nested line so the crisp edge matches the fill's top. */
191
111
  readonly smooth = input(false);
192
- readonly userClass = input<string>('', { alias: 'class' });
193
-
194
- readonly pointTap = output<ChartPoint>();
195
-
196
- // Project each data point into plot-local pixel space via the shared scales.
197
- readonly #pixelPoints = computed(() => {
198
- const scaleX = this.#chart.xScale();
199
- const scaleY = this.#chart.yScale();
200
- return this.data().map((point) => ({
201
- x: scaleX(point.x),
202
- y: scaleY(point.y),
203
- }));
204
- });
205
112
 
206
- protected readonly fillLayerStyle = computed(
207
- () =>
208
- `position: absolute; left: 0px; top: 0px; width: 100%; height: 100%; opacity: ${this.fillOpacity()};`,
113
+ /** The tapped point's datum. */
114
+ readonly pointTap = output<XYDatum>();
115
+
116
+ protected readonly definition = computed(() =>
117
+ areaChartDefinition({
118
+ ...this.commonOptions(),
119
+ data: this.data(),
120
+ color: this.color(),
121
+ strokeWidth: this.strokeWidth(),
122
+ dotRadius: this.dotRadius(),
123
+ showDots: this.showDots(),
124
+ fillOpacity: this.fillOpacity(),
125
+ baseline: this.baseline(),
126
+ smooth: this.smooth(),
127
+ }),
209
128
  );
210
129
 
211
- protected readonly columns = computed(() => {
212
- const fill = this.color();
213
- // Clamp the baseline into the plot so a baseline outside the domain anchors
214
- // the fill to the nearest edge (top/bottom) instead of drawing off-plot.
215
- const baseY = clamp(
216
- this.#chart.yScale()(this.baseline()),
217
- 0,
218
- this.#chart.plotHeight(),
219
- );
220
- // In smooth mode the fill traces the same monotone-cubic curve as the line:
221
- // sampling the polyline densely means `computeAreaColumns` (which linearly
222
- // interpolates the height at each strip) closely follows the curve's top edge.
223
- const pixels = this.#pixelPoints();
224
- const line = this.smooth() ? sampleSmoothLine(pixels) : pixels;
225
- return computeAreaColumns(
226
- line,
227
- baseY,
228
- this.#chart.plotWidth(),
229
- STRIP_WIDTH,
230
- ).map((c) => ({
231
- style:
232
- `position: absolute; left: ${px(c.x)}; top: ${px(c.top)}; ` +
233
- `width: ${px(c.width)}; height: ${px(c.height)}; background-color: ${fill};`,
234
- }));
235
- });
236
- }
237
-
238
- /**
239
- * A ready-to-use area chart for the common single-series case. Composes a
240
- * `<ui-cartesian-chart>` with one `<ui-area-series>` and auto-derives rounded
241
- * axis domains + ticks from the data. For multiple series or full control over
242
- * the axes, use `<ui-cartesian-chart>` with `<ui-area-series>` children directly.
243
- */
244
- @Component({
245
- selector: 'ui-area-chart',
246
- standalone: true,
247
- imports: [LYNX_ELEMENTS, UiCartesianChart, UiAreaSeries],
248
- encapsulation: ViewEncapsulation.None,
249
- template: `
250
- <ui-cartesian-chart
251
- [xDomain]="xAxis().domain"
252
- [yDomain]="yAxis().domain"
253
- [xTicks]="xAxis().ticks"
254
- [yTicks]="yAxis().ticks"
255
- [width]="width()"
256
- [height]="height()"
257
- [tickCount]="tickCount()"
258
- [showXGrid]="showXGrid()"
259
- [xAxisLabel]="xAxisLabel()"
260
- [yAxisLabel]="yAxisLabel()"
261
- [padding]="padding()"
262
- [xTickFormat]="xTickFormat()"
263
- [yTickFormat]="yTickFormat()"
264
- [zoomable]="zoomable()"
265
- [zoomAxes]="zoomAxes()"
266
- [showZoomControls]="showZoomControls()"
267
- [minZoom]="minZoom()"
268
- [maxZoom]="maxZoom()"
269
- [zoomStep]="zoomStep()"
270
- [class]="userClass()"
271
- >
272
- <ui-area-series
273
- [data]="data()"
274
- [color]="color()"
275
- [strokeWidth]="strokeWidth()"
276
- [dotRadius]="dotRadius()"
277
- [showDots]="showDots()"
278
- [fillOpacity]="fillOpacity()"
279
- [baseline]="baseline()"
280
- [smooth]="smooth()"
281
- (pointTap)="pointTap.emit($event)"
282
- />
283
- </ui-cartesian-chart>
284
- `,
285
- })
286
- export class UiAreaChart {
287
- readonly data = input.required<ChartPoint[]>();
288
- readonly color = input('var(--primary)');
289
- readonly width = input(320);
290
- readonly height = input(200);
291
- readonly tickCount = input(5);
292
- readonly strokeWidth = input(2);
293
- readonly dotRadius = input(3);
294
- readonly showDots = input(true);
295
- readonly fillOpacity = input(0.2);
296
- readonly baseline = input(0);
297
- /** Fill under a smooth (monotone-cubic) curve instead of straight segments. */
298
- readonly smooth = input(false);
299
- /** Draw vertical gridlines at each x tick. */
300
- readonly showXGrid = input(false);
301
- /** Title for the x-axis (centered below the tick labels). */
302
- readonly xAxisLabel = input<string>('');
303
- /** Title for the y-axis (rotated in the left gutter). */
304
- readonly yAxisLabel = input<string>('');
305
- /** Inner inset of the plot area, each side a fraction (0–1) — see {@link ChartPadding}. */
306
- readonly padding = input<ChartPadding>({});
307
- /** Force the x-axis lower/upper bound instead of deriving it from the data.
308
- * Omitted bounds fall back to the data extremes. */
309
- readonly xMin = input<number | undefined>(undefined);
310
- readonly xMax = input<number | undefined>(undefined);
311
- /** Force the y-axis lower/upper bound. The `baseline` is still folded in, so
312
- * `yMin` only lowers the floor further — it can't clip the fill's base off. */
313
- readonly yMin = input<number | undefined>(undefined);
314
- readonly yMax = input<number | undefined>(undefined);
315
- readonly xTickFormat = input<(value: number) => string>(defaultTickFormat);
316
- readonly yTickFormat = input<(value: number) => string>(defaultTickFormat);
317
- readonly userClass = input<string>('', { alias: 'class' });
318
-
319
- // Pan/zoom — forwarded to the underlying <ui-cartesian-chart>. Off by default.
320
- readonly zoomable = input(false);
321
- readonly zoomAxes = input<'x' | 'y' | 'xy'>('xy');
322
- readonly showZoomControls = input(true);
323
- readonly minZoom = input(1);
324
- readonly maxZoom = input(8);
325
- readonly zoomStep = input(1.4);
326
-
327
- readonly pointTap = output<ChartPoint>();
328
-
329
- // Auto-compute rounded axes from the data extremes, letting an explicit
330
- // min/max override either end. `Math.min/max(...[])` yield ±Infinity for empty
331
- // data, which `niceScale` handles by padding.
332
- protected readonly xAxis = computed(() => {
333
- const xs = this.data().map((p) => p.x);
334
- const lo = this.xMin() ?? Math.min(...xs);
335
- const hi = this.xMax() ?? Math.max(...xs);
336
- return niceScale(lo, hi, this.tickCount());
337
- });
338
- // Fold the baseline into the y-extent so the axis always spans the whole filled
339
- // region: a positive-only series still shows the baseline at the bottom, and
340
- // negative values pull the domain below it. An explicit yMin/yMax overrides the
341
- // corresponding data-derived end (the baseline still participates in the other).
342
- protected readonly yAxis = computed(() => {
343
- const ys = this.data().map((p) => p.y);
344
- const base = this.baseline();
345
- const lo = this.yMin() ?? Math.min(base, ...ys);
346
- const hi = this.yMax() ?? Math.max(base, ...ys);
347
- return niceScale(lo, hi, this.tickCount());
348
- });
130
+ protected onSelect(point: ChartPoint | null): void {
131
+ const datum = this.datumOf(point);
132
+ if (datum) this.pointTap.emit(datum);
133
+ }
349
134
  }
@@ -1,6 +1,5 @@
1
1
  export {
2
+ type AreaChartOptions,
2
3
  UiAreaChart,
3
- UiAreaSeries,
4
- computeAreaColumns,
5
- type AreaColumn,
4
+ areaChartDefinition,
6
5
  } from './area-chart';
@@ -1,69 +1,113 @@
1
- import { Component } from '@angular/core';
1
+ import { Component, signal } from '@angular/core';
2
2
  import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
3
  import { describe, expect, it } from 'vitest';
5
- import { setInputSignal } from '../../../test-utils/set-input-signal';
4
+ import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
6
5
  import { UiAvatar } from './avatar';
7
6
 
7
+ type AvatarSize = 'default' | 'sm' | 'lg' | 'xl' | '2xl';
8
+
8
9
  @Component({
9
- standalone: true,
10
- imports: [UiAvatar, LYNX_ELEMENTS],
11
- template: `<ui-avatar [src]="src" [fallback]="fallback" />`,
10
+ imports: [UiAvatar],
11
+ template: `
12
+ <ui-avatar [src]="src()" fallback="AB" [size]="size()" class="border" />
13
+ `,
12
14
  })
13
15
  class AvatarHost {
14
- src = '';
15
- fallback = 'AB';
16
+ readonly src = signal('');
17
+ readonly size = signal<AvatarSize>('default');
16
18
  }
17
19
 
20
+ const renderAvatar = async (src = '') => {
21
+ const result = await render(AvatarHost);
22
+ const host = result.componentRef.instance as AvatarHost;
23
+ host.src.set(src);
24
+ await waitForUpdate();
25
+ return {
26
+ ...result,
27
+ host,
28
+ avatar: () => result.container.querySelector('.overflow-hidden')!,
29
+ image: () => result.container.querySelector('image'),
30
+ /** The pulsing placeholder over the image while it loads. */
31
+ skeleton: () => result.container.querySelector('.inset-0'),
32
+ initials: () => result.queryByText('AB'),
33
+ };
34
+ };
35
+
18
36
  describe('UiAvatar', () => {
19
- it('renders the fallback branch when src is empty', async () => {
20
- const { container } = await render(AvatarHost);
21
- expect(container).toBeTruthy();
37
+ it('shows the initials in a muted circle without an image', async () => {
38
+ const { avatar, image, initials } = await renderAvatar();
39
+ expect(image()).toBeNull();
40
+ expect(initials()?.parentElement?.classList).toContain('bg-muted');
41
+ expect(avatar().classList).toContain('border');
22
42
  });
23
43
 
24
- it('renders without a src (default fallback path)', async () => {
25
- const { container } = await render(UiAvatar);
26
- expect(container).toBeTruthy();
44
+ it('covers the image with a placeholder while it loads', async () => {
45
+ const { image, skeleton, initials } = await renderAvatar(
46
+ 'https://example.com/avatar.png',
47
+ );
48
+ expect(image()?.getAttribute('src')).toBe('https://example.com/avatar.png');
49
+ expect(skeleton()).not.toBeNull();
50
+ expect(initials()).toBeNull();
27
51
  });
28
52
 
29
- it('renders the image branch when src is programmatically set', async () => {
30
- // JIT template bindings are inert against input(); drive src directly so
31
- // the @if(src()) branch fires and the image + skeleton render.
32
- const { componentRef, container } = await render(UiAvatar);
33
- setInputSignal(
34
- (componentRef.instance as UiAvatar).src,
53
+ it('reveals the image once it loads', async () => {
54
+ const { image, skeleton, initials } = await renderAvatar(
35
55
  'https://example.com/avatar.png',
36
56
  );
57
+ fireLynxEvent(image()!, 'load');
37
58
  await waitForUpdate();
38
- expect(container.querySelector('image')).toBeTruthy();
59
+ expect(skeleton()).toBeNull();
60
+ expect(image()).not.toBeNull();
61
+
62
+ // Lynx can dispatch `load` again as the placeholder's animation stops:
63
+ // the avatar stays as it is.
64
+ fireLynxEvent(image()!, 'load');
65
+ await waitForUpdate();
66
+ expect(skeleton()).toBeNull();
67
+ expect(image()).not.toBeNull();
68
+ expect(initials()).toBeNull();
39
69
  });
40
70
 
41
- it('keeps the image load / error handlers idempotent', async () => {
42
- // Directly invoke the protected handlers via the instance to cover the
43
- // re-entrancy latches — Lynx's on-device behavior re-dispatches the load
44
- // event when the skeleton animation tears down.
45
- const { componentRef } = await render(UiAvatar);
46
- const inst = componentRef.instance as unknown as {
47
- onImageLoad: () => void;
48
- onImageError: () => void;
49
- loaded: () => boolean;
50
- errored: () => boolean;
51
- };
52
- inst.onImageLoad();
53
- inst.onImageLoad(); // second call is a no-op (loaded latch)
54
- expect(inst.loaded()).toBe(true);
71
+ it('falls back to the initials when the image fails to load', async () => {
72
+ const { image, skeleton, initials } = await renderAvatar(
73
+ 'https://example.com/missing.png',
74
+ );
75
+ // Lynx can dispatch the event again before the avatar updates, as the
76
+ // placeholder's animation stops: the second one changes nothing.
77
+ fireLynxEvent(image()!, 'error', { detail: { errMsg: 'Not found' } });
78
+ fireLynxEvent(image()!, 'error', { detail: { errMsg: 'Not found' } });
79
+ await waitForUpdate();
80
+ expect(image()).toBeNull();
81
+ expect(skeleton()).toBeNull();
82
+ expect(initials()).not.toBeNull();
83
+ });
55
84
 
56
- inst.onImageError();
57
- inst.onImageError(); // second call is a no-op (errored latch)
58
- expect(inst.errored()).toBe(true);
85
+ it.each<[AvatarSize, string, string]>([
86
+ ['sm', 'h-8', 'text-sm'],
87
+ ['default', 'h-10', 'text-sm'],
88
+ ['lg', 'h-14', 'text-lg'],
89
+ ['xl', 'h-20', 'text-2xl'],
90
+ ['2xl', 'h-28', 'text-3xl'],
91
+ ])('sizes a %s avatar and its initials', async (size, height, text) => {
92
+ const { host, avatar, initials } = await renderAvatar();
93
+ host.size.set(size);
94
+ await waitForUpdate();
95
+ expect(avatar().classList).toContain(height);
96
+ expect(initials()?.classList).toContain(text);
59
97
  });
60
98
 
61
- it('applies size-specific classes for each supported size', async () => {
62
- const { componentRef, container } = await render(UiAvatar);
63
- for (const size of ['sm', 'default', 'lg', 'xl', '2xl'] as const) {
64
- setInputSignal((componentRef.instance as UiAvatar).size, size);
65
- await waitForUpdate();
66
- expect(container).toBeTruthy();
67
- }
99
+ it.each<[AvatarSize, string]>([
100
+ ['sm', 'h-8'],
101
+ ['default', 'h-10'],
102
+ ['lg', 'h-14'],
103
+ ['xl', 'h-20'],
104
+ ['2xl', 'h-28'],
105
+ ])('sizes the image of a %s avatar', async (size, height) => {
106
+ const { host, image } = await renderAvatar(
107
+ 'https://example.com/avatar.png',
108
+ );
109
+ host.size.set(size);
110
+ await waitForUpdate();
111
+ expect(image()?.classList).toContain(height);
68
112
  });
69
113
  });