Leaflet 사진 카드 클러스터 (iOS 사진 앱 스타일)
프론트엔드Leaflet 사진 카드 클러스터 (iOS 사진 앱 스타일) — 실전 적용 구조와 코드 예시
언제 쓰나 · 지도에 사진(또는 임의 미디어)이 GPS 좌표로 흩뿌려질 때, **기본 Leaflet 핀이나 단조로운 원형 카운트 배지** 대신 iOS 사진 앱처럼 **대표 썸네일이 박힌 폴라로이드 카드**를 보여주고 싶을 때. · 핵심 효과: · 한 장씩 보지 않아도 지도에서 분위기 파악 · 줌 인/아웃에 따라 카드 자동 분리/병합
#frontend#Leaflet#클러스터#iOS
---
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<T extends { latitude: number; longitude: number }>(
images: T[], precision = 2,
): Cluster[] {
const map = new Map<string, { sumLat: number; sumLng: number; images: T[] }>();
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<T extends { latitude: number; longitude: number }>(
images: T[],
): Cluster<T>[] {
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`을 쓰므로 **`<MapContainer>` 자식 컴포넌트에서만** 호출 가능.
## 폴라로이드 카드 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 = `
<svg width="${cardW}" height="${tailH}" viewBox="0 0 ${cardW} ${tailH}" style="display:block;">
<polygon points="${cardW/2-8},0 ${cardW/2+8},0 ${cardW/2},${tailH}"
fill="#ffffff" stroke="rgba(0,0,0,0.08)" stroke-width="1" />
</svg>`;
const html = `
<div style="width:${cardW}px;height:${totalH}px;
filter:drop-shadow(0 4px 8px rgba(0,0,0,0.35));">
<div style="width:${cardW}px;height:${photoH}px;
background:#fff;border-radius:8px;padding:3px;box-sizing:border-box;
position:relative;overflow:hidden;">
<div style="width:100%;height:100%;
background-image:url('${thumbUrl.replace(/'/g, "\\'")}');
background-size:cover;background-position:center;
background-color:#e5e7eb;border-radius:5px;${photoTransform}"></div>
${showBadge ? `
<div style="position:absolute;left:6px;bottom:6px;
background:rgba(0,0,0,0.7);color:#fff;
font-weight:700;font-size:${badgeFont}px;
padding:2px 7px;border-radius:10px;line-height:1.2;
min-width:20px;text-align:center;">${badgeText}</div>` : ""}
</div>
${tail}
</div>`;
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<ImageT>) => 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 (
<Marker
key={`${cluster.lat.toFixed(4)},${cluster.lng.toFixed(4)}-${cidx}`}
position={[cluster.lat, cluster.lng]}
icon={createClusterIcon({
count: cluster.images.length,
thumbUrl,
orientation: rep.orientation,
})}
eventHandlers={{ click: () => onSelectCluster?.(cluster) }}
/>
);
})}
</>
);
}
```
⚠️ `Popup` 미사용 — 클릭하면 곧장 부모 콜백. 그리드 뷰는 부모(상위 페이지)가 띄움.
## 클릭 시 그리드 뷰 (2단계 패턴)
iOS 사진 앱과 동일: 카드 클릭 → 그리드 → 풀스크린 슬라이드.
```tsx
// 부모 (page) 단
const [gridCluster, setGridCluster] = useState<Cluster | null>(null);
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
const [viewerOverride, setViewerOverride] = useState<MediaItem[] | null>(null);
return (
<>
<LocationMap images={...} onSelectCluster={setGridCluster} />
<LocationGridView
cluster={gridCluster}
onClose={() => setGridCluster(null)}
onSelectImage={(imgs, idx) => {
setViewerOverride(imgs.map(toMediaItem));
setViewerIndex(idx);
setGridCluster(null);
}}
/>
{viewerIndex !== null && (
<MediaViewer items={viewerOverride!} currentIndex={viewerIndex} ... />
)}
</>
);
```
`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 (
<div className="fixed inset-0 z-[1100] bg-dark-950/95 backdrop-blur-sm flex flex-col"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
{/* 헤더 (count + 좌표 + 닫기) */}
{/* 4-6열 grid - 모바일 3, sm 4, md 5, lg 6 */}
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-1">
{cluster.images.map((img, idx) => (
<button key={img.id} onClick={() => onSelectImage(cluster.images, idx)}
className="relative aspect-square overflow-hidden rounded bg-dark-800 group">
<img src={img.thumb_url || ...} loading="lazy"
className="w-full h-full object-cover transition-transform group-hover:scale-105" />
</button>
))}
</div>
</div>
);
}
```
## 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=...`) |
| 이미지 로드 실패 | `<img onError>`에서 폴백 한 번만 (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`jt · v1 · CC0-1.0 · 복사 0