wing-ops/frontend/src/tabs/aerial/components/WingAI.tsx

1852 lines
69 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 { MapMouseEvent } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
import { useMapStore } from '@common/store/mapStore';
// 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;
}
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-body-2"
style={{
background: 'rgba(6,182,212,0.15)',
border: '1px solid rgba(6,182,212,0.3)',
}}
>
🤖
</div>
<div className="text-label-1 font-bold font-korean text-fg">AI /</div>
{/* <span
className="text-caption px-1.5 py-0.5 rounded font-bold"
style={{
background: 'color-mix(in srgb, var(--color-tertiary) 12%, transparent)',
color: 'var(--color-tertiary)',
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-caption font-bold font-korean cursor-pointer border transition-colors"
style={
activeTab === t.id
? {
background: 'rgba(6,182,212,.08)',
borderColor: 'rgba(6,182,212,.3)',
color: 'var(--color-accent)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
}
}
>
{/* {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 currentMapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
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.5,
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.8,
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.1,
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: 'var(--fg-default)' },
{ value: String(mismatchCount), label: '선종 불일치', color: 'var(--fg-default)' },
{ value: String(confirmingCount), label: '확인 중', color: 'var(--fg-default)' },
{
value: String(detections.filter((d) => !d.mismatch).length),
label: '정상',
color: 'var(--fg-default)',
},
];
const filtered =
filterStatus === '전체' ? detections : detections.filter((d) => d.status === filterStatus);
const statusStyle = (s: MismatchStatus) => {
if (s === '불일치')
return {
background: 'color-mix(in srgb, var(--color-danger) 12%, transparent)',
color: 'var(--color-danger)',
border: '1px solid color-mix(in srgb, var(--color-danger) 25%, transparent)',
};
if (s === '의심')
return {
background: 'color-mix(in srgb, var(--color-caution) 12%, transparent)',
color: 'var(--color-caution)',
border: '1px solid color-mix(in srgb, var(--color-caution) 25%, transparent)',
};
if (s === '확인중')
return {
background: 'rgba(6,182,212,.08)',
color: 'var(--color-accent)',
border: '1px solid rgba(6,182,212,.25)',
};
return {
background: 'color-mix(in srgb, var(--color-success) 12%, transparent)',
color: 'var(--color-success)',
border: '1px solid color-mix(in srgb, var(--color-success) 25%, transparent)',
};
};
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-elevated border border-stroke 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-caption text-fg-disabled mt-1 font-korean">{s.label}</div>
</div>
))}
</div>
<div className="grid grid-cols-[1fr_380px] gap-4">
{/* 탐지 결과 지도 */}
<div
className="bg-bg-elevated border border-stroke rounded-md overflow-hidden"
style={{ minHeight: 480 }}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-stroke">
<div className="text-label-2 font-bold font-korean text-fg">
🎯
</div>
<div className="text-caption text-fg-disabled 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={currentMapStyle}
attributionControl={false}
>
<S57EncOverlay visible={mapToggles['s57'] ?? 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 [6, 182, 212, 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-elevated/90 border border-stroke rounded px-3 py-2 backdrop-blur-sm">
<div className="flex items-center gap-3">
{[
{ color: 'var(--color-danger)', label: '불일치' },
{ color: 'var(--color-caution)', label: '의심' },
{ color: 'var(--color-accent)', label: '확인중' },
{ color: 'var(--color-success)', 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-caption font-korean text-fg-disabled">{l.label}</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* 탐지 목록 */}
<div
className="bg-bg-elevated border border-stroke rounded-md overflow-hidden flex flex-col"
style={{ maxHeight: 520 }}
>
<div className="px-4 py-3 border-b border-stroke shrink-0">
<div className="flex items-center justify-between mb-2">
<div className="text-label-2 font-bold font-korean text-fg">
📋 MMSI
</div>
<div className="text-caption text-fg-disabled 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-caption font-bold font-korean cursor-pointer border transition-colors"
style={
filterStatus === f
? {
background: 'rgba(6,182,212,.08)',
borderColor: 'rgba(6,182,212,.3)',
color: 'var(--color-accent)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
}
}
>
{f}
</button>
))}
</div>
</div>
<div className="divide-y divide-stroke overflow-y-auto flex-1">
{filtered.map((d) => (
<div
key={d.id}
onClick={() => setSelectedId(selectedId === d.id ? null : d.id)}
className="px-4 py-3 hover:bg-bg-surface-hover/30 transition-colors cursor-pointer"
style={{ background: selectedId === d.id ? 'rgba(6,182,212,.04)' : undefined }}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="text-caption font-mono text-fg-disabled">{d.id}</span>
<span className="text-caption font-bold font-korean text-fg">
{d.vesselName}
</span>
</div>
<span
className="px-1.5 py-0.5 rounded text-caption 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="text-caption font-korean text-fg">AIS: {d.aisType}</span>
{d.mismatch && <span className="text-caption text-fg-disabled"></span>}
{!d.mismatch && <span className="text-caption text-fg-disabled">=</span>}
<span className="text-caption font-korean text-fg">AI: {d.detectedType}</span>
</div>
<div className="flex items-center gap-3 mt-1.5 text-caption text-fg-disabled 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-caption font-korean"
style={{
background: 'rgba(6,182,212,.04)',
border: '1px solid rgba(6,182,212,.12)',
color: 'var(--fg-sub)',
}}
>
{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: 'var(--color-accent)',
desc: 'KOMPSAT-3A / Sentinel SAR 수신 영상',
},
{
id: 'cctv',
label: 'CCTV',
icon: '📹',
color: 'var(--color-info)',
desc: 'KHOA / KBS 해안 CCTV 스냅샷',
},
{
id: 'drone',
label: '드론',
icon: '🛸',
color: 'var(--color-success)',
desc: '정밀 촬영 / 열화상 이미지',
},
{
id: 'ais',
label: 'AIS',
icon: '🚢',
color: 'var(--color-warning)',
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: 'color-mix(in srgb, var(--color-danger) 12%, transparent)',
color: 'var(--color-danger)',
border: '1px solid color-mix(in srgb, var(--color-danger) 25%, transparent)',
};
if (s === '보통')
return {
background: 'color-mix(in srgb, var(--color-caution) 12%, transparent)',
color: 'var(--color-caution)',
border: '1px solid color-mix(in srgb, var(--color-caution) 25%, transparent)',
};
return {
background: 'color-mix(in srgb, var(--color-success) 12%, transparent)',
color: 'var(--color-success)',
border: '1px solid color-mix(in srgb, var(--color-success) 25%, transparent)',
};
};
const sourceStyle = (src: SourceConfig, active = true) =>
active
? { background: `${src.color}18`, color: src.color, border: `1px solid ${src.color}40` }
: {
background: 'var(--bg-card)',
color: 'var(--fg-disabled)',
border: '1px solid var(--stroke-default)',
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-caption font-bold font-korean text-fg-disabled 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-caption 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-caption text-fg-disabled 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-elevated border border-stroke rounded-md overflow-hidden">
<div
className="flex items-center justify-between px-4 py-2.5 border-b border-stroke"
style={{ background: 'rgba(6,182,212,.05)' }}
>
<div className="flex items-center gap-2">
<span
className="text-label-2 font-bold font-korean"
style={{ color: 'var(--color-accent)' }}
>
AS-IS
</span>
<span
className="text-caption px-1.5 py-0.5 rounded font-bold"
style={{
background: 'rgba(6,182,212,.12)',
color: 'var(--color-accent)',
border: '1px solid rgba(6,182,212,.25)',
}}
>
</span>
</div>
<div className="flex items-center gap-2">
<input
type="date"
defaultValue="2026-03-14"
className="text-caption font-mono px-2 py-1 rounded border outline-none"
style={{
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-sub)',
}}
/>
<input
type="time"
defaultValue="14:00"
className="text-caption font-mono px-2 py-1 rounded border outline-none"
style={{
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-sub)',
}}
/>
</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-caption font-bold"
style={sourceStyle(s)}
>
{s.icon} {s.label}
</span>
))}
{activeCount === 0 && (
<span className="text-caption text-fg-disabled font-korean">레이어를 선택하세요</span>
)}
</div> */}
{/* 지도 플레이스홀더 */}
<div
className="flex items-center justify-center text-fg-disabled"
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-label-2 font-korean text-fg-disabled">
</div>
<div className="text-caption text-fg-disabled mt-1">
{SOURCES.filter((s) => layers[s.id])
.map((s) => s.label)
.join(' + ')}{' '}
</div>
</div>
</div>
</div>
{/* 현재 시점 */}
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden">
<div
className="flex items-center justify-between px-4 py-2.5 border-b border-stroke"
style={{ background: 'rgba(6,182,212,.05)' }}
>
<div className="flex items-center gap-2">
<span
className="text-label-2 font-bold font-korean"
style={{ color: 'var(--color-accent)' }}
>
</span>
<span
className="text-caption px-1.5 py-0.5 rounded font-bold"
style={{
background: 'rgba(6,182,212,.12)',
color: 'var(--color-accent)',
border: '1px solid rgba(6,182,212,.25)',
}}
>
NOW
</span>
</div>
<span className="text-caption text-fg-disabled 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-caption font-bold"
style={sourceStyle(s)}
>
{s.icon} {s.label}
</span>
))}
{activeCount === 0 && (
<span className="text-caption text-fg-disabled font-korean">레이어를 선택하세요</span>
)}
</div> */}
{/* 지도 플레이스홀더 */}
<div
className="flex items-center justify-center text-fg-disabled"
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-label-2 font-korean text-fg-disabled">
</div>
<div className="text-caption text-fg-disabled mt-1">
{SOURCES.filter((s) => layers[s.id])
.map((s) => s.label)
.join(' + ')}{' '}
</div>
</div>
</div>
</div>
</div>
{/* 복합 변화 감지 목록 */}
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-stroke">
<div className="text-label-2 font-bold font-korean text-fg">
🔄
</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-caption font-bold font-korean cursor-pointer border transition-colors"
style={
sourceFilter === 'all'
? {
background: 'rgba(6,182,212,.08)',
borderColor: 'rgba(6,182,212,.3)',
color: 'var(--color-accent)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
}
}
>
</button>
{SOURCES.map((s) => (
<button
key={s.id}
onClick={() => setSourceFilter(s.id)}
className="px-2 py-0.5 rounded text-caption font-bold font-korean cursor-pointer border transition-colors"
style={
sourceFilter === s.id
? {
background: 'rgba(6,182,212,.08)',
borderColor: 'rgba(6,182,212,.3)',
color: 'var(--color-accent)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
}
}
>
{s.label}
</button>
))}
</div>
<div className="text-caption text-fg-disabled 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-surface-hover/30 transition-colors cursor-pointer"
style={{
gridTemplateColumns: '52px 1fr 200px 150px 52px',
background: isOpen ? 'rgba(6,182,212,.04)' : undefined,
}}
>
<div className="text-caption font-mono text-fg-disabled">{c.id}</div>
<div>
<div className="flex items-center gap-2">
<span className="text-label-2 font-semibold text-fg font-korean">{c.area}</span>
<span
className="text-caption font-korean"
style={{ color: 'var(--fg-disabled)' }}
>
{c.type}
</span>
</div>
<div className="text-caption text-fg-disabled 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-caption text-fg"
style={{
background: 'transparent',
border: `1px solid color-mix(in srgb, ${cfg.color} 40%, transparent)`,
color: `${cfg.color}`,
}}
>
{cfg.label}
</span>
);
})}
</div>
<div className="text-caption font-mono">
<span className="text-fg">
{c.date1} {c.time1}
</span>
<span className="text-fg-disabled mx-1"></span>
<span className="text-fg">
{c.date2} {c.time2}
</span>
</div>
<div>
<span
className="px-1.5 py-0.5 rounded text-caption 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(6,182,212,.02)' }}>
{/* 교차검증 */}
{c.crossRef && (
<div
className="mb-3 px-3 py-2 rounded text-caption font-korean"
style={{
background: 'rgba(6,182,212,.06)',
border: '1px solid rgba(6,182,212,.15)',
color: 'var(--fg-sub)',
}}
>
<span className="font-bold" style={{ color: 'var(--color-accent)' }}>
</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-caption font-bold text-fg-disabled font-korean">
</div>
<div className="text-caption font-bold font-korean text-fg">
AS-IS ({c.date1} {c.time1})
</div>
<div className="text-caption font-bold font-korean text-fg">
({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-caption font-bold px-1.5 py-0.5 rounded"
style={sourceStyle(cfg)}
>
{cfg.icon} {cfg.label}
</span>
</div>
<div className="text-caption font-korean text-fg-disabled">
{c.asIsDetail[sid] || '-'}
</div>
<div className="text-caption font-korean text-fg">
{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: 'var(--color-accent)',
desc: 'KOMPSAT/Sentinel 주기 촬영',
},
{
id: 'cctv',
label: 'CCTV',
icon: '📹',
color: 'var(--color-info)',
desc: 'KHOA/KBS 해안 CCTV 실시간',
},
{
id: 'drone',
label: '드론',
icon: '🛸',
color: 'var(--color-success)',
desc: '드론 정밀 촬영 / 열화상',
},
{
id: 'ais',
label: 'AIS',
icon: '🚢',
color: 'var(--color-warning)',
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 = [
'var(--color-accent)',
'var(--color-info)',
'var(--color-success)',
'var(--color-warning)',
'var(--color-danger)',
'var(--color-danger)',
'var(--color-accent)',
];
const INITIAL_ZONES: MonitorZone[] = [
{
id: 'AOI-001',
name: '여수항 반경',
interval: '6h',
lastCheck: '03-16 14:00',
status: '정상',
alerts: 0,
monitoring: true,
color: 'var(--color-info)',
polygon: [
[127.68, 34.78],
[127.78, 34.78],
[127.78, 34.7],
[127.68, 34.7],
],
sources: ['satellite', 'cctv', 'ais'],
},
{
id: 'AOI-002',
name: '제주 서귀포 해상',
interval: '3h',
lastCheck: '03-16 13:30',
status: '경보',
alerts: 2,
monitoring: true,
color: 'var(--color-danger)',
polygon: [
[126.45, 33.28],
[126.58, 33.28],
[126.58, 33.2],
[126.45, 33.2],
],
sources: ['satellite', 'drone', 'cctv', 'ais'],
},
{
id: 'AOI-003',
name: '부산항 외항',
interval: '12h',
lastCheck: '03-16 08:00',
status: '정상',
alerts: 0,
monitoring: true,
color: 'var(--color-success)',
polygon: [
[129.05, 35.12],
[129.2, 35.12],
[129.2, 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: 'var(--color-warning)',
polygon: [
[128.3, 34.9],
[128.65, 34.9],
[128.65, 34.8],
[128.3, 34.8],
],
sources: ['satellite', 'drone'],
},
];
function AoiPanel() {
const [zones, setZones] = useState<MonitorZone[]>(INITIAL_ZONES);
const [selectedZone, setSelectedZone] = useState<string | null>(null);
const currentMapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
// 드로잉 상태
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: 'color-mix(in srgb, var(--color-danger) 12%, transparent)',
color: 'var(--color-danger)',
border: '1px solid color-mix(in srgb, var(--color-danger) 25%, transparent)',
};
if (s === '주의')
return {
background: 'color-mix(in srgb, var(--color-caution) 12%, transparent)',
color: 'var(--color-caution)',
border: '1px solid color-mix(in srgb, var(--color-caution) 25%, transparent)',
};
return {
background: 'color-mix(in srgb, var(--color-success) 12%, transparent)',
color: 'var(--color-success)',
border: '1px solid color-mix(in srgb, var(--color-success) 25%, transparent)',
};
};
// 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: [6, 182, 212, 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: [6, 182, 212, 25],
getLineColor: [6, 182, 212, 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-caption font-korean outline-none border';
const inputStyle = {
background: 'var(--bg-elevated)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-default)',
};
return (
<div>
{/* 통계 */}
<div className="grid grid-cols-4 gap-3 mb-4">
{[
{ value: String(activeMonitoring), label: '감시 구역', color: 'var(--color-info)' },
{ value: String(alertCount), label: '경보', color: 'var(--color-danger)' },
{ value: String(warningCount), label: '주의', color: 'var(--color-caution)' },
{ value: String(totalAlerts), label: '미확인 알림', color: 'var(--color-tertiary)' },
].map((s, i) => (
<div key={i} className="bg-bg-elevated border border-stroke rounded-md p-3 text-center">
<div className="text-[20px] font-bold font-mono text-fg">{s.value}</div>
<div className="text-caption text-fg-disabled mt-0.5 font-korean">{s.label}</div>
</div>
))}
</div>
<div className="grid grid-cols-[1fr_360px] gap-4">
{/* 지도 영역 */}
<div
className="bg-bg-elevated border border-stroke rounded-md overflow-hidden"
style={{ minHeight: 520 }}
>
{/* 지도 헤더 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-stroke">
<div className="flex items-center gap-2">
<div className="text-label-2 font-bold font-korean text-fg">📍 </div>
{isDrawing && (
<span
className="text-caption px-1.5 py-0.5 rounded font-bold animate-pulse"
style={{
background: 'rgba(6,182,212,.12)',
color: 'var(--color-accent)',
border: '1px solid rgba(6,182,212,.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-color-accent rounded-sm text-caption font-semibold cursor-pointer font-korean disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: drawingPoints.length >= 3 ? 'rgba(6,182,212,0.08)' : '#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-caption font-semibold cursor-pointer font-korean border disabled:opacity-40"
style={{
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-sub)',
}}
>
</button>
<button
onClick={cancelDrawing}
className="px-2 h-6 rounded-sm text-caption font-semibold cursor-pointer font-korean border"
style={{
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--color-danger)',
}}
>
</button>
</>
) : (
<button
onClick={startDrawing}
className="px-2.5 h-6 text-color-accent rounded-sm text-caption font-semibold cursor-pointer font-korean flex items-center gap-1"
style={{ background: 'rgba(6,182,212,0.08)' }}
>
+
</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={currentMapStyle}
attributionControl={false}
onClick={handleMapClick}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<DeckGLOverlay layers={deckLayers} />
</Map>
</div>
</div>
{/* 우측 패널 */}
<div
className="flex flex-col gap-3"
style={{
maxHeight: 520,
overflowY: 'scroll',
scrollbarWidth: 'thin',
scrollbarColor: 'var(--stroke-default) transparent',
}}
>
{/* 등록 폼: 이름만 입력 → 바로 등록 */}
{showForm && drawingPoints.length >= 3 && (
<div
className="bg-bg-elevated border rounded-md overflow-hidden"
style={{ borderColor: 'rgba(6,182,212,.3)' }}
>
<div
className="px-4 py-2.5 border-b font-korean"
style={{ borderColor: 'rgba(6,182,212,.15)', background: 'rgba(6,182,212,.05)' }}
>
<div className="text-label-2 font-bold" style={{ color: 'var(--color-accent)' }}>
</div>
<div className="text-caption text-fg-disabled mt-0.5">
{drawingPoints.length}
</div>
</div>
<div className="px-4 py-3">
<label className="text-caption font-bold text-fg-disabled 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-color-accent rounded text-caption font-bold cursor-pointer font-korean disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: formName.trim() ? 'rgba(6,182,212,0.08)' : '#333',
}}
>
</button>
<button
onClick={cancelDrawing}
className="px-4 py-2 rounded text-caption font-bold cursor-pointer font-korean border"
style={{
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-sub)',
}}
>
</button>
</div>
</div>
</div>
)}
{/* 감시 구역 목록 */}
<div className="bg-bg-elevated border border-stroke rounded-md flex-1">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-stroke">
<div className="text-label-2 font-bold font-korean text-fg">📋 </div>
<div className="text-caption text-fg-disabled font-mono">{zones.length}</div>
</div>
<div className="divide-y divide-stroke">
{zones.map((z) => {
const isOpen = selectedZone === z.id;
return (
<div key={z.id} className="border-b border-stroke last:border-b-0">
{/* 목록 행 */}
<div
onClick={() => setSelectedZone(isOpen ? null : z.id)}
className="px-4 py-2.5 hover:bg-bg-surface-hover/30 transition-colors cursor-pointer"
style={{ background: isOpen ? 'rgba(6,182,212,.04)' : undefined }}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="text-caption font-mono text-fg-disabled">{z.id}</span>
<span className="text-label-2 font-semibold text-fg font-korean">
{z.name}
</span>
</div>
<div className="flex items-center gap-1.5">
<span
className="px-1.5 py-0.5 rounded text-caption font-bold font-korean"
style={statusStyle(z.status)}
>
{z.status}
</span>
<span className="text-caption text-fg-disabled">
{isOpen ? '▲' : '▼'}
</span>
</div>
</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-caption font-bold"
style={{
background: 'transparent',
color: cfg.color,
border: `1px solid color-mix(in srgb, ${cfg.color} 40%, transparent)`,
}}
>
{cfg.label}
</span>
);
})}
<span className="text-caption text-fg-disabled font-mono ml-auto">
{z.interval} · {z.lastCheck}
</span>
</div>
{z.alerts > 0 && (
<div
className="mt-1.5 px-2 py-1 rounded text-caption font-bold font-korean"
style={{
background: 'color-mix(in srgb, var(--color-danger) 8%, transparent)',
color: 'var(--color-danger)',
}}
>
{z.alerts}
</div>
)}
</div>
{/* 인라인 설정 패널 (아코디언) */}
{isOpen && (
<div
className="px-4 py-3 space-y-3 border-t"
style={{
borderColor: `color-mix(in srgb, ${z.color} 12%, transparent)`,
background: `color-mix(in srgb, ${z.color} 3%, transparent)`,
}}
>
{/* 감시 주기 */}
<div>
<label className="text-caption font-bold text-fg-disabled 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-caption font-bold font-mono cursor-pointer border transition-colors"
style={
z.interval === iv
? {
background: 'rgba(6,182,212,.08)',
borderColor: 'rgba(6,182,212,.3)',
color: 'var(--color-accent)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
}
}
>
{iv}
</button>
))}
</div>
</div>
{/* 모니터링 방법 */}
<div>
<label className="text-caption font-bold text-fg-disabled 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: 'transparent',
borderColor: `color-mix(in srgb, ${src.color} 40%, transparent)`,
color: 'var(--fg-default)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
opacity: 0.6,
}
}
>
<span className="text-body-2 shrink-0">{active ? '◉' : '○'}</span>
<span className="text-body-2 shrink-0">{src.icon}</span>
<div className="flex-1 min-w-0">
<div className="text-caption font-bold font-korean">
{src.label}
</div>
<div className="text-caption font-korean text-fg-disabled">
{src.desc}
</div>
</div>
</button>
);
})}
</div>
{z.sources.length === 0 && (
<div className="text-caption text-fg-disabled font-korean mt-1.5">
1
</div>
)}
</div>
{/* 하단 컨트롤 */}
<div
className="flex items-center gap-2 pt-1 border-t"
style={{ borderColor: 'var(--stroke-default)' }}
>
<button
onClick={(e) => {
e.stopPropagation();
toggleMonitoring(z.id);
}}
className="px-3 py-1.5 rounded text-caption font-bold font-korean cursor-pointer border transition-colors"
style={
z.monitoring
? {
background:
'color-mix(in srgb, var(--color-success) 12%, transparent)',
borderColor:
'color-mix(in srgb, var(--color-success) 25%, transparent)',
color: 'var(--color-success)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
}
}
>
{z.monitoring ? '◉ 감시 중' : '○ 일시정지'}
</button>
<span className="text-caption text-fg-disabled 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-caption font-bold font-korean cursor-pointer border"
style={{
background: 'color-mix(in srgb, var(--color-danger) 8%, transparent)',
borderColor:
'color-mix(in srgb, var(--color-danger) 20%, transparent)',
color: 'var(--color-danger)',
}}
>
</button>
</div>
</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-caption text-fg-disabled font-korean">
</div>
<div className="text-caption text-fg-disabled font-korean mt-1">
"+ 감시 구역 등록"
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}