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