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.
npx skills add https://github.com/curiositech/some_claude_skills --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, spatial clustering algorithms, viewport-based loading, and progressive disclosure UX patterns for map-based applications.
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)
┌─────────────────────────────────────────────────────────────┐
│ 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 │
│ │
└─────────────────────────────────────────────────────────────┘
| 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 |
Why Supercluster beats alternatives:
Implementation Pattern:
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>
);
}
Database Function:
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:
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
});
}
Show appropriate detail levels based on zoom:
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;
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
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]
});
}
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);
}, []);
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:
{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>
)}
{!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>
)}
// BAD: Fetches 10k records on mount
const { data } = useQuery(["all-places"], fetchAllPlaces);
// BAD: Updates state on every pixel
map.on("move", () => setBounds(map.getBounds()));
// BAD: React component per marker
<Marker icon={<ComplexSVGComponent />} />
// BAD: Same clustering at all zoom levels
const clusterOptions = { radius: 80, maxZoom: 20 };
When optimizing an existing slow map:
EXPLAIN ANALYZE)npm install use-supercluster){
"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"
}
}
Skill Author: Claude Code (Sonnet 4.5)
Domain: Geospatial Data Visualization, Web Performance
Complexity: Advanced (requires PostGIS, React, spatial algorithms knowledge)
Automate YouTube tasks via Rube MCP (Composio): upload videos, manage playlists, search content, get analytics, and handle comments. Always search tools first for current schemas.
Create and audit truthful, accessible, publication-ready scientific figures with Matplotlib, Seaborn, or Plotly. Use for figure design, multi-panel layouts, uncertainty and missing-data displays, color/contrast review, image metadata validation, and journal export planning.
This skill should be used when comparing two videos to analyze compression results or quality differences. Generates interactive HTML reports with quality metrics (PSNR, SSIM) and frame-by-frame visual comparisons. Triggers when users mention "compare videos", "video quality", "compression analysis", "before/after compression", or request quality assessment of compressed videos.
Python bridge to ImageJ2/Fiji for macros, plugins (Bio-Formats, TrackMate, Analyze Particles), NumPy↔ImagePlus/ImgLib2 exchange, and ImageJ Ops. Automates Fiji headlessly from Python. Use scikit-image for pure Python without Fiji plugins; napari for visualization.
Create 3D scenes, interactive experiences, and visual effects using Three.js. Use when user requests 3D graphics, WebGL experiences, 3D visualizations, animations, or interactive 3D elements.
Generate publication-quality PNG chart images from data, supporting line, bar, area, candlestick, pie, and heatmap charts. Triggers when the user asks to visualize data, create a graph, plot a time series, or generate a chart for a report, alert, or dashboard. Runs as a lightweight, headless Node.js process without a browser.
Performs deep Root Cause Analysis (RCA) on NVIDIA TAO Visual ChangeNet classification experiments with image-evidence-driven investigation. Use when analyzing ChangeNet model failures, investigating poor recall / FAR / PASS-NO_PASS metrics, auditing visual inspection pipeline quality, or running an RCA report for an AOI defect-detection model. Trigger phrases include "RCA on my ChangeNet model", "why is my AOI model failing", "audit ChangeNet predictions", "investigate FAR regressions", "root cause analysis on visual-changenet".
Build 3D web apps with Three.js (WebGL/WebGPU). Use for 3D scenes, animations, custom shaders, PBR materials, VR/XR experiences, games, data visualizations, product configurators.
Take curiositech/large-scale-map-visualization from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference npm.
Without those the skill loads but fails at the first command.