wing-ops/frontend/src/tabs/incidents/components/IncidentsView.tsx
jeonghyo.k 2640d882da feat(incidents): 이미지 분석 연동 강화 및 사고 팝업 리뉴얼
- 사고별 이미지 분석 API 및 항공 미디어 조회 연동
- 사고 마커 팝업 디자인 개선, 필터링된 사고만 지도 표시
- 이미지 분석 시 사고명 파라미터 지원, 기본 예측시간 6시간으로 변경
- 유출량 정밀도 NUMERIC(14,10) 확대 (migration 031)
- OpenDrift 유종 매핑 수정 (원유, 등유)
2026-04-13 16:41:56 +09:00

2295 lines
81 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useMemo, useRef } from 'react';
import { Map as MapLibre, Popup, useControl, useMap } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { ScatterplotLayer, IconLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers';
import { PathStyleExtension } from '@deck.gl/extensions';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel';
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel';
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData';
import { fetchIncidents } from '../services/incidentsApi';
import type { IncidentCompat } from '../services/incidentsApi';
import { fetchAnalysisTrajectory } from '@tabs/prediction/services/predictionApi';
import type {
TrajectoryResponse,
SensitiveResourceFeatureCollection,
} from '@tabs/prediction/services/predictionApi';
import { DischargeZonePanel } from './DischargeZonePanel';
import {
estimateDistanceFromCoast,
determineZone,
getDischargeZoneLines,
loadTerritorialBaseline,
getCachedBaseline,
loadZoneGeoJSON,
getCachedZones,
} from '../utils/dischargeZoneData';
import { useMapStore } from '@common/store/mapStore';
import { useMeasureTool } from '@common/hooks/useMeasureTool';
import { buildMeasureLayers } from '@common/components/map/measureLayers';
import { MeasureOverlay } from '@common/components/map/MeasureOverlay';
// ── 민감자원 카테고리별 색상 (목록 순서 인덱스 기반 — 중복 없음) ────────────
const CATEGORY_PALETTE: [number, number, number][] = [
[239, 68, 68], // red
[249, 115, 22], // orange
[234, 179, 8], // yellow
[132, 204, 22], // lime
[20, 184, 166], // teal
[6, 182, 212], // cyan
[59, 130, 246], // blue
[99, 102, 241], // indigo
[168, 85, 247], // purple
[236, 72, 153], // pink
[244, 63, 94], // rose
[16, 185, 129], // emerald
[14, 165, 233], // sky
[139, 92, 246], // violet
[217, 119, 6], // amber
[45, 212, 191], // turquoise
];
function getCategoryColor(index: number): [number, number, number] {
return CATEGORY_PALETTE[index % CATEGORY_PALETTE.length];
}
// ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
return null;
}
// ── FlyToController: 사고 선택 시 지도 이동 ──────────
function FlyToController({ incident }: { incident: IncidentCompat | null }) {
const { current: map } = useMap();
const prevIdRef = useRef<string | null>(null);
useEffect(() => {
if (!map || !incident) return;
if (prevIdRef.current === incident.id) return;
prevIdRef.current = incident.id;
map.flyTo({
center: [incident.location.lon, incident.location.lat],
zoom: 10,
duration: 800,
});
}, [map, incident]);
return null;
}
// ── 사고 상태 색상 ──────────────────────────────────────
function getMarkerColor(s: string): [number, number, number, number] {
if (s === 'active') return [239, 68, 68, 204];
if (s === 'investigating') return [245, 158, 11, 204];
return [107, 114, 128, 204];
}
function getMarkerStroke(s: string): [number, number, number, number] {
if (s === 'active') return [220, 38, 38, 255];
if (s === 'investigating') return [217, 119, 6, 255];
return [75, 85, 99, 255];
}
const getStatusLabel = (s: string) =>
s === 'active' ? '대응중' : s === 'investigating' ? '조사중' : s === 'closed' ? '종료' : '';
// ── 선박 아이콘 SVG (삼각형) ────────────────────────────
// deck.gl IconLayer용 atlas 없이 SVGOverlay 방식 대신
// ScatterplotLayer로 단순화하여 선박을 원으로 표현 (heading 정보 별도 레이어)
// → 원 마커 + 방향 지시선을 deck.gl ScatterplotLayer로 표현
// 팝업 정보
interface VesselPopupInfo {
longitude: number;
latitude: number;
vessel: Vessel;
}
interface IncidentPopupInfo {
longitude: number;
latitude: number;
incident: IncidentCompat;
}
// 호버 툴팁 정보
interface HoverInfo {
x: number;
y: number;
object: Vessel | IncidentCompat;
type: 'vessel' | 'incident';
}
/* ════════════════════════════════════════════════════
IncidentsView
════════════════════════════════════════════════════ */
export function IncidentsView() {
const [incidents, setIncidents] = useState<IncidentCompat[]>([]);
const [filteredIncidents, setFilteredIncidents] = useState<IncidentCompat[]>([]);
const [selectedIncidentId, setSelectedIncidentId] = useState<string | null>(null);
const [selectedVessel, setSelectedVessel] = useState<Vessel | null>(null);
const [detailVessel, setDetailVessel] = useState<Vessel | null>(null);
const [vesselPopup, setVesselPopup] = useState<VesselPopupInfo | null>(null);
const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null);
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null);
const [dischargeMode, setDischargeMode] = useState(false);
const [dischargeInfo, setDischargeInfo] = useState<{
lat: number;
lon: number;
distanceNm: number;
zoneIndex: number;
} | null>(null);
const [baselineLoaded, setBaselineLoaded] = useState(
() => getCachedBaseline() !== null && getCachedZones() !== null,
);
// Map style & toggles
const currentMapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
// Measure tool
const { handleMeasureClick, measureMode } = useMeasureTool();
const measureInProgress = useMapStore((s) => s.measureInProgress);
const measurements = useMapStore((s) => s.measurements);
// Analysis view mode
const [viewMode, setViewMode] = useState<ViewMode>('overlay');
const [analysisActive, setAnalysisActive] = useState(false);
const [analysisTags, setAnalysisTags] = useState<
{ icon: string; label: string; color: string }[]
>([]);
// 예측 trajectory & 민감자원 지도 표출
const [trajectoryEntries, setTrajectoryEntries] = useState<
Record<string, { data: TrajectoryResponse; occurredAt: string }>
>({});
const [sensitiveGeojson, setSensitiveGeojson] =
useState<SensitiveResourceFeatureCollection | null>(null);
const [sensCheckedCategories, setSensCheckedCategories] = useState<Set<string>>(new Set());
const [sensColorMap, setSensColorMap] = useState<Map<string, [number, number, number]>>(
new Map(),
);
useEffect(() => {
fetchIncidents().then((data) => {
setIncidents(data);
});
Promise.all([loadTerritorialBaseline(), loadZoneGeoJSON()]).then(() => setBaselineLoaded(true));
}, []);
// 사고 전환 시 지도 레이어 즉시 초기화
useEffect(() => {
setTrajectoryEntries({});
setSensitiveGeojson(null);
setSensCheckedCategories(new Set());
setSensColorMap(new Map());
}, [selectedIncidentId]);
const selectedIncident = incidents.find((i) => i.id === selectedIncidentId) ?? null;
const handleRunAnalysis = (sections: AnalysisSection[], sensitiveCount: number) => {
if (sections.length === 0) return;
const tags: { icon: string; label: string; color: string }[] = [];
sections.forEach((s) => {
if (s.key === 'oil') tags.push({ icon: '🛢', label: '유출유', color: '#f97316' });
if (s.key === 'hns') tags.push({ icon: '🧪', label: 'HNS', color: '#a855f7' });
if (s.key === 'rsc') tags.push({ icon: '🚨', label: '구난', color: '#06b6d4' });
});
if (sensitiveCount > 0)
tags.push({ icon: '🐟', label: `민감자원 ${sensitiveCount}`, color: '#22c55e' });
setAnalysisTags(tags);
setAnalysisActive(true);
};
const handleCloseAnalysis = () => {
setAnalysisActive(false);
setAnalysisTags([]);
};
const handleCheckedPredsChange = async (
checked: Array<{ id: string; acdntSn: number; predRunSn: number | null; occurredAt: string }>,
) => {
const newEntries: Record<string, { data: TrajectoryResponse; occurredAt: string }> = {};
await Promise.all(
checked.map(async ({ id, acdntSn, predRunSn, occurredAt }) => {
const existing = trajectoryEntries[id];
if (existing) {
newEntries[id] = existing;
return;
}
try {
const data = await fetchAnalysisTrajectory(acdntSn, predRunSn ?? undefined);
newEntries[id] = { data, occurredAt };
} catch {
/* 조용히 실패 */
}
}),
);
setTrajectoryEntries(newEntries);
};
const handleSensitiveDataChange = (
geojson: SensitiveResourceFeatureCollection | null,
checkedCategories: Set<string>,
categoryOrder: string[],
) => {
setSensitiveGeojson(geojson);
setSensCheckedCategories(checkedCategories);
const colorMap = new Map<string, [number, number, number]>();
categoryOrder.forEach((cat, i) => colorMap.set(cat, getCategoryColor(i)));
setSensColorMap(colorMap);
};
// ── 사고 마커 (ScatterplotLayer) ──────────────────────
const incidentLayer = useMemo(
() =>
new ScatterplotLayer({
id: 'incidents',
data: filteredIncidents,
getPosition: (d: IncidentCompat) => [d.location.lon, d.location.lat],
getRadius: (d: IncidentCompat) => (selectedIncidentId === d.id ? 16 : 12),
getFillColor: (d: IncidentCompat) => getMarkerColor(d.status),
getLineColor: (d: IncidentCompat) =>
selectedIncidentId === d.id ? [6, 182, 212, 255] : getMarkerStroke(d.status),
getLineWidth: (d: IncidentCompat) => (selectedIncidentId === d.id ? 3 : 2),
stroked: true,
radiusMinPixels: 6,
radiusMaxPixels: 20,
radiusUnits: 'pixels',
pickable: true,
onClick: (info: { object?: IncidentCompat; coordinate?: number[] }) => {
if (info.object && info.coordinate) {
const newId = selectedIncidentId === info.object.id ? null : info.object.id;
setSelectedIncidentId(newId);
if (newId) {
setIncidentPopup({
longitude: info.coordinate[0],
latitude: info.coordinate[1],
incident: info.object,
});
} else {
setIncidentPopup(null);
}
setVesselPopup(null);
}
},
onHover: (info: { object?: IncidentCompat; x?: number; y?: number }) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setHoverInfo({ x: info.x, y: info.y, object: info.object, type: 'incident' });
} else {
setHoverInfo((h) => (h?.type === 'incident' ? null : h));
}
},
updateTriggers: {
getRadius: [selectedIncidentId],
getLineColor: [selectedIncidentId],
getLineWidth: [selectedIncidentId],
},
}),
[filteredIncidents, selectedIncidentId],
);
// ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ──────
// 글로우 효과 + 드롭쉐도우가 있는 개선된 SVG 삼각형
const vesselIconLayer = useMemo(() => {
const makeTriangleSvg = (color: string, isAccident: boolean) => {
const opacity = isAccident ? '1' : '0.85';
const glowOpacity = isAccident ? '0.9' : '0.75';
const svgStr = [
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="20" viewBox="0 0 16 20">',
'<defs><filter id="g" x="-50%" y="-50%" width="200%" height="200%">',
'<feGaussianBlur stdDeviation="1.2"/></filter></defs>',
`<polygon points="8,0 15,20 1,20" fill="${color}" opacity="${glowOpacity}" filter="url(#g)"/>`,
`<polygon points="8,1 14,19 2,19" fill="${color}" opacity="${opacity}" stroke="${color}" stroke-width="0.5"/>`,
'</svg>',
].join('');
return `data:image/svg+xml;base64,${btoa(svgStr)}`;
};
return new IconLayer({
id: 'vessel-icons',
data: mockVessels,
getPosition: (d: Vessel) => [d.lng, d.lat],
getIcon: (d: Vessel) => ({
url: makeTriangleSvg(d.color, d.status.includes('사고')),
width: 16,
height: 20,
anchorX: 8,
anchorY: 10,
}),
getSize: 16,
getAngle: (d: Vessel) => -d.heading,
sizeUnits: 'pixels',
sizeScale: 1,
pickable: true,
onClick: (info: { object?: Vessel; coordinate?: number[] }) => {
if (info.object && info.coordinate) {
setSelectedVessel(info.object);
setVesselPopup({
longitude: info.coordinate[0],
latitude: info.coordinate[1],
vessel: info.object,
});
setIncidentPopup(null);
setDetailVessel(null);
}
},
onHover: (info: { object?: Vessel; x?: number; y?: number }) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setHoverInfo({ x: info.x, y: info.y, object: info.object, type: 'vessel' });
} else {
setHoverInfo((h) => (h?.type === 'vessel' ? null : h));
}
},
});
}, []);
// ── 배출 구역 경계선 레이어 ──
const dischargeZoneLayers = useMemo(() => {
if (!dischargeMode || !baselineLoaded) return [];
const zoneLines = getDischargeZoneLines();
return zoneLines.map(
(line, i) =>
new PathLayer({
id: `discharge-zone-${i}`,
data: [line],
getPath: (d: typeof line) => d.path,
getColor: (d: typeof line) => d.color,
getWidth: 2,
widthUnits: 'pixels',
getDashArray: [6, 3],
dashJustified: true,
extensions: [new PathStyleExtension({ dash: true })],
pickable: false,
}),
);
}, [dischargeMode, baselineLoaded]);
const measureDeckLayers = useMemo(
() => buildMeasureLayers(measureInProgress, measureMode, measurements),
[measureInProgress, measureMode, measurements],
);
// ── 예측 결과 레이어 (입자 클라우드, 중심점 경로, 시간 라벨, 해안 부착 입자) ──────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const trajectoryLayers: any[] = useMemo(() => {
const layers: unknown[] = [];
// 모델별 색상 (prediction 탭과 동일)
const MODEL_COLORS: Record<string, [number, number, number]> = {
KOSPS: [6, 182, 212], // cyan
POSEIDON: [239, 68, 68], // red
OpenDrift: [59, 130, 246], // blue
default: [249, 115, 22], // orange
};
const pad = (n: number) => String(n).padStart(2, '0');
let runIdx = 0;
for (const [runId, entry] of Object.entries(trajectoryEntries)) {
const { data: traj, occurredAt } = entry;
const { trajectory, centerPoints } = traj;
const startDt = new Date(occurredAt);
runIdx++;
if (trajectory && trajectory.length > 0) {
const maxTime = Math.max(...trajectory.map((p) => p.time));
// 최종 스텝 부유 입자: 모델별로 그룹핑하여 각각 다른 색
const lastStepByModel: Record<string, typeof trajectory> = {};
trajectory.forEach((p) => {
if (p.time === maxTime && p.stranded !== 1) {
const m = p.model ?? 'default';
if (!lastStepByModel[m]) lastStepByModel[m] = [];
lastStepByModel[m].push(p);
}
});
Object.entries(lastStepByModel).forEach(([model, particles]) => {
const color = MODEL_COLORS[model] ?? MODEL_COLORS['default'];
layers.push(
new ScatterplotLayer({
id: `traj-particles-${runId}-${model}`,
data: particles,
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
getFillColor: [...color, 180] as [number, number, number, number],
getRadius: 3,
radiusMinPixels: 2,
radiusMaxPixels: 5,
}),
);
});
// 해안 부착 입자: 모델별 색상 + 테두리 강조
const beachedByModel: Record<string, typeof trajectory> = {};
trajectory.forEach((p) => {
if (p.stranded === 1) {
const m = p.model ?? 'default';
if (!beachedByModel[m]) beachedByModel[m] = [];
beachedByModel[m].push(p);
}
});
Object.entries(beachedByModel).forEach(([model, particles]) => {
const color = MODEL_COLORS[model] ?? MODEL_COLORS['default'];
layers.push(
new ScatterplotLayer({
id: `traj-beached-${runId}-${model}`,
data: particles,
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
getFillColor: [...color, 220] as [number, number, number, number],
getRadius: 4,
radiusMinPixels: 3,
radiusMaxPixels: 6,
stroked: true,
getLineColor: [255, 255, 255, 160] as [number, number, number, number],
getLineWidth: 1,
lineWidthMinPixels: 1,
}),
);
});
}
// 중심점 경로선 (모델별 그룹)
if (centerPoints && centerPoints.length >= 2) {
const byModel: Record<string, typeof centerPoints> = {};
centerPoints.forEach((cp) => {
const m = cp.model ?? 'default';
if (!byModel[m]) byModel[m] = [];
byModel[m].push(cp);
});
Object.entries(byModel).forEach(([model, pts]) => {
const color = MODEL_COLORS[model] ?? MODEL_COLORS['default'];
const sorted = [...pts].sort((a, b) => a.time - b.time);
const pathId = `${runIdx}-${model}`;
layers.push(
new PathLayer({
id: `traj-path-${pathId}`,
data: [{ path: sorted.map((p) => [p.lon, p.lat]) }],
getPath: (d: { path: number[][] }) => d.path,
getColor: [...color, 230] as [number, number, number, number],
getWidth: 2,
widthMinPixels: 2,
widthMaxPixels: 4,
}),
);
layers.push(
new ScatterplotLayer({
id: `traj-centers-${pathId}`,
data: sorted,
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
getFillColor: [...color, 230] as [number, number, number, number],
getRadius: 5,
radiusMinPixels: 4,
radiusMaxPixels: 8,
}),
);
layers.push(
new TextLayer({
id: `traj-labels-${pathId}`,
data: sorted,
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
getText: (d: { time: number }) => {
const dt = new Date(startDt.getTime() + d.time * 3600 * 1000);
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
},
getSize: 11,
getColor: [...color, 240] as [number, number, number, number],
getPixelOffset: [0, -14] as [number, number],
outlineWidth: 2,
outlineColor: [0, 0, 0, 180] as [number, number, number, number],
fontSettings: { sdf: true },
billboard: true,
}),
);
});
}
}
return layers;
}, [trajectoryEntries]);
// ── 민감자원 GeoJSON 레이어 ──────────────────────────
const sensLayer = useMemo(() => {
if (!sensitiveGeojson || sensCheckedCategories.size === 0) return null;
const filtered = {
...sensitiveGeojson,
features: sensitiveGeojson.features.filter((f) =>
sensCheckedCategories.has(
((f.properties as Record<string, unknown>)?.['category'] as string) ?? '',
),
),
};
if (filtered.features.length === 0) return null;
return new GeoJsonLayer({
id: 'incidents-sensitive-geojson',
data: filtered,
pickable: false,
stroked: true,
filled: true,
pointRadiusMinPixels: 8,
lineWidthMinPixels: 1,
getFillColor: (f: { properties: Record<string, unknown> }) => {
const color = sensColorMap.get((f.properties['category'] as string) ?? '') ?? [
128, 128, 128,
];
return [...color, 60] as [number, number, number, number];
},
getLineColor: (f: { properties: Record<string, unknown> }) => {
const color = sensColorMap.get((f.properties['category'] as string) ?? '') ?? [
128, 128, 128,
];
return [...color, 180] as [number, number, number, number];
},
getLineWidth: 1,
updateTriggers: {
getFillColor: [sensColorMap],
getLineColor: [sensColorMap],
},
});
}, [sensitiveGeojson, sensCheckedCategories, sensColorMap]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deckLayers: any[] = useMemo(
() => [
incidentLayer,
vesselIconLayer,
...dischargeZoneLayers,
...measureDeckLayers,
...trajectoryLayers,
...(sensLayer ? [sensLayer] : []),
],
[
incidentLayer,
vesselIconLayer,
dischargeZoneLayers,
measureDeckLayers,
trajectoryLayers,
sensLayer,
],
);
return (
<div className="flex flex-1 overflow-hidden">
{/* Left Panel */}
<IncidentsLeftPanel
incidents={incidents}
selectedIncidentId={selectedIncidentId}
onIncidentSelect={setSelectedIncidentId}
onFilteredChange={setFilteredIncidents}
/>
{/* Center - Map + Analysis Views */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Analysis Bar */}
{analysisActive && (
<div
className="shrink-0 flex items-center justify-between border-b border-stroke"
style={{
height: 36,
padding: '0 16px',
background: 'linear-gradient(90deg,rgba(6,182,212,0.06),var(--bg-surface))',
}}
>
<div className="flex items-center gap-2">
<span className="text-caption font-bold">🔬 </span>
<span className="text-caption text-fg-disabled">{selectedIncident?.name}</span>
<div className="flex gap-1">
{analysisTags.map((t, i) => (
<span
key={i}
className="text-caption font-semibold rounded-md"
style={{
padding: '2px 8px',
background: `${t.color}18`,
border: `1px solid ${t.color}40`,
color: t.color,
}}
>
{t.icon} {t.label}
</span>
))}
</div>
</div>
<div className="flex gap-1 ml-auto">
{(
[
{ mode: 'overlay' as ViewMode, icon: '🗂', label: '오버레이' },
{ mode: 'split2' as ViewMode, icon: '◫', label: '2분할' },
{ mode: 'split3' as ViewMode, icon: '⊞', label: '3분할' },
] as const
).map((v) => (
<button
key={v.mode}
onClick={() => setViewMode(v.mode)}
className="text-caption font-semibold cursor-pointer rounded-sm"
style={{
padding: '3px 10px',
background: viewMode === v.mode ? 'rgba(6,182,212,0.12)' : 'var(--bg-card)',
border:
viewMode === v.mode
? '1px solid rgba(6,182,212,0.3)'
: '1px solid var(--stroke-default)',
color: viewMode === v.mode ? 'var(--color-accent)' : 'var(--fg-disabled)',
}}
>
{v.icon} {v.label}
</button>
))}
<button
onClick={handleCloseAnalysis}
className="text-caption font-semibold cursor-pointer rounded-sm"
style={{
padding: '3px 8px',
background: 'rgba(239,68,68,0.06)',
border: '1px solid rgba(239,68,68,0.2)',
color: 'var(--color-danger)',
}}
>
</button>
</div>
</div>
)}
{/* Map / Analysis Content Area */}
<div className="flex-1 relative overflow-hidden">
{/* Default Map (visible when not in analysis or in overlay mode) */}
{(!analysisActive || viewMode === 'overlay') && (
<div className="absolute inset-0">
<MapLibre
initialViewState={{ longitude: 127.8, latitude: 35.0, zoom: 7 }}
mapStyle={currentMapStyle}
style={{ width: '100%', height: '100%', background: '#f0f0f0' }}
attributionControl={false}
onClick={(e) => {
if (measureMode !== null && e.lngLat) {
handleMeasureClick(e.lngLat.lng, e.lngLat.lat);
return;
}
if (dischargeMode && e.lngLat) {
const lat = e.lngLat.lat;
const lon = e.lngLat.lng;
const distanceNm = estimateDistanceFromCoast(lat, lon);
const zoneIndex = determineZone(lat, lon);
setDischargeInfo({ lat, lon, distanceNm, zoneIndex });
}
}}
cursor={measureMode !== null || dischargeMode ? 'crosshair' : undefined}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<DeckGLOverlay layers={deckLayers} />
<FlyToController incident={selectedIncident} />
<MeasureOverlay />
{/* 사고 팝업 */}
{incidentPopup && (
<Popup
longitude={incidentPopup.longitude}
latitude={incidentPopup.latitude}
anchor="bottom"
onClose={() => setIncidentPopup(null)}
closeButton={false}
closeOnClick={false}
className="incident-popup"
maxWidth="none"
>
<IncidentPopupContent
incident={incidentPopup.incident}
onClose={() => setIncidentPopup(null)}
/>
</Popup>
)}
</MapLibre>
{/* 호버 툴팁 */}
{hoverInfo && (
<div
className="absolute z-[1000] pointer-events-none rounded-md"
style={{
left: hoverInfo.x + 12,
top: hoverInfo.y - 12,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
padding: '8px 12px',
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
minWidth: 150,
}}
>
{hoverInfo.type === 'vessel' ? (
<VesselTooltipContent vessel={hoverInfo.object as Vessel} />
) : (
<IncidentTooltipContent incident={hoverInfo.object as IncidentCompat} />
)}
</div>
)}
{/* 분석 오버레이 (지도 위 시각효과) */}
{analysisActive && viewMode === 'overlay' && (
<div className="absolute inset-0 z-[500] pointer-events-none">
{analysisTags.some((t) => t.label === '유출유') && (
<div
style={{
position: 'absolute',
top: '30%',
left: '45%',
width: 180,
height: 120,
background:
'radial-gradient(ellipse, rgba(249,115,22,0.35) 0%, rgba(249,115,22,0.1) 50%, transparent 70%)',
borderRadius: '50%',
transform: 'rotate(-15deg)',
}}
/>
)}
{analysisTags.some((t) => t.label === 'HNS') && (
<div
style={{
position: 'absolute',
top: '25%',
left: '50%',
width: 150,
height: 100,
background:
'radial-gradient(ellipse, rgba(168,85,247,0.3) 0%, rgba(168,85,247,0.08) 50%, transparent 70%)',
borderRadius: '50%',
transform: 'rotate(20deg)',
}}
/>
)}
{analysisTags.some((t) => t.label === '구난') && (
<div
style={{
position: 'absolute',
top: '35%',
left: '42%',
width: 200,
height: 200,
border: '2px dashed rgba(6,182,212,0.4)',
borderRadius: '50%',
}}
/>
)}
</div>
)}
{/* 오염물 배출 규정 토글 */}
<button
onClick={() => {
setDischargeMode(!dischargeMode);
if (dischargeMode) setDischargeInfo(null);
}}
className="absolute z-[500] cursor-pointer rounded-md text-caption font-bold font-korean"
style={{
top: 10,
right: dischargeMode ? 340 : 180,
padding: '6px 10px',
background: dischargeMode ? 'rgba(6,182,212,0.15)' : 'rgba(13,17,23,0.88)',
border: dischargeMode
? '1px solid rgba(6,182,212,0.4)'
: '1px solid var(--stroke-default)',
color: dischargeMode ? '#22d3ee' : 'var(--fg-disabled)',
backdropFilter: 'blur(8px)',
transition: 'all 0.2s',
}}
>
🚢 {dischargeMode ? 'ON' : 'OFF'}
</button>
{/* 오염물 배출 규정 패널 */}
{dischargeMode && dischargeInfo && (
<DischargeZonePanel
lat={dischargeInfo.lat}
lon={dischargeInfo.lon}
distanceNm={dischargeInfo.distanceNm}
zoneIndex={dischargeInfo.zoneIndex}
onClose={() => setDischargeInfo(null)}
/>
)}
{/* 배출규정 모드 안내 */}
{dischargeMode && !dischargeInfo && (
<div
className="absolute z-[500] rounded-md text-label-2 font-korean font-semibold"
style={{
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
padding: '12px 20px',
background: 'rgba(13,17,23,0.9)',
border: '1px solid rgba(6,182,212,0.3)',
color: '#22d3ee',
backdropFilter: 'blur(8px)',
pointerEvents: 'none',
}}
>
📍
</div>
)}
{/* AIS Live Badge */}
<div
className="absolute top-[10px] right-[10px] z-[500] rounded-md"
style={{
background: 'rgba(13,17,23,0.88)',
border: '1px solid var(--stroke-default)',
padding: '8px 12px',
backdropFilter: 'blur(8px)',
}}
>
<div className="flex items-center gap-1.5 mb-[5px]">
<div
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: 'var(--color-success)',
animation: 'pd 1.5s infinite',
}}
/>
<span className="text-caption font-bold">AIS Live</span>
<span className="text-caption text-fg-disabled font-mono">MarineTraffic</span>
</div>
<div className="flex gap-2.5 text-caption font-mono">
<div className="text-fg-sub">
<b className="text-color-accent">20</b>
</div>
<div className="text-fg-sub">
<b className="text-red-400">6</b>
</div>
<div className="text-fg-sub">
<b className="text-cyan-500">2</b>
</div>
</div>
</div>
{/* Legend */}
<div
className="absolute bottom-[10px] left-[10px] z-[500] rounded-md flex flex-col gap-1.5"
style={{
background: 'rgba(13,17,23,0.88)',
border: '1px solid var(--stroke-default)',
padding: '8px 12px',
backdropFilter: 'blur(8px)',
}}
>
<div className="text-caption font-bold text-fg-sub"> </div>
<div className="flex gap-2.5">
{[
{ c: '#ef4444', l: '대응중' },
{ c: '#f59e0b', l: '조사중' },
{ c: '#6b7280', l: '종료' },
].map((s) => (
<div key={s.l} className="flex items-center gap-1">
<div style={{ width: 8, height: 8, borderRadius: '50%', background: s.c }} />
<span className="text-caption text-fg-disabled">{s.l}</span>
</div>
))}
</div>
<div className="text-caption font-bold text-fg-sub mt-0.5">AIS </div>
<div className="flex flex-wrap gap-[6px_12px]">
{VESSEL_LEGEND.map((vl) => (
<div key={vl.type} className="flex items-center gap-1">
<div
style={{
width: 0,
height: 0,
borderLeft: '3px solid transparent',
borderRight: '3px solid transparent',
borderBottom: `7px solid ${vl.color}`,
}}
/>
<span className="text-caption text-fg-disabled">{vl.type}</span>
</div>
))}
</div>
</div>
{/* 선박 팝업 패널 */}
{vesselPopup && selectedVessel && !detailVessel && (
<VesselPopupPanel
vessel={selectedVessel}
onClose={() => {
setVesselPopup(null);
setSelectedVessel(null);
}}
onDetail={() => {
setDetailVessel(selectedVessel);
setVesselPopup(null);
setSelectedVessel(null);
}}
/>
)}
{detailVessel && (
<VesselDetailModal vessel={detailVessel} onClose={() => setDetailVessel(null)} />
)}
</div>
)}
{/* ── 2분할 View ─────────────────────────────── */}
{analysisActive && viewMode === 'split2' && (
<div className="flex h-full">
<div
className="flex-1 flex flex-col overflow-hidden"
style={{ borderRight: '2px solid var(--color-accent)' }}
>
<div
className="flex items-center shrink-0 bg-bg-surface border-b border-stroke"
style={{ height: 28, padding: '0 10px' }}
>
<span className="text-caption font-bold text-color-accent">
{analysisTags[0]
? `${analysisTags[0].icon} ${analysisTags[0].label}`
: '— 분석 결과를 선택하세요 —'}
</span>
</div>
<div
className="flex-1 overflow-y-auto bg-bg-base flex flex-col gap-2"
style={{ padding: 12 }}
>
<SplitPanelContent tag={analysisTags[0]} incident={selectedIncident} />
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div
className="flex items-center shrink-0 bg-bg-surface border-b border-stroke"
style={{ height: 28, padding: '0 10px' }}
>
<span className="text-caption font-bold text-color-accent">
{analysisTags[1]
? `${analysisTags[1].icon} ${analysisTags[1].label}`
: '— 분석 결과를 선택하세요 —'}
</span>
</div>
<div
className="flex-1 overflow-y-auto bg-bg-base flex flex-col gap-2"
style={{ padding: 12 }}
>
<SplitPanelContent tag={analysisTags[1]} incident={selectedIncident} />
</div>
</div>
</div>
)}
{/* ── 3분할 View ─────────────────────────────── */}
{analysisActive && viewMode === 'split3' && (
<div className="flex h-full">
<div className="flex-1 flex flex-col overflow-hidden border-r border-stroke">
<div
className="flex items-center shrink-0 border-b border-stroke"
style={{
height: 28,
background: 'linear-gradient(90deg,rgba(249,115,22,0.08),var(--bg-surface))',
padding: '0 10px',
}}
>
<span className="text-caption font-bold" style={{ color: '#f97316' }}>
🛢
</span>
</div>
<div
className="flex-1 overflow-y-auto bg-bg-base flex flex-col gap-1.5"
style={{ padding: 10 }}
>
<SplitPanelContent
tag={{ icon: '🛢', label: '유출유', color: '#f97316' }}
incident={selectedIncident}
/>
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden border-r border-stroke">
<div
className="flex items-center shrink-0 border-b border-stroke"
style={{
height: 28,
background: 'linear-gradient(90deg,rgba(168,85,247,0.08),var(--bg-surface))',
padding: '0 10px',
}}
>
<span className="text-caption font-bold" style={{ color: '#a855f7' }}>
🧪 HNS
</span>
</div>
<div
className="flex-1 overflow-y-auto bg-bg-base flex flex-col gap-1.5"
style={{ padding: 10 }}
>
<SplitPanelContent
tag={{ icon: '🧪', label: 'HNS', color: '#a855f7' }}
incident={selectedIncident}
/>
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div
className="flex items-center shrink-0 border-b border-stroke"
style={{
height: 28,
background: 'linear-gradient(90deg,rgba(6,182,212,0.08),var(--bg-surface))',
padding: '0 10px',
}}
>
<span className="text-caption font-bold text-color-accent">🚨 </span>
</div>
<div
className="flex-1 overflow-y-auto bg-bg-base flex flex-col gap-1.5"
style={{ padding: 10 }}
>
<SplitPanelContent
tag={{ icon: '🚨', label: '구난', color: '#06b6d4' }}
incident={selectedIncident}
/>
</div>
</div>
</div>
)}
</div>
{/* Decision Bar */}
{analysisActive && (
<div
className="shrink-0 flex items-center justify-between bg-bg-surface border-t border-stroke"
style={{ padding: '6px 16px' }}
>
<div className="text-caption text-fg-disabled">
📊 {selectedIncident?.name} · {analysisTags.map((t) => t.label).join(' + ')}
</div>
<div className="flex gap-[6px]">
<button
className="cursor-pointer rounded text-caption font-semibold"
style={{
padding: '4px 12px',
background: 'rgba(59,130,246,0.1)',
border: '1px solid rgba(59,130,246,0.2)',
color: 'var(--color-info)',
}}
>
📋
</button>
<button
className="cursor-pointer rounded text-caption font-semibold"
style={{
padding: '4px 12px',
background: 'rgba(168,85,247,0.1)',
border: '1px solid rgba(168,85,247,0.2)',
color: '#a78bfa',
}}
>
🔗 R&D
</button>
</div>
</div>
)}
</div>
{/* Right Panel */}
<IncidentsRightPanel
incident={selectedIncident}
viewMode={viewMode}
onViewModeChange={setViewMode}
onRunAnalysis={handleRunAnalysis}
analysisActive={analysisActive}
onCloseAnalysis={handleCloseAnalysis}
onCheckedPredsChange={handleCheckedPredsChange}
onSensitiveDataChange={handleSensitiveDataChange}
selectedVessel={selectedVessel}
/>
</div>
);
}
/* ════════════════════════════════════════════════════
SplitPanelContent
════════════════════════════════════════════════════ */
function SplitPanelContent({
tag,
incident,
}: {
tag?: { icon: string; label: string; color: string };
incident: Incident | null;
}) {
if (!tag) {
return (
<div className="flex-1 flex items-center justify-center text-fg-disabled text-label-2">
R&D
</div>
);
}
const mockData: Record<
string,
{
title: string;
model: string;
items: { label: string; value: string; color?: string }[];
summary: string;
}
> = {
: {
title: '유출유 확산예측 결과',
model: 'KOSPS + OpenDrift · BUNKER-C 150kL',
items: [
{ label: '예측 시간', value: '72시간 (3일)' },
{ label: '최대 확산거리', value: '12.3 NM', color: '#f97316' },
{ label: '해안 도달 시간', value: '18시간 후', color: '#ef4444' },
{ label: '영향 해안선', value: '27.5 km' },
{ label: '풍화율', value: '32.4%' },
{ label: '잔존유량', value: '101.4 kL', color: '#f97316' },
],
summary:
'여수항 남동쪽 방향 확산, 18시간 후 돌산도 해안 도달 예상. 조류 영향으로 남서쪽 이동.',
},
HNS: {
title: 'HNS 대기확산 결과',
model: 'ALOHA + PHAST · 톨루엔 5톤',
items: [
{ label: 'IDLH 범위', value: '1.2 km', color: '#ef4444' },
{ label: 'ERPG-2 범위', value: '2.8 km', color: '#f97316' },
{ label: 'ERPG-1 범위', value: '5.1 km', color: '#eab308' },
{ label: '풍향', value: 'SW → NE 방향' },
{ label: '대기 안정도', value: 'D등급 (중립)' },
{ label: '영향 인구', value: '약 2,400명', color: '#ef4444' },
],
summary: '남서풍에 의해 북동쪽 내륙 방향 확산. IDLH 1.2km 이내 즉시 대피 필요.',
},
: {
title: '긴급구난 SAR 결과',
model: 'SAROPS · Monte Carlo 10,000회 시뮬레이션',
items: [
{ label: '95% 확률 범위', value: '8.5 NM²', color: '#06b6d4' },
{ label: '최적 탐색 경로', value: 'Sector Search' },
{ label: '예상 표류 속도', value: '1.8 kn' },
{ label: '표류 방향', value: 'NE (045°)' },
{ label: '생존 가능 시간', value: '36시간', color: '#ef4444' },
{ label: '필요 자산', value: '헬기 2 + 경비정 3', color: '#f97316' },
],
summary: '북동쪽 방향 표류 예상. 06:00 기준 최적 탐색 패턴: 섹터탐색(Sector Search).',
},
};
const data = mockData[tag.label] || mockData['유출유'];
return (
<>
<div
className="rounded-sm"
style={{
padding: '10px 12px',
background: `${tag.color}08`,
border: `1px solid ${tag.color}20`,
}}
>
<div className="text-label-2 font-bold" style={{ color: tag.color, marginBottom: 4 }}>
{tag.icon} {data.title}
</div>
<div className="text-caption text-fg-disabled font-mono">{data.model}</div>
{incident && (
<div className="text-caption text-fg-disabled" style={{ marginTop: 2 }}>
: {incident.name} · {incident.date} {incident.time}
</div>
)}
</div>
<div className="rounded-sm border border-stroke overflow-hidden">
{data.items.map((item, i) => (
<div
key={i}
className="flex justify-between items-center"
style={{
padding: '6px 10px',
borderBottom: i < data.items.length - 1 ? '1px solid var(--stroke-default)' : 'none',
background: i % 2 === 0 ? 'var(--bg-surface)' : 'var(--bg-elevated)',
}}
>
<span className="text-caption text-fg-disabled">{item.label}</span>
<span
className="text-caption font-semibold font-mono"
style={{ color: item.color || 'var(--fg-default)' }}
>
{item.value}
</span>
</div>
))}
</div>
<div
className="rounded-sm text-caption text-fg-sub"
style={{
padding: '8px 10px',
background: `${tag.color}06`,
border: `1px solid ${tag.color}15`,
lineHeight: 1.6,
}}
>
💡 {data.summary}
</div>
<div
className="rounded-sm bg-bg-base border border-stroke flex items-center justify-center flex-col gap-1"
style={{ height: 120 }}
>
<div className="text-[32px] opacity-30">{tag.icon}</div>
<div className="text-caption text-fg-disabled"> </div>
</div>
</>
);
}
/* ════════════════════════════════════════════════════
VesselPopupPanel
════════════════════════════════════════════════════ */
function VesselPopupPanel({
vessel: v,
onClose,
onDetail,
}: {
vessel: Vessel;
onClose: () => void;
onDetail: () => void;
}) {
const statusColor = v.status.includes('사고') ? '#ef4444' : '#22c55e';
const statusBg = v.status.includes('사고') ? 'rgba(239,68,68,0.15)' : 'rgba(34,197,94,0.1)';
return (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%,-50%)',
zIndex: 9995,
width: 300,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
borderRadius: 12,
boxShadow: '0 16px 48px rgba(0,0,0,0.6)',
overflow: 'hidden',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '10px 14px',
background: 'var(--bg-elevated)',
borderBottom: '1px solid var(--stroke-default)',
}}
>
<div
className="flex items-center justify-center text-title-2"
style={{ width: 28, height: 20 }}
>
{v.flag}
</div>
<div className="flex-1 min-w-0">
<div className="text-label-1 font-[800] text-fg whitespace-nowrap overflow-hidden text-ellipsis">
{v.name}
</div>
<div className="text-caption text-fg-disabled font-mono">MMSI: {v.mmsi}</div>
</div>
<span onClick={onClose} className="text-title-3 cursor-pointer text-fg-disabled p-[2px]">
</span>
</div>
{/* Ship Image */}
<div
className="w-full flex items-center justify-center text-[40px] text-fg-disabled"
style={{
height: 120,
background: '#0d1117',
borderBottom: '1px solid #21262d',
}}
>
🚢
</div>
{/* Tags */}
<div
className="flex gap-2"
style={{ padding: '6px 14px', borderBottom: '1px solid #21262d' }}
>
<span
className="text-caption font-bold rounded text-color-info"
style={{
padding: '2px 8px',
background: 'rgba(59,130,246,0.12)',
border: '1px solid rgba(59,130,246,0.25)',
}}
>
{v.typS}
</span>
<span
className="text-caption font-bold rounded"
style={{
padding: '2px 8px',
background: statusBg,
border: `1px solid ${statusColor}40`,
color: statusColor,
}}
>
{v.status}
</span>
</div>
{/* Data rows */}
<div style={{ padding: '4px 0' }}>
<PopupRow label="속도/항로" value={`${v.speed} kn / ${v.heading}°`} accent />
<PopupRow label="흘수" value={`${v.draft}m`} />
<div
className="flex flex-col gap-1"
style={{
padding: '6px 14px',
borderBottom: '1px solid rgba(48,54,61,0.4)',
}}
>
<div className="flex justify-between">
<span className="text-caption text-fg-disabled"></span>
<span className="text-caption text-fg-sub font-semibold font-mono">{v.depart}</span>
</div>
<div className="flex justify-between">
<span className="text-caption text-fg-disabled"></span>
<span className="text-caption text-fg-sub font-semibold font-mono">{v.arrive}</span>
</div>
</div>
<PopupRow label="데이터 수신" value="2026-02-25 14:32:00" muted />
</div>
{/* Buttons */}
<div className="flex gap-1.5" style={{ padding: '10px 14px' }}>
<button
onClick={onDetail}
className="flex-1 text-caption font-bold cursor-pointer text-center rounded-sm text-color-info"
style={{
padding: 6,
background: 'rgba(59,130,246,0.12)',
border: '1px solid rgba(59,130,246,0.3)',
}}
>
📋
</button>
<button
className="flex-1 text-caption font-bold cursor-pointer text-center rounded-sm text-color-tertiary"
style={{
padding: 6,
background: 'rgba(168,85,247,0.1)',
border: '1px solid rgba(168,85,247,0.25)',
}}
>
🔍
</button>
<button
className="flex-1 text-caption font-bold cursor-pointer text-center rounded-sm text-color-accent"
style={{
padding: 6,
background: 'rgba(6,182,212,0.1)',
border: '1px solid rgba(6,182,212,0.25)',
}}
>
📐
</button>
</div>
</div>
);
}
function PopupRow({
label,
value,
accent,
muted,
}: {
label: string;
value: string;
accent?: boolean;
muted?: boolean;
}) {
return (
<div
className="flex justify-between text-caption"
style={{
padding: '6px 14px',
borderBottom: '1px solid rgba(48,54,61,0.4)',
}}
>
<span className="text-fg-disabled">{label}</span>
<span
className="font-semibold font-mono"
style={{
color: muted ? '#8b949e' : accent ? '#22d3ee' : '#c9d1d9',
fontSize: muted ? 9 : 10,
}}
>
{value}
</span>
</div>
);
}
/* ════════════════════════════════════════════════════
IncidentPopupContent 사고 마커 클릭 팝업
════════════════════════════════════════════════════ */
function IncidentPopupContent({
incident: inc,
onClose,
}: {
incident: IncidentCompat;
onClose: () => void;
}) {
const dotColor: Record<string, string> = {
active: 'var(--color-danger)',
investigating: 'var(--color-warning)',
closed: 'var(--fg-disabled)',
};
const stBg: Record<string, string> = {
active: 'rgba(239,68,68,0.15)',
investigating: 'rgba(249,115,22,0.15)',
closed: 'rgba(100,116,139,0.15)',
};
const stColor: Record<string, string> = {
active: 'var(--color-danger)',
investigating: 'var(--color-warning)',
closed: 'var(--fg-disabled)',
};
return (
<div
style={{
width: 260,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
borderRadius: 10,
boxShadow: '0 12px 40px rgba(0,0,0,0.5)',
overflow: 'hidden',
}}
>
{/* Header */}
<div
className="flex items-center gap-2 border-b border-stroke"
style={{ padding: '10px 14px' }}
>
<span
className="shrink-0"
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: dotColor[inc.status],
boxShadow: inc.status !== 'closed' ? `0 0 6px ${dotColor[inc.status]}` : 'none',
}}
/>
<div className="flex-1 min-w-0 text-label-1 font-bold text-fg whitespace-nowrap overflow-hidden text-ellipsis">
{inc.name}
</div>
<span
onClick={onClose}
className="cursor-pointer text-fg-disabled hover:text-fg flex items-center justify-center"
style={{
width: 22,
height: 22,
borderRadius: 'var(--radius-sm)',
fontSize: 14,
lineHeight: 1,
transition: '0.15s',
}}
>
</span>
</div>
{/* Tags */}
<div
className="flex flex-wrap gap-1.5 border-b border-stroke"
style={{ padding: '8px 14px' }}
>
<span
className="text-caption font-semibold rounded-sm"
style={{
padding: '2px 8px',
background: stBg[inc.status],
border: `1px solid ${stColor[inc.status]}`,
color: stColor[inc.status],
}}
>
{getStatusLabel(inc.status)}
</span>
{inc.causeType && (
<span
className="text-caption font-medium text-fg-sub rounded-sm"
style={{
padding: '2px 8px',
background: 'rgba(100,116,139,0.08)',
border: '1px solid rgba(100,116,139,0.2)',
}}
>
{inc.causeType}
</span>
)}
{inc.oilType && (
<span
className="text-caption font-medium text-color-warning rounded-sm"
style={{
padding: '2px 8px',
background: 'rgba(249,115,22,0.08)',
border: '1px solid rgba(249,115,22,0.2)',
}}
>
{inc.oilType}
</span>
)}
</div>
{/* Info rows */}
<div style={{ padding: '4px 0' }}>
<div
className="flex justify-between text-caption"
style={{ padding: '5px 14px', borderBottom: '1px solid rgba(48,54,61,0.4)' }}
>
<span className="text-fg-disabled"></span>
<span className="text-fg-sub font-semibold font-mono">
{inc.date} {inc.time}
</span>
</div>
<div
className="flex justify-between text-caption"
style={{ padding: '5px 14px', borderBottom: '1px solid rgba(48,54,61,0.4)' }}
>
<span className="text-fg-disabled"></span>
<span className="text-fg-sub font-semibold">{inc.office}</span>
</div>
<div
className="flex justify-between text-caption"
style={{ padding: '5px 14px' }}
>
<span className="text-fg-disabled"></span>
<span className="text-fg-sub font-semibold">{inc.region}</span>
</div>
</div>
{/* Prediction badge */}
{inc.prediction && (
<div className="border-t border-stroke" style={{ padding: '8px 14px' }}>
<span
className="text-caption font-semibold text-color-accent rounded-sm"
style={{
padding: '3px 10px',
background: 'rgba(6,182,212,0.1)',
border: '1px solid rgba(6,182,212,0.25)',
}}
>
{inc.prediction}
</span>
</div>
)}
</div>
);
}
/* ════════════════════════════════════════════════════
VesselDetailModal
════════════════════════════════════════════════════ */
type DetTab = 'info' | 'nav' | 'spec' | 'ins' | 'dg';
const TAB_LABELS: { key: DetTab; label: string }[] = [
{ key: 'info', label: '상세정보' },
{ key: 'nav', label: '항해정보' },
{ key: 'spec', label: '선박제원' },
{ key: 'ins', label: '보험정보' },
{ key: 'dg', label: '위험물정보' },
];
function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: () => void }) {
const [tab, setTab] = useState<DetTab>('info');
return (
<div
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
className="fixed inset-0 z-[10000] flex items-center justify-center"
style={{
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(6px)',
}}
>
<div
className="flex flex-col overflow-hidden"
style={{
width: 560,
height: '85vh',
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 14,
boxShadow: '0 24px 64px rgba(0,0,0,0.7)',
}}
>
{/* Header */}
<div
className="shrink-0 flex items-center justify-between"
style={{
padding: '14px 18px',
background: 'linear-gradient(135deg,#1c2333,#161b22)',
borderBottom: '1px solid #30363d',
}}
>
<div className="flex items-center gap-[10px]">
<span className="text-lg">{v.flag}</span>
<div>
<div className="text-title-3 font-[800] text-fg">{v.name}</div>
<div className="text-caption text-fg-disabled font-mono">
MMSI: {v.mmsi} · IMO: {v.imo}
</div>
</div>
</div>
<span onClick={onClose} className="text-title-2 cursor-pointer text-fg-disabled">
</span>
</div>
{/* Tabs */}
<div
className="shrink-0 flex gap-0.5 overflow-x-auto"
style={{
padding: '0 18px',
background: '#0d1117',
borderBottom: '1px solid #21262d',
}}
>
{TAB_LABELS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className="text-label-2 cursor-pointer whitespace-nowrap"
style={{
padding: '8px 11px',
fontWeight: tab === t.key ? 600 : 400,
color: tab === t.key ? '#58a6ff' : '#8b949e',
borderBottom: tab === t.key ? '2px solid #58a6ff' : '2px solid transparent',
background: 'none',
border: 'none',
transition: '0.15s',
}}
>
{t.label}
</button>
))}
</div>
{/* Body */}
<div
className="flex-1 overflow-y-auto flex flex-col"
style={{
padding: '16px 18px',
gap: 14,
scrollbarWidth: 'thin',
scrollbarColor: '#30363d transparent',
}}
>
{tab === 'info' && <TabInfo v={v} />}
{tab === 'nav' && <TabNav v={v} />}
{tab === 'spec' && <TabSpec v={v} />}
{tab === 'ins' && <TabInsurance v={v} />}
{tab === 'dg' && <TabDangerous v={v} />}
</div>
</div>
</div>
);
}
/* ── shared section helpers ──────────────────────── */
function Sec({
title,
borderColor,
bgColor,
badge,
children,
}: {
title: string;
borderColor?: string;
bgColor?: string;
badge?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div
style={{
border: `1px solid ${borderColor || '#21262d'}`,
borderRadius: 8,
overflow: 'hidden',
}}
>
<div
className="text-label-2 font-bold text-fg-sub flex items-center justify-between"
style={{
padding: '8px 12px',
background: bgColor || '#0d1117',
borderBottom: `1px solid ${borderColor || '#21262d'}`,
}}
>
<span>{title}</span>
{badge}
</div>
{children}
</div>
);
}
function Grid({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-2">{children}</div>;
}
function Cell({
label,
value,
span,
color,
}: {
label: string;
value: string;
span?: boolean;
color?: string;
}) {
return (
<div
style={{
padding: '8px 12px',
borderBottom: '1px solid rgba(33,38,45,0.6)',
borderRight: span ? 'none' : '1px solid rgba(33,38,45,0.6)',
gridColumn: span ? '1 / -1' : undefined,
}}
>
<div className="text-caption text-fg-disabled" style={{ marginBottom: 2 }}>
{label}
</div>
<div className="text-label-2 font-semibold font-mono" style={{ color: color || '#f0f6fc' }}>
{value}
</div>
</div>
);
}
function StatusBadge({ label, color }: { label: string; color: string }) {
return (
<span
className="text-caption font-bold"
style={{
padding: '2px 6px',
borderRadius: 8,
marginLeft: 'auto',
background: `${color}25`,
color,
}}
>
{label}
</span>
);
}
/* ── Tab 0: 상세정보 ─────────────────────────────── */
function TabInfo({ v }: { v: Vessel }) {
return (
<>
<div
className="w-full rounded-lg overflow-hidden flex items-center justify-center text-[60px] text-fg-disabled"
style={{
height: 160,
background: '#0d1117',
}}
>
🚢
</div>
<Sec title="📡 실시간 현황">
<Grid>
<Cell label="선박상태" value={v.status} />
<Cell label="속도 / 항로" value={`${v.speed} kn / ${v.heading}°`} color="#22d3ee" />
<Cell label="위도" value={`${v.lat.toFixed(4)}°N`} />
<Cell label="경도" value={`${v.lng.toFixed(4)}°E`} />
<Cell label="흘수" value={`${v.draft}m`} />
<Cell label="수신시간" value="2026-02-25 14:30" />
</Grid>
</Sec>
<Sec title="🚢 항해 일정">
<Grid>
<Cell label="출항지" value={v.depart} />
<Cell label="입항지" value={v.arrive} />
<Cell label="출항일시" value={v.etd || '—'} />
<Cell label="입항일시(ETA)" value={v.eta || '—'} />
</Grid>
</Sec>
</>
);
}
/* ── Tab 1: 항해정보 ─────────────────────────────── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function TabNav(_props: { v: Vessel }) {
const hours = ['08', '09', '10', '11', '12', '13', '14'];
const heights = [45, 60, 78, 82, 70, 85, 75];
const colors = [
'rgba(34,197,94,.3)',
'rgba(34,197,94,.4)',
'rgba(59,130,246,.4)',
'rgba(59,130,246,.5)',
'rgba(59,130,246,.5)',
'rgba(59,130,246,.6)',
'rgba(6,182,212,.5)',
];
return (
<>
<Sec title="🗺 최근 항적 (24h)">
<div
className="flex items-center justify-center relative overflow-hidden"
style={{
height: 180,
background: '#0d1117',
}}
>
<svg
width="100%"
height="100%"
viewBox="0 0 400 180"
style={{ position: 'absolute', inset: 0 }}
>
<path
d="M50,150 C80,140 120,100 160,95 S240,70 280,50 S340,30 370,20"
fill="none"
stroke="#58a6ff"
strokeWidth="2"
strokeDasharray="6,3"
opacity=".6"
/>
<circle cx="50" cy="150" r="4" fill="#8b949e" />
<circle cx="160" cy="95" r="3" fill="#58a6ff" opacity=".5" />
<circle cx="280" cy="50" r="3" fill="#58a6ff" opacity=".5" />
<circle cx="370" cy="20" r="5" fill="#58a6ff" />
<text x="45" y="168" fill="#8b949e" fontSize="9" fontFamily="monospace">
08:00
</text>
<text x="150" y="113" fill="#8b949e" fontSize="9" fontFamily="monospace">
10:30
</text>
<text x="270" y="68" fill="#8b949e" fontSize="9" fontFamily="monospace">
12:45
</text>
<text x="350" y="16" fill="#58a6ff" fontSize="9" fontFamily="monospace">
</text>
</svg>
</div>
</Sec>
<Sec title="📊 속도 이력">
<div className="p-3 bg-bg-base">
<div className="flex items-end gap-1.5" style={{ height: 80 }}>
{hours.map((h, i) => (
<div key={h} className="flex-1 flex flex-col items-center gap-0.5">
<div
className="w-full"
style={{
background: colors[i],
borderRadius: '2px 2px 0 0',
height: `${heights[i]}%`,
}}
/>
<span className="text-caption text-fg-disabled">{h}</span>
</div>
))}
</div>
<div className="text-center text-caption text-fg-disabled" style={{ marginTop: 6 }}>
: <b className="text-color-info">8.4 kn</b> · :{' '}
<b className="text-color-accent">11.2 kn</b>
</div>
</div>
</Sec>
<div className="flex gap-[8px]">
<ActionBtn
icon="🔍"
label="전체 항적 조회"
bg="rgba(168,85,247,0.1)"
bd="rgba(168,85,247,0.25)"
fg="#a78bfa"
/>
<ActionBtn
icon="📐"
label="항로 예측"
bg="rgba(6,182,212,0.1)"
bd="rgba(6,182,212,0.25)"
fg="#22d3ee"
/>
</div>
</>
);
}
/* ── Tab 2: 선박제원 ─────────────────────────────── */
function TabSpec({ v }: { v: Vessel }) {
return (
<>
<Sec title="📐 선체 제원">
<Grid>
<Cell label="선종" value={v.typS} />
<Cell label="선적국" value={`${v.flag}`} />
<Cell label="총톤수 (GT)" value={v.gt} />
<Cell label="재화중량 (DWT)" value={v.dwt} />
<Cell label="전장 (LOA)" value={v.loa} />
<Cell label="선폭" value={v.beam} />
<Cell label="건조년도" value={v.built} />
<Cell label="건조 조선소" value={v.yard} />
</Grid>
</Sec>
<Sec title="📡 통신 / 식별">
<Grid>
<Cell label="MMSI" value={String(v.mmsi)} />
<Cell label="IMO" value={v.imo} />
<Cell label="호출부호" value={v.callSign} />
<Cell label="선급" value={v.cls} />
</Grid>
</Sec>
<Sec title="⚠ 위험물 적재 정보">
<div className="p-[10px_12px] bg-bg-base">
<div
className="flex items-center gap-2 rounded"
style={{
padding: '5px 8px',
background: 'rgba(239,68,68,0.06)',
border: '1px solid rgba(239,68,68,0.12)',
}}
>
<span className="text-label-1">🛢</span>
<div className="flex-1">
<div className="text-caption font-semibold text-fg">
{v.cargo.split('·')[0].trim()}
</div>
<div className="text-caption text-fg-disabled">{v.cargo}</div>
</div>
{v.cargo.includes('IMO') && (
<span
className="text-caption font-bold text-color-danger"
style={{
padding: '2px 6px',
background: 'rgba(239,68,68,0.15)',
borderRadius: 3,
}}
>
</span>
)}
</div>
</div>
</Sec>
</>
);
}
/* ── Tab 3: 보험정보 ─────────────────────────────── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function TabInsurance(_props: { v: Vessel }) {
return (
<>
<Sec title="🏢 선주 / 운항사">
<Grid>
<Cell label="선주" value="대한해운(주)" />
<Cell label="운항사" value="대한해운(주)" />
<Cell label="P&I Club" value="한국선주상호보험" span />
</Grid>
</Sec>
<Sec
title="🚢 선체보험 (H&M)"
borderColor="rgba(6,182,212,0.2)"
bgColor="rgba(6,182,212,0.06)"
badge={<StatusBadge label="유효" color="#22c55e" />}
>
<Grid>
<Cell label="보험사" value="삼성화재해상보험" />
<Cell label="보험가액" value="USD 28,500,000" color="#22d3ee" />
<Cell label="보험기간" value="2025.01 ~ 2026.01" color="#22c55e" />
<Cell label="면책금" value="USD 150,000" />
</Grid>
</Sec>
<Sec
title="📦 화물보험 (Cargo)"
borderColor="rgba(168,85,247,0.2)"
bgColor="rgba(168,85,247,0.06)"
badge={<StatusBadge label="유효" color="#22c55e" />}
>
<Grid>
<Cell label="보험사" value="DB손해보험" />
<Cell label="보험가액" value="USD 42,100,000" color="#a855f7" />
<Cell label="적하물" value="벙커C유 72,850톤" />
<Cell label="조건" value="ICC(A) All Risks" />
</Grid>
</Sec>
<Sec
title="🛢 유류오염배상 (CLC/IOPC)"
borderColor="rgba(239,68,68,0.2)"
bgColor="rgba(239,68,68,0.06)"
badge={<StatusBadge label="유효" color="#22c55e" />}
>
<Grid>
<Cell label="배상보증서" value="유효 (2025-12-31)" color="#22c55e" />
<Cell label="발급기관" value="한국선주상호보험" />
<Cell label="CLC 한도" value="89.77M SDR" color="#ef4444" />
<Cell label="IOPC 기금" value="203M SDR" />
<Cell label="추가기금" value="750M SDR" />
<Cell label="총 배상한도" value="약 1,042.77M SDR" color="#f97316" />
</Grid>
</Sec>
<div
className="rounded-sm text-caption text-fg-disabled"
style={{
padding: '8px 10px',
background: 'rgba(59,130,246,0.04)',
border: '1px solid rgba(59,130,246,0.1)',
lineHeight: 1.6,
}}
>
💡 (KSA) Open API .
주기: 24시간
</div>
</>
);
}
/* ── Tab 4: 위험물정보 ───────────────────────────── */
function TabDangerous({ v }: { v: Vessel }) {
return (
<>
<Sec
title="⚠ 위험물 화물 신고정보"
bgColor="rgba(249,115,22,0.06)"
badge={
<span
className="text-caption font-bold text-color-danger"
style={{
padding: '2px 6px',
background: 'rgba(239,68,68,0.15)',
borderRadius: 8,
}}
>
PORT-MIS
</span>
}
>
<Grid>
<Cell label="화물명" value={v.cargo.split('·')[0].trim() || '—'} color="#f97316" />
<Cell label="컨테이너갯수/총량" value="— / 72,850 톤" />
<Cell label="하역업체코드" value="KRY-2847" />
<Cell label="하역기간" value="02-26 ~ 02-28" />
<Cell label="신고업체코드" value="DHW-0412" />
<Cell label="사용장소(부두)" value="여수 1부두 2선석" />
<Cell label="신고일시" value="2026-02-24 09:30" />
<Cell label="전출항지" value="울산항" />
<Cell label="EDI ID" value="EDI-2026022400187" />
<Cell label="수리일시" value="2026-02-24 10:15" />
</Grid>
</Sec>
<Sec title="📋 화물창 및 첨부">
<div
className="flex items-center justify-between gap-2 bg-bg-base"
style={{ padding: '12px' }}
>
<div className="flex items-center gap-2 text-label-2 whitespace-nowrap">
<span className="text-fg-disabled"> 2 </span>
<span className="inline-flex items-center gap-1">
<span
className="flex items-center justify-center text-caption text-color-accent"
style={{
width: 14,
height: 14,
borderRadius: '50%',
border: '2px solid #22d3ee',
}}
>
</span>
<span className="font-semibold text-caption text-color-accent"></span>
</span>
</div>
<button
className="text-caption font-semibold text-color-info cursor-pointer whitespace-nowrap shrink-0 rounded"
style={{
padding: '6px 14px',
background: 'rgba(59,130,246,0.1)',
border: '1px solid rgba(59,130,246,0.2)',
}}
>
📎 []
</button>
</div>
</Sec>
<Sec
title="🔥 IMO 위험물 분류"
borderColor="rgba(239,68,68,0.2)"
bgColor="rgba(239,68,68,0.06)"
>
<Grid>
<Cell label="IMO Class" value="Class 3" color="#ef4444" />
<Cell label="분류" value="인화성 액체" />
<Cell label="UN No." value="UN 1993" />
<Cell label="포장등급" value="III" />
<Cell label="인화점" value="60°C 이상" color="#f97316" />
<Cell label="해양오염물질" value="해당 (P)" color="#ef4444" />
</Grid>
</Sec>
<Sec title="🚨 비상 대응 요약 (EmS)" bgColor="rgba(234,179,8,0.06)">
<div className="flex flex-col gap-1.5 bg-bg-base" style={{ padding: '10px 12px' }}>
<EmsRow
icon="🔥"
label="화재시"
value="포말소화제, CO₂ 소화기 사용 · 물분무 냉각"
bg="rgba(239,68,68,0.05)"
bd="rgba(239,68,68,0.12)"
/>
<EmsRow
icon="🌊"
label="유출시"
value="오일펜스 전개 · 유흡착재 투입 · 해상 기름 회수"
bg="rgba(59,130,246,0.05)"
bd="rgba(59,130,246,0.12)"
/>
<EmsRow
icon="🫁"
label="보호장비"
value="내화학장갑, 보안경, 방독마스크 · 레벨C 보호복"
bg="rgba(168,85,247,0.05)"
bd="rgba(168,85,247,0.12)"
/>
</div>
</Sec>
<div
className="rounded-sm text-caption text-fg-disabled"
style={{
padding: '8px 10px',
background: 'rgba(249,115,22,0.04)',
border: '1px solid rgba(249,115,22,0.1)',
lineHeight: 1.6,
}}
>
💡 PORT-MIS() . IMDG Code
(Amendment 42-24) .
</div>
</>
);
}
function EmsRow({
icon,
label,
value,
bg,
bd,
}: {
icon: string;
label: string;
value: string;
bg: string;
bd: string;
}) {
return (
<div
className="flex items-center gap-2 rounded"
style={{
padding: '6px 10px',
background: bg,
border: `1px solid ${bd}`,
}}
>
<span className="text-title-4">{icon}</span>
<div>
<div className="text-caption text-fg-disabled">{label}</div>
<div className="text-caption font-semibold text-fg">{value}</div>
</div>
</div>
);
}
function ActionBtn({
icon,
label,
bg,
bd,
fg,
}: {
icon: string;
label: string;
bg: string;
bd: string;
fg: string;
}) {
return (
<button
className="flex-1 text-caption font-bold cursor-pointer text-center rounded-sm"
style={{
padding: 6,
background: bg,
border: `1px solid ${bd}`,
color: fg,
}}
>
{icon} {label}
</button>
);
}
/* ════════════════════════════════════════════════════
호버 툴팁 컴포넌트
════════════════════════════════════════════════════ */
function VesselTooltipContent({ vessel: v }: { vessel: Vessel }) {
return (
<>
<div className="text-label-2 font-bold text-fg" style={{ marginBottom: 3 }}>
{v.name}
</div>
<div className="text-caption text-fg-disabled" style={{ marginBottom: 4 }}>
{v.typS} · {v.flag}
</div>
<div className="flex justify-between text-caption">
<span className="text-color-accent font-semibold">{v.speed} kn</span>
<span className="text-fg-disabled">HDG {v.heading}°</span>
</div>
</>
);
}
function IncidentTooltipContent({ incident: i }: { incident: IncidentCompat }) {
const statusColor =
i.status === 'active' ? '#ef4444' : i.status === 'investigating' ? '#f59e0b' : '#6b7280';
return (
<>
<div className="text-label-2 font-bold text-fg" style={{ marginBottom: 3 }}>
{i.name}
</div>
<div className="text-caption text-fg-disabled" style={{ marginBottom: 4 }}>
{i.date} {i.time}
</div>
<div className="flex justify-between items-center">
<span className="text-caption font-semibold" style={{ color: statusColor }}>
{getStatusLabel(i.status)}
</span>
<span className="text-caption text-color-info font-mono">
{i.location.lat.toFixed(3)}°N, {i.location.lon.toFixed(3)}°E
</span>
</div>
</>
);
}