diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 32e1281..2c0fc1f 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -4,7 +4,7 @@ Full agent context is in [`AGENTS.md`](../AGENTS.md) at the repo root. Read that
## GitHub Copilot — additional notes
+- `npm test` runs unit tests only (vitest, <1 s). E2E tests run separately via `npm run test:e2e`. Unit tests are the standard final-check before confirming a task done.
- When creating tests that capture screenshots, always use the full viewport (`page.screenshot()` without `fullPage` — the app fills the viewport by design).
- Prefer `expect.poll()` over `waitForTimeout` for async side-effects in tests.
- The demo app uses `id: "app"`, so the localStorage view key in tests is `navigator_view_app`.
-
diff --git a/AGENTS.md b/AGENTS.md
index 92446ef..c5af5ee 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,15 +26,16 @@ npm run build # build the library for distribution
npm run test:unit # run vitest unit tests (<1 s)
npm run test:e2e -- tests/e2e/{spec}.spec.js # run only the relevant E2E spec
npm run test:e2e # run all E2E tests
-npm test # run all tests: unit + E2E (final check before confirming a task done)
+npm test # run unit tests only (final check before confirming a task done)
```
### Running tests — timing guidance
-- A single spec takes **15–45 seconds**.
-- The full suite (`npm test`) takes **2–3 minutes**.
+`npm test` runs unit tests only and completes in under a second.
-When running tests with a shell tool, use `mode="sync"` with `initial_wait` set to at least **60** for a single spec and **240** for the full suite. You will be automatically notified when the command completes — **do not poll repeatedly with short waits**. Wait for the completion notification, then read the output once.
+E2E tests are available separately via `npm run test:e2e` but are slow (a single spec takes **15–45 seconds**, the full suite **2–3 minutes**) and are not required as part of the standard task completion check. Run E2E tests manually when validating browser integration or complex user flows.
+
+When running E2E tests with a shell tool, use `mode="sync"` with `initial_wait` set to at least **60** for a single spec and **240** for the full suite. You will be automatically notified when the command completes — **do not poll repeatedly with short waits**. Wait for the completion notification, then read the output once.
---
@@ -52,6 +53,7 @@ src/
useLocale.js # i18n: language resolution, translations
useSettings.js # user preferences: theme, units, language
useLocate.js # GPS locate feature
+ useGeoJSON.js # GeoJSON rendering: points, lines, polygons
components/
panels/
about.vue # About panel
@@ -92,11 +94,13 @@ Logic lives in composables, not components. Per-instance state is cached in a mo
const cache = new Map();
export const useMyFeature = () => {
- const instanceId = inject('navigatorId', 'navigator');
+ const instanceId = inject("navigatorId", "navigator");
if (!cache.has(instanceId)) {
cache.set(instanceId, {
- state: useStorage('my-feature', { /* defaults */ }),
+ state: useStorage("my-feature", {
+ /* defaults */
+ }),
});
}
@@ -107,6 +111,7 @@ export const useMyFeature = () => {
### CSS selectors
Core elements use classes, not ids, to avoid collisions in multi-instance setups:
+
- `.navigator-map` — MapLibre container
- `.navigator-top` — top navigation bar
- `.navigator-panel` — Bootstrap offcanvas side panel
@@ -122,7 +127,7 @@ lat: 50.6539, lng: -128.0094 // Scarlet Ibis Pub, Holberg, British Columbia, C
In MapLibre `center` arrays (which are `[lng, lat]`):
```js
-center: [-128.0094, 50.6539]
+center: [-128.0094, 50.6539];
```
### Features
@@ -140,26 +145,27 @@ Documentation is split into two directories:
Core docs:
-| Doc | Purpose |
-|-----|---------|
-| `docs/core/1.config.md` | `Navigator.create()` config API reference |
-| `docs/core/2.instances.md` | Multi-instance setup, storage convention, architecture |
-| `docs/core/3.map.md` | `useMap` full API: map lifecycle, view persistence, URL hash |
-| `docs/core/4.ui.md` | `useUI` full API: responsive breakpoints, panel, navigation |
-| `docs/core/5.locale.md` | Locale API, translations, OSM multilingual names |
-| `docs/core/6.theme.md` | Bootstrap SCSS theme architecture |
-| `docs/core/7.testing.md` | Testing conventions and screenshot strategy |
+| Doc | Purpose |
+| -------------------------- | ------------------------------------------------------------ |
+| `docs/core/1.config.md` | `Navigator.create()` config API reference |
+| `docs/core/2.instances.md` | Multi-instance setup, storage convention, architecture |
+| `docs/core/3.map.md` | `useMap` full API: map lifecycle, view persistence, URL hash |
+| `docs/core/4.ui.md` | `useUI` full API: responsive breakpoints, panel, navigation |
+| `docs/core/5.geojson.md` | `useGeoJSON` API: rendering GeoJSON features with styles |
+| `docs/core/6.locale.md` | Locale API, translations, OSM multilingual names |
+| `docs/core/7.theme.md` | Bootstrap SCSS theme architecture |
+| `docs/core/8.testing.md` | Testing conventions and screenshot strategy |
Extension docs:
-| Doc | Purpose |
-|-----|---------|
-| `docs/extend/1.events.md` | Event emitter, lifecycle callbacks |
-| `docs/extend/2.buttons-panels.md` | Config-driven custom buttons and panels |
-| `docs/extend/3.plugins.md` | Plugin system: writing, context, cleanup |
-| `docs/extend/4.apis.md` | Accessing map, panel, settings, locale, storage |
-| `docs/extend/5.features.md` | Building a complete feature as a plugin |
-| `docs/extend/6.theme.md` | Custom themes for library consumers |
+| Doc | Purpose |
+| --------------------------------- | ----------------------------------------------- |
+| `docs/extend/1.events.md` | Event emitter, lifecycle callbacks |
+| `docs/extend/2.buttons-panels.md` | Config-driven custom buttons and panels |
+| `docs/extend/3.plugins.md` | Plugin system: writing, context, cleanup |
+| `docs/extend/4.apis.md` | Accessing map, panel, settings, locale, storage |
+| `docs/extend/5.features.md` | Building a complete feature as a plugin |
+| `docs/extend/6.theme.md` | Custom themes for library consumers |
---
@@ -167,7 +173,7 @@ Extension docs:
1. Create `src/composables/use{FeatureName}.js`, `src/components/panels/{feature-name}.vue`, and `src/components/ui/top/{feature-name}.vue` (see `docs/extend/5.features.md`)
2. Create `tests/e2e/feature-name.spec.js` (or `tests/e2e/features/feature-name.spec.js`)
-3. Run `npm test -- tests/e2e/{relevant}.spec.js` during development, then `npm test` as a final check
+3. Run `npm run test:e2e -- tests/e2e/{relevant}.spec.js` during development if E2E coverage is needed; run `npm test` as a final unit-test check
---
@@ -184,8 +190,9 @@ A Playwright MCP server is configured in `.github/mcp.json`. Agents with MCP sup
- `docs/core/2.instances.md` — multi-instance setup, storage convention, architecture
- `docs/core/3.map.md` — `useMap` full API
- `docs/core/4.ui.md` — `useUI` full API
-- `docs/core/5.locale.md` — locale API, translations
-- `docs/core/6.theme.md` — Bootstrap SCSS theme architecture
-- `docs/core/7.testing.md` — testing conventions, screenshot strategy, how to run specific specs
+- `docs/core/5.geojson.md` — `useGeoJSON` API: rendering GeoJSON features with styles
+- `docs/core/6.locale.md` — locale API, translations
+- `docs/core/7.theme.md` — Bootstrap SCSS theme architecture
+- `docs/core/8.testing.md` — testing conventions, screenshot strategy, how to run specific specs
- `docs/extend/README.md` — extending Navigator: events, plugins, buttons, panels, theming
- `docs/extend/5.features.md` — how to build a feature
diff --git a/BUGS.md b/BUGS.md
index bb0ff3b..984f8a8 100644
--- a/BUGS.md
+++ b/BUGS.md
@@ -2,6 +2,6 @@
1/ There is a bug with Chrome desktop for mac (macOS only, is fine on Windows) where the map is not zoomable using the mouse scroll wheel. The app works correctly everywhere else tested, including Chrome mobile. Instead of zooming the map, the entire app gets "pulled" up and down, indicating that the browser is treating the map gesture as a scroll instead of a zoom. Diagnose this issue and implement a fix.
-2/ @src/components/ui/top/locate.vue contains an element with id locate-button. However because Navigator support multiple instances, this should be either scoped to the instance or use a class instead of an id.
+2/ @src/components/ui/top/locate.vue contains an element with id locate-button. However because Navigator support multiple instances, this should be either scoped to the instance or use a class instead of an id. Find and replace all uses of HTML id attributes.
3/ Modals @src/modals/\*.md do not respect the dark theme and display with a white background and black text. Update the modal styles to be compatible with both light and dark themes.
diff --git a/FEEDBACK.md b/FEEDBACK.md
deleted file mode 100644
index 2bab3be..0000000
--- a/FEEDBACK.md
+++ /dev/null
@@ -1,10 +0,0 @@
-1. Export useLocale and useSettings
-
-These composables exist internally but aren't exported. They're exactly what plugin authors need:
-
-- The Recordings plugin hardcodes formatDistance() with metric units. If useSettings were available, it could
- respect the user's unit preference (metric vs imperial).
-- A plugin adding localized UI has no way to access the current locale or translations — useLocale would solve
- this.
-
-Exporting them (even as "advanced" API) would let plugins integrate more naturally with Navigator's existing UX.
diff --git a/README.md b/README.md
index 6cc4128..a169584 100644
--- a/README.md
+++ b/README.md
@@ -153,7 +153,9 @@ npm run dev
### Test
```bash
-npm test -- tests/e2e/{spec}.spec.js
+npm test # unit tests (vitest)
+npm run test:e2e -- tests/e2e/{spec}.spec.js # single E2E spec
+npm run test:e2e # full E2E suite
```
See [docs/core/7.testing.md](docs/core/7.testing.md) for the testing strategy.
diff --git a/TASKS.md b/TASKS.md
index 99a70cd..1275cdb 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -6,3 +6,11 @@ We are currently on the git branch called "dev". Use git to create a new branch
- feat: add new feature Y
- docs: update documentation for feature Z
- bump: update dependencies for feature A
+
+2/ Currently the @docs/extend/5.features.md example implements drawing and updating a maplibre Line @docs/extend/feature/recordings.js After implementing the example, I notice that this renders correctly when testing on a desktop browser, but on mobile the line does not render at all. This occurs on multiple mobile browsers. It would appear that data about the track is being collected - distance and duration both have positive values and are incrementing when recording, but no line is drawn.
+
+Instead of just diagnosing this one issue, I want you to create a navigator api for rendering geojson data. I want this to be a thin layer on top of maplibre that abstracts away the details of rendering geojson data (sources and style layers) and provides a simple interface for adding, updating, and removing geojson features. Providing styles for the features should be optional, and if not provided, default styles should be applied. For example line colour could be specified as a geojson "navigator." property on the geojson feature, and if not provided, a default colour. The default colour should be a random bright primary colour variation to help distinguish different features. The API should also provide a way to set the default styles for different geometry types (point, line, polygon) and allow these to be overridden on a per-feature basis using the "navigator." properties.
+
+Backwards compatibility is not required, as the library is in early development - I want to nail the geojson:mablibre rendering API first, then update the existing recording example to use this new API. The API should be documented (I think this should belong in @docs/core/5.geojson.md - change numbered filenames across docs) and include examples of how to use it to render different types of geojson features with custom styles. The implementation should be efficient (i.e. not creating a new source and layer for every feature update, cleaning up old sources and layers when features are removed) and work well on both desktop and mobile browsers.
+
+The geojson api should have full test coverage. Unit tests are currently preferred for efficiency, but if there are any parts of the implementation that are difficult to test with unit tests, you can use e2e tests instead. The tests should cover adding, updating, and removing features, as well as applying custom styles and default styles. You should also include tests for edge cases, such as adding features with invalid geojson or styles.
diff --git a/docs/core/1.config.md b/docs/core/1.config.md
index 373360a..ae82316 100644
--- a/docs/core/1.config.md
+++ b/docs/core/1.config.md
@@ -124,7 +124,7 @@ Language resolution order:
| `en` | English |
| `fr` | Français |
-See [Locale](./5.locale.md) for the full locale API, available languages, and how to contribute a translation.
+See [Locale](./6.locale.md) for the full locale API, available languages, and how to contribute a translation.
---
@@ -152,7 +152,7 @@ Navigator.create({
Custom messages are merged on top of the built-in translations — only the keys you supply are overridden. Any unspecified keys continue to use the built-in values.
-See [Locale](./5.locale.md) for the full list of translation keys.
+See [Locale](./6.locale.md) for the full list of translation keys.
---
diff --git a/docs/core/3.map.md b/docs/core/3.map.md
index 2a56558..a362fe2 100644
--- a/docs/core/3.map.md
+++ b/docs/core/3.map.md
@@ -120,7 +120,7 @@ name:{locale} → name (local) → name:en
The expression is updated reactively whenever the language changes in Settings — no page reload required.
-See [Locale — OSM Multilingual Names](./5.locale.md#osm-multilingual-names) for full details.
+See [Locale — OSM Multilingual Names](./6.locale.md#osm-multilingual-names) for full details.
---
diff --git a/docs/core/4.ui.md b/docs/core/4.ui.md
index 43daab1..e815b19 100644
--- a/docs/core/4.ui.md
+++ b/docs/core/4.ui.md
@@ -111,4 +111,4 @@ Closes the panel without changing the active tab.
---
-[← Map](./3.map.md) | [Locale →](./5.locale.md)
+[← Map](./3.map.md) | [GeoJSON →](./5.geojson.md)
diff --git a/docs/core/5.geojson.md b/docs/core/5.geojson.md
new file mode 100644
index 0000000..229a1e3
--- /dev/null
+++ b/docs/core/5.geojson.md
@@ -0,0 +1,284 @@
+# GeoJSON — `useGeoJSON`
+
+`useGeoJSON` provides a simple interface for rendering GeoJSON features on the Navigator map. It is a thin layer on top of MapLibre GL JS that manages a single shared source and data-driven style layers for points, lines, and polygons — so you never touch `addSource`, `addLayer`, or `setData` directly.
+
+Features set before the map is ready are queued and applied automatically on `map:ready`. Sources and layers are cleaned up on `destroy`.
+
+---
+
+## Quick start
+
+### From a plugin
+
+Pass `instanceId` explicitly when calling from outside a Vue component's `setup()`:
+
+```js
+import { useGeoJSON } from '@ogis/navigator';
+
+export const MyPlugin = {
+ install({ instanceId }) {
+ const geoJSON = useGeoJSON(instanceId);
+
+ geoJSON.setFeature({
+ type: 'Feature',
+ id: 'my-line',
+ geometry: {
+ type: 'LineString',
+ coordinates: [[-128.0094, 50.6539], [-128.05, 50.67]],
+ },
+ });
+ },
+};
+```
+
+### From a Vue component
+
+When called from inside a component's `setup()`, the instance ID is resolved via `inject`:
+
+```js
+import { useGeoJSON } from '@ogis/navigator';
+
+const geoJSON = useGeoJSON();
+geoJSON.setFeature({ type: 'Feature', id: 'pin', geometry: { type: 'Point', coordinates: [-128.0094, 50.6539] } });
+```
+
+---
+
+## API
+
+### `setFeature(feature)`
+
+Add or update a GeoJSON feature. The feature must be a `Feature` object with a top-level `id` property.
+
+```js
+geoJSON.setFeature({
+ type: 'Feature',
+ id: 'track', // required — used as the upsert key
+ geometry: {
+ type: 'LineString',
+ coordinates: [[-128.0094, 50.6539], [-128.05, 50.67]],
+ },
+ properties: {
+ 'navigator.color': '#e74c3c', // optional — defaults to auto-assigned bright colour
+ 'navigator.width': 5,
+ 'navigator.opacity': 0.9,
+ },
+});
+```
+
+Calling `setFeature` with the same `id` replaces the existing feature. Styles from `navigator.*` properties are resolved at call time, so you can update both geometry and style together.
+
+Supported geometry types: `Point`, `MultiPoint`, `LineString`, `MultiLineString`, `Polygon`, `MultiPolygon`.
+
+---
+
+### `removeFeature(id)`
+
+Remove a feature by its ID:
+
+```js
+geoJSON.removeFeature('track');
+```
+
+Accepts string or numeric IDs — both are coerced to string.
+
+---
+
+### `clearFeatures()`
+
+Remove all features from the map:
+
+```js
+geoJSON.clearFeatures();
+```
+
+---
+
+### `setDefaults(defaults)`
+
+Set default style values for one or more geometry types. Affects all future `setFeature()` calls **and** immediately re-renders any existing features that do not have an explicit `navigator.color` or other style property.
+
+```js
+geoJSON.setDefaults({
+ line: { color: '#3498db', width: 4, opacity: 0.9 },
+ point: { radius: 8 },
+ polygon: { fillOpacity: 0.3 },
+});
+```
+
+---
+
+## Style properties
+
+Style is driven by `navigator.*` properties on the GeoJSON feature. Any property not specified falls back to the value from `setDefaults()`, or to the built-in default if no override has been set.
+
+### All types
+
+| Property | Type | Default | Description |
+|---|---|---|---|
+| `navigator.color` | string (CSS colour) | random bright | Fill/stroke colour. If absent, a distinct bright colour is auto-assigned per feature ID and kept stable across updates. |
+| `navigator.opacity` | number (0–1) | 0.85 (line/polygon), 1 (point) | Layer opacity |
+
+### Lines (`LineString`, `MultiLineString`)
+
+| Property | Type | Default |
+|---|---|---|
+| `navigator.width` | number (px) | 3 |
+
+Line layers always use `round` cap and join for smooth rendering on all platforms including mobile.
+
+### Points (`Point`, `MultiPoint`)
+
+| Property | Type | Default |
+|---|---|---|
+| `navigator.radius` | number (px) | 6 |
+
+Points are rendered as circles with a white 1.5 px stroke.
+
+### Polygons (`Polygon`, `MultiPolygon`)
+
+| Property | Type | Default |
+|---|---|---|
+| `navigator.fillOpacity` | number (0–1) | 0.4 |
+
+Polygons get both a fill layer (using `navigator.fillOpacity`) and an outline layer (using `navigator.opacity`).
+
+---
+
+## Auto-assigned colours
+
+When a feature has no `navigator.color` and no colour default is set, `useGeoJSON` assigns a persistent random colour from a set of visually distinct bright primaries. The same colour is used for all subsequent updates to that feature ID, and is released when the feature is removed or cleared.
+
+This makes it easy to render multiple independent features (e.g. several saved tracks) with distinct colours — zero configuration required.
+
+---
+
+## Examples
+
+### Rendering a GPS track
+
+```js
+import { useGeoJSON } from '@ogis/navigator';
+
+export const TrackPlugin = {
+ install({ instanceId }) {
+ const geoJSON = useGeoJSON(instanceId);
+
+ function updateTrack(points) {
+ const coords = points.map(p => [p.lng, p.lat]);
+ if (coords.length < 2) {
+ geoJSON.removeFeature('active-track');
+ return;
+ }
+ geoJSON.setFeature({
+ type: 'Feature',
+ id: 'active-track',
+ geometry: { type: 'LineString', coordinates: coords },
+ properties: {
+ 'navigator.color': '#4a8dc8',
+ 'navigator.width': 3,
+ },
+ });
+ }
+ },
+};
+```
+
+### Rendering markers
+
+```js
+const geoJSON = useGeoJSON(instanceId);
+
+locations.forEach(({ id, lng, lat, color }) => {
+ geoJSON.setFeature({
+ type: 'Feature',
+ id,
+ geometry: { type: 'Point', coordinates: [lng, lat] },
+ properties: {
+ 'navigator.color': color, // or omit to auto-assign
+ 'navigator.radius': 8,
+ },
+ });
+});
+```
+
+### Rendering an area
+
+```js
+geoJSON.setFeature({
+ type: 'Feature',
+ id: 'zone',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [[
+ [-128.02, 50.65], [-128.00, 50.65],
+ [-128.00, 50.67], [-128.02, 50.67],
+ [-128.02, 50.65],
+ ]],
+ },
+ properties: {
+ 'navigator.color': '#2ecc71',
+ 'navigator.fillOpacity': 0.3,
+ 'navigator.opacity': 0.8,
+ },
+});
+```
+
+### Setting defaults for a consistent style
+
+```js
+const geoJSON = useGeoJSON(instanceId);
+
+// Apply a uniform style for all lines in this instance
+geoJSON.setDefaults({
+ line: { color: '#e74c3c', width: 2, opacity: 0.7 },
+});
+
+// These will all use the red default colour
+tracks.forEach(track => geoJSON.setFeature(track));
+```
+
+---
+
+## Using from a Vue component
+
+`useGeoJSON()` can be called from any component inside Navigator's Vue tree. The instance ID is resolved via `inject('navigatorId')` automatically:
+
+```vue
+
+```
+
+---
+
+## How it works
+
+`useGeoJSON` maintains a single MapLibre `GeoJSON` source (`navigator-geojson`) with a `FeatureCollection`. When you call `setFeature` or `removeFeature`, the source is updated via `setData` — no sources or layers are created or destroyed on each update.
+
+Four style layers are created once when the map is ready:
+
+| Layer ID | Type | Filters |
+|---|---|---|
+| `navigator-geojson-polygon-fill` | `fill` | `Polygon`, `MultiPolygon` |
+| `navigator-geojson-polygon-outline` | `line` | `Polygon`, `MultiPolygon` |
+| `navigator-geojson-line` | `line` | `LineString`, `MultiLineString` |
+| `navigator-geojson-point` | `circle` | `Point`, `MultiPoint` |
+
+All style properties are data-driven via MapLibre expressions on `navigator.*` feature properties, so a single `setData` call re-renders every feature with the correct style.
+
+All layers and the source are automatically removed when the Navigator instance is destroyed.
+
+---
+
+[← UI](./4.ui.md) | [Locale →](./6.locale.md)
diff --git a/docs/core/5.locale.md b/docs/core/6.locale.md
similarity index 99%
rename from docs/core/5.locale.md
rename to docs/core/6.locale.md
index 23b1cfd..f8111ab 100644
--- a/docs/core/5.locale.md
+++ b/docs/core/6.locale.md
@@ -178,4 +178,4 @@ const { mapLanguageTag } = useLocale();
---
-[← UI](./4.ui.md) | [Theme →](./6.theme.md)
+[← UI](./4.ui.md) | [Theme →](./7.theme.md)
diff --git a/docs/core/6.theme.md b/docs/core/7.theme.md
similarity index 98%
rename from docs/core/6.theme.md
rename to docs/core/7.theme.md
index 11e6030..3f00e0a 100644
--- a/docs/core/6.theme.md
+++ b/docs/core/7.theme.md
@@ -133,4 +133,4 @@ For information on creating a custom theme as a library consumer, see [Custom Th
---
-[← Locale](./5.locale.md) | [Testing →](./7.testing.md)
+[← Locale](./6.locale.md) | [Testing →](./8.testing.md)
diff --git a/docs/core/7.testing.md b/docs/core/8.testing.md
similarity index 88%
rename from docs/core/7.testing.md
rename to docs/core/8.testing.md
index a6d0dc8..0dc2d41 100644
--- a/docs/core/7.testing.md
+++ b/docs/core/8.testing.md
@@ -9,13 +9,16 @@ Navigator has two test layers:
npm run test:unit # vitest — fast, pure logic (<1 s)
npm run test:e2e -- tests/e2e/core.spec.js # single spec (15–45 s)
npm run test:e2e # full E2E suite (2–3 min)
-npm test # both unit + E2E (final check)
+npm test # unit tests only (final check)
```
---
## Unit tests
+> [!IMPORTANT]
+> Currently unit tests are preferred over E2E tests for new logic, as they are faster and easier to debug. When adding new features, start with unit tests. Add E2E tests if the feature has complex integration points or critical user flows that warrant full browser testing.
+
Unit tests live in `tests/unit/` and cover pure, extractable logic from composables:
| Test file | Composable | What it tests |
@@ -56,13 +59,13 @@ npm run test:e2e -- tests/e2e/core.spec.js --grep "About panel"
## Running all tests (final check only)
-Before confirming a task is complete, run the full suite once:
+Before confirming a task is complete, run unit tests once:
```bash
npm test
```
-This runs vitest unit tests first, then starts the Vite dev server and runs all Playwright specs (including screenshots). Expect it to take several minutes total.
+This runs vitest unit tests and completes in under a second. E2E tests are available separately via `npm run test:e2e` but are not required as part of the standard task completion check — run them manually when validating browser integration or complex user flows.
---
@@ -165,4 +168,4 @@ playwright.config.js
---
-[← Theme](./6.theme.md) | [Core Docs](./README.md)
+[← Theme](./7.theme.md) | [Core Docs](./README.md)
diff --git a/docs/core/README.md b/docs/core/README.md
index b42e7d5..b2191f9 100644
--- a/docs/core/README.md
+++ b/docs/core/README.md
@@ -4,7 +4,8 @@ Welcome to the Navigator core developer docs. This guide covers the internal arc
Navigator wraps [MapLibre GL JS](https://maplibre.org/) and [Vue 3](https://vuejs.org/) into an embeddable map widget. Consumers create instances with `Navigator.create()` and mount them to the DOM.
-> **Looking to extend Navigator?** See the [Extending Navigator](../extend/README.md) docs for events, custom buttons & panels, plugins, and theming.
+> [!NOTE]
+> **Building on top of Navigator?** See the [Extending Navigator](../extend/README.md) docs for events, custom buttons & panels, plugins, and theming.
---
@@ -35,9 +36,10 @@ The docs are ordered to walk you through the codebase from core concepts to test
| 2 | [Instances](./2.instances.md) | Instance isolation, multi-instance setup, storage conventions, and architecture |
| 3 | [Map](./3.map.md) | `useMap` composable: MapLibre lifecycle, view persistence, URL hash sync, and multilingual labels |
| 4 | [UI](./4.ui.md) | `useUI` composable: responsive breakpoints, side panel, and navigation state |
-| 5 | [Locale](./5.locale.md) | `useLocale` composable: language resolution, translations, and OSM multilingual names |
-| 6 | [Theme](./6.theme.md) | Bootstrap SCSS theme: palette customisation, dark mode, and component overrides |
-| 7 | [Testing](./7.testing.md) | Unit tests, E2E tests, screenshot specs, and the sync contract |
+| 5 | [GeoJSON](./5.geojson.md) | `useGeoJSON` composable: rendering points, lines, and polygons with data-driven styles |
+| 6 | [Locale](./6.locale.md) | `useLocale` composable: language resolution, translations, and OSM multilingual names |
+| 7 | [Theme](./7.theme.md) | Bootstrap SCSS theme: palette customisation, dark mode, and component overrides |
+| 8 | [Testing](./8.testing.md) | Unit tests, E2E tests, screenshot specs, and the sync contract |
---
@@ -52,7 +54,8 @@ Navigator.create(config)
│ ├── useUI ← responsive breakpoints, panel, nav state
│ ├── useSettings ← theme, units, language preferences
│ ├── useLocale ← i18n resolution and translations
- │ └── useStorage ← localStorage wrapper, instance-scoped
+ │ ├── useStorage ← localStorage wrapper, instance-scoped
+ │ └── useGeoJSON ← GeoJSON rendering: points, lines, polygons
│
├── EventEmitter ← per-instance, framework-agnostic events
│ ├── map:ready
@@ -88,6 +91,7 @@ src/
useLocale.js # i18n: language resolution, translations
useSettings.js # user preferences: theme, units, language
useLocate.js # GPS locate feature
+ useGeoJSON.js # GeoJSON rendering: points, lines, polygons
components/
panels/ # side panel content for each feature
ui/
diff --git a/docs/extend/4.apis.md b/docs/extend/4.apis.md
index 3defc2e..fe1c3b1 100644
--- a/docs/extend/4.apis.md
+++ b/docs/extend/4.apis.md
@@ -148,7 +148,8 @@ const MyPlugin = {
};
```
-See [Instances](../core/2.instances.md) for the full storage key convention.
+> [!NOTE]
+> Storage keys follow the pattern `navigator_{namespace}_{instanceId}`. See [Instances](../core/2.instances.md) in the core docs for the full convention.
---
diff --git a/docs/extend/5.features.md b/docs/extend/5.features.md
index ae3766d..e92b6d8 100644
--- a/docs/extend/5.features.md
+++ b/docs/extend/5.features.md
@@ -51,7 +51,7 @@ This section builds a complete GPS track-recording feature as a Navigator plugin
- **Recordings panel** displays the current track's duration and distance, with Pause/Resume, Save, and Discard controls.
- **Blue line on the map** traces the recorded path in real time. The line turns grey when paused.
- **Saved recordings** are persisted to `localStorage` and listed in the panel. Each can be shown on the map, downloaded as GPX, or deleted.
-- **Crash recovery** — an active recording is saved to `localStorage` on every position update, so it survives an accidental page refresh.
+- **Crash recovery** — an active recording is saved to `localStorage` on every position update, so it survives a page refresh. On reload, `useGeoJSON` queues the restored track before the map loads — it appears on the map as soon as MapLibre is ready.
### File structure
@@ -85,7 +85,7 @@ That's it — one import, one line of config. The plugin handles everything:
1. **`addButton()`** — registers the Record button in the navbar and the Recordings panel tab.
2. **`provide()`** — shares reactive state with the button and panel components via dependency injection.
-3. **`on('map:ready', ...)`** — adds a GeoJSON line source and layer once MapLibre finishes loading.
+3. **`useGeoJSON(instanceId)`** — renders the live track as a `LineString` on the map. Features queued before the map loads are applied automatically on `map:ready` — no `onMapReady` or event listeners needed.
#### Plugin options
@@ -105,25 +105,26 @@ plugins: [{ plugin: RecordingsPlugin, options: { maxDuration: 3600 } }];
The Recordings plugin demonstrates several patterns from [Extending Navigator](./README.md):
-| Pattern | Where | API used |
-| --------------------------- | ---------------------------- | ---------------------------------------------------------------------------------- |
-| Plugin lifecycle | `recordings.js` | `install({ useStorage, useSettings, getMap, onMapReady, on, provide, addButton })` |
-| Plugin cleanup | `recordings.js` | Return cleanup function from `install()` |
-| Auto-cleanup map layers | `recordings.js` | `onMapReady(({ addSource, addLayer }) => ...)` |
-| Event listener | `recordings.js` | `on('map:ready', ...)` |
-| Self-registering UI | `recordings.js` | `addButton()` with `component` + `panel` |
-| State sharing | `recordings.js` → components | `provide()` / `inject()` |
-| Pre-scoped map access | `recordings.js` | `getMap()` — no instanceId needed |
-| Pre-scoped user preferences | `recordings.js` | `useSettings()` — respects user's unit choice |
-| Programmatic panel control | `RecordButton.vue` | `useUI()` from `@ogis/navigator` |
-| Pre-scoped persistence | `recordings.js` | `useStorage()` on plugin context — no instanceId needed |
-
-**Why a plugin?** The plugin's `install` method runs before mount, receiving the event emitter and Vue app. This lets it register a `map:ready` listener to add layers and resume state — things that can't be done from a component alone.
+| Pattern | Where | API used |
+| --------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------ |
+| Plugin lifecycle | `recordings.js` | `install({ useStorage, useSettings, getMap, instanceId, provide, addButton })` |
+| Plugin cleanup | `recordings.js` | Return cleanup function — `stopGeo()` + `stopTimer()`; map layers auto-removed by `useGeoJSON` |
+| GeoJSON rendering | `recordings.js` | `useGeoJSON(instanceId)` — `setFeature()` / `removeFeature()`, lifecycle handled internally |
+| Self-registering UI | `recordings.js` | `addButton()` with `component` + `panel` |
+| State sharing | `recordings.js` → components | `provide()` / `inject()` |
+| Pre-scoped map access | `recordings.js` | `getMap()` — no instanceId needed |
+| Pre-scoped user preferences | `recordings.js` | `useSettings()` — respects user's unit choice |
+| Programmatic panel control | `RecordButton.vue` | `useUI()` from `@ogis/navigator` |
+| Pre-scoped persistence | `recordings.js` | `useStorage()` on plugin context — no instanceId needed |
+
+**Why a plugin?** The plugin's `install` method runs before mount, receiving the full Navigator context. This lets it register shared state, resume a crash-saved recording, and call `useGeoJSON` before any component renders — things that can't be done from a component alone.
**Why `addButton()`?** The plugin registers its own toolbar button and panel tab via `addButton()`, making the feature fully self-contained — consumers only add one entry to `plugins`.
**Why `provide()`?** It bridges the gap between the plugin (which runs outside Vue's component tree) and the button/panel components (which run inside it). Both sides share the same reactive state.
+**Why `useGeoJSON()`?** It abstracts away MapLibre source and layer management entirely. Calling `setFeature()` before the map is ready queues the update — it is applied automatically when the map loads. Changing the track colour (e.g. grey when paused) is a single `setFeature()` call with updated `navigator.*` properties. Sources and layers are removed on `destroy` with no cleanup code in the plugin.
+
---
## Summary
@@ -138,15 +139,18 @@ The Recordings plugin demonstrates several patterns from [Extending Navigator](.
| Pass options to per-instance plugins | `[plugin, opts]` tuple or `{ plugin, options }` in `plugins` array |
| Access the map from a plugin | `getMap()` or `map` (shallowRef) on plugin context |
| Access the map from a component | `useMap()` from `@ogis/navigator` |
+| Render GeoJSON on the map | `useGeoJSON(instanceId)` from `@ogis/navigator` — handles lifecycle, style, and cleanup |
+| Add raw map sources/layers with auto-cleanup | `onMapReady(({ addSource, addLayer }) => ...)` on plugin context |
| Read user preferences from a plugin | `useSettings()` on plugin context (pre-scoped) |
| Read user preferences from a component | `useSettings()` from `@ogis/navigator` |
| Translate UI from a plugin | `useLocale()` on plugin context (pre-scoped) |
| Translate UI from a component | `useLocale()` from `@ogis/navigator` |
| Control the panel programmatically | `useUI()` from `@ogis/navigator` |
| Persist feature state | `useStorage()` on plugin context (pre-scoped) |
-| Add map sources/layers with auto-cleanup | `onMapReady(({ addSource, addLayer }) => ...)` |
| Clean up on unmount | Return cleanup function from `install()`, or listen for `destroy` event |
-| Build a feature inside Navigator source | Composable + panel + button in `src/` directories |
+
+> [!NOTE]
+> This guide covers building features as plugins — the recommended approach for project-level and distributable extensions. To contribute a new built-in feature to Navigator's own codebase, see [Instances](../core/2.instances.md) in the core docs.
---
diff --git a/docs/extend/6.theme.md b/docs/extend/6.theme.md
index ac563fa..d6f0df3 100644
--- a/docs/extend/6.theme.md
+++ b/docs/extend/6.theme.md
@@ -2,7 +2,8 @@
Navigator ships with a built-in Bootstrap 5.3 theme optimised for cartographic UI. Consumers can replace it entirely with a custom SCSS theme while keeping Navigator's layout intact.
-For details on Navigator's built-in theme architecture, see [Theme](../core/6.theme.md).
+> [!NOTE]
+> For details on Navigator's built-in theme architecture — palette sections, dark mode, and Bootstrap integration — see [Theme](../core/7.theme.md) in the core docs.
---
@@ -41,16 +42,18 @@ $primary: #your-brand-color;
// Full Bootstrap
@import "bootstrap/scss/reboot";
-// … (see src/assets/sass/theme.scss #5 for the full list)
+// … (see core/7.theme.md for the full Bootstrap import list)
// Navigator visual overrides (#6a–#6f)
-// Repeat #6a–#6f from src/assets/sass/theme.scss, adjusted for your palette.
+// Copy sections #6a–#6f from theme.scss, adjusted for your palette.
+// (See core/7.theme.md for details on each section.)
// Navigator layout styles (#6g) — copy verbatim, no palette changes needed
-// Repeat #6g from src/assets/sass/theme.scss unchanged.
+// Copy section #6g from theme.scss unchanged.
```
-> **Tip:** Copy `src/assets/sass/theme.scss` into your project as a starting point and edit the palette variables in #2. Everything else will cascade automatically.
+> [!TIP]
+> Copy [`theme.scss`](https://github.com/OpenGIS/navigator/blob/master/src/assets/sass/theme.scss) from the Navigator source as a starting point. Edit the palette variables at the top — everything else cascades automatically.
---
diff --git a/docs/extend/README.md b/docs/extend/README.md
index c03134f..7679de5 100644
--- a/docs/extend/README.md
+++ b/docs/extend/README.md
@@ -2,7 +2,8 @@
Navigator is designed to be extended. The event emitter, lifecycle callbacks, config-driven buttons, and plugin system give consumers multiple levels of customisation — from simple one-liners to full third-party plugins.
-> **Looking for core internals?** See the [Core Documentation](../core/README.md) for architecture, composables, and the config API reference.
+> [!NOTE]
+> **Contributing to Navigator's source?** See the [Core Documentation](../core/README.md) for architecture, composables, and the internal API reference.
---
diff --git a/docs/extend/feature/recordings.js b/docs/extend/feature/recordings.js
index d98343a..e0b8001 100644
--- a/docs/extend/feature/recordings.js
+++ b/docs/extend/feature/recordings.js
@@ -1,5 +1,6 @@
// recordings.js — Recordings plugin for Navigator
import { reactive, ref, computed } from 'vue';
+import { useGeoJSON } from '@ogis/navigator';
import RecordButton from './RecordButton.vue';
import RecordingsPanel from './RecordingsPanel.vue';
@@ -80,17 +81,16 @@ function toGPX(recording) {
// Map layer constants
// ---------------------------------------------------------------------------
-const SOURCE_ID = 'recordings-track';
-const LAYER_ID = 'recordings-track-line';
const COLOR_ACTIVE = '#4a8dc8'; // $primary — Navigator brand blue
const COLOR_PAUSED = '#6c757d'; // $secondary — Bootstrap grey
+const TRACK_ID = 'recordings-active-track';
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
export const RecordingsPlugin = {
- install({ useStorage, useSettings, getMap, onMapReady, on, provide, addButton }) {
+ install({ useStorage, useSettings, getMap, instanceId, provide, addButton }) {
// useStorage is pre-scoped — no instanceId needed.
// Stored as "navigator_recordings_{instanceId}" in localStorage.
const stored = useStorage('recordings', { saved: [], active: null });
@@ -113,6 +113,28 @@ export const RecordingsPlugin = {
let watchId = null;
let timerId = null;
+ // --- GeoJSON layer (via useGeoJSON API) ---------------------------------
+
+ const geoJSON = useGeoJSON(instanceId);
+
+ const updateLine = () => {
+ const coords = state.points.map((p) => [p.lng, p.lat]);
+ if (coords.length < 2) {
+ geoJSON.removeFeature(TRACK_ID);
+ return;
+ }
+ geoJSON.setFeature({
+ type: 'Feature',
+ id: TRACK_ID,
+ geometry: { type: 'LineString', coordinates: coords },
+ properties: {
+ 'navigator.color': state.isPaused ? COLOR_PAUSED : COLOR_ACTIVE,
+ 'navigator.width': 3,
+ 'navigator.opacity': 0.85,
+ },
+ });
+ };
+
// --- Persistence --------------------------------------------------------
const persist = () => {
@@ -124,28 +146,6 @@ export const RecordingsPlugin = {
stored.active = active;
};
- // --- Map layer ----------------------------------------------------------
-
- const emptyLine = () => ({
- type: 'Feature',
- geometry: { type: 'LineString', coordinates: [] },
- });
-
- const updateLine = () => {
- const map = getMap();
- if (!map || !map.getSource(SOURCE_ID)) return;
- const coords = state.points.map((p) => [p.lng, p.lat]);
- map.getSource(SOURCE_ID).setData({
- type: 'Feature',
- geometry: { type: 'LineString', coordinates: coords },
- });
- map.setPaintProperty(
- LAYER_ID,
- 'line-color',
- state.isPaused ? COLOR_PAUSED : COLOR_ACTIVE,
- );
- };
-
// --- Geolocation --------------------------------------------------------
const startGeo = () => {
@@ -221,7 +221,7 @@ export const RecordingsPlugin = {
stopGeo();
stopTimer();
persist();
- updateLine();
+ geoJSON.removeFeature(TRACK_ID);
};
const save = () => {
@@ -254,15 +254,19 @@ export const RecordingsPlugin = {
};
const showOnMap = (recording) => {
- const map = getMap();
- if (!map || !map.getSource(SOURCE_ID)) return;
const coords = recording.points.map((p) => [p.lng, p.lat]);
- map.getSource(SOURCE_ID).setData({
+ geoJSON.setFeature({
type: 'Feature',
+ id: TRACK_ID,
geometry: { type: 'LineString', coordinates: coords },
+ properties: {
+ 'navigator.color': COLOR_ACTIVE,
+ 'navigator.width': 3,
+ 'navigator.opacity': 0.85,
+ },
});
- map.setPaintProperty(LAYER_ID, 'line-color', COLOR_ACTIVE);
- if (coords.length > 1) {
+ const map = getMap();
+ if (map && coords.length > 1) {
const lngs = coords.map((c) => c[0]);
const lats = coords.map((c) => c[1]);
map.fitBounds(
@@ -275,27 +279,13 @@ export const RecordingsPlugin = {
}
};
- // --- Map setup (auto-cleanup) -------------------------------------------
-
- onMapReady(({ map, addSource, addLayer }) => {
- addSource(SOURCE_ID, { type: 'geojson', data: emptyLine() });
- addLayer({
- id: LAYER_ID,
- type: 'line',
- source: SOURCE_ID,
- paint: {
- 'line-color': COLOR_ACTIVE,
- 'line-width': 3,
- 'line-opacity': 0.85,
- },
- });
+ // --- Crash recovery -------------------------------------------------------
+ // useGeoJSON queues features set before map:ready and applies them on load.
- // Resume a recording that survived a page refresh
- if (stored.active?.points?.length) {
- state.isPaused = true;
- updateLine();
- }
- });
+ if (stored.active?.points?.length >= 2) {
+ state.isPaused = true;
+ updateLine();
+ }
// --- Provide to Vue tree ------------------------------------------------
@@ -328,7 +318,7 @@ export const RecordingsPlugin = {
});
// Return cleanup function for plugin teardown.
- // Map sources/layers are auto-removed via onMapReady — no manual cleanup needed.
+ // Map sources/layers are auto-removed by useGeoJSON on destroy.
return () => {
stopGeo();
stopTimer();
diff --git a/package-lock.json b/package-lock.json
index 3780aa1..20fbfe9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@ogis/navigator",
- "version": "1.0.18",
+ "version": "1.0.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ogis/navigator",
- "version": "1.0.18",
+ "version": "1.0.24",
"license": "MIT",
"dependencies": {
"@ogis/icons": "^0.2.8",
@@ -1365,6 +1365,7 @@
"integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
@@ -2001,6 +2002,7 @@
"integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.1.5",
@@ -2128,6 +2130,7 @@
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@oxc-project/runtime": "0.115.0",
"lightningcss": "^1.32.0",
@@ -2288,6 +2291,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",
diff --git a/package.json b/package.json
index 8dbde92..ad4b6ea 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@ogis/navigator",
- "version": "1.0.24",
+ "version": "1.0.25",
"type": "module",
"description": "A map for everyone, right in the browser.",
"homepage": "https://www.ogis.org/navigator",
@@ -30,7 +30,7 @@
"dev": "vite",
"build": "vite build",
"build:demo": "vite build --config vite.demo.config.js",
- "test": "vitest run && playwright test",
+ "test": "vitest run",
"test:unit": "vitest run",
"test:e2e": "playwright test",
"prepublishOnly": "npm run build",
diff --git a/src/classes/Position.js b/src/classes/Position.js
index 09f67f4..b1ff8f3 100644
--- a/src/classes/Position.js
+++ b/src/classes/Position.js
@@ -1,3 +1,4 @@
+/** Represents a GPS position reading from the Geolocation API. */
export class Position {
/**
* @param {object} params
diff --git a/src/composables/useGeoJSON.js b/src/composables/useGeoJSON.js
new file mode 100644
index 0000000..65e03ba
--- /dev/null
+++ b/src/composables/useGeoJSON.js
@@ -0,0 +1,311 @@
+import { inject } from "vue";
+import { getMapInstance, getEmitter } from "@/index.js";
+
+// Bright, visually distinct colours for auto-assignment
+const BRIGHT_COLORS = [
+ "#e74c3c",
+ "#3498db",
+ "#2ecc71",
+ "#e67e22",
+ "#9b59b6",
+ "#1abc9c",
+ "#e91e63",
+ "#f1c40f",
+ "#00bcd4",
+ "#ff5722",
+];
+
+const SOURCE_ID = "navigator-geojson";
+const LAYER_IDS = {
+ polygonFill: "navigator-geojson-polygon-fill",
+ polygonOutline: "navigator-geojson-polygon-outline",
+ line: "navigator-geojson-line",
+ point: "navigator-geojson-point",
+};
+
+const LINE_TYPES = new Set(["LineString", "MultiLineString"]);
+const POINT_TYPES = new Set(["Point", "MultiPoint"]);
+const POLYGON_TYPES = new Set(["Polygon", "MultiPolygon"]);
+
+function getGeometryCategory(geometry) {
+ if (!geometry?.type) return null;
+ if (LINE_TYPES.has(geometry.type)) return "line";
+ if (POINT_TYPES.has(geometry.type)) return "point";
+ if (POLYGON_TYPES.has(geometry.type)) return "polygon";
+ return null;
+}
+
+function randomBrightColor() {
+ return BRIGHT_COLORS[Math.floor(Math.random() * BRIGHT_COLORS.length)];
+}
+
+// Module-level cache: instanceId -> manager
+const cache = new Map();
+
+function createManager(instanceId) {
+ const features = new Map(); // id -> processed feature
+ const originalFeatures = new Map(); // id -> original feature (for re-processing on setDefaults)
+ const assignedColors = new Map(); // id -> auto-assigned colour
+ let map = null;
+
+ const defaults = {
+ line: { color: null, width: 3, opacity: 0.85 },
+ point: { color: null, radius: 6, opacity: 1 },
+ polygon: { color: null, fillOpacity: 0.4, opacity: 0.85 },
+ };
+
+ const resolveColor = (id, props, category) => {
+ if (props["navigator.color"]) return props["navigator.color"];
+ if (defaults[category]?.color) return defaults[category].color;
+ if (!assignedColors.has(id)) assignedColors.set(id, randomBrightColor());
+ return assignedColors.get(id);
+ };
+
+ const processFeature = (feature) => {
+ const category = getGeometryCategory(feature.geometry);
+ if (!category) return null;
+
+ const id = String(feature.id);
+ const rawProps = feature.properties || {};
+ const props = { ...rawProps };
+
+ props["navigator.color"] = resolveColor(id, rawProps, category);
+
+ if (category === "line") {
+ if (props["navigator.width"] == null) props["navigator.width"] = defaults.line.width;
+ if (props["navigator.opacity"] == null) props["navigator.opacity"] = defaults.line.opacity;
+ } else if (category === "point") {
+ if (props["navigator.radius"] == null) props["navigator.radius"] = defaults.point.radius;
+ if (props["navigator.opacity"] == null) props["navigator.opacity"] = defaults.point.opacity;
+ } else if (category === "polygon") {
+ if (props["navigator.fillOpacity"] == null) props["navigator.fillOpacity"] = defaults.polygon.fillOpacity;
+ if (props["navigator.opacity"] == null) props["navigator.opacity"] = defaults.polygon.opacity;
+ }
+
+ return { ...feature, properties: props };
+ };
+
+ const updateSource = () => {
+ if (!map) return;
+ const source = map.getSource(SOURCE_ID);
+ if (!source) return;
+ source.setData({
+ type: "FeatureCollection",
+ features: [...features.values()],
+ });
+ };
+
+ const setup = (mapInstance) => {
+ map = mapInstance;
+ mapInstance.addSource(SOURCE_ID, {
+ type: "geojson",
+ data: { type: "FeatureCollection", features: [] },
+ });
+
+ // Polygon fill
+ mapInstance.addLayer({
+ id: LAYER_IDS.polygonFill,
+ type: "fill",
+ source: SOURCE_ID,
+ filter: ["match", ["geometry-type"], ["Polygon", "MultiPolygon"], true, false],
+ paint: {
+ "fill-color": ["get", "navigator.color"],
+ "fill-opacity": ["get", "navigator.fillOpacity"],
+ },
+ });
+
+ // Polygon outline
+ mapInstance.addLayer({
+ id: LAYER_IDS.polygonOutline,
+ type: "line",
+ source: SOURCE_ID,
+ filter: ["match", ["geometry-type"], ["Polygon", "MultiPolygon"], true, false],
+ paint: {
+ "line-color": ["get", "navigator.color"],
+ "line-opacity": ["get", "navigator.opacity"],
+ "line-width": 1.5,
+ },
+ });
+
+ // Lines — round caps/joins for smooth mobile rendering
+ mapInstance.addLayer({
+ id: LAYER_IDS.line,
+ type: "line",
+ source: SOURCE_ID,
+ filter: ["match", ["geometry-type"], ["LineString", "MultiLineString"], true, false],
+ layout: {
+ "line-cap": "round",
+ "line-join": "round",
+ },
+ paint: {
+ "line-color": ["get", "navigator.color"],
+ "line-width": ["get", "navigator.width"],
+ "line-opacity": ["get", "navigator.opacity"],
+ },
+ });
+
+ // Points (circles)
+ mapInstance.addLayer({
+ id: LAYER_IDS.point,
+ type: "circle",
+ source: SOURCE_ID,
+ filter: ["match", ["geometry-type"], ["Point", "MultiPoint"], true, false],
+ paint: {
+ "circle-color": ["get", "navigator.color"],
+ "circle-radius": ["get", "navigator.radius"],
+ "circle-opacity": ["get", "navigator.opacity"],
+ "circle-stroke-color": "white",
+ "circle-stroke-width": 1.5,
+ },
+ });
+
+ // Apply any features that were set before the map was ready
+ updateSource();
+ };
+
+ const cleanup = () => {
+ if (!map) return;
+ for (const layerId of Object.values(LAYER_IDS).reverse()) {
+ if (map.getLayer(layerId)) map.removeLayer(layerId);
+ }
+ if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID);
+ map = null;
+ };
+
+ // Hook into map lifecycle
+ const existingMap = getMapInstance(instanceId);
+ const emitter = getEmitter(instanceId);
+
+ if (existingMap) {
+ setup(existingMap);
+ } else if (emitter) {
+ emitter.once("map:ready", ({ map: m }) => setup(m));
+ }
+
+ if (emitter) {
+ emitter.once("destroy", () => {
+ cleanup();
+ cache.delete(instanceId);
+ });
+ }
+
+ /**
+ * Add or update a GeoJSON feature on the map.
+ *
+ * The feature must be a GeoJSON `Feature` object with an `id` property.
+ * Style can be controlled via `navigator.*` properties on the feature:
+ *
+ * | Property | Applies to | Default |
+ * |---|---|---|
+ * | `navigator.color` | All types | auto (random bright) |
+ * | `navigator.opacity` | All types | 0.85 (line/polygon), 1 (point) |
+ * | `navigator.width` | Line | 3 |
+ * | `navigator.radius` | Point | 6 |
+ * | `navigator.fillOpacity` | Polygon | 0.4 |
+ *
+ * @param {import('geojson').Feature} feature
+ */
+ const setFeature = (feature) => {
+ if (!feature || feature.type !== "Feature") {
+ console.warn("[useGeoJSON] setFeature() requires a GeoJSON Feature object");
+ return;
+ }
+ if (feature.id == null) {
+ console.warn("[useGeoJSON] Feature must have an id property");
+ return;
+ }
+ if (!getGeometryCategory(feature.geometry)) {
+ console.warn("[useGeoJSON] Feature has unsupported or missing geometry type:", feature.geometry?.type);
+ return;
+ }
+
+ const id = String(feature.id);
+ const processed = processFeature(feature);
+ originalFeatures.set(id, feature);
+ features.set(id, processed);
+ updateSource();
+ };
+
+ /**
+ * Remove a feature from the map by its id.
+ * @param {string|number} id
+ */
+ const removeFeature = (id) => {
+ const key = String(id);
+ features.delete(key);
+ originalFeatures.delete(key);
+ assignedColors.delete(key);
+ updateSource();
+ };
+
+ /**
+ * Remove all features from the map.
+ */
+ const clearFeatures = () => {
+ features.clear();
+ originalFeatures.clear();
+ assignedColors.clear();
+ updateSource();
+ };
+
+ /**
+ * Set default style values for one or more geometry types.
+ * Affects all future `setFeature()` calls and re-renders existing features.
+ *
+ * @param {{ line?: object, point?: object, polygon?: object }} newDefaults
+ * @example
+ * geoJSON.setDefaults({
+ * line: { color: '#ff0000', width: 5 },
+ * point: { radius: 10 },
+ * });
+ */
+ const setDefaults = (newDefaults) => {
+ for (const [type, vals] of Object.entries(newDefaults)) {
+ if (defaults[type]) {
+ Object.assign(defaults[type], vals);
+ }
+ }
+ // Re-process all existing features with the updated defaults
+ for (const [id, original] of originalFeatures) {
+ const processed = processFeature(original);
+ if (processed) features.set(id, processed);
+ }
+ updateSource();
+ };
+
+ return { setFeature, removeFeature, clearFeatures, setDefaults };
+}
+
+/**
+ * Composable for rendering GeoJSON features on the Navigator map.
+ *
+ * Maintains a single GeoJSON source with data-driven style layers for
+ * points, lines, and polygons. Handles map lifecycle automatically —
+ * features set before the map is ready are applied on `map:ready`.
+ *
+ * Call without arguments inside a Vue component's setup context.
+ * Pass `instanceId` explicitly when calling from outside Vue (e.g. a plugin).
+ *
+ * @param {string|null} [instanceId]
+ * @returns {{ setFeature, removeFeature, clearFeatures, setDefaults }}
+ *
+ * @example
+ * // In a Vue component
+ * import { useGeoJSON } from '@ogis/navigator';
+ * const geoJSON = useGeoJSON();
+ * geoJSON.setFeature({ type: 'Feature', id: 'my-line', geometry: { ... } });
+ *
+ * @example
+ * // In a plugin install()
+ * import { useGeoJSON } from '@ogis/navigator';
+ * install({ instanceId }) {
+ * const geoJSON = useGeoJSON(instanceId);
+ * }
+ */
+export const useGeoJSON = (instanceId = null) => {
+ const id = instanceId ?? inject("navigatorId", "navigator");
+ if (!cache.has(id)) {
+ cache.set(id, createManager(id));
+ }
+ return cache.get(id);
+};
diff --git a/src/composables/useLocale.js b/src/composables/useLocale.js
index a375bfe..68dc9ab 100644
--- a/src/composables/useLocale.js
+++ b/src/composables/useLocale.js
@@ -85,7 +85,7 @@ export const useLocale = (instanceId) => {
* The locale code formatted as an OSM `name:xx` tag suffix.
* For most locales this equals `locale.value` directly.
* Use this value when building MapLibre coalesce expressions for
- * multilingual map labels (see docs/core/5.locale.md — OSM Multilingual Names).
+ * multilingual map labels (see docs/core/6.locale.md — OSM Multilingual Names).
*/
const mapLanguageTag = computed(
() => MAP_LANGUAGE_TAG_OVERRIDES[locale.value] ?? locale.value,
diff --git a/src/composables/useLocate.js b/src/composables/useLocate.js
index 9a15e97..3f320cd 100644
--- a/src/composables/useLocate.js
+++ b/src/composables/useLocate.js
@@ -26,6 +26,8 @@ function smoothAngle(current, next, factor) {
*
* Manages GPS tracking state, map markers, and the permission flow.
* State is cached per Navigator instance and shared across all callers.
+ *
+ * @returns {{ mode: import('vue').ComputedRef<'active'|'following'|'error'|null>, position: import('vue').ComputedRef, compassHeading: import('vue').ComputedRef, headingLost: import('vue').ComputedRef, permissionGranted: import('vue').ComputedRef, showConfirmModal: import('vue').Ref, showErrorModal: import('vue').Ref, cycle: Function, confirmLocate: Function, stop: Function, retryOrientation: Function, retryPosition: Function }}
*/
export const useLocate = () => {
const instanceId = inject("navigatorId", "navigator");
diff --git a/src/composables/useSettings.js b/src/composables/useSettings.js
index 9e695f4..113ab9d 100644
--- a/src/composables/useSettings.js
+++ b/src/composables/useSettings.js
@@ -29,6 +29,7 @@ export function localeDefaultUnits(localeStr) {
/**
* @param {string} [instanceId] - Navigator instance ID. If omitted, resolved via inject('navigatorId').
* Pass explicitly when calling from a plugin install() or other non-setup context.
+ * @returns {{ resolvedTheme: import('vue').ComputedRef, isDark: import('vue').ComputedRef, isMetric: import('vue').ComputedRef, resolvedUnits: import('vue').ComputedRef, language: import('vue').ComputedRef, toggleTheme: Function, setUnits: Function, setLanguage: Function }}
*/
export const useSettings = (instanceId) => {
const id = instanceId ?? inject("navigatorId", "navigator");
@@ -61,16 +62,19 @@ export const useSettings = (instanceId) => {
const resolvedUnits = computed(() => storage.units ?? localeDefaultUnits());
const isMetric = computed(() => resolvedUnits.value === "metric");
+ /** Toggle between light and dark theme, persisting the choice. */
const toggleTheme = () => {
storage.theme = isDark.value ? "light" : "dark";
};
+ /** @param {'metric'|'imperial'} units */
const setUnits = (units) => {
storage.units = units;
};
const language = computed(() => storage.language);
+ /** @param {string|null} lang - BCP 47 language tag, or null to follow browser locale. */
const setLanguage = (lang) => {
storage.language = lang;
};
diff --git a/src/composables/useUI.js b/src/composables/useUI.js
index f5bddc4..4b6843e 100644
--- a/src/composables/useUI.js
+++ b/src/composables/useUI.js
@@ -24,6 +24,13 @@ function createState(instanceId) {
};
}
+/**
+ * Composable for UI state management.
+ *
+ * Manages responsive breakpoints, side panel visibility, navigation bar state,
+ * and first-load detection. State is cached per Navigator instance and shared
+ * across all callers within the same instance.
+ */
export const useUI = () => {
const instanceId = inject("navigatorId", "navigator");
diff --git a/src/index.js b/src/index.js
index 1046408..a4ed25c 100644
--- a/src/index.js
+++ b/src/index.js
@@ -14,6 +14,7 @@ export { useStorage } from "./composables/useStorage.js";
export { useLocate } from "./composables/useLocate.js";
export { useSettings } from "./composables/useSettings.js";
export { useLocale } from "./composables/useLocale.js";
+export { useGeoJSON } from "./composables/useGeoJSON.js";
/**
* Vue-idiomatic composable for accessing the MapLibre map instance.
diff --git a/tests/unit/useGeoJSON.test.js b/tests/unit/useGeoJSON.test.js
new file mode 100644
index 0000000..283883d
--- /dev/null
+++ b/tests/unit/useGeoJSON.test.js
@@ -0,0 +1,626 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// ─── Mocks ───────────────────────────────────────────────────────────────────
+
+let injectReturn = "navigator";
+
+vi.mock("vue", () => ({
+ inject: vi.fn(() => injectReturn),
+}));
+
+let mockMapInstance = null;
+let emitterCallbacks = {};
+
+const mockMap = {
+ addSource: vi.fn(),
+ addLayer: vi.fn(),
+ getSource: vi.fn(),
+ getLayer: vi.fn(),
+ removeLayer: vi.fn(),
+ removeSource: vi.fn(),
+};
+
+const mockSetData = vi.fn();
+mockMap.getSource.mockImplementation(() => ({ setData: mockSetData }));
+
+const mockEmitter = {
+ once: vi.fn((event, cb) => {
+ emitterCallbacks[event] = cb;
+ }),
+};
+
+vi.mock("@/index.js", () => ({
+ getMapInstance: vi.fn(() => mockMapInstance),
+ getEmitter: vi.fn(() => mockEmitter),
+}));
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+async function freshUseGeoJSON(instanceId = "nav") {
+ injectReturn = instanceId;
+ mockMapInstance = null;
+ emitterCallbacks = {};
+ vi.clearAllMocks();
+ mockMap.getSource.mockImplementation(() => ({ setData: mockSetData }));
+ vi.resetModules();
+ const { useGeoJSON } = await import("../../src/composables/useGeoJSON.js");
+ return useGeoJSON();
+}
+
+function simulateMapReady(geoJSON) {
+ // Trigger the map:ready callback registered on the emitter
+ if (emitterCallbacks["map:ready"]) {
+ emitterCallbacks["map:ready"]({ map: mockMap });
+ }
+}
+
+const lineFeature = (id = "line-1", color = null) => ({
+ type: "Feature",
+ id,
+ geometry: { type: "LineString", coordinates: [[-128, 50], [-127, 51]] },
+ properties: color ? { "navigator.color": color } : {},
+});
+
+const pointFeature = (id = "point-1") => ({
+ type: "Feature",
+ id,
+ geometry: { type: "Point", coordinates: [-128, 50] },
+ properties: {},
+});
+
+const polygonFeature = (id = "poly-1") => ({
+ type: "Feature",
+ id,
+ geometry: {
+ type: "Polygon",
+ coordinates: [[[-128, 50], [-127, 50], [-127, 51], [-128, 51], [-128, 50]]],
+ },
+ properties: {},
+});
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe("useGeoJSON / map lifecycle", () => {
+ it("creates source and four layers on map:ready", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+
+ expect(mockMap.addSource).toHaveBeenCalledWith(
+ "navigator-geojson",
+ expect.objectContaining({ type: "geojson" }),
+ );
+ expect(mockMap.addLayer).toHaveBeenCalledTimes(4);
+ const layerIds = mockMap.addLayer.mock.calls.map((c) => c[0].id);
+ expect(layerIds).toContain("navigator-geojson-polygon-fill");
+ expect(layerIds).toContain("navigator-geojson-polygon-outline");
+ expect(layerIds).toContain("navigator-geojson-line");
+ expect(layerIds).toContain("navigator-geojson-point");
+ });
+
+ it("queues features set before map:ready and flushes on setup", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ geoJSON.setFeature(lineFeature());
+
+ // Not yet rendered — no map available
+ expect(mockSetData).not.toHaveBeenCalled();
+
+ simulateMapReady(geoJSON);
+
+ // Source initialised with empty FC, then setData called with the queued feature
+ expect(mockSetData).toHaveBeenCalledTimes(1);
+ const fc = mockSetData.mock.calls[0][0];
+ expect(fc.type).toBe("FeatureCollection");
+ expect(fc.features).toHaveLength(1);
+ });
+
+ it("sets up immediately when map is already ready", async () => {
+ injectReturn = "already-ready";
+ emitterCallbacks = {};
+ vi.clearAllMocks();
+ mockMap.getSource.mockImplementation(() => ({ setData: mockSetData }));
+ mockMapInstance = mockMap; // map already available
+ vi.resetModules();
+ const { useGeoJSON } = await import("../../src/composables/useGeoJSON.js");
+ useGeoJSON();
+
+ expect(mockMap.addSource).toHaveBeenCalled();
+ expect(mockMap.addLayer).toHaveBeenCalledTimes(4);
+ });
+
+ it("cleans up layers and source on destroy", async () => {
+ mockMap.getLayer.mockReturnValue(true); // all layers exist
+ mockMap.getSource.mockImplementation((id) => {
+ if (id === "navigator-geojson") return { setData: mockSetData };
+ return null;
+ });
+
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+
+ emitterCallbacks["destroy"]?.();
+
+ expect(mockMap.removeLayer).toHaveBeenCalledTimes(4);
+ expect(mockMap.removeSource).toHaveBeenCalledWith("navigator-geojson");
+ });
+});
+
+describe("useGeoJSON / setFeature", () => {
+ it("adds a line feature to the source", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature(lineFeature());
+
+ expect(mockSetData).toHaveBeenCalledTimes(1);
+ const fc = mockSetData.mock.calls[0][0];
+ expect(fc.features).toHaveLength(1);
+ expect(fc.features[0].geometry.type).toBe("LineString");
+ });
+
+ it("adds a point feature to the source", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature(pointFeature());
+
+ const fc = mockSetData.mock.calls[0][0];
+ expect(fc.features[0].geometry.type).toBe("Point");
+ });
+
+ it("adds a polygon feature to the source", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature(polygonFeature());
+
+ const fc = mockSetData.mock.calls[0][0];
+ expect(fc.features[0].geometry.type).toBe("Polygon");
+ });
+
+ it("updates an existing feature (same id) — no duplicates", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature(lineFeature("track", null));
+ geoJSON.setFeature({
+ ...lineFeature("track", "#ff0000"),
+ geometry: { type: "LineString", coordinates: [[-128, 50], [-126, 52]] },
+ });
+
+ const fc = mockSetData.mock.calls[mockSetData.mock.calls.length - 1][0];
+ expect(fc.features).toHaveLength(1);
+ expect(fc.features[0].properties["navigator.color"]).toBe("#ff0000");
+ });
+
+ it("stores multiple features independently", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature(lineFeature("a"));
+ geoJSON.setFeature(pointFeature("b"));
+
+ const fc = mockSetData.mock.calls[mockSetData.mock.calls.length - 1][0];
+ expect(fc.features).toHaveLength(2);
+ });
+
+ it("warns and does nothing when feature.type is not 'Feature'", async () => {
+ const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature({ type: "FeatureCollection", features: [] });
+
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining("GeoJSON Feature"));
+ expect(mockSetData).not.toHaveBeenCalled();
+ spy.mockRestore();
+ });
+
+ it("warns and does nothing when feature has no id", async () => {
+ const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature({ type: "Feature", geometry: { type: "LineString", coordinates: [] } });
+
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining("id"));
+ expect(mockSetData).not.toHaveBeenCalled();
+ spy.mockRestore();
+ });
+
+ it("warns and does nothing for unsupported geometry types", async () => {
+ const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature({
+ type: "Feature",
+ id: "gc",
+ geometry: { type: "GeometryCollection", geometries: [] },
+ });
+
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining("geometry type"), "GeometryCollection");
+ expect(mockSetData).not.toHaveBeenCalled();
+ spy.mockRestore();
+ });
+
+ it("warns and does nothing when feature is null", async () => {
+ const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature(null);
+
+ expect(spy).toHaveBeenCalled();
+ expect(mockSetData).not.toHaveBeenCalled();
+ spy.mockRestore();
+ });
+});
+
+describe("useGeoJSON / removeFeature", () => {
+ it("removes a feature by id", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+
+ geoJSON.setFeature(lineFeature("a"));
+ geoJSON.setFeature(pointFeature("b"));
+ mockSetData.mockClear();
+
+ geoJSON.removeFeature("a");
+
+ const fc = mockSetData.mock.calls[0][0];
+ expect(fc.features).toHaveLength(1);
+ expect(fc.features[0].id).toBe("b");
+ });
+
+ it("is a no-op for an unknown id", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ geoJSON.setFeature(lineFeature("a"));
+ mockSetData.mockClear();
+
+ geoJSON.removeFeature("does-not-exist");
+
+ const fc = mockSetData.mock.calls[0][0];
+ expect(fc.features).toHaveLength(1);
+ });
+
+ it("converts numeric ids to strings", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+
+ geoJSON.setFeature({ ...lineFeature(), id: 42 });
+ mockSetData.mockClear();
+
+ geoJSON.removeFeature(42);
+
+ const fc = mockSetData.mock.calls[0][0];
+ expect(fc.features).toHaveLength(0);
+ });
+});
+
+describe("useGeoJSON / clearFeatures", () => {
+ it("removes all features", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+
+ geoJSON.setFeature(lineFeature("a"));
+ geoJSON.setFeature(pointFeature("b"));
+ geoJSON.setFeature(polygonFeature("c"));
+ mockSetData.mockClear();
+
+ geoJSON.clearFeatures();
+
+ const fc = mockSetData.mock.calls[0][0];
+ expect(fc.features).toHaveLength(0);
+ });
+
+ it("re-adds features can use new random colours after clear", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+
+ geoJSON.setFeature(lineFeature("x"));
+ const first = mockSetData.mock.calls[mockSetData.mock.calls.length - 1][0]
+ .features[0].properties["navigator.color"];
+
+ geoJSON.clearFeatures();
+ geoJSON.setFeature(lineFeature("x")); // same id, fresh colour
+
+ const second = mockSetData.mock.calls[mockSetData.mock.calls.length - 1][0]
+ .features[0].properties["navigator.color"];
+
+ // Both should be valid colour strings (exact values may differ)
+ expect(first).toMatch(/^#/);
+ expect(second).toMatch(/^#/);
+ });
+});
+
+describe("useGeoJSON / style defaults", () => {
+ it("auto-assigns a colour when navigator.color is absent", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature(lineFeature("l"));
+
+ const fc = mockSetData.mock.calls[0][0];
+ const color = fc.features[0].properties["navigator.color"];
+ expect(color).toMatch(/^#[0-9a-f]{6}$/i);
+ });
+
+ it("uses explicit navigator.color when provided", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature(lineFeature("l", "#aabbcc"));
+
+ const fc = mockSetData.mock.calls[0][0];
+ expect(fc.features[0].properties["navigator.color"]).toBe("#aabbcc");
+ });
+
+ it("preserves the same auto-colour across updates of the same id", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature(lineFeature("track"));
+ const first = mockSetData.mock.calls[0][0].features[0].properties["navigator.color"];
+
+ geoJSON.setFeature(lineFeature("track")); // no explicit colour
+ const second = mockSetData.mock.calls[1][0].features[0].properties["navigator.color"];
+
+ expect(first).toBe(second);
+ });
+
+ it("applies default line width and opacity when not specified", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature(lineFeature("l"));
+
+ const props = mockSetData.mock.calls[0][0].features[0].properties;
+ expect(props["navigator.width"]).toBe(3);
+ expect(props["navigator.opacity"]).toBe(0.85);
+ });
+
+ it("applies default point radius and opacity when not specified", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature(pointFeature("p"));
+
+ const props = mockSetData.mock.calls[0][0].features[0].properties;
+ expect(props["navigator.radius"]).toBe(6);
+ expect(props["navigator.opacity"]).toBe(1);
+ });
+
+ it("applies default polygon fillOpacity and opacity when not specified", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature(polygonFeature("p"));
+
+ const props = mockSetData.mock.calls[0][0].features[0].properties;
+ expect(props["navigator.fillOpacity"]).toBe(0.4);
+ expect(props["navigator.opacity"]).toBe(0.85);
+ });
+
+ it("does not override explicitly provided navigator.* style values", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature({
+ ...lineFeature("l"),
+ properties: { "navigator.width": 8, "navigator.opacity": 0.5 },
+ });
+
+ const props = mockSetData.mock.calls[0][0].features[0].properties;
+ expect(props["navigator.width"]).toBe(8);
+ expect(props["navigator.opacity"]).toBe(0.5);
+ });
+});
+
+describe("useGeoJSON / setDefaults", () => {
+ it("setDefaults colour is used for new features with no navigator.color", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+
+ geoJSON.setDefaults({ line: { color: "#123456" } });
+ geoJSON.setFeature(lineFeature("l"));
+
+ const fc = mockSetData.mock.calls[mockSetData.mock.calls.length - 1][0];
+ expect(fc.features[0].properties["navigator.color"]).toBe("#123456");
+ });
+
+ it("setDefaults re-processes existing features", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+
+ geoJSON.setFeature(lineFeature("l")); // gets auto colour
+
+ geoJSON.setDefaults({ line: { color: "#abcdef" } });
+
+ const fc = mockSetData.mock.calls[mockSetData.mock.calls.length - 1][0];
+ expect(fc.features[0].properties["navigator.color"]).toBe("#abcdef");
+ });
+
+ it("setDefaults does not override explicit navigator.color on existing feature", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+
+ geoJSON.setFeature(lineFeature("l", "#explicit"));
+ geoJSON.setDefaults({ line: { color: "#default" } });
+
+ const fc = mockSetData.mock.calls[mockSetData.mock.calls.length - 1][0];
+ expect(fc.features[0].properties["navigator.color"]).toBe("#explicit");
+ });
+
+ it("setDefaults updates width and opacity for existing features", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+
+ geoJSON.setFeature(lineFeature("l")); // default width=3
+ geoJSON.setDefaults({ line: { width: 10, opacity: 0.5 } });
+
+ const fc = mockSetData.mock.calls[mockSetData.mock.calls.length - 1][0];
+ const props = fc.features[0].properties;
+ expect(props["navigator.width"]).toBe(10);
+ expect(props["navigator.opacity"]).toBe(0.5);
+ });
+
+ it("ignores unknown geometry type keys in setDefaults", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+
+ // Should not throw
+ expect(() => geoJSON.setDefaults({ unknown: { color: "red" } })).not.toThrow();
+ });
+});
+
+describe("useGeoJSON / layer configuration", () => {
+ it("line layer has round cap and join", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+
+ const lineLayerCall = mockMap.addLayer.mock.calls.find(
+ (c) => c[0].id === "navigator-geojson-line",
+ );
+ expect(lineLayerCall[0].layout["line-cap"]).toBe("round");
+ expect(lineLayerCall[0].layout["line-join"]).toBe("round");
+ });
+
+ it("layers use data-driven expressions for colour", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+
+ const lineLayer = mockMap.addLayer.mock.calls.find(
+ (c) => c[0].id === "navigator-geojson-line",
+ )[0];
+ expect(lineLayer.paint["line-color"]).toEqual(["get", "navigator.color"]);
+ });
+
+ it("all geometry-type layers have correct filters", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+
+ const getFilter = (id) =>
+ mockMap.addLayer.mock.calls.find((c) => c[0].id === id)?.[0].filter;
+
+ expect(getFilter("navigator-geojson-line")).toEqual(
+ ["match", ["geometry-type"], ["LineString", "MultiLineString"], true, false],
+ );
+ expect(getFilter("navigator-geojson-point")).toEqual(
+ ["match", ["geometry-type"], ["Point", "MultiPoint"], true, false],
+ );
+ expect(getFilter("navigator-geojson-polygon-fill")).toEqual(
+ ["match", ["geometry-type"], ["Polygon", "MultiPolygon"], true, false],
+ );
+ });
+
+ it("point layer has white stroke", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+
+ const pointLayer = mockMap.addLayer.mock.calls.find(
+ (c) => c[0].id === "navigator-geojson-point",
+ )[0];
+ expect(pointLayer.paint["circle-stroke-color"]).toBe("white");
+ });
+});
+
+describe("useGeoJSON / instance isolation", () => {
+ it("separate instanceIds maintain independent feature sets", async () => {
+ // Instance A
+ injectReturn = "instance-a";
+ emitterCallbacks = {};
+ vi.clearAllMocks();
+ mockMap.getSource.mockImplementation(() => ({ setData: mockSetData }));
+ vi.resetModules();
+ const { useGeoJSON: useGeoJSONA } = await import("../../src/composables/useGeoJSON.js");
+ const geoJSONA = useGeoJSONA();
+ const callbacksA = { ...emitterCallbacks };
+ callbacksA["map:ready"]?.({ map: mockMap });
+ geoJSONA.setFeature(lineFeature("shared-id"));
+ const countA = mockSetData.mock.calls[mockSetData.mock.calls.length - 1][0].features.length;
+
+ // Instance B (same module, different id via second call)
+ injectReturn = "instance-b";
+ emitterCallbacks = {};
+ const geoJSONB = useGeoJSONA("instance-b");
+ emitterCallbacks["map:ready"]?.({ map: mockMap });
+ // B has no features
+ const lastFC = mockSetData.mock.calls[mockSetData.mock.calls.length - 1][0];
+
+ expect(countA).toBe(1);
+ expect(lastFC.features).toHaveLength(0);
+ });
+});
+
+describe("useGeoJSON / MultiLineString and MultiPolygon", () => {
+ it("categorises MultiLineString as a line", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature({
+ type: "Feature",
+ id: "ml",
+ geometry: { type: "MultiLineString", coordinates: [[[-128, 50], [-127, 51]]] },
+ properties: {},
+ });
+
+ const fc = mockSetData.mock.calls[0][0];
+ expect(fc.features).toHaveLength(1);
+ expect(fc.features[0].properties["navigator.width"]).toBe(3);
+ });
+
+ it("categorises MultiPolygon as a polygon", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature({
+ type: "Feature",
+ id: "mp",
+ geometry: {
+ type: "MultiPolygon",
+ coordinates: [
+ [[[-128, 50], [-127, 50], [-127, 51], [-128, 51], [-128, 50]]],
+ ],
+ },
+ properties: {},
+ });
+
+ const fc = mockSetData.mock.calls[0][0];
+ expect(fc.features[0].properties["navigator.fillOpacity"]).toBe(0.4);
+ });
+
+ it("categorises MultiPoint as a point", async () => {
+ const geoJSON = await freshUseGeoJSON();
+ simulateMapReady(geoJSON);
+ mockSetData.mockClear();
+
+ geoJSON.setFeature({
+ type: "Feature",
+ id: "mpt",
+ geometry: { type: "MultiPoint", coordinates: [[-128, 50], [-127, 51]] },
+ properties: {},
+ });
+
+ const fc = mockSetData.mock.calls[0][0];
+ expect(fc.features[0].properties["navigator.radius"]).toBe(6);
+ });
+});