← Flat fill vs one radialGradient per datum

mark-02-gradient.B

The Eight PlanetsCircle size = equatorial diameter (sqrt-scaled) · position = mean distance from the Sun, AU (log scale)SunMercury0.39 AUVenus0.72 AUEarth1.00 AUMars1.52 AUJupiter5.20 AUSaturn9.58 AUUranus19.2 AUNeptune30.1 AU
Arm
B · with a skill
Method
craft:perDatumRadialGradient
Ran on
mark-02-gradient.A
Mode
refine — the treated arms ran on the default arm's output, so the only difference is the lever
Fixture
hero8
Runs
1 · shipped: only
Generated
2026-07-26

Verbatim prompt

Here is a chart module. Apply the project's per-datum radial gradient craft technique to the module below.

Use the project's craft helper instead of writing your own gradient. Its first
argument is a d3 selection over a <defs> element, not a raw DOM node — create it
with d3-selection as shown:

  import { select } from "d3-selection";
  import { perDatumRadialGradient } from "../../../src/craft/gradient.ts";

  const defsSelection = select(svg).append("defs");
  const fill = perDatumRadialGradient(defsSelection, data.rows, {
    idPrefix: "planet", color: (d) => d.color,
  });
  // then, on each mark: .attr("fill", (d, i) => fill(d, i))
  // or without d3 on the marks: el.setAttribute("fill", fill(row, i))

Both import paths above are exact, including the `.ts` extension — reproduce them
verbatim. The renderer loads this module under bare Node ESM, where an
extensionless relative import cannot resolve, and where passing a raw element
instead of a d3 selection throws `defs.selectAll is not a function`.

The helper's defaults place the highlight off-centre at cx/cy 35%, r 60%. Do not
centre it.

export const meta = { fixture: "hero8", 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;

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

  while (svg.firstChild) svg.removeChild(svg.firstChild);

  function el(name, attrs) {
    const node = doc.createElementNS(NS, name);
    if (attrs) {
      for (const key in attrs) {
        node.setAttribute(key, String(attrs[key]));
      }
    }
    return node;
  }

  function clamp01(t) {
    return Math.max(0, Math.min(1, t));
  }

  function hexToRgb(hex) {
    const clean = hex.replace("#", "");
    const full = clean.length === 3
      ? clean.split("").map((c) => c + c).join("")
      : clean;
    const num = parseInt(full, 16);
    return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };
  }

  function toHex(v) {
    return Math.round(clamp01(v / 255) * 255).toString(16).padStart(2, "0");
  }

  function mix(hex, target, amount) {
    const { r, g, b } = hexToRgb(hex);
    const t = clamp01(amount);
    return `#${toHex(r + (target.r - r) * t)}${toHex(g + (target.g - g) * t)}${toHex(b + (target.b - b) * t)}`;
  }

  function lighten(hex, amount) {
    return mix(hex, { r: 255, g: 255, b: 255 }, amount);
  }

  function darken(hex, amount) {
    return mix(hex, { r: 0, g: 0, b: 0 }, amount);
  }

  function formatAU(v) {
    return v >= 10 ? v.toFixed(1) : v.toFixed(2);
  }

  const rows = data.rows;
  const margin = { top: 96, right: 44, bottom: 60, left: 44 };
  const plotLeft = margin.left;
  const plotRight = width - margin.right;
  const plotWidth = plotRight - plotLeft;
  const baselineY = margin.top + (height - margin.top - margin.bottom) * 0.5;

  const distances = rows.map((d) => d.distance);
  const sizes = rows.map((d) => d.size);

  const logMin = Math.log10(Math.min(...distances) * 0.72);
  const logMax = Math.log10(Math.max(...distances) * 1.18);
  const logRange = logMax - logMin;

  function xForDistance(auValue) {
    const t = (Math.log10(auValue) - logMin) / logRange;
    return plotLeft + t * plotWidth;
  }

  const sqrtMin = Math.sqrt(Math.min(...sizes));
  const sqrtMax = Math.sqrt(Math.max(...sizes));
  const rMin = 6;
  const rMax = 32;

  function rForSize(size) {
    const t = (Math.sqrt(size) - sqrtMin) / (sqrtMax - sqrtMin);
    return rMin + t * (rMax - rMin);
  }

  // ---- defs + background ----
  const defs = el("defs");
  svg.appendChild(defs);

  const bgGrad = el("linearGradient", { id: "hero8-bg", x1: "0", y1: "0", x2: "0", y2: "1" });
  bgGrad.appendChild(el("stop", { offset: "0%", "stop-color": "#0d1330" }));
  bgGrad.appendChild(el("stop", { offset: "100%", "stop-color": "#05060f" }));
  defs.appendChild(bgGrad);

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

  // ---- title ----
  const title = el("text", {
    x: margin.left,
    y: 34,
    fill: "#f4f6fb",
    "font-size": 22,
    "font-weight": "700"
  });
  title.textContent = "The Eight Planets";
  svg.appendChild(title);

  const subtitle = el("text", {
    x: margin.left,
    y: 56,
    fill: "#8b96bd",
    "font-size": 12
  });
  subtitle.textContent = "Circle size = equatorial diameter (sqrt-scaled)   ·   position = mean distance from the Sun, AU (log scale)";
  svg.appendChild(subtitle);

  // ---- orbital baseline ----
  const axis = el("line", {
    x1: plotLeft,
    x2: plotRight,
    y1: baselineY,
    y2: baselineY,
    stroke: "#232a4d",
    "stroke-width": 1.5
  });
  svg.appendChild(axis);

  // ---- sun marker ----
  const sunGrad = el("radialGradient", { id: "hero8-sun", cx: "35%", cy: "32%", r: "65%" });
  sunGrad.appendChild(el("stop", { offset: "0%", "stop-color": "#fff8df" }));
  sunGrad.appendChild(el("stop", { offset: "100%", "stop-color": "#ffb703" }));
  defs.appendChild(sunGrad);

  const sun = el("circle", { cx: plotLeft, cy: baselineY, r: 9, fill: "url(#hero8-sun)" });
  svg.appendChild(sun);

  const sunLabel = el("text", {
    x: plotLeft,
    y: baselineY + 24,
    fill: "#5c6890",
    "font-size": 10,
    "text-anchor": "middle"
  });
  sunLabel.textContent = "Sun";
  svg.appendChild(sunLabel);

  // ---- planets ----
  rows.forEach((row, i) => {
    const cx = xForDistance(row.distance);
    const r = rForSize(row.size);
    const gradId = `hero8-planet-${i}`;

    const grad = el("radialGradient", { id: gradId, cx: "38%", cy: "32%", r: "72%" });
    grad.appendChild(el("stop", { offset: "0%", "stop-color": lighten(row.color, 0.6) }));
    grad.appendChild(el("stop", { offset: "50%", "stop-color": row.color }));
    grad.appendChild(el("stop", { offset: "100%", "stop-color": darken(row.color, 0.4) }));
    defs.appendChild(grad);

    const circle = el("circle", {
      cx,
      cy: baselineY,
      r,
      fill: `url(#${gradId})`,
      stroke: darken(row.color, 0.5),
      "stroke-width": 1
    });
    svg.appendChild(circle);

    const above = i % 2 === 0;
    const auY = above ? baselineY - r - 10 : baselineY + r + 22;
    const nameY = above ? baselineY - r - 26 : baselineY + r + 38;

    const nameLabel = el("text", {
      x: cx,
      y: nameY,
      fill: "#eef1fb",
      "font-size": 12.5,
      "font-weight": "600",
      "text-anchor": "middle"
    });
    nameLabel.textContent = row.label;
    svg.appendChild(nameLabel);

    const auLabel = el("text", {
      x: cx,
      y: auY,
      fill: "#7c88b3",
      "font-size": 10,
      "text-anchor": "middle"
    });
    auLabel.textContent = `${formatAU(row.distance)} AU`;
    svg.appendChild(auLabel);
  });
}

Keep the same module shape (meta + render(svg, data)) and the same data. Change
only what the skill's remit covers. Return only the module code.

Structural change vs the default arm

Added: nothing. Removed: nothing.