{"spec_id":"scatter-map-geographic","library":"d3","language":"javascript","code":"// anyplot.ai\n// scatter-map-geographic: Scatter Map with Geographic Points\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-02\n\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 120, right: 40, bottom: 30, left: 40 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Simplified world coastlines (low-poly, in-memory GeoJSON) --------------\nconst northAmerica = [\n  [-160, 70], [-141, 60], [-125, 49], [-117, 33], [-108, 18], [-97, 16],\n  [-88, 14], [-80, 9], [-77, 8], [-83, 22], [-90, 30], [-97, 26], [-104, 30],\n  [-106, 42], [-124, 49], [-131, 55], [-145, 60], [-160, 70],\n].reverse();\nconst southAmerica = [\n  [-79, 9], [-77, 4], [-75, -2], [-70, -10], [-70, -20], [-71, -30],\n  [-73, -40], [-75, -50], [-70, -55], [-64, -55], [-57, -52], [-53, -35],\n  [-48, -25], [-40, -15], [-35, -8], [-40, 0], [-50, 5], [-61, 8], [-70, 10],\n  [-79, 9],\n].reverse();\nconst africa = [\n  [-17, 15], [-10, 20], [0, 20], [10, 22], [20, 28], [32, 27], [34, 23],\n  [42, 8], [50, 9], [45, 0], [40, -11], [35, -20], [33, -27], [25, -34],\n  [18, -34], [14, -22], [12, -5], [9, 5], [0, 4], [-10, 7], [-17, 15],\n];\n// Southern boundary follows the Mediterranean/Red Sea/Gulf of Aden north\n// shore (not the African coast), well above Africa's northern coastline\n// above, so a clearly visible sea gap separates Eurasia from Africa below.\nconst eurasia = [\n  [-9, 36], [0, 43], [10, 44], [19, 42], [23, 42], [28, 41], [30, 46],\n  [40, 44], [47, 42], [50, 45], [55, 50], [60, 55], [68, 60], [80, 66],\n  [92, 72], [104, 73], [118, 73], [135, 71], [150, 65], [168, 66], [178, 65],\n  [170, 55], [155, 45], [141, 35], [130, 32], [122, 25], [110, 20], [105, 10],\n  [98, 7], [93, 15], [90, 22], [88, 22], [80, 20], [70, 20], [65, 25],\n  [60, 25], [52, 22], [44, 22], [35, 36], [28, 39], [16, 41], [6, 41],\n  [-4, 41], [-7, 39], [-9, 36],\n];\nconst australia = [\n  [113, -22], [121, -33], [129, -32], [137, -33], [141, -38], [145, -38],\n  [150, -37], [153, -28], [145, -16], [135, -12], [130, -12], [122, -18],\n  [113, -22],\n].reverse();\nconst landmasses = {\n  type: \"FeatureCollection\",\n  features: [northAmerica, southAmerica, africa, eurasia, australia].map(\n    (ring) => ({ type: \"Feature\", geometry: { type: \"Polygon\", coordinates: [ring] } })\n  ),\n};\n\n// --- Data: earthquake epicenters along major fault lines --------------------\n// Small fixed-seed LCG — the browser has no seeded RNG.\nlet seed = 42;\nconst rand = () => {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n};\n\nconst faultClusters = [\n  { lon: 140, lat: 36, spread: 5, n: 11 }, // Japan Trench\n  { lon: 118, lat: -4, spread: 10, n: 12 }, // Indonesia\n  { lon: 122, lat: 13, spread: 7, n: 8 }, // Philippines\n  { lon: -71, lat: -28, spread: 7, n: 11 }, // Chile\n  { lon: -120, lat: 36, spread: 4, n: 8 }, // California\n  { lon: -155, lat: 58, spread: 11, n: 8 }, // Aleutians\n  { lon: 174, lat: -40, spread: 5, n: 6 }, // New Zealand\n  { lon: 85, lat: 29, spread: 7, n: 8 }, // Himalaya\n  { lon: 28, lat: 38, spread: 7, n: 6 }, // Anatolia\n  { lon: -30, lat: 0, spread: 45, n: 8 }, // Mid-Atlantic Ridge\n];\n\nconst epicenters = [];\nfor (const c of faultClusters) {\n  for (let i = 0; i < c.n; i++) {\n    const lon = Math.max(-179, Math.min(179, c.lon + (rand() - 0.5) * c.spread * 2));\n    const lat = Math.max(-80, Math.min(80, c.lat + (rand() - 0.5) * c.spread));\n    const magnitude = 4.0 + rand() * 4.2;\n    const depthKm = 5 + rand() * rand() * 640;\n    epicenters.push({ lon, lat, magnitude, depthKm });\n  }\n}\n\n// --- Projection + path -------------------------------------------------------\nconst projection = d3.geoNaturalEarth1().fitSize([iw, ih], { type: \"Sphere\" });\nconst path = d3.geoPath(projection);\n\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// Zoomable layer: sphere/graticule/land/points pan+zoom together; the title\n// and legends stay fixed in screen space (per the spec's zoom/pan suggestion).\nconst mapLayer = g.append(\"g\");\n\n// --- Basemap: sphere outline, graticule, coastlines --------------------------\nmapLayer\n  .append(\"path\")\n  .datum({ type: \"Sphere\" })\n  .attr(\"d\", path)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1);\n\nmapLayer\n  .append(\"path\")\n  .datum(d3.geoGraticule10())\n  .attr(\"d\", path)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 0.75);\n\nmapLayer\n  .selectAll(\"path.land\")\n  .data(landmasses.features)\n  .join(\"path\")\n  .attr(\"class\", \"land\")\n  .attr(\"d\", path)\n  .attr(\"fill\", t.elevatedBg)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-opacity\", 0.5)\n  .attr(\"stroke-width\", 1);\n\n// --- Scales -------------------------------------------------------------------\nconst depthExtent = d3.extent(epicenters, (d) => d.depthKm);\nconst magExtent = d3.extent(epicenters, (d) => d.magnitude);\nconst color = d3.scaleSequential(d3.interpolateRgbBasis(t.seq)).domain(depthExtent);\nconst radius = d3.scaleSqrt().domain(magExtent).range([5, 20]);\n\n// --- Points ---------------------------------------------------------------\nmapLayer\n  .selectAll(\"circle.epicenter\")\n  .data(epicenters)\n  .join(\"circle\")\n  .attr(\"class\", \"epicenter\")\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.magnitude))\n  .attr(\"fill\", (d) => color(d.depthKm))\n  .attr(\"fill-opacity\", 0.58)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1);\n\n// --- Zoom/pan: explore dense clusters (identity transform at rest, so the\n// static screenshot is unaffected) ---------------------------------------\nconst zoom = d3\n  .zoom()\n  .scaleExtent([1, 8])\n  .translateExtent([\n    [0, 0],\n    [iw, ih],\n  ])\n  .on(\"zoom\", (event) => mapLayer.attr(\"transform\", event.transform));\nsvg.call(zoom);\n\n// --- Legend: depth (color, sequential) ---------------------------------------\nconst legendX = 24;\nconst legendY = ih - 70;\nconst gradientId = \"depthGradient\";\nconst defs = svg.append(\"defs\");\nconst gradient = defs.append(\"linearGradient\").attr(\"id\", gradientId);\nd3.range(0, 1.0001, 0.1).forEach((s) =>\n  gradient\n    .append(\"stop\")\n    .attr(\"offset\", `${s * 100}%`)\n    .attr(\"stop-color\", color(depthExtent[0] + s * (depthExtent[1] - depthExtent[0])))\n);\n\nconst legend = g.append(\"g\").attr(\"transform\", `translate(${legendX},${legendY})`);\nlegend\n  .append(\"text\")\n  .attr(\"x\", 0)\n  .attr(\"y\", -10)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(\"Depth (km)\");\nlegend\n  .append(\"rect\")\n  .attr(\"width\", 140)\n  .attr(\"height\", 12)\n  .attr(\"fill\", `url(#${gradientId})`)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 0.5);\nlegend\n  .append(\"text\")\n  .attr(\"x\", 0)\n  .attr(\"y\", 30)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(`${Math.round(depthExtent[0])}`);\nlegend\n  .append(\"text\")\n  .attr(\"x\", 140)\n  .attr(\"y\", 30)\n  .attr(\"text-anchor\", \"end\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(`${Math.round(depthExtent[1])}`);\n\n// --- Legend: magnitude (size) --------------------------------------------\nconst sizeLegend = g.append(\"g\").attr(\"transform\", `translate(${legendX + 220},${legendY})`);\nsizeLegend\n  .append(\"text\")\n  .attr(\"x\", 0)\n  .attr(\"y\", -10)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(\"Magnitude\");\nconst magSteps = [magExtent[0], (magExtent[0] + magExtent[1]) / 2, magExtent[1]];\nlet cx = 0;\nmagSteps.forEach((m) => {\n  const r = radius(m);\n  cx += r + 6;\n  sizeLegend\n    .append(\"circle\")\n    .attr(\"cx\", cx)\n    .attr(\"cy\", 12 - r)\n    .attr(\"r\", r)\n    .attr(\"fill\", t.inkSoft)\n    .attr(\"fill-opacity\", 0.35)\n    .attr(\"stroke\", t.inkSoft)\n    .attr(\"stroke-width\", 1);\n  sizeLegend\n    .append(\"text\")\n    .attr(\"x\", cx)\n    .attr(\"y\", 30)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"14px\")\n    .text(m.toFixed(1));\n  cx += r + 14;\n});\n\n// --- Title --------------------------------------------------------------\nconst title = \"Global Earthquake Epicenters · scatter-map-geographic · javascript · d3 · anyplot.ai\";\nconst titleFontSize = Math.round(22 * Math.min(1, 67 / title.length));\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 56)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(title);\n"}