@hexclave/dashboard-ui-components 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/button.d.ts +2 -2
- package/dist/components/pill-toggle.js +2 -2
- package/dist/components/pill-toggle.js.map +1 -1
- package/dist/dashboard-ui-components.global.js +7447 -9242
- package/dist/dashboard-ui-components.global.js.map +4 -4
- package/dist/esm/components/button.d.ts +2 -2
- package/dist/esm/components/pill-toggle.js +3 -3
- package/dist/esm/components/pill-toggle.js.map +1 -1
- package/package.json +4 -3
- package/src/components/alert.tsx +120 -0
- package/src/components/analytics-chart/analytics-chart-pie.tsx +369 -0
- package/src/components/analytics-chart/analytics-chart.tsx +1585 -0
- package/src/components/analytics-chart/default-analytics-chart-tooltip.tsx +265 -0
- package/src/components/analytics-chart/format.ts +101 -0
- package/src/components/analytics-chart/index.ts +75 -0
- package/src/components/analytics-chart/palette.ts +68 -0
- package/src/components/analytics-chart/render-data-series.tsx +169 -0
- package/src/components/analytics-chart/state.ts +165 -0
- package/src/components/analytics-chart/strings.ts +72 -0
- package/src/components/analytics-chart/types.ts +220 -0
- package/src/components/badge.tsx +108 -0
- package/src/components/button.tsx +104 -0
- package/src/components/card.tsx +263 -0
- package/src/components/chart-card.tsx +101 -0
- package/src/components/chart-container.tsx +155 -0
- package/src/components/chart-legend.tsx +65 -0
- package/src/components/chart-theme.tsx +52 -0
- package/src/components/chart-tooltip.tsx +165 -0
- package/src/components/cursor-blast-effect.tsx +334 -0
- package/src/components/data-grid/data-grid-sizing.ts +51 -0
- package/src/components/data-grid/data-grid-toolbar.tsx +373 -0
- package/src/components/data-grid/data-grid.test.tsx +366 -0
- package/src/components/data-grid/data-grid.tsx +1220 -0
- package/src/components/data-grid/index.ts +64 -0
- package/src/components/data-grid/state.ts +235 -0
- package/src/components/data-grid/strings.ts +45 -0
- package/src/components/data-grid/types.ts +401 -0
- package/src/components/data-grid/use-data-source.ts +413 -0
- package/src/components/data-grid/use-url-state.test.tsx +88 -0
- package/src/components/data-grid/use-url-state.ts +298 -0
- package/src/components/dialog.tsx +207 -0
- package/src/components/edit-mode.tsx +17 -0
- package/src/components/empty-state.tsx +64 -0
- package/src/components/input.tsx +85 -0
- package/src/components/metric-card.tsx +142 -0
- package/src/components/pill-toggle.tsx +159 -0
- package/src/components/progress-bar.tsx +82 -0
- package/src/components/separator.tsx +36 -0
- package/src/components/skeleton.tsx +30 -0
- package/src/components/table.tsx +113 -0
- package/src/components/tabs.tsx +214 -0
- package/src/index.ts +69 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { cn } from "@hexclave/ui";
|
|
2
|
+
import {
|
|
3
|
+
ArrowDownIcon,
|
|
4
|
+
ArrowUpIcon,
|
|
5
|
+
CursorClickIcon,
|
|
6
|
+
MinusIcon,
|
|
7
|
+
PushPinSimpleIcon,
|
|
8
|
+
} from "@phosphor-icons/react";
|
|
9
|
+
import type { CSSProperties } from "react";
|
|
10
|
+
import type { AnalyticsChartStrings } from "./strings";
|
|
11
|
+
import type { AnalyticsChartDelta, Point } from "./types";
|
|
12
|
+
|
|
13
|
+
/** Trend pill — small rounded badge with an up/down/flat arrow, a signed
|
|
14
|
+
* percentage, and an optional trailing label. Shared between the default
|
|
15
|
+
* tooltip, the pie view, and the demo panels (which re-export it). */
|
|
16
|
+
export function TrendPill({
|
|
17
|
+
delta,
|
|
18
|
+
label,
|
|
19
|
+
size = "sm",
|
|
20
|
+
}: {
|
|
21
|
+
delta: AnalyticsChartDelta,
|
|
22
|
+
label?: string,
|
|
23
|
+
size?: "sm" | "md",
|
|
24
|
+
}) {
|
|
25
|
+
const { pct, sign } = delta;
|
|
26
|
+
const tone =
|
|
27
|
+
sign === "up" ? "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
|
|
28
|
+
: sign === "down" ? "text-rose-600 dark:text-rose-400 bg-rose-500/10"
|
|
29
|
+
: "text-muted-foreground bg-foreground/[0.06]";
|
|
30
|
+
const Icon = sign === "up" ? ArrowUpIcon : sign === "down" ? ArrowDownIcon : MinusIcon;
|
|
31
|
+
const text = pct == null ? "—" : `${pct > 0 ? "+" : ""}${pct}%`;
|
|
32
|
+
return (
|
|
33
|
+
<span className={cn(
|
|
34
|
+
"inline-flex items-center gap-1 rounded-full font-mono tabular-nums font-medium",
|
|
35
|
+
size === "md" ? "px-2 py-0.5 text-[11px]" : "px-1.5 py-[1px] text-[10px]",
|
|
36
|
+
tone,
|
|
37
|
+
)}>
|
|
38
|
+
<Icon weight="bold" className={size === "md" ? "size-3" : "size-2.5"} aria-hidden="true" />
|
|
39
|
+
{text}
|
|
40
|
+
{label && <span className="ml-0.5 text-muted-foreground font-normal">{label}</span>}
|
|
41
|
+
</span>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type AnalyticsChartTooltipSegmentRow = {
|
|
46
|
+
key: string,
|
|
47
|
+
label: string,
|
|
48
|
+
value: number,
|
|
49
|
+
/** Light-theme color for the dot / swatch. */
|
|
50
|
+
color: string,
|
|
51
|
+
/** Dark-theme color for the dot / swatch. */
|
|
52
|
+
colorDark: string,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type AnalyticsChartTooltipLayerView = {
|
|
56
|
+
/** Stable layer id (e.g. `"signups"`, `"previous"`). */
|
|
57
|
+
id: string,
|
|
58
|
+
/** Consumer-provided layer label. */
|
|
59
|
+
label: string,
|
|
60
|
+
/** Resolved layer color (light theme). */
|
|
61
|
+
color: string,
|
|
62
|
+
/** Resolved layer color (dark theme). */
|
|
63
|
+
colorDark: string,
|
|
64
|
+
/** Flat total for this layer at the hovered index. Populated regardless
|
|
65
|
+
* of segmentation so consumers can render the same number in either mode. */
|
|
66
|
+
total: number,
|
|
67
|
+
/** True iff this layer is rendered as a stacked breakdown. */
|
|
68
|
+
segmented: boolean,
|
|
69
|
+
/** Per-segment rows — empty when `segmented === false`. Order matches
|
|
70
|
+
* `segmentSeries`. */
|
|
71
|
+
segments: AnalyticsChartTooltipSegmentRow[],
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type AnalyticsChartTooltipContext = {
|
|
75
|
+
/** Index into the visible window. */
|
|
76
|
+
activeIndex: number,
|
|
77
|
+
/** Raw point at `activeIndex` — convenient for `.ts` access. */
|
|
78
|
+
point: Point,
|
|
79
|
+
/** True when the tooltip is pinned (via click) and stable under hover. */
|
|
80
|
+
isPinned: boolean,
|
|
81
|
+
/** Primary layer view or null when the primary layer is hidden. */
|
|
82
|
+
primary: AnalyticsChartTooltipLayerView | null,
|
|
83
|
+
/** Compare layer view or null when the compare layer is hidden. */
|
|
84
|
+
compare: AnalyticsChartTooltipLayerView | null,
|
|
85
|
+
/** Flat-mode delta between primary and compare totals. Null when either
|
|
86
|
+
* side is hidden. Consumers should feed this into their trend pill. */
|
|
87
|
+
delta: AnalyticsChartDelta | null,
|
|
88
|
+
/** Pre-bound value formatter for y-axis values. */
|
|
89
|
+
formatValue: (v: number) => string,
|
|
90
|
+
/** Pre-bound formatter for x-axis values. */
|
|
91
|
+
formatDate: (ts: number) => string,
|
|
92
|
+
/** Resolved strings — already merged with defaults. */
|
|
93
|
+
strings: AnalyticsChartStrings,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type DefaultAnalyticsChartTooltipProps = {
|
|
97
|
+
ctx: AnalyticsChartTooltipContext,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** The default tooltip body. The tooltip is rendered as an
|
|
101
|
+
* absolutely-positioned sibling of `<ChartContainer>`, which means it sits
|
|
102
|
+
* OUTSIDE the `[data-chart=…]` subtree that scopes shadcn's `--color-${key}`
|
|
103
|
+
* CSS variables. We therefore cannot reference those variables for segment
|
|
104
|
+
* swatches — instead, every swatch uses a single span with `--c-l`/`--c-d`
|
|
105
|
+
* custom properties + Tailwind arbitrary variants so one DOM element covers
|
|
106
|
+
* both themes. */
|
|
107
|
+
export function DefaultAnalyticsChartTooltip({ ctx }: DefaultAnalyticsChartTooltipProps) {
|
|
108
|
+
const { point, isPinned, primary, compare, delta, formatValue: fv, formatDate: fd, strings } = ctx;
|
|
109
|
+
const anySegmented = (primary?.segmented ?? false) || (compare?.segmented ?? false);
|
|
110
|
+
return (
|
|
111
|
+
<div className="rounded-xl border border-foreground/10 bg-background/95 px-3 py-2.5 shadow-[0_10px_28px_rgba(15,23,42,0.18)] backdrop-blur-xl dark:shadow-[0_10px_28px_rgba(0,0,0,0.55)] min-w-[180px]">
|
|
112
|
+
<div className="flex items-center justify-between gap-3">
|
|
113
|
+
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
114
|
+
{fd(point.ts)}
|
|
115
|
+
</span>
|
|
116
|
+
{isPinned && (
|
|
117
|
+
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-[1px] text-[9px] font-semibold uppercase tracking-wider text-blue-600 dark:text-blue-300">
|
|
118
|
+
<PushPinSimpleIcon weight="fill" className="size-2.5" aria-hidden="true" />
|
|
119
|
+
{strings.pinnedBadge}
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
<div className="mt-2 flex flex-col gap-1.5">
|
|
124
|
+
<TooltipLayerRows view={primary} keyPrefix="p" fv={fv} muted={false} />
|
|
125
|
+
<TooltipLayerRows view={compare} keyPrefix="c" fv={fv} muted />
|
|
126
|
+
</div>
|
|
127
|
+
{anySegmented && (
|
|
128
|
+
<div className="mt-2 flex flex-col gap-1 border-t border-foreground/[0.07] pt-2">
|
|
129
|
+
{primary?.segmented && (
|
|
130
|
+
<LayerSummaryRow
|
|
131
|
+
label={`${primary.label}${strings.layerTotalSuffix}`}
|
|
132
|
+
value={fv(primary.total)}
|
|
133
|
+
muted={false}
|
|
134
|
+
/>
|
|
135
|
+
)}
|
|
136
|
+
{compare?.segmented && (
|
|
137
|
+
<LayerSummaryRow
|
|
138
|
+
label={`${compare.label}${strings.layerTotalSuffix}`}
|
|
139
|
+
value={fv(compare.total)}
|
|
140
|
+
muted
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
143
|
+
{primary?.segmented && compare?.segmented && delta && (
|
|
144
|
+
<div className="mt-1 flex items-center justify-between">
|
|
145
|
+
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
146
|
+
{strings.deltaVsPrev}
|
|
147
|
+
</span>
|
|
148
|
+
<TrendPill delta={delta} size="sm" />
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
{!anySegmented && delta && primary && compare && (
|
|
154
|
+
<div className="mt-2 flex items-center justify-between border-t border-foreground/[0.07] pt-2">
|
|
155
|
+
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
156
|
+
{strings.deltaVsPrev}
|
|
157
|
+
</span>
|
|
158
|
+
<TrendPill delta={delta} size="sm" />
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
<div className="mt-2 flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
162
|
+
<CursorClickIcon weight="bold" className="size-2.5" aria-hidden="true" />
|
|
163
|
+
<span>{isPinned ? strings.hintClickAnywhereUnpin : strings.hintClickToPin}</span>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Render either a single flat row or the per-segment breakdown rows for
|
|
170
|
+
* one tooltip layer view. Collapses what were formerly two parallel
|
|
171
|
+
* primary/compare branches into one component. */
|
|
172
|
+
function TooltipLayerRows({
|
|
173
|
+
view,
|
|
174
|
+
keyPrefix,
|
|
175
|
+
fv,
|
|
176
|
+
muted,
|
|
177
|
+
}: {
|
|
178
|
+
view: AnalyticsChartTooltipLayerView | null,
|
|
179
|
+
keyPrefix: string,
|
|
180
|
+
fv: (v: number) => string,
|
|
181
|
+
muted: boolean,
|
|
182
|
+
}) {
|
|
183
|
+
if (!view) return null;
|
|
184
|
+
if (!view.segmented) {
|
|
185
|
+
return (
|
|
186
|
+
<LayerTotalRow
|
|
187
|
+
light={view.color}
|
|
188
|
+
dark={view.colorDark}
|
|
189
|
+
label={view.label}
|
|
190
|
+
value={fv(view.total)}
|
|
191
|
+
muted={muted}
|
|
192
|
+
/>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return (
|
|
196
|
+
<>
|
|
197
|
+
{view.segments.map((s) => (
|
|
198
|
+
<LayerTotalRow
|
|
199
|
+
key={`${keyPrefix}-${s.key}`}
|
|
200
|
+
light={s.color}
|
|
201
|
+
dark={s.colorDark}
|
|
202
|
+
label={s.label}
|
|
203
|
+
value={fv(s.value)}
|
|
204
|
+
muted={muted}
|
|
205
|
+
/>
|
|
206
|
+
))}
|
|
207
|
+
</>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Single row: swatch + label + value. One DOM element for the swatch —
|
|
212
|
+
* dark-mode color is picked up via `--c-d`. */
|
|
213
|
+
function LayerTotalRow({
|
|
214
|
+
light,
|
|
215
|
+
dark,
|
|
216
|
+
label,
|
|
217
|
+
value,
|
|
218
|
+
muted,
|
|
219
|
+
}: {
|
|
220
|
+
light: string,
|
|
221
|
+
dark: string,
|
|
222
|
+
label: string,
|
|
223
|
+
value: string,
|
|
224
|
+
muted: boolean,
|
|
225
|
+
}) {
|
|
226
|
+
return (
|
|
227
|
+
<div className="flex items-center gap-2.5">
|
|
228
|
+
<span
|
|
229
|
+
className="size-2 rounded-full bg-[var(--c-l)] dark:bg-[var(--c-d)]"
|
|
230
|
+
style={{ "--c-l": light, "--c-d": dark } as CSSProperties}
|
|
231
|
+
/>
|
|
232
|
+
<span className="text-[11px] text-muted-foreground">{label}</span>
|
|
233
|
+
<span className={cn(
|
|
234
|
+
"ml-auto font-mono text-xs tabular-nums",
|
|
235
|
+
muted ? "text-muted-foreground" : "font-semibold text-foreground",
|
|
236
|
+
)}>
|
|
237
|
+
{value}
|
|
238
|
+
</span>
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function LayerSummaryRow({
|
|
244
|
+
label,
|
|
245
|
+
value,
|
|
246
|
+
muted,
|
|
247
|
+
}: {
|
|
248
|
+
label: string,
|
|
249
|
+
value: string,
|
|
250
|
+
muted: boolean,
|
|
251
|
+
}) {
|
|
252
|
+
return (
|
|
253
|
+
<div className="flex items-center justify-between">
|
|
254
|
+
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
255
|
+
{label}
|
|
256
|
+
</span>
|
|
257
|
+
<span className={cn(
|
|
258
|
+
"font-mono text-xs tabular-nums",
|
|
259
|
+
muted ? "text-muted-foreground" : "font-semibold text-foreground",
|
|
260
|
+
)}>
|
|
261
|
+
{value}
|
|
262
|
+
</span>
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { FormatKind, FormatKindType, AnalyticsChartDelta } from "./types";
|
|
2
|
+
|
|
3
|
+
export const FORMAT_KIND_TYPES: FormatKindType[] = [
|
|
4
|
+
"numeric",
|
|
5
|
+
"short",
|
|
6
|
+
"currency",
|
|
7
|
+
"duration",
|
|
8
|
+
"datetime",
|
|
9
|
+
"percent",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_FORMAT_KIND: { [K in FormatKindType]: Extract<FormatKind, { type: K }> } = {
|
|
13
|
+
numeric: { type: "numeric", locale: "en-US", decimals: 0 },
|
|
14
|
+
short: { type: "short", precision: 1, locale: "en-US" },
|
|
15
|
+
currency: { type: "currency", currency: "USD", divisor: 100, locale: "en-US" },
|
|
16
|
+
duration: { type: "duration", unit: "s", showZero: false },
|
|
17
|
+
datetime: { type: "datetime", style: "short", locale: "en-US" },
|
|
18
|
+
percent: { type: "percent", source: "fraction", decimals: 1 },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function formatRelative(value: number, locale: string): string {
|
|
22
|
+
const diff = value - Date.now();
|
|
23
|
+
const absSec = Math.abs(diff) / 1000;
|
|
24
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
|
25
|
+
if (absSec >= 86_400) return rtf.format(Math.round(diff / 86_400_000), "day");
|
|
26
|
+
if (absSec >= 3600) return rtf.format(Math.round(diff / 3_600_000), "hour");
|
|
27
|
+
if (absSec >= 60) return rtf.format(Math.round(diff / 60_000), "minute");
|
|
28
|
+
return "just now";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `short` uses compact notation (e.g. `1.2K`), not a custom `k` suffix. */
|
|
32
|
+
export function formatValue(value: number, kind: FormatKind): string {
|
|
33
|
+
switch (kind.type) {
|
|
34
|
+
case "numeric": {
|
|
35
|
+
const decimals = kind.decimals ?? 0;
|
|
36
|
+
return value.toLocaleString(kind.locale ?? "en-US", {
|
|
37
|
+
minimumFractionDigits: decimals,
|
|
38
|
+
maximumFractionDigits: decimals,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
case "short": {
|
|
42
|
+
const precision = kind.precision ?? 1;
|
|
43
|
+
return new Intl.NumberFormat(kind.locale ?? "en-US", {
|
|
44
|
+
notation: "compact",
|
|
45
|
+
compactDisplay: "short",
|
|
46
|
+
minimumFractionDigits: precision,
|
|
47
|
+
maximumFractionDigits: precision,
|
|
48
|
+
}).format(value);
|
|
49
|
+
}
|
|
50
|
+
case "currency": {
|
|
51
|
+
const divisor = kind.divisor ?? 1;
|
|
52
|
+
return new Intl.NumberFormat(kind.locale ?? "en-US", {
|
|
53
|
+
style: "currency",
|
|
54
|
+
currency: kind.currency ?? "USD",
|
|
55
|
+
}).format(value / divisor);
|
|
56
|
+
}
|
|
57
|
+
case "duration": {
|
|
58
|
+
const unit = kind.unit ?? "s";
|
|
59
|
+
const seconds = unit === "ms" ? value / 1000
|
|
60
|
+
: unit === "m" ? value * 60
|
|
61
|
+
: unit === "h" ? value * 3600
|
|
62
|
+
: value;
|
|
63
|
+
const h = Math.floor(seconds / 3600);
|
|
64
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
65
|
+
const s = Math.floor(seconds % 60);
|
|
66
|
+
if (h > 0) return `${h}h ${m}m ${s}s`;
|
|
67
|
+
if (m > 0) return `${m}m ${s}s`;
|
|
68
|
+
if (unit === "ms" && seconds < 1) return `${Math.round(value)}ms`;
|
|
69
|
+
if (s > 0 || kind.showZero) return `${s}s`;
|
|
70
|
+
return "0s";
|
|
71
|
+
}
|
|
72
|
+
case "datetime": {
|
|
73
|
+
const d = new Date(value);
|
|
74
|
+
const style = kind.style ?? "short";
|
|
75
|
+
const locale = kind.locale ?? "en-US";
|
|
76
|
+
if (style === "iso") return d.toISOString();
|
|
77
|
+
if (style === "relative") return formatRelative(value, locale);
|
|
78
|
+
if (style === "long") return d.toLocaleString(locale, {
|
|
79
|
+
dateStyle: "medium",
|
|
80
|
+
timeStyle: "short",
|
|
81
|
+
});
|
|
82
|
+
return d.toLocaleDateString(locale, { month: "short", day: "numeric" });
|
|
83
|
+
}
|
|
84
|
+
case "percent": {
|
|
85
|
+
const source = kind.source ?? "fraction";
|
|
86
|
+
const decimals = kind.decimals ?? 1;
|
|
87
|
+
const pct = source === "basis" ? value / 100
|
|
88
|
+
: source === "whole" ? value
|
|
89
|
+
: value * 100;
|
|
90
|
+
return `${pct.toFixed(decimals)}%`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function formatDelta(current: number, previous: number): AnalyticsChartDelta {
|
|
96
|
+
if (!Number.isFinite(current) || !Number.isFinite(previous)) return { pct: null, sign: "na" };
|
|
97
|
+
if (previous === 0) return current === 0 ? { pct: 0, sign: "flat" } : { pct: null, sign: "na" };
|
|
98
|
+
const pct = Number((((current - previous) / previous) * 100).toFixed(1));
|
|
99
|
+
const sign = pct > 0 ? "up" : pct < 0 ? "down" : "flat";
|
|
100
|
+
return { pct, sign };
|
|
101
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export { AnalyticsChart, type AnalyticsChartProps, type Margin } from "./analytics-chart";
|
|
2
|
+
export {
|
|
3
|
+
DefaultAnalyticsChartTooltip,
|
|
4
|
+
TrendPill,
|
|
5
|
+
type AnalyticsChartTooltipContext,
|
|
6
|
+
type AnalyticsChartTooltipLayerView,
|
|
7
|
+
type AnalyticsChartTooltipSegmentRow,
|
|
8
|
+
type DefaultAnalyticsChartTooltipProps,
|
|
9
|
+
} from "./default-analytics-chart-tooltip";
|
|
10
|
+
export {
|
|
11
|
+
formatDelta,
|
|
12
|
+
formatValue,
|
|
13
|
+
DEFAULT_FORMAT_KIND,
|
|
14
|
+
FORMAT_KIND_TYPES,
|
|
15
|
+
} from "./format";
|
|
16
|
+
export {
|
|
17
|
+
ANALYTICS_CHART_DEFAULT_PALETTE,
|
|
18
|
+
buildRampColors,
|
|
19
|
+
buildSegmentThemeMap,
|
|
20
|
+
resolveAnalyticsChartPalette,
|
|
21
|
+
} from "./palette";
|
|
22
|
+
export {
|
|
23
|
+
ANALYTICS_CHART_DEFAULT_LAYERS,
|
|
24
|
+
ANALYTICS_CHART_DEFAULT_STATE,
|
|
25
|
+
computeLocalInProgressIdx,
|
|
26
|
+
EMPTY_MATRIX,
|
|
27
|
+
EMPTY_SERIES,
|
|
28
|
+
findAnnotationsLayer,
|
|
29
|
+
findCompareLayer,
|
|
30
|
+
findLayerById,
|
|
31
|
+
findPrimaryLayer,
|
|
32
|
+
isAnalyticsChartDataLayer,
|
|
33
|
+
isTimeseriesState,
|
|
34
|
+
patchLayerById,
|
|
35
|
+
resolveDataLayerStyle,
|
|
36
|
+
setLayerById,
|
|
37
|
+
STROKE_DASHARRAY,
|
|
38
|
+
type ResolvedDataLayerStyle,
|
|
39
|
+
} from "./state";
|
|
40
|
+
export {
|
|
41
|
+
ANALYTICS_CHART_DEFAULT_STRINGS,
|
|
42
|
+
resolveAnalyticsChartStrings,
|
|
43
|
+
type AnalyticsChartStrings,
|
|
44
|
+
} from "./strings";
|
|
45
|
+
export {
|
|
46
|
+
pointValue,
|
|
47
|
+
type AnalyticsChartAnnotationsLayer,
|
|
48
|
+
type AnalyticsChartAreaLayer,
|
|
49
|
+
type AnalyticsChartBarLayer,
|
|
50
|
+
type AnalyticsChartDataLayer,
|
|
51
|
+
type AnalyticsChartDelta,
|
|
52
|
+
type AnalyticsChartLayer,
|
|
53
|
+
type AnalyticsChartLayers,
|
|
54
|
+
type AnalyticsChartLayerType,
|
|
55
|
+
type AnalyticsChartLineLayer,
|
|
56
|
+
type AnalyticsChartPalette,
|
|
57
|
+
type AnalyticsChartPieProps,
|
|
58
|
+
type AnalyticsChartPieState,
|
|
59
|
+
type AnalyticsChartSegmentRamp,
|
|
60
|
+
type AnalyticsChartSeries,
|
|
61
|
+
type AnalyticsChartState,
|
|
62
|
+
type AnalyticsChartStrokeStyle,
|
|
63
|
+
type AnalyticsChartTimeseriesState,
|
|
64
|
+
type AnalyticsChartView,
|
|
65
|
+
type Annotation,
|
|
66
|
+
type FormatKind,
|
|
67
|
+
type FormatKindCurrency,
|
|
68
|
+
type FormatKindDatetime,
|
|
69
|
+
type FormatKindDuration,
|
|
70
|
+
type FormatKindNumeric,
|
|
71
|
+
type FormatKindPercent,
|
|
72
|
+
type FormatKindShort,
|
|
73
|
+
type FormatKindType,
|
|
74
|
+
type Point,
|
|
75
|
+
} from "./types";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnalyticsChartPalette,
|
|
3
|
+
AnalyticsChartSegmentRamp,
|
|
4
|
+
AnalyticsChartSeries,
|
|
5
|
+
} from "./types";
|
|
6
|
+
|
|
7
|
+
export const ANALYTICS_CHART_DEFAULT_PALETTE: AnalyticsChartPalette = {
|
|
8
|
+
primary: {
|
|
9
|
+
kind: "procedural",
|
|
10
|
+
hue: 220,
|
|
11
|
+
sat: 78,
|
|
12
|
+
shadeRangeLight: [28, 62],
|
|
13
|
+
shadeRangeDark: [52, 82],
|
|
14
|
+
},
|
|
15
|
+
compare: {
|
|
16
|
+
kind: "procedural",
|
|
17
|
+
hue: 38,
|
|
18
|
+
sat: 92,
|
|
19
|
+
shadeRangeLight: [28, 62],
|
|
20
|
+
shadeRangeDark: [52, 82],
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function resolveAnalyticsChartPalette(
|
|
25
|
+
override: Partial<AnalyticsChartPalette> | undefined,
|
|
26
|
+
): AnalyticsChartPalette {
|
|
27
|
+
if (!override) return ANALYTICS_CHART_DEFAULT_PALETTE;
|
|
28
|
+
return {
|
|
29
|
+
primary: override.primary ?? ANALYTICS_CHART_DEFAULT_PALETTE.primary,
|
|
30
|
+
compare: override.compare ?? ANALYTICS_CHART_DEFAULT_PALETTE.compare,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Expand a ramp into N colors for a given theme. */
|
|
35
|
+
export function buildRampColors(
|
|
36
|
+
ramp: AnalyticsChartSegmentRamp,
|
|
37
|
+
count: number,
|
|
38
|
+
theme: "light" | "dark",
|
|
39
|
+
): string[] {
|
|
40
|
+
if (ramp.kind === "explicit") {
|
|
41
|
+
const list = theme === "light" ? ramp.light : ramp.dark;
|
|
42
|
+
if (list.length === 0) return Array.from({ length: count }, () => "#888");
|
|
43
|
+
return Array.from(
|
|
44
|
+
{ length: count },
|
|
45
|
+
(_, i) => list[i < list.length ? i : list.length - 1]!,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const range = theme === "light" ? ramp.shadeRangeLight : ramp.shadeRangeDark;
|
|
49
|
+
return Array.from({ length: count }, (_, i) => {
|
|
50
|
+
const t = count <= 1 ? 0.5 : i / (count - 1);
|
|
51
|
+
const l = range[0] + t * (range[1] - range[0]);
|
|
52
|
+
return `hsl(${ramp.hue} ${ramp.sat}% ${l.toFixed(1)}%)`;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Per-segment light/dark colors for `ChartConfig.theme` (SVG only; siblings use inline vars). */
|
|
57
|
+
export function buildSegmentThemeMap(
|
|
58
|
+
series: readonly AnalyticsChartSeries[],
|
|
59
|
+
ramp: AnalyticsChartSegmentRamp,
|
|
60
|
+
): Record<string, { light: string, dark: string }> {
|
|
61
|
+
const light = buildRampColors(ramp, series.length, "light");
|
|
62
|
+
const dark = buildRampColors(ramp, series.length, "dark");
|
|
63
|
+
const out: Record<string, { light: string, dark: string }> = {};
|
|
64
|
+
series.forEach((s, i) => {
|
|
65
|
+
out[s.key] = { light: light[i]!, dark: dark[i]! };
|
|
66
|
+
});
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Area, Bar, Line } from "recharts";
|
|
3
|
+
import type {
|
|
4
|
+
AnalyticsChartDataLayer,
|
|
5
|
+
AnalyticsChartSeries,
|
|
6
|
+
} from "./types";
|
|
7
|
+
|
|
8
|
+
/** Area layers use fill-only `<Area>` plus `<Line>` for the top edge (not Recharts' closed-path stroke). */
|
|
9
|
+
export type RenderDataSeriesArgs = {
|
|
10
|
+
layer: AnalyticsChartDataLayer,
|
|
11
|
+
segmented: boolean,
|
|
12
|
+
segmentSeries: readonly AnalyticsChartSeries[],
|
|
13
|
+
segKey: (segKey: string) => string,
|
|
14
|
+
stackId: string,
|
|
15
|
+
strokeDasharray: string | undefined,
|
|
16
|
+
segmentedStrokeDasharray: string | undefined,
|
|
17
|
+
fillOpacity: number,
|
|
18
|
+
segmentedFillOpacity: number,
|
|
19
|
+
baseOpacity?: number,
|
|
20
|
+
strokeWidth: number,
|
|
21
|
+
segmentedStrokeWidth: number,
|
|
22
|
+
inProgressKeys: { solid: string, dashed: string } | null,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Return value must be spread into `<ComposedChart>` as siblings — do not wrap in `<Fragment>`. */
|
|
26
|
+
export function renderDataSeries(args: RenderDataSeriesArgs): ReactNode[] {
|
|
27
|
+
const {
|
|
28
|
+
layer,
|
|
29
|
+
segmented,
|
|
30
|
+
segmentSeries,
|
|
31
|
+
segKey,
|
|
32
|
+
stackId,
|
|
33
|
+
strokeDasharray,
|
|
34
|
+
segmentedStrokeDasharray,
|
|
35
|
+
fillOpacity,
|
|
36
|
+
segmentedFillOpacity,
|
|
37
|
+
baseOpacity = 1,
|
|
38
|
+
strokeWidth,
|
|
39
|
+
segmentedStrokeWidth,
|
|
40
|
+
inProgressKeys,
|
|
41
|
+
} = args;
|
|
42
|
+
|
|
43
|
+
const nodes: ReactNode[] = [];
|
|
44
|
+
|
|
45
|
+
if (segmented) {
|
|
46
|
+
segmentSeries.forEach((s, sIdx) => {
|
|
47
|
+
const key = segKey(s.key);
|
|
48
|
+
const nodeKey = `${layer.id}_seg_${s.key}`;
|
|
49
|
+
if (layer.type === "bar") {
|
|
50
|
+
const isTop = sIdx === segmentSeries.length - 1;
|
|
51
|
+
nodes.push(
|
|
52
|
+
<Bar
|
|
53
|
+
key={nodeKey}
|
|
54
|
+
dataKey={key}
|
|
55
|
+
stackId={stackId}
|
|
56
|
+
fill={`var(--color-${key})`}
|
|
57
|
+
radius={isTop ? [2, 2, 0, 0] : 0}
|
|
58
|
+
isAnimationActive={false}
|
|
59
|
+
opacity={baseOpacity}
|
|
60
|
+
/>,
|
|
61
|
+
);
|
|
62
|
+
} else if (layer.type === "area") {
|
|
63
|
+
nodes.push(
|
|
64
|
+
<Area
|
|
65
|
+
key={nodeKey}
|
|
66
|
+
dataKey={key}
|
|
67
|
+
stackId={stackId}
|
|
68
|
+
type="linear"
|
|
69
|
+
fill={`var(--color-${key})`}
|
|
70
|
+
fillOpacity={segmentedFillOpacity}
|
|
71
|
+
stroke={`var(--color-${key})`}
|
|
72
|
+
strokeWidth={segmentedStrokeWidth}
|
|
73
|
+
strokeDasharray={segmentedStrokeDasharray}
|
|
74
|
+
isAnimationActive={false}
|
|
75
|
+
opacity={baseOpacity}
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
} else {
|
|
79
|
+
nodes.push(
|
|
80
|
+
<Line
|
|
81
|
+
key={nodeKey}
|
|
82
|
+
dataKey={key}
|
|
83
|
+
type="linear"
|
|
84
|
+
stroke={`var(--color-${key})`}
|
|
85
|
+
strokeWidth={strokeWidth}
|
|
86
|
+
strokeDasharray={segmentedStrokeDasharray}
|
|
87
|
+
dot={false}
|
|
88
|
+
isAnimationActive={false}
|
|
89
|
+
opacity={baseOpacity}
|
|
90
|
+
/>,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return nodes;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (layer.type === "bar") {
|
|
98
|
+
nodes.push(
|
|
99
|
+
<Bar
|
|
100
|
+
key={`${layer.id}_main`}
|
|
101
|
+
dataKey={layer.id}
|
|
102
|
+
fill={`var(--color-${layer.id})`}
|
|
103
|
+
radius={2}
|
|
104
|
+
isAnimationActive={false}
|
|
105
|
+
/>,
|
|
106
|
+
);
|
|
107
|
+
return nodes;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (layer.type === "area") {
|
|
111
|
+
nodes.push(
|
|
112
|
+
<Area
|
|
113
|
+
key={`${layer.id}_area`}
|
|
114
|
+
dataKey={layer.id}
|
|
115
|
+
type="linear"
|
|
116
|
+
fill={`var(--color-${layer.id})`}
|
|
117
|
+
fillOpacity={fillOpacity}
|
|
118
|
+
stroke="none"
|
|
119
|
+
isAnimationActive={false}
|
|
120
|
+
/>,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (inProgressKeys) {
|
|
125
|
+
nodes.push(
|
|
126
|
+
<Line
|
|
127
|
+
key={`${layer.id}_solid`}
|
|
128
|
+
dataKey={inProgressKeys.solid}
|
|
129
|
+
type="linear"
|
|
130
|
+
stroke={`var(--color-${layer.id})`}
|
|
131
|
+
strokeWidth={strokeWidth}
|
|
132
|
+
strokeDasharray={strokeDasharray}
|
|
133
|
+
dot={false}
|
|
134
|
+
isAnimationActive={false}
|
|
135
|
+
connectNulls
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
nodes.push(
|
|
139
|
+
<Line
|
|
140
|
+
key={`${layer.id}_dashed`}
|
|
141
|
+
dataKey={inProgressKeys.dashed}
|
|
142
|
+
type="linear"
|
|
143
|
+
stroke={`var(--color-${layer.id})`}
|
|
144
|
+
strokeWidth={strokeWidth}
|
|
145
|
+
strokeDasharray="4 4"
|
|
146
|
+
dot={false}
|
|
147
|
+
isAnimationActive={false}
|
|
148
|
+
connectNulls
|
|
149
|
+
opacity={0.85}
|
|
150
|
+
/>,
|
|
151
|
+
);
|
|
152
|
+
} else {
|
|
153
|
+
nodes.push(
|
|
154
|
+
<Line
|
|
155
|
+
key={`${layer.id}_line`}
|
|
156
|
+
dataKey={layer.id}
|
|
157
|
+
type="linear"
|
|
158
|
+
stroke={`var(--color-${layer.id})`}
|
|
159
|
+
strokeWidth={strokeWidth}
|
|
160
|
+
strokeDasharray={strokeDasharray}
|
|
161
|
+
dot={false}
|
|
162
|
+
isAnimationActive={false}
|
|
163
|
+
opacity={baseOpacity}
|
|
164
|
+
/>,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return nodes;
|
|
169
|
+
}
|