Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78f7e83a0e | ||
|
|
247547c516 | ||
|
|
8de8696424 |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -6,6 +6,23 @@
|
||||
|
||||
## 2026-06-16
|
||||
|
||||
### 🏷️ NaverMapView 마커에 식당명 박스 (v0.1.61)
|
||||
- 단순 동그라미 → GoogleMapView와 동일 핀 디자인(박스+화살표+식당명+cuisine 아이콘)
|
||||
- 채널별 배경/테두리/화살표 색상, 폐업(business_status CLOSED_*) 표시 회색 + 취소선
|
||||
- selected 식당 강조 (1.15× scale + 파란 박스), zIndex 1000
|
||||
- InfoWindow 제거 (식당명 자체가 박스로 보이므로 불필요)
|
||||
|
||||
### 🎨 NaverMapView 채널별 마커 색상 (v0.1.60)
|
||||
- GoogleMapView와 동일 팔레트 (amber/blue/green/pink/purple/red/teal/yellow)
|
||||
- 식당의 첫 채널 기준 색상, activeChannel 있으면 그 채널 우선
|
||||
|
||||
### ⚡ NaverMapView SDK 네이티브 마커 + InfoWindow (v0.1.59)
|
||||
- 마커를 React `absolute div` overlay → `naver.maps.Marker` 네이티브로 교체
|
||||
- 줌/팬 시 SDK가 GPU 최적화, 매 frame React 리렌더링 없음 → 랙 해소
|
||||
- 식당명 InfoWindow 추가 (마커 클릭 시 표시)
|
||||
- bounds_changed → idle 이벤트로 sync (줌/팬 중 발화 빈도 ↓)
|
||||
- 클러스터도 네이티브 마커 (HTML 콘텐츠로 숫자 표시)
|
||||
|
||||
### 🗺️ NaverMapView 안정화 + 재활성 (v0.1.57)
|
||||
- divRef 항상 마운트 (이전: ready 가드로 첫 렌더 ref 누락 가능)
|
||||
- 명시적 width/height + 회색 배경(시각적 로딩 표시)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
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 { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||
import type { MapBounds, MapViewProps } from "@/components/MapView.types";
|
||||
|
||||
declare global {
|
||||
@@ -12,26 +12,63 @@ declare global {
|
||||
naver?: { maps: NaverMaps };
|
||||
}
|
||||
}
|
||||
type LatLng = { lat: () => number; lng: () => number };
|
||||
type NaverMaps = {
|
||||
LatLng: new (lat: number, lng: number) => unknown;
|
||||
LatLng: new (lat: number, lng: number) => LatLng;
|
||||
Map: new (el: HTMLElement, opts: Record<string, unknown>) => NaverMapInstance;
|
||||
Event: { addListener: (target: unknown, type: string, fn: (...args: unknown[]) => void) => unknown };
|
||||
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;
|
||||
getCenter: () => { lat: () => number; lng: () => number };
|
||||
getZoom: () => number;
|
||||
getBounds: () => { getNE: () => { lat: () => number; lng: () => number }; getSW: () => { lat: () => number; lng: () => number } };
|
||||
getProjection: () => { fromCoordToOffset: (latlng: unknown) => { x: number; y: number } };
|
||||
getBounds: () => { getNE: () => LatLng; getSW: () => LatLng };
|
||||
panTo: (latlng: unknown, opts?: Record<string, unknown>) => void;
|
||||
refresh: (noEffect?: boolean) => void;
|
||||
setSize?: (size: unknown) => 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);
|
||||
@@ -89,6 +126,35 @@ function getClusterSize(count: number): number {
|
||||
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,
|
||||
@@ -96,16 +162,20 @@ export default function NaverMapView({
|
||||
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회 생성 — divRef 항상 마운트되므로 ready 시점에 안정적으로 잡힘
|
||||
// 지도 1회 생성
|
||||
useEffect(() => {
|
||||
if (!ready || !divRef.current || mapRef.current) return;
|
||||
try {
|
||||
@@ -122,13 +192,20 @@ export default function NaverMapView({
|
||||
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,
|
||||
});
|
||||
|
||||
// 컨테이너 크기 변경(예: flex layout 안에서 첫 마운트 시 0×0 → 실제 크기) → refresh
|
||||
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();
|
||||
@@ -141,13 +218,12 @@ export default function NaverMapView({
|
||||
console.warn("[NaverMap] sync failed", e);
|
||||
}
|
||||
};
|
||||
// 컨테이너 크기가 정해진 다음 sync (rAF로 다음 프레임)
|
||||
requestAnimationFrame(() => {
|
||||
try { m.refresh(true); } catch { /* noop */ }
|
||||
sync();
|
||||
});
|
||||
n.Event.addListener(m, "bounds_changed", sync);
|
||||
n.Event.addListener(m, "zoom_changed", 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);
|
||||
@@ -163,34 +239,88 @@ export default function NaverMapView({
|
||||
if (flyTo.zoom) m.setZoom(flyTo.zoom, true);
|
||||
}, [flyTo]);
|
||||
|
||||
// 클러스터 계산 (bounds/zoom 변경 시)
|
||||
const clusters = useMemo(() => {
|
||||
if (!bounds) return [];
|
||||
return getClusters(bounds, zoom);
|
||||
}, [bounds, zoom, getClusters]);
|
||||
|
||||
const toScreen = useCallback((lat: number, lng: number) => {
|
||||
// 마커를 SDK 네이티브로 그림 — clusters 바뀌면 기존 마커 모두 제거 후 새로 생성
|
||||
useEffect(() => {
|
||||
const m = mapRef.current;
|
||||
if (!m || !window.naver?.maps) return null;
|
||||
try {
|
||||
return m.getProjection().fromCoordToOffset(new window.naver.maps.LatLng(lat, lng));
|
||||
} catch {
|
||||
return null;
|
||||
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.panTo(new naver.LatLng(lat, lng));
|
||||
m.setZoom(z, true);
|
||||
});
|
||||
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" }}
|
||||
/>
|
||||
|
||||
{/* 로딩/에러 overlay */}
|
||||
{(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} — 새로고침 또는 GoogleMap로 fallback
|
||||
{error ?? initError}
|
||||
</div>
|
||||
)}
|
||||
{!ready && !error && (
|
||||
@@ -198,54 +328,11 @@ export default function NaverMapView({
|
||||
네이버 지도 로딩 중…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 마커 overlay */}
|
||||
{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"
|
||||
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>
|
||||
@@ -253,3 +340,7 @@ export default function NaverMapView({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch] as string));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user