release: 2026-03-18.4 (5건 커밋) #56

병합
htlee develop 에서 main 로 5 commits 를 머지했습니다 2026-03-18 09:55:51 +09:00
8개의 변경된 파일242개의 추가작업 그리고 63개의 파일을 삭제

파일 보기

@ -4,6 +4,16 @@
## [Unreleased]
## [2026-03-18.4]
### 추가
- 한국 선박 현황 헤더 ON/OFF 토글 → 지도 강조 링+라벨 표시 (기본 ON)
- 우측 패널 한국 선박 목록: hover 시 지도 강조 링, 클릭 시 선박 모달 호출
### 변경
- 지진파 그래프: LineChart → ScatterChart (진도별 색상/크기, 이벤트 점 표시)
- 기압 그래프: 버킷 평균 → 관측소별 개별 라인 (데이터 없는 구간 0 제거)
## [2026-03-18.3]
### 추가

파일 보기

@ -747,6 +747,27 @@
border-bottom: 1px solid var(--kcg-hover);
}
.korean-highlight-toggle {
margin-left: auto;
padding: 1px 8px;
font-size: 9px;
font-weight: 700;
font-family: 'Courier New', monospace;
border-radius: 3px;
border: 1px solid var(--kcg-border);
background: transparent;
color: var(--kcg-muted);
cursor: pointer;
transition: all 0.15s;
line-height: 1.6;
}
.korean-highlight-toggle.active {
border-color: #00e5ff;
background: rgba(0, 229, 255, 0.15);
color: #00e5ff;
}
.area-ship-icon {
font-size: 14px;
}
@ -920,6 +941,16 @@
.iran-mil-item:hover {
background: rgba(255,255,255,0.03);
}
.iran-mil-item-interactive {
cursor: pointer;
transition: background 0.15s;
border-radius: 3px;
}
.iran-mil-item-interactive:hover {
background: rgba(0, 229, 255, 0.1);
}
.iran-mil-flag { font-size: 11px; flex-shrink: 0; }
.iran-mil-name {
flex: 1;

파일 보기

@ -58,7 +58,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
aircraft: true,
satellites: true,
ships: true,
koreanShips: false,
koreanShips: true,
airports: true,
sensorCharts: true,
oilFacilities: true,
@ -120,6 +120,8 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
const [hoveredShipMmsi, setHoveredShipMmsi] = useState<string | null>(null);
const [focusShipMmsi, setFocusShipMmsi] = useState<string | null>(null);
const replay = useReplay();
const monitor = useMonitor();
@ -366,6 +368,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
layers={layers}
flyToTarget={flyToTarget}
onFlyToDone={() => setFlyToTarget(null)}
hoveredShipMmsi={hoveredShipMmsi}
focusShipMmsi={focusShipMmsi}
onFocusShipClear={() => setFocusShipMmsi(null)}
/>
) : mapMode === 'globe' ? (
<GlobeMap
@ -384,6 +389,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
satellites={iranData.satPositions}
ships={iranData.visibleShips}
layers={layers}
hoveredShipMmsi={hoveredShipMmsi}
focusShipMmsi={focusShipMmsi}
onFocusShipClear={() => setFocusShipMmsi(null)}
/>
)}
<div className="map-overlay-left">
@ -422,6 +430,10 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
dashboardTab={dashboardTab}
onTabChange={setDashboardTab}
ships={iranData.ships}
highlightKoreanShips={layers.koreanShips}
onToggleHighlightKorean={() => setLayers(prev => ({ ...prev, koreanShips: !prev.koreanShips }))}
onShipHover={setHoveredShipMmsi}
onShipClick={setFocusShipMmsi}
/>
</aside>
</main>

파일 보기

@ -17,6 +17,10 @@ interface Props {
dashboardTab?: DashboardTab;
onTabChange?: (tab: DashboardTab) => void;
ships?: Ship[];
highlightKoreanShips?: boolean;
onToggleHighlightKorean?: () => void;
onShipHover?: (mmsi: string | null) => void;
onShipClick?: (mmsi: string) => void;
}
// ═══ 속보 / 트럼프 발언 + 유가·에너지 뉴스 ═══
@ -354,7 +358,7 @@ function useTimeAgo() {
};
}
export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS }: Props) {
export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS, highlightKoreanShips = false, onToggleHighlightKorean, onShipHover, onShipClick }: Props) {
const { t } = useTranslation(['common', 'events', 'ships']);
const timeAgo = useTimeAgo();
@ -443,7 +447,13 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
const mtColor = MT_CATEGORY_COLORS[cat] || '#888';
const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') });
return (
<div key={s.mmsi} className="iran-mil-item">
<div
key={s.mmsi}
className="iran-mil-item iran-mil-item-interactive"
onMouseEnter={() => onShipHover?.(s.mmsi)}
onMouseLeave={() => onShipHover?.(null)}
onClick={() => onShipClick?.(s.mmsi)}
>
<span className="iran-mil-flag">{'\u{1F1F0}\u{1F1F7}'}</span>
<span className="iran-mil-name">{s.name}</span>
<span className="iran-mil-cat" style={{ color: mtColor, background: `${mtColor}22` }}>
@ -588,6 +598,16 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
<span className="area-ship-icon">{'\u{1F1F0}\u{1F1F7}'}</span>
<span className="area-ship-title">{t('ships:shipStatus.koreanTitle')}</span>
<span className="area-ship-total">{koreanShips.length}{t('common:units.vessels')}</span>
{onToggleHighlightKorean && dashboardTab === 'iran' && (
<button
type="button"
className={`korean-highlight-toggle ${highlightKoreanShips ? 'active' : ''}`}
onClick={onToggleHighlightKorean}
title={highlightKoreanShips ? '지도 강조 끄기' : '지도에서 강조 표시'}
>
{highlightKoreanShips ? 'ON' : 'OFF'}
</button>
)}
</div>
{koreanShips.length > 0 && (() => {
const groups: Record<string, Ship[]> = {};

파일 보기

@ -8,6 +8,10 @@ import {
CartesianGrid,
Tooltip,
ResponsiveContainer,
ScatterChart,
Scatter,
ZAxis,
Cell,
} from 'recharts';
import type { SeismicDto, PressureDto } from '../../services/sensorApi';
@ -36,43 +40,39 @@ function buildTicks(currentTime: number, historyMinutes: number): number[] {
return ticks;
}
function aggregateSeismic(
/**
* 데이터: 버킷이
*/
function prepareSeismicPoints(
data: SeismicDto[],
rangeStart: number,
rangeEnd: number,
): { time: number; value: number }[] {
const bucketSize = (rangeEnd - rangeStart) / BUCKET_COUNT;
const buckets = Array.from({ length: BUCKET_COUNT }, (_, i) => ({
time: rangeStart + (i + 0.5) * bucketSize,
value: 0,
}));
for (const ev of data) {
if (ev.timestamp < rangeStart || ev.timestamp > rangeEnd) continue;
const idx = Math.min(Math.floor((ev.timestamp - rangeStart) / bucketSize), BUCKET_COUNT - 1);
buckets[idx].value = Math.max(buckets[idx].value, ev.magnitude * 10);
}
return buckets;
): { time: number; magnitude: number; place: string }[] {
return data
.filter(ev => ev.timestamp >= rangeStart && ev.timestamp <= rangeEnd)
.map(ev => ({ time: ev.timestamp, magnitude: ev.magnitude, place: ev.place }));
}
function aggregatePressure(
/**
* 데이터: 관측소별 ( )
* connectNulls ,
*/
function preparePressureByStation(
data: PressureDto[],
rangeStart: number,
rangeEnd: number,
): { time: number; value: number }[] {
const bucketSize = (rangeEnd - rangeStart) / BUCKET_COUNT;
const buckets = Array.from({ length: BUCKET_COUNT }, (_, i) => ({
time: rangeStart + (i + 0.5) * bucketSize,
values: [] as number[],
}));
): Record<string, { time: number; value: number }[]> {
const byStation: Record<string, { time: number; value: number }[]> = {};
for (const r of data) {
if (r.timestamp < rangeStart || r.timestamp > rangeEnd) continue;
const idx = Math.min(Math.floor((r.timestamp - rangeStart) / bucketSize), BUCKET_COUNT - 1);
buckets[idx].values.push(r.pressureHpa);
if (!byStation[r.station]) byStation[r.station] = [];
byStation[r.station].push({ time: r.timestamp, value: r.pressureHpa });
}
return buckets.map(b => ({
time: b.time,
value: b.values.length > 0 ? b.values.reduce((a, c) => a + c, 0) / b.values.length : 0,
}));
// 시간순 정렬
for (const arr of Object.values(byStation)) {
arr.sort((a, b) => a.time - b.time);
}
return byStation;
}
function generateDemoData(
@ -90,6 +90,23 @@ function generateDemoData(
});
}
const STATION_COLORS: Record<string, string> = {
'tehran': '#ef4444',
'isfahan': '#f97316',
'bandar-abbas': '#3b82f6',
'shiraz': '#22c55e',
'tabriz': '#a855f7',
};
const MAGNITUDE_COLORS = ['#fbbf24', '#f97316', '#ef4444', '#dc2626', '#991b1b'];
function getMagnitudeColor(mag: number): string {
if (mag < 2.5) return MAGNITUDE_COLORS[0];
if (mag < 3.5) return MAGNITUDE_COLORS[1];
if (mag < 4.5) return MAGNITUDE_COLORS[2];
if (mag < 5.5) return MAGNITUDE_COLORS[3];
return MAGNITUDE_COLORS[4];
}
export function SensorChart({ seismicData, pressureData, currentTime, historyMinutes }: Props) {
const { t } = useTranslation();
@ -99,14 +116,16 @@ export function SensorChart({ seismicData, pressureData, currentTime, historyMin
const ticks = useMemo(() => buildTicks(currentTime, historyMinutes), [currentTime, historyMinutes]);
const seismicChart = useMemo(
() => aggregateSeismic(seismicData, rangeStart, rangeEnd),
const seismicPoints = useMemo(
() => prepareSeismicPoints(seismicData, rangeStart, rangeEnd),
[seismicData, rangeStart, rangeEnd],
);
const pressureChart = useMemo(
() => aggregatePressure(pressureData, rangeStart, rangeEnd),
const pressureByStation = useMemo(
() => preparePressureByStation(pressureData, rangeStart, rangeEnd),
[pressureData, rangeStart, rangeEnd],
);
const noiseChart = useMemo(
() => generateDemoData(rangeStart, rangeEnd, 45, 30),
[rangeStart, rangeEnd],
@ -117,7 +136,6 @@ export function SensorChart({ seismicData, pressureData, currentTime, historyMin
);
const commonXAxis = {
dataKey: 'time' as const,
type: 'number' as const,
domain: [rangeStart, rangeEnd] as [number, number],
ticks,
@ -125,40 +143,75 @@ export function SensorChart({ seismicData, pressureData, currentTime, historyMin
tick: { fontSize: 9, fill: '#888' },
};
// 지진 y축: 데이터가 있으면 0~max+1, 없으면 0~6
const maxMag = seismicPoints.length > 0
? Math.max(...seismicPoints.map(p => p.magnitude))
: 0;
const seismicYMax = Math.max(maxMag + 1, 6);
return (
<div className="sensor-chart">
<h3>{t('sensor.title')}</h3>
<div className="chart-grid">
{/* 지진파: Scatter (이벤트 점) */}
<div className="chart-item">
<h4>{t('sensor.seismicActivity')}</h4>
<h4>
{t('sensor.seismicActivity')}
{seismicPoints.length > 0 && (
<span className="text-[9px] text-kcg-muted ml-1">
({seismicPoints.length} events)
</span>
)}
</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={seismicChart}>
<ScatterChart>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis {...commonXAxis} />
<YAxis domain={[0, 100]} tick={{ fontSize: 10, fill: '#888' }} />
<XAxis dataKey="time" {...commonXAxis} />
<YAxis dataKey="magnitude" domain={[0, seismicYMax]} tick={{ fontSize: 10, fill: '#888' }} name="M" />
<ZAxis dataKey="magnitude" range={[30, 200]} name="Size" />
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }}
contentStyle={{ background: '#1a1a2e', border: '1px solid #333', fontSize: 10 }}
labelFormatter={formatTickTime}
formatter={(v: number) => [v.toFixed(1), 'Magnitude×10']}
formatter={(v: number, name: string) => {
if (name === 'M') return [`M${v.toFixed(1)}`, 'Magnitude'];
return [v, name];
}}
/>
<Line type="monotone" dataKey="value" stroke="#ef4444" dot={false} strokeWidth={1.5} />
</LineChart>
<Scatter data={seismicPoints} shape="circle">
{seismicPoints.map((p, i) => (
<Cell key={i} fill={getMagnitudeColor(p.magnitude)} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</div>
{/* 기압: 관측소별 개별 라인 */}
<div className="chart-item">
<h4>{t('sensor.airPressureHpa')}</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={pressureChart}>
<LineChart>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis {...commonXAxis} />
<YAxis domain={[990, 1030]} tick={{ fontSize: 10, fill: '#888' }} />
<XAxis dataKey="time" {...commonXAxis} />
<YAxis domain={['auto', 'auto']} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }}
contentStyle={{ background: '#1a1a2e', border: '1px solid #333', fontSize: 10 }}
labelFormatter={formatTickTime}
formatter={(v: number) => [v.toFixed(1), 'hPa']}
formatter={(v: number, name: string) => [`${v.toFixed(1)} hPa`, name]}
/>
<Line type="monotone" dataKey="value" stroke="#3b82f6" dot={false} strokeWidth={1.5} />
{Object.entries(pressureByStation).map(([station, points]) => (
<Line
key={station}
data={points}
dataKey="value"
name={station}
stroke={STATION_COLORS[station] || '#888'}
dot={false}
strokeWidth={1.2}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
@ -171,7 +224,7 @@ export function SensorChart({ seismicData, pressureData, currentTime, historyMin
<ResponsiveContainer width="100%" height={80}>
<LineChart data={noiseChart}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis {...commonXAxis} />
<XAxis dataKey="time" {...commonXAxis} />
<YAxis domain={[0, 140]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }}
@ -191,7 +244,7 @@ export function SensorChart({ seismicData, pressureData, currentTime, historyMin
<ResponsiveContainer width="100%" height={80}>
<LineChart data={radiationChart}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis {...commonXAxis} />
<XAxis dataKey="time" {...commonXAxis} />
<YAxis domain={[0, 0.3]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip
contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }}

파일 보기

@ -31,6 +31,9 @@ interface Props {
onFlyToDone?: () => void;
initialCenter?: { lng: number; lat: number };
initialZoom?: number;
hoveredShipMmsi?: string | null;
focusShipMmsi?: string | null;
onFocusShipClear?: () => void;
}
// MarineTraffic-style: dark ocean + satellite land + nautical overlay
@ -105,7 +108,7 @@ const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
osint: 8,
};
export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom }: Props) {
export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom, hoveredShipMmsi, focusShipMmsi, onFocusShipClear }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
@ -423,7 +426,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
{layers.aircraft && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
{layers.satellites && <SatelliteLayer satellites={satellites} />}
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} />}
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} hoveredMmsi={hoveredShipMmsi} focusMmsi={focusShipMmsi} onFocusClear={onFocusShipClear} />}
{layers.ships && <DamagedShipLayer currentTime={currentTime} />}
{layers.airports && <AirportLayer airports={middleEastAirports} />}
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}

파일 보기

@ -22,6 +22,9 @@ interface Props {
satellites: SatellitePosition[];
ships: Ship[];
layers: LayerVisibility;
hoveredShipMmsi?: string | null;
focusShipMmsi?: string | null;
onFocusShipClear?: () => void;
}
// ESRI World Imagery + ESRI boundaries overlay
@ -86,7 +89,7 @@ const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
osint: 8,
};
export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) {
export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers, hoveredShipMmsi, focusShipMmsi, onFocusShipClear }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
@ -248,7 +251,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
{/* Overlay layers */}
{layers.aircraft && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
{layers.satellites && <SatelliteLayer satellites={satellites} />}
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} />}
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} hoveredMmsi={hoveredShipMmsi} focusMmsi={focusShipMmsi} onFocusClear={onFocusShipClear} />}
<DamagedShipLayer currentTime={currentTime} />
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
{layers.airports && <AirportLayer airports={middleEastAirports} />}

파일 보기

@ -8,6 +8,9 @@ interface Props {
ships: Ship[];
militaryOnly: boolean;
koreanOnly?: boolean;
hoveredMmsi?: string | null;
focusMmsi?: string | null;
onFocusClear?: () => void;
}
// ── MarineTraffic-style vessel type colors (CSS variable references) ──
@ -351,17 +354,25 @@ function ensureTriangleImage(map: maplibregl.Map) {
}
// ── Main layer (WebGL symbol rendering — triangles) ──
export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) {
export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear }: Props) {
const { current: map } = useMap();
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [imageReady, setImageReady] = useState(false);
const highlightKorean = !!koreanOnly;
// focusMmsi로 외부에서 모달 열기
useEffect(() => {
if (focusMmsi) {
setSelectedMmsi(focusMmsi);
onFocusClear?.();
}
}, [focusMmsi, onFocusClear]);
const filtered = useMemo(() => {
let result = ships;
if (koreanOnly) result = result.filter(s => s.flag === 'KR');
if (militaryOnly) result = result.filter(s => isMilitary(s.category));
return result;
}, [ships, militaryOnly, koreanOnly]);
}, [ships, militaryOnly]);
// Add triangle image to map
useEffect(() => {
@ -382,11 +393,13 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) {
type: 'Feature' as const,
properties: {
mmsi: ship.mmsi,
name: ship.name,
color: getShipHex(ship),
size: SIZE_MAP[ship.category],
isMil: isMilitary(ship.category) ? 1 : 0,
isKorean: ship.flag === 'KR' ? 1 : 0,
isCheonghae: ship.mmsi === '440001981' ? 1 : 0,
isHovered: ship.mmsi === hoveredMmsi ? 1 : 0,
heading: ship.heading,
},
geometry: {
@ -395,7 +408,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) {
},
}));
return { type: 'FeatureCollection' as const, features };
}, [filtered]);
}, [filtered, hoveredMmsi]);
// Register click and cursor handlers
useEffect(() => {
@ -435,19 +448,53 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) {
return (
<>
<Source id="ships-source" type="geojson" data={shipGeoJson}>
{/* Korean ship outer ring (circle behind triangle) */}
{/* Hovered ship highlight ring */}
<Layer
id="ships-hover-ring"
type="circle"
filter={['==', ['get', 'isHovered'], 1]}
paint={{
'circle-radius': 18,
'circle-color': 'rgba(255, 255, 255, 0.1)',
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2,
'circle-stroke-opacity': 0.9,
}}
/>
{/* Korean ship outer ring — enlarged when highlighted */}
<Layer
id="ships-korean-ring"
type="circle"
filter={['==', ['get', 'isKorean'], 1]}
paint={{
'circle-radius': ['*', ['get', 'size'], 14],
'circle-color': 'transparent',
'circle-radius': highlightKorean ? ['*', ['get', 'size'], 22] : ['*', ['get', 'size'], 14],
'circle-color': highlightKorean ? 'rgba(0, 229, 255, 0.08)' : 'transparent',
'circle-stroke-color': '#00e5ff',
'circle-stroke-width': 1.5,
'circle-stroke-opacity': 0.6,
'circle-stroke-width': highlightKorean ? 2.5 : 1.5,
'circle-stroke-opacity': highlightKorean ? 1 : 0.6,
}}
/>
{/* Korean ship label (only when highlighted) */}
{highlightKorean && (
<Layer
id="ships-korean-label"
type="symbol"
filter={['==', ['get', 'isKorean'], 1]}
layout={{
'text-field': ['get', 'name'],
'text-size': 9,
'text-offset': [0, 2.2],
'text-anchor': 'top',
'text-allow-overlap': false,
'text-font': ['Open Sans Regular'],
}}
paint={{
'text-color': '#00e5ff',
'text-halo-color': 'rgba(0,0,0,0.8)',
'text-halo-width': 1,
}}
/>
)}
{/* Main ship triangles */}
<Layer
id="ships-triangles"