2026-06-01 01:51:39 +08:00

472 lines
13 KiB
JavaScript

import { useEffect, useMemo, useRef, useState } from "react";
import worldMap from "../data/world.json";
import { formatMoney, formatNumber } from "../utils/format.js";
const DEGREE = Math.PI / 180;
const INITIAL_ROTATION = { lat: 8, lon: 65 };
const MAX_LATITUDE = 58;
const LAND_RINGS = extractRings(worldMap.features);
export function GlobeMap({ items }) {
const canvasRef = useRef(null);
const wrapRef = useRef(null);
const dragRef = useRef(null);
const [dragging, setDragging] = useState(false);
const [hovered, setHovered] = useState(null);
const [rotation, setRotation] = useState(INITIAL_ROTATION);
const [size, setSize] = useState({ height: 0, width: 0 });
const points = useMemo(() => normalizePoints(items), [items]);
useEffect(() => {
const element = wrapRef.current;
if (!element) {
return undefined;
}
const syncSize = () => {
const rect = element.getBoundingClientRect();
setSize({ height: Math.max(1, Math.round(rect.height)), width: Math.max(1, Math.round(rect.width)) });
};
syncSize();
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(syncSize) : null;
observer?.observe(element);
window.addEventListener("resize", syncSize);
return () => {
observer?.disconnect();
window.removeEventListener("resize", syncSize);
};
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !size.width || !size.height) {
return;
}
drawGlobe(canvas, size, rotation, points);
}, [points, rotation, size]);
const onPointerDown = (event) => {
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
lat: rotation.lat,
lon: rotation.lon,
x: event.clientX,
y: event.clientY
};
setHovered(null);
setDragging(true);
};
const onPointerMove = (event) => {
const drag = dragRef.current;
if (!drag) {
setHovered(findHoveredPoint(event, wrapRef.current, size, rotation, points));
return;
}
const dx = event.clientX - drag.x;
const dy = event.clientY - drag.y;
setRotation({
lat: clamp(drag.lat + dy * 0.24, -MAX_LATITUDE, MAX_LATITUDE),
lon: normalizeLongitude(drag.lon - dx * 0.34)
});
};
const endDrag = (event) => {
if (dragRef.current) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
}
dragRef.current = null;
setDragging(false);
setHovered(findHoveredPoint(event, wrapRef.current, size, rotation, points));
};
return (
<div
aria-label="可旋转全球充值分布"
className={["globe-map", dragging ? "is-dragging" : ""].filter(Boolean).join(" ")}
onPointerCancel={endDrag}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerLeave={() => setHovered(null)}
ref={wrapRef}
role="img"
>
<canvas className="globe-canvas" ref={canvasRef} />
{hovered ? (
<div className="globe-tooltip" style={{ left: hovered.x, top: hovered.y }}>
<strong>{hovered.item.country}</strong>
<span>充值 {formatMoney(hovered.item.value)}</span>
<span>活跃 {formatNumber(hovered.item.activeUsers)}</span>
<span>付费 {formatNumber(hovered.item.paidUsers)}</span>
</div>
) : null}
</div>
);
}
function drawGlobe(canvas, size, rotation, points) {
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(size.width * dpr);
canvas.height = Math.round(size.height * dpr);
canvas.style.width = `${size.width}px`;
canvas.style.height = `${size.height}px`;
const context = canvas.getContext("2d");
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, size.width, size.height);
const cx = size.width / 2;
const cy = size.height / 2;
const radius = Math.max(1, Math.min(size.width * 0.48, size.height * 0.47));
drawAtmosphere(context, cx, cy, radius);
context.save();
context.beginPath();
context.arc(cx, cy, radius, 0, Math.PI * 2);
context.clip();
drawGraticule(context, rotation, cx, cy, radius);
drawLand(context, rotation, cx, cy, radius);
drawRoutes(context, points, rotation, cx, cy, radius);
drawPoints(context, points, rotation, cx, cy, radius);
drawRankMarkers(context, points, rotation, cx, cy, radius);
context.restore();
drawRim(context, cx, cy, radius);
}
function drawAtmosphere(context, cx, cy, radius) {
context.save();
context.shadowBlur = 34;
context.shadowColor = "rgba(35, 228, 255, 0.22)";
context.fillStyle = "rgba(5, 31, 52, 0.92)";
context.beginPath();
context.arc(cx, cy, radius + 1, 0, Math.PI * 2);
context.fill();
context.restore();
const ocean = context.createRadialGradient(cx - radius * 0.42, cy - radius * 0.48, radius * 0.08, cx, cy, radius);
ocean.addColorStop(0, "#144766");
ocean.addColorStop(0.46, "#082c49");
ocean.addColorStop(0.76, "#061f36");
ocean.addColorStop(1, "#04182d");
context.fillStyle = ocean;
context.beginPath();
context.arc(cx, cy, radius, 0, Math.PI * 2);
context.fill();
}
function drawGraticule(context, rotation, cx, cy, radius) {
context.save();
context.lineWidth = 0.65;
context.strokeStyle = "rgba(102, 184, 222, 0.16)";
for (let lat = -60; lat <= 60; lat += 30) {
drawProjectedLine(
context,
Array.from({ length: 91 }, (_, index) => [-180 + index * 4, lat]),
rotation,
cx,
cy,
radius
);
}
for (let lon = -150; lon <= 180; lon += 30) {
drawProjectedLine(
context,
Array.from({ length: 41 }, (_, index) => [lon, -80 + index * 4]),
rotation,
cx,
cy,
radius
);
}
context.restore();
}
function drawLand(context, rotation, cx, cy, radius) {
context.save();
context.fillStyle = "rgba(25, 63, 91, 0.9)";
context.strokeStyle = "rgba(75, 148, 190, 0.58)";
context.lineWidth = 0.62;
for (const ring of LAND_RINGS) {
drawRing(context, ring, rotation, cx, cy, radius);
}
context.restore();
}
function drawRoutes(context, points, rotation, cx, cy, radius) {
if (points.length < 2) {
return;
}
const hub = points[0];
const hubPoint = project(hub.point, rotation, cx, cy, radius);
if (!hubPoint) {
return;
}
context.save();
context.lineWidth = 0.8;
context.strokeStyle = "rgba(43, 230, 246, 0.22)";
for (const target of points.slice(1, 8)) {
const targetPoint = project(target.point, rotation, cx, cy, radius);
if (!targetPoint) {
continue;
}
const midX = (hubPoint.x + targetPoint.x) / 2;
const midY = (hubPoint.y + targetPoint.y) / 2;
const lift = Math.min(36, Math.hypot(targetPoint.x - hubPoint.x, targetPoint.y - hubPoint.y) * 0.22);
context.beginPath();
context.moveTo(hubPoint.x, hubPoint.y);
context.quadraticCurveTo(midX, midY - lift, targetPoint.x, targetPoint.y);
context.stroke();
}
context.restore();
}
function drawPoints(context, points, rotation, cx, cy, radius) {
const max = Math.max(...points.map((item) => item.value), 1);
for (const item of points) {
const point = project(item.point, rotation, cx, cy, radius);
if (!point) {
continue;
}
const strength = Math.sqrt(item.value / max);
const halo = 10 + strength * 24;
const core = 3.2 + strength * 5.4;
const glow = context.createRadialGradient(point.x, point.y, 1, point.x, point.y, halo);
glow.addColorStop(0, "rgba(129, 252, 255, 0.86)");
glow.addColorStop(0.2, "rgba(39, 228, 245, 0.42)");
glow.addColorStop(1, "rgba(39, 228, 245, 0)");
context.fillStyle = glow;
context.beginPath();
context.arc(point.x, point.y, halo, 0, Math.PI * 2);
context.fill();
context.strokeStyle = "rgba(126, 252, 255, 0.42)";
context.lineWidth = 1;
context.beginPath();
context.arc(point.x, point.y, core * 2.2, 0, Math.PI * 2);
context.stroke();
context.fillStyle = "#35f0f3";
context.shadowBlur = 16;
context.shadowColor = "rgba(53, 240, 243, 0.78)";
context.beginPath();
context.arc(point.x, point.y, core, 0, Math.PI * 2);
context.fill();
context.shadowBlur = 0;
}
}
function drawRankMarkers(context, points, rotation, cx, cy, radius) {
const visible = points
.map((item, index) => ({ index, item, point: project(item.point, rotation, cx, cy, radius) }))
.filter((item) => item.point)
.filter((item) => item.index < 5);
if (!visible.length) {
return;
}
context.save();
context.textAlign = "center";
context.textBaseline = "middle";
context.font = "800 11px Inter, Arial, sans-serif";
for (const marker of visible) {
const rank = marker.index + 1;
const markerRadius = rank <= 2 ? 10.5 : 9.5;
context.shadowBlur = 16;
context.shadowColor = "rgba(47, 232, 243, 0.55)";
context.fillStyle = "rgba(4, 31, 50, 0.92)";
context.beginPath();
context.arc(marker.point.x, marker.point.y, markerRadius, 0, Math.PI * 2);
context.fill();
context.shadowBlur = 0;
context.lineWidth = 1.5;
context.strokeStyle = rank <= 2 ? "rgba(143, 255, 255, 0.92)" : "rgba(76, 224, 241, 0.72)";
context.stroke();
context.fillStyle = rank <= 2 ? "#f0fcff" : "#bdf9ff";
context.fillText(String(rank), marker.point.x, marker.point.y + 0.5);
}
context.restore();
}
function drawRim(context, cx, cy, radius) {
context.save();
const rim = context.createRadialGradient(cx, cy, radius * 0.7, cx, cy, radius * 1.04);
rim.addColorStop(0, "rgba(37, 228, 245, 0)");
rim.addColorStop(0.78, "rgba(37, 228, 245, 0.02)");
rim.addColorStop(1, "rgba(37, 228, 245, 0.34)");
context.fillStyle = rim;
context.beginPath();
context.arc(cx, cy, radius * 1.04, 0, Math.PI * 2);
context.fill();
context.lineWidth = 1.1;
context.strokeStyle = "rgba(86, 186, 226, 0.42)";
context.beginPath();
context.arc(cx, cy, radius, 0, Math.PI * 2);
context.stroke();
context.restore();
}
function drawRing(context, ring, rotation, cx, cy, radius) {
context.beginPath();
let started = false;
let hasPoint = false;
for (const coordinate of ring) {
const point = project(coordinate, rotation, cx, cy, radius);
if (!point) {
started = false;
continue;
}
if (!started) {
context.moveTo(point.x, point.y);
started = true;
} else {
context.lineTo(point.x, point.y);
}
hasPoint = true;
}
if (hasPoint) {
context.fill();
context.stroke();
}
}
function drawProjectedLine(context, coordinates, rotation, cx, cy, radius) {
context.beginPath();
let started = false;
for (const coordinate of coordinates) {
const point = project(coordinate, rotation, cx, cy, radius);
if (!point) {
started = false;
continue;
}
if (!started) {
context.moveTo(point.x, point.y);
started = true;
} else {
context.lineTo(point.x, point.y);
}
}
context.stroke();
}
function project(coordinate, rotation, cx, cy, radius) {
const lon = coordinate[0] * DEGREE;
const lat = coordinate[1] * DEGREE;
const centerLon = rotation.lon * DEGREE;
const centerLat = rotation.lat * DEGREE;
const deltaLon = lon - centerLon;
const cosc = Math.sin(centerLat) * Math.sin(lat) + Math.cos(centerLat) * Math.cos(lat) * Math.cos(deltaLon);
if (cosc <= 0.02) {
return null;
}
return {
x: cx + radius * Math.cos(lat) * Math.sin(deltaLon),
y: cy - radius * (Math.cos(centerLat) * Math.sin(lat) - Math.sin(centerLat) * Math.cos(lat) * Math.cos(deltaLon))
};
}
function normalizePoints(items = []) {
return items
.filter((item) => Array.isArray(item.geo_point) && item.geo_point.length === 2)
.map((item) => ({
activeUsers: Number(item.active_users) || 0,
country: item.country,
paidUsers: Number(item.paid_users) || 0,
point: item.geo_point,
value: Number(item.recharge_usd_minor) || 0
}))
.sort((left, right) => right.value - left.value);
}
function findHoveredPoint(event, element, size, rotation, points) {
if (!element || !size.width || !size.height || !points.length) {
return null;
}
const rect = element.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const cx = size.width / 2;
const cy = size.height / 2;
const radius = Math.max(1, Math.min(size.width * 0.48, size.height * 0.47));
let nearest = null;
for (const item of points) {
const projected = project(item.point, rotation, cx, cy, radius);
if (!projected) {
continue;
}
const distance = Math.hypot(projected.x - x, projected.y - y);
if (distance <= 18 && (!nearest || distance < nearest.distance)) {
nearest = { distance, item, x: projected.x, y: projected.y };
}
}
if (!nearest) {
return null;
}
return {
item: nearest.item,
x: clamp(nearest.x + 14, 8, size.width - 136),
y: clamp(nearest.y - 76, 8, size.height - 96)
};
}
function extractRings(features = []) {
const rings = [];
for (const feature of features) {
const geometry = feature.geometry;
if (!geometry) {
continue;
}
if (geometry.type === "Polygon") {
rings.push(...geometry.coordinates);
}
if (geometry.type === "MultiPolygon") {
for (const polygon of geometry.coordinates) {
rings.push(...polygon);
}
}
}
return rings;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeLongitude(value) {
return ((((value + 180) % 360) + 360) % 360) - 180;
}