fix: 新增态 效果
This commit is contained in:
Binary file not shown.
Binary file not shown.
19
server/db.js
19
server/db.js
@@ -291,6 +291,25 @@ function runMigrations(db) {
|
|||||||
exec('ALTER TABLE map_strike_line ADD COLUMN struck_at TEXT')
|
exec('ALTER TABLE map_strike_line ADD COLUMN struck_at TEXT')
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS animation_config (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
strike_cutoff_days INTEGER NOT NULL DEFAULT 5,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO animation_config (id, strike_cutoff_days) VALUES (1, 5);
|
||||||
|
`)
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS war_map_config (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
config TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initDb() {
|
async function initDb() {
|
||||||
|
|||||||
@@ -191,6 +191,41 @@ router.get('/stats', (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 战区地图配置:钳形轴线、以黎轴线、防御线路径,供前端 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) => {
|
router.get('/events', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const s = getSituation()
|
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 summaryUs = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('us')
|
||||||
const summaryIr = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('iran')
|
const summaryIr = db.prepare('SELECT * FROM force_summary WHERE side = ?').get('iran')
|
||||||
let displayStats = null
|
let displayStats = null
|
||||||
|
let animationConfig = null
|
||||||
try {
|
try {
|
||||||
displayStats = db.prepare('SELECT override_enabled, viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get()
|
displayStats = db.prepare('SELECT override_enabled, viewers, cumulative, share_count, like_count, feedback_count FROM display_stats WHERE id = 1').get()
|
||||||
} catch (_) {}
|
} 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 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 realShare = db.prepare('SELECT total FROM share_count WHERE id = 1').get()?.total ?? 0
|
||||||
const liveViewers = db.prepare(
|
const liveViewers = db.prepare(
|
||||||
@@ -251,6 +290,9 @@ router.get('/edit/raw', (req, res) => {
|
|||||||
likeCount: displayStats?.like_count ?? realLikeCount,
|
likeCount: displayStats?.like_count ?? realLikeCount,
|
||||||
feedbackCount: displayStats?.feedback_count ?? realFeedback,
|
feedbackCount: displayStats?.feedback_count ?? realFeedback,
|
||||||
},
|
},
|
||||||
|
animationConfig: {
|
||||||
|
strikeCutoffDays: animationConfig?.strike_cutoff_days ?? 5,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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
|
module.exports = router
|
||||||
|
|||||||
@@ -190,7 +190,16 @@ function seed() {
|
|||||||
[46.42, 33.64, '伊拉姆导弹阵地', '2026-02-28T02:40:00.000Z'],
|
[46.42, 33.64, '伊拉姆导弹阵地', '2026-02-28T02:40:00.000Z'],
|
||||||
[48.35, 33.48, '霍拉马巴德储备库', '2026-02-28T02:42: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))
|
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))
|
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))
|
||||||
|
|
||||||
@@ -229,6 +238,28 @@ function seed() {
|
|||||||
const insertUpdate = db.prepare('INSERT INTO situation_update (id, timestamp, category, summary, severity) VALUES (?, ?, ?, ?, ?)')
|
const insertUpdate = db.prepare('INSERT INTO situation_update (id, timestamp, category, summary, severity) VALUES (?, ?, ?, ?, ?)')
|
||||||
updateRows.forEach((row) => insertUpdate.run(...row))
|
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')
|
db.prepare("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)").run('2026-03-01T11:45:00.000Z')
|
||||||
console.log('Seed completed.')
|
console.log('Seed completed.')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ function getSituation() {
|
|||||||
const updates = db.prepare('SELECT * FROM situation_update ORDER BY timestamp DESC LIMIT 50').all()
|
const updates = db.prepare('SELECT * FROM situation_update ORDER BY timestamp DESC LIMIT 50').all()
|
||||||
// 数据更新时间:与前端「实时更新」一致,仅在爬虫 notify / 编辑保存时由 index.js 或 routes 更新
|
// 数据更新时间:与前端「实时更新」一致,仅在爬虫 notify / 编辑保存时由 index.js 或 routes 更新
|
||||||
const meta = db.prepare('SELECT updated_at FROM situation WHERE id = 1').get()
|
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 conflictEvents = []
|
||||||
let conflictStats = { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 }
|
let conflictStats = { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 }
|
||||||
@@ -180,6 +184,9 @@ function getSituation() {
|
|||||||
strikeSources: mapStrikeSources,
|
strikeSources: mapStrikeSources,
|
||||||
strikeLines: mapStrikeLines,
|
strikeLines: mapStrikeLines,
|
||||||
},
|
},
|
||||||
|
animationConfig: {
|
||||||
|
strikeCutoffDays: animationConfigRow?.strike_cutoff_days ?? 5,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,12 +63,17 @@ export interface DisplayStatsRow {
|
|||||||
feedbackCount: number
|
feedbackCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AnimationConfigRow {
|
||||||
|
strikeCutoffDays: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface EditRawData {
|
export interface EditRawData {
|
||||||
combatLosses: { us: CombatLossesRow | null; iran: CombatLossesRow | null }
|
combatLosses: { us: CombatLossesRow | null; iran: CombatLossesRow | null }
|
||||||
keyLocations: { us: KeyLocationRow[]; iran: KeyLocationRow[] }
|
keyLocations: { us: KeyLocationRow[]; iran: KeyLocationRow[] }
|
||||||
situationUpdates: SituationUpdateRow[]
|
situationUpdates: SituationUpdateRow[]
|
||||||
forceSummary: { us: ForceSummaryRow | null; iran: ForceSummaryRow | null }
|
forceSummary: { us: ForceSummaryRow | null; iran: ForceSummaryRow | null }
|
||||||
displayStats?: DisplayStatsRow
|
displayStats?: DisplayStatsRow
|
||||||
|
animationConfig?: AnimationConfigRow
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchEditRaw(): Promise<EditRawData> {
|
export async function fetchEditRaw(): Promise<EditRawData> {
|
||||||
@@ -155,3 +160,15 @@ export async function putDisplayStats(
|
|||||||
throw new Error((e as { error?: string }).error || res.statusText)
|
throw new Error((e as { error?: string }).error || res.statusText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function putAnimationConfig(body: Partial<AnimationConfigRow>): Promise<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import 'mapbox-gl/dist/mapbox-gl.css'
|
|||||||
import { useReplaySituation } from '@/hooks/useReplaySituation'
|
import { useReplaySituation } from '@/hooks/useReplaySituation'
|
||||||
import { usePlaybackStore } from '@/store/playbackStore'
|
import { usePlaybackStore } from '@/store/playbackStore'
|
||||||
import { config } from '@/config'
|
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 || ''
|
const MAPBOX_TOKEN = config.mapboxAccessToken || ''
|
||||||
|
|
||||||
@@ -53,9 +55,32 @@ const ALLIES_ADMIN = [
|
|||||||
'Djibouti',
|
'Djibouti',
|
||||||
]
|
]
|
||||||
|
|
||||||
// 伊朗攻击源 德黑兰 [lng, lat]
|
// 伊朗攻击源 德黑兰 [lng, lat](若后端 map_strike_source 有 iran/tehran 则可由 API 覆盖)
|
||||||
const TEHRAN_SOURCE: [number, number] = [51.389, 35.6892]
|
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 一致)
|
// API 未返回 mapData 时的静态 fallback,保证美/以打击线与动画不消失(与 server/seed.js 一致)
|
||||||
const FALLBACK_STRIKE_SOURCES: { id: string; name: string; lng: number; lat: number }[] = [
|
const FALLBACK_STRIKE_SOURCES: { id: string; name: string; lng: number; lat: number }[] = [
|
||||||
{ id: 'israel', name: '以色列', lng: 34.78, lat: 32.08 },
|
{ id: 'israel', name: '以色列', lng: 34.78, lat: 32.08 },
|
||||||
@@ -70,6 +95,11 @@ const FALLBACK_STRIKE_LINES: { sourceId: string; targets: { lng: number; lat: nu
|
|||||||
{ lng: 50.876409, lat: 34.625448, name: '伊朗专家会议秘书处' },
|
{ lng: 50.876409, lat: 34.625448, name: '伊朗专家会议秘书处' },
|
||||||
{ lng: 51.916, lat: 33.666, name: '纳坦兹' },
|
{ lng: 51.916, lat: 33.666, name: '纳坦兹' },
|
||||||
{ lng: 51.002, lat: 35.808, 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 mapRef = useRef<MapRef>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const animRef = useRef<number>(0)
|
const animRef = useRef<number>(0)
|
||||||
|
const pincerAnimRef = useRef<{ lastProgressStep?: number }>({ lastProgressStep: -1 })
|
||||||
const startRef = useRef<number>(0)
|
const startRef = useRef<number>(0)
|
||||||
const lastAnimUpdateRef = useRef<number>(0)
|
const lastAnimUpdateRef = useRef<number>(0)
|
||||||
const attackPathsRef = useRef<[number, number][][]>([])
|
const attackPathsRef = useRef<[number, number][][]>([])
|
||||||
@@ -185,8 +216,11 @@ export function WarMap() {
|
|||||||
const hezbollahPathsRef = useRef<[number, number][][]>([])
|
const hezbollahPathsRef = useRef<[number, number][][]>([])
|
||||||
const hormuzPathsRef = useRef<[number, number][][]>([])
|
const hormuzPathsRef = useRef<[number, number][][]>([])
|
||||||
const situation = useReplaySituation()
|
const situation = useReplaySituation()
|
||||||
const { isReplayMode } = usePlaybackStore()
|
const { isReplayMode, playbackTime } = usePlaybackStore()
|
||||||
const { usForces, iranForces, conflictEvents = [] } = situation
|
const { usForces, iranForces, conflictEvents = [] } = situation
|
||||||
|
/** 时间衰减基准:回放模式用回放时刻,否则用数据更新时间或当前时间 */
|
||||||
|
const referenceTime =
|
||||||
|
isReplayMode ? playbackTime : situation.lastUpdated || new Date().toISOString()
|
||||||
|
|
||||||
const usLocs = (usForces.keyLocations || []) as KeyLoc[]
|
const usLocs = (usForces.keyLocations || []) as KeyLoc[]
|
||||||
const irLocs = (iranForces.keyLocations || []) as KeyLoc[]
|
const irLocs = (iranForces.keyLocations || []) as KeyLoc[]
|
||||||
@@ -226,16 +260,23 @@ export function WarMap() {
|
|||||||
}, [usForces.keyLocations, iranForces.keyLocations])
|
}, [usForces.keyLocations, iranForces.keyLocations])
|
||||||
|
|
||||||
const mapData = situation.mapData
|
const mapData = situation.mapData
|
||||||
const attackedTargets = mapData?.attackedTargets ?? []
|
|
||||||
const strikeSources =
|
const strikeSources =
|
||||||
mapData?.strikeSources?.length > 0 ? mapData.strikeSources : FALLBACK_STRIKE_SOURCES
|
mapData?.strikeSources?.length > 0 ? mapData.strikeSources : FALLBACK_STRIKE_SOURCES
|
||||||
const strikeLines =
|
const strikeLines =
|
||||||
mapData?.strikeLines?.length > 0 ? mapData.strikeLines : FALLBACK_STRIKE_LINES
|
mapData?.strikeLines?.length > 0 ? mapData.strikeLines : FALLBACK_STRIKE_LINES
|
||||||
|
|
||||||
const attackPaths = useMemo(
|
/** 伊朗→美军基地:仅用 DB 数据,5 天内显示飞行动画 */
|
||||||
() => attackedTargets.map((target) => parabolaPath(TEHRAN_SOURCE, target as [number, number])),
|
const strikeCutoffDays = situation.animationConfig?.strikeCutoffDays ?? 5
|
||||||
[attackedTargets]
|
|
||||||
|
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
|
attackPathsRef.current = attackPaths
|
||||||
|
|
||||||
@@ -245,34 +286,48 @@ export function WarMap() {
|
|||||||
return m
|
return m
|
||||||
}, [strikeSources])
|
}, [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 lincolnPaths = useMemo(() => {
|
||||||
const line = strikeLines.find((l) => l.sourceId === 'lincoln')
|
const line = strikeLines.find((l) => l.sourceId === 'lincoln')
|
||||||
const coords = sourceCoords.lincoln
|
const coords = sourceCoords.lincoln
|
||||||
if (!coords || !line) return []
|
if (!coords || !line) return []
|
||||||
return line.targets.map((t) => parabolaPath(coords, [t.lng, t.lat]))
|
return filterTargetsByAnimationWindow(line.targets).map((t) =>
|
||||||
}, [strikeLines, sourceCoords])
|
parabolaPath(coords, [t.lng, t.lat])
|
||||||
|
)
|
||||||
|
}, [strikeLines, sourceCoords, filterTargetsByAnimationWindow])
|
||||||
const fordPaths = useMemo(() => {
|
const fordPaths = useMemo(() => {
|
||||||
const line = strikeLines.find((l) => l.sourceId === 'ford')
|
const line = strikeLines.find((l) => l.sourceId === 'ford')
|
||||||
const coords = sourceCoords.ford
|
const coords = sourceCoords.ford
|
||||||
if (!coords || !line) return []
|
if (!coords || !line) return []
|
||||||
return line.targets.map((t) => parabolaPath(coords, [t.lng, t.lat]))
|
return filterTargetsByAnimationWindow(line.targets).map((t) =>
|
||||||
}, [strikeLines, sourceCoords])
|
parabolaPath(coords, [t.lng, t.lat])
|
||||||
|
)
|
||||||
|
}, [strikeLines, sourceCoords, filterTargetsByAnimationWindow])
|
||||||
const israelPaths = useMemo(() => {
|
const israelPaths = useMemo(() => {
|
||||||
const line = strikeLines.find((l) => l.sourceId === 'israel')
|
const line = strikeLines.find((l) => l.sourceId === 'israel')
|
||||||
const coords = sourceCoords.israel
|
const coords = sourceCoords.israel
|
||||||
if (!coords || !line) return []
|
if (!coords || !line) return []
|
||||||
return line.targets.map((t) => parabolaPath(coords, [t.lng, t.lat]))
|
return filterTargetsByAnimationWindow(line.targets).map((t) =>
|
||||||
}, [strikeLines, sourceCoords])
|
parabolaPath(coords, [t.lng, t.lat])
|
||||||
// 真主党 → 以色列北部三处目标(与美/以打击弧线一致:同一 parabola 高度与动画方式)
|
|
||||||
const hezbollahSource = EXTENDED_WAR_ZONES.hezbollahStrikeSource
|
|
||||||
const hezbollahPaths = useMemo(
|
|
||||||
() =>
|
|
||||||
isReplayMode
|
|
||||||
? []
|
|
||||||
: EXTENDED_WAR_ZONES.activeAttacks.map((t) => parabolaPath(hezbollahSource, t.coords, 3)),
|
|
||||||
[hezbollahSource, isReplayMode]
|
|
||||||
)
|
)
|
||||||
// 伊朗不同地点 → 霍尔木兹海峡多点攻击(黄色轨迹)
|
}, [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(
|
const hormuzTargetPoints = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[
|
[
|
||||||
@@ -282,18 +337,39 @@ export function WarMap() {
|
|||||||
] as [number, number][],
|
] as [number, number][],
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
/** 伊朗多处→霍尔木兹:德黑兰、克尔曼沙赫、库姆 攻击海峡目标(无日期按 decay=1 显示) */
|
||||||
const hormuzPaths = useMemo(() => {
|
const hormuzPaths = useMemo(() => {
|
||||||
if (isReplayMode) return []
|
|
||||||
// 使用更远的伊朗腹地/纵深位置,弧线更明显
|
|
||||||
const sources: [number, number][] = [
|
const sources: [number, number][] = [
|
||||||
TEHRAN_SOURCE, // 德黑兰
|
TEHRAN_SOURCE,
|
||||||
[47.16, 34.35], // 克尔曼沙赫导弹阵地
|
[47.16, 34.35],
|
||||||
[50.88, 34.64], // 库姆附近
|
[50.88, 34.64],
|
||||||
]
|
]
|
||||||
return hormuzTargetPoints.map((target, idx) =>
|
return hormuzTargetPoints.map((target, idx) =>
|
||||||
parabolaPath(sources[idx % sources.length], target, 3)
|
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
|
lincolnPathsRef.current = lincolnPaths
|
||||||
fordPathsRef.current = fordPaths
|
fordPathsRef.current = fordPaths
|
||||||
israelPathsRef.current = israelPaths
|
israelPathsRef.current = israelPaths
|
||||||
@@ -333,6 +409,21 @@ export function WarMap() {
|
|||||||
}),
|
}),
|
||||||
[israelPaths]
|
[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(
|
const hezbollahLinesGeoJson = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
type: 'FeatureCollection' as const,
|
type: 'FeatureCollection' as const,
|
||||||
@@ -458,20 +549,24 @@ export function WarMap() {
|
|||||||
|
|
||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
const zoom = map.getZoom()
|
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 {
|
try {
|
||||||
// 光点从起点飞向目标的循环动画
|
// 伊朗→美军基地:速度固定,仅减少光点数量(频次)
|
||||||
const src = map.getSource('attack-dots') as { setData: (d: GeoJSON.FeatureCollection) => void } | undefined
|
const src = map.getSource('attack-dots') as { setData: (d: GeoJSON.FeatureCollection) => void } | undefined
|
||||||
const paths = attackPathsRef.current
|
const paths = attackPathsRef.current
|
||||||
if (src && paths.length > 0) {
|
if (src && paths.length > 0) {
|
||||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = paths.map((path, i) => {
|
const features: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||||
const progress = ((elapsed / FLIGHT_DURATION_MS + i / paths.length) % 1)
|
paths.forEach((path, i) => {
|
||||||
const coord = interpolateOnPath(path, progress)
|
if (i % step !== 0) return
|
||||||
return {
|
const progress = (elapsed / FLIGHT_DURATION_MS + i / Math.max(paths.length, 1)) % 1
|
||||||
|
features.push({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: {},
|
properties: {},
|
||||||
geometry: { type: 'Point' as const, coordinates: coord },
|
geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) },
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
src.setData({ type: 'FeatureCollection', features })
|
src.setData({ type: 'FeatureCollection', features })
|
||||||
}
|
}
|
||||||
@@ -480,12 +575,12 @@ export function WarMap() {
|
|||||||
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.003)
|
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.003)
|
||||||
map.setPaintProperty('points-damaged', 'circle-opacity', blink)
|
map.setPaintProperty('points-damaged', 'circle-opacity', blink)
|
||||||
}
|
}
|
||||||
// attacked: 红色脉冲 2s 循环, 半径随 zoom 缩放;phase/r/opacity 钳位避免浮点或取模越界
|
// attacked: 红色脉冲,半径 = 基准×phase×zoomScale×decayScale(线性衰减)
|
||||||
if (map.getLayer('points-attacked-pulse')) {
|
if (map.getLayer('points-attacked-pulse')) {
|
||||||
const cycle = 2000
|
const cycle = 2000
|
||||||
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
||||||
const r = Math.max(0, 40 * phase * zoomScale)
|
const r = Math.max(0, 32 * phase * zoomScale * decayScale)
|
||||||
const opacity = Math.min(1, Math.max(0, 1 - phase))
|
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-radius', r)
|
||||||
map.setPaintProperty('points-attacked-pulse', 'circle-opacity', opacity)
|
map.setPaintProperty('points-attacked-pulse', 'circle-opacity', opacity)
|
||||||
}
|
}
|
||||||
@@ -495,18 +590,16 @@ export function WarMap() {
|
|||||||
| undefined
|
| undefined
|
||||||
const lincolnPaths = lincolnPathsRef.current
|
const lincolnPaths = lincolnPathsRef.current
|
||||||
if (lincolnSrc && lincolnPaths.length > 0) {
|
if (lincolnSrc && lincolnPaths.length > 0) {
|
||||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = lincolnPaths.map(
|
const features: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||||
(path, i) => {
|
lincolnPaths.forEach((path, i) => {
|
||||||
const progress =
|
if (i % step !== 0) return
|
||||||
(elapsed / FLIGHT_DURATION_MS + 0.5 + i / Math.max(lincolnPaths.length, 1)) % 1
|
const progress = (elapsed / FLIGHT_DURATION_MS + 0.5 + i / Math.max(lincolnPaths.length, 1)) % 1
|
||||||
const coord = interpolateOnPath(path, progress)
|
features.push({
|
||||||
return {
|
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: {},
|
properties: {},
|
||||||
geometry: { type: 'Point' as const, coordinates: coord },
|
geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) },
|
||||||
}
|
})
|
||||||
}
|
})
|
||||||
)
|
|
||||||
lincolnSrc.setData({ type: 'FeatureCollection', features })
|
lincolnSrc.setData({ type: 'FeatureCollection', features })
|
||||||
}
|
}
|
||||||
// 福特号打击伊朗:青色光点
|
// 福特号打击伊朗:青色光点
|
||||||
@@ -515,18 +608,16 @@ export function WarMap() {
|
|||||||
| undefined
|
| undefined
|
||||||
const fordPaths = fordPathsRef.current
|
const fordPaths = fordPathsRef.current
|
||||||
if (fordSrc && fordPaths.length > 0) {
|
if (fordSrc && fordPaths.length > 0) {
|
||||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = fordPaths.map(
|
const features: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||||
(path, i) => {
|
fordPaths.forEach((path, i) => {
|
||||||
const progress =
|
if (i % step !== 0) return
|
||||||
(elapsed / FLIGHT_DURATION_MS + 0.3 + i / Math.max(fordPaths.length, 1)) % 1
|
const progress = (elapsed / FLIGHT_DURATION_MS + 0.3 + i / Math.max(fordPaths.length, 1)) % 1
|
||||||
const coord = interpolateOnPath(path, progress)
|
features.push({
|
||||||
return {
|
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: {},
|
properties: {},
|
||||||
geometry: { type: 'Point' as const, coordinates: coord },
|
geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) },
|
||||||
}
|
})
|
||||||
}
|
})
|
||||||
)
|
|
||||||
fordSrc.setData({ type: 'FeatureCollection', features })
|
fordSrc.setData({ type: 'FeatureCollection', features })
|
||||||
}
|
}
|
||||||
// 以色列打击伊朗:浅青/白色光点
|
// 以色列打击伊朗:浅青/白色光点
|
||||||
@@ -535,18 +626,16 @@ export function WarMap() {
|
|||||||
| undefined
|
| undefined
|
||||||
const israelPaths = israelPathsRef.current
|
const israelPaths = israelPathsRef.current
|
||||||
if (israelSrc && israelPaths.length > 0) {
|
if (israelSrc && israelPaths.length > 0) {
|
||||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = israelPaths.map(
|
const features: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||||
(path, i) => {
|
israelPaths.forEach((path, i) => {
|
||||||
const progress =
|
if (i % step !== 0) return
|
||||||
(elapsed / FLIGHT_DURATION_MS + 0.1 + i / Math.max(israelPaths.length, 1)) % 1
|
const progress = (elapsed / FLIGHT_DURATION_MS + 0.1 + i / Math.max(israelPaths.length, 1)) % 1
|
||||||
const coord = interpolateOnPath(path, progress)
|
features.push({
|
||||||
return {
|
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: {},
|
properties: {},
|
||||||
geometry: { type: 'Point' as const, coordinates: coord },
|
geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) },
|
||||||
}
|
})
|
||||||
}
|
})
|
||||||
)
|
|
||||||
israelSrc.setData({ type: 'FeatureCollection', features })
|
israelSrc.setData({ type: 'FeatureCollection', features })
|
||||||
}
|
}
|
||||||
// 真主党打击以色列北部:橙红光点,与林肯/福特/以色列同一动画方式
|
// 真主党打击以色列北部:橙红光点,与林肯/福特/以色列同一动画方式
|
||||||
@@ -555,15 +644,15 @@ export function WarMap() {
|
|||||||
| undefined
|
| undefined
|
||||||
const hezPaths = hezbollahPathsRef.current
|
const hezPaths = hezbollahPathsRef.current
|
||||||
if (hezSrc && hezPaths.length > 0) {
|
if (hezSrc && hezPaths.length > 0) {
|
||||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = hezPaths.map((path, i) => {
|
const features: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||||
const progress =
|
hezPaths.forEach((path, i) => {
|
||||||
(elapsed / FLIGHT_DURATION_MS + 0.2 + i / Math.max(hezPaths.length, 1)) % 1
|
if (i % step !== 0) return
|
||||||
const coord = interpolateOnPath(path, progress)
|
const progress = (elapsed / FLIGHT_DURATION_MS + 0.2 + i / Math.max(hezPaths.length, 1)) % 1
|
||||||
return {
|
features.push({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: {},
|
properties: {},
|
||||||
geometry: { type: 'Point' as const, coordinates: coord },
|
geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) },
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
hezSrc.setData({ type: 'FeatureCollection', features })
|
hezSrc.setData({ type: 'FeatureCollection', features })
|
||||||
}
|
}
|
||||||
@@ -573,56 +662,119 @@ export function WarMap() {
|
|||||||
| undefined
|
| undefined
|
||||||
const hormuzPaths = hormuzPathsRef.current
|
const hormuzPaths = hormuzPathsRef.current
|
||||||
if (hormuzSrc && hormuzPaths.length > 0) {
|
if (hormuzSrc && hormuzPaths.length > 0) {
|
||||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = hormuzPaths.map((path, i) => {
|
const features: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||||
const progress =
|
hormuzPaths.forEach((path, i) => {
|
||||||
(elapsed / FLIGHT_DURATION_MS + 0.15 + i / Math.max(hormuzPaths.length, 1)) % 1
|
if (i % step !== 0) return
|
||||||
const coord = interpolateOnPath(path, progress)
|
const progress = (elapsed / FLIGHT_DURATION_MS + 0.15 + i / Math.max(hormuzPaths.length, 1)) % 1
|
||||||
return {
|
features.push({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: {},
|
properties: {},
|
||||||
geometry: { type: 'Point' as const, coordinates: coord },
|
geometry: { type: 'Point' as const, coordinates: interpolateOnPath(path, progress) },
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
hormuzSrc.setData({ type: 'FeatureCollection', features })
|
hormuzSrc.setData({ type: 'FeatureCollection', features })
|
||||||
}
|
}
|
||||||
// 伊朗被打击目标:蓝色脉冲 (2s 周期), 半径随 zoom 缩放;phase/r/opacity 钳位
|
// 盟军打击目标:脉冲半径 = 基准×decayScale×zoomScale,线性衰减,镜头拉远半径变小
|
||||||
if (map.getLayer('allied-strike-targets-pulse')) {
|
if (map.getLayer('allied-strike-targets-pulse')) {
|
||||||
const cycle = 2000
|
const cycle = 2000
|
||||||
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
||||||
const r = Math.max(0, 35 * phase * zoomScale)
|
const breathMin = 8
|
||||||
const opacity = Math.min(1, Math.max(0, 1 - phase * 1.2))
|
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-radius', r)
|
||||||
map.setPaintProperty('allied-strike-targets-pulse', 'circle-opacity', opacity)
|
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):闪烁
|
// GDELT 橙色 (4–6):闪烁
|
||||||
if (map.getLayer('gdelt-events-orange')) {
|
if (map.getLayer('gdelt-events-orange')) {
|
||||||
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.004)
|
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.004)
|
||||||
map.setPaintProperty('gdelt-events-orange', 'circle-opacity', blink)
|
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')) {
|
if (map.getLayer('gdelt-events-red-pulse')) {
|
||||||
const cycle = 2200
|
const cycle = 2200
|
||||||
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
||||||
const r = Math.max(0, 30 * phase * zoomScale)
|
const r = Math.max(0, 24 * phase * zoomScale * decayScale)
|
||||||
const opacity = Math.min(1, Math.max(0, 1 - phase * 1.1))
|
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-radius', r)
|
||||||
map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity)
|
map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity)
|
||||||
}
|
}
|
||||||
// 真主党攻击目标:橙红脉冲,与 allied-strike-targets 同一周期与半径
|
// 真主党攻击目标:脉冲半径衰减线性插值,镜头拉远变小
|
||||||
if (map.getLayer('hezbollah-attack-targets-pulse')) {
|
if (map.getLayer('hezbollah-attack-targets-pulse')) {
|
||||||
const cycle = 2000
|
const cycle = 2000
|
||||||
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
||||||
const r = Math.max(0, 35 * phase * zoomScale)
|
const r = Math.max(0, 26 * phase * zoomScale * decayScale)
|
||||||
const opacity = Math.min(1, Math.max(0, 1 - phase * 1.2))
|
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-radius', r)
|
||||||
map.setPaintProperty('hezbollah-attack-targets-pulse', 'circle-opacity', opacity)
|
map.setPaintProperty('hezbollah-attack-targets-pulse', 'circle-opacity', opacity)
|
||||||
}
|
}
|
||||||
// 霍尔木兹海峡被打击目标:琥珀黄脉冲,保持与其他被打击点一致但颜色区分
|
// 霍尔木兹海峡被打击目标:脉冲半径随 zoom×decayScale
|
||||||
if (map.getLayer('iran-hormuz-targets-pulse')) {
|
if (map.getLayer('iran-hormuz-targets-pulse')) {
|
||||||
const cycle = 2000
|
const cycle = 2000
|
||||||
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
const phase = Math.max(0, Math.min(1, (elapsed % cycle) / cycle))
|
||||||
const r = Math.max(0, 32 * phase * zoomScale)
|
const r = Math.max(0, 24 * phase * zoomScale * decayScale)
|
||||||
const opacity = Math.min(1, Math.max(0, 1 - phase * 1.1))
|
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-radius', r)
|
||||||
map.setPaintProperty('iran-hormuz-targets-pulse', 'circle-opacity', opacity)
|
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-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('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-green') ||
|
||||||
map.getSource('gdelt-events-orange') ||
|
map.getSource('gdelt-events-orange') ||
|
||||||
map.getSource('gdelt-events-red')
|
map.getSource('gdelt-events-red')
|
||||||
@@ -737,6 +893,9 @@ export function WarMap() {
|
|||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="h-1.5 w-1.5 rounded-sm bg-lime-400/40" /> 真主党势力
|
<span className="h-1.5 w-1.5 rounded-sm bg-lime-400/40" /> 真主党势力
|
||||||
</span>
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-sm bg-[#E066FF]/80" /> 库尔德武装
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Map
|
<Map
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
@@ -749,7 +908,13 @@ export function WarMap() {
|
|||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
fitToTheater()
|
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 拉远变小,拉近变大 */}
|
{/* 矢量标记:zoom 拉远变小,拉近变大 */}
|
||||||
@@ -1015,6 +1180,163 @@ export function WarMap() {
|
|||||||
/>
|
/>
|
||||||
</Source>
|
</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}>
|
<Source id="allied-strike-lines-lincoln" type="geojson" data={lincolnLinesGeoJson}>
|
||||||
<Layer
|
<Layer
|
||||||
@@ -1145,17 +1467,13 @@ export function WarMap() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
{/* 美军打击目标点位 (蓝色) */}
|
{/* 盟军打击目标点位 (蓝色):含林肯/福特/以色列→伊朗 + 以色列→黎巴嫩,统一名称与脉冲动效 */}
|
||||||
<Source
|
<Source
|
||||||
id="allied-strike-targets"
|
id="allied-strike-targets"
|
||||||
type="geojson"
|
type="geojson"
|
||||||
data={{
|
data={{
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: (situation.iranForces?.keyLocations ?? []).map((s) => ({
|
features: alliedStrikeTargetsFeatures,
|
||||||
type: 'Feature' as const,
|
|
||||||
properties: { name: s.name },
|
|
||||||
geometry: { type: 'Point' as const, coordinates: [s.lng, s.lat] },
|
|
||||||
})),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Layer
|
<Layer
|
||||||
@@ -1178,8 +1496,9 @@ export function WarMap() {
|
|||||||
'text-offset': [0, 0.8],
|
'text-offset': [0, 0.8],
|
||||||
}}
|
}}
|
||||||
paint={{
|
paint={{
|
||||||
'text-color': '#60A5FA',
|
'text-color': '#FFFFFF',
|
||||||
'text-halo-width': 0,
|
'text-halo-color': '#1a1a1a',
|
||||||
|
'text-halo-width': 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Layer
|
<Layer
|
||||||
@@ -1355,6 +1674,30 @@ export function WarMap() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</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}>
|
<Source id="countries" type="geojson" data={COUNTRIES_GEOJSON}>
|
||||||
{/* 伊朗区域填充 - 红色系 */}
|
{/* 伊朗区域填充 - 红色系 */}
|
||||||
@@ -1501,6 +1844,35 @@ export function WarMap() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Source>
|
</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>
|
</Map>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,8 +91,20 @@ export const EXTENDED_WAR_ZONES = {
|
|||||||
// 真主党区域标注点(用于显示文字)
|
// 真主党区域标注点(用于显示文字)
|
||||||
hezbollahLabelCenter: [35.7, 33.7] as [number, number],
|
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],
|
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)
|
// 3. 真主党当前攻击目标 (North Israel Targets)
|
||||||
activeAttacks: [
|
activeAttacks: [
|
||||||
@@ -117,6 +129,139 @@ export const EXTENDED_WAR_ZONES = {
|
|||||||
],
|
],
|
||||||
} as const
|
} 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<GeoJSON.LineString> = {
|
||||||
|
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 = {
|
export const STRIKE_DAMAGE_ASSESSMENT = {
|
||||||
lebanonFront: [
|
lebanonFront: [
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ export interface MilitarySituation {
|
|||||||
targets: { lng: number; lat: number; name?: string; struck_at?: string | null }[]
|
targets: { lng: number; lat: number; name?: string; struck_at?: string | null }[]
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
/** 动画配置:攻击脉冲衰减窗口(天),可在编辑面板调整 */
|
||||||
|
animationConfig?: {
|
||||||
|
strikeCutoffDays: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INITIAL_MOCK_DATA: MilitarySituation = {
|
export const INITIAL_MOCK_DATA: MilitarySituation = {
|
||||||
@@ -329,6 +333,11 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
|
|||||||
{ lng: 50.876409, lat: 34.625448, name: '伊朗专家会议秘书处' },
|
{ lng: 50.876409, lat: 34.625448, name: '伊朗专家会议秘书处' },
|
||||||
{ lng: 51.916, lat: 33.666, name: '纳坦兹' },
|
{ lng: 51.916, lat: 33.666, name: '纳坦兹' },
|
||||||
{ lng: 51.002, lat: 35.808, 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: '赫尔梅勒无人机阵地' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
109
src/hooks/useWarMapData.ts
Normal file
109
src/hooks/useWarMapData.ts
Normal file
@@ -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<WarMapData, keyof WarMapConfig> {
|
||||||
|
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<WarMapConfig>(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])
|
||||||
|
}
|
||||||
@@ -9,11 +9,13 @@ import {
|
|||||||
deleteSituationUpdate,
|
deleteSituationUpdate,
|
||||||
putForceSummary,
|
putForceSummary,
|
||||||
putDisplayStats,
|
putDisplayStats,
|
||||||
|
putAnimationConfig,
|
||||||
type EditRawData,
|
type EditRawData,
|
||||||
type CombatLossesRow,
|
type CombatLossesRow,
|
||||||
type KeyLocationRow,
|
type KeyLocationRow,
|
||||||
type ForceSummaryRow,
|
type ForceSummaryRow,
|
||||||
type DisplayStatsRow,
|
type DisplayStatsRow,
|
||||||
|
type AnimationConfigRow,
|
||||||
} from '@/api/edit'
|
} from '@/api/edit'
|
||||||
import { fetchAndSetSituation } from '@/store/situationStore'
|
import { fetchAndSetSituation } from '@/store/situationStore'
|
||||||
import { useStatsStore } from '@/store/statsStore'
|
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 () => {
|
const handleClearDisplayStatsOverrides = async () => {
|
||||||
if (!confirm('确定清除所有覆盖?将恢复为实时统计(在看=近2分钟访问数,看过=累计访问等)。')) return
|
if (!confirm('确定清除所有覆盖?将恢复为实时统计(在看=近2分钟访问数,看过=累计访问等)。')) return
|
||||||
setSaving('displayStats')
|
setSaving('displayStats')
|
||||||
@@ -245,6 +259,57 @@ export function EditDashboard() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<main className="max-w-4xl space-y-2 p-4">
|
<main className="max-w-4xl space-y-2 p-4">
|
||||||
|
{/* 动画衰减参数 */}
|
||||||
|
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('animation')}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-cyan-400">动画 / 衰减参数</span>
|
||||||
|
{openSections.has('animation') ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
{openSections.has('animation') && data && (
|
||||||
|
<div className="border-t border-military-border p-4 space-y-3 text-sm">
|
||||||
|
<p className="text-military-text-secondary text-xs">
|
||||||
|
衰减系数用于控制「打击脉冲」持续天数。超出该天数后,仅保留减弱的呼吸效果与标注。
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
className="flex flex-wrap items-center gap-3"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
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)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-military-text-secondary">
|
||||||
|
<span>衰减窗口(天)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="strikeCutoffDays"
|
||||||
|
min={1}
|
||||||
|
max={30}
|
||||||
|
defaultValue={data.animationConfig?.strikeCutoffDays ?? 5}
|
||||||
|
className="w-20 rounded border border-military-border bg-black/40 px-2 py-1 text-right text-xs text-military-text-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving === 'animationConfig'}
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-military-border px-3 py-1.5 text-xs text-military-text-secondary hover:bg-military-border/30 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className="h-3 w-3" />
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* 看过、在看、分享、点赞、留言 */}
|
{/* 看过、在看、分享、点赞、留言 */}
|
||||||
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
|
<section className="rounded border border-military-border bg-military-panel/80 overflow-hidden">
|
||||||
<button
|
<button
|
||||||
|
|||||||
106
src/utils/defenseLine.ts
Normal file
106
src/utils/defenseLine.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* 战场防御线:反弓曲线路径 + 锯齿齿形
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LngLat = [number, number]
|
||||||
|
|
||||||
|
/** 二次贝塞尔 B(t) = (1-t)²P0 + 2(1-t)t P1 + t² P2 */
|
||||||
|
function quadraticBezier(P0: LngLat, P1: LngLat, P2: LngLat, t: number): LngLat {
|
||||||
|
const u = 1 - t
|
||||||
|
return [
|
||||||
|
u * u * P0[0] + 2 * u * t * P1[0] + t * t * P2[0],
|
||||||
|
u * u * P0[1] + 2 * u * t * P1[1] + t * t * P2[1],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成反弓曲线路径:通过 path 各点,弧面向进攻方向(西侧)凸出
|
||||||
|
* @param path 关键点 [[lng, lat], ...](如大不里士→萨南达季→克尔曼沙赫)
|
||||||
|
* @param bulgeWest 向西凸出量(经度,正值表示弧顶在西侧/面向进攻方)
|
||||||
|
* @param samplesPerSegment 每段贝塞尔采样点数
|
||||||
|
*/
|
||||||
|
export function createCurvedDefensePath(
|
||||||
|
path: LngLat[],
|
||||||
|
bulgeWest: number = 0.35,
|
||||||
|
samplesPerSegment: number = 24
|
||||||
|
): LngLat[] {
|
||||||
|
if (path.length < 2) return path
|
||||||
|
const out: LngLat[] = []
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
const a = path[i]
|
||||||
|
const b = path[i + 1]
|
||||||
|
const midLng = (a[0] + b[0]) / 2
|
||||||
|
const midLat = (a[1] + b[1]) / 2
|
||||||
|
const control: LngLat = [midLng - bulgeWest, midLat]
|
||||||
|
const startK = i === 0 ? 0 : 1
|
||||||
|
for (let k = startK; k <= samplesPerSegment; k++) {
|
||||||
|
out.push(quadraticBezier(a, control, b, k / samplesPerSegment))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 沿路径线性插值增加点(直线段)
|
||||||
|
*/
|
||||||
|
export function interpolatePath(path: LngLat[], stepsPerSegment: number = 4): LngLat[] {
|
||||||
|
if (path.length < 2) return path
|
||||||
|
const out: LngLat[] = []
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
const a = path[i]
|
||||||
|
const b = path[i + 1]
|
||||||
|
const startK = i === 0 ? 0 : 1
|
||||||
|
for (let k = startK; k <= stepsPerSegment; k++) {
|
||||||
|
const t = k / stepsPerSegment
|
||||||
|
out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锯齿防御线:沿 path 每 toothWidth(比例)生成齿,齿高 toothHeight,朝向法向(敌方侧)
|
||||||
|
* @param path 路径点 [[lng, lat], ...](可先经 interpolatePath 插值)
|
||||||
|
* @param toothWidth 齿宽(与段长同单位,经纬度)
|
||||||
|
* @param toothHeight 齿高(法向伸出)
|
||||||
|
*/
|
||||||
|
export function createDefenseLine(
|
||||||
|
path: number[][],
|
||||||
|
toothWidth: number,
|
||||||
|
toothHeight: number
|
||||||
|
): number[][] {
|
||||||
|
const coords: number[][] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
const start = path[i] as [number, number]
|
||||||
|
const end = path[i + 1] as [number, number]
|
||||||
|
|
||||||
|
const dx = end[0] - start[0]
|
||||||
|
const dy = end[1] - start[1]
|
||||||
|
const L = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
if (L < 1e-9) continue
|
||||||
|
|
||||||
|
const ux = dx / L
|
||||||
|
const uy = dy / L
|
||||||
|
const nx = -uy
|
||||||
|
const ny = ux
|
||||||
|
|
||||||
|
const numTeeth = Math.max(1, Math.floor(L / toothWidth))
|
||||||
|
const actualWidth = L / numTeeth
|
||||||
|
|
||||||
|
for (let j = 0; j < numTeeth; j++) {
|
||||||
|
const t1 = j * actualWidth
|
||||||
|
const t2 = t1 + actualWidth * 0.5
|
||||||
|
const t3 = (j + 1) * actualWidth
|
||||||
|
|
||||||
|
coords.push([start[0] + ux * t1, start[1] + uy * t1])
|
||||||
|
coords.push([
|
||||||
|
start[0] + ux * t2 + nx * toothHeight,
|
||||||
|
start[1] + uy * t2 + ny * toothHeight,
|
||||||
|
])
|
||||||
|
coords.push([start[0] + ux * t3, start[1] + uy * t3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coords.push(path[path.length - 1] as [number, number])
|
||||||
|
return coords
|
||||||
|
}
|
||||||
282
src/utils/tacticalPincerArrow.ts
Normal file
282
src/utils/tacticalPincerArrow.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* 军事钳形箭头多边形生成
|
||||||
|
* 支持两种形态:
|
||||||
|
* 1) 底宽头小 + 曲线轮廓 + 生长动画(单头)
|
||||||
|
* 2) 集团军级三叉戟(Pro Tactical Trident):22 点逻辑模型,基座→柄部收腰→三支独立箭指(左/右副箭 + 主箭),腋下回撤无自交
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LngLat = [number, number]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 集团军级三叉戟(18 点一笔画):基座 4 → 右颈 → 右副 3 → 右缝槽 → 主箭 3 → 左缝槽 → 左副 3 → 左颈 → 闭合
|
||||||
|
// 生长时 t>progress 则 offset 归零,前沿齐平防自交
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 18 点三叉戟:槽位在指缝 (0.4L),左正右负;t>progress 时收束到 progress 中心线
|
||||||
|
*/
|
||||||
|
function createProTacticalTrident(
|
||||||
|
start: LngLat,
|
||||||
|
end: LngLat,
|
||||||
|
progress: number
|
||||||
|
): LngLat[] {
|
||||||
|
const [x1, y1] = start
|
||||||
|
const dx = end[0] - x1
|
||||||
|
const dy = end[1] - y1
|
||||||
|
const L = Math.hypot(dx, dy) || 1e-6
|
||||||
|
const nx = -dy / L
|
||||||
|
const ny = dx / L
|
||||||
|
|
||||||
|
const pt = (tAlong: number, nOffset: number): LngLat => {
|
||||||
|
if (tAlong > progress) {
|
||||||
|
return [x1 + dx * progress, y1 + dy * progress]
|
||||||
|
}
|
||||||
|
const lateral = nOffset * L
|
||||||
|
return [x1 + dx * tAlong + nx * lateral, y1 + dy * tAlong + ny * lateral]
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROOVE = 0.07
|
||||||
|
|
||||||
|
return [
|
||||||
|
pt(0, 0.15),
|
||||||
|
pt(0.05, 0.16),
|
||||||
|
pt(0.05, -0.16),
|
||||||
|
pt(0, -0.15),
|
||||||
|
pt(0.35, -0.05),
|
||||||
|
pt(0.45, -0.12),
|
||||||
|
pt(0.9, -0.18),
|
||||||
|
pt(0.55, -0.06),
|
||||||
|
pt(0.4, -GROOVE),
|
||||||
|
pt(0.65, -0.04),
|
||||||
|
pt(1, 0),
|
||||||
|
pt(0.65, 0.04),
|
||||||
|
pt(0.4, GROOVE),
|
||||||
|
pt(0.55, 0.06),
|
||||||
|
pt(0.9, 0.18),
|
||||||
|
pt(0.45, 0.12),
|
||||||
|
pt(0.35, 0.05),
|
||||||
|
pt(0, 0.15),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createArmyGroupPincer(
|
||||||
|
start: LngLat,
|
||||||
|
end: LngLat,
|
||||||
|
_prongCount: number = 3
|
||||||
|
): LngLat[] {
|
||||||
|
return createProTacticalTrident(start, end, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生长动画:progress 0→1,t>progress 时收束到中心线
|
||||||
|
*/
|
||||||
|
export function createArmyGroupPincerAtProgress(
|
||||||
|
start: LngLat,
|
||||||
|
end: LngLat,
|
||||||
|
progress: number,
|
||||||
|
_prongCount: number = 3
|
||||||
|
): LngLat[] {
|
||||||
|
if (progress <= 0.02) {
|
||||||
|
const [x1, y1] = start
|
||||||
|
const dx = end[0] - x1
|
||||||
|
const dy = end[1] - y1
|
||||||
|
const L = Math.hypot(dx, dy) || 1e-6
|
||||||
|
const nx = -dy / L
|
||||||
|
const ny = dx / L
|
||||||
|
const w = L * 0.15 * 0.5
|
||||||
|
const left: LngLat = [x1 + nx * w, y1 + ny * w]
|
||||||
|
const right: LngLat = [x1 - nx * w, y1 - ny * w]
|
||||||
|
return [left, right, [x1, y1], left]
|
||||||
|
}
|
||||||
|
return createProTacticalTrident(start, end, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 单头钳形(底宽头小、曲线、生长)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TacticalPincerOptions {
|
||||||
|
/** 箭尾宽度系数(底宽),建议 0.04–0.1,默认 0.06 */
|
||||||
|
widthFactor?: number
|
||||||
|
/** 头部相对底部的宽度比(<1 即头小),默认 0.45 */
|
||||||
|
headWidthRatio?: number
|
||||||
|
/** 箭头头部占进攻方向向量的比例,默认 0.22 */
|
||||||
|
headLengthRatio?: number
|
||||||
|
/** 是否在箭体两侧用贝塞尔曲线平滑(底→肩),默认 true */
|
||||||
|
curved?: boolean
|
||||||
|
/** 曲线插值点数(每侧),默认 6 */
|
||||||
|
curveSteps?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTS: Required<Omit<TacticalPincerOptions, 'curved'>> & { curved: boolean } = {
|
||||||
|
widthFactor: 0.055,
|
||||||
|
headWidthRatio: 0.48,
|
||||||
|
headLengthRatio: 0.24,
|
||||||
|
curved: true,
|
||||||
|
curveSteps: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 二次贝塞尔 B(t) = (1-t)²P0 + 2(1-t)t P1 + t² P2 */
|
||||||
|
function quadraticBezier(P0: LngLat, P1: LngLat, P2: LngLat, t: number): LngLat {
|
||||||
|
const u = 1 - t
|
||||||
|
return [
|
||||||
|
u * u * P0[0] + 2 * u * t * P1[0] + t * t * P2[0],
|
||||||
|
u * u * P0[1] + 2 * u * t * P1[1] + t * t * P2[1],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在箭体两侧(左尾→左肩、右肩→右尾)插入贝塞尔曲线点,使轮廓柔和 */
|
||||||
|
function smoothRingWithCurves(
|
||||||
|
ring: LngLat[],
|
||||||
|
n: number,
|
||||||
|
leftTail: LngLat,
|
||||||
|
leftShoulder: LngLat,
|
||||||
|
rightTail: LngLat,
|
||||||
|
rightShoulder: LngLat,
|
||||||
|
nx: number,
|
||||||
|
ny: number
|
||||||
|
): LngLat[] {
|
||||||
|
const curveBulge = 0.2
|
||||||
|
// 左缘:leftTail -> leftShoulder,贝塞尔控制点向外凸出,箭体呈弧线
|
||||||
|
const leftMid: LngLat = [
|
||||||
|
(leftTail[0] + leftShoulder[0]) / 2 + nx * curveBulge,
|
||||||
|
(leftTail[1] + leftShoulder[1]) / 2 + ny * curveBulge,
|
||||||
|
]
|
||||||
|
const leftCurve: LngLat[] = []
|
||||||
|
for (let i = 1; i <= n; i++) {
|
||||||
|
leftCurve.push(quadraticBezier(leftTail, leftMid, leftShoulder, i / (n + 1)))
|
||||||
|
}
|
||||||
|
// 右缘:rightShoulder -> rightTail,控制点向外凸出
|
||||||
|
const rightMid: LngLat = [
|
||||||
|
(rightShoulder[0] + rightTail[0]) / 2 - nx * curveBulge,
|
||||||
|
(rightShoulder[1] + rightTail[1]) / 2 - ny * curveBulge,
|
||||||
|
]
|
||||||
|
const rightCurve: LngLat[] = []
|
||||||
|
for (let i = 1; i <= n; i++) {
|
||||||
|
rightCurve.push(quadraticBezier(rightShoulder, rightMid, rightTail, i / (n + 1)))
|
||||||
|
}
|
||||||
|
// Ring: 0 leftTail, 1 rightTail, 2 shoulderR, 3 barbR, 4 tip, 5 barbL, 6 shoulderL, 7 leftTail
|
||||||
|
// 顺序:左尾 → 左曲线 → 左肩 → 左倒钩 → 顶点 → 右倒钩 → 右肩 → 右曲线 → 右尾 → 闭合
|
||||||
|
const out: LngLat[] = [
|
||||||
|
leftTail,
|
||||||
|
...leftCurve,
|
||||||
|
leftShoulder,
|
||||||
|
ring[5], // barbLeft
|
||||||
|
ring[4], // tip
|
||||||
|
ring[3], // barbRight
|
||||||
|
rightShoulder,
|
||||||
|
...rightCurve,
|
||||||
|
rightTail,
|
||||||
|
leftTail,
|
||||||
|
]
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeKeyPoints(
|
||||||
|
start: LngLat,
|
||||||
|
end: LngLat,
|
||||||
|
progress: number,
|
||||||
|
opts: Required<TacticalPincerOptions> & { curved: boolean }
|
||||||
|
): { ring: LngLat[]; nx: number; ny: number } {
|
||||||
|
const [x1, y1] = start
|
||||||
|
const dx = end[0] - x1
|
||||||
|
const dy = end[1] - y1
|
||||||
|
const tipX = x1 + progress * dx
|
||||||
|
const tipY = y1 + progress * dy
|
||||||
|
const headLen = opts.headLengthRatio
|
||||||
|
const BbackX = tipX - headLen * dx
|
||||||
|
const BbackY = tipY - headLen * dy
|
||||||
|
|
||||||
|
const nx = -dy * opts.widthFactor
|
||||||
|
const ny = dx * opts.widthFactor
|
||||||
|
const headScale = opts.headWidthRatio
|
||||||
|
|
||||||
|
const leftTail: LngLat = [x1 + nx, y1 + ny]
|
||||||
|
const rightTail: LngLat = [x1 - nx, y1 - ny]
|
||||||
|
const shoulderRight: LngLat = [BbackX - nx * headScale * 0.5, BbackY - ny * headScale * 0.5]
|
||||||
|
const barbRight: LngLat = [BbackX - nx * headScale * 1.2, BbackY - ny * headScale * 1.2]
|
||||||
|
const tip: LngLat = [tipX, tipY]
|
||||||
|
const barbLeft: LngLat = [BbackX + nx * headScale * 1.2, BbackY + ny * headScale * 1.2]
|
||||||
|
const shoulderLeft: LngLat = [BbackX + nx * headScale * 0.5, BbackY + ny * headScale * 0.5]
|
||||||
|
|
||||||
|
const ring: LngLat[] = [
|
||||||
|
leftTail,
|
||||||
|
rightTail,
|
||||||
|
shoulderRight,
|
||||||
|
barbRight,
|
||||||
|
tip,
|
||||||
|
barbLeft,
|
||||||
|
shoulderLeft,
|
||||||
|
leftTail,
|
||||||
|
]
|
||||||
|
return { ring, nx, ny }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成军事钳形箭头(底宽头小、可选曲线)
|
||||||
|
*/
|
||||||
|
export function createTacticalPincer(
|
||||||
|
start: LngLat,
|
||||||
|
end: LngLat,
|
||||||
|
widthFactor: number = DEFAULT_OPTS.widthFactor
|
||||||
|
): LngLat[] {
|
||||||
|
return createTacticalPincerExplicit(start, end, { widthFactor })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTacticalPincerExplicit(
|
||||||
|
start: LngLat,
|
||||||
|
end: LngLat,
|
||||||
|
options: TacticalPincerOptions = {}
|
||||||
|
): LngLat[] {
|
||||||
|
const opts = { ...DEFAULT_OPTS, ...options }
|
||||||
|
const { ring, nx, ny } = computeKeyPoints(start, end, 1, opts as any)
|
||||||
|
if (!opts.curved) return ring
|
||||||
|
const steps = opts.curveSteps ?? 6
|
||||||
|
return smoothRingWithCurves(
|
||||||
|
ring,
|
||||||
|
steps,
|
||||||
|
ring[0],
|
||||||
|
ring[6],
|
||||||
|
ring[1],
|
||||||
|
ring[2],
|
||||||
|
nx,
|
||||||
|
ny
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生长动画:progress 0→1 表示箭头从起点延伸到终点
|
||||||
|
* progress < headLengthRatio 时画三角 stub,否则画完整箭头
|
||||||
|
*/
|
||||||
|
export function createTacticalPincerAtProgress(
|
||||||
|
start: LngLat,
|
||||||
|
end: LngLat,
|
||||||
|
progress: number,
|
||||||
|
options: TacticalPincerOptions = {}
|
||||||
|
): LngLat[] {
|
||||||
|
const opts = { ...DEFAULT_OPTS, ...options }
|
||||||
|
const [x1, y1] = start
|
||||||
|
const dx = end[0] - x1
|
||||||
|
const dy = end[1] - y1
|
||||||
|
const tipX = x1 + progress * dx
|
||||||
|
const tipY = y1 + progress * dy
|
||||||
|
const nx = -dy * opts.widthFactor
|
||||||
|
const ny = dx * opts.widthFactor
|
||||||
|
|
||||||
|
if (progress <= 0.02) {
|
||||||
|
const leftTail: LngLat = [x1 + nx * 0.5, y1 + ny * 0.5]
|
||||||
|
const rightTail: LngLat = [x1 - nx * 0.5, y1 - ny * 0.5]
|
||||||
|
return [leftTail, rightTail, [x1, y1], leftTail]
|
||||||
|
}
|
||||||
|
if (progress < opts.headLengthRatio) {
|
||||||
|
const leftTail: LngLat = [x1 + nx, y1 + ny]
|
||||||
|
const rightTail: LngLat = [x1 - nx, y1 - ny]
|
||||||
|
const tip: LngLat = [tipX, tipY]
|
||||||
|
return [leftTail, rightTail, tip, leftTail]
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ring, nx: nxx, ny: nyy } = computeKeyPoints(start, end, progress, opts as any)
|
||||||
|
if (!opts.curved) return ring
|
||||||
|
const steps = opts.curveSteps ?? 6
|
||||||
|
return smoothRingWithCurves(ring, steps, ring[0], ring[6], ring[1], ring[2], nxx, nyy)
|
||||||
|
}
|
||||||
1
start.sh
1
start.sh
@@ -30,6 +30,7 @@ if [ ! -f "$DB_FILE_PATH" ]; then
|
|||||||
npm run api:seed
|
npm run api:seed
|
||||||
else
|
else
|
||||||
echo " - Existing DB detected at $DB_FILE_PATH, skip seeding"
|
echo " - Existing DB detected at $DB_FILE_PATH, skip seeding"
|
||||||
|
echo " (若刚更新了 seed 想刷新地图/战损等,请先执行: npm run api:seed)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Starting API (http://localhost:3001)..."
|
echo "==> Starting API (http://localhost:3001)..."
|
||||||
|
|||||||
Reference in New Issue
Block a user