--- name: large-scale-map-visualization description: Master of high-performance web map implementations handling 5,000-100,000+ geographic data points. Specializes in Leaflet.js optimization, Supercluster algorithms, viewport-based loading, canvas rendering, and progressive disclosure UX patterns. allowed-tools: Read, Write, Edit, Bash, Glob, Grep, WebSearch, WebFetch metadata: category: Data & Analytics tags: - maps - leaflet - geospatial - clustering - performance - visualization - supercluster - react pairs-with: - skill: geospatial-data-pipeline reason: Geospatial pipelines prepare and optimize the data that map visualizations render - skill: react-performance-optimizer reason: Rendering 100K+ markers requires React virtualization and memoization optimization - skill: data-viz-2025 reason: Map visualizations combine geographic rendering with chart overlays and data tooltips --- # Large-Scale Map Visualization Expert Master of high-performance web map implementations handling 5,000-100,000+ geographic data points. Specializes in Leaflet.js optimization, spatial clustering algorithms, viewport-based loading, and progressive disclosure UX patterns for map-based applications. ## Activation Triggers **Activate on:** "map performance", "too many markers", "slow map", "clustering", "10k points", "marker clustering", "leaflet performance", "spatial visualization", "geospatial clustering", "viewport loading", "map data optimization", "real-time map", "Supercluster", "marker cluster" **NOT for:** Static map images (use Mapbox/Google Static) | 3D visualizations (use Maplibre GL) | Non-geographic data visualization (use D3.js/Chart.js) | Simple maps with <100 markers (vanilla Leaflet is fine) ## Core Expertise ### Performance Architecture ``` ┌─────────────────────────────────────────────────────────────┐ │ MAP PERFORMANCE TIERS │ ├─────────────────────────────────────────────────────────────┤ │ │ │ 0-100 markers → Vanilla Leaflet (no optimization) │ │ 100-1,000 → Basic clustering (react-leaflet-cluster)│ │ 1,000-10,000 → Supercluster + viewport loading │ │ 10,000-50,000 → Supercluster + canvas + sampling │ │ 50,000-500,000 → Web Workers + server-side clustering │ │ 500,000+ → MVT tiles + backend pre-aggregation │ │ │ └─────────────────────────────────────────────────────────────┘ ``` ### Technology Stack Decisions | Use Case | Best Library | Why | |----------|--------------|-----| | React + <5k points | `react-leaflet-cluster` | Simple drop-in, wraps Leaflet.markercluster | | React + 5-50k points | `use-supercluster` hook | 3-5x faster, viewport-aware, GeoJSON native | | React + 50k+ points | `supercluster` + Web Workers | Offload clustering to background thread | | Static sites | Server-side clustering | Pre-compute at build time | | Real-time updates | Canvas renderer + sampling | Minimize DOM manipulation | ## Key Techniques ### 1. Marker Clustering with Supercluster **Why Supercluster beats alternatives:** - **Performance**: Handles 500k points in 1-2 seconds vs 8+ seconds for Leaflet.markercluster - **Architecture**: Index-based k-d tree clustering, can run server-side or in Workers - **API**: Simple GeoJSON input/output - **Viewport-aware**: Only clusters visible points **Implementation Pattern:** ```tsx import useSupercluster from "use-supercluster"; export function OptimizedMap({ locations }: { locations: Place[] }) { const mapRef = useRef(null); const [bounds, setBounds] = useState(null); const [zoom, setZoom] = useState(10); // Convert to GeoJSON Feature collection const points = useMemo(() => locations.map(place => ({ type: "Feature" as const, properties: { cluster: false, placeId: place.id, place }, geometry: { type: "Point" as const, coordinates: [place.longitude, place.latitude] } })), [locations] ); // Cluster points based on viewport const { clusters, supercluster } = useSupercluster({ points, bounds, zoom, options: { radius: 75, // Cluster radius in pixels maxZoom: 16, // Stop clustering at street level minPoints: 2 // Minimum points to form cluster } }); // Update viewport on map move useEffect(() => { if (!mapRef.current) return; const handleMove = () => { const map = mapRef.current!; const b = map.getBounds(); setBounds([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]); setZoom(map.getZoom()); }; mapRef.current.on("moveend", handleMove); handleMove(); // Initial load return () => mapRef.current?.off("moveend", handleMove); }, []); return ( {clusters.map(cluster => { const [lng, lat] = cluster.geometry.coordinates; const { cluster: isCluster, point_count } = cluster.properties; if (isCluster) { return ( { const expansionZoom = Math.min( supercluster!.getClusterExpansionZoom(cluster.id), 18 ); mapRef.current?.setView([lat, lng], expansionZoom, { animate: true }); } }} /> ); } return ( ); })} ); } ``` ### 2. Viewport-Based Loading (Supabase + PostGIS) **Database Function:** ```sql CREATE OR REPLACE FUNCTION find_in_viewport( min_lng DOUBLE PRECISION, min_lat DOUBLE PRECISION, max_lng DOUBLE PRECISION, max_lat DOUBLE PRECISION, zoom_level INTEGER DEFAULT 11, max_results INTEGER DEFAULT 10000 ) RETURNS TABLE ( id UUID, name TEXT, latitude DOUBLE PRECISION, longitude DOUBLE PRECISION /* other fields */ ) AS $$ BEGIN -- At low zoom levels, sample to reduce density IF zoom_level < 9 THEN RETURN QUERY SELECT p.id, p.name, ST_Y(p.geog::geometry) as latitude, ST_X(p.geog::geometry) as longitude FROM places p WHERE p.geog && ST_MakeEnvelope(min_lng, min_lat, max_lng, max_lat, 4326)::geography AND random() < 0.2 -- Show 20% for performance LIMIT max_results / 2; ELSE -- Full data at higher zoom RETURN QUERY SELECT p.id, p.name, ST_Y(p.geog::geometry) as latitude, ST_X(p.geog::geometry) as longitude FROM places p WHERE p.geog && ST_MakeEnvelope(min_lng, min_lat, max_lng, max_lat, 4326)::geography LIMIT max_results; END IF; END; $$ LANGUAGE plpgsql STABLE; -- Ensure spatial index exists CREATE INDEX IF NOT EXISTS idx_places_geog ON places USING GIST (geog); ``` **React Query Hook:** ```tsx import { useQuery } from "@tanstack/react-query"; import { supabase } from "@/lib/supabase"; type BBox = [number, number, number, number]; // [west, south, east, north] export function usePlacesInViewport( bounds: BBox | null, zoom: number, enabled = true ) { return useQuery({ queryKey: ["places", "viewport", bounds?.join(","), zoom], queryFn: async () => { if (!bounds) return []; const [west, south, east, north] = bounds; const { data, error } = await supabase.rpc("find_in_viewport", { min_lng: west, min_lat: south, max_lng: east, max_lat: north, zoom_level: zoom }); if (error) throw error; return data || []; }, enabled: enabled && !!bounds, staleTime: 5 * 60 * 1000, // 5 min (locations rarely change) gcTime: 30 * 60 * 1000, // 30 min in cache refetchOnWindowFocus: false }); } ``` ### 3. Progressive Disclosure Strategy Show appropriate detail levels based on zoom: ```tsx const getClusterOptions = (zoom: number) => ({ radius: zoom < 10 ? 100 : zoom < 14 ? 75 : 50, maxZoom: 16, minPoints: zoom < 10 ? 5 : 2 }); const getMarkerSize = (zoom: number) => zoom < 12 ? 24 : zoom < 15 ? 32 : 40; const shouldShowLabel = (zoom: number) => zoom >= 14; ``` ### 4. Canvas Rendering for Performance ```tsx import L from "leaflet"; // Enable canvas renderer globally const canvasRenderer = L.canvas({ tolerance: 10, // Hit detection tolerance padding: 0.5 // Extra render area (0.5 = 50% of viewport) }); const mapOptions = { preferCanvas: true, renderer: canvasRenderer, // Disable animations on mobile zoomAnimation: !isMobile(), fadeAnimation: !isMobile(), markerZoomAnimation: !isMobile() }; ``` **Performance gain**: 3-5x faster rendering with 1,000+ markers ### 5. Efficient Cluster Icons ```tsx import L from "leaflet"; // Use divIcon (faster than custom components) function createClusterIcon(count: number, zoom: number) { const size = getMarkerSize(zoom); return L.divIcon({ html: `
${count}
`, className: "cluster-icon", iconSize: [size, size], iconAnchor: [size / 2, size / 2] }); } ``` ### 6. Debounced Map Events ```tsx import { useDebouncedCallback } from "use-debounce"; const handleMapMove = useDebouncedCallback(() => { const bounds = mapRef.current?.getBounds(); const zoom = mapRef.current?.getZoom(); if (bounds && zoom) { setBounds([ bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth() ]); setZoom(zoom); } }, 300); // 300ms debounce useEffect(() => { mapRef.current?.on("moveend", handleMapMove); return () => mapRef.current?.off("moveend", handleMapMove); }, []); ``` ## Performance Benchmarks Based on real-world testing and research (sources in references): | Strategy | 1k points | 5k points | 10k points | Mobile (4G) | |----------|-----------|-----------|------------|-------------| | No clustering | 800ms | 3.5s ❌ | 8s ❌ | 12s ❌ | | Basic clustering | 400ms | 1.8s ⚠️ | 4s ⚠️ | 6s ❌ | | Leaflet.markercluster | 200ms | 800ms ⚠️ | 2s ⚠️ | 3s ⚠️ | | Supercluster + viewport | 150ms ✅ | 300ms ✅ | 500ms ✅ | 800ms ✅ | | Supercluster + canvas | 100ms ✅ | 200ms ✅ | 350ms ✅ | 500ms ✅ | **Target Performance Goals:** - Initial load: <500ms (perceived) - Pan/zoom: <200ms response - Marker click: <100ms - Mobile: 2x desktop times acceptable ## UX Patterns ### Cluster Interaction Patterns 1. **Click to Expand** (Recommended) - Click cluster → zoom to expansion zoom level - Shows "spider" view of underlying points 2. **Click to List** - Click cluster → show sidebar with all items - Good for dense areas (downtown cores) 3. **Hover Preview** - Hover cluster → show count + top 3 items - Good for discovery UX ### Loading States ```tsx {isLoading && (
Loading {loadedCount} of {totalCount} locations...
)} ``` ### Empty States ```tsx {!isLoading && clusters.length === 0 && (

No locations in this area

Try zooming out or searching a different location.

)} ``` ## Common Pitfalls ### ❌ Anti-patterns to Avoid 1. **Loading all data upfront** ```tsx // BAD: Fetches 10k records on mount const { data } = useQuery(["all-places"], fetchAllPlaces); ``` 2. **Re-rendering on every map move** ```tsx // BAD: Updates state on every pixel map.on("move", () => setBounds(map.getBounds())); ``` 3. **Complex marker components** ```tsx // BAD: React component per marker } /> ``` 4. **No zoom-level adaptation** ```tsx // BAD: Same clustering at all zoom levels const clusterOptions = { radius: 80, maxZoom: 20 }; ``` ### ✅ Best Practices 1. **Viewport-based loading with debouncing** 2. **Simple marker icons (divIcon with inline styles)** 3. **Progressive disclosure (adapt to zoom level)** 4. **Canvas rendering for large datasets** 5. **Proper React Query cache configuration** ## Real-World Examples ### Zillow Pattern - **Low zoom**: Neighborhood price clusters - **Medium zoom**: Individual properties with price - **High zoom**: Full property cards - **Click**: Expand cluster or open details ### Airbnb Pattern - **Server-side**: Pre-cluster at 10 zoom levels - **Client-side**: Viewport API with 300ms debounce - **Rendering**: Canvas for price labels - **Interaction**: Hover for preview, click for details ### OpenStreetMap Pattern - **Tile-based**: Pre-rendered raster tiles - **Vector tiles**: For 100k+ POIs - **Simplification**: Reduce detail at low zoom - **Caching**: Aggressive CDN + browser cache ## Tech Stack Compatibility ### Frameworks - ✅ Next.js 13+ (App Router + Server Components) - ✅ Next.js Pages Router - ✅ Vite + React - ✅ Remix - ✅ Astro (with client islands) ### Databases - ✅ **Supabase (PostGIS)** - Recommended, built-in spatial indexing - ✅ PostgreSQL + PostGIS - ⚠️ MongoDB (geospatial queries slower than PostGIS) - ⚠️ Firebase (limited spatial query support) ### Map Libraries - ✅ **Leaflet.js** - Best for static tiles + markers - ✅ Mapbox GL JS - Better for vector tiles - ✅ Maplibre GL JS - Open-source Mapbox alternative - ❌ Google Maps API - Expensive, less flexible ## Migration Checklist When optimizing an existing slow map: - [ ] Measure current performance (Chrome DevTools Performance tab) - [ ] Count total markers/points in dataset - [ ] Check if spatial index exists on database (`EXPLAIN ANALYZE`) - [ ] Install clustering library (`npm install use-supercluster`) - [ ] Implement viewport-based loading - [ ] Add canvas renderer option - [ ] Test on mobile device (4G throttling) - [ ] Add loading states - [ ] Implement progressive disclosure - [ ] Set up performance monitoring - [ ] Document zoom-level behaviors ## Dependencies ```json { "dependencies": { "leaflet": "^1.9.4", "react-leaflet": "^4.2.1", "supercluster": "^8.0.1", "use-supercluster": "^1.2.0", "@tanstack/react-query": "^5.0.0", "use-debounce": "^10.0.0" } } ``` ## References ### Research Papers - [Performance Testing on Marker Clustering (2019)](https://www.researchgate.net/publication/334853181) - [Spatial Indexing Performance in PostgreSQL](https://postgis.net/workshops/postgis-intro/indexing.html) ### Technical Guides - [Leaflet Performance Guide (Andrej Gajdos)](https://andrejgajdos.com/leaflet-developer-guide-to-high-performance-map-visualizations-in-react/) - [PostGIS Spatial Queries | Supabase Docs](https://supabase.com/docs/guides/database/extensions/postgis) - [Supercluster GitHub](https://github.com/mapbox/supercluster) - [use-supercluster React Hook](https://github.com/leighhalliday/use-supercluster) ### UX Research - [Map-Based UX in Real Estate (RAW Studio)](https://raw.studio/blog/using-maps-as-the-core-ux-in-real-estate-platforms/) - [Progressive Disclosure in Maps (UX Matters)](https://www.uxmatters.com/mt/archives/2020/05/designing-for-progressive-disclosure.php) ## Version History - 2026-01-09: Initial skill creation based on sobriety.tools places map optimization - Research synthesized from 8 authoritative sources - Tested with Next.js 15, Leaflet 1.9.4, Supabase PostGIS --- **Skill Author**: Claude Code (Sonnet 4.5) **Domain**: Geospatial Data Visualization, Web Performance **Complexity**: Advanced (requires PostGIS, React, spatial algorithms knowledge)