From 7dcf3f2a8b06e3023f97786a8c0df130ac9d54dc Mon Sep 17 00:00:00 2001 From: lsy Date: Sat, 19 Apr 2025 18:54:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=BC=E8=88=AA=E6=A0=8F?= =?UTF-8?q?=E9=AB=98=E4=BA=AE=E4=BB=A3=E7=A0=81=E4=B8=8D=E4=BC=9A=E5=8F=98?= =?UTF-8?q?=E5=8C=96=E5=92=8C=E5=88=87=E6=8D=A2=E4=B8=BB=E9=A2=98=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E9=97=AA=E7=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Footer.astro | 88 ++ src/components/Footer.tsx | 65 -- src/components/Header.astro | 767 ++++++++++++++++++ src/components/Header.tsx | 421 ---------- src/components/Layout.astro | 26 +- src/components/MediaGrid.tsx | 192 +++-- src/components/ThemeToggle.astro | 139 ++++ src/components/ThemeToggle.tsx | 131 --- src/components/WorldHeatmap.tsx | 547 +++++++------ src/content/旅行笔记/第一次出国旅行-东南亚.md | 18 +- 10 files changed, 1445 insertions(+), 949 deletions(-) create mode 100644 src/components/Footer.astro delete mode 100644 src/components/Footer.tsx create mode 100644 src/components/Header.astro delete mode 100644 src/components/Header.tsx create mode 100644 src/components/ThemeToggle.astro delete mode 100644 src/components/ThemeToggle.tsx diff --git a/src/components/Footer.astro b/src/components/Footer.astro new file mode 100644 index 0000000..2bb604f --- /dev/null +++ b/src/components/Footer.astro @@ -0,0 +1,88 @@ +--- +interface Props { + icp?: string; + psbIcp?: string; + psbIcpUrl?: string; +} + +const { + icp = "", + psbIcp = "", + psbIcpUrl = "http://www.beian.gov.cn/portal/registerSystemInfo", +} = Astro.props; + +const currentYear = new Date().getFullYear(); +--- + + diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx deleted file mode 100644 index f7ebd94..0000000 --- a/src/components/Footer.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; - -interface FooterProps { - icp?: string; - psbIcp?: string; - psbIcpUrl?: string; -} - -export function Footer({ - icp = "", - psbIcp = "", - psbIcpUrl = "http://www.beian.gov.cn/portal/registerSystemInfo", -}: FooterProps) { - const currentYear = new Date().getFullYear(); - - return ( - - ); -} \ No newline at end of file diff --git a/src/components/Header.astro b/src/components/Header.astro new file mode 100644 index 0000000..2367740 --- /dev/null +++ b/src/components/Header.astro @@ -0,0 +1,767 @@ +--- +import { SITE_NAME, NAV_LINKS } from "@/consts.ts"; +import ThemeToggle from "./ThemeToggle.astro"; + +// 获取当前路径 +const currentPath = Astro.url.pathname; + +// 移除结尾的斜杠以统一路径格式(保留根路径的斜杠) +const normalizedPath = + currentPath === "/" + ? "/" + : currentPath.endsWith("/") + ? currentPath.slice(0, -1) + : currentPath; + +// 定义导航链接 +--- + +
+
+
+ +
+ + diff --git a/src/components/Header.tsx b/src/components/Header.tsx deleted file mode 100644 index 42b098f..0000000 --- a/src/components/Header.tsx +++ /dev/null @@ -1,421 +0,0 @@ -import { useEffect, useState, useRef } from 'react'; -import { SITE_NAME, NAV_LINKS } from '@/consts.ts'; -import { ThemeToggle } from './ThemeToggle'; -import '@/styles/header.css'; - - -// 文章对象类型定义 -interface Article { - id: string; - title: string; - date: string | Date; - summary?: string; - tags?: string[]; - image?: string; - content?: string; -} - -export default function Header() { - // 状态定义 - const [scrolled, setScrolled] = useState(false); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const [mobileSearchOpen, setMobileSearchOpen] = useState(false); - const [articles, setArticles] = useState([]); - const [isArticlesLoaded, setIsArticlesLoaded] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [desktopSearchFocused, setDesktopSearchFocused] = useState(false); - - // 获取当前路径 (使用window.location.pathname) - const [pathname, setPathname] = useState('/'); - useEffect(() => { - setPathname(window.location.pathname); - }, []); - - // 移除结尾的斜杠以统一路径格式 - const normalizedPath = pathname?.endsWith('/') ? pathname.slice(0, -1) : pathname; - - // 引用 - const desktopSearchRef = useRef(null); - const desktopResultsRef = useRef(null); - const mobileSearchRef = useRef(null); - const searchTimeoutRef = useRef(null); - - // 处理滚动效果 - useEffect(() => { - const scrollThreshold = 50; - - function updateHeaderBackground() { - if (window.scrollY > scrollThreshold) { - setScrolled(true); - } else { - setScrolled(false); - } - } - - // 初始检查 - updateHeaderBackground(); - - // 添加滚动事件监听 - window.addEventListener('scroll', updateHeaderBackground); - - // 清理 - return () => window.removeEventListener('scroll', updateHeaderBackground); - }, []); - - // 搜索节流函数 - function debounce void>(func: T, wait: number): (...args: Parameters) => void { - let timeout: ReturnType | undefined; - return function(this: any, ...args: Parameters): void { - clearTimeout(timeout); - timeout = setTimeout(() => func.apply(this, args), wait); - }; - } - - // 获取文章数据 - async function fetchArticles() { - if (isArticlesLoaded && articles.length > 0) return; - - try { - const response = await fetch('/api/search'); - if (!response.ok) { - throw new Error('获取文章数据失败'); - } - const data = await response.json(); - setArticles(data); - setIsArticlesLoaded(true); - } catch (error) { - console.error('获取文章失败:', error); - } - } - - // 高亮文本中的匹配部分 - function highlightText(text: string, query: string): string { - if (!text || !query.trim()) return text; - - // 转义正则表达式中的特殊字符 - const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(`(${escapedQuery})`, 'gi'); - - return text.replace(regex, '$1'); - } - - // 搜索文章逻辑 - const debouncedSearch = debounce((query: string) => { - setSearchQuery(query); - }, 300); - - // 点击页面其他区域关闭搜索结果 - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - desktopSearchRef.current && - desktopResultsRef.current && - !desktopSearchRef.current.contains(event.target as Node) && - !desktopResultsRef.current.contains(event.target as Node) - ) { - setDesktopSearchFocused(false); - } - } - - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - // 处理ESC键 - useEffect(() => { - function handleKeyDown(e: KeyboardEvent) { - if (e.key === 'Escape') { - setDesktopSearchFocused(false); - setMobileSearchOpen(false); - } - } - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, []); - - // 搜索结果处理 - const getFilteredArticles = () => { - if (!searchQuery.trim()) return []; - - const lowerQuery = searchQuery.toLowerCase(); - - return articles - .filter((article: Article) => { - const title = article.title.toLowerCase(); - const tags = article.tags ? article.tags.map((tag: string) => tag.toLowerCase()) : []; - const summary = article.summary ? article.summary.toLowerCase() : ''; - const content = article.content ? article.content.toLowerCase() : ''; - - return title.includes(lowerQuery) || - tags.some((tag: string) => tag.includes(lowerQuery)) || - summary.includes(lowerQuery) || - content.includes(lowerQuery); - }) - .sort((a: Article, b: Article) => { - // 标题匹配优先 - const aTitle = a.title.toLowerCase(); - const bTitle = b.title.toLowerCase(); - - if (aTitle.includes(lowerQuery) && !bTitle.includes(lowerQuery)) { - return -1; - } - if (!aTitle.includes(lowerQuery) && bTitle.includes(lowerQuery)) { - return 1; - } - - // 内容匹配次之 - const aContent = a.content ? a.content.toLowerCase() : ''; - const bContent = b.content ? b.content.toLowerCase() : ''; - - if (aContent.includes(lowerQuery) && !bContent.includes(lowerQuery)) { - return -1; - } - if (!aContent.includes(lowerQuery) && bContent.includes(lowerQuery)) { - return 1; - } - - // 日期排序 - return new Date(b.date).getTime() - new Date(a.date).getTime(); - }) - .slice(0, 10); // 限制结果数量 - }; - - // 生成搜索结果列表项 - const renderSearchResults = () => { - const filteredArticles = getFilteredArticles(); - if (filteredArticles.length === 0) { - return ( -
- 没有找到相关文章 -
- ); - } - - return ( - - ); - }; - - // 准备样式类 - const headerBgClasses = `absolute inset-0 bg-gray-50/95 dark:bg-dark-bg/95 ${ - scrolled ? 'scrolled' : '' - }`; - - return ( -
-
- -
- ); -} \ No newline at end of file diff --git a/src/components/Layout.astro b/src/components/Layout.astro index e4a3e74..47e9a21 100644 --- a/src/components/Layout.astro +++ b/src/components/Layout.astro @@ -1,7 +1,7 @@ --- import "@/styles/global.css"; -import Header from "@/components/Header.tsx"; -import {Footer} from "@/components/Footer.tsx"; +import Header from "@/components/Header.astro"; +import Footer from "@/components/Footer.astro"; import { ICP, PSB_ICP, PSB_ICP_URL, SITE_NAME, SITE_DESCRIPTION } from "@/consts"; // 定义Props接口 @@ -62,22 +62,24 @@ const { title = SITE_NAME, description = SITE_DESCRIPTION, date, author, tags, i // 设置标志,表示初始化已完成 window.__themeInitDone = true; - const theme = (() => { - if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) { - return localStorage.getItem('theme'); - } - if (window.matchMedia('(prefers-color-scheme: dark)').matches) { - return 'dark'; - } - return 'light'; - })(); + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + const savedTheme = typeof localStorage !== 'undefined' ? localStorage.getItem('theme') : null; + const theme = savedTheme || systemTheme; + + // 立即设置文档主题 document.documentElement.dataset.theme = theme; + + // 将主题信息存储在全局变量中,以便 React 组件可以立即访问 + window.__THEME_DATA__ = { + currentTheme: theme, + systemTheme: systemTheme + }; } })(); -
+
diff --git a/src/components/MediaGrid.tsx b/src/components/MediaGrid.tsx index d6389de..1819395 100644 --- a/src/components/MediaGrid.tsx +++ b/src/components/MediaGrid.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from "react"; interface MediaGridProps { - type: 'movie' | 'book'; + type: "movie" | "book"; title: string; doubanId: string; } @@ -12,11 +12,6 @@ interface MediaItem { link: string; } -interface PaginationInfo { - current: number; - hasNext: boolean; -} - const MediaGrid: React.FC = ({ type, title, doubanId }) => { const [items, setItems] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -26,40 +21,44 @@ const MediaGrid: React.FC = ({ type, title, doubanId }) => { const itemsPerPage = 15; const mediaListRef = useRef(null); const lastScrollTime = useRef(0); - + // 使用ref来跟踪关键状态,避免闭包问题 const stateRef = useRef({ isLoading: false, hasMoreContent: true, currentPage: 1, - error: null as string | null + error: null as string | null, }); // 封装fetch函数但不使用useCallback以避免依赖循环 const fetchMedia = async (page = 1, append = false) => { // 使用ref中的最新状态 - if (stateRef.current.isLoading || - (!append && !stateRef.current.hasMoreContent) || - (append && !stateRef.current.hasMoreContent)) { + if ( + stateRef.current.isLoading || + (!append && !stateRef.current.hasMoreContent) || + (append && !stateRef.current.hasMoreContent) + ) { return; } - + // 更新状态和ref setIsLoading(true); stateRef.current.isLoading = true; - + // 只在首次加载时清除错误 if (!append) { setError(null); stateRef.current.error = null; } - + const start = (page - 1) * itemsPerPage; try { - const response = await fetch(`/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`); + const response = await fetch( + `/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`, + ); if (!response.ok) { // 解析响应内容,获取详细错误信息 - let errorMessage = `获取${type === 'movie' ? '电影' : '图书'}数据失败`; + let errorMessage = `获取${type === "movie" ? "电影" : "图书"}数据失败`; try { const errorData = await response.json(); if (errorData && errorData.error) { @@ -71,7 +70,7 @@ const MediaGrid: React.FC = ({ type, title, doubanId }) => { } catch (e) { // 无法解析JSON,使用默认错误信息 } - + // 针对不同错误提供更友好的提示 if (response.status === 403) { errorMessage = "豆瓣接口访问受限,可能是请求过于频繁,请稍后再试"; @@ -87,21 +86,21 @@ const MediaGrid: React.FC = ({ type, title, doubanId }) => { errorMessage = "未找到相关内容,请检查豆瓣ID是否正确"; } } - + // 设置错误状态和ref setError(errorMessage); stateRef.current.error = errorMessage; - + // 只有非追加模式才清空数据 if (!append) { setItems([]); } - + throw new Error(errorMessage); } - + const data = await response.json(); - + if (data.items.length === 0) { // 如果返回的项目为空,则认为已经没有更多内容 setHasMoreContent(false); @@ -111,7 +110,7 @@ const MediaGrid: React.FC = ({ type, title, doubanId }) => { } } else { if (append) { - setItems(prev => { + setItems((prev) => { const newItems = [...prev, ...data.items]; return newItems; }); @@ -121,7 +120,7 @@ const MediaGrid: React.FC = ({ type, title, doubanId }) => { // 更新页码状态和ref setCurrentPage(data.pagination.current); stateRef.current.currentPage = data.pagination.current; - + // 更新是否有更多内容的状态和ref const newHasMoreContent = data.pagination.hasNext; setHasMoreContent(newHasMoreContent); @@ -147,19 +146,23 @@ const MediaGrid: React.FC = ({ type, title, doubanId }) => { const documentHeight = document.documentElement.scrollHeight; const scrollPosition = scrollY + windowHeight; const threshold = documentHeight - 300; - + // 限制滚动日志频率,每秒最多输出一次 const now = Date.now(); if (now - lastScrollTime.current < 1000) { return; } lastScrollTime.current = now; - + // 使用ref中的最新状态来检查 - if (stateRef.current.isLoading || !stateRef.current.hasMoreContent || stateRef.current.error) { + if ( + stateRef.current.isLoading || + !stateRef.current.hasMoreContent || + stateRef.current.error + ) { return; } - + // 当滚动到距离底部300px时加载更多 if (scrollPosition >= threshold) { fetchMedia(stateRef.current.currentPage + 1, true); @@ -188,100 +191,116 @@ const MediaGrid: React.FC = ({ type, title, doubanId }) => { // 重置状态 setCurrentPage(1); stateRef.current.currentPage = 1; - + setHasMoreContent(true); stateRef.current.hasMoreContent = true; - + setError(null); stateRef.current.error = null; - + setIsLoading(false); stateRef.current.isLoading = false; - + // 清空列表 setItems([]); - + // 加载第一页数据 fetchMedia(1, false); - + // 管理滚动事件 const scrollListener = handleScroll; - + // 移除任何现有监听器 - window.removeEventListener('scroll', scrollListener); - + window.removeEventListener("scroll", scrollListener); + // 添加滚动事件监听器 - 使用passive: true可提高滚动性能 - window.addEventListener('scroll', scrollListener, { passive: true }); - + window.addEventListener("scroll", scrollListener, { passive: true }); + // 创建一个IntersectionObserver作为备选检测方案 const observerOptions = { root: null, - rootMargin: '300px', - threshold: 0.1 + rootMargin: "300px", + threshold: 0.1, }; - + const intersectionObserver = new IntersectionObserver((entries) => { const entry = entries[0]; - - if (entry.isIntersecting && - !stateRef.current.isLoading && - stateRef.current.hasMoreContent && - !stateRef.current.error) { + + if ( + entry.isIntersecting && + !stateRef.current.isLoading && + stateRef.current.hasMoreContent && + !stateRef.current.error + ) { fetchMedia(stateRef.current.currentPage + 1, true); } }, observerOptions); - + // 添加检测底部的元素 - 放在grid容器的后面而不是内部 - const footer = document.createElement('div'); - footer.id = 'scroll-detector'; - footer.style.width = '100%'; - footer.style.height = '10px'; + const footer = document.createElement("div"); + footer.id = "scroll-detector"; + footer.style.width = "100%"; + footer.style.height = "10px"; // 确保mediaListRef有父元素 if (mediaListRef.current && mediaListRef.current.parentElement) { // 插入到grid后面而不是内部 - mediaListRef.current.parentElement.insertBefore(footer, mediaListRef.current.nextSibling); + mediaListRef.current.parentElement.insertBefore( + footer, + mediaListRef.current.nextSibling, + ); intersectionObserver.observe(footer); } - + // 初始检查一次,以防内容不足一屏 const timeoutId = setTimeout(() => { if (stateRef.current.hasMoreContent && !stateRef.current.isLoading) { scrollListener(); } }, 500); - + // 清理函数 return () => { clearTimeout(timeoutId); - window.removeEventListener('scroll', scrollListener); + window.removeEventListener("scroll", scrollListener); intersectionObserver.disconnect(); - document.getElementById('scroll-detector')?.remove(); + document.getElementById("scroll-detector")?.remove(); }; }, [type, doubanId]); // 只在关键属性变化时执行 - + // 错误提示组件 const ErrorMessage = () => { if (!error) return null; - + return (
- - + +

访问错误

{error}

- + + \ 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 点被炸街吵醒,没想到精神小伙也是全世界统一 ---