mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' of github.com:bilawalsidhu/gods-eye-view
This commit is contained in:
commit
2be9e00522
17 files changed
+472
-25
No files matched your search
@@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat
|
|||||||
|
|
||||||
## ⚡ Quick Start
|
## ⚡ Quick Start
|
||||||
|
|
||||||
**Start without an account or API keys.** Both paths open the same app with
|
**Start without an account or API keys.** Every path opens the same app with
|
||||||
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
|
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
|
||||||
unreachable. Flights, military traffic, satellites, earthquakes, public
|
unreachable. Flights, military traffic, satellites, earthquakes, public
|
||||||
cameras, radio, and launches are available without keys.
|
cameras, radio, and launches are available without keys.
|
||||||
@@ -233,6 +233,41 @@ the container.
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### Path 4 — GitHub Codespaces (cloud, no local install)
|
||||||
|
|
||||||
|
Runs the same dev server in a cloud workspace, so Node and the dependencies
|
||||||
|
never touch your machine. The globe still renders in your own browser, so it
|
||||||
|
needs the same graphics there as any other path. GitHub Free includes 120
|
||||||
|
core-hours a month with no card on file — about 60 hours on the default 2-core
|
||||||
|
machine. Org and Enterprise plans include no free quota; current terms and
|
||||||
|
quotas apply.
|
||||||
|
|
||||||
|
1. Fork this repository, then open **your fork** on GitHub.
|
||||||
|
2. Click the green **Code** button, the **Codespaces** tab, then **Create codespace on main**.
|
||||||
|
3. In the codespace terminal:
|
||||||
|
```bash
|
||||||
|
nvm install 24 && nvm use 24
|
||||||
|
npm ci
|
||||||
|
npm run doctor
|
||||||
|
GEV_ALLOWED_HOSTS="$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" npm run dev
|
||||||
|
```
|
||||||
|
4. Open the **Ports** tab, find `4173`, and click the globe icon to open it in
|
||||||
|
the browser. The port is private by default, so the URL asks for your GitHub
|
||||||
|
login. Keep it private: a public port lets anyone use the app and the keys
|
||||||
|
in your `.env`.
|
||||||
|
|
||||||
|
`GEV_ALLOWED_HOSTS` there is required, not optional. The dev server answers only
|
||||||
|
the hostnames it trusts, and a forwarded port arrives on
|
||||||
|
`$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN`. Without it the
|
||||||
|
page is `Blocked request. This host is not allowed.` Leading-dot wildcards are
|
||||||
|
ignored on purpose, so pass that exact name.
|
||||||
|
|
||||||
|
⚠️ **A codespace is reachable from the internet.** Treat it as the
|
||||||
|
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
|
||||||
|
Settings does not open on the forwarded URL, so put keys in the workspace
|
||||||
|
`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the
|
||||||
|
threat model.
|
||||||
|
|
||||||
### Then power it up — in the app, not in a file
|
### Then power it up — in the app, not in a file
|
||||||
|
|
||||||
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
|
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Keep every submarine-cable segment associated with its source feature after
|
||||||
|
Cesium expands multipart geometry, correcting segment colors, click destinations
|
||||||
|
and horizon culling while retaining one reference stem per feature
|
||||||
|
(@Aliciawque, #1093).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- The military-installations proxy (`/api/military-installations`) now refuses
|
||||||
|
cross-site and reverse-proxy-forwarded browser requests with 403 before any
|
||||||
|
Overpass work, through the same shared gate as `/api/overpass`. Same-origin
|
||||||
|
app requests and local tools such as curl still work (@Sunil56224972, #1427).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Between 721px and 900px wide (tablet portrait, phone landscape), the
|
||||||
|
Location and Visual Presets dock wings show only their icons, so their
|
||||||
|
labels no longer spill over the voice control or take taps meant for the
|
||||||
|
mic button. The full name stays in the tooltip and accessible name, and the
|
||||||
|
Cyber layout keeps its labels (Denis Groshev, @ZloyS, #1457).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Leave layer aliases the layer id already spells, such as "dams" for
|
||||||
|
`local-dams`, out of the voice layer hint. The Realtime prompt is 305
|
||||||
|
characters shorter, leaving room under the 52,000 gate for new
|
||||||
|
voice-toggleable layers; every alias is still accepted (@quanticsoul4772,
|
||||||
|
#1462).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Document GitHub Codespaces as a fourth setup path in the README: a cloud
|
||||||
|
workspace runs the dev server while the globe renders in your own browser
|
||||||
|
(John Leshan, @johnleshan, #980).
|
||||||
@@ -2796,6 +2796,20 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only
|
|||||||
> name the panel (`#location-bar` / `#control-panel`) to reach (2,5,0). Adding
|
> name the panel (`#location-bar` / `#control-panel`) to reach (2,5,0). Adding
|
||||||
> a new tray rule means checking it against the pinned variants, not just
|
> a new tray rule means checking it against the pinned variants, not just
|
||||||
> against the base rule.
|
> against the base rule.
|
||||||
|
> - **Dock wings are icon-only from 900px down to 721px (2026-10-28):** at
|
||||||
|
> ≤900px `--dock-side-compact` shrinks the Location and Visual Presets wings
|
||||||
|
> to 4.25rem, open or closed (an open tray pops out above the dock), which is
|
||||||
|
> too narrow for their text labels. The labels used to spill over the voice
|
||||||
|
> control, and the LOCATION label captured taps on the left half of the mic
|
||||||
|
> button. `src/ui/styles/command-dock-trays.css` now sets the
|
||||||
|
> labels to `font-size: 0` and drops their `›`/`‹` chevrons in that band, so
|
||||||
|
> each wing shows only its icon. `CommandDockView.vue` still gives the toggles
|
||||||
|
> their "Expand/Collapse …" `aria-label` and `title` (`trayWords`), so the
|
||||||
|
> browser tooltip is the only visible name on hover.
|
||||||
|
> The Cyber HUD layout is excluded: it sets `--dock-side-compact` to
|
||||||
|
> 9–9.75rem, which fits its labels. The former ≤620px ellipsis clip is gone:
|
||||||
|
> the phone shell takes over at 720px, so it never applied to a visible
|
||||||
|
> dock.
|
||||||
> - **LOCATION mini-status is data-only today (2026-08-20):** the collapsed
|
> - **LOCATION mini-status is data-only today (2026-08-20):** the collapsed
|
||||||
> readout now follows a free-text geocode search as well as preset pills
|
> readout now follows a free-text geocode search as well as preset pills
|
||||||
> (`src/locationStatus.js` owns the copy for both), and every other camera
|
> (`src/locationStatus.js` owns the copy for both), and every other camera
|
||||||
@@ -5086,6 +5100,8 @@ repointing `OVERPASS_UPSTREAMS` refetches instead of serving another instance's
|
|||||||
answers; untagged entries stop serving once an instance is configured.
|
answers; untagged entries stop serving once an instance is configured.
|
||||||
|
|
||||||
The shared transport guards `/api/overpass` and `/api/military-installations`.
|
The shared transport guards `/api/overpass` and `/api/military-installations`.
|
||||||
|
Both query handlers refuse cross-site and forwarded browser requests through
|
||||||
|
the shared local-request gate, before any rate-limit, cache or upstream work.
|
||||||
With no configured upstream, each route serves existing cache data (including
|
With no configured upstream, each route serves existing cache data (including
|
||||||
expired entries, marked stale with the original cache/retrieval date) without
|
expired entries, marked stale with the original cache/retrieval date) without
|
||||||
refresh. An annotation miss returns HTTP 503, `OVERPASS_NOT_CONFIGURED`,
|
refresh. An annotation miss returns HTTP 503, `OVERPASS_NOT_CONFIGURED`,
|
||||||
@@ -6373,6 +6389,12 @@ three Cesium data sources; enabling rebuilds from the accepted parsed cache,
|
|||||||
and destroying clears it. Load ownership prevents cancelled work from adding
|
and destroying clears it. Load ownership prevents cancelled work from adding
|
||||||
entities after teardown.
|
entities after teardown.
|
||||||
|
|
||||||
|
Every Cesium entity from multipart cable or landing-point geometry stays tied
|
||||||
|
to its source feature, including generated IDs and IDs that collide with
|
||||||
|
Cesium's multipart suffixes. Each segment gets its own feature's styling, click
|
||||||
|
destination and horizon-culling reference, and each source feature has one
|
||||||
|
reference stem. The source collections are not modified.
|
||||||
|
|
||||||
Natural Earth regions and neighborhood polygon lookup are package exports.
|
Natural Earth regions and neighborhood polygon lookup are package exports.
|
||||||
Their existing lazy loaders, retry behavior, bundled datasets and attribution
|
Their existing lazy loaders, retry behavior, bundled datasets and attribution
|
||||||
are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by
|
are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by
|
||||||
|
|||||||
@@ -432,7 +432,9 @@
|
|||||||
"server/providers/overpass/transport.js",
|
"server/providers/overpass/transport.js",
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/sources/rateLimit.js"
|
"src/sources/rateLimit.js",
|
||||||
|
"server/providers/common/same-site.js",
|
||||||
|
"src/localRequestGate.mjs"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||||
|
import { admitSameSite } from './common/same-site.js';
|
||||||
import {
|
import {
|
||||||
MILITARY_INSTALLATION_ELEMENT_CAP,
|
MILITARY_INSTALLATION_ELEMENT_CAP,
|
||||||
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
|
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
|
||||||
@@ -97,6 +98,7 @@ function militaryInstallationsProxy() {
|
|||||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (admitSameSite(req, res)) return;
|
||||||
const url = new URL(req.url, 'http://localhost');
|
const url = new URL(req.url, 'http://localhost');
|
||||||
const requested = validMilitaryInstallationBox(url.searchParams);
|
const requested = validMilitaryInstallationBox(url.searchParams);
|
||||||
if (!requested) {
|
if (!requested) {
|
||||||
|
|||||||
@@ -1754,3 +1754,252 @@ test('another GeoJSON source renders with its own label and rebuilds from accept
|
|||||||
env.layer.destroy(env.viewer);
|
env.layer.destroy(env.viewer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function multipartCableFixture() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {
|
||||||
|
id: 'alpha',
|
||||||
|
name: 'Alpha cable',
|
||||||
|
color: '#ff0000',
|
||||||
|
coordinates: [-40, 35],
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'MultiLineString',
|
||||||
|
coordinates: [
|
||||||
|
[
|
||||||
|
[-40, 35],
|
||||||
|
[-39, 36],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[-38, 37],
|
||||||
|
[-37, 38],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[-36, 39],
|
||||||
|
[-35, 40],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {
|
||||||
|
id: 'beta',
|
||||||
|
name: 'Beta cable',
|
||||||
|
color: '#0000ff',
|
||||||
|
coordinates: [140, -35],
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: [
|
||||||
|
[140, -35],
|
||||||
|
[141, -34],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function geometrySource(cables, landingPoints = []) {
|
||||||
|
return {
|
||||||
|
label: 'Multipart test source',
|
||||||
|
async fetch() {
|
||||||
|
return {
|
||||||
|
cables: { type: 'FeatureCollection', features: cables },
|
||||||
|
landingPoints: { type: 'FeatureCollection', features: landingPoints },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('multipart association accepts frozen provider collections without modifying properties', async () => {
|
||||||
|
const payload = {
|
||||||
|
cables: { type: 'FeatureCollection', features: multipartCableFixture() },
|
||||||
|
landingPoints: {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: multipartCableFixture(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
payload.cables.features[0].properties.__gevSourceFeatureIndex =
|
||||||
|
'provider value';
|
||||||
|
const before = structuredClone(payload);
|
||||||
|
const freeze = (value) => {
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
for (const child of Object.values(value)) freeze(child);
|
||||||
|
Object.freeze(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
freeze(payload);
|
||||||
|
const env = await createRealCableLayerHarness({
|
||||||
|
source: { label: 'Frozen multipart source', fetch: async () => payload },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (const dataSource of env.dataSources.slice(0, 2)) {
|
||||||
|
assert.deepEqual(
|
||||||
|
dataSource.entities.values.map(
|
||||||
|
(entity) => entity.__gevTeleGeography?.label,
|
||||||
|
),
|
||||||
|
['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(env.dataSources[2].entities.values.length, 4);
|
||||||
|
assert.deepEqual(payload, before);
|
||||||
|
const entity = env.dataSources[0].entities.values[0];
|
||||||
|
assert.equal(entity.properties.__gevSourceFeatureIndex.getValue(), 0);
|
||||||
|
assert.match(entity.description.getValue(), /__gevSourceFeatureIndex/);
|
||||||
|
} finally {
|
||||||
|
env.layer.destroy(env.viewer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multipart cables retain their own styling, picking and culling on every segment', async () => {
|
||||||
|
const features = multipartCableFixture();
|
||||||
|
const before = JSON.stringify(features);
|
||||||
|
const env = await createRealCableLayerHarness({
|
||||||
|
source: geometrySource(features),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const entities = env.dataSources[0].entities.values;
|
||||||
|
assert.equal(entities.length, 4);
|
||||||
|
for (const [index, entity] of entities.entries()) {
|
||||||
|
const feature = features[index < 3 ? 0 : 1];
|
||||||
|
const info = entity.__gevTeleGeography;
|
||||||
|
assert.equal(info?.label, feature.properties.name, entity.id);
|
||||||
|
assert.deepEqual(info.reference, {
|
||||||
|
lon: feature.properties.coordinates[0],
|
||||||
|
lat: feature.properties.coordinates[1],
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
entity.polyline.material.color.getValue(),
|
||||||
|
Cesium.Color.fromCssColorString(feature.properties.color).withAlpha(
|
||||||
|
0.92,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(entity.polyline.width.getValue(), 2.5);
|
||||||
|
}
|
||||||
|
env.raiseSweep();
|
||||||
|
assert.deepEqual(
|
||||||
|
entities.map((entity) => entity.show),
|
||||||
|
[true, true, true, false],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
env.dataSources[2].entities.values.length,
|
||||||
|
2,
|
||||||
|
'one reference per feature',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(features),
|
||||||
|
before,
|
||||||
|
'source features stay unchanged',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
env.layer.destroy(env.viewer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multipart association supports missing and repeated IDs without guessing Cesium suffixes', async () => {
|
||||||
|
const features = multipartCableFixture();
|
||||||
|
features[1].id = 'alpha_2';
|
||||||
|
delete features[1].properties.id;
|
||||||
|
const generated = structuredClone(features[1]);
|
||||||
|
delete generated.id;
|
||||||
|
generated.properties.name = 'Generated cable';
|
||||||
|
features.push(generated);
|
||||||
|
const repeated = structuredClone(features[1]);
|
||||||
|
repeated.properties.id = 'alpha';
|
||||||
|
repeated.properties.name = 'Repeated ID cable';
|
||||||
|
features.push(repeated);
|
||||||
|
const env = await createRealCableLayerHarness({
|
||||||
|
source: geometrySource(features),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const entities = env.dataSources[0].entities.values;
|
||||||
|
assert.deepEqual(
|
||||||
|
entities.map((entity) => entity.id),
|
||||||
|
['alpha', 'alpha_2', 'alpha_3', 'alpha_2_2', 'cable-2', 'alpha_4'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
entities.map((entity) => entity.__gevTeleGeography?.label),
|
||||||
|
[
|
||||||
|
'Alpha cable',
|
||||||
|
'Alpha cable',
|
||||||
|
'Alpha cable',
|
||||||
|
'Beta cable',
|
||||||
|
'Generated cable',
|
||||||
|
'Repeated ID cable',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
env.dataSources[2].entities.values.map(
|
||||||
|
(entity) => entity.__gevTeleGeography.featureId,
|
||||||
|
),
|
||||||
|
['alpha', 'alpha_2', 'cable-2', 'alpha'],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
env.layer.destroy(env.viewer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multipart landing geometry keeps every pick with one reference per feature', async () => {
|
||||||
|
const features = multipartCableFixture();
|
||||||
|
const env = await createRealCableLayerHarness({
|
||||||
|
source: geometrySource([], features),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const entities = env.dataSources[1].entities.values;
|
||||||
|
assert.deepEqual(
|
||||||
|
entities.map((entity) => entity.__gevTeleGeography?.label),
|
||||||
|
['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'],
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
entities.every(
|
||||||
|
(entity) => entity.__gevTeleGeography.kind === 'landing-point',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(env.dataSources[2].entities.values.length, 2);
|
||||||
|
} finally {
|
||||||
|
env.layer.destroy(env.viewer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every bundled cable segment is associated with its source feature', async () => {
|
||||||
|
const { features } = JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
new URL(
|
||||||
|
'./local_data/telegeography_submarine_cables/cable-geo.json',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Some bundled features share a cable ID but have different references.
|
||||||
|
const expandedFeatures = features.flatMap((feature) =>
|
||||||
|
feature.geometry.coordinates.map(() => feature),
|
||||||
|
);
|
||||||
|
assert.ok(expandedFeatures.length > features.length);
|
||||||
|
const env = await createRealCableLayerHarness({
|
||||||
|
source: geometrySource(features),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const entities = env.dataSources[0].entities.values;
|
||||||
|
assert.equal(entities.length, expandedFeatures.length);
|
||||||
|
for (const [index, entity] of entities.entries()) {
|
||||||
|
const feature = expandedFeatures[index];
|
||||||
|
assert.equal(
|
||||||
|
entity.__gevTeleGeography?.label,
|
||||||
|
feature.properties.name,
|
||||||
|
entity.id,
|
||||||
|
);
|
||||||
|
assert.deepEqual(entity.__gevTeleGeography.reference, {
|
||||||
|
lon: feature.properties.coordinates[0],
|
||||||
|
lat: feature.properties.coordinates[1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.equal(env.dataSources[2].entities.values.length, features.length);
|
||||||
|
} finally {
|
||||||
|
env.layer.destroy(env.viewer);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1029,10 +1029,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
|||||||
// Panel adds timeout and named voice/provider targets; consolidated guidance
|
// Panel adds timeout and named voice/provider targets; consolidated guidance
|
||||||
// preserves the mission mapping and globe-rotation arguments.
|
// preserves the mission mapping and globe-rotation arguments.
|
||||||
// S2 GhostMaps extends the generated layer enums; both feature contracts remain.
|
// S2 GhostMaps extends the generated layer enums; both feature contracts remain.
|
||||||
assert.equal(block.length, 31206, 'serialized tool schema length drifted');
|
// The layer alias hint leaves out aliases the layer id already spells.
|
||||||
|
assert.equal(block.length, 30901, 'serialized tool schema length drifted');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
crypto.createHash('sha256').update(block).digest('hex'),
|
crypto.createHash('sha256').update(block).digest('hex'),
|
||||||
'f772df95404494bf492e02fde1beabffdfe61986940bffd0cf9ad5a38d768746',
|
'9e1bd8fde648953c47718ba248758415a95c1484b0da6369df6b89b0505c0de2',
|
||||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||||
);
|
);
|
||||||
const instructions = fs.readFileSync(
|
const instructions = fs.readFileSync(
|
||||||
|
|||||||
@@ -6,6 +6,21 @@ import {
|
|||||||
} from './geometry.js';
|
} from './geometry.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
|
const SOURCE_FEATURE_INDEX = '__gevSourceFeatureIndex';
|
||||||
|
|
||||||
|
function indexedCollection(features) {
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: features.map((feature, index) => ({
|
||||||
|
...feature,
|
||||||
|
properties: {
|
||||||
|
...feature.properties,
|
||||||
|
[SOURCE_FEATURE_INDEX]: index,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createIngestion({ state, parts, source }) {
|
export function createIngestion({ state, parts, source }) {
|
||||||
async function load(viewer) {
|
async function load(viewer) {
|
||||||
if (state._loading || state._loaded) return;
|
if (state._loading || state._loaded) return;
|
||||||
@@ -49,7 +64,7 @@ export function createIngestion({ state, parts, source }) {
|
|||||||
const landingFeatures = normalizeFeatures(landingJson, 'landing');
|
const landingFeatures = normalizeFeatures(landingJson, 'landing');
|
||||||
|
|
||||||
const cableDataSource = await Cesium.GeoJsonDataSource.load(
|
const cableDataSource = await Cesium.GeoJsonDataSource.load(
|
||||||
{ type: 'FeatureCollection', features: cableFeatures },
|
indexedCollection(cableFeatures),
|
||||||
{
|
{
|
||||||
clampToGround: true,
|
clampToGround: true,
|
||||||
stroke: state.cableColor.withAlpha(0.95),
|
stroke: state.cableColor.withAlpha(0.95),
|
||||||
@@ -61,7 +76,7 @@ export function createIngestion({ state, parts, source }) {
|
|||||||
);
|
);
|
||||||
if (!owns()) return;
|
if (!owns()) return;
|
||||||
const landingDataSource = await Cesium.GeoJsonDataSource.load(
|
const landingDataSource = await Cesium.GeoJsonDataSource.load(
|
||||||
{ type: 'FeatureCollection', features: landingFeatures },
|
indexedCollection(landingFeatures),
|
||||||
{
|
{
|
||||||
clampToGround: true,
|
clampToGround: true,
|
||||||
stroke: state.landingColor.withAlpha(0.9),
|
stroke: state.landingColor.withAlpha(0.9),
|
||||||
@@ -131,7 +146,12 @@ export function createIngestion({ state, parts, source }) {
|
|||||||
state._referenceRecords = [];
|
state._referenceRecords = [];
|
||||||
state._surfaceRecords = [];
|
state._surfaceRecords = [];
|
||||||
|
|
||||||
cableEntities.forEach((entity, index) => {
|
// Cesium expands multipart geometry into several entities and suffixes
|
||||||
|
// colliding IDs. Its copied properties retain this source index even
|
||||||
|
// when a provider has no ID, so every segment keeps the right owner.
|
||||||
|
const cableReferences = new Set();
|
||||||
|
cableEntities.forEach((entity) => {
|
||||||
|
const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue();
|
||||||
const feature = cableFeatures[index];
|
const feature = cableFeatures[index];
|
||||||
const reference = featureReference(feature);
|
const reference = featureReference(feature);
|
||||||
if (!reference) return;
|
if (!reference) return;
|
||||||
@@ -146,6 +166,8 @@ export function createIngestion({ state, parts, source }) {
|
|||||||
entity,
|
entity,
|
||||||
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
|
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
|
||||||
});
|
});
|
||||||
|
if (cableReferences.has(index)) return;
|
||||||
|
cableReferences.add(index);
|
||||||
parts.rendering.addReferenceStem({
|
parts.rendering.addReferenceStem({
|
||||||
reference,
|
reference,
|
||||||
label: featureLabel(feature),
|
label: featureLabel(feature),
|
||||||
@@ -155,7 +177,9 @@ export function createIngestion({ state, parts, source }) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
landingEntities.forEach((entity, index) => {
|
const landingReferences = new Set();
|
||||||
|
landingEntities.forEach((entity) => {
|
||||||
|
const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue();
|
||||||
const feature = landingFeatures[index];
|
const feature = landingFeatures[index];
|
||||||
const reference = featureReference(feature);
|
const reference = featureReference(feature);
|
||||||
if (!reference) return;
|
if (!reference) return;
|
||||||
@@ -170,6 +194,8 @@ export function createIngestion({ state, parts, source }) {
|
|||||||
entity,
|
entity,
|
||||||
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
|
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
|
||||||
});
|
});
|
||||||
|
if (landingReferences.has(index)) return;
|
||||||
|
landingReferences.add(index);
|
||||||
parts.rendering.addReferenceStem({
|
parts.rendering.addReferenceStem({
|
||||||
reference,
|
reference,
|
||||||
label: featureLabel(feature),
|
label: featureLabel(feature),
|
||||||
|
|||||||
@@ -54,14 +54,22 @@ async function call(handlers, url, init = {}) {
|
|||||||
Object.assign(req, {
|
Object.assign(req, {
|
||||||
method: init.method || 'GET',
|
method: init.method || 'GET',
|
||||||
url: parsed.pathname.slice(mount.length) + parsed.search || '/',
|
url: parsed.pathname.slice(mount.length) + parsed.search || '/',
|
||||||
headers: {},
|
headers: init.headers || {},
|
||||||
socket: { remoteAddress: '127.0.0.1' },
|
socket: { remoteAddress: '127.0.0.1' },
|
||||||
});
|
});
|
||||||
let status, headers, body;
|
let status, body;
|
||||||
|
let headers = {};
|
||||||
await handlers.get(mount)(req, {
|
await handlers.get(mount)(req, {
|
||||||
|
// The cross-site gate answers through statusCode/setHeader, not writeHead.
|
||||||
|
set statusCode(s) {
|
||||||
|
status = s;
|
||||||
|
},
|
||||||
|
setHeader(name, value) {
|
||||||
|
headers[name] = value;
|
||||||
|
},
|
||||||
writeHead(s, h) {
|
writeHead(s, h) {
|
||||||
status = s;
|
status = s;
|
||||||
headers = h;
|
headers = { ...headers, ...h };
|
||||||
},
|
},
|
||||||
end(b) {
|
end(b) {
|
||||||
body = b;
|
body = b;
|
||||||
@@ -166,6 +174,36 @@ test('zero egress: every Overpass consumer reaches real default handlers, and re
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the installations proxy refuses cross-site and forwarded requests before any Overpass work', async (t) => {
|
||||||
|
env(t);
|
||||||
|
t.mock.method(globalThis, 'fetch', () =>
|
||||||
|
assert.fail('unexpected upstream fetch'),
|
||||||
|
);
|
||||||
|
const handlers = routes(militaryInstallationsProxy());
|
||||||
|
const url =
|
||||||
|
'/api/military-installations?south=48.8&west=2.2&north=48.9&east=2.4';
|
||||||
|
for (const headers of [
|
||||||
|
{ 'sec-fetch-site': 'cross-site' },
|
||||||
|
{ 'sec-fetch-site': 'same-site' },
|
||||||
|
{ origin: 'https://foreign.example' },
|
||||||
|
{ origin: 'null' },
|
||||||
|
{ 'sec-fetch-site': 'same-origin', 'x-forwarded-for': '203.0.113.9' },
|
||||||
|
]) {
|
||||||
|
const response = await call(handlers, url, { headers });
|
||||||
|
assert.equal(response.status, 403, JSON.stringify(headers));
|
||||||
|
assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||||
|
}
|
||||||
|
for (const headers of [
|
||||||
|
{},
|
||||||
|
{ origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' },
|
||||||
|
]) {
|
||||||
|
const response = await call(handlers, url, {
|
||||||
|
headers: { host: 'localhost:4173', ...headers },
|
||||||
|
});
|
||||||
|
assert.notEqual(response.status, 403, JSON.stringify(headers));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('an unconfigured server is probed once and never sent a feature query', async (t) => {
|
test('an unconfigured server is probed once and never sent a feature query', async (t) => {
|
||||||
env(t);
|
env(t);
|
||||||
t.mock.method(globalThis, 'fetch', () =>
|
t.mock.method(globalThis, 'fetch', () =>
|
||||||
|
|||||||
@@ -495,6 +495,36 @@ body:not(.ui-clean-view):not(.recording-mode):has(
|
|||||||
#command-dock .gev-voice-error-tray {
|
#command-dock .gev-voice-error-tray {
|
||||||
width: min(24rem, calc(100vw - 1.5rem));
|
width: min(24rem, calc(100vw - 1.5rem));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The wings shrink to --dock-side-compact at this width, open or closed (an
|
||||||
|
open tray pops out above the dock), which is too narrow for the text
|
||||||
|
labels; they would spill over the voice control and take its taps. The
|
||||||
|
wings show only their icon; the toggles keep their aria-label. Cyber sets
|
||||||
|
a wider --dock-side-compact that fits its labels, so it keeps them. */
|
||||||
|
:root:not([data-ui-theme='cyber'])
|
||||||
|
#command-dock
|
||||||
|
:is(#location-bar, #control-panel)
|
||||||
|
.panel-title,
|
||||||
|
:root:not([data-ui-theme='cyber'])
|
||||||
|
#command-dock
|
||||||
|
:is(#location-bar, #control-panel)
|
||||||
|
.location-toolbar-label {
|
||||||
|
font-size: 0;
|
||||||
|
letter-spacing: 0;
|
||||||
|
/* The empty text is still a flex item; drop the gap it would claim. */
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-ui-theme='cyber'])
|
||||||
|
#command-dock
|
||||||
|
:is(#location-bar, #control-panel)
|
||||||
|
.panel-title::after,
|
||||||
|
:root:not([data-ui-theme='cyber'])
|
||||||
|
#command-dock
|
||||||
|
:is(#location-bar, #control-panel)
|
||||||
|
.location-toolbar-label::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
@@ -503,13 +533,6 @@ body:not(.ui-clean-view):not(.recording-mode):has(
|
|||||||
--dock-voice-width: 8.75rem;
|
--dock-voice-width: 8.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#command-dock .panel-title,
|
|
||||||
#command-dock .location-toolbar-label {
|
|
||||||
max-width: 2.3rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
#left-panel-stack {
|
#left-panel-stack {
|
||||||
bottom: calc(50vh + 8px);
|
bottom: calc(50vh + 8px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
|
|||||||
// is consolidated without changing either feature's arguments.
|
// is consolidated without changing either feature's arguments.
|
||||||
// Snow leaves the set_visual_style enum.
|
// Snow leaves the set_visual_style enum.
|
||||||
// The Operator HUD layout leaves set_hud's layout enum.
|
// The Operator HUD layout leaves set_hud's layout enum.
|
||||||
'f47a7f78f13b1f79978fa524fad2618e32bd813e1a5dfd746bf35d29909fc91b',
|
// The layer alias hint leaves out aliases the layer id already spells.
|
||||||
|
'e65812db60a2702cebb496fca257cb04a28f1bbbf250cb9ab65a3db33005e0c8',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -525,22 +525,31 @@ const SELF_EVIDENT_IDS = new Set([
|
|||||||
'radio',
|
'radio',
|
||||||
'wind',
|
'wind',
|
||||||
'directions',
|
'directions',
|
||||||
// The brand is already the layer id; all spoken aliases remain accepted.
|
|
||||||
's2-ghostmaps',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** Lower case with spaces and hyphens removed: "Data centers" → "datacenters". */
|
||||||
|
const spelled = (text) => text.toLowerCase().replace(/[\s-]+/g, '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact model-facing alias hint: "alias/alias→id; …" for toggleable layers
|
* Compact model-facing alias hint: "alias/alias→id; …" for toggleable layers
|
||||||
* whose id is not already the spoken word. At most two aliases per layer keep
|
* whose id is not already the spoken word. At most two aliases per layer keep
|
||||||
* the tool JSON small; the runner accepts the full alias list.
|
* the tool JSON small; the runner accepts the full alias list. Of those two,
|
||||||
|
* an alias the id already spells ("dams"→local-dams, "weather radar"→
|
||||||
|
* weather-radar) is left out, and a layer with neither left gets no hint.
|
||||||
* @returns {string} One line for the layerId description.
|
* @returns {string} One line for the layerId description.
|
||||||
*/
|
*/
|
||||||
export function voiceLayerAliasHint() {
|
export function voiceLayerAliasHint() {
|
||||||
return VOICE_LAYER_MANIFEST.filter(
|
return VOICE_LAYER_MANIFEST.filter(
|
||||||
(entry) =>
|
(entry) => entry.toggle && !SELF_EVIDENT_IDS.has(entry.id),
|
||||||
entry.toggle && entry.aliases.length && !SELF_EVIDENT_IDS.has(entry.id),
|
|
||||||
)
|
)
|
||||||
.map((entry) => `${entry.aliases.slice(0, 2).join('/')}→${entry.id}`)
|
.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
aliases: entry.aliases
|
||||||
|
.slice(0, 2)
|
||||||
|
.filter((alias) => !spelled(entry.id).includes(spelled(alias))),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.aliases.length)
|
||||||
|
.map((entry) => `${entry.aliases.join('/')}→${entry.id}`)
|
||||||
.join(';');
|
.join(';');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
VOICE_TOGGLE_LAYER_IDS,
|
VOICE_TOGGLE_LAYER_IDS,
|
||||||
VOICE_CONTEXT_LAYER_IDS,
|
VOICE_CONTEXT_LAYER_IDS,
|
||||||
VOICE_QUERY_LAYER_IDS,
|
VOICE_QUERY_LAYER_IDS,
|
||||||
|
voiceLayerAliasHint,
|
||||||
voiceLayerAliasMap,
|
voiceLayerAliasMap,
|
||||||
} from './layerManifest.js';
|
} from './layerManifest.js';
|
||||||
|
|
||||||
@@ -78,6 +79,23 @@ test('tool enums are generated from the manifest', () => {
|
|||||||
assert.ok(panels.includes('weather-panel') && panels.includes('recent-imagery-panel'));
|
assert.ok(panels.includes('weather-panel') && panels.includes('recent-imagery-panel'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the alias hint leaves out names the layer id already spells', () => {
|
||||||
|
const hint = voiceLayerAliasHint();
|
||||||
|
const entries = hint.split(';').map((entry) => entry.split('→'));
|
||||||
|
const flat = (text) => text.toLowerCase().replace(/[\s-]+/g, '');
|
||||||
|
for (const [aliases, id] of entries) {
|
||||||
|
for (const alias of aliases.split('/')) {
|
||||||
|
assert.ok(!flat(id).includes(flat(alias)), `"${alias}" is already spelled by ${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ids = entries.map(([, id]) => id);
|
||||||
|
assert.ok(!ids.includes('local-dams'), 'a layer left with no alias gets no entry');
|
||||||
|
assert.ok(!ids.includes('weather-radar'));
|
||||||
|
assert.match(hint, /(^|;)aqhi→air-quality(;|$)/, 'an alias the id does not spell stays');
|
||||||
|
assert.match(hint, /(^|;)hurricanes→weather-cyclones(;|$)/);
|
||||||
|
assert.match(hint, /(^|;)fires\/wildfires→local-firms(;|$)/);
|
||||||
|
});
|
||||||
|
|
||||||
test('spoken aliases resolve to real layers, keep every legacy phrase and never collide', () => {
|
test('spoken aliases resolve to real layers, keep every legacy phrase and never collide', () => {
|
||||||
const aliases = voiceLayerAliasMap();
|
const aliases = voiceLayerAliasMap();
|
||||||
for (const id of aliases.values()) assert.ok(manifestIds.includes(id), id);
|
for (const id of aliases.values()) assert.ok(manifestIds.includes(id), id);
|
||||||
|
|||||||
Reference in new issue
Block a user