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)
|
||||
}
|
||||
|
||||
/** 从 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 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<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] },
|
||||
|
||||
Reference in New Issue
Block a user