wing-ops/frontend/src/tabs/incidents/components/IncidentsView.tsx
jeonghyo.k 1da2553694 feat(incidents): 통합 분석 패널 분할 뷰 및 유출유 확산 요약 API 추가
- Incidents 통합 분석 시 이전 분석 결과를 분할 화면으로 표출
- 유출유/HNS/구난 분석 선택 모달(AnalysisSelectModal) 추가
- prediction /analyses/:acdntSn/oil-summary API 신규 (primary + byModel)
- HNS 분석 생성 시 acdntSn 연결 지원
- GSC 사고 목록 응답에 acdntSn 노출
- 민감자원 누적/카테고리 관리 및 HNS 확산 레이어 유틸(hnsDispersionLayers) 추가
2026-04-16 15:24:06 +09:00

2904 lines
105 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 MapLibreMap, Popup, useMap } from '@vis.gl/react-maplibre';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { ScatterplotLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers';
import { PathStyleExtension } from '@deck.gl/extensions';
import 'maplibre-gl/dist/maplibre-gl.css';
import { BaseMap } from '@common/components/map/BaseMap';
import { DeckGLOverlay } from '@common/components/map/DeckGLOverlay';
import { MapBoundsTracker } from '@common/components/map/MapBoundsTracker';
import { buildVesselLayers, VESSEL_LEGEND, getShipKindLabel } from '@common/components/map/VesselLayer';
import { useVesselSignals } from '@common/hooks/useVesselSignals';
import type { MapBounds, VesselPosition } from '@common/types/vessel';
import { getVesselCacheStatus, type VesselCacheStatus } from '@common/services/vesselApi';
import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel';
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel';
import { fetchIncidents } from '../services/incidentsApi';
import type { IncidentCompat } from '../services/incidentsApi';
import { fetchHnsAnalyses } from '@tabs/hns/services/hnsApi';
import type { HnsAnalysisItem } from '@tabs/hns/services/hnsApi';
import { buildHnsDispersionLayers } from '../utils/hnsDispersionLayers';
import { fetchAnalysisTrajectory, fetchOilSpillSummary } from '@tabs/prediction/services/predictionApi';
import type {
TrajectoryResponse,
SensitiveResourceFeatureCollection,
SensitiveResourceCategory,
PredictionAnalysis,
OilSpillSummaryResponse,
} from '@tabs/prediction/services/predictionApi';
import type { RescueOpsItem } from '@tabs/rescue/services/rescueApi';
import { DischargeZonePanel } from './DischargeZonePanel';
import {
estimateDistanceFromCoast,
determineZone,
getDischargeZoneLines,
loadTerritorialBaseline,
getCachedBaseline,
loadZoneGeoJSON,
getCachedZones,
} from '../utils/dischargeZoneData';
import { useMapStore } from '@common/store/mapStore';
// ── 민감자원 카테고리별 색상 (목록 순서 인덱스 기반 — 중복 없음) ────────────
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];
}
// ── 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' ? '종료' : '';
// 팝업 정보
interface VesselPopupInfo {
longitude: number;
latitude: number;
vessel: VesselPosition;
}
interface IncidentPopupInfo {
longitude: number;
latitude: number;
incident: IncidentCompat;
}
// 호버 툴팁 정보
interface HoverInfo {
x: number;
y: number;
object: VesselPosition | 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<VesselPosition | null>(null);
const [detailVessel, setDetailVessel] = useState<VesselPosition | null>(null);
const [vesselPopup, setVesselPopup] = useState<VesselPopupInfo | null>(null);
const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null);
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null);
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const [mapZoom, setMapZoom] = useState<number>(10);
const realVessels = useVesselSignals(mapBounds);
const [vesselStatus, setVesselStatus] = useState<VesselCacheStatus | null>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const status = await getVesselCacheStatus();
if (!cancelled) setVesselStatus(status);
} catch {
// 무시 — 다음 폴링에서 재시도
}
};
load();
const id = setInterval(load, 30_000);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
const [dischargeMode, setDischargeMode] = useState(false);
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const [dischargeInfo, setDischargeInfo] = useState<{
lat: number;
lon: number;
distanceNm: number;
zoneIndex: number;
} | null>(null);
const [baselineLoaded, setBaselineLoaded] = useState(
() => getCachedBaseline() !== null && getCachedZones() !== null,
);
// Measure mode (cursor 결정용 — 측정 클릭/레이어는 BaseMap이 처리)
const measureMode = useMapStore((s) => s.measureMode);
// Analysis view mode
const [viewMode, setViewMode] = useState<ViewMode>('overlay');
const [analysisActive, setAnalysisActive] = useState(true);
// 분할 뷰에서 사용할 체크된 원본 아이템들 (우측 패널에서 주입)
const [checkedPredItems, setCheckedPredItems] = useState<PredictionAnalysis[]>([]);
const [checkedHnsItems, setCheckedHnsItems] = useState<HnsAnalysisItem[]>([]);
const [checkedRescueItems, setCheckedRescueItems] = useState<RescueOpsItem[]>([]);
const [sensCategoriesFull, setSensCategoriesFull] = useState<SensitiveResourceCategory[]>([]);
const [checkedSensCategoriesFull, setCheckedSensCategoriesFull] = useState<Set<string>>(
new Set(),
);
// 2분할 좌/우 슬롯에 표시할 분석 종류
const [split2Slots, setSplit2Slots] = useState<[SplitSlotKey | null, SplitSlotKey | null]>([
null,
null,
]);
// 예측 trajectory & 민감자원 지도 표출
const [trajectoryEntries, setTrajectoryEntries] = useState<
Record<string, { data: TrajectoryResponse; occurredAt: string }>
>({});
// 유출유 확산 요약 (분할 패널용)
const [oilSummaryEntries, setOilSummaryEntries] = useState<
Record<string, OilSpillSummaryResponse>
>({});
// HNS 대기확산 분석 (선택 사고에 연결된 완료 분석들)
const [hnsAnalyses, setHnsAnalyses] = useState<HnsAnalysisItem[]>([]);
// null = 아직 패널에서 전달되지 않음 → 전체 표시 / Set = 체크된 항목만 표시
const [checkedHnsIds, setCheckedHnsIds] = useState<Set<string> | null>(null);
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));
}, []);
// 사고 전환 시 지도 레이어 즉시 초기화 + HNS 분석 자동 로드
useEffect(() => {
setTrajectoryEntries({});
setSensitiveGeojson(null);
setSensCheckedCategories(new Set());
setSensColorMap(new Map());
setHnsAnalyses([]);
setCheckedHnsIds(null);
if (!selectedIncidentId) return;
const acdntSn = parseInt(selectedIncidentId, 10);
if (Number.isNaN(acdntSn)) return;
fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' })
.then((items) => setHnsAnalyses(items))
.catch(() => {});
}, [selectedIncidentId]);
const selectedIncident = incidents.find((i) => i.id === selectedIncidentId) ?? null;
const handleRunAnalysis = (sections: AnalysisSection[], sensitiveCount: number) => {
if (sections.length === 0 && sensitiveCount === 0) return;
// 2분할 기본 슬롯 설정 — 체크된 분석 목록에 실제 아이템이 있는 종류로 자동 배치
const available: SplitSlotKey[] = [];
if (checkedPredItems.length > 0) available.push('oil');
if (checkedHnsItems.length > 0) available.push('hns');
if (checkedRescueItems.length > 0) available.push('rescue');
setSplit2Slots([available[0] ?? null, available[1] ?? null]);
setAnalysisActive(true);
};
const handleCloseAnalysis = () => {
setAnalysisActive(false);
};
// 상단 분석 태그 — 현재 viewMode에 따라 동적으로 계산
// overlay: 체크된 섹션 + 민감자원
// split2: split2Slots에 선택된 2개 슬롯만
// split3: oil/hns/rescue 3개 고정
const analysisTags = useMemo<{ icon: string; label: string; color: string }[]>(() => {
if (!analysisActive) return [];
const OIL = { icon: '🛢', label: '유출유', color: 'var(--color-warning)' };
const HNS = { icon: '🧪', label: 'HNS', color: 'var(--color-tertiary)' };
const RESCUE = { icon: '🚨', label: '구난', color: 'var(--color-accent)' };
if (viewMode === 'split2') {
const tags: { icon: string; label: string; color: string }[] = [];
split2Slots.forEach((key) => {
if (key === 'oil') tags.push(OIL);
else if (key === 'hns') tags.push(HNS);
else if (key === 'rescue') tags.push(RESCUE);
});
return tags;
}
if (viewMode === 'split3') {
return [OIL, HNS, RESCUE];
}
// overlay
const tags: { icon: string; label: string; color: string }[] = [];
if (checkedPredItems.length > 0) tags.push(OIL);
if (checkedHnsItems.length > 0) tags.push(HNS);
if (checkedRescueItems.length > 0) tags.push(RESCUE);
const sensCount = checkedSensCategoriesFull.size;
if (sensCount > 0)
tags.push({
icon: '🐟',
label: `민감자원 ${sensCount}`,
color: 'var(--color-success)',
});
return tags;
}, [
analysisActive,
viewMode,
split2Slots,
checkedPredItems.length,
checkedHnsItems.length,
checkedRescueItems.length,
checkedSensCategoriesFull,
]);
const handleCheckedPredsChange = async (
checked: Array<{ id: string; acdntSn: number; predRunSn: number | null; occurredAt: string }>,
) => {
const newEntries: Record<string, { data: TrajectoryResponse; occurredAt: string }> = {};
const newSummaries: Record<string, OilSpillSummaryResponse> = {};
await Promise.all(
checked.map(async ({ id, acdntSn, predRunSn, occurredAt }) => {
const existing = trajectoryEntries[id];
if (existing) {
newEntries[id] = existing;
if (oilSummaryEntries[id]) newSummaries[id] = oilSummaryEntries[id];
return;
}
try {
const [data, summary] = await Promise.all([
fetchAnalysisTrajectory(acdntSn, predRunSn ?? undefined),
fetchOilSpillSummary(acdntSn, predRunSn ?? undefined),
]);
newEntries[id] = { data, occurredAt };
newSummaries[id] = summary;
} catch {
/* 조용히 실패 */
}
}),
);
setTrajectoryEntries(newEntries);
setOilSummaryEntries(newSummaries);
};
const handleCheckedHnsChange = (
checked: Array<{ id: string; hnsAnlysSn: number; acdntSn: number | null }>,
) => {
setCheckedHnsIds(new Set(checked.map((h) => String(h.hnsAnlysSn))));
};
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],
);
// ── 배출 구역 경계선 레이어 ──
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]);
// ── HNS 대기확산 레이어 (히트맵 BitmapLayer + AEGL 원) ──
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hnsZoneLayers: any[] = useMemo(() => {
// null = 패널 초기화 전 → 전체 표시 / Set → 체크된 항목만
const visibleAnalyses =
checkedHnsIds === null
? hnsAnalyses
: hnsAnalyses.filter((a) => checkedHnsIds.has(String(a.hnsAnlysSn)));
return buildHnsDispersionLayers(visibleAnalyses, analysisActive);
}, [hnsAnalyses, checkedHnsIds, analysisActive]);
// ── 예측 결과 레이어 (입자 클라우드, 중심점 경로, 시간 라벨, 해안 부착 입자) ──────
// 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,
visible: analysisActive,
}),
);
});
// 해안 부착 입자: 모델별 색상 + 테두리 강조
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,
visible: analysisActive,
}),
);
});
}
// 중심점 경로선 (모델별 그룹)
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,
visible: analysisActive,
}),
);
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,
visible: analysisActive,
}),
);
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,
visible: analysisActive,
}),
);
});
}
}
return layers;
}, [trajectoryEntries, analysisActive]);
// ── 민감자원 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,
visible: analysisActive,
updateTriggers: {
getFillColor: [sensColorMap],
getLineColor: [sensColorMap],
},
});
}, [sensitiveGeojson, sensCheckedCategories, sensColorMap, analysisActive]);
const realVesselLayers = useMemo(
() =>
buildVesselLayers(
realVessels,
{
onClick: (vessel, coordinate) => {
setSelectedVessel(vessel);
setVesselPopup({
longitude: coordinate[0],
latitude: coordinate[1],
vessel,
});
setIncidentPopup(null);
setDetailVessel(null);
},
onHover: (vessel, x, y) => {
if (vessel) {
setHoverInfo({ x, y, object: vessel, type: 'vessel' });
} else {
setHoverInfo((h) => (h?.type === 'vessel' ? null : h));
}
},
},
mapZoom,
),
[realVessels, mapZoom],
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deckLayers: any[] = useMemo(
() => [
incidentLayer,
...realVesselLayers,
...dischargeZoneLayers,
...hnsZoneLayers,
...trajectoryLayers,
...(sensLayer ? [sensLayer] : []),
],
[incidentLayer, realVesselLayers, dischargeZoneLayers, hnsZoneLayers, trajectoryLayers, sensLayer],
);
return (
<div className="flex flex-1 overflow-hidden">
{/* Left Panel */}
<div className="shrink-0 overflow-hidden" style={{ width: leftCollapsed ? 0 : 360 }}>
<IncidentsLeftPanel
incidents={incidents}
selectedIncidentId={selectedIncidentId}
onIncidentSelect={setSelectedIncidentId}
onFilteredChange={setFilteredIncidents}
/>
</div>
{/* 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, color-mix(in srgb, var(--color-accent) 6%, transparent), 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: `color-mix(in srgb, ${t.color} 18%, transparent)`,
border: `1px solid color-mix(in srgb, ${t.color} 40%, transparent)`,
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
? 'color-mix(in srgb, var(--color-accent) 12%, transparent)'
: 'var(--bg-card)',
border:
viewMode === v.mode
? '1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)'
: '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: 'color-mix(in srgb, var(--color-danger) 6%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-danger) 20%, transparent)',
color: 'var(--color-danger)',
}}
>
</button>
</div>
</div>
)}
{/* Map / Analysis Content Area */}
<div className="flex-1 relative overflow-hidden">
{/* Left panel toggle button */}
<button
onClick={() => setLeftCollapsed((v) => !v)}
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
style={{
left: 0,
width: 18,
height: 40,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
borderLeft: 'none',
borderRadius: '0 6px 6px 0',
color: 'var(--fg-sub)',
cursor: 'pointer',
}}
>
{leftCollapsed ? '▶' : '◀'}
</button>
{/* Right panel toggle button */}
<button
onClick={() => setRightCollapsed((v) => !v)}
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
style={{
right: 0,
width: 18,
height: 40,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
borderRight: 'none',
borderRadius: '6px 0 0 6px',
color: 'var(--fg-sub)',
cursor: 'pointer',
}}
>
{rightCollapsed ? '◀' : '▶'}
</button>
{/* Default Map — 항상 마운트하여 모드 전환 시 pan/zoom 및 deck.gl 레이어 상태를 보존한다 */}
<div className="absolute inset-0">
<BaseMap
center={[35.0, 127.8]}
zoom={7}
cursor={measureMode !== null || dischargeMode ? 'crosshair' : undefined}
onMapClick={(lon, lat) => {
if (dischargeMode) {
const distanceNm = estimateDistanceFromCoast(lat, lon);
const zoneIndex = determineZone(lat, lon);
setDischargeInfo({ lat, lon, distanceNm, zoneIndex });
}
}}
>
<DeckGLOverlay layers={deckLayers} />
<MapBoundsTracker onBoundsChange={setMapBounds} onZoomChange={setMapZoom} />
<FlyToController incident={selectedIncident} />
{/* 사고 팝업 */}
{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>
)}
</BaseMap>
{/* 호버 툴팁 */}
{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 VesselPosition} />
) : (
<IncidentTooltipContent incident={hoverInfo.object as IncidentCompat} />
)}
</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: 180,
padding: '6px 10px',
background: 'var(--bg-base)',
border: '1px solid var(--stroke-default)',
color: dischargeMode ? 'var(--color-accent)' : 'var(--fg-disabled)',
backdropFilter: 'blur(8px)',
transition: 'color 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: 'var(--bg-base)',
border: '1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)',
color: 'var(--color-accent)',
backdropFilter: 'blur(8px)',
pointerEvents: 'none',
}}
>
📍
</div>
)}
{/* AIS Live Badge */}
<div
className="absolute top-[10px] right-[10px] z-[500] rounded-md"
style={{
background: 'var(--bg-base)',
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">AIS Live</span>
</div>
<div className="flex gap-2.5 text-caption font-mono">
<div className="text-fg-sub"> {vesselStatus?.count ?? 0}</div>
<div className="text-fg-sub"> {filteredIncidents.length}</div>
<div className="text-fg-sub"> {vesselStatus?.bangjeCount ?? 0}</div>
</div>
</div>
{/* Legend */}
<div
className="absolute bottom-[10px] left-[10px] z-[500] rounded-md flex flex-col gap-1.5"
style={{
background: 'var(--bg-base)',
border: '1px solid var(--stroke-default)',
padding: '8px 12px',
backdropFilter: 'blur(8px)',
}}
>
<div className="text-caption font-bold text-fg-sub"> </div>
<div className="grid grid-cols-3 gap-1.5">
{[
{ c: 'var(--color-danger)', l: '대응중' },
{ c: 'var(--color-warning)', l: '조사중' },
{ c: 'var(--fg-disabled)', l: '종료' },
].map((s) => (
<div key={s.l} className="flex flex-col items-center gap-0.5">
<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="grid grid-cols-3 gap-1.5">
{VESSEL_LEGEND.map((vl) => (
<div key={vl.type} className="flex flex-col items-center gap-0.5">
<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 text-center">{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="absolute inset-0 flex z-[5] bg-bg-base">
{[0, 1].map((slotIndex) => {
const slotKey = split2Slots[slotIndex];
const otherKey = split2Slots[slotIndex === 0 ? 1 : 0];
const tag = slotKey ? SLOT_TAG[slotKey] : undefined;
return (
<div
key={slotIndex}
className="flex-1 flex flex-col overflow-hidden"
style={{
borderRight:
slotIndex === 0 ? '2px solid var(--stroke-default)' : undefined,
}}
>
<div
className="flex items-center shrink-0 bg-bg-surface border-b border-stroke gap-2"
style={{
height: 32,
padding: '0 10px',
background: tag
? `linear-gradient(90deg, color-mix(in srgb, ${tag.color} 8%, transparent), var(--bg-surface))`
: undefined,
}}
>
<span
className="text-caption font-bold"
style={{ color: tag?.color ?? 'var(--fg-disabled)' }}
>
{tag ? `${tag.icon} ${tag.label}` : '분석 선택'}
</span>
<select
value={slotKey ?? ''}
onChange={(e) => {
const v = (e.target.value || null) as SplitSlotKey | null;
setSplit2Slots((prev) => {
const next: [SplitSlotKey | null, SplitSlotKey | null] = [
prev[0],
prev[1],
];
next[slotIndex] = v;
// 다른 슬롯과 중복 방지 — 중복 시 다른 슬롯을 빈 값으로
const other = slotIndex === 0 ? 1 : 0;
if (v && next[other] === v) next[other] = null;
return next;
});
}}
className="ml-auto text-caption bg-bg-base border border-stroke rounded-sm"
style={{ padding: '2px 6px', color: 'var(--fg)' }}
>
<option value=""> </option>
{(['oil', 'hns', 'rescue'] as const).map((k) => (
<option key={k} value={k} disabled={k === otherKey}>
{SLOT_TAG[k].icon} {SLOT_TAG[k].label}
</option>
))}
</select>
</div>
<div
className="flex-1 min-h-0 overflow-y-auto bg-bg-base scrollbar-thin"
style={{ padding: 12 }}
>
<div className="flex flex-col gap-2">
<SplitPanelContent
slotKey={slotKey}
incident={selectedIncident}
checkedPreds={checkedPredItems}
checkedHns={checkedHnsItems}
checkedRescues={checkedRescueItems}
sensCategories={sensCategoriesFull}
checkedSensCategories={checkedSensCategoriesFull}
trajectoryLayers={trajectoryLayers}
hnsZoneLayers={hnsZoneLayers}
sensLayer={sensLayer}
oilSummaries={oilSummaryEntries}
/>
</div>
</div>
</div>
);
})}
</div>
)}
{/* ── 3분할 View ─────────────────────────────── */}
{analysisActive && viewMode === 'split3' && (
<div className="absolute inset-0 flex z-[5] bg-bg-base">
{(['oil', 'hns', 'rescue'] as const).map((slotKey, i) => {
const tag = SLOT_TAG[slotKey];
return (
<div
key={slotKey}
className="flex-1 flex flex-col overflow-hidden"
style={{ borderRight: i < 2 ? '1px solid var(--stroke-default)' : undefined }}
>
<div
className="flex items-center shrink-0 border-b border-stroke"
style={{
height: 28,
background: `linear-gradient(90deg, color-mix(in srgb, ${tag.color} 8%, transparent), var(--bg-surface))`,
padding: '0 10px',
}}
>
<span className="text-caption font-bold" style={{ color: tag.color }}>
{tag.icon} {tag.fullLabel}
</span>
</div>
<div
className="flex-1 min-h-0 overflow-y-auto bg-bg-base scrollbar-thin"
style={{ padding: 10 }}
>
<div className="flex flex-col gap-1.5">
<SplitPanelContent
slotKey={slotKey}
incident={selectedIncident}
checkedPreds={checkedPredItems}
checkedHns={checkedHnsItems}
checkedRescues={checkedRescueItems}
sensCategories={sensCategoriesFull}
checkedSensCategories={checkedSensCategoriesFull}
trajectoryLayers={trajectoryLayers}
hnsZoneLayers={hnsZoneLayers}
sensLayer={sensLayer}
oilSummaries={oilSummaryEntries}
/>
</div>
</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: 'color-mix(in srgb, var(--color-info) 10%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-info) 20%, transparent)',
color: 'var(--color-info)',
}}
>
📋
</button>
<button
className="cursor-pointer rounded text-caption font-semibold"
style={{
padding: '4px 12px',
background: 'color-mix(in srgb, var(--color-tertiary) 10%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-tertiary) 20%, transparent)',
color: 'var(--color-tertiary)',
}}
>
🔗 R&D
</button>
</div>
</div>
)}
</div>
{/* Right Panel */}
<div className="shrink-0 overflow-hidden" style={{ width: rightCollapsed ? 0 : 280 }}>
<IncidentsRightPanel
incident={selectedIncident}
viewMode={viewMode}
onViewModeChange={setViewMode}
onRunAnalysis={handleRunAnalysis}
analysisActive={analysisActive}
onCloseAnalysis={handleCloseAnalysis}
onCheckedPredsChange={handleCheckedPredsChange}
onCheckedHnsChange={handleCheckedHnsChange}
onCheckedPredItemsChange={setCheckedPredItems}
onCheckedHnsItemsChange={setCheckedHnsItems}
onCheckedRescueItemsChange={setCheckedRescueItems}
onSensitiveCategoriesChange={(cats, checked) => {
setSensCategoriesFull(cats);
setCheckedSensCategoriesFull(checked);
}}
onSensitiveDataChange={handleSensitiveDataChange}
selectedVessel={
selectedVessel
? {
lat: selectedVessel.lat,
lng: selectedVessel.lon,
name: selectedVessel.shipNm,
}
: null
}
/>
</div>
</div>
);
}
/* ════════════════════════════════════════════════════
SplitPanelContent — 2/3분할 내부 렌더러
════════════════════════════════════════════════════ */
type SplitSlotKey = 'oil' | 'hns' | 'rescue';
const SLOT_TAG: Record<
SplitSlotKey,
{ icon: string; label: string; fullLabel: string; color: string }
> = {
oil: { icon: '🛢', label: '유출유', fullLabel: '유출유 확산예측', color: 'var(--color-warning)' },
hns: { icon: '🧪', label: 'HNS', fullLabel: 'HNS 대기확산', color: 'var(--color-tertiary)' },
rescue: { icon: '🚨', label: '구난', fullLabel: '긴급구난', color: 'var(--color-accent)' },
};
const SPLIT_CATEGORY_ICON: Record<string, string> = {
: '🐟',
: '🦪',
: '🦪',
: '🐟',
: '🦪',
: '🌿',
: '🔲',
: '🦐',
: '🐟',
: '🏖',
: '⛵',
: '🚢',
: '⛵',
: '⚓',
: '⚓',
: '⚓',
: '⚓',
: '💧',
'취수구·배수구': '🚰',
LNG: '⚡',
: '🔌',
: '🛢',
: '🪨',
_ESI: '🏖',
: '🛡',
: '🌿',
: '🐦',
: '🏖',
: '🐢',
};
const SPLIT_MOCK_FALLBACK: Record<
SplitSlotKey,
{
model: string;
items: { label: string; value: string; color?: string }[];
summary: string;
}
> = {
oil: {
model: '-',
items: [
{ label: '예측 시간', value: '-' },
{ label: '최대 확산거리', value: '-', color: 'var(--color-warning)' },
{ label: '해안 도달 시간', value: '-', color: 'var(--color-danger)' },
{ label: '영향 해안선', value: '-' },
{ label: '풍화율', value: '-' },
{ label: '잔존유량', value: '-', color: 'var(--color-warning)' },
],
summary: '-',
},
hns: {
model: '-',
items: [
{ label: 'IDLH 범위', value: '-', color: 'var(--color-danger)' },
{ label: 'ERPG-2 범위', value: '-', color: 'var(--color-warning)' },
{ label: 'ERPG-1 범위', value: '-', color: 'var(--color-caution)' },
{ label: '풍향', value: '-' },
{ label: '대기 안정도', value: '-' },
{ label: '영향 인구', value: '-', color: 'var(--color-danger)' },
],
summary: '-',
},
rescue: {
model: '-',
items: [
{ label: '95% 확률 범위', value: '-', color: 'var(--color-accent)' },
{ label: '최적 탐색 경로', value: '-' },
{ label: '예상 표류 속도', value: '-' },
{ label: '표류 방향', value: '-' },
{ label: '생존 가능 시간', value: '-', color: 'var(--color-danger)' },
{ label: '필요 자산', value: '-', color: 'var(--color-warning)' },
],
summary: '-',
},
};
function SplitPanelContent({
slotKey,
incident,
checkedPreds,
checkedHns,
checkedRescues,
sensCategories,
checkedSensCategories,
trajectoryLayers,
hnsZoneLayers,
sensLayer,
oilSummaries,
}: {
slotKey: SplitSlotKey | null;
incident: Incident | null;
checkedPreds: PredictionAnalysis[];
checkedHns: HnsAnalysisItem[];
checkedRescues: RescueOpsItem[];
sensCategories: SensitiveResourceCategory[];
checkedSensCategories: Set<string>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
trajectoryLayers: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hnsZoneLayers: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sensLayer: any;
oilSummaries: Record<string, OilSpillSummaryResponse>;
}) {
if (!slotKey) {
return (
<div className="flex-1 flex items-center justify-center text-fg-disabled text-label-2">
</div>
);
}
const tag = SLOT_TAG[slotKey];
const mock = SPLIT_MOCK_FALLBACK[slotKey];
// 슬롯별 체크된 항목 리스트 행 (우측 패널 포맷과 유사)
const listRows: { id: string; name: string; sub: string }[] =
slotKey === 'oil'
? checkedPreds.map((p) => {
const date = p.runDtm ? p.runDtm.slice(0, 10) : (p.occurredAt?.slice(0, 10) ?? '-');
const oil = p.oilType || '유출유';
const models = [
p.kospsStatus && p.kospsStatus !== 'pending' && p.kospsStatus !== 'none'
? 'KOSPS'
: null,
p.poseidonStatus && p.poseidonStatus !== 'pending' && p.poseidonStatus !== 'none'
? 'POSEIDON'
: null,
p.opendriftStatus && p.opendriftStatus !== 'pending' && p.opendriftStatus !== 'none'
? 'OpenDrift'
: null,
]
.filter(Boolean)
.join('+');
return {
id: String(p.predRunSn ?? p.acdntSn),
name: `${date} ${oil} 확산예측`.trim(),
sub: `${models || '-'}${p.volume != null ? ` · ${p.volume}kL` : ''}`,
};
})
: slotKey === 'hns'
? checkedHns.map((h) => {
const date = h.regDtm ? h.regDtm.slice(0, 10) : '-';
const sbst = h.sbstNm || 'HNS';
const sub =
[h.algoCd, h.fcstHr != null ? `${h.fcstHr}h` : null].filter(Boolean).join(' · ') ||
'-';
return {
id: String(h.hnsAnlysSn),
name: `${date} ${sbst} 대기확산`.trim(),
sub,
};
})
: checkedRescues.map((r) => {
const date = r.regDtm ? r.regDtm.slice(0, 10) : '-';
const vessel = r.vesselNm || '선박';
const sub = [r.acdntTpCd, r.commanderNm].filter(Boolean).join(' · ') || '-';
return {
id: String(r.rescueOpsSn),
name: `${date} ${vessel} 긴급구난`.trim(),
sub,
};
});
// 첫 번째 체크된 항목을 기준으로 메트릭 실제 값으로 보정
const first = listRows[0];
const items = mock.items.map((m) => {
if (!first) return m;
if (slotKey === 'oil') {
const p = checkedPreds[0];
if (!p) return m;
const summaryKey = String(p.predRunSn ?? p.acdntSn);
const oilSummary = oilSummaries[summaryKey]?.primary;
if (!oilSummary) return m;
switch (m.label) {
case '예측 시간':
return oilSummary.forecastDurationHr != null
? { ...m, value: `${oilSummary.forecastDurationHr}시간` } : m;
case '최대 확산거리':
return oilSummary.maxSpreadDistanceKm != null
? { ...m, value: `${oilSummary.maxSpreadDistanceKm.toFixed(1)} km` } : m;
case '해안 도달 시간':
return oilSummary.coastArrivalTimeHr != null
? { ...m, value: `${oilSummary.coastArrivalTimeHr}시간` } : m;
case '영향 해안선':
return oilSummary.affectedCoastlineKm != null
? { ...m, value: `${oilSummary.affectedCoastlineKm.toFixed(1)} km` } : m;
case '풍화율':
return oilSummary.weatheringRatePct != null
? { ...m, value: `${oilSummary.weatheringRatePct.toFixed(1)}%` } : m;
case '잔존유량':
return oilSummary.remainingVolumeKl != null
? { ...m, value: `${oilSummary.remainingVolumeKl.toFixed(1)} kL` } : m;
default:
return m;
}
} else if (slotKey === 'hns') {
const h = checkedHns[0];
if (!h) return m;
if (m.label === '풍향' && h.windDir) return { ...m, value: `${h.windDir}` };
}
return m;
});
const modelString =
slotKey === 'oil' && checkedPreds[0]
? `${checkedPreds[0].oilType || '-'}${checkedPreds[0].volume != null ? ` · ${checkedPreds[0].volume}kL` : ''}`
: slotKey === 'hns' && checkedHns[0]
? `${checkedHns[0].algoCd ?? '-'} · ${checkedHns[0].sbstNm ?? '-'}${checkedHns[0].spilQty != null ? ` ${checkedHns[0].spilQty}${checkedHns[0].spilUnitCd ?? ''}` : ''}`
: slotKey === 'rescue' && checkedRescues[0]
? `${checkedRescues[0].acdntTpCd ?? '-'} · ${checkedRescues[0].vesselNm ?? '-'}`
: mock.model;
return (
<>
{/* 헤더 카드 */}
<div
className="rounded-sm"
style={{
padding: '10px 12px',
background: `color-mix(in srgb, ${tag.color} 8%, transparent)`,
border: `1px solid color-mix(in srgb, ${tag.color} 20%, transparent)`,
}}
>
<div className="text-label-2 font-bold" style={{ color: tag.color, marginBottom: 4 }}>
{tag.icon} {tag.fullLabel}
</div>
<div className="text-caption text-fg-disabled font-mono">{modelString}</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">
<div
className="text-caption font-bold"
style={{
padding: '5px 10px',
background: 'var(--bg-elevated)',
borderBottom: '1px solid var(--stroke-default)',
color: tag.color,
}}
>
({listRows.length})
</div>
{listRows.length === 0 ? (
<div
className="text-caption text-fg-disabled text-center"
style={{ padding: '8px 10px' }}
>
</div>
) : (
listRows.map((r, i) => (
<div
key={r.id}
style={{
padding: '5px 10px',
borderBottom:
i < listRows.length - 1 ? '1px solid var(--stroke-default)' : 'none',
background: i % 2 === 0 ? 'var(--bg-surface)' : 'var(--bg-elevated)',
}}
>
<div className="text-caption font-semibold whitespace-nowrap overflow-hidden text-ellipsis">
{r.name}
</div>
<div className="text-caption text-fg-disabled font-mono">{r.sub}</div>
</div>
))
)}
</div>
{/* 메트릭 테이블 */}
<div className="rounded-sm border border-stroke overflow-hidden">
{items.map((item, i) => (
<div
key={i}
className="flex justify-between items-center"
style={{
padding: '6px 10px',
borderBottom: i < 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)' }}
>
{item.value || '-'}
</span>
</div>
))}
</div>
{/* 시각화 영역 — 실제 지도 캡처 (선택 분석 레이어만 표출, 4:3 고정 비율) */}
<div
className="rounded-sm bg-bg-base border border-stroke overflow-hidden relative w-full shrink-0"
style={{ aspectRatio: '4 / 3' }}
>
{incident ? (
<SplitResultMap
incident={incident}
instanceKey={slotKey}
layers={
slotKey === 'oil'
? [...trajectoryLayers, ...(sensLayer ? [sensLayer] : [])]
: slotKey === 'hns'
? hnsZoneLayers
: []
}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-caption text-fg-disabled">
</div>
)}
<div
className="absolute top-1.5 left-1.5 text-caption font-mono rounded-sm"
style={{
padding: '2px 6px',
background: 'rgba(0,0,0,0.55)',
color: tag.color,
border: `1px solid color-mix(in srgb, ${tag.color} 40%, transparent)`,
}}
>
{tag.icon} {tag.label}
</div>
</div>
{/* 민감자원 섹션 (유출유 전용) */}
{slotKey === 'oil' && (
<div className="rounded-sm border border-stroke overflow-hidden">
<div
className="text-caption font-bold"
style={{
padding: '5px 10px',
background: 'var(--bg-elevated)',
borderBottom: '1px solid var(--stroke-default)',
color: 'var(--color-success)',
}}
>
🐟 ({sensCategories.length})
</div>
{sensCategories.length === 0 ? (
<div
className="text-caption text-fg-disabled text-center"
style={{ padding: '8px 10px' }}
>
-
</div>
) : (
sensCategories.map((cat, i) => {
const areaLabel =
cat.totalArea != null
? `${cat.totalArea.toLocaleString('ko-KR', { maximumFractionDigits: 0 })}ha`
: `${cat.count}개소`;
const isChecked = checkedSensCategories.has(cat.category);
return (
<div
key={cat.category}
className="flex items-center gap-1.5"
style={{
padding: '4px 10px',
borderBottom:
i < sensCategories.length - 1
? '1px solid var(--stroke-default)'
: 'none',
background: i % 2 === 0 ? 'var(--bg-surface)' : 'var(--bg-elevated)',
opacity: isChecked ? 1 : 0.5,
}}
>
<span className="text-caption">
{SPLIT_CATEGORY_ICON[cat.category] ?? '📍'}
</span>
<span className="flex-1 text-caption">{cat.category}</span>
<span className="text-caption font-mono text-fg-disabled">{areaLabel}</span>
</div>
);
})
)}
</div>
)}
</>
);
}
/* ── 분할 패널 내부 미니 지도 — 실제 체크된 분석 레이어 표출 ────────────── */
function SplitResultMap({
incident,
layers,
instanceKey,
}: {
incident: Incident;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
layers: any[];
instanceKey: string;
}) {
const mapStyle = useBaseMapStyle();
const center: [number, number] = [incident.location.lon, incident.location.lat];
// deck.gl 레이어는 단일 Deck 인스턴스 소유를 가정 → 분할마다 고유 id로 clone
const scopedLayers = useMemo(
() =>
layers
.filter((l) => l != null)
.map((l) => (l && typeof l.clone === 'function' ? l.clone({ id: `${l.id}__${instanceKey}` }) : l)),
[layers, instanceKey],
);
return (
<MapLibreMap
key={`${incident.id}-${instanceKey}`}
initialViewState={{ longitude: center[0], latitude: center[1], zoom: 9 }}
mapStyle={mapStyle}
attributionControl={false}
style={{ width: '100%', height: '100%' }}
>
<DeckGLOverlay layers={scopedLayers} />
</MapLibreMap>
);
}
/* ── (미사용) 분석별 SVG placeholder — 참고용 보존 ────────────────── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function SplitVisualization({ slotKey, color }: { slotKey: SplitSlotKey; color: string }) {
if (slotKey === 'oil') {
return (
<svg viewBox="0 0 320 200" className="w-full h-full">
<defs>
<radialGradient id="oilSpread" cx="35%" cy="55%" r="55%">
<stop offset="0%" stopColor={color} stopOpacity="0.85" />
<stop offset="60%" stopColor={color} stopOpacity="0.35" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</radialGradient>
<linearGradient id="oilSea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#0e3a5f" />
<stop offset="100%" stopColor="#072338" />
</linearGradient>
</defs>
<rect width="320" height="200" fill="url(#oilSea)" />
{/* 해안선 */}
<path
d="M 0,20 C 60,30 120,5 180,25 C 230,40 270,20 320,35 L 320,0 L 0,0 Z"
fill="#334155"
opacity="0.9"
/>
<path
d="M 0,20 C 60,30 120,5 180,25 C 230,40 270,20 320,35"
stroke="#94a3b8"
strokeWidth="1"
fill="none"
opacity="0.8"
/>
{/* 확산 타원 */}
<ellipse cx="120" cy="120" rx="90" ry="45" fill="url(#oilSpread)" />
{/* 사고점 */}
<circle cx="120" cy="120" r="4" fill="#ffffff" />
<circle cx="120" cy="120" r="7" fill="none" stroke="#ffffff" strokeOpacity="0.6" />
{/* 궤적 화살표 */}
<path
d="M 120,120 Q 170,100 210,85 L 200,80 M 210,85 L 205,93"
stroke={color}
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
<text x="10" y="190" fill="#cbd5e1" fontSize="9" fontFamily="monospace">
(72h)
</text>
</svg>
);
}
if (slotKey === 'hns') {
return (
<svg viewBox="0 0 320 200" className="w-full h-full">
<defs>
<linearGradient id="hnsSky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#1e293b" />
<stop offset="100%" stopColor="#0f172a" />
</linearGradient>
</defs>
<rect width="320" height="200" fill="url(#hnsSky)" />
{/* AEGL 3단 동심원 (풍하방향 오프셋) */}
<ellipse cx="140" cy="110" rx="130" ry="55" fill="#fde047" fillOpacity="0.18" stroke="#fde047" strokeOpacity="0.5" />
<ellipse cx="130" cy="108" rx="90" ry="40" fill="#fb923c" fillOpacity="0.25" stroke="#fb923c" strokeOpacity="0.6" />
<ellipse cx="120" cy="106" rx="50" ry="24" fill="#ef4444" fillOpacity="0.35" stroke="#ef4444" strokeOpacity="0.75" />
{/* 누출점 */}
<circle cx="90" cy="106" r="5" fill="#ffffff" />
<circle cx="90" cy="106" r="8" fill="none" stroke="#ffffff" strokeOpacity="0.5" />
{/* 풍향 콘 */}
<path d="M 90,106 L 60,80 L 70,95 L 55,90 L 70,105 Z" fill="#38bdf8" fillOpacity="0.9" />
<text x="30" y="75" fill="#38bdf8" fontSize="10" fontFamily="monospace">
</text>
{/* 범례 */}
<g fontFamily="monospace" fontSize="9">
<circle cx="235" cy="35" r="5" fill="#ef4444" />
<text x="245" y="38" fill="#cbd5e1">IDLH</text>
<circle cx="235" cy="52" r="5" fill="#fb923c" />
<text x="245" y="55" fill="#cbd5e1">ERPG-2</text>
<circle cx="235" cy="69" r="5" fill="#fde047" />
<text x="245" y="72" fill="#cbd5e1">ERPG-1</text>
</g>
<text x="10" y="190" fill="#cbd5e1" fontSize="9" fontFamily="monospace">
HNS (AEGL )
</text>
</svg>
);
}
// rescue
return (
<svg viewBox="0 0 320 200" className="w-full h-full">
<defs>
<linearGradient id="rescueSea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#083244" />
<stop offset="100%" stopColor="#041a24" />
</linearGradient>
</defs>
<rect width="320" height="200" fill="url(#rescueSea)" />
{/* 표류 확률 타원(Monte Carlo) */}
<ellipse
cx="170"
cy="110"
rx="95"
ry="45"
fill={color}
fillOpacity="0.12"
stroke={color}
strokeOpacity="0.55"
strokeDasharray="4 3"
/>
<ellipse
cx="165"
cy="108"
rx="60"
ry="28"
fill={color}
fillOpacity="0.22"
stroke={color}
strokeOpacity="0.7"
/>
{/* Sector Search 패턴 */}
<g stroke="#ffffff" strokeOpacity="0.5" strokeWidth="1" fill="none">
<path d="M 165,108 L 215,70" />
<path d="M 215,70 L 235,120" />
<path d="M 235,120 L 175,145" />
<path d="M 175,145 L 115,125" />
<path d="M 115,125 L 135,75" />
<path d="M 135,75 L 165,108" />
</g>
{/* 사고점 */}
<circle cx="100" cy="108" r="5" fill="#ef4444" />
<circle cx="100" cy="108" r="9" fill="none" stroke="#ef4444" strokeOpacity="0.6" />
<text x="85" y="98" fill="#ef4444" fontSize="10" fontFamily="monospace">
</text>
{/* 표류 화살표 */}
<path
d="M 100,108 Q 130,100 165,108 L 155,103 M 165,108 L 156,113"
stroke={color}
strokeWidth="2"
fill="none"
strokeLinecap="round"
/>
<text x="10" y="190" fill="#cbd5e1" fontSize="9" fontFamily="monospace">
(Sector Search)
</text>
</svg>
);
}
/* ════════════════════════════════════════════════════
VesselPopupPanel / VesselDetailModal 공용 유틸
════════════════════════════════════════════════════ */
function formatDateTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '-';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function displayVal(v: unknown): string {
if (v === undefined || v === null || v === '') return '-';
return String(v);
}
function VesselPopupPanel({
vessel: v,
onClose,
onDetail,
}: {
vessel: VesselPosition;
onClose: () => void;
onDetail: () => void;
}) {
const statusText = v.status ?? '-';
const isAccident = (v.status ?? '').includes('사고');
const statusColor = isAccident ? 'var(--color-danger)' : 'var(--color-success)';
const statusBg = isAccident
? 'color-mix(in srgb, var(--color-danger) 15%, transparent)'
: 'color-mix(in srgb, var(--color-success) 10%, transparent)';
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-';
const heading = v.heading ?? v.cog;
const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-';
const receivedAt = v.lastUpdate ? formatDateTime(v.lastUpdate) : '-';
return (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%,-50%)',
zIndex: 9995,
width: 300,
background: 'rgba(13,17,23,0.97)',
border: '1px solid rgba(48,54,61,0.8)',
borderRadius: 12,
boxShadow: '0 16px 48px rgba(0,0,0,0.7)',
overflow: 'hidden',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '10px 14px',
background: 'rgba(22,27,34,0.97)',
borderBottom: '1px solid rgba(48,54,61,0.8)',
}}
>
<div
className="flex items-center justify-center text-title-2"
style={{ width: 28, height: 20 }}
>
{v.nationalCode ?? '🚢'}
</div>
<div className="flex-1 min-w-0">
<div
className="text-label-1 font-[800] whitespace-nowrap overflow-hidden text-ellipsis"
style={{ color: '#e6edf3' }}
>
{v.shipNm ?? '(이름 없음)'}
</div>
<div className="text-caption font-mono" style={{ color: '#8b949e' }}>
MMSI: {v.mmsi}
</div>
</div>
<span
onClick={onClose}
className="text-title-3 cursor-pointer p-[2px]"
style={{ color: '#8b949e' }}
>
</span>
</div>
{/* Ship Image */}
<div
className="w-full flex items-center justify-center text-[40px]"
style={{
height: 120,
background: 'rgba(22,27,34,0.97)',
borderBottom: '1px solid rgba(48,54,61,0.6)',
color: '#484f58',
}}
>
🚢
</div>
{/* Tags */}
<div
className="flex gap-2"
style={{ padding: '6px 14px', borderBottom: '1px solid rgba(48,54,61,0.6)' }}
>
<span
className="text-caption font-bold rounded text-color-info"
style={{
padding: '2px 8px',
background: 'color-mix(in srgb, var(--color-info) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-info) 25%, transparent)',
}}
>
{getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-'}
</span>
<span
className="text-caption font-bold rounded"
style={{
padding: '2px 8px',
background: statusBg,
border: `1px solid color-mix(in srgb, ${statusColor} 40%, transparent)`,
color: statusColor,
}}
>
{statusText}
</span>
</div>
{/* Data rows */}
<div style={{ padding: '4px 0' }}>
<PopupRow label="속도/항로" value={`${speed} / ${headingText}`} accent />
<PopupRow label="흘수" value={v.draught !== undefined ? `${v.draught.toFixed(2)} 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" style={{ color: '#8b949e' }}>
</span>
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
-
</span>
</div>
<div className="flex justify-between">
<span className="text-caption" style={{ color: '#8b949e' }}>
</span>
<span className="text-caption font-semibold font-mono" style={{ color: '#c9d1d9' }}>
{v.destination ?? '-'}
</span>
</div>
</div>
<PopupRow label="데이터 수신" value={receivedAt} 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"
style={{
padding: 6,
color: '#58a6ff',
background: 'rgba(88,166,255,0.12)',
border: '1px solid rgba(88,166,255,0.3)',
}}
>
📋
</button>
<button
className="flex-1 text-caption font-bold cursor-pointer text-center rounded-sm"
style={{
padding: 6,
color: '#a5d6ff',
background: 'rgba(165,214,255,0.1)',
border: '1px solid rgba(165,214,255,0.25)',
}}
>
🔍
</button>
<button
className="flex-1 text-caption font-bold cursor-pointer text-center rounded-sm"
style={{
padding: 6,
color: '#22d3ee',
background: 'rgba(34,211,238,0.1)',
border: '1px solid rgba(34,211,238,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 style={{ color: '#8b949e' }}>{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: VesselPosition;
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 bg-bg-surface border border-stroke"
style={{
width: 560,
height: '85vh',
borderRadius: 14,
boxShadow: '0 24px 64px rgba(0,0,0,0.7)',
}}
>
{/* Header */}
<div
className="shrink-0 flex items-center justify-between bg-bg-surface border-b border-stroke"
style={{ padding: '14px 18px' }}
>
<div className="flex items-center gap-[10px]">
<span className="text-lg">{v.nationalCode ?? '🚢'}</span>
<div>
<div className="text-title-3 font-[800] text-fg">
{v.shipNm ?? '(이름 없음)'}
</div>
<div className="text-caption text-fg-disabled font-mono">
MMSI: {v.mmsi} · IMO: {displayVal(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 bg-bg-base border-b border-stroke"
style={{ padding: '0 18px' }}
>
{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 ? 'var(--color-accent)' : 'var(--fg-disabled)',
borderBottom:
tab === t.key ? '2px solid var(--color-accent)' : '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: 'var(--stroke-default) 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 || 'var(--stroke-default)'}`,
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 || 'var(--bg-base)',
borderBottom: `1px solid ${borderColor || 'var(--stroke-default)'}`,
}}
>
<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 color-mix(in srgb, var(--stroke-default) 60%, transparent)',
borderRight: span
? 'none'
: '1px solid color-mix(in srgb, var(--stroke-default) 60%, transparent)',
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 || 'var(--fg)' }}>
{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-mix(in srgb, ${color} 25%, transparent)`,
color,
}}
>
{label}
</span>
);
}
/* ── Tab 0: 상세정보 ─────────────────────────────── */
function TabInfo({ v }: { v: VesselPosition }) {
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '-';
const heading = v.heading ?? v.cog;
const headingText = heading !== undefined ? `${Math.round(heading)}°` : '-';
return (
<>
<div
className="w-full rounded-lg overflow-hidden flex items-center justify-center text-[60px] text-fg-disabled bg-bg-base"
style={{ height: 160 }}
>
🚢
</div>
<Sec title="📡 실시간 현황">
<Grid>
<Cell label="선박상태" value={displayVal(v.status)} />
<Cell
label="속도 / 항로"
value={`${speed} / ${headingText}`}
color="var(--color-accent)"
/>
<Cell label="위도" value={`${v.lat.toFixed(4)}°N`} />
<Cell label="경도" value={`${v.lon.toFixed(4)}°E`} />
<Cell label="흘수" value={v.draught !== undefined ? `${v.draught} m` : '-'} />
<Cell label="수신시간" value={v.lastUpdate ? formatDateTime(v.lastUpdate) : '-'} />
</Grid>
</Sec>
<Sec title="🚢 항해 일정">
<Grid>
<Cell label="출항지" value="-" />
<Cell label="입항지" value={displayVal(v.destination)} />
<Cell label="출항일시" value="-" />
<Cell label="입항일시(ETA)" value="-" />
</Grid>
</Sec>
</>
);
}
/* ── Tab 1: 항해정보 ─────────────────────────────── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function TabNav(_props: { v: VesselPosition }) {
const hours = ['08', '09', '10', '11', '12', '13', '14'];
const heights = [45, 60, 78, 82, 70, 85, 75];
const colors = [
'color-mix(in srgb, var(--color-success) 30%, transparent)',
'color-mix(in srgb, var(--color-success) 40%, transparent)',
'color-mix(in srgb, var(--color-info) 40%, transparent)',
'color-mix(in srgb, var(--color-info) 50%, transparent)',
'color-mix(in srgb, var(--color-info) 50%, transparent)',
'color-mix(in srgb, var(--color-info) 60%, transparent)',
'color-mix(in srgb, var(--color-accent) 50%, transparent)',
];
return (
<>
<Sec title="🗺 최근 항적 (24h)">
<div
className="flex items-center justify-center relative overflow-hidden bg-bg-base"
style={{ height: 180 }}
>
<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="var(--color-accent)"
strokeWidth="2"
strokeDasharray="6,3"
opacity=".6"
/>
<circle cx="50" cy="150" r="4" fill="var(--fg-disabled)" />
<circle cx="160" cy="95" r="3" fill="var(--color-accent)" opacity=".5" />
<circle cx="280" cy="50" r="3" fill="var(--color-accent)" opacity=".5" />
<circle cx="370" cy="20" r="5" fill="var(--color-accent)" />
<text
x="45"
y="168"
fill="var(--fg-disabled)"
fontSize="9"
fontFamily="var(--font-mono)"
>
08:00
</text>
<text
x="150"
y="113"
fill="var(--fg-disabled)"
fontSize="9"
fontFamily="var(--font-mono)"
>
10:30
</text>
<text
x="270"
y="68"
fill="var(--fg-disabled)"
fontSize="9"
fontFamily="var(--font-mono)"
>
12:45
</text>
<text
x="350"
y="16"
fill="var(--color-accent)"
fontSize="9"
fontFamily="var(--font-mono)"
>
</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="color-mix(in srgb, var(--color-tertiary) 10%, transparent)"
bd="color-mix(in srgb, var(--color-tertiary) 25%, transparent)"
fg="var(--color-tertiary)"
/>
<ActionBtn
icon="📐"
label="항로 예측"
bg="color-mix(in srgb, var(--color-accent) 10%, transparent)"
bd="color-mix(in srgb, var(--color-accent) 25%, transparent)"
fg="var(--color-accent)"
/>
</div>
</>
);
}
/* ── Tab 2: 선박제원 ─────────────────────────────── */
function TabSpec({ v }: { v: VesselPosition }) {
const loa = v.length !== undefined ? `${v.length} m` : '-';
const beam = v.width !== undefined ? `${v.width} m` : '-';
return (
<>
<Sec title="📐 선체 제원">
<Grid>
<Cell label="선종" value={displayVal(getShipKindLabel(v.shipKindCode) ?? v.shipTy)} />
<Cell label="선적국" value={displayVal(v.nationalCode)} />
<Cell label="총톤수 (GT)" value="-" />
<Cell label="재화중량 (DWT)" value="-" />
<Cell label="전장 (LOA)" value={loa} />
<Cell label="선폭" value={beam} />
<Cell label="건조년도" value="-" />
<Cell label="건조 조선소" value="-" />
</Grid>
</Sec>
<Sec title="📡 통신 / 식별">
<Grid>
<Cell label="MMSI" value={String(v.mmsi)} />
<Cell label="IMO" value={displayVal(v.imo)} />
<Cell label="호출부호" value="-" />
<Cell label="선급" value="-" />
</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: 'color-mix(in srgb, var(--color-danger) 6%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-danger) 12%, transparent)',
}}
>
<span className="text-label-1">🛢</span>
<div className="flex-1">
<div className="text-caption font-semibold text-fg">-</div>
<div className="text-caption text-fg-disabled"> </div>
</div>
</div>
</div>
</Sec>
</>
);
}
/* ── Tab 3: 보험정보 ─────────────────────────────── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function TabInsurance(_props: { v: VesselPosition }) {
return (
<>
<Sec title="🏢 선주 / 운항사">
<Grid>
<Cell label="선주" value="대한해운(주)" />
<Cell label="운항사" value="대한해운(주)" />
<Cell label="P&I Club" value="한국선주상호보험" span />
</Grid>
</Sec>
<Sec
title="🚢 선체보험 (H&M)"
borderColor="color-mix(in srgb, var(--color-accent) 20%, transparent)"
bgColor="color-mix(in srgb, var(--color-accent) 6%, transparent)"
badge={<StatusBadge label="유효" color="var(--color-success)" />}
>
<Grid>
<Cell label="보험사" value="삼성화재해상보험" />
<Cell label="보험가액" value="USD 28,500,000" color="var(--color-accent)" />
<Cell label="보험기간" value="2025.01 ~ 2026.01" color="var(--color-success)" />
<Cell label="면책금" value="USD 150,000" />
</Grid>
</Sec>
<Sec
title="📦 화물보험 (Cargo)"
borderColor="color-mix(in srgb, var(--color-tertiary) 20%, transparent)"
bgColor="color-mix(in srgb, var(--color-tertiary) 6%, transparent)"
badge={<StatusBadge label="유효" color="var(--color-success)" />}
>
<Grid>
<Cell label="보험사" value="DB손해보험" />
<Cell label="보험가액" value="USD 42,100,000" color="var(--color-tertiary)" />
<Cell label="적하물" value="벙커C유 72,850톤" />
<Cell label="조건" value="ICC(A) All Risks" />
</Grid>
</Sec>
<Sec
title="🛢 유류오염배상 (CLC/IOPC)"
borderColor="color-mix(in srgb, var(--color-danger) 20%, transparent)"
bgColor="color-mix(in srgb, var(--color-danger) 6%, transparent)"
badge={<StatusBadge label="유효" color="var(--color-success)" />}
>
<Grid>
<Cell label="배상보증서" value="유효 (2025-12-31)" color="var(--color-success)" />
<Cell label="발급기관" value="한국선주상호보험" />
<Cell label="CLC 한도" value="89.77M SDR" color="var(--color-danger)" />
<Cell label="IOPC 기금" value="203M SDR" />
<Cell label="추가기금" value="750M SDR" />
<Cell label="총 배상한도" value="약 1,042.77M SDR" color="var(--color-warning)" />
</Grid>
</Sec>
<div
className="rounded-sm text-caption text-fg-disabled"
style={{
padding: '8px 10px',
background: 'color-mix(in srgb, var(--color-info) 4%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-info) 10%, transparent)',
lineHeight: 1.6,
}}
>
💡 (KSA) Open API .
주기: 24시간
</div>
</>
);
}
/* ── Tab 4: 위험물정보 ───────────────────────────── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function TabDangerous(_props: { v: VesselPosition }) {
return (
<>
<Sec
title="⚠ 위험물 화물 신고정보"
bgColor="color-mix(in srgb, var(--color-warning) 6%, transparent)"
badge={
<span
className="text-caption font-bold text-color-danger"
style={{
padding: '2px 6px',
background: 'color-mix(in srgb, var(--color-danger) 15%, transparent)',
borderRadius: 8,
}}
>
PORT-MIS
</span>
}
>
<Grid>
<Cell label="화물명" value="-" color="var(--color-warning)" />
<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 var(--color-accent)',
}}
>
</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: 'color-mix(in srgb, var(--color-info) 10%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-info) 20%, transparent)',
}}
>
📎 []
</button>
</div>
</Sec>
<Sec
title="🔥 IMO 위험물 분류"
borderColor="color-mix(in srgb, var(--color-danger) 20%, transparent)"
bgColor="color-mix(in srgb, var(--color-danger) 6%, transparent)"
>
<Grid>
<Cell label="IMO Class" value="Class 3" color="var(--color-danger)" />
<Cell label="분류" value="인화성 액체" />
<Cell label="UN No." value="UN 1993" />
<Cell label="포장등급" value="III" />
<Cell label="인화점" value="60°C 이상" color="var(--color-warning)" />
<Cell label="해양오염물질" value="해당 (P)" color="var(--color-danger)" />
</Grid>
</Sec>
<Sec
title="🚨 비상 대응 요약 (EmS)"
bgColor="color-mix(in srgb, var(--color-caution) 6%, transparent)"
>
<div className="flex flex-col gap-1.5 bg-bg-base" style={{ padding: '10px 12px' }}>
<EmsRow
icon="🔥"
label="화재시"
value="포말소화제, CO₂ 소화기 사용 · 물분무 냉각"
bg="color-mix(in srgb, var(--color-danger) 5%, transparent)"
bd="color-mix(in srgb, var(--color-danger) 12%, transparent)"
/>
<EmsRow
icon="🌊"
label="유출시"
value="오일펜스 전개 · 유흡착재 투입 · 해상 기름 회수"
bg="color-mix(in srgb, var(--color-info) 5%, transparent)"
bd="color-mix(in srgb, var(--color-info) 12%, transparent)"
/>
<EmsRow
icon="🫁"
label="보호장비"
value="내화학장갑, 보안경, 방독마스크 · 레벨C 보호복"
bg="color-mix(in srgb, var(--color-tertiary) 5%, transparent)"
bd="color-mix(in srgb, var(--color-tertiary) 12%, transparent)"
/>
</div>
</Sec>
<div
className="rounded-sm text-caption text-fg-disabled"
style={{
padding: '8px 10px',
background: 'color-mix(in srgb, var(--color-warning) 4%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-warning) 10%, transparent)',
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: VesselPosition }) {
const speed = v.sog !== undefined ? `${v.sog.toFixed(1)} kn` : '- kn';
const heading = v.heading ?? v.cog;
const headingText = heading !== undefined ? `HDG ${Math.round(heading)}°` : 'HDG -';
const typeText = [getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-', v.nationalCode].filter(Boolean).join(' · ');
return (
<>
<div className="text-label-2 font-bold text-fg" style={{ marginBottom: 3 }}>
{v.shipNm ?? '(이름 없음)'}
</div>
<div className="text-caption text-fg-disabled" style={{ marginBottom: 4 }}>
{typeText}
</div>
<div className="flex justify-between text-caption">
<span className="text-color-accent font-semibold">{speed}</span>
<span className="text-fg-disabled">{headingText}</span>
</div>
</>
);
}
function IncidentTooltipContent({ incident: i }: { incident: IncidentCompat }) {
const statusColor =
i.status === 'active'
? 'var(--color-danger)'
: i.status === 'investigating'
? 'var(--color-warning)'
: 'var(--fg-disabled)';
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>
</>
);
}