"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { APIProvider, AdvancedMarker, Map as GoogleMap, useMap } from "@vis.gl/react-google-maps"; import { Footprints, Undo2, HeartHandshake, Waves, Home, Play, Pause, SkipBack, SkipForward, X, ChevronLeft, ChevronRight, MapPinned, } from "lucide-react"; import ArtifactChat from "../../components/ArtifactChat"; import ArtifactImage from "../../components/ArtifactImage"; import RouteLayer from "../../components/RouteLayer"; import { API_URL, CATEGORY_LABELS, CATEGORY_MARKS, DYNASTY_OPTIONS, LEVEL_LABELS, REPATRIATION_LABELS, themeOf, markerSizeFor, type MapPoint, type RouteDetail, type RouteSummary, type Theme, } from "../../lib/artifact"; const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? ""; interface MapStats { total_artifacts: number; total_institutions: number; total_locations: number; } interface ArtifactGroup { key: string; institution_name: string; city: string; lat: number; lng: number; theme: Theme; points: MapPoint[]; } interface MarkersLayerProps { groups: ArtifactGroup[]; selected: MapPoint | null; selectedGroup: ArtifactGroup | null; zoom: number; onGroupClick: (g: ArtifactGroup) => void; onSingleClick: (p: MapPoint) => void; } function MarkersLayer({ groups, selected, selectedGroup, zoom, onGroupClick, onSingleClick }: MarkersLayerProps) { return ( <> {groups.map((group) => { const count = group.points.length; const isMulti = count > 1; const isGroupActive = selectedGroup?.key === group.key; const hasAnyActive = group.points.some((p) => p.id === selected?.id); const isActive = isGroupActive || hasAnyActive; const first = group.points[0]; const size = markerSizeFor(zoom, count); const fontSize = Math.max(9, Math.round(size * 0.44)); const showLabel = zoom >= 5 || isActive; const label = isMulti ? group.institution_name : first.name; // 国内馆藏=琥珀金,流失海外=青瓷绿,已回归=回归金绿 const palette = group.theme === "overseas" ? isActive ? "border-[#cdeee2] bg-[#3f8d7e] text-[#06140f] ring-[#cdeee2]/45 ring-offset-[#0c0a06]" : "border-[#bfe3d6]/70 bg-[#2f6b60] text-[#e2f7ef] ring-[#8fc7b8]/30 ring-offset-[#0c0a06] hover:border-[#cdeee2] hover:bg-[#3f8d7e]" : group.theme === "repatriated" ? isActive ? "border-[#d6f5c9] bg-[#3f9e6a] text-[#06140f] ring-[#d6f5c9]/45 ring-offset-[#0c0a06]" : "border-[#bfe7b8]/70 bg-[#2f7d52] text-[#e6ffe0] ring-[#8fc79a]/30 ring-offset-[#0c0a06] hover:border-[#d6f5c9] hover:bg-[#3f9e6a]" : isActive ? "border-[#ffe2a0] bg-[#d99b3d] text-[#1a1005] ring-[#ffe2a0]/45 ring-offset-[#0c0a06]" : "border-[#ffe2a0]/70 bg-[#b9832f] text-[#1f140a] ring-[#c9a55a]/30 ring-offset-[#0c0a06] hover:border-[#ffe2a0] hover:bg-[#d99b3d]"; return ( { if (isMulti) onGroupClick(group); else onSingleClick(first); }} title={isMulti ? `${group.institution_name}\uff08${count} \u4ef6\uff09` : first.name} >
{count}
{showLabel && ( {label} )}
); })} ); } function MapIdleListener({ onIdle, onZoomChange }: { onIdle: () => void; onZoomChange?: (z: number) => void }) { const map = useMap(); useEffect(() => { if (!map) return; const listener = map.addListener("idle", () => { onIdle(); if (onZoomChange) onZoomChange(map.getZoom() ?? 4); }); return () => listener.remove(); }, [map, onIdle, onZoomChange]); return null; } export default function MapPage() { const [points, setPoints] = useState([]); const [stats, setStats] = useState(null); const [selected, setSelected] = useState(null); const [pointsLoading, setPointsLoading] = useState(false); const [pointsError, setPointsError] = useState(false); const pointsCacheRef = useRef>(new Map()); const fetchAbortRef = useRef(null); const fetchTimerRef = useRef | null>(null); const [filterCategory, setFilterCategory] = useState(""); const [filterDynasty, setFilterDynasty] = useState(""); const [filterCity, setFilterCity] = useState(""); const [cityPage, setCityPage] = useState(0); const [sameInstOpen, setSameInstOpen] = useState(false); const [routes, setRoutes] = useState([]); const [activeRoute, setActiveRoute] = useState(null); const [routeStep, setRouteStep] = useState(1); const [routePlaying, setRoutePlaying] = useState(false); const [showWelfare, setShowWelfare] = useState(false); const [showRepatriationPicker, setShowRepatriationPicker] = useState(false); const [repatPickerCat, setRepatPickerCat] = useState(""); const [searchQ, setSearchQ] = useState(""); const [selectedGroup, setSelectedGroup] = useState(null); const [zoom, setZoom] = useState(4); const onZoomChange = useCallback((z: number) => setZoom(z), []); const [rightWidth, setRightWidth] = useState(320); const [leftCollapsed, setLeftCollapsed] = useState(false); const [rightCollapsed, setRightCollapsed] = useState(false); const leftW = leftCollapsed ? 0 : 288; const effRightW = rightCollapsed ? 0 : rightWidth; // 初始化:右栏宽度取屏幕宽度的 1/3 useEffect(() => { setRightWidth(Math.round(window.innerWidth / 3)); }, []); const [chatStarted, setChatStarted] = useState(false); const [infoCollapsed, setInfoCollapsed] = useState(false); const handleConversationChange = useCallback((active: boolean) => { setChatStarted(active); setInfoCollapsed(active); }, []); // 切换文物时恢复信息展开 useEffect(() => { setChatStarted(false); setInfoCollapsed(false); setSameInstOpen(false); }, [selected?.id]); const startResize = useCallback((e: React.MouseEvent) => { e.preventDefault(); const onMove = (ev: MouseEvent) => { const w = window.innerWidth - ev.clientX; setRightWidth(Math.max(300, Math.min(Math.round(window.innerWidth * 0.6), w))); }; const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); document.body.style.userSelect = ""; document.body.style.cursor = ""; }; document.body.style.userSelect = "none"; document.body.style.cursor = "col-resize"; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, []); useEffect(() => { fetch(`${API_URL}/api/v1/map/stats`) .then((r) => r.json()) .then(setStats) .catch(() => null); }, []); // 加载叙事路线列表(南迁 / 回归) useEffect(() => { fetch(`${API_URL}/api/v1/routes`) .then((r) => r.json()) .then((d) => setRoutes(Array.isArray(d) ? d : [])) .catch(() => null); }, []); const loadRoute = useCallback(async (code: string): Promise => { try { const res = await fetch(`${API_URL}/api/v1/routes/${code}`); const data: RouteDetail = await res.json(); setActiveRoute(data); setRouteStep(1); setRoutePlaying(false); return data; } catch { setActiveRoute(null); return null; } }, []); const toggleRoute = useCallback( async (code: string) => { setRoutePlaying(false); if (activeRoute?.code === code) { setActiveRoute(null); return; } await loadRoute(code); }, [activeRoute, loadRoute] ); // 选择某件回归国宝:激活其路线 + 右栏定位到该文物 const selectRepatriation = useCallback( async (route: RouteSummary) => { setShowRepatriationPicker(false); const detail = await loadRoute(route.code); const aid = detail?.artifact_id ?? route.artifact_id; if (aid) { const p = points.find((x) => x.id === aid); if (p) { setSelected(p); setSelectedGroup(null); } } }, [loadRoute, points] ); // 路线自动播放:逐站推进 useEffect(() => { if (!routePlaying || !activeRoute) return; const total = activeRoute.stops?.length ?? 0; if (routeStep >= total) { setRoutePlaying(false); return; } const t = setTimeout(() => setRouteStep((s) => Math.min(total, s + 1)), 1500); return () => clearTimeout(t); }, [routePlaying, routeStep, activeRoute]); const fetchPoints = useCallback(async () => { const params = new URLSearchParams(); if (filterCategory) params.set("category", filterCategory); if (filterDynasty) params.set("dynasty", filterDynasty); const key = params.toString(); // 命中缓存直接用,避免地图移动时重复请求 const cached = pointsCacheRef.current.get(key); if (cached) { setPoints(cached); setPointsError(false); return; } fetchAbortRef.current?.abort(); const ctrl = new AbortController(); fetchAbortRef.current = ctrl; setPointsLoading(true); setPointsError(false); try { const res = await fetch(`${API_URL}/api/v1/map/points?${key}`, { signal: ctrl.signal }); const data: unknown = await res.json(); const arr = Array.isArray(data) ? (data as MapPoint[]) : []; pointsCacheRef.current.set(key, arr); setPoints(arr); } catch (e) { if ((e as Error).name !== "AbortError") { setPoints([]); setPointsError(true); } } finally { setPointsLoading(false); } }, [filterCategory, filterDynasty]); // 地图移动后的取数做防抖,避免频繁触发 const scheduleFetch = useCallback(() => { if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current); fetchTimerRef.current = setTimeout(() => { void fetchPoints(); }, 250); }, [fetchPoints]); useEffect(() => { fetchPoints(); }, [fetchPoints]); const cityStats = useMemo(() => { const count = points.reduce>((acc, point) => { const city = point.city || point.province || "未知"; acc[city] = (acc[city] ?? 0) + 1; return acc; }, {}); return Object.entries(count) .sort((a, b) => b[1] - a[1]) .map(([city, total]) => ({ city, total })); }, [points]); const CITY_PAGE_SIZE = 6; const cityTotalPages = Math.max(1, Math.ceil(cityStats.length / CITY_PAGE_SIZE)); const citySafePage = Math.min(cityPage, cityTotalPages - 1); const pagedCities = cityStats.slice( citySafePage * CITY_PAGE_SIZE, citySafePage * CITY_PAGE_SIZE + CITY_PAGE_SIZE ); const visiblePoints = useMemo(() => { return points.filter((point) => { const hitKeyword = searchQ ? point.name.includes(searchQ) || point.institution_name?.includes(searchQ) || point.dynasty?.includes(searchQ) || point.city?.includes(searchQ) : true; const hitCity = filterCity ? point.city === filterCity : true; return hitKeyword && hitCity; }); }, [filterCity, points, searchQ]); const groupedPoints = useMemo(() => { const useCity = zoom <= 7; const map = new Map(); visiblePoints.forEach((p) => { const key = useCity ? (p.city || p.province || `${p.lat.toFixed(1)},${p.lng.toFixed(1)}`) : (p.institution_name || `${p.lat.toFixed(3)},${p.lng.toFixed(3)}`); const label = useCity ? (p.city || p.province || "未知城市") : (p.institution_name || "未知机构"); if (!map.has(key)) { map.set(key, { key, institution_name: label, city: p.city || p.province || "未知", lat: p.lat, lng: p.lng, theme: themeOf(p), points: [], }); } const grp = map.get(key)!; grp.points.push(p); // 组内只要有海外/回归则整体按该主题着色(海外优先) const t = themeOf(p); if (t === "overseas") grp.theme = "overseas"; else if (t === "repatriated" && grp.theme !== "overseas") grp.theme = "repatriated"; }); if (useCity) { for (const g of map.values()) { if (g.points.length > 1) { g.lat = g.points.reduce((s, p) => s + p.lat, 0) / g.points.length; g.lng = g.points.reduce((s, p) => s + p.lng, 0) / g.points.length; } } } return Array.from(map.values()); }, [visiblePoints, zoom]); const institutionCount = useMemo(() => { return new Set(visiblePoints.map((p) => p.institution_name).filter(Boolean)).size; }, [visiblePoints]); const selectedCityText = selected?.city || selected?.province || "未知城市"; const noKey = !API_KEY; return (
中华文明全图鉴
Cultural Heritage Atlas
文物全图 机构直供 文明路线 知识图谱
{stats?.total_artifacts ?? points.length} 件文物 {cityStats.length} 个城市 {stats?.total_institutions ?? 0} 家机构
{noKey ? (

请配置 Google Maps API Key

NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=你的Key

API 数据已就绪:{visiblePoints.length} 个点位可展示

) : ( {!activeRoute && ( { setSelectedGroup(g); setSelected(null); }} onSingleClick={(p) => { setSelected(p); setSelectedGroup(null); }} /> )} {activeRoute && activeRoute.stops?.length > 0 && ( { setRoutePlaying(false); setRouteStep(i + 1); }} /> )} )}
{(pointsLoading || pointsError) && (
{pointsError ? ( ) : ( 加载文物点位… )}
)} {activeRoute && activeRoute.stops?.length > 0 && (
{activeRoute.type === "migration" ? : } {activeRoute.title}
{/* 可点击时间轴 */}
{activeRoute.stops.map((s, i) => { const active = i === routeStep - 1; const past = i < routeStep - 1; const c = activeRoute.color ?? "#e0b15a"; return (
{i > 0 && ( )}
); })}
{/* 播放控制 + 当前事件 */}

{activeRoute.stops[routeStep - 1]?.year_label} {activeRoute.stops[routeStep - 1]?.name} {activeRoute.stops[routeStep - 1]?.event ? ` —— ${activeRoute.stops[routeStep - 1]?.event}` : ""}

)}
{/* 左栏收起/展开 */} {/* 右栏收起/展开 */}
显示 {groupedPoints.length} {zoom <= 7 ? "个城市" : "个机构"}({institutionCount} 家机构 · {visiblePoints.length} 件文物) {filterCity && 城市:{filterCity}} {filterCategory && 门类:{CATEGORY_LABELS[filterCategory]}} {filterDynasty && 年代:{filterDynasty}}
国内馆藏 流失海外 已回归 数据来源:测试机构 seed · 用于 MVP 原型验证
{showWelfare && (
setShowWelfare(false)} className="fixed inset-0 z-[300] flex items-center justify-center bg-black/80 backdrop-blur-sm" >
e.stopPropagation()} className="w-[min(460px,90vw)] rounded-2xl border border-[#d6aa5b]/25 bg-[#0c0a06] p-6 shadow-[0_20px_60px_rgba(0,0,0,0.6)]" >
支持文物保护公益

「留下来的这些瑰宝一定要千方百计呵护好、珍惜好,把我们这个世界上唯一没有中断的文明继续传承下去。」

本平台秉持公益优先:在展览中特别设立公益专场,并从衍生品销售中预留特定比例,用于支持文物修复、文物南迁史料整理与国宝回归等公益文保活动。让更多人参与守护文明火种。

MVP 原型 · 公益模块为示意,暂未接入真实捐助

)} {showRepatriationPicker && (
setShowRepatriationPicker(false)} className="fixed inset-0 z-[300] flex items-center justify-center bg-black/80 backdrop-blur-sm" >
e.stopPropagation()} className="flex max-h-[80vh] w-[min(560px,92vw)] flex-col rounded-2xl border border-[#3f9e6a]/35 bg-[#0c0a06] shadow-[0_20px_60px_rgba(0,0,0,0.6)]" >
国宝海外回归 · 选择文物
{/* 门类筛选 */}
{Array.from( new Set( routes .filter((r) => r.type === "repatriation" && r.artifact_category) .map((r) => r.artifact_category as string) ) ).map((cat) => ( ))}
{/* 文物列表 */}
{routes .filter( (r) => r.type === "repatriation" && (repatPickerCat === "" || r.artifact_category === repatPickerCat) ) .map((r) => ( ))}
选定后将展示该文物的回归之路,并在右侧定位到它
)}
); }