2025-03-28 02:27:42 +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-03-09 01:11:43 +08:00
|
|
|
|
import worldData from '@/assets/world.zh.json';
|
|
|
|
|
import chinaData from '@/assets/china.json';
|
|
|
|
|
|
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-03-28 18:54:07 +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 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;
|
|
|
|
|
regionImportance?: Map<string, number>;
|
|
|
|
|
importanceThreshold?: number;
|
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 = () => {
|
|
|
|
|
const isDark =
|
|
|
|
|
document.documentElement.classList.contains('dark') ||
|
|
|
|
|
document.documentElement.getAttribute('data-theme') === 'dark';
|
|
|
|
|
setTheme(isDark ? 'dark' : 'light');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 创建 MutationObserver 来监听 class 和 data-theme 属性的变化
|
|
|
|
|
const observer = new MutationObserver((mutations) => {
|
|
|
|
|
mutations.forEach((mutation) => {
|
|
|
|
|
if (
|
|
|
|
|
(mutation.attributeName === 'class' && mutation.target === document.documentElement) ||
|
|
|
|
|
(mutation.attributeName === 'data-theme' && mutation.target === document.documentElement)
|
|
|
|
|
) {
|
|
|
|
|
handleThemeChange();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 开始观察
|
|
|
|
|
observer.observe(document.documentElement, {
|
|
|
|
|
attributes: true,
|
|
|
|
|
attributeFilter: ['class', 'data-theme']
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 初始检查
|
|
|
|
|
handleThemeChange();
|
|
|
|
|
|
|
|
|
|
// 清理
|
|
|
|
|
return () => {
|
|
|
|
|
observer.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-03-09 01:11:43 +08:00
|
|
|
|
useEffect(() => {
|
2025-03-28 02:27:42 +08:00
|
|
|
|
if (!containerRef.current) 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();
|
|
|
|
|
containerRef.current.innerHTML = '';
|
2025-03-27 21:40:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 检查当前是否为暗色模式
|
2025-03-28 18:54:07 +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-03-28 18:54:07 +08:00
|
|
|
|
earthBase: isDarkMode ? '#111827' : '#f3f4f6', // 深色模式更暗,浅色模式更亮
|
|
|
|
|
visited: isDarkMode ? '#065f46' : '#34d399', // 访问过的颜色更鲜明
|
|
|
|
|
border: isDarkMode ? '#6b7280' : '#d1d5db', // 边界颜色更柔和
|
|
|
|
|
visitedBorder: isDarkMode ? '#10b981' : '#059669', // 访问过的边界颜色更鲜明
|
|
|
|
|
chinaBorder: isDarkMode ? '#f87171' : '#ef4444', // 中国边界使用红色
|
|
|
|
|
text: isDarkMode ? '#f9fafb' : '#1f2937', // 文本颜色对比更强
|
|
|
|
|
highlight: isDarkMode ? '#fbbf24' : '#d97706', // 高亮颜色更适合当前主题
|
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
|
|
|
|
// 添加一个动态计算小区域的机制
|
|
|
|
|
const regionSizeMetrics = new Map<string, {
|
|
|
|
|
boundingBoxSize?: number,
|
|
|
|
|
pointCount?: number,
|
|
|
|
|
importance?: number,
|
|
|
|
|
isSmallRegion?: boolean,
|
|
|
|
|
polygonArea?: number
|
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
// 创建材质的辅助函数
|
|
|
|
|
const createMaterial = (color: string, side: THREE.Side = THREE.FrontSide, opacity: number = 1.0) => {
|
|
|
|
|
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,
|
|
|
|
|
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);
|
|
|
|
|
const earthMaterial = createMaterial(colors.earthBase, THREE.FrontSide, isDarkMode ? 0.9 : 0.8);
|
|
|
|
|
const earth = new THREE.Mesh(earthGeometry, earthMaterial);
|
|
|
|
|
earth.renderOrder = 1;
|
|
|
|
|
scene.add(earth);
|
|
|
|
|
|
|
|
|
|
// 添加光源
|
|
|
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, isDarkMode ? 0.7 : 0.8);
|
|
|
|
|
scene.add(ambientLight);
|
|
|
|
|
|
|
|
|
|
const directionalLight = new THREE.DirectionalLight(isDarkMode ? 0xeeeeff : 0xffffff, isDarkMode ? 0.6 : 0.5);
|
|
|
|
|
directionalLight.position.set(5, 3, 5);
|
|
|
|
|
scene.add(directionalLight);
|
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 创建相机
|
|
|
|
|
const camera = new THREE.PerspectiveCamera(
|
|
|
|
|
45,
|
|
|
|
|
containerRef.current.clientWidth / containerRef.current.clientHeight,
|
|
|
|
|
0.1,
|
|
|
|
|
1000
|
|
|
|
|
);
|
|
|
|
|
camera.position.z = 8;
|
|
|
|
|
|
|
|
|
|
// 创建渲染器
|
|
|
|
|
const renderer = new THREE.WebGLRenderer({
|
|
|
|
|
antialias: true,
|
2025-03-28 18:54:07 +08:00
|
|
|
|
alpha: true,
|
2025-03-28 02:27:42 +08:00
|
|
|
|
logarithmicDepthBuffer: true,
|
|
|
|
|
preserveDrawingBuffer: true,
|
|
|
|
|
precision: "highp"
|
2025-03-27 21:40:41 +08:00
|
|
|
|
});
|
2025-03-28 02:27:42 +08:00
|
|
|
|
renderer.sortObjects = true;
|
|
|
|
|
renderer.setClearColor(0x000000, 0);
|
|
|
|
|
renderer.setPixelRatio(window.devicePixelRatio);
|
|
|
|
|
renderer.setSize(containerRef.current.clientWidth, containerRef.current.clientHeight);
|
|
|
|
|
containerRef.current.appendChild(renderer.domElement);
|
|
|
|
|
|
|
|
|
|
// 创建CSS2D渲染器用于标签
|
|
|
|
|
const labelRenderer = new CSS2DRenderer();
|
|
|
|
|
labelRenderer.setSize(containerRef.current.clientWidth, containerRef.current.clientHeight);
|
|
|
|
|
labelRenderer.domElement.style.position = 'absolute';
|
|
|
|
|
labelRenderer.domElement.style.top = '0';
|
|
|
|
|
labelRenderer.domElement.style.pointerEvents = 'none';
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
controls.minPolarAngle = Math.PI * 0.1;
|
|
|
|
|
controls.maxPolarAngle = Math.PI * 0.9;
|
|
|
|
|
|
|
|
|
|
controls.addEventListener('change', () => {
|
|
|
|
|
if (sceneRef.current) {
|
|
|
|
|
renderer.render(scene, camera);
|
|
|
|
|
labelRenderer.render(scene, camera);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 创建国家边界
|
|
|
|
|
const countries = new Map<string, THREE.Object3D>();
|
|
|
|
|
const countryGroup = new THREE.Group();
|
|
|
|
|
earth.add(countryGroup);
|
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 保存所有线条对象的引用,用于快速检测
|
|
|
|
|
const allLineObjects: THREE.Line[] = [];
|
|
|
|
|
const lineToCountryMap = new Map<THREE.Line, string>();
|
|
|
|
|
|
|
|
|
|
// 保存所有国家和省份的边界盒,用于优化检测
|
|
|
|
|
const countryBoundingBoxes = new Map<string, THREE.Box3>();
|
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 创建一个辅助函数,用于将经纬度转换为三维坐标
|
|
|
|
|
const latLongToVector3 = (lat: number, lon: number, radius: number): THREE.Vector3 => {
|
|
|
|
|
// 调整经度范围,确保它在[-180, 180]之间
|
|
|
|
|
while (lon > 180) lon -= 360;
|
|
|
|
|
while (lon < -180) lon += 360;
|
|
|
|
|
|
|
|
|
|
const phi = (90 - lat) * Math.PI / 180;
|
|
|
|
|
const theta = (lon + 180) * Math.PI / 180;
|
|
|
|
|
|
|
|
|
|
const x = -radius * Math.sin(phi) * Math.cos(theta);
|
|
|
|
|
const y = radius * Math.cos(phi);
|
|
|
|
|
const z = radius * Math.sin(phi) * Math.sin(theta);
|
|
|
|
|
|
|
|
|
|
return new THREE.Vector3(x, y, z);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 省份边界和中心点数据结构
|
|
|
|
|
const provinceCenters = new Map<string, THREE.Vector3>();
|
|
|
|
|
|
|
|
|
|
// 创建一个通用函数,用于处理地理特性(国家或省份)
|
|
|
|
|
const processGeoFeature = (
|
|
|
|
|
feature: any,
|
|
|
|
|
parent: THREE.Group,
|
|
|
|
|
options: {
|
|
|
|
|
regionType: 'country' | 'province',
|
|
|
|
|
parentName?: string,
|
|
|
|
|
scale?: number,
|
|
|
|
|
borderColor?: string,
|
|
|
|
|
visitedBorderColor?: string
|
|
|
|
|
}
|
|
|
|
|
) => {
|
|
|
|
|
const { regionType, parentName, scale = 2.01, borderColor, visitedBorderColor } = options;
|
|
|
|
|
|
|
|
|
|
const regionName = regionType === 'province' && parentName
|
|
|
|
|
? `${parentName}-${feature.properties.name}`
|
|
|
|
|
: feature.properties.name;
|
|
|
|
|
|
|
|
|
|
const isRegionVisited = visitedPlaces.includes(regionName);
|
|
|
|
|
|
|
|
|
|
// 为每个地区创建一个组
|
|
|
|
|
const regionObject = new THREE.Group();
|
|
|
|
|
regionObject.userData = { name: regionName, isVisited: isRegionVisited };
|
|
|
|
|
|
|
|
|
|
// 计算地区中心点
|
|
|
|
|
let centerLon = 0;
|
|
|
|
|
let centerLat = 0;
|
|
|
|
|
let pointCount = 0;
|
2025-03-28 18:54:07 +08:00
|
|
|
|
|
|
|
|
|
// 创建边界盒用于碰撞检测
|
|
|
|
|
const boundingBox = new THREE.Box3();
|
2025-03-28 02:27:42 +08:00
|
|
|
|
|
|
|
|
|
// 首先检查GeoJSON特性中是否有预定义的中心点
|
|
|
|
|
let hasPreDefinedCenter = false;
|
|
|
|
|
let centerVector;
|
|
|
|
|
|
|
|
|
|
if (feature.properties.cp && Array.isArray(feature.properties.cp) && feature.properties.cp.length === 2) {
|
|
|
|
|
const [cpLon, cpLat] = feature.properties.cp;
|
|
|
|
|
hasPreDefinedCenter = true;
|
|
|
|
|
centerVector = latLongToVector3(cpLat, cpLon, scale + 0.005);
|
|
|
|
|
centerLon = cpLon;
|
|
|
|
|
centerLat = cpLat;
|
|
|
|
|
|
|
|
|
|
// 保存预定义中心点
|
2025-03-28 18:54:07 +08:00
|
|
|
|
if (regionType === 'province') {
|
|
|
|
|
provinceCenters.set(regionName, centerVector);
|
|
|
|
|
}
|
2025-03-28 02:27:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 存储区域边界
|
|
|
|
|
const boundaries: THREE.Vector3[][] = [];
|
|
|
|
|
|
|
|
|
|
// 处理多边形坐标
|
|
|
|
|
const processPolygon = (polygonCoords: any) => {
|
|
|
|
|
const points: THREE.Vector3[] = [];
|
|
|
|
|
|
|
|
|
|
// 收集多边形的点
|
|
|
|
|
polygonCoords.forEach((point: number[]) => {
|
|
|
|
|
const lon = point[0];
|
|
|
|
|
const lat = point[1];
|
|
|
|
|
centerLon += lon;
|
|
|
|
|
centerLat += lat;
|
|
|
|
|
pointCount++;
|
|
|
|
|
|
|
|
|
|
// 使用辅助函数将经纬度转换为3D坐标
|
2025-03-28 18:54:07 +08:00
|
|
|
|
const vertex = latLongToVector3(lat, lon, scale);
|
|
|
|
|
points.push(vertex);
|
|
|
|
|
|
|
|
|
|
// 扩展边界盒以包含此点
|
|
|
|
|
boundingBox.expandByPoint(vertex);
|
2025-03-28 02:27:42 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 保存边界多边形
|
|
|
|
|
if (points.length > 2) {
|
|
|
|
|
boundaries.push(points);
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 收集区域大小指标
|
|
|
|
|
if (!regionSizeMetrics.has(regionName)) {
|
|
|
|
|
regionSizeMetrics.set(regionName, {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const metrics = regionSizeMetrics.get(regionName)!;
|
|
|
|
|
if (points.length > 2) {
|
|
|
|
|
// 计算边界框大小
|
|
|
|
|
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
|
|
|
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
|
|
|
|
|
|
|
|
|
points.forEach(point => {
|
|
|
|
|
minX = Math.min(minX, point.x);
|
|
|
|
|
minY = Math.min(minY, point.y);
|
|
|
|
|
minZ = Math.min(minZ, point.z);
|
|
|
|
|
maxX = Math.max(maxX, point.x);
|
|
|
|
|
maxY = Math.max(maxY, point.y);
|
|
|
|
|
maxZ = Math.max(maxZ, point.z);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const sizeX = maxX - minX;
|
|
|
|
|
const sizeY = maxY - minY;
|
|
|
|
|
const sizeZ = maxZ - minZ;
|
|
|
|
|
const boxSize = Math.sqrt(sizeX * sizeX + sizeY * sizeY + sizeZ * sizeZ);
|
|
|
|
|
|
|
|
|
|
// 更新或初始化指标
|
|
|
|
|
metrics.boundingBoxSize = metrics.boundingBoxSize ?
|
|
|
|
|
Math.max(metrics.boundingBoxSize, boxSize) : boxSize;
|
|
|
|
|
metrics.pointCount = (metrics.pointCount || 0) + points.length;
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 创建边界线
|
|
|
|
|
if (points.length > 1) {
|
|
|
|
|
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
|
|
|
|
|
const lineMaterial = new THREE.LineBasicMaterial({
|
|
|
|
|
color: isRegionVisited
|
|
|
|
|
? (visitedBorderColor || colors.visitedBorder)
|
|
|
|
|
: (borderColor || colors.border),
|
|
|
|
|
linewidth: isRegionVisited ? 1.5 : 1,
|
|
|
|
|
transparent: true,
|
2025-03-28 18:54:07 +08:00
|
|
|
|
opacity: isRegionVisited ? 0.9 : 0.7
|
2025-03-28 02:27:42 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const line = new THREE.Line(lineGeometry, lineMaterial);
|
|
|
|
|
line.userData = {
|
|
|
|
|
name: regionName,
|
|
|
|
|
isVisited: isRegionVisited,
|
|
|
|
|
originalColor: isRegionVisited
|
|
|
|
|
? (visitedBorderColor || colors.visitedBorder)
|
2025-03-28 18:54:07 +08:00
|
|
|
|
: (borderColor || colors.border),
|
|
|
|
|
highlightColor: colors.highlight // 使用主题颜色中定义的高亮颜色
|
2025-03-09 01:11:43 +08:00
|
|
|
|
};
|
2025-03-28 18:54:07 +08:00
|
|
|
|
|
|
|
|
|
// 设置渲染顺序
|
2025-03-28 02:27:42 +08:00
|
|
|
|
line.renderOrder = isRegionVisited ? 3 : 2;
|
|
|
|
|
regionObject.add(line);
|
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 保存线条对象引用和对应的国家/地区名称
|
|
|
|
|
allLineObjects.push(line);
|
|
|
|
|
lineToCountryMap.set(line, regionName);
|
2025-03-09 01:11:43 +08:00
|
|
|
|
}
|
2025-03-28 02:27:42 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 处理不同类型的几何体
|
|
|
|
|
if (feature.geometry && (feature.geometry.type === 'Polygon' || feature.geometry.type === 'MultiPolygon')) {
|
|
|
|
|
if (feature.geometry.type === 'Polygon') {
|
|
|
|
|
feature.geometry.coordinates.forEach((ring: any) => {
|
|
|
|
|
processPolygon(ring);
|
|
|
|
|
});
|
|
|
|
|
} else if (feature.geometry.type === 'MultiPolygon') {
|
|
|
|
|
feature.geometry.coordinates.forEach((polygon: any) => {
|
|
|
|
|
polygon.forEach((ring: any) => {
|
|
|
|
|
processPolygon(ring);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (pointCount > 0 && !hasPreDefinedCenter) {
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 计算平均中心点
|
2025-03-28 02:27:42 +08:00
|
|
|
|
centerLon /= pointCount;
|
|
|
|
|
centerLat /= pointCount;
|
|
|
|
|
|
|
|
|
|
// 将中心点经纬度转换为3D坐标
|
|
|
|
|
centerVector = latLongToVector3(centerLat, centerLon, scale + 0.005);
|
|
|
|
|
|
|
|
|
|
// 保存计算的中心点
|
|
|
|
|
if (regionType === 'province') {
|
|
|
|
|
provinceCenters.set(regionName, centerVector);
|
2025-03-09 01:11:43 +08:00
|
|
|
|
}
|
2025-03-28 02:27:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (pointCount > 0) {
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 保存地区的边界盒
|
|
|
|
|
countryBoundingBoxes.set(regionName, boundingBox);
|
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 添加地区对象到父组
|
|
|
|
|
parent.add(regionObject);
|
|
|
|
|
countries.set(regionName, regionObject);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return regionObject;
|
2025-03-09 01:11:43 +08:00
|
|
|
|
};
|
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 处理世界GeoJSON数据
|
|
|
|
|
worldData.features.forEach((feature: any) => {
|
|
|
|
|
const countryName = feature.properties.name;
|
|
|
|
|
|
|
|
|
|
// 跳过中国,因为我们将使用更详细的中国地图数据
|
|
|
|
|
if (countryName === '中国') return;
|
|
|
|
|
|
|
|
|
|
processGeoFeature(feature, countryGroup, {
|
|
|
|
|
regionType: 'country',
|
|
|
|
|
scale: 2.01
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 处理中国的省份
|
|
|
|
|
const chinaObject = new THREE.Group();
|
|
|
|
|
chinaObject.userData = { name: '中国', isVisited: visitedPlaces.includes('中国') };
|
|
|
|
|
|
|
|
|
|
chinaData.features.forEach((feature: any) => {
|
|
|
|
|
processGeoFeature(feature, chinaObject, {
|
|
|
|
|
regionType: 'province',
|
|
|
|
|
parentName: '中国',
|
|
|
|
|
scale: 2.015,
|
|
|
|
|
borderColor: colors.chinaBorder,
|
|
|
|
|
visitedBorderColor: colors.visitedBorder
|
|
|
|
|
});
|
|
|
|
|
});
|
2025-03-09 01:11:43 +08:00
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 添加中国对象到国家组
|
|
|
|
|
countryGroup.add(chinaObject);
|
|
|
|
|
countries.set('中国', chinaObject);
|
2025-03-27 21:40:41 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 将视图旋转到中国位置
|
|
|
|
|
const positionCameraToFaceChina = () => {
|
|
|
|
|
// 检查是否为小屏幕
|
|
|
|
|
const isSmallScreen = containerRef.current && containerRef.current.clientWidth < 640;
|
|
|
|
|
|
|
|
|
|
// 根据屏幕大小设置不同的相机初始位置
|
|
|
|
|
let fixedPosition;
|
|
|
|
|
if (isSmallScreen) {
|
|
|
|
|
// 小屏幕显示距离更远,以便看到更多地球
|
|
|
|
|
fixedPosition = new THREE.Vector3(-2.10, 3.41, -8.0);
|
|
|
|
|
} else {
|
|
|
|
|
// 大屏幕使用原来的位置
|
|
|
|
|
fixedPosition = new THREE.Vector3(-2.10, 3.41, -6.5);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 应用位置
|
|
|
|
|
camera.position.copy(fixedPosition);
|
|
|
|
|
camera.lookAt(0, 0, 0);
|
|
|
|
|
controls.update();
|
|
|
|
|
|
|
|
|
|
// 禁用自动旋转一段时间
|
|
|
|
|
controls.autoRotate = false;
|
|
|
|
|
|
|
|
|
|
// 6秒后恢复旋转
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (sceneRef.current) {
|
|
|
|
|
sceneRef.current.controls.autoRotate = true;
|
|
|
|
|
}
|
|
|
|
|
}, 6000);
|
|
|
|
|
|
|
|
|
|
// 渲染
|
|
|
|
|
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-03-28 02:27:42 +08:00
|
|
|
|
return function(this: any, ...args: any[]) {
|
|
|
|
|
if (!inThrottle) {
|
|
|
|
|
func.apply(this, args);
|
|
|
|
|
inThrottle = true;
|
2025-03-28 18:54:07 +08:00
|
|
|
|
lastRan = Date.now();
|
2025-03-28 02:27:42 +08:00
|
|
|
|
setTimeout(() => inThrottle = false, limit);
|
2025-03-28 18:54:07 +08:00
|
|
|
|
} else {
|
|
|
|
|
// 取消之前的延迟调用
|
|
|
|
|
if (lastFunc) clearTimeout(lastFunc);
|
|
|
|
|
|
|
|
|
|
// 如果距离上次执行已经接近阈值,确保我们能及时处理下一个事件
|
|
|
|
|
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
|
|
|
|
// 根据球面上的点找到最近的国家或地区
|
|
|
|
|
const findNearestCountry = (point: THREE.Vector3): string | null => {
|
|
|
|
|
let closestCountry = null;
|
|
|
|
|
let minDistance = Infinity;
|
|
|
|
|
let smallRegionDistance = Infinity;
|
|
|
|
|
let smallRegionCountry = null;
|
|
|
|
|
|
|
|
|
|
// 遍历所有国家/地区的边界盒
|
|
|
|
|
for (const [countryName, box] of countryBoundingBoxes.entries()) {
|
|
|
|
|
// 计算点到边界盒的距离
|
|
|
|
|
const distance = box.distanceToPoint(point);
|
|
|
|
|
|
|
|
|
|
// 估算边界盒大小
|
|
|
|
|
const boxSize = box.getSize(new THREE.Vector3()).length();
|
|
|
|
|
|
|
|
|
|
// 如果点在边界盒内或距离非常近,直接选择该区域
|
|
|
|
|
if (distance < 0.001) {
|
|
|
|
|
return countryName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 同时跟踪绝对最近的区域
|
|
|
|
|
if (distance < minDistance) {
|
|
|
|
|
minDistance = distance;
|
|
|
|
|
closestCountry = countryName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 对于小区域,使用加权距离
|
|
|
|
|
// 小区域的阈值(较小的边界盒尺寸)
|
|
|
|
|
const SMALL_REGION_THRESHOLD = 0.5;
|
|
|
|
|
if (boxSize < SMALL_REGION_THRESHOLD) {
|
|
|
|
|
// 针对小区域的加权距离(降低小区域的选中难度)
|
|
|
|
|
const weightedDistance = distance * (0.5 + boxSize / 2);
|
|
|
|
|
if (weightedDistance < smallRegionDistance) {
|
|
|
|
|
smallRegionDistance = weightedDistance;
|
|
|
|
|
smallRegionCountry = countryName;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-03-28 02:27:42 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 小区域优化逻辑
|
|
|
|
|
if (smallRegionCountry && smallRegionDistance < minDistance * 2) {
|
|
|
|
|
return smallRegionCountry;
|
|
|
|
|
}
|
2025-03-28 02:27:42 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 处理中国的特殊情况 - 如果点击非常接近省份边界
|
|
|
|
|
if (closestCountry === "中国") {
|
|
|
|
|
// 查找最近的中国省份
|
|
|
|
|
let closestProvince = null;
|
|
|
|
|
let minProvinceDistance = Infinity;
|
|
|
|
|
|
|
|
|
|
// 查找最近的中国省份
|
|
|
|
|
for (const [countryName, box] of countryBoundingBoxes.entries()) {
|
|
|
|
|
if (countryName.startsWith("中国-")) {
|
|
|
|
|
const distance = box.distanceToPoint(point);
|
|
|
|
|
if (distance < minProvinceDistance) {
|
|
|
|
|
minProvinceDistance = distance;
|
|
|
|
|
closestProvince = countryName;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (closestProvince && minProvinceDistance < minDistance * 1.5) {
|
|
|
|
|
return closestProvince;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-03-28 02:27:42 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
return closestCountry;
|
2025-03-28 02:27:42 +08:00
|
|
|
|
};
|
2025-03-27 21:40:41 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 解决射线检测和球面相交的问题
|
|
|
|
|
const getPointOnSphere = (mouseX: number, mouseY: number, camera: THREE.Camera, radius: number): THREE.Vector3 | 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) {
|
|
|
|
|
return earthIntersects[0].point;
|
2025-03-28 02:27:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 如果没有直接相交,使用球体辅助检测
|
|
|
|
|
const sphereGeom = new THREE.SphereGeometry(radius, 32, 32);
|
|
|
|
|
const sphereMesh = new THREE.Mesh(sphereGeom);
|
|
|
|
|
|
|
|
|
|
const intersects = ray.intersectObject(sphereMesh);
|
|
|
|
|
if (intersects.length > 0) {
|
|
|
|
|
return intersects[0].point;
|
2025-03-28 02:27:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 简化的鼠标移动事件处理函数
|
2025-03-28 02:27:42 +08:00
|
|
|
|
const onMouseMove = throttle((event: MouseEvent) => {
|
|
|
|
|
if (!containerRef.current || !sceneRef.current) return;
|
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 获取鼠标在球面上的点
|
|
|
|
|
const spherePoint = getPointOnSphere(event.clientX, event.clientY, camera, 2.01);
|
2025-03-28 02:27:42 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 重置所有线条颜色
|
|
|
|
|
allLineObjects.forEach(line => {
|
|
|
|
|
if (line.material instanceof THREE.LineBasicMaterial) {
|
|
|
|
|
line.material.color.set(line.userData.originalColor);
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-03-28 02:27:42 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 如果找到点,寻找最近的国家/地区
|
|
|
|
|
if (spherePoint) {
|
|
|
|
|
const countryName = findNearestCountry(spherePoint);
|
|
|
|
|
|
|
|
|
|
if (countryName) {
|
|
|
|
|
// 高亮显示该国家/地区的线条
|
|
|
|
|
allLineObjects.forEach(line => {
|
|
|
|
|
if (lineToCountryMap.get(line) === countryName && line.material instanceof THREE.LineBasicMaterial) {
|
|
|
|
|
line.material.color.set(line.userData.highlightColor);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 更新悬停国家
|
|
|
|
|
if (countryName !== hoveredCountry) {
|
|
|
|
|
setHoveredCountry(countryName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 禁用自动旋转
|
|
|
|
|
controls.autoRotate = false;
|
|
|
|
|
} else {
|
|
|
|
|
// 如果没有找到国家/地区,清除悬停状态
|
|
|
|
|
if (hoveredCountry) {
|
|
|
|
|
setHoveredCountry(null);
|
|
|
|
|
controls.autoRotate = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-03-28 02:27:42 +08:00
|
|
|
|
} else {
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 如果没有找到球面点,清除悬停状态
|
|
|
|
|
if (hoveredCountry) {
|
|
|
|
|
setHoveredCountry(null);
|
|
|
|
|
controls.autoRotate = true;
|
|
|
|
|
}
|
2025-03-28 02:27:42 +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-03-28 02:27:42 +08:00
|
|
|
|
const clearSelection = () => {
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 恢复所有线条的原始颜色
|
|
|
|
|
allLineObjects.forEach(line => {
|
|
|
|
|
if (line.material instanceof THREE.LineBasicMaterial) {
|
|
|
|
|
line.material.color.set(line.userData.originalColor);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
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) => {
|
|
|
|
|
if (!containerRef.current || !sceneRef.current) return;
|
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 获取鼠标在球面上的点
|
|
|
|
|
const spherePoint = getPointOnSphere(event.clientX, event.clientY, camera, 2.01);
|
2025-03-28 02:27:42 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 如果找到点,寻找最近的国家/地区
|
|
|
|
|
if (spherePoint) {
|
|
|
|
|
const countryName = findNearestCountry(spherePoint);
|
|
|
|
|
|
|
|
|
|
if (countryName) {
|
|
|
|
|
// 重置所有线条颜色
|
|
|
|
|
allLineObjects.forEach(line => {
|
|
|
|
|
if (line.material instanceof THREE.LineBasicMaterial) {
|
|
|
|
|
line.material.color.set(line.userData.originalColor);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 高亮显示该国家/地区的线条
|
|
|
|
|
allLineObjects.forEach(line => {
|
|
|
|
|
if (lineToCountryMap.get(line) === countryName && line.material instanceof THREE.LineBasicMaterial) {
|
|
|
|
|
line.material.color.set(line.userData.highlightColor);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 更新选中国家
|
|
|
|
|
setHoveredCountry(countryName);
|
|
|
|
|
sceneRef.current.lastClickedCountry = countryName;
|
|
|
|
|
controls.autoRotate = false;
|
|
|
|
|
} else {
|
|
|
|
|
// 如果没有找到国家/地区,清除选择
|
|
|
|
|
clearSelection();
|
|
|
|
|
}
|
2025-03-28 02:27:42 +08:00
|
|
|
|
} else {
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 如果没有找到球面点,清除选择
|
2025-03-28 02:27:42 +08:00
|
|
|
|
clearSelection();
|
|
|
|
|
}
|
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-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-03-28 02:27:42 +08:00
|
|
|
|
containerRef.current.addEventListener('mousemove', onMouseMove);
|
|
|
|
|
containerRef.current.addEventListener('click', onClick);
|
|
|
|
|
containerRef.current.addEventListener('dblclick', onDoubleClick);
|
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 简化的动画循环函数
|
2025-03-28 02:27:42 +08:00
|
|
|
|
const animate = () => {
|
|
|
|
|
if (!sceneRef.current) return;
|
|
|
|
|
|
|
|
|
|
// 更新控制器
|
|
|
|
|
sceneRef.current.controls.update();
|
|
|
|
|
|
|
|
|
|
// 渲染
|
|
|
|
|
sceneRef.current.renderer.render(scene, camera);
|
|
|
|
|
sceneRef.current.labelRenderer.render(scene, camera);
|
|
|
|
|
|
|
|
|
|
// 请求下一帧
|
|
|
|
|
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,
|
|
|
|
|
regionImportance: undefined,
|
|
|
|
|
importanceThreshold: undefined
|
2025-03-27 21:40:41 +08:00
|
|
|
|
};
|
|
|
|
|
|
2025-03-28 02:27:42 +08:00
|
|
|
|
// 处理窗口大小变化
|
|
|
|
|
const handleResize = () => {
|
|
|
|
|
if (!containerRef.current || !sceneRef.current) return;
|
|
|
|
|
|
|
|
|
|
const { camera, renderer, labelRenderer } = sceneRef.current;
|
|
|
|
|
const width = containerRef.current.clientWidth;
|
|
|
|
|
const height = containerRef.current.clientHeight;
|
|
|
|
|
|
|
|
|
|
camera.aspect = width / height;
|
|
|
|
|
camera.updateProjectionMatrix();
|
|
|
|
|
renderer.setSize(width, height);
|
|
|
|
|
labelRenderer.setSize(width, height);
|
|
|
|
|
|
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-03-27 21:40:41 +08:00
|
|
|
|
window.addEventListener('resize', handleResize);
|
2025-03-09 01:11:43 +08:00
|
|
|
|
|
2025-03-28 18:54:07 +08:00
|
|
|
|
// 开始动画
|
|
|
|
|
sceneRef.current.animationId = requestAnimationFrame(animate);
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 处理渲染器的处理
|
|
|
|
|
sceneRef.current.renderer.dispose();
|
|
|
|
|
sceneRef.current.renderer.forceContextLoss();
|
|
|
|
|
sceneRef.current.renderer.domElement.remove();
|
|
|
|
|
|
|
|
|
|
// 移除标签渲染器
|
|
|
|
|
if (sceneRef.current.labelRenderer) {
|
|
|
|
|
sceneRef.current.labelRenderer.domElement.remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 释放控制器
|
|
|
|
|
if (sceneRef.current.controls) {
|
|
|
|
|
sceneRef.current.controls.dispose();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 移除事件监听器
|
|
|
|
|
if (containerRef.current) {
|
|
|
|
|
containerRef.current.removeEventListener('mousemove', onMouseMove);
|
|
|
|
|
containerRef.current.removeEventListener('click', onClick);
|
|
|
|
|
containerRef.current.removeEventListener('dblclick', onDoubleClick);
|
2025-03-27 21:40:41 +08:00
|
|
|
|
}
|
2025-03-28 02:27:42 +08:00
|
|
|
|
|
|
|
|
|
// 移除窗口事件监听器
|
2025-03-27 21:40:41 +08:00
|
|
|
|
window.removeEventListener('resize', handleResize);
|
2025-03-09 01:11:43 +08:00
|
|
|
|
};
|
2025-03-28 18:54:07 +08:00
|
|
|
|
}, [visitedPlaces, theme]); // 依赖于visitedPlaces和theme变化
|
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">
|
|
|
|
|
<div
|
|
|
|
|
ref={containerRef}
|
|
|
|
|
className="w-full h-[400px] sm:h-[450px] md:h-[500px] lg:h-[600px] xl:h-[700px]"
|
|
|
|
|
/>
|
|
|
|
|
{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-03-28 02:27:42 +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">
|
|
|
|
|
<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" />
|
|
|
|
|
</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
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default WorldHeatmap;
|