Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ build/
*.egg
.eggs/
*.so
*.log

# Virtual environments
.venv/
Expand Down
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ COPY examples/data/ examples/data/
# Create data directories for SQLite + rendered files
RUN mkdir -p /app/data /app/server/data/files

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

EXPOSE 8000

WORKDIR /app/server
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
[![MapLibre](https://img.shields.io/badge/MapLibre-GL-396CB2?logo=maplibre&logoColor=white)](https://maplibre.org/)
[![ESIP](https://img.shields.io/badge/ESIP-Federation-1B6CA8)](https://www.esipfed.org/)

---
![Demo: Flyover to Matterhorn](docs/output2.gif)
---

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.
Expand Down Expand Up @@ -51,7 +53,7 @@ Think of it as the Star Trek computer's map console. You say the words; the map
| [`server/`](server/) | FastAPI server — REST API, WebSocket hub, MCP server, GeoTIFF & screenshot services, auth portal |
| [`sdk/`](sdk/) | `mapcontrol` — typed Python client SDK |
| [`examples/`](examples/) | Runnable demo scripts (shapes, terrain, glyphs, GeoTIFFs) + sample data |
| [`docs/`](docs/) | Guides — MCP integration, LLM context block, MCP Apps field guide, map-engine comparison |
| [`docs/`](docs/) | Guides — MCP integration, LLM context block, MCP Apps field guide, map-engine comparison, Puppeteer animation skills |
| [`deploy/`](deploy/) | Deployment helpers (local PyPI index for the SDK) |

## Quick start
Expand All @@ -71,6 +73,8 @@ services:
docker compose up -d
```

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

**Verify it's up:**

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

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

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

## Running tests

The acceptance suites run inside the same image you deploy — exactly how CI gates every push:
Expand Down
Binary file added docs/output2.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
75 changes: 75 additions & 0 deletions docs/puppeteer-skills/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Puppeteer animation skills (reference examples)

These are **reference skills** — illustrative, copy-and-adapt examples that show how to
drive the MapControl web map with [Puppeteer](https://pptr.dev) to produce animations
for different scenarios. They are documentation, not a shipped/tested package; treat each
`SKILL.md` as a recipe and each `animate.mjs` as a starting point.

Each skill drives a **live map page** the same way a browser user would: it navigates to a
map URL, waits for the map to be ready, then scripts camera moves. Nothing here reaches
into private server internals — animation goes through the in-page MapLibre map object the
page already publishes.

## What the page gives you

The served map page publishes two hooks the moment it is ready (see
[`server/mapcontrol_server/static/esip-contract.js`](../../server/mapcontrol_server/static/esip-contract.js)):

| Hook | What it is | Use it for |
|---|---|---|
| `window.__esipInternals.map` | the raw **MapLibre GL JS** `Map` instance | camera animation — `flyTo`, `easeTo`, `rotateTo`, `setBearing`, `setPitch` |
| `window.ESIPMap` | the **public command surface** | basemap, visibility, `zoomToAssets`, reading the asset registry |
| `esip:ready` event | fired once the contract is live | knowing when the hooks exist |

Because animation just calls MapLibre's own camera methods, everything MapLibre supports
is available — including the smooth van Wijk `flyTo` and 3D globe + terrain (the same
terrain/sky path fixed in the server shell).

## Prerequisites

```bash
npm install puppeteer
```

You also need a **map to point at**. Create one first (any of the usual ways) and grab its
`map_id`:

```bash
# Minimal: create a map over REST and read back the id
curl -s -X POST http://localhost:8000/api/maps | python3 -c "import sys,json; print(json.load(sys.stdin)['map_id'])"
```

or from the Python SDK:

```python
from mapcontrol import MapControl
session = MapControl("http://localhost:8000").create_map()
print(session.map_id) # feed this to MAP_ID below
```

The map URL every skill opens is:

```
http://localhost:8000/map/<MAP_ID>?ui=none
```

`ui=none` serves the **naked canvas** (no picker, no draw tools) — the cleanest frame for a
recording. Drop it if you want the chrome. If `user_session` is omitted the page
auto-creates one, which is fine for a throwaway animation.

## Shared helper

All skills import [`lib/esip-map.mjs`](lib/esip-map.mjs), a tiny helper that launches a
browser, opens a map URL, and resolves once `window.__esipInternals.map` exists and the
style has loaded. Read it once; the per-skill scripts stay short.

## The skills

| Skill | Scenario |
|---|---|
| [`flyto-tour/`](flyto-tour/SKILL.md) | Ballistic **city-to-city tour** — smooth `flyTo` between waypoints |
| [`terrain-orbit/`](terrain-orbit/SKILL.md) | **3D globe orbit** around a peak (Matterhorn) with terrain + sky |
| [`keyframe-screenshots/`](keyframe-screenshots/SKILL.md) | Capture **PNG stills** at scripted keyframes |
| [`record-frames/`](record-frames/SKILL.md) | Capture a **frame sequence** during an animation (→ GIF/MP4) |

Each folder has a `SKILL.md` (when to use it + the recipe) and a runnable `animate.mjs`.
57 changes: 57 additions & 0 deletions docs/puppeteer-skills/flyto-tour/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
name: flyto-tour
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.
---

# Skill: Ballistic flyTo tour

Fly the camera between a sequence of waypoints with MapLibre's `flyTo` — the smooth
van Wijk zoom-out-then-in arc, so long hops don't tear through tiles.

## When to use

- A "world tour" or multi-site flythrough for a demo, header, or explainer.
- Any time you have an ordered list of `[lon, lat, zoom]` stops to visit.

## Recipe

1. Open the map with the shared helper and wait until it's ready.
2. For each waypoint, call `flyTo` and `await` `moveend` before the next hop.
3. Tune `speed`/`curve` for how aggressive the arc is; add a short hold at each stop.

The waypoints below are illustrative — swap in your own. See
[`animate.mjs`](animate.mjs) for the runnable version.

```js
import { openMap, cameraMove, sleep } from "../lib/esip-map.mjs";

const STOPS = [
{ name: "New York", center: [-74.0060, 40.7128], zoom: 12 },
{ name: "London", center: [-0.1276, 51.5074], zoom: 12 },
{ name: "Tokyo", center: [139.6917, 35.6895], zoom: 12 },
{ name: "Sydney", center: [151.2093, -33.8688], zoom: 12 },
];

const { page, close } = await openMap({ mapId: process.env.MAP_ID });

for (const stop of STOPS) {
console.log(`→ ${stop.name}`);
await cameraMove(page, "flyTo", {
center: stop.center,
zoom: stop.zoom,
speed: 0.8, // lower = slower, more cinematic
curve: 1.42, // arc "zoom-out" amount
essential: true,
});
await sleep(1200); // hold on the destination
}

await close();
```

## Knobs

- `speed` — animation pace (default ~1.2). Lower is slower/dramatic.
- `curve` — how far the camera zooms out mid-flight for long hops.
- Hold time — the `sleep()` between stops.
- Combine with `terrain-orbit` to arrive and then orbit a destination.
47 changes: 47 additions & 0 deletions docs/puppeteer-skills/flyto-tour/animate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env node
// Reference skill: ballistic flyTo tour across waypoints.
//
// MAP_ID=<map_id> node animate.mjs
//
// Requires a running server (default http://localhost:8000, override with
// MAPCONTROL_SERVER) and an existing map_id. See ../README.md.

import { openMap, cameraMove, sleep } from "../lib/esip-map.mjs";

const MAP_ID = process.env.MAP_ID;
if (!MAP_ID) {
console.error("Set MAP_ID=<map_id> (see docs/puppeteer-skills/README.md)");
process.exit(1);
}

// Waypoints — swap in your own [lon, lat, zoom] stops.
const STOPS = [
{ name: "New York City", center: [-74.006, 40.7128], zoom: 12 },
{ name: "London", center: [-0.1276, 51.5074], zoom: 12 },
{ name: "Tokyo", center: [139.6917, 35.6895], zoom: 12 },
{ name: "Sydney", center: [151.2093, -33.8688], zoom: 12 },
{ name: "Cape Town", center: [18.4241, -33.9249], zoom: 12 },
];

const { page, close } = await openMap({ mapId: MAP_ID, headless: true });

// Start planted on the first stop, then fly the rest.
await cameraMove(page, "jumpTo", { center: STOPS[0].center, zoom: STOPS[0].zoom });
console.log(`start: ${STOPS[0].name}`);
await sleep(800);

for (let i = 1; i < STOPS.length; i++) {
const stop = STOPS[i];
console.log(`fly → ${stop.name}`);
await cameraMove(page, "flyTo", {
center: stop.center,
zoom: stop.zoom,
speed: 0.8,
curve: 1.42,
essential: true,
});
await sleep(1200);
}

console.log("tour complete");
await close();
49 changes: 49 additions & 0 deletions docs/puppeteer-skills/keyframe-screenshots/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
name: keyframe-screenshots
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.
---

# Skill: Keyframe screenshots

Drive the camera to named keyframes and snapshot each. This is the scenario for producing
docs imagery, README thumbnails, or a quick visual regression check (e.g. confirming the 3D
view renders with a clean console after the sky fix).

## When to use

- You want a handful of PNG stills of specific views, not a full animation.
- You want to assert "this view renders" in CI without a running human.

## Recipe

Capture with Puppeteer's own `page.screenshot()` (browser-side, no server round-trip). See
[`animate.mjs`](animate.mjs).

```js
import { openMap, cameraMove, sleep } from "../lib/esip-map.mjs";

const KEYFRAMES = [
{ name: "matterhorn-3d", center: [7.6586, 45.9763], zoom: 12.5, pitch: 70 },
{ name: "zermatt-town", center: [7.7491, 46.0207], zoom: 14, pitch: 45 },
];

const { page, close } = await openMap({ mapId: process.env.MAP_ID });

for (const kf of KEYFRAMES) {
await cameraMove(page, "flyTo", { ...kf, essential: true });
await sleep(1500); // let tiles finish
await page.screenshot({ path: `${kf.name}.png` });
console.log(`saved ${kf.name}.png`);
}

await close();
```

## Notes

- `page.screenshot()` grabs exactly what the viewport shows — set the viewport in `openMap`
to control output resolution.
- The server also has its own screenshot endpoint
(`POST /api/maps/{map_id}/sessions/{user_session_id}/screenshot`) if you'd rather capture
server-side; this skill stays fully client-side so it needs no session id.
- To turn keyframes into a visual check, compare each PNG against a committed baseline.
38 changes: 38 additions & 0 deletions docs/puppeteer-skills/keyframe-screenshots/animate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env node
// Reference skill: capture PNG stills at scripted keyframes.
//
// MAP_ID=<map_id> node animate.mjs
//
// Writes one PNG per keyframe into the current directory.

import { openMap, cameraMove, sleep } from "../lib/esip-map.mjs";

const MAP_ID = process.env.MAP_ID;
if (!MAP_ID) {
console.error("Set MAP_ID=<map_id> (see docs/puppeteer-skills/README.md)");
process.exit(1);
}

const KEYFRAMES = [
{ name: "matterhorn-3d", center: [7.6586, 45.9763], zoom: 12.5, pitch: 70, bearing: 20 },
{ name: "zermatt-town", center: [7.7491, 46.0207], zoom: 14, pitch: 45, bearing: 0 },
{ name: "alps-wide", center: [8.0, 46.2], zoom: 8, pitch: 30, bearing: 0 },
];

const { page, close } = await openMap({ mapId: MAP_ID, headless: true, viewport: [1600, 900] });

for (const kf of KEYFRAMES) {
console.log(`framing ${kf.name}`);
await cameraMove(page, "flyTo", {
center: kf.center,
zoom: kf.zoom,
pitch: kf.pitch,
bearing: kf.bearing,
essential: true,
});
await sleep(1500); // let tiles finish loading before the snap
await page.screenshot({ path: `${kf.name}.png` });
console.log(`saved ${kf.name}.png`);
}

await close();
Loading
Loading