feat: RoutePanel/StationOverlay 개선
- 시점/종점: 드론 첫/마지막 프레임 기준 역명 표시 (회덕, 대전조차장 등) - (상)/(하) 접미어 제거 (cleanTitle) - 패널 높이 90%, 글씨 크기 +30%, 투명도 밝게 - 교량/터널 POI 겹침 방지 (7% 간격) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,8 @@ interface RoutePanelProps {
|
|||||||
|
|
||||||
const VIDEO_FPS = 30000 / 1001;
|
const VIDEO_FPS = 30000 / 1001;
|
||||||
|
|
||||||
|
const cleanTitle = (t: string) => t.replace(/\s*\([상하]\)\s*$/, '').trim();
|
||||||
|
|
||||||
function stationKm(title: string): number {
|
function stationKm(title: string): number {
|
||||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
const m = title.match(/(\d+)[Kk](\d+)/);
|
||||||
if (!m) return -1;
|
if (!m) return -1;
|
||||||
@@ -61,6 +63,8 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
|||||||
const [currentKm, setCurrentKm] = useState(0);
|
const [currentKm, setCurrentKm] = useState(0);
|
||||||
const [currentStationTitle, setCurrentStationTitle] = useState('');
|
const [currentStationTitle, setCurrentStationTitle] = useState('');
|
||||||
const [visibleRange, setVisibleRange] = useState<{ minKm: number; maxKm: number } | null>(null);
|
const [visibleRange, setVisibleRange] = useState<{ minKm: number; maxKm: number } | null>(null);
|
||||||
|
const [routeStartTitle, setRouteStartTitle] = useState('');
|
||||||
|
const [routeEndTitle, setRouteEndTitle] = useState('');
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const [dragYPct, setDragYPct] = useState(0);
|
const [dragYPct, setDragYPct] = useState(0);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
@@ -89,6 +93,24 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [visible, droneFramesLoaded]);
|
}, [visible, droneFramesLoaded]);
|
||||||
|
|
||||||
|
// 시점/종점: 드론 첫/마지막 프레임에서 가장 가까운 역 이름
|
||||||
|
useEffect(() => {
|
||||||
|
if (!droneFramesLoaded || !stations.length) return;
|
||||||
|
const frames = allDroneFramesRef.current;
|
||||||
|
if (!frames.length) return;
|
||||||
|
const nearest = (f: DroneFrameBasic) => {
|
||||||
|
let best = stations[0];
|
||||||
|
let bestD = (f.lat - best.lat) ** 2 + (f.lon - best.lon) ** 2;
|
||||||
|
for (let i = 1; i < stations.length; i++) {
|
||||||
|
const d = (f.lat - stations[i].lat) ** 2 + (f.lon - stations[i].lon) ** 2;
|
||||||
|
if (d < bestD) { best = stations[i]; bestD = d; }
|
||||||
|
}
|
||||||
|
return best.title;
|
||||||
|
};
|
||||||
|
setRouteStartTitle(nearest(frames[0]));
|
||||||
|
setRouteEndTitle(nearest(frames[frames.length - 1]));
|
||||||
|
}, [droneFramesLoaded, stations]);
|
||||||
|
|
||||||
// Update current km and visible range based on currentTime
|
// Update current km and visible range based on currentTime
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!droneFramesLoaded) return;
|
if (!droneFramesLoaded) return;
|
||||||
@@ -236,57 +258,63 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="absolute left-0 w-24 bg-black/80 border-r border-white/10 z-30"
|
className="absolute left-0 w-28 border-r border-white/20 z-30"
|
||||||
style={{ top: '12%', height: '76%' }}
|
style={{ top: '5%', height: '90%', background: 'rgba(0,0,0,0.6)' }}
|
||||||
>
|
>
|
||||||
{/* Center vertical line */}
|
{/* Center vertical line */}
|
||||||
<div
|
<div
|
||||||
className="absolute"
|
className="absolute"
|
||||||
style={{ left: 36, width: 2, top: 18, bottom: 18, background: 'rgba(255,255,255,0.3)' }}
|
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: 3 }}>
|
<div className="absolute left-0 right-0 flex items-center gap-1" style={{ top: 4 }}>
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-white/50 shrink-0" style={{ marginLeft: 30 }} />
|
<div className="w-2 h-2 rounded-full bg-white/80 shrink-0" style={{ marginLeft: 29 }} />
|
||||||
<span className="text-[9px] text-white/60 truncate">{startTitle}</span>
|
<span className="text-[11px] text-white/90 font-semibold truncate">{cleanTitle(routeStartTitle)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 종점 측점명 — 하단 */}
|
{/* 종점 역명 — 하단 */}
|
||||||
<div className="absolute left-0 right-0 flex items-center gap-1" style={{ bottom: 3 }}>
|
<div className="absolute left-0 right-0 flex items-center gap-1" style={{ bottom: 4 }}>
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-white/50 shrink-0" style={{ marginLeft: 30 }} />
|
<div className="w-2 h-2 rounded-full bg-white/80 shrink-0" style={{ marginLeft: 29 }} />
|
||||||
<span className="text-[9px] text-white/60 truncate">{endTitle}</span>
|
<span className="text-[11px] text-white/90 font-semibold truncate">{cleanTitle(routeEndTitle)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 교량/터널 POIs */}
|
{/* 교량/터널 POIs — 겹침 방지: Y 간격 7% 미만이면 건너뜀 */}
|
||||||
{filteredPois.map((poi, i) => {
|
{(() => {
|
||||||
|
const MIN_GAP = 7; // %
|
||||||
|
const placed: number[] = [];
|
||||||
|
return filteredPois.map((poi, i) => {
|
||||||
const km = poiKm(poi, validStations);
|
const km = poiKm(poi, validStations);
|
||||||
if (km < 0) return null;
|
if (km < 0) return null;
|
||||||
const y = kmToY(km);
|
const y = kmToY(km);
|
||||||
if (y < 2 || y > 98) return null;
|
if (y < 5 || y > 95) return null;
|
||||||
|
if (placed.some(py => Math.abs(py - y) < MIN_GAP)) return null;
|
||||||
|
placed.push(y);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`poi-${i}`}
|
key={`poi-${i}`}
|
||||||
className="absolute flex items-center pointer-events-none"
|
className="absolute flex items-center pointer-events-none"
|
||||||
style={{ top: `${y}%`, transform: 'translateY(-50%)', left: 0, right: 0 }}
|
style={{ top: `${y}%`, transform: 'translateY(-50%)', left: 0, right: 0 }}
|
||||||
>
|
>
|
||||||
<div style={{ position: 'absolute', left: 30, width: 12, display: 'flex', justifyContent: 'center' }}>
|
<div style={{ position: 'absolute', left: 30, width: 16, display: 'flex', justifyContent: 'center' }}>
|
||||||
<div
|
<div
|
||||||
className="w-2.5 h-2.5 rounded-sm"
|
className="w-3 h-3 rounded-sm"
|
||||||
style={{ background: poi.category === '\uD130\uB110' ? '#6366f1' : '#0ea5e9' }}
|
style={{ background: poi.category === '\uD130\uB110' ? '#818cf8' : '#38bdf8' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-[8px] truncate"
|
className="text-[10px] truncate font-medium"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', left: 44, right: 2,
|
position: 'absolute', left: 48, right: 2,
|
||||||
color: poi.category === '\uD130\uB110' ? '#a5b4fc' : '#7dd3fc',
|
color: poi.category === '\uD130\uB110' ? '#c7d2fe' : '#bae6fd',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{poi.title}
|
{cleanTitle(poi.title)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Green visible range box */}
|
{/* Green visible range box */}
|
||||||
{visibleRange && (
|
{visibleRange && (
|
||||||
@@ -326,9 +354,9 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
style={{ position: 'absolute', left: 44 }}
|
style={{ position: 'absolute', left: 44 }}
|
||||||
className="bg-orange-500 text-white text-[9px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap"
|
className="bg-orange-500 text-white text-[11px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{currentStationTitle}
|
{cleanTitle(currentStationTitle)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ interface RenderCache {
|
|||||||
poiCount: number;
|
poiCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cleanTitle = (t: string) => t.replace(/\s*\([상하]\)\s*$/, '').trim();
|
||||||
|
|
||||||
function stationOrder(title: string): number {
|
function stationOrder(title: string): number {
|
||||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
const m = title.match(/(\d+)[Kk](\d+)/);
|
||||||
if (!m) return 0;
|
if (!m) return 0;
|
||||||
@@ -470,10 +472,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
const lx = Math.max(2, x + 8);
|
const lx = Math.max(2, x + 8);
|
||||||
ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4;
|
ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4;
|
||||||
ctx.lineJoin = 'round';
|
ctx.lineJoin = 'round';
|
||||||
ctx.strokeText(stA.title, lx, y);
|
ctx.strokeText(cleanTitle(stA.title), lx, y);
|
||||||
// 텍스트 본문
|
// 텍스트 본문
|
||||||
ctx.fillStyle = 'rgba(255,200,200,1.0)';
|
ctx.fillStyle = 'rgba(255,200,200,1.0)';
|
||||||
ctx.fillText(stA.title, lx, y);
|
ctx.fillText(cleanTitle(stA.title), lx, y);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POI 마커 — 보간 후 EMA 적용
|
// POI 마커 — 보간 후 EMA 적용
|
||||||
@@ -495,7 +497,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
// 이모지 + 텍스트
|
// 이모지 + 텍스트
|
||||||
const emoji = CATEGORY_EMOJI[poiA.category] ?? '📌';
|
const emoji = CATEGORY_EMOJI[poiA.category] ?? '📌';
|
||||||
const label = `${emoji} ${poiA.title}`;
|
const label = `${emoji} ${cleanTitle(poiA.title)}`;
|
||||||
const lx = Math.max(2, px + 14);
|
const lx = Math.max(2, px + 14);
|
||||||
ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4;
|
ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4;
|
||||||
ctx.lineJoin = 'round';
|
ctx.lineJoin = 'round';
|
||||||
|
|||||||
Reference in New Issue
Block a user