{"spec_id":"map-marker-clustered","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// map-marker-clustered: Clustered Marker Map\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 82/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst CATEGORIES = [\"Flagship\", \"Standard\", \"Outlet\"];\n\n// --- Deterministic PRNG (browser has no seeded Math.random) ----------------\nfunction makeLcg(seed) {\n  let state = seed >>> 0;\n  return function rand() {\n    state = (1103515245 * state + 12345) >>> 0;\n    return state / 4294967296;\n  };\n}\nfunction gaussian(rand) {\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 pickCategory(rand, weights) {\n  const r = rand();\n  if (r < weights[0]) return \"Flagship\";\n  if (r < weights[0] + weights[1]) return \"Standard\";\n  return \"Outlet\";\n}\n\n// --- Data: individual store locations around metro hubs --------------------\n// Each hub carries its own Flagship/Standard/Outlet mix, so different regions\n// have a different dominant store type on the clustered map.\nconst rand = makeLcg(42);\nconst HUBS = [\n  { lon: -122.3, lat: 47.6, weights: [0.1, 0.75, 0.15], count: 35 }, // Seattle — Standard-heavy\n  { lon: -118.2, lat: 34.0, weights: [0.55, 0.35, 0.1], count: 65 }, // Los Angeles — Flagship-heavy\n  { lon: -104.9, lat: 39.7, weights: [0.05, 0.35, 0.6], count: 20 }, // Denver — Outlet-heavy\n  { lon: -96.8, lat: 32.8, weights: [0.1, 0.7, 0.2], count: 55 }, // Dallas — Standard-heavy\n  { lon: -87.6, lat: 41.9, weights: [0.15, 0.65, 0.2], count: 70 }, // Chicago — Standard-heavy\n  { lon: -84.4, lat: 33.7, weights: [0.05, 0.35, 0.6], count: 25 }, // Atlanta — Outlet-heavy\n  { lon: -74.0, lat: 40.7, weights: [0.6, 0.35, 0.05], count: 80 }, // New York — Flagship-heavy\n  { lon: -80.2, lat: 25.8, weights: [0.15, 0.6, 0.25], count: 40 }, // Miami — Standard-heavy\n];\n\nconst stores = [];\nHUBS.forEach((hub) => {\n  for (let i = 0; i < hub.count; i += 1) {\n    stores.push({\n      lon: hub.lon + gaussian(rand) * 0.55,\n      lat: hub.lat + gaussian(rand) * 0.45,\n      category: pickCategory(rand, hub.weights),\n    });\n  }\n});\n\n// --- Cluster a set of stores by greedy nearest-centroid grouping -----------\n// A point joins the closest existing cluster within `radius`, else it seeds a\n// new one — the same distance-based grouping marker-cluster libraries use.\n// Re-run at a tighter radius (and over a narrower point subset) on click to\n// drive zoom-level-dependent re-clustering.\nconst CLUSTER_RADIUS_DEG = 3.5;\nconst MIN_CLUSTER_RADIUS_DEG = 0.05;\nconst radiusFor = (count) => Math.min(70, Math.max(10, 8 + 6 * Math.sqrt(count)));\n\nfunction buildClusters(points, radius) {\n  const rawClusters = [];\n  points.forEach((store) => {\n    let nearest = null;\n    let nearestDist = Infinity;\n    rawClusters.forEach((cluster) => {\n      const dLon = store.lon - cluster.lonSum / cluster.count;\n      const dLat = store.lat - cluster.latSum / cluster.count;\n      const dist = Math.sqrt(dLon * dLon + dLat * dLat);\n      if (dist < radius && dist < nearestDist) {\n        nearestDist = dist;\n        nearest = cluster;\n      }\n    });\n    if (!nearest) {\n      nearest = { lonSum: 0, latSum: 0, count: 0, byCategory: {}, members: [] };\n      rawClusters.push(nearest);\n    }\n    nearest.lonSum += store.lon;\n    nearest.latSum += store.lat;\n    nearest.count += 1;\n    nearest.byCategory[store.category] = (nearest.byCategory[store.category] || 0) + 1;\n    nearest.members.push(store);\n  });\n\n  return rawClusters.map((cell) => {\n    const dominant = CATEGORIES.reduce(\n      (best, cat) => ((cell.byCategory[cat] || 0) > (cell.byCategory[best] || 0) ? cat : best),\n      CATEGORIES[0],\n    );\n    return {\n      x: cell.lonSum / cell.count,\n      y: cell.latSum / cell.count,\n      r: radiusFor(cell.count),\n      count: cell.count,\n      category: dominant,\n      members: cell.members,\n    };\n  });\n}\n\nfunction datasetsFor(clusterList) {\n  return CATEGORIES.map((category, i) => ({\n    label: category,\n    data: clusterList.filter((c) => c.category === category),\n    backgroundColor: t.palette[i],\n    borderColor: t.pageBg,\n    borderWidth: 2,\n  }));\n}\n\n// --- Simplified contiguous-US outline for geographic context ---------------\nconst US_OUTLINE = [\n  [-124.7, 48.4], [-124.2, 43.8], [-122.5, 37.8], [-117.2, 32.7],\n  [-114.7, 32.5], [-111.0, 31.3], [-108.2, 31.3], [-106.5, 31.8],\n  [-104.9, 29.4], [-99.5, 26.4], [-97.4, 25.9], [-97.1, 27.8],\n  [-93.8, 29.7], [-89.4, 29.2], [-85.0, 29.7], [-82.7, 27.9],\n  [-81.5, 25.2], [-80.1, 25.8], [-80.0, 26.7], [-81.5, 30.3],\n  [-79.9, 32.8], [-77.9, 34.2], [-76.0, 36.9], [-75.5, 39.4],\n  [-74.0, 40.7], [-70.3, 41.8], [-70.3, 43.7], [-67.0, 45.1],\n  [-71.5, 45.0], [-79.2, 43.3], [-83.1, 42.3], [-84.5, 46.5],\n  [-89.6, 48.0], [-95.2, 49.0], [-104.0, 49.0], [-114.0, 49.0],\n  [-123.0, 49.0],\n];\n\nconst FULL_BOUNDS = { xMin: -128, xMax: -63, yMin: 23, yMax: 50 };\n\nfunction boundsForMembers(members) {\n  const lons = members.map((m) => m.lon);\n  const lats = members.map((m) => m.lat);\n  const spreadLon = Math.max(...lons) - Math.min(...lons);\n  const spreadLat = Math.max(...lats) - Math.min(...lats);\n  const padLon = Math.max(0.6, spreadLon * 0.4);\n  const padLat = Math.max(0.6, spreadLat * 0.4);\n  return {\n    xMin: Math.min(...lons) - padLon,\n    xMax: Math.max(...lons) + padLon,\n    yMin: Math.min(...lats) - padLat,\n    yMax: Math.max(...lats) + padLat,\n  };\n}\n\n// --- Interaction state (hover spider lines, click-to-expand drill-down) ----\nlet hoveredCluster = null;\nlet currentRadius = CLUSTER_RADIUS_DEG;\n\nfunction applyView(chart, points, radius, bounds) {\n  const clusters = buildClusters(points, radius);\n  chart.data.datasets.forEach((ds, i) => {\n    ds.data = clusters.filter((c) => c.category === CATEGORIES[i]);\n  });\n  chart.options.scales.x.min = bounds.xMin;\n  chart.options.scales.x.max = bounds.xMax;\n  chart.options.scales.y.min = bounds.yMin;\n  chart.options.scales.y.max = bounds.yMax;\n  // Static PNG render happens before any user interaction, so re-enabling\n  // animation here (initial chart creation keeps `animation: false`) only\n  // affects the interactive HTML — it drives the smooth zoom transition.\n  chart.options.animation = { duration: 450, easing: \"easeInOutQuad\" };\n  chart.update();\n}\n\nconst basemapPlugin = {\n  id: \"basemapOutline\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const pts = US_OUTLINE.map(([lon, lat]) => [\n      scales.x.getPixelForValue(lon),\n      scales.y.getPixelForValue(lat),\n    ]);\n    ctx.save();\n    ctx.beginPath();\n    const start = pts[pts.length - 1];\n    ctx.moveTo((start[0] + pts[0][0]) / 2, (start[1] + pts[0][1]) / 2);\n    // Quadratic-through-midpoints smoothing: rounds every vertex into a curve\n    // instead of a hard corner, so the coastline reads as a basemap outline\n    // rather than a jagged straight-segment polygon.\n    for (let i = 0; i < pts.length; i += 1) {\n      const curr = pts[i];\n      const next = pts[(i + 1) % pts.length];\n      const midX = (curr[0] + next[0]) / 2;\n      const midY = (curr[1] + next[1]) / 2;\n      ctx.quadraticCurveTo(curr[0], curr[1], midX, midY);\n    }\n    ctx.closePath();\n    ctx.fillStyle = t.elevatedBg;\n    ctx.fill();\n    ctx.globalAlpha = 0.6;\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 1.5;\n    ctx.stroke();\n    ctx.restore();\n  },\n};\n\nconst clusterCountPlugin = {\n  id: \"clusterCount\",\n  afterDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    ctx.save();\n    ctx.font = \"600 15px sans-serif\";\n    ctx.fillStyle = \"#FFFFFF\";\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    chart.data.datasets.forEach((dataset) => {\n      dataset.data.forEach((point) => {\n        if (point.count <= 1) return; // a lone expanded marker needs no count badge\n        const px = scales.x.getPixelForValue(point.x);\n        const py = scales.y.getPixelForValue(point.y);\n        ctx.fillText(String(point.count), px, py);\n      });\n    });\n    ctx.restore();\n  },\n};\n\nconst spiderLinesPlugin = {\n  id: \"spiderLines\",\n  afterDatasetsDraw(chart) {\n    if (!hoveredCluster || hoveredCluster.count <= 1) return;\n    const { ctx, scales } = chart;\n    const cx = scales.x.getPixelForValue(hoveredCluster.x);\n    const cy = scales.y.getPixelForValue(hoveredCluster.y);\n    ctx.save();\n    ctx.strokeStyle = t.inkSoft;\n    ctx.globalAlpha = 0.5;\n    ctx.lineWidth = 1;\n    hoveredCluster.members.forEach((member) => {\n      const mx = scales.x.getPixelForValue(member.lon);\n      const my = scales.y.getPixelForValue(member.lat);\n      ctx.beginPath();\n      ctx.moveTo(cx, cy);\n      ctx.lineTo(mx, my);\n      ctx.stroke();\n    });\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart ---------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"bubble\",\n  data: { datasets: datasetsFor(buildClusters(stores, CLUSTER_RADIUS_DEG)) },\n  plugins: [basemapPlugin, clusterCountPlugin, spiderLinesPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 16 },\n    interaction: { mode: \"nearest\", intersect: true },\n    onClick(event, elements, chart) {\n      if (!elements.length) {\n        if (currentRadius !== CLUSTER_RADIUS_DEG) {\n          currentRadius = CLUSTER_RADIUS_DEG;\n          hoveredCluster = null;\n          applyView(chart, stores, currentRadius, FULL_BOUNDS);\n        }\n        return;\n      }\n      const el = elements[0];\n      const cluster = chart.data.datasets[el.datasetIndex].data[el.index];\n      if (cluster.count <= 1 || currentRadius <= MIN_CLUSTER_RADIUS_DEG) return;\n      currentRadius = Math.max(MIN_CLUSTER_RADIUS_DEG, currentRadius / 3);\n      hoveredCluster = null;\n      applyView(chart, cluster.members, currentRadius, boundsForMembers(cluster.members));\n    },\n    onHover(event, elements, chart) {\n      const el = elements[0];\n      const cluster = el ? chart.data.datasets[el.datasetIndex].data[el.index] : null;\n      const next = cluster && cluster.count > 1 ? cluster : null;\n      chart.canvas.style.cursor = cluster && cluster.count > 1 ? \"pointer\" : \"default\";\n      if (next !== hoveredCluster) {\n        hoveredCluster = next;\n        chart.draw();\n      }\n    },\n    plugins: {\n      title: {\n        display: true,\n        text: \"map-marker-clustered · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 24, weight: \"500\" },\n        padding: { bottom: 20 },\n      },\n      legend: {\n        position: \"bottom\",\n        labels: { color: t.ink, font: { size: 16 }, usePointStyle: true, pointStyle: \"circle\" },\n      },\n      tooltip: {\n        callbacks: {\n          label: (ctx) => {\n            const point = ctx.raw;\n            const noun = point.count === 1 ? \"store\" : \"stores\";\n            const hint = point.count > 1 ? \" — click to expand\" : \"\";\n            return `${ctx.dataset.label}: ${point.count} ${noun}${hint}`;\n          },\n        },\n      },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: FULL_BOUNDS.xMin,\n        max: FULL_BOUNDS.xMax,\n        title: { display: true, text: \"Longitude (°)\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 14 }, callback: (v) => `${v}°` },\n        grid: { color: t.grid, lineWidth: 0.5 },\n      },\n      y: {\n        type: \"linear\",\n        min: FULL_BOUNDS.yMin,\n        max: FULL_BOUNDS.yMax,\n        title: { display: true, text: \"Latitude (°)\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 14 }, callback: (v) => `${v}°` },\n        grid: { color: t.grid, lineWidth: 0.5 },\n      },\n    },\n  },\n});\n"}