From 4636679537d614d3ee5d3b256a2b87d8fec27bdb Mon Sep 17 00:00:00 2001 From: minsung Date: Wed, 1 Apr 2026 16:32:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20RoutePanel/StationOverlay=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 시점/종점: 드론 첫/마지막 프레임 기준 역명 표시 (회덕, 대전조차장 등) - (상)/(하) 접미어 제거 (cleanTitle) - 패널 높이 90%, 글씨 크기 +30%, 투명도 밝게 - 교량/터널 POI 겹침 방지 (7% 간격) Co-Authored-By: Claude Sonnet 4.6 --- client/src/components/overlay/RoutePanel.tsx | 84 ++++++++++++------- .../src/components/overlay/StationOverlay.tsx | 8 +- 2 files changed, 61 insertions(+), 31 deletions(-) diff --git a/client/src/components/overlay/RoutePanel.tsx b/client/src/components/overlay/RoutePanel.tsx index 17e2b25..43a6fe9 100644 --- a/client/src/components/overlay/RoutePanel.tsx +++ b/client/src/components/overlay/RoutePanel.tsx @@ -23,6 +23,8 @@ interface RoutePanelProps { 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; @@ -61,6 +63,8 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP 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); @@ -89,6 +93,24 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP .catch(() => {}); }, [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 useEffect(() => { if (!droneFramesLoaded) return; @@ -236,57 +258,63 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP return (
{/* Center vertical line */}
- {/* 시점 측점명 — 상단 */} -
-
- {startTitle} + {/* 시점 역명 — 상단 */} +
+
+ {cleanTitle(routeStartTitle)}
- {/* 종점 측점명 — 하단 */} -
-
- {endTitle} + {/* 종점 역명 — 하단 */} +
+
+ {cleanTitle(routeEndTitle)}
- {/* 교량/터널 POIs */} - {filteredPois.map((poi, i) => { - const km = poiKm(poi, validStations); - if (km < 0) return null; - const y = kmToY(km); - if (y < 2 || y > 98) return null; - return ( + {/* 교량/터널 POIs — 겹침 방지: Y 간격 7% 미만이면 건너뜀 */} + {(() => { + const MIN_GAP = 7; // % + 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 (
-
+
- {poi.title} + {cleanTitle(poi.title)}
); - })} + }); + })()} {/* Green visible range box */} {visibleRange && ( @@ -326,9 +354,9 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP />
- {currentStationTitle} + {cleanTitle(currentStationTitle)}
diff --git a/client/src/components/overlay/StationOverlay.tsx b/client/src/components/overlay/StationOverlay.tsx index 8a64624..c0e3f35 100644 --- a/client/src/components/overlay/StationOverlay.tsx +++ b/client/src/components/overlay/StationOverlay.tsx @@ -65,6 +65,8 @@ interface RenderCache { poiCount: number; } +const cleanTitle = (t: string) => t.replace(/\s*\([상하]\)\s*$/, '').trim(); + function stationOrder(title: string): number { const m = title.match(/(\d+)[Kk](\d+)/); if (!m) return 0; @@ -470,10 +472,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible const lx = Math.max(2, x + 8); ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4; 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.fillText(stA.title, lx, y); + ctx.fillText(cleanTitle(stA.title), lx, y); }); // POI 마커 — 보간 후 EMA 적용 @@ -495,7 +497,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible ctx.stroke(); // 이모지 + 텍스트 const emoji = CATEGORY_EMOJI[poiA.category] ?? '📌'; - const label = `${emoji} ${poiA.title}`; + const label = `${emoji} ${cleanTitle(poiA.title)}`; const lx = Math.max(2, px + 14); ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4; ctx.lineJoin = 'round';