{"spec_id":"map-connection-lines","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// map-connection-lines: Connection Lines Map (Origin-Destination)\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-08-26\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Global flight routes between major airports; passenger volume drives arc\n// thickness/opacity. Longitude/latitude double as the chart's x/y coordinates\n// (a simple equirectangular projection), so Chart.js's own linear scales and\n// gridlines become the map's graticule.\nconst airports = [\n  { code: \"JFK\", lat: 40.64, lon: -73.78 },\n  { code: \"LAX\", lat: 33.94, lon: -118.41 },\n  { code: \"ORD\", lat: 41.98, lon: -87.9 },\n  { code: \"LHR\", lat: 51.47, lon: -0.45 },\n  { code: \"CDG\", lat: 49.01, lon: 2.55 },\n  { code: \"FRA\", lat: 50.03, lon: 8.57 },\n  { code: \"DXB\", lat: 25.25, lon: 55.36 },\n  { code: \"DEL\", lat: 28.56, lon: 77.1 },\n  { code: \"PEK\", lat: 40.08, lon: 116.58 },\n  { code: \"HKG\", lat: 22.31, lon: 113.91 },\n  { code: \"SIN\", lat: 1.36, lon: 103.99 },\n  { code: \"HND\", lat: 35.55, lon: 139.78 },\n  { code: \"SYD\", lat: -33.95, lon: 151.18 },\n  { code: \"GRU\", lat: -23.43, lon: -46.47 },\n  { code: \"JNB\", lat: -26.13, lon: 28.24 },\n];\nconst airportByCode = Object.fromEntries(airports.map((a) => [a.code, a]));\n\nconst routes = [\n  { from: \"JFK\", to: \"LHR\", passengers: 780 },\n  { from: \"LAX\", to: \"HND\", passengers: 480 },\n  { from: \"LHR\", to: \"DXB\", passengers: 550 },\n  { from: \"CDG\", to: \"JFK\", passengers: 410 },\n  { from: \"DXB\", to: \"SIN\", passengers: 500 },\n  { from: \"HND\", to: \"SYD\", passengers: 300 },\n  { from: \"GRU\", to: \"CDG\", passengers: 150 },\n  { from: \"JNB\", to: \"LHR\", passengers: 350 },\n  { from: \"SIN\", to: \"SYD\", passengers: 320 },\n  { from: \"PEK\", to: \"LAX\", passengers: 390 },\n  { from: \"FRA\", to: \"JFK\", passengers: 460 },\n  { from: \"ORD\", to: \"LHR\", passengers: 400 },\n  { from: \"DEL\", to: \"DXB\", passengers: 430 },\n  { from: \"HKG\", to: \"SIN\", passengers: 370 },\n  { from: \"LAX\", to: \"SYD\", passengers: 340 },\n];\nconst passengerValues = routes.map((r) => r.passengers);\nconst minPassengers = Math.min(...passengerValues);\nconst maxPassengers = Math.max(...passengerValues);\n\nconst degreeByCode = Object.fromEntries(airports.map((a) => [a.code, 0]));\nroutes.forEach((r) => {\n  degreeByCode[r.from] += 1;\n  degreeByCode[r.to] += 1;\n});\n\n// Simplified continent silhouettes (lon/lat polygons) — stylized geographic\n// context only, not survey-accurate coastlines.\nconst CONTINENTS = [\n  [\n    [-165, 68], [-150, 71], [-125, 70], [-95, 78], [-75, 68], [-65, 60],\n    [-55, 50], [-60, 45], [-70, 41], [-75, 35], [-81, 25], [-97, 26],\n    [-105, 21], [-90, 14], [-83, 9], [-92, 15], [-105, 23], [-115, 30],\n    [-124, 40], [-125, 49], [-135, 58],\n  ],\n  [\n    [-77, 8], [-70, 12], [-60, 8], [-50, 0], [-35, -6], [-40, -18],\n    [-48, -25], [-58, -34], [-68, -38], [-73, -45], [-75, -52], [-70, -55],\n    [-66, -52], [-68, -40], [-72, -30], [-70, -18], [-72, -5], [-77, 1],\n  ],\n  [\n    [-9, 43], [-9, 51], [-5, 58], [5, 61], [15, 58], [25, 60],\n    [30, 55], [28, 46], [18, 42], [14, 38], [3, 43], [-3, 37],\n  ],\n  [\n    [-17, 21], [-16, 15], [-10, 6], [8, 5], [9, -5], [13, -18],\n    [18, -34], [26, -34], [33, -25], [35, -12], [40, -2], [48, 12],\n    [43, 12], [37, 15], [33, 22], [35, 31], [25, 33], [10, 37],\n    [-2, 36], [-10, 32],\n  ],\n  [\n    [27, 41], [35, 47], [48, 55], [60, 55], [70, 58], [80, 60],\n    [95, 65], [110, 70], [130, 72], [150, 68], [162, 60], [155, 52],\n    [142, 45], [140, 36], [130, 33], [122, 31], [110, 21], [103, 16],\n    [98, 8], [102, 3], [95, 5], [92, 15], [88, 22], [80, 8],\n    [77, 20], [70, 24], [61, 25], [50, 29], [44, 33], [35, 36],\n  ],\n  [\n    [113, -22], [122, -18], [131, -12], [137, -12], [142, -11],\n    [145, -17], [153, -28], [150, -37], [143, -39], [137, -35],\n    [131, -32], [123, -34], [114, -34], [113, -26],\n  ],\n];\n\nconst hexToRgba = (hex, alpha) => {\n  const h = hex.replace(\"#\", \"\");\n  const r = parseInt(h.substring(0, 2), 16);\n  const g = parseInt(h.substring(2, 4), 16);\n  const b = parseInt(h.substring(4, 6), 16);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n};\n\nconst project = (chart, lon, lat) => ({\n  x: chart.scales.x.getPixelForValue(lon),\n  y: chart.scales.y.getPixelForValue(lat),\n});\n\n// --- Custom draw plugins -----------------------------------------------------\n// Chart.js has no native geo/map chart type; the base map and the curved\n// connection arcs are drawn directly with the canvas context, projected\n// through the chart's own linear scales. Both run in beforeDatasetsDraw so\n// the airport markers (a real Chart.js dataset) render on top.\nconst worldMapPlugin = {\n  id: \"worldMap\",\n  beforeDatasetsDraw(chart) {\n    const { ctx } = chart;\n    ctx.save();\n    ctx.fillStyle =\n      window.ANYPLOT_THEME === \"light\" ? \"rgba(26, 26, 23, 0.06)\" : \"rgba(240, 239, 232, 0.08)\";\n    ctx.strokeStyle = t.grid;\n    ctx.lineWidth = 1;\n    CONTINENTS.forEach((polygon) => {\n      ctx.beginPath();\n      polygon.forEach(([lon, lat], i) => {\n        const p = project(chart, lon, lat);\n        if (i === 0) ctx.moveTo(p.x, p.y);\n        else ctx.lineTo(p.x, p.y);\n      });\n      ctx.closePath();\n      ctx.fill();\n      ctx.stroke();\n    });\n    ctx.restore();\n  },\n};\n\nconst arcStyle = (route) => {\n  const ratio = (route.passengers - minPassengers) / (maxPassengers - minPassengers);\n  return { lineWidth: 1.5 + ratio * 4.5, strokeStyle: hexToRgba(t.palette[0], 0.3 + ratio * 0.3) };\n};\n\nconst drawArc = (ctx, p0, p1, style) => {\n  const dx = p1.x - p0.x;\n  const dy = p1.y - p0.y;\n  const dist = Math.hypot(dx, dy) || 1;\n  const bulge = dist * 0.15;\n  const cx = (p0.x + p1.x) / 2 - (dy / dist) * bulge;\n  const cy = (p0.y + p1.y) / 2 + (dx / dist) * bulge;\n  ctx.lineWidth = style.lineWidth;\n  ctx.strokeStyle = style.strokeStyle;\n  ctx.beginPath();\n  ctx.moveTo(p0.x, p0.y);\n  ctx.quadraticCurveTo(cx, cy, p1.x, p1.y);\n  ctx.stroke();\n};\n\nconst connectionArcsPlugin = {\n  id: \"connectionArcs\",\n  beforeDatasetsDraw(chart) {\n    const { ctx } = chart;\n    ctx.save();\n    ctx.lineCap = \"round\";\n    routes.forEach((route) => {\n      const origin = airportByCode[route.from];\n      const dest = airportByCode[route.to];\n      const style = arcStyle(route);\n      const lonDelta = dest.lon - origin.lon;\n      if (Math.abs(lonDelta) > 180) {\n        // Antimeridian wraparound (e.g. LAX-HND, PEK-LAX, LAX-SYD): a direct\n        // pixel chord would sweep the \"long way\" through Europe/Africa. Split\n        // the arc in two at the +/-180 boundary so each half bulges toward the\n        // true geodesic (Pacific) side instead.\n        const originEdgeLon = lonDelta > 0 ? -180 : 180;\n        const destEdgeLon = -originEdgeLon;\n        const unwrappedDestLon = lonDelta > 0 ? dest.lon - 360 : dest.lon + 360;\n        const frac = (originEdgeLon - origin.lon) / (unwrappedDestLon - origin.lon);\n        const crossingLat = origin.lat + frac * (dest.lat - origin.lat);\n        const p0 = project(chart, origin.lon, origin.lat);\n        const pOriginEdge = project(chart, originEdgeLon, crossingLat);\n        const pDestEdge = project(chart, destEdgeLon, crossingLat);\n        const p1 = project(chart, dest.lon, dest.lat);\n        drawArc(ctx, p0, pOriginEdge, style);\n        drawArc(ctx, pDestEdge, p1, style);\n      } else {\n        const p0 = project(chart, origin.lon, origin.lat);\n        const p1 = project(chart, dest.lon, dest.lat);\n        drawArc(ctx, p0, p1, style);\n      }\n    });\n    ctx.restore();\n  },\n};\n\n// Small legend translating arc thickness/opacity back into passenger counts;\n// placed in the empty south-Pacific corner of the map so it doesn't collide\n// with any airport marker or route.\nconst legendStops = [0, 0.5, 1].map((ratio) => ({\n  ratio,\n  label: `${Math.round(minPassengers + ratio * (maxPassengers - minPassengers))}`,\n}));\n\nconst arcLegendPlugin = {\n  id: \"arcLegend\",\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea } = chart;\n    ctx.save();\n    ctx.textBaseline = \"middle\";\n    ctx.font = \"12px sans-serif\";\n    const x0 = chartArea.left + 20;\n    let y = chartArea.bottom - 76;\n    ctx.fillStyle = t.inkSoft;\n    ctx.fillText(\"Passengers / route\", x0, y - 16);\n    legendStops.forEach((stop) => {\n      ctx.lineCap = \"round\";\n      ctx.lineWidth = 1.5 + stop.ratio * 4.5;\n      ctx.strokeStyle = hexToRgba(t.palette[0], 0.3 + stop.ratio * 0.3);\n      ctx.beginPath();\n      ctx.moveTo(x0, y);\n      ctx.lineTo(x0 + 28, y);\n      ctx.stroke();\n      ctx.fillStyle = t.inkSoft;\n      ctx.fillText(stop.label, x0 + 36, y);\n      y += 18;\n    });\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Title (scale fontsize to the rendered length, see plot-generator.md) ---\nconst title = \"Global Flight Routes · map-connection-lines · javascript · chartjs · anyplot.ai\";\nconst titleFontSize = Math.max(15, Math.round(22 * Math.min(1, 67 / title.length)));\n\n// --- Chart ---------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      {\n        label: \"Airports\",\n        data: airports.map((a) => ({ x: a.lon, y: a.lat })),\n        pointRadius: airports.map((a) => 6 + degreeByCode[a.code] * 2),\n        pointHoverRadius: airports.map((a) => 8 + degreeByCode[a.code] * 2),\n        pointBackgroundColor: t.palette[0],\n        pointBorderColor: t.pageBg,\n        pointBorderWidth: 1.5,\n      },\n    ],\n  },\n  plugins: [worldMapPlugin, connectionArcsPlugin, arcLegendPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 16 },\n    plugins: {\n      title: {\n        display: true,\n        text: title,\n        color: t.ink,\n        font: { size: titleFontSize, weight: \"500\" },\n      },\n      legend: { display: false },\n      tooltip: {\n        callbacks: {\n          label: (ctx) => airports[ctx.dataIndex].code,\n        },\n      },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: -170,\n        max: 170,\n        title: { display: true, text: \"Longitude\", color: t.ink, font: { size: 16 } },\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          stepSize: 30,\n          callback: (v) => `${v}°`,\n        },\n        grid: { color: t.grid },\n      },\n      y: {\n        type: \"linear\",\n        min: -58,\n        max: 78,\n        title: { display: true, text: \"Latitude\", color: t.ink, font: { size: 16 } },\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          stepSize: 20,\n          callback: (v) => `${v}°`,\n        },\n        grid: { color: t.grid },\n      },\n    },\n  },\n});\n"}