1import { useRef, useState } from 'react'
2import type { BoundResult, CodecResult } from '../compress/codecs'
3import { useWidth } from './useWidth'
5const GROUPS = ['no prefilter', 'delta', 'LPC']
6const CODER_NAMES = ['zlib', 'zstd', 'ANS']
7const CODER_VARS = ['var(--series-1)', 'var(--series-2)', 'var(--series-3)']
8const BOUND_LABEL = 'entropy limit'
10const LABEL_W = 100
11const RIGHT_PAD = 64
12const AXIS_H = 26
13const GROUP_H = 20
14const ROW_H = 24
15const BAR_H = 16
16const ROWS_PER_GROUP = 4
18type Metric = 'bits' | 'ratio'
20/** One drawn bar: a measured codec size, or the entropy limit for its group. */
21interface Row {
22 key: string
23 label: string
24 name: string
25 note: string
26 bytes: number
27 bitsPerSample: number
28 ratio: number
29 isBound: boolean
30 color: string
31}
33interface Tip {
34 x: number
35 y: number
36 row: Row
37}
39function axisTicks(max: number): number[] {
40 const step = max > 24 ? 8 : max > 12 ? 4 : max > 6 ? 2 : max > 3 ? 1 : 0.5
41 const out: number[] = []
42 for (let v = 0; v <= max + 1e-9; v += step) out.push(v)
43 return out
44}
46/** A bar whose data-end is rounded (4px) while the baseline end stays square. */
47function barPath(x0: number, y: number, len: number, h: number): string {
48 const r = Math.min(4, len)
49 return `M${x0},${y} h${len - r} a${r},${r} 0 0 1 ${r},${r} v${h - 2 * r} a${r},${r} 0 0 1 ${-r},${r} h${-(len - r)} z`
50}
52/** Codec rows then the entropy limit, group by group. */
53function buildRows(results: CodecResult[], bounds: BoundResult[]): Row[] {
54 const rows: Row[] = []
55 GROUPS.forEach((group, g) => {
56 CODER_NAMES.forEach((coder, c) => {
57 const r = results[g * 3 + c]
58 if (!r) return
59 rows.push({
60 key: r.codec,
61 label: coder,
62 name: r.codec,
63 note: r.note,
64 bytes: r.bytes,
65 bitsPerSample: r.bitsPerSample,
66 ratio: r.ratio,
67 isBound: false,
68 color: CODER_VARS[c],
69 })
70 })
71 const b = bounds.find(x => x.group === group)
72 if (b) {
73 rows.push({
74 key: `${group}-bound`,
75 label: BOUND_LABEL,
76 name: `${BOUND_LABEL} (${group})`,
77 note: b.note,
78 bytes: b.bytes,
79 bitsPerSample: b.bitsPerSample,
80 ratio: b.ratio,
81 isBound: true,
82 color: 'var(--muted)',
83 })
84 }
85 })
86 return rows
87}
89export default function CompressionChart(props: {
90 results: CodecResult[]
91 bounds: BoundResult[]
92 theoryBits: number
93 computing: boolean
94}) {
95 const ref = useRef<HTMLDivElement>(null)
96 const width = useWidth(ref, 720)
97 const [metric, setMetric] = useState<Metric>('ratio')
98 const [tip, setTip] = useState<Tip | null>(null)
99 const [hovered, setHovered] = useState<string | null>(null)
101 const { results, bounds, theoryBits } = props
102 if (results.length === 0) {
103 return <p className="card-note">Computing compression on the first block…</p>
104 }
106 const rows = buildRows(results, bounds)
107 const value = (r: { bitsPerSample: number; ratio: number }) =>
108 metric === 'bits' ? r.bitsPerSample : r.ratio
109 const theoryValue = metric === 'bits' ? theoryBits : theoryBits > 0 ? 16 / theoryBits : NaN
110 const theoryVisible = Number.isFinite(theoryValue) && theoryValue > 0
111 const xMax =
112 metric === 'bits'
113 ? Math.max(16, ...rows.map(value), theoryVisible ? theoryValue : 0) * 1.02
114 : Math.max(...rows.map(value), theoryVisible ? theoryValue : 0) * 1.1
116 const plotW = width - LABEL_W - RIGHT_PAD
117 const height = AXIS_H + GROUPS.length * (GROUP_H + ROWS_PER_GROUP * ROW_H) + 6
118 const xOf = (v: number) => LABEL_W + (v / xMax) * plotW
119 const rowY = (i: number) =>
120 AXIS_H +
121 Math.floor(i / ROWS_PER_GROUP) * (GROUP_H + ROWS_PER_GROUP * ROW_H) +
122 GROUP_H +
123 (i % ROWS_PER_GROUP) * ROW_H
125 const fmt = (r: Row) => (metric === 'bits' ? r.bitsPerSample.toFixed(2) : `${r.ratio.toFixed(2)}×`)
127 const onBarMove = (e: React.PointerEvent, row: Row) => {
128 const box = ref.current!.getBoundingClientRect()
129 setTip({ x: e.clientX - box.left, y: e.clientY - box.top, row })
130 }
132 const theoryX = theoryVisible ? xOf(theoryValue) : 0
133 const theoryLabel =
134 metric === 'bits' ? `R = ${theoryBits.toFixed(2)}` : `R ⇒ ${(16 / theoryBits).toFixed(2)}×`
136 return (
137 <div>
138 <div className="chart-header">
139 <div>
140 <div className="segmented" role="group" aria-label="metric">
141 <button className={metric === 'ratio' ? 'active' : ''} onClick={() => setMetric('ratio')}>
142 compression ratio
143 </button>
144 <button className={metric === 'bits' ? 'active' : ''} onClick={() => setMetric('bits')}>
145 bits / sample
146 </button>
147 </div>
148 <span className="metric-hint">
149 {metric === 'bits' ? 'lower is better' : 'vs int16 — higher is better'}
150 </span>
151 </div>
152 <div className="legend">
153 {CODER_NAMES.map((name, i) => (
154 <span key={name}>
155 <span className="swatch" style={{ background: CODER_VARS[i] }} />
156 {name}
157 </span>
158 ))}
159 <span>
160 <span className="swatch hollow" />
161 entropy limit (not achieved)
162 </span>
163 </div>
164 </div>
165 <div className={`chart-body${props.computing ? ' computing' : ''}`} ref={ref}>
166 {props.computing && <span className="computing-badge">computing…</span>}
167 <svg width={width} height={height}>
168 {axisTicks(xMax).map(v => (
169 <g key={v}>
170 <line x1={xOf(v)} x2={xOf(v)} y1={AXIS_H - 6} y2={height - 4} stroke="var(--grid)" strokeWidth={1} />
171 <text x={xOf(v)} y={AXIS_H - 10} textAnchor="middle" className="axis-tick">
172 {+v.toFixed(1)}
173 </text>
174 </g>
175 ))}
176 <line x1={xOf(0)} x2={xOf(0)} y1={AXIS_H - 6} y2={height - 4} stroke="var(--baseline)" strokeWidth={1} />
177 {GROUPS.map((g, gi) => (
178 <text
179 key={g}
180 x={0}
181 y={AXIS_H + gi * (GROUP_H + ROWS_PER_GROUP * ROW_H) + 15}
182 className="bar-group-label"
183 >
184 {g}
185 </text>
186 ))}
187 {rows.map((r, i) => {
188 const y = rowY(i)
189 const len = Math.max(1, (value(r) / xMax) * plotW)
190 return (
191 <g key={r.key} opacity={hovered === null || hovered === r.key ? 1 : 0.45}>
192 <text
193 x={8}
194 y={y + BAR_H / 2 + 4}
195 className={r.isBound ? 'bar-row-label bound' : 'bar-row-label'}
196 >
197 {r.label}
198 </text>
199 {r.isBound ? (
200 // Hollow: a limit nobody reached, not a measured size.
201 <path
202 d={barPath(xOf(0), y + 1, len, BAR_H - 2)}
203 fill="var(--muted)"
204 fillOpacity={0.12}
205 stroke="var(--muted)"
206 strokeWidth={1.25}
207 />
208 ) : (
209 <path d={barPath(xOf(0), y, len, BAR_H)} fill={r.color} />
210 )}
211 <text
212 x={xOf(0) + len + 6}
213 y={y + BAR_H / 2 + 4}
214 className={r.isBound ? 'bar-value bound' : 'bar-value'}
215 >
216 {fmt(r)}
217 </text>
218 <rect
219 x={0}
220 y={y - (ROW_H - BAR_H) / 2}
221 width={width}
222 height={ROW_H}
223 fill="transparent"
224 onPointerMove={e => {
225 setHovered(r.key)
226 onBarMove(e, r)
227 }}
228 onPointerLeave={() => {
229 setHovered(null)
230 setTip(null)
231 }}
232 />
233 </g>
234 )
235 })}
236 {theoryVisible && (
237 <g>
238 <line
239 x1={theoryX}
240 x2={theoryX}
241 y1={AXIS_H - 2}
242 y2={height - 4}
243 stroke="var(--ink-2)"
244 strokeWidth={1.5}
245 strokeDasharray="5 4"
246 />
247 <text
248 x={theoryX + (theoryX > width - 150 ? -6 : 6)}
249 y={AXIS_H + 10}
250 textAnchor={theoryX > width - 150 ? 'end' : 'start'}
251 className="bar-value"
252 fill="var(--ink)"
253 >
254 {theoryLabel}
255 </text>
256 </g>
257 )}
258 </svg>
259 {tip && (
260 <div className="viz-tooltip" style={{ left: tip.x + 14, top: tip.y - 8 }}>
261 <div>
262 <span className="tip-value">
263 {tip.row.bitsPerSample.toFixed(3)} bits/sample · {tip.row.ratio.toFixed(2)}×
264 </span>{' '}
265 <span className="tip-label">{tip.row.name}</span>
266 </div>
267 <div className="tip-label">
268 {tip.row.isBound ? 'equivalent to ' : ''}
269 {tip.row.bytes.toLocaleString()} bytes · {tip.row.note}
270 </div>
271 </div>
272 )}
273 </div>
274 <details className="chart-table">
275 <summary>Table view</summary>
276 <table>
277 <thead>
278 <tr>
279 <th>method</th>
280 <th>bytes</th>
281 <th>bits/sample</th>
282 <th>ratio vs int16</th>
283 </tr>
284 </thead>
285 <tbody>
286 {rows.map(r => (
287 <tr key={r.key}>
288 <td>{r.name}</td>
289 <td>{r.bytes.toLocaleString()}</td>
290 <td>{r.bitsPerSample.toFixed(3)}</td>
291 <td>{r.ratio.toFixed(3)}</td>
292 </tr>
293 ))}
294 {theoryBits > 0 && (
295 <tr>
296 <td>theory: rate R</td>
297 <td>—</td>
298 <td>{theoryBits.toFixed(3)}</td>
299 <td>{(16 / theoryBits).toFixed(3)}</td>
300 </tr>
301 )}
302 </tbody>
303 </table>
304 </details>
305 </div>
306 )
307}