@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.
Files changed (164) hide show
  1. package/README.md +9 -0
  2. package/package.json +59 -0
  3. package/src/components/Accordion.tsx +65 -0
  4. package/src/components/ActionMenu.tsx +131 -0
  5. package/src/components/ActivityFeed.tsx +77 -0
  6. package/src/components/Alert.tsx +33 -0
  7. package/src/components/AnnotatedTimeSeries.tsx +148 -0
  8. package/src/components/AppShell.tsx +158 -0
  9. package/src/components/ArcDiagram.tsx +114 -0
  10. package/src/components/AreaChart.tsx +101 -0
  11. package/src/components/Avatar.tsx +37 -0
  12. package/src/components/AvatarGroup.tsx +36 -0
  13. package/src/components/Badge.tsx +36 -0
  14. package/src/components/Banner.tsx +49 -0
  15. package/src/components/BarChart.tsx +106 -0
  16. package/src/components/BeeswarmChart.tsx +142 -0
  17. package/src/components/BoxPlot.tsx +142 -0
  18. package/src/components/Breadcrumb.tsx +44 -0
  19. package/src/components/BubbleChart.tsx +115 -0
  20. package/src/components/BulletChart.tsx +121 -0
  21. package/src/components/BumpChart.tsx +127 -0
  22. package/src/components/Button.tsx +61 -0
  23. package/src/components/CIDRInput.tsx +191 -0
  24. package/src/components/CalendarHeatmap.tsx +117 -0
  25. package/src/components/Callout.tsx +82 -0
  26. package/src/components/CandlestickChart.tsx +113 -0
  27. package/src/components/Card.tsx +27 -0
  28. package/src/components/Carousel.tsx +78 -0
  29. package/src/components/Checkbox.tsx +42 -0
  30. package/src/components/ChordDiagram.tsx +133 -0
  31. package/src/components/CirclePacking.tsx +189 -0
  32. package/src/components/CodeBlock.tsx +27 -0
  33. package/src/components/ColorPicker.tsx +120 -0
  34. package/src/components/ColumnToggle.tsx +106 -0
  35. package/src/components/Combobox.tsx +211 -0
  36. package/src/components/CommandPalette.tsx +168 -0
  37. package/src/components/ConfirmDialog.tsx +114 -0
  38. package/src/components/ContextMenu.tsx +127 -0
  39. package/src/components/ContextualHelp.tsx +69 -0
  40. package/src/components/ContourPlot.tsx +255 -0
  41. package/src/components/CopyButton.tsx +47 -0
  42. package/src/components/CorrelationMatrix.tsx +107 -0
  43. package/src/components/CountUp.tsx +70 -0
  44. package/src/components/CumulativeDistribution.tsx +155 -0
  45. package/src/components/DatePicker.tsx +315 -0
  46. package/src/components/DateRangePicker.tsx +327 -0
  47. package/src/components/Dendrogram.tsx +150 -0
  48. package/src/components/Donut.tsx +101 -0
  49. package/src/components/DotPlot.tsx +195 -0
  50. package/src/components/Drawer.tsx +81 -0
  51. package/src/components/DualAxisChart.tsx +209 -0
  52. package/src/components/DumbbellChart.tsx +102 -0
  53. package/src/components/DurationInput.tsx +144 -0
  54. package/src/components/EmptyState.tsx +40 -0
  55. package/src/components/ErrorBandChart.tsx +124 -0
  56. package/src/components/ExpandableRow.tsx +58 -0
  57. package/src/components/FileUpload.tsx +129 -0
  58. package/src/components/FilterBuilder.tsx +217 -0
  59. package/src/components/FlameGraph.tsx +119 -0
  60. package/src/components/ForceGraph.tsx +149 -0
  61. package/src/components/FunnelChart.tsx +79 -0
  62. package/src/components/GanttChart.tsx +96 -0
  63. package/src/components/Gauge.tsx +71 -0
  64. package/src/components/GroupedList.tsx +125 -0
  65. package/src/components/HealthBar.tsx +25 -0
  66. package/src/components/HeatMap.tsx +106 -0
  67. package/src/components/HexbinChart.tsx +152 -0
  68. package/src/components/Histogram.tsx +121 -0
  69. package/src/components/HorizonChart.tsx +110 -0
  70. package/src/components/HoverCard.tsx +52 -0
  71. package/src/components/IPInput.tsx +147 -0
  72. package/src/components/IcicleChart.tsx +118 -0
  73. package/src/components/InlineEdit.tsx +119 -0
  74. package/src/components/Input.tsx +94 -0
  75. package/src/components/InputGroup.tsx +21 -0
  76. package/src/components/JsonViewer.tsx +116 -0
  77. package/src/components/KVTable.tsx +29 -0
  78. package/src/components/KanbanBoard.tsx +171 -0
  79. package/src/components/Kbd.tsx +22 -0
  80. package/src/components/LineChart.tsx +112 -0
  81. package/src/components/LogViewer.tsx +102 -0
  82. package/src/components/LollipopChart.tsx +110 -0
  83. package/src/components/MACInput.tsx +164 -0
  84. package/src/components/MarimekkoChart.tsx +143 -0
  85. package/src/components/MatrixChart.tsx +118 -0
  86. package/src/components/Menubar.tsx +194 -0
  87. package/src/components/MirroredBarChart.tsx +148 -0
  88. package/src/components/Modal.tsx +87 -0
  89. package/src/components/MultiSelect.tsx +232 -0
  90. package/src/components/NightingaleChart.tsx +101 -0
  91. package/src/components/NotificationPanel.tsx +187 -0
  92. package/src/components/NumberInput.tsx +129 -0
  93. package/src/components/OTPInput.tsx +135 -0
  94. package/src/components/Pagination.tsx +73 -0
  95. package/src/components/ParallelCoordinates.tsx +149 -0
  96. package/src/components/ParetoChart.tsx +159 -0
  97. package/src/components/PasswordInput.tsx +112 -0
  98. package/src/components/PieChart.tsx +109 -0
  99. package/src/components/Pill.tsx +26 -0
  100. package/src/components/PolarHeatmap.tsx +118 -0
  101. package/src/components/Popover.tsx +103 -0
  102. package/src/components/ProgressBar.tsx +33 -0
  103. package/src/components/PunchCard.tsx +93 -0
  104. package/src/components/QuadrantChart.tsx +256 -0
  105. package/src/components/RadarChart.tsx +119 -0
  106. package/src/components/RadialBarChart.tsx +103 -0
  107. package/src/components/RadioGroup.tsx +69 -0
  108. package/src/components/RangeSlider.tsx +116 -0
  109. package/src/components/RankedList.tsx +61 -0
  110. package/src/components/ResizablePanel.tsx +122 -0
  111. package/src/components/RidgelinePlot.tsx +155 -0
  112. package/src/components/SankeyDiagram.tsx +156 -0
  113. package/src/components/ScatterPlot.tsx +96 -0
  114. package/src/components/SegmentedControl.tsx +78 -0
  115. package/src/components/Select.tsx +158 -0
  116. package/src/components/SelectionToolbar.tsx +55 -0
  117. package/src/components/Signal.tsx +19 -0
  118. package/src/components/Skeleton.tsx +37 -0
  119. package/src/components/SkipLink.tsx +19 -0
  120. package/src/components/Slider.tsx +74 -0
  121. package/src/components/SlopeChart.tsx +205 -0
  122. package/src/components/SortableList.tsx +130 -0
  123. package/src/components/SortableTable.tsx +46 -0
  124. package/src/components/Sparkline.tsx +50 -0
  125. package/src/components/SparklineMatrix.tsx +117 -0
  126. package/src/components/Spinner.tsx +8 -0
  127. package/src/components/SplitButton.tsx +151 -0
  128. package/src/components/Spoiler.tsx +47 -0
  129. package/src/components/StackedBarChart.tsx +145 -0
  130. package/src/components/StackedProgress.tsx +47 -0
  131. package/src/components/StarRating.tsx +103 -0
  132. package/src/components/Stat.tsx +48 -0
  133. package/src/components/StatusIndicator.tsx +26 -0
  134. package/src/components/StepChart.tsx +130 -0
  135. package/src/components/Stepper.tsx +51 -0
  136. package/src/components/StreamGraph.tsx +97 -0
  137. package/src/components/StripeChart.tsx +76 -0
  138. package/src/components/SunburstChart.tsx +142 -0
  139. package/src/components/SwitchPortGrid.tsx +59 -0
  140. package/src/components/Tabs.tsx +91 -0
  141. package/src/components/Tag.tsx +37 -0
  142. package/src/components/TagInput.tsx +99 -0
  143. package/src/components/Textarea.tsx +9 -0
  144. package/src/components/ThresholdAreaChart.tsx +113 -0
  145. package/src/components/TimePicker.tsx +144 -0
  146. package/src/components/TimeRange.tsx +152 -0
  147. package/src/components/Timeline.tsx +31 -0
  148. package/src/components/Toast.tsx +72 -0
  149. package/src/components/Toggle.tsx +68 -0
  150. package/src/components/ToggleGroup.tsx +57 -0
  151. package/src/components/Tooltip.tsx +25 -0
  152. package/src/components/TransferList.tsx +227 -0
  153. package/src/components/TreeMap.tsx +126 -0
  154. package/src/components/TreeView.tsx +221 -0
  155. package/src/components/UptimeTimeline.tsx +138 -0
  156. package/src/components/VennDiagram.tsx +151 -0
  157. package/src/components/ViolinPlot.tsx +158 -0
  158. package/src/components/VirtualList.tsx +59 -0
  159. package/src/components/WaffleChart.tsx +79 -0
  160. package/src/components/WaterfallChart.tsx +164 -0
  161. package/src/components/WordCloud.tsx +107 -0
  162. package/src/icons.tsx +241 -0
  163. package/src/index.ts +160 -0
  164. 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} &ndash; {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
+ }