{"spec_id":"choropleth-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// choropleth-basic: Choropleth Map with Regional Coloring\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 88/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n// `muted` isn't part of ANYPLOT_TOKENS — derive it from the theme, matching\n// the Imprint semantic anchor in prompts/default-style-guide.md.\nconst MUTED = t.theme === \"dark\" ? \"#A8A79F\" : \"#6B6A63\";\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Illustrative renewable-electricity share (%) of generation, one synthetic\n// value per U.S. state. Laid out as an equal-area tile grid (\"state grid\n// map\" cartogram) rather than literal shapefile boundaries — the renderer\n// runs offline with no network/topojson access, and every state keeps the\n// same visual weight regardless of its true land area, a well-established\n// choropleth variant for all-state comparisons (e.g. NPR's state grid maps).\nlet seed = 42;\nfunction nextRandom() {\n  seed = (1103515245 * seed + 12345) % 2147483648;\n  return seed / 2147483648;\n}\n\nconst regions = [\n  { abbr: \"AK\", col: -1, row: 0 }, { abbr: \"HI\", col: -1, row: 1 },\n  { abbr: \"WA\", col: 0, row: 0 }, { abbr: \"OR\", col: 0, row: 1 }, { abbr: \"CA\", col: 0, row: 2 },\n  { abbr: \"NV\", col: 0, row: 3 }, { abbr: \"AZ\", col: 0, row: 4 },\n  { abbr: \"ID\", col: 1, row: 0 }, { abbr: \"UT\", col: 1, row: 1 }, { abbr: \"NM\", col: 1, row: 2 },\n  { abbr: \"MT\", col: 2, row: 0 }, { abbr: \"WY\", col: 2, row: 1 }, { abbr: \"CO\", col: 2, row: 2 },\n  { abbr: \"ND\", col: 3, row: 0 }, { abbr: \"SD\", col: 3, row: 1 }, { abbr: \"NE\", col: 3, row: 2 },\n  { abbr: \"KS\", col: 3, row: 3 }, { abbr: \"OK\", col: 3, row: 4 }, { abbr: \"TX\", col: 3, row: 5 },\n  { abbr: \"MN\", col: 4, row: 0 }, { abbr: \"IA\", col: 4, row: 1 }, { abbr: \"MO\", col: 4, row: 2 },\n  { abbr: \"AR\", col: 4, row: 3 }, { abbr: \"LA\", col: 4, row: 4 },\n  { abbr: \"WI\", col: 5, row: 0 }, { abbr: \"IL\", col: 5, row: 1 }, { abbr: \"KY\", col: 5, row: 2 },\n  { abbr: \"TN\", col: 5, row: 3 }, { abbr: \"MS\", col: 5, row: 4 }, { abbr: \"AL\", col: 5, row: 5 },\n  { abbr: \"MI\", col: 6, row: 0 }, { abbr: \"IN\", col: 6, row: 1 }, { abbr: \"OH\", col: 6, row: 2 },\n  { abbr: \"WV\", col: 6, row: 3 }, { abbr: \"VA\", col: 6, row: 4 }, { abbr: \"NC\", col: 6, row: 5 },\n  { abbr: \"SC\", col: 6, row: 6 }, { abbr: \"GA\", col: 6, row: 7 }, { abbr: \"FL\", col: 6, row: 8 },\n  { abbr: \"NY\", col: 7, row: 0 }, { abbr: \"PA\", col: 7, row: 1 }, { abbr: \"NJ\", col: 7, row: 2 },\n  { abbr: \"DE\", col: 7, row: 3 }, { abbr: \"MD\", col: 7, row: 4 },\n  { abbr: \"VT\", col: 8, row: 0 }, { abbr: \"MA\", col: 8, row: 1 }, { abbr: \"CT\", col: 8, row: 2 },\n  { abbr: \"RI\", col: 8, row: 3 },\n  { abbr: \"ME\", col: 9, row: 0 }, { abbr: \"NH\", col: 9, row: 1 },\n].map((d) => ({ ...d, value: Math.round(5 + nextRandom() * 90) }));\n\n// Two states illustrate graceful handling of missing data (spec requirement).\nconst noDataStates = new Set([\"WY\", \"SC\"]);\nfor (const region of regions) {\n  if (noDataStates.has(region.abbr)) region.value = null;\n}\n\n// Highest/lowest extremes, called out below to sharpen the story beyond the\n// base color encoding and to give the map's empty southwest corner a job.\nconst validRegions = regions.filter((d) => d.value != null);\nconst maxRegion = validRegions.reduce((a, b) => (b.value > a.value ? b : a));\nconst minRegion = validRegions.reduce((a, b) => (b.value < a.value ? b : a));\nconst highlightAbbrs = new Set([maxRegion.abbr, minRegion.abbr]);\n\n// --- Layout -------------------------------------------------------------\nconst margin = { top: 150, right: 70, bottom: 40, left: 70 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\nconst x = d3.scaleBand().domain(d3.range(-1, 10)).range([0, iw]).padding(0.1);\nconst y = d3.scaleBand().domain(d3.range(0, 9)).range([0, ih]).padding(0.1);\n\nconst valueExtent = d3.extent(regions.filter((d) => d.value != null), (d) => d.value);\nconst color = d3.scaleSequential(d3.interpolateRgbBasis(t.seq)).domain(valueExtent);\n\n// Pick dark or light label ink from the tile's own fill luminance, independent\n// of page theme — a pale tile needs dark text even on the dark surface.\nfunction labelInk(hex) {\n  const c = d3.rgb(hex);\n  const luminance = 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;\n  return luminance > 150 ? \"#1A1A17\" : \"#F0EFE8\";\n}\n\n// --- SVG mount ----------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Tiles ----------------------------------------------------------------\nconst tile = g\n  .selectAll(\"g.tile\")\n  .data(regions)\n  .join(\"g\")\n  .attr(\"class\", \"tile\")\n  .attr(\"transform\", (d) => `translate(${x(d.col)},${y(d.row)})`);\n\ntile\n  .append(\"rect\")\n  .attr(\"width\", x.bandwidth())\n  .attr(\"height\", y.bandwidth())\n  .attr(\"rx\", 4)\n  .attr(\"fill\", (d) => (d.value == null ? MUTED : color(d.value)))\n  .attr(\"stroke\", (d) => (highlightAbbrs.has(d.abbr) ? t.ink : t.pageBg))\n  .attr(\"stroke-width\", (d) => (highlightAbbrs.has(d.abbr) ? 4 : 3));\n\ntile\n  .append(\"text\")\n  .attr(\"x\", x.bandwidth() / 2)\n  .attr(\"y\", y.bandwidth() / 2)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"dominant-baseline\", \"central\")\n  .style(\"font-size\", \"16px\")\n  .style(\"font-weight\", \"600\")\n  .attr(\"fill\", (d) => labelInk(d.value == null ? MUTED : color(d.value)))\n  .text((d) => d.abbr);\n\n// --- Highest/lowest callout --------------------------------------------------\n// The tile-grid's true US shape leaves cols -1..2 empty below row 5 (nothing\n// west of Texas reaches that far south) — use that void for a data-storytelling\n// callout instead of leaving it dead space, and echo the ink-stroke swatches\n// against the two highlighted tiles above.\nconst calloutX = x(-1);\nconst calloutRowY = y(5);\nconst calloutEntries = [\n  { region: maxRegion, label: \"highest\" },\n  { region: minRegion, label: \"lowest\" },\n];\ng.append(\"text\")\n  .attr(\"x\", calloutX)\n  .attr(\"y\", calloutRowY + 14)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Highest & lowest share\");\ncalloutEntries.forEach((entry, i) => {\n  const rowY = calloutRowY + 40 + i * 30;\n  g.append(\"rect\")\n    .attr(\"x\", calloutX)\n    .attr(\"y\", rowY)\n    .attr(\"width\", 16)\n    .attr(\"height\", 16)\n    .attr(\"rx\", 3)\n    .attr(\"fill\", \"none\")\n    .attr(\"stroke\", t.ink)\n    .attr(\"stroke-width\", 3);\n  g.append(\"text\")\n    .attr(\"x\", calloutX + 24)\n    .attr(\"y\", rowY + 13)\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"14px\")\n    .text(`${entry.region.abbr} — ${entry.region.value}% (${entry.label})`);\n});\n\n// --- Title ------------------------------------------------------------------\nconst title = \"Renewable Electricity Share · choropleth-basic · javascript · d3 · anyplot.ai\";\n// Default bumped from the style-guide's 22px baseline: at the formula's 19px\n// result this title measured only ~42% of canvas width (below the ~50-70%\n// comfort range) per AI review — 30px recalibrates for this title's actual\n// rendered character width while the 67-char linear taper still guards long\n// titles from overflow.\nconst titleFontSize = Math.round(30 * Math.min(1, 67 / title.length));\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 50)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(title);\n\n// --- Legend (sequential gradient + no-data swatch) --------------------------\nconst legendWidth = 260;\nconst legendHeight = 18;\nconst noDataSwatchGap = 20;\nconst noDataLabelWidth = 60;\nconst legendTotalWidth = legendWidth + noDataSwatchGap + legendHeight + 8 + noDataLabelWidth;\nconst legendX = width - 20 - legendTotalWidth;\nconst legendY = 95;\n\nconst gradientId = \"choropleth-legend-gradient\";\nconst gradient = svg\n  .append(\"defs\")\n  .append(\"linearGradient\")\n  .attr(\"id\", gradientId)\n  .attr(\"x1\", \"0%\")\n  .attr(\"x2\", \"100%\")\n  .attr(\"y1\", \"0%\")\n  .attr(\"y2\", \"0%\");\nd3.range(0, 1.001, 0.1).forEach((stop) => {\n  gradient\n    .append(\"stop\")\n    .attr(\"offset\", `${stop * 100}%`)\n    .attr(\"stop-color\", color(valueExtent[0] + stop * (valueExtent[1] - valueExtent[0])));\n});\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", legendX)\n  .attr(\"y\", legendY - 12)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(\"Renewable electricity share (%)\");\n\nsvg\n  .append(\"rect\")\n  .attr(\"x\", legendX)\n  .attr(\"y\", legendY)\n  .attr(\"width\", legendWidth)\n  .attr(\"height\", legendHeight)\n  .attr(\"fill\", `url(#${gradientId})`);\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", legendX)\n  .attr(\"y\", legendY + legendHeight + 20)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(`${valueExtent[0]}%`);\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", legendX + legendWidth)\n  .attr(\"y\", legendY + legendHeight + 20)\n  .attr(\"text-anchor\", \"end\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(`${valueExtent[1]}%`);\n\nsvg\n  .append(\"rect\")\n  .attr(\"x\", legendX + legendWidth + noDataSwatchGap)\n  .attr(\"y\", legendY)\n  .attr(\"width\", legendHeight)\n  .attr(\"height\", legendHeight)\n  .attr(\"rx\", 3)\n  .attr(\"fill\", MUTED);\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", legendX + legendWidth + noDataSwatchGap + legendHeight + 8)\n  .attr(\"y\", legendY + legendHeight - 4)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\"No data\");\n"}