@w5-ui/react 0.1.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/README.md +9 -0
- package/package.json +59 -0
- package/src/components/Accordion.tsx +65 -0
- package/src/components/ActionMenu.tsx +131 -0
- package/src/components/ActivityFeed.tsx +77 -0
- package/src/components/Alert.tsx +33 -0
- package/src/components/AnnotatedTimeSeries.tsx +148 -0
- package/src/components/AppShell.tsx +158 -0
- package/src/components/ArcDiagram.tsx +114 -0
- package/src/components/AreaChart.tsx +101 -0
- package/src/components/Avatar.tsx +37 -0
- package/src/components/AvatarGroup.tsx +36 -0
- package/src/components/Badge.tsx +36 -0
- package/src/components/Banner.tsx +49 -0
- package/src/components/BarChart.tsx +106 -0
- package/src/components/BeeswarmChart.tsx +142 -0
- package/src/components/BoxPlot.tsx +142 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/BubbleChart.tsx +115 -0
- package/src/components/BulletChart.tsx +121 -0
- package/src/components/BumpChart.tsx +127 -0
- package/src/components/Button.tsx +61 -0
- package/src/components/CIDRInput.tsx +191 -0
- package/src/components/CalendarHeatmap.tsx +117 -0
- package/src/components/Callout.tsx +82 -0
- package/src/components/CandlestickChart.tsx +113 -0
- package/src/components/Card.tsx +27 -0
- package/src/components/Carousel.tsx +78 -0
- package/src/components/Checkbox.tsx +42 -0
- package/src/components/ChordDiagram.tsx +133 -0
- package/src/components/CirclePacking.tsx +189 -0
- package/src/components/CodeBlock.tsx +27 -0
- package/src/components/ColorPicker.tsx +120 -0
- package/src/components/ColumnToggle.tsx +106 -0
- package/src/components/Combobox.tsx +211 -0
- package/src/components/CommandPalette.tsx +168 -0
- package/src/components/ConfirmDialog.tsx +114 -0
- package/src/components/ContextMenu.tsx +127 -0
- package/src/components/ContextualHelp.tsx +69 -0
- package/src/components/ContourPlot.tsx +255 -0
- package/src/components/CopyButton.tsx +47 -0
- package/src/components/CorrelationMatrix.tsx +107 -0
- package/src/components/CountUp.tsx +70 -0
- package/src/components/CumulativeDistribution.tsx +155 -0
- package/src/components/DatePicker.tsx +315 -0
- package/src/components/DateRangePicker.tsx +327 -0
- package/src/components/Dendrogram.tsx +150 -0
- package/src/components/Donut.tsx +101 -0
- package/src/components/DotPlot.tsx +195 -0
- package/src/components/Drawer.tsx +81 -0
- package/src/components/DualAxisChart.tsx +209 -0
- package/src/components/DumbbellChart.tsx +102 -0
- package/src/components/DurationInput.tsx +144 -0
- package/src/components/EmptyState.tsx +40 -0
- package/src/components/ErrorBandChart.tsx +124 -0
- package/src/components/ExpandableRow.tsx +58 -0
- package/src/components/FileUpload.tsx +129 -0
- package/src/components/FilterBuilder.tsx +217 -0
- package/src/components/FlameGraph.tsx +119 -0
- package/src/components/ForceGraph.tsx +149 -0
- package/src/components/FunnelChart.tsx +79 -0
- package/src/components/GanttChart.tsx +96 -0
- package/src/components/Gauge.tsx +71 -0
- package/src/components/GroupedList.tsx +125 -0
- package/src/components/HealthBar.tsx +25 -0
- package/src/components/HeatMap.tsx +106 -0
- package/src/components/HexbinChart.tsx +152 -0
- package/src/components/Histogram.tsx +121 -0
- package/src/components/HorizonChart.tsx +110 -0
- package/src/components/HoverCard.tsx +52 -0
- package/src/components/IPInput.tsx +147 -0
- package/src/components/IcicleChart.tsx +118 -0
- package/src/components/InlineEdit.tsx +119 -0
- package/src/components/Input.tsx +94 -0
- package/src/components/InputGroup.tsx +21 -0
- package/src/components/JsonViewer.tsx +116 -0
- package/src/components/KVTable.tsx +29 -0
- package/src/components/KanbanBoard.tsx +171 -0
- package/src/components/Kbd.tsx +22 -0
- package/src/components/LineChart.tsx +112 -0
- package/src/components/LogViewer.tsx +102 -0
- package/src/components/LollipopChart.tsx +110 -0
- package/src/components/MACInput.tsx +164 -0
- package/src/components/MarimekkoChart.tsx +143 -0
- package/src/components/MatrixChart.tsx +118 -0
- package/src/components/Menubar.tsx +194 -0
- package/src/components/MirroredBarChart.tsx +148 -0
- package/src/components/Modal.tsx +87 -0
- package/src/components/MultiSelect.tsx +232 -0
- package/src/components/NightingaleChart.tsx +101 -0
- package/src/components/NotificationPanel.tsx +187 -0
- package/src/components/NumberInput.tsx +129 -0
- package/src/components/OTPInput.tsx +135 -0
- package/src/components/Pagination.tsx +73 -0
- package/src/components/ParallelCoordinates.tsx +149 -0
- package/src/components/ParetoChart.tsx +159 -0
- package/src/components/PasswordInput.tsx +112 -0
- package/src/components/PieChart.tsx +109 -0
- package/src/components/Pill.tsx +26 -0
- package/src/components/PolarHeatmap.tsx +118 -0
- package/src/components/Popover.tsx +103 -0
- package/src/components/ProgressBar.tsx +33 -0
- package/src/components/PunchCard.tsx +93 -0
- package/src/components/QuadrantChart.tsx +256 -0
- package/src/components/RadarChart.tsx +119 -0
- package/src/components/RadialBarChart.tsx +103 -0
- package/src/components/RadioGroup.tsx +69 -0
- package/src/components/RangeSlider.tsx +116 -0
- package/src/components/RankedList.tsx +61 -0
- package/src/components/ResizablePanel.tsx +122 -0
- package/src/components/RidgelinePlot.tsx +155 -0
- package/src/components/SankeyDiagram.tsx +156 -0
- package/src/components/ScatterPlot.tsx +96 -0
- package/src/components/SegmentedControl.tsx +78 -0
- package/src/components/Select.tsx +158 -0
- package/src/components/SelectionToolbar.tsx +55 -0
- package/src/components/Signal.tsx +19 -0
- package/src/components/Skeleton.tsx +37 -0
- package/src/components/SkipLink.tsx +19 -0
- package/src/components/Slider.tsx +74 -0
- package/src/components/SlopeChart.tsx +205 -0
- package/src/components/SortableList.tsx +130 -0
- package/src/components/SortableTable.tsx +46 -0
- package/src/components/Sparkline.tsx +50 -0
- package/src/components/SparklineMatrix.tsx +117 -0
- package/src/components/Spinner.tsx +8 -0
- package/src/components/SplitButton.tsx +151 -0
- package/src/components/Spoiler.tsx +47 -0
- package/src/components/StackedBarChart.tsx +145 -0
- package/src/components/StackedProgress.tsx +47 -0
- package/src/components/StarRating.tsx +103 -0
- package/src/components/Stat.tsx +48 -0
- package/src/components/StatusIndicator.tsx +26 -0
- package/src/components/StepChart.tsx +130 -0
- package/src/components/Stepper.tsx +51 -0
- package/src/components/StreamGraph.tsx +97 -0
- package/src/components/StripeChart.tsx +76 -0
- package/src/components/SunburstChart.tsx +142 -0
- package/src/components/SwitchPortGrid.tsx +59 -0
- package/src/components/Tabs.tsx +91 -0
- package/src/components/Tag.tsx +37 -0
- package/src/components/TagInput.tsx +99 -0
- package/src/components/Textarea.tsx +9 -0
- package/src/components/ThresholdAreaChart.tsx +113 -0
- package/src/components/TimePicker.tsx +144 -0
- package/src/components/TimeRange.tsx +152 -0
- package/src/components/Timeline.tsx +31 -0
- package/src/components/Toast.tsx +72 -0
- package/src/components/Toggle.tsx +68 -0
- package/src/components/ToggleGroup.tsx +57 -0
- package/src/components/Tooltip.tsx +25 -0
- package/src/components/TransferList.tsx +227 -0
- package/src/components/TreeMap.tsx +126 -0
- package/src/components/TreeView.tsx +221 -0
- package/src/components/UptimeTimeline.tsx +138 -0
- package/src/components/VennDiagram.tsx +151 -0
- package/src/components/ViolinPlot.tsx +158 -0
- package/src/components/VirtualList.tsx +59 -0
- package/src/components/WaffleChart.tsx +79 -0
- package/src/components/WaterfallChart.tsx +164 -0
- package/src/components/WordCloud.tsx +107 -0
- package/src/icons.tsx +241 -0
- package/src/index.ts +160 -0
- package/src/styles.css +2 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface DualAxisSeries {
|
|
4
|
+
label: string
|
|
5
|
+
color: string
|
|
6
|
+
values: number[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface DualAxisChartProps {
|
|
10
|
+
bars: DualAxisSeries
|
|
11
|
+
line: DualAxisSeries
|
|
12
|
+
labels?: string[]
|
|
13
|
+
height?: number
|
|
14
|
+
ariaLabel?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const VW = 400
|
|
18
|
+
const PAD = { t: 16, r: 44, b: 28, l: 44 }
|
|
19
|
+
|
|
20
|
+
function fmtTick(n: number): string {
|
|
21
|
+
if (n >= 1000) return `${(n / 1000).toFixed(0)}k`
|
|
22
|
+
return String(Math.round(n))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function DualAxisChart({
|
|
26
|
+
bars,
|
|
27
|
+
line,
|
|
28
|
+
labels = [],
|
|
29
|
+
height = 160,
|
|
30
|
+
ariaLabel = 'Dual axis chart',
|
|
31
|
+
}: DualAxisChartProps) {
|
|
32
|
+
const { barRects, linePath, lineArea, gridLines, leftTicks, rightTicks } = useMemo(() => {
|
|
33
|
+
const n = bars.values.length
|
|
34
|
+
if (!n)
|
|
35
|
+
return {
|
|
36
|
+
barRects: [],
|
|
37
|
+
linePath: '',
|
|
38
|
+
lineArea: '',
|
|
39
|
+
gridLines: [],
|
|
40
|
+
leftTicks: [],
|
|
41
|
+
rightTicks: [],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const chartW = VW - PAD.l - PAD.r
|
|
45
|
+
const chartH = height - PAD.t - PAD.b
|
|
46
|
+
const bot = PAD.t + chartH
|
|
47
|
+
const gw = chartW / n
|
|
48
|
+
const barW = gw * 0.55
|
|
49
|
+
|
|
50
|
+
const barMax = Math.max(...bars.values, 1)
|
|
51
|
+
const lineMin = Math.min(...line.values)
|
|
52
|
+
const lineMax = Math.max(...line.values, lineMin + 1)
|
|
53
|
+
const lineRange = lineMax - lineMin || 1
|
|
54
|
+
|
|
55
|
+
const toBarH = (v: number) => (v / barMax) * chartH
|
|
56
|
+
const toLineX = (i: number) => PAD.l + (n > 1 ? (i / (n - 1)) * chartW : chartW / 2)
|
|
57
|
+
const toLineY = (v: number) => PAD.t + chartH - ((v - lineMin) / lineRange) * chartH
|
|
58
|
+
|
|
59
|
+
const gridLines = [0, 0.25, 0.5, 0.75, 1].map((f) => PAD.t + (1 - f) * chartH)
|
|
60
|
+
|
|
61
|
+
const barRects = bars.values.map((v, i) => ({
|
|
62
|
+
key: i,
|
|
63
|
+
x: (PAD.l + i * gw + gw / 2 - barW / 2).toFixed(1),
|
|
64
|
+
y: (bot - toBarH(v)).toFixed(1),
|
|
65
|
+
w: barW.toFixed(1),
|
|
66
|
+
h: toBarH(v).toFixed(1),
|
|
67
|
+
}))
|
|
68
|
+
|
|
69
|
+
const linePts = line.values.map((v, i) => `${toLineX(i).toFixed(1)},${toLineY(v).toFixed(1)}`)
|
|
70
|
+
const linePath = `M ${linePts.join(' L ')}`
|
|
71
|
+
const lineArea = `M ${toLineX(0).toFixed(1)},${bot} L ${linePts.join(' L ')} L ${toLineX(n - 1).toFixed(1)},${bot} Z`
|
|
72
|
+
|
|
73
|
+
const leftTicks = [0, 0.5, 1].map((f) => ({
|
|
74
|
+
y: PAD.t + (1 - f) * chartH,
|
|
75
|
+
label: fmtTick(barMax * f),
|
|
76
|
+
}))
|
|
77
|
+
const rightTicks = [0, 0.5, 1].map((f) => ({
|
|
78
|
+
y: PAD.t + (1 - f) * chartH,
|
|
79
|
+
label: fmtTick(lineMin + lineRange * f),
|
|
80
|
+
}))
|
|
81
|
+
|
|
82
|
+
return { barRects, linePath, lineArea, gridLines, leftTicks, rightTicks }
|
|
83
|
+
}, [bars, line, height])
|
|
84
|
+
|
|
85
|
+
const chartW = VW - PAD.l - PAD.r
|
|
86
|
+
const n = bars.values.length
|
|
87
|
+
const gw = n > 0 ? chartW / n : chartW
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
|
|
91
|
+
<svg
|
|
92
|
+
viewBox={`0 0 ${VW} ${height}`}
|
|
93
|
+
style={{ width: '100%', height: 'auto', display: 'block' }}
|
|
94
|
+
aria-hidden="true"
|
|
95
|
+
focusable="false"
|
|
96
|
+
>
|
|
97
|
+
{gridLines.map((y, i) => (
|
|
98
|
+
<line
|
|
99
|
+
key={i}
|
|
100
|
+
x1={PAD.l}
|
|
101
|
+
y1={y}
|
|
102
|
+
x2={VW - PAD.r}
|
|
103
|
+
y2={y}
|
|
104
|
+
stroke="rgba(255,255,255,0.06)"
|
|
105
|
+
strokeWidth={1}
|
|
106
|
+
/>
|
|
107
|
+
))}
|
|
108
|
+
{leftTicks.map((t, i) => (
|
|
109
|
+
<text
|
|
110
|
+
key={i}
|
|
111
|
+
x={PAD.l - 4}
|
|
112
|
+
y={t.y + 4}
|
|
113
|
+
textAnchor="end"
|
|
114
|
+
fill={bars.color}
|
|
115
|
+
fontSize={9}
|
|
116
|
+
fontFamily="inherit"
|
|
117
|
+
fillOpacity={0.8}
|
|
118
|
+
>
|
|
119
|
+
{t.label}
|
|
120
|
+
</text>
|
|
121
|
+
))}
|
|
122
|
+
{rightTicks.map((t, i) => (
|
|
123
|
+
<text
|
|
124
|
+
key={i}
|
|
125
|
+
x={VW - PAD.r + 4}
|
|
126
|
+
y={t.y + 4}
|
|
127
|
+
textAnchor="start"
|
|
128
|
+
fill={line.color}
|
|
129
|
+
fontSize={9}
|
|
130
|
+
fontFamily="inherit"
|
|
131
|
+
fillOpacity={0.8}
|
|
132
|
+
>
|
|
133
|
+
{t.label}
|
|
134
|
+
</text>
|
|
135
|
+
))}
|
|
136
|
+
{barRects.map((b) => (
|
|
137
|
+
<rect
|
|
138
|
+
key={b.key}
|
|
139
|
+
x={b.x}
|
|
140
|
+
y={b.y}
|
|
141
|
+
width={b.w}
|
|
142
|
+
height={b.h}
|
|
143
|
+
fill={bars.color}
|
|
144
|
+
fillOpacity={0.75}
|
|
145
|
+
rx={2}
|
|
146
|
+
/>
|
|
147
|
+
))}
|
|
148
|
+
<path d={lineArea} fill={line.color} fillOpacity={0.12} />
|
|
149
|
+
<path
|
|
150
|
+
d={linePath}
|
|
151
|
+
fill="none"
|
|
152
|
+
stroke={line.color}
|
|
153
|
+
strokeWidth={2}
|
|
154
|
+
strokeLinejoin="round"
|
|
155
|
+
strokeLinecap="round"
|
|
156
|
+
/>
|
|
157
|
+
{labels.map((lbl, i) => (
|
|
158
|
+
<text
|
|
159
|
+
key={i}
|
|
160
|
+
x={(PAD.l + (i + 0.5) * gw).toFixed(1)}
|
|
161
|
+
y={height - 4}
|
|
162
|
+
textAnchor="middle"
|
|
163
|
+
fill="#6E7079"
|
|
164
|
+
fontSize={9}
|
|
165
|
+
fontFamily="inherit"
|
|
166
|
+
>
|
|
167
|
+
{lbl}
|
|
168
|
+
</text>
|
|
169
|
+
))}
|
|
170
|
+
</svg>
|
|
171
|
+
<div
|
|
172
|
+
style={{
|
|
173
|
+
display: 'flex',
|
|
174
|
+
gap: 16,
|
|
175
|
+
justifyContent: 'center',
|
|
176
|
+
fontSize: 11,
|
|
177
|
+
color: '#A4A7B5',
|
|
178
|
+
marginTop: 4,
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
182
|
+
<span
|
|
183
|
+
style={{
|
|
184
|
+
display: 'inline-block',
|
|
185
|
+
width: 10,
|
|
186
|
+
height: 10,
|
|
187
|
+
background: bars.color,
|
|
188
|
+
borderRadius: 2,
|
|
189
|
+
opacity: 0.75,
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
{bars.label}
|
|
193
|
+
</span>
|
|
194
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
195
|
+
<span
|
|
196
|
+
style={{
|
|
197
|
+
display: 'inline-block',
|
|
198
|
+
width: 16,
|
|
199
|
+
height: 2,
|
|
200
|
+
background: line.color,
|
|
201
|
+
borderRadius: 1,
|
|
202
|
+
}}
|
|
203
|
+
/>
|
|
204
|
+
{line.label}
|
|
205
|
+
</span>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export interface DumbbellItem {
|
|
2
|
+
label: string
|
|
3
|
+
start: number
|
|
4
|
+
end: number
|
|
5
|
+
color?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface DumbbellChartProps {
|
|
9
|
+
items: DumbbellItem[]
|
|
10
|
+
unit?: string
|
|
11
|
+
color?: string
|
|
12
|
+
ariaLabel?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const VW = 340
|
|
16
|
+
const LABEL_W = 110
|
|
17
|
+
const PAD_R = 8
|
|
18
|
+
const TRACK_W = VW - LABEL_W - PAD_R
|
|
19
|
+
const DOT_R = 5
|
|
20
|
+
const ROW_H = 26
|
|
21
|
+
const PAD_T = 6
|
|
22
|
+
const AXIS_H = 18
|
|
23
|
+
const TICKS = 4
|
|
24
|
+
|
|
25
|
+
export function DumbbellChart({
|
|
26
|
+
items,
|
|
27
|
+
unit = '',
|
|
28
|
+
color = '#006FFF',
|
|
29
|
+
ariaLabel = 'Dumbbell chart',
|
|
30
|
+
}: DumbbellChartProps) {
|
|
31
|
+
const allValues = items.flatMap((it) => [it.start, it.end])
|
|
32
|
+
const minVal = allValues.length ? Math.min(...allValues) : 0
|
|
33
|
+
const maxVal = allValues.length ? Math.max(...allValues) : 1
|
|
34
|
+
const range = maxVal - minVal || 1
|
|
35
|
+
const svgH = PAD_T + items.length * ROW_H + AXIS_H
|
|
36
|
+
|
|
37
|
+
const tx = (v: number) => LABEL_W + ((v - minVal) / range) * TRACK_W
|
|
38
|
+
|
|
39
|
+
const ticks = Array.from({ length: TICKS + 1 }, (_, i) =>
|
|
40
|
+
Math.round(minVal + (i / TICKS) * range),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
|
|
45
|
+
<svg
|
|
46
|
+
viewBox={`0 0 ${VW} ${svgH}`}
|
|
47
|
+
style={{ width: '100%', height: 'auto', display: 'block' }}
|
|
48
|
+
aria-hidden="true"
|
|
49
|
+
focusable="false"
|
|
50
|
+
>
|
|
51
|
+
{ticks.map((t, i) => (
|
|
52
|
+
<g key={i}>
|
|
53
|
+
<line
|
|
54
|
+
x1={tx(t)}
|
|
55
|
+
y1={PAD_T}
|
|
56
|
+
x2={tx(t)}
|
|
57
|
+
y2={PAD_T + items.length * ROW_H}
|
|
58
|
+
stroke="rgba(255,255,255,0.06)"
|
|
59
|
+
strokeWidth={1}
|
|
60
|
+
/>
|
|
61
|
+
<text
|
|
62
|
+
x={tx(t)}
|
|
63
|
+
y={PAD_T + items.length * ROW_H + 13}
|
|
64
|
+
fill="#6E7079"
|
|
65
|
+
fontSize={9}
|
|
66
|
+
textAnchor="middle"
|
|
67
|
+
fontFamily="inherit"
|
|
68
|
+
>
|
|
69
|
+
{t}
|
|
70
|
+
{unit}
|
|
71
|
+
</text>
|
|
72
|
+
</g>
|
|
73
|
+
))}
|
|
74
|
+
|
|
75
|
+
{items.map((item, i) => {
|
|
76
|
+
const cy = PAD_T + i * ROW_H + ROW_H / 2
|
|
77
|
+
const cx1 = tx(item.start)
|
|
78
|
+
const cx2 = tx(item.end)
|
|
79
|
+
const c = item.color ?? color
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<g key={i}>
|
|
83
|
+
<text
|
|
84
|
+
x={LABEL_W - 6}
|
|
85
|
+
y={cy + 4}
|
|
86
|
+
fill="#A4A7B5"
|
|
87
|
+
fontSize={10}
|
|
88
|
+
textAnchor="end"
|
|
89
|
+
fontFamily="inherit"
|
|
90
|
+
>
|
|
91
|
+
{item.label}
|
|
92
|
+
</text>
|
|
93
|
+
<line x1={cx1} y1={cy} x2={cx2} y2={cy} stroke={c} strokeWidth={2} opacity={0.4} />
|
|
94
|
+
<circle cx={cx1} cy={cy} r={DOT_R} fill="#13131A" stroke={c} strokeWidth={1.5} />
|
|
95
|
+
<circle cx={cx2} cy={cy} r={DOT_R} fill={c} />
|
|
96
|
+
</g>
|
|
97
|
+
)
|
|
98
|
+
})}
|
|
99
|
+
</svg>
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useId, useRef, useState } from 'react'
|
|
2
|
+
import type { KeyboardEvent, ChangeEvent } from 'react'
|
|
3
|
+
|
|
4
|
+
export interface DurationInputProps {
|
|
5
|
+
label?: string
|
|
6
|
+
value?: number
|
|
7
|
+
defaultValue?: number
|
|
8
|
+
maxHours?: number
|
|
9
|
+
disabled?: boolean
|
|
10
|
+
onChange?: (seconds: number) => void
|
|
11
|
+
id?: string
|
|
12
|
+
className?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function pad(n: number): string {
|
|
16
|
+
return String(n).padStart(2, '0')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toHMS(secs: number): [number, number, number] {
|
|
20
|
+
const t = Math.max(0, secs)
|
|
21
|
+
return [Math.floor(t / 3600), Math.floor((t % 3600) / 60), t % 60]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fromHMS(h: number, m: number, s: number): number {
|
|
25
|
+
return h * 3600 + m * 60 + s
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function DurationInput({
|
|
29
|
+
label,
|
|
30
|
+
value: valueProp,
|
|
31
|
+
defaultValue = 0,
|
|
32
|
+
maxHours = 99,
|
|
33
|
+
disabled = false,
|
|
34
|
+
onChange,
|
|
35
|
+
id,
|
|
36
|
+
className = '',
|
|
37
|
+
}: DurationInputProps) {
|
|
38
|
+
const gid = useId()
|
|
39
|
+
const inputId = id ?? gid
|
|
40
|
+
const [internal, setInternal] = useState<number>(valueProp ?? defaultValue)
|
|
41
|
+
const refs = useRef<[HTMLInputElement | null, HTMLInputElement | null, HTMLInputElement | null]>([
|
|
42
|
+
null,
|
|
43
|
+
null,
|
|
44
|
+
null,
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
const [hours, minutes, seconds] = toHMS(valueProp !== undefined ? valueProp : internal)
|
|
48
|
+
|
|
49
|
+
function commit(h: number, m: number, s: number) {
|
|
50
|
+
const total = fromHMS(Math.min(maxHours, h), m, s)
|
|
51
|
+
if (valueProp === undefined) setInternal(total)
|
|
52
|
+
onChange?.(total)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function handleKey(field: 0 | 1 | 2, e: KeyboardEvent<HTMLInputElement>) {
|
|
56
|
+
const vals: [number, number, number] = [hours, minutes, seconds]
|
|
57
|
+
const maxes = [maxHours, 59, 59]
|
|
58
|
+
if (e.key === 'ArrowUp') {
|
|
59
|
+
e.preventDefault()
|
|
60
|
+
const next = vals[field] >= maxes[field] ? 0 : vals[field] + 1
|
|
61
|
+
vals[field] = next
|
|
62
|
+
commit(...vals)
|
|
63
|
+
} else if (e.key === 'ArrowDown') {
|
|
64
|
+
e.preventDefault()
|
|
65
|
+
const next = vals[field] <= 0 ? maxes[field] : vals[field] - 1
|
|
66
|
+
vals[field] = next
|
|
67
|
+
commit(...vals)
|
|
68
|
+
} else if ((e.key === ':' || e.key === 'ArrowRight') && field < 2) {
|
|
69
|
+
e.preventDefault()
|
|
70
|
+
refs.current[field + 1]?.focus()
|
|
71
|
+
} else if (e.key === 'ArrowLeft' && field > 0) {
|
|
72
|
+
e.preventDefault()
|
|
73
|
+
refs.current[field - 1]?.focus()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function handleChange(field: 0 | 1 | 2, e: ChangeEvent<HTMLInputElement>) {
|
|
78
|
+
const raw = e.target.value
|
|
79
|
+
if (!/^\d{0,2}$/.test(raw)) return
|
|
80
|
+
const n = parseInt(raw, 10)
|
|
81
|
+
if (isNaN(n)) return
|
|
82
|
+
const maxes = [maxHours, 59, 59]
|
|
83
|
+
const updated: [number, number, number] = [hours, minutes, seconds]
|
|
84
|
+
updated[field] = Math.min(maxes[field], n)
|
|
85
|
+
commit(...updated)
|
|
86
|
+
if (raw.length === 2 && field < 2) refs.current[field + 1]?.focus()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const FIELDS = [
|
|
90
|
+
{ idx: 0 as const, val: hours, ariaLabel: 'Hours', ariaMax: maxHours },
|
|
91
|
+
{ idx: 1 as const, val: minutes, ariaLabel: 'Minutes', ariaMax: 59 },
|
|
92
|
+
{ idx: 2 as const, val: seconds, ariaLabel: 'Seconds', ariaMax: 59 },
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className={`dur-input-wrapper${className ? ` ${className}` : ''}`}>
|
|
97
|
+
{label && (
|
|
98
|
+
<label htmlFor={`${inputId}-h`} className="dur-input__label">
|
|
99
|
+
{label}
|
|
100
|
+
</label>
|
|
101
|
+
)}
|
|
102
|
+
<div
|
|
103
|
+
className={`dur-input${disabled ? ' dur-input--disabled' : ''}`}
|
|
104
|
+
role="group"
|
|
105
|
+
aria-label={label ?? 'Duration'}
|
|
106
|
+
>
|
|
107
|
+
{FIELDS.flatMap(({ idx, val, ariaLabel, ariaMax }, i) => {
|
|
108
|
+
const nodes = [
|
|
109
|
+
<input
|
|
110
|
+
key={`f${idx}`}
|
|
111
|
+
ref={(el) => {
|
|
112
|
+
refs.current[idx] = el
|
|
113
|
+
}}
|
|
114
|
+
id={idx === 0 ? `${inputId}-h` : undefined}
|
|
115
|
+
type="text"
|
|
116
|
+
inputMode="numeric"
|
|
117
|
+
pattern="[0-9]*"
|
|
118
|
+
role="spinbutton"
|
|
119
|
+
aria-label={ariaLabel}
|
|
120
|
+
aria-valuenow={val}
|
|
121
|
+
aria-valuemin={0}
|
|
122
|
+
aria-valuemax={ariaMax}
|
|
123
|
+
aria-valuetext={pad(val)}
|
|
124
|
+
value={pad(val)}
|
|
125
|
+
disabled={disabled}
|
|
126
|
+
maxLength={2}
|
|
127
|
+
className="dur-input__field"
|
|
128
|
+
onChange={(e) => handleChange(idx, e)}
|
|
129
|
+
onKeyDown={(e) => handleKey(idx, e)}
|
|
130
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
131
|
+
/>,
|
|
132
|
+
]
|
|
133
|
+
if (i < 2)
|
|
134
|
+
nodes.push(
|
|
135
|
+
<span key={`c${idx}`} className="dur-input__sep" aria-hidden="true">
|
|
136
|
+
:
|
|
137
|
+
</span>,
|
|
138
|
+
)
|
|
139
|
+
return nodes
|
|
140
|
+
})}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface EmptyStateProps {
|
|
4
|
+
title: string
|
|
5
|
+
description?: string
|
|
6
|
+
action?: ReactNode
|
|
7
|
+
className?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function EmptyState({ title, description, action, className = '' }: EmptyStateProps) {
|
|
11
|
+
return (
|
|
12
|
+
<div role="status" aria-live="polite" className={`empty-state ${className}`.trim()}>
|
|
13
|
+
<svg
|
|
14
|
+
className="empty-state__icon"
|
|
15
|
+
viewBox="0 0 48 48"
|
|
16
|
+
fill="none"
|
|
17
|
+
aria-hidden="true"
|
|
18
|
+
focusable={false}
|
|
19
|
+
>
|
|
20
|
+
<rect x="6" y="11" width="36" height="28" rx="4" stroke="currentColor" strokeWidth="1.5" />
|
|
21
|
+
<path
|
|
22
|
+
d="M6 30h10l3 5h10l3-5h10"
|
|
23
|
+
stroke="currentColor"
|
|
24
|
+
strokeWidth="1.5"
|
|
25
|
+
strokeLinejoin="round"
|
|
26
|
+
/>
|
|
27
|
+
<path
|
|
28
|
+
d="M15 20h18M15 25h12"
|
|
29
|
+
stroke="currentColor"
|
|
30
|
+
strokeWidth="1.5"
|
|
31
|
+
strokeLinecap="round"
|
|
32
|
+
opacity=".4"
|
|
33
|
+
/>
|
|
34
|
+
</svg>
|
|
35
|
+
<p className="empty-state__title">{title}</p>
|
|
36
|
+
{description && <p className="empty-state__desc">{description}</p>}
|
|
37
|
+
{action && <div className="empty-state__action">{action}</div>}
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
export interface ErrorBandSeries {
|
|
2
|
+
label: string
|
|
3
|
+
color: string
|
|
4
|
+
mean: number[]
|
|
5
|
+
lower: number[]
|
|
6
|
+
upper: number[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ErrorBandChartProps {
|
|
10
|
+
series: ErrorBandSeries[]
|
|
11
|
+
xLabels?: string[]
|
|
12
|
+
yRange?: [number, number]
|
|
13
|
+
height?: number
|
|
14
|
+
unit?: string
|
|
15
|
+
ariaLabel?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const VW = 340
|
|
19
|
+
const PAD_L = 28
|
|
20
|
+
const PAD_R = 8
|
|
21
|
+
const PAD_T = 8
|
|
22
|
+
const PAD_B = 20
|
|
23
|
+
const PLOT_W = VW - PAD_L - PAD_R
|
|
24
|
+
|
|
25
|
+
export function ErrorBandChart({
|
|
26
|
+
series,
|
|
27
|
+
xLabels = [],
|
|
28
|
+
yRange,
|
|
29
|
+
height = 180,
|
|
30
|
+
unit = '',
|
|
31
|
+
ariaLabel = 'Error band chart',
|
|
32
|
+
}: ErrorBandChartProps) {
|
|
33
|
+
if (!series.length || !series[0].mean.length) return null
|
|
34
|
+
|
|
35
|
+
const n = series[0].mean.length
|
|
36
|
+
const PLOT_H = height - PAD_T - PAD_B
|
|
37
|
+
|
|
38
|
+
const allVals = series.flatMap((s) => [...s.lower, ...s.upper])
|
|
39
|
+
const minV = yRange ? yRange[0] : Math.min(...allVals)
|
|
40
|
+
const maxV = yRange ? yRange[1] : Math.max(...allVals)
|
|
41
|
+
const range = maxV - minV || 1
|
|
42
|
+
|
|
43
|
+
const tx = (i: number) => PAD_L + (i / (n - 1 || 1)) * PLOT_W
|
|
44
|
+
const ty = (v: number) => PAD_T + (1 - (v - minV) / range) * PLOT_H
|
|
45
|
+
|
|
46
|
+
const bandPath = (lower: number[], upper: number[]) => {
|
|
47
|
+
const top = upper
|
|
48
|
+
.map((v, i) => `${i === 0 ? 'M' : 'L'}${tx(i).toFixed(1)},${ty(v).toFixed(1)}`)
|
|
49
|
+
.join(' ')
|
|
50
|
+
const bot = [...lower]
|
|
51
|
+
.reverse()
|
|
52
|
+
.map((v, i) => `L${tx(n - 1 - i).toFixed(1)},${ty(v).toFixed(1)}`)
|
|
53
|
+
.join(' ')
|
|
54
|
+
return `${top} ${bot} Z`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const linePath = (mean: number[]) =>
|
|
58
|
+
mean.map((v, i) => `${i === 0 ? 'M' : 'L'}${tx(i).toFixed(1)},${ty(v).toFixed(1)}`).join(' ')
|
|
59
|
+
|
|
60
|
+
const yTicks = [minV, (minV + maxV) / 2, maxV]
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
|
|
64
|
+
<svg
|
|
65
|
+
viewBox={`0 0 ${VW} ${height}`}
|
|
66
|
+
style={{ width: '100%', height: 'auto', display: 'block' }}
|
|
67
|
+
aria-hidden="true"
|
|
68
|
+
focusable="false"
|
|
69
|
+
>
|
|
70
|
+
{yTicks.map((v, i) => (
|
|
71
|
+
<g key={i}>
|
|
72
|
+
<line
|
|
73
|
+
x1={PAD_L}
|
|
74
|
+
y1={ty(v)}
|
|
75
|
+
x2={PAD_L + PLOT_W}
|
|
76
|
+
y2={ty(v)}
|
|
77
|
+
stroke="rgba(255,255,255,0.06)"
|
|
78
|
+
strokeWidth={1}
|
|
79
|
+
/>
|
|
80
|
+
<text
|
|
81
|
+
x={PAD_L - 4}
|
|
82
|
+
y={ty(v) + 4}
|
|
83
|
+
fill="#6E7079"
|
|
84
|
+
fontSize={8}
|
|
85
|
+
textAnchor="end"
|
|
86
|
+
fontFamily="inherit"
|
|
87
|
+
>
|
|
88
|
+
{Math.round(v)}
|
|
89
|
+
{unit}
|
|
90
|
+
</text>
|
|
91
|
+
</g>
|
|
92
|
+
))}
|
|
93
|
+
|
|
94
|
+
{series.map((s, si) => (
|
|
95
|
+
<g key={si}>
|
|
96
|
+
<path d={bandPath(s.lower, s.upper)} fill={s.color} opacity={0.12} />
|
|
97
|
+
<path
|
|
98
|
+
d={linePath(s.mean)}
|
|
99
|
+
fill="none"
|
|
100
|
+
stroke={s.color}
|
|
101
|
+
strokeWidth={1.5}
|
|
102
|
+
strokeLinejoin="round"
|
|
103
|
+
strokeLinecap="round"
|
|
104
|
+
/>
|
|
105
|
+
</g>
|
|
106
|
+
))}
|
|
107
|
+
|
|
108
|
+
{xLabels.map((lbl, i) => (
|
|
109
|
+
<text
|
|
110
|
+
key={i}
|
|
111
|
+
x={PAD_L + (i / (xLabels.length - 1 || 1)) * PLOT_W}
|
|
112
|
+
y={height - 4}
|
|
113
|
+
fill="#6E7079"
|
|
114
|
+
fontSize={9}
|
|
115
|
+
textAnchor="middle"
|
|
116
|
+
fontFamily="inherit"
|
|
117
|
+
>
|
|
118
|
+
{lbl}
|
|
119
|
+
</text>
|
|
120
|
+
))}
|
|
121
|
+
</svg>
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useId, useState, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface ExpandableRowProps {
|
|
4
|
+
row: ReactNode
|
|
5
|
+
detail: ReactNode
|
|
6
|
+
colSpan: number
|
|
7
|
+
defaultExpanded?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ExpandableRow({
|
|
11
|
+
row,
|
|
12
|
+
detail,
|
|
13
|
+
colSpan,
|
|
14
|
+
defaultExpanded = false,
|
|
15
|
+
}: ExpandableRowProps) {
|
|
16
|
+
const [open, setOpen] = useState(defaultExpanded)
|
|
17
|
+
const detailId = useId()
|
|
18
|
+
return (
|
|
19
|
+
<tbody className="exp-tbody">
|
|
20
|
+
<tr className="exp-row">
|
|
21
|
+
<td className="exp-toggle-cell">
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
className="exp-toggle"
|
|
25
|
+
aria-expanded={open}
|
|
26
|
+
aria-controls={detailId}
|
|
27
|
+
onClick={() => setOpen((o) => !o)}
|
|
28
|
+
>
|
|
29
|
+
<svg
|
|
30
|
+
className={`exp-chevron${open ? ' exp-chevron--open' : ''}`}
|
|
31
|
+
viewBox="0 0 10 6"
|
|
32
|
+
fill="none"
|
|
33
|
+
aria-hidden="true"
|
|
34
|
+
>
|
|
35
|
+
<path
|
|
36
|
+
d="M1 1l4 4 4-4"
|
|
37
|
+
stroke="currentColor"
|
|
38
|
+
strokeWidth="1.5"
|
|
39
|
+
strokeLinecap="round"
|
|
40
|
+
strokeLinejoin="round"
|
|
41
|
+
/>
|
|
42
|
+
</svg>
|
|
43
|
+
</button>
|
|
44
|
+
</td>
|
|
45
|
+
{row}
|
|
46
|
+
</tr>
|
|
47
|
+
<tr
|
|
48
|
+
id={detailId}
|
|
49
|
+
className={`exp-detail${open ? ' exp-detail--open' : ''}`}
|
|
50
|
+
aria-hidden={!open}
|
|
51
|
+
>
|
|
52
|
+
<td colSpan={colSpan + 1} className="exp-detail-cell">
|
|
53
|
+
{detail}
|
|
54
|
+
</td>
|
|
55
|
+
</tr>
|
|
56
|
+
</tbody>
|
|
57
|
+
)
|
|
58
|
+
}
|