Relevant source files
The following files were used as context for generating this wiki page:
- apps/bundle-analyzer/lib/diff-treemap-layout.ts
- apps/bundle-analyzer/components/treemap-visualizer.tsx
- apps/bundle-analyzer/components/analyzer.tsx
- apps/bundle-analyzer/lib/treemap-layout.ts
- apps/bundle-analyzer/components/diff-treemap.tsx
- apps/bundle-analyzer/components/import-chain.tsx
- apps/bundle-analyzer/lib/analyze-data.ts
- packages/next/src/build/analyze/graph-dump.ts
- apps/bundle-analyzer/components/sidebar.tsx
- packages/next/src/cli/internal/static-routes-info.ts
- apps/bundle-analyzer/app/compare/page.tsx
- apps/bundle-analyzer/app/analyze/page.tsx
- apps/bundle-analyzer/app/page.tsx
- apps/bundle-analyzer/lib/module-graph.ts
- apps/bundle-analyzer/lib/diff.ts
- apps/bundle-analyzer/components/compare-layout.tsx
- packages/next/src/build/analyze/index.ts
- apps/bundle-analyzer/components/index.ts
- packages/next/src/cli/next-analyze.ts
- apps/bundle-analyzer/app/layout.tsx
- apps/bundle-analyzer/lib/analyzer-data.ts
- packages/next/src/cli/internal/turbo-trace-server.ts
- packages/next-bundle-analyzer/index.js
- packages/next/analyze/graph-v1.schema.json
Overview
The Bundle Analyzer provides an interactive web-based interface and data pipeline for visualizing, traversing, and diffing bundle sizes and module compositions across Next.js application builds. By parsing structured build artifacts and binary graph datasets emitted during production compilation, it empowers developers to inspect hierarchical directory structures, trace import dependencies, and pinpoint size regressions. Sources: apps/bundle-analyzer/app/layout.tsx:6-10, packages/next/src/build/analyze/graph-dump.ts:1-50, apps/bundle-analyzer/lib/analyze-data.ts:231-259
Build Graph Ingestion and Schema
CLI Export and Replay Pipeline
The analyzer pipeline initiates through the next analyze command or the export sub-pipeline managed by nextAnalyzeExport, which unpacks snapshot records without re-running compilation. The export flow reads history metadata from diagnostics/analyze/history/history.json and resolves target snapshots via snapshotNameSchema before invoking dumpAnalyzeGraph on the snapshot directory.
Sources: packages/next/src/cli/next-analyze.ts:36-63
| Option Name | Type | Default | Description |
|---|---|---|---|
experimentalAnalyze | boolean | — | Enables experimental build graph analysis workflows. |
profile | boolean | false | Activates CPU profiling and React production profiling during analysis. |
mangling | boolean | true | Controls variable mangling; disabling logs a debugging warning. |
port | number | 4000 | Port used by the interactive bundle analyzer web server. |
output | boolean | false | Writes results to disk instead of starting the interactive server. |
experimentalAppOnly | boolean | false | Restricts analysis strictly to the App Router. |
snapshot | string | — | Explicit identifier for capturing or loading a specific analysis snapshot. |
Sources: packages/next/src/cli/next-analyze.ts:20-34
Note
Replaying snapshots via nextAnalyzeExport entirely bypasses next.config loading, lock acquisition, and fresh compilation artifacts, relying solely on previously serialized analysis records.
Sources: packages/next/src/cli/next-analyze.ts:40-40
Binary Graph Data Structures and Schema Validation
Graph data is structured according to schema version 1 (urn:nextjs:analyze:analyzer-graph:v1), defining newline-delimited JSON record types: meta, module, route, output, part, and group. Binary files prepend a 4-byte big-endian JSON header length followed by the JSON payload and a packed binary edge section. Edges and offset indices are validated via validateEdges, ensuring record counts, offset monotonicity, and target index boundaries are strictly enforced.
Sources: packages/next/src/build/analyze/graph-dump.ts:61-128, packages/next/analyze/graph-v1.schema.json:3-13
Sources: packages/next/src/build/analyze/graph-dump.ts:61-128
Warning
module_index_hash acts as an ordered, snapshot-scoped fingerprint of modules.data indices; it guarantees matching snapshot structures but does not serve as proof of identical graphs or build outputs.
Sources: packages/next/analyze/graph-v1.schema.json:46-49
Caution
If header.schema_version differs from 1, readData throws an explicit validation error rather than attempting schema migration or fallback parsing.
Sources: packages/next/src/build/analyze/graph-dump.ts:72-76
Client-Side Data Fetching and Models
SWR Data Hydration and Snapshot Resolution
The client bundle analyzer manages state via custom hooks built on top of useSWR. The useAnalyzerModel hook fetches history metadata, active route configurations, route summaries, and raw binary analyzer payloads. Static endpoints such as /data/metadata.json, ${comparisonBaseDir}/routes.json, and ${comparisonBaseDir}/route-summaries.json are retrieved with revalidateOnFocus and revalidateOnReconnect explicitly set to false. Dynamic data files including ${comparisonBaseDir}/modules.data and analyze.data are loaded with suspense: true enabled to coordinate asynchronous asset unpacking.
Sources: apps/bundle-analyzer/components/analyzer.tsx:122-130, apps/bundle-analyzer/components/analyzer.tsx:165-198, apps/bundle-analyzer/components/analyzer.tsx:313-316, apps/bundle-analyzer/lib/analyzer-data.ts:6-14
| Hook Name | Default SWR Options | Suspense | Purpose |
|---|---|---|---|
useHistoryIndex | revalidateOnFocus: false, revalidateOnReconnect: false, shouldRetryOnError: false | false | Fetches history index data from /history/history.json. |
useSuspenseJsonData | revalidateOnFocus: false, revalidateOnReconnect: false (overridable) | true | Generic helper for fetching JSON assets with React Suspense enabled. |
useSWR (modulesData) | None specified | true | Fetches binary module index data from ${comparisonBaseDir}/modules.data. |
useSWR (analyzeData) | revalidateOnFocus: false, revalidateOnReconnect: false | true | Fetches and parses binary route-specific data buffers into AnalyzeData instances. |
Sources: apps/bundle-analyzer/components/analyzer.tsx:122-125, apps/bundle-analyzer/components/analyzer.tsx:165-191, apps/bundle-analyzer/components/analyzer.tsx:172-175, apps/bundle-analyzer/lib/analyzer-data.ts:6-33
Note
useAnalyzerModel invokes useSearchParams() before rendering suspense boundaries, allowing static prerendering bailouts to execute correctly prior to asynchronous data resolution.
Sources: apps/bundle-analyzer/components/analyzer.tsx:106-108
Module Graph Model Generation and Scope Classification
Once module and analysis buffers are fetched, AnalyzeData parses a 4-byte big-endian JSON header length, decodes the UTF-8 JSON header metadata, and wraps the trailing binary buffer in a DataView. Module relationships are processed by computeActiveEntries, which scans modules matching known Next.js template identifiers such as next/dist/esm/build/templates/pages.js, next/dist/esm/build/templates/app-page.js, and [next]/entry/page-loader.ts.
Sources: apps/bundle-analyzer/lib/analyze-data.ts:236-252, apps/bundle-analyzer/lib/module-graph.ts:25-79
const potentialEntryDependents = [
'next/dist/esm/build/templates/pages.js',
'next/dist/esm/build/templates/pages-api.js',
'next/dist/esm/build/templates/pages-edge-api.js',
'next/dist/esm/build/templates/edge-ssr.js',
'next/dist/esm/build/templates/app-route.js',
'next/dist/esm/build/templates/edge-app-route.js',
'next/dist/esm/build/templates/app-page.js',
'next/dist/esm/build/templates/edge-ssr-app.js',
'next/dist/esm/build/templates/middleware.js',
'[next]/entry/page-loader.ts',
]Sources: apps/bundle-analyzer/lib/module-graph.ts:29-40
Module depth maps are built via breadth-first search utilizing computeModuleDepthMap. Async dependencies impose a depth penalty of 1000 and are buffered into a delayed queue sorted in descending order of depth to guarantee correct hierarchical evaluation order. Finally, computeSourceLoadScopes classifies every source index into one of five load scopes: 'initial', 'async', 'mixed', 'traced', or 'unknown'.
Sources: apps/bundle-analyzer/lib/module-graph.ts:86-167, apps/bundle-analyzer/lib/module-graph.ts:173-244
| Scope Name | Bitmask Value | Description |
|---|---|---|
ModuleLoadScope.Initial | 1 | Modules loaded immediately during initial route entry. |
ModuleLoadScope.Async | 2 | Modules loaded via asynchronous chunks or dynamic imports. |
ModuleLoadScope.Traced | 4 | Modules discovered via dependency graph tracing heuristics. |
Sources: apps/bundle-analyzer/lib/module-graph.ts:3-7
Caution
assertMatchingModuleIndex verifies that the module_index_hash string from ModulesData matches the fingerprint inside AnalyzeData, throwing an explicit error if a route record is paired with a mismatched module table.
Sources: apps/bundle-analyzer/lib/analyze-data.ts:215-226
Treemap Layout Calculation
Hierarchical Directory Packing and Metadata Precomputation
The treemap layout engine processes hierarchical module data by first executing a bottom-up precomputation sweep across all sources via precomputeSourceMetadata. This pass calculates aggregate uncompressed and compressed sizes for every directory node and establishes whether a source is filtered out based on visibility rules.
Sources: apps/bundle-analyzer/lib/treemap-layout.ts:83-142
export enum SizeMode {
Compressed = 'compressed',
Uncompressed = 'uncompressed',
}Sources: apps/bundle-analyzer/lib/treemap-layout.ts:42-45
During recursive layout generation inside computeTreemapLayoutFromAnalyzeInternal, single-child directories are folded together when their combined path length does not exceed 40 characters, reducing visual clutter in deep folder structures.
Sources: apps/bundle-analyzer/lib/treemap-layout.ts:162-180
Note
I> Directory nodes whose height drops below 30 pixels automatically switch to a collapsed state ('collapsed-directory'), bypassing child coordinate computation and rendering an aggregate item count instead.
Sources: apps/bundle-analyzer/lib/treemap-layout.ts:206-237
Descendant Counting and Coordinate Computation
When a directory node is collapsed due to spatial constraints, it recursively counts all descendant files using a depth-first traversal of the child indices. Sources: apps/bundle-analyzer/lib/treemap-layout.ts:216-224
// Count all descendant files
function countDescendants(idx: SourceIndex): number {
const children = analyzeData.sourceChildren(idx)
if (children.length === 0) return 1
return children.reduce(
(sum, childIdx) => sum + countDescendants(childIdx),
0
)
}Sources: apps/bundle-analyzer/lib/treemap-layout.ts:216-224
For uncollapsed directories with visible children, child nodes are filtered, mapped to their active size weights under either SizeMode.Compressed or SizeMode.Uncompressed, and sorted in descending order of size before invoking layoutTreemap to compute bounding rectangles within the content area.
Sources: apps/bundle-analyzer/lib/treemap-layout.ts:240-282
| SizeMode Constant | Value String | Description |
|---|---|---|
SizeMode.Compressed | 'compressed' | Uses gzip/brotli compressed size for node sizing and weights. |
SizeMode.Uncompressed | 'uncompressed' | Uses raw uncompressed byte size for node sizing and weights. |
Sources: apps/bundle-analyzer/lib/treemap-layout.ts:42-45
Interactive Treemap Canvas Rendering
Canvas Drawing Loop and Visual Hierarchy Styling
function getFileColor(
node: {
js?: boolean
css?: boolean
json?: boolean
asset?: boolean
server?: boolean
client?: boolean
traced?: boolean
specialModuleType: SpecialModule | null
},
colors: CanvasColors
): string {
const { js, css, json, asset, client, traced, specialModuleType } = node
if (isPolyfill(specialModuleType)) {
return colors.polyfill
}
let color = '#9ca3af' // gray-400 default
if (js) color = '#4682b4'
if (css) color = '#8b7d9e'
if (json) color = '#297a3a'
if (asset) color = '#da2f35'
if (!client) {
// Make it darker for server (30% darker)
color = darken(0.3, color)
if (traced) {
// Make it slightly lighter (15% lighter than darkened)
color = lighten(0.15, color)
}
}
return color
}Sources: apps/bundle-analyzer/components/treemap-visualizer.tsx:57-92
File tile coloring relies on file type flags and special module classifiers. Polyfill modules receive the dedicated polyfill color, while standard files are mapped using base hex values: #4682b4 for JavaScript (js), #8b7d9e for CSS (css), #297a3a for JSON (json), and #da2f35 for assets (asset), defaulting to #9ca3af. Non-client (server) modules apply a 0.3 darkening factor via darken(0.3, color), and if traced (traced), are lightened by 0.15 using lighten(0.15, color).
Sources: apps/bundle-analyzer/components/treemap-visualizer.tsx:57-92
| File Type / Flag | Base Hex Color / Transformation | Description |
|---|---|---|
| Polyfill Module | colors.polyfill | Applied when isPolyfill() matches special module types. |
JavaScript (js) | #4682b4 | Default color for client/server JS files. |
CSS (css) | #8b7d9e | Stylesheet bundles. |
JSON (json) | #297a3a | JSON data modules. |
Asset (asset) | #da2f35 | Static assets and binary files. |
| Server Non-Client | darken(0.3, color) | Darkens base module color by 30% for server environments. |
| Traced Server | lighten(0.15, darkenedColor) | Lightens darkened server color by 15% when traced. |
Sources: apps/bundle-analyzer/components/treemap-visualizer.tsx:57-92
Title and size font sizing inside rendered nodes is derived dynamically from the allocated title bar height using calculateTitleFontSizes. The title font size is restricted to a maximum of 10 pixels or half the title bar height (titleBarHeight * 0.5), while the size font takes the minimum of 9 pixels or titleFontSize - 2. Text measurement uses an LRU-style cache bounded at 30,000 entries (TEXT_WIDTH_CACHE_SIZE) combined with measureTextCached to prevent layout performance degradation.
Sources: apps/bundle-analyzer/components/treemap-visualizer.tsx:101-138
Note
truncateTextWithEllipsisIfNeeded computes text fitting using binary search over the string length to insert ellipsis markers (...) without repeatedly polluting the text width cache with failed measurement attempts.
Sources: apps/bundle-analyzer/components/treemap-visualizer.tsx:140-170
Hit Testing and Zooming Mechanics
function findNodeAtPosition(
node: LayoutNode,
x: number,
y: number
): LayoutNode | null {
const { rect } = node
// Check if point is within this node's bounds
if (
x < rect.x ||
x > rect.x + rect.width ||
y < rect.y ||
y > rect.y + rect.height
) {
return null
}
if (node.type === 'collapsed-directory') {
return node
}
// For regular directories, check if we're in the title bar first
if (node.type === 'directory') {
const titleBarHeight = node.titleBarHeight || 0
if (y >= rect.y && y <= rect.y + titleBarHeight) {
return node // Clicked on title bar
}
}
// Check children (if any)
if (node.children) {
for (const child of node.children) {
const found = findNodeAtPosition(child, x, y)
if (found) return found
}
}
return node
}Sources: apps/bundle-analyzer/components/treemap-visualizer.tsx:172-210
Hit testing is executed by findNodeAtPosition, which recursively traverses layout nodes to locate the deepest matching rectangle enclosing coordinates (x, y). If a node is a collapsed directory ('collapsed-directory'), it returns immediately. For standard directory nodes, it checks whether the vertical coordinate falls within the title bar bounds (y >= rect.y && y <= rect.y + titleBarHeight), returning the directory node itself when matched; otherwise, it descends into child nodes.
Sources: apps/bundle-analyzer/components/treemap-visualizer.tsx:172-210
Warning
Pressing the Escape key globally dispatches a window keydown event handler in AnalyzerModel that resets both the selected source index and focused source index back to the root source index of the active analysis data, provided an input or textarea element is not actively focused.
Sources: apps/bundle-analyzer/components/analyzer.tsx:223-244
Import Chain Dependency Traversal
Sidebar Component and Selection Details
When a source file is selected in the bundle analyzer, the Sidebar component delegates rendering to SelectionDetails, which inspects whether the node contains child modules using analyzeData.sourceChildren(selectedSourceIndex).length > 0. If a node is a leaf module lacking child nodes, SelectionDetails renders the ImportChain component alongside output chunk identifiers extracted via analyzeData.sourceChunks(selectedSourceIndex).
{selectedSource && !hasChildModules && (
<>
<ImportChain
startFileId={selectedSourceIndex}
analyzeData={analyzeData}
modulesData={modulesData}
depthMap={moduleDepthMap}
environmentFilter={environmentFilter}
/>
{chunks.length > 0 ? (
<div className="mt-2">
<p className="text-xs font-semibold text-foreground">
Output Chunks
</p>
<ul className="text-xs text-muted-foreground font-mono mt-1 space-y-1">
{chunks.map((chunk) => (
<li key={chunk} className="break-all">
{chunk}
</li>
))}
</ul>
</div>
) : null}
</>
)}Sources: apps/bundle-analyzer/components/sidebar.tsx:157-181
The ImportChain component manages route filtering through a checkbox bound to currentRouteOnly state, allowing users to toggle between including only dependent modules tied to the current route's bundle or inspecting all routes. State tracking for selected dependents across chain levels is maintained using selectedIndices. Handlers handlePrevious and handleNext update these indices by decrementing or incrementing with bounds-checking against level.totalCount - 1, slicing the index array back to levelIndex + 1.
export function ImportChain({
startFileId,
analyzeData,
modulesData,
depthMap,
environmentFilter,
}: ImportChainProps) {
// Filter to include only the current route
const [currentRouteOnly, setCurrentRouteOnly] = useState(true)
// Track which dependent is selected at each level
const [selectedIndices, setSelectedIndices] = useState<number[]>([])
const chain = buildImportChain(
startFileId,
analyzeData,
modulesData,
depthMap,
environmentFilter,
selectedIndices,
currentRouteOnly
)
const handlePrevious = (levelIndex: number) => {
setSelectedIndices((prev) => {
const newIndices = [...prev]
const currentIdx = newIndices[levelIndex] ?? 0
const level = chain[levelIndex]
newIndices[levelIndex] =
currentIdx > 0 ? currentIdx - 1 : level.totalCount - 1
return newIndices.slice(0, levelIndex + 1)
})
}
const handleNext = (levelIndex: number) => {
setSelectedIndices((prev) => {
const newIndices = [...prev]
const currentIdx = newIndices[levelIndex] ?? 0
const level = chain[levelIndex]
newIndices[levelIndex] =
currentIdx < level.totalCount - 1 ? currentIdx + 1 : 0
return newIndices.slice(0, levelIndex + 1)
})
}Sources: apps/bundle-analyzer/components/import-chain.tsx:311-354
Layer Icons and Graph Traversal
Each level of the rendered import chain evaluates module layers against regular expressions to determine badge icon states. The layer types and corresponding icon mapping rules are outlined below:
| Layer Matcher | Icon Component | Icon Color / State | Description |
|---|---|---|---|
/client/ | PanelTop | Green (text-green-500) if flags.client, else Gray | Included on client-side of this route |
/ssr/ | Globe | Blue (text-blue-500) if flags.server, else Gray | Included on server-side rendering layer |
/rsc/ | Server | Orange (text-orange-500) if flags.server, else Gray | Included as Server Component |
/route|api/ | SquareFunction | Red (text-red-500) if flags.server, else Gray | Included for API routes |
app / pages / externals-tracing | Box / File / Package | Layer-specific colors | Fallback handler via LayerIcon for App/Pages routers and external assets |
Sources: apps/bundle-analyzer/components/import-chain.tsx:478-580
Warning
buildImportChain incorporates a hard safety limit that breaks out of dependent traversal loops if levelIndex > 100, preventing infinite recursion when circular dependencies exist in the module graph.
Sources: apps/bundle-analyzer/components/import-chain.tsx:304-306
Bundle Comparison and Diffing
Synthetic Diff Layout Generation
The bundle analyzer computes build-to-build size differentials by joining leaf source entries across two AnalyzeData datasets using their stable full source paths. The diffSources function filters out directory aggregations and empty contributions where both size and compressed size evaluate to zero, collecting per-side leaf entries into maps keyed by file path. Each resulting diff row captures status flags, absolute sizes, compressed sizes, and OR-merged environment flags across both sides.
function collectLeaves(
side: 'A' | 'B',
data: AnalyzeData,
options: SourceDiffOptions
): Map<string, LeafEntry> {
const out = new Map<string, LeafEntry>()
const count = data.sourceCount()
for (let i = 0; i < count; i++) {
// Only include leaf sources (real files, not directory aggregations).
if (data.sourceChildren(i).length > 0) continue
if (options.filterSource && !options.filterSource(side, i)) continue
const fullPath = data.getFullSourcePath(i)
if (!fullPath) continue
const own = data.getOwnSizes(i)
// Skip empty contributions — they aren't really "in" the bundle.
if (own.size === 0 && own.compressedSize === 0) continue
const flags = data.getSourceFlags(i)
out.set(fullPath, {
index: i,
size: own.size,
compressedSize: own.compressedSize,
client: flags.client,
server: flags.server,
})
}
return out
}
/**
* Diffs every source between two analyze datasets, joining by full source
* path (stable across builds modulo file moves).
*
* Directories (sources with children) are excluded — only leaf "files" appear
* in the resulting rows. Their own size is the sum of their {@link
* AnalyzeData.sourceChunkParts} contributions.
*/
export function diffSources(
analyzeA: AnalyzeData | null,
analyzeB: AnalyzeData | null,
options: SourceDiffOptions = {}
): DiffSummary<SourceDiffRow> {
const summary = emptySummary<SourceDiffRow>()
const leavesA = analyzeA
? collectLeaves('A', analyzeA, options)
: new Map<string, LeafEntry>()
const leavesB = analyzeB
? collectLeaves('B', analyzeB, options)
: new Map<string, LeafEntry>()
const allKeys = new Set<string>([...leavesA.keys(), ...leavesB.keys()])
for (const key of allKeys) {
const a = leavesA.get(key)
const b = leavesB.get(key)
const pretty = prettifySourcePath(key)
pushRow(summary, {
key,
name: pretty.display,
status: statusFor(
a?.size ?? 0,
b?.size ?? 0,
a !== undefined,
b !== undefined
),
sizeA: a?.size ?? 0,
sizeB: b?.size ?? 0,
compressedA: a?.compressedSize ?? 0,
compressedB: b?.compressedSize ?? 0,
sourceIndexA: a?.index ?? null,
sourceIndexB: b?.index ?? null,
pathKind: pretty.kind,
packageName: pretty.packageName,
// OR-merge env flags across both sides so the badge reflects the
// row's overall environment(s). A source that's client-only in A
// and removed in B is still "client" for the purpose of the row.
client: (a?.client ?? false) || (b?.client ?? false),
server: (a?.server ?? false) || (b?.server ?? false),
})
}
Sources: apps/bundle-analyzer/lib/diff.ts:237-318
To visualize these differentials within the treemap interface, createDiffTreemapLayout constructs a synthetic hierarchical tree containing only changed modules. Path segments are parsed from row keys—treating Next.js project output paths starting with [project]/.next/ as unattributed output basenames—and nested directory nodes are instantiated dynamically.
export function createDiffTreemapLayout(
rows: SourceDiffRow[],
useCompressed: boolean
): DiffTreemapLayout {
let nextIndex = 0
const root: DiffTreeNode = {
index: nextIndex++,
name: 'All Changed Modules',
parentIndex: null,
size: 0,
children: [],
}
const nodeByIndex = new Map<number, DiffTreeNode>([[root.index, root]])
const directoryByPath = new Map<string, DiffTreeNode>([['', root]])
const rowBySourceIndex = new Map<number, SourceDiffRow>()
const sourceIndexByKey = new Map<string, number>()
for (const row of rows) {
const change = delta(row, useCompressed)
if (change === 0) continue
const parts = isNextBuildOutput(row.key)
? ['Unattributed output', getBasename(row.key)]
: row.key.split('/').filter(Boolean)
let parent = root
let directoryPath = ''
for (const part of parts.slice(0, -1)) {
directoryPath += `/${part}`
let directory = directoryByPath.get(directoryPath)
if (!directory) {
directory = {
index: nextIndex++,
name: `${part}/`,
parentIndex: parent.index,
size: 0,
children: [],
}
parent.children.push(directory)
directoryByPath.set(directoryPath, directory)
nodeByIndex.set(directory.index, directory)
}
parent = directory
}
const leaf: DiffTreeNode = {
index: nextIndex++,
name: parts.at(-1) ?? row.name,
parentIndex: parent.index,
size: Math.abs(change),
row,
children: [],
}
parent.children.push(leaf)
nodeByIndex.set(leaf.index, leaf)
rowBySourceIndex.set(leaf.index, row)
sourceIndexByKey.set(row.key, leaf.index)
}Sources: apps/bundle-analyzer/lib/diff-treemap-layout.ts:20-77
Note
The absolute magnitude of the change (Math.abs(change)) is assigned as the leaf node size, ensuring that tiles in the diff treemap directly represent size deltas rather than absolute file footprints.
Sources: apps/bundle-analyzer/lib/diff-treemap-layout.ts:65-72
Layout Calculations and Color Mapping
Once leaf and directory node sizes are aggregated recursively via aggregateSize, computeLayout processes rectangular bounds by determining title bar heights and checking collapse states. If a directory rectangle's height drops below 30 pixels, it is rendered as a collapsed directory containing an item count; otherwise, child nodes are sorted descending by size and passed to the treemap layout engine.
function aggregateSize(node: DiffTreeNode): number {
if (node.row) return node.size
node.size = node.children.reduce(
(total, child) => total + aggregateSize(child),
0
)
return node.size
}
aggregateSize(root)
function computeLayout(node: DiffTreeNode, rect: LayoutRect): LayoutNode {
if (node.row) {
return {
name: node.name,
size: node.size,
type: 'file',
rect,
sourceIndex: node.index,
specialModuleType: null,
client: node.row.client,
server: node.row.server,
}
}
const titleBarHeight = Math.round(
Math.max(12, Math.min(24, rect.height * 0.1))
)
const isCollapsed = rect.height < 30
if (isCollapsed) {
return {
name: node.name,
size: node.size,
type: 'collapsed-directory',
rect,
titleBarHeight,
children: [],
itemCount: countLeaves(node),
sourceIndex: node.index,
specialModuleType: null,
}
}
const contentRect: LayoutRect = {
x: Math.round(rect.x),
y: Math.round(rect.y + titleBarHeight),
width: Math.max(0, Math.round(rect.width - 2)),
height: Math.max(0, Math.round(rect.height - titleBarHeight - 2)),
}
const children = [...node.children].sort((a, b) => b.size - a.size)
const childRects = layoutTreemap(
children.map((child) => child.size),
contentRect
)
return {
name: node.name,
size: node.size,
type: 'directory',
rect,
titleBarHeight,
children: children.map((child, index) =>
computeLayout(child, childRects[index])
),
sourceIndex: node.index,
specialModuleType: null,
}
}Sources: apps/bundle-analyzer/lib/diff-treemap-layout.ts:79-145
The DiffTreemap component overrides file colors and size labels using colorForRow, scaling fill opacities based on the relative magnitude of the change against baseline measurements.
const diffLayout = useMemo(
() => createDiffTreemapLayout(summary.rows, useCompressed),
[summary.rows, useCompressed]
)
const { rowBySourceIndex, sourceIndexByKey } = diffLayout
function getFileColorOverride(
node: LayoutNode,
colors: CanvasColors
): string | undefined {
if (node.sourceIndex === undefined) return undefined
const row = rowBySourceIndex.get(node.sourceIndex)
return row ? colorForRow(row, useCompressed, colors) : undefined
}
// Show size deltas on changed tiles instead of absolute sizes.
function getFileSizeLabel(node: LayoutNode): string | undefined {
if (node.sourceIndex === undefined) return undefined
const row = rowBySourceIndex.get(node.sourceIndex)
if (!row || row.status === 'identical') return undefined
return formatDelta(delta(row, useCompressed))
}
// Focus state stays local: it controls drill-in/zoom, which is purely a
// visual concern of the treemap and shouldn't affect the sidebar.
const initialRoot = diffLayout.rootIndex
const [focusedSourceIndex, setFocusedSourceIndex] =
useState<number>(initialRoot)
// Translate selection into the synthetic tree's index. If the selected
// path has no size delta, fall back to the root of changed files.
const selectedSourceIndex =
(selectedKey != null ? sourceIndexByKey.get(selectedKey) : undefined) ??
initialRoot
const handleSelectSourceIndex = (idx: number) => {
if (!onSelectKey) return
const row = rowBySourceIndex.get(idx)
onSelectKey(row?.key ?? null)
}
if (!data) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No data for this route.
</div>
)
}
return (
<TreemapVisualizer
source={diffLayout}
selectedSourceIndex={selectedSourceIndex}
onSelectSourceIndex={handleSelectSourceIndex}
focusedSourceIndex={focusedSourceIndex}
onFocusSourceIndex={setFocusedSourceIndex}
getFileSizeLabel={getFileSizeLabel}
sizeMode={useCompressed ? SizeMode.Compressed : SizeMode.Uncompressed}
getFileColorOverride={getFileColorOverride}
searchQuery={searchQuery}
overlay={<DiffLegend />}
/>
)
}
/**
* Picks a fill color for a diff row. Mirrors the previous DiffTreemap's
* scheme: bright blue/amber for added/removed and lighter tints scaled by the
* relative magnitude of the change for grew/shrank.
*/
function colorForRow(
row: SourceDiffRow,
useCompressed: boolean,
colors: CanvasColors
): string {
if (row.status === 'added') return colors.increase
if (row.status === 'removed') return colors.decrease
const deltaValue = useCompressed
? row.compressedB - row.compressedA
: row.sizeB - row.sizeASources: apps/bundle-analyzer/components/diff-treemap.tsx:63-150
Related
On This Page
OverviewBuild Graph Ingestion and SchemaCLI Export and Replay PipelineBinary Graph Data Structures and Schema ValidationClient-Side Data Fetching and ModelsSWR Data Hydration and Snapshot ResolutionModule Graph Model Generation and Scope ClassificationTreemap Layout CalculationHierarchical Directory Packing and Metadata PrecomputationDescendant Counting and Coordinate ComputationInteractive Treemap Canvas RenderingCanvas Drawing Loop and Visual Hierarchy StylingHit Testing and Zooming MechanicsImport Chain Dependency TraversalSidebar Component and Selection DetailsLayer Icons and Graph TraversalBundle Comparison and DiffingSynthetic Diff Layout GenerationLayout Calculations and Color MappingRelated