{"spec_id":"hexbin-map-geographic","library":"muix","language":"javascript","code":"// anyplot.ai\n// hexbin-map-geographic: Hexagonal Binning Map\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-08-25\n//# anyplot-orientation: square\n// anyplot.ai\n// hexbin-map-geographic: Whale & Dolphin Sighting Density, Southern California Coast\n// Library: MUI X Charts | React | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-08-25\nimport { ScatterChart } from \"@mui/x-charts/ScatterChart\";\nimport { useXScale, useYScale } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst THEME = window.ANYPLOT_THEME === \"dark\" ? \"dark\" : \"light\";\nconst INK_MUTED = THEME === \"dark\" ? \"#A8A79F\" : \"#6B6A63\";\n\n// --- Geographic domain: Southern California coast, Point Conception to San\n// Diego. The community @mui/x-charts surface has no polygon/basemap\n// primitive, so lon/lat are plotted directly as linear axes (a simple\n// equirectangular projection) and the coastline is hand-traced from ~14\n// approximate real vertices via the chart's own xScale/yScale hooks — the\n// same technique used to place any custom SVG overlay on a MUI X chart. ----\nconst LON_MIN = -120.6;\nconst LON_MAX = -117.0;\nconst LAT_MIN = 32.45;\nconst LAT_MAX = 34.55;\n\n// North -> south, approximate real coastline vertices (degrees).\nconst COASTLINE = [\n  [-120.47, 34.45], // Point Conception\n  [-119.84, 34.41], // Santa Barbara\n  [-119.3, 34.28], // Ventura\n  [-119.1, 34.09], // Point Mugu\n  [-118.8, 34.03], // Malibu\n  [-118.5, 33.99], // Santa Monica\n  [-118.41, 33.74], // Palos Verdes\n  [-118.19, 33.75], // Long Beach\n  [-117.93, 33.6], // Newport Beach\n  [-117.7, 33.46], // Dana Point\n  [-117.38, 33.2], // Oceanside\n  [-117.27, 32.85], // La Jolla\n  [-117.17, 32.72], // San Diego Bay\n  [-117.13, 32.55], // Point Loma\n];\n\n// --- Deterministic PRNG (mulberry32) — the browser has no seeded RNG ------\nlet seed = 42;\nfunction rand() {\n  seed |= 0;\n  seed = (seed + 0x6d2b79f5) | 0;\n  let x = Math.imul(seed ^ (seed >>> 15), 1 | seed);\n  x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;\n  return ((x ^ (x >>> 14)) >>> 0) / 4294967296;\n}\nfunction gaussian() {\n  const u = Math.max(rand(), 1e-9);\n  const v = rand();\n  return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);\n}\n\n// Coastline longitude at a given latitude, by linear interpolation between\n// the traced vertices — used to keep synthetic sightings offshore.\nfunction coastLonAt(lat) {\n  for (let i = 0; i < COASTLINE.length - 1; i += 1) {\n    const [lonA, latA] = COASTLINE[i];\n    const [lonB, latB] = COASTLINE[i + 1];\n    if ((lat <= latA && lat >= latB) || (lat >= latA && lat <= latB)) {\n      const f = (lat - latA) / (latB - latA);\n      return lonA + f * (lonB - lonA);\n    }\n  }\n  // Outside the traced range: snap to whichever end (north/Point Conception\n  // or south/Point Loma) is actually closest, not always the last vertex.\n  return lat > COASTLINE[0][1] ? COASTLINE[0][0] : COASTLINE[COASTLINE.length - 1][0];\n}\n\n// --- Data: synthetic gray-whale & common-dolphin sighting reports, clustered\n// around three real Southern California whale-watching launch points and\n// offset west of the coast into open water, plus sparse background sightings\n// along the rest of the coastline. -------------------------------------------\nconst HOTSPOTS = [\n  { lat: 34.15, lonOffset: 0.55, sigma: 0.28, n: 260 }, // Santa Barbara Channel / Channel Islands\n  { lat: 33.47, lonOffset: 0.22, sigma: 0.16, n: 190 }, // Dana Point\n  { lat: 32.78, lonOffset: 0.18, sigma: 0.14, n: 170 }, // La Jolla / San Diego\n];\n\nconst points = [];\nHOTSPOTS.forEach(({ lat: centerLat, lonOffset, sigma, n }) => {\n  for (let i = 0; i < n; i += 1) {\n    const lat = centerLat + gaussian() * sigma * 0.6;\n    const coastLon = coastLonAt(lat);\n    const lon = coastLon - Math.abs(lonOffset + gaussian() * sigma);\n    if (lon > LON_MIN && lon < LON_MAX - 0.05 && lat > LAT_MIN && lat < LAT_MAX) {\n      points.push([lon, lat]);\n    }\n  }\n});\nfor (let i = 0; i < 140; i += 1) {\n  const lat = LAT_MIN + rand() * (LAT_MAX - LAT_MIN);\n  const coastLon = coastLonAt(lat);\n  const lon = coastLon - 0.05 - rand() * 0.5;\n  if (lon > LON_MIN) points.push([lon, lat]);\n}\n\n// --- Hexagonal binning, done in PIXEL space (after projection) so the drawn\n// cells are always regular hexagons and bin adjacency is isotropic — equal\n// screen distance to every neighbor, the property that makes hex grids\n// superior to square grids for spatial aggregation. --------------------------\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 165, right: 40, bottom: 180, left: 82 };\nconst innerWidth = width - margin.left - margin.right;\nconst innerHeight = height - margin.top - margin.bottom;\n\nconst lonToPx = (lon) => margin.left + ((lon - LON_MIN) / (LON_MAX - LON_MIN)) * innerWidth;\nconst latToPy = (lat) => margin.top + (1 - (lat - LAT_MIN) / (LAT_MAX - LAT_MIN)) * innerHeight;\nconst pxToLon = (px) => LON_MIN + ((px - margin.left) / innerWidth) * (LON_MAX - LON_MIN);\nconst pyToLat = (py) => LAT_MIN + (1 - (py - margin.top) / innerHeight) * (LAT_MAX - LAT_MIN);\n\nconst HEX_R = 34; // center-to-vertex radius, in CSS px\n\n// Cube-round a fractional axial coordinate to its nearest hex (redblobgames).\nfunction axialRound(q, r) {\n  const x = q;\n  const z = r;\n  const y = -x - z;\n  let rx = Math.round(x);\n  let ry = Math.round(y);\n  let rz = Math.round(z);\n  const xDiff = Math.abs(rx - x);\n  const yDiff = Math.abs(ry - y);\n  const zDiff = Math.abs(rz - z);\n  if (xDiff > yDiff && xDiff > zDiff) rx = -ry - rz;\n  else if (yDiff > zDiff) ry = -rx - rz;\n  else rz = -rx - ry;\n  return [rx, rz];\n}\n\nconst binMap = new Map();\npoints.forEach(([lon, lat]) => {\n  const px = lonToPx(lon) - margin.left;\n  const py = latToPy(lat) - margin.top;\n  const q = (2 / 3) * (px / HEX_R);\n  const r = (-1 / 3) * (px / HEX_R) + (Math.sqrt(3) / 3) * (py / HEX_R);\n  const [rq, rr] = axialRound(q, r);\n  const key = `${rq},${rr}`;\n  binMap.set(key, (binMap.get(key) || 0) + 1);\n});\n\nconst bins = Array.from(binMap.entries()).map(([key, count]) => {\n  const [q, r] = key.split(\",\").map(Number);\n  const cx = margin.left + HEX_R * 1.5 * q;\n  const cy = margin.top + HEX_R * Math.sqrt(3) * (r + q / 2);\n  return { count, cx, cy, lon: pxToLon(cx), lat: pyToLat(cy) };\n});\nconst maxCount = Math.max(2, ...bins.map((b) => b.count));\n\nconst series = [\n  {\n    id: \"hex-bins\",\n    label: \"Sighting density\",\n    data: bins.map((b, i) => ({ x: b.lon, y: b.lat, id: `hex-${i}` })),\n    markerSize: HEX_R,\n    color: t.palette[0],\n    valueFormatter: (_value, context) => {\n      const b = bins[context.dataIndex];\n      const lonLabel = `${Math.abs(b.lon).toFixed(2)}°W`;\n      const latLabel = `${b.lat.toFixed(2)}°N`;\n      return `${b.count} sighting${b.count === 1 ? \"\" : \"s\"} · ${lonLabel}, ${latLabel}`;\n    },\n  },\n];\n\n// --- Custom scatter-marker slot: renders each bin as a flat-top hexagon\n// (instead of MUI X's default circle), fill color driven by the zAxis\n// colorMap below. Hover/tooltip still work — they key off the underlying\n// data point positions, independent of how the marker is drawn. ------------\nfunction HexMarks({ series: s, xScale, yScale, markerSize, color, colorGetter }) {\n  return (\n    <g>\n      {s.data.map((d, i) => {\n        if (d === null) return null;\n        const cx = xScale(d.x);\n        const cy = yScale(d.y);\n        const fill = colorGetter ? colorGetter(i) : color;\n        const hexPoints = Array.from({ length: 6 }, (_, k) => {\n          const angle = (Math.PI / 180) * (60 * k);\n          return `${cx + markerSize * Math.cos(angle)},${cy + markerSize * Math.sin(angle)}`;\n        }).join(\" \");\n        return <polygon key={d.id ?? i} points={hexPoints} fill={fill} fillOpacity={0.85} stroke={t.pageBg} strokeWidth={2} />;\n      })}\n    </g>\n  );\n}\n\n// Simplified coastline + land tint, positioned via the chart's real scales.\nfunction CoastlineLayer() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  const coastPx = COASTLINE.map(([lon, lat]) => [xScale(lon), yScale(lat)]);\n  const top = coastPx[0];\n  const bottom = coastPx[coastPx.length - 1];\n  const landPolygon = [[xScale(LON_MAX), top[1]], ...coastPx, [xScale(LON_MAX), bottom[1]]].map((p) => p.join(\",\")).join(\" \");\n  const coastPath = coastPx.map((p, i) => `${i === 0 ? \"M\" : \"L\"} ${p[0]} ${p[1]}`).join(\" \");\n  return (\n    <g>\n      <polygon points={landPolygon} fill={t.grid} />\n      <path d={coastPath} fill=\"none\" stroke={INK_MUTED} strokeWidth={1.5} strokeOpacity={0.6} />\n    </g>\n  );\n}\n\nconst title = \"hexbin-map-geographic · javascript · muix · anyplot.ai\";\nconst titleFontSize = Math.round(22 * Math.min(1, 67 / title.length));\n\n// Continuous density legend — @mui/x-charts has no built-in colorbar for a\n// zAxis colorMap, so it's drawn with plain SVG using the same imprint_seq\n// stops passed to the chart.\nfunction DensityLegend() {\n  const legendX = margin.left;\n  const legendY = height - 66;\n  const legendWidth = innerWidth * 0.5;\n  return (\n    <g>\n      <defs>\n        <linearGradient id=\"densityGradient\" x1=\"0\" x2=\"1\" y1=\"0\" y2=\"0\">\n          <stop offset=\"0\" stopColor={t.seq[0]} />\n          <stop offset=\"1\" stopColor={t.seq[1]} />\n        </linearGradient>\n      </defs>\n      <text x={legendX} y={legendY - 10} fontSize={13} fill={t.inkSoft}>\n        Sightings per hex cell\n      </text>\n      <rect x={legendX} y={legendY} width={legendWidth} height={14} fill=\"url(#densityGradient)\" />\n      <text x={legendX} y={legendY + 30} fontSize={13} fill={t.inkSoft}>\n        1\n      </text>\n      <text x={legendX + legendWidth} y={legendY + 30} textAnchor=\"end\" fontSize={13} fill={t.inkSoft}>\n        {maxCount}\n      </text>\n    </g>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) -----------\nexport default function Chart() {\n  return (\n    <ScatterChart\n      width={width}\n      height={height}\n      series={series}\n      zAxis={[\n        { data: bins.map((b) => b.count), colorMap: { type: \"continuous\", min: 1, max: maxCount, color: [t.seq[0], t.seq[1]] } },\n      ]}\n      margin={margin}\n      xAxis={[\n        {\n          scaleType: \"linear\",\n          min: LON_MIN,\n          max: LON_MAX,\n          tickNumber: 5,\n          valueFormatter: (v) => `${Math.abs(v).toFixed(1)}°W`,\n          tickLabelStyle: { fontSize: 13, fill: t.inkSoft },\n        },\n      ]}\n      yAxis={[\n        {\n          scaleType: \"linear\",\n          min: LAT_MIN,\n          max: LAT_MAX,\n          tickNumber: 5,\n          valueFormatter: (v) => `${v.toFixed(1)}°N`,\n          tickLabelStyle: { fontSize: 13, fill: t.inkSoft },\n        },\n      ]}\n      grid={{ vertical: true, horizontal: true }}\n      slots={{ scatter: HexMarks }}\n      tooltip={{ trigger: \"item\" }}\n      legend={{ hidden: true }}\n      skipAnimation\n    >\n      <CoastlineLayer />\n      <text x={width / 2} y={48} textAnchor=\"middle\" fontSize={titleFontSize} fontWeight={600} fill={t.ink}>\n        {title}\n      </text>\n      <text x={width / 2} y={78} textAnchor=\"middle\" fontSize={15} fill={t.inkSoft}>\n        Southern California coast · gray whale &amp; dolphin sighting reports, hex-binned by count\n      </text>\n      <text x={margin.left + innerWidth / 2} y={height - margin.bottom + 46} textAnchor=\"middle\" fontSize={13} fill={t.inkSoft}>\n        Longitude\n      </text>\n      <text\n        x={20}\n        y={margin.top + innerHeight / 2}\n        textAnchor=\"middle\"\n        fontSize={13}\n        fill={t.inkSoft}\n        transform={`rotate(-90 20 ${margin.top + innerHeight / 2})`}\n      >\n        Latitude\n      </text>\n      <DensityLegend />\n    </ScatterChart>\n  );\n}\n"}