Interactive maps,
without the weight.
CargoCanvas is a JavaScript library for building fast, mobile-friendly interactive maps. No dependencies, a class-based ES module API, and about 40 kB of gzipped JavaScript.
Every map on this page is live and running the real build of the library. Open Source under any of them to see the exact code that produced it — the snippet shown is the very string the demo executes, so it cannot drift out of date. All of them are centred on Curitiba, PR.
Prefer a complete file you can copy wholesale? Every topic below also exists as a standalone example page.
Installation
CargoCanvas ships as an ES module with a UMD fallback, plus one stylesheet.
npm
npm install cargocanvasScript tag
The UMD build exposes a single global, CargoCanvas:
<link rel="stylesheet" href="/dist/cargo-canvas.css" />
<script src="/dist/cargo-canvas-global.js"></script>
<script>
const map = new CargoCanvas.CargoMap('map', {center: [-25.4284, -49.2733], zoom: 12});
</script>The stylesheet filename matters. The default marker icon resolves its image path by looking for a <link> whose href ends in cargo-canvas.css. If you rename or inline the stylesheet, set iconUrl on your icons explicitly.
Quick start
A map needs three things: a container with a height, a view, and a source of tiles.
1. Give the container a height
This is the single most common reason a map renders blank — a <div> with no height is zero pixels tall.
<div id="map" style="height: 400px"></div>2. Create the map
import {CargoMap, TileLayer, Marker} from 'cargocanvas';
import 'cargocanvas/styles.css';
const map = new CargoMap('map', {
center: [-25.4284, -49.2733],
zoom: 12,
});
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19,
}).addTo(map);
new Marker([-25.4284, -49.2733]).bindPopup('Hello').addTo(map);There are no factory functions. CargoCanvas is class-based throughout: write new Marker(…), never marker(…). Every class is a named export.
Coordinates
CargoCanvas takes coordinates as [latitude, longitude] — the opposite order from GeoJSON, which uses [longitude, latitude]. The GeoJSON layer handles that conversion for you; everywhere else, latitude comes first.
A map and a tile layer
TileLayer takes a URL template with {z}, {x} and {y} placeholders. Any XYZ raster source works — CargoCanvas is provider-agnostic.
Every snippet below is extracted from the code that produced the map above it. They all share these two constants, declared once at the top of lib/demos.js:
const OSM = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
const OSM_ATTR = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';Basic map
Drag to pan, scroll to zoom, double-click to zoom in.
const map = new CargoMap(el, {
center: CURITIBA,
zoom: 13,
});
new TileLayer(OSM, {
attribution: OSM_ATTR,
maxZoom: 19,
}).addTo(map);Markers, popups and tooltips
bindPopup opens on click; bindTooltip opens on hover. Both accept a string, an HTMLElement, or a function returning either.
Three markers, three presentations
Default icon with a popup, default icon with a tooltip, and a custom DivIcon.
const map = new CargoMap(el, {center: CURITIBA, zoom: 13});
new TileLayer(OSM, {attribution: OSM_ATTR, maxZoom: 19}).addTo(map);
// Default icon, with a popup that opens on click
new Marker([-25.4296, -49.2719])
.bindPopup('<strong>Praça Tiradentes</strong><br>The historical centre of Curitiba.')
.addTo(map);
// Default icon, with a tooltip that opens on hover
new Marker([-25.4415, -49.2397])
.bindTooltip('Jardim Botânico')
.addTo(map);
// A DivIcon is styled entirely with HTML and CSS — no image needed
const badge = new DivIcon({
html: '<span class="pin">CD</span>',
className: 'pin-wrap',
iconSize: [34, 34],
});
new Marker([-25.4374, -49.2620], {icon: badge})
.bindPopup('<strong>Rodoferroviária</strong><br>A DivIcon is just markup — style it however you like.')
.addTo(map);Vector layers
Paths share a common styling vocabulary: color, weight, opacity, fill, fillColor, fillOpacity. Note that Circle takes a radius in metres, while CircleMarker takes it in pixels.
Circle, polygon, polyline, rectangle
Click any shape for its popup. Zoom out — the circle scales with the map, in metres.
const map = new CargoMap(el, {center: CURITIBA, zoom: 12});
new TileLayer(OSM, {attribution: OSM_ATTR, maxZoom: 19}).addTo(map);
// Circle takes a radius in metres, so it stays true to scale as you zoom
new Circle(CURITIBA, {
radius: 2500,
color: '#1f6feb',
fillOpacity: 0.12,
}).bindPopup('Same-day delivery radius — 2.5 km').addTo(map);
// Roughly the outline of Parque Barigui
new Polygon([
[-25.4155, -49.3130],
[-25.4160, -49.3040],
[-25.4300, -49.3055],
[-25.4330, -49.3125],
], {color: '#2fa36b', weight: 2}).bindPopup('Parque Barigui').addTo(map);
// A route: bus terminal → centre → museum → Parque Tanguá
new Polyline([
[-25.4374, -49.2620],
[-25.4296, -49.2719],
[-25.4098, -49.2660],
[-25.3846, -49.2757],
[-25.3781, -49.2921],
], {color: '#e05252', weight: 4}).bindPopup('Collection route').addTo(map);
// Rectangle takes LatLngBounds, not a list of corners
new Rectangle(new LatLngBounds(
[-25.4100, -49.3300],
[-25.3900, -49.3050],
), {color: '#b3591a', weight: 2}).bindPopup('Santa Felicidade sector').addTo(map);GeoJSON
The GeoJSON layer accepts a Feature, a FeatureCollection or a raw geometry. Three hooks cover almost everything: style for paths, pointToLayer to decide what a Point becomes, and onEachFeature to wire up interactions.
Styled FeatureCollection
Points become CircleMarkers sized by a property; the polygon is styled by its kind.
const map = new CargoMap(el, {center: [-25.4450, -49.2900], zoom: 12});
new TileLayer(OSM, {attribution: OSM_ATTR, maxZoom: 19}).addTo(map);
// Remember: GeoJSON coordinates are [longitude, latitude] — the reverse
// of the [lat, lng] order used everywhere else in the API.
const network = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {name: 'CD Cidade Industrial', kind: 'hub', load: 0.86},
geometry: {type: 'Point', coordinates: [-49.3400, -25.4900]},
},
{
type: 'Feature',
properties: {name: 'Depósito Boqueirão', kind: 'depot', load: 0.41},
geometry: {type: 'Point', coordinates: [-49.2450, -25.4800]},
},
{
type: 'Feature',
properties: {name: 'Cross-dock Rodoferroviária', kind: 'depot', load: 0.63},
geometry: {type: 'Point', coordinates: [-49.2620, -25.4374]},
},
{
type: 'Feature',
properties: {name: 'Zona de entrega — Centro', kind: 'zone'},
geometry: {
type: 'Polygon',
coordinates: [[
[-49.2900, -25.4150],
[-49.2500, -25.4150],
[-49.2500, -25.4450],
[-49.2900, -25.4450],
[-49.2900, -25.4150],
]],
},
},
],
};
new GeoJSON(network, {
// style() is applied to polygons and lines
style: feature => ({
color: feature.properties.kind === 'zone' ? '#7b4ddb' : '#1f6feb',
weight: 2,
fillOpacity: 0.12,
}),
// pointToLayer() decides what a GeoJSON Point becomes
pointToLayer: (feature, latlng) => new CircleMarker(latlng, {
radius: 6 + (feature.properties.load ?? 0) * 12,
color: feature.properties.kind === 'hub' ? '#e05252' : '#2fa36b',
fillOpacity: 0.7,
}),
// onEachFeature() wires up interactions, feature by feature
onEachFeature: (feature, layer) => {
const {name, kind, load} = feature.properties;
const pct = load === undefined ? '' : `<br>Occupancy: ${Math.round(load * 100)}%`;
layer.bindPopup(`<strong>${name}</strong><br>${kind}${pct}`);
},
}).addTo(map);Controls
The zoom and attribution controls are added automatically — disable them with the zoomControl: false and attributionControl: false map options. Others you add yourself.
Layer switcher and scale bar
Switch base layers with the control at the top right; toggle the coverage overlay.
const map = new CargoMap(el, {center: CURITIBA, zoom: 12});
// Base layers are mutually exclusive — only one shows at a time
const streets = new TileLayer(OSM, {attribution: OSM_ATTR, maxZoom: 19});
const humanitarian = new TileLayer('https://tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
attribution: OSM_ATTR,
maxZoom: 19,
});
// Whichever base layer you add to the map is the one selected on load
streets.addTo(map);
// Overlays toggle independently of each other
const coverage = new Circle(CURITIBA, {
radius: 5000,
color: '#1f6feb',
fillOpacity: 0.1,
});
new LayersControl(
{'Streets': streets, 'Humanitarian': humanitarian},
{'Coverage area': coverage},
).addTo(map);
new ScaleControl({position: 'bottomleft'}).addTo(map);| Control | Purpose |
|---|---|
ZoomControl | Zoom in / out buttons. Added by default. |
AttributionControl | Data attribution line. Added by default. |
LayersControl | Switch base layers, toggle overlays. |
ScaleControl | Metric / imperial scale bar. |
Control | Base class — extend it for your own. |
Canvas renderer
By default every path is an SVG element. That is convenient but does not scale — a few thousand DOM nodes will stutter. Pass a shared Canvas renderer and the same paths are drawn into a single <canvas>.
2,000 circle markers
Rendered to one canvas element. Pan and zoom stay smooth.
const map = new CargoMap(el, {center: CURITIBA, zoom: 11});
new TileLayer(OSM, {attribution: OSM_ATTR, maxZoom: 19}).addTo(map);
// One renderer instance, shared by every path below
const renderer = new Canvas();
// Deterministic pseudo-random, so the demo looks the same on every load
let seed = 42;
const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
for (let i = 0; i < 2000; i++) {
new CircleMarker([
CURITIBA[0] + (rand() - 0.5) * 0.28,
CURITIBA[1] + (rand() - 0.5) * 0.34,
], {
renderer,
radius: 3,
stroke: false,
fillColor: '#1f6feb',
fillOpacity: 0.5,
}).addTo(map);
}You can also set renderer once as a map option so every path inherits it, rather than passing it to each layer.
Events
Every class extends Evented, so on, once, off and fire are available everywhere — on the map, on layers, on controls.
Click to place, click again to remove
Mouse events carry a latlng; zoom events carry the new view.
const map = new CargoMap(el, {center: CURITIBA, zoom: 13});
new TileLayer(OSM, {attribution: OSM_ATTR, maxZoom: 19}).addTo(map);
const readout = new Popup();
map.on('click', (e) => {
const {lat, lng} = e.latlng;
const marker = new Marker(e.latlng)
.bindTooltip(`${lat.toFixed(4)}, ${lng.toFixed(4)}`)
.addTo(map);
// Layer events work exactly like map events
marker.on('click', () => map.removeLayer(marker));
});
map.on('zoomend', () => {
readout
.setLatLng(map.getCenter())
.setContent(`zoom level ${map.getZoom()}`)
.openOn(map);
});| Event | Fired when | Payload |
|---|---|---|
click / dblclick | Pointer click on the map or a layer | latlng, containerPoint |
pointerover / pointerout / pointermove | Pointer enters, leaves or moves over the target | latlng, containerPoint |
moveend | Panning finishes | — |
zoomend | Zooming finishes | — |
layeradd / layerremove | A layer enters or leaves the map | layer |
resize | The container is resized | oldSize, newSize |
API surface
Everything below is a named export of the cargocanvas module. Detailed per-option documentation lives in the source, as comment blocks next to each class.
Map
- CargoMap
- Map (alias)
Tile layers
- TileLayer
- WMSTileLayer
- GridLayer
Markers
- Marker
- Icon
- DefaultIcon
- DivIcon
Vector
- Path
- Polyline
- Polygon
- Rectangle
- Circle
- CircleMarker
Renderers
- Renderer
- SVG
- Canvas
Overlays
- ImageOverlay
- VideoOverlay
- SVGOverlay
- BlanketOverlay
Grouping
- Layer
- LayerGroup
- FeatureGroup
- GeoJSON
UI
- Popup
- Tooltip
- DivOverlay
Controls
- Control
- ZoomControl
- AttributionControl
- LayersControl
- ScaleControl
Geometry
- LatLng
- LatLngBounds
- Point
- Bounds
- Transformation
- LineUtil / PolyUtil
Projection
- CRS
- EarthCRS
- EPSG3857
- EPSG3395
- EPSG4326
- SimpleCRS
- Projection
Core
- Class
- Evented
- Handler
- Browser
- Util
- DomUtil / DomEvent
- Draggable
- PosAnimation
Map handlers
- DragHandler
- ScrollWheelZoomHandler
- DoubleClickZoomHandler
- BoxZoomHandler
- KeyboardHandler
- PinchZoomHandler
- TapHoldHandler
Extending a class
include adds methods to an existing class; mergeOptions adds default options; addInitHook runs code at construction. This is how the built-in controls attach themselves to the map, and how plugins should attach themselves too.
import {Marker, CargoMap} from 'cargocanvas';
Marker.include({
flash() {
this.getElement().animate(
[{opacity: 1}, {opacity: 0.2}, {opacity: 1}],
{duration: 600},
);
return this;
},
});
CargoMap.mergeOptions({myPluginEnabled: true});
CargoMap.addInitHook(function () {
if (this.options.myPluginEnabled) { /* … */ }
});License
CargoCanvas is released under the BSD 2-Clause License. You may use it commercially, modify it, and ship it inside closed-source products, with no fee and no obligation to open-source your own code.
When you redistribute it you must retain the copyright notice and the disclaimer in source distributions, and reproduce them in the documentation or other materials of binary distributions. The bundles in dist/ already carry them in their banner comment, so shipping those unmodified satisfies the requirement.