1import { useRef, useState } from 'react'
2import type { 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 COND_VAR = 'var(--series-4)'
10const LABEL_W = 100
11const RIGHT_PAD = 64
12const AXIS_H = 26
13/** Strip under the axis reserved for the two reference-line labels, so they
14 * never sit on top of the bars or each other. */
15const REF_BAND = 30
16const GROUP_H = 20
17const ROW_H = 24
18const BAR_H = 16
20type Metric = 'bits' | 'ratio'
22/** One drawn bar: a measured codec size. */
23interface Row {
24 key: string
25 label: string
26 name: string
27 note: string
28 bytes: number
29 bitsPerSample: number
30 ratio: number
31 color: string
32}
34interface Tip {
35 x: number
36 y: number
37 row: Row
38}
40function axisTicks(max: number): number[] {
41 const step = max > 24 ? 8 : max > 12 ? 4 : max > 6 ? 2 : max > 3 ? 1 : 0.5
42 const out: number[] = []
43 for (let v = 0; v <= max + 1e-9; v += step) out.push(v)
44 return out
45}
47/** A bar whose data-end is rounded (4px) while the baseline end stays square. */
48function barPath(x0: number, y: number, len: number, h: number): string {
49 const r = Math.min(4, len)
50 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`
51}
53/** A reference-line label: a short sample of the line's own stroke, then the
54 * value in ink, flipped to end-anchored near the right edge. */
55function RefLabel(props: {
56 x: number
57 y: number
58 width: number
59 stroke: string
60 dash: string
61 text: string
62}) {
63 const flip = props.x > props.width - 170
64 const dir = flip ? -1 : 1
65 const x0 = props.x + 6 * dir
66 return (
67 <g>
68 <line
69 x1={x0}
70 x2={x0 + 16 * dir}
71 y1={props.y - 4}
72 y2={props.y - 4}
73 stroke={props.stroke}
74 strokeWidth={1.5}
75 strokeDasharray={props.dash}
76 />
77 <text
78 x={x0 + 20 * dir}
79 y={props.y}
80 textAnchor={flip ? 'end' : 'start'}
81 className="bar-value"
82 fill="var(--ink)"
83 >
84 {props.text}
85 </text>
86 </g>
87 )
88}
90interface Group {
91 label: string
92 rows: Row[]
93}
95/** The three prefilter groups, then the conditional coder — results[9] — as
96 * its own group. */
97function buildGroups(results: CodecResult[]): Group[] {
98 const groups: Group[] = GROUPS.map((group, g) => ({
99 label: group,
100 rows: CODER_NAMES.map((coder, c) => ({ coder, c, r: results[g * 3 + c] }))
101 .filter(x => x.r)
102 .map(({ coder, c, r }) => ({
103 key: r.codec,
104 label: coder,
105 name: r.codec,
106 note: r.note,
107 bytes: r.bytes,
108 bitsPerSample: r.bitsPerSample,
109 ratio: r.ratio,
110 color: CODER_VARS[c],
111 })),
112 }))
113 const extra = results[GROUPS.length * CODER_NAMES.length]
114 if (extra) {
115 groups.push({
116 label: 'conditional',
117 rows: [
118 {
119 key: extra.codec,
120 label: 'Gaussian AC',
121 name: extra.codec,
122 note: extra.note,
123 bytes: extra.bytes,
124 bitsPerSample: extra.bitsPerSample,
125 ratio: extra.ratio,
126 color: COND_VAR,
127 },
128 ],
129 })
130 }
131 return groups
132}
134export default function CompressionChart(props: {
135 results: CodecResult[]
136 /** The browser-estimated entropy rate R, once at least one past is in. */
137 rateBits: number | null
138 /** Standard error of that estimate, drawn as a band around its line. */
139 rateSe: number | null
140 /** The analytic prediction of R, always shown as a dotted reference. */
141 theoryBits: number
142 computing: boolean
143}) {
144 const ref = useRef<HTMLDivElement>(null)
145 const width = useWidth(ref, 720)
146 const [metric, setMetric] = useState<Metric>('ratio')
147 const [tip, setTip] = useState<Tip | null>(null)
148 const [hovered, setHovered] = useState<string | null>(null)
150 const { results, rateBits, rateSe, theoryBits } = props
151 if (results.length === 0) {
152 return <p className="card-note">Computing compression on the first block…</p>
153 }
155 const groups = buildGroups(results)
156 // Every coder that codes against an explicit model can be scored against it.
157 const efficiency = results.filter(r => r.modelBitsPerSample !== undefined)
159 // Group headers and bar rows laid out top to bottom; groups may differ in
160 // row count, so positions accumulate rather than being indexed.
161 const groupLabels: { label: string; y: number }[] = []
162 const placed: { row: Row; y: number }[] = []
163 let yCursor = AXIS_H + REF_BAND
164 for (const g of groups) {
165 groupLabels.push({ label: g.label, y: yCursor + 15 })
166 yCursor += GROUP_H
167 for (const row of g.rows) {
168 placed.push({ row, y: yCursor })
169 yCursor += ROW_H
170 }
171 }
173 const rows = placed.map(p => p.row)
174 const value = (r: { bitsPerSample: number; ratio: number }) =>
175 metric === 'bits' ? r.bitsPerSample : r.ratio
176 const rateValue =
177 rateBits !== null && rateBits > 0 ? (metric === 'bits' ? rateBits : 16 / rateBits) : null
178 const theoryValue = theoryBits > 0 ? (metric === 'bits' ? theoryBits : 16 / theoryBits) : null
179 const xMax = Math.max(...rows.map(value), rateValue ?? 0, theoryValue ?? 0) * 1.1
181 const plotW = width - LABEL_W - RIGHT_PAD
182 const height = yCursor + 6
183 const xOf = (v: number) => LABEL_W + (v / xMax) * plotW
185 const fmt = (r: Row) => (metric === 'bits' ? r.bitsPerSample.toFixed(2) : `${r.ratio.toFixed(2)}×`)
187 const onBarMove = (e: React.PointerEvent, row: Row) => {
188 const box = ref.current!.getBoundingClientRect()
189 setTip({ x: e.clientX - box.left, y: e.clientY - box.top, row })
190 }
192 const rateX = rateValue !== null ? xOf(rateValue) : 0
193 const rateLabel =
194 rateBits !== null && rateBits > 0
195 ? metric === 'bits'
196 ? `Monte-Carlo = ${rateBits.toFixed(2)}`
197 : `Monte-Carlo ⇒ ${(16 / rateBits).toFixed(2)}×`
198 : ''
199 const theoryX = theoryValue !== null ? xOf(theoryValue) : 0
200 const theoryLabel =
201 theoryValue !== null
202 ? metric === 'bits'
203 ? `theory ≈ ${theoryBits.toFixed(2)}`
204 : `theory ⇒ ${(16 / theoryBits).toFixed(2)}×`
205 : ''
206 // ± one standard error around the Monte-Carlo line, in the plotted metric.
207 let band: { x: number; w: number } | null = null
208 if (rateBits !== null && rateBits > 0 && rateSe !== null && rateSe > 0) {
209 const loBits = Math.max(rateBits - rateSe, 1e-9)
210 const hiBits = rateBits + rateSe
211 const x1 = xOf(metric === 'bits' ? loBits : 16 / hiBits)
212 const x2 = Math.min(xOf(metric === 'bits' ? hiBits : 16 / loBits), LABEL_W + plotW)
213 band = { x: x1, w: Math.max(x2 - x1, 0) }
214 }
216 return (
217 <div>
218 <div className="chart-header">
219 <div>
220 <div className="segmented" role="group" aria-label="metric">
221 <button className={metric === 'ratio' ? 'active' : ''} onClick={() => setMetric('ratio')}>
222 compression ratio
223 </button>
224 <button className={metric === 'bits' ? 'active' : ''} onClick={() => setMetric('bits')}>
225 bits / sample
226 </button>
227 </div>
228 <span className="metric-hint">
229 {metric === 'bits' ? 'lower is better' : 'vs int16 — higher is better'}
230 </span>
231 </div>
232 <div className="legend">
233 {CODER_NAMES.map((name, i) => (
234 <span key={name}>
235 <span className="swatch" style={{ background: CODER_VARS[i] }} />
236 {name}
237 </span>
238 ))}
239 <span>
240 <span className="swatch" style={{ background: COND_VAR }} />
241 cond. Gaussian AC
242 </span>
243 </div>
244 </div>
245 <div className={`chart-body${props.computing ? ' computing' : ''}`} ref={ref}>
246 {props.computing && <span className="computing-badge">computing…</span>}
247 <svg width={width} height={height}>
248 {axisTicks(xMax).map(v => (
249 <g key={v}>
250 <line x1={xOf(v)} x2={xOf(v)} y1={AXIS_H - 6} y2={height - 4} stroke="var(--grid)" strokeWidth={1} />
251 <text x={xOf(v)} y={AXIS_H - 10} textAnchor="middle" className="axis-tick">
252 {+v.toFixed(1)}
253 </text>
254 </g>
255 ))}
256 <line x1={xOf(0)} x2={xOf(0)} y1={AXIS_H - 6} y2={height - 4} stroke="var(--baseline)" strokeWidth={1} />
257 {band && (
258 <rect
259 x={band.x}
260 y={AXIS_H - 2}
261 width={band.w}
262 height={height - 2 - AXIS_H}
263 fill="var(--ink-2)"
264 opacity={0.15}
265 />
266 )}
267 {groupLabels.map(g => (
268 <text key={g.label} x={0} y={g.y} className="bar-group-label">
269 {g.label}
270 </text>
271 ))}
272 {placed.map(({ row: r, y }) => {
273 const len = Math.max(1, (value(r) / xMax) * plotW)
274 return (
275 <g key={r.key} opacity={hovered === null || hovered === r.key ? 1 : 0.45}>
276 <text x={8} y={y + BAR_H / 2 + 4} className="bar-row-label">
277 {r.label}
278 </text>
279 <path d={barPath(xOf(0), y, len, BAR_H)} fill={r.color} />
280 <text x={xOf(0) + len + 6} y={y + BAR_H / 2 + 4} className="bar-value">
281 {fmt(r)}
282 </text>
283 <rect
284 x={0}
285 y={y - (ROW_H - BAR_H) / 2}
286 width={width}
287 height={ROW_H}
288 fill="transparent"
289 onPointerMove={e => {
290 setHovered(r.key)
291 onBarMove(e, r)
292 }}
293 onPointerLeave={() => {
294 setHovered(null)
295 setTip(null)
296 }}
297 />
298 </g>
299 )
300 })}
301 {theoryValue !== null && (
302 <g>
303 <line
304 x1={theoryX}
305 x2={theoryX}
306 y1={AXIS_H - 2}
307 y2={height - 4}
308 stroke="var(--theory)"
309 strokeWidth={1.5}
310 strokeDasharray="2 3"
311 />
312 <RefLabel
313 x={theoryX}
314 y={AXIS_H + 11}
315 width={width}
316 stroke="var(--theory)"
317 dash="2 3"
318 text={theoryLabel}
319 />
320 </g>
321 )}
322 {rateValue !== null && (
323 <g>
324 <line
325 x1={rateX}
326 x2={rateX}
327 y1={AXIS_H - 2}
328 y2={height - 4}
329 stroke="var(--ink-2)"
330 strokeWidth={1.5}
331 strokeDasharray="5 4"
332 />
333 <RefLabel
334 x={rateX}
335 y={AXIS_H + 25}
336 width={width}
337 stroke="var(--ink-2)"
338 dash="5 4"
339 text={rateLabel}
340 />
341 </g>
342 )}
343 </svg>
344 {tip && (
345 <div className="viz-tooltip" style={{ left: tip.x + 14, top: tip.y - 8 }}>
346 <div>
347 <span className="tip-value">
348 {tip.row.bitsPerSample.toFixed(3)} bits/sample · {tip.row.ratio.toFixed(2)}×
349 </span>{' '}
350 <span className="tip-label">{tip.row.name}</span>
351 </div>
352 <div className="tip-label">
353 {tip.row.bytes.toLocaleString()} bytes · {tip.row.note}
354 </div>
355 </div>
356 )}
357 </div>
358 {/* How much each entropy coder loses against the model it is coding
359 against — its own overhead, separate from how good the model is. */}
360 {efficiency.length > 0 && (
361 <div className="coder-efficiency">
362 <span className="coder-efficiency-title">
363 entropy-coder overhead — output vs the model it codes against
364 </span>
365 {efficiency.map(r => {
366 const model = r.modelBitsPerSample as number
367 const over = model > 0 ? (r.bitsPerSample / model - 1) * 100 : 0
368 return (
369 <span key={r.codec} className="coder-efficiency-item">
370 <span className="coder-efficiency-name">{r.codec}</span>
371 <span className="coder-efficiency-bits">
372 {r.bitsPerSample.toFixed(3)} / {model.toFixed(3)} bits
373 </span>
374 <span className="coder-efficiency-pct">
375 {over >= 0 ? '+' : ''}
376 {over.toFixed(1)}%
377 </span>
378 </span>
379 )
380 })}
381 </div>
382 )}
383 <details className="chart-table">
384 <summary>Table view</summary>
385 <table>
386 <thead>
387 <tr>
388 <th>method</th>
389 <th>bytes</th>
390 <th>bits/sample</th>
391 <th>ratio vs int16</th>
392 </tr>
393 </thead>
394 <tbody>
395 {rows.map(r => (
396 <tr key={r.key}>
397 <td>{r.name}</td>
398 <td>{r.bytes.toLocaleString()}</td>
399 <td>{r.bitsPerSample.toFixed(3)}</td>
400 <td>{r.ratio.toFixed(3)}</td>
401 </tr>
402 ))}
403 {theoryBits > 0 && (
404 <tr>
405 <td>entropy rate R — analytic theory</td>
406 <td>—</td>
407 <td>{theoryBits.toFixed(3)}</td>
408 <td>{(16 / theoryBits).toFixed(3)}</td>
409 </tr>
410 )}
411 {rateBits !== null && rateBits > 0 && (
412 <tr>
413 <td>entropy rate R — Monte-Carlo ground truth</td>
414 <td>—</td>
415 <td>
416 {rateBits.toFixed(3)}
417 {rateSe !== null && rateSe > 0 ? ` ± ${rateSe.toFixed(3)}` : ''}
418 </td>
419 <td>{(16 / rateBits).toFixed(3)}</td>
420 </tr>
421 )}
422 </tbody>
423 </table>
424 </details>
425 </div>
426 )
427}