- MMSI 선종 불일치 탐지: AIS 등록 선종 vs AI 영상 분석 선종 비교, 지도 위 위치 표시 - 변화 감지: AS-IS/현재 시점 복합 정보원(위성/CCTV/드론/AIS) 오버레이 비교 - 연안자동감지: 지도 폴리곤 드로잉으로 감시 구역 등록, 주기/모니터링 방법 설정 - 위성요청 라벨 '위성영상'으로 변경, 서브탭 순서 재배치 - aerial:spectral 권한 트리 마이그레이션 추가 (022) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1138 lines
57 KiB
TypeScript
1138 lines
57 KiB
TypeScript
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
|
import { Map, useControl } from '@vis.gl/react-maplibre';
|
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
|
import { PolygonLayer, ScatterplotLayer } from '@deck.gl/layers';
|
|
import type { StyleSpecification, MapMouseEvent } from 'maplibre-gl';
|
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
|
|
|
// 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;
|
|
}
|
|
|
|
const AOI_MAP_STYLE: StyleSpecification = {
|
|
version: 8,
|
|
sources: {
|
|
'carto-dark': {
|
|
type: 'raster',
|
|
tiles: [
|
|
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
|
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
|
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
|
],
|
|
tileSize: 256,
|
|
},
|
|
},
|
|
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }],
|
|
};
|
|
|
|
type WingAITab = 'detect' | 'change' | 'aoi';
|
|
|
|
const tabItems: { id: WingAITab; label: string; icon: string; desc: string }[] = [
|
|
{ id: 'detect', label: '객체 탐지', icon: '🎯', desc: '위성/드론 영상에서 선박·차량·시설물 자동 탐지 및 분류' },
|
|
{ id: 'change', label: '변화 감지', icon: '🔄', desc: '동일 지역 다시점 영상 비교 분석 (Before/After)' },
|
|
{ id: 'aoi', label: '연안자동감지', icon: '📍', desc: '연안 관심지역 등록 → 변화 자동 감지 및 알림' },
|
|
];
|
|
|
|
export function WingAI() {
|
|
const [activeTab, setActiveTab] = useState<WingAITab>('detect');
|
|
|
|
return (
|
|
<div className="overflow-y-auto px-6 pt-1 pb-2">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center gap-3 mb-4 h-9">
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<div
|
|
className="w-7 h-7 rounded-md flex items-center justify-center text-sm"
|
|
style={{ background: 'linear-gradient(135deg,rgba(168,85,247,.2),rgba(236,72,153,.2))', border: '1px solid rgba(168,85,247,.3)' }}
|
|
>
|
|
🤖
|
|
</div>
|
|
<div className="text-[12px] font-bold font-korean text-text-1">AI 탐지/분석</div>
|
|
<span className="text-[9px] px-1.5 py-0.5 rounded font-bold" style={{ background: 'rgba(168,85,247,.12)', color: '#a855f7', border: '1px solid rgba(168,85,247,.25)' }}>WingAI</span>
|
|
</div>
|
|
<div className="flex gap-1 h-7">
|
|
{tabItems.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => setActiveTab(t.id)}
|
|
className="px-2.5 h-full rounded text-[10px] font-bold font-korean cursor-pointer border transition-colors"
|
|
style={
|
|
activeTab === t.id
|
|
? { background: 'rgba(168,85,247,.12)', borderColor: 'rgba(168,85,247,.3)', color: '#a855f7' }
|
|
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
|
}
|
|
>
|
|
{t.icon} {t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 탭 콘텐츠 */}
|
|
{activeTab === 'detect' && <DetectPanel />}
|
|
{activeTab === 'change' && <ChangeDetectPanel />}
|
|
{activeTab === 'aoi' && <AoiPanel />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── 객체 탐지 패널 (MMSI 선종 불일치 탐지) ─────────── */
|
|
type MismatchStatus = '불일치' | '의심' | '정상' | '확인중';
|
|
|
|
interface VesselDetection {
|
|
id: string;
|
|
mmsi: string;
|
|
vesselName: string;
|
|
/** AIS 등록 선종 */
|
|
aisType: string;
|
|
/** AI 영상 분석 선종 */
|
|
detectedType: string;
|
|
/** 불일치 여부 */
|
|
mismatch: boolean;
|
|
status: MismatchStatus;
|
|
confidence: string;
|
|
coord: string;
|
|
lon: number;
|
|
lat: number;
|
|
time: string;
|
|
detail: string;
|
|
}
|
|
|
|
function DetectPanel() {
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [filterStatus, setFilterStatus] = useState<MismatchStatus | '전체'>('전체');
|
|
|
|
const detections: VesselDetection[] = [
|
|
{ id: 'VD-001', mmsi: '440123456', vesselName: 'OCEAN GLORY', aisType: '화물선', detectedType: '유조선', mismatch: true, status: '불일치', confidence: '94.2%', coord: '33.24°N 126.50°E', lon: 126.50, lat: 33.24, time: '14:23', detail: 'AIS 화물선 등록 → 영상 분석 결과 유조선 선형 + 탱크 구조 탐지' },
|
|
{ id: 'VD-002', mmsi: '441987654', vesselName: 'SEA PHOENIX', aisType: '유조선', detectedType: '화물선', mismatch: true, status: '불일치', confidence: '91.7%', coord: '34.73°N 127.68°E', lon: 127.68, lat: 34.73, time: '14:18', detail: 'AIS 유조선 등록 → 영상 분석 결과 컨테이너 적재 확인, 화물선 판정' },
|
|
{ id: 'VD-003', mmsi: '440555123', vesselName: 'DONGBANG 7', aisType: '어선', detectedType: '화물선', mismatch: true, status: '의심', confidence: '78.3%', coord: '35.15°N 129.13°E', lon: 129.13, lat: 35.15, time: '14:10', detail: 'AIS 어선 등록 → 선체 규모 및 구조가 어선 대비 과대, 화물선 의심' },
|
|
{ id: 'VD-004', mmsi: '440678901', vesselName: 'KOREA STAR', aisType: '화물선', detectedType: '화물선', mismatch: false, status: '정상', confidence: '97.8%', coord: '34.80°N 126.37°E', lon: 126.37, lat: 34.80, time: '14:05', detail: 'AIS 등록 선종과 영상 분석 결과 일치' },
|
|
{ id: 'VD-005', mmsi: 'N/A', vesselName: '미식별', aisType: 'AIS 미수신', detectedType: '유조선', mismatch: true, status: '확인중', confidence: '85.6%', coord: '33.11°N 126.27°E', lon: 126.27, lat: 33.11, time: '14:01', detail: 'AIS 신호 없음 → 위성 SAR로 유조선급 선형 탐지, 불법 운항 의심' },
|
|
{ id: 'VD-006', mmsi: '440234567', vesselName: 'BUSAN EXPRESS', aisType: '컨테이너선', detectedType: '유조선', mismatch: true, status: '불일치', confidence: '89.1%', coord: '35.05°N 129.10°E', lon: 129.10, lat: 35.05, time: '13:55', detail: 'AIS 컨테이너선 → 갑판 컨테이너 미확인, 탱크 구조 감지' },
|
|
{ id: 'VD-007', mmsi: '440345678', vesselName: 'JEJU BREEZE', aisType: '여객선', detectedType: '여객선', mismatch: false, status: '정상', confidence: '98.1%', coord: '33.49°N 126.52°E', lon: 126.52, lat: 33.49, time: '13:50', detail: 'AIS 등록 선종과 영상 분석 결과 일치' },
|
|
];
|
|
|
|
const mismatchCount = detections.filter((d) => d.mismatch).length;
|
|
const confirmingCount = detections.filter((d) => d.status === '확인중').length;
|
|
|
|
const stats = [
|
|
{ value: String(detections.length), label: '분석 선박', color: '#3b82f6' },
|
|
{ value: String(mismatchCount), label: '선종 불일치', color: '#ef4444' },
|
|
{ value: String(confirmingCount), label: '확인 중', color: '#eab308' },
|
|
{ value: String(detections.filter((d) => !d.mismatch).length), label: '정상', color: '#22c55e' },
|
|
];
|
|
|
|
const filtered = filterStatus === '전체' ? detections : detections.filter((d) => d.status === filterStatus);
|
|
|
|
const statusStyle = (s: MismatchStatus) => {
|
|
if (s === '불일치') return { background: 'rgba(239,68,68,.12)', color: '#ef4444', border: '1px solid rgba(239,68,68,.25)' };
|
|
if (s === '의심') return { background: 'rgba(234,179,8,.12)', color: '#eab308', border: '1px solid rgba(234,179,8,.25)' };
|
|
if (s === '확인중') return { background: 'rgba(168,85,247,.12)', color: '#a855f7', border: '1px solid rgba(168,85,247,.25)' };
|
|
return { background: 'rgba(34,197,94,.12)', color: '#22c55e', border: '1px solid rgba(34,197,94,.25)' };
|
|
};
|
|
|
|
const filters: (MismatchStatus | '전체')[] = ['전체', '불일치', '의심', '확인중', '정상'];
|
|
|
|
return (
|
|
<div>
|
|
{/* 통계 카드 */}
|
|
<div className="grid grid-cols-4 gap-3 mb-5">
|
|
{stats.map((s, i) => (
|
|
<div key={i} className="bg-bg-2 border border-border rounded-md p-3.5 text-center">
|
|
<div className="text-[22px] font-bold font-mono" style={{ color: s.color }}>{s.value}</div>
|
|
<div className="text-[10px] text-text-3 mt-1 font-korean">{s.label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[1fr_380px] gap-4">
|
|
{/* 탐지 결과 지도 */}
|
|
<div className="bg-bg-2 border border-border rounded-md overflow-hidden" style={{ minHeight: 480 }}>
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
<div className="text-[11px] font-bold font-korean text-text-1">🎯 선종 불일치 탐지 지도</div>
|
|
<div className="text-[9px] text-text-3 font-mono">{filtered.length}척 표시</div>
|
|
</div>
|
|
<div className="relative" style={{ height: 440 }}>
|
|
<Map
|
|
initialViewState={{ longitude: 127.5, latitude: 34.0, zoom: 6.5 }}
|
|
style={{ width: '100%', height: '100%' }}
|
|
mapStyle={AOI_MAP_STYLE}
|
|
attributionControl={false}
|
|
>
|
|
<DeckGLOverlay layers={[
|
|
new ScatterplotLayer({
|
|
id: 'vessel-detect-markers',
|
|
data: filtered,
|
|
getPosition: (d: VesselDetection) => [d.lon, d.lat],
|
|
getRadius: (d: VesselDetection) => selectedId === d.id ? 10 : 7,
|
|
radiusUnits: 'pixels' as const,
|
|
getFillColor: (d: VesselDetection) => {
|
|
if (d.status === '불일치') return [239, 68, 68, 200];
|
|
if (d.status === '의심') return [234, 179, 8, 200];
|
|
if (d.status === '확인중') return [168, 85, 247, 200];
|
|
return [34, 197, 94, 160];
|
|
},
|
|
getLineColor: [255, 255, 255, 255],
|
|
lineWidthMinPixels: 2,
|
|
stroked: true,
|
|
pickable: true,
|
|
onClick: ({ object }: { object: VesselDetection }) => {
|
|
if (object) setSelectedId(object.id === selectedId ? null : object.id);
|
|
},
|
|
updateTriggers: { getRadius: [selectedId] },
|
|
}),
|
|
]} />
|
|
</Map>
|
|
{/* 범례 */}
|
|
<div className="absolute bottom-3 left-3 bg-bg-2/90 border border-border rounded px-3 py-2 backdrop-blur-sm">
|
|
<div className="flex items-center gap-3">
|
|
{[
|
|
{ color: '#ef4444', label: '불일치' },
|
|
{ color: '#eab308', label: '의심' },
|
|
{ color: '#a855f7', label: '확인중' },
|
|
{ color: '#22c55e', label: '정상' },
|
|
].map((l) => (
|
|
<div key={l.label} className="flex items-center gap-1">
|
|
<div className="w-2.5 h-2.5 rounded-full" style={{ background: l.color }} />
|
|
<span className="text-[8px] font-korean text-text-3">{l.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 탐지 목록 */}
|
|
<div className="bg-bg-2 border border-border rounded-md overflow-hidden flex flex-col" style={{ maxHeight: 520 }}>
|
|
<div className="px-4 py-3 border-b border-border shrink-0">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="text-[11px] font-bold font-korean text-text-1">📋 MMSI 선종 검증 목록</div>
|
|
<div className="text-[9px] text-text-3 font-mono">{filtered.length}건</div>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
{filters.map((f) => (
|
|
<button
|
|
key={f}
|
|
onClick={() => setFilterStatus(f)}
|
|
className="px-2 py-0.5 rounded text-[9px] font-bold font-korean cursor-pointer border transition-colors"
|
|
style={filterStatus === f
|
|
? { background: 'rgba(168,85,247,.12)', borderColor: 'rgba(168,85,247,.3)', color: '#a855f7' }
|
|
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
|
}
|
|
>
|
|
{f}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="divide-y overflow-y-auto flex-1" style={{ borderColor: 'rgba(255,255,255,.04)' }}>
|
|
{filtered.map((d) => (
|
|
<div
|
|
key={d.id}
|
|
onClick={() => setSelectedId(selectedId === d.id ? null : d.id)}
|
|
className="px-4 py-3 hover:bg-bg-hover/30 transition-colors cursor-pointer"
|
|
style={{ background: selectedId === d.id ? 'rgba(168,85,247,.04)' : undefined }}
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-mono text-text-3">{d.id}</span>
|
|
<span className="text-[10px] font-bold font-korean text-text-1">{d.vesselName}</span>
|
|
</div>
|
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold font-korean" style={statusStyle(d.status)}>
|
|
{d.status}
|
|
</span>
|
|
</div>
|
|
{/* 선종 비교 */}
|
|
<div className="flex items-center gap-1.5 mt-1.5">
|
|
<span className="px-1.5 py-0.5 rounded text-[8px] font-bold font-korean" style={{ background: 'rgba(59,130,246,.1)', color: '#3b82f6', border: '1px solid rgba(59,130,246,.2)' }}>
|
|
AIS: {d.aisType}
|
|
</span>
|
|
{d.mismatch && <span className="text-[10px]" style={{ color: '#ef4444' }}>≠</span>}
|
|
{!d.mismatch && <span className="text-[10px]" style={{ color: '#22c55e' }}>=</span>}
|
|
<span className="px-1.5 py-0.5 rounded text-[8px] font-bold font-korean" style={{
|
|
background: d.mismatch ? 'rgba(239,68,68,.1)' : 'rgba(34,197,94,.1)',
|
|
color: d.mismatch ? '#ef4444' : '#22c55e',
|
|
border: `1px solid ${d.mismatch ? 'rgba(239,68,68,.2)' : 'rgba(34,197,94,.2)'}`,
|
|
}}>
|
|
AI: {d.detectedType}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-1.5 text-[9px] text-text-4 font-mono">
|
|
<span>MMSI {d.mmsi}</span>
|
|
<span>{d.coord}</span>
|
|
<span>{d.time}</span>
|
|
<span>신뢰도 {d.confidence}</span>
|
|
</div>
|
|
{/* 펼침: 상세 */}
|
|
{selectedId === d.id && (
|
|
<div className="mt-2 px-3 py-2 rounded text-[9px] font-korean" style={{ background: 'rgba(168,85,247,.04)', border: '1px solid rgba(168,85,247,.12)', color: '#c4b5fd' }}>
|
|
{d.detail}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── 변화 감지 패널 (복합 정보원 시점 비교) ──────────── */
|
|
type SourceType = 'satellite' | 'cctv' | 'drone' | 'ais';
|
|
|
|
interface SourceConfig {
|
|
id: SourceType;
|
|
label: string;
|
|
icon: string;
|
|
color: string;
|
|
desc: string;
|
|
}
|
|
|
|
const SOURCES: SourceConfig[] = [
|
|
{ id: 'satellite', label: '위성영상', icon: '🛰', color: '#a855f7', desc: 'KOMPSAT-3A / Sentinel SAR 수신 영상' },
|
|
{ id: 'cctv', label: 'CCTV', icon: '📹', color: '#3b82f6', desc: 'KHOA / KBS 해안 CCTV 스냅샷' },
|
|
{ id: 'drone', label: '드론', icon: '🛸', color: '#22c55e', desc: '정밀 촬영 / 열화상 이미지' },
|
|
{ id: 'ais', label: 'AIS', icon: '🚢', color: '#f59e0b', desc: '선박 위치·항적·MMSI 궤적' },
|
|
];
|
|
|
|
interface ChangeRecord {
|
|
id: string;
|
|
area: string;
|
|
type: string;
|
|
date1: string;
|
|
time1: string;
|
|
date2: string;
|
|
time2: string;
|
|
severity: '심각' | '보통' | '낮음';
|
|
detail: string;
|
|
sources: SourceType[];
|
|
crossRef?: string;
|
|
/** 각 정보원별 AS-IS 시점 요약 */
|
|
asIsDetail: Partial<Record<SourceType, string>>;
|
|
/** 각 정보원별 현재 시점 요약 */
|
|
nowDetail: Partial<Record<SourceType, string>>;
|
|
}
|
|
|
|
function ChangeDetectPanel() {
|
|
const [layers, setLayers] = useState<Record<SourceType, boolean>>({
|
|
satellite: true, cctv: true, drone: true, ais: true,
|
|
});
|
|
const [sourceFilter, setSourceFilter] = useState<SourceType | 'all'>('all');
|
|
const [selectedChange, setSelectedChange] = useState<string | null>(null);
|
|
|
|
const toggleLayer = (id: SourceType) => setLayers((prev) => ({ ...prev, [id]: !prev[id] }));
|
|
const activeCount = Object.values(layers).filter(Boolean).length;
|
|
|
|
const changes: ChangeRecord[] = [
|
|
{
|
|
id: 'CHG-001', area: '여수항 북측 해안', type: '선박 이동',
|
|
date1: '03-14', time1: '14:00', date2: '03-16', time2: '14:23',
|
|
severity: '보통', detail: '정박 선박 3척 → 7척 (증가)',
|
|
sources: ['satellite', 'ais', 'cctv'],
|
|
crossRef: 'AIS MMSI 440123456 외 3척 신규 입항 — 위성+CCTV 동시 확인',
|
|
asIsDetail: { satellite: '정박 선박 3척 식별', ais: 'MMSI 3건 정박 상태', cctv: '여수 오동도 CCTV 정상' },
|
|
nowDetail: { satellite: '선박 7척 식별 (4척 증가)', ais: 'MMSI 7건 (신규 4건 입항)', cctv: '여수 오동도 CCTV 선박 증가 확인' },
|
|
},
|
|
{
|
|
id: 'CHG-002', area: '제주 서귀포 해상', type: '유막 확산',
|
|
date1: '03-15', time1: '10:30', date2: '03-16', time2: '14:23',
|
|
severity: '심각', detail: '유막 면적 2.1km² → 4.8km² (확산)',
|
|
sources: ['satellite', 'drone', 'cctv', 'ais'],
|
|
crossRef: '4개 정보원 교차확인 — 유막 남동 방향 확산 일치',
|
|
asIsDetail: { satellite: 'SAR 유막 2.1km² 탐지', drone: '열화상 유막 경계 포착', cctv: '서귀포 카메라 해면 이상 없음', ais: '인근 유조선 1척 정박' },
|
|
nowDetail: { satellite: 'SAR 유막 4.8km² 확산', drone: '열화상 유막 남동 2.7km 확대', cctv: '서귀포 카메라 해면 변색 감지', ais: '유조선 이탈, 방제선 2척 진입' },
|
|
},
|
|
{
|
|
id: 'CHG-003', area: '부산항 외항', type: '방제장비 배치',
|
|
date1: '03-10', time1: '09:00', date2: '03-16', time2: '14:23',
|
|
severity: '낮음', detail: '부유식 오일펜스 신규 배치 확인',
|
|
sources: ['drone', 'cctv'],
|
|
crossRef: 'CCTV 부산항 #204 + 드론 정밀 촬영 일치',
|
|
asIsDetail: { drone: '오일펜스 미배치', cctv: '부산 민락항 CCTV 해상 장비 없음' },
|
|
nowDetail: { drone: '오일펜스 300m 배치 확인', cctv: '부산 민락항 CCTV 오일펜스 포착' },
|
|
},
|
|
{
|
|
id: 'CHG-004', area: '통영 해역 남측', type: '미식별 선박',
|
|
date1: '03-13', time1: '22:00', date2: '03-16', time2: '14:23',
|
|
severity: '보통', detail: 'AIS 미송출 선박 2척 위성 포착',
|
|
sources: ['satellite', 'ais'],
|
|
crossRef: 'AIS 미등록 — 위성 SAR 반사 신호로 탐지, 불법 조업 의심',
|
|
asIsDetail: { satellite: '해역 내 선박 신호 없음', ais: '등록 선박 0척' },
|
|
nowDetail: { satellite: 'SAR 반사 2건 포착 (선박 추정)', ais: 'MMSI 미수신 — 미식별 선박' },
|
|
},
|
|
{
|
|
id: 'CHG-005', area: '인천 연안부두', type: '야간 이상징후',
|
|
date1: '03-15', time1: '03:42', date2: '03-16', time2: '03:45',
|
|
severity: '심각', detail: 'CCTV 야간 유출 의심 + AIS 정박선 이탈',
|
|
sources: ['cctv', 'ais'],
|
|
crossRef: 'KBS CCTV #9981 03:42 해면 반사 이상 → AIS 03:45 정박선 이탈 연계',
|
|
asIsDetail: { cctv: '인천 연안부두 CCTV 야간 정상', ais: '정박선 5척 정상 정박' },
|
|
nowDetail: { cctv: '03:42 해면 반사광 이상 감지', ais: '03:45 정박선 1척 이탈 (MMSI 441987654)' },
|
|
},
|
|
{
|
|
id: 'CHG-006', area: '마라도 주변 해역', type: '해안선 변화',
|
|
date1: '03-12', time1: '11:00', date2: '03-16', time2: '11:15',
|
|
severity: '낮음', detail: '해안 퇴적물 분포 변경 감지',
|
|
sources: ['satellite', 'drone'],
|
|
crossRef: '위성 다분광 + 드론 정밀 촬영 퇴적 방향 확인',
|
|
asIsDetail: { satellite: '해안선 퇴적 분포 기준점', drone: '미촬영' },
|
|
nowDetail: { satellite: '퇴적 남서 방향 이동 감지', drone: '정밀 촬영으로 퇴적 경계 확인' },
|
|
},
|
|
];
|
|
|
|
const severityStyle = (s: '심각' | '보통' | '낮음') => {
|
|
if (s === '심각') return { background: 'rgba(239,68,68,.12)', color: '#ef4444', border: '1px solid rgba(239,68,68,.25)' };
|
|
if (s === '보통') return { background: 'rgba(234,179,8,.12)', color: '#eab308', border: '1px solid rgba(234,179,8,.25)' };
|
|
return { background: 'rgba(34,197,94,.12)', color: '#22c55e', border: '1px solid rgba(34,197,94,.25)' };
|
|
};
|
|
|
|
const sourceStyle = (src: SourceConfig, active = true) => active
|
|
? { background: `${src.color}18`, color: src.color, border: `1px solid ${src.color}40` }
|
|
: { background: 'var(--bg3)', color: 'var(--t4)', border: '1px solid var(--bd)', opacity: 0.5 };
|
|
|
|
const filteredChanges = sourceFilter === 'all'
|
|
? changes
|
|
: changes.filter((c) => c.sources.includes(sourceFilter));
|
|
|
|
return (
|
|
<div>
|
|
{/* 레이어 토글 바 */}
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="text-[10px] font-bold font-korean text-text-3 shrink-0">오버레이 레이어</div>
|
|
<div className="flex gap-1.5">
|
|
{SOURCES.map((s) => (
|
|
<button
|
|
key={s.id}
|
|
onClick={() => toggleLayer(s.id)}
|
|
className="px-2.5 py-1.5 rounded text-[9px] font-bold font-korean cursor-pointer border transition-all"
|
|
style={sourceStyle(s, layers[s.id])}
|
|
>
|
|
<span className="mr-1">{layers[s.id] ? '◉' : '○'}</span>
|
|
{s.icon} {s.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="text-[9px] text-text-4 font-mono ml-auto">{activeCount}/4 레이어 활성</div>
|
|
</div>
|
|
|
|
{/* AS-IS / 현재 시점 비교 뷰 */}
|
|
<div className="grid grid-cols-2 gap-4 mb-4" style={{ minHeight: 400 }}>
|
|
{/* AS-IS 시점 */}
|
|
<div className="bg-bg-2 border border-border rounded-md overflow-hidden">
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border" style={{ background: 'rgba(234,179,8,.05)' }}>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[11px] font-bold font-korean" style={{ color: '#eab308' }}>AS-IS</span>
|
|
<span className="text-[9px] px-1.5 py-0.5 rounded font-bold" style={{ background: 'rgba(234,179,8,.12)', color: '#eab308', border: '1px solid rgba(234,179,8,.25)' }}>과거 시점</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input type="date" defaultValue="2026-03-14" className="text-[9px] font-mono px-2 py-1 rounded border outline-none" style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }} />
|
|
<input type="time" defaultValue="14:00" className="text-[9px] font-mono px-2 py-1 rounded border outline-none" style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }} />
|
|
</div>
|
|
</div>
|
|
{/* 활성 레이어 표시 */}
|
|
<div className="flex gap-1 px-3 py-2 border-b" style={{ borderColor: 'rgba(255,255,255,.04)' }}>
|
|
{SOURCES.filter((s) => layers[s.id]).map((s) => (
|
|
<span key={s.id} className="px-1.5 py-0.5 rounded text-[8px] font-bold" style={sourceStyle(s)}>
|
|
{s.icon} {s.label}
|
|
</span>
|
|
))}
|
|
{activeCount === 0 && <span className="text-[9px] text-text-4 font-korean">레이어를 선택하세요</span>}
|
|
</div>
|
|
{/* 지도 플레이스홀더 */}
|
|
<div className="flex items-center justify-center text-text-3" style={{ height: 320 }}>
|
|
<div className="text-center">
|
|
<div className="flex justify-center gap-2 text-2xl mb-3 opacity-30">
|
|
{SOURCES.filter((s) => layers[s.id]).map((s) => <span key={s.id}>{s.icon}</span>)}
|
|
</div>
|
|
<div className="text-[11px] font-korean text-text-3">과거 시점 복합 오버레이</div>
|
|
<div className="text-[9px] text-text-4 mt-1">
|
|
{SOURCES.filter((s) => layers[s.id]).map((s) => s.label).join(' + ')} 통합 표시
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 현재 시점 */}
|
|
<div className="bg-bg-2 border border-border rounded-md overflow-hidden">
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border" style={{ background: 'rgba(59,130,246,.05)' }}>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[11px] font-bold font-korean" style={{ color: '#3b82f6' }}>현재</span>
|
|
<span className="text-[9px] px-1.5 py-0.5 rounded font-bold" style={{ background: 'rgba(59,130,246,.12)', color: '#3b82f6', border: '1px solid rgba(59,130,246,.25)' }}>NOW</span>
|
|
</div>
|
|
<span className="text-[9px] text-text-3 font-mono">2026-03-16 14:23</span>
|
|
</div>
|
|
{/* 활성 레이어 표시 */}
|
|
<div className="flex gap-1 px-3 py-2 border-b" style={{ borderColor: 'rgba(255,255,255,.04)' }}>
|
|
{SOURCES.filter((s) => layers[s.id]).map((s) => (
|
|
<span key={s.id} className="px-1.5 py-0.5 rounded text-[8px] font-bold" style={sourceStyle(s)}>
|
|
{s.icon} {s.label}
|
|
</span>
|
|
))}
|
|
{activeCount === 0 && <span className="text-[9px] text-text-4 font-korean">레이어를 선택하세요</span>}
|
|
</div>
|
|
{/* 지도 플레이스홀더 */}
|
|
<div className="flex items-center justify-center text-text-3" style={{ height: 320 }}>
|
|
<div className="text-center">
|
|
<div className="flex justify-center gap-2 text-2xl mb-3 opacity-30">
|
|
{SOURCES.filter((s) => layers[s.id]).map((s) => <span key={s.id}>{s.icon}</span>)}
|
|
</div>
|
|
<div className="text-[11px] font-korean text-text-3">현재 시점 복합 오버레이</div>
|
|
<div className="text-[9px] text-text-4 mt-1">
|
|
{SOURCES.filter((s) => layers[s.id]).map((s) => s.label).join(' + ')} 실시간 통합
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 복합 변화 감지 목록 */}
|
|
<div className="bg-bg-2 border border-border rounded-md overflow-hidden">
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
<div className="text-[11px] font-bold font-korean text-text-1">🔄 복합 변화 감지 타임라인</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex gap-1">
|
|
<button
|
|
onClick={() => setSourceFilter('all')}
|
|
className="px-2 py-0.5 rounded text-[9px] font-bold font-korean cursor-pointer border transition-colors"
|
|
style={sourceFilter === 'all' ? { background: 'rgba(168,85,247,.12)', borderColor: 'rgba(168,85,247,.3)', color: '#a855f7' } : { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }}
|
|
>
|
|
전체
|
|
</button>
|
|
{SOURCES.map((s) => (
|
|
<button
|
|
key={s.id}
|
|
onClick={() => setSourceFilter(s.id)}
|
|
className="px-2 py-0.5 rounded text-[9px] font-bold font-korean cursor-pointer border transition-colors"
|
|
style={sourceFilter === s.id ? sourceStyle(s) : { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }}
|
|
>
|
|
{s.icon}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="text-[9px] text-text-3 font-mono">{filteredChanges.length}건</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 데이터 행 */}
|
|
{filteredChanges.map((c) => {
|
|
const isOpen = selectedChange === c.id;
|
|
return (
|
|
<div key={c.id} className="border-b" style={{ borderColor: 'rgba(255,255,255,.04)' }}>
|
|
{/* 요약 행 */}
|
|
<div
|
|
onClick={() => setSelectedChange(isOpen ? null : c.id)}
|
|
className="grid gap-0 px-4 py-3 items-center hover:bg-bg-hover/30 transition-colors cursor-pointer"
|
|
style={{ gridTemplateColumns: '52px 1fr 100px 130px 60px', background: isOpen ? 'rgba(168,85,247,.04)' : undefined }}
|
|
>
|
|
<div className="text-[10px] font-mono text-text-3">{c.id}</div>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[11px] font-semibold text-text-1 font-korean">{c.area}</span>
|
|
<span className="text-[10px] font-semibold font-korean" style={{ color: 'var(--t3)' }}>{c.type}</span>
|
|
</div>
|
|
<div className="text-[9px] text-text-4 font-korean mt-0.5">{c.detail}</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{c.sources.map((sid) => {
|
|
const cfg = SOURCES.find((s) => s.id === sid)!;
|
|
return <span key={sid} className="px-1.5 py-0.5 rounded text-[8px] font-bold" style={sourceStyle(cfg)}>{cfg.icon}</span>;
|
|
})}
|
|
</div>
|
|
<div className="text-[9px] font-mono text-text-2">
|
|
<span style={{ color: '#eab308' }}>{c.date1} {c.time1}</span>
|
|
<span className="text-text-4 mx-1">→</span>
|
|
<span style={{ color: '#3b82f6' }}>{c.date2} {c.time2}</span>
|
|
</div>
|
|
<div>
|
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold font-korean" style={severityStyle(c.severity)}>{c.severity}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 펼침: 정보원별 AS-IS → 현재 상세 */}
|
|
{isOpen && (
|
|
<div className="px-4 pb-3 pt-1" style={{ background: 'rgba(168,85,247,.02)' }}>
|
|
{/* 교차검증 */}
|
|
{c.crossRef && (
|
|
<div className="mb-3 px-3 py-2 rounded text-[9px] font-korean" style={{ background: 'rgba(168,85,247,.06)', border: '1px solid rgba(168,85,247,.15)', color: '#c4b5fd' }}>
|
|
<span className="font-bold" style={{ color: '#a855f7' }}>교차검증</span> {c.crossRef}
|
|
</div>
|
|
)}
|
|
{/* 정보원별 비교 그리드 */}
|
|
<div className="grid gap-2">
|
|
<div className="grid gap-0 px-3 py-1.5" style={{ gridTemplateColumns: '90px 1fr 1fr' }}>
|
|
<div className="text-[8px] font-bold text-text-4 font-korean">정보원</div>
|
|
<div className="text-[8px] font-bold font-korean" style={{ color: '#eab308' }}>AS-IS ({c.date1} {c.time1})</div>
|
|
<div className="text-[8px] font-bold font-korean" style={{ color: '#3b82f6' }}>현재 ({c.date2} {c.time2})</div>
|
|
</div>
|
|
{c.sources.map((sid) => {
|
|
const cfg = SOURCES.find((s) => s.id === sid)!;
|
|
return (
|
|
<div key={sid} className="grid gap-0 px-3 py-2 rounded" style={{ gridTemplateColumns: '90px 1fr 1fr', background: `${cfg.color}06`, border: `1px solid ${cfg.color}15` }}>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={sourceStyle(cfg)}>{cfg.icon} {cfg.label}</span>
|
|
</div>
|
|
<div className="text-[9px] font-korean text-text-3">{c.asIsDetail[sid] || '-'}</div>
|
|
<div className="text-[9px] font-korean text-text-1 font-semibold">{c.nowDetail[sid] || '-'}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── 연안자동감지 패널 ──────────────────────────────── */
|
|
type ZoneStatus = '정상' | '경보' | '주의';
|
|
type MonitorSource = 'satellite' | 'cctv' | 'drone' | 'ais';
|
|
|
|
interface MonitorSourceConfig {
|
|
id: MonitorSource;
|
|
label: string;
|
|
icon: string;
|
|
color: string;
|
|
desc: string;
|
|
}
|
|
|
|
const MONITOR_SOURCES: MonitorSourceConfig[] = [
|
|
{ id: 'satellite', label: '위성영상', icon: '🛰', color: '#a855f7', desc: 'KOMPSAT/Sentinel 주기 촬영' },
|
|
{ id: 'cctv', label: 'CCTV', icon: '📹', color: '#3b82f6', desc: 'KHOA/KBS 해안 CCTV 실시간' },
|
|
{ id: 'drone', label: '드론', icon: '🛸', color: '#22c55e', desc: '드론 정밀 촬영 / 열화상' },
|
|
{ id: 'ais', label: 'AIS', icon: '🚢', color: '#f59e0b', desc: '선박 위치·항적 실시간 수신' },
|
|
];
|
|
|
|
interface MonitorZone {
|
|
id: string;
|
|
name: string;
|
|
interval: string;
|
|
lastCheck: string;
|
|
status: ZoneStatus;
|
|
alerts: number;
|
|
polygon: [number, number][];
|
|
color: string;
|
|
monitoring: boolean;
|
|
/** 활성 모니터링 소스 */
|
|
sources: MonitorSource[];
|
|
}
|
|
|
|
const INTERVAL_OPTIONS = ['1h', '3h', '6h', '12h', '24h'];
|
|
|
|
const ZONE_COLORS = ['#a855f7', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'];
|
|
|
|
const INITIAL_ZONES: MonitorZone[] = [
|
|
{
|
|
id: 'AOI-001', name: '여수항 반경', interval: '6h', lastCheck: '03-16 14:00', status: '정상', alerts: 0, monitoring: true, color: '#3b82f6',
|
|
polygon: [[127.68, 34.78], [127.78, 34.78], [127.78, 34.70], [127.68, 34.70]],
|
|
sources: ['satellite', 'cctv', 'ais'],
|
|
},
|
|
{
|
|
id: 'AOI-002', name: '제주 서귀포 해상', interval: '3h', lastCheck: '03-16 13:30', status: '경보', alerts: 2, monitoring: true, color: '#ef4444',
|
|
polygon: [[126.45, 33.28], [126.58, 33.28], [126.58, 33.20], [126.45, 33.20]],
|
|
sources: ['satellite', 'drone', 'cctv', 'ais'],
|
|
},
|
|
{
|
|
id: 'AOI-003', name: '부산항 외항', interval: '12h', lastCheck: '03-16 08:00', status: '정상', alerts: 0, monitoring: true, color: '#22c55e',
|
|
polygon: [[129.05, 35.12], [129.20, 35.12], [129.20, 35.05], [129.05, 35.05]],
|
|
sources: ['cctv', 'ais'],
|
|
},
|
|
{
|
|
id: 'AOI-004', name: '통영 ~ 거제 해역', interval: '24h', lastCheck: '03-15 20:00', status: '주의', alerts: 1, monitoring: true, color: '#f59e0b',
|
|
polygon: [[128.30, 34.90], [128.65, 34.90], [128.65, 34.80], [128.30, 34.80]],
|
|
sources: ['satellite', 'drone'],
|
|
},
|
|
];
|
|
|
|
function AoiPanel() {
|
|
const [zones, setZones] = useState<MonitorZone[]>(INITIAL_ZONES);
|
|
const [selectedZone, setSelectedZone] = useState<string | null>(null);
|
|
// 드로잉 상태
|
|
const [isDrawing, setIsDrawing] = useState(false);
|
|
const [drawingPoints, setDrawingPoints] = useState<[number, number][]>([]);
|
|
// 등록 폼
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [formName, setFormName] = useState('');
|
|
// 실시간 시뮬
|
|
const [now, setNow] = useState(() => new Date());
|
|
|
|
// 시계 갱신 (1분마다)
|
|
useEffect(() => {
|
|
const t = setInterval(() => setNow(new Date()), 60_000);
|
|
return () => clearInterval(t);
|
|
}, []);
|
|
|
|
const nextId = useRef(zones.length + 1);
|
|
|
|
// 지도 클릭 → 폴리곤 포인트 수집
|
|
const handleMapClick = useCallback((e: MapMouseEvent) => {
|
|
if (!isDrawing) return;
|
|
setDrawingPoints((prev) => [...prev, [e.lngLat.lng, e.lngLat.lat]]);
|
|
}, [isDrawing]);
|
|
|
|
// 드로잉 시작
|
|
const startDrawing = () => {
|
|
setDrawingPoints([]);
|
|
setIsDrawing(true);
|
|
setShowForm(false);
|
|
setSelectedZone(null);
|
|
};
|
|
|
|
// 드로잉 완료 → 폼 표시
|
|
const finishDrawing = () => {
|
|
if (drawingPoints.length < 3) return;
|
|
setIsDrawing(false);
|
|
setShowForm(true);
|
|
setFormName('');
|
|
};
|
|
|
|
// 드로잉 취소
|
|
const cancelDrawing = () => {
|
|
setIsDrawing(false);
|
|
setDrawingPoints([]);
|
|
setShowForm(false);
|
|
};
|
|
|
|
// 구역 등록 (이름만 → 등록 후 설정)
|
|
const registerZone = () => {
|
|
if (!formName.trim() || drawingPoints.length < 3) return;
|
|
const newId = `AOI-${String(nextId.current++).padStart(3, '0')}`;
|
|
const newZone: MonitorZone = {
|
|
id: newId,
|
|
name: formName.trim(),
|
|
interval: '6h',
|
|
lastCheck: `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`,
|
|
status: '정상',
|
|
alerts: 0,
|
|
polygon: [...drawingPoints],
|
|
color: ZONE_COLORS[zones.length % ZONE_COLORS.length],
|
|
monitoring: true,
|
|
sources: ['satellite', 'cctv'],
|
|
};
|
|
setZones((prev) => [...prev, newZone]);
|
|
setDrawingPoints([]);
|
|
setShowForm(false);
|
|
setSelectedZone(newId);
|
|
};
|
|
|
|
// 구역 설정 변경
|
|
const updateZone = (id: string, patch: Partial<MonitorZone>) => {
|
|
setZones((prev) => prev.map((z) => z.id === id ? { ...z, ...patch } : z));
|
|
};
|
|
|
|
// 모니터링 소스 토글
|
|
const toggleSource = (id: string, src: MonitorSource) => {
|
|
setZones((prev) => prev.map((z) => {
|
|
if (z.id !== id) return z;
|
|
const has = z.sources.includes(src);
|
|
return { ...z, sources: has ? z.sources.filter((s) => s !== src) : [...z.sources, src] };
|
|
}));
|
|
};
|
|
|
|
// 모니터링 토글
|
|
const toggleMonitoring = (id: string) => {
|
|
setZones((prev) => prev.map((z) => z.id === id ? { ...z, monitoring: !z.monitoring } : z));
|
|
};
|
|
|
|
// 구역 삭제
|
|
const removeZone = (id: string) => {
|
|
setZones((prev) => prev.filter((z) => z.id !== id));
|
|
if (selectedZone === id) setSelectedZone(null);
|
|
};
|
|
|
|
const statusStyle = (s: ZoneStatus) => {
|
|
if (s === '경보') return { background: 'rgba(239,68,68,.12)', color: '#ef4444', border: '1px solid rgba(239,68,68,.25)' };
|
|
if (s === '주의') return { background: 'rgba(234,179,8,.12)', color: '#eab308', border: '1px solid rgba(234,179,8,.25)' };
|
|
return { background: 'rgba(34,197,94,.12)', color: '#22c55e', border: '1px solid rgba(34,197,94,.25)' };
|
|
};
|
|
|
|
// deck.gl 레이어: 등록된 폴리곤 + 드로잉 중 포인트
|
|
const deckLayers = useMemo(() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const result: any[] = [];
|
|
|
|
// 등록된 구역 폴리곤
|
|
const visibleZones = zones.filter((z) => z.monitoring);
|
|
if (visibleZones.length > 0) {
|
|
result.push(
|
|
new PolygonLayer({
|
|
id: 'aoi-zones',
|
|
data: visibleZones,
|
|
getPolygon: (d: MonitorZone) => [...d.polygon, d.polygon[0]],
|
|
getFillColor: (d: MonitorZone) => {
|
|
const hex = d.color;
|
|
const r = parseInt(hex.slice(1, 3), 16);
|
|
const g = parseInt(hex.slice(3, 5), 16);
|
|
const b = parseInt(hex.slice(5, 7), 16);
|
|
const alpha = selectedZone === d.id ? 60 : 30;
|
|
return [r, g, b, alpha];
|
|
},
|
|
getLineColor: (d: MonitorZone) => {
|
|
const hex = d.color;
|
|
const r = parseInt(hex.slice(1, 3), 16);
|
|
const g = parseInt(hex.slice(3, 5), 16);
|
|
const b = parseInt(hex.slice(5, 7), 16);
|
|
return [r, g, b, selectedZone === d.id ? 220 : 140];
|
|
},
|
|
getLineWidth: (d: MonitorZone) => selectedZone === d.id ? 3 : 1.5,
|
|
lineWidthUnits: 'pixels',
|
|
pickable: true,
|
|
onClick: ({ object }: { object: MonitorZone }) => {
|
|
if (object && !isDrawing) setSelectedZone(object.id === selectedZone ? null : object.id);
|
|
},
|
|
updateTriggers: {
|
|
getFillColor: [selectedZone],
|
|
getLineColor: [selectedZone],
|
|
getLineWidth: [selectedZone],
|
|
},
|
|
}),
|
|
);
|
|
|
|
// 경보/주의 구역 중심점 펄스
|
|
const alertZones = visibleZones.filter((z) => z.status !== '정상');
|
|
if (alertZones.length > 0) {
|
|
result.push(
|
|
new ScatterplotLayer({
|
|
id: 'aoi-alert-pulse',
|
|
data: alertZones,
|
|
getPosition: (d: MonitorZone) => {
|
|
const lngs = d.polygon.map((p) => p[0]);
|
|
const lats = d.polygon.map((p) => p[1]);
|
|
return [(Math.min(...lngs) + Math.max(...lngs)) / 2, (Math.min(...lats) + Math.max(...lats)) / 2];
|
|
},
|
|
getRadius: 8000,
|
|
radiusUnits: 'meters' as const,
|
|
getFillColor: (d: MonitorZone) => d.status === '경보' ? [239, 68, 68, 80] : [234, 179, 8, 60],
|
|
getLineColor: (d: MonitorZone) => d.status === '경보' ? [239, 68, 68, 180] : [234, 179, 8, 150],
|
|
lineWidthMinPixels: 2,
|
|
stroked: true,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
// 드로잉 중 포인트
|
|
if (drawingPoints.length > 0) {
|
|
result.push(
|
|
new ScatterplotLayer({
|
|
id: 'drawing-points',
|
|
data: drawingPoints,
|
|
getPosition: (d: [number, number]) => d,
|
|
getRadius: 5,
|
|
radiusUnits: 'pixels' as const,
|
|
getFillColor: [168, 85, 247, 220],
|
|
getLineColor: [255, 255, 255, 255],
|
|
lineWidthMinPixels: 2,
|
|
stroked: true,
|
|
}),
|
|
);
|
|
|
|
// 드로잉 폴리곤 미리보기
|
|
if (drawingPoints.length >= 3) {
|
|
result.push(
|
|
new PolygonLayer({
|
|
id: 'drawing-preview',
|
|
data: [{ polygon: [...drawingPoints, drawingPoints[0]] }],
|
|
getPolygon: (d: { polygon: [number, number][] }) => d.polygon,
|
|
getFillColor: [168, 85, 247, 25],
|
|
getLineColor: [168, 85, 247, 200],
|
|
getLineWidth: 2,
|
|
lineWidthUnits: 'pixels',
|
|
getDashArray: [4, 4],
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}, [zones, drawingPoints, selectedZone, isDrawing]);
|
|
|
|
const activeMonitoring = zones.filter((z) => z.monitoring).length;
|
|
const alertCount = zones.filter((z) => z.status === '경보').length;
|
|
const warningCount = zones.filter((z) => z.status === '주의').length;
|
|
const totalAlerts = zones.reduce((s, a) => s + a.alerts, 0);
|
|
|
|
const inputCls = 'w-full px-2.5 py-1.5 rounded text-[10px] font-korean outline-none border';
|
|
const inputStyle = { background: '#161b22', borderColor: '#21262d', color: '#e2e8f0' };
|
|
|
|
return (
|
|
<div>
|
|
{/* 통계 */}
|
|
<div className="grid grid-cols-4 gap-3 mb-4">
|
|
{[
|
|
{ value: String(activeMonitoring), label: '감시 구역', color: '#3b82f6' },
|
|
{ value: String(alertCount), label: '경보', color: '#ef4444' },
|
|
{ value: String(warningCount), label: '주의', color: '#eab308' },
|
|
{ value: String(totalAlerts), label: '미확인 알림', color: '#a855f7' },
|
|
].map((s, i) => (
|
|
<div key={i} className="bg-bg-2 border border-border rounded-md p-3 text-center">
|
|
<div className="text-[20px] font-bold font-mono" style={{ color: s.color }}>{s.value}</div>
|
|
<div className="text-[10px] text-text-3 mt-0.5 font-korean">{s.label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[1fr_360px] gap-4">
|
|
{/* 지도 영역 */}
|
|
<div className="bg-bg-2 border border-border rounded-md overflow-hidden" style={{ minHeight: 520 }}>
|
|
{/* 지도 헤더 */}
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
|
<div className="flex items-center gap-2">
|
|
<div className="text-[11px] font-bold font-korean text-text-1">📍 연안 감시 구역</div>
|
|
{isDrawing && (
|
|
<span className="text-[9px] px-1.5 py-0.5 rounded font-bold animate-pulse" style={{ background: 'rgba(168,85,247,.15)', color: '#a855f7', border: '1px solid rgba(168,85,247,.3)' }}>
|
|
드로잉 모드 · 지도를 클릭하여 꼭짓점 추가 ({drawingPoints.length}점)
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-1.5">
|
|
{isDrawing ? (
|
|
<>
|
|
<button
|
|
onClick={finishDrawing}
|
|
disabled={drawingPoints.length < 3}
|
|
className="px-2.5 h-6 text-white border-none rounded-sm text-[9px] font-semibold cursor-pointer font-korean disabled:opacity-40 disabled:cursor-not-allowed"
|
|
style={{ background: drawingPoints.length >= 3 ? 'linear-gradient(135deg,#a855f7,#ec4899)' : '#333' }}
|
|
>
|
|
완료 ({drawingPoints.length >= 3 ? `${drawingPoints.length}점` : '3점 이상'})
|
|
</button>
|
|
<button
|
|
onClick={() => setDrawingPoints((p) => p.slice(0, -1))}
|
|
disabled={drawingPoints.length === 0}
|
|
className="px-2 h-6 rounded-sm text-[9px] font-semibold cursor-pointer font-korean border disabled:opacity-40"
|
|
style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }}
|
|
>
|
|
되돌리기
|
|
</button>
|
|
<button
|
|
onClick={cancelDrawing}
|
|
className="px-2 h-6 rounded-sm text-[9px] font-semibold cursor-pointer font-korean border"
|
|
style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: '#ef4444' }}
|
|
>
|
|
취소
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
onClick={startDrawing}
|
|
className="px-2.5 h-6 text-white border-none rounded-sm text-[9px] font-semibold cursor-pointer font-korean flex items-center gap-1"
|
|
style={{ background: 'linear-gradient(135deg,#a855f7,#ec4899)' }}
|
|
>
|
|
+ 감시 구역 등록
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* MapLibre 지도 */}
|
|
<div style={{ height: 480, cursor: isDrawing ? 'crosshair' : undefined }}>
|
|
<Map
|
|
initialViewState={{ longitude: 127.8, latitude: 34.5, zoom: 6.5 }}
|
|
style={{ width: '100%', height: '100%' }}
|
|
mapStyle={AOI_MAP_STYLE}
|
|
attributionControl={false}
|
|
onClick={handleMapClick}
|
|
>
|
|
<DeckGLOverlay layers={deckLayers} />
|
|
</Map>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 우측 패널 */}
|
|
<div className="flex flex-col gap-3" style={{ maxHeight: 520, overflowY: 'auto' }}>
|
|
{/* 등록 폼: 이름만 입력 → 바로 등록 */}
|
|
{showForm && drawingPoints.length >= 3 && (
|
|
<div className="bg-bg-2 border rounded-md overflow-hidden" style={{ borderColor: 'rgba(168,85,247,.3)' }}>
|
|
<div className="px-4 py-2.5 border-b font-korean" style={{ borderColor: 'rgba(168,85,247,.15)', background: 'rgba(168,85,247,.05)' }}>
|
|
<div className="text-[11px] font-bold" style={{ color: '#a855f7' }}>새 감시 구역 등록</div>
|
|
<div className="text-[9px] text-text-4 mt-0.5">폴리곤 {drawingPoints.length}점 설정 완료</div>
|
|
</div>
|
|
<div className="px-4 py-3">
|
|
<label className="text-[9px] font-bold text-text-3 font-korean block mb-1.5">구역 이름</label>
|
|
<input
|
|
value={formName}
|
|
onChange={(e) => setFormName(e.target.value)}
|
|
placeholder="예: 여수항 북측 해안"
|
|
className={inputCls}
|
|
style={inputStyle}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') registerZone(); }}
|
|
autoFocus
|
|
/>
|
|
<div className="flex gap-2 mt-3">
|
|
<button
|
|
onClick={registerZone}
|
|
disabled={!formName.trim()}
|
|
className="flex-1 py-2 text-white border-none rounded text-[10px] font-bold cursor-pointer font-korean disabled:opacity-40 disabled:cursor-not-allowed"
|
|
style={{ background: formName.trim() ? 'linear-gradient(135deg,#a855f7,#ec4899)' : '#333' }}
|
|
>
|
|
등록
|
|
</button>
|
|
<button
|
|
onClick={cancelDrawing}
|
|
className="px-4 py-2 rounded text-[10px] font-bold cursor-pointer font-korean border"
|
|
style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }}
|
|
>
|
|
취소
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 선택된 구역: 모니터링 설정 패널 */}
|
|
{selectedZone && (() => {
|
|
const z = zones.find((zone) => zone.id === selectedZone);
|
|
if (!z) return null;
|
|
return (
|
|
<div className="bg-bg-2 border rounded-md overflow-hidden" style={{ borderColor: `${z.color}40` }}>
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b" style={{ borderColor: `${z.color}20`, background: `${z.color}08` }}>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-mono text-text-3">{z.id}</span>
|
|
<span className="text-[11px] font-bold font-korean text-text-1">{z.name}</span>
|
|
</div>
|
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold font-korean" style={statusStyle(z.status)}>
|
|
{z.status}
|
|
</span>
|
|
</div>
|
|
<div className="px-4 py-3 space-y-3">
|
|
{/* 감시 주기 */}
|
|
<div>
|
|
<label className="text-[9px] font-bold text-text-3 font-korean block mb-1.5">감시 주기</label>
|
|
<div className="flex gap-1">
|
|
{INTERVAL_OPTIONS.map((iv) => (
|
|
<button
|
|
key={iv}
|
|
onClick={(e) => { e.stopPropagation(); updateZone(z.id, { interval: iv }); }}
|
|
className="px-2.5 py-1 rounded text-[9px] font-bold font-mono cursor-pointer border transition-colors"
|
|
style={z.interval === iv
|
|
? { background: 'rgba(59,130,246,.12)', borderColor: 'rgba(59,130,246,.3)', color: '#3b82f6' }
|
|
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
|
}
|
|
>
|
|
{iv}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 모니터링 방법 (정보원 소스) */}
|
|
<div>
|
|
<label className="text-[9px] font-bold text-text-3 font-korean block mb-1.5">모니터링 방법</label>
|
|
<div className="space-y-1.5">
|
|
{MONITOR_SOURCES.map((src) => {
|
|
const active = z.sources.includes(src.id);
|
|
return (
|
|
<button
|
|
key={src.id}
|
|
onClick={(e) => { e.stopPropagation(); toggleSource(z.id, src.id); }}
|
|
className="w-full flex items-center gap-2.5 px-3 py-2 rounded border cursor-pointer transition-all text-left"
|
|
style={active
|
|
? { background: `${src.color}10`, borderColor: `${src.color}40`, color: src.color }
|
|
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t4)', opacity: 0.6 }
|
|
}
|
|
>
|
|
<span className="text-sm shrink-0">{active ? '◉' : '○'}</span>
|
|
<span className="text-sm shrink-0">{src.icon}</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-[10px] font-bold font-korean">{src.label}</div>
|
|
<div className="text-[8px] font-korean" style={{ color: active ? `${src.color}bb` : 'var(--t4)' }}>{src.desc}</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
{z.sources.length === 0 && (
|
|
<div className="text-[9px] text-text-4 font-korean mt-1.5">최소 1개 이상의 모니터링 방법을 선택하세요</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 하단 컨트롤 */}
|
|
<div className="flex items-center gap-2 pt-1 border-t" style={{ borderColor: 'rgba(255,255,255,.06)' }}>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); toggleMonitoring(z.id); }}
|
|
className="px-3 py-1.5 rounded text-[9px] font-bold font-korean cursor-pointer border transition-colors"
|
|
style={z.monitoring
|
|
? { background: 'rgba(34,197,94,.12)', borderColor: 'rgba(34,197,94,.25)', color: '#22c55e' }
|
|
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
|
}
|
|
>
|
|
{z.monitoring ? '◉ 감시 중' : '○ 일시정지'}
|
|
</button>
|
|
<span className="text-[9px] text-text-4 font-mono">{z.polygon.length}점 · {z.sources.length}소스 · {z.interval}</span>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); removeZone(z.id); }}
|
|
className="ml-auto px-2 py-1.5 rounded text-[9px] font-bold font-korean cursor-pointer border"
|
|
style={{ background: 'rgba(239,68,68,.08)', borderColor: 'rgba(239,68,68,.2)', color: '#ef4444' }}
|
|
>
|
|
삭제
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* 감시 구역 목록 */}
|
|
<div className="bg-bg-2 border border-border rounded-md overflow-hidden flex-1">
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
|
<div className="text-[11px] font-bold font-korean text-text-1">📋 등록된 감시 구역</div>
|
|
<div className="text-[9px] text-text-3 font-mono">{zones.length}건</div>
|
|
</div>
|
|
<div className="divide-y" style={{ borderColor: 'rgba(255,255,255,.04)' }}>
|
|
{zones.map((z) => (
|
|
<div
|
|
key={z.id}
|
|
onClick={() => setSelectedZone(selectedZone === z.id ? null : z.id)}
|
|
className="px-4 py-2.5 hover:bg-bg-hover/30 transition-colors cursor-pointer"
|
|
style={{ background: selectedZone === z.id ? `${z.color}08` : undefined, borderLeft: `3px solid ${z.monitoring ? z.color : 'transparent'}` }}
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-mono text-text-3">{z.id}</span>
|
|
<span className="text-[11px] font-semibold text-text-1 font-korean">{z.name}</span>
|
|
</div>
|
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold font-korean" style={statusStyle(z.status)}>
|
|
{z.status}
|
|
</span>
|
|
</div>
|
|
{/* 활성 소스 배지 + 주기 */}
|
|
<div className="flex items-center gap-1.5 mt-1">
|
|
{z.sources.map((sid) => {
|
|
const cfg = MONITOR_SOURCES.find((s) => s.id === sid)!;
|
|
return (
|
|
<span key={sid} className="px-1.5 py-0.5 rounded text-[8px] font-bold" style={{ background: `${cfg.color}15`, color: cfg.color, border: `1px solid ${cfg.color}30` }}>
|
|
{cfg.icon}
|
|
</span>
|
|
);
|
|
})}
|
|
<span className="text-[9px] text-text-4 font-mono ml-auto">{z.interval} · {z.lastCheck}</span>
|
|
</div>
|
|
{z.alerts > 0 && (
|
|
<div className="mt-1.5 px-2 py-1 rounded text-[9px] font-bold font-korean" style={{ background: 'rgba(239,68,68,.08)', color: '#ef4444' }}>
|
|
미확인 알림 {z.alerts}건
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
{zones.length === 0 && (
|
|
<div className="px-4 py-8 text-center">
|
|
<div className="text-2xl opacity-30 mb-2">📍</div>
|
|
<div className="text-[10px] text-text-4 font-korean">등록된 감시 구역이 없습니다</div>
|
|
<div className="text-[9px] text-text-4 font-korean mt-1">"+ 감시 구역 등록" 버튼으로 시작하세요</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|