newechoes/src/components/WorldHeatmap.tsx

958 lines
30 KiB
TypeScript
Raw Normal View History

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";
import worldData from "@/assets/world.zh.json";
import chinaData from "@/assets/china.json";
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);
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;
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);
// 监听主题变化
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();
};
}, []);
useEffect(() => {
2025-03-28 02:27:42 +08:00
if (!containerRef.current) 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.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
// 检查当前是否为暗色模式
const isDarkMode =
document.documentElement.classList.contains("dark") ||
document.documentElement.getAttribute("data-theme") === "dark";
2025-03-28 02:27:42 +08:00
// 根据当前模式设置颜色
const getColors = () => {
return {
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
};
};
2025-03-28 02:27:42 +08:00
const colors = getColors();
// 创建场景
const scene = new THREE.Scene();
scene.background = null;
// 添加一个动态计算小区域的机制
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,
transparent: true,
opacity: opacity,
2025-03-28 02:27:42 +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,
2025-03-28 02:27:42 +08:00
);
camera.position.z = 8;
// 创建渲染器
const renderer = new THREE.WebGLRenderer({
2025-03-28 02:27:42 +08:00
antialias: true,
alpha: true,
logarithmicDepthBuffer: true,
2025-03-28 02:27:42 +08:00
preserveDrawingBuffer: true,
precision: "highp",
2025-03-27 21:40:41 +08:00
});
renderer.sortObjects = true;
renderer.setClearColor(0x000000, 0);
2025-03-28 02:27:42 +08:00
renderer.setPixelRatio(window.devicePixelRatio);
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();
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;
controls.dampingFactor = 0.25; // 大幅增加阻尼因子从0.1到0.25提高稳定性
controls.rotateSpeed = 0.2; // 降低旋转速度,提高稳定性
2025-03-28 02:27:42 +08:00
controls.autoRotate = true;
controls.autoRotateSpeed = 0.3; // 降低自动旋转速度
2025-03-28 02:27:42 +08:00
controls.minDistance = 5;
controls.maxDistance = 15;
2025-03-28 02:27:42 +08:00
controls.minPolarAngle = Math.PI * 0.1;
controls.maxPolarAngle = Math.PI * 0.9;
controls.addEventListener("change", () => {
2025-03-28 02:27:42 +08:00
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);
// 保存所有线条对象的引用,用于快速检测
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 => {
2025-03-28 02:27:42 +08:00
// 调整经度范围,确保它在[-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;
2025-03-28 02:27:42 +08:00
const x = -radius * Math.sin(phi) * Math.cos(theta);
const y = radius * Math.cos(phi);
const z = radius * Math.sin(phi) * Math.sin(theta);
2025-03-28 02:27:42 +08:00
return new THREE.Vector3(x, y, z);
};
2025-03-28 02:27:42 +08:00
// 省份边界和中心点数据结构
const provinceCenters = new Map<string, THREE.Vector3>();
// 创建一个通用函数,用于处理地理特性(国家或省份)
const processGeoFeature = (
feature: any,
parent: THREE.Group,
2025-03-28 02:27:42 +08:00
options: {
regionType: "country" | "province";
parentName?: string;
scale?: number;
borderColor?: string;
visitedBorderColor?: string;
},
2025-03-28 02:27:42 +08:00
) => {
const {
regionType,
parentName,
scale = 2.01,
borderColor,
visitedBorderColor,
} = options;
const regionName =
regionType === "province" && parentName
? `${parentName}-${feature.properties.name}`
: feature.properties.name;
2025-03-28 02:27:42 +08:00
const isRegionVisited = visitedPlaces.includes(regionName);
2025-03-28 02:27:42 +08:00
// 为每个地区创建一个组
const regionObject = new THREE.Group();
regionObject.userData = { name: regionName, isVisited: isRegionVisited };
2025-03-28 02:27:42 +08:00
// 计算地区中心点
let centerLon = 0;
let centerLat = 0;
let pointCount = 0;
// 创建边界盒用于碰撞检测
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
) {
2025-03-28 02:27:42 +08:00
const [cpLon, cpLat] = feature.properties.cp;
hasPreDefinedCenter = true;
centerVector = latLongToVector3(cpLat, cpLon, scale + 0.005);
centerLon = cpLon;
centerLat = cpLat;
2025-03-28 02:27:42 +08:00
// 保存预定义中心点
if (regionType === "province") {
provinceCenters.set(regionName, centerVector);
}
2025-03-28 02:27:42 +08:00
}
2025-03-28 02:27:42 +08:00
// 存储区域边界
const boundaries: THREE.Vector3[][] = [];
2025-03-28 02:27:42 +08:00
// 处理多边形坐标
const processPolygon = (polygonCoords: any) => {
const points: THREE.Vector3[] = [];
2025-03-28 02:27:42 +08:00
// 收集多边形的点
polygonCoords.forEach((point: number[]) => {
const lon = point[0];
const lat = point[1];
centerLon += lon;
centerLat += lat;
pointCount++;
2025-03-28 02:27:42 +08:00
// 使用辅助函数将经纬度转换为3D坐标
const vertex = latLongToVector3(lat, lon, scale);
points.push(vertex);
// 扩展边界盒以包含此点
boundingBox.expandByPoint(vertex);
2025-03-28 02:27:42 +08:00
});
2025-03-28 02:27:42 +08:00
// 保存边界多边形
if (points.length > 2) {
boundaries.push(points);
}
// 收集区域大小指标
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,
2025-03-28 02:27:42 +08:00
linewidth: isRegionVisited ? 1.5 : 1,
transparent: true,
opacity: isRegionVisited ? 0.9 : 0.7,
2025-03-28 02:27:42 +08:00
});
2025-03-28 02:27:42 +08:00
const line = new THREE.Line(lineGeometry, lineMaterial);
line.userData = {
name: regionName,
2025-03-28 02:27:42 +08:00
isVisited: isRegionVisited,
originalColor: isRegionVisited
? visitedBorderColor || colors.visitedBorder
: borderColor || colors.border,
highlightColor: colors.highlight, // 使用主题颜色中定义的高亮颜色
};
// 设置渲染顺序
2025-03-28 02:27:42 +08:00
line.renderOrder = isRegionVisited ? 3 : 2;
regionObject.add(line);
// 保存线条对象引用和对应的国家/地区名称
allLineObjects.push(line);
lineToCountryMap.set(line, regionName);
}
2025-03-28 02:27:42 +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") {
2025-03-28 02:27:42 +08:00
feature.geometry.coordinates.forEach((ring: any) => {
processPolygon(ring);
});
} else if (feature.geometry.type === "MultiPolygon") {
2025-03-28 02:27:42 +08:00
feature.geometry.coordinates.forEach((polygon: any) => {
polygon.forEach((ring: any) => {
processPolygon(ring);
});
});
}
2025-03-28 02:27:42 +08:00
if (pointCount > 0 && !hasPreDefinedCenter) {
// 计算平均中心点
centerLon /= pointCount;
centerLat /= pointCount;
2025-03-28 02:27:42 +08:00
// 将中心点经纬度转换为3D坐标
centerVector = latLongToVector3(centerLat, centerLon, scale + 0.005);
2025-03-28 02:27:42 +08:00
// 保存计算的中心点
if (regionType === "province") {
2025-03-28 02:27:42 +08:00
provinceCenters.set(regionName, centerVector);
}
2025-03-28 02:27:42 +08:00
}
2025-03-28 02:27:42 +08:00
if (pointCount > 0) {
// 保存地区的边界盒
countryBoundingBoxes.set(regionName, boundingBox);
2025-03-28 02:27:42 +08:00
// 添加地区对象到父组
parent.add(regionObject);
countries.set(regionName, regionObject);
}
}
2025-03-28 02:27:42 +08:00
return regionObject;
};
2025-03-28 02:27:42 +08:00
// 处理世界GeoJSON数据
worldData.features.forEach((feature: any) => {
const countryName = feature.properties.name;
2025-03-28 02:27:42 +08:00
// 跳过中国,因为我们将使用更详细的中国地图数据
if (countryName === "中国") return;
2025-03-28 02:27:42 +08:00
processGeoFeature(feature, countryGroup, {
regionType: "country",
scale: 2.01,
2025-03-28 02:27:42 +08:00
});
});
2025-03-28 02:27:42 +08:00
// 处理中国的省份
const chinaObject = new THREE.Group();
chinaObject.userData = {
name: "中国",
isVisited: visitedPlaces.includes("中国"),
};
2025-03-28 02:27:42 +08:00
chinaData.features.forEach((feature: any) => {
processGeoFeature(feature, chinaObject, {
regionType: "province",
parentName: "中国",
2025-03-28 02:27:42 +08:00
scale: 2.015,
borderColor: colors.chinaBorder,
visitedBorderColor: colors.visitedBorder,
2025-03-28 02:27:42 +08:00
});
});
2025-03-28 02:27:42 +08:00
// 添加中国对象到国家组
countryGroup.add(chinaObject);
countries.set("中国", chinaObject);
// 将视图旋转到中国位置
const positionCameraToFaceChina = () => {
// 检查是否为小屏幕
const isSmallScreen =
containerRef.current && containerRef.current.clientWidth < 640;
// 根据屏幕大小设置不同的相机初始位置
let fixedPosition;
if (isSmallScreen) {
// 小屏幕显示距离更远,以便看到更多地球
fixedPosition = new THREE.Vector3(-2.1, 3.41, -8.0);
} else {
// 大屏幕使用原来的位置
fixedPosition = new THREE.Vector3(-2.1, 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;
let lastFunc: number | null = null;
let lastRan: number | null = null;
return function (this: any, ...args: any[]) {
2025-03-28 02:27:42 +08:00
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
lastRan = Date.now();
setTimeout(() => (inThrottle = false), limit);
} 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
// 根据球面上的点找到最近的国家或地区
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;
}
}
}
// 小区域优化逻辑
if (smallRegionCountry && smallRegionDistance < minDistance * 2) {
return smallRegionCountry;
}
// 处理中国的特殊情况 - 如果点击非常接近省份边界
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;
}
}
return closestCountry;
2025-03-28 02:27:42 +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
}
// 如果没有直接相交,使用球体辅助检测
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
}
2025-03-28 02:27:42 +08:00
return null;
};
// 简化的鼠标移动事件处理函数
2025-03-28 02:27:42 +08:00
const onMouseMove = throttle((event: MouseEvent) => {
if (!containerRef.current || !sceneRef.current) return;
// 获取鼠标在球面上的点
const spherePoint = getPointOnSphere(
event.clientX,
event.clientY,
camera,
2.01,
);
// 重置所有线条颜色
allLineObjects.forEach((line) => {
if (line.material instanceof THREE.LineBasicMaterial) {
line.material.color.set(line.userData.originalColor);
}
});
// 如果找到点,寻找最近的国家/地区
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 {
// 如果没有找到球面点,清除悬停状态
if (hoveredCountry) {
setHoveredCountry(null);
controls.autoRotate = true;
}
2025-03-28 02:27:42 +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 = () => {
// 恢复所有线条的原始颜色
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;
sceneRef.current.lastHoverTime = null;
2025-03-28 02:27:42 +08:00
}
controls.autoRotate = true;
};
// 简化的鼠标点击事件处理函数
2025-03-28 02:27:42 +08:00
const onClick = (event: MouseEvent) => {
if (!containerRef.current || !sceneRef.current) return;
// 获取鼠标在球面上的点
const spherePoint = getPointOnSphere(
event.clientX,
event.clientY,
camera,
2.01,
);
// 如果找到点,寻找最近的国家/地区
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 02:27:42 +08:00
clearSelection();
}
// 更新最后的鼠标位置和点击时间
sceneRef.current.lastMouseX = event.clientX;
sceneRef.current.lastMouseY = event.clientY;
sceneRef.current.lastHoverTime = Date.now();
2025-03-28 02:27:42 +08:00
};
// 鼠标双击事件处理
const onDoubleClick = (event: MouseEvent) => {
2025-03-28 02:27:42 +08:00
clearSelection();
event.preventDefault();
event.stopPropagation();
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 02:27:42 +08:00
// 简化的动画循环函数
2025-03-28 02:27:42 +08:00
const animate = () => {
if (!sceneRef.current) return;
2025-03-28 02:27:42 +08:00
// 更新控制器
sceneRef.current.controls.update();
2025-03-28 02:27:42 +08:00
// 渲染
sceneRef.current.renderer.render(scene, camera);
sceneRef.current.labelRenderer.render(scene, camera);
2025-03-28 02:27:42 +08:00
// 请求下一帧
sceneRef.current.animationId = requestAnimationFrame(animate);
};
// 保存场景引用
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,
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;
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-03-28 02:27:42 +08:00
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
labelRenderer.setSize(width, height);
// 立即渲染一次
2025-03-28 02:27:42 +08:00
renderer.render(sceneRef.current.scene, camera);
labelRenderer.render(sceneRef.current.scene, camera);
};
window.addEventListener("resize", handleResize);
// 开始动画
sceneRef.current.animationId = requestAnimationFrame(animate);
2025-03-28 02:27:42 +08:00
// 清理函数
return () => {
2025-03-28 02:27:42 +08:00
// 清理资源和事件监听器
if (sceneRef.current) {
// 取消动画帧
if (sceneRef.current.animationId !== null) {
cancelAnimationFrame(sceneRef.current.animationId);
}
2025-03-28 02:27:42 +08:00
// 处理渲染器的处理
sceneRef.current.renderer.dispose();
sceneRef.current.renderer.forceContextLoss();
sceneRef.current.renderer.domElement.remove();
2025-03-28 02:27:42 +08:00
// 移除标签渲染器
if (sceneRef.current.labelRenderer) {
sceneRef.current.labelRenderer.domElement.remove();
}
2025-03-28 02:27:42 +08:00
// 释放控制器
if (sceneRef.current.controls) {
sceneRef.current.controls.dispose();
}
}
2025-03-28 02:27:42 +08:00
// 移除事件监听器
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
// 移除窗口事件监听器
window.removeEventListener("resize", handleResize);
};
}, [visitedPlaces, theme]); // 依赖于visitedPlaces和theme变化
2025-03-27 21:40:41 +08:00
return (
2025-03-28 02:27:42 +08:00
<div className="relative">
<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]"
/>
{hoveredCountry && (
<div className="absolute bottom-5 left-0 right-0 text-center z-10">
<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">
<p className="text-gray-800 dark:text-white font-medium text-lg flex items-center justify-center gap-2">
{hoveredCountry}
{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
);
};
export default WorldHeatmap;