kmToY=(1-ratio), visibleRange/drag seek 모두 동일 방향으로 수정 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
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<string, string> = {
|
|
'\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<GeoPoint[]>([]);
|
|
const [pois, setPois] = useState<GeoPoint[]>([]);
|
|
const [droneFramesLoaded, setDroneFramesLoaded] = useState(false);
|
|
const allDroneFramesRef = useRef<DroneFrameBasic[]>([]);
|
|
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<HTMLDivElement>(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 (
|
|
<div
|
|
ref={panelRef}
|
|
className="absolute left-0 w-28 border-r border-white/20 z-30"
|
|
style={{ top: '8%', height: '80%', background: 'rgba(0,0,0,0.6)' }}
|
|
>
|
|
{/* Center vertical line */}
|
|
<div
|
|
className="absolute"
|
|
style={{ left: 38, width: 2, top: 22, bottom: 22, background: 'rgba(255,255,255,0.5)' }}
|
|
/>
|
|
|
|
{/* 시점 역명 — 상단 */}
|
|
<div className="absolute left-0 right-0 flex items-center gap-1" style={{ top: 4 }}>
|
|
<div className="w-2 h-2 rounded-full bg-white/80 shrink-0" style={{ marginLeft: 29 }} />
|
|
<span className="text-[11px] text-white/90 font-semibold truncate">{cleanTitle(routeStartTitle)}</span>
|
|
</div>
|
|
|
|
{/* 종점 역명 — 하단 */}
|
|
<div className="absolute left-0 right-0 flex items-center gap-1" style={{ bottom: 4 }}>
|
|
<div className="w-2 h-2 rounded-full bg-white/80 shrink-0" style={{ marginLeft: 29 }} />
|
|
<span className="text-[11px] text-white/90 font-semibold truncate">{cleanTitle(routeEndTitle)}</span>
|
|
</div>
|
|
|
|
{/* 교량/터널 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 (
|
|
<div
|
|
key={`poi-${i}`}
|
|
className="absolute flex items-center pointer-events-none"
|
|
style={{ top: `${y}%`, transform: 'translateY(-50%)', left: 0, right: 0 }}
|
|
>
|
|
<div style={{ position: 'absolute', left: 30, width: 16, display: 'flex', justifyContent: 'center' }}>
|
|
<div
|
|
className="w-3 h-3 rounded-sm"
|
|
style={{ background: poi.category === '\uD130\uB110' ? '#818cf8' : '#38bdf8' }}
|
|
/>
|
|
</div>
|
|
<div
|
|
className="text-[10px] truncate font-medium"
|
|
style={{
|
|
position: 'absolute', left: 48, right: 2,
|
|
color: poi.category === '\uD130\uB110' ? '#c7d2fe' : '#bae6fd',
|
|
}}
|
|
>
|
|
{cleanTitle(poi.title)}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
})()}
|
|
|
|
{/* Green visible range box */}
|
|
{visibleRange && (
|
|
<div
|
|
className="absolute pointer-events-none"
|
|
style={{
|
|
left: 30,
|
|
right: 4,
|
|
top: `${kmToY(visibleRange.maxKm)}%`,
|
|
bottom: `${100 - kmToY(visibleRange.minKm)}%`,
|
|
border: '1px solid rgba(74,222,128,0.7)',
|
|
background: 'rgba(74,222,128,0.08)',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Orange current position marker */}
|
|
<div
|
|
className="absolute left-0 right-0 flex items-center cursor-grab z-10"
|
|
style={{
|
|
top: `${dragging ? dragYPct : kmToY(currentKm)}%`,
|
|
transform: 'translateY(-50%)',
|
|
}}
|
|
onMouseDown={handleMouseDown}
|
|
>
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
left: 30,
|
|
width: 12,
|
|
height: 12,
|
|
borderRadius: '50%',
|
|
background: '#f97316',
|
|
border: '2px solid white',
|
|
transform: 'translateX(-50%)',
|
|
}}
|
|
/>
|
|
<div
|
|
style={{ position: 'absolute', left: 44 }}
|
|
className="bg-orange-500 text-white text-[11px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap"
|
|
>
|
|
{cleanTitle(currentStationTitle)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|