--- title: Leaflet 사진 카드 클러스터 (iOS 사진 앱 스타일) tags: [frontend, react-leaflet, leaflet, map, cluster, photo, ios-style] created: 2026-04-29 --- # Leaflet 사진 카드 클러스터 (iOS 사진 앱 스타일) ## 언제 쓰나 지도에 사진(또는 임의 미디어)이 GPS 좌표로 흩뿌려질 때, **기본 Leaflet 핀이나 단조로운 원형 카운트 배지** 대신 iOS 사진 앱처럼 **대표 썸네일이 박힌 폴라로이드 카드**를 보여주고 싶을 때. 핵심 효과: - 한 장씩 보지 않아도 지도에서 분위기 파악 - 줌 인/아웃에 따라 카드 자동 분리/병합 - 카드 클릭 → 그리드 → 풀스크린 슬라이드 2단계 흐름 ## 구성 요소 (3가지) 1. **폴라로이드 카드 `L.divIcon`** — HTML/CSS로 사진 + 카운트 배지 + 핀 꼬리 2. **`useZoomedClusters` 훅** — 줌 변경 listen → 좌표 정밀도 동적 조정 3. **그리드 뷰 컴포넌트** — 카드 클릭 시 풀스크린 모달, 썸네일 클릭 시 풀스크린 슬라이드 호출 ## 줌→정밀도 매핑 ```ts function precisionForZoom(zoom: number): number { if (zoom < 8) return 1; // 광역시·도 (≈ 10km × 7km) if (zoom < 11) return 2; // 시·동 (≈ 1km × 0.7km) return 3; // 블록 (≈ 100m × 70m) } ``` `lat.toFixed(p) + "," + lng.toFixed(p)`를 클러스터 키로 사용 → 같은 박스 안 좌표는 한 클러스터. ## 클러스터링 함수 ```ts interface Cluster { lat: number; lng: number; images: T[] } function clusterImages( images: T[], precision = 2, ): Cluster[] { const map = new Map(); for (const img of images) { const key = `${img.latitude.toFixed(precision)},${img.longitude.toFixed(precision)}`; const e = map.get(key); if (e) { e.sumLat += img.latitude; e.sumLng += img.longitude; e.images.push(img); } else { map.set(key, { sumLat: img.latitude, sumLng: img.longitude, images: [img] }); } } return Array.from(map.values()).map((e) => ({ lat: e.sumLat / e.images.length, lng: e.sumLng / e.images.length, images: e.images, })); } ``` ## 줌 인지 훅 ```ts import { useEffect, useMemo, useState } from "react"; import { useMap } from "react-leaflet"; function useZoomedClusters( images: T[], ): Cluster[] { const map = useMap(); const [zoom, setZoom] = useState(map.getZoom()); useEffect(() => { const handler = () => setZoom(map.getZoom()); map.on("zoomend", handler); return () => { map.off("zoomend", handler); }; }, [map]); return useMemo( () => clusterImages(images, precisionForZoom(zoom)), [images, zoom], ); } ``` ⚠️ `useMap`을 쓰므로 **`` 자식 컴포넌트에서만** 호출 가능. ## 폴라로이드 카드 divIcon ```ts import L from "leaflet"; function createClusterIcon(opts: { count: number; thumbUrl: string; orientation?: number | null; // EXIF 회전 (0/90/180/-90) }): L.DivIcon { const { count, thumbUrl, orientation } = opts; const cardW = 70, photoH = 70, tailH = 14; const totalH = photoH + tailH; const rotation = orientation ?? 0; const photoTransform = rotation ? `transform: rotate(${rotation}deg);` : ""; const showBadge = count > 1; const badgeText = count < 100 ? String(count) : "99+"; const badgeFont = count < 100 ? 11 : 9; const tail = ` `; const html = `
${showBadge ? `
${badgeText}
` : ""}
${tail}
`; return L.divIcon({ html, className: "ts-cluster-card", iconSize: [cardW, totalH], iconAnchor: [cardW/2, totalH], // 핀 꼬리 끝이 좌표에 닿게 }); } ``` **디자인 포인트** - 카드 크기 70×84px (가로 4-5개 겹쳐도 시각 정리) - 흰색 패딩 3px → 폴라로이드 느낌 - 핀 꼬리는 SVG ▽ — `iconAnchor`로 끝점이 정확히 좌표 위치 - `drop-shadow` filter로 카드+꼬리 일체 그림자 - count===1이면 배지 숨겨 시각 일관성 ## 클러스터 마커 컴포넌트 ```tsx import { Marker } from "react-leaflet"; function ClusterMarkers({ images, onSelectCluster, }: { images: ImageT[]; onSelectCluster?: (cluster: Cluster) => void; }) { const clusters = useZoomedClusters(images); return ( <> {clusters.map((cluster, cidx) => { const rep = cluster.images[0]; // 대표 = 첫 이미지 (정렬 순) const thumbUrl = rep.thumb_url || `/api/preview?path=${encodeURIComponent(rep.file_path)}`; return ( onSelectCluster?.(cluster) }} /> ); })} ); } ``` ⚠️ `Popup` 미사용 — 클릭하면 곧장 부모 콜백. 그리드 뷰는 부모(상위 페이지)가 띄움. ## 클릭 시 그리드 뷰 (2단계 패턴) iOS 사진 앱과 동일: 카드 클릭 → 그리드 → 풀스크린 슬라이드. ```tsx // 부모 (page) 단 const [gridCluster, setGridCluster] = useState(null); const [viewerIndex, setViewerIndex] = useState(null); const [viewerOverride, setViewerOverride] = useState(null); return ( <> setGridCluster(null)} onSelectImage={(imgs, idx) => { setViewerOverride(imgs.map(toMediaItem)); setViewerIndex(idx); setGridCluster(null); }} /> {viewerIndex !== null && ( )} ); ``` `LocationGridView` 핵심 (풀스크린 모달, 4-6열 정사각 grid, ESC 닫기, body scroll lock): ```tsx export default function LocationGridView({ cluster, onClose, onSelectImage }: Props) { useEffect(() => { // ESC 키 if (!cluster) return; const h = (e: KeyboardEvent) => e.key === "Escape" && onClose(); window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, [cluster, onClose]); useEffect(() => { // body scroll lock if (!cluster) return; const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prev; }; }, [cluster]); if (!cluster) return null; return (
{ if (e.target === e.currentTarget) onClose(); }}> {/* 헤더 (count + 좌표 + 닫기) */} {/* 4-6열 grid - 모바일 3, sm 4, md 5, lg 6 */}
{cluster.images.map((img, idx) => ( ))}
); } ``` ## fitBounds는 클러스터가 아닌 이미지 좌표로 ```tsx function FitBoundsToImages({ images }: { images: ImageT[] }) { const map = useMap(); useEffect(() => { if (!images.length) return; const bounds = L.latLngBounds(images.map(i => [i.latitude, i.longitude])); map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 }); }, [images, map]); return null; } ``` ⚠️ **클러스터가 아니라 원본 이미지 좌표 기반**으로 fitBounds — 안 그러면 줌 변경(precision 변동)마다 fitBounds가 흔들려 사용자 줌 조작 무시됨. ## 엣지 케이스 정리 | 케이스 | 처리 | |---|---| | `thumb_url` null | 서버 preview 엔드포인트 폴백 (`/api/preview?path=...`) | | 이미지 로드 실패 | ``에서 폴백 한 번만 (dataset.fallback 플래그) | | EXIF orientation | `transform: rotate(...)`로 카드/그리드 양쪽 회전 보정 | | singleton (count===1) | 같은 카드 + 카운트 배지만 숨김 (시각 일관성) | | 같은 좌표 다중 사진 | 정상 클러스터링 — 카운트만 늘어남 | | 줌 빠르게 바뀜 | `useMemo`로 안정. debounce 필요 시 추가 | ## 안티패턴 (피할 것) - **클러스터링을 fitBounds 의존**: zoom마다 cluster 갯수·중심이 바뀌어 fitBounds 호출이 흔들림 → 위 `FitBoundsToImages`처럼 원본 이미지 기준 - **Popup 안에 미니그리드 + "전체보기 →"**: 클릭 단계가 어색함. iOS는 카드→풀스크린 그리드 직행. - **마커 key를 index만 사용**: zoom 변경 시 React가 reuse하려다 stale icon 표시. 좌표+index 조합. - **풀스크린 슬라이드만 제공 (그리드 단계 생략)**: 사진 50장+이면 슬라이드만으로 둘러보기 답답. ## 실제 적용 사례 `raspberrypi_at_home/nas-dashboard` (NAS 사진 39,742장, GPS 10,841장): - `frontend/src/components/LocationMap.tsx` — 본 패턴 모두 포함 - `frontend/src/components/LocationGridView.tsx` — 그리드 모달 - `frontend/src/pages/ImageClassify.tsx` — 부모 통합 - 한국 V-World 타일 + 해외 OSM 타일 분기 설계 스펙: `docs/superpowers/specs/2026-04-25-location-map-photos-style-design.md` 구현 plan: `docs/superpowers/plans/2026-04-27-location-map-photos-style.md`