wing-ops/frontend/src/tabs/scat/components/ScatTimeline.tsx
htlee c727afd1ba refactor(frontend): 대형 View 서브탭 단위 분할 + FEATURE_ID 체계 도입
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>
2026-02-28 16:19:22 +09:00

145 lines
6.5 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 { 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