@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,158 @@
1
+ export interface ViolinSeries {
2
+ label: string
3
+ values: number[]
4
+ color?: string
5
+ }
6
+
7
+ export interface ViolinPlotProps {
8
+ series: ViolinSeries[]
9
+ yRange?: [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 = 60
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
+ (y) =>
30
+ c *
31
+ vals.reduce((acc, x) => {
32
+ const z = (y - x) / bw
33
+ return acc + Math.exp(-0.5 * z * z)
34
+ }, 0),
35
+ )
36
+ }
37
+
38
+ function median(vals: number[]): number {
39
+ const s = [...vals].sort((a, b) => a - b)
40
+ const m = Math.floor(s.length / 2)
41
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2
42
+ }
43
+
44
+ export function ViolinPlot({
45
+ series,
46
+ yRange,
47
+ height = 160,
48
+ ariaLabel = 'Violin plot',
49
+ }: ViolinPlotProps) {
50
+ const VW = 380
51
+ const VH = height
52
+ const PAD_L = 28
53
+ const PAD_R = 8
54
+ const PAD_T = 8
55
+ const PAD_B = 20
56
+
57
+ const allVals = series.flatMap((s) => s.values)
58
+ const [yMin, yMax] = yRange ?? [Math.min(...allVals), Math.max(...allVals)]
59
+
60
+ const TRACK_W = VW - PAD_L - PAD_R
61
+ const TRACK_H = VH - PAD_T - PAD_B
62
+ const slotW = TRACK_W / series.length
63
+ const maxHalfW = slotW * 0.38
64
+
65
+ const evalPts = Array.from(
66
+ { length: NUM_PTS },
67
+ (_, i) => yMin + (i / (NUM_PTS - 1)) * (yMax - yMin),
68
+ )
69
+
70
+ function ySvg(v: number) {
71
+ return PAD_T + ((yMax - v) / (yMax - yMin)) * TRACK_H
72
+ }
73
+
74
+ const violins = series.map((s, si) => {
75
+ const bw = bandwidth(s.values)
76
+ const dens = kde(s.values, bw, evalPts)
77
+ const maxD = Math.max(...dens, 1e-9)
78
+ const cx = PAD_L + si * slotW + slotW / 2
79
+
80
+ const pts = evalPts.map((v, i) => ({ x: (dens[i] / maxD) * maxHalfW, y: ySvg(v) }))
81
+ const right = pts.map((p) => `${(cx + p.x).toFixed(1)},${p.y.toFixed(1)}`)
82
+ const left = [...pts].reverse().map((p) => `${(cx - p.x).toFixed(1)},${p.y.toFixed(1)}`)
83
+ const d = `M${cx},${ySvg(yMin)} L${right.join(' L')} L${left.join(' L')} Z`
84
+
85
+ const med = median(s.values)
86
+ return {
87
+ d,
88
+ cx,
89
+ medY: ySvg(med),
90
+ maxHalfW,
91
+ color: s.color ?? COLORS[si % COLORS.length],
92
+ label: s.label,
93
+ }
94
+ })
95
+
96
+ const ticks = [0, 0.25, 0.5, 0.75, 1].map((t) => {
97
+ const v = yMin + t * (yMax - yMin)
98
+ return { v, y: ySvg(v) }
99
+ })
100
+
101
+ return (
102
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
103
+ <svg
104
+ viewBox={`0 0 ${VW} ${VH}`}
105
+ style={{ width: '100%', height: 'auto', display: 'block' }}
106
+ aria-hidden="true"
107
+ focusable="false"
108
+ >
109
+ {ticks.map(({ v, y }) => (
110
+ <g key={v}>
111
+ <line
112
+ x1={PAD_L}
113
+ y1={y}
114
+ x2={VW - PAD_R}
115
+ y2={y}
116
+ stroke="rgba(255,255,255,0.06)"
117
+ strokeWidth={1}
118
+ />
119
+ <text
120
+ x={PAD_L - 3}
121
+ y={y + 3}
122
+ fill="#6E7079"
123
+ fontSize={8}
124
+ textAnchor="end"
125
+ fontFamily="inherit"
126
+ >
127
+ {v.toFixed(0)}
128
+ </text>
129
+ </g>
130
+ ))}
131
+ {violins.map((v, si) => (
132
+ <g key={si}>
133
+ <path d={v.d} fill={v.color} opacity={0.72} />
134
+ <line
135
+ x1={v.cx - v.maxHalfW * 0.45}
136
+ y1={v.medY}
137
+ x2={v.cx + v.maxHalfW * 0.45}
138
+ y2={v.medY}
139
+ stroke="#fff"
140
+ strokeWidth={1.5}
141
+ strokeLinecap="round"
142
+ />
143
+ <text
144
+ x={v.cx}
145
+ y={VH - 5}
146
+ fill="#6E7079"
147
+ fontSize={8}
148
+ textAnchor="middle"
149
+ fontFamily="inherit"
150
+ >
151
+ {v.label}
152
+ </text>
153
+ </g>
154
+ ))}
155
+ </svg>
156
+ </div>
157
+ )
158
+ }
@@ -0,0 +1,59 @@
1
+ import { useState, useCallback, type ReactNode } from 'react'
2
+
3
+ export interface VirtualListProps<T> {
4
+ items: T[]
5
+ itemHeight: number
6
+ height: number
7
+ renderItem: (item: T, index: number) => ReactNode
8
+ overscan?: number
9
+ label?: string
10
+ className?: string
11
+ }
12
+
13
+ export function VirtualList<T>({
14
+ items,
15
+ itemHeight,
16
+ height,
17
+ renderItem,
18
+ overscan = 3,
19
+ label,
20
+ className,
21
+ }: VirtualListProps<T>) {
22
+ const [scrollTop, setScrollTop] = useState(0)
23
+
24
+ const totalHeight = items.length * itemHeight
25
+ const firstVisible = Math.floor(scrollTop / itemHeight)
26
+ const startIndex = Math.max(0, firstVisible - overscan)
27
+ const endIndex = Math.min(
28
+ items.length,
29
+ firstVisible + Math.ceil(height / itemHeight) + overscan + 1,
30
+ )
31
+
32
+ const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
33
+ setScrollTop(e.currentTarget.scrollTop)
34
+ }, [])
35
+
36
+ return (
37
+ <div
38
+ role="list"
39
+ aria-label={label}
40
+ className={['vl', className].filter(Boolean).join(' ')}
41
+ style={{ height, overflowY: 'auto' }}
42
+ onScroll={handleScroll}
43
+ >
44
+ <div style={{ height: totalHeight, position: 'relative' }}>
45
+ <div style={{ position: 'absolute', top: startIndex * itemHeight, width: '100%' }}>
46
+ {items.slice(startIndex, endIndex).map((item, i) => (
47
+ <div
48
+ key={startIndex + i}
49
+ role="listitem"
50
+ style={{ height: itemHeight, overflow: 'hidden' }}
51
+ >
52
+ {renderItem(item, startIndex + i)}
53
+ </div>
54
+ ))}
55
+ </div>
56
+ </div>
57
+ </div>
58
+ )
59
+ }
@@ -0,0 +1,79 @@
1
+ export interface WaffleSegment {
2
+ label: string
3
+ value: number
4
+ color?: string
5
+ }
6
+
7
+ export interface WaffleChartProps {
8
+ segments: WaffleSegment[]
9
+ total?: number
10
+ cols?: number
11
+ rows?: number
12
+ gap?: number
13
+ ariaLabel?: string
14
+ }
15
+
16
+ const COLORS = ['#006FFF', '#00C7A8', '#F5A623', '#FF6B6B', '#A78BFA']
17
+ const VW = 280
18
+ const LEGEND_H = 24
19
+
20
+ export function WaffleChart({
21
+ segments,
22
+ total,
23
+ cols = 10,
24
+ rows = 10,
25
+ gap = 2,
26
+ ariaLabel = 'Waffle chart',
27
+ }: WaffleChartProps) {
28
+ const totalCells = cols * rows
29
+ const sum = total ?? segments.reduce((s, seg) => s + seg.value, 0)
30
+ const cellSize = (VW - gap * (cols - 1)) / cols
31
+ const gridH = rows * cellSize + gap * (rows - 1)
32
+ const svgH = gridH + LEGEND_H + 6
33
+
34
+ const cellMap: number[] = Array.from({ length: totalCells }, () => -1)
35
+ let cursor = 0
36
+ segments.forEach((seg, si) => {
37
+ const count = Math.round((seg.value / sum) * totalCells)
38
+ for (let j = 0; j < count && cursor < totalCells; j++, cursor++) {
39
+ cellMap[cursor] = si
40
+ }
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
+ {Array.from({ length: totalCells }, (_, i) => {
52
+ const col = i % cols
53
+ const row = Math.floor(i / cols)
54
+ const x = col * (cellSize + gap)
55
+ const y = row * (cellSize + gap)
56
+ const si = cellMap[i]
57
+ const fill =
58
+ si >= 0 ? (segments[si].color ?? COLORS[si % COLORS.length]) : 'rgba(255,255,255,0.06)'
59
+ return <rect key={i} x={x} y={y} width={cellSize} height={cellSize} fill={fill} rx={1} />
60
+ })}
61
+
62
+ {segments.map((seg, si) => {
63
+ const color = seg.color ?? COLORS[si % COLORS.length]
64
+ const pct = Math.round((seg.value / sum) * 100)
65
+ const lx = si * (VW / segments.length)
66
+ const ly = gridH + 8
67
+ return (
68
+ <g key={si}>
69
+ <rect x={lx} y={ly} width={8} height={8} fill={color} rx={1} />
70
+ <text x={lx + 11} y={ly + 7.5} fill="#A4A7B5" fontSize={9} fontFamily="inherit">
71
+ {seg.label} {pct}%
72
+ </text>
73
+ </g>
74
+ )
75
+ })}
76
+ </svg>
77
+ </div>
78
+ )
79
+ }
@@ -0,0 +1,164 @@
1
+ import { useMemo } from 'react'
2
+
3
+ export interface WaterfallBar {
4
+ label: string
5
+ value: number
6
+ type?: 'start' | 'delta' | 'total'
7
+ }
8
+
9
+ export interface WaterfallChartProps {
10
+ bars: WaterfallBar[]
11
+ height?: number
12
+ ariaLabel?: string
13
+ }
14
+
15
+ const VW = 320
16
+ const PAD = { t: 12, r: 8, b: 24, l: 8 }
17
+ const COLOR_START = '#006FFF'
18
+ const COLOR_POS = '#00C875'
19
+ const COLOR_NEG = '#F04949'
20
+ const COLOR_TOTAL = '#A78BFA'
21
+
22
+ export function WaterfallChart({
23
+ bars,
24
+ height = 160,
25
+ ariaLabel = 'Waterfall chart',
26
+ }: WaterfallChartProps) {
27
+ const { rects, connectors, labels } = useMemo(() => {
28
+ if (!bars.length) return { rects: [], connectors: [], labels: [] }
29
+ const chartH = height - PAD.t - PAD.b
30
+ const chartW = VW - PAD.l - PAD.r
31
+ const n = bars.length
32
+ const colW = chartW / n
33
+ const gap = colW * 0.15
34
+ const barW = colW - gap * 2
35
+
36
+ // compute cumulative bases
37
+ const cumBases: number[] = []
38
+ let acc = 0
39
+ for (const b of bars) {
40
+ if (b.type === 'start' || b.type === 'total') {
41
+ cumBases.push(0)
42
+ if (b.type === 'start') acc = b.value
43
+ else acc = b.value
44
+ } else {
45
+ cumBases.push(acc)
46
+ acc += b.value
47
+ }
48
+ }
49
+
50
+ // y domain: include 0 and all tops/bases
51
+ const tops = bars.map((b, i) => {
52
+ if (b.type === 'start' || b.type === 'total') return b.value
53
+ return cumBases[i] + b.value
54
+ })
55
+ const allVals = [0, ...cumBases, ...tops]
56
+ const minV = Math.min(...allVals)
57
+ const maxV = Math.max(...allVals)
58
+ const range = maxV - minV || 1
59
+
60
+ const toY = (v: number) => PAD.t + (1 - (v - minV) / range) * chartH
61
+ const bot = PAD.t + chartH
62
+
63
+ const rects = bars.map((b, i) => {
64
+ const base = cumBases[i]
65
+ const top = b.type === 'start' || b.type === 'total' ? b.value : base + b.value
66
+ const y0 = toY(Math.max(base, top))
67
+ const y1 = toY(Math.min(base, top))
68
+ const x = PAD.l + i * colW + gap
69
+ const color =
70
+ b.type === 'start'
71
+ ? COLOR_START
72
+ : b.type === 'total'
73
+ ? COLOR_TOTAL
74
+ : b.value >= 0
75
+ ? COLOR_POS
76
+ : COLOR_NEG
77
+ return {
78
+ x: x.toFixed(1),
79
+ y: y0.toFixed(1),
80
+ w: barW.toFixed(1),
81
+ h: Math.max(y1 - y0, 1).toFixed(1),
82
+ color,
83
+ topV: top,
84
+ baseV: base,
85
+ }
86
+ })
87
+
88
+ // connector: horizontal line from top of bar i to x of bar i+1
89
+ const connectors = rects.slice(0, -1).map((r, i) => {
90
+ const nextX = PAD.l + (i + 1) * colW + gap
91
+ const nextEndX = nextX + barW
92
+ const topY = parseFloat(r.y)
93
+ return { x1: parseFloat(r.x), x2: nextEndX + barW + gap, y: topY }
94
+ })
95
+
96
+ const labels = bars.map((b, i) => ({
97
+ x: (PAD.l + (i + 0.5) * colW).toFixed(1),
98
+ y: (bot + 10).toFixed(1),
99
+ label: b.label,
100
+ }))
101
+
102
+ return { rects, connectors, labels }
103
+ }, [bars, height])
104
+
105
+ return (
106
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
107
+ <svg
108
+ viewBox={`0 0 ${VW} ${height}`}
109
+ style={{ width: '100%', height: 'auto', display: 'block' }}
110
+ aria-hidden="true"
111
+ focusable="false"
112
+ >
113
+ {[0.25, 0.5, 0.75, 1].map((f, i) => (
114
+ <line
115
+ key={i}
116
+ x1={PAD.l}
117
+ y1={(PAD.t + (1 - f) * (height - PAD.t - PAD.b)).toFixed(1)}
118
+ x2={VW - PAD.r}
119
+ y2={(PAD.t + (1 - f) * (height - PAD.t - PAD.b)).toFixed(1)}
120
+ stroke="rgba(255,255,255,0.06)"
121
+ strokeWidth={1}
122
+ />
123
+ ))}
124
+ {connectors.map((c, i) => (
125
+ <line
126
+ key={i}
127
+ x1={c.x1}
128
+ y1={c.y}
129
+ x2={c.x2}
130
+ y2={c.y}
131
+ stroke="rgba(255,255,255,0.2)"
132
+ strokeWidth={1}
133
+ strokeDasharray="2 2"
134
+ />
135
+ ))}
136
+ {rects.map((r, i) => (
137
+ <rect
138
+ key={i}
139
+ x={r.x}
140
+ y={r.y}
141
+ width={r.w}
142
+ height={r.h}
143
+ fill={r.color}
144
+ fillOpacity={0.85}
145
+ rx={2}
146
+ />
147
+ ))}
148
+ {labels.map((l, i) => (
149
+ <text
150
+ key={i}
151
+ x={l.x}
152
+ y={l.y}
153
+ textAnchor="middle"
154
+ fill="#6E7079"
155
+ fontSize={9}
156
+ fontFamily="inherit"
157
+ >
158
+ {l.label}
159
+ </text>
160
+ ))}
161
+ </svg>
162
+ </div>
163
+ )
164
+ }
@@ -0,0 +1,107 @@
1
+ export interface WordCloudItem {
2
+ word: string
3
+ weight: number
4
+ color?: string
5
+ }
6
+
7
+ export interface WordCloudProps {
8
+ items: WordCloudItem[]
9
+ height?: number
10
+ ariaLabel?: string
11
+ }
12
+
13
+ const VW = 380
14
+ const PAD = 14
15
+ const MIN_FONT = 9
16
+ const MAX_FONT = 28
17
+ const GAP = 6
18
+ const COLORS = [
19
+ '#006FFF',
20
+ '#00C8C8',
21
+ '#F5A623',
22
+ '#7FB6FF',
23
+ '#A878F5',
24
+ '#F56342',
25
+ '#00C875',
26
+ '#FF7BB1',
27
+ ]
28
+
29
+ export function WordCloud({ items, height = 160, ariaLabel = 'Word cloud' }: WordCloudProps) {
30
+ const sorted = [...items].sort((a, b) => b.weight - a.weight)
31
+ const maxW = sorted[0]?.weight ?? 1
32
+ const minW = sorted[sorted.length - 1]?.weight ?? 0
33
+ const range = maxW - minW || 1
34
+
35
+ const words = sorted.map((item, i) => {
36
+ const t = (item.weight - minW) / range
37
+ const fontSize = MIN_FONT + t * (MAX_FONT - MIN_FONT)
38
+ const approxW = item.word.length * fontSize * 0.56 + GAP * 2
39
+ return {
40
+ word: item.word,
41
+ weight: item.weight,
42
+ fontSize,
43
+ approxW,
44
+ color: item.color ?? COLORS[i % COLORS.length],
45
+ bold: fontSize >= 16,
46
+ }
47
+ })
48
+
49
+ const usableW = VW - PAD * 2
50
+ const rows: (typeof words)[] = []
51
+ let row: typeof words = []
52
+ let rowW = 0
53
+
54
+ for (const w of words) {
55
+ if (rowW + w.approxW > usableW && row.length > 0) {
56
+ rows.push(row)
57
+ row = [w]
58
+ rowW = w.approxW
59
+ } else {
60
+ row.push(w)
61
+ rowW += w.approxW
62
+ }
63
+ }
64
+ if (row.length) rows.push(row)
65
+
66
+ const ROW_H = 32
67
+ const totalH = rows.length * ROW_H + PAD * 2
68
+ const svgH = Math.max(height, totalH)
69
+ const offsetY = PAD + (svgH - totalH) / 2
70
+
71
+ const placed = rows.flatMap((r, ri) => {
72
+ const rowUsed = r.reduce((s, w) => s + w.approxW, 0)
73
+ let x = PAD + (usableW - rowUsed) / 2
74
+ const baseY = offsetY + ri * ROW_H
75
+ return r.map((w) => {
76
+ const cx = x + w.approxW / 2
77
+ x += w.approxW
78
+ return { ...w, cx, cy: baseY + w.fontSize }
79
+ })
80
+ })
81
+
82
+ return (
83
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
84
+ <svg
85
+ viewBox={`0 0 ${VW} ${svgH}`}
86
+ style={{ width: '100%', height: 'auto', display: 'block' }}
87
+ aria-hidden="true"
88
+ focusable="false"
89
+ >
90
+ {placed.map((w, i) => (
91
+ <text
92
+ key={i}
93
+ x={w.cx}
94
+ y={w.cy}
95
+ textAnchor="middle"
96
+ fill={w.color}
97
+ fontSize={w.fontSize}
98
+ fontWeight={w.bold ? 600 : 400}
99
+ fontFamily="inherit"
100
+ >
101
+ {w.word}
102
+ </text>
103
+ ))}
104
+ </svg>
105
+ </div>
106
+ )
107
+ }