{"spec_id":"map-marker-clustered","library":"muix","language":"javascript","code":"// anyplot.ai\n// map-marker-clustered: Clustered Marker Map\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 81/100 | Created: 2026-09-02\n\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { ScatterPlot } from \"@mui/x-charts/ScatterChart\";\nimport { ChartsXAxis } from \"@mui/x-charts/ChartsXAxis\";\nimport { ChartsYAxis } from \"@mui/x-charts/ChartsYAxis\";\nimport { ChartsGrid } from \"@mui/x-charts/ChartsGrid\";\nimport { ChartsTooltip } from \"@mui/x-charts/ChartsTooltip\";\nimport { useXScale, useYScale } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst MARGIN = { top: 24, right: 56, bottom: 84, left: 96 };\nconst TITLE_HEIGHT = 56;\nconst LEGEND_HEIGHT = 48;\n\n// --- Reproducible LCG (seed 42) — no Math.random() in the browser harness ---\nlet seed = 42;\nfunction rng() {\n  seed = (1664525 * seed + 1013904223) >>> 0;\n  return seed / 4294967296;\n}\nfunction randomNormal(mean, stdDev) {\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 * stdDev;\n}\n\n// --- Data: TrailBrew Coffee Co. store locations across the Pacific Northwest,\n// grid-clustered the way a marker-cluster map aggregates pins at a fixed zoom\n// level — dense city hubs collapse into count-labeled clusters, rural stores\n// stay as individual markers. -------------------------------------------------\nconst CATEGORIES = [\"Flagship\", \"Standard\", \"Kiosk\"];\n\nconst HUBS = [\n  { lat: 47.6062, lon: -122.3321, count: 55, spread: 0.22, categoryBias: 0 }, // Seattle\n  { lat: 45.5152, lon: -122.6784, count: 42, spread: 0.2, categoryBias: 1 }, // Portland\n  { lat: 47.2529, lon: -122.4443, count: 26, spread: 0.16, categoryBias: 2 }, // Tacoma\n  { lat: 48.7519, lon: -122.4787, count: 18, spread: 0.14, categoryBias: 0 }, // Bellingham\n  { lat: 44.0582, lon: -123.0868, count: 16, spread: 0.14, categoryBias: 1 }, // Eugene\n];\n\nconst LAT_RANGE = [43.7, 49.2];\nconst LON_RANGE = [-124.6, -119.8];\nconst RURAL_STORE_COUNT = 22;\n\nconst stores = [];\nHUBS.forEach((hub) => {\n  for (let i = 0; i < hub.count; i++) {\n    const categoryIndex =\n      rng() < 0.65 ? hub.categoryBias : Math.floor(rng() * CATEGORIES.length);\n    stores.push({\n      lat: hub.lat + randomNormal(0, hub.spread),\n      lon: hub.lon + randomNormal(0, hub.spread * 1.3),\n      category: CATEGORIES[categoryIndex],\n    });\n  }\n});\nfor (let i = 0; i < RURAL_STORE_COUNT; i++) {\n  stores.push({\n    lat: LAT_RANGE[0] + rng() * (LAT_RANGE[1] - LAT_RANGE[0]),\n    lon: LON_RANGE[0] + rng() * (LON_RANGE[1] - LON_RANGE[0]),\n    category: CATEGORIES[Math.floor(rng() * CATEGORIES.length)],\n  });\n}\n\n// --- Grid-based proximity clustering (fixed zoom level) ---------------------\n// Cell size tuned so adjacent cluster bubbles don't visually overlap at this\n// canvas scale (larger cells merge nearby stores into a single bubble\n// instead of leaving crowded neighbors).\nconst LAT_CELL = 0.55;\nconst LON_CELL = 0.75;\n\nconst cells = new Map();\nstores.forEach((store) => {\n  const key = `${Math.floor(store.lat / LAT_CELL)}_${Math.floor(store.lon / LON_CELL)}`;\n  if (!cells.has(key)) cells.set(key, []);\n  cells.get(key).push(store);\n});\n\nfunction dominantCategory(members) {\n  const counts = {};\n  members.forEach((m) => {\n    counts[m.category] = (counts[m.category] || 0) + 1;\n  });\n  return CATEGORIES.reduce(\n    (best, cat) => ((counts[cat] || 0) > (counts[best] || 0) ? cat : best),\n    CATEGORIES[0],\n  );\n}\n\nconst clusters = Array.from(cells.values()).map((members, i) => ({\n  id: `cluster-${i}`,\n  x: members.reduce((sum, m) => sum + m.lon, 0) / members.length,\n  y: members.reduce((sum, m) => sum + m.lat, 0) / members.length,\n  z: dominantCategory(members),\n  count: members.length,\n}));\n\n// Bubble radius (px) per cluster-size tier — MUI X's ScatterPlot `markerSize`\n// is the marker *radius* in CSS px (confirmed by measuring the rendered PNG:\n// a markerSize=17 bubble paints a 33-34px-diameter circle), so these match\n// the `markerSize` values passed to the series below directly.\nfunction radiusPxFor(count) {\n  if (count > 6) return 30;\n  if (count >= 2) return 17;\n  return 7;\n}\n\n// Nudge overlapping cluster centroids apart in lon/lat space so every\n// bubble's full circumference stays visible, converting the pixel-space\n// circle-circle separation back through the known linear axis scale.\nconst plotWidthPx = width - MARGIN.left - MARGIN.right;\nconst plotHeightPx = height - TITLE_HEIGHT - LEGEND_HEIGHT - MARGIN.top - MARGIN.bottom;\nconst pxPerLon = plotWidthPx / (LON_RANGE[1] - LON_RANGE[0]);\nconst pxPerLat = plotHeightPx / (LAT_RANGE[1] - LAT_RANGE[0]);\nconst MIN_GAP_PX = 8;\nfor (let pass = 0; pass < 40; pass++) {\n  let moved = false;\n  for (let i = 0; i < clusters.length; i++) {\n    for (let j = i + 1; j < clusters.length; j++) {\n      const a = clusters[i];\n      const b = clusters[j];\n      const dxPx = (b.x - a.x) * pxPerLon;\n      const dyPx = (b.y - a.y) * pxPerLat;\n      const distPx = Math.hypot(dxPx, dyPx) || 0.001;\n      const minDistPx = radiusPxFor(a.count) + radiusPxFor(b.count) + MIN_GAP_PX;\n      if (distPx < minDistPx) {\n        const pushPx = (minDistPx - distPx) / 2;\n        const nx = dxPx / distPx;\n        const ny = dyPx / distPx;\n        a.x -= (nx * pushPx) / pxPerLon;\n        a.y -= (ny * pushPx) / pxPerLat;\n        b.x += (nx * pushPx) / pxPerLon;\n        b.y += (ny * pushPx) / pxPerLat;\n        moved = true;\n      }\n    }\n  }\n  if (!moved) break;\n}\n\nconst individualStores = clusters.filter((c) => c.count === 1);\nconst smallClusters = clusters.filter((c) => c.count >= 2 && c.count <= 6);\nconst largeClusters = clusters.filter((c) => c.count > 6);\n\n// --- Chrome ------------------------------------------------------------------\nconst categoryColors = [t.palette[0], t.palette[1], t.palette[2]];\n\nconst TITLE =\n  \"TrailBrew Coffee Co. · map-marker-clustered · javascript · muix · anyplot.ai\";\nconst TITLE_FONT_DEFAULT = 22;\nconst titleFontSize =\n  TITLE.length > 67 ? Math.round(TITLE_FONT_DEFAULT * (67 / TITLE.length)) : TITLE_FONT_DEFAULT;\n\nconst SIZE_LEGEND = [\n  { label: \"1 store\", diameter: 7 },\n  { label: \"2–6 stores\", diameter: 17 },\n  { label: \"7+ stores\", diameter: 30 },\n];\n\n// --- Lightweight static basemap approximation --------------------------------\n// MUI X community has no tile/geo layer, so the geographic context the spec\n// asks for (\"a basemap with appropriate geographic context\") is drawn as a\n// simplified vector coastline + strait/sound inlet + state/international\n// boundary lines, positioned in real lon/lat and projected through the live\n// axis scale — the same technique ClusterCountLabels already uses.\nconst PACIFIC_COAST = [\n  [-124.35, 43.7],\n  [-124.15, 44.2],\n  [-124.35, 44.9],\n  [-124.4, 45.6],\n  [-124.0, 46.15],\n  [-124.35, 46.5],\n  [-124.4, 47.3],\n  [-124.45, 47.9],\n  [-124.5, 48.3],\n];\n\nconst PUGET_SOUND = [\n  [-123.3, 48.25],\n  [-122.9, 48.3],\n  [-122.6, 48.1],\n  [-122.4, 47.75],\n  [-122.35, 47.45],\n  [-122.5, 47.05],\n  [-122.65, 47.15],\n  [-122.55, 47.55],\n  [-122.65, 47.9],\n  [-122.95, 48.15],\n];\n\n// Straight-line approximations, close enough at this zoom level.\nconst CANADA_BORDER_LAT = 49.0;\nconst WA_OR_BORDER_LAT = 46.0;\n\nfunction GeographicBackdrop() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  if (!xScale || !yScale) return null;\n\n  const project = ([lon, lat]) => `${xScale(lon)},${yScale(lat)}`;\n\n  // Ocean strip: coastline plus the two viewport corners on the west edge\n  // (lon = LON_RANGE[0]) so the shape closes into a clean west-of-coast fill.\n  const oceanPoints = [\n    [LON_RANGE[0], LAT_RANGE[0]],\n    ...PACIFIC_COAST,\n    [LON_RANGE[0], LAT_RANGE[1]],\n  ]\n    .map(project)\n    .join(\" \");\n\n  const soundPoints = PUGET_SOUND.map(project).join(\" \");\n\n  // \"Water → blue\" per the Imprint semantic-color convention, at low alpha\n  // so it reads as a tint rather than a data series.\n  const waterColor = t.palette[2];\n\n  return (\n    <g pointerEvents=\"none\">\n      <polygon points={oceanPoints} fill={waterColor} opacity={0.22} stroke=\"none\" />\n      <polygon points={soundPoints} fill={waterColor} opacity={0.22} stroke=\"none\" />\n      <line\n        x1={xScale(LON_RANGE[0])}\n        y1={yScale(CANADA_BORDER_LAT)}\n        x2={xScale(LON_RANGE[1])}\n        y2={yScale(CANADA_BORDER_LAT)}\n        stroke={t.inkSoft}\n        strokeWidth={1.25}\n        strokeDasharray=\"6 4\"\n        opacity={0.5}\n      />\n      <line\n        x1={xScale(-123.6)}\n        y1={yScale(WA_OR_BORDER_LAT)}\n        x2={xScale(LON_RANGE[1])}\n        y2={yScale(WA_OR_BORDER_LAT)}\n        stroke={t.inkSoft}\n        strokeWidth={1.25}\n        strokeDasharray=\"6 4\"\n        opacity={0.5}\n      />\n    </g>\n  );\n}\n\n// Cluster-count labels, drawn at the live axis scale so each count sits\n// exactly centered on its bubble — the honest way to show \"how many\n// markers this cluster represents\" without faking a hover tooltip in the\n// static PNG.\nfunction ClusterCountLabels() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  if (!xScale || !yScale) return null;\n\n  return (\n    <g pointerEvents=\"none\">\n      {[...smallClusters, ...largeClusters].map((c) => (\n        <text\n          key={c.id}\n          x={xScale(c.x)}\n          y={yScale(c.y)}\n          fontSize={c.count > 6 ? 13 : 11}\n          fontWeight={600}\n          fill={t.pageBg}\n          textAnchor=\"middle\"\n          dominantBaseline=\"central\"\n        >\n          {c.count}\n        </text>\n      ))}\n    </g>\n  );\n}\n\nexport default function Chart() {\n  const chartHeight = height - TITLE_HEIGHT - LEGEND_HEIGHT;\n\n  return (\n    <div style={{ width, height, backgroundColor: t.pageBg }}>\n      <div\n        style={{\n          height: TITLE_HEIGHT,\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          fontSize: titleFontSize,\n          fontWeight: 600,\n          color: t.ink,\n        }}\n      >\n        {TITLE}\n      </div>\n      <ChartContainer\n        width={width}\n        height={chartHeight}\n        margin={MARGIN}\n        series={[\n          {\n            type: \"scatter\",\n            id: \"individual\",\n            data: individualStores,\n            label: \"Single location\",\n            markerSize: 7,\n            zAxisId: \"category\",\n          },\n          {\n            type: \"scatter\",\n            id: \"small-cluster\",\n            data: smallClusters,\n            label: \"Small cluster (2–6 stores)\",\n            markerSize: 17,\n            zAxisId: \"category\",\n          },\n          {\n            type: \"scatter\",\n            id: \"large-cluster\",\n            data: largeClusters,\n            label: \"Large cluster (7+ stores)\",\n            markerSize: 30,\n            zAxisId: \"category\",\n          },\n        ]}\n        zAxis={[\n          {\n            id: \"category\",\n            colorMap: { type: \"ordinal\", values: CATEGORIES, colors: categoryColors },\n          },\n        ]}\n        xAxis={[\n          {\n            scaleType: \"linear\",\n            min: LON_RANGE[0],\n            max: LON_RANGE[1],\n            label: \"Longitude (°)\",\n            valueFormatter: (v) => `${Math.abs(v).toFixed(0)}°${v < 0 ? \"W\" : \"E\"}`,\n            tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n            labelStyle: { fontSize: 16, fill: t.ink },\n          },\n        ]}\n        yAxis={[\n          {\n            scaleType: \"linear\",\n            min: LAT_RANGE[0],\n            max: LAT_RANGE[1],\n            label: \"Latitude (°)\",\n            valueFormatter: (v) => `${Math.abs(v).toFixed(0)}°${v < 0 ? \"S\" : \"N\"}`,\n            tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n            labelStyle: { fontSize: 16, fill: t.ink },\n          },\n        ]}\n        sx={{\n          \"& .MuiChartsAxis-line\": { stroke: t.inkSoft },\n          \"& .MuiChartsAxis-tick\": { stroke: t.inkSoft },\n          \"& .MuiChartsGrid-line\": { stroke: t.grid, strokeWidth: 1 },\n        }}\n      >\n        <GeographicBackdrop />\n        <ChartsGrid horizontal vertical />\n        <ScatterPlot skipAnimation />\n        <ClusterCountLabels />\n        <ChartsXAxis tickLabelStyle={{ fontSize: 14, fill: t.inkSoft }} labelStyle={{ fontSize: 16, fill: t.ink }} />\n        <ChartsYAxis tickLabelStyle={{ fontSize: 14, fill: t.inkSoft }} labelStyle={{ fontSize: 16, fill: t.ink }} />\n        <ChartsTooltip trigger=\"item\" />\n      </ChartContainer>\n      <div\n        style={{\n          height: LEGEND_HEIGHT,\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          gap: 36,\n        }}\n      >\n        <div style={{ display: \"flex\", alignItems: \"center\", gap: 16 }}>\n          <span style={{ fontSize: 13, color: t.inkSoft }}>Store format:</span>\n          {CATEGORIES.map((cat, i) => (\n            <div key={cat} style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n              <span\n                style={{\n                  width: 12,\n                  height: 12,\n                  borderRadius: \"50%\",\n                  backgroundColor: categoryColors[i],\n                  flexShrink: 0,\n                }}\n              />\n              <span style={{ fontSize: 13, color: t.ink }}>{cat}</span>\n            </div>\n          ))}\n        </div>\n        <div style={{ display: \"flex\", alignItems: \"center\", gap: 16 }}>\n          <span style={{ fontSize: 13, color: t.inkSoft }}>Cluster size:</span>\n          {SIZE_LEGEND.map((entry) => (\n            <div key={entry.label} style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n              <span\n                style={{\n                  width: entry.diameter,\n                  height: entry.diameter,\n                  borderRadius: \"50%\",\n                  backgroundColor: t.inkSoft,\n                  opacity: 0.55,\n                  flexShrink: 0,\n                }}\n              />\n              <span style={{ fontSize: 13, color: t.ink }}>{entry.label}</span>\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n"}