{"spec_id":"heatmap-geographic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// heatmap-geographic: Geographic Heatmap for Spatial Density\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-02\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Deterministic PRNG (32-bit LCG, Math.imul avoids float precision loss) -\nlet seed = 42;\nfunction rand() {\n  seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\nfunction gaussian() {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\n// --- Data: simulated incident reports across a downtown bounding box -------\nconst LON_MIN = -122.52;\nconst LON_MAX = -122.38;\nconst LAT_MIN = 37.73;\nconst LAT_MAX = 37.81;\n\nconst hotspots = [\n  { lon: -122.412, lat: 37.784, weight: 0.42, spreadLon: 0.012, spreadLat: 0.01 }, // downtown core\n  { lon: -122.404, lat: 37.792, weight: 0.24, spreadLon: 0.01, spreadLat: 0.008 }, // financial district\n  { lon: -122.431, lat: 37.775, weight: 0.2, spreadLon: 0.014, spreadLat: 0.011 }, // tenderloin\n  { lon: -122.393, lat: 37.778, weight: 0.14, spreadLon: 0.013, spreadLat: 0.01 }, // transit hub\n];\n\nconst incidents = [];\nfor (let i = 0; i < 900; i++) {\n  const r = rand();\n  let acc = 0;\n  let cluster = hotspots[hotspots.length - 1];\n  for (const h of hotspots) {\n    acc += h.weight;\n    if (r <= acc) {\n      cluster = h;\n      break;\n    }\n  }\n  const lon = cluster.lon + gaussian() * cluster.spreadLon;\n  const lat = cluster.lat + gaussian() * cluster.spreadLat;\n  if (lon < LON_MIN || lon > LON_MAX || lat < LAT_MIN || lat > LAT_MAX) continue;\n  incidents.push({ lon, lat, severity: 0.6 + rand() * 0.4 });\n}\n\n// --- Kernel density estimation on a regular grid ----------------------------\nconst COLS = 28;\nconst ROWS = 16;\nconst bwLon = (LON_MAX - LON_MIN) / 9;\nconst bwLat = (LAT_MAX - LAT_MIN) / 9;\n\nconst cells = [];\nlet maxDensity = 0;\nfor (let cy = 0; cy < ROWS; cy++) {\n  for (let cx = 0; cx < COLS; cx++) {\n    const lon = LON_MIN + (cx + 0.5) * ((LON_MAX - LON_MIN) / COLS);\n    const lat = LAT_MIN + (cy + 0.5) * ((LAT_MAX - LAT_MIN) / ROWS);\n    let density = 0;\n    for (const p of incidents) {\n      const dx = (p.lon - lon) / bwLon;\n      const dy = (p.lat - lat) / bwLat;\n      density += p.severity * Math.exp(-0.5 * (dx * dx + dy * dy));\n    }\n    if (density > maxDensity) maxDensity = density;\n    cells.push({ lon, lat, density });\n  }\n}\n\n// Hide near-zero cells so the page background reads through, like a basemap.\nconst DENSITY_FLOOR = 0.06;\nconst points = cells\n  .map((c) => ({ x: c.lon, y: c.lat, d: c.density / maxDensity }))\n  .filter((c) => c.d > DENSITY_FLOOR);\n\n// --- Imprint sequential colormap (brand green -> blue) ----------------------\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}\nfunction lerpRGB(hexA, hexB, f) {\n  const a = [1, 3, 5].map((i) => parseInt(hexA.slice(i, i + 2), 16));\n  const b = [1, 3, 5].map((i) => parseInt(hexB.slice(i, i + 2), 16));\n  return a.map((v, i) => Math.round(v + (b[i] - v) * f));\n}\nfunction densityColor(f, alpha) {\n  const [r, g, b] = lerpRGB(t.seq[0], t.seq[1], f);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\n\n// Radius approximates half the grid spacing so hotspot cells read as a\n// continuous mosaic while sparse cells stay small and let the page bg show.\n// The alpha floor (0.55) keeps low-density edge cells visible against a\n// near-black dark-theme background instead of fading out entirely.\nconst CELL_RADIUS = 21;\nconst bubbleData = points.map((p) => ({\n  x: p.x,\n  y: p.y,\n  r: CELL_RADIUS * (0.6 + 0.4 * p.d),\n  d: p.d,\n}));\n\n// --- Simplified Pacific coastline for basemap context (rough, schematic) ---\n// Ocean Beach runs roughly along -122.51 deg; sketched as a gentle\n// north-south curve at the low-longitude edge of the bounding box, well\n// west of every hotspot so it never competes with the density bubbles.\nconst COASTLINE = [\n  { lon: -122.508, lat: LAT_MIN },\n  { lon: -122.512, lat: 37.75 },\n  { lon: -122.509, lat: 37.765 },\n  { lon: -122.513, lat: 37.78 },\n  { lon: -122.51, lat: 37.795 },\n  { lon: -122.507, lat: LAT_MAX },\n];\n\nconst basemapPlugin = {\n  id: \"basemap\",\n  beforeDatasetsDraw(chart) {\n    const {\n      ctx,\n      scales: { x, y },\n      chartArea: ca,\n    } = chart;\n    const coastPx = COASTLINE.map((p) => [x.getPixelForValue(p.lon), y.getPixelForValue(p.lat)]);\n\n    ctx.save();\n    ctx.beginPath();\n    coastPx.forEach(([px, py], i) => (i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py)));\n    ctx.lineTo(ca.left, ca.top);\n    ctx.lineTo(ca.left, ca.bottom);\n    ctx.closePath();\n    ctx.fillStyle = hexToRgba(t.seq[1], 0.1);\n    ctx.fill();\n\n    ctx.beginPath();\n    coastPx.forEach(([px, py], i) => (i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py)));\n    ctx.strokeStyle = hexToRgba(t.ink, 0.3);\n    ctx.lineWidth = 1.5;\n    ctx.stroke();\n\n    ctx.save();\n    ctx.translate(ca.left + 24, (ca.top + ca.bottom) / 2);\n    ctx.rotate(-Math.PI / 2);\n    ctx.font = \"italic 13px sans-serif\";\n    ctx.fillStyle = hexToRgba(t.inkSoft, 0.9);\n    ctx.textAlign = \"center\";\n    ctx.fillText(\"Pacific Ocean\", 0, 0);\n    ctx.restore();\n    ctx.restore();\n  },\n};\n\n// --- Density colorbar (replaces a discrete-band legend with a true scale) --\nconst colorbarPlugin = {\n  id: \"colorbar\",\n  afterDraw(chart) {\n    const { ctx, chartArea: ca } = chart;\n    const barX = ca.right + 24;\n    const barW = 22;\n    const barH = ca.bottom - ca.top;\n\n    ctx.save();\n    const grad = ctx.createLinearGradient(0, ca.bottom, 0, ca.top);\n    grad.addColorStop(0, t.seq[0]);\n    grad.addColorStop(1, t.seq[1]);\n    ctx.fillStyle = grad;\n    ctx.fillRect(barX, ca.top, barW, barH);\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 1;\n    ctx.strokeRect(barX, ca.top, barW, barH);\n\n    ctx.fillStyle = t.ink;\n    ctx.font = \"bold 15px sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.fillText(\"Density\", barX + barW / 2, ca.top - 14);\n\n    const TICK_INSET = 10;\n    const ticks = [\n      { f: 1, label: \"High\" },\n      { f: 0.5, label: \"Medium\" },\n      { f: 0, label: \"Low\" },\n    ];\n    ctx.font = \"13px sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.strokeStyle = t.inkSoft;\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"middle\";\n    for (const { f, label } of ticks) {\n      const ty = ca.bottom - TICK_INSET - f * (barH - 2 * TICK_INSET);\n      ctx.beginPath();\n      ctx.moveTo(barX + barW, ty);\n      ctx.lineTo(barX + barW + 5, ty);\n      ctx.stroke();\n      ctx.fillText(label, barX + barW + 8, ty);\n    }\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart -------------------------------------------------------------------\nconst chart = new Chart(canvas, {\n  type: \"bubble\",\n  data: {\n    datasets: [\n      {\n        label: \"Incident density\",\n        data: bubbleData,\n        backgroundColor: bubbleData.map((p) => densityColor(p.d, 0.5 + 0.4 * p.d)),\n        borderWidth: 0,\n      },\n    ],\n  },\n  plugins: [basemapPlugin, colorbarPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { right: 110, top: 32, bottom: 4 } },\n    plugins: {\n      title: {\n        display: true,\n        text: \"heatmap-geographic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n      },\n      legend: { display: false },\n    },\n    scales: {\n      x: {\n        min: LON_MIN,\n        max: LON_MAX,\n        ticks: { color: t.inkSoft, font: { size: 14 }, callback: (v) => `${Number(v).toFixed(2)}°` },\n        grid: { color: t.grid },\n        title: { display: true, text: \"Longitude\", color: t.ink, font: { size: 16 } },\n      },\n      y: {\n        min: LAT_MIN,\n        max: LAT_MAX,\n        ticks: { color: t.inkSoft, font: { size: 14 }, callback: (v) => `${Number(v).toFixed(2)}°` },\n        grid: { color: t.grid },\n        title: { display: true, text: \"Latitude\", color: t.ink, font: { size: 16 } },\n      },\n    },\n  },\n});\n\n// --- Zoom & pan (native wheel/drag, no external plugin) ---------------------\n// The spec asks interactive libraries to let users explore density at\n// different scales; chartjs-plugin-zoom isn't installed in this runtime, so\n// wheel-to-zoom and drag-to-pan are wired directly onto the linear scales.\ncanvas.style.cursor = \"grab\";\nlet isPanning = false;\nlet lastX = 0;\nlet lastY = 0;\ncanvas.addEventListener(\"mousedown\", (evt) => {\n  isPanning = true;\n  lastX = evt.offsetX;\n  lastY = evt.offsetY;\n  canvas.style.cursor = \"grabbing\";\n});\ncanvas.addEventListener(\"mousemove\", (evt) => {\n  if (!isPanning) return;\n  const { x: xScale, y: yScale } = chart.scales;\n  const dLon = xScale.getValueForPixel(lastX) - xScale.getValueForPixel(evt.offsetX);\n  const dLat = yScale.getValueForPixel(lastY) - yScale.getValueForPixel(evt.offsetY);\n  xScale.options.min += dLon;\n  xScale.options.max += dLon;\n  yScale.options.min += dLat;\n  yScale.options.max += dLat;\n  lastX = evt.offsetX;\n  lastY = evt.offsetY;\n  chart.update(\"none\");\n});\n[\"mouseup\", \"mouseleave\"].forEach((evtName) =>\n  canvas.addEventListener(evtName, () => {\n    isPanning = false;\n    canvas.style.cursor = \"grab\";\n  }),\n);\ncanvas.addEventListener(\n  \"wheel\",\n  (evt) => {\n    evt.preventDefault();\n    const { x: xScale, y: yScale } = chart.scales;\n    const zoomFactor = evt.deltaY < 0 ? 0.9 : 1.1;\n    const cursorLon = xScale.getValueForPixel(evt.offsetX);\n    const cursorLat = yScale.getValueForPixel(evt.offsetY);\n    xScale.options.min = cursorLon + (xScale.min - cursorLon) * zoomFactor;\n    xScale.options.max = cursorLon + (xScale.max - cursorLon) * zoomFactor;\n    yScale.options.min = cursorLat + (yScale.min - cursorLat) * zoomFactor;\n    yScale.options.max = cursorLat + (yScale.max - cursorLat) * zoomFactor;\n    chart.update(\"none\");\n  },\n  { passive: false },\n);\n"}