@umituz/web-dashboard 4.2.0 → 4.4.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": "@umituz/web-dashboard",
3
- "version": "4.2.0",
3
+ "version": "4.4.0",
4
4
  "description": "Installable app skeleton for the fleet — layouts, auth, onboarding, analytics (dependency-free SVG charts), calendar, i18n injection, permissions/session/module registry; ships its own udb-* stylesheet and --u-* tokens",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { renderToString } from 'react-dom/server';
3
+ import { BarChart, Donut, LineChart, Sparkline } from './components';
4
+
5
+ describe('charts markup smoke', () => {
6
+ it('Sparkline renders an svg path for enough data and null for less', () => {
7
+ const html = renderToString(<Sparkline data={[1, 3, 2, 5]} />);
8
+ expect(html).toContain('<svg');
9
+ expect(html).toContain('<path');
10
+ expect(renderToString(<Sparkline data={[1]} />)).toBe('');
11
+ });
12
+
13
+ it('BarChart renders one column per datum with height proportional to value', () => {
14
+ const html = renderToString(
15
+ <BarChart data={[{ label: 'mon', value: 4 }, { label: 'tue', value: 8 }, { label: 'wed', value: 0, highlight: true }]} />,
16
+ );
17
+ expect(html.match(/udb-barchart__col/g)?.length).toBe(3);
18
+ expect(html).toContain('height:50%');
19
+ expect(html).toContain('height:100%');
20
+ expect(html).toContain('#c2410c');
21
+ });
22
+
23
+ it('Donut renders a segment circle per label with dash math and center fallback', () => {
24
+ const html = renderToString(
25
+ <Donut
26
+ segments={[
27
+ { label: 'a', value: 3, tone: '#111111' },
28
+ { label: 'b', value: 1, tone: '#222222' },
29
+ ]}
30
+ centerValue="4"
31
+ centerLabel="total"
32
+ />,
33
+ );
34
+ expect(html.match(/udb-donut__item(?![a-z])/g)?.length).toBe(2);
35
+ expect(html).toContain('75<!-- -->%');
36
+ expect(html).toContain('#111111');
37
+ expect(html).toContain('>total<');
38
+ });
39
+
40
+ it('LineChart renders series paths, grid lines and a legend entry per series', () => {
41
+ const html = renderToString(
42
+ <LineChart
43
+ points={[{ label: 'w1' }, { label: 'w2' }, { label: 'w3' }]}
44
+ series={[
45
+ { key: 's1', label: 'revenue', values: [2, 4, 8] },
46
+ { key: 's2', label: 'cost', values: [5, 3, 1], kind: 'bars', axis: 'right' },
47
+ ]}
48
+ />,
49
+ );
50
+ expect(html).toContain('udb-linechart__grid');
51
+ expect(html).toContain('revenue');
52
+ expect(html).toContain('cost');
53
+ expect(html.match(/<rect /g)?.length).toBe(3);
54
+ expect(html.match(/udb-chartlegend__dot/g)?.length).toBeGreaterThanOrEqual(2);
55
+ });
56
+
57
+ it('LineChart renders nothing without points', () => {
58
+ expect(renderToString(<LineChart points={[]} series={[]} />)).toBe('');
59
+ });
60
+ });
@@ -1,5 +1,6 @@
1
+ import { useId, useMemo, useState } from 'react';
1
2
  import type { ReactNode } from 'react';
2
- import type { TipState } from './utils';
3
+ import { PALETTE, gridTicks, niceMax, type TipState } from './utils';
3
4
 
4
5
  export function ChartTip({ tip }: { tip: TipState }) {
5
6
  return (
@@ -72,3 +73,410 @@ export function Legend({ items }: { items: LegendItem[] }) {
72
73
  </div>
73
74
  );
74
75
  }
76
+
77
+ export function Sparkline({
78
+ data,
79
+ height = 34,
80
+ width = 110,
81
+ color = 'currentColor',
82
+ }: {
83
+ data: number[];
84
+ height?: number;
85
+ width?: number;
86
+ color?: string;
87
+ }) {
88
+ const id = useId();
89
+ if (data.length < 2) return null;
90
+ const min = Math.min(...data);
91
+ const max = Math.max(...data);
92
+ const span = max - min || 1;
93
+ const pts = data.map((v, i) => [(i / (data.length - 1)) * width, height - 3 - ((v - min) / span) * (height - 8)]);
94
+ const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0]!.toFixed(1)},${p[1]!.toFixed(1)}`).join(' ');
95
+ const area = `${line} L${width},${height} L0,${height} Z`;
96
+ return (
97
+ <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden>
98
+ <defs>
99
+ <linearGradient id={`sp${id}`} x1="0" y1="0" x2="0" y2="1">
100
+ <stop offset="0%" stopColor={color} stopOpacity="0.28" />
101
+ <stop offset="100%" stopColor={color} stopOpacity="0" />
102
+ </linearGradient>
103
+ </defs>
104
+ <path d={area} fill={`url(#sp${id})`} />
105
+ <path d={line} fill="none" stroke={color} strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round" />
106
+ <circle cx={pts.at(-1)![0]} cy={pts.at(-1)![1]} r="2.4" fill={color} />
107
+ </svg>
108
+ );
109
+ }
110
+
111
+ export interface HBarRow {
112
+ label: ReactNode;
113
+ sub?: ReactNode;
114
+ value: number;
115
+ tone?: string;
116
+ }
117
+
118
+ export function HBars({ rows, formatValue = (n: number) => String(n) }: { rows: HBarRow[]; formatValue?: (n: number) => string }) {
119
+ const max = Math.max(1, ...rows.map((r) => r.value));
120
+ return (
121
+ <div className="udb-hbars">
122
+ {rows.map((r, i) => (
123
+ <div key={i} className="udb-hbars__row">
124
+ <div className="udb-hbars__label">
125
+ <div className="udb-hbars__name">{r.label}</div>
126
+ {r.sub && <div className="udb-hbars__sub">{r.sub}</div>}
127
+ </div>
128
+ <div className="udb-hbars__track">
129
+ <div
130
+ className="udb-hbars__fill"
131
+ style={{ width: `${(r.value / max) * 100}%`, ...(r.tone ? { background: r.tone } : {}) }}
132
+ />
133
+ </div>
134
+ <span className="udb-hbars__value">{formatValue(r.value)}</span>
135
+ </div>
136
+ ))}
137
+ </div>
138
+ );
139
+ }
140
+
141
+ export interface BarDatum {
142
+ label: string;
143
+ value: number;
144
+ tone?: string;
145
+ highlight?: boolean;
146
+ }
147
+
148
+ export function BarChart({
149
+ data,
150
+ height = 130,
151
+ tone = PALETTE[0]!,
152
+ highlightTone = '#c2410c',
153
+ formatValue = (v: number) => String(v),
154
+ formatLabel = (l: string) => l,
155
+ }: {
156
+ data: BarDatum[];
157
+ height?: number;
158
+ tone?: string;
159
+ highlightTone?: string;
160
+ formatValue?: (v: number) => string;
161
+ formatLabel?: (l: string) => string;
162
+ }) {
163
+ const [hover, setHover] = useState<number | null>(null);
164
+ const max = Math.max(1, ...data.map((d) => d.value));
165
+ return (
166
+ <div className="udb-barchart" style={{ height }}>
167
+ {data.map((d, i) => (
168
+ <div
169
+ key={i}
170
+ className="udb-barchart__col"
171
+ onMouseEnter={() => setHover(i)}
172
+ onMouseLeave={() => setHover(null)}
173
+ >
174
+ <div
175
+ className="udb-barchart__bar"
176
+ style={{
177
+ height: `${Math.max(2, (d.value / max) * 100)}%`,
178
+ background: d.highlight ? highlightTone : (d.tone ?? tone),
179
+ opacity: hover === null || hover === i ? 1 : 0.4,
180
+ }}
181
+ />
182
+ {hover === i && (
183
+ <div className="udb-barchart__tip">
184
+ <b>{formatValue(d.value)}</b>
185
+ {formatLabel(d.label)}
186
+ </div>
187
+ )}
188
+ </div>
189
+ ))}
190
+ </div>
191
+ );
192
+ }
193
+
194
+ export interface DonutSegment {
195
+ label: string;
196
+ value: number;
197
+ tone?: string;
198
+ }
199
+
200
+ export function Donut({
201
+ segments,
202
+ size = 148,
203
+ thickness = 18,
204
+ centerValue,
205
+ centerLabel,
206
+ formatValue = (v: number) => String(v),
207
+ }: {
208
+ segments: DonutSegment[];
209
+ size?: number;
210
+ thickness?: number;
211
+ centerValue?: string;
212
+ centerLabel?: string;
213
+ formatValue?: (v: number) => string;
214
+ }) {
215
+ const [active, setActive] = useState<number | null>(null);
216
+ const total = segments.reduce((sum, seg) => sum + seg.value, 0) || 1;
217
+ const radius = (size - thickness) / 2;
218
+ const circumference = 2 * Math.PI * radius;
219
+ let offset = 0;
220
+ return (
221
+ <div className="udb-donut">
222
+ <div className="udb-donut__wrap" style={{ width: size, height: size }}>
223
+ <svg width={size} height={size} style={{ transform: 'rotate(-90deg)' }}>
224
+ <circle
225
+ cx={size / 2}
226
+ cy={size / 2}
227
+ r={radius}
228
+ fill="none"
229
+ stroke="var(--u-color-surface-muted)"
230
+ strokeWidth={thickness}
231
+ />
232
+ {segments.map((seg, i) => {
233
+ const dash = (seg.value / total) * circumference;
234
+ const el = (
235
+ <circle
236
+ key={seg.label}
237
+ cx={size / 2}
238
+ cy={size / 2}
239
+ r={radius}
240
+ fill="none"
241
+ stroke={seg.tone ?? PALETTE[i % PALETTE.length]!}
242
+ strokeWidth={active === i ? thickness + 3 : thickness}
243
+ strokeDasharray={`${dash} ${circumference - dash}`}
244
+ strokeDashoffset={-offset}
245
+ strokeLinecap="butt"
246
+ opacity={active === null || active === i ? 1 : 0.35}
247
+ className="udb-donut__seg"
248
+ onMouseEnter={() => setActive(i)}
249
+ onMouseLeave={() => setActive(null)}
250
+ />
251
+ );
252
+ offset += dash;
253
+ return el;
254
+ })}
255
+ </svg>
256
+ <div className="udb-donut__center">
257
+ <span className="udb-donut__value">
258
+ {active !== null ? formatValue(segments[active]!.value) : centerValue}
259
+ </span>
260
+ <span className="udb-donut__label">{active !== null ? segments[active]!.label : centerLabel}</span>
261
+ </div>
262
+ </div>
263
+ <div className="udb-donut__list">
264
+ {segments.map((seg, i) => (
265
+ <div
266
+ key={seg.label}
267
+ className="udb-donut__item"
268
+ onMouseEnter={() => setActive(i)}
269
+ onMouseLeave={() => setActive(null)}
270
+ >
271
+ <span className="udb-donut__itemlabel">
272
+ <span className="udb-donut__dot" style={{ background: seg.tone ?? PALETTE[i % PALETTE.length]! }} />
273
+ {seg.label}
274
+ </span>
275
+ <span className="udb-donut__itemvalue">
276
+ {formatValue(seg.value)}
277
+ <span className="udb-donut__pct">{Math.round((seg.value / total) * 100)}%</span>
278
+ </span>
279
+ </div>
280
+ ))}
281
+ </div>
282
+ </div>
283
+ );
284
+ }
285
+
286
+ export interface LineSeries {
287
+ key: string;
288
+ label: string;
289
+ values: number[];
290
+ tone?: string;
291
+ kind?: 'line' | 'bars';
292
+ axis?: 'left' | 'right';
293
+ dashed?: boolean;
294
+ area?: boolean;
295
+ }
296
+
297
+ export interface LineChartPoint {
298
+ label: string;
299
+ }
300
+
301
+ export function LineChart({
302
+ points,
303
+ series,
304
+ height = 220,
305
+ formatLeft = (v: number) => String(Math.round(v)),
306
+ formatRight = (v: number) => String(Math.round(v)),
307
+ yLabelLeft,
308
+ yLabelRight,
309
+ }: {
310
+ points: LineChartPoint[];
311
+ series: LineSeries[];
312
+ height?: number;
313
+ formatLeft?: (v: number) => string;
314
+ formatRight?: (v: number) => string;
315
+ yLabelLeft?: string;
316
+ yLabelRight?: string;
317
+ }) {
318
+ const [hover, setHover] = useState<number | null>(null);
319
+ const w = 720;
320
+ const padLeft = 52;
321
+ const padRight = series.some((s) => s.axis === 'right') ? 46 : 14;
322
+ const padTop = 12;
323
+ const padBottom = 24;
324
+ const innerW = w - padLeft - padRight;
325
+ const innerH = height - padTop - padBottom;
326
+ const n = points.length;
327
+
328
+ const toneOf = (s: LineSeries, i: number) => s.tone ?? PALETTE[i % PALETTE.length]!;
329
+
330
+ const leftMax = useMemo(
331
+ () => niceMax(Math.max(1, ...series.filter((s) => s.axis !== 'right').flatMap((s) => s.values))),
332
+ [series],
333
+ );
334
+ const rightMax = useMemo(
335
+ () => niceMax(Math.max(1, ...series.filter((s) => s.axis === 'right').flatMap((s) => s.values))),
336
+ [series],
337
+ );
338
+ const leftTicks = useMemo(() => gridTicks(Math.max(...series.filter((s) => s.axis !== 'right').flatMap((s) => s.values), 1)), [series]);
339
+
340
+ const xAt = (i: number) => padLeft + (n <= 1 ? innerW / 2 : (i / (n - 1)) * innerW);
341
+ const yLeft = (v: number) => padTop + innerH - (v / leftMax) * innerH;
342
+ const yRight = (v: number) => padTop + innerH - (v / rightMax) * innerH;
343
+ const labelEvery = Math.max(1, Math.ceil(n / 8));
344
+
345
+ if (n === 0) return null;
346
+
347
+ return (
348
+ <div className="udb-linechart">
349
+ <svg
350
+ viewBox={`0 0 ${w} ${height}`}
351
+ className="udb-linechart__svg"
352
+ style={{ height }}
353
+ onMouseLeave={() => setHover(null)}
354
+ onMouseMove={(e) => {
355
+ const rect = e.currentTarget.getBoundingClientRect();
356
+ const x = ((e.clientX - rect.left) / rect.width) * w;
357
+ const idx = Math.round(((x - padLeft) / innerW) * (n - 1));
358
+ setHover(Math.max(0, Math.min(n - 1, idx)));
359
+ }}
360
+ >
361
+ {leftTicks.map((v, i) => (
362
+ <g key={i}>
363
+ <line x1={padLeft} x2={w - padRight} y1={yLeft(v)} y2={yLeft(v)} className="udb-linechart__grid" />
364
+ <text x={padLeft - 6} y={yLeft(v) + 3} textAnchor="end" className="udb-linechart__axis">
365
+ {formatLeft(v)}
366
+ </text>
367
+ </g>
368
+ ))}
369
+ {series.some((s) => s.axis === 'right') && (
370
+ <g>
371
+ {leftTicks.map((v, i) => (
372
+ <text key={i} x={w - padRight + 6} y={yLeft(v) + 3} textAnchor="start" className="udb-linechart__axis">
373
+ {formatRight((v / leftMax) * rightMax)}
374
+ </text>
375
+ ))}
376
+ </g>
377
+ )}
378
+ {points.map((p, i) =>
379
+ i % labelEvery === 0 ? (
380
+ <text key={i} x={xAt(i)} y={height - 8} textAnchor="middle" className="udb-linechart__axis">
381
+ {p.label}
382
+ </text>
383
+ ) : null,
384
+ )}
385
+ {series.map((s, si) => {
386
+ const yFn = s.axis === 'right' ? yRight : yLeft;
387
+ if (s.kind === 'bars') {
388
+ const barW = Math.max(3, (innerW / Math.max(1, n - 1)) * 0.5);
389
+ return (
390
+ <g key={s.key}>
391
+ {s.values.slice(0, n).map((v, i) => (
392
+ <rect
393
+ key={i}
394
+ x={xAt(i) - barW / 2}
395
+ y={yFn(v)}
396
+ width={barW}
397
+ height={Math.max(0, padTop + innerH - yFn(v))}
398
+ rx={1.5}
399
+ fill={toneOf(s, si)}
400
+ opacity={hover === null || hover === i ? 0.85 : 0.35}
401
+ />
402
+ ))}
403
+ </g>
404
+ );
405
+ }
406
+ const path = s.values
407
+ .slice(0, n)
408
+ .map((v, i) => `${i === 0 ? 'M' : 'L'}${xAt(i).toFixed(1)},${yFn(v).toFixed(1)}`)
409
+ .join(' ');
410
+ const areaPath = `${path} L${xAt(n - 1).toFixed(1)},${padTop + innerH} L${xAt(0).toFixed(1)},${padTop + innerH} Z`;
411
+ return (
412
+ <g key={s.key}>
413
+ {s.area !== false && !s.dashed && <path d={areaPath} fill={toneOf(s, si)} opacity={0.07} />}
414
+ <path
415
+ d={path}
416
+ fill="none"
417
+ stroke={toneOf(s, si)}
418
+ strokeWidth={2}
419
+ strokeLinejoin="round"
420
+ strokeLinecap="round"
421
+ strokeDasharray={s.dashed ? '4 4' : undefined}
422
+ />
423
+ {s.values.slice(0, n).map((v, i) => (
424
+ <circle
425
+ key={i}
426
+ cx={xAt(i)}
427
+ cy={yFn(v)}
428
+ r={hover === i ? 3.4 : 0}
429
+ fill="var(--u-color-surface)"
430
+ stroke={toneOf(s, si)}
431
+ strokeWidth={2}
432
+ />
433
+ ))}
434
+ </g>
435
+ );
436
+ })}
437
+ {hover !== null && (
438
+ <line
439
+ x1={xAt(hover)}
440
+ x2={xAt(hover)}
441
+ y1={padTop}
442
+ y2={padTop + innerH}
443
+ className="udb-linechart__crosshair"
444
+ />
445
+ )}
446
+ {yLabelLeft && (
447
+ <text x={4} y={10} className="udb-linechart__axis">
448
+ {yLabelLeft}
449
+ </text>
450
+ )}
451
+ {yLabelRight && (
452
+ <text x={w - 4} y={10} textAnchor="end" className="udb-linechart__axis">
453
+ {yLabelRight}
454
+ </text>
455
+ )}
456
+ </svg>
457
+ {hover !== null && (
458
+ <div
459
+ className="udb-linechart__tip"
460
+ style={{
461
+ left: `${(xAt(hover) / w) * 100}%`,
462
+ transform: hover > n / 2 ? 'translateX(calc(-100% - 10px))' : 'translateX(10px)',
463
+ }}
464
+ >
465
+ <div className="udb-linechart__tiptitle">{points[hover]!.label}</div>
466
+ {series.map((s, si) => (
467
+ <div key={s.key} className="udb-linechart__tiprow">
468
+ <span>
469
+ <span className="udb-linechart__swatch" style={{ background: toneOf(s, si) }} />
470
+ {s.label}
471
+ </span>
472
+ <b>{(s.axis === 'right' ? formatRight : formatLeft)(s.values[hover]!)}</b>
473
+ </div>
474
+ ))}
475
+ </div>
476
+ )}
477
+ <div className="udb-linechart__legendhost">
478
+ <Legend items={series.map((s, si) => ({ label: s.label, color: toneOf(s, si) }))} />
479
+ </div>
480
+ </div>
481
+ );
482
+ }
@@ -1,4 +1,12 @@
1
1
  export { PALETTE, useTip, niceMax, gridTicks } from './utils';
2
2
  export type { TipRow, TipState } from './utils';
3
- export { ChartTip, ChartCard, Legend } from './components';
4
- export type { ChartCardProps, LegendItem } from './components';
3
+ export { ChartTip, ChartCard, Legend, Sparkline, HBars, BarChart, Donut, LineChart } from './components';
4
+ export type {
5
+ ChartCardProps,
6
+ LegendItem,
7
+ HBarRow,
8
+ BarDatum,
9
+ DonutSegment,
10
+ LineSeries,
11
+ LineChartPoint,
12
+ } from './components';
@@ -2857,3 +2857,267 @@
2857
2857
  align-items: center;
2858
2858
  gap: 5px;
2859
2859
  }
2860
+
2861
+ .udb-hbars {
2862
+ display: flex;
2863
+ flex-direction: column;
2864
+ gap: 10px;
2865
+ }
2866
+
2867
+ .udb-hbars__row {
2868
+ display: grid;
2869
+ grid-template-columns: minmax(120px, 1.2fr) 2fr auto;
2870
+ align-items: center;
2871
+ gap: 12px;
2872
+ }
2873
+
2874
+ .udb-hbars__label {
2875
+ min-width: 0;
2876
+ }
2877
+
2878
+ .udb-hbars__name {
2879
+ font-weight: 600;
2880
+ font-size: var(--u-font-size-sm);
2881
+ overflow: hidden;
2882
+ text-overflow: ellipsis;
2883
+ white-space: nowrap;
2884
+ color: var(--u-color-text);
2885
+ }
2886
+
2887
+ .udb-hbars__sub {
2888
+ font-size: var(--u-font-size-xs);
2889
+ color: var(--u-color-muted);
2890
+ }
2891
+
2892
+ .udb-hbars__track {
2893
+ height: 8px;
2894
+ background: var(--u-color-border);
2895
+ border-radius: 999px;
2896
+ overflow: hidden;
2897
+ }
2898
+
2899
+ .udb-hbars__fill {
2900
+ height: 100%;
2901
+ background: var(--u-color-primary);
2902
+ border-radius: 999px;
2903
+ }
2904
+
2905
+ .udb-hbars__value {
2906
+ font-size: var(--u-font-size-sm);
2907
+ color: var(--u-color-text);
2908
+ text-align: right;
2909
+ font-variant-numeric: tabular-nums;
2910
+ }
2911
+
2912
+ .udb-barchart {
2913
+ display: flex;
2914
+ align-items: flex-end;
2915
+ gap: 3px;
2916
+ width: 100%;
2917
+ }
2918
+
2919
+ .udb-barchart__col {
2920
+ position: relative;
2921
+ display: flex;
2922
+ flex: 1;
2923
+ align-items: flex-end;
2924
+ min-width: 0;
2925
+ height: 100%;
2926
+ }
2927
+
2928
+ .udb-barchart__bar {
2929
+ width: 100%;
2930
+ min-height: 2px;
2931
+ border-radius: 3px 3px 0 0;
2932
+ transition: opacity 150ms ease, height 300ms ease;
2933
+ }
2934
+
2935
+ .udb-barchart__tip {
2936
+ position: absolute;
2937
+ bottom: calc(100% + 6px);
2938
+ left: 50%;
2939
+ z-index: 10;
2940
+ padding: 4px 8px;
2941
+ white-space: nowrap;
2942
+ pointer-events: none;
2943
+ border: 1px solid var(--u-color-border);
2944
+ border-radius: var(--u-radius-sm);
2945
+ background: var(--u-color-surface);
2946
+ box-shadow: var(--u-shadow-sm);
2947
+ font-size: var(--u-font-size-xs);
2948
+ color: var(--u-color-muted);
2949
+ }
2950
+
2951
+ .udb-barchart__tip b {
2952
+ margin-right: 4px;
2953
+ font-weight: var(--u-font-weight-medium);
2954
+ color: var(--u-color-text);
2955
+ }
2956
+
2957
+ .udb-donut {
2958
+ display: flex;
2959
+ align-items: center;
2960
+ gap: 20px;
2961
+ }
2962
+
2963
+ .udb-donut__wrap {
2964
+ position: relative;
2965
+ flex-shrink: 0;
2966
+ }
2967
+
2968
+ .udb-donut__seg {
2969
+ cursor: pointer;
2970
+ transition: stroke-width 150ms ease, opacity 150ms ease;
2971
+ }
2972
+
2973
+ .udb-donut__center {
2974
+ position: absolute;
2975
+ inset: 0;
2976
+ display: flex;
2977
+ flex-direction: column;
2978
+ align-items: center;
2979
+ justify-content: center;
2980
+ }
2981
+
2982
+ .udb-donut__value {
2983
+ font-size: var(--u-font-size-lg);
2984
+ font-weight: var(--u-font-weight-bold);
2985
+ color: var(--u-color-text);
2986
+ font-variant-numeric: tabular-nums;
2987
+ }
2988
+
2989
+ .udb-donut__label {
2990
+ margin-top: 2px;
2991
+ font-size: var(--u-font-size-xs);
2992
+ color: var(--u-color-muted);
2993
+ }
2994
+
2995
+ .udb-donut__list {
2996
+ display: flex;
2997
+ min-width: 0;
2998
+ flex: 1;
2999
+ flex-direction: column;
3000
+ gap: 6px;
3001
+ }
3002
+
3003
+ .udb-donut__item {
3004
+ display: flex;
3005
+ align-items: center;
3006
+ justify-content: space-between;
3007
+ gap: 8px;
3008
+ padding: 4px 6px;
3009
+ border-radius: var(--u-radius-sm);
3010
+ }
3011
+
3012
+ .udb-donut__item:hover {
3013
+ background: var(--u-color-surface-muted);
3014
+ }
3015
+
3016
+ .udb-donut__itemlabel {
3017
+ display: flex;
3018
+ min-width: 0;
3019
+ align-items: center;
3020
+ gap: 8px;
3021
+ font-size: var(--u-font-size-sm);
3022
+ color: var(--u-color-text);
3023
+ }
3024
+
3025
+ .udb-donut__dot {
3026
+ width: 8px;
3027
+ height: 8px;
3028
+ flex-shrink: 0;
3029
+ border-radius: 3px;
3030
+ }
3031
+
3032
+ .udb-donut__itemvalue {
3033
+ flex-shrink: 0;
3034
+ font-size: var(--u-font-size-sm);
3035
+ font-weight: var(--u-font-weight-medium);
3036
+ color: var(--u-color-text);
3037
+ font-variant-numeric: tabular-nums;
3038
+ }
3039
+
3040
+ .udb-donut__pct {
3041
+ margin-left: 6px;
3042
+ color: var(--u-color-muted);
3043
+ }
3044
+
3045
+ .udb-linechart {
3046
+ position: relative;
3047
+ width: 100%;
3048
+ user-select: none;
3049
+ }
3050
+
3051
+ .udb-linechart__svg {
3052
+ display: block;
3053
+ width: 100%;
3054
+ }
3055
+
3056
+ .udb-linechart__axis {
3057
+ fill: var(--u-color-muted);
3058
+ font-size: var(--u-font-size-xs);
3059
+ font-family: var(--u-font-sans);
3060
+ }
3061
+
3062
+ .udb-linechart__grid {
3063
+ stroke: var(--u-color-border);
3064
+ stroke-width: 1;
3065
+ }
3066
+
3067
+ .udb-linechart__crosshair {
3068
+ stroke: var(--u-color-border-strong);
3069
+ stroke-width: 1;
3070
+ stroke-dasharray: 3 3;
3071
+ }
3072
+
3073
+ .udb-linechart__tip {
3074
+ position: absolute;
3075
+ top: 8px;
3076
+ z-index: 10;
3077
+ min-width: 132px;
3078
+ padding: 8px;
3079
+ pointer-events: none;
3080
+ border: 1px solid var(--u-color-border);
3081
+ border-radius: var(--u-radius-md);
3082
+ background: var(--u-color-surface);
3083
+ box-shadow: var(--u-shadow-md);
3084
+ }
3085
+
3086
+ .udb-linechart__tiptitle {
3087
+ margin-bottom: 4px;
3088
+ font-size: var(--u-font-size-xs);
3089
+ color: var(--u-color-muted);
3090
+ }
3091
+
3092
+ .udb-linechart__tiprow {
3093
+ display: flex;
3094
+ align-items: center;
3095
+ justify-content: space-between;
3096
+ gap: 12px;
3097
+ font-size: var(--u-font-size-sm);
3098
+ }
3099
+
3100
+ .udb-linechart__tiprow span {
3101
+ display: flex;
3102
+ align-items: center;
3103
+ gap: 6px;
3104
+ color: var(--u-color-muted);
3105
+ }
3106
+
3107
+ .udb-linechart__tiprow b {
3108
+ font-weight: var(--u-font-weight-medium);
3109
+ color: var(--u-color-text);
3110
+ font-variant-numeric: tabular-nums;
3111
+ }
3112
+
3113
+ .udb-linechart__swatch {
3114
+ display: inline-block;
3115
+ width: 12px;
3116
+ height: 3px;
3117
+ border-radius: 999px;
3118
+ }
3119
+
3120
+ .udb-linechart__legendhost {
3121
+ margin-top: 4px;
3122
+ padding: 0 8px;
3123
+ }