# Freeboard-SK: Plotter Extension Host Support

Freeboard-SK is the **reference host** for the Signal K *Plotter Extensions API* —
the mechanism by which a Signal K server plugin contributes UI (panels, widgets,
buttons) and behaviour into a chartplotter web app through sandboxed iframes that
talk to the host over a small JSON-RPC message bus.

This document is an implementation reference for the **host side** in
Freeboard-SK. The host-agnostic contracts it implements live in:

- [`../api/plotter-extensions-api.md`](../api/plotter-extensions-api.md) — the API
  (capabilities, methods, events) any conforming host exposes.
- [`../api/plotter_extension_provider_plugins.md`](../api/plotter_extension_provider_plugins.md)
  — the "using the APIs" guide for plugin authors who *provide* extensions.

The wire contract (the JSON-RPC-over-`postMessage` bus) is the
[`signalk-plotterext-bus`](https://www.npmjs.com/package/signalk-plotterext-bus)
package; Freeboard depends on it (`^0.8.0`) and imports its host entry point
(`signalk-plotterext-bus/host`).

## How the host works

- **Discovery.** Extensions are advertised as `plotterExtensions` resources
  (`/signalk/v2/api/resources/plotterExtensions`). `PlotterExtensionService`
  reads the manifests, keeps the compatible ones, and drives the UI from them.
- **One connection per context.** Each live extension iframe (panel, widget, or a
  headless background runtime) gets its own `HostConnection` over a
  `windowPort`. The host answers the extension's method calls and relays events
  the extension has subscribed to.
- **No host-side enable/disable.** Extension availability is controlled entirely
  on the server (plugin install + enable). Presence in the `plotterExtensions`
  collection is the enablement signal.
- **Capability negotiation.** The host advertises `HOST_API_VERSION = '1'` and a
  `HOST_CAPABILITIES` set; an extension declares what it `requires` and the host
  only mounts it when compatible.

### Capabilities Freeboard advertises (`HOST_CAPABILITIES`)

`widgets`, `panels.iframe`, `buttons`, `signalk.stream`, `signalk.put`, `units`,
`map`, `resources`, `resources.filter`, `routes`, `charts`, `nightMode`,
`background.iframe`, `ui`.

(The authoritative list is `HOST_CAPABILITIES` in
`src/app/modules/plotterext/types.ts`.)

### Embedding hosts (reverse embedding)

Freeboard can also run **inside** another application's iframe (an *embedding
host*, e.g. KIP) and expose the same host API to that parent — the reverse of the
usual topology. See the API spec's *Embedding Hosts* section for the contract.

- `PlotterExtensionService.attachEmbeddingHost()` registers one `HostConnection`
  whose `windowPort` targets `window.parent`, with the full method set and a
  `kind: 'embedding-host'` context. `init()` calls it once, only when embedded
  (`!app.isTopWindow()`). Freeboard only stands up the endpoint — the **embedding
  application initiates** the handshake by calling `connectExtension` from the
  parent frame.
- **Same-origin only.** The port pins the origin to Freeboard's own
  (`window.location.origin`) — a cross-origin embedder is refused. A same-origin
  embedder already shares the user's session, so this grants no new authority.
- **Caller identity.** `adoptCallerId: true` adopts the id the embedder asserts in
  `bus.ready` as the handshake `context.id`; `state.*` stays under a single
  `embedding-host` scope (one embedder per server in practice).
- The legacy parent-`postMessage` night-mode bridge in `app.facade.ts`
  (`parseMessageFromParent`) is retained for backward compatibility.

## The `routes` capability

`routes` lets an extension create and edit routes on the chart — e.g. an
auto-router that reshapes a drawn route around land. Freeboard is both the
reference host for the capability and a first-class user of the underlying model
in its own native route UI.

### The visible-route model

Routes the user is working with live in an in-memory **visible set**, managed by
`RouteBufferRegistry`. A route in the set is addressed by an opaque `routeId` and
carries two flags:

- **`saved`** — backed by a stored `routes` resource on the server.
- **`dirty`** — has edits not yet persisted.

So a route is one of: an unsaved **draft** (`saved:false`), a clean mirror of a
stored route (`saved:true, dirty:false`), or a stored route with pending edits
(`saved:true, dirty:true`).

### Methods

`route.list`, `route.create` (≥2 points), `route.show(ref)`, `route.get`,
`route.replace(routeId, points)`, `route.save(routeId, {name?, description?,
dialog?})`, `route.hide`, `route.delete`. Geometry is always edited **whole**
(`route.replace`) — there is no per-point CRUD; routes are small and the host's
native editing yields whole coordinate arrays, so whole-replace is the practical
shape (see the API spec for the rationale).

### Events (`route.**`)

`route.visible`, `route.dirty`, `route.saved`, `route.hidden`. **`route.dirty` is
the conformance floor**: every content change emits it, so a follower can stay in
sync with a single "on `route.dirty`, re-`route.get`" loop without tracking who
changed what. Events fire for *every* change regardless of origin — an extension
command, the user's native editing, or another extension.

### Error reasons

Failures reject with a stable `error.data.reason`: `routes.unknownId`,
`routes.badRequest`, `routes.badRef`, `routes.saveFailed` (server rejected a
persist — distinct from the user-cancel `routes.saveCancelled`),
`routes.deleteFailed`, `routes.notSupported`.

### The Freeboard route UX built on this

- Drawing a route creates an editable **amber draft** you review/adjust on the
  chart before saving, instead of going straight to a save dialog.
- **Tap-to-edit** a live route; the route popover and info panel offer **Save /
  Edit / Delete** consistently (drafts get a quick Delete/discard; server-backed
  actions like Start / Route Points / Show Notes are hidden for unsaved drafts).
- Saving keeps the route on the map (it isn't consumed), and per-point
  names/descriptions plus the route description round-trip through save.
- Freeboard mirrors its own displayed/edited routes into the visible set, so
  extensions observe native edits as `route.*` events too.

### Key files

| File | Role |
|------|------|
| `src/app/modules/plotterext/plotterext.service.ts` | host service: discovery, per-iframe `HostConnection`, method dispatch, event bridging |
| `src/app/modules/plotterext/route-buffer.registry.ts` | the `routeId → buffer` visible-set registry (`saved`/`dirty`/`rev`, events) |
| `src/app/modules/plotterext/route-methods.ts` | the `route.*` method handlers + param validation |
| `src/app/modules/plotterext/types.ts` | `HOST_API_VERSION`, `HOST_CAPABILITIES`, manifest types |
| `src/app/modules/map/fb-map.component.ts` + `app.component.ts` | native route draw/modify/delete bridged into the registry |

## The `charts` capability

`charts` is a lightweight facade over the chart layers Freeboard **already
manages** — the same charts the user turns on and off in the chart controls. An
extension enumerates them and changes visibility, opacity and stacking order. It
is deliberately **not** a chart provider: no create, add, import or delete.

### The chart model

Freeboard's charts come from the server `charts` resource plus the built-in
OpenStreetMap / OpenSeaMap defaults. The displayed set is `SKResourceService`'s
`charts()` signal (bottom-first render order); which charts are on is stored in
`app.config.selections.charts` (an explicit id list, or `null` = "show all"),
their stacking in `selections.chartOrder`, and per-chart opacity in
`selections.chartOpacity`. Each chart is addressed by an **opaque `id`**.

### Methods

`chart.list`, `chart.setVisibility({ids, visible})`,
`chart.setOpacity({ids, opacity})`, `chart.setOrder({order})`. All mutators are
**batch** (take a set of ids). `chart.list` returns the full available set
(visible **and** available-but-off charts, each with a `visible` flag) in display
order, **topmost first** — so it doubles as the way to read the current order.
The handlers are a pure factory (`chart-methods.ts`) over accessors the service
binds to `SKResourceService`.

### Events (`chart.**`)

`chart.visibility` (`{id, visible}`), `chart.opacity` (`{id, opacity}`),
`chart.order` (`{order}`). Emitted for **every** change regardless of origin — an
extension command *or* the user's own chart controls — by diffing successive
`charts()` emissions in an Angular `effect`. A batch `chart.setVisibility` emits
one `chart.visibility` per *changed* chart. `chart.order` carries the topmost-first
order of the **displayed** charts (hidden charts have no stacking position);
re-read `chart.list` for the full snapshot.

### Freeboard specifics

- **`chart.setOrder` is host-clamped.** OpenStreetMap sits at the bottom and the
  reorder applies to the rendered (visible) charts; the request expresses relative
  intent and the resulting order is whatever the next `chart.list` reports.
- **Show-all vs. explicit set.** When no chart has been deselected, Freeboard runs
  in "show all" mode (`selections.charts === null`); hiding a chart materialises
  the explicit selection, and showing the last missing one returns to show-all —
  matching the native chart list's behaviour so extension and user stay in sync.
- **Opacity persists.** `chart.setOpacity` writes `selections.chartOpacity` so the
  value survives a refresh and applies when a currently-hidden chart is shown.

### Error reasons

`charts.unknownId` (a supplied id names no managed chart), `charts.badRequest`
(malformed params — e.g. a non-array `ids`, non-boolean `visible`, or out-of-range
`opacity`), `charts.notSupported`.

### Key files

| File | Role |
|------|------|
| `src/app/modules/plotterext/chart-methods.ts` | the `chart.*` handlers + param validation + `FBChart`→`ChartLayer` mapping |
| `src/app/modules/plotterext/plotterext.service.ts` | binds the handlers to the service and emits `chart.*` events (`emitChartChanges`) |
| `src/app/modules/skresources/resources.service.ts` | `chartsForHostApi` / `setChartsVisibility` / `setChartsOpacity` / `setChartsOrder` |

## The `nightMode` capability

`nightMode` lets an extension read and control Freeboard's night-vision display
mode — the dimmed low-light appearance the user toggles from the display settings
or that tracks the server's `environment.mode`. It exists so an embedded panel
(e.g. an instrument gauge) can match the host instead of glowing white on a dark
bridge, and it is the first step in moving the bespoke KIP integration onto the
standard API.

### The night-mode model

The applied night state (the `app-night` class) is
`stream.selfNightMode() || uiCtrl().forceNightMode`: the **auto** path
(`config.display.nightMode`, the "Auto-set Night Mode" setting) turns night on when
`environment.mode === 'night'`, and the **force** path (`uiCtrl().forceNightMode`)
is a manual override. The capability maps these to `{ enabled, auto }`:

- **`enabled`** — the resolved applied state (`selfNightMode() || forceNightMode`).
- **`auto`** — the "Auto-set Night Mode" setting (`config.display.nightMode`).

### Methods

`nightMode.get` → `{ enabled, auto }`; `nightMode.set({ enabled?, auto? })`. The
three effective requests: force on (`{enabled:true}`), force off
(`{enabled:false}`), follow the server (`{auto:true}`). Setting `enabled` is a
manual override — it clears `auto`; setting `{auto:true}` clears any manual force
so the state purely tracks `environment.mode`. `applyNightMode` calls
`SKStreamFacade.refreshSelfNightMode()` so an `auto` change takes effect
immediately rather than on the next delta. The handlers are a pure factory
(`nightmode-methods.ts`) over service accessors.

### Events (`nightMode.changed`)

`nightMode.changed` (`{ enabled, auto }`) is emitted for **every** change
regardless of origin — a host `nightMode.set`, the user's own display setting, or
the server's `environment.mode` flipping while `auto` is on. A reactive `effect`
covers server/force-driven changes; `applyNightMode` also emits directly so an
`auto`-only change (which may not move the resolved state) is not missed.
`emitNightModeChange` de-dupes so each real change fires exactly once.

### Error reasons

`nightMode.badRequest` (malformed params — a non-boolean `enabled`/`auto`, or
neither field present), `nightMode.notSupported`.

### Key files

| File | Role |
|------|------|
| `src/app/modules/plotterext/nightmode-methods.ts` | the `nightMode.*` handlers + param validation |
| `src/app/modules/plotterext/plotterext.service.ts` | binds the handlers (`readNightMode`/`applyNightMode`) and emits `nightMode.changed` (`emitNightModeChange`) |
| `src/app/modules/skstream/skstream.facade.ts` | `selfNightMode` signal + `refreshSelfNightMode()` |

## The `map` capability

`map` exposes the chart viewport: `map.getView` reads it, `map.center` /
`map.fitBounds` drive it, and `map.view` follows it.

Moves are routed through Freeboard's own centering path
(`AppFacade.mapMoveRequest` → `AppComponent` effect → `centerAndZoom`), never by
reaching into the OpenLayers view directly — driving the OL view bypasses the
`mapCenter`/`mapZoom` signal flow, so chart and resource layers would not refresh
after the move.

### Events (`map.view`)

`map.view` (`{ center, zoom, bounds }`) is emitted for **every** settled viewport
change regardless of origin — the user panning/zooming, Freeboard recentring on
the vessel, or an extension's own `map.center` / `map.fitBounds`. The seam is
`fb-map.component.ts`'s `onMapMoveEnd`, the OpenLayers `moveend` handler, which
writes `config.map.center`, `config.map.zoomLevel` and the `mapExtent` signal; a
reactive `effect` on `mapExtent` therefore fires once per settled move rather than
per frame. `mapView()` builds the payload so the event and `map.getView` provably
share one shape, and `emitMapViewChange` de-dupes — OL hands over a fresh extent
array on every `moveend`, so an unchanged view still re-assigns the signal and the
comparison must be by value.

### Key files

| File | Role |
|------|------|
| `src/app/modules/plotterext/plotterext.service.ts` | the `map.*` handlers, `mapView()`, and `map.view` emission (`emitMapViewChange`) |
| `src/app/modules/map/fb-map.component.ts` | `onMapMoveEnd` — the OL `moveend` seam that updates the view state |

## Extending the host API? Update the agent bridge

When you add or change a host API method here, also add or update the matching
tool in [`../../dev-tools/fsk-mcp/src/tools.js`](../../dev-tools/fsk-mcp/src/tools.js)
so the `fsk-mcp` dev bridge can drive the new behaviour from an agent (setup:
[`../dev-tools/fsk-mcp.md`](../dev-tools/fsk-mcp.md)). The generic `fsk_call` tool
already reaches any method; the curated tools are for discoverability.

## See also

- [`../api/plotter-extensions-api.md`](../api/plotter-extensions-api.md) — the
  host-agnostic API contract.
- [`../api/plotter_extension_provider_plugins.md`](../api/plotter_extension_provider_plugins.md)
  — the guide for plugin authors providing extensions.
- [`signalk-plotterext-bus`](https://www.npmjs.com/package/signalk-plotterext-bus)
  — the wire-format/message-bus package (the protocol contract).
