Compare commits
5 Commits
bd8d82dd5d
...
v0.1.59
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8de8696424 | ||
|
|
a4de9ba87b | ||
|
|
cf37e496d4 | ||
|
|
ce3e34938c | ||
|
|
5199475d67 |
19
CHANGELOG.md
19
CHANGELOG.md
@@ -6,6 +6,25 @@
|
|||||||
|
|
||||||
## 2026-06-16
|
## 2026-06-16
|
||||||
|
|
||||||
|
### ⚡ 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)
|
||||||
|
- NaverMapView 코드는 유지 — 안정화 후 환경변수 채우면 재활성
|
||||||
|
|
||||||
### 🐛 /api/stats/visits 500 — StatsMapper resultType int → long (v0.1.54)
|
### 🐛 /api/stats/visits 500 — StatsMapper resultType int → long (v0.1.54)
|
||||||
- StatsMapper interface는 `long` 반환인데 XML resultType이 `int` → Integer를 long에 cast 실패
|
- StatsMapper interface는 `long` 반환인데 XML resultType이 `int` → Integer를 long에 cast 실패
|
||||||
- ClassCastException: Integer → Long. resultType만 long으로 교정
|
- ClassCastException: Integer → Long. resultType만 long으로 교정
|
||||||
|
|||||||
@@ -40,9 +40,11 @@ public class AdminVideoRelevanceController {
|
|||||||
@PostMapping("/all")
|
@PostMapping("/all")
|
||||||
public Map<String, Object> verifyAll(@RequestParam(defaultValue = "10") int batchSize) {
|
public Map<String, Object> verifyAll(@RequestParam(defaultValue = "10") int batchSize) {
|
||||||
var admin = AuthUtil.requireAdmin();
|
var admin = AuthUtil.requireAdmin();
|
||||||
log.info("[ADMIN] {} triggered video-relevance verifyAll(batchSize={})", admin.getSubject(), batchSize);
|
int pending = restaurantService.countUnevaluatedLinks();
|
||||||
int processed = relevanceService.verifyAll(batchSize);
|
log.info("[ADMIN] {} triggered video-relevance verifyAllAsync (batchSize={}, pending={})", admin.getSubject(), batchSize, pending);
|
||||||
return Map.of("processed", processed);
|
// 비동기 트리거 — HTTP request는 즉시 응답. 진행은 /pending 폴링으로 확인.
|
||||||
|
relevanceService.verifyAllAsync(batchSize);
|
||||||
|
return Map.of("started", true, "pending", pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{linkId}/evaluate")
|
@PostMapping("/{linkId}/evaluate")
|
||||||
|
|||||||
@@ -63,6 +63,16 @@ public class VideoRelevanceService {
|
|||||||
restaurantService.updateLinkRelevance(linkId, result.relevance(), truncate(result.reason(), 120));
|
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) {
|
public int verifyAll(int batchSize) {
|
||||||
int total = 0;
|
int total = 0;
|
||||||
List<Map<String, Object>> batch;
|
List<Map<String, Object>> batch;
|
||||||
|
|||||||
@@ -82,3 +82,31 @@ MapView (dispatcher)
|
|||||||
- 한 화면 mixed(국가 경계 근처) 동시 마커 — 후속.
|
- 한 화면 mixed(국가 경계 근처) 동시 마커 — 후속.
|
||||||
- 사용자 토글 UI — 후속.
|
- 사용자 토글 UI — 후속.
|
||||||
- 모바일 nearby 동일 분기 — 1차 적용 후 결정.
|
- 모바일 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.55–56 | 임시 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에 도입.
|
||||||
|
|||||||
@@ -3,35 +3,43 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Supercluster from "supercluster";
|
import Supercluster from "supercluster";
|
||||||
import type { Restaurant } from "@/lib/api";
|
import type { Restaurant } from "@/lib/api";
|
||||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
import type { MapBounds, FlyTo, MapViewProps } from "@/components/MapView.types";
|
import type { MapBounds, MapViewProps } from "@/components/MapView.types";
|
||||||
|
|
||||||
// ---- naver maps v3 타입 최소 정의 (full 타입은 @types/navermaps 없음) ----
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
naver?: {
|
naver?: { maps: NaverMaps };
|
||||||
maps: NaverMaps;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
type LatLng = { lat: () => number; lng: () => number };
|
||||||
type NaverMaps = {
|
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;
|
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;
|
||||||
Position?: unknown;
|
InfoWindow: new (opts: Record<string, unknown>) => NaverInfoWindow;
|
||||||
MapTypeControlStyle?: unknown;
|
Event: { addListener: (target: unknown, type: string, fn: (...args: unknown[]) => void) => unknown; removeListener: (handler: unknown) => void };
|
||||||
ZoomControlStyle?: unknown;
|
Size: new (w: number, h: number) => unknown;
|
||||||
|
Point: new (x: number, y: number) => unknown;
|
||||||
};
|
};
|
||||||
type NaverMapInstance = {
|
type NaverMapInstance = {
|
||||||
setCenter: (latlng: unknown) => void;
|
setCenter: (latlng: unknown) => void;
|
||||||
setZoom: (zoom: number, useEffect?: boolean) => void;
|
setZoom: (zoom: number, useEffect?: boolean) => void;
|
||||||
getCenter: () => { lat: () => number; lng: () => number; x: number; y: number };
|
|
||||||
getZoom: () => number;
|
getZoom: () => number;
|
||||||
getBounds: () => { getNE: () => { lat: () => number; lng: () => number }; getSW: () => { lat: () => number; lng: () => number } };
|
getBounds: () => { getNE: () => LatLng; getSW: () => LatLng };
|
||||||
getProjection: () => { fromCoordToOffset: (latlng: unknown) => { x: number; y: number } };
|
|
||||||
panTo: (latlng: unknown, opts?: Record<string, unknown>) => void;
|
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 || "";
|
const NAVER_CLIENT_ID = process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || "";
|
||||||
@@ -41,10 +49,7 @@ function useNaverMaps(): { ready: boolean; error: string | null } {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!NAVER_CLIENT_ID) {
|
if (!NAVER_CLIENT_ID) { setError("NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 미설정"); return; }
|
||||||
setError("NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 미설정");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (window.naver?.maps) { setReady(true); return; }
|
if (window.naver?.maps) { setReady(true); return; }
|
||||||
const existing = document.querySelector<HTMLScriptElement>(`script[data-naver-maps]`);
|
const existing = document.querySelector<HTMLScriptElement>(`script[data-naver-maps]`);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -52,7 +57,6 @@ function useNaverMaps(): { ready: boolean; error: string | null } {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const s = document.createElement("script");
|
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.src = `https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=${NAVER_CLIENT_ID}`;
|
||||||
s.async = true;
|
s.async = true;
|
||||||
s.dataset.naverMaps = "1";
|
s.dataset.naverMaps = "1";
|
||||||
@@ -97,6 +101,15 @@ function getClusterSize(count: number): number {
|
|||||||
return 54;
|
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>`;
|
||||||
|
}
|
||||||
|
// 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({
|
export default function NaverMapView({
|
||||||
restaurants,
|
restaurants,
|
||||||
selected,
|
selected,
|
||||||
@@ -108,13 +121,17 @@ export default function NaverMapView({
|
|||||||
const { ready, error } = useNaverMaps();
|
const { ready, error } = useNaverMaps();
|
||||||
const divRef = useRef<HTMLDivElement | null>(null);
|
const divRef = useRef<HTMLDivElement | null>(null);
|
||||||
const mapRef = useRef<NaverMapInstance | 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 [bounds, setBounds] = useState<MapBounds | null>(null);
|
||||||
const [zoom, setZoom] = useState(13);
|
const [zoom, setZoom] = useState(13);
|
||||||
|
const [initError, setInitError] = useState<string | null>(null);
|
||||||
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
|
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
|
||||||
|
|
||||||
// 1) 지도 인스턴스 1회 생성
|
// 지도 1회 생성
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ready || !divRef.current || mapRef.current) return;
|
if (!ready || !divRef.current || mapRef.current) return;
|
||||||
|
try {
|
||||||
const n = window.naver!.maps;
|
const n = window.naver!.maps;
|
||||||
const initLat = flyTo?.lat ?? selected?.latitude ?? 37.5665;
|
const initLat = flyTo?.lat ?? selected?.latitude ?? 37.5665;
|
||||||
const initLng = flyTo?.lng ?? selected?.longitude ?? 126.978;
|
const initLng = flyTo?.lng ?? selected?.longitude ?? 126.978;
|
||||||
@@ -128,20 +145,46 @@ export default function NaverMapView({
|
|||||||
zoomControl: false,
|
zoomControl: false,
|
||||||
});
|
});
|
||||||
mapRef.current = m;
|
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 = () => {
|
const sync = () => {
|
||||||
|
try {
|
||||||
const b = m.getBounds();
|
const b = m.getBounds();
|
||||||
const ne = b.getNE(), sw = b.getSW();
|
const ne = b.getNE(), sw = b.getSW();
|
||||||
const nb: MapBounds = { north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() };
|
const nb: MapBounds = { north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() };
|
||||||
setBounds(nb);
|
setBounds(nb);
|
||||||
setZoom(m.getZoom());
|
setZoom(m.getZoom());
|
||||||
onBoundsChanged?.(nb);
|
onBoundsChanged?.(nb);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[NaverMap] sync failed", e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try { m.refresh(true); } catch { /* noop */ }
|
||||||
sync();
|
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]);
|
}, [ready, flyTo, selected, onBoundsChanged]);
|
||||||
|
|
||||||
// 2) flyTo 변경 반영
|
// flyTo 변경 반영
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const m = mapRef.current;
|
const m = mapRef.current;
|
||||||
if (!m || !flyTo || !window.naver?.maps) return;
|
if (!m || !flyTo || !window.naver?.maps) return;
|
||||||
@@ -149,74 +192,99 @@ export default function NaverMapView({
|
|||||||
if (flyTo.zoom) m.setZoom(flyTo.zoom, true);
|
if (flyTo.zoom) m.setZoom(flyTo.zoom, true);
|
||||||
}, [flyTo]);
|
}, [flyTo]);
|
||||||
|
|
||||||
|
// 클러스터 계산 (bounds/zoom 변경 시)
|
||||||
const clusters = useMemo(() => {
|
const clusters = useMemo(() => {
|
||||||
if (!bounds) return [];
|
if (!bounds) return [];
|
||||||
return getClusters(bounds, zoom);
|
return getClusters(bounds, zoom);
|
||||||
}, [bounds, zoom, getClusters]);
|
}, [bounds, zoom, getClusters]);
|
||||||
|
|
||||||
// 3) 좌표 → 화면 픽셀 변환 (네이버 projection)
|
// 마커를 SDK 네이티브로 그림 — clusters 바뀌면 기존 마커 모두 제거 후 새로 생성
|
||||||
const toScreen = useCallback((lat: number, lng: number) => {
|
useEffect(() => {
|
||||||
const m = mapRef.current;
|
const m = mapRef.current;
|
||||||
if (!m || !window.naver?.maps) return null;
|
const naver = window.naver?.maps;
|
||||||
const p = m.getProjection().fromCoordToOffset(new window.naver.maps.LatLng(lat, lng));
|
if (!m || !naver) return;
|
||||||
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>;
|
for (const mk of markersRef.current) mk.setMap(null);
|
||||||
|
markersRef.current = [];
|
||||||
|
|
||||||
return (
|
for (const feature of clusters) {
|
||||||
<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 [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;
|
const isCluster = feature.properties && "cluster" in feature.properties && feature.properties.cluster;
|
||||||
if (isCluster) {
|
if (isCluster) {
|
||||||
const { cluster_id, point_count } = feature.properties as Supercluster.ClusterProperties;
|
const { cluster_id, point_count } = feature.properties as Supercluster.ClusterProperties;
|
||||||
const size = getClusterSize(point_count);
|
const size = getClusterSize(point_count);
|
||||||
return (
|
const marker = new naver.Marker({
|
||||||
<button
|
position: new naver.LatLng(lat, lng),
|
||||||
key={`c-${cluster_id}`}
|
map: m,
|
||||||
onClick={() => {
|
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 z = Math.min(getExpansionZoom(cluster_id), 18);
|
||||||
const m = mapRef.current;
|
m.panTo(new naver.LatLng(lat, lng));
|
||||||
if (!m || !window.naver?.maps) return;
|
|
||||||
m.panTo(new window.naver.maps.LatLng(lat, lng));
|
|
||||||
m.setZoom(z, true);
|
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"
|
markersRef.current.push(marker);
|
||||||
style={{ left: pt.x, top: pt.y, width: size, height: size, fontSize: size > 44 ? 14 : 12 }}
|
} else {
|
||||||
>
|
|
||||||
{point_count}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const r = (feature.properties as RestaurantProps).restaurant;
|
const r = (feature.properties as RestaurantProps).restaurant;
|
||||||
const cuisineIcon = getCuisineIcon(r.cuisine_type);
|
const marker = new naver.Marker({
|
||||||
const isSel = selected?.id === r.id;
|
position: new naver.LatLng(lat, lng),
|
||||||
return (
|
map: m,
|
||||||
<button
|
title: r.name,
|
||||||
key={r.id}
|
icon: {
|
||||||
onClick={() => onSelectRestaurant?.(r)}
|
content: markerIconHtml(),
|
||||||
className={`absolute -translate-x-1/2 -translate-y-full rounded-full shadow-md ring-2 ring-white transition-transform ${isSel ? "scale-125 z-10" : ""}`}
|
anchor: new naver.Point(14, 14),
|
||||||
style={{ left: pt.x, top: pt.y, width: 32, height: 32, background: "#f59e0b", color: "#78350f" }}
|
},
|
||||||
title={r.name}
|
});
|
||||||
>
|
naver.Event.addListener(marker, "click", () => {
|
||||||
<Icon name={cuisineIcon} size={18} />
|
const iw = infoWindowRef.current;
|
||||||
</button>
|
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]);
|
||||||
|
|
||||||
{/* 내 위치 버튼 */}
|
// 컴포넌트 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 && (
|
{onMyLocation && (
|
||||||
<button
|
<button
|
||||||
onClick={onMyLocation}
|
onClick={onMyLocation}
|
||||||
aria-label="내 위치"
|
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} />
|
<Icon name="my-location" size={22} />
|
||||||
</button>
|
</button>
|
||||||
@@ -224,3 +292,7 @@ export default function NaverMapView({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch] as string));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user