feat: add new file
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user