Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
247547c516 | ||
|
|
8de8696424 | ||
|
|
a4de9ba87b | ||
|
|
cf37e496d4 | ||
|
|
ce3e34938c | ||
|
|
5199475d67 | ||
|
|
bd8d82dd5d | ||
|
|
bc83923261 | ||
|
|
f17ba9e37a | ||
|
|
7789671fbc | ||
|
|
c5b0216a37 | ||
|
|
40e448fe95 |
62
CHANGELOG.md
62
CHANGELOG.md
@@ -4,8 +4,70 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-16
|
||||||
|
|
||||||
|
### 🎨 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)
|
||||||
|
- NaverMapView 코드는 유지 — 안정화 후 환경변수 채우면 재활성
|
||||||
|
|
||||||
|
### 🐛 /api/stats/visits 500 — StatsMapper resultType int → long (v0.1.54)
|
||||||
|
- StatsMapper interface는 `long` 반환인데 XML resultType이 `int` → Integer를 long에 cast 실패
|
||||||
|
- ClassCastException: Integer → Long. resultType만 long으로 교정
|
||||||
|
|
||||||
|
### 🐛 NaverMap 인증 파라미터 ncpClientId → ncpKeyId (v0.1.53)
|
||||||
|
- NCLOUD 신 정책: `ncpKeyId` 사용 (옛 `ncpClientId`는 NAVER Developers용)
|
||||||
|
- 인증 200/Failed의 진짜 원인 — 도메인 등록은 정확했으나 파라미터 이름 차이로 키 인식 실패
|
||||||
|
- 새 NCLOUD Maps Client ID(`fg01bipxbo`)로 prod 재빌드
|
||||||
|
- 참고: https://github.com/navermaps/maps.js.ncp/blob/master/index.html
|
||||||
|
|
||||||
|
### 🗺️ #363 메인 지도 SDK 국내(네이버)/해외(구글) 분기 (v0.1.52)
|
||||||
|
- MapView를 dispatcher로 전환: 좌표가 KR bbox + NAVER_MAP_CLIENT_ID 설정 시 NaverMapView, 그 외 GoogleMapView
|
||||||
|
- NaverMapView 신규 (네이버 v3 직접 wrapper, Supercluster 재사용, 마커/클러스터/flyTo)
|
||||||
|
- GoogleMapView 신규 (기존 MapView 내용 rename)
|
||||||
|
- MapView.types.ts 공용 (MapBounds/FlyTo/MapViewProps + isKoreaCoord)
|
||||||
|
- Dockerfile + deploy.sh: NEXT_PUBLIC_NAVER_MAP_CLIENT_ID build-arg 추가
|
||||||
|
- 키 미설정 시 GoogleMap fallback (회귀 0)
|
||||||
|
- 설계서: docs/design/363-map-sdk-branch/README.md
|
||||||
|
- Refs: #363
|
||||||
|
|
||||||
|
### 🗺️ 식당 상세 지도 링크 국내/해외 분기 (v0.1.51)
|
||||||
|
- 좌표 기반 한국 판정 (WGS84 KR bbox 33~38.7°N, 124~132°E)
|
||||||
|
- 국내: 네이버 지도 primary + Google Maps 보조 (네이버 URL은 신 도메인 /p/search/)
|
||||||
|
- 해외: Google Maps 단독
|
||||||
|
- 좌표 없으면 region 첫 토큰 fallback (구 데이터 호환)
|
||||||
|
- frontend-only 배포
|
||||||
|
|
||||||
## 2026-06-15
|
## 2026-06-15
|
||||||
|
|
||||||
|
### 🐛 캐치테이블 URL 패턴 수정 (v0.1.50)
|
||||||
|
- 실제 catchtable URL은 `app.catchtable.co.kr/ct/shop/...` 형식 (옛 `/shop/`, `/dining/`은 매칭 실패)
|
||||||
|
- 첫 회차(v0.1.49) 캐치테이블 벌크 결과 1044건 전부 미발견(매핑 0%)의 원인
|
||||||
|
- 패턴을 `catchtable.co.kr/ct/shop/`, `catchtable.co.kr/ct/dining/`로 교정 후 NONE 해제 + 재실행
|
||||||
|
|
||||||
|
### 🐛 WebSearchService HTTP timeout 추가 (v0.1.49)
|
||||||
|
- 벌크 백필 중 특정 검색에서 무한 hang → backend executor virtual thread 점유로 후속 작업 중단 (90건 처리 후 멈춤)
|
||||||
|
- connectTimeout=5s + request timeout=15s (Naver/DDG 둘 다)
|
||||||
|
- 해당 식당은 HttpTimeoutException → notfound로 안전 처리
|
||||||
|
|
||||||
### ⏱️ bulk-tabling/catchtable SSE timeout 10분 → 3시간 (v0.1.48)
|
### ⏱️ bulk-tabling/catchtable SSE timeout 10분 → 3시간 (v0.1.48)
|
||||||
- 대량 백필(724건 ≈ 100분) 시 10분 SSE timeout으로 중간 끊김 → 3시간으로 확장
|
- 대량 백필(724건 ≈ 100분) 시 10분 SSE timeout으로 중간 끊김 → 3시간으로 확장
|
||||||
- 백엔드 작업은 virtual thread로 별도 진행됐지만 emit() 예외로 마지막 cache.flush + complete 누락이슈 해소
|
- 백엔드 작업은 virtual thread로 별도 진행됐지만 emit() 예외로 마지막 cache.flush + complete 누락이슈 해소
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -423,9 +423,10 @@ public class RestaurantController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<Map<String, Object>> searchCatchtable(String restaurantName) {
|
private List<Map<String, Object>> searchCatchtable(String restaurantName) {
|
||||||
|
// 실제 캐치테이블 URL은 /ct/shop/ 형식. 옛 /dining/ /shop/ 패턴은 매칭 실패.
|
||||||
return webSearch.search(
|
return webSearch.search(
|
||||||
"site:app.catchtable.co.kr " + restaurantName,
|
"site:app.catchtable.co.kr " + restaurantName,
|
||||||
"catchtable.co.kr/dining/", "catchtable.co.kr/shop/"
|
"catchtable.co.kr/ct/shop/", "catchtable.co.kr/ct/dining/"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import java.net.http.HttpClient;
|
|||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
@@ -30,8 +31,10 @@ public class WebSearchService {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(WebSearchService.class);
|
private static final Logger log = LoggerFactory.getLogger(WebSearchService.class);
|
||||||
private static final int MAX_RESULTS = 5;
|
private static final int MAX_RESULTS = 5;
|
||||||
|
|
||||||
|
private static final Duration REQ_TIMEOUT = Duration.ofSeconds(15);
|
||||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.connectTimeout(Duration.ofSeconds(5))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
private static final Pattern DDG_RESULT = Pattern.compile(
|
private static final Pattern DDG_RESULT = Pattern.compile(
|
||||||
@@ -74,6 +77,7 @@ public class WebSearchService {
|
|||||||
String url = "https://openapi.naver.com/v1/search/webkr.json?query=" + encoded + "&display=30";
|
String url = "https://openapi.naver.com/v1/search/webkr.json?query=" + encoded + "&display=30";
|
||||||
HttpRequest req = HttpRequest.newBuilder()
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(url))
|
.uri(URI.create(url))
|
||||||
|
.timeout(REQ_TIMEOUT)
|
||||||
.header("X-Naver-Client-Id", naverClientId)
|
.header("X-Naver-Client-Id", naverClientId)
|
||||||
.header("X-Naver-Client-Secret", naverClientSecret)
|
.header("X-Naver-Client-Secret", naverClientSecret)
|
||||||
.GET()
|
.GET()
|
||||||
@@ -104,6 +108,7 @@ public class WebSearchService {
|
|||||||
String url = "https://html.duckduckgo.com/html/?q=" + encoded;
|
String url = "https://html.duckduckgo.com/html/?q=" + encoded;
|
||||||
HttpRequest req = HttpRequest.newBuilder()
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(url))
|
.uri(URI.create(url))
|
||||||
|
.timeout(REQ_TIMEOUT)
|
||||||
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
||||||
.header("Accept", "text/html,application/xhtml+xml")
|
.header("Accept", "text/html,application/xhtml+xml")
|
||||||
.header("Accept-Language", "ko-KR,ko;q=0.9")
|
.header("Accept-Language", "ko-KR,ko;q=0.9")
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
WHEN NOT MATCHED THEN INSERT (visit_date, visit_count) VALUES (src.d, 1)
|
WHEN NOT MATCHED THEN INSERT (visit_date, visit_count) VALUES (src.d, 1)
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<select id="getTodayVisits" resultType="int">
|
<select id="getTodayVisits" resultType="long">
|
||||||
SELECT NVL(visit_count, 0)
|
SELECT NVL(visit_count, 0)
|
||||||
FROM site_visits
|
FROM site_visits
|
||||||
WHERE visit_date = TRUNC(SYSDATE)
|
WHERE visit_date = TRUNC(SYSDATE)
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="getTotalVisits" resultType="int">
|
<select id="getTotalVisits" resultType="long">
|
||||||
SELECT NVL(SUM(visit_count), 0)
|
SELECT NVL(SUM(visit_count), 0)
|
||||||
FROM site_visits
|
FROM site_visits
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -62,15 +62,18 @@ if [[ "$TARGET" == "all" || "$TARGET" == "frontend" ]]; then
|
|||||||
# Read build args from env or .env file
|
# Read build args from env or .env file
|
||||||
MAPS_KEY="${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY:-}"
|
MAPS_KEY="${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY:-}"
|
||||||
CLIENT_ID="${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}"
|
CLIENT_ID="${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}"
|
||||||
|
NAVER_MAP_ID="${NEXT_PUBLIC_NAVER_MAP_CLIENT_ID:-}"
|
||||||
|
|
||||||
if [[ -f frontend/.env.local ]]; then
|
if [[ -f frontend/.env.local ]]; then
|
||||||
MAPS_KEY="${MAPS_KEY:-$(grep NEXT_PUBLIC_GOOGLE_MAPS_API_KEY frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
MAPS_KEY="${MAPS_KEY:-$(grep NEXT_PUBLIC_GOOGLE_MAPS_API_KEY frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
||||||
CLIENT_ID="${CLIENT_ID:-$(grep NEXT_PUBLIC_GOOGLE_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
CLIENT_ID="${CLIENT_ID:-$(grep NEXT_PUBLIC_GOOGLE_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
||||||
|
NAVER_MAP_ID="${NAVER_MAP_ID:-$(grep NEXT_PUBLIC_NAVER_MAP_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker build --platform "$PLATFORM" \
|
docker build --platform "$PLATFORM" \
|
||||||
--build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="$MAPS_KEY" \
|
--build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="$MAPS_KEY" \
|
||||||
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID="$CLIENT_ID" \
|
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID="$CLIENT_ID" \
|
||||||
|
--build-arg NEXT_PUBLIC_NAVER_MAP_CLIENT_ID="$NAVER_MAP_ID" \
|
||||||
-t "$REGISTRY/frontend:$TAG" \
|
-t "$REGISTRY/frontend:$TAG" \
|
||||||
-t "$REGISTRY/frontend:latest" \
|
-t "$REGISTRY/frontend:latest" \
|
||||||
frontend/
|
frontend/
|
||||||
|
|||||||
112
docs/design/363-map-sdk-branch/README.md
Normal file
112
docs/design/363-map-sdk-branch/README.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# 설계서: 메인 지도 탭 SDK 국내/해외 분기 (#363)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-16
|
||||||
|
> **추적성** — Redmine: #363 · 부모: v0.1.51 1단계(외부 링크 분기) · 관련: MapView.tsx, mobile nearby
|
||||||
|
> · 구현 파일: `frontend/src/components/MapView.tsx`(dispatcher), `frontend/src/components/GoogleMapView.tsx`(rename from 기존 MapView 내용), `frontend/src/components/NaverMapView.tsx`(신규), `frontend/src/lib/map-utils.ts`(공용 헬퍼)
|
||||||
|
> · 테스트: 본 범위 밖 (수동 — dev 브라우저 검증)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
현재 MapView는 `@vis.gl/react-google-maps` 단일 사용. 한국 식당은 네이버 지도가 지번/도로명/상호/길찾기에서 압도적으로 정확. 메인 지도 탭 자체를 국내/해외 분기.
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
- 포함: MapView를 dispatcher로 전환, 좌표 기반 자동 분기(KR bbox), 네이버 키 미설정 시 GoogleMap fallback.
|
||||||
|
- 제외 (별도 후속): 사용자 강제 토글 UI, mixed 화면(한국+해외 동시) 최적화, 모바일 nearby도 동일 분기는 1차 적용 후 검토.
|
||||||
|
|
||||||
|
## 3. 인수조건
|
||||||
|
|
||||||
|
- [ ] `NEXT_PUBLIC_NAVER_MAP_CLIENT_ID` 환경변수 설정 + 화면 중심이 KR bbox 안이면 NaverMap 렌더.
|
||||||
|
- [ ] 키 미설정 또는 화면이 KR 밖이면 GoogleMap 렌더 (현행 동일).
|
||||||
|
- [ ] Supercluster + 클러스터/단일 마커 표시, 클릭 → onSelectRestaurant 콜백 동일.
|
||||||
|
- [ ] flyTo, onBoundsChanged, 내 위치, 채널 색상 동일하게 동작.
|
||||||
|
- [ ] 빌드/타입 회귀 없음.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- 네이버 지도 v3: `https://oapi.map.naver.com/openapi/v3/maps.js?ncpClientId=<ID>` 스크립트 로드.
|
||||||
|
- 네이버 좌표계: 기본 WGS84 (`naver.maps.LatLng(lat, lng)`).
|
||||||
|
- 직접 wrapper 채택 (react-naver-maps 의존성 제거 — 메인터넌스 리스크).
|
||||||
|
- Supercluster는 SDK 독립이라 재사용.
|
||||||
|
- KR bbox: 위도 33~38.7, 경도 124~132. 화면 중심좌표가 안에 있으면 한국.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
MapView (dispatcher)
|
||||||
|
│
|
||||||
|
├─ 화면 중심 좌표가 KR bbox AND 네이버 키 있음 → NaverMapView
|
||||||
|
│ ├─ <script src=naver maps v3> 동적 로드
|
||||||
|
│ ├─ useEffect: new naver.maps.Map(div, ...)
|
||||||
|
│ ├─ Supercluster로 cluster 계산 → markers div overlay
|
||||||
|
│ └─ flyTo: map.setCenter + setZoom
|
||||||
|
│
|
||||||
|
└─ 그 외 → GoogleMapView (기존 MapView 내용 그대로 이전)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 함수 명세
|
||||||
|
|
||||||
|
| 함수 | 책임 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| `MapView` (dispatcher) | 좌표 기반 분기 | flyTo 또는 첫 마운트 좌표로 판정 |
|
||||||
|
| `GoogleMapView` | 기존 MapView 내용 | rename만, 로직 변경 X |
|
||||||
|
| `NaverMapView` | 신규 — 네이버 지도 + Supercluster + markers | wrapper 직접 |
|
||||||
|
| `useNaverMaps(clientId)` | 스크립트 로드 + ready boolean | 한 번만 로드 |
|
||||||
|
| `isKoreaBounds(lat, lng)` | KR bbox 판정 | map-utils 공용 |
|
||||||
|
|
||||||
|
## 7. 흐름
|
||||||
|
|
||||||
|
1. MapView 마운트 → flyTo or 첫 식당 평균 좌표로 초기 중심 계산.
|
||||||
|
2. KR bbox + 키 있음 → NaverMapView 마운트.
|
||||||
|
3. NaverMapView: `useNaverMaps` 훅으로 v3 스크립트 로드, ready되면 `new naver.maps.Map(divRef, options)` 생성.
|
||||||
|
4. Supercluster로 cluster 계산 → 마커는 absolute positioned div overlay (네이버 OverlayView 또는 자체 좌표 변환).
|
||||||
|
5. 사용자 줌/팬 → bounds_changed 이벤트 → 클러스터 재계산 + onBoundsChanged 콜백.
|
||||||
|
|
||||||
|
## 8. 엣지케이스
|
||||||
|
|
||||||
|
- **네이버 스크립트 로드 실패**: ready=false 유지, dispatcher가 다음 렌더 사이클에서 GoogleMap fallback.
|
||||||
|
- **flyTo가 해외 좌표인데 현재 NaverMap 중**: dispatcher 재판정 → GoogleMap로 교체 (remount).
|
||||||
|
- **mixed 화면(한국+해외 식당)**: 화면 중심 기준 SDK 선택 → 다른 나라 식당은 화면 밖에 있어 무관.
|
||||||
|
- **키 미설정**: 항상 GoogleMap (회귀 0).
|
||||||
|
|
||||||
|
## 9. 리스크 & 대안
|
||||||
|
|
||||||
|
- **선택**: 직접 wrapper. 의존성 최소, 유지보수 자유.
|
||||||
|
- **대안 A**: `react-naver-maps` npm — 빠른 시작이지만 메인터넌스 상태 불확실.
|
||||||
|
- **대안 B**: 단일 SDK(Maplibre + 네이버 타일) — 타일 권리 이슈.
|
||||||
|
- **트레이드오프**: 직접 wrapper는 초기 코드 양 ↑이지만 한 번 만들면 안정.
|
||||||
|
|
||||||
|
## 10. 미해결 질문
|
||||||
|
|
||||||
|
- 한 화면 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.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에 도입.
|
||||||
@@ -6,6 +6,7 @@ RUN npm ci
|
|||||||
COPY . .
|
COPY . .
|
||||||
ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
||||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||||
|
ARG NEXT_PUBLIC_NAVER_MAP_CLIENT_ID
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ── Runtime stage ──
|
# ── Runtime stage ──
|
||||||
|
|||||||
395
frontend/src/components/GoogleMapView.tsx
Normal file
395
frontend/src/components/GoogleMapView.tsx
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
APIProvider,
|
||||||
|
Map,
|
||||||
|
AdvancedMarker,
|
||||||
|
InfoWindow,
|
||||||
|
useMap,
|
||||||
|
} from "@vis.gl/react-google-maps";
|
||||||
|
import Supercluster from "supercluster";
|
||||||
|
import type { Restaurant } from "@/lib/api";
|
||||||
|
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
|
|
||||||
|
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
|
||||||
|
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
|
||||||
|
|
||||||
|
// Channel color palette
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
import type { MapBounds, FlyTo, MapViewProps } from "@/components/MapView.types";
|
||||||
|
|
||||||
|
type RestaurantProps = { restaurant: Restaurant };
|
||||||
|
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
|
||||||
|
|
||||||
|
function useSupercluster(restaurants: Restaurant[]) {
|
||||||
|
// #278 — indexRef 제거 (set만 되고 read 없는 dead code)
|
||||||
|
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) => {
|
||||||
|
return index.getClusters(
|
||||||
|
[bounds.west, bounds.south, bounds.east, bounds.north],
|
||||||
|
Math.floor(zoom)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[index]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getExpansionZoom = useCallback(
|
||||||
|
(clusterId: number): number => {
|
||||||
|
try {
|
||||||
|
return index.getClusterExpansionZoom(clusterId);
|
||||||
|
} catch {
|
||||||
|
return 17;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[index]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { getClusters, getExpansionZoom, index };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClusterSize(count: number): number {
|
||||||
|
if (count < 10) return 36;
|
||||||
|
if (count < 50) return 42;
|
||||||
|
if (count < 100) return 48;
|
||||||
|
return 54;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeChannel }: Omit<MapViewProps, "onMyLocation" | "onBoundsChanged">) {
|
||||||
|
const map = useMap();
|
||||||
|
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
|
||||||
|
const [zoom, setZoom] = useState(13);
|
||||||
|
const [bounds, setBounds] = useState<MapBounds | null>(null);
|
||||||
|
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||||
|
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
|
||||||
|
|
||||||
|
// #278 — restaurantMap 제거 (빌드만 되고 렌더에서 사용 안 됨, dead code)
|
||||||
|
|
||||||
|
const clusters = useMemo(() => {
|
||||||
|
if (!bounds) return [];
|
||||||
|
return getClusters(bounds, zoom);
|
||||||
|
}, [bounds, zoom, getClusters]);
|
||||||
|
|
||||||
|
const handleMarkerClick = useCallback(
|
||||||
|
(r: Restaurant) => {
|
||||||
|
setInfoTarget(r);
|
||||||
|
onSelectRestaurant?.(r);
|
||||||
|
},
|
||||||
|
[onSelectRestaurant]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClusterClick = useCallback(
|
||||||
|
(clusterId: number, lng: number, lat: number) => {
|
||||||
|
if (!map) return;
|
||||||
|
const expansionZoom = Math.min(getExpansionZoom(clusterId), 18);
|
||||||
|
map.panTo({ lat, lng });
|
||||||
|
map.setZoom(expansionZoom);
|
||||||
|
},
|
||||||
|
[map, getExpansionZoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track camera changes for clustering
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
const listener = map.addListener("idle", () => {
|
||||||
|
const b = map.getBounds();
|
||||||
|
const z = map.getZoom();
|
||||||
|
if (b && z != null) {
|
||||||
|
const ne = b.getNorthEast();
|
||||||
|
const sw = b.getSouthWest();
|
||||||
|
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
|
||||||
|
setZoom(z);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Trigger initial bounds
|
||||||
|
const b = map.getBounds();
|
||||||
|
const z = map.getZoom();
|
||||||
|
if (b && z != null) {
|
||||||
|
const ne = b.getNorthEast();
|
||||||
|
const sw = b.getSouthWest();
|
||||||
|
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
|
||||||
|
setZoom(z);
|
||||||
|
}
|
||||||
|
return () => google.maps.event.removeListener(listener);
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
// Fly to a specific location (region filter)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || !flyTo) return;
|
||||||
|
map.panTo({ lat: flyTo.lat, lng: flyTo.lng });
|
||||||
|
if (flyTo.zoom) map.setZoom(flyTo.zoom);
|
||||||
|
}, [map, flyTo]);
|
||||||
|
|
||||||
|
// Pan and zoom to selected restaurant
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || !selected) return;
|
||||||
|
map.panTo({ lat: selected.latitude, lng: selected.longitude });
|
||||||
|
map.setZoom(16);
|
||||||
|
setInfoTarget(selected);
|
||||||
|
}, [map, selected]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{clusters.map((feature) => {
|
||||||
|
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);
|
||||||
|
return (
|
||||||
|
<AdvancedMarker
|
||||||
|
key={`cluster-${cluster_id}`}
|
||||||
|
position={{ lat, lng }}
|
||||||
|
onClick={() => handleClusterClick(cluster_id, lng, lat)}
|
||||||
|
zIndex={100}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
aria-label={`${point_count}개 식당이 모인 클러스터, 클릭하면 확대됩니다`}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "linear-gradient(135deg, #E8720C 0%, #f59e0b 100%)",
|
||||||
|
border: "3px solid #fff",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.25)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: size > 42 ? 15 : 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "transform 0.2s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{point_count}
|
||||||
|
</div>
|
||||||
|
</AdvancedMarker>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual marker
|
||||||
|
const r = (feature.properties as { restaurant: Restaurant }).restaurant;
|
||||||
|
const isSelected = selected?.id === r.id;
|
||||||
|
const isClosed = r.business_status === "CLOSED_PERMANENTLY";
|
||||||
|
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
|
||||||
|
const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0];
|
||||||
|
const c = chColor || CHANNEL_COLORS[0];
|
||||||
|
return (
|
||||||
|
<AdvancedMarker
|
||||||
|
key={r.id}
|
||||||
|
position={{ lat: r.latitude, lng: r.longitude }}
|
||||||
|
onClick={() => handleMarkerClick(r)}
|
||||||
|
zIndex={isSelected ? 1000 : 1}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
aria-label={`${r.name}${isClosed ? ' (폐업)' : ''}, 클릭하면 상세 정보가 표시됩니다`}
|
||||||
|
style={{ display: "flex", flexDirection: "column", alignItems: "center", transition: "transform 0.2s ease", transform: isSelected ? "scale(1.15)" : "scale(1)", opacity: isClosed ? 0.5 : 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "4px 8px",
|
||||||
|
backgroundColor: isSelected ? "#2563eb" : isClosed ? "#f3f4f6" : c.bg,
|
||||||
|
color: isSelected ? "#fff" : isClosed ? "#9ca3af" : c.text,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: isSelected ? "2px solid #1d4ed8" : `1.5px solid ${c.border}`,
|
||||||
|
boxShadow: isSelected
|
||||||
|
? "0 2px 8px rgba(37,99,235,0.4)"
|
||||||
|
: `0 1px 4px ${c.border}40`,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: 120,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
textDecoration: isClosed ? "line-through" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-rounded" style={{ fontSize: 14, width: 14, height: 14, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center", marginRight: 3, verticalAlign: "middle", color: "#E8720C" }}>{getCuisineIcon(r.cuisine_type)}</span>
|
||||||
|
{r.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
borderLeft: "6px solid transparent",
|
||||||
|
borderRight: "6px solid transparent",
|
||||||
|
borderTop: isSelected ? "6px solid #1d4ed8" : `6px solid ${c.arrow}`,
|
||||||
|
marginTop: -1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AdvancedMarker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{infoTarget && (
|
||||||
|
<InfoWindow
|
||||||
|
position={{ lat: infoTarget.latitude, lng: infoTarget.longitude }}
|
||||||
|
onCloseClick={() => setInfoTarget(null)}
|
||||||
|
>
|
||||||
|
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-bold text-base" style={{ color: "#171717" }}><span className="material-symbols-rounded" style={{ fontSize: 18, width: 18, height: 18, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center", verticalAlign: "middle", color: "#E8720C", marginRight: 4 }}>{getCuisineIcon(infoTarget.cuisine_type)}</span>{infoTarget.name}</h3>
|
||||||
|
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
|
||||||
|
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
||||||
|
)}
|
||||||
|
{infoTarget.business_status === "CLOSED_TEMPORARILY" && (
|
||||||
|
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded text-[10px] font-semibold">임시휴업</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{infoTarget.rating && (
|
||||||
|
<p className="text-xs mt-0.5">
|
||||||
|
<span className="text-yellow-500">★</span> {infoTarget.rating}
|
||||||
|
{infoTarget.rating_count && (
|
||||||
|
<span className="text-gray-400 ml-1">({infoTarget.rating_count.toLocaleString()})</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{infoTarget.cuisine_type && (
|
||||||
|
<p className="text-xs text-gray-500">{infoTarget.cuisine_type}</p>
|
||||||
|
)}
|
||||||
|
{infoTarget.address && (
|
||||||
|
<p className="text-[11px] text-gray-400 mt-1">{infoTarget.address}</p>
|
||||||
|
)}
|
||||||
|
{infoTarget.price_range && (
|
||||||
|
<p className="text-[11px] text-gray-400">{infoTarget.price_range}</p>
|
||||||
|
)}
|
||||||
|
{infoTarget.phone && (
|
||||||
|
<p className="text-[11px] text-gray-400">{infoTarget.phone}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectRestaurant?.(infoTarget)}
|
||||||
|
className="mt-2 text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
상세 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</InfoWindow>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GoogleMapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo, onMyLocation, activeChannel }: MapViewProps) {
|
||||||
|
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||||
|
const channelNames = useMemo(() => {
|
||||||
|
const names = Object.keys(channelColors);
|
||||||
|
if (activeChannel) return names.filter((n) => n === activeChannel);
|
||||||
|
return names;
|
||||||
|
}, [channelColors, activeChannel]);
|
||||||
|
const boundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const handleCameraChanged = useCallback((ev: { detail: { bounds: { north: number; south: number; east: number; west: number } } }) => {
|
||||||
|
if (!onBoundsChanged) return;
|
||||||
|
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||||
|
boundsTimerRef.current = setTimeout(() => {
|
||||||
|
const { north, south, east, west } = ev.detail.bounds;
|
||||||
|
onBoundsChanged({ north, south, east, west });
|
||||||
|
}, 150);
|
||||||
|
}, [onBoundsChanged]);
|
||||||
|
|
||||||
|
// #278 — 언마운트 시 디바운스 타이머 정리 (메모리 누수 + unmounted setState 경고 방지)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<APIProvider apiKey={API_KEY}>
|
||||||
|
<Map
|
||||||
|
defaultCenter={SEOUL_CENTER}
|
||||||
|
defaultZoom={13}
|
||||||
|
mapId="tasteby-map"
|
||||||
|
className="h-full w-full"
|
||||||
|
colorScheme="LIGHT"
|
||||||
|
mapTypeControl={false}
|
||||||
|
fullscreenControl={false}
|
||||||
|
onCameraChanged={handleCameraChanged}
|
||||||
|
>
|
||||||
|
<MapContent
|
||||||
|
restaurants={restaurants}
|
||||||
|
selected={selected}
|
||||||
|
onSelectRestaurant={onSelectRestaurant}
|
||||||
|
flyTo={flyTo}
|
||||||
|
activeChannel={activeChannel}
|
||||||
|
/>
|
||||||
|
</Map>
|
||||||
|
{onMyLocation && (
|
||||||
|
<button
|
||||||
|
onClick={onMyLocation}
|
||||||
|
aria-label="내 위치로 이동"
|
||||||
|
// #278 — 44×44px 터치 영역 확보 (이전 36px)
|
||||||
|
className="absolute top-2 right-2 w-11 h-11 bg-surface rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-brand-500 dark:hover:text-brand-400 transition-colors z-10 touch-manipulation"
|
||||||
|
title="내 위치"
|
||||||
|
>
|
||||||
|
<Icon name="my_location" size={22} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{channelNames.length > 0 && (
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="채널 범례"
|
||||||
|
className="absolute bottom-2 left-2 bg-surface/90 backdrop-blur-sm rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10"
|
||||||
|
>
|
||||||
|
{channelNames.map((ch) => (
|
||||||
|
<div key={ch} className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="inline-block w-2.5 h-2.5 rounded-full border"
|
||||||
|
style={{ backgroundColor: channelColors[ch].border, borderColor: channelColors[ch].border }}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{ch}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</APIProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,416 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import GoogleMapView from "@/components/GoogleMapView";
|
||||||
import {
|
import NaverMapView from "@/components/NaverMapView";
|
||||||
APIProvider,
|
import { isKoreaCoord, type MapBounds, type FlyTo, type MapViewProps } from "@/components/MapView.types";
|
||||||
Map,
|
|
||||||
AdvancedMarker,
|
|
||||||
InfoWindow,
|
|
||||||
useMap,
|
|
||||||
} from "@vis.gl/react-google-maps";
|
|
||||||
import Supercluster from "supercluster";
|
|
||||||
import type { Restaurant } from "@/lib/api";
|
|
||||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
|
||||||
import Icon from "@/components/Icon";
|
|
||||||
|
|
||||||
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
|
export type { MapBounds, FlyTo };
|
||||||
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
|
|
||||||
|
|
||||||
// Channel color palette
|
const NAVER_KEY = process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || "";
|
||||||
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>();
|
* #363 — 메인 지도 dispatcher.
|
||||||
restaurants.forEach((r) => r.channels?.forEach((ch) => channels.add(ch)));
|
* - 키 미설정: GoogleMap (회귀 0)
|
||||||
const map: Record<string, typeof CHANNEL_COLORS[0]> = {};
|
* - flyTo 또는 selected 좌표가 KR bbox: NaverMap
|
||||||
let i = 0;
|
* - 그 외 (해외 + 좌표 추정): GoogleMap
|
||||||
for (const ch of channels) {
|
* - 초기 마운트 시 화면 중심을 추정할 수 없으면 식당 평균 좌표로.
|
||||||
map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
|
*/
|
||||||
i++;
|
export default function MapView(props: MapViewProps) {
|
||||||
|
if (!NAVER_KEY) return <GoogleMapView {...props} />;
|
||||||
|
|
||||||
|
const targetLat = props.flyTo?.lat ?? props.selected?.latitude ?? avgLat(props.restaurants);
|
||||||
|
const targetLng = props.flyTo?.lng ?? props.selected?.longitude ?? avgLng(props.restaurants);
|
||||||
|
|
||||||
|
if (targetLat != null && targetLng != null && isKoreaCoord(targetLat, targetLng)) {
|
||||||
|
return <NaverMapView {...props} />;
|
||||||
}
|
}
|
||||||
return map;
|
return <GoogleMapView {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapBounds {
|
function avgLat(rs: MapViewProps["restaurants"]): number | null {
|
||||||
north: number;
|
if (!rs.length) return null;
|
||||||
south: number;
|
return rs.reduce((s, r) => s + r.latitude, 0) / rs.length;
|
||||||
east: number;
|
|
||||||
west: number;
|
|
||||||
}
|
}
|
||||||
|
function avgLng(rs: MapViewProps["restaurants"]): number | null {
|
||||||
export interface FlyTo {
|
if (!rs.length) return null;
|
||||||
lat: number;
|
return rs.reduce((s, r) => s + r.longitude, 0) / rs.length;
|
||||||
lng: number;
|
|
||||||
zoom?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MapViewProps {
|
|
||||||
restaurants: Restaurant[];
|
|
||||||
selected?: Restaurant | null;
|
|
||||||
onSelectRestaurant?: (r: Restaurant) => void;
|
|
||||||
onBoundsChanged?: (bounds: MapBounds) => void;
|
|
||||||
flyTo?: FlyTo | null;
|
|
||||||
onMyLocation?: () => void;
|
|
||||||
activeChannel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type RestaurantProps = { restaurant: Restaurant };
|
|
||||||
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
|
|
||||||
|
|
||||||
function useSupercluster(restaurants: Restaurant[]) {
|
|
||||||
// #278 — indexRef 제거 (set만 되고 read 없는 dead code)
|
|
||||||
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) => {
|
|
||||||
return index.getClusters(
|
|
||||||
[bounds.west, bounds.south, bounds.east, bounds.north],
|
|
||||||
Math.floor(zoom)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[index]
|
|
||||||
);
|
|
||||||
|
|
||||||
const getExpansionZoom = useCallback(
|
|
||||||
(clusterId: number): number => {
|
|
||||||
try {
|
|
||||||
return index.getClusterExpansionZoom(clusterId);
|
|
||||||
} catch {
|
|
||||||
return 17;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[index]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { getClusters, getExpansionZoom, index };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClusterSize(count: number): number {
|
|
||||||
if (count < 10) return 36;
|
|
||||||
if (count < 50) return 42;
|
|
||||||
if (count < 100) return 48;
|
|
||||||
return 54;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeChannel }: Omit<MapViewProps, "onMyLocation" | "onBoundsChanged">) {
|
|
||||||
const map = useMap();
|
|
||||||
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
|
|
||||||
const [zoom, setZoom] = useState(13);
|
|
||||||
const [bounds, setBounds] = useState<MapBounds | null>(null);
|
|
||||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
|
||||||
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
|
|
||||||
|
|
||||||
// #278 — restaurantMap 제거 (빌드만 되고 렌더에서 사용 안 됨, dead code)
|
|
||||||
|
|
||||||
const clusters = useMemo(() => {
|
|
||||||
if (!bounds) return [];
|
|
||||||
return getClusters(bounds, zoom);
|
|
||||||
}, [bounds, zoom, getClusters]);
|
|
||||||
|
|
||||||
const handleMarkerClick = useCallback(
|
|
||||||
(r: Restaurant) => {
|
|
||||||
setInfoTarget(r);
|
|
||||||
onSelectRestaurant?.(r);
|
|
||||||
},
|
|
||||||
[onSelectRestaurant]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClusterClick = useCallback(
|
|
||||||
(clusterId: number, lng: number, lat: number) => {
|
|
||||||
if (!map) return;
|
|
||||||
const expansionZoom = Math.min(getExpansionZoom(clusterId), 18);
|
|
||||||
map.panTo({ lat, lng });
|
|
||||||
map.setZoom(expansionZoom);
|
|
||||||
},
|
|
||||||
[map, getExpansionZoom]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Track camera changes for clustering
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map) return;
|
|
||||||
const listener = map.addListener("idle", () => {
|
|
||||||
const b = map.getBounds();
|
|
||||||
const z = map.getZoom();
|
|
||||||
if (b && z != null) {
|
|
||||||
const ne = b.getNorthEast();
|
|
||||||
const sw = b.getSouthWest();
|
|
||||||
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
|
|
||||||
setZoom(z);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Trigger initial bounds
|
|
||||||
const b = map.getBounds();
|
|
||||||
const z = map.getZoom();
|
|
||||||
if (b && z != null) {
|
|
||||||
const ne = b.getNorthEast();
|
|
||||||
const sw = b.getSouthWest();
|
|
||||||
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
|
|
||||||
setZoom(z);
|
|
||||||
}
|
|
||||||
return () => google.maps.event.removeListener(listener);
|
|
||||||
}, [map]);
|
|
||||||
|
|
||||||
// Fly to a specific location (region filter)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map || !flyTo) return;
|
|
||||||
map.panTo({ lat: flyTo.lat, lng: flyTo.lng });
|
|
||||||
if (flyTo.zoom) map.setZoom(flyTo.zoom);
|
|
||||||
}, [map, flyTo]);
|
|
||||||
|
|
||||||
// Pan and zoom to selected restaurant
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map || !selected) return;
|
|
||||||
map.panTo({ lat: selected.latitude, lng: selected.longitude });
|
|
||||||
map.setZoom(16);
|
|
||||||
setInfoTarget(selected);
|
|
||||||
}, [map, selected]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{clusters.map((feature) => {
|
|
||||||
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);
|
|
||||||
return (
|
|
||||||
<AdvancedMarker
|
|
||||||
key={`cluster-${cluster_id}`}
|
|
||||||
position={{ lat, lng }}
|
|
||||||
onClick={() => handleClusterClick(cluster_id, lng, lat)}
|
|
||||||
zIndex={100}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
aria-label={`${point_count}개 식당이 모인 클러스터, 클릭하면 확대됩니다`}
|
|
||||||
style={{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: "linear-gradient(135deg, #E8720C 0%, #f59e0b 100%)",
|
|
||||||
border: "3px solid #fff",
|
|
||||||
boxShadow: "0 2px 8px rgba(0,0,0,0.25)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: size > 42 ? 15 : 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "transform 0.2s ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{point_count}
|
|
||||||
</div>
|
|
||||||
</AdvancedMarker>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Individual marker
|
|
||||||
const r = (feature.properties as { restaurant: Restaurant }).restaurant;
|
|
||||||
const isSelected = selected?.id === r.id;
|
|
||||||
const isClosed = r.business_status === "CLOSED_PERMANENTLY";
|
|
||||||
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
|
|
||||||
const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0];
|
|
||||||
const c = chColor || CHANNEL_COLORS[0];
|
|
||||||
return (
|
|
||||||
<AdvancedMarker
|
|
||||||
key={r.id}
|
|
||||||
position={{ lat: r.latitude, lng: r.longitude }}
|
|
||||||
onClick={() => handleMarkerClick(r)}
|
|
||||||
zIndex={isSelected ? 1000 : 1}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
aria-label={`${r.name}${isClosed ? ' (폐업)' : ''}, 클릭하면 상세 정보가 표시됩니다`}
|
|
||||||
style={{ display: "flex", flexDirection: "column", alignItems: "center", transition: "transform 0.2s ease", transform: isSelected ? "scale(1.15)" : "scale(1)", opacity: isClosed ? 0.5 : 1 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "4px 8px",
|
|
||||||
backgroundColor: isSelected ? "#2563eb" : isClosed ? "#f3f4f6" : c.bg,
|
|
||||||
color: isSelected ? "#fff" : isClosed ? "#9ca3af" : c.text,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
borderRadius: 6,
|
|
||||||
border: isSelected ? "2px solid #1d4ed8" : `1.5px solid ${c.border}`,
|
|
||||||
boxShadow: isSelected
|
|
||||||
? "0 2px 8px rgba(37,99,235,0.4)"
|
|
||||||
: `0 1px 4px ${c.border}40`,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
maxWidth: 120,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
textDecoration: isClosed ? "line-through" : "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-rounded" style={{ fontSize: 14, width: 14, height: 14, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center", marginRight: 3, verticalAlign: "middle", color: "#E8720C" }}>{getCuisineIcon(r.cuisine_type)}</span>
|
|
||||||
{r.name}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
borderLeft: "6px solid transparent",
|
|
||||||
borderRight: "6px solid transparent",
|
|
||||||
borderTop: isSelected ? "6px solid #1d4ed8" : `6px solid ${c.arrow}`,
|
|
||||||
marginTop: -1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AdvancedMarker>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{infoTarget && (
|
|
||||||
<InfoWindow
|
|
||||||
position={{ lat: infoTarget.latitude, lng: infoTarget.longitude }}
|
|
||||||
onCloseClick={() => setInfoTarget(null)}
|
|
||||||
>
|
|
||||||
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-bold text-base" style={{ color: "#171717" }}><span className="material-symbols-rounded" style={{ fontSize: 18, width: 18, height: 18, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center", verticalAlign: "middle", color: "#E8720C", marginRight: 4 }}>{getCuisineIcon(infoTarget.cuisine_type)}</span>{infoTarget.name}</h3>
|
|
||||||
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
|
|
||||||
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
|
||||||
)}
|
|
||||||
{infoTarget.business_status === "CLOSED_TEMPORARILY" && (
|
|
||||||
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded text-[10px] font-semibold">임시휴업</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{infoTarget.rating && (
|
|
||||||
<p className="text-xs mt-0.5">
|
|
||||||
<span className="text-yellow-500">★</span> {infoTarget.rating}
|
|
||||||
{infoTarget.rating_count && (
|
|
||||||
<span className="text-gray-400 ml-1">({infoTarget.rating_count.toLocaleString()})</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{infoTarget.cuisine_type && (
|
|
||||||
<p className="text-xs text-gray-500">{infoTarget.cuisine_type}</p>
|
|
||||||
)}
|
|
||||||
{infoTarget.address && (
|
|
||||||
<p className="text-[11px] text-gray-400 mt-1">{infoTarget.address}</p>
|
|
||||||
)}
|
|
||||||
{infoTarget.price_range && (
|
|
||||||
<p className="text-[11px] text-gray-400">{infoTarget.price_range}</p>
|
|
||||||
)}
|
|
||||||
{infoTarget.phone && (
|
|
||||||
<p className="text-[11px] text-gray-400">{infoTarget.phone}</p>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => onSelectRestaurant?.(infoTarget)}
|
|
||||||
className="mt-2 text-sm text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
상세 보기
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</InfoWindow>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo, onMyLocation, activeChannel }: MapViewProps) {
|
|
||||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
|
||||||
const channelNames = useMemo(() => {
|
|
||||||
const names = Object.keys(channelColors);
|
|
||||||
if (activeChannel) return names.filter((n) => n === activeChannel);
|
|
||||||
return names;
|
|
||||||
}, [channelColors, activeChannel]);
|
|
||||||
const boundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const handleCameraChanged = useCallback((ev: { detail: { bounds: { north: number; south: number; east: number; west: number } } }) => {
|
|
||||||
if (!onBoundsChanged) return;
|
|
||||||
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
|
||||||
boundsTimerRef.current = setTimeout(() => {
|
|
||||||
const { north, south, east, west } = ev.detail.bounds;
|
|
||||||
onBoundsChanged({ north, south, east, west });
|
|
||||||
}, 150);
|
|
||||||
}, [onBoundsChanged]);
|
|
||||||
|
|
||||||
// #278 — 언마운트 시 디바운스 타이머 정리 (메모리 누수 + unmounted setState 경고 방지)
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<APIProvider apiKey={API_KEY}>
|
|
||||||
<Map
|
|
||||||
defaultCenter={SEOUL_CENTER}
|
|
||||||
defaultZoom={13}
|
|
||||||
mapId="tasteby-map"
|
|
||||||
className="h-full w-full"
|
|
||||||
colorScheme="LIGHT"
|
|
||||||
mapTypeControl={false}
|
|
||||||
fullscreenControl={false}
|
|
||||||
onCameraChanged={handleCameraChanged}
|
|
||||||
>
|
|
||||||
<MapContent
|
|
||||||
restaurants={restaurants}
|
|
||||||
selected={selected}
|
|
||||||
onSelectRestaurant={onSelectRestaurant}
|
|
||||||
flyTo={flyTo}
|
|
||||||
activeChannel={activeChannel}
|
|
||||||
/>
|
|
||||||
</Map>
|
|
||||||
{onMyLocation && (
|
|
||||||
<button
|
|
||||||
onClick={onMyLocation}
|
|
||||||
aria-label="내 위치로 이동"
|
|
||||||
// #278 — 44×44px 터치 영역 확보 (이전 36px)
|
|
||||||
className="absolute top-2 right-2 w-11 h-11 bg-surface rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-brand-500 dark:hover:text-brand-400 transition-colors z-10 touch-manipulation"
|
|
||||||
title="내 위치"
|
|
||||||
>
|
|
||||||
<Icon name="my_location" size={22} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{channelNames.length > 0 && (
|
|
||||||
<div
|
|
||||||
role="region"
|
|
||||||
aria-label="채널 범례"
|
|
||||||
className="absolute bottom-2 left-2 bg-surface/90 backdrop-blur-sm rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10"
|
|
||||||
>
|
|
||||||
{channelNames.map((ch) => (
|
|
||||||
<div key={ch} className="flex items-center gap-1">
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className="inline-block w-2.5 h-2.5 rounded-full border"
|
|
||||||
style={{ backgroundColor: channelColors[ch].border, borderColor: channelColors[ch].border }}
|
|
||||||
/>
|
|
||||||
<span className="text-gray-700 dark:text-gray-300">{ch}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</APIProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
29
frontend/src/components/MapView.types.ts
Normal file
29
frontend/src/components/MapView.types.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Restaurant } from "@/lib/api";
|
||||||
|
|
||||||
|
export interface MapBounds {
|
||||||
|
north: number;
|
||||||
|
south: number;
|
||||||
|
east: number;
|
||||||
|
west: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlyTo {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
zoom?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapViewProps {
|
||||||
|
restaurants: Restaurant[];
|
||||||
|
selected?: Restaurant | null;
|
||||||
|
onSelectRestaurant?: (r: Restaurant) => void;
|
||||||
|
onBoundsChanged?: (bounds: MapBounds) => void;
|
||||||
|
flyTo?: FlyTo | null;
|
||||||
|
onMyLocation?: () => void;
|
||||||
|
activeChannel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 좌표가 한국 영토 bbox 안인지 (WGS84).
|
||||||
|
export function isKoreaCoord(lat: number, lng: number): boolean {
|
||||||
|
return lat >= 33 && lat <= 38.7 && lng >= 124 && lng <= 132;
|
||||||
|
}
|
||||||
326
frontend/src/components/NaverMapView.tsx
Normal file
326
frontend/src/components/NaverMapView.tsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
"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 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;
|
||||||
|
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 = [
|
||||||
|
{ border: "#f59e0b" }, // amber (default)
|
||||||
|
{ border: "#3b82f6" }, // blue
|
||||||
|
{ border: "#22c55e" }, // green
|
||||||
|
{ border: "#ec4899" }, // pink
|
||||||
|
{ border: "#a855f7" }, // purple
|
||||||
|
{ border: "#ef4444" }, // red
|
||||||
|
{ border: "#14b8a6" }, // teal
|
||||||
|
{ border: "#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 단일 마커 — 채널 색상별
|
||||||
|
function markerIconHtml(color: string): string {
|
||||||
|
return `<div style="width:28px;height:28px;border-radius:9999px;background:${color};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({
|
||||||
|
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 변경 반영
|
||||||
|
useEffect(() => {
|
||||||
|
const m = mapRef.current;
|
||||||
|
if (!m || !flyTo || !window.naver?.maps) return;
|
||||||
|
m.panTo(new window.naver.maps.LatLng(flyTo.lat, flyTo.lng));
|
||||||
|
if (flyTo.zoom) m.setZoom(flyTo.zoom, true);
|
||||||
|
}, [flyTo]);
|
||||||
|
|
||||||
|
// 클러스터 계산 (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.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 marker = new naver.Marker({
|
||||||
|
position: new naver.LatLng(lat, lng),
|
||||||
|
map: m,
|
||||||
|
title: r.name,
|
||||||
|
icon: {
|
||||||
|
content: markerIconHtml(chColor?.border ?? "#f59e0b"),
|
||||||
|
anchor: new naver.Point(14, 14),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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, channelColors, activeChannel]);
|
||||||
|
|
||||||
|
// 컴포넌트 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));
|
||||||
|
}
|
||||||
@@ -24,6 +24,15 @@ function buildSearchQuery(r: Restaurant): string {
|
|||||||
return r.name;
|
return r.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 좌표 기반 한국 판정 (WGS84). KR bbox 대략 33~38.7°N, 124~132°E.
|
||||||
|
// 좌표 없으면 region 첫 토큰으로 fallback (구 데이터 호환).
|
||||||
|
function isKoreaRestaurant(r: Restaurant): boolean {
|
||||||
|
if (r.latitude != null && r.longitude != null) {
|
||||||
|
return r.latitude >= 33 && r.latitude <= 38.7 && r.longitude >= 124 && r.longitude <= 132;
|
||||||
|
}
|
||||||
|
return !r.region || r.region.split("|")[0] === "한국";
|
||||||
|
}
|
||||||
|
|
||||||
export default function RestaurantDetail({
|
export default function RestaurantDetail({
|
||||||
restaurant,
|
restaurant,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -138,22 +147,33 @@ export default function RestaurantDetail({
|
|||||||
)}
|
)}
|
||||||
{restaurant.google_place_id && (
|
{restaurant.google_place_id && (
|
||||||
<p className="flex gap-3">
|
<p className="flex gap-3">
|
||||||
<a
|
{isKoreaRestaurant(restaurant) ? (
|
||||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
|
<>
|
||||||
target="_blank"
|
<a
|
||||||
rel="noopener noreferrer"
|
href={`https://map.naver.com/p/search/${encodeURIComponent(buildSearchQuery(restaurant))}`}
|
||||||
className="text-brand-600 dark:text-brand-400 hover:underline text-xs"
|
target="_blank"
|
||||||
>
|
rel="noopener noreferrer"
|
||||||
Google Maps에서 보기
|
className="text-green-600 dark:text-green-400 hover:underline text-xs"
|
||||||
</a>
|
>
|
||||||
{(!restaurant.region || restaurant.region.split("|")[0] === "한국") && (
|
네이버 지도에서 보기
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-500 dark:text-gray-400 hover:underline text-xs"
|
||||||
|
>
|
||||||
|
Google Maps
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<a
|
<a
|
||||||
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
|
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-green-600 dark:text-green-400 hover:underline text-xs"
|
className="text-brand-600 dark:text-brand-400 hover:underline text-xs"
|
||||||
>
|
>
|
||||||
네이버 지도에서 보기
|
Google Maps에서 보기
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user