# Map Editor
Interactive map editor on OpenStreetMap data: markers, polygons, polylines, circles, rectangles and tracks, with vertex editing, snap, undo/redo, free-text notes, address search, measurement, and round-trip import/export in **GeoJSON**, **KML** and **GPX**.
Built on top of the [`gadgets/map`](https://cdn.sgapps.io/components/application-prototype-thirdparty/docs/#/gadgets/map.md) module (OpenStreetMap tiles via Leaflet, in-house editing + provider layer). The editor adds a save/export toolbar, a per-feature note panel, and the `/online/webapp/map-editor` window-manager wrapper so the whole thing drops into an iframe or an in-page custom element with one line.
## Live Demo
## Embed
```html
```
### Open with a file
The editor opens whatever file-manager path is passed as the first argument. The file extension picks the parser:
| Extension | Format | Round-trip |
|---|---|---|
| `.geojson`, `.json` (GeoJSON content) | RFC 7946 FeatureCollection | ✅ full — circles survive as `Point + properties.radius_m`, rectangles as `Polygon + properties.shape = "rectangle"` |
| `.kml` | OGC KML 2.2 Placemarks | ✅ points, lines, polygons; circles / rectangles kept via `` hints |
| `.gpx` | GPX 1.1 (waypoints / tracks / routes) | points → ``, lines → ``, polygons → `` (ring, lossy on area semantics) |
```html
```
### In-page (no iframe)
The CDN-registered wrapper `b2b.editor.map-editor` creates an in-page custom element and connects to the window-manager app behind the scenes.
```html
```
---
## Events Reference
Everything below is sent through the standard webapp socket:
```js
var socket = new WindowSocket(iframe.contentWindow);
socket.start();
socket.fire("webapp::instance::request", eventName, ...args, callback);
```
The map also emits events upward through `api-event::instance:*`; see the full list under **Map events (upstream)** below.
---
### `toGeoJSON` -- Export the current map
Returns everything on the map as an RFC 7946 FeatureCollection. Circles become `Point + properties.radius_m`; rectangles become `Polygon + properties.shape = "rectangle"`; notes are serialised as `properties.note`.
```js
socket.fire("webapp::instance::request", "toGeoJSON",
function (err, fc) { console.log(fc.features.length, "features"); });
```
---
### `loadGeoJSON` -- Replace or merge features
Loads a FeatureCollection into the map. Options: `{ replace: true, fit: true }`.
| Arg | Type |
|---|---|
| `fc` | FeatureCollection |
| `opts` | `{replace?, fit?}` |
| `callback` | `(err, acceptedCount)` |
```js
socket.fire("webapp::instance::request", "loadGeoJSON", fc, { replace: true, fit: true },
function (err, n) { console.log("loaded", n); });
```
---
### `exportAs` -- Serialise to a format string
| Arg | Type |
|---|---|
| `format` | `"geojson"` \| `"kml"` \| `"gpx"` |
| `callback` | `(err, text: string)` |
```js
socket.fire("webapp::instance::request", "exportAs", "kml",
function (err, kmlText) {
var blob = new Blob([kmlText], { type: "application/vnd.google-earth.kml+xml" });
var a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.download = "map.kml"; a.click();
});
```
---
### `addMarker`, `removeMarker`, `clearMarkers`
```js
socket.fire("webapp::instance::request", "addMarker",
{ id: "pos-1", lat: 47.0105, lng: 28.8638, label: "HQ", color: "#d93025" },
function (err, id) { /* id = "pos-1" */ });
```
Marker shape: `{ id, lat, lng, label?, title?, color?, size?, popup?, draggable?, opacity?, properties? }`.
---
### `drawPolygon`, `drawPolyline`
Enter drawing mode: a click per vertex, double-click or **Done** to finish.
```js
socket.fire("webapp::instance::request", "drawPolygon",
"zone-a", { editable: true, color: "#d93025" },
function (err, id) { /* id when drawing starts */ });
```
Finished shapes emit `event:shape-finished` upward.
Click on the map to place vertices, then double-click (or press **Done** in the map toolbar) to finish.
---
### `setPolygon`, `setPolyline`, `setCircle`, `setRectangle`
Place or replace a shape from code. Coordinates are latitude first.
```js
socket.fire("webapp::instance::request", "setPolygon",
"yard", [[47.01, 28.86], [47.02, 28.87], [47.03, 28.85]],
{ editable: true, color: "#d93025" }, function (err, id) {});
socket.fire("webapp::instance::request", "setCircle",
"radius", 47.0105, 28.8638, 5000,
{ label: "5 km", color: "#188038" }, function (err, id) {});
socket.fire("webapp::instance::request", "setRectangle",
"bbox", { south: 46.9, west: 28.7, north: 47.1, east: 29.0 },
{}, function (err, id) {});
```
---
### `getShapes`, `getShape`, `removeShape`, `clearShapes`
```js
socket.fire("webapp::instance::request", "getShapes",
function (err, list) { /* [{id, kind, label}] */ });
socket.fire("webapp::instance::request", "getShape", "yard", "pairs",
function (err, data) { /* {coords: [[lat,lng],...]} or {lat,lng,radius} or {bounds} */ });
```
---
### `setView`, `getView`, `fitAll`
```js
socket.fire("webapp::instance::request", "setView", 47.0105, 28.8638, 15,
function (err) {});
socket.fire("webapp::instance::request", "getView",
function (err, v) { /* {lat, lng, zoom, bounds: {south, west, north, east}} */ });
```
---
### `setMode`, `getMode`
Modes: `view`, `pick-point`, `edit-polygon`, `edit-line`, `draw-polygon`, `draw-line`, `measure`.
---
### `geocode`, `reverse`
Address search against the configured Nominatim provider (OSM default). Serialised at module level (≥ 1.1 s apart across every map on the page).
```js
socket.fire("webapp::instance::request", "geocode", "Str. Alba Iulia 75, Chisinau",
function (err, hits) { /* [{lat, lng, label, name, type, bbox, address, osm}] */ });
socket.fire("webapp::instance::request", "reverse", 47.0105, 28.8638,
function (err, hit) { /* {lat, lng, label, address, osm} */ });
```
Error codes to branch on: `ERR_NO_GEOCODER`, `ERR_NETWORK`, `ERR_TIMEOUT`, `ERR_PARSE`, `ERR_NO_RESULT`, `ERR_HTTP_`.
---
### `setProviders` -- swap tile / geocoding / routing endpoints
```js
socket.fire("webapp::instance::request", "setProviders", {
tiles: ["osm", { url: "https://tiles.example.com/{z}/{x}/{y}.png", attribution: "© Example" }],
geocode: { url: "https://nominatim.example.com", countryCodes: "md,ro", language: "ro" },
route: { url: "https://osrm.example.com", profile: "driving", attribution: "Routing: OSRM" }
}, function (err) {});
```
Shorthand `{ tiles: "blank" }` renders the tile-less coordinate graticule (no network traffic).
---
### `undo`, `redo`
One step of vertex editing. Returns a boolean.
---
### `setNote`, `getNote`
Attach a free-text description to any marker or shape (`properties.note` in the GeoJSON export).
```js
socket.fire("webapp::instance::request", "setNote", "pos-1", "Main warehouse — gate 2",
function (err) {});
socket.fire("webapp::instance::request", "getNote", "pos-1",
function (err, note) { /* string or null */ });
```
The in-app **Note…** button opens a picker over the current markers and shapes.
---
### `save`, `saveAs`
Writes the current map back to the file the editor was opened with (via `app.fs()`):
```js
socket.fire("webapp::instance::request", "save", function (err) {});
```
Save-As asks for a filename via `file-selector`:
```js
socket.fire("webapp::instance::request", "saveAs", "kml", null, function (err) {});
```
Or save directly to a known path:
```js
socket.fire("webapp::instance::request", "saveAs", "gpx", "/home/me/trail.gpx", function (err) {});
```
---
## Map events (upstream)
These are forwarded through `api-event::instance:*` and arrive on the socket as:
```js
socket.on("api-event::instance:event:polygon-changed", function (e) {
if (e.phase !== "commit") return; // 'drag' fires per mouse move
console.log("polygon", e.id, e.area_m2);
});
```
| Event | Payload |
|---|---|
| `ready` | `app` |
| `event:click` | `{lat, lng}` |
| `event:point-changed` | `{id, lat, lng}` |
| `event:marker-moved` | `{id, lat, lng}` |
| `event:marker-click` | `{id, lat, lng, label}` |
| `event:polygon-changed` | `{id, coords, phase, points, area_m2}` -- `phase` is `drawing`, `drag` (live) or `commit` |
| `event:polyline-changed` | `{id, coords, phase, points, length_m}` |
| `event:shape-finished` | `{id, kind, coords}` |
| `event:shape-click` | `{id, lat, lng}` |
| `event:view-changed` | `{lat, lng, zoom, bounds}` (debounced) |
| `event:mode-changed` | `{mode}` |
| `event:layer-changed` | `{index, name}` |
| `event:search-results` | `{query, results}` |
| `event:search-selected` | the result row |
| `event:measure` | `{points, length_m, area_m2, coords}` |
| `event:route-done` | the route |
| `event:track` | `{id, progress, walked_m, total_m, lat, lng, done}` |
| `event:locate` | `{lat, lng, accuracy_m}` |
| `event:error` | `{code, message, error}` |
**Persist on `commit`, not on `drag`:** a vertex drag emits `polygon-changed` with `phase: "drag"` once per mouse move; wait for `phase: "commit"` before writing to a database.
---
## Use Cases
### Delivery zone editor
```js
socket.fire("webapp::instance::request", "setPolygon",
"zone", existingCoords, { editable: true, color: "#d93025" });
socket.on("api-event::instance:event:polygon-changed", function (e) {
if (e.phase === "commit") form.zoneGeoJson.value = JSON.stringify(e.coords);
});
```
### Geofence check against a drawn zone
Load the GeoJSON that comes back from `toGeoJSON()` into any point-in-polygon helper on the server, or (if the host also has `gadgets/map/geo`) run the check client-side with the same semantics the module uses.
### Store locator + address search
```js
socket.fire("webapp::instance::request", "geocode", userInput, function (err, hits) {
if (hits.length) {
socket.fire("webapp::instance::request", "setView",
hits[0].lat, hits[0].lng, 16, function () {});
}
});
```
### Blank-canvas map (no OSM tiles)
```js
socket.fire("webapp::instance::request", "setProviders", { tiles: "blank" }, function () {});
```
The coordinate grid replaces the tiles, every vector feature still works, and no requests are sent to any third party. Useful for air-gapped demos and for review builds that must not contact OSM.
---
## Privacy / what reaches third parties
By default the editor uses the standard OpenStreetMap tiles (`tile.openstreetmap.org`) and Nominatim geocoding (`nominatim.openstreetmap.org`). Each receives the viewer's IP, the browser's `Referer`, and the tile coordinates or searched text. There is no autocomplete, no prefetch, no cache. Routing has no default provider -- `route()` without a configured server fails with `ERR_NO_ROUTER` and sends nothing. To disable OSM entirely use the `blank` tiles shorthand above.
See [`gadgets/map` docs §What reaches third parties](https://cdn.sgapps.io/components/application-prototype-thirdparty/docs/#/gadgets/map.md) for the full policy summary.