6개 대형 View(AerialView, AssetsView, ReportsView, PreScatView, AdminView, LeftPanel)를 서브탭 단위로 분할하여 모듈 경계를 명확히 함. - AerialView (2,526줄 → 8파일): MediaManagement, OilAreaAnalysis, RealtimeDrone 등 - AssetsView (2,047줄 → 8파일): AssetManagement, AssetMap, ShipInsurance 등 - ReportsView (1,596줄 → 5파일): TemplateFormEditor, ReportGenerator 등 - PreScatView (1,390줄 → 7파일): ScatLeftPanel, ScatMap, ScatPopup 등 - AdminView (1,306줄 → 7파일): UsersPanel, PermissionsPanel, MenusPanel 등 - LeftPanel (1,237줄 → 5파일): PredictionInputSection, InfoLayerSection, OilBoomSection 등 FEATURE_ID 레지스트리(common/constants/featureIds.ts) 및 감사로그 서브탭 추적 훅(useFeatureTracking) 추가. .gitignore의 scat/ → /scat/ 수정 (scat 탭 파일 추적 누락 수정) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
145 lines
6.5 KiB
TypeScript
145 lines
6.5 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react'
|
||
import type { ScatSegment } from './scatTypes'
|
||
|
||
interface ScatTimelineProps {
|
||
segments: ScatSegment[]
|
||
currentIdx: number
|
||
onSeek: (idx: number) => void
|
||
}
|
||
|
||
function ScatTimeline({
|
||
segments,
|
||
currentIdx,
|
||
onSeek,
|
||
}: ScatTimelineProps) {
|
||
const [playing, setPlaying] = useState(false)
|
||
const [speed, setSpeed] = useState(1)
|
||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||
|
||
const total = Math.min(segments.length, 12)
|
||
const displaySegs = segments.slice(0, total)
|
||
const pct = ((currentIdx + 1) / total) * 100
|
||
|
||
const stop = useCallback(() => {
|
||
if (intervalRef.current) {
|
||
clearInterval(intervalRef.current)
|
||
intervalRef.current = null
|
||
}
|
||
setPlaying(false)
|
||
}, [])
|
||
|
||
const play = useCallback(() => {
|
||
stop()
|
||
setPlaying(true)
|
||
intervalRef.current = setInterval(() => {
|
||
onSeek(-1) // signal to advance
|
||
}, 800 / speed)
|
||
}, [speed, stop, onSeek])
|
||
|
||
const togglePlay = () => {
|
||
if (playing) stop()
|
||
else play()
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (playing && intervalRef.current) {
|
||
clearInterval(intervalRef.current)
|
||
intervalRef.current = setInterval(() => {
|
||
onSeek(-1)
|
||
}, 800 / speed)
|
||
}
|
||
}, [speed, playing, onSeek])
|
||
|
||
useEffect(() => {
|
||
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
|
||
}, [])
|
||
|
||
const cycleSpeed = () => {
|
||
const speeds = [1, 2, 4]
|
||
setSpeed(s => speeds[(speeds.indexOf(s) + 1) % speeds.length])
|
||
}
|
||
|
||
const doneCount = segments.filter(s => s.status === '완료').length
|
||
const progCount = segments.filter(s => s.status === '진행중').length
|
||
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0)
|
||
|
||
return (
|
||
<div className="absolute bottom-0 left-0 right-0 h-[72px] bg-[rgba(15,21,36,0.95)] backdrop-blur-2xl border-t border-border flex items-center px-5 gap-4 z-[40]">
|
||
{/* Controls */}
|
||
<div className="flex gap-1 flex-shrink-0">
|
||
<button onClick={() => onSeek(0)} className="w-[34px] h-[34px] rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-bg-hover text-sm">⏮</button>
|
||
<button onClick={() => onSeek(Math.max(0, currentIdx - 1))} className="w-[34px] h-[34px] rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-bg-hover text-sm">◀</button>
|
||
<button onClick={togglePlay} className={`w-[34px] h-[34px] rounded-sm border flex items-center justify-center cursor-pointer text-sm ${playing ? 'bg-status-green text-black border-status-green' : 'border-border bg-bg-3 text-text-2 hover:bg-bg-hover'}`}>
|
||
{playing ? '⏸' : '▶'}
|
||
</button>
|
||
<button onClick={() => onSeek(Math.min(total - 1, currentIdx + 1))} className="w-[34px] h-[34px] rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-bg-hover text-sm">▶</button>
|
||
<button onClick={() => onSeek(total - 1)} className="w-[34px] h-[34px] rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-bg-hover text-sm">⏭</button>
|
||
<div className="w-2" />
|
||
<button onClick={cycleSpeed} className="w-[34px] h-[34px] rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-bg-hover text-xs font-mono font-bold">{speed}×</button>
|
||
</div>
|
||
|
||
{/* Progress */}
|
||
<div className="flex-1 flex flex-col gap-1.5">
|
||
<div className="flex justify-between px-1">
|
||
{displaySegs.map((s, i) => (
|
||
<span
|
||
key={i}
|
||
className={`text-[10px] font-mono cursor-pointer ${i === currentIdx ? 'text-status-green font-semibold' : 'text-text-3'}`}
|
||
onClick={() => onSeek(i)}
|
||
>
|
||
{s.code}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="relative h-6 flex items-center">
|
||
<div className="w-full h-1 bg-border rounded relative">
|
||
<div className="absolute top-0 left-0 h-full rounded transition-all duration-300" style={{ width: `${pct}%`, background: 'linear-gradient(90deg, var(--green), #4ade80)' }} />
|
||
{/* Markers */}
|
||
{displaySegs.map((s, i) => {
|
||
const x = ((i + 0.5) / total) * 100
|
||
return (
|
||
<div key={i} className="absolute w-0.5 bg-border-light" style={{ left: `${x}%`, top: -3, height: i % 3 === 0 ? 14 : 10 }}>
|
||
{s.status === '완료' && (
|
||
<span className="absolute -top-[18px] left-1/2 -translate-x-1/2 text-[10px] cursor-pointer" style={{ filter: 'drop-shadow(0 0 4px rgba(34,197,94,0.5))' }}>✅</span>
|
||
)}
|
||
{s.status === '진행중' && (
|
||
<span className="absolute -top-[18px] left-1/2 -translate-x-1/2 text-[10px]">⏳</span>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
{/* Thumb */}
|
||
<div
|
||
className="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-status-green border-[3px] border-bg-0 rounded-full cursor-grab shadow-[0_0_10px_rgba(34,197,94,0.4)] z-[2] transition-all duration-300"
|
||
style={{ left: `${pct}%`, transform: `translate(-50%, -50%)` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Info */}
|
||
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[210px]">
|
||
<span className="text-sm font-semibold text-status-green font-mono">
|
||
구간 {displaySegs[currentIdx]?.code || 'S-001'} / {total}개
|
||
</span>
|
||
<div className="flex gap-3.5">
|
||
<span className="flex items-center gap-1.5 text-[11px]">
|
||
<span className="text-text-2 font-korean">완료</span>
|
||
<span className="text-text-1 font-semibold font-mono" style={{ color: 'var(--green)' }}>{doneCount}/{segments.length}</span>
|
||
</span>
|
||
<span className="flex items-center gap-1.5 text-[11px]">
|
||
<span className="text-text-2 font-korean">진행중</span>
|
||
<span className="text-text-1 font-semibold font-mono" style={{ color: 'var(--orange)' }}>{progCount}/{segments.length}</span>
|
||
</span>
|
||
<span className="flex items-center gap-1.5 text-[11px]">
|
||
<span className="text-text-2 font-korean">총 해안선</span>
|
||
<span className="text-text-1 font-semibold font-mono">{(totalLen / 1000).toFixed(1)} km</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default ScatTimeline
|