/ concept-collection / timeseries-compressibility
Sign in
concept-collection / timeseries-compressibility
timeseries-compressibility / src / components / CompressionChart.tsx
328 lines · 10.7 KBBlameHistoryRaw
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
13const GROUP_H = 20
14const ROW_H = 24
15const BAR_H = 16
17type Metric = 'bits' | 'ratio'
19/** One drawn bar: a measured codec size. */
20interface Row {
21 key: string
22 label: string
23 name: string
24 note: string
25 bytes: number
26 bitsPerSample: number
27 ratio: number
28 color: string
31interface Tip {
32 x: number
33 y: number
34 row: Row
37function axisTicks(max: number): number[] {
38 const step = max > 24 ? 8 : max > 12 ? 4 : max > 6 ? 2 : max > 3 ? 1 : 0.5
39 const out: number[] = []
40 for (let v = 0; v <= max + 1e-9; v += step) out.push(v)
41 return out
44/** A bar whose data-end is rounded (4px) while the baseline end stays square. */
45function barPath(x0: number, y: number, len: number, h: number): string {
46 const r = Math.min(4, len)
47 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`
50interface Group {
51 label: string
52 rows: Row[]
55/** The three prefilter groups, then the conditional coder — results[9] — as
56 * its own group. */
57function buildGroups(results: CodecResult[]): Group[] {
58 const groups: Group[] = GROUPS.map((group, g) => ({
59 label: group,
60 rows: CODER_NAMES.map((coder, c) => ({ coder, c, r: results[g * 3 + c] }))
61 .filter(x => x.r)
62 .map(({ coder, c, r }) => ({
63 key: r.codec,
64 label: coder,
65 name: r.codec,
66 note: r.note,
67 bytes: r.bytes,
68 bitsPerSample: r.bitsPerSample,
69 ratio: r.ratio,
70 color: CODER_VARS[c],
71 })),
72 }))
73 const extra = results[GROUPS.length * CODER_NAMES.length]
74 if (extra) {
75 groups.push({
76 label: 'conditional',
77 rows: [
78 {
79 key: extra.codec,
80 label: 'Gaussian AC',
81 name: extra.codec,
82 note: extra.note,
83 bytes: extra.bytes,
84 bitsPerSample: extra.bitsPerSample,
85 ratio: extra.ratio,
86 color: COND_VAR,
87 },
88 ],
89 })
90 }
91 return groups
94export default function CompressionChart(props: {
95 results: CodecResult[]
96 /** The browser-estimated entropy rate R, once at least one past is in. */
97 rateBits: number | null
98 computing: boolean
99}) {
100 const ref = useRef<HTMLDivElement>(null)
101 const width = useWidth(ref, 720)
102 const [metric, setMetric] = useState<Metric>('ratio')
103 const [tip, setTip] = useState<Tip | null>(null)
104 const [hovered, setHovered] = useState<string | null>(null)
106 const { results, rateBits } = props
107 if (results.length === 0) {
108 return <p className="card-note">Computing compression on the first block…</p>
109 }
111 const groups = buildGroups(results)
112 // Every coder that codes against an explicit model can be scored against it.
113 const efficiency = results.filter(r => r.modelBitsPerSample !== undefined)
115 // Group headers and bar rows laid out top to bottom; groups may differ in
116 // row count, so positions accumulate rather than being indexed.
117 const groupLabels: { label: string; y: number }[] = []
118 const placed: { row: Row; y: number }[] = []
119 let yCursor = AXIS_H
120 for (const g of groups) {
121 groupLabels.push({ label: g.label, y: yCursor + 15 })
122 yCursor += GROUP_H
123 for (const row of g.rows) {
124 placed.push({ row, y: yCursor })
125 yCursor += ROW_H
126 }
127 }
129 const rows = placed.map(p => p.row)
130 const value = (r: { bitsPerSample: number; ratio: number }) =>
131 metric === 'bits' ? r.bitsPerSample : r.ratio
132 const rateValue =
133 rateBits !== null && rateBits > 0 ? (metric === 'bits' ? rateBits : 16 / rateBits) : null
134 const xMax =
135 metric === 'bits'
136 ? Math.max(16, ...rows.map(value), rateValue ?? 0) * 1.02
137 : Math.max(...rows.map(value), rateValue ?? 0) * 1.1
139 const plotW = width - LABEL_W - RIGHT_PAD
140 const height = yCursor + 6
141 const xOf = (v: number) => LABEL_W + (v / xMax) * plotW
143 const fmt = (r: Row) => (metric === 'bits' ? r.bitsPerSample.toFixed(2) : `${r.ratio.toFixed(2)}×`)
145 const onBarMove = (e: React.PointerEvent, row: Row) => {
146 const box = ref.current!.getBoundingClientRect()
147 setTip({ x: e.clientX - box.left, y: e.clientY - box.top, row })
148 }
150 const rateX = rateValue !== null ? xOf(rateValue) : 0
151 const rateLabel =
152 rateBits !== null && rateBits > 0
153 ? metric === 'bits'
154 ? `R = ${rateBits.toFixed(2)}`
155 : `R ⇒ ${(16 / rateBits).toFixed(2)}×`
156 : ''
158 return (
159 <div>
160 <div className="chart-header">
161 <div>
162 <div className="segmented" role="group" aria-label="metric">
163 <button className={metric === 'ratio' ? 'active' : ''} onClick={() => setMetric('ratio')}>
164 compression ratio
165 </button>
166 <button className={metric === 'bits' ? 'active' : ''} onClick={() => setMetric('bits')}>
167 bits / sample
168 </button>
169 </div>
170 <span className="metric-hint">
171 {metric === 'bits' ? 'lower is better' : 'vs int16 — higher is better'}
172 </span>
173 </div>
174 <div className="legend">
175 {CODER_NAMES.map((name, i) => (
176 <span key={name}>
177 <span className="swatch" style={{ background: CODER_VARS[i] }} />
178 {name}
179 </span>
180 ))}
181 <span>
182 <span className="swatch" style={{ background: COND_VAR }} />
183 cond. Gaussian AC
184 </span>
185 </div>
186 </div>
187 <div className={`chart-body${props.computing ? ' computing' : ''}`} ref={ref}>
188 {props.computing && <span className="computing-badge">computing…</span>}
189 <svg width={width} height={height}>
190 {axisTicks(xMax).map(v => (
191 <g key={v}>
192 <line x1={xOf(v)} x2={xOf(v)} y1={AXIS_H - 6} y2={height - 4} stroke="var(--grid)" strokeWidth={1} />
193 <text x={xOf(v)} y={AXIS_H - 10} textAnchor="middle" className="axis-tick">
194 {+v.toFixed(1)}
195 </text>
196 </g>
197 ))}
198 <line x1={xOf(0)} x2={xOf(0)} y1={AXIS_H - 6} y2={height - 4} stroke="var(--baseline)" strokeWidth={1} />
199 {groupLabels.map(g => (
200 <text key={g.label} x={0} y={g.y} className="bar-group-label">
201 {g.label}
202 </text>
203 ))}
204 {placed.map(({ row: r, y }) => {
205 const len = Math.max(1, (value(r) / xMax) * plotW)
206 return (
207 <g key={r.key} opacity={hovered === null || hovered === r.key ? 1 : 0.45}>
208 <text x={8} y={y + BAR_H / 2 + 4} className="bar-row-label">
209 {r.label}
210 </text>
211 <path d={barPath(xOf(0), y, len, BAR_H)} fill={r.color} />
212 <text x={xOf(0) + len + 6} y={y + BAR_H / 2 + 4} className="bar-value">
213 {fmt(r)}
214 </text>
215 <rect
216 x={0}
217 y={y - (ROW_H - BAR_H) / 2}
218 width={width}
219 height={ROW_H}
220 fill="transparent"
221 onPointerMove={e => {
222 setHovered(r.key)
223 onBarMove(e, r)
224 }}
225 onPointerLeave={() => {
226 setHovered(null)
227 setTip(null)
228 }}
229 />
230 </g>
231 )
232 })}
233 {rateValue !== null && (
234 <g>
235 <line
236 x1={rateX}
237 x2={rateX}
238 y1={AXIS_H - 2}
239 y2={height - 4}
240 stroke="var(--ink-2)"
241 strokeWidth={1.5}
242 strokeDasharray="5 4"
243 />
244 <text
245 x={rateX + (rateX > width - 150 ? -6 : 6)}
246 y={AXIS_H + 10}
247 textAnchor={rateX > width - 150 ? 'end' : 'start'}
248 className="bar-value"
249 fill="var(--ink)"
250 >
251 {rateLabel}
252 </text>
253 </g>
254 )}
255 </svg>
256 {tip && (
257 <div className="viz-tooltip" style={{ left: tip.x + 14, top: tip.y - 8 }}>
258 <div>
259 <span className="tip-value">
260 {tip.row.bitsPerSample.toFixed(3)} bits/sample · {tip.row.ratio.toFixed(2)}×
261 </span>{' '}
262 <span className="tip-label">{tip.row.name}</span>
263 </div>
264 <div className="tip-label">
265 {tip.row.bytes.toLocaleString()} bytes · {tip.row.note}
266 </div>
267 </div>
268 )}
269 </div>
270 {/* How much each entropy coder loses against the model it is coding
271 against — its own overhead, separate from how good the model is. */}
272 {efficiency.length > 0 && (
273 <div className="coder-efficiency">
274 <span className="coder-efficiency-title">
275 entropy-coder overhead — output vs the model it codes against
276 </span>
277 {efficiency.map(r => {
278 const model = r.modelBitsPerSample as number
279 const over = model > 0 ? (r.bitsPerSample / model - 1) * 100 : 0
280 return (
281 <span key={r.codec} className="coder-efficiency-item">
282 <span className="coder-efficiency-name">{r.codec}</span>
283 <span className="coder-efficiency-bits">
284 {r.bitsPerSample.toFixed(3)} / {model.toFixed(3)} bits
285 </span>
286 <span className="coder-efficiency-pct">
287 {over >= 0 ? '+' : ''}
288 {over.toFixed(1)}%
289 </span>
290 </span>
291 )
292 })}
293 </div>
294 )}
295 <details className="chart-table">
296 <summary>Table view</summary>
297 <table>
298 <thead>
299 <tr>
300 <th>method</th>
301 <th>bytes</th>
302 <th>bits/sample</th>
303 <th>ratio vs int16</th>
304 </tr>
305 </thead>
306 <tbody>
307 {rows.map(r => (
308 <tr key={r.key}>
309 <td>{r.name}</td>
310 <td>{r.bytes.toLocaleString()}</td>
311 <td>{r.bitsPerSample.toFixed(3)}</td>
312 <td>{r.ratio.toFixed(3)}</td>
313 </tr>
314 ))}
315 {rateBits !== null && rateBits > 0 && (
316 <tr>
317 <td>entropy rate R (Monte-Carlo)</td>
318 <td></td>
319 <td>{rateBits.toFixed(3)}</td>
320 <td>{(16 / rateBits).toFixed(3)}</td>
321 </tr>
322 )}
323 </tbody>
324 </table>
325 </details>
326 </div>
327 )
moveopenescclose