'use client'; import { useEffect, useRef, useState, useCallback } from 'react'; import maplibregl from 'maplibre-gl'; import type { FeatureCollection } from 'geojson'; import { Navbar } from '@/components/ui/Navbar'; import { MapControls } from '@/components/map/MapControls'; import { LayerPanel, LayerState } from '@/components/sidebar/LayerPanel'; import { relicsData, dynastyColors, categoryIcons } from '@/data/relics'; // 朝代颜色映射 const colors = dynastyColors; // 种类形状映射(使用 MapLibre 符号图层实现不同图标) const categories = ['painting', 'sculpture', 'bronze', 'porcelain', 'jade', 'calligraphy', 'textile', 'gold', 'lacquer', 'ceramic']; const dynasties = Object.keys(colors); const defaultLayerState: LayerState = { dynasties: Object.fromEntries(dynasties.map((d) => [d, true])), categories: Object.fromEntries(categories.map((c) => [c, true])), clusters: true, labels: true, }; export function MapContainer() { const mapContainer = useRef(null); const map = useRef(null); const [layerState, setLayerState] = useState(defaultLayerState); const [loaded, setLoaded] = useState(false); const applyFilters = useCallback(() => { if (!map.current || !loaded) return; const visibleDynasties = dynasties.filter((d) => layerState.dynasties[d]); const visibleCategories = categories.filter((c) => layerState.categories[c]); categories.forEach((category) => { const layerId = `relics-${category}`; if (!map.current?.getLayer(layerId)) return; const categoryVisible = visibleCategories.includes(category); const visibility = categoryVisible ? 'visible' : 'none'; map.current.setLayoutProperty(layerId, 'visibility', visibility); if (categoryVisible) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const filter: any = ['all', ['==', ['get', 'category'], category], ['!', ['has', 'point_count']]]; if (visibleDynasties.length > 0 && visibleDynasties.length < dynasties.length) { filter.push(['match', ['get', 'dynasty'], visibleDynasties, true, false]); } map.current.setFilter(layerId, filter); } }); ['clusters', 'cluster-count'].forEach((id) => { if (!map.current?.getLayer(id)) return; map.current.setLayoutProperty(id, 'visibility', layerState.clusters ? 'visible' : 'none'); }); }, [layerState, loaded]); useEffect(() => { applyFilters(); }, [applyFilters]); useEffect(() => { if (!mapContainer.current || map.current) return; // 初始化地图(3D 地球模式 + 星空背景) // 使用 CARTO dark-matter vector 样式(与 OpenGridWorks 相同) map.current = new maplibregl.Map({ container: mapContainer.current, style: 'https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json', center: [30, 25], zoom: 1.8, pitch: 0, minZoom: 0.5, maxZoom: 18, maxPitch: 85, }); // 样式加载完成后设置 globe 投影和星空背景 map.current.on('style.load', () => { if (!map.current) return; // 设置地球投影 map.current.setProjection({ type: 'globe' }); // 设置星空背景(纯黑太空) map.current.setSky({ 'sky-color': '#06060c', 'sky-horizon-blend': 0.0, 'horizon-color': '#06060c', 'horizon-fog-blend': 0.0, }); // 隐藏 CARTO 背景层,让星空 canvas 在太空区域透出 if (map.current.getLayer('background')) { map.current.setLayoutProperty('background', 'visibility', 'none'); } // 添加海洋填充层:覆盖全球的多边形,只在地球表面渲染 // 这样海洋不透明,而太空区域仍然透明显示星空 map.current.addSource('ocean-fill', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[-180, -85], [180, -85], [180, 85], [-180, 85], [-180, -85]]], }, properties: {}, }, }); // 在所有现有图层之前插入海洋填充层 const firstLayerId = map.current.getStyle().layers[0]?.id; map.current.addLayer({ id: 'ocean-fill', type: 'fill', source: 'ocean-fill', paint: { 'fill-color': '#0a0e1a', }, }, firstLayerId); }); // 地图加载完成后添加数据 map.current.on('load', () => { if (!map.current) return; // 添加经纬网格线(graticule) const graticuleData = generateGraticule(30); map.current.addSource('graticule', { type: 'geojson', data: graticuleData as any, }); // 主要网格线(30°间隔) map.current.addLayer({ id: 'graticule-main', type: 'line', source: 'graticule', paint: { 'line-color': '#1e3a5f', 'line-width': 0.8, 'line-opacity': 0.5, }, }); // 添加更细的网格线(10°间隔) const fineGraticule = generateGraticule(10, 30); map.current.addSource('graticule-fine', { type: 'geojson', data: fineGraticule as any, }); map.current.addLayer({ id: 'graticule-fine', type: 'line', source: 'graticule-fine', paint: { 'line-color': '#15294a', 'line-width': 0.4, 'line-opacity': 0.3, }, layout: { 'visibility': 'visible', }, }); // 网格标签 const graticuleLabels = generateGraticuleLabels(30); map.current.addSource('graticule-labels', { type: 'geojson', data: graticuleLabels as any, }); map.current.addLayer({ id: 'graticule-labels', type: 'symbol', source: 'graticule-labels', layout: { 'text-field': ['get', 'label'], 'text-size': 10, 'text-anchor': 'center', 'text-allow-overlap': true, 'text-font': ['Open Sans Semibold', 'Arial Unicode MS Bold'], }, paint: { 'text-color': '#3b6ea5', 'text-opacity': 0.6, }, }); // 添加文物数据源 map.current.addSource('relics', { type: 'geojson', data: relicsData as any, cluster: true, clusterMaxZoom: 8, clusterRadius: 60, }); // 为每个种类添加单独的图层(不同形状) categories.forEach((category) => { // 非聚类点图层 map.current!.addLayer({ id: `relics-${category}`, type: 'symbol', source: 'relics', filter: ['all', ['==', ['get', 'category'], category], ['!', ['has', 'point_count']] ], layout: { 'icon-image': `relic-${category}`, 'icon-size': 1.2, 'icon-allow-overlap': true, 'visibility': 'visible', }, paint: { 'icon-color': [ 'case', ...Object.entries(colors).flatMap(([dyn, col]) => [ ['==', ['get', 'dynasty'], dyn], col ]), '#888888' ], } as any // eslint-disable-line @typescript-eslint/no-explicit-any }); }); // 添加聚类图层 map.current.addLayer({ id: 'clusters', type: 'circle', source: 'relics', filter: ['has', 'point_count'], paint: { 'circle-color': [ 'step', ['get', 'point_count'], '#4a5568', 5, '#2d3748', 10, '#1a202c', ], 'circle-radius': ['step', ['get', 'point_count'], 20, 5, 28, 10, 36], 'circle-opacity': 0.85, 'circle-stroke-width': 2, 'circle-stroke-color': '#a0aec0', }, }); // 聚类数量 map.current.addLayer({ id: 'cluster-count', type: 'symbol', source: 'relics', filter: ['has', 'point_count'], layout: { 'text-field': '{point_count_abbreviated}', 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-size': 14, }, paint: { 'text-color': '#ffffff', }, }); // 创建自定义图标 createRelicIcons(map.current!); setLoaded(true); // 点击聚类放大 map.current.on('click', 'clusters', (e) => { if (!map.current) return; const features = map.current.queryRenderedFeatures(e.point, { layers: ['clusters'] }); const clusterId = features[0].properties.cluster_id; (map.current.getSource('relics') as maplibregl.GeoJSONSource) .getClusterExpansionZoom(clusterId) .then((zoom) => { if (!map.current) return; map.current.easeTo({ center: (features[0].geometry as any).coordinates, zoom: zoom, }); }); }); // 点击文物显示详情 map.current.on('click', (e) => { if (!map.current) return; const features = map.current.queryRenderedFeatures(e.point, { layers: categories.map(c => `relics-${c}`) }); if (features.length === 0) return; const props = features[0].properties; const coordinates = (features[0].geometry as any).coordinates.slice(); const name = JSON.parse(props.name); const museum = JSON.parse(props.museum); const currentLocation = JSON.parse(props.currentLocation); const dynastyName = getDynastyName(props.dynasty); const categoryName = getCategoryName(props.category); const color = colors[props.dynasty] || '#888888'; const icon = categoryIcons[props.category] || '●'; const html = `
${icon}

${name.zh}

${name.en}

${dynastyName} ${categoryName}

收藏地 ${currentLocation.zh}

机构 ${museum.zh}

${props.year ? `

年代${props.year}

` : ''}
`; new maplibregl.Popup({ offset: 28, closeButton: true, maxWidth: '340px', className: 'heritage-popup' }) .setLngLat(coordinates) .setHTML(html) .addTo(map.current); }); // 鼠标样式 categories.forEach(cat => { map.current!.on('mouseenter', `relics-${cat}`, () => { if (map.current) map.current.getCanvas().style.cursor = 'pointer'; }); map.current!.on('mouseleave', `relics-${cat}`, () => { if (map.current) map.current.getCanvas().style.cursor = ''; }); }); map.current.on('mouseenter', 'clusters', () => { if (map.current) map.current.getCanvas().style.cursor = 'pointer'; }); map.current.on('mouseleave', 'clusters', () => { if (map.current) map.current.getCanvas().style.cursor = ''; }); }); return () => { if (map.current) { map.current.remove(); map.current = null; } }; }, []); const handleZoomIn = () => map.current?.zoomIn(); const handleZoomOut = () => map.current?.zoomOut(); const handleLocate = () => { if (!map.current) return; navigator.geolocation?.getCurrentPosition( (pos) => { map.current?.flyTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 5, }); }, () => { map.current?.flyTo({ center: [105, 35], zoom: 4 }); } ); }; const handleSpotlight = () => { // Placeholder: could enable circle selection mode if (!map.current) return; map.current.getCanvas().style.cursor = 'crosshair'; setTimeout(() => { if (map.current) map.current.getCanvas().style.cursor = ''; }, 3000); }; const handleSearch = (query: string) => { if (!map.current) return; const features = (relicsData as any).features; const match = features.find((f: any) => { const name = f.properties.name; return ( name.zh.includes(query) || name.en.toLowerCase().includes(query.toLowerCase()) || f.properties.museum.zh.includes(query) || f.properties.museum.en.toLowerCase().includes(query.toLowerCase()) ); }); if (match) { map.current.flyTo({ center: match.geometry.coordinates, zoom: 8, }); } }; const handleReset = () => setLayerState(defaultLayerState); return (
); } // 动态星空背景组件 function Starfield() { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; let animationId: number; let running = true; // eslint-disable-next-line @typescript-eslint/no-explicit-any let stars: any[] = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any let shootingStars: any[] = []; // 按颜色分组,减少 fillStyle 切换 const colorGroups = [ { rgb: '255, 255, 255', indices: [] as number[] }, { rgb: '200, 220, 255', indices: [] as number[] }, { rgb: '255, 240, 200', indices: [] as number[] }, { rgb: '220, 230, 255', indices: [] as number[] }, ]; const resize = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight; const count = Math.floor((canvas.width * canvas.height) / 3500); colorGroups.forEach(g => g.indices = []); stars = Array.from({ length: count }, (_, i) => { const ci = Math.floor(Math.random() * colorGroups.length); colorGroups[ci].indices.push(i); return { x: Math.random() * canvas.width, y: Math.random() * canvas.height, r: Math.random() * 1.5 + 0.3, baseOpacity: Math.random() * 0.7 + 0.3, twinkleSpeed: Math.random() * 0.04 + 0.01, twinklePhase: Math.random() * Math.PI * 2, vx: (Math.random() - 0.5) * 0.03, vy: (Math.random() - 0.5) * 0.03, ci, }; }); }; resize(); window.addEventListener('resize', resize); // 标签页隐藏时暂停动画 const onVisibility = () => { if (document.hidden) { running = false; if (animationId) cancelAnimationFrame(animationId); } else if (!running) { running = true; render(); } }; document.addEventListener('visibilitychange', onVisibility); let frame = 0; const render = () => { if (!running) return; ctx.clearRect(0, 0, canvas.width, canvas.height); // 按颜色分组绘制星星,减少 fillStyle 切换 for (const group of colorGroups) { ctx.fillStyle = `rgba(${group.rgb}, 1)`; for (const i of group.indices) { const star = stars[i]; star.twinklePhase += star.twinkleSpeed; star.x += star.vx; star.y += star.vy; if (star.x < 0) star.x = canvas.width; if (star.x > canvas.width) star.x = 0; if (star.y < 0) star.y = canvas.height; if (star.y > canvas.height) star.y = 0; const alpha = star.baseOpacity * (0.2 + 0.8 * (0.5 + 0.5 * Math.sin(star.twinklePhase))); ctx.globalAlpha = alpha; ctx.beginPath(); ctx.arc(star.x, star.y, star.r, 0, Math.PI * 2); ctx.fill(); if (star.r > 1.0) { ctx.globalAlpha = alpha * 0.15; ctx.beginPath(); ctx.arc(star.x, star.y, star.r * 2.5, 0, Math.PI * 2); ctx.fill(); } } } ctx.globalAlpha = 1; // 随机生成流星 frame++; if (frame % 200 === 0 && Math.random() < 0.7) { const startX = Math.random() * canvas.width; const startY = Math.random() * canvas.height * 0.5; const angle = Math.PI / 4 + (Math.random() - 0.5) * 0.3; const speed = 8 + Math.random() * 6; shootingStars.push({ x: startX, y: startY, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 0, maxLife: 40 + Math.random() * 20, length: 80 + Math.random() * 60, }); } // 绘制流星 shootingStars = shootingStars.filter(s => s.life < s.maxLife); for (const s of shootingStars) { s.life++; s.x += s.vx; s.y += s.vy; const progress = s.life / s.maxLife; const alpha = progress < 0.3 ? progress / 0.3 : 1 - (progress - 0.3) / 0.7; const tailX = s.x - s.vx / Math.hypot(s.vx, s.vy) * s.length; const tailY = s.y - s.vy / Math.hypot(s.vx, s.vy) * s.length; const gradient = ctx.createLinearGradient(s.x, s.y, tailX, tailY); gradient.addColorStop(0, `rgba(255, 255, 255, ${alpha})`); gradient.addColorStop(0.5, `rgba(200, 220, 255, ${alpha * 0.5})`); gradient.addColorStop(1, 'rgba(200, 220, 255, 0)'); ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineTo(tailX, tailY); ctx.strokeStyle = gradient; ctx.lineWidth = 1.5; ctx.stroke(); ctx.beginPath(); ctx.arc(s.x, s.y, 1.5, 0, Math.PI * 2); ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`; ctx.fill(); } animationId = requestAnimationFrame(render); }; render(); return () => { running = false; if (animationId) cancelAnimationFrame(animationId); window.removeEventListener('resize', resize); document.removeEventListener('visibilitychange', onVisibility); }; }, []); return ( ); } // 生成经纬网格线 GeoJSON function generateGraticule(interval: number, skipMultiples?: number): FeatureCollection { const features: any[] = []; const skip = skipMultiples || 0; // 经线 (meridians) for (let lon = -180; lon <= 180; lon += interval) { if (skip > 0 && lon % skip === 0) continue; const coords: [number, number][] = []; for (let lat = -85; lat <= 85; lat += 5) { coords.push([lon, lat]); } features.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { type: 'meridian', value: lon }, }); } // 纬线 (parallels) for (let lat = -80; lat <= 80; lat += interval) { if (skip > 0 && lat % skip === 0) continue; const coords: [number, number][] = []; for (let lon = -180; lon <= 180; lon += 5) { coords.push([lon, lat]); } features.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { type: 'parallel', value: lat }, }); } return { type: 'FeatureCollection', features }; } // 生成网格标签 GeoJSON function generateGraticuleLabels(interval: number): FeatureCollection { const features: any[] = []; // 经度标签(赤道附近) for (let lon = -180; lon <= 180; lon += interval) { features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [lon, 0] }, properties: { label: lon === 0 ? '0°' : lon > 0 ? `${lon}°E` : `${Math.abs(lon)}°W`, }, }); } // 纬度标签(本初子午线附近) for (let lat = -60; lat <= 60; lat += interval) { if (lat === 0) continue; features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [0, lat] }, properties: { label: lat > 0 ? `${lat}°N` : `${Math.abs(lat)}°S`, }, }); } return { type: 'FeatureCollection', features }; } // 种类名称映射 function getCategoryName(category: string): string { const names: Record = { painting: '书画', sculpture: '雕塑', bronze: '青铜', porcelain: '瓷器', jade: '玉器', calligraphy: '书法', textile: '织物', gold: '金银', lacquer: '漆器', ceramic: '陶器' }; return names[category] || category; } // 朝代名称映射 function getDynastyName(dynasty: string): string { const names: Record = { shang: '商', zhou: '周', qin: '秦', han: '汉', tang: '唐', song: '宋', yuan: '元', ming: '明', qing: '清' }; return names[dynasty] || dynasty; } // 创建自定义图标 function createRelicIcons(map: maplibregl.Map) { const size = 40; // 为每个种类创建不同形状的图标 categories.forEach(category => { const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d')!; ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#000000'; ctx.lineWidth = 2; switch (category) { case 'painting': // 方块 ctx.fillRect(8, 8, 24, 24); ctx.strokeRect(8, 8, 24, 24); break; case 'sculpture': // 三角 ctx.beginPath(); ctx.moveTo(20, 6); ctx.lineTo(34, 34); ctx.lineTo(6, 34); ctx.closePath(); ctx.fill(); ctx.stroke(); break; case 'bronze': // 菱形 ctx.beginPath(); ctx.moveTo(20, 4); ctx.lineTo(36, 20); ctx.lineTo(20, 36); ctx.lineTo(4, 20); ctx.closePath(); ctx.fill(); ctx.stroke(); break; case 'porcelain': // 圆点 ctx.beginPath(); ctx.arc(20, 20, 14, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); break; case 'jade': // 六边形 drawHexagon(ctx, 20, 20, 14); ctx.fill(); ctx.stroke(); break; case 'calligraphy': // 星形 drawStar(ctx, 20, 20, 5, 14, 7); ctx.fill(); ctx.stroke(); break; case 'textile': // 菱星 drawDiamondStar(ctx, 20, 20, 12); ctx.fill(); ctx.stroke(); break; case 'gold': // 八边 drawOctagon(ctx, 20, 20, 14); ctx.fill(); ctx.stroke(); break; case 'lacquer': // 双菱 drawDoubleDiamond(ctx, 20, 20, 12); ctx.fill(); ctx.stroke(); break; case 'ceramic': // 空心圆 ctx.beginPath(); ctx.arc(20, 20, 14, 0, Math.PI * 2); ctx.stroke(); break; } const imageData = ctx.getImageData(0, 0, size, size); map.addImage(`relic-${category}`, { width: size, height: size, data: imageData.data as any }); }); } function drawHexagon(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) { ctx.beginPath(); for (let i = 0; i < 6; i++) { const angle = (Math.PI / 3) * i - Math.PI / 2; const x = cx + r * Math.cos(angle); const y = cy + r * Math.sin(angle); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); } function drawStar(ctx: CanvasRenderingContext2D, cx: number, cy: number, points: number, outer: number, inner: number) { ctx.beginPath(); for (let i = 0; i < points * 2; i++) { const r = i % 2 === 0 ? outer : inner; const angle = (Math.PI / points) * i - Math.PI / 2; const x = cx + r * Math.cos(angle); const y = cy + r * Math.sin(angle); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); } function drawDiamondStar(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) { ctx.beginPath(); ctx.moveTo(cx, cy - r); ctx.lineTo(cx + r, cy); ctx.lineTo(cx, cy + r); ctx.lineTo(cx - r, cy); ctx.closePath(); ctx.moveTo(cx, cy - r * 0.6); ctx.lineTo(cx + r * 0.6, cy); ctx.lineTo(cx, cy + r * 0.6); ctx.lineTo(cx - r * 0.6, cy); ctx.closePath(); } function drawOctagon(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) { ctx.beginPath(); for (let i = 0; i < 8; i++) { const angle = (Math.PI / 4) * i - Math.PI / 8; const x = cx + r * Math.cos(angle); const y = cy + r * Math.sin(angle); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); } function drawDoubleDiamond(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) { ctx.beginPath(); ctx.moveTo(cx, cy - r); ctx.lineTo(cx + r * 0.7, cy); ctx.lineTo(cx, cy + r); ctx.lineTo(cx - r * 0.7, cy); ctx.closePath(); ctx.strokeRect(cx - r * 0.5, cy - r * 0.5, r, r); }