{"spec_id":"flowmap-origin-destination","library":"d3","language":"javascript","code":"// anyplot.ai\n// flowmap-origin-destination: Origin-Destination Flow Map\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 87/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data: illustrative population-movement volumes between world cities --\n// (thousands of people/year, synthetic but realistically proportioned)\nconst cities = [\n  { id: \"MEX\", name: \"Mexico City\", lon: -99.13, lat: 19.43 },\n  { id: \"WAS\", name: \"Washington, D.C.\", lon: -77.04, lat: 38.9 },\n  { id: \"TOR\", name: \"Toronto\", lon: -79.38, lat: 43.65 },\n  { id: \"SAO\", name: \"São Paulo\", lon: -46.63, lat: -23.55 },\n  { id: \"BUE\", name: \"Buenos Aires\", lon: -58.38, lat: -34.6 },\n  { id: \"LON\", name: \"London\", lon: -0.13, lat: 51.5 },\n  { id: \"PAR\", name: \"Paris\", lon: 2.35, lat: 48.85 },\n  { id: \"BER\", name: \"Berlin\", lon: 13.4, lat: 52.52 },\n  { id: \"IST\", name: \"Istanbul\", lon: 28.98, lat: 41.01 },\n  { id: \"CAI\", name: \"Cairo\", lon: 31.24, lat: 30.04 },\n  { id: \"LAG\", name: \"Lagos\", lon: 3.39, lat: 6.45 },\n  { id: \"NAI\", name: \"Nairobi\", lon: 36.82, lat: -1.29 },\n  { id: \"MOW\", name: \"Moscow\", lon: 37.62, lat: 55.75 },\n  { id: \"DEL\", name: \"Delhi\", lon: 77.2, lat: 28.6 },\n  { id: \"DAC\", name: \"Dhaka\", lon: 90.41, lat: 23.81 },\n  { id: \"JAK\", name: \"Jakarta\", lon: 106.85, lat: -6.2 },\n  { id: \"MNL\", name: \"Manila\", lon: 120.98, lat: 14.6 },\n  { id: \"SYD\", name: \"Sydney\", lon: 151.21, lat: -33.87 },\n];\nconst cityById = new Map(cities.map((c) => [c.id, c]));\n\nconst flows = [\n  { from: \"MEX\", to: \"WAS\", flow: 480 },\n  { from: \"TOR\", to: \"WAS\", flow: 150 },\n  { from: \"WAS\", to: \"TOR\", flow: 90 },\n  { from: \"MEX\", to: \"TOR\", flow: 40 },\n  { from: \"SAO\", to: \"BUE\", flow: 130 },\n  { from: \"BUE\", to: \"SAO\", flow: 70 },\n  { from: \"SAO\", to: \"LON\", flow: 60 },\n  { from: \"WAS\", to: \"LON\", flow: 210 },\n  { from: \"LON\", to: \"PAR\", flow: 180 },\n  { from: \"PAR\", to: \"BER\", flow: 140 },\n  { from: \"BER\", to: \"LON\", flow: 95 },\n  { from: \"LON\", to: \"IST\", flow: 75 },\n  { from: \"PAR\", to: \"CAI\", flow: 65 },\n  { from: \"IST\", to: \"CAI\", flow: 120 },\n  { from: \"CAI\", to: \"LAG\", flow: 55 },\n  { from: \"LAG\", to: \"NAI\", flow: 40 },\n  { from: \"CAI\", to: \"NAI\", flow: 85 },\n  { from: \"NAI\", to: \"DEL\", flow: 45 },\n  { from: \"IST\", to: \"MOW\", flow: 100 },\n  { from: \"MOW\", to: \"BER\", flow: 130 },\n  { from: \"DEL\", to: \"DAC\", flow: 160 },\n  { from: \"DAC\", to: \"DEL\", flow: 60 },\n  { from: \"DEL\", to: \"JAK\", flow: 50 },\n  { from: \"JAK\", to: \"MNL\", flow: 70 },\n  { from: \"MNL\", to: \"SYD\", flow: 55 },\n  { from: \"JAK\", to: \"SYD\", flow: 90 },\n];\n\nconst nodeTotals = new Map();\nfor (const f of flows) {\n  nodeTotals.set(f.from, (nodeTotals.get(f.from) || 0) + f.flow);\n  nodeTotals.set(f.to, (nodeTotals.get(f.to) || 0) + f.flow);\n}\n\n// --- Layout ------------------------------------------------------------\nconst margin = { top: 140, right: 250, bottom: 40, left: 40 };\nconst mapW = width - margin.left - margin.right;\nconst mapH = height - margin.top - margin.bottom;\n\n// --- Projection: flat equirectangular grid, fit to the map area ------------\nconst projection = d3.geoEquirectangular().fitExtent(\n  [\n    [0, 0],\n    [mapW, mapH],\n  ],\n  { type: \"Sphere\" },\n);\nconst geoPath = d3.geoPath(projection);\nconst graticule = d3.geoGraticule10();\n\n// --- Scales --------------------------------------------------------------\nconst flowExtent = d3.extent(flows, (d) => d.flow);\nconst arcWidth = d3.scaleLinear().domain(flowExtent).range([1.5, 6]);\nconst arcOpacity = d3.scaleLinear().domain(flowExtent).range([0.45, 0.75]);\nconst nodeRadius = d3\n  .scaleSqrt()\n  .domain(d3.extent(Array.from(nodeTotals.values())))\n  .range([6, 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 map's bounding frame under the equirectangular grid\nmapG\n  .append(\"path\")\n  .datum({ type: \"Sphere\" })\n  .attr(\"d\", geoPath)\n  .attr(\"fill\", t.pageBg)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1)\n  .attr(\"stroke-opacity\", 0.5);\n\n// Graticule — subtle 20-degree lon/lat grid for geographic context\nmapG\n  .append(\"path\")\n  .datum(graticule)\n  .attr(\"d\", geoPath)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 0.6);\n\n// Simplified landmass silhouettes — coarse, non-political shapes that give the\n// grid geographic context without claiming coastline accuracy. Each ring is\n// authored as [lon, lat] control points in the winding order d3-geo's\n// spherical clipping expects for a small enclosed patch (verified against the\n// projected bounds — the wrong winding would fill the whole sphere instead).\nfunction landmass(points) {\n  const ring = points.slice();\n  ring.push(ring[0]);\n  return { type: \"Polygon\", coordinates: [ring] };\n}\n\nconst landmasses = [\n  landmass([\n    [-160, 68], [-125, 70], [-95, 62], [-80, 50], [-65, 42], [-68, 25],\n    [-82, 10], [-95, 16], [-110, 24], [-122, 38], [-138, 55],\n  ]),\n  landmass([\n    [-80, 10], [-60, 5], [-48, -5], [-35, -10], [-40, -22], [-58, -35],\n    [-70, -30], [-75, -15], [-80, -2],\n  ]),\n  landmass([\n    [-18, 35], [10, 32], [35, 30], [45, 12], [42, -12], [30, -28],\n    [15, -34], [8, -18], [-5, 5], [-16, 15],\n  ]),\n  landmass([\n    [-10, 60], [15, 68], [45, 66], [75, 58], [100, 55], [130, 55],\n    [140, 45], [125, 35], [105, 20], [90, 10], [70, 15], [50, 30],\n    [30, 38], [15, 42], [0, 45],\n  ]),\n  landmass([\n    [113, -12], [130, -11], [145, -18], [150, -30], [140, -38], [125, -33],\n    [115, -25],\n  ]),\n];\n\nmapG\n  .selectAll(\"path.landmass\")\n  .data(landmasses)\n  .join(\"path\")\n  .attr(\"class\", \"landmass\")\n  .attr(\"d\", geoPath)\n  .attr(\"fill\", t.inkSoft)\n  .attr(\"fill-opacity\", 0.16)\n  .attr(\"stroke\", \"none\");\n\n// Direction gradient + arrowhead — every arc fades brand green (origin) into\n// blue (destination), so color alone encodes flow direction independent of\n// magnitude (width/opacity encode magnitude instead).\nconst defs = svg.append(\"defs\");\n// markerUnits=\"userSpaceOnUse\" pins the arrowhead to a small fixed pixel size\n// (8x8) independent of each arc's stroke-width, so high-flow arcs no longer\n// balloon a wedge over the destination city circle.\ndefs\n  .append(\"marker\")\n  .attr(\"id\", \"flow-arrow\")\n  .attr(\"viewBox\", \"0 0 10 10\")\n  .attr(\"refX\", 8.5)\n  .attr(\"refY\", 5)\n  .attr(\"markerUnits\", \"userSpaceOnUse\")\n  .attr(\"markerWidth\", 8)\n  .attr(\"markerHeight\", 8)\n  .attr(\"orient\", \"auto\")\n  .append(\"path\")\n  .attr(\"d\", \"M0,0 L10,5 L0,10 Z\")\n  .attr(\"fill\", t.palette[2]);\n\n// --- Curved origin-destination arcs (quadratic Bezier, not great-circle) ---\n// Each arc bows toward the same side (perpendicular offset proportional to\n// distance) — the classic flow-map look, distinct from a geodesic path.\nfunction arcPath(x0, y0, x1, y1) {\n  const dx = x1 - x0;\n  const dy = y1 - y0;\n  const dist = Math.hypot(dx, dy) || 1;\n  const bow = dist * 0.16;\n  const mx = (x0 + x1) / 2 - (dy / dist) * bow;\n  const my = (y0 + y1) / 2 + (dx / dist) * bow;\n  return `M${x0},${y0} Q${mx},${my} ${x1},${y1}`;\n}\n\nconst arcLayer = mapG.append(\"g\");\nflows.forEach((d, i) => {\n  const o = cityById.get(d.from);\n  const e = cityById.get(d.to);\n  const [x0, y0] = projection([o.lon, o.lat]);\n  const [x1, y1] = projection([e.lon, e.lat]);\n\n  defs\n    .append(\"linearGradient\")\n    .attr(\"id\", `flow-grad-${i}`)\n    .attr(\"gradientUnits\", \"userSpaceOnUse\")\n    .attr(\"x1\", x0).attr(\"y1\", y0).attr(\"x2\", x1).attr(\"y2\", y1)\n    .selectAll(\"stop\")\n    .data([\n      { offset: \"0%\", color: t.palette[0] },\n      { offset: \"100%\", color: t.palette[2] },\n    ])\n    .join(\"stop\")\n    .attr(\"offset\", (s) => s.offset)\n    .attr(\"stop-color\", (s) => s.color);\n\n  arcLayer\n    .append(\"path\")\n    .datum(d)\n    .attr(\"d\", arcPath(x0, y0, x1, y1))\n    .attr(\"fill\", \"none\")\n    .attr(\"stroke\", `url(#flow-grad-${i})`)\n    .attr(\"stroke-width\", arcWidth(d.flow))\n    .attr(\"stroke-opacity\", arcOpacity(d.flow))\n    .attr(\"stroke-linecap\", \"round\")\n    .attr(\"marker-end\", \"url(#flow-arrow)\");\n});\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\narcLayer\n  .selectAll(\"path\")\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 = cityById.get(d.from);\n    const e = cityById.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.flow}k people/yr`);\n  })\n  .on(\"mouseleave\", function (event, d) {\n    d3.select(this).attr(\"stroke-opacity\", arcOpacity(d.flow));\n    tooltip.style(\"opacity\", 0);\n  });\n\n// City markers — sized by total connected volume (inbound + outbound)\nconst nodeLayer = mapG.append(\"g\");\nnodeLayer\n  .selectAll(\"circle.city\")\n  .data(cities)\n  .join(\"circle\")\n  .attr(\"class\", \"city\")\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/yr`);\n  })\n  .on(\"mouseleave\", function () {\n    d3.select(this).attr(\"stroke\", t.pageBg);\n    tooltip.style(\"opacity\", 0);\n  });\n\n// City labels — static, for the top movers only (keeps the map legible).\n// Default placement is below the marker (least likely to cross an arc); a\n// greedy nudge-down pass then resolves any remaining overlap against labels\n// already placed, using real getBBox() measurements from the browser layout.\nconst topCities = cities\n  .slice()\n  .sort((a, b) => (nodeTotals.get(b.id) || 0) - (nodeTotals.get(a.id) || 0))\n  .slice(0, 8);\nconst placedLabelBoxes = [];\nconst overlaps = (a, b) =>\n  a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;\n\nnodeLayer\n  .selectAll(\"text.city-label\")\n  .data(topCities)\n  .join(\"text\")\n  .attr(\"class\", \"city-label\")\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text((d) => d.name)\n  .each(function (d) {\n    const [px, py] = projection([d.lon, d.lat]);\n    const r = nodeRadius(nodeTotals.get(d.id) || 0);\n    const sel = d3.select(this).attr(\"x\", px).attr(\"y\", py + r + 18);\n    let box = this.getBBox();\n    let attempts = 0;\n    while (placedLabelBoxes.some((b) => overlaps(box, b)) && attempts < 6) {\n      sel.attr(\"y\", py + r + 18 + (attempts + 1) * 16);\n      box = this.getBBox();\n      attempts += 1;\n    }\n    placedLabelBoxes.push(box);\n  });\n\n// --- Legend: direction gradient + magnitude width -------------------------\nconst legendX = mapW + 55;\nconst legendG = g.append(\"g\").attr(\"transform\", `translate(${legendX},20)`);\n\nlegendG\n  .append(\"text\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Direction\");\n\nlegendG\n  .append(\"defs\")\n  .append(\"linearGradient\")\n  .attr(\"id\", \"legend-direction\")\n  .attr(\"x1\", \"0%\").attr(\"x2\", \"100%\")\n  .selectAll(\"stop\")\n  .data([\n    { offset: \"0%\", color: t.palette[0] },\n    { offset: \"100%\", color: t.palette[2] },\n  ])\n  .join(\"stop\")\n  .attr(\"offset\", (s) => s.offset)\n  .attr(\"stop-color\", (s) => s.color);\n\nlegendG\n  .append(\"rect\")\n  .attr(\"y\", 14)\n  .attr(\"width\", 150)\n  .attr(\"height\", 12)\n  .attr(\"fill\", \"url(#legend-direction)\");\nlegendG\n  .append(\"text\")\n  .attr(\"y\", 44)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\"origin → destination\");\n\nlegendG\n  .append(\"text\")\n  .attr(\"y\", 90)\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Flow volume\");\n\nconst widthLegend = [\n  { label: \"50k/yr\", flow: flowExtent[0] },\n  { label: \"260k/yr\", flow: (flowExtent[0] + flowExtent[1]) / 2 },\n  { label: \"480k/yr\", flow: flowExtent[1] },\n];\nconst wlG = legendG.append(\"g\").attr(\"transform\", \"translate(0,110)\");\nwidthLegend.forEach((d, i) => {\n  const y = i * 30;\n  wlG\n    .append(\"line\")\n    .attr(\"x1\", 0).attr(\"x2\", 60).attr(\"y1\", y).attr(\"y2\", y)\n    .attr(\"stroke\", t.palette[0])\n    .attr(\"stroke-opacity\", 0.7)\n    .attr(\"stroke-width\", arcWidth(d.flow))\n    .attr(\"stroke-linecap\", \"round\");\n  wlG\n    .append(\"text\")\n    .attr(\"x\", 74).attr(\"y\", y + 5)\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"13px\")\n    .text(d.label);\n});\n\nlegendG\n  .append(\"circle\")\n  .attr(\"cx\", 8)\n  .attr(\"cy\", 220)\n  .attr(\"r\", 10)\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\", 26)\n  .attr(\"y\", 216)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\"City, sized by\");\nlegendG\n  .append(\"text\")\n  .attr(\"x\", 26)\n  .attr(\"y\", 232)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\"total connected volume\");\n\n// --- Title -----------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 54)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"flowmap-origin-destination · javascript · d3 · anyplot.ai\");\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 84)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"Population movement between major world cities (thousands per year)\");\n"}