Files
usa/src/components/WarMap.tsx
2026-03-05 15:53:10 +08:00

1880 lines
69 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.
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<MapRef>(null)
const containerRef = useRef<HTMLDivElement>(null)
const animRef = useRef<number>(0)
const pincerAnimRef = useRef<{ lastProgressStep?: number }>({ lastProgressStep: -1 })
const startRef = useRef<number>(0)
const lastAnimUpdateRef = useRef<number>(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<GeoJSON.Point>[] = []
const op: GeoJSON.Feature<GeoJSON.Point>[] = []
const damaged: GeoJSON.Feature<GeoJSON.Point>[] = []
const attacked: GeoJSON.Feature<GeoJSON.Point>[] = []
const labels: GeoJSON.Feature<GeoJSON.Point>[] = []
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<string, [number, number]> = {}
strikeSources.forEach((s) => { m[s.id] = [s.lng, s.lat] })
return m
}, [strikeSources])
/** 盟军打击线:仅用 DB strikeLines5 天内目标显示飞行动画 */
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<GeoJSON.Point, { name: string; decay: number }>[] = []
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 冲突事件13 绿, 46 橙闪, 710 红脉
const { conflictEventsGreen, conflictEventsOrange, conflictEventsRed } = useMemo(() => {
const green: GeoJSON.Feature<GeoJSON.Point>[] = []
const orange: GeoJSON.Feature<GeoJSON.Point>[] = []
const red: GeoJSON.Feature<GeoJSON.Point>[] = []
for (const e of conflictEvents) {
const score = e.impact_score ?? 1
const f: GeoJSON.Feature<GeoJSON.Point> = {
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<GeoJSON.Point>[] = []
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<GeoJSON.Point>[] = []
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<GeoJSON.Point>[] = []
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<GeoJSON.Point>[] = []
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<GeoJSON.Point>[] = []
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<GeoJSON.Point>[] = []
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<GeoJSON.Polygon>[] = 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 橙色 (46):闪烁
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 (
<div className="flex h-full w-full items-center justify-center bg-military-dark">
<div className="rounded-lg border border-military-border bg-military-panel/95 p-8 text-center shadow-lg">
<p className="font-orbitron text-sm font-medium text-military-text-primary"> Mapbox </p>
<p className="mt-2 text-xs text-military-text-secondary">
.env.example .env
</p>
<a
href="https://account.mapbox.com/access-tokens/"
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-block text-[10px] text-military-accent hover:underline"
>
Mapbox
</a>
</div>
</div>
)
}
return (
<div ref={containerRef} className="relative h-full w-full min-w-0">
{/* 图例 - 随容器自适应,避免遮挡 */}
<div className="absolute bottom-2 left-2 z-10 flex flex-wrap gap-x-3 gap-y-1 rounded bg-black/70 px-2 py-1.5 text-[9px] sm:text-[10px]">
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#22C55E]" />
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#EF4444]" />
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#3B82F6]" />
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#EF4444]" />
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-sm bg-red-500/40" />
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#3B82F6]" />
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#06B6D4]" />
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#22D3EE]" />
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#22C55E]" />
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#F97316]" />
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#EF4444]" />
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-sm bg-yellow-400/50" />
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-sm bg-lime-400/40" />
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-sm bg-[#E066FF]/80" />
</span>
</div>
<Map
ref={mapRef}
initialViewState={DEFAULT_VIEW}
mapStyle="mapbox://styles/mapbox/dark-v11"
mapboxAccessToken={MAPBOX_TOKEN}
attributionControl={false}
dragRotate={false}
touchRotate={false}
style={{ width: '100%', height: '100%' }}
onLoad={(e) => {
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 拉远变小,拉近变大 */}
<Source id="points-us-naval" type="geojson" data={usNaval}>
<Layer
id="points-us-naval"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 1, 5, 2, 7, 3.5, 10, 6],
'circle-color': '#3B82F6',
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 7, 1, 10, 1.5],
'circle-stroke-color': '#fff',
}}
/>
</Source>
<Source id="points-us-base-op" type="geojson" data={usBaseOp}>
<Layer
id="points-us-base-op"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 1, 5, 2, 7, 3.5, 10, 6],
'circle-color': '#22C55E',
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 7, 1, 10, 1.5],
'circle-stroke-color': '#fff',
}}
/>
</Source>
{/* 伊朗对霍尔木兹海峡的打击路径(黄色轨迹) */}
<Source id="iran-hormuz-lines" type="geojson" data={hormuzLinesGeoJson}>
<Layer
id="iran-hormuz-lines"
type="line"
paint={{
'line-color': 'rgba(250, 204, 21, 0.55)',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.6, 8, 1.2, 12, 2],
}}
/>
</Source>
{/* 伊朗对霍尔木兹的打击光点(黄色) */}
<Source
id="iran-hormuz-dots"
type="geojson"
data={{
type: 'FeatureCollection',
features: hormuzPaths.map((path) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'Point' as const, coordinates: path[0] },
})),
}}
>
<Layer
id="iran-hormuz-dots-glow"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 8, 5.5, 12, 9],
'circle-color': 'rgba(250, 204, 21, 0.65)',
'circle-blur': 0.3,
}}
/>
<Layer
id="iran-hormuz-dots-core"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 1.2, 8, 2.2, 12, 3.8],
'circle-color': '#facc15',
'circle-stroke-width': 0.6,
'circle-stroke-color': '#fff',
}}
/>
</Source>
{/* 霍尔木兹海峡被打击目标点 + 脉冲(与其他被打击点风格一致,颜色区分为琥珀黄) */}
<Source id="iran-hormuz-targets" type="geojson" data={hormuzTargetsGeoJson}>
<Layer
id="iran-hormuz-targets-dot"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 2, 8, 3.5, 12, 5],
'circle-color': '#fbbf24',
'circle-stroke-width': 0.5,
'circle-stroke-color': '#fff',
}}
/>
<Layer
id="iran-hormuz-targets-pulse"
type="circle"
paint={{
'circle-radius': 0,
'circle-color': 'rgba(251, 191, 36, 0.45)',
'circle-opacity': 0,
}}
/>
</Source>
<Source id="points-us-base-damaged" type="geojson" data={usBaseDamaged}>
<Layer
id="points-damaged"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 1, 5, 2, 7, 3.5, 10, 6],
'circle-color': '#F97316',
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 7, 1, 10, 1.5],
'circle-stroke-color': '#fff',
'circle-opacity': 1,
}}
/>
</Source>
<Source id="points-us-base-attacked" type="geojson" data={usBaseAttacked}>
<Layer
id="points-attacked-dot"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 1, 5, 2, 7, 3.5, 10, 6],
'circle-color': '#EF4444',
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 7, 1, 10, 1.5],
'circle-stroke-color': '#fff',
}}
/>
<Layer
id="points-attacked-pulse"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 4, 8, 12, 12, 24],
'circle-color': '#EF4444',
'circle-opacity': 0,
}}
/>
</Source>
<Source
id="points-iran"
type="geojson"
data={{
type: 'FeatureCollection',
features: irLocs.map((loc) => toFeature(loc, 'iran')),
}}
>
<Layer
id="points-iran"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 1, 5, 2, 7, 3.5, 10, 6],
'circle-color': '#EF4444',
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 7, 1, 10, 1.5],
'circle-stroke-color': '#fff',
}}
/>
</Source>
{/* GDELT 冲突事件13 绿点, 46 橙闪, 710 红脉 */}
<Source id="gdelt-events-green" type="geojson" data={conflictEventsGreen}>
<Layer
id="gdelt-events-green"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 2, 8, 4, 12, 6],
'circle-color': '#22C55E',
'circle-stroke-width': 0.5,
'circle-stroke-color': '#fff',
}}
/>
</Source>
<Source id="gdelt-events-orange" type="geojson" data={conflictEventsOrange}>
<Layer
id="gdelt-events-orange"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 8, 5, 12, 8],
'circle-color': '#F97316',
'circle-opacity': 0.8,
}}
/>
</Source>
<Source id="gdelt-events-red" type="geojson" data={conflictEventsRed}>
<Layer
id="gdelt-events-red-dot"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 2, 8, 4, 12, 6],
'circle-color': '#EF4444',
'circle-stroke-width': 0.5,
'circle-stroke-color': '#fff',
}}
/>
<Layer
id="gdelt-events-red-pulse"
type="circle"
paint={{
'circle-radius': 0,
'circle-color': 'rgba(239, 68, 68, 0.5)',
'circle-opacity': 0,
}}
/>
</Source>
{/* 真主党对以色列北部的攻击矢量线(与林肯/福特/以色列线宽一致) */}
<Source id="hezbollah-attack-lines" type="geojson" data={hezbollahLinesGeoJson}>
<Layer
id="hezbollah-attack-lines"
type="line"
paint={{
'line-color': 'rgba(248, 113, 113, 0.45)',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.5, 8, 1, 12, 2],
}}
/>
</Source>
{/* 真主党打击光点(与林肯/福特/以色列光点半径与动画一致) */}
<Source
id="hezbollah-strike-dots"
type="geojson"
data={{
type: 'FeatureCollection',
features: hezbollahPaths.map((path) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'Point' as const, coordinates: path[0] },
})),
}}
>
<Layer
id="hezbollah-strike-dots-glow"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 8, 6, 12, 10],
'circle-color': 'rgba(248, 113, 113, 0.6)',
'circle-blur': 0.3,
}}
/>
<Layer
id="hezbollah-strike-dots-core"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 1, 8, 2, 12, 4],
'circle-color': '#F97316',
'circle-stroke-width': 0.5,
'circle-stroke-color': '#fff',
}}
/>
</Source>
<Source id="hezbollah-attack-targets" type="geojson" data={hezbollahTargetsGeoJson}>
<Layer
id="hezbollah-attack-targets-dot"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 2, 8, 3.5, 12, 5],
'circle-color': '#F97316',
'circle-stroke-width': 0.5,
'circle-stroke-color': '#fff',
}}
/>
<Layer
id="hezbollah-attack-targets-pulse"
type="circle"
paint={{
'circle-radius': 0,
'circle-color': 'rgba(248, 113, 113, 0.45)',
'circle-opacity': 0,
}}
/>
</Source>
{/* 跨国库尔德势力:土(Bakur)/叙(Rojava)/伊(Bashur) 三区 MultiPolygon + 北/南钳形箭头 */}
<Source id="kurdish-front-source" type="geojson" data={KURDISH_FRONT_GEOJSON}>
{/* 势力范围:紫色半透明,远景更显、近景更透(描边单独 line 层,参考真主党) */}
<Layer
id="kurdish-zones"
type="fill"
filter={['==', ['get', 'region_type'], 'InfluenceZone']}
paint={{
'fill-color': '#800080',
'fill-opacity': [
'interpolate',
['linear'],
['zoom'],
3,
0.4,
8,
0.15,
],
}}
/>
<Layer
id="kurdish-zones-outline"
type="line"
filter={['==', ['get', 'region_type'], 'InfluenceZone']}
paint={{
'line-color': '#BA55D3',
'line-width': 1.2,
}}
/>
{/* 进攻目的地地名:亮紫色醒目识别 */}
<Layer
id="kurdish-target-labels"
type="symbol"
filter={['==', ['get', 'region_type'], 'Target']}
layout={{
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 3, 6, 5, 8, 7, 10, 10, 13],
'text-anchor': 'top',
'text-offset': [0, 0.8],
}}
paint={{
'text-color': '#E066FF',
'text-halo-color': '#1a1a1a',
'text-halo-width': 1,
}}
/>
{/* 萨南达季、克尔曼沙赫显示圆点(大不里士不显示标记) */}
<Layer
id="kurdish-target-dots"
type="circle"
filter={['all', ['==', ['get', 'region_type'], 'Target'], ['==', ['get', 'showMarker'], true]]}
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 1.5, 5, 2.5, 8, 4],
'circle-color': '#BF40BF',
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 7, 1],
'circle-stroke-color': '#fff',
}}
/>
</Source>
{/* 伊朗被库尔德进攻点防御线:反弓曲线 + 锯齿,黄色;名称标注 */}
<Source id="kurdish-attack-defense-line" type="geojson" data={warMapData.kurdishDefenseLineGeoJson}>
<Layer
id="kurdish-attack-defense-line-layer"
type="line"
filter={['==', ['geometry-type'], 'LineString']}
paint={{
'line-color': '#FACC15',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 1, 8, 1.5, 12, 2.5],
'line-join': 'round',
'line-cap': 'round',
}}
/>
<Layer
id="kurdish-attack-defense-line-label"
type="symbol"
filter={['==', ['geometry-type'], 'Point']}
layout={{
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 7, 7, 9, 10, 11],
'text-anchor': 'center',
'text-rotate': ['get', 'angle'],
}}
paint={{
'text-color': '#FACC15',
'text-halo-color': '#1a1a1a',
'text-halo-width': 1,
}}
/>
</Source>
{/* 单箭头钳形:曲线箭体,白色加粗轮廓与美方攻击点样式一致 */}
<Source id="kurdish-pincer-growth" type="geojson" data={warMapData.kurdishPincerGrowthInitial}>
<Layer
id="attack-pincers"
type="fill"
paint={{
'fill-color': '#2563eb',
'fill-opacity': 1,
'fill-outline-color': '#FFFFFF',
}}
/>
<Layer
id="attack-pincers-inner-glow"
type="fill"
paint={{
'fill-color': '#60a5fa',
'fill-opacity': 0,
'fill-outline-color': 'transparent',
}}
/>
<Layer
id="attack-pincers-outline"
type="line"
paint={{
'line-color': '#FFFFFF',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 7, 1.2, 10, 1.8],
'line-opacity': 1,
'line-join': 'round',
'line-cap': 'round',
}}
/>
</Source>
{/* 以色列进攻黎巴嫩箭头:与库尔德同款曲线生长、白轮廓、内发光 */}
<Source id="israel-lebanon-arrow" type="geojson" data={warMapData.israelLebanonArrowInitial}>
<Layer
id="israel-lebanon-arrow-fill"
type="fill"
paint={{
'fill-color': '#2563eb',
'fill-opacity': 1,
'fill-outline-color': '#FFFFFF',
}}
/>
<Layer
id="israel-lebanon-arrow-inner-glow"
type="fill"
paint={{
'fill-color': '#60a5fa',
'fill-opacity': 0,
'fill-outline-color': 'transparent',
}}
/>
<Layer
id="israel-lebanon-arrow-outline"
type="line"
paint={{
'line-color': '#FFFFFF',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 7, 1.2, 10, 1.8],
'line-opacity': 1,
'line-join': 'round',
'line-cap': 'round',
}}
/>
</Source>
{/* 美以联军打击伊朗:路径线 */}
<Source id="allied-strike-lines-lincoln" type="geojson" data={lincolnLinesGeoJson}>
<Layer
id="allied-strike-lines-lincoln"
type="line"
paint={{
'line-color': 'rgba(96, 165, 250, 0.45)',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.5, 8, 1, 12, 2],
}}
/>
</Source>
<Source id="allied-strike-lines-ford" type="geojson" data={fordLinesGeoJson}>
<Layer
id="allied-strike-lines-ford"
type="line"
paint={{
'line-color': 'rgba(6, 182, 212, 0.45)',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.5, 8, 1, 12, 2],
}}
/>
</Source>
<Source id="allied-strike-lines-israel" type="geojson" data={israelLinesGeoJson}>
<Layer
id="allied-strike-lines-israel"
type="line"
paint={{
'line-color': 'rgba(34, 211, 238, 0.45)',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.5, 8, 1, 12, 2],
}}
/>
</Source>
{/* 林肯号打击光点 */}
<Source
id="allied-strike-dots-lincoln"
type="geojson"
data={{
type: 'FeatureCollection',
features: lincolnPaths.map((path) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'Point' as const, coordinates: path[0] },
})),
}}
>
<Layer
id="allied-strike-dots-lincoln-glow"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 8, 6, 12, 10],
'circle-color': 'rgba(96, 165, 250, 0.6)',
'circle-blur': 0.3,
}}
/>
<Layer
id="allied-strike-dots-lincoln-core"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 1, 8, 2, 12, 4],
'circle-color': '#3B82F6',
'circle-stroke-width': 0.5,
'circle-stroke-color': '#fff',
}}
/>
</Source>
{/* 福特号打击光点 */}
<Source
id="allied-strike-dots-ford"
type="geojson"
data={{
type: 'FeatureCollection',
features: fordPaths.map((path) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'Point' as const, coordinates: path[0] },
})),
}}
>
<Layer
id="allied-strike-dots-ford-glow"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 8, 6, 12, 10],
'circle-color': 'rgba(6, 182, 212, 0.6)',
'circle-blur': 0.3,
}}
/>
<Layer
id="allied-strike-dots-ford-core"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 1, 8, 2, 12, 4],
'circle-color': '#06B6D4',
'circle-stroke-width': 0.5,
'circle-stroke-color': '#fff',
}}
/>
</Source>
{/* 以色列打击光点 */}
<Source
id="allied-strike-dots-israel"
type="geojson"
data={{
type: 'FeatureCollection',
features: israelPaths.map((path) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'Point' as const, coordinates: path[0] },
})),
}}
>
<Layer
id="allied-strike-dots-israel-glow"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 8, 6, 12, 10],
'circle-color': 'rgba(34, 211, 238, 0.6)',
'circle-blur': 0.3,
}}
/>
<Layer
id="allied-strike-dots-israel-core"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 1, 8, 2, 12, 4],
'circle-color': '#22D3EE',
'circle-stroke-width': 0.5,
'circle-stroke-color': '#fff',
}}
/>
</Source>
{/* 盟军打击目标点位 (蓝色):含林肯/福特/以色列→伊朗 + 以色列→黎巴嫩,统一名称与脉冲动效 */}
<Source
id="allied-strike-targets"
type="geojson"
data={{
type: 'FeatureCollection',
features: alliedStrikeTargetsFeatures,
}}
>
<Layer
id="allied-strike-targets-circle"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 1.5, 5, 2.5, 8, 4],
'circle-color': '#3B82F6',
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 7, 1],
'circle-stroke-color': '#fff',
}}
/>
<Layer
id="allied-strike-targets-label"
type="symbol"
layout={{
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 3, 6, 5, 8, 7, 10, 10, 13],
'text-anchor': 'top',
'text-offset': [0, 0.8],
}}
paint={{
'text-color': '#FFFFFF',
'text-halo-color': '#1a1a1a',
'text-halo-width': 1,
}}
/>
<Layer
id="allied-strike-targets-pulse"
type="circle"
paint={{
'circle-radius': 0,
'circle-color': 'rgba(96, 165, 250, 0.5)',
'circle-opacity': 0,
}}
/>
</Source>
{/* 伊朗攻击路径:细线 (矢量) */}
<Source id="attack-lines" type="geojson" data={attackLinesGeoJson}>
<Layer
id="attack-lines"
type="line"
paint={{
'line-color': 'rgba(255, 100, 100, 0.4)',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.5, 8, 1, 12, 2],
}}
/>
</Source>
{/* 光点飞行动画:从德黑兰飞向各目标 */}
<Source
id="attack-dots"
type="geojson"
data={{
type: 'FeatureCollection',
features: attackPaths.map((path) => ({
type: 'Feature' as const,
properties: {},
geometry: {
type: 'Point' as const,
coordinates: path[0],
},
})),
}}
>
<Layer
id="attack-dots-glow"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 8, 6, 12, 10],
'circle-color': 'rgba(255, 100, 100, 0.6)',
'circle-blur': 0.3,
}}
/>
<Layer
id="attack-dots-core"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 1, 8, 2, 12, 4],
'circle-color': '#ff4444',
'circle-stroke-width': 0.5,
'circle-stroke-color': '#fff',
}}
/>
</Source>
{/* 中文标注 - 随 zoom 自适应大小 */}
<Source id="labels" type="geojson" data={labelsGeoJson}>
<Layer
id="labels"
type="symbol"
layout={{
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 3, 6, 5, 8, 7, 10, 10, 13],
'text-anchor': 'top',
'text-offset': [0, 0.8],
}}
paint={{
'text-color': '#E5E7EB',
'text-halo-width': 0,
}}
/>
</Source>
{/* 胡塞武装失控区 - 伊朗红 */}
<Source
id="houthi-area"
type="geojson"
data={{
type: 'Feature',
properties: { name: '胡塞武装' },
geometry: {
type: 'Polygon',
coordinates: [HOUTHI_POLYGON],
},
}}
>
<Layer
id="houthi-fill"
type="fill"
paint={{
'fill-color': 'rgba(239, 68, 68, 0.2)',
'fill-outline-color': '#EF4444',
'fill-opacity': 0.4,
}}
/>
</Source>
{/* 胡塞武装标注 */}
<Source
id="houthi-label"
type="geojson"
data={{
type: 'Feature',
properties: { name: '胡塞武装' },
geometry: { type: 'Point', coordinates: [44, 15.5] },
}}
>
<Layer
id="houthi-label-text"
type="symbol"
layout={{
'text-field': '胡塞武装',
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 12],
'text-anchor': 'center',
}}
paint={{
'text-color': '#EF4444',
'text-halo-width': 0,
}}
/>
</Source>
{/* 伊朗标注 */}
<Source
id="iran-label"
type="geojson"
data={{
type: 'Feature',
properties: { name: '伊朗' },
geometry: { type: 'Point', coordinates: [53.5, 32.5] },
}}
>
<Layer
id="iran-label-text"
type="symbol"
layout={{
'text-field': '伊朗',
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 11, 8, 16],
'text-anchor': 'center',
}}
paint={{
'text-color': '#EF4444',
'text-halo-width': 0,
}}
/>
</Source>
{/* 以色列标注 */}
<Source
id="israel-label"
type="geojson"
data={{
type: 'Feature',
properties: { name: '以色列' },
geometry: { type: 'Point', coordinates: [35, 31] },
}}
>
<Layer
id="israel-label-text"
type="symbol"
layout={{
'text-field': '以色列',
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 11, 8, 16],
'text-anchor': 'center',
}}
paint={{
'text-color': '#60A5FA',
'text-halo-width': 0,
}}
/>
</Source>
{/* 黎巴嫩标注(以色列打击黎巴嫩目标时可见) */}
<Source
id="lebanon-label"
type="geojson"
data={{
type: 'Feature',
properties: { name: '黎巴嫩' },
geometry: { type: 'Point', coordinates: [35.7, 33.7] },
}}
>
<Layer
id="lebanon-label-text"
type="symbol"
layout={{
'text-field': '黎巴嫩',
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 11, 8, 16],
'text-anchor': 'center',
}}
paint={{
'text-color': '#94A3B8',
'text-halo-width': 0,
}}
/>
</Source>
<Source id="countries" type="geojson" data={COUNTRIES_GEOJSON}>
{/* 伊朗区域填充 - 红色系 */}
<Layer
id="iran-fill"
type="fill"
filter={['==', ['get', 'ADMIN'], IRAN_ADMIN]}
paint={{
'fill-color': 'rgba(239, 68, 68, 0.15)',
'fill-outline-color': '#EF4444',
'fill-opacity': 0.5,
}}
/>
<Layer
id="iran-outline"
type="line"
filter={['==', ['get', 'ADMIN'], IRAN_ADMIN]}
paint={{
'line-color': '#EF4444',
'line-width': 2,
'line-opacity': 0.9,
}}
/>
{/* 以色列区域填充 - 蓝色系 */}
<Layer
id="israel-fill"
type="fill"
filter={['==', ['get', 'ADMIN'], 'Israel']}
paint={{
'fill-color': 'rgba(96, 165, 250, 0.25)',
'fill-outline-color': '#60A5FA',
'fill-opacity': 0.6,
}}
/>
<Layer
id="allies-outline"
type="line"
filter={['in', ['get', 'ADMIN'], ['literal', ALLIES_ADMIN]]}
paint={{
'line-color': '#3B82F6',
'line-width': 1.5,
'line-opacity': 0.8,
}}
/>
</Source>
{/* 霍尔木兹海峡交战区 - 金黄色 mesh 区域 */}
<Source id="hormuz-combat-zone" type="geojson" data={hormuzZone}>
<Layer
id="hormuz-combat-fill"
type="fill"
paint={{
'fill-color': (hormuzZone.properties as any).style.fillColor,
'fill-opacity': (hormuzZone.properties as any).style.fillOpacity ?? 0.4,
}}
/>
<Layer
id="hormuz-combat-outline"
type="line"
paint={{
'line-color': '#FACC15',
'line-width': 1.5,
'line-dasharray': [1.5, 1.5],
}}
/>
</Source>
{/* 真主党势力范围 - 绿色半透明区域 */}
<Source id="hezbollah-zone" type="geojson" data={hezbollahZone}>
<Layer
id="hezbollah-fill"
type="fill"
paint={{
'fill-color': (hezbollahZone.properties as any).color || '#32CD32',
'fill-opacity': 0.28,
}}
/>
<Layer
id="hezbollah-outline"
type="line"
paint={{
'line-color': '#22C55E',
'line-width': 1.2,
}}
/>
</Source>
{/* 霍尔木兹海峡区域标注 */}
<Source
id="hormuz-label"
type="geojson"
data={{
type: 'Feature',
properties: { name: (hormuzZone.properties as any).name },
geometry: {
type: 'Point',
coordinates: EXTENDED_WAR_ZONES.hormuzLabelCenter,
},
}}
>
<Layer
id="hormuz-label-text"
type="symbol"
layout={{
'text-field': ['get', 'name'],
// 字体进一步调小,避免与该区域多重效果叠加后显得拥挤
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 5.5, 7, 7.5, 10, 9],
'text-anchor': 'center',
}}
paint={{
'text-color': '#FACC15',
'text-halo-color': '#1a1a1a',
'text-halo-width': 0.8,
}}
/>
</Source>
{/* 真主党势力范围标注 */}
<Source
id="hezbollah-label"
type="geojson"
data={{
type: 'Feature',
properties: { name: (hezbollahZone.properties as any).name },
geometry: {
type: 'Point',
coordinates: EXTENDED_WAR_ZONES.hezbollahLabelCenter,
},
}}
>
<Layer
id="hezbollah-label-text"
type="symbol"
layout={{
'text-field': ['get', 'name'],
// 字体尽量小一些,避免遮挡
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 7, 7, 9, 10, 11],
'text-anchor': 'center',
}}
paint={{
'text-color': '#22C55E',
'text-halo-color': '#1a1a1a',
'text-halo-width': 1,
}}
/>
</Source>
{/* 库尔德武装势力范围标注(参照真主党紫色区域标记) */}
<Source
id="kurdish-label"
type="geojson"
data={{
type: 'Feature',
properties: { name: '库尔德武装' },
geometry: {
type: 'Point',
coordinates: EXTENDED_WAR_ZONES.kurdishLabelCenter,
},
}}
>
<Layer
id="kurdish-label-text"
type="symbol"
layout={{
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 7, 7, 9, 10, 11],
'text-anchor': 'center',
}}
paint={{
'text-color': '#E066FF',
'text-halo-color': '#1a1a1a',
'text-halo-width': 1,
}}
/>
</Source>
</Map>
</div>
)
}