) : items.length > 0 ? (
items.map((item, index) => (
-
+
-
@@ -332,28 +362,28 @@ const MediaGrid: React.FC
= ({ type, title, doubanId }) => {
))
) : !isLoading ? (
-
暂无{type === 'movie' ? '电影' : '图书'}数据
+
+ 暂无{type === "movie" ? "电影" : "图书"}数据
+
) : null}
-
+
{error && items.length > 0 && (
)}
-
+
{isLoading && (
)}
-
- {!hasMoreContent && items.length > 0 && !isLoading && (
-
- )}
+
+ {!hasMoreContent && items.length > 0 && !isLoading &&
}
);
};
-export default MediaGrid;
\ No newline at end of file
+export default MediaGrid;
diff --git a/src/components/ThemeToggle.astro b/src/components/ThemeToggle.astro
new file mode 100644
index 0000000..e455d33
--- /dev/null
+++ b/src/components/ThemeToggle.astro
@@ -0,0 +1,139 @@
+---
+interface Props {
+ height?: number;
+ width?: number;
+ fill?: string;
+ className?: string;
+}
+
+const {
+ height = 16,
+ width = 16,
+ fill = "currentColor",
+ className = ""
+} = Astro.props;
+---
+
+
+
+
\ No newline at end of file
diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx
deleted file mode 100644
index e4176d4..0000000
--- a/src/components/ThemeToggle.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import { useEffect, useState, useCallback, useRef } from 'react';
-
-export function ThemeToggle({ height = 16, width = 16, fill = "currentColor", className = "" }) {
- // 使用null作为初始状态,表示尚未确定主题
- const [theme, setTheme] = useState
(null);
- const [mounted, setMounted] = useState(false);
- const [transitioning, setTransitioning] = useState(false);
- const transitionTimeoutRef = useRef(null);
-
- // 获取系统主题
- const getSystemTheme = useCallback(() => {
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
- }, []);
-
- // 在客户端挂载后再确定主题
- useEffect(() => {
- setMounted(true);
-
- // 从 localStorage 或 document.documentElement.dataset.theme 获取主题
- const savedTheme = localStorage.getItem('theme');
- const rootTheme = document.documentElement.dataset.theme;
- const systemTheme = getSystemTheme();
-
- // 优先使用已保存的主题,其次是文档根元素的主题,最后是系统主题
- const initialTheme = savedTheme || rootTheme || systemTheme;
- setTheme(initialTheme);
-
- // 确保文档根元素的主题与状态一致
- document.documentElement.dataset.theme = initialTheme;
-
- // 监听系统主题变化
- const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
- const handleMediaChange = (e: MediaQueryListEvent) => {
- // 只有当主题设置为跟随系统时才更新主题
- if (!localStorage.getItem('theme')) {
- const newTheme = e.matches ? 'dark' : 'light';
- setTheme(newTheme);
- document.documentElement.dataset.theme = newTheme;
- }
- };
-
- mediaQuery.addEventListener('change', handleMediaChange);
-
- return () => {
- mediaQuery.removeEventListener('change', handleMediaChange);
-
- // 清理可能的超时
- if (transitionTimeoutRef.current) {
- clearTimeout(transitionTimeoutRef.current);
- transitionTimeoutRef.current = null;
- }
- };
- }, [getSystemTheme]);
-
- // 当主题改变时更新 DOM 和 localStorage
- useEffect(() => {
- if (!mounted || theme === null) return;
-
- document.documentElement.dataset.theme = theme;
-
- // 检查是否是跟随系统的主题
- const isSystemTheme = theme === getSystemTheme();
-
- if (isSystemTheme) {
- localStorage.removeItem('theme');
- } else {
- localStorage.setItem('theme', theme);
- }
- }, [theme, mounted, getSystemTheme]);
-
- const toggleTheme = useCallback(() => {
- if (transitioning) return; // 避免快速连续点击
-
- setTransitioning(true);
- setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
-
- // 添加300ms的防抖,避免快速切换
- transitionTimeoutRef.current = setTimeout(() => {
- setTransitioning(false);
- }, 300);
- }, [transitioning]);
-
- // 在客户端挂载前,返回一个空的占位符
- if (!mounted || theme === null) {
- return (
-
- 加载主题切换按钮...
-
- );
- }
-
- return (
- {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- toggleTheme();
- }
- }}
- aria-label={`切换到${theme === 'dark' ? '浅色' : '深色'}模式`}
- >
- {theme === 'dark' ? (
-
- ) : (
-
- )}
-
- );
-}
\ No newline at end of file
diff --git a/src/components/WorldHeatmap.tsx b/src/components/WorldHeatmap.tsx
index 6828cd3..317c8ab 100644
--- a/src/components/WorldHeatmap.tsx
+++ b/src/components/WorldHeatmap.tsx
@@ -1,9 +1,9 @@
-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';
+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[];
@@ -12,10 +12,12 @@ interface WorldHeatmapProps {
const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
const containerRef = useRef(null);
const [hoveredCountry, setHoveredCountry] = useState(null);
- const [theme, setTheme] = useState<'light' | 'dark'>(
- typeof document !== 'undefined' &&
- (document.documentElement.classList.contains('dark') || document.documentElement.getAttribute('data-theme') === 'dark')
- ? 'dark' : 'light'
+ const [theme, setTheme] = useState<"light" | "dark">(
+ typeof document !== "undefined" &&
+ (document.documentElement.classList.contains("dark") ||
+ document.documentElement.getAttribute("data-theme") === "dark")
+ ? "dark"
+ : "light",
);
const sceneRef = useRef<{
@@ -42,18 +44,20 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
// 监听主题变化
useEffect(() => {
const handleThemeChange = () => {
- const isDark =
- document.documentElement.classList.contains('dark') ||
- document.documentElement.getAttribute('data-theme') === 'dark';
- setTheme(isDark ? 'dark' : 'light');
+ 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)
+ (mutation.attributeName === "class" &&
+ mutation.target === document.documentElement) ||
+ (mutation.attributeName === "data-theme" &&
+ mutation.target === document.documentElement)
) {
handleThemeChange();
}
@@ -61,9 +65,9 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
});
// 开始观察
- observer.observe(document.documentElement, {
- attributes: true,
- attributeFilter: ['class', 'data-theme']
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["class", "data-theme"],
});
// 初始检查
@@ -86,26 +90,27 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
sceneRef.current.renderer.dispose();
sceneRef.current.labelRenderer.domElement.remove();
sceneRef.current.scene.clear();
- containerRef.current.innerHTML = '';
+ containerRef.current.innerHTML = "";
}
// 检查当前是否为暗色模式
- const isDarkMode = document.documentElement.classList.contains('dark') ||
- document.documentElement.getAttribute('data-theme') === 'dark';
-
+ const isDarkMode =
+ document.documentElement.classList.contains("dark") ||
+ document.documentElement.getAttribute("data-theme") === "dark";
+
// 根据当前模式设置颜色
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', // 高亮颜色更适合当前主题
+ 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", // 高亮颜色更适合当前主题
};
};
-
+
const colors = getColors();
// 创建场景
@@ -113,68 +118,91 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
scene.background = null;
// 添加一个动态计算小区域的机制
- const regionSizeMetrics = new Map();
+ 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) => {
+ const createMaterial = (
+ color: string,
+ side: THREE.Side = THREE.FrontSide,
+ opacity: number = 1.0,
+ ) => {
return new THREE.MeshBasicMaterial({
color: color,
side: side,
transparent: true,
- opacity: opacity
+ opacity: opacity,
});
};
// 创建地球几何体
const earthGeometry = new THREE.SphereGeometry(2.0, 64, 64);
- const earthMaterial = createMaterial(colors.earthBase, THREE.FrontSide, isDarkMode ? 0.9 : 0.8);
+ 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);
+ 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);
+ const directionalLight = new THREE.DirectionalLight(
+ isDarkMode ? 0xeeeeff : 0xffffff,
+ isDarkMode ? 0.6 : 0.5,
+ );
directionalLight.position.set(5, 3, 5);
scene.add(directionalLight);
// 创建相机
const camera = new THREE.PerspectiveCamera(
- 45,
- containerRef.current.clientWidth / containerRef.current.clientHeight,
- 0.1,
- 1000
+ 45,
+ containerRef.current.clientWidth / containerRef.current.clientHeight,
+ 0.1,
+ 1000,
);
camera.position.z = 8;
// 创建渲染器
- const renderer = new THREE.WebGLRenderer({
+ const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
- logarithmicDepthBuffer: true,
+ logarithmicDepthBuffer: true,
preserveDrawingBuffer: true,
- precision: "highp"
+ precision: "highp",
});
- renderer.sortObjects = true;
- renderer.setClearColor(0x000000, 0);
+ renderer.sortObjects = true;
+ renderer.setClearColor(0x000000, 0);
renderer.setPixelRatio(window.devicePixelRatio);
- renderer.setSize(containerRef.current.clientWidth, containerRef.current.clientHeight);
+ 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';
+ 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);
// 添加控制器
@@ -186,11 +214,11 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
controls.autoRotateSpeed = 0.3; // 降低自动旋转速度
controls.minDistance = 5;
controls.maxDistance = 15;
-
+
controls.minPolarAngle = Math.PI * 0.1;
controls.maxPolarAngle = Math.PI * 0.9;
-
- controls.addEventListener('change', () => {
+
+ controls.addEventListener("change", () => {
if (sceneRef.current) {
renderer.render(scene, camera);
labelRenderer.render(scene, camera);
@@ -201,89 +229,104 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
const countries = new Map();
const countryGroup = new THREE.Group();
earth.add(countryGroup);
-
+
// 保存所有线条对象的引用,用于快速检测
const allLineObjects: THREE.Line[] = [];
const lineToCountryMap = new Map();
-
+
// 保存所有国家和省份的边界盒,用于优化检测
const countryBoundingBoxes = new Map();
-
+
// 创建一个辅助函数,用于将经纬度转换为三维坐标
- const latLongToVector3 = (lat: number, lon: number, radius: number): THREE.Vector3 => {
+ 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 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();
// 创建一个通用函数,用于处理地理特性(国家或省份)
const processGeoFeature = (
- feature: any,
- parent: THREE.Group,
+ feature: any,
+ parent: THREE.Group,
options: {
- regionType: 'country' | 'province',
- parentName?: string,
- scale?: number,
- borderColor?: string,
- visitedBorderColor?: string
- }
+ 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 {
+ 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;
-
+
// 创建边界盒用于碰撞检测
const boundingBox = new THREE.Box3();
-
+
// 首先检查GeoJSON特性中是否有预定义的中心点
let hasPreDefinedCenter = false;
let centerVector;
-
- if (feature.properties.cp && Array.isArray(feature.properties.cp) && feature.properties.cp.length === 2) {
+
+ 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;
-
+
// 保存预定义中心点
- if (regionType === 'province') {
+ if (regionType === "province") {
provinceCenters.set(regionName, centerVector);
}
}
-
+
// 存储区域边界
const boundaries: THREE.Vector3[][] = [];
-
+
// 处理多边形坐标
const processPolygon = (polygonCoords: any) => {
const points: THREE.Vector3[] = [];
-
+
// 收集多边形的点
polygonCoords.forEach((point: number[]) => {
const lon = point[0];
@@ -291,32 +334,36 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
centerLon += lon;
centerLat += lat;
pointCount++;
-
+
// 使用辅助函数将经纬度转换为3D坐标
const vertex = latLongToVector3(lat, lon, scale);
points.push(vertex);
-
+
// 扩展边界盒以包含此点
boundingBox.expandByPoint(vertex);
});
-
+
// 保存边界多边形
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 => {
+ 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);
@@ -324,152 +371,163 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
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);
-
+ const boxSize = Math.sqrt(
+ sizeX * sizeX + sizeY * sizeY + sizeZ * sizeZ,
+ );
+
// 更新或初始化指标
- metrics.boundingBoxSize = metrics.boundingBoxSize ?
- Math.max(metrics.boundingBoxSize, boxSize) : boxSize;
+ metrics.boundingBoxSize = metrics.boundingBoxSize
+ ? Math.max(metrics.boundingBoxSize, boxSize)
+ : boxSize;
metrics.pointCount = (metrics.pointCount || 0) + points.length;
}
-
+
// 创建边界线
if (points.length > 1) {
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
- const lineMaterial = new THREE.LineBasicMaterial({
- color: isRegionVisited
- ? (visitedBorderColor || colors.visitedBorder)
- : (borderColor || colors.border),
+ const lineMaterial = new THREE.LineBasicMaterial({
+ color: isRegionVisited
+ ? visitedBorderColor || colors.visitedBorder
+ : borderColor || colors.border,
linewidth: isRegionVisited ? 1.5 : 1,
transparent: true,
- opacity: isRegionVisited ? 0.9 : 0.7
+ opacity: isRegionVisited ? 0.9 : 0.7,
});
-
+
const line = new THREE.Line(lineGeometry, lineMaterial);
- line.userData = {
- name: regionName,
+ line.userData = {
+ name: regionName,
isVisited: isRegionVisited,
- originalColor: isRegionVisited
- ? (visitedBorderColor || colors.visitedBorder)
- : (borderColor || colors.border),
- highlightColor: colors.highlight // 使用主题颜色中定义的高亮颜色
+ originalColor: isRegionVisited
+ ? visitedBorderColor || colors.visitedBorder
+ : borderColor || colors.border,
+ highlightColor: colors.highlight, // 使用主题颜色中定义的高亮颜色
};
-
+
// 设置渲染顺序
line.renderOrder = isRegionVisited ? 3 : 2;
regionObject.add(line);
-
+
// 保存线条对象引用和对应的国家/地区名称
allLineObjects.push(line);
lineToCountryMap.set(line, regionName);
}
};
-
+
// 处理不同类型的几何体
- if (feature.geometry && (feature.geometry.type === 'Polygon' || feature.geometry.type === 'MultiPolygon')) {
- if (feature.geometry.type === 'Polygon') {
+ 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') {
+ } else if (feature.geometry.type === "MultiPolygon") {
feature.geometry.coordinates.forEach((polygon: any) => {
polygon.forEach((ring: any) => {
processPolygon(ring);
});
});
}
-
+
if (pointCount > 0 && !hasPreDefinedCenter) {
// 计算平均中心点
- centerLon /= pointCount;
- centerLat /= pointCount;
-
+ centerLon /= pointCount;
+ centerLat /= pointCount;
+
// 将中心点经纬度转换为3D坐标
centerVector = latLongToVector3(centerLat, centerLon, scale + 0.005);
-
+
// 保存计算的中心点
- if (regionType === 'province') {
+ if (regionType === "province") {
provinceCenters.set(regionName, centerVector);
}
}
-
+
if (pointCount > 0) {
// 保存地区的边界盒
countryBoundingBoxes.set(regionName, boundingBox);
-
+
// 添加地区对象到父组
parent.add(regionObject);
countries.set(regionName, regionObject);
}
}
-
+
return regionObject;
};
// 处理世界GeoJSON数据
worldData.features.forEach((feature: any) => {
const countryName = feature.properties.name;
-
+
// 跳过中国,因为我们将使用更详细的中国地图数据
- if (countryName === '中国') return;
-
+ if (countryName === "中国") return;
+
processGeoFeature(feature, countryGroup, {
- regionType: 'country',
- scale: 2.01
+ regionType: "country",
+ scale: 2.01,
});
});
-
+
// 处理中国的省份
const chinaObject = new THREE.Group();
- chinaObject.userData = { name: '中国', isVisited: visitedPlaces.includes('中国') };
-
+ chinaObject.userData = {
+ name: "中国",
+ isVisited: visitedPlaces.includes("中国"),
+ };
+
chinaData.features.forEach((feature: any) => {
processGeoFeature(feature, chinaObject, {
- regionType: 'province',
- parentName: '中国',
+ regionType: "province",
+ parentName: "中国",
scale: 2.015,
borderColor: colors.chinaBorder,
- visitedBorderColor: colors.visitedBorder
+ visitedBorderColor: colors.visitedBorder,
});
});
// 添加中国对象到国家组
countryGroup.add(chinaObject);
- countries.set('中国', chinaObject);
-
+ countries.set("中国", chinaObject);
+
// 将视图旋转到中国位置
const positionCameraToFaceChina = () => {
// 检查是否为小屏幕
- const isSmallScreen = containerRef.current && containerRef.current.clientWidth < 640;
-
+ const isSmallScreen =
+ containerRef.current && containerRef.current.clientWidth < 640;
+
// 根据屏幕大小设置不同的相机初始位置
let fixedPosition;
if (isSmallScreen) {
// 小屏幕显示距离更远,以便看到更多地球
- fixedPosition = new THREE.Vector3(-2.10, 3.41, -8.0);
+ fixedPosition = new THREE.Vector3(-2.1, 3.41, -8.0);
} else {
// 大屏幕使用原来的位置
- fixedPosition = new THREE.Vector3(-2.10, 3.41, -6.5);
+ 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);
@@ -487,17 +545,17 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
let inThrottle: boolean = false;
let lastFunc: number | null = null;
let lastRan: number | null = null;
-
- return function(this: any, ...args: any[]) {
+
+ return function (this: any, ...args: any[]) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
lastRan = Date.now();
- setTimeout(() => inThrottle = false, limit);
+ setTimeout(() => (inThrottle = false), limit);
} else {
// 取消之前的延迟调用
if (lastFunc) clearTimeout(lastFunc);
-
+
// 如果距离上次执行已经接近阈值,确保我们能及时处理下一个事件
const sinceLastRan = Date.now() - (lastRan || 0);
if (sinceLastRan >= limit * 0.8) {
@@ -518,26 +576,26 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
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;
@@ -550,18 +608,18 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
}
}
}
-
+
// 小区域优化逻辑
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("中国-")) {
@@ -572,75 +630,88 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
}
}
}
-
+
if (closestProvince && minProvinceDistance < minDistance * 1.5) {
return closestProvince;
}
}
-
+
return closestCountry;
};
-
+
// 解决射线检测和球面相交的问题
- const getPointOnSphere = (mouseX: number, mouseY: number, camera: THREE.Camera, radius: number): THREE.Vector3 | null => {
+ 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;
}
-
+
// 如果没有直接相交,使用球体辅助检测
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;
}
-
+
return null;
};
// 简化的鼠标移动事件处理函数
const onMouseMove = throttle((event: MouseEvent) => {
if (!containerRef.current || !sceneRef.current) return;
-
+
// 获取鼠标在球面上的点
- const spherePoint = getPointOnSphere(event.clientX, event.clientY, camera, 2.01);
-
+ const spherePoint = getPointOnSphere(
+ event.clientX,
+ event.clientY,
+ camera,
+ 2.01,
+ );
+
// 重置所有线条颜色
- allLineObjects.forEach(line => {
+ 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) {
+ 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 {
@@ -657,23 +728,23 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
controls.autoRotate = true;
}
}
-
+
// 保存鼠标事件和位置
sceneRef.current.lastMouseEvent = event;
sceneRef.current.lastMouseX = event.clientX;
sceneRef.current.lastMouseY = event.clientY;
sceneRef.current.lastHoverTime = Date.now();
}, 100);
-
+
// 清除选择的函数
const clearSelection = () => {
// 恢复所有线条的原始颜色
- allLineObjects.forEach(line => {
+ allLineObjects.forEach((line) => {
if (line.material instanceof THREE.LineBasicMaterial) {
line.material.color.set(line.userData.originalColor);
}
});
-
+
setHoveredCountry(null);
if (sceneRef.current) {
sceneRef.current.lastClickedCountry = null;
@@ -685,29 +756,37 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
// 简化的鼠标点击事件处理函数
const onClick = (event: MouseEvent) => {
if (!containerRef.current || !sceneRef.current) return;
-
+
// 获取鼠标在球面上的点
- const spherePoint = getPointOnSphere(event.clientX, event.clientY, camera, 2.01);
-
+ const spherePoint = getPointOnSphere(
+ event.clientX,
+ event.clientY,
+ camera,
+ 2.01,
+ );
+
// 如果找到点,寻找最近的国家/地区
if (spherePoint) {
const countryName = findNearestCountry(spherePoint);
-
+
if (countryName) {
// 重置所有线条颜色
- allLineObjects.forEach(line => {
+ 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) {
+ 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;
@@ -720,13 +799,13 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
// 如果没有找到球面点,清除选择
clearSelection();
}
-
+
// 更新最后的鼠标位置和点击时间
sceneRef.current.lastMouseX = event.clientX;
sceneRef.current.lastMouseY = event.clientY;
sceneRef.current.lastHoverTime = Date.now();
};
-
+
// 鼠标双击事件处理
const onDoubleClick = (event: MouseEvent) => {
clearSelection();
@@ -735,21 +814,21 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
};
// 添加事件监听器
- containerRef.current.addEventListener('mousemove', onMouseMove);
- containerRef.current.addEventListener('click', onClick);
- containerRef.current.addEventListener('dblclick', onDoubleClick);
+ containerRef.current.addEventListener("mousemove", onMouseMove);
+ containerRef.current.addEventListener("click", onClick);
+ containerRef.current.addEventListener("dblclick", onDoubleClick);
// 简化的动画循环函数
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);
};
@@ -773,28 +852,28 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
lastMouseY: null,
lastHoverTime: null,
regionImportance: undefined,
- importanceThreshold: undefined
+ importanceThreshold: undefined,
};
// 处理窗口大小变化
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);
-
+
// 立即渲染一次
renderer.render(sceneRef.current.scene, camera);
labelRenderer.render(sceneRef.current.scene, camera);
};
- window.addEventListener('resize', handleResize);
+ window.addEventListener("resize", handleResize);
// 开始动画
sceneRef.current.animationId = requestAnimationFrame(animate);
@@ -807,50 +886,58 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
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);
+ containerRef.current.removeEventListener("mousemove", onMouseMove);
+ containerRef.current.removeEventListener("click", onClick);
+ containerRef.current.removeEventListener("dblclick", onDoubleClick);
}
-
+
// 移除窗口事件监听器
- window.removeEventListener('resize', handleResize);
+ window.removeEventListener("resize", handleResize);
};
}, [visitedPlaces, theme]); // 依赖于visitedPlaces和theme变化
return (
-
{hoveredCountry && (
- {hoveredCountry}
+ {hoveredCountry}
{hoveredCountry && visitedPlaces.includes(hoveredCountry) ? (
-
@@ -867,4 +954,4 @@ const WorldHeatmap: React.FC = ({ visitedPlaces }) => {
);
};
-export default WorldHeatmap;
\ No newline at end of file
+export default WorldHeatmap;
diff --git a/src/content/旅行笔记/第一次出国旅行-东南亚.md b/src/content/旅行笔记/第一次出国旅行-东南亚.md
index a34cf2e..c34cf03 100644
--- a/src/content/旅行笔记/第一次出国旅行-东南亚.md
+++ b/src/content/旅行笔记/第一次出国旅行-东南亚.md
@@ -4,13 +4,13 @@ date: 2025-04-18T22:01:57+08:00
tags: []
---
->大多数和别人的对话都是使用谷歌翻译的同声翻译
+> 大多数和别人的对话都是使用谷歌翻译的同声翻译
## 睁眼说瞎话
- 值机的时候碰到本次旅行第一个交流的外国人,一个会讲中文的马来西亚男人,感觉马来西亚男人说话像乱序中文但是能听懂,马来西亚男人知道了我没有马来西亚货币,提出换一点林吉特给我,马来西亚男人的老婆说人民币拿去没用,我都不抱希望了,但是马来西亚夫妻还是兑换了100人民币给我。
+ 值机的时候碰到本次旅行第一个交流的外国人,一个会讲中文的马来西亚男人,感觉马来西亚男人说话像乱序中文但是能听懂,马来西亚男人知道了我没有马来西亚货币,提出换一点林吉特给我,马来西亚男人的老婆说人民币拿去没用,我都不抱希望了,但是马来西亚夫妻还是兑换了 100 人民币给我。
- 还没出国门就遇到第一个问题,海关闸机刷了不开门,海关警察来了对我进行盘问,海关的警察问我很多问题,其中就有我父母是否同意,现在遇到突发问题,也是可以面不改色的说假话了,不过好在中午的时候打电话询问了一下我爹的意见,虽然我爹不同意,但是好在留下了通话记录,我将过去的旅游照片给海关看,与海关警察周旋了10多分钟,幸好过海关的时候快到晚上12点了,不好向我父母核实,差点这次旅行计划早夭了。
+ 还没出国门就遇到第一个问题,海关闸机刷了不开门,海关警察来了对我进行盘问,海关的警察问我很多问题,其中就有我父母是否同意,现在遇到突发问题,也是可以面不改色的说假话了,不过好在中午的时候打电话询问了一下我爹的意见,虽然我爹不同意,但是好在留下了通话记录,我将过去的旅游照片给海关看,与海关警察周旋了 10 多分钟,幸好过海关的时候快到晚上 12 点了,不好向我父母核实,差点这次旅行计划早夭了。
第一次做廉航,亚航位置空隙比国内任何大巴空隙都要小,最难受的交通出行方式。
@@ -18,21 +18,21 @@ tags: []
## 可恶的公交车司机
- 马来西亚第一站计划去粉红清真寺,从吉隆坡机场1楼乘坐巴士直接过去,但是购票的时候,公交车售票厅工作人员说没有到粉红清真寺的公交车只能打出租车前往,看了一下打车的价格,决定重新做攻略再挣扎一下,在休息长椅坐了半个小时终于找到新路线`地铁站->布城->公交站->粉红清真寺`,往机场3楼地铁站走的时候发现斜挎包不见了,听说国外酒店工作人员会翻包偷钱,就买了个斜挎包放护照,现金等重要的东西,惊慌了一会,还好头脑风暴了一会想起来了在做攻略的长椅忘拿了。
+ 马来西亚第一站计划去粉红清真寺,从吉隆坡机场 1 楼乘坐巴士直接过去,但是购票的时候,公交车售票厅工作人员说没有到粉红清真寺的公交车只能打出租车前往,看了一下打车的价格,决定重新做攻略再挣扎一下,在休息长椅坐了半个小时终于找到新路线`地铁站->布城->公交站->粉红清真寺`,往机场 3 楼地铁站走的时候发现斜挎包不见了,听说国外酒店工作人员会翻包偷钱,就买了个斜挎包放护照,现金等重要的东西,惊慌了一会,还好头脑风暴了一会想起来了在做攻略的长椅忘拿了。
- 布城的公交车站是始发站,在最后一个站台才找到`T523`,我想上车但是司机朝我摆手,我就在车子旁边的亭子研究如何打车,研究了一会司机叫了我一声,给我一个招揽的手势,上去了我给司机看我的谷歌地图,用翻译软件软件问司机可以去这里吗,司机用本地话说一大堆,我将谷歌同声翻译打开司机,告诉司机对着这个说我就可以听懂了,但是给司机一个字不说,拿开了手机司机又开始说本地话,僵持了一会司机不耐烦的打手势让我去旁边公交车,我以为上错车了,我将地图给旁边公交车司机看,说要去地图的地方,旁边公交车的司机指着`T523`告诉我那辆车可以去,回到T523后告诉司机就是这个车,车开了一会司机说 three ,我本以为司机完全不会英语呢,途中看到一个清真寺,打开谷歌地图显示现在要去的最后一个站,我指着清真寺问司机"is there?",司机说"yes,down",看着绿色的清真寺我觉得现在照骗太多了,看着别人拿着证件或者是手机给安保人员看了才能进去,我想网上不要预约和门票的说法看来是过时了,不过来都来了我要去试试,到了门口工作人员拦下我,我告诉工作人员我没有预约但是我想进去参观,工作人员反复询问我确定要进去吗,我告诉工作人员我专程过来参观这个清真寺,工作人员的话翻译过来是“这是政府办公的地方不允许外人靠近,但是你是第一次”,我打开地图重新导航显示距离粉红清真寺还有1.2km。
+ 布城的公交车站是始发站,在最后一个站台才找到`T523`,我想上车但是司机朝我摆手,我就在车子旁边的亭子研究如何打车,研究了一会司机叫了我一声,给我一个招揽的手势,上去了我给司机看我的谷歌地图,用翻译软件软件问司机可以去这里吗,司机用本地话说一大堆,我将谷歌同声翻译打开司机,告诉司机对着这个说我就可以听懂了,但是给司机一个字不说,拿开了手机司机又开始说本地话,僵持了一会司机不耐烦的打手势让我去旁边公交车,我以为上错车了,我将地图给旁边公交车司机看,说要去地图的地方,旁边公交车的司机指着`T523`告诉我那辆车可以去,回到 T523 后告诉司机就是这个车,车开了一会司机说 three ,我本以为司机完全不会英语呢,途中看到一个清真寺,打开谷歌地图显示现在要去的最后一个站,我指着清真寺问司机"is there?",司机说"yes,down",看着绿色的清真寺我觉得现在照骗太多了,看着别人拿着证件或者是手机给安保人员看了才能进去,我想网上不要预约和门票的说法看来是过时了,不过来都来了我要去试试,到了门口工作人员拦下我,我告诉工作人员我没有预约但是我想进去参观,工作人员反复询问我确定要进去吗,我告诉工作人员我专程过来参观这个清真寺,工作人员的话翻译过来是“这是政府办公的地方不允许外人靠近,但是你是第一次”,我打开地图重新导航显示距离粉红清真寺还有 1.2km。
粉红清真寺的穹顶真的是粉红色的!穹顶里面看更漂亮,由红色,浅粉色,白色构成的图案
在粉红清真寺里面有两幅捐款地图,捐款一次可以用针扎自己的家乡,一副世界地图一副中国地图,世界地图上的中国和中国地图都是密密麻麻的针
- 国外的公交车不适合i人,我打算从布城从地铁到市中心,需要先坐公交车到布城去,差10秒就赶上了,但是站台没人所以公交车司机没有停,第二次在休息区等了半个小时司机又没停,可能是司机没看到我吧,第三次我站在公交车站台等车的位置等待可是他还是没停,这次可能是没有给司机信号,第四次等公交车快到的时候我死死的看着司机,与他建立心灵链接,但他还是不停,浏览器查询原来要招手,用打车软件看了一下两公里,还是选择打车了
+ 国外的公交车不适合 i 人,我打算从布城从地铁到市中心,需要先坐公交车到布城去,差 10 秒就赶上了,但是站台没人所以公交车司机没有停,第二次在休息区等了半个小时司机又没停,可能是司机没看到我吧,第三次我站在公交车站台等车的位置等待可是他还是没停,这次可能是没有给司机信号,第四次等公交车快到的时候我死死的看着司机,与他建立心灵链接,但他还是不停,浏览器查询原来要招手,用打车软件看了一下两公里,还是选择打车了
- 在去酒店的路上看到了很多流浪汉,不过感觉他们的穿搭和我没有区别,一个包+拖鞋,历经大雨来到谷歌地图显示的位置,却找不到酒店,找了一个印度男人问路,他也找不到,他给酒店客服打电话后,告诉我不在这个区域,给我指路,往哪走再往哪走到一个塔下快到了问问别人,我一点没记住好在用高德地图重新导航,竟然没问题。
+ 在去酒店的路上看到了很多流浪汉,不过感觉他们的穿搭和我没有区别,一个包+拖鞋,历经大雨来到谷歌地图显示的位置,却找不到酒店,找了一个印度男人问路,他也找不到,他给酒店客服打电话后,告诉我不在这个区域,给我指路,往哪走,再往哪走,到一个塔下就快到了,再问问别人,我一点没记住好在用高德地图重新导航,竟然没问题。
- 晚餐找了家本地人多的店,点了一个大虾饭,没想到是正宗印度菜,`米饭味道=70%八角+20%洗衣服+10%辣椒`
+ 晚餐在酒店旁找了家本地人多的店,点了一个大虾饭,没想到是正宗印度菜,`米饭味道=70%八角+20%洗衣服+10%辣椒`
- 凌晨3点被炸街吵醒,没想到精神小伙也是全世界统一
+ 凌晨 3 点被炸街吵醒,没想到精神小伙也是全世界统一
---