{"spec_id":"hexbin-map-geographic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// hexbin-map-geographic: Hexagonal Binning Map\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-08-25\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Deterministic PRNG (fixed-seed LCG + Box-Muller) -----------------------\nfunction makeLcg(seed) {\n  let state = seed >>> 0;\n  return function lcg() {\n    state = (Math.imul(state, 1664525) + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rand = makeLcg(42);\nfunction gaussian(mean, std) {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * std;\n}\n\n// --- Geography: bounding box + simplified Central California coastline -----\nconst LAT_MIN = 34.0;\nconst LAT_MAX = 38.5;\nconst LON_MIN = -124.6;\nconst LON_MAX = -119.6;\n\n// [lat, lon] north -> south, deliberately simplified for map context (not navigational)\nconst COASTLINE = [\n  [38.3, -123.05],\n  [37.99, -122.98],\n  [37.77, -122.51],\n  [37.2, -122.4],\n  [36.97, -122.03],\n  [36.6, -121.9],\n  [36.27, -121.85],\n  [35.66, -121.28],\n  [35.15, -120.72],\n  [34.65, -120.47],\n  [34.42, -119.85],\n];\n\nfunction coastLonAtLat(lat) {\n  for (let i = 0; i < COASTLINE.length - 1; i++) {\n    const [latA, lonA] = COASTLINE[i];\n    const [latB, lonB] = COASTLINE[i + 1];\n    if (lat <= latA && lat >= latB) {\n      const f = (latA - lat) / (latA - latB);\n      return lonA + (lonB - lonA) * f;\n    }\n  }\n  return COASTLINE[COASTLINE.length - 1][1];\n}\n\n// --- Sightings: gray whale pods offshore, clustered near known feeding grounds\nconst HOTSPOTS = [\n  { lat: 37.85, lon: -123.55, weight: 260 }, // Cordell Bank / Farallones\n  { lat: 36.78, lon: -122.25, weight: 300 }, // Monterey Bay submarine canyon\n  { lat: 35.35, lon: -121.35, weight: 190 }, // offshore Big Sur / Cambria\n  { lat: 34.55, lon: -120.55, weight: 160 }, // Point Conception approach\n];\nconst BACKGROUND_COUNT = 45;\n\nconst sightings = [];\nfor (const spot of HOTSPOTS) {\n  for (let i = 0; i < spot.weight; i++) {\n    sightings.push({ lat: gaussian(spot.lat, 0.24), lon: gaussian(spot.lon, 0.24) });\n  }\n}\nfor (let i = 0; i < BACKGROUND_COUNT; i++) {\n  sightings.push({\n    lat: LAT_MIN + rand() * (LAT_MAX - LAT_MIN),\n    lon: LON_MIN + rand() * (LON_MAX - LON_MIN),\n  });\n}\n\nconst oceanSightings = sightings\n  .filter((p) => p.lat >= LAT_MIN && p.lat <= LAT_MAX && p.lon >= LON_MIN && p.lon <= LON_MAX)\n  .filter((p) => p.lon <= coastLonAtLat(p.lat) - 0.05)\n  .map((p) => ({ ...p, pod: 1 + Math.floor(rand() * 6) })); // individuals per sighting\n\n// --- Hexagonal binning (pointy-top axial grid) ------------------------------\nconst HEX_COLUMNS = 12;\nconst HEX_SIZE = (LON_MAX - LON_MIN) / (HEX_COLUMNS * Math.sqrt(3)); // circumradius, degrees\n\nfunction toHex(x, y) {\n  const q = ((Math.sqrt(3) / 3) * x - (1 / 3) * y) / HEX_SIZE;\n  const r = ((2 / 3) * y) / HEX_SIZE;\n  const cy = -q - r;\n  let rx = Math.round(q);\n  let ry = Math.round(cy);\n  let rz = Math.round(r);\n  const dx = Math.abs(rx - q);\n  const dy = Math.abs(ry - cy);\n  const dz = Math.abs(rz - r);\n  if (dx > dy && dx > dz) rx = -ry - rz;\n  else if (dy > dz) ry = -rx - rz;\n  else rz = -rx - ry;\n  return { q: rx, r: rz };\n}\nfunction hexCenter(q, r) {\n  return {\n    x: HEX_SIZE * (Math.sqrt(3) * q + (Math.sqrt(3) / 2) * r),\n    y: HEX_SIZE * 1.5 * r,\n  };\n}\n\nconst bins = new Map();\nfor (const s of oceanSightings) {\n  const x = s.lon - LON_MIN;\n  const y = s.lat - LAT_MIN;\n  const { q, r } = toHex(x, y);\n  const key = `${q},${r}`;\n  if (!bins.has(key)) bins.set(key, { q, r, count: 0, podSum: 0 });\n  const bin = bins.get(key);\n  bin.count += 1;\n  bin.podSum += s.pod;\n}\n\nconst hexBins = Array.from(bins.values()).map((bin) => {\n  const center = hexCenter(bin.q, bin.r);\n  return {\n    x: center.x + LON_MIN,\n    y: center.y + LAT_MIN,\n    count: bin.count,\n    podSum: bin.podSum,\n    podMean: bin.podSum / bin.count,\n  };\n});\n\nconst maxCount = Math.max(...hexBins.map((b) => b.count));\n\n// --- Color: imprint_seq (sequential, single-polarity density) --------------\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\nfunction seqColor(ratio, alpha) {\n  const [r0, g0, b0] = hexToRgb(t.seq[0]);\n  const [r1, g1, b1] = hexToRgb(t.seq[1]);\n  const r = Math.round(r0 + (r1 - r0) * ratio);\n  const g = Math.round(g0 + (g1 - g0) * ratio);\n  const b = Math.round(b0 + (b1 - b0) * ratio);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\n\n// --- Hexagon marker icons (pointy-top, pre-rendered per bin) ---------------\nconst ICON_PX = 78;\nfunction hexIcon(color) {\n  const iconCanvas = document.createElement(\"canvas\");\n  iconCanvas.width = ICON_PX;\n  iconCanvas.height = ICON_PX;\n  const ctx = iconCanvas.getContext(\"2d\");\n  const cx = ICON_PX / 2;\n  const cy = ICON_PX / 2;\n  const radius = ICON_PX / 2 - 1;\n  ctx.beginPath();\n  for (let i = 0; i < 6; i++) {\n    const angle = (Math.PI / 3) * i - Math.PI / 2;\n    const px = cx + radius * Math.cos(angle);\n    const py = cy + radius * Math.sin(angle);\n    if (i === 0) ctx.moveTo(px, py);\n    else ctx.lineTo(px, py);\n  }\n  ctx.closePath();\n  ctx.fillStyle = color;\n  ctx.fill();\n  ctx.lineWidth = 1.75;\n  ctx.strokeStyle = t.pageBg;\n  ctx.stroke();\n  return iconCanvas;\n}\n\nconst hexPointStyles = hexBins.map((b) => hexIcon(seqColor(b.count / maxCount, 0.82)));\nconst peakBin = hexBins.find((b) => b.count === maxCount);\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Custom plugin: sequential color-scale legend ---------------------------\nconst colorbarPlugin = {\n  id: \"imprintColorbar\",\n  afterDraw(chart) {\n    const { ctx, chartArea } = chart;\n    const barW = 26;\n    const barH = 190;\n    const x = chartArea.right - barW - 90;\n    const y = chartArea.bottom - barH - 46;\n\n    ctx.save();\n    const gradient = ctx.createLinearGradient(0, y, 0, y + barH);\n    gradient.addColorStop(0, seqColor(1, 0.9));\n    gradient.addColorStop(1, seqColor(0, 0.9));\n    ctx.fillStyle = gradient;\n    ctx.fillRect(x, y, barW, barH);\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 1;\n    ctx.strokeRect(x, y, barW, barH);\n\n    ctx.fillStyle = t.ink;\n    ctx.font = \"13px sans-serif\";\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"middle\";\n    ctx.fillText(String(maxCount), x + barW + 8, y);\n    ctx.fillText(\"0\", x + barW + 8, y + barH);\n\n    ctx.save();\n    ctx.translate(x - 8, y + barH / 2);\n    ctx.rotate(-Math.PI / 2);\n    ctx.textAlign = \"center\";\n    ctx.fillText(\"Sightings / hex\", 0, 0);\n    ctx.restore();\n    ctx.restore();\n  },\n};\n\n// --- Custom plugin: numeric callout on the single highest-density cell -----\nconst peakLabelPlugin = {\n  id: \"peakLabel\",\n  afterDraw(chart) {\n    const { ctx, scales } = chart;\n    const px = scales.x.getPixelForValue(peakBin.x);\n    const py = scales.y.getPixelForValue(peakBin.y);\n\n    ctx.save();\n    ctx.fillStyle = \"#FFFFFF\";\n    ctx.font = \"bold 15px sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    ctx.shadowColor = \"rgba(0, 0, 0, 0.6)\";\n    ctx.shadowBlur = 3;\n    ctx.fillText(String(peakBin.count), px, py);\n    ctx.restore();\n  },\n};\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      {\n        label: \"Coastline\",\n        type: \"line\",\n        data: COASTLINE.map(([lat, lon]) => ({ x: lon, y: lat })),\n        showLine: true,\n        borderColor: t.inkSoft,\n        borderWidth: 2.5,\n        pointRadius: 0,\n        tension: 0.3,\n        order: 1,\n      },\n      {\n        label: \"Sighting density\",\n        data: hexBins,\n        pointStyle: hexPointStyles,\n        pointRadius: ICON_PX / 2,\n        pointHoverRadius: ICON_PX / 2 + 3,\n        order: 2,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 8, right: 16, bottom: 8, left: 8 } },\n    plugins: {\n      title: {\n        display: true,\n        text: \"hexbin-map-geographic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n      },\n      legend: {\n        labels: {\n          color: t.ink,\n          font: { size: 16 },\n          generateLabels: (chart) => {\n            const coastline = chart.data.datasets[0];\n            return [\n              {\n                text: coastline.label,\n                strokeStyle: coastline.borderColor,\n                lineWidth: coastline.borderWidth,\n                fillStyle: \"transparent\",\n                pointStyle: \"line\",\n                datasetIndex: 0,\n              },\n            ];\n          },\n        },\n      },\n      tooltip: {\n        callbacks: {\n          title: () => \"Hex cell\",\n          label: (ctx) => {\n            const d = ctx.raw;\n            if (d.count === undefined) {\n              return `${d.y.toFixed(2)}°N, ${Math.abs(d.x).toFixed(2)}°W`;\n            }\n            return [\n              `Sightings: ${d.count}`,\n              `Individuals (sum): ${d.podSum}`,\n              `Pod size (mean): ${d.podMean.toFixed(1)}`,\n              `Center: ${d.y.toFixed(2)}°N, ${Math.abs(d.x).toFixed(2)}°W`,\n            ];\n          },\n        },\n      },\n    },\n    scales: {\n      x: {\n        min: LON_MIN - HEX_SIZE,\n        max: LON_MAX + HEX_SIZE,\n        title: { display: true, text: \"Longitude (°W)\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 14 }, callback: (v) => Math.abs(v).toFixed(1) },\n        grid: { color: t.grid },\n      },\n      y: {\n        min: LAT_MIN - HEX_SIZE,\n        max: LAT_MAX + HEX_SIZE,\n        title: { display: true, text: \"Latitude (°N)\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { color: t.grid },\n      },\n    },\n  },\n  plugins: [colorbarPlugin, peakLabelPlugin],\n});\n"}