{"spec_id":"hexbin-basic","library":"muix","language":"javascript","code":"// anyplot.ai\n// hexbin-basic: Basic Hexbin Plot\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 88/100 | Created: 2026-08-25\n//# anyplot-orientation: square\n// anyplot.ai\n// hexbin-basic: Basic Hexbin Plot\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 { ContinuousColorLegend } from \"@mui/x-charts/ChartsLegend\";\nimport Box from \"@mui/material/Box\";\nimport Typography from \"@mui/material/Typography\";\n\nconst t = window.ANYPLOT_TOKENS;\n\n// Deterministic LCG (seed 42) — no Math.random() in the browser harness\nfunction makeLcg(seed) {\n  let state = seed >>> 0;\n  return () => {\n    state = (Math.imul(state, 1664525) + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rng = makeLcg(42);\n\n// Box-Muller standard normal draw, fed by the LCG above.\nfunction gaussian() {\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\n// --- Data: pedestrian GPS pings across a city district ---------------------\n// Three named foot-traffic hotspots plus a wide, low-weight ambient cluster\n// standing in for baseline city-wide movement — the kind of dataset where a\n// scatter plot degenerates into a solid smear of overlapping points and a\n// hexbin is the tool that recovers the density structure.\nconst DOMAIN = 60; // both axes span [-DOMAIN, DOMAIN] meters from the plaza\nconst CLUSTERS = [\n  { cx: 0, cy: 6, sx: 9, sy: 8, weight: 0.42 }, // Central Plaza\n  { cx: 34, cy: -24, sx: 7, sy: 10, weight: 0.28 }, // Transit Hub\n  { cx: -31, cy: 21, sx: 11, sy: 6, weight: 0.22 }, // Waterfront Promenade\n  { cx: 0, cy: 0, sx: 30, sy: 26, weight: 0.08 }, // Ambient foot traffic\n];\n\nconst POINT_COUNT = 30000;\nconst points = [];\nlet attempts = 0;\nwhile (points.length < POINT_COUNT && attempts < POINT_COUNT * 4) {\n  attempts += 1;\n  const r = rng();\n  let acc = 0;\n  let cluster = CLUSTERS[CLUSTERS.length - 1];\n  for (let c = 0; c < CLUSTERS.length; c += 1) {\n    acc += CLUSTERS[c].weight;\n    if (r <= acc) {\n      cluster = CLUSTERS[c];\n      break;\n    }\n  }\n  const x = cluster.cx + gaussian() * cluster.sx;\n  const y = cluster.cy + gaussian() * cluster.sy;\n  if (Math.abs(x) > DOMAIN || Math.abs(y) > DOMAIN) continue;\n  points.push({ x, y });\n}\n\n// --- Hexagonal binning -------------------------------------------------\n// HEX_RADIUS (data-space units) is the hexbin equivalent of a \"gridsize\"\n// knob: smaller radius -> more, smaller hexagons; larger radius -> coarser,\n// smoother density. Offset-grid math follows the standard flat/pointy-top\n// hexbin layout (as used by d3-hexbin): columns spaced `r*sqrt(3)` apart,\n// rows spaced `1.5r` apart, odd rows offset by half a column.\nconst HEX_RADIUS = 4.5;\nconst hexDx = HEX_RADIUS * Math.sqrt(3);\nconst hexDy = HEX_RADIUS * 1.5;\n\nfunction hexBin(rawPoints) {\n  const binsById = new Map();\n  rawPoints.forEach(({ x: px, y: py }) => {\n    const py1 = py / hexDy;\n    let pj = Math.round(py1);\n    let px1 = px / hexDx - (pj & 1) / 2;\n    let pi = Math.round(px1);\n    const py2 = py1 - pj;\n    if (Math.abs(py2) * 3 > 1) {\n      const px2 = px1 - pi;\n      const pi2 = pi + (px1 < pi ? -1 : 1) / 2;\n      const pj2 = pj + (py1 < pj ? -1 : 1);\n      const px3 = px1 - pi2;\n      const py3 = py2 - pj2;\n      if (px2 * px2 + py2 * py2 > px3 * px3 + py3 * py3) {\n        pi = pi2 + (pj & 1 ? 1 : -1) / 2;\n        pj = pj2;\n      }\n    }\n    const id = `${pi}-${pj}`;\n    const existing = binsById.get(id);\n    if (existing) {\n      existing.count += 1;\n    } else {\n      binsById.set(id, {\n        id,\n        cx: (pi + (pj & 1) / 2) * hexDx,\n        cy: pj * hexDy,\n        count: 1,\n      });\n    }\n  });\n  return Array.from(binsById.values());\n}\n\nconst hexBins = hexBin(points);\nconst binCountMax = Math.max(...hexBins.map((b) => b.count));\nconst binCountMin = Math.min(...hexBins.map((b) => b.count));\n\n// Color on a log scale — a handful of hotspot bins vastly outnumber the\n// sparse fringe bins, so log contrast separates the density tiers far\n// better than a linear ramp would.\nconst colorDomainMin = Math.log1p(binCountMin);\nconst colorDomainMax = Math.log1p(binCountMax);\n\n// Every hex cell that overlaps the domain, occupied or not — filling in the\n// zero-count cells (instead of only plotting occupied bins) avoids blank\n// \"swiss cheese\" gaps in the sparse fringe; empty cells get a faint fixed\n// tint below instead of participating in the log-count color scale.\nfunction fullHexGrid() {\n  const cells = [];\n  const rowPad = 1;\n  const pjMin = Math.floor(-DOMAIN / hexDy) - rowPad;\n  const pjMax = Math.ceil(DOMAIN / hexDy) + rowPad;\n  for (let pj = pjMin; pj <= pjMax; pj += 1) {\n    const offset = (pj & 1) / 2;\n    const piMin = Math.floor(-DOMAIN / hexDx - offset) - rowPad;\n    const piMax = Math.ceil(DOMAIN / hexDx - offset) + rowPad;\n    for (let pi = piMin; pi <= piMax; pi += 1) {\n      const cx = (pi + (pj & 1) / 2) * hexDx;\n      const cy = pj * hexDy;\n      if (Math.abs(cx) > DOMAIN + HEX_RADIUS || Math.abs(cy) > DOMAIN + HEX_RADIUS) continue;\n      cells.push({ id: `${pi}-${pj}`, cx, cy });\n    }\n  }\n  return cells;\n}\n\nconst occupiedById = new Map(hexBins.map((bin) => [bin.id, bin]));\nconst hexBinPoints = fullHexGrid().map((cell) => {\n  const occupied = occupiedById.get(cell.id);\n  const count = occupied ? occupied.count : 0;\n  return {\n    id: cell.id,\n    x: cell.cx,\n    y: cell.cy,\n    z: count > 0 ? Math.log1p(count) : colorDomainMin,\n    isEmpty: count === 0,\n  };\n});\n\n// Community @mui/x-charts has no hexbin/heatmap component (Pro-only) — a\n// ScatterChart with a custom hexagon-path marker, positioned from the\n// underlying linear scales, reproduces a true hexagonal tiling instead of\n// circular bubbles.\nfunction HexCell(props) {\n  const { series, xScale, yScale, colorGetter, color } = props;\n  const radiusX = Math.abs(xScale(HEX_RADIUS) - xScale(0));\n  const radiusY = Math.abs(yScale(HEX_RADIUS) - yScale(0));\n\n  // Bins whose data-space center lands just past [-DOMAIN, DOMAIN] (the\n  // offset hex grid doesn't align exactly to the axis edge) would otherwise\n  // draw over the axis ticks/labels — clip the whole layer to the plot's own\n  // drawing rect, read straight from the scales' pixel ranges.\n  const [xr0, xr1] = xScale.range();\n  const [yr0, yr1] = yScale.range();\n  const clipX = Math.min(xr0, xr1);\n  const clipY = Math.min(yr0, yr1);\n  const clipWidth = Math.abs(xr1 - xr0);\n  const clipHeight = Math.abs(yr1 - yr0);\n\n  return (\n    <g clipPath=\"url(#hexbin-plot-clip)\">\n      <defs>\n        <clipPath id=\"hexbin-plot-clip\">\n          <rect x={clipX} y={clipY} width={clipWidth} height={clipHeight} />\n        </clipPath>\n      </defs>\n      {series.data.map((point, i) => {\n        const ccx = xScale(point.x);\n        const ccy = yScale(point.y);\n        let d = \"\";\n        for (let k = 0; k < 6; k += 1) {\n          const angle = (k * Math.PI) / 3;\n          const vx = ccx + Math.sin(angle) * radiusX;\n          const vy = ccy - Math.cos(angle) * radiusY;\n          d += `${k === 0 ? \"M\" : \"L\"}${vx},${vy}`;\n        }\n        return (\n          <path\n            key={point.id}\n            d={`${d}Z`}\n            fill={point.isEmpty ? t.seq[0] : colorGetter ? colorGetter(i) : color}\n            fillOpacity={point.isEmpty ? 0.12 : 1}\n            stroke={t.pageBg}\n            strokeWidth={1}\n          />\n        );\n      })}\n    </g>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) -----------\nexport default function Chart() {\n  const { width, height } = window.ANYPLOT_SIZE;\n  const titleHeight = 70;\n\n  return (\n    <Box sx={{ width, height, bgcolor: t.pageBg, display: \"flex\", flexDirection: \"column\" }}>\n      <Typography\n        sx={{\n          color: t.ink,\n          fontSize: 26,\n          fontWeight: 600,\n          textAlign: \"center\",\n          lineHeight: 1.2,\n          pt: \"18px\",\n          height: titleHeight,\n          fontFamily: \"inherit\",\n        }}\n      >\n        hexbin-basic · javascript · muix · anyplot.ai\n      </Typography>\n      <Box sx={{ position: \"relative\", flex: 1 }}>\n        <ScatterChart\n          width={width}\n          height={height - titleHeight}\n          series={[\n            {\n              id: \"hex-density\",\n              type: \"scatter\",\n              data: hexBinPoints,\n              label: \"Ping count\",\n              zAxisId: \"count\",\n            },\n          ]}\n          xAxis={[\n            {\n              scaleType: \"linear\",\n              min: -DOMAIN,\n              max: DOMAIN,\n              label: \"Distance east of plaza (m)\",\n              tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n              labelStyle: { fontSize: 16, fill: t.ink },\n            },\n          ]}\n          yAxis={[\n            {\n              scaleType: \"linear\",\n              min: -DOMAIN,\n              max: DOMAIN,\n              label: \"Distance north of plaza (m)\",\n              tickFontSize: 42,\n              tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n              labelStyle: { fontSize: 16, fill: t.ink },\n            },\n          ]}\n          zAxis={[\n            {\n              id: \"count\",\n              min: colorDomainMin,\n              max: colorDomainMax,\n              colorMap: {\n                type: \"continuous\",\n                min: colorDomainMin,\n                max: colorDomainMax,\n                color: [t.seq[0], t.seq[1]],\n              },\n            },\n          ]}\n          margin={{ top: 40, right: 90, bottom: 90, left: 190 }}\n          slots={{ scatter: HexCell }}\n          slotProps={{ legend: { hidden: true } }}\n          skipAnimation\n        >\n          <ContinuousColorLegend\n            axisId=\"count\"\n            axisDirection=\"z\"\n            direction=\"row\"\n            position={{ horizontal: \"middle\", vertical: \"bottom\" }}\n            length=\"45%\"\n            thickness={16}\n            minLabel={`${binCountMin}`}\n            maxLabel={`${binCountMax}`}\n            labelStyle={{ fontSize: 14, fill: t.inkSoft, fontFamily: \"inherit\" }}\n          />\n        </ScatterChart>\n        <Typography\n          sx={{\n            position: \"absolute\",\n            left: 0,\n            right: 0,\n            bottom: 26,\n            textAlign: \"center\",\n            color: t.inkSoft,\n            fontSize: 14,\n            fontFamily: \"inherit\",\n          }}\n        >\n          Ping count per bin (log scale)\n        </Typography>\n      </Box>\n    </Box>\n  );\n}\n"}