feat: 다중구역이동 항적 분석 + STS 접촉 분석 프론트엔드 이관

- 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 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-20 17:07:14 +09:00
부모 f41463d0f2
커밋 1cc25f9f3b
24개의 변경된 파일2765개의 추가작업 그리고 8개의 파일을 삭제

파일 보기

@ -18,6 +18,8 @@
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",
"recharts": "^2.15.3", "recharts": "^2.15.3",
"terra-draw": "^1.25.0",
"terra-draw-maplibre-gl-adapter": "^1.3.0",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
@ -5367,6 +5369,22 @@
"url": "https://opencollective.com/webpack" "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": { "node_modules/texture-compressor": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz", "resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz",

파일 보기

@ -20,6 +20,8 @@
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",
"recharts": "^2.15.3", "recharts": "^2.15.3",
"terra-draw": "^1.25.0",
"terra-draw-maplibre-gl-adapter": "^1.3.0",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {

파일 보기

@ -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 (
<div className="space-y-3">
{/* 시간 범위 */}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-muted"> </label>
<div className="flex gap-2">
<input
type="datetime-local"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-foreground"
/>
<span className="self-center text-xs text-muted">~</span>
<input
type="datetime-local"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-foreground"
/>
</div>
</div>
{/* 구역 그리기 */}
<ZoneDrawPanel maxZones={maxZones} disabled={isLoading} />
{/* 모드별 탭 */}
{mode === 'area-search' && <AreaSearchTab />}
{mode === 'sts' && <StsAnalysisTab />}
{/* 조회 버튼 */}
<button
onClick={handleSearch}
disabled={zones.length === 0 || isLoading}
className="w-full rounded-md bg-primary px-3 py-2 text-sm font-medium text-white transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? '조회 중...' : '조회'}
</button>
</div>
)
}

파일 보기

@ -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 (
<div className="space-y-2">
{/* 검색 모드 */}
<div>
<label className="mb-1 block text-xs font-medium text-muted"> </label>
<div className="space-y-1">
{SEARCH_MODE_OPTIONS.map((opt) => (
<label
key={opt.value}
className={`flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs transition ${
searchMode === opt.value
? 'bg-primary/10 text-primary'
: 'text-foreground/70 hover:bg-surface-hover'
}`}
>
<input
type="radio"
name="searchMode"
value={opt.value}
checked={searchMode === opt.value}
onChange={() => handleModeChange(opt.value)}
className="accent-primary"
/>
<span className="font-medium">{opt.label}</span>
<span className="text-[10px] text-muted">{opt.desc}</span>
</label>
))}
</div>
</div>
{/* 결과 요약 */}
{summary && (
<div className="flex items-center justify-between rounded-md bg-primary/5 px-2 py-1.5 text-xs">
<span className="font-medium text-primary">{summary.totalVessels} </span>
{summary.processingTimeMs != null && (
<span className="text-muted">{summary.processingTimeMs}ms</span>
)}
</div>
)}
{/* 결과 선박 리스트 */}
{tracks.length > 0 && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted"> </span>
<button
onClick={handleExportCSV}
className="text-[10px] text-primary hover:underline"
>
CSV
</button>
</div>
<ul className="max-h-48 space-y-0.5 overflow-y-auto">
{tracks.map((track) => {
const isDisabled = disabledVesselIds.has(track.vesselId)
const isHighlighted = highlightedVesselId === track.vesselId
const hits = hitDetails[track.vesselId] || []
return (
<li
key={track.vesselId}
onMouseEnter={() => 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)}
>
<input
type="checkbox"
checked={!isDisabled}
onChange={() => toggleVesselEnabled(track.vesselId)}
onClick={(e) => e.stopPropagation()}
className="accent-primary"
/>
<span className="font-mono text-[10px] text-muted">{track.vesselId}</span>
<span className="truncate">{track.shipName || '-'}</span>
<span className="text-[10px] text-muted">{getShipKindLabel(track.shipKindCode)}</span>
{hits.length > 0 && (
<span className="ml-auto text-[10px] text-primary">{hits.length}</span>
)}
</li>
)
})}
</ul>
</div>
)}
</div>
)
}

파일 보기

@ -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 (
<div className="space-y-3">
{/* 파라미터 슬라이더 */}
<div className="space-y-2">
<label className="block text-xs font-medium text-muted">STS </label>
{/* 최소 접촉 시간 */}
<div>
<div className="flex items-center justify-between text-[11px]">
<span className="text-foreground/70"> </span>
<span className="font-mono font-medium text-primary">{minDuration}</span>
</div>
<input
type="range"
min={STS_LIMITS.DURATION_MIN}
max={STS_LIMITS.DURATION_MAX}
step={10}
value={minDuration}
onChange={(e) => setMinDuration(Number(e.target.value))}
className="w-full accent-primary"
/>
<div className="flex justify-between text-[9px] text-muted">
<span>{STS_LIMITS.DURATION_MIN}</span>
<span>{STS_LIMITS.DURATION_MAX}</span>
</div>
</div>
{/* 최대 접촉 거리 */}
<div>
<div className="flex items-center justify-between text-[11px]">
<span className="text-foreground/70"> </span>
<span className="font-mono font-medium text-primary">{maxDistance}m</span>
</div>
<input
type="range"
min={STS_LIMITS.DISTANCE_MIN}
max={STS_LIMITS.DISTANCE_MAX}
step={50}
value={maxDistance}
onChange={(e) => setMaxDistance(Number(e.target.value))}
className="w-full accent-primary"
/>
<div className="flex justify-between text-[9px] text-muted">
<span>{STS_LIMITS.DISTANCE_MIN}m</span>
<span>{STS_LIMITS.DISTANCE_MAX}m</span>
</div>
</div>
</div>
{/* 결과 요약 */}
{summary && (
<div className="flex items-center gap-3 rounded-md bg-primary/5 px-2 py-1.5 text-xs">
<span className="font-medium text-primary">{summary.totalContactPairs} </span>
<span className="text-muted">{summary.totalVesselsInvolved} </span>
{summary.processingTimeMs != null && (
<span className="ml-auto text-muted">{summary.processingTimeMs}ms</span>
)}
</div>
)}
{/* 접촉 쌍 리스트 */}
{queryCompleted && <StsContactList />}
</div>
)
}

파일 보기

@ -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 (
<li
className={`relative overflow-hidden rounded-lg border transition ${
isHighlighted ? 'border-primary/40 bg-primary/5' : 'border-border'
} ${isDisabled ? 'opacity-40' : ''}`}
onMouseEnter={() => useStsStore.getState().setHighlightedGroupIndex(index)}
onMouseLeave={() => useStsStore.getState().setHighlightedGroupIndex(null)}
>
{/* 위험도 색상 바 */}
<div
className="absolute left-0 top-0 h-full w-1"
style={{ backgroundColor: `rgba(${riskColor.join(',')})` }}
/>
<div className="space-y-1 pl-3 pr-2 py-2">
{/* 선박 정보 */}
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1">
<span className="text-muted">{getShipKindLabel(group.vessel1.shipKindCode)}</span>
<span className="font-medium">{group.vessel1.vesselName || group.vessel1.vesselId}</span>
</div>
<span className="text-muted/60"></span>
<div className="flex items-center gap-1">
<span className="font-medium">{group.vessel2.vesselName || group.vessel2.vesselId}</span>
<span className="text-muted">{getShipKindLabel(group.vessel2.shipKindCode)}</span>
</div>
</div>
{/* 요약 */}
<div className="flex items-center gap-2 text-[10px] text-muted">
<span>{formatDuration(group.totalDurationMinutes)}</span>
<span> {formatDistance(group.avgDistanceMeters)}</span>
{group.contacts.length > 1 && <span>{group.contacts.length}</span>}
</div>
{/* Indicator 뱃지 */}
{activeIndicators.length > 0 && (
<div className="flex flex-wrap gap-1">
{activeIndicators.map(([key]) => (
<span
key={key}
className="rounded-sm bg-red-500/10 px-1.5 py-0.5 text-[9px] font-medium text-red-600"
>
{INDICATOR_LABELS[key] || key}
</span>
))}
</div>
)}
{/* 토글 + 체크 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={!isDisabled}
onChange={() => useStsStore.getState().toggleGroupEnabled(index)}
onClick={(e) => e.stopPropagation()}
className="accent-primary"
/>
<button
onClick={() => useStsStore.getState().setExpandedGroupIndex(index)}
className="text-[10px] text-primary hover:underline"
>
{isExpanded ? '접기 ▲' : '상세 ▼'}
</button>
</div>
{/* 확장 상세 */}
{isExpanded && (
<div className="space-y-1 border-t border-border/50 pt-1 text-[10px] text-muted">
<div>거리: 최소 {formatDistance(group.minDistanceMeters)} / {formatDistance(group.avgDistanceMeters)} / {formatDistance(group.maxDistanceMeters)}</div>
<div>: {group.totalContactPointCount} </div>
{group.contacts.length > 1 && (
<div className="space-y-0.5">
<span className="font-medium"> ({group.contacts.length})</span>
{group.contacts.map((c, ci) => (
<div key={ci} className="pl-2">
#{ci + 1} {formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)}
{c.contactDurationMinutes != null && ` (${c.contactDurationMinutes}분)`}
</div>
))}
</div>
)}
</div>
)}
</div>
</li>
)
}
export default function StsContactList() {
const groupedContacts = useStsStore((s) => s.groupedContacts)
const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex)
const listRef = useRef<HTMLUListElement>(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 <div className="py-2 text-center text-xs text-muted"> </div>
}
return (
<ul ref={listRef} className="max-h-56 space-y-1.5 overflow-y-auto">
{groupedContacts.map((group, idx) => (
<GroupCard key={group.pairKey} group={group} index={idx} />
))}
</ul>
)
}

파일 보기

@ -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 (
<div className="absolute bottom-4 left-1/2 z-10 w-[560px] -translate-x-1/2 rounded-lg border border-border bg-surface/95 p-3 shadow-lg backdrop-blur">
{/* 타임라인 */}
<div className="mb-2">
<input
type="range"
min={0}
max={1000}
value={Math.round(progress * 1000)}
onChange={(e) => setProgressByRatio(Number(e.target.value) / 1000)}
className="h-1.5 w-full cursor-pointer accent-primary"
/>
<div className="mt-1 flex justify-between text-[10px] text-muted">
<span>{formatTime(startTime)}</span>
<span className="font-medium text-foreground">{formatTime(currentTime)}</span>
<span>{formatTime(endTime)}</span>
</div>
</div>
{/* 컨트롤 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<button
onClick={stop}
className="rounded px-2 py-1 text-xs text-muted hover:bg-surface-hover"
title="정지"
>
&#9632;
</button>
<button
onClick={isPlaying ? pause : play}
className="rounded bg-primary px-3 py-1 text-xs font-medium text-white hover:bg-primary/90"
>
{isPlaying ? '||' : '\u25B6'}
</button>
<button
onClick={toggleLoop}
className={`rounded px-2 py-1 text-xs transition ${
loop ? 'text-primary' : 'text-muted hover:text-foreground'
}`}
title="반복"
>
&#x21BB;
</button>
</div>
<div className="flex items-center gap-1">
{PLAYBACK_SPEEDS.map((speed) => (
<button
key={speed}
onClick={() => setPlaybackSpeed(speed)}
className={`rounded px-1.5 py-0.5 text-[10px] transition ${
playbackSpeed === speed
? 'bg-primary/10 font-bold text-primary'
: 'text-muted hover:text-foreground'
}`}
>
{speed}x
</button>
))}
</div>
</div>
</div>
)
}

파일 보기

@ -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<number | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(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 (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted"> </span>
<span className="text-[10px] text-muted">{zones.length}/{maxZones}</span>
</div>
{/* 그리기 버튼 */}
<div className="flex gap-1">
{DRAW_BUTTONS.map((btn) => (
<button
key={btn.type}
onClick={() => handleDrawClick(btn.type)}
disabled={!canAddZone || disabled}
className={`flex-1 rounded-md px-2 py-1.5 text-xs transition ${
activeDrawType === btn.type
? 'bg-primary/20 font-medium text-primary ring-1 ring-primary/40'
: 'bg-surface-hover text-foreground/70 hover:bg-surface-hover/80'
} disabled:cursor-not-allowed disabled:opacity-40`}
>
<span className="mr-1">{btn.icon}</span>
{btn.label}
</button>
))}
</div>
{activeDrawType && (
<p className="text-[10px] text-muted">
{activeDrawType === 'Polygon' && '지도를 클릭하여 꼭짓점을 추가하세요. 더블클릭으로 완료.'}
{activeDrawType === 'Box' && '드래그하여 사각형을 그리세요.'}
{activeDrawType === 'Circle' && '드래그하여 원을 그리세요.'}
<span className="ml-1 text-muted/60">(ESC: 취소)</span>
</p>
)}
{/* 구역 목록 */}
{zones.length > 0 && (
<ul className="space-y-1">
{zones.map((zone, index) => {
const color = ZONE_COLORS[zone.colorIndex % ZONE_COLORS.length]
return (
<li
key={zone.id}
draggable={zones.length > 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 && (
<span className="cursor-grab text-muted/50 active:cursor-grabbing"></span>
)}
<span
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: color.label }}
/>
<span className="font-medium"> {zone.name}</span>
<span className="text-[10px] text-muted">{zone.type === 'Box' ? '사각형' : zone.type === 'Circle' ? '원' : '폴리곤'}</span>
<button
onClick={(e) => { e.stopPropagation(); removeZone(zone.id) }}
className="ml-auto text-muted hover:text-red-500 transition"
title="삭제"
>
×
</button>
</li>
)
})}
</ul>
)}
</div>
)
}

파일 보기

@ -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<TripsData[]>([])
const pathDataRef = useRef<TrackPathData[]>([])
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<TripsData>({
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])
}

파일 보기

@ -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<TripsData[]>([])
const pathDataRef = useRef<TrackPathData[]>([])
const contactPointsRef = useRef<ContactPointData[]>([])
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<ContactPointData>({
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<TripsData>({
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])
}

파일 보기

@ -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<TerraDraw | null>(null)
const featureZoneMap = useRef<Map<string, string>>(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
}, [])
}

파일 보기

@ -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])
}

파일 보기

@ -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'

파일 보기

@ -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<string, RawHitDetail[]>
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<string, HitDetail[]>
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<AreaSearchRawResponse>('/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<string, HitDetail[]> = {}
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 }
}

파일 보기

@ -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<string, unknown>
vessel2: Record<string, unknown>
contactStartTimestamp?: number | null
contactEndTimestamp?: number | null
contactDurationMinutes?: number
contactPointCount?: number
avgDistanceMeters?: number
minDistanceMeters: number
maxDistanceMeters: number
contactCenterPoint?: [number, number]
indicators?: Record<string, boolean>
}
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<string, unknown>) {
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<StsRawResponse>('/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 }
}

파일 보기

@ -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<AnimationState>((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,
})
},
}))

파일 보기

@ -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<string, HitDetail[]>
summary: AreaSearchSummary | null
isLoading: boolean
queryCompleted: boolean
// UI 상태
disabledVesselIds: Set<string>
highlightedVesselId: string | null
showPaths: boolean
showTrail: boolean
// 구역 관리 액션
addZone: (zone: Pick<Zone, 'type' | 'coordinates' | 'terraDrawFeatureId'>) => 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<string, HitDetail[]>, 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<string, number>()
// 기본 시간 범위: 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<AreaSearchState>((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,
})
},
}))

파일 보기

@ -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<number>
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<string>
getCurrentPositions: (currentTime: number) => InterpolatedPosition[]
clearResults: () => void
reset: () => void
}
const stsCursors = new Map<string, number>()
export const useStsStore = create<StsState>((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<string>()
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,
})
},
}))

파일 보기

@ -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

파일 보기

@ -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<string, string> = {
lowSpeedContact: '저속',
differentVesselTypes: '이종',
differentNationalities: '외국적',
nightTimeContact: '야간',
}
/** 접촉 쌍 그룹핑 */
export function groupContactsByPair(contacts: StsContact[]): StsGroupedContact[] {
const groupMap = new Map<string, StsGroupedContact>()
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
})
}

파일 보기

@ -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<string, HitDetail[]>,
zones: Zone[],
): void {
// 구역별 최대 방문 횟수
const maxVisits: Record<string, number> = {}
zones.forEach((z) => { maxVisits[z.id] = 1 })
Object.values(hitDetails).forEach((hits) => {
const countByZone: Record<string, number> = {}
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)
}

파일 보기

@ -127,6 +127,8 @@ const en = {
'explorer.recentPositions': 'Recent Positions', 'explorer.recentPositions': 'Recent Positions',
'explorer.vesselTracks': 'Vessel Tracks', 'explorer.vesselTracks': 'Vessel Tracks',
'explorer.viewportReplay': 'Viewport Replay', 'explorer.viewportReplay': 'Viewport Replay',
'explorer.areaSearch': 'Area Track Analysis',
'explorer.stsAnalysis': 'STS Contact Analysis',
'explorer.parameters': 'Parameters', 'explorer.parameters': 'Parameters',
'explorer.positionsDesc': 'Fetches vessels with position updates within 10 minutes.', 'explorer.positionsDesc': 'Fetches vessels with position updates within 10 minutes.',
'explorer.vesselDesc': 'Fetches tracks for specific vessels by MMSI list.', 'explorer.vesselDesc': 'Fetches tracks for specific vessels by MMSI list.',

파일 보기

@ -127,6 +127,8 @@ const ko = {
'explorer.recentPositions': '최근 위치', 'explorer.recentPositions': '최근 위치',
'explorer.vesselTracks': '선박별 항적', 'explorer.vesselTracks': '선박별 항적',
'explorer.viewportReplay': '뷰포트 리플레이', 'explorer.viewportReplay': '뷰포트 리플레이',
'explorer.areaSearch': '구역 항적 분석',
'explorer.stsAnalysis': 'STS 접촉 분석',
'explorer.parameters': '파라미터', 'explorer.parameters': '파라미터',
'explorer.positionsDesc': '최근 10분 이내 위치 업데이트된 선박 목록을 조회합니다.', 'explorer.positionsDesc': '최근 10분 이내 위치 업데이트된 선박 목록을 조회합니다.',
'explorer.vesselDesc': 'MMSI 목록으로 특정 선박의 항적을 조회합니다.', 'explorer.vesselDesc': 'MMSI 목록으로 특정 선박의 항적을 조회합니다.',

파일 보기

@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useMemo } from 'react' import { useState, useCallback, useMemo } from 'react'
import { useI18n } from '../hooks/useI18n.ts' import { useI18n } from '../hooks/useI18n.ts'
import MapContainer from '../components/map/MapContainer.tsx' import MapContainer from '../components/map/MapContainer.tsx'
import Sidebar from '../components/layout/Sidebar.tsx' import Sidebar from '../components/layout/Sidebar.tsx'
@ -25,8 +25,17 @@ import {
ReplaySetupPanel, ReplaySetupPanel,
ReplayControlPanel, ReplayControlPanel,
} from '../features/viewport-replay' } 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() { function usePositionRawData() {
@ -90,13 +99,57 @@ function useReplayRawData() {
}, [vesselChunks, queryCompleted, receivedChunks]) }, [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() { export default function ApiExplorer() {
const { t } = useI18n() const { t } = useI18n()
const [mode, setMode] = useState<ApiMode>('positions') const [mode, setMode] = useState<ApiMode>('positions')
const mapRef = useRef<maplibregl.Map | null>(null) const [mapInstance, setMapInstance] = useState<maplibregl.Map | null>(null)
const [zoom, setZoom] = useState(7) const [zoom, setZoom] = useState(7)
const [trackMmsi, setTrackMmsi] = useState<string | undefined>() const [trackMmsi, setTrackMmsi] = useState<string | undefined>()
const [replayLayers, setReplayLayers] = useState<Layer[]>([]) const [replayLayers, setReplayLayers] = useState<Layer[]>([])
const [areaLayers, setAreaLayers] = useState<Layer[]>([])
const [stsLayers, setStsLayers] = useState<Layer[]>([])
const selectVessel = usePositionStore((s) => s.selectVessel) const selectVessel = usePositionStore((s) => s.selectVessel)
@ -122,31 +175,72 @@ export default function ApiExplorer() {
onLayersUpdate: handleReplayLayersUpdate, 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) => { const handleMapReady = useCallback((m: maplibregl.Map) => {
mapRef.current = m setMapInstance(m)
setZoom(m.getZoom()) setZoom(m.getZoom())
m.on('zoom', () => 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) => { const handleTrackQuery = useCallback((mmsi: string) => {
setTrackMmsi(mmsi) setTrackMmsi(mmsi)
setMode('vessel') handleModeChange('vessel')
selectVessel(null) selectVessel(null)
}, [selectVessel]) }, [selectVessel, handleModeChange])
// Raw Data 훅 // Raw Data 훅
const positionData = usePositionRawData() const positionData = usePositionRawData()
const trackData = useTrackRawData() const trackData = useTrackRawData()
const replayData = useReplayRawData() const replayData = useReplayRawData()
const areaSearchData = useAreaSearchRawData()
const stsData = useStsRawData()
// 모드별 Deck.gl 레이어 // 모드별 Deck.gl 레이어
const deckLayers: Layer[] = const deckLayers: Layer[] =
mode === 'positions' ? positionLayers : mode === 'positions' ? positionLayers :
mode === 'vessel' ? trackLayers : mode === 'vessel' ? trackLayers :
mode === 'replay' ? replayLayers : mode === 'replay' ? replayLayers :
mode === 'area-search' ? areaLayers :
mode === 'sts' ? stsLayers :
[] []
const isAreaMode = mode === 'area-search' || mode === 'sts'
return ( return (
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden"> <div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
{/* Sidebar */} {/* Sidebar */}
@ -165,10 +259,12 @@ export default function ApiExplorer() {
{ value: 'positions' as const, label: t('explorer.recentPositions') }, { value: 'positions' as const, label: t('explorer.recentPositions') },
{ value: 'vessel' as const, label: t('explorer.vesselTracks') }, { value: 'vessel' as const, label: t('explorer.vesselTracks') },
{ value: 'replay' as const, label: '뷰포트 리플레이' }, { 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 => ( ] as const).map(opt => (
<button <button
key={opt.value} key={opt.value}
onClick={() => setMode(opt.value)} onClick={() => handleModeChange(opt.value)}
className={`w-full rounded-md px-3 py-2 text-left text-sm transition ${ className={`w-full rounded-md px-3 py-2 text-left text-sm transition ${
mode === opt.value mode === opt.value
? 'bg-primary/10 font-medium text-primary' ? 'bg-primary/10 font-medium text-primary'
@ -199,7 +295,13 @@ export default function ApiExplorer() {
{mode === 'replay' && ( {mode === 'replay' && (
<div className="rounded-lg border border-border p-3"> <div className="rounded-lg border border-border p-3">
<div className="mb-2 text-xs font-medium text-muted"> </div> <div className="mb-2 text-xs font-medium text-muted"> </div>
<ReplaySetupPanel map={mapRef.current} /> <ReplaySetupPanel map={mapInstance} />
</div>
)}
{isAreaMode && (
<div className="rounded-lg border border-border p-3">
<AreaSearchPanel map={mapInstance} mode={mode} />
</div> </div>
)} )}
@ -225,6 +327,20 @@ export default function ApiExplorer() {
emptyText="뷰포트 리플레이를 실행하세요" emptyText="뷰포트 리플레이를 실행하세요"
/> />
)} )}
{mode === 'area-search' && (
<RawDataPanel
title="구역분석 결과"
data={areaSearchData}
emptyText="구역을 그리고 조회하세요"
/>
)}
{mode === 'sts' && (
<RawDataPanel
title="STS 접촉 분석 결과"
data={stsData}
emptyText="구역을 그리고 조회하세요"
/>
)}
</div> </div>
</Sidebar> </Sidebar>
</div> </div>
@ -238,12 +354,15 @@ export default function ApiExplorer() {
{mode === 'positions' && t('explorer.recentPositions')} {mode === 'positions' && t('explorer.recentPositions')}
{mode === 'vessel' && t('explorer.vesselTracks')} {mode === 'vessel' && t('explorer.vesselTracks')}
{mode === 'replay' && '뷰포트 리플레이'} {mode === 'replay' && '뷰포트 리플레이'}
{mode === 'area-search' && t('explorer.areaSearch')}
{mode === 'sts' && t('explorer.stsAnalysis')}
</div> </div>
{/* 모드별 오버레이 */} {/* 모드별 오버레이 */}
{mode === 'positions' && <VesselPopup onTrackQuery={handleTrackQuery} />} {mode === 'positions' && <VesselPopup onTrackQuery={handleTrackQuery} />}
{mode === 'vessel' && <TrackInfoPanel />} {mode === 'vessel' && <TrackInfoPanel />}
{mode === 'replay' && <ReplayControlPanel />} {mode === 'replay' && <ReplayControlPanel />}
{isAreaMode && <TimelineControl />}
</div> </div>
</div> </div>
) )