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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type CSSProperties } from "react";
|
|
4
|
+
|
|
5
|
+
import { ChartTable, defaultFormat, toneAt, type ChartTone } from "./chartParts";
|
|
6
|
+
|
|
7
|
+
export type RadialBarDatum = {
|
|
8
|
+
label: string;
|
|
9
|
+
value: number;
|
|
10
|
+
/** The value that closes the ring. */
|
|
11
|
+
max: number;
|
|
12
|
+
tone?: ChartTone;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const DEFAULT_SIZE = 200;
|
|
16
|
+
/** A gauge spans three quarters of a turn, open at the bottom. */
|
|
17
|
+
const GAUGE_SWEEP = 0.75;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Progress toward goals as rings. `rings` nests one ring per measure, like activity rings;
|
|
21
|
+
* `gauge` draws the first measure as an open three-quarter dial. Rings sweep in on first paint
|
|
22
|
+
* and the center reads the hovered ring.
|
|
23
|
+
*/
|
|
24
|
+
export function RadialBarChart({
|
|
25
|
+
data,
|
|
26
|
+
label,
|
|
27
|
+
variant = "rings",
|
|
28
|
+
size = DEFAULT_SIZE,
|
|
29
|
+
thickness,
|
|
30
|
+
formatValue = defaultFormat,
|
|
31
|
+
hasLegend = true,
|
|
32
|
+
}: {
|
|
33
|
+
data: RadialBarDatum[];
|
|
34
|
+
/** Accessible name for the chart. */
|
|
35
|
+
label: string;
|
|
36
|
+
variant?: "rings" | "gauge";
|
|
37
|
+
size?: number;
|
|
38
|
+
/** Ring width in px. Defaults to a share of the size. */
|
|
39
|
+
thickness?: number;
|
|
40
|
+
formatValue?: (value: number) => string;
|
|
41
|
+
hasLegend?: boolean;
|
|
42
|
+
}) {
|
|
43
|
+
const [active, setActive] = useState<number | null>(null);
|
|
44
|
+
const toned = data.map((d, index) => ({ ...d, tone: toneAt(index, d.tone) }));
|
|
45
|
+
const shown = variant === "gauge" ? toned.slice(0, 1) : toned;
|
|
46
|
+
const stroke = thickness ?? Math.max(8, Math.round(size * (variant === "gauge" ? 0.1 : 0.075)));
|
|
47
|
+
const gap = Math.round(stroke * 0.3);
|
|
48
|
+
const center = size / 2;
|
|
49
|
+
const share = (d: RadialBarDatum) => (d.max > 0 ? Math.min(1, Math.max(0, d.value / d.max)) : 0);
|
|
50
|
+
const focus = toned[active ?? 0];
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="os-radial">
|
|
54
|
+
<div
|
|
55
|
+
className="os-radial-figure"
|
|
56
|
+
style={{ width: size, height: size }}
|
|
57
|
+
role="img"
|
|
58
|
+
aria-label={`${label}: ${data
|
|
59
|
+
.map((d) => `${d.label} ${formatValue(d.value)} of ${formatValue(d.max)}`)
|
|
60
|
+
.join(", ")}`}
|
|
61
|
+
>
|
|
62
|
+
<svg width={size} height={size} aria-hidden>
|
|
63
|
+
{shown.map((d, index) => {
|
|
64
|
+
const r = center - stroke / 2 - index * (stroke + gap);
|
|
65
|
+
if (r <= 0) return null;
|
|
66
|
+
const sweep = variant === "gauge" ? GAUGE_SWEEP : 1;
|
|
67
|
+
const rotate = variant === "gauge" ? 135 : -90;
|
|
68
|
+
return (
|
|
69
|
+
<g
|
|
70
|
+
key={d.label}
|
|
71
|
+
data-tone={d.tone}
|
|
72
|
+
data-dim={active !== null && active !== index ? true : undefined}
|
|
73
|
+
className="os-radial-ring"
|
|
74
|
+
transform={`rotate(${rotate} ${center} ${center})`}
|
|
75
|
+
onPointerEnter={() => setActive(index)}
|
|
76
|
+
onPointerLeave={() => setActive(null)}
|
|
77
|
+
>
|
|
78
|
+
<circle
|
|
79
|
+
className="os-radial-track"
|
|
80
|
+
cx={center}
|
|
81
|
+
cy={center}
|
|
82
|
+
r={r}
|
|
83
|
+
strokeWidth={stroke}
|
|
84
|
+
pathLength={1}
|
|
85
|
+
strokeDasharray={`${sweep} 1`}
|
|
86
|
+
/>
|
|
87
|
+
<circle
|
|
88
|
+
className="os-radial-bar"
|
|
89
|
+
cx={center}
|
|
90
|
+
cy={center}
|
|
91
|
+
r={r}
|
|
92
|
+
strokeWidth={stroke}
|
|
93
|
+
pathLength={1}
|
|
94
|
+
style={
|
|
95
|
+
{
|
|
96
|
+
"--os-radial-to": `${share(d) * sweep} 1`,
|
|
97
|
+
animationDelay: `${index * 110}ms`,
|
|
98
|
+
} as CSSProperties
|
|
99
|
+
}
|
|
100
|
+
/>
|
|
101
|
+
</g>
|
|
102
|
+
);
|
|
103
|
+
})}
|
|
104
|
+
</svg>
|
|
105
|
+
<div className="os-radial-center" aria-hidden>
|
|
106
|
+
<strong>
|
|
107
|
+
{variant === "gauge"
|
|
108
|
+
? `${Math.round(share(focus) * 100)}%`
|
|
109
|
+
: formatValue(focus.value)}
|
|
110
|
+
</strong>
|
|
111
|
+
<span>
|
|
112
|
+
{variant === "gauge"
|
|
113
|
+
? focus.label
|
|
114
|
+
: `${focus.label} · ${Math.round(share(focus) * 100)}%`}
|
|
115
|
+
</span>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
{hasLegend ? (
|
|
119
|
+
<ul className="os-radial-legend" aria-hidden>
|
|
120
|
+
{shown.map((d, index) => (
|
|
121
|
+
<li
|
|
122
|
+
key={d.label}
|
|
123
|
+
data-active={active === index || undefined}
|
|
124
|
+
onPointerEnter={() => setActive(index)}
|
|
125
|
+
onPointerLeave={() => setActive(null)}
|
|
126
|
+
>
|
|
127
|
+
<span className="os-chart-key" data-tone={d.tone} />
|
|
128
|
+
<span className="os-radial-name">{d.label}</span>
|
|
129
|
+
<strong>{formatValue(d.value)}</strong>
|
|
130
|
+
<span className="os-radial-of">/ {formatValue(d.max)}</span>
|
|
131
|
+
</li>
|
|
132
|
+
))}
|
|
133
|
+
</ul>
|
|
134
|
+
) : null}
|
|
135
|
+
<ChartTable
|
|
136
|
+
caption={label}
|
|
137
|
+
headers={["", "Value", "Goal", "Progress"]}
|
|
138
|
+
rows={data.map((d) => [
|
|
139
|
+
d.label,
|
|
140
|
+
formatValue(d.value),
|
|
141
|
+
formatValue(d.max),
|
|
142
|
+
`${Math.round(share(d) * 100)}%`,
|
|
143
|
+
])}
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
|
|
4
4
|
|
|
5
5
|
export const RATING_MAX = 5;
|
|
6
6
|
|
|
7
|
+
/** The mark that repeats: gold stars by default, rose hearts for likes and favorites. */
|
|
8
|
+
export type RatingIcon = "star" | "heart";
|
|
9
|
+
|
|
7
10
|
export interface RatingProps {
|
|
8
11
|
value: number;
|
|
9
12
|
onChange?: (value: number) => void;
|
|
@@ -14,9 +17,40 @@ export interface RatingProps {
|
|
|
14
17
|
/** Hides the numeric score. The marks still expose it to assistive tech. */
|
|
15
18
|
showValue?: boolean;
|
|
16
19
|
caption?: string;
|
|
20
|
+
icon?: RatingIcon;
|
|
21
|
+
/** Lets a click on the left half of a mark pick a half, and arrow keys step by halves. */
|
|
22
|
+
allowHalf?: boolean;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
|
-
|
|
25
|
+
const PATHS: Record<RatingIcon, string> = {
|
|
26
|
+
star: "M12 2.6l2.9 5.9 6.5.95-4.7 4.6 1.1 6.45L12 17.45 6.2 20.5l1.1-6.45-4.7-4.6 6.5-.95z",
|
|
27
|
+
heart:
|
|
28
|
+
"M12 20.6s-7.6-4.55-9.35-9.4C1.5 8.05 3.55 4.4 7.15 4.4c2.05 0 3.6 1.1 4.85 2.75C13.25 5.5 14.8 4.4 16.85 4.4c3.6 0 5.65 3.65 4.5 6.8-1.75 4.85-9.35 9.4-9.35 9.4z",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function Mark({ icon, fill }: { icon: RatingIcon; fill: number }) {
|
|
32
|
+
return (
|
|
33
|
+
<>
|
|
34
|
+
<svg className="os-rating-mark-base" viewBox="0 0 24 24" aria-hidden>
|
|
35
|
+
<path d={PATHS[icon]} />
|
|
36
|
+
</svg>
|
|
37
|
+
<span className="os-rating-mark-fill" style={{ width: `${fill * 100}%` }}>
|
|
38
|
+
<svg viewBox="0 0 24 24" aria-hidden>
|
|
39
|
+
<path d={PATHS[icon]} />
|
|
40
|
+
</svg>
|
|
41
|
+
</span>
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function format(value: number) {
|
|
47
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A score in stars or hearts. Read-only values can be fractional — 4.3 fills 4.3 marks — and the
|
|
52
|
+
* number is written beside them unless `showValue` is off.
|
|
53
|
+
*/
|
|
20
54
|
export function Rating({
|
|
21
55
|
value,
|
|
22
56
|
onChange,
|
|
@@ -26,50 +60,105 @@ export function Rating({
|
|
|
26
60
|
label = "Rating",
|
|
27
61
|
showValue = true,
|
|
28
62
|
caption,
|
|
63
|
+
icon = "star",
|
|
64
|
+
allowHalf = false,
|
|
29
65
|
}: RatingProps) {
|
|
30
66
|
const [hover, setHover] = useState<number | null>(null);
|
|
67
|
+
// Bumps on every pick so the chosen mark replays its pop.
|
|
68
|
+
const [popped, setPopped] = useState<{ mark: number; key: number } | null>(null);
|
|
69
|
+
const marks = useRef<Array<HTMLButtonElement | null>>([]);
|
|
31
70
|
const shown = hover ?? value;
|
|
32
71
|
const interactive = Boolean(onChange) && !readOnly;
|
|
72
|
+
const step = allowHalf ? 0.5 : 1;
|
|
73
|
+
|
|
74
|
+
const pick = (next: number) => {
|
|
75
|
+
const clamped = Math.max(step, Math.min(max, next));
|
|
76
|
+
onChange?.(clamped);
|
|
77
|
+
setPopped((p) => ({ mark: Math.ceil(clamped), key: (p?.key ?? 0) + 1 }));
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const pointerValue = (mark: number, event: MouseEvent<HTMLButtonElement>) => {
|
|
81
|
+
if (!allowHalf) return mark;
|
|
82
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
83
|
+
const fromStart = document.dir === "rtl" ? box.right - event.clientX : event.clientX - box.left;
|
|
84
|
+
return fromStart < box.width / 2 ? mark - 0.5 : mark;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
88
|
+
const keys: Record<string, number> = {
|
|
89
|
+
ArrowRight: step,
|
|
90
|
+
ArrowUp: step,
|
|
91
|
+
ArrowLeft: -step,
|
|
92
|
+
ArrowDown: -step,
|
|
93
|
+
};
|
|
94
|
+
if (event.key in keys) {
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
const next = Math.max(step, Math.min(max, (value || 0) + keys[event.key]));
|
|
97
|
+
pick(next);
|
|
98
|
+
marks.current[Math.ceil(next) - 1]?.focus();
|
|
99
|
+
} else if (event.key === "Home" || event.key === "End") {
|
|
100
|
+
event.preventDefault();
|
|
101
|
+
const next = event.key === "Home" ? step : max;
|
|
102
|
+
pick(next);
|
|
103
|
+
marks.current[Math.ceil(next) - 1]?.focus();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const focusMark = Math.max(1, Math.ceil(value || 1));
|
|
33
108
|
|
|
34
109
|
return (
|
|
35
|
-
<div className={`os-rating os-rating-${size}`}>
|
|
110
|
+
<div className={`os-rating os-rating-${size} os-rating-${icon}`}>
|
|
36
111
|
<div
|
|
37
|
-
className="os-rating-
|
|
112
|
+
className="os-rating-marks"
|
|
38
113
|
role={interactive ? "radiogroup" : "img"}
|
|
39
|
-
aria-label={interactive ? label : `${label}, ${value} of ${max}`}
|
|
114
|
+
aria-label={interactive ? label : `${label}, ${format(value)} of ${max}`}
|
|
40
115
|
onMouseLeave={() => setHover(null)}
|
|
116
|
+
onKeyDown={interactive ? onKeyDown : undefined}
|
|
41
117
|
>
|
|
42
118
|
{Array.from({ length: max }, (_, index) => {
|
|
43
|
-
const
|
|
44
|
-
const
|
|
119
|
+
const mark = index + 1;
|
|
120
|
+
const fill = Math.max(0, Math.min(1, shown - index));
|
|
121
|
+
const pop = popped?.mark === mark;
|
|
45
122
|
if (!interactive) {
|
|
46
123
|
return (
|
|
47
|
-
<span key={
|
|
48
|
-
|
|
124
|
+
<span key={mark} className="os-rating-mark" aria-hidden>
|
|
125
|
+
<Mark icon={icon} fill={fill} />
|
|
49
126
|
</span>
|
|
50
127
|
);
|
|
51
128
|
}
|
|
52
129
|
return (
|
|
53
130
|
<button
|
|
54
|
-
key={
|
|
131
|
+
key={mark}
|
|
132
|
+
ref={(node) => {
|
|
133
|
+
marks.current[index] = node;
|
|
134
|
+
}}
|
|
55
135
|
type="button"
|
|
56
136
|
role="radio"
|
|
57
|
-
aria-checked={value ===
|
|
58
|
-
aria-label={`${
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
137
|
+
aria-checked={Math.ceil(value) === mark}
|
|
138
|
+
aria-label={`${mark} of ${max}`}
|
|
139
|
+
tabIndex={mark === focusMark ? 0 : -1}
|
|
140
|
+
className={[
|
|
141
|
+
"os-rating-mark",
|
|
142
|
+
hover != null && mark <= Math.ceil(hover) && "os-rating-mark-preview",
|
|
143
|
+
pop && "os-rating-mark-pop",
|
|
144
|
+
]
|
|
145
|
+
.filter(Boolean)
|
|
146
|
+
.join(" ")}
|
|
147
|
+
onMouseMove={(event) => setHover(pointerValue(mark, event))}
|
|
148
|
+
onFocus={() => setHover(null)}
|
|
149
|
+
onClick={(event) => pick(pointerValue(mark, event))}
|
|
64
150
|
>
|
|
65
|
-
|
|
151
|
+
{/* Re-keyed on each pick so the pop replays without remounting the button. */}
|
|
152
|
+
<span key={pop ? popped.key : 0} className="os-rating-mark-inner">
|
|
153
|
+
<Mark icon={icon} fill={fill} />
|
|
154
|
+
</span>
|
|
66
155
|
</button>
|
|
67
156
|
);
|
|
68
157
|
})}
|
|
69
158
|
</div>
|
|
70
159
|
{showValue && (
|
|
71
160
|
<span className="os-rating-value">
|
|
72
|
-
{value} / {max}
|
|
161
|
+
{format(value)} / {max}
|
|
73
162
|
</span>
|
|
74
163
|
)}
|
|
75
164
|
{caption && <span className="os-rating-caption">{caption}</span>}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
|
|
4
|
+
|
|
5
|
+
import { linearScale, niceDomain } 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 ScatterPoint = {
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
/** Gives the point an area — a bubble chart. */
|
|
20
|
+
size?: number;
|
|
21
|
+
/** Names the point in the readout. */
|
|
22
|
+
label?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ScatterSeries = {
|
|
26
|
+
label: string;
|
|
27
|
+
points: ScatterPoint[];
|
|
28
|
+
tone?: ChartTone;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const MARGIN = { top: 16, right: 16, bottom: 44, left: 52 };
|
|
32
|
+
const DEFAULT_HEIGHT = 320;
|
|
33
|
+
const DOT = 5;
|
|
34
|
+
/** Bubble radii in px, smallest to largest value. */
|
|
35
|
+
const BUBBLE: [number, number] = [6, 26];
|
|
36
|
+
/** How close the pointer must come to a point to read it. */
|
|
37
|
+
const SNAP = 40;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Two measures against each other, one dot per item — or a bubble whose area is a third.
|
|
41
|
+
* The nearest point to the pointer lights up and reads out; the others fade. Optional mean
|
|
42
|
+
* lines split the plot into quadrants.
|
|
43
|
+
*/
|
|
44
|
+
export function ScatterChart({
|
|
45
|
+
series,
|
|
46
|
+
label,
|
|
47
|
+
xLabel,
|
|
48
|
+
yLabel,
|
|
49
|
+
height = DEFAULT_HEIGHT,
|
|
50
|
+
formatX = defaultFormat,
|
|
51
|
+
formatY = defaultFormat,
|
|
52
|
+
formatSize = defaultFormat,
|
|
53
|
+
sizeLabel = "Size",
|
|
54
|
+
hasMeanLines = false,
|
|
55
|
+
hasLegend,
|
|
56
|
+
}: {
|
|
57
|
+
series: ScatterSeries[];
|
|
58
|
+
/** Accessible name for the chart. */
|
|
59
|
+
label: string;
|
|
60
|
+
/** Axis titles. */
|
|
61
|
+
xLabel: string;
|
|
62
|
+
yLabel: string;
|
|
63
|
+
height?: number;
|
|
64
|
+
formatX?: (value: number) => string;
|
|
65
|
+
formatY?: (value: number) => string;
|
|
66
|
+
formatSize?: (value: number) => string;
|
|
67
|
+
/** Names the bubble size in the readout. */
|
|
68
|
+
sizeLabel?: string;
|
|
69
|
+
/** Dashed lines at the mean of x and y, for a four-quadrant read. */
|
|
70
|
+
hasMeanLines?: boolean;
|
|
71
|
+
hasLegend?: boolean;
|
|
72
|
+
}) {
|
|
73
|
+
const frame = useRef<HTMLDivElement>(null);
|
|
74
|
+
const width = useElementWidth(frame);
|
|
75
|
+
const [active, setActive] = useState<{ s: number; p: number } | null>(null);
|
|
76
|
+
|
|
77
|
+
const all = series.flatMap((s) => s.points);
|
|
78
|
+
const xTicks = niceDomain(Math.min(...all.map((p) => p.x)), Math.max(...all.map((p) => p.x)), 5);
|
|
79
|
+
const yTicks = niceDomain(Math.min(...all.map((p) => p.y)), Math.max(...all.map((p) => p.y)), 4);
|
|
80
|
+
const sizes = all.map((p) => p.size ?? 0);
|
|
81
|
+
const maxSize = Math.max(0, ...sizes);
|
|
82
|
+
const plotW = Math.max(0, width - MARGIN.left - MARGIN.right);
|
|
83
|
+
const plotH = Math.max(0, height - MARGIN.top - MARGIN.bottom);
|
|
84
|
+
const x = linearScale([xTicks[0], xTicks[xTicks.length - 1]], [MARGIN.left, MARGIN.left + plotW]);
|
|
85
|
+
const y = linearScale([yTicks[0], yTicks[yTicks.length - 1]], [MARGIN.top + plotH, MARGIN.top]);
|
|
86
|
+
// Area, not radius, carries the value.
|
|
87
|
+
const r = (size?: number) =>
|
|
88
|
+
size == null || maxSize === 0
|
|
89
|
+
? DOT
|
|
90
|
+
: BUBBLE[0] + (BUBBLE[1] - BUBBLE[0]) * Math.sqrt(Math.max(0, size) / maxSize);
|
|
91
|
+
const toned = series.map((item, index) => ({ ...item, tone: toneAt(index, item.tone) }));
|
|
92
|
+
const showLegend = hasLegend ?? series.length > 1;
|
|
93
|
+
const flat = toned.flatMap((s, si) => s.points.map((p, pi) => ({ s: si, p: pi, point: p })));
|
|
94
|
+
const meanX = all.reduce((sum, p) => sum + p.x, 0) / Math.max(1, all.length);
|
|
95
|
+
const meanY = all.reduce((sum, p) => sum + p.y, 0) / Math.max(1, all.length);
|
|
96
|
+
|
|
97
|
+
const onMove = (event: PointerEvent<SVGRectElement>) => {
|
|
98
|
+
const box = event.currentTarget.ownerSVGElement?.getBoundingClientRect();
|
|
99
|
+
if (!box) return;
|
|
100
|
+
const px = event.clientX - box.left;
|
|
101
|
+
const py = event.clientY - box.top;
|
|
102
|
+
let best: { s: number; p: number } | null = null;
|
|
103
|
+
let distance = SNAP;
|
|
104
|
+
for (const item of flat) {
|
|
105
|
+
const d = Math.hypot(x(item.point.x) - px, y(item.point.y) - py) - r(item.point.size) / 2;
|
|
106
|
+
if (d < distance) {
|
|
107
|
+
distance = d;
|
|
108
|
+
best = { s: item.s, p: item.p };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
setActive(best);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const onKey = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
115
|
+
if (flat.length === 0) return;
|
|
116
|
+
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
const order = [...flat].sort((a, b) => a.point.x - b.point.x);
|
|
119
|
+
const at = active ? order.findIndex((o) => o.s === active.s && o.p === active.p) : -1;
|
|
120
|
+
const step = event.key === "ArrowRight" ? 1 : -1;
|
|
121
|
+
const next = order[(at + step + order.length) % order.length];
|
|
122
|
+
setActive({ s: next.s, p: next.p });
|
|
123
|
+
} else if (event.key === "Escape") {
|
|
124
|
+
setActive(null);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const focus = active ? toned[active.s].points[active.p] : null;
|
|
129
|
+
const focusX = focus ? x(focus.x) : 0;
|
|
130
|
+
const focusY = focus ? y(focus.y) : 0;
|
|
131
|
+
const tipAlign = focusX < width / 3 ? "start" : focusX > (width * 2) / 3 ? "end" : "center";
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className="os-chart">
|
|
135
|
+
{showLegend ? (
|
|
136
|
+
<ChartLegend items={toned.map((item) => ({ label: item.label, tone: item.tone }))} />
|
|
137
|
+
) : null}
|
|
138
|
+
<div
|
|
139
|
+
ref={frame}
|
|
140
|
+
className="os-chart-frame"
|
|
141
|
+
style={{ height }}
|
|
142
|
+
tabIndex={0}
|
|
143
|
+
role="group"
|
|
144
|
+
aria-label={`${label}. Use the arrow keys to read each point.`}
|
|
145
|
+
onKeyDown={onKey}
|
|
146
|
+
onBlur={() => setActive(null)}
|
|
147
|
+
>
|
|
148
|
+
{width > 0 ? (
|
|
149
|
+
<svg width={width} height={height} className="os-chart-svg" aria-hidden>
|
|
150
|
+
{yTicks.map((tick) => (
|
|
151
|
+
<g key={`y-${tick}`}>
|
|
152
|
+
<line
|
|
153
|
+
className="os-chart-grid"
|
|
154
|
+
x1={MARGIN.left}
|
|
155
|
+
x2={MARGIN.left + plotW}
|
|
156
|
+
y1={y(tick)}
|
|
157
|
+
y2={y(tick)}
|
|
158
|
+
/>
|
|
159
|
+
<text className="os-chart-axis" x={MARGIN.left - 8} y={y(tick)} dy="0.32em" textAnchor="end">
|
|
160
|
+
{formatY(tick)}
|
|
161
|
+
</text>
|
|
162
|
+
</g>
|
|
163
|
+
))}
|
|
164
|
+
{xTicks.map((tick) => (
|
|
165
|
+
<g key={`x-${tick}`}>
|
|
166
|
+
<line
|
|
167
|
+
className="os-chart-grid"
|
|
168
|
+
x1={x(tick)}
|
|
169
|
+
x2={x(tick)}
|
|
170
|
+
y1={MARGIN.top}
|
|
171
|
+
y2={MARGIN.top + plotH}
|
|
172
|
+
/>
|
|
173
|
+
<text className="os-chart-axis" x={x(tick)} y={MARGIN.top + plotH + 18} textAnchor="middle">
|
|
174
|
+
{formatX(tick)}
|
|
175
|
+
</text>
|
|
176
|
+
</g>
|
|
177
|
+
))}
|
|
178
|
+
<text
|
|
179
|
+
className="os-chart-axis-title"
|
|
180
|
+
x={MARGIN.left + plotW / 2}
|
|
181
|
+
y={height - 4}
|
|
182
|
+
textAnchor="middle"
|
|
183
|
+
>
|
|
184
|
+
{xLabel}
|
|
185
|
+
</text>
|
|
186
|
+
<text
|
|
187
|
+
className="os-chart-axis-title"
|
|
188
|
+
transform={`translate(14 ${MARGIN.top + plotH / 2}) rotate(-90)`}
|
|
189
|
+
textAnchor="middle"
|
|
190
|
+
>
|
|
191
|
+
{yLabel}
|
|
192
|
+
</text>
|
|
193
|
+
{hasMeanLines ? (
|
|
194
|
+
<g className="os-scatter-mean">
|
|
195
|
+
<line x1={x(meanX)} x2={x(meanX)} y1={MARGIN.top} y2={MARGIN.top + plotH} />
|
|
196
|
+
<line x1={MARGIN.left} x2={MARGIN.left + plotW} y1={y(meanY)} y2={y(meanY)} />
|
|
197
|
+
</g>
|
|
198
|
+
) : null}
|
|
199
|
+
{focus ? (
|
|
200
|
+
<g className="os-scatter-guide">
|
|
201
|
+
<line x1={focusX} x2={focusX} y1={focusY} y2={MARGIN.top + plotH} />
|
|
202
|
+
<line x1={MARGIN.left} x2={focusX} y1={focusY} y2={focusY} />
|
|
203
|
+
</g>
|
|
204
|
+
) : null}
|
|
205
|
+
{toned.map((item, si) => (
|
|
206
|
+
<g key={item.label} data-tone={item.tone}>
|
|
207
|
+
{item.points.map((point, pi) => {
|
|
208
|
+
const on = active?.s === si && active.p === pi;
|
|
209
|
+
return (
|
|
210
|
+
<circle
|
|
211
|
+
key={pi}
|
|
212
|
+
className="os-scatter-dot"
|
|
213
|
+
data-bubble={point.size != null || undefined}
|
|
214
|
+
data-active={on || undefined}
|
|
215
|
+
data-dim={active && !on ? true : undefined}
|
|
216
|
+
cx={x(point.x)}
|
|
217
|
+
cy={y(point.y)}
|
|
218
|
+
r={r(point.size)}
|
|
219
|
+
style={{ animationDelay: `${Math.min(600, (si * item.points.length + pi) * 18)}ms` }}
|
|
220
|
+
/>
|
|
221
|
+
);
|
|
222
|
+
})}
|
|
223
|
+
</g>
|
|
224
|
+
))}
|
|
225
|
+
<rect
|
|
226
|
+
className="os-chart-hit"
|
|
227
|
+
x={MARGIN.left}
|
|
228
|
+
y={MARGIN.top}
|
|
229
|
+
width={plotW}
|
|
230
|
+
height={plotH}
|
|
231
|
+
onPointerMove={onMove}
|
|
232
|
+
onPointerLeave={() => setActive(null)}
|
|
233
|
+
/>
|
|
234
|
+
</svg>
|
|
235
|
+
) : null}
|
|
236
|
+
{focus && active ? (
|
|
237
|
+
<ChartTip
|
|
238
|
+
title={focus.label ?? toned[active.s].label}
|
|
239
|
+
left={focusX + (tipAlign === "start" ? 14 : tipAlign === "end" ? -14 : 0)}
|
|
240
|
+
top={
|
|
241
|
+
focusY < MARGIN.top + plotH / 2
|
|
242
|
+
? focusY + r(focus.size) + 12
|
|
243
|
+
: Math.max(0, focusY - r(focus.size) - (focus.size != null ? 92 : 76))
|
|
244
|
+
}
|
|
245
|
+
align={tipAlign}
|
|
246
|
+
rows={[
|
|
247
|
+
{ label: xLabel, value: formatX(focus.x), tone: toned[active.s].tone },
|
|
248
|
+
{ label: yLabel, value: formatY(focus.y) },
|
|
249
|
+
...(focus.size != null ? [{ label: sizeLabel, value: formatSize(focus.size) }] : []),
|
|
250
|
+
]}
|
|
251
|
+
/>
|
|
252
|
+
) : null}
|
|
253
|
+
</div>
|
|
254
|
+
<ChartTable
|
|
255
|
+
caption={label}
|
|
256
|
+
headers={["Series", "Point", xLabel, yLabel, ...(maxSize > 0 ? [sizeLabel] : [])]}
|
|
257
|
+
rows={flat.map(({ s, point }) => [
|
|
258
|
+
toned[s].label,
|
|
259
|
+
point.label ?? "",
|
|
260
|
+
formatX(point.x),
|
|
261
|
+
formatY(point.y),
|
|
262
|
+
...(maxSize > 0 ? [point.size != null ? formatSize(point.size) : ""] : []),
|
|
263
|
+
])}
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import type { StatusDotVariant } from "@astryxdesign/core/StatusDot";
|
|
4
|
+
|
|
5
|
+
export type StatusIndicatorSize = "sm" | "md" | "lg";
|
|
6
|
+
|
|
7
|
+
/** plain is a dot and words; soft and outline wrap them in a pill. */
|
|
8
|
+
export type StatusIndicatorAppearance = "plain" | "soft" | "outline";
|
|
9
|
+
|
|
10
|
+
export interface StatusIndicatorProps {
|
|
11
|
+
/** Same variants as StatusDot. */
|
|
12
|
+
variant: StatusDotVariant;
|
|
13
|
+
/** The visible status, such as “Operational”. It is also what assistive tech reads. */
|
|
14
|
+
label: ReactNode;
|
|
15
|
+
/** Quieter text after the label — a time, a count, a percentage. */
|
|
16
|
+
detail?: ReactNode;
|
|
17
|
+
size?: StatusIndicatorSize;
|
|
18
|
+
appearance?: StatusIndicatorAppearance;
|
|
19
|
+
/** A ripple around the dot for live or urgent states. Still under reduced motion. */
|
|
20
|
+
isPulsing?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A status dot with its words beside it. Use it wherever a dot would otherwise need a
|
|
25
|
+
* hand-built row — service lists, table cells, presence, headers.
|
|
26
|
+
*/
|
|
27
|
+
export function StatusIndicator({
|
|
28
|
+
variant,
|
|
29
|
+
label,
|
|
30
|
+
detail,
|
|
31
|
+
size = "md",
|
|
32
|
+
appearance = "plain",
|
|
33
|
+
isPulsing = false,
|
|
34
|
+
}: StatusIndicatorProps) {
|
|
35
|
+
return (
|
|
36
|
+
<span
|
|
37
|
+
className={[
|
|
38
|
+
"os-status",
|
|
39
|
+
`os-status-${variant}`,
|
|
40
|
+
`os-status-${size}`,
|
|
41
|
+
`os-status-${appearance}`,
|
|
42
|
+
isPulsing && "os-status-pulsing",
|
|
43
|
+
]
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.join(" ")}
|
|
46
|
+
>
|
|
47
|
+
<span className="os-status-dot" aria-hidden />
|
|
48
|
+
<span className="os-status-label">{label}</span>
|
|
49
|
+
{detail != null && <span className="os-status-detail">{detail}</span>}
|
|
50
|
+
</span>
|
|
51
|
+
);
|
|
52
|
+
}
|