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 /** The browser-estimated entropy rate R, once at least one past is in. */
93 rateBits: number | null
94 computing: boolean
95}) {
96 const ref = useRef<HTMLDivElement>(null)
97 const width = useWidth(ref, 720)
98 const [metric, setMetric] = useState<Metric>('ratio')
99 const [tip, setTip] = useState<Tip | null>(null)
100 const [hovered, setHovered] = useState<string | null>(null)
102 const { results, bounds, rateBits } = props
103 if (results.length === 0) {
104 return <p className="card-note">Computing compression on the first block…</p>
105 }
107 const rows = buildRows(results, bounds)
108 const value = (r: { bitsPerSample: number; ratio: number }) =>
109 metric === 'bits' ? r.bitsPerSample : r.ratio
110 const rateValue =
111 rateBits !== null && rateBits > 0 ? (metric === 'bits' ? rateBits : 16 / rateBits) : null
112 const xMax =
113 metric === 'bits'
114 ? Math.max(16, ...rows.map(value), rateValue ?? 0) * 1.02
115 : Math.max(...rows.map(value), rateValue ?? 0) * 1.1
117 const plotW = width - LABEL_W - RIGHT_PAD
118 const height = AXIS_H + GROUPS.length * (GROUP_H + ROWS_PER_GROUP * ROW_H) + 6
119 const xOf = (v: number) => LABEL_W + (v / xMax) * plotW
120 const rowY = (i: number) =>
121 AXIS_H +
122 Math.floor(i / ROWS_PER_GROUP) * (GROUP_H + ROWS_PER_GROUP * ROW_H) +
123 GROUP_H +
124 (i % ROWS_PER_GROUP) * ROW_H
126 const fmt = (r: Row) => (metric === 'bits' ? r.bitsPerSample.toFixed(2) : `${r.ratio.toFixed(2)}×`)
128 const onBarMove = (e: React.PointerEvent, row: Row) => {
129 const box = ref.current!.getBoundingClientRect()
130 setTip({ x: e.clientX - box.left, y: e.clientY - box.top, row })
131 }
133 const rateX = rateValue !== null ? xOf(rateValue) : 0
134 const rateLabel =
135 rateBits !== null && rateBits > 0
136 ? metric === 'bits'
137 ? `R = ${rateBits.toFixed(2)}`
138 : `R ⇒ ${(16 / rateBits).toFixed(2)}×`
139 : ''
141 return (
142 <div>
143 <div className="chart-header">
144 <div>
145 <div className="segmented" role="group" aria-label="metric">
146 <button className={metric === 'ratio' ? 'active' : ''} onClick={() => setMetric('ratio')}>
147 compression ratio
148 </button>
149 <button className={metric === 'bits' ? 'active' : ''} onClick={() => setMetric('bits')}>
150 bits / sample
151 </button>
152 </div>
153 <span className="metric-hint">
154 {metric === 'bits' ? 'lower is better' : 'vs int16 — higher is better'}
155 </span>
156 </div>
157 <div className="legend">
158 {CODER_NAMES.map((name, i) => (
159 <span key={name}>
160 <span className="swatch" style={{ background: CODER_VARS[i] }} />
161 {name}
162 </span>
163 ))}
164 <span>
165 <span className="swatch hollow" />
166 entropy limit (not achieved)
167 </span>
168 </div>
169 </div>
170 <div className={`chart-body${props.computing ? ' computing' : ''}`} ref={ref}>
171 {props.computing && <span className="computing-badge">computing…</span>}
172 <svg width={width} height={height}>
173 {axisTicks(xMax).map(v => (
174 <g key={v}>
175 <line x1={xOf(v)} x2={xOf(v)} y1={AXIS_H - 6} y2={height - 4} stroke="var(--grid)" strokeWidth={1} />
176 <text x={xOf(v)} y={AXIS_H - 10} textAnchor="middle" className="axis-tick">
177 {+v.toFixed(1)}
178 </text>
179 </g>
180 ))}
181 <line x1={xOf(0)} x2={xOf(0)} y1={AXIS_H - 6} y2={height - 4} stroke="var(--baseline)" strokeWidth={1} />
182 {GROUPS.map((g, gi) => (
183 <text
184 key={g}
185 x={0}
186 y={AXIS_H + gi * (GROUP_H + ROWS_PER_GROUP * ROW_H) + 15}
187 className="bar-group-label"
188 >
189 {g}
190 </text>
191 ))}
192 {rows.map((r, i) => {
193 const y = rowY(i)
194 const len = Math.max(1, (value(r) / xMax) * plotW)
195 return (
196 <g key={r.key} opacity={hovered === null || hovered === r.key ? 1 : 0.45}>
197 <text
198 x={8}
199 y={y + BAR_H / 2 + 4}
200 className={r.isBound ? 'bar-row-label bound' : 'bar-row-label'}
201 >
202 {r.label}
203 </text>
204 {r.isBound ? (
205 // Hollow: a limit nobody reached, not a measured size.
206 <path
207 d={barPath(xOf(0), y + 1, len, BAR_H - 2)}
208 fill="var(--muted)"
209 fillOpacity={0.12}
210 stroke="var(--muted)"
211 strokeWidth={1.25}
212 />
213 ) : (
214 <path d={barPath(xOf(0), y, len, BAR_H)} fill={r.color} />
215 )}
216 <text
217 x={xOf(0) + len + 6}
218 y={y + BAR_H / 2 + 4}
219 className={r.isBound ? 'bar-value bound' : 'bar-value'}
220 >
221 {fmt(r)}
222 </text>
223 <rect
224 x={0}
225 y={y - (ROW_H - BAR_H) / 2}
226 width={width}
227 height={ROW_H}
228 fill="transparent"
229 onPointerMove={e => {
230 setHovered(r.key)
231 onBarMove(e, r)
232 }}
233 onPointerLeave={() => {
234 setHovered(null)
235 setTip(null)
236 }}
237 />
238 </g>
239 )
240 })}
241 {rateValue !== null && (
242 <g>
243 <line
244 x1={rateX}
245 x2={rateX}
246 y1={AXIS_H - 2}
247 y2={height - 4}
248 stroke="var(--ink-2)"
249 strokeWidth={1.5}
250 strokeDasharray="5 4"
251 />
252 <text
253 x={rateX + (rateX > width - 150 ? -6 : 6)}
254 y={AXIS_H + 10}
255 textAnchor={rateX > width - 150 ? 'end' : 'start'}
256 className="bar-value"
257 fill="var(--ink)"
258 >
259 {rateLabel}
260 </text>
261 </g>
262 )}
263 </svg>
264 {tip && (
265 <div className="viz-tooltip" style={{ left: tip.x + 14, top: tip.y - 8 }}>
266 <div>
267 <span className="tip-value">
268 {tip.row.bitsPerSample.toFixed(3)} bits/sample · {tip.row.ratio.toFixed(2)}×
269 </span>{' '}
270 <span className="tip-label">{tip.row.name}</span>
271 </div>
272 <div className="tip-label">
273 {tip.row.isBound ? 'equivalent to ' : ''}
274 {tip.row.bytes.toLocaleString()} bytes · {tip.row.note}
275 </div>
276 </div>
277 )}
278 </div>
279 <details className="chart-table">
280 <summary>Table view</summary>
281 <table>
282 <thead>
283 <tr>
284 <th>method</th>
285 <th>bytes</th>
286 <th>bits/sample</th>
287 <th>ratio vs int16</th>
288 </tr>
289 </thead>
290 <tbody>
291 {rows.map(r => (
292 <tr key={r.key}>
293 <td>{r.name}</td>
294 <td>{r.bytes.toLocaleString()}</td>
295 <td>{r.bitsPerSample.toFixed(3)}</td>
296 <td>{r.ratio.toFixed(3)}</td>
297 </tr>
298 ))}
299 {rateBits !== null && rateBits > 0 && (
300 <tr>
301 <td>entropy rate R (Monte-Carlo)</td>
302 <td>—</td>
303 <td>{rateBits.toFixed(3)}</td>
304 <td>{(16 / rateBits).toFixed(3)}</td>
305 </tr>
306 )}
307 </tbody>
308 </table>
309 </details>
310 </div>
311 )
312}