{"spec_id":"map-marker-clustered","library":"echarts","language":"javascript","code":"// anyplot.ai\n// map-marker-clustered: Clustered Marker Map\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 94/100 | Created: 2026-09-02\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\nconst size = window.ANYPLOT_SIZE;\n\n// --- Data: retail store locations around U.S. metro areas -------------------\n// Small fixed-seed LCG (no seeded Math.random in the browser).\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\n\nconst CATEGORIES = [\n  { name: \"Flagship\", color: t.palette[0] },\n  { name: \"Outlet\", color: t.palette[1] },\n  { name: \"Kiosk\", color: t.palette[2] },\n  { name: \"Partner\", color: t.palette[3] },\n];\n\nconst METROS = [\n  { name: \"New York\", lon: -74.0, lat: 40.71, n: 58, spread: 1.3 },\n  { name: \"Los Angeles\", lon: -118.24, lat: 34.05, n: 46, spread: 1.15 },\n  { name: \"Chicago\", lon: -87.63, lat: 41.88, n: 36, spread: 1.0 },\n  { name: \"Houston\", lon: -95.37, lat: 29.76, n: 31, spread: 1.05 },\n  { name: \"Miami\", lon: -80.19, lat: 25.76, n: 26, spread: 0.85 },\n  { name: \"Seattle\", lon: -122.33, lat: 47.61, n: 21, spread: 0.75 },\n  { name: \"Denver\", lon: -104.99, lat: 39.74, n: 17, spread: 0.8 },\n];\n\nconst stores = [];\nfor (const metro of METROS) {\n  for (let i = 0; i < metro.n; i++) {\n    const angle = rand() * Math.PI * 2;\n    const r = Math.pow(rand(), 0.5) * metro.spread;\n    stores.push({\n      lon: metro.lon + Math.cos(angle) * r,\n      lat: metro.lat + Math.sin(angle) * r * 0.65,\n      category: CATEGORIES[Math.floor(rand() * CATEGORIES.length)],\n    });\n  }\n}\n\n// Equirectangular projection, latitude-corrected — the simplest stand-in for\n// the Web Mercator projection real slippy-map tiles use.\nconst meanLatRad = (stores.reduce((s, p) => s + p.lat, 0) / stores.length) * (Math.PI / 180);\nconst lonScale = Math.cos(meanLatRad);\nconst project = (lon, lat) => [lon * lonScale, lat];\nfor (const p of stores) [p.x, p.y] = project(p.lon, p.lat);\n\nconst xs = stores.map((p) => p.x);\nconst ys = stores.map((p) => p.y);\nconst padX = (Math.max(...xs) - Math.min(...xs)) * 0.1;\nconst padY = (Math.max(...ys) - Math.min(...ys)) * 0.1;\nconst FULL_X_MIN = Math.min(...xs) - padX;\nconst FULL_X_MAX = Math.max(...xs) + padX;\nconst FULL_Y_MIN = Math.min(...ys) - padY;\nconst FULL_Y_MAX = Math.max(...ys) + padY;\n\n// Simplified continental-U.S. coastline/border outline — hardcoded, low-fidelity\n// but geographically real, gives the marker map its basemap context offline\n// (the render harness is sandboxed with no fetch/CDN, so no live tile provider).\nconst US_OUTLINE_LONLAT = [\n  [-124.7, 48.4], [-124.1, 44.6], [-124.0, 40.8], [-122.5, 37.8], [-120.6, 34.5],\n  [-117.2, 32.6], [-114.7, 32.5], [-111.0, 31.3], [-108.2, 31.3], [-106.5, 31.8],\n  [-104.9, 29.5], [-99.5, 26.4], [-97.4, 25.9], [-97.2, 27.8], [-95.3, 28.9],\n  [-93.8, 29.7], [-89.4, 29.2], [-85.0, 29.7], [-82.7, 27.8], [-81.8, 25.8],\n  [-80.2, 25.8], [-80.0, 26.7], [-81.5, 30.3], [-79.9, 32.8], [-77.9, 34.2],\n  [-76.5, 34.7], [-75.7, 35.2], [-76.0, 36.9], [-75.5, 38.3], [-74.0, 40.6],\n  [-71.0, 41.5], [-70.0, 42.0], [-70.2, 43.7], [-68.5, 44.3], [-67.0, 44.9],\n  [-68.3, 46.4], [-69.8, 47.3], [-71.0, 45.3], [-73.3, 45.0], [-76.0, 44.2],\n  [-79.2, 43.3], [-83.1, 42.3], [-84.5, 46.5], [-88.0, 48.0],\n  [-95.2, 49.0], [-104.0, 49.0], [-110.0, 49.0], [-116.0, 49.0], [-122.8, 49.0],\n  [-124.7, 48.4],\n];\nconst usOutline = US_OUTLINE_LONLAT.map(([lon, lat]) => project(lon, lat));\n\n// --- Layout -------------------------------------------------------------\nconst GRID = { left: 70, top: 130, right: 230, bottom: 60 };\n\n// --- Screen-space proximity clustering -------------------------------------\n// Union-find single-linkage merge: any two points closer than CELL_PX (in\n// screen pixels, at the *current* zoom window) join the same cluster. This\n// chains an entire metro area into one cluster regardless of where its points\n// happen to fall relative to a fixed grid line — unlike naive grid-bucket\n// clustering, adjacency near a cell boundary no longer splits one visual\n// blob into several overlapping circles. Threshold is fixed in CSS px, but\n// the data-space distance it covers grows or shrinks with the current zoom\n// window, so zooming in naturally splits clusters apart and zooming out\n// re-merges them.\nconst CELL_PX = 56;\n\nfunction clusterStores(xMin, xMax, yMin, yMax) {\n  const pxPerX = (size.width - GRID.left - GRID.right) / (xMax - xMin);\n  const pxPerY = (size.height - GRID.top - GRID.bottom) / (yMax - yMin);\n  const visible = stores.filter((p) => p.x >= xMin && p.x <= xMax && p.y >= yMin && p.y <= yMax);\n  const px = visible.map((p) => (p.x - xMin) * pxPerX);\n  const py = visible.map((p) => (p.y - yMin) * pxPerY);\n\n  const parent = visible.map((_, i) => i);\n  function find(i) {\n    while (parent[i] !== i) {\n      parent[i] = parent[parent[i]];\n      i = parent[i];\n    }\n    return i;\n  }\n  const thresh2 = CELL_PX * CELL_PX;\n  for (let i = 0; i < visible.length; i++) {\n    for (let j = i + 1; j < visible.length; j++) {\n      const dx = px[i] - px[j];\n      const dy = py[i] - py[j];\n      if (dx * dx + dy * dy <= thresh2) {\n        const ri = find(i);\n        const rj = find(j);\n        if (ri !== rj) parent[ri] = rj;\n      }\n    }\n  }\n\n  const groups = new Map();\n  visible.forEach((p, i) => {\n    const root = find(i);\n    if (!groups.has(root)) groups.set(root, []);\n    groups.get(root).push(p);\n  });\n\n  const clusters = [];\n  const singles = [];\n  for (const members of groups.values()) {\n    if (members.length === 1) {\n      singles.push(members[0]);\n      continue;\n    }\n    const counts = new Map();\n    for (const m of members) counts.set(m.category.name, (counts.get(m.category.name) || 0) + 1);\n    // Canonical CATEGORIES order (not sorted by count) so a cluster's ring\n    // always draws the same category in the same angular slot — comparing\n    // the color mix across clusters at a glance doesn't require re-reading\n    // each one from scratch.\n    const segments = CATEGORIES.map((cat) => ({\n      name: cat.name,\n      color: cat.color,\n      count: counts.get(cat.name) || 0,\n    })).filter((s) => s.count > 0);\n    clusters.push({\n      x: members.reduce((s, m) => s + m.x, 0) / members.length,\n      y: members.reduce((s, m) => s + m.y, 0) / members.length,\n      count: members.length,\n      segments,\n      breakdown: [...segments].sort((a, b) => b.count - a.count).map((s) => [s.name, s.count]),\n    });\n  }\n  return { clusters, singles };\n}\n\nconst clusterSize = (count) => 26 + Math.sqrt(count) * 7;\n\nfunction seriesFor(xMin, xMax, yMin, yMax) {\n  const { clusters, singles } = clusterStores(xMin, xMax, yMin, yMax);\n  return [\n    {\n      name: \"Coastline\",\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      renderItem(params, api) {\n        const points = usOutline.map((c) => api.coord(c));\n        return {\n          type: \"polyline\",\n          shape: { points },\n          style: { stroke: t.inkSoft, lineWidth: 1.5, fill: \"none\", opacity: 0.5 },\n        };\n      },\n      data: [0],\n      silent: true,\n      z: 0,\n    },\n    {\n      name: \"Stores\",\n      type: \"scatter\",\n      coordinateSystem: \"cartesian2d\",\n      data: singles.map((p) => ({\n        value: [p.x, p.y],\n        category: p.category.name,\n        itemStyle: { color: p.category.color },\n      })),\n      encode: { x: 0, y: 1 },\n      symbolSize: 12,\n      itemStyle: { borderColor: t.pageBg, borderWidth: 1.5, opacity: 0.9 },\n      z: 2,\n    },\n    {\n      // Donut-ring glyph: each cluster's category mix is drawn directly as\n      // ring segments (canonical category order, so the same type always\n      // lands in the same angular slot across clusters) instead of a single\n      // dominant-color dot — the breakdown reads at a glance, no hover\n      // needed. The hole is punched to the page background so the count\n      // label stays legible regardless of which colors sit in the ring.\n      name: \"Clusters\",\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      renderItem(params, api) {\n        const [cx, cy] = api.coord([api.value(0), api.value(1)]);\n        const c = clusters[params.dataIndex];\n        const outerR = clusterSize(c.count) / 2;\n        const innerR = outerR * 0.58;\n        const children = [\n          { type: \"circle\", shape: { cx, cy, r: innerR }, style: { fill: t.pageBg } },\n        ];\n        let angle = -Math.PI / 2;\n        for (const seg of c.segments) {\n          const sweep = (seg.count / c.count) * Math.PI * 2;\n          children.push({\n            type: \"sector\",\n            shape: { cx, cy, r: outerR, r0: innerR, startAngle: angle, endAngle: angle + sweep, clockwise: true },\n            style: { fill: seg.color, stroke: t.pageBg, lineWidth: 1.5 },\n          });\n          angle += sweep;\n        }\n        children.push({\n          type: \"circle\",\n          shape: { cx, cy, r: outerR },\n          style: { stroke: t.ink, lineWidth: 1, opacity: 0.12, fill: \"none\" },\n        });\n        children.push({\n          type: \"text\",\n          style: {\n            x: cx,\n            y: cy,\n            text: String(c.count),\n            fill: t.ink,\n            fontSize: 13,\n            fontWeight: \"bold\",\n            align: \"center\",\n            verticalAlign: \"middle\",\n          },\n        });\n        return { type: \"group\", children };\n      },\n      data: clusters.map((c) => ({ value: [c.x, c.y], count: c.count, breakdown: c.breakdown })),\n      encode: { x: 0, y: 1 },\n      cursor: \"pointer\",\n      z: 3,\n    },\n  ];\n}\n\n// --- Category color key (fixed screen-space graphic, stacked top-to-bottom) -\n// A manual key rather than the `legend` component: legend swatches derive\n// their color from a matching series name, but \"Flagship\"/\"Outlet\"/etc. are\n// per-point categories split across the \"Stores\" and \"Clusters\" series, not\n// series of their own — a plain `legend.data` list has nothing to bind to.\nconst KEY_X = size.width - GRID.right + 40;\nconst keyGraphics = [\n  {\n    type: \"text\",\n    left: KEY_X,\n    top: 150,\n    style: { text: \"Store type\", fill: t.inkSoft, fontSize: 13, fontWeight: 500 },\n  },\n];\nCATEGORIES.forEach((cat, i) => {\n  const cy = 180 + i * 30;\n  keyGraphics.push({\n    type: \"circle\",\n    shape: { cx: KEY_X + 7, cy, r: 7 },\n    style: { fill: cat.color },\n  });\n  keyGraphics.push({\n    type: \"text\",\n    left: KEY_X + 22,\n    top: cy - 8,\n    style: { text: cat.name, fill: t.inkSoft, fontSize: 14 },\n  });\n});\nkeyGraphics.push({\n  type: \"text\",\n  left: KEY_X,\n  top: 180 + CATEGORIES.length * 30 + 10,\n  style: {\n    text: \"Ring segments show the\\ncategory mix per cluster;\\ncenter number = store count\",\n    fill: t.inkSoft,\n    fontSize: 12,\n    lineHeight: 17,\n  },\n});\n\n// --- Init -----------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\nconst title = \"map-marker-clustered · javascript · echarts · anyplot.ai\";\n\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  title: {\n    text: title,\n    subtext: \"Scroll to zoom, drag a cluster into view, click a cluster to expand it\",\n    left: \"center\",\n    top: 24,\n    textStyle: { color: t.ink, fontSize: 22, fontWeight: 500 },\n    subtextStyle: { color: t.inkSoft, fontSize: 13 },\n  },\n  graphic: keyGraphics,\n  tooltip: {\n    trigger: \"item\",\n    formatter(params) {\n      if (params.seriesName === \"Clusters\") {\n        const lines = params.data.breakdown.map(([name, n]) => `${name}: ${n}`);\n        return `${params.data.count} stores<br/>${lines.join(\"<br/>\")}`;\n      }\n      if (params.seriesName === \"Stores\") return `${params.data.category} store`;\n      return \"\";\n    },\n  },\n  grid: GRID,\n  xAxis: {\n    type: \"value\",\n    min: FULL_X_MIN,\n    max: FULL_X_MAX,\n    show: false,\n  },\n  yAxis: {\n    type: \"value\",\n    min: FULL_Y_MIN,\n    max: FULL_Y_MAX,\n    show: false,\n  },\n  dataZoom: [\n    { type: \"inside\", xAxisIndex: 0, zoomOnMouseWheel: true, moveOnMouseMove: true, filterMode: \"none\" },\n    { type: \"inside\", yAxisIndex: 0, zoomOnMouseWheel: true, moveOnMouseMove: true, filterMode: \"none\" },\n  ],\n  series: seriesFor(FULL_X_MIN, FULL_X_MAX, FULL_Y_MIN, FULL_Y_MAX),\n});\n\n// Current visible window in data units, derived from the dataZoom components'\n// start/end percentages (valid whether the zoom came from the mouse wheel,\n// a drag-pan, or a dispatched action).\nfunction currentWindow() {\n  const [dzX, dzY] = chart.getOption().dataZoom;\n  const xMin = FULL_X_MIN + ((FULL_X_MAX - FULL_X_MIN) * dzX.start) / 100;\n  const xMax = FULL_X_MIN + ((FULL_X_MAX - FULL_X_MIN) * dzX.end) / 100;\n  const yMin = FULL_Y_MIN + ((FULL_Y_MAX - FULL_Y_MIN) * dzY.start) / 100;\n  const yMax = FULL_Y_MIN + ((FULL_Y_MAX - FULL_Y_MIN) * dzY.end) / 100;\n  return { xMin, xMax, yMin, yMax };\n}\n\n// Re-cluster whenever the visible window changes (scroll-zoom or pan), so\n// zooming in genuinely splits clusters into their member markers instead of\n// just rescaling the same fixed dots.\nchart.on(\"dataZoom\", () => {\n  const { xMin, xMax, yMin, yMax } = currentWindow();\n  chart.setOption({ series: seriesFor(xMin, xMax, yMin, yMax) });\n});\n\n// Click a cluster to zoom into its neighborhood — real dataZoom action, not a\n// drawn/faked \"expanded\" state.\nchart.on(\"click\", (params) => {\n  if (params.seriesName !== \"Clusters\") return;\n  const [cx, cy] = params.value;\n  const { xMin, xMax, yMin, yMax } = currentWindow();\n  const newXSpan = (xMax - xMin) * 0.4;\n  const newYSpan = (yMax - yMin) * 0.4;\n  chart.dispatchAction({\n    type: \"dataZoom\",\n    xAxisIndex: 0,\n    startValue: cx - newXSpan / 2,\n    endValue: cx + newXSpan / 2,\n  });\n  chart.dispatchAction({\n    type: \"dataZoom\",\n    yAxisIndex: 0,\n    startValue: cy - newYSpan / 2,\n    endValue: cy + newYSpan / 2,\n  });\n});\n"}