feat: 지도 나침반(OSM/위성) 추가 + 측점 검색·마커 정확도 개선 + POI/영상 수정

- 지도 기반 나침반(노스업, 현재위치, 시야 역삼각형): 호버 확대·휠 줌·클릭 위성전환, OSM/Esri 타일 서버 프록시(/api/tile)
- 스테이션 검색: 실제 측점(chain) 기준 이동, 없으면 '측점 없음' 안내
- 역 마커: 직교 투영 측점 일치 시에만 표시, 미도착 종점은 추가 방식
- POI 팝업 겹침/재등장·라벨 정합 수정
- 영상 fps 데이터 기반 자동 산출
- 기술/발표/쉬운설명 문서 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 18:03:30 +09:00
co-authored by Claude Opus 4.8
parent e9052143d7
commit e0ff5dd6d0
58 changed files with 9635 additions and 378 deletions
+2
View File
@@ -14,6 +14,7 @@ import metaRouter from './routes/meta';
import annotationsRouter from './routes/annotations';
import geoRouter from './routes/geo';
import elevationRouter from './routes/elevation';
import tileRouter from './routes/tile';
const app = express();
@@ -35,6 +36,7 @@ app.use('/api/meta', metaRouter);
app.use('/api/annotations/:videoId', annotationsRouter);
app.use('/api/geo', geoRouter);
app.use('/api/elevation', elevationRouter);
app.use('/api/tile', tileRouter);
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
+51
View File
@@ -0,0 +1,51 @@
import { Router } from 'express';
const router = Router();
/**
* 지도 타일 프록시 — 브라우저 CSP(img-src 'self') / COEP(require-corp) 때문에
* 클라이언트가 외부 타일 서버를 직접 <img> 로 못 부른다. 서버가 중계해 같은 출처로 응답.
*
* GET /api/tile/:source/:z/:x/:y (y 는 숫자 또는 "숫자.png")
* - source=osm : OpenStreetMap 일반(도로) 지도 https://tile.openstreetmap.org/{z}/{x}/{y}.png
* - source=sat : Esri World Imagery 위성 .../tile/{z}/{y}/{x} (row=y, col=x 순서 주의)
*
* 주의: 각 타일 서버 사용 정책 준수(식별 User-Agent, 저부하). Esri World Imagery 는 출처표기 필요.
*/
router.get('/:source/:z/:x/:y', async (req, res) => {
const source = String(req.params.source);
const z = Number(req.params.z);
const x = Number(req.params.x); // col (tx)
const y = Number(String(req.params.y).replace(/\.png$/i, '')); // row (ty)
if (![z, x, y].every(Number.isInteger) || z < 0 || z > 19) {
res.status(400).json({ error: 'invalid tile coords' });
return;
}
const n = 2 ** z;
if (x < 0 || x >= n || y < 0 || y >= n) {
res.status(400).json({ error: 'tile out of range' });
return;
}
const upstreamUrl =
source === 'sat'
? `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${z}/${y}/${x}` // z/row/col
: `https://tile.openstreetmap.org/${z}/${x}/${y}.png`; // z/col/row
try {
const upstream = await fetch(upstreamUrl, {
headers: { 'User-Agent': 'GhiVideo/1.0 (drone route inspection player)' },
});
if (!upstream.ok) {
res.status(502).json({ error: 'upstream ' + upstream.status });
return;
}
const buf = Buffer.from(await upstream.arrayBuffer());
res.setHeader('Content-Type', upstream.headers.get('content-type') ?? 'image/png');
res.setHeader('Cache-Control', 'public, max-age=604800, immutable'); // 7일 캐시
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); // COEP 만족
res.send(buf);
} catch (e) {
res.status(502).json({ error: String(e) });
}
});
export default router;