Composable Svelte Maps
Interactive maps and geospatial data visualization with Maplibre GL, and an optional Mapbox GL adapter.
UPGRADE 1 AGENT ENTRY
For an application built with the integrated Upgrade 1 companion packages, begin with the managed package reference and its executable recipe. The same reference is included in the package at node_modules/@composable-svelte/maps/MANAGED.md; use the installed version's declarations and instructions as the API authority.
Use genuine child views and the typed native-event actions in the shipped managed recipe. The attachment owns the supplied adapter lifetime and disposes it on retirement; do not keep a live map engine attached to a retired feature for exit animation.
The standalone store and callback examples below describe standalone usage. For an owned application feature, follow the managed recipe rather than copying the standalone setup and adding ad hoc lifetime glue. Candidate qualification and npm publication are separate; verify the installed package version contains this managed surface.
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)
- Injectable adapters:
MapAdapteris the contract. MapLibre is the default;MapboxAdaptercomes from@composable-svelte/maps/mapboxand needs the optionalmapbox-glpeer installed. Never import that entry from code that must work without it.
Core Features:
- Interactive maps with zoom/pan
- Markers with popups
- GeoJSON layers (polygons, lines, points)
- Heatmap visualization
- Multiple tile providers (OpenStreetMap, Stadia, MapTiler, CARTO, custom)
- Viewport animations (flyTo, fitBounds)
- Feature interactions (hover, click)
State Management: All map state managed via pure reducers following Composable Architecture patterns.
QUICK START
<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], // NYC
zoom: 12,
tileProvider: 'osm'
}),
reducer: mapReducer,
dependencies: {}
});
</script>
<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 the injected adapter (MapLibre unless one is passed)
- 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 {
// API key for the tile provider (maptiler), or the Mapbox access token when
// using MapboxAdapter. There is no `provider` field: the engine is supplied
// as the `adapter` prop, not named in state.
accessToken?: string;
// 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
Taken from TILE_PROVIDERS in src/lib/utils/tile-providers.ts, which is the
only place these are defined:
'openstreetmap'- MapLibre demo tiles (default, free)'stadia'- Stadia Maps OSM Bright (free)'maptiler'- MapTiler Streets (needs an API key, passed asaccessToken)'carto-light'- CARTO Positron (free)'carto-dark'- CARTO Dark Matter (free)'custom'- your own style URL viacustomTileURL
'mapbox' was removed: its style URL used the mapbox:// scheme, which MapLibre
cannot resolve, so selecting it produced a broken map rather than an unstyled
one. Mapbox styles need MapboxAdapter.
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
// Any style URL the active adapter can load. Under MapLibre that means an
// http(s) URL; `mapbox://` styles work only with MapboxAdapter.
mapStore.dispatch({
type: 'changeStyle',
style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'
});
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/test';
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
ALL EXPORTS
Components
Map— Main map componentMapPrimitive— Low-level map for advanced customizationGeoJSONLayer— 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 reducercreateInitialMapState(config)— Initial state factory
Adapters & Utilities
createMapAdapter(config)— Create a map adapter instanceMaplibreAdapter— Maplibre GL adapter (default)TILE_PROVIDERS— Constant with all built-in tile provider configsgetTileProviderConfig(provider)— Get config for a specific tile providergetStyleURL(provider)— Get the style/tile URL for a providergetAvailableTileProviders()— List all available tile providersrequiresAPIKey(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, WebGL (see composable-svelte-graphics)
- code: Code editors, media players (see composable-svelte-code)