maplibre-zone-manager
v0.1.2
Published
Zone and polygon management for MapLibre GL + Terra Draw. Draw, edit and assign geographic zones to entities with marker support and state persistence.
Maintainers
Readme
maplibre-zone-manager
Zone and polygon management for MapLibre GL + Terra Draw. Draw, edit and assign geographic zones to entities — with markers, state persistence and keyboard shortcuts.
Use cases: delivery zones, sales territories, service areas, geofencing — anywhere you need to assign polygons to people or things on a map.
Features
- Draw and edit polygons via Terra Draw
- Assign zones to generic entities (sellers, drivers, regions…)
- Configurable markers with category-based colors
- State persistence in localStorage
- Keyboard shortcuts (Delete, Escape)
- Modal for marker details via injectable callback
- Framework-agnostic core
- Optional Stimulus adapter for Rails / Hotwire apps
- Optional Rails nested-attributes adapter for saving
- Optional Flatpickr date-range plugin
Install
# npm / yarn / pnpm
npm install maplibre-zone-manager maplibre-gl terra-draw terra-draw-maplibre-gl-adapter
# Rails import maps
bin/importmap pin maplibre-zone-managermaplibre-gl, terra-draw and terra-draw-maplibre-gl-adapter are peer dependencies — you provide them.
Quick start
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl/dist/maplibre-gl.css">
<div id="map" style="width: 100%; height: 500px"></div>import { ZoneMap } from 'maplibre-zone-manager'
const map = new ZoneMap({
container: document.getElementById('map'),
center: { lat: -34.6, lng: -58.4 },
zoom: 12,
entities: [
{
id: 1,
name: 'Alice',
zones: [
{ id: 10, name: 'North', geometry: { type: 'Polygon', coordinates: [[...]] } }
]
},
{ id: 2, name: 'Bob', zones: [] }
],
markers: [
{ id: 1, lat: -34.61, lng: -58.38, title: 'Store A', category: 'recent' }
],
markerColors: {
recent: '#34A853',
moderate: '#FBBC04',
old: '#EA4335',
default: '#9CA3AF'
},
onZonesSave: async (changes) => {
const res = await fetch('/api/zones/bulk', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes)
})
return res.json() // { success: true, newZones: [...] }
}
})
await map.ready()
// Select an entity and enter edit mode
map.selectEntity(1)
map.startEditing()
map.setMode('polygon') // start drawingAPI
new ZoneMap(options)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| container | HTMLElement | required | Map container |
| center | { lat, lng } | { lat: 0, lng: 0 } | Initial center |
| zoom | number | 10 | Initial zoom |
| style | object | string | OSM tiles | MapLibre style |
| entities | array | [] | Entities with zones |
| zonesKey | string | "zones" | Property name for zones in entity objects |
| markers | array | [] | Marker data |
| markerColors | object | {} | category → hex color map |
| markerPopupTemplate | function | default | (data, id) => HTML |
| drawStyles | object | — | { polygon: {...}, select: {...} } |
| modal | HTMLElement | — | Modal container |
| modalContent | HTMLElement | — | Modal content target |
| fetchMarkerDetails | function | — | async (marker, filters) => HTML |
| persistState | boolean | true | Enable localStorage persistence |
| storageKey | string | "zone_map_state" | localStorage key |
| keyboardShortcuts | boolean | true | Enable keyboard shortcuts |
Callbacks
| Callback | Signature | Description |
|----------|-----------|-------------|
| onReady | (zoneMap) => void | Map finished initializing |
| onZonesSave | async (changes) => { success, newZones } | Save zones to your backend |
| onMarkerClick | (markerData) => void | Marker clicked |
| onEntitySelect | (entity) => void | Entity selected |
| onModeChange | (mode) => void | Drawing mode changed |
| onZoneCreated | (feature, entityId) => void | New polygon finished |
| onZoneDeleted | (feature) => void | Polygon deleted |
Methods
map.selectEntity(id) // Show zones for entity
map.showAllEntities() // Show all zones from every entity
map.startEditing() // Enter edit mode
map.stopEditing() // Exit edit mode
map.setMode('polygon') // Switch to drawing mode ('polygon' | 'select')
map.deleteSelectedZone() // Delete the selected polygon
map.toggleZones(bool) // Show/hide zones
map.getChanges() // { created, updated, deleted }
map.saveZones() // Calls onZonesSave and applies server IDs
map.updateMarkers([...]) // Replace markers on the map
map.saveState() // Persist to localStorage
map.restoreState() // Restore from localStorage
map.destroy() // Clean upChange-set format
getChanges() and the onZonesSave callback receive:
{
created: [
{ entityId: 1, name: 'Zone 2025-03-14', geometry: { type: 'Polygon', coordinates: [...] } }
],
updated: [
{ id: 10, geometry: { type: 'Polygon', coordinates: [...] } }
],
deleted: [11, 12] // zone IDs
}Your backend should return { success: true, newZones: [{ id, entityId }] } so that newly created zones get their server-assigned IDs mapped back.
Stimulus adapter (Rails / Hotwire)
For Hotwire apps, extend the base controller instead of wiring everything manually:
// app/javascript/controllers/zone_map_controller.js
import { Controller } from '@hotwired/stimulus'
import { createStimulusController } from 'maplibre-zone-manager/adapters/stimulus'
export default class extends createStimulusController(Controller) {
async fetchMarkerDetails(marker, filters) {
const res = await fetch(`/stores/${marker.id}/details`)
return res.text()
}
async saveZones(changes) {
const { RailsAdapter } = await import('maplibre-zone-manager/adapters/rails')
const body = RailsAdapter.formatNestedAttributes(changes, {
parentKey: 'company',
attributesKey: 'seller_zones_attributes',
entityForeignKey: 'seller_id'
})
const { headers, body: reqBody } = RailsAdapter.buildRequest(body)
const res = await fetch('/company/seller_zones/bulk_update', {
method: 'PATCH', headers, body: reqBody
})
return res.json()
}
}<div data-controller="zone-map"
data-zone-map-lat-value="<%= @lat %>"
data-zone-map-lng-value="<%= @lng %>"
data-zone-map-zoom-value="12"
data-zone-map-entities-value="<%= @sellers.to_json %>"
data-zone-map-markers-value="<%= @stores.to_json %>">
<div data-zone-map-target="map" style="height: 500px"></div>
<button data-action="zone-map#startEditing">Edit zones</button>
<button data-action="zone-map#save">Save</button>
</div>Rails nested-attributes adapter
import { RailsAdapter } from 'maplibre-zone-manager/adapters/rails'
const changes = zoneMap.getChanges()
const payload = RailsAdapter.formatNestedAttributes(changes, {
parentKey: 'company',
attributesKey: 'seller_zones_attributes',
entityForeignKey: 'seller_id',
geometryKey: 'geometry_json' // default
})
// payload =>
// {
// company: {
// seller_zones_attributes: {
// "0": { seller_id: 1, name: "...", geometry_json: {...} },
// "1": { id: 10, geometry_json: {...} },
// "2": { id: 11, _destroy: true }
// }
// }
// }Date-range plugin
Requires flatpickr as a peer dependency.
import { DateRangePlugin } from 'maplibre-zone-manager/plugins/date-range'
const dateRange = new DateRangePlugin({
input: document.getElementById('date-range'),
clearButton: document.getElementById('clear-date'),
locale: 'es',
onChange: (range) => {
// range = { start: '2025-01-01', end: '2025-01-31' } | null
}
})
dateRange.clear()
dateRange.destroy()Using individual modules
Every module is independently importable:
import { PolygonManager } from 'maplibre-zone-manager'
import { MarkerBuilder } from 'maplibre-zone-manager'
import { MapStateManager } from 'maplibre-zone-manager'Entity data format
Entities follow a simple convention:
{
id: 1,
name: 'Entity name',
zones: [
{
id: 10, // server-assigned ID
name: 'Zone name',
geometry: { // GeoJSON Polygon
type: 'Polygon',
coordinates: [[[lng, lat], ...]]
}
}
]
}The zones key is configurable via zonesKey. For Rails apps that use seller_zones, it also auto-detects that key.
Marker data format
{
id: 1,
lat: -34.61,
lng: -58.38,
title: 'Marker title', // shown in popup
description: 'Optional text',
category: 'recent' // maps to markerColors
}Browser support
Same as MapLibre GL JS — all modern browsers.
License
MIT
