Files
doormile_react/src/components/sections/IndustryWorldMap.tsx

250 lines
7.9 KiB
TypeScript

"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<HTMLCanvasElement>(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 <canvas ref={canvasRef} className="ind__map" aria-hidden="true" />;
}