← Detached legend vs labels pinned to marks

legend-01-direct.B

Life Expectancy at Birth vs. World BaselineBaseline = 73.48 years (life expectancy at birth)JapanFranceGermanyUnited StatesChinaBrazilMexicoIndiaEgypt, Arab Rep.IndonesiaSouth AfricaNigeria+10.56+9.50+7.31+5.41+4.54+2.54+1.78-1.25-1.67-2.19-7.17-18.85Above baselineBelow baseline
Arm
B · with a skill
Method
craft:annotationAdjacency
Ran on
legend-01-direct.A
Mode
refine — the treated arms ran on the default arm's output, so the only difference is the lever
Fixture
table12
Runs
1 · shipped: only
Generated
2026-07-27

Verbatim prompt

Here is a chart module. Apply the project's annotation-adjacency craft technique to the module below.

Use the project's craft helper instead of writing your own label placement. Its
import path is exact, including the `.ts` extension — reproduce it verbatim. The
renderer loads this module under bare Node ESM, where an extensionless relative
import (`"../../../src/craft/annotation"`) fails with ERR_MODULE_NOT_FOUND:

  import { placeAnnotations } from "../../../src/craft/annotation.ts";

  const placed = placeAnnotations(anchors, { width: 640, height: 400 });

`anchors` is a plain array of `{ x, y, text, markRadius }` — one per mark it
describes, in the order you want them placed; `markRadius` is optional. It takes
plain numbers and strings, not a d3 selection and not DOM nodes. Each result is
`{ text, x, y, anchor, leader }`, where `anchor` is "start" | "middle" | "end"
for the text-anchor attribute and `leader` is either null or
`{ x1, y1, x2, y2 }` for a short leader line you must also draw. Its `pad` and
`gap` options default to 4 and 10; leave them at their defaults.

The technique the helper encodes: place text on or beside the mark it describes,
use a short leader only when adjacency is impossible, and put a legend last.

export const meta = { fixture: "table12", width: 640, height: 400 };

export function render(svg, data) {
  const doc = svg.ownerDocument;
  const NS = "http://www.w3.org/2000/svg";

  const width = meta.width;
  const height = meta.height;
  const margin = { top: 48, right: 24, bottom: 56, left: 160 };
  const innerWidth = width - margin.left - margin.right;
  const innerHeight = height - margin.top - margin.bottom;

  svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
  svg.setAttribute("width", String(width));
  svg.setAttribute("height", String(height));
  svg.setAttribute("font-family", "Helvetica, Arial, sans-serif");

  function el(tag, attrs, text) {
    const node = doc.createElementNS(NS, tag);
    for (const key in attrs) {
      node.setAttribute(key, attrs[key]);
    }
    if (text !== undefined) {
      node.textContent = text;
    }
    return node;
  }

  const bg = el("rect", {
    x: 0,
    y: 0,
    width,
    height,
    fill: "#ffffff",
  });
  svg.appendChild(bg);

  const title = el(
    "text",
    {
      x: width / 2,
      y: 24,
      "text-anchor": "middle",
      "font-size": 16,
      "font-weight": "bold",
      fill: "#1a1a1a",
    },
    "Life Expectancy at Birth vs. World Baseline"
  );
  svg.appendChild(title);

  const subtitle = el(
    "text",
    {
      x: width / 2,
      y: 40,
      "text-anchor": "middle",
      "font-size": 11,
      fill: "#666666",
    },
    `Baseline = ${data.baseline} ${data.unit}`
  );
  svg.appendChild(subtitle);

  const rows = data.rows.slice().sort((a, b) => b.delta - a.delta);

  const maxAbsDelta = Math.max(...rows.map((r) => Math.abs(r.delta)));
  const scaleMax = Math.ceil(maxAbsDelta / 5) * 5 || 1;

  const xScale = (delta) => (delta / scaleMax) * (innerWidth / 2);
  const centerX = margin.left + innerWidth / 2;

  const barHeight = innerHeight / rows.length;
  const barPadding = barHeight * 0.25;

  const plotGroup = el("g", {
    transform: `translate(${margin.left}, ${margin.top})`,
  });
  svg.appendChild(plotGroup);

  const zeroLine = el("line", {
    x1: innerWidth / 2,
    y1: 0,
    x2: innerWidth / 2,
    y2: innerHeight,
    stroke: "#999999",
    "stroke-width": 1,
  });
  plotGroup.appendChild(zeroLine);

  rows.forEach((row, i) => {
    const y = i * barHeight + barPadding / 2;
    const h = barHeight - barPadding;
    const dx = xScale(row.delta);
    const barX = dx >= 0 ? innerWidth / 2 : innerWidth / 2 + dx;
    const barW = Math.abs(dx);
    const color = row.delta >= 0 ? "#2b7a78" : "#c0392b";

    const bar = el("rect", {
      x: barX,
      y,
      width: barW,
      height: h,
      fill: color,
    });
    plotGroup.appendChild(bar);

    const label = el(
      "text",
      {
        x: -10,
        y: y + h / 2 + 4,
        "text-anchor": "end",
        "font-size": 12,
        fill: "#1a1a1a",
      },
      row.label
    );
    plotGroup.appendChild(label);

    const valueLabel = el(
      "text",
      {
        x: dx >= 0 ? innerWidth / 2 + dx + 6 : innerWidth / 2 + dx - 6,
        y: y + h / 2 + 4,
        "text-anchor": dx >= 0 ? "start" : "end",
        "font-size": 11,
        fill: "#333333",
      },
      `${row.delta >= 0 ? "+" : ""}${row.delta.toFixed(2)}`
    );
    plotGroup.appendChild(valueLabel);
  });

  const legendGroup = el("g", {
    transform: `translate(${margin.left}, ${height - 24})`,
  });
  svg.appendChild(legendGroup);

  const legendSwatchAbove = el("rect", {
    x: 0,
    y: -10,
    width: 12,
    height: 12,
    fill: "#2b7a78",
  });
  legendGroup.appendChild(legendSwatchAbove);

  const legendLabelAbove = el(
    "text",
    {
      x: 18,
      y: 0,
      "font-size": 11,
      fill: "#1a1a1a",
    },
    "Above baseline"
  );
  legendGroup.appendChild(legendLabelAbove);

  const legendSwatchBelow = el("rect", {
    x: 150,
    y: -10,
    width: 12,
    height: 12,
    fill: "#c0392b",
  });
  legendGroup.appendChild(legendSwatchBelow);

  const legendLabelBelow = el(
    "text",
    {
      x: 168,
      y: 0,
      "font-size": 11,
      fill: "#1a1a1a",
    },
    "Below baseline"
  );
  legendGroup.appendChild(legendLabelBelow);
}

Keep the same module shape (meta + render(svg, data)) and the same data, and the
same harness constraints: no network, no fonts loaded at render time, no
Date.now() or Math.random(), and a byte-stable render. Change only what the
skill's remit covers. Return only the module code.

Structural change vs the default arm

Added: nothing. Removed: nothing.