signal-batch/frontend/src/features/area-search/components/ZoneDrawPanel.tsx
htlee 1cc25f9f3b 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>
2026-02-20 17:07:14 +09:00

137 lines
5.1 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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