# Tintin Rendering Specification (v32)

> Requires file attachments: `filmstintinTalkingTtf` (font) and `tintinSpecMd` (this file).
> **This is a specification, not a guide. Every rule is mandatory. Every token value is exact. Do not improvise, approximate, or substitute your own values. Read this file fully and comply with it before writing any drawing code.**

---

## Bootstrap

Run this single cell exactly as written. It exports one name — `_T` — containing all library functions and tokens. Do not modify it.

    const _T = await (async () => {
      const src = [...(await tintinSpecMd.text()).matchAll(/```js\n([\s\S]*?)```/g)]
        .reduce((a, b) => a[1].length > b[1].length ? a : b)[1]
      const lib = new Function("filmstintinTalkingTtf", "d3", src + ";" +
        `return{${src.match(/^(?:function|const|let)\s+([A-Za-z_$]\w*)/gm)
          .map(m => m.split(/\s+/)[1]).join(",")}}`)
        (filmstintinTalkingTtf, d3)
      await lib.tintinFontReady
      return lib
    })()

After running the bootstrap, all exports are on `_T`. Access them as `_T.TINTIN`, `_T.tintinLayout(...)`, `_T.tintinBar(...)`, etc. The font is already loaded — no need to await it again.

**Important:** Observable cells can only declare one `const` per cell. Do NOT attempt to destructure `_T` into individual names — it will fail. Always use the `_T.` prefix.

The rest of this file is the specification: mandatory rules, exact token values, and helper API documentation. Wherever the spec references a name like `TINTIN` or `tintinBar(...)`, use `_T.TINTIN` or `_T.tintinBar(...)` in your code.

---

## Rules

**Every rule below is load-bearing. Treat omissions as bugs, not style choices.**

### STEP 1 — Plan the chart elements

Before writing any drawing code, list every element this chart needs. Go through each item below and decide **include** or **skip** — do not start coding until this is done. All items below are handled automatically when you pass the relevant parameter to `tintinLayout` — they are rendered into `L.chromeSvg`. You do not draw them manually.

- Title → `title: "..."`
- Key / legend → `keyItems: [{ label, color }, ...]`
- Y-axis domain line → `yDomain: true`
- X-axis domain line → `xDomain: true`
- Y-axis tick lines + labels → `yTicks: [...]`
- X-axis tick lines + labels → `xTicks: { labels: [...], band: true }`
- Grid lines → `grid: "y"` or `"x"` or `"both"`
- Y-axis label → `yLabel: "..."`
- X-axis label → `xLabel: "..."`
- Attribution line → `attribution: "..."`

Not all charts need all elements — a pie has no axes, a heatmap has no domain lines. But every omission must be a conscious choice, not an oversight.

`tintinLayout` renders all of these into `L.chromeSvg`. Include it in your SVG string — do **not** draw title, key, ticks, domain lines, grid lines, axis labels, or attribution manually.

### Display & font

- **SVG display:** set `container.innerHTML`. Never use `DOMParser` or `display(svgNode)`.
- **Font:** Already loaded by the bootstrap cell. No need to await it in chart cells.

### Layout

Always call `tintinLayout()` and use its returned positions. Never hardcode coordinates.

**Typical usage** — pass all content strings and let the function handle measurement, positioning, and rendering:

```
const L = tintinLayout({
  plotW: 700, plotH: 400,
  title: "Sales by Region",
  keyItems: items,                                    // [{ label, color }, ...]
  yTicks: ["0", "20", "40", "60"],                   // evenly spaced bottom→top
  xTicks: { labels: categories, band: true },         // centred in equal bands
  yDomain: true, xDomain: true,
  grid: "y",
  yLabel: "Revenue ($M)",
  xLabel: "Quarter",
  attribution: "Source: Company filings",
})
```

`L.chromeSvg` contains everything except data marks and the background rect — title, key, ticks, domain lines, grid, axis labels, and attribution. Include it in your SVG:

```
let s = `<rect width="${L.totalW}" height="${L.totalH}" fill="${bg}"/>`
s += L.chromeSvg
// … draw data marks using L.plotLeft, L.plotTop, L.plotW, L.plotH …
```

**Tick positioning modes:**
- `yTicks: ["0", "20", "40"]` — evenly spaced edge-to-edge (linear scale). Labels[0] at bottom, labels[n−1] at top.
- `yTicks: { labels: [...], band: true }` — centred in equal bands (categorical/band scale).
- Same for `xTicks` (left-to-right).

### Paths

- Draw fill and outline as **separate `<path>` elements**. Never set both fill and stroke on the same path (shared edges double opacity).
- Use `_fillPath(d, color)` and `_outlinePath(d, color)` to emit mark paths — they encode all standard attributes (`fill-opacity`, `stroke-width`, `stroke-linejoin`, etc.) from tokens.
- Data mark stroke-width: `TINTIN.strokeWidth.mark` (1.2). Structural chrome: `TINTIN.strokeWidth.reference` (1.0).

### Structural chrome

All structural lines (grid lines, domain lines, tick marks) use `TINTIN.ink`, `TINTIN.opacity.reference` (0.25), and `TINTIN.strokeWidth.reference` (1.0). Never hardcode values. When using the new-style `tintinLayout` API, `L.chromeSvg` contains everything except data marks and the background rect — title, key, ticks, domain lines, grid, axis labels, and attribution. Do **not** draw any of these manually. The function already skips grid lines that coincide with domain lines. For chart helpers that render their own chrome, use `_chromeLine(pts)` to emit structural paths.

### Text tokens

| Element | Size | Weight | Color | Opacity | Anchor |
|---|---|---|---|---|---|
| Chart title | `fsTitle` (15px) | `fwTitle` (bold) | `TINTIN.ink` | full | middle |
| Axis labels | `fsLabel` (15px) | `fwBody` | `TINTIN.ink` | full | middle / rotated |
| Axis ticks | `fsTick` (14px) | `fwBody` | `TINTIN.ink` | full | end (y) / middle (x) |
| Heatmap cells | `fsCell` (14px) | `fwBody` | `TINTIN.ink` | full | middle |
| Annotations, n= labels | `fsAnnotation` (12px) | `fwBody` | `TINTIN.ink` | full | context-dependent |
| Attribution | `fsAnnotation` (12px) | `fwBody` | `TINTIN.ink` | full | start |

Use `_tintinText(x, y, text, { size, weight, anchor, rotate })` to emit text elements — it encodes font-family, fill colour, and all standard attributes from tokens. `rotate` is degrees clockwise around the element's own (x, y); use `rotate: -90` for a vertical y-axis label.

**Note:** when you pass content strings (`title`, `keyItems`, `yLabel`, `xLabel`, `attribution`) or tick arrays (`yTicks`, `xTicks`) to `tintinLayout`, all text is positioned and rendered automatically in `L.chromeSvg`. The formulas below are only needed by chart helpers that render their own text and ticks.

| Axis | Placement | Formula |
|---|---|---|
| Y-axis | Text centered vertically on a horizontal tick | `y + TINTIN.baselineShift` |
| X-axis | Text placed below a vertical tick | `tickY + TINTIN.tick.length + TINTIN.tick.gap + TINTIN.capHeight` |

`TINTIN.baselineShift` (4px) shifts from optical centre to SVG baseline — only meaningful for vertically centring text on a horizontal line (y-axis tick labels, key swatches, heatmap cells). `TINTIN.capHeight` (10px) is the measured ascent of the tick font — use it for placing text below a point (x-axis tick labels). These two tokens serve different purposes: **never** substitute one for the other. **Never** pass `baselineShift` as an option to `_tintinText` — it is not an accepted option; add it to the y coordinate manually.

If the user explicitly asks for reduced opacity on annotations, snap to the nearest defined token from `TINTIN.opacity` (`secondary: 0.25`, `reference: 0.25`) — never invent an arbitrary value.

### Debug mode

Pass `debug: true` to any chart helper, or call debug functions manually after setting `container.innerHTML`:

- **`tintinDebugText(container)`** — red boxes behind every text element (getBBox, respects rotation). Verifies baseline alignment and tick label clearance.
- **`tintinDebugLayout(container, L)`** — blue boxes showing spacing zones (margins, gaps), yellow box showing the plot area boundary. Verifies the spacing stack matches the spec. Pass the `L` object returned by `tintinLayout()`.

Remove `debug: true` before publishing.

### Attribution line

Pass `attribution: "Source: ..."` to `tintinLayout()`. This increases the bottom margin from 3N to 4N and renders the text into `L.chromeSvg` at 1.5N from the left and 1.5N from the bottom of the SVG. For chart helpers that render their own text, the old-style `hasAttribution: true` still works — it reserves the space and returns `L.attrX` / `L.attrY` without rendering.

### Axis domain lines

Draw on **both** axes or **neither**. Asymmetry is only correct when the two axes are structurally different in kind (e.g. one quantitative, one purely categorical with no meaningful edge).

**Omit domain lines when:**
- No axes at all (pie, donut).
- A chart helper explicitly omits them (heatmap — cell grid defines its own boundary; ridgeline — y-axis is a categorical stack).
- The scale has no meaningful outer edge (radial/polar axis).

All domain lines: `TINTIN.wiggle.reference` amplitude, `TINTIN.ink` color, `TINTIN.opacity.reference` opacity. Seeds: 998 (y-axis), 999 (x-axis).

### Palettes

- Every chart uses colours from exactly **one** palette entry. Use `bg` for the background, `colors[]` for all marks and key swatches.
- For **n > 2** data colours, use `tintinPaletteColors(palette, n)`. Always declare the final `n` upfront so hue spacing is globally optimal — never call it with `n=2` and then manually add more.

### Seeds

Every mark gets a unique integer seed via `tintinRng(seed)`. Use different seeds for fill vs. outline paths (`seed`, `seed+1`). Use a consistent scheme across a chart (e.g. `row * 1000 + col * 10`) so renders are fully deterministic.

### Wiggle methods

| Method | Use for | Amplitude unit |
|---|---|---|
| `tintinWiggle()` / `tintinWigglePath()` | Straight lines, axis ticks, grid lines, bar/rect edges | px |
| `tintinOrganicCirclePath()` | Dots and circles **only** — smooth harmonic radius modulation | Fraction of radius |

Never use per-point jitter on circles (creates visible corners). Never use harmonic modulation on straight lines.

### Keys (categorical legends)

Pass `keyItems: [{ label, color }, ...]` to `tintinLayout()`. The function measures labels, wraps rows, allocates space, and renders the key into `L.chromeSvg` automatically.

For chart helpers that need custom key rendering (e.g. heatmap gradient bar), use the old-style API:

1. Decide `plotW`.
2. `const keyLayout = tintinKeyLayout(items, plotW)` — `items`: `[{ label, color }]`.
3. Pass to `tintinLayout({ hasKey: true, keyHeight: keyLayout.keyHeight, keyNRows: keyLayout.keyNRows, keyRowGap: keyLayout.rowGap, ... })`.
4. Draw: `tintinKeyWiggleRows(keyLayout.keyRows, L.keyRowYs, L.plotCX, keyLayout.itemWidths)`.

The heatmap gradient bar is NOT a categorical key and does NOT use these helpers.

### Pie charts

Never use D3's `arc()` path for outlines — SVG arc command parameters are corrupted by `tintinWigglePath`. Instead:

1. Build one shared organic orbit via `tintinPieOrbit()` with a fixed seed.
2. Draw each slice fill as a polygon using `orbit.point()` for the rim.
3. Stroke each slice's arc outline along its angular segment of the shared orbit.
4. Draw each spoke with `tintinWiggle()`, ending at `orbit.point(startAngle)`.

Never split a spoke into two half-coloured segments. Set `plotW = max(plotDiameter, tintinKeyLayout(items, plotW).keyWidth)` so the key never overflows margins.

### Heatmaps

Always use `tintinHeatmap(container, options)` — never build a heatmap by hand.

### Radial / polar charts

_(Future chart type.)_ No domain lines on radial axes (the axis has no defined outer edge). Spacing follows the same N-based system. A `tintinRadial()` helper will be added here.

---

## Mark helpers

Mark helpers return SVG string fragments. The caller assembles the full SVG.

**Pattern:** `tintinXxx(geometry, color, seed) → string`

| Mark | Helper | Wiggle | Notes |
|---|---|---|---|
| Bar / rect | `tintinBar(x, y, w, h, color, seed)` | 0.1 px | Fill + outline paths |
| Key swatch | via `tintinKeyWiggleRows(...)` | 0.1 px | Measured + wrapped rows |
| Dot / scatter | `tintinOrganicDot(cx, cy, r, color, seed)` | 0.01 × r | Harmonic circle, fill + outline |
| Pie slice fill | `tintinPieSliceFill(orbit, cx, cy, start, end, color)` | via orbit | Polygon, no stroke |
| Pie arc outline | `tintinPieArc(orbit, start, end, strokeColor)` | via orbit | Polyline, stroke only |
| Pie spoke | `tintinPieSpoke(orbit, cx, cy, angle, strokeColor, seed)` | 0.3 px | Wiggled line to orbit rim |

### Shared SVG helpers

| Helper | Purpose |
|---|---|
| `_fillPath(d, color)` | Fill `<path>` with `color.dark` at `TINTIN.opacity.fill` |
| `_outlinePath(d, colorOrStroke)` | Stroke-only `<path>` at `TINTIN.strokeWidth.mark`; accepts color object or hex string |
| `_chromeLine(pts, color?)` | Structural line at `TINTIN.strokeWidth.reference` and `TINTIN.opacity.reference`; default color is `TINTIN.ink` |
| `_tintinText(x, y, text, opts?)` | `<text>` element with all standard font/fill attributes; opts: `size`, `weight`, `anchor`, `rotate` (degrees around x,y) |
| `_tintinMeasureText(textOrArray, fontSize?)` | getBBox width measurement; accepts string or array |
| `tintinDebugText(container)` | Red boxes behind every `<text>` element via getBBox. Respects rotation. |
| `tintinDebugLayout(container, L)` | Blue boxes for spacing zones (margins, gaps), yellow box for plot area. Pass `L` from `tintinLayout()`. |

Low-level building blocks: `tintinRectPath()`, `tintinOrganicCirclePath()`, `tintinWiggle()`, `tintinWigglePath()`, `tintinSwatchRect()`.

---

## Chart helpers

Chart helpers render a complete SVG into a container element.

**Pattern:** `tintinXxx(container, options)` — sets `container.innerHTML`.

**Common options** (all chart helpers accept or should accept):

| Field | Type | Required | Notes |
|---|---|---|---|
| `title` | string | yes | Chart title |
| `bg` or `palette` | string / object | yes | Background colour; chart helpers use one or the other |
| `plotW` | number | no | Inner plot width (px), default varies |
| `debug` | boolean | no | If true, call `tintinDebugText(container)` and `tintinDebugLayout(container, L)` after rendering to overlay debug visuals |

### Chart helper reference

| Chart | Helper | Domain lines | Key fields |
|---|---|---|---|
| Ridgeline | `tintinRidgeline(container, opts)` | x-axis only | `rows`, `xLabels`, `yMin`, `yMax`, `rowH`, `overlapRatio` |
| Heatmap | `tintinHeatmap(container, opts)` | Neither axis | `rows`, `cols`, `vals`, `palette`, `cellW`, `cellH` |
| _(Radial)_ | `tintinRadial(container, opts)` | _(None — future)_ | _(TBD)_ |

---

## Code

```js
// ═══════════════════════════════════════════════════════════════════
// TINTIN SPEC — v32
// ═══════════════════════════════════════════════════════════════════

// ── 1. FONT ─────────────────────────────────────────────────────────
const tintinFontReady = (async () => {
  const url = filmstintinTalkingTtf.href
  const face = new FontFace("FilmstintinTalking", `url(${url})`)
  await face.load()
  document.fonts.add(face)
})()

// ── 2. CORE STYLE TOKENS ───────────────────────────────────────────
const TINTIN = {
  // Typography — N = fsLabel is the base unit for all spacing.
  font:         "'FilmstintinTalking', Georgia, serif",
  fsTitle:      15,
  fsLabel:      15,   // N
  fsTick:       14,
  fsCell:       14,
  fsAnnotation: 12,   // annotations, n= labels, attribution

  fwTitle:  "bold",
  fwBody:   "normal",

  ink:   "#555",

  opacity: {
    fill:      0.70,
    stroke:    0.70,
    line:      0.70,
    reference: 0.25,
    secondary: 0.25,
  },

  strokeWidth: {
    mark:      1.2,   // data mark outlines, pie arcs, pie spokes
    reference: 1.0,   // grid lines, domain lines, tick marks
  },
  lineCap:    "round",
  lineJoin:   "round",
  medianDash: [2, 4],

  baselineShift: 4,  // vertical nudge to visually centre text against a mark or row centre.
  capHeight: 10.09, // measured ascent of FilmstintinTalking at fsTick (14px) — use for x-axis label placement

  tick: {
    length: 6,
    gap:    5,
  },

  wiggle: {
    fill:      0.2,
    outline:   0.2,
    line:      0.3,
    reference: 0.3,
    tick:      0.1,
  },

  key: {
    swatchSize: 12,
    swatchGap:   6,
    itemGap:    28,
  },
}

// ── 2b. FONT METRICS ───────────────────────────────────────────────
// Measured via getBBox("Agpqy") — bold = normal for this font, so one
// set of values covers all weights. Do not re-measure at runtime.
const _TINTIN_METRICS = {
  12: { ascent:  8.6563, descent: 2.0469 },
  14: { ascent: 10.0938, descent: 2.3906 },
  15: { ascent: 10.8125, descent: 2.5469 },
}
// Attribution descent kept as a named constant for backwards compat.
const TINTIN_ATTR_DESCENT = _TINTIN_METRICS[12].descent

// ── 3. LAYOUT SYSTEM ───────────────────────────────────────────────
// All spacing derived from N = TINTIN.fsLabel.
//
// Vertical stack (top → bottom):
//   3N  outer top margin
//   Title (fsTitle, bold, centered)
//   — if key present:
//     2N  gap
//     Key (optional, variable height from tintinKeyLayout)
//     2N  breathing room
//   — if no key:
//     3N  breathing room
//   Top chart label (optional) + 1N gap
//   Plot area
//   1N gap + Bottom chart label (optional)
//   tickMarkTotal + xTickLabelHeight
//   1N gap + X-axis label (optional)
//   3N outer bottom margin (4N if attribution present)
//
// Horizontal stack (left → right):
//   3N  outer left margin
//   Y-axis label (optional, ~1N wide) + 1N gap
//   tickLabelWidth + tickMarkTotal
//   Plot area
//   1N gap + Right label (optional)
//   3N  outer right margin
//
// New-style API: pass content strings and the function handles everything.
// Title, key, axis labels, and attribution are rendered into L.chromeSvg
// alongside ticks, domain lines, and grid. The agent includes L.chromeSvg
// in the SVG and only draws data marks.
//
// title: string — rendered at fsTitle/bold/middle into chromeSvg
// keyItems: [{ label, color }] — auto-measured, wrapped, rendered into chromeSvg
// yLabel: string — rendered rotated -90° at yLabelX into chromeSvg
// xLabel: string — rendered at plotCX, xLabelY into chromeSvg
// attribution: string — rendered at 1.5N from left/bottom into chromeSvg
//
// yTicks: string[] — evenly spaced bottom-to-top (linear scale)
//   or { labels: string[], band: true } — centred in equal bands
// xTicks: string[] — evenly spaced left-to-right (linear scale)
//   or { labels: string[], band: true } — centred in equal bands
// yDomain: boolean — draw y-axis domain line (seed 998)
// xDomain: boolean — draw x-axis domain line (seed 999)
// grid: "x" | "y" | "both" | false — grid lines at tick positions
//
// Old-style API (hasKey, hasYLabel, hasXLabel, hasAttribution, hasXTicks,
// tickLabelWidth, xTickLabelHeight) still accepted for chart helpers that
// render their own structural elements and text.

function tintinLayout({
  // New-style: pass content strings, get auto-rendered chromeSvg
  title            = null,
  keyItems         = null,
  yLabel           = null,
  xLabel           = null,
  attribution      = null,
  // Old-style booleans (still accepted for chart helpers)
  hasKey           = false,
  keyHeight        = 0,
  keyNRows         = 1,
  keyRowGap        = 0,
  hasTopLabel      = false,
  hasBottomLabel   = false,
  hasXLabel        = false,
  hasYLabel        = false,
  hasRightLabel    = false,
  hasAttribution   = false,
  plotW            = 600,
  plotH            = 400,
  // Old-style tick params (still accepted for chart helpers)
  hasXTicks        = false,
  tickLabelWidth   = 0,
  tickMarkTotal    = TINTIN.tick.length + TINTIN.tick.gap,
  xTickLabelHeight = 0,
  // New-style: pass tick labels, get auto-measured layout + pre-rendered chrome
  yTicks           = null,
  xTicks           = null,
  yDomain          = false,
  xDomain          = false,
  grid             = false,
} = {}) {
  // Infer booleans from new-style content strings
  if (keyItems)    hasKey = true
  if (yLabel)      hasYLabel = true
  if (xLabel)      hasXLabel = true
  if (attribution) hasAttribution = true

  // Auto-measure key when keyItems provided
  let _keyLayout = null
  if (keyItems) {
    _keyLayout = tintinKeyLayout(keyItems, plotW)
    keyHeight = _keyLayout.keyHeight
    keyNRows  = _keyLayout.keyNRows
    keyRowGap = _keyLayout.rowGap
  }

  // Normalise new-style tick specs
  const yTickSpec = yTicks
    ? (Array.isArray(yTicks) ? { labels: yTicks, band: false } : yTicks)
    : null
  const xTickSpec = xTicks
    ? (Array.isArray(xTicks) ? { labels: xTicks, band: false } : xTicks)
    : null

  // Auto-measure when new-style ticks provided
  if (yTickSpec) {
    tickLabelWidth = Math.max(..._tintinMeasureText(yTickSpec.labels, TINTIN.fsTick))
    tickMarkTotal = TINTIN.tick.length + TINTIN.tick.gap
  }
  if (xTickSpec) {
    hasXTicks = true
    xTickLabelHeight = _TINTIN_METRICS[14].ascent + _TINTIN_METRICS[14].descent
  }

  const N = TINTIN.fsLabel
  const hasAnyXTicks = hasXTicks || hasXLabel

  // Debug zone tracking — records every spacing/content band in the stack.
  // Each zone: { x, y, w, h, label, type } where type is "spacing"|"content"|"plot"
  const _debugZones = []

  // ── Vertical ────────────────────────────────────────────────────
  let top = 0
  const _vZone = (h, label, type = "spacing") => {
    if (h > 0) _debugZones.push({ x: 0, y: top, w: "totalW", h, label, type, axis: "v" })
    top += h
  }

  _vZone(3 * N, "3N top margin")
  const titleY = top + _TINTIN_METRICS[15].ascent
  _vZone(N, "Title", "content")

  let keyRowYs = null
  if (hasKey) {
    _vZone(2 * N, "2N key gap")
    const rowH = N
    const effectiveKeyHeight = keyHeight || N  // default to 1 row if not specified
    keyRowYs = Array.from({ length: keyNRows }, (_, r) =>
      top + r * (rowH + keyRowGap) + rowH / 2
    )
    _vZone(effectiveKeyHeight, "Key", "content")
    _vZone(2 * N, "2N breathing room")
  } else {
    _vZone(3 * N, "3N breathing room")
  }
  let topLabelY = null
  if (hasTopLabel) {
    topLabelY = top + _TINTIN_METRICS[15].ascent
    _vZone(N, "Top label", "content")
    _vZone(N, "1N gap")
  }
  const plotTop = top
  _debugZones.push({ x: "plotLeft", y: plotTop, w: "plotW", h: plotH, label: "Plot area", type: "plot", axis: "v" })
  top += plotH
  let bottomLabelY = null
  if (hasBottomLabel) {
    _vZone(N, "1N gap")
    bottomLabelY = top + _TINTIN_METRICS[15].ascent
    _vZone(N, "Bottom label", "content")
  }
  if (hasAnyXTicks) _vZone(tickMarkTotal + xTickLabelHeight, "Ticks area", "content")
  let xLabelY = null
  if (hasXLabel) {
    _vZone(N, "1N gap")
    xLabelY = top + _TINTIN_METRICS[15].ascent
    _vZone(N, "X-axis label", "content")
  }
  _vZone((hasAttribution ? 4 : 3) * N, `${hasAttribution ? '4' : '3'}N bottom margin`)
  const totalH = top
  const attrY = hasAttribution
    ? totalH - 1.5 * N - TINTIN_ATTR_DESCENT
    : null

  // ── Horizontal ──────────────────────────────────────────────────
  let left = 0
  const _hZone = (w, label, type = "spacing") => {
    if (w > 0) _debugZones.push({ x: left, y: 0, w, h: "totalH", label, type, axis: "h" })
    left += w
  }

  _hZone(3 * N, "3N left margin")
  let yLabelX = null
  if (hasYLabel) {
    yLabelX = left + _TINTIN_METRICS[15].ascent
    _hZone(N, "Y label", "content")
    _hZone(N, "1N gap")
  }
  if (tickLabelWidth > 0) _hZone(tickLabelWidth + tickMarkTotal, "Y tick labels + marks", "content")
  const plotLeft = left
  left += plotW
  let rightLabelX = null
  if (hasRightLabel) {
    _hZone(N, "1N gap")
    rightLabelX = left + N / 2
    _hZone(N, "Right label", "content")
  }
  _hZone(3 * N, "3N right margin")
  const totalW = left

  // Resolve deferred "totalW"/"totalH"/"plotLeft"/"plotW" references in debug zones
  for (const z of _debugZones) {
    if (z.w === "totalW") z.w = totalW
    if (z.h === "totalH") z.h = totalH
    if (z.x === "plotLeft") z.x = plotLeft
    if (z.w === "plotW") z.w = plotW
  }

  // Tick gap zones — the space between tick mark end and tick label
  const _tL = TINTIN.tick.length, _tG = TINTIN.tick.gap
  if (yTickSpec || tickLabelWidth > 0) {
    // Vertical band to the left of the plot: width = tickGap, spanning full plot height
    _debugZones.push({ x: plotLeft - _tL - _tG, y: plotTop, w: _tG, h: plotH, label: "tick gap", type: "spacing", axis: "h" })
  }
  if (xTickSpec || hasXTicks) {
    // Horizontal band below the domain line: height = tickGap, spanning full plot width
    _debugZones.push({ x: plotLeft, y: plotTop + plotH + _tL, w: plotW, h: _tG, label: "tick gap", type: "spacing", axis: "v" })
  }

  // ── Chrome SVG ─────────────────────────────────────────────────
  let chromeSvg = ""
  const plotCX = plotLeft + plotW / 2

  // Title
  if (title) {
    chromeSvg += _tintinText(plotCX, titleY, title,
      { size: TINTIN.fsTitle, weight: TINTIN.fwTitle })
  }

  // Key
  if (_keyLayout) {
    chromeSvg += tintinKeyWiggleRows(
      _keyLayout.keyRows, keyRowYs, plotCX, _keyLayout.itemWidths)
  }

  // Structural chrome (ticks, domain lines, grid)
  if (yTickSpec || xTickSpec || yDomain || xDomain || grid) {
    const { length: tickLen, gap: tickGap } = TINTIN.tick
    const xAxisY = plotTop + plotH

    // Helper: fraction → pixel position along an axis
    const yPos = (i, n, band) => {
      const frac = band ? (i + 0.5) / n : (n > 1 ? i / (n - 1) : 0.5)
      return plotTop + plotH * (1 - frac)  // bottom-to-top
    }
    const xPos = (i, n, band) => {
      const frac = band ? (i + 0.5) / n : (n > 1 ? i / (n - 1) : 0.5)
      return plotLeft + plotW * frac
    }

    // Grid lines (drawn first — behind everything else)
    if ((grid === "y" || grid === "both") && yTickSpec) {
      const n = yTickSpec.labels.length
      yTickSpec.labels.forEach((_, i) => {
        const y = yPos(i, n, yTickSpec.band)
        if (xDomain && Math.abs(y - xAxisY) < 0.5) return  // skip if under x-domain line
        chromeSvg += _chromeLine(tintinWiggle(tintinRng(4000 + i),
          plotLeft, y, plotLeft + plotW, y, TINTIN.wiggle.reference))
      })
    }
    if ((grid === "x" || grid === "both") && xTickSpec) {
      const n = xTickSpec.labels.length
      xTickSpec.labels.forEach((_, i) => {
        const x = xPos(i, n, xTickSpec.band)
        if (yDomain && Math.abs(x - plotLeft) < 0.5) return  // skip if over y-domain line
        chromeSvg += _chromeLine(tintinWiggle(tintinRng(5000 + i),
          x, plotTop, x, xAxisY, TINTIN.wiggle.reference))
      })
    }

    // Domain lines
    if (yDomain) {
      chromeSvg += _chromeLine(tintinWiggle(tintinRng(998),
        plotLeft, plotTop, plotLeft, plotTop + plotH, TINTIN.wiggle.reference))
    }
    if (xDomain) {
      chromeSvg += _chromeLine(tintinWiggle(tintinRng(999),
        plotLeft, xAxisY, plotLeft + plotW, xAxisY, TINTIN.wiggle.reference))
    }

    // Y-axis tick marks + labels
    if (yTickSpec) {
      const n = yTickSpec.labels.length
      yTickSpec.labels.forEach((label, i) => {
        const y = yPos(i, n, yTickSpec.band)
        chromeSvg += _chromeLine(tintinWiggle(tintinRng(3000 + i),
          plotLeft - tickLen, y, plotLeft, y, TINTIN.wiggle.tick))
        chromeSvg += _tintinText(
          plotLeft - tickLen - tickGap, y + TINTIN.baselineShift, label,
          { size: TINTIN.fsTick, anchor: "end" })
      })
    }

    // X-axis tick marks + labels
    if (xTickSpec) {
      const n = xTickSpec.labels.length
      xTickSpec.labels.forEach((label, i) => {
        const x = xPos(i, n, xTickSpec.band)
        chromeSvg += _chromeLine(tintinWiggle(tintinRng(2000 + i),
          x, xAxisY, x, xAxisY + tickLen, TINTIN.wiggle.tick))
        chromeSvg += _tintinText(
          x, xAxisY + tickLen + tickGap + TINTIN.capHeight, label,
          { size: TINTIN.fsTick })
      })
    }
  }

  // Axis labels
  if (yLabel) {
    chromeSvg += _tintinText(yLabelX, plotTop + plotH / 2, yLabel, { rotate: -90 })
  }
  if (xLabel) {
    chromeSvg += _tintinText(plotCX, xLabelY, xLabel)
  }

  // Attribution
  if (attribution) {
    chromeSvg += _tintinText(1.5 * N, attrY, attribution,
      { size: TINTIN.fsAnnotation, anchor: "start" })
  }

  return {
    N, totalW, totalH,
    plotLeft, plotTop, plotW, plotH,
    titleY,
    keyRowYs,
    topLabelY,
    bottomLabelY,
    xLabelY,
    yLabelX,
    yLabelCY: plotTop + plotH / 2,
    rightLabelX,
    plotCX,
    plotCY: plotTop  + plotH / 2,
    attrY,
    attrX:  1.5 * N,
    chromeSvg: chromeSvg || null,
    _debugZones,
  }
}

// ── 4. PALETTES ─────────────────────────────────────────────────────
// 16 curated palettes. Each has bg + two data colours (light, dark, stroke).
// For >2 colours see tintinPaletteColors() below.
const TINTIN_PALETTES = {

  "Flight 714 (Pacific)": {
    bg: "#EAF2F5",
    colors: [
      { light: "#C0E0F0", dark: "#4898C0", stroke: "#185880" },
      { light: "#F0D8B8", dark: "#C09858", stroke: "#705828" },
    ],
  },

  "King Ottokar's Sceptre": {
    bg: "#F2EEE8",
    colors: [
      { light: "#E8D0B8", dark: "#B87840", stroke: "#6A3A10" },
      { light: "#C8C8E0", dark: "#7070B8", stroke: "#303080" },
    ],
  },

  "Prisoners of the Sun": {
    bg: "#F0EAE0",
    colors: [
      { light: "#F0D0A0", dark: "#C87840", stroke: "#784010" },
      { light: "#B8D0C0", dark: "#508870", stroke: "#205040" },
    ],
  },

  "The Blue Lotus": {
    bg: "#EEF2F0",
    colors: [
      { light: "#A8D0D0", dark: "#207878", stroke: "#004040" },
      { light: "#F0C8D0", dark: "#C05878", stroke: "#782040" },
    ],
  },

  "The Broken Ear": {
    bg: "#F0EDE8",
    colors: [
      { light: "#E8C8A8", dark: "#B87030", stroke: "#703010" },
      { light: "#C8D8C0", dark: "#608858", stroke: "#285028" },
    ],
  },

  "The Calculus Affair (Geneva)": {
    bg: "#EAF0EC",
    colors: [
      { light: "#A8C8C8", dark: "#307878", stroke: "#084848" },
      { light: "#F0E098", dark: "#C8B020", stroke: "#786800" },
    ],
  },

  "The Castafiore Emerald": {
    bg: "#EEF0E8",
    colors: [
      { light: "#D8C8E8", dark: "#9878C0", stroke: "#5A3A7A" },
      { light: "#B8C8B0", dark: "#7A9A70", stroke: "#3A6030" },
    ],
  },

  "The Lotus": {
    bg: "#F0EAF2",
    colors: [
      { light: "#E0C8E0", dark: "#A060A8", stroke: "#602870" },
      { light: "#C8D8C8", dark: "#608860", stroke: "#285028" },
    ],
  },

  "The Red Sea Sharks": {
    bg: "#EAF0F2",
    colors: [
      { light: "#B8D0E8", dark: "#5888B0", stroke: "#1A4870" },
      { light: "#F0C8B0", dark: "#C05848", stroke: "#782018" },
    ],
  },

  "The Secret of the Unicorn": {
    bg: "#F0EDE8",
    colors: [
      { light: "#F0DCA8", dark: "#C09848", stroke: "#705018" },
      { light: "#C0D0C8", dark: "#608888", stroke: "#204848" },
    ],
  },

  "The Seven Crystal Balls": {
    bg: "#EAE8F0",
    colors: [
      { light: "#C8C0E0", dark: "#8860B0", stroke: "#482878" },
      { light: "#F0D8B0", dark: "#C09040", stroke: "#705010" },
    ],
  },

  "Tintin and Alph-Art": {
    bg: "#F0EAF0",
    colors: [
      { light: "#E8B8D8", dark: "#B83880", stroke: "#701848" },
      { light: "#C0D0C0", dark: "#688868", stroke: "#305030" },
    ],
  },

  "Tintin and the Lake of Sharks": {
    bg: "#E8F0F2",
    colors: [
      { light: "#A8D0D8", dark: "#208898", stroke: "#004858" },
      { light: "#F0C8B0", dark: "#D07048", stroke: "#883020" },
    ],
  },

  "Tintin and the Picaros": {
    bg: "#EEEAE4",
    colors: [
      { light: "#C8D0B0", dark: "#708040", stroke: "#304010" },
      { light: "#F0C0B0", dark: "#C05848", stroke: "#782018" },
    ],
  },

  "Tintin in America (Chicago)": {
    bg: "#EEE8E4",
    colors: [
      { light: "#E8C0A0", dark: "#B86030", stroke: "#703010" },
      { light: "#B0C8C8", dark: "#487878", stroke: "#184848" },
    ],
  },

  "Tintin in the Congo": {
    bg: "#F0EBE0",
    colors: [
      { light: "#F0DEB0", dark: "#C09030", stroke: "#705010" },
      { light: "#B8D0B8", dark: "#508050", stroke: "#205020" },
    ],
  },

}

// ── 4b. EXTENDED PALETTE COLOURS ────────────────────────────────────
// Generate n colours from a palette. For n ≤ 2 use palette.colors directly.
// Algorithm: fixes the two anchor hues on the 360° wheel, places n−2
// additional hues to maximise the minimum angular gap (exact O(n) solution).
// Generated entries use mean S/L of the anchors' dark components;
// stroke = dark L − 22, light = dark L + 32 at S × 0.55.

function _tintinHexToHsl(hex) {
  let r = parseInt(hex.slice(1, 3), 16) / 255
  let g = parseInt(hex.slice(3, 5), 16) / 255
  let b = parseInt(hex.slice(5, 7), 16) / 255
  const max = Math.max(r, g, b), min = Math.min(r, g, b)
  let h, s, l = (max + min) / 2
  if (max === min) {
    h = s = 0
  } else {
    const d = max - min
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
    switch (max) {
      case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break
      case g: h = ((b - r) / d + 2) / 6; break
      case b: h = ((r - g) / d + 4) / 6; break
    }
  }
  return [h * 360, s * 100, l * 100]
}

function _tintinHslToHex(h, s, l) {
  h = ((h % 360) + 360) % 360
  s = Math.max(0, Math.min(100, s))
  l = Math.max(5, Math.min(95, l))
  const hN = h / 360, sN = s / 100, lN = l / 100
  const q = lN < 0.5 ? lN * (1 + sN) : lN + sN - lN * sN
  const p = 2 * lN - q
  const hue2rgb = (t) => {
    if (t < 0) t += 1
    if (t > 1) t -= 1
    if (t < 1/6) return p + (q - p) * 6 * t
    if (t < 1/2) return q
    if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
    return p
  }
  const toHex = (x) => Math.round(x * 255).toString(16).padStart(2, "0")
  return "#" + toHex(hue2rgb(hN + 1/3)) + toHex(hue2rgb(hN)) + toHex(hue2rgb(hN - 1/3))
}

// Optimal hue placement: h0 and h1 fixed, n−2 hues maximise min angular gap.
function _tintinOptimalHues(h0, h1, n) {
  if (n <= 1) return [h0].slice(0, n)
  if (n <= 2) return [h0, h1].slice(0, n)

  const arcA = ((h1 - h0) + 360) % 360
  const arcB = 360 - arcA
  const free = n - 2

  let bestK = 0, bestMin = -1
  for (let k = 0; k <= free; k++) {
    const minSpacing = Math.min(arcA / (k + 1), arcB / (free - k + 1))
    if (minSpacing > bestMin) { bestMin = minSpacing; bestK = k }
  }

  const huesA = Array.from({ length: bestK },        (_, i) => (h0 + arcA * (i + 1) / (bestK + 1) + 360) % 360)
  const huesB = Array.from({ length: free - bestK }, (_, i) => (h1 + arcB * (i + 1) / (free - bestK + 1) + 360) % 360)

  const generated = []
  const maxLen = Math.max(huesA.length, huesB.length)
  for (let i = 0; i < maxLen; i++) {
    if (i < huesA.length) generated.push(huesA[i])
    if (i < huesB.length) generated.push(huesB[i])
  }
  return [h0, h1, ...generated]
}

function tintinPaletteColors(palette, n) {
  const STROKE_L_DELTA = -22
  const LIGHT_L_DELTA  = +32
  const LIGHT_S_RATIO  = 0.55

  const base = palette.colors
  if (n <= base.length) return base.slice(0, n)

  const [h0, s0, l0] = _tintinHexToHsl(base[0].dark)
  const [h1, s1, l1] = _tintinHexToHsl(base[1].dark)
  const meanS = (s0 + s1) / 2
  const meanL = (l0 + l1) / 2

  const hues   = _tintinOptimalHues(h0, h1, n)
  const result = [...base]

  for (let i = 2; i < n; i++) {
    const h = hues[i]
    result.push({
      light:  _tintinHslToHex(h, meanS * LIGHT_S_RATIO, meanL + LIGHT_L_DELTA),
      dark:   _tintinHslToHex(h, meanS, meanL),
      stroke: _tintinHslToHex(h, meanS, meanL + STROKE_L_DELTA),
    })
  }
  return result
}

// ── 5. WIGGLE HELPERS ───────────────────────────────────────────────

// Seeded PRNG (mulberry32).
function tintinRng(seed) {
  let s = seed >>> 0
  return () => {
    s += 0x6D2B79F5
    let t = s
    t = Math.imul(t ^ t >>> 15, t | 1)
    t ^= t + Math.imul(t ^ t >>> 7, t | 61)
    return ((t ^ t >>> 14) >>> 0) / 4294967296
  }
}

// Wobbly straight line from (x1,y1) to (x2,y2). Returns array of [x,y].
// Usage: "M" + pts.map(p => p.join(",")).join("L")
function tintinWiggle(rng, x1, y1, x2, y2, amp = TINTIN.wiggle.line, steps = 8) {
  const dx = x2 - x1, dy = y2 - y1
  const len = Math.sqrt(dx * dx + dy * dy) || 1
  const px = -dy / len, py = dx / len
  const pts = [[x1, y1]]
  for (let i = 1; i < steps; i++) {
    const t = i / steps
    pts.push([
      x1 + dx * t + px * (rng() - 0.5) * 2 * amp,
      y1 + dy * t + py * (rng() - 0.5) * 2 * amp,
    ])
  }
  pts.push([x2, y2])
  return pts
}

// Nudge every coordinate pair in a D3-generated SVG path string by ±amp px.
function tintinWigglePath(rng, pathString, amp = TINTIN.wiggle.fill) {
  return pathString.replace(/(-?\d+\.?\d*),(-?\d+\.?\d*)/g, (_, x, y) => {
    const nx = parseFloat(x) + (rng() - 0.5) * 2 * amp
    const ny = parseFloat(y) + (rng() - 0.5) * 2 * amp
    return `${nx.toFixed(2)},${ny.toFixed(2)}`
  })
}

// ── 5b. SVG ELEMENT HELPERS ─────────────────────────────────────────

// Resolve a palette: accepts a TINTIN_PALETTES object or a palette name string.
function _tintinPalette(p) {
  return typeof p === "string" ? TINTIN_PALETTES[p] : p
}

// Measure text width via getBBox. Accepts a single string or an array;
// returns a single width or array of widths accordingly.
function _tintinMeasureText(textOrTexts, fontSize = TINTIN.fsTick) {
  const isArray = Array.isArray(textOrTexts)
  const texts = isArray ? textOrTexts : [textOrTexts]
  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
  document.body.appendChild(svg)
  const widths = texts.map(text => {
    const t = document.createElementNS("http://www.w3.org/2000/svg", "text")
    t.setAttribute("font-family", TINTIN.font)
    t.setAttribute("font-size", fontSize)
    t.textContent = text
    svg.appendChild(t)
    const w = Math.ceil(t.getBBox().width)
    svg.removeChild(t)
    return w
  })
  svg.remove()
  return isArray ? widths : widths[0]
}

// Fill path for a data mark (color.dark at TINTIN.opacity.fill).
function _fillPath(d, color) {
  return `<path d="${d}" fill="${color.dark}" fill-opacity="${TINTIN.opacity.fill}" stroke="none"/>`
}

// Outline path for a data mark. Accepts a color object (uses .stroke) or a hex string.
function _outlinePath(d, colorOrStroke) {
  const stroke = typeof colorOrStroke === "string" ? colorOrStroke : colorOrStroke.stroke
  return `<path d="${d}" fill="none" stroke="${stroke}"` +
    ` stroke-opacity="${TINTIN.opacity.stroke}" stroke-width="${TINTIN.strokeWidth.mark}"` +
    ` stroke-linejoin="${TINTIN.lineJoin}" stroke-linecap="${TINTIN.lineCap}"/>`
}

function _chromeLine(pts, color = TINTIN.ink) {
  return `<path d="M${pts.map(p => p.join(",")).join("L")}" fill="none"` +
    ` stroke="${color}" stroke-opacity="${TINTIN.opacity.reference}"` +
    ` stroke-width="${TINTIN.strokeWidth.reference}" stroke-linecap="${TINTIN.lineCap}"/>`
}

// Text element with standard font/fill attributes from TINTIN tokens.
function _tintinText(x, y, text, { size = TINTIN.fsLabel, weight = TINTIN.fwBody, anchor = "middle", rotate } = {}) {
  const transform = rotate != null ? ` transform="rotate(${rotate},${x},${y})"` : ""
  return `<text x="${x}" y="${y}" font-family="${TINTIN.font}" font-size="${size}"` +
    ` font-weight="${weight}" fill="${TINTIN.ink}" text-anchor="${anchor}"${transform}>${text}</text>`
}

// Debug: overlay a red box behind every <text> element in a rendered SVG container.
// Call AFTER setting container.innerHTML. Measures each text element via getBBox(),
// respects transforms (rotation), and inserts a <rect> immediately before each <text>.
function tintinDebugText(container) {
  const svg = container.querySelector("svg")
  if (!svg) return
  for (const t of [...svg.querySelectorAll("text")]) {
    const { x, y, width, height } = t.getBBox()
    const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect")
    rect.setAttribute("x", x)
    rect.setAttribute("y", y)
    rect.setAttribute("width", width)
    rect.setAttribute("height", height)
    rect.setAttribute("fill", "red")
    rect.setAttribute("fill-opacity", "0.25")
    rect.setAttribute("stroke", "red")
    rect.setAttribute("stroke-width", "0.5")
    // Copy the text element's transform so the rect aligns with rotated text
    const tf = t.getAttribute("transform")
    if (tf) rect.setAttribute("transform", tf)
    t.parentNode.insertBefore(rect, t)
  }
}

// Debug: overlay coloured rectangles showing layout zones.
// Yellow = plot area (drawn at back). Blue = spacing zones. Content zones are unlabelled.
// Call AFTER setting container.innerHTML. Pass the L object from tintinLayout().
function tintinDebugLayout(container, L) {
  const svg = container.querySelector("svg")
  if (!svg) return
  const first = svg.firstChild
  for (const z of L._debugZones) {
    if (z.h === 0 || z.w === 0) continue
    const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect")
    rect.setAttribute("x", z.x)
    rect.setAttribute("y", z.y)
    rect.setAttribute("width", z.w)
    rect.setAttribute("height", z.h)
    if (z.type === "plot") {
      // Yellow — plot area boundary
      rect.setAttribute("fill", "#FFD700")
      rect.setAttribute("fill-opacity", "0.20")
      rect.setAttribute("stroke", "#FFD700")
      rect.setAttribute("stroke-width", "1")
      rect.setAttribute("stroke-opacity", "0.5")
      svg.insertBefore(rect, first.nextSibling)  // right after bg rect
    } else if (z.type === "content") {
      continue  // content zones are invisible — just gaps between spacing zones
    } else {
      // Blue — spacing zones (margins, gaps)
      rect.setAttribute("fill", "#4488FF")
      rect.setAttribute("fill-opacity", "0.15")
      rect.setAttribute("stroke", "#4488FF")
      rect.setAttribute("stroke-width", "0.5")
      rect.setAttribute("stroke-opacity", "0.4")
      svg.appendChild(rect)
    }
    // Tiny label inside the zone (spacing and plot only — content zones are unlabelled)
    if (z.type === "content") continue
    const label = document.createElementNS("http://www.w3.org/2000/svg", "text")
    const lx = z.axis === "h" ? z.x + z.w / 2 : z.x + 4
    const ly = z.axis === "h" ? z.y + 10 : z.y + z.h / 2 + 3
    label.setAttribute("x", lx)
    label.setAttribute("y", ly)
    label.setAttribute("font-size", "8")
    label.setAttribute("font-family", "system-ui, sans-serif")
    label.setAttribute("fill", z.type === "plot" ? "#997700" : "#2255CC")
    label.setAttribute("fill-opacity", "0.8")
    if (z.axis === "h") label.setAttribute("text-anchor", "middle")
    label.textContent = z.label
    svg.appendChild(label)
  }
}

// ── 6. CANONICAL MARK AMPLITUDES ────────────────────────────────────
const WIGGLE_BAR = 0.1   // bars, in px
const WIGGLE_KEY = 0.1   // key swatches, in px
const WIGGLE_DOT = 0.01  // dots, as fraction of radius
const WIGGLE_PIE = 0.01  // pie orbit, as fraction of radius

// ── 7. BAR HELPER ───────────────────────────────────────────────────

// Closed wiggly rect path string.
function tintinRectPath(rng, x, y, w, h, amp) {
  const top    = tintinWiggle(rng, x,     y,     x + w, y,     amp)
  const right  = tintinWiggle(rng, x + w, y,     x + w, y + h, amp)
  const bottom = tintinWiggle(rng, x + w, y + h, x,     y + h, amp)
  const left   = tintinWiggle(rng, x,     y + h, x,     y,     amp)
  const all = [...top, ...right.slice(1), ...bottom.slice(1), ...left.slice(1)]
  return "M" + all.map(p => p.map(v => v.toFixed(2)).join(",")).join("L") + "Z"
}

// Single bar: fill + outline as separate paths.
function tintinBar(x, y, w, h, color, seed, amp = WIGGLE_BAR) {
  const fp = tintinRectPath(tintinRng(seed),     x, y, w, h, amp)
  const op = tintinRectPath(tintinRng(seed + 1), x, y, w, h, amp)
  return _fillPath(fp, color) + _outlinePath(op, color)
}

// ── 8. KEY HELPERS ──────────────────────────────────────────────────

// Single key swatch rect: fill + outline.
function tintinSwatchRect(x, y, color, seed, amp = WIGGLE_KEY) {
  const sz = TINTIN.key.swatchSize
  const fp = tintinRectPath(tintinRng(seed),     x, y, sz, sz, amp)
  const op = tintinRectPath(tintinRng(seed + 1), x, y, sz, sz, amp)
  return _fillPath(fp, color) + _outlinePath(op, color)
}

// Measure and pack key items into rows. Call BEFORE tintinLayout().
// items: [{ label, color: { dark, stroke } }]. Returns { keyRows, keyHeight, keyNRows, itemWidths, rowGap }.
function tintinKeyLayout(items, plotW) {
  const { swatchSize, swatchGap, itemGap } = TINTIN.key
  const N = TINTIN.fsLabel
  const rowGap = Math.round(0.6 * N)

  const labelWidths = _tintinMeasureText(items.map(i => i.label), TINTIN.fsLabel)
  const itemWidths = labelWidths.map(w => swatchSize + swatchGap + w)

  const keyRows = []
  let currentRow = []
  let currentW = 0
  items.forEach((item, i) => {
    const w = itemWidths[i]
    const needed = currentRow.length === 0 ? w : currentW + itemGap + w
    if (needed <= plotW) {
      currentRow.push(item)
      currentW = needed
    } else {
      keyRows.push(currentRow)
      currentRow = [item]
      currentW = w
    }
  })
  if (currentRow.length > 0) keyRows.push(currentRow)

  const nRows = keyRows.length
  const keyHeight = nRows * N + (nRows - 1) * rowGap

  return { keyRows, keyHeight, keyNRows: nRows, itemWidths, rowGap }
}

// Draw a wrapped categorical key. Uses measured widths from tintinKeyLayout.
function tintinKeyWiggleRows(keyRows, keyRowYs, plotCX, itemWidths) {
  const { swatchSize, swatchGap, itemGap } = TINTIN.key
  let flatIdx = 0
  const parts = keyRows.map((row, r) => {
    const ky = keyRowYs[r]
    const rowW = row.reduce((sum, _, i) =>
      sum + itemWidths[flatIdx + i] + (i > 0 ? itemGap : 0), 0)
    let x = plotCX - rowW / 2
    const rowParts = row.map(({ label, color }, i) => {
      const ix = x
      x += itemWidths[flatIdx + i] + itemGap
      const sy = ky - swatchSize / 2
      return tintinSwatchRect(ix, sy, color, (flatIdx + i) * 100 + 7) +
        _tintinText(ix + swatchSize + swatchGap, ky + TINTIN.baselineShift, label,
          { anchor: "start" })
    })
    flatIdx += row.length
    return rowParts.join("")
  })
  return `<g>${parts.join("")}</g>`
}

// ── 9. DOT HELPER ───────────────────────────────────────────────────

// Smooth organic circle via low-frequency harmonic radius modulation.
function tintinOrganicCirclePath(rng, cx, cy, r, amp, steps = 64) {
  const harmonics = [
    { freq: 2, mag: rng() * amp },
    { freq: 3, mag: rng() * amp * 0.6 },
    { freq: 4, mag: rng() * amp * 0.3 },
  ]
  const phases = harmonics.map(() => rng() * Math.PI * 2)
  const pts = []
  for (let i = 0; i < steps; i++) {
    const angle = (i / steps) * 2 * Math.PI
    let dr = 0
    for (let h = 0; h < harmonics.length; h++) {
      dr += harmonics[h].mag * r * Math.cos(harmonics[h].freq * angle + phases[h])
    }
    pts.push([cx + Math.cos(angle) * (r + dr), cy + Math.sin(angle) * (r + dr)])
  }
  const n = pts.length
  let d = `M${pts[0][0].toFixed(2)},${pts[0][1].toFixed(2)}`
  for (let i = 0; i < n; i++) {
    const p0 = pts[(i - 1 + n) % n]
    const p1 = pts[i]
    const p2 = pts[(i + 1) % n]
    const p3 = pts[(i + 2) % n]
    const cp1x = p1[0] + (p2[0] - p0[0]) / 6
    const cp1y = p1[1] + (p2[1] - p0[1]) / 6
    const cp2x = p2[0] - (p3[0] - p1[0]) / 6
    const cp2y = p2[1] - (p3[1] - p1[1]) / 6
    d += ` C${cp1x.toFixed(2)},${cp1y.toFixed(2)} ${cp2x.toFixed(2)},${cp2y.toFixed(2)} ${p2[0].toFixed(2)},${p2[1].toFixed(2)}`
  }
  return d + " Z"
}

// Organic dot: fill + outline as separate paths.
function tintinOrganicDot(cx, cy, r, color, seed, amp = WIGGLE_DOT) {
  const fp = tintinOrganicCirclePath(tintinRng(seed),     cx, cy, r, amp)
  const op = tintinOrganicCirclePath(tintinRng(seed + 1), cx, cy, r, amp)
  return _fillPath(fp, color) + _outlinePath(op, color)
}

// ── 10. PIE HELPERS ─────────────────────────────────────────────────
// Shared organic orbit so all slice boundaries meet exactly.
// Angles follow D3 pie convention: 0 = top, clockwise, radians.
//
// Workflow:
//   const orbit = tintinPieOrbit(cx, cy, r, 999)
//   slices.forEach((a, i) => {
//     svgString += tintinPieSliceFill(orbit, cx, cy, a.startAngle, a.endAngle, colors[i])
//   })
//   slices.forEach((a, i) => {
//     svgString += tintinPieArc(orbit, a.startAngle, a.endAngle, colors[i].stroke)
//   })
//   slices.forEach((a, i) => {
//     svgString += tintinPieSpoke(orbit, cx, cy, a.startAngle, colors[i].stroke, i * 20)
//   })

function tintinPieOrbit(cx, cy, r, seed, amp = WIGGLE_PIE) {
  const rng = tintinRng(seed)
  const harmonics = [
    { freq: 2, mag: rng() * amp },
    { freq: 3, mag: rng() * amp * 0.6 },
    { freq: 4, mag: rng() * amp * 0.3 },
  ]
  const phases = harmonics.map(() => rng() * Math.PI * 2)
  function radius(a) {
    let dr = 0
    for (let h = 0; h < harmonics.length; h++)
      dr += harmonics[h].mag * r * Math.cos(harmonics[h].freq * a + phases[h])
    return r + dr
  }
  function point(a) {
    const rr = radius(a)
    return [cx + Math.sin(a) * rr, cy - Math.cos(a) * rr]
  }
  return { point, r: radius }
}

function tintinPieSliceFill(orbit, cx, cy, startAngle, endAngle, color, steps = 128) {
  const pts = [[cx, cy]]
  for (let s = 0; s <= steps; s++) {
    const a = startAngle + (endAngle - startAngle) * (s / steps)
    pts.push(orbit.point(a))
  }
  pts.push([cx, cy])
  const d = "M" + pts.map(p => `${p[0].toFixed(2)},${p[1].toFixed(2)}`).join("L") + "Z"
  return _fillPath(d, color)
}

function tintinPieArc(orbit, startAngle, endAngle, strokeColor, steps = 128) {
  const pts = []
  for (let s = 0; s <= steps; s++) {
    const a = startAngle + (endAngle - startAngle) * (s / steps)
    pts.push(orbit.point(a))
  }
  const d = "M" + pts.map(p => `${p[0].toFixed(2)},${p[1].toFixed(2)}`).join("L")
  return _outlinePath(d, strokeColor)
}

function tintinPieSpoke(orbit, cx, cy, angle, strokeColor, seed) {
  const [rx, ry] = orbit.point(angle)
  const pts = tintinWiggle(tintinRng(seed), cx, cy, rx, ry, TINTIN.wiggle.line)
  const d = "M" + pts.map(p => `${p[0].toFixed(2)},${p[1].toFixed(2)}`).join("L")
  return _outlinePath(d, strokeColor)
}

// ── 11. RIDGELINE HELPER ────────────────────────────────────────────
// Complete chart: tintinRidgeline(container, options)
//
// Geometry: maxRidgeH = overlapRatio × rowH. Top row peak lands exactly
// at plotTop, so the breathing room above plotTop is always preserved.
//
// overlapRatio: 1.0 = no overlap, 2.0 = classic (default), >2.5 = dramatic.
// Row ordering: rows[0] = back/top, rows[last] = front/bottom.
// Seeds: row i → i*1000 (fill), +1 (outline), +2 (baseline), +3 (y-tick).
//
// options: { title, rows: [{ label, values: number[], color }],
//   xLabels, xAxisLabel?, yMin, yMax, bg, plotW=560, rowH=60, overlapRatio=2.0 }

function tintinRidgeline(container, {
  title,
  rows,
  xLabels,
  xAxisLabel   = null,
  yMin,
  yMax,
  bg,
  plotW        = 560,
  rowH         = 60,
  overlapRatio = 2.0,
  debug        = false,
}) {
  const nRows     = rows.length
  const maxRidgeH = overlapRatio * rowH
  const topRowMax = d3.max(rows[0].values)
  const topPeakH  = ((topRowMax - yMin) / (yMax - yMin)) * maxRidgeH
  const plotH     = topPeakH + (nRows - 1) * rowH

  const tickLabelWidth = _tintinMeasureText(
    rows.reduce((a, r) => r.label.length > a.length ? r.label : a, ""))

  const L = tintinLayout({
    hasXTicks:        true,
    hasXLabel:        !!xAxisLabel,
    tickLabelWidth,
    xTickLabelHeight: _TINTIN_METRICS[14].ascent + _TINTIN_METRICS[14].descent,
    plotW,
    plotH,
  })

  const xAxisY      = L.plotTop + L.plotH
  const { length: tickLen, gap: tickGap } = TINTIN.tick
  const rowBaseline = (i) => L.plotTop + topPeakH + i * rowH
  const valToH      = (v) => ((v - yMin) / (yMax - yMin)) * maxRidgeH

  const xScale = d3.scalePoint()
    .domain(d3.range(xLabels.length))
    .range([L.plotLeft, L.plotLeft + L.plotW])
    .padding(0.5)

  let s = ""

  s += `<rect width="${L.totalW}" height="${L.totalH}" fill="${bg}"/>`

  s += _tintinText(L.plotCX, L.titleY, title,
    { size: TINTIN.fsTitle, weight: TINTIN.fwTitle })

  // X-axis domain line
  s += _chromeLine(tintinWiggle(tintinRng(1999),
    L.plotLeft, xAxisY, L.plotLeft + L.plotW, xAxisY, TINTIN.wiggle.reference))

  // X-axis ticks + labels
  xLabels.forEach((label, i) => {
    const x = xScale(i)
    s += _chromeLine(tintinWiggle(tintinRng(2000 + i),
      x, xAxisY, x, xAxisY + tickLen, TINTIN.wiggle.tick))
    s += _tintinText(x, xAxisY + tickLen + tickGap + TINTIN.capHeight, label,
      { size: TINTIN.fsTick })
  })

  if (xAxisLabel) {
    s += _tintinText(L.plotCX, L.xLabelY, xAxisLabel)
  }

  // Rows: back (i=0) to front (last)
  for (let i = 0; i < nRows; i++) {
    const row   = rows[i]
    const color = row.color
    const base  = rowBaseline(i)
    const seed  = i * 1000

    const pts = row.values.map((v, j) => [xScale(j), base - valToH(v)])

    const areaGen = d3.area()
      .x((_, j) => pts[j][0]).y0(base).y1((_, j) => pts[j][1])
      .curve(d3.curveCatmullRom.alpha(0.5))
    const lineGen = d3.line()
      .x((_, j) => pts[j][0]).y((_, j) => pts[j][1])
      .curve(d3.curveCatmullRom.alpha(0.5))

    const fillPath    = tintinWigglePath(tintinRng(seed),     areaGen(row.values), TINTIN.wiggle.fill)
    const outlinePath = tintinWigglePath(tintinRng(seed + 1), lineGen(row.values), TINTIN.wiggle.outline)

    s += _fillPath(fillPath, color)
    s += _outlinePath(outlinePath, color)

    // Skip baseline for last row — coincides with x-axis domain line
    if (i < nRows - 1) {
      s += _chromeLine(tintinWiggle(tintinRng(seed + 2),
        L.plotLeft, base, L.plotLeft + L.plotW, base, TINTIN.wiggle.reference))
    }

    s += _chromeLine(tintinWiggle(tintinRng(seed + 3),
      L.plotLeft - tickLen, base, L.plotLeft, base, TINTIN.wiggle.tick))

    s += _tintinText(L.plotLeft - tickLen - tickGap, base + TINTIN.baselineShift, row.label,
      { size: TINTIN.fsTick, anchor: "end" })
  }

  container.innerHTML =
    `<svg xmlns="http://www.w3.org/2000/svg" width="${L.totalW}" height="${L.totalH}">${s}</svg>`
  if (debug) { tintinDebugLayout(container, L); tintinDebugText(container) }
}

// ── 12. HEATMAP HELPER ──────────────────────────────────────────────
// Complete chart: tintinHeatmap(container, options)
//
// Design: no domain lines (cell grid is its own boundary). Cell inset
// 1px/side. Fill opacity 0.12→0.90 linear. Single hue (colors[0].dark).
// Seed scheme: ri*60 + ci*6. Gradient scale bar at key position.
//
// options: { title, rows: string[], cols: string[],
//   vals: number[][], palette, cellW=72, cellH=60 }

function tintinHeatmap(container, {
  title,
  rows,
  cols,
  vals,
  palette: paletteArg,
  cellW = 72,
  cellH = 60,
  debug = false,
}) {
  const palette = _tintinPalette(paletteArg)
  const color0 = palette.colors[0]
  const bg     = palette.bg
  const { length: tL, gap: tG } = TINTIN.tick
  const gap = 1.0

  const tickLabelWidth = _tintinMeasureText(
    rows.reduce((a, r) => r.length > a.length ? r : a, ""))

  const N = TINTIN.fsLabel
  const L = tintinLayout({
    hasKey:           true,
    keyHeight:        N,
    keyNRows:         1,
    keyRowGap:        0,
    hasXTicks:        true,
    xTickLabelHeight: _TINTIN_METRICS[14].ascent + _TINTIN_METRICS[14].descent,
    tickLabelWidth,
    plotW:            cols.length * cellW,
    plotH:            rows.length * cellH,
  })

  const allVals = vals.flat()
  const vMin = d3.min(allVals), vMax = d3.max(allVals)
  const xAxisY = L.plotTop + L.plotH

  // Gradient legend
  const { swatchSize, swatchGap } = TINTIN.key
  const legendW = 160
  const legendH = swatchSize
  const legendX = L.plotCX - legendW / 2
  const legendY = L.keyRowYs[0] - legendH / 2
  const gradId  = "tintinHeatmapGrad"

  const defs = `<defs>
    <linearGradient id="${gradId}" x1="0" y1="0" x2="1" y2="0">
      <stop offset="0%"   stop-color="${color0.dark}" stop-opacity="0.12"/>
      <stop offset="100%" stop-color="${color0.dark}" stop-opacity="0.90"/>
    </linearGradient>
  </defs>`

  let s = `<rect width="${L.totalW}" height="${L.totalH}" fill="${bg}"/>`
  s += defs

  s += _tintinText(L.plotCX, L.titleY, title,
    { size: TINTIN.fsTitle, weight: TINTIN.fwTitle })

  // Gradient bar
  const barPath = tintinRectPath(tintinRng(8888), legendX, legendY, legendW, legendH, WIGGLE_KEY)
  s += `<path d="${barPath}" fill="url(#${gradId})" stroke="none"/>`

  // Min / max labels
  s += _tintinText(legendX - swatchGap, L.keyRowYs[0] + TINTIN.baselineShift, vMin,
    { anchor: "end" })
  s += _tintinText(legendX + legendW + swatchGap, L.keyRowYs[0] + TINTIN.baselineShift, vMax,
    { anchor: "start" })

  // X-axis ticks + labels (no domain line)
  cols.forEach((col, ci) => {
    const x = L.plotLeft + (ci + 0.5) * cellW
    s += _chromeLine(tintinWiggle(tintinRng(300 + ci),
      x, xAxisY, x, xAxisY + tL, TINTIN.wiggle.tick))
    s += _tintinText(x, xAxisY + tL + tG + TINTIN.capHeight, col,
      { size: TINTIN.fsTick })
  })

  // Y-axis ticks + labels (no domain line)
  rows.forEach((row, ri) => {
    const y = L.plotTop + (ri + 0.5) * cellH
    s += _chromeLine(tintinWiggle(tintinRng(350 + ri),
      L.plotLeft - tL, y, L.plotLeft, y, TINTIN.wiggle.tick))
    s += _tintinText(L.plotLeft - tL - tG, y + TINTIN.baselineShift, row,
      { size: TINTIN.fsTick, anchor: "end" })
  })

  // Cells
  vals.forEach((rowVals, ri) => {
    rowVals.forEach((v, ci) => {
      const t      = (v - vMin) / (vMax - vMin)
      const x      = L.plotLeft + ci * cellW + gap
      const y      = L.plotTop  + ri * cellH + gap
      const w      = cellW - gap * 2
      const h      = cellH - gap * 2
      const seed   = ri * 60 + ci * 6
      const fillOp = 0.12 + t * 0.78

      const fp = tintinRectPath(tintinRng(seed), x, y, w, h, WIGGLE_BAR)
      s += `<path d="${fp}" fill="${color0.dark}" fill-opacity="${fillOp.toFixed(2)}" stroke="none"/>`

      s += _tintinText(L.plotLeft + (ci + 0.5) * cellW,
        L.plotTop + (ri + 0.5) * cellH + TINTIN.baselineShift, v,
        { size: TINTIN.fsCell })
    })
  })

  container.innerHTML =
    `<svg xmlns="http://www.w3.org/2000/svg" width="${L.totalW}" height="${L.totalH}">${s}</svg>`
  if (debug) { tintinDebugLayout(container, L); tintinDebugText(container) }
}
```
