import { useMemo, useEffect, useRef, useCallback } 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 { usePlaybackStore } from '@/store/playbackStore' import { config } from '@/config' import { EXTENDED_WAR_ZONES, KURDISH_FRONT_GEOJSON } from '@/data/extendedWarData' import { useWarMapData } from '@/hooks/useWarMapData' import { createTacticalPincerAtProgress } from '@/utils/tacticalPincerArrow' const MAPBOX_TOKEN = config.mapboxAccessToken || '' // 相关区域 bbox:伊朗、以色列、胡塞区 (minLng, minLat, maxLng, maxLat),覆盖红蓝区域 const THEATER_BBOX = [22, 11, 64, 41] as const /** 移动端/小屏时 fitBounds 使区域完整显示 */ const THEATER_BOUNDS: [[number, number], [number, number]] = [ [THEATER_BBOX[0], THEATER_BBOX[1]], [THEATER_BBOX[2], THEATER_BBOX[3]], ] 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](若后端 map_strike_source 有 iran/tehran 则可由 API 覆盖) const TEHRAN_SOURCE: [number, number] = [51.389, 35.6892] /** 攻击动画时间衰减:N 天内按天衰减(脉冲缩小、频次降低),超出仅保留减弱呼吸效果;天数可在编辑面板配置 */ const MS_PER_DAY = 24 * 60 * 60 * 1000 function isWithinAnimationWindow( iso: string | null | undefined, referenceTime: string, cutoffDays: number ): boolean { if (!iso) return true const ref = new Date(referenceTime).getTime() const t = new Date(iso).getTime() const days = (ref - t) / MS_PER_DAY return days >= 0 && days <= cutoffDays } /** 衰减系数 0..1:天数越久越小,用于缩小脉冲范围、降低攻击频次 */ function getDecayFactor(iso: string | null | undefined, referenceTime: string, cutoffDays: number): number { if (!iso) return 1 const ref = new Date(referenceTime).getTime() const t = new Date(iso).getTime() const days = (ref - t) / MS_PER_DAY if (days < 0 || days > cutoffDays) return 0 return Math.max(0, 1 - days / cutoffDays) } // API 未返回 mapData 时的静态 fallback,保证美/以打击线与动画不消失(与 server/seed.js 一致) const FALLBACK_STRIKE_SOURCES: { id: string; name: string; lng: number; lat: number }[] = [ { id: 'israel', name: '以色列', lng: 34.78, lat: 32.08 }, { id: 'lincoln', name: '林肯号航母', lng: 58.4215, lat: 24.1568 }, { id: 'ford', name: '福特号航母', lng: 24.1002, lat: 35.7397 }, ] const FALLBACK_STRIKE_LINES: { sourceId: string; targets: { lng: number; lat: number; name?: string }[] }[] = [ { sourceId: 'israel', targets: [ { lng: 50.88, lat: 34.64, name: '库姆' }, { lng: 50.876409, lat: 34.625448, name: '伊朗专家会议秘书处' }, { lng: 51.916, lat: 33.666, name: '纳坦兹' }, { lng: 51.002, lat: 35.808, name: '卡拉季无人机厂' }, { lng: 35.5, lat: 33.86, name: '贝鲁特南郊指挥所' }, { lng: 35.32, lat: 33.34, name: '利塔尼弹药库' }, { lng: 36.2, lat: 34.01, name: '巴勒贝克后勤枢纽' }, { lng: 35.19, lat: 33.27, name: '提尔海岸阵地' }, { lng: 36.38, lat: 34.39, name: '赫尔梅勒无人机阵地' }, ], }, { sourceId: 'lincoln', targets: [ { lng: 56.27, lat: 27.18, name: '阿巴斯港海军司令部' }, { lng: 57.08, lat: 27.13, name: '米纳布' }, { lng: 56.5, lat: 27.0, name: '霍尔木兹岸防阵地' }, { lng: 50.838, lat: 28.968, name: '布什尔雷达站' }, { lng: 51.667, lat: 32.654, name: '伊斯法罕核设施' }, ], }, { sourceId: 'ford', targets: [ { lng: 51.42, lat: 35.69, name: '哈梅内伊官邸' }, { lng: 51.41, lat: 35.72, name: '总统府/情报部' }, { lng: 51.15, lat: 35.69, name: '梅赫拉巴德机场' }, { lng: 46.29, lat: 38.08, name: '大不里士空军基地' }, { lng: 47.076, lat: 34.314, name: '克尔曼沙赫导弹掩体' }, { lng: 46.42, lat: 33.64, name: '伊拉姆导弹阵地' }, { lng: 48.35, lat: 33.48, name: '霍拉马巴德储备库' }, ], }, ] /** 二次贝塞尔曲线路径,更平滑的弧线 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 // 光点飞行单程时间 /** 移动端/小屏降低动画更新频率以减轻卡顿;返回最小间隔 ms */ function getAnimIntervalMs(): number { try { if (typeof window === 'undefined') return 33 const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches if (reducedMotion) return 100 // 约 10fps,兼顾可访问性 return window.innerWidth <= 768 ? 50 : 33 // 移动端约 20fps,桌面约 30fps } catch { return 33 } } export function WarMap() { const mapRef = useRef(null) const containerRef = useRef(null) const animRef = useRef(0) const pincerAnimRef = useRef<{ lastProgressStep?: number }>({ lastProgressStep: -1 }) const startRef = useRef(0) const lastAnimUpdateRef = useRef(0) const attackPathsRef = useRef<[number, number][][]>([]) const lincolnPathsRef = useRef<[number, number][][]>([]) const fordPathsRef = useRef<[number, number][][]>([]) const israelPathsRef = useRef<[number, number][][]>([]) const hezbollahPathsRef = useRef<[number, number][][]>([]) const hormuzPathsRef = useRef<[number, number][][]>([]) const situation = useReplaySituation() const { isReplayMode, playbackTime } = usePlaybackStore() const { usForces, iranForces, conflictEvents = [] } = situation /** 时间衰减基准:回放模式用回放时刻,否则用数据更新时间或当前时间 */ const referenceTime = isReplayMode ? playbackTime : situation.lastUpdated || new Date().toISOString() 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]) const mapData = situation.mapData const strikeSources = mapData?.strikeSources?.length > 0 ? mapData.strikeSources : FALLBACK_STRIKE_SOURCES const strikeLines = mapData?.strikeLines?.length > 0 ? mapData.strikeLines : FALLBACK_STRIKE_LINES /** 伊朗→美军基地:仅用 DB 数据,5 天内显示飞行动画 */ const strikeCutoffDays = situation.animationConfig?.strikeCutoffDays ?? 5 const attackPaths = useMemo(() => { const attacked = (usForces.keyLocations || []).filter( (loc): loc is typeof loc & { attacked_at: string } => loc.status === 'attacked' && !!loc.attacked_at && isWithinAnimationWindow(loc.attacked_at, referenceTime, strikeCutoffDays) ) return attacked.map((loc) => parabolaPath(TEHRAN_SOURCE, [loc.lng, loc.lat])) }, [usForces.keyLocations, referenceTime, strikeCutoffDays]) attackPathsRef.current = attackPaths const sourceCoords = useMemo(() => { const m: Record = {} strikeSources.forEach((s) => { m[s.id] = [s.lng, s.lat] }) return m }, [strikeSources]) /** 盟军打击线:仅用 DB strikeLines,5 天内目标显示飞行动画 */ const filterTargetsByAnimationWindow = useMemo( () => (targets: { lng: number; lat: number; struck_at?: string | null }[]) => targets.filter((t) => isWithinAnimationWindow(t.struck_at ?? null, referenceTime, strikeCutoffDays)), [referenceTime, strikeCutoffDays] ) const lincolnPaths = useMemo(() => { const line = strikeLines.find((l) => l.sourceId === 'lincoln') const coords = sourceCoords.lincoln if (!coords || !line) return [] return filterTargetsByAnimationWindow(line.targets).map((t) => parabolaPath(coords, [t.lng, t.lat]) ) }, [strikeLines, sourceCoords, filterTargetsByAnimationWindow]) const fordPaths = useMemo(() => { const line = strikeLines.find((l) => l.sourceId === 'ford') const coords = sourceCoords.ford if (!coords || !line) return [] return filterTargetsByAnimationWindow(line.targets).map((t) => parabolaPath(coords, [t.lng, t.lat]) ) }, [strikeLines, sourceCoords, filterTargetsByAnimationWindow]) const israelPaths = useMemo(() => { const line = strikeLines.find((l) => l.sourceId === 'israel') const coords = sourceCoords.israel if (!coords || !line) return [] return filterTargetsByAnimationWindow(line.targets).map((t) => parabolaPath(coords, [t.lng, t.lat]) ) }, [strikeLines, sourceCoords, filterTargetsByAnimationWindow]) /** 黎巴嫩→以色列:攻击源为黎巴嫩境内多处(提尔、西顿、巴勒贝克等),目标为以色列北部 */ const hezbollahPaths = useMemo(() => { const sources = EXTENDED_WAR_ZONES.lebanonStrikeSources const targets = EXTENDED_WAR_ZONES.activeAttacks return targets.map((t, i) => parabolaPath(sources[i % sources.length], t.coords, 3) ) }, []) /** 霍尔木兹海峡被打击目标点位;飞行动画由伊朗多处→该区域 */ const hormuzTargetPoints = useMemo( () => [ [55.7, 25.6], [56.0, 26.0], [56.4, 26.4], ] as [number, number][], [] ) /** 伊朗多处→霍尔木兹:德黑兰、克尔曼沙赫、库姆 攻击海峡目标(无日期按 decay=1 显示) */ const hormuzPaths = useMemo(() => { const sources: [number, number][] = [ TEHRAN_SOURCE, [47.16, 34.35], [50.88, 34.64], ] return hormuzTargetPoints.map((target, idx) => parabolaPath(sources[idx % sources.length], target, 3) ) }, [hormuzTargetPoints]) const warMapData = useWarMapData() /** 当前参与动画的目标的最小衰减系数,用于脉冲范围与攻击频次(缩小脉冲、拉长飞行周期) */ const animationDecayFactor = useMemo(() => { const decays: number[] = [] ;(usForces.keyLocations || []) .filter((l) => l.status === 'attacked' && l.attacked_at && isWithinAnimationWindow(l.attacked_at, referenceTime, strikeCutoffDays)) .forEach((l) => decays.push(getDecayFactor(l.attacked_at ?? null, referenceTime, strikeCutoffDays))) for (const line of strikeLines) { for (const t of filterTargetsByAnimationWindow(line.targets)) { decays.push(getDecayFactor(t.struck_at ?? null, referenceTime, strikeCutoffDays)) } } if (hormuzPaths.length > 0) decays.push(1) if (hezbollahPaths.length > 0) decays.push(1) return decays.length > 0 ? Math.min(...decays) : 1 }, [usForces.keyLocations, strikeLines, referenceTime, strikeCutoffDays, filterTargetsByAnimationWindow, hormuzPaths.length, hezbollahPaths.length]) const animationDecayRef = useRef(1) animationDecayRef.current = animationDecayFactor lincolnPathsRef.current = lincolnPaths fordPathsRef.current = fordPaths israelPathsRef.current = israelPaths hezbollahPathsRef.current = hezbollahPaths hormuzPathsRef.current = hormuzPaths 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 alliedStrikeTargetsFeatures = useMemo(() => { const out: GeoJSON.Feature[] = [] for (const line of strikeLines) { for (const t of line.targets) { const decay = getDecayFactor(t.struck_at ?? null, referenceTime, strikeCutoffDays) out.push({ type: 'Feature', properties: { name: t.name ?? '', decay }, geometry: { type: 'Point', coordinates: [t.lng, t.lat] }, }) } } return out }, [strikeLines, referenceTime]) const hezbollahLinesGeoJson = useMemo( () => ({ type: 'FeatureCollection' as const, features: hezbollahPaths.map((coords) => ({ type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates: coords }, })), }), [hezbollahPaths] ) const hormuzLinesGeoJson = useMemo( () => ({ type: 'FeatureCollection' as const, features: hormuzPaths.map((coords) => ({ type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates: coords }, })), }), [hormuzPaths] ) const attackLinesGeoJson = useMemo( () => ({ type: 'FeatureCollection' as const, features: attackPaths.map((coords) => ({ type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates: coords }, })), }), [attackPaths] ) // 真主党当前攻击目标点 const hezbollahTargetsGeoJson = useMemo( () => isReplayMode ? { type: 'FeatureCollection' as const, features: [] } : { type: 'FeatureCollection' as const, features: EXTENDED_WAR_ZONES.activeAttacks.map((t) => ({ type: 'Feature' as const, properties: { name: t.name, type: t.type, damage: t.damage }, geometry: { type: 'Point' as const, coordinates: t.coords }, })), }, [isReplayMode] ) // 霍尔木兹海峡被持续打击的海面目标(用于脉冲与标记) const hormuzTargetsGeoJson = useMemo( () => isReplayMode ? { type: 'FeatureCollection' as const, features: [] } : { type: 'FeatureCollection' as const, features: hormuzTargetPoints.map((coords, idx) => ({ type: 'Feature' as const, properties: { id: `H${idx + 1}` }, geometry: { type: 'Point' as const, coordinates: coords }, })), }, [hormuzTargetPoints, isReplayMode] ) // 霍尔木兹海峡交战区 & 真主党势力范围(静态面) const hormuzZone = EXTENDED_WAR_ZONES.hormuzCombatZone const hezbollahZone = EXTENDED_WAR_ZONES.hezbollahZone // 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 intervalMs = getAnimIntervalMs() const shouldUpdate = t - lastAnimUpdateRef.current >= intervalMs if (shouldUpdate) lastAnimUpdateRef.current = t if (shouldUpdate) { const zoom = map.getZoom() const zoomScale = Math.max(0.4, Math.min(1.6, zoom / 4.2)) // 镜头拉近效果变大、拉远脉冲半径变小 const decay = Math.max(0.2, animationDecayRef.current) const decayScale = 0.3 + 0.7 * decay // 衰减强度线性插值:decay 低则脉冲半径缩小,避免过多特效 const step = Math.max(1, Math.round(1 / decay)) // 频次:decay 越小,step 越大,参与动画的路径越少 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.forEach((path, i) => { if (i % step !== 0) return const progress = (elapsed / FLIGHT_DURATION_MS + i / Math.max(paths.length, 1)) % 1 features.push({ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) }, }) }) 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: 红色脉冲,半径 = 基准×phase×zoomScale×decayScale(线性衰减) if (map.getLayer('points-attacked-pulse')) { const cycle = 2000 const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle)) const r = Math.max(0, 32 * phase * zoomScale * decayScale) const opacity = Math.min(1, Math.max(0, (0.4 + 0.6 * decay) * (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.forEach((path, i) => { if (i % step !== 0) return const progress = (elapsed / FLIGHT_DURATION_MS + 0.5 + i / Math.max(lincolnPaths.length, 1)) % 1 features.push({ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) }, }) }) 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.forEach((path, i) => { if (i % step !== 0) return const progress = (elapsed / FLIGHT_DURATION_MS + 0.3 + i / Math.max(fordPaths.length, 1)) % 1 features.push({ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) }, }) }) 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.forEach((path, i) => { if (i % step !== 0) return const progress = (elapsed / FLIGHT_DURATION_MS + 0.1 + i / Math.max(israelPaths.length, 1)) % 1 features.push({ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) }, }) }) israelSrc.setData({ type: 'FeatureCollection', features }) } // 真主党打击以色列北部:橙红光点,与林肯/福特/以色列同一动画方式 const hezSrc = map.getSource('hezbollah-strike-dots') as | { setData: (d: GeoJSON.FeatureCollection) => void } | undefined const hezPaths = hezbollahPathsRef.current if (hezSrc && hezPaths.length > 0) { const features: GeoJSON.Feature[] = [] hezPaths.forEach((path, i) => { if (i % step !== 0) return const progress = (elapsed / FLIGHT_DURATION_MS + 0.2 + i / Math.max(hezPaths.length, 1)) % 1 features.push({ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) }, }) }) hezSrc.setData({ type: 'FeatureCollection', features }) } // 伊朗对霍尔木兹海峡:黄色光点,沿海峡方向飞行 const hormuzSrc = map.getSource('iran-hormuz-dots') as | { setData: (d: GeoJSON.FeatureCollection) => void } | undefined const hormuzPaths = hormuzPathsRef.current if (hormuzSrc && hormuzPaths.length > 0) { const features: GeoJSON.Feature[] = [] hormuzPaths.forEach((path, i) => { if (i % step !== 0) return const progress = (elapsed / FLIGHT_DURATION_MS + 0.15 + i / Math.max(hormuzPaths.length, 1)) % 1 features.push({ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) }, }) }) hormuzSrc.setData({ type: 'FeatureCollection', features }) } // 盟军打击目标:脉冲半径 = 基准×decayScale×zoomScale,线性衰减,镜头拉远半径变小 if (map.getLayer('allied-strike-targets-pulse')) { const cycle = 2000 const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle)) const breathMin = 8 const breathMax = 26 const r = Math.max(0, (breathMin + (breathMax - breathMin) * decay) * phase * zoomScale * decayScale) const opacity = Math.min(1, Math.max(0, (0.35 + 0.65 * decay) * (1 - phase * 1.15))) map.setPaintProperty('allied-strike-targets-pulse', 'circle-radius', r) map.setPaintProperty('allied-strike-targets-pulse', 'circle-opacity', opacity) } // 单箭头钳形动画:生长(缓动) → 保持 2s 循环(已去掉内发光闪烁) const pincerGrowthSrc = map.getSource('kurdish-pincer-growth') as | { setData: (d: GeoJSON.FeatureCollection) => void } | undefined const PINCER_GROW_MS = 2500 const PINCER_HOLD_MS = 2000 const PINCER_CYCLE_MS = PINCER_GROW_MS + PINCER_HOLD_MS const pincerInCycle = elapsed % PINCER_CYCLE_MS const isGrowth = pincerInCycle < PINCER_GROW_MS const growthT = isGrowth ? pincerInCycle / PINCER_GROW_MS : 1 const progressEased = growthT * growthT * (3 - 2 * growthT) const pincerProgress = isGrowth ? progressEased : 1 // 节流:仅当 progress 步进变化或进入保持阶段时更新 GeoJSON,减轻首帧卡顿与每帧 setData 开销 const progressStep = Math.floor(pincerProgress * 40) / 40 const lastStep = pincerAnimRef.current.lastProgressStep ?? -1 const shouldUpdate = progressStep !== lastStep || (!isGrowth && lastStep !== 1) if (shouldUpdate) { pincerAnimRef.current.lastProgressStep = isGrowth ? progressStep : 1 const progressToUse = isGrowth ? pincerProgress : 1 if (pincerGrowthSrc && warMapData.pincerAxes.length > 0) { const features: GeoJSON.Feature[] = warMapData.pincerAxes.map((axis) => ({ type: 'Feature', properties: { name: axis.name }, geometry: { type: 'Polygon', coordinates: [createTacticalPincerAtProgress(axis.start, axis.end, progressToUse)], }, })) pincerGrowthSrc.setData({ type: 'FeatureCollection', features }) } const israelLebanonSrc = map.getSource('israel-lebanon-arrow') as | { setData: (d: GeoJSON.FeatureCollection) => void } | undefined if (israelLebanonSrc) { const { start, end, name } = warMapData.israelLebanonAxis israelLebanonSrc.setData({ type: 'FeatureCollection', features: [{ type: 'Feature', properties: { name }, geometry: { type: 'Polygon', coordinates: [createTacticalPincerAtProgress(start, end, progressToUse)], }, }], }) } } if (map.getLayer('attack-pincers')) { map.setPaintProperty('attack-pincers', 'fill-opacity', 1) } if (map.getLayer('attack-pincers-inner-glow')) { map.setPaintProperty('attack-pincers-inner-glow', 'fill-opacity', 0) } if (map.getLayer('israel-lebanon-arrow-fill')) { map.setPaintProperty('israel-lebanon-arrow-fill', 'fill-opacity', 1) } if (map.getLayer('israel-lebanon-arrow-inner-glow')) { map.setPaintProperty('israel-lebanon-arrow-inner-glow', 'fill-opacity', 0) } // 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 红色:脉冲半径随 zoom×decayScale 线性变化 if (map.getLayer('gdelt-events-red-pulse')) { const cycle = 2200 const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle)) const r = Math.max(0, 24 * phase * zoomScale * decayScale) const opacity = Math.min(1, Math.max(0, (0.4 + 0.6 * decay) * (1 - phase * 1.05))) map.setPaintProperty('gdelt-events-red-pulse', 'circle-radius', r) map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity) } // 真主党攻击目标:脉冲半径衰减线性插值,镜头拉远变小 if (map.getLayer('hezbollah-attack-targets-pulse')) { const cycle = 2000 const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle)) const r = Math.max(0, 26 * phase * zoomScale * decayScale) const opacity = Math.min(1, Math.max(0, (0.35 + 0.65 * decay) * (1 - phase * 1.1))) map.setPaintProperty('hezbollah-attack-targets-pulse', 'circle-radius', r) map.setPaintProperty('hezbollah-attack-targets-pulse', 'circle-opacity', opacity) } // 霍尔木兹海峡被打击目标:脉冲半径随 zoom×decayScale if (map.getLayer('iran-hormuz-targets-pulse')) { const cycle = 2000 const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle)) const r = Math.max(0, 24 * phase * zoomScale * decayScale) const opacity = Math.min(1, Math.max(0, (0.35 + 0.65 * decay) * (1 - phase * 1.05))) map.setPaintProperty('iran-hormuz-targets-pulse', 'circle-radius', r) map.setPaintProperty('iran-hormuz-targets-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('hezbollah-strike-dots') && hezbollahPathsRef.current.length > 0) || (map.getSource('iran-hormuz-dots') && hormuzPathsRef.current.length > 0) || map.getSource('kurdish-pincer-growth') || map.getSource('israel-lebanon-arrow') || 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) }, []) // 容器尺寸变化时 fitBounds,保证区域完整显示(移动端自适应) const fitToTheater = useCallback(() => { const map = mapRef.current?.getMap() if (!map) return map.fitBounds(THEATER_BOUNDS, { padding: 32, maxZoom: 6, duration: 0 }) }, []) useEffect(() => { const el = containerRef.current if (!el) return const ro = new ResizeObserver(() => fitToTheater()) ro.observe(el) return () => ro.disconnect() }, [fitToTheater]) if (!MAPBOX_TOKEN) { return (

地图需要 Mapbox 令牌

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

免费申请 Mapbox 令牌 →
) } return (
{/* 图例 - 随容器自适应,避免遮挡 */}
基地 遭袭 海军 伊朗 胡塞武装 林肯打击 福特打击 以色列打击 低烈度 中烈度 高烈度 霍尔木兹交战区 真主党势力 库尔德武装
{ fitToTheater() // 等地图 style/tiles 就绪后再启动动画,减轻首帧卡顿 const map = e.target if (map.isStyleLoaded()) { map.once('idle', () => initAnimation.current(map)) } else { map.once('load', () => map.once('idle', () => initAnimation.current(map))) } }} > {/* 矢量标记:zoom 拉远变小,拉近变大 */} {/* 伊朗对霍尔木兹海峡的打击路径(黄色轨迹) */} {/* 伊朗对霍尔木兹的打击光点(黄色) */} ({ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: path[0] }, })), }} > {/* 霍尔木兹海峡被打击目标点 + 脉冲(与其他被打击点风格一致,颜色区分为琥珀黄) */} toFeature(loc, 'iran')), }} > {/* GDELT 冲突事件:1–3 绿点, 4–6 橙闪, 7–10 红脉 */} {/* 真主党对以色列北部的攻击矢量线(与林肯/福特/以色列线宽一致) */} {/* 真主党打击光点(与林肯/福特/以色列光点半径与动画一致) */} ({ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: path[0] }, })), }} > {/* 跨国库尔德势力:土(Bakur)/叙(Rojava)/伊(Bashur) 三区 MultiPolygon + 北/南钳形箭头 */} {/* 势力范围:紫色半透明,远景更显、近景更透(描边单独 line 层,参考真主党) */} {/* 进攻目的地地名:亮紫色醒目识别 */} {/* 萨南达季、克尔曼沙赫显示圆点(大不里士不显示标记) */} {/* 伊朗被库尔德进攻点防御线:反弓曲线 + 锯齿,黄色;名称标注 */} {/* 单箭头钳形:曲线箭体,白色加粗轮廓与美方攻击点样式一致 */} {/* 以色列进攻黎巴嫩箭头:与库尔德同款曲线生长、白轮廓、内发光 */} {/* 美以联军打击伊朗:路径线 */} {/* 林肯号打击光点 */} ({ 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: {}, geometry: { type: 'Point' as const, coordinates: path[0], }, })), }} > {/* 中文标注 - 随 zoom 自适应大小 */} {/* 胡塞武装失控区 - 伊朗红 */} {/* 胡塞武装标注 */} {/* 伊朗标注 */} {/* 以色列标注 */} {/* 黎巴嫩标注(以色列打击黎巴嫩目标时可见) */} {/* 伊朗区域填充 - 红色系 */} {/* 以色列区域填充 - 蓝色系 */} {/* 霍尔木兹海峡交战区 - 金黄色 mesh 区域 */} {/* 真主党势力范围 - 绿色半透明区域 */} {/* 霍尔木兹海峡区域标注 */} {/* 真主党势力范围标注 */} {/* 库尔德武装势力范围标注(参照真主党紫色区域标记) */}
) }