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 (
+
+ {/* 시간 범위 */}
+
+
+ {/* 구역 그리기 */}
+
+
+ {/* 모드별 탭 */}
+ {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 && (
+
+
+ 결과 선박
+
+
+
+
+ )}
+
+ )
+}
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