GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Treemap Chart: 9 Canvas Layout Scenes

How does a treemap chart split cells? 01 re-measures the worst ratio as each cell joins a row, 02 springs cells to new slots on a value change, and 06 measures

A Treemap Chart Is Not a Picture to Read Area From but a Calculation That Divides It: a Treemap Starts by Filling One Rectangle With a List of Values Without a Gap

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine scenes share one design. A cell's position is not decided by the drawing code, because squarify and slice return a list of rectangles and the screen only paints that list against one scene clock. There is no library, no WebGL and no CDN, and the single random source is a seeded generator, so one seed builds the same board each run.

How a list of values becomes cell areas

A treemap fills one rectangle with no gap and makes each cell's area proportional to its value. BASE holds 24 values from large to small, each scene takes the first few, and the names are made-up buckets such as video, photo and music, so no real company or service appears.

The layout function computes and the screen paints the result

slice takes an array of values and one rectangle and returns { x, y, w, h } per cell as a pure function. squarify has the same shape, so the drawing code fills those rectangles and puts text on them without ever computing an area itself.

    // One direction slice: no rows, only vertical strips in input order
    function slice(vals, R) {
        let total = 0;
        for (const v of vals) {
            total += Math.max(v, 1e-6);
        }
        let x = R.x;
        return vals.map(function (v) {
            const w = R.w * Math.max(v, 1e-6) / total;
            const r = { x: x, y: R.y, w: w, h: R.h };
            x += w;
            return r;
        });
    }

Treemap Chart: 9 Scenes — Row Closing That Aims for Squares, a Spring Reflow When Values Change, a Drill Down Into a Cell, Diverging Color From Change Rate, Nesting With Headers and Padding, Labels That Fit by Measuring, Rank Crossing Swaps, a Rectangle and Circle Compare Morph and a Folder Size Hero

The order moves from layout to a landing screen. 01 and 02 cover the layout and its reflow, 03 to 05 cover layers and color, 06 and 07 cover text and rank, and 08 and 09 cover a shape compare and a hero.

What nine renders measured for area and motion

Scene Cumulative area Moving frames Mean intensity
01 Row closing 44.1% 19/23 73.9
02 Spring reflow 41.6% 21/23 44.0
03 Drill down 48.0% 15/23 45.4
04 Diverging color 22.4% 23/23 43.0
05 Nested padding 39.5% 18/23 19.0
06 Label fit 38.7% 22/23 53.2
07 Rank crossing 44.2% 23/23 61.6
08 Circle morph 26.5% 12/23 32.8
09 Disk hero 54.6% 12/23 65.7

Scene 09 covers the most area at 54.6% and 04 the least at 22.4%, and mean intensity peaks at 73.9 in 01 and bottoms out at 19.0 in 05. Scenes 04 and 07 moved in 23/23 frames, while 08 and 09 sat still for about half of them at 12/23.

01A Split That Aims for Squares Instead of Long Strips

When a disk or budget breakdown becomes cells, long thin cells hide the small values. On the left, squarify re-measures the worst cell ratio with worstRow each time a cell joins a row and closes the row once the ratio gets worse, while slice on the right cuts vertical strips with no rows. With 12 cells the worst ratio is 1.58 on the left and 28.73 on the right.

A Split That Aims for Squares Instead of Long Strips
Open in a new tab

01 · A Split That Aims for Squares Instead of Long Strips — Measured area 44.126% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

worstRowsquarifyslice
    function worstRow(areas, side) {
        let s = 0;
        let mx = 0;
        let mn = Infinity;
        for (const a of areas) {
            s += a;
            if (a > mx) {
                mx = a;
            }
            if (a < mn) {
                mn = a;
            }
        }
        const s2 = s * s;
        const d2 = side * side;
        return Math.max(d2 * mx / s2, s2 / (d2 * mn));
    }

02A Reflow Where Cells Spring to Their New Slots When Values Change

On a dashboard whose values change live, cells that teleport make the eye lose which cell went where. springStep pulls each of a cell's four edges toward its target with its own damped spring, the target is recomputed by relayout only at the moment a value changes, and the board on the right paints the target directly so cells pop. After the document cell is multiplied by 2.6 once, every cell stays within 0.5 px of its target from 1.45 s on, with a peak overshoot of 24.0 px.

A Reflow Where Cells Spring to Their New Slots When Values Change
Open in a new tab

02 · A Reflow Where Cells Spring to Their New Slots When Values Change — Measured area 41.64% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPRING_KspringSteprelayout
    function springStep(cur, vel, tgt, k, zeta, dt) {
        const c = 2 * zeta * Math.sqrt(k);
        const n = Math.max(1, Math.ceil(dt * 120));
        const h = dt / n;
        for (let s = 0; s < n; s += 1) {
            for (let j = 0; j < cur.length; j += 1) {
                vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
                cur[j] += vel[j] * h;
            }
        }
    }

03A Drill Down That Zooms a Cell Into Its Inner Children

Picking a folder or category and watching it grow to fill the view is a drill down, and it suits data with layers. viewRect(p) interpolates the viewed rectangle toward the group rectangle, toScreen maps world coordinates into that view, and when the zoom ends kidLayout fills the inside with the same squarify. The first group stretches ×1.92 wide and ×1.65 tall and the third ×2.08 wide and ×2.60 tall, so groups with a different aspect zoom unevenly.

A Drill Down That Zooms a Cell Into Its Inner Children
Open in a new tab

03 · A Drill Down That Zooms a Cell Into Its Inner Children — Measured area 48.039% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

ZOOM_TviewRecttoScreen
    function viewRect(p) {
        if (zg < 0) {
            return PANEL_L;
        }
        const g = rootRects[zg];
        return { x: lerp(PANEL_L.x, g.x, p), y: lerp(PANEL_L.y, g.y, p), w: lerp(PANEL_L.w, g.w, p), h: lerp(PANEL_L.h, g.h, p) };
    }
    function toScreen(r, v) {
        const sx = PANEL_L.w / v.w;
        const sy = PANEL_L.h / v.h;
        return { x: PANEL_L.x + (r.x - v.x) * sx, y: PANEL_L.y + (r.y - v.y) * sy, w: r.w * sx, h: r.h * sy };
    }

04Pastel Diverging Colors Painted From a Single Change Rate

On a price or score board, area should carry the value and color the change rate, so each cell says two separate things. divergeColor(d) leaves a cell neutral when the absolute change is 0.12 or less and otherwise blends through ease toward rose for a fall or mint for a rise, while valueColor on the right derives color from the value and repeats the same information. In all four data sets 4 cells sit at or below 0.12, and the rise to fall counts are 6 to 4, 5 to 5, 6 to 4 and 5 to 5.

Pastel Diverging Colors Painted From a Single Change Rate
Open in a new tab

04 · Pastel Diverging Colors Painted From a Single Change Rate — Measured area 22.384% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GRAY_NEAR_ZEROdivergeColorDELTA_SETS
    function divergeColor(d) {
        const a = clamp(Math.abs(d), 0, 1);
        const k = a <= GRAY_NEAR_ZERO ? 0 : ease((a - GRAY_NEAR_ZERO) / (1 - GRAY_NEAR_ZERO));
        return mix(NEUTRAL, d < 0 ? ROSE : MINT, k);
    }
    // Contrast board: color comes from the value, not the change rate (a bigger value gives a deeper lavender)
    function valueColor(v) { return mix([236, 232, 250], [184, 170, 236], clamp(v / BASE[0], 0, 1)); }
    function signed(d) { const p = Math.round(d * 100); return (p > 0 ? '+' : '') + p + '%'; }

05Nested Cells That Mark Layers With Group Headers and Inner Padding

A budget split into departments, teams and people hides its layer borders without headers and padding. layNode cuts off a header strip, shrinks the rectangle by the padAt padding and lays the children out again with squarify, where the left board multiplies the padding by 0.55 at each depth and the right gives every layer the same padding. At 8 px the leaves cover 40.1% of the board on the left against 19.1% on the right, and the shortest leaf side is 5.2 px against 0 px.

Nested Cells That Mark Layers With Group Headers and Inner Padding
Open in a new tab

05 · Nested Cells That Mark Layers With Group Headers and Inner Padding — Measured area 39.463% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

HEADER_HPAD_DECAYlayNode
    function padAt(depth, flat) { return flat ? PAD : PAD * Math.pow(PAD_DECAY, depth); }
    // Place node n in rectangle R. Cut the header (depth 1 and 2), then re-lay the children in the rectangle shrunk by the inner padding
    function layNode(n, R, depth, flat) {
        const b = { r: R, inner: R, depth: depth, name: n.name, v: n.v, at: 0, kids: [] };
        seen[depth] += 1;
        b.at = depth === 0 ? 0 : (depth === 1 ? 0.4 + (seen[1] - 1) * 0.15 : (depth === 2 ? 1.5 + (seen[2] - 1) * 0.08 : 2.8 + (seen[3] - 1) * 0.03));
        if (n.kids.length === 0) {
            return b;
        }
        const p = padAt(depth, flat);
        const head = depth === 0 ? 0 : HEADER_H;
        const innerR = { x: R.x + p, y: R.y + head + (depth === 0 ? p : 0), w: Math.max(0, R.w - 2 * p), h: Math.max(0, R.h - head - p - (depth === 0 ? p : 0)) };
        b.inner = innerR;
        const lay = squarify(n.kids.map(function (k) { return k.v; }), innerR);
        b.kids = n.kids.map(function (k, i) { return layNode(k, lay.rects[i], depth + 1, flat); });
        return b;
    }

06A Label Fit That Measures the Cell Then Shrinks or Hides the Text

With many small cells, a name longer than its cell spills into the neighbor. fitPx estimates text width with estW, steps down from 15 px by 2 px to find the largest size that fits and returns 0 below 8 px to hide the name, while the right board fixes 12 px so the spill shows on the neighbor. Fed the 14 English names and a board squeezed to 0.25 wide, 13 of 14 cells hide their name on the left and 14 of 14 spill on the right.

A Label Fit That Measures the Cell Then Shrinks or Hides the Text
Open in a new tab

06 · A Label Fit That Measures the Cell Then Shrinks or Hides the Text — Measured area 38.724% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

CHAR_WFONT_STEPHIDE_MIN
    function fitPx(s, r) {
        for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
            if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) {
                return px;
            }
        }
        return 0;
    }

07Cells That Swap Places When Flowing Values Cross in Rank

On a live share or visit board, recomputing the layout for every small drift makes cells tremble. relayoutL runs only on a frame where orderKey changes or a value drifts more than 20% from its last laid out value, so two cells whose ranks cross spring past each other and trade places. Over one lap of about 9.8 seconds the left board recomputes 31 times and the right board 527 times.

Cells That Swap Places When Flowing Values Cross in Rank
Open in a new tab

07 · Cells That Swap Places When Flowing Values Cross in Rank — Measured area 44.235% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

LCG_STEPorderKeyRANK_SPRING_K
        let apart = false;
        for (let i = 0; i < CELL_N; i += 1) {
            if (Math.abs(val[i] - usedVals[i]) > usedVals[i] * 0.2) {
                apart = true;
            }
        }
        if (apart || orderKey(val) !== usedKey) {
            relayoutL();
        }

08A Morph That Compares the Same Data as Rectangles and Circles

How big a value feels depends on the chart shape, and morphing the same data between rectangles and circles makes that plain. The left radius is r = √(area × FILL / π) so each circle keeps its cell area, the right diameter follows the value, and pushApart pushes overlapping circles apart over 80 rounds. Circle areas cover 60.0% of the board against 42.4%, and the largest to smallest area ratio is about 6 on the left and about 32 on the right.

A Morph That Compares the Same Data as Rectangles and Circles
Open in a new tab

08 · A Morph That Compares the Same Data as Rectangles and Circles — Measured area 26.478% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

FILLpushApartPUSH_ROUNDS
        rectsL = squarify(vals, PANEL_L).rects;
        rectsR = squarify(vals, PANEL_R).rects;
        const radL = rectsL.map(function (r) { return Math.sqrt(r.w * r.h * FILL / Math.PI); });
        // On the right, the largest cell's diameter is 10% above the largest circle on the left and the rest scale with value
        const rBig = radL[0] * 1.1;
        const radR = vals.map(function (v) { return rBig * v / vals[0]; });
        ballsL = pushApart(rectsL, radL, PANEL_L);
        ballsR = pushApart(rectsR, radR, PANEL_R);

09A Disk Usage Hero Filled by Folder Sizes

For a landing screen that shows storage, budget or traffic on one board, the first impression is the moment a table turns into cells. A table of folder names and sizes goes through squarify once to become cells, the view zooms into the biggest folder (video, 220 GB) and back, and sizeCheck divides the sum of cell areas by the board area and multiplies by the size total to confirm it equals the sum. The 8 folders add up to 730 GB, and the board prints Σ 730 = 730 ✓.

A Disk Usage Hero Filled by Folder Sizes
Open in a new tab

09 · A Disk Usage Hero Filled by Folder Sizes — Measured area 54.558% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

sizeCheckMORPH_GAPviewRect
    function sizeCheck() {
        // Divide the sum of cell areas in pixels by the board area and multiply by the value total to check that it matches the sum
        let area = 0;
        let want = diskTotal;
        if (inside()) {
            for (const r of kidRects()) {
                area += r.w * r.h;
            }
            want = disk[zg].v;
        }
        else {
            for (const r of tgt) {
                area += r.w * r.h;
            }
        }
        const got = area / (R9.w * R9.h) * want;
        return 'Σ ' + want + ' = ' + Math.round(got) + (Math.abs(got - want) < 0.5 ? ' ✓' : ' ✗');
    }

When You Write Treemap Chart Code, How Many Values Decide a Cell?

One cell is one value and one rectangle

A cell splits into one value, one rectangle from the layout function, and the four edges of that rectangle. Keeping the four edges apart lets a spring pull left, top, right and bottom to their targets separately, which is how a cell grows and shrinks as it moves.

Value Meaning
vals One value per cell. The area is proportional to it
Rectangle { x, y, w, h } The slot the layout function gave the cell
edgesOf(r) The four coordinates left, top, right and bottom
kids The list of child cells inside a cell

Where to put it when the total is fixed

A treemap graph fits any whole that has a fixed total, such as disk usage, a budget split or portfolio weights. The sizeCheck in 09 compares the cell area sum with the value sum every time, so a changed value list brings its own check along.

For dashboard cells, the idea in 02 and 07 of recomputing the target only when a value changes carries over unchanged. A browsing screen with layers can chain the zoom transform of 03 to the headers and padding of 05.

Sort the Values From Large to Small, Add Cells to a Row While Measuring the Ratio, Close the Row to Split the Rectangle and Repeat the Same Calculation Inside Each Cell: the Order in Which One Treemap Is Computed

A row closes when the ratio gets worse

For anyone looking for treemap chart javascript, the whole algorithm is this loop. squarify sorts values from large to small with idx.sort, adds one cell at a time and re-measures worstRow, and when the new ratio wr is worse than the row's current cur that cell moves to the next row.

        while (at < n) {
            const side = Math.max(Math.min(rw, rh), 1e-6);
            const row = [];
            const areas = [];
            let cur = Infinity;
            while (at < n) {
                const a = Math.max(vals[idx[at]], 1e-6) * k;
                const next = areas.slice();
                next.push(a);
                const wr = worstRow(next, side);
                if (areas.length > 0 && wr > cur) {
                    break;
                }
                areas.push(a);
                row.push(idx[at]);
                cur = wr;
                at += 1;
            }

A closed row sits as a vertical row on the left when the remaining rectangle is wide, otherwise as a horizontal row on top, and the same work repeats in what is left.

The same calculation runs again inside each cell

layNode in 05 lays children out with squarify and then calls itself on each child, and kidLayout in 03 uses the same function inside a zoomed group. That is why the layout stays one function however many layers there are.

Where a Treemap Chart Breaks: Cells Thinned by Slice Only Cutting, Inner Cells Lost to the Same Padding on Every Layer and Text Spilled Into a Neighbor Because Nothing Was Measured

Cutting by slice alone thins the cells

slice cuts vertical strips in input order, so with 12 cells the worst ratio is 28.73 and the mean is 14.76. The same values through squarify give 1.58 and 1.24, so even small cells stay cells instead of strips. The two averages come from running the compiled layout in Node, since the screen corner only shows the worst ratio.

The same padding on every layer erases the inner cells

The right board in 05 gives every layer 8 px, so a deep cell is left with a width near 0 px once the padding is subtracted. Of 41 leaves, 38 have a short side of 5 px or more and are drawn, and the smallest leaf has a short side of 0 px.

Without measuring, text spills into the neighbor

The CHAR_W = 1 estimate counts one em per character, and English names are longer than the Korean ones the first measurement used. To get English figures I cut squarify, fitPx and estW out of the vanilla source and ran them on the 14 English names in Node. At full width 7 of 14 names are hidden on the left and 11 spill at the fixed 12 px, at 0.45 wide it is 10 and 14, and at 0.25 wide it is 13 and 14.

Recomputing the target every frame makes cells tremble

The right board in 07 adds ±2.5% noise to the values on every update and recomputes the layout 527 times per lap, while the left recomputes 31 times, only when the rank changes or a value drifts past 20%. With the same flowing values, the left cells settle and the right cells keep trembling.

The source of all nine scenes is in the zip card below, and the password to unpack it is 5jqmrgdv, which you type exactly as shown.

Change the Cell Count, the Spring Strength and the Padding and How Much Do Shape and Legibility Shift? Count, Spring and Padding Compared

Name Changed value Feel
More cells 01 CELL_N 12 → 24 The worst ratio grows from 1.58 to 2.08 for squarify and from 28.73 to 159.06 for slice, and rows go from 7 to 12
Stiffer spring 02 SPRING_K 60 → 140 The settle time within 0.5 px falls from 1.45 s to 0.93 s, but the overshoot barely moves from 24.0 px to 23.5 px because the damping ratio stays at 0.5
Thicker padding 05 PAD 8 → 12 Leaf area share goes from 40.1% to 28.7% on the left and from 19.1% to 5.8% on the right, and visible cells go from 41 to 38 on the left and from 38 to 21 on the right

Before you change a value

Raising SPRING_K shortens the time to reach the target, but the size of the overshoot is set by the damping ratio ZETA, so a calmer spring needs ZETA raised instead. Scene 02 uses 0.5 and scene 07 uses 0.6.

PAD shrinks by 0.55 at each depth, so raising it to 12 px spreads down to the deepest layer. These figures are tied to a 222×208 board and the first lap's data, so a different board size changes the ratios and area shares.

One Settled Frame Under Reduced Motion: Accessibility Through Cell Names and Shares Read as a Sentence and Keyboard Cell Picking

Pick a cell with the arrow keys and press Enter

With focus on the stage box (role="application", tabindex 0), ← and ↑ go to the previous cell, → and ↓ to the next, and Enter or Space picks it. P pauses, N steps once and R restarts, and the first arrow key stops the auto demo and hands control to you.

The status line stays quiet during the demo

Pointing at a cell makes the status line read its name, value and share as a sentence. During the demo it is aria-live="off", which turns to polite on the first touch, and the canvas has role="img" with a per scene description. A hidden role="status" reads statusText() once, 0.6 seconds before a lap ends.

Reduced motion and a hidden tab

Under reduced motion there is no loop and one settled layout is drawn at the REST_S second, and when input arrives settleStill() runs 3 seconds of update so the final layout appears with no tween. A hidden tab pauses the clock and a returning tab resyncs it.

Text contrast

Dark ink (INK) on the pastel cell fills is at least 5.9:1 and 5.2:1 on the header strip, and the secondary ink (INK2) is at least 4.9:1. INK on the board floor is 11.3:1 and the pill text is 14.0:1.

Treemap Chart FAQ: Building Without a Library, How a Treemap Differs From a Pie Chart and Handling Negative or Zero Values

Do I need a treemap javascript library to build a treemap chart?

No. Canvas 2D is enough, and the nine scenes use 0 libraries, 0 WebGL and 0 CDN. The layout is the single squarify function and the one random source is a seeded generator, so the same input builds the same board.

How is a treemap different from a pie chart?

A pie chart shows share by angle and a treemap by rectangle area, so data with many cells or with layers fits on one board as a treemap. Scene 08 morphs the same data between rectangles and circles, which shows how differently the biggest value reads by area and by diameter.

How are negative or zero values handled?

squarify and slice raise each value to at least Math.max(v, 1e-6), so no cell ends up with an area of 0. A negative value cannot be an area, so a change is better shown through color as in 04.

For references, the MDN measureText page covers measuring text width, the requestAnimationFrame page and the basic animations tutorial cover the draw loop, and the prefers-reduced-motion page covers the setting. For a chart built on the same calculation see the bar chart article and the pie chart article, and for packed cell layouts see the masonry layout article and the org chart article.

Enter the archive password

The password is inside this article. You will find it as you read.