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 }) { 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(null); const layerRef = useRef(null); const containerRef = useRef(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 (
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' }} > {/* 원형 클립 지도 */}
{/* 팬 레이어 — RAF 가 transform 으로 이동. 타일은 tileCenter 기준 배치. */}
{tiles.map((t) => ( ))}
{!tileCenter && (
지도 로딩…
)}
{/* 드론 시야(역삼각형 ▽) — 중심(드론)에서 촬영방향으로 벌어짐. yaw 로 회전. 주황 그라데이션: 근거리(드론) 진하게 → 멀수록 투명. 보더 없음. */}
{/* 꼭짓점이 현재위치 점에 붙음(50). */}
{/* 현재 위치 점(중심) + 상단 N(노스업 고정, 크게·붉은·반투명) — SVG 라 위젯 크기에 맞춰 스케일. */} {/* 현재 위치 점 — 빨강 단색, 보더 없음. */} N
); }