> ## Documentation Index
> Fetch the complete documentation index at: https://docs.loci.ng/llms.txt
> Use this file to discover all available pages before exploring further.

# The map

> Give the map work areas and saved entries, let the user draw inside the boundaries, and read back what they drew. Events, the controller, the built-in controls, styling and geofencing.

This page is the map itself: the data you give it, what the user draws, and
what you get back. To fill the map from the LOCI frame index instead of your
own boundaries, read [Frames](/sdks/flutter-frames) first — it produces the
same work areas this page consumes.

## Work areas

A work area is the polygon a user may collect data in, such as a frame. Pass
the boundaries you hold; you do not need to convert Esri JSON first.

```dart three ways in theme={null}
// 1. An Esri query response, exactly as the feature service returned it.
final fromEsri = MapWorkArea.listFromEsri(esriQueryResponse);
// 2. GeoJSON: a FeatureCollection, or a list of Features.
final fromGeoJson = MapWorkArea.listFromGeoJson(featureCollection);
// 3. Build one yourself.
final area = MapWorkArea(
  id: 'A1',
  geometry: {'type': 'Polygon', 'coordinates': ring},
  properties: {'NAME': 'Island North'},
  initiallyVisible: true,
);
```

Coordinates are `[longitude, latitude]`. The factories accept a decoded
`Map` or the JSON text itself, so you can paste raw JSON into a string.

| Factory | You give it | Rules |
| - | - | - |
| `fromGeoJson` | A Feature, a bare geometry, or the JSON text of either | A bare geometry needs an explicit `id`. |
| `listFromGeoJson` | A FeatureCollection, or a list of Features | Each feature needs the id property. |
| `fromEsri` | `{attributes, geometry: {rings}}`, one feature | Holes are attached to the outer ring that contains them; several outer rings become a MultiPolygon. |
| `listFromEsri` | A whole query response with a `features` list | Reads the response's `spatialReference` when a feature has none. |
| `MapWorkArea(...)` | An `id` and a Polygon or MultiPolygon | Throws if the shape is anything else or a ring has fewer than four points. |

**The id** comes from the `FRAME_ID` property by default, matched without
regard to case. Change it with `idProperty`, or pass `id` yourself. A
feature with no id throws, so bad data fails loudly.

**Spatial references.** `4326` is used as is. Web Mercator (`102100`,
`102113`, `3857`, `900913`) is reprojected to WGS84 for you. Anything else
throws `MapWorkAreaFormatException`; ask Esri for `outSR=4326`.

**Visibility.** Work areas start hidden unless you set `initiallyVisible`.
The user's choices are saved per `mapId` and come back next time. Changing
the list with `setWorkAreas` keeps the choices for ids that remain.

## Features and entries

A feature is one saved entry drawn on the map: a point, a line or a polygon.
Build a `MapFeatureModel` from each record you hold. The map only needs an
id and the shape.

| Field | Type | Meaning |
| - | - | - |
| `id` | `int` | Required. Your entry id. Comes back in `FeatureTapped` and is what `zoomToFeature` takes. |
| `geoJson` | `String?` | Required in practice. The shape as a GeoJSON geometry string (not a Feature), with `[longitude, latitude]` coordinates. |
| `properties` | `Map?` | Your entry data. Read only to sum a column for the grouping threshold. |
| `clusterId`, `watcherCategoryId`, `workAreaId`, `mapGeometryId` | `String?` | Grouping data. Leave out unless grouping is on. |
| `isWatcherBoundary` | `bool` | True for the polygon that outlines a group. Default false. |

```dart from your own records theme={null}
final features = [
  for (final entry in myEntries)
    if (entry.geometry != null) // skip entries with no shape
      MapFeatureModel(
        id: entry.id,
        geoJson: jsonEncode(entry.geometry), // a GeoJSON geometry map
        properties: entry.fields,
      ),
];
```

<Warning>
  Coordinates are `[longitude, latitude]`, the reverse of how people say
  them. A point at `[6.53, 3.38]` lands in the ocean off Somalia.
</Warning>

Give them to the map with `features:` on `MilsatMap`, or
`controller.setFeatures(features)`. With layers, each layer owns its own
list.

## Layers

One `MapOptions` draws one kind of shape. To work with several at once —
houses (points), roads (lines), plots (polygons) — give the map `layers`. A
layer owns its features and says what the user draws while it is active.

```dart three layers theme={null}
MilsatMap(
  options: const MapOptions(mapId: 'survey-1'),
  workAreas: workAreas,
  layers: [
    MapLayer(id: 'homes', label: 'Homes', geometry: DrawGeometry.point, features: homes),
    MapLayer(id: 'roads', label: 'Roads', geometry: DrawGeometry.line, features: roads),
    MapLayer(id: 'parcels', label: 'Parcels', geometry: DrawGeometry.polygon, features: plots),
    MapLayer(id: 'notes', label: 'Notes', features: notes), // view only
  ],
  activeLayerId: 'parcels', // optional
  onLayerChanged: (id) => debugPrint('drawing in $id'),
  onSave: (data) => saveEntry(data.layerId, data),
)
```

* **Drawing** creates the active layer's shape; switching the layer changes
  the shape and cancels a drawing in progress.
* **Overlay.** Every layer shows by default. The built-in **Layers** dialog
  has a checkbox to show or hide each one and a radio to choose where Draw
  goes. It appears with two or more layers.
* **View-only layers** have no `geometry`: shown, but not drawable.
* **Results.** `DataFromMap.layerId` says which layer a shape was drawn in;
  a tap on an entry reports the layer id as `metadata.formName`.

A layer rejects features that do not match it (on by default), throwing
`MapLayerFormatException` listing every bad feature. Pass
`MapOptions(strictGeometry: false)` to draw mismatched features anyway. Two
layers with the same id always throw.

| Layer geometry | Accepts |
| - | - |
| `point` | Point |
| `line` | LineString |
| `polygon` | Polygon, MultiPolygon |
| `none` | Anything (view only) |

```dart with your own widget theme={null}
await controller.setLayers(layers, activeLayerId: 'homes');
await controller.setActiveLayer('parcels'); // also shows it
await controller.hideLayer('notes');
await controller.startDrawing(); // draws a polygon now
controller.visibleLayerIds; // {'homes', 'roads', 'parcels'}
```

`setLayers` keeps the user's choices for layers that stay. Changes arrive as
`LayerChanged` and `LayerVisibilityChanged` events.

## What you get back

When the user finishes a shape you receive a `DataFromMap`. Use it from
`onDrawCompleted` when the shape exists, or from `onSave` once the user
presses **Save**.

| Field | Type | Meaning |
| - | - | - |
| `featureGeometry` | `String` | The drawn shape as a GeoJSON string. |
| `userLat`, `userLong` | `double` | The user's GPS position when they drew. In pin mode, the tapped point. |
| `userDistance` | `double` | Metres from the user to the centre of the shape. |
| `frameProperties` | `Map` | The properties of the work area the shape falls in. Empty when none matches. |
| `layerId` | `String?` | Which layer the shape was drawn in. Null with no layers. |

While drawing, every tap passes checks in order — zoomed in enough (zoom 13
or closer), a work area visible, the point inside it, the user in the same
one, and under the grouping threshold when set. A failed check reports a
`MapFailure` and the tap is dropped. Points place at once; lines and
polygons add a vertex, and a polygon closes when the user taps near its
first point after at least three points.

## Events

Everything the map tells you is a `MapEvent` on `controller.events`. The
type is sealed, so a `switch` tells you if you missed one. `MilsatMap` also
gives each event its own callback, and `onEvent` receives all of them.

| Event | Payload | Fires when |
| - | - | - |
| `MapReady` | — | The map attached and drew its work areas. |
| `FeatureTapped` | `metadata` | The user taps a saved entry (`id`, `formName`, `isWatcherBoundary`, `isNavigation`, `latitude`, `longitude`). |
| `NavigateRequested` | `latitude`, `longitude` | The user taps the map while navigation is on. |
| `DrawCompleted` | `data` | A shape is finished. |
| `GroupingDrawn` | `data` | A grouping boundary is finished (`layerId` is null). |
| `LayerChanged` | `layerId` | The layer shapes are drawn into changes. |
| `LayerVisibilityChanged` | `layerId`, `visible` | A layer is shown or hidden. |
| `LayersRendered` | `layerCount`, `featureCount`, `renderMs` | A `setLayers` or toggle is fully on screen. |
| `UndoRedoChanged` | `undoDepth`, `redoDepth` | The undo or redo history changes. |
| `ModeChanged` | `mode` | Drawing, grouping or navigation starts or stops. |
| `WorkAreaVisibilityChanged` | `workAreaId`, `visible` | A work area is shown or hidden. |
| `BaseMapChanged` | `label`, `baseMap` | The imagery changes. |
| `EntriesVisibilityChanged` | `visible` | Saved entries are shown or hidden. |
| `LocationAccuracyImproved` | `accuracy` | GPS accuracy improves while locating. |
| `LocationFound` | `position` | A position is found. |
| `MapFailure` | `error`, `requiresGrouping` | Anything went wrong. See [Errors](#errors). |

`mode` is one of `none`, `pickingGeometry`, `categorizingGeometry` or
`navigating`.

## The controller

`MilsatMapController` is the one object that drives the map. Commands return
a `Future`; those that can fail return a `MapResult` with `isSuccess`,
`data` and `error`. A command never throws — a failure comes back as a
result with an error, or as a `MapFailure` event.

| Member | What it does |
| - | - |
| `movePin(lat, lng)` | Move the pin and centre the camera on it. |
| `setWorkAreas(list)` | Replace the work areas, keeping saved visibility for ids that remain. |
| `setFeatures(features, ...)` | Draw saved entries, replacing what is shown. |
| `showEntries()` / `hideEntries()` | Show the last features again, or hide them. |
| `startDrawing(...)` | Let the user draw. `editingEntryId` relocates a point; `geometryOverride` draws another type. |
| `stopDrawing()` | Leave drawing mode and clear the draft. |
| `startNavigation()` / `stopNavigation()` | Turn navigation taps on or off. |
| `startGrouping()` | Draw a boundary around entries (needs the grouping flag). |
| `setLayers(...)` / `setActiveLayer(id)` | Replace layers, or choose where Draw goes. |
| `showLayer(id)` / `hideLayer(id)` / `toggleLayer(id)` | Control layer visibility. |
| `undo()` / `redo()` | Step through a line or polygon. |
| `zoomIn()` / `zoomOut()` | One zoom step. |
| `zoomToFeature(entryId)` / `zoomToWorkArea(id)` | Fly to an entry, or fit a work area. |
| `showWorkArea(id)` / `hideWorkArea(id)` / `toggleWorkArea(id)` | Control visibility. |
| `setBaseMap(index)` / `toggleBaseMap()` | Pick or cycle imagery from `options.baseMaps`. |
| `locateMe({moveCamera})` | Find the device position, reporting accuracy as it improves. |
| `workAreaForPoint(lat, lng)` | The work area that contains a point, or null. |
| `detach()` / `dispose()` | Disconnect; `dispose` also closes the event stream. |

It also exposes live state: `events`, `layers`, `activeLayerId`,
`visibleLayerIds`, `drawGeometry`, `isAttached`, `mode`, `entriesVisible`,
`canUndo`, `canRedo`, `workAreas`, `totalEntriesCount`, `activeBaseMap`.

## Built-in controls

The prebuilt `MilsatMap` draws controls over the map. Each is a thin front
for a controller command, so anything a button does you can also do
yourself. Rename with `MilsatMapLabels(key: 'text')`, hide with
`MilsatMapControls(flag: false)`, or replace with `MilsatMapBuilders(...)`.

| Control | What it does | Rename | Hide flag |
| - | - | - | - |
| Navigate | Turns navigation on; taps report `NavigateRequested`. | `navigate` | `navigate` |
| Hide / Show Entries | Hides or shows every saved entry. | `hideEntries` / `showEntries` | `entriesToggle` |
| Work Area | Dialog to show, hide or zoom to each work area. | `workAreas` | `workAreas` |
| Locate Me | Finds the device position and drops a marker. | `locateMe` | `locate` |
| Layers | Dialog to show, hide and choose the draw layer (2+ layers). | `layers` | `layers` |
| Group Entries | Draw a boundary to group entries (grouping flag on). | `group` | — |
| Toggle Imagery | Switch the base map from `options.baseMaps`. | `toggleImagery` | `imagery` |
| Zoom − / + | Zoom out and in by one step. | `zoomOut` / `zoomIn` | `zoom` |
| Draw | Starts drawing the active shape. Hidden when geometry is `none`. | `draw` | `drawing` |
| Save | Takes Draw's place while drawing; calls `onSave`. | `save` | omit `onSave` |
| Undo / Redo / Cancel | Step a line or polygon, or clear the draft. | `undo` / `redo` / `cancel` | `drawing` |

```dart rename and hide theme={null}
MilsatMap(
  options: options,
  labels: const MilsatMapLabels(draw: 'Add household', save: 'Save household'),
  controls: const MilsatMapControls(navigate: false, imagery: false),
)
```

Pass only the label keys you want to change; everything else keeps its
English default, which is how you translate the whole widget.

## Make it match your app

The built-in controls read your `ThemeData`: primary and on-primary colours
from your `ColorScheme`, text from your `TextTheme`. Override any of it, or
none, through these parameters.

| Parameter | Type | Use it to |
| - | - | - |
| `theme` | `MilsatMapTheme` | Override `primary`, `onPrimary`, `controlColor`, `tileColor`, the text styles and the radii (`buttonRadius` 12, `pillRadius` 10, `dialogRadius` 20). Anything null follows your theme. |
| `labels` | `MilsatMapLabels` | Rename or translate every string. |
| `controls` | `MilsatMapControls` | Turn controls on or off; `MilsatMapControls.none()` hides them all. |
| `builders` | `MilsatMapBuilders` | Replace a control with your own widget: `zoom`, `drawing`, `options`, `action`, `overlay`. Each receives the controller. |
| `styleUri`, `initialCenter`, `initialZoom` | — | Map style and where the camera starts. Defaults: Mapbox satellite, central Nigeria, zoom 16. |
| `showErrors` | `bool` | Turn off the built-in error snackbar and handle `onError` yourself. |

```dart replace one control theme={null}
MilsatMap(
  options: options,
  builders: MilsatMapBuilders(
    zoom: (context, controller) => MyZoomButtons(
      onIn: controller.zoomIn,
      onOut: controller.zoomOut,
    ),
  ),
)
```

## Geofencing

By default every drawing is checked against the work areas: one must be
visible, the point must be inside a visible one, and the user must be
standing in the same one. That keeps field data inside the survey boundary.

| Rule | On (default) | Off |
| - | - | - |
| A work area must be visible | Required | Skipped |
| The point must be inside one | Required | Skipped |
| The user must be in the same one | Required | Skipped |
| A GPS fix is required to draw | Required | Not needed; distance reads 0 |
| Editing a shape stays in bounds | Enforced | Skipped |
| Minimum zoom 13 | Enforced | Enforced |

```dart testing only theme={null}
const MapOptions(mapId: 'demo', enforceGeofencing: false)
```

<Warning>
  Do not ship `enforceGeofencing: false`. Data drawn outside a work area is
  accepted, and nothing marks it. A debug message prints whenever the map
  starts with it off.
</Warning>

## Options and flags

| Option | Type | Meaning |
| - | - | - |
| `mapId` | `String` | Required. Scopes saved state such as work-area visibility. |
| `geometry` | `DrawGeometry` | `none`, `point`, `line` or `polygon`. `none` hides the Draw button. |
| `baseMaps` | `List<MapBaseMapOption>` | Imagery choices; the first is active. Empty means the default style only. |
| `watcher` | `MapWatcherRule?` | Grouping threshold rule. Used only when grouping is on. |
| `flags` | `MapFeatureFlags` | Optional features, all off by default. |
| `enforceGeofencing` | `bool` | Default `true`. See [Geofencing](#geofencing). |
| `strictGeometry` | `bool` | Default `true`. With layers, reject features that do not match their layer. |

Grouping lets a user draw a boundary around entries and blocks further
entries once a threshold is reached. It is off by default:

```dart grouping theme={null}
const MapOptions(
  mapId: 'survey-1',
  flags: MapFeatureFlags(grouping: true),
  watcher: MapWatcherRule(type: 'form', value: 20), // 20 entries per group
)
```

## Styles

Four small models describe how things look. Colours are plain ARGB ints
(`0xAARRGGBB`), so `color.toARGB32()` works for a Flutter `Color`. They are
the same on Android and iOS.

| Model | Fields (defaults) |
| - | - |
| `MarkerStyle` | `shape` (circle), `color` (`0xFF803785`), `sizeDp` (24, 8–96), `innerColor` (white), `showLabel` (true), `iconPng` (null, replaces the shape). |
| `MarkerShape` | `circle`, `square`, `roundedSquare`, `diamond`, `triangle`, `hexagon`, `pin`. |
| `LayerStyle` | `point` (a `MarkerStyle`), `polygonCentre`, `lineColor` (red), `lineWidth` (2), `fillColor`, `fillOpacity` (0.40). |
| `WorkAreaStyle` | `lineColor`, `lineWidth` (3), `labelColor`. |
| `LocationStyle` | `sizeDp` (36), `ringColor`, `dotColor`, `iconPng`. |

```dart styles theme={null}
const options = MapOptions(
  mapId: 'survey-1',
  geometry: DrawGeometry.point,
  style: LayerStyle(
    point: MarkerStyle(shape: MarkerShape.pin, color: 0xFF1565C0, sizeDp: 40),
  ),
  workAreaStyle: WorkAreaStyle(lineColor: 0xFF2E7D32, lineWidth: 4),
);
// Change them while the map is open.
await controller.setStyle(
  const LayerStyle(point: MarkerStyle(shape: MarkerShape.diamond, sizeDp: 32)),
);
await controller.setLayerStyle('parcels', null); // back to the default
```

A layer's own style wins over `MapOptions.style`, which wins over the
built-in look. Constructors assert the ranges; `fromJson` clamps or ignores
bad values.

## Errors

Every error extends `MapError` and has a `message` you can show. They arrive
as `MapFailure` events or inside a failed `MapResult`.

| Error | When |
| - | - |
| `MapGeofencingError` | A tap broke a rule: zoom too low, no work area, outside it, or the user is elsewhere. |
| `WatcherThresholdError` | The grouping threshold was reached. |
| `LocationError` | Location is off or denied, or no position could be found. |
| `MapLayerError` | A layer command could not run: unknown, hidden, view-only, or no layer chosen. |
| `WorkAreaError` | A work area id was not found. |
| `ZoomError` | A zoom or fly-to failed, or the entry was not found. |
| `MapInitializationError` | The map could not start (also on web and desktop). |
| `MapFeatureDisabledError` | You called something that needs a flag that is off, such as `startGrouping`. |
| `UnknownMapError` | Anything else; the message carries the original text. |

Bad work-area data throws `MapWorkAreaFormatException` straight from the
factory, and bad layers throw `MapLayerFormatException` from `setLayers`,
before anything reaches the map.

These are the map's own errors. The errors the SDK raises when it calls the
LOCI API — rejected keys, rate limits, offline — are separate and live in
[Frames](/sdks/flutter-frames#errors-and-events).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.