> ## 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/android-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.
Create them from Esri JSON, GeoJSON or by hand, and set them on
`view.workAreas`. Coordinates are `[longitude, latitude]`. To load work
areas from LOCI frames instead, see [Frames](/sdks/android-frames); frames
you load can be saved on the device, see [Packs](/sdks/android-frames#packs).

```kotlin three ways in theme={null}
// 1. An Esri query response, exactly as the feature service returned it.
val fromEsri = MapWorkArea.listFromEsri(esriQueryResponse)

// 2. GeoJSON: a FeatureCollection, or a list of Features.
val fromGeoJson = MapWorkArea.listFromGeoJson(featureCollection)

// 3. Build one yourself. Each polygon is a list of rings: the outer ring first, then any holes.
val area = MapWorkArea(
    id = "A1",
    type = PolygonGeometryType.POLYGON,
    polygons = listOf(rings),
    properties = mapOf("NAME" to "Island North"),
    initiallyVisible = true,
)
```

A work area starts hidden unless you pass `initiallyVisible = true`. Drawing
needs at least one visible work area ([Geofencing](#geofencing)). The user's
choice of visible work areas is remembered per `mapId`.

`fromGeoJson` also takes a single Feature or a bare geometry. A bare geometry
needs an `id`, and you can read the id from a different property:

```kotlin one feature theme={null}
val harbour = MapWorkArea.fromGeoJson(oneFeature)
val bare = MapWorkArea.fromGeoJson(bareGeometry, id = "C3")      // the id is required here
val one = MapWorkArea.fromGeoJson(singleFeature, idProperty = "NAME")
```

**The id** comes from the `FRAME_ID` property by default. **Spatial
references:** query Esri with `outSR=4326`. Web Mercator is converted; any
other spatial reference throws `MapWorkAreaFormatException`.

Replace them with the property; show, hide and zoom with the controller:

```kotlin work area commands theme={null}
val controller = view.controller
view.workAreas = newAreas                // replace them; visibility choices are kept for ids that remain
controller.showWorkArea("A1")
controller.hideWorkArea("A1")
controller.toggleWorkArea("B2")
controller.zoomToWorkArea("A1")          // fit it on screen
```

Each change raises `WorkAreaVisibilityChanged`. Invalid data throws
`MapWorkAreaFormatException` from the factory.

## Features and entries

A feature is one saved entry drawn on the map: a point, a line or a polygon.
Build a `MapFeature` from each record you hold. Only `id` and `geoJson` are
needed.

| Field | Type | Meaning |
| - | - | - |
| `id` | `Int` | Required. Your entry id. Comes back in `FeatureTapped` and is what `zoomToFeature` takes. |
| `geoJson` | `String?` | The shape as a GeoJSON geometry string (not a Feature): Point, LineString, Polygon or MultiPolygon, coordinates `[longitude, latitude]`. A feature with no shape is not drawn. |
| `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. `mapGeometryId` is the label shown on the entry once it is grouped. |
| `isWatcherBoundary` | `Boolean` | True for the polygon that outlines a group. Default false. |

```kotlin from your own records theme={null}
val features = myEntries.mapNotNull { entry ->
    val shape = entry.geometry ?: return@mapNotNull null    // skip entries with no shape
    MapFeature(
        id = entry.id,
        geoJson = shape,                                    // a GeoJSON geometry string
        properties = entry.fields,
    )
}

view.features = features
```

Set your entries on `view.features`, or in a layer ([Layers](#layers)). Set
them again whenever your data changes. LOCI does not store features: see
[Packs](/sdks/android-frames#packs) for what is cached.

Tapping an entry calls `onFeatureTap`. The metadata has the entry id and the
layer id as `formName`. Fly to an entry, or let the user move a point:

```kotlin editing and zooming theme={null}
view.onFeatureTap = { entry ->          // id, formName, isWatcherBoundary, latitude, longitude
    open(entry.id)
}

controller.zoomToFeature(1)             // fly to a saved entry

// Let the user move an existing point.
controller.startDrawing(editingEntryId = 1, editingOriginal = LatLng(6.530, 3.380))
```

While editing, a new position is accepted inside a visible work area, or
within 200 m of the original point. `hideEntries()` and `showEntries()` hide
and show every entry.

## Layers

Set layers on `view.layers`. Layers let one map hold several kinds of data,
such as houses (points), roads (lines) and plots (polygons). A layer owns its
features and sets the shape the user draws while it is the active layer.

```kotlin three layers theme={null}
view.layers = listOf(
    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 = parcels),
)
view.activeLayerId = "homes"
```

* **Drawing.** Draw creates the active layer's shape. Switching the layer
  cancels a drawing in progress.
* **Visibility.** Every layer is shown by default. The **Layers** option
  shows or hides each layer and chooses the active one. It appears with two
  or more layers.
* **View-only layers.** A layer with no geometry (`DrawGeometry.NONE`, the
  default) is shown but cannot be drawn into.
* **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 its geometry. Setting
`view.layers` throws `MapLayerFormatException` listing each one — for example
`Feature 7 is a point but layer "parcels" draws polygon`. Watcher boundaries
are exempt.

| Layer geometry | Accepts |
| - | - |
| `POINT` | Point |
| `LINE` | LineString |
| `POLYGON` | Polygon, MultiPolygon |
| `NONE` | Anything (view only) |

Pass `MapOptions(strictGeometry = false)` to draw mismatched features anyway.
Two layers with the same id always throw. Set the layers after `onMapReady`
so the exception reaches you, or check first:

```kotlin check before you pass theme={null}
val problems = layers.flatMap { it.geometryProblems() }   // one sentence per bad feature
if (problems.isEmpty()) {
    MapLayer.checkUniqueIds(layers)                       // a repeated id throws
    view.layers = layers
}
```

Switch and toggle layers on the controller. Setting layers again keeps the
user's visibility choices for layers that remain; a layer added after the
first time starts hidden, so show it with `controller.showLayer(id)`. Changes
call `onLayerChanged` and `onLayerVisibilityChanged`.

```kotlin layer commands theme={null}
controller.setActiveLayer("parcels")     // also shows it
controller.hideLayer("notes")
controller.toggleLayer("roads")
controller.startDrawing()                // draws a polygon now
```

The first layer keeps the default look. Every other layer without a style of
its own gets its own colour, the same on every run and on every platform.
Recolour one in place; a colour you set overrides every style and is kept
when you pass the layers again:

```kotlin recolour a layer theme={null}
controller.setLayerColor("parcels", 0xFF1565C0.toInt())    // recolour in place, nothing is drawn again
val shown: Int? = controller.layerColor("parcels")         // the colour it is drawn in, or null for an unknown layer
controller.setLayerColor("parcels", null)                  // back to the layer's own look
```

With grouping on, the user can draw a boundary around entries. It raises
`GroupingDrawn` (not `DrawCompleted`) and has no layer. Handle it with
`onSaveGrouping`. The threshold counts entries over all layers.

## What you get back

A finished shape arrives as a `DataFromMap`: in `onDrawCompleted` when it is
drawn, or in `onSave` when 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. 0 when unknown. In pin mode, the tapped point. |
| `userDistance` | `Double` | Metres from the user to the point; to the centre for a line or polygon. 0 when the position is not known. |
| `area` | `Double?` | Square metres inside a polygon. Null for points and lines. |
| `frameProperties` | `Map` | The properties of the work area the shape falls in. Empty when none matches. |
| `layerId` | `String?` | The layer the shape was drawn in. Null for a grouping boundary and when you use no layers. |
| `mapId` | `Int` | The number the entry will show on the map. 0 when unknown. |

```kotlin handle the result theme={null}
view.onSave = { data ->
    repository(data.featureGeometry, data.area, data.layerId)
}

// With your own buttons, read it as soon as the shape is drawn:
view.onDrawCompleted = { data ->
    println("${data.featureGeometry} drawn ${data.userDistance} m from the user")
}
```

### Drawing

```kotlin drawing theme={null}
controller.startDrawing()      // the active layer's shape, or MapOptions.geometry without layers
controller.undo()              // step back through a line or polygon
controller.redo()
controller.closePolygon()      // as when the first marker is tapped
controller.stopDrawing()       // leave drawing mode and clear the draft
```

`startDrawing()` returns a `MapResult`; it fails when no layer is chosen, or
the layer is hidden or view-only ([Errors](#errors)). Each tap is checked in
order — zoom 13 or closer (always), then the geofencing checks, then the
grouping threshold when set. A failed check raises `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.

### Address verification

Pin mode places one pin, with no work areas and no geofencing. A tap calls
`onDrawCompleted` with the tapped point.

```kotlin pin mode theme={null}
controller.attachPin(id = "address-1", latitude = 6.5244, longitude = 3.3792)
```

## Events

Set a callback on the view for the events you need. `onEvent` receives every
event, after the callback of its own.

```kotlin callbacks theme={null}
view.onMapReady = { println("ready") }
view.onDrawCompleted = { data -> println(data.featureGeometry) }
view.onFeatureTap = { entry -> println(entry.id) }
view.onError = { error, requiresGrouping ->
    println(error.message)
    if (requiresGrouping) { /* offer to group entries */ }
}
view.onEvent = { event -> if (event is MapEvent.UndoRedoChanged) println(event.undoDepth) }
```

| Event | Callback | Payload | Fires when |
| - | - | - | - |
| `MapReady` | `onMapReady` | — | The map attached and drew its work areas. |
| `FeatureTapped` | `onFeatureTap` | `GeometryMetadata` | The user taps a saved entry (`id`, `formName`, `isWatcherBoundary`, `isNavigation`, `latitude`, `longitude`). |
| `NavigateRequested` | `onNavigateRequested` | `latitude`, `longitude` | The user taps the map while navigation is on. |
| `DrawCompleted` | `onDrawCompleted` | `DataFromMap` | A shape is finished. |
| `GroupingDrawn` | `onGroupingDrawn` | `DataFromMap` | A grouping boundary is finished (`layerId` is null). |
| `LayerChanged` | `onLayerChanged` | `layerId` | The layer shapes are drawn into changes. Null when none. |
| `LayerVisibilityChanged` | `onLayerVisibilityChanged` | `layerId`, `visible` | A layer is shown or hidden. |
| `LayersRendered` | `onEvent` | `layerCount`, `featureCount`, `renderMs` | A layers change that draws is fully on screen. |
| `UndoRedoChanged` | `onEvent` | `undoDepth`, `redoDepth` | The undo or redo history changes. |
| `ModeChanged` | `onModeChanged` | `MapClickListenerType` | Drawing, grouping or navigation starts or stops. |
| `WorkAreaVisibilityChanged` | `onWorkAreaVisibilityChanged` | `workAreaId`, `visible` | A work area is shown or hidden. |
| `BaseMapChanged` | `onBaseMapChanged` | `baseMap` | The imagery changes. |
| `EntriesVisibilityChanged` | `onEntriesVisibilityChanged` | `visible` | Saved entries are shown or hidden. |
| `LocationAccuracyImproved` | `onEvent` | `accuracy` | GPS accuracy improves while locating. |
| `LocationFound` | `onLocationFound` | `position`, `accuracy` | A position is found. |
| `MapFailure` | `onError` | `error`, `requiresGrouping` | Anything went wrong. See [Errors](#errors). |

Controller users can listen with `controller.addListener`, which receives the
same events. `mode` is `NONE`, `PICKING_GEOMETRY`, `CATEGORIZING_GEOMETRY` or
`NAVIGATING`. `LociMapView` shows a `MapFailure` message as a toast; set
`view.showErrors = false` to handle it yourself.

## The controller

Use the controller for commands: drawing, zooming, showing and hiding. Get it
as `view.controller`. Setup and results are properties and callbacks on the
view ([Work areas](#work-areas), [Events](#events)).

A command never throws. Commands that return nothing (such as `attach`,
`setLayers`, `setFeatures` and `undo`) run once the map is ready, so you can
call them at any time. Commands that return a `MapResult` (such as
`showLayer`, `startDrawing` and `zoomToFeature`) fail with
`MapInitializationError` until the map is ready — call them after
`onMapReady`, or inside `whenReady { }`.

```kotlin results theme={null}
val result = controller.setActiveLayer("parcels")
if (result.isFailure) println(result.errorOrNull()?.message)     // safe to show a user

controller.whenReady {
    controller.zoomToWorkArea("A1")
}
```

| Member | What it does |
| - | - |
| `attach(options, workAreas)` | Start the map with its options and work areas. `view.options` calls it. Raises `MapReady`. |
| `attachPin(id, lat, lng)` | Address-verification mode: one pin, no work areas, no geofencing. |
| `setWorkAreas(list)` | Replace the work areas. `view.workAreas` calls it. Visibility choices are kept for ids that remain. |
| `showWorkArea(id)` / `hideWorkArea(id)` / `toggleWorkArea(id)` | Control visibility. Each emits `WorkAreaVisibilityChanged`. |
| `setFeatures(features, group, fitCamera)` | Draw saved entries, replacing what is shown for that group. `view.features` calls it. |
| `showEntries()` / `hideEntries()` | Show the last features again, or hide them. |
| `setLayers(layers, activeLayerId, visibleLayerIds, fitCamera)` | Replace the layers. `view.layers` calls it. Throws `MapLayerFormatException` for a repeated id or a mismatched feature. |
| `setActiveLayer(id)` | Choose where Draw goes and show that layer. Cancels a drawing in progress. |
| `showLayer(id)` / `hideLayer(id)` / `toggleLayer(id)` | Control layer visibility. Each emits `LayerVisibilityChanged`. |
| `setLayerColor(id, color)` / `layerColor(id)` | Recolour a layer in place with an ARGB int, or null for its own look. |
| `setStyle(style)` / `setLayerStyle(id, style)` | How entries look for every layer, or for one. See [Styles](#styles). |
| `setWorkAreaStyle(style)` / `setLocationStyle(style)` | How work areas and the user's position look. |
| `startDrawing(editingEntryId, editingOriginal, geometryOverride)` | Let the user draw. `editingEntryId` relocates a point; `geometryOverride` draws another type. |
| `stopDrawing()` | Leave drawing mode and clear the draft. |
| `undo()` / `redo()` / `closePolygon()` | Step through a line or polygon, or close the polygon being drawn. |
| `startNavigation()` / `stopNavigation()` | Turn navigation taps on or off. |
| `startGrouping()` | Draw a boundary around entries (needs the grouping flag, else `MapFeatureDisabledError`). |
| `zoomIn()` / `zoomOut()` | One zoom step. |
| `zoomToFeature(entryId)` / `zoomToWorkArea(id)` | Fly to an entry, or fit a work area. |
| `setBaseMap(baseMap)` / `toggleBaseMap()` | Show `MapBaseMap.IMAGERY_1`, `IMAGERY_2` or `IMAGERY_3`, or move to the next. |
| `locateMe(moveCamera)` | Find the device position, reporting accuracy as it improves. Needs the location permission. |
| `addListener(listener)` | Receive every `MapEvent`. Returns the function that stops it. |
| `whenReady { }` / `whenRendered { }` / `isReady` | Run a block when the map is ready, or when everything asked for is drawn. |
| `release()` | Stop the GPS and free the map. `LociMapView.release()` calls it. |

Current state is on `controller.core`:

```kotlin state theme={null}
val core = controller.core
core.mode                      // NONE, PICKING_GEOMETRY, CATEGORIZING_GEOMETRY or NAVIGATING
core.canUndo; core.canRedo
core.entriesVisible
core.workAreas                 // the live work areas, with their visibility
core.layers; core.activeLayerId; core.visibleLayerIds
core.totalEntriesCount; core.uncategorizedEntriesCount
core.activeBaseMap
core.userLocation              // the last known position, or null
```

## Built-in controls

`LociMapView` draws these controls over the map. Each one calls a controller
command. Rename with `labels = LociMapLabels(key = "text")`, hide with
`controls = LociMapControls(flag = false)`, or replace with `builders =
LociMapBuilders(...)`. While drawing, the options list is replaced by undo,
redo and cancel.

| 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` |
| Show/Hide 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 between Imagery 1, 2 and 3. | `toggleImagery` | `imagery` |
| Zoom in / out | Zoom by one step; each disables at the limit. | `zoomIn` / `zoomOut` | `zoom` |
| Draw | Starts drawing the active layer's shape. | `draw` | `drawing` |
| Save | Replaces Draw once a shape is finished; appears only when you set `onSave`. | `save` | `drawing` |
| Undo / Redo / Cancel | The top row while drawing. | `undo` / `redo` / `cancel` | `drawing` |
| Gradient | The dark fade behind the top controls. | — | `gradient` |

## Make it match your app

Colours are ARGB integers. Anything you leave out keeps its default.

| Property on `LociMapView` | Type | Use it to |
| - | - | - |
| `theme` | `LociMapTheme` | Override `primary`, `onPrimary`, `controlColor`, `onControlColor`, `tileColor`, `pillTextSizeSp` (12) and the radii `buttonRadiusDp` (12), `smallButtonRadiusDp` (8), `pillRadiusDp` (10), `dialogRadiusDp` (20). Anything null follows the defaults. |
| `labels` | `LociMapLabels` | Rename or translate every string on the controls. |
| `controls` | `LociMapControls` | Turn controls on or off: `zoom`, `locate`, `drawing`, `workAreas`, `imagery`, `navigate`, `entriesToggle`, `layers`, `gradient`. `LociMapControls.NONE` hides them all. |
| `builders` | `LociMapBuilders` | Replace a control with your own view: `zoom`, `drawing`, `options`, `action` (Draw and Save), `overlay`. Each builder receives a `Context` and the controller, and wins over the matching flag. |
| `onSave`, `onSaveGrouping` | `(DataFromMap) -> Unit` | Handle the Save button. Setting `onSave` is what makes Save appear; `onSaveGrouping` handles a grouping boundary and falls back to `onSave`. |
| `showErrors` | `Boolean` | Turn off the built-in error toast and handle `onError` yourself. |

```kotlin rename, hide, recolour theme={null}
view.labels = LociMapLabels(draw = "Add household", save = "Save household")
view.controls = LociMapControls(entriesToggle = false, gradient = false)
view.theme = LociMapTheme(
    primary = 0xFF00897B.toInt(),
    onPrimary = 0xFFFFFFFF.toInt(),
    buttonRadiusDp = 16f,
    pillRadiusDp = 20f,
)
```

A builder receives the context and the controller. This one replaces the zoom
buttons with a single button:

```kotlin replace one control theme={null}
view.builders = LociMapBuilders(
    zoom = { context, controller ->
        Button(context).apply {
            text = "Closer"
            setOnClickListener { controller.zoomIn() }
        }
    },
)
```

## Geofencing

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 inside the same one.
To try the map without being in a work area, switch the check off:

```kotlin testing only theme={null}
val options = MapOptions(mapId = "demo", enforceGeofencing = false)
```

| 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, user position falls back to the shape |
| Editing a shape stays in bounds | Enforced | Skipped |
| Minimum zoom 13 | Enforced | Enforced |
| Grouping threshold | Enforced when grouping is on | Enforced |

> [!WARNING]
>
> Do not ship `enforceGeofencing = false`. Data drawn outside a work area is
> accepted, and nothing marks it.

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

```kotlin grouping theme={null}
val options = MapOptions(
    mapId = "survey-1",
    flags = MapFeatureFlags(grouping = true),
    watcher = MapWatcherRule(type = "form", value = 20),     // 20 entries per group
)
```

With it on, **Group Entries** appears while entries are ungrouped. When the
threshold stops a draw, `MapFailure.requiresGrouping` is true. A rule of type
`form` counts entries; any other type sums the `columnName` property.

## Options and flags

Set `MapOptions` on `view.options`. Only `mapId` is required.

| 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. With layers, the active layer decides instead. |
| `baseMap` | `MapBaseMap` | The map shown first. `IMAGERY_1` by default. |
| `baseMaps` | `List<MapBaseMap>` | The maps the user can switch between. All three by default. |
| `watcher` | `MapWatcherRule?` | Threshold rule. Used only when grouping is on. |
| `flags` | `MapFeatureFlags` | Optional features, all off by default. |
| `enforceGeofencing` | `Boolean` | Default `true`. See [Geofencing](#geofencing). |
| `strictGeometry` | `Boolean` | Default `true`. With layers, reject features that do not match their layer. |
| `style` | `LayerStyle` | How entries look unless their layer has its own style. See [Styles](#styles). |
| `workAreaStyle`, `locationStyle` | `WorkAreaStyle`, `LocationStyle` | How work-area outlines and the user's position look. |
| `largeSetThreshold` | `Int` | A layer with more features than this is drawn in a way that stays light at any size. Default 3,000. Must be at least 1. |

```kotlin baseMaps theme={null}
val options = MapOptions(
    mapId = "survey-1",
    baseMap = MapBaseMap.IMAGERY_3,
    baseMaps = listOf(MapBaseMap.IMAGERY_1, MapBaseMap.IMAGERY_3),
)
```

## Styles

Colours are ARGB integers; in JSON, `#RRGGBB` or `#AARRGGBB`. Set styles in
`MapOptions`, per layer, or later on the controller. A layer's style takes
priority over `MapOptions.style`.

| Class | Fields (defaults) |
| - | - |
| `MarkerStyle` | `shape` (CIRCLE), `color` (`0xFF803785`), `sizeDp` (24, 8–96), `innerColor` (white), `labelColor` (null, uses `color`), `showLabel` (true), `iconPng` (null, replaces the shape). |
| `MarkerShape` | `CIRCLE`, `SQUARE`, `ROUNDED_SQUARE`, `DIAMOND`, `TRIANGLE`, `HEXAGON`, `PIN`. |
| `LayerStyle` | `point` (a `MarkerStyle`), `polygonCentre` (a red `MarkerStyle`), `lineColor` (red), `lineWidth` (2), `fillColor`, `fillOpacity` (0.40). |
| `WorkAreaStyle` | `lineColor`, `lineWidth` (3), `labelColor`. |
| `LocationStyle` | `sizeDp` (36), `ringColor`, `dotColor`, `iconPng`. |

Constructors check the ranges; `fromJson` clamps or ignores bad values.

```kotlin styles theme={null}
// Pin-shaped, bigger markers for every layer, from the start.
val options = MapOptions(
    mapId = "survey-1",
    geometry = DrawGeometry.POINT,
    style = LayerStyle(point = MarkerStyle(shape = MarkerShape.PIN, color = 0xFF1565C0.toInt(), sizeDp = 40f)),
    workAreaStyle = WorkAreaStyle(lineColor = 0xFF2E7D32.toInt(), lineWidth = 4.0),
)

// One layer gets its own look.
val parcels = MapLayer(
    id = "parcels", label = "Parcels", geometry = DrawGeometry.POLYGON,
    style = LayerStyle(
        polygonCentre = MarkerStyle(shape = MarkerShape.HEXAGON, color = 0xFFEF6C00.toInt()),
        lineColor = 0xFFEF6C00.toInt(),
        fillOpacity = 0.25,
    ),
)

// Change them while the map is open.
controller.setStyle(LayerStyle(point = MarkerStyle(shape = MarkerShape.DIAMOND, sizeDp = 32f)))
controller.setLayerStyle("parcels", null)               // back to the default
controller.setWorkAreaStyle(WorkAreaStyle(lineWidth = 5.0))

// The user's position: a bigger puck, or your own PNG.
controller.setLocationStyle(LocationStyle(sizeDp = 48f, ringColor = 0xE6FFFFFF.toInt(), dotColor = 0xFF1565C0.toInt()))
controller.setLocationStyle(LocationStyle(iconPng = puckPngBytes))
```

A PNG replaces the drawn shape; it is scaled to `sizeDp`, and the number is
drawn over it unless you turn it off with `showLabel = false`. Build a style
from JSON, where missing or unreadable fields keep their defaults:

```json style.json theme={null}
{
  "point": { "shape": "hexagon", "color": "#1E88E5", "sizeDp": 28 },
  "polygonCentre": { "shape": "square", "color": "#1B5E20" },
  "lineColor": "#1B5E20", "lineWidth": 3,
  "fillColor": "#66BB6A", "fillOpacity": 0.35
}
```

```kotlin parse it theme={null}
val style = LayerStyle.fromJson(JSONObject(text))
```

## Errors

Every error extends `MapError` and has a `message` you can show. They arrive
in `onError` 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. `requiresCategorization` says whether grouping is needed. |
| `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, or a command that needs a ready map ran too early. |
| `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.

```kotlin handle errors theme={null}
view.onError = { error, requiresGrouping ->
    show(error.message ?: "Something went wrong")        // safe to show a user
    if (requiresGrouping) { /* offer to group entries */ }
}

val result = view.controller.zoomToFeature(42)
when (val error = result.errorOrNull()) {
    is ZoomError -> show("That entry is not on the map")
    null -> Unit
    else -> show(error.message ?: "")
}
```

| You see | Why | What to do |
| - | - | - |
| Draw does nothing and the map reports a zoom error | Drawing needs zoom 13 or closer. | Zoom in, or call `zoomToWorkArea`. |
| "This point is not within your work area" | Geofencing: the point, or the user, is outside every visible work area. | Show a work area and stand inside it. On an emulator, set the location inside it. |
| Save never appears | The Save button shows only when you set `onSave` or `onSaveGrouping`. | Set `view.onSave`. |
| Locate Me reports a location error | The permission was not granted, or location is off. | Request `ACCESS_FINE_LOCATION` before the map is used. |
| A command returns a failure saying the map is not ready | It needs a ready map. | Call it after `onMapReady`, or inside `whenReady { }`. |
| A layer you added later is not on the map | New layers start hidden after the first time you set layers. | Call `controller.showLayer(id)`. |
| The Layers option is missing | It appears only with two or more layers. | Give the map two layers. |

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/android-frames#errors-and-events).


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