{"spec_id":"flowmap-origin-destination","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// flowmap-origin-destination: Origin-Destination Flow Map\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Deterministic PRNG (LCG, seed 42) --------------------------------------\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\n\n// --- Data: a logistics network — three US distribution hubs shipping crates\n// to regional retail centers. Flow (crates/week) sets arc thickness.\nconst hubs = [\n  {\n    name: \"Memphis Hub\",\n    lon: -90.049,\n    lat: 35.1495,\n    color: t.palette[0],\n    destinations: [\n      { name: \"Atlanta\", lon: -84.388, lat: 33.749 },\n      { name: \"Miami\", lon: -80.1918, lat: 25.7617 },\n      { name: \"Charlotte\", lon: -80.8431, lat: 35.2271 },\n      { name: \"Nashville\", lon: -86.7816, lat: 36.1627 },\n      { name: \"Dallas\", lon: -96.797, lat: 32.7767 },\n      { name: \"Houston\", lon: -95.3698, lat: 29.7604 },\n      { name: \"New Orleans\", lon: -90.0715, lat: 29.9511 },\n      { name: \"St. Louis\", lon: -90.1994, lat: 38.627 },\n    ],\n  },\n  {\n    name: \"Columbus Hub\",\n    lon: -82.9988,\n    lat: 39.9612,\n    color: t.palette[1],\n    destinations: [\n      { name: \"Chicago\", lon: -87.6298, lat: 41.8781 },\n      { name: \"Detroit\", lon: -83.0458, lat: 42.3314 },\n      { name: \"Pittsburgh\", lon: -79.9959, lat: 40.4406 },\n      { name: \"Boston\", lon: -71.0589, lat: 42.3601 },\n      { name: \"New York\", lon: -74.006, lat: 40.7128 },\n      { name: \"Indianapolis\", lon: -86.1581, lat: 39.7684 },\n      { name: \"Cincinnati\", lon: -84.512, lat: 39.1031 },\n      { name: \"Cleveland\", lon: -81.6944, lat: 41.4993 },\n    ],\n  },\n  {\n    name: \"Reno Hub\",\n    lon: -119.8138,\n    lat: 39.5296,\n    color: t.palette[2],\n    destinations: [\n      { name: \"Seattle\", lon: -122.3321, lat: 47.6062 },\n      { name: \"Portland\", lon: -122.6765, lat: 45.5152 },\n      { name: \"Sacramento\", lon: -121.4944, lat: 38.5816 },\n      { name: \"Los Angeles\", lon: -118.2437, lat: 34.0522 },\n      { name: \"Phoenix\", lon: -112.074, lat: 33.4484 },\n      { name: \"Denver\", lon: -104.9903, lat: 39.7392 },\n      { name: \"Salt Lake City\", lon: -111.891, lat: 40.7608 },\n      { name: \"San Diego\", lon: -117.1611, lat: 32.7157 },\n    ],\n  },\n];\n\n// Deterministic shipment volumes and the flat flow list the arc plugin reads.\nconst flows = [];\nhubs.forEach((hub) => {\n  hub.destinations.forEach((dest) => {\n    dest.flow = Math.round(80 + rand() * 420);\n    flows.push({\n      originLon: hub.lon,\n      originLat: hub.lat,\n      destLon: dest.lon,\n      destLat: dest.lat,\n      color: hub.color,\n      volume: dest.flow,\n      originName: hub.name,\n      destName: dest.name,\n    });\n  });\n  hub.totalFlow = hub.destinations.reduce((sum, d) => sum + d.flow, 0);\n});\n\n// Hub-to-hub trunk flows (both directions per pair) — independently sampled\n// volumes naturally show the directional imbalance the spec calls out\n// (e.g. Memphis ships far more to Columbus than it receives back).\nconst hubPairs = [\n  [0, 1],\n  [1, 0],\n  [1, 2],\n  [2, 1],\n  [2, 0],\n  [0, 2],\n];\nhubPairs.forEach(([i, j]) => {\n  const origin = hubs[i];\n  const dest = hubs[j];\n  const volume = Math.round(60 + rand() * 340);\n  flows.push({\n    originLon: origin.lon,\n    originLat: origin.lat,\n    destLon: dest.lon,\n    destLat: dest.lat,\n    color: origin.color,\n    volume,\n    originName: origin.name,\n    destName: dest.name,\n  });\n});\n\nconst flowValues = flows.map((f) => f.volume);\nconst minFlow = Math.min(...flowValues);\nconst maxFlow = Math.max(...flowValues);\nconst widthFor = (v) => 1.5 + ((v - minFlow) / (maxFlow - minFlow)) * 7.5;\nconst heaviestFlow = flows.reduce((max, f) => (f.volume > max.volume ? f : max), flows[0]);\n\nfunction withAlpha(hex, alpha) {\n  const r = parseInt(hex.slice(1, 3), 16);\n  const g = parseInt(hex.slice(3, 5), 16);\n  const b = parseInt(hex.slice(5, 7), 16);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\n\n// --- Simplified contiguous-US border (static, hand-digitized low-res\n// coastline/border vertices) — a light geographic reference frame drawn with\n// the same canvas-plugin technique, since chartjs.md forbids a geo plugin.\nconst usOutline = [\n  [-124.7, 48.4], [-124.1, 46.9], [-124.1, 44.0], [-124.4, 42.0], [-124.4, 40.8],\n  [-123.8, 39.0], [-122.4, 37.8], [-121.9, 36.6], [-120.5, 34.5], [-119.7, 34.4],\n  [-118.3, 33.7], [-117.6, 33.2], [-117.2, 32.7],\n  [-114.7, 32.5], [-111.0, 31.3], [-108.2, 31.3], [-106.5, 31.8],\n  [-104.9, 29.5], [-102.3, 29.9], [-99.5, 26.4], [-97.4, 25.9],\n  [-97.2, 27.0], [-97.4, 27.8], [-95.3, 28.9], [-93.8, 29.7],\n  [-91.0, 29.1], [-89.4, 29.2], [-88.0, 30.3], [-85.6, 30.4],\n  [-85.0, 29.7], [-83.5, 29.1], [-82.6, 27.8], [-81.8, 25.8],\n  [-80.4, 25.2], [-80.1, 25.8], [-80.5, 28.5], [-81.5, 30.7],\n  [-79.9, 32.8], [-78.6, 33.9], [-77.9, 34.2], [-76.5, 34.7],\n  [-75.5, 35.2], [-76.0, 37.0], [-75.9, 38.0], [-74.9, 38.9],\n  [-74.0, 39.4], [-74.0, 40.6], [-72.9, 41.3], [-71.4, 41.3],\n  [-70.0, 42.3], [-70.2, 43.7], [-68.2, 44.4], [-67.0, 44.8],\n  [-67.8, 47.3], [-70.3, 46.2], [-71.5, 45.0], [-73.3, 45.0],\n  [-76.2, 44.0], [-79.2, 43.3], [-82.4, 41.7], [-83.1, 42.3],\n  [-82.4, 43.6], [-84.5, 46.5], [-87.9, 48.0], [-89.6, 48.0],\n  [-92.3, 48.6], [-95.2, 49.0], [-104.0, 49.0], [-114.0, 49.0],\n  [-117.0, 49.0], [-122.8, 49.0], [-123.2, 48.5], [-124.7, 48.4],\n];\n\nconst basemapPlugin = {\n  id: \"basemap\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const { x, y } = scales;\n    ctx.save();\n    ctx.beginPath();\n    usOutline.forEach(([lon, lat], i) => {\n      const px = x.getPixelForValue(lon);\n      const py = y.getPixelForValue(lat);\n      if (i === 0) ctx.moveTo(px, py);\n      else ctx.lineTo(px, py);\n    });\n    ctx.closePath();\n    ctx.lineWidth = 1.25;\n    ctx.strokeStyle = withAlpha(t.inkSoft, 0.3);\n    ctx.stroke();\n    ctx.restore();\n  },\n};\n\n// --- Custom plugin: draw curved flow arcs beneath the city markers ---------\n// Uses only the chart's own scales + canvas context (core Chart.js plugin\n// hooks), not a community geo/flow chart-type plugin.\nconst flowArcsPlugin = {\n  id: \"flowArcs\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const { x, y } = scales;\n    ctx.save();\n    flows.forEach((f) => {\n      const x0 = x.getPixelForValue(f.originLon);\n      const y0 = y.getPixelForValue(f.originLat);\n      const x1 = x.getPixelForValue(f.destLon);\n      const y1 = y.getPixelForValue(f.destLat);\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 cx = (x0 + x1) / 2 - (dy / dist) * bow;\n      const cy = (y0 + y1) / 2 + (dx / dist) * bow;\n      ctx.beginPath();\n      ctx.moveTo(x0, y0);\n      ctx.quadraticCurveTo(cx, cy, x1, y1);\n      ctx.lineWidth = widthFor(f.volume);\n      ctx.lineCap = \"round\";\n      ctx.strokeStyle = withAlpha(f.color, 0.5);\n      ctx.stroke();\n      // Cache the arc's midpoint (t=0.5 on the quadratic Bezier) so the\n      // callout plugin can label the heaviest corridor without recomputing.\n      f.midX = 0.25 * x0 + 0.5 * cx + 0.25 * x1;\n      f.midY = 0.25 * y0 + 0.5 * cy + 0.25 * y1;\n    });\n    ctx.restore();\n  },\n};\n\n// --- Custom plugin: callout label on the single heaviest corridor ----------\nconst calloutPlugin = {\n  id: \"flowCallout\",\n  afterDatasetsDraw(chart) {\n    const { ctx } = chart;\n    const label = `Heaviest: ${heaviestFlow.originName} → ${heaviestFlow.destName} (${heaviestFlow.volume}/wk)`;\n    ctx.save();\n    ctx.font = \"bold 13px sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    const metrics = ctx.measureText(label);\n    const padX = 8;\n    const padY = 5;\n    const boxW = metrics.width + padX * 2;\n    const boxH = 13 + padY * 2;\n    const bx = heaviestFlow.midX - boxW / 2;\n    const by = heaviestFlow.midY - boxH / 2;\n    ctx.fillStyle = t.elevatedBg;\n    ctx.strokeStyle = t.grid;\n    ctx.lineWidth = 1;\n    ctx.beginPath();\n    ctx.roundRect(bx, by, boxW, boxH, 5);\n    ctx.fill();\n    ctx.stroke();\n    ctx.fillStyle = t.ink;\n    ctx.fillText(label, heaviestFlow.midX, heaviestFlow.midY);\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Datasets: one per hub — the hub itself (diamond) plus its destination\n// cities (circles), sharing the hub's color so arcs and markers read as one group.\nconst datasets = hubs.map((hub) => ({\n  label: hub.name,\n  data: [{ x: hub.lon, y: hub.lat }, ...hub.destinations.map((d) => ({ x: d.lon, y: d.lat }))],\n  pointMeta: [\n    { name: hub.name, detail: `${hub.totalFlow} crates/week outbound` },\n    ...hub.destinations.map((d) => ({ name: d.name, detail: `${d.flow} crates/week from ${hub.name}` })),\n  ],\n  backgroundColor: hub.color,\n  borderColor: t.pageBg,\n  borderWidth: 1.5,\n  pointStyle: [\"rectRot\", ...hub.destinations.map(() => \"circle\")],\n  pointRadius: [15, ...hub.destinations.map(() => 7)],\n  pointHoverRadius: [17, ...hub.destinations.map(() => 9)],\n  showLine: false,\n}));\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets },\n  plugins: [basemapPlugin, flowArcsPlugin, calloutPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 24 },\n    plugins: {\n      title: {\n        display: true,\n        text: \"flowmap-origin-destination · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n        padding: { bottom: 20 },\n      },\n      legend: {\n        position: \"top\",\n        align: \"end\",\n        labels: { color: t.ink, font: { size: 16 }, usePointStyle: true },\n      },\n      tooltip: {\n        backgroundColor: t.elevatedBg,\n        titleColor: t.ink,\n        bodyColor: t.inkSoft,\n        borderColor: t.grid,\n        borderWidth: 1,\n        callbacks: {\n          title: (items) => items[0].dataset.pointMeta[items[0].dataIndex].name,\n          label: (item) => item.dataset.pointMeta[item.dataIndex].detail,\n        },\n      },\n    },\n    scales: {\n      x: {\n        min: -126,\n        max: -68,\n        title: { display: true, text: \"Longitude (°)\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 14 }, stepSize: 10 },\n        grid: { color: t.grid },\n      },\n      y: {\n        min: 24,\n        max: 49,\n        title: { display: true, text: \"Latitude (°)\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 14 }, stepSize: 5 },\n        grid: { color: t.grid },\n      },\n    },\n  },\n});\n"}