@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,116 @@
|
|
|
1
|
+
import { useState, useId } from 'react'
|
|
2
|
+
import type { ChangeEvent, CSSProperties } from 'react'
|
|
3
|
+
|
|
4
|
+
export interface RangeSliderProps {
|
|
5
|
+
label?: string
|
|
6
|
+
min?: number
|
|
7
|
+
max?: number
|
|
8
|
+
step?: number
|
|
9
|
+
defaultLow?: number
|
|
10
|
+
defaultHigh?: number
|
|
11
|
+
low?: number
|
|
12
|
+
high?: number
|
|
13
|
+
suffix?: string
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
onChange?: (range: [number, number]) => void
|
|
16
|
+
className?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function RangeSlider({
|
|
20
|
+
label,
|
|
21
|
+
min = 0,
|
|
22
|
+
max = 100,
|
|
23
|
+
step = 1,
|
|
24
|
+
defaultLow,
|
|
25
|
+
defaultHigh,
|
|
26
|
+
low: lowProp,
|
|
27
|
+
high: highProp,
|
|
28
|
+
suffix,
|
|
29
|
+
disabled = false,
|
|
30
|
+
onChange,
|
|
31
|
+
className = '',
|
|
32
|
+
}: RangeSliderProps) {
|
|
33
|
+
const uid = useId()
|
|
34
|
+
const labelId = `${uid}-label`
|
|
35
|
+
const [internalLow, setInternalLow] = useState(defaultLow ?? min)
|
|
36
|
+
const [internalHigh, setInternalHigh] = useState(defaultHigh ?? max)
|
|
37
|
+
const low = lowProp !== undefined ? lowProp : internalLow
|
|
38
|
+
const high = highProp !== undefined ? highProp : internalHigh
|
|
39
|
+
|
|
40
|
+
function toPercent(v: number) {
|
|
41
|
+
return max === min ? 0 : ((v - min) / (max - min)) * 100
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lowPct = toPercent(low)
|
|
45
|
+
const highPct = toPercent(high)
|
|
46
|
+
const lowOnTop = low >= max - step
|
|
47
|
+
|
|
48
|
+
const fillStyle: CSSProperties = {
|
|
49
|
+
'--rs-low': `${lowPct}%`,
|
|
50
|
+
'--rs-high': `${highPct}%`,
|
|
51
|
+
} as CSSProperties
|
|
52
|
+
|
|
53
|
+
function handleLowChange(e: ChangeEvent<HTMLInputElement>) {
|
|
54
|
+
const next = Math.min(Number(e.target.value), high - step)
|
|
55
|
+
if (lowProp === undefined) setInternalLow(next)
|
|
56
|
+
onChange?.([next, high])
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function handleHighChange(e: ChangeEvent<HTMLInputElement>) {
|
|
60
|
+
const next = Math.max(Number(e.target.value), low + step)
|
|
61
|
+
if (highProp === undefined) setInternalHigh(next)
|
|
62
|
+
onChange?.([low, next])
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className={`range-slider ${className}`.trim()}>
|
|
67
|
+
{label && (
|
|
68
|
+
<div className="range-slider__header">
|
|
69
|
+
<span className="range-slider__label" id={labelId}>
|
|
70
|
+
{label}
|
|
71
|
+
</span>
|
|
72
|
+
<span className="range-slider__value" aria-live="polite">
|
|
73
|
+
{low}
|
|
74
|
+
{suffix} – {high}
|
|
75
|
+
{suffix}
|
|
76
|
+
</span>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
<div
|
|
80
|
+
className="range-slider__wrap"
|
|
81
|
+
style={fillStyle}
|
|
82
|
+
role="group"
|
|
83
|
+
aria-labelledby={label ? labelId : undefined}
|
|
84
|
+
>
|
|
85
|
+
<div className="range-slider__track-bg" />
|
|
86
|
+
<div className="range-slider__fill" />
|
|
87
|
+
<input
|
|
88
|
+
type="range"
|
|
89
|
+
className="range-slider__input"
|
|
90
|
+
min={min}
|
|
91
|
+
max={max}
|
|
92
|
+
step={step}
|
|
93
|
+
value={low}
|
|
94
|
+
disabled={disabled}
|
|
95
|
+
style={{ zIndex: lowOnTop ? 2 : 1 }}
|
|
96
|
+
aria-label={label ? `${label} minimum` : 'Minimum'}
|
|
97
|
+
aria-valuetext={suffix ? `${low}${suffix}` : String(low)}
|
|
98
|
+
onChange={handleLowChange}
|
|
99
|
+
/>
|
|
100
|
+
<input
|
|
101
|
+
type="range"
|
|
102
|
+
className="range-slider__input"
|
|
103
|
+
min={min}
|
|
104
|
+
max={max}
|
|
105
|
+
step={step}
|
|
106
|
+
value={high}
|
|
107
|
+
disabled={disabled}
|
|
108
|
+
style={{ zIndex: lowOnTop ? 1 : 2 }}
|
|
109
|
+
aria-label={label ? `${label} maximum` : 'Maximum'}
|
|
110
|
+
aria-valuetext={suffix ? `${high}${suffix}` : String(high)}
|
|
111
|
+
onChange={handleHighChange}
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface RankedItem {
|
|
2
|
+
label: string
|
|
3
|
+
value: number
|
|
4
|
+
sublabel?: string
|
|
5
|
+
color?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RankedListProps {
|
|
9
|
+
items: RankedItem[]
|
|
10
|
+
unit?: string
|
|
11
|
+
max?: number
|
|
12
|
+
ariaLabel?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function RankedList({
|
|
16
|
+
items,
|
|
17
|
+
unit,
|
|
18
|
+
max: maxProp,
|
|
19
|
+
ariaLabel = 'Ranked list',
|
|
20
|
+
}: RankedListProps) {
|
|
21
|
+
const max = maxProp ?? Math.max(...items.map((i) => i.value), 1)
|
|
22
|
+
return (
|
|
23
|
+
<table className="rl" aria-label={ariaLabel}>
|
|
24
|
+
<caption className="sr-only">{ariaLabel}</caption>
|
|
25
|
+
<thead className="sr-only">
|
|
26
|
+
<tr>
|
|
27
|
+
<th scope="col">#</th>
|
|
28
|
+
<th scope="col">Name</th>
|
|
29
|
+
<th scope="col">Distribution</th>
|
|
30
|
+
<th scope="col">{unit ?? 'Value'}</th>
|
|
31
|
+
</tr>
|
|
32
|
+
</thead>
|
|
33
|
+
<tbody>
|
|
34
|
+
{items.map((item, i) => (
|
|
35
|
+
<tr key={i} className="rl-row">
|
|
36
|
+
<td className="rl-rank">{i + 1}</td>
|
|
37
|
+
<td className="rl-label-cell">
|
|
38
|
+
<div className="rl-label">{item.label}</div>
|
|
39
|
+
{item.sublabel && <div className="rl-sublabel">{item.sublabel}</div>}
|
|
40
|
+
</td>
|
|
41
|
+
<td className="rl-bar-col" aria-hidden="true">
|
|
42
|
+
<div className="rl-bar-track">
|
|
43
|
+
<div
|
|
44
|
+
className="rl-bar-fill"
|
|
45
|
+
style={{
|
|
46
|
+
width: `${((item.value / max) * 100).toFixed(1)}%`,
|
|
47
|
+
background: item.color ?? '#006FFF',
|
|
48
|
+
}}
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
</td>
|
|
52
|
+
<td className="rl-val">
|
|
53
|
+
{item.value.toLocaleString()}
|
|
54
|
+
{unit && <span className="rl-unit"> {unit}</span>}
|
|
55
|
+
</td>
|
|
56
|
+
</tr>
|
|
57
|
+
))}
|
|
58
|
+
</tbody>
|
|
59
|
+
</table>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useRef, useState, useCallback, type ReactNode, type CSSProperties } from 'react'
|
|
2
|
+
|
|
3
|
+
export type ResizableOrientation = 'horizontal' | 'vertical'
|
|
4
|
+
|
|
5
|
+
export interface ResizablePanelProps {
|
|
6
|
+
defaultSize?: number
|
|
7
|
+
min?: number
|
|
8
|
+
max?: number
|
|
9
|
+
orientation?: ResizableOrientation
|
|
10
|
+
label?: string
|
|
11
|
+
className?: string
|
|
12
|
+
style?: CSSProperties
|
|
13
|
+
children: [ReactNode, ReactNode]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ResizablePanel({
|
|
17
|
+
defaultSize = 50,
|
|
18
|
+
min = 20,
|
|
19
|
+
max = 80,
|
|
20
|
+
orientation = 'vertical',
|
|
21
|
+
label = 'Resize panels',
|
|
22
|
+
className,
|
|
23
|
+
style,
|
|
24
|
+
children,
|
|
25
|
+
}: ResizablePanelProps) {
|
|
26
|
+
const [size, setSize] = useState(defaultSize)
|
|
27
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
28
|
+
const dragging = useRef(false)
|
|
29
|
+
|
|
30
|
+
const clamp = useCallback((v: number) => Math.min(max, Math.max(min, v)), [min, max])
|
|
31
|
+
|
|
32
|
+
const onMouseDown = useCallback(
|
|
33
|
+
(e: React.MouseEvent) => {
|
|
34
|
+
e.preventDefault()
|
|
35
|
+
dragging.current = true
|
|
36
|
+
|
|
37
|
+
const onMove = (me: MouseEvent) => {
|
|
38
|
+
if (!dragging.current || !containerRef.current) return
|
|
39
|
+
const rect = containerRef.current.getBoundingClientRect()
|
|
40
|
+
const pct =
|
|
41
|
+
orientation === 'vertical'
|
|
42
|
+
? ((me.clientX - rect.left) / rect.width) * 100
|
|
43
|
+
: ((me.clientY - rect.top) / rect.height) * 100
|
|
44
|
+
setSize(clamp(Math.round(pct)))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const onUp = () => {
|
|
48
|
+
dragging.current = false
|
|
49
|
+
window.removeEventListener('mousemove', onMove)
|
|
50
|
+
window.removeEventListener('mouseup', onUp)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
window.addEventListener('mousemove', onMove)
|
|
54
|
+
window.addEventListener('mouseup', onUp)
|
|
55
|
+
},
|
|
56
|
+
[orientation, clamp],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const onKeyDown = useCallback(
|
|
60
|
+
(e: React.KeyboardEvent) => {
|
|
61
|
+
const step = e.shiftKey ? 10 : 1
|
|
62
|
+
if (orientation === 'vertical') {
|
|
63
|
+
if (e.key === 'ArrowLeft') {
|
|
64
|
+
e.preventDefault()
|
|
65
|
+
setSize((s) => clamp(s - step))
|
|
66
|
+
}
|
|
67
|
+
if (e.key === 'ArrowRight') {
|
|
68
|
+
e.preventDefault()
|
|
69
|
+
setSize((s) => clamp(s + step))
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
if (e.key === 'ArrowUp') {
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
setSize((s) => clamp(s - step))
|
|
75
|
+
}
|
|
76
|
+
if (e.key === 'ArrowDown') {
|
|
77
|
+
e.preventDefault()
|
|
78
|
+
setSize((s) => clamp(s + step))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (e.key === 'Home') {
|
|
82
|
+
e.preventDefault()
|
|
83
|
+
setSize(min)
|
|
84
|
+
}
|
|
85
|
+
if (e.key === 'End') {
|
|
86
|
+
e.preventDefault()
|
|
87
|
+
setSize(max)
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
[orientation, min, max, clamp],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const firstStyle =
|
|
94
|
+
orientation === 'vertical'
|
|
95
|
+
? { flex: `0 0 ${size}%`, minWidth: 0 }
|
|
96
|
+
: { flex: `0 0 ${size}%`, minHeight: 0 }
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
ref={containerRef}
|
|
101
|
+
className={['rp', `rp--${orientation}`, className].filter(Boolean).join(' ')}
|
|
102
|
+
style={style}
|
|
103
|
+
>
|
|
104
|
+
<div className="rp-pane" style={firstStyle}>
|
|
105
|
+
{children[0]}
|
|
106
|
+
</div>
|
|
107
|
+
<div
|
|
108
|
+
role="separator"
|
|
109
|
+
tabIndex={0}
|
|
110
|
+
aria-orientation={orientation}
|
|
111
|
+
aria-valuenow={size}
|
|
112
|
+
aria-valuemin={min}
|
|
113
|
+
aria-valuemax={max}
|
|
114
|
+
aria-label={label}
|
|
115
|
+
className="rp-handle"
|
|
116
|
+
onMouseDown={onMouseDown}
|
|
117
|
+
onKeyDown={onKeyDown}
|
|
118
|
+
/>
|
|
119
|
+
<div className="rp-pane rp-pane--second">{children[1]}</div>
|
|
120
|
+
</div>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
export interface RidgelineSeries {
|
|
2
|
+
label: string
|
|
3
|
+
values: number[]
|
|
4
|
+
color?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface RidgelinePlotProps {
|
|
8
|
+
series: RidgelineSeries[]
|
|
9
|
+
xRange?: [number, number]
|
|
10
|
+
height?: number
|
|
11
|
+
ariaLabel?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const COLORS = ['#006FFF', '#00C8C8', '#F5A623', '#7FB6FF', '#A878F5', '#F56342']
|
|
15
|
+
const SQRT_TWO_PI = Math.sqrt(2 * Math.PI)
|
|
16
|
+
const NUM_PTS = 80
|
|
17
|
+
|
|
18
|
+
function bandwidth(vals: number[]): number {
|
|
19
|
+
const n = vals.length
|
|
20
|
+
const m = vals.reduce((a, b) => a + b, 0) / n
|
|
21
|
+
const s = Math.sqrt(vals.reduce((a, b) => a + (b - m) ** 2, 0) / n)
|
|
22
|
+
return Math.max(0.9 * s * Math.pow(n, -0.2), 0.5)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function kde(vals: number[], bw: number, pts: number[]): number[] {
|
|
26
|
+
const n = vals.length
|
|
27
|
+
const c = 1 / (n * bw * SQRT_TWO_PI)
|
|
28
|
+
return pts.map(
|
|
29
|
+
(x) =>
|
|
30
|
+
c *
|
|
31
|
+
vals.reduce((acc, v) => {
|
|
32
|
+
const z = (x - v) / bw
|
|
33
|
+
return acc + Math.exp(-0.5 * z * z)
|
|
34
|
+
}, 0),
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function RidgelinePlot({
|
|
39
|
+
series,
|
|
40
|
+
xRange,
|
|
41
|
+
height = 180,
|
|
42
|
+
ariaLabel = 'Ridgeline plot',
|
|
43
|
+
}: RidgelinePlotProps) {
|
|
44
|
+
const VW = 380
|
|
45
|
+
const VH = height
|
|
46
|
+
const PAD_L = 56
|
|
47
|
+
const PAD_R = 12
|
|
48
|
+
const PAD_T = 16
|
|
49
|
+
const PAD_B = 20
|
|
50
|
+
|
|
51
|
+
const TRACK_W = VW - PAD_L - PAD_R
|
|
52
|
+
const TRACK_H = VH - PAD_T - PAD_B
|
|
53
|
+
const n = series.length
|
|
54
|
+
const rowH = TRACK_H / n
|
|
55
|
+
const maxDensH = rowH * 1.6
|
|
56
|
+
|
|
57
|
+
const allVals = series.flatMap((s) => s.values)
|
|
58
|
+
const [xMin, xMax] = xRange ?? [Math.min(...allVals), Math.max(...allVals)]
|
|
59
|
+
|
|
60
|
+
const evalPts = Array.from(
|
|
61
|
+
{ length: NUM_PTS },
|
|
62
|
+
(_, i) => xMin + (i / (NUM_PTS - 1)) * (xMax - xMin),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
function xSvg(v: number) {
|
|
66
|
+
return PAD_L + ((v - xMin) / (xMax - xMin)) * TRACK_W
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ridges = series.map((s, si) => {
|
|
70
|
+
const bw = bandwidth(s.values)
|
|
71
|
+
const dens = kde(s.values, bw, evalPts)
|
|
72
|
+
const maxD = Math.max(...dens, 1e-9)
|
|
73
|
+
const baselineY = PAD_T + (si + 1) * rowH
|
|
74
|
+
const color = s.color ?? COLORS[si % COLORS.length]
|
|
75
|
+
|
|
76
|
+
const d = [
|
|
77
|
+
`M${xSvg(xMin).toFixed(1)},${baselineY.toFixed(1)}`,
|
|
78
|
+
...evalPts.map(
|
|
79
|
+
(v, i) => `L${xSvg(v).toFixed(1)},${(baselineY - (dens[i] / maxD) * maxDensH).toFixed(1)}`,
|
|
80
|
+
),
|
|
81
|
+
`L${xSvg(xMax).toFixed(1)},${baselineY.toFixed(1)}`,
|
|
82
|
+
'Z',
|
|
83
|
+
].join(' ')
|
|
84
|
+
|
|
85
|
+
return { d, baselineY, color, label: s.label }
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const xTicks = [0, 0.25, 0.5, 0.75, 1].map((t) => {
|
|
89
|
+
const v = xMin + t * (xMax - xMin)
|
|
90
|
+
return { v, x: xSvg(v) }
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const reversedRidges = [...ridges].reverse()
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
|
|
97
|
+
<svg
|
|
98
|
+
viewBox={`0 0 ${VW} ${VH}`}
|
|
99
|
+
style={{ width: '100%', height: 'auto', display: 'block' }}
|
|
100
|
+
aria-hidden="true"
|
|
101
|
+
focusable="false"
|
|
102
|
+
>
|
|
103
|
+
{xTicks.map(({ v, x }) => (
|
|
104
|
+
<g key={v}>
|
|
105
|
+
<line
|
|
106
|
+
x1={x}
|
|
107
|
+
y1={PAD_T}
|
|
108
|
+
x2={x}
|
|
109
|
+
y2={VH - PAD_B}
|
|
110
|
+
stroke="rgba(255,255,255,0.06)"
|
|
111
|
+
strokeWidth={1}
|
|
112
|
+
/>
|
|
113
|
+
<text
|
|
114
|
+
x={x}
|
|
115
|
+
y={VH - PAD_B + 12}
|
|
116
|
+
fill="#6E7079"
|
|
117
|
+
fontSize={8}
|
|
118
|
+
textAnchor="middle"
|
|
119
|
+
fontFamily="inherit"
|
|
120
|
+
>
|
|
121
|
+
{v.toFixed(0)}
|
|
122
|
+
</text>
|
|
123
|
+
</g>
|
|
124
|
+
))}
|
|
125
|
+
{reversedRidges.map((r) => (
|
|
126
|
+
<g key={r.label}>
|
|
127
|
+
<line
|
|
128
|
+
x1={PAD_L}
|
|
129
|
+
y1={r.baselineY}
|
|
130
|
+
x2={VW - PAD_R}
|
|
131
|
+
y2={r.baselineY}
|
|
132
|
+
stroke="rgba(255,255,255,0.08)"
|
|
133
|
+
strokeWidth={0.5}
|
|
134
|
+
/>
|
|
135
|
+
<path d={r.d} fill={r.color} opacity={0.45} />
|
|
136
|
+
<path d={r.d} fill="none" stroke={r.color} strokeWidth={1.2} opacity={0.9} />
|
|
137
|
+
</g>
|
|
138
|
+
))}
|
|
139
|
+
{ridges.map((r, si) => (
|
|
140
|
+
<text
|
|
141
|
+
key={si}
|
|
142
|
+
x={PAD_L - 4}
|
|
143
|
+
y={r.baselineY - 2}
|
|
144
|
+
fill="#A4A7B5"
|
|
145
|
+
fontSize={8}
|
|
146
|
+
textAnchor="end"
|
|
147
|
+
fontFamily="inherit"
|
|
148
|
+
>
|
|
149
|
+
{r.label}
|
|
150
|
+
</text>
|
|
151
|
+
))}
|
|
152
|
+
</svg>
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface SankeyLink {
|
|
4
|
+
source: string
|
|
5
|
+
target: string
|
|
6
|
+
value: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SankeyNode {
|
|
10
|
+
id: string
|
|
11
|
+
label: string
|
|
12
|
+
color?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SankeyDiagramProps {
|
|
16
|
+
nodes: SankeyNode[]
|
|
17
|
+
links: SankeyLink[]
|
|
18
|
+
height?: number
|
|
19
|
+
ariaLabel?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const VW = 320
|
|
23
|
+
const NODE_W = 10
|
|
24
|
+
const NODE_GAP = 8
|
|
25
|
+
const PAD = 16
|
|
26
|
+
const PALETTE = ['#006FFF', '#00C875', '#A78BFA', '#F5A623', '#50B8E7', '#F04949']
|
|
27
|
+
|
|
28
|
+
interface PositionedNode {
|
|
29
|
+
id: string
|
|
30
|
+
label: string
|
|
31
|
+
color: string
|
|
32
|
+
x: number
|
|
33
|
+
y: number
|
|
34
|
+
h: number
|
|
35
|
+
side: 'left' | 'right'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function SankeyDiagram({
|
|
39
|
+
nodes,
|
|
40
|
+
links,
|
|
41
|
+
height = 180,
|
|
42
|
+
ariaLabel = 'Sankey diagram',
|
|
43
|
+
}: SankeyDiagramProps) {
|
|
44
|
+
const { positioned, paths } = useMemo(() => {
|
|
45
|
+
if (!nodes.length || !links.length)
|
|
46
|
+
return { positioned: [] as PositionedNode[], paths: [] as { d: string; color: string }[] }
|
|
47
|
+
|
|
48
|
+
const nodeMap = new Map(
|
|
49
|
+
nodes.map((n, i) => [n.id, { ...n, color: n.color ?? PALETTE[i % PALETTE.length] }]),
|
|
50
|
+
)
|
|
51
|
+
const outflow = new Map<string, number>()
|
|
52
|
+
const inflow = new Map<string, number>()
|
|
53
|
+
for (const l of links) {
|
|
54
|
+
outflow.set(l.source, (outflow.get(l.source) ?? 0) + l.value)
|
|
55
|
+
inflow.set(l.target, (inflow.get(l.target) ?? 0) + l.value)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const leftIds = [...new Set(links.map((l) => l.source))].filter((id) => !inflow.has(id))
|
|
59
|
+
const rightIds = [...new Set(links.map((l) => l.target))].filter((id) => !outflow.has(id))
|
|
60
|
+
const totalFlow = links.reduce((s, l) => s + l.value, 0)
|
|
61
|
+
|
|
62
|
+
const leftAvailH = height - PAD * 2 - (leftIds.length - 1) * NODE_GAP
|
|
63
|
+
const rightAvailH = height - PAD * 2 - (rightIds.length - 1) * NODE_GAP
|
|
64
|
+
|
|
65
|
+
const positioned: PositionedNode[] = []
|
|
66
|
+
|
|
67
|
+
let y = PAD
|
|
68
|
+
for (const id of leftIds) {
|
|
69
|
+
const n = nodeMap.get(id)!
|
|
70
|
+
const h = Math.max(4, ((outflow.get(id) ?? 0) / totalFlow) * leftAvailH)
|
|
71
|
+
positioned.push({ id, label: n.label, color: n.color, x: PAD, y, h, side: 'left' })
|
|
72
|
+
y += h + NODE_GAP
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
y = PAD
|
|
76
|
+
for (const id of rightIds) {
|
|
77
|
+
const n = nodeMap.get(id)!
|
|
78
|
+
const h = Math.max(4, ((inflow.get(id) ?? 0) / totalFlow) * rightAvailH)
|
|
79
|
+
positioned.push({
|
|
80
|
+
id,
|
|
81
|
+
label: n.label,
|
|
82
|
+
color: n.color,
|
|
83
|
+
x: VW - PAD - NODE_W,
|
|
84
|
+
y,
|
|
85
|
+
h,
|
|
86
|
+
side: 'right',
|
|
87
|
+
})
|
|
88
|
+
y += h + NODE_GAP
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const sourcePort = new Map<string, number>(
|
|
92
|
+
positioned.filter((n) => n.side === 'left').map((n) => [n.id, n.y]),
|
|
93
|
+
)
|
|
94
|
+
const targetPort = new Map<string, number>(
|
|
95
|
+
positioned.filter((n) => n.side === 'right').map((n) => [n.id, n.y]),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const paths: { d: string; color: string }[] = []
|
|
99
|
+
for (const l of links) {
|
|
100
|
+
const srcNode = positioned.find((n) => n.id === l.source)
|
|
101
|
+
const tgtNode = positioned.find((n) => n.id === l.target)
|
|
102
|
+
if (!srcNode || !tgtNode) continue
|
|
103
|
+
|
|
104
|
+
const lh = Math.max(1, (l.value / totalFlow) * leftAvailH)
|
|
105
|
+
const sy = sourcePort.get(l.source)!
|
|
106
|
+
const ty = targetPort.get(l.target)!
|
|
107
|
+
sourcePort.set(l.source, sy + lh)
|
|
108
|
+
targetPort.set(l.target, ty + lh)
|
|
109
|
+
|
|
110
|
+
const x1 = srcNode.x + NODE_W
|
|
111
|
+
const x2 = tgtNode.x
|
|
112
|
+
const cx = (x1 + x2) / 2
|
|
113
|
+
const d = `M${x1},${sy.toFixed(1)} C${cx},${sy.toFixed(1)} ${cx},${ty.toFixed(1)} ${x2},${ty.toFixed(1)} L${x2},${(ty + lh).toFixed(1)} C${cx},${(ty + lh).toFixed(1)} ${cx},${(sy + lh).toFixed(1)} ${x1},${(sy + lh).toFixed(1)} Z`
|
|
114
|
+
paths.push({ d, color: srcNode.color })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { positioned, paths }
|
|
118
|
+
}, [nodes, links, height])
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
|
|
122
|
+
<svg
|
|
123
|
+
viewBox={`0 0 ${VW} ${height}`}
|
|
124
|
+
style={{ width: '100%', height: 'auto', display: 'block' }}
|
|
125
|
+
aria-hidden="true"
|
|
126
|
+
focusable="false"
|
|
127
|
+
>
|
|
128
|
+
{paths.map((p, i) => (
|
|
129
|
+
<path key={i} d={p.d} fill={p.color} fillOpacity={0.35} />
|
|
130
|
+
))}
|
|
131
|
+
{positioned.map((n) => (
|
|
132
|
+
<g key={n.id}>
|
|
133
|
+
<rect
|
|
134
|
+
x={n.x}
|
|
135
|
+
y={n.y.toFixed(1)}
|
|
136
|
+
width={NODE_W}
|
|
137
|
+
height={n.h.toFixed(1)}
|
|
138
|
+
fill={n.color}
|
|
139
|
+
rx={2}
|
|
140
|
+
/>
|
|
141
|
+
<text
|
|
142
|
+
x={n.side === 'left' ? n.x + NODE_W + 5 : n.x - 5}
|
|
143
|
+
y={(n.y + n.h / 2 + 4).toFixed(1)}
|
|
144
|
+
textAnchor={n.side === 'left' ? 'start' : 'end'}
|
|
145
|
+
fill="#C8C9D0"
|
|
146
|
+
fontSize={9}
|
|
147
|
+
fontFamily="inherit"
|
|
148
|
+
>
|
|
149
|
+
{n.label}
|
|
150
|
+
</text>
|
|
151
|
+
</g>
|
|
152
|
+
))}
|
|
153
|
+
</svg>
|
|
154
|
+
</div>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface ScatterPoint {
|
|
4
|
+
x: number
|
|
5
|
+
y: number
|
|
6
|
+
color?: string
|
|
7
|
+
r?: number
|
|
8
|
+
key?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ScatterPlotProps {
|
|
12
|
+
points: ScatterPoint[]
|
|
13
|
+
xRange?: [number, number]
|
|
14
|
+
yRange?: [number, number]
|
|
15
|
+
height?: number
|
|
16
|
+
ariaLabel?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const VW = 400
|
|
20
|
+
const PAD = { t: 12, r: 8, b: 12, l: 8 }
|
|
21
|
+
const DEFAULT_COLOR = '#006FFF'
|
|
22
|
+
|
|
23
|
+
export function ScatterPlot({
|
|
24
|
+
points,
|
|
25
|
+
xRange,
|
|
26
|
+
yRange,
|
|
27
|
+
height = 160,
|
|
28
|
+
ariaLabel = 'Scatter plot',
|
|
29
|
+
}: ScatterPlotProps) {
|
|
30
|
+
const { dots, gridLines } = useMemo(() => {
|
|
31
|
+
if (!points.length) return { dots: [], gridLines: [] }
|
|
32
|
+
const xs = points.map((p) => p.x)
|
|
33
|
+
const ys = points.map((p) => p.y)
|
|
34
|
+
const x0 = xRange?.[0] ?? Math.min(...xs)
|
|
35
|
+
const x1 = xRange?.[1] ?? Math.max(...xs)
|
|
36
|
+
const y0 = yRange?.[0] ?? Math.min(...ys)
|
|
37
|
+
const y1 = yRange?.[1] ?? Math.max(...ys)
|
|
38
|
+
const xSpan = x1 - x0 || 1
|
|
39
|
+
const ySpan = y1 - y0 || 1
|
|
40
|
+
const chartW = VW - PAD.l - PAD.r
|
|
41
|
+
const chartH = height - PAD.t - PAD.b
|
|
42
|
+
|
|
43
|
+
const toX = (x: number) => PAD.l + ((x - x0) / xSpan) * chartW
|
|
44
|
+
const toY = (y: number) => PAD.t + chartH - ((y - y0) / ySpan) * chartH
|
|
45
|
+
|
|
46
|
+
const gridLines = [0.25, 0.5, 0.75].map((f) => ({
|
|
47
|
+
x: PAD.l + f * chartW,
|
|
48
|
+
y: PAD.t + (1 - f) * chartH,
|
|
49
|
+
}))
|
|
50
|
+
|
|
51
|
+
const dots = points.map((p, i) => ({
|
|
52
|
+
cx: toX(p.x).toFixed(1),
|
|
53
|
+
cy: toY(p.y).toFixed(1),
|
|
54
|
+
r: p.r ?? 5,
|
|
55
|
+
color: p.color ?? DEFAULT_COLOR,
|
|
56
|
+
key: p.key ?? String(i),
|
|
57
|
+
}))
|
|
58
|
+
|
|
59
|
+
return { dots, gridLines }
|
|
60
|
+
}, [points, xRange, yRange, height])
|
|
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
|
+
{gridLines.map((g, i) => (
|
|
71
|
+
<g key={i}>
|
|
72
|
+
<line
|
|
73
|
+
x1={g.x}
|
|
74
|
+
y1={PAD.t}
|
|
75
|
+
x2={g.x}
|
|
76
|
+
y2={PAD.t + (height - PAD.t - PAD.b)}
|
|
77
|
+
stroke="rgba(255,255,255,0.06)"
|
|
78
|
+
strokeWidth={1}
|
|
79
|
+
/>
|
|
80
|
+
<line
|
|
81
|
+
x1={PAD.l}
|
|
82
|
+
y1={g.y}
|
|
83
|
+
x2={VW - PAD.r}
|
|
84
|
+
y2={g.y}
|
|
85
|
+
stroke="rgba(255,255,255,0.06)"
|
|
86
|
+
strokeWidth={1}
|
|
87
|
+
/>
|
|
88
|
+
</g>
|
|
89
|
+
))}
|
|
90
|
+
{dots.map((d) => (
|
|
91
|
+
<circle key={d.key} cx={d.cx} cy={d.cy} r={d.r} fill={d.color} fillOpacity={0.8} />
|
|
92
|
+
))}
|
|
93
|
+
</svg>
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|