{"spec_id":"map-marker-clustered","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// map-marker-clustered: Clustered Marker Map\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-02\n\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Fixed-seed LCG — the browser has no seeded RNG.\nlet seed = 42;\nfunction nextRandom() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\nfunction gaussianJitter() {\n  const u1 = Math.max(nextRandom(), 1e-9);\n  const u2 = nextRandom();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\nconst CATEGORIES = [\"Electronics\", \"Grocery\", \"Clothing\", \"Home & Garden\"];\nconst CATEGORY_COLORS = {\n  [CATEGORIES[0]]: t.palette[0],\n  [CATEGORIES[1]]: t.palette[1],\n  [CATEGORIES[2]]: t.palette[2],\n  [CATEGORIES[3]]: t.palette[3],\n};\n\n// Real Italian city coordinates used as retail-store hotspots.\nconst CITIES = [\n  { name: \"Milan\", lon: 9.19, lat: 45.46, count: 110, dominant: 0 },\n  { name: \"Rome\", lon: 12.5, lat: 41.9, count: 130, dominant: 2 },\n  { name: \"Naples\", lon: 14.27, lat: 40.85, count: 90, dominant: 1 },\n  { name: \"Turin\", lon: 7.68, lat: 45.07, count: 60, dominant: 0 },\n  { name: \"Palermo\", lon: 13.36, lat: 38.12, count: 65, dominant: 1 },\n  { name: \"Bologna\", lon: 11.34, lat: 44.49, count: 50, dominant: 3 },\n  { name: \"Florence\", lon: 11.26, lat: 43.77, count: 45, dominant: 2 },\n  { name: \"Venice\", lon: 12.32, lat: 45.44, count: 40, dominant: 3 },\n  { name: \"Bari\", lon: 16.87, lat: 41.13, count: 35, dominant: 1 },\n  { name: \"Catania\", lon: 15.09, lat: 37.5, count: 30, dominant: 0 },\n];\n\nconst stores = [];\nCITIES.forEach((city) => {\n  for (let i = 0; i < city.count; i++) {\n    const category =\n      nextRandom() < 0.55 ? CATEGORIES[city.dominant] : CATEGORIES[Math.floor(nextRandom() * CATEGORIES.length)];\n    stores.push({\n      lon: city.lon + gaussianJitter() * 0.28,\n      lat: city.lat + gaussianJitter() * 0.22,\n      category,\n      label: `${city.name} — ${category}`,\n    });\n  }\n});\n\n// Simplified coastline outlines (basemap context) — [lon, lat] pairs.\nconst ITALY_MAINLAND = [\n  [7.0, 45.9], [7.5, 45.9], [8.0, 46.0], [9.0, 46.5], [10.5, 46.5],\n  [12.0, 46.6], [13.7, 46.5], [13.8, 45.6], [14.4, 44.8], [14.0, 43.6],\n  [15.9, 41.9], [16.2, 41.3], [17.0, 40.9], [18.5, 40.1], [17.2, 39.8],\n  [16.6, 38.9], [15.7, 38.2], [15.9, 37.9], [15.6, 38.3], [16.5, 39.4],\n  [16.2, 40.0], [15.3, 40.6], [14.9, 40.7], [14.3, 40.8], [13.9, 41.2],\n  [12.5, 41.7], [11.2, 42.4], [10.5, 42.9], [10.0, 43.6], [9.5, 44.1],\n  [8.0, 44.4], [7.5, 44.0], [7.0, 44.7], [7.0, 45.9],\n];\nconst ITALY_SICILY = [\n  [12.4, 38.2], [13.4, 38.3], [15.2, 38.25], [15.6, 37.9],\n  [15.1, 37.0], [14.0, 36.7], [12.9, 37.6], [12.4, 38.2],\n];\n\nconst LON_MIN = 6.4;\nconst LON_MAX = 18.9;\nconst LAT_MIN = 36.2;\nconst LAT_MAX = 47.4;\nconst BOUNDARY_COLOR = t.theme === \"dark\" ? \"rgba(240,239,232,0.3)\" : \"rgba(26,26,23,0.32)\";\n\n// --- Zoom-dependent clustering -----------------------------------------------\n// Buckets stores into a pixel-space grid sized to the *current* zoom level, so\n// re-running this after every pan/zoom naturally expands clusters as the user\n// zooms in (each pixel cell then spans fewer degrees).\nconst CELL_PX = 68;\n\nfunction computeClusters(chart) {\n  const xAxis = chart.xAxis[0];\n  const yAxis = chart.yAxis[0];\n  const cells = new Map();\n\n  stores.forEach((store) => {\n    const px = xAxis.toPixels(store.lon, true);\n    const py = yAxis.toPixels(store.lat, true);\n    if (px < 0 || px > chart.plotWidth || py < 0 || py > chart.plotHeight) return;\n    const key = `${Math.floor(px / CELL_PX)}_${Math.floor(py / CELL_PX)}`;\n    if (!cells.has(key)) cells.set(key, []);\n    cells.get(key).push(store);\n  });\n\n  const points = [];\n  cells.forEach((members) => {\n    if (members.length === 1) {\n      const store = members[0];\n      points.push({\n        x: store.lon,\n        y: store.lat,\n        name: store.label,\n        marker: { radius: 6, symbol: \"circle\", fillColor: CATEGORY_COLORS[store.category], lineColor: t.pageBg, lineWidth: 1 },\n        custom: { isCluster: false, members },\n      });\n    } else {\n      const lon = members.reduce((sum, m) => sum + m.lon, 0) / members.length;\n      const lat = members.reduce((sum, m) => sum + m.lat, 0) / members.length;\n      const categoryCounts = {};\n      members.forEach((m) => {\n        categoryCounts[m.category] = (categoryCounts[m.category] || 0) + 1;\n      });\n      const dominant = Object.keys(categoryCounts).reduce((a, b) => (categoryCounts[a] >= categoryCounts[b] ? a : b));\n      const radius = Math.min(34, 12 + Math.sqrt(members.length) * 3.2);\n      points.push({\n        x: lon,\n        y: lat,\n        marker: { radius, symbol: \"circle\", fillColor: CATEGORY_COLORS[dominant], lineColor: t.ink, lineWidth: 1 },\n        dataLabels: {\n          enabled: true,\n          format: String(members.length),\n          style: { color: t.pageBg, fontSize: \"13px\", fontWeight: \"600\", textOutline: \"none\" },\n        },\n        custom: { isCluster: true, members, count: members.length, categoryCounts },\n      });\n    }\n  });\n\n  // Nudge apart cluster circles that would otherwise touch/overlap at their\n  // edges once rendered — clustering only guarantees a shared pixel cell, not\n  // a rendered gap between neighboring cells' circles.\n  separateClusterCircles(\n    points.filter((p) => p.custom.isCluster),\n    xAxis,\n    yAxis\n  );\n  return points;\n}\n\nfunction separateClusterCircles(clusterPoints, xAxis, yAxis) {\n  const MIN_GAP_PX = 3;\n  const nodes = clusterPoints.map((point) => ({\n    point,\n    px: xAxis.toPixels(point.x, true),\n    py: yAxis.toPixels(point.y, true),\n    r: point.marker.radius,\n  }));\n\n  for (let iter = 0; iter < 4; iter++) {\n    let moved = false;\n    for (let i = 0; i < nodes.length; i++) {\n      for (let j = i + 1; j < nodes.length; j++) {\n        const a = nodes[i];\n        const b = nodes[j];\n        const dx = b.px - a.px;\n        const dy = b.py - a.py;\n        const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;\n        const minDist = a.r + b.r + MIN_GAP_PX;\n        if (dist < minDist) {\n          const push = (minDist - dist) / 2;\n          const ux = dx / dist;\n          const uy = dy / dist;\n          a.px -= ux * push;\n          a.py -= uy * push;\n          b.px += ux * push;\n          b.py += uy * push;\n          moved = true;\n        }\n      }\n    }\n    if (!moved) break;\n  }\n\n  nodes.forEach((node) => {\n    node.point.x = xAxis.toValue(node.px, true);\n    node.point.y = yAxis.toValue(node.py, true);\n  });\n}\n\nfunction recluster() {\n  const chart = this.chart;\n  const series = chart.get(\"stores\");\n  if (series) series.setData(computeClusters(chart), true, false, false);\n}\n\n// --- Hover spider-lines (member locations of a cluster) ---------------------\nlet spiderLines = [];\nfunction clearSpiderLines() {\n  spiderLines.forEach((line) => line.destroy());\n  spiderLines = [];\n}\nfunction drawSpiderLines(point) {\n  const custom = point.custom;\n  if (!custom || !custom.isCluster) return;\n  const chart = point.series.chart;\n  const xAxis = chart.xAxis[0];\n  const yAxis = chart.yAxis[0];\n  const originX = point.plotX + chart.plotLeft;\n  const originY = point.plotY + chart.plotTop;\n  // Cap the fan-out so a very large cluster doesn't draw hundreds of lines.\n  custom.members.slice(0, 40).forEach((member) => {\n    const targetX = xAxis.toPixels(member.lon);\n    const targetY = yAxis.toPixels(member.lat);\n    spiderLines.push(\n      chart.renderer\n        .path([\"M\", originX, originY, \"L\", targetX, targetY])\n        .attr({ stroke: t.inkSoft, \"stroke-width\": 1, opacity: 0.55, zIndex: 6 })\n        .add()\n    );\n  });\n}\n\n// --- Title (fontsize scaled to the ~67-char baseline) ------------------------\nconst TITLE = \"Store Locations Across Italy · map-marker-clustered · javascript · highcharts · anyplot.ai\";\nconst TITLE_FONT_SIZE = `${Math.round(22 * Math.min(1, 67 / TITLE.length))}px`;\n\n// --- Chart -------------------------------------------------------------------\nHighcharts.chart(\"container\", {\n  chart: {\n    type: \"scatter\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    zooming: { type: \"xy\" },\n    style: { fontFamily: \"inherit\" },\n    events: {\n      load: function () {\n        this.get(\"stores\").setData(computeClusters(this), true, false, false);\n      },\n    },\n  },\n  credits: { enabled: false },\n  colors: t.palette,\n  title: { text: TITLE, style: { color: t.ink, fontSize: TITLE_FONT_SIZE, fontWeight: \"600\" } },\n  subtitle: {\n    text: \"Drag to zoom into a region · click a cluster to expand · hover a cluster to see its members\",\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: {\n    min: LON_MIN,\n    max: LON_MAX,\n    startOnTick: false,\n    endOnTick: false,\n    title: { text: \"Longitude (°E)\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n    labels: { style: { color: t.inkSoft, fontSize: \"14px\" }, format: \"{value}°\" },\n    lineColor: t.inkSoft,\n    tickColor: t.inkSoft,\n    gridLineColor: t.grid,\n    gridLineDashStyle: \"Dot\",\n    events: { afterSetExtremes: recluster },\n  },\n  yAxis: {\n    min: LAT_MIN,\n    max: LAT_MAX,\n    startOnTick: false,\n    endOnTick: false,\n    title: { text: \"Latitude (°N)\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n    labels: { style: { color: t.inkSoft, fontSize: \"14px\" }, format: \"{value}°\" },\n    lineColor: t.inkSoft,\n    tickColor: t.inkSoft,\n    gridLineColor: t.grid,\n    gridLineDashStyle: \"Dot\",\n    events: { afterSetExtremes: recluster },\n  },\n  legend: {\n    itemStyle: { color: t.inkSoft, fontSize: \"14px\" },\n    itemHoverStyle: { color: t.ink },\n  },\n  tooltip: {\n    backgroundColor: t.elevatedBg,\n    borderColor: t.grid,\n    style: { color: t.ink, fontSize: \"13px\" },\n    formatter: function () {\n      const custom = this.point.custom;\n      if (custom && custom.isCluster) {\n        const breakdown = Object.entries(custom.categoryCounts)\n          .map(([category, count]) => `${category}: ${count}`)\n          .join(\"<br>\");\n        return `<b>${custom.count} stores</b><br>${breakdown}`;\n      }\n      return `<b>${this.point.name}</b>`;\n    },\n  },\n  plotOptions: {\n    series: { animation: false },\n  },\n  series: [\n    {\n      type: \"line\",\n      name: \"Coastline\",\n      data: ITALY_MAINLAND,\n      color: BOUNDARY_COLOR,\n      lineWidth: 1.5,\n      marker: { enabled: false },\n      enableMouseTracking: false,\n      showInLegend: false,\n      zIndex: 0,\n    },\n    {\n      type: \"line\",\n      name: \"Coastline (Sicily)\",\n      data: ITALY_SICILY,\n      color: BOUNDARY_COLOR,\n      lineWidth: 1.5,\n      marker: { enabled: false },\n      enableMouseTracking: false,\n      showInLegend: false,\n      zIndex: 0,\n    },\n    {\n      type: \"scatter\",\n      id: \"stores\",\n      name: \"Stores\",\n      data: [],\n      cursor: \"pointer\",\n      showInLegend: false,\n      zIndex: 1,\n      states: { hover: { halo: { size: 6 } } },\n      point: {\n        events: {\n          click: function () {\n            const custom = this.custom;\n            if (!custom || !custom.isCluster) return;\n            const chart = this.series.chart;\n            const lons = custom.members.map((m) => m.lon);\n            const lats = custom.members.map((m) => m.lat);\n            const lonPad = Math.max((Math.max(...lons) - Math.min(...lons)) * 0.4, 0.15);\n            const latPad = Math.max((Math.max(...lats) - Math.min(...lats)) * 0.4, 0.15);\n            chart.xAxis[0].setExtremes(Math.min(...lons) - lonPad, Math.max(...lons) + lonPad);\n            chart.yAxis[0].setExtremes(Math.min(...lats) - latPad, Math.max(...lats) + latPad);\n          },\n          mouseOver: function () {\n            drawSpiderLines(this);\n          },\n          mouseOut: clearSpiderLines,\n        },\n      },\n    },\n    ...CATEGORIES.map((category, i) => ({\n      type: \"scatter\",\n      name: category,\n      color: t.palette[i],\n      data: [],\n      enableMouseTracking: false,\n      marker: { symbol: \"circle\", radius: 6 },\n      showInLegend: true,\n      zIndex: 1,\n    })),\n    {\n      type: \"scatter\",\n      name: \"Cluster (larger, bordered, count inside)\",\n      data: [],\n      enableMouseTracking: false,\n      marker: { symbol: \"circle\", radius: 9, fillColor: t.pageBg, lineColor: t.ink, lineWidth: 1 },\n      showInLegend: true,\n      zIndex: 1,\n    },\n  ],\n});\n"}