@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,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
|
+
}
|