{"spec_id":"hexbin-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// hexbin-basic: Basic Hexbin Plot\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-08-25\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: simulated GPS pings around a city center, clustered by land use --\n// (deterministic LCG + Box-Muller — the browser has no seeded Math.random)\nfunction makeRng(seed) {\n  let s = seed >>> 0;\n  return function rng() {\n    s = (s * 1664525 + 1013904223) >>> 0;\n    return s / 4294967296;\n  };\n}\nfunction gaussian(rng) {\n  const u1 = Math.max(rng(), 1e-9);\n  const u2 = rng();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\nconst rng = makeRng(42);\nconst CLUSTERS = [\n  { n: 2600, cx: 0.0, cy: 0.0, sx: 3.2, sy: 2.4 }, // downtown core\n  { n: 1500, cx: 5.5, cy: 3.2, sx: 1.6, sy: 1.3 }, // business park\n  { n: 1200, cx: -4.5, cy: -5.5, sx: 1.9, sy: 1.4 }, // airport corridor\n  { n: 700, cx: 7.2, cy: -3.8, sx: 1.1, sy: 0.9 }, // suburban mall\n];\n\nconst points = [];\nfor (const c of CLUSTERS) {\n  for (let i = 0; i < c.n; i++) {\n    points.push({ x: c.cx + gaussian(rng) * c.sx, y: c.cy + gaussian(rng) * c.sy });\n  }\n}\n\nlet xMin = Infinity;\nlet xMax = -Infinity;\nlet yMin = Infinity;\nlet yMax = -Infinity;\nfor (const p of points) {\n  if (p.x < xMin) xMin = p.x;\n  if (p.x > xMax) xMax = p.x;\n  if (p.y < yMin) yMin = p.y;\n  if (p.y > yMax) yMax = p.y;\n}\nconst padX = (xMax - xMin) * 0.05;\nconst padY = (yMax - yMin) * 0.05;\nconst domain = { xMin: xMin - padX, xMax: xMax + padX, yMin: yMin - padY, yMax: yMax + padY };\n\n// --- Hexagonal binning (pointy-top axial grid, gridsize controls resolution) -\n// Binned and rendered entirely in PIXEL space (not data space): each point is\n// converted to its pixel position first, then assigned to an axial hex grid\n// with a fixed pixel radius. This keeps every hexagon geometrically regular\n// on screen regardless of the mismatch between the data x:y domain ratio and\n// the chart's actual pixel aspect ratio.\nconst GRIDSIZE = 24;\n\nfunction pointToHex(x, y, size) {\n  const q = ((Math.sqrt(3) / 3) * x - y / 3) / size;\n  const r = ((2 / 3) * y) / size;\n  return hexRound(q, r);\n}\nfunction hexRound(q, r) {\n  const s = -q - r;\n  let rq = Math.round(q);\n  let rr = Math.round(r);\n  const rs = Math.round(s);\n  const qDiff = Math.abs(rq - q);\n  const rDiff = Math.abs(rr - r);\n  const sDiff = Math.abs(rs - s);\n  if (qDiff > rDiff && qDiff > sDiff) rq = -rr - rs;\n  else if (rDiff > sDiff) rr = -rq - rs;\n  return { q: rq, r: rr };\n}\nfunction hexToPoint(q, r, size) {\n  return { x: size * Math.sqrt(3) * (q + r / 2), y: size * 1.5 * r };\n}\n\n// --- Density color: imprint_seq (brand green -> blue), log-scaled -----------\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };\n}\nconst seqLow = hexToRgb(t.seq[0]);\nconst seqHigh = hexToRgb(t.seq[1]);\nlet maxCount = 0;\nfunction densityColor(count) {\n  const ratio = Math.log1p(count) / Math.log1p(maxCount);\n  const r = Math.round(seqLow.r + (seqHigh.r - seqLow.r) * ratio);\n  const g = Math.round(seqLow.g + (seqHigh.g - seqLow.g) * ratio);\n  const b = Math.round(seqLow.b + (seqHigh.b - seqLow.b) * ratio);\n  return \"rgb(\" + r + \",\" + g + \",\" + b + \")\";\n}\n\n// --- Plugin: bin points (in pixel space) and draw the aggregated hexagons ---\n// over the (empty) scatter axes. colorbarPlugin's afterDraw runs right after\n// this afterDatasetsDraw within the same draw pass, so it always sees the\n// maxCount computed here.\nconst HEX_ANGLES = [0, 1, 2, 3, 4, 5].map((i) => (Math.PI / 180) * (60 * i - 30));\nconst hexbinPlugin = {\n  id: \"hexbinPlugin\",\n  afterDatasetsDraw(chart) {\n    const { ctx, scales, chartArea } = chart;\n    const hexSizePx = (chartArea.right - chartArea.left) / GRIDSIZE / Math.sqrt(3);\n\n    const bins = new Map();\n    for (const p of points) {\n      const px = scales.x.getPixelForValue(p.x);\n      const py = scales.y.getPixelForValue(p.y);\n      const { q, r } = pointToHex(px, py, hexSizePx);\n      const key = q + \",\" + r;\n      bins.set(key, (bins.get(key) || 0) + 1);\n    }\n    maxCount = 0;\n    const hexBins = [];\n    for (const [key, count] of bins) {\n      const [q, r] = key.split(\",\").map(Number);\n      const center = hexToPoint(q, r, hexSizePx);\n      hexBins.push({ x: center.x, y: center.y, count });\n      if (count > maxCount) maxCount = count;\n    }\n\n    ctx.save();\n    for (const bin of hexBins) {\n      ctx.beginPath();\n      HEX_ANGLES.forEach((angle, i) => {\n        const vx = bin.x + hexSizePx * Math.cos(angle);\n        const vy = bin.y + hexSizePx * Math.sin(angle);\n        if (i === 0) ctx.moveTo(vx, vy);\n        else ctx.lineTo(vx, vy);\n      });\n      ctx.closePath();\n      ctx.fillStyle = densityColor(bin.count);\n      ctx.fill();\n      ctx.strokeStyle = t.pageBg;\n      ctx.lineWidth = 1.5;\n      ctx.stroke();\n    }\n    ctx.restore();\n  },\n};\n\n// --- Plugin: density color bar (Chart.js has no built-in colorbar) ----------\nconst colorbarPlugin = {\n  id: \"colorbarPlugin\",\n  afterDraw(chart) {\n    const { ctx, chartArea } = chart;\n    const barWidth = 28;\n    const barX = chartArea.right + 34;\n    const barTop = chartArea.top;\n    const barHeight = chartArea.bottom - chartArea.top;\n\n    const gradient = ctx.createLinearGradient(0, chartArea.bottom, 0, barTop);\n    gradient.addColorStop(0, t.seq[0]);\n    gradient.addColorStop(1, t.seq[1]);\n\n    ctx.save();\n    ctx.fillStyle = gradient;\n    ctx.fillRect(barX, barTop, barWidth, barHeight);\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 1;\n    ctx.strokeRect(barX, barTop, barWidth, barHeight);\n\n    ctx.fillStyle = t.inkSoft;\n    ctx.font = \"14px sans-serif\";\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"middle\";\n    for (const frac of [0, 0.25, 0.5, 0.75, 1]) {\n      const count = Math.round(Math.expm1(frac * Math.log1p(maxCount)));\n      const py = chartArea.bottom - frac * barHeight;\n      ctx.beginPath();\n      ctx.moveTo(barX + barWidth, py);\n      ctx.lineTo(barX + barWidth + 5, py);\n      ctx.stroke();\n      ctx.fillText(String(count), barX + barWidth + 9, py);\n    }\n\n    ctx.translate(barX + barWidth + 48, barTop + barHeight / 2);\n    ctx.rotate(Math.PI / 2);\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"alphabetic\";\n    ctx.fillStyle = t.ink;\n    ctx.font = \"15px sans-serif\";\n    ctx.fillText(\"Points per bin\", 0, 0);\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart (empty scatter dataset supplies the axes; plugins draw the bins) -\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets: [{ data: [] }] },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { right: 150 } },\n    plugins: {\n      title: {\n        display: true,\n        text: \"hexbin-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22, weight: \"500\" },\n      },\n      legend: { display: false },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: domain.xMin,\n        max: domain.xMax,\n        title: { display: true, text: \"Distance East of Downtown (km)\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { color: t.grid },\n      },\n      y: {\n        type: \"linear\",\n        min: domain.yMin,\n        max: domain.yMax,\n        title: { display: true, text: \"Distance North of Downtown (km)\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { color: t.grid },\n      },\n    },\n  },\n  plugins: [hexbinPlugin, colorbarPlugin],\n});\n"}