---
type: skill
lifecycle: stable
inheritance: inheritable
name: svg-dashboard-composition
description: Compose one self-contained SVG from multiple visual fragments — banner, treemap, bar charts, KPI strip, footer — using a shared coordinate system and one panel primitive.
tier: extended
applyTo: '**/*svg*,**/*dashboard*,**/*composition*'
currency: 2026-04-29
lastReviewed: 2026-04-30
---

# SVG Dashboard Composition

Compose one self-contained SVG from multiple visual fragments — banner, treemap, bar charts, KPI strip, footer — using a shared coordinate system and one panel primitive. The output is a single image that can be embedded in a README, opened standalone, or rendered in a print pipeline without external assets.

## When to Use

- Building a dashboard that lives inside a Markdown README (GitHub profile, project landing, fleet status)
- Replacing a multi-image collage with a single composable artifact (cleaner mobile rendering, single source of truth)
- Generating dashboards on a schedule (CI, cron, daily refresh) where consistency across runs matters more than per-run customization
- Anywhere a printable A4/Letter dashboard needs to survive a pandoc → docx pipeline

## When *Not* to Use

- Interactive dashboards (use a real frontend; SVG composition is for static snapshots)
- Real-time data (the technique is stateless; pair with a hash-cached LLM pipeline if you need that flavor)
- One-off illustrations (overkill — use [`svg-graphics`](../svg-graphics/SKILL.md) instead)

## The Five Composition Rules

### 1. One canvas, one viewBox

Every fragment is authored in its own viewBox, then scaled to a single shared width (typical: 820px for README, 1200px for print). The combiner reads each fragment's viewBox, computes a uniform scale, wraps the inner content in a `<g transform="translate scale">`, and stacks them with a fixed gap. The result is one parent `<svg>` with a viewBox sized to cumulative height.

```js
const SHARED_WIDTH = 820;
const STACK_GAP = 6;

function placeFragment(parsed, yOffset) {
  const scale = SHARED_WIDTH / parsed.width;
  return {
    block: `<g transform="translate(0, ${yOffset}) scale(${scale.toFixed(6)})">${parsed.inner}</g>`,
    height: parsed.height * scale,
  };
}
```

**Win**: every fragment can be developed and previewed independently. Open the combined SVG to see the whole; open just the topic-viz output to see one row.

### 2. One panel primitive

Every block — Top Languages, Tier & Activity, treemap clusters, KPI cards — is built from one helper. Pastel body + slightly darker header band (rounded top corners only) + hairline divider + colored title + optional subtitle. Same function, same visual idiom across blocks generated by different scripts.

```js
panel({ x, y, w, h, title, subtitle, color })
```

The constants that matter:

| Constant | Value | Why |
|---|---|---|
| `radius` | 6 | Just enough rounding to read as "card", not as "blob" |
| `fillOpacity` | 0.15 | Pastel without losing readability of the colored stroke |
| `headerOpacity` | 0.10 | Header band reads ~0.25 (combines with body) — visible separation, not a stripe |
| `dividerOpacity` | 0.45 | Hairline; visible at 1px but not loud |
| `titleFontSize` | 12 | Same on every panel — consistency over hierarchy |

### 3. Treemap rows have equal height

Force `row1H = H/2, row2H = H - row1H`. A naive flex layout makes the bottom row a weird trailing band when one row has 3 wide clusters and the other has 5 narrow ones. Equal rows = balanced negative space.

Inside each cluster, topics are tall thin colored blocks with names rotated 90° downward. Vertical text packs ~10 labels into a 60px-wide slot horizontal text could fit only 2 of. The trade-off is the user tilts their head — accept it because the dominant scan path is *cluster names first, drill into topics only if interested*.

### 4. Bar charts vertical-center, labels wrap

When a panel has fewer rows than its slot allows (6 themes in a slot sized for 10), the rows must center vertically; top-aligned looks broken:

```js
yOffset = (h - rowH * items.length) / 2;
```

Labels longer than the available width wrap into `<tspan>` children. The bar's `y` baseline is the *center* of the wrapped block, not the top, so the bar stays vertically centered against multi-line labels.

### 5. Footer is the final SVG row, not separate HTML

The stat line (`63 repositories · 48 active · 13 flagships`) lives inside the SVG as the last fragment, rendered with mixed-weight `<tspan>`s (bold values, muted labels, dot separators). A separate `<p align="center">` block creates a visible gap between the SVG's bottom edge and the prose. Embed the footer to remove the seam.

```js
function statsFooter(yOffset, height, parts) {
  const cx = SHARED_WIDTH / 2;
  const baseline = yOffset + height / 2 + 4;
  const spans = parts.flatMap((p, i) => [
    i > 0 ? `<tspan fill="#64748b" dx="6">·</tspan>` : '',
    `<tspan font-weight="700" fill="#1f2328" ${i > 0 ? 'dx="6"' : ''}>${p.value}</tspan>`,
    `<tspan fill="#64748b" dx="4">${p.label}</tspan>`,
  ]).filter(Boolean);
  return `<text x="${cx}" y="${baseline}" text-anchor="middle" font-family="Segoe UI" font-size="13">${spans.join('')}</text>`;
}
```

## The Banner Regex Trick

A pre-designed banner SVG with 200+ elements (gradient stops, filter primitives, logo) is too big to mutate via XML parser. The technique: **never modify the source file**; read it as a string and run scoped regex replacements before stitching.

```js
bannerSvg = bannerSrc
  // Inject dynamic tagline into the role-line slot
  .replace(/<text x="320" y="180"[^>]*>[^<]*<\/text>/,
    `<text x="320" y="180" textLength="800" lengthAdjust="spacingAndGlyphs">${tagline}</text>`)
  // Replace name with credentialed full name
  .replace(/<text x="320" y="135" font-size="72"[^>]*>[^<]*<\/text>/,
    `<text x="320" y="135" font-size="74">${name}</text>`)
  // Replace static "POWERED BY" with build timestamp
  .replace(/<g transform="translate\(320, 255\)">[\s\S]*?<\/g>/,
    `<g transform="translate(320, 255)"><text>UPDATED</text><text x="70" font-weight="700">${stamp}</text></g>`);
```

Each regex anchors on **stable layout coordinates** (`x="320" y="180"`) or **HTML comments in the source** (`<!-- Main name -->`). That makes substitutions resilient to non-essential edits — change the gradient, the corner radius, the constellation graphics: the regex still matches.

Why regex and not an SVG parser? Eight lines vs. an XML dependency + a serializer that preserves whitespace. Regex over four anchored lines survives every refactor.

## Review Gate

Run a content review after every regeneration. The script asserts:

- Counts in the rendered output match the source JSON (treemap topic count == classification count)
- Required markers (`<!-- DASHBOARD:START -->`, `END -->`) are intact
- Every flagship name from the data appears in the output
- SVG is well-formed (open + close tags, viewBox present, no orphan `<g>`)

If review fails, exit non-zero. The CI never publishes a broken artifact. This catches silent regressions — an LLM that helpfully drops 3 items when re-clustering — before they ship.

## Anti-Patterns

| Anti-pattern | Why it breaks |
|---|---|
| Multi-image collage with separate `<p align="center"><img></p>` blocks | Inconsistent margins, broken alignment on mobile, no shared font |
| Mutating the banner source SVG during build | Loses authoring intent; next pre-designed change clobbers your dynamic edits |
| Using an XML parser for the banner regex trick | Forces an XML dependency and a serializer; 8 lines of regex wins |
| Letting the LLM pick the overall verdict in a review stage | Inconsistent rollups; let the model narrate per-check, count failures in deterministic JS |
| Forgetting `textLength` + `lengthAdjust` on injected text | Long taglines overflow the slot; constraint-fit by attribute, not by substring trimming |
| Different `radius` / `fontSize` per panel | Reads as "many cards from many sources"; dashboards earn trust through visual uniformity |

## Cross-References

- [`svg-graphics`](../svg-graphics/SKILL.md) — accessible, theme-aware SVG basics. Use for one-off illustrations.
- [`document-banner-pastel`](../document-banner-pastel/SKILL.md) — hand-authored 1200×240 banners. Compatible: a dashboard's banner row can be a pastel banner.
- [`dashboard-design`](../../data/dashboard-design/SKILL.md) — KPI selection, layout, and narrative flow. Use *before* this skill: that decides what to show; this skill decides how to draw it.
- [`champion-challenger-cache`](../../../patterns/champion-challenger-cache.md) — hash-cache the LLM stages that feed the dashboard so a no-change run costs zero tokens.
- Source: [AlexFleetPortfolio publish/VISUAL-STORYTELLING.md](https://github.com/fabioc-aloha/AlexFleetPortfolio/blob/main/publish/VISUAL-STORYTELLING.md) — full pipeline walk-through with the live artifact and the Responsible-AI review stage.

## Falsifiability

The skill is wrong if, in the next 30 days, a heir attempting to apply it to a different dataset (fleet inventory, traffic stats, anything) cannot produce a working dashboard within one work session. Symptoms that would indicate failure:

- The `placeFragment` scaling math is unclear and the heir abandons in favor of multi-image
- The panel primitive is too restrictive for the data being visualized (e.g., needs nested panels, mosaics)
- The banner regex trick fails on a banner with non-standard coordinate anchors

Mitigation if symptoms appear: revisit and split into composition-only and pipeline-only skills.
