- 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>
137 lines
5.1 KiB
TypeScript
137 lines
5.1 KiB
TypeScript
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>
|
||
)
|
||
}
|