Compare commits

..

7 Commits

Author SHA1 Message Date
joungmin
1164139312 feat(map): 식당 선택 시 지도 자동 줌인/이동
- 리스트/검색결과에서 식당 클릭 → setRegionFlyTo로 그 좌표 + zoom 16
- NaverMap/GoogleMap dispatcher 둘 다 panTo + setZoom
- 마커 selected 강조(1.15× 파란 박스)와 함께 동작

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 17:29:28 +09:00
joungmin
78f7e83a0e feat(map): NaverMapView 마커에 식당명 박스 (GoogleMap 동일)
- 단순 동그라미 → 박스+화살표+식당명+cuisine 아이콘 핀
- 채널별 bg/text/border/arrow 색상
- 폐업(CLOSED_*) 회색 + 취소선, selected 강조 (1.15× + 파란박스)
- InfoWindow 제거 (식당명이 박스로 보임)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 17:13:37 +09:00
joungmin
247547c516 feat(map): NaverMapView 채널별 마커 색상
- GoogleMapView와 동일 팔레트 (8 색)
- 식당 첫 채널 기준 색상, activeChannel 있으면 우선
- getChannelColorMap 재사용 패턴 동일

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 17:09:02 +09:00
joungmin
8de8696424 perf(map): NaverMapView SDK 네이티브 마커 + InfoWindow
- React absolute div overlay → naver.maps.Marker 네이티브 교체
- 줌/팬 시 SDK GPU 최적화로 랙 해소
- 식당명 InfoWindow (마커 클릭 시 표시)
- bounds_changed → idle 이벤트로 sync 빈도 감소
- 클러스터도 네이티브 마커 (HTML 콘텐츠 숫자)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 16:58:03 +09:00
joungmin
a4de9ba87b feat(admin): #356 후속 — verifyAllAsync로 즉시 응답
- 1244 링크 백필 동기 호출 시 HTTP 25~30분 hang (ingress 5분 timeout 초과)
- @Async verifyAllAsync 추가, controller는 started/pending 즉시 응답
- 진행은 /api/admin/video-relevance/pending 폴링으로 확인

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 15:15:48 +09:00
joungmin
cf37e496d4 docs(design): #363 실 운영 fix 기록 (1~2단계 + v0.1.57 안정화)
- 배포 흐름 v0.1.51~v0.1.57 정리
- 운영 진단 확인사항 (NCLOUD 도메인 형식, Search/Maps 별개 시스템)
- NaverMapView 안정화 핵심 결정사항 (divRef 항상 마운트, ResizeObserver, try/catch)
- 후속 분리 항목 명시

Refs: #363

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:56:51 +09:00
joungmin
ce3e34938c fix(map): NaverMapView 안정화 — divRef 항상 마운트 + ResizeObserver
- 이전: ready 가드로 첫 렌더 시 ref 누락 가능 → SDK가 div 못 잡음
- 명시적 width/height + 회색 배경(시각적 로딩 표시)
- ResizeObserver + rAF로 컨테이너 0×0 → 정상 크기 시 m.refresh
- try/catch + initError로 init 실패 가시화

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:38:57 +09:00
6 changed files with 296 additions and 103 deletions

View File

@@ -6,6 +6,35 @@
## 2026-06-16
### 🎯 식당 선택 시 지도 자동 줌인/이동 (v0.1.62)
- 리스트 / 검색결과에서 식당 클릭 → setRegionFlyTo로 그 식당 좌표 + zoom 16
- 지도가 선택 식당으로 panTo + zoom — NaverMap/GoogleMap 둘 다
- 마커의 selected 강조(1.15× + 파란 박스)와 함께 동작
### 🏷️ 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 + 회색 배경(시각적 로딩 표시)
- ResizeObserver + requestAnimationFrame으로 컨테이너 0×0 → 정상 크기 시 refresh
- try/catch + initError state로 init 실패 가시화
- Naver 키 재활성
### ⏪ NaverMap 임시 비활성, 한국도 GoogleMap fallback (v0.1.55)
- NaverMapView 골격이 실 운영에서 지도/마커 렌더 실패 (정확한 원인 추후 진단)
- NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 빈 값으로 dispatcher가 GoogleMap fallback (회귀 0)

View File

@@ -40,9 +40,11 @@ public class AdminVideoRelevanceController {
@PostMapping("/all")
public Map<String, Object> verifyAll(@RequestParam(defaultValue = "10") int batchSize) {
var admin = AuthUtil.requireAdmin();
log.info("[ADMIN] {} triggered video-relevance verifyAll(batchSize={})", admin.getSubject(), batchSize);
int processed = relevanceService.verifyAll(batchSize);
return Map.of("processed", processed);
int pending = restaurantService.countUnevaluatedLinks();
log.info("[ADMIN] {} triggered video-relevance verifyAllAsync (batchSize={}, pending={})", admin.getSubject(), batchSize, pending);
// 비동기 트리거 — HTTP request는 즉시 응답. 진행은 /pending 폴링으로 확인.
relevanceService.verifyAllAsync(batchSize);
return Map.of("started", true, "pending", pending);
}
@PostMapping("/{linkId}/evaluate")

View File

@@ -63,6 +63,16 @@ public class VideoRelevanceService {
restaurantService.updateLinkRelevance(linkId, result.relevance(), truncate(result.reason(), 120));
}
@Async
public void verifyAllAsync(int batchSize) {
try {
int n = verifyAll(batchSize);
log.info("[VideoRelevance] backfill done: {} processed", n);
} catch (Exception e) {
log.warn("verifyAllAsync failed: {}", e.getMessage());
}
}
public int verifyAll(int batchSize) {
int total = 0;
List<Map<String, Object>> batch;

View File

@@ -82,3 +82,31 @@ MapView (dispatcher)
- 한 화면 mixed(국가 경계 근처) 동시 마커 — 후속.
- 사용자 토글 UI — 후속.
- 모바일 nearby 동일 분기 — 1차 적용 후 결정.
## 11. 실제 구현 기록 (2026-06-16)
### 배포 흐름
| 버전 | 내용 |
|---|---|
| v0.1.51 | **1단계** — 식당 상세 외부 링크 좌표 기반 분기 (`RestaurantDetail.tsx`) |
| v0.1.52 | **2단계** — MapView dispatcher + NaverMapView/GoogleMapView 분리 + Dockerfile/deploy.sh build-arg |
| v0.1.53 | **fix**: 인증 파라미터 `ncpClientId``ncpKeyId` (NCLOUD 신 정책, 옛 NAVER Developers와 다름) |
| v0.1.5556 | 임시 fallback (운영 일시 GoogleMap, 디버그) |
| v0.1.57 | **안정화 + 재활성** — divRef 첫 렌더 누락 fix, ResizeObserver/rAF, try/catch |
### 운영 진단에서 확인된 사항
- NCLOUD Maps Application의 Web 서비스 URL은 **스킴 포함**(`https://...`).
- 옛 NAVER Developers와 NCLOUD는 다른 시스템 — Search Application과 Maps Application은 도메인 중복 충돌 없음.
- NCLOUD 콘솔의 신규 경로: `Services > Application Services > Maps > Application`.
### NaverMapView 안정화 핵심 결정사항
- **`divRef` 항상 마운트** (early return 제거) — `ready=false` 동안에도 div를 두고 로딩 메시지는 overlay로 표시.
- **명시적 `width:100%; height:100%`** + 회색 배경 — 컨테이너 영역이 시각적으로 확인 가능.
- **ResizeObserver + requestAnimationFrame**으로 컨테이너 0×0 → 정상 크기 변경 시 `m.refresh(true)`.
- **try/catch + `initError` state** — init 실패 시 화면 가시화.
### 후속 (별도 PR)
- 사용자 토글 (네이버/구글 강제 선택) UI.
- mixed 화면(국경 근처) 동시 마커.
- 모바일 nearby 탭 동일 분기 검토.
- 채널 색상/InfoWindow 등 GoogleMapView 수준의 디테일을 NaverMapView에 도입.

View File

@@ -385,6 +385,10 @@ export default function Home() {
const handleSelectRestaurant = useCallback((r: Restaurant) => {
setSelected(r);
setShowDetail(true);
// 지도가 선택 식당으로 이동/줌인 — 객체 새로 만들어 flyTo effect 매번 트리거
if (r.latitude != null && r.longitude != null) {
setRegionFlyTo({ lat: r.latitude, lng: r.longitude, zoom: 16 });
}
}, []);
const handleCloseDetail = useCallback(() => {

View File

@@ -3,48 +3,78 @@
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";
import { getCuisineIcon } from "@/lib/cuisine-icons";
import type { MapBounds, MapViewProps } from "@/components/MapView.types";
// ---- naver maps v3 타입 최소 정의 (full 타입은 @types/navermaps 없음) ----
declare global {
interface Window {
naver?: {
maps: NaverMaps;
};
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 };
Position?: unknown;
MapTypeControlStyle?: unknown;
ZoomControlStyle?: 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; 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 } };
getBounds: () => { getNE: () => LatLng; getSW: () => LatLng };
panTo: (latlng: unknown, opts?: Record<string, unknown>) => void;
destroy?: () => 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 (!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) {
@@ -52,7 +82,6 @@ function useNaverMaps(): { ready: boolean; error: string | null } {
return;
}
const s = document.createElement("script");
// NCLOUD 신 정책: 파라미터는 ncpKeyId (옛 ncpClientId는 NAVER Developers용).
s.src = `https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=${NAVER_CLIENT_ID}`;
s.async = true;
s.dataset.naverMaps = "1";
@@ -97,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,
@@ -104,17 +162,23 @@ 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) 지도 인스턴스 1회 생성
// 지도 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;
@@ -128,20 +192,46 @@ 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,
});
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();
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);
setInitError(msg);
}
}, [ready, flyTo, selected, onBoundsChanged]);
// 2) flyTo 변경 반영
// flyTo 변경 반영
useEffect(() => {
const m = mapRef.current;
if (!m || !flyTo || !window.naver?.maps) return;
@@ -149,74 +239,100 @@ 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]);
// 3) 좌표 → 화면 픽셀 변환 (네이버 projection)
const toScreen = useCallback((lat: number, lng: number) => {
// 마커를 SDK 네이티브로 그림 — clusters 바뀌면 기존 마커 모두 제거 후 새로 생성
useEffect(() => {
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;
}, []);
const naver = window.naver?.maps;
if (!m || !naver) return;
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>;
// 기존 마커 제거
for (const mk of markersRef.current) mk.setMap(null);
markersRef.current = [];
return (
<div className="relative w-full h-full">
<div ref={divRef} className="absolute inset-0" />
{/* 마커 overlay (좌표→픽셀 변환 + absolute positioned div) */}
{clusters.map((feature) => {
for (const feature of clusters) {
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 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);
const m = mapRef.current;
if (!m || !window.naver?.maps) return;
m.panTo(new window.naver.maps.LatLng(lat, lng));
m.panTo(new naver.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>
);
}
});
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 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>
);
})}
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"
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>
@@ -224,3 +340,7 @@ export default function NaverMapView({
</div>
);
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch] as string));
}