"use client"; import React, { useEffect, useRef } from "react"; /** * Animated dotted world map (Canvas 2D) for the Solutions industry section. * - Continents drawn as a grid of dots clipped to rough continent polygons. * - Major logistics hub cities with continuous double red pulse rings. * - Red glowing packets travelling along arced quadratic-bezier routes. * - Low-opacity dashed connection lines between hubs. * Continent / city coordinates are normalized (0..1): x west→east, y north→south. */ const CONTINENTS: number[][][] = [ // North America [[0.04,0.20],[0.10,0.12],[0.18,0.10],[0.24,0.13],[0.29,0.12],[0.30,0.18], [0.27,0.22],[0.26,0.28],[0.22,0.30],[0.20,0.38],[0.17,0.44],[0.15,0.40], [0.16,0.32],[0.12,0.30],[0.09,0.26],[0.06,0.24]], // South America [[0.21,0.50],[0.27,0.48],[0.31,0.52],[0.31,0.60],[0.29,0.66],[0.27,0.74], [0.24,0.82],[0.22,0.80],[0.22,0.70],[0.205,0.62],[0.20,0.55]], // Europe [[0.45,0.16],[0.50,0.13],[0.55,0.15],[0.57,0.19],[0.55,0.24],[0.50,0.27], [0.47,0.25],[0.455,0.20]], // Africa [[0.46,0.34],[0.53,0.32],[0.58,0.36],[0.585,0.44],[0.56,0.52],[0.53,0.60], [0.50,0.66],[0.47,0.62],[0.46,0.52],[0.45,0.44],[0.45,0.38]], // Asia [[0.56,0.14],[0.64,0.10],[0.74,0.10],[0.84,0.14],[0.90,0.20],[0.92,0.26], [0.86,0.30],[0.80,0.30],[0.74,0.34],[0.70,0.34],[0.66,0.30],[0.60,0.30], [0.575,0.24],[0.565,0.18]], // Australia [[0.81,0.66],[0.87,0.64],[0.92,0.68],[0.92,0.74],[0.86,0.77],[0.81,0.74],[0.80,0.70]], ]; const CITIES: [number, number][] = [ [0.115, 0.30], // 0 Los Angeles [0.265, 0.255],// 1 New York [0.285, 0.66], // 2 São Paulo [0.475, 0.185],// 3 London [0.605, 0.345],// 4 Dubai [0.655, 0.40], // 5 Mumbai [0.745, 0.50], // 6 Singapore [0.815, 0.275],// 7 Shanghai [0.865, 0.715],// 8 Sydney ]; const ROUTES: [number, number][] = [ [0, 1], [1, 3], [3, 4], [4, 5], [5, 6], [6, 7], [7, 8], [1, 2], [3, 7], [0, 7], ]; function pointInPoly(x: number, y: number, poly: number[][]) { let inside = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const xi = poly[i][0], yi = poly[i][1]; const xj = poly[j][0], yj = poly[j][1]; const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; if (intersect) inside = !inside; } return inside; } /** Parse a #rrggbb hex into an [r,g,b] triple. Falls back to the section red. */ function hexToRgb(hex: string): [number, number, number] { const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); if (!m) return [239, 68, 68]; const int = parseInt(m[1], 16); return [(int >> 16) & 255, (int >> 8) & 255, int & 255]; } /** * @param accent Network accent colour (#rrggbb) for the hub nodes, pulse * rings, travelling packets and dashed routes. The dotted continent * silhouette stays neutral grey. Defaults to the section red so the Women * Empowerment usage is unchanged; the MileTruth workflows pass their own * accent (WF1 teal/cyan · WF2 crimson/red). */ export default function IndustryWorldMap({ accent = "#ef4444", }: { accent?: string; }) { const canvasRef = useRef(null); useEffect(() => { const [ar, ag, ab] = hexToRgb(accent); const rgba = (a: number) => `rgba(${ar},${ag},${ab},${a})`; const solid = `rgb(${ar},${ag},${ab})`; const canvas = canvasRef.current; const parent = canvas?.parentElement; if (!canvas || !parent) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; let w = 0, h = 0; let dots: { x: number; y: number }[] = []; let raf = 0; let startTs = 0; const buildDots = () => { dots = []; const gap = Math.max(11, Math.min(17, w / 70)); for (let gx = gap / 2; gx < w; gx += gap) { for (let gy = gap / 2; gy < h; gy += gap) { const nx = gx / w, ny = gy / h; for (const poly of CONTINENTS) { if (pointInPoly(nx, ny, poly)) { dots.push({ x: gx, y: gy }); break; } } } } }; const resize = () => { const rect = parent.getBoundingClientRect(); w = Math.max(1, rect.width); h = Math.max(1, rect.height); const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); canvas.style.width = w + "px"; canvas.style.height = h + "px"; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); buildDots(); }; type Point = { x: number; y: number }; const cityPx = (): Point[] => CITIES.map(([cx, cy]) => ({ x: cx * w, y: cy * h })); const ctrl = (p0: Point, p1: Point): Point => { const mx = (p0.x + p1.x) / 2, my = (p0.y + p1.y) / 2; const lift = Math.hypot(p1.x - p0.x, p1.y - p0.y) * 0.28; return { x: mx, y: my - lift }; }; const bezier = (p0: Point, c: Point, p1: Point, t: number): Point => { const u = 1 - t; return { x: u * u * p0.x + 2 * u * t * c.x + t * t * p1.x, y: u * u * p0.y + 2 * u * t * c.y + t * t * p1.y, }; }; const draw = (time: number) => { ctx.clearRect(0, 0, w, h); // Continent dots ctx.fillStyle = "rgba(120,122,130,0.55)"; for (const d of dots) { ctx.beginPath(); ctx.arc(d.x, d.y, 1.15, 0, Math.PI * 2); ctx.fill(); } const cs = cityPx(); // Dashed connection lines (low opacity) ctx.save(); ctx.setLineDash([4, 7]); ctx.lineWidth = 1; ctx.strokeStyle = rgba(0.13); for (const [a, b] of ROUTES) { const c = ctrl(cs[a], cs[b]); ctx.beginPath(); ctx.moveTo(cs[a].x, cs[a].y); ctx.quadraticCurveTo(c.x, c.y, cs[b].x, cs[b].y); ctx.stroke(); } ctx.restore(); // Travelling red glowing packets ctx.save(); for (let r = 0; r < ROUTES.length; r++) { const [a, b] = ROUTES[r]; const c = ctrl(cs[a], cs[b]); const t = ((time * 0.11 + r * 0.137) % 1 + 1) % 1; const p = bezier(cs[a], c, cs[b], t); // soft trail const tt = Math.max(0, t - 0.04); const pt = bezier(cs[a], c, cs[b], tt); const grad = ctx.createLinearGradient(pt.x, pt.y, p.x, p.y); grad.addColorStop(0, rgba(0)); grad.addColorStop(1, rgba(0.5)); ctx.strokeStyle = grad; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(pt.x, pt.y); ctx.lineTo(p.x, p.y); ctx.stroke(); ctx.shadowColor = solid; ctx.shadowBlur = 12; ctx.fillStyle = solid; ctx.beginPath(); ctx.arc(p.x, p.y, 2.6, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; } ctx.restore(); // City hub nodes + double pulse rings for (const c of cs) { for (let k = 0; k < 2; k++) { const period = 2.6; const phase = (((time + (k * period) / 2) % period) + period) % period / period; const radius = 3 + phase * 24; const alpha = (1 - phase) * 0.45; ctx.beginPath(); ctx.strokeStyle = rgba(alpha); ctx.lineWidth = 1.5; ctx.arc(c.x, c.y, radius, 0, Math.PI * 2); ctx.stroke(); } ctx.fillStyle = solid; ctx.shadowColor = solid; ctx.shadowBlur = 8; ctx.beginPath(); ctx.arc(c.x, c.y, 2.6, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; } }; const loop = (ts: number) => { if (!startTs) startTs = ts; draw((ts - startTs) / 1000); raf = requestAnimationFrame(loop); }; resize(); if (reduced) { draw(0); } else { raf = requestAnimationFrame(loop); } const ro = new ResizeObserver(() => { resize(); if (reduced) draw(0); }); ro.observe(parent); return () => { cancelAnimationFrame(raf); ro.disconnect(); }; }, [accent]); return