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 (
setHovered(null)} ref={wrapRef} role="img" > {hovered ? (
{hovered.item.country} 充值 {formatMoney(hovered.item.value)} 活跃 {formatNumber(hovered.item.activeUsers)} 付费 {formatNumber(hovered.item.paidUsers)}
) : null}
); } 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; }