fix:新增打击查看方式
This commit is contained in:
@@ -81,6 +81,25 @@ function getDecayFactor(iso: string | null | undefined, referenceTime: string, c
|
|||||||
return Math.max(0, 1 - days / cutoffDays)
|
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 一致)
|
// API 未返回 mapData 时的静态 fallback,保证美/以打击线与动画不消失(与 server/seed.js 一致)
|
||||||
const FALLBACK_STRIKE_SOURCES: { id: string; name: string; lng: number; lat: number }[] = [
|
const FALLBACK_STRIKE_SOURCES: { id: string; name: string; lng: number; lat: number }[] = [
|
||||||
{ id: 'israel', name: '以色列', lng: 34.78, lat: 32.08 },
|
{ id: 'israel', name: '以色列', lng: 34.78, lat: 32.08 },
|
||||||
@@ -216,6 +235,45 @@ export function WarMap() {
|
|||||||
const hezbollahPathsRef = useRef<[number, number][][]>([])
|
const hezbollahPathsRef = useRef<[number, number][][]>([])
|
||||||
const hormuzPathsRef = useRef<[number, number][][]>([])
|
const hormuzPathsRef = useRef<[number, number][][]>([])
|
||||||
const [legendOpen, setLegendOpen] = useState(true)
|
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 situation = useReplaySituation()
|
||||||
const { isReplayMode, playbackTime } = usePlaybackStore()
|
const { isReplayMode, playbackTime } = usePlaybackStore()
|
||||||
const { usForces, iranForces, conflictEvents = [] } = situation
|
const { usForces, iranForces, conflictEvents = [] } = situation
|
||||||
@@ -299,20 +357,34 @@ export function WarMap() {
|
|||||||
const strikeLines =
|
const strikeLines =
|
||||||
mapData?.strikeLines?.length > 0 ? mapData.strikeLines : FALLBACK_STRIKE_LINES
|
mapData?.strikeLines?.length > 0 ? mapData.strikeLines : FALLBACK_STRIKE_LINES
|
||||||
|
|
||||||
/** 伊朗→美军基地:仅用 DB 数据,5 天内显示飞行动画 */
|
/** 伊朗→美军基地:仅用 DB 数据,N 天内显示飞行动画 */
|
||||||
const strikeCutoffDays = situation.animationConfig?.strikeCutoffDays ?? 5
|
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 attackPaths = useMemo(() => {
|
||||||
const attacked = (usForces.keyLocations || []).filter(
|
const attacked = (usForces.keyLocations || []).filter(
|
||||||
(loc): loc is typeof loc & { attacked_at: string } =>
|
(loc): loc is typeof loc & { attacked_at: string } =>
|
||||||
loc.status === 'attacked' &&
|
loc.status === 'attacked' &&
|
||||||
!!loc.attacked_at &&
|
!!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]))
|
return attacked.map((loc) => parabolaPath(TEHRAN_SOURCE, [loc.lng, loc.lat]))
|
||||||
}, [usForces.keyLocations, referenceTime, strikeCutoffDays])
|
}, [usForces.keyLocations, referenceTime, effectiveCutoffDays])
|
||||||
|
/** 图例选中伊朗→美国时用:全部遭袭基地路径(不按时间窗口过滤) */
|
||||||
attackPathsRef.current = attackPaths
|
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 sourceCoords = useMemo(() => {
|
||||||
const m: Record<string, [number, number]> = {}
|
const m: Record<string, [number, number]> = {}
|
||||||
@@ -320,11 +392,11 @@ export function WarMap() {
|
|||||||
return m
|
return m
|
||||||
}, [strikeSources])
|
}, [strikeSources])
|
||||||
|
|
||||||
/** 盟军打击线:仅用 DB strikeLines,5 天内目标显示飞行动画 */
|
/** 盟军打击线:仅用 effectiveCutoffDays 内目标显示飞行动画(全部=配置天数,实时=1 天) */
|
||||||
const filterTargetsByAnimationWindow = useMemo(
|
const filterTargetsByAnimationWindow = useMemo(
|
||||||
() => (targets: { lng: number; lat: number; struck_at?: string | null }[]) =>
|
() => (targets: { lng: number; lat: number; struck_at?: string | null }[]) =>
|
||||||
targets.filter((t) => isWithinAnimationWindow(t.struck_at ?? null, referenceTime, strikeCutoffDays)),
|
targets.filter((t) => isWithinAnimationWindow(t.struck_at ?? null, referenceTime, effectiveCutoffDays)),
|
||||||
[referenceTime, strikeCutoffDays]
|
[referenceTime, effectiveCutoffDays]
|
||||||
)
|
)
|
||||||
|
|
||||||
const lincolnPaths = useMemo(() => {
|
const lincolnPaths = useMemo(() => {
|
||||||
@@ -352,6 +424,26 @@ export function WarMap() {
|
|||||||
)
|
)
|
||||||
}, [strikeLines, sourceCoords, filterTargetsByAnimationWindow])
|
}, [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 hezbollahPaths = useMemo(() => {
|
||||||
const sources = EXTENDED_WAR_ZONES.lebanonStrikeSources
|
const sources = EXTENDED_WAR_ZONES.lebanonStrikeSources
|
||||||
@@ -389,66 +481,192 @@ export function WarMap() {
|
|||||||
const animationDecayFactor = useMemo(() => {
|
const animationDecayFactor = useMemo(() => {
|
||||||
const decays: number[] = []
|
const decays: number[] = []
|
||||||
;(usForces.keyLocations || [])
|
;(usForces.keyLocations || [])
|
||||||
.filter((l) => l.status === 'attacked' && l.attacked_at && isWithinAnimationWindow(l.attacked_at, 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, strikeCutoffDays)))
|
.forEach((l) => decays.push(getDecayFactor(l.attacked_at ?? null, referenceTime, effectiveCutoffDays)))
|
||||||
for (const line of strikeLines) {
|
for (const line of strikeLines) {
|
||||||
for (const t of filterTargetsByAnimationWindow(line.targets)) {
|
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 (hormuzPaths.length > 0) decays.push(1)
|
||||||
if (hezbollahPaths.length > 0) decays.push(1)
|
if (hezbollahPaths.length > 0) decays.push(1)
|
||||||
return decays.length > 0 ? Math.min(...decays) : 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)
|
const animationDecayRef = useRef(1)
|
||||||
animationDecayRef.current = animationDecayFactor
|
animationDecayRef.current = animationDecayFactor
|
||||||
|
|
||||||
lincolnPathsRef.current = lincolnPaths
|
/** 图例筛选后实际显示的路径。全部/单项选中=全部路径+完整动画;实时=时间窗口内路径+衰减 */
|
||||||
fordPathsRef.current = fordPaths
|
const effectiveLincolnPaths =
|
||||||
israelPathsRef.current = israelPaths
|
strikeLegendFilter === 'lincoln'
|
||||||
hezbollahPathsRef.current = hezbollahPaths
|
? lincolnPathsAll
|
||||||
hormuzPathsRef.current = hormuzPaths
|
: 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(
|
const lincolnLinesGeoJson = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
type: 'FeatureCollection' as const,
|
type: 'FeatureCollection' as const,
|
||||||
features: lincolnPaths.map((coords) => ({
|
features: effectiveLincolnPaths.map((coords) => ({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: {},
|
properties: {},
|
||||||
geometry: { type: 'LineString' as const, coordinates: coords },
|
geometry: { type: 'LineString' as const, coordinates: coords },
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
[lincolnPaths]
|
[effectiveLincolnPaths]
|
||||||
)
|
)
|
||||||
const fordLinesGeoJson = useMemo(
|
const fordLinesGeoJson = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
type: 'FeatureCollection' as const,
|
type: 'FeatureCollection' as const,
|
||||||
features: fordPaths.map((coords) => ({
|
features: effectiveFordPaths.map((coords) => ({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: {},
|
properties: {},
|
||||||
geometry: { type: 'LineString' as const, coordinates: coords },
|
geometry: { type: 'LineString' as const, coordinates: coords },
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
[fordPaths]
|
[effectiveFordPaths]
|
||||||
)
|
)
|
||||||
const israelLinesGeoJson = useMemo(
|
const israelLinesGeoJson = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
type: 'FeatureCollection' as const,
|
type: 'FeatureCollection' as const,
|
||||||
features: israelPaths.map((coords) => ({
|
features: effectiveIsraelPaths.map((coords) => ({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: {},
|
properties: {},
|
||||||
geometry: { type: 'LineString' as const, coordinates: coords },
|
geometry: { type: 'LineString' as const, coordinates: coords },
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
[israelPaths]
|
[effectiveIsraelPaths]
|
||||||
)
|
)
|
||||||
/** 盟军打击目标点位(林肯/福特/以色列→伊朗+以色列→黎巴嫩),带衰减系数供脉冲缩放 */
|
/** 盟军打击目标点位(林肯/福特/以色列→伊朗+以色列→黎巴嫩),带衰减系数;全部/实时下按 effectiveCutoffDays 过滤 */
|
||||||
const alliedStrikeTargetsFeatures = useMemo(() => {
|
const alliedStrikeTargetsFeatures = useMemo(() => {
|
||||||
const out: GeoJSON.Feature<GeoJSON.Point, { name: string; decay: number }>[] = []
|
const out: GeoJSON.Feature<GeoJSON.Point, { name: string; decay: number }>[] = []
|
||||||
for (const line of strikeLines) {
|
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) {
|
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({
|
out.push({
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
properties: { name: t.name ?? '', decay },
|
properties: { name: t.name ?? '', decay },
|
||||||
@@ -457,41 +675,41 @@ export function WarMap() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}, [strikeLines, referenceTime])
|
}, [strikeLines, referenceTime, strikeLegendFilter, effectiveCutoffDays])
|
||||||
const hezbollahLinesGeoJson = useMemo(
|
const hezbollahLinesGeoJson = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
type: 'FeatureCollection' as const,
|
type: 'FeatureCollection' as const,
|
||||||
features: hezbollahPaths.map((coords) => ({
|
features: effectiveHezbollahPaths.map((coords) => ({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: {},
|
properties: {},
|
||||||
geometry: { type: 'LineString' as const, coordinates: coords },
|
geometry: { type: 'LineString' as const, coordinates: coords },
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
[hezbollahPaths]
|
[effectiveHezbollahPaths]
|
||||||
)
|
)
|
||||||
|
|
||||||
const hormuzLinesGeoJson = useMemo(
|
const hormuzLinesGeoJson = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
type: 'FeatureCollection' as const,
|
type: 'FeatureCollection' as const,
|
||||||
features: hormuzPaths.map((coords) => ({
|
features: effectiveHormuzPaths.map((coords) => ({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: {},
|
properties: {},
|
||||||
geometry: { type: 'LineString' as const, coordinates: coords },
|
geometry: { type: 'LineString' as const, coordinates: coords },
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
[hormuzPaths]
|
[effectiveHormuzPaths]
|
||||||
)
|
)
|
||||||
|
|
||||||
const attackLinesGeoJson = useMemo(
|
const attackLinesGeoJson = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
type: 'FeatureCollection' as const,
|
type: 'FeatureCollection' as const,
|
||||||
features: attackPaths.map((coords) => ({
|
features: effectiveAttackPaths.map((coords) => ({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: {},
|
properties: {},
|
||||||
geometry: { type: 'LineString' as const, coordinates: coords },
|
geometry: { type: 'LineString' as const, coordinates: coords },
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
[attackPaths]
|
[effectiveAttackPaths]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 真主党当前攻击目标点
|
// 真主党当前攻击目标点
|
||||||
@@ -610,9 +828,12 @@ export function WarMap() {
|
|||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
const zoom = map.getZoom()
|
const zoom = map.getZoom()
|
||||||
const zoomScale = Math.max(0.4, Math.min(1.6, zoom / 4.2)) // 镜头拉近效果变大、拉远脉冲半径变小
|
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 soloMode =
|
||||||
const step = Math.max(1, Math.round(1 / decay)) // 频次:decay 越小,step 越大,参与动画的路径越少
|
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 {
|
try {
|
||||||
// 伊朗→美军基地:速度固定,仅减少光点数量(频次)
|
// 伊朗→美军基地:速度固定,仅减少光点数量(频次)
|
||||||
const src = map.getSource('attack-dots') as { setData: (d: GeoJSON.FeatureCollection) => void } | undefined
|
const src = map.getSource('attack-dots') as { setData: (d: GeoJSON.FeatureCollection) => void } | undefined
|
||||||
@@ -874,11 +1095,12 @@ export function WarMap() {
|
|||||||
return () => cancelAnimationFrame(animRef.current)
|
return () => cancelAnimationFrame(animRef.current)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 容器尺寸变化时 fitBounds,保证区域完整显示(移动端自适应)
|
// 主视角:容器尺寸变化时无动画;图例点「全部/实时」时平滑飞回
|
||||||
const fitToTheater = useCallback(() => {
|
const fitToTheater = useCallback((options?: { duration?: number }) => {
|
||||||
const map = mapRef.current?.getMap()
|
const map = mapRef.current?.getMap()
|
||||||
if (!map) return
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -889,6 +1111,37 @@ export function WarMap() {
|
|||||||
return () => ro.disconnect()
|
return () => ro.disconnect()
|
||||||
}, [fitToTheater])
|
}, [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 重新挂载
|
// 广播更新后检查轮廓层是否仍在;若被误删则递进 zoneSourceKey 强制轮廓 Source 重新挂载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const lastUpdated = situation.lastUpdated ?? ''
|
const lastUpdated = situation.lastUpdated ?? ''
|
||||||
@@ -932,7 +1185,7 @@ export function WarMap() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative h-full w-full min-w-0">
|
<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]">
|
<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="flex items-center gap-1">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-[#22C55E]" /> 基地
|
<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="flex items-center gap-1">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-[#3B82F6]" /> 海军
|
<span className="h-1.5 w-1.5 rounded-full bg-[#3B82F6]" /> 海军
|
||||||
</span>
|
</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 className="h-1.5 w-1.5 rounded-full bg-[#EF4444]" /> 伊朗
|
||||||
</span>
|
</button>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="h-1.5 w-1.5 rounded-sm bg-red-500/40" /> 胡塞武装
|
<span className="h-1.5 w-1.5 rounded-sm bg-red-500/40" /> 胡塞武装
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="h-1.5 w-1.5 rounded-sm bg-red-500/40" /> 苏丹武装
|
<span className="h-1.5 w-1.5 rounded-sm bg-red-500/40" /> 苏丹武装
|
||||||
</span>
|
</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 className="h-1.5 w-1.5 rounded-full bg-[#3B82F6]" /> 林肯打击
|
||||||
</span>
|
</button>
|
||||||
<span className="flex items-center gap-1">
|
<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 className="h-1.5 w-1.5 rounded-full bg-[#06B6D4]" /> 福特打击
|
||||||
</span>
|
</button>
|
||||||
<span className="flex items-center gap-1">
|
<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 className="h-1.5 w-1.5 rounded-full bg-[#22D3EE]" /> 以色列打击
|
||||||
</span>
|
</button>
|
||||||
<span className="flex items-center gap-1">
|
<button
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-[#22C55E]" /> 低烈度
|
type="button"
|
||||||
</span>
|
onClick={() => toggleStrikeFilter('hormuz')}
|
||||||
<span className="flex items-center gap-1">
|
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'}`}
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-[#F97316]" /> 中烈度
|
title="只显示霍尔木兹交战区"
|
||||||
</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 className="h-1.5 w-1.5 rounded-sm bg-yellow-400/50" /> 霍尔木兹交战区
|
||||||
</span>
|
</button>
|
||||||
<span className="flex items-center gap-1">
|
<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 className="h-1.5 w-1.5 rounded-sm bg-lime-400/40" /> 真主党势力
|
||||||
</span>
|
</button>
|
||||||
<span className="flex items-center gap-1">
|
<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 className="h-1.5 w-1.5 rounded-sm bg-[#E066FF]/80" /> 库尔德武装
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* 右侧图例模块:可收纳悬浮按钮 */}
|
{/* 右侧图例模块:可收纳悬浮按钮 */}
|
||||||
<div className="absolute right-2 top-1/2 z-10 -translate-y-1/2">
|
<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="h-1.5 w-1.5 rounded-full bg-[#EF4444]" />
|
||||||
<span className="text-[9px] text-military-text-secondary">伊朗遭袭目标 / 高烈度事件</span>
|
<span className="text-[9px] text-military-text-secondary">伊朗遭袭目标 / 高烈度事件</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -1096,7 +1418,7 @@ export function WarMap() {
|
|||||||
type="geojson"
|
type="geojson"
|
||||||
data={{
|
data={{
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: hormuzPaths.map((path) => ({
|
features: effectiveHormuzPaths.map((path) => ({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: {},
|
properties: {},
|
||||||
geometry: { type: 'Point' as const, coordinates: path[0] },
|
geometry: { type: 'Point' as const, coordinates: path[0] },
|
||||||
|
|||||||
Reference in New Issue
Block a user