Skip to content

Commit 2e1bb00

Browse files
authored
Fix MapLibre sky API + screenshot loopback self-navigation; add Puppeteer animation skills (#3)
setSky() replaces the invalid Mapbox-style sky layer (MapLibre has no 'sky' layer type; failures surfaced on the error event, uncatchable via try/catch). Headless screenshots now self-navigate via loopback on the bound port (_internal_base_url) instead of MAPCONTROL_PUBLIC_URL, so host-side port remaps and proxy origins no longer break take_screenshot; Dockerfile pins MAPCONTROL_PORT=8000 so config.server.port matches the bound port. Regression test test_screenshot_loopback.py pins the invariant. Adds Puppeteer animation skills (reference docs) + Matterhorn demo GIF, README port-conflict troubleshooting note, and *.log to .gitignore.
1 parent 44b63b6 commit 2e1bb00

17 files changed

Lines changed: 780 additions & 17 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ build/
88
*.egg
99
.eggs/
1010
*.so
11+
*.log
1112

1213
# Virtual environments
1314
.venv/

Dockerfile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ COPY examples/data/ examples/data/
2828
# Create data directories for SQLite + rendered files
2929
RUN mkdir -p /app/data /app/server/data/files
3030

31+
# Single source of truth for the bound port. The working-dir config.toml sets a
32+
# different dev port (7777), so without this load_config().server.port would
33+
# disagree with the port uvicorn actually binds below — and headless-screenshot
34+
# self-navigation (_internal_base_url) would target the wrong port. Setting the
35+
# env makes config.server.port == the bound port. Keep this in sync with the
36+
# --port in CMD.
37+
ENV MAPCONTROL_PORT=8000
38+
3139
EXPOSE 8000
3240

3341
WORKDIR /app/server

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
[![MapLibre](https://img.shields.io/badge/MapLibre-GL-396CB2?logo=maplibre&logoColor=white)](https://maplibre.org/)
1111
[![ESIP](https://img.shields.io/badge/ESIP-Federation-1B6CA8)](https://www.esipfed.org/)
1212

13+
---
14+
![Demo: Flyover to Matterhorn](docs/output2.gif)
1315
---
1416

1517
Ask your AI assistant to *"draw the burn scar over Los Alamos and fly the camera to it"* — and watch it happen live in a browser tab. MapControl is a headless map server with a real-time MapLibre frontend: create maps, add GeoJSON and GeoTIFF overlays, animate the camera, switch basemaps and themes, take screenshots — over a **Python SDK**, a **REST API**, or the **Model Context Protocol** for Claude, Cline, and any other MCP client.
@@ -51,7 +53,7 @@ Think of it as the Star Trek computer's map console. You say the words; the map
5153
| [`server/`](server/) | FastAPI server — REST API, WebSocket hub, MCP server, GeoTIFF & screenshot services, auth portal |
5254
| [`sdk/`](sdk/) | `mapcontrol` — typed Python client SDK |
5355
| [`examples/`](examples/) | Runnable demo scripts (shapes, terrain, glyphs, GeoTIFFs) + sample data |
54-
| [`docs/`](docs/) | Guides — MCP integration, LLM context block, MCP Apps field guide, map-engine comparison |
56+
| [`docs/`](docs/) | Guides — MCP integration, LLM context block, MCP Apps field guide, map-engine comparison, Puppeteer animation skills |
5557
| [`deploy/`](deploy/) | Deployment helpers (local PyPI index for the SDK) |
5658

5759
## Quick start
@@ -71,6 +73,8 @@ services:
7173
docker compose up -d
7274
```
7375

76+
> **Port 8000 already taken on your machine?** (`lsof -i :8000` shows what's using it.) Remap only the *host* side and leave the container port unchanged — `ports: ["8080:8000"]` — then reach it at `http://localhost:8080`. Keep the container on 8000: the image binds 8000 and self-navigates there for screenshots, so changing the container side would break them.
77+
7478
**Verify it's up:**
7579

7680
```bash
@@ -202,6 +206,11 @@ python examples/demo_glyphs.py # glyph markers & labels
202206

203207
Sample GeoTIFFs live in [`examples/data/`](examples/data/).
204208

209+
Want to drive the map from a **browser** instead of Python — for camera animations,
210+
recorded flythroughs, or screenshot capture? See the reference
211+
**[Puppeteer animation skills](docs/puppeteer-skills/)** (ballistic flyTo tours, 3D terrain
212+
orbits, keyframe stills, frame-sequence recording).
213+
205214
## Running tests
206215

207216
The acceptance suites run inside the same image you deploy — exactly how CI gates every push:

docs/output2.gif

10.2 MB
Loading

docs/puppeteer-skills/README.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Puppeteer animation skills (reference examples)
2+
3+
These are **reference skills** — illustrative, copy-and-adapt examples that show how to
4+
drive the MapControl web map with [Puppeteer](https://pptr.dev) to produce animations
5+
for different scenarios. They are documentation, not a shipped/tested package; treat each
6+
`SKILL.md` as a recipe and each `animate.mjs` as a starting point.
7+
8+
Each skill drives a **live map page** the same way a browser user would: it navigates to a
9+
map URL, waits for the map to be ready, then scripts camera moves. Nothing here reaches
10+
into private server internals — animation goes through the in-page MapLibre map object the
11+
page already publishes.
12+
13+
## What the page gives you
14+
15+
The served map page publishes two hooks the moment it is ready (see
16+
[`server/mapcontrol_server/static/esip-contract.js`](../../server/mapcontrol_server/static/esip-contract.js)):
17+
18+
| Hook | What it is | Use it for |
19+
|---|---|---|
20+
| `window.__esipInternals.map` | the raw **MapLibre GL JS** `Map` instance | camera animation — `flyTo`, `easeTo`, `rotateTo`, `setBearing`, `setPitch` |
21+
| `window.ESIPMap` | the **public command surface** | basemap, visibility, `zoomToAssets`, reading the asset registry |
22+
| `esip:ready` event | fired once the contract is live | knowing when the hooks exist |
23+
24+
Because animation just calls MapLibre's own camera methods, everything MapLibre supports
25+
is available — including the smooth van Wijk `flyTo` and 3D globe + terrain (the same
26+
terrain/sky path fixed in the server shell).
27+
28+
## Prerequisites
29+
30+
```bash
31+
npm install puppeteer
32+
```
33+
34+
You also need a **map to point at**. Create one first (any of the usual ways) and grab its
35+
`map_id`:
36+
37+
```bash
38+
# Minimal: create a map over REST and read back the id
39+
curl -s -X POST http://localhost:8000/api/maps | python3 -c "import sys,json; print(json.load(sys.stdin)['map_id'])"
40+
```
41+
42+
or from the Python SDK:
43+
44+
```python
45+
from mapcontrol import MapControl
46+
session = MapControl("http://localhost:8000").create_map()
47+
print(session.map_id) # feed this to MAP_ID below
48+
```
49+
50+
The map URL every skill opens is:
51+
52+
```
53+
http://localhost:8000/map/<MAP_ID>?ui=none
54+
```
55+
56+
`ui=none` serves the **naked canvas** (no picker, no draw tools) — the cleanest frame for a
57+
recording. Drop it if you want the chrome. If `user_session` is omitted the page
58+
auto-creates one, which is fine for a throwaway animation.
59+
60+
## Shared helper
61+
62+
All skills import [`lib/esip-map.mjs`](lib/esip-map.mjs), a tiny helper that launches a
63+
browser, opens a map URL, and resolves once `window.__esipInternals.map` exists and the
64+
style has loaded. Read it once; the per-skill scripts stay short.
65+
66+
## The skills
67+
68+
| Skill | Scenario |
69+
|---|---|
70+
| [`flyto-tour/`](flyto-tour/SKILL.md) | Ballistic **city-to-city tour** — smooth `flyTo` between waypoints |
71+
| [`terrain-orbit/`](terrain-orbit/SKILL.md) | **3D globe orbit** around a peak (Matterhorn) with terrain + sky |
72+
| [`keyframe-screenshots/`](keyframe-screenshots/SKILL.md) | Capture **PNG stills** at scripted keyframes |
73+
| [`record-frames/`](record-frames/SKILL.md) | Capture a **frame sequence** during an animation (→ GIF/MP4) |
74+
75+
Each folder has a `SKILL.md` (when to use it + the recipe) and a runnable `animate.mjs`.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
name: flyto-tour
3+
description: Animate a smooth ballistic camera tour across a list of geographic waypoints on a MapControl map using Puppeteer and MapLibre's flyTo. Use when you want a cinematic city-to-city or site-to-site flythrough.
4+
---
5+
6+
# Skill: Ballistic flyTo tour
7+
8+
Fly the camera between a sequence of waypoints with MapLibre's `flyTo` — the smooth
9+
van Wijk zoom-out-then-in arc, so long hops don't tear through tiles.
10+
11+
## When to use
12+
13+
- A "world tour" or multi-site flythrough for a demo, header, or explainer.
14+
- Any time you have an ordered list of `[lon, lat, zoom]` stops to visit.
15+
16+
## Recipe
17+
18+
1. Open the map with the shared helper and wait until it's ready.
19+
2. For each waypoint, call `flyTo` and `await` `moveend` before the next hop.
20+
3. Tune `speed`/`curve` for how aggressive the arc is; add a short hold at each stop.
21+
22+
The waypoints below are illustrative — swap in your own. See
23+
[`animate.mjs`](animate.mjs) for the runnable version.
24+
25+
```js
26+
import { openMap, cameraMove, sleep } from "../lib/esip-map.mjs";
27+
28+
const STOPS = [
29+
{ name: "New York", center: [-74.0060, 40.7128], zoom: 12 },
30+
{ name: "London", center: [-0.1276, 51.5074], zoom: 12 },
31+
{ name: "Tokyo", center: [139.6917, 35.6895], zoom: 12 },
32+
{ name: "Sydney", center: [151.2093, -33.8688], zoom: 12 },
33+
];
34+
35+
const { page, close } = await openMap({ mapId: process.env.MAP_ID });
36+
37+
for (const stop of STOPS) {
38+
console.log(`${stop.name}`);
39+
await cameraMove(page, "flyTo", {
40+
center: stop.center,
41+
zoom: stop.zoom,
42+
speed: 0.8, // lower = slower, more cinematic
43+
curve: 1.42, // arc "zoom-out" amount
44+
essential: true,
45+
});
46+
await sleep(1200); // hold on the destination
47+
}
48+
49+
await close();
50+
```
51+
52+
## Knobs
53+
54+
- `speed` — animation pace (default ~1.2). Lower is slower/dramatic.
55+
- `curve` — how far the camera zooms out mid-flight for long hops.
56+
- Hold time — the `sleep()` between stops.
57+
- Combine with `terrain-orbit` to arrive and then orbit a destination.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env node
2+
// Reference skill: ballistic flyTo tour across waypoints.
3+
//
4+
// MAP_ID=<map_id> node animate.mjs
5+
//
6+
// Requires a running server (default http://localhost:8000, override with
7+
// MAPCONTROL_SERVER) and an existing map_id. See ../README.md.
8+
9+
import { openMap, cameraMove, sleep } from "../lib/esip-map.mjs";
10+
11+
const MAP_ID = process.env.MAP_ID;
12+
if (!MAP_ID) {
13+
console.error("Set MAP_ID=<map_id> (see docs/puppeteer-skills/README.md)");
14+
process.exit(1);
15+
}
16+
17+
// Waypoints — swap in your own [lon, lat, zoom] stops.
18+
const STOPS = [
19+
{ name: "New York City", center: [-74.006, 40.7128], zoom: 12 },
20+
{ name: "London", center: [-0.1276, 51.5074], zoom: 12 },
21+
{ name: "Tokyo", center: [139.6917, 35.6895], zoom: 12 },
22+
{ name: "Sydney", center: [151.2093, -33.8688], zoom: 12 },
23+
{ name: "Cape Town", center: [18.4241, -33.9249], zoom: 12 },
24+
];
25+
26+
const { page, close } = await openMap({ mapId: MAP_ID, headless: true });
27+
28+
// Start planted on the first stop, then fly the rest.
29+
await cameraMove(page, "jumpTo", { center: STOPS[0].center, zoom: STOPS[0].zoom });
30+
console.log(`start: ${STOPS[0].name}`);
31+
await sleep(800);
32+
33+
for (let i = 1; i < STOPS.length; i++) {
34+
const stop = STOPS[i];
35+
console.log(`fly → ${stop.name}`);
36+
await cameraMove(page, "flyTo", {
37+
center: stop.center,
38+
zoom: stop.zoom,
39+
speed: 0.8,
40+
curve: 1.42,
41+
essential: true,
42+
});
43+
await sleep(1200);
44+
}
45+
46+
console.log("tour complete");
47+
await close();
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
name: keyframe-screenshots
3+
description: Move a MapControl map camera to a set of scripted keyframes and capture a PNG still at each one using Puppeteer. Use to generate documentation stills or thumbnails, or to visually verify the map renders a given view.
4+
---
5+
6+
# Skill: Keyframe screenshots
7+
8+
Drive the camera to named keyframes and snapshot each. This is the scenario for producing
9+
docs imagery, README thumbnails, or a quick visual regression check (e.g. confirming the 3D
10+
view renders with a clean console after the sky fix).
11+
12+
## When to use
13+
14+
- You want a handful of PNG stills of specific views, not a full animation.
15+
- You want to assert "this view renders" in CI without a running human.
16+
17+
## Recipe
18+
19+
Capture with Puppeteer's own `page.screenshot()` (browser-side, no server round-trip). See
20+
[`animate.mjs`](animate.mjs).
21+
22+
```js
23+
import { openMap, cameraMove, sleep } from "../lib/esip-map.mjs";
24+
25+
const KEYFRAMES = [
26+
{ name: "matterhorn-3d", center: [7.6586, 45.9763], zoom: 12.5, pitch: 70 },
27+
{ name: "zermatt-town", center: [7.7491, 46.0207], zoom: 14, pitch: 45 },
28+
];
29+
30+
const { page, close } = await openMap({ mapId: process.env.MAP_ID });
31+
32+
for (const kf of KEYFRAMES) {
33+
await cameraMove(page, "flyTo", { ...kf, essential: true });
34+
await sleep(1500); // let tiles finish
35+
await page.screenshot({ path: `${kf.name}.png` });
36+
console.log(`saved ${kf.name}.png`);
37+
}
38+
39+
await close();
40+
```
41+
42+
## Notes
43+
44+
- `page.screenshot()` grabs exactly what the viewport shows — set the viewport in `openMap`
45+
to control output resolution.
46+
- The server also has its own screenshot endpoint
47+
(`POST /api/maps/{map_id}/sessions/{user_session_id}/screenshot`) if you'd rather capture
48+
server-side; this skill stays fully client-side so it needs no session id.
49+
- To turn keyframes into a visual check, compare each PNG against a committed baseline.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env node
2+
// Reference skill: capture PNG stills at scripted keyframes.
3+
//
4+
// MAP_ID=<map_id> node animate.mjs
5+
//
6+
// Writes one PNG per keyframe into the current directory.
7+
8+
import { openMap, cameraMove, sleep } from "../lib/esip-map.mjs";
9+
10+
const MAP_ID = process.env.MAP_ID;
11+
if (!MAP_ID) {
12+
console.error("Set MAP_ID=<map_id> (see docs/puppeteer-skills/README.md)");
13+
process.exit(1);
14+
}
15+
16+
const KEYFRAMES = [
17+
{ name: "matterhorn-3d", center: [7.6586, 45.9763], zoom: 12.5, pitch: 70, bearing: 20 },
18+
{ name: "zermatt-town", center: [7.7491, 46.0207], zoom: 14, pitch: 45, bearing: 0 },
19+
{ name: "alps-wide", center: [8.0, 46.2], zoom: 8, pitch: 30, bearing: 0 },
20+
];
21+
22+
const { page, close } = await openMap({ mapId: MAP_ID, headless: true, viewport: [1600, 900] });
23+
24+
for (const kf of KEYFRAMES) {
25+
console.log(`framing ${kf.name}`);
26+
await cameraMove(page, "flyTo", {
27+
center: kf.center,
28+
zoom: kf.zoom,
29+
pitch: kf.pitch,
30+
bearing: kf.bearing,
31+
essential: true,
32+
});
33+
await sleep(1500); // let tiles finish loading before the snap
34+
await page.screenshot({ path: `${kf.name}.png` });
35+
console.log(`saved ${kf.name}.png`);
36+
}
37+
38+
await close();

0 commit comments

Comments
 (0)