{"spec_id":"voronoi-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// voronoi-basic: Voronoi Diagram for Spatial Partitioning\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-02\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Retail-store service areas across a metro grid, in km from the city center.\n// A tiny fixed-seed LCG stands in for Math.random() (not reproducible in-browser).\nfunction lcg(seed) {\n  let s = seed;\n  return () => {\n    s = (s * 1664525 + 1013904223) % 4294967296;\n    return s / 4294967296;\n  };\n}\nconst rand = lcg(42);\nconst N_STORES = 20;\nconst DOMAIN_MIN = 0;\nconst DOMAIN_MAX = 40;\nconst stores = Array.from({ length: N_STORES }, (_, i) => ({\n  label: `Store ${i + 1}`,\n  x: DOMAIN_MIN + 2 + rand() * (DOMAIN_MAX - DOMAIN_MIN - 4),\n  y: DOMAIN_MIN + 2 + rand() * (DOMAIN_MAX - DOMAIN_MIN - 4),\n}));\n\n// --- Layout — symmetric margins keep the plot area a true square, so the\n// Voronoi cells (computed directly in pixel space below) aren't stretched. ---\nconst margin = { top: 130, right: 110, bottom: 90, left: 110 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\nconst xScale = d3.scaleLinear().domain([DOMAIN_MIN, DOMAIN_MAX]).range([0, iw]);\nconst yScale = d3.scaleLinear().domain([DOMAIN_MIN, DOMAIN_MAX]).range([ih, 0]);\n\n// --- Voronoi tessellation, clipped to the visible plot bounding box ---------\nconst pixelPoints = stores.map((d) => [xScale(d.x), yScale(d.y)]);\nconst delaunay = d3.Delaunay.from(pixelPoints);\nconst voronoi = delaunay.voronoi([0, 0, iw, ih]);\n\n// Greedy graph coloring over the Delaunay adjacency so neighboring cells never\n// share a color — a more legible variant than cycling palette index by array order.\nconst colorIndex = new Array(N_STORES).fill(0);\nfor (let i = 0; i < N_STORES; i++) {\n  const used = new Set();\n  for (const j of delaunay.neighbors(i)) {\n    if (j < i) used.add(colorIndex[j]);\n  }\n  let c = 0;\n  while (used.has(c)) c++;\n  colorIndex[i] = c % t.palette.length;\n}\n\n// Focal store — the one with the largest service area — gives the viewer a\n// concrete entry point into the tessellation instead of uniform visual weight.\nconst cellAreas = stores.map((_, i) => Math.abs(d3.polygonArea(voronoi.cellPolygon(i))));\nconst focalIndex = d3.greatestIndex(cellAreas);\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// --- Voronoi cells --------------------------------------------------------------\n// The focal cell (largest service area) gets a touch more fill opacity and a\n// heavier ink-toned border so it reads as the entry point into the tessellation.\ng.append(\"g\")\n  .selectAll(\"path\")\n  .data(\n    stores.map((d, i) => ({\n      path: voronoi.renderCell(i),\n      color: t.palette[colorIndex[i]],\n      focal: i === focalIndex,\n    }))\n  )\n  .join(\"path\")\n  .attr(\"d\", (d) => d.path)\n  .attr(\"fill\", (d) => d.color)\n  .attr(\"fill-opacity\", (d) => (d.focal ? 0.75 : 0.55))\n  .attr(\"stroke\", (d) => (d.focal ? t.ink : t.pageBg))\n  .attr(\"stroke-width\", (d) => (d.focal ? 4 : 3));\n\n// --- Bounding box outline -------------------------------------------------------\ng.append(\"rect\")\n  .attr(\"x\", 0)\n  .attr(\"y\", 0)\n  .attr(\"width\", iw)\n  .attr(\"height\", ih)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5);\n\n// --- Axes ---------------------------------------------------------------------\n// Restrained treatment (short ticks, thin low-contrast domain line) so the\n// tessellation's cell colors stay the primary content, not the chrome.\nconst xAxis = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(xScale).ticks(8).tickSize(4).tickPadding(8));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(yScale).ticks(8).tickSize(4).tickPadding(8));\nfor (const ax of [xAxis, yAxis]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"16px\");\n  ax.selectAll(\"line\").attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n  ax.select(\".domain\").attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n}\n\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 66)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"18px\")\n  .text(\"Distance East of City Center (km)\");\n\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -78)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"18px\")\n  .text(\"Distance North of City Center (km)\");\n\n// --- Seed points — the store locations, marked prominently in brand green ---\n// The focal store (largest service area) gets a larger marker and an outer\n// ring so it doubles as the entry point the callout label points to.\ng.append(\"g\")\n  .selectAll(\"circle\")\n  .data(pixelPoints.map((d, i) => ({ d, focal: i === focalIndex })))\n  .join(\"circle\")\n  .attr(\"cx\", (d) => d.d[0])\n  .attr(\"cy\", (d) => d.d[1])\n  .attr(\"r\", (d) => (d.focal ? 13 : 9))\n  .attr(\"fill\", t.palette[0])\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 2.5);\n\ng.append(\"circle\")\n  .attr(\"cx\", pixelPoints[focalIndex][0])\n  .attr(\"cy\", pixelPoints[focalIndex][1])\n  .attr(\"r\", 19)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-width\", 1.5);\n\n// --- Focal callout — leader line + label pointing at the largest service area,\n// anchored so it always points away from the canvas edges it's nearest to. ---\nconst focalPx = pixelPoints[focalIndex];\nconst dirX = focalPx[0] > iw / 2 ? -1 : 1;\nconst dirY = focalPx[1] > ih / 2 ? -1 : 1;\nconst calloutX = focalPx[0] + dirX * 60;\nconst calloutY = focalPx[1] + dirY * 50;\ng.append(\"line\")\n  .attr(\"x1\", focalPx[0])\n  .attr(\"y1\", focalPx[1])\n  .attr(\"x2\", calloutX)\n  .attr(\"y2\", calloutY)\n  .attr(\"stroke\", t.ink)\n  .attr(\"stroke-width\", 1.5);\ng.append(\"text\")\n  .attr(\"x\", calloutX + dirX * 6)\n  .attr(\"y\", calloutY)\n  .attr(\"text-anchor\", dirX > 0 ? \"start\" : \"end\")\n  .attr(\"dominant-baseline\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"17px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Largest service area\");\n\n// --- Title --------------------------------------------------------------------\nconst titleText = \"Store Service Areas · voronoi-basic · javascript · d3 · anyplot.ai\";\nconst titleFontSize = titleText.length > 67 ? Math.round(22 * (67 / titleText.length)) : 22;\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 60)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(titleText);\n"}