@mapmap/maps 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,6 +22,11 @@ shared copy (two MapLibre instances on one page break the WebGL context).
22
22
  You also need a MapMap gateway API key (`snk_…`). See
23
23
  [`docs/SDK-DISTRIBUTION.md`](../docs/SDK-DISTRIBUTION.md) for licensing.
24
24
 
25
+ **Building with an AI agent?** The package ships
26
+ [`llms-sdk.txt`](./llms-sdk.txt) - a concise, agent-facing integration
27
+ guide (init, key issuance, routing, places, flythrough, effects,
28
+ isochrones, and the classic gotchas). Point your coding agent at it.
29
+
25
30
  ## Quickstart
26
31
 
27
32
  ```ts
@@ -114,6 +119,12 @@ puck.remove();
114
119
 
115
120
  ### `class PlacesLayer` - places / store finder
116
121
 
122
+ > **⚠️ Give the map container an explicit height first.** MapLibre silently
123
+ > renders into a 0px-tall canvas when the container's height resolves to
124
+ > zero (a bare `<div id="map">` with no CSS) - no error, no map, no pins.
125
+ > Set `#map { height: 100vh; }` (or any real height) before debugging
126
+ > anything else.
127
+
117
128
  Drop your own places (e.g. 300 store locations) onto the map: clustered
118
129
  pins, popups, and "nearest branch" answers by straight line or by drive
119
130
  time (via the gateway's `POST /matrix`).
@@ -128,15 +139,18 @@ const stores = new PlacesLayer(map /* MapMapMap or maplibregl.Map */, {
128
139
  // …or a plain GeoJSON FeatureCollection of Points
129
140
  ],
130
141
  cluster: true, // default; clusterRadius?, clusterMaxZoom?
131
- color: "#ff6b35", // pin + cluster colour (default signal blue)
142
+ color: "#ff6b35", // pin colour: string or expression (below)
132
143
  fitBounds: true, // fit the view to the places on first set
133
144
  popup: (place) => `<strong>${place.name}</strong>`,
134
145
  onPlaceClick: (place, lngLat) => console.log(place.id, lngLat),
135
146
  });
136
147
 
137
- stores.setPlaces(nextPlaces); // replace data in place
148
+ stores.setPlaces(nextPlaces); // replace data - never dropped
149
+ stores.select("man-01"); // list→map sync: popup + camera
150
+ stores.deselect(); // close the popup
138
151
  stores.nearest({ lat: 51.5, lon: -0.13 }, 3); // haversine, adds distanceM
139
152
  await stores.nearestByDriveTime({ lat: 51.5, lon: -0.13 }, { n: 3, costing: "truck" });
153
+ stores.ids; // { source, points, clusters, clusterCounts } - see below
140
154
  stores.clear(); // remove pins; stores.destroy() also detaches listeners
141
155
  ```
142
156
 
@@ -146,6 +160,71 @@ circle pin if it fails to load). `nearestByDriveTime` sorts by `durationS`
146
160
  (seconds, driven `distanceM` attached, unreachable places dropped) and reuses
147
161
  the map's `baseUrl`/`apiKey` - or pass them as layer options.
148
162
 
163
+ `setPlaces` never silently drops an update: once the source exists the data
164
+ is applied immediately - even mid-render, while `map.isStyleLoaded()` is
165
+ transiently `false` - and calls made before the style has first loaded are
166
+ stashed (the latest one wins) and installed on `style.load`.
167
+ Search-as-you-type just works.
168
+
169
+ #### Per-category pin colours
170
+
171
+ `color` also accepts a MapLibre expression, evaluated against each
172
+ feature's `properties`. Everything in a place's `properties` is copied
173
+ verbatim onto the **top level** of its GeoJSON feature's properties,
174
+ alongside the reserved `id`, `name` and `__mapmapIndex` keys (which win on
175
+ collision) - so a scalar like `category` is directly `["get", …]`-able.
176
+ MapLibre JSON-stringifies nested objects/arrays at render time, so keep
177
+ anything you want to style on as a top-level string/number/boolean:
178
+
179
+ ```ts
180
+ const stores = new PlacesLayer(map, {
181
+ places: [
182
+ { id: "s1", name: "Corner Deli", lat: 51.5, lon: -0.1,
183
+ properties: { category: "food" } },
184
+ { id: "s2", name: "St Pancras", lat: 51.53, lon: -0.126,
185
+ properties: { category: "travel" } },
186
+ ],
187
+ color: [
188
+ "match", ["get", "category"],
189
+ "food", "#e63946",
190
+ "travel", "#457b9d",
191
+ "postoffice", "#d90429",
192
+ "childcare", "#ffb703",
193
+ /* fallback */ "#3a86ff",
194
+ ],
195
+ clusterColor: "#3a86ff", // clusters mix categories → plain colour only
196
+ });
197
+ ```
198
+
199
+ A cluster mixes categories, so an expression never applies to cluster
200
+ circles: they use `clusterColor`, which defaults to `color` when that is a
201
+ plain string, else to MapMap signal blue. (With a custom `icon`, pins are
202
+ images - `color` only styles the default circle pins.)
203
+
204
+ #### Programmatic selection (list → map)
205
+
206
+ `select(id, options?)` syncs a results list to the map: it opens the
207
+ configured `popup` at the place (`popup: false` to skip) and eases the
208
+ camera to it (`flyTo: false` to skip; `zoom` to also zoom in). It returns
209
+ the `Place`, or `undefined` for an unknown id (nothing happens). It does
210
+ **not** call `onPlaceClick` - programmatic selection is not a user click.
211
+ `deselect()` closes any open popup.
212
+
213
+ #### Escape-hatch styling: `ids`
214
+
215
+ The generated MapLibre ids are public API via `stores.ids` →
216
+ `{ source, points, clusters, clusterCounts }`, so raw MapLibre calls are
217
+ supported when the options don't reach far enough:
218
+
219
+ ```ts
220
+ map.setPaintProperty(stores.ids.points, "circle-radius", 9);
221
+ map.queryRenderedFeatures({ layers: [stores.ids.points] });
222
+ ```
223
+
224
+ `clusters`/`clusterCounts` are only installed with `cluster: true` (the
225
+ default); `points` is a circle layer by default, or a symbol layer once a
226
+ custom `icon` has loaded.
227
+
149
228
  ### `class NavigationCamera`
150
229
 
151
230
  The turnkey chase cam: follows each GPS fix course-up, tilted, with the
@@ -303,6 +382,89 @@ puck.setLocation({ lat: 51.5074, lon: -0.1278 }, 45); // heading in degrees
303
382
  payloads capped at 64 KB) - a malformed block falls back per-field to the
304
383
  defaults rather than failing.
305
384
 
385
+ ## Cinematic flythrough and scrollytelling
386
+
387
+ `flythrough(map, route, options?)` replays a route as a chase-cam ride: the
388
+ camera glides along the line, bearing eased along the shortest arc towards
389
+ a look-ahead point, at a configurable pitch/zoom/speed.
390
+
391
+ ```ts
392
+ import { flythrough, bindFlythroughToScroll } from "@mapmap/maps";
393
+
394
+ const route = await routes.route(from, to);
395
+ const replay = flythrough(map, route, { pitch: 60, durationMs: 15000 });
396
+ replay.onProgress((t) => scrubber.value = String(t));
397
+ replay.play(); // also: pause(), stop(), seek(0..1), speed = 2
398
+
399
+ // Scrollytelling: scrolling the story column scrubs the camera.
400
+ const unbind = bindFlythroughToScroll(replay, document.querySelector("#story")!);
401
+ ```
402
+
403
+ Options: `pitch` (default 60), `zoom` (16), `durationMs` (20 000) or
404
+ `speedMps` (ground speed; wins over duration), `lookAheadM` (200),
405
+ `bearingEase` (3). Accepts a `ParsedRoute` or a GeoJSON LineString. Nothing
406
+ moves until `play()`/`seek()`. `flythroughPose`, `bearingBetween` and
407
+ `shortestArcDelta` are exported for apps driving the camera themselves.
408
+
409
+ ## Route effects (the flowing ribbon)
410
+
411
+ `map.setRouteEffect("flow")` draws an animated energy ribbon along the
412
+ active route line - a MapLibre custom layer with first-party GLSL, no
413
+ extra dependencies.
414
+
415
+ ```ts
416
+ map.setRouteEffect("flow"); // uses the RouteLayer route
417
+ map.setRouteEffect("flow", { color: "#ff7a1f", width: 12, speed: 0.8 });
418
+ map.setRouteEffect("flow", { geometry }); // explicit geometry
419
+ map.setRouteEffect(null); // plain line again
420
+ ```
421
+
422
+ - Attaches automatically to whatever a `RouteLayer` on the map draws
423
+ (current and future routes), and dies with `routes.clear()`.
424
+ - Themes can request it: a Studio theme with
425
+ `"effects": { "route": "flow", "params": { … } }` compiles to
426
+ `metadata["mapmap:effects"]` on the style, and the map auto-enables the
427
+ ribbon. An explicit `setRouteEffect(…)` call (including `null`) wins.
428
+ - `prefers-reduced-motion` freezes the ribbon to a static gradient.
429
+ - If WebGL setup for the effect fails, it falls back silently to the plain
430
+ route line (one console warning).
431
+
432
+ ## Walkability rings (isochrones)
433
+
434
+ Reachability contours from the gateway's `POST /isochrone`:
435
+
436
+ ```ts
437
+ import { IsochroneLayer } from "@mapmap/maps";
438
+
439
+ const rings = new IsochroneLayer(map); // baseUrl/apiKey from the map
440
+ await rings.showReachability({
441
+ origin: { lat: 51.5074, lon: -0.1278 },
442
+ mode: "walk", // "walk" | "cycle" | "drive" | "truck" | raw costing
443
+ minutes: [5, 10, 15],
444
+ color: "#3a86ff", // optional
445
+ });
446
+ rings.clear();
447
+ ```
448
+
449
+ Renders graduated-opacity fills (nearest ring strongest), contour outlines
450
+ and `"N min"` labels, survives theme swaps, and resolves to the raw GeoJSON
451
+ `FeatureCollection` (each feature carries a `contour` property in minutes).
452
+ `costingOptions` forwards Valhalla costing options verbatim (e.g.
453
+ `{ pedestrian: { use_lit: 1.0 } }`).
454
+
455
+ ## Self-diagnosing errors
456
+
457
+ The map diagnoses the classic silent failures at construction and logs ONE
458
+ actionable `console.error` per issue per page, each with a docs link:
459
+
460
+ - `[container-zero-height]` - the 0px container (top blank-map cause)
461
+ - `[container-detached]` - container not in the DOM
462
+ - `[duplicate-maplibre]` - two maplibre-gl copies on one page
463
+ - `[webgl-unavailable]` - no WebGL context available
464
+ - `[invalid-api-key]` - a 401 from the gateway (missing/mistyped/revoked key)
465
+
466
+ `runMapDiagnostics(…)` is exported for apps wrapping a raw `maplibregl.Map`.
467
+
306
468
  ## The MapMap mark
307
469
 
308
470
  Maps render a small MapMap wordmark bottom-right (the same convention as
@@ -317,7 +479,7 @@ removable.
317
479
  import { GuidanceBanner, extractGuidance, speak } from "@mapmap/maps";
318
480
 
319
481
  const route = await routes.route(from, to, {
320
- costing: "truck",
482
+ profile: "truck",
321
483
  voice: true,
322
484
  banner: true,
323
485
  language: "en-GB",