Skip to content
Closed
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
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ COPY examples/data/ examples/data/
# Create data directories for SQLite + rendered files
RUN mkdir -p /app/data /app/server/data/files

EXPOSE 8000
EXPOSE 8080

WORKDIR /app/server

CMD ["uvicorn", "mapcontrol_server.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"]
CMD ["uvicorn", "mapcontrol_server.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--forwarded-allow-ips", "*"]
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,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 @@ -64,7 +64,7 @@ All you need is [Docker](https://docs.docker.com/get-docker/) — the published
services:
mapcontrol:
image: ghcr.io/esipfed/mc2:latest
ports: ["8000:8000"]
ports: ["8080:8080"]
```

```bash
Expand All @@ -74,7 +74,7 @@ docker compose up -d
**Verify it's up:**

```bash
curl http://localhost:8000/docs # interactive API docs
curl http://localhost:8080/docs # interactive API docs
```

That's the whole install. Want maps that survive restarts, share links that work off your machine, or premium basemaps? See **[docs/deployment.md](docs/deployment.md)** — it walks from this minimal setup to a full production config and explains what every knob does. (Contributors: `git clone` this repo and `docker compose up --build` builds the image locally.)
Expand All @@ -89,8 +89,8 @@ pip install "git+https://github.com/ESIPFed/mc2.git#subdirectory=sdk"

```python
from mapcontrol import MapControl

mc = MapControl("http://localhost:8000")
import httpx
mc = MapControl("http://localhost:8080")
session = mc.create_map()
print(session.url) # ← open this in a browser; it updates live

Expand All @@ -113,6 +113,10 @@ session.set_basemap("satellite")
session.set_theme("dark")

shot = session.take_screenshot() # PNG of the current view
png = httpx.get(shot.full_url).content
with open(shot.filename, "wb") as f:
f.write(png)

```

Watch the browser tab while the script runs — every call lands on the shared map live.
Expand All @@ -127,7 +131,7 @@ The MCP server is mounted **in-process** at `/mcp` (Streamable HTTP — the curr
{
"mcpServers": {
"mapcontrol": {
"url": "http://localhost:8000/mcp"
"url": "http://localhost:8080/mcp"
}
}
}
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 All @@ -217,7 +226,7 @@ docker compose run --rm mapcontrol python tests/test_mcp_auth.py # MCP authori

| Symptom | Fix |
|---|---|
| `port is already allocated` on start | Something else is on 8000. Change the compose mapping to e.g. `"8010:8000"` and point the SDK at `http://localhost:8010`. |
| `port is already allocated` on start | Something else is on 8080. Change the compose mapping to e.g. `"8010:8080"` and point the SDK at `http://localhost:8010`. |
| Shared map links point at `localhost` | Set `MAPCONTROL_PUBLIC_URL` to your server's public URL so map links work off-machine. |
| Maps vanish after container restart | Keep the `./data` volume mount from `docker-compose.yml` — SQLite and uploaded files live there. |

Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ services:
# Contributors: build locally instead of pulling the published image.
# build: .
ports:
- "8000:8000"
- "8080:8080"
volumes:
- ./data:/app/data # maps + uploads survive restarts
environment:
Expand Down
16 changes: 8 additions & 8 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ setup, and explains what every knob does to the map.
services:
mapcontrol:
image: ghcr.io/esipfed/mc2:latest
ports: ["8000:8000"]
ports: ["8080:8080"]
```

```bash
docker compose up -d
curl http://localhost:8000/docs # up?
curl http://localhost:8080/docs # up?
```

You get: the live map UI, the REST API, and `/mcp` — all on port 8000.
You get: the live map UI, the REST API, and `/mcp` — all on port 8080.
You don't get: persistence (maps vanish when the container is removed),
working share links off this machine, or the premium basemaps.

Expand All @@ -30,7 +30,7 @@ working share links off this machine, or the premium basemaps.
services:
mapcontrol:
image: ghcr.io/esipfed/mc2:latest
ports: ["8000:8000"]
ports: ["8080:8080"]
volumes:
- ./data:/app/data # maps survive restarts
environment:
Expand All @@ -54,7 +54,7 @@ link and screenshot URL. Leave it unset and links point at `localhost`
services:
mapcontrol:
image: ghcr.io/esipfed/mc2:sha-2920da5 # pin a digest for deployments
ports: ["8000:8000"]
ports: ["8080:8080"]
volumes:
- ./data:/app/data
- ./config.toml:/app/server/config.toml:ro # your own map defaults
Expand All @@ -77,7 +77,7 @@ and `vX.Y.Z` tags appear on releases.
| `MAPTILER_API_KEY` | Adds the MapTiler basemaps (vector streets, hybrid, topo, dataviz, satellite-dark) to the basemap picker and to `set_basemap`. Without it you still get the keyless three: OSM, Esri Satellite, Carto Dark. |
| `MAPCONTROL_DB_PATH` | Where the SQLite database lives — maps, assets, the full event log that replays when someone opens a map URL. |
| `MAPCONTROL_FILE_DIR` | Where uploaded GeoTIFFs and rendered raster tiles are stored. |
| `MAPCONTROL_PORT` / `MAPCONTROL_HOST` | Bind address inside the container (default `0.0.0.0:8000`). Usually you change the compose port mapping instead. |
| `MAPCONTROL_PORT` / `MAPCONTROL_HOST` | Bind address inside the container (default `0.0.0.0:8080`). Usually you change the compose port mapping instead. |
| `MAPCONTROL_ROOT_PATH` | Serve under a path prefix (e.g. `/maps`) behind a reverse proxy. Rewrites every route and generated link accordingly. |
| `MAPCONTROL_CONFIG_PATH` | Point at an alternative `config.toml`. |
| `MAPCONTROL_AUTH_MODE` | Turns on authorization for `/mcp` (`standalone` runs the built-in portal). Off by default — anyone who can reach the server can drive maps. |
Expand All @@ -102,13 +102,13 @@ Grab the stock file as a starting point:

## Behind a reverse proxy

Point your proxy at port 8000 and make sure **WebSocket upgrades pass
Point your proxy at port 8080 and make sure **WebSocket upgrades pass
through** — the live map updates ride `/ws/...`. Set
`MAPCONTROL_PUBLIC_URL` to the proxy's public origin.

```caddyfile
maps.example.org {
reverse_proxy mapcontrol:8000
reverse_proxy mapcontrol:8080
}
```

Expand Down
2 changes: 1 addition & 1 deletion docs/llm-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ SETUP
pip install "git+https://github.com/ESIPFed/mc2.git#subdirectory=sdk"

from mapcontrol import MapControl
mc = MapControl("http://localhost:8000") # or any MapControl server URL
mc = MapControl("http://localhost:8080") # or any MapControl server URL
session = mc.create_map() # theme="auto"|"dark"|"light"
print(session.url) # ALWAYS print this — it is the deliverable
# reattach to an existing map: session = mc.connect_map(map_id)
Expand Down
4 changes: 2 additions & 2 deletions docs/mcp-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,14 +247,14 @@ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
"/absolute/path/to/mapcontrol-mcp/build/index.js"
],
"env": {
"MAPCONTROL_SERVER_URL": "http://<EC2_PUBLIC_IP>:8000"
"MAPCONTROL_SERVER_URL": "http://<EC2_PUBLIC_IP>:8080"
}
}
}
}
```

Replace `<EC2_PUBLIC_IP>` with the actual public IP of your EC2 instance (e.g., `http://100.53.219.245:8000`).
Replace `<EC2_PUBLIC_IP>` with the actual public IP of your EC2 instance (e.g., `http://100.53.219.245:8080`).

### Cline / VS Code

Expand Down
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:8080/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:8080").create_map()
print(session.map_id) # feed this to MAP_ID below
```

The map URL every skill opens is:

```
http://localhost:8080/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:8080, 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();
Loading
Loading