472 lines
13 KiB
JavaScript
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;
|
|
}
|