Files
awesome-copilot/extensions/flight-map-canvas/game/destinationInput.js
T
John Haugabook c46b4a919a 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>
2026-07-30 13:32:55 +10:00

311 lines
11 KiB
JavaScript

/***********************************************************************
* 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; }
};
})();