Files
usa/src/components/WarMap.tsx
2026-03-03 11:14:34 +08:00

1068 lines
37 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 { 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
/** 移动端/小屏时 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]
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 // 光点飞行单程时间
/** 移动端/小屏降低动画更新频率以减轻卡顿;返回最小间隔 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 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 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<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])
// 德黑兰到 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 冲突事件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.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<GeoJSON.Point>[] = 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 缩放phase/r/opacity 钳位避免浮点或取模越界
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, 40 * phase * zoomScale)
const opacity = Math.min(1, Math.max(0, 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.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<GeoJSON.Point>[] = 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<GeoJSON.Point>[] = 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 缩放phase/r/opacity 钳位
if (map.getLayer('allied-strike-targets-pulse')) {
const cycle = 2000
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
const r = Math.max(0, 35 * phase * zoomScale)
const opacity = Math.min(1, 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 橙色 (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 红色 (710):脉冲扩散, 半径随 zoom 缩放phase/r/opacity 钳位
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, 30 * phase * zoomScale)
const opacity = Math.min(1, 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)
}, [])
// 容器尺寸变化时 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>
</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()
setTimeout(() => initAnimation.current(e.target), 150)
}}
>
{/* 矢量标记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="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="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: ALLIED_STRIKE_LOCATIONS.map((s) => ({
type: 'Feature' as const,
properties: { name: s.name },
geometry: { type: 'Point' as const, coordinates: s.coords },
})),
}}
>
<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': '#60A5FA',
'text-halo-width': 0,
}}
/>
<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="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>
</Map>
</div>
)
}