feat: add new file

This commit is contained in:
Daniel
2026-03-01 19:23:48 +08:00
parent d705fd6c83
commit c07fc681dd
24 changed files with 2711 additions and 166 deletions

View File

@@ -1,51 +1,213 @@
import { useRef, useEffect } from 'react'
import Map, { Marker } from 'react-map-gl'
import { useMemo, useEffect, useRef } from 'react'
import Map, { Source, Layer } from 'react-map-gl'
import type { MapRef } from 'react-map-gl'
import type { Map as MapboxMap } from 'mapbox-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import { useSituationStore } from '@/store/situationStore'
import { ATTACKED_TARGETS } from '@/data/mapLocations'
const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN || ''
// Persian Gulf center
const DEFAULT_VIEW = {
longitude: 54,
latitude: 27,
zoom: 5.5,
const DEFAULT_VIEW = { longitude: 52.5, latitude: 26.5, zoom: 5.2 }
const COUNTRIES_GEOJSON =
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson'
const IRAN_ADMIN = 'Iran'
const ALLIES_ADMIN = [
'Qatar',
'Bahrain',
'Kuwait',
'United Arab Emirates',
'Saudi Arabia',
'Iraq',
'Syria',
'Jordan',
'Turkey',
'Israel',
'Oman',
'Egypt',
'Djibouti',
]
// 伊朗攻击源 德黑兰 [lng, lat]
const TEHRAN_SOURCE: [number, number] = [51.389, 35.6892]
function parabolaPath(
start: [number, number],
end: [number, number],
height = 2
): [number, number][] {
const midLng = (start[0] + end[0]) / 2
const midLat = (start[1] + end[1]) / 2 + height
return [start, [midLng, midLat], end]
}
type BaseStatus = 'operational' | 'damaged' | 'attacked'
interface KeyLoc {
name: string
lat: number
lng: number
type?: string
status?: BaseStatus
damage_level?: number
}
function toFeature(loc: KeyLoc, side: 'us' | 'iran', status?: BaseStatus) {
return {
type: 'Feature' as const,
properties: {
side,
name: loc.name,
status: status ?? (loc as KeyLoc & { status?: BaseStatus }).status ?? 'operational',
},
geometry: {
type: 'Point' as const,
coordinates: [loc.lng, loc.lat] as [number, number],
},
}
}
export function WarMap() {
const mapRef = useRef<MapRef>(null)
const animRef = useRef<number>(0)
const startRef = useRef<number>(0)
const { situation } = useSituationStore()
const { usForces, iranForces } = situation
const usMarkers = usForces.keyLocations
const iranMarkers = iranForces.keyLocations
const usLocs = (usForces.keyLocations || []) as KeyLoc[]
const irLocs = (iranForces.keyLocations || []) as KeyLoc[]
const { usNaval, usBaseOp, usBaseDamaged, usBaseAttacked, labelsGeoJson } = useMemo(() => {
const naval: GeoJSON.Feature<GeoJSON.Point>[] = []
const op: GeoJSON.Feature<GeoJSON.Point>[] = []
const damaged: GeoJSON.Feature<GeoJSON.Point>[] = []
const attacked: GeoJSON.Feature<GeoJSON.Point>[] = []
const labels: GeoJSON.Feature<GeoJSON.Point>[] = []
for (const loc of usLocs as KeyLoc[]) {
const f = toFeature(loc, 'us')
labels.push({ ...f, properties: { ...f.properties, name: loc.name } })
if (loc.type === 'Base') {
const s = (loc.status ?? 'operational') as BaseStatus
if (s === 'attacked') attacked.push(f)
else if (s === 'damaged') damaged.push(f)
else op.push(f)
} else {
naval.push(f)
}
}
for (const loc of irLocs) {
const f = toFeature(loc, 'iran')
labels.push({ ...f, properties: { ...f.properties, name: loc.name } })
}
return {
usNaval: { type: 'FeatureCollection' as const, features: naval },
usBaseOp: { type: 'FeatureCollection' as const, features: op },
usBaseDamaged: { type: 'FeatureCollection' as const, features: damaged },
usBaseAttacked: { type: 'FeatureCollection' as const, features: attacked },
labelsGeoJson: { type: 'FeatureCollection' as const, features: labels },
}
}, [usForces.keyLocations, iranForces.keyLocations])
// 德黑兰到 27 个被袭目标的攻击曲线
const attackLinesGeoJson = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: ATTACKED_TARGETS.map((target) => ({
type: 'Feature' as const,
properties: {},
geometry: {
type: 'LineString' as const,
coordinates: parabolaPath(TEHRAN_SOURCE, target as [number, number]),
},
})),
}),
[]
)
const hideNonBelligerentLabels = (map: MapboxMap) => {
const labelLayers = [
'country-label',
'state-label',
'place-label',
'place-label-capital',
'place-label-city',
'place-label-town',
'place-label-village',
'poi-label',
]
for (const id of labelLayers) {
try {
if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none')
} catch (_) {}
}
}
useEffect(() => {
const map = mapRef.current?.getMap()
if (!map) return
startRef.current = performance.now()
const tick = (t: number) => {
const elapsed = t - startRef.current
try {
if (map.getLayer('attack-lines')) {
const offset = (elapsed / 16) * 0.8
map.setPaintProperty('attack-lines', 'line-dasharray', [2, 2])
map.setPaintProperty('attack-lines', 'line-dash-offset', -offset)
}
// damaged: 橙色闪烁 opacity 0.5 ~ 1, 约 1s 周期
if (map.getLayer('points-damaged')) {
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.003)
map.setPaintProperty('points-damaged', 'circle-opacity', blink)
}
// attacked: 红色脉冲 2s 循环, 扩散半径 0→40px, opacity 1→0 (map.md)
if (map.getLayer('points-attacked-pulse')) {
const cycle = 2000
const phase = (elapsed % cycle) / cycle
const r = 40 * phase
const opacity = 1 - phase
map.setPaintProperty('points-attacked-pulse', 'circle-radius', r)
map.setPaintProperty('points-attacked-pulse', 'circle-opacity', opacity)
}
} catch (_) {}
animRef.current = requestAnimationFrame(tick)
}
const start = () => {
hideNonBelligerentLabels(map)
if (map.getLayer('attack-lines')) {
animRef.current = requestAnimationFrame(tick)
} else {
animRef.current = requestAnimationFrame(start)
}
}
if (map.isStyleLoaded()) start()
else map.once('load', start)
return () => cancelAnimationFrame(animRef.current)
}, [])
// Fallback when no Mapbox token - show placeholder
if (!MAPBOX_TOKEN) {
return (
<div className="flex h-full w-full items-center justify-center bg-military-dark">
<div className="rounded-lg border border-military-border bg-military-panel p-8 text-center">
<p className="font-orbitron text-sm text-military-text-primary">
Mapbox
</p>
<div className="rounded-lg border border-military-border bg-military-panel/95 p-8 text-center shadow-lg">
<p className="font-orbitron text-sm font-medium text-military-text-primary"> Mapbox </p>
<p className="mt-2 text-xs text-military-text-secondary">
.env VITE_MAPBOX_ACCESS_TOKEN
.env.example .env
</p>
<div className="mt-4 flex justify-center gap-8">
<div>
<p className="text-[10px] text-military-us"></p>
{usMarkers.map((loc) => (
<p key={loc.name} className="text-xs text-military-text-primary">{loc.name}</p>
))}
</div>
<div>
<p className="text-[10px] text-military-iran"></p>
{iranMarkers.map((loc) => (
<p key={loc.name} className="text-xs text-military-text-primary">{loc.name}</p>
))}
</div>
</div>
<a
href="https://account.mapbox.com/access-tokens/"
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-block text-[10px] text-military-accent hover:underline"
>
Mapbox
</a>
</div>
</div>
)
@@ -59,40 +221,160 @@ export function WarMap() {
mapStyle="mapbox://styles/mapbox/dark-v11"
mapboxAccessToken={MAPBOX_TOKEN}
attributionControl={false}
dragRotate={false}
touchRotate={false}
style={{ width: '100%', height: '100%' }}
>
{usMarkers.map((loc) => (
<Marker
key={`us-${loc.name}`}
longitude={loc.lng}
latitude={loc.lat}
anchor="bottom"
color="#3B82F6"
>
<div className="flex flex-col items-center">
<div className="h-4 w-4 rounded-full border-2 border-white bg-military-us shadow-lg" />
<span className="mt-1 rounded bg-military-panel px-1.5 py-0.5 font-orbitron text-[10px] text-military-us">
{loc.name}
</span>
</div>
</Marker>
))}
{iranMarkers.map((loc) => (
<Marker
key={`ir-${loc.name}`}
longitude={loc.lng}
latitude={loc.lat}
anchor="bottom"
color="#EF4444"
>
<div className="flex flex-col items-center">
<div className="h-4 w-4 rounded-full border-2 border-white bg-military-iran shadow-lg" />
<span className="mt-1 rounded bg-military-panel px-1.5 py-0.5 font-orbitron text-[10px] text-military-iran">
{loc.name}
</span>
</div>
</Marker>
))}
{/* 美国海军 - 蓝色 */}
<Source id="points-us-naval" type="geojson" data={usNaval}>
<Layer
id="points-us-naval"
type="circle"
paint={{
'circle-radius': 6,
'circle-color': '#3B82F6',
'circle-stroke-width': 2,
'circle-stroke-color': '#fff',
}}
/>
</Source>
{/* 美军基地-正常 - 绿色 */}
<Source id="points-us-base-op" type="geojson" data={usBaseOp}>
<Layer
id="points-us-base-op"
type="circle"
paint={{
'circle-radius': 6,
'circle-color': '#22C55E',
'circle-stroke-width': 2,
'circle-stroke-color': '#fff',
}}
/>
</Source>
{/* 美军基地-损毁 - 橙色闪烁 */}
<Source id="points-us-base-damaged" type="geojson" data={usBaseDamaged}>
<Layer
id="points-damaged"
type="circle"
paint={{
'circle-radius': 6,
'circle-color': '#F97316',
'circle-stroke-width': 2,
'circle-stroke-color': '#fff',
'circle-opacity': 1,
}}
/>
</Source>
{/* 美军基地-遭袭 - 红点 + 脉冲 */}
<Source id="points-us-base-attacked" type="geojson" data={usBaseAttacked}>
<Layer
id="points-attacked-dot"
type="circle"
paint={{
'circle-radius': 6,
'circle-color': '#EF4444',
'circle-stroke-width': 2,
'circle-stroke-color': '#fff',
}}
/>
<Layer
id="points-attacked-pulse"
type="circle"
paint={{
'circle-radius': 6,
'circle-color': '#EF4444',
'circle-opacity': 0,
}}
/>
</Source>
{/* 伊朗 - 红色 */}
<Source
id="points-iran"
type="geojson"
data={{
type: 'FeatureCollection',
features: irLocs.map((loc) => toFeature(loc, 'iran')),
}}
>
<Layer
id="points-iran"
type="circle"
paint={{
'circle-radius': 6,
'circle-color': '#EF4444',
'circle-stroke-width': 2,
'circle-stroke-color': '#fff',
}}
/>
</Source>
<Source id="attack-lines" type="geojson" data={attackLinesGeoJson}>
<Layer
id="attack-lines"
type="line"
paint={{
'line-color': '#ff0000',
'line-width': 2,
'line-dasharray': [2, 2],
}}
/>
</Source>
{/* 中文标注 */}
<Source id="labels" type="geojson" data={labelsGeoJson}>
<Layer
id="labels"
type="symbol"
layout={{
'text-field': ['get', 'name'],
'text-size': 11,
'text-anchor': 'top',
'text-offset': [0, 0.8],
}}
paint={{
'text-color': '#E5E7EB',
'text-halo-color': 'rgba(0,0,0,0.8)',
'text-halo-width': 2,
}}
/>
</Source>
<Source id="countries" type="geojson" data={COUNTRIES_GEOJSON}>
{/* 以色列 mesh 高亮 */}
<Layer
id="israel-fill"
type="fill"
filter={['==', ['get', 'ADMIN'], 'Israel']}
paint={{
'fill-color': 'rgba(96, 165, 250, 0.25)',
'fill-outline-color': '#60A5FA',
}}
/>
<Layer
id="iran-outline"
type="line"
filter={['==', ['get', 'ADMIN'], IRAN_ADMIN]}
paint={{
'line-color': '#EF4444',
'line-width': 2,
'line-opacity': 0.9,
}}
/>
<Layer
id="allies-outline"
type="line"
filter={['in', ['get', 'ADMIN'], ['literal', ALLIES_ADMIN]]}
paint={{
'line-color': '#3B82F6',
'line-width': 1.5,
'line-opacity': 0.8,
}}
/>
</Source>
</Map>
</div>
)