diff --git a/src/components/WarMap.tsx b/src/components/WarMap.tsx index 8670404..4c5f9fc 100644 --- a/src/components/WarMap.tsx +++ b/src/components/WarMap.tsx @@ -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(null) + const strikeLegendFilterRef = useRef(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 = {} @@ -320,11 +392,11 @@ export function WarMap() { return m }, [strikeSources]) - /** 盟军打击线:仅用 DB strikeLines,5 天内目标显示飞行动画 */ + /** 盟军打击线:仅用 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[] = [] 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 (
- {/* 图例 - 随容器自适应,避免遮挡 */} + {/* 图例 - 全部=主视角+所有动画,实时=主视角+仅最新进展 */}
基地 @@ -943,42 +1196,96 @@ export function WarMap() { 海军 - + 胡塞武装 苏丹武装 - + + + + + + + +
{/* 右侧图例模块:可收纳悬浮按钮 */}
@@ -1019,6 +1326,21 @@ export function WarMap() { 伊朗遭袭目标 / 高烈度事件
+
+ GDELT 烈度 +
+
+ + 低烈度 +
+
+ + 中烈度 +
+
+ + 高烈度 +
) : (