{"spec_id":"map-marker-clustered","library":"d3","language":"javascript","code":"// anyplot.ai\n// map-marker-clustered: Clustered Marker Map\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst SPEC_ID = \"map-marker-clustered\";\nconst margin = { top: 100, right: 230, bottom: 24, left: 40 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// A tiny fixed-seed LCG stands in for the browser's lack of a seeded RNG.\nfunction lcg(seed) {\n  let state = seed >>> 0;\n  return () => {\n    state = (Math.imul(state, 1664525) + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rand = lcg(20260902);\n\nconst CATEGORIES = [\"Cafe\", \"Retail\", \"Grocery\", \"Pharmacy\"];\n// A 3x3 grid of districts, spaced generously relative to the per-point jitter\n// below so each forms one cohesive blob at the default zoom instead of\n// fragmenting or bleeding into its neighbor — and fills the landscape canvas\n// better than a sparser layout would.\nconst NEIGHBORHOODS = [\n  { name: \"Northpark\", lon: -122.47, lat: 37.85, n: 42, primary: \"Cafe\" },\n  { name: \"Downtown\", lon: -122.4, lat: 37.85, n: 68, primary: \"Retail\" },\n  { name: \"Eastgate\", lon: -122.33, lat: 37.85, n: 50, primary: \"Pharmacy\" },\n  { name: \"Lakeside\", lon: -122.47, lat: 37.815, n: 30, primary: \"Grocery\" },\n  { name: \"Midtown\", lon: -122.4, lat: 37.815, n: 58, primary: \"Cafe\" },\n  { name: \"Harbor\", lon: -122.33, lat: 37.815, n: 34, primary: \"Retail\" },\n  { name: \"Riverside\", lon: -122.47, lat: 37.78, n: 44, primary: \"Grocery\" },\n  { name: \"Hillcrest\", lon: -122.4, lat: 37.78, n: 36, primary: \"Pharmacy\" },\n  { name: \"Old Town\", lon: -122.33, lat: 37.78, n: 28, primary: \"Cafe\" },\n];\n\nconst data = [];\nfor (const nb of NEIGHBORHOODS) {\n  for (let i = 0; i < nb.n; i++) {\n    const jitterLon = ((rand() + rand() + rand() - 1.5) / 1.5) * 0.0022;\n    const jitterLat = ((rand() + rand() + rand() - 1.5) / 1.5) * 0.0022;\n    const category = rand() < 0.6 ? nb.primary : CATEGORIES[Math.floor(rand() * CATEGORIES.length)];\n    data.push({ lon: nb.lon + jitterLon, lat: nb.lat + jitterLat, category });\n  }\n}\n\n// --- Mount + projection ------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst color = d3.scaleOrdinal().domain(CATEGORIES).range(t.palette);\n\n// A city-scale extent is small enough that Mercator curvature is negligible,\n// so lon/lat map straight onto the canvas via independent linear scales —\n// this fills the drawing area edge-to-edge instead of Mercator's fitExtent\n// letterboxing to a fixed true-aspect ratio.\nconst pad = 60;\nconst lonScale = d3.scaleLinear().domain(d3.extent(data, (d) => d.lon)).range([pad, iw - pad]);\nconst latScale = d3.scaleLinear().domain(d3.extent(data, (d) => d.lat)).range([ih - pad, pad]);\nconst basePoints = data.map((d) => ({ x: lonScale(d.lon), y: latScale(d.lat), category: d.category }));\n\nconst mapG = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// Background + pointer-event surface for panning/zooming the map.\nconst captureRect = mapG\n  .append(\"rect\")\n  .attr(\"width\", iw)\n  .attr(\"height\", ih)\n  .attr(\"fill\", t.pageBg)\n  .attr(\"pointer-events\", \"all\");\n\n// A fixed clip frame keeps the viewport stable while its content pans/zooms.\nsvg\n  .append(\"defs\")\n  .append(\"clipPath\")\n  .attr(\"id\", \"map-clip\")\n  .append(\"rect\")\n  .attr(\"width\", iw)\n  .attr(\"height\", ih);\nconst clipG = mapG.append(\"g\").attr(\"clip-path\", \"url(#map-clip)\");\nconst zoomLayer = clipG.append(\"g\");\nconst streetsLayer = zoomLayer.append(\"g\");\nconst hullLayer = zoomLayer.append(\"g\");\nconst focusLayer = zoomLayer.append(\"g\");\nconst markerLayer = zoomLayer.append(\"g\");\n\n// Stylized city-street grid basemap (geographic context per the spec notes).\n// non-scaling-stroke keeps line thickness constant on screen while zoomLayer scales.\nconst streetCols = 13;\nconst streetRows = 8;\n\n// Subtle \"city block\" fills between the grid lines — purely decorative basemap\n// texture (a deterministic checkerboard-like pattern, not random) that fills\n// the dead space between clusters so the default view reads as a real city\n// fabric instead of an empty grid. Drawn before the street lines so the lines\n// sit on top of the blocks.\nfor (let col = 0; col < streetCols; col++) {\n  for (let row = 0; row < streetRows; row++) {\n    if ((col * 3 + row * 2) % 5 >= 2) continue;\n    const bw = iw / streetCols;\n    const bh = ih / streetRows;\n    streetsLayer\n      .append(\"rect\")\n      .attr(\"x\", col * bw + 3)\n      .attr(\"y\", row * bh + 3)\n      .attr(\"width\", bw - 6)\n      .attr(\"height\", bh - 6)\n      .attr(\"fill\", t.ink)\n      .attr(\"opacity\", 0.05);\n  }\n}\n\nfor (let i = 1; i < streetCols; i++) {\n  const x = (i * iw) / streetCols;\n  streetsLayer\n    .append(\"line\")\n    .attr(\"x1\", x)\n    .attr(\"y1\", 0)\n    .attr(\"x2\", x)\n    .attr(\"y2\", ih)\n    .attr(\"stroke\", t.grid)\n    .attr(\"stroke-width\", i % 4 === 0 ? 1.8 : 0.9)\n    .style(\"vector-effect\", \"non-scaling-stroke\");\n}\nfor (let j = 1; j < streetRows; j++) {\n  const y = (j * ih) / streetRows;\n  streetsLayer\n    .append(\"line\")\n    .attr(\"x1\", 0)\n    .attr(\"y1\", y)\n    .attr(\"x2\", iw)\n    .attr(\"y2\", y)\n    .attr(\"stroke\", t.grid)\n    .attr(\"stroke-width\", j % 3 === 0 ? 1.8 : 0.9)\n    .style(\"vector-effect\", \"non-scaling-stroke\");\n}\n\nmapG.append(\"rect\").attr(\"width\", iw).attr(\"height\", ih).attr(\"fill\", \"none\").attr(\"stroke\", t.inkSoft).attr(\"stroke-width\", 1.5);\n\n// --- Clustering ---------------------------------------------------------------\n// Chain (flood-fill) proximity clustering: a point joins a cluster if it's\n// within `radius` screen px of ANY member already in it, not just the seed —\n// this keeps a dense blob as one cluster instead of fragmenting into several\n// overlapping sub-circles. Effective radius shrinks as zoomLayer's scale\n// grows, so a fixed on-screen distance drives clusters apart while zooming.\nfunction clusterPoints(points, radius) {\n  const n = points.length;\n  const used = new Array(n).fill(false);\n  const groups = [];\n  for (let i = 0; i < n; i++) {\n    if (used[i]) continue;\n    const group = [points[i]];\n    used[i] = true;\n    const frontier = [points[i]];\n    while (frontier.length) {\n      const p = frontier.pop();\n      for (let j = 0; j < n; j++) {\n        if (used[j]) continue;\n        if (Math.hypot(p.x - points[j].x, p.y - points[j].y) < radius) {\n          used[j] = true;\n          group.push(points[j]);\n          frontier.push(points[j]);\n        }\n      }\n    }\n    groups.push(group);\n  }\n  return groups;\n}\n\nconst CLUSTER_RADIUS_PX = 52;\nconst SCALE_MAX = 9;\n\nfunction dominantCategory(members) {\n  const counts = d3.rollup(\n    members,\n    (v) => v.length,\n    (m) => m.category\n  );\n  return Array.from(counts).sort((a, b) => b[1] - a[1])[0][0];\n}\n\nfunction radiusFor(count) {\n  return count === 1 ? 7 : Math.min(70, 18 + Math.sqrt(count) * 4.5);\n}\n\nfunction showHull(d, k) {\n  hullLayer.selectAll(\"*\").remove();\n  if (d.count < 2) return;\n  const strokeW = 1.5 / k;\n  if (d.count === 2) {\n    for (const m of d.members) {\n      hullLayer\n        .append(\"line\")\n        .attr(\"x1\", d.x)\n        .attr(\"y1\", d.y)\n        .attr(\"x2\", m.x)\n        .attr(\"y2\", m.y)\n        .attr(\"stroke\", color(d.category))\n        .attr(\"stroke-width\", strokeW)\n        .attr(\"stroke-dasharray\", `${4 / k},${3 / k}`);\n    }\n  } else {\n    const hull = d3.polygonHull(d.members.map((m) => [m.x, m.y]));\n    if (hull) {\n      hullLayer\n        .append(\"polygon\")\n        .attr(\"points\", hull.map((p) => p.join(\",\")).join(\" \"))\n        .attr(\"fill\", color(d.category))\n        .attr(\"fill-opacity\", 0.12)\n        .attr(\"stroke\", color(d.category))\n        .attr(\"stroke-width\", strokeW);\n    }\n  }\n}\nfunction hideHull() {\n  hullLayer.selectAll(\"*\").remove();\n}\n\nfunction zoomToCluster(d) {\n  if (d.count < 2) return;\n  const current = d3.zoomTransform(captureRect.node());\n  const k2 = Math.min(SCALE_MAX, current.k * 2.4);\n  const target = d3.zoomIdentity.translate(iw / 2, ih / 2).scale(k2).translate(-d.x, -d.y);\n  captureRect.transition().duration(750).call(zoomBehavior.transform, target);\n}\n\n// Re-clusters and redraws markers for the current zoom transform. Called once\n// on mount and again on every zoom/pan event, so cluster membership genuinely\n// tracks the live zoom level rather than a fixed pre-baked state.\nfunction update(transform) {\n  const k = transform.k;\n  const groups = clusterPoints(basePoints, CLUSTER_RADIUS_PX / k);\n  const clusters = groups.map((members) => ({\n    x: d3.mean(members, (m) => m.x),\n    y: d3.mean(members, (m) => m.y),\n    count: members.length,\n    category: dominantCategory(members),\n    members,\n  }));\n\n  const sel = markerLayer.selectAll(\"g.cluster\").data(clusters);\n  sel.exit().remove();\n  const enter = sel.enter().append(\"g\").attr(\"class\", \"cluster\");\n  enter.append(\"circle\");\n  enter.append(\"text\");\n  const merged = enter.merge(sel);\n\n  merged\n    .attr(\"transform\", (d) => `translate(${d.x},${d.y})`)\n    .style(\"cursor\", (d) => (d.count > 1 ? \"pointer\" : \"default\"))\n    .on(\"mouseenter\", (event, d) => showHull(d, k))\n    .on(\"mouseleave\", hideHull)\n    .on(\"click\", (event, d) => zoomToCluster(d));\n\n  merged\n    .select(\"circle\")\n    .attr(\"r\", (d) => radiusFor(d.count) / k)\n    .attr(\"fill\", (d) => color(d.category))\n    .attr(\"fill-opacity\", (d) => (d.count > 1 ? 0.88 : 1))\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", (d) => (d.count > 1 ? 3 : 2) / k)\n    .style(\"vector-effect\", \"non-scaling-stroke\");\n\n  merged\n    .select(\"text\")\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"dy\", \"0.35em\")\n    .style(\"font-size\", `${15 / k}px`)\n    .style(\"font-weight\", \"600\")\n    .style(\"fill\", \"#FFFFFF\")\n    .style(\"pointer-events\", \"none\")\n    .text((d) => (d.count > 1 ? d.count : \"\"));\n\n  // Focal point: recomputed every update so it always tracks whichever\n  // cluster is currently largest — a highlight ring + callout instead of a\n  // flat scene where every cluster carries equal visual weight.\n  focusLayer.selectAll(\"*\").remove();\n  const busiest = clusters.reduce((a, b) => (b.count > a.count ? b : a), clusters[0]);\n  if (busiest && busiest.count > 1) {\n    const ringR = radiusFor(busiest.count) / k + 10 / k;\n    const labelGap = 20 / k;\n    // Flip the callout below the ring when there isn't room above, so it\n    // never gets cropped by the map's clip frame for a top-row cluster.\n    const labelY = busiest.y - ringR - labelGap > 18 / k ? busiest.y - ringR - 8 / k : busiest.y + ringR + labelGap;\n    focusLayer\n      .append(\"circle\")\n      .attr(\"cx\", busiest.x)\n      .attr(\"cy\", busiest.y)\n      .attr(\"r\", ringR)\n      .attr(\"fill\", \"none\")\n      .attr(\"stroke\", t.ink)\n      .attr(\"stroke-width\", 1.5 / k)\n      .attr(\"stroke-dasharray\", `${5 / k},${4 / k}`)\n      .style(\"vector-effect\", \"non-scaling-stroke\")\n      .style(\"pointer-events\", \"none\");\n    focusLayer\n      .append(\"text\")\n      .attr(\"x\", busiest.x)\n      .attr(\"y\", labelY)\n      .attr(\"text-anchor\", \"middle\")\n      .attr(\"fill\", t.ink)\n      .style(\"font-size\", `${12.5 / k}px`)\n      .style(\"font-weight\", \"600\")\n      .style(\"pointer-events\", \"none\")\n      .text(\"Busiest cluster\");\n  }\n}\n\n// --- Zoom / pan behavior -------------------------------------------------------\nfunction zoomed(event) {\n  zoomLayer.attr(\"transform\", event.transform);\n  update(event.transform);\n}\nconst zoomBehavior = d3\n  .zoom()\n  .scaleExtent([1, SCALE_MAX])\n  .translateExtent([\n    [0, 0],\n    [iw, ih],\n  ])\n  .extent([\n    [0, 0],\n    [iw, ih],\n  ])\n  .on(\"zoom\", zoomed);\ncaptureRect.call(zoomBehavior).call(zoomBehavior.transform, d3.zoomIdentity);\n\n// --- Title + subtitle -----------------------------------------------------------\nconst titleText = `City Store Locator · ${SPEC_ID} · javascript · d3 · anyplot.ai`;\nconst titleFontSize = titleText.length > 67 ? Math.round((22 * 67) / titleText.length) : 22;\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 44)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(titleText);\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 74)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(`${data.length} store locations across ${NEIGHBORHOODS.length} neighborhoods — drag to pan, scroll to zoom, click a cluster to expand`);\n\n// --- Legend (dedicated right-margin column, never overlaps map content) --------\nconst legendW = 190;\nconst legendH = 46 + CATEGORIES.length * 24 + 26;\nconst legendG = mapG.append(\"g\").attr(\"transform\", `translate(${iw + 20},0)`);\nlegendG.append(\"rect\").attr(\"width\", legendW).attr(\"height\", legendH).attr(\"fill\", t.elevatedBg).attr(\"stroke\", t.grid).attr(\"rx\", 6);\nlegendG.append(\"text\").attr(\"x\", 14).attr(\"y\", 24).attr(\"fill\", t.ink).style(\"font-size\", \"13px\").style(\"font-weight\", \"600\").text(\"Category\");\nCATEGORIES.forEach((cat, i) => {\n  const gy = 44 + i * 24;\n  legendG.append(\"rect\").attr(\"x\", 14).attr(\"y\", gy - 11).attr(\"width\", 13).attr(\"height\", 13).attr(\"rx\", 3).attr(\"fill\", color(cat));\n  legendG.append(\"text\").attr(\"x\", 34).attr(\"y\", gy).attr(\"fill\", t.inkSoft).style(\"font-size\", \"12.5px\").text(cat);\n});\nlegendG\n  .append(\"text\")\n  .attr(\"x\", 14)\n  .attr(\"y\", 44 + CATEGORIES.length * 24 + 16)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"11px\")\n  .style(\"opacity\", 0.85)\n  .text(\"Circle size = clustered count\");\n"}