import { useMemo, useEffect, useRef } from 'react' import Map, { Source, Layer } from 'react-map-gl' import type { MapRef } from 'react-map-gl' import type { Map as MapboxMap } from 'mapbox-gl' import 'mapbox-gl/dist/mapbox-gl.css' import { useReplaySituation } from '@/hooks/useReplaySituation' import { config } from '@/config' import { ATTACKED_TARGETS, ALLIED_STRIKE_LOCATIONS, LINCOLN_COORDS, LINCOLN_STRIKE_TARGETS, FORD_COORDS, FORD_STRIKE_TARGETS, ISRAEL_STRIKE_SOURCE, ISRAEL_STRIKE_TARGETS, } from '@/data/mapLocations' const MAPBOX_TOKEN = config.mapboxAccessToken || '' // 相关区域 bbox:伊朗、以色列、胡塞区 (minLng, minLat, maxLng, maxLat),覆盖红蓝区域 const THEATER_BBOX = [22, 11, 64, 41] as const const THEATER_CENTER = { longitude: (THEATER_BBOX[0] + THEATER_BBOX[2]) / 2, latitude: (THEATER_BBOX[1] + THEATER_BBOX[3]) / 2, } const DEFAULT_VIEW = { ...THEATER_CENTER, zoom: 4.2 } const COUNTRIES_GEOJSON = 'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson' // 胡塞武装失控区 [lng, lat] 闭环 const HOUTHI_POLYGON: [number, number][] = [ [42.7, 15.8], [43.3, 16.5], [45.1, 17.2], [45.8, 15.1], [44.2, 13.5], [42.7, 15.8], ] const IRAN_ADMIN = 'Iran' const ALLIES_ADMIN = [ 'Qatar', 'Bahrain', 'Kuwait', 'United Arab Emirates', 'Saudi Arabia', 'Iraq', 'Syria', 'Jordan', 'Turkey', 'Israel', 'Oman', 'Egypt', 'Djibouti', ] // 伊朗攻击源 德黑兰 [lng, lat] const TEHRAN_SOURCE: [number, number] = [51.389, 35.6892] /** 二次贝塞尔曲线路径,更平滑的弧线 height 控制弧高 */ function parabolaPath( start: [number, number], end: [number, number], height = 3 ): [number, number][] { const ctrl: [number, number] = [ (start[0] + end[0]) / 2, (start[1] + end[1]) / 2 + height, ] // 生成多段点使曲线更平滑 const pts: [number, number][] = [start] for (let i = 1; i < 12; i++) { const s = i / 12 const t = 1 - s const x = t * t * start[0] + 2 * t * s * ctrl[0] + s * s * end[0] const y = t * t * start[1] + 2 * t * s * ctrl[1] + s * s * end[1] pts.push([x, y]) } pts.push(end) return pts } /** 沿路径插值,t ∈ [0,1],支持多点路径 */ function interpolateOnPath(path: [number, number][], t: number): [number, number] { if (t <= 0) return path[0] if (t >= 1) return path[path.length - 1] const n = path.length - 1 const seg = Math.min(Math.floor(t * n), n - 1) const u = (t * n) - seg const a = path[seg] const b = path[seg + 1] return [a[0] + u * (b[0] - a[0]), a[1] + u * (b[1] - a[1])] } type BaseStatus = 'operational' | 'damaged' | 'attacked' interface KeyLoc { name: string lat: number lng: number type?: string status?: BaseStatus damage_level?: number } function toFeature(loc: KeyLoc, side: 'us' | 'iran', status?: BaseStatus) { return { type: 'Feature' as const, properties: { side, name: loc.name, status: status ?? (loc as KeyLoc & { status?: BaseStatus }).status ?? 'operational', }, geometry: { type: 'Point' as const, coordinates: [loc.lng, loc.lat] as [number, number], }, } } const FLIGHT_DURATION_MS = 2500 // 光点飞行单程时间 export function WarMap() { const mapRef = useRef(null) const animRef = useRef(0) const startRef = useRef(0) const attackPathsRef = useRef<[number, number][][]>([]) const lincolnPathsRef = useRef<[number, number][][]>([]) const fordPathsRef = useRef<[number, number][][]>([]) const israelPathsRef = useRef<[number, number][][]>([]) const situation = useReplaySituation() const { usForces, iranForces, conflictEvents = [] } = situation const usLocs = (usForces.keyLocations || []) as KeyLoc[] const irLocs = (iranForces.keyLocations || []) as KeyLoc[] const { usNaval, usBaseOp, usBaseDamaged, usBaseAttacked, labelsGeoJson } = useMemo(() => { const naval: GeoJSON.Feature[] = [] const op: GeoJSON.Feature[] = [] const damaged: GeoJSON.Feature[] = [] const attacked: GeoJSON.Feature[] = [] const labels: GeoJSON.Feature[] = [] for (const loc of usLocs as KeyLoc[]) { const f = toFeature(loc, 'us') labels.push({ ...f, properties: { ...f.properties, name: loc.name } }) if (loc.type === 'Base') { const s = (loc.status ?? 'operational') as BaseStatus if (s === 'attacked') attacked.push(f) else if (s === 'damaged') damaged.push(f) else op.push(f) } else { naval.push(f) } } for (const loc of irLocs) { const f = toFeature(loc, 'iran') labels.push({ ...f, properties: { ...f.properties, name: loc.name } }) } return { usNaval: { type: 'FeatureCollection' as const, features: naval }, usBaseOp: { type: 'FeatureCollection' as const, features: op }, usBaseDamaged: { type: 'FeatureCollection' as const, features: damaged }, usBaseAttacked: { type: 'FeatureCollection' as const, features: attacked }, labelsGeoJson: { type: 'FeatureCollection' as const, features: labels }, } }, [usForces.keyLocations, iranForces.keyLocations]) // 德黑兰到 27 个被袭目标的攻击路径(静态线条) const attackPaths = useMemo( () => ATTACKED_TARGETS.map((target) => parabolaPath(TEHRAN_SOURCE, target as [number, number])), [] ) attackPathsRef.current = attackPaths const lincolnPaths = useMemo( () => LINCOLN_STRIKE_TARGETS.map((t) => parabolaPath(LINCOLN_COORDS, t)), [] ) const fordPaths = useMemo( () => FORD_STRIKE_TARGETS.map((t) => parabolaPath(FORD_COORDS, t)), [] ) const israelPaths = useMemo( () => ISRAEL_STRIKE_TARGETS.map((t) => parabolaPath(ISRAEL_STRIKE_SOURCE, t)), [] ) lincolnPathsRef.current = lincolnPaths fordPathsRef.current = fordPaths israelPathsRef.current = israelPaths const lincolnLinesGeoJson = useMemo( () => ({ type: 'FeatureCollection' as const, features: lincolnPaths.map((coords) => ({ type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates: coords }, })), }), [lincolnPaths] ) const fordLinesGeoJson = useMemo( () => ({ type: 'FeatureCollection' as const, features: fordPaths.map((coords) => ({ type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates: coords }, })), }), [fordPaths] ) const israelLinesGeoJson = useMemo( () => ({ type: 'FeatureCollection' as const, features: israelPaths.map((coords) => ({ type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates: coords }, })), }), [israelPaths] ) const attackLinesGeoJson = useMemo( () => ({ type: 'FeatureCollection' as const, features: attackPaths.map((coords) => ({ type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates: coords }, })), }), [attackPaths] ) // GDELT 冲突事件:1–3 绿, 4–6 橙闪, 7–10 红脉 const { conflictEventsGreen, conflictEventsOrange, conflictEventsRed } = useMemo(() => { const green: GeoJSON.Feature[] = [] const orange: GeoJSON.Feature[] = [] const red: GeoJSON.Feature[] = [] for (const e of conflictEvents) { const score = e.impact_score ?? 1 const f: GeoJSON.Feature = { type: 'Feature', properties: { event_id: e.event_id, impact_score: score }, geometry: { type: 'Point', coordinates: [e.lng, e.lat] }, } if (score <= 3) green.push(f) else if (score <= 6) orange.push(f) else red.push(f) } return { conflictEventsGreen: { type: 'FeatureCollection' as const, features: green }, conflictEventsOrange: { type: 'FeatureCollection' as const, features: orange }, conflictEventsRed: { type: 'FeatureCollection' as const, features: red }, } }, [conflictEvents]) const hideNonBelligerentLabels = (map: MapboxMap) => { const labelLayers = [ 'country-label', 'state-label', 'place-label', 'place-label-capital', 'place-label-city', 'place-label-town', 'place-label-village', 'poi-label', ] for (const id of labelLayers) { try { if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none') } catch (_) {} } } const initAnimation = useRef<(map: MapboxMap) => void>(null!) initAnimation.current = (map: MapboxMap) => { startRef.current = performance.now() const tick = (t: number) => { const elapsed = t - startRef.current const zoom = map.getZoom() const zoomScale = Math.max(0.5, zoom / 4.2) // 随镜头缩放:放大变大、缩小变小(4.2 为默认 zoom) try { // 光点从起点飞向目标的循环动画 const src = map.getSource('attack-dots') as { setData: (d: GeoJSON.FeatureCollection) => void } | undefined const paths = attackPathsRef.current if (src && paths.length > 0) { const features: GeoJSON.Feature[] = paths.map((path, i) => { const progress = ((elapsed / FLIGHT_DURATION_MS + i / paths.length) % 1) const coord = interpolateOnPath(path, progress) return { type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: coord }, } }) src.setData({ type: 'FeatureCollection', features }) } // damaged: 橙色闪烁 opacity 0.5 ~ 1, 约 1s 周期 if (map.getLayer('points-damaged')) { const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.003) map.setPaintProperty('points-damaged', 'circle-opacity', blink) } // attacked: 红色脉冲 2s 循环, 半径随 zoom 缩放 if (map.getLayer('points-attacked-pulse')) { const cycle = 2000 const phase = (elapsed % cycle) / cycle const r = 40 * phase * zoomScale const opacity = 1 - phase map.setPaintProperty('points-attacked-pulse', 'circle-radius', r) map.setPaintProperty('points-attacked-pulse', 'circle-opacity', opacity) } // 林肯号打击伊朗:蓝色光点 const lincolnSrc = map.getSource('allied-strike-dots-lincoln') as | { setData: (d: GeoJSON.FeatureCollection) => void } | undefined const lincolnPaths = lincolnPathsRef.current if (lincolnSrc && lincolnPaths.length > 0) { const features: GeoJSON.Feature[] = lincolnPaths.map( (path, i) => { const progress = (elapsed / FLIGHT_DURATION_MS + 0.5 + i / Math.max(lincolnPaths.length, 1)) % 1 const coord = interpolateOnPath(path, progress) return { type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: coord }, } } ) lincolnSrc.setData({ type: 'FeatureCollection', features }) } // 福特号打击伊朗:青色光点 const fordSrc = map.getSource('allied-strike-dots-ford') as | { setData: (d: GeoJSON.FeatureCollection) => void } | undefined const fordPaths = fordPathsRef.current if (fordSrc && fordPaths.length > 0) { const features: GeoJSON.Feature[] = fordPaths.map( (path, i) => { const progress = (elapsed / FLIGHT_DURATION_MS + 0.3 + i / Math.max(fordPaths.length, 1)) % 1 const coord = interpolateOnPath(path, progress) return { type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: coord }, } } ) fordSrc.setData({ type: 'FeatureCollection', features }) } // 以色列打击伊朗:浅青/白色光点 const israelSrc = map.getSource('allied-strike-dots-israel') as | { setData: (d: GeoJSON.FeatureCollection) => void } | undefined const israelPaths = israelPathsRef.current if (israelSrc && israelPaths.length > 0) { const features: GeoJSON.Feature[] = israelPaths.map( (path, i) => { const progress = (elapsed / FLIGHT_DURATION_MS + 0.1 + i / Math.max(israelPaths.length, 1)) % 1 const coord = interpolateOnPath(path, progress) return { type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: coord }, } } ) israelSrc.setData({ type: 'FeatureCollection', features }) } // 伊朗被打击目标:蓝色脉冲 (2s 周期), 半径随 zoom 缩放 if (map.getLayer('allied-strike-targets-pulse')) { const cycle = 2000 const phase = (elapsed % cycle) / cycle const r = 35 * phase * zoomScale const opacity = Math.max(0, 1 - phase * 1.2) map.setPaintProperty('allied-strike-targets-pulse', 'circle-radius', r) map.setPaintProperty('allied-strike-targets-pulse', 'circle-opacity', opacity) } // GDELT 橙色 (4–6):闪烁 if (map.getLayer('gdelt-events-orange')) { const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.004) map.setPaintProperty('gdelt-events-orange', 'circle-opacity', blink) } // GDELT 红色 (7–10):脉冲扩散, 半径随 zoom 缩放 if (map.getLayer('gdelt-events-red-pulse')) { const cycle = 2200 const phase = (elapsed % cycle) / cycle const r = 30 * phase * zoomScale const opacity = Math.max(0, 1 - phase * 1.1) map.setPaintProperty('gdelt-events-red-pulse', 'circle-radius', r) map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity) } } catch (_) {} animRef.current = requestAnimationFrame(tick) } const start = () => { hideNonBelligerentLabels(map) map.fitBounds( [[THEATER_BBOX[0], THEATER_BBOX[1]], [THEATER_BBOX[2], THEATER_BBOX[3]]], { padding: 40, maxZoom: 5, duration: 0 } ) const hasAnim = (map.getSource('attack-dots') && attackPathsRef.current.length > 0) || (map.getSource('allied-strike-dots-lincoln') && lincolnPathsRef.current.length > 0) || (map.getSource('allied-strike-dots-ford') && fordPathsRef.current.length > 0) || (map.getSource('allied-strike-dots-israel') && israelPathsRef.current.length > 0) || map.getSource('gdelt-events-green') || map.getSource('gdelt-events-orange') || map.getSource('gdelt-events-red') if (hasAnim) { animRef.current = requestAnimationFrame(tick) } else { animRef.current = requestAnimationFrame(start) } } start() } useEffect(() => { return () => cancelAnimationFrame(animRef.current) }, []) if (!MAPBOX_TOKEN) { return (

地图需要 Mapbox 令牌

复制 .env.example 为 .env,填入令牌后重启

免费申请 Mapbox 令牌 →
) } return (
{/* 图例 - 随容器自适应,避免遮挡 */}
基地 遭袭 海军 伊朗 胡塞武装 林肯打击 福特打击 以色列打击 低烈度 中烈度 高烈度
{ // 地图加载完成后启动动画;延迟确保 Source/Layer 已挂载 setTimeout(() => initAnimation.current(e.target), 150) }} > {/* 矢量标记:zoom 拉远变小,拉近变大 */} toFeature(loc, 'iran')), }} > {/* GDELT 冲突事件:1–3 绿点, 4–6 橙闪, 7–10 红脉 */} {/* 美以联军打击伊朗:路径线 */} {/* 林肯号打击光点 */} ({ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: path[0] }, })), }} > {/* 福特号打击光点 */} ({ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: path[0] }, })), }} > {/* 以色列打击光点 */} ({ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: path[0] }, })), }} > {/* 美军打击目标点位 (蓝色) */} ({ type: 'Feature' as const, properties: { name: s.name }, geometry: { type: 'Point' as const, coordinates: s.coords }, })), }} > {/* 伊朗攻击路径:细线 (矢量) */} {/* 光点飞行动画:从德黑兰飞向各目标 */} ({ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: path[0], }, })), }} > {/* 中文标注 - 随 zoom 自适应大小 */} {/* 胡塞武装失控区 - 伊朗红 */} {/* 胡塞武装标注 */} {/* 伊朗标注 */} {/* 以色列标注 */} {/* 伊朗区域填充 - 红色系 */} {/* 以色列区域填充 - 蓝色系 */}
) }