{"spec_id":"voronoi-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// voronoi-basic: Voronoi Diagram for Spatial Partitioning\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-02\n\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\nfunction hexToRgba(hex, alpha) {\n  const r = parseInt(hex.slice(1, 3), 16);\n  const g = parseInt(hex.slice(3, 5), 16);\n  const b = parseInt(hex.slice(5, 7), 16);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\n\n// --- Data: retail store locations across a 100 km x 100 km metro region ---\n// A jittered 4x4 grid keeps the tessellation readable while still looking\n// organic — real store networks rarely sit on an exact lattice.\nconst STORE_LABELS = [\"A\", \"B\", \"C\", \"D\", \"E\", \"F\", \"G\", \"H\", \"I\", \"J\", \"K\", \"L\", \"M\", \"N\", \"O\", \"P\"];\nconst stores = [\n  { x: 15.5, y: 8.5 },\n  { x: 32.5, y: 14.5 },\n  { x: 66.5, y: 17.5 },\n  { x: 85.5, y: 9.5 },\n  { x: 14.5, y: 40.5 },\n  { x: 33.5, y: 35.5 },\n  { x: 67.5, y: 32.5 },\n  { x: 84.5, y: 41.5 },\n  { x: 10.5, y: 67.5 },\n  { x: 40.5, y: 59.5 },\n  { x: 57.5, y: 58.5 },\n  { x: 91.5, y: 64.5 },\n  { x: 13.5, y: 82.5 },\n  { x: 33.5, y: 90.5 },\n  { x: 67.5, y: 91.5 },\n  { x: 84.5, y: 85.5 },\n].map((p, i) => ({ ...p, label: STORE_LABELS[i], color: t.palette[i % t.palette.length] }));\n\n// --- Mount -------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// Clip a convex polygon (in canvas pixel space) to the half-plane closer to\n// `site` than `other` — Sutherland-Hodgman clipping against the perpendicular\n// bisector of the two sites. Intersecting a bounding box with one bisector\n// per neighbor produces the exact Voronoi cell, clipped to the visible frame.\nfunction clipToBisector(poly, site, other) {\n  const midX = (site.x + other.x) / 2;\n  const midY = (site.y + other.y) / 2;\n  const dx = other.x - site.x;\n  const dy = other.y - site.y;\n  const side = (p) => (p.x - midX) * dx + (p.y - midY) * dy;\n\n  const out = [];\n  for (let i = 0; i < poly.length; i++) {\n    const curr = poly[i];\n    const next = poly[(i + 1) % poly.length];\n    const currSide = side(curr);\n    const nextSide = side(next);\n    if (currSide < 0) out.push(curr);\n    if (currSide < 0 !== nextSide < 0) {\n      const tRatio = currSide / (currSide - nextSide);\n      out.push({ x: curr.x + tRatio * (next.x - curr.x), y: curr.y + tRatio * (next.y - curr.y) });\n    }\n  }\n  return out;\n}\n\n// Inline plugin: computes the Voronoi tessellation directly in screen-pixel\n// space (so cell shapes read correctly regardless of the x/y scale ratio),\n// fills each cell behind the points, then labels the seeds on top.\nconst voronoiCells = {\n  id: \"voronoiCells\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, chartArea, scales } = chart;\n    const sites = stores.map((s) => ({\n      x: scales.x.getPixelForValue(s.x),\n      y: scales.y.getPixelForValue(s.y),\n      color: s.color,\n    }));\n\n    ctx.save();\n    sites.forEach((site, i) => {\n      let cell = [\n        { x: chartArea.left, y: chartArea.top },\n        { x: chartArea.right, y: chartArea.top },\n        { x: chartArea.right, y: chartArea.bottom },\n        { x: chartArea.left, y: chartArea.bottom },\n      ];\n      sites.forEach((other, j) => {\n        if (i !== j) cell = clipToBisector(cell, site, other);\n      });\n      if (cell.length < 3) return;\n\n      ctx.beginPath();\n      cell.forEach((p, idx) => (idx === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)));\n      ctx.closePath();\n      ctx.fillStyle = hexToRgba(site.color, 0.32);\n      ctx.fill();\n      ctx.strokeStyle = t.pageBg;\n      ctx.lineWidth = 4;\n      ctx.stroke();\n    });\n    ctx.restore();\n  },\n  afterDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    ctx.save();\n    ctx.font = \"600 16px sans-serif\";\n    ctx.fillStyle = t.ink;\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"bottom\";\n    stores.forEach((s) => {\n      const px = scales.x.getPixelForValue(s.x);\n      const py = scales.y.getPixelForValue(s.y);\n      ctx.fillText(s.label, px, py - 20);\n    });\n    ctx.restore();\n  },\n};\n\n// --- Chart -----------------------------------------------------------------\nconst title = \"voronoi-basic · javascript · chartjs · anyplot.ai\";\nconst titleFontSize = Math.round(26 * (title.length > 67 ? 67 / title.length : 1));\n\nnew Chart(canvas, {\n  type: \"scatter\",\n  plugins: [voronoiCells],\n  data: {\n    datasets: [\n      {\n        label: \"Store locations\",\n        data: stores.map((s) => ({ x: s.x, y: s.y })),\n        pointBackgroundColor: stores.map((s) => s.color),\n        pointBorderColor: t.pageBg,\n        pointBorderWidth: 3,\n        pointRadius: 14,\n        pointHoverRadius: 14,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 8, right: 16 } },\n    plugins: {\n      title: {\n        display: true,\n        text: title,\n        color: t.ink,\n        font: { size: titleFontSize },\n        padding: { top: 12, bottom: 16 },\n      },\n      legend: { display: false },\n    },\n    scales: {\n      x: {\n        min: 0,\n        max: 100,\n        ticks: { color: t.inkSoft, font: { size: 16 } },\n        grid: { display: false },\n        title: { display: true, text: \"Distance East (km)\", color: t.ink, font: { size: 20 } },\n      },\n      y: {\n        min: 0,\n        max: 100,\n        ticks: { color: t.inkSoft, font: { size: 16 } },\n        grid: { display: false },\n        title: { display: true, text: \"Distance North (km)\", color: t.ink, font: { size: 20 } },\n      },\n    },\n  },\n});\n"}