sid-ui 0.1.2 → 0.2.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 +1 -1
- package/src/components/BarChart.tsx +3 -3
- package/src/components/CardEffects.tsx +199 -0
- package/src/components/Chip.tsx +250 -0
- package/src/components/CodeInput.tsx +83 -1
- package/src/components/DonutChart.tsx +32 -15
- package/src/components/Glyph.tsx +10 -0
- package/src/components/Notification.tsx +115 -34
- package/src/components/NotificationTrigger.tsx +71 -9
- package/src/components/RadarChart.tsx +220 -0
- package/src/components/RadialBarChart.tsx +147 -0
- package/src/components/Rating.tsx +108 -19
- package/src/components/ScatterChart.tsx +267 -0
- package/src/components/StatusIndicator.tsx +52 -0
- package/src/components/Treemap.tsx +167 -0
- package/src/components/TrendChart.tsx +55 -15
- package/src/components/UptimeBar.tsx +96 -0
- package/src/components/chartMath.ts +151 -0
- package/src/components/index.ts +43 -3
- package/src/styles/components/card-effects.css +304 -0
- package/src/styles/components/charts.css +470 -7
- package/src/styles/components/chip.css +343 -0
- package/src/styles/components/code-input.css +137 -84
- package/src/styles/components/notification.css +236 -41
- package/src/styles/components/status.css +117 -0
- package/src/styles/components.css +99 -14
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useId, type ReactNode } from "react";
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import { Glyph } from "./Glyph";
|
|
6
6
|
|
|
7
7
|
/** Accent on the unread mark. The title always says what happened. */
|
|
8
8
|
export type NotificationTone = "accent" | "success" | "warning" | "danger" | "neutral";
|
|
@@ -16,7 +16,13 @@ export interface NotificationProps {
|
|
|
16
16
|
time?: ReactNode;
|
|
17
17
|
/** Leading visual — an avatar or an icon. */
|
|
18
18
|
start?: ReactNode;
|
|
19
|
-
/** A
|
|
19
|
+
/** A small icon pinned to the corner of `start`, tinted by `tone` — what kind of event this is. */
|
|
20
|
+
badge?: ReactNode;
|
|
21
|
+
/** Rich detail under the description: a quoted comment, a file, an amount. Keep it non-interactive. */
|
|
22
|
+
preview?: ReactNode;
|
|
23
|
+
/** Buttons under the copy, such as Accept and Decline. */
|
|
24
|
+
actions?: ReactNode;
|
|
25
|
+
/** A trailing next step, such as a button or a link. */
|
|
20
26
|
action?: ReactNode;
|
|
21
27
|
/** Draws the unread mark. Read items stay quiet. */
|
|
22
28
|
unread?: boolean;
|
|
@@ -34,6 +40,19 @@ export interface NotificationListProps {
|
|
|
34
40
|
label?: string;
|
|
35
41
|
/** Title and actions above the list, such as “Mark all read”. */
|
|
36
42
|
header?: ReactNode;
|
|
43
|
+
/** Shown in place of the rows when there are none. */
|
|
44
|
+
empty?: ReactNode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface NotificationGroupProps {
|
|
48
|
+
/** The section title, such as “Today” or “Earlier”. */
|
|
49
|
+
label: ReactNode;
|
|
50
|
+
children: ReactNode;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hasItems(children: ReactNode) {
|
|
54
|
+
const filled = (child: ReactNode) => child != null && child !== false;
|
|
55
|
+
return Array.isArray(children) ? children.some(filled) : filled(children);
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
/** A feed of things that happened — unread marks, times, and optional actions. */
|
|
@@ -41,23 +60,66 @@ export function NotificationList({
|
|
|
41
60
|
children,
|
|
42
61
|
label = "Notifications",
|
|
43
62
|
header,
|
|
63
|
+
empty,
|
|
44
64
|
}: NotificationListProps) {
|
|
65
|
+
const showEmpty = empty != null && !hasItems(children);
|
|
45
66
|
return (
|
|
46
67
|
<section className="os-notes" aria-label={header ? undefined : label}>
|
|
47
68
|
{header && <div className="os-notes-header">{header}</div>}
|
|
48
|
-
|
|
69
|
+
{showEmpty ? (
|
|
70
|
+
<div className="os-notes-empty">{empty}</div>
|
|
71
|
+
) : (
|
|
72
|
+
<ul className="os-notes-list" aria-label={header ? label : undefined}>
|
|
73
|
+
{children}
|
|
74
|
+
</ul>
|
|
75
|
+
)}
|
|
76
|
+
</section>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface NotificationIconProps {
|
|
81
|
+
/** An Icon or Glyph. */
|
|
82
|
+
icon: ReactNode;
|
|
83
|
+
tone?: NotificationTone;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A tinted round icon for `start` when the event comes from the system, not a person. */
|
|
87
|
+
export function NotificationIcon({ icon, tone = "accent" }: NotificationIconProps) {
|
|
88
|
+
return (
|
|
89
|
+
<span className={`os-note-icon os-note-icon-${tone}`} aria-hidden>
|
|
90
|
+
{icon}
|
|
91
|
+
</span>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A labelled run of notifications inside a NotificationList — “Today”, “This week”. */
|
|
96
|
+
export function NotificationGroup({ label, children }: NotificationGroupProps) {
|
|
97
|
+
const id = useId();
|
|
98
|
+
return (
|
|
99
|
+
<li className="os-note-group">
|
|
100
|
+
<div className="os-note-group-label" id={id}>
|
|
101
|
+
{label}
|
|
102
|
+
</div>
|
|
103
|
+
<ul className="os-notes-list" aria-labelledby={id}>
|
|
49
104
|
{children}
|
|
50
105
|
</ul>
|
|
51
|
-
</
|
|
106
|
+
</li>
|
|
52
107
|
);
|
|
53
108
|
}
|
|
54
109
|
|
|
55
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* One item in a notification feed. Unread items announce themselves; the title carries the
|
|
112
|
+
* meaning. With `href` or `onClick` the whole row is the target, while `actions`, `action`,
|
|
113
|
+
* and the dismiss button stay separately clickable.
|
|
114
|
+
*/
|
|
56
115
|
export function Notification({
|
|
57
116
|
title,
|
|
58
117
|
description,
|
|
59
118
|
time,
|
|
60
119
|
start,
|
|
120
|
+
badge,
|
|
121
|
+
preview,
|
|
122
|
+
actions,
|
|
61
123
|
action,
|
|
62
124
|
unread = false,
|
|
63
125
|
tone = "accent",
|
|
@@ -66,39 +128,58 @@ export function Notification({
|
|
|
66
128
|
onDismiss,
|
|
67
129
|
dismissLabel = "Dismiss",
|
|
68
130
|
}: NotificationProps) {
|
|
69
|
-
const
|
|
131
|
+
const label = (
|
|
70
132
|
<>
|
|
71
|
-
<span
|
|
72
|
-
|
|
73
|
-
aria-hidden
|
|
74
|
-
/>
|
|
75
|
-
<span className={start ? "os-note-start" : "os-note-start os-note-start-empty"}>{start}</span>
|
|
76
|
-
<span className="os-note-body">
|
|
77
|
-
<span className="os-note-title">{title}</span>
|
|
78
|
-
{description != null && <span className="os-note-description">{description}</span>}
|
|
79
|
-
</span>
|
|
80
|
-
{time != null && <span className="os-note-time">{time}</span>}
|
|
133
|
+
{unread && <span className="os-sr">Unread. </span>}
|
|
134
|
+
{title}
|
|
81
135
|
</>
|
|
82
136
|
);
|
|
83
137
|
|
|
84
138
|
return (
|
|
85
|
-
<li
|
|
86
|
-
{
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
139
|
+
<li
|
|
140
|
+
className={[
|
|
141
|
+
"os-note",
|
|
142
|
+
unread && "os-note-unread",
|
|
143
|
+
(href || onClick) && "os-note-interactive",
|
|
144
|
+
]
|
|
145
|
+
.filter(Boolean)
|
|
146
|
+
.join(" ")}
|
|
147
|
+
>
|
|
148
|
+
<div className="os-note-main">
|
|
149
|
+
<span
|
|
150
|
+
className={["os-note-mark", unread && `os-note-tone-${tone}`].filter(Boolean).join(" ")}
|
|
151
|
+
aria-hidden
|
|
152
|
+
/>
|
|
153
|
+
{start != null && (
|
|
154
|
+
<span className="os-note-start">
|
|
155
|
+
{start}
|
|
156
|
+
{badge != null && (
|
|
157
|
+
<span className={`os-note-badge os-note-badge-${tone}`} aria-hidden>
|
|
158
|
+
{badge}
|
|
159
|
+
</span>
|
|
160
|
+
)}
|
|
161
|
+
</span>
|
|
162
|
+
)}
|
|
163
|
+
<span className="os-note-body">
|
|
164
|
+
<span className="os-note-title">
|
|
165
|
+
{href ? (
|
|
166
|
+
<a className="os-note-hit" href={href}>
|
|
167
|
+
{label}
|
|
168
|
+
</a>
|
|
169
|
+
) : onClick ? (
|
|
170
|
+
<button type="button" className="os-note-hit" onClick={onClick}>
|
|
171
|
+
{label}
|
|
172
|
+
</button>
|
|
173
|
+
) : (
|
|
174
|
+
label
|
|
175
|
+
)}
|
|
176
|
+
</span>
|
|
177
|
+
{description != null && <span className="os-note-description">{description}</span>}
|
|
178
|
+
{preview != null && <span className="os-note-preview">{preview}</span>}
|
|
179
|
+
{actions != null && <span className="os-note-actions">{actions}</span>}
|
|
180
|
+
</span>
|
|
181
|
+
{time != null && <span className="os-note-time">{time}</span>}
|
|
182
|
+
</div>
|
|
102
183
|
{action && <span className="os-note-action">{action}</span>}
|
|
103
184
|
{onDismiss && (
|
|
104
185
|
<button
|
|
@@ -12,26 +12,49 @@ import {
|
|
|
12
12
|
} from "react";
|
|
13
13
|
import { createPortal } from "react-dom";
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { Button } from "./Action";
|
|
16
|
+
import { Glyph, type GlyphName } from "./Glyph";
|
|
16
17
|
|
|
17
18
|
import type { NotificationTone } from "./Notification";
|
|
18
19
|
|
|
19
20
|
/** Where a triggered notification sits. Logical start/end mirror under RTL. */
|
|
20
21
|
export type NotificationPosition = "topStart" | "topEnd" | "bottomStart" | "bottomEnd";
|
|
21
22
|
|
|
23
|
+
/** A button on a triggered notification. Clicking it runs `onClick`, then dismisses. */
|
|
24
|
+
export interface NotificationAction {
|
|
25
|
+
label: string;
|
|
26
|
+
onClick: () => void;
|
|
27
|
+
variant?: "primary" | "secondary" | "ghost";
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
export interface ShowNotificationOptions {
|
|
23
31
|
title: ReactNode;
|
|
24
32
|
description?: ReactNode;
|
|
25
33
|
tone?: NotificationTone;
|
|
26
34
|
/** Corner of the viewport. */
|
|
27
35
|
position?: NotificationPosition;
|
|
28
|
-
/** How long it stays. 0 keeps it until dismissed. */
|
|
36
|
+
/** How long it stays. 0 keeps it until dismissed. Hovering or focusing the card pauses it. */
|
|
29
37
|
duration?: number;
|
|
38
|
+
/** Leading visual in place of the tone icon — an avatar or a thumbnail. */
|
|
39
|
+
start?: ReactNode;
|
|
40
|
+
/** Replaces the tone's default icon. */
|
|
41
|
+
icon?: ReactNode;
|
|
42
|
+
/** Up to two buttons under the copy, such as Undo or View. */
|
|
43
|
+
actions?: NotificationAction[];
|
|
44
|
+
/** Draws the time left as a bar along the bottom edge. Defaults to true when it times out. */
|
|
45
|
+
showProgress?: boolean;
|
|
30
46
|
}
|
|
31
47
|
|
|
32
48
|
export type NotificationDismiss = () => void;
|
|
33
49
|
|
|
34
50
|
const POSITIONS: NotificationPosition[] = ["topStart", "topEnd", "bottomStart", "bottomEnd"];
|
|
51
|
+
const TONE_GLYPH: Record<NotificationTone, GlyphName> = {
|
|
52
|
+
accent: "info",
|
|
53
|
+
success: "check",
|
|
54
|
+
warning: "alert",
|
|
55
|
+
danger: "alertCircle",
|
|
56
|
+
neutral: "bell",
|
|
57
|
+
};
|
|
35
58
|
const DEFAULT_DURATION_MS = 5000;
|
|
36
59
|
const DEFAULT_POSITION: NotificationPosition = "topEnd";
|
|
37
60
|
/** Matches --duration-slow, so the card finishes its exit before it unmounts. */
|
|
@@ -44,6 +67,10 @@ interface Notice {
|
|
|
44
67
|
tone: NotificationTone;
|
|
45
68
|
position: NotificationPosition;
|
|
46
69
|
duration: number;
|
|
70
|
+
start?: ReactNode;
|
|
71
|
+
icon?: ReactNode;
|
|
72
|
+
actions: NotificationAction[];
|
|
73
|
+
showProgress: boolean;
|
|
47
74
|
leaving: boolean;
|
|
48
75
|
}
|
|
49
76
|
|
|
@@ -91,6 +118,10 @@ export function NotificationViewport({ children }: { children: ReactNode }) {
|
|
|
91
118
|
tone: options.tone ?? "accent",
|
|
92
119
|
position: options.position ?? DEFAULT_POSITION,
|
|
93
120
|
duration: options.duration ?? DEFAULT_DURATION_MS,
|
|
121
|
+
start: options.start,
|
|
122
|
+
icon: options.icon,
|
|
123
|
+
actions: (options.actions ?? []).slice(0, 2),
|
|
124
|
+
showProgress: options.showProgress ?? true,
|
|
94
125
|
leaving: false,
|
|
95
126
|
};
|
|
96
127
|
setItems((all) => [notice, ...all]);
|
|
@@ -208,12 +239,7 @@ function NotificationCard({
|
|
|
208
239
|
onRemove: () => void;
|
|
209
240
|
}) {
|
|
210
241
|
const titleId = useId();
|
|
211
|
-
|
|
212
|
-
useEffect(() => {
|
|
213
|
-
if (item.duration === 0 || item.leaving) return;
|
|
214
|
-
const timer = window.setTimeout(onDismiss, item.duration);
|
|
215
|
-
return () => window.clearTimeout(timer);
|
|
216
|
-
}, [item.duration, item.leaving, onDismiss]);
|
|
242
|
+
const timed = item.duration > 0;
|
|
217
243
|
|
|
218
244
|
useEffect(() => {
|
|
219
245
|
if (!item.leaving) return;
|
|
@@ -230,7 +256,13 @@ function NotificationCard({
|
|
|
230
256
|
role={item.tone === "danger" ? "alert" : "status"}
|
|
231
257
|
aria-labelledby={titleId}
|
|
232
258
|
>
|
|
233
|
-
|
|
259
|
+
{item.start != null ? (
|
|
260
|
+
<span className="os-note-card-start">{item.start}</span>
|
|
261
|
+
) : (
|
|
262
|
+
<span className="os-note-card-icon" aria-hidden>
|
|
263
|
+
{item.icon ?? <Glyph name={TONE_GLYPH[item.tone]} />}
|
|
264
|
+
</span>
|
|
265
|
+
)}
|
|
234
266
|
<span className="os-note-card-copy">
|
|
235
267
|
<span className="os-note-card-title" id={titleId}>
|
|
236
268
|
{item.title}
|
|
@@ -238,10 +270,40 @@ function NotificationCard({
|
|
|
238
270
|
{item.description != null && (
|
|
239
271
|
<span className="os-note-card-description">{item.description}</span>
|
|
240
272
|
)}
|
|
273
|
+
{item.actions.length > 0 && (
|
|
274
|
+
<span className="os-note-card-actions">
|
|
275
|
+
{item.actions.map((action, index) => (
|
|
276
|
+
<Button
|
|
277
|
+
key={action.label}
|
|
278
|
+
label={action.label}
|
|
279
|
+
size="sm"
|
|
280
|
+
variant={action.variant ?? (index === 0 ? "secondary" : "ghost")}
|
|
281
|
+
onClick={() => {
|
|
282
|
+
action.onClick();
|
|
283
|
+
onDismiss();
|
|
284
|
+
}}
|
|
285
|
+
/>
|
|
286
|
+
))}
|
|
287
|
+
</span>
|
|
288
|
+
)}
|
|
241
289
|
</span>
|
|
242
290
|
<button type="button" className="os-note-dismiss" aria-label="Dismiss" onClick={onDismiss}>
|
|
243
291
|
<Glyph name="close" />
|
|
244
292
|
</button>
|
|
293
|
+
{/* The bar is the timer: it pauses with the card on hover or focus, and its end dismisses. */}
|
|
294
|
+
{timed && !item.leaving && (
|
|
295
|
+
<span
|
|
296
|
+
className={[
|
|
297
|
+
"os-note-card-progress",
|
|
298
|
+
!item.showProgress && "os-note-card-progress-hidden",
|
|
299
|
+
]
|
|
300
|
+
.filter(Boolean)
|
|
301
|
+
.join(" ")}
|
|
302
|
+
style={{ animationDuration: `${item.duration}ms` }}
|
|
303
|
+
onAnimationEnd={onDismiss}
|
|
304
|
+
aria-hidden
|
|
305
|
+
/>
|
|
306
|
+
)}
|
|
245
307
|
</div>
|
|
246
308
|
</div>
|
|
247
309
|
);
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useState, type KeyboardEvent } from "react";
|
|
4
|
+
|
|
5
|
+
import { niceTicks, polarPoint, polygonPath } from "./chartMath";
|
|
6
|
+
import {
|
|
7
|
+
ChartLegend,
|
|
8
|
+
ChartTable,
|
|
9
|
+
ChartTip,
|
|
10
|
+
defaultFormat,
|
|
11
|
+
toneAt,
|
|
12
|
+
type ChartTone,
|
|
13
|
+
} from "./chartParts";
|
|
14
|
+
import { useElementWidth } from "./Responsive";
|
|
15
|
+
|
|
16
|
+
export type RadarSeries = {
|
|
17
|
+
label: string;
|
|
18
|
+
/** One per axis, in axis order. */
|
|
19
|
+
values: number[];
|
|
20
|
+
tone?: ChartTone;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const DEFAULT_SIZE = 420;
|
|
24
|
+
/** Room around the web for axis names. */
|
|
25
|
+
const LABEL_ROOM = 64;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Several measures of a few things at once — a skill profile, a product comparison. Each axis
|
|
29
|
+
* runs from the center out; each series is a filled shape. Hover or arrow through the axes to
|
|
30
|
+
* read every series on one axis.
|
|
31
|
+
*/
|
|
32
|
+
export function RadarChart({
|
|
33
|
+
axes,
|
|
34
|
+
series,
|
|
35
|
+
label,
|
|
36
|
+
max,
|
|
37
|
+
levels = 4,
|
|
38
|
+
size = DEFAULT_SIZE,
|
|
39
|
+
shape = "polygon",
|
|
40
|
+
formatValue = defaultFormat,
|
|
41
|
+
hasLegend,
|
|
42
|
+
}: {
|
|
43
|
+
/** Axis names, clockwise from twelve o'clock. Three or more. */
|
|
44
|
+
axes: string[];
|
|
45
|
+
series: RadarSeries[];
|
|
46
|
+
/** Accessible name for the chart. */
|
|
47
|
+
label: string;
|
|
48
|
+
/** The value at the rim. Defaults to a clean number above the largest value. */
|
|
49
|
+
max?: number;
|
|
50
|
+
/** Rings in the web. */
|
|
51
|
+
levels?: number;
|
|
52
|
+
/** The largest the chart grows to, in px. It shrinks to fit narrow frames. */
|
|
53
|
+
size?: number;
|
|
54
|
+
/** polygon draws a straight-edged web; circle draws round rings. */
|
|
55
|
+
shape?: "polygon" | "circle";
|
|
56
|
+
formatValue?: (value: number) => string;
|
|
57
|
+
/** Defaults to on for two or more series. */
|
|
58
|
+
hasLegend?: boolean;
|
|
59
|
+
}) {
|
|
60
|
+
const frame = useRef<HTMLDivElement>(null);
|
|
61
|
+
const width = useElementWidth(frame);
|
|
62
|
+
const [active, setActive] = useState<number | null>(null);
|
|
63
|
+
|
|
64
|
+
const side = Math.max(0, Math.min(size, width));
|
|
65
|
+
const center = side / 2;
|
|
66
|
+
const radius = Math.max(0, center - LABEL_ROOM);
|
|
67
|
+
const largest = Math.max(0, ...series.flatMap((s) => s.values));
|
|
68
|
+
const ticks = niceTicks(largest, levels);
|
|
69
|
+
const rim = max ?? ticks[ticks.length - 1];
|
|
70
|
+
const rings = max ? Array.from({ length: levels + 1 }, (_, i) => (max / levels) * i) : ticks;
|
|
71
|
+
const n = axes.length;
|
|
72
|
+
const toned = series.map((item, index) => ({ ...item, tone: toneAt(index, item.tone) }));
|
|
73
|
+
const showLegend = hasLegend ?? series.length > 1;
|
|
74
|
+
const at = (axis: number, value: number) =>
|
|
75
|
+
polarPoint(center, center, rim > 0 ? (Math.max(0, value) / rim) * radius : 0, axis / n);
|
|
76
|
+
|
|
77
|
+
const onKey = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
78
|
+
if (n === 0) return;
|
|
79
|
+
if (["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"].includes(event.key)) {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1;
|
|
82
|
+
setActive((current) => ((current ?? -step) + step + n) % n);
|
|
83
|
+
} else if (event.key === "Escape") {
|
|
84
|
+
setActive(null);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const tipPoint = active === null ? null : polarPoint(center, center, radius + 8, active / n);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className="os-chart os-radar">
|
|
92
|
+
{showLegend ? (
|
|
93
|
+
<ChartLegend items={toned.map((item) => ({ label: item.label, tone: item.tone }))} />
|
|
94
|
+
) : null}
|
|
95
|
+
<div
|
|
96
|
+
ref={frame}
|
|
97
|
+
className="os-chart-frame os-radar-frame"
|
|
98
|
+
style={{ height: side || size }}
|
|
99
|
+
tabIndex={0}
|
|
100
|
+
role="group"
|
|
101
|
+
aria-label={`${label}. Use the arrow keys to read each axis.`}
|
|
102
|
+
onKeyDown={onKey}
|
|
103
|
+
onBlur={() => setActive(null)}
|
|
104
|
+
>
|
|
105
|
+
{side > 0 ? (
|
|
106
|
+
<svg
|
|
107
|
+
width={side}
|
|
108
|
+
height={side}
|
|
109
|
+
className="os-chart-svg"
|
|
110
|
+
aria-hidden
|
|
111
|
+
onPointerLeave={() => setActive(null)}
|
|
112
|
+
>
|
|
113
|
+
{rings.slice(1).map((ring) => {
|
|
114
|
+
const r = rim > 0 ? (ring / rim) * radius : 0;
|
|
115
|
+
return shape === "circle" ? (
|
|
116
|
+
<circle key={ring} className="os-radar-ring" cx={center} cy={center} r={r} />
|
|
117
|
+
) : (
|
|
118
|
+
<path
|
|
119
|
+
key={ring}
|
|
120
|
+
className="os-radar-ring"
|
|
121
|
+
d={polygonPath(axes.map((_, i) => polarPoint(center, center, r, i / n)))}
|
|
122
|
+
/>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
{rings.slice(1).map((ring) => (
|
|
126
|
+
<text
|
|
127
|
+
key={`t-${ring}`}
|
|
128
|
+
className="os-chart-axis os-radar-tick"
|
|
129
|
+
x={center + 4}
|
|
130
|
+
y={center - (rim > 0 ? (ring / rim) * radius : 0)}
|
|
131
|
+
dy="-0.3em"
|
|
132
|
+
>
|
|
133
|
+
{formatValue(ring)}
|
|
134
|
+
</text>
|
|
135
|
+
))}
|
|
136
|
+
{axes.map((name, i) => {
|
|
137
|
+
const end = polarPoint(center, center, radius, i / n);
|
|
138
|
+
const labelAt = polarPoint(center, center, radius + 14, i / n);
|
|
139
|
+
const anchor =
|
|
140
|
+
Math.abs(labelAt.x - center) < 4 ? "middle" : labelAt.x > center ? "start" : "end";
|
|
141
|
+
return (
|
|
142
|
+
<g key={name} data-active={active === i || undefined}>
|
|
143
|
+
<line
|
|
144
|
+
className="os-radar-spoke"
|
|
145
|
+
x1={center}
|
|
146
|
+
y1={center}
|
|
147
|
+
x2={end.x}
|
|
148
|
+
y2={end.y}
|
|
149
|
+
/>
|
|
150
|
+
<text
|
|
151
|
+
className="os-radar-label"
|
|
152
|
+
x={labelAt.x}
|
|
153
|
+
y={labelAt.y}
|
|
154
|
+
textAnchor={anchor}
|
|
155
|
+
dy={labelAt.y < center - radius * 0.7 ? "-0.2em" : labelAt.y > center + radius * 0.7 ? "0.9em" : "0.35em"}
|
|
156
|
+
>
|
|
157
|
+
{name}
|
|
158
|
+
</text>
|
|
159
|
+
</g>
|
|
160
|
+
);
|
|
161
|
+
})}
|
|
162
|
+
{toned.map((item, s) => {
|
|
163
|
+
const points = axes.map((_, i) => at(i, item.values[i] ?? 0));
|
|
164
|
+
return (
|
|
165
|
+
<g
|
|
166
|
+
key={item.label}
|
|
167
|
+
data-tone={item.tone}
|
|
168
|
+
className="os-radar-series"
|
|
169
|
+
style={{ animationDelay: `${s * 120}ms`, transformOrigin: `${center}px ${center}px` }}
|
|
170
|
+
>
|
|
171
|
+
<path className="os-radar-shape" d={polygonPath(points)} />
|
|
172
|
+
{points.map((point, i) => (
|
|
173
|
+
<circle
|
|
174
|
+
key={axes[i]}
|
|
175
|
+
className="os-radar-dot"
|
|
176
|
+
cx={point.x}
|
|
177
|
+
cy={point.y}
|
|
178
|
+
r={active === i ? 5 : 3}
|
|
179
|
+
/>
|
|
180
|
+
))}
|
|
181
|
+
</g>
|
|
182
|
+
);
|
|
183
|
+
})}
|
|
184
|
+
{/* One wedge per axis catches the pointer anywhere in its slice. */}
|
|
185
|
+
{axes.map((name, i) => {
|
|
186
|
+
const a = polarPoint(center, center, radius + LABEL_ROOM, (i - 0.5) / n);
|
|
187
|
+
const b = polarPoint(center, center, radius + LABEL_ROOM, (i + 0.5) / n);
|
|
188
|
+
return (
|
|
189
|
+
<path
|
|
190
|
+
key={`hit-${name}`}
|
|
191
|
+
className="os-chart-hit"
|
|
192
|
+
d={`M${center},${center}L${a.x},${a.y}L${b.x},${b.y}Z`}
|
|
193
|
+
onPointerEnter={() => setActive(i)}
|
|
194
|
+
/>
|
|
195
|
+
);
|
|
196
|
+
})}
|
|
197
|
+
</svg>
|
|
198
|
+
) : null}
|
|
199
|
+
{active !== null && tipPoint ? (
|
|
200
|
+
<ChartTip
|
|
201
|
+
title={axes[active]}
|
|
202
|
+
left={tipPoint.x + (side - width < 0 ? (width - side) / 2 : 0)}
|
|
203
|
+
top={tipPoint.y}
|
|
204
|
+
align={tipPoint.x < center - 4 ? "end" : tipPoint.x > center + 4 ? "start" : "center"}
|
|
205
|
+
rows={toned.map((item) => ({
|
|
206
|
+
label: item.label,
|
|
207
|
+
value: formatValue(item.values[active] ?? 0),
|
|
208
|
+
tone: item.tone,
|
|
209
|
+
}))}
|
|
210
|
+
/>
|
|
211
|
+
) : null}
|
|
212
|
+
</div>
|
|
213
|
+
<ChartTable
|
|
214
|
+
caption={label}
|
|
215
|
+
headers={["", ...series.map((item) => item.label)]}
|
|
216
|
+
rows={axes.map((axis, i) => [axis, ...series.map((item) => formatValue(item.values[i] ?? 0))])}
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|