fix: hotfix 동기화 — history/detail candidate_count 안전 처리 #225
@ -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"
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user