2378 lines
85 KiB
TypeScript
Executable File
2378 lines
85 KiB
TypeScript
Executable File
import { useState, useEffect, useMemo, useRef } from 'react';
|
||
import { Popup, useMap } from '@vis.gl/react-maplibre';
|
||
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 { fetchAnalysisTrajectory } from '@tabs/prediction/services/predictionApi';
|
||
import type {
|
||
TrajectoryResponse,
|
||
SensitiveResourceFeatureCollection,
|
||
} from '@tabs/prediction/services/predictionApi';
|
||
import { DischargeZonePanel } from './DischargeZonePanel';
|
||
import {
|
||
estimateDistanceFromCoast,
|
||
determineZone,
|
||
getDischargeZoneLines,
|
||
loadTerritorialBaseline,
|
||
getCachedBaseline,
|
||
loadZoneGeoJSON,
|
||
getCachedZones,
|
||
} from '../utils/dischargeZoneData';
|
||
import { useMapStore } from '@common/store/mapStore';
|
||
|
||
// ── 민감자원 카테고리별 색상 (목록 순서 인덱스 기반 — 중복 없음) ────────────
|
||
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(false);
|
||
const [analysisTags, setAnalysisTags] = useState<
|
||
{ icon: string; label: string; color: string }[]
|
||
>([]);
|
||
|
||
// 예측 trajectory & 민감자원 지도 표출
|
||
const [trajectoryEntries, setTrajectoryEntries] = useState<
|
||
Record<string, { data: TrajectoryResponse; occurredAt: string }>
|
||
>({});
|
||
const [sensitiveGeojson, setSensitiveGeojson] =
|
||
useState<SensitiveResourceFeatureCollection | null>(null);
|
||
const [sensCheckedCategories, setSensCheckedCategories] = useState<Set<string>>(new Set());
|
||
const [sensColorMap, setSensColorMap] = useState<Map<string, [number, number, number]>>(
|
||
new Map(),
|
||
);
|
||
|
||
useEffect(() => {
|
||
fetchIncidents().then((data) => {
|
||
setIncidents(data);
|
||
});
|
||
Promise.all([loadTerritorialBaseline(), loadZoneGeoJSON()]).then(() => setBaselineLoaded(true));
|
||
}, []);
|
||
|
||
// 사고 전환 시 지도 레이어 즉시 초기화
|
||
useEffect(() => {
|
||
setTrajectoryEntries({});
|
||
setSensitiveGeojson(null);
|
||
setSensCheckedCategories(new Set());
|
||
setSensColorMap(new Map());
|
||
}, [selectedIncidentId]);
|
||
|
||
const selectedIncident = incidents.find((i) => i.id === selectedIncidentId) ?? null;
|
||
|
||
const handleRunAnalysis = (sections: AnalysisSection[], sensitiveCount: number) => {
|
||
if (sections.length === 0) return;
|
||
const tags: { icon: string; label: string; color: string }[] = [];
|
||
sections.forEach((s) => {
|
||
if (s.key === 'oil')
|
||
tags.push({ icon: '🛢', label: '유출유', color: 'var(--color-warning)' });
|
||
if (s.key === 'hns') tags.push({ icon: '🧪', label: 'HNS', color: 'var(--color-tertiary)' });
|
||
if (s.key === 'rsc') tags.push({ icon: '🚨', label: '구난', color: 'var(--color-accent)' });
|
||
});
|
||
if (sensitiveCount > 0)
|
||
tags.push({
|
||
icon: '🐟',
|
||
label: `민감자원 ${sensitiveCount}건`,
|
||
color: 'var(--color-success)',
|
||
});
|
||
setAnalysisTags(tags);
|
||
setAnalysisActive(true);
|
||
};
|
||
|
||
const handleCloseAnalysis = () => {
|
||
setAnalysisActive(false);
|
||
setAnalysisTags([]);
|
||
};
|
||
|
||
const handleCheckedPredsChange = async (
|
||
checked: Array<{ id: string; acdntSn: number; predRunSn: number | null; occurredAt: string }>,
|
||
) => {
|
||
const newEntries: Record<string, { data: TrajectoryResponse; occurredAt: string }> = {};
|
||
await Promise.all(
|
||
checked.map(async ({ id, acdntSn, predRunSn, occurredAt }) => {
|
||
const existing = trajectoryEntries[id];
|
||
if (existing) {
|
||
newEntries[id] = existing;
|
||
return;
|
||
}
|
||
try {
|
||
const data = await fetchAnalysisTrajectory(acdntSn, predRunSn ?? undefined);
|
||
newEntries[id] = { data, occurredAt };
|
||
} catch {
|
||
/* 조용히 실패 */
|
||
}
|
||
}),
|
||
);
|
||
setTrajectoryEntries(newEntries);
|
||
};
|
||
|
||
const handleSensitiveDataChange = (
|
||
geojson: SensitiveResourceFeatureCollection | null,
|
||
checkedCategories: Set<string>,
|
||
categoryOrder: string[],
|
||
) => {
|
||
setSensitiveGeojson(geojson);
|
||
setSensCheckedCategories(checkedCategories);
|
||
const colorMap = new Map<string, [number, number, number]>();
|
||
categoryOrder.forEach((cat, i) => colorMap.set(cat, getCategoryColor(i)));
|
||
setSensColorMap(colorMap);
|
||
};
|
||
|
||
// ── 사고 마커 (ScatterplotLayer) ──────────────────────
|
||
const incidentLayer = useMemo(
|
||
() =>
|
||
new ScatterplotLayer({
|
||
id: 'incidents',
|
||
data: filteredIncidents,
|
||
getPosition: (d: IncidentCompat) => [d.location.lon, d.location.lat],
|
||
getRadius: (d: IncidentCompat) => (selectedIncidentId === d.id ? 16 : 12),
|
||
getFillColor: (d: IncidentCompat) => getMarkerColor(d.status),
|
||
getLineColor: (d: IncidentCompat) =>
|
||
selectedIncidentId === d.id ? [6, 182, 212, 255] : getMarkerStroke(d.status),
|
||
getLineWidth: (d: IncidentCompat) => (selectedIncidentId === d.id ? 3 : 2),
|
||
stroked: true,
|
||
radiusMinPixels: 6,
|
||
radiusMaxPixels: 20,
|
||
radiusUnits: 'pixels',
|
||
pickable: true,
|
||
onClick: (info: { object?: IncidentCompat; coordinate?: number[] }) => {
|
||
if (info.object && info.coordinate) {
|
||
const newId = selectedIncidentId === info.object.id ? null : info.object.id;
|
||
setSelectedIncidentId(newId);
|
||
if (newId) {
|
||
setIncidentPopup({
|
||
longitude: info.coordinate[0],
|
||
latitude: info.coordinate[1],
|
||
incident: info.object,
|
||
});
|
||
} else {
|
||
setIncidentPopup(null);
|
||
}
|
||
setVesselPopup(null);
|
||
}
|
||
},
|
||
onHover: (info: { object?: IncidentCompat; x?: number; y?: number }) => {
|
||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||
setHoverInfo({ x: info.x, y: info.y, object: info.object, type: 'incident' });
|
||
} else {
|
||
setHoverInfo((h) => (h?.type === 'incident' ? null : h));
|
||
}
|
||
},
|
||
updateTriggers: {
|
||
getRadius: [selectedIncidentId],
|
||
getLineColor: [selectedIncidentId],
|
||
getLineWidth: [selectedIncidentId],
|
||
},
|
||
}),
|
||
[filteredIncidents, selectedIncidentId],
|
||
);
|
||
|
||
|
||
// ── 배출 구역 경계선 레이어 ──
|
||
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]);
|
||
|
||
// ── 예측 결과 레이어 (입자 클라우드, 중심점 경로, 시간 라벨, 해안 부착 입자) ──────
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const trajectoryLayers: any[] = useMemo(() => {
|
||
const layers: unknown[] = [];
|
||
|
||
// 모델별 색상 (prediction 탭과 동일)
|
||
const MODEL_COLORS: Record<string, [number, number, number]> = {
|
||
KOSPS: [6, 182, 212], // cyan
|
||
POSEIDON: [239, 68, 68], // red
|
||
OpenDrift: [59, 130, 246], // blue
|
||
default: [249, 115, 22], // orange
|
||
};
|
||
|
||
const pad = (n: number) => String(n).padStart(2, '0');
|
||
|
||
let runIdx = 0;
|
||
for (const [runId, entry] of Object.entries(trajectoryEntries)) {
|
||
const { data: traj, occurredAt } = entry;
|
||
const { trajectory, centerPoints } = traj;
|
||
const startDt = new Date(occurredAt);
|
||
runIdx++;
|
||
|
||
if (trajectory && trajectory.length > 0) {
|
||
const maxTime = Math.max(...trajectory.map((p) => p.time));
|
||
|
||
// 최종 스텝 부유 입자: 모델별로 그룹핑하여 각각 다른 색
|
||
const lastStepByModel: Record<string, typeof trajectory> = {};
|
||
trajectory.forEach((p) => {
|
||
if (p.time === maxTime && p.stranded !== 1) {
|
||
const m = p.model ?? 'default';
|
||
if (!lastStepByModel[m]) lastStepByModel[m] = [];
|
||
lastStepByModel[m].push(p);
|
||
}
|
||
});
|
||
Object.entries(lastStepByModel).forEach(([model, particles]) => {
|
||
const color = MODEL_COLORS[model] ?? MODEL_COLORS['default'];
|
||
layers.push(
|
||
new ScatterplotLayer({
|
||
id: `traj-particles-${runId}-${model}`,
|
||
data: particles,
|
||
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
|
||
getFillColor: [...color, 180] as [number, number, number, number],
|
||
getRadius: 3,
|
||
radiusMinPixels: 2,
|
||
radiusMaxPixels: 5,
|
||
}),
|
||
);
|
||
});
|
||
|
||
// 해안 부착 입자: 모델별 색상 + 테두리 강조
|
||
const beachedByModel: Record<string, typeof trajectory> = {};
|
||
trajectory.forEach((p) => {
|
||
if (p.stranded === 1) {
|
||
const m = p.model ?? 'default';
|
||
if (!beachedByModel[m]) beachedByModel[m] = [];
|
||
beachedByModel[m].push(p);
|
||
}
|
||
});
|
||
Object.entries(beachedByModel).forEach(([model, particles]) => {
|
||
const color = MODEL_COLORS[model] ?? MODEL_COLORS['default'];
|
||
layers.push(
|
||
new ScatterplotLayer({
|
||
id: `traj-beached-${runId}-${model}`,
|
||
data: particles,
|
||
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
|
||
getFillColor: [...color, 220] as [number, number, number, number],
|
||
getRadius: 4,
|
||
radiusMinPixels: 3,
|
||
radiusMaxPixels: 6,
|
||
stroked: true,
|
||
getLineColor: [255, 255, 255, 160] as [number, number, number, number],
|
||
getLineWidth: 1,
|
||
lineWidthMinPixels: 1,
|
||
}),
|
||
);
|
||
});
|
||
}
|
||
|
||
// 중심점 경로선 (모델별 그룹)
|
||
if (centerPoints && centerPoints.length >= 2) {
|
||
const byModel: Record<string, typeof centerPoints> = {};
|
||
centerPoints.forEach((cp) => {
|
||
const m = cp.model ?? 'default';
|
||
if (!byModel[m]) byModel[m] = [];
|
||
byModel[m].push(cp);
|
||
});
|
||
Object.entries(byModel).forEach(([model, pts]) => {
|
||
const color = MODEL_COLORS[model] ?? MODEL_COLORS['default'];
|
||
const sorted = [...pts].sort((a, b) => a.time - b.time);
|
||
const pathId = `${runIdx}-${model}`;
|
||
layers.push(
|
||
new PathLayer({
|
||
id: `traj-path-${pathId}`,
|
||
data: [{ path: sorted.map((p) => [p.lon, p.lat]) }],
|
||
getPath: (d: { path: number[][] }) => d.path,
|
||
getColor: [...color, 230] as [number, number, number, number],
|
||
getWidth: 2,
|
||
widthMinPixels: 2,
|
||
widthMaxPixels: 4,
|
||
}),
|
||
);
|
||
layers.push(
|
||
new ScatterplotLayer({
|
||
id: `traj-centers-${pathId}`,
|
||
data: sorted,
|
||
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
|
||
getFillColor: [...color, 230] as [number, number, number, number],
|
||
getRadius: 5,
|
||
radiusMinPixels: 4,
|
||
radiusMaxPixels: 8,
|
||
}),
|
||
);
|
||
layers.push(
|
||
new TextLayer({
|
||
id: `traj-labels-${pathId}`,
|
||
data: sorted,
|
||
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
|
||
getText: (d: { time: number }) => {
|
||
const dt = new Date(startDt.getTime() + d.time * 3600 * 1000);
|
||
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
||
},
|
||
getSize: 11,
|
||
getColor: [...color, 240] as [number, number, number, number],
|
||
getPixelOffset: [0, -14] as [number, number],
|
||
outlineWidth: 2,
|
||
outlineColor: [0, 0, 0, 180] as [number, number, number, number],
|
||
fontSettings: { sdf: true },
|
||
billboard: true,
|
||
}),
|
||
);
|
||
});
|
||
}
|
||
}
|
||
return layers;
|
||
}, [trajectoryEntries]);
|
||
|
||
// ── 민감자원 GeoJSON 레이어 ──────────────────────────
|
||
const sensLayer = useMemo(() => {
|
||
if (!sensitiveGeojson || sensCheckedCategories.size === 0) return null;
|
||
const filtered = {
|
||
...sensitiveGeojson,
|
||
features: sensitiveGeojson.features.filter((f) =>
|
||
sensCheckedCategories.has(
|
||
((f.properties as Record<string, unknown>)?.['category'] as string) ?? '',
|
||
),
|
||
),
|
||
};
|
||
if (filtered.features.length === 0) return null;
|
||
return new GeoJsonLayer({
|
||
id: 'incidents-sensitive-geojson',
|
||
data: filtered,
|
||
pickable: false,
|
||
stroked: true,
|
||
filled: true,
|
||
pointRadiusMinPixels: 8,
|
||
lineWidthMinPixels: 1,
|
||
getFillColor: (f: { properties: Record<string, unknown> }) => {
|
||
const color = sensColorMap.get((f.properties['category'] as string) ?? '') ?? [
|
||
128, 128, 128,
|
||
];
|
||
return [...color, 60] as [number, number, number, number];
|
||
},
|
||
getLineColor: (f: { properties: Record<string, unknown> }) => {
|
||
const color = sensColorMap.get((f.properties['category'] as string) ?? '') ?? [
|
||
128, 128, 128,
|
||
];
|
||
return [...color, 180] as [number, number, number, number];
|
||
},
|
||
getLineWidth: 1,
|
||
updateTriggers: {
|
||
getFillColor: [sensColorMap],
|
||
getLineColor: [sensColorMap],
|
||
},
|
||
});
|
||
}, [sensitiveGeojson, sensCheckedCategories, sensColorMap]);
|
||
|
||
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,
|
||
...trajectoryLayers,
|
||
...(sensLayer ? [sensLayer] : []),
|
||
],
|
||
[incidentLayer, realVesselLayers, dischargeZoneLayers, 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-[50] 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-[50] 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 (visible when not in analysis or in overlay mode) */}
|
||
{(!analysisActive || viewMode === 'overlay') && (
|
||
<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>
|
||
)}
|
||
|
||
{/* 분석 오버레이 (지도 위 시각효과) */}
|
||
{analysisActive && viewMode === 'overlay' && (
|
||
<div className="absolute inset-0 z-[500] pointer-events-none">
|
||
{analysisTags.some((t) => t.label === '유출유') && (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
top: '30%',
|
||
left: '45%',
|
||
width: 180,
|
||
height: 120,
|
||
background:
|
||
'radial-gradient(ellipse, color-mix(in srgb, var(--color-warning) 35%, transparent) 0%, color-mix(in srgb, var(--color-warning) 10%, transparent) 50%, transparent 70%)',
|
||
borderRadius: '50%',
|
||
transform: 'rotate(-15deg)',
|
||
}}
|
||
/>
|
||
)}
|
||
{analysisTags.some((t) => t.label === 'HNS') && (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
top: '25%',
|
||
left: '50%',
|
||
width: 150,
|
||
height: 100,
|
||
background:
|
||
'radial-gradient(ellipse, color-mix(in srgb, var(--color-tertiary) 30%, transparent) 0%, color-mix(in srgb, var(--color-tertiary) 8%, transparent) 50%, transparent 70%)',
|
||
borderRadius: '50%',
|
||
transform: 'rotate(20deg)',
|
||
}}
|
||
/>
|
||
)}
|
||
{analysisTags.some((t) => t.label === '구난') && (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
top: '35%',
|
||
left: '42%',
|
||
width: 200,
|
||
height: 200,
|
||
border:
|
||
'2px dashed color-mix(in srgb, var(--color-accent) 40%, transparent)',
|
||
borderRadius: '50%',
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 오염물 배출 규정 토글 */}
|
||
<button
|
||
onClick={() => {
|
||
setDischargeMode(!dischargeMode);
|
||
if (dischargeMode) setDischargeInfo(null);
|
||
}}
|
||
className="absolute z-[500] cursor-pointer rounded-md text-caption font-bold font-korean"
|
||
style={{
|
||
top: 10,
|
||
right: 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="flex h-full">
|
||
<div
|
||
className="flex-1 flex flex-col overflow-hidden"
|
||
style={{ borderRight: '2px solid var(--color-accent)' }}
|
||
>
|
||
<div
|
||
className="flex items-center shrink-0 bg-bg-surface border-b border-stroke"
|
||
style={{ height: 28, padding: '0 10px' }}
|
||
>
|
||
<span className="text-caption font-bold text-color-accent">
|
||
{analysisTags[0]
|
||
? `${analysisTags[0].icon} ${analysisTags[0].label}`
|
||
: '— 분석 결과를 선택하세요 —'}
|
||
</span>
|
||
</div>
|
||
<div
|
||
className="flex-1 overflow-y-auto bg-bg-base flex flex-col gap-2"
|
||
style={{ padding: 12 }}
|
||
>
|
||
<SplitPanelContent tag={analysisTags[0]} incident={selectedIncident} />
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 flex flex-col overflow-hidden">
|
||
<div
|
||
className="flex items-center shrink-0 bg-bg-surface border-b border-stroke"
|
||
style={{ height: 28, padding: '0 10px' }}
|
||
>
|
||
<span className="text-caption font-bold text-color-accent">
|
||
{analysisTags[1]
|
||
? `${analysisTags[1].icon} ${analysisTags[1].label}`
|
||
: '— 분석 결과를 선택하세요 —'}
|
||
</span>
|
||
</div>
|
||
<div
|
||
className="flex-1 overflow-y-auto bg-bg-base flex flex-col gap-2"
|
||
style={{ padding: 12 }}
|
||
>
|
||
<SplitPanelContent tag={analysisTags[1]} incident={selectedIncident} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── 3분할 View ─────────────────────────────── */}
|
||
{analysisActive && viewMode === 'split3' && (
|
||
<div className="flex h-full">
|
||
<div className="flex-1 flex flex-col overflow-hidden border-r border-stroke">
|
||
<div
|
||
className="flex items-center shrink-0 border-b border-stroke"
|
||
style={{
|
||
height: 28,
|
||
background:
|
||
'linear-gradient(90deg, color-mix(in srgb, var(--color-warning) 8%, transparent), var(--bg-surface))',
|
||
padding: '0 10px',
|
||
}}
|
||
>
|
||
<span
|
||
className="text-caption font-bold"
|
||
style={{ color: 'var(--color-warning)' }}
|
||
>
|
||
🛢 유출유 확산예측
|
||
</span>
|
||
</div>
|
||
<div
|
||
className="flex-1 overflow-y-auto bg-bg-base flex flex-col gap-1.5"
|
||
style={{ padding: 10 }}
|
||
>
|
||
<SplitPanelContent
|
||
tag={{ icon: '🛢', label: '유출유', color: 'var(--color-warning)' }}
|
||
incident={selectedIncident}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 flex flex-col overflow-hidden border-r border-stroke">
|
||
<div
|
||
className="flex items-center shrink-0 border-b border-stroke"
|
||
style={{
|
||
height: 28,
|
||
background:
|
||
'linear-gradient(90deg, color-mix(in srgb, var(--color-tertiary) 8%, transparent), var(--bg-surface))',
|
||
padding: '0 10px',
|
||
}}
|
||
>
|
||
<span
|
||
className="text-caption font-bold"
|
||
style={{ color: 'var(--color-tertiary)' }}
|
||
>
|
||
🧪 HNS 대기확산
|
||
</span>
|
||
</div>
|
||
<div
|
||
className="flex-1 overflow-y-auto bg-bg-base flex flex-col gap-1.5"
|
||
style={{ padding: 10 }}
|
||
>
|
||
<SplitPanelContent
|
||
tag={{ icon: '🧪', label: 'HNS', color: 'var(--color-tertiary)' }}
|
||
incident={selectedIncident}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 flex flex-col overflow-hidden">
|
||
<div
|
||
className="flex items-center shrink-0 border-b border-stroke"
|
||
style={{
|
||
height: 28,
|
||
background:
|
||
'linear-gradient(90deg, color-mix(in srgb, var(--color-accent) 8%, transparent), var(--bg-surface))',
|
||
padding: '0 10px',
|
||
}}
|
||
>
|
||
<span className="text-caption font-bold text-color-accent">🚨 긴급구난</span>
|
||
</div>
|
||
<div
|
||
className="flex-1 overflow-y-auto bg-bg-base flex flex-col gap-1.5"
|
||
style={{ padding: 10 }}
|
||
>
|
||
<SplitPanelContent
|
||
tag={{ icon: '🚨', label: '구난', color: 'var(--color-accent)' }}
|
||
incident={selectedIncident}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Decision Bar */}
|
||
{analysisActive && (
|
||
<div
|
||
className="shrink-0 flex items-center justify-between bg-bg-surface border-t border-stroke"
|
||
style={{ padding: '6px 16px' }}
|
||
>
|
||
<div className="text-caption text-fg-disabled">
|
||
📊 {selectedIncident?.name} · {analysisTags.map((t) => t.label).join(' + ')} 분석 결과
|
||
비교
|
||
</div>
|
||
<div className="flex gap-[6px]">
|
||
<button
|
||
className="cursor-pointer rounded text-caption font-semibold"
|
||
style={{
|
||
padding: '4px 12px',
|
||
background: '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}
|
||
onSensitiveDataChange={handleSensitiveDataChange}
|
||
selectedVessel={
|
||
selectedVessel
|
||
? {
|
||
lat: selectedVessel.lat,
|
||
lng: selectedVessel.lon,
|
||
name: selectedVessel.shipNm,
|
||
}
|
||
: null
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════
|
||
SplitPanelContent
|
||
════════════════════════════════════════════════════ */
|
||
function SplitPanelContent({
|
||
tag,
|
||
incident,
|
||
}: {
|
||
tag?: { icon: string; label: string; color: string };
|
||
incident: Incident | null;
|
||
}) {
|
||
if (!tag) {
|
||
return (
|
||
<div className="flex-1 flex items-center justify-center text-fg-disabled text-label-2">
|
||
R&D 분석 결과를 선택하세요
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const mockData: Record<
|
||
string,
|
||
{
|
||
title: string;
|
||
model: string;
|
||
items: { label: string; value: string; color?: string }[];
|
||
summary: string;
|
||
}
|
||
> = {
|
||
유출유: {
|
||
title: '유출유 확산예측 결과',
|
||
model: 'KOSPS + OpenDrift · BUNKER-C 150kL',
|
||
items: [
|
||
{ label: '예측 시간', value: '72시간 (3일)' },
|
||
{ label: '최대 확산거리', value: '12.3 NM', color: 'var(--color-warning)' },
|
||
{ label: '해안 도달 시간', value: '18시간 후', color: 'var(--color-danger)' },
|
||
{ label: '영향 해안선', value: '27.5 km' },
|
||
{ label: '풍화율', value: '32.4%' },
|
||
{ label: '잔존유량', value: '101.4 kL', color: 'var(--color-warning)' },
|
||
],
|
||
summary:
|
||
'여수항 남동쪽 방향 확산, 18시간 후 돌산도 해안 도달 예상. 조류 영향으로 남서쪽 이동.',
|
||
},
|
||
HNS: {
|
||
title: 'HNS 대기확산 결과',
|
||
model: 'ALOHA + PHAST · 톨루엔 5톤',
|
||
items: [
|
||
{ label: 'IDLH 범위', value: '1.2 km', color: 'var(--color-danger)' },
|
||
{ label: 'ERPG-2 범위', value: '2.8 km', color: 'var(--color-warning)' },
|
||
{ label: 'ERPG-1 범위', value: '5.1 km', color: 'var(--color-caution)' },
|
||
{ label: '풍향', value: 'SW → NE 방향' },
|
||
{ label: '대기 안정도', value: 'D등급 (중립)' },
|
||
{ label: '영향 인구', value: '약 2,400명', color: 'var(--color-danger)' },
|
||
],
|
||
summary: '남서풍에 의해 북동쪽 내륙 방향 확산. IDLH 1.2km 이내 즉시 대피 필요.',
|
||
},
|
||
구난: {
|
||
title: '긴급구난 SAR 결과',
|
||
model: 'SAROPS · Monte Carlo 10,000회 시뮬레이션',
|
||
items: [
|
||
{ label: '95% 확률 범위', value: '8.5 NM²', color: 'var(--color-accent)' },
|
||
{ label: '최적 탐색 경로', value: 'Sector Search' },
|
||
{ label: '예상 표류 속도', value: '1.8 kn' },
|
||
{ label: '표류 방향', value: 'NE (045°)' },
|
||
{ label: '생존 가능 시간', value: '36시간', color: 'var(--color-danger)' },
|
||
{ label: '필요 자산', value: '헬기 2 + 경비정 3', color: 'var(--color-warning)' },
|
||
],
|
||
summary: '북동쪽 방향 표류 예상. 06:00 기준 최적 탐색 패턴: 섹터탐색(Sector Search).',
|
||
},
|
||
};
|
||
|
||
const data = mockData[tag.label] || mockData['유출유'];
|
||
|
||
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} {data.title}
|
||
</div>
|
||
<div className="text-caption text-fg-disabled font-mono">{data.model}</div>
|
||
{incident && (
|
||
<div className="text-caption text-fg-disabled" style={{ marginTop: 2 }}>
|
||
사고: {incident.name} · {incident.date} {incident.time}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="rounded-sm border border-stroke overflow-hidden">
|
||
{data.items.map((item, i) => (
|
||
<div
|
||
key={i}
|
||
className="flex justify-between items-center"
|
||
style={{
|
||
padding: '6px 10px',
|
||
borderBottom: i < data.items.length - 1 ? '1px solid var(--stroke-default)' : 'none',
|
||
background: i % 2 === 0 ? 'var(--bg-surface)' : 'var(--bg-elevated)',
|
||
}}
|
||
>
|
||
<span className="text-caption text-fg-disabled">{item.label}</span>
|
||
<span
|
||
className="text-caption font-semibold font-mono"
|
||
style={{ color: item.color || 'var(--fg)' }}
|
||
>
|
||
{item.value}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div
|
||
className="rounded-sm text-caption text-fg-sub"
|
||
style={{
|
||
padding: '8px 10px',
|
||
background: `color-mix(in srgb, ${tag.color} 6%, transparent)`,
|
||
border: `1px solid color-mix(in srgb, ${tag.color} 15%, transparent)`,
|
||
lineHeight: 1.6,
|
||
}}
|
||
>
|
||
💡 {data.summary}
|
||
</div>
|
||
|
||
<div
|
||
className="rounded-sm bg-bg-base border border-stroke flex items-center justify-center flex-col gap-1"
|
||
style={{ height: 120 }}
|
||
>
|
||
<div className="text-[32px] opacity-30">{tag.icon}</div>
|
||
<div className="text-caption text-fg-disabled">시각화 영역</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════
|
||
VesselPopupPanel / 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>
|
||
</>
|
||
);
|
||
}
|