| 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';
const mapStore = createStore({
initialState: createInitialMapState({
center: [-74.006, 40.7128],
zoom: 12,
tileProvider: 'osm'
}),
reducer: mapReducer,
dependencies: {}
});
<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
mapLoaded when ready
- Syncs viewport, markers, layers, popups
- Cleans up on unmount
STATE MANAGEMENT
MapState Interface
interface MapState {
provider: 'maplibre' | 'mapbox';
accessToken?: string;
tileProvider: TileProvider;
customTileURL?: string;
customAttribution?: string;
viewport: {
center: [number, number];
zoom: number;
bearing: number;
pitch: number;
bounds?: [[number, number], [number, number]];
};
isInteractive: boolean;
isDragging: boolean;
isZooming: boolean;
flyToTarget?: FlyToOptions;
markers: Marker[];
layers: Layer[];
popups: Popup[];
hoveredFeature: FeatureReference | null;
selectedFeatures: FeatureReference[];
style: string;
isLoaded: boolean;
isLoading: boolean;
error: string | null;
}
MapAction Types
type MapAction =
| { 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' }
| { type: 'zoomIn' }
| { type: 'zoomOut' }
| { type: 'panStart'; position: [number, number] }
| { type: 'panMove'; delta: [number, number] }
| { type: 'panEnd' }
| { type: 'addMarker'; marker: Marker }
| { type: 'removeMarker'; id: string }
| { type: 'updateMarker'; id: string; updates: Partial<Marker> }
| { type: 'moveMarker'; id: string; position: [number, number] }
| { type: 'clearMarkers' }
| { type: 'addLayer'; layer: Layer }
| { type: 'removeLayer'; id: string }
| { type: 'toggleLayerVisibility'; id: string }
| { type: 'updateLayerStyle'; id: string; style: Partial<LayerStyle> }
| { type: 'clearLayers' }
| { type: 'openPopup'; popup: Popup }
| { type: 'closePopup'; id: string }
| { type: 'closeAllPopups' }
| { type: 'featureHovered'; feature: FeatureReference }
| { type: 'featureUnhovered' }
| { type: 'featureClicked'; feature: FeatureReference }
| { type: 'clearSelection' }
| { 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],
zoom: 12,
tileProvider: 'osm',
provider: 'maplibre'
});
VIEWPORT CONTROLS
Center & Zoom
mapStore.dispatch({
type: 'setCenter',
center: [-122.4194, 37.7749]
});
mapStore.dispatch({
type: 'setZoom',
zoom: 15
});
mapStore.dispatch({ type: 'zoomIn' });
mapStore.dispatch({ type: 'zoomOut' });
Bearing & Pitch
mapStore.dispatch({
type: 'setBearing',
bearing: 45
});
mapStore.dispatch({
type: 'setPitch',
pitch: 30
});
mapStore.dispatch({ type: 'resetNorth' });
Animated Navigation
mapStore.dispatch({
type: 'flyTo',
center: [-0.1276, 51.5074],
zoom: 12,
duration: 2000
});
mapStore.dispatch({
type: 'fitBounds',
bounds: [
[-74.1, 40.7],
[-73.9, 40.8]
],
padding: 50
});
MARKERS
Purpose: Point-of-interest indicators on the map.
Marker Interface
interface Marker<TData = unknown> {
id: string;
position: [number, number];
icon?: string;
draggable?: boolean;
data?: TData;
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
mapStore.dispatch({
type: 'updateMarker',
id: 'marker-1',
updates: {
icon: '/icons/pin-blue.png',
popup: { content: 'Updated!', isOpen: true }
}
});
mapStore.dispatch({
type: 'moveMarker',
id: 'marker-1',
position: [-73.935, 40.730]
});
Removing Markers
mapStore.dispatch({
type: 'removeMarker',
id: 'marker-1'
});
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;
style: LayerStyle;
visible: boolean;
interactive: boolean;
}
interface LayerStyle {
fillColor?: string;
fillOpacity?: number;
strokeColor?: string;
strokeWidth?: number;
strokeOpacity?: number;
radius?: number;
intensity?: number;
colorGradient?: [number, string][];
}
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
}
});
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
mapStore.dispatch({
type: 'updateLayerStyle',
id: 'neighborhoods',
style: {
fillColor: '#ff8800',
fillOpacity: 0.7
}
});
mapStore.dispatch({
type: 'toggleLayerVisibility',
id: 'neighborhoods'
});
mapStore.dispatch({
type: 'removeLayer',
id: 'neighborhoods'
});
mapStore.dispatch({ type: 'clearLayers' });
POPUPS
Purpose: Display information overlays at specific locations.
Popup Interface
interface Popup {
id: string;
position: [number, number];
content: string;
isOpen: boolean;
closeButton?: boolean;
closeOnClick?: boolean;
}
Managing Popups
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
}
});
mapStore.dispatch({
type: 'closePopup',
id: 'info-popup'
});
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
mapStore.dispatch({
type: 'changeTileProvider',
provider: 'carto-dark'
});
mapStore.dispatch({
type: 'changeTileProvider',
provider: 'custom',
customURL: 'https://tiles.example.com/{z}/{x}/{y}.png',
customAttribution: '© Example Maps'
});
Style Presets
mapStore.dispatch({
type: 'changeStyle',
style: 'mapbox://styles/mapbox/streets-v11'
});
FEATURE INTERACTIONS
Purpose: Respond to user interactions with map features (layers).
Hover
$effect(() => {
if ($mapStore.hoveredFeature) {
console.log('Hovered:', $mapStore.hoveredFeature);
}
});
Click
$effect(() => {
const selected = $mapStore.selectedFeatures;
if (selected.length > 0) {
console.log('Selected features:', selected);
}
});
mapStore.dispatch({ type: 'clearSelection' });
FeatureReference Interface
interface FeatureReference<TData = unknown> {
layer: string;
featureId: string | number;
data?: TData;
}
COMPLETE EXAMPLES
Basic Map with Markers
<script lang="ts">
import { createStore } from '@composable-svelte/core';
import { Map, mapReducer, createInitialMapState } from '@composable-svelte/maps';
const mapStore = createStore({
initialState: createInitialMapState({
center: [-74.006, 40.7128],
zoom: 12,
tileProvider: 'osm'
}),
reducer: mapReducer,
dependencies: {}
});
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],
zoom: 4,
tileProvider: 'carto-light'
}),
reducer: mapReducer,
dependencies: {}
});
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
}
});
});
$effect(() => {
const selected = $mapStore.selectedFeatures;
if (selected.length > 0) {
const state = selected[0].data;
console.log('Selected state:', state.name);
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],
zoom: 11,
tileProvider: 'carto-dark'
}),
reducer: mapReducer,
dependencies: {}
});
const crimeData = [
{ lat: 34.05, lng: -118.25, severity: 5 },
{ lat: 34.06, lng: -118.24, severity: 8 },
];
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
}
});
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];
mapStore.dispatch({
type: 'flyTo',
center: userLocation,
zoom: 15,
duration: 1500
});
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
}
});
$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
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
mapshaper to reduce vertices
- Tile vector data: Use vector tiles (
.pbf) instead of GeoJSON
- Load progressively: Stream features 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 });
const parsed = parsePartialGeoJSON(buffer);
features.push(...parsed.features);
buffer = parsed.remaining;
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: {}
});
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
ALL EXPORTS
Components
Map — Main map component
MapPrimitive — Low-level map for advanced customization
GeoJSONLayer — Render GeoJSON data on map (child of Map)
HeatmapLayer — Point density heatmap visualization (child of Map)
MapPopup — Popup anchored to a map position (child of Map)
TileProviderControl — UI control to switch tile providers (child of Map)
State Management
mapReducer — Map state reducer
createInitialMapState(config) — Initial state factory
Adapters & Utilities
createMapAdapter(config) — Create a map adapter instance
MaplibreAdapter — Maplibre GL adapter (default)
TILE_PROVIDERS — Constant with all built-in tile provider configs
getTileProviderConfig(provider) — Get config for a specific tile provider
getStyleURL(provider) — Get the style/tile URL for a provider
getAvailableTileProviders() — List all available tile providers
requiresAPIKey(provider) — Check if provider needs an API key
Types
MapProvider, TileProvider, LngLat, BBox, MapViewport, Marker, GeoJSON, LayerStyle, Layer, Popup, FeatureReference, MapState, MapAction, FlyToOptions, MapAdapter, MapInitOptions, TileProviderConfig
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)