@w5-ui/react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/package.json +59 -0
- package/src/components/Accordion.tsx +65 -0
- package/src/components/ActionMenu.tsx +131 -0
- package/src/components/ActivityFeed.tsx +77 -0
- package/src/components/Alert.tsx +33 -0
- package/src/components/AnnotatedTimeSeries.tsx +148 -0
- package/src/components/AppShell.tsx +158 -0
- package/src/components/ArcDiagram.tsx +114 -0
- package/src/components/AreaChart.tsx +101 -0
- package/src/components/Avatar.tsx +37 -0
- package/src/components/AvatarGroup.tsx +36 -0
- package/src/components/Badge.tsx +36 -0
- package/src/components/Banner.tsx +49 -0
- package/src/components/BarChart.tsx +106 -0
- package/src/components/BeeswarmChart.tsx +142 -0
- package/src/components/BoxPlot.tsx +142 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/BubbleChart.tsx +115 -0
- package/src/components/BulletChart.tsx +121 -0
- package/src/components/BumpChart.tsx +127 -0
- package/src/components/Button.tsx +61 -0
- package/src/components/CIDRInput.tsx +191 -0
- package/src/components/CalendarHeatmap.tsx +117 -0
- package/src/components/Callout.tsx +82 -0
- package/src/components/CandlestickChart.tsx +113 -0
- package/src/components/Card.tsx +27 -0
- package/src/components/Carousel.tsx +78 -0
- package/src/components/Checkbox.tsx +42 -0
- package/src/components/ChordDiagram.tsx +133 -0
- package/src/components/CirclePacking.tsx +189 -0
- package/src/components/CodeBlock.tsx +27 -0
- package/src/components/ColorPicker.tsx +120 -0
- package/src/components/ColumnToggle.tsx +106 -0
- package/src/components/Combobox.tsx +211 -0
- package/src/components/CommandPalette.tsx +168 -0
- package/src/components/ConfirmDialog.tsx +114 -0
- package/src/components/ContextMenu.tsx +127 -0
- package/src/components/ContextualHelp.tsx +69 -0
- package/src/components/ContourPlot.tsx +255 -0
- package/src/components/CopyButton.tsx +47 -0
- package/src/components/CorrelationMatrix.tsx +107 -0
- package/src/components/CountUp.tsx +70 -0
- package/src/components/CumulativeDistribution.tsx +155 -0
- package/src/components/DatePicker.tsx +315 -0
- package/src/components/DateRangePicker.tsx +327 -0
- package/src/components/Dendrogram.tsx +150 -0
- package/src/components/Donut.tsx +101 -0
- package/src/components/DotPlot.tsx +195 -0
- package/src/components/Drawer.tsx +81 -0
- package/src/components/DualAxisChart.tsx +209 -0
- package/src/components/DumbbellChart.tsx +102 -0
- package/src/components/DurationInput.tsx +144 -0
- package/src/components/EmptyState.tsx +40 -0
- package/src/components/ErrorBandChart.tsx +124 -0
- package/src/components/ExpandableRow.tsx +58 -0
- package/src/components/FileUpload.tsx +129 -0
- package/src/components/FilterBuilder.tsx +217 -0
- package/src/components/FlameGraph.tsx +119 -0
- package/src/components/ForceGraph.tsx +149 -0
- package/src/components/FunnelChart.tsx +79 -0
- package/src/components/GanttChart.tsx +96 -0
- package/src/components/Gauge.tsx +71 -0
- package/src/components/GroupedList.tsx +125 -0
- package/src/components/HealthBar.tsx +25 -0
- package/src/components/HeatMap.tsx +106 -0
- package/src/components/HexbinChart.tsx +152 -0
- package/src/components/Histogram.tsx +121 -0
- package/src/components/HorizonChart.tsx +110 -0
- package/src/components/HoverCard.tsx +52 -0
- package/src/components/IPInput.tsx +147 -0
- package/src/components/IcicleChart.tsx +118 -0
- package/src/components/InlineEdit.tsx +119 -0
- package/src/components/Input.tsx +94 -0
- package/src/components/InputGroup.tsx +21 -0
- package/src/components/JsonViewer.tsx +116 -0
- package/src/components/KVTable.tsx +29 -0
- package/src/components/KanbanBoard.tsx +171 -0
- package/src/components/Kbd.tsx +22 -0
- package/src/components/LineChart.tsx +112 -0
- package/src/components/LogViewer.tsx +102 -0
- package/src/components/LollipopChart.tsx +110 -0
- package/src/components/MACInput.tsx +164 -0
- package/src/components/MarimekkoChart.tsx +143 -0
- package/src/components/MatrixChart.tsx +118 -0
- package/src/components/Menubar.tsx +194 -0
- package/src/components/MirroredBarChart.tsx +148 -0
- package/src/components/Modal.tsx +87 -0
- package/src/components/MultiSelect.tsx +232 -0
- package/src/components/NightingaleChart.tsx +101 -0
- package/src/components/NotificationPanel.tsx +187 -0
- package/src/components/NumberInput.tsx +129 -0
- package/src/components/OTPInput.tsx +135 -0
- package/src/components/Pagination.tsx +73 -0
- package/src/components/ParallelCoordinates.tsx +149 -0
- package/src/components/ParetoChart.tsx +159 -0
- package/src/components/PasswordInput.tsx +112 -0
- package/src/components/PieChart.tsx +109 -0
- package/src/components/Pill.tsx +26 -0
- package/src/components/PolarHeatmap.tsx +118 -0
- package/src/components/Popover.tsx +103 -0
- package/src/components/ProgressBar.tsx +33 -0
- package/src/components/PunchCard.tsx +93 -0
- package/src/components/QuadrantChart.tsx +256 -0
- package/src/components/RadarChart.tsx +119 -0
- package/src/components/RadialBarChart.tsx +103 -0
- package/src/components/RadioGroup.tsx +69 -0
- package/src/components/RangeSlider.tsx +116 -0
- package/src/components/RankedList.tsx +61 -0
- package/src/components/ResizablePanel.tsx +122 -0
- package/src/components/RidgelinePlot.tsx +155 -0
- package/src/components/SankeyDiagram.tsx +156 -0
- package/src/components/ScatterPlot.tsx +96 -0
- package/src/components/SegmentedControl.tsx +78 -0
- package/src/components/Select.tsx +158 -0
- package/src/components/SelectionToolbar.tsx +55 -0
- package/src/components/Signal.tsx +19 -0
- package/src/components/Skeleton.tsx +37 -0
- package/src/components/SkipLink.tsx +19 -0
- package/src/components/Slider.tsx +74 -0
- package/src/components/SlopeChart.tsx +205 -0
- package/src/components/SortableList.tsx +130 -0
- package/src/components/SortableTable.tsx +46 -0
- package/src/components/Sparkline.tsx +50 -0
- package/src/components/SparklineMatrix.tsx +117 -0
- package/src/components/Spinner.tsx +8 -0
- package/src/components/SplitButton.tsx +151 -0
- package/src/components/Spoiler.tsx +47 -0
- package/src/components/StackedBarChart.tsx +145 -0
- package/src/components/StackedProgress.tsx +47 -0
- package/src/components/StarRating.tsx +103 -0
- package/src/components/Stat.tsx +48 -0
- package/src/components/StatusIndicator.tsx +26 -0
- package/src/components/StepChart.tsx +130 -0
- package/src/components/Stepper.tsx +51 -0
- package/src/components/StreamGraph.tsx +97 -0
- package/src/components/StripeChart.tsx +76 -0
- package/src/components/SunburstChart.tsx +142 -0
- package/src/components/SwitchPortGrid.tsx +59 -0
- package/src/components/Tabs.tsx +91 -0
- package/src/components/Tag.tsx +37 -0
- package/src/components/TagInput.tsx +99 -0
- package/src/components/Textarea.tsx +9 -0
- package/src/components/ThresholdAreaChart.tsx +113 -0
- package/src/components/TimePicker.tsx +144 -0
- package/src/components/TimeRange.tsx +152 -0
- package/src/components/Timeline.tsx +31 -0
- package/src/components/Toast.tsx +72 -0
- package/src/components/Toggle.tsx +68 -0
- package/src/components/ToggleGroup.tsx +57 -0
- package/src/components/Tooltip.tsx +25 -0
- package/src/components/TransferList.tsx +227 -0
- package/src/components/TreeMap.tsx +126 -0
- package/src/components/TreeView.tsx +221 -0
- package/src/components/UptimeTimeline.tsx +138 -0
- package/src/components/VennDiagram.tsx +151 -0
- package/src/components/ViolinPlot.tsx +158 -0
- package/src/components/VirtualList.tsx +59 -0
- package/src/components/WaffleChart.tsx +79 -0
- package/src/components/WaterfallChart.tsx +164 -0
- package/src/components/WordCloud.tsx +107 -0
- package/src/icons.tsx +241 -0
- package/src/index.ts +160 -0
- package/src/styles.css +2 -0
|
@@ -0,0 +1,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
|
+
}
|