@olympusoss/canvas 2.11.0 → 2.12.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olympusoss/canvas",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,316 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "../../lib/utils";
4
+
5
+ export type MetricBreakdownTone = "success" | "warning" | "error" | "neutral";
6
+
7
+ const TONE_HSL: Record<MetricBreakdownTone, string> = {
8
+ success: "143 70% 40%",
9
+ warning: "38 92% 50%",
10
+ error: "0 80% 60%",
11
+ neutral: "var(--muted-foreground)",
12
+ };
13
+
14
+ export interface MetricBreakdownRow {
15
+ /** Row label (often a code or category key). Used as the React key. */
16
+ label: React.ReactNode;
17
+ /** Numeric value rendered to the right of the label. */
18
+ value: number;
19
+ /**
20
+ * Period-over-period change as a percentage. Positive renders as `▲ N%` in
21
+ * the success hue, negative as `▼ N%` in the error hue. Omit to hide the
22
+ * delta column for that row.
23
+ */
24
+ delta?: number;
25
+ /**
26
+ * CSS variable name (without leading `--`) used for the row's swatch and
27
+ * bar fill. Falls back to the wrapper's `defaultColorVar`.
28
+ */
29
+ colorVar?: string;
30
+ }
31
+
32
+ export interface MetricBreakdownChip {
33
+ /** Chip label, typically a code or short tag. Used as the React key. */
34
+ label: React.ReactNode;
35
+ /** Optional count rendered after a thin separator. */
36
+ count?: number;
37
+ /** Visual tone. Default `error`. */
38
+ tone?: MetricBreakdownTone;
39
+ }
40
+
41
+ export interface MetricBreakdownProps extends React.HTMLAttributes<HTMLDivElement> {
42
+ /** Primary metric value rendered as the headline number. */
43
+ value: React.ReactNode;
44
+ /** Caption rendered below the headline value (uppercase muted). */
45
+ label: React.ReactNode;
46
+ /** Secondary metric rendered top-right (e.g. an error rate). */
47
+ rate?: React.ReactNode;
48
+ /** Caption for the secondary metric (uppercase muted). */
49
+ rateLabel?: React.ReactNode;
50
+ /** Tone driving the secondary metric color. Default `neutral`. */
51
+ rateTone?: MetricBreakdownTone;
52
+ /** Sparkline data points. Rendered as an SVG line + area-fill ramp. */
53
+ spark?: number[];
54
+ /** Unit suffix rendered next to the last value of the sparkline, e.g. `req/s`. */
55
+ sparkUnit?: React.ReactNode;
56
+ /**
57
+ * CSS variable name (without leading `--`) for the sparkline color. Default
58
+ * `chart-1`. Also used as the fallback for breakdown rows that omit `colorVar`.
59
+ */
60
+ defaultColorVar?: string;
61
+ /** Pixel height of the sparkline. Default `36`. */
62
+ sparkHeight?: number;
63
+ /** Category breakdown rows. */
64
+ breakdown?: MetricBreakdownRow[];
65
+ /** Format breakdown row values. Default `toLocaleString`. */
66
+ valueFormatter?: (value: number) => string;
67
+ /** Trailing chip row (e.g. recent error codes). */
68
+ chips?: MetricBreakdownChip[];
69
+ /**
70
+ * Label rendered before the chip row (uppercase muted). Default `"Errors"`.
71
+ * Pass `null` to hide.
72
+ */
73
+ chipsLabel?: React.ReactNode;
74
+ }
75
+
76
+ /**
77
+ * Multi-section metric card: a headline value with an optional rate, a small
78
+ * inline trend sparkline, a per-category breakdown with delta arrows, and a
79
+ * trailing chip row for recent issues.
80
+ *
81
+ * Designed for "throughput-style" dashboards (token issuance, API request
82
+ * volume, job throughput, sign-ups by source): anywhere a single metric needs
83
+ * to be decomposed by category and contextualized by trend and notable issues
84
+ * in one card. Identity-agnostic; bring your own labels and color tokens.
85
+ */
86
+ export const MetricBreakdown = React.forwardRef<HTMLDivElement, MetricBreakdownProps>(
87
+ (
88
+ {
89
+ value,
90
+ label,
91
+ rate,
92
+ rateLabel,
93
+ rateTone = "neutral",
94
+ spark,
95
+ sparkUnit,
96
+ defaultColorVar = "chart-1",
97
+ sparkHeight = 36,
98
+ breakdown,
99
+ valueFormatter = (v) => v.toLocaleString(),
100
+ chips,
101
+ chipsLabel = "Errors",
102
+ className,
103
+ ...props
104
+ },
105
+ ref,
106
+ ) => {
107
+ const rateColor =
108
+ rateTone === "neutral" ? "hsl(var(--muted-foreground))" : `hsl(${TONE_HSL[rateTone]})`;
109
+ const showChipsLabel = chipsLabel !== null && chipsLabel !== undefined && chipsLabel !== "";
110
+ return (
111
+ <div ref={ref} className={cn("w-full", className)} {...props}>
112
+ <div className="mb-2.5 flex items-baseline justify-between gap-3">
113
+ <div>
114
+ <div className="font-mono text-[22px] font-semibold leading-[1.1] tabular-nums">
115
+ {value}
116
+ </div>
117
+ <div className="text-[11px] uppercase tracking-[0.04em] text-muted-foreground">
118
+ {label}
119
+ </div>
120
+ </div>
121
+ {(rate !== undefined || rateLabel) && (
122
+ <div className="text-right">
123
+ <div
124
+ className="font-mono text-[13px] font-medium tabular-nums"
125
+ style={{ color: rateColor }}
126
+ >
127
+ {rate}
128
+ </div>
129
+ {rateLabel && (
130
+ <div className="text-[11px] uppercase tracking-[0.04em] text-muted-foreground">
131
+ {rateLabel}
132
+ </div>
133
+ )}
134
+ </div>
135
+ )}
136
+ </div>
137
+
138
+ {spark && spark.length > 1 && (
139
+ <MetricBreakdownSpark
140
+ data={spark}
141
+ height={sparkHeight}
142
+ colorVar={defaultColorVar}
143
+ unit={sparkUnit}
144
+ />
145
+ )}
146
+
147
+ {breakdown && breakdown.length > 0 && (
148
+ <MetricBreakdownRows
149
+ rows={breakdown}
150
+ defaultColorVar={defaultColorVar}
151
+ valueFormatter={valueFormatter}
152
+ />
153
+ )}
154
+
155
+ {chips && chips.length > 0 && (
156
+ <div
157
+ className={cn(
158
+ "flex flex-wrap items-center gap-1.5",
159
+ (breakdown && breakdown.length > 0) || spark
160
+ ? "mt-3 border-t border-border pt-2.5"
161
+ : "",
162
+ )}
163
+ >
164
+ {showChipsLabel && (
165
+ <span className="mr-0.5 text-[10.5px] uppercase tracking-[0.04em] text-muted-foreground">
166
+ {chipsLabel}
167
+ </span>
168
+ )}
169
+ {chips.map((chip, i) => {
170
+ const tone = chip.tone ?? "error";
171
+ const hsl = TONE_HSL[tone];
172
+ return (
173
+ <span
174
+ key={`${i}-${typeof chip.label === "string" ? chip.label : i}`}
175
+ className="inline-flex items-center gap-1 rounded font-mono text-[10.5px]"
176
+ style={{
177
+ padding: "2px 6px",
178
+ background: `hsl(${hsl} / 0.1)`,
179
+ color: `hsl(${hsl})`,
180
+ }}
181
+ >
182
+ {chip.label}
183
+ {chip.count !== undefined && <span className="opacity-70">·{chip.count}</span>}
184
+ </span>
185
+ );
186
+ })}
187
+ </div>
188
+ )}
189
+ </div>
190
+ );
191
+ },
192
+ );
193
+ MetricBreakdown.displayName = "MetricBreakdown";
194
+
195
+ interface MetricBreakdownSparkProps {
196
+ data: number[];
197
+ height: number;
198
+ colorVar: string;
199
+ unit?: React.ReactNode;
200
+ }
201
+
202
+ function MetricBreakdownSpark({ data, height, colorVar, unit }: MetricBreakdownSparkProps) {
203
+ const max = Math.max(...data);
204
+ const min = Math.min(...data);
205
+ const range = max - min || 1;
206
+ const w = (data.length - 1) * 10;
207
+ const h = height;
208
+ const pts = data
209
+ .map((v, i) => {
210
+ const x = i * 10;
211
+ const y = h - 2 - ((v - min) / range) * (h - 6);
212
+ return `${x},${y}`;
213
+ })
214
+ .join(" ");
215
+ const area = `0,${h} ${pts} ${w},${h}`;
216
+ const last = data[data.length - 1];
217
+ const lastY = h - 2 - ((last - min) / range) * (h - 6);
218
+ const fillId = React.useId();
219
+ return (
220
+ <div className="relative mb-3.5" style={{ height }}>
221
+ <svg
222
+ viewBox={`0 0 ${w} ${h}`}
223
+ preserveAspectRatio="none"
224
+ className="h-full w-full overflow-visible"
225
+ aria-hidden
226
+ >
227
+ <defs>
228
+ <linearGradient id={fillId} x1="0" x2="0" y1="0" y2="1">
229
+ <stop offset="0%" stopColor={`hsl(var(--${colorVar}))`} stopOpacity="0.25" />
230
+ <stop offset="100%" stopColor={`hsl(var(--${colorVar}))`} stopOpacity="0" />
231
+ </linearGradient>
232
+ </defs>
233
+ <polygon points={area} fill={`url(#${fillId})`} />
234
+ <polyline
235
+ points={pts}
236
+ fill="none"
237
+ stroke={`hsl(var(--${colorVar}))`}
238
+ strokeWidth="1.5"
239
+ vectorEffect="non-scaling-stroke"
240
+ />
241
+ <circle cx={w} cy={lastY} r="2.5" fill={`hsl(var(--${colorVar}))`} />
242
+ </svg>
243
+ {unit !== undefined && (
244
+ <div
245
+ className="absolute right-0 top-0 bg-card px-1 font-mono text-[11px]"
246
+ style={{ color: `hsl(var(--${colorVar}))` }}
247
+ >
248
+ {last}
249
+ {typeof unit === "string" ? ` ${unit}` : <> {unit}</>}
250
+ </div>
251
+ )}
252
+ </div>
253
+ );
254
+ }
255
+
256
+ interface MetricBreakdownRowsProps {
257
+ rows: MetricBreakdownRow[];
258
+ defaultColorVar: string;
259
+ valueFormatter: (value: number) => string;
260
+ }
261
+
262
+ function MetricBreakdownRows({ rows, defaultColorVar, valueFormatter }: MetricBreakdownRowsProps) {
263
+ const total = rows.reduce((sum, r) => sum + r.value, 0) || 1;
264
+ return (
265
+ <div className="flex flex-col gap-2">
266
+ {rows.map((row, i) => {
267
+ const colorVar = row.colorVar ?? defaultColorVar;
268
+ const pct = (row.value / total) * 100;
269
+ const hasDelta = row.delta !== undefined;
270
+ const up = hasDelta && (row.delta as number) >= 0;
271
+ const deltaHsl = up ? TONE_HSL.success : TONE_HSL.error;
272
+ return (
273
+ <div
274
+ key={`${i}-${typeof row.label === "string" ? row.label : i}`}
275
+ className="text-[12.5px]"
276
+ >
277
+ <div className="mb-1 flex items-center gap-2">
278
+ <span
279
+ className="size-2 shrink-0 rounded-sm"
280
+ style={{ background: `hsl(var(--${colorVar}))` }}
281
+ aria-hidden
282
+ />
283
+ <span className="flex-1 truncate font-mono text-[11.5px] text-foreground">
284
+ {row.label}
285
+ </span>
286
+ <span className="font-mono text-[11.5px] tabular-nums">
287
+ {valueFormatter(row.value)}
288
+ </span>
289
+ {hasDelta && (
290
+ <span
291
+ className="min-w-[38px] text-right font-mono text-[10.5px] tabular-nums"
292
+ style={{ color: `hsl(${deltaHsl})` }}
293
+ >
294
+ {up ? "▲" : "▼"} {Math.abs(row.delta as number)}%
295
+ </span>
296
+ )}
297
+ </div>
298
+ <div
299
+ className="overflow-hidden rounded-full bg-muted"
300
+ style={{ height: 3 }}
301
+ aria-hidden
302
+ >
303
+ <div
304
+ className="h-full rounded-full"
305
+ style={{
306
+ width: `${pct}%`,
307
+ background: `hsl(var(--${colorVar}))`,
308
+ }}
309
+ />
310
+ </div>
311
+ </div>
312
+ );
313
+ })}
314
+ </div>
315
+ );
316
+ }
package/src/index.ts CHANGED
@@ -101,6 +101,13 @@ export {
101
101
  type LabeledBarListItem,
102
102
  type LabeledBarListProps,
103
103
  } from "./components/charts/labeled-bar-list";
104
+ export {
105
+ MetricBreakdown,
106
+ type MetricBreakdownChip,
107
+ type MetricBreakdownProps,
108
+ type MetricBreakdownRow,
109
+ type MetricBreakdownTone,
110
+ } from "./components/charts/metric-breakdown";
104
111
  export {
105
112
  ReferenceArea,
106
113
  ReferenceDot,