/** * Top loading bar (NProgress-style) for the DWG/DXF load pipeline. * * Why two layers: * The expensive stages — `parse_dwg_json` on the WASM heap and * `Viewer2D.load()` — are SYNCHRONOUS. While they run, the main thread is * blocked: no rAF ticks, no `width` transitions, no repaint. A JS-driven bar * simply freezes for the seconds that matter most. * * · `.bar` real progress, driven by `width` (accurate, main-thread bound). * · `.sweep` liveness, a `transform` keyframe animation. Transform keyframes * are handed to the compositor thread, so the stripe keeps moving * across a blocked main thread. That is the only feedback the page * can give while the parser is inside WASM. * * The markup and CSS live in index.html (`#progress`), same as the other * panels; this module just drives them. */ /** Wait until the browser has actually painted the pending style change. */ function nextPaint(): Promise { return new Promise((resolve) => { let settled = false; const finish = () => { if (settled) return; settled = true; resolve(); }; // Two frames: the first callback still runs BEFORE paint, the second one // only fires once the frame carrying our change has been composited. requestAnimationFrame(() => requestAnimationFrame(finish)); // Backgrounded tabs never fire rAF — don't stall the load behind it. setTimeout(finish, 150); }); } export class TopProgress { private root: HTMLElement; private bar: HTMLElement; private value = 0; private hideTimer = 0; constructor(root: HTMLElement) { this.root = root; this.bar = root.querySelector('.bar') ?? root; } /** Show the bar and reset it to zero (no rewind animation). */ start(): void { window.clearTimeout(this.hideTimer); this.root.classList.remove('err'); this.value = 0; this.jumpTo(0); this.root.classList.add('on'); } /** * Move to `v` (0–1). Monotonic — a lower value is ignored, so out-of-order * stage callbacks can never make the bar walk backwards. */ set(v: number): void { const next = Math.max(this.value, Math.min(1, Math.max(0, v))); if (next === this.value) return; this.value = next; this.bar.style.width = `${(next * 100).toFixed(2)}%`; } /** Toggle the compositor-driven sweep for a stage that blocks the main thread. */ busy(on: boolean): void { this.root.classList.toggle('busy', on); } /** Resolve once the pending width/class change is on screen. */ paint(): Promise { return nextPaint(); } /** Fill to 100% and fade out. */ done(): void { this.finish(); } /** Fill to 100% in the error color and fade out. */ fail(): void { this.root.classList.add('err'); this.finish(); } private finish(): void { this.busy(false); this.set(1); window.clearTimeout(this.hideTimer); this.hideTimer = window.setTimeout(() => { this.root.classList.remove('on'); // Rewind only after the fade-out has finished, so the bar never visibly // slides back to zero. this.hideTimer = window.setTimeout(() => { this.root.classList.remove('err'); this.value = 0; this.jumpTo(0); }, 320); }, 260); } /** Set the width with the transition suppressed for one frame. */ private jumpTo(v: number): void { const prev = this.bar.style.transition; this.bar.style.transition = 'none'; this.bar.style.width = `${(v * 100).toFixed(2)}%`; void this.bar.offsetWidth; // force reflow so the next change animates again this.bar.style.transition = prev; } } /** 1.4 MB / 812 kB — short byte label for the download readout. */ export function formatBytes(n: number): string { if (!Number.isFinite(n) || n < 0) return '?'; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} kB`; return `${(n / 1024 / 1024).toFixed(1)} MB`; } /** * fetch() that reports download progress. * * Falls back to a plain `arrayBuffer()` when the response has no body stream * or no Content-Length (then `total` is 0 and the caller should show an * indeterminate state). `loaded` is decompressed bytes while Content-Length is * the compressed size, so the ratio is clamped to 1. */ export async function fetchWithProgress( url: string, onProgress: (loaded: number, total: number) => void, ): Promise { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const total = Number(res.headers.get('content-length')) || 0; if (!res.body || !total) { const buf = await res.arrayBuffer(); onProgress(buf.byteLength, buf.byteLength); return buf; } const reader = res.body.getReader(); const chunks: Uint8Array[] = []; let loaded = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; if (!value) continue; chunks.push(value); loaded += value.length; onProgress(loaded, total); } const out = new Uint8Array(loaded); let offset = 0; for (const c of chunks) { out.set(c, offset); offset += c.length; } return out.buffer; }