2025-04-19 18:54:19 +08:00
|
|
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
|
|
|
import * as THREE from "three";
|
|
|
|
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
|
|
|
import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
2025-05-02 23:47:55 +08:00
|
|
|
|
|
|
|
|
|
// WASM模块接口
|
|
|
|
|
interface GeoWasmModule {
|
|
|
|
|
GeoProcessor: new () => {
|
|
|
|
|
process_geojson: (worldData: string, chinaData: string, visitedPlaces: string, scale: number) => void;
|
|
|
|
|
get_boundary_lines: () => any[];
|
|
|
|
|
find_nearest_country: (x: number, y: number, z: number, radius: number) => string | null;
|
|
|
|
|
};
|
|
|
|
|
default?: () => Promise<any>;
|
|
|
|
|
}
|
2025-03-09 01:11:43 +08:00
|
|
|
|
|
2025-03-10 17:22:18 +08:00
|
|
|
|
interface WorldHeatmapProps {
|
|
|
|
|
visitedPlaces: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
2025-03-28 02:27:42 +08:00
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const [hoveredCountry, setHoveredCountry] = useState<string | null>(null);
|
2025-04-19 18:54:19 +08:00
|
|
|
|
const [theme, setTheme] = useState<"light" | "dark">(
|
|
|
|
|
typeof document !== "undefined" &&
|
|
|
|
|
(document.documentElement.classList.contains("dark") ||
|
|
|
|
|
document.documentElement.getAttribute("data-theme") === "dark")
|
|
|
|
|
? "dark"
|
|
|
|
|
: "light",
|
2025-03-28 18:54:07 +08:00
|
|
|
|
);
|
2025-05-02 23:47:55 +08:00
|
|
|
|
|
|
|
|
|
// 用于存储WASM模块和处理器实例
|
|
|
|
|
const [wasmModule, setWasmModule] = useState<GeoWasmModule | null>(null);
|
|
|
|
|
const [geoProcessor, setGeoProcessor] = useState<any>(null);
|
|
|
|
|
const [wasmError, setWasmError] = useState<string | null>(null);
|
|
|
|
|
// 添加状态标记WASM是否已准备好
|
|
|
|
|
const [wasmReady, setWasmReady] = useState(false);
|
|
|
|
|
|
|
|
|
|
// 添加地图数据状态
|
|
|
|
|
const [mapData, setMapData] = useState<{
|
|
|
|
|
worldData: any | null;
|
|
|
|
|
chinaData: any | null;
|
|
|
|
|
}>({ worldData: null, chinaData: null });
|
|
|
|
|
|
|
|
|
|
// 添加地图加载状态
|
|
|
|
|
const [mapLoading, setMapLoading] = useState(true);
|
|
|
|
|
const [mapError, setMapError] = useState<string | null>(null);
|
2025-03-28 18:54:07 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
const sceneRef = useRef<{
|
|
|
|
|
scene: THREE.Scene;
|
|
|
|
|
camera: THREE.PerspectiveCamera;
|
|
|
|
|
renderer: THREE.WebGLRenderer;
|
|
|
|
|
labelRenderer: CSS2DRenderer;
|
|
|
|
|
controls: OrbitControls;
|
|
|
|
|
earth: THREE.Mesh;
|
|
|
|
|
countries: Map<string, THREE.Object3D>;
|
|
|
|
|
raycaster: THREE.Raycaster;
|
|
|
|
|
mouse: THREE.Vector2;
|
|
|
|
|
animationId: number | null;
|
|
|
|
|
lastCameraPosition: THREE.Vector3 | null;
|
|
|
|
|
lastMouseEvent: MouseEvent | null;
|
|
|
|
|
lastClickedCountry: string | null;
|
2025-03-28 18:54:07 +08:00
|
|
|
|
lastMouseX: number | null;
|
|
|
|
|
lastMouseY: number | null;
|
|
|
|
|
lastHoverTime: number | null;
|
2025-05-02 23:47:55 +08:00
|
|
|
|
lineToCountryMap: Map<THREE.Line, string>;
|
|
|
|
|
allLineObjects: THREE.Line[];
|
2025-03-28 02:27:42 +08:00
|
|
|
|
} | null>(null);
|
2025-03-09 01:11:43 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 监听主题变化
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleThemeChange = () => {
|
2025-04-19 18:54:19 +08:00
|
|
|
|
const isDark =
|
|
|
|
|
document.documentElement.classList.contains("dark") ||
|
|
|
|
|
document.documentElement.getAttribute("data-theme") === "dark";
|
|
|
|
|
setTheme(isDark ? "dark" : "light");
|
2025-03-28 18:54:07 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 创建 MutationObserver 来监听 class 和 data-theme 属性的变化
|
|
|
|
|
const observer = new MutationObserver((mutations) => {
|
|
|
|
|
mutations.forEach((mutation) => {
|
|
|
|
|
if (
|
2025-04-19 18:54:19 +08:00
|
|
|
|
(mutation.attributeName === "class" &&
|
|
|
|
|
mutation.target === document.documentElement) ||
|
|
|
|
|
(mutation.attributeName === "data-theme" &&
|
|
|
|
|
mutation.target === document.documentElement)
|
2025-03-28 18:54:07 +08:00
|
|
|
|
) {
|
|
|
|
|
handleThemeChange();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 开始观察
|
2025-04-19 18:54:19 +08:00
|
|
|
|
observer.observe(document.documentElement, {
|
|
|
|
|
attributes: true,
|
|
|
|
|
attributeFilter: ["class", "data-theme"],
|
2025-03-28 18:54:07 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 初始检查
|
|
|
|
|
handleThemeChange();
|
|
|
|
|
|
|
|
|
|
// 清理
|
|
|
|
|
return () => {
|
|
|
|
|
observer.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-05-02 23:47:55 +08:00
|
|
|
|
// 动态加载地图数据
|
2025-03-09 01:11:43 +08:00
|
|
|
|
useEffect(() => {
|
2025-05-02 23:47:55 +08:00
|
|
|
|
const loadMapData = async () => {
|
|
|
|
|
try {
|
|
|
|
|
setMapLoading(true);
|
|
|
|
|
setMapError(null);
|
|
|
|
|
|
|
|
|
|
// 并行加载两个地图数据
|
|
|
|
|
const [worldDataModule, chinaDataModule] = await Promise.all([
|
|
|
|
|
import("@/assets/map/world.zh.json"),
|
|
|
|
|
import("@/assets/map/china.json")
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
setMapData({
|
|
|
|
|
worldData: worldDataModule.default || worldDataModule,
|
|
|
|
|
chinaData: chinaDataModule.default || chinaDataModule
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setMapLoading(false);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("加载地图数据失败:", err);
|
|
|
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
|
|
|
setMapError(`地图数据加载失败: ${errorMessage}`);
|
|
|
|
|
setMapLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
loadMapData();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 加载WASM模块
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const loadWasmModule = async () => {
|
|
|
|
|
try {
|
|
|
|
|
// 改为使用JS胶水文件方式加载WASM
|
|
|
|
|
const wasm = await import(
|
|
|
|
|
"@/assets/wasm/geo/geo_wasm.js"
|
|
|
|
|
);
|
|
|
|
|
if (typeof wasm.default === "function") {
|
|
|
|
|
await wasm.default();
|
|
|
|
|
}
|
|
|
|
|
setWasmModule(wasm as unknown as GeoWasmModule);
|
|
|
|
|
setWasmError(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("加载WASM模块失败:", err);
|
|
|
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
|
|
|
setWasmError(`WASM模块初始化失败: ${errorMessage}`);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
loadWasmModule();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 初始化WASM数据
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!wasmModule || !mapData.worldData || !mapData.chinaData) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 使用JS胶水层方式初始化WASM
|
|
|
|
|
const geoProcessorInstance = new wasmModule.GeoProcessor();
|
|
|
|
|
geoProcessorInstance.process_geojson(
|
|
|
|
|
JSON.stringify(mapData.worldData),
|
|
|
|
|
JSON.stringify(mapData.chinaData),
|
|
|
|
|
JSON.stringify(visitedPlaces),
|
|
|
|
|
2.01
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
setGeoProcessor(geoProcessorInstance);
|
|
|
|
|
setWasmReady(true);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("WASM数据处理失败:", error);
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
|
|
|
setWasmError(`WASM数据处理失败: ${errorMessage}`);
|
|
|
|
|
}
|
|
|
|
|
}, [wasmModule, visitedPlaces, mapData.worldData, mapData.chinaData]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!containerRef.current || !wasmModule || !wasmReady || !geoProcessor) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-03-09 01:11:43 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 清理之前的场景
|
|
|
|
|
if (sceneRef.current) {
|
|
|
|
|
if (sceneRef.current.animationId !== null) {
|
|
|
|
|
cancelAnimationFrame(sceneRef.current.animationId);
|
|
|
|
|
}
|
|
|
|
|
sceneRef.current.renderer.dispose();
|
|
|
|
|
sceneRef.current.labelRenderer.domElement.remove();
|
|
|
|
|
sceneRef.current.scene.clear();
|
2025-04-19 18:54:19 +08:00
|
|
|
|
containerRef.current.innerHTML = "";
|
2025-03-27 21:40:41 +08:00
|
|
|
|
}
|
2025-05-02 23:47:55 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 检查当前是否为暗色模式
|
2025-04-19 18:54:19 +08:00
|
|
|
|
const isDarkMode =
|
|
|
|
|
document.documentElement.classList.contains("dark") ||
|
|
|
|
|
document.documentElement.getAttribute("data-theme") === "dark";
|
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 根据当前模式设置颜色
|
|
|
|
|
const getColors = () => {
|
|
|
|
|
return {
|
2025-05-02 23:47:55 +08:00
|
|
|
|
earthBase: isDarkMode ? "#111827" : "#2a4d69", // 深色模式保持深色,浅色模式改为更柔和的蓝色
|
2025-04-19 18:54:19 +08:00
|
|
|
|
visited: isDarkMode ? "#065f46" : "#34d399", // 访问过的颜色更鲜明
|
2025-05-02 23:47:55 +08:00
|
|
|
|
border: isDarkMode ? "#6b7280" : "#e0e0e0", // 边界颜色调整为更亮的浅灰色
|
|
|
|
|
visitedBorder: isDarkMode ? "#10b981" : "#0d9488", // 访问过的边界颜色
|
2025-04-19 18:54:19 +08:00
|
|
|
|
chinaBorder: isDarkMode ? "#f87171" : "#ef4444", // 中国边界使用红色
|
|
|
|
|
text: isDarkMode ? "#f9fafb" : "#1f2937", // 文本颜色对比更强
|
2025-05-02 23:47:55 +08:00
|
|
|
|
highlight: isDarkMode ? "#fcd34d" : "#60a5fa", // 高亮颜色改为浅蓝色,更配合背景
|
2025-03-28 02:27:42 +08:00
|
|
|
|
};
|
|
|
|
|
};
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
const colors = getColors();
|
|
|
|
|
|
|
|
|
|
// 创建场景
|
|
|
|
|
const scene = new THREE.Scene();
|
|
|
|
|
scene.background = null;
|
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 创建材质的辅助函数
|
2025-04-19 18:54:19 +08:00
|
|
|
|
const createMaterial = (
|
|
|
|
|
color: string,
|
|
|
|
|
side: THREE.Side = THREE.FrontSide,
|
|
|
|
|
opacity: number = 1.0,
|
|
|
|
|
) => {
|
2025-03-28 18:54:07 +08:00
|
|
|
|
return new THREE.MeshBasicMaterial({
|
2025-03-28 02:27:42 +08:00
|
|
|
|
color: color,
|
|
|
|
|
side: side,
|
2025-03-28 18:54:07 +08:00
|
|
|
|
transparent: true,
|
2025-04-19 18:54:19 +08:00
|
|
|
|
opacity: opacity,
|
2025-03-28 02:27:42 +08:00
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 创建地球几何体
|
|
|
|
|
const earthGeometry = new THREE.SphereGeometry(2.0, 64, 64);
|
2025-04-19 18:54:19 +08:00
|
|
|
|
const earthMaterial = createMaterial(
|
|
|
|
|
colors.earthBase,
|
|
|
|
|
THREE.FrontSide,
|
2025-05-02 23:47:55 +08:00
|
|
|
|
isDarkMode ? 0.9 : 0.9, // 调整明亮模式下的不透明度
|
2025-04-19 18:54:19 +08:00
|
|
|
|
);
|
2025-03-28 18:54:07 +08:00
|
|
|
|
const earth = new THREE.Mesh(earthGeometry, earthMaterial);
|
|
|
|
|
earth.renderOrder = 1;
|
|
|
|
|
scene.add(earth);
|
|
|
|
|
|
|
|
|
|
// 添加光源
|
2025-04-19 18:54:19 +08:00
|
|
|
|
const ambientLight = new THREE.AmbientLight(
|
|
|
|
|
0xffffff,
|
2025-05-02 23:47:55 +08:00
|
|
|
|
isDarkMode ? 0.7 : 0.85, // 微调明亮模式下的光照强度
|
2025-04-19 18:54:19 +08:00
|
|
|
|
);
|
2025-03-28 18:54:07 +08:00
|
|
|
|
scene.add(ambientLight);
|
|
|
|
|
|
2025-04-19 18:54:19 +08:00
|
|
|
|
const directionalLight = new THREE.DirectionalLight(
|
2025-05-02 23:47:55 +08:00
|
|
|
|
isDarkMode ? 0xeeeeff : 0xffffff, // 恢复明亮模式下的纯白光源
|
|
|
|
|
isDarkMode ? 0.6 : 0.65, // 微调明亮模式下的定向光强度
|
2025-04-19 18:54:19 +08:00
|
|
|
|
);
|
2025-03-28 18:54:07 +08:00
|
|
|
|
directionalLight.position.set(5, 3, 5);
|
|
|
|
|
scene.add(directionalLight);
|
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 创建相机
|
|
|
|
|
const camera = new THREE.PerspectiveCamera(
|
2025-04-19 18:54:19 +08:00
|
|
|
|
45,
|
|
|
|
|
containerRef.current.clientWidth / containerRef.current.clientHeight,
|
|
|
|
|
0.1,
|
|
|
|
|
1000,
|
2025-03-28 02:27:42 +08:00
|
|
|
|
);
|
|
|
|
|
camera.position.z = 8;
|
|
|
|
|
|
|
|
|
|
// 创建渲染器
|
2025-04-19 18:54:19 +08:00
|
|
|
|
const renderer = new THREE.WebGLRenderer({
|
2025-03-28 02:27:42 +08:00
|
|
|
|
antialias: true,
|
2025-03-28 18:54:07 +08:00
|
|
|
|
alpha: true,
|
2025-04-19 18:54:19 +08:00
|
|
|
|
logarithmicDepthBuffer: true,
|
2025-03-28 02:27:42 +08:00
|
|
|
|
preserveDrawingBuffer: true,
|
2025-04-19 18:54:19 +08:00
|
|
|
|
precision: "highp",
|
2025-03-27 21:40:41 +08:00
|
|
|
|
});
|
2025-04-19 18:54:19 +08:00
|
|
|
|
renderer.sortObjects = true;
|
|
|
|
|
renderer.setClearColor(0x000000, 0);
|
2025-03-28 02:27:42 +08:00
|
|
|
|
renderer.setPixelRatio(window.devicePixelRatio);
|
2025-04-19 18:54:19 +08:00
|
|
|
|
renderer.setSize(
|
|
|
|
|
containerRef.current.clientWidth,
|
|
|
|
|
containerRef.current.clientHeight,
|
|
|
|
|
);
|
2025-03-28 02:27:42 +08:00
|
|
|
|
containerRef.current.appendChild(renderer.domElement);
|
|
|
|
|
|
|
|
|
|
// 创建CSS2D渲染器用于标签
|
|
|
|
|
const labelRenderer = new CSS2DRenderer();
|
2025-04-19 18:54:19 +08:00
|
|
|
|
labelRenderer.setSize(
|
|
|
|
|
containerRef.current.clientWidth,
|
|
|
|
|
containerRef.current.clientHeight,
|
|
|
|
|
);
|
|
|
|
|
labelRenderer.domElement.style.position = "absolute";
|
|
|
|
|
labelRenderer.domElement.style.top = "0";
|
|
|
|
|
labelRenderer.domElement.style.pointerEvents = "none";
|
2025-03-28 02:27:42 +08:00
|
|
|
|
containerRef.current.appendChild(labelRenderer.domElement);
|
|
|
|
|
|
|
|
|
|
// 添加控制器
|
|
|
|
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|
|
|
|
controls.enableDamping = true;
|
2025-03-28 18:54:07 +08:00
|
|
|
|
controls.dampingFactor = 0.25; // 大幅增加阻尼因子,从0.1到0.25提高稳定性
|
|
|
|
|
controls.rotateSpeed = 0.2; // 降低旋转速度,提高稳定性
|
2025-03-28 02:27:42 +08:00
|
|
|
|
controls.autoRotate = true;
|
2025-03-28 18:54:07 +08:00
|
|
|
|
controls.autoRotateSpeed = 0.3; // 降低自动旋转速度
|
2025-03-28 02:27:42 +08:00
|
|
|
|
controls.minDistance = 5;
|
|
|
|
|
controls.maxDistance = 15;
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
controls.minPolarAngle = Math.PI * 0.1;
|
|
|
|
|
controls.maxPolarAngle = Math.PI * 0.9;
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
|
|
|
|
controls.addEventListener("change", () => {
|
2025-03-28 02:27:42 +08:00
|
|
|
|
if (sceneRef.current) {
|
|
|
|
|
renderer.render(scene, camera);
|
|
|
|
|
labelRenderer.render(scene, camera);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 保存所有线条对象的引用,用于快速检测
|
|
|
|
|
const allLineObjects: THREE.Line[] = [];
|
|
|
|
|
const lineToCountryMap = new Map<THREE.Line, string>();
|
2025-05-02 23:47:55 +08:00
|
|
|
|
|
|
|
|
|
// 创建国家边界组
|
|
|
|
|
const countryGroup = new THREE.Group();
|
|
|
|
|
earth.add(countryGroup);
|
|
|
|
|
|
|
|
|
|
// 创建国家边界
|
|
|
|
|
const countries = new Map<string, THREE.Object3D>();
|
|
|
|
|
|
|
|
|
|
// 从WASM获取边界线数据
|
|
|
|
|
const boundaryLines = geoProcessor.get_boundary_lines();
|
|
|
|
|
|
|
|
|
|
// 处理边界线数据
|
|
|
|
|
if (boundaryLines) {
|
|
|
|
|
// 遍历所有边界线
|
|
|
|
|
for (const boundaryLine of boundaryLines) {
|
|
|
|
|
const { points, region_name, is_visited } = boundaryLine;
|
|
|
|
|
|
|
|
|
|
// 创建区域组
|
|
|
|
|
const regionObject = new THREE.Group();
|
|
|
|
|
regionObject.userData = { name: region_name, isVisited: is_visited };
|
|
|
|
|
|
|
|
|
|
// 转换点数组为THREE.Vector3数组
|
|
|
|
|
const threePoints = points.map((p: { x: number; y: number; z: number }) => new THREE.Vector3(p.x, p.y, p.z));
|
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 创建边界线
|
2025-05-02 23:47:55 +08:00
|
|
|
|
if (threePoints.length > 1) {
|
|
|
|
|
const lineGeometry = new THREE.BufferGeometry().setFromPoints(threePoints);
|
|
|
|
|
|
|
|
|
|
// 确定线条颜色
|
|
|
|
|
const isChina = region_name === "中国" || region_name.startsWith("中国-");
|
|
|
|
|
let borderColor;
|
|
|
|
|
|
|
|
|
|
if (is_visited) {
|
|
|
|
|
// 已访问的地区,包括中国城市,都使用绿色边界
|
|
|
|
|
borderColor = colors.visitedBorder;
|
|
|
|
|
} else if (isChina) {
|
|
|
|
|
// 未访问的中国和中国区域使用红色边界
|
|
|
|
|
borderColor = colors.chinaBorder;
|
|
|
|
|
} else {
|
|
|
|
|
// 其他未访问区域使用默认边界颜色
|
|
|
|
|
borderColor = colors.border;
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-19 18:54:19 +08:00
|
|
|
|
const lineMaterial = new THREE.LineBasicMaterial({
|
2025-05-02 23:47:55 +08:00
|
|
|
|
color: borderColor,
|
|
|
|
|
linewidth: is_visited ? 1.8 : 1.2, // 微调线条宽度,保持已访问区域更明显
|
2025-03-28 02:27:42 +08:00
|
|
|
|
transparent: true,
|
2025-05-02 23:47:55 +08:00
|
|
|
|
opacity: is_visited ? 0.95 : 0.85, // 调整不透明度,使边界明显但不突兀
|
2025-03-28 02:27:42 +08:00
|
|
|
|
});
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
const line = new THREE.Line(lineGeometry, lineMaterial);
|
2025-04-19 18:54:19 +08:00
|
|
|
|
line.userData = {
|
2025-05-02 23:47:55 +08:00
|
|
|
|
name: region_name,
|
|
|
|
|
isVisited: is_visited,
|
|
|
|
|
originalColor: borderColor,
|
|
|
|
|
highlightColor: colors.highlight,
|
2025-03-09 01:11:43 +08:00
|
|
|
|
};
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 设置渲染顺序
|
2025-05-02 23:47:55 +08:00
|
|
|
|
line.renderOrder = is_visited ? 3 : 2;
|
2025-03-28 02:27:42 +08:00
|
|
|
|
regionObject.add(line);
|
2025-05-02 23:47:55 +08:00
|
|
|
|
|
|
|
|
|
// 保存线条对象引用和对应的区域名称
|
2025-03-28 18:54:07 +08:00
|
|
|
|
allLineObjects.push(line);
|
2025-05-02 23:47:55 +08:00
|
|
|
|
lineToCountryMap.set(line, region_name);
|
2025-03-28 02:27:42 +08:00
|
|
|
|
}
|
2025-05-02 23:47:55 +08:00
|
|
|
|
|
|
|
|
|
// 添加区域对象到国家组
|
|
|
|
|
countryGroup.add(regionObject);
|
|
|
|
|
countries.set(region_name, regionObject);
|
2025-03-28 02:27:42 +08:00
|
|
|
|
}
|
2025-05-02 23:47:55 +08:00
|
|
|
|
}
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 将视图旋转到中国位置
|
|
|
|
|
const positionCameraToFaceChina = () => {
|
|
|
|
|
// 检查是否为小屏幕
|
2025-04-19 18:54:19 +08:00
|
|
|
|
const isSmallScreen =
|
|
|
|
|
containerRef.current && containerRef.current.clientWidth < 640;
|
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 根据屏幕大小设置不同的相机初始位置
|
|
|
|
|
let fixedPosition;
|
|
|
|
|
if (isSmallScreen) {
|
|
|
|
|
// 小屏幕显示距离更远,以便看到更多地球
|
2025-04-19 18:54:19 +08:00
|
|
|
|
fixedPosition = new THREE.Vector3(-2.1, 3.41, -8.0);
|
2025-03-28 18:54:07 +08:00
|
|
|
|
} else {
|
|
|
|
|
// 大屏幕使用原来的位置
|
2025-04-19 18:54:19 +08:00
|
|
|
|
fixedPosition = new THREE.Vector3(-2.1, 3.41, -6.5);
|
2025-03-28 18:54:07 +08:00
|
|
|
|
}
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 应用位置
|
|
|
|
|
camera.position.copy(fixedPosition);
|
|
|
|
|
camera.lookAt(0, 0, 0);
|
|
|
|
|
controls.update();
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-05-02 23:47:55 +08:00
|
|
|
|
// 确保自动旋转始终开启
|
|
|
|
|
controls.autoRotate = true;
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 渲染
|
|
|
|
|
renderer.render(scene, camera);
|
|
|
|
|
labelRenderer.render(scene, camera);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 应用初始相机位置
|
|
|
|
|
positionCameraToFaceChina();
|
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 创建射线投射器用于鼠标交互
|
|
|
|
|
const raycaster = new THREE.Raycaster();
|
|
|
|
|
const mouse = new THREE.Vector2();
|
|
|
|
|
|
|
|
|
|
// 添加节流函数,限制鼠标移动事件的触发频率
|
|
|
|
|
const throttle = (func: Function, limit: number) => {
|
|
|
|
|
let inThrottle: boolean = false;
|
2025-03-28 18:54:07 +08:00
|
|
|
|
let lastFunc: number | null = null;
|
|
|
|
|
let lastRan: number | null = null;
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
|
|
|
|
return function (this: any, ...args: any[]) {
|
2025-03-28 02:27:42 +08:00
|
|
|
|
if (!inThrottle) {
|
|
|
|
|
func.apply(this, args);
|
|
|
|
|
inThrottle = true;
|
2025-03-28 18:54:07 +08:00
|
|
|
|
lastRan = Date.now();
|
2025-04-19 18:54:19 +08:00
|
|
|
|
setTimeout(() => (inThrottle = false), limit);
|
2025-03-28 18:54:07 +08:00
|
|
|
|
} else {
|
|
|
|
|
// 取消之前的延迟调用
|
|
|
|
|
if (lastFunc) clearTimeout(lastFunc);
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 如果距离上次执行已经接近阈值,确保我们能及时处理下一个事件
|
|
|
|
|
const sinceLastRan = Date.now() - (lastRan || 0);
|
|
|
|
|
if (sinceLastRan >= limit * 0.8) {
|
|
|
|
|
lastFunc = window.setTimeout(() => {
|
|
|
|
|
if (lastRan && Date.now() - lastRan >= limit) {
|
|
|
|
|
func.apply(this, args);
|
|
|
|
|
lastRan = Date.now();
|
|
|
|
|
}
|
|
|
|
|
}, Math.max(limit - sinceLastRan, 0));
|
|
|
|
|
}
|
2025-03-28 02:27:42 +08:00
|
|
|
|
}
|
2025-03-27 21:40:41 +08:00
|
|
|
|
};
|
|
|
|
|
};
|
2025-03-28 02:27:42 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 简化的鼠标移动事件处理函数
|
2025-03-28 02:27:42 +08:00
|
|
|
|
const onMouseMove = throttle((event: MouseEvent) => {
|
2025-05-02 23:47:55 +08:00
|
|
|
|
if (!containerRef.current || !sceneRef.current || !geoProcessor) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 获取鼠标在球面上的点
|
2025-05-02 23:47:55 +08:00
|
|
|
|
const result = getPointOnSphere(
|
2025-04-19 18:54:19 +08:00
|
|
|
|
event.clientX,
|
|
|
|
|
event.clientY,
|
|
|
|
|
camera,
|
|
|
|
|
2.01,
|
|
|
|
|
);
|
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 重置所有线条颜色
|
2025-04-19 18:54:19 +08:00
|
|
|
|
allLineObjects.forEach((line) => {
|
2025-03-28 18:54:07 +08:00
|
|
|
|
if (line.material instanceof THREE.LineBasicMaterial) {
|
|
|
|
|
line.material.color.set(line.userData.originalColor);
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-05-02 23:47:55 +08:00
|
|
|
|
// 如果找到点和对应的国家/地区
|
|
|
|
|
if (result && result.countryName) {
|
|
|
|
|
// 高亮显示该国家/地区的线条
|
|
|
|
|
allLineObjects.forEach((line) => {
|
|
|
|
|
if (
|
|
|
|
|
lineToCountryMap.get(line) === result.countryName &&
|
|
|
|
|
line.material instanceof THREE.LineBasicMaterial
|
|
|
|
|
) {
|
|
|
|
|
line.material.color.set(line.userData.highlightColor);
|
2025-03-28 18:54:07 +08:00
|
|
|
|
}
|
2025-05-02 23:47:55 +08:00
|
|
|
|
});
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-05-02 23:47:55 +08:00
|
|
|
|
// 更新悬停国家
|
|
|
|
|
if (result.countryName !== hoveredCountry) {
|
|
|
|
|
setHoveredCountry(result.countryName);
|
2025-03-28 18:54:07 +08:00
|
|
|
|
}
|
2025-05-02 23:47:55 +08:00
|
|
|
|
|
|
|
|
|
// 不禁用自动旋转,保持地球旋转
|
2025-03-28 02:27:42 +08:00
|
|
|
|
} else {
|
2025-05-02 23:47:55 +08:00
|
|
|
|
// 如果没有找到国家/地区,清除悬停状态
|
2025-03-28 18:54:07 +08:00
|
|
|
|
if (hoveredCountry) {
|
|
|
|
|
setHoveredCountry(null);
|
|
|
|
|
}
|
2025-03-28 02:27:42 +08:00
|
|
|
|
}
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 保存鼠标事件和位置
|
|
|
|
|
sceneRef.current.lastMouseEvent = event;
|
|
|
|
|
sceneRef.current.lastMouseX = event.clientX;
|
|
|
|
|
sceneRef.current.lastMouseY = event.clientY;
|
|
|
|
|
sceneRef.current.lastHoverTime = Date.now();
|
|
|
|
|
}, 100);
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 清除选择的函数
|
2025-03-28 02:27:42 +08:00
|
|
|
|
const clearSelection = () => {
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 恢复所有线条的原始颜色
|
2025-04-19 18:54:19 +08:00
|
|
|
|
allLineObjects.forEach((line) => {
|
2025-03-28 18:54:07 +08:00
|
|
|
|
if (line.material instanceof THREE.LineBasicMaterial) {
|
|
|
|
|
line.material.color.set(line.userData.originalColor);
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
setHoveredCountry(null);
|
|
|
|
|
if (sceneRef.current) {
|
|
|
|
|
sceneRef.current.lastClickedCountry = null;
|
2025-03-28 18:54:07 +08:00
|
|
|
|
sceneRef.current.lastHoverTime = null;
|
2025-03-28 02:27:42 +08:00
|
|
|
|
}
|
2025-05-02 23:47:55 +08:00
|
|
|
|
// 确保自动旋转始终开启
|
2025-03-28 02:27:42 +08:00
|
|
|
|
controls.autoRotate = true;
|
2025-03-09 01:11:43 +08:00
|
|
|
|
};
|
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 简化的鼠标点击事件处理函数
|
2025-03-28 02:27:42 +08:00
|
|
|
|
const onClick = (event: MouseEvent) => {
|
2025-05-02 23:47:55 +08:00
|
|
|
|
if (!containerRef.current || !sceneRef.current || !geoProcessor) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 获取鼠标在球面上的点
|
2025-05-02 23:47:55 +08:00
|
|
|
|
const result = getPointOnSphere(
|
2025-04-19 18:54:19 +08:00
|
|
|
|
event.clientX,
|
|
|
|
|
event.clientY,
|
|
|
|
|
camera,
|
|
|
|
|
2.01,
|
|
|
|
|
);
|
|
|
|
|
|
2025-05-02 23:47:55 +08:00
|
|
|
|
// 如果找到点和对应的国家/地区
|
|
|
|
|
if (result && result.countryName) {
|
|
|
|
|
// 重置所有线条颜色
|
|
|
|
|
allLineObjects.forEach((line) => {
|
|
|
|
|
if (line.material instanceof THREE.LineBasicMaterial) {
|
|
|
|
|
line.material.color.set(line.userData.originalColor);
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-05-02 23:47:55 +08:00
|
|
|
|
// 高亮显示该国家/地区的线条
|
|
|
|
|
allLineObjects.forEach((line) => {
|
|
|
|
|
if (
|
|
|
|
|
lineToCountryMap.get(line) === result.countryName &&
|
|
|
|
|
line.material instanceof THREE.LineBasicMaterial
|
|
|
|
|
) {
|
|
|
|
|
line.material.color.set(line.userData.highlightColor);
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-05-02 23:47:55 +08:00
|
|
|
|
// 更新选中国家
|
|
|
|
|
setHoveredCountry(result.countryName);
|
|
|
|
|
sceneRef.current.lastClickedCountry = result.countryName;
|
|
|
|
|
// 不禁用自动旋转,保持地球始终旋转
|
2025-03-28 02:27:42 +08:00
|
|
|
|
} else {
|
2025-05-02 23:47:55 +08:00
|
|
|
|
// 如果没有找到国家/地区,清除选择
|
2025-03-28 02:27:42 +08:00
|
|
|
|
clearSelection();
|
|
|
|
|
}
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 更新最后的鼠标位置和点击时间
|
|
|
|
|
sceneRef.current.lastMouseX = event.clientX;
|
|
|
|
|
sceneRef.current.lastMouseY = event.clientY;
|
|
|
|
|
sceneRef.current.lastHoverTime = Date.now();
|
2025-03-28 02:27:42 +08:00
|
|
|
|
};
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 鼠标双击事件处理
|
|
|
|
|
const onDoubleClick = (event: MouseEvent) => {
|
2025-03-28 02:27:42 +08:00
|
|
|
|
clearSelection();
|
2025-03-28 18:54:07 +08:00
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopPropagation();
|
2025-03-28 02:27:42 +08:00
|
|
|
|
};
|
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 添加事件监听器
|
2025-05-02 23:47:55 +08:00
|
|
|
|
containerRef.current.addEventListener("mousemove", onMouseMove, { passive: true });
|
|
|
|
|
containerRef.current.addEventListener("click", onClick, { passive: false });
|
|
|
|
|
containerRef.current.addEventListener("dblclick", onDoubleClick, { passive: false });
|
2025-03-28 02:27:42 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 简化的动画循环函数
|
2025-03-28 02:27:42 +08:00
|
|
|
|
const animate = () => {
|
|
|
|
|
if (!sceneRef.current) return;
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 更新控制器
|
|
|
|
|
sceneRef.current.controls.update();
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 渲染
|
|
|
|
|
sceneRef.current.renderer.render(scene, camera);
|
|
|
|
|
sceneRef.current.labelRenderer.render(scene, camera);
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 请求下一帧
|
|
|
|
|
sceneRef.current.animationId = requestAnimationFrame(animate);
|
|
|
|
|
};
|
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 保存场景引用
|
2025-03-28 02:27:42 +08:00
|
|
|
|
sceneRef.current = {
|
|
|
|
|
scene,
|
|
|
|
|
camera,
|
|
|
|
|
renderer,
|
|
|
|
|
labelRenderer,
|
|
|
|
|
controls,
|
|
|
|
|
earth,
|
|
|
|
|
countries,
|
|
|
|
|
raycaster,
|
|
|
|
|
mouse,
|
|
|
|
|
animationId: null,
|
|
|
|
|
lastCameraPosition: null,
|
|
|
|
|
lastMouseEvent: null,
|
2025-03-28 18:54:07 +08:00
|
|
|
|
lastClickedCountry: null,
|
|
|
|
|
lastMouseX: null,
|
|
|
|
|
lastMouseY: null,
|
|
|
|
|
lastHoverTime: null,
|
2025-05-02 23:47:55 +08:00
|
|
|
|
lineToCountryMap,
|
|
|
|
|
allLineObjects,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 开始动画
|
|
|
|
|
sceneRef.current.animationId = requestAnimationFrame(animate);
|
|
|
|
|
|
|
|
|
|
// 获取球面上的点对应的国家/地区
|
|
|
|
|
const getPointOnSphere = (
|
|
|
|
|
mouseX: number,
|
|
|
|
|
mouseY: number,
|
|
|
|
|
camera: THREE.Camera,
|
|
|
|
|
radius: number,
|
|
|
|
|
): { point: THREE.Vector3, countryName: string | null } | null => {
|
|
|
|
|
// 计算鼠标在画布中的归一化坐标
|
|
|
|
|
const rect = containerRef.current!.getBoundingClientRect();
|
|
|
|
|
const x = ((mouseX - rect.left) / rect.width) * 2 - 1;
|
|
|
|
|
const y = -((mouseY - rect.top) / rect.height) * 2 + 1;
|
|
|
|
|
|
|
|
|
|
// 创建射线
|
|
|
|
|
const ray = new THREE.Raycaster();
|
|
|
|
|
ray.setFromCamera(new THREE.Vector2(x, y), camera);
|
|
|
|
|
|
|
|
|
|
// 检测射线与实际地球模型的相交
|
|
|
|
|
const earthIntersects = ray.intersectObject(earth, false);
|
|
|
|
|
if (earthIntersects.length > 0) {
|
|
|
|
|
const point = earthIntersects[0].point;
|
|
|
|
|
|
|
|
|
|
// 使用WASM查找最近的国家/地区
|
|
|
|
|
const countryName = geoProcessor.find_nearest_country(
|
|
|
|
|
point.x, point.y, point.z, radius
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return { point, countryName };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 如果没有直接相交,使用球体辅助检测
|
|
|
|
|
const sphereGeom = new THREE.SphereGeometry(radius, 32, 32);
|
|
|
|
|
const sphereMesh = new THREE.Mesh(sphereGeom);
|
|
|
|
|
|
|
|
|
|
const intersects = ray.intersectObject(sphereMesh);
|
|
|
|
|
if (intersects.length > 0) {
|
|
|
|
|
const point = intersects[0].point;
|
|
|
|
|
|
|
|
|
|
// 使用WASM查找最近的国家/地区
|
|
|
|
|
const countryName = geoProcessor.find_nearest_country(
|
|
|
|
|
point.x, point.y, point.z, radius
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return { point, countryName };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
2025-03-27 21:40:41 +08:00
|
|
|
|
};
|
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 处理窗口大小变化
|
|
|
|
|
const handleResize = () => {
|
|
|
|
|
if (!containerRef.current || !sceneRef.current) return;
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
const { camera, renderer, labelRenderer } = sceneRef.current;
|
|
|
|
|
const width = containerRef.current.clientWidth;
|
|
|
|
|
const height = containerRef.current.clientHeight;
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
camera.aspect = width / height;
|
|
|
|
|
camera.updateProjectionMatrix();
|
|
|
|
|
renderer.setSize(width, height);
|
|
|
|
|
labelRenderer.setSize(width, height);
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 立即渲染一次
|
2025-03-28 02:27:42 +08:00
|
|
|
|
renderer.render(sceneRef.current.scene, camera);
|
|
|
|
|
labelRenderer.render(sceneRef.current.scene, camera);
|
|
|
|
|
};
|
|
|
|
|
|
2025-05-02 23:47:55 +08:00
|
|
|
|
window.addEventListener("resize", handleResize, { passive: true });
|
2025-03-28 02:27:42 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 清理函数
|
2025-03-09 01:11:43 +08:00
|
|
|
|
return () => {
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 清理资源和事件监听器
|
|
|
|
|
if (sceneRef.current) {
|
|
|
|
|
// 取消动画帧
|
|
|
|
|
if (sceneRef.current.animationId !== null) {
|
|
|
|
|
cancelAnimationFrame(sceneRef.current.animationId);
|
|
|
|
|
}
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 处理渲染器的处理
|
|
|
|
|
sceneRef.current.renderer.dispose();
|
|
|
|
|
sceneRef.current.renderer.forceContextLoss();
|
|
|
|
|
sceneRef.current.renderer.domElement.remove();
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 移除标签渲染器
|
|
|
|
|
if (sceneRef.current.labelRenderer) {
|
|
|
|
|
sceneRef.current.labelRenderer.domElement.remove();
|
|
|
|
|
}
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 释放控制器
|
|
|
|
|
if (sceneRef.current.controls) {
|
|
|
|
|
sceneRef.current.controls.dispose();
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 移除事件监听器
|
|
|
|
|
if (containerRef.current) {
|
2025-04-19 18:54:19 +08:00
|
|
|
|
containerRef.current.removeEventListener("mousemove", onMouseMove);
|
|
|
|
|
containerRef.current.removeEventListener("click", onClick);
|
|
|
|
|
containerRef.current.removeEventListener("dblclick", onDoubleClick);
|
2025-03-27 21:40:41 +08:00
|
|
|
|
}
|
2025-04-19 18:54:19 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 移除窗口事件监听器
|
2025-04-19 18:54:19 +08:00
|
|
|
|
window.removeEventListener("resize", handleResize);
|
2025-03-09 01:11:43 +08:00
|
|
|
|
};
|
2025-05-02 23:47:55 +08:00
|
|
|
|
}, [visitedPlaces, theme, wasmReady, geoProcessor]); // 添加geoProcessor依赖
|
2025-03-09 01:11:43 +08:00
|
|
|
|
|
2025-03-27 21:40:41 +08:00
|
|
|
|
return (
|
2025-03-28 02:27:42 +08:00
|
|
|
|
<div className="relative">
|
2025-04-19 18:54:19 +08:00
|
|
|
|
<div
|
|
|
|
|
ref={containerRef}
|
2025-03-28 02:27:42 +08:00
|
|
|
|
className="w-full h-[400px] sm:h-[450px] md:h-[500px] lg:h-[600px] xl:h-[700px]"
|
|
|
|
|
/>
|
2025-05-02 23:47:55 +08:00
|
|
|
|
|
|
|
|
|
{(wasmError || mapError) && (
|
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-800/80 z-20">
|
|
|
|
|
<div className="bg-red-50 dark:bg-red-900 p-4 rounded-lg shadow-lg max-w-md">
|
|
|
|
|
<h3 className="text-red-700 dark:text-red-300 font-bold text-lg mb-2">地图加载错误</h3>
|
|
|
|
|
<p className="text-red-600 dark:text-red-400 text-sm">{wasmError || mapError}</p>
|
|
|
|
|
<button
|
|
|
|
|
className="mt-3 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
|
|
|
|
onClick={() => window.location.reload()}
|
|
|
|
|
>
|
|
|
|
|
重新加载
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{mapLoading && !mapError && (
|
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-800/80 z-20">
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<div className="inline-block w-12 h-12 border-4 border-gray-300 dark:border-gray-600 border-t-blue-500 dark:border-t-blue-400 rounded-full animate-spin"></div>
|
|
|
|
|
<p className="mt-3 text-gray-700 dark:text-gray-300">加载地图数据中...</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
{hoveredCountry && (
|
2025-03-28 18:54:07 +08:00
|
|
|
|
<div className="absolute bottom-5 left-0 right-0 text-center z-10">
|
2025-04-19 16:19:39 +08:00
|
|
|
|
<div className="inline-block bg-white/95 dark:bg-gray-800/95 px-6 py-3 rounded-xl shadow-lg backdrop-blur-sm border border-gray-200 dark:border-gray-700 hover:scale-105">
|
2025-03-28 18:54:07 +08:00
|
|
|
|
<p className="text-gray-800 dark:text-white font-medium text-lg flex items-center justify-center gap-2">
|
2025-04-19 18:54:19 +08:00
|
|
|
|
{hoveredCountry}
|
2025-03-28 18:54:07 +08:00
|
|
|
|
{hoveredCountry && visitedPlaces.includes(hoveredCountry) ? (
|
|
|
|
|
<span className="inline-flex items-center justify-center bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-400 px-2.5 py-1 rounded-full text-sm ml-1.5 whitespace-nowrap">
|
2025-04-19 18:54:19 +08:00
|
|
|
|
<svg
|
|
|
|
|
className="w-4 h-4 mr-1"
|
|
|
|
|
viewBox="0 0 20 20"
|
|
|
|
|
fill="currentColor"
|
|
|
|
|
>
|
|
|
|
|
<path
|
|
|
|
|
fillRule="evenodd"
|
|
|
|
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
|
|
|
clipRule="evenodd"
|
|
|
|
|
/>
|
2025-03-28 18:54:07 +08:00
|
|
|
|
</svg>
|
|
|
|
|
已去过
|
|
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="inline-flex items-center justify-center bg-gray-100 dark:bg-gray-700/60 text-gray-600 dark:text-gray-400 px-2.5 py-1 rounded-full text-sm ml-1.5 whitespace-nowrap">
|
|
|
|
|
尚未去过
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2025-03-28 02:27:42 +08:00
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-03-27 21:40:41 +08:00
|
|
|
|
);
|
2025-03-09 01:11:43 +08:00
|
|
|
|
};
|
|
|
|
|
|
2025-04-19 18:54:19 +08:00
|
|
|
|
export default WorldHeatmap;
|