{"spec_id":"hexbin-map-geographic","library":"d3","language":"javascript","code":"// anyplot.ai\n// hexbin-map-geographic: Hexagonal Binning Map\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-08-25\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Deterministic PRNG (LCG + Box-Muller) ----------------------------------\nlet seed = 42;\nfunction nextRandom() {\n  seed = (1103515245 * seed + 12345) % 2147483648;\n  return seed / 2147483648;\n}\nfunction randomGaussian() {\n  const u1 = Math.max(nextRandom(), 1e-6);\n  const u2 = nextRandom();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\nfunction clamp(value, lo, hi) {\n  return Math.max(lo, Math.min(hi, value));\n}\n\n// --- Data: air-quality sensor readings across a river-adjacent metro area --\nconst lonMin = -74.32;\nconst lonMax = -73.68;\nconst latMin = 40.58;\nconst latMax = 40.92;\n\nconst hotspots = [\n  { lon: -74.1, lat: 40.7, spread: 0.045, boost: 24 }, // industrial zone\n  { lon: -73.95, lat: 40.78, spread: 0.05, boost: 16 }, // highway interchange\n  { lon: -73.8, lat: 40.62, spread: 0.04, boost: 20 }, // downtown core\n];\n\nconst nPoints = 5000;\nconst points = [];\nfor (let i = 0; i < nPoints; i += 1) {\n  const roll = nextRandom();\n  let lon;\n  let lat;\n  let baseValue;\n  if (roll < 0.72) {\n    const hs = hotspots[Math.min(hotspots.length - 1, Math.floor(nextRandom() * hotspots.length))];\n    lon = hs.lon + randomGaussian() * hs.spread;\n    lat = hs.lat + randomGaussian() * hs.spread;\n    const dist = Math.hypot(lon - hs.lon, lat - hs.lat) / hs.spread;\n    baseValue = 7 + hs.boost * Math.exp(-0.5 * dist * dist) + randomGaussian() * 2;\n  } else {\n    lon = lonMin + nextRandom() * (lonMax - lonMin);\n    lat = latMin + nextRandom() * (latMax - latMin);\n    baseValue = 6 + randomGaussian() * 1.5;\n  }\n  points.push({\n    lon: clamp(lon, lonMin, lonMax),\n    lat: clamp(lat, latMin, latMax),\n    value: Math.max(1, baseValue),\n  });\n}\n\n// River waypoints, used only as base-map context under the hexagons\nconst riverLonLat = [\n  [-74.3, 40.6],\n  [-74.18, 40.66],\n  [-74.05, 40.72],\n  [-73.94, 40.78],\n  [-73.82, 40.84],\n  [-73.7, 40.9],\n];\n\n// Street waypoints — a second base-map context cue alongside the river, thin\n// and neutral so they read as street lines rather than data\nconst streetLonLats = [\n  [\n    [-74.3, 40.615],\n    [-74.0, 40.622],\n    [-73.75, 40.615],\n    [-73.68, 40.605],\n  ],\n  [\n    [-73.98, 40.58],\n    [-73.965, 40.7],\n    [-73.945, 40.78],\n    [-73.9, 40.92],\n  ],\n];\n\n// --- Layout -------------------------------------------------------------\nconst margin = { top: 110, right: 230, bottom: 90, left: 90 };\nconst mapW = width - margin.left - margin.right;\nconst mapH = height - margin.top - margin.bottom;\n\nconst xScale = d3.scaleLinear().domain([lonMin, lonMax]).range([0, mapW]);\nconst yScale = d3.scaleLinear().domain([latMin, latMax]).range([mapH, 0]);\n\n// --- Hexagonal binning (axial cube-rounding, pointy-top hexagons) ----------\nconst hexSize = 24; // center-to-corner, CSS px\n\nfunction cubeRound(qf, rf) {\n  const xf = qf;\n  const zf = rf;\n  const yf = -xf - zf;\n  let rx = Math.round(xf);\n  let ry = Math.round(yf);\n  let rz = Math.round(zf);\n  const dx = Math.abs(rx - xf);\n  const dy = Math.abs(ry - yf);\n  const dz = Math.abs(rz - zf);\n  if (dx > dy && dx > dz) rx = -ry - rz;\n  else if (dy > dz) ry = -rx - rz;\n  else rz = -rx - ry;\n  return [rx, rz];\n}\n\nfunction pixelToAxial(px, py, size) {\n  const q = ((Math.sqrt(3) / 3) * px - py / 3) / size;\n  const r = ((2 / 3) * py) / size;\n  return cubeRound(q, r);\n}\n\nfunction axialToPixel(q, r, size) {\n  return [size * (Math.sqrt(3) * q + (Math.sqrt(3) / 2) * r), size * 1.5 * r];\n}\n\nfunction hexPoints(cx, cy, size) {\n  return d3.range(6).map((i) => {\n    const angle = (Math.PI / 180) * (60 * i - 30);\n    return [cx + size * Math.cos(angle), cy + size * Math.sin(angle)];\n  });\n}\n\nconst bins = new Map();\nfor (const p of points) {\n  const px = xScale(p.lon);\n  const py = yScale(p.lat);\n  const [q, r] = pixelToAxial(px, py, hexSize);\n  const key = `${q},${r}`;\n  let bin = bins.get(key);\n  if (!bin) {\n    bin = { q, r, count: 0, sum: 0 };\n    bins.set(key, bin);\n  }\n  bin.count += 1;\n  bin.sum += p.value;\n}\nconst hexData = Array.from(bins.values()).map((b) => {\n  const center = axialToPixel(b.q, b.r, hexSize);\n  return { ...b, mean: b.sum / b.count, center };\n});\n\nconst meanExtent = d3.extent(hexData, (d) => d.mean);\nconst countMax = d3.max(hexData, (d) => d.count);\nconst color = d3.scaleSequential(d3.interpolateRgbBasis(t.seq)).domain(meanExtent);\nconst hexOpacity = d3.scaleLinear().domain([1, countMax]).range([0.55, 0.92]).clamp(true);\n// Sparse cells get a firmer stroke to stay legible against the page background;\n// dense, high-fill-opacity cells get a near-invisible stroke so the frame\n// doesn't compete with the color/opacity encoding.\nconst hexStrokeOpacity = d3.scaleLinear().domain([1, countMax]).range([0.85, 0.18]).clamp(true);\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\nsvg\n  .append(\"defs\")\n  .append(\"clipPath\")\n  .attr(\"id\", \"map-clip\")\n  .append(\"rect\")\n  .attr(\"width\", mapW)\n  .attr(\"height\", mapH);\nconst mapG = g.append(\"g\").attr(\"clip-path\", \"url(#map-clip)\");\n\n// River — geographic base-map context, drawn under the hexagons\nconst riverLine = d3\n  .line()\n  .x((d) => xScale(d[0]))\n  .y((d) => yScale(d[1]))\n  .curve(d3.curveBasis);\nmapG\n  .append(\"path\")\n  .datum(riverLonLat)\n  .attr(\"d\", riverLine)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 46)\n  .attr(\"stroke-linecap\", \"round\")\n  .attr(\"stroke-linejoin\", \"round\")\n  .attr(\"opacity\", 0.22);\n\n// Streets — thin neutral lines crossing the hotspots, a second geographic\n// context cue so the map reads as more than a coordinate grid with one\n// decoration\nconst streetLine = d3\n  .line()\n  .x((d) => xScale(d[0]))\n  .y((d) => yScale(d[1]))\n  .curve(d3.curveBasis);\nmapG\n  .selectAll(\"path.street\")\n  .data(streetLonLats)\n  .join(\"path\")\n  .attr(\"class\", \"street\")\n  .attr(\"d\", streetLine)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 3)\n  .attr(\"stroke-linecap\", \"round\")\n  .attr(\"stroke-dasharray\", \"2,5\")\n  .attr(\"opacity\", 0.4);\n\n// Tooltip — real hover, only live in the exported interactive HTML\nconst tooltip = d3\n  .select(\"#container\")\n  .append(\"div\")\n  .style(\"position\", \"fixed\")\n  .style(\"pointer-events\", \"none\")\n  .style(\"opacity\", 0)\n  .style(\"background\", t.elevatedBg)\n  .style(\"color\", t.ink)\n  .style(\"border\", `1px solid ${t.grid}`)\n  .style(\"border-radius\", \"6px\")\n  .style(\"padding\", \"8px 10px\")\n  .style(\"font-size\", \"13px\")\n  .style(\"font-family\", \"sans-serif\")\n  .style(\"line-height\", \"1.5\")\n  .style(\"box-shadow\", \"0 2px 8px rgba(0,0,0,0.25)\")\n  .style(\"transition\", \"opacity 0.1s linear\");\n\n// Hexagons — mean PM2.5 per cell, with count-driven transparency so the\n// river shows through sparsely-sampled bins\nmapG\n  .selectAll(\"polygon.hex\")\n  .data(hexData)\n  .join(\"polygon\")\n  .attr(\"class\", \"hex\")\n  .attr(\"points\", (d) =>\n    hexPoints(d.center[0], d.center[1], hexSize * 0.96)\n      .map((p) => p.join(\",\"))\n      .join(\" \"),\n  )\n  .attr(\"fill\", (d) => color(d.mean))\n  .attr(\"fill-opacity\", (d) => hexOpacity(d.count))\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.2)\n  .attr(\"stroke-opacity\", (d) => hexStrokeOpacity(d.count))\n  .style(\"cursor\", \"pointer\")\n  .on(\"mouseenter\", function (event, d) {\n    d3.select(this).attr(\"stroke\", t.ink).attr(\"stroke-width\", 2).attr(\"stroke-opacity\", 1);\n    tooltip.style(\"opacity\", 1);\n  })\n  .on(\"mousemove\", function (event, d) {\n    const lon = xScale.invert(d.center[0]);\n    const lat = yScale.invert(d.center[1]);\n    tooltip\n      .style(\"left\", `${event.clientX + 16}px`)\n      .style(\"top\", `${event.clientY + 16}px`)\n      .html(\n        `<strong>Count:</strong> ${d.count}<br>` +\n          `<strong>Sum:</strong> ${d.sum.toFixed(1)} µg/m³<br>` +\n          `<strong>Mean:</strong> ${d.mean.toFixed(1)} µg/m³<br>` +\n          `<strong>Center:</strong> ${lon.toFixed(3)}°, ${lat.toFixed(3)}°`,\n      );\n  })\n  .on(\"mouseleave\", function (event, d) {\n    d3.select(this)\n      .attr(\"stroke\", t.pageBg)\n      .attr(\"stroke-width\", 1.2)\n      .attr(\"stroke-opacity\", hexStrokeOpacity(d.count));\n    tooltip.style(\"opacity\", 0);\n  });\n\n// --- Map frame + lon/lat axes -----------------------------------------\ng.append(\"rect\")\n  .attr(\"width\", mapW)\n  .attr(\"height\", mapH)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1)\n  .attr(\"stroke-opacity\", 0.5);\n\nconst lonAxis = d3\n  .axisBottom(xScale)\n  .ticks(6)\n  .tickFormat((d) => `${d.toFixed(2)}°`);\nconst latAxis = d3\n  .axisLeft(yScale)\n  .ticks(5)\n  .tickFormat((d) => `${d.toFixed(2)}°`);\n\nconst lonAxisG = g.append(\"g\").attr(\"transform\", `translate(0,${mapH})`).call(lonAxis);\nconst latAxisG = g.append(\"g\").call(latAxis);\nfor (const axisG of [lonAxisG, latAxisG]) {\n  axisG.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  axisG.selectAll(\"line\").attr(\"stroke\", t.grid);\n  axisG.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\n\ng.append(\"text\")\n  .attr(\"x\", mapW / 2)\n  .attr(\"y\", mapH + 56)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Longitude\");\n\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -mapH / 2)\n  .attr(\"y\", -56)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Latitude\");\n\n// --- Legend --------------------------------------------------------------\nconst legendW = 26;\nconst legendH = mapH * 0.6;\nconst legendX = mapW + 55;\nconst legendY = (mapH - legendH) / 2;\nconst nStops = 10;\n\nsvg\n  .append(\"defs\")\n  .append(\"linearGradient\")\n  .attr(\"id\", \"legend-gradient\")\n  .attr(\"x1\", \"0%\")\n  .attr(\"y1\", \"100%\")\n  .attr(\"x2\", \"0%\")\n  .attr(\"y2\", \"0%\")\n  .selectAll(\"stop\")\n  .data(d3.range(nStops + 1))\n  .join(\"stop\")\n  .attr(\"offset\", (d) => `${(d / nStops) * 100}%`)\n  .attr(\"stop-color\", (d) => color(meanExtent[0] + (d / nStops) * (meanExtent[1] - meanExtent[0])));\n\nconst legendG = g.append(\"g\").attr(\"transform\", `translate(${legendX},${legendY})`);\nlegendG\n  .append(\"rect\")\n  .attr(\"width\", legendW)\n  .attr(\"height\", legendH)\n  .attr(\"fill\", \"url(#legend-gradient)\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1);\n\nconst legendScale = d3.scaleLinear().domain(meanExtent).range([legendH, 0]);\nconst legendAxis = d3\n  .axisRight(legendScale)\n  .ticks(5)\n  .tickFormat((d) => d.toFixed(0));\nconst legendAxisG = legendG.append(\"g\").attr(\"transform\", `translate(${legendW},0)`).call(legendAxis);\nlegendAxisG.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\nlegendAxisG.selectAll(\"line\").attr(\"stroke\", t.grid);\nlegendAxisG.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\nlegendG\n  .append(\"text\")\n  .attr(\"x\", legendW / 2)\n  .attr(\"y\", -18)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Mean PM2.5\");\nlegendG\n  .append(\"text\")\n  .attr(\"x\", legendW / 2)\n  .attr(\"y\", -2)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\"(µg/m³)\");\n\n// --- Title -----------------------------------------------------------------\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\", \"24px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"hexbin-map-geographic · javascript · d3 · anyplot.ai\");\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 78)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"Color: mean PM2.5 · Opacity: sample count\");\n"}