| 1 | import React, { useState, useEffect, useCallback, useMemo } from "react" |
| 2 | import { CategoryColorProvider } from "semiotic" |
| 3 | import { FlowMap } from "semiotic/geo" |
| 4 | |
| 5 | // Each species has southbound + northbound flows tagged with a peak quarter. |
| 6 | // A rolling 2-quarter window filters which flows are visible. |
| 7 | |
| 8 | const migrationNodes = [ |
| 9 | { id: "iceland", lon: -20, lat: 64 }, |
| 10 | { id: "azores", lon: -28, lat: 38 }, |
| 11 | // ... 30 nodes total (see full source) |
| 12 | ] |
| 13 | |
| 14 | function makeFlows(segments, species, quarter, direction) { |
| 15 | return segments.map(([source, target, value]) => ({ |
| 16 | source, target, value, species, quarter, direction, |
| 17 | })) |
| 18 | } |
| 19 | |
| 20 | const speciesConfig = [ |
| 21 | { |
| 22 | name: "Arctic Tern", color: "#00e5ff", |
| 23 | migrations: [ |
| 24 | ...makeFlows([ |
| 25 | ["iceland", "azores", 7], ["azores", "westAfrica", 6], ... |
| 26 | ], "Arctic Tern", 3, "Southbound"), |
| 27 | ...makeFlows([ |
| 28 | ["weddellSea", "namibiaCoast", 5], ["namibiaCoast", "gulfOfGuinea", 5], ... |
| 29 | ], "Arctic Tern", 1, "Northbound"), |
| 30 | ] |
| 31 | }, |
| 32 | // ... 6 species total, each with southbound + northbound legs |
| 33 | ] |
| 34 | |
| 35 | const allFlows = speciesConfig.flatMap(s => s.migrations) |
| 36 | const speciesColorMap = Object.fromEntries(speciesConfig.map(s => [s.name, s.color])) |
| 37 | |
| 38 | // Window positions: Q1+Q2, Q2+Q3, Q3+Q4, Q4+Q1 |
| 39 | function getWindowQuarters(pos) { |
| 40 | return [(pos % 4) + 1, (pos + 1) % 4 + 1] |
| 41 | } |
| 42 | |
| 43 | export default function StreamingMigrationMap({ width = 900 }) { |
| 44 | const height = Math.round(width * 0.55) |
| 45 | const [windowPos, setWindowPos] = useState(0) |
| 46 | const [playing, setPlaying] = useState(true) |
| 47 | |
| 48 | // Advance window every 3 seconds, cycling back to Q1+Q2 |
| 49 | useEffect(() => { |
| 50 | if (!playing) return |
| 51 | const timer = setInterval(() => { |
| 52 | setWindowPos(prev => (prev + 1) % 4) |
| 53 | }, 3000) |
| 54 | return () => clearInterval(timer) |
| 55 | }, [playing]) |
| 56 | |
| 57 | const windowQ = useMemo(() => getWindowQuarters(windowPos), [windowPos]) |
| 58 | const visibleFlows = useMemo( |
| 59 | () => allFlows.filter(f => windowQ.includes(f.quarter)), |
| 60 | [windowQ] |
| 61 | ) |
| 62 | |
| 63 | return ( |
| 64 | <CategoryColorProvider categories={speciesNames} colors={speciesColorMap}> |
| 65 | {/* Quarter timeline bar showing active window */} |
| 66 | <div style={{ display: "flex", gap: 2 }}> |
| 67 | {quarters.map((q, i) => ( |
| 68 | <div key={i} style={{ |
| 69 | flex: 1, |
| 70 | background: windowQ.includes(i + 1) ? "#1b3a5c" : "#0f1a2a", |
| 71 | border: \`1px solid \${windowQ.includes(i+1) ? "#2a5a8a" : "#1a2535"}\`, |
| 72 | borderRadius: 4, padding: "6px 8px", textAlign: "center", |
| 73 | transition: "all 0.4s ease", |
| 74 | }}> |
| 75 | <div style={{ fontWeight: 600, fontSize: 12 }}>{q.label}</div> |
| 76 | <div style={{ fontSize: 10 }}>{q.months}</div> |
| 77 | </div> |
| 78 | ))} |
| 79 | </div> |
| 80 | |
| 81 | <FlowMap |
| 82 | flows={visibleFlows} |
| 83 | nodes={migrationNodes} |
| 84 | areas="world-110m" |
| 85 | projection="equalEarth" |
| 86 | lineType="geo" |
| 87 | edgeColorBy="species" |
| 88 | showParticles |
| 89 | particleStyle={{ |
| 90 | radius: 2, |
| 91 | color: (d) => speciesColorMap[d?.species] || "#fff", |
| 92 | opacity: 0.9, speedMultiplier: 0.7, |
| 93 | maxPerLine: 30, spawnRate: 0.18 |
| 94 | }} |
| 95 | frameProps={{ |
| 96 | areaStyle: () => ({ fill: "#1b2838", stroke: "#2a3f55" }), |
| 97 | background: "#0d1b2a" |
| 98 | }} |
| 99 | /> |
| 100 | </CategoryColorProvider> |
| 101 | ) |
| 102 | } |