{"spec_id":"choropleth-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// choropleth-basic: Choropleth Map with Regional Coloring\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 85/100 | Created: 2026-09-02\n\n// Chart.js has no built-in geographic/shape geometry (that lives only in the\n// chartjs-chart-geo plugin, which is not installed in this runtime — see\n// prompts/library/chartjs.md \"No Workarounds\"). This renders the same\n// region -> value story as a tile map: one square per region, positioned by\n// its real-world longitude/latitude, colored on the Imprint sequential scale.\n// Region \"boundaries\" are the square's stroke; missing data renders as a\n// muted gray tile, per the specification's data-handling note.\nconst t = window.ANYPLOT_TOKENS;\nconst MUTED = t.theme === \"dark\" ? \"#A8A79F\" : \"#6B6A63\";\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Renewable share of electricity generation (%) by country, with approximate\n// centroid coordinates. Two countries carry no reading to demonstrate the\n// missing-data handling the spec calls for.\nconst countries = [\n  { code: \"CA\", lon: -106.3, lat: 56.1, value: 68 },\n  { code: \"US\", lon: -95.7, lat: 37.1, value: 21 },\n  { code: \"MX\", lon: -102.6, lat: 23.6, value: 24 },\n  { code: \"BR\", lon: -51.9, lat: -14.2, value: 84 },\n  { code: \"AR\", lon: -63.6, lat: -38.4, value: 33 },\n  { code: \"CO\", lon: -74.1, lat: 4.6, value: 70 },\n  { code: \"PE\", lon: -75.2, lat: -9.2, value: 60 },\n  { code: \"CL\", lon: -71.5, lat: -35.7, value: 46 },\n  { code: \"GB\", lon: -3.4, lat: 55.4, value: 43 },\n  { code: \"FR\", lon: 2.2, lat: 46.6, value: 27 },\n  { code: \"DE\", lon: 10.5, lat: 51.2, value: 46 },\n  { code: \"ES\", lon: -3.7, lat: 40.5, value: 50 },\n  { code: \"IT\", lon: 12.6, lat: 41.9, value: 41 },\n  { code: \"NO\", lon: 8.5, lat: 60.5, value: 98 },\n  { code: \"SE\", lon: 18.6, lat: 60.1, value: 68 },\n  { code: \"PL\", lon: 19.1, lat: 51.9, value: 17 },\n  { code: \"RU\", lon: 105.3, lat: 61.5, value: 20 },\n  { code: \"TR\", lon: 35.2, lat: 38.9, value: 44 },\n  { code: \"EG\", lon: 30.8, lat: 26.8, value: 12 },\n  { code: \"NG\", lon: 8.7, lat: 9.1, value: null },\n  { code: \"ZA\", lon: 22.9, lat: -30.6, value: 9 },\n  { code: \"KE\", lon: 37.9, lat: -0.0, value: 90 },\n  { code: \"MA\", lon: -7.1, lat: 31.8, value: 20 },\n  { code: \"SA\", lon: 45.1, lat: 23.9, value: 1 },\n  { code: \"IN\", lon: 78.9, lat: 20.6, value: 23 },\n  { code: \"CN\", lon: 104.1, lat: 35.9, value: 31 },\n  { code: \"JP\", lon: 138.3, lat: 36.2, value: 22 },\n  { code: \"KR\", lon: 127.8, lat: 35.9, value: 9 },\n  { code: \"ID\", lon: 113.9, lat: -0.8, value: null },\n  { code: \"TH\", lon: 100.9, lat: 15.9, value: 18 },\n  { code: \"VN\", lon: 108.8, lat: 14.1, value: 40 },\n  { code: \"AU\", lon: 133.8, lat: -25.3, value: 32 },\n  { code: \"NZ\", lon: 174.9, lat: -40.9, value: 87 },\n];\n\n// --- Imprint sequential scale (green -> blue), binned for a readable legend\nconst hexToRgb = (hex) => ({\n  r: parseInt(hex.slice(1, 3), 16),\n  g: parseInt(hex.slice(3, 5), 16),\n  b: parseInt(hex.slice(5, 7), 16),\n});\nconst lerp = (a, b, f) => Math.round(a + (b - a) * f);\nconst seqRgb = (frac) => {\n  const c0 = hexToRgb(t.seq[0]);\n  const c1 = hexToRgb(t.seq[1]);\n  return { r: lerp(c0.r, c1.r, frac), g: lerp(c0.g, c1.g, frac), b: lerp(c0.b, c1.b, frac) };\n};\nconst toCss = ({ r, g, b }) => `rgb(${r}, ${g}, ${b})`;\n// WCAG-ish relative luminance so a code label always reads against its own tile,\n// regardless of theme (tile fill colors are identical across themes).\nconst luminance = ({ r, g, b }) => 0.299 * r + 0.587 * g + 0.114 * b;\nconst LABEL_LIGHT = \"#FFFDF6\";\nconst LABEL_DARK = \"#1A1A17\";\n\nconst bins = [\n  { label: \"< 10%\", max: 10 },\n  { label: \"10–25%\", max: 25 },\n  { label: \"25–40%\", max: 40 },\n  { label: \"40–65%\", max: 65 },\n  { label: \"≥ 65%\", max: Infinity },\n].map((bin, i, arr) => {\n  // Highest-renewable bins read as brand green, lowest as blue, so the color\n  // story matches the metric (more green = more renewable).\n  const rgb = seqRgb(1 - i / (arr.length - 1));\n  return { ...bin, color: toCss(rgb), textColor: luminance(rgb) > 140 ? LABEL_DARK : LABEL_LIGHT };\n});\n\nconst binIndex = (value) => bins.findIndex((bin) => value <= bin.max);\nconst mutedTextColor = luminance(hexToRgb(MUTED)) > 140 ? LABEL_DARK : LABEL_LIGHT;\n\n// --- Basemap -----------------------------------------------------------------\n// Chart.js core has no polygon-fill geometry, so this draws a lightweight\n// continent-outline graticule (stroked line datasets, no fill) behind the\n// tiles purely for geographic orientation — it is not a projected basemap.\nconst CONTINENTS = [\n  [\n    [-125, 49], [-95, 78], [-75, 68], [-60, 50], [-52, 47], [-65, 44],\n    [-75, 35], [-80, 26], [-97, 26], [-90, 15], [-105, 20], [-115, 29], [-125, 49],\n  ],\n  [\n    [-77, 10], [-60, 10], [-50, 0], [-35, -5], [-40, -20], [-48, -25],\n    [-58, -34], [-68, -47], [-72, -40], [-70, -20], [-79, -5], [-77, 10],\n  ],\n  [\n    [-17, 15], [0, 5], [10, 4], [15, -5], [13, -18], [18, -34], [30, -30],\n    [40, -15], [42, 0], [45, 10], [38, 15], [33, 31], [10, 37], [-17, 15],\n  ],\n  [\n    [-9, 43], [0, 50], [10, 54], [20, 55], [30, 60], [40, 65], [30, 45],\n    [20, 40], [10, 38], [-5, 36], [-9, 43],\n  ],\n  [\n    [26, 40], [40, 45], [60, 55], [80, 55], [100, 50], [120, 50], [140, 55],\n    [145, 45], [130, 35], [120, 25], [105, 10], [95, 5], [80, 10], [70, 20],\n    [60, 25], [50, 30], [35, 30], [26, 40],\n  ],\n  [\n    [113, -22], [122, -18], [135, -12], [145, -16], [153, -28], [150, -38],\n    [140, -38], [130, -32], [115, -34], [113, -22],\n  ],\n];\nconst basemapDatasets = CONTINENTS.map((outline, i) => ({\n  type: \"line\",\n  label: `basemap-${i}`,\n  skipLegend: true,\n  data: outline.map(([lon, lat]) => ({ x: lon, y: lat })),\n  borderColor: t.grid,\n  backgroundColor: \"transparent\",\n  borderWidth: 1.5,\n  pointRadius: 0,\n  pointHoverRadius: 0,\n  fill: false,\n  tension: 0,\n}));\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// A tile-map has no room for chartjs-chart-geo's boundary shapes (not\n// installed — see prompts/library/chartjs.md), so region codes are drawn\n// directly onto each square with a plain Chart.js plugin (native canvas\n// access, no external package) instead.\nconst regionLabelPlugin = {\n  id: \"regionLabels\",\n  afterDatasetsDraw(chart) {\n    const { ctx } = chart;\n    chart.data.datasets.forEach((dataset, dsIndex) => {\n      if (dataset.skipLegend) return; // basemap outline, not a data tile\n      const meta = chart.getDatasetMeta(dsIndex);\n      meta.data.forEach((point, i) => {\n        const raw = dataset.data[i];\n        ctx.save();\n        ctx.font = \"600 12px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif\";\n        ctx.fillStyle = dataset.textColor;\n        ctx.textAlign = \"center\";\n        ctx.textBaseline = \"middle\";\n        ctx.fillText(raw.code, point.x, point.y);\n        ctx.restore();\n      });\n    });\n  },\n};\n\n// --- Chart ---------------------------------------------------------------\nconst datasets = bins.map((bin, i) => ({\n  label: `${bin.label} renewable`,\n  data: countries.filter((c) => c.value !== null && binIndex(c.value) === i),\n  backgroundColor: bin.color,\n  textColor: bin.textColor,\n  borderColor: t.ink,\n  borderWidth: 1.5,\n  pointStyle: \"rect\",\n  pointRadius: 20,\n  pointHoverRadius: 22,\n}));\ndatasets.push({\n  label: \"No data\",\n  data: countries.filter((c) => c.value === null),\n  backgroundColor: MUTED,\n  textColor: mutedTextColor,\n  borderColor: t.ink,\n  borderWidth: 1.5,\n  pointStyle: \"rect\",\n  pointRadius: 20,\n  pointHoverRadius: 22,\n});\n\nconst title = \"Renewable Electricity Share · choropleth-basic · javascript · chartjs · anyplot.ai\";\nconst titleFontSize = Math.round(22 * Math.min(1, 67 / title.length));\n\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    // Basemap outlines first so tile squares draw on top of them.\n    datasets: [\n      ...basemapDatasets,\n      ...datasets.map((d) => ({\n        ...d,\n        data: d.data.map((c) => ({ x: c.lon, y: c.lat, code: c.code, value: c.value })),\n      })),\n    ],\n  },\n  plugins: [regionLabelPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 8, bottom: 8, left: 16, right: 16 } },\n    plugins: {\n      title: { display: true, text: title, color: t.ink, font: { size: titleFontSize, weight: \"500\" } },\n      legend: {\n        position: \"bottom\",\n        labels: {\n          color: t.inkSoft,\n          font: { size: 16 },\n          boxWidth: 22,\n          boxHeight: 22,\n          padding: 18,\n          filter: (item, data) => !data.datasets[item.datasetIndex].skipLegend,\n        },\n      },\n      tooltip: {\n        filter: (item) => !item.dataset.skipLegend,\n        callbacks: {\n          title: () => \"\",\n          label: (ctx) =>\n            ctx.raw.value === null || ctx.raw.value === undefined\n              ? `${ctx.raw.code}: no data`\n              : `${ctx.raw.code}: ${ctx.raw.value}% renewable`,\n        },\n      },\n    },\n    scales: {\n      // Tightened to the real longitude/latitude extent of the 32 countries\n      // (plus a small margin) rather than the full -180..180/-90..90 globe,\n      // so the canvas isn't dominated by empty ocean.\n      x: {\n        type: \"linear\",\n        min: -118,\n        max: 182,\n        display: true,\n        border: { display: false },\n        ticks: { display: false },\n        grid: { display: false },\n      },\n      y: {\n        type: \"linear\",\n        min: -48,\n        max: 68,\n        display: true,\n        border: { display: false },\n        ticks: { display: false },\n        grid: { display: false },\n      },\n    },\n  },\n});\n"}