- selected 시 무조건 zoom 16 → 줌 18에서 마커 클릭하면 다시 16으로 줄어 클러스터링 - 현재 zoom ≥ 16이면 유지, 미만이면 16으로 (Naver/Google 둘 다) - page.tsx의 setRegionFlyTo 호출 제거 — selected useEffect로 일원화 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
359 lines
15 KiB
TypeScript
359 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import Supercluster from "supercluster";
|
|
import type { Restaurant } from "@/lib/api";
|
|
import Icon from "@/components/Icon";
|
|
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
|
import type { MapBounds, MapViewProps } from "@/components/MapView.types";
|
|
|
|
declare global {
|
|
interface Window {
|
|
naver?: { maps: NaverMaps };
|
|
}
|
|
}
|
|
type LatLng = { lat: () => number; lng: () => number };
|
|
type NaverMaps = {
|
|
LatLng: new (lat: number, lng: number) => LatLng;
|
|
Map: new (el: HTMLElement, opts: Record<string, unknown>) => NaverMapInstance;
|
|
Marker: new (opts: Record<string, unknown>) => NaverMarker;
|
|
InfoWindow: new (opts: Record<string, unknown>) => NaverInfoWindow;
|
|
Event: { addListener: (target: unknown, type: string, fn: (...args: unknown[]) => void) => unknown; removeListener: (handler: unknown) => void };
|
|
Size: new (w: number, h: number) => unknown;
|
|
Point: new (x: number, y: number) => unknown;
|
|
};
|
|
type NaverMapInstance = {
|
|
setCenter: (latlng: unknown) => void;
|
|
setZoom: (zoom: number, useEffect?: boolean) => void;
|
|
getZoom: () => number;
|
|
getBounds: () => { getNE: () => LatLng; getSW: () => LatLng };
|
|
panTo: (latlng: unknown, opts?: Record<string, unknown>) => void;
|
|
morph: (latlng: unknown, zoom?: number, transitionOptions?: Record<string, unknown>) => void;
|
|
refresh: (noEffect?: boolean) => void;
|
|
};
|
|
type NaverMarker = {
|
|
setMap: (map: NaverMapInstance | null) => void;
|
|
setIcon: (icon: Record<string, unknown>) => void;
|
|
setPosition: (latlng: unknown) => void;
|
|
getPosition: () => LatLng;
|
|
};
|
|
type NaverInfoWindow = {
|
|
open: (map: NaverMapInstance, marker: NaverMarker) => void;
|
|
close: () => void;
|
|
setContent: (content: string) => void;
|
|
getMap: () => NaverMapInstance | null;
|
|
};
|
|
|
|
const NAVER_CLIENT_ID = process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || "";
|
|
|
|
// Channel color palette — GoogleMapView와 동일
|
|
const CHANNEL_COLORS = [
|
|
{ bg: "#fff7ed", text: "#78350f", border: "#f59e0b", arrow: "#f59e0b" }, // amber (default)
|
|
{ bg: "#eff6ff", text: "#1e3a5f", border: "#3b82f6", arrow: "#3b82f6" }, // blue
|
|
{ bg: "#f0fdf4", text: "#14532d", border: "#22c55e", arrow: "#22c55e" }, // green
|
|
{ bg: "#fdf2f8", text: "#831843", border: "#ec4899", arrow: "#ec4899" }, // pink
|
|
{ bg: "#faf5ff", text: "#581c87", border: "#a855f7", arrow: "#a855f7" }, // purple
|
|
{ bg: "#fff1f2", text: "#7f1d1d", border: "#ef4444", arrow: "#ef4444" }, // red
|
|
{ bg: "#f0fdfa", text: "#134e4a", border: "#14b8a6", arrow: "#14b8a6" }, // teal
|
|
{ bg: "#fefce8", text: "#713f12", border: "#eab308", arrow: "#eab308" }, // yellow
|
|
];
|
|
|
|
function getChannelColorMap(restaurants: Restaurant[]) {
|
|
const channels = new Set<string>();
|
|
restaurants.forEach((r) => r.channels?.forEach((ch) => channels.add(ch)));
|
|
const map: Record<string, typeof CHANNEL_COLORS[0]> = {};
|
|
let i = 0;
|
|
for (const ch of channels) {
|
|
map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
|
|
i++;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
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?ncpKeyId=${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;
|
|
}
|
|
|
|
// 단일 마커 — 식당명 박스 + 화살표 핀 (GoogleMapView와 동일 디자인)
|
|
function markerIconHtml(
|
|
name: string,
|
|
cuisineIcon: string,
|
|
c: typeof CHANNEL_COLORS[0],
|
|
opts: { isSelected: boolean; isClosed: boolean }
|
|
): string {
|
|
const { isSelected, isClosed } = opts;
|
|
const bg = isSelected ? "#2563eb" : isClosed ? "#f3f4f6" : c.bg;
|
|
const text = isSelected ? "#fff" : isClosed ? "#9ca3af" : c.text;
|
|
const border = isSelected ? "2px solid #1d4ed8" : `1.5px solid ${c.border}`;
|
|
const shadow = isSelected ? "0 2px 8px rgba(37,99,235,0.4)" : `0 1px 4px ${c.border}40`;
|
|
const arrowColor = isSelected ? "#1d4ed8" : c.arrow;
|
|
const opacity = isClosed ? 0.5 : 1;
|
|
const deco = isClosed ? "line-through" : "none";
|
|
return `
|
|
<div style="display:flex;flex-direction:column;align-items:center;transition:transform .2s ease;transform:scale(${isSelected ? 1.15 : 1});opacity:${opacity};">
|
|
<div style="padding:4px 8px;background:${bg};color:${text};font-size:12px;font-weight:600;border-radius:6px;border:${border};box-shadow:${shadow};white-space:nowrap;max-width:120px;overflow:hidden;text-overflow:ellipsis;text-decoration:${deco};">
|
|
<span class="material-symbols-rounded" style="font-size:14px;width:14px;height:14px;overflow:hidden;display:inline-flex;align-items:center;justify-content:center;margin-right:3px;vertical-align:middle;color:#E8720C;">${escapeHtml(cuisineIcon)}</span>${escapeHtml(name)}
|
|
</div>
|
|
<div style="width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:6px solid ${arrowColor};margin-top:-1px;"></div>
|
|
</div>
|
|
`;
|
|
}
|
|
// SVG data URL — 클러스터(숫자)
|
|
function clusterIconHtml(count: number, size: number): string {
|
|
return `<div style="width:${size}px;height:${size}px;border-radius:9999px;background:rgba(245,158,11,.92);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:${size > 44 ? 14 : 12}px;border:2px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.3);">${count}</div>`;
|
|
}
|
|
|
|
export default function NaverMapView({
|
|
restaurants,
|
|
selected,
|
|
onSelectRestaurant,
|
|
onBoundsChanged,
|
|
flyTo,
|
|
onMyLocation,
|
|
activeChannel,
|
|
}: MapViewProps) {
|
|
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
|
const { ready, error } = useNaverMaps();
|
|
const divRef = useRef<HTMLDivElement | null>(null);
|
|
const mapRef = useRef<NaverMapInstance | null>(null);
|
|
const markersRef = useRef<NaverMarker[]>([]);
|
|
const infoWindowRef = useRef<NaverInfoWindow | null>(null);
|
|
const [bounds, setBounds] = useState<MapBounds | null>(null);
|
|
const [zoom, setZoom] = useState(13);
|
|
const [initError, setInitError] = useState<string | null>(null);
|
|
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
|
|
|
|
// 지도 1회 생성
|
|
useEffect(() => {
|
|
if (!ready || !divRef.current || mapRef.current) return;
|
|
try {
|
|
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;
|
|
infoWindowRef.current = new n.InfoWindow({
|
|
borderWidth: 0,
|
|
anchorSize: new n.Size(10, 10),
|
|
pixelOffset: new n.Point(0, -8),
|
|
backgroundColor: "transparent",
|
|
disableAnchor: false,
|
|
});
|
|
|
|
const ro = new ResizeObserver(() => {
|
|
try { m.refresh(true); } catch { /* noop */ }
|
|
});
|
|
ro.observe(divRef.current);
|
|
|
|
// bounds_changed가 줌/팬 끝나는 시점에 한 번만 emit (SDK가 throttle)
|
|
const sync = () => {
|
|
try {
|
|
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);
|
|
} catch (e) {
|
|
console.warn("[NaverMap] sync failed", e);
|
|
}
|
|
};
|
|
requestAnimationFrame(() => {
|
|
try { m.refresh(true); } catch { /* noop */ }
|
|
sync();
|
|
});
|
|
// idle = 줌/팬 끝났을 때 한 번 (bounds_changed보다 적게 발화 → 성능)
|
|
n.Event.addListener(m, "idle", sync);
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
console.error("[NaverMap] init failed", e);
|
|
setInitError(msg);
|
|
}
|
|
}, [ready, flyTo, selected, onBoundsChanged]);
|
|
|
|
// flyTo 변경 반영 — morph로 panTo+setZoom 충돌 회피
|
|
useEffect(() => {
|
|
const m = mapRef.current;
|
|
if (!m || !flyTo || !window.naver?.maps) return;
|
|
const latlng = new window.naver.maps.LatLng(flyTo.lat, flyTo.lng);
|
|
if (flyTo.zoom) m.morph(latlng, flyTo.zoom);
|
|
else m.panTo(latlng);
|
|
}, [flyTo]);
|
|
|
|
// selected 변경 시 자동 중앙 — 현재 줌이 16 이상이면 그대로, 미만이면 16으로 확대
|
|
// (클러스터 expansion으로 18까지 들어간 상태에서 마커 클릭 시 다시 16으로 줄어 클러스터링되는 것 방지)
|
|
useEffect(() => {
|
|
const m = mapRef.current;
|
|
if (!m || !selected || !window.naver?.maps) return;
|
|
if (selected.latitude == null || selected.longitude == null) return;
|
|
const latlng = new window.naver.maps.LatLng(selected.latitude, selected.longitude);
|
|
const targetZoom = Math.max(m.getZoom(), 16);
|
|
m.morph(latlng, targetZoom);
|
|
}, [selected]);
|
|
|
|
// 클러스터 계산 (bounds/zoom 변경 시)
|
|
const clusters = useMemo(() => {
|
|
if (!bounds) return [];
|
|
return getClusters(bounds, zoom);
|
|
}, [bounds, zoom, getClusters]);
|
|
|
|
// 마커를 SDK 네이티브로 그림 — clusters 바뀌면 기존 마커 모두 제거 후 새로 생성
|
|
useEffect(() => {
|
|
const m = mapRef.current;
|
|
const naver = window.naver?.maps;
|
|
if (!m || !naver) return;
|
|
|
|
// 기존 마커 제거
|
|
for (const mk of markersRef.current) mk.setMap(null);
|
|
markersRef.current = [];
|
|
|
|
for (const feature of clusters) {
|
|
const [lng, lat] = feature.geometry.coordinates;
|
|
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);
|
|
const marker = new naver.Marker({
|
|
position: new naver.LatLng(lat, lng),
|
|
map: m,
|
|
icon: {
|
|
content: clusterIconHtml(point_count, size),
|
|
anchor: new naver.Point(size / 2, size / 2),
|
|
},
|
|
});
|
|
naver.Event.addListener(marker, "click", () => {
|
|
const z = Math.min(getExpansionZoom(cluster_id), 18);
|
|
m.morph(new naver.LatLng(lat, lng), z);
|
|
});
|
|
markersRef.current.push(marker);
|
|
} else {
|
|
const r = (feature.properties as RestaurantProps).restaurant;
|
|
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
|
|
const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0];
|
|
const isSelected = selected?.id === r.id;
|
|
const isClosed = r.business_status === "CLOSED_PERMANENTLY" || r.business_status === "CLOSED_TEMPORARILY";
|
|
const cuisineIcon = getCuisineIcon(r.cuisine_type);
|
|
const marker = new naver.Marker({
|
|
position: new naver.LatLng(lat, lng),
|
|
map: m,
|
|
title: r.name,
|
|
zIndex: isSelected ? 1000 : 1,
|
|
icon: {
|
|
content: markerIconHtml(r.name, cuisineIcon, chColor ?? CHANNEL_COLORS[0], { isSelected, isClosed }),
|
|
// 박스 폭 가변 — 화살표 끝(하단 중앙)이 좌표에 위치하도록 추정 anchor
|
|
// approxWidth = textLen * 7 + 30 (icon+padding), height = box 24 + arrow 6 = 30
|
|
anchor: new naver.Point(Math.min(r.name.length * 4 + 18, 64), 30),
|
|
},
|
|
});
|
|
naver.Event.addListener(marker, "click", () => {
|
|
onSelectRestaurant?.(r);
|
|
});
|
|
markersRef.current.push(marker);
|
|
}
|
|
}
|
|
}, [clusters, getExpansionZoom, onSelectRestaurant, channelColors, activeChannel, selected]);
|
|
|
|
// 컴포넌트 unmount 시 마커 정리
|
|
useEffect(() => {
|
|
return () => {
|
|
for (const mk of markersRef.current) mk.setMap(null);
|
|
markersRef.current = [];
|
|
infoWindowRef.current?.close();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="relative w-full h-full">
|
|
<div
|
|
ref={divRef}
|
|
className="absolute inset-0"
|
|
style={{ width: "100%", height: "100%", backgroundColor: "#e5e7eb" }}
|
|
/>
|
|
{(error || initError) && (
|
|
<div className="absolute inset-0 flex items-center justify-center text-xs text-red-600 bg-white/80 pointer-events-none">
|
|
{error ?? initError}
|
|
</div>
|
|
)}
|
|
{!ready && !error && (
|
|
<div className="absolute inset-0 flex items-center justify-center text-xs text-gray-500 bg-white/80 pointer-events-none">
|
|
네이버 지도 로딩 중…
|
|
</div>
|
|
)}
|
|
{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 z-10"
|
|
>
|
|
<Icon name="my-location" size={22} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function escapeHtml(s: string): string {
|
|
return s.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch] as string));
|
|
}
|