@eifi1/ui-kit 0.7.1 → 0.8.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.
Files changed (222) hide show
  1. package/README.md +32 -17
  2. package/dist/chart.d.ts +3 -2
  3. package/dist/components/alert-banner.d.ts +59 -10
  4. package/dist/components/alert-banner.js +114 -8
  5. package/dist/components/alert-banner.js.map +1 -1
  6. package/dist/components/amount-input.d.ts +3 -0
  7. package/dist/components/button-group.d.ts +26 -0
  8. package/dist/components/button-group.js +49 -0
  9. package/dist/components/button-group.js.map +1 -0
  10. package/dist/components/calculator.d.ts +3 -0
  11. package/dist/components/chart-zoom.d.ts +63 -14
  12. package/dist/components/chart-zoom.js +82 -16
  13. package/dist/components/chart-zoom.js.map +1 -1
  14. package/dist/components/chip.d.ts +67 -2
  15. package/dist/components/chip.js +73 -8
  16. package/dist/components/chip.js.map +1 -1
  17. package/dist/components/confirm-dialog.d.ts +120 -0
  18. package/dist/components/confirm-dialog.js +96 -0
  19. package/dist/components/confirm-dialog.js.map +1 -0
  20. package/dist/components/copy-button.d.ts +62 -0
  21. package/dist/components/copy-button.js +95 -0
  22. package/dist/components/copy-button.js.map +1 -0
  23. package/dist/components/data-table-filter-popover.d.ts +1 -1
  24. package/dist/components/data-table-filters.d.ts +1 -1
  25. package/dist/components/data-table-pagination.d.ts +5 -1
  26. package/dist/components/data-table-pagination.js +71 -58
  27. package/dist/components/data-table-pagination.js.map +1 -1
  28. package/dist/components/data-table-sort.d.ts +28 -5
  29. package/dist/components/data-table-sort.js +11 -7
  30. package/dist/components/data-table-sort.js.map +1 -1
  31. package/dist/components/data-table.d.ts +1 -1
  32. package/dist/components/data-table.js +544 -423
  33. package/dist/components/data-table.js.map +1 -1
  34. package/dist/components/date-picker.d.ts +51 -5
  35. package/dist/components/date-picker.js +152 -40
  36. package/dist/components/date-picker.js.map +1 -1
  37. package/dist/components/description-list.d.ts +60 -0
  38. package/dist/components/description-list.js +112 -0
  39. package/dist/components/description-list.js.map +1 -0
  40. package/dist/components/disclosure.d.ts +37 -2
  41. package/dist/components/disclosure.js +14 -5
  42. package/dist/components/disclosure.js.map +1 -1
  43. package/dist/components/facing-pair.d.ts +2 -0
  44. package/dist/components/file-button.d.ts +17 -1
  45. package/dist/components/file-button.js +8 -1
  46. package/dist/components/file-button.js.map +1 -1
  47. package/dist/components/file-dropzone.d.ts +54 -9
  48. package/dist/components/file-dropzone.js +100 -70
  49. package/dist/components/file-dropzone.js.map +1 -1
  50. package/dist/components/floating-panel.d.ts +107 -0
  51. package/dist/components/floating-panel.js +206 -0
  52. package/dist/components/floating-panel.js.map +1 -0
  53. package/dist/components/full-bleed-dialog.d.ts +12 -1
  54. package/dist/components/full-bleed-dialog.js +28 -5
  55. package/dist/components/full-bleed-dialog.js.map +1 -1
  56. package/dist/components/modal.d.ts +6 -2
  57. package/dist/components/modal.js +2 -1
  58. package/dist/components/modal.js.map +1 -1
  59. package/dist/components/number-field.d.ts +3 -0
  60. package/dist/components/number-input.d.ts +3 -0
  61. package/dist/components/numpad-sheet.d.ts +3 -0
  62. package/dist/components/progress-bar.d.ts +54 -0
  63. package/dist/components/progress-bar.js +94 -0
  64. package/dist/components/progress-bar.js.map +1 -0
  65. package/dist/components/scroll-area.d.ts +53 -0
  66. package/dist/components/scroll-area.js +86 -0
  67. package/dist/components/scroll-area.js.map +1 -0
  68. package/dist/components/separator.d.ts +23 -0
  69. package/dist/components/separator.js +24 -0
  70. package/dist/components/separator.js.map +1 -0
  71. package/dist/components/series-chart-ticks.d.ts +34 -1
  72. package/dist/components/series-chart-ticks.js +83 -1
  73. package/dist/components/series-chart-ticks.js.map +1 -1
  74. package/dist/components/series-chart.d.ts +314 -19
  75. package/dist/components/series-chart.js +510 -120
  76. package/dist/components/series-chart.js.map +1 -1
  77. package/dist/components/skeleton.d.ts +36 -0
  78. package/dist/components/skeleton.js +35 -0
  79. package/dist/components/skeleton.js.map +1 -0
  80. package/dist/components/stat-tile.d.ts +60 -6
  81. package/dist/components/stat-tile.js +60 -26
  82. package/dist/components/stat-tile.js.map +1 -1
  83. package/dist/components/table.d.ts +83 -0
  84. package/dist/components/table.js +166 -0
  85. package/dist/components/table.js.map +1 -0
  86. package/dist/components/toggle-legend.d.ts +3 -2
  87. package/dist/components/toggle-legend.js +1 -1
  88. package/dist/components/toggle-legend.js.map +1 -1
  89. package/dist/components/tree-view.d.ts +129 -0
  90. package/dist/components/tree-view.js +376 -0
  91. package/dist/components/tree-view.js.map +1 -0
  92. package/dist/components/treemap.d.ts +9 -2
  93. package/dist/components/treemap.js +6 -2
  94. package/dist/components/treemap.js.map +1 -1
  95. package/dist/components/ui.d.ts +94 -13
  96. package/dist/components/ui.js +111 -29
  97. package/dist/components/ui.js.map +1 -1
  98. package/dist/components/use-table-state.d.ts +1 -1
  99. package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
  100. package/dist/data-table.d.ts +2 -2
  101. package/dist/data-table.js.map +1 -1
  102. package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
  103. package/dist/hooks/use-copy-to-clipboard.js +81 -0
  104. package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
  105. package/dist/hooks/use-debounce.d.ts +43 -0
  106. package/dist/hooks/use-debounce.js +56 -0
  107. package/dist/hooks/use-debounce.js.map +1 -0
  108. package/dist/hooks/use-file-drop.d.ts +72 -0
  109. package/dist/hooks/use-file-drop.js +57 -0
  110. package/dist/hooks/use-file-drop.js.map +1 -0
  111. package/dist/i18n/defaults.d.ts +3 -0
  112. package/dist/i18n/defaults.js +7 -1
  113. package/dist/i18n/defaults.js.map +1 -1
  114. package/dist/i18n/kit-labels.d.ts +15 -0
  115. package/dist/i18n/kit-labels.js +6 -2
  116. package/dist/i18n/kit-labels.js.map +1 -1
  117. package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
  118. package/dist/i18n/locales/de-CH-informal.js +8 -0
  119. package/dist/i18n/locales/de-CH-informal.js.map +1 -0
  120. package/dist/i18n/locales/de-CH.d.ts +3 -0
  121. package/dist/i18n/locales/de-CH.js +1 -14
  122. package/dist/i18n/locales/de-CH.js.map +1 -1
  123. package/dist/i18n/locales/de-informal.d.ts +64 -0
  124. package/dist/i18n/locales/de-informal.js +37 -0
  125. package/dist/i18n/locales/de-informal.js.map +1 -0
  126. package/dist/i18n/locales/de.d.ts +3 -0
  127. package/dist/i18n/locales/de.js +25 -0
  128. package/dist/i18n/locales/de.js.map +1 -1
  129. package/dist/i18n/locales/es.d.ts +3 -0
  130. package/dist/i18n/locales/es.js +25 -0
  131. package/dist/i18n/locales/es.js.map +1 -1
  132. package/dist/i18n/locales/fr.d.ts +3 -0
  133. package/dist/i18n/locales/fr.js +25 -0
  134. package/dist/i18n/locales/fr.js.map +1 -1
  135. package/dist/i18n/locales/hu.d.ts +3 -0
  136. package/dist/i18n/locales/hu.js +25 -0
  137. package/dist/i18n/locales/hu.js.map +1 -1
  138. package/dist/i18n/locales/it.d.ts +3 -0
  139. package/dist/i18n/locales/it.js +25 -0
  140. package/dist/i18n/locales/it.js.map +1 -1
  141. package/dist/i18n/locales/zh.d.ts +3 -0
  142. package/dist/i18n/locales/zh.js +25 -0
  143. package/dist/i18n/locales/zh.js.map +1 -1
  144. package/dist/i18n/swiss.d.ts +13 -0
  145. package/dist/i18n/swiss.js +19 -0
  146. package/dist/i18n/swiss.js.map +1 -0
  147. package/dist/index.d.ts +25 -10
  148. package/dist/index.js +22 -0
  149. package/dist/index.js.map +1 -1
  150. package/dist/lib/dates.d.ts +37 -1
  151. package/dist/lib/dates.js +19 -0
  152. package/dist/lib/dates.js.map +1 -1
  153. package/dist/search/command-palette.d.ts +15 -1
  154. package/dist/search/command-palette.js +10 -2
  155. package/dist/search/command-palette.js.map +1 -1
  156. package/dist/wizard/stepper-nav.d.ts +35 -2
  157. package/dist/wizard/stepper-nav.js +54 -22
  158. package/dist/wizard/stepper-nav.js.map +1 -1
  159. package/dist/wizard/types.d.ts +107 -5
  160. package/dist/wizard/types.js +2 -1
  161. package/dist/wizard/types.js.map +1 -1
  162. package/dist/wizard/use-wizard.js +141 -21
  163. package/dist/wizard/use-wizard.js.map +1 -1
  164. package/dist/wizard/wizard-summary.d.ts +10 -2
  165. package/dist/wizard/wizard-summary.js +3 -1
  166. package/dist/wizard/wizard-summary.js.map +1 -1
  167. package/dist/wizard.d.ts +4 -1
  168. package/dist/wizard.js.map +1 -1
  169. package/package.json +1 -1
  170. package/src/components/alert-banner.tsx +211 -20
  171. package/src/components/button-group.tsx +75 -0
  172. package/src/components/chart-zoom.tsx +158 -20
  173. package/src/components/chip.tsx +160 -9
  174. package/src/components/confirm-dialog.tsx +242 -0
  175. package/src/components/copy-button.tsx +158 -0
  176. package/src/components/data-table-pagination.tsx +19 -6
  177. package/src/components/data-table-sort.ts +49 -10
  178. package/src/components/data-table.tsx +293 -24
  179. package/src/components/date-picker.tsx +262 -57
  180. package/src/components/description-list.tsx +174 -0
  181. package/src/components/disclosure.tsx +58 -5
  182. package/src/components/file-button.tsx +22 -2
  183. package/src/components/file-dropzone.tsx +198 -100
  184. package/src/components/floating-panel.tsx +341 -0
  185. package/src/components/full-bleed-dialog.tsx +53 -5
  186. package/src/components/modal.tsx +9 -2
  187. package/src/components/progress-bar.tsx +163 -0
  188. package/src/components/scroll-area.tsx +129 -0
  189. package/src/components/separator.tsx +37 -0
  190. package/src/components/series-chart-ticks.ts +135 -0
  191. package/src/components/series-chart.tsx +934 -67
  192. package/src/components/skeleton.tsx +65 -0
  193. package/src/components/stat-tile.tsx +160 -27
  194. package/src/components/table.tsx +263 -0
  195. package/src/components/toggle-legend.tsx +4 -3
  196. package/src/components/tree-view.tsx +589 -0
  197. package/src/components/treemap.tsx +13 -2
  198. package/src/components/ui.tsx +270 -39
  199. package/src/data-table.ts +2 -0
  200. package/src/hooks/use-copy-to-clipboard.ts +122 -0
  201. package/src/hooks/use-debounce.ts +105 -0
  202. package/src/hooks/use-file-drop.ts +123 -0
  203. package/src/i18n/defaults.ts +6 -0
  204. package/src/i18n/kit-labels.tsx +19 -0
  205. package/src/i18n/locales/de-CH-informal.ts +10 -0
  206. package/src/i18n/locales/de-CH.ts +1 -16
  207. package/src/i18n/locales/de-informal.ts +61 -0
  208. package/src/i18n/locales/de.ts +25 -0
  209. package/src/i18n/locales/es.ts +25 -0
  210. package/src/i18n/locales/fr.ts +25 -0
  211. package/src/i18n/locales/hu.ts +25 -0
  212. package/src/i18n/locales/it.ts +25 -0
  213. package/src/i18n/locales/zh.ts +25 -0
  214. package/src/i18n/swiss.ts +25 -0
  215. package/src/index.ts +29 -0
  216. package/src/lib/dates.ts +48 -0
  217. package/src/search/command-palette.tsx +29 -3
  218. package/src/wizard/stepper-nav.tsx +102 -28
  219. package/src/wizard/types.ts +106 -4
  220. package/src/wizard/use-wizard.ts +211 -31
  221. package/src/wizard/wizard-summary.tsx +24 -12
  222. package/src/wizard.ts +3 -2
@@ -0,0 +1,129 @@
1
+ import { useCallback, useLayoutEffect, useRef, useState } from "react";
2
+ import type { ComponentPropsWithoutRef, Ref, RefObject } from "react";
3
+ import { cn } from "../lib/cn";
4
+
5
+ /**
6
+ * Whether `ref`'s element currently overflows its box — the one fact a scroll
7
+ * container needs to decide whether it is a keyboard stop.
8
+ *
9
+ * Measured on every commit (cheap: two reads, and state is set only on a change) and
10
+ * whenever the element or one of its direct children resizes, which covers both
11
+ * "the panel got narrower" and "the rows arrived". Without `ResizeObserver` (jsdom,
12
+ * very old engines) the per-commit measurement is what remains.
13
+ *
14
+ * Shared with {@link Table}'s overflow wrapper, which has the same obligation.
15
+ */
16
+ export function useScrollOverflow(ref: RefObject<HTMLElement | null>): boolean {
17
+ const [overflowing, setOverflowing] = useState(false);
18
+
19
+ const measure = useCallback(() => {
20
+ const el = ref.current;
21
+ if (!el) return;
22
+ const next = el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
23
+ setOverflowing((prev) => (prev === next ? prev : next));
24
+ }, [ref]);
25
+
26
+ // No dependency list on purpose: content changes arrive as re-renders.
27
+ useLayoutEffect(() => {
28
+ measure();
29
+ });
30
+
31
+ useLayoutEffect(() => {
32
+ const el = ref.current;
33
+ if (!el || typeof ResizeObserver === "undefined") return;
34
+ const ro = new ResizeObserver(measure);
35
+ ro.observe(el);
36
+ for (const child of Array.from(el.children)) ro.observe(child);
37
+ return () => ro.disconnect();
38
+ }, [ref, measure]);
39
+
40
+ return overflowing;
41
+ }
42
+
43
+ /** Thin, token-coloured scrollbars. `scrollbar-width` / `scrollbar-color` are the
44
+ * standard (Firefox, Chrome 121+); the `::-webkit-scrollbar` rules are for Safari,
45
+ * which has neither. Where the standard pair applies, Chrome ignores the rest. */
46
+ export const THIN_SCROLLBAR_CLASS = cn(
47
+ "[scrollbar-width:thin] [scrollbar-color:var(--border-strong)_transparent]",
48
+ "[&::-webkit-scrollbar]:size-2 [&::-webkit-scrollbar-track]:bg-transparent",
49
+ "[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-[var(--border-strong)]",
50
+ );
51
+
52
+ const AXIS = {
53
+ vertical: "overflow-y-auto overflow-x-hidden",
54
+ horizontal: "overflow-x-auto overflow-y-hidden",
55
+ both: "overflow-auto",
56
+ } as const;
57
+
58
+ export interface ScrollAreaProps extends ComponentPropsWithoutRef<"div"> {
59
+ /** Which way the content may scroll. Default `vertical`. */
60
+ orientation?: keyof typeof AXIS;
61
+ /**
62
+ * The region's accessible name. With it (or `aria-labelledby`) the container is a
63
+ * `role="region"` a reader can find and name; without it, it is a plain box.
64
+ * Name every ScrollArea whose content can overflow: once it is a tab stop, a
65
+ * reader announces it on focus, and an unnamed one is announced as nothing.
66
+ */
67
+ label?: string;
68
+ ref?: Ref<HTMLDivElement>;
69
+ }
70
+
71
+ /**
72
+ * A styled overflow container: a thin scrollbar in the kit's colours, a focus ring,
73
+ * and the keyboard access a plain `overflow-auto` div does not have.
74
+ *
75
+ * kastlan used Radix's ScrollArea for this and nothing else from it. Radix draws a
76
+ * custom scrollbar in JavaScript; the platform's own, thinned and coloured, costs no
77
+ * script and keeps native momentum, overscroll and scrollbar-dragging behaviour.
78
+ *
79
+ * A container that scrolls but holds nothing focusable cannot be scrolled from the
80
+ * keyboard at all — Tab jumps straight past it (WCAG 2.1.1). So while the content
81
+ * overflows, the container itself is a tab stop (`tabIndex=0`), and arrow keys and
82
+ * Page Up/Down scroll it natively. While nothing overflows it is not, so a short list
83
+ * adds no empty stop to the tab order.
84
+ */
85
+ export function ScrollArea({
86
+ orientation = "vertical",
87
+ label,
88
+ className,
89
+ ref,
90
+ tabIndex,
91
+ "aria-labelledby": labelledBy,
92
+ ...rest
93
+ }: ScrollAreaProps) {
94
+ const inner = useRef<HTMLDivElement | null>(null);
95
+ const overflowing = useScrollOverflow(inner);
96
+ const setRef = useCallback(
97
+ (el: HTMLDivElement | null) => {
98
+ inner.current = el;
99
+ if (typeof ref === "function") ref(el);
100
+ else if (ref) ref.current = el;
101
+ },
102
+ [ref],
103
+ );
104
+ const named = label !== undefined || labelledBy !== undefined;
105
+
106
+ return (
107
+ <div
108
+ {...rest}
109
+ ref={setRef}
110
+ role={named ? "region" : undefined}
111
+ aria-label={label}
112
+ aria-labelledby={labelledBy}
113
+ tabIndex={tabIndex ?? (overflowing ? 0 : undefined)}
114
+ data-overflowing={overflowing || undefined}
115
+ data-orientation={orientation}
116
+ className={cn(
117
+ "relative min-h-0 min-w-0",
118
+ AXIS[orientation],
119
+ THIN_SCROLLBAR_CLASS,
120
+ // An OUTLINE, inset: a ring is a box-shadow, which paints beneath the content —
121
+ // a row with its own background would cover exactly the part of the ring that
122
+ // says where focus is. An outline paints above it, and the negative offset
123
+ // keeps it inside a parent that clips.
124
+ "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--brand)]",
125
+ className,
126
+ )}
127
+ />
128
+ );
129
+ }
@@ -0,0 +1,37 @@
1
+ import type { ComponentPropsWithoutRef } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ export interface SeparatorProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> {
5
+ orientation?: "horizontal" | "vertical";
6
+ /**
7
+ * `false` (default, as in Radix): a real `role="separator"`, which a reader
8
+ * announces — right between groups of controls, a menu's sections, a toolbar's
9
+ * halves. `true`: a line for the eye only (`role="none"`), for a divider that
10
+ * separates nothing the structure does not already separate.
11
+ */
12
+ decorative?: boolean;
13
+ }
14
+
15
+ /**
16
+ * A hairline between two things.
17
+ *
18
+ * kastlan's other Radix leftover. `aria-orientation` is set only when vertical:
19
+ * horizontal is the role's default, and stating a default is noise in every reader's
20
+ * verbose mode.
21
+ */
22
+ export function Separator({ orientation = "horizontal", decorative = false, className, ...rest }: SeparatorProps) {
23
+ const vertical = orientation === "vertical";
24
+ return (
25
+ <div
26
+ {...rest}
27
+ role={decorative ? "none" : "separator"}
28
+ aria-orientation={!decorative && vertical ? "vertical" : undefined}
29
+ data-orientation={orientation}
30
+ className={cn(
31
+ "shrink-0 bg-[var(--border)]",
32
+ vertical ? "w-px self-stretch" : "h-px w-full",
33
+ className,
34
+ )}
35
+ />
36
+ );
37
+ }
@@ -50,3 +50,138 @@ export function niceTicks(
50
50
  }
51
51
  return ticks.length ? ticks : undefined;
52
52
  }
53
+
54
+ /**
55
+ * {@link niceTicks} on whole numbers only: the step never drops below 1, so a short
56
+ * index series (a domain of [-0.2, 4.2]) is ticked 0 1 2 3 4 instead of every half.
57
+ * `undefined` when the window holds no whole number at all, so the caller can fall
58
+ * back to the fractional ticks of a deep zoom rather than show none.
59
+ */
60
+ export function integerTicks(
61
+ domain: readonly [number, number] | undefined,
62
+ target: number = TARGET_TICKS,
63
+ ): number[] | undefined {
64
+ if (!domain) return undefined;
65
+ const [low, high] = domain;
66
+ if (!Number.isFinite(low) || !Number.isFinite(high) || !(high >= low)) return undefined;
67
+ const step = high > low ? Math.max(1, niceStep(high - low, target)) : 1;
68
+ const ticks: number[] = [];
69
+ const first = Math.ceil(low / step - 1e-6);
70
+ const last = Math.floor(high / step + 1e-6);
71
+ for (let k = first; k <= last; k++) ticks.push(k * step + 0);
72
+ return ticks.length ? ticks : undefined;
73
+ }
74
+
75
+ /**
76
+ * The slots of a CATEGORY axis that lie inside a window over their positions.
77
+ *
78
+ * `SeriesChart` draws a category axis on the slot INDEX (see `x.type: "category"`), so a
79
+ * zoom window is a range of fractional positions — [2.6, 8.3] after a drag across the
80
+ * third to ninth month. A tick belongs on every whole slot inside it, and on nothing in
81
+ * between: there is no month 2.5. Recharts' `minTickGap` thins them where they crowd.
82
+ */
83
+ export function categoryTicks(
84
+ domain: readonly [number, number] | undefined,
85
+ count: number,
86
+ ): number[] | undefined {
87
+ if (!domain || !(count > 0)) return undefined;
88
+ const first = Math.max(0, Math.ceil(domain[0] - 1e-6));
89
+ const last = Math.min(count - 1, Math.floor(domain[1] + 1e-6));
90
+ const ticks: number[] = [];
91
+ for (let slot = first; slot <= last; slot++) ticks.push(slot);
92
+ return ticks.length ? ticks : undefined;
93
+ }
94
+
95
+ /** What one tick of a time axis stands for, which is also how its label is written. */
96
+ export type TimeTickUnit = "hour" | "day" | "month" | "year";
97
+
98
+ const HOUR = 3_600_000;
99
+ const DAY = 24 * HOUR;
100
+
101
+ /**
102
+ * The calendar steps a time axis may tick in, with their rough length for choosing.
103
+ * Months and years are stepped with `Date` setters, not by adding their "length":
104
+ * thirty days from the 1st of February is the 3rd of March, and a tick that promises a
105
+ * month start and prints the 3rd is the ruler lying.
106
+ */
107
+ const TIME_STEPS: readonly { unit: TimeTickUnit; n: number; ms: number }[] = [
108
+ { unit: "hour", n: 1, ms: HOUR },
109
+ { unit: "hour", n: 3, ms: 3 * HOUR },
110
+ { unit: "hour", n: 6, ms: 6 * HOUR },
111
+ { unit: "hour", n: 12, ms: 12 * HOUR },
112
+ { unit: "day", n: 1, ms: DAY },
113
+ { unit: "day", n: 2, ms: 2 * DAY },
114
+ { unit: "day", n: 7, ms: 7 * DAY },
115
+ { unit: "month", n: 1, ms: 30.44 * DAY },
116
+ { unit: "month", n: 2, ms: 60.88 * DAY },
117
+ { unit: "month", n: 3, ms: 91.31 * DAY },
118
+ { unit: "month", n: 6, ms: 182.62 * DAY },
119
+ ];
120
+
121
+ /**
122
+ * Calendar ticks inside a window of epoch milliseconds — midnights, Mondays, month and
123
+ * quarter starts, new years, in LOCAL time — and the unit they are in, so the label can
124
+ * say as much as the tick means ("Mar 26" for a month start, "14 Mar" for a day).
125
+ *
126
+ * The time counterpart of {@link niceTicks}: a 1/2/5 ladder over raw milliseconds lands
127
+ * on 5 × 10⁹ ms, every 58 days, at whatever hour that happens to be. keksdose's price
128
+ * history and cash buffer each thinned their own ticks by hand for exactly this reason.
129
+ */
130
+ export function timeTicksWithUnit(
131
+ domain: readonly [number, number] | undefined,
132
+ target: number = TARGET_TICKS,
133
+ ): { ticks: number[]; unit: TimeTickUnit } | undefined {
134
+ if (!domain) return undefined;
135
+ const [low, high] = domain;
136
+ if (!Number.isFinite(low) || !Number.isFinite(high) || !(high > low)) return undefined;
137
+ const rough = (high - low) / Math.max(1, target);
138
+ let unit: TimeTickUnit = "year";
139
+ let n = 1;
140
+ let best = Infinity;
141
+ for (const step of TIME_STEPS) {
142
+ const miss = Math.abs(Math.log(step.ms / rough));
143
+ if (miss < best) {
144
+ best = miss;
145
+ unit = step.unit;
146
+ n = step.n;
147
+ }
148
+ }
149
+ // The year ladder competes on the same terms as the table: one year is 365.25 days.
150
+ const yearN = Math.max(1, Math.round(niceStep((high - low) / (365.25 * DAY), target)));
151
+ if (Math.abs(Math.log((yearN * 365.25 * DAY) / rough)) < best) {
152
+ unit = "year";
153
+ n = yearN;
154
+ }
155
+
156
+ const start = new Date(low);
157
+ start.setMinutes(0, 0, 0);
158
+ if (unit !== "hour") start.setHours(0);
159
+ if (unit === "month" || unit === "year") start.setDate(1);
160
+ if (unit === "year") start.setMonth(0);
161
+ // Onto the step's own grid: hours of the day, Mondays, month indices, years — so a
162
+ // zoom window starting on a Wednesday still ticks on Mondays.
163
+ if (unit === "hour") start.setHours(start.getHours() - (start.getHours() % n));
164
+ if (unit === "day" && n === 7) start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
165
+ if (unit === "month") start.setMonth(start.getMonth() - (start.getMonth() % n));
166
+ if (unit === "year") start.setFullYear(start.getFullYear() - (start.getFullYear() % n));
167
+
168
+ const ticks: number[] = [];
169
+ const at = new Date(start);
170
+ // Bounded, so a window of a million years cannot hang the render.
171
+ for (let guard = 0; guard < 1000 && at.getTime() <= high; guard++) {
172
+ if (at.getTime() >= low) ticks.push(at.getTime());
173
+ if (unit === "hour") at.setHours(at.getHours() + n);
174
+ else if (unit === "day") at.setDate(at.getDate() + n);
175
+ else if (unit === "month") at.setMonth(at.getMonth() + n);
176
+ else at.setFullYear(at.getFullYear() + n);
177
+ }
178
+ return ticks.length ? { ticks, unit } : undefined;
179
+ }
180
+
181
+ /** {@link timeTicksWithUnit}, ticks only. */
182
+ export function timeTicks(
183
+ domain: readonly [number, number] | undefined,
184
+ target: number = TARGET_TICKS,
185
+ ): number[] | undefined {
186
+ return timeTicksWithUnit(domain, target)?.ticks;
187
+ }