@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,129 @@
1
+ import { useRef, useState, type DragEvent, type ChangeEvent } from 'react'
2
+
3
+ export interface FileUploadProps {
4
+ label?: string
5
+ hint?: string
6
+ accept?: string
7
+ multiple?: boolean
8
+ disabled?: boolean
9
+ onFiles?: (files: File[]) => void
10
+ }
11
+
12
+ export function FileUpload({
13
+ label = 'Upload file',
14
+ hint,
15
+ accept,
16
+ multiple = false,
17
+ disabled = false,
18
+ onFiles,
19
+ }: FileUploadProps) {
20
+ const inputRef = useRef<HTMLInputElement>(null)
21
+ const [drag, setDrag] = useState(false)
22
+ const [done, setDone] = useState<string | null>(null)
23
+
24
+ function handleFiles(files: FileList | null) {
25
+ if (!files || files.length === 0) return
26
+ const arr = Array.from(files)
27
+ setDone(arr.length === 1 ? arr[0].name : `${arr.length} files selected`)
28
+ onFiles?.(arr)
29
+ }
30
+
31
+ function onChange(e: ChangeEvent<HTMLInputElement>) {
32
+ handleFiles(e.target.files)
33
+ e.target.value = ''
34
+ }
35
+
36
+ function onDragOver(e: DragEvent) {
37
+ e.preventDefault()
38
+ if (!disabled) setDrag(true)
39
+ }
40
+
41
+ function onDragLeave(e: DragEvent) {
42
+ if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) {
43
+ setDrag(false)
44
+ }
45
+ }
46
+
47
+ function onDrop(e: DragEvent) {
48
+ e.preventDefault()
49
+ setDrag(false)
50
+ if (!disabled) handleFiles(e.dataTransfer.files)
51
+ }
52
+
53
+ const cls = [
54
+ 'file-upload',
55
+ drag ? 'file-upload--drag' : '',
56
+ done ? 'file-upload--done' : '',
57
+ disabled ? 'file-upload--disabled' : '',
58
+ ]
59
+ .filter(Boolean)
60
+ .join(' ')
61
+
62
+ return (
63
+ <div
64
+ role="button"
65
+ aria-label={done ?? label}
66
+ aria-disabled={disabled}
67
+ className={cls}
68
+ onDragOver={onDragOver}
69
+ onDragLeave={onDragLeave}
70
+ onDrop={onDrop}
71
+ onClick={() => {
72
+ if (!disabled) inputRef.current?.click()
73
+ }}
74
+ onKeyDown={(e) => {
75
+ if (e.key === 'Enter' || e.key === ' ') {
76
+ e.preventDefault()
77
+ if (!disabled) inputRef.current?.click()
78
+ }
79
+ }}
80
+ tabIndex={disabled ? -1 : 0}
81
+ >
82
+ <input
83
+ ref={inputRef}
84
+ type="file"
85
+ accept={accept}
86
+ multiple={multiple}
87
+ disabled={disabled}
88
+ className="sr-only"
89
+ onChange={onChange}
90
+ aria-label={label}
91
+ tabIndex={-1}
92
+ />
93
+ <svg className="file-upload__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
94
+ {done ? (
95
+ <path
96
+ d="M5 13l4 4L19 7"
97
+ stroke="currentColor"
98
+ strokeWidth="1.5"
99
+ strokeLinecap="round"
100
+ strokeLinejoin="round"
101
+ fill="none"
102
+ />
103
+ ) : (
104
+ <>
105
+ <path
106
+ d="M12 15V3m0 0L8 7m4-4 4 4"
107
+ stroke="currentColor"
108
+ strokeWidth="1.5"
109
+ strokeLinecap="round"
110
+ strokeLinejoin="round"
111
+ fill="none"
112
+ />
113
+ <path
114
+ d="M3 15v4a2 2 0 002 2h14a2 2 0 002-2v-4"
115
+ stroke="currentColor"
116
+ strokeWidth="1.5"
117
+ strokeLinecap="round"
118
+ fill="none"
119
+ />
120
+ </>
121
+ )}
122
+ </svg>
123
+ <div>
124
+ <span className="file-upload__label">{done ?? label}</span>
125
+ {!done && hint && <p className="file-upload__hint">{hint}</p>}
126
+ </div>
127
+ </div>
128
+ )
129
+ }
@@ -0,0 +1,217 @@
1
+ import { useId } from 'react'
2
+
3
+ export interface FilterField {
4
+ key: string
5
+ label: string
6
+ type?: 'text' | 'number' | 'select'
7
+ options?: string[]
8
+ }
9
+
10
+ export interface FilterRule {
11
+ id: string
12
+ field: string
13
+ op: string
14
+ value: string
15
+ }
16
+
17
+ export interface FilterBuilderProps {
18
+ fields: FilterField[]
19
+ value?: FilterRule[]
20
+ conjunction?: 'and' | 'or'
21
+ onChange?: (rules: FilterRule[], conjunction: 'and' | 'or') => void
22
+ 'aria-label'?: string
23
+ }
24
+
25
+ const TEXT_OPS = ['contains', 'not contains', 'equals', 'not equals', 'starts with']
26
+ const NUM_OPS = ['equals', 'not equals', 'greater than', 'less than', 'at least', 'at most']
27
+ const SEL_OPS = ['is', 'is not']
28
+
29
+ function opsFor(type?: string) {
30
+ if (type === 'number') return NUM_OPS
31
+ if (type === 'select') return SEL_OPS
32
+ return TEXT_OPS
33
+ }
34
+
35
+ function defaultOp(type?: string) {
36
+ return opsFor(type)[0]
37
+ }
38
+
39
+ let _id = 0
40
+ function uid() {
41
+ return String(++_id)
42
+ }
43
+
44
+ export function FilterBuilder({
45
+ fields,
46
+ value = [],
47
+ conjunction = 'and',
48
+ onChange,
49
+ 'aria-label': ariaLabel = 'Filter builder',
50
+ }: FilterBuilderProps) {
51
+ const groupId = useId()
52
+
53
+ function update(rules: FilterRule[], conj: 'and' | 'or' = conjunction) {
54
+ onChange?.(rules, conj)
55
+ }
56
+
57
+ function addRule() {
58
+ const f = fields[0]
59
+ update([...value, { id: uid(), field: f?.key ?? '', op: defaultOp(f?.type), value: '' }])
60
+ }
61
+
62
+ function removeRule(id: string) {
63
+ update(value.filter((r) => r.id !== id))
64
+ }
65
+
66
+ function setField(id: string, key: string) {
67
+ const f = fields.find((x) => x.key === key)
68
+ update(
69
+ value.map((r) => (r.id === id ? { ...r, field: key, op: defaultOp(f?.type), value: '' } : r)),
70
+ )
71
+ }
72
+
73
+ function setOp(id: string, op: string) {
74
+ update(value.map((r) => (r.id === id ? { ...r, op } : r)))
75
+ }
76
+
77
+ function setValue(id: string, val: string) {
78
+ update(value.map((r) => (r.id === id ? { ...r, value: val } : r)))
79
+ }
80
+
81
+ return (
82
+ <div className="fb" role="group" aria-label={ariaLabel}>
83
+ <div className="fb-header">
84
+ <span className="fb-label">Filters</span>
85
+ {value.length > 0 && (
86
+ <>
87
+ <span className="fb-conj-label">Match</span>
88
+ <div className="fb-conj" role="group" aria-label="Conjunction">
89
+ {(['and', 'or'] as const).map((c) => (
90
+ <button
91
+ key={c}
92
+ type="button"
93
+ className={`fb-conj-btn${conjunction === c ? ' is-active' : ''}`}
94
+ aria-pressed={conjunction === c}
95
+ onClick={() => update(value, c)}
96
+ >
97
+ {c.toUpperCase()}
98
+ </button>
99
+ ))}
100
+ </div>
101
+ <span className="fb-conj-label">conditions</span>
102
+ </>
103
+ )}
104
+ <button type="button" className="fb-add" onClick={addRule} aria-label="Add filter rule">
105
+ + Add rule
106
+ </button>
107
+ {value.length > 0 && (
108
+ <button
109
+ type="button"
110
+ className="fb-clear"
111
+ onClick={() => update([])}
112
+ aria-label="Clear all filters"
113
+ >
114
+ Clear
115
+ </button>
116
+ )}
117
+ </div>
118
+ {value.length > 0 && (
119
+ <ul className="fb-rules" aria-label="Filter rules">
120
+ {value.map((rule, i) => {
121
+ const f = fields.find((x) => x.key === rule.field)
122
+ const ops = opsFor(f?.type)
123
+ const fieldId = `${groupId}-f${i}`
124
+ const opId = `${groupId}-op${i}`
125
+ const valId = `${groupId}-v${i}`
126
+ return (
127
+ <li key={rule.id} className="fb-rule">
128
+ <label className="sr-only" htmlFor={fieldId}>
129
+ Field {i + 1}
130
+ </label>
131
+ <select
132
+ id={fieldId}
133
+ className="fb-select"
134
+ value={rule.field}
135
+ onChange={(e) => setField(rule.id, e.target.value)}
136
+ aria-label={`Rule ${i + 1} field`}
137
+ >
138
+ {fields.map((x) => (
139
+ <option key={x.key} value={x.key}>
140
+ {x.label}
141
+ </option>
142
+ ))}
143
+ </select>
144
+ <label className="sr-only" htmlFor={opId}>
145
+ Operator {i + 1}
146
+ </label>
147
+ <select
148
+ id={opId}
149
+ className="fb-select fb-select--op"
150
+ value={rule.op}
151
+ onChange={(e) => setOp(rule.id, e.target.value)}
152
+ aria-label={`Rule ${i + 1} operator`}
153
+ >
154
+ {ops.map((op) => (
155
+ <option key={op} value={op}>
156
+ {op}
157
+ </option>
158
+ ))}
159
+ </select>
160
+ <label className="sr-only" htmlFor={valId}>
161
+ Value {i + 1}
162
+ </label>
163
+ {f?.type === 'select' ? (
164
+ <select
165
+ id={valId}
166
+ className="fb-select fb-select--val"
167
+ value={rule.value}
168
+ onChange={(e) => setValue(rule.id, e.target.value)}
169
+ aria-label={`Rule ${i + 1} value`}
170
+ >
171
+ <option value="">Any</option>
172
+ {f.options?.map((o) => (
173
+ <option key={o} value={o}>
174
+ {o}
175
+ </option>
176
+ ))}
177
+ </select>
178
+ ) : (
179
+ <input
180
+ id={valId}
181
+ className="fb-input"
182
+ type={f?.type === 'number' ? 'number' : 'text'}
183
+ value={rule.value}
184
+ placeholder="Value…"
185
+ onChange={(e) => setValue(rule.id, e.target.value)}
186
+ aria-label={`Rule ${i + 1} value`}
187
+ />
188
+ )}
189
+ <button
190
+ type="button"
191
+ className="fb-remove icon-btn"
192
+ onClick={() => removeRule(rule.id)}
193
+ aria-label={`Remove rule ${i + 1}`}
194
+ >
195
+ <svg
196
+ viewBox="0 0 12 12"
197
+ width="10"
198
+ height="10"
199
+ aria-hidden="true"
200
+ fill="currentColor"
201
+ >
202
+ <path
203
+ d="M1 1l10 10M11 1L1 11"
204
+ stroke="currentColor"
205
+ strokeWidth="1.8"
206
+ strokeLinecap="round"
207
+ />
208
+ </svg>
209
+ </button>
210
+ </li>
211
+ )
212
+ })}
213
+ </ul>
214
+ )}
215
+ </div>
216
+ )
217
+ }
@@ -0,0 +1,119 @@
1
+ import { useMemo } from 'react'
2
+
3
+ export interface FlameNode {
4
+ label: string
5
+ value?: number
6
+ color?: string
7
+ children?: FlameNode[]
8
+ }
9
+
10
+ export interface FlameGraphProps {
11
+ root: FlameNode
12
+ height?: number
13
+ ariaLabel?: string
14
+ }
15
+
16
+ interface FlatCell {
17
+ x: number
18
+ y: number
19
+ w: number
20
+ h: number
21
+ label: string
22
+ color: string
23
+ depth: number
24
+ }
25
+
26
+ const PALETTE = ['#006FFF', '#FF7B2E', '#F5A623', '#FB923C', '#F97316', '#EF4444', '#A78BFA']
27
+ const VW = 340
28
+ const GAP = 1.5
29
+
30
+ function nodeValue(node: FlameNode): number {
31
+ if (node.children?.length) return node.children.reduce((s, c) => s + nodeValue(c), 0)
32
+ return node.value ?? 1
33
+ }
34
+
35
+ function treeDepth(node: FlameNode): number {
36
+ if (!node.children?.length) return 0
37
+ return 1 + Math.max(...node.children.map(treeDepth))
38
+ }
39
+
40
+ function flatten(
41
+ node: FlameNode,
42
+ x: number,
43
+ depth: number,
44
+ w: number,
45
+ levelH: number,
46
+ totalHeight: number,
47
+ inheritColor: string,
48
+ colorIdx: { n: number },
49
+ cells: FlatCell[],
50
+ ): void {
51
+ let color = node.color ?? ''
52
+ if (!color) {
53
+ if (depth === 0) color = 'rgba(255,255,255,0.10)'
54
+ else if (depth === 1) color = PALETTE[colorIdx.n++ % PALETTE.length]
55
+ else color = inheritColor
56
+ }
57
+ const y = totalHeight - (depth + 1) * levelH
58
+ cells.push({ x, y, w, h: levelH, label: node.label, color, depth })
59
+ if (node.children?.length && w > 0) {
60
+ const total = nodeValue(node)
61
+ let cx = x
62
+ for (const child of node.children) {
63
+ const cw = total > 0 ? (nodeValue(child) / total) * w : 0
64
+ flatten(child, cx, depth + 1, cw, levelH, totalHeight, color, colorIdx, cells)
65
+ cx += cw
66
+ }
67
+ }
68
+ }
69
+
70
+ export function FlameGraph({ root, height = 160, ariaLabel = 'Flame graph' }: FlameGraphProps) {
71
+ const cells = useMemo(() => {
72
+ const depth = treeDepth(root)
73
+ const levelH = height / (depth + 1)
74
+ const result: FlatCell[] = []
75
+ flatten(root, 0, 0, VW, levelH, height, PALETTE[0], { n: 0 }, result)
76
+ return result
77
+ }, [root, height])
78
+
79
+ if (!cells.length) return null
80
+
81
+ return (
82
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
83
+ <svg
84
+ viewBox={`0 0 ${VW} ${height}`}
85
+ style={{ width: '100%', height: 'auto', display: 'block' }}
86
+ aria-hidden="true"
87
+ focusable="false"
88
+ >
89
+ {cells.map((cell, i) =>
90
+ cell.w > GAP * 2 ? (
91
+ <g key={i}>
92
+ <rect
93
+ x={cell.x + GAP / 2}
94
+ y={cell.y + GAP / 2}
95
+ width={Math.max(0, cell.w - GAP)}
96
+ height={Math.max(0, cell.h - GAP)}
97
+ fill={cell.color}
98
+ fillOpacity={cell.depth === 0 ? 1 : cell.depth === 1 ? 0.7 : 0.85}
99
+ rx={2}
100
+ />
101
+ {cell.w > 32 && (
102
+ <text
103
+ x={cell.x + cell.w / 2}
104
+ y={cell.y + cell.h / 2 + 4}
105
+ textAnchor="middle"
106
+ fontSize={Math.min(10, (cell.w - GAP) / 6, cell.h - 4)}
107
+ fill="#fff"
108
+ fontFamily="inherit"
109
+ >
110
+ {cell.label}
111
+ </text>
112
+ )}
113
+ </g>
114
+ ) : null,
115
+ )}
116
+ </svg>
117
+ </div>
118
+ )
119
+ }
@@ -0,0 +1,149 @@
1
+ export interface FDNode {
2
+ id: string
3
+ label?: string
4
+ color?: string
5
+ r?: number
6
+ }
7
+
8
+ export interface FDLink {
9
+ source: string
10
+ target: string
11
+ }
12
+
13
+ export interface ForceGraphProps {
14
+ nodes: FDNode[]
15
+ links: FDLink[]
16
+ height?: number
17
+ ariaLabel?: string
18
+ }
19
+
20
+ const PALETTE = ['#006FFF', '#00C875', '#FF7B7B', '#F5C26B', '#A78BFA', '#34D399']
21
+ const VW = 340
22
+ const ITERS = 300
23
+
24
+ type State = { x: number; y: number; vx: number; vy: number }
25
+
26
+ function layout(
27
+ nodes: FDNode[],
28
+ links: FDLink[],
29
+ W: number,
30
+ H: number,
31
+ ): { x: number; y: number }[] {
32
+ const idxById: Record<string, number> = {}
33
+ nodes.forEach((n, i) => {
34
+ idxById[n.id] = i
35
+ })
36
+ const edges = links
37
+ .map((l) => [idxById[l.source], idxById[l.target]] as [number, number])
38
+ .filter(([a, b]) => a !== undefined && b !== undefined)
39
+
40
+ const cx = W / 2
41
+ const cy = H / 2
42
+ const step = (2 * Math.PI) / nodes.length
43
+ const r0 = Math.min(W, H) * 0.35
44
+
45
+ const state: State[] = nodes.map((_, i) => ({
46
+ x: cx + r0 * Math.cos(i * step),
47
+ y: cy + r0 * Math.sin(i * step),
48
+ vx: 0,
49
+ vy: 0,
50
+ }))
51
+
52
+ for (let iter = 0; iter < ITERS; iter++) {
53
+ for (const [a, b] of edges) {
54
+ const dx = state[b].x - state[a].x
55
+ const dy = state[b].y - state[a].y
56
+ const d = Math.sqrt(dx * dx + dy * dy) || 1
57
+ const f = (0.04 * (d - 80)) / d
58
+ state[a].vx += dx * f
59
+ state[a].vy += dy * f
60
+ state[b].vx -= dx * f
61
+ state[b].vy -= dy * f
62
+ }
63
+ for (let i = 0; i < state.length; i++) {
64
+ for (let j = i + 1; j < state.length; j++) {
65
+ const dx = state[j].x - state[i].x
66
+ const dy = state[j].y - state[i].y
67
+ const d2 = dx * dx + dy * dy || 1
68
+ const d = Math.sqrt(d2)
69
+ const f = 600 / d2
70
+ state[i].vx -= (dx / d) * f
71
+ state[i].vy -= (dy / d) * f
72
+ state[j].vx += (dx / d) * f
73
+ state[j].vy += (dy / d) * f
74
+ }
75
+ }
76
+ for (const s of state) {
77
+ s.vx += (cx - s.x) * 0.008
78
+ s.vy += (cy - s.y) * 0.008
79
+ s.vx *= 0.85
80
+ s.vy *= 0.85
81
+ s.x += s.vx
82
+ s.y += s.vy
83
+ }
84
+ }
85
+
86
+ return state.map((s) => ({ x: s.x, y: s.y }))
87
+ }
88
+
89
+ export function ForceGraph({
90
+ nodes,
91
+ links,
92
+ height = 260,
93
+ ariaLabel = 'Force-directed network graph',
94
+ }: ForceGraphProps) {
95
+ if (!nodes.length) return null
96
+
97
+ const pos = layout(nodes, links, VW, height)
98
+ const idxById: Record<string, number> = {}
99
+ nodes.forEach((n, i) => {
100
+ idxById[n.id] = i
101
+ })
102
+
103
+ return (
104
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
105
+ <svg
106
+ viewBox={`0 0 ${VW} ${height}`}
107
+ style={{ width: '100%', height: 'auto', display: 'block' }}
108
+ aria-hidden="true"
109
+ focusable="false"
110
+ >
111
+ {links.map((l, li) => {
112
+ const si = idxById[l.source]
113
+ const ti = idxById[l.target]
114
+ if (si === undefined || ti === undefined) return null
115
+ return (
116
+ <line
117
+ key={li}
118
+ x1={pos[si].x}
119
+ y1={pos[si].y}
120
+ x2={pos[ti].x}
121
+ y2={pos[ti].y}
122
+ stroke="rgba(255,255,255,0.12)"
123
+ strokeWidth={1.5}
124
+ />
125
+ )
126
+ })}
127
+ {nodes.map((n, ni) => {
128
+ const r = n.r ?? 10
129
+ const color = n.color ?? PALETTE[ni % PALETTE.length]
130
+ return (
131
+ <g key={ni}>
132
+ <circle cx={pos[ni].x} cy={pos[ni].y} r={r} fill={color} fillOpacity={0.85} />
133
+ <text
134
+ x={pos[ni].x}
135
+ y={pos[ni].y + r + 11}
136
+ fill="#A4A7B5"
137
+ fontSize={8}
138
+ textAnchor="middle"
139
+ fontFamily="inherit"
140
+ >
141
+ {n.label ?? n.id}
142
+ </text>
143
+ </g>
144
+ )
145
+ })}
146
+ </svg>
147
+ </div>
148
+ )
149
+ }
@@ -0,0 +1,79 @@
1
+ import { useMemo } from 'react'
2
+
3
+ export interface FunnelSegment {
4
+ label: string
5
+ value: number
6
+ color?: string
7
+ }
8
+
9
+ export interface FunnelChartProps {
10
+ segments: FunnelSegment[]
11
+ height?: number
12
+ ariaLabel?: string
13
+ }
14
+
15
+ const VW = 320
16
+ const PAD = { t: 4, r: 8, b: 4, l: 8 }
17
+ const SEP = 2
18
+ const DEFAULT_COLORS = ['#006FFF', '#0092FF', '#00B4C2', '#00C875', '#F5A623']
19
+
20
+ export function FunnelChart({
21
+ segments,
22
+ height = 160,
23
+ ariaLabel = 'Funnel chart',
24
+ }: FunnelChartProps) {
25
+ const shapes = useMemo(() => {
26
+ if (!segments.length) return []
27
+ const maxVal = Math.max(...segments.map((s) => s.value))
28
+ const n = segments.length
29
+ const chartH = height - PAD.t - PAD.b
30
+ const slotH = (chartH - SEP * (n - 1)) / n
31
+ const chartW = VW - PAD.l - PAD.r
32
+ const cx = PAD.l + chartW / 2
33
+ const widths = segments.map((s) => (s.value / (maxVal || 1)) * chartW)
34
+
35
+ return segments.map((s, i) => {
36
+ const tw = widths[i]
37
+ const bw = i + 1 < n ? widths[i + 1] : widths[i] * 0.7
38
+ const y0 = PAD.t + i * (slotH + SEP)
39
+ const y1 = y0 + slotH
40
+ return {
41
+ points: `${(cx - tw / 2).toFixed(1)},${y0.toFixed(1)} ${(cx + tw / 2).toFixed(1)},${y0.toFixed(1)} ${(cx + bw / 2).toFixed(1)},${y1.toFixed(1)} ${(cx - bw / 2).toFixed(1)},${y1.toFixed(1)}`,
42
+ color: s.color ?? DEFAULT_COLORS[i % DEFAULT_COLORS.length],
43
+ label: s.label,
44
+ value: s.value,
45
+ cx: cx.toFixed(1),
46
+ cy: ((y0 + y1) / 2).toFixed(1),
47
+ }
48
+ })
49
+ }, [segments, height])
50
+
51
+ return (
52
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
53
+ <svg
54
+ viewBox={`0 0 ${VW} ${height}`}
55
+ style={{ width: '100%', height: 'auto', display: 'block' }}
56
+ aria-hidden="true"
57
+ focusable="false"
58
+ >
59
+ {shapes.map((s, i) => (
60
+ <g key={i}>
61
+ <polygon points={s.points} fill={s.color} fillOpacity={0.75} />
62
+ <text
63
+ x={s.cx}
64
+ y={s.cy}
65
+ textAnchor="middle"
66
+ dominantBaseline="central"
67
+ fill="rgba(255,255,255,0.9)"
68
+ fontSize={10}
69
+ fontFamily="inherit"
70
+ fontWeight={500}
71
+ >
72
+ {s.label} · {s.value}
73
+ </text>
74
+ </g>
75
+ ))}
76
+ </svg>
77
+ </div>
78
+ )
79
+ }