Add flight-map-canvas extension (#2482)

* Add flight-map-canvas extension

A canvas port of the Flight Map VSCode extension: a first-person
satellite terrain map flown with flight simulator controls, for
session breaks while an agent works.

The simulator under game/ is copied verbatim from the source
extension's media/ folder. That page already reached its host through
one seam - an acquireVsCodeApi() object and a placeholder in its head -
so extension.mjs fills that seam for the canvas: a loopback server that
injects a policy, the render configuration, and a shim translating
Server-Sent Events into the messages the page already handles.

Two agent actions: fly_to sends the flight to a capital, a geocoded
city, or a raw lat/lng, and picks a random capital when called with no
input; report_job shows the current job step under the HUD.

Adds the vendored three.min.js to the codespell skip list, matching the
existing entry for arcade-canvas's phaser.min.js.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Apply suggestions from code review

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
John Haugabook
2026-07-29 23:32:55 -04:00
committed by GitHub
parent 43527d1336
commit c46b4a919a
22 changed files with 5394 additions and 3 deletions
+4 -3
View File
@@ -45,9 +45,9 @@
# Wee, Sherif - proper name (Wee, Sherif, contributor names should not be flagged as typos)
# queston - intentional misspelling example in skills/arize-dataset/SKILL.md demonstrating typo detection in field names
# extenions - intentional misspelled key name used in plugin validators to detect invalid manifests
# nin - MongoDB $nin operator in security instructions NoSQL injection detection regex
# Vertexes - FreeCAD shape sub-elements used as property of obj.Shape
@@ -67,6 +67,7 @@
ignore-words-list = numer,wit,aks,edn,ser,ois,gir,rouge,categor,aline,ative,afterall,deques,dateA,dateB,TE,FillIn,alle,vai,LOD,InOut,pixelX,aNULL,Wee,Sherif,queston,extenions,Vertexes,nin,FO,CAF,Parth,ans,gud,Vally,vally,ACI
# Skip certain files and directories
# *.tm7 - MTM DataContract exports; embedded base64 icon blobs trigger false positives
skip = .git,node_modules,package-lock.json,*.lock,website/build,website/.docusaurus,.all-contributorrc,./skills/geofeed-tuner/assets/*.json,./skills/geofeed-tuner/references/*.txt,./plugins/fastah-ip-geo-tools/skills/geofeed-tuner/assets/*.json,./plugins/fastah-ip-geo-tools/skills/geofeed-tuner/references/*.txt,./extensions/arcade-canvas/game/phaser.min.js,./extensions/pr-artifact-explorer/assets/asciinema-player.min.js,./extensions/pr-artifact-explorer/assets/asciinema-player-worker.min.js,./extensions/pr-artifact-explorer/assets/primer-*.css,*.tm7
skip = .git,node_modules,package-lock.json,*.lock,website/build,website/.docusaurus,.all-contributorrc,./skills/geofeed-tuner/assets/*.json,./skills/geofeed-tuner/references/*.txt,./plugins/fastah-ip-geo-tools/skills/geofeed-tuner/assets/*.json,./plugins/fastah-ip-geo-tools/skills/geofeed-tuner/references/*.txt,./extensions/arcade-canvas/game/phaser.min.js,./extensions/pr-artifact-explorer/assets/asciinema-player.min.js,./extensions/pr-artifact-explorer/assets/asciinema-player-worker.min.js,./extensions/pr-artifact-explorer/assets/primer-*.css,./extensions/flight-map-canvas/game/vendor/three.min.js,*.tm7
+6
View File
@@ -599,6 +599,12 @@
"repo": "figma/mcp-server-guide"
}
},
{
"name": "flight-map-canvas",
"source": "extensions/flight-map-canvas",
"description": "A GitHub Copilot canvas that generates a view where Google Maps can be explored using 3D controls, as if a flight simulator. Agents can send the flight anywhere and report what they are working on.",
"version": "1.0.0"
},
{
"name": "flowstudio-power-automate",
"source": "plugins/flowstudio-power-automate",
+20
View File
@@ -0,0 +1,20 @@
{
"name": "flight-map-canvas",
"description": "A GitHub Copilot canvas that generates a view where Google Maps can be explored using 3D controls, as if a flight simulator. Agents can send the flight anywhere and report what they are working on.",
"version": "1.0.0",
"author": {
"name": "John Haugabook",
"url": "https://github.com/jhauga"
},
"keywords": [
"copilot-canvas",
"flight-simulator",
"geography",
"google-maps",
"interactive-canvas",
"session-breaks",
"threejs"
],
"logo": "assets/preview.png",
"extensions": "."
}
+116
View File
@@ -0,0 +1,116 @@
# Flight Map Canvas
A GitHub Copilot canvas that generates a view where Google Maps can be explored using 3D controls, as if a flight simulator. No 3D plane model as the focus is the aerial landscape. Fly around in 1st person for several minutes, starting from a capital city while Copilot works.
It is a canvas port of the [Flight Map VSCode extension](https://github.com/isocialPractice/vscode-flight-map). Satellite imagery tiles stream in around the camera as it moves, unloaded gaps are filled with color sampled from the viewport, and drifting procedural clouds sit over the fill. The HUD reads heading, speed, altitude, pitch, and live coordinates, with the city, region, and country under the camera looked up as it flies.
While an agent works in the session, it can send the flight somewhere new and report what it is doing on a status strip under the HUD.
## Files
- `extension.mjs` — canvas declaration, loopback game server, the host shim it injects into the page, destination resolution, and agent actions.
- `game/` — the simulator itself, byte for byte the VSCode extension's `media/` folder. `index.html` is its `flightMap.html`; the rest is the Three.js scene, tile loader, flight controls, terrain fill, HUD, and configuration panel.
- `game/vendor/three.min.js` — vendored Three.js r128, so the only remote requests are the ones the simulation makes for imagery and place names.
- `renderMap.json` — the render configuration the canvas starts on.
- `assets/` — app icon and `preview.png` for the extensions gallery.
- `package.json` — declares the Copilot SDK dependency and ESM entry point.
- `copilot-extension.json` — Copilot extension name/version metadata.
## Prerequisites
- **Node.js 20.19 or newer**, because the Copilot SDK requires `node ^20.19.0 || >=22.12.0`.
- A WebGL-capable canvas surface; the scene is rendered through Three.js.
- Network access. Satellite tiles, the location label, and destination lookups are all fetched at runtime.
- The GitHub Copilot app canvas / UI-extensions experiment enabled.
## Install
Drop this folder at `~/.copilot/extensions/flight-map-canvas/` for user scope, or in a repository at `.github/extensions/flight-map-canvas/` for project scope. Then install dependencies from inside the copied folder:
```sh
# User scope
cd ~/.copilot/extensions/flight-map-canvas
# Or project scope, from the repository root
cd .github/extensions/flight-map-canvas
npm install
```
Reload extensions in the GitHub Copilot app, then open the `flight-map-canvas` canvas. Pick a destination from the menu, click the view to start flying, and use the controls below.
The canvas accepts optional open inputs, resolved the same way `fly_to` resolves them:
| Input | Type | Description |
| --- | --- | --- |
| `capital` | string | A world capital by city or country name. Matched against the capitals the canvas ships with. |
| `city` | string | City name for anywhere that is not a shipped capital. Geocoded on the way in. |
| `state` | string | State, region, province, or district that narrows the city. |
| `country` | string | Country that narrows the city. |
| `lat` / `lng` | number | Raw coordinates, which skip the geocoder entirely. Latitude runs -85.0511 to 85.0511, the limit of the Web Mercator tile imagery; longitude runs -180 to 180. Give both or neither. |
Opened with no input, the canvas starts on its own welcome screen and waits for a destination.
## Controls
| Input | Action |
| --- | --- |
| `W` / `S` or `Up` / `Down` | Throttle |
| Mouse (after clicking the view) | Pitch and yaw |
| `A` / `D` or `Left` / `Right` | Roll |
| `Q` / `E` | Rudder yaw |
| `R` / `F` | Climb / descend |
| `Space` | Auto-level |
| `Shift` | Boost |
| `P` | Pause / resume |
| `Esc` | Release the view |
Mouse look prefers pointer lock. If the canvas host refuses it, the controls fall back to drag look: hold the left mouse button and move to steer. The on-screen help says which mode is active.
The **Destination** menu lists the world capitals grouped by continent and opens with **Input Destination**, which takes a typed state/region and city plus a country from a menu, then lists what matched so one can be selected. **Config** opens live rendering sliders, and **Export JSON** downloads the current configuration as `renderMap.json`.
## Agent actions
- `fly_to { capital?, city?, state?, country?, lat?, lng? }` — send the flight to a destination. A capital is matched against the shipped list, a city is geocoded, and a lat/lng pair is flown to directly. Called with no input it picks a random world capital, which is the way to say "take me somewhere else".
- `report_job { status, tokens?, done? }` — report the current job step. The status shows on a strip under the HUD and the token count runs beside it. Call it when work starts, again on each new step, and once more with `done: true` when finished.
To have the agent narrate itself automatically, reference these actions from a `.github/copilot-instructions.md` so it calls `report_job` on each step of a chat request.
## How the port works
The simulator under `game/` is unchanged from the VSCode extension. That page was already written to run in two places, and it reaches whatever is hosting it through one seam: an `acquireVsCodeApi()` object for messaging, and a `<!--{{HOST_HEAD}}-->` placeholder in its `<head>` that the host fills with a content security policy and its render configuration.
`extension.mjs` fills that seam for the canvas:
- A loopback HTTP server serves the page and its assets, replacing the placeholder with a policy, the render configuration read from `renderMap.json`, and a shim that defines `acquireVsCodeApi()`.
- Agent activity is streamed to the page over Server-Sent Events at `/events`. The shim translates those events into the exact `flyTo` and `jobStatus` commands the page already registers handlers for.
- The page's export button has no save dialog to reach here, so the shim falls back to the browser download the standalone page uses.
- Destinations are resolved server-side before broadcasting, against the page's own `capitals.js` for shipped capitals and OpenStreetMap Nominatim for everything else.
This mirrors the [`backrooms-canvas`](../backrooms-canvas) and [`arcade-canvas`](../arcade-canvas) architecture: a static frontend served from a loopback server, with the agent driving it through canvas actions.
## Development
The port is a copy, not a fork. Everything under `game/` comes from the VSCode extension's `media/` folder, so edits belong there.
In that repository the two are held together by symlinks, which means there is only ever one copy to edit and the port cannot go stale. Submission needs real files, though, because a link pointing at a sibling repository resolves to nothing anywhere else, so the stage command resolves every link on the way out:
```sh
# In the vscode-flight-map repository
npm run canvas:link # create or repair the symlinks
npm run canvas:check # verify them; non-zero when stale
npm run canvas:stage -- <destination> # real-file copy for submission
```
`media/flightMap.html` is linked as `game/index.html`; nothing else is renamed and no file is rewritten. To run the simulator outside a canvas while working on it, open `media/flightMap.html` in a browser.
## Credits
- Source extension and canvas port: [vscode-flight-map](https://github.com/isocialPractice/vscode-flight-map) by John Haugabook.
- Satellite imagery: Google Maps tiles, with Esri World Imagery as the fallback provider. The imagery is served by, and remains the property of, those providers; this canvas only displays it, subject to their respective terms of service.
- Place names and destination lookups: [OpenStreetMap Nominatim](https://nominatim.openstreetmap.org/). Data © OpenStreetMap contributors, available under the [Open Database License](https://www.openstreetmap.org/copyright).
- 3D rendering: [Three.js](https://threejs.org/) r128.
## License
MIT
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

@@ -0,0 +1,4 @@
{
"name": "flight-map-canvas",
"version": 1
}
+632
View File
@@ -0,0 +1,632 @@
/**
* Copilot canvas entry point for Flight Map.
*
* The simulator itself is the same page the VS Code extension ships: a
* plain Three.js document under game/ that loads satellite tiles, flies a
* first-person camera over them, and reads its render configuration off
* `window.__flightMapConfig`. This file is only the host. It runs a
* loopback HTTP server that serves that page, and registers a canvas so
* an agent can steer the flight and report what it is working on.
*
* The page carries a `<!--{{HOST_HEAD}}-->` placeholder that each host
* fills with whatever only it can supply. The VS Code panel puts a
* content security policy, webview asset URIs, and the render config
* there. This server puts a policy, the render config, and a shim that
* stands in for `acquireVsCodeApi()`, so the page runs unchanged in both.
*
* Messages the shim relays, matching the commands the page already
* registers through media/hostBridge.js:
*
* host -> page : { command: 'flyTo', destination }
* { command: 'jobStatus', job }
* page -> host : { command: 'ready' }
* { command: 'exportConfig', json } // handled in the shim
*/
import { createReadStream } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { randomBytes } from "node:crypto";
import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const gameRoot = path.join(__dirname, "game");
const assetsRoot = path.join(__dirname, "assets");
const indexPath = path.join(gameRoot, "index.html");
/** Idle agent job; the status strip hides itself. */
const IDLE_JOB = { working: false, status: "", tokens: 0 };
/**
* Nominatim asks every client to identify itself. The canvas makes at most
* one lookup per fly_to, well inside the one-request-per-second policy.
*/
const USER_AGENT = "flight-map-canvas (https://github.com/isocialPractice/vscode-flight-map)";
const NOMINATIM = "https://nominatim.openstreetmap.org/search";
/**
* The tile imagery the page flies over is Web Mercator, which runs out of
* projection at about 85.0511 degrees. Past that there are no tiles to
* fetch, so a latitude beyond it is rejected rather than flown to a blank.
*/
const MAX_LATITUDE = 85.0511;
const servers = new Map();
/**
* The capitals the page ships with, read out of its own data file rather
* than duplicated here. game/capitals.js is a plain script that assigns
* one `var`, so evaluating it and returning that binding keeps this
* server and the page reading from a single list.
*/
const CAPITALS = await (async () => {
const source = await readFile(path.join(gameRoot, "capitals.js"), "utf8");
const regions = new Function(`${source}\nreturn CAPITALS_BY_REGION;`)();
return regions.flatMap((region) =>
region.capitals.map((capital) => ({ ...capital, region: region.region })),
);
})();
/** The render configuration the page starts on, or defaults when absent. */
async function readRenderConfig() {
try {
return JSON.parse(await readFile(path.join(__dirname, "renderMap.json"), "utf8"));
} catch {
// Missing or malformed: the page falls back to its own defaults
return {};
}
}
function contentType(filePath) {
switch (path.extname(filePath).toLowerCase()) {
case ".html":
return "text/html; charset=utf-8";
case ".js":
return "text/javascript; charset=utf-8";
case ".css":
return "text/css; charset=utf-8";
case ".json":
return "application/json; charset=utf-8";
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".svg":
return "image/svg+xml";
case ".webp":
return "image/webp";
default:
return "application/octet-stream";
}
}
/** Resolves a request path under a root, refusing anything that escapes it. */
function resolveUnder(root, requestPath) {
const resolved = path.resolve(root, `.${requestPath}`);
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
throw new CanvasError("invalid_path", "Requested path is outside the flight map assets.");
}
return resolved;
}
function sendNotFound(res) {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
res.end("Not found");
}
function sendSse(res, event, data) {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
function broadcast(entry, event, data) {
for (const client of entry.clients) {
sendSse(client, event, data);
}
}
/* ============================================================
Destination resolution
============================================================ */
function normalizeText(value) {
return typeof value === "string" ? value.trim() : "";
}
function randomCapital() {
return CAPITALS[Math.floor(Math.random() * CAPITALS.length)];
}
function asDestination(capital) {
return {
capital: capital.capital,
country: capital.country,
lat: capital.lat,
lng: capital.lng,
label: `${capital.capital}, ${capital.country}`,
};
}
/** Matches a name against the shipped capitals, by city or by country. */
function findCapital(name) {
const needle = name.toLowerCase();
return (
CAPITALS.find((c) => c.capital.toLowerCase() === needle) ??
CAPITALS.find((c) => c.country.toLowerCase() === needle) ??
CAPITALS.find((c) => c.capital.toLowerCase().includes(needle)) ??
CAPITALS.find((c) => c.country.toLowerCase().includes(needle))
);
}
/**
* Forward geocodes typed fields the same way the page's own destination
* dialog does: structured Nominatim parameters rather than one
* concatenated string, so a city that also exists elsewhere does not
* outrank the intended match.
*/
async function geocode(fields) {
const params = new URLSearchParams({ format: "jsonv2", addressdetails: "1", limit: "1" });
for (const key of ["city", "state", "country"]) {
if (fields[key]) params.set(key, fields[key]);
}
let response;
try {
response = await fetch(`${NOMINATIM}?${params}`, {
headers: { "user-agent": USER_AGENT, accept: "application/json" },
});
} catch (error) {
throw new CanvasError("geocode_unreachable", `Could not reach the geocoding service: ${error.message}`);
}
if (!response.ok) {
throw new CanvasError("geocode_failed", `Geocoding failed with status ${response.status}.`);
}
const [match] = await response.json();
if (!match) {
return null;
}
const address = match.address ?? {};
const city = address.city || address.town || address.village || address.municipality || match.name;
const state = address.state || address.province || address.region || address.county;
const code = address.country_code ? address.country_code.toUpperCase() : "";
const label = [state, city && city !== state ? city : null, code].filter(Boolean).join(", ");
return {
capital: city || "Destination",
country: address.country || "",
lat: Number.parseFloat(match.lat),
lng: Number.parseFloat(match.lon),
label: label || match.display_name,
};
}
/**
* Turns fly_to / open input into a destination the page can load.
*
* lat + lng flown to directly
* capital matched against the shipped capitals
* city/state/country geocoded
* nothing a random capital, so "take me somewhere" works
*/
async function resolveDestination(input) {
const fields = {
capital: normalizeText(input?.capital),
city: normalizeText(input?.city),
state: normalizeText(input?.state),
country: normalizeText(input?.country),
};
const lat = typeof input?.lat === "number" ? input.lat : undefined;
const lng = typeof input?.lng === "number" ? input.lng : undefined;
if (lat !== undefined || lng !== undefined) {
if (lat === undefined || lng === undefined) {
throw new CanvasError("incomplete_coordinates", "Give both lat and lng, or neither.");
}
if (!Number.isFinite(lat) || lat < -MAX_LATITUDE || lat > MAX_LATITUDE) {
throw new CanvasError(
"invalid_latitude",
`lat must be between -${MAX_LATITUDE} and ${MAX_LATITUDE}, the limit of the Web Mercator tile imagery.`,
);
}
if (!Number.isFinite(lng) || lng < -180 || lng > 180) {
throw new CanvasError("invalid_longitude", "lng must be between -180 and 180.");
}
return {
capital: fields.city || fields.capital || "Waypoint",
country: fields.country,
lat,
lng,
label: fields.city || fields.capital
? `${fields.city || fields.capital}${fields.country ? `, ${fields.country}` : ""}`
: `${Math.abs(lat).toFixed(4)}°${lat >= 0 ? "N" : "S"} ` +
`${Math.abs(lng).toFixed(4)}°${lng >= 0 ? "E" : "W"}`,
};
}
if (fields.capital) {
const capital = findCapital(fields.capital);
if (capital) return asDestination(capital);
// Not one of the shipped capitals: treat it as a place name
fields.city ||= fields.capital;
}
if (fields.city || fields.state || fields.country) {
const resolved = await geocode(fields);
if (resolved) return resolved;
// A country with no city still has a capital in the shipped list
const fallback = fields.country && findCapital(fields.country);
if (fallback) return asDestination(fallback);
throw new CanvasError("destination_not_found", "No destination matched that description.");
}
return asDestination(randomCapital());
}
/* ============================================================
Page serving
============================================================ */
/**
* Builds the head the page's placeholder expects: a policy, the render
* configuration, and the host shim. The shim must run before the page's
* own scripts, which is why it goes in the head rather than after them.
*/
function buildHead(entry, nonce) {
const csp = [
"default-src 'self'",
// Satellite tiles are remote images; the terrain fill samples the
// canvas, so the tile textures are read back and need to be clean
"img-src 'self' https: data: blob:",
"style-src 'self' 'unsafe-inline'",
`script-src 'self' 'nonce-${nonce}'`,
// 'self' for the event stream below, https: for the geocoder the
// page's own destination dialog calls
"connect-src 'self' https:",
].join("; ");
const init = {
config: entry.config,
job: entry.job,
destination: entry.destination,
};
return (
`<meta http-equiv="Content-Security-Policy" content="${csp}">\n` +
` <script nonce="${nonce}">\n${hostShim(init)}\n </script>`
);
}
/**
* Stands in for the VS Code webview host. The page reaches its host
* through `acquireVsCodeApi()` and a window `message` listener, so
* defining that API and translating the server's Server-Sent Events into
* those messages is the whole port.
*/
function hostShim(init) {
return ` (function () {
var init = ${JSON.stringify(init).replace(/</g, "\\u003c")};
// The page reads its render configuration off this global
window.__flightMapConfig = init.config;
function dispatch(message) {
window.postMessage(message, '*');
}
window.acquireVsCodeApi = function () {
return {
postMessage: function (message) {
if (!message || typeof message !== 'object') return;
if (message.command === 'ready') {
// Replay the opening state once the page is listening
setTimeout(function () {
if (init.destination) dispatch({ command: 'flyTo', destination: init.destination });
if (init.job) dispatch({ command: 'jobStatus', job: init.job });
}, 0);
} else if (message.command === 'exportConfig') {
// No save dialog in a canvas, so the export falls back to the
// same browser download the standalone page uses
try {
var blob = new Blob([message.json], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'renderMap.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
// Downloads blocked by the host: the live config panel still works
}
}
},
getState: function () { return undefined; },
setState: function () {}
};
};
// Live host -> page channel for agent activity
try {
var events = new EventSource('/events');
events.addEventListener('flyTo', function (event) {
try { dispatch({ command: 'flyTo', destination: JSON.parse(event.data).destination }); } catch (e) {}
});
events.addEventListener('jobStatus', function (event) {
try { dispatch({ command: 'jobStatus', job: JSON.parse(event.data).job }); } catch (e) {}
});
} catch (e) {
// No EventSource: the simulator still flies, just without agent activity
}
})();`;
}
async function renderIndex(entry) {
const html = await readFile(indexPath, "utf8");
const nonce = randomBytes(16).toString("base64url");
return html.replace("<!--{{HOST_HEAD}}-->", buildHead(entry, nonce));
}
async function streamFile(res, filePath) {
const fileStat = await stat(filePath).catch(() => undefined);
if (!fileStat?.isFile()) {
sendNotFound(res);
return;
}
res.writeHead(200, {
"content-type": contentType(filePath),
"cache-control": "no-cache",
});
const stream = createReadStream(filePath);
stream.on("error", () => {
if (!res.headersSent) {
sendNotFound(res);
} else {
res.destroy();
}
});
stream.pipe(res);
}
async function handleRequest(entry, req, res) {
const url = new URL(req.url ?? "/", entry.url);
if (url.pathname === "/events") {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive",
});
entry.clients.add(res);
// Catch a fresh or reconnecting client up to the live state
sendSse(res, "jobStatus", { job: entry.job });
if (entry.destination) {
sendSse(res, "flyTo", { destination: entry.destination });
}
req.on("close", () => entry.clients.delete(res));
return;
}
if (url.pathname === "/favicon.ico") {
await streamFile(res, path.join(assetsRoot, "icon.png"));
return;
}
try {
if (url.pathname === "/" || url.pathname === "/index.html") {
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-cache",
});
res.end(await renderIndex(entry));
return;
}
const staticPath = url.pathname.startsWith("/assets/")
? resolveUnder(assetsRoot, url.pathname.slice("/assets".length))
: resolveUnder(gameRoot, url.pathname);
await streamFile(res, staticPath);
} catch (error) {
if (error instanceof CanvasError) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
res.end(error.message);
return;
}
throw error;
}
}
async function startServer(instanceId, destination) {
const entry = {
clients: new Set(),
config: await readRenderConfig(),
job: { ...IDLE_JOB },
destination,
server: undefined,
url: undefined,
};
const server = createServer((req, res) => {
handleRequest(entry, req, res).catch((error) => {
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
res.end(error instanceof Error ? error.message : "Flight map canvas server error");
});
});
entry.server = server;
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
entry.url = `http://127.0.0.1:${port}/`;
servers.set(instanceId, entry);
return entry;
}
function getOpenEntry(instanceId) {
const entry = servers.get(instanceId);
if (!entry) {
throw new CanvasError("flight_map_not_open", "Open the Flight Map canvas before invoking this action.");
}
return entry;
}
/**
* Coerces report_job input into the job shape the status strip reads.
* A call that omits tokens keeps the count already on screen, so the
* final done=true call settles the job without wiping its total.
*/
function normalizeJob(input, previous) {
if (!input || typeof input !== "object") {
return { ...IDLE_JOB };
}
return {
working: input.done !== true,
status: typeof input.status === "string" ? input.status.slice(0, 120) : "",
tokens:
typeof input.tokens === "number" && Number.isFinite(input.tokens)
? Math.max(0, Math.floor(input.tokens))
: (previous?.tokens ?? 0),
};
}
const DESTINATION_PROPERTIES = {
capital: {
type: "string",
description:
"A world capital to fly to, by city or country name (e.g. 'Wellington', 'New Zealand'). Matched against the capitals the canvas ships with.",
},
city: {
type: "string",
description: "City name for anywhere that is not a shipped capital. Geocoded on the way in.",
},
state: {
type: "string",
description: "State, region, province, or district that narrows the city.",
},
country: {
type: "string",
description: "Country that narrows the city.",
},
lat: {
type: "number",
description: `Latitude in degrees, -${MAX_LATITUDE} to ${MAX_LATITUDE} (the limit of the Web Mercator tile imagery). Give lng as well to skip the geocoder entirely.`,
minimum: -MAX_LATITUDE,
maximum: MAX_LATITUDE,
},
lng: {
type: "number",
description: "Longitude in degrees, -180 to 180. Give lat as well.",
minimum: -180,
maximum: 180,
},
};
await joinSession({
canvases: [
createCanvas({
id: "flight-map-canvas",
displayName: "Flight Map",
description:
"Fly a first-person satellite terrain map of the world while agents work. The agent can send the flight anywhere and report what it is doing.",
inputSchema: {
type: "object",
properties: DESTINATION_PROPERTIES,
additionalProperties: false,
},
actions: [
{
name: "fly_to",
description:
"Send the flight to a destination. Give a capital by name, a city with an optional state and country, or a raw lat/lng. Called with no input it picks a random world capital, which is the way to say 'take me somewhere else'.",
inputSchema: {
type: "object",
properties: DESTINATION_PROPERTIES,
additionalProperties: false,
},
handler: async (ctx) => {
const entry = getOpenEntry(ctx.instanceId);
const destination = await resolveDestination(ctx.input);
// Held so a reload, or a client that connects later,
// arrives at the same place the agent sent the flight
entry.destination = destination;
broadcast(entry, "flyTo", { destination });
return destination;
},
},
{
name: "report_job",
description:
"Report your current job status to the flight map. Call it when you start a chat request, again on each new step, and once more with done=true when finished. The status shows on a strip under the HUD and the token count runs beside it.",
inputSchema: {
type: "object",
properties: {
status: {
type: "string",
description:
"Short status text to show under the HUD (e.g. 'reading the codebase', 'rewriting the parser').",
},
tokens: {
type: "number",
description: "Approximate tokens consumed by the current job so far.",
},
done: {
type: "boolean",
description:
"Set true when the job is finished; the activity indicator stills and the last status stays readable.",
},
},
required: ["status"],
additionalProperties: false,
},
handler: (ctx) => {
const entry = getOpenEntry(ctx.instanceId);
entry.job = normalizeJob(ctx.input, entry.job);
broadcast(entry, "jobStatus", { job: entry.job });
return { job: entry.job };
},
},
],
open: async (ctx) => {
const hasInput = ctx.input && Object.keys(ctx.input).length > 0;
// Only resolve when asked for somewhere; otherwise the page
// opens on its own welcome screen and the menu picks
const destination = hasInput ? await resolveDestination(ctx.input) : undefined;
let entry = servers.get(ctx.instanceId);
if (!entry) {
entry = await startServer(ctx.instanceId, destination);
} else if (destination) {
entry.destination = destination;
broadcast(entry, "flyTo", { destination });
}
return {
title: "Flight Map",
status: entry.job.working && entry.job.status
? entry.job.status
: (entry.destination?.label ?? "Pick a destination"),
url: entry.url,
};
},
onClose: async (ctx) => {
const entry = servers.get(ctx.instanceId);
if (!entry) return;
servers.delete(ctx.instanceId);
for (const client of entry.clients) {
client.end();
}
await new Promise((resolve) => entry.server.close(() => resolve()));
},
}),
],
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
/**************************************************************
* World Capitals organized by continent/region. *
* Each capital includes country, city name, and coordinates. *
*************************************************************/
var CAPITALS_BY_REGION = [
{
region: "North America",
capitals: [
{ country: "United States", capital: "Washington, D.C.", lat: 38.8951, lng: -77.0364 },
{ country: "Canada", capital: "Ottawa", lat: 45.4215, lng: -75.6972 },
{ country: "Mexico", capital: "Mexico City", lat: 19.4326, lng: -99.1332 },
{ country: "Cuba", capital: "Havana", lat: 23.1136, lng: -82.3666 },
{ country: "Guatemala", capital: "Guatemala City", lat: 14.6349, lng: -90.5069 },
{ country: "Costa Rica", capital: "San Jose", lat: 9.9281, lng: -84.0907 },
{ country: "Panama", capital: "Panama City", lat: 8.9824, lng: -79.5199 },
{ country: "Jamaica", capital: "Kingston", lat: 18.0179, lng: -76.8099 }
]
},
{
region: "South America",
capitals: [
{ country: "Brazil", capital: "Brasilia", lat: -15.7975, lng: -47.8919 },
{ country: "Argentina", capital: "Buenos Aires", lat: -34.6037, lng: -58.3816 },
{ country: "Chile", capital: "Santiago", lat: -33.4489, lng: -70.6693 },
{ country: "Colombia", capital: "Bogota", lat: 4.7110, lng: -74.0721 },
{ country: "Peru", capital: "Lima", lat: -12.0464, lng: -77.0428 },
{ country: "Venezuela", capital: "Caracas", lat: 10.4806, lng: -66.9036 },
{ country: "Ecuador", capital: "Quito", lat: -0.1807, lng: -78.4678 },
{ country: "Uruguay", capital: "Montevideo", lat: -34.9011, lng: -56.1645 }
]
},
{
region: "Europe",
capitals: [
{ country: "United Kingdom", capital: "London", lat: 51.5074, lng: -0.1278 },
{ country: "France", capital: "Paris", lat: 48.8566, lng: 2.3522 },
{ country: "Germany", capital: "Berlin", lat: 52.5200, lng: 13.4050 },
{ country: "Italy", capital: "Rome", lat: 41.9028, lng: 12.4964 },
{ country: "Spain", capital: "Madrid", lat: 40.4168, lng: -3.7038 },
{ country: "Russia", capital: "Moscow", lat: 55.7558, lng: 37.6173 },
{ country: "Netherlands", capital: "Amsterdam", lat: 52.3676, lng: 4.9041 },
{ country: "Greece", capital: "Athens", lat: 37.9838, lng: 23.7275 },
{ country: "Sweden", capital: "Stockholm", lat: 59.3293, lng: 18.0686 },
{ country: "Norway", capital: "Oslo", lat: 59.9139, lng: 10.7522 },
{ country: "Portugal", capital: "Lisbon", lat: 38.7223, lng: -9.1393 },
{ country: "Czech Republic", capital: "Prague", lat: 50.0755, lng: 14.4378 },
{ country: "Austria", capital: "Vienna", lat: 48.2082, lng: 16.3738 },
{ country: "Switzerland", capital: "Bern", lat: 46.9480, lng: 7.4474 },
{ country: "Poland", capital: "Warsaw", lat: 52.2297, lng: 21.0122 },
{ country: "Turkey", capital: "Ankara", lat: 39.9334, lng: 32.8597 }
]
},
{
region: "Asia",
capitals: [
{ country: "Japan", capital: "Tokyo", lat: 35.6762, lng: 139.6503 },
{ country: "China", capital: "Beijing", lat: 39.9042, lng: 116.4074 },
{ country: "India", capital: "New Delhi", lat: 28.6139, lng: 77.2090 },
{ country: "South Korea", capital: "Seoul", lat: 37.5665, lng: 126.9780 },
{ country: "Thailand", capital: "Bangkok", lat: 13.7563, lng: 100.5018 },
{ country: "Vietnam", capital: "Hanoi", lat: 21.0285, lng: 105.8542 },
{ country: "Indonesia", capital: "Jakarta", lat: -6.2088, lng: 106.8456 },
{ country: "Philippines", capital: "Manila", lat: 14.5995, lng: 120.9842 },
{ country: "Singapore", capital: "Singapore", lat: 1.3521, lng: 103.8198 },
{ country: "Malaysia", capital: "Kuala Lumpur", lat: 3.1390, lng: 101.6869 },
{ country: "Pakistan", capital: "Islamabad", lat: 33.6844, lng: 73.0479 },
{ country: "Israel", capital: "Jerusalem", lat: 31.7683, lng: 35.2137 },
{ country: "UAE", capital: "Abu Dhabi", lat: 24.4539, lng: 54.3773 },
{ country: "Saudi Arabia", capital: "Riyadh", lat: 24.7136, lng: 46.6753 }
]
},
{
region: "Africa",
capitals: [
{ country: "Egypt", capital: "Cairo", lat: 30.0444, lng: 31.2357 },
{ country: "South Africa", capital: "Pretoria", lat: -25.7479, lng: 28.2293 },
{ country: "Kenya", capital: "Nairobi", lat: -1.2921, lng: 36.8219 },
{ country: "Nigeria", capital: "Abuja", lat: 9.0579, lng: 7.4951 },
{ country: "Morocco", capital: "Rabat", lat: 34.0209, lng: -6.8416 },
{ country: "Ethiopia", capital: "Addis Ababa", lat: 9.0250, lng: 38.7469 },
{ country: "Tanzania", capital: "Dodoma", lat: -6.1630, lng: 35.7516 },
{ country: "Ghana", capital: "Accra", lat: 5.6037, lng: -0.1870 }
]
},
{
region: "Oceania",
capitals: [
{ country: "Australia", capital: "Canberra", lat: -35.2809, lng: 149.1300 },
{ country: "New Zealand", capital: "Wellington", lat: -41.2865, lng: 174.7762 },
{ country: "Fiji", capital: "Suva", lat: -18.1416, lng: 178.4419 },
{ country: "Papua New Guinea", capital: "Port Moresby", lat: -6.3149, lng: 143.9556 }
]
}
];
@@ -0,0 +1,310 @@
/***********************************************************************
* DestinationInput - Keyboard entry for destinations that are not one *
* of the built-in capitals. *
* *
* Opened from the "Input Destination" entry at the top of the *
* destination menu. The dialog runs in two stages: *
* *
* 1. Form - state/region and city are typed, country is picked *
* from the menu. Any one of the three is enough. *
* 2. Results - the matches the geocoder returned, one per row. *
* Picking one and pressing Select flies there. *
* *
* The dialog owns the keyboard while it is open: flight input already *
* ignores events from form fields, and the shortcuts below stop at *
* the dialog so a typed letter never reaches the simulator. *
***********************************************************************/
var DestinationInput = (function () {
var dialog, formStage, resultsStage;
var stateInput, cityInput, countrySelect;
var formMessage, resultsMessage, resultList;
var searchBtn, selectBtn;
var onSelect = null;
var onOpen = null;
var onClose = null;
var results = [];
var activeIndex = -1;
var searching = false;
var isOpen = false;
/* ------------------------------------------------------------
Country menu
------------------------------------------------------------ */
/**
* Fills the country menu from the capitals dataset, which is the
* only list of country names the page ships with. Leaving the menu
* on its first entry searches without a country filter.
*/
function populateCountries() {
if (typeof CAPITALS_BY_REGION === 'undefined') return;
var seen = {};
var names = [];
for (var i = 0; i < CAPITALS_BY_REGION.length; i++) {
var capitals = CAPITALS_BY_REGION[i].capitals;
for (var j = 0; j < capitals.length; j++) {
var country = capitals[j].country;
if (seen[country]) continue;
seen[country] = true;
names.push(country);
}
}
names.sort();
for (var k = 0; k < names.length; k++) {
var option = document.createElement('option');
option.value = names[k];
option.textContent = names[k];
countrySelect.appendChild(option);
}
}
/* ------------------------------------------------------------
Stage switching
------------------------------------------------------------ */
function showForm() {
formStage.classList.remove('hidden');
resultsStage.classList.add('hidden');
resultsMessage.textContent = '';
}
function showResults() {
formStage.classList.add('hidden');
resultsStage.classList.remove('hidden');
formMessage.textContent = '';
}
function setSearching(active) {
searching = active;
searchBtn.disabled = active;
searchBtn.textContent = active ? 'Searching...' : 'Search';
}
/* ------------------------------------------------------------
Results list
------------------------------------------------------------ */
function renderResults() {
resultList.innerHTML = '';
for (var i = 0; i < results.length; i++) {
var row = document.createElement('li');
row.className = 'dest-result';
// Focus stays on the listbox, so each option needs an id for
// the list's aria-activedescendant to point a reader at it
row.id = 'dest-result-' + i;
row.setAttribute('role', 'option');
row.setAttribute('aria-selected', 'false');
row.dataset.index = String(i);
row.textContent = results[i].label;
resultList.appendChild(row);
}
setActiveIndex(results.length ? 0 : -1);
}
function setActiveIndex(index) {
activeIndex = index;
var rows = resultList.children;
for (var i = 0; i < rows.length; i++) {
var active = i === index;
rows[i].classList.toggle('active', active);
rows[i].setAttribute('aria-selected', active ? 'true' : 'false');
}
selectBtn.disabled = index < 0;
// Point the listbox at the option the arrow keys landed on, and
// drop the reference entirely when nothing is active
if (index >= 0 && rows[index]) {
resultList.setAttribute('aria-activedescendant', rows[index].id);
if (rows[index].scrollIntoView) {
rows[index].scrollIntoView({ block: 'nearest' });
}
} else {
resultList.removeAttribute('aria-activedescendant');
}
}
function moveActive(step) {
if (!results.length) return;
var next = activeIndex + step;
if (next < 0) next = 0;
if (next > results.length - 1) next = results.length - 1;
setActiveIndex(next);
}
/* ------------------------------------------------------------
Actions
------------------------------------------------------------ */
function runSearch() {
if (searching) return;
formMessage.textContent = '';
setSearching(true);
Geocoder.search({
state: stateInput.value,
city: cityInput.value,
country: countrySelect.value
}, function (error, found) {
setSearching(false);
if (error) {
formMessage.textContent = error;
return;
}
results = found;
renderResults();
showResults();
resultsMessage.textContent = found.length
? ''
: 'No matching destination was found. Cancel to adjust the search.';
if (found.length) resultList.focus();
});
}
function commitSelection() {
if (activeIndex < 0 || !results[activeIndex]) return;
var chosen = results[activeIndex];
close();
if (onSelect) onSelect(chosen);
}
/* ------------------------------------------------------------
Open / close
------------------------------------------------------------ */
function open() {
if (isOpen) return;
isOpen = true;
results = [];
activeIndex = -1;
// Rows from the previous search are stale the moment the form
// reopens, so nothing should still be pointing at one of them
resultList.removeAttribute('aria-activedescendant');
formMessage.textContent = '';
setSearching(false);
showForm();
dialog.classList.remove('hidden');
if (onOpen) onOpen();
stateInput.focus();
stateInput.select();
}
function close() {
if (!isOpen) return;
isOpen = false;
dialog.classList.add('hidden');
if (onClose) onClose();
}
/* ------------------------------------------------------------
Wiring
------------------------------------------------------------ */
/**
* Binds the dialog. options:
* onSelect(destination) - a result the user picked
* onOpen() - the dialog became visible
* onClose() - the dialog was dismissed or committed
*/
function init(options) {
options = options || {};
onSelect = options.onSelect || null;
onOpen = options.onOpen || null;
onClose = options.onClose || null;
dialog = document.getElementById('destination-dialog');
if (!dialog) return;
formStage = document.getElementById('destination-form');
resultsStage = document.getElementById('destination-results');
stateInput = document.getElementById('dest-state');
cityInput = document.getElementById('dest-city');
countrySelect = document.getElementById('dest-country');
formMessage = document.getElementById('dest-form-message');
resultsMessage = document.getElementById('dest-results-message');
resultList = document.getElementById('dest-result-list');
searchBtn = document.getElementById('dest-search');
selectBtn = document.getElementById('dest-select');
populateCountries();
searchBtn.addEventListener('click', runSearch);
selectBtn.addEventListener('click', commitSelection);
document.getElementById('dest-cancel').addEventListener('click', close);
document.getElementById('dest-results-cancel').addEventListener('click', close);
// Enter submits from any field in the form stage
formStage.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
runSearch();
}
});
// Click a row to highlight it, double click to fly straight there
resultList.addEventListener('click', function (e) {
var row = e.target.closest ? e.target.closest('.dest-result') : null;
if (!row) return;
setActiveIndex(parseInt(row.dataset.index, 10));
});
resultList.addEventListener('dblclick', function (e) {
var row = e.target.closest ? e.target.closest('.dest-result') : null;
if (!row) return;
setActiveIndex(parseInt(row.dataset.index, 10));
commitSelection();
});
resultList.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
moveActive(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
moveActive(-1);
} else if (e.key === 'Enter') {
e.preventDefault();
commitSelection();
}
});
// Escape closes from anywhere inside the dialog, and clicking the
// backdrop outside the card does the same
dialog.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
close();
}
});
dialog.addEventListener('mousedown', function (e) {
if (e.target === dialog) close();
});
}
return {
init: init,
open: open,
close: close,
isOpen: function () { return isOpen; }
};
})();
@@ -0,0 +1,385 @@
/*******************************************************************
* FlightControls - Flight simulator camera controls for Three.js. *
* *
* Controls: *
* Mouse (pointer lock): Pitch and Yaw *
* W / Up Arrow: Increase speed *
* S / Down Arrow: Decrease speed *
* A / Left Arrow: Roll left *
* D / Right Arrow: Roll right *
* Q: Yaw left (rudder) *
* E: Yaw right (rudder) *
* R: Climb *
* F: Descend *
* Space: Auto-level (stabilize) *
* Shift: Boost speed *
* *
* Look modes: *
* pointer - pointer lock captures the cursor (preferred) *
* drag - hold the left mouse button and drag to look *
* *
* Drag mode is the fallback for hosts that deny pointer lock, *
* such as an embedded panel whose frame withholds the permission. *
* Escape releases drag mode the way it releases pointer lock. *
******************************************************************/
/**
* True when a key event belongs to the page rather than to the
* simulator: the user is typing, choosing a value, or working inside a
* dialog. Flight input listens on the document, so without this guard a
* space typed into a destination field would trigger auto-level and
* have its default suppressed.
*/
function isTextEntryTarget(target) {
if (!target || !target.tagName) return false;
var tag = target.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if (target.isContentEditable === true) return true;
return !!(target.closest && target.closest('[role="dialog"]'));
}
function FlightControls(camera, domElement) {
this.camera = camera;
this.domElement = domElement;
// Position and orientation
this.position = camera.position.clone();
this.quaternion = new THREE.Quaternion();
// Speed
this.speed = 0;
this.targetSpeed = 0;
this.minSpeed = 0;
this.maxSpeed = 12;
this.boostMultiplier = 3;
this.acceleration = 4.0;
this.deceleration = 3.0;
// Rotation
this.rollSpeed = 1.8;
this.yawSpeed = 1.0;
this.mouseSensitivity = 0.002;
// Altitude
this.verticalSpeed = 0;
this.verticalAccel = 4.0;
this.minAltitude = 8;
// Input state
this.keys = {};
this.mouseMovementX = 0;
this.mouseMovementY = 0;
this.isPointerLocked = false;
this.isEnabled = false;
// Look mode: 'pointer' until a lock request is refused, then 'drag'
this.lookMode = 'pointer';
this.isDragging = false;
this.lockTimeoutMs = 700;
this._lockTimer = null;
// Auto-level
this.autoLevel = false;
this.autoLevelSpeed = 3.0;
this._init();
}
FlightControls.prototype._init = function () {
var self = this;
document.addEventListener('keydown', function (e) {
if (isTextEntryTarget(e.target)) return;
self.keys[e.code] = true;
if (e.code === 'Space') {
self.autoLevel = true;
e.preventDefault();
}
// Pointer lock exits on Escape by itself; drag mode needs releasing
if (e.code === 'Escape' && self.lookMode === 'drag') {
self._release();
}
});
document.addEventListener('keyup', function (e) {
if (isTextEntryTarget(e.target)) return;
self.keys[e.code] = false;
if (e.code === 'Space') {
self.autoLevel = false;
}
});
this.domElement.addEventListener('click', function () {
if (self.isEnabled) return;
if (self.lookMode === 'drag') {
self._engage();
} else if (!self.isPointerLocked) {
self._requestLock();
}
});
this.domElement.addEventListener('mousedown', function (e) {
if (self.lookMode !== 'drag' || !self.isEnabled) return;
if (e.button !== 0) return;
self.isDragging = true;
// Suppress the text selection drag that would otherwise start
e.preventDefault();
});
document.addEventListener('mouseup', function () {
self.isDragging = false;
});
document.addEventListener('mousemove', function (e) {
var capturing = self.isPointerLocked ||
(self.lookMode === 'drag' && self.isEnabled && self.isDragging);
if (!capturing) return;
self.mouseMovementX += e.movementX || 0;
self.mouseMovementY += e.movementY || 0;
});
document.addEventListener('pointerlockchange', function () {
self.isPointerLocked = document.pointerLockElement === self.domElement;
clearTimeout(self._lockTimer);
if (self.isPointerLocked) {
self.lookMode = 'pointer';
self._engage();
} else {
self._release();
}
});
document.addEventListener('pointerlockerror', function () {
self._fallbackToDrag();
});
this.domElement.addEventListener('contextmenu', function (e) {
e.preventDefault();
});
};
/**
* Requests pointer lock, falling back to drag look when the host
* refuses the request, throws, or ignores it without an error event.
*/
FlightControls.prototype._requestLock = function () {
var self = this;
if (!this.domElement.requestPointerLock) {
this._fallbackToDrag();
return;
}
var request;
try {
request = this.domElement.requestPointerLock();
} catch (e) {
this._fallbackToDrag();
return;
}
// Newer browsers resolve a promise; older ones return undefined and
// report failure through the pointerlockerror event
if (request && typeof request.catch === 'function') {
request.catch(function () {
self._fallbackToDrag();
});
}
clearTimeout(this._lockTimer);
this._lockTimer = setTimeout(function () {
if (!self.isPointerLocked) self._fallbackToDrag();
}, this.lockTimeoutMs);
};
/**
* Switches to drag look and engages immediately, so the click that
* asked for pointer lock still starts the flight.
*/
FlightControls.prototype._fallbackToDrag = function () {
clearTimeout(this._lockTimer);
if (this.lookMode !== 'drag') {
this.lookMode = 'drag';
}
this._engage();
};
/**
* Enables flight input and notifies the UI.
*/
FlightControls.prototype._engage = function () {
if (this.isEnabled) return;
this.isEnabled = true;
this._dispatch(true);
};
/**
* Disables flight input and clears any state that could read as stuck.
*/
FlightControls.prototype._release = function () {
if (!this.isEnabled) return;
this.isEnabled = false;
this.isDragging = false;
// Keyup events can be missed while released; clear input state
// so no key reads as stuck on the next engage
this.keys = {};
this.autoLevel = false;
this.mouseMovementX = 0;
this.mouseMovementY = 0;
this._dispatch(false);
};
FlightControls.prototype._dispatch = function (locked) {
document.dispatchEvent(new CustomEvent('flightcontrols', {
detail: { locked: locked, mode: this.lookMode }
}));
};
FlightControls.prototype.update = function (delta) {
if (!this.isEnabled) return;
delta = Math.min(delta, 0.1);
// --- Mouse look: pitch and yaw ---
if (this.mouseMovementX !== 0 || this.mouseMovementY !== 0) {
var pitchAngle = -this.mouseMovementY * this.mouseSensitivity;
var yawAngle = -this.mouseMovementX * this.mouseSensitivity;
// Pitch around camera's local X axis
var right = new THREE.Vector3(1, 0, 0).applyQuaternion(this.quaternion);
var pitchQuat = new THREE.Quaternion().setFromAxisAngle(right, pitchAngle);
this.quaternion.premultiply(pitchQuat);
// Yaw around world Y axis for stability
var worldUp = new THREE.Vector3(0, 1, 0);
var yawQuat = new THREE.Quaternion().setFromAxisAngle(worldUp, yawAngle);
this.quaternion.premultiply(yawQuat);
this.quaternion.normalize();
this.mouseMovementX = 0;
this.mouseMovementY = 0;
}
// --- Keyboard roll (A/D, Left/Right) ---
var rollDelta = 0;
if (this.keys['KeyA'] || this.keys['ArrowLeft']) rollDelta += this.rollSpeed * delta;
if (this.keys['KeyD'] || this.keys['ArrowRight']) rollDelta -= this.rollSpeed * delta;
if (rollDelta !== 0) {
var forward = new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion);
var rollQuat = new THREE.Quaternion().setFromAxisAngle(forward, rollDelta);
this.quaternion.premultiply(rollQuat);
this.quaternion.normalize();
}
// --- Keyboard yaw / rudder (Q/E) ---
var yawDelta = 0;
if (this.keys['KeyQ']) yawDelta += this.yawSpeed * delta;
if (this.keys['KeyE']) yawDelta -= this.yawSpeed * delta;
if (yawDelta !== 0) {
var localUp = new THREE.Vector3(0, 1, 0).applyQuaternion(this.quaternion);
var rudderQuat = new THREE.Quaternion().setFromAxisAngle(localUp, yawDelta);
this.quaternion.premultiply(rudderQuat);
this.quaternion.normalize();
}
// --- Throttle (W/S, Up/Down) ---
var boost = (this.keys['ShiftLeft'] || this.keys['ShiftRight']) ? this.boostMultiplier : 1;
if (this.keys['KeyW'] || this.keys['ArrowUp']) {
this.targetSpeed = Math.min(this.targetSpeed + this.acceleration * delta, this.maxSpeed * boost);
} else if (this.keys['KeyS'] || this.keys['ArrowDown']) {
this.targetSpeed = Math.max(this.targetSpeed - this.deceleration * delta, this.minSpeed);
}
this.targetSpeed = Math.min(this.targetSpeed, this.maxSpeed * boost);
this.speed += (this.targetSpeed - this.speed) * Math.min(5 * delta, 1);
// --- Altitude (R/F) ---
if (this.keys['KeyR']) this.verticalSpeed += this.verticalAccel * delta;
if (this.keys['KeyF']) this.verticalSpeed -= this.verticalAccel * delta;
this.verticalSpeed *= Math.pow(0.93, delta * 60);
// --- Auto-level (Space held) ---
if (this.autoLevel) {
var currentUp = new THREE.Vector3(0, 1, 0).applyQuaternion(this.quaternion);
var targetUp = new THREE.Vector3(0, 1, 0);
var correction = new THREE.Quaternion().setFromUnitVectors(currentUp, targetUp);
var t = Math.min(this.autoLevelSpeed * delta, 1);
correction.slerp(new THREE.Quaternion(), 1 - t);
this.quaternion.premultiply(correction);
this.quaternion.normalize();
}
// --- Update position ---
var fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion);
this.position.addScaledVector(fwd, this.speed * delta * 60);
this.position.y += this.verticalSpeed * delta * 60;
if (this.position.y < this.minAltitude) {
this.position.y = this.minAltitude;
this.verticalSpeed = Math.max(this.verticalSpeed, 0);
}
// --- Apply to camera ---
this.camera.position.copy(this.position);
this.camera.quaternion.copy(this.quaternion);
};
FlightControls.prototype.getHeading = function () {
var fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion);
var heading = Math.atan2(fwd.x, -fwd.z) * 180 / Math.PI;
return ((heading % 360) + 360) % 360;
};
FlightControls.prototype.getPitch = function () {
var fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion);
return Math.asin(Math.max(-1, Math.min(1, fwd.y))) * 180 / Math.PI;
};
FlightControls.prototype.getRoll = function () {
var right = new THREE.Vector3(1, 0, 0).applyQuaternion(this.quaternion);
return Math.asin(Math.max(-1, Math.min(1, right.y))) * 180 / Math.PI;
};
FlightControls.prototype.getSpeed = function () {
return this.speed;
};
FlightControls.prototype.getAltitude = function () {
return this.position.y;
};
/**
* Hands control back to the page. Used when a dialog opens over the
* canvas so the aircraft is not still flying behind it.
*/
FlightControls.prototype.release = function () {
if (this.isPointerLocked && document.exitPointerLock) {
document.exitPointerLock();
}
this._release();
};
FlightControls.prototype.resetTo = function (position, lookAt) {
this.position.copy(position);
this.speed = 0;
this.targetSpeed = 0;
this.verticalSpeed = 0;
var tempCam = new THREE.PerspectiveCamera();
tempCam.position.copy(position);
tempCam.lookAt(lookAt);
this.quaternion.copy(tempCam.quaternion);
this.camera.position.copy(this.position);
this.camera.quaternion.copy(this.quaternion);
};
// Shared with the rest of the page so every document-level shortcut
// applies the same rule about what counts as typing
FlightControls.isTextEntryTarget = isTextEntryTarget;
@@ -0,0 +1,314 @@
/************************************************************************
* Geocoder - Talks to the OpenStreetMap Nominatim API in both *
* directions. *
* *
* update() reverse geocodes the camera's latitude/longitude into a *
* human-readable location label ("City, State, Country"), falling back *
* to "Rural <state>, <country>" when the position is not within a *
* city/town/village boundary. Requests are throttled by time and by *
* minimum movement so the HUD label costs as few lookups as possible. *
* *
* search() forward geocodes a typed state/city/country into a list of *
* candidate destinations for the destination input dialog. *
* *
* Both directions go out through one queue, so the module holds to the *
* Nominatim usage policy (absolute maximum of 1 request per second) *
* whichever direction is asking. *
***********************************************************************/
var Geocoder = (function () {
var NOMINATIM = 'https://nominatim.openstreetmap.org/';
var MIN_REQUEST_INTERVAL_MS = 1000; // Nominatim policy: 1 request/second
var MIN_INTERVAL_MS = 5000; // Minimum time between reverse lookups
var MIN_MOVE_DEG = 0.005; // Minimum movement (~500m) before a new lookup
var SEARCH_LIMIT = 10; // Candidates shown in the results list
var lastLat = null;
var lastLng = null;
var lastRequestTime = 0;
var lastLabel = '';
var pending = false;
// Bumped by reset(). A reverse lookup carries the value it was queued
// under, so a response that belongs to the location left behind is
// recognized as stale instead of overwriting the HUD with it.
var generation = 0;
/* ------------------------------------------------------------
Shared request queue
Nominatim counts requests per client across its endpoints, so
reverse lookups and searches cannot each keep their own pace.
One request is in flight at a time and consecutive sends are
spaced by MIN_REQUEST_INTERVAL_MS.
------------------------------------------------------------ */
var queue = [];
var inFlight = null;
var lastSendTime = 0;
var drainTimer = null;
/**
* Queues one request. job is
* { url, success(data), failure(reason), stale() }, where reason is
* { status, malformed } and the optional stale() predicate lets a
* job whose result is no longer wanted be dropped before it is sent.
*/
function enqueue(job) {
queue.push(job);
drain();
}
function drain() {
if (inFlight) return;
// Drop abandoned jobs before they spend a request slot
while (queue.length && queue[0].stale && queue[0].stale()) {
queue.shift();
}
if (!queue.length) return;
var wait = MIN_REQUEST_INTERVAL_MS - (performance.now() - lastSendTime);
if (wait > 0) {
if (drainTimer === null) {
drainTimer = setTimeout(function () {
drainTimer = null;
drain();
}, wait);
}
return;
}
send(queue.shift());
}
function send(job) {
var settled = false;
var xhr = new XMLHttpRequest();
inFlight = xhr;
lastSendTime = performance.now();
// A network failure raises both onerror and a readyState 4 with no
// status, so the first outcome to arrive is the one that counts
function finish(handler, argument) {
if (settled) return;
settled = true;
inFlight = null;
handler(argument);
drain();
}
xhr.open('GET', job.url, true);
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
if (xhr.status !== 200) {
finish(job.failure, { status: xhr.status, malformed: false });
return;
}
var data;
try {
data = JSON.parse(xhr.responseText);
} catch (e) {
finish(job.failure, { status: xhr.status, malformed: true });
return;
}
finish(job.success, data);
};
xhr.onerror = function () {
finish(job.failure, { status: 0, malformed: false });
};
xhr.send();
}
/**
* Builds a display label from a Nominatim address object.
* Within a city boundary: "City, State, Country".
* Outside one: "Rural State, Country" (e.g. "Rural New York, United States").
*/
function buildLabel(address) {
// Only city/town count as being "within a city"; villages, hamlets,
// and US civil townships ("Town of X" -> village) read as rural
var city = address.city || address.town;
var state = address.state || address.province || address.region ||
address.county;
var country = address.country;
var parts = [];
if (city) {
parts.push(city);
if (state) parts.push(state);
if (country) parts.push(country);
} else {
var area = state || country;
if (!area) return '';
parts.push('Rural ' + area);
if (state && country) parts.push(country);
}
return parts.join(', ');
}
/**
* Requests a label for the given position. Throttled internally,
* so it is safe to call every frame. Invokes callback(label) only
* when the label has changed.
*/
function update(lat, lng, callback) {
var now = performance.now();
if (pending || now - lastRequestTime < MIN_INTERVAL_MS) return;
if (lastLat !== null &&
Math.abs(lat - lastLat) < MIN_MOVE_DEG &&
Math.abs(lng - lastLng) < MIN_MOVE_DEG) return;
pending = true;
lastRequestTime = now;
var token = generation;
function abandoned() {
return token !== generation;
}
enqueue({
url: NOMINATIM + 'reverse?format=jsonv2&zoom=10' +
'&lat=' + encodeURIComponent(lat) +
'&lon=' + encodeURIComponent(lng),
stale: abandoned,
success: function (data) {
// Switched location while this was in flight: the label and
// the position it describes belong to somewhere else now
if (abandoned()) return;
pending = false;
var label = buildLabel((data && data.address) || {});
lastLat = lat;
lastLng = lng;
if (label && label !== lastLabel) {
lastLabel = label;
callback(label);
}
},
failure: function () {
if (abandoned()) return;
pending = false;
// Keep the last label on failure or a malformed response
}
});
}
/**
* Clears cached state when switching capitals so the next lookup
* runs immediately for the new location. Lookups already queued or
* in flight for the old location are abandoned rather than allowed
* to report back over the new one.
*/
function reset() {
generation++;
lastLat = null;
lastLng = null;
lastRequestTime = 0;
lastLabel = '';
pending = false;
}
/**
* Builds the label shown for one search result:
* "State, City, CC" (e.g. "New York, New York City, US").
*
* Nominatim omits whichever administrative levels a place does not
* have, so the parts that exist are joined and the raw display_name
* covers a result that carries no structured address at all.
*/
function buildResultLabel(item) {
var address = item.address || {};
var city = address.city || address.town || address.village ||
address.municipality || address.hamlet;
var state = address.state || address.province || address.region ||
address.county;
var code = address.country_code ? address.country_code.toUpperCase() : '';
var parts = [];
if (state) parts.push(state);
if (city && city !== state) parts.push(city);
if (code) parts.push(code);
if (!parts.length) return item.display_name || 'Unknown location';
return parts.join(', ');
}
/**
* Forward geocodes a destination typed into the input dialog.
*
* Fields are optional individually but at least one must be filled;
* they are sent as Nominatim's structured query parameters rather
* than concatenated into a free-text string, which keeps a city name
* that also exists in another country from outranking the match.
*
* Calls callback(error, results), where each result is
* { label, lat, lng, city, country } ready for loadCapital().
*/
function search(fields, callback) {
var params = [];
var keys = ['state', 'city', 'country'];
for (var i = 0; i < keys.length; i++) {
var value = (fields[keys[i]] || '').trim();
if (value) params.push(keys[i] + '=' + encodeURIComponent(value));
}
if (!params.length) {
callback('Enter a state/region, a city, or a country to search.', []);
return;
}
enqueue({
url: NOMINATIM + 'search?format=jsonv2&addressdetails=1' +
'&limit=' + SEARCH_LIMIT + '&' + params.join('&'),
success: function (data) {
if (!data || !data.length) {
callback(null, []);
return;
}
var results = [];
for (var i = 0; i < data.length; i++) {
var item = data[i];
var lat = parseFloat(item.lat);
var lng = parseFloat(item.lon);
if (isNaN(lat) || isNaN(lng)) continue;
var address = item.address || {};
results.push({
label: buildResultLabel(item),
lat: lat,
lng: lng,
city: address.city || address.town || address.village ||
item.name || 'Destination',
country: address.country || ''
});
}
callback(null, results);
},
failure: function (reason) {
if (reason.malformed) {
callback('Search returned a malformed response.', []);
} else if (!reason.status) {
callback('Search could not reach the geocoding service.', []);
} else {
callback('Search failed (' + reason.status + ').', []);
}
}
});
}
return {
update: update,
reset: reset,
search: search
};
})();
@@ -0,0 +1,73 @@
/************************************************************************
* HostBridge - Thin abstraction over the VS Code webview messaging API. *
* *
* The simulator runs in two places: inside a VS Code webview panel and *
* as a plain page in a browser. Everything that needs the extension *
* host (config injection, saving renderMap.json) goes through here so *
* the rest of the code stays identical in both environments. *
* *
* In the browser the API is absent and every call degrades to a no-op, *
* leaving the original web behavior in place. *
***********************************************************************/
var HostBridge = (function () {
var api = null;
var handlers = {};
// acquireVsCodeApi exists only inside a webview, and may be called
// exactly once per page load
if (typeof acquireVsCodeApi === 'function') {
try {
api = acquireVsCodeApi();
} catch (e) {
api = null;
}
}
if (api) {
window.addEventListener('message', function (event) {
var message = event.data;
if (!message || !message.command) return;
var fn = handlers[message.command];
if (fn) fn(message);
});
}
/**
* True when running inside the VS Code webview panel.
*/
function isHosted() {
return api !== null;
}
/**
* Send a message to the extension host. No-op in the browser.
*/
function post(message) {
if (api) api.postMessage(message);
}
/**
* Register a handler for a command sent by the extension host.
*/
function on(command, fn) {
handlers[command] = fn;
}
/**
* Render configuration injected by the extension host, or null when
* running as a standalone page.
*/
function getInjectedConfig() {
return (window.__flightMapConfig && typeof window.__flightMapConfig === 'object')
? window.__flightMapConfig
: null;
}
return {
isHosted: isHosted,
post: post,
on: on,
getInjectedConfig: getInjectedConfig
};
})();
@@ -0,0 +1,284 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!--{{HOST_HEAD}}-->
<title>Flight Map</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Top Bar: capital selector on the left, location readout centered.
The bar owns both so the readout can never sit under the menu. -->
<div id="top-bar">
<div id="selector-panel">
<label for="capital-select">Destination</label>
<!-- The placeholder is hidden from the list so "Input Destination"
reads as the first entry, but still shows as the closed value
until a destination has been chosen. -->
<select id="capital-select">
<option value="" selected hidden>-- Select a Capital --</option>
<option value="__input__" id="opt-input-destination">Input Destination</option>
</select>
<button id="btn-toggle-config" type="button" title="Configure Rendering">Config</button>
<button id="btn-export-json" type="button" title="Export renderMap.json">Export JSON</button>
</div>
<div id="hud-location" class="hidden">
<div id="hud-capital">---</div>
<div id="hud-coords">---</div>
</div>
</div>
<!-- Render Config Panel -->
<div id="config-panel" class="hidden">
<h3>Render Configuration</h3>
<p class="config-hint">Adjust rendering parameters to determine suitability for different gaming applications.</p>
<fieldset>
<legend>Map</legend>
<div class="cfg-row">
<label for="cfg-width">Width</label>
<input type="range" id="cfg-width" data-config="map.width" min="640" max="3840" step="10">
<span id="cfg-width-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-height">Height</label>
<input type="range" id="cfg-height" data-config="map.height" min="480" max="2160" step="10">
<span id="cfg-height-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-tileZoom">Tile Zoom</label>
<input type="range" id="cfg-tileZoom" data-config="map.tileZoom" min="10" max="19" step="1">
<span id="cfg-tileZoom-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-loadRadius">Load Radius</label>
<input type="range" id="cfg-loadRadius" data-config="map.loadRadius" min="2" max="48" step="1">
<span id="cfg-loadRadius-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-radiusFeather">Radius Feather</label>
<input type="range" id="cfg-radiusFeather" data-config="map.radiusFeather" min="0" max="100" step="0.5">
<span id="cfg-radiusFeather-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-startAlt">Start Altitude</label>
<input type="range" id="cfg-startAlt" data-config="map.startAltitude" min="50" max="1000" step="10">
<span id="cfg-startAlt-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-fogDensity">Fog Density</label>
<input type="range" id="cfg-fogDensity" data-config="map.fogDensity" min="0.00001" max="0.01" step="0.00001">
<span id="cfg-fogDensity-val"></span>
</div>
</fieldset>
<fieldset>
<legend>Filler</legend>
<div class="cfg-row">
<label for="cfg-sampleInterval">Sample Interval</label>
<input type="range" id="cfg-sampleInterval" data-config="filler.sampleInterval" min="10" max="120" step="5">
<span id="cfg-sampleInterval-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-updateInterval">Update Time (s)</label>
<input type="range" id="cfg-updateInterval" data-config="filler.updateIntervalSec" min="5" max="60" step="1">
<span id="cfg-updateInterval-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-perimCount">Perimeter Count</label>
<input type="range" id="cfg-perimCount" data-config="filler.perimeter.patchCount" min="1" max="20" step="1">
<span id="cfg-perimCount-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-perimSize">Perimeter Size</label>
<input type="range" id="cfg-perimSize" data-config="filler.perimeter.patchSize" min="2" max="20" step="1">
<span id="cfg-perimSize-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-centerCount">Center Count</label>
<input type="range" id="cfg-centerCount" data-config="filler.center.patchCount" min="1" max="15" step="1">
<span id="cfg-centerCount-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-centerSize">Center Size</label>
<input type="range" id="cfg-centerSize" data-config="filler.center.patchSize" min="2" max="20" step="1">
<span id="cfg-centerSize-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-padCount">Padding Count</label>
<input type="range" id="cfg-padCount" data-config="filler.padding.patchCount" min="1" max="12" step="1">
<span id="cfg-padCount-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-padSize">Padding Size</label>
<input type="range" id="cfg-padSize" data-config="filler.padding.patchSize" min="2" max="15" step="1">
<span id="cfg-padSize-val"></span>
</div>
</fieldset>
<fieldset>
<legend>Cloud Effect</legend>
<div class="cfg-row">
<label for="cfg-cloud-enabled">Enabled</label>
<input type="checkbox" id="cfg-cloud-enabled">
</div>
<div class="cfg-row">
<label for="cfg-cloudOpacity">Opacity</label>
<input type="range" id="cfg-cloudOpacity" data-config="cloud.opacity" min="0" max="1" step="0.05">
<span id="cfg-cloudOpacity-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-cloudSpeed">Speed</label>
<input type="range" id="cfg-cloudSpeed" data-config="cloud.speed" min="0.0001" max="0.002" step="0.0001">
<span id="cfg-cloudSpeed-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-cloudCoverage">Coverage</label>
<input type="range" id="cfg-cloudCoverage" data-config="cloud.coverage" min="0.1" max="1" step="0.05">
<span id="cfg-cloudCoverage-val"></span>
</div>
<div class="cfg-row">
<label for="cfg-cloudScale">Scale</label>
<input type="range" id="cfg-cloudScale" data-config="cloud.scale" min="0.002" max="0.03" step="0.001">
<span id="cfg-cloudScale-val"></span>
</div>
</fieldset>
</div>
<!-- Destination Input Dialog: opened by the "Input Destination" entry
in the destination menu. Stage one collects a typed location,
stage two lists the matches the geocoder returned for it. -->
<div id="destination-dialog" class="hidden" role="dialog" aria-modal="true"
aria-label="Input destination">
<div class="destination-card">
<div id="destination-form">
<div class="dest-row">
<label class="dest-label" for="dest-state">State/region</label>
<input type="text" id="dest-state" placeholder="Keyboard Input"
autocomplete="off" spellcheck="false">
</div>
<div class="dest-row">
<label class="dest-label" for="dest-city">City</label>
<input type="text" id="dest-city" placeholder="Keyboard Input"
autocomplete="off" spellcheck="false">
</div>
<div class="dest-row">
<label class="dest-label" for="dest-country">Country</label>
<select id="dest-country">
<option value="">Dropdown Menu</option>
</select>
</div>
<p id="dest-form-message" class="dest-message" role="status"></p>
<div class="dest-actions">
<button id="dest-search" type="button">Search</button>
<button id="dest-cancel" type="button">Cancel</button>
</div>
</div>
<div id="destination-results" class="hidden">
<ul id="dest-result-list" role="listbox" tabindex="0"
aria-label="Matching destinations"></ul>
<p id="dest-results-message" class="dest-message" role="status"></p>
<div class="dest-actions">
<button id="dest-select" type="button">Select</button>
<button id="dest-results-cancel" type="button">Cancel</button>
</div>
</div>
</div>
</div>
<!-- 3D Canvas Container -->
<div id="canvas-container"></div>
<!-- HUD Overlay -->
<div id="hud" class="hidden">
<div id="hud-heading">
<span id="hud-heading-value">N 000</span>
</div>
<div id="hud-speed">
<div class="hud-label">SPD</div>
<div id="hud-speed-value">0</div>
<div class="hud-unit">km/h</div>
</div>
<div id="hud-altitude">
<div class="hud-label">ALT</div>
<div id="hud-altitude-value">0</div>
<div class="hud-unit">m</div>
</div>
<div id="hud-crosshair">+</div>
<div id="hud-pitch">
<span id="hud-pitch-value">0</span>
</div>
</div>
<!-- Click to Fly Prompt -->
<div id="fly-prompt" class="hidden">
<div class="prompt-text">Click to Fly</div>
<div class="prompt-hint" id="fly-prompt-hint"></div>
</div>
<!-- Controls Help -->
<div id="controls-help" class="hidden">
<div>W/S: Speed &middot; <span id="controls-help-look">Mouse: Look</span> &middot; A/D: Roll</div>
<div>Q/E: Yaw &middot; R/F: Alt &middot; Space: Level &middot; Shift: Boost &middot; P: Pause</div>
</div>
<!-- Static Controls List -->
<div id="controls-list" class="hidden">
<div class="controls-title">Controls</div>
<ul>
<li><span>W / S</span>Throttle</li>
<li><span id="controls-list-look">Mouse</span>Look</li>
<li><span>A / D</span>Roll</li>
<li><span>Q / E</span>Yaw</li>
<li><span>R / F</span>Climb / Descend</li>
<li><span>Space</span>Auto-level</li>
<li><span>Shift</span>Boost</li>
<li><span>P</span>Pause</li>
</ul>
</div>
<!-- Agent Status: what the host reports its agent is working on. Stays
hidden until a host sends a status, so the VS Code panel and the
standalone page never show it. -->
<div id="agent-status" class="hidden">
<span id="agent-status-dot"></span>
<span id="agent-status-text"></span>
<span id="agent-status-tokens"></span>
</div>
<!-- Pause Indicator -->
<div id="pause-indicator" class="hidden">Paused</div>
<!-- Loading Overlay -->
<div id="loading-overlay" class="hidden">
<div id="loading-text">Loading terrain...</div>
<div id="loading-bar-container">
<div id="loading-bar"></div>
</div>
<div id="loading-progress">0%</div>
</div>
<!-- Welcome Screen -->
<div id="welcome">
<h1>3D Flight Simulator</h1>
<p>Satellite Terrain Viewer - World Capitals</p>
<p class="welcome-hint">Select a capital city to begin</p>
</div>
<script src="vendor/three.min.js"></script>
<script src="hostBridge.js"></script>
<script src="capitals.js"></script>
<script src="geocoder.js"></script>
<script src="destinationInput.js"></script>
<script src="flightControls.js"></script>
<script src="terrainFill.js"></script>
<script src="renderConfig.js"></script>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,269 @@
/***************************************************************************
* RenderConfig - Loads, applies, and exports map rendering configuration. *
* *
* Inside the VS Code panel the extension host reads renderMap.json and *
* injects it before the page scripts run. As a standalone page the *
* config is fetched from renderMap.json in the project root. Either way *
* the values are merged over the defaults and applied to the live *
* simulation. *
* *
* Export hands the current configuration to the extension host, which *
* writes it through a save dialog. In the browser it falls back to a *
* plain file download. *
**************************************************************************/
var RenderConfig = (function () {
var defaults = {
map: {
width: 1920,
height: 1080,
tileZoom: 15,
tileSize: 100,
loadRadius: 6,
unloadRadius: 9,
startAltitude: 200,
fogDensity: 0.00018,
radiusFeather: 3,
maxTiles: 600
},
filler: {
sampleInterval: 45,
updateIntervalSec: 10,
perimeter: { patchCount: 10, patchSize: 10 },
center: { patchCount: 5, patchSize: 10 },
padding: { patchCount: 4, patchSize: 5 }
},
cloud: {
enabled: true,
opacity: 0.35,
speed: 0.0004,
coverage: 0.5,
scale: 0.008
}
};
var config = JSON.parse(JSON.stringify(defaults));
var listeners = [];
/**
* Deep merge source into target, preserving existing keys.
*/
function merge(target, source) {
for (var key in source) {
if (!source.hasOwnProperty(key)) continue;
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
function freshDefaults() {
return JSON.parse(JSON.stringify(defaults));
}
/**
* Load configuration, merged over defaults. Prefers the config the
* extension host injected; otherwise reads renderMap.json from the
* project root relative to this page.
*/
function load(callback) {
var injected = HostBridge.getInjectedConfig();
if (injected) {
config = merge(freshDefaults(), injected);
if (callback) callback(config);
return;
}
var xhr = new XMLHttpRequest();
xhr.open('GET', '../renderMap.json', true);
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
if (xhr.status === 200) {
try {
var data = JSON.parse(xhr.responseText);
config = merge(freshDefaults(), data);
} catch (e) {
console.warn('RenderConfig: invalid JSON, using defaults');
config = freshDefaults();
}
} else {
console.warn('RenderConfig: could not load renderMap.json, using defaults');
config = freshDefaults();
}
if (callback) callback(config);
};
xhr.send();
}
/**
* Get the full config object.
*/
function get() {
return config;
}
/**
* Set a config value by dot-notation path (e.g. "cloud.opacity").
*/
function set(path, value) {
var keys = path.split('.');
var obj = config;
for (var i = 0; i < keys.length - 1; i++) {
if (!obj[keys[i]]) obj[keys[i]] = {};
obj = obj[keys[i]];
}
obj[keys[keys.length - 1]] = value;
apply();
notify(path, value);
}
/**
* Subscribe to config changes.
*/
function onChange(fn) {
listeners.push(fn);
}
function notify(path, value) {
for (var i = 0; i < listeners.length; i++) {
listeners[i](path, value, config);
}
}
/**
* Apply current config values to the live simulation globals.
*/
function apply() {
// Map globals
TILE_ZOOM = config.map.tileZoom;
TILE_SIZE = config.map.tileSize;
LOAD_RADIUS = config.map.loadRadius;
UNLOAD_RADIUS = Math.max(config.map.unloadRadius, LOAD_RADIUS + 3);
START_ALTITUDE = config.map.startAltitude;
FOG_DENSITY = config.map.fogDensity;
RADIUS_FEATHER = config.map.radiusFeather;
MAX_TILES = config.map.maxTiles;
// Apply fog density to scene
if (typeof scene !== 'undefined' && scene && scene.fog) {
scene.fog.density = config.map.fogDensity;
}
// Apply render resolution. CSS stretches the canvas buffer over
// the panel, so a buffer shaped differently from the panel would
// render distorted. Keep the configured width x height as the
// pixel budget and reshape it to the panel's aspect ratio, so
// resizing the panel changes how much is in frame rather than
// how the frame is squashed.
if (typeof renderer !== 'undefined' && renderer &&
typeof camera !== 'undefined' && camera &&
config.map.width > 0 && config.map.height > 0) {
var aspect = (window.innerWidth > 0 && window.innerHeight > 0)
? window.innerWidth / window.innerHeight
: config.map.width / config.map.height;
var pixelBudget = config.map.width * config.map.height;
var height = Math.max(1, Math.round(Math.sqrt(pixelBudget / aspect)));
var width = Math.max(1, Math.round(height * aspect));
renderer.setSize(width, height, false);
camera.aspect = aspect;
camera.updateProjectionMatrix();
}
// Apply filler settings to TerrainFill instance
if (typeof terrainFill !== 'undefined' && terrainFill) {
terrainFill.sampleInterval = config.filler.sampleInterval;
terrainFill.updateIntervalSec = config.filler.updateIntervalSec;
terrainFill.perimeterPatchCount = config.filler.perimeter.patchCount;
terrainFill.perimeterPatchSize = config.filler.perimeter.patchSize;
terrainFill.centerPatchCount = config.filler.center.patchCount;
terrainFill.centerPatchSize = config.filler.center.patchSize;
terrainFill.paddingPatchCount = config.filler.padding.patchCount;
terrainFill.paddingPatchSize = config.filler.padding.patchSize;
}
// Apply cloud settings
if (typeof terrainFill !== 'undefined' && terrainFill && terrainFill.cloudOverlay) {
terrainFill.cloudOverlay.enabled = config.cloud.enabled;
terrainFill.cloudOverlay.opacity = config.cloud.opacity;
terrainFill.cloudOverlay.speed = config.cloud.speed;
terrainFill.cloudOverlay.coverage = config.cloud.coverage;
terrainFill.cloudOverlay.scale = config.cloud.scale;
}
}
/**
* Export the current configuration as renderMap.json. The extension
* host owns the file write so the save lands in the workspace; a
* standalone page falls back to a browser download.
*/
function exportJSON() {
var json = JSON.stringify(config, null, 4);
if (HostBridge.isHosted()) {
HostBridge.post({ command: 'exportConfig', json: json });
return;
}
var blob = new Blob([json], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'renderMap.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Populate slider/input elements from the current config and bind change events.
*/
function bindSliders() {
var sliders = document.querySelectorAll('[data-config]');
for (var i = 0; i < sliders.length; i++) {
(function (el) {
var path = el.getAttribute('data-config');
var keys = path.split('.');
var val = config;
for (var k = 0; k < keys.length; k++) {
val = val[keys[k]];
}
el.value = val;
var display = document.getElementById(el.id + '-val');
if (display) display.textContent = val;
el.addEventListener('input', function () {
var v = parseFloat(el.value);
set(path, v);
if (display) display.textContent = v;
});
})(sliders[i]);
}
// Cloud enabled checkbox
var cloudToggle = document.getElementById('cfg-cloud-enabled');
if (cloudToggle) {
cloudToggle.checked = config.cloud.enabled;
cloudToggle.addEventListener('change', function () {
set('cloud.enabled', cloudToggle.checked);
});
}
}
return {
load: load,
get: get,
set: set,
apply: apply,
exportJSON: exportJSON,
bindSliders: bindSliders,
onChange: onChange
};
})();
@@ -0,0 +1,846 @@
/* ============================================================
3D Flight Simulator - Styles
============================================================ */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
background: #000;
color: #e0e0e0;
overflow: hidden;
width: 100vw;
height: 100vh;
}
/* ---- Canvas Container ---- */
#canvas-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
#canvas-container canvas {
display: block;
width: 100%;
height: 100%;
}
/* ---- Top Bar ---- */
/*
The selector panel and the location readout share one row. The bar
mirrors the selector panel's measured width as right padding so the
readout reads as centered in the panel rather than in the space left
over beside the menu. When the panel is too narrow for both, app.js
adds .stacked and the readout takes its own row underneath.
--selector-width and --top-bar-height are published by app.js.
*/
#top-bar {
position: fixed;
top: 0;
left: 0;
width: auto;
z-index: 100;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 12px;
padding-right: calc(12px + var(--selector-width, 0px));
pointer-events: none;
}
#top-bar.stacked {
padding-right: 12px;
}
/* ---- Capital Selector ---- */
#selector-panel {
flex: 0 1 auto;
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
background: rgba(0, 0, 0, 0.65);
padding: 8px 14px;
border-radius: 6px;
border: 1px solid rgba(100, 200, 255, 0.2);
backdrop-filter: blur(8px);
pointer-events: auto;
}
#selector-panel label {
font-size: 0.75rem;
color: #88bbdd;
text-transform: uppercase;
letter-spacing: 1px;
}
#capital-select {
padding: 5px 10px;
font-size: 0.85rem;
font-family: inherit;
border: 1px solid rgba(100, 200, 255, 0.3);
border-radius: 3px;
background: rgba(10, 20, 40, 0.9);
color: #c0e0f0;
cursor: pointer;
flex: 1 1 auto;
min-width: 140px;
max-width: 100%;
outline: none;
}
#capital-select:focus {
border-color: rgba(100, 200, 255, 0.6);
}
#capital-select optgroup {
color: #66aacc;
font-weight: bold;
}
#capital-select option {
color: #c0e0f0;
background: #0a1428;
}
/* ---- HUD Overlay ---- */
#hud {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 50;
pointer-events: none;
}
/* Location display (lives in #top-bar; the flex basis below is mirrored
by LOCATION_MIN_WIDTH in app.js, which decides when to stack) */
#hud-location {
/* flex: 1 1 240px; */
min-width: 0;
text-align: center;
}
/* Both lines sit over bright satellite imagery, so they carry a hard
1px drop shadow rather than a blurred glow. A blur radius spreads the
dark halo through the glyphs themselves and softens the strokes, which
is what made the coordinates unreadable against a pale sky. */
#hud-capital {
font-size: calc(1rem + 2pt);
color: rgb(235, 246, 255);
font-weight: bold;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* white-space: pre keeps the two spaces that separate the latitude from
the longitude; normal whitespace handling would collapse them to one */
#hud-coords {
font-size: calc(0.72rem + 2pt);
color: rgb(214, 234, 250);
margin-top: 2px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85);
overflow: hidden;
text-overflow: ellipsis;
white-space: pre;
}
/* Heading: sits under the top bar at whatever height it settled on */
#hud-heading {
position: absolute;
top: calc(var(--top-bar-height, 56px) + 6px);
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.35);
padding: 3px 16px;
border-radius: 3px;
border: 1px solid rgba(100, 200, 255, 0.15);
}
#hud-heading-value {
font-size: 0.9rem;
color: rgba(100, 220, 255, 0.85);
text-shadow: 0 0 6px rgba(0, 100, 200, 0.3);
}
/* Speed (left side) */
#hud-speed {
position: absolute;
left: 30px;
top: 50%;
transform: translateY(-50%);
text-align: center;
background: rgba(0, 0, 0, 0.35);
padding: 10px 14px;
border-radius: 4px;
border: 1px solid rgba(100, 200, 255, 0.15);
}
#hud-speed .hud-label {
font-size: 0.65rem;
color: rgba(100, 200, 255, 0.5);
letter-spacing: 2px;
margin-bottom: 4px;
}
#hud-speed-value {
font-size: 1.6rem;
color: rgba(100, 255, 180, 0.9);
font-weight: bold;
text-shadow: 0 0 10px rgba(0, 200, 100, 0.3);
}
#hud-speed .hud-unit {
font-size: 0.6rem;
color: rgba(100, 200, 255, 0.4);
margin-top: 2px;
}
/* Altitude (right side) */
#hud-altitude {
position: absolute;
right: 30px;
top: 50%;
transform: translateY(-50%);
text-align: center;
background: rgba(0, 0, 0, 0.35);
padding: 10px 14px;
border-radius: 4px;
border: 1px solid rgba(100, 200, 255, 0.15);
}
#hud-altitude .hud-label {
font-size: 0.65rem;
color: rgba(100, 200, 255, 0.5);
letter-spacing: 2px;
margin-bottom: 4px;
}
#hud-altitude-value {
font-size: 1.6rem;
color: rgba(255, 200, 100, 0.9);
font-weight: bold;
text-shadow: 0 0 10px rgba(200, 150, 0, 0.3);
}
#hud-altitude .hud-unit {
font-size: 0.6rem;
color: rgba(100, 200, 255, 0.4);
margin-top: 2px;
}
/* Crosshair */
#hud-crosshair {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.5rem;
color: rgba(200, 230, 255, 0.4);
text-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
line-height: 1;
}
/* Pitch indicator */
#hud-pitch {
position: absolute;
right: 30px;
top: 50%;
transform: translateY(80px);
text-align: center;
}
#hud-pitch-value {
font-size: 0.75rem;
color: rgba(180, 200, 255, 0.6);
}
/* ---- Click to Fly Prompt ---- */
#fly-prompt {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 80;
text-align: center;
pointer-events: none;
}
#fly-prompt .prompt-text {
font-size: 1.8rem;
color: rgba(180, 220, 255, 0.9);
text-shadow: 0 0 20px rgba(50, 150, 255, 0.5);
animation: pulse 2s ease-in-out infinite;
letter-spacing: 2px;
text-transform: uppercase;
}
#fly-prompt .prompt-hint {
margin-top: 8px;
font-size: 0.72rem;
letter-spacing: 1px;
color: rgba(120, 170, 210, 0.6);
}
@keyframes pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
/* ---- Controls Help ---- */
#controls-help {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 80;
text-align: center;
background: rgba(0, 0, 0, 0.5);
padding: 8px 20px;
border-radius: 6px;
border: 1px solid rgba(100, 200, 255, 0.15);
pointer-events: none;
font-size: 0.72rem;
color: rgba(160, 200, 230, 0.8);
line-height: 1.6;
transition: opacity 2s ease;
}
#controls-help.fade-out {
opacity: 0;
}
/* ---- Static Controls List ---- */
#controls-list {
position: fixed;
left: 12px;
bottom: 20px;
z-index: 60;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(100, 200, 255, 0.1);
border-radius: 4px;
padding: 8px 12px;
pointer-events: none;
font-size: 0.62rem;
color: rgba(150, 190, 220, 0.6);
}
#controls-list .controls-title {
font-size: 0.58rem;
color: rgba(120, 170, 210, 0.5);
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: 5px;
}
#controls-list ul {
list-style: none;
}
#controls-list li {
line-height: 1.55;
white-space: nowrap;
}
#controls-list li span {
display: inline-block;
width: 52px;
color: rgba(120, 210, 255, 0.7);
}
/* ---- Pause Indicator ---- */
#pause-indicator {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 95;
background: rgba(0, 0, 0, 0.45);
padding: 12px 28px;
border-radius: 8px;
border: 1px solid rgba(255, 220, 120, 0.3);
pointer-events: none;
font-size: 1.6rem;
letter-spacing: 6px;
text-transform: uppercase;
color: rgba(255, 220, 120, 0.9);
text-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
}
/* ---- Loading Overlay ---- */
#loading-overlay {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 90;
text-align: center;
background: rgba(0, 10, 30, 0.85);
padding: 30px 50px;
border-radius: 10px;
border: 1px solid rgba(100, 200, 255, 0.2);
backdrop-filter: blur(10px);
}
#loading-text {
font-size: 1rem;
color: rgba(150, 200, 240, 0.9);
margin-bottom: 14px;
text-transform: uppercase;
letter-spacing: 2px;
}
#loading-bar-container {
width: 240px;
height: 4px;
background: rgba(50, 80, 120, 0.5);
border-radius: 2px;
overflow: hidden;
margin: 0 auto;
}
#loading-bar {
width: 0%;
height: 100%;
background: linear-gradient(90deg, #2288cc, #44ccff);
border-radius: 2px;
transition: width 0.3s ease;
}
#loading-progress {
font-size: 0.8rem;
color: rgba(100, 180, 230, 0.7);
margin-top: 8px;
}
/* ---- Welcome Screen ---- */
#welcome {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 70;
text-align: center;
pointer-events: none;
}
#welcome h1 {
font-size: 2.2rem;
color: rgba(180, 220, 255, 0.9);
font-weight: 300;
letter-spacing: 4px;
text-transform: uppercase;
text-shadow: 0 0 30px rgba(50, 150, 255, 0.3);
margin-bottom: 8px;
}
#welcome p {
font-size: 0.85rem;
color: rgba(130, 170, 210, 0.6);
letter-spacing: 1px;
}
#welcome .welcome-hint {
margin-top: 20px;
font-size: 0.75rem;
color: rgba(100, 150, 200, 0.5);
animation: pulse 3s ease-in-out infinite;
}
/* ---- Agent Status ---- */
/* Clears the controls help that sits at the bottom edge, so both can be
on screen during the five seconds before the help fades */
#agent-status {
position: fixed;
bottom: 64px;
left: 50%;
transform: translateX(-50%);
z-index: 90;
max-width: min(560px, calc(100vw - 24px));
display: flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
border-radius: 20px;
background: rgba(0, 8, 24, 0.8);
border: 1px solid rgba(100, 200, 255, 0.25);
font-size: 0.75rem;
color: rgba(200, 230, 255, 0.9);
pointer-events: none;
}
#agent-status-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: #66ddaa;
animation: pulse 1.6s ease-in-out infinite;
}
/* A finished job leaves the last status readable but stops the pulse */
#agent-status.idle #agent-status-dot {
background: rgba(120, 170, 210, 0.5);
animation: none;
}
#agent-status-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#agent-status-tokens {
flex: 0 0 auto;
color: rgba(120, 200, 255, 0.65);
}
#agent-status-tokens:empty {
display: none;
}
/* ---- Utility ---- */
.hidden {
display: none !important;
}
/* ---- Destination Input Dialog ---- */
/* The "Input Destination" entry is the only action in the menu, so it
is set apart from the capitals listed under it. */
#opt-input-destination {
background: #e8edf1;
color: #16283f;
font-weight: bold;
}
/* Sits above the top bar so the dialog is never partly behind the menu,
and covers the canvas so a click cannot engage flight behind it. The
card hangs from just under the bar rather than centering, so it stays
next to the menu entry that opened it. */
#destination-dialog {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 24px;
padding-top: calc(var(--top-bar-height, 56px) + 16px);
background: rgba(4, 12, 24, 0.45);
}
.destination-card {
width: 100%;
max-width: 400px;
max-height: calc(100vh - var(--top-bar-height, 56px) - 40px);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
border-radius: 14px;
background: rgba(238, 242, 245, 0.97);
border: 1px solid rgba(120, 160, 200, 0.45);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
}
#destination-form,
#destination-results {
display: flex;
flex-direction: column;
gap: 10px;
}
.dest-row {
display: flex;
align-items: center;
gap: 10px;
}
/* Fixed basis keeps the three fields on one left edge regardless of
how wide the individual label text is */
.dest-label {
flex: 0 0 92px;
padding: 8px 10px;
border-radius: 8px;
background: #ccd4da;
color: #1c2b3d;
font-size: 0.7rem;
letter-spacing: 0.5px;
text-align: center;
}
.dest-row input,
.dest-row select {
flex: 1 1 auto;
min-width: 0;
padding: 9px 12px;
font-family: inherit;
font-size: 0.9rem;
border-radius: 8px;
border: 1px solid rgba(80, 130, 180, 0.35);
background: #16283f;
color: #dceaf6;
outline: none;
}
.dest-row input::placeholder {
color: rgba(190, 215, 235, 0.55);
}
.dest-row input:focus,
.dest-row select:focus {
border-color: rgba(120, 200, 255, 0.85);
}
.dest-row select option {
background: #16283f;
color: #dceaf6;
}
#dest-result-list {
list-style: none;
max-height: 210px;
overflow-y: auto;
padding: 6px 4px;
border-radius: 10px;
border: 1px solid rgba(80, 130, 180, 0.35);
background: #16283f;
outline: none;
}
#dest-result-list:focus {
border-color: rgba(120, 200, 255, 0.85);
}
/* The dashed rule between rows is a bottom border on every row but the
last, so the list reads as separated entries rather than a paragraph */
.dest-result {
padding: 9px 10px;
border-radius: 6px;
color: #dceaf6;
font-size: 0.85rem;
cursor: pointer;
border-bottom: 1px dashed rgba(140, 185, 225, 0.4);
}
.dest-result:last-child {
border-bottom: none;
}
.dest-result:hover {
background: rgba(90, 140, 190, 0.25);
}
.dest-result.active {
background: rgba(110, 165, 215, 0.45);
color: #ffffff;
}
.dest-message {
min-height: 1em;
font-size: 0.72rem;
color: #7a1f1f;
}
#dest-results-message {
color: #33465c;
}
.dest-actions {
display: flex;
gap: 12px;
}
.dest-actions button {
flex: 1 1 0;
padding: 10px 12px;
font-family: inherit;
font-size: 0.85rem;
border-radius: 8px;
border: 1px solid rgba(80, 130, 180, 0.35);
background: #16283f;
color: #dceaf6;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.dest-actions button:hover:not(:disabled) {
background: #21395a;
border-color: rgba(120, 200, 255, 0.7);
}
.dest-actions button:disabled {
opacity: 0.5;
cursor: default;
}
/* ---- Selector Panel Buttons ---- */
#selector-panel button {
padding: 5px 12px;
font-size: 0.75rem;
font-family: inherit;
border: 1px solid rgba(100, 200, 255, 0.3);
border-radius: 3px;
background: rgba(10, 20, 40, 0.9);
color: #c0e0f0;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: border-color 0.2s, background 0.2s;
}
#selector-panel button:hover {
border-color: rgba(100, 200, 255, 0.6);
background: rgba(20, 40, 70, 0.9);
}
#btn-export-json {
color: #88ddaa !important;
border-color: rgba(100, 255, 160, 0.3) !important;
}
#btn-export-json:hover {
border-color: rgba(100, 255, 160, 0.6) !important;
}
/* ---- Render Config Panel ---- */
#config-panel {
position: fixed;
top: calc(var(--top-bar-height, 56px) + 12px);
left: 12px;
z-index: 100;
width: 320px;
max-width: calc(100vw - 24px);
max-height: calc(100vh - var(--top-bar-height, 56px) - 36px);
overflow-y: auto;
background: rgba(0, 8, 24, 0.88);
padding: 14px 16px;
border-radius: 6px;
border: 1px solid rgba(100, 200, 255, 0.2);
backdrop-filter: blur(10px);
font-size: 0.72rem;
}
#config-panel h3 {
font-size: 0.85rem;
color: rgba(180, 220, 255, 0.9);
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 4px;
}
#config-panel .config-hint {
font-size: 0.65rem;
color: rgba(120, 170, 210, 0.6);
margin-bottom: 12px;
line-height: 1.4;
}
#config-panel fieldset {
border: 1px solid rgba(100, 200, 255, 0.12);
border-radius: 4px;
padding: 8px 10px;
margin-bottom: 10px;
}
#config-panel legend {
font-size: 0.7rem;
color: #66aacc;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
padding: 0 6px;
}
.cfg-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.cfg-row label {
flex: 0 0 100px;
font-size: 0.68rem;
color: rgba(160, 200, 230, 0.8);
text-align: right;
}
.cfg-row input[type="range"] {
flex: 1;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(50, 80, 120, 0.5);
border-radius: 2px;
outline: none;
}
.cfg-row input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #44ccff;
cursor: pointer;
border: 1px solid rgba(0, 0, 0, 0.3);
}
.cfg-row input[type="range"]::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #44ccff;
cursor: pointer;
border: 1px solid rgba(0, 0, 0, 0.3);
}
.cfg-row input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: #44ccff;
}
.cfg-row span {
flex: 0 0 50px;
font-size: 0.65rem;
color: rgba(100, 220, 255, 0.85);
text-align: right;
font-variant-numeric: tabular-nums;
}
/* Scrollbar has styling for config panel */
#config-panel::-webkit-scrollbar {
width: 4px;
}
#config-panel::-webkit-scrollbar-track {
background: transparent;
}
#config-panel::-webkit-scrollbar-thumb {
background: rgba(100, 200, 255, 0.2);
border-radius: 2px;
}
@@ -0,0 +1,441 @@
/************************************************************************************
* TerrainFill - Fills unloaded terrain gaps using sampled colors *
* from the rendered viewport. *
* *
* Algorithm: *
* 1. Render scene (without fill) to a low-res offscreen target *
* 2. Sample 10x10px patches from each of the 4 viewport borders (40 samples) *
* 3. Sample 10x10px patches from the middle area (5 samples) *
* 4. Sample 5x5px patches from padding perimeter in flight direction (4 samples) *
* 5. Filter out placeholder/sky/water colors *
* 6. Generate a fill texture by blending sampled colors with value noise *
* 7. Apply the texture to a large fill plane between tiles and water *
***********************************************************************************/
function TerrainFill(renderer, scene, camera) {
this.renderer = renderer;
this.scene = scene;
this.camera = camera;
// Low-res render target for viewport sampling
this.sampleW = 256;
this.sampleH = 256;
this.sampleTarget = new THREE.WebGLRenderTarget(this.sampleW, this.sampleH);
this.sampleBuffer = new Uint8Array(this.sampleW * this.sampleH * 4);
// Fill texture canvas
this.texW = 256;
this.texH = 256;
this.fillCanvas = document.createElement('canvas');
this.fillCanvas.width = this.texW;
this.fillCanvas.height = this.texH;
this.fillCtx = this.fillCanvas.getContext('2d');
// Initialize with placeholder color
this.fillCtx.fillStyle = '#2a3a2a';
this.fillCtx.fillRect(0, 0, this.texW, this.texH);
// Three.js canvas texture for the fill plane
this.fillTexture = new THREE.CanvasTexture(this.fillCanvas);
this.fillTexture.wrapS = THREE.RepeatWrapping;
this.fillTexture.wrapT = THREE.RepeatWrapping;
this.fillTexture.repeat.set(50, 50);
// Fill plane mesh sitting between tile meshes (y=0) and water (y=-2)
var geo = new THREE.PlaneGeometry(30000, 30000);
var mat = new THREE.MeshBasicMaterial({ map: this.fillTexture });
this.fillMesh = new THREE.Mesh(geo, mat);
this.fillMesh.rotation.x = -Math.PI / 2;
this.fillMesh.position.y = -0.5;
scene.add(this.fillMesh);
// Collected sample colors (array of {r,g,b} objects)
this.sampleColors = [];
// Timing
this.frameCount = 0;
this.sampleInterval = 45; // Sample every N frames
// Time-based filler pattern update interval (seconds)
this.updateIntervalSec = 10;
this.lastUpdateTime = 0;
// Configurable patch counts and sizes (overridden by RenderConfig)
this.perimeterPatchCount = 10;
this.perimeterPatchSize = 10;
this.centerPatchCount = 5;
this.centerPatchSize = 10;
this.paddingPatchCount = 4;
this.paddingPatchSize = 5;
// Cloud overlay effect applied to the fill area
this.cloudOverlay = {
enabled: true,
opacity: 0.35,
speed: 0.0004,
coverage: 0.5,
scale: 0.008,
time: 0
};
// Cloud canvas and texture (rendered on top of fill plane)
this.cloudCanvas = document.createElement('canvas');
this.cloudCanvas.width = this.texW;
this.cloudCanvas.height = this.texH;
this.cloudCtx = this.cloudCanvas.getContext('2d');
this.cloudTexture = new THREE.CanvasTexture(this.cloudCanvas);
this.cloudTexture.wrapS = THREE.RepeatWrapping;
this.cloudTexture.wrapT = THREE.RepeatWrapping;
this.cloudTexture.repeat.set(50, 50);
var cloudGeo = new THREE.PlaneGeometry(30000, 30000);
var cloudMat = new THREE.MeshBasicMaterial({
map: this.cloudTexture,
transparent: true,
depthWrite: false,
opacity: 1.0
});
this.cloudMesh = new THREE.Mesh(cloudGeo, cloudMat);
this.cloudMesh.rotation.x = -Math.PI / 2;
this.cloudMesh.position.y = -0.3;
scene.add(this.cloudMesh);
// Pre-compute noise permutation table
this.perm = new Array(512);
var p = new Array(256);
for (var i = 0; i < 256; i++) p[i] = i;
for (var i = 255; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = p[i]; p[i] = p[j]; p[j] = tmp;
}
for (var i = 0; i < 512; i++) this.perm[i] = p[i & 255];
// Second permutation for cloud noise (different seed)
this.cloudPerm = new Array(512);
var cp = new Array(256);
for (var i = 0; i < 256; i++) cp[i] = i;
for (var i = 255; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = cp[i]; cp[i] = cp[j]; cp[j] = tmp;
}
for (var i = 0; i < 512; i++) this.cloudPerm[i] = cp[i & 255];
// Pre-compute noise map (noise values won't change, only color mapping does)
this.noiseMap = new Float32Array(this.texW * this.texH);
for (var y = 0; y < this.texH; y++) {
for (var x = 0; x < this.texW; x++) {
var n1 = this._noise2d(x * 0.012, y * 0.012);
var n2 = this._noise2d(x * 0.035 + 97.3, y * 0.035 + 97.3) * 0.5;
var n3 = this._noise2d(x * 0.08 + 231.7, y * 0.08 + 231.7) * 0.25;
this.noiseMap[y * this.texW + x] = (n1 + n2 + n3) / 1.75;
}
}
}
/**
* Called each frame after main render. Periodically samples the viewport
* and regenerates the fill texture.
*/
TerrainFill.prototype.update = function (flightHeading) {
// Keep fill plane centered on camera
this.fillMesh.position.x = this.camera.position.x;
this.fillMesh.position.z = this.camera.position.z;
// Keep cloud mesh centered on camera
this.cloudMesh.position.x = this.camera.position.x;
this.cloudMesh.position.z = this.camera.position.z;
this.frameCount++;
if (this.frameCount % this.sampleInterval !== 0) return;
// Collect viewport samples every sampleInterval frames
this._collectSamples(flightHeading);
// Regenerate fill texture on a time-based interval (seconds)
var now = performance.now() / 1000;
if (this.sampleColors.length > 20 && (now - this.lastUpdateTime) >= this.updateIntervalSec) {
this._generateFillTexture();
this.lastUpdateTime = now;
}
// Update cloud effect
this._updateCloud();
};
/**
* Samples colors from the rendered viewport at border, middle,
* and padding perimeter positions.
*/
TerrainFill.prototype._collectSamples = function (flightHeading) {
// Hide generated overlays so we only sample actual terrain + sky
var cloudWasVisible = this.cloudMesh.visible;
this.fillMesh.visible = false;
this.cloudMesh.visible = false;
// Render scene to low-res offscreen target
this.renderer.setRenderTarget(this.sampleTarget);
this.renderer.render(this.scene, this.camera);
this.renderer.setRenderTarget(null);
this.fillMesh.visible = true;
this.cloudMesh.visible = cloudWasVisible;
// Read pixels from the render target
this.renderer.readRenderTargetPixels(
this.sampleTarget, 0, 0,
this.sampleW, this.sampleH,
this.sampleBuffer
);
var w = this.sampleW;
var h = this.sampleH;
var buf = this.sampleBuffer;
var self = this;
this.sampleColors = [];
// Helper: extract valid (non-placeholder/sky/water) colors from a patch
function extractPatch(px, py, size) {
var result = [];
for (var dy = 0; dy < size; dy++) {
for (var dx = 0; dx < size; dx++) {
var sx = Math.max(0, Math.min(px + dx, w - 1));
var sy = Math.max(0, Math.min(py + dy, h - 1));
var idx = (sy * w + sx) * 4;
var r = buf[idx], g = buf[idx + 1], b = buf[idx + 2];
if (!self._isSkipColor(r, g, b)) {
result.push({ r: r, g: g, b: b });
}
}
}
return result;
}
var borderDepth = Math.max(Math.floor(h * 0.08), 8);
var patchSize = this.perimeterPatchSize;
var smallPatch = this.paddingPatchSize;
var perimCount = this.perimeterPatchCount;
var centerCount = this.centerPatchCount;
var centerSize = this.centerPatchSize;
var padCount = this.paddingPatchCount;
// ---- Border samples: perimCount per border ----
// Top border
for (var i = 0; i < perimCount; i++) {
var x = Math.floor(Math.random() * (w - patchSize));
var y = Math.floor(Math.random() * borderDepth);
this.sampleColors = this.sampleColors.concat(extractPatch(x, y, patchSize));
}
// Bottom border
for (var i = 0; i < perimCount; i++) {
var x = Math.floor(Math.random() * (w - patchSize));
var y = h - borderDepth + Math.floor(Math.random() * Math.max(1, borderDepth - patchSize));
this.sampleColors = this.sampleColors.concat(extractPatch(x, y, patchSize));
}
// Left border
for (var i = 0; i < perimCount; i++) {
var x = Math.floor(Math.random() * borderDepth);
var y = Math.floor(Math.random() * (h - patchSize));
this.sampleColors = this.sampleColors.concat(extractPatch(x, y, patchSize));
}
// Right border
for (var i = 0; i < perimCount; i++) {
var x = w - borderDepth + Math.floor(Math.random() * Math.max(1, borderDepth - patchSize));
var y = Math.floor(Math.random() * (h - patchSize));
this.sampleColors = this.sampleColors.concat(extractPatch(x, y, patchSize));
}
// ---- Middle area: centerCount samples ----
var midRange = Math.min(w, h) / 4;
for (var i = 0; i < centerCount; i++) {
var x = Math.floor(w / 2 - midRange / 2 + Math.random() * midRange);
var y = Math.floor(h / 2 - midRange / 2 + Math.random() * midRange);
this.sampleColors = this.sampleColors.concat(extractPatch(x, y, centerSize));
}
// ---- Padding perimeter: padCount samples toward flight direction ----
var headRad = ((flightHeading || 0)) * Math.PI / 180;
var dirX = Math.sin(headRad);
var dirY = -Math.cos(headRad);
for (var i = 0; i < padCount; i++) {
var dist = 0.25 + Math.random() * 0.35;
var spread = (Math.random() - 0.5) * 0.4;
var sx = Math.floor(w / 2 + (dirX * dist + dirY * spread) * w * 0.45);
var sy = Math.floor(h / 2 + (dirY * dist - dirX * spread) * h * 0.45);
sx = Math.max(0, Math.min(sx, w - smallPatch));
sy = Math.max(0, Math.min(sy, h - smallPatch));
this.sampleColors = this.sampleColors.concat(extractPatch(sx, sy, smallPatch));
}
};
/**
* Checks whether a pixel color should be skipped during sampling
* (placeholder tiles, sky, water, or black).
*/
TerrainFill.prototype._isSkipColor = function (r, g, b) {
// Placeholder dark green: #2a3a2a (42, 58, 42)
if (Math.abs(r - 42) < 20 && Math.abs(g - 58) < 20 && Math.abs(b - 42) < 20) return true;
// Sky blue: #87CEEB (135, 206, 235)
if (Math.abs(r - 135) < 35 && Math.abs(g - 206) < 35 && Math.abs(b - 235) < 35) return true;
// Water blue: #1a4a7a (26, 74, 122)
if (Math.abs(r - 26) < 25 && Math.abs(g - 74) < 25 && Math.abs(b - 122) < 25) return true;
// Near-black
if (r < 8 && g < 8 && b < 8) return true;
// Fill plane color itself (dark placeholder)
if (r < 50 && g < 65 && b < 50 && Math.abs(r - b) < 10) return true;
return false;
};
/**
* Generates the fill texture by mapping sampled colors through
* a pre-computed noise map. Produces a natural-looking blend of
* terrain colors that fills gaps between loaded tiles.
*/
TerrainFill.prototype._generateFillTexture = function () {
var colors = this.sampleColors;
if (colors.length < 10) return;
var ctx = this.fillCtx;
var w = this.texW;
var h = this.texH;
var imgData = ctx.createImageData(w, h);
var data = imgData.data;
var numColors = colors.length;
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
// Look up pre-computed noise value (0-1)
var noise = this.noiseMap[y * w + x];
// Map noise to color index range and blend between neighbors
var fidx = noise * (numColors - 1);
var idx1 = Math.floor(fidx);
var idx2 = Math.min(idx1 + 1, numColors - 1);
var blend = fidx - idx1;
var c1 = colors[idx1];
var c2 = colors[idx2];
var px = (y * w + x) * 4;
data[px] = Math.round(c1.r + (c2.r - c1.r) * blend);
data[px + 1] = Math.round(c1.g + (c2.g - c1.g) * blend);
data[px + 2] = Math.round(c1.b + (c2.b - c1.b) * blend);
data[px + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
this.fillTexture.needsUpdate = true;
};
/**
* 2D value noise using the pre-computed permutation table.
*/
TerrainFill.prototype._noise2d = function (x, y) {
var xi = Math.floor(x) & 255;
var yi = Math.floor(y) & 255;
var xf = x - Math.floor(x);
var yf = y - Math.floor(y);
// Smoothstep fade curves
var u = xf * xf * (3 - 2 * xf);
var v = yf * yf * (3 - 2 * yf);
// Hash four corners
var aa = this.perm[this.perm[xi] + yi] / 255;
var ab = this.perm[this.perm[xi] + yi + 1] / 255;
var ba = this.perm[this.perm[xi + 1] + yi] / 255;
var bb = this.perm[this.perm[xi + 1] + yi + 1] / 255;
// Bilinear interpolation
var x1 = aa * (1 - u) + ba * u;
var x2 = ab * (1 - u) + bb * u;
return x1 * (1 - v) + x2 * v;
};
/**
* Generates animated cloud overlay texture using time-offset noise.
* The cloud effect applies a semi-transparent white layer over the
* fill area, simulating drifting cloud cover.
*/
TerrainFill.prototype._updateCloud = function () {
if (!this.cloudOverlay.enabled) {
this.cloudMesh.visible = false;
return;
}
this.cloudMesh.visible = true;
this.cloudOverlay.time += this.cloudOverlay.speed * this.sampleInterval;
var ctx = this.cloudCtx;
var w = this.texW;
var h = this.texH;
var imgData = ctx.createImageData(w, h);
var data = imgData.data;
var t = this.cloudOverlay.time;
var scale = this.cloudOverlay.scale;
var coverage = this.cloudOverlay.coverage;
var opacity = this.cloudOverlay.opacity;
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
// Multi-octave noise with time offset for drift
var n1 = this._cloudNoise2d(x * scale + t, y * scale + t * 0.7);
var n2 = this._cloudNoise2d(x * scale * 2.3 + 50 + t * 1.3, y * scale * 2.3 + 50 + t * 0.9) * 0.5;
var n3 = this._cloudNoise2d(x * scale * 5.1 + 120 - t * 0.5, y * scale * 5.1 + 120 + t * 0.3) * 0.25;
var noise = (n1 + n2 + n3) / 1.75;
// Apply coverage threshold: lower coverage = fewer clouds
var cloudDensity = Math.max(0, noise - (1 - coverage)) / coverage;
cloudDensity = Math.min(1, cloudDensity);
// Smooth edges
cloudDensity = cloudDensity * cloudDensity * (3 - 2 * cloudDensity);
var alpha = Math.round(cloudDensity * opacity * 255);
var px = (y * w + x) * 4;
data[px] = 255; // White clouds
data[px + 1] = 255;
data[px + 2] = 255;
data[px + 3] = alpha;
}
}
ctx.putImageData(imgData, 0, 0);
this.cloudTexture.needsUpdate = true;
};
/**
* 2D value noise using the cloud-specific permutation table.
*/
TerrainFill.prototype._cloudNoise2d = function (x, y) {
var xi = Math.floor(x) & 255;
var yi = Math.floor(y) & 255;
var xf = x - Math.floor(x);
var yf = y - Math.floor(y);
var u = xf * xf * (3 - 2 * xf);
var v = yf * yf * (3 - 2 * yf);
var aa = this.cloudPerm[this.cloudPerm[xi] + yi] / 255;
var ab = this.cloudPerm[this.cloudPerm[xi] + yi + 1] / 255;
var ba = this.cloudPerm[this.cloudPerm[xi + 1] + yi] / 255;
var bb = this.cloudPerm[this.cloudPerm[xi + 1] + yi + 1] / 255;
var x1 = aa * (1 - u) + ba * u;
var x2 = ab * (1 - u) + bb * u;
return x1 * (1 - v) + x2 * v;
};
/**
* Reset fill state when switching capitals.
*/
TerrainFill.prototype.reset = function () {
this.sampleColors = [];
this.frameCount = 0;
this.fillCtx.fillStyle = '#2a3a2a';
this.fillCtx.fillRect(0, 0, this.texW, this.texH);
this.fillTexture.needsUpdate = true;
this.cloudOverlay.time = 0;
this.cloudCtx.clearRect(0, 0, this.texW, this.texH);
this.cloudTexture.needsUpdate = true;
};
File diff suppressed because one or more lines are too long
+475
View File
@@ -0,0 +1,475 @@
{
"name": "flight-map-canvas",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flight-map-canvas",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@github/copilot-sdk": "latest"
}
},
"node_modules/@github/copilot": {
"version": "1.0.75",
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.75.tgz",
"integrity": "sha512-rn7ZQmhydCZ9XRdG6V78QEhXIdYlChlUvOVAtyJ6KHJGt2O9/71si7PCt84amfmg0N7IXBT2UGRYF4GQDQBt+g==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"detect-libc": "^2.1.2"
},
"bin": {
"copilot": "npm-loader.js"
},
"optionalDependencies": {
"@github/copilot-darwin-arm64": "1.0.75",
"@github/copilot-darwin-x64": "1.0.75",
"@github/copilot-linux-arm64": "1.0.75",
"@github/copilot-linux-x64": "1.0.75",
"@github/copilot-linuxmusl-arm64": "1.0.75",
"@github/copilot-linuxmusl-x64": "1.0.75",
"@github/copilot-win32-arm64": "1.0.75",
"@github/copilot-win32-x64": "1.0.75"
}
},
"node_modules/@github/copilot-darwin-arm64": {
"version": "1.0.75",
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.75.tgz",
"integrity": "sha512-avt2ganQwtycGkqz9z8JpARoEz2Fzi02O+WitbB8I8iF3jfvEGpQ8k3z9oSCPAAO0rIMBeSl05xEUUV5RJwfRA==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
],
"bin": {
"copilot-darwin-arm64": "copilot"
}
},
"node_modules/@github/copilot-darwin-x64": {
"version": "1.0.75",
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.75.tgz",
"integrity": "sha512-eyM5cz83d9h05m6cnnG7tIHR4lSHHXUnxXeQm4rU3nGB1uwHWOa1TH9B4uAaW3zKQgkCkNgm+IlcPJ7geM0KBg==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
],
"bin": {
"copilot-darwin-x64": "copilot"
}
},
"node_modules/@github/copilot-linux-arm64": {
"version": "1.0.75",
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.75.tgz",
"integrity": "sha512-PuGjPCy+z7DrD0rkcXQRBzoA8lVxD1WC6UeZxkZaIb9asueE798eqHHMV169zYiA3gsiSegIKu5J/aUeWwhpuQ==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linux-arm64": "copilot"
}
},
"node_modules/@github/copilot-linux-x64": {
"version": "1.0.75",
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.75.tgz",
"integrity": "sha512-aGDtUrNyj/ubqLfXOomL4XYZR//1Cs4tDGe7r/464e3Nw4WlNxWPjBbNyOCcUPUMbPHXck6TyxjEvBwwlKCK5w==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linux-x64": "copilot"
}
},
"node_modules/@github/copilot-linuxmusl-arm64": {
"version": "1.0.75",
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.75.tgz",
"integrity": "sha512-4cbjjR/laL+4a0VjMkzjIsprRUxiAHAefWVTppFitKiqNxfgyRuOhfSUPUA5TVxPwfyYXknsxeYSNRL8zu9YDA==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linuxmusl-arm64": "copilot"
}
},
"node_modules/@github/copilot-linuxmusl-x64": {
"version": "1.0.75",
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.75.tgz",
"integrity": "sha512-I4oFPNOULMGxWghGCG/VkiT0a62UiL4XGKSlp6X0rVD9xyoYyiLGlsBmTumrooPbzEvgKICYWIz8PCZjrZTyhA==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linuxmusl-x64": "copilot"
}
},
"node_modules/@github/copilot-sdk": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.8.tgz",
"integrity": "sha512-dbahVsyt2aX8qqtOOtmYNe40MnvzSvOSHYFFgoFK7gHZSTNz9QgOht8b1sCCJlcXaFAn/w+5qNc7CwWoCjpQ0g==",
"license": "MIT",
"dependencies": {
"@github/copilot": "^1.0.73",
"koffi": "^3.1.0",
"vscode-jsonrpc": "^8.2.1",
"zod": "^4.3.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@github/copilot-win32-arm64": {
"version": "1.0.75",
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.75.tgz",
"integrity": "sha512-wlLDuor+Qm1H43VwZNi03QI4H9FSyZoQzE/YHpGhUay7sKA+4cDJF8ZZNGrtYkyPskfH5HCdNygUn7EEt6lRyQ==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
],
"bin": {
"copilot-win32-arm64": "copilot.exe"
}
},
"node_modules/@github/copilot-win32-x64": {
"version": "1.0.75",
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.75.tgz",
"integrity": "sha512-TKISaaILkgcOi7ThaTnln1XoasbUIx+HKSb+pf678RvGucIfLohQyLceuq+rTykoynX54KQ0pf9TEUbMVkUNAA==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
],
"bin": {
"copilot-win32-x64": "copilot.exe"
}
},
"node_modules/@koromix/koffi-darwin-arm64": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.2.tgz",
"integrity": "sha512-32pU4pNZABIz+l9DNJl51Y+jur4vv+SF4Ip2CSF4OUg1xUyefoLpX0NttDmzGITIrneUEVSEN+dT22524ESKBw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-darwin-x64": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.2.tgz",
"integrity": "sha512-S+H6LQgUoMj77BqDegwlRaxwLXDfwvSJGuceOqtH0I5V8rzKLmu/hC7NBlxOoAlvKlcV63FtdNiE2E9YSltffg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-freebsd-arm64": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.2.tgz",
"integrity": "sha512-fD0ow2PBE60nw7K6xcbala6qwXxfcYeU62tduNeIPvx0KoWhU2rMKZiDNe+iI5TQb3rxYYjjP+aF2Sdm9y6EXQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-freebsd-ia32": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.2.tgz",
"integrity": "sha512-t8OmL+hoJGDLZDnuLjgLemSYrXX99M7Md+zJX8bMHOtiNbFtkGXn/mV21Pb1ik9JhBXjwK1r4hvBPNlqTMGrHg==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-freebsd-x64": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.2.tgz",
"integrity": "sha512-axbLgiM4Y2vyDOTqlXCI8vkg9wqjwSRsmoWXSKreA5YFJwnYA6Sc4aHMz+qZgUSfFei52Qrv1RGhDyo4kHvqhA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-arm64": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.2.tgz",
"integrity": "sha512-f0hqAIlFcL9wlRGJ/uCfyfspqnGaASk2gLx1UAP3RBgMQl68D1e+fiHNdXa7g9d76ttmpA8/PGNAqc1X4Byy1Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-ia32": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.2.tgz",
"integrity": "sha512-UGLPuqeOV/UArsK6oeB5yI/XjSWkFqFlBTC9rUbezBuHJhSibk1EMv7QC0cvtDMu18bo+ucqXWPzh42oT5yYlw==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-loong64": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.2.tgz",
"integrity": "sha512-jI0+gM2oDsJ7reOt3XPyO7lyQtZ1CT6NR2uqGQcQVM43cyXBAVYYCUxEH3LHCbgumFaZ+LueIUgbMSwb9pHBxQ==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-riscv64": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.2.tgz",
"integrity": "sha512-yB99adXBRd5T+xXG+f6nnUkC3jCI0iXvPU6RqD9Kx7aZP4Y4NNUWJ5Q4FaP9jb1XmZLY4pGBUiHt8u03Yl7NyA==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-x64": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.2.tgz",
"integrity": "sha512-Oxvo6F3Edzy/Jm2EtbHWkJ2xRB0mXDAe63k5+USL5uiGE5xZjwEUDOBKIhv2BpCZSOAJrfoojFFogj6+ICKQhw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-openbsd-ia32": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.2.tgz",
"integrity": "sha512-SSWzUhL8Ex84JTsO67+MdWZrdwgOzoOrQ0+ZbB+UsivHoAxmWLHKWZaSafNqyBZtxGY1EgtR8AIPouWE9U+Zfw==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-openbsd-x64": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.2.tgz",
"integrity": "sha512-0ZuI4St7chq3M0d3VivvKIqacZ7RhgohdR476V3HpJkaNdfIywsJIw+GBvqkQahu+4A2Rpu6yQJpWSrfk/Z+Jw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-win32-arm64": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.2.tgz",
"integrity": "sha512-8Wn6phw7y53uI52+aBPAqEfZ5pj/HCjg/YtdthqSWYHy+d0MhyASKlcmuP0B5raxQnnA1Bm9LC8UO3M3RojeBw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-win32-ia32": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.2.tgz",
"integrity": "sha512-FkKaPBMawgHMNnp1FwLldXMNvEa139GXkxPi9JD9xU71Kh/ZmuEYHGSD6JwZDmDr4jekVrBrr+eGZ+j6C2mkXg==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-win32-x64": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.2.tgz",
"integrity": "sha512-FeFC59UU1XX4J3ZaqKrsrEzczzB5qksMJo7/R45vIg8mGNVSLMVE85JRiZpjcp9i5Lbav5Vw47QvwFzBgIfvlw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/koffi": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.2.tgz",
"integrity": "sha512-wVwuE21TBl8/si6E0hPorKR2PJ2q33mEWVETANrtSp3kFM8fi2FcD/J5wmxu0T4TBcqmMQ4xKuF1X1ayFmphzw==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"url": "https://liberapay.com/Koromix"
},
"optionalDependencies": {
"@koromix/koffi-darwin-arm64": "3.1.2",
"@koromix/koffi-darwin-x64": "3.1.2",
"@koromix/koffi-freebsd-arm64": "3.1.2",
"@koromix/koffi-freebsd-ia32": "3.1.2",
"@koromix/koffi-freebsd-x64": "3.1.2",
"@koromix/koffi-linux-arm64": "3.1.2",
"@koromix/koffi-linux-ia32": "3.1.2",
"@koromix/koffi-linux-loong64": "3.1.2",
"@koromix/koffi-linux-riscv64": "3.1.2",
"@koromix/koffi-linux-x64": "3.1.2",
"@koromix/koffi-openbsd-ia32": "3.1.2",
"@koromix/koffi-openbsd-x64": "3.1.2",
"@koromix/koffi-win32-arm64": "3.1.2",
"@koromix/koffi-win32-ia32": "3.1.2",
"@koromix/koffi-win32-x64": "3.1.2"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
"integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "flight-map-canvas",
"version": "1.0.0",
"main": "extension.mjs",
"author": "John Haugabook",
"license": "MIT",
"type": "module",
"dependencies": {
"@github/copilot-sdk": "latest"
},
"description": "A GitHub Copilot canvas that generates a view where Google Maps can be explored using 3D controls, as if a flight simulator. Agents can send the flight anywhere and report what they are working on.",
"keywords": [
"copilot-canvas",
"flight-simulator",
"geography",
"google-maps",
"interactive-canvas",
"session-breaks",
"threejs"
]
}
@@ -0,0 +1,37 @@
{
"map": {
"width": 2210,
"height": 1290,
"tileZoom": 15,
"tileSize": 100,
"loadRadius": 25,
"unloadRadius": 9,
"startAltitude": 200,
"fogDensity": 0.00092,
"radiusFeather": 28.5,
"maxTiles": 600
},
"filler": {
"sampleInterval": 55,
"updateIntervalSec": 15,
"perimeter": {
"patchCount": 15,
"patchSize": 15
},
"center": {
"patchCount": 4,
"patchSize": 7
},
"padding": {
"patchCount": 6,
"patchSize": 9
}
},
"cloud": {
"enabled": true,
"opacity": 0.35,
"speed": 0.0004,
"coverage": 0.5,
"scale": 0.008
}
}