From af59d6367f054a8415188dff0205a12ffe911993 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 5 Mar 2026 15:53:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=96=B0=E5=A2=9E=E6=80=81=20=E6=95=88?= =?UTF-8?q?=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crawler/__pycache__/db_merge.cpython-39.pyc | Bin 8337 -> 10646 bytes server/data.db-shm | Bin 32768 -> 0 bytes server/data.db-wal | 0 server/db.js | 19 + server/routes.js | 58 ++ server/seed.js | 31 + server/situationData.js | 7 + src/api/edit.ts | 17 + src/components/WarMap.tsx | 596 ++++++++++++++++---- src/data/extendedWarData.ts | 147 ++++- src/data/mockData.ts | 9 + src/hooks/useWarMapData.ts | 109 ++++ src/pages/EditDashboard.tsx | 65 +++ src/utils/defenseLine.ts | 106 ++++ src/utils/tacticalPincerArrow.ts | 282 +++++++++ start.sh | 1 + 16 files changed, 1334 insertions(+), 113 deletions(-) delete mode 100644 server/data.db-shm delete mode 100644 server/data.db-wal create mode 100644 src/hooks/useWarMapData.ts create mode 100644 src/utils/defenseLine.ts create mode 100644 src/utils/tacticalPincerArrow.ts diff --git a/crawler/__pycache__/db_merge.cpython-39.pyc b/crawler/__pycache__/db_merge.cpython-39.pyc index accfe2238f5f48c47755d29e24d1c4ae659582db..6acc48f3a6c9d22b41b77f736ac132032a89efc3 100644 GIT binary patch delta 3308 zcmaJ@T~Hg>72do0TZx~5ObB3p*0TAL5g@y1>~RQ$f^dj2q(}seL*=!0!Lo!T-(CEp zuB?oU6FXx&@~rDLb}}P%oVFP^2_5o~V&c{_=~L60b|#(9?DQ>`ATRYpAKFf)ou0cZ zWTCVX?Ad$IJ@=gN+GQb#GDXz$c}I@5{QN)(3*>4-r-^$ z5pF>sCdu4S?yuij&8DsrZ8$Qoy*SCiCT7{}Yj0;SzW2=_TLXjh&9FKKKST#-5{Ksn z$)ESR*#p;)$0A&ELFgw6NMbP%@^ezZ7J1`0pJv}kXD=-m2Pc7S>z97H@%G)Gfe|+Q z=0~|JmvbN7$)(q_Yj1Acht*qsw0319^=Woh$)>Kc2R%Nvhl>S!#GtguNx^7@UB9(N zTn=#(*G(2~FeG^Oh3nDRag=1kK3uz%yZv$Q<}&!xH(?#R*WS}{6>kXapK=jBi`10| z=wHab6ZRPI0-+iLTqfnH(K;Z5f9n$4+<#M*rV1 zCuzgV14|vvJ*_-vZci2kCC7~I*y6BouD>nn<_Z7y;G0%?40(vj(iHM$g-o|5Sf~~a z&(^Ww@d?k!!7=ub=Lp+T0Pcj0hdmRXu|bcwxTbW2j%1K`7Bbdbaql3kq0hsPj176F z*^yxls%Lt{>+`Zf{;a=b$?^omj*k`lCyQBx!iiLC0esx?(!{{Q!vn0uC69*%e=sr^ z?I`i;+)@@aoETCXUUdm`mpLs={I@A4etxC1{4;Zs_sP_0DrLbxi{Fqb-d7I3rWQw?PE6Y)D)ABXXPE z&R6g=7wLLRPIk!7Ib((fo->@Z@zo(CG34zDc%L!@%hdqOZA)3s-6^IZ{-_3;!KdTP z0Q#r~`lpHA@@@eAiU#oWJm^kP)E%(fsN)lSlC$w5fh&d-|2_^HaEgMq|zM(lf zE>)$gGgR8n@6Vt)q`OKt5XzN}Q^5RSwVm|5P#dr|%(=fJD&dCvp$1jZduv>h2-5OKEBN+a9e{k!m7W zaO`~=?*&5@g-U7xKMm?X5ZB*ThPLl5R~b%{xIjqYag}mm`*gQn@%h>QlG&=YcV37H zXJfeU3*86Pcw{G;f{zlRwKPEpLW}ih-EOJz(C!&~5i#kcDXMgkSB-?CRRdf(AUG-h zeUe-8b)p-y9YNm35?dDwwjemk$-X==IthH-(eGmO|4#e_1f40q1PO(TK%3fU3Vd6s zw#_>PViww*q?Q#f4fv|UZtxS2jEs3b6F%T@-}pDk>M<|7fPyza)*iPxsnFJe7nu8h zA%_&4@k4q%oN=LT6^op)-EWol)t~8GH^uVVxI)!%LPBiAMS1o=&9}W2ZI6}hj zQ-0NQEJ@Z>HNZAu3sI8GLNx>Q!+6MJqfJE~faXcCgW8$(1cA*4RrgtIAFNTFa ja)F2}2&0vOdxqewp{r1Xkpb-FYu5O+*=lF(=-__==-s)C delta 1078 zcmZ8g-)j_C6uxI>c4sr1G;0J+(ioHNt{H2SC1?#bCWy3H1%ItEX8SOi?2O55&5I77g+y^sM5@EF;)CXGrIhWHtg3aIdhq0zU4=2@EYzf4%^*L(C$2F&X$F}tgM z*SxQb@qwNmnc{9(jd73?@vwrS_N6^1HT|M(i09q;iF75Z*8^^A_yt9&s;GuXRbB$0 zD_@u6hj9~qhr0_kKOQ1qTolvUaUJluSj+YuPU=%NeH;Zc4$@5OLpf^HNHt8Sz)&S6~U~e+-%S zlm00XzYTn2k5b(`^&36=S!f_VkWr8Aoi&*i=+p(*ud;NcvFWXkhv+?6^;ExMLX z6b}pME=xcOt#Z2Ba?ou_t=hseC6{%tzF4-XNH5?)6vi^$aM7*D68v2f$W-0OMgLn` z75~k|>XB#S=aK!rcZl0?(e9O|<9JcUanKB_h { } }) +// 战区地图配置:钳形轴线、以黎轴线、防御线路径,供前端 useWarMapData 拉取;新增/修改数据落库 +router.get('/war-map-config', (req, res) => { + try { + const row = db.prepare('SELECT config FROM war_map_config WHERE id = 1').get() + if (!row || !row.config) { + return res.json({}) + } + const data = JSON.parse(row.config) + res.json(data) + } catch (err) { + console.error(err) + res.status(500).json({ error: err.message }) + } +}) + +router.put('/war-map-config', requireAdmin, (req, res) => { + try { + const body = req.body || {} + const pincerAxes = Array.isArray(body.pincerAxes) ? body.pincerAxes : null + const israelLebanonAxis = body.israelLebanonAxis && typeof body.israelLebanonAxis === 'object' ? body.israelLebanonAxis : null + const defenseLinePath = Array.isArray(body.defenseLinePath) ? body.defenseLinePath : null + if (!pincerAxes?.length || !israelLebanonAxis || !defenseLinePath?.length) { + return res.status(400).json({ error: 'pincerAxes (array), israelLebanonAxis (object), defenseLinePath (array) required' }) + } + const config = JSON.stringify({ pincerAxes, israelLebanonAxis, defenseLinePath }) + db.prepare( + 'INSERT INTO war_map_config (id, config, updated_at) VALUES (1, ?, datetime(\'now\')) ON CONFLICT(id) DO UPDATE SET config = excluded.config, updated_at = datetime(\'now\')' + ).run(config) + res.json({ ok: true }) + } catch (err) { + console.error(err) + res.status(500).json({ error: err.message }) + } +}) + router.get('/events', (req, res) => { try { const s = getSituation() @@ -225,9 +260,13 @@ router.get('/edit/raw', (req, res) => { const summaryUs = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('us') const summaryIr = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('iran') let displayStats = null + let animationConfig = null try { displayStats = db.prepare('SELECT override_enabled, viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get() } catch (_) {} + try { + animationConfig = db.prepare('SELECT strike_cutoff_days FROM animation_config WHERE id = 1').get() + } catch (_) {} const realCumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0 const realShare = db.prepare('SELECT total FROM share_count WHERE id = 1').get()?.total ?? 0 const liveViewers = db.prepare( @@ -251,6 +290,9 @@ router.get('/edit/raw', (req, res) => { likeCount: displayStats?.like_count ?? realLikeCount, feedbackCount: displayStats?.feedback_count ?? realFeedback, }, + animationConfig: { + strikeCutoffDays: animationConfig?.strike_cutoff_days ?? 5, + }, }) } catch (err) { console.error(err) @@ -438,4 +480,20 @@ router.put('/edit/display-stats', (req, res) => { } }) +router.put('/edit/animation-config', (req, res) => { + try { + const body = req.body || {} + const v = body.strikeCutoffDays + const n = Math.max(1, parseInt(v, 10) || 0) + if (!Number.isFinite(n)) return res.status(400).json({ error: 'strikeCutoffDays must be number' }) + db.prepare('INSERT OR IGNORE INTO animation_config (id, strike_cutoff_days) VALUES (1, 5)').run() + db.prepare('UPDATE animation_config SET strike_cutoff_days = ?, updated_at = datetime(\'now\') WHERE id = 1').run(n) + broadcastAfterEdit(req) + res.json({ ok: true }) + } catch (err) { + console.error(err) + res.status(400).json({ error: err.message }) + } +}) + module.exports = router diff --git a/server/seed.js b/server/seed.js index 60801d7..091cdb1 100644 --- a/server/seed.js +++ b/server/seed.js @@ -190,7 +190,16 @@ function seed() { [46.42, 33.64, '伊拉姆导弹阵地', '2026-02-28T02:40:00.000Z'], [48.35, 33.48, '霍拉马巴德储备库', '2026-02-28T02:42:00.000Z'], ] + // 以色列攻击黎巴嫩(真主党目标),时间在伊朗反击之后 14:00–14:40 + const israelLebanonTargets = [ + [35.5, 33.86, '贝鲁特南郊指挥所', '2026-02-28T14:00:00.000Z'], + [35.32, 33.34, '利塔尼弹药库', '2026-02-28T14:10:00.000Z'], + [36.2, 34.01, '巴勒贝克后勤枢纽', '2026-02-28T14:20:00.000Z'], + [35.19, 33.27, '提尔海岸阵地', '2026-02-28T14:30:00.000Z'], + [36.38, 34.39, '赫尔梅勒无人机阵地', '2026-02-28T14:40:00.000Z'], + ] israelTargets.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)) fordTargets.forEach(([lng, lat, name, struckAt]) => insertStrikeLine.run('ford', lng, lat, name, struckAt)) @@ -229,6 +238,28 @@ function seed() { const insertUpdate = db.prepare('INSERT INTO situation_update (id, timestamp, category, summary, severity) VALUES (?, ?, ?, ?, ?)') updateRows.forEach((row) => insertUpdate.run(...row)) + try { + db.prepare( + 'INSERT OR REPLACE INTO animation_config (id, strike_cutoff_days, updated_at) VALUES (1, ?, datetime(\'now\'))' + ).run(5) + } catch (_) {} + + // 战区地图配置:钳形轴线、以黎轴线、防御线(与 src/data/extendedWarData 一致,新增数据落库) + const warMapConfig = { + pincerAxes: [ + { start: [43.6, 37.2], end: [46.27, 38.08], name: 'North Pincer (Tabriz)' }, + { start: [45.0, 35.4], end: [46.99, 35.31], name: 'Central Pincer (Sanandaj)' }, + { start: [45.6, 35.2], end: [47.07, 34.31], name: 'South Pincer (Kermanshah)' }, + ], + israelLebanonAxis: { start: [35.25, 32.95], end: [35.55, 33.45], name: 'Israel → Lebanon' }, + defenseLinePath: [[46.27, 38.08], [46.99, 35.31], [47.07, 34.31]], + } + try { + db.prepare( + 'INSERT INTO war_map_config (id, config, updated_at) VALUES (1, ?, datetime(\'now\')) ON CONFLICT(id) DO UPDATE SET config = excluded.config, updated_at = datetime(\'now\')' + ).run(JSON.stringify(warMapConfig)) + } catch (_) {} + db.prepare("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)").run('2026-03-01T11:45:00.000Z') console.log('Seed completed.') } diff --git a/server/situationData.js b/server/situationData.js index b59a145..06dfc9d 100644 --- a/server/situationData.js +++ b/server/situationData.js @@ -82,6 +82,10 @@ function getSituation() { const updates = db.prepare('SELECT * FROM situation_update ORDER BY timestamp DESC LIMIT 50').all() // 数据更新时间:与前端「实时更新」一致,仅在爬虫 notify / 编辑保存时由 index.js 或 routes 更新 const meta = db.prepare('SELECT updated_at FROM situation WHERE id = 1').get() + let animationConfigRow = null + try { + animationConfigRow = db.prepare('SELECT strike_cutoff_days FROM animation_config WHERE id = 1').get() + } catch (_) {} let conflictEvents = [] let conflictStats = { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 } @@ -180,6 +184,9 @@ function getSituation() { strikeSources: mapStrikeSources, strikeLines: mapStrikeLines, }, + animationConfig: { + strikeCutoffDays: animationConfigRow?.strike_cutoff_days ?? 5, + }, } } diff --git a/src/api/edit.ts b/src/api/edit.ts index 210a30f..d3feeb8 100644 --- a/src/api/edit.ts +++ b/src/api/edit.ts @@ -63,12 +63,17 @@ export interface DisplayStatsRow { feedbackCount: number } +export interface AnimationConfigRow { + strikeCutoffDays: number +} + export interface EditRawData { combatLosses: { us: CombatLossesRow | null; iran: CombatLossesRow | null } keyLocations: { us: KeyLocationRow[]; iran: KeyLocationRow[] } situationUpdates: SituationUpdateRow[] forceSummary: { us: ForceSummaryRow | null; iran: ForceSummaryRow | null } displayStats?: DisplayStatsRow + animationConfig?: AnimationConfigRow } export async function fetchEditRaw(): Promise { @@ -155,3 +160,15 @@ export async function putDisplayStats( throw new Error((e as { error?: string }).error || res.statusText) } } + +export async function putAnimationConfig(body: Partial): Promise { + const res = await fetch('/api/edit/animation-config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const e = await res.json().catch(() => ({})) + throw new Error((e as { error?: string }).error || res.statusText) + } +} diff --git a/src/components/WarMap.tsx b/src/components/WarMap.tsx index 0f79566..c696a01 100644 --- a/src/components/WarMap.tsx +++ b/src/components/WarMap.tsx @@ -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(null) const containerRef = useRef(null) const animRef = useRef(0) + const pincerAnimRef = useRef<{ lastProgressStep?: number }>({ lastProgressStep: -1 }) const startRef = useRef(0) const lastAnimUpdateRef = useRef(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[] = [] + 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[] = paths.map((path, i) => { - const progress = ((elapsed / FLIGHT_DURATION_MS + i / paths.length) % 1) - const coord = interpolateOnPath(path, progress) - return { + const features: GeoJSON.Feature[] = [] + 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[] = 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[] = [] + 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[] = 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[] = [] + 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[] = 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[] = [] + 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[] = 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[] = [] + 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[] = 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[] = [] + 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[] = 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() { 真主党势力 + + 库尔德武装 + { 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() { /> + {/* 跨国库尔德势力:土(Bakur)/叙(Rojava)/伊(Bashur) 三区 MultiPolygon + 北/南钳形箭头 */} + + {/* 势力范围:紫色半透明,远景更显、近景更透(描边单独 line 层,参考真主党) */} + + + {/* 进攻目的地地名:亮紫色醒目识别 */} + + {/* 萨南达季、克尔曼沙赫显示圆点(大不里士不显示标记) */} + + + + {/* 伊朗被库尔德进攻点防御线:反弓曲线 + 锯齿,黄色;名称标注 */} + + + + + + {/* 单箭头钳形:曲线箭体,白色加粗轮廓与美方攻击点样式一致 */} + + + + + + + {/* 以色列进攻黎巴嫩箭头:与库尔德同款曲线生长、白轮廓、内发光 */} + + + + + + {/* 美以联军打击伊朗:路径线 */} - {/* 美军打击目标点位 (蓝色) */} + {/* 盟军打击目标点位 (蓝色):含林肯/福特/以色列→伊朗 + 以色列→黎巴嫩,统一名称与脉冲动效 */} ({ - type: 'Feature' as const, - properties: { name: s.name }, - geometry: { type: 'Point' as const, coordinates: [s.lng, s.lat] }, - })), + features: alliedStrikeTargetsFeatures, }} > + {/* 黎巴嫩标注(以色列打击黎巴嫩目标时可见) */} + + + {/* 伊朗区域填充 - 红色系 */} @@ -1501,6 +1844,35 @@ export function WarMap() { }} /> + + {/* 库尔德武装势力范围标注(参照真主党紫色区域标记) */} + + + ) diff --git a/src/data/extendedWarData.ts b/src/data/extendedWarData.ts index 9353ca7..120a1b5 100644 --- a/src/data/extendedWarData.ts +++ b/src/data/extendedWarData.ts @@ -91,8 +91,20 @@ export const EXTENDED_WAR_ZONES = { // 真主党区域标注点(用于显示文字) hezbollahLabelCenter: [35.7, 33.7] as [number, number], - // 真主党打击源(黎巴嫩南部,与势力范围一致,用于攻击矢量起点)[lng, lat] + // 库尔德武装势力区域标注点(叙/土/伊三区紫色带中心附近) + kurdishLabelCenter: [43.5, 36.3] as [number, number], + + // 真主党打击源(黎巴嫩境内多处,随机选取作为攻击起点)[lng, lat] hezbollahStrikeSource: [35.32, 33.28] as [number, number], + /** 黎巴嫩境内攻击源(提尔、西顿、巴勒贝克、贝鲁特南郊、纳巴提耶等),用于黎巴嫩→以色列动画 */ + lebanonStrikeSources: [ + [35.19, 33.27], // 提尔 Tyre + [35.37, 33.56], // 西顿 Sidon + [36.2, 34.01], // 巴勒贝克 Baalbek + [35.5, 33.86], // 贝鲁特南郊 Dahieh + [35.38, 33.38], // 纳巴提耶 Nabatiyeh + [35.85, 33.85], // 贝卡谷地 + ] as [number, number][], // 3. 真主党当前攻击目标 (North Israel Targets) activeAttacks: [ @@ -117,6 +129,139 @@ export const EXTENDED_WAR_ZONES = { ], } as const +/** 跨国库尔德势力范围与钳形攻势 — 土(Bakur)/叙(Rojava)/伊(Bashur) 三区 + 北/南钳形箭头 */ + +const SYRIA_ROJAVA: [number, number][] = [ + [36.0, 36.5], + [42.3, 37.1], + [42.0, 35.0], + [39.0, 34.5], + [38.0, 35.5], + [36.0, 36.5], +] +const TURKEY_BAKUR: [number, number][] = [ + [39.5, 38.5], + [44.5, 38.2], + [44.8, 37.0], + [42.5, 37.0], + [40.0, 37.5], + [39.5, 38.5], +] +const IRAQ_BASHUR: [number, number][] = [ + [42.5, 37.2], + [45.0, 37.3], + [46.2, 35.8], + [45.5, 34.5], + [43.5, 34.8], + [42.5, 36.0], + [42.5, 37.2], +] + +/** 北/中/南三线进攻轴线起止点 [lng, lat],供生长动画与固定几何使用 */ +export const PINCER_AXES = [ + { start: [43.6, 37.2] as [number, number], end: [46.27, 38.08] as [number, number], name: 'North Pincer (Tabriz)' }, + { start: [45.0, 35.4] as [number, number], end: [46.99, 35.31] as [number, number], name: 'Central Pincer (Sanandaj)' }, + { start: [45.6, 35.2] as [number, number], end: [47.07, 34.31] as [number, number], name: 'South Pincer (Kermanshah)' }, +] as const + +/** 以色列进攻黎巴嫩轴线 [lng, lat]:以色列北部 → 黎巴嫩南部/真主党势力方向 */ +export const ISRAEL_LEBANON_AXIS = { + start: [35.25, 32.95] as [number, number], + end: [35.55, 33.45] as [number, number], + name: 'Israel → Lebanon', +} as const + +/** 伊朗被库尔德武装进攻点防御线:大不里士→萨南达季→克尔曼沙赫,黄色虚线 */ +export const KURDISH_ATTACK_DEFENSE_LINE: GeoJSON.Feature = { + type: 'Feature', + properties: { name: '伊朗西部防御线' }, + geometry: { + type: 'LineString', + coordinates: [ + [46.27, 38.08], + [46.99, 35.31], + [47.07, 34.31], + ], + }, +} + +/** 三叉戟 18 点一笔画固定几何(不回头路径、槽位 t 小于主尖),与 PINCER_AXES 顺序一致:北 / 中 / 南 */ +export const PINCER_TRIDENT_RINGS: [number, number][][] = [ + [ // 北线 (Tabriz) — 彻底修复版 + [43.45, 37.55], [43.75, 36.95], [43.85, 37.05], [43.55, 37.65], + [44.40, 37.35], + [45.10, 37.15], [46.15, 37.60], [45.40, 37.55], + [44.80, 37.70], + [45.50, 37.85], [46.27, 38.08], [45.40, 38.00], + [44.75, 37.95], + [45.05, 38.25], [46.00, 38.45], [45.20, 38.15], + [44.35, 37.60], + [43.45, 37.55], + ], + [ + [44.95, 35.70], [45.05, 35.10], [45.15, 35.15], [45.05, 35.75], + [45.60, 35.25], [46.20, 35.10], [46.85, 35.15], [46.20, 35.25], + [45.85, 35.31], [46.40, 35.25], [46.99, 35.31], [46.40, 35.38], + [45.85, 35.45], [46.25, 35.55], [46.80, 35.65], [46.25, 35.45], + [45.65, 35.55], [44.95, 35.70], + ], + [ + [45.85, 35.45], [45.35, 34.95], [45.45, 35.05], [45.95, 35.55], + [46.05, 34.85], [46.45, 34.50], [46.95, 34.15], [46.50, 34.35], + [46.25, 34.45], [46.65, 34.35], [47.07, 34.31], [46.75, 34.55], + [46.35, 34.75], [46.65, 34.85], [47.15, 34.65], [46.55, 34.95], + [46.30, 35.15], [45.85, 35.45], + ], +] + +export const KURDISH_FRONT_GEOJSON: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + name: 'Kurdish Tactical Range (Bakur / Rojava / Bashur)', + region_type: 'InfluenceZone', + description: '叙利亚东北部(SDF)、土耳其东南部、伊拉克 KRG 自治区,紫色弧形地带向 Rojhelat 钳形攻势', + }, + geometry: { + type: 'MultiPolygon', + coordinates: [[SYRIA_ROJAVA], [TURKEY_BAKUR], [IRAQ_BASHUR]], + }, + }, + { + type: 'Feature', + properties: { + name: '大不里士 (Tabriz)', + region_type: 'Target', + status: 'Obj: CRITICAL', + showMarker: false, + }, + geometry: { type: 'Point', coordinates: [46.27, 38.08] }, + }, + { + type: 'Feature', + properties: { + name: '萨南达季 (Sanandaj)', + region_type: 'Target', + status: 'Obj: HIGH', + showMarker: true, + }, + geometry: { type: 'Point', coordinates: [46.99, 35.31] }, + }, + { + type: 'Feature', + properties: { + name: '克尔曼沙赫 (Kermanshah)', + region_type: 'Target', + status: 'Obj: STRATEGIC', + showMarker: true, + }, + geometry: { type: 'Point', coordinates: [47.07, 34.31] }, + }, + ], +} + // 战损评估点位(以色列打击黎巴嫩 & 联军打击伊朗本土) export const STRIKE_DAMAGE_ASSESSMENT = { lebanonFront: [ diff --git a/src/data/mockData.ts b/src/data/mockData.ts index 1a02c25..1fef51a 100644 --- a/src/data/mockData.ts +++ b/src/data/mockData.ts @@ -133,6 +133,10 @@ export interface MilitarySituation { targets: { lng: number; lat: number; name?: string; struck_at?: string | null }[] }[] } + /** 动画配置:攻击脉冲衰减窗口(天),可在编辑面板调整 */ + animationConfig?: { + strikeCutoffDays: number + } } export const INITIAL_MOCK_DATA: MilitarySituation = { @@ -329,6 +333,11 @@ export const INITIAL_MOCK_DATA: MilitarySituation = { { 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: '赫尔梅勒无人机阵地' }, ], }, { diff --git a/src/hooks/useWarMapData.ts b/src/hooks/useWarMapData.ts new file mode 100644 index 0000000..ba15f30 --- /dev/null +++ b/src/hooks/useWarMapData.ts @@ -0,0 +1,109 @@ +import { useMemo, useState, useEffect } from 'react' +import { + PINCER_AXES, + ISRAEL_LEBANON_AXIS, + KURDISH_ATTACK_DEFENSE_LINE, +} from '@/data/extendedWarData' +import { createTacticalPincerAtProgress } from '@/utils/tacticalPincerArrow' +import { createCurvedDefensePath, createDefenseLine } from '@/utils/defenseLine' + +export type PincerAxis = { start: [number, number]; end: [number, number]; name: string } +export type IsraelLebanonAxis = { start: [number, number]; end: [number, number]; name: string } + +export type WarMapConfig = { + pincerAxes: PincerAxis[] + israelLebanonAxis: IsraelLebanonAxis + defenseLinePath: [number, number][] +} + +const DEFAULT_CONFIG: WarMapConfig = { + pincerAxes: [...PINCER_AXES], + israelLebanonAxis: { ...ISRAEL_LEBANON_AXIS }, + defenseLinePath: KURDISH_ATTACK_DEFENSE_LINE.geometry.coordinates as [number, number][], +} + +export type WarMapData = WarMapConfig & { + kurdishPincerGrowthInitial: GeoJSON.FeatureCollection + israelLebanonArrowInitial: GeoJSON.FeatureCollection + kurdishDefenseLineGeoJson: GeoJSON.FeatureCollection +} + +function buildGeoJsonFromConfig(config: WarMapConfig): Omit { + const kurdishPincerGrowthInitial: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: config.pincerAxes.map((axis) => ({ + type: 'Feature', + properties: { name: axis.name }, + geometry: { + type: 'Polygon', + coordinates: [createTacticalPincerAtProgress(axis.start, axis.end, 0)], + }, + })), + } + const israelLebanonArrowInitial: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + properties: { name: config.israelLebanonAxis.name }, + geometry: { + type: 'Polygon', + coordinates: [createTacticalPincerAtProgress( + config.israelLebanonAxis.start, + config.israelLebanonAxis.end, + 0 + )], + }, + }], + } + const curvedPath = createCurvedDefensePath(config.defenseLinePath, 0.35, 24) + const lineCoords = createDefenseLine(curvedPath, 0.18, 0.08) + // 整体防御线方向:用锯齿线起点与终点的连线方向 + const startPoint = lineCoords[0] + const endPoint = lineCoords[lineCoords.length - 1] + const dx = endPoint[0] - startPoint[0] + const dy = endPoint[1] - startPoint[1] + const tangentAngle = Math.atan2(dy, dx) + const normalAngle = tangentAngle + Math.PI / 2 + let angleDeg = (normalAngle * 180) / Math.PI + angleDeg = ((angleDeg + 540) % 360) - 180 // 归一到 [-180, 180] + // 标注点放在整条防御线(锯齿线)中点 + const labelIndex = Math.floor(lineCoords.length / 2) + const labelPoint = lineCoords[labelIndex] + const name = '伊朗西部防御线' + const kurdishDefenseLineGeoJson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [ + { type: 'Feature', properties: { name }, geometry: { type: 'LineString', coordinates: lineCoords } }, + { type: 'Feature', properties: { name, angle: angleDeg }, geometry: { type: 'Point', coordinates: labelPoint } }, + ], + } + return { kurdishPincerGrowthInitial, israelLebanonArrowInitial, kurdishDefenseLineGeoJson } +} + +/** 封装地图轴线、防御线及派生 GeoJSON,可从 API 加载后落库,方便后续直接调用 */ +export function useWarMapData(): WarMapData { + const [config, setConfig] = useState(DEFAULT_CONFIG) + + useEffect(() => { + let cancelled = false + fetch('/api/war-map-config', { cache: 'no-store' }) + .then((res) => (res.ok ? res.json() : null)) + .then((data: WarMapConfig | null) => { + if (cancelled || !data) return + if (Array.isArray(data.pincerAxes) && data.pincerAxes.length > 0 && data.israelLebanonAxis && Array.isArray(data.defenseLinePath)) { + setConfig({ + pincerAxes: data.pincerAxes, + israelLebanonAxis: data.israelLebanonAxis, + defenseLinePath: data.defenseLinePath, + }) + } + }) + .catch(() => {}) + return () => { cancelled = true } + }, []) + + return useMemo(() => { + const built = buildGeoJsonFromConfig(config) + return { ...config, ...built } + }, [config]) +} diff --git a/src/pages/EditDashboard.tsx b/src/pages/EditDashboard.tsx index d2592a1..7eebdfa 100644 --- a/src/pages/EditDashboard.tsx +++ b/src/pages/EditDashboard.tsx @@ -9,11 +9,13 @@ import { deleteSituationUpdate, putForceSummary, putDisplayStats, + putAnimationConfig, type EditRawData, type CombatLossesRow, type KeyLocationRow, type ForceSummaryRow, type DisplayStatsRow, + type AnimationConfigRow, } from '@/api/edit' import { fetchAndSetSituation } from '@/store/situationStore' import { useStatsStore } from '@/store/statsStore' @@ -186,6 +188,18 @@ export function EditDashboard() { } } + const handleSaveAnimationConfig = async (row: AnimationConfigRow) => { + setSaving('animationConfig') + try { + await putAnimationConfig({ strikeCutoffDays: row.strikeCutoffDays }) + await afterSave() + } catch (e) { + setError(e instanceof Error ? e.message : '保存失败') + } finally { + setSaving(null) + } + } + const handleClearDisplayStatsOverrides = async () => { if (!confirm('确定清除所有覆盖?将恢复为实时统计(在看=近2分钟访问数,看过=累计访问等)。')) return setSaving('displayStats') @@ -245,6 +259,57 @@ export function EditDashboard() { )}
+ {/* 动画衰减参数 */} +
+ + {openSections.has('animation') && data && ( +
+

+ 衰减系数用于控制「打击脉冲」持续天数。超出该天数后,仅保留减弱的呼吸效果与标注。 +

+
{ + e.preventDefault() + const form = e.currentTarget + const fd = new FormData(form) + const v = Number(fd.get('strikeCutoffDays') || 0) + const current: AnimationConfigRow = { + strikeCutoffDays: Number.isFinite(v) && v > 0 ? v : data.animationConfig?.strikeCutoffDays ?? 5, + } + handleSaveAnimationConfig(current) + }} + > + + +
+
+ )} +
+ {/* 看过、在看、分享、点赞、留言 */}