프론트엔드

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