| name | composable-svelte-maps |
| description | Interactive maps and geospatial visualizations for Composable Svelte. Use when implementing maps, geolocation, markers, overlays, or geospatial data visualization. Covers Map component, markers, popups, GeoJSON layers, heatmaps, tile providers, viewport controls from @composable-svelte/maps package built with Maplibre GL. |
Composable Svelte Maps
Interactive maps and geospatial data visualization with Maplibre GL and Mapbox GL.
PACKAGE OVERVIEW
Package: @composable-svelte/maps
Purpose: State-driven interactive maps with markers, layers, and geospatial features.
Technology Stack:
- Maplibre GL: Open-source WebGL-based maps (primary)
- Mapbox GL: Optional premium provider (requires API key)
Core Features:
- Interactive maps with zoom/pan
- Markers with popups
- GeoJSON layers (polygons, lines, points)
- Heatmap visualization
- Multiple tile providers (OSM, CartoDB, Stamen, etc.)
- Viewport animations (flyTo, fitBounds)
- Feature interactions (hover, click)
State Management: All map state managed via pure reducers following Composable Architecture patterns.
QUICK START
import { createStore } from '@composable-svelte/core';
import { Map, mapReducer, createInitialMapState } from '@composable-svelte/maps';
// Create map store
const mapStore = createStore({
initialState: createInitialMapState({
center: [-74.006, 40.7128], // NYC
zoom: 12,
tileProvider: 'osm'
}),
reducer: mapReducer,
dependencies: {}
});
// Render map
<Map
store={mapStore}
width="100%"
height="600px"
/>
MAP COMPONENT
Purpose: Root container for interactive maps.
Props
store: Store<MapState, MapAction>- Map store (required)width: string | number- Width (default: '100%')height: string | number- Height (default: '600px')onMapClick: (lngLat: [number, number]) => void- Click handler (optional)children: Snippet- Child components (optional)
Usage
<Map
store={mapStore}
width="100%"
height="600px"
onMapClick={(lngLat) => console.log('Clicked:', lngLat)}
/>
Lifecycle
- Creates container element
- Initializes Maplibre/Mapbox
- Sets up manual subscription for state sync
- Dispatches
mapLoadedwhen ready - Syncs viewport, markers, layers, popups
- Cleans up on unmount
STATE MANAGEMENT
MapState Interface
interface MapState {
// Provider
provider: 'maplibre' | 'mapbox';
accessToken?: string; // Mapbox only
// Tile provider
tileProvider: TileProvider;
customTileURL?: string;
customAttribution?: string;
// Viewport
viewport: {
center: [number, number]; // [lng, lat]
zoom: number; // 0-22
bearing: number; // 0-360 (rotation)
pitch: number; // 0-60 (tilt)
bounds?: [[number, number], [number, number]];
};
// Interaction
isInteractive: boolean;
isDragging: boolean;
isZooming: boolean;
flyToTarget?: FlyToOptions;
// Markers
markers: Marker[];
// Layers
layers: Layer[];
// Popups
popups: Popup[];
// Feature interactions
hoveredFeature: FeatureReference | null;
selectedFeatures: FeatureReference[];
// Map style
style: string; // Style URL
// Loading
isLoaded: boolean;
isLoading: boolean;
error: string | null;
}
MapAction Types
type MapAction =
// Viewport
| { type: 'setCenter'; center: [number, number] }
| { type: 'setZoom'; zoom: number }
| { type: 'setBearing'; bearing: number }
| { type: 'setPitch'; pitch: number }
| { type: 'fitBounds'; bounds: [[number, number], [number, number]]; padding?: number }
| { type: 'flyTo'; center: [number, number]; zoom?: number; duration?: number }
| { type: 'resetNorth' }
// Interaction
| { type: 'zoomIn' }
| { type: 'zoomOut' }
| { type: 'panStart'; position: [number, number] }
| { type: 'panMove'; delta: [number, number] }
| { type: 'panEnd' }
// Markers
| { type: 'addMarker'; marker: Marker }
| { type: 'removeMarker'; id: string }
| { type: 'updateMarker'; id: string; updates: Partial<Marker> }
| { type: 'moveMarker'; id: string; position: [number, number] }
| { type: 'clearMarkers' }
// Layers
| { type: 'addLayer'; layer: Layer }
| { type: 'removeLayer'; id: string }
| { type: 'toggleLayerVisibility'; id: string }
| { type: 'updateLayerStyle'; id: string; style: Partial<LayerStyle> }
| { type: 'clearLayers' }
// Popups
| { type: 'openPopup'; popup: Popup }
| { type: 'closePopup'; id: string }
| { type: 'closeAllPopups' }
// Features
| { type: 'featureHovered'; feature: FeatureReference }
| { type: 'featureUnhovered' }
| { type: 'featureClicked'; feature: FeatureReference }
| { type: 'clearSelection' }
// Map lifecycle
| { type: 'viewportChanged'; viewport: MapViewport }
| { type: 'mapLoaded' }
| { type: 'mapError'; error: string }
| { type: 'changeStyle'; style: string }
| { type: 'changeTileProvider'; provider: TileProvider; customURL?: string };
Creating Initial State
import { createInitialMapState } from '@composable-svelte/maps';
const initialState = createInitialMapState({
center: [-74.006, 40.7128], // NYC
zoom: 12,
tileProvider: 'osm',
provider: 'maplibre' // Default (free)
});
VIEWPORT CONTROLS
Center & Zoom
// Set center
mapStore.dispatch({
type: 'setCenter',
center: [-122.4194, 37.7749] // San Francisco
});
// Set zoom
mapStore.dispatch({
type: 'setZoom',
zoom: 15
});
// Zoom in/out
mapStore.dispatch({ type: 'zoomIn' });
mapStore.dispatch({ type: 'zoomOut' });
Bearing & Pitch
// Rotate map (bearing)
mapStore.dispatch({
type: 'setBearing',
bearing: 45 // 0-360 degrees
});
// Tilt map (pitch)
mapStore.dispatch({
type: 'setPitch',
pitch: 30 // 0-60 degrees
});
// Reset to north
mapStore.dispatch({ type: 'resetNorth' });
Animated Navigation
// Fly to location
mapStore.dispatch({
type: 'flyTo',
center: [-0.1276, 51.5074], // London
zoom: 12,
duration: 2000 // milliseconds
});
// Fit bounds
mapStore.dispatch({
type: 'fitBounds',
bounds: [
[-74.1, 40.7], // Southwest corner
[-73.9, 40.8] // Northeast corner
],
padding: 50
});
MARKERS
Purpose: Point-of-interest indicators on the map.
Marker Interface
interface Marker<TData = unknown> {
id: string;
position: [number, number]; // [lng, lat]
icon?: string; // URL or data URI
draggable?: boolean;
data?: TData; // Custom data
popup?: {
content: string;
isOpen: boolean;
};
}
Adding Markers
mapStore.dispatch({
type: 'addMarker',
marker: {
id: 'marker-1',
position: [-74.006, 40.7128],
icon: '/icons/pin-red.png',
draggable: false,
data: { name: 'NYC', population: 8000000 },
popup: {
content: '<h3>New York City</h3><p>Population: 8M</p>',
isOpen: false
}
}
});
Updating Markers
// Update marker properties
mapStore.dispatch({
type: 'updateMarker',
id: 'marker-1',
updates: {
icon: '/icons/pin-blue.png',
popup: { content: 'Updated!', isOpen: true }
}
});
// Move marker
mapStore.dispatch({
type: 'moveMarker',
id: 'marker-1',
position: [-73.935, 40.730]
});
Removing Markers
// Remove single marker
mapStore.dispatch({
type: 'removeMarker',
id: 'marker-1'
});
// Clear all markers
mapStore.dispatch({ type: 'clearMarkers' });
LAYERS
Purpose: Visualize geospatial data (polygons, lines, heatmaps).
Layer Types
- GeoJSON: Polygon, LineString, Point, MultiPolygon, etc.
- Heatmap: Density visualization from point data
Layer Interface
interface Layer {
id: string;
type: 'geojson' | 'heatmap';
data: GeoJSON | string; // Object or URL
style: LayerStyle;
visible: boolean;
interactive: boolean;
}
interface LayerStyle {
fillColor?: string;
fillOpacity?: number;
strokeColor?: string;
strokeWidth?: number;
strokeOpacity?: number;
radius?: number; // For points
intensity?: number; // For heatmaps
colorGradient?: [number, string][]; // For heatmaps
}
GeoJSON Layer
// Add GeoJSON layer
mapStore.dispatch({
type: 'addLayer',
layer: {
id: 'neighborhoods',
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [[
[-74.0, 40.7],
[-74.0, 40.8],
[-73.9, 40.8],
[-73.9, 40.7],
[-74.0, 40.7]
]]
},
properties: { name: 'Chelsea', population: 50000 }
}
]
},
style: {
fillColor: '#3388ff',
fillOpacity: 0.5,
strokeColor: '#0066cc',
strokeWidth: 2
},
visible: true,
interactive: true
}
});
// Or load from URL
mapStore.dispatch({
type: 'addLayer',
layer: {
id: 'countries',
type: 'geojson',
data: '/data/countries.geojson',
style: { fillColor: '#88cc88', fillOpacity: 0.3 },
visible: true,
interactive: true
}
});
Heatmap Layer
mapStore.dispatch({
type: 'addLayer',
layer: {
id: 'crime-heatmap',
type: 'heatmap',
data: {
type: 'FeatureCollection',
features: crimeData.map(crime => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [crime.lng, crime.lat]
},
properties: { intensity: crime.severity }
}))
},
style: {
intensity: 1.0,
radius: 30,
colorGradient: [
[0, 'rgba(0, 0, 255, 0)'],
[0.5, 'rgba(0, 255, 255, 0.5)'],
[1, 'rgba(255, 0, 0, 1)']
]
},
visible: true,
interactive: false
}
});
Managing Layers
// Update layer style
mapStore.dispatch({
type: 'updateLayerStyle',
id: 'neighborhoods',
style: {
fillColor: '#ff8800',
fillOpacity: 0.7
}
});
// Toggle visibility
mapStore.dispatch({
type: 'toggleLayerVisibility',
id: 'neighborhoods'
});
// Remove layer
mapStore.dispatch({
type: 'removeLayer',
id: 'neighborhoods'
});
// Clear all layers
mapStore.dispatch({ type: 'clearLayers' });
POPUPS
Purpose: Display information overlays at specific locations.
Popup Interface
interface Popup {
id: string;
position: [number, number]; // [lng, lat]
content: string; // HTML content
isOpen: boolean;
closeButton?: boolean;
closeOnClick?: boolean;
}
Managing Popups
// Open popup
mapStore.dispatch({
type: 'openPopup',
popup: {
id: 'info-popup',
position: [-74.006, 40.7128],
content: '<h3>NYC</h3><p>The Big Apple</p>',
isOpen: true,
closeButton: true,
closeOnClick: true
}
});
// Close popup
mapStore.dispatch({
type: 'closePopup',
id: 'info-popup'
});
// Close all popups
mapStore.dispatch({ type: 'closeAllPopups' });
TILE PROVIDERS
Purpose: Different map styles and base layers.
Available Providers
'osm'- OpenStreetMap (default, free)'carto-light'- CartoDB Light (free)'carto-dark'- CartoDB Dark (free)'stamen-terrain'- Stamen Terrain (free)'stamen-toner'- Stamen Toner (free)'satellite'- Satellite imagery (requires Mapbox)'custom'- Custom tile URL
Changing Tile Provider
// Use built-in provider
mapStore.dispatch({
type: 'changeTileProvider',
provider: 'carto-dark'
});
// Use custom tiles
mapStore.dispatch({
type: 'changeTileProvider',
provider: 'custom',
customURL: 'https://tiles.example.com/{z}/{x}/{y}.png',
customAttribution: '© Example Maps'
});
Style Presets
// Change map style (Mapbox only)
mapStore.dispatch({
type: 'changeStyle',
style: 'mapbox://styles/mapbox/streets-v11'
});
FEATURE INTERACTIONS
Purpose: Respond to user interactions with map features (layers).
Hover
// Listen for hover
$effect(() => {
if ($mapStore.hoveredFeature) {
console.log('Hovered:', $mapStore.hoveredFeature);
// Update UI, show tooltip, etc.
}
});
// Reducer handles hover automatically from map component
Click
// Listen for click
$effect(() => {
const selected = $mapStore.selectedFeatures;
if (selected.length > 0) {
console.log('Selected features:', selected);
// Display info panel, highlight, etc.
}
});
// Clear selection
mapStore.dispatch({ type: 'clearSelection' });
FeatureReference Interface
interface FeatureReference<TData = unknown> {
layer: string; // Layer ID
featureId: string | number; // Feature ID
data?: TData; // Feature properties
}
COMPLETE EXAMPLES
Basic Map with Markers
<script lang="ts">
import { createStore } from '@composable-svelte/core';
import { Map, mapReducer, createInitialMapState } from '@composable-svelte/maps';
// Create map store
const mapStore = createStore({
initialState: createInitialMapState({
center: [-74.006, 40.7128],
zoom: 12,
tileProvider: 'osm'
}),
reducer: mapReducer,
dependencies: {}
});
// Add markers
const cities = [
{ id: 'nyc', name: 'New York', position: [-74.006, 40.7128] },
{ id: 'sf', name: 'San Francisco', position: [-122.4194, 37.7749] },
{ id: 'la', name: 'Los Angeles', position: [-118.2437, 34.0522] }
];
cities.forEach(city => {
mapStore.dispatch({
type: 'addMarker',
marker: {
id: city.id,
position: city.position,
popup: {
content: `<h3>${city.name}</h3>`,
isOpen: false
}
}
});
});
</script>
<Map store={mapStore} width="100%" height="600px" />
GeoJSON Visualization
<script lang="ts">
import { createStore } from '@composable-svelte/core';
import { Map, mapReducer, createInitialMapState } from '@composable-svelte/maps';
const mapStore = createStore({
initialState: createInitialMapState({
center: [-98.5795, 39.8283], // Center of US
zoom: 4,
tileProvider: 'carto-light'
}),
reducer: mapReducer,
dependencies: {}
});
// Load US states GeoJSON
fetch('/data/us-states.geojson')
.then(res => res.json())
.then(geojson => {
mapStore.dispatch({
type: 'addLayer',
layer: {
id: 'us-states',
type: 'geojson',
data: geojson,
style: {
fillColor: '#3388ff',
fillOpacity: 0.4,
strokeColor: '#0066cc',
strokeWidth: 2
},
visible: true,
interactive: true
}
});
});
// Handle feature clicks
$effect(() => {
const selected = $mapStore.selectedFeatures;
if (selected.length > 0) {
const state = selected[0].data;
console.log('Selected state:', state.name);
// Open popup
mapStore.dispatch({
type: 'openPopup',
popup: {
id: 'state-popup',
position: state.centroid,
content: `<h3>${state.name}</h3><p>Population: ${state.population}</p>`,
isOpen: true
}
});
}
});
</script>
<Map store={mapStore} width="100%" height="600px" />
Heatmap with Controls
<script lang="ts">
import { createStore } from '@composable-svelte/core';
import { Map, mapReducer, createInitialMapState } from '@composable-svelte/maps';
const mapStore = createStore({
initialState: createInitialMapState({
center: [-118.2437, 34.0522], // LA
zoom: 11,
tileProvider: 'carto-dark'
}),
reducer: mapReducer,
dependencies: {}
});
// Crime data (example)
const crimeData = [
{ lat: 34.05, lng: -118.25, severity: 5 },
{ lat: 34.06, lng: -118.24, severity: 8 },
// ... more points
];
// Add heatmap layer
mapStore.dispatch({
type: 'addLayer',
layer: {
id: 'crime-heatmap',
type: 'heatmap',
data: {
type: 'FeatureCollection',
features: crimeData.map(crime => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [crime.lng, crime.lat]
},
properties: { weight: crime.severity }
}))
},
style: {
intensity: 1.0,
radius: 30,
colorGradient: [
[0, 'rgba(0, 0, 255, 0)'],
[0.5, 'rgba(0, 255, 255, 0.5)'],
[1, 'rgba(255, 0, 0, 1)']
]
},
visible: true,
interactive: false
}
});
// Intensity control
let intensity = $state(1.0);
$effect(() => {
mapStore.dispatch({
type: 'updateLayerStyle',
id: 'crime-heatmap',
style: { intensity }
});
});
</script>
<div>
<Map store={mapStore} width="100%" height="600px" />
<div class="controls">
<label>
Intensity: {intensity.toFixed(1)}
<input
type="range"
bind:value={intensity}
min="0"
max="2"
step="0.1"
/>
</label>
</div>
</div>
COMMON PATTERNS
User Location
navigator.geolocation.getCurrentPosition(
(position) => {
const userLocation = [position.coords.longitude, position.coords.latitude];
// Center on user
mapStore.dispatch({
type: 'flyTo',
center: userLocation,
zoom: 15,
duration: 1500
});
// Add marker
mapStore.dispatch({
type: 'addMarker',
marker: {
id: 'user-location',
position: userLocation,
icon: '/icons/user-pin.png'
}
});
},
(error) => console.error('Geolocation error:', error)
);
Draggable Markers
mapStore.dispatch({
type: 'addMarker',
marker: {
id: 'draggable-pin',
position: [-74.006, 40.7128],
draggable: true
}
});
// Listen for updates
$effect(() => {
const marker = $mapStore.markers.find(m => m.id === 'draggable-pin');
if (marker) {
console.log('Marker position:', marker.position);
}
});
Layer Toggle
let showLayer = $state(true);
$effect(() => {
if (showLayer) {
mapStore.dispatch({
type: 'addLayer',
layer: myLayer
});
} else {
mapStore.dispatch({
type: 'removeLayer',
id: myLayer.id
});
}
});
<button onclick={() => showLayer = !showLayer}>
{showLayer ? 'Hide' : 'Show'} Layer
</button>
Viewport Sync
// Sync viewport between two maps
const map1Store = createStore({...});
const map2Store = createStore({...});
$effect(() => {
const viewport = $map1Store.viewport;
map2Store.dispatch({ type: 'viewportChanged', viewport });
});
PERFORMANCE CONSIDERATIONS
Large GeoJSON Files
Problem: Loading large GeoJSON (10MB+) can freeze UI.
Solutions:
- Simplify geometry: Use tools like
mapshaperto reduce vertices - Tile vector data: Use vector tiles (
.pbf) instead of GeoJSON - Load progressively: Stream features in chunks
// Example: Load in chunks
async function loadLargeGeoJSON(url: string) {
const response = await fetch(url);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let features = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse complete features
// (simplified - actual implementation more complex)
const parsed = parsePartialGeoJSON(buffer);
features.push(...parsed.features);
buffer = parsed.remaining;
// Update map periodically
if (features.length >= 100) {
mapStore.dispatch({
type: 'addLayer',
layer: {
id: 'large-layer',
type: 'geojson',
data: { type: 'FeatureCollection', features },
style: myStyle,
visible: true,
interactive: true
}
});
features = [];
}
}
}
Many Markers
Problem: 1000+ markers slow down rendering.
Solutions:
- Clustering: Group nearby markers
- Viewport culling: Only show markers in view
- Use GeoJSON layer: More efficient than individual markers
Heatmap Performance
Problem: Heatmaps with 10,000+ points lag.
Solutions:
- Reduce radius: Smaller radius = less overlap = faster
- Lower intensity: Less blending computation
- Downsample: Show fewer points when zoomed out
TESTING
Basic Map Testing
import { TestStore } from '@composable-svelte/core';
import { mapReducer, createInitialMapState } from '@composable-svelte/maps';
const store = new TestStore({
initialState: createInitialMapState({
center: [0, 0],
zoom: 10
}),
reducer: mapReducer,
dependencies: {}
});
// Test viewport change
await store.send({
type: 'setCenter',
center: [-74.006, 40.7128]
}, (state) => {
expect(state.viewport.center).toEqual([-74.006, 40.7128]);
});
await store.send({
type: 'setZoom',
zoom: 15
}, (state) => {
expect(state.viewport.zoom).toBe(15);
});
Marker Testing
await store.send({
type: 'addMarker',
marker: {
id: 'test-marker',
position: [0, 0]
}
}, (state) => {
expect(state.markers.length).toBe(1);
expect(state.markers[0].id).toBe('test-marker');
});
await store.send({
type: 'moveMarker',
id: 'test-marker',
position: [1, 1]
}, (state) => {
expect(state.markers[0].position).toEqual([1, 1]);
});
await store.send({
type: 'removeMarker',
id: 'test-marker'
}, (state) => {
expect(state.markers.length).toBe(0);
});
TROUBLESHOOTING
Map not rendering:
- Check Maplibre GL CSS imported:
import 'maplibre-gl/dist/maplibre-gl.css' - Verify container has explicit height set
- Check browser console for initialization errors
Markers not appearing:
- Verify position format:
[longitude, latitude](not[lat, lng]) - Check marker is within viewport bounds
- Ensure icon URL is valid (if using custom icon)
GeoJSON not showing:
- Validate GeoJSON format (use online validator)
- Check coordinates are
[lng, lat]order - Verify layer
visible: true
Poor performance:
- Simplify geometry (reduce vertices)
- Use vector tiles for large datasets
- Cluster markers when zoomed out
- Reduce heatmap radius/intensity
Tile provider not working:
- Check network requests in DevTools
- Verify custom tile URL format includes
{z}/{x}/{y} - Some providers require attribution in UI
CROSS-REFERENCES
Related Skills:
- composable-svelte-core: Store, reducer, Effect system
- composable-svelte-components: UI components (Button, Slider, etc.)
- composable-svelte-testing: TestStore for testing map reducers
When to Use Each Package:
- maps: Geospatial data, interactive maps, markers, GeoJSON
- charts: 2D data visualization (see composable-svelte-charts)
- graphics: 3D scenes, WebGPU/WebGL (see composable-svelte-graphics)
- code: Code editors, media players (see composable-svelte-code)