feat(types): add treemap for part-of-whole by area (#87)

Add Treemap as the 28th visual type with light, dark, and editorial variants; area, label, and information-marker verification; synchronized documentation and manifests; and plugin version 2.5.0.
This commit is contained in:
0xDarkMatter
2026-08-18 00:50:19 -07:00
committed by GitHub
parent 72f17665cb
commit de75c0adb5
21 changed files with 1072 additions and 36 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "diagram-design",
"metadata": {
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling."
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling."
},
"owner": {
"name": "Cathryn Lavery"
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.4.8",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.5.0",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.4.8",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.5.0",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
@@ -26,7 +26,7 @@
"interface": {
"displayName": "Diagram Design",
"shortDescription": "Create editorial technical and product diagrams as standalone HTML.",
"longDescription": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"longDescription": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"developerName": "Cathryn Lavery",
"category": "Productivity",
"capabilities": [
+12
View File
@@ -185,6 +185,16 @@ jobs:
id: geometry_tests
run: python scripts/test-verify-geometry.py
- name: Verify treemap area fidelity and label fit
if: always()
id: treemap
run: python scripts/verify-treemap.py --all
- name: Verify treemap checker
if: always()
id: treemap_tests
run: python scripts/test-verify-treemap.py
- name: Verify generated icon assets
if: always()
id: icons
@@ -219,4 +229,6 @@ jobs:
echo "| Packaged Self-Check | ${{ steps.self_check.outcome == 'success' && '✅ Passed' || (steps.self_check.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Label Geometry | ${{ steps.geometry.outcome == 'success' && '✅ Passed' || (steps.geometry.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Label Geometry Checker | ${{ steps.geometry_tests.outcome == 'success' && '✅ Passed' || (steps.geometry_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Treemap Area & Label Fit | ${{ steps.treemap.outcome == 'success' && '✅ Passed' || (steps.treemap.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Treemap Checker | ${{ steps.treemap_tests.outcome == 'success' && '✅ Passed' || (steps.treemap_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Primitive Icons & Documentation | ${{ steps.icons.outcome == 'success' && '✅ Passed' || (steps.icons.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
+7 -3
View File
@@ -8,7 +8,7 @@ Please read [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) first. All contributions ar
## What this project is
Diagram Design is an agent skill (Claude Code, Codex, Pi) that produces editorial-quality diagrams as self-contained HTML files. The repo is documentation-first: `skills/diagram-design/SKILL.md` is the index, each of the 27 visual types has its own reference file, and the extractor scripts in `skills/diagram-design/scripts/` turn draw.io and Mermaid sources into a structured IR.
Diagram Design is an agent skill (Claude Code, Codex, Pi) that produces editorial-quality diagrams as self-contained HTML files. The repo is documentation-first: `skills/diagram-design/SKILL.md` is the index, each of the 28 visual types has its own reference file, and the extractor scripts in `skills/diagram-design/scripts/` turn draw.io and Mermaid sources into a structured IR.
See [README.md](README.md) for the full picture, including the design system and the import/export flows.
@@ -56,6 +56,8 @@ The helper refuses to run if the Claude and Codex versions already differ. If an
| Packaged output self-check behaves (pass + adversarial cases) | `python3 scripts/test-self-check.py` |
| Label masks are never clipped by a node painted after them | `python3 scripts/verify-geometry.py --all` |
| Label geometry checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-geometry.py` |
| Treemap cells match the values they are labelled with, and labels fit | `python3 scripts/verify-treemap.py --all` |
| Treemap checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-treemap.py` |
| Generated icon assets are up to date (`icons.html`, `primitive-icons.md`) | `python3 scripts/build-icons.py` then `git diff --exit-code` on the two generated files |
The semantic-pattern gate also caps `skills/diagram-design/SKILL.md` at 40,000 bytes so the installed skill remains practical to load. If that gate fails, reduce duplication or move detail into a routed reference; do not remove routing vocabulary from frontmatter.
@@ -79,7 +81,9 @@ python3 scripts/test-plugin-package.py \
&& python3 scripts/test-verify-docs-sync.py \
&& python3 scripts/test-self-check.py \
&& python3 scripts/verify-geometry.py --all \
&& python3 scripts/test-verify-geometry.py
&& python3 scripts/test-verify-geometry.py \
&& python3 scripts/verify-treemap.py --all \
&& python3 scripts/test-verify-treemap.py
```
### If a gate fails
@@ -127,7 +131,7 @@ Motion is opt-in. Start from `skills/diagram-design/assets/template-motion.html`
## Design decisions (ADRs)
Settled policies live as short records in `docs/adr/` — one pinned motion controller, semantic patterns never expanding the 27-type taxonomy, the reveal-only autoplay rule, the SKILL.md byte cap with its trigger-rich description requirement, and geometric label placement being verified rather than reviewed. Read the relevant ADR before proposing a change that touches one; when a PR settles a new policy, add an ADR in the same PR.
Settled policies live as short records in `docs/adr/` — one pinned motion controller, semantic patterns never expanding the visual-type taxonomy, the reveal-only autoplay rule, the SKILL.md byte cap with its trigger-rich description requirement, and geometric label placement being verified rather than reviewed. Read the relevant ADR before proposing a change that touches one; when a PR settles a new policy, add an ADR in the same PR.
## Adding a new diagram type
+10 -9
View File
@@ -12,7 +12,7 @@
*New in 2.3: semantic system patterns and optional accessible motion, while static output stays the default.*
27 visual types. One agent skill for Claude Code, Codex, and Pi. Semantic patterns describe behavior separately from layout, so a queue, policy trace, or trust boundary can use the nearest existing type without expanding the type count. Static HTML remains the default; optional motion is available for ordered explanations. The skill also redraws draw.io or Mermaid sources at a chosen format, size, and detail level.
28 visual types. One agent skill for Claude Code, Codex, and Pi. Semantic patterns describe behavior separately from layout, so a queue, policy trace, or trust boundary can use the nearest existing type without expanding the type count. Static HTML remains the default; optional motion is available for ordered explanations. The skill also redraws draw.io or Mermaid sources at a chosen format, size, and detail level.
No Figma. No generic rounded boxes. No 30-minute color-picking sessions.
@@ -22,7 +22,7 @@ No Figma. No generic rounded boxes. No 30-minute color-picking sessions.
I write at [littlemight.com](https://littlemight.com?utm_source=diagram-design&utm_medium=readme&utm_campaign=github&utm_content=intro) (and run [BestSelf.co](https://bestself.co?utm_source=diagram-design&utm_medium=readme&utm_campaign=github&utm_content=intro) on the side). Every time I needed a diagram — an architecture sketch, a flowchart, a pyramid of what matters most — I'd ask Claude and get back a generic rounded-box thing that looked nothing like the rest of the site. I'd either fight with Figma for 30 minutes or just skip the diagram.
So I built a Claude Code skill for it. Twenty-seven visual types, editorial quality, matches your brand in 60 seconds by reading your website.
So I built a Claude Code skill for it. Twenty-eight visual types, editorial quality, matches your brand in 60 seconds by reading your website.
> *The highest-quality move is usually deletion.* Every node earns its place. The accent color is reserved for the 1–2 things the reader should look at first. Target density: 4/10.
@@ -30,7 +30,7 @@ So I built a Claude Code skill for it. Twenty-seven visual types, editorial qual
## What it makes
All 27 visual types ship in three static variants: minimal light, minimal dark, and full-editorial. Open any of them directly in a browser. There is no build step, JavaScript, or external image dependency.
All 28 visual types ship in three static variants: minimal light, minimal dark, and full-editorial. Open any of them directly in a browser. There is no build step, JavaScript, or external image dependency.
<table>
<tr>
@@ -80,12 +80,12 @@ All 27 visual types ship in three static variants: minimal light, minimal dark,
</tr>
<tr>
<td align="center"><img src="docs/screenshots/dp-security-matrix.png" alt="DP security matrix"><br><b>DP security matrix</b><br><sub>Per-role access permissions</sub></td>
<td></td>
<td align="center"><img src="docs/screenshots/treemap.png" alt="Treemap"><br><b>Treemap</b><br><sub>Part-of-whole by area</sub></td>
<td></td>
</tr>
</table>
**Browse the live gallery:** [cathrynlavery.github.io/diagram-design](https://cathrynlavery.github.io/diagram-design/) — or open [`skills/diagram-design/assets/index.html`](skills/diagram-design/assets/index.html) locally to flip through all 27 diagrams with light / dark / full-editorial tabs.
**Browse the live gallery:** [cathrynlavery.github.io/diagram-design](https://cathrynlavery.github.io/diagram-design/) — or open [`skills/diagram-design/assets/index.html`](skills/diagram-design/assets/index.html) locally to flip through all 28 diagrams with light / dark / full-editorial tabs.
---
@@ -185,7 +185,7 @@ Every diagram template gives the inline SVG an accessible name and description:
### Manual override
Prefer to set tokens by hand? Open [`skills/diagram-design/references/style-guide.md`](skills/diagram-design/references/style-guide.md) and edit the table. Everything downstream reads from there — all 27 diagrams, the annotation primitive, and the gallery all inherit semantic role names (`accent`, not `#eb6c36`).
Prefer to set tokens by hand? Open [`skills/diagram-design/references/style-guide.md`](skills/diagram-design/references/style-guide.md) and edit the table. Everything downstream reads from there — all 28 diagrams, the annotation primitive, and the gallery all inherit semantic role names (`accent`, not `#eb6c36`).
### First-run gate
@@ -206,7 +206,7 @@ The profile library is shared across Claude Code, Codex, and Pi. Use `/diagram-d
## Quickstart
```bash
# From a cloned checkout, open the gallery to see all 27 diagrams
# From a cloned checkout, open the gallery to see all 28 diagrams
open skills/diagram-design/assets/index.html # macOS
xdg-open skills/diagram-design/assets/index.html # Linux
@@ -370,7 +370,7 @@ diagram-design/
│ └── assets/
│ ├── index.html — live gallery, tabbed
│ ├── template*.html — scaffolds for new diagrams
│ ├── example-<type>.html — 3 variants × 27 types
│ ├── example-<type>.html — 3 variants × 28 types
│ ├── example-loop-terminal.html
│ ├── example-quadrant-consultant.html
│ ├── example-import-drawio.html
@@ -407,6 +407,7 @@ it covers all supported grammars, multi-block Markdown, adversarial labels, trus
behavior, resource caps, named failures, and reference/command wiring.
Label placement is gated geometrically: `python3 scripts/verify-geometry.py --all` fails CI when a label mask overlaps a node declared later in the document, because the node fill would clip the text at render time. `python3 scripts/test-verify-geometry.py` keeps that checker honest in both directions.
Treemaps get a second geometric gate, because their whole claim is that area *is* the encoding: `python3 scripts/verify-treemap.py --all` fails CI when a cell's share of the drawn area doesn't match the value printed inside it, or when a label overruns the cell it names. It measures area error as a *relative* figure — an absolute one passes exactly the small cells most likely to be wrong. `python3 scripts/test-verify-treemap.py` keeps it honest in both directions.
Docs and routing surfaces are themselves gated: `python3 scripts/verify-docs-sync.py` fails CI if the SKILL.md description loses a type's lexical hook, the gallery can't reach a shipped example, the README tree names a file that doesn't exist, a relative `references/*.md` link in SKILL.md is broken, or the Claude/Pi profile surfaces drift from `profiles.md`. `python3 scripts/test-verify-docs-sync.py` exercises those newer checks adversarially. The skill also ships `skills/diagram-design/scripts/self_check.py` — a distilled output checker installed agents can run on their own generated diagrams; `python3 scripts/test-self-check.py` keeps it honest. Settled design decisions (why one pinned controller, why patterns never add types, the autoplay policy, the SKILL.md byte cap, why label placement is verified geometrically, and why client profiles use marker-first resolution) live as short ADRs in `docs/adr/` — read them before relitigating one, add one when you settle a new policy.
All pull requests and pushes are automatically validated across Linux, Windows, and macOS runners via GitHub Actions CI (`.github/workflows/ci.yml`).
@@ -428,7 +429,7 @@ At startup, the agent sees only the skill name and description. When a request m
| "Give me a terminal / CLI-window version" | `SKILL.md` + `references/primitive-terminal.md` |
| "Redraw this .drawio file for my deck" | `SKILL.md` + `references/import-drawio.md` + `references/output-spec.md` + the chosen type's reference |
| "Redraw this Mermaid block for my deck" | `SKILL.md` + `references/import-mermaid.md` + `references/output-spec.md` + the chosen type's reference |
| Routine static diagram-making (any of the 27 visual types) | Only `SKILL.md` + that one type's reference |
| Routine static diagram-making (any of the 28 visual types) | Only `SKILL.md` + that one type's reference |
No matter how many types exist, the agent only reads the one you need. Add a new type tomorrow and nothing else changes.
@@ -12,6 +12,12 @@ Behavior is a separate axis. The seven semantic patterns in `references/semantic
## Consequences
- "27 visual types" is a stable, verifiable claim (`verify-semantic-motion.py` and `verify-docs-sync.py` both count it).
- The visual-type count is a stable, verifiable claim (`verify-semantic-motion.py` and `verify-docs-sync.py` both count it) — 27 when this record was accepted; see Amendments for the current figure.
- A new behavior costs one pattern section plus a routing-table row — not a new type reference, template set, and example triple.
- If a pattern ever needs a layout no existing type provides, that is the signal to add a type, with the full §10 shipping set.
## Amendments
**2026-08-18 — the count is 28.** Treemap was admitted under the escape clause above: recursive area subdivision is a layout grammar no existing type provides (bar encodes with length, nested with containment and no quantity, pyramid with rank). It shipped the full §10 set, and the counters named above moved 27 → 28 together with the prose.
The decision itself is unchanged — semantic patterns still never add a type, and the count still moves only for a new *layout* grammar. What this amendment records is the procedure: the two counters are this ADR's enforcement, so a PR that edits them without amending this file has quietly made itself the authority. Amend here in the same PR, or the number in the test is just whatever the last contributor typed.
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+2 -2
View File
@@ -402,7 +402,7 @@ def main() -> int:
missing_guide = directory / "missing-guide.md"
missing_guide.write_text(
original_skill.read_text(encoding="utf-8").replace(
"### Visual-type guide (27)", "### Visual guide"
"### Visual-type guide (28)", "### Visual guide"
),
encoding="utf-8",
)
@@ -410,7 +410,7 @@ def main() -> int:
markdown_errors = semantic_module.verify_markdown()
finally:
semantic_module.SKILL = original_skill
if not any("must contain the 27-row visual-type guide" in error for error in markdown_errors):
if not any("must contain the 28-row visual-type guide" in error for error in markdown_errors):
raise AssertionError(f"missing visual-guide anchor was accepted: {markdown_errors}")
print("OK: missing visual-type guide anchor is rejected")
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Adversarial tests for verify-treemap.py — both polarities.
Per ADR 0005, a geometric contract in this repo is a checker plus fixtures that
prove it fires when it should and stays quiet when it shouldn't. The mutations
below are the two defects that actually shipped in review: a sliver cell drawn a
third too small, and a label longer than the cell holding it.
Usage: python3 scripts/test-verify-treemap.py
Exit: 0 all pass, 1 a case failed.
"""
from __future__ import annotations
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CHECKER = ROOT / "scripts/verify-treemap.py"
GOOD = ROOT / "skills/diagram-design/assets/example-treemap.html"
def run(path: Path) -> tuple[int, str]:
result = subprocess.run(
[sys.executable, str(CHECKER), str(path)],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
return result.returncode, (result.stdout or "") + (result.stderr or "")
def write(directory: Path, name: str, source: str) -> Path:
path = directory / name
path.write_text(source, encoding="utf-8")
return path
def main() -> int:
source = GOOD.read_text(encoding="utf-8")
failures: list[str] = []
with tempfile.TemporaryDirectory() as raw:
directory = Path(raw)
# 1. The shipped example must pass untouched.
code, output = run(write(directory, "clean.html", source))
if code != 0:
failures.append(f"clean example was rejected: {output.strip()}")
else:
print("OK: the shipped treemap passes")
# 2. Shrink a labelled cell below the share it prints. This is the
# defect that shipped in review — a cell drawn smaller than it claims
# — caught here on Africa, which carries a label to contradict.
shrunk = source.replace(
'<rect x="576" y="40" width="252" height="252"',
'<rect x="576" y="40" width="200" height="252"',
)
if shrunk == source:
failures.append("could not build the undersized-cell fixture (anchor moved)")
else:
code, output = run(write(directory, "undersized.html", shrunk))
if code == 0:
failures.append("cell drawn under its stated share was accepted")
elif "relative" not in output:
failures.append(f"undersized cell reported without an area finding: {output.strip()}")
else:
print("OK: a cell drawn smaller than its label is rejected")
# 3. Inflate Asia instead — the opposite direction of the same defect.
# Both rects move: a cell is painted as a paper mask then a body, and
# mutating only one leaves the original size still present, which the
# smallest-host rule would quietly measure instead.
inflated = source.replace(
'<rect x="40" y="40" width="532" height="380"',
'<rect x="40" y="40" width="700" height="380"',
)
if inflated == source:
failures.append("could not build the oversized-cell fixture (anchor moved)")
else:
code, _ = run(write(directory, "oversized.html", inflated))
if code == 0:
failures.append("oversized focal cell was accepted")
else:
print("OK: a cell drawn larger than its label is rejected")
# 4. Overrun a label past its own cell.
overflowing = source.replace(
">South America</text>",
">South America and the Caribbean Basin</text>",
)
if overflowing == source:
failures.append("could not build the overflowing-label fixture (anchor moved)")
else:
code, output = run(write(directory, "overflow.html", overflowing))
if code == 0:
failures.append("label overflowing its cell was accepted")
elif "overflows" not in output:
failures.append(f"overflow reported without a fit finding: {output.strip()}")
else:
print("OK: a label wider than its cell is rejected")
# 4b. Overflow off the LEFT edge, via an end-anchored label. Checking
# only the right and bottom edges would pass this silently, and the
# text would read as belonging to the cell next door.
left_over = source.replace(
'<text x="804" y="324" fill="#2d3142" font-size="12" font-weight="600"',
'<text x="804" y="324" text-anchor="end" fill="#2d3142" font-size="12" font-weight="600"',
)
if left_over == source:
failures.append("could not build the left-overflow fixture (anchor moved)")
else:
code, output = run(write(directory, "leftoverflow.html", left_over))
if code == 0:
failures.append("label hanging off the left of its cell was accepted")
elif "left" not in output:
failures.append(f"left overflow not named in the finding: {output.strip()}")
else:
print("OK: a label overflowing the left edge is rejected")
# 4c. Overflow off the TOP edge — the label's ascent clears the cell.
top_over = source.replace(
'<text x="592" y="68" fill="#2d3142" font-size="13"',
'<text x="592" y="44" fill="#2d3142" font-size="13"',
)
if top_over == source:
failures.append("could not build the top-overflow fixture (anchor moved)")
else:
code, output = run(write(directory, "topoverflow.html", top_over))
if code == 0:
failures.append("label overflowing the top of its cell was accepted")
elif "top" not in output:
failures.append(f"top overflow not named in the finding: {output.strip()}")
else:
print("OK: a label overflowing the top edge is rejected")
# 4d. A sliver can be narrower than the fixed 10px information-mark
# disc. The marker must never cross into the neighbouring cell.
marker_over = source.replace(
'<rect x="940" y="296" width="16" height="124"',
'<rect x="940" y="296" width="8" height="124"',
).replace('cx="948" cy="358"', 'cx="944" cy="358"').replace(
'<text x="948" y="361"', '<text x="944" y="361"'
)
if marker_over == source:
failures.append("could not build the overflowing-marker fixture (anchor moved)")
else:
code, output = run(write(directory, "markeroverflow.html", marker_over))
if code == 0:
failures.append("information mark overflowing a sliver cell was accepted")
elif "information marker" not in output:
failures.append(f"marker overflow not named in the finding: {output.strip()}")
else:
print("OK: an information mark overflowing its cell is rejected")
# 5. The shipped example leaves its smallest cell deliberately
# unlabelled. That must not switch the area check off for the cells
# that ARE labelled — the failure mode where a checker reports clean
# because it found nothing to compare.
unlabelled_shrunk = source.replace(
'<rect x="576" y="296" width="208" height="124"',
'<rect x="576" y="296" width="140" height="124"',
)
if unlabelled_shrunk == source:
failures.append("could not build the still-checks-labelled-cells fixture")
else:
code, _ = run(write(directory, "unlabelled.html", unlabelled_shrunk))
if code == 0:
failures.append(
"area check went silent when a cell was unlabelled — a labelled "
"cell was resized and no finding was reported"
)
else:
print("OK: an unlabelled sliver does not disable the area check")
for failure in failures:
print(f"FAIL: {failure}")
if failures:
print(f"\n{len(failures)} case(s) failed.")
return 1
print("\nOK verify-treemap: both polarities behave")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+61 -3
View File
@@ -11,10 +11,15 @@ Five drift classes, each of which has shipped before:
3. Every concrete file named in README.md's architecture tree must exist.
4. Every relative references/*.md link in SKILL.md must resolve.
5. Claude and Pi profile surfaces must both route to the profile reference.
6. The plugin manifests repeat the SKILL.md description verbatim. They are the
text a user reads *before installing*, so by ADR 0004's own argument they
need every type's lexical hook too - and nothing else notices when they
drift, because they are four separate copies of one sentence.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
@@ -67,8 +72,8 @@ def check_description(errors: list[str]) -> None:
errors.append("SKILL.md frontmatter description is missing")
return
types = selection_table_types(markdown)
if len(types) != 27:
errors.append(f"expected 27 visual types in the selection table; found {len(types)}")
if len(types) != 28:
errors.append(f"expected 28 visual types in the selection table; found {len(types)}")
for name in types:
key = normalized(name)
key = DESCRIPTION_ALIASES.get(key, key)
@@ -151,9 +156,62 @@ def check_profile_surfaces(errors: list[str], root: Path) -> None:
)
MANIFEST_DESCRIPTIONS = (
(Path(".claude-plugin/plugin.json"), ("description",)),
(Path(".claude-plugin/marketplace.json"), ("description",)),
(Path(".codex-plugin/plugin.json"), ("description", "longDescription")),
)
def find_key(node: object, key: str) -> str | None:
"""First value for *key* anywhere in a nested JSON document."""
if isinstance(node, dict):
if isinstance(node.get(key), str):
return node[key]
for value in node.values():
found = find_key(value, key)
if found is not None:
return found
elif isinstance(node, list):
for value in node:
found = find_key(value, key)
if found is not None:
return found
return None
def check_manifest_descriptions(errors: list[str], root: Path) -> None:
markdown = SKILL.read_text(encoding="utf-8")
description = normalized(frontmatter_description(markdown))
if not description:
return
types = selection_table_types(markdown)
for relative, keys in MANIFEST_DESCRIPTIONS:
path = root / relative
if not path.exists():
errors.append(f"missing plugin manifest: {relative.as_posix()}")
continue
document = json.loads(path.read_text(encoding="utf-8"))
for key in keys:
value = find_key(document, key)
if value is None:
errors.append(f"{relative.as_posix()} has no {key!r}")
continue
text = normalized(value)
for name in types:
hook = DESCRIPTION_ALIASES.get(normalized(name), normalized(name))
if hook not in text:
errors.append(
f"{relative.as_posix()} {key!r} lost the lexical hook for "
f"type {name!r} (expected {hook!r}) — it must name every "
f"type the SKILL.md description names"
)
def main() -> int:
errors: list[str] = []
check_description(errors)
check_manifest_descriptions(errors, ROOT)
check_gallery(errors)
check_readme_tree(errors)
check_skill_reference_links(
@@ -169,7 +227,7 @@ def main() -> int:
return 1
print(
"OK docs sync: description hooks, gallery reachability, README tree, "
"reference links, profile surfaces"
"reference links, profile surfaces, manifest descriptions"
)
return 0
+6 -6
View File
@@ -191,18 +191,18 @@ def verify_markdown() -> list[str]:
if "Selection: semantic pattern, then visual type" not in skill:
errors.append("SKILL.md must choose semantic pattern before visual type")
router_position = skill.find("semantic-patterns.md")
guide_position = skill.find("### Visual-type guide (27)")
guide_position = skill.find("### Visual-type guide (28)")
if router_position < 0:
errors.append("SKILL.md must link to semantic-patterns.md")
if guide_position < 0:
errors.append("SKILL.md must contain the 27-row visual-type guide")
errors.append("SKILL.md must contain the 28-row visual-type guide")
if router_position >= 0 and guide_position >= 0 and router_position > guide_position:
errors.append("semantic-pattern router must precede the visual-type guide")
visual_guide = section(skill, "### Visual-type guide (27)", "Rules of thumb:")
visual_guide = section(skill, "### Visual-type guide (28)", "Rules of thumb:")
visual_rows = re.findall(r"^\|.*\[type-[^)]+\.md\]", visual_guide, re.MULTILINE)
if len(visual_rows) != 27:
errors.append(f"visual-type guide must preserve 27 rows; found {len(visual_rows)}")
if len(visual_rows) != 28:
errors.append(f"visual-type guide must preserve 28 rows; found {len(visual_rows)}")
for index, name in enumerate(PATTERN_NAMES, 1):
if name not in skill:
@@ -419,7 +419,7 @@ def main() -> int:
print(f"- {error}")
return 1
if not args.example_only:
print("OK: 7 semantic patterns route independently to the preserved 27 visual types")
print("OK: 7 semantic patterns route independently to the preserved 28 visual types")
print("OK: animation modes, primitives, static fallback, and accessibility contract")
if not args.markdown_only:
print("OK: policy-trace example controls, structure, reduced motion, and unique IDs")
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env python3
"""Verify the two invariants a treemap can silently break.
A treemap's whole claim is that **area is the encoding**. Both ways of breaking
that claim render perfectly - nothing errors, nothing disappears, and neither
`lint-skin.py` (colors, fonts, a11y) nor `verify-geometry.py` (label masks vs
later-painted nodes) reads a cell against the number printed inside it.
1. AREA FIDELITY - a cell's share of the drawn area must match the share its
own label claims. This is checked as **relative** error, not percentage
points: a 0.13pp slip is noise on a 59% cell and a quarter of a 0.5% cell,
so an absolute threshold passes exactly the cell most likely to be wrong.
The failure is real - the smallest cell absorbs the whole 4px-grid residue,
because 4px is a third of a sliver and a rounding error on a giant.
2. LABEL AND MARKER FIT - a label or information marker must stay inside the
cell it names. Cells have no clip path, so overflow bleeds into a neighbour
or off the viewBox and reads as belonging to the wrong thing. A cell too
small for its annotation is not a nudge-the-coordinates problem: drop the
annotation (the cell keeps its honest area) or merge the tail into a named
"Other".
Text extent is estimated from font metrics rather than measured in a browser,
deliberately: every other gate here is pure Python and runs in CI with no
browser. The advances below are calibrated against Chromium renderings of the
shipped Geist / Geist Mono faces and rounded UP, so the estimate reports
overflow slightly before real overflow, never after.
Usage:
python3 scripts/verify-treemap.py --all
python3 scripts/verify-treemap.py skills/diagram-design/assets/example-treemap.html
Exit: 0 clean, 1 findings, 2 usage.
"""
from __future__ import annotations
import argparse
import html
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ASSET_DIR = ROOT / "skills/diagram-design/assets"
CELL_RE = re.compile(
r"<rect\b[^>]*?"
r'\bx="(?P<x>-?[\d.]+)"\s+y="(?P<y>-?[\d.]+)"\s+'
r'width="(?P<w>[\d.]+)"\s+height="(?P<h>[\d.]+)"[^>]*?'
r'rx="2"',
re.IGNORECASE,
)
TEXT_RE = re.compile(r"<text\b(?P<attrs>[^>]*)>(?P<body>.*?)</text>", re.IGNORECASE | re.DOTALL)
CIRCLE_RE = re.compile(r"<circle\b(?P<attrs>[^>]*)/?>", re.IGNORECASE)
ATTR_RE = re.compile(r'(?P<name>[\w:-]+)="(?P<value>[^"]*)"')
ROTATE_RE = re.compile(r"rotate\(\s*(?P<deg>-?[\d.]+)")
TAG_RE = re.compile(r"<[^>]+>")
# A cell states its size one of two ways, and the two must not be confused: a
# magnitude ("4.78B") is a value to be normalised against the other cells, while
# a percentage ("59%", "0.6%") is already the claimed share. Reading "0.6%" as
# the number 0.6 against cells measured in billions produces nonsense, so the
# percentage pattern is tried first and anchored on the sign.
PCT_RE = re.compile(r"(?P<num>\d[\d,]*(?:\.\d+)?)\s*%")
VALUE_RE = re.compile(r"(?P<num>\d[\d,]*(?:\.\d+)?)\s*(?P<unit>[BMK])\b")
MONO_ADVANCE = 0.62
SANS_ADVANCE = 0.60
ASCENT = 0.74
UNITS = {None: 1.0, "K": 1e3, "M": 1e6, "B": 1e9}
# A treemap cell is large enough to hold a label and small enough not to be a
# page background or diagram container.
CELL_MIN_AREA = 400.0
CELL_MAX_W = 900.0
CELL_MAX_H = 460.0
EPSILON = 1.0
# Relative area tolerance. Grid snapping on a 1000x500 viewBox cannot do better
# than a couple of percent on a sub-1% cell; 8% is loose enough never to fire on
# honest rounding and tight enough to have caught the 30%-undersized sliver this
# check exists for.
AREA_TOLERANCE = 8.0
MARKER_EPSILON = 0.01
class Box:
__slots__ = ("x", "y", "w", "h", "offset")
def __init__(self, x: float, y: float, w: float, h: float, offset: int = 0) -> None:
self.x, self.y, self.w, self.h, self.offset = x, y, w, h, offset
@property
def right(self) -> float:
return self.x + self.w
@property
def bottom(self) -> float:
return self.y + self.h
@property
def area(self) -> float:
return self.w * self.h
def contains_point(self, x: float, y: float) -> bool:
return (
self.x - EPSILON <= x <= self.right + EPSILON
and self.y - EPSILON <= y <= self.bottom + EPSILON
)
def line_of(source: str, offset: int) -> int:
return source.count("\n", 0, offset) + 1
def plain(body: str) -> str:
return html.unescape(TAG_RE.sub("", body)).strip()
def parse_cells(source: str) -> list[Box]:
"""Deduped cell rects. Each cell is painted twice: paper mask, then body."""
seen: list[Box] = []
for m in CELL_RE.finditer(source):
box = Box(
float(m.group("x")),
float(m.group("y")),
float(m.group("w")),
float(m.group("h")),
m.start(),
)
if box.area < CELL_MIN_AREA or box.w > CELL_MAX_W or box.h > CELL_MAX_H:
continue
if any(
abs(s.x - box.x) < 0.01
and abs(s.y - box.y) < 0.01
and abs(s.w - box.w) < 0.01
and abs(s.h - box.h) < 0.01
for s in seen
):
continue
seen.append(box)
return seen
def label_box(attrs: dict[str, str], body: str) -> Box | None:
"""Estimated rendered box of a <text>, in root user space."""
try:
x = float(attrs["x"])
y = float(attrs["y"])
size = float(attrs.get("font-size", "12"))
except (KeyError, ValueError):
return None
chars = len(plain(body))
if not chars:
return None
mono = "mono" in attrs.get("font-family", "").lower()
width = chars * size * (MONO_ADVANCE if mono else SANS_ADVANCE)
anchor = attrs.get("text-anchor", "start")
transform = attrs.get("transform", "")
if transform:
m = ROTATE_RE.search(transform)
# Only the quarter-turn cases are modelled; anything else is skipped
# rather than guessed at.
if m is None or abs(abs(float(m.group("deg"))) - 90.0) > 0.01:
return None
if anchor == "middle":
top = y - width / 2
elif anchor == "end":
top = y
else:
top = y - width
return Box(x - size * ASCENT, top, size, width)
if anchor == "middle":
left = x - width / 2
elif anchor == "end":
left = x - width
else:
left = x
return Box(left, y - size * ASCENT, width, size)
def _number(match: re.Match[str] | None) -> float | None:
if match is None:
return None
try:
return float(match.group("num").replace(",", ""))
except ValueError:
return None
def parse_claim(text: str) -> tuple[float | None, float | None]:
"""(percentage, value) claimed by a label. Either may be absent."""
percentage = _number(PCT_RE.search(text))
value_match = VALUE_RE.search(text)
value = _number(value_match)
if value is not None and value_match is not None:
value *= UNITS[value_match.group("unit")]
return percentage, value
def check(path: Path) -> list[str]:
source = path.read_text(encoding="utf-8")
cells = parse_cells(source)
if len(cells) < 3:
return []
findings: list[str] = []
labels: dict[int, list[str]] = {index: [] for index in range(len(cells))}
for m in TEXT_RE.finditer(source):
attrs = {a.group("name"): a.group("value") for a in ATTR_RE.finditer(m.group("attrs"))}
box = label_box(attrs, m.group("body"))
if box is None:
continue
# Host is resolved from the text's ANCHOR, not its box corner. An
# end- or middle-anchored label extends left of its anchor, so a box
# corner can land outside the very cell the label belongs to - which
# would silently attribute it to a neighbour, or to nothing at all.
anchor_x, anchor_y = float(attrs["x"]), float(attrs["y"])
host_index = None
for index, cell in enumerate(cells):
if not cell.contains_point(anchor_x, anchor_y):
continue
if host_index is None or cell.area < cells[host_index].area:
host_index = index
if host_index is None:
continue
host = cells[host_index]
labels[host_index].append(plain(m.group("body")))
# All four edges. Checking only right and bottom passes an end-anchored
# label hanging off the left, or a label whose ascent clears the top -
# both of which read as belonging to the neighbouring cell.
overflow = {
"left": host.x - box.x,
"top": host.y - box.y,
"right": box.right - host.right,
"bottom": box.bottom - host.bottom,
}
breached = {side: over for side, over in overflow.items() if over > EPSILON}
if breached:
detail = " / ".join(f"{over:.1f} {side}" for side, over in breached.items())
findings.append(
f"{path.name}:{line_of(source, m.start())}: label "
f"{plain(m.group('body'))[:38]!r} overflows its {host.w:g}x{host.h:g} cell "
f"by {detail} — drop the label or merge the cell into a named Other"
)
# Information marks are 8–12px discs placed inside sliver cells. Their
# one-letter text can fit while the surrounding disc still crosses a cell
# boundary, so circles need their own containment check.
for m in CIRCLE_RE.finditer(source):
attrs = {a.group("name"): a.group("value") for a in ATTR_RE.finditer(m.group("attrs"))}
try:
cx = float(attrs["cx"])
cy = float(attrs["cy"])
radius = float(attrs["r"])
except (KeyError, ValueError):
continue
if not 4.0 <= radius <= 6.0:
continue
host_index = None
for index, cell in enumerate(cells):
if not cell.contains_point(cx, cy):
continue
if host_index is None or cell.area < cells[host_index].area:
host_index = index
if host_index is None:
continue
host = cells[host_index]
overflow = {
"left": host.x - (cx - radius),
"top": host.y - (cy - radius),
"right": (cx + radius) - host.right,
"bottom": (cy + radius) - host.bottom,
}
breached = {
side: over for side, over in overflow.items() if over > MARKER_EPSILON
}
if breached:
detail = " / ".join(f"{over:.1f} {side}" for side, over in breached.items())
findings.append(
f"{path.name}:{line_of(source, m.start())}: information marker "
f"overflows its {host.w:g}x{host.h:g} cell by {detail} — "
"drop the marker and identify the sliver in the legend"
)
claims = {index: parse_claim(" ".join(texts)) for index, texts in labels.items()}
# Only compare on a basis every cell states, so the shares share a
# denominator. Percentages win when present: they are the claim itself.
percentages = {i: p for i, (p, _) in claims.items() if p}
magnitudes = {i: v for i, (_, v) in claims.items() if v}
known = percentages if len(percentages) >= len(magnitudes) else magnitudes
# Normalise both sides over the labelled cells only. A sliver too small to
# label is legal - it keeps its honest area and is named in the source line
# - and it must not switch this check off for the cells that do carry a
# claim, which is what comparing against the full drawn area would do.
if len(known) >= 3:
drawn_total = sum(cells[index].area for index in known)
value_total = sum(known.values())
for index, value in sorted(known.items()):
cell = cells[index]
area_share = cell.area / drawn_total * 100
value_share = value / value_total * 100
relative = (area_share - value_share) / value_share * 100
if abs(relative) > AREA_TOLERANCE:
findings.append(
f"{path.name}:{line_of(source, cell.offset)}: cell "
f"{cell.w:g}x{cell.h:g} labelled {' '.join(labels[index])[:30]!r} draws "
f"{area_share:.2f}% of the area for a {value_share:.2f}% value — "
f"{relative:+.1f}% relative. Area is the only encoding; resize the cell"
)
return findings
def targets(args: argparse.Namespace) -> list[Path]:
if args.all:
return sorted(ASSET_DIR.glob("example-treemap*.html"))
return [Path(p) for p in args.paths]
def main() -> int:
parser = argparse.ArgumentParser(
description="Verify treemap area fidelity and label/marker fit."
)
parser.add_argument("paths", nargs="*", help="HTML files to check")
parser.add_argument("--all", action="store_true", help="check every shipped treemap example")
args = parser.parse_args()
if not args.all and not args.paths:
parser.print_help()
return 2
findings: list[str] = []
checked = 0
for path in targets(args):
if not path.exists():
print(f"error: {path} does not exist", file=sys.stderr)
return 2
findings.extend(check(path))
checked += 1
for finding in findings:
print(finding)
if findings:
print(f"\n{len(findings)} treemap finding(s) across {checked} file(s).")
return 1
print(
f"OK treemap: {checked} file(s), area matches labels and every label/marker fits its cell"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+6 -4
View File
@@ -1,6 +1,6 @@
---
name: diagram-design
description: Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.
description: Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.
license: MIT
metadata:
version: "2.4"
@@ -10,7 +10,7 @@ metadata:
Create visual diagrams as self-contained HTML files with inline SVG and CSS, following an opinionated editorial design system.
Twenty-seven visual types. Semantic patterns describe behavior independently; type references describe layout. Details load from `references/` only when selected.
Twenty-eight visual types. Semantic patterns describe behavior independently; type references describe layout. Details load from `references/` only when selected.
---
@@ -49,7 +49,7 @@ Applied to schematics:
## 2. When to Use
Use for any of the 27 visual types (§3) when a reader will learn more from a visual than from prose, a table, or a bulleted list.
Use for any of the 28 visual types (§3) when a reader will learn more from a visual than from prose, a table, or a bulleted list.
**Don't use for:**
@@ -78,7 +78,7 @@ When behavior, state, enforcement, or risk carries the meaning, first load [`ref
The pattern owns semantic primitives and its tighter budget; the type owns layout grammar. Use [`references/animation.md`](references/animation.md) only when motion is requested or materially clarifies ordered change; static remains the default.
### Visual-type guide (27)
### Visual-type guide (28)
| If you're showing… | Use | Reference |
|---|---|---|
@@ -100,6 +100,7 @@ The pattern owns semantic primitives and its tighter budget; the type owns layou
| Overlap between sets | **Venn** | [type-venn.md](references/type-venn.md) |
| Ranked hierarchy or conversion drop-off | **Pyramid / funnel** | [type-pyramid.md](references/type-pyramid.md) |
| Quantitative comparison across categories | **Bar chart** | [type-bar.md](references/type-bar.md) |
| Part-of-whole where the relative sizes are the story | **Treemap** | [type-treemap.md](references/type-treemap.md) |
| Continuous trends over time | **Line chart** | [type-line.md](references/type-line.md) |
| Tasks and phases on a timeline | **Gantt** | [type-gantt.md](references/type-gantt.md) |
| Distribution and correlation between two variables | **Scatter plot** | [type-scatter.md](references/type-scatter.md) |
@@ -378,6 +379,7 @@ Quick check: if a coordinate ends in 1, 2, 3, 5, 6, 7, 9 — fix it.
| Max radar series | 5 |
| Max focal radar series | 1 |
| Max bars (bar chart) | 8 |
| Max cells (treemap) | 8 |
| Max series (line chart) | 5 |
| Max tasks (Gantt) | 12 |
| Max points (scatter plot) | 30 |
@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>World population · Treemap · dark</title>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--color-paper: #2d3142;
--color-ink: #f5f5f5;
--color-muted: #bfc0c0;
--color-accent: #f08a59;
--font-sans: 'Geist', system-ui, sans-serif;
--font-serif: 'Instrument Serif', serif;
--font-mono: 'Geist Mono', ui-monospace, monospace;
}
body { font-family: var(--font-sans); background: var(--color-paper); color: var(--color-ink); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 3rem 2rem; }
.frame { max-width: 1200px; width: 100%; }
.eyebrow { font-family: var(--font-mono); font-size: 0.66rem; font-weight: 500; letter-spacing: 0.18em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.5rem; }
h1 { font-family: var(--font-serif); font-size: clamp(1.5rem, 2.4vw + 0.75rem, 2rem); font-weight: 400; letter-spacing: -0.02em; line-height: 1.15; margin-bottom: 1.5rem; }
svg { width: 100%; min-width: 760px; display: block; }
</style>
</head>
<body>
<div class="frame">
<p class="eyebrow">Treemap · Diagram Design</p>
<h1>World population · six continents</h1>
<svg viewBox="0 0 1000 500" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="treemap-dark-title treemap-dark-desc">
<title id="treemap-dark-title">World population · six continents</title>
<desc id="treemap-dark-desc">Treemap of world population by continent in mid-2023, where cell area is population; Asia holds about 59 percent of the world total and Oceania, at under one percent, is too small to label.</desc>
<defs>
<pattern id="dots" width="22" height="22" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.9" fill="rgba(245,245,245,0.08)"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="#2d3142"/>
<rect width="100%" height="100%" fill="url(#dots)" opacity="0.55"/>
<!-- ─── CELLS ─── Squarified layout on a 4px grid. Area is the only
encoding: every cell is within 2.7% of its true share, measured as
RELATIVE error, because a 0.1pp slip is nothing on Asia and a third
of Oceania. Verified by scripts/verify-treemap.py. The non-focal
cells run a rank-ordered ink ramp, so their order survives greyscale;
the focal cell is marked by its accent stroke, not by tone. ─── -->
<!-- Asia FOCAL: 532×380 -->
<rect x="40" y="40" width="532" height="380" rx="2" fill="#2d3142"/>
<rect x="40" y="40" width="532" height="380" rx="2" fill="rgba(240,138,89,0.16)" stroke="#f08a59" stroke-width="1.5"/>
<text x="56" y="68" fill="#f5f5f5" font-size="14" font-weight="600" font-family="'Geist', sans-serif">Asia</text>
<text x="56" y="88" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace">4.78B · 59% of world</text>
<!-- Africa: 252×252 -->
<rect x="576" y="40" width="252" height="252" rx="2" fill="#2d3142"/>
<rect x="576" y="40" width="252" height="252" rx="2" fill="rgba(245,245,245,0.14)" stroke="rgba(245,245,245,0.30)" stroke-width="1"/>
<text x="592" y="68" fill="#f5f5f5" font-size="13" font-weight="600" font-family="'Geist', sans-serif">Africa</text>
<text x="592" y="88" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace">1.48B · 18%</text>
<!-- Europe: 124×252 -->
<rect x="832" y="40" width="124" height="252" rx="2" fill="#2d3142"/>
<rect x="832" y="40" width="124" height="252" rx="2" fill="rgba(245,245,245,0.11)" stroke="rgba(245,245,245,0.30)" stroke-width="1"/>
<text x="848" y="68" fill="#f5f5f5" font-size="13" font-weight="600" font-family="'Geist', sans-serif">Europe</text>
<text x="848" y="88" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace">0.75B · 9%</text>
<!-- North America: 208×124 -->
<rect x="576" y="296" width="208" height="124" rx="2" fill="#2d3142"/>
<rect x="576" y="296" width="208" height="124" rx="2" fill="rgba(245,245,245,0.08)" stroke="rgba(245,245,245,0.30)" stroke-width="1"/>
<text x="592" y="324" fill="#f5f5f5" font-size="12" font-weight="600" font-family="'Geist', sans-serif">North America</text>
<text x="592" y="344" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace">0.61B · 8%</text>
<!-- South America: 148×124 -->
<rect x="788" y="296" width="148" height="124" rx="2" fill="#2d3142"/>
<rect x="788" y="296" width="148" height="124" rx="2" fill="rgba(245,245,245,0.06)" stroke="rgba(245,245,245,0.30)" stroke-width="1"/>
<text x="804" y="324" fill="#f5f5f5" font-size="12" font-weight="600" font-family="'Geist', sans-serif">South America</text>
<text x="804" y="344" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace">0.43B · 5%</text>
<!-- Oceania: 16×124. Too narrow for a label, so it carries the info mark
rather than sideways text — the only rotated glyph in the figure would
read as a mistake. The cell keeps its honest area and is named in the
legend: a treemap must not silently omit a part of the whole it claims
to show, but it need not label what it cannot label well. -->
<rect x="940" y="296" width="16" height="124" rx="2" fill="#2d3142"/>
<rect x="940" y="296" width="16" height="124" rx="2" fill="rgba(245,245,245,0.04)" stroke="rgba(245,245,245,0.30)" stroke-width="1"/>
<circle cx="948" cy="358" r="5" fill="#f5f5f5"/>
<text x="948" y="361" fill="#2d3142" font-size="7" font-weight="600" font-family="'Geist', sans-serif" text-anchor="middle">i</text>
<!-- Legend -->
<line x1="40" y1="462" x2="960" y2="462" stroke="rgba(245,245,245,0.08)" stroke-width="0.8"/>
<text x="40" y="478" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.18em">LEGEND</text>
<text x="960" y="478" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.06em" text-anchor="end">AREA = POPULATION · OUR WORLD IN DATA CONTINENTS (UN WPP) · MID-2023 · 8.09B TOTAL</text>
<rect x="40" y="488" width="16" height="10" rx="2" fill="rgba(240,138,89,0.16)" stroke="#f08a59" stroke-width="1"/>
<text x="64" y="497" fill="#bfc0c0" font-size="8.5" font-family="'Geist', sans-serif">Asia · the focal share</text>
<rect x="240" y="488" width="16" height="10" rx="2" fill="rgba(245,245,245,0.14)" stroke="rgba(245,245,245,0.30)" stroke-width="1"/>
<text x="264" y="497" fill="#bfc0c0" font-size="8.5" font-family="'Geist', sans-serif">Other continents · darker is larger</text>
<circle cx="484" cy="493" r="5" fill="#f5f5f5"/>
<text x="484" y="496" fill="#2d3142" font-size="7" font-weight="600" font-family="'Geist', sans-serif" text-anchor="middle">i</text>
<text x="500" y="497" fill="#bfc0c0" font-size="8.5" font-family="'Geist', sans-serif">Oceania · 0.05B, 0.6% — too small to label</text>
</svg>
</div>
</body>
</html>
@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>World population · Treemap</title>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --color-paper:#f5f5f5; --color-paper-2:#ececec; --color-ink:#2d3142; --color-muted:#4f5d75; --color-soft:#7a8399; --color-rule:rgba(45,49,66,0.12); --color-accent:#eb6c36; --font-sans:'Geist',system-ui,sans-serif; --font-serif:'Instrument Serif',serif; --font-mono:'Geist Mono',ui-monospace,monospace; }
body { font-family: var(--font-sans); background: var(--color-paper); min-height: 100vh; padding: 3rem 2rem; color: var(--color-ink); }
.container { max-width: 1200px; margin: 0 auto; }
.header { margin-bottom: 2.5rem; }
.header-eyebrow { font-family: var(--font-mono); font-size: 0.66rem; font-weight: 500; letter-spacing: 0.18em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.75rem; }
h1 { font-family: var(--font-serif); font-size: clamp(1.75rem, 3vw + 1rem, 2.5rem); font-weight: 400; letter-spacing: -0.02em; line-height: 1.1; margin-bottom: 0.5rem; }
.subtitle { font-size: 1rem; line-height: 1.55; color: var(--color-muted); max-width: 58ch; }
.diagram-container { background: var(--color-paper-2); border-radius: 8px; border: 1px solid var(--color-rule); padding: 1.5rem; overflow-x: auto; }
svg { width: 100%; min-width: 760px; display: block; }
.cards { display: grid; grid-template-columns: 1.1fr 1fr 0.9fr; gap: 1rem; margin-top: 1.5rem; }
@media (max-width: 720px) { .cards { grid-template-columns: 1fr; } }
.card { background: #fff; border-radius: 6px; border: 1px solid var(--color-rule); padding: 1.25rem; }
.card .eyebrow { font-family: var(--font-mono); font-size: 0.5rem; letter-spacing: 0.18em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.5rem; }
.card-header { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.875rem; padding-bottom: 0.875rem; border-bottom: 1px solid rgba(45,49,66,0.08); }
.card-dot { width: 7px; height: 7px; border-radius: 50%; }
.card-dot.coral { background: var(--color-accent); } .card-dot.ink { background: var(--color-ink); } .card-dot.muted { background: var(--color-muted); }
.card h3 { font-size: 0.875rem; font-weight: 600; }
.card p { color: var(--color-muted); font-size: 0.8125rem; line-height: 1.55; }
.footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid rgba(45,49,66,0.10); font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.06em; color: var(--color-soft); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<p class="header-eyebrow">Treemap · Diagram Design</p>
<h1>World population · six continents</h1>
<p class="subtitle">Population by continent, mid-2023. Asia holds 4.78 billion people — 59% of everyone alive, and more than the other five continents put together.</p>
</div>
<div class="diagram-container">
<svg viewBox="0 0 1000 500" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="treemap-full-title treemap-full-desc">
<title id="treemap-full-title">World population · six continents</title>
<desc id="treemap-full-desc">Treemap of world population by continent in mid-2023, where cell area is population; Asia holds about 59 percent of the world total and Oceania, at under one percent, is too small to label.</desc>
<defs>
<pattern id="dots" width="22" height="22" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.9" fill="rgba(45,49,66,0.10)"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="#f5f5f5"/>
<rect width="100%" height="100%" fill="url(#dots)" opacity="0.55"/>
<!-- ─── CELLS ─── Squarified layout on a 4px grid. Area is the only
encoding: every cell is within 2.7% of its true share, measured as
RELATIVE error, because a 0.1pp slip is nothing on Asia and a third
of Oceania. Verified by scripts/verify-treemap.py. The non-focal
cells run a rank-ordered ink ramp, so their order survives greyscale;
the focal cell is marked by its accent stroke, not by tone. ─── -->
<!-- Asia FOCAL: 532×380 -->
<rect x="40" y="40" width="532" height="380" rx="2" fill="#f5f5f5"/>
<rect x="40" y="40" width="532" height="380" rx="2" fill="rgba(235,108,54,0.16)" stroke="#eb6c36" stroke-width="1.5"/>
<text x="56" y="68" fill="#2d3142" font-size="14" font-weight="600" font-family="'Geist', sans-serif">Asia</text>
<text x="56" y="88" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">4.78B · 59% of world</text>
<!-- Africa: 252×252 -->
<rect x="576" y="40" width="252" height="252" rx="2" fill="#f5f5f5"/>
<rect x="576" y="40" width="252" height="252" rx="2" fill="rgba(45,49,66,0.16)" stroke="rgba(45,49,66,0.30)" stroke-width="1"/>
<text x="592" y="68" fill="#2d3142" font-size="13" font-weight="600" font-family="'Geist', sans-serif">Africa</text>
<text x="592" y="88" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">1.48B · 18%</text>
<!-- Europe: 124×252 -->
<rect x="832" y="40" width="124" height="252" rx="2" fill="#f5f5f5"/>
<rect x="832" y="40" width="124" height="252" rx="2" fill="rgba(45,49,66,0.13)" stroke="rgba(45,49,66,0.30)" stroke-width="1"/>
<text x="848" y="68" fill="#2d3142" font-size="13" font-weight="600" font-family="'Geist', sans-serif">Europe</text>
<text x="848" y="88" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">0.75B · 9%</text>
<!-- North America: 208×124 -->
<rect x="576" y="296" width="208" height="124" rx="2" fill="#f5f5f5"/>
<rect x="576" y="296" width="208" height="124" rx="2" fill="rgba(45,49,66,0.10)" stroke="rgba(45,49,66,0.30)" stroke-width="1"/>
<text x="592" y="324" fill="#2d3142" font-size="12" font-weight="600" font-family="'Geist', sans-serif">North America</text>
<text x="592" y="344" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">0.61B · 8%</text>
<!-- South America: 148×124 -->
<rect x="788" y="296" width="148" height="124" rx="2" fill="#f5f5f5"/>
<rect x="788" y="296" width="148" height="124" rx="2" fill="rgba(45,49,66,0.07)" stroke="rgba(45,49,66,0.30)" stroke-width="1"/>
<text x="804" y="324" fill="#2d3142" font-size="12" font-weight="600" font-family="'Geist', sans-serif">South America</text>
<text x="804" y="344" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">0.43B · 5%</text>
<!-- Oceania: 16×124. Too narrow for a label, so it carries the info mark
rather than sideways text — the only rotated glyph in the figure would
read as a mistake. The cell keeps its honest area and is named in the
legend: a treemap must not silently omit a part of the whole it claims
to show, but it need not label what it cannot label well. -->
<rect x="940" y="296" width="16" height="124" rx="2" fill="#f5f5f5"/>
<rect x="940" y="296" width="16" height="124" rx="2" fill="rgba(45,49,66,0.04)" stroke="rgba(45,49,66,0.30)" stroke-width="1"/>
<circle cx="948" cy="358" r="5" fill="#2d3142"/>
<text x="948" y="361" fill="#f5f5f5" font-size="7" font-weight="600" font-family="'Geist', sans-serif" text-anchor="middle">i</text>
<!-- Legend -->
<line x1="40" y1="462" x2="960" y2="462" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<text x="40" y="478" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.18em">LEGEND</text>
<text x="960" y="478" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.06em" text-anchor="end">AREA = POPULATION · OUR WORLD IN DATA CONTINENTS (UN WPP) · MID-2023 · 8.09B TOTAL</text>
<rect x="40" y="488" width="16" height="10" rx="2" fill="rgba(235,108,54,0.16)" stroke="#eb6c36" stroke-width="1"/>
<text x="64" y="497" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">Asia · the focal share</text>
<rect x="240" y="488" width="16" height="10" rx="2" fill="rgba(45,49,66,0.16)" stroke="rgba(45,49,66,0.30)" stroke-width="1"/>
<text x="264" y="497" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">Other continents · darker is larger</text>
<circle cx="484" cy="493" r="5" fill="#2d3142"/>
<text x="484" y="496" fill="#f5f5f5" font-size="7" font-weight="600" font-family="'Geist', sans-serif" text-anchor="middle">i</text>
<text x="500" y="497" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">Oceania · 0.05B, 0.6% — too small to label</text>
</svg>
</div>
<div class="cards">
<div class="card">
<div class="card-header"><span class="card-dot coral"></span><h3>One cell carries the story</h3></div>
<p>Asia is the focal cell because it is the finding, not because it sorts first. Nearly six of every ten people alive are inside that rectangle.</p>
</div>
<div class="card">
<div class="card-header"><span class="card-dot ink"></span><h3>How to read it</h3></div>
<p>Cells are laid out squarified, largest first, so aspect ratios stay near 1 and areas stay comparable by eye. Among the other five, darker means larger.</p>
</div>
<div class="card">
<div class="card-header"><span class="card-dot muted"></span><h3>What the chart doesn't show</h3></div>
<p>Population, not growth. Africa's cell is under a third of Asia's today, and it is the one adding people fastest — this is one moment, not a direction.</p>
</div>
</div>
<div class="footer">
<span>world population · six continents</span>
<span>example · diagram design</span>
</div>
</div>
</body>
</html>
@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>World population · Treemap</title>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--color-paper: #f5f5f5;
--color-ink: #2d3142;
--color-muted: #4f5d75;
--color-accent: #eb6c36;
--font-sans: 'Geist', system-ui, sans-serif;
--font-serif: 'Instrument Serif', serif;
--font-mono: 'Geist Mono', ui-monospace, monospace;
}
body { font-family: var(--font-sans); background: var(--color-paper); color: var(--color-ink); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 3rem 2rem; }
.frame { max-width: 1200px; width: 100%; }
.eyebrow { font-family: var(--font-mono); font-size: 0.66rem; font-weight: 500; letter-spacing: 0.18em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.5rem; }
h1 { font-family: var(--font-serif); font-size: clamp(1.5rem, 2.4vw + 0.75rem, 2rem); font-weight: 400; letter-spacing: -0.02em; line-height: 1.15; margin-bottom: 1.5rem; }
svg { width: 100%; min-width: 760px; display: block; }
</style>
</head>
<body>
<div class="frame">
<p class="eyebrow">Treemap · Diagram Design</p>
<h1>World population · six continents</h1>
<svg viewBox="0 0 1000 500" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="treemap-title treemap-desc">
<title id="treemap-title">World population · six continents</title>
<desc id="treemap-desc">Treemap of world population by continent in mid-2023, where cell area is population; Asia holds about 59 percent of the world total and Oceania, at under one percent, is too small to label.</desc>
<defs>
<pattern id="dots" width="22" height="22" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.9" fill="rgba(45,49,66,0.10)"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="#f5f5f5"/>
<rect width="100%" height="100%" fill="url(#dots)" opacity="0.55"/>
<!-- ─── CELLS ─── Squarified layout on a 4px grid. Area is the only
encoding: every cell is within 2.7% of its true share, measured as
RELATIVE error, because a 0.1pp slip is nothing on Asia and a third
of Oceania. Verified by scripts/verify-treemap.py. The non-focal
cells run a rank-ordered ink ramp, so their order survives greyscale;
the focal cell is marked by its accent stroke, not by tone. ─── -->
<!-- Asia FOCAL: 532×380 -->
<rect x="40" y="40" width="532" height="380" rx="2" fill="#f5f5f5"/>
<rect x="40" y="40" width="532" height="380" rx="2" fill="rgba(235,108,54,0.16)" stroke="#eb6c36" stroke-width="1.5"/>
<text x="56" y="68" fill="#2d3142" font-size="14" font-weight="600" font-family="'Geist', sans-serif">Asia</text>
<text x="56" y="88" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">4.78B · 59% of world</text>
<!-- Africa: 252×252 -->
<rect x="576" y="40" width="252" height="252" rx="2" fill="#f5f5f5"/>
<rect x="576" y="40" width="252" height="252" rx="2" fill="rgba(45,49,66,0.16)" stroke="rgba(45,49,66,0.30)" stroke-width="1"/>
<text x="592" y="68" fill="#2d3142" font-size="13" font-weight="600" font-family="'Geist', sans-serif">Africa</text>
<text x="592" y="88" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">1.48B · 18%</text>
<!-- Europe: 124×252 -->
<rect x="832" y="40" width="124" height="252" rx="2" fill="#f5f5f5"/>
<rect x="832" y="40" width="124" height="252" rx="2" fill="rgba(45,49,66,0.13)" stroke="rgba(45,49,66,0.30)" stroke-width="1"/>
<text x="848" y="68" fill="#2d3142" font-size="13" font-weight="600" font-family="'Geist', sans-serif">Europe</text>
<text x="848" y="88" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">0.75B · 9%</text>
<!-- North America: 208×124 -->
<rect x="576" y="296" width="208" height="124" rx="2" fill="#f5f5f5"/>
<rect x="576" y="296" width="208" height="124" rx="2" fill="rgba(45,49,66,0.10)" stroke="rgba(45,49,66,0.30)" stroke-width="1"/>
<text x="592" y="324" fill="#2d3142" font-size="12" font-weight="600" font-family="'Geist', sans-serif">North America</text>
<text x="592" y="344" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">0.61B · 8%</text>
<!-- South America: 148×124 -->
<rect x="788" y="296" width="148" height="124" rx="2" fill="#f5f5f5"/>
<rect x="788" y="296" width="148" height="124" rx="2" fill="rgba(45,49,66,0.07)" stroke="rgba(45,49,66,0.30)" stroke-width="1"/>
<text x="804" y="324" fill="#2d3142" font-size="12" font-weight="600" font-family="'Geist', sans-serif">South America</text>
<text x="804" y="344" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">0.43B · 5%</text>
<!-- Oceania: 16×124. Too narrow for a label, so it carries the info mark
rather than sideways text — the only rotated glyph in the figure would
read as a mistake. The cell keeps its honest area and is named in the
legend: a treemap must not silently omit a part of the whole it claims
to show, but it need not label what it cannot label well. -->
<rect x="940" y="296" width="16" height="124" rx="2" fill="#f5f5f5"/>
<rect x="940" y="296" width="16" height="124" rx="2" fill="rgba(45,49,66,0.04)" stroke="rgba(45,49,66,0.30)" stroke-width="1"/>
<circle cx="948" cy="358" r="5" fill="#2d3142"/>
<text x="948" y="361" fill="#f5f5f5" font-size="7" font-weight="600" font-family="'Geist', sans-serif" text-anchor="middle">i</text>
<!-- Legend -->
<line x1="40" y1="462" x2="960" y2="462" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<text x="40" y="478" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.18em">LEGEND</text>
<text x="960" y="478" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.06em" text-anchor="end">AREA = POPULATION · OUR WORLD IN DATA CONTINENTS (UN WPP) · MID-2023 · 8.09B TOTAL</text>
<rect x="40" y="488" width="16" height="10" rx="2" fill="rgba(235,108,54,0.16)" stroke="#eb6c36" stroke-width="1"/>
<text x="64" y="497" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">Asia · the focal share</text>
<rect x="240" y="488" width="16" height="10" rx="2" fill="rgba(45,49,66,0.16)" stroke="rgba(45,49,66,0.30)" stroke-width="1"/>
<text x="264" y="497" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">Other continents · darker is larger</text>
<circle cx="484" cy="493" r="5" fill="#2d3142"/>
<text x="484" y="496" fill="#f5f5f5" font-size="7" font-weight="600" font-family="'Geist', sans-serif" text-anchor="middle">i</text>
<text x="500" y="497" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">Oceania · 0.05B, 0.6% — too small to label</text>
</svg>
</div>
</body>
</html>
+3
View File
@@ -276,6 +276,9 @@
<button class="tab new" data-type="high-level-vertical">
<span class="eyebrow">28</span>High-Level / Parametric
</button>
<button class="tab new" data-type="treemap">
<span class="eyebrow">29</span>Treemap
</button>
<button class="tab new" data-type="dp-security-matrix">
<span class="eyebrow">32</span>DP security matrix
</button>
@@ -159,7 +159,7 @@ Write the new tokens to `style-guide.md`. Suggest running the `/regenerate-examp
After onboarding, the user should:
1. Open `assets/index.html` (gallery) and confirm the new palette feels coherent across all 27 types.
1. Open `assets/index.html` (gallery) and confirm the new palette feels coherent across all 28 types.
2. If any type looks off, they usually need to tune `muted` (often too dark or too light against the new `paper`).
---
@@ -1,6 +1,6 @@
# Semantic patterns
Semantic patterns describe **what a system does**; the 27 visual types describe **how information is arranged**. Choose a pattern first when behavior, state, enforcement, or risk is load-bearing, then use its nearest visual type as the layout grammar. If no pattern matches, choose a visual type directly.
Semantic patterns describe **what a system does**; the 28 visual types describe **how information is arranged**. Choose a pattern first when behavior, state, enforcement, or risk is load-bearing, then use its nearest visual type as the layout grammar. If no pattern matches, choose a visual type directly.
Use one primary pattern per figure. A second pattern may supply at most one supporting primitive; if both need full treatment, split overview and detail. Labels and outcomes must remain complete in a static frame.
@@ -0,0 +1,56 @@
# Treemap
**Best for:** part-of-whole where the *relative sizes are the story* — disk and bundle usage, budget or spend breakdowns, market share, population, time allocation. Use when a single total decomposes into parts and the reader's question is "what dominates, and by how much?"
Not for: ranked lists where exact values matter more than proportion (use a **bar chart**), containment or scope relationships with no quantity (use **nested**), or a hierarchy you need to trace parent-to-child (use a **tree**).
## Layout conventions
- **Plot area:** `x` 40 → 956, `y` 40 → 420 inside a `0 0 1000 500` viewBox — the same vertical rhythm as bar, line and scatter, so the legend block sits where a reader of those types already expects it (rule at `y=462`, `LEGEND` at `478`, keys at `488`).
- **Squarified layout** (Bruls et al., 2000): sort descending, lay each row against the *shorter* side of the remaining rectangle. Aspect ratios stay near 1, which is what makes two areas comparable by eye. Never lay cells out in simple stripes — long thin cells cannot be compared.
- **Cell count:** 4–8. Past 8, the tail becomes unlabelable slivers — group the tail into a single explicit "Other" cell and name what it contains in the source line.
- **4px grid:** cell edges snap to the grid like everything else, with a 4px gutter between cells. Snapping and gutters both move area, so check the result — and check it as **relative** error (`(drawn − true) ÷ true`), not percentage points. A 0.1pp slip is nothing on a 59% cell and a quarter of a 0.5% cell; absolute error hides exactly the mistake you need to catch. Keep every cell within a few percent of its true share — the shipped example is within 2.7%, its worst case being the sliver, where the grid cannot do better — and state the encoding in the source line (`AREA = POPULATION`). `scripts/verify-treemap.py` gates this.
- **Fill:** a rank-ordered `ink` opacity ramp (e.g. `0.16 → 0.04`), so the non-focal order survives greyscale printing and colour-blind readers. One accent cell only — the editorially focal one, not automatically the largest. Note what this does *not* buy you: the focal cell is painted off-ramp, so in greyscale it lands wherever its tint happens to fall, not at its rank. Identify it by the accent stroke, and never let tone carry meaning the area doesn't already carry.
- **Stroke:** 1px `ink @ 0.30` hairline on every cell; the focal cell takes a 1.5px `accent` stroke.
- **Labels sit inside the cell**, top-left, 16px in from the edge: name in Geist 12–14px 600, value in Geist Mono 9px on the next line. Three tiers by cell size:
- large — name + value + share (`4.78B · 59% of world`)
- medium — name + value
- small — a 3-letter mono abbreviation, if one reads honestly
- sliver — **no text.** When the cell is at least 12×12px, use a filled `ink` disc, `r=5`, centred in the cell, carrying a paper-coloured `i`, with the cell's name and share spelled out in the legend. Below 12px on either axis, omit the in-cell mark and identify the sliver by position in the legend; a fixed-size disc must never cross the cell boundary. `scripts/verify-treemap.py` checks marker containment. Resist rotating a label to make it fit: a single sideways word among five upright ones reads as a mistake before it reads as data, and its centring is a trap — `text-anchor="middle"` centres along the *baseline*, which a quarter-turn maps to the cell's long axis, leaving nothing centring the cap-height band across the narrow one.
- Never shrink a cell to fit its label. The cell size is the data; the label is commentary.
- **Contrast:** compute the ceiling against the token you actually ship. The 9px value line is `muted`, not `ink`, and `muted` needs a lighter cell than `ink` does: measured against the composited fill, the top of the ramp can go to **0.16 on light paper and 0.14 on dark** before it drops under 4.5:1. (`ink` would tolerate 0.20 — which is exactly the number you will write down if you check the wrong token.) A mid-tone fill — solid accent, or 50% ink — fails against both light and dark text; use the tint-plus-stroke pattern instead. The source line takes `muted` too: `soft` measures 3.48:1 on paper and never reaches AA anywhere on this ramp.
- **Legend:** the house block (rule, `LEGEND`, keys), naming the focal cell, the direction of the ink ramp, and any cell carrying an info mark. The source line rides the same row as `LEGEND`, right-aligned in mono 8px, stating what area encodes plus the dataset and its date.
### Cell element pattern
```svg
<!-- Opaque paper mask prevents the dot pattern showing through the tint -->
<rect x="X" y="Y" width="W" height="H" rx="2" fill="#f5f5f5"/>
<!-- Cell body -->
<rect x="X" y="Y" width="W" height="H" rx="2" fill="rgba(45,49,66,0.18)" stroke="rgba(45,49,66,0.30)" stroke-width="1"/>
<text x="X+16" y="Y+28" fill="#2d3142" font-size="13" font-weight="600" font-family="'Geist', sans-serif">NAME</text>
<text x="X+16" y="Y+46" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">VALUE · SHARE</text>
```
Focal cell: replace the fill with `rgba(235,108,54,0.16)` and the stroke with `#eb6c36` at 1.5px.
## Honest-data rule
**Area is the only encoding.** Never clip, floor, or log-scale a cell to make it visible, and never drop a cell because it is small — a treemap claims to show a whole, so an omitted part makes the picture a lie. A part too small to label gets a legend entry and, only when the cell can contain it, an info mark; parts too small to draw get merged into one honest, named "Other". If several cells are invisible at the target size, the data wants a bar chart.
Watch the smallest cell hardest: it is the one that grid snapping and gutters distort most, and the one nobody checks. Beware, too, the rounding you *display*: six values each rounded up can sum past the total you printed underneath them. Either carry enough precision that the parts reconcile, or say plainly in the source line that they don't (`PARTS ROUNDED, MAY NOT SUM`).
## Anti-patterns
- More than 8 cells without an "Other" bucket (unlabelable slivers).
- Cells that do not sum to the stated whole, or a percentage column that doesn't total 100.
- Stripe layout instead of squarified — defeats area comparison.
- Rainbow fills: one hue per cell destroys the rank reading and the one-accent rule.
- Nesting more than two levels deep in a static diagram; a second level needs a heavier border and its own label tier, and a third is unreadable without interaction.
- 3-D or shadowed cells — area is already the message.
## Examples
- `assets/example-treemap.html` — minimal light
- `assets/example-treemap-dark.html` — minimal dark
- `assets/example-treemap-full.html` — full editorial