fix: 新增态 效果
This commit is contained in:
@@ -6,7 +6,9 @@ import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import { useReplaySituation } from '@/hooks/useReplaySituation'
|
||||
import { usePlaybackStore } from '@/store/playbackStore'
|
||||
import { config } from '@/config'
|
||||
import { EXTENDED_WAR_ZONES } from '@/data/extendedWarData'
|
||||
import { EXTENDED_WAR_ZONES, KURDISH_FRONT_GEOJSON } from '@/data/extendedWarData'
|
||||
import { useWarMapData } from '@/hooks/useWarMapData'
|
||||
import { createTacticalPincerAtProgress } from '@/utils/tacticalPincerArrow'
|
||||
|
||||
const MAPBOX_TOKEN = config.mapboxAccessToken || ''
|
||||
|
||||
@@ -53,9 +55,32 @@ const ALLIES_ADMIN = [
|
||||
'Djibouti',
|
||||
]
|
||||
|
||||
// 伊朗攻击源 德黑兰 [lng, lat]
|
||||
// 伊朗攻击源 德黑兰 [lng, lat](若后端 map_strike_source 有 iran/tehran 则可由 API 覆盖)
|
||||
const TEHRAN_SOURCE: [number, number] = [51.389, 35.6892]
|
||||
|
||||
/** 攻击动画时间衰减:N 天内按天衰减(脉冲缩小、频次降低),超出仅保留减弱呼吸效果;天数可在编辑面板配置 */
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||
function isWithinAnimationWindow(
|
||||
iso: string | null | undefined,
|
||||
referenceTime: string,
|
||||
cutoffDays: number
|
||||
): boolean {
|
||||
if (!iso) return true
|
||||
const ref = new Date(referenceTime).getTime()
|
||||
const t = new Date(iso).getTime()
|
||||
const days = (ref - t) / MS_PER_DAY
|
||||
return days >= 0 && days <= cutoffDays
|
||||
}
|
||||
/** 衰减系数 0..1:天数越久越小,用于缩小脉冲范围、降低攻击频次 */
|
||||
function getDecayFactor(iso: string | null | undefined, referenceTime: string, cutoffDays: number): number {
|
||||
if (!iso) return 1
|
||||
const ref = new Date(referenceTime).getTime()
|
||||
const t = new Date(iso).getTime()
|
||||
const days = (ref - t) / MS_PER_DAY
|
||||
if (days < 0 || days > cutoffDays) return 0
|
||||
return Math.max(0, 1 - days / cutoffDays)
|
||||
}
|
||||
|
||||
// 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 },
|
||||
@@ -70,6 +95,11 @@ const FALLBACK_STRIKE_LINES: { sourceId: string; targets: { lng: number; lat: nu
|
||||
{ lng: 50.876409, lat: 34.625448, name: '伊朗专家会议秘书处' },
|
||||
{ lng: 51.916, lat: 33.666, name: '纳坦兹' },
|
||||
{ lng: 51.002, lat: 35.808, name: '卡拉季无人机厂' },
|
||||
{ lng: 35.5, lat: 33.86, name: '贝鲁特南郊指挥所' },
|
||||
{ lng: 35.32, lat: 33.34, name: '利塔尼弹药库' },
|
||||
{ lng: 36.2, lat: 34.01, name: '巴勒贝克后勤枢纽' },
|
||||
{ lng: 35.19, lat: 33.27, name: '提尔海岸阵地' },
|
||||
{ lng: 36.38, lat: 34.39, name: '赫尔梅勒无人机阵地' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -176,6 +206,7 @@ export function WarMap() {
|
||||
const mapRef = useRef<MapRef>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const animRef = useRef<number>(0)
|
||||
const pincerAnimRef = useRef<{ lastProgressStep?: number }>({ lastProgressStep: -1 })
|
||||
const startRef = useRef<number>(0)
|
||||
const lastAnimUpdateRef = useRef<number>(0)
|
||||
const attackPathsRef = useRef<[number, number][][]>([])
|
||||
@@ -185,8 +216,11 @@ export function WarMap() {
|
||||
const hezbollahPathsRef = useRef<[number, number][][]>([])
|
||||
const hormuzPathsRef = useRef<[number, number][][]>([])
|
||||
const situation = useReplaySituation()
|
||||
const { isReplayMode } = usePlaybackStore()
|
||||
const { isReplayMode, playbackTime } = usePlaybackStore()
|
||||
const { usForces, iranForces, conflictEvents = [] } = situation
|
||||
/** 时间衰减基准:回放模式用回放时刻,否则用数据更新时间或当前时间 */
|
||||
const referenceTime =
|
||||
isReplayMode ? playbackTime : situation.lastUpdated || new Date().toISOString()
|
||||
|
||||
const usLocs = (usForces.keyLocations || []) as KeyLoc[]
|
||||
const irLocs = (iranForces.keyLocations || []) as KeyLoc[]
|
||||
@@ -226,16 +260,23 @@ export function WarMap() {
|
||||
}, [usForces.keyLocations, iranForces.keyLocations])
|
||||
|
||||
const mapData = situation.mapData
|
||||
const attackedTargets = mapData?.attackedTargets ?? []
|
||||
const strikeSources =
|
||||
mapData?.strikeSources?.length > 0 ? mapData.strikeSources : FALLBACK_STRIKE_SOURCES
|
||||
const strikeLines =
|
||||
mapData?.strikeLines?.length > 0 ? mapData.strikeLines : FALLBACK_STRIKE_LINES
|
||||
|
||||
const attackPaths = useMemo(
|
||||
() => attackedTargets.map((target) => parabolaPath(TEHRAN_SOURCE, target as [number, number])),
|
||||
[attackedTargets]
|
||||
)
|
||||
/** 伊朗→美军基地:仅用 DB 数据,5 天内显示飞行动画 */
|
||||
const strikeCutoffDays = situation.animationConfig?.strikeCutoffDays ?? 5
|
||||
|
||||
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)
|
||||
)
|
||||
return attacked.map((loc) => parabolaPath(TEHRAN_SOURCE, [loc.lng, loc.lat]))
|
||||
}, [usForces.keyLocations, referenceTime, strikeCutoffDays])
|
||||
|
||||
attackPathsRef.current = attackPaths
|
||||
|
||||
@@ -245,34 +286,48 @@ export function WarMap() {
|
||||
return m
|
||||
}, [strikeSources])
|
||||
|
||||
/** 盟军打击线:仅用 DB strikeLines,5 天内目标显示飞行动画 */
|
||||
const filterTargetsByAnimationWindow = useMemo(
|
||||
() => (targets: { lng: number; lat: number; struck_at?: string | null }[]) =>
|
||||
targets.filter((t) => isWithinAnimationWindow(t.struck_at ?? null, referenceTime, strikeCutoffDays)),
|
||||
[referenceTime, strikeCutoffDays]
|
||||
)
|
||||
|
||||
const lincolnPaths = 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])
|
||||
return filterTargetsByAnimationWindow(line.targets).map((t) =>
|
||||
parabolaPath(coords, [t.lng, t.lat])
|
||||
)
|
||||
}, [strikeLines, sourceCoords, filterTargetsByAnimationWindow])
|
||||
const fordPaths = 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])
|
||||
return filterTargetsByAnimationWindow(line.targets).map((t) =>
|
||||
parabolaPath(coords, [t.lng, t.lat])
|
||||
)
|
||||
}, [strikeLines, sourceCoords, filterTargetsByAnimationWindow])
|
||||
const israelPaths = 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])
|
||||
// 真主党 → 以色列北部三处目标(与美/以打击弧线一致:同一 parabola 高度与动画方式)
|
||||
const hezbollahSource = EXTENDED_WAR_ZONES.hezbollahStrikeSource
|
||||
const hezbollahPaths = useMemo(
|
||||
() =>
|
||||
isReplayMode
|
||||
? []
|
||||
: EXTENDED_WAR_ZONES.activeAttacks.map((t) => parabolaPath(hezbollahSource, t.coords, 3)),
|
||||
[hezbollahSource, isReplayMode]
|
||||
)
|
||||
// 伊朗不同地点 → 霍尔木兹海峡多点攻击(黄色轨迹)
|
||||
return filterTargetsByAnimationWindow(line.targets).map((t) =>
|
||||
parabolaPath(coords, [t.lng, t.lat])
|
||||
)
|
||||
}, [strikeLines, sourceCoords, filterTargetsByAnimationWindow])
|
||||
|
||||
/** 黎巴嫩→以色列:攻击源为黎巴嫩境内多处(提尔、西顿、巴勒贝克等),目标为以色列北部 */
|
||||
const hezbollahPaths = useMemo(() => {
|
||||
const sources = EXTENDED_WAR_ZONES.lebanonStrikeSources
|
||||
const targets = EXTENDED_WAR_ZONES.activeAttacks
|
||||
return targets.map((t, i) =>
|
||||
parabolaPath(sources[i % sources.length], t.coords, 3)
|
||||
)
|
||||
}, [])
|
||||
|
||||
/** 霍尔木兹海峡被打击目标点位;飞行动画由伊朗多处→该区域 */
|
||||
const hormuzTargetPoints = useMemo(
|
||||
() =>
|
||||
[
|
||||
@@ -282,18 +337,39 @@ export function WarMap() {
|
||||
] as [number, number][],
|
||||
[]
|
||||
)
|
||||
/** 伊朗多处→霍尔木兹:德黑兰、克尔曼沙赫、库姆 攻击海峡目标(无日期按 decay=1 显示) */
|
||||
const hormuzPaths = useMemo(() => {
|
||||
if (isReplayMode) return []
|
||||
// 使用更远的伊朗腹地/纵深位置,弧线更明显
|
||||
const sources: [number, number][] = [
|
||||
TEHRAN_SOURCE, // 德黑兰
|
||||
[47.16, 34.35], // 克尔曼沙赫导弹阵地
|
||||
[50.88, 34.64], // 库姆附近
|
||||
TEHRAN_SOURCE,
|
||||
[47.16, 34.35],
|
||||
[50.88, 34.64],
|
||||
]
|
||||
return hormuzTargetPoints.map((target, idx) =>
|
||||
parabolaPath(sources[idx % sources.length], target, 3)
|
||||
)
|
||||
}, [hormuzTargetPoints, isReplayMode])
|
||||
}, [hormuzTargetPoints])
|
||||
|
||||
const warMapData = useWarMapData()
|
||||
|
||||
/** 当前参与动画的目标的最小衰减系数,用于脉冲范围与攻击频次(缩小脉冲、拉长飞行周期) */
|
||||
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)))
|
||||
for (const line of strikeLines) {
|
||||
for (const t of filterTargetsByAnimationWindow(line.targets)) {
|
||||
decays.push(getDecayFactor(t.struck_at ?? null, referenceTime, strikeCutoffDays))
|
||||
}
|
||||
}
|
||||
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])
|
||||
|
||||
const animationDecayRef = useRef(1)
|
||||
animationDecayRef.current = animationDecayFactor
|
||||
|
||||
lincolnPathsRef.current = lincolnPaths
|
||||
fordPathsRef.current = fordPaths
|
||||
israelPathsRef.current = israelPaths
|
||||
@@ -333,6 +409,21 @@ export function WarMap() {
|
||||
}),
|
||||
[israelPaths]
|
||||
)
|
||||
/** 盟军打击目标点位(林肯/福特/以色列→伊朗+以色列→黎巴嫩),带衰减系数供脉冲缩放 */
|
||||
const alliedStrikeTargetsFeatures = useMemo(() => {
|
||||
const out: GeoJSON.Feature<GeoJSON.Point, { name: string; decay: number }>[] = []
|
||||
for (const line of strikeLines) {
|
||||
for (const t of line.targets) {
|
||||
const decay = getDecayFactor(t.struck_at ?? null, referenceTime, strikeCutoffDays)
|
||||
out.push({
|
||||
type: 'Feature',
|
||||
properties: { name: t.name ?? '', decay },
|
||||
geometry: { type: 'Point', coordinates: [t.lng, t.lat] },
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [strikeLines, referenceTime])
|
||||
const hezbollahLinesGeoJson = useMemo(
|
||||
() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
@@ -458,20 +549,24 @@ export function WarMap() {
|
||||
|
||||
if (shouldUpdate) {
|
||||
const zoom = map.getZoom()
|
||||
const zoomScale = Math.max(0.5, zoom / 4.2) // 随镜头缩放:放大变大、缩小变小(4.2 为默认 zoom)
|
||||
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 越大,参与动画的路径越少
|
||||
try {
|
||||
// 光点从起点飞向目标的循环动画
|
||||
// 伊朗→美军基地:速度固定,仅减少光点数量(频次)
|
||||
const src = map.getSource('attack-dots') as { setData: (d: GeoJSON.FeatureCollection) => void } | undefined
|
||||
const paths = attackPathsRef.current
|
||||
if (src && paths.length > 0) {
|
||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = paths.map((path, i) => {
|
||||
const progress = ((elapsed / FLIGHT_DURATION_MS + i / paths.length) % 1)
|
||||
const coord = interpolateOnPath(path, progress)
|
||||
return {
|
||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||
paths.forEach((path, i) => {
|
||||
if (i % step !== 0) return
|
||||
const progress = (elapsed / FLIGHT_DURATION_MS + i / Math.max(paths.length, 1)) % 1
|
||||
features.push({
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'Point' as const, coordinates: coord },
|
||||
}
|
||||
geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) },
|
||||
})
|
||||
})
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}
|
||||
@@ -480,12 +575,12 @@ export function WarMap() {
|
||||
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.003)
|
||||
map.setPaintProperty('points-damaged', 'circle-opacity', blink)
|
||||
}
|
||||
// attacked: 红色脉冲 2s 循环, 半径随 zoom 缩放;phase/r/opacity 钳位避免浮点或取模越界
|
||||
// attacked: 红色脉冲,半径 = 基准×phase×zoomScale×decayScale(线性衰减)
|
||||
if (map.getLayer('points-attacked-pulse')) {
|
||||
const cycle = 2000
|
||||
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
||||
const r = Math.max(0, 40 * phase * zoomScale)
|
||||
const opacity = Math.min(1, Math.max(0, 1 - phase))
|
||||
const r = Math.max(0, 32 * phase * zoomScale * decayScale)
|
||||
const opacity = Math.min(1, Math.max(0, (0.4 + 0.6 * decay) * (1 - phase)))
|
||||
map.setPaintProperty('points-attacked-pulse', 'circle-radius', r)
|
||||
map.setPaintProperty('points-attacked-pulse', 'circle-opacity', opacity)
|
||||
}
|
||||
@@ -495,18 +590,16 @@ export function WarMap() {
|
||||
| undefined
|
||||
const lincolnPaths = lincolnPathsRef.current
|
||||
if (lincolnSrc && lincolnPaths.length > 0) {
|
||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = lincolnPaths.map(
|
||||
(path, i) => {
|
||||
const progress =
|
||||
(elapsed / FLIGHT_DURATION_MS + 0.5 + i / Math.max(lincolnPaths.length, 1)) % 1
|
||||
const coord = interpolateOnPath(path, progress)
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'Point' as const, coordinates: coord },
|
||||
}
|
||||
}
|
||||
)
|
||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||
lincolnPaths.forEach((path, i) => {
|
||||
if (i % step !== 0) return
|
||||
const progress = (elapsed / FLIGHT_DURATION_MS + 0.5 + i / Math.max(lincolnPaths.length, 1)) % 1
|
||||
features.push({
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) },
|
||||
})
|
||||
})
|
||||
lincolnSrc.setData({ type: 'FeatureCollection', features })
|
||||
}
|
||||
// 福特号打击伊朗:青色光点
|
||||
@@ -515,18 +608,16 @@ export function WarMap() {
|
||||
| undefined
|
||||
const fordPaths = fordPathsRef.current
|
||||
if (fordSrc && fordPaths.length > 0) {
|
||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = fordPaths.map(
|
||||
(path, i) => {
|
||||
const progress =
|
||||
(elapsed / FLIGHT_DURATION_MS + 0.3 + i / Math.max(fordPaths.length, 1)) % 1
|
||||
const coord = interpolateOnPath(path, progress)
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'Point' as const, coordinates: coord },
|
||||
}
|
||||
}
|
||||
)
|
||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||
fordPaths.forEach((path, i) => {
|
||||
if (i % step !== 0) return
|
||||
const progress = (elapsed / FLIGHT_DURATION_MS + 0.3 + i / Math.max(fordPaths.length, 1)) % 1
|
||||
features.push({
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) },
|
||||
})
|
||||
})
|
||||
fordSrc.setData({ type: 'FeatureCollection', features })
|
||||
}
|
||||
// 以色列打击伊朗:浅青/白色光点
|
||||
@@ -535,18 +626,16 @@ export function WarMap() {
|
||||
| undefined
|
||||
const israelPaths = israelPathsRef.current
|
||||
if (israelSrc && israelPaths.length > 0) {
|
||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = israelPaths.map(
|
||||
(path, i) => {
|
||||
const progress =
|
||||
(elapsed / FLIGHT_DURATION_MS + 0.1 + i / Math.max(israelPaths.length, 1)) % 1
|
||||
const coord = interpolateOnPath(path, progress)
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'Point' as const, coordinates: coord },
|
||||
}
|
||||
}
|
||||
)
|
||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||
israelPaths.forEach((path, i) => {
|
||||
if (i % step !== 0) return
|
||||
const progress = (elapsed / FLIGHT_DURATION_MS + 0.1 + i / Math.max(israelPaths.length, 1)) % 1
|
||||
features.push({
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) },
|
||||
})
|
||||
})
|
||||
israelSrc.setData({ type: 'FeatureCollection', features })
|
||||
}
|
||||
// 真主党打击以色列北部:橙红光点,与林肯/福特/以色列同一动画方式
|
||||
@@ -555,15 +644,15 @@ export function WarMap() {
|
||||
| undefined
|
||||
const hezPaths = hezbollahPathsRef.current
|
||||
if (hezSrc && hezPaths.length > 0) {
|
||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = hezPaths.map((path, i) => {
|
||||
const progress =
|
||||
(elapsed / FLIGHT_DURATION_MS + 0.2 + i / Math.max(hezPaths.length, 1)) % 1
|
||||
const coord = interpolateOnPath(path, progress)
|
||||
return {
|
||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||
hezPaths.forEach((path, i) => {
|
||||
if (i % step !== 0) return
|
||||
const progress = (elapsed / FLIGHT_DURATION_MS + 0.2 + i / Math.max(hezPaths.length, 1)) % 1
|
||||
features.push({
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'Point' as const, coordinates: coord },
|
||||
}
|
||||
geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) },
|
||||
})
|
||||
})
|
||||
hezSrc.setData({ type: 'FeatureCollection', features })
|
||||
}
|
||||
@@ -573,56 +662,119 @@ export function WarMap() {
|
||||
| undefined
|
||||
const hormuzPaths = hormuzPathsRef.current
|
||||
if (hormuzSrc && hormuzPaths.length > 0) {
|
||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = hormuzPaths.map((path, i) => {
|
||||
const progress =
|
||||
(elapsed / FLIGHT_DURATION_MS + 0.15 + i / Math.max(hormuzPaths.length, 1)) % 1
|
||||
const coord = interpolateOnPath(path, progress)
|
||||
return {
|
||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||
hormuzPaths.forEach((path, i) => {
|
||||
if (i % step !== 0) return
|
||||
const progress = (elapsed / FLIGHT_DURATION_MS + 0.15 + i / Math.max(hormuzPaths.length, 1)) % 1
|
||||
features.push({
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'Point' as const, coordinates: coord },
|
||||
}
|
||||
geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) },
|
||||
})
|
||||
})
|
||||
hormuzSrc.setData({ type: 'FeatureCollection', features })
|
||||
}
|
||||
// 伊朗被打击目标:蓝色脉冲 (2s 周期), 半径随 zoom 缩放;phase/r/opacity 钳位
|
||||
// 盟军打击目标:脉冲半径 = 基准×decayScale×zoomScale,线性衰减,镜头拉远半径变小
|
||||
if (map.getLayer('allied-strike-targets-pulse')) {
|
||||
const cycle = 2000
|
||||
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
||||
const r = Math.max(0, 35 * phase * zoomScale)
|
||||
const opacity = Math.min(1, Math.max(0, 1 - phase * 1.2))
|
||||
const breathMin = 8
|
||||
const breathMax = 26
|
||||
const r = Math.max(0, (breathMin + (breathMax - breathMin) * decay) * phase * zoomScale * decayScale)
|
||||
const opacity = Math.min(1, Math.max(0, (0.35 + 0.65 * decay) * (1 - phase * 1.15)))
|
||||
map.setPaintProperty('allied-strike-targets-pulse', 'circle-radius', r)
|
||||
map.setPaintProperty('allied-strike-targets-pulse', 'circle-opacity', opacity)
|
||||
}
|
||||
// 单箭头钳形动画:生长(缓动) → 保持 2s 循环(已去掉内发光闪烁)
|
||||
const pincerGrowthSrc = map.getSource('kurdish-pincer-growth') as
|
||||
| { setData: (d: GeoJSON.FeatureCollection) => void }
|
||||
| undefined
|
||||
const PINCER_GROW_MS = 2500
|
||||
const PINCER_HOLD_MS = 2000
|
||||
const PINCER_CYCLE_MS = PINCER_GROW_MS + PINCER_HOLD_MS
|
||||
const pincerInCycle = elapsed % PINCER_CYCLE_MS
|
||||
const isGrowth = pincerInCycle < PINCER_GROW_MS
|
||||
const growthT = isGrowth ? pincerInCycle / PINCER_GROW_MS : 1
|
||||
const progressEased = growthT * growthT * (3 - 2 * growthT)
|
||||
const pincerProgress = isGrowth ? progressEased : 1
|
||||
|
||||
// 节流:仅当 progress 步进变化或进入保持阶段时更新 GeoJSON,减轻首帧卡顿与每帧 setData 开销
|
||||
const progressStep = Math.floor(pincerProgress * 40) / 40
|
||||
const lastStep = pincerAnimRef.current.lastProgressStep ?? -1
|
||||
const shouldUpdate = progressStep !== lastStep || (!isGrowth && lastStep !== 1)
|
||||
if (shouldUpdate) {
|
||||
pincerAnimRef.current.lastProgressStep = isGrowth ? progressStep : 1
|
||||
const progressToUse = isGrowth ? pincerProgress : 1
|
||||
if (pincerGrowthSrc && warMapData.pincerAxes.length > 0) {
|
||||
const features: GeoJSON.Feature<GeoJSON.Polygon>[] = warMapData.pincerAxes.map((axis) => ({
|
||||
type: 'Feature',
|
||||
properties: { name: axis.name },
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [createTacticalPincerAtProgress(axis.start, axis.end, progressToUse)],
|
||||
},
|
||||
}))
|
||||
pincerGrowthSrc.setData({ type: 'FeatureCollection', features })
|
||||
}
|
||||
const israelLebanonSrc = map.getSource('israel-lebanon-arrow') as
|
||||
| { setData: (d: GeoJSON.FeatureCollection) => void }
|
||||
| undefined
|
||||
if (israelLebanonSrc) {
|
||||
const { start, end, name } = warMapData.israelLebanonAxis
|
||||
israelLebanonSrc.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: { name },
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [createTacticalPincerAtProgress(start, end, progressToUse)],
|
||||
},
|
||||
}],
|
||||
})
|
||||
}
|
||||
}
|
||||
if (map.getLayer('attack-pincers')) {
|
||||
map.setPaintProperty('attack-pincers', 'fill-opacity', 1)
|
||||
}
|
||||
if (map.getLayer('attack-pincers-inner-glow')) {
|
||||
map.setPaintProperty('attack-pincers-inner-glow', 'fill-opacity', 0)
|
||||
}
|
||||
if (map.getLayer('israel-lebanon-arrow-fill')) {
|
||||
map.setPaintProperty('israel-lebanon-arrow-fill', 'fill-opacity', 1)
|
||||
}
|
||||
if (map.getLayer('israel-lebanon-arrow-inner-glow')) {
|
||||
map.setPaintProperty('israel-lebanon-arrow-inner-glow', 'fill-opacity', 0)
|
||||
}
|
||||
// GDELT 橙色 (4–6):闪烁
|
||||
if (map.getLayer('gdelt-events-orange')) {
|
||||
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.004)
|
||||
map.setPaintProperty('gdelt-events-orange', 'circle-opacity', blink)
|
||||
}
|
||||
// GDELT 红色 (7–10):脉冲扩散, 半径随 zoom 缩放;phase/r/opacity 钳位
|
||||
// GDELT 红色:脉冲半径随 zoom×decayScale 线性变化
|
||||
if (map.getLayer('gdelt-events-red-pulse')) {
|
||||
const cycle = 2200
|
||||
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
||||
const r = Math.max(0, 30 * phase * zoomScale)
|
||||
const opacity = Math.min(1, Math.max(0, 1 - phase * 1.1))
|
||||
const r = Math.max(0, 24 * phase * zoomScale * decayScale)
|
||||
const opacity = Math.min(1, Math.max(0, (0.4 + 0.6 * decay) * (1 - phase * 1.05)))
|
||||
map.setPaintProperty('gdelt-events-red-pulse', 'circle-radius', r)
|
||||
map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity)
|
||||
}
|
||||
// 真主党攻击目标:橙红脉冲,与 allied-strike-targets 同一周期与半径
|
||||
// 真主党攻击目标:脉冲半径衰减线性插值,镜头拉远变小
|
||||
if (map.getLayer('hezbollah-attack-targets-pulse')) {
|
||||
const cycle = 2000
|
||||
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
||||
const r = Math.max(0, 35 * phase * zoomScale)
|
||||
const opacity = Math.min(1, Math.max(0, 1 - phase * 1.2))
|
||||
const r = Math.max(0, 26 * phase * zoomScale * decayScale)
|
||||
const opacity = Math.min(1, Math.max(0, (0.35 + 0.65 * decay) * (1 - phase * 1.1)))
|
||||
map.setPaintProperty('hezbollah-attack-targets-pulse', 'circle-radius', r)
|
||||
map.setPaintProperty('hezbollah-attack-targets-pulse', 'circle-opacity', opacity)
|
||||
}
|
||||
// 霍尔木兹海峡被打击目标:琥珀黄脉冲,保持与其他被打击点一致但颜色区分
|
||||
// 霍尔木兹海峡被打击目标:脉冲半径随 zoom×decayScale
|
||||
if (map.getLayer('iran-hormuz-targets-pulse')) {
|
||||
const cycle = 2000
|
||||
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
||||
const r = Math.max(0, 32 * phase * zoomScale)
|
||||
const opacity = Math.min(1, Math.max(0, 1 - phase * 1.1))
|
||||
const r = Math.max(0, 24 * phase * zoomScale * decayScale)
|
||||
const opacity = Math.min(1, Math.max(0, (0.35 + 0.65 * decay) * (1 - phase * 1.05)))
|
||||
map.setPaintProperty('iran-hormuz-targets-pulse', 'circle-radius', r)
|
||||
map.setPaintProperty('iran-hormuz-targets-pulse', 'circle-opacity', opacity)
|
||||
}
|
||||
@@ -642,6 +794,10 @@ export function WarMap() {
|
||||
(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-israel') && israelPathsRef.current.length > 0) ||
|
||||
(map.getSource('hezbollah-strike-dots') && hezbollahPathsRef.current.length > 0) ||
|
||||
(map.getSource('iran-hormuz-dots') && hormuzPathsRef.current.length > 0) ||
|
||||
map.getSource('kurdish-pincer-growth') ||
|
||||
map.getSource('israel-lebanon-arrow') ||
|
||||
map.getSource('gdelt-events-green') ||
|
||||
map.getSource('gdelt-events-orange') ||
|
||||
map.getSource('gdelt-events-red')
|
||||
@@ -737,6 +893,9 @@ export function WarMap() {
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 rounded-sm bg-lime-400/40" /> 真主党势力
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 rounded-sm bg-[#E066FF]/80" /> 库尔德武装
|
||||
</span>
|
||||
</div>
|
||||
<Map
|
||||
ref={mapRef}
|
||||
@@ -749,7 +908,13 @@ export function WarMap() {
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
onLoad={(e) => {
|
||||
fitToTheater()
|
||||
setTimeout(() => initAnimation.current(e.target), 150)
|
||||
// 等地图 style/tiles 就绪后再启动动画,减轻首帧卡顿
|
||||
const map = e.target
|
||||
if (map.isStyleLoaded()) {
|
||||
map.once('idle', () => initAnimation.current(map))
|
||||
} else {
|
||||
map.once('load', () => map.once('idle', () => initAnimation.current(map)))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 矢量标记:zoom 拉远变小,拉近变大 */}
|
||||
@@ -1015,6 +1180,163 @@ export function WarMap() {
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 跨国库尔德势力:土(Bakur)/叙(Rojava)/伊(Bashur) 三区 MultiPolygon + 北/南钳形箭头 */}
|
||||
<Source id="kurdish-front-source" type="geojson" data={KURDISH_FRONT_GEOJSON}>
|
||||
{/* 势力范围:紫色半透明,远景更显、近景更透(描边单独 line 层,参考真主党) */}
|
||||
<Layer
|
||||
id="kurdish-zones"
|
||||
type="fill"
|
||||
filter={['==', ['get', 'region_type'], 'InfluenceZone']}
|
||||
paint={{
|
||||
'fill-color': '#800080',
|
||||
'fill-opacity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
3,
|
||||
0.4,
|
||||
8,
|
||||
0.15,
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="kurdish-zones-outline"
|
||||
type="line"
|
||||
filter={['==', ['get', 'region_type'], 'InfluenceZone']}
|
||||
paint={{
|
||||
'line-color': '#BA55D3',
|
||||
'line-width': 1.2,
|
||||
}}
|
||||
/>
|
||||
{/* 进攻目的地地名:亮紫色醒目识别 */}
|
||||
<Layer
|
||||
id="kurdish-target-labels"
|
||||
type="symbol"
|
||||
filter={['==', ['get', 'region_type'], 'Target']}
|
||||
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': '#E066FF',
|
||||
'text-halo-color': '#1a1a1a',
|
||||
'text-halo-width': 1,
|
||||
}}
|
||||
/>
|
||||
{/* 萨南达季、克尔曼沙赫显示圆点(大不里士不显示标记) */}
|
||||
<Layer
|
||||
id="kurdish-target-dots"
|
||||
type="circle"
|
||||
filter={['all', ['==', ['get', 'region_type'], 'Target'], ['==', ['get', 'showMarker'], true]]}
|
||||
paint={{
|
||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 1.5, 5, 2.5, 8, 4],
|
||||
'circle-color': '#BF40BF',
|
||||
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 7, 1],
|
||||
'circle-stroke-color': '#fff',
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 伊朗被库尔德进攻点防御线:反弓曲线 + 锯齿,黄色;名称标注 */}
|
||||
<Source id="kurdish-attack-defense-line" type="geojson" data={warMapData.kurdishDefenseLineGeoJson}>
|
||||
<Layer
|
||||
id="kurdish-attack-defense-line-layer"
|
||||
type="line"
|
||||
filter={['==', ['geometry-type'], 'LineString']}
|
||||
paint={{
|
||||
'line-color': '#FACC15',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 1, 8, 1.5, 12, 2.5],
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round',
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="kurdish-attack-defense-line-label"
|
||||
type="symbol"
|
||||
filter={['==', ['geometry-type'], 'Point']}
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 7, 7, 9, 10, 11],
|
||||
'text-anchor': 'center',
|
||||
'text-rotate': ['get', 'angle'],
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#FACC15',
|
||||
'text-halo-color': '#1a1a1a',
|
||||
'text-halo-width': 1,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 单箭头钳形:曲线箭体,白色加粗轮廓与美方攻击点样式一致 */}
|
||||
<Source id="kurdish-pincer-growth" type="geojson" data={warMapData.kurdishPincerGrowthInitial}>
|
||||
<Layer
|
||||
id="attack-pincers"
|
||||
type="fill"
|
||||
paint={{
|
||||
'fill-color': '#2563eb',
|
||||
'fill-opacity': 1,
|
||||
'fill-outline-color': '#FFFFFF',
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="attack-pincers-inner-glow"
|
||||
type="fill"
|
||||
paint={{
|
||||
'fill-color': '#60a5fa',
|
||||
'fill-opacity': 0,
|
||||
'fill-outline-color': 'transparent',
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="attack-pincers-outline"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': '#FFFFFF',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 7, 1.2, 10, 1.8],
|
||||
'line-opacity': 1,
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round',
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 以色列进攻黎巴嫩箭头:与库尔德同款曲线生长、白轮廓、内发光 */}
|
||||
<Source id="israel-lebanon-arrow" type="geojson" data={warMapData.israelLebanonArrowInitial}>
|
||||
<Layer
|
||||
id="israel-lebanon-arrow-fill"
|
||||
type="fill"
|
||||
paint={{
|
||||
'fill-color': '#2563eb',
|
||||
'fill-opacity': 1,
|
||||
'fill-outline-color': '#FFFFFF',
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="israel-lebanon-arrow-inner-glow"
|
||||
type="fill"
|
||||
paint={{
|
||||
'fill-color': '#60a5fa',
|
||||
'fill-opacity': 0,
|
||||
'fill-outline-color': 'transparent',
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="israel-lebanon-arrow-outline"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': '#FFFFFF',
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 7, 1.2, 10, 1.8],
|
||||
'line-opacity': 1,
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round',
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 美以联军打击伊朗:路径线 */}
|
||||
<Source id="allied-strike-lines-lincoln" type="geojson" data={lincolnLinesGeoJson}>
|
||||
<Layer
|
||||
@@ -1145,17 +1467,13 @@ export function WarMap() {
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
{/* 美军打击目标点位 (蓝色) */}
|
||||
{/* 盟军打击目标点位 (蓝色):含林肯/福特/以色列→伊朗 + 以色列→黎巴嫩,统一名称与脉冲动效 */}
|
||||
<Source
|
||||
id="allied-strike-targets"
|
||||
type="geojson"
|
||||
data={{
|
||||
type: 'FeatureCollection',
|
||||
features: (situation.iranForces?.keyLocations ?? []).map((s) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { name: s.name },
|
||||
geometry: { type: 'Point' as const, coordinates: [s.lng, s.lat] },
|
||||
})),
|
||||
features: alliedStrikeTargetsFeatures,
|
||||
}}
|
||||
>
|
||||
<Layer
|
||||
@@ -1178,8 +1496,9 @@ export function WarMap() {
|
||||
'text-offset': [0, 0.8],
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#60A5FA',
|
||||
'text-halo-width': 0,
|
||||
'text-color': '#FFFFFF',
|
||||
'text-halo-color': '#1a1a1a',
|
||||
'text-halo-width': 1,
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
@@ -1355,6 +1674,30 @@ export function WarMap() {
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
{/* 黎巴嫩标注(以色列打击黎巴嫩目标时可见) */}
|
||||
<Source
|
||||
id="lebanon-label"
|
||||
type="geojson"
|
||||
data={{
|
||||
type: 'Feature',
|
||||
properties: { name: '黎巴嫩' },
|
||||
geometry: { type: 'Point', coordinates: [35.7, 33.7] },
|
||||
}}
|
||||
>
|
||||
<Layer
|
||||
id="lebanon-label-text"
|
||||
type="symbol"
|
||||
layout={{
|
||||
'text-field': '黎巴嫩',
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 11, 8, 16],
|
||||
'text-anchor': 'center',
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#94A3B8',
|
||||
'text-halo-width': 0,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
<Source id="countries" type="geojson" data={COUNTRIES_GEOJSON}>
|
||||
{/* 伊朗区域填充 - 红色系 */}
|
||||
@@ -1501,6 +1844,35 @@ export function WarMap() {
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 库尔德武装势力范围标注(参照真主党紫色区域标记) */}
|
||||
<Source
|
||||
id="kurdish-label"
|
||||
type="geojson"
|
||||
data={{
|
||||
type: 'Feature',
|
||||
properties: { name: '库尔德武装' },
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: EXTENDED_WAR_ZONES.kurdishLabelCenter,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Layer
|
||||
id="kurdish-label-text"
|
||||
type="symbol"
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 7, 7, 9, 10, 11],
|
||||
'text-anchor': 'center',
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#E066FF',
|
||||
'text-halo-color': '#1a1a1a',
|
||||
'text-halo-width': 1,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
</Map>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user