fix:新增打击查看方式

This commit is contained in:
Daniel
2026-03-06 13:04:08 +08:00
parent 97b04b6ccc
commit 4a51bf0767

View File

@@ -81,6 +81,25 @@ function getDecayFactor(iso: string | null | undefined, referenceTime: string, c
return Math.max(0, 1 - days / cutoffDays)
}
/** 从 GeoJSON 多边形/多边形的 coordinates 中提取所有 [lng, lat] 并计算 bbox */
function bboxFromCoords(coords: number[][][] | number[][][][]): [number, number, number, number] {
const points: [number, number][] = []
const push = (c: number[]) => {
if (c.length >= 2) points.push([c[0], c[1]])
}
const flatten = (arr: number[][] | number[][][] | number[][][][]) => {
for (const item of arr) {
if (typeof item[0] === 'number') push(item as number[])
else flatten(item as number[][][] | number[][][][])
}
}
flatten(coords)
if (points.length === 0) return [0, 0, 1, 1]
const lngs = points.map((p) => p[0])
const lats = points.map((p) => p[1])
return [Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)]
}
// 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 },
@@ -216,6 +235,45 @@ export function WarMap() {
const hezbollahPathsRef = useRef<[number, number][][]>([])
const hormuzPathsRef = useRef<[number, number][][]>([])
const [legendOpen, setLegendOpen] = useState(true)
/** 图例点击筛选:打击类型 或 势力/区域null = 显示全部;与「林肯打击」同逻辑:只显示该项 + 飞镜头 + 完整动画 */
type LegendFilterKey =
| 'lincoln'
| 'ford'
| 'israel'
| 'hezbollah'
| 'iranToUs'
| 'iran'
| 'hormuz'
| 'hezbollahZone'
| 'kurdish'
const [strikeLegendFilter, setStrikeLegendFilter] = useState<LegendFilterKey | null>(null)
const strikeLegendFilterRef = useRef<LegendFilterKey | null>(null)
strikeLegendFilterRef.current = strikeLegendFilter
const getBoundsForFilterRef = useRef<(k: LegendFilterKey | null) => [number, number, number, number] | null>(() => null)
const toggleStrikeFilter = (key: LegendFilterKey | null) => {
const next = strikeLegendFilter === key ? null : key
strikeLegendFilterRef.current = next
// 点击时同步飞镜头,避免等 useEffect 造成的明显延迟;缓动曲线使动画更顺滑
if (next != null) {
const bounds = getBoundsForFilterRef.current(next)
const map = mapRef.current?.getMap()
if (bounds && map) {
map.fitBounds(
[
[bounds[0], bounds[1]],
[bounds[2], bounds[3]],
],
{
padding: 80,
maxZoom: 8,
duration: 500,
easing: (t) => 1 - Math.pow(1 - t, 3), // easeOutCubic
}
)
}
}
setStrikeLegendFilter(next)
}
const situation = useReplaySituation()
const { isReplayMode, playbackTime } = usePlaybackStore()
const { usForces, iranForces, conflictEvents = [] } = situation
@@ -299,20 +357,34 @@ export function WarMap() {
const strikeLines =
mapData?.strikeLines?.length > 0 ? mapData.strikeLines : FALLBACK_STRIKE_LINES
/** 伊朗→美军基地:仅用 DB 数据,5 天内显示飞行动画 */
/** 伊朗→美军基地:仅用 DB 数据,N 天内显示飞行动画 */
const strikeCutoffDays = situation.animationConfig?.strikeCutoffDays ?? 5
/** 实时模式:仅渲染最近 1 天内的进展(主视角);默认开启 */
const REALTIME_CUTOFF_DAYS = 1
const [isRealtimeView, setIsRealtimeView] = useState(true)
const isRealtimeViewRef = useRef(true)
isRealtimeViewRef.current = isRealtimeView
/** 未选中单项时:全部=用配置天数,实时=1 天;选中某项时用配置天数(该单项完整动画) */
const effectiveCutoffDays =
strikeLegendFilter === null && isRealtimeView ? REALTIME_CUTOFF_DAYS : strikeCutoffDays
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)
isWithinAnimationWindow(loc.attacked_at, referenceTime, effectiveCutoffDays)
)
return attacked.map((loc) => parabolaPath(TEHRAN_SOURCE, [loc.lng, loc.lat]))
}, [usForces.keyLocations, referenceTime, strikeCutoffDays])
attackPathsRef.current = attackPaths
}, [usForces.keyLocations, referenceTime, effectiveCutoffDays])
/** 图例选中伊朗→美国时用:全部遭袭基地路径(不按时间窗口过滤) */
const attackPathsAll = useMemo(() => {
const attacked = (usForces.keyLocations || []).filter(
(loc): loc is typeof loc & { lng: number; lat: number } =>
loc.status === 'attacked' && typeof loc.lng === 'number' && typeof loc.lat === 'number'
)
return attacked.map((loc) => parabolaPath(TEHRAN_SOURCE, [loc.lng, loc.lat]))
}, [usForces.keyLocations])
const sourceCoords = useMemo(() => {
const m: Record<string, [number, number]> = {}
@@ -320,11 +392,11 @@ export function WarMap() {
return m
}, [strikeSources])
/** 盟军打击线:仅用 DB strikeLines5 天内目标显示飞行动画 */
/** 盟军打击线:仅用 effectiveCutoffDays 内目标显示飞行动画(全部=配置天数,实时=1 天) */
const filterTargetsByAnimationWindow = useMemo(
() => (targets: { lng: number; lat: number; struck_at?: string | null }[]) =>
targets.filter((t) => isWithinAnimationWindow(t.struck_at ?? null, referenceTime, strikeCutoffDays)),
[referenceTime, strikeCutoffDays]
targets.filter((t) => isWithinAnimationWindow(t.struck_at ?? null, referenceTime, effectiveCutoffDays)),
[referenceTime, effectiveCutoffDays]
)
const lincolnPaths = useMemo(() => {
@@ -352,6 +424,26 @@ export function WarMap() {
)
}, [strikeLines, sourceCoords, filterTargetsByAnimationWindow])
/** 图例选中时用:该打击的全部路径(不按时间窗口过滤),直接渲染完整结果 */
const lincolnPathsAll = useMemo(() => {
const line = strikeLines.find((l) => l.sourceId === 'lincoln')
const coords = sourceCoords.lincoln
if (!coords || !line) return []
return line.targets.map((t) => parabolaPath(coords, [t.lng, t.lat]))
}, [strikeLines, sourceCoords])
const fordPathsAll = useMemo(() => {
const line = strikeLines.find((l) => l.sourceId === 'ford')
const coords = sourceCoords.ford
if (!coords || !line) return []
return line.targets.map((t) => parabolaPath(coords, [t.lng, t.lat]))
}, [strikeLines, sourceCoords])
const israelPathsAll = useMemo(() => {
const line = strikeLines.find((l) => l.sourceId === 'israel')
const coords = sourceCoords.israel
if (!coords || !line) return []
return line.targets.map((t) => parabolaPath(coords, [t.lng, t.lat]))
}, [strikeLines, sourceCoords])
/** 黎巴嫩→以色列:攻击源为黎巴嫩境内多处(提尔、西顿、巴勒贝克等),目标为以色列北部 */
const hezbollahPaths = useMemo(() => {
const sources = EXTENDED_WAR_ZONES.lebanonStrikeSources
@@ -389,66 +481,192 @@ export function WarMap() {
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)))
.filter((l) => l.status === 'attacked' && l.attacked_at && isWithinAnimationWindow(l.attacked_at, referenceTime, effectiveCutoffDays))
.forEach((l) => decays.push(getDecayFactor(l.attacked_at ?? null, referenceTime, effectiveCutoffDays)))
for (const line of strikeLines) {
for (const t of filterTargetsByAnimationWindow(line.targets)) {
decays.push(getDecayFactor(t.struck_at ?? null, referenceTime, strikeCutoffDays))
decays.push(getDecayFactor(t.struck_at ?? null, referenceTime, effectiveCutoffDays))
}
}
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])
}, [usForces.keyLocations, strikeLines, referenceTime, effectiveCutoffDays, 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 effectiveLincolnPaths =
strikeLegendFilter === 'lincoln'
? lincolnPathsAll
: strikeLegendFilter === null
? (isRealtimeView ? lincolnPaths : lincolnPathsAll)
: []
const effectiveFordPaths =
strikeLegendFilter === 'ford'
? fordPathsAll
: strikeLegendFilter === null
? (isRealtimeView ? fordPaths : fordPathsAll)
: []
const effectiveIsraelPaths =
strikeLegendFilter === 'israel'
? israelPathsAll
: strikeLegendFilter === null
? (isRealtimeView ? israelPaths : israelPathsAll)
: []
const effectiveHezbollahPaths =
strikeLegendFilter === 'hezbollah' || strikeLegendFilter === 'hezbollahZone'
? hezbollahPaths
: strikeLegendFilter === null
? hezbollahPaths
: []
const effectiveAttackPaths =
strikeLegendFilter === 'iranToUs' || strikeLegendFilter === 'iran'
? attackPathsAll
: strikeLegendFilter === null
? (isRealtimeView ? attackPaths : attackPathsAll)
: []
const effectiveHormuzPaths =
strikeLegendFilter === 'hormuz' || strikeLegendFilter === 'iran' ? hormuzPaths : strikeLegendFilter === null ? hormuzPaths : []
lincolnPathsRef.current = effectiveLincolnPaths
fordPathsRef.current = effectiveFordPaths
israelPathsRef.current = effectiveIsraelPaths
hezbollahPathsRef.current = effectiveHezbollahPaths
hormuzPathsRef.current = effectiveHormuzPaths
attackPathsRef.current = effectiveAttackPaths
/** 伊朗大致 bbox图例选中伊朗时飞镜头用 */
const IRAN_BBOX: [number, number, number, number] = [44, 25, 63, 40]
const pad = 0.5
/** 根据图例 key 计算 bbox点击时同步调用避免等 useEffect 造成的延迟) */
const getBoundsForFilter = useCallback(
(key: LegendFilterKey | null): [number, number, number, number] | null => {
if (key == null) return null
const points: [number, number][] = []
const push = (p: [number, number]) => { points.push(p) }
const flattenPaths = (paths: [number, number][][]) =>
paths.forEach((path) => path.forEach((c) => push(c)))
switch (key) {
case 'lincoln':
if (sourceCoords.lincoln) push(sourceCoords.lincoln)
flattenPaths(lincolnPathsAll)
break
case 'ford':
if (sourceCoords.ford) push(sourceCoords.ford)
flattenPaths(fordPathsAll)
break
case 'israel':
if (sourceCoords.israel) push(sourceCoords.israel)
flattenPaths(israelPathsAll)
break
case 'hezbollah':
flattenPaths(hezbollahPaths)
break
case 'hezbollahZone': {
const [minLng, minLat, maxLng, maxLat] = bboxFromCoords(
EXTENDED_WAR_ZONES.hezbollahZone.geometry.coordinates
)
return [minLng - pad, minLat - pad, maxLng + pad, maxLat + pad]
}
case 'iranToUs':
push(TEHRAN_SOURCE)
flattenPaths(attackPathsAll)
break
case 'iran': {
const [minLng, minLat, maxLng, maxLat] = IRAN_BBOX
return [minLng - pad, minLat - pad, maxLng + pad, maxLat + pad]
}
case 'hormuz': {
const [minLng, minLat, maxLng, maxLat] = bboxFromCoords(
EXTENDED_WAR_ZONES.hormuzCombatZone.geometry.coordinates
)
return [minLng - pad, minLat - pad, maxLng + pad, maxLat + pad]
}
case 'kurdish': {
const feat = KURDISH_FRONT_GEOJSON.features[0]
if (feat?.geometry?.type === 'MultiPolygon') {
const [minLng, minLat, maxLng, maxLat] = bboxFromCoords(feat.geometry.coordinates)
return [minLng - pad, minLat - pad, maxLng + pad, maxLat + pad]
}
const [cx, cy] = EXTENDED_WAR_ZONES.kurdishLabelCenter
return [cx - 4, cy - 4, cx + 4, cy + 4]
}
default:
return null
}
if (points.length === 0) return null
const lngs = points.map((p) => p[0])
const lats = points.map((p) => p[1])
return [
Math.min(...lngs) - pad,
Math.min(...lats) - pad,
Math.max(...lngs) + pad,
Math.max(...lats) + pad,
]
},
[
sourceCoords,
lincolnPathsAll,
fordPathsAll,
israelPathsAll,
hezbollahPaths,
attackPathsAll,
]
)
getBoundsForFilterRef.current = getBoundsForFilter
const strikeFilterBounds = useMemo(
() => getBoundsForFilter(strikeLegendFilter),
[strikeLegendFilter, getBoundsForFilter]
)
const lincolnLinesGeoJson = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: lincolnPaths.map((coords) => ({
features: effectiveLincolnPaths.map((coords) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'LineString' as const, coordinates: coords },
})),
}),
[lincolnPaths]
[effectiveLincolnPaths]
)
const fordLinesGeoJson = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: fordPaths.map((coords) => ({
features: effectiveFordPaths.map((coords) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'LineString' as const, coordinates: coords },
})),
}),
[fordPaths]
[effectiveFordPaths]
)
const israelLinesGeoJson = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: israelPaths.map((coords) => ({
features: effectiveIsraelPaths.map((coords) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'LineString' as const, coordinates: coords },
})),
}),
[israelPaths]
[effectiveIsraelPaths]
)
/** 盟军打击目标点位(林肯/福特/以色列→伊朗+以色列→黎巴嫩),带衰减系数供脉冲缩放 */
/** 盟军打击目标点位(林肯/福特/以色列→伊朗+以色列→黎巴嫩),带衰减系数;全部/实时下按 effectiveCutoffDays 过滤 */
const alliedStrikeTargetsFeatures = useMemo(() => {
const out: GeoJSON.Feature<GeoJSON.Point, { name: string; decay: number }>[] = []
for (const line of strikeLines) {
if (strikeLegendFilter != null && strikeLegendFilter !== 'lincoln' && strikeLegendFilter !== 'ford' && strikeLegendFilter !== 'israel') continue
if (strikeLegendFilter === 'lincoln' && line.sourceId !== 'lincoln') continue
if (strikeLegendFilter === 'ford' && line.sourceId !== 'ford') continue
if (strikeLegendFilter === 'israel' && line.sourceId !== 'israel') continue
for (const t of line.targets) {
const decay = getDecayFactor(t.struck_at ?? null, referenceTime, strikeCutoffDays)
if (!isWithinAnimationWindow(t.struck_at ?? null, referenceTime, effectiveCutoffDays)) continue
const decay = getDecayFactor(t.struck_at ?? null, referenceTime, effectiveCutoffDays)
out.push({
type: 'Feature',
properties: { name: t.name ?? '', decay },
@@ -457,41 +675,41 @@ export function WarMap() {
}
}
return out
}, [strikeLines, referenceTime])
}, [strikeLines, referenceTime, strikeLegendFilter, effectiveCutoffDays])
const hezbollahLinesGeoJson = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: hezbollahPaths.map((coords) => ({
features: effectiveHezbollahPaths.map((coords) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'LineString' as const, coordinates: coords },
})),
}),
[hezbollahPaths]
[effectiveHezbollahPaths]
)
const hormuzLinesGeoJson = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: hormuzPaths.map((coords) => ({
features: effectiveHormuzPaths.map((coords) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'LineString' as const, coordinates: coords },
})),
}),
[hormuzPaths]
[effectiveHormuzPaths]
)
const attackLinesGeoJson = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: attackPaths.map((coords) => ({
features: effectiveAttackPaths.map((coords) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'LineString' as const, coordinates: coords },
})),
}),
[attackPaths]
[effectiveAttackPaths]
)
// 真主党当前攻击目标点
@@ -610,9 +828,12 @@ export function WarMap() {
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 越大,参与动画的路径越少
// 全部 / 单项选中:完整动画不受衰减;实时:按时间衰减
const soloMode =
strikeLegendFilterRef.current != null || !isRealtimeViewRef.current
const decay = soloMode ? 1 : Math.max(0.2, animationDecayRef.current)
const decayScale = soloMode ? 1 : (0.3 + 0.7 * decay) // 衰减强度线性插值decay 低则脉冲半径缩小
const step = soloMode ? 1 : Math.max(1, Math.round(1 / decay)) // 频次:单选时每条路径都显示光点
try {
// 伊朗→美军基地:速度固定,仅减少光点数量(频次)
const src = map.getSource('attack-dots') as { setData: (d: GeoJSON.FeatureCollection) => void } | undefined
@@ -874,11 +1095,12 @@ export function WarMap() {
return () => cancelAnimationFrame(animRef.current)
}, [])
// 容器尺寸变化时 fitBounds保证区域完整显示移动端自适应
const fitToTheater = useCallback(() => {
// 主视角:容器尺寸变化时无动画;图例点「全部/实时」时平滑飞回
const fitToTheater = useCallback((options?: { duration?: number }) => {
const map = mapRef.current?.getMap()
if (!map) return
map.fitBounds(THEATER_BOUNDS, { padding: 32, maxZoom: 6, duration: 0 })
const duration = options?.duration ?? 0
map.fitBounds(THEATER_BOUNDS, { padding: 32, maxZoom: 6, duration })
}, [])
useEffect(() => {
@@ -889,6 +1111,37 @@ export function WarMap() {
return () => ro.disconnect()
}, [fitToTheater])
// 势力/区域选中时:只显示该势力填充层,其余填充层隐藏(与「林肯打击」只显示该路径同逻辑)
const ZONE_FILL_LAYERS = ['iran-fill', 'hormuz-combat-fill', 'hezbollah-fill', 'kurdish-zones'] as const
const zoneOnlyVisibility = useMemo(() => {
const f = strikeLegendFilter
if (f === 'iran') return { 'iran-fill': true, 'hormuz-combat-fill': false, 'hezbollah-fill': false, 'kurdish-zones': false }
if (f === 'hormuz') return { 'iran-fill': false, 'hormuz-combat-fill': true, 'hezbollah-fill': false, 'kurdish-zones': false }
if (f === 'hezbollahZone') return { 'iran-fill': false, 'hormuz-combat-fill': false, 'hezbollah-fill': true, 'kurdish-zones': false }
if (f === 'kurdish') return { 'iran-fill': false, 'hormuz-combat-fill': false, 'hezbollah-fill': false, 'kurdish-zones': true }
return null
}, [strikeLegendFilter])
useEffect(() => {
const map = mapRef.current?.getMap()
if (!map?.isStyleLoaded() || zoneOnlyVisibility == null) return
for (const id of ZONE_FILL_LAYERS) {
try {
if (map.getLayer(id))
map.setLayoutProperty(id, 'visibility', zoneOnlyVisibility[id] ? 'visible' : 'none')
} catch (_) {}
}
}, [zoneOnlyVisibility])
useEffect(() => {
if (zoneOnlyVisibility != null) return
const map = mapRef.current?.getMap()
if (!map?.isStyleLoaded()) return
for (const id of ZONE_FILL_LAYERS) {
try {
if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'visible')
} catch (_) {}
}
}, [zoneOnlyVisibility])
// 广播更新后检查轮廓层是否仍在;若被误删则递进 zoneSourceKey 强制轮廓 Source 重新挂载
useEffect(() => {
const lastUpdated = situation.lastUpdated ?? ''
@@ -932,7 +1185,7 @@ export function WarMap() {
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]" />
@@ -943,42 +1196,96 @@ export function WarMap() {
<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">
<button
type="button"
onClick={() => toggleStrikeFilter('iran')}
className={`flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${strikeLegendFilter === 'iran' ? 'ring-1 ring-[#EF4444] bg-[#EF4444]/20' : 'hover:bg-white/10'}`}
title="只显示伊朗相关"
>
<span className="h-1.5 w-1.5 rounded-full bg-[#EF4444]" />
</span>
</button>
<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-sm bg-red-500/40" />
</span>
<span className="flex items-center gap-1">
<button
type="button"
onClick={() => {
strikeLegendFilterRef.current = null
isRealtimeViewRef.current = false
setStrikeLegendFilter(null)
setIsRealtimeView(false)
fitToTheater({ duration: 500 })
}}
className={`flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${strikeLegendFilter === null && !isRealtimeView ? 'ring-1 ring-cyan-400/80 bg-cyan-400/10' : 'hover:bg-white/10'}`}
title="主视角,显示全部打击动画"
>
<span className="h-1.5 w-1.5 rounded-full bg-military-text-secondary" />
</button>
<button
type="button"
onClick={() => {
strikeLegendFilterRef.current = null
isRealtimeViewRef.current = true
setStrikeLegendFilter(null)
setIsRealtimeView(true)
fitToTheater({ duration: 500 })
}}
className={`flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${strikeLegendFilter === null && isRealtimeView ? 'ring-1 ring-amber-400/80 bg-amber-400/10' : 'hover:bg-white/10'}`}
title="主视角,仅渲染最新进展(近 1 天)"
>
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
</button>
<button
type="button"
onClick={() => toggleStrikeFilter('lincoln')}
className={`flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${strikeLegendFilter === 'lincoln' ? 'ring-1 ring-[#3B82F6] bg-[#3B82F6]/20' : 'hover:bg-white/10'}`}
title="只显示林肯打击"
>
<span className="h-1.5 w-1.5 rounded-full bg-[#3B82F6]" />
</span>
<span className="flex items-center gap-1">
</button>
<button
type="button"
onClick={() => toggleStrikeFilter('ford')}
className={`flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${strikeLegendFilter === 'ford' ? 'ring-1 ring-[#06B6D4] bg-[#06B6D4]/20' : 'hover:bg-white/10'}`}
title="只显示福特打击"
>
<span className="h-1.5 w-1.5 rounded-full bg-[#06B6D4]" />
</span>
<span className="flex items-center gap-1">
</button>
<button
type="button"
onClick={() => toggleStrikeFilter('israel')}
className={`flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${strikeLegendFilter === 'israel' ? 'ring-1 ring-[#22D3EE] bg-[#22D3EE]/20' : 'hover:bg-white/10'}`}
title="只显示以色列打击"
>
<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">
</button>
<button
type="button"
onClick={() => toggleStrikeFilter('hormuz')}
className={`flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${strikeLegendFilter === 'hormuz' ? 'ring-1 ring-yellow-400 bg-yellow-400/20' : 'hover:bg-white/10'}`}
title="只显示霍尔木兹交战区"
>
<span className="h-1.5 w-1.5 rounded-sm bg-yellow-400/50" />
</span>
<span className="flex items-center gap-1">
</button>
<button
type="button"
onClick={() => toggleStrikeFilter('hezbollahZone')}
className={`flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${strikeLegendFilter === 'hezbollahZone' ? 'ring-1 ring-lime-400 bg-lime-400/20' : 'hover:bg-white/10'}`}
title="只显示真主党势力"
>
<span className="h-1.5 w-1.5 rounded-sm bg-lime-400/40" />
</span>
<span className="flex items-center gap-1">
</button>
<button
type="button"
onClick={() => toggleStrikeFilter('kurdish')}
className={`flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${strikeLegendFilter === 'kurdish' ? 'ring-1 ring-[#E066FF] bg-[#E066FF]/20' : 'hover:bg-white/10'}`}
title="只显示库尔德武装"
>
<span className="h-1.5 w-1.5 rounded-sm bg-[#E066FF]/80" />
</span>
</button>
</div>
{/* 右侧图例模块:可收纳悬浮按钮 */}
<div className="absolute right-2 top-1/2 z-10 -translate-y-1/2">
@@ -1019,6 +1326,21 @@ export function WarMap() {
<span className="h-1.5 w-1.5 rounded-full bg-[#EF4444]" />
<span className="text-[9px] text-military-text-secondary"> / </span>
</div>
<div className="mt-1 border-t border-white/10 pt-1">
<span className="text-[9px] text-military-text-secondary">GDELT </span>
</div>
<div className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#22C55E]" />
<span className="text-[9px] text-military-text-secondary"></span>
</div>
<div className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#F97316]" />
<span className="text-[9px] text-military-text-secondary"></span>
</div>
<div className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[#EF4444]" />
<span className="text-[9px] text-military-text-secondary"></span>
</div>
</div>
) : (
<button
@@ -1096,7 +1418,7 @@ export function WarMap() {
type="geojson"
data={{
type: 'FeatureCollection',
features: hormuzPaths.map((path) => ({
features: effectiveHormuzPaths.map((path) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'Point' as const, coordinates: path[0] },