Agent skill

large-scale-map-visualization

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.

Stars 81
Forks 12

Install this agent skill to your Project

npx add-skill https://github.com/curiositech/some_claude_skills/tree/main/.claude/skills/large-scale-map-visualization

Metadata

Additional technical details for this skill

tags
maps leaflet geospatial clustering performance visualization supercluster react
category
Data & Analytics
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"
    }
]

SKILL.md

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<L.Map | null>(null);
  const [bounds, setBounds] = useState<BBox | null>(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 (
    <MapContainer ref={mapRef} preferCanvas={true}>
      {clusters.map(cluster => {
        const [lng, lat] = cluster.geometry.coordinates;
        const { cluster: isCluster, point_count } = cluster.properties;

        if (isCluster) {
          return (
            <Marker
              key={`cluster-${cluster.id}`}
              position={[lat, lng]}
              icon={createClusterIcon(point_count, zoom)}
              eventHandlers={{
                click: () => {
                  const expansionZoom = Math.min(
                    supercluster!.getClusterExpansionZoom(cluster.id),
                    18
                  );
                  mapRef.current?.setView([lat, lng], expansionZoom, {
                    animate: true
                  });
                }
              }}
            />
          );
        }

        return (
          <PlaceMarker
            key={cluster.properties.placeId}
            place={cluster.properties.place}
          />
        );
      })}
    </MapContainer>
  );
}

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: `
      <div style="
        width: ${size}px;
        height: ${size}px;
        background: linear-gradient(135deg, #d97706, #f59e0b);
        border-radius: 50%;
        border: 3px solid #1a1410;
        display: flex;
        align-items: center;
        justify-content: center;
        color: white;
        font-weight: bold;
        font-size: ${zoom < 12 ? '10px' : '14px'};
        box-shadow: 0 4px 12px rgba(0,0,0,0.4);
      ">
        ${count}
      </div>
    `,
    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 && (
  <div className="absolute inset-0 bg-leather-900/50 backdrop-blur-sm z-[1000] flex items-center justify-center">
    <div className="text-sand-100">
      Loading {loadedCount} of {totalCount} locations...
    </div>
  </div>
)}

Empty States

tsx
{!isLoading && clusters.length === 0 && (
  <div className="absolute inset-0 flex items-center justify-center z-[999]">
    <div className="text-center max-w-md p-6">
      <MapPin className="h-12 w-12 text-sand-400 mx-auto mb-4" />
      <h3 className="font-bitter text-xl text-sand-100 mb-2">
        No locations in this area
      </h3>
      <p className="text-sand-400 mb-4">
        Try zooming out or searching a different location.
      </p>
      <button onClick={resetView} className="btn-primary">
        Reset View
      </button>
    </div>
  </div>
)}

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
    <Marker icon={<ComplexSVGComponent />} />
    
  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

Technical Guides

UX Research

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)

Expand your agent's capabilities with these related and highly-rated skills.

curiositech/some_claude_skills

3d-cv-labeling-2026

Expert in 3D computer vision labeling tools, workflows, and AI-assisted annotation for LiDAR, point clouds, and sensor fusion. Covers SAM4D/Point-SAM, human-in-the-loop architectures, and vertical-specific training strategies. Activate on '3D labeling', 'point cloud annotation', 'LiDAR labeling', 'SAM 3D', 'SAM4D', 'sensor fusion annotation', '3D bounding box', 'semantic segmentation point cloud'. NOT for 2D image labeling (use clip-aware-embeddings), general ML training (use ml-engineer), video annotation without 3D (use computer-vision-pipeline), or VLM prompt engineering (use prompt-engineer).

81 12
Explore
curiositech/some_claude_skills

project-management-guru-adhd

Expert project manager for ADHD engineers managing multiple concurrent projects. Specializes in hyperfocus management, context-switching minimization, and parakeet-style gentle reminders. Activate on 'ADHD project management', 'context switching', 'hyperfocus', 'task prioritization', 'multiple projects', 'productivity for ADHD', 'task chunking', 'deadline management'. NOT for neurotypical project management, rigid waterfall processes, or general productivity advice without ADHD context.

81 12
Explore
curiositech/some_claude_skills

adhd-design-expert

Designs digital experiences for ADHD brains using neuroscience research and UX principles. Expert in reducing cognitive load, time blindness solutions, dopamine-driven engagement, and compassionate design patterns. Activate on 'ADHD design', 'cognitive load', 'accessibility', 'neurodivergent UX', 'time blindness', 'dopamine-driven', 'executive function'. NOT for general accessibility (WCAG only), neurotypical UX design, or simple UI styling without ADHD context.

81 12
Explore
curiositech/some_claude_skills

liaison

Translate multi-agent ecosystem activity into human-readable status briefings, decision requests, and progress summaries. Use for 'status update', 'brief me', 'what happened', 'summarize progress'. NOT for project planning (use project-management-guru-adhd), code review, or technical documentation.

81 12
Explore
curiositech/some_claude_skills

windows-95-web-designer

Modern web applications with authentic Windows 95 aesthetic. Gradient title bars, Start menu paradigm, taskbar patterns, 3D beveled chrome. Extrapolates Win95 to AI chatbots, mobile UIs, responsive layouts. Activate on 'windows 95', 'win95', 'start menu', 'taskbar', 'retro desktop', '95 aesthetic', 'clippy'. NOT for Windows 3.1 (use windows-3-1-web-designer), vaporwave/synthwave, macOS, flat design.

81 12
Explore
curiositech/some_claude_skills

wedding-immortalist

Transform thousands of wedding photos and hours of footage into an immersive 3D Gaussian Splatting experience with theatre mode replay, face-clustered guest roster, and AI-curated best photos per person. Expert in 3DGS pipelines, face clustering, aesthetic scoring, and adaptive design matching the couple's wedding theme (disco, rustic, modern, LGBTQ+ celebrations). Activate on "wedding photos", "wedding video", "3D wedding", "Gaussian Splatting wedding", "wedding memory", "wedding immortalize", "face clustering wedding", "best wedding photos". NOT for general photo editing (use native-app-designer), non-wedding 3DGS (use drone-inspection-specialist), or event planning (not a wedding planner).

81 12
Explore

Didn't find tool you were looking for?

Be as detailed as possible for better results