Draw & Measure¶
The draw and measure widgets share an architecture: a controller class that drives all map interaction and rendering, decoupled from React, wrapping @mapbox/mapbox-gl-draw ("Draw"). Draw owns editable geometry + vertex editing; the controller layers its own rendering (labels, drafts, per-feature color) on top.
app/widgets/draw/DrawController.tsapp/widgets/measure/MeasureController.ts
The React component is thin: it holds UI state (which tool is active, the list of items) and forwards user actions to the controller via method calls, receiving updates through callbacks (onChange, onFinish, onSelect, and measure's onLive).
MapLibre ↔ mapbox-gl-draw integration¶
mapbox-gl-draw targets Mapbox, so two adaptations are needed in every controller constructor:
- DOM class remapping. Draw looks up DOM classes by Mapbox names; the controller reassigns
MapboxDraw.constants.classesto MapLibre's equivalents (maplibregl-canvas,maplibregl-ctrl, …), per the official MapLibre example. The@typesdeclare these readonly, so the code casts to a mutable record. - Control type cast.
@types/mapbox__mapbox-gl-drawtypesMapboxDrawagainst mapbox-gl'sMap, so itsIControlsignature doesn't match MapLibre's.addControl/removeControlcast throughIControl; the runtime wiring is the documented MapLibre pattern.
Draw is created with displayControlsDefault: false (the widgets supply their own tool buttons), userProperties: true (so feature props are addressable as user_* in the style array), and a custom styles array (drawStyles.ts).
Per-feature color via setFeatureProperty¶
Both widgets assign each feature a color (measure cycles a palette; draw seeds from a palette and lets the user change it). The color is stored on the Draw feature via setFeatureProperty(id, 'color', …) so it rides along in draw.getAll() and the style array reads it as user_color.
Caveat: setFeatureProperty alone does not repaint — Draw only re-renders on its own events. After changing a property the controller calls repaintDraw(), which re-sets the current FeatureCollection (draw.set(draw.getAll())) to trigger a render without losing selection/mode.
Session survival (close → reopen)¶
Closing the widget unmounts the component, which destroys the controller and removes Draw. To survive that, each controller keeps a module-level session snapshot (measureSession / the draw equivalent) — the finished features (geometry + per-feature metadata) plus the color counter. It lives outside the controller instance on purpose.
saveSession()writes the snapshot on every change.restoreSession()(in the constructor) re-adds the saved features to a fresh Draw and repopulates the metadata map. Draw preserves a provided stringid, so re-adding keeps feature identity stable.
Measure also persists widget preferences (unit/label/persist toggles) in the same session via getMeasurePrefs / setMeasurePrefs, so they don't reset to defaults on reopen.
Basemap-switch reattach¶
map.setStyle() wipes Draw's layers and the controller's own layers. The widget listens on style.load and calls reattach(), which re-creates the controller's label/draft layers and refreshes. (Draw itself is removed + re-added by the widget's effect cleanup/re-run.)
Labels (measure)¶
Measure renders its own label layer (a symbol layer over a GeoJSON source), rebuilt by buildLabelFeatures() from Draw's current features:
- Total label — one per feature (polygon centroid for areas, last vertex for lines), gated on the Show Total toggle.
- Per-edge labels — one per segment midpoint, gated on Show Edge Labels, never for freehand (its dense vertices would spam labels).
- A
kindproperty (total/segment/segment-edit) drives the label's size, color, and offset via a MapLibrecaseexpression. While a feature is being edited, its edge labels becomesegment-editand are nudged up so the add-vertex midpoint nodes underneath stay clickable.
After each refresh the controller re-asserts label-on-top (moveLayer) because Draw re-inserts its own layers on top during its renders.
Freehand capture (measure)¶
Draw has no freehand mode, so the controller captures it directly: on mousedown with a freehand tool armed it streams cursor coordinates into a draft GeoJSON source (rendered dashed), and on mouseup hands the finished geometry to Draw as a normal editable feature. dragPan and doubleClickZoom are disabled while a freehand tool is armed and restored via setTool(null) on finish (setting tool = null directly would skip that restore and leave the map un-pannable).
Draft vertices (measure)¶
While a click-tool line/polygon is being drawn, Draw only shows the latest committed vertex. The controller renders its own dots for the committed points (dropping the rubber-band point that tracks the cursor) into a draft-vertices source, colored to match the in-progress feature's palette color so the draft reads correctly from the first click. This is driven off Draw's draw.render event.
The persist overlay (measure)¶
settings.measure.persistOnClose keeps finished measurements on the map after the widget closes. Since Draw only renders while attached, destroy(persist = true) snapshots the geometry + labels into plain GeoJSON layers (the "persist overlay") before removing Draw. Because the widget is closed (no controller to listen for style.load), the overlay registers its own module-level style.load handler so it survives basemap switches. Reopening the widget calls uninstallPersistOverlay() and hands rendering back to Draw.
Drawings as a layer (draw)¶
The draw widget exposes its drawings to the rest of the app as a synthetic "Drawings" layer in the layer list. It writes LayerMetadata (id __drawings__) into the store while drawings exist and removes it when empty. The metadata sets labelIsGeometry: true so text drawings (which render via the -label sublayer) toggle with the layer rather than via a separate labels button — see setLayerVisibility(includeLabel).
Saved sets and GeoJSON export are layered on top (storage.ts, exportGeoJSON.ts); saved sets are namespaced by branding.appId.