801 lines
31 KiB
TypeScript
801 lines
31 KiB
TypeScript
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
|
import { Map } from '@vis.gl/react-maplibre';
|
|
import { PolygonLayer, ScatterplotLayer } from '@deck.gl/layers';
|
|
import type { MapMouseEvent } from 'maplibre-gl';
|
|
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
|
import { useMapStore } from '@common/store/mapStore';
|
|
import { S57EncOverlay } from '@components/common/map/S57EncOverlay';
|
|
import { DeckGLOverlay } from '../WingAI';
|
|
|
|
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'],
|
|
},
|
|
];
|
|
|
|
export 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>
|
|
);
|
|
}
|