#338: /api/version 신규 - HealthController에 @Value 빌드 정보 + GET /api/version 추가 - SecurityConfig.permitAll에 /api/version 추가 - application.yml app.build.version/commit (env APP_VERSION/APP_COMMIT) - 부수: SecurityConfig에서 /api/daemon/config permitAll 제거 (이미 admin-only) #320: findRegionFromCoords 거리 보정 - 유클리드 거리 → cos(lat) 가중치(equirectangular approx)로 위경도 실거리 보정 - 위도가 큰 지역(부산↔서울)에서 city 추정 정확도 향상 #340: MapView 마커/범례 ARIA - 클러스터 마커: role=button + aria-label - 개별 식당 마커: role=button + aria-label (name + 폐업 여부) - 채널 범례: role=region + aria-label, 색상 점은 aria-hidden #333: ChannelController 캐시 세분화 - cache.flush() 전체 무효화 → cache.del(makeKey("channels"))로 채널 키만 evict - 다른 모듈(restaurants/search) 캐시 hit율 보존 후속: deploy.sh에 APP_VERSION/APP_COMMIT env 주입은 별도 (현재 dev/unknown 응답) Refs: #338 #320 #340 #333
417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
"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;
|
||
}
|
||
|
||
export interface MapBounds {
|
||
north: number;
|
||
south: number;
|
||
east: number;
|
||
west: number;
|
||
}
|
||
|
||
export interface FlyTo {
|
||
lat: number;
|
||
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>
|
||
);
|
||
}
|