/** * Phase 18.η — Bundle analyzer. * * Post-processes `.mandu/client/` output + the emitted `BundleManifest` into * a structured JSON report and a self-contained HTML treemap. Intended for * `mandu build --analyze` and for programmatic use from deploy scripts / CI. * * ── What this module produces ──────────────────────────────────────────────── * * analyzeBundle(rootDir, manifest) → AnalyzeReport * { * islands: [ * { * name: "home" // island route id * js: "/.mandu/client/home.island.js" * totalRaw: 123456 * totalGz: 42017 * priority: "visible" * shared: ["runtime","vendor"] // chunks referenced * modules: [ { path, size, gz } ... up to top-20 ] * } * ], * shared: [ * { * id: "vendor" * js: "/.mandu/client/_vendor.js" * size: 258912 * gz: 87120 * usedBy: ["home","dashboard"] * } * ], * summary: { * totalRaw, totalGz, * largestIsland: { name, totalRaw }, * heaviestDep: { path, size } // heaviest module seen anywhere * islandCount, sharedCount, * } * } * * ── Module-level breakdown ────────────────────────────────────────────────── * * When `--sourcemap` was passed to `mandu build`, each JS output gets an * external `.map` file next to it. We parse the `sources[]` array + the * `sourcesContent[]` (if present) to derive per-source-file byte sizes. That * gives us the "top-20 heaviest modules per island" drill-down that the HTML * report renders as a second-level treemap. * * When sourcemaps are absent we degrade gracefully: * - `modules: []` on every island entry * - `heaviestDep: { path: "", size: 0 }` * - HTML report still renders the island-level treemap * * ── Design choices ────────────────────────────────────────────────────────── * * - Zero runtime deps. `zlib` (`Bun.gzipSync`) + `fs/promises` only. * The HTML report inlines its own squarify treemap (~150 LOC) so the * output file is fully portable — drag-and-drop into any browser, no * CDN. This is a deliberate no-d3 choice (see `report.html` comment). * - Pure function. `analyzeBundle()` does filesystem reads but does not * write. Serialization (`writeReport()` / `renderHtml()`) is separate so * tests can assert on the report shape without touching disk. * - Stable output. Islands are sorted by `totalRaw DESC`, shared chunks by * size DESC, modules within an island by size DESC. CI snapshots won't * churn on Map iteration order. * * ── What this module deliberately does NOT do ─────────────────────────────── * * - No tree-shaking suggestions. That's an optimizer concern; the report * is purely descriptive. Agent η's scope ends at "show the developer * what's in their bundle". * - No historical / delta comparison. A separate command (future * `mandu build --analyze-against=prev.json`) can diff two reports. * - No network. Absolutely nothing is fetched. The report is generated * from files on disk produced by `buildClientBundles()`. */ import fs from "fs/promises"; import path from "path"; import zlib from "zlib"; import type { BundleManifest } from "./types"; import type { BudgetReport } from "./budget"; // ============================================================================ // Types // ============================================================================ export interface AnalyzeModule { /** Source path as emitted by the sourcemap (e.g. `node_modules/react/index.js`). */ path: string; /** Raw byte contribution attributed to this module inside the final bundle. */ size: number; /** Estimated gzip size (proportional scaling from bundle gz, since gz is non-additive). */ gz: number; } export interface AnalyzeIsland { /** Island / route id (e.g. `home`, `dashboard`). */ name: string; /** Absolute `/.mandu/client/.js` path from the manifest. */ js: string; /** Raw (uncompressed) bytes of the island's JS output. */ totalRaw: number; /** Real gzip size of the island's JS output — not an estimate. */ totalGz: number; /** Hydration priority propagated from the manifest. */ priority: "immediate" | "visible" | "idle" | "interaction"; /** Shared-chunk ids this island depends on (e.g. `["runtime", "vendor"]`). */ shared: string[]; /** Top-20 heaviest source modules (empty when sourcemaps are unavailable). */ modules: AnalyzeModule[]; } export interface AnalyzeSharedChunk { /** Logical id — `runtime`, `vendor`, `router`, `fastRefresh.runtime`, etc. */ id: string; /** `/.mandu/client/...` path from the manifest. */ js: string; /** Raw byte size of the chunk. */ size: number; /** Gzip byte size of the chunk. */ gz: number; /** Every island name that transitively depends on this chunk. */ usedBy: string[]; } export interface AnalyzeSummary { /** Sum of every island + every shared chunk, raw bytes. */ totalRaw: number; /** Sum of every island + every shared chunk, gzip bytes. */ totalGz: number; /** The single heaviest island (by raw bytes). */ largestIsland: { name: string; totalRaw: number } | null; /** The heaviest source module across all islands (source-map derived). */ heaviestDep: { path: string; size: number } | null; /** Island count — convenience for CLI table rendering. */ islandCount: number; /** Shared-chunk count. */ sharedCount: number; /** Deduplication savings: bytes that would have been duplicated if each * island inlined its shared deps instead of referencing them. */ dedupeSavings: number; /** Report schema version. Bump on breaking shape changes. */ version: 1; /** ISO timestamp when the report was generated. */ generatedAt: string; } export interface AnalyzeReport { islands: AnalyzeIsland[]; shared: AnalyzeSharedChunk[]; summary: AnalyzeSummary; } // ============================================================================ // Size primitives // ============================================================================ /** * Strip the leading `/.mandu/client/` prefix and join against `/.mandu/client/`. * * The bundle manifest stores URLs as browser-relative absolute paths * (`/.mandu/client/`). The analyzer runs on disk, so we need to flip * the URL into a `fs` path. Returns `null` for any URL that does not match * the expected shape — defensive against `data:` / `http:` / tampered * manifests. (The manifest is already Zod-validated at build time, but * belt-and-braces: one more check means this file is safe to feed an * unvalidated manifest from an old build.) */ function urlToFsPath(rootDir: string, url: string | undefined): string | null { if (!url || typeof url !== "string") return null; if (!url.startsWith("/.mandu/client/")) return null; const rel = url.slice("/".length); // keep `.mandu/client/...` return path.join(rootDir, rel); } /** * Read a file and return both raw and gzip byte counts. Returns `{ raw: 0, * gz: 0 }` when the file is missing — the bundler may have emitted a * stub entry (e.g. `shared.fastRefresh` in prod) and we don't want the * report to crash on one missing file. */ async function measureFile(absPath: string): Promise<{ raw: number; gz: number }> { try { const buf = await fs.readFile(absPath); const gz = zlib.gzipSync(buf, { level: 9 }); return { raw: buf.byteLength, gz: gz.byteLength }; } catch { return { raw: 0, gz: 0 }; } } // ============================================================================ // Sourcemap parsing // ============================================================================ interface SourceMapV3 { version: 3; sources: string[]; sourcesContent?: (string | null)[]; mappings?: string; file?: string; } /** * Parse the sourcemap file associated with a built bundle (same path + `.map`). * * Returns a `AnalyzeModule[]` aggregated by source path, largest first, * truncated at `limit` entries. Size attribution uses `sourcesContent` * byte length as the best available proxy. When sourcesContent is absent * (minified-only map) we return an empty array — a stub rather than * misleading data. * * Gzip per module is estimated as `size * (bundleGz / bundleRaw)` — a * linear proportional attribution. Gzip is not actually additive (two * identical modules don't double the gz weight), so this is an * approximation; the HTML treemap labels it as "~gz" to signal that. */ async function readTopModules( jsAbsPath: string, bundleRaw: number, bundleGz: number, limit = 20 ): Promise { const mapPath = `${jsAbsPath}.map`; let text: string; try { text = await fs.readFile(mapPath, "utf8"); } catch { return []; } let map: SourceMapV3; try { map = JSON.parse(text) as SourceMapV3; } catch { return []; } if (!Array.isArray(map.sources) || !Array.isArray(map.sourcesContent)) { return []; } // One source can appear multiple times in a concatenated chunk; we // aggregate by path. Use a Map so insertion order is preserved for ties. const sizeByPath = new Map(); for (let i = 0; i < map.sources.length; i++) { const src = map.sources[i]; const content = map.sourcesContent[i]; if (typeof content !== "string") continue; // Normalise `../../../node_modules/react/index.js` → `react/index.js` // so the treemap legend groups every `react/*` under the same prefix. const norm = normalizeSourcePath(src); sizeByPath.set(norm, (sizeByPath.get(norm) ?? 0) + content.length); } const gzRatio = bundleRaw > 0 ? bundleGz / bundleRaw : 0; return [...sizeByPath.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, limit) .map(([p, size]) => ({ path: p, size, gz: Math.round(size * gzRatio), })); } /** * Collapse a Bun.build source path to something recognizable. * * Examples: * `../../../node_modules/react/index.js` → `react/index.js` * `../../src/app/home/page.tsx` → `src/app/home/page.tsx` * `bun:react-dom` → `bun:react-dom` */ export function normalizeSourcePath(raw: string): string { if (!raw) return ""; if (raw.startsWith("bun:")) return raw; // Normalise Windows slashes FIRST so the relative-prefix strip below // catches `..\src\foo` as well as `../src/foo`. let p = raw.replace(/\\/g, "/"); // Strip leading `./` and `../` segments. p = p.replace(/^(\.\.\/|\.\/)+/, ""); // `node_modules/foo/...` → `foo/...` (keeps dep identity, drops pnpm noise) const nmIdx = p.lastIndexOf("node_modules/"); if (nmIdx !== -1) { p = p.slice(nmIdx + "node_modules/".length); } return p; } // ============================================================================ // Main analyzer // ============================================================================ /** * Read every artifact referenced by the manifest and build the report. * * `rootDir` is the project root (the one containing `.mandu/`). */ export async function analyzeBundle( rootDir: string, manifest: BundleManifest ): Promise { // ── Step 1: Measure shared chunks ──────────────────────────────────────── const shared: AnalyzeSharedChunk[] = []; const sharedUrls: { id: string; url: string }[] = []; if (manifest.shared?.runtime) sharedUrls.push({ id: "runtime", url: manifest.shared.runtime }); if (manifest.shared?.vendor) sharedUrls.push({ id: "vendor", url: manifest.shared.vendor }); if (manifest.shared?.router) sharedUrls.push({ id: "router", url: manifest.shared.router }); if (manifest.shared?.fastRefresh?.runtime) { sharedUrls.push({ id: "fastRefresh.runtime", url: manifest.shared.fastRefresh.runtime, }); } if (manifest.shared?.fastRefresh?.glue) { sharedUrls.push({ id: "fastRefresh.glue", url: manifest.shared.fastRefresh.glue }); } const sharedById = new Map(); for (const { id, url } of sharedUrls) { const abs = urlToFsPath(rootDir, url); if (!abs) continue; const { raw, gz } = await measureFile(abs); const entry: AnalyzeSharedChunk = { id, js: url, size: raw, gz, usedBy: [] }; shared.push(entry); sharedById.set(id, entry); } // ── Step 2: Walk islands / bundles ─────────────────────────────────────── // // Both `manifest.bundles` (route-level) and `manifest.islands` (per-island // code-split) describe client JS entrypoints. We treat each as an "island" // in the report — the shape of the drill-down doesn't differ, only the id. const islandSources: { name: string; url: string; priority: AnalyzeIsland["priority"]; deps: string[] }[] = []; for (const [routeId, entry] of Object.entries(manifest.bundles ?? {})) { islandSources.push({ name: routeId, url: entry.js, priority: entry.priority, deps: entry.dependencies ?? [], }); } for (const [islandName, entry] of Object.entries(manifest.islands ?? {})) { // Avoid double-counting: if a per-island chunk shares its route id with // a route-level bundle, we prefer the island entry (finer granularity). const existing = islandSources.findIndex((s) => s.name === islandName); if (existing !== -1) islandSources.splice(existing, 1); islandSources.push({ name: islandName, url: entry.js, priority: entry.priority, deps: [], }); } for (const [partialName, entry] of Object.entries(manifest.partials ?? {})) { islandSources.push({ name: `partial:${partialName}`, url: entry.js, priority: entry.priority, deps: [], }); } const islands: AnalyzeIsland[] = []; for (const src of islandSources) { const abs = urlToFsPath(rootDir, src.url); if (!abs) continue; const { raw, gz } = await measureFile(abs); const modules = await readTopModules(abs, raw, gz); // Shared-chunk attribution: every island implicitly depends on the // `runtime` + `vendor` chunks the bundler emits as a contract; `router` // is added when the app uses SPA navigation. We add the dep names here // so `shared[].usedBy` stays in sync with the treemap links. const implicitShared = ["runtime", "vendor"]; if (sharedById.has("router")) implicitShared.push("router"); const sharedDeps = [...new Set([...implicitShared, ...src.deps])].filter((id) => sharedById.has(id) ); for (const depId of sharedDeps) { const entry = sharedById.get(depId); if (entry && !entry.usedBy.includes(src.name)) entry.usedBy.push(src.name); } islands.push({ name: src.name, js: src.url, totalRaw: raw, totalGz: gz, priority: src.priority, shared: sharedDeps, modules, }); } islands.sort((a, b) => b.totalRaw - a.totalRaw); shared.sort((a, b) => b.size - a.size); // ── Step 3: Summary ────────────────────────────────────────────────────── const islandTotalRaw = islands.reduce((s, i) => s + i.totalRaw, 0); const islandTotalGz = islands.reduce((s, i) => s + i.totalGz, 0); const sharedTotalRaw = shared.reduce((s, c) => s + c.size, 0); const sharedTotalGz = shared.reduce((s, c) => s + c.gz, 0); // Dedupe savings — if every island had to inline each shared chunk it // uses, the wire cost would be `sum_over_islands(sharedUsed). Subtracting // the one-copy cost gives the "load-once" savings the manifest unlocks. let dedupeSavings = 0; for (const chunk of shared) { if (chunk.usedBy.length > 1) { dedupeSavings += chunk.size * (chunk.usedBy.length - 1); } } let heaviestDep: AnalyzeSummary["heaviestDep"] = null; for (const island of islands) { for (const m of island.modules) { if (!heaviestDep || m.size > heaviestDep.size) { heaviestDep = { path: m.path, size: m.size }; } } } const summary: AnalyzeSummary = { totalRaw: islandTotalRaw + sharedTotalRaw, totalGz: islandTotalGz + sharedTotalGz, largestIsland: islands[0] ? { name: islands[0].name, totalRaw: islands[0].totalRaw } : null, heaviestDep, islandCount: islands.length, sharedCount: shared.length, dedupeSavings, version: 1, generatedAt: new Date().toISOString(), }; return { islands, shared, summary }; } // ============================================================================ // Serializers // ============================================================================ /** * Write `report.json` + `report.html` to `/.mandu/analyze/`. * * Returns the absolute paths of both files so the CLI can print them. * Callers that want JSON only can skip the HTML step via `{ htmlPath: null }`. * * Phase 18.φ — `opts.budget` is an optional pre-computed budget report * that, when present, renders a budget-bar section in the HTML output * and is serialised alongside `report.json` as `report.budget`. */ export async function writeAnalyzeReport( rootDir: string, report: AnalyzeReport, opts: { html?: boolean; budget?: BudgetReport | null } = {} ): Promise<{ jsonPath: string; htmlPath: string | null }> { const outDir = path.join(rootDir, ".mandu", "analyze"); await fs.mkdir(outDir, { recursive: true }); const jsonPath = path.join(outDir, "report.json"); const jsonPayload = opts.budget ? { ...report, budget: opts.budget } : report; await fs.writeFile(jsonPath, JSON.stringify(jsonPayload, null, 2), "utf8"); let htmlPath: string | null = null; if (opts.html !== false) { htmlPath = path.join(outDir, "report.html"); await fs.writeFile(htmlPath, renderAnalyzeHtml(report, opts.budget ?? null), "utf8"); } return { jsonPath, htmlPath }; } // ============================================================================ // HTML report // ============================================================================ /** * Render a self-contained single-file HTML report. No external CDN, no d3, * no webpack-bundle-analyzer — just inline SVG + a hand-rolled squarify * treemap. The result is ~12-30 KB for typical projects. * * Design notes: * - Dark theme + monospace font so stack-trace-like paths stay readable. * - Clicking an island rectangle drills into its module breakdown. * - ESC / click-outside returns to the island view. * - All SVG elements are generated server-side — the client script only * toggles visibility. This keeps the report working even with JS * disabled (you lose drill-down, but the island treemap still renders). */ export function renderAnalyzeHtml( report: AnalyzeReport, budget: BudgetReport | null = null ): string { const { islands, shared, summary } = report; // ── Island-level treemap ───────────────────────────────────────────────── const VIEW_W = 960; const VIEW_H = 480; const islandRects = squarify( islands.map((i) => ({ name: i.name, value: Math.max(i.totalRaw, 1), island: i })), VIEW_W, VIEW_H ); const islandSvg = islandRects .map((r, idx) => { const color = ISLAND_PALETTE[idx % ISLAND_PALETTE.length]; const island = r.data.island; const label = `${r.data.name}\n${fmtBytes(island.totalRaw)} / ${fmtBytes(island.totalGz)} gz`; const canLabel = r.w > 60 && r.h > 28; // XSS note: island names come from user route ids / island file // basenames, so they are not fully trusted. We never embed the raw // name in a JS string context — the drill target is carried on // `data-drill` and a single delegated event listener reads it via // `Element.dataset`, which does zero string parsing. return ` ${ canLabel ? `${escText(r.data.name)} ${fmtBytes( island.totalRaw )} / ${fmtBytes(island.totalGz)} gz` : "" } ${escText(label)} `; }) .join(""); // ── Per-island drill-down panels ───────────────────────────────────────── // // DOM id is `drill-` (a safe numeric) rather than `drill-` // so the name never enters an id-attribute context. The name itself is // still shown via the `data-drill` attribute matched by the client // script, both values normalised by `escAttr` / `escText`. const drillPanels = islands .map((island, idx) => { const safeId = `drill-${idx}`; if (island.modules.length === 0) { return ``; } const maxSize = Math.max(...island.modules.map((m) => m.size), 1); const rows = island.modules .map((m) => { const pct = (m.size / island.totalRaw) * 100; const barW = (m.size / maxSize) * 100; return ` ${escText(m.path)} ${fmtBytes(m.size)} ~${fmtBytes(m.gz)} ${pct.toFixed(1)}% `; }) .join(""); return ``; }) .join(""); // ── Shared-chunks table ────────────────────────────────────────────────── const sharedRows = shared .map( (c) => ` ${escText(c.id)} ${fmtBytes(c.size)} ${fmtBytes(c.gz)} ${c.usedBy.length} ${escText(c.usedBy.join(", ") || "—")} ` ) .join(""); // ── Summary cards ──────────────────────────────────────────────────────── const summaryCards = `
Total raw
${fmtBytes(summary.totalRaw)}
Total gzip
${fmtBytes(summary.totalGz)}
Islands
${summary.islandCount}
Shared chunks
${summary.sharedCount}
Largest island
${ summary.largestIsland ? `${escText(summary.largestIsland.name)} (${fmtBytes(summary.largestIsland.totalRaw)})` : "—" }
Heaviest dep
${ summary.heaviestDep ? `${escText(summary.heaviestDep.path)} (${fmtBytes(summary.heaviestDep.size)})` : "—" }
Dedupe savings
${fmtBytes(summary.dedupeSavings)}
`; // ── Phase 18.φ — Budget bar section ────────────────────────────────────── // // Renders one horizontal bar per island when a budget was evaluated, // coloured by `BudgetStatus`: green (within), yellow (within 10% of // limit), red (exceeded). The bar width is proportional to // `island.gz / gzLimit` (or `raw / rawLimit` if gzLimit is null). // When every axis is unconstrained the bar is hidden with a muted "—" // placeholder. Matches the "red/yellow/green" spec in Phase 18.φ. const budgetSection = budget ? renderBudgetSection(budget) : ""; return ` Mandu Bundle Analyzer

Mandu Bundle Analyzer

generated ${escText(summary.generatedAt)} · schema v${summary.version}

Summary

${summaryCards}
${budgetSection}

Islands (click to drill in)

${islandSvg || `No islands to display.`}
${drillPanels}

Shared chunks

${sharedRows || ``}
idsizegzipused by #islands
(no shared chunks — pure-SSR project?)

Islands

${islands .map( (i) => `` ) .join("")}
namerawgzipprioritysharedmodules
${escText(i.name)} ${fmtBytes(i.totalRaw)} ${fmtBytes(i.totalGz)} ${escText(i.priority)} ${escText(i.shared.join(", ") || "—")} ${i.modules.length}
`; } // ============================================================================ // Helpers — formatting + squarify // ============================================================================ /** * Phase 18.φ — render the budget-bar block. Colour-codes each island * by {@link BudgetReport.BudgetStatus} and the project-wide total (when * present). Islands without any applicable limit render a diagonal- * hatched "unbounded" bar so the user sees the row but understands * nothing is enforced. Self-contained: no JS, no external assets. */ function renderBudgetSection(budget: BudgetReport): string { const modeClass = budget.mode === "error" ? "error" : "warning"; const rows = budget.islands .map((i) => renderBudgetRow(i.name, i.raw, i.gz, i.rawLimit, i.gzLimit, i.status)) .join(""); const totalRow = budget.total ? renderBudgetRow( "", budget.total.raw, budget.total.gz, budget.total.rawLimit, budget.total.gzLimit, budget.total.status ) : ""; const exceedHeadline = budget.hasExceeded ? ` · ${budget.exceededCount} over limit` : ""; return `

Bundle budget ${escText(budget.mode)}

within approaching (≥90%) exceeded

${budget.withinCount}/${budget.islandCount} islands within limits${exceedHeadline}

${rows} ${totalRow}
`; } function renderBudgetRow( name: string, raw: number, gz: number, rawLimit: number | null, gzLimit: number | null, status: "within" | "within10" | "exceeded" ): string { // Prefer gz-axis progress bar when a gz limit exists (the 90%-of-the- // time-useful axis); fall back to raw when only raw is constrained. let pct = 0; let barClass: string = status; let meta: string; if (gzLimit !== null) { pct = Math.min(100, Math.max(0, (gz / Math.max(gzLimit, 1)) * 100)); meta = `${fmtBytes(gz)} / ${fmtBytes(gzLimit)} gz`; } else if (rawLimit !== null) { pct = Math.min(100, Math.max(0, (raw / Math.max(rawLimit, 1)) * 100)); meta = `${fmtBytes(raw)} / ${fmtBytes(rawLimit)} raw`; } else { barClass = "unbounded"; pct = 100; meta = `${fmtBytes(gz)} gz · no limit`; } return `
${escText(name)}
${meta}
`; } /** Human-readable byte formatter. Matches the style used by `printBundleStats`. */ export function fmtBytes(n: number): string { if (!Number.isFinite(n) || n <= 0) return "0 B"; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(2)} MB`; } function escText(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">"); } function escAttr(s: string): string { return escText(s).replace(/"/g, """).replace(/'/g, "'"); } /** Distinct dark-theme colour ramp, no dependency on d3-scale. */ const ISLAND_PALETTE = [ "#1e3a8a", "#155e75", "#166534", "#854d0e", "#7c2d12", "#581c87", "#831843", "#0f766e", "#3730a3", "#92400e", "#064e3b", "#6b21a8", "#134e4a", "#7f1d1d", "#1e40af", ]; interface SquarifyInput { name: string; value: number; island: T; } interface SquarifyRect { x: number; y: number; w: number; h: number; data: SquarifyInput; } /** * Minimal squarify treemap layout. * * Classic Bruls-Huijing-van-Wijk algorithm — pack rectangles into a * bounding box to minimise worst-case aspect ratio. Implemented in-repo so * the HTML report has zero runtime deps. ~60 LOC. * * Input is pre-sorted descending by `value`. We maintain a current "row" * (strip) and keep adding rectangles until the worst aspect ratio would * increase if we added the next one; at that point we lay out the row * and start a new one on the remaining area. */ function squarify( input: SquarifyInput[], width: number, height: number ): SquarifyRect[] { const sorted = [...input].sort((a, b) => b.value - a.value); const total = sorted.reduce((s, n) => s + n.value, 0); if (total <= 0 || sorted.length === 0) return []; // Scale values so the sum equals width * height. const scale = (width * height) / total; const scaled = sorted.map((n) => ({ ...n, area: n.value * scale })); const result: SquarifyRect[] = []; function worst(row: number[], side: number): number { if (row.length === 0) return Infinity; const sum = row.reduce((s, v) => s + v, 0); const rowMax = Math.max(...row); const rowMin = Math.min(...row); const s2 = side * side; const sum2 = sum * sum; return Math.max((s2 * rowMax) / sum2, sum2 / (s2 * rowMin)); } let x = 0; let y = 0; let w = width; let h = height; let i = 0; while (i < scaled.length) { const row: number[] = []; const rowData: typeof scaled = []; const side = Math.min(w, h); // Build row. while (i < scaled.length) { const next = scaled[i].area; const candidate = [...row, next]; if (row.length === 0 || worst(candidate, side) <= worst(row, side)) { row.push(next); rowData.push(scaled[i]); i++; } else { break; } } // Lay out row. const rowSum = row.reduce((s, v) => s + v, 0); const rowThickness = rowSum / side; if (w <= h) { // Horizontal strip along the top. let cx = x; for (let r = 0; r < row.length; r++) { const rw = row[r] / rowThickness; result.push({ x: cx, y, w: rw, h: rowThickness, data: rowData[r], }); cx += rw; } y += rowThickness; h -= rowThickness; } else { // Vertical strip along the left. let cy = y; for (let r = 0; r < row.length; r++) { const rh = row[r] / rowThickness; result.push({ x, y: cy, w: rowThickness, h: rh, data: rowData[r], }); cy += rh; } x += rowThickness; w -= rowThickness; } } return result; }