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(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([]); const [filteredIncidents, setFilteredIncidents] = useState([]); const [selectedIncidentId, setSelectedIncidentId] = useState(null); const [selectedVessel, setSelectedVessel] = useState(null); const [detailVessel, setDetailVessel] = useState(null); const [vesselPopup, setVesselPopup] = useState(null); const [incidentPopup, setIncidentPopup] = useState(null); const [hoverInfo, setHoverInfo] = useState(null); const [mapBounds, setMapBounds] = useState(null); const [mapZoom, setMapZoom] = useState(10); const realVessels = useVesselSignals(mapBounds); const [vesselStatus, setVesselStatus] = useState(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('overlay'); const [analysisActive, setAnalysisActive] = useState(true); // 분할 뷰에서 사용할 체크된 원본 아이템들 (우측 패널에서 주입) const [checkedPredItems, setCheckedPredItems] = useState([]); const [checkedHnsItems, setCheckedHnsItems] = useState([]); const [checkedRescueItems, setCheckedRescueItems] = useState([]); const [sensCategoriesFull, setSensCategoriesFull] = useState([]); const [checkedSensCategoriesFull, setCheckedSensCategoriesFull] = useState>( new Set(), ); // 2분할 좌/우 슬롯에 표시할 분석 종류 const [split2Slots, setSplit2Slots] = useState<[SplitSlotKey | null, SplitSlotKey | null]>([ null, null, ]); // 예측 trajectory & 민감자원 지도 표출 const [trajectoryEntries, setTrajectoryEntries] = useState< Record >({}); // 유출유 확산 요약 (분할 패널용) const [oilSummaryEntries, setOilSummaryEntries] = useState< Record >({}); // HNS 대기확산 분석 (선택 사고에 연결된 완료 분석들) const [hnsAnalyses, setHnsAnalyses] = useState([]); // null = 아직 패널에서 전달되지 않음 → 전체 표시 / Set = 체크된 항목만 표시 const [checkedHnsIds, setCheckedHnsIds] = useState | null>(null); const [sensitiveGeojson, setSensitiveGeojson] = useState(null); const [sensCheckedCategories, setSensCheckedCategories] = useState>(new Set()); const [sensColorMap, setSensColorMap] = useState>( 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 = {}; const newSummaries: Record = {}; 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, categoryOrder: string[], ) => { setSensitiveGeojson(geojson); setSensCheckedCategories(checkedCategories); const colorMap = new Map(); 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 = { 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 = {}; 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 = {}; 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 = {}; 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)?.['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 }) => { const color = sensColorMap.get((f.properties['category'] as string) ?? '') ?? [ 128, 128, 128, ]; return [...color, 60] as [number, number, number, number]; }, getLineColor: (f: { properties: Record }) => { 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 (
{/* Left Panel */}
{/* Center - Map + Analysis Views */}
{/* Analysis Bar */} {analysisActive && (
🔬 통합 분석 비교 {selectedIncident?.name}
{analysisTags.map((t, i) => ( {t.icon} {t.label} ))}
{( [ { mode: 'overlay' as ViewMode, icon: '🗂', label: '오버레이' }, { mode: 'split2' as ViewMode, icon: '◫', label: '2분할' }, { mode: 'split3' as ViewMode, icon: '⊞', label: '3분할' }, ] as const ).map((v) => ( ))}
)} {/* Map / Analysis Content Area */}
{/* Left panel toggle button */} {/* Right panel toggle button */} {/* Default Map — 항상 마운트하여 모드 전환 시 pan/zoom 및 deck.gl 레이어 상태를 보존한다 */}
{ if (dischargeMode) { const distanceNm = estimateDistanceFromCoast(lat, lon); const zoneIndex = determineZone(lat, lon); setDischargeInfo({ lat, lon, distanceNm, zoneIndex }); } }} > {/* 사고 팝업 */} {incidentPopup && ( setIncidentPopup(null)} closeButton={false} closeOnClick={false} className="incident-popup" maxWidth="none" > setIncidentPopup(null)} /> )} {/* 호버 툴팁 */} {hoverInfo && (
{hoverInfo.type === 'vessel' ? ( ) : ( )}
)} {/* 오염물 배출 규정 토글 */} {/* 오염물 배출 규정 패널 */} {dischargeMode && dischargeInfo && ( setDischargeInfo(null)} /> )} {/* 배출규정 모드 안내 */} {dischargeMode && !dischargeInfo && (
📍 지도를 클릭하여 배출 규정을 확인하세요
)} {/* AIS Live Badge */}
{/*
*/} AIS Live
선박 {vesselStatus?.count ?? 0}
사고 {filteredIncidents.length}
방제선 {vesselStatus?.bangjeCount ?? 0}
{/* Legend */}
사고 상태
{[ { c: 'var(--color-danger)', l: '대응중' }, { c: 'var(--color-warning)', l: '조사중' }, { c: 'var(--fg-disabled)', l: '종료' }, ].map((s) => (
{s.l}
))}
AIS 선박
{VESSEL_LEGEND.map((vl) => (
{vl.type}
))}
{/* 선박 팝업 패널 */} {vesselPopup && selectedVessel && !detailVessel && ( { setVesselPopup(null); setSelectedVessel(null); }} onDetail={() => { setDetailVessel(selectedVessel); setVesselPopup(null); setSelectedVessel(null); }} /> )} {detailVessel && ( setDetailVessel(null)} /> )}
{/* ── 2분할 View ─────────────────────────────── */} {analysisActive && viewMode === 'split2' && (
{[0, 1].map((slotIndex) => { const slotKey = split2Slots[slotIndex]; const otherKey = split2Slots[slotIndex === 0 ? 1 : 0]; const tag = slotKey ? SLOT_TAG[slotKey] : undefined; return (
{tag ? `${tag.icon} ${tag.label}` : '분석 선택'}
); })}
)} {/* ── 3분할 View ─────────────────────────────── */} {analysisActive && viewMode === 'split3' && (
{(['oil', 'hns', 'rescue'] as const).map((slotKey, i) => { const tag = SLOT_TAG[slotKey]; return (
{tag.icon} {tag.fullLabel}
); })}
)}
{/* Decision Bar */} {analysisActive && (
📊 {selectedIncident?.name} · {analysisTags.map((t) => t.label).join(' + ')} 분석 결과 비교
)}
{/* Right Panel */}
{ setSensCategoriesFull(cats); setCheckedSensCategoriesFull(checked); }} onSensitiveDataChange={handleSensitiveDataChange} selectedVessel={ selectedVessel ? { lat: selectedVessel.lat, lng: selectedVessel.lon, name: selectedVessel.shipNm, } : null } />
); } /* ════════════════════════════════════════════════════ 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 = { 어장정보: '🐟', 양식장: '🦪', 양식어업: '🦪', 어류양식장: '🐟', 패류양식장: '🦪', 해조류양식장: '🌿', 가두리양식장: '🔲', 갑각류양식장: '🦐', 수산시장: '🐟', 해수욕장: '🏖', 마리나항: '⛵', 무역항: '🚢', 연안항: '⛵', 국가어항: '⚓', 지방어항: '⚓', 어항: '⚓', 항만구역: '⚓', 해수취수시설: '💧', '취수구·배수구': '🚰', 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; // 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; }) { if (!slotKey) { return (
분석 결과를 선택하세요
); } 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 ( <> {/* 헤더 카드 */}
{tag.icon} {tag.fullLabel} 결과
{modelString}
{incident && (
사고: {incident.name} · {incident.date} {incident.time}
)}
{/* 체크된 분석 목록 */}
선택된 분석 ({listRows.length})
{listRows.length === 0 ? (
선택된 분석이 없습니다
) : ( listRows.map((r, i) => (
{r.name}
{r.sub}
)) )}
{/* 메트릭 테이블 */}
{items.map((item, i) => (
{item.label} {item.value || '-'}
))}
{/* 시각화 영역 — 실제 지도 캡처 (선택 분석 레이어만 표출, 4:3 고정 비율) */}
{incident ? ( ) : (
사고를 선택하세요
)}
{tag.icon} {tag.label} 지도
{/* 민감자원 섹션 (유출유 전용) */} {slotKey === 'oil' && (
🐟 민감자원 ({sensCategories.length})
{sensCategories.length === 0 ? (
-
) : ( 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 (
{SPLIT_CATEGORY_ICON[cat.category] ?? '📍'} {cat.category} {areaLabel}
); }) )}
)} ); } /* ── 분할 패널 내부 미니 지도 — 실제 체크된 분석 레이어 표출 ────────────── */ 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 ( ); } /* ── (미사용) 분석별 SVG placeholder — 참고용 보존 ────────────────── */ // eslint-disable-next-line @typescript-eslint/no-unused-vars function SplitVisualization({ slotKey, color }: { slotKey: SplitSlotKey; color: string }) { if (slotKey === 'oil') { return ( {/* 해안선 */} {/* 확산 타원 */} {/* 사고점 */} {/* 궤적 화살표 */} 유출유 확산 시뮬레이션 (72h) ); } if (slotKey === 'hns') { return ( {/* AEGL 3단 동심원 (풍하방향 오프셋) */} {/* 누출점 */} {/* 풍향 콘 */} 풍향 {/* 범례 */} IDLH ERPG-2 ERPG-1 HNS 대기 확산 (AEGL 등급) ); } // rescue return ( {/* 표류 확률 타원(Monte Carlo) */} {/* Sector Search 패턴 */} {/* 사고점 */} 사고점 {/* 표류 화살표 */} 구조 시나리오 (Sector Search) ); } /* ════════════════════════════════════════════════════ 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 (
{/* Header */}
{v.nationalCode ?? '🚢'}
{v.shipNm ?? '(이름 없음)'}
MMSI: {v.mmsi}
{/* Ship Image */}
🚢
{/* Tags */}
{getShipKindLabel(v.shipKindCode) ?? v.shipTy ?? '-'} {statusText}
{/* Data rows */}
출항지 -
입항지 {v.destination ?? '-'}
{/* Buttons */}
); } function PopupRow({ label, value, accent, muted, }: { label: string; value: string; accent?: boolean; muted?: boolean; }) { return (
{label} {value}
); } /* ════════════════════════════════════════════════════ IncidentPopupContent – 사고 마커 클릭 팝업 ════════════════════════════════════════════════════ */ function IncidentPopupContent({ incident: inc, onClose, }: { incident: IncidentCompat; onClose: () => void; }) { const dotColor: Record = { active: 'var(--color-danger)', investigating: 'var(--color-warning)', closed: 'var(--fg-disabled)', }; const stBg: Record = { active: 'rgba(239,68,68,0.15)', investigating: 'rgba(249,115,22,0.15)', closed: 'rgba(100,116,139,0.15)', }; const stColor: Record = { active: 'var(--color-danger)', investigating: 'var(--color-warning)', closed: 'var(--fg-disabled)', }; return (
{/* Header */}
{inc.name}
{/* Tags */}
{getStatusLabel(inc.status)} {inc.causeType && ( {inc.causeType} )} {inc.oilType && ( {inc.oilType} )}
{/* Info rows */}
일시 {inc.date} {inc.time}
관할 {inc.office}
지역 {inc.region}
{/* Prediction badge */} {inc.prediction && (
{inc.prediction}
)}
); } /* ════════════════════════════════════════════════════ 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('info'); return (
{ 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)', }} >
{/* Header */}
{v.nationalCode ?? '🚢'}
{v.shipNm ?? '(이름 없음)'}
MMSI: {v.mmsi} · IMO: {displayVal(v.imo)}
{/* Tabs */}
{TAB_LABELS.map((t) => ( ))}
{/* Body */}
{tab === 'info' && } {tab === 'nav' && } {tab === 'spec' && } {tab === 'ins' && } {tab === 'dg' && }
); } /* ── shared section helpers ──────────────────────── */ function Sec({ title, borderColor, bgColor, badge, children, }: { title: string; borderColor?: string; bgColor?: string; badge?: React.ReactNode; children: React.ReactNode; }) { return (
{title} {badge}
{children}
); } function Grid({ children }: { children: React.ReactNode }) { return
{children}
; } function Cell({ label, value, span, color, }: { label: string; value: string; span?: boolean; color?: string; }) { return (
{label}
{value}
); } function StatusBadge({ label, color }: { label: string; color: string }) { return ( {label} ); } /* ── 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 ( <>
🚢
); } /* ── 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 ( <>
08:00 10:30 12:45 현재
{hours.map((h, i) => (
{h}
))}
평균: 8.4 kn · 최대:{' '} 11.2 kn
); } /* ── Tab 2: 선박제원 ─────────────────────────────── */ function TabSpec({ v }: { v: VesselPosition }) { const loa = v.length !== undefined ? `${v.length} m` : '-'; const beam = v.width !== undefined ? `${v.width} m` : '-'; return ( <>
🛢
-
정보 없음
); } /* ── Tab 3: 보험정보 ─────────────────────────────── */ // eslint-disable-next-line @typescript-eslint/no-unused-vars function TabInsurance(_props: { v: VesselPosition }) { return ( <> } > } > } >
💡 보험정보는 한국해운조합(KSA) Open API 및 해양수산부 선박정보시스템 연동 데이터입니다. 실시간 갱신 주기: 24시간
); } /* ── Tab 4: 위험물정보 ───────────────────────────── */ // eslint-disable-next-line @typescript-eslint/no-unused-vars function TabDangerous(_props: { v: VesselPosition }) { return ( <> PORT-MIS } >
화물창 2개이상 여부
💡 위험물정보는 PORT-MIS(항만운영정보시스템) 위험물 반입신고 데이터 연동입니다. IMDG Code 최신 개정판(Amendment 42-24) 기준.
); } function EmsRow({ icon, label, value, bg, bd, }: { icon: string; label: string; value: string; bg: string; bd: string; }) { return (
{icon}
{label}
{value}
); } function ActionBtn({ icon, label, bg, bd, fg, }: { icon: string; label: string; bg: string; bd: string; fg: string; }) { return ( ); } /* ════════════════════════════════════════════════════ 호버 툴팁 컴포넌트 ════════════════════════════════════════════════════ */ 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 ( <>
{v.shipNm ?? '(이름 없음)'}
{typeText}
{speed} {headingText}
); } function IncidentTooltipContent({ incident: i }: { incident: IncidentCompat }) { const statusColor = i.status === 'active' ? 'var(--color-danger)' : i.status === 'investigating' ? 'var(--color-warning)' : 'var(--fg-disabled)'; return ( <>
{i.name}
{i.date} {i.time}
{getStatusLabel(i.status)} {i.location.lat.toFixed(3)}°N, {i.location.lon.toFixed(3)}°E
); }