feat: Heritage Globe - 中国文物全球分布地图

- Next.js + MapLibre GL 3D 地球
- CARTO dark-matter vector 底图
- 动态星空背景(闪烁、漂移、流星)
- 经纬网格线(graticule)
- 文物数据图层与朝代/类别筛选
- 中国风信息弹窗
- 中英双语 i18n
- LayerPanel + MapControls 浮动面板
This commit is contained in:
freedakgmail
2026-07-01 01:37:18 +08:00
commit a550d6d0fd
35 changed files with 11760 additions and 0 deletions
+822
View File
@@ -0,0 +1,822 @@
'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);
}
+103
View File
@@ -0,0 +1,103 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Search, Target, MapPin, Plus, Minus, X } from 'lucide-react';
interface MapControlsProps {
onSearch?: (query: string) => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
onLocate?: () => void;
onSpotlight?: () => void;
}
export function MapControls({
onSearch,
onZoomIn,
onZoomOut,
onLocate,
onSpotlight,
}: MapControlsProps) {
const t = useTranslations('map');
const [searchOpen, setSearchOpen] = useState(false);
const [query, setQuery] = useState('');
const submitSearch = () => {
if (onSearch && query.trim()) {
onSearch(query.trim());
}
};
return (
<div className="absolute top-16 left-4 z-10 flex flex-col gap-2">
{/* Search */}
<div className="flex flex-col gap-2">
<button
onClick={() => setSearchOpen(!searchOpen)}
className="w-9 h-9 rounded-lg bg-[#06060c]/90 backdrop-blur-md border border-[var(--map-border)] flex items-center justify-center text-[var(--map-text-muted)] hover:text-white hover:bg-white/10 transition-colors shadow-lg"
aria-label="Open search"
>
{searchOpen ? <X className="w-4 h-4" /> : <Search className="w-4 h-4" />}
</button>
{searchOpen && (
<div className="flex items-center gap-1 p-1 rounded-lg bg-[#06060c]/90 backdrop-blur-md border border-[var(--map-border)] shadow-lg">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submitSearch()}
placeholder={t('search')}
className="w-48 px-2 py-1.5 bg-transparent text-sm text-[var(--map-text)] placeholder:text-[var(--map-text-muted)] focus:outline-none"
autoFocus
/>
<button
onClick={submitSearch}
className="w-7 h-7 rounded-md bg-[var(--map-panel)] hover:bg-[var(--map-panel-hover)] flex items-center justify-center text-[var(--map-text-muted)] hover:text-white transition-colors"
>
<Search className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
{/* Spotlight selection */}
<button
onClick={onSpotlight}
className="w-9 h-9 rounded-lg bg-[#06060c]/90 backdrop-blur-md border border-[var(--map-border)] flex items-center justify-center text-[var(--map-text-muted)] hover:text-white hover:bg-white/10 transition-colors shadow-lg"
aria-label="Enable spotlight selection"
>
<Target className="w-4 h-4" />
</button>
{/* Zoom to my location */}
<button
onClick={onLocate}
className="w-9 h-9 rounded-lg bg-[#06060c]/90 backdrop-blur-md border border-[var(--map-border)] flex items-center justify-center text-[var(--map-text-muted)] hover:text-white hover:bg-white/10 transition-colors shadow-lg"
aria-label="Zoom to my location"
>
<MapPin className="w-4 h-4" />
</button>
{/* Zoom in/out */}
<div className="flex flex-col rounded-lg bg-[#06060c]/90 backdrop-blur-md border border-[var(--map-border)] shadow-lg overflow-hidden">
<button
onClick={onZoomIn}
className="w-9 h-9 flex items-center justify-center text-[var(--map-text-muted)] hover:text-white hover:bg-white/10 transition-colors"
aria-label={t('zoomIn')}
>
<Plus className="w-4 h-4" />
</button>
<div className="h-px bg-[var(--map-border)]" />
<button
onClick={onZoomOut}
className="w-9 h-9 flex items-center justify-center text-[var(--map-text-muted)] hover:text-white hover:bg-white/10 transition-colors"
aria-label={t('zoomOut')}
>
<Minus className="w-4 h-4" />
</button>
</div>
</div>
);
}