fix: 新增处 战役

This commit is contained in:
Daniel
2026-03-06 17:43:27 +08:00
parent 13a8d8af91
commit 7e0c209d9a
6 changed files with 576 additions and 20 deletions

View File

@@ -162,6 +162,9 @@ function seed() {
['israel', '以色列', 34.78, 32.08], ['israel', '以色列', 34.78, 32.08],
['lincoln', '林肯号航母', 58.4215, 24.1568], ['lincoln', '林肯号航母', 58.4215, 24.1568],
['ford', '福特号航母', 24.1002, 35.7397], ['ford', '福特号航母', 24.1002, 35.7397],
['virginia_srilanka', '洛杉矶级攻击核潜艇(斯里兰卡外海)', 78.5, 5.2],
['tomahawk_arabian', '阿拉伯海潜艇(战斧)', 59.2, 23.0],
['arabian_sea_sub_torpedo', '阿拉伯海潜艇(鱼雷)', 59.2, 23.0],
] ]
strikeSources.forEach((r) => insertStrikeSource.run(...r)) strikeSources.forEach((r) => insertStrikeSource.run(...r))
@@ -202,6 +205,12 @@ function seed() {
israelLebanonTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('israel', lng, lat, name, struckAt)) israelLebanonTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('israel', lng, lat, name, struckAt))
lincolnTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('lincoln', lng, lat, name, struckAt)) lincolnTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('lincoln', lng, lat, name, struckAt))
fordTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('ford', lng, lat, name, struckAt)) fordTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('ford', lng, lat, name, struckAt))
const virginiaSrilankaTargets = [[79.8, 6.5, '德纳号轻型护卫舰', '2026-02-28T08:00:00.000Z']]
virginiaSrilankaTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('virginia_srilanka', lng, lat, name, struckAt))
const tomahawkArabianTargets = [[56.26, 27.18, '沙希德·鲁德基号/无人机航母', '2026-02-28T03:00:00.000Z']]
tomahawkArabianTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('tomahawk_arabian', lng, lat, name, struckAt))
const arabianSubTorpedoTargets = [[56.26, 27.18, '沙希德·鲁德基号/无人机航母', '2026-02-28T03:00:00.000Z']]
arabianSubTorpedoTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('arabian_sea_sub_torpedo', lng, lat, name, struckAt))
try { try {
db.exec(` db.exec(`

View File

@@ -33,7 +33,6 @@ export function BaseStatusPanel({ keyLocations, className = '' }: BaseStatusPane
<div className="mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-military-text-secondary"> <div className="mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-military-text-secondary">
<MapPin className="h-3 w-3 shrink-0 text-blue-400" /> <MapPin className="h-3 w-3 shrink-0 text-blue-400" />
<span className="normal-case opacity-75"> AI </span>
</div> </div>
<div className="flex flex-col gap-1.5 text-xs tabular-nums"> <div className="flex flex-col gap-1.5 text-xs tabular-nums">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">

View File

@@ -32,7 +32,6 @@ export function IranBaseStatusPanel({ keyLocations = [], className = '' }: IranB
<div className="mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-military-text-secondary"> <div className="mb-2 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-military-text-secondary">
<MapPin className="h-3 w-3 shrink-0 text-amber-500" /> <MapPin className="h-3 w-3 shrink-0 text-amber-500" />
<span className="normal-case opacity-75"> AI </span>
</div> </div>
<div className="flex flex-col gap-1.5 text-xs tabular-nums"> <div className="flex flex-col gap-1.5 text-xs tabular-nums">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">

View File

@@ -60,6 +60,8 @@ const TEHRAN_SOURCE: [number, number] = [51.389, 35.6892]
/** 攻击动画时间衰减N 天内按天衰减(脉冲缩小、频次降低),超出仅保留减弱呼吸效果;天数可在编辑面板配置 */ /** 攻击动画时间衰减N 天内按天衰减(脉冲缩小、频次降低),超出仅保留减弱呼吸效果;天数可在编辑面板配置 */
const MS_PER_DAY = 24 * 60 * 60 * 1000 const MS_PER_DAY = 24 * 60 * 60 * 1000
/** 手动添加的打击源:始终参与实时渲染列表,用 strikeCutoffDays 做衰减;仅在图例选中该项时不衰减 */
const MANUAL_STRIKE_SOURCE_IDS = new Set(['virginia_srilanka', 'tomahawk_arabian', 'arabian_sea_sub_torpedo'])
function isWithinAnimationWindow( function isWithinAnimationWindow(
iso: string | null | undefined, iso: string | null | undefined,
referenceTime: string, referenceTime: string,
@@ -105,8 +107,11 @@ const FALLBACK_STRIKE_SOURCES: { id: string; name: string; lng: number; lat: num
{ id: 'israel', name: '以色列', lng: 34.78, lat: 32.08 }, { id: 'israel', name: '以色列', lng: 34.78, lat: 32.08 },
{ id: 'lincoln', name: '林肯号航母', lng: 58.4215, lat: 24.1568 }, { id: 'lincoln', name: '林肯号航母', lng: 58.4215, lat: 24.1568 },
{ id: 'ford', name: '福特号航母', lng: 24.1002, lat: 35.7397 }, { id: 'ford', name: '福特号航母', lng: 24.1002, lat: 35.7397 },
{ id: 'virginia_srilanka', name: '洛杉矶级攻击核潜艇(斯里兰卡外海)', lng: 78.5, lat: 5.2 },
{ id: 'tomahawk_arabian', name: '阿拉伯海潜艇(战斧)', lng: 59.2, lat: 23.0 },
{ id: 'arabian_sea_sub_torpedo', name: '阿拉伯海潜艇(鱼雷)', lng: 59.2, lat: 23.0 },
] ]
const FALLBACK_STRIKE_LINES: { sourceId: string; targets: { lng: number; lat: number; name?: string }[] }[] = [ const FALLBACK_STRIKE_LINES: { sourceId: string; targets: { lng: number; lat: number; name?: string; struck_at?: string | null }[] }[] = [
{ {
sourceId: 'israel', sourceId: 'israel',
targets: [ targets: [
@@ -143,6 +148,24 @@ const FALLBACK_STRIKE_LINES: { sourceId: string; targets: { lng: number; lat: nu
{ lng: 48.35, lat: 33.48, name: '霍拉马巴德储备库' }, { lng: 48.35, lat: 33.48, name: '霍拉马巴德储备库' },
], ],
}, },
{
sourceId: 'virginia_srilanka',
targets: [
{ lng: 79.8, lat: 6.5, name: '德纳号轻型护卫舰', struck_at: '2026-02-28T08:00:00.000Z' },
],
},
{
sourceId: 'tomahawk_arabian',
targets: [
{ lng: 56.26, lat: 27.18, name: '沙希德·鲁德基号/无人机航母', struck_at: '2026-02-28T03:00:00.000Z' },
],
},
{
sourceId: 'arabian_sea_sub_torpedo',
targets: [
{ lng: 56.26, lat: 27.18, name: '沙希德·鲁德基号/无人机航母', struck_at: '2026-02-28T03:00:00.000Z' },
],
},
] ]
/** 二次贝塞尔曲线路径,更平滑的弧线 height 控制弧高 */ /** 二次贝塞尔曲线路径,更平滑的弧线 height 控制弧高 */
@@ -232,6 +255,9 @@ export function WarMap() {
const lincolnPathsRef = useRef<[number, number][][]>([]) const lincolnPathsRef = useRef<[number, number][][]>([])
const fordPathsRef = useRef<[number, number][][]>([]) const fordPathsRef = useRef<[number, number][][]>([])
const israelPathsRef = useRef<[number, number][][]>([]) const israelPathsRef = useRef<[number, number][][]>([])
const virginiaPathsRef = useRef<[number, number][][]>([])
const tomahawkPathsRef = useRef<[number, number][][]>([])
const arabianSubTorpedoPathsRef = useRef<[number, number][][]>([])
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)
@@ -240,6 +266,8 @@ export function WarMap() {
| 'lincoln' | 'lincoln'
| 'ford' | 'ford'
| 'israel' | 'israel'
| 'virginia_srilanka'
| 'tomahawk_arabian'
| 'hezbollah' | 'hezbollah'
| 'iranToUs' | 'iranToUs'
| 'iran' | 'iran'
@@ -356,21 +384,40 @@ export function WarMap() {
}, [usForces.keyLocations, iranForces.keyLocations]) }, [usForces.keyLocations, iranForces.keyLocations])
const mapData = situation.mapData const mapData = situation.mapData
const strikeSources = /** 打击源API 有则用,并合并手动场景源(斯里兰卡潜艇、阿巴斯港战斧+潜艇鱼雷),缺则从 fallback 补全 */
mapData?.strikeSources?.length > 0 ? mapData.strikeSources : FALLBACK_STRIKE_SOURCES const strikeSources = useMemo(() => {
const strikeLines = const fromApi = mapData?.strikeSources?.length > 0 ? mapData.strikeSources : FALLBACK_STRIKE_SOURCES
mapData?.strikeLines?.length > 0 ? mapData.strikeLines : FALLBACK_STRIKE_LINES const ids = new Set((fromApi as { id: string }[]).map((s) => s.id))
const toAdd = FALLBACK_STRIKE_SOURCES.filter((s) => !ids.has(s.id))
return toAdd.length > 0 ? [...fromApi, ...toAdd] : fromApi
}, [mapData?.strikeSources])
/** 打击线API 有则用 API并始终合并斯里兰卡/阿巴斯港(战斧+潜艇) fallback保证场景必有数据可渲染 */
const strikeLines = useMemo(() => {
const fromApi = mapData?.strikeLines?.length > 0 ? mapData.strikeLines : FALLBACK_STRIKE_LINES
const hasVirginia = fromApi.some((l) => l.sourceId === 'virginia_srilanka')
const hasTomahawk = fromApi.some((l) => l.sourceId === 'tomahawk_arabian')
const hasArabianSubTorpedo = fromApi.some((l) => l.sourceId === 'arabian_sea_sub_torpedo')
const extra = [
...(hasVirginia ? [] : FALLBACK_STRIKE_LINES.filter((l) => l.sourceId === 'virginia_srilanka')),
...(hasTomahawk ? [] : FALLBACK_STRIKE_LINES.filter((l) => l.sourceId === 'tomahawk_arabian')),
...(hasArabianSubTorpedo ? [] : FALLBACK_STRIKE_LINES.filter((l) => l.sourceId === 'arabian_sea_sub_torpedo')),
]
return extra.length > 0 ? [...fromApi, ...extra] : fromApi
}, [mapData?.strikeLines])
/** 伊朗→美军基地:仅用 DB 数据N 天内显示飞行动画 */ /** 伊朗→美军基地:仅用 DB 数据N 天内显示飞行动画 */
const strikeCutoffDays = situation.animationConfig?.strikeCutoffDays ?? 5 const strikeCutoffDays = situation.animationConfig?.strikeCutoffDays ?? 5
/** 实时模式:渲染最近 1 天内的进展(主视角);默认开启 */ /** 实时模式:渲染最近 5 天内的进展(主视角);全部=不限时长 */
const REALTIME_CUTOFF_DAYS = 1 const REALTIME_CUTOFF_DAYS = 5
const UNLIMITED_CUTOFF_DAYS = 365
const [isRealtimeView, setIsRealtimeView] = useState(true) const [isRealtimeView, setIsRealtimeView] = useState(true)
const isRealtimeViewRef = useRef(true) const isRealtimeViewRef = useRef(true)
isRealtimeViewRef.current = isRealtimeView isRealtimeViewRef.current = isRealtimeView
/** 未选中单项时:全部=用配置天数,实时=1 天;选中某项时用配置天数(该单项完整动画) */ /** 未选中单项时:全部=不限时长(365天),实时=5 天;选中某项时用 strikeCutoffDays(该单项完整动画) */
const effectiveCutoffDays = const effectiveCutoffDays =
strikeLegendFilter === null && isRealtimeView ? REALTIME_CUTOFF_DAYS : strikeCutoffDays strikeLegendFilter === null
? (isRealtimeView ? REALTIME_CUTOFF_DAYS : UNLIMITED_CUTOFF_DAYS)
: strikeCutoffDays
const attackPaths = useMemo(() => { const attackPaths = useMemo(() => {
const attacked = (usForces.keyLocations || []).filter( const attacked = (usForces.keyLocations || []).filter(
@@ -396,7 +443,7 @@ export function WarMap() {
return m return m
}, [strikeSources]) }, [strikeSources])
/** 盟军打击线:仅用 effectiveCutoffDays 内目标显示飞行动画(全部=配置天数,实时=1 天) */ /** 盟军打击线:仅用 effectiveCutoffDays 内目标显示飞行动画(全部=不限时长,实时=5 天) */
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, effectiveCutoffDays)), targets.filter((t) => isWithinAnimationWindow(t.struck_at ?? null, referenceTime, effectiveCutoffDays)),
@@ -448,6 +495,41 @@ export function WarMap() {
return line.targets.map((t) => parabolaPath(coords, [t.lng, t.lat])) return line.targets.map((t) => parabolaPath(coords, [t.lng, t.lat]))
}, [strikeLines, sourceCoords]) }, [strikeLines, sourceCoords])
/** 场景 A斯里兰卡深海伏击 — 鱼雷轨迹为直线(潜艇 [78.5,5.2] → 驱逐舰 [79.8,6.5]),不采用抛物线 */
const virginiaSrilankaPaths = useMemo(() => {
const line = strikeLines.find((l) => l.sourceId === 'virginia_srilanka')
const coords = sourceCoords.virginia_srilanka
if (!coords || !line) return []
return line.targets.map((t) => [coords, [t.lng, t.lat]] as [number, number][])
}, [strikeLines, sourceCoords])
/** 场景 B阿拉伯海战斧 → 阿巴斯港,抛物线弹道 */
const tomahawkArabianPaths = useMemo(() => {
const line = strikeLines.find((l) => l.sourceId === 'tomahawk_arabian')
const coords = sourceCoords.tomahawk_arabian
if (!coords || !line) return []
return line.targets.map((t) => parabolaPath(coords, [t.lng, t.lat]))
}, [strikeLines, sourceCoords])
/** 上述两场景在时间窗口内的路径(与林肯/福特/以色列一致,用于实时模式) */
const effectiveVirginiaSrilankaPaths = useMemo(() => {
const line = strikeLines.find((l) => l.sourceId === 'virginia_srilanka')
const coords = sourceCoords.virginia_srilanka
if (!coords || !line) return []
return filterTargetsByAnimationWindow(line.targets).map((t) => [coords, [t.lng, t.lat]] as [number, number][])
}, [strikeLines, sourceCoords, filterTargetsByAnimationWindow])
const effectiveTomahawkArabianPaths = useMemo(() => {
const line = strikeLines.find((l) => l.sourceId === 'tomahawk_arabian')
const coords = sourceCoords.tomahawk_arabian
if (!coords || !line) return []
return filterTargetsByAnimationWindow(line.targets).map((t) => parabolaPath(coords, [t.lng, t.lat]))
}, [strikeLines, sourceCoords, filterTargetsByAnimationWindow])
/** 场景 B阿巴斯港 — 同艇潜射/鱼雷轨迹(直线,与战斧同时) */
const arabianSubTorpedoPaths = useMemo(() => {
const line = strikeLines.find((l) => l.sourceId === 'arabian_sea_sub_torpedo')
const coords = sourceCoords.arabian_sea_sub_torpedo
if (!coords || !line) return []
return line.targets.map((t) => [coords, [t.lng, t.lat]] as [number, number][])
}, [strikeLines, sourceCoords])
/** 黎巴嫩→以色列:攻击源为黎巴嫩境内多处(提尔、西顿、巴勒贝克等),目标为以色列北部 */ /** 黎巴嫩→以色列:攻击源为黎巴嫩境内多处(提尔、西顿、巴勒贝克等),目标为以色列北部 */
const hezbollahPaths = useMemo(() => { const hezbollahPaths = useMemo(() => {
const sources = EXTENDED_WAR_ZONES.lebanonStrikeSources const sources = EXTENDED_WAR_ZONES.lebanonStrikeSources
@@ -481,21 +563,24 @@ export function WarMap() {
const warMapData = useWarMapData() const warMapData = useWarMapData()
/** 当前参与动画的目标的最小衰减系数,用于脉冲范围与攻击频次(缩小脉冲、拉长飞行周期) */ /** 当前参与动画的目标的最小衰减系数;手动打击(virginia/tomahawk)用 strikeCutoffDays 参与衰减,其余用 effectiveCutoffDays */
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, effectiveCutoffDays)) .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))) .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)) { const cutoffDays = MANUAL_STRIKE_SOURCE_IDS.has(line.sourceId) ? strikeCutoffDays : effectiveCutoffDays
decays.push(getDecayFactor(t.struck_at ?? null, referenceTime, effectiveCutoffDays)) const inWindow = (t: { struck_at?: string | null }) => isWithinAnimationWindow(t.struck_at ?? null, referenceTime, cutoffDays)
for (const t of line.targets) {
if (!inWindow(t)) continue
decays.push(getDecayFactor((t as { struck_at?: string | null }).struck_at ?? null, referenceTime, cutoffDays))
} }
} }
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, effectiveCutoffDays, filterTargetsByAnimationWindow, hormuzPaths.length, hezbollahPaths.length]) }, [usForces.keyLocations, strikeLines, referenceTime, effectiveCutoffDays, strikeCutoffDays, hormuzPaths.length, hezbollahPaths.length])
const animationDecayRef = useRef(1) const animationDecayRef = useRef(1)
animationDecayRef.current = animationDecayFactor animationDecayRef.current = animationDecayFactor
@@ -534,9 +619,31 @@ export function WarMap() {
const effectiveHormuzPaths = const effectiveHormuzPaths =
strikeLegendFilter === 'hormuz' || strikeLegendFilter === 'iran' ? hormuzPaths : strikeLegendFilter === null ? hormuzPaths : [] strikeLegendFilter === 'hormuz' || strikeLegendFilter === 'iran' ? hormuzPaths : strikeLegendFilter === null ? hormuzPaths : []
/** 斯里兰卡伏击 / 阿巴斯港突袭(手动打击):实时模式下始终进入渲染列表并做衰减;图例选中时完整动画不衰减 */
const effectiveVirginiaSrilankaPathsDisplay =
strikeLegendFilter === 'virginia_srilanka'
? virginiaSrilankaPaths
: strikeLegendFilter === null
? virginiaSrilankaPaths
: []
const effectiveTomahawkArabianPathsDisplay =
strikeLegendFilter === 'tomahawk_arabian'
? tomahawkArabianPaths
: strikeLegendFilter === null
? tomahawkArabianPaths
: []
/** 阿巴斯港潜艇直线(与战斧同时):与 tomahawk_arabian 同显 */
const effectiveArabianSubTorpedoPathsDisplay =
strikeLegendFilter === 'tomahawk_arabian' || strikeLegendFilter === null
? arabianSubTorpedoPaths
: []
lincolnPathsRef.current = effectiveLincolnPaths lincolnPathsRef.current = effectiveLincolnPaths
fordPathsRef.current = effectiveFordPaths fordPathsRef.current = effectiveFordPaths
israelPathsRef.current = effectiveIsraelPaths israelPathsRef.current = effectiveIsraelPaths
virginiaPathsRef.current = effectiveVirginiaSrilankaPathsDisplay
tomahawkPathsRef.current = effectiveTomahawkArabianPathsDisplay
arabianSubTorpedoPathsRef.current = effectiveArabianSubTorpedoPathsDisplay
hezbollahPathsRef.current = effectiveHezbollahPaths hezbollahPathsRef.current = effectiveHezbollahPaths
hormuzPathsRef.current = effectiveHormuzPaths hormuzPathsRef.current = effectiveHormuzPaths
attackPathsRef.current = effectiveAttackPaths attackPathsRef.current = effectiveAttackPaths
@@ -566,6 +673,38 @@ export function WarMap() {
if (sourceCoords.israel) push(sourceCoords.israel) if (sourceCoords.israel) push(sourceCoords.israel)
flattenPaths(israelPathsAll) flattenPaths(israelPathsAll)
break break
case 'virginia_srilanka': {
const src = sourceCoords.virginia_srilanka
const paths = effectiveVirginiaSrilankaPathsDisplay
if (src && paths.length > 0) {
const tgt = paths[0][paths[0].length - 1]
const padLine = 1.2
return [
Math.min(src[0], tgt[0]) - padLine,
Math.min(src[1], tgt[1]) - padLine,
Math.max(src[0], tgt[0]) + padLine,
Math.max(src[1], tgt[1]) + padLine,
]
}
break
}
case 'tomahawk_arabian': {
const src = sourceCoords.tomahawk_arabian ?? sourceCoords.arabian_sea_sub_torpedo
const paths = [...effectiveTomahawkArabianPathsDisplay, ...effectiveArabianSubTorpedoPathsDisplay]
if (src && paths.length > 0) {
const allCoords = paths.flatMap((p) => p)
const lngs = allCoords.map((c) => c[0])
const lats = allCoords.map((c) => c[1])
const padLine = 1.2
return [
Math.min(...lngs) - padLine,
Math.min(...lats) - padLine,
Math.max(...lngs) + padLine,
Math.max(...lats) + padLine,
]
}
break
}
case 'hezbollah': case 'hezbollah':
flattenPaths(hezbollahPaths) flattenPaths(hezbollahPaths)
break break
@@ -616,6 +755,9 @@ export function WarMap() {
lincolnPathsAll, lincolnPathsAll,
fordPathsAll, fordPathsAll,
israelPathsAll, israelPathsAll,
effectiveVirginiaSrilankaPathsDisplay,
effectiveTomahawkArabianPathsDisplay,
effectiveArabianSubTorpedoPathsDisplay,
hezbollahPaths, hezbollahPaths,
attackPathsAll, attackPathsAll,
] ]
@@ -660,16 +802,113 @@ export function WarMap() {
}), }),
[effectiveIsraelPaths] [effectiveIsraelPaths]
) )
/** 盟军打击目标点位(林肯/福特/以色列→伊朗+以色列→黎巴嫩)。全部/单项:显示全部目标;实时:仅 effectiveCutoffDays 内 */ /** 场景 A斯里兰卡鱼雷轨迹 — 直线、蓝色虚线Mk 48 ADCAP 声纳制导路径) */
const submarineTorpedoLinesGeoJson = useMemo(
() => ({
type: 'FeatureCollection' as const,
features:
strikeLegendFilter === null || strikeLegendFilter === 'virginia_srilanka'
? effectiveVirginiaSrilankaPathsDisplay.map((coords) => ({
type: 'Feature' as const,
properties: { weapon: 'Mk 48 Torpedo', status: 'Impact Confirmed' },
geometry: { type: 'LineString' as const, coordinates: coords },
}))
: [],
}),
[effectiveVirginiaSrilankaPathsDisplay, strikeLegendFilter]
)
/** 场景 B阿拉伯海战斧弹道 — 抛物线(阿巴斯港/沙希德·鲁德基号) */
const tomahawkStrikeLinesGeoJson = useMemo(
() => ({
type: 'FeatureCollection' as const,
features:
strikeLegendFilter === null || strikeLegendFilter === 'tomahawk_arabian'
? effectiveTomahawkArabianPathsDisplay.map((coords) => ({
type: 'Feature' as const,
properties: { target: 'Drone Carrier Shahid Roudaki', damage: 'Critical/Sunk' },
geometry: { type: 'LineString' as const, coordinates: coords },
}))
: [],
}),
[effectiveTomahawkArabianPathsDisplay, strikeLegendFilter]
)
/** 场景 B阿巴斯港同艇潜射/鱼雷 — 直线(与战斧同时) */
const arabianSubTorpedoLinesGeoJson = useMemo(
() => ({
type: 'FeatureCollection' as const,
features:
strikeLegendFilter === null || strikeLegendFilter === 'tomahawk_arabian'
? effectiveArabianSubTorpedoPathsDisplay.map((coords) => ({
type: 'Feature' as const,
properties: { weapon: 'Sub torpedo', status: 'Impact Confirmed' },
geometry: { type: 'LineString' as const, coordinates: coords },
}))
: [],
}),
[effectiveArabianSubTorpedoPathsDisplay, strikeLegendFilter]
)
/** 战场态势标注:红圈×斯里兰卡外海驱逐舰沉没;紫色◆阿巴斯港高价值目标摧毁 */
const tacticalSymbolsGeoJson = useMemo(() => {
const showAll = strikeLegendFilter === null
const showVirginia = strikeLegendFilter === 'virginia_srilanka'
const showTomahawk = strikeLegendFilter === 'tomahawk_arabian'
const features: GeoJSON.Feature<GeoJSON.Point, { symbol: string; name: string; target?: string; damage?: string }>[] = []
if (showAll || showVirginia) {
features.push({
type: 'Feature' as const,
properties: { symbol: 'destruction', name: 'Destroyed Surface Combatant' },
geometry: { type: 'Point' as const, coordinates: [79.8, 6.5] as [number, number] },
})
}
if (showAll || showTomahawk) {
features.push({
type: 'Feature' as const,
properties: {
symbol: 'explosion',
name: 'Port Facility/High Value Asset Neutralized',
target: 'Drone Carrier Shahid Roudaki',
damage: 'Critical/Sunk',
},
geometry: { type: 'Point' as const, coordinates: [56.26, 27.18] as [number, number] },
})
}
return { type: 'FeatureCollection' as const, features }
}, [strikeLegendFilter])
/** 盟军打击发起点地标(林肯/福特/以色列/斯里兰卡潜艇/阿拉伯海潜艇):样式统一,仅阵营颜色不同 */
const alliedStrikeSourcesGeoJson = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: strikeSources.map((s) => ({
type: 'Feature' as const,
properties: { id: s.id, name: s.name },
geometry: { type: 'Point' as const, coordinates: [s.lng, s.lat] as [number, number] },
})),
}),
[strikeSources]
)
/** 盟军打击目标点位(林肯/福特/以色列→伊朗+以色列→黎巴嫩+斯里兰卡/阿巴斯港)。全部/单项:显示全部目标;实时:仅 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 }>[] = []
const onlyRealtimeFilter = const onlyRealtimeFilter =
strikeLegendFilter === null && isRealtimeView strikeLegendFilter === null && isRealtimeView
for (const line of strikeLines) { for (const line of strikeLines) {
if (strikeLegendFilter != null && strikeLegendFilter !== 'lincoln' && strikeLegendFilter !== 'ford' && strikeLegendFilter !== 'israel') continue if (
strikeLegendFilter != null &&
strikeLegendFilter !== 'lincoln' &&
strikeLegendFilter !== 'ford' &&
strikeLegendFilter !== 'israel' &&
strikeLegendFilter !== 'virginia_srilanka' &&
strikeLegendFilter !== 'tomahawk_arabian' &&
strikeLegendFilter !== 'arabian_sea_sub_torpedo'
)
continue
if (strikeLegendFilter === 'lincoln' && line.sourceId !== 'lincoln') continue if (strikeLegendFilter === 'lincoln' && line.sourceId !== 'lincoln') continue
if (strikeLegendFilter === 'ford' && line.sourceId !== 'ford') continue if (strikeLegendFilter === 'ford' && line.sourceId !== 'ford') continue
if (strikeLegendFilter === 'israel' && line.sourceId !== 'israel') continue if (strikeLegendFilter === 'israel' && line.sourceId !== 'israel') continue
if (strikeLegendFilter === 'virginia_srilanka' && line.sourceId !== 'virginia_srilanka') continue
if (strikeLegendFilter === 'tomahawk_arabian' && line.sourceId !== 'tomahawk_arabian' && line.sourceId !== 'arabian_sea_sub_torpedo') continue
if (strikeLegendFilter === 'arabian_sea_sub_torpedo' && line.sourceId !== 'arabian_sea_sub_torpedo') continue
for (const t of line.targets) { for (const t of line.targets) {
if (onlyRealtimeFilter && !isWithinAnimationWindow((t as { struck_at?: string | null }).struck_at ?? null, referenceTime, effectiveCutoffDays)) if (onlyRealtimeFilter && !isWithinAnimationWindow((t as { struck_at?: string | null }).struck_at ?? null, referenceTime, effectiveCutoffDays))
continue continue
@@ -929,6 +1168,60 @@ export function WarMap() {
}) })
israelSrc.setData({ type: 'FeatureCollection', features }) israelSrc.setData({ type: 'FeatureCollection', features })
} }
// 斯里兰卡伏击:鱼雷轨迹蓝色光点(直线路径)
const virginiaSrc = map.getSource('allied-strike-dots-virginia') as
| { setData: (d: GeoJSON.FeatureCollection) => void }
| undefined
const virginiaPaths = virginiaPathsRef.current
if (virginiaSrc && virginiaPaths.length > 0) {
const features: GeoJSON.Feature<GeoJSON.Point>[] = []
virginiaPaths.forEach((path, i) => {
if (i % step !== 0) return
const progress = (elapsed / FLIGHT_DURATION_MS + 0.6 + i / Math.max(virginiaPaths.length, 1)) % 1
features.push({
type: 'Feature' as const,
properties: {},
geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) },
})
})
virginiaSrc.setData({ type: 'FeatureCollection', features })
}
// 阿巴斯港突袭:战斧弹道紫色光点(抛物线路径)
const tomahawkSrc = map.getSource('allied-strike-dots-tomahawk') as
| { setData: (d: GeoJSON.FeatureCollection) => void }
| undefined
const tomahawkPaths = tomahawkPathsRef.current
if (tomahawkSrc && tomahawkPaths.length > 0) {
const features: GeoJSON.Feature<GeoJSON.Point>[] = []
tomahawkPaths.forEach((path, i) => {
if (i % step !== 0) return
const progress = (elapsed / FLIGHT_DURATION_MS + 0.4 + i / Math.max(tomahawkPaths.length, 1)) % 1
features.push({
type: 'Feature' as const,
properties: {},
geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) },
})
})
tomahawkSrc.setData({ type: 'FeatureCollection', features })
}
// 阿巴斯港同艇潜射/鱼雷光点(直线路径,与战斧同时)
const arabianSubSrc = map.getSource('allied-strike-dots-arabian-sub') as
| { setData: (d: GeoJSON.FeatureCollection) => void }
| undefined
const arabianSubPaths = arabianSubTorpedoPathsRef.current
if (arabianSubSrc && arabianSubPaths.length > 0) {
const features: GeoJSON.Feature<GeoJSON.Point>[] = []
arabianSubPaths.forEach((path, i) => {
if (i % step !== 0) return
const progress = (elapsed / FLIGHT_DURATION_MS + 0.5 + i / Math.max(arabianSubPaths.length, 1)) % 1
features.push({
type: 'Feature' as const,
properties: {},
geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) },
})
})
arabianSubSrc.setData({ type: 'FeatureCollection', features })
}
// 真主党打击以色列北部:橙红光点,与林肯/福特/以色列同一动画方式 // 真主党打击以色列北部:橙红光点,与林肯/福特/以色列同一动画方式
const hezSrc = map.getSource('hezbollah-strike-dots') as const hezSrc = map.getSource('hezbollah-strike-dots') as
| { setData: (d: GeoJSON.FeatureCollection) => void } | { setData: (d: GeoJSON.FeatureCollection) => void }
@@ -1085,6 +1378,9 @@ export function WarMap() {
(map.getSource('allied-strike-dots-lincoln') && lincolnPathsRef.current.length > 0) || (map.getSource('allied-strike-dots-lincoln') && lincolnPathsRef.current.length > 0) ||
(map.getSource('allied-strike-dots-ford') && fordPathsRef.current.length > 0) || (map.getSource('allied-strike-dots-ford') && fordPathsRef.current.length > 0) ||
(map.getSource('allied-strike-dots-israel') && israelPathsRef.current.length > 0) || (map.getSource('allied-strike-dots-israel') && israelPathsRef.current.length > 0) ||
(map.getSource('allied-strike-dots-virginia') && virginiaPathsRef.current.length > 0) ||
(map.getSource('allied-strike-dots-tomahawk') && tomahawkPathsRef.current.length > 0) ||
(map.getSource('allied-strike-dots-arabian-sub') && arabianSubTorpedoPathsRef.current.length > 0) ||
(map.getSource('hezbollah-strike-dots') && hezbollahPathsRef.current.length > 0) || (map.getSource('hezbollah-strike-dots') && hezbollahPathsRef.current.length > 0) ||
(map.getSource('iran-hormuz-dots') && hormuzPathsRef.current.length > 0) || (map.getSource('iran-hormuz-dots') && hormuzPathsRef.current.length > 0) ||
map.getSource('kurdish-pincer-growth') || map.getSource('kurdish-pincer-growth') ||
@@ -1262,7 +1558,7 @@ export function WarMap() {
fitToTheater({ duration: 500 }) 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'}`} 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 天)" title="主视角,仅渲染最新进展(近 5 天)"
> >
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> <span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
</button> </button>
@@ -1290,6 +1586,22 @@ export function WarMap() {
> >
<span className="h-1.5 w-1.5 rounded-full bg-[#22D3EE]" /> <span className="h-1.5 w-1.5 rounded-full bg-[#22D3EE]" />
</button> </button>
<button
type="button"
onClick={() => toggleStrikeFilter('virginia_srilanka')}
className={`flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${strikeLegendFilter === 'virginia_srilanka' ? 'ring-1 ring-blue-400 bg-blue-400/20' : 'hover:bg-white/10'}`}
title="斯里兰卡海域深海伏击 — 鱼雷轨迹"
>
<span className="h-1.5 w-1.5 rounded-full bg-blue-400" />
</button>
<button
type="button"
onClick={() => toggleStrikeFilter('tomahawk_arabian')}
className={`flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${strikeLegendFilter === 'tomahawk_arabian' ? 'ring-1 ring-purple-400 bg-purple-400/20' : 'hover:bg-white/10'}`}
title="阿巴斯港/沙希德·鲁德基号远程打击"
>
<span className="h-1.5 w-1.5 rounded-full bg-purple-400" />
</button>
<button <button
type="button" type="button"
onClick={() => toggleStrikeFilter('hormuz')} onClick={() => toggleStrikeFilter('hormuz')}
@@ -1833,6 +2145,65 @@ export function WarMap() {
/> />
</Source> </Source>
{/* 盟军打击发起点地标:与目标点样式统一(大小/描边一致),仅颜色区分阵营 */}
<Source id="allied-strike-sources" type="geojson" data={alliedStrikeSourcesGeoJson}>
<Layer
id="allied-strike-sources-circle"
type="circle"
filter={
strikeLegendFilter == null
? ['in', ['get', 'id'], ['literal', ['lincoln', 'ford', 'israel', 'virginia_srilanka', 'tomahawk_arabian', 'arabian_sea_sub_torpedo']]]
: strikeLegendFilter === 'tomahawk_arabian'
? ['in', ['get', 'id'], ['literal', ['tomahawk_arabian', 'arabian_sea_sub_torpedo']]]
: ['==', ['get', 'id'], strikeLegendFilter]
}
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 1.5, 5, 2.5, 8, 4],
'circle-color': [
'match',
['get', 'id'],
'lincoln',
'#3B82F6',
'ford',
'#06B6D4',
'israel',
'#22D3EE',
'virginia_srilanka',
'#3B82F6',
'tomahawk_arabian',
'#A855F7',
'arabian_sea_sub_torpedo',
'#06B6D4',
'#94A3B8',
],
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 7, 1],
'circle-stroke-color': '#fff',
}}
/>
<Layer
id="allied-strike-sources-label"
type="symbol"
filter={
strikeLegendFilter == null
? ['in', ['get', 'id'], ['literal', ['lincoln', 'ford', 'israel', 'virginia_srilanka', 'tomahawk_arabian', 'arabian_sea_sub_torpedo']]]
: strikeLegendFilter === 'tomahawk_arabian'
? ['in', ['get', 'id'], ['literal', ['tomahawk_arabian', 'arabian_sea_sub_torpedo']]]
: ['==', ['get', 'id'], strikeLegendFilter]
}
layout={{
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 3, 6, 5, 8, 7, 10, 10, 13],
'text-anchor': 'top',
'text-offset': [0, 0.8],
}}
paint={{
'text-color': '#FFFFFF',
'text-halo-color': '#1a1a1a',
'text-halo-width': 1,
}}
/>
</Source>
{/* 美以联军打击伊朗:路径线 */} {/* 美以联军打击伊朗:路径线 */}
<Source id="allied-strike-lines-lincoln" type="geojson" data={lincolnLinesGeoJson}> <Source id="allied-strike-lines-lincoln" type="geojson" data={lincolnLinesGeoJson}>
<Layer <Layer
@@ -1864,6 +2235,85 @@ export function WarMap() {
}} }}
/> />
</Source> </Source>
{/* 场景 A斯里兰卡海域鱼雷轨迹 — 蓝色虚线Mk 48 ADCAP 声纳制导路径) */}
<Source id="submarine-torpedo-lines" type="geojson" data={submarineTorpedoLinesGeoJson}>
<Layer
id="submarine-torpedo-lines"
type="line"
paint={{
'line-color': 'rgba(59, 130, 246, 0.7)',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 8, 1.5, 12, 2.5],
'line-dasharray': [2, 1.5],
}}
/>
</Source>
{/* 场景 B阿拉伯海战斧 → 阿巴斯港无人机航母 */}
<Source id="tomahawk-strike-lines" type="geojson" data={tomahawkStrikeLinesGeoJson}>
<Layer
id="tomahawk-strike-lines"
type="line"
paint={{
'line-color': 'rgba(168, 85, 247, 0.5)',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.5, 8, 1, 12, 2],
}}
/>
</Source>
{/* 场景 B阿巴斯港同艇潜射/鱼雷 — 直线、青虚线(与战斧同时) */}
<Source id="arabian-sub-torpedo-lines" type="geojson" data={arabianSubTorpedoLinesGeoJson}>
<Layer
id="arabian-sub-torpedo-lines"
type="line"
paint={{
'line-color': 'rgba(6, 182, 212, 0.65)',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.6, 8, 1.2, 12, 2],
'line-dasharray': [2, 1.5],
}}
/>
</Source>
{/* 战术态势标注:红圈 X 驱逐舰沉没 / 紫色爆炸云 港口高价值目标摧毁 */}
<Source id="tactical-symbols" type="geojson" data={tacticalSymbolsGeoJson}>
<Layer
id="tactical-symbols-circle"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 4, 8, 8, 12, 12],
'circle-color': ['match', ['get', 'symbol'], 'destruction', '#EF4444', 'explosion', '#A855F7', '#94A3B8'],
'circle-stroke-width': 1.5,
'circle-stroke-color': '#fff',
'circle-opacity': 0.9,
}}
/>
<Layer
id="tactical-symbols-icon"
type="symbol"
layout={{
'text-field': ['match', ['get', 'symbol'], 'destruction', '×', 'explosion', '◆', ''],
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 10, 8, 14, 12, 18],
'text-anchor': 'center',
'text-allow-overlap': true,
}}
paint={{
'text-color': '#fff',
'text-halo-color': 'rgba(0,0,0,0.6)',
'text-halo-width': 1,
}}
/>
<Layer
id="tactical-symbols-label"
type="symbol"
layout={{
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 7, 8, 9, 10, 11],
'text-anchor': 'top',
'text-offset': [0, 0.6],
}}
paint={{
'text-color': '#E5E7EB',
'text-halo-color': '#1a1a1a',
'text-halo-width': 1,
}}
/>
</Source>
{/* 林肯号打击光点 */} {/* 林肯号打击光点 */}
<Source <Source
id="allied-strike-dots-lincoln" id="allied-strike-dots-lincoln"
@@ -1963,7 +2413,106 @@ export function WarMap() {
}} }}
/> />
</Source> </Source>
{/* 盟军打击目标点位 (蓝色):含林肯/福特/以色列→伊朗 + 以色列→黎巴嫩,统一名称与脉冲动效 */} {/* 斯里兰卡伏击:鱼雷轨迹光点(蓝色) */}
<Source
id="allied-strike-dots-virginia"
type="geojson"
data={{
type: 'FeatureCollection',
features: effectiveVirginiaSrilankaPathsDisplay.map((path) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'Point' as const, coordinates: path[0] },
})),
}}
>
<Layer
id="allied-strike-dots-virginia-glow"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 8, 6, 12, 10],
'circle-color': 'rgba(59, 130, 246, 0.6)',
'circle-blur': 0.3,
}}
/>
<Layer
id="allied-strike-dots-virginia-core"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 1, 8, 2, 12, 4],
'circle-color': '#3B82F6',
'circle-stroke-width': 0.5,
'circle-stroke-color': '#fff',
}}
/>
</Source>
{/* 阿巴斯港突袭:战斧弹道光点(紫色) */}
<Source
id="allied-strike-dots-tomahawk"
type="geojson"
data={{
type: 'FeatureCollection',
features: effectiveTomahawkArabianPathsDisplay.map((path) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'Point' as const, coordinates: path[0] },
})),
}}
>
<Layer
id="allied-strike-dots-tomahawk-glow"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 8, 6, 12, 10],
'circle-color': 'rgba(168, 85, 247, 0.6)',
'circle-blur': 0.3,
}}
/>
<Layer
id="allied-strike-dots-tomahawk-core"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 1, 8, 2, 12, 4],
'circle-color': '#A855F7',
'circle-stroke-width': 0.5,
'circle-stroke-color': '#fff',
}}
/>
</Source>
{/* 阿巴斯港同艇潜射/鱼雷光点(青色,与战斧同时) */}
<Source
id="allied-strike-dots-arabian-sub"
type="geojson"
data={{
type: 'FeatureCollection',
features: effectiveArabianSubTorpedoPathsDisplay.map((path) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'Point' as const, coordinates: path[0] },
})),
}}
>
<Layer
id="allied-strike-dots-arabian-sub-glow"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 8, 6, 12, 10],
'circle-color': 'rgba(6, 182, 212, 0.6)',
'circle-blur': 0.3,
}}
/>
<Layer
id="allied-strike-dots-arabian-sub-core"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 1, 8, 2, 12, 4],
'circle-color': '#06B6D4',
'circle-stroke-width': 0.5,
'circle-stroke-color': '#fff',
}}
/>
</Source>
{/* 盟军打击目标点位 (蓝色):含林肯/福特/以色列→伊朗 + 以色列→黎巴嫩 + 斯里兰卡/阿巴斯港,统一名称与脉冲动效 */}
<Source <Source
id="allied-strike-targets" id="allied-strike-targets"
type="geojson" type="geojson"