{"spec_id":"hexbin-map-geographic","library":"echarts","language":"javascript","code":"// anyplot.ai\n// hexbin-map-geographic: Hexagonal Binning Map\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 94/100 | Created: 2026-08-25\n\nconst t = window.ANYPLOT_TOKENS;\nconst size = window.ANYPLOT_SIZE;\n\n// --- Study area: San Francisco peninsula, synthetic taxi pickups -----------\nconst LON_MIN = -122.58;\nconst LON_MAX = -122.33;\nconst LAT_MIN = 37.705;\nconst LAT_MAX = 37.815;\n\n// Rough Pacific-facing shoreline, north to south — geographic context only,\n// not a precise survey boundary.\nconst COASTLINE = [\n  [-122.4783, 37.8199],\n  [-122.5091, 37.7930],\n  [-122.5107, 37.7783],\n  [-122.5100, 37.7383],\n  [-122.4880, 37.7080],\n  [-122.4500, 37.7080],\n];\n\nconst HOTSPOTS = [\n  { lon: -122.4014, lat: 37.7936, weight: 0.28, spread: 0.014 }, // Financial District\n  { lon: -122.4194, lat: 37.7599, weight: 0.24, spread: 0.016 }, // Mission District\n  { lon: -122.4348, lat: 37.8060, weight: 0.18, spread: 0.012 }, // Marina & Wharf\n  { lon: -122.4477, lat: 37.7692, weight: 0.16, spread: 0.014 }, // Haight-Ashbury\n  { lon: -122.4869, lat: 37.7599, weight: 0.14, spread: 0.02 },  // Sunset District\n];\n\n// --- Fixed-seed LCG (browser has no seeded RNG) -----------------------------\nlet seed = 42;\nfunction rand() {\n  seed = (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}\nfunction pickHotspot() {\n  const r = rand();\n  let cumulative = 0;\n  for (const h of HOTSPOTS) {\n    cumulative += h.weight;\n    if (r <= cumulative) return h;\n  }\n  return HOTSPOTS[HOTSPOTS.length - 1];\n}\n\n// --- Synthetic pickup points -------------------------------------------------\nconst POINT_COUNT = 2600;\nconst points = [];\nfor (let i = 0; i < POINT_COUNT; i++) {\n  const hotspot = pickHotspot();\n  const lon = hotspot.lon + (gaussian() * hotspot.spread) / 0.79;\n  const lat = hotspot.lat + gaussian() * hotspot.spread;\n  const fare = 6 + Math.abs(gaussian()) * 9 + rand() * 6;\n  if (lon < LON_MIN || lon > LON_MAX || lat < LAT_MIN || lat > LAT_MAX) continue;\n  points.push({ lon, lat, fare });\n}\n\n// --- Geographic projection (must mirror the chart's linear value axes) -----\nconst GRID = { left: 120, right: 260, top: 120, bottom: 110 };\nconst PLOT_W = size.width - GRID.left - GRID.right;\nconst PLOT_H = size.height - GRID.top - GRID.bottom;\n\nfunction project(lon, lat) {\n  const px = ((lon - LON_MIN) / (LON_MAX - LON_MIN)) * PLOT_W;\n  const py = ((LAT_MAX - lat) / (LAT_MAX - LAT_MIN)) * PLOT_H;\n  return [px, py];\n}\nfunction unproject(px, py) {\n  const lon = LON_MIN + (px / PLOT_W) * (LON_MAX - LON_MIN);\n  const lat = LAT_MAX - (py / PLOT_H) * (LAT_MAX - LAT_MIN);\n  return [lon, lat];\n}\n\n// --- Hexagonal binning (pointy-top grid, nearest-center assignment) --------\nconst HEX_RADIUS = 30;\nconst HEX_DX = HEX_RADIUS * Math.sqrt(3);\nconst HEX_DY = HEX_RADIUS * 1.5;\n\nfunction nearestHexCell(px, py) {\n  const rowGuess = Math.round(py / HEX_DY);\n  let bestRow = rowGuess;\n  let bestCol = 0;\n  let bestDist = Infinity;\n  for (let rowOffset = -1; rowOffset <= 1; rowOffset++) {\n    const row = rowGuess + rowOffset;\n    const shift = (((row % 2) + 2) % 2 === 1) ? HEX_DX / 2 : 0;\n    const colGuess = Math.round((px - shift) / HEX_DX);\n    for (let colOffset = -1; colOffset <= 1; colOffset++) {\n      const col = colGuess + colOffset;\n      const cx = col * HEX_DX + shift;\n      const cy = row * HEX_DY;\n      const dist = (cx - px) ** 2 + (cy - py) ** 2;\n      if (dist < bestDist) {\n        bestDist = dist;\n        bestRow = row;\n        bestCol = col;\n      }\n    }\n  }\n  const shift = (((bestRow % 2) + 2) % 2 === 1) ? HEX_DX / 2 : 0;\n  return { row: bestRow, col: bestCol, cx: bestCol * HEX_DX + shift, cy: bestRow * HEX_DY };\n}\n\nconst bins = new Map();\nfor (const p of points) {\n  const [px, py] = project(p.lon, p.lat);\n  const cell = nearestHexCell(px, py);\n  const key = `${cell.row}_${cell.col}`;\n  let bin = bins.get(key);\n  if (!bin) {\n    bin = { count: 0, sumFare: 0, cx: cell.cx, cy: cell.cy };\n    bins.set(key, bin);\n  }\n  bin.count += 1;\n  bin.sumFare += p.fare;\n}\n\nconst hexData = Array.from(bins.values()).map((bin) => {\n  const [lon, lat] = unproject(bin.cx, bin.cy);\n  return [lon, lat, bin.count, bin.sumFare / bin.count, bin.sumFare];\n});\nconst maxCount = Math.max(...hexData.map((d) => d[2]));\n\n// --- Ocean tint: fills the water side of the coastline (west of the coast) -\nconst OCEAN_POLYGON = [\n  [LON_MIN, LAT_MAX],\n  ...COASTLINE,\n  [LON_MIN, LAT_MIN],\n];\nfunction renderOcean(params, api) {\n  const shapePoints = OCEAN_POLYGON.map(([lon, lat]) => api.coord([lon, lat]));\n  return {\n    type: \"polygon\",\n    shape: { points: shapePoints },\n    style: { fill: t.palette[2], opacity: 0.2 },\n    silent: true,\n  };\n}\n\n// --- Custom series: draw one regular hexagon per occupied cell -------------\nfunction renderHex(params, api) {\n  const center = api.coord([api.value(0), api.value(1)]);\n  const shapePoints = [];\n  for (let i = 0; i < 6; i++) {\n    const angle = (Math.PI / 180) * (60 * i - 90);\n    shapePoints.push([\n      center[0] + HEX_RADIUS * Math.cos(angle),\n      center[1] + HEX_RADIUS * Math.sin(angle),\n    ]);\n  }\n  return {\n    type: \"polygon\",\n    shape: { points: shapePoints },\n    style: api.style({\n      fill: api.visual(\"color\"),\n      stroke: t.pageBg,\n      lineWidth: 1,\n      opacity: 0.65,\n    }),\n  };\n}\n\n// --- Title (fontsize scaled to the ~67-char baseline) -----------------------\nconst titleText = \"SF Taxi Pickups · hexbin-map-geographic · javascript · echarts · anyplot.ai\";\nconst titleFontSize = Math.round(22 * Math.min(1, 67 / titleText.length));\n\n// --- Init ---------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  title: {\n    text: titleText,\n    left: \"center\",\n    top: 24,\n    textStyle: { color: t.ink, fontSize: titleFontSize, fontWeight: 500 },\n  },\n  tooltip: { trigger: \"item\" },\n  grid: { left: GRID.left, right: GRID.right, top: GRID.top, bottom: GRID.bottom },\n  visualMap: {\n    type: \"continuous\",\n    dimension: 2,\n    min: 0,\n    max: maxCount,\n    orient: \"vertical\",\n    right: 40,\n    top: \"middle\",\n    itemHeight: 320,\n    itemWidth: 18,\n    text: [\"More pickups\", \"Fewer pickups\"],\n    textStyle: { color: t.inkSoft, fontSize: 14 },\n    inRange: { color: t.seq },\n    calculable: false,\n  },\n  xAxis: {\n    type: \"value\",\n    min: LON_MIN,\n    max: LON_MAX,\n    name: \"Longitude\",\n    nameLocation: \"middle\",\n    nameGap: 40,\n    nameTextStyle: { color: t.inkSoft, fontSize: 14 },\n    axisLabel: { color: t.inkSoft, fontSize: 13, formatter: (v) => `${v.toFixed(2)}°` },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    splitLine: { show: false },\n  },\n  yAxis: {\n    type: \"value\",\n    min: LAT_MIN,\n    max: LAT_MAX,\n    name: \"Latitude\",\n    nameLocation: \"middle\",\n    nameGap: 55,\n    nameTextStyle: { color: t.inkSoft, fontSize: 14 },\n    axisLabel: { color: t.inkSoft, fontSize: 13, formatter: (v) => `${v.toFixed(2)}°` },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    splitLine: { show: false },\n  },\n  series: [\n    {\n      name: \"Ocean\",\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      clip: true,\n      renderItem: renderOcean,\n      data: [[LON_MIN, LAT_MIN]],\n      encode: { x: 0, y: 1 },\n      z: 0,\n    },\n    {\n      name: \"Coastline\",\n      type: \"line\",\n      coordinateSystem: \"cartesian2d\",\n      data: COASTLINE,\n      showSymbol: false,\n      smooth: 0.3,\n      lineStyle: { color: t.inkSoft, width: 2, opacity: 0.45 },\n      z: 1,\n      silent: true,\n    },\n    {\n      name: \"Pickup density\",\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      clip: true,\n      renderItem: renderHex,\n      data: hexData,\n      dimensions: [\"lon\", \"lat\", \"count\", \"meanFare\", \"sumFare\"],\n      encode: { x: 0, y: 1, tooltip: [2, 3, 4] },\n      tooltip: {\n        formatter: (params) =>\n          `Pickups: ${params.value[2]}<br/>Total fares: $${params.value[4].toFixed(2)}<br/>` +\n          `Avg fare: $${params.value[3].toFixed(2)}<br/>` +\n          `Center: ${params.value[1].toFixed(3)}°N, ${Math.abs(params.value[0]).toFixed(3)}°W`,\n      },\n      z: 3,\n    },\n  ],\n});\n"}