From 1cc25f9f3bb3839f38bdd1b894c697f6304e2db0 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Feb 2026 17:07:14 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8B=A4=EC=A4=91=EA=B5=AC=EC=97=AD?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=ED=95=AD=EC=A0=81=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?+=20STS=20=EC=A0=91=EC=B4=89=20=EB=B6=84=EC=84=9D=20=ED=94=84?= =?UTF-8?q?=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - terra-draw 기반 지도 폴리곤/사각형/원 그리기 + 편집 (OL Draw 대체) - 구역 항적 분석: ANY/ALL/SEQUENTIAL 검색모드, 다중구역 시각화 - STS 선박쌍 접촉 분석: 접촉쌍 그룹핑, 위험도 indicator, ScatterplotLayer - Deck.gl 레이어: PathLayer + TripsLayer + IconLayer (커서 기반 O(1) 보간) - 공유 타임라인 컨트롤 (재생/배속/프로그레스바) - CSV 내보내기 (다중 방문 동적 컬럼, BOM+UTF-8) - ApiExplorer 5모드 통합 (positions/vessel/replay/area-search/sts) 신규 17파일 (features/area-search/), 수정 5파일 Co-Authored-By: Claude Opus 4.6 --- frontend/package-lock.json | 18 ++ frontend/package.json | 2 + .../components/AreaSearchPanel.tsx | 136 +++++++++ .../area-search/components/AreaSearchTab.tsx | 118 ++++++++ .../area-search/components/StsAnalysisTab.tsx | 77 ++++++ .../area-search/components/StsContactList.tsx | 151 ++++++++++ .../components/TimelineControl.tsx | 97 +++++++ .../area-search/components/ZoneDrawPanel.tsx | 136 +++++++++ .../area-search/hooks/useAreaSearchLayer.ts | 178 ++++++++++++ .../features/area-search/hooks/useStsLayer.ts | 224 +++++++++++++++ .../features/area-search/hooks/useZoneDraw.ts | 205 ++++++++++++++ .../area-search/hooks/useZoneLayer.ts | 130 +++++++++ frontend/src/features/area-search/index.ts | 28 ++ .../area-search/services/areaSearchApi.ts | 125 +++++++++ .../features/area-search/services/stsApi.ts | 112 ++++++++ .../area-search/stores/animationStore.ts | 121 ++++++++ .../area-search/stores/areaSearchStore.ts | 261 ++++++++++++++++++ .../features/area-search/stores/stsStore.ts | 188 +++++++++++++ .../area-search/types/areaSearch.types.ts | 64 +++++ .../features/area-search/types/sts.types.ts | 152 ++++++++++ .../features/area-search/utils/csvExport.ts | 111 ++++++++ frontend/src/i18n/en.ts | 2 + frontend/src/i18n/ko.ts | 2 + frontend/src/pages/ApiExplorer.tsx | 135 ++++++++- 24 files changed, 2765 insertions(+), 8 deletions(-) create mode 100644 frontend/src/features/area-search/components/AreaSearchPanel.tsx create mode 100644 frontend/src/features/area-search/components/AreaSearchTab.tsx create mode 100644 frontend/src/features/area-search/components/StsAnalysisTab.tsx create mode 100644 frontend/src/features/area-search/components/StsContactList.tsx create mode 100644 frontend/src/features/area-search/components/TimelineControl.tsx create mode 100644 frontend/src/features/area-search/components/ZoneDrawPanel.tsx create mode 100644 frontend/src/features/area-search/hooks/useAreaSearchLayer.ts create mode 100644 frontend/src/features/area-search/hooks/useStsLayer.ts create mode 100644 frontend/src/features/area-search/hooks/useZoneDraw.ts create mode 100644 frontend/src/features/area-search/hooks/useZoneLayer.ts create mode 100644 frontend/src/features/area-search/index.ts create mode 100644 frontend/src/features/area-search/services/areaSearchApi.ts create mode 100644 frontend/src/features/area-search/services/stsApi.ts create mode 100644 frontend/src/features/area-search/stores/animationStore.ts create mode 100644 frontend/src/features/area-search/stores/areaSearchStore.ts create mode 100644 frontend/src/features/area-search/stores/stsStore.ts create mode 100644 frontend/src/features/area-search/types/areaSearch.types.ts create mode 100644 frontend/src/features/area-search/types/sts.types.ts create mode 100644 frontend/src/features/area-search/utils/csvExport.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 156f5ca..3b8cdb2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,8 @@ "react-dom": "^19.2.0", "react-router-dom": "^7.13.0", "recharts": "^2.15.3", + "terra-draw": "^1.25.0", + "terra-draw-maplibre-gl-adapter": "^1.3.0", "zustand": "^5.0.11" }, "devDependencies": { @@ -5367,6 +5369,22 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/terra-draw": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/terra-draw/-/terra-draw-1.25.0.tgz", + "integrity": "sha512-SQFdY6ObBqln01NFcYjrsVLPoAxHgl3fJszqU0pkDkxNIbV7FZUM102BcC2v9xXQ5Tr8hhnBELf1qG4GaXcALQ==", + "license": "MIT" + }, + "node_modules/terra-draw-maplibre-gl-adapter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/terra-draw-maplibre-gl-adapter/-/terra-draw-maplibre-gl-adapter-1.3.0.tgz", + "integrity": "sha512-Q0Kds+IxLIcMPv3xYAJ3TDXp1Ml1uGk2xFN8Xx5tKpVdU9AhlTkpGxJ7i+Is1v6PC4t+O7MfyGP4Tl+R+qdYeQ==", + "license": "MIT", + "peerDependencies": { + "maplibre-gl": ">=4", + "terra-draw": "^1.0.0" + } + }, "node_modules/texture-compressor": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index f5654e1..a59cd2d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,8 @@ "react-dom": "^19.2.0", "react-router-dom": "^7.13.0", "recharts": "^2.15.3", + "terra-draw": "^1.25.0", + "terra-draw-maplibre-gl-adapter": "^1.3.0", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/frontend/src/features/area-search/components/AreaSearchPanel.tsx b/frontend/src/features/area-search/components/AreaSearchPanel.tsx new file mode 100644 index 0000000..1b06c45 --- /dev/null +++ b/frontend/src/features/area-search/components/AreaSearchPanel.tsx @@ -0,0 +1,136 @@ +import { useCallback } from 'react' +import type maplibregl from 'maplibre-gl' +import { useAreaSearchStore } from '../stores/areaSearchStore' +import { useStsStore } from '../stores/stsStore' +import { useAreaAnimationStore } from '../stores/animationStore' +import { fetchAreaSearch } from '../services/areaSearchApi' +import { fetchVesselContacts } from '../services/stsApi' +import ZoneDrawPanel from './ZoneDrawPanel' +import AreaSearchTab from './AreaSearchTab' +import StsAnalysisTab from './StsAnalysisTab' +import { useZoneDraw } from '../hooks/useZoneDraw' +import { useZoneLayer } from '../hooks/useZoneLayer' + +interface AreaSearchPanelProps { + map: maplibregl.Map | null + mode: 'area-search' | 'sts' +} + +export default function AreaSearchPanel({ map, mode }: AreaSearchPanelProps) { + const maxZones = mode === 'sts' ? 1 : 3 + + // terra-draw + MapLibre zone layer 초기화 + useZoneDraw(map, maxZones) + useZoneLayer(map) + + const zones = useAreaSearchStore((s) => s.zones) + const searchMode = useAreaSearchStore((s) => s.searchMode) + const startTime = useAreaSearchStore((s) => s.startTime) + const endTime = useAreaSearchStore((s) => s.endTime) + const isLoadingArea = useAreaSearchStore((s) => s.isLoading) + const isLoadingSts = useStsStore((s) => s.isLoading) + const isLoading = isLoadingArea || isLoadingSts + + const setStartTime = useAreaSearchStore((s) => s.setStartTime) + const setEndTime = useAreaSearchStore((s) => s.setEndTime) + + /** 구역 분석 조회 */ + const handleAreaSearch = useCallback(async () => { + if (zones.length === 0) return + const store = useAreaSearchStore.getState() + store.setLoading(true) + try { + const polygons = zones.map((z) => ({ id: z.id, name: z.name, coordinates: z.coordinates })) + const result = await fetchAreaSearch(startTime, endTime, searchMode, polygons) + store.setResults(result.tracks, result.hitDetails, result.summary) + + // 애니메이션 시간 범위 설정 + if (result.tracks.length > 0) { + let min = Infinity, max = -Infinity + for (const t of result.tracks) { + if (t.timestampsMs.length > 0) { + if (t.timestampsMs[0] < min) min = t.timestampsMs[0] + if (t.timestampsMs[t.timestampsMs.length - 1] > max) max = t.timestampsMs[t.timestampsMs.length - 1] + } + } + if (min < max) useAreaAnimationStore.getState().setTimeRange(min, max) + } + } catch (err) { + console.error('[AreaSearch] 조회 실패:', err) + store.setLoading(false) + } + }, [zones, startTime, endTime, searchMode]) + + /** STS 접촉 분석 조회 */ + const handleStsSearch = useCallback(async () => { + if (zones.length === 0) return + const stsState = useStsStore.getState() + stsState.setLoading(true) + try { + const polygon = { id: zones[0].id, name: zones[0].name, coordinates: zones[0].coordinates } + const result = await fetchVesselContacts( + startTime, endTime, polygon, + stsState.minContactDurationMinutes, + stsState.maxContactDistanceMeters, + ) + stsState.setResults(result) + + // 애니메이션 시간 범위 + if (result.tracks.length > 0) { + let min = Infinity, max = -Infinity + for (const t of result.tracks) { + if (t.timestampsMs.length > 0) { + if (t.timestampsMs[0] < min) min = t.timestampsMs[0] + if (t.timestampsMs[t.timestampsMs.length - 1] > max) max = t.timestampsMs[t.timestampsMs.length - 1] + } + } + if (min < max) useAreaAnimationStore.getState().setTimeRange(min, max) + } + } catch (err) { + console.error('[STS] 조회 실패:', err) + stsState.setLoading(false) + } + }, [zones, startTime, endTime]) + + const handleSearch = mode === 'sts' ? handleStsSearch : handleAreaSearch + + return ( +
+ {/* 시간 범위 */} +
+ +
+ setStartTime(e.target.value)} + className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-foreground" + /> + ~ + setEndTime(e.target.value)} + className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-foreground" + /> +
+
+ + {/* 구역 그리기 */} + + + {/* 모드별 탭 */} + {mode === 'area-search' && } + {mode === 'sts' && } + + {/* 조회 버튼 */} + +
+ ) +} diff --git a/frontend/src/features/area-search/components/AreaSearchTab.tsx b/frontend/src/features/area-search/components/AreaSearchTab.tsx new file mode 100644 index 0000000..c839410 --- /dev/null +++ b/frontend/src/features/area-search/components/AreaSearchTab.tsx @@ -0,0 +1,118 @@ +import { useCallback } from 'react' +import { useAreaSearchStore } from '../stores/areaSearchStore' +import { SEARCH_MODE_OPTIONS } from '../types/areaSearch.types' +import { getShipKindLabel } from '../../vessel-map' +import { exportAreaSearchCSV } from '../utils/csvExport' +import type { SearchMode } from '../types/areaSearch.types' + +export default function AreaSearchTab() { + const searchMode = useAreaSearchStore((s) => s.searchMode) + const setSearchMode = useAreaSearchStore((s) => s.setSearchMode) + const tracks = useAreaSearchStore((s) => s.tracks) + const hitDetails = useAreaSearchStore((s) => s.hitDetails) + const zones = useAreaSearchStore((s) => s.zones) + const summary = useAreaSearchStore((s) => s.summary) + const disabledVesselIds = useAreaSearchStore((s) => s.disabledVesselIds) + const highlightedVesselId = useAreaSearchStore((s) => s.highlightedVesselId) + const toggleVesselEnabled = useAreaSearchStore((s) => s.toggleVesselEnabled) + const setHighlightedVesselId = useAreaSearchStore((s) => s.setHighlightedVesselId) + + const handleModeChange = useCallback( + (mode: SearchMode) => setSearchMode(mode), + [setSearchMode], + ) + + const handleExportCSV = useCallback(() => { + if (tracks.length === 0) return + exportAreaSearchCSV(tracks, hitDetails, zones) + }, [tracks, hitDetails, zones]) + + return ( +
+ {/* 검색 모드 */} +
+ +
+ {SEARCH_MODE_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* 결과 요약 */} + {summary && ( +
+ {summary.totalVessels}척 발견 + {summary.processingTimeMs != null && ( + {summary.processingTimeMs}ms + )} +
+ )} + + {/* 결과 선박 리스트 */} + {tracks.length > 0 && ( +
+
+ 결과 선박 + +
+
    + {tracks.map((track) => { + const isDisabled = disabledVesselIds.has(track.vesselId) + const isHighlighted = highlightedVesselId === track.vesselId + const hits = hitDetails[track.vesselId] || [] + return ( +
  • setHighlightedVesselId(track.vesselId)} + onMouseLeave={() => setHighlightedVesselId(null)} + className={`flex items-center gap-2 rounded px-2 py-1 text-[11px] transition cursor-pointer ${ + isHighlighted ? 'bg-primary/10' : 'hover:bg-surface-hover/50' + } ${isDisabled ? 'opacity-40' : ''}`} + onClick={() => toggleVesselEnabled(track.vesselId)} + > + toggleVesselEnabled(track.vesselId)} + onClick={(e) => e.stopPropagation()} + className="accent-primary" + /> + {track.vesselId} + {track.shipName || '-'} + {getShipKindLabel(track.shipKindCode)} + {hits.length > 0 && ( + {hits.length}회 + )} +
  • + ) + })} +
+
+ )} +
+ ) +} diff --git a/frontend/src/features/area-search/components/StsAnalysisTab.tsx b/frontend/src/features/area-search/components/StsAnalysisTab.tsx new file mode 100644 index 0000000..330c2f0 --- /dev/null +++ b/frontend/src/features/area-search/components/StsAnalysisTab.tsx @@ -0,0 +1,77 @@ +import { useStsStore } from '../stores/stsStore' +import { STS_LIMITS } from '../types/sts.types' +import StsContactList from './StsContactList' + +export default function StsAnalysisTab() { + const minDuration = useStsStore((s) => s.minContactDurationMinutes) + const maxDistance = useStsStore((s) => s.maxContactDistanceMeters) + const setMinDuration = useStsStore((s) => s.setMinContactDuration) + const setMaxDistance = useStsStore((s) => s.setMaxContactDistance) + const summary = useStsStore((s) => s.summary) + const queryCompleted = useStsStore((s) => s.queryCompleted) + + return ( +
+ {/* 파라미터 슬라이더 */} +
+ + + {/* 최소 접촉 시간 */} +
+
+ 최소 접촉 시간 + {minDuration}분 +
+ setMinDuration(Number(e.target.value))} + className="w-full accent-primary" + /> +
+ {STS_LIMITS.DURATION_MIN}분 + {STS_LIMITS.DURATION_MAX}분 +
+
+ + {/* 최대 접촉 거리 */} +
+
+ 최대 접촉 거리 + {maxDistance}m +
+ setMaxDistance(Number(e.target.value))} + className="w-full accent-primary" + /> +
+ {STS_LIMITS.DISTANCE_MIN}m + {STS_LIMITS.DISTANCE_MAX}m +
+
+
+ + {/* 결과 요약 */} + {summary && ( +
+ {summary.totalContactPairs}쌍 발견 + {summary.totalVesselsInvolved}척 관련 + {summary.processingTimeMs != null && ( + {summary.processingTimeMs}ms + )} +
+ )} + + {/* 접촉 쌍 리스트 */} + {queryCompleted && } +
+ ) +} diff --git a/frontend/src/features/area-search/components/StsContactList.tsx b/frontend/src/features/area-search/components/StsContactList.tsx new file mode 100644 index 0000000..78137d9 --- /dev/null +++ b/frontend/src/features/area-search/components/StsContactList.tsx @@ -0,0 +1,151 @@ +import { useRef, useEffect } from 'react' +import { useStsStore } from '../stores/stsStore' +import { getContactRiskColor, INDICATOR_LABELS } from '../types/sts.types' +import { getShipKindLabel } from '../../vessel-map' +import type { StsGroupedContact } from '../types/sts.types' + +function formatDuration(minutes: number): string { + if (minutes < 60) return `${minutes}분` + const h = Math.floor(minutes / 60) + const m = minutes % 60 + return m > 0 ? `${h}시간 ${m}분` : `${h}시간` +} + +function formatDistance(meters: number): string { + if (meters < 1000) return `${Math.round(meters)}m` + return `${(meters / 1000).toFixed(1)}km` +} + +function formatTimestamp(ms: number | null): string { + if (!ms) return '-' + const d = new Date(ms) + const pad = (n: number) => String(n).padStart(2, '0') + return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +function GroupCard({ group, index }: { group: StsGroupedContact; index: number }) { + const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex) + const disabledGroupIndices = useStsStore((s) => s.disabledGroupIndices) + const expandedGroupIndex = useStsStore((s) => s.expandedGroupIndex) + + const isHighlighted = highlightedGroupIndex === index + const isDisabled = disabledGroupIndices.has(index) + const isExpanded = expandedGroupIndex === index + const riskColor = getContactRiskColor(group.indicators) + + const activeIndicators = Object.entries(group.indicators || {}).filter(([, v]) => v) + + return ( +
  • useStsStore.getState().setHighlightedGroupIndex(index)} + onMouseLeave={() => useStsStore.getState().setHighlightedGroupIndex(null)} + > + {/* 위험도 색상 바 */} +
    + +
    + {/* 선박 정보 */} +
    +
    + {getShipKindLabel(group.vessel1.shipKindCode)} + {group.vessel1.vesselName || group.vessel1.vesselId} +
    + +
    + {group.vessel2.vesselName || group.vessel2.vesselId} + {getShipKindLabel(group.vessel2.shipKindCode)} +
    +
    + + {/* 요약 */} +
    + {formatDuration(group.totalDurationMinutes)} + 평균 {formatDistance(group.avgDistanceMeters)} + {group.contacts.length > 1 && {group.contacts.length}회} +
    + + {/* Indicator 뱃지 */} + {activeIndicators.length > 0 && ( +
    + {activeIndicators.map(([key]) => ( + + {INDICATOR_LABELS[key] || key} + + ))} +
    + )} + + {/* 토글 + 체크 */} +
    + useStsStore.getState().toggleGroupEnabled(index)} + onClick={(e) => e.stopPropagation()} + className="accent-primary" + /> + +
    + + {/* 확장 상세 */} + {isExpanded && ( +
    +
    거리: 최소 {formatDistance(group.minDistanceMeters)} / 평균 {formatDistance(group.avgDistanceMeters)} / 최대 {formatDistance(group.maxDistanceMeters)}
    +
    측정: {group.totalContactPointCount} 포인트
    + {group.contacts.length > 1 && ( +
    + 접촉 이력 ({group.contacts.length}회) + {group.contacts.map((c, ci) => ( +
    + #{ci + 1} {formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)} + {c.contactDurationMinutes != null && ` (${c.contactDurationMinutes}분)`} +
    + ))} +
    + )} +
    + )} +
    +
  • + ) +} + +export default function StsContactList() { + const groupedContacts = useStsStore((s) => s.groupedContacts) + const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex) + const listRef = useRef(null) + + // 하이라이트된 카드로 스크롤 + useEffect(() => { + if (highlightedGroupIndex === null || !listRef.current) return + const items = listRef.current.children + const el = items[highlightedGroupIndex] as HTMLElement | undefined + if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + }, [highlightedGroupIndex]) + + if (groupedContacts.length === 0) { + return
    접촉 쌍이 없습니다
    + } + + return ( +
      + {groupedContacts.map((group, idx) => ( + + ))} +
    + ) +} diff --git a/frontend/src/features/area-search/components/TimelineControl.tsx b/frontend/src/features/area-search/components/TimelineControl.tsx new file mode 100644 index 0000000..85509ae --- /dev/null +++ b/frontend/src/features/area-search/components/TimelineControl.tsx @@ -0,0 +1,97 @@ +import { useAreaAnimationStore, PLAYBACK_SPEEDS } from '../stores/animationStore' + +export default function TimelineControl() { + const isPlaying = useAreaAnimationStore((s) => s.isPlaying) + const currentTime = useAreaAnimationStore((s) => s.currentTime) + const startTime = useAreaAnimationStore((s) => s.startTime) + const endTime = useAreaAnimationStore((s) => s.endTime) + const playbackSpeed = useAreaAnimationStore((s) => s.playbackSpeed) + const loop = useAreaAnimationStore((s) => s.loop) + const play = useAreaAnimationStore((s) => s.play) + const pause = useAreaAnimationStore((s) => s.pause) + const stop = useAreaAnimationStore((s) => s.stop) + const setPlaybackSpeed = useAreaAnimationStore((s) => s.setPlaybackSpeed) + const setProgressByRatio = useAreaAnimationStore((s) => s.setProgressByRatio) + const toggleLoop = useAreaAnimationStore((s) => s.toggleLoop) + + const duration = endTime - startTime + const progress = duration > 0 ? (currentTime - startTime) / duration : 0 + + const formatTime = (ms: number) => { + const d = new Date(ms) + return d.toLocaleString('ko-KR', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + } + + if (startTime === 0 && endTime === 0) return null + + return ( +
    + {/* 타임라인 */} +
    + setProgressByRatio(Number(e.target.value) / 1000)} + className="h-1.5 w-full cursor-pointer accent-primary" + /> +
    + {formatTime(startTime)} + {formatTime(currentTime)} + {formatTime(endTime)} +
    +
    + + {/* 컨트롤 */} +
    +
    + + + +
    + +
    + {PLAYBACK_SPEEDS.map((speed) => ( + + ))} +
    +
    +
    + ) +} diff --git a/frontend/src/features/area-search/components/ZoneDrawPanel.tsx b/frontend/src/features/area-search/components/ZoneDrawPanel.tsx new file mode 100644 index 0000000..a78a5fb --- /dev/null +++ b/frontend/src/features/area-search/components/ZoneDrawPanel.tsx @@ -0,0 +1,136 @@ +import { useCallback, useRef, useState } from 'react' +import { useAreaSearchStore } from '../stores/areaSearchStore' +import { MAX_ZONES, ZONE_COLORS } from '../types/areaSearch.types' +import type { ZoneDrawType } from '../types/areaSearch.types' + +const DRAW_BUTTONS: { type: ZoneDrawType; label: string; icon: string }[] = [ + { type: 'Polygon', label: '폴리곤', icon: '⬠' }, + { type: 'Box', label: '사각형', icon: '▭' }, + { type: 'Circle', label: '원', icon: '○' }, +] + +interface ZoneDrawPanelProps { + maxZones?: number + disabled?: boolean +} + +export default function ZoneDrawPanel({ maxZones = MAX_ZONES, disabled }: ZoneDrawPanelProps) { + const zones = useAreaSearchStore((s) => s.zones) + const activeDrawType = useAreaSearchStore((s) => s.activeDrawType) + const setActiveDrawType = useAreaSearchStore((s) => s.setActiveDrawType) + const removeZone = useAreaSearchStore((s) => s.removeZone) + const reorderZones = useAreaSearchStore((s) => s.reorderZones) + + const canAddZone = zones.length < maxZones + + // 드래그 순서 변경 + const dragIndexRef = useRef(null) + const [dragOverIndex, setDragOverIndex] = useState(null) + + const handleDrawClick = useCallback( + (type: ZoneDrawType) => { + if (!canAddZone || disabled) return + setActiveDrawType(activeDrawType === type ? null : type) + }, + [activeDrawType, canAddZone, disabled, setActiveDrawType], + ) + + const handleDragStart = useCallback((e: React.DragEvent, index: number) => { + dragIndexRef.current = index + e.dataTransfer.effectAllowed = 'move' + }, []) + + const handleDragOver = useCallback((e: React.DragEvent, index: number) => { + e.preventDefault() + setDragOverIndex(index) + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent, toIndex: number) => { + e.preventDefault() + const from = dragIndexRef.current + if (from !== null && from !== toIndex) { + reorderZones(from, toIndex) + } + dragIndexRef.current = null + setDragOverIndex(null) + }, + [reorderZones], + ) + + return ( +
    +
    + 구역 설정 + {zones.length}/{maxZones} +
    + + {/* 그리기 버튼 */} +
    + {DRAW_BUTTONS.map((btn) => ( + + ))} +
    + + {activeDrawType && ( +

    + {activeDrawType === 'Polygon' && '지도를 클릭하여 꼭짓점을 추가하세요. 더블클릭으로 완료.'} + {activeDrawType === 'Box' && '드래그하여 사각형을 그리세요.'} + {activeDrawType === 'Circle' && '드래그하여 원을 그리세요.'} + (ESC: 취소) +

    + )} + + {/* 구역 목록 */} + {zones.length > 0 && ( +
      + {zones.map((zone, index) => { + const color = ZONE_COLORS[zone.colorIndex % ZONE_COLORS.length] + return ( +
    • 1} + onDragStart={(e) => handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDrop={(e) => handleDrop(e, index)} + onDragEnd={() => { dragIndexRef.current = null; setDragOverIndex(null) }} + className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-xs transition ${ + dragOverIndex === index ? 'ring-1 ring-primary/40' : '' + } bg-surface-hover/50 hover:bg-surface-hover`} + > + {zones.length > 1 && ( + + )} + + 구역 {zone.name} + {zone.type === 'Box' ? '사각형' : zone.type === 'Circle' ? '원' : '폴리곤'} + +
    • + ) + })} +
    + )} +
    + ) +} diff --git a/frontend/src/features/area-search/hooks/useAreaSearchLayer.ts b/frontend/src/features/area-search/hooks/useAreaSearchLayer.ts new file mode 100644 index 0000000..a81648c --- /dev/null +++ b/frontend/src/features/area-search/hooks/useAreaSearchLayer.ts @@ -0,0 +1,178 @@ +import { useEffect, useRef, useCallback } from 'react' +import type { Layer } from '@deck.gl/core' +import { TripsLayer } from '@deck.gl/geo-layers' +import { useAreaSearchStore } from '../stores/areaSearchStore' +import { useAreaAnimationStore } from '../stores/animationStore' +import { createTrackPathLayer, createVesselIconLayer } from '../../vessel-map' +import { getTrackColor, getIconKey, getIconSize } from '../../vessel-map/utils/shipKindColors' +import type { TrackPathData, VesselIconData } from '../../vessel-map' + +const TRAIL_LENGTH_MS = 3_600_000 // 1시간 트레일 +const RENDER_INTERVAL_MS = 100 // ~10fps 스로틀 + +interface TripsData { + vesselId: string + shipKindCode: string + path: [number, number][] + timestamps: number[] +} + +interface UseAreaSearchLayerProps { + zoom: number + onLayersUpdate: (layers: Layer[]) => void +} + +/** + * 구역분석 Deck.gl 레이어: PathLayer(정적 항적) + TripsLayer(궤적) + IconLayer(보간 선박) + * zustand subscribe로 React 렌더 바이패스 + */ +export function useAreaSearchLayer({ zoom, onLayersUpdate }: UseAreaSearchLayerProps) { + const queryCompleted = useAreaSearchStore((s) => s.queryCompleted) + const tripsDataRef = useRef([]) + const pathDataRef = useRef([]) + const startTimeRef = useRef(0) + const lastRenderTimeRef = useRef(0) + + // 정적 데이터 구축 (결과 변경 시) + const buildStaticData = useCallback(() => { + const { tracks, disabledVesselIds } = useAreaSearchStore.getState() + const enabledTracks = tracks.filter((t) => !disabledVesselIds.has(t.vesselId)) + + if (enabledTracks.length === 0) { + tripsDataRef.current = [] + pathDataRef.current = [] + startTimeRef.current = 0 + return + } + + // 시간 기준점 + let min = Infinity + for (const t of enabledTracks) { + if (t.timestampsMs.length > 0 && t.timestampsMs[0] < min) min = t.timestampsMs[0] + } + startTimeRef.current = min + + // PathLayer 데이터 + pathDataRef.current = enabledTracks.map((t) => ({ + vesselId: t.vesselId, + path: t.geometry, + color: getTrackColor(t.shipKindCode), + shipKindCode: t.shipKindCode, + })) + + // TripsLayer 데이터 + tripsDataRef.current = enabledTracks.map((t) => ({ + vesselId: t.vesselId, + shipKindCode: t.shipKindCode || '000027', + path: t.geometry, + timestamps: t.timestampsMs.map((ts) => ts - min), + })) + }, []) + + // 결과/필터 변경 시 정적 데이터 재빌드 + useEffect(() => { + if (!queryCompleted) return + + buildStaticData() + + // 필터 변경 구독 + const unsub = useAreaSearchStore.subscribe( + (state, prev) => { + if (state.disabledVesselIds !== prev.disabledVesselIds || state.highlightedVesselId !== prev.highlightedVesselId) { + buildStaticData() + } + }, + ) + + return unsub + }, [queryCompleted, buildStaticData]) + + // 렌더링 루프 + useEffect(() => { + if (!queryCompleted || tripsDataRef.current.length === 0) { + onLayersUpdate([]) + return + } + + const renderFrame = () => { + const { currentTime } = useAreaAnimationStore.getState() + const relativeTime = currentTime - startTimeRef.current + const highlightedId = useAreaSearchStore.getState().highlightedVesselId + + // 보간 위치 + const positions = useAreaSearchStore.getState().getCurrentPositions(currentTime) + const iconData: VesselIconData[] = positions.map((p) => { + const displaySog = p.speed > 0 ? p.speed : 2 + return { + mmsi: p.vesselId, + position: [p.lon, p.lat], + angle: p.heading, + icon: getIconKey(p.shipKindCode, displaySog), + size: getIconSize(zoom, p.shipKindCode, displaySog), + shipNm: p.shipName, + shipKindCode: p.shipKindCode, + sog: p.speed, + } + }) + + // 하이라이트 적용 + const pathData = highlightedId + ? pathDataRef.current.map((d) => ({ + ...d, + color: d.vesselId === highlightedId + ? d.color + : [d.color[0], d.color[1], d.color[2], 60] as [number, number, number, number], + })) + : pathDataRef.current + + const layers: Layer[] = [ + createTrackPathLayer({ + id: 'area-path', + data: pathData, + widthMinPixels: 2, + }), + new TripsLayer({ + id: 'area-trails', + data: tripsDataRef.current, + getPath: (d) => d.path, + getTimestamps: (d) => d.timestamps, + getColor: [120, 120, 120, 180], + widthMinPixels: 2, + widthMaxPixels: 3, + jointRounded: true, + capRounded: true, + fadeTrail: true, + trailLength: TRAIL_LENGTH_MS, + currentTime: relativeTime, + }), + createVesselIconLayer({ + id: 'area-ships', + data: iconData, + pickable: false, + }), + ] + + onLayersUpdate(layers) + } + + renderFrame() + + let prevTime = useAreaAnimationStore.getState().currentTime + const unsub = useAreaAnimationStore.subscribe((state) => { + if (state.currentTime === prevTime) return + prevTime = state.currentTime + + if (!state.isPlaying) { + renderFrame() + return + } + const now = performance.now() + if (now - lastRenderTimeRef.current >= RENDER_INTERVAL_MS) { + lastRenderTimeRef.current = now + renderFrame() + } + }) + + return unsub + }, [queryCompleted, zoom, onLayersUpdate]) +} diff --git a/frontend/src/features/area-search/hooks/useStsLayer.ts b/frontend/src/features/area-search/hooks/useStsLayer.ts new file mode 100644 index 0000000..38a6d3c --- /dev/null +++ b/frontend/src/features/area-search/hooks/useStsLayer.ts @@ -0,0 +1,224 @@ +import { useEffect, useRef, useCallback } from 'react' +import type { Layer } from '@deck.gl/core' +import { ScatterplotLayer } from '@deck.gl/layers' +import { TripsLayer } from '@deck.gl/geo-layers' +import { useStsStore } from '../stores/stsStore' +import { useAreaAnimationStore } from '../stores/animationStore' +import { getContactRiskColor } from '../types/sts.types' +import { createTrackPathLayer, createVesselIconLayer } from '../../vessel-map' +import { getTrackColor, getIconKey, getIconSize } from '../../vessel-map/utils/shipKindColors' +import type { TrackPathData, VesselIconData } from '../../vessel-map' + +const TRAIL_LENGTH_MS = 3_600_000 +const RENDER_INTERVAL_MS = 100 + +interface TripsData { + vesselId: string + shipKindCode: string + path: [number, number][] + timestamps: number[] +} + +interface ContactPointData { + position: [number, number] + color: [number, number, number, number] + radius: number + groupIndex: number +} + +interface UseStsLayerProps { + zoom: number + onLayersUpdate: (layers: Layer[]) => void +} + +/** + * STS 분석 Deck.gl 레이어: + * PathLayer(항적) + ScatterplotLayer(접촉 포인트) + TripsLayer(궤적) + IconLayer(보간 선박) + */ +export function useStsLayer({ zoom, onLayersUpdate }: UseStsLayerProps) { + const queryCompleted = useStsStore((s) => s.queryCompleted) + const tripsDataRef = useRef([]) + const pathDataRef = useRef([]) + const contactPointsRef = useRef([]) + const startTimeRef = useRef(0) + const lastRenderTimeRef = useRef(0) + + const buildStaticData = useCallback(() => { + const { tracks, groupedContacts, disabledGroupIndices } = useStsStore.getState() + const disabledIds = useStsStore.getState().getDisabledVesselIds() + const enabledTracks = tracks.filter((t) => !disabledIds.has(t.vesselId)) + + if (enabledTracks.length === 0) { + tripsDataRef.current = [] + pathDataRef.current = [] + contactPointsRef.current = [] + startTimeRef.current = 0 + return + } + + let min = Infinity + for (const t of enabledTracks) { + if (t.timestampsMs.length > 0 && t.timestampsMs[0] < min) min = t.timestampsMs[0] + } + startTimeRef.current = min + + pathDataRef.current = enabledTracks.map((t) => ({ + vesselId: t.vesselId, + path: t.geometry, + color: getTrackColor(t.shipKindCode), + shipKindCode: t.shipKindCode, + })) + + tripsDataRef.current = enabledTracks.map((t) => ({ + vesselId: t.vesselId, + shipKindCode: t.shipKindCode || '000027', + path: t.geometry, + timestamps: t.timestampsMs.map((ts) => ts - min), + })) + + // 접촉 포인트 (그룹별 중심점 + 위험도 색상) + const points: ContactPointData[] = [] + groupedContacts.forEach((group, idx) => { + if (disabledGroupIndices.has(idx)) return + if (group.contactCenterPoint) { + points.push({ + position: group.contactCenterPoint, + color: getContactRiskColor(group.indicators), + radius: Math.max(50, Math.min(500, group.avgDistanceMeters / 2)), + groupIndex: idx, + }) + } + // 개별 접촉 포인트도 추가 + for (const c of group.contacts) { + if (c.contactCenterPoint && c.contactCenterPoint !== group.contactCenterPoint) { + points.push({ + position: c.contactCenterPoint, + color: getContactRiskColor(c.indicators), + radius: Math.max(30, Math.min(300, (c.avgDistanceMeters || 200) / 3)), + groupIndex: idx, + }) + } + } + }) + contactPointsRef.current = points + }, []) + + useEffect(() => { + if (!queryCompleted) return + + buildStaticData() + + const unsub = useStsStore.subscribe( + (state, prev) => { + if (state.disabledGroupIndices !== prev.disabledGroupIndices || state.highlightedGroupIndex !== prev.highlightedGroupIndex) { + buildStaticData() + } + }, + ) + + return unsub + }, [queryCompleted, buildStaticData]) + + useEffect(() => { + if (!queryCompleted || tripsDataRef.current.length === 0) { + onLayersUpdate([]) + return + } + + const renderFrame = () => { + const { currentTime } = useAreaAnimationStore.getState() + const relativeTime = currentTime - startTimeRef.current + const highlightedIdx = useStsStore.getState().highlightedGroupIndex + + const positions = useStsStore.getState().getCurrentPositions(currentTime) + const iconData: VesselIconData[] = positions.map((p) => { + const displaySog = p.speed > 0 ? p.speed : 2 + return { + mmsi: p.vesselId, + position: [p.lon, p.lat], + angle: p.heading, + icon: getIconKey(p.shipKindCode, displaySog), + size: getIconSize(zoom, p.shipKindCode, displaySog), + shipNm: p.shipName, + shipKindCode: p.shipKindCode, + sog: p.speed, + } + }) + + // 하이라이트 적용 + const contactPoints = highlightedIdx !== null + ? contactPointsRef.current.map((d) => ({ + ...d, + color: d.groupIndex === highlightedIdx + ? d.color + : [d.color[0], d.color[1], d.color[2], 60] as [number, number, number, number], + })) + : contactPointsRef.current + + const layers: Layer[] = [ + createTrackPathLayer({ + id: 'sts-path', + data: pathDataRef.current, + widthMinPixels: 2, + }), + new ScatterplotLayer({ + id: 'sts-contact-points', + data: contactPoints, + getPosition: (d) => d.position, + getFillColor: (d) => d.color, + getRadius: (d) => d.radius, + radiusUnits: 'meters' as const, + radiusMinPixels: 6, + radiusMaxPixels: 40, + opacity: 0.7, + stroked: true, + getLineColor: [255, 255, 255, 180], + getLineWidth: 1, + lineWidthUnits: 'pixels' as const, + pickable: false, + }), + new TripsLayer({ + id: 'sts-trails', + data: tripsDataRef.current, + getPath: (d) => d.path, + getTimestamps: (d) => d.timestamps, + getColor: [120, 120, 120, 180], + widthMinPixels: 2, + widthMaxPixels: 3, + jointRounded: true, + capRounded: true, + fadeTrail: true, + trailLength: TRAIL_LENGTH_MS, + currentTime: relativeTime, + }), + createVesselIconLayer({ + id: 'sts-ships', + data: iconData, + pickable: false, + }), + ] + + onLayersUpdate(layers) + } + + renderFrame() + + let prevTime = useAreaAnimationStore.getState().currentTime + const unsub = useAreaAnimationStore.subscribe((state) => { + if (state.currentTime === prevTime) return + prevTime = state.currentTime + + if (!state.isPlaying) { + renderFrame() + return + } + const now = performance.now() + if (now - lastRenderTimeRef.current >= RENDER_INTERVAL_MS) { + lastRenderTimeRef.current = now + renderFrame() + } + }) + + return unsub + }, [queryCompleted, zoom, onLayersUpdate]) +} diff --git a/frontend/src/features/area-search/hooks/useZoneDraw.ts b/frontend/src/features/area-search/hooks/useZoneDraw.ts new file mode 100644 index 0000000..b952e4a --- /dev/null +++ b/frontend/src/features/area-search/hooks/useZoneDraw.ts @@ -0,0 +1,205 @@ +import { useEffect, useRef } from 'react' +import { + TerraDraw, + TerraDrawPolygonMode, + TerraDrawRectangleMode, + TerraDrawCircleMode, + TerraDrawSelectMode, +} from 'terra-draw' +import { TerraDrawMapLibreGLAdapter } from 'terra-draw-maplibre-gl-adapter' +import type maplibregl from 'maplibre-gl' +import { useAreaSearchStore } from '../stores/areaSearchStore' +import { MAX_ZONES, ZONE_COLORS } from '../types/areaSearch.types' +import type { ZoneDrawType } from '../types/areaSearch.types' + +/** terra-draw 모드 이름 → ZoneDrawType 변환 */ +function toZoneType(mode: string): ZoneDrawType { + if (mode === 'rectangle') return 'Box' + if (mode === 'circle') return 'Circle' + return 'Polygon' +} + +/** ZoneDrawType → terra-draw 모드 이름 변환 */ +function toDrawMode(type: ZoneDrawType): string { + if (type === 'Box') return 'rectangle' + if (type === 'Circle') return 'circle' + return 'polygon' +} + +/** + * terra-draw 기반 폴리곤/사각형/원 그리기 + 편집 훅 + * + * Dark의 useZoneDraw.ts (OL Draw) + useZoneEdit.ts (OL Modify/Translate)를 + * terra-draw 하나로 통합. 좌표는 WGS84(4326) 네이티브. + */ +export function useZoneDraw(map: maplibregl.Map | null, maxZones = MAX_ZONES) { + const drawRef = useRef(null) + const featureZoneMap = useRef>(new Map()) // terraDrawFeatureId → zoneId + + useEffect(() => { + if (!map) return + + const toHex = (r: number, g: number, b: number) => + `#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}` as `#${string}` + + const colorForIndex = (idx: number) => { + const c = ZONE_COLORS[idx % ZONE_COLORS.length] + return toHex(c.stroke[0], c.stroke[1], c.stroke[2]) + } + + const fillForIndex = (idx: number) => { + const c = ZONE_COLORS[idx % ZONE_COLORS.length] + return toHex(c.fill[0], c.fill[1], c.fill[2]) + } + + const draw = new TerraDraw({ + adapter: new TerraDrawMapLibreGLAdapter({ map }), + modes: [ + new TerraDrawPolygonMode({ + styles: { + fillColor: fillForIndex(0), + outlineColor: colorForIndex(0), + outlineWidth: 2, + closingPointColor: colorForIndex(0), + closingPointWidth: 6, + closingPointOutlineColor: '#ffffff', + closingPointOutlineWidth: 2, + }, + }), + new TerraDrawRectangleMode({ + styles: { + fillColor: fillForIndex(0), + outlineColor: colorForIndex(0), + outlineWidth: 2, + }, + }), + new TerraDrawCircleMode({ + styles: { + fillColor: fillForIndex(0), + outlineColor: colorForIndex(0), + outlineWidth: 2, + }, + }), + new TerraDrawSelectMode({ + flags: { + polygon: { feature: { draggable: true, coordinates: { midpoints: true, draggable: true, deletable: true } } }, + rectangle: { feature: { draggable: true, coordinates: { midpoints: false, draggable: true, deletable: false } } }, + circle: { feature: { draggable: true, coordinates: { midpoints: false, draggable: false, deletable: false } } }, + }, + }), + ], + }) + + draw.start() + draw.setMode('static') + drawRef.current = draw + + // 그리기 완료 이벤트 + draw.on('finish', (id: string | number) => { + const fid = String(id) + const store = useAreaSearchStore.getState() + if (store.zones.length >= maxZones) { + draw.removeFeatures([fid]) + return + } + + const snapshot = draw.getSnapshot() + const feature = snapshot.find((f) => String(f.id) === fid) + if (!feature || feature.geometry.type !== 'Polygon') { + draw.removeFeatures([fid]) + return + } + + const coordinates = (feature.geometry.coordinates[0] as number[][]).map(([lon, lat]) => [lon, lat]) + const mode = feature.properties?.mode as string || 'polygon' + + store.addZone({ + type: toZoneType(mode), + coordinates, + terraDrawFeatureId: fid, + }) + + // featureId → zoneId 매핑 업데이트 + const zones = useAreaSearchStore.getState().zones + const lastZone = zones[zones.length - 1] + if (lastZone) { + featureZoneMap.current.set(fid, lastZone.id) + } + + draw.setMode('select') + }) + + // 편집 완료 이벤트 (Select 모드에서 드래그/꼭짓점 수정 후) + draw.on('change', (ids: (string | number)[]) => { + const snapshot = draw.getSnapshot() + for (const id of ids) { + const fid = String(id) + const zoneId = featureZoneMap.current.get(fid) + if (!zoneId) continue + const feature = snapshot.find((f) => String(f.id) === fid) + if (feature?.geometry.type === 'Polygon') { + const coords = (feature.geometry.coordinates[0] as number[][]).map(([lon, lat]) => [lon, lat]) + useAreaSearchStore.getState().updateZoneGeometry(zoneId, coords) + } + } + }) + + // ESC 키: 그리기 취소 → select 모드 복귀 + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + useAreaSearchStore.getState().setActiveDrawType(null) + draw.setMode('select') + } + } + document.addEventListener('keydown', handleKeyDown) + + return () => { + document.removeEventListener('keydown', handleKeyDown) + draw.stop() + drawRef.current = null + featureZoneMap.current.clear() + } + }, [map, maxZones]) + + // activeDrawType 변경 시 terra-draw 모드 전환 + useEffect(() => { + let prevType = useAreaSearchStore.getState().activeDrawType + const unsub = useAreaSearchStore.subscribe((state) => { + if (state.activeDrawType === prevType) return + prevType = state.activeDrawType + const draw = drawRef.current + if (!draw) return + if (state.activeDrawType) { + draw.setMode(toDrawMode(state.activeDrawType)) + } else { + draw.setMode('select') + } + }) + return unsub + }, []) + + // 구역 삭제 시 terra-draw에서도 feature 제거 + useEffect(() => { + let prevZones = useAreaSearchStore.getState().zones + const unsub = useAreaSearchStore.subscribe((state) => { + if (state.zones === prevZones) return + prevZones = state.zones + const draw = drawRef.current + if (!draw) return + + // 현재 zones에 없는 featureId 삭제 + const activeFeatureIds = new Set(state.zones.map((z) => z.terraDrawFeatureId).filter(Boolean)) + const toRemove: string[] = [] + for (const [featureId] of featureZoneMap.current) { + if (!activeFeatureIds.has(featureId)) { + toRemove.push(featureId) + } + } + if (toRemove.length > 0) { + try { draw.removeFeatures(toRemove) } catch { /* feature already removed */ } + toRemove.forEach((id) => featureZoneMap.current.delete(id)) + } + }) + return unsub + }, []) +} diff --git a/frontend/src/features/area-search/hooks/useZoneLayer.ts b/frontend/src/features/area-search/hooks/useZoneLayer.ts new file mode 100644 index 0000000..cf5a708 --- /dev/null +++ b/frontend/src/features/area-search/hooks/useZoneLayer.ts @@ -0,0 +1,130 @@ +import { useEffect, useRef } from 'react' +import type maplibregl from 'maplibre-gl' +import { useAreaSearchStore } from '../stores/areaSearchStore' +import { ZONE_COLORS } from '../types/areaSearch.types' + +const SOURCE_ID = 'zone-polygons' +const FILL_LAYER = 'zone-fill' +const LINE_LAYER = 'zone-line' +const LABEL_LAYER = 'zone-label' + +function buildGeoJSON(zones: { coordinates: number[][]; colorIndex: number; name: string }[]) { + return { + type: 'FeatureCollection' as const, + features: zones.map((z, i) => { + const c = ZONE_COLORS[z.colorIndex % ZONE_COLORS.length] + return { + type: 'Feature' as const, + id: i, + properties: { + name: z.name, + fillColor: `rgba(${c.fill[0]},${c.fill[1]},${c.fill[2]},${c.fill[3]})`, + strokeColor: `rgba(${c.stroke[0]},${c.stroke[1]},${c.stroke[2]},${c.stroke[3]})`, + }, + geometry: { + type: 'Polygon' as const, + coordinates: [z.coordinates], + }, + } + }), + } +} + +/** + * MapLibre GeoJSON source/layer로 구역 시각화 + * terra-draw 비활성 상태에서 구역 표시 용도 + */ +export function useZoneLayer(map: maplibregl.Map | null) { + const addedRef = useRef(false) + + useEffect(() => { + if (!map) return + + const addLayers = () => { + if (addedRef.current) return + if (map.getSource(SOURCE_ID)) return + + map.addSource(SOURCE_ID, { + type: 'geojson', + data: buildGeoJSON([]), + }) + + map.addLayer({ + id: FILL_LAYER, + type: 'fill', + source: SOURCE_ID, + paint: { + 'fill-color': ['get', 'fillColor'], + 'fill-opacity': 1, + }, + }) + + map.addLayer({ + id: LINE_LAYER, + type: 'line', + source: SOURCE_ID, + paint: { + 'line-color': ['get', 'strokeColor'], + 'line-width': 2, + }, + }) + + map.addLayer({ + id: LABEL_LAYER, + type: 'symbol', + source: SOURCE_ID, + layout: { + 'text-field': ['get', 'name'], + 'text-size': 14, + 'text-font': ['Open Sans Bold'], + 'text-anchor': 'center', + }, + paint: { + 'text-color': ['get', 'strokeColor'], + 'text-halo-color': '#ffffff', + 'text-halo-width': 1.5, + }, + }) + + addedRef.current = true + } + + // 맵 스타일 로드 후 레이어 추가 + if (map.isStyleLoaded()) { + addLayers() + } else { + map.once('styledata', addLayers) + } + + return () => { + if (!addedRef.current) return + try { + if (map.getLayer(LABEL_LAYER)) map.removeLayer(LABEL_LAYER) + if (map.getLayer(LINE_LAYER)) map.removeLayer(LINE_LAYER) + if (map.getLayer(FILL_LAYER)) map.removeLayer(FILL_LAYER) + if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID) + } catch { /* map already disposed */ } + addedRef.current = false + } + }, [map]) + + // zones 변경 시 GeoJSON source 갱신 + useEffect(() => { + if (!map) return + + let prevZones = useAreaSearchStore.getState().zones + const unsub = useAreaSearchStore.subscribe((state) => { + if (state.zones === prevZones) return + prevZones = state.zones + + if (!addedRef.current) return + const source = map.getSource(SOURCE_ID) as maplibregl.GeoJSONSource | undefined + if (!source) return + + const data = buildGeoJSON(state.zones) + source.setData(data as GeoJSON.FeatureCollection) + }) + + return unsub + }, [map]) +} diff --git a/frontend/src/features/area-search/index.ts b/frontend/src/features/area-search/index.ts new file mode 100644 index 0000000..30ec6e7 --- /dev/null +++ b/frontend/src/features/area-search/index.ts @@ -0,0 +1,28 @@ +// Types +export type { Zone, ZoneDrawType, SearchMode, HitDetail, AreaSearchSummary } from './types/areaSearch.types' +export { ZONE_COLORS, ZONE_NAMES, MAX_ZONES, SEARCH_MODE_OPTIONS, PLAYBACK_SPEEDS } from './types/areaSearch.types' + +export type { StsContact, StsVessel, StsIndicators, StsGroupedContact, StsSummary } from './types/sts.types' +export { STS_DEFAULTS, STS_LIMITS, INDICATOR_LABELS, getContactRiskColor, groupContactsByPair } from './types/sts.types' + +// Stores +export { useAreaSearchStore } from './stores/areaSearchStore' +export { useStsStore } from './stores/stsStore' +export { useAreaAnimationStore } from './stores/animationStore' + +// Services +export { fetchAreaSearch } from './services/areaSearchApi' +export { fetchVesselContacts } from './services/stsApi' + +// Hooks +export { useZoneDraw } from './hooks/useZoneDraw' +export { useZoneLayer } from './hooks/useZoneLayer' +export { useAreaSearchLayer } from './hooks/useAreaSearchLayer' +export { useStsLayer } from './hooks/useStsLayer' + +// Components +export { default as AreaSearchPanel } from './components/AreaSearchPanel' +export { default as TimelineControl } from './components/TimelineControl' + +// Utils +export { exportAreaSearchCSV } from './utils/csvExport' diff --git a/frontend/src/features/area-search/services/areaSearchApi.ts b/frontend/src/features/area-search/services/areaSearchApi.ts new file mode 100644 index 0000000..22d4b30 --- /dev/null +++ b/frontend/src/features/area-search/services/areaSearchApi.ts @@ -0,0 +1,125 @@ +import { postJson } from '../../../api/httpClient.ts' +import type { TrackSegment } from '../../vessel-map' +import type { SearchMode, HitDetail, AreaSearchSummary } from '../types/areaSearch.types' +import type { VesselTrackResult } from '../../../api/gisApi.ts' + +interface AreaSearchRequest { + startTime: string + endTime: string + mode: SearchMode + polygons: { id: string; name: string; coordinates: number[][] }[] +} + +interface AreaSearchRawResponse { + tracks: VesselTrackResult[] + hitDetails: Record + summary: AreaSearchSummary | null +} + +interface RawHitDetail { + polygonId: string + polygonName?: string + entryTimestamp?: number | string | null + exitTimestamp?: number | string | null + hitPointCount?: number + visitIndex?: number +} + +/** 타임스탬프 초→밀리초 변환 */ +function toMs(ts: number | string | null | undefined): number | null { + if (ts == null) return null + const num = typeof ts === 'number' ? ts : parseInt(ts, 10) + if (isNaN(num)) return null + return num < 10_000_000_000 ? num * 1000 : num +} + +/** CompactVesselTrack → TrackSegment 변환 */ +function toTrackSegment(raw: VesselTrackResult): TrackSegment { + return { + vesselId: raw.vesselId, + shipName: raw.shipName, + shipKindCode: raw.shipKindCode, + nationalCode: raw.nationalCode, + geometry: raw.geometry as [number, number][], + timestampsMs: raw.timestamps.map((t) => { + const n = typeof t === 'number' ? t : parseInt(t, 10) + return n < 10_000_000_000 ? n * 1000 : n + }), + speeds: raw.speeds, + totalDistance: raw.totalDistance, + avgSpeed: raw.avgSpeed, + maxSpeed: raw.maxSpeed, + pointCount: raw.pointCount ?? raw.geometry.length, + } +} + +/** 이진 탐색 기반 위치 보간 (hitDetail 진입/진출 위치 계산) */ +function interpolatePositionAtTime(track: TrackSegment, targetTime: number | null): number[] | null { + if (!targetTime || track.timestampsMs.length === 0) return null + const ts = track.timestampsMs + const geom = track.geometry + + if (targetTime <= ts[0]) return [...geom[0]] + if (targetTime >= ts[ts.length - 1]) return [...geom[geom.length - 1]] + + let lo = 0, hi = ts.length - 1 + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (ts[mid] < targetTime) lo = mid + 1 + else hi = mid + } + + const idx1 = Math.max(0, lo - 1) + const idx2 = lo + if (idx1 === idx2 || ts[idx1] === ts[idx2]) return [...geom[idx1]] + + const ratio = (targetTime - ts[idx1]) / (ts[idx2] - ts[idx1]) + return [ + geom[idx1][0] + (geom[idx2][0] - geom[idx1][0]) * ratio, + geom[idx1][1] + (geom[idx2][1] - geom[idx1][1]) * ratio, + ] +} + +export async function fetchAreaSearch( + startTime: string, + endTime: string, + mode: SearchMode, + polygons: { id: string; name: string; coordinates: number[][] }[], +): Promise<{ + tracks: TrackSegment[] + hitDetails: Record + summary: AreaSearchSummary | null +}> { + const request: AreaSearchRequest = { + startTime: startTime.length === 16 ? startTime + ':00' : startTime, + endTime: endTime.length === 16 ? endTime + ':00' : endTime, + mode, + polygons, + } + + const result = await postJson('/api/v2/tracks/area-search', request) + + const tracks = (result.tracks || []).map(toTrackSegment) + const trackMap = new Map(tracks.map((t) => [t.vesselId, t])) + + const hitDetails: Record = {} + for (const [vesselId, rawHits] of Object.entries(result.hitDetails || {})) { + const track = trackMap.get(vesselId) + hitDetails[vesselId] = (rawHits || []).map((h) => { + const entryMs = toMs(h.entryTimestamp) + const exitMs = toMs(h.exitTimestamp) + return { + polygonId: h.polygonId, + polygonName: h.polygonName, + visitIndex: h.visitIndex ?? 1, + entryTimestamp: entryMs, + exitTimestamp: exitMs, + hitPointCount: h.hitPointCount, + entryPosition: track ? interpolatePositionAtTime(track, entryMs) : null, + exitPosition: track ? interpolatePositionAtTime(track, exitMs) : null, + } + }) + } + + return { tracks, hitDetails, summary: result.summary } +} diff --git a/frontend/src/features/area-search/services/stsApi.ts b/frontend/src/features/area-search/services/stsApi.ts new file mode 100644 index 0000000..090eae5 --- /dev/null +++ b/frontend/src/features/area-search/services/stsApi.ts @@ -0,0 +1,112 @@ +import { postJson } from '../../../api/httpClient.ts' +import type { TrackSegment } from '../../vessel-map' +import type { StsContact, StsSummary } from '../types/sts.types' +import type { VesselTrackResult } from '../../../api/gisApi.ts' + +interface StsSearchRequest { + startTime: string + endTime: string + polygon: { id: string; name: string; coordinates: number[][] } + minContactDurationMinutes: number + maxContactDistanceMeters: number +} + +interface StsRawResponse { + contacts: StsRawContact[] + tracks: VesselTrackResult[] + summary: StsSummary | null +} + +interface StsRawContact { + vessel1: Record + vessel2: Record + contactStartTimestamp?: number | null + contactEndTimestamp?: number | null + contactDurationMinutes?: number + contactPointCount?: number + avgDistanceMeters?: number + minDistanceMeters: number + maxDistanceMeters: number + contactCenterPoint?: [number, number] + indicators?: Record +} + +function toMs(ts: number | string | null | undefined): number | null { + if (ts == null) return null + const num = typeof ts === 'number' ? ts : parseInt(String(ts), 10) + if (isNaN(num)) return null + return num < 10_000_000_000 ? num * 1000 : num +} + +function toTrackSegment(raw: VesselTrackResult): TrackSegment { + return { + vesselId: raw.vesselId, + shipName: raw.shipName, + shipKindCode: raw.shipKindCode, + nationalCode: raw.nationalCode, + geometry: raw.geometry as [number, number][], + timestampsMs: raw.timestamps.map((t) => { + const n = typeof t === 'number' ? t : parseInt(t, 10) + return n < 10_000_000_000 ? n * 1000 : n + }), + speeds: raw.speeds, + totalDistance: raw.totalDistance, + avgSpeed: raw.avgSpeed, + maxSpeed: raw.maxSpeed, + pointCount: raw.pointCount ?? raw.geometry.length, + } +} + +function convertVessel(raw: Record) { + return { + vesselId: String(raw.vesselId ?? ''), + vesselName: raw.vesselName as string | undefined, + shipType: raw.shipType as string | undefined, + shipKindCode: raw.shipKindCode as string | undefined, + nationalCode: raw.nationalCode as string | undefined, + insidePolygonStartTs: toMs(raw.insidePolygonStartTs as number | null), + insidePolygonEndTs: toMs(raw.insidePolygonEndTs as number | null), + insidePolygonDurationMinutes: raw.insidePolygonDurationMinutes as number | undefined, + estimatedAvgSpeedKnots: raw.estimatedAvgSpeedKnots as number | undefined, + } +} + +export async function fetchVesselContacts( + startTime: string, + endTime: string, + polygon: { id: string; name: string; coordinates: number[][] }, + minContactDurationMinutes: number, + maxContactDistanceMeters: number, +): Promise<{ + contacts: StsContact[] + tracks: TrackSegment[] + summary: StsSummary | null +}> { + const request: StsSearchRequest = { + startTime: startTime.length === 16 ? startTime + ':00' : startTime, + endTime: endTime.length === 16 ? endTime + ':00' : endTime, + polygon, + minContactDurationMinutes, + maxContactDistanceMeters, + } + + const result = await postJson('/api/v2/tracks/vessel-contacts', request) + + const tracks = (result.tracks || []).map(toTrackSegment) + + const contacts: StsContact[] = (result.contacts || []).map((c) => ({ + vessel1: convertVessel(c.vessel1), + vessel2: convertVessel(c.vessel2), + contactStartTimestamp: toMs(c.contactStartTimestamp), + contactEndTimestamp: toMs(c.contactEndTimestamp), + contactDurationMinutes: c.contactDurationMinutes, + contactPointCount: c.contactPointCount, + avgDistanceMeters: c.avgDistanceMeters, + minDistanceMeters: c.minDistanceMeters, + maxDistanceMeters: c.maxDistanceMeters, + contactCenterPoint: c.contactCenterPoint, + indicators: c.indicators, + })) + + return { contacts, tracks, summary: result.summary } +} diff --git a/frontend/src/features/area-search/stores/animationStore.ts b/frontend/src/features/area-search/stores/animationStore.ts new file mode 100644 index 0000000..26d0820 --- /dev/null +++ b/frontend/src/features/area-search/stores/animationStore.ts @@ -0,0 +1,121 @@ +import { create } from 'zustand' +import { PLAYBACK_SPEEDS } from '../types/areaSearch.types' + +interface AnimationState { + isPlaying: boolean + currentTime: number // Unix epoch ms + startTime: number + endTime: number + playbackSpeed: number // 1x ~ 1000x + loop: boolean + animationFrameId: number | null + lastFrameTime: number + + play: () => void + pause: () => void + stop: () => void + setPlaybackSpeed: (speed: number) => void + setCurrentTime: (time: number) => void + setProgressByRatio: (ratio: number) => void + setTimeRange: (start: number, end: number) => void + toggleLoop: () => void + getProgress: () => number // 0~100 + reset: () => void +} + +export { PLAYBACK_SPEEDS } + +export const useAreaAnimationStore = create((set, get) => ({ + isPlaying: false, + currentTime: 0, + startTime: 0, + endTime: 0, + playbackSpeed: 100, + loop: false, + animationFrameId: null, + lastFrameTime: 0, + + play: () => { + const state = get() + if (state.isPlaying) return + if (state.startTime >= state.endTime) return + + if (state.currentTime >= state.endTime) { + set({ currentTime: state.startTime }) + } + + set({ isPlaying: true, lastFrameTime: performance.now() }) + + const animate = (timestamp: number) => { + const s = get() + if (!s.isPlaying) return + + const deltaMs = timestamp - s.lastFrameTime + const increment = (deltaMs / 1000) * s.playbackSpeed * 1000 + let newTime = s.currentTime + increment + + if (newTime > s.endTime) { + if (s.loop) { + newTime = s.startTime + } else { + set({ isPlaying: false, currentTime: s.endTime, animationFrameId: null }) + return + } + } + + set({ currentTime: newTime, lastFrameTime: timestamp }) + const frameId = requestAnimationFrame(animate) + set({ animationFrameId: frameId }) + } + + const frameId = requestAnimationFrame(animate) + set({ animationFrameId: frameId }) + }, + + pause: () => { + const { animationFrameId } = get() + if (animationFrameId !== null) cancelAnimationFrame(animationFrameId) + set({ isPlaying: false, animationFrameId: null }) + }, + + stop: () => { + const { animationFrameId, startTime } = get() + if (animationFrameId !== null) cancelAnimationFrame(animationFrameId) + set({ isPlaying: false, animationFrameId: null, currentTime: startTime }) + }, + + setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }), + + setCurrentTime: (time) => set({ currentTime: time }), + + setProgressByRatio: (ratio) => { + const { startTime, endTime } = get() + const time = startTime + (endTime - startTime) * Math.max(0, Math.min(1, ratio)) + set({ currentTime: time }) + }, + + setTimeRange: (start, end) => set({ startTime: start, endTime: end, currentTime: start }), + + toggleLoop: () => set((s) => ({ loop: !s.loop })), + + getProgress: () => { + const { currentTime, startTime, endTime } = get() + if (endTime <= startTime) return 0 + return ((currentTime - startTime) / (endTime - startTime)) * 100 + }, + + reset: () => { + const { animationFrameId } = get() + if (animationFrameId !== null) cancelAnimationFrame(animationFrameId) + set({ + isPlaying: false, + currentTime: 0, + startTime: 0, + endTime: 0, + playbackSpeed: 100, + loop: false, + animationFrameId: null, + lastFrameTime: 0, + }) + }, +})) diff --git a/frontend/src/features/area-search/stores/areaSearchStore.ts b/frontend/src/features/area-search/stores/areaSearchStore.ts new file mode 100644 index 0000000..e0a17ef --- /dev/null +++ b/frontend/src/features/area-search/stores/areaSearchStore.ts @@ -0,0 +1,261 @@ +import { create } from 'zustand' +import type { TrackSegment, InterpolatedPosition } from '../../vessel-map' +import type { Zone, ZoneDrawType, SearchMode, HitDetail, AreaSearchSummary } from '../types/areaSearch.types' +import { ZONE_NAMES, MAX_ZONES } from '../types/areaSearch.types' + +let zoneIdCounter = 0 + +interface AreaSearchState { + // 구역 관리 + zones: Zone[] + activeDrawType: ZoneDrawType | null + selectedZoneId: string | null + + // 검색 조건 + searchMode: SearchMode + startTime: string // 'YYYY-MM-DDTHH:mm' + endTime: string + + // 검색 결과 + tracks: TrackSegment[] + hitDetails: Record + summary: AreaSearchSummary | null + isLoading: boolean + queryCompleted: boolean + + // UI 상태 + disabledVesselIds: Set + highlightedVesselId: string | null + showPaths: boolean + showTrail: boolean + + // 구역 관리 액션 + addZone: (zone: Pick) => void + removeZone: (zoneId: string) => void + clearZones: () => void + reorderZones: (from: number, to: number) => void + selectZone: (zoneId: string | null) => void + updateZoneGeometry: (zoneId: string, coordinates: number[][]) => void + setActiveDrawType: (type: ZoneDrawType | null) => void + + // 검색 + setSearchMode: (mode: SearchMode) => void + setStartTime: (time: string) => void + setEndTime: (time: string) => void + + // 결과 + setResults: (tracks: TrackSegment[], hitDetails: Record, summary: AreaSearchSummary | null) => void + setLoading: (loading: boolean) => void + + // 필터 + toggleVesselEnabled: (vesselId: string) => void + setHighlightedVesselId: (id: string | null) => void + + // 파생 + getEnabledTracks: () => TrackSegment[] + getCurrentPositions: (currentTime: number) => InterpolatedPosition[] + + // 초기화 + clearResults: () => void + reset: () => void +} + +/** 위치 보간 커서 캐시 (React 외부) */ +const positionCursors = new Map() + +// 기본 시간 범위: D-7 00:00 ~ D-1 23:59 +function defaultStartTime(): string { + const d = new Date() + d.setDate(d.getDate() - 7) + d.setHours(0, 0, 0, 0) + return d.toISOString().slice(0, 16) +} + +function defaultEndTime(): string { + const d = new Date() + d.setDate(d.getDate() - 1) + d.setHours(23, 59, 0, 0) + return d.toISOString().slice(0, 16) +} + +export const useAreaSearchStore = create((set, get) => ({ + zones: [], + activeDrawType: null, + selectedZoneId: null, + searchMode: 'SEQUENTIAL', + startTime: defaultStartTime(), + endTime: defaultEndTime(), + tracks: [], + hitDetails: {}, + summary: null, + isLoading: false, + queryCompleted: false, + disabledVesselIds: new Set(), + highlightedVesselId: null, + showPaths: true, + showTrail: true, + + addZone: (zone) => { + const { zones } = get() + if (zones.length >= MAX_ZONES) return + + const usedColors = new Set(zones.map((z) => z.colorIndex)) + let colorIndex = 0 + while (usedColors.has(colorIndex)) colorIndex++ + + const newZone: Zone = { + ...zone, + id: `zone-${++zoneIdCounter}`, + name: ZONE_NAMES[colorIndex] || `${colorIndex + 1}`, + colorIndex, + } + + set({ zones: [...zones, newZone], activeDrawType: null }) + }, + + removeZone: (zoneId) => { + set((s) => ({ zones: s.zones.filter((z) => z.id !== zoneId) })) + }, + + clearZones: () => set({ zones: [] }), + + reorderZones: (from, to) => { + const newZones = [...get().zones] + const [moved] = newZones.splice(from, 1) + newZones.splice(to, 0, moved) + set({ zones: newZones }) + }, + + selectZone: (zoneId) => set({ selectedZoneId: zoneId }), + + updateZoneGeometry: (zoneId, coordinates) => { + set((s) => ({ + zones: s.zones.map((z) => (z.id === zoneId ? { ...z, coordinates } : z)), + })) + }, + + setActiveDrawType: (type) => set({ activeDrawType: type }), + setSearchMode: (mode) => set({ searchMode: mode }), + setStartTime: (time) => set({ startTime: time }), + setEndTime: (time) => set({ endTime: time }), + + setResults: (tracks, hitDetails, summary) => { + positionCursors.clear() + set({ tracks, hitDetails, summary, queryCompleted: true, isLoading: false, disabledVesselIds: new Set() }) + }, + + setLoading: (loading) => set({ isLoading: loading }), + + toggleVesselEnabled: (vesselId) => { + const next = new Set(get().disabledVesselIds) + if (next.has(vesselId)) next.delete(vesselId) + else next.add(vesselId) + set({ disabledVesselIds: next }) + }, + + setHighlightedVesselId: (id) => set({ highlightedVesselId: id }), + + getEnabledTracks: () => { + const { tracks, disabledVesselIds } = get() + return tracks.filter((t) => !disabledVesselIds.has(t.vesselId)) + }, + + /** 커서 기반 위치 보간 — O(1) 재생, O(log n) seek */ + getCurrentPositions: (currentTime) => { + const { tracks, disabledVesselIds } = get() + const positions: InterpolatedPosition[] = [] + + for (const track of tracks) { + if (disabledVesselIds.has(track.vesselId)) continue + const ts = track.timestampsMs + if (ts.length === 0) continue + if (currentTime < ts[0] || currentTime > ts[ts.length - 1]) continue + + let cursor = positionCursors.get(track.vesselId) + if (cursor === undefined || cursor >= ts.length || (cursor > 0 && ts[cursor - 1] > currentTime)) { + let lo = 0, hi = ts.length - 1 + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (ts[mid] < currentTime) lo = mid + 1 + else hi = mid + } + cursor = lo + } else { + while (cursor < ts.length - 1 && ts[cursor] < currentTime) cursor++ + } + positionCursors.set(track.vesselId, cursor) + + const idx1 = Math.max(0, cursor - 1) + const idx2 = Math.min(ts.length - 1, cursor) + let lon: number, lat: number, heading: number, speed: number + + if (idx1 === idx2 || ts[idx1] === ts[idx2]) { + lon = track.geometry[idx1][0] + lat = track.geometry[idx1][1] + heading = 0 + speed = track.speeds[idx1] || 0 + } else { + const ratio = (currentTime - ts[idx1]) / (ts[idx2] - ts[idx1]) + const p1 = track.geometry[idx1] + const p2 = track.geometry[idx2] + lon = p1[0] + (p2[0] - p1[0]) * ratio + lat = p1[1] + (p2[1] - p1[1]) * ratio + speed = (track.speeds[idx1] || 0) + ((track.speeds[idx2] || 0) - (track.speeds[idx1] || 0)) * ratio + + const dLon = ((p2[0] - p1[0]) * Math.PI) / 180 + const lat1R = (p1[1] * Math.PI) / 180 + const lat2R = (p2[1] * Math.PI) / 180 + const y = Math.sin(dLon) * Math.cos(lat2R) + const x = Math.cos(lat1R) * Math.sin(lat2R) - Math.sin(lat1R) * Math.cos(lat2R) * Math.cos(dLon) + heading = (Math.atan2(y, x) * 180) / Math.PI + if (heading < 0) heading += 360 + } + + positions.push({ + vesselId: track.vesselId, + lon, + lat, + heading, + speed, + shipName: track.shipName || '', + shipKindCode: track.shipKindCode || '', + }) + } + + return positions + }, + + clearResults: () => { + positionCursors.clear() + set({ + tracks: [], + hitDetails: {}, + summary: null, + queryCompleted: false, + disabledVesselIds: new Set(), + highlightedVesselId: null, + }) + }, + + reset: () => { + positionCursors.clear() + zoneIdCounter = 0 + set({ + zones: [], + activeDrawType: null, + selectedZoneId: null, + searchMode: 'SEQUENTIAL', + startTime: defaultStartTime(), + endTime: defaultEndTime(), + tracks: [], + hitDetails: {}, + summary: null, + isLoading: false, + queryCompleted: false, + disabledVesselIds: new Set(), + highlightedVesselId: null, + showPaths: true, + showTrail: true, + }) + }, +})) diff --git a/frontend/src/features/area-search/stores/stsStore.ts b/frontend/src/features/area-search/stores/stsStore.ts new file mode 100644 index 0000000..ac0a2e4 --- /dev/null +++ b/frontend/src/features/area-search/stores/stsStore.ts @@ -0,0 +1,188 @@ +import { create } from 'zustand' +import type { TrackSegment, InterpolatedPosition } from '../../vessel-map' +import type { StsContact, StsGroupedContact, StsSummary } from '../types/sts.types' +import { STS_DEFAULTS, groupContactsByPair } from '../types/sts.types' + +interface StsState { + // 파라미터 + minContactDurationMinutes: number + maxContactDistanceMeters: number + + // 결과 + contacts: StsContact[] + groupedContacts: StsGroupedContact[] + tracks: TrackSegment[] + summary: StsSummary | null + isLoading: boolean + queryCompleted: boolean + + // UI + highlightedGroupIndex: number | null + disabledGroupIndices: Set + expandedGroupIndex: number | null + showPaths: boolean + showTrail: boolean + + // 액션 + setMinContactDuration: (val: number) => void + setMaxContactDistance: (val: number) => void + setResults: (result: { contacts: StsContact[]; tracks: TrackSegment[]; summary: StsSummary | null }) => void + setLoading: (loading: boolean) => void + setHighlightedGroupIndex: (idx: number | null) => void + setExpandedGroupIndex: (idx: number | null) => void + toggleGroupEnabled: (idx: number) => void + + // 파생 + getDisabledVesselIds: () => Set + getCurrentPositions: (currentTime: number) => InterpolatedPosition[] + + clearResults: () => void + reset: () => void +} + +const stsCursors = new Map() + +export const useStsStore = create((set, get) => ({ + minContactDurationMinutes: STS_DEFAULTS.MIN_CONTACT_DURATION, + maxContactDistanceMeters: STS_DEFAULTS.MAX_CONTACT_DISTANCE, + contacts: [], + groupedContacts: [], + tracks: [], + summary: null, + isLoading: false, + queryCompleted: false, + highlightedGroupIndex: null, + disabledGroupIndices: new Set(), + expandedGroupIndex: null, + showPaths: true, + showTrail: true, + + setMinContactDuration: (val) => set({ minContactDurationMinutes: val }), + setMaxContactDistance: (val) => set({ maxContactDistanceMeters: val }), + + setResults: ({ contacts, tracks, summary }) => { + stsCursors.clear() + const groupedContacts = groupContactsByPair(contacts) + set({ contacts, groupedContacts, tracks, summary, queryCompleted: true, isLoading: false, disabledGroupIndices: new Set() }) + }, + + setLoading: (loading) => set({ isLoading: loading }), + setHighlightedGroupIndex: (idx) => set({ highlightedGroupIndex: idx }), + setExpandedGroupIndex: (idx) => set((s) => ({ expandedGroupIndex: s.expandedGroupIndex === idx ? null : idx })), + + toggleGroupEnabled: (idx) => { + const next = new Set(get().disabledGroupIndices) + if (next.has(idx)) next.delete(idx) + else next.add(idx) + set({ disabledGroupIndices: next }) + }, + + /** 비활성 그룹에 속한 선박 ID 집합 */ + getDisabledVesselIds: () => { + const { groupedContacts, disabledGroupIndices } = get() + const ids = new Set() + disabledGroupIndices.forEach((idx) => { + const g = groupedContacts[idx] + if (g) { + ids.add(g.vessel1.vesselId) + ids.add(g.vessel2.vesselId) + } + }) + return ids + }, + + /** 커서 기반 위치 보간 */ + getCurrentPositions: (currentTime) => { + const { tracks } = get() + const disabledIds = get().getDisabledVesselIds() + const positions: InterpolatedPosition[] = [] + + for (const track of tracks) { + if (disabledIds.has(track.vesselId)) continue + const ts = track.timestampsMs + if (ts.length === 0 || currentTime < ts[0] || currentTime > ts[ts.length - 1]) continue + + let cursor = stsCursors.get(track.vesselId) + if (cursor === undefined || cursor >= ts.length || (cursor > 0 && ts[cursor - 1] > currentTime)) { + let lo = 0, hi = ts.length - 1 + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (ts[mid] < currentTime) lo = mid + 1 + else hi = mid + } + cursor = lo + } else { + while (cursor < ts.length - 1 && ts[cursor] < currentTime) cursor++ + } + stsCursors.set(track.vesselId, cursor) + + const idx1 = Math.max(0, cursor - 1) + const idx2 = Math.min(ts.length - 1, cursor) + let lon: number, lat: number, heading: number, speed: number + + if (idx1 === idx2 || ts[idx1] === ts[idx2]) { + lon = track.geometry[idx1][0] + lat = track.geometry[idx1][1] + heading = 0 + speed = track.speeds[idx1] || 0 + } else { + const ratio = (currentTime - ts[idx1]) / (ts[idx2] - ts[idx1]) + const p1 = track.geometry[idx1] + const p2 = track.geometry[idx2] + lon = p1[0] + (p2[0] - p1[0]) * ratio + lat = p1[1] + (p2[1] - p1[1]) * ratio + speed = (track.speeds[idx1] || 0) + ((track.speeds[idx2] || 0) - (track.speeds[idx1] || 0)) * ratio + + const dLon = ((p2[0] - p1[0]) * Math.PI) / 180 + const lat1R = (p1[1] * Math.PI) / 180 + const lat2R = (p2[1] * Math.PI) / 180 + const y = Math.sin(dLon) * Math.cos(lat2R) + const x = Math.cos(lat1R) * Math.sin(lat2R) - Math.sin(lat1R) * Math.cos(lat2R) * Math.cos(dLon) + heading = (Math.atan2(y, x) * 180) / Math.PI + if (heading < 0) heading += 360 + } + + positions.push({ + vesselId: track.vesselId, + lon, lat, heading, speed, + shipName: track.shipName || '', + shipKindCode: track.shipKindCode || '', + }) + } + + return positions + }, + + clearResults: () => { + stsCursors.clear() + set({ + contacts: [], + groupedContacts: [], + tracks: [], + summary: null, + queryCompleted: false, + disabledGroupIndices: new Set(), + highlightedGroupIndex: null, + expandedGroupIndex: null, + }) + }, + + reset: () => { + stsCursors.clear() + set({ + minContactDurationMinutes: STS_DEFAULTS.MIN_CONTACT_DURATION, + maxContactDistanceMeters: STS_DEFAULTS.MAX_CONTACT_DISTANCE, + contacts: [], + groupedContacts: [], + tracks: [], + summary: null, + isLoading: false, + queryCompleted: false, + highlightedGroupIndex: null, + disabledGroupIndices: new Set(), + expandedGroupIndex: null, + showPaths: true, + showTrail: true, + }) + }, +})) diff --git a/frontend/src/features/area-search/types/areaSearch.types.ts b/frontend/src/features/area-search/types/areaSearch.types.ts new file mode 100644 index 0000000..1bd4659 --- /dev/null +++ b/frontend/src/features/area-search/types/areaSearch.types.ts @@ -0,0 +1,64 @@ +/** 구역 그리기 유형 */ +export type ZoneDrawType = 'Polygon' | 'Box' | 'Circle' + +/** 검색 모드 */ +export type SearchMode = 'ANY' | 'ALL' | 'SEQUENTIAL' + +/** 구역 정의 */ +export interface Zone { + id: string + name: string // 'A' | 'B' | 'C' + type: ZoneDrawType + coordinates: number[][] // [[lon, lat], ...] WGS84 (닫힌 폴리곤) + colorIndex: number // 0, 1, 2 + terraDrawFeatureId?: string // terra-draw 내부 feature ID +} + +/** 구역 진입/진출 상세 */ +export interface HitDetail { + polygonId: string + polygonName?: string + visitIndex: number // 1-based (동일 구역 재방문 시 증가) + entryTimestamp: number | null // Unix epoch ms + exitTimestamp: number | null // Unix epoch ms + hitPointCount?: number + entryPosition?: number[] | null // [lon, lat] (보간) + exitPosition?: number[] | null // [lon, lat] (보간) +} + +/** 구역 검색 결과 요약 */ +export interface AreaSearchSummary { + totalVessels: number + totalPoints?: number + mode?: SearchMode + processingTimeMs?: number + polygonIds?: string[] + cachedDates?: string[] +} + +/** 구역 색상 정의 */ +export interface ZoneColor { + fill: [number, number, number, number] // RGBA (0~255, alpha 0~1로 사용) + stroke: [number, number, number, number] + label: string // CSS color +} + +/** 구역 색상 팔레트 (최대 3개) */ +export const ZONE_COLORS: ZoneColor[] = [ + { fill: [255, 59, 48, 0.15], stroke: [255, 59, 48, 0.8], label: '#FF3B30' }, // A: 빨강 + { fill: [0, 199, 190, 0.15], stroke: [0, 199, 190, 0.8], label: '#00C7BE' }, // B: 청록 + { fill: [255, 204, 0, 0.15], stroke: [255, 204, 0, 0.8], label: '#FFCC00' }, // C: 노랑 +] + +export const ZONE_NAMES = ['A', 'B', 'C'] as const +export const MAX_ZONES = 3 + +/** 검색 모드 옵션 */ +export const SEARCH_MODE_OPTIONS: { value: SearchMode; label: string; desc: string }[] = [ + { value: 'ANY', label: '합집합', desc: '어느 구역이든 통과한 선박' }, + { value: 'ALL', label: '교집합', desc: '모든 구역을 통과한 선박' }, + { value: 'SEQUENTIAL', label: '순차통과', desc: '구역을 순서대로 통과한 선박' }, +] + +/** 재생 배속 옵션 */ +export const PLAYBACK_SPEEDS = [1, 10, 50, 100, 500, 1000] as const diff --git a/frontend/src/features/area-search/types/sts.types.ts b/frontend/src/features/area-search/types/sts.types.ts new file mode 100644 index 0000000..0a15c7e --- /dev/null +++ b/frontend/src/features/area-search/types/sts.types.ts @@ -0,0 +1,152 @@ +/** STS 선박 정보 */ +export interface StsVessel { + vesselId: string + vesselName?: string + shipType?: string + shipKindCode?: string + nationalCode?: string + insidePolygonStartTs?: number | null // Unix epoch ms + insidePolygonEndTs?: number | null + insidePolygonDurationMinutes?: number + estimatedAvgSpeedKnots?: number +} + +/** STS 환적 의심 지표 */ +export interface StsIndicators { + lowSpeedContact?: boolean // 양 선박 저속 (< 3kn) + differentVesselTypes?: boolean // 이종 선박 접촉 + differentNationalities?: boolean // 외국적 선박 접촉 + nightTimeContact?: boolean // 야간 접촉 (22:00~06:00 KST) + [key: string]: boolean | undefined +} + +/** 단일 접촉 이벤트 */ +export interface StsContact { + vessel1: StsVessel + vessel2: StsVessel + contactStartTimestamp: number | null // Unix epoch ms + contactEndTimestamp: number | null + contactDurationMinutes?: number + contactPointCount?: number + avgDistanceMeters?: number + minDistanceMeters: number + maxDistanceMeters: number + contactCenterPoint?: [number, number] // [lon, lat] + indicators?: StsIndicators +} + +/** 그룹화된 접촉 (동일 선박 쌍의 여러 접촉 통합) */ +export interface StsGroupedContact { + pairKey: string // "vesselId1_vesselId2" (정렬) + vessel1: StsVessel + vessel2: StsVessel + contacts: StsContact[] + totalDurationMinutes: number + avgDistanceMeters: number + minDistanceMeters: number + maxDistanceMeters: number + contactCenterPoint?: [number, number] + totalContactPointCount: number + indicators: StsIndicators +} + +/** STS 검색 결과 요약 */ +export interface StsSummary { + totalContactPairs: number + totalVesselsInvolved: number + totalVesselsInPolygon?: number + processingTimeMs?: number + polygonId?: string + cachedDates?: string[] +} + +/** STS 파라미터 제한 */ +export const STS_DEFAULTS = { + MIN_CONTACT_DURATION: 60, // 분 + MAX_CONTACT_DISTANCE: 500, // 미터 +} as const + +export const STS_LIMITS = { + DURATION_MIN: 30, + DURATION_MAX: 360, + DISTANCE_MIN: 50, + DISTANCE_MAX: 5000, +} as const + +/** Indicator 위험도 색상 (RGBA) */ +export function getContactRiskColor(indicators?: StsIndicators): [number, number, number, number] { + if (!indicators) return [150, 150, 150, 200] + const count = Object.values(indicators).filter(Boolean).length + if (count >= 3) return [231, 76, 60, 220] // 빨강 + if (count === 2) return [243, 156, 18, 220] // 주황 + if (count === 1) return [241, 196, 15, 200] // 노랑 + return [150, 150, 150, 200] // 회색 +} + +/** Indicator 라벨 */ +export const INDICATOR_LABELS: Record = { + lowSpeedContact: '저속', + differentVesselTypes: '이종', + differentNationalities: '외국적', + nightTimeContact: '야간', +} + +/** 접촉 쌍 그룹핑 */ +export function groupContactsByPair(contacts: StsContact[]): StsGroupedContact[] { + const groupMap = new Map() + + for (const contact of contacts) { + const v1 = contact.vessel1.vesselId + const v2 = contact.vessel2.vesselId + const pairKey = v1 < v2 ? `${v1}_${v2}` : `${v2}_${v1}` + + if (!groupMap.has(pairKey)) { + groupMap.set(pairKey, { + pairKey, + vessel1: v1 < v2 ? contact.vessel1 : contact.vessel2, + vessel2: v1 < v2 ? contact.vessel2 : contact.vessel1, + contacts: [], + totalDurationMinutes: 0, + avgDistanceMeters: 0, + minDistanceMeters: Infinity, + maxDistanceMeters: 0, + totalContactPointCount: 0, + indicators: {}, + }) + } + groupMap.get(pairKey)!.contacts.push(contact) + } + + return [...groupMap.values()].map((group) => { + group.contacts.sort((a, b) => (a.contactStartTimestamp ?? 0) - (b.contactStartTimestamp ?? 0)) + + group.totalDurationMinutes = group.contacts.reduce((s, c) => s + (c.contactDurationMinutes || 0), 0) + + // 가중 평균 거리 (contactPointCount 기준) + let totalWeight = 0 + let weightedSum = 0 + for (const c of group.contacts) { + const w = c.contactPointCount || 1 + weightedSum += (c.avgDistanceMeters || 0) * w + totalWeight += w + } + group.avgDistanceMeters = totalWeight > 0 ? Math.round(weightedSum / totalWeight) : 0 + group.minDistanceMeters = Math.min(...group.contacts.map((c) => c.minDistanceMeters)) + group.maxDistanceMeters = Math.max(...group.contacts.map((c) => c.maxDistanceMeters)) + group.totalContactPointCount = group.contacts.reduce((s, c) => s + (c.contactPointCount || 0), 0) + group.contactCenterPoint = group.contacts[0]?.contactCenterPoint + + // indicators OR 합산 + const merged: StsIndicators = {} + for (const c of group.contacts) { + if (c.indicators) { + for (const [k, v] of Object.entries(c.indicators)) { + if (v) merged[k] = true + } + } + } + group.indicators = merged + + return group + }) +} diff --git a/frontend/src/features/area-search/utils/csvExport.ts b/frontend/src/features/area-search/utils/csvExport.ts new file mode 100644 index 0000000..0768f3b --- /dev/null +++ b/frontend/src/features/area-search/utils/csvExport.ts @@ -0,0 +1,111 @@ +import type { TrackSegment } from '../../vessel-map' +import { getShipKindLabel } from '../../vessel-map' +import type { HitDetail, Zone } from '../types/areaSearch.types' + +function escapeCsv(value: string | number): string { + const str = String(value ?? '') + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +function formatTs(ms: number | null): string { + if (!ms) return '' + const d = new Date(ms) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} + +function formatPos(pos: number[] | null | undefined): string { + if (!pos || pos.length < 2) return '' + const lat = pos[1] + const lon = pos[0] + return `${Math.abs(lat).toFixed(4)}${lat >= 0 ? 'N' : 'S'} ${Math.abs(lon).toFixed(4)}${lon >= 0 ? 'E' : 'W'}` +} + +/** + * 구역 분석 결과 CSV 내보내기 + * - 다중 방문 동적 컬럼 지원 + * - BOM + UTF-8 (한글 엑셀 호환) + */ +export function exportAreaSearchCSV( + tracks: TrackSegment[], + hitDetails: Record, + zones: Zone[], +): void { + // 구역별 최대 방문 횟수 + const maxVisits: Record = {} + zones.forEach((z) => { maxVisits[z.id] = 1 }) + Object.values(hitDetails).forEach((hits) => { + const countByZone: Record = {} + hits.forEach((h) => { countByZone[h.polygonId] = (countByZone[h.polygonId] || 0) + 1 }) + for (const [zoneId, count] of Object.entries(countByZone)) { + maxVisits[zoneId] = Math.max(maxVisits[zoneId] || 0, count) + } + }) + + // 헤더 + const base = ['MMSI', '선박명', '선종', '국적', '포인트수', '이동거리(NM)', '평균속도(kn)', '최대속도(kn)'] + const zoneHeaders: string[] = [] + zones.forEach((zone) => { + const max = maxVisits[zone.id] || 1 + for (let v = 1; v <= max; v++) { + const prefix = `구역${zone.name}${max > 1 ? `_${v}차` : ''}` + zoneHeaders.push(`${prefix}_진입시각`, `${prefix}_진입위치`, `${prefix}_진출시각`, `${prefix}_진출위치`) + } + }) + + const headers = [...base, ...zoneHeaders] + + // 행 + const rows = tracks.map((track) => { + const row: (string | number)[] = [ + track.vesselId, + track.shipName || '', + getShipKindLabel(track.shipKindCode), + track.nationalCode || '', + track.pointCount ?? track.geometry.length, + track.totalDistance?.toFixed(2) ?? '', + track.avgSpeed?.toFixed(1) ?? '', + track.maxSpeed?.toFixed(1) ?? '', + ] + + const hits = hitDetails[track.vesselId] || [] + zones.forEach((zone) => { + const max = maxVisits[zone.id] || 1 + const zoneHits = hits + .filter((h) => h.polygonId === zone.id) + .sort((a, b) => (a.visitIndex || 1) - (b.visitIndex || 1)) + + for (let v = 0; v < max; v++) { + const hit = zoneHits[v] + if (hit) { + row.push(formatTs(hit.entryTimestamp), formatPos(hit.entryPosition), formatTs(hit.exitTimestamp), formatPos(hit.exitPosition)) + } else { + row.push('', '', '', '') + } + } + }) + + return row + }) + + // CSV 생성 + 다운로드 + const lines = [headers.map(escapeCsv).join(','), ...rows.map((r) => r.map(escapeCsv).join(','))] + const csv = lines.join('\n') + + const BOM = '\uFEFF' + const blob = new Blob([BOM + csv], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + + const now = new Date() + const pad = (n: number) => String(n).padStart(2, '0') + const filename = `항적분석_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.csv` + + const link = document.createElement('a') + link.href = url + link.download = filename + link.click() + URL.revokeObjectURL(url) +} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 6f2783a..c33c0f0 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -127,6 +127,8 @@ const en = { 'explorer.recentPositions': 'Recent Positions', 'explorer.vesselTracks': 'Vessel Tracks', 'explorer.viewportReplay': 'Viewport Replay', + 'explorer.areaSearch': 'Area Track Analysis', + 'explorer.stsAnalysis': 'STS Contact Analysis', 'explorer.parameters': 'Parameters', 'explorer.positionsDesc': 'Fetches vessels with position updates within 10 minutes.', 'explorer.vesselDesc': 'Fetches tracks for specific vessels by MMSI list.', diff --git a/frontend/src/i18n/ko.ts b/frontend/src/i18n/ko.ts index aca3a7d..8bae93d 100644 --- a/frontend/src/i18n/ko.ts +++ b/frontend/src/i18n/ko.ts @@ -127,6 +127,8 @@ const ko = { 'explorer.recentPositions': '최근 위치', 'explorer.vesselTracks': '선박별 항적', 'explorer.viewportReplay': '뷰포트 리플레이', + 'explorer.areaSearch': '구역 항적 분석', + 'explorer.stsAnalysis': 'STS 접촉 분석', 'explorer.parameters': '파라미터', 'explorer.positionsDesc': '최근 10분 이내 위치 업데이트된 선박 목록을 조회합니다.', 'explorer.vesselDesc': 'MMSI 목록으로 특정 선박의 항적을 조회합니다.', diff --git a/frontend/src/pages/ApiExplorer.tsx b/frontend/src/pages/ApiExplorer.tsx index aa6e90b..c759d2d 100644 --- a/frontend/src/pages/ApiExplorer.tsx +++ b/frontend/src/pages/ApiExplorer.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useMemo } from 'react' +import { useState, useCallback, useMemo } from 'react' import { useI18n } from '../hooks/useI18n.ts' import MapContainer from '../components/map/MapContainer.tsx' import Sidebar from '../components/layout/Sidebar.tsx' @@ -25,8 +25,17 @@ import { ReplaySetupPanel, ReplayControlPanel, } from '../features/viewport-replay' +import { + useAreaSearchStore, + useStsStore, + useAreaAnimationStore, + useAreaSearchLayer, + useStsLayer, + AreaSearchPanel, + TimelineControl, +} from '../features/area-search' -type ApiMode = 'positions' | 'vessel' | 'replay' +type ApiMode = 'positions' | 'vessel' | 'replay' | 'area-search' | 'sts' /** 최근위치 — 선택된 선박의 원본 데이터 */ function usePositionRawData() { @@ -90,13 +99,57 @@ function useReplayRawData() { }, [vesselChunks, queryCompleted, receivedChunks]) } +/** 구역분석 — 조회 결과 요약 */ +function useAreaSearchRawData() { + const tracks = useAreaSearchStore((s) => s.tracks) + const summary = useAreaSearchStore((s) => s.summary) + + return useMemo(() => { + if (tracks.length === 0) return null + return { + summary, + vessels: tracks.map((t) => ({ + vesselId: t.vesselId, + shipName: t.shipName, + shipKindCode: t.shipKindCode, + pointCount: t.geometry.length, + totalDistance: t.totalDistance?.toFixed(2), + avgSpeed: t.avgSpeed?.toFixed(1), + })), + } + }, [tracks, summary]) +} + +/** STS — 접촉 분석 결과 요약 */ +function useStsRawData() { + const groupedContacts = useStsStore((s) => s.groupedContacts) + const summary = useStsStore((s) => s.summary) + + return useMemo(() => { + if (groupedContacts.length === 0) return null + return { + summary, + contactPairs: groupedContacts.map((g) => ({ + vessel1: g.vessel1.vesselName || g.vessel1.vesselId, + vessel2: g.vessel2.vesselName || g.vessel2.vesselId, + contacts: g.contacts.length, + totalDuration: g.totalDurationMinutes, + avgDistance: g.avgDistanceMeters, + indicators: Object.entries(g.indicators).filter(([, v]) => v).map(([k]) => k), + })), + } + }, [groupedContacts, summary]) +} + export default function ApiExplorer() { const { t } = useI18n() const [mode, setMode] = useState('positions') - const mapRef = useRef(null) + const [mapInstance, setMapInstance] = useState(null) const [zoom, setZoom] = useState(7) const [trackMmsi, setTrackMmsi] = useState() const [replayLayers, setReplayLayers] = useState([]) + const [areaLayers, setAreaLayers] = useState([]) + const [stsLayers, setStsLayers] = useState([]) const selectVessel = usePositionStore((s) => s.selectVessel) @@ -122,31 +175,72 @@ export default function ApiExplorer() { onLayersUpdate: handleReplayLayersUpdate, }) + // Deck.gl 레이어 — area search + const handleAreaLayersUpdate = useCallback((layers: Layer[]) => { + setAreaLayers(layers) + }, []) + + useAreaSearchLayer({ + zoom, + onLayersUpdate: handleAreaLayersUpdate, + }) + + // Deck.gl 레이어 — STS + const handleStsLayersUpdate = useCallback((layers: Layer[]) => { + setStsLayers(layers) + }, []) + + useStsLayer({ + zoom, + onLayersUpdate: handleStsLayersUpdate, + }) + const handleMapReady = useCallback((m: maplibregl.Map) => { - mapRef.current = m + setMapInstance(m) setZoom(m.getZoom()) m.on('zoom', () => setZoom(m.getZoom())) }, []) + // 모드 전환 핸들러 (이전 모드 리소스 정리 포함) + const handleModeChange = useCallback((newMode: ApiMode) => { + setMode((prev) => { + if (prev === 'area-search' && newMode !== 'area-search') { + useAreaSearchStore.getState().clearResults() + useAreaAnimationStore.getState().reset() + } + if (prev === 'sts' && newMode !== 'sts') { + useStsStore.getState().clearResults() + useAreaAnimationStore.getState().reset() + } + return newMode + }) + }, []) + // 최근위치 → 항적조회 전환 const handleTrackQuery = useCallback((mmsi: string) => { setTrackMmsi(mmsi) - setMode('vessel') + handleModeChange('vessel') selectVessel(null) - }, [selectVessel]) + }, [selectVessel, handleModeChange]) // Raw Data 훅 const positionData = usePositionRawData() const trackData = useTrackRawData() const replayData = useReplayRawData() + const areaSearchData = useAreaSearchRawData() + const stsData = useStsRawData() // 모드별 Deck.gl 레이어 const deckLayers: Layer[] = mode === 'positions' ? positionLayers : mode === 'vessel' ? trackLayers : mode === 'replay' ? replayLayers : + mode === 'area-search' ? areaLayers : + mode === 'sts' ? stsLayers : [] + const isAreaMode = mode === 'area-search' || mode === 'sts' + return (
    {/* Sidebar */} @@ -165,10 +259,12 @@ export default function ApiExplorer() { { value: 'positions' as const, label: t('explorer.recentPositions') }, { value: 'vessel' as const, label: t('explorer.vesselTracks') }, { value: 'replay' as const, label: '뷰포트 리플레이' }, + { value: 'area-search' as const, label: t('explorer.areaSearch') }, + { value: 'sts' as const, label: t('explorer.stsAnalysis') }, ] as const).map(opt => (
    @@ -238,12 +354,15 @@ export default function ApiExplorer() { {mode === 'positions' && t('explorer.recentPositions')} {mode === 'vessel' && t('explorer.vesselTracks')} {mode === 'replay' && '뷰포트 리플레이'} + {mode === 'area-search' && t('explorer.areaSearch')} + {mode === 'sts' && t('explorer.stsAnalysis')} {/* 모드별 오버레이 */} {mode === 'positions' && } {mode === 'vessel' && } {mode === 'replay' && } + {isAreaMode && } ) -- 2.45.2