Files
heritage-globe/components/map/MapContainer.tsx
T
freedakgmail a550d6d0fd feat: Heritage Globe - 中国文物全球分布地图
- Next.js + MapLibre GL 3D 地球
- CARTO dark-matter vector 底图
- 动态星空背景(闪烁、漂移、流星)
- 经纬网格线(graticule)
- 文物数据图层与朝代/类别筛选
- 中国风信息弹窗
- 中英双语 i18n
- LayerPanel + MapControls 浮动面板
2026-07-01 01:37:18 +08:00

822 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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);
}