a550d6d0fd
- Next.js + MapLibre GL 3D 地球 - CARTO dark-matter vector 底图 - 动态星空背景(闪烁、漂移、流星) - 经纬网格线(graticule) - 文物数据图层与朝代/类别筛选 - 中国风信息弹窗 - 中英双语 i18n - LayerPanel + MapControls 浮动面板
822 lines
27 KiB
TypeScript
822 lines
27 KiB
TypeScript
'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<HTMLDivElement>(null);
|
||
const map = useRef<maplibregl.Map | null>(null);
|
||
const [layerState, setLayerState] = useState<LayerState>(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 = `
|
||
<div style="min-width: 260px; max-width: 320px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; color: #f5f0e8;">
|
||
<div style="display: flex; align-items: flex-start; gap: 10px; margin-bottom: 10px;">
|
||
<div style="display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: 50%; background: linear-gradient(135deg, ${color}22, ${color}44); border: 1px solid ${color}66; flex-shrink: 0;">
|
||
<span style="font-size: 20px; filter: drop-shadow(0 1px 2px rgba(0,0,0,0.4));">${icon}</span>
|
||
</div>
|
||
<div style="flex: 1; min-width: 0;">
|
||
<h3 style="margin: 0 0 4px 0; font-size: 17px; font-weight: 600; color: #faf6ef; letter-spacing: 0.04em; line-height: 1.3;">${name.zh}</h3>
|
||
<p style="margin: 0; font-size: 12px; color: #b0a89a; font-style: italic; letter-spacing: 0.02em;">${name.en}</p>
|
||
</div>
|
||
</div>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px;">
|
||
<span style="background: ${color}33; color: ${color}; border: 1px solid ${color}66; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 500;">${dynastyName}</span>
|
||
<span style="background: rgba(251, 191, 36, 0.12); color: #fbbf24; border: 1px solid rgba(251, 191, 36, 0.35); padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 500;">${categoryName}</span>
|
||
</div>
|
||
<div style="border-top: 1px solid rgba(255, 255, 255, 0.1); padding-top: 10px; font-size: 13px; color: #d9d0c3; line-height: 1.7;">
|
||
<p style="margin: 5px 0; display: flex; gap: 6px;">
|
||
<span style="color: #a89a88; flex-shrink: 0;">收藏地</span>
|
||
<span style="color: #f5f0e8;">${currentLocation.zh}</span>
|
||
</p>
|
||
<p style="margin: 5px 0; display: flex; gap: 6px;">
|
||
<span style="color: #a89a88; flex-shrink: 0;">机构</span>
|
||
<span style="color: #f5f0e8;">${museum.zh}</span>
|
||
</p>
|
||
${props.year ? `<p style="margin: 5px 0; display: flex; gap: 6px;"><span style="color: #a89a88; flex-shrink: 0;">年代</span><span style="color: #f5f0e8;">${props.year}</span></p>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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 (
|
||
<div className="relative w-full h-full">
|
||
<Starfield />
|
||
<Navbar />
|
||
<MapControls
|
||
onSearch={handleSearch}
|
||
onZoomIn={handleZoomIn}
|
||
onZoomOut={handleZoomOut}
|
||
onLocate={handleLocate}
|
||
onSpotlight={handleSpotlight}
|
||
/>
|
||
<LayerPanel state={layerState} onChange={setLayerState} onReset={handleReset} />
|
||
<div ref={mapContainer} className="w-full h-full relative z-0" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 动态星空背景组件
|
||
function Starfield() {
|
||
const canvasRef = useRef<HTMLCanvasElement>(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 (
|
||
<canvas
|
||
ref={canvasRef}
|
||
className="absolute inset-0 z-0 pointer-events-none"
|
||
style={{ background: '#06060c', willChange: 'transform' }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// 生成经纬网格线 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<string, string> = {
|
||
painting: '书画', sculpture: '雕塑', bronze: '青铜', porcelain: '瓷器',
|
||
jade: '玉器', calligraphy: '书法', textile: '织物', gold: '金银',
|
||
lacquer: '漆器', ceramic: '陶器'
|
||
};
|
||
return names[category] || category;
|
||
}
|
||
|
||
// 朝代名称映射
|
||
function getDynastyName(dynasty: string): string {
|
||
const names: Record<string, string> = {
|
||
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);
|
||
} |