import React, { useEffect, useRef, useState, useCallback } from 'react'; import { toCameraCoords, pixelFromCamera, type DroneFrameBasic, DEFAULT_CAMERA_PARAMS, } from '../../utils/geoProjection'; interface GeoPoint { title: string; category: string; lat: number; lon: number; z: number; type: string; } interface RoutePanelProps { currentTime: number; visible: boolean; onSeek: (time: number) => void; } const VIDEO_FPS = 30000 / 1001; const cleanTitle = (t: string) => t.replace(/\s*\([상하]\)\s*$/, '').trim(); function stationKm(title: string): number { const m = title.match(/(\d+)[Kk](\d+)/); if (!m) return -1; return parseInt(m[1]) * 1000 + parseInt(m[2]); } const CATEGORY_EMOJI: Record = { '\uD130\uB110': '\uD83D\uDE87', '\uAD50\uB7C9': '\uD83C\uDF09', '\uC5ED\uC0AC': '\uD83D\uDE89', '\uC9C0\uC7A5\uBB3C': '\uD83C\uDFE2', '\uCE21\uC810': '\uD83D\uDCCD', }; function poiKm(poi: GeoPoint, stations: GeoPoint[]): number { if (!stations.length) return -1; const sorted = [...stations] .map(st => ({ st, d: (poi.lat - st.lat) ** 2 + (poi.lon - st.lon) ** 2, })) .sort((a, b) => a.d - b.d); const a = sorted[0], b = sorted[1]; if (!b || b.d === 0) return stationKm(a.st.title); const ka = stationKm(a.st.title), kb = stationKm(b.st.title); if (ka < 0 || kb < 0) return ka >= 0 ? ka : kb; const t = a.d / (a.d + b.d); return Math.round(ka + (kb - ka) * t); } export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelProps) { const [stations, setStations] = useState([]); const [pois, setPois] = useState([]); const [droneFramesLoaded, setDroneFramesLoaded] = useState(false); const allDroneFramesRef = useRef([]); const [currentKm, setCurrentKm] = useState(0); const [currentStationTitle, setCurrentStationTitle] = useState(''); const [visibleRange, setVisibleRange] = useState<{ minKm: number; maxKm: number } | null>(null); const [routeStartTitle, setRouteStartTitle] = useState(''); const [routeEndTitle, setRouteEndTitle] = useState(''); const panelRef = useRef(null); const [dragYPct, setDragYPct] = useState(0); const [dragging, setDragging] = useState(false); // Load POIs and stations useEffect(() => { if (!visible) return; fetch('/api/geo/pois') .then(r => r.json()) .then((data: GeoPoint[]) => { setStations(data.filter(p => p.type === 'station')); setPois(data.filter(p => p.type === 'poi')); }) .catch(() => {}); }, [visible]); // Load drone frames when visible and not yet loaded useEffect(() => { if (!visible || droneFramesLoaded) return; fetch('/api/geo/frames?step=1') .then(r => r.json()) .then((data: DroneFrameBasic[]) => { allDroneFramesRef.current = data; setDroneFramesLoaded(true); }) .catch(() => {}); }, [visible, droneFramesLoaded]); // 시점/종점: 역사(category=역사) POI 중 km 최소/최대 useEffect(() => { if (!stations.length || !pois.length) return; const validSt = stations.filter(s => stationKm(s.title) >= 0); if (!validSt.length) return; const stationPois = pois.filter(p => p.category === '\uC5ED\uC0AC'); // 역사 if (!stationPois.length) return; let minKmPoi = stationPois[0], maxKmPoi = stationPois[0]; let minK = poiKm(stationPois[0], validSt), maxK = minK; for (let i = 1; i < stationPois.length; i++) { const k = poiKm(stationPois[i], validSt); if (k >= 0 && k < minK) { minK = k; minKmPoi = stationPois[i]; } if (k >= 0 && k > maxK) { maxK = k; maxKmPoi = stationPois[i]; } } setRouteStartTitle(minKmPoi.title); setRouteEndTitle(maxKmPoi.title); }, [stations, pois]); // Update current km and visible range based on currentTime useEffect(() => { if (!droneFramesLoaded) return; const frames = allDroneFramesRef.current; if (!frames.length || !stations.length) return; // Find closest frame by time const targetFrame = Math.round(currentTime * VIDEO_FPS); let closest = frames[0]; let closestDist = Math.abs(closest.frame - targetFrame); for (let i = 1; i < frames.length; i++) { const d = Math.abs(frames[i].frame - targetFrame); if (d < closestDist) { closest = frames[i]; closestDist = d; } } // Find nearest station to current drone position const validStations = stations.filter(s => stationKm(s.title) >= 0); if (!validStations.length) return; let nearestStation = validStations[0]; let nearestDist = (closest.lat - nearestStation.lat) ** 2 + (closest.lon - nearestStation.lon) ** 2; for (let i = 1; i < validStations.length; i++) { const d = (closest.lat - validStations[i].lat) ** 2 + (closest.lon - validStations[i].lon) ** 2; if (d < nearestDist) { nearestStation = validStations[i]; nearestDist = d; } } setCurrentKm(stationKm(nearestStation.title)); setCurrentStationTitle(nearestStation.title); // Calculate visible range (green box) const allPoints = [...validStations, ...pois]; const visibleKms: number[] = []; for (const pt of allPoints) { const cc = toCameraCoords(closest, pt.lat, pt.lon, pt.z, DEFAULT_CAMERA_PARAMS); if (cc.Zc <= 0) continue; const { pyRaw } = pixelFromCamera(cc, DEFAULT_CAMERA_PARAMS); if (pyRaw >= 0.0 && pyRaw <= 1.0) { const km = pt.type === 'station' ? stationKm(pt.title) : poiKm(pt, validStations); if (km >= 0) visibleKms.push(km); } } if (visibleKms.length >= 2) { setVisibleRange({ minKm: Math.min(...visibleKms), maxKm: Math.max(...visibleKms), }); } else { setVisibleRange(null); } }, [currentTime, droneFramesLoaded, stations, pois]); // Drag handling const handleMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); setDragging(true); }, []); useEffect(() => { if (!dragging) return; const handleMouseMove = (e: MouseEvent) => { if (!panelRef.current) return; const rect = panelRef.current.getBoundingClientRect(); const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); setDragYPct(y * 100); }; const handleMouseUp = (e: MouseEvent) => { setDragging(false); if (!panelRef.current) return; const rect = panelRef.current.getBoundingClientRect(); const yPct = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); const validStations = stations.filter(s => stationKm(s.title) >= 0); if (validStations.length < 2) return; const allKms = [ ...validStations.map(s => stationKm(s.title)), ...pois.map(p => poiKm(p, validStations)).filter(k => k >= 0), ]; const minK = Math.min(...allKms); const maxK = Math.max(...allKms); const targetKm = maxK - yPct * (maxK - minK); // Find closest station to target km let bestStation = validStations[0]; let bestDiff = Math.abs(stationKm(bestStation.title) - targetKm); for (let i = 1; i < validStations.length; i++) { const diff = Math.abs(stationKm(validStations[i].title) - targetKm); if (diff < bestDiff) { bestStation = validStations[i]; bestDiff = diff; } } // Find closest drone frame to that station's lat/lon const frames = allDroneFramesRef.current; if (!frames.length) return; let bestFrame = frames[0]; let bestFrameDist = (bestFrame.lat - bestStation.lat) ** 2 + (bestFrame.lon - bestStation.lon) ** 2; for (let i = 1; i < frames.length; i++) { const d = (frames[i].lat - bestStation.lat) ** 2 + (frames[i].lon - bestStation.lon) ** 2; if (d < bestFrameDist) { bestFrame = frames[i]; bestFrameDist = d; } } onSeek(bestFrame.frame / VIDEO_FPS); }; window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; }, [dragging, stations, pois, onSeek]); // Render guard if (!visible || stations.length === 0) return null; const validStations = stations.filter(s => stationKm(s.title) >= 0); if (validStations.length < 2) return null; const allKms = [ ...validStations.map(s => stationKm(s.title)), ...pois.map(p => poiKm(p, validStations)).filter(k => k >= 0), ]; const minKm = Math.min(...allKms); const maxKm = Math.max(...allKms); const kmToY = (km: number) => (1 - (km - minKm) / (maxKm - minKm)) * 100; // 교량/터널만 표시 const filteredPois = pois.filter(p => p.category === '\uD130\uB110' || p.category === '\uAD50\uB7C9'); return (
{/* Center vertical line */}
{/* 시점 역명 — 상단 */}
{cleanTitle(routeStartTitle)}
{/* 종점 역명 — 하단 */}
{cleanTitle(routeEndTitle)}
{/* 교량/터널 POIs — 겹침 방지: Y 간격 7% 미만이면 건너뜀 */} {(() => { const MIN_GAP = 9; // % const placed: number[] = []; return filteredPois.map((poi, i) => { const km = poiKm(poi, validStations); if (km < 0) return null; const y = kmToY(km); if (y < 5 || y > 95) return null; if (placed.some(py => Math.abs(py - y) < MIN_GAP)) return null; placed.push(y); return (
{cleanTitle(poi.title)}
); }); })()} {/* Green visible range box */} {visibleRange && (
)} {/* Orange current position marker */}
{cleanTitle(currentStationTitle)}
); }