Files
tasteby/frontend/src/components/GoogleMapView.tsx
joungmin f17ba9e37a feat(map): #363 메인 지도 SDK 국내(네이버)/해외(구글) 분기
- MapView dispatcher: NAVER 키 + KR bbox 좌표 → NaverMapView
- NaverMapView 신규 (네이버 v3 직접 wrapper, Supercluster 재사용)
- GoogleMapView 신규 (기존 MapView 내용 rename)
- MapView.types.ts 공용 타입 + isKoreaCoord 헬퍼
- Dockerfile/deploy.sh: NEXT_PUBLIC_NAVER_MAP_CLIENT_ID build-arg
- 키 미설정 시 GoogleMap fallback (회귀 0)

Refs: #363

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 06:25:47 +09:00

396 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}