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(() => 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('detect'); return (
{/* ํ—ค๋” */}
๐Ÿค–
AI ํƒ์ง€/๋ถ„์„
{/* WingAI */}
{tabItems.map((t) => ( ))}
{/* ํƒญ ์ฝ˜ํ…์ธ  */} {activeTab === 'detect' && } {activeTab === 'change' && } {activeTab === 'aoi' && }
); } /* โ”€โ”€โ”€ ๊ฐ์ฒด ํƒ์ง€ ํŒจ๋„ (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(null); const [filterStatus, setFilterStatus] = useState('์ „์ฒด'); 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 (
{/* ํ†ต๊ณ„ ์นด๋“œ */}
{stats.map((s, i) => (
{s.value}
{s.label}
))}
{/* ํƒ์ง€ ๊ฒฐ๊ณผ ์ง€๋„ */}
๐ŸŽฏ ์„ ์ข… ๋ถˆ์ผ์น˜ ํƒ์ง€ ์ง€๋„
{filtered.length}์ฒ™ ํ‘œ์‹œ
[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] }, }), ]} /> {/* ๋ฒ”๋ก€ */}
{[ { color: 'var(--color-danger)', label: '๋ถˆ์ผ์น˜' }, { color: 'var(--color-caution)', label: '์˜์‹ฌ' }, { color: 'var(--color-accent)', label: 'ํ™•์ธ์ค‘' }, { color: 'var(--color-success)', label: '์ •์ƒ' }, ].map((l) => (
{l.label}
))}
{/* ํƒ์ง€ ๋ชฉ๋ก */}
๐Ÿ“‹ MMSI ์„ ์ข… ๊ฒ€์ฆ ๋ชฉ๋ก
{filtered.length}๊ฑด
{filters.map((f) => ( ))}
{filtered.map((d) => (
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 }} >
{d.id} {d.vesselName}
{d.status}
{/* ์„ ์ข… ๋น„๊ต */}
AIS: {d.aisType} {d.mismatch && โ‰ } {!d.mismatch && =} AI: {d.detectedType}
MMSI {d.mmsi} {d.coord} {d.time} ์‹ ๋ขฐ๋„ {d.confidence}
{/* ํŽผ์นจ: ์ƒ์„ธ */} {selectedId === d.id && (
{d.detail}
)}
))}
); } /* โ”€โ”€โ”€ ๋ณ€ํ™” ๊ฐ์ง€ ํŒจ๋„ (๋ณตํ•ฉ ์ •๋ณด์› ์‹œ์  ๋น„๊ต) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ 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>; /** ๊ฐ ์ •๋ณด์›๋ณ„ ํ˜„์žฌ ์‹œ์  ์š”์•ฝ */ nowDetail: Partial>; } function ChangeDetectPanel() { const [layers, setLayers] = useState>({ satellite: true, cctv: true, drone: true, ais: true, }); const [sourceFilter, setSourceFilter] = useState('all'); const [selectedChange, setSelectedChange] = useState(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 (
{/* ๋ ˆ์ด์–ด ํ† ๊ธ€ ๋ฐ” */}
์˜ค๋ฒ„๋ ˆ์ด ๋ ˆ์ด์–ด
{SOURCES.map((s) => ( ))}
{activeCount}/4 ๋ ˆ์ด์–ด ํ™œ์„ฑ
{/* AS-IS / ํ˜„์žฌ ์‹œ์  ๋น„๊ต ๋ทฐ */}
{/* AS-IS ์‹œ์  */}
AS-IS ๊ณผ๊ฑฐ ์‹œ์ 
{/* ํ™œ์„ฑ ๋ ˆ์ด์–ด ํ‘œ์‹œ */} {/*
{SOURCES.filter((s) => layers[s.id]).map((s) => ( {s.icon} {s.label} ))} {activeCount === 0 && ( ๋ ˆ์ด์–ด๋ฅผ ์„ ํƒํ•˜์„ธ์š” )}
*/} {/* ์ง€๋„ ํ”Œ๋ ˆ์ด์Šคํ™€๋” */}
{SOURCES.filter((s) => layers[s.id]).map((s) => ( {s.icon} ))}
๊ณผ๊ฑฐ ์‹œ์  ๋ณตํ•ฉ ์˜ค๋ฒ„๋ ˆ์ด
{SOURCES.filter((s) => layers[s.id]) .map((s) => s.label) .join(' + ')}{' '} ํ†ตํ•ฉ ํ‘œ์‹œ
{/* ํ˜„์žฌ ์‹œ์  */}
ํ˜„์žฌ NOW
2026-03-16 14:23
{/* ํ™œ์„ฑ ๋ ˆ์ด์–ด ํ‘œ์‹œ */} {/*
{SOURCES.filter((s) => layers[s.id]).map((s) => ( {s.icon} {s.label} ))} {activeCount === 0 && ( ๋ ˆ์ด์–ด๋ฅผ ์„ ํƒํ•˜์„ธ์š” )}
*/} {/* ์ง€๋„ ํ”Œ๋ ˆ์ด์Šคํ™€๋” */}
{SOURCES.filter((s) => layers[s.id]).map((s) => ( {s.icon} ))}
ํ˜„์žฌ ์‹œ์  ๋ณตํ•ฉ ์˜ค๋ฒ„๋ ˆ์ด
{SOURCES.filter((s) => layers[s.id]) .map((s) => s.label) .join(' + ')}{' '} ์‹ค์‹œ๊ฐ„ ํ†ตํ•ฉ
{/* ๋ณตํ•ฉ ๋ณ€ํ™” ๊ฐ์ง€ ๋ชฉ๋ก */}
๐Ÿ”„ ๋ณตํ•ฉ ๋ณ€ํ™” ๊ฐ์ง€ ํƒ€์ž„๋ผ์ธ
{SOURCES.map((s) => ( ))}
{filteredChanges.length}๊ฑด
{/* ๋ฐ์ดํ„ฐ ํ–‰ */} {filteredChanges.map((c) => { const isOpen = selectedChange === c.id; return (
{/* ์š”์•ฝ ํ–‰ */}
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, }} >
{c.id}
{c.area} {c.type}
{c.detail}
{c.sources.map((sid) => { const cfg = SOURCES.find((s) => s.id === sid)!; return ( {cfg.label} ); })}
{c.date1} {c.time1} โ†’ {c.date2} {c.time2}
{c.severity}
{/* ํŽผ์นจ: ์ •๋ณด์›๋ณ„ AS-IS โ†’ ํ˜„์žฌ ์ƒ์„ธ */} {isOpen && (
{/* ๊ต์ฐจ๊ฒ€์ฆ */} {c.crossRef && (
๊ต์ฐจ๊ฒ€์ฆ {' '} {c.crossRef}
)} {/* ์ •๋ณด์›๋ณ„ ๋น„๊ต ๊ทธ๋ฆฌ๋“œ */}
์ •๋ณด์›
AS-IS ({c.date1} {c.time1})
ํ˜„์žฌ ({c.date2} {c.time2})
{c.sources.map((sid) => { const cfg = SOURCES.find((s) => s.id === sid)!; return (
{cfg.icon} {cfg.label}
{c.asIsDetail[sid] || '-'}
{c.nowDetail[sid] || '-'}
); })}
)}
); })}
); } /* โ”€โ”€โ”€ ์—ฐ์•ˆ์ž๋™๊ฐ์ง€ ํŒจ๋„ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ 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(INITIAL_ZONES); const [selectedZone, setSelectedZone] = useState(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) => { 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 (
{/* ํ†ต๊ณ„ */}
{[ { 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) => (
{s.value}
{s.label}
))}
{/* ์ง€๋„ ์˜์—ญ */}
{/* ์ง€๋„ ํ—ค๋” */}
๐Ÿ“ ์—ฐ์•ˆ ๊ฐ์‹œ ๊ตฌ์—ญ
{isDrawing && ( ๋“œ๋กœ์ž‰ ๋ชจ๋“œ ยท ์ง€๋„๋ฅผ ํด๋ฆญํ•˜์—ฌ ๊ผญ์ง“์  ์ถ”๊ฐ€ ({drawingPoints.length}์ ) )}
{isDrawing ? ( <> ) : ( )}
{/* MapLibre ์ง€๋„ */}
{/* ์šฐ์ธก ํŒจ๋„ */}
{/* ๋“ฑ๋ก ํผ: ์ด๋ฆ„๋งŒ ์ž…๋ ฅ โ†’ ๋ฐ”๋กœ ๋“ฑ๋ก */} {showForm && drawingPoints.length >= 3 && (
์ƒˆ ๊ฐ์‹œ ๊ตฌ์—ญ ๋“ฑ๋ก
ํด๋ฆฌ๊ณค {drawingPoints.length}์  ์„ค์ • ์™„๋ฃŒ
setFormName(e.target.value)} placeholder="์˜ˆ: ์—ฌ์ˆ˜ํ•ญ ๋ถ์ธก ํ•ด์•ˆ" className={inputCls} style={inputStyle} onKeyDown={(e) => { if (e.key === 'Enter') registerZone(); }} autoFocus />
)} {/* ๊ฐ์‹œ ๊ตฌ์—ญ ๋ชฉ๋ก */}
๐Ÿ“‹ ๋“ฑ๋ก๋œ ๊ฐ์‹œ ๊ตฌ์—ญ
{zones.length}๊ฑด
{zones.map((z) => { const isOpen = selectedZone === z.id; return (
{/* ๋ชฉ๋ก ํ–‰ */}
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 }} >
{z.id} {z.name}
{z.status} {isOpen ? 'โ–ฒ' : 'โ–ผ'}
{/* ํ™œ์„ฑ ์†Œ์Šค ๋ฐฐ์ง€ + ์ฃผ๊ธฐ */}
{z.sources.map((sid) => { const cfg = MONITOR_SOURCES.find((s) => s.id === sid)!; return ( {cfg.label} ); })} {z.interval} ยท {z.lastCheck}
{z.alerts > 0 && (
๋ฏธํ™•์ธ ์•Œ๋ฆผ {z.alerts}๊ฑด
)}
{/* ์ธ๋ผ์ธ ์„ค์ • ํŒจ๋„ (์•„์ฝ”๋””์–ธ) */} {isOpen && (
{/* ๊ฐ์‹œ ์ฃผ๊ธฐ */}
{INTERVAL_OPTIONS.map((iv) => ( ))}
{/* ๋ชจ๋‹ˆํ„ฐ๋ง ๋ฐฉ๋ฒ• */}
{MONITOR_SOURCES.map((src) => { const active = z.sources.includes(src.id); return ( ); })}
{z.sources.length === 0 && (
์ตœ์†Œ 1๊ฐœ ์ด์ƒ์˜ ๋ชจ๋‹ˆํ„ฐ๋ง ๋ฐฉ๋ฒ•์„ ์„ ํƒํ•˜์„ธ์š”
)}
{/* ํ•˜๋‹จ ์ปจํŠธ๋กค */}
{z.polygon.length}์  ยท {z.sources.length}์†Œ์Šค ยท {z.interval}
)}
); })} {zones.length === 0 && (
๐Ÿ“
๋“ฑ๋ก๋œ ๊ฐ์‹œ ๊ตฌ์—ญ์ด ์—†์Šต๋‹ˆ๋‹ค
"+ ๊ฐ์‹œ ๊ตฌ์—ญ ๋“ฑ๋ก" ๋ฒ„ํŠผ์œผ๋กœ ์‹œ์ž‘ํ•˜์„ธ์š”
)}
); }