> ## 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.

# Frames

> Fill the map from the LOCI frame index. Initialise the SDK with an API key, preload frames and the data recorded on them, cache for offline use, and spend the key's budget on purpose.

The map renders boundaries you already hold without any credential. To fill
it from LOCI instead — the frame under a point, the data recorded on a frame
— you give the SDK an **API key** and preload frames before the map appears.
The map never touches the network itself: panning, drawing, showing and
hiding work areas never wait on a connection and never spend the key's
budget.

Everything on this page is the SDK making the same calls this site
documents. The request and response contract is the
[API reference](/api-reference/introduction); this page is which of those
calls the SDK makes, and when.

## Which API calls the SDK makes

The SDK makes exactly three kinds of request, all on the frames service. It
does **not** call `/v1/frames/admin`, `/v1/states`, `/v1/lgas`, `/v1/wards`
or `/v1/frames/insight`, and it never looks frames up by box.

| SDK method | API call | Needs |
| - | - | - |
| `frameAtLocation(lat, lng)` | [`GET /v1/frames/at`](/api-reference/introduction) | `frame.read` |
| `frameFromWorkArea(area)` | `GET /v1/frames/at` for a point inside your polygon | `frame.read` |
| `loadFrames(ids)`, `savePack(...)` | [`GET /v1/frames?ids=`](/api-reference/introduction) for outlines, then [`GET /v1/frames/data?ids=`](/api-reference/introduction) in batches of 100 | `frame.read`; `feature.read` for feature datasets |

## Get your key

The SDK needs one credential, an API key. Create it in your LOCI dashboard
under **Developers → New key**: pick a scope and an expiry, and copy the
secret, which is shown once. See [Authentication](/authentication) for the
full story, and [Scopes](/scopes) for what each preset reaches.

| Scope | For frames, this gives you |
| - | - |
| **Locality** | The frame under a point, and the baseline rows recorded on frames (administrative, population, buildings, physical, cultural). |
| **Analyst** | The same reads. The question call it adds is not used by the SDK. |
| **Full read** | Also the individual points, lines and polygons recorded on each frame — the `feature.read` a dataset of shapes needs. |

<Note>
  A key is read-only, acts for one organisation, and expires. A key narrowed
  to named datasets only ever preloads those datasets. Every device that
  uses the same key shares one rate-limit budget — see
  [cost](#rate-limits-and-cost).
</Note>

## Initialise the SDK

Call `MilsatMaps.initialize` once, for example in `main`, with the key you
created. After that, `MilsatMaps.frames` is the object every example here
uses. Call `MilsatMaps.dispose` when your app is done with it.

```dart initialise the SDK theme={null}
await MilsatMaps.initialize(apiKey: '<your key>');
```

## Preload your frames

You hand the SDK a list of frame ids. It fetches each frame's outline and
the data recorded on it, groups the data by layer, and gives back ready-made
work areas. Call these methods in a **prepare step** before the map appears
— when the user opens a project, say — not from a tap or a camera callback.

```dart load, then show theme={null}
final result = await MilsatMaps.frames.loadFrames(
  ['2163538', '2163551'],
  hiddenLayerIds: {'internal-notes'},
);
await controller.setWorkAreas(result.workAreas);
```

Each frame is a `MapWorkArea` with the frame id as its id; the dataset rows
sit under `properties[MilsatFramePack.propertyKey]`, grouped by layer id.
Pass the list to `controller.setWorkAreas`, or to the prebuilt widget as
`workAreas:`. Frames arrive switched off, like any work area with
`initiallyVisible` false; the user turns them on, or you call
`controller.showWorkArea(id)`. A frame is a work area, so every work-area
rule, including [geofencing](/sdks/flutter-maps#geofencing), applies.

### What you get back

| Field | Meaning |
| - | - |
| `workAreas` | One per frame found. Hidden layers are removed. |
| `frameIds` | The ids you asked for. |
| `missingIds` | Ids the service has no frame or no data for. **Not an error** — show it to the user. |
| `charged` | Datasets this call was billed for. See [cost](#rate-limits-and-cost). |
| `leftOut` | Datasets your key is not allowed to read, with the reason. |
| `truncatedLayerIds` | Layers that hit the 200-feature cap per request, so may be incomplete. |
| `fromPack`, `packId`, `createdAtSeconds` | Whether the data came from a saved pack, which one, and when it was sealed. |
| `requests` | HTTP requests this call made. Zero when it came from a pack. |

```dart read a result theme={null}
for (final area in result.workAreas) {
  final rows = area.properties[MilsatFramePack.propertyKey];
  debugPrint('${area.id}: $rows');
}
if (result.missingIds.isNotEmpty) {
  showBanner('No data for ${result.missingIds.join(', ')}');
}
debugPrint('${result.requests} requests, from a pack: ${result.fromPack}');
```

## Finding ids to start from

A frame id is a string of digits. You can hold ids from your dashboard or
your own records, or resolve them.

<Tabs>
  <Tab title="You have ids">
    Ids go straight to `loadFrames`. An id the service does not know comes
    back in `missingIds`, not as an error.

    ```dart theme={null}
    final result = await MilsatMaps.frames.loadFrames(['2163538', '2163551']);
    ```
  </Tab>

  <Tab title="You have a point">
    `frameAtLocation(latitude, longitude)` returns the frame that contains
    the point. A point outside the grid throws `not_found`.

    ```dart theme={null}
    final frame = await MilsatMaps.frames.frameAtLocation(9.0579, 7.4951);
    final result = await MilsatMaps.frames.loadFrames([frame.id]);
    ```
  </Tab>

  <Tab title="You have a polygon">
    `frameFromWorkArea(area)` finds the frame your own work area belongs to.
    The SDK picks a point inside your polygon and asks the frame-at request
    about it — a point inside, because a centroid can fall outside a
    C-shaped or holed polygon.

    ```dart theme={null}
    final mine = MapWorkArea.fromGeoJson(myPolygonGeoJson, id: 'plot-12');
    final frame = await MilsatMaps.frames.frameFromWorkArea(mine);
    final result = await MilsatMaps.frames.loadFrames([frame.id]);
    ```

    One point resolves one frame. A work area spanning several frames
    resolves to the frame under its interior point, not to every frame it
    touches.
  </Tab>

  <Tab title="You have a box">
    The SDK does not query by box. `MilsatWorkArea.fromBbox(bbox, id)` is a
    pure helper that turns a box into a work area with no network; pass the
    result to `frameFromWorkArea` to find the frame under it.

    ```dart theme={null}
    final area = MilsatWorkArea.fromBbox([7.485, 9.048, 7.505, 9.068], 'site-1');
    final frame = await MilsatMaps.frames.frameFromWorkArea(area);
    ```
  </Tab>
</Tabs>

## Offline packs

A pack is a saved set of frames and their data, kept on the device under an
id you choose (1–64 of letters, digits, `_` or `-`). Nothing is cached
unless you call these; nothing refreshes a pack for you.

```dart save, load, refresh, list, delete theme={null}
await MilsatMaps.frames.savePack('ward-7', ids,
    hiddenLayerIds: {'internal-notes'}, maxAge: const Duration(days: 14));
final offline = await MilsatMaps.frames.loadPack('ward-7'); // no network
await MilsatMaps.frames.refreshPack('ward-7');
final names = await MilsatMaps.frames.listPacks();
await MilsatMaps.frames.deletePack('ward-7');
```

| Call | What it does |
| - | - |
| `savePack` | Fetches the ids and seals them. Replaces any pack with that id. If anything fails, the previous pack stays exactly as it was. |
| `loadPack` | Reads the pack. No network. Throws when missing, foreign-organisation, or expired. Never deletes the pack. |
| `refreshPack` | Fetches again and replaces the pack. Runs only when you call it. Two concurrent refreshes share one request. |
| `deletePack` | Removes the pack file. Returns whether there was one. |
| `listPacks` | Pack ids on this device, sorted. |

**Expiry.** `maxAge` on `savePack`/`refreshPack` marks a pack expired (not
deleted) after that long; `loadPack` then throws. With no `maxAge` a pack
never expires. **Hidden layers** passed to any call are left out when data
is stored and again when it is returned, so a hidden layer is not on the
map, not in the result, and not in the pack file.

### Source: cloud or pack

`loadFrames` takes one `source`, so the two stores look like a single
choice.

| Source | Behaviour | Needs `packId` |
| - | - | :-: |
| `cloud` (default) | Always asks the service. Never reads or writes a pack. | No |
| `packOnly` | Only the saved pack. Throws `missing` or `expired` if it cannot serve one. No network. | Yes |
| `packFirst` | The pack when present and fresh, otherwise the service. Does not save what it fetched. | Yes |

```dart the three sources theme={null}
final fresh  = await MilsatMaps.frames.loadFrames(ids);
final stored = await MilsatMaps.frames.loadFrames(ids,
    source: MilsatFramesSource.packOnly, packId: 'ward-7');
final either = await MilsatMaps.frames.loadFrames(ids,
    source: MilsatFramesSource.packFirst, packId: 'ward-7');
```

## Errors and events

Failures are typed and the same on Android and iOS. None carries the key.
Branch on the code, never on the message.

```dart handle each family theme={null}
try {
  await MilsatMaps.frames.loadFrames(['2163538']);
} on MilsatFramesException catch (e) {
  switch (e.code) {
    case MilsatFramesErrorCode.unauthorized:
    case MilsatFramesErrorCode.invalidKey:
    case MilsatFramesErrorCode.insufficientScope:
      askForNewKey();
    case MilsatFramesErrorCode.rateLimited:
      waitAndTryAgain(e.retryAfterSeconds);
    case MilsatFramesErrorCode.network:
      showBanner('No connection. Try a saved pack.');
    default:
      showBanner('Frames said ${e.code.wire} (${e.status}).');
  }
} on MilsatFramesPackStoreException catch (e) {
  // missing, expired, wrongOrganisation, storageFailed
  showBanner(e.failure.name);
}
```

### Service errors — `MilsatFramesException`

These mirror the API's own [error codes](/errors). The SDK adds three
transport codes the service never sends: `network`, `untrusted_connection`
and `unknown`.

| Code | Means | Retried by the SDK |
| - | - | - |
| `unauthorized` | The request carried no key. | No |
| `invalid_key` | The service refused the key: wrong, revoked or expired. | No |
| `insufficient_scope` | The key is valid but not allowed to read this data. | No |
| `invalid_point` | A point request had a bad coordinate. | No |
| `invalid_bbox` | A box request had a bad box. | No |
| `invalid` | Malformed, for example a bad id list. | No |
| `not_found` | Nothing there; the point is outside the grid. | No |
| `rate_limited` | Too many requests. `retryAfterSeconds` says how long to wait. | Yes, up to 3 times |
| `internal` | The service failed. | Yes, once |
| `network` | No connection or a timeout (SDK transport). | Yes, once |
| `untrusted_connection` | The secure connection could not be established. Status is always 0 (SDK transport). | No |
| `unknown` | A response this version does not recognise. | No |

The three key codes are final: the SDK does not retry them and emits a
`MilsatFramesTokenRejected` event. On `rate_limited` the SDK waits the
`Retry-After` time and tries up to three more times before surfacing the
error; do not loop on it. See [Limits](/limits).

### Pack errors

A `MilsatFramesPackStoreException` reports `missing`, `expired`,
`wrongOrganisation` or `storageFailed`. A `MilsatFramesPackException`
reports a damaged or foreign pack file (`authenticationFailed`, `truncated`,
`badMagic`, `unsupportedVersion`, `corruptPayload`). Every pack failure
closes: you get an error and no data, and **nothing is deleted** — a failed
read never destroys a pack a fix could recover. Call `deletePack` to get rid
of a bad one.

### Events

```dart listen for events theme={null}
MilsatMaps.frames.events.listen((event) {
  switch (event) {
    case MilsatFramesTokenRejected(:final code):
      askForNewKey();
    case MilsatFramesMayBeTruncated(:final layerIds):
      debugPrint('Possibly incomplete: $layerIds');
    default:
      break;
  }
});
```

Billing is not an event: read `charged` on each result.

## Rate limits and cost

The limits and caps are the API's own — the full detail is in
[Limits](/limits). Read `retryAfterSeconds` on a `rate_limited` error rather
than any number here, because the numbers can change.

* **Shared budget.** Every device using the same key shares one bucket. A
  whole team loading frames at 9am will see the first few succeed and the
  rest get `rate_limited`. Stagger syncs, or give large teams more than one
  key.
* **Load once, then work from a pack.** Save a pack when the user opens a
  project and use `packFirst` or `packOnly` after that. Do not refresh on
  startup, on a timer, or in a retry loop — each refresh spends requests
  again.
* **The cost of a load.** The SDK sends at most 100 ids per request, one
  request at a time, and a load makes two kinds of call (outlines and data),
  so `N` ids cost about `2 × ceil(N / 100)` requests. Resolving a point or a
  work area is one request.
* **Watch `charged`.** Some datasets are
  [licensed per query](/limits#per-query-datasets) and billed per request
  they contribute to; each refresh bills them again.
* **Watch `truncatedLayerIds`.** A layer at 200 features per request may be
  incomplete, and nothing else says so.

<Warning>
  Keys expire, and a key stops working when the person who created it leaves
  the organisation. A phone offline for weeks may find its key dead — but
  pack data already on the device stays readable.
</Warning>


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