@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,126 @@
1
+ import { useMemo } from 'react'
2
+
3
+ export interface TreeMapNode {
4
+ label: string
5
+ value: number
6
+ color?: string
7
+ }
8
+
9
+ export interface TreeMapProps {
10
+ nodes: TreeMapNode[]
11
+ height?: number
12
+ ariaLabel?: string
13
+ }
14
+
15
+ const VW = 320
16
+ const PAD = 4
17
+ const GAP = 2
18
+ const PALETTE = ['#006FFF', '#00C875', '#F04949', '#A78BFA', '#F5A623', '#50B8E7']
19
+
20
+ interface LayoutRect {
21
+ x: number
22
+ y: number
23
+ w: number
24
+ h: number
25
+ label: string
26
+ value: number
27
+ color: string
28
+ }
29
+
30
+ function layout(
31
+ nodes: { label: string; value: number; color: string }[],
32
+ x: number,
33
+ y: number,
34
+ w: number,
35
+ h: number,
36
+ ): LayoutRect[] {
37
+ if (!nodes.length) return []
38
+ if (nodes.length === 1) return [{ x, y, w: Math.max(w, 0), h: Math.max(h, 0), ...nodes[0] }]
39
+ const total = nodes.reduce((s, n) => s + n.value, 0)
40
+ let acc = 0
41
+ let split = nodes.length - 1
42
+ for (let i = 0; i < nodes.length - 1; i++) {
43
+ acc += nodes[i].value
44
+ if (acc / total >= 0.5) {
45
+ split = i + 1
46
+ break
47
+ }
48
+ }
49
+ const ratio = nodes.slice(0, split).reduce((s, n) => s + n.value, 0) / total
50
+ if (w > h) {
51
+ const w1 = Math.max((w - GAP) * ratio, 0)
52
+ return [
53
+ ...layout(nodes.slice(0, split), x, y, w1, h),
54
+ ...layout(nodes.slice(split), x + w1 + GAP, y, Math.max(w - w1 - GAP, 0), h),
55
+ ]
56
+ }
57
+ const h1 = Math.max((h - GAP) * ratio, 0)
58
+ return [
59
+ ...layout(nodes.slice(0, split), x, y, w, h1),
60
+ ...layout(nodes.slice(split), x, y + h1 + GAP, w, Math.max(h - h1 - GAP, 0)),
61
+ ]
62
+ }
63
+
64
+ export function TreeMap({ nodes, height = 160, ariaLabel = 'Treemap chart' }: TreeMapProps) {
65
+ const rects = useMemo(() => {
66
+ if (!nodes.length) return []
67
+ const sorted = [...nodes]
68
+ .sort((a, b) => b.value - a.value)
69
+ .map((n, i) => ({ ...n, color: n.color ?? PALETTE[i % PALETTE.length] }))
70
+ return layout(sorted, PAD, PAD, VW - PAD * 2, height - PAD * 2)
71
+ }, [nodes, height])
72
+
73
+ return (
74
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
75
+ <svg
76
+ viewBox={`0 0 ${VW} ${height}`}
77
+ style={{ width: '100%', height: 'auto', display: 'block' }}
78
+ aria-hidden="true"
79
+ focusable="false"
80
+ >
81
+ {rects.map((r, i) => {
82
+ const showLabel = r.w >= 36 && r.h >= 22
83
+ const fs = Math.min(10, Math.max(7, Math.floor(r.w / 6)))
84
+ return (
85
+ <g key={i}>
86
+ <rect
87
+ x={r.x.toFixed(1)}
88
+ y={r.y.toFixed(1)}
89
+ width={r.w.toFixed(1)}
90
+ height={r.h.toFixed(1)}
91
+ fill={r.color}
92
+ fillOpacity={0.85}
93
+ rx={3}
94
+ />
95
+ {showLabel && (
96
+ <>
97
+ <text
98
+ x={(r.x + r.w / 2).toFixed(1)}
99
+ y={(r.y + r.h / 2 - 4).toFixed(1)}
100
+ textAnchor="middle"
101
+ fill="#fff"
102
+ fontSize={fs}
103
+ fontFamily="inherit"
104
+ fontWeight={600}
105
+ >
106
+ {r.label}
107
+ </text>
108
+ <text
109
+ x={(r.x + r.w / 2).toFixed(1)}
110
+ y={(r.y + r.h / 2 + 9).toFixed(1)}
111
+ textAnchor="middle"
112
+ fill="rgba(255,255,255,0.65)"
113
+ fontSize={Math.max(fs - 1, 7)}
114
+ fontFamily="inherit"
115
+ >
116
+ {r.value}
117
+ </text>
118
+ </>
119
+ )}
120
+ </g>
121
+ )
122
+ })}
123
+ </svg>
124
+ </div>
125
+ )
126
+ }
@@ -0,0 +1,221 @@
1
+ import { useState, useRef, useCallback } from 'react'
2
+ import type { HTMLAttributes, KeyboardEvent, ReactNode } from 'react'
3
+
4
+ export interface TreeNode {
5
+ id: string
6
+ label: string
7
+ icon?: ReactNode
8
+ meta?: string
9
+ children?: TreeNode[]
10
+ }
11
+
12
+ export interface TreeViewProps extends Omit<HTMLAttributes<HTMLUListElement>, 'onSelect'> {
13
+ nodes: TreeNode[]
14
+ selected?: string
15
+ onSelect?: (id: string) => void
16
+ defaultExpanded?: string[]
17
+ label?: string
18
+ }
19
+
20
+ function visibleIds(nodes: TreeNode[], expanded: Set<string>): string[] {
21
+ const ids: string[] = []
22
+ function walk(list: TreeNode[]) {
23
+ for (const n of list) {
24
+ ids.push(n.id)
25
+ if (n.children?.length && expanded.has(n.id)) walk(n.children)
26
+ }
27
+ }
28
+ walk(nodes)
29
+ return ids
30
+ }
31
+
32
+ function findNode(id: string, list: TreeNode[]): TreeNode | undefined {
33
+ for (const n of list) {
34
+ if (n.id === id) return n
35
+ if (n.children) {
36
+ const found = findNode(id, n.children)
37
+ if (found) return found
38
+ }
39
+ }
40
+ }
41
+
42
+ function TreeItem({
43
+ node,
44
+ selected,
45
+ focusedId,
46
+ expanded,
47
+ }: {
48
+ node: TreeNode
49
+ selected: string | undefined
50
+ focusedId: string
51
+ expanded: Set<string>
52
+ }) {
53
+ const hasChildren = !!node.children?.length
54
+ const isExpanded = expanded.has(node.id)
55
+ const isSelected = selected === node.id
56
+
57
+ return (
58
+ <li
59
+ role="treeitem"
60
+ aria-expanded={hasChildren ? isExpanded : undefined}
61
+ aria-selected={isSelected}
62
+ tabIndex={focusedId === node.id ? 0 : -1}
63
+ data-tree-id={node.id}
64
+ className="tree__item"
65
+ >
66
+ <div className={`tree__row${isSelected ? ' tree__row--selected' : ''}`}>
67
+ {hasChildren ? (
68
+ <svg
69
+ className={`tree__chevron${isExpanded ? ' tree__chevron--open' : ''}`}
70
+ viewBox="0 0 12 12"
71
+ fill="none"
72
+ aria-hidden="true"
73
+ >
74
+ <path
75
+ d="M4 2l4 4-4 4"
76
+ stroke="currentColor"
77
+ strokeWidth="1.5"
78
+ strokeLinecap="round"
79
+ strokeLinejoin="round"
80
+ />
81
+ </svg>
82
+ ) : (
83
+ <span className="tree__indent" aria-hidden="true" />
84
+ )}
85
+ {node.icon && (
86
+ <span className="tree__icon" aria-hidden="true">
87
+ {node.icon}
88
+ </span>
89
+ )}
90
+ <span className="tree__label">{node.label}</span>
91
+ {node.meta && <span className="tree__meta">{node.meta}</span>}
92
+ </div>
93
+ {hasChildren && isExpanded && (
94
+ <ul role="group" className="tree__group">
95
+ {node.children!.map((child) => (
96
+ <TreeItem
97
+ key={child.id}
98
+ node={child}
99
+ selected={selected}
100
+ focusedId={focusedId}
101
+ expanded={expanded}
102
+ />
103
+ ))}
104
+ </ul>
105
+ )}
106
+ </li>
107
+ )
108
+ }
109
+
110
+ export function TreeView({
111
+ nodes,
112
+ selected,
113
+ onSelect,
114
+ defaultExpanded = [],
115
+ label,
116
+ className = '',
117
+ ...rest
118
+ }: TreeViewProps) {
119
+ const [expanded, setExpanded] = useState<Set<string>>(() => new Set(defaultExpanded))
120
+ const [focusedId, setFocusedId] = useState<string>(() => nodes[0]?.id ?? '')
121
+ const rootRef = useRef<HTMLUListElement>(null)
122
+
123
+ const toggle = useCallback((id: string) => {
124
+ setExpanded((prev) => {
125
+ const next = new Set(prev)
126
+ if (next.has(id)) next.delete(id)
127
+ else next.add(id)
128
+ return next
129
+ })
130
+ }, [])
131
+
132
+ function moveFocus(id: string) {
133
+ setFocusedId(id)
134
+ requestAnimationFrame(() => {
135
+ rootRef.current?.querySelector<HTMLElement>(`[data-tree-id="${id}"]`)?.focus()
136
+ })
137
+ }
138
+
139
+ function handleFocus(e: React.FocusEvent<HTMLUListElement>) {
140
+ const el = (e.target as HTMLElement).closest('[data-tree-id]')
141
+ if (el) setFocusedId(el.getAttribute('data-tree-id') ?? focusedId)
142
+ }
143
+
144
+ function handleClick(e: React.MouseEvent<HTMLUListElement>) {
145
+ const row = (e.target as HTMLElement).closest('.tree__row')
146
+ if (!row) return
147
+ const item = row.closest('[data-tree-id]')
148
+ if (!item) return
149
+ const id = item.getAttribute('data-tree-id')
150
+ if (!id) return
151
+ onSelect?.(id)
152
+ setFocusedId(id)
153
+ moveFocus(id)
154
+ const node = findNode(id, nodes)
155
+ if (node?.children?.length) toggle(id)
156
+ }
157
+
158
+ function handleKeyDown(e: KeyboardEvent<HTMLUListElement>) {
159
+ const el = (e.target as HTMLElement).closest('[data-tree-id]')
160
+ if (!el) return
161
+ const id = el.getAttribute('data-tree-id')
162
+ if (!id) return
163
+
164
+ const visible = visibleIds(nodes, expanded)
165
+ const idx = visible.indexOf(id)
166
+ const node = findNode(id, nodes)
167
+ const hasChildren = !!node?.children?.length
168
+ const isNodeExpanded = expanded.has(id)
169
+
170
+ if (e.key === 'ArrowDown') {
171
+ e.preventDefault()
172
+ const next = visible[idx + 1]
173
+ if (next) moveFocus(next)
174
+ } else if (e.key === 'ArrowUp') {
175
+ e.preventDefault()
176
+ const prev = visible[idx - 1]
177
+ if (prev) moveFocus(prev)
178
+ } else if (e.key === 'ArrowRight') {
179
+ e.preventDefault()
180
+ if (hasChildren && !isNodeExpanded) toggle(id)
181
+ else if (hasChildren && isNodeExpanded) moveFocus(node!.children![0]!.id)
182
+ } else if (e.key === 'ArrowLeft') {
183
+ e.preventDefault()
184
+ if (hasChildren && isNodeExpanded) toggle(id)
185
+ } else if (e.key === 'Home') {
186
+ e.preventDefault()
187
+ if (visible[0]) moveFocus(visible[0])
188
+ } else if (e.key === 'End') {
189
+ e.preventDefault()
190
+ const last = visible[visible.length - 1]
191
+ if (last) moveFocus(last)
192
+ } else if (e.key === 'Enter' || e.key === ' ') {
193
+ e.preventDefault()
194
+ onSelect?.(id)
195
+ setFocusedId(id)
196
+ }
197
+ }
198
+
199
+ return (
200
+ <ul
201
+ ref={rootRef}
202
+ role="tree"
203
+ aria-label={label}
204
+ className={`tree ${className}`.trim()}
205
+ onFocus={handleFocus}
206
+ onClick={handleClick}
207
+ onKeyDown={handleKeyDown}
208
+ {...rest}
209
+ >
210
+ {nodes.map((node) => (
211
+ <TreeItem
212
+ key={node.id}
213
+ node={node}
214
+ selected={selected}
215
+ focusedId={focusedId}
216
+ expanded={expanded}
217
+ />
218
+ ))}
219
+ </ul>
220
+ )
221
+ }
@@ -0,0 +1,138 @@
1
+ export type UptimeStatus = 'up' | 'degraded' | 'down'
2
+
3
+ export interface UptimeSegment {
4
+ from: number
5
+ to: number
6
+ status: UptimeStatus
7
+ }
8
+
9
+ export interface UptimeSeries {
10
+ label: string
11
+ segments: UptimeSegment[]
12
+ }
13
+
14
+ export interface UptimeTimelineProps {
15
+ series: UptimeSeries[]
16
+ xLabels?: string[]
17
+ ariaLabel?: string
18
+ }
19
+
20
+ const VW = 400
21
+ const LABEL_W = 88
22
+ const PAD_R = 10
23
+ const PAD_T = 6
24
+ const ROW_H = 14
25
+ const ROW_GAP = 8
26
+ const XLBL_H = 16
27
+ const LEG_H = 16
28
+ const PAD_B = XLBL_H + LEG_H + 4
29
+
30
+ const STATUS_COLOR: Record<UptimeStatus, string> = {
31
+ up: '#00C875',
32
+ degraded: '#F5A623',
33
+ down: '#F03E3E',
34
+ }
35
+
36
+ const LEGEND: { status: UptimeStatus; label: string }[] = [
37
+ { status: 'up', label: 'Up' },
38
+ { status: 'degraded', label: 'Degraded' },
39
+ { status: 'down', label: 'Down' },
40
+ ]
41
+
42
+ export function UptimeTimeline({
43
+ series,
44
+ xLabels,
45
+ ariaLabel = 'Uptime timeline',
46
+ }: UptimeTimelineProps) {
47
+ const trackW = VW - LABEL_W - PAD_R
48
+ const rowsH = series.length * (ROW_H + ROW_GAP) - ROW_GAP
49
+ const totalH = PAD_T + rowsH + PAD_B
50
+ const xlblY = PAD_T + rowsH + XLBL_H
51
+ const legY = xlblY + LEG_H
52
+
53
+ return (
54
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
55
+ <svg
56
+ viewBox={`0 0 ${VW} ${totalH}`}
57
+ style={{ width: '100%', height: 'auto', display: 'block' }}
58
+ aria-hidden="true"
59
+ focusable="false"
60
+ >
61
+ {series.map((s, si) => {
62
+ const y = PAD_T + si * (ROW_H + ROW_GAP)
63
+ return (
64
+ <g key={s.label}>
65
+ <text
66
+ x={LABEL_W - 8}
67
+ y={y + ROW_H / 2 + 4}
68
+ textAnchor="end"
69
+ fill="#A4A7B5"
70
+ fontSize={11}
71
+ fontFamily="inherit"
72
+ >
73
+ {s.label}
74
+ </text>
75
+ <rect
76
+ x={LABEL_W}
77
+ y={y}
78
+ width={trackW}
79
+ height={ROW_H}
80
+ fill="rgba(255,255,255,0.04)"
81
+ rx={3}
82
+ />
83
+ {s.segments.map((seg, i) => (
84
+ <rect
85
+ key={i}
86
+ x={LABEL_W + seg.from * trackW}
87
+ y={y}
88
+ width={(seg.to - seg.from) * trackW}
89
+ height={ROW_H}
90
+ fill={STATUS_COLOR[seg.status]}
91
+ rx={2}
92
+ fillOpacity={0.85}
93
+ />
94
+ ))}
95
+ </g>
96
+ )
97
+ })}
98
+
99
+ {xLabels?.map((lbl, i) => (
100
+ <text
101
+ key={lbl}
102
+ x={LABEL_W + (xLabels.length > 1 ? i / (xLabels.length - 1) : 0) * trackW}
103
+ y={xlblY}
104
+ textAnchor={i === 0 ? 'start' : i === xLabels.length - 1 ? 'end' : 'middle'}
105
+ fill="#6E7079"
106
+ fontSize={10}
107
+ fontFamily="inherit"
108
+ >
109
+ {lbl}
110
+ </text>
111
+ ))}
112
+
113
+ {LEGEND.map(({ status, label }, i) => (
114
+ <g key={status}>
115
+ <rect
116
+ x={LABEL_W + i * 80}
117
+ y={legY - 8}
118
+ width={12}
119
+ height={8}
120
+ fill={STATUS_COLOR[status]}
121
+ rx={2}
122
+ fillOpacity={0.85}
123
+ />
124
+ <text
125
+ x={LABEL_W + i * 80 + 16}
126
+ y={legY}
127
+ fill="#6E7079"
128
+ fontSize={10}
129
+ fontFamily="inherit"
130
+ >
131
+ {label}
132
+ </text>
133
+ </g>
134
+ ))}
135
+ </svg>
136
+ </div>
137
+ )
138
+ }
@@ -0,0 +1,151 @@
1
+ export interface VennSet {
2
+ id: string
3
+ label: string
4
+ value: number
5
+ color?: string
6
+ }
7
+
8
+ export interface VennIntersection {
9
+ sets: string[]
10
+ value: number
11
+ }
12
+
13
+ export interface VennDiagramProps {
14
+ sets: VennSet[]
15
+ intersections?: VennIntersection[]
16
+ height?: number
17
+ ariaLabel?: string
18
+ }
19
+
20
+ const PALETTE = ['#006FFF', '#00C875', '#A78BFA']
21
+ const VW = 340
22
+
23
+ function fmt(n: number): string {
24
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
25
+ }
26
+
27
+ export function VennDiagram({
28
+ sets,
29
+ intersections = [],
30
+ height = 240,
31
+ ariaLabel = 'Venn diagram',
32
+ }: VennDiagramProps) {
33
+ if (sets.length < 2 || sets.length > 3) return null
34
+
35
+ const is3 = sets.length === 3
36
+ const cx = VW / 2
37
+ const cy = height / 2
38
+
39
+ const circles = is3
40
+ ? [
41
+ { x: cx, y: cy - 40, r: 75 },
42
+ { x: cx - 65, y: cy + 50, r: 75 },
43
+ { x: cx + 65, y: cy + 50, r: 75 },
44
+ ]
45
+ : [
46
+ { x: cx - 58, y: cy, r: 85 },
47
+ { x: cx + 58, y: cy, r: 85 },
48
+ ]
49
+
50
+ const exclusiveValues = sets.map((s) =>
51
+ Math.max(
52
+ 0,
53
+ s.value -
54
+ intersections.filter((i) => i.sets.includes(s.id)).reduce((sum, i) => sum + i.value, 0),
55
+ ),
56
+ )
57
+
58
+ function getPt(inter: VennIntersection): { x: number; y: number } | null {
59
+ const idxById: Record<string, number> = {}
60
+ sets.forEach((s, i) => {
61
+ idxById[s.id] = i
62
+ })
63
+ const idxs = inter.sets
64
+ .map((id) => idxById[id])
65
+ .filter((v) => v !== undefined)
66
+ .sort((a, b) => a - b)
67
+ if (!is3) return { x: cx, y: cy }
68
+ if (idxs.length >= 3) return { x: cx, y: cy + 18 }
69
+ if (idxs[0] === 0 && idxs[1] === 1) return { x: cx - 33, y: cy + 4 }
70
+ if (idxs[0] === 0 && idxs[1] === 2) return { x: cx + 33, y: cy + 4 }
71
+ if (idxs[0] === 1 && idxs[1] === 2) return { x: cx, y: cy + 50 }
72
+ return null
73
+ }
74
+
75
+ return (
76
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
77
+ <svg
78
+ viewBox={`0 0 ${VW} ${height}`}
79
+ style={{ width: '100%', height: 'auto', display: 'block' }}
80
+ aria-hidden="true"
81
+ focusable="false"
82
+ >
83
+ {sets.map((s, i) => {
84
+ const c = circles[i]
85
+ const color = s.color ?? PALETTE[i % PALETTE.length]
86
+ return (
87
+ <circle
88
+ key={i}
89
+ cx={c.x}
90
+ cy={c.y}
91
+ r={c.r}
92
+ fill={color}
93
+ fillOpacity={0.15}
94
+ stroke={color}
95
+ strokeOpacity={0.55}
96
+ strokeWidth={1.5}
97
+ />
98
+ )
99
+ })}
100
+
101
+ {sets.map((s, i) => {
102
+ const c = circles[i]
103
+ const color = s.color ?? PALETTE[i % PALETTE.length]
104
+ return (
105
+ <g key={i}>
106
+ <text
107
+ x={c.x}
108
+ y={c.y - 8}
109
+ fill={color}
110
+ fontSize={11}
111
+ textAnchor="middle"
112
+ fontFamily="inherit"
113
+ fontWeight={600}
114
+ >
115
+ {s.label}
116
+ </text>
117
+ <text
118
+ x={c.x}
119
+ y={c.y + 8}
120
+ fill="#A4A7B5"
121
+ fontSize={10}
122
+ textAnchor="middle"
123
+ fontFamily="inherit"
124
+ >
125
+ {fmt(exclusiveValues[i])}
126
+ </text>
127
+ </g>
128
+ )
129
+ })}
130
+
131
+ {intersections.map((inter, i) => {
132
+ const pt = getPt(inter)
133
+ if (!pt) return null
134
+ return (
135
+ <text
136
+ key={i}
137
+ x={pt.x}
138
+ y={pt.y}
139
+ fill="rgba(255,255,255,0.65)"
140
+ fontSize={10}
141
+ textAnchor="middle"
142
+ fontFamily="inherit"
143
+ >
144
+ {fmt(inter.value)}
145
+ </text>
146
+ )
147
+ })}
148
+ </svg>
149
+ </div>
150
+ )
151
+ }