{"spec_id":"map-connection-lines","library":"d3","language":"javascript","code":"// anyplot.ai\n// map-connection-lines: Connection Lines Map (Origin-Destination)\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 88/100 | Created: 2026-08-26\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data: international flight routes between major hub airports ----------\nconst hubs = [\n  { id: \"JFK\", name: \"New York\", lon: -73.8, lat: 40.6 },\n  { id: \"LAX\", name: \"Los Angeles\", lon: -118.4, lat: 33.9 },\n  { id: \"ORD\", name: \"Chicago\", lon: -87.9, lat: 41.98 },\n  { id: \"YYZ\", name: \"Toronto\", lon: -79.63, lat: 43.68 },\n  { id: \"GRU\", name: \"Sao Paulo\", lon: -46.47, lat: -23.43 },\n  { id: \"LHR\", name: \"London\", lon: -0.45, lat: 51.47 },\n  { id: \"CDG\", name: \"Paris\", lon: 2.55, lat: 49.0 },\n  { id: \"FRA\", name: \"Frankfurt\", lon: 8.57, lat: 50.03 },\n  { id: \"JNB\", name: \"Johannesburg\", lon: 28.25, lat: -26.13 },\n  { id: \"DXB\", name: \"Dubai\", lon: 55.36, lat: 25.25 },\n  { id: \"DOH\", name: \"Doha\", lon: 51.6, lat: 25.27 },\n  { id: \"DEL\", name: \"Delhi\", lon: 77.1, lat: 28.56 },\n  { id: \"SIN\", name: \"Singapore\", lon: 103.99, lat: 1.36 },\n  { id: \"HKG\", name: \"Hong Kong\", lon: 113.9, lat: 22.3 },\n  { id: \"NRT\", name: \"Tokyo\", lon: 140.39, lat: 35.76 },\n  { id: \"SYD\", name: \"Sydney\", lon: 151.18, lat: -33.95 },\n];\nconst hubById = new Map(hubs.map((h) => [h.id, h]));\n\n// value: annual passenger volume, thousands\nconst routes = [\n  { from: \"JFK\", to: \"LHR\", value: 1200 },\n  { from: \"JFK\", to: \"CDG\", value: 950 },\n  { from: \"JFK\", to: \"FRA\", value: 780 },\n  { from: \"LAX\", to: \"NRT\", value: 640 },\n  { from: \"LAX\", to: \"SYD\", value: 420 },\n  { from: \"LHR\", to: \"DXB\", value: 890 },\n  { from: \"LHR\", to: \"JNB\", value: 380 },\n  { from: \"CDG\", to: \"DXB\", value: 610 },\n  { from: \"FRA\", to: \"DEL\", value: 520 },\n  { from: \"DXB\", to: \"SIN\", value: 700 },\n  { from: \"DXB\", to: \"HKG\", value: 560 },\n  { from: \"DOH\", to: \"SIN\", value: 480 },\n  { from: \"SIN\", to: \"SYD\", value: 610 },\n  { from: \"HKG\", to: \"NRT\", value: 540 },\n  { from: \"HKG\", to: \"SYD\", value: 390 },\n  { from: \"DEL\", to: \"SIN\", value: 460 },\n  { from: \"GRU\", to: \"JNB\", value: 260 },\n  { from: \"GRU\", to: \"LHR\", value: 430 },\n  { from: \"ORD\", to: \"LHR\", value: 560 },\n  { from: \"YYZ\", to: \"LHR\", value: 470 },\n  { from: \"JFK\", to: \"GRU\", value: 500 },\n  { from: \"NRT\", to: \"SYD\", value: 330 },\n];\n\nconst nodeTotals = new Map();\nfor (const r of routes) {\n  nodeTotals.set(r.from, (nodeTotals.get(r.from) || 0) + r.value);\n  nodeTotals.set(r.to, (nodeTotals.get(r.to) || 0) + r.value);\n}\n\n// --- Layout ------------------------------------------------------------\nconst margin = { top: 130, right: 260, bottom: 40, left: 40 };\nconst mapW = width - margin.left - margin.right;\nconst mapH = height - margin.top - margin.bottom;\n\n// --- Projection: whole-world pseudo-cylindrical, fit to the map area -------\nconst projection = d3.geoNaturalEarth1().fitExtent(\n  [\n    [0, 0],\n    [mapW, mapH],\n  ],\n  { type: \"Sphere\" },\n);\nprojection.precision(0.15); // finer resampling so great-circle arcs read smoothly\nconst path = d3.geoPath(projection);\nconst graticule = d3.geoGraticule10();\n\n// --- Scales --------------------------------------------------------------\nconst valueExtent = d3.extent(routes, (d) => d.value);\nconst arcColor = d3.scaleSequential(d3.interpolateRgbBasis(t.seq)).domain(valueExtent);\nconst arcWidth = d3.scaleLinear().domain(valueExtent).range([1.5, 5]);\nconst arcOpacity = d3.scaleLinear().domain(valueExtent).range([0.3, 0.6]);\nconst nodeRadius = d3\n  .scaleSqrt()\n  .domain(d3.extent(Array.from(nodeTotals.values())))\n  .range([5, 12]);\n\n// --- SVG mount -----------------------------------------------------------\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})`);\nconst mapG = g.append(\"g\");\n\n// Sphere outline — the globe's silhouette under the Natural Earth projection\nmapG\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  .attr(\"stroke-opacity\", 0.4);\n\n// Graticule — subtle lon/lat grid for geographic context\nmapG\n  .append(\"path\")\n  .datum(graticule)\n  .attr(\"d\", path)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 0.6)\n  .attr(\"stroke-opacity\", 0.5);\n\n// Simplified continent silhouettes — abstract, non-self-intersecting blobs for\n// geographic context (not political borders). Built from a center plus radii\n// sampled at strictly increasing angles, which guarantees a simple polygon.\n// d3-geo's spherical winding rule is the opposite of planar-SVG intuition: a\n// ring built by increasing angle (counterclockwise in the standard lon/lat\n// plane) is interpreted as the *complement* — the whole sphere minus the\n// blob — so each \"continent\" ends up covering almost the entire globe\n// instead of a small patch. Reversing the ring flips it back to the small\n// enclosed area that was actually intended.\nfunction blobRing(clon, clat, radii) {\n  const ring = radii.map(([angleDeg, rLon, rLat]) => {\n    const rad = (angleDeg * Math.PI) / 180;\n    return [clon + rLon * Math.cos(rad), clat + rLat * Math.sin(rad)];\n  });\n  ring.reverse();\n  ring.push(ring[0]);\n  return ring;\n}\n\nconst continents = [\n  {\n    name: \"North America\",\n    coordinates: blobRing(-100, 50, [\n      [0, 24, 8], [40, 16, 20], [80, 12, 24], [120, 18, 20], [160, 24, 10],\n      [200, 14, 8], [240, 7, 18], [280, 9, 22], [320, 18, 14],\n    ]),\n  },\n  {\n    name: \"South America\",\n    coordinates: blobRing(-58, -18, [\n      [0, 14, 10], [45, 10, 16], [90, 8, 20], [135, 10, 16], [180, 12, 10],\n      [225, 8, 22], [270, 6, 30], [315, 10, 20],\n    ]),\n  },\n  {\n    name: \"Africa\",\n    coordinates: blobRing(20, 5, [\n      [0, 20, 10], [50, 24, 14], [90, 14, 20], [140, 18, 16], [180, 20, 10],\n      [230, 14, 26], [270, 10, 30], [310, 16, 18],\n    ]),\n  },\n  {\n    name: \"Eurasia\",\n    coordinates: blobRing(60, 45, [\n      [0, 85, 20], [30, 70, 28], [60, 55, 30], [90, 40, 28], [120, 55, 22],\n      [150, 70, 14], [180, 75, 10], [210, 60, 8], [240, 40, 14], [270, 30, 22],\n      [300, 45, 26], [330, 70, 22],\n    ]),\n  },\n  {\n    name: \"Australia\",\n    coordinates: blobRing(135, -25, [\n      [0, 18, 8], [60, 14, 10], [120, 10, 8], [180, 16, 8], [240, 12, 10],\n      [300, 16, 10],\n    ]),\n  },\n];\n\n// Note: ANYPLOT_TOKENS has no `muted` key (only pageBg/elevatedBg/ink/inkSoft/\n// grid/palette/amber/seq/div) — using it here would leave `fill` unset, and the\n// SVG UA-default fill is solid black, which is what caused the map interior to\n// render as a near-black disc regardless of theme. `t.inkSoft` is the defined,\n// theme-adaptive token closest to the intended \"muted neutral\" role.\nmapG\n  .selectAll(\"path.landmass\")\n  .data(continents)\n  .join(\"path\")\n  .attr(\"class\", \"landmass\")\n  .attr(\"d\", (d) => path({ type: \"Polygon\", coordinates: [d.coordinates] }))\n  .attr(\"fill\", t.inkSoft)\n  .attr(\"fill-opacity\", 0.3)\n  .attr(\"stroke\", \"none\");\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// Connection arcs — geodesic (great-circle) paths, thickness + color encode\n// passenger volume; d3.geoPath adaptively resamples a 2-point LineString\n// along the sphere, so this bows the way a real flight route would.\nmapG\n  .selectAll(\"path.route\")\n  .data(routes)\n  .join(\"path\")\n  .attr(\"class\", \"route\")\n  .attr(\"d\", (d) => {\n    const o = hubById.get(d.from);\n    const e = hubById.get(d.to);\n    return path({\n      type: \"LineString\",\n      coordinates: [\n        [o.lon, o.lat],\n        [e.lon, e.lat],\n      ],\n    });\n  })\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", (d) => arcColor(d.value))\n  .attr(\"stroke-width\", (d) => arcWidth(d.value))\n  .attr(\"stroke-opacity\", (d) => arcOpacity(d.value))\n  .attr(\"stroke-linecap\", \"round\")\n  .style(\"cursor\", \"pointer\")\n  .on(\"mouseenter\", function (event, d) {\n    d3.select(this).attr(\"stroke-opacity\", 1);\n    tooltip.style(\"opacity\", 1);\n  })\n  .on(\"mousemove\", function (event, d) {\n    const o = hubById.get(d.from);\n    const e = hubById.get(d.to);\n    tooltip\n      .style(\"left\", `${event.clientX + 16}px`)\n      .style(\"top\", `${event.clientY + 16}px`)\n      .html(`<strong>${o.name} → ${e.name}</strong><br>${d.value}k passengers/yr`);\n  })\n  .on(\"mouseleave\", function (event, d) {\n    d3.select(this).attr(\"stroke-opacity\", arcOpacity(d.value));\n    tooltip.style(\"opacity\", 0);\n  });\n\n// Hub markers — location endpoints, sized by total connected volume\nmapG\n  .selectAll(\"circle.hub\")\n  .data(hubs)\n  .join(\"circle\")\n  .attr(\"class\", \"hub\")\n  .attr(\"cx\", (d) => projection([d.lon, d.lat])[0])\n  .attr(\"cy\", (d) => projection([d.lon, d.lat])[1])\n  .attr(\"r\", (d) => nodeRadius(nodeTotals.get(d.id) || 0))\n  .attr(\"fill\", t.palette[0])\n  .attr(\"fill-opacity\", 0.9)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.5)\n  .style(\"cursor\", \"pointer\")\n  .on(\"mouseenter\", function () {\n    d3.select(this).attr(\"stroke\", t.ink);\n    tooltip.style(\"opacity\", 1);\n  })\n  .on(\"mousemove\", function (event, d) {\n    tooltip\n      .style(\"left\", `${event.clientX + 16}px`)\n      .style(\"top\", `${event.clientY + 16}px`)\n      .html(`<strong>${d.name}</strong><br>${nodeTotals.get(d.id)}k connected passengers/yr`);\n  })\n  .on(\"mouseleave\", function () {\n    d3.select(this).attr(\"stroke\", t.pageBg);\n    tooltip.style(\"opacity\", 0);\n  });\n\n// --- Legend: passenger volume (color + width) -----------------------------\nconst legendW = 26;\nconst legendH = mapH * 0.5;\nconst legendX = mapW + 55;\nconst legendY = mapH * 0.08;\nconst nStops = 10;\n\nsvg\n  .append(\"defs\")\n  .append(\"linearGradient\")\n  .attr(\"id\", \"legend-gradient\")\n  .attr(\"x1\", \"0%\")\n  .attr(\"y1\", \"100%\")\n  .attr(\"x2\", \"0%\")\n  .attr(\"y2\", \"0%\")\n  .selectAll(\"stop\")\n  .data(d3.range(nStops + 1))\n  .join(\"stop\")\n  .attr(\"offset\", (d) => `${(d / nStops) * 100}%`)\n  .attr(\"stop-color\", (d) => arcColor(valueExtent[0] + (d / nStops) * (valueExtent[1] - valueExtent[0])));\n\nconst legendG = g.append(\"g\").attr(\"transform\", `translate(${legendX},${legendY})`);\nconst legendRectTop = 26; // clears the title/unit text stacked above the bar\nlegendG\n  .append(\"rect\")\n  .attr(\"y\", legendRectTop)\n  .attr(\"width\", legendW)\n  .attr(\"height\", legendH)\n  .attr(\"fill\", \"url(#legend-gradient)\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1);\n\nconst legendScale = d3.scaleLinear().domain(valueExtent).range([legendH + legendRectTop, legendRectTop]);\nconst legendAxis = d3\n  .axisRight(legendScale)\n  .ticks(5)\n  .tickFormat((d) => d.toFixed(0));\nconst legendAxisG = legendG.append(\"g\").attr(\"transform\", `translate(${legendW},0)`).call(legendAxis);\nlegendAxisG.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\nlegendAxisG.selectAll(\"line\").attr(\"stroke\", t.grid);\nlegendAxisG.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\nlegendG\n  .append(\"text\")\n  .attr(\"x\", legendW / 2)\n  .attr(\"y\", -34)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Passenger\");\n\nlegendG\n  .append(\"text\")\n  .attr(\"x\", legendW / 2)\n  .attr(\"y\", -16)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"volume\");\n\nlegendG\n  .append(\"text\")\n  .attr(\"x\", legendW / 2)\n  .attr(\"y\", 2)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\"(000s/yr)\");\n\n// Marker-size note, placed under the volume legend for context\nlegendG\n  .append(\"circle\")\n  .attr(\"cx\", legendW / 2)\n  .attr(\"cy\", legendH + legendRectTop + 48)\n  .attr(\"r\", 9)\n  .attr(\"fill\", t.palette[0])\n  .attr(\"fill-opacity\", 0.9)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.5);\nlegendG\n  .append(\"text\")\n  .attr(\"x\", legendW / 2 + 22)\n  .attr(\"y\", legendH + legendRectTop + 44)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\"Hub, sized by\");\nlegendG\n  .append(\"text\")\n  .attr(\"x\", legendW / 2 + 22)\n  .attr(\"y\", legendH + legendRectTop + 60)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\"total traffic\");\n\n// --- Title -----------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 50)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"map-connection-lines · javascript · d3 · anyplot.ai\");\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 78)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"International flight routes between major hub airports\");\n"}