Tree-map (sales by region·category)

The canonical BI tree-map: revenue broken down hierarchically into nested rectangles - bigger value = bigger rectangle. The squarified algorithm keeps every cell close to a square so labels stay readable. Switch the drill order (Region → Category vs. Category → Region) to compare the same data two ways.

A live, editable Svelte 5 data grid example from the SvGrid gallery (Charts). See the SvGrid documentation for the full API.

About this example

A treemap of sales by region, category and product in Svelte 5. type treemap breaks revenue down into nested rectangles where a bigger value is a bigger box, running a squarified layout so every rectangle stays close to a square and labels remain readable even for skewed data. Three nested levels show drill-down structure with a palette colour per level, and switching the drill order between Region then Category and Category then Region compares the same data two ways.

The canonical BI treemap: revenue broken down hierarchically. Bigger box = bigger revenue. Three nested levels (region -> category -> product) give the layout enough depth to show drill-down structure; each level cycles a colour from the palette so the grouping reads at a glance without a legend.

type: 'treemap' runs a squarified algorithm (Bruls et al. 2000) so every rectangle stays close to a square - labels remain readable even when the data is very skewed.

Imports, features and API used

Imports: @svgrid/grid

Table features registered: rowSortingFeature, columnFilteringFeature

Columns: region (Region), category (Category), product (Product), revenue (Revenue)

SvGridApi methods called: api.getDisplayedRows()

Frequently asked questions

How is the hierarchy passed?

As nested tree nodes with a name, a value and children; the demo builds them from the grid rows by grouping on the chosen fields in order.

Why squarified?

A naive slice-and-dice layout produces long thin slivers for small values. The squarified algorithm keeps aspect ratios near one so text fits and areas are easier to compare.

How are colours assigned?

Each depth cycles the palette, so regions, categories and products are distinguishable without a legend; a colour per node can override it.

Related documentation

Related articles

Source code (164-chart-treemap.svelte)

<!-- Documented in: docs/help/charts/types.md -->
<script lang="ts">
  /**
   * 164. Tree-map (sales by region · category · product)
   * ------------------------------------------------------
   * The canonical BI treemap: revenue broken down hierarchically. Bigger
   * box = bigger revenue. Three nested levels (region -> category ->
   * product) give the layout enough depth to show drill-down structure;
   * each level cycles a colour from the palette so the grouping reads
   * at a glance without a legend.
   *
   * `type: 'treemap'` runs a squarified algorithm (Bruls et al. 2000) so
   * every rectangle stays close to a square - labels remain readable
   * even when the data is very skewed.
   */
  import {
    SvGrid,
    SvGridChart,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    type GridColumns,
    type SvGridApi,
    type ChartSpec,
    type TreeNode,
  } from '@svgrid/grid'

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })

  type Row = { id: number; region: string; category: string; product: string; revenue: number }
  // ~30 SKUs across 3 regions x 4 categories. Numbers are made-up but
  // shaped like a real product mix - Electronics dominates, Outdoors is
  // a long tail of small SKUs.
  const rows: Row[] = [
    // Americas
    { id:  0, region: 'Americas', category: 'Electronics', product: 'Smartphone Z',   revenue: 4_820_000 },
    { id:  1, region: 'Americas', category: 'Electronics', product: 'Laptop Pro 15',  revenue: 3_140_000 },
    { id:  2, region: 'Americas', category: 'Electronics', product: 'Smart TV 55"',   revenue: 2_270_000 },
    { id:  3, region: 'Americas', category: 'Electronics', product: 'Wireless Buds',  revenue: 1_180_000 },
    { id:  4, region: 'Americas', category: 'Apparel',     product: 'Hoodie Classic', revenue:   870_000 },
    { id:  5, region: 'Americas', category: 'Apparel',     product: 'Running Shoes',  revenue: 1_440_000 },
    { id:  6, region: 'Americas', category: 'Home',        product: 'Coffee Maker',   revenue:   610_000 },
    { id:  7, region: 'Americas', category: 'Home',        product: 'Air Purifier',   revenue:   780_000 },
    { id:  8, region: 'Americas', category: 'Outdoors',    product: '2-Person Tent',  revenue:   240_000 },
    { id:  9, region: 'Americas', category: 'Outdoors',    product: 'Camping Stove',  revenue:   130_000 },
    // EMEA
    { id: 10, region: 'EMEA',     category: 'Electronics', product: 'Smartphone Z',   revenue: 3_910_000 },
    { id: 11, region: 'EMEA',     category: 'Electronics', product: 'Laptop Pro 15',  revenue: 2_680_000 },
    { id: 12, region: 'EMEA',     category: 'Electronics', product: 'Smart TV 55"',   revenue: 1_540_000 },
    { id: 13, region: 'EMEA',     category: 'Electronics', product: 'Wireless Buds',  revenue:   980_000 },
    { id: 14, region: 'EMEA',     category: 'Apparel',     product: 'Hoodie Classic', revenue: 1_220_000 },
    { id: 15, region: 'EMEA',     category: 'Apparel',     product: 'Running Shoes',  revenue: 1_660_000 },
    { id: 16, region: 'EMEA',     category: 'Home',        product: 'Coffee Maker',   revenue:   920_000 },
    { id: 17, region: 'EMEA',     category: 'Home',        product: 'Air Purifier',   revenue:   540_000 },
    { id: 18, region: 'EMEA',     category: 'Outdoors',    product: '2-Person Tent',  revenue:   410_000 },
    { id: 19, region: 'EMEA',     category: 'Outdoors',    product: 'Camping Stove',  revenue:   220_000 },
    // APAC
    { id: 20, region: 'APAC',     category: 'Electronics', product: 'Smartphone Z',   revenue: 6_240_000 },
    { id: 21, region: 'APAC',     category: 'Electronics', product: 'Laptop Pro 15',  revenue: 2_870_000 },
    { id: 22, region: 'APAC',     category: 'Electronics', product: 'Smart TV 55"',   revenue: 3_080_000 },
    { id: 23, region: 'APAC',     category: 'Electronics', product: 'Wireless Buds',  revenue: 2_150_000 },
    { id: 24, region: 'APAC',     category: 'Apparel',     product: 'Hoodie Classic', revenue:   430_000 },
    { id: 25, region: 'APAC',     category: 'Apparel',     product: 'Running Shoes',  revenue: 1_080_000 },
    { id: 26, region: 'APAC',     category: 'Home',        product: 'Coffee Maker',   revenue:   380_000 },
    { id: 27, region: 'APAC',     category: 'Home',        product: 'Air Purifier',   revenue: 1_220_000 },
    { id: 28, region: 'APAC',     category: 'Outdoors',    product: '2-Person Tent',  revenue:    90_000 },
    { id: 29, region: 'APAC',     category: 'Outdoors',    product: 'Camping Stove',  revenue:    70_000 },
  ]

  const columns: GridColumns<Row> = [
    { field: 'region',   header: 'Region',   width: 120 },
    { field: 'category', header: 'Category', width: 130 },
    { field: 'product',  header: 'Product',  width: 180 },
    { field: 'revenue',  header: 'Revenue',  width: 140, align: 'right',
      format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
  ]

  let api = $state<SvGridApi<typeof features, Row> | null>(null)
  let displayed = $state<Row[]>(rows)
  let groupBy = $state<'region-cat' | 'cat-region' | 'region' | 'category'>('region-cat')
  function sync() { displayed = (api?.getDisplayedRows() as Row[]) ?? rows }

  /** Group filtered rows into a tree. The two outer-key choices change
   *  the story the treemap tells - same data, different drill order. */
  const tree = $derived.by<TreeNode>(() => {
    const make = (rs: Row[], keys: Array<keyof Row>): TreeNode => {
      if (keys.length === 0) {
        // Leaves: one node per row, value = revenue.
        return { name: 'all', children: rs.map((r) => ({ name: r.product, value: r.revenue })) }
      }
      const [head, ...rest] = keys
      const buckets = new Map<string, Row[]>()
      for (const r of rs) {
        const k = String(r[head!])
        const arr = buckets.get(k) ?? []
        arr.push(r); buckets.set(k, arr)
      }
      return {
        name: 'all',
        children: [...buckets.entries()].map(([name, sub]) => ({
          name,
          children: make(sub, rest).children,
        })),
      }
    }
    const keys: Array<keyof Row> =
      groupBy === 'region-cat' ? ['region', 'category', 'product'] :
      groupBy === 'cat-region' ? ['category', 'region', 'product'] :
      groupBy === 'region'     ? ['region', 'product'] :
                                 ['category', 'product']
    return make(displayed, keys)
  })

  const spec = $derived.by<ChartSpec>(() => ({
    type: 'treemap',
    categories: [],
    series: [],
    treemap: tree,
    width: 720,
    height: 420,
  }))

  /** Pane size, so the chart fills its card rather than a fixed viewBox. */
  let paneW = $state(0)
  let paneH = $state(0)

  const compact = (v: number) => {
    const a = Math.abs(v)
    if (a >= 1e6) return '$' + (v / 1e6).toFixed(1) + 'M'
    if (a >= 1e3) return '$' + (v / 1e3).toFixed(0) + 'k'
    return '$' + Math.round(v)
  }
</script>

<section class="flex flex-col flex-1 min-h-0 gap-3">
  <div class="shrink-0 rounded-lg border px-4 py-3" style="border-color: var(--sg-border); background: var(--sg-header-bg);">
    <p class="text-sm font-semibold" style="color: var(--sg-fg);">
      Tree-map - revenue by region · category · product
    </p>
    <p class="mt-0.5 text-xs" style="color: var(--sg-muted);">
      Cell area is proportional to revenue. Switch the drill order to compare regions across
      categories (default) vs. categories across regions. Filter the grid and the layout reflows
      live - hover any cell for the exact value.
    </p>
    <div class="mt-2 flex flex-wrap items-center gap-2 text-xs">
      <span style="color: var(--sg-muted);">Drill:</span>
      <select bind:value={groupBy} class="ic-sel">
        <option value="region-cat">Region → Category → Product</option>
        <option value="cat-region">Category → Region → Product</option>
        <option value="region">Region → Product</option>
        <option value="category">Category → Product</option>
      </select>
    </div>
  </div>

  <div class="flex flex-1 min-h-0 gap-3">
    <div class="flex-1 min-w-0 min-h-0">
      <SvGrid responsive={true}
      columnResize
        data={rows}
        columns={columns}
        features={features}
        sortable
        filterable
        selectionMode="none"
        rowHeight={28}
        containerHeight="100%"
        fitColumns={true}
        onApiReady={(a) => { api = a; sync() }}
        onFiltersChange={sync}
        onSortingChange={sync}
      />
    </div>
    <!-- The chart is sized to the pane rather than to a fixed viewBox, so it
         fills the card instead of floating in the top half of it. -->
    <div
      class="rounded-lg border p-3"
      style="flex: 0 1 760px; min-width: 0; min-height: 0; border-color: var(--sg-border); background: var(--sg-bg);"
    >
      <!-- Measured box, not the card: its height comes from the parent, so the
           chart cannot push the thing it is sized against. -->
      <div style="width: 100%; height: 100%; min-height: 0;" bind:clientWidth={paneW} bind:clientHeight={paneH}>
        {#if paneW > 40 && paneH > 40}
          <SvGridChart {spec} formatValue={compact} width={paneW} height={paneH} />
        {/if}
      </div>
    </div>
  </div>
</section>

<style>
  .ic-sel {
    border: 1px solid var(--sg-input-border, var(--sg-border));
    background: var(--sg-input-bg, var(--sg-bg));
    color: var(--sg-fg);
    border-radius: 6px;
    padding: 4px 8px;
    font-size: 12px;
  }
</style>

View this example on GitHub

More Charts examples

  • Chart a selection (context menu) - With integrated charting enabled, the right-click menu gains a Chart selected range item: select a block of cells, right-click, and the chart panel opens scoped to that range - the Excel chart-this gesture, built in. The item appears only when charting is on and is appended to the default context menu automatically.
  • Chart view of the grid - The `chart` prop turns the same <SvGrid> into a chart, driven by the grid’s filtered + sorted rows (search + sort flow through). A view of the grid like board and scheduler, but the renderer is free: the grid lazy-loads a built-in view wrapping the standalone SvChart via rowsToChartSpec. Flip Table <-> Chart (bar / line / area) over one source of truth.
  • Candlestick / OHLC - Candlestick and OHLC price marks with an ordinal date axis. ChartSeries.ohlc carries the four prices while values keeps the closes, so the CSV export, the screen-reader table and a 10-day moving average all work with no candle-specific code. Toggle the axis: a real time axis opens a gap over every weekend, an ordinal one spaces sessions evenly and still labels them by date. The strip under the plot is the chart brush: drag its window to pan, drag an edge to resize.
  • Box plot + error bars - Distribution rather than average: box plots with the 1.5 IQR whisker rule and individual outliers, next to the same data as a bar chart of the means with error bars. two regions with nearly identical means sit side by side in the bars and look nothing alike in the boxes. boxStats() summarises a raw sample, rowsToBoxSpec() does it per group, and ChartSeries.errors annotates any existing mark without changing its type.
  • Axes, titles and styling - The chart hands the decisions back: a numeric x axis that spreads payload sizes the way the numbers do, pinned domains with a fixed tick interval and a formatter per axis, grid lines on or off, a shaded budget band, a vertical reference line, per-point markers and colours, a stepped dashed series on the right axis, a title / subtitle / caption, a legend on any side, and a tooltip snippet in single-series mode. Below it, 50,000 monitoring readings decimated to one point per pixel with LTTB or min / max - toggle it off to see the cost.