{"spec_id":"bubble-map-geographic","library":"d3","language":"javascript","code":"// anyplot.ai\n// bubble-map-geographic: Bubble Map with Sized Geographic Markers\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-01\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst THEME = window.ANYPLOT_THEME;\nconst BRAND = t.palette[0];\nconst MUTED = THEME === \"dark\" ? \"#A8A79F\" : \"#6B6A63\";\n\n// --- Data: the world's most populous metro areas ---------------------------\nconst cities = [\n  { name: \"Tokyo\", lon: 139.69, lat: 35.68, pop: 37.4 },\n  { name: \"Delhi\", lon: 77.21, lat: 28.61, pop: 32.9 },\n  { name: \"Shanghai\", lon: 121.47, lat: 31.23, pop: 28.5 },\n  { name: \"Dhaka\", lon: 90.41, lat: 23.81, pop: 21.7 },\n  { name: \"São Paulo\", lon: -46.63, lat: -23.55, pop: 22.4 },\n  { name: \"Mexico City\", lon: -99.13, lat: 19.43, pop: 22.2 },\n  { name: \"Cairo\", lon: 31.24, lat: 30.04, pop: 21.3 },\n  { name: \"Mumbai\", lon: 72.88, lat: 19.08, pop: 21.3 },\n  { name: \"Beijing\", lon: 116.41, lat: 39.9, pop: 20.9 },\n  { name: \"Osaka\", lon: 135.5, lat: 34.69, pop: 19.1 },\n  { name: \"New York\", lon: -74.01, lat: 40.71, pop: 18.8 },\n  { name: \"Karachi\", lon: 67.01, lat: 24.86, pop: 16.8 },\n  { name: \"Chongqing\", lon: 106.55, lat: 29.56, pop: 16.4 },\n  { name: \"Kinshasa\", lon: 15.27, lat: -4.44, pop: 15.6 },\n  { name: \"Istanbul\", lon: 28.98, lat: 41.01, pop: 15.5 },\n  { name: \"Lagos\", lon: 3.38, lat: 6.52, pop: 15.4 },\n  { name: \"Kolkata\", lon: 88.36, lat: 22.57, pop: 15.1 },\n  { name: \"Manila\", lon: 120.98, lat: 14.6, pop: 14.4 },\n  { name: \"Rio de Janeiro\", lon: -43.17, lat: -22.91, pop: 13.5 },\n  { name: \"Moscow\", lon: 37.62, lat: 55.76, pop: 12.6 },\n  { name: \"Los Angeles\", lon: -118.24, lat: 34.05, pop: 12.4 },\n  { name: \"Jakarta\", lon: 106.85, lat: -6.21, pop: 10.9 },\n  { name: \"Paris\", lon: 2.35, lat: 48.85, pop: 11.1 },\n  { name: \"London\", lon: -0.13, lat: 51.51, pop: 9.5 },\n].sort((a, b) => b.pop - a.pop); // largest first — small bubbles draw on top\n\n// --- Simplified world landmasses (hand-simplified coastlines, lon/lat rings) ---\n// d3-geo always clips against the antimeridian, which depends on the right-hand\n// winding rule — normalize each hand-authored ring via its signed geoArea rather\n// than trust hand-derived orientation (a flipped ring fills \"everything but the\n// shape\", ~4π−area, instead of the shape itself).\nconst poly = (ring) => {\n  const f = { type: \"Feature\", geometry: { type: \"Polygon\", coordinates: [ring] } };\n  return d3.geoArea(f) > 2 * Math.PI\n    ? { type: \"Feature\", geometry: { type: \"Polygon\", coordinates: [ring.slice().reverse()] } }\n    : f;\n};\n\n// Insert extra vertices (with a small deterministic perpendicular bulge) along\n// long, dead-straight edges so the hand-simplified rings read as coastal\n// curves instead of faceted polygon edges — no RNG, stays reproducible.\nconst densify = (ring) =>\n  ring.slice(0, -1).flatMap(([x0, y0], i) => {\n    const [x1, y1] = ring[i + 1];\n    const dist = Math.hypot(x1 - x0, y1 - y0);\n    const extra = dist > 14 ? 3 : dist > 7 ? 1 : 0;\n    const points = [[x0, y0]];\n    for (let s = 1; s <= extra; s++) {\n      const f = s / (extra + 1);\n      const bulge = Math.sin(f * Math.PI) * (dist > 14 ? 1.1 : 0.6);\n      points.push([\n        x0 + (x1 - x0) * f - ((y1 - y0) / dist) * bulge,\n        y0 + (y1 - y0) * f + ((x1 - x0) / dist) * bulge,\n      ]);\n    }\n    return points;\n  }).concat([ring[ring.length - 1]]);\n\nconst NORTH_AMERICA = [\n  [-168, 66], [-155, 59], [-135, 58], [-125, 48], [-117, 32], [-105, 20],\n  [-97, 16], [-90, 14], [-83, 9], [-77, 8], [-80, 25], [-75, 35],\n  [-70, 42], [-60, 47], [-55, 52], [-65, 58], [-80, 66], [-100, 71],\n  [-125, 70], [-140, 70], [-155, 71], [-168, 66],\n];\n\nconst SOUTH_AMERICA = [\n  [-77, 8], [-72, -3], [-80, -6], [-81, -15], [-75, -20], [-70, -30],\n  [-71, -40], [-73, -50], [-68, -55], [-63, -52], [-57, -35], [-48, -25],\n  [-40, -13], [-35, -7], [-42, 2], [-52, 6], [-60, 9], [-70, 10], [-77, 8],\n];\n\nconst EURASIA = [\n  [-9, 43], [-9, 38], [-6, 36], [0, 38], [3, 42], [-1, 45], [-4, 48], [1, 50],\n  [6, 51], [9, 54], [10, 58], [20, 70], [35, 70], [45, 68], [60, 68], [75, 72],\n  [90, 73], [110, 73], [130, 72], [145, 68], [160, 62], [170, 65], [178, 64],\n  [170, 55], [155, 50], [142, 44], [130, 38], [122, 31], [112, 22], [105, 10],\n  [98, 8], [92, 20], [80, 18], [72, 20], [68, 25], [56, 26], [50, 30],\n  [45, 32], [35, 32], [28, 37], [15, 40], [2, 41], [-9, 43],\n];\n\nconst AFRICA = [\n  [-17, 15], [-16, 12], [-10, 10], [-5, 5], [0, 6], [6, 5], [9, -2],\n  [13, -6], [12, -13], [12, -18], [16, -23], [20, -30], [25, -34],\n  [32, -25], [35, -18], [40, -12], [43, -2], [44, 4], [45, 10],\n  [43, 12], [38, 15], [35, 20], [32, 25], [30, 31], [10, 37],\n  [-6, 35], [-15, 25], [-17, 20], [-17, 15],\n];\n\nconst AUSTRALIA = [\n  [113, -22], [114, -28], [117, -33], [122, -34], [129, -31], [136, -35],\n  [142, -38], [148, -37], [153, -28], [150, -22], [145, -16], [137, -12],\n  [130, -12], [122, -17], [113, -22],\n];\n\nconst JAPAN = [\n  [130, 32], [132, 34], [136, 35], [139, 36], [141, 39], [141, 42],\n  [138, 40], [135, 35], [132, 34], [130, 32],\n];\n\nconst PHILIPPINES = [\n  [120, 6], [122, 9], [124, 13], [122, 17], [120, 14], [119, 10], [120, 6],\n];\n\nconst INDONESIA = [\n  [95, 3], [98, -1], [104, -6], [110, -8], [116, -6], [119, -2],\n  [117, 2], [110, 1], [104, -1], [98, 2], [95, 3],\n];\n\nconst BRITISH_ISLES = [\n  [-6, 50], [-5, 52], [-4, 55], [-2, 58], [0, 55], [-1, 52], [-3, 50], [-6, 50],\n];\n\nconst landFeatures = [\n  NORTH_AMERICA, SOUTH_AMERICA, EURASIA, AFRICA, AUSTRALIA,\n  JAPAN, PHILIPPINES, INDONESIA, BRITISH_ISLES,\n].map((ring) => poly(densify(ring)));\n\nconst mapBounds = {\n  type: \"Polygon\",\n  coordinates: [[[-175, -58], [178, -58], [178, 80], [-175, 80], [-175, -58]]],\n};\n\n// --- Layout ------------------------------------------------------------\nconst marginTop = 150;\nconst marginBottom = 50;\nconst marginLeft = 60;\nconst marginRight = 60;\nconst mapX0 = marginLeft;\nconst mapY0 = marginTop;\nconst mapX1 = width - marginRight;\nconst mapY1 = height - marginBottom;\n\nconst projection = d3.geoEqualEarth();\nconst path = d3.geoPath(projection);\nprojection.fitExtent([[mapX0, mapY0], [mapX1, mapY1]], mapBounds);\n\n// --- SVG mount -----------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// Ocean — the mount background already matches PAGE_BG, an explicit sphere\n// keeps the map panel visually bounded without painting over it\nsvg\n  .append(\"path\")\n  .datum({ type: \"Sphere\" })\n  .attr(\"d\", path)\n  .attr(\"fill\", \"none\");\n\n// Landmasses — neutral fill, not a data-driven color\nsvg\n  .selectAll(\"path.land\")\n  .data(landFeatures)\n  .join(\"path\")\n  .attr(\"class\", \"land\")\n  .attr(\"d\", path)\n  .attr(\"fill\", MUTED)\n  .attr(\"fill-opacity\", 0.4)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 0.6)\n  .attr(\"stroke-opacity\", 0.5);\n\n// --- Size scale — bubble AREA (not radius) proportional to population -----\nconst maxPop = d3.max(cities, (d) => d.pop);\nconst radius = d3.scaleSqrt().domain([0, maxPop]).range([6, 34]);\n\n// Tooltip — real hover, only live in the exported interactive HTML\nconst tooltip = d3\n  .select(\"#container\")\n  .append(\"div\")\n  .style(\"position\", \"fixed\")\n  .style(\"pointer-events\", \"none\")\n  .style(\"opacity\", 0)\n  .style(\"background\", t.elevatedBg)\n  .style(\"color\", t.ink)\n  .style(\"border\", `1px solid ${t.grid}`)\n  .style(\"border-radius\", \"6px\")\n  .style(\"padding\", \"8px 10px\")\n  .style(\"font-size\", \"13px\")\n  .style(\"font-family\", \"sans-serif\")\n  .style(\"line-height\", \"1.5\")\n  .style(\"box-shadow\", \"0 2px 8px rgba(0,0,0,0.25)\")\n  .style(\"transition\", \"opacity 0.1s linear\");\n\n// Bubbles — population by metro area, alpha handles the overlap in dense\n// regions (South/East Asia)\nsvg\n  .selectAll(\"circle.bubble\")\n  .data(cities)\n  .join(\"circle\")\n  .attr(\"class\", \"bubble\")\n  .attr(\"cx\", (d) => projection([d.lon, d.lat])[0])\n  .attr(\"cy\", (d) => projection([d.lon, d.lat])[1])\n  .attr(\"r\", (d) => radius(d.pop))\n  .attr(\"fill\", BRAND)\n  .attr(\"fill-opacity\", 0.6)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.2)\n  .style(\"cursor\", \"pointer\")\n  .on(\"mouseenter\", function () {\n    d3.select(this).attr(\"stroke\", t.ink).attr(\"stroke-width\", 2);\n    tooltip.style(\"opacity\", 1);\n  })\n  .on(\"mousemove\", (event, d) => {\n    tooltip\n      .style(\"left\", `${event.clientX + 16}px`)\n      .style(\"top\", `${event.clientY + 16}px`)\n      .html(\n        `<strong>${d.name}</strong><br>` +\n          `Population: ${d.pop.toFixed(1)}M<br>` +\n          `${d.lat.toFixed(2)}°, ${d.lon.toFixed(2)}°`,\n      );\n  })\n  .on(\"mouseleave\", function () {\n    d3.select(this).attr(\"stroke\", t.pageBg).attr(\"stroke-width\", 1.2);\n    tooltip.style(\"opacity\", 0);\n  });\n\n// --- Size legend — inset panel over open ocean -----------------------------\n// Bottom-right (mapX1-20, mapY1-20 corner) sits on top of Australia in this\n// projection/fitExtent; the empty Pacific strip left of North America\n// (x < ~300 at this canvas size) is verified clear of every landmass and\n// bubble, so the panel is anchored there instead.\nconst legendW = 210;\nconst legendH = 160;\nconst legendX = mapX0 + 20;\nconst legendY = mapY1 - legendH - 20;\n// Top reference value must be >= the largest bubble's population (37.4M)\n// so the legend never under-represents the largest marker on the map.\nconst legendValues = [10, 20, 40];\nconst legendMaxR = radius(d3.max(legendValues));\n\nconst legendG = svg.append(\"g\").attr(\"transform\", `translate(${legendX},${legendY})`);\nlegendG\n  .append(\"rect\")\n  .attr(\"width\", legendW)\n  .attr(\"height\", legendH)\n  .attr(\"rx\", 8)\n  .attr(\"fill\", t.elevatedBg)\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1)\n  .attr(\"opacity\", 0.95);\n\nlegendG\n  .append(\"text\")\n  .attr(\"x\", legendW / 2)\n  .attr(\"y\", 26)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Population (millions)\");\n\nconst legendCx = 40;\nconst legendBaseline = legendH - 16;\nlegendValues.forEach((v) => {\n  const r = radius(v);\n  const cy = legendBaseline - r;\n  legendG\n    .append(\"circle\")\n    .attr(\"cx\", legendCx)\n    .attr(\"cy\", cy)\n    .attr(\"r\", r)\n    .attr(\"fill\", \"none\")\n    .attr(\"stroke\", t.inkSoft)\n    .attr(\"stroke-width\", 1.2);\n  legendG\n    .append(\"line\")\n    .attr(\"x1\", legendCx)\n    .attr(\"y1\", cy - r)\n    .attr(\"x2\", legendCx + legendMaxR + 30)\n    .attr(\"y2\", cy - r)\n    .attr(\"stroke\", t.grid)\n    .attr(\"stroke-width\", 1);\n  legendG\n    .append(\"text\")\n    .attr(\"x\", legendCx + legendMaxR + 36)\n    .attr(\"y\", cy - r + 4)\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"12px\")\n    .text(`${v}M`);\n});\n\n// --- Title + subtitle --------------------------------------------------\nconst titleStr = \"World Megacities by Population · bubble-map-geographic · javascript · d3 · anyplot.ai\";\nconst titleSize = Math.max(14, Math.round(22 * Math.min(1, 67 / titleStr.length)));\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 46)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(titleStr);\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 76)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(\"Bubble area ∝ metro-area population · fewer, larger markers avoid overplotting\");\n"}