- Incidents 통합 분석 시 이전 분석 결과를 분할 화면으로 표출 - 유출유/HNS/구난 분석 선택 모달(AnalysisSelectModal) 추가 - prediction /analyses/:acdntSn/oil-summary API 신규 (primary + byModel) - HNS 분석 생성 시 acdntSn 연결 지원 - GSC 사고 목록 응답에 acdntSn 노출 - 민감자원 누적/카테고리 관리 및 HNS 확산 레이어 유틸(hnsDispersionLayers) 추가
2904 lines
105 KiB
TypeScript
Executable File
2904 lines
105 KiB
TypeScript
Executable File
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>
|
||
</>
|
||
);
|
||
}
|