250 lines
7.9 KiB
TypeScript
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" />;
|
|
}
|