|
|
|
|
@@ -4,6 +4,7 @@ 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 {
|
|
|
|
|
@@ -44,6 +45,30 @@ type NaverInfoWindow = {
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
@@ -101,9 +126,29 @@ function getClusterSize(count: number): number {
|
|
|
|
|
return 54;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SVG data URL — 단일 마커(주황 핀)
|
|
|
|
|
function markerIconHtml(): string {
|
|
|
|
|
return `<div style="width:28px;height:28px;border-radius:9999px;background:#f59e0b;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,.25);"></div>`;
|
|
|
|
|
// 단일 마커 — 식당명 박스 + 화살표 핀 (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 {
|
|
|
|
|
@@ -117,7 +162,9 @@ 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);
|
|
|
|
|
@@ -230,29 +277,30 @@ export default function NaverMapView({
|
|
|
|
|
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(),
|
|
|
|
|
anchor: new naver.Point(14, 14),
|
|
|
|
|
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", () => {
|
|
|
|
|
const iw = infoWindowRef.current;
|
|
|
|
|
if (iw && m) {
|
|
|
|
|
iw.setContent(
|
|
|
|
|
`<div style="background:#fff;border-radius:8px;padding:8px 12px;box-shadow:0 4px 12px rgba(0,0,0,.18);font-size:13px;font-weight:600;color:#111;white-space:nowrap;">${escapeHtml(r.name)}</div>`
|
|
|
|
|
);
|
|
|
|
|
iw.open(m, marker);
|
|
|
|
|
}
|
|
|
|
|
onSelectRestaurant?.(r);
|
|
|
|
|
});
|
|
|
|
|
markersRef.current.push(marker);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [clusters, getExpansionZoom, onSelectRestaurant]);
|
|
|
|
|
}, [clusters, getExpansionZoom, onSelectRestaurant, channelColors, activeChannel, selected]);
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 unmount 시 마커 정리
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|