@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.
- package/README.md +32 -17
- package/dist/chart.d.ts +3 -2
- package/dist/components/alert-banner.d.ts +59 -10
- package/dist/components/alert-banner.js +114 -8
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +3 -0
- package/dist/components/button-group.d.ts +26 -0
- package/dist/components/button-group.js +49 -0
- package/dist/components/button-group.js.map +1 -0
- package/dist/components/calculator.d.ts +3 -0
- package/dist/components/chart-zoom.d.ts +63 -14
- package/dist/components/chart-zoom.js +82 -16
- package/dist/components/chart-zoom.js.map +1 -1
- package/dist/components/chip.d.ts +67 -2
- package/dist/components/chip.js +73 -8
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +120 -0
- package/dist/components/confirm-dialog.js +96 -0
- package/dist/components/confirm-dialog.js.map +1 -0
- package/dist/components/copy-button.d.ts +62 -0
- package/dist/components/copy-button.js +95 -0
- package/dist/components/copy-button.js.map +1 -0
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-pagination.d.ts +5 -1
- package/dist/components/data-table-pagination.js +71 -58
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-sort.d.ts +28 -5
- package/dist/components/data-table-sort.js +11 -7
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +544 -423
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +51 -5
- package/dist/components/date-picker.js +152 -40
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +60 -0
- package/dist/components/description-list.js +112 -0
- package/dist/components/description-list.js.map +1 -0
- package/dist/components/disclosure.d.ts +37 -2
- package/dist/components/disclosure.js +14 -5
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/facing-pair.d.ts +2 -0
- package/dist/components/file-button.d.ts +17 -1
- package/dist/components/file-button.js +8 -1
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +54 -9
- package/dist/components/file-dropzone.js +100 -70
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/floating-panel.d.ts +107 -0
- package/dist/components/floating-panel.js +206 -0
- package/dist/components/floating-panel.js.map +1 -0
- package/dist/components/full-bleed-dialog.d.ts +12 -1
- package/dist/components/full-bleed-dialog.js +28 -5
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/modal.d.ts +6 -2
- package/dist/components/modal.js +2 -1
- package/dist/components/modal.js.map +1 -1
- package/dist/components/number-field.d.ts +3 -0
- package/dist/components/number-input.d.ts +3 -0
- package/dist/components/numpad-sheet.d.ts +3 -0
- package/dist/components/progress-bar.d.ts +54 -0
- package/dist/components/progress-bar.js +94 -0
- package/dist/components/progress-bar.js.map +1 -0
- package/dist/components/scroll-area.d.ts +53 -0
- package/dist/components/scroll-area.js +86 -0
- package/dist/components/scroll-area.js.map +1 -0
- package/dist/components/separator.d.ts +23 -0
- package/dist/components/separator.js +24 -0
- package/dist/components/separator.js.map +1 -0
- package/dist/components/series-chart-ticks.d.ts +34 -1
- package/dist/components/series-chart-ticks.js +83 -1
- package/dist/components/series-chart-ticks.js.map +1 -1
- package/dist/components/series-chart.d.ts +314 -19
- package/dist/components/series-chart.js +510 -120
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/skeleton.d.ts +36 -0
- package/dist/components/skeleton.js +35 -0
- package/dist/components/skeleton.js.map +1 -0
- package/dist/components/stat-tile.d.ts +60 -6
- package/dist/components/stat-tile.js +60 -26
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/table.d.ts +83 -0
- package/dist/components/table.js +166 -0
- package/dist/components/table.js.map +1 -0
- package/dist/components/toggle-legend.d.ts +3 -2
- package/dist/components/toggle-legend.js +1 -1
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tree-view.d.ts +129 -0
- package/dist/components/tree-view.js +376 -0
- package/dist/components/tree-view.js.map +1 -0
- package/dist/components/treemap.d.ts +9 -2
- package/dist/components/treemap.js +6 -2
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/ui.d.ts +94 -13
- package/dist/components/ui.js +111 -29
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
- package/dist/data-table.d.ts +2 -2
- package/dist/data-table.js.map +1 -1
- package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
- package/dist/hooks/use-copy-to-clipboard.js +81 -0
- package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
- package/dist/hooks/use-debounce.d.ts +43 -0
- package/dist/hooks/use-debounce.js +56 -0
- package/dist/hooks/use-debounce.js.map +1 -0
- package/dist/hooks/use-file-drop.d.ts +72 -0
- package/dist/hooks/use-file-drop.js +57 -0
- package/dist/hooks/use-file-drop.js.map +1 -0
- package/dist/i18n/defaults.d.ts +3 -0
- package/dist/i18n/defaults.js +7 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +15 -0
- package/dist/i18n/kit-labels.js +6 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
- package/dist/i18n/locales/de-CH-informal.js +8 -0
- package/dist/i18n/locales/de-CH-informal.js.map +1 -0
- package/dist/i18n/locales/de-CH.d.ts +3 -0
- package/dist/i18n/locales/de-CH.js +1 -14
- package/dist/i18n/locales/de-CH.js.map +1 -1
- package/dist/i18n/locales/de-informal.d.ts +64 -0
- package/dist/i18n/locales/de-informal.js +37 -0
- package/dist/i18n/locales/de-informal.js.map +1 -0
- package/dist/i18n/locales/de.d.ts +3 -0
- package/dist/i18n/locales/de.js +25 -0
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +3 -0
- package/dist/i18n/locales/es.js +25 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +3 -0
- package/dist/i18n/locales/fr.js +25 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +3 -0
- package/dist/i18n/locales/hu.js +25 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +3 -0
- package/dist/i18n/locales/it.js +25 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +3 -0
- package/dist/i18n/locales/zh.js +25 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/swiss.d.ts +13 -0
- package/dist/i18n/swiss.js +19 -0
- package/dist/i18n/swiss.js.map +1 -0
- package/dist/index.d.ts +25 -10
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/dates.d.ts +37 -1
- package/dist/lib/dates.js +19 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/search/command-palette.d.ts +15 -1
- package/dist/search/command-palette.js +10 -2
- package/dist/search/command-palette.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +35 -2
- package/dist/wizard/stepper-nav.js +54 -22
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +107 -5
- package/dist/wizard/types.js +2 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +141 -21
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-summary.d.ts +10 -2
- package/dist/wizard/wizard-summary.js +3 -1
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +4 -1
- package/dist/wizard.js.map +1 -1
- package/package.json +1 -1
- package/src/components/alert-banner.tsx +211 -20
- package/src/components/button-group.tsx +75 -0
- package/src/components/chart-zoom.tsx +158 -20
- package/src/components/chip.tsx +160 -9
- package/src/components/confirm-dialog.tsx +242 -0
- package/src/components/copy-button.tsx +158 -0
- package/src/components/data-table-pagination.tsx +19 -6
- package/src/components/data-table-sort.ts +49 -10
- package/src/components/data-table.tsx +293 -24
- package/src/components/date-picker.tsx +262 -57
- package/src/components/description-list.tsx +174 -0
- package/src/components/disclosure.tsx +58 -5
- package/src/components/file-button.tsx +22 -2
- package/src/components/file-dropzone.tsx +198 -100
- package/src/components/floating-panel.tsx +341 -0
- package/src/components/full-bleed-dialog.tsx +53 -5
- package/src/components/modal.tsx +9 -2
- package/src/components/progress-bar.tsx +163 -0
- package/src/components/scroll-area.tsx +129 -0
- package/src/components/separator.tsx +37 -0
- package/src/components/series-chart-ticks.ts +135 -0
- package/src/components/series-chart.tsx +934 -67
- package/src/components/skeleton.tsx +65 -0
- package/src/components/stat-tile.tsx +160 -27
- package/src/components/table.tsx +263 -0
- package/src/components/toggle-legend.tsx +4 -3
- package/src/components/tree-view.tsx +589 -0
- package/src/components/treemap.tsx +13 -2
- package/src/components/ui.tsx +270 -39
- package/src/data-table.ts +2 -0
- package/src/hooks/use-copy-to-clipboard.ts +122 -0
- package/src/hooks/use-debounce.ts +105 -0
- package/src/hooks/use-file-drop.ts +123 -0
- package/src/i18n/defaults.ts +6 -0
- package/src/i18n/kit-labels.tsx +19 -0
- package/src/i18n/locales/de-CH-informal.ts +10 -0
- package/src/i18n/locales/de-CH.ts +1 -16
- package/src/i18n/locales/de-informal.ts +61 -0
- package/src/i18n/locales/de.ts +25 -0
- package/src/i18n/locales/es.ts +25 -0
- package/src/i18n/locales/fr.ts +25 -0
- package/src/i18n/locales/hu.ts +25 -0
- package/src/i18n/locales/it.ts +25 -0
- package/src/i18n/locales/zh.ts +25 -0
- package/src/i18n/swiss.ts +25 -0
- package/src/index.ts +29 -0
- package/src/lib/dates.ts +48 -0
- package/src/search/command-palette.tsx +29 -3
- package/src/wizard/stepper-nav.tsx +102 -28
- package/src/wizard/types.ts +106 -4
- package/src/wizard/use-wizard.ts +211 -31
- package/src/wizard/wizard-summary.tsx +24 -12
- 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
|
+
}
|