{"spec_id":"hexbin-map-geographic","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// hexbin-map-geographic: Hexagonal Binning Map\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-08-25\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Layout constants --------------------------------------------------------\n// The mount is a fixed 1600×900 CSS box (harness renders at deviceScaleFactor 2\n// -> 3200×1800 PNG). Margins are set explicitly (not \"spacing\") so the plot area\n// pixel size is known at codegen time, letting us pick a km bounding box whose\n// aspect ratio exactly matches plotWidth/plotHeight — that's what keeps the\n// hand-tiled hexagons regular instead of stretched (1 km must map to the same\n// pixel count on both axes).\nconst MOUNT_WIDTH = 1600;\nconst MOUNT_HEIGHT = 900;\nconst MARGIN_LEFT = 85;\nconst MARGIN_RIGHT = 50;\nconst MARGIN_TOP = 110;\nconst MARGIN_BOTTOM = 195; // x-axis title/ticks + gap + hand-drawn gradient legend\nconst PLOT_WIDTH = MOUNT_WIDTH - MARGIN_LEFT - MARGIN_RIGHT;\nconst PLOT_HEIGHT = MOUNT_HEIGHT - MARGIN_TOP - MARGIN_BOTTOM;\n\n// Fictional coastal service area — illustrative lat/lon, not a real place.\nconst LAT0 = 41.85;\nconst LON0 = -71.35;\nconst KM_PER_DEG_LAT = 110.57;\nconst KM_PER_DEG_LON = 111.32 * Math.cos((LAT0 * Math.PI) / 180);\n\nconst X_RANGE_KM = 8; // area width, city-scale\nconst Y_RANGE_KM = (X_RANGE_KM * PLOT_HEIGHT) / PLOT_WIDTH; // locks pixel aspect\nconst X_MIN = -X_RANGE_KM / 2;\nconst X_MAX = X_RANGE_KM / 2;\nconst Y_MIN = -Y_RANGE_KM / 2;\nconst Y_MAX = Y_RANGE_KM / 2;\nconst PX_PER_KM = PLOT_WIDTH / X_RANGE_KM;\n\n// Hexagon size — configurable: smaller = more detail, larger = more overview.\nconst HEX_RADIUS_KM = 0.28;\nconst HEX_RADIUS_PX = HEX_RADIUS_KM * PX_PER_KM;\nconst SQRT3 = Math.sqrt(3);\n\n// --- Deterministic PRNG (browser has no seeded RNG) --------------------------\nlet lcgState = 42;\nfunction nextRandom() {\n  lcgState = (lcgState * 1103515245 + 12345) % 2147483648;\n  return lcgState / 2147483648;\n}\nfunction jitter(spread) {\n  // Sum of 3 uniforms centred on 0 — cheap approx-normal, bounded, deterministic.\n  return ((nextRandom() + nextRandom() + nextRandom() - 1.5) / 1.5) * spread;\n}\n\n// --- Coastline (base-map context) --------------------------------------------\n// Water sits south of this wavy curve; ride-hailing pickups never originate on\n// water, so the exclusion also shapes which hexagons end up with data.\nfunction coastlineY(x) {\n  return -0.15 * Y_RANGE_KM + 0.12 * Y_RANGE_KM * Math.sin((x / X_RANGE_KM) * Math.PI * 1.4 + 0.6);\n}\n\n// --- Data: ride-hailing pickups around four demand hotspots -------------------\n// (lat, lon, value=fare $) aggregated per hex cell into count / sum / mean.\nconst N_POINTS = 4000;\nconst HOTSPOTS = [\n  { name: \"Downtown\", cx: -1.3, cy: 0.9, spread: 0.85, weight: 0.38 },\n  { name: \"Transit Hub\", cx: 0.35, cy: 1.1, spread: 0.55, weight: 0.27 },\n  // Waterfront Promenade sits on a pier, so its pickups are allowed to dip\n  // below the coastline — this is what actually lets the hex-cell alpha=0.85\n  // fill show the water polygon underneath instead of only ever sitting on land.\n  { name: \"Waterfront Promenade\", cx: 1.6, cy: -0.15, spread: 0.5, weight: 0.2, waterMargin: -0.22 },\n  { name: \"North Residential\", cx: -2.5, cy: 1.3, spread: 1.0, weight: 0.15 },\n];\n\nconst points = [];\nHOTSPOTS.forEach((hotspot) => {\n  const target = Math.round(N_POINTS * hotspot.weight);\n  let placed = 0;\n  let attempts = 0;\n  const waterMargin = hotspot.waterMargin ?? 0.05;\n  while (placed < target && attempts < target * 20) {\n    attempts += 1;\n    const x = hotspot.cx + jitter(hotspot.spread);\n    const y = hotspot.cy + jitter(hotspot.spread);\n    if (x < X_MIN || x > X_MAX || y < Y_MIN || y > Y_MAX) continue;\n    if (y < coastlineY(x) + waterMargin) continue; // no pickups on open water\n    const distFromCenter = Math.hypot(x - hotspot.cx, y - hotspot.cy);\n    const fare = Math.max(4, 7 + distFromCenter * 1.4 + jitter(3));\n    points.push({ x, y, value: fare });\n    placed += 1;\n  }\n});\n\n// --- Hexagonal binning (pointy-top axial grid, cube-rounded) ------------------\nfunction pixelToAxial(x, y, size) {\n  const q = ((SQRT3 / 3) * x - (1 / 3) * y) / size;\n  const r = ((2 / 3) * y) / size;\n  return cubeRound(q, r);\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 { q: rx, r: rz };\n}\nfunction axialToPixel(q, r, size) {\n  return { x: size * (SQRT3 * q + (SQRT3 / 2) * r), y: size * 1.5 * r };\n}\n\nconst bins = new Map();\npoints.forEach((p) => {\n  const { q, r } = pixelToAxial(p.x, p.y, HEX_RADIUS_KM);\n  const key = `${q},${r}`;\n  let bin = bins.get(key);\n  if (!bin) {\n    const center = axialToPixel(q, r, HEX_RADIUS_KM);\n    bin = { x: center.x, y: center.y, count: 0, sum: 0 };\n    bins.set(key, bin);\n  }\n  bin.count += 1;\n  bin.sum += p.value;\n});\n\nconst hexCells = Array.from(bins.values()).map((bin) => {\n  const mean = bin.sum / bin.count;\n  const lat = LAT0 + bin.y / KM_PER_DEG_LAT;\n  const lon = LON0 + bin.x / KM_PER_DEG_LON;\n  return {\n    x: bin.x,\n    y: bin.y,\n    count: bin.count,\n    sum: Math.round(bin.sum),\n    mean: Math.round(mean * 10) / 10,\n    lat: Math.round(lat * 1000) / 1000,\n    lon: Math.round(lon * 1000) / 1000,\n  };\n});\nconst minCount = Math.min(...hexCells.map((c) => c.count));\nconst maxCount = Math.max(...hexCells.map((c) => c.count));\n\n// The core Highcharts bundle has no heatmap/colorAxis module (see\n// prompts/library/highcharts.md), so each cell's fill is computed by hand — a\n// linear interpolation across the two-stop imprint_seq gradient.\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\nconst seqLow = hexToRgb(t.seq[0]);\nconst seqHigh = hexToRgb(t.seq[1]);\nfunction valueToColor(value, min, max) {\n  const frac = max > min ? (value - min) / (max - min) : 1;\n  const rgb = seqLow.map((c, i) => Math.round(c + (seqHigh[i] - c) * frac));\n  return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0.85)`;\n}\nhexCells.forEach((c) => {\n  c.color = valueToColor(c.count, minCount, maxCount);\n});\n\n// Sampled coastline curve for the water-fill area series (base-map context).\nconst coastlineSamples = [];\nfor (let i = 0; i <= 40; i++) {\n  const x = X_MIN + (i / 40) * X_RANGE_KM;\n  coastlineSamples.push([x, coastlineY(x)]);\n}\n\n// Schematic street grid — additional base-map context beyond the coastline\n// alone (per spec: \"base map showing geographic context\"). Each line is its\n// own tiny series (not one multi-segment series) so Highcharts never\n// re-sorts the two endpoints by x, which would otherwise scramble the grid.\nconst streetRgb = hexToRgb(t.inkSoft);\nconst streetLineColor = `rgba(${streetRgb[0]},${streetRgb[1]},${streetRgb[2]},0.45)`;\nconst STREET_GRID_X_FRACS = [0.2, 0.4, 0.6, 0.8];\nconst STREET_GRID_Y_FRACS = [0.35, 0.55, 0.75];\nfunction streetLine(data) {\n  return {\n    type: \"line\",\n    data,\n    color: streetLineColor,\n    lineWidth: 1,\n    dashStyle: \"Dash\",\n    marker: { enabled: false },\n    enableMouseTracking: false,\n    showInLegend: false,\n    zIndex: 0,\n  };\n}\nconst streetGridSeries = [\n  ...STREET_GRID_X_FRACS.map((f) => streetLine([\n    [X_MIN + f * X_RANGE_KM, Y_MAX],\n    [X_MIN + f * X_RANGE_KM, Y_MIN],\n  ])),\n  ...STREET_GRID_Y_FRACS.map((f) => streetLine([\n    [X_MIN, Y_MIN + f * Y_RANGE_KM],\n    [X_MAX, Y_MIN + f * Y_RANGE_KM],\n  ])),\n];\n\n// --- Custom hexagon marker symbol --------------------------------------------\n// Pointy-top orientation to match the axial math above; registering a symbol on\n// the SVGRenderer is a core-Highcharts feature (no add-on module needed).\nHighcharts.SVGRenderer.prototype.symbols.hexagon = function (x, y, w, h) {\n  const cx = x + w / 2;\n  const cy = y + h / 2;\n  const r = w / 2;\n  const path = [];\n  for (let i = 0; i < 6; i++) {\n    const angle = (Math.PI / 180) * (-90 + 60 * i);\n    const px = cx + r * Math.cos(angle);\n    const py = cy + r * Math.sin(angle);\n    path.push(i === 0 ? \"M\" : \"L\", px, py);\n  }\n  path.push(\"Z\");\n  return path;\n};\n\n// --- Chart ---------------------------------------------------------------\nconst title = \"hexbin-map-geographic · javascript · highcharts · anyplot.ai\";\n\nfunction drawColorLegend(chart) {\n  const r = chart.renderer;\n  const x0 = chart.plotLeft;\n  const y0 = chart.plotTop + chart.plotHeight + 68;\n  const barWidth = 260;\n  const barHeight = 16;\n\n  r.text(\"Pickups per cell\", x0, y0 - 10)\n    .css({ color: t.inkSoft, fontSize: \"14px\", fontWeight: \"600\" })\n    .add();\n\n  r.rect(x0, y0, barWidth, barHeight, 3)\n    .attr({\n      fill: {\n        linearGradient: { x1: 0, y1: 0, x2: 1, y2: 0 },\n        stops: [\n          [0, t.seq[0]],\n          [1, t.seq[1]],\n        ],\n      },\n      \"stroke-width\": 0,\n    })\n    .add();\n  r.text(String(minCount), x0, y0 + barHeight + 18)\n    .css({ color: t.inkSoft, fontSize: \"12px\" })\n    .add();\n  r.text(String(maxCount), x0 + barWidth - 14, y0 + barHeight + 18)\n    .css({ color: t.inkSoft, fontSize: \"12px\" })\n    .add();\n}\n\nfunction drawWaterLabel(chart) {\n  const px = chart.xAxis[0].toPixels(X_MAX - 1.1, false);\n  const py = chart.yAxis[0].toPixels(Y_MIN + 0.35, false);\n  chart.renderer\n    .text(\"Meridian Bay (water)\", px, py)\n    .css({ color: t.inkSoft, fontSize: \"13px\", fontStyle: \"italic\" })\n    .add();\n}\n\n// Label the two densest hotspots directly on the chart (turns the color\n// contrast into a named story instead of leaving it to the tooltip alone).\n// Placed in the margin band just above the plot area — hex cells can be\n// clipped right up to the axis-max edge, so any label inside the plot area\n// risks sitting on top of a cell; the margin band is guaranteed clear.\nfunction drawHotspotLabels(chart) {\n  const topHotspots = [HOTSPOTS[0], HOTSPOTS[1]]; // Downtown, Transit Hub\n  const py = chart.plotTop - 10;\n  topHotspots.forEach((h) => {\n    const px = chart.xAxis[0].toPixels(h.cx, false);\n    chart.renderer\n      .text(h.name, px, py)\n      .attr({ align: \"center\" })\n      .css({ color: t.ink, fontSize: \"13px\", fontWeight: \"700\" })\n      .add();\n  });\n}\n\nHighcharts.chart(\n  \"container\",\n  {\n    chart: {\n      type: \"scatter\",\n      backgroundColor: \"transparent\",\n      animation: false,\n      style: { fontFamily: \"inherit\" },\n      marginLeft: MARGIN_LEFT,\n      marginRight: MARGIN_RIGHT,\n      marginTop: MARGIN_TOP,\n      marginBottom: MARGIN_BOTTOM,\n    },\n    credits: { enabled: false },\n    colors: t.palette,\n    title: {\n      text: title,\n      align: \"left\",\n      style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n    },\n    subtitle: {\n      text: \"Ride-hailing pickups aggregated into 0.28 km hexagonal cells near Meridian Bay — hover a cell for total & average fare\",\n      align: \"left\",\n      style: { color: t.inkSoft, fontSize: \"14px\" },\n    },\n    xAxis: {\n      min: X_MIN,\n      max: X_MAX,\n      startOnTick: false,\n      endOnTick: false,\n      title: { text: \"km east of downtown\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n      lineColor: t.inkSoft,\n      tickColor: t.inkSoft,\n      gridLineColor: t.grid,\n      gridLineWidth: 1,\n      labels: { style: { color: t.inkSoft, fontSize: \"14px\" } },\n    },\n    yAxis: {\n      min: Y_MIN,\n      max: Y_MAX,\n      startOnTick: false,\n      endOnTick: false,\n      title: { text: \"km north of downtown\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n      lineColor: t.inkSoft,\n      tickColor: t.inkSoft,\n      gridLineColor: t.grid,\n      gridLineWidth: 1,\n      labels: { style: { color: t.inkSoft, fontSize: \"14px\" } },\n    },\n    legend: { enabled: false },\n    tooltip: {\n      backgroundColor: t.elevatedBg,\n      borderColor: t.inkSoft,\n      style: { color: t.ink, fontSize: \"13px\" },\n      useHTML: false,\n    },\n    plotOptions: {\n      series: { animation: false },\n    },\n    series: [\n      ...streetGridSeries,\n      {\n        name: \"Bay waters\",\n        type: \"area\",\n        data: coastlineSamples,\n        threshold: Y_MIN,\n        color: \"rgba(68,103,163,0.16)\",\n        lineWidth: 1.5,\n        lineColor: \"rgba(68,103,163,0.5)\",\n        marker: { enabled: false },\n        enableMouseTracking: false,\n        showInLegend: false,\n        zIndex: 1,\n      },\n      {\n        name: \"Hex cells\",\n        type: \"scatter\",\n        data: hexCells,\n        showInLegend: false,\n        zIndex: 2,\n        marker: {\n          symbol: \"hexagon\",\n          radius: HEX_RADIUS_PX,\n          lineWidth: 1,\n          lineColor: t.pageBg,\n          states: { hover: { lineWidthPlus: 1, lineColor: t.ink } },\n        },\n        tooltip: {\n          pointFormatter() {\n            return (\n              `<b>${this.count} pickups</b><br/>` +\n              `Total fare: $${this.sum}<br/>` +\n              `Avg fare: $${this.mean}<br/>` +\n              `≈ ${this.lat}°N, ${Math.abs(this.lon)}°W`\n            );\n          },\n        },\n      },\n    ],\n  },\n  function (chart) {\n    drawColorLegend(chart);\n    drawWaterLabel(chart);\n    drawHotspotLabels(chart);\n  },\n);\n"}