feat(map): #363 메인 지도 SDK 국내(네이버)/해외(구글) 분기
- MapView dispatcher: NAVER 키 + KR bbox 좌표 → NaverMapView - NaverMapView 신규 (네이버 v3 직접 wrapper, Supercluster 재사용) - GoogleMapView 신규 (기존 MapView 내용 rename) - MapView.types.ts 공용 타입 + isKoreaCoord 헬퍼 - Dockerfile/deploy.sh: NEXT_PUBLIC_NAVER_MAP_CLIENT_ID build-arg - 키 미설정 시 GoogleMap fallback (회귀 0) Refs: #363 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
225
frontend/src/components/NaverMapView.tsx
Normal file
225
frontend/src/components/NaverMapView.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Supercluster from "supercluster";
|
||||
import type { Restaurant } from "@/lib/api";
|
||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||
import Icon from "@/components/Icon";
|
||||
import type { MapBounds, FlyTo, MapViewProps } from "@/components/MapView.types";
|
||||
|
||||
// ---- naver maps v3 타입 최소 정의 (full 타입은 @types/navermaps 없음) ----
|
||||
declare global {
|
||||
interface Window {
|
||||
naver?: {
|
||||
maps: NaverMaps;
|
||||
};
|
||||
}
|
||||
}
|
||||
type NaverMaps = {
|
||||
LatLng: new (lat: number, lng: number) => unknown;
|
||||
Map: new (el: HTMLElement, opts: Record<string, unknown>) => NaverMapInstance;
|
||||
Event: { addListener: (target: unknown, type: string, fn: (...args: unknown[]) => void) => unknown };
|
||||
Position?: unknown;
|
||||
MapTypeControlStyle?: unknown;
|
||||
ZoomControlStyle?: unknown;
|
||||
};
|
||||
type NaverMapInstance = {
|
||||
setCenter: (latlng: unknown) => void;
|
||||
setZoom: (zoom: number, useEffect?: boolean) => void;
|
||||
getCenter: () => { lat: () => number; lng: () => number; x: number; y: number };
|
||||
getZoom: () => number;
|
||||
getBounds: () => { getNE: () => { lat: () => number; lng: () => number }; getSW: () => { lat: () => number; lng: () => number } };
|
||||
getProjection: () => { fromCoordToOffset: (latlng: unknown) => { x: number; y: number } };
|
||||
panTo: (latlng: unknown, opts?: Record<string, unknown>) => void;
|
||||
destroy?: () => void;
|
||||
};
|
||||
|
||||
const NAVER_CLIENT_ID = process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || "";
|
||||
|
||||
function useNaverMaps(): { ready: boolean; error: string | null } {
|
||||
const [ready, setReady] = useState(typeof window !== "undefined" && !!window.naver?.maps);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!NAVER_CLIENT_ID) {
|
||||
setError("NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 미설정");
|
||||
return;
|
||||
}
|
||||
if (window.naver?.maps) { setReady(true); return; }
|
||||
const existing = document.querySelector<HTMLScriptElement>(`script[data-naver-maps]`);
|
||||
if (existing) {
|
||||
existing.addEventListener("load", () => setReady(true), { once: true });
|
||||
return;
|
||||
}
|
||||
const s = document.createElement("script");
|
||||
s.src = `https://oapi.map.naver.com/openapi/v3/maps.js?ncpClientId=${NAVER_CLIENT_ID}`;
|
||||
s.async = true;
|
||||
s.dataset.naverMaps = "1";
|
||||
s.onload = () => setReady(true);
|
||||
s.onerror = () => setError("naver maps v3 스크립트 로드 실패");
|
||||
document.head.appendChild(s);
|
||||
}, []);
|
||||
|
||||
return { ready, error };
|
||||
}
|
||||
|
||||
type RestaurantProps = { restaurant: Restaurant };
|
||||
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
|
||||
|
||||
function useSupercluster(restaurants: Restaurant[]) {
|
||||
const points: RestaurantFeature[] = useMemo(
|
||||
() => restaurants.map((r) => ({
|
||||
type: "Feature" as const,
|
||||
geometry: { type: "Point" as const, coordinates: [r.longitude, r.latitude] },
|
||||
properties: { restaurant: r },
|
||||
})),
|
||||
[restaurants]
|
||||
);
|
||||
const index = useMemo(() => {
|
||||
const sc = new Supercluster<{ restaurant: Restaurant }>({ radius: 60, maxZoom: 16, minPoints: 2 });
|
||||
sc.load(points);
|
||||
return sc;
|
||||
}, [points]);
|
||||
const getClusters = useCallback((bounds: MapBounds, zoom: number) =>
|
||||
index.getClusters([bounds.west, bounds.south, bounds.east, bounds.north], Math.floor(zoom))
|
||||
, [index]);
|
||||
const getExpansionZoom = useCallback((clusterId: number) => {
|
||||
try { return index.getClusterExpansionZoom(clusterId); } catch { return 17; }
|
||||
}, [index]);
|
||||
return { getClusters, getExpansionZoom };
|
||||
}
|
||||
|
||||
function getClusterSize(count: number): number {
|
||||
if (count < 10) return 36;
|
||||
if (count < 50) return 42;
|
||||
if (count < 100) return 48;
|
||||
return 54;
|
||||
}
|
||||
|
||||
export default function NaverMapView({
|
||||
restaurants,
|
||||
selected,
|
||||
onSelectRestaurant,
|
||||
onBoundsChanged,
|
||||
flyTo,
|
||||
onMyLocation,
|
||||
}: MapViewProps) {
|
||||
const { ready, error } = useNaverMaps();
|
||||
const divRef = useRef<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<NaverMapInstance | null>(null);
|
||||
const [bounds, setBounds] = useState<MapBounds | null>(null);
|
||||
const [zoom, setZoom] = useState(13);
|
||||
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
|
||||
|
||||
// 1) 지도 인스턴스 1회 생성
|
||||
useEffect(() => {
|
||||
if (!ready || !divRef.current || mapRef.current) return;
|
||||
const n = window.naver!.maps;
|
||||
const initLat = flyTo?.lat ?? selected?.latitude ?? 37.5665;
|
||||
const initLng = flyTo?.lng ?? selected?.longitude ?? 126.978;
|
||||
const initZoom = flyTo?.zoom ?? 13;
|
||||
const m = new n.Map(divRef.current, {
|
||||
center: new n.LatLng(initLat, initLng),
|
||||
zoom: initZoom,
|
||||
logoControl: false,
|
||||
mapDataControl: false,
|
||||
scaleControl: false,
|
||||
zoomControl: false,
|
||||
});
|
||||
mapRef.current = m;
|
||||
const sync = () => {
|
||||
const b = m.getBounds();
|
||||
const ne = b.getNE(), sw = b.getSW();
|
||||
const nb: MapBounds = { north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() };
|
||||
setBounds(nb);
|
||||
setZoom(m.getZoom());
|
||||
onBoundsChanged?.(nb);
|
||||
};
|
||||
sync();
|
||||
n.Event.addListener(m, "bounds_changed", sync);
|
||||
n.Event.addListener(m, "zoom_changed", sync);
|
||||
}, [ready, flyTo, selected, onBoundsChanged]);
|
||||
|
||||
// 2) flyTo 변경 반영
|
||||
useEffect(() => {
|
||||
const m = mapRef.current;
|
||||
if (!m || !flyTo || !window.naver?.maps) return;
|
||||
m.panTo(new window.naver.maps.LatLng(flyTo.lat, flyTo.lng));
|
||||
if (flyTo.zoom) m.setZoom(flyTo.zoom, true);
|
||||
}, [flyTo]);
|
||||
|
||||
const clusters = useMemo(() => {
|
||||
if (!bounds) return [];
|
||||
return getClusters(bounds, zoom);
|
||||
}, [bounds, zoom, getClusters]);
|
||||
|
||||
// 3) 좌표 → 화면 픽셀 변환 (네이버 projection)
|
||||
const toScreen = useCallback((lat: number, lng: number) => {
|
||||
const m = mapRef.current;
|
||||
if (!m || !window.naver?.maps) return null;
|
||||
const p = m.getProjection().fromCoordToOffset(new window.naver.maps.LatLng(lat, lng));
|
||||
return p;
|
||||
}, []);
|
||||
|
||||
if (error) return <div className="w-full h-full flex items-center justify-center text-xs text-red-500">{error} — 새로고침 후에도 같으면 Google Map으로 폴백됩니다.</div>;
|
||||
if (!ready) return <div className="w-full h-full flex items-center justify-center text-xs text-gray-400">네이버 지도 로딩 중…</div>;
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<div ref={divRef} className="absolute inset-0" />
|
||||
|
||||
{/* 마커 overlay (좌표→픽셀 변환 + absolute positioned div) */}
|
||||
{clusters.map((feature) => {
|
||||
const [lng, lat] = feature.geometry.coordinates;
|
||||
const pt = toScreen(lat, lng);
|
||||
if (!pt) return null;
|
||||
const isCluster = feature.properties && "cluster" in feature.properties && feature.properties.cluster;
|
||||
if (isCluster) {
|
||||
const { cluster_id, point_count } = feature.properties as Supercluster.ClusterProperties;
|
||||
const size = getClusterSize(point_count);
|
||||
return (
|
||||
<button
|
||||
key={`c-${cluster_id}`}
|
||||
onClick={() => {
|
||||
const z = Math.min(getExpansionZoom(cluster_id), 18);
|
||||
const m = mapRef.current;
|
||||
if (!m || !window.naver?.maps) return;
|
||||
m.panTo(new window.naver.maps.LatLng(lat, lng));
|
||||
m.setZoom(z, true);
|
||||
}}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full bg-brand-500/90 text-white font-semibold shadow-lg ring-2 ring-white"
|
||||
style={{ left: pt.x, top: pt.y, width: size, height: size, fontSize: size > 44 ? 14 : 12 }}
|
||||
>
|
||||
{point_count}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const r = (feature.properties as RestaurantProps).restaurant;
|
||||
const cuisineIcon = getCuisineIcon(r.cuisine_type);
|
||||
const isSel = selected?.id === r.id;
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => onSelectRestaurant?.(r)}
|
||||
className={`absolute -translate-x-1/2 -translate-y-full rounded-full shadow-md ring-2 ring-white transition-transform ${isSel ? "scale-125 z-10" : ""}`}
|
||||
style={{ left: pt.x, top: pt.y, width: 32, height: 32, background: "#f59e0b", color: "#78350f" }}
|
||||
title={r.name}
|
||||
>
|
||||
<Icon name={cuisineIcon} size={18} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 내 위치 버튼 */}
|
||||
{onMyLocation && (
|
||||
<button
|
||||
onClick={onMyLocation}
|
||||
aria-label="내 위치"
|
||||
className="absolute right-3 bottom-3 size-11 rounded-full bg-white shadow-lg flex items-center justify-center"
|
||||
>
|
||||
<Icon name="my-location" size={22} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user