diff --git a/.gitignore b/.gitignore index 1b47f2e..ad28357 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ build/ *.egg .eggs/ *.so +*.log # Virtual environments .venv/ diff --git a/Dockerfile b/Dockerfile index 3c0f820..da237bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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", "*"] diff --git a/README.md b/README.md index ceb04b9..ece4527 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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.) @@ -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 @@ -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. @@ -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" } } } @@ -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: @@ -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. | diff --git a/docker-compose.yml b/docker-compose.yml index 65c61c4..e47d068 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/deployment.md b/docs/deployment.md index 00d4464..14ddbad 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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. @@ -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: @@ -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 @@ -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. | @@ -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 } ``` diff --git a/docs/llm-context.md b/docs/llm-context.md index 84815b0..338060f 100644 --- a/docs/llm-context.md +++ b/docs/llm-context.md @@ -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) diff --git a/docs/mcp-integration.md b/docs/mcp-integration.md index 5797a23..273fbb5 100644 --- a/docs/mcp-integration.md +++ b/docs/mcp-integration.md @@ -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://:8000" + "MAPCONTROL_SERVER_URL": "http://:8080" } } } } ``` -Replace `` with the actual public IP of your EC2 instance (e.g., `http://100.53.219.245:8000`). +Replace `` with the actual public IP of your EC2 instance (e.g., `http://100.53.219.245:8080`). ### Cline / VS Code diff --git a/docs/puppeteer-skills/README.md b/docs/puppeteer-skills/README.md new file mode 100644 index 0000000..600279b --- /dev/null +++ b/docs/puppeteer-skills/README.md @@ -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/?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`. diff --git a/docs/puppeteer-skills/flyto-tour/SKILL.md b/docs/puppeteer-skills/flyto-tour/SKILL.md new file mode 100644 index 0000000..1c84b22 --- /dev/null +++ b/docs/puppeteer-skills/flyto-tour/SKILL.md @@ -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. diff --git a/docs/puppeteer-skills/flyto-tour/animate.mjs b/docs/puppeteer-skills/flyto-tour/animate.mjs new file mode 100644 index 0000000..d040b02 --- /dev/null +++ b/docs/puppeteer-skills/flyto-tour/animate.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// Reference skill: ballistic flyTo tour across waypoints. +// +// 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= (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(); diff --git a/docs/puppeteer-skills/keyframe-screenshots/SKILL.md b/docs/puppeteer-skills/keyframe-screenshots/SKILL.md new file mode 100644 index 0000000..f143aa9 --- /dev/null +++ b/docs/puppeteer-skills/keyframe-screenshots/SKILL.md @@ -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. diff --git a/docs/puppeteer-skills/keyframe-screenshots/animate.mjs b/docs/puppeteer-skills/keyframe-screenshots/animate.mjs new file mode 100644 index 0000000..031e6b4 --- /dev/null +++ b/docs/puppeteer-skills/keyframe-screenshots/animate.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// Reference skill: capture PNG stills at scripted keyframes. +// +// 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= (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(); diff --git a/docs/puppeteer-skills/lib/esip-map.mjs b/docs/puppeteer-skills/lib/esip-map.mjs new file mode 100644 index 0000000..05a5b0f --- /dev/null +++ b/docs/puppeteer-skills/lib/esip-map.mjs @@ -0,0 +1,80 @@ +// Shared helper for the Puppeteer animation skills. +// +// Launches a browser, opens a MapControl map page, and resolves once the map +// is genuinely ready to animate: the page's `window.__esipInternals.map` +// (the raw MapLibre GL JS instance) exists AND its style has loaded. +// +// This is reference/example code — adapt freely. + +import puppeteer from "puppeteer"; + +const DEFAULT_SERVER = process.env.MAPCONTROL_SERVER || "http://localhost:8080"; + +/** + * Open a map and wait until it is ready to animate. + * + * @param {object} opts + * @param {string} opts.mapId - the map_id to open (required) + * @param {string} [opts.server] - server base URL + * @param {boolean} [opts.uiNone] - serve the naked canvas (default true) + * @param {boolean} [opts.headless] - run headless (default true) + * @param {[number, number]} [opts.viewport] - [width, height], default 1280x720 + * @returns {Promise<{browser, page, close}>} + */ +export async function openMap({ + mapId, + server = DEFAULT_SERVER, + uiNone = true, + headless = true, + viewport = [1280, 720], +} = {}) { + if (!mapId) throw new Error("openMap: mapId is required"); + + const browser = await puppeteer.launch({ + headless, + args: ["--no-sandbox", "--disable-setuid-sandbox"], + }); + const page = await browser.newPage(); + await page.setViewport({ width: viewport[0], height: viewport[1] }); + + const url = `${server}/map/${mapId}${uiNone ? "?ui=none" : ""}`; + await page.goto(url, { waitUntil: "networkidle2" }); + + // Wait for the map object to be published and its style to finish loading. + await page.waitForFunction( + () => { + const m = window.__esipInternals && window.__esipInternals.map; + return !!m && m.isStyleLoaded(); + }, + { timeout: 30000 }, + ); + + return { + browser, + page, + close: () => browser.close(), + }; +} + +/** + * Run a MapLibre camera call and resolve when the camera comes to rest. + * `method` is any camera method name ('flyTo' | 'easeTo' | 'jumpTo' | ...). + * + * Resolves on the map's 'moveend' event so callers can `await` a move instead + * of guessing a sleep duration. + */ +export async function cameraMove(page, method, options) { + await page.evaluate( + (method, options) => + new Promise((resolve) => { + const map = window.__esipInternals.map; + map.once("moveend", () => resolve()); + map[method](options); + }), + method, + options, + ); +} + +/** Small await-able sleep for pacing between moves. */ +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); diff --git a/docs/puppeteer-skills/record-frames/SKILL.md b/docs/puppeteer-skills/record-frames/SKILL.md new file mode 100644 index 0000000..3c44e64 --- /dev/null +++ b/docs/puppeteer-skills/record-frames/SKILL.md @@ -0,0 +1,63 @@ +--- +name: record-frames +description: Capture a numbered sequence of PNG frames while a MapControl map animates, so the frames can be assembled into a GIF or MP4 with ffmpeg. Use to produce a shareable animated clip (e.g. a header animation) from a Puppeteer-driven camera move. +--- + +# Skill: Record a frame sequence + +Capture frames on a fixed cadence while the camera animates, then hand the sequence to +`ffmpeg` to make a GIF or MP4. This is the scenario for producing an actual animated clip +(a docs header, a social preview) rather than stills. + +## When to use + +- You need a looping GIF/MP4 of a camera move, not a live page. +- You want deterministic frames (grab N frames, one every M ms) you can re-encode. + +## How it works + +Rather than screen-record, this steps the animation in small time slices and calls +`page.screenshot()` for each — giving evenly spaced, artifact-free frames. It pairs well +with `terrain-orbit` (record an orbit) or `flyto-tour` (record a flythrough). + +## Recipe + +See [`animate.mjs`](animate.mjs). Sketch: + +```js +import { openMap, sleep } from "../lib/esip-map.mjs"; + +const { page, close } = await openMap({ mapId: process.env.MAP_ID }); + +// Kick off a non-blocking orbit inside the page, then sample frames from Node. +await page.evaluate(() => { + const map = window.__esipInternals.map; + map.setProjection({ type: "globe" }); + map.jumpTo({ center: [7.6586, 45.9763], zoom: 12.5, pitch: 70 }); +}); + +const FRAMES = 72; // 72 frames * 30ms bearing step ≈ one full turn +for (let i = 0; i < FRAMES; i++) { + await page.evaluate((b) => window.__esipInternals.map.setBearing(b), (i / FRAMES) * 360); + await sleep(60); // let the frame paint + await page.screenshot({ path: `frame_${String(i).padStart(4, "0")}.png` }); +} + +await close(); +``` + +Then encode: + +```bash +# GIF +ffmpeg -framerate 24 -i frame_%04d.png -vf "scale=800:-1:flags=lanczos" orbit.gif +# MP4 +ffmpeg -framerate 24 -i frame_%04d.png -c:v libx264 -pix_fmt yuv420p orbit.mp4 +``` + +## Knobs + +- `FRAMES` × bearing step — total rotation and smoothness. +- `sleep()` per frame — paint budget; raise it if frames look half-drawn. +- `-framerate` on encode — playback speed, independent of capture cadence. +- Swap the in-page move for a `flyTo` path to record a flythrough instead of an orbit. diff --git a/docs/puppeteer-skills/record-frames/animate.mjs b/docs/puppeteer-skills/record-frames/animate.mjs new file mode 100644 index 0000000..bc01e0b --- /dev/null +++ b/docs/puppeteer-skills/record-frames/animate.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Reference skill: capture a numbered frame sequence during an orbit. +// +// MAP_ID= node animate.mjs +// +// Writes frame_0000.png ... into ./frames/. Encode afterwards, e.g.: +// ffmpeg -framerate 24 -i frames/frame_%04d.png -vf scale=800:-1 orbit.gif + +import { mkdir } from "node:fs/promises"; +import { openMap, sleep } from "../lib/esip-map.mjs"; + +const MAP_ID = process.env.MAP_ID; +if (!MAP_ID) { + console.error("Set MAP_ID= (see docs/puppeteer-skills/README.md)"); + process.exit(1); +} + +const MATTERHORN = [7.6586, 45.9763]; +const FRAMES = 72; // one frame per 5° → a full 360° orbit + +await mkdir("frames", { recursive: true }); + +const { page, close } = await openMap({ mapId: MAP_ID, headless: true, viewport: [1280, 720] }); + +// Set up the 3D scene (globe + terrain + sky), framed on the peak. +await page.evaluate((center) => { + const map = window.__esipInternals.map; + map.setProjection({ type: "globe" }); + if (!map.getSource("terrain-dem")) { + map.addSource("terrain-dem", { + type: "raster-dem", + url: "https://demotiles.maplibre.org/terrain-tiles/tiles.json", + tileSize: 256, + }); + } + map.setTerrain({ source: "terrain-dem", exaggeration: 1.5 }); + map.setSky({ "sky-color": "#199EF3", "horizon-color": "#ffffff", "fog-color": "#ffffff" }); + map.jumpTo({ center, zoom: 12.5, pitch: 70, bearing: 0 }); +}, MATTERHORN); + +await sleep(2000); // let terrain tiles settle before the first frame + +console.log(`capturing ${FRAMES} frames`); +for (let i = 0; i < FRAMES; i++) { + const bearing = (i / FRAMES) * 360; + await page.evaluate((b) => window.__esipInternals.map.setBearing(b), bearing); + await sleep(60); // paint budget + const name = `frames/frame_${String(i).padStart(4, "0")}.png`; + await page.screenshot({ path: name }); + if (i % 12 === 0) console.log(` ${i}/${FRAMES}`); +} + +console.log("done — encode frames/ with ffmpeg (see SKILL.md)"); +await close(); diff --git a/docs/puppeteer-skills/terrain-orbit/SKILL.md b/docs/puppeteer-skills/terrain-orbit/SKILL.md new file mode 100644 index 0000000..9f30b6e --- /dev/null +++ b/docs/puppeteer-skills/terrain-orbit/SKILL.md @@ -0,0 +1,69 @@ +--- +name: terrain-orbit +description: Fly to a mountain or landmark, enable 3D globe terrain and sky, then slowly orbit the camera around it using Puppeteer and MapLibre. Use for a dramatic 3D hero shot (e.g. the Matterhorn) for a docs header or demo. +--- + +# Skill: 3D terrain orbit + +Frame a peak in 3D — globe projection, terrain exaggeration, atmospheric sky — then rotate +the camera bearing around it for a slow orbit. This is the "hero shot" scenario. + +## When to use + +- A dramatic 3D flythrough of dramatic relief (the Matterhorn is the canonical subject). +- Any landmark that reads best tilted and rotating rather than flat. + +## How it works + +The map page already supports 3D via the server shell's terrain path (globe projection + +`terrain-dem` source + `setSky`). This skill turns that on through the raw MapLibre map, +tilts the camera (`pitch`), then steps the `bearing` in a loop with `easeTo` to orbit. + +> Terrain here is driven directly on the MapLibre map for a self-contained example. If your +> deployment prefers to flip terrain through the server (so the mode is part of session +> state), send the `set_terrain` event instead and just do the pitch/bearing orbit here. + +## Recipe + +See [`animate.mjs`](animate.mjs). The core: + +```js +import { openMap, cameraMove, sleep } from "../lib/esip-map.mjs"; + +const MATTERHORN = [7.6586, 45.9763]; // lon, lat + +const { page, close } = await openMap({ mapId: process.env.MAP_ID }); + +// Enable globe + terrain + sky, then frame the peak tilted. +await page.evaluate((center) => { + const map = window.__esipInternals.map; + map.setProjection({ type: "globe" }); + if (!map.getSource("terrain-dem")) { + map.addSource("terrain-dem", { + type: "raster-dem", + url: "https://demotiles.maplibre.org/terrain-tiles/tiles.json", + tileSize: 256, + }); + } + map.setTerrain({ source: "terrain-dem", exaggeration: 1.5 }); + map.setSky({ "sky-color": "#199EF3", "horizon-color": "#ffffff", "fog-color": "#ffffff" }); + map.jumpTo({ center, zoom: 12.5, pitch: 70, bearing: 0 }); +}, MATTERHORN); + +await sleep(1500); + +// Orbit: step the bearing a full turn. +for (let bearing = 0; bearing <= 360; bearing += 30) { + await cameraMove(page, "easeTo", { bearing, duration: 1000, essential: true }); +} + +await close(); +``` + +## Knobs + +- `exaggeration` — terrain height multiplier (1.5 is punchy; 1.0 is true-scale). +- `pitch` — camera tilt (0 = top-down, ~70 = dramatic). +- Orbit step / `duration` — smaller steps + shorter durations = smoother spin. +- `zoom` — how tightly you frame the peak. +- Swap `MATTERHORN` for any `[lon, lat]`. diff --git a/docs/puppeteer-skills/terrain-orbit/animate.mjs b/docs/puppeteer-skills/terrain-orbit/animate.mjs new file mode 100644 index 0000000..f5b3d10 --- /dev/null +++ b/docs/puppeteer-skills/terrain-orbit/animate.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// Reference skill: 3D globe terrain orbit around a peak (Matterhorn). +// +// MAP_ID= node animate.mjs +// +// Requires a running 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= (see docs/puppeteer-skills/README.md)"); + process.exit(1); +} + +const MATTERHORN = [7.6586, 45.9763]; // lon, lat + +const { page, close } = await openMap({ mapId: MAP_ID, headless: true }); + +// Enable globe projection + terrain + atmospheric sky, framed on the peak. +console.log("enabling 3D terrain + sky"); +await page.evaluate((center) => { + const map = window.__esipInternals.map; + map.setProjection({ type: "globe" }); + if (!map.getSource("terrain-dem")) { + map.addSource("terrain-dem", { + type: "raster-dem", + url: "https://demotiles.maplibre.org/terrain-tiles/tiles.json", + tileSize: 256, + }); + } + map.setTerrain({ source: "terrain-dem", exaggeration: 1.5 }); + // MapLibre uses setSky(), NOT a `type: 'sky'` layer (that is Mapbox's API). + map.setSky({ + "sky-color": "#199EF3", + "sky-horizon-blend": 0.5, + "horizon-color": "#ffffff", + "fog-color": "#ffffff", + "fog-ground-blend": 0.5, + }); + map.jumpTo({ center, zoom: 12.5, pitch: 70, bearing: 0 }); +}, MATTERHORN); + +// Let terrain tiles settle before spinning. +await sleep(2000); + +console.log("orbiting"); +for (let bearing = 30; bearing <= 360; bearing += 30) { + await cameraMove(page, "easeTo", { bearing, duration: 1000, essential: true }); +} + +console.log("orbit complete"); +await close(); diff --git a/examples/demo.py b/examples/demo.py index 5d82dfd..f0b6d15 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -6,7 +6,7 @@ the features one by one with pauses so you can watch it happen. Prerequisites: - - Server running: docker compose up (or: cd server && uvicorn mapcontrol_server.main:app --reload --port 8000) + - Server running: docker compose up (or: cd server && uvicorn mapcontrol_server.main:app --reload --port 8080) - SDK installed: cd sdk && pip install -e . Usage: @@ -24,7 +24,7 @@ from mapcontrol import MapControl, Style -SERVER_URL = "http://localhost:8000" +SERVER_URL = "http://localhost:8080" PAUSE = 3 # seconds between actions diff --git a/examples/demo_ballistic_zoom.py b/examples/demo_ballistic_zoom.py index 567278b..db5042c 100644 --- a/examples/demo_ballistic_zoom.py +++ b/examples/demo_ballistic_zoom.py @@ -6,7 +6,7 @@ smooth zoom algorithm. Flies between 10 cities around the world. Prerequisites: - - Server running: docker compose up (or: cd server && uvicorn mapcontrol_server.main:app --reload --port 8000) + - Server running: docker compose up (or: cd server && uvicorn mapcontrol_server.main:app --reload --port 8080) - SDK installed: cd sdk && pip install -e . Usage: @@ -23,7 +23,7 @@ from mapcontrol import MapControl -SERVER_URL = "http://localhost:8000" +SERVER_URL = "http://localhost:8080" # Time to wait for each animation to complete before sending the next WAIT = 6 diff --git a/examples/demo_drawing.py b/examples/demo_drawing.py index 356a291..8894e88 100644 --- a/examples/demo_drawing.py +++ b/examples/demo_drawing.py @@ -1,7 +1,7 @@ """Drawing tools demo — shows how to use the SDK to enable drawing and retrieve drawn features. Run the server first: - docker compose up (or: cd server && uvicorn mapcontrol_server.main:app --reload --port 8000) + docker compose up (or: cd server && uvicorn mapcontrol_server.main:app --reload --port 8080) Then run this demo: python demo_drawing.py @@ -21,7 +21,7 @@ from mapcontrol import MapControl -SERVER = "http://localhost:8000" +SERVER = "http://localhost:8080" def main(): diff --git a/examples/demo_glyphs.py b/examples/demo_glyphs.py index 7d4bd09..ffe02b6 100644 --- a/examples/demo_glyphs.py +++ b/examples/demo_glyphs.py @@ -12,10 +12,10 @@ Usage: # In one terminal (from server/): - uv run uvicorn mapcontrol_server.main:app --port 8000 + uv run uvicorn mapcontrol_server.main:app --port 8080 # In another: - python3 demo_glyphs.py [--server http://localhost:8000] [--fast] + python3 demo_glyphs.py [--server http://localhost:8080] [--fast] The script opens the map in your browser and walks through the scenes with pauses so you can watch. Hover over the gradient rings in Scene 4 to see the @@ -32,7 +32,7 @@ import requests parser = argparse.ArgumentParser() -parser.add_argument("--server", default="http://localhost:8000") +parser.add_argument("--server", default="http://localhost:8080") parser.add_argument("--fast", action="store_true", help="minimal pauses") args = parser.parse_args() @@ -54,7 +54,7 @@ def banner(text): r.raise_for_status() except Exception as e: print(f"❌ MapControl server not reachable at {SERVER} ({e})") - print(" Start it with: cd server && uv run uvicorn mapcontrol_server.main:app --port 8000") + print(" Start it with: cd server && uv run uvicorn mapcontrol_server.main:app --port 8080") sys.exit(1) print(f"✅ Server up at {SERVER}") diff --git a/examples/demo_multi_session.py b/examples/demo_multi_session.py index 50b3ede..84ad491 100644 --- a/examples/demo_multi_session.py +++ b/examples/demo_multi_session.py @@ -24,7 +24,7 @@ from mapcontrol import MapControl, Style -SERVER_URL = "http://localhost:8000" +SERVER_URL = "http://localhost:8080" PAUSE = 3 diff --git a/examples/demo_terrain.py b/examples/demo_terrain.py index da539a2..0b6e010 100644 --- a/examples/demo_terrain.py +++ b/examples/demo_terrain.py @@ -5,7 +5,7 @@ then toggles to 3D with terrain and sky, waits, then back to 2D. Usage: - # Start server first: docker compose up (or: cd server && uvicorn mapcontrol_server.main:app --reload --port 8000) + # Start server first: docker compose up (or: cd server && uvicorn mapcontrol_server.main:app --reload --port 8080) # Then run: python demo_terrain.py """ @@ -15,7 +15,7 @@ from mapcontrol import MapControl -SERVER = "http://localhost:8000" +SERVER = "http://localhost:8080" def main(): diff --git a/examples/demo_terrain_showcase.py b/examples/demo_terrain_showcase.py index 7b50b7f..eb6c7b2 100644 --- a/examples/demo_terrain_showcase.py +++ b/examples/demo_terrain_showcase.py @@ -7,7 +7,7 @@ Usage: # Start server first: - docker compose up (or: cd server && uvicorn mapcontrol_server.main:app --reload --port 8000) + docker compose up (or: cd server && uvicorn mapcontrol_server.main:app --reload --port 8080) # Then run: python demo_terrain_showcase.py @@ -21,7 +21,7 @@ from mapcontrol import MapControl -SERVER = "http://localhost:8000" +SERVER = "http://localhost:8080" # sample.tif lives in the repo at examples/data/sample.tif SAMPLE_TIFF = os.path.join(os.path.dirname(__file__), "data", "sample.tif") diff --git a/examples/test_screenshot.py b/examples/test_screenshot.py index 1491302..41820e7 100644 --- a/examples/test_screenshot.py +++ b/examples/test_screenshot.py @@ -3,7 +3,7 @@ import httpx import json -BASE = "http://localhost:8000" +BASE = "http://localhost:8080" print("=== Screenshot Test (Playwright, no browser) ===\n") diff --git a/sdk/README.md b/sdk/README.md index ce55f4c..1aa2867 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -18,7 +18,7 @@ pip install mapcontrol ```python from mapcontrol import MapControl -mc = MapControl(server_url="http://localhost:8000") +mc = MapControl(server_url="http://localhost:8080") session = mc.create_map(name="demo") print(session.map_url) # open this in a browser — updates live @@ -43,7 +43,7 @@ shot = session.take_screenshot() # PNG of the current view The server ships as a container: ```bash -docker run -p 8000:8000 ghcr.io/ama-labs/mapcontrol-server:latest +docker run -p 8080:8080 ghcr.io/ama-labs/mapcontrol-server:latest ``` See the server repository for configuration (basemaps, providers, auth, MCP). diff --git a/sdk/mapcontrol/client.py b/sdk/mapcontrol/client.py index 2523e45..36a0a1d 100644 --- a/sdk/mapcontrol/client.py +++ b/sdk/mapcontrol/client.py @@ -12,13 +12,13 @@ class MapControl: """Client for the Map Control proxy server. Usage: - mc = MapControl(server_url="http://localhost:8000") + mc = MapControl(server_url="http://localhost:8080") session = mc.create_map() print(session.url) session.add_polygon(geojson='{"type":"Feature",...}') """ - def __init__(self, server_url: str = "http://localhost:8000"): + def __init__(self, server_url: str = "http://localhost:8080"): self.server_url = server_url.rstrip("/") self._client = httpx.Client(base_url=self.server_url, timeout=30.0) diff --git a/server/mapcontrol_server/auth.py b/server/mapcontrol_server/auth.py index 5e4e8d9..ba02087 100644 --- a/server/mapcontrol_server/auth.py +++ b/server/mapcontrol_server/auth.py @@ -27,7 +27,7 @@ > (``https:///.well-known/oauth-protected-resource/service/map``). The > single-origin edge currently routes origin-root ``/`` → Svelte, so BEFORE > enabling auth in cloud the edge needs a location for -> ``/.well-known/oauth-protected-resource`` → ESIP (``172.17.0.1:8000``). +> ``/.well-known/oauth-protected-resource`` → ESIP (``172.17.0.1:8080``). """ from __future__ import annotations @@ -69,7 +69,7 @@ def _resource_url() -> str: return ( os.environ.get("MAPCONTROL_MCP_RESOURCE") or os.environ.get("MAPCONTROL_PUBLIC_URL") - or "http://localhost:8000" + or "http://localhost:8080" ).rstrip("/") diff --git a/server/mapcontrol_server/auth_server.py b/server/mapcontrol_server/auth_server.py index bcdf348..ef7f9bd 100644 --- a/server/mapcontrol_server/auth_server.py +++ b/server/mapcontrol_server/auth_server.py @@ -86,7 +86,7 @@ def _public_base() -> str: return ( os.environ.get("MAPCONTROL_MCP_RESOURCE") or os.environ.get("MAPCONTROL_PUBLIC_URL") - or "http://localhost:8000" + or "http://localhost:8080" ).rstrip("/") diff --git a/server/mapcontrol_server/config.py b/server/mapcontrol_server/config.py index 8afe31e..892ba73 100644 --- a/server/mapcontrol_server/config.py +++ b/server/mapcontrol_server/config.py @@ -18,7 +18,7 @@ @dataclass class ServerConfig: host: str = "0.0.0.0" - port: int = 8000 + port: int = 8080 ## changed this to 8080 because port 8000 will clash with some mac airport utility # ASGI mount prefix for deployment behind a single-origin reverse proxy # that exposes this server under a sub-path (e.g. "/service/map"). Empty = # served at root. Dual-deployability invariant (EOGPT-Roadmap ADR-0001): the diff --git a/server/mapcontrol_server/main.py b/server/mapcontrol_server/main.py index fa4a6ef..e73b646 100644 --- a/server/mapcontrol_server/main.py +++ b/server/mapcontrol_server/main.py @@ -944,6 +944,28 @@ class BasemapPickerControl {{ return (style && style[prop]) || fallback; }} + // Atmospheric sky for 3D/globe terrain modes. MapLibre GL JS has no + // `sky` *layer* type (that is Mapbox's API) — it uses map.setSky(). + // Adding a `type: 'sky'` layer fails style validation and, because the + // error is fired on the map's error event rather than thrown, a + // try/catch around addLayer cannot suppress it. setSky is the correct, + // validation-clean API (MapLibre 5+). + function enableSky() {{ + try {{ + map.setSky({{ + 'sky-color': '#199EF3', + 'sky-horizon-blend': 0.5, + 'horizon-color': '#ffffff', + 'horizon-fog-blend': 0.5, + 'fog-color': '#ffffff', + 'fog-ground-blend': 0.5, + }}); + }} catch(e) {{ /* setSky unsupported in this MapLibre version */ }} + }} + function disableSky() {{ + try {{ map.setSky(undefined); }} catch(e) {{}} + }} + // Compute LngLatBounds from a GeoJSON object function geojsonBounds(geojson) {{ const bounds = new maplibregl.LngLatBounds(); @@ -1833,11 +1855,7 @@ class BasemapPickerControl {{ try {{ map.setProjection({{ type: 'globe' }}); }} catch(e) {{}} ensureTerrainSource(); try {{ map.setTerrain({{ source: 'terrain-dem', exaggeration: 1.5 }}); }} catch(e) {{}} - try {{ - if (!map.getLayer('sky-layer')) {{ - map.addLayer({{ id: 'sky-layer', type: 'sky', paint: {{ 'sky-type': 'atmosphere', 'sky-atmosphere-sun': [0.0, 90.0], 'sky-atmosphere-sun-intensity': 15 }} }}); - }} - }} catch(e) {{}} + enableSky(); }} try {{ map.jumpTo({{ @@ -1911,12 +1929,12 @@ class BasemapPickerControl {{ try {{ map.setProjection({{ type: 'globe' }}); }} catch(e) {{}} ensureTerrainSource(); map.setTerrain({{ source: 'terrain-dem', exaggeration: 1.5 }}); - try {{ if (!map.getLayer('sky-layer')) {{ map.addLayer({{ id: 'sky-layer', type: 'sky', paint: {{ 'sky-type': 'atmosphere', 'sky-atmosphere-sun': [0.0, 90.0], 'sky-atmosphere-sun-intensity': 15 }} }}); }} }} catch(e) {{ /* sky layers not supported in this MapLibre version */ }} + enableSky(); }} else if (snapshot.terrain === '2d') {{ currentTerrain = '2d'; try {{ map.setProjection({{ type: 'mercator' }}); }} catch(e) {{}} map.setTerrain(null); - try {{ if (map.getLayer('sky-layer')) map.removeLayer('sky-layer'); }} catch(e) {{}} + disableSky(); }} // Terrain restore may have changed projection — re-arbitrate // deck-ribbon vs flat-line for any restored arcs. @@ -2090,7 +2108,7 @@ class BasemapPickerControl {{ try {{ map.setProjection({{ type: 'globe' }}); }} catch(e) {{}} ensureTerrainSource(); map.setTerrain({{ source: 'terrain-dem', exaggeration: 1.5 }}); - try {{ if (!map.getLayer('sky-layer')) {{ map.addLayer({{ id: 'sky-layer', type: 'sky', paint: {{ 'sky-type': 'atmosphere', 'sky-atmosphere-sun': [0.0, 90.0], 'sky-atmosphere-sun-intensity': 15 }} }}); }} }} catch(e) {{ /* sky layers not supported */ }} + enableSky(); // Force nadir (straight down) — pitch=0, bearing=0 map.jumpTo({{ pitch: 0, bearing: 0 }}); console.log('Applied default terrain mode: 3D Globe (nadir, pitch=0)'); @@ -2331,8 +2349,8 @@ class BasemapPickerControl {{ try {{ map.setProjection({{ type: 'globe' }}); }} catch(e) {{ console.warn('Globe projection not available:', e.message); }} ensureTerrainSource(); map.setTerrain({{ source: 'terrain-dem', exaggeration: 1.5 }}); - // Add sky layer for atmospheric effect - try {{ if (!map.getLayer('sky-layer')) {{ map.addLayer({{ id: 'sky-layer', type: 'sky', paint: {{ 'sky-type': 'atmosphere', 'sky-atmosphere-sun': [0.0, 90.0], 'sky-atmosphere-sun-intensity': 15 }} }}); }} }} catch(e) {{ /* sky layers not supported in this MapLibre version */ }} + // Atmospheric sky for the 3D view + enableSky(); if (animate) {{ map.easeTo({{ pitch: 60, duration: 1500 }}); }} else {{ @@ -2347,12 +2365,12 @@ class BasemapPickerControl {{ // Remove terrain after animation completes setTimeout(function() {{ map.setTerrain(null); - try {{ if (map.getLayer('sky-layer')) map.removeLayer('sky-layer'); }} catch(e) {{}} + disableSky(); }}, 1600); }} else {{ map.jumpTo({{ pitch: 0, bearing: 0 }}); map.setTerrain(null); - try {{ if (map.getLayer('sky-layer')) map.removeLayer('sky-layer'); }} catch(e) {{}} + disableSky(); }} console.log('Terrain mode: 2D Flat (mercator)'); }} diff --git a/server/mapcontrol_server/mcp_tools.py b/server/mapcontrol_server/mcp_tools.py index 39b054a..1ec6c75 100644 --- a/server/mapcontrol_server/mcp_tools.py +++ b/server/mapcontrol_server/mcp_tools.py @@ -169,9 +169,9 @@ def _public_base_url() -> str: """Base URL used to build returned map/screenshot URLs. Defaults to localhost for dev; set MAPCONTROL_PUBLIC_URL on the deployment - (e.g. http://18.116.107.200:8000) so create_map returns a reachable link. + (e.g. http://18.116.107.200:8080) so create_map returns a reachable link. """ - return os.environ.get("MAPCONTROL_PUBLIC_URL", "http://localhost:8000").rstrip("/") + return os.environ.get("MAPCONTROL_PUBLIC_URL", "http://localhost:8080").rstrip("/") # ─── map:// URI helpers (single source for the resource scheme) ──────────────