GhiVideo 소스 복제 — v4 작업 시작 기준
기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제. (node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
|
||||
/**
|
||||
* 지도 기반 나침반 (노스업) — OSM 타일 위에 '현재 위치(중심 고정 점)' + '드론 시야(반투명 역삼각형)'.
|
||||
* - 노스업: 지도는 북쪽 위로 고정. 드론이 움직이면 지도가 팬(현재 위치가 항상 중앙).
|
||||
* - 시야 역삼각형(▽): 중심(드론)에서 진행/촬영 방향으로 벌어지는 반투명 부채꼴. yaw 로 회전.
|
||||
* - 마우스 오버: 위젯 자체가 커져(스케일 아님, 타일 재배치) 더 넓은 영역을 선명하게 표시.
|
||||
* 휠로 줌 인/아웃. 벗어나면 기본 크기·기본 줌으로 복귀.
|
||||
* - poseRef: 부모 RAF 가 매 프레임 { lat, lon, yaw(도, 0=N 시계+) } 갱신 → 내부 RAF 가 읽어
|
||||
* 역삼각형 회전(즉시) + 지도 팬(transform) + 임계 이동/줌·크기 변경 시 타일 재배치.
|
||||
* - 타일은 서버 프록시(/api/tile) 경유(외부 직접 로드는 CSP img-src/COEP 로 차단).
|
||||
*/
|
||||
|
||||
export interface CompassPose { lat: number; lon: number; yaw: number }
|
||||
|
||||
const BASE_D = 200; // 기본 지름(px)
|
||||
const HOVER_D = 400; // 오버 시 지름(px, 2배) — 위젯 확대(스케일 아님 → 더 넓은 영역, 선명)
|
||||
const TILE = 256; // OSM 타일 크기
|
||||
const MARGIN = TILE; // 팬 중 빈틈 방지용 여유 타일 폭
|
||||
const RELAYOUT_PX = 96; // 중심이 이만큼 벗어나면 타일 재배치(팬 리셋)
|
||||
const DEFAULT_ZOOM = 16;
|
||||
const MIN_ZOOM = 12;
|
||||
const MAX_ZOOM = 19;
|
||||
|
||||
/** 위경도 → 전역 픽셀(Web Mercator, zoom z). */
|
||||
function project(lat: number, lon: number, z: number): { x: number; y: number } {
|
||||
const n = 2 ** z;
|
||||
const x = ((lon + 180) / 360) * n * TILE;
|
||||
const s = Math.min(0.9999, Math.max(-0.9999, Math.sin((lat * Math.PI) / 180)));
|
||||
const y = (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n * TILE;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
export function MapCompass({ poseRef }: { poseRef: MutableRefObject<CompassPose> }) {
|
||||
const mapStyle = useSettingsStore((s) => s.compassMapStyle); // 'street' | 'sat'
|
||||
const setMapStyle = useSettingsStore((s) => s.setCompassMapStyle);
|
||||
const src = mapStyle === 'sat' ? 'sat' : 'osm';
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [zoom, setZoomState] = useState(DEFAULT_ZOOM);
|
||||
const zoomRef = useRef(zoom);
|
||||
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
|
||||
const setZoom = (z: number): void => setZoomState(Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Math.round(z))));
|
||||
|
||||
const D = hovered ? HOVER_D : BASE_D;
|
||||
const HALF = D / 2;
|
||||
|
||||
// 타일 배치 기준 중심(이 좌표에 맞춰 타일을 깔고, 현재위치와의 차이는 transform 으로 팬).
|
||||
const [tileCenter, setTileCenter] = useState<{ lat: number; lon: number } | null>(null);
|
||||
const arrowRef = useRef<HTMLDivElement>(null);
|
||||
const layerRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tileCenterRef = useRef<{ lat: number; lon: number } | null>(null);
|
||||
useEffect(() => { tileCenterRef.current = tileCenter; }, [tileCenter]);
|
||||
|
||||
// 줌 변경 시 현재 위치 기준으로 타일 재배치(줌 전환 튐 방지).
|
||||
useEffect(() => {
|
||||
const p = poseRef.current;
|
||||
if (p && isFinite(p.lat) && isFinite(p.lon)) setTileCenter({ lat: p.lat, lon: p.lon });
|
||||
}, [zoom, poseRef]);
|
||||
|
||||
// 휠 줌(페이지 스크롤 대신). native 리스너(passive:false)로 preventDefault.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
e.preventDefault();
|
||||
setZoom(zoomRef.current + (e.deltaY < 0 ? 1 : -1)); // 위로 스크롤 = 확대
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
const tick = (): void => {
|
||||
raf = requestAnimationFrame(tick);
|
||||
const p = poseRef.current;
|
||||
if (!p || !isFinite(p.lat) || !isFinite(p.lon)) return;
|
||||
const z = zoomRef.current;
|
||||
if (arrowRef.current) arrowRef.current.style.transform = `rotate(${p.yaw}deg)`;
|
||||
const tc = tileCenterRef.current;
|
||||
if (!tc) { setTileCenter({ lat: p.lat, lon: p.lon }); return; }
|
||||
const cur = project(p.lat, p.lon, z);
|
||||
const base = project(tc.lat, tc.lon, z);
|
||||
const dx = cur.x - base.x, dy = cur.y - base.y;
|
||||
if (layerRef.current) layerRef.current.style.transform = `translate(${-dx}px, ${-dy}px)`;
|
||||
if (Math.hypot(dx, dy) > RELAYOUT_PX) setTileCenter({ lat: p.lat, lon: p.lon });
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [poseRef]);
|
||||
|
||||
// 벗어나면 기본값 복귀(기본 크기 + 기본 줌).
|
||||
const onLeave = (): void => { setHovered(false); setZoomState(DEFAULT_ZOOM); };
|
||||
|
||||
// 타일 배치 — 기준 중심(tileCenter)을 뷰포트 중앙에 두고, 뷰포트+여유를 덮는 타일 나열.
|
||||
const tiles: { key: string; url: string; left: number; top: number }[] = [];
|
||||
if (tileCenter) {
|
||||
const c = project(tileCenter.lat, tileCenter.lon, zoom);
|
||||
const n = 2 ** zoom;
|
||||
const minTx = Math.floor((c.x - HALF - MARGIN) / TILE);
|
||||
const maxTx = Math.floor((c.x + HALF + MARGIN) / TILE);
|
||||
const minTy = Math.floor((c.y - HALF - MARGIN) / TILE);
|
||||
const maxTy = Math.floor((c.y + HALF + MARGIN) / TILE);
|
||||
for (let tx = minTx; tx <= maxTx; tx++) {
|
||||
for (let ty = minTy; ty <= maxTy; ty++) {
|
||||
if (ty < 0 || ty >= n) continue;
|
||||
const wx = ((tx % n) + n) % n; // 경도 래핑
|
||||
tiles.push({
|
||||
key: `${src}_${zoom}_${tx}_${ty}`,
|
||||
url: `/api/tile/${src}/${zoom}/${wx}/${ty}`,
|
||||
left: tx * TILE - c.x + HALF,
|
||||
top: ty * TILE - c.y + HALF,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={onLeave}
|
||||
onClick={() => setMapStyle(mapStyle === 'sat' ? 'street' : 'sat')}
|
||||
title="클릭: 위성 ↔ 일반 전환"
|
||||
style={{ position: 'absolute', top: 14, right: 14, width: D, height: D, zIndex: hovered ? 40 : 30, pointerEvents: 'auto', cursor: 'pointer' }}
|
||||
>
|
||||
{/* 원형 클립 지도 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', inset: 0, borderRadius: '50%', overflow: 'hidden',
|
||||
background: 'rgba(16,22,32,0.55)', border: '3px solid rgba(255,255,255,0.92)', boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{/* 팬 레이어 — RAF 가 transform 으로 이동. 타일은 tileCenter 기준 배치. */}
|
||||
<div ref={layerRef} style={{ position: 'absolute', inset: 0, willChange: 'transform' }}>
|
||||
{tiles.map((t) => (
|
||||
<img
|
||||
key={t.key} src={t.url} width={TILE} height={TILE} alt="" draggable={false}
|
||||
style={{ position: 'absolute', left: t.left, top: t.top, maxWidth: 'none' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!tileCenter && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,0.7)', fontSize: 11 }}>
|
||||
지도 로딩…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 드론 시야(역삼각형 ▽) — 중심(드론)에서 촬영방향으로 벌어짐. yaw 로 회전.
|
||||
주황 그라데이션: 근거리(드론) 진하게 → 멀수록 투명. 보더 없음. */}
|
||||
<div ref={arrowRef} style={{ position: 'absolute', inset: 0, transformOrigin: '50% 50%' }}>
|
||||
<svg viewBox="0 0 100 100" width={D} height={D} style={{ position: 'absolute', inset: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="fovGrad" gradientUnits="userSpaceOnUse" x1="50" y1="50" x2="50" y2="13">
|
||||
<stop offset="0%" stopColor="#ff8800" stopOpacity="0.85" />
|
||||
<stop offset="100%" stopColor="#ff8800" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* 꼭짓점이 현재위치 점에 붙음(50). */}
|
||||
<polygon points="50,50 27,13 73,13" fill="url(#fovGrad)" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 현재 위치 점(중심) + 상단 N(노스업 고정, 크게·붉은·반투명) — SVG 라 위젯 크기에 맞춰 스케일. */}
|
||||
<svg viewBox="0 0 100 100" width={D} height={D} style={{ position: 'absolute', inset: 0 }}>
|
||||
{/* 현재 위치 점 — 빨강 단색, 보더 없음. */}
|
||||
<circle cx="50" cy="50" r="3.6" fill="rgba(226,35,26,0.8)" />
|
||||
<text
|
||||
x="50" y="13.5" textAnchor="middle" fontSize="15" fontWeight="800"
|
||||
fill="#e22319" textLength="9" lengthAdjust="spacingAndGlyphs"
|
||||
>N</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user