{"spec_id":"heatmap-geographic","library":"echarts","language":"javascript","code":"// anyplot.ai\n// heatmap-geographic: Geographic Heatmap for Spatial Density\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 85/100 | Created: 2026-09-02\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: synthetic retail-visit pings across San Francisco ---------------\n// Fixed-seed LCG (no seeded Math.random in the browser)\nfunction makeLcg(seed) {\n  let state = seed >>> 0;\n  return function rng() {\n    state = (Math.imul(state, 1664525) + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rng = makeLcg(42);\n\nfunction gaussian(mean, std) {\n  const u1 = Math.max(rng(), 1e-9);\n  const u2 = rng();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * std;\n}\n\n// Neighborhood clusters: center coord, point count, spread, visit-weight range.\n// Downtown/Mission lonStd/latStd (~0.22km) are narrow relative to their ~1.9km\n// separation, and the KDE bandwidth below is tightened to match, so the two\n// peaks stay distinct instead of blurring into a shared plateau. Sunset keeps\n// a wider spread (it's a more residential/coastal area) but its weightMean/n\n// are close enough to Downtown/Mission's that its peak still clears the\n// low-density baseline once the density is color-mapped (see the `scaled`\n// power-law compression below) - verified against the rendered PNG, not just\n// asserted here.\nconst neighborhoods = [\n  { name: \"Downtown\", lon: -122.4194, lat: 37.7749, n: 700, lonStd: 0.0025, latStd: 0.0021, weightMean: 62, weightStd: 20 },\n  { name: \"Mission\", lon: -122.4090, lat: 37.7599, n: 600, lonStd: 0.0025, latStd: 0.0021, weightMean: 50, weightStd: 18 },\n  { name: \"Sunset\", lon: -122.4862, lat: 37.7599, n: 540, lonStd: 0.0032, latStd: 0.0028, weightMean: 45, weightStd: 15 },\n];\n\nconst points = [];\nneighborhoods.forEach((c) => {\n  for (let i = 0; i < c.n; i++) {\n    points.push({\n      lon: gaussian(c.lon, c.lonStd),\n      lat: gaussian(c.lat, c.latStd),\n      weight: Math.max(5, gaussian(c.weightMean, c.weightStd)),\n    });\n  }\n});\n\n// --- Kernel density estimation onto a lon/lat grid --------------------------\nconst lonMin = -122.52, lonMax = -122.375;\nconst latMin = 37.735, latMax = 37.805;\nconst nx = 42, ny = 38;\nconst bandwidthLon = 0.0025, bandwidthLat = 0.0021;\n\nconst cellLon = (i) => lonMin + ((lonMax - lonMin) * i) / (nx - 1);\nconst cellLat = (j) => latMin + ((latMax - latMin) * j) / (ny - 1);\nconst lonIndexF = (lon) => ((lon - lonMin) / (lonMax - lonMin)) * (nx - 1);\nconst latIndexF = (lat) => ((lat - latMin) / (latMax - latMin)) * (ny - 1);\n\nconst rawGrid = [];\nlet maxDensity = 0;\nfor (let i = 0; i < nx; i++) {\n  const lon = cellLon(i);\n  for (let j = 0; j < ny; j++) {\n    const lat = cellLat(j);\n    let density = 0;\n    for (const p of points) {\n      const dLon = (lon - p.lon) / bandwidthLon;\n      const dLat = (lat - p.lat) / bandwidthLat;\n      density += p.weight * Math.exp(-0.5 * (dLon * dLon + dLat * dLat));\n    }\n    // Power-law (exponent 0.35, stronger than sqrt) compression: pulls the\n    // Downtown peak down relatively more than the Sunset peak, so all three\n    // named hotspots land in visibly distinct bands of the color scale\n    // instead of Sunset washing out near the zero-density baseline.\n    const scaled = Math.pow(density, 0.35);\n    rawGrid.push([i, j, scaled]);\n    if (scaled > maxDensity) maxDensity = scaled;\n  }\n}\n\n// Mask out the near-zero-density baseline (the vast majority of the grid)\n// instead of rendering it opaque, so the coastline basemap beneath actually\n// shows through per the spec's transparency requirement. Per-cell\n// itemStyle.opacity was tried first but produces visible seams between\n// adjacent translucent cells (a canvas anti-aliasing artifact); omitting\n// low-density cells entirely avoids that and reads as an honest density\n// floor besides.\nconst minDensity = 0.1 * maxDensity;\nconst grid = rawGrid.filter(([, , scaled]) => scaled >= minDensity);\n\nconst lonIndex = (lon) => Math.round(((lon - lonMin) / (lonMax - lonMin)) * (nx - 1));\nconst latIndex = (lat) => Math.round(((lat - latMin) / (latMax - latMin)) * (ny - 1));\nconst markers = neighborhoods.map((c) => ({\n  value: [lonIndex(c.lon), latIndex(c.lat)],\n  name: c.name,\n}));\n\nconst lonLabels = Array.from({ length: nx }, (_, i) => cellLon(i).toFixed(2));\nconst latLabels = Array.from({ length: ny }, (_, j) => cellLat(j).toFixed(2));\n\n// Simplified San Francisco shoreline (Ocean Beach -> Golden Gate -> northern\n// waterfront -> Bay side), hand-picked lon/lat vertices anchoring the density\n// grid to real geography.\nconst coastlinePoints = [\n  [-122.5090, 37.736],\n  [-122.5110, 37.752],\n  [-122.5115, 37.768],\n  [-122.5095, 37.784],\n  [-122.5010, 37.797],\n  [-122.4830, 37.804],\n  [-122.4520, 37.805],\n  [-122.4250, 37.804],\n  [-122.4040, 37.799],\n  [-122.3910, 37.789],\n  [-122.3855, 37.774],\n  [-122.3800, 37.758],\n  [-122.3770, 37.742],\n].map(([lon, lat]) => [lonIndexF(lon), latIndexF(lat)]);\n\n// --- Title (fontsize scales down for the descriptive prefix) ---------------\nconst title = \"San Francisco Retail Visits · heatmap-geographic · javascript · echarts · anyplot.ai\";\nconst titleFontSize = Math.max(14, Math.round(22 * Math.min(1, 67 / title.length)));\n\n// --- Init ---------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\n// --- Option ---------------------------------------------------------------\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  title: {\n    text: title,\n    left: \"center\",\n    top: 16,\n    textStyle: { color: t.ink, fontSize: titleFontSize, fontWeight: 500 },\n  },\n  grid: { left: 90, right: 40, top: 110, bottom: 190 },\n  xAxis: {\n    type: \"category\",\n    data: lonLabels,\n    name: \"Longitude\",\n    nameLocation: \"middle\",\n    nameGap: 60,\n    nameTextStyle: { color: t.inkSoft, fontSize: 14 },\n    axisLabel: { color: t.inkSoft, fontSize: 12, interval: 6, rotate: 45 },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    axisTick: { show: false },\n    splitArea: { show: false },\n  },\n  yAxis: {\n    type: \"category\",\n    data: latLabels,\n    name: \"Latitude\",\n    nameLocation: \"middle\",\n    nameGap: 55,\n    nameTextStyle: { color: t.inkSoft, fontSize: 14 },\n    axisLabel: { color: t.inkSoft, fontSize: 12, interval: 6 },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    axisTick: { show: false },\n    splitArea: { show: false },\n  },\n  visualMap: {\n    type: \"continuous\",\n    min: minDensity,\n    max: maxDensity,\n    calculable: false,\n    orient: \"horizontal\",\n    left: \"center\",\n    bottom: 30,\n    itemWidth: 16,\n    itemHeight: 220,\n    text: [\"High visit density\", \"Low\"],\n    textStyle: { color: t.inkSoft, fontSize: 12 },\n    inRange: { color: t.seq },\n  },\n  dataZoom: [\n    { type: \"inside\", xAxisIndex: 0 },\n    { type: \"inside\", yAxisIndex: 0 },\n  ],\n  series: [\n    {\n      // Drawn first (below the heatmap in z-order) as the basemap layer -\n      // visible wherever the masked density grid above leaves a gap.\n      name: \"Coastline\",\n      type: \"line\",\n      coordinateSystem: \"cartesian2d\",\n      data: coastlinePoints,\n      showSymbol: false,\n      smooth: 0.3,\n      lineStyle: { color: t.inkSoft, width: 2, type: \"dashed\", opacity: 0.7 },\n      z: 1,\n      silent: true,\n      tooltip: { show: false },\n    },\n    {\n      name: \"Visit density\",\n      type: \"heatmap\",\n      coordinateSystem: \"cartesian2d\",\n      data: grid,\n      progressive: 0,\n      itemStyle: { borderWidth: 0 },\n      z: 2,\n    },\n    {\n      name: \"Neighborhood\",\n      type: \"scatter\",\n      data: markers,\n      symbolSize: 7,\n      itemStyle: { color: t.ink, opacity: 0.6 },\n      label: {\n        show: true,\n        formatter: \"{b}\",\n        position: \"top\",\n        color: t.ink,\n        fontSize: 13,\n        fontWeight: 500,\n      },\n      z: 10,\n      tooltip: { show: false },\n    },\n  ],\n});\n"}