mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-01 23:12:29 +00:00
c46b4a919a
* 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>
315 lines
11 KiB
JavaScript
315 lines
11 KiB
JavaScript
/************************************************************************
|
|
* 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
|
|
};
|
|
})();
|