mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Match Navara satellite landscapes to the Re:Earth Fuji view
This commit is contained in:
1 parent
5e658d8c9d
commit
3cbc17a926
26 files changed
+262
-112
No files matched your search
@@ -33,6 +33,7 @@ How to read this:
|
||||
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
|
||||
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
||||
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
|
||||
| **EOX Sentinel-2 cloudless 2020** | Optional satellite basemap in Cesium and Navara. Navara uses daylight for terrain inspection. | [EOX Maps](https://maps.eox.at/) and [imagery licence](https://cloudless.eox.at/) | Sentinel-2 cloudless 2020 by EOX IT Services GmbH (contains modified Copernicus Sentinel data 2020) |
|
||||
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
|
||||
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
|
||||
| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" |
|
||||
|
||||
@@ -106,7 +106,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
|
||||
| 12 | `panelState` | LEN | none | packed panel entries, see panels |
|
||||
| 13 | thing | LEN | none | sub-message, see thing views |
|
||||
| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow (retired) |
|
||||
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm |
|
||||
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm, sentinel2 |
|
||||
| 16 | `hudVariant` | enum | style | tactical, operator (retired), minimal, cyber |
|
||||
| 17 | `bloomIntensity` | uint | style | |
|
||||
| 18 | `bloomVersion` | uint | 2 | |
|
||||
|
||||
@@ -2450,6 +2450,7 @@
|
||||
"src/maps/defaultSources.js",
|
||||
"src/maps/google3d.js",
|
||||
"src/maps/imagery.js",
|
||||
"src/maps/sentinel2.js",
|
||||
"src/maps/registry.js",
|
||||
"src/maps/terrain.js",
|
||||
"src/maps/terrainRetry.js",
|
||||
@@ -3009,6 +3010,7 @@
|
||||
"src/maps/defaultSources.js",
|
||||
"src/maps/google3d.js",
|
||||
"src/maps/imagery.js",
|
||||
"src/maps/sentinel2.js",
|
||||
"src/maps/registry.js",
|
||||
"src/maps/terrain.js",
|
||||
"src/maps/terrainRetry.js",
|
||||
@@ -4285,6 +4287,7 @@
|
||||
"src/maps/credits.js",
|
||||
"src/globe/cesium/surface.js",
|
||||
"src/maps/imagery.js",
|
||||
"src/maps/sentinel2.js",
|
||||
"src/services/groundSnap.js",
|
||||
"src/maps/google3d.js",
|
||||
"src/globe/cesium/input.js",
|
||||
@@ -5054,6 +5057,7 @@
|
||||
"src/maps/google3d.js",
|
||||
"src/services/groundSnap.js",
|
||||
"src/maps/imagery.js",
|
||||
"src/maps/sentinel2.js",
|
||||
"src/globe/cesium/surface.js",
|
||||
"src/maps/credits.js",
|
||||
"src/maps/sceneApply.js",
|
||||
|
||||
@@ -196,6 +196,23 @@ try {
|
||||
});
|
||||
await switchTo('navara');
|
||||
assert.equal(await stored(), 'cesium');
|
||||
// The real Map Source chips move into daylight inspection and back to live lighting.
|
||||
await page.$eval('[data-stack-id="sentinel2"]', (el) => el.click());
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.mapStackController.getActiveId() === 'sentinel2' &&
|
||||
window.__godsEyeView.engine.host.container.hasAttribute(
|
||||
'data-terrain-daylight',
|
||||
),
|
||||
);
|
||||
await page.$eval('[data-stack-id="osm"]', (el) => el.click());
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.mapStackController.getActiveId() === 'osm' &&
|
||||
!window.__godsEyeView.engine.host.container.hasAttribute(
|
||||
'data-terrain-daylight',
|
||||
),
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS Cesium default, both switches, same document, warm engine identity, parked canvas, no startup splash, share pose/map/layers, Back/Forward, remembered engine, blocked preference storage',
|
||||
|
||||
@@ -316,9 +316,9 @@ try {
|
||||
controls: document.getElementById('control-panel-toggle')?.getAttribute('aria-controls'),
|
||||
}));
|
||||
check(
|
||||
'exact five-source presentation; the retired left Map Stack panel is gone',
|
||||
'exact six-source presentation; the retired left Map Stack panel is gone',
|
||||
JSON.stringify(presentation.ids) === JSON.stringify([
|
||||
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm',
|
||||
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'sentinel2', 'osm',
|
||||
]) && !presentation.retiredPanel,
|
||||
JSON.stringify(presentation),
|
||||
);
|
||||
|
||||
@@ -985,6 +985,10 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter(
|
||||
(tool) => tool.name !== 'set_cyber_sonar',
|
||||
);
|
||||
// The EOX basemap extends the map enum, independently of the mission tools.
|
||||
const stacks = legacyTools.find((tool) => tool.name === 'set_map_stack')
|
||||
.parameters.properties.stack.enum;
|
||||
assert.equal(stacks.pop(), 'sentinel2');
|
||||
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
|
||||
.parameters.properties.layout;
|
||||
assert.deepEqual(hudLayout.enum, ['tactical', 'minimal', 'cyber']);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '../../maps/imagery.js';
|
||||
import { applyTerrain, terrainSourceFor } from '../../maps/terrain.js';
|
||||
|
||||
const IMAGERY_KINDS = new Set(['osm', 'esri-imagery', 'ion']);
|
||||
const IMAGERY_KINDS = new Set(['osm', 'esri-imagery', 'sentinel2', 'ion']);
|
||||
|
||||
function imageryCreator(source) {
|
||||
if (typeof source?.imagery === 'function') return source.imagery;
|
||||
|
||||
@@ -116,6 +116,8 @@ export async function createNavaraGlobe({
|
||||
const plugin = new DefaultPlugin();
|
||||
view.addPlugin(plugin);
|
||||
await view.init();
|
||||
const automaticLandscape =
|
||||
!effects && new URLSearchParams(search).get('sun') !== 'live';
|
||||
const plan = navaraScenePlan(effects ?? resolveNavaraLook(search), {
|
||||
mobile: Boolean(view.isMobileOptimized?.()),
|
||||
});
|
||||
@@ -172,12 +174,22 @@ export async function createNavaraGlobe({
|
||||
if (!moving && !groundSettled && engine.surface.tilesLoaded())
|
||||
readGround();
|
||||
const placed = readCameraPlace(camera);
|
||||
const landscape =
|
||||
placed &&
|
||||
lookCarto.height < 100_000 &&
|
||||
plan.globeLighting &&
|
||||
automaticLandscape &&
|
||||
(engine.maps.baseKind === 'sentinel2' ||
|
||||
engine.maps.baseKind === 'esri-imagery');
|
||||
if (container.hasAttribute('data-terrain-daylight') !== landscape)
|
||||
container.toggleAttribute('data-terrain-daylight', landscape);
|
||||
if (
|
||||
look.frame(
|
||||
placed ? lookCarto.height : Number.NaN,
|
||||
moving,
|
||||
ground,
|
||||
placed ? lookPlace : null,
|
||||
landscape,
|
||||
)
|
||||
)
|
||||
engine.render.request('navara-look');
|
||||
@@ -206,6 +218,7 @@ export async function createNavaraGlobe({
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
container.removeAttribute('data-terrain-daylight');
|
||||
removeLook();
|
||||
removePostSampling();
|
||||
engine.destroy();
|
||||
|
||||
+49
-15
@@ -3,8 +3,7 @@
|
||||
// (the engine clock's frame time in the app: the app clock, which is the wall
|
||||
// clock the HUD's SUN elevation reads unless QA moves it); the cinematic look
|
||||
// lights the globe
|
||||
// from the atmosphere when seen from space, shows its clouds only for low
|
||||
// views over low ground and while the sun is up, brightens the atmosphere
|
||||
// from the atmosphere when seen from space, places its clouds above the terrain while the sun is up, brightens the atmosphere
|
||||
// (createLuminanceScaler, uniforms from internals.js), compiles both
|
||||
// night-side shader variants at startup and shows its stars only at night
|
||||
// or from space. All of it runs only on frames the view draws anyway, so an
|
||||
@@ -12,7 +11,11 @@
|
||||
|
||||
import {
|
||||
cloudsSunlitAt,
|
||||
cloudsVisibleAt,
|
||||
ACES_FILMIC,
|
||||
AGX,
|
||||
cloudTerrainOffset,
|
||||
landscapeSunTime,
|
||||
CUMULUS_BASES,
|
||||
globeLightingAt,
|
||||
starsVisibleAt,
|
||||
sunDateStale,
|
||||
@@ -82,14 +85,30 @@ export function buildNavaraLook(
|
||||
let warmUp = aerial ? 2 : 0;
|
||||
let cloudsShown = true;
|
||||
let cloudsSunlit = true;
|
||||
let landscape = false;
|
||||
const tone = effect('toneMapping');
|
||||
let starsShown = true;
|
||||
|
||||
const frame = (height, moving = false, ground = Number.NaN, place = null) => {
|
||||
const frame = (
|
||||
height,
|
||||
moving = false,
|
||||
ground = Number.NaN,
|
||||
place = null,
|
||||
landscapeRequested = false,
|
||||
) => {
|
||||
// Every drawn frame: a material Navara rebuilds comes back unscaled.
|
||||
let changed = brighten();
|
||||
let changed = brighten(!landscapeRequested);
|
||||
if (landscapeRequested !== landscape && tone) {
|
||||
landscape = landscapeRequested;
|
||||
view.toneMappingExposure = landscape ? 10 : 1;
|
||||
tone.update({ toneMapping: { mode: landscape ? AGX : ACES_FILMIC } });
|
||||
changed = true;
|
||||
}
|
||||
const atmosphere = view.atmosphere;
|
||||
if (atmosphere && sunDateStale(atmosphere.date.getTime(), now())) {
|
||||
atmosphere.date = new Date(now());
|
||||
const time =
|
||||
landscape && place ? landscapeSunTime(now(), place.lng) : now();
|
||||
if (atmosphere && sunDateStale(atmosphere.date.getTime(), time)) {
|
||||
atmosphere.date = new Date(time);
|
||||
changed = true;
|
||||
}
|
||||
const elevation =
|
||||
@@ -116,12 +135,24 @@ export function buildNavaraLook(
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// Clouds only for a camera below them over low ground, and while the
|
||||
// sun is up at the camera (after sunset they read as dark puffs);
|
||||
// switching them costs no recompile, so it follows the camera in motion.
|
||||
// Terrain-relative layers keep distant cumulus above mountain ridges.
|
||||
// Keep a downward view clear once the camera rises above the cloud deck.
|
||||
if (clouds) {
|
||||
const offset = cloudTerrainOffset(height, ground);
|
||||
const pass = clouds.ref?.raw;
|
||||
const layers = pass?.cloudLayers;
|
||||
if (layers) {
|
||||
for (let i = 0; i < CUMULUS_BASES.length; i++) {
|
||||
const altitude = CUMULUS_BASES[i] + offset;
|
||||
if (layers[i].altitude !== altitude) {
|
||||
layers[i].altitude = altitude;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
cloudsSunlit = cloudsSunlitAt(elevation, cloudsSunlit);
|
||||
const next = cloudsSunlit && cloudsVisibleAt(height, ground, cloudsShown);
|
||||
const ceiling = CUMULUS_BASES[0] + offset + (cloudsShown ? 800 : 500);
|
||||
const next = cloudsSunlit && height <= ceiling;
|
||||
if (next !== cloudsShown) {
|
||||
cloudsShown = next;
|
||||
clouds.update(next ? VISIBLE : HIDDEN);
|
||||
@@ -175,13 +206,15 @@ export function createLuminanceScaler(scale, forEachUniform, handles) {
|
||||
}
|
||||
return found;
|
||||
};
|
||||
let applyScale = true;
|
||||
const record = (key, uniform) => entry(key, uniform.value);
|
||||
const scaleOne = (key, uniform, scaled) => {
|
||||
if (!scaled) return;
|
||||
const { target } = entry(key, uniform.value);
|
||||
const { base, target } = entry(key, uniform.value);
|
||||
const desired = applyScale ? target : base;
|
||||
scaledVectors.add(uniform.value);
|
||||
if (uniform.value.equals(target)) return;
|
||||
uniform.value.copy(target);
|
||||
if (uniform.value.equals(desired)) return;
|
||||
uniform.value.copy(desired);
|
||||
changed = true;
|
||||
};
|
||||
const keepBase = (key, uniform, scaled) => {
|
||||
@@ -195,7 +228,8 @@ export function createLuminanceScaler(scale, forEachUniform, handles) {
|
||||
changed = true;
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
return (enabled = true) => {
|
||||
applyScale = enabled;
|
||||
changed = false;
|
||||
// Bases first, from the values as Navara built them.
|
||||
forEachUniform(handles, record);
|
||||
|
||||
@@ -128,26 +128,21 @@ test('from space the globe is lit by the atmosphere; near the ground the tiles k
|
||||
});
|
||||
});
|
||||
|
||||
test('the clouds show only below their tops, switching in motion too', () => {
|
||||
test('cumulus rises over mountain terrain and leaves the orbital view clear', () => {
|
||||
const view = fakeView(0);
|
||||
const look = warmCinematic(view);
|
||||
const clouds = view.added.find(({ desc }) => Object.hasOwn(desc, 'clouds'));
|
||||
assert.equal(look.frame(900, true), false, 'shown at street level');
|
||||
assert.equal(look.frame(20_000, true), true);
|
||||
const layers = [{ altitude: 1500 }, { altitude: 1800 }];
|
||||
const pass = { cloudLayers: layers };
|
||||
clouds.ref = { raw: pass };
|
||||
assert.equal(look.frame(4350, false, 3100), true);
|
||||
assert.deepEqual(layers, [{ altitude: 6000 }, { altitude: 6300 }]);
|
||||
assert.equal(look.frame(4350, false, 3100), false);
|
||||
look.frame(8_000_000, true, 3100);
|
||||
assert.deepEqual(layers, [{ altitude: 1500 }, { altitude: 1800 }]);
|
||||
assert.deepEqual(clouds.updates, [{ visible: false }]);
|
||||
assert.equal(look.frame(3_800, true), false, 'hysteresis');
|
||||
assert.equal(
|
||||
view.atmosphere.overlay.value,
|
||||
null,
|
||||
'no ghost of the last clouds',
|
||||
);
|
||||
assert.equal(view.atmosphere.shadowLength.value, null);
|
||||
assert.equal(look.frame(3_000, true), true);
|
||||
look.frame(900, false, 0);
|
||||
assert.deepEqual(clouds.updates.at(-1), { visible: true });
|
||||
assert.equal(view.atmosphere.overlay.value, 'overlay', 'put back');
|
||||
assert.equal(view.atmosphere.shadowLength.value, 'shafts');
|
||||
assert.equal(look.frame(3_000, false, 2_100), true, 'over high ground');
|
||||
assert.deepEqual(clouds.updates.at(-1), { visible: false });
|
||||
});
|
||||
|
||||
test('globe lighting switches on only at rest, and off even in motion', () => {
|
||||
@@ -156,8 +151,8 @@ test('globe lighting switches on only at rest, and off even in motion', () => {
|
||||
const aerial = view.added.find(({ desc }) =>
|
||||
Object.hasOwn(desc, 'aerialPerspective'),
|
||||
);
|
||||
look.frame(20_000, true); // the clouds hide on the way up
|
||||
assert.equal(look.frame(2_300_000, true), false, 'a flight through space');
|
||||
look.frame(20_000, true);
|
||||
look.frame(2_300_000, true);
|
||||
assert.deepEqual(aerial.updates, []);
|
||||
assert.equal(look.frame(8_000_000, false), true, 'at rest');
|
||||
assert.equal(look.frame(500_000, true), true, 'coming down');
|
||||
@@ -166,6 +161,26 @@ test('globe lighting switches on only at rest, and off even in motion', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('daylight inspection restores live exposure, tone mapping and time when left', () => {
|
||||
const view = fakeView(0);
|
||||
const look = warmCinematic(view);
|
||||
const tone = view.added.find(({ desc }) =>
|
||||
Object.hasOwn(desc, 'toneMapping'),
|
||||
);
|
||||
const fuji = { lat: 35.2599, lng: 138.6574 };
|
||||
look.frame(6508, false, 2600, fuji, true);
|
||||
assert.equal(view.toneMappingExposure, 10);
|
||||
assert.equal(tone.updates.at(-1).toneMapping.mode, 7);
|
||||
assert.ok(
|
||||
Math.abs(view.atmosphere.date.getTime() / 3_600_000 + fuji.lng / 15 - 13) <
|
||||
1e-6,
|
||||
);
|
||||
look.frame(6508, false, 2600, fuji, false);
|
||||
assert.equal(view.toneMappingExposure, 1);
|
||||
assert.equal(tone.updates.at(-1).toneMapping.mode, 6);
|
||||
assert.equal(view.atmosphere.date.getTime(), 0);
|
||||
});
|
||||
|
||||
test('the sun follows the wall clock, a minute at most behind, only on drawn frames', () => {
|
||||
let now = 1_000_000;
|
||||
const view = fakeView(now);
|
||||
@@ -361,13 +376,16 @@ test('the cinematic clouds hide after sunset at the camera', () => {
|
||||
const place = { lat: 40.7, lng: -74 };
|
||||
assert.equal(look.frame(900, false, 0, place), true, 'the stars hide');
|
||||
assert.deepEqual(clouds.updates, []);
|
||||
elevation = -1;
|
||||
elevation = -8;
|
||||
assert.equal(look.frame(900, false, 0, place), true);
|
||||
assert.deepEqual(clouds.updates, [{ visible: false }]);
|
||||
elevation = 1;
|
||||
elevation = -5;
|
||||
look.frame(900, false, 0, place);
|
||||
assert.deepEqual(clouds.updates, [{ visible: false }], 'hysteresis');
|
||||
elevation = 3;
|
||||
assert.equal(view.atmosphere.overlay.value, null);
|
||||
look.frame(8_000_000, false, 0, place);
|
||||
assert.deepEqual(clouds.updates.at(-1), { visible: false });
|
||||
elevation = -2;
|
||||
look.frame(900, false, 0, place);
|
||||
assert.deepEqual(clouds.updates.at(-1), { visible: true });
|
||||
});
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
// it back until setBaseImagery. Hidden, not deleted: deleting the last
|
||||
// raster layer stops Navara 0.2.1 loading terrain at the current view.
|
||||
|
||||
import {
|
||||
SENTINEL2_CREDIT,
|
||||
SENTINEL2_MAX_ZOOM,
|
||||
SENTINEL2_URL,
|
||||
} from '../../maps/sentinel2.js';
|
||||
|
||||
const OSM_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
const OSM_CREDIT = {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
@@ -46,6 +52,12 @@ function imageryFor(source) {
|
||||
const credit =
|
||||
source?.credit != null ? { attributionHtml: String(source.credit) } : null;
|
||||
switch (kind) {
|
||||
case 'sentinel2':
|
||||
return {
|
||||
url: SENTINEL2_URL,
|
||||
maxZoom: SENTINEL2_MAX_ZOOM,
|
||||
credit: { attributionHtml: SENTINEL2_CREDIT },
|
||||
};
|
||||
case 'osm':
|
||||
return { url: OSM_URL, credit: credit ?? OSM_CREDIT };
|
||||
case 'esri-imagery':
|
||||
@@ -165,6 +177,8 @@ export function createNavaraMaps(
|
||||
type: 'quantized-mesh',
|
||||
url: KEYLESS_TERRAIN_URL,
|
||||
maxZoom: KEYLESS_TERRAIN_MAX_ZOOM,
|
||||
requestVertexNormals: true,
|
||||
requestWaterMask: true,
|
||||
};
|
||||
case 'world': {
|
||||
const token = String(source.accessToken || '').trim();
|
||||
@@ -195,16 +209,23 @@ export function createNavaraMaps(
|
||||
const knownTiles = new Set();
|
||||
|
||||
const maps = {
|
||||
get baseKind() {
|
||||
return kindOf(currentBase);
|
||||
},
|
||||
async setBaseImagery(source) {
|
||||
const generation = ++generations.display;
|
||||
const { url, credit } = imageryFor(source);
|
||||
const { url, credit, maxZoom } = imageryFor(source);
|
||||
if (destroyed || generation !== generations.display) return;
|
||||
baseSource = source;
|
||||
currentBase = source;
|
||||
if (slots.imagery?.key !== url)
|
||||
show('imagery', url, { type: 'raster-tile', url }, { type: 'raster' }, [
|
||||
credit,
|
||||
]);
|
||||
show(
|
||||
'imagery',
|
||||
url,
|
||||
{ type: 'raster-tile', url, ...(maxZoom && { maxZoom }) },
|
||||
{ type: 'raster' },
|
||||
[credit],
|
||||
);
|
||||
// Under tiles the base imagery waits hidden, credit included.
|
||||
setImageryShown(slots.photoreal == null);
|
||||
},
|
||||
|
||||
@@ -90,6 +90,21 @@ test('setBaseImagery builds Navara raster layers for OSM and Esri, one at a time
|
||||
assert.ok(view.frames > 0);
|
||||
});
|
||||
|
||||
test('Sentinel-2 uses the EOX mosaic with attribution and a capped source zoom', async () => {
|
||||
const view = fakeView();
|
||||
const { maps } = createNavaraMaps(view);
|
||||
await maps.setBaseImagery({ kind: 'sentinel2' });
|
||||
assert.equal(maps.baseKind, 'sentinel2');
|
||||
assert.match(view.sources[0].desc.url, /s2cloudless-2020_3857/);
|
||||
assert.equal(view.sources[0].desc.maxZoom, 14);
|
||||
assert.match(
|
||||
view.shown[0].attributionHtml,
|
||||
/modified Copernicus Sentinel data 2020/,
|
||||
);
|
||||
await maps.setBaseImagery({ kind: 'osm' });
|
||||
assert.equal(maps.baseKind, 'osm');
|
||||
});
|
||||
|
||||
test('the same imagery source again keeps the live layer', async () => {
|
||||
const view = fakeView();
|
||||
const { maps } = createNavaraMaps(view);
|
||||
|
||||
@@ -50,6 +50,16 @@ export function resolveNavaraLook(search = '') {
|
||||
|
||||
// postprocessing's ToneMappingMode.ACES_FILMIC (Navara's default is AGX, 7).
|
||||
export const ACES_FILMIC = 6;
|
||||
export const AGX = 7;
|
||||
|
||||
// The Sentinel-2 landscape uses early afternoon at the camera longitude,
|
||||
// independently of the telemetry clock. Only the displayed sunlight moves.
|
||||
export function landscapeSunTime(now, longitude) {
|
||||
const hour = 3_600_000;
|
||||
const day = 24 * hour;
|
||||
const offset = (longitude / 15) * hour;
|
||||
return Math.floor((now + offset) / day) * day + 13 * hour - offset;
|
||||
}
|
||||
|
||||
// Camera heights (m) where the cinematic look switches the globe to
|
||||
// atmosphere lighting (night side and terminator) and back. The gap keeps a
|
||||
@@ -70,28 +80,14 @@ export function globeLightingAt(height, current = false) {
|
||||
: height >= GLOBE_LIGHTING_ENTER_M;
|
||||
}
|
||||
|
||||
// Where the cinematic clouds show. Their layers span 1,500 to 3,000 m above
|
||||
// the ellipsoid. Above 4 km the camera would look down on them, and they
|
||||
// would hide the map under clouds that are a backdrop, not weather; over
|
||||
// ground above 1,000 m (canyons, the Alps) they would cut into the terrain
|
||||
// as dark puffs.
|
||||
// Each limit has a gap so a camera near it does not flicker them.
|
||||
export const CLOUDS_HIDE_ABOVE_M = 4_000;
|
||||
export const CLOUDS_SHOW_BELOW_M = 3_500;
|
||||
export const CLOUDS_HIDE_OVER_GROUND_M = 1_000;
|
||||
export const CLOUDS_SHOW_OVER_GROUND_M = 800;
|
||||
|
||||
/**
|
||||
* Whether the cinematic clouds show for a camera at `height` looking at
|
||||
* ground at `ground` (both metres above the ellipsoid; an unknown ground
|
||||
* counts as low).
|
||||
*/
|
||||
export function cloudsVisibleAt(height, ground, current = true) {
|
||||
if (!Number.isFinite(height)) return current;
|
||||
const low = Number.isFinite(ground) ? ground : 0;
|
||||
return current
|
||||
? height <= CLOUDS_HIDE_ABOVE_M && low <= CLOUDS_HIDE_OVER_GROUND_M
|
||||
: height < CLOUDS_SHOW_BELOW_M && low < CLOUDS_SHOW_OVER_GROUND_M;
|
||||
// Navara cloud layers use ellipsoid heights. Over uplands raise the two
|
||||
// cumulus layers together; at world scale a single ground sample no longer
|
||||
// represents the whole view. Quantize the offset to avoid tiny layer updates.
|
||||
export const LOCAL_CLOUD_COVERAGE = 0.22;
|
||||
export const CUMULUS_BASES = Object.freeze([1500, 1800]);
|
||||
export function cloudTerrainOffset(height, ground) {
|
||||
if (!Number.isFinite(ground) || height >= STARS_SPACE_ENTER_M) return 0;
|
||||
return Math.ceil(Math.max(0, ground - 500) / 500) * 750;
|
||||
}
|
||||
|
||||
// Navara's sky, clouds and haze come from the atmosphere model in physical
|
||||
@@ -113,10 +109,9 @@ export const STARS_HIDE_SUN_ABOVE_DEG = -4;
|
||||
export const STARS_SPACE_ENTER_M = 100_000;
|
||||
export const STARS_SPACE_EXIT_M = 80_000;
|
||||
|
||||
// The cinematic clouds hide once the sun at the camera sets (below 0°):
|
||||
// unlit, they read as dark puffs over a dusk sky. They show again above 2°.
|
||||
export const CLOUDS_HIDE_SUN_BELOW_DEG = 0;
|
||||
export const CLOUDS_SHOW_SUN_ABOVE_DEG = 2;
|
||||
// Keep the sunlit cloud tops through twilight. Hide them locally at night.
|
||||
export const CLOUDS_HIDE_SUN_BELOW_DEG = -6;
|
||||
export const CLOUDS_SHOW_SUN_ABOVE_DEG = -4;
|
||||
|
||||
/** Whether the sun is up enough for the cinematic clouds; NaN keeps `current`. */
|
||||
export function cloudsSunlitAt(sunElevation, current = true) {
|
||||
@@ -177,17 +172,15 @@ const cinematic = (mobile) => ({
|
||||
: [
|
||||
{
|
||||
clouds: {
|
||||
// 'low' looks the same here and costs about 1 ms less than
|
||||
// 'medium' (clouds cost about 3 ms a frame at 1280×800).
|
||||
qualityPreset: 'low',
|
||||
coverage: 0.2,
|
||||
coverage: LOCAL_CLOUD_COVERAGE,
|
||||
// No cloud shadows on the ground: dark patches over the map
|
||||
// read as features (and cost nothing measurable either way).
|
||||
shadows: false,
|
||||
// The two cumulus layers start at 1,500 m instead of 750 and
|
||||
// 1,000 m, above the city views (Austin's 600 m, Manhattan's
|
||||
// 900 m): clouds fill the sky without hiding the streets.
|
||||
cloudLayers: [{ altitude: 1500 }, { altitude: 1800 }],
|
||||
cloudLayers: CUMULUS_BASES.map((altitude) => ({ altitude })),
|
||||
},
|
||||
},
|
||||
{ lensFlare: {} },
|
||||
@@ -229,7 +222,7 @@ const PLANS = {
|
||||
* - `photoreal`: the default plugin's addDefaultPhotorealScene (sky, sun,
|
||||
* aerial perspective, lens flare, AgX tone mapping, SMAA);
|
||||
* - `cinematic` (the app's default): photoreal plus volumetric clouds for
|
||||
* low views over low ground (cloudsVisibleAt), ACES tone mapping, brighter stars, no sun shadow
|
||||
* mountain and coastal views, ACES tone mapping, brighter stars, no sun shadow
|
||||
* maps, and atmosphere lighting of the globe from space (`globeLighting`,
|
||||
* see globeLightingAt), a brighter atmosphere (ATMOSPHERE_LUMINANCE_SCALE)
|
||||
* and stars only at night or from space (starsVisibleAt). On a mobile-optimized
|
||||
|
||||
@@ -6,11 +6,7 @@ import {
|
||||
GLOBE_LIGHTING_EXIT_M,
|
||||
NAVARA_CONTEXT_ATTRIBUTES,
|
||||
NAVARA_LOOKS,
|
||||
CLOUDS_HIDE_ABOVE_M,
|
||||
CLOUDS_HIDE_OVER_GROUND_M,
|
||||
CLOUDS_SHOW_BELOW_M,
|
||||
CLOUDS_SHOW_OVER_GROUND_M,
|
||||
cloudsVisibleAt,
|
||||
cloudTerrainOffset,
|
||||
globeLightingAt,
|
||||
navaraScenePlan,
|
||||
navaraViewOptions,
|
||||
@@ -106,7 +102,7 @@ test('the cinematic look: photoreal plus clouds and ACES, no sun shadow maps, no
|
||||
useNormalBuffer: false,
|
||||
albedoScale: 2,
|
||||
});
|
||||
assert.equal(clouds.clouds.coverage, 0.2);
|
||||
assert.equal(clouds.clouds.coverage, 0.22);
|
||||
assert.equal(clouds.clouds.shadows, false, 'no cloud shadow maps');
|
||||
assert.ok(
|
||||
clouds.clouds.cloudLayers.every(({ altitude }) => altitude >= 1000),
|
||||
@@ -154,30 +150,12 @@ test('globe lighting turns on from space and off near the ground, with hysteresi
|
||||
assert.ok(GLOBE_LIGHTING_EXIT_M < GLOBE_LIGHTING_ENTER_M);
|
||||
});
|
||||
|
||||
test('the clouds show for low views over low ground, with hysteresis', () => {
|
||||
assert.equal(cloudsVisibleAt(900, 10, true), true);
|
||||
assert.equal(cloudsVisibleAt(900, Number.NaN, true), true, 'unknown ground');
|
||||
assert.equal(cloudsVisibleAt(CLOUDS_HIDE_ABOVE_M, 0, true), true);
|
||||
assert.equal(cloudsVisibleAt(CLOUDS_HIDE_ABOVE_M + 1, 0, true), false);
|
||||
assert.equal(cloudsVisibleAt(3_800, 0, false), false, 'inside the gap');
|
||||
assert.equal(cloudsVisibleAt(CLOUDS_SHOW_BELOW_M - 1, 0, false), true);
|
||||
assert.equal(cloudsVisibleAt(3_000, 2_100, true), false, 'over a canyon rim');
|
||||
assert.equal(cloudsVisibleAt(3_000, 900, false), false, 'inside the gap');
|
||||
assert.equal(
|
||||
cloudsVisibleAt(3_000, CLOUDS_SHOW_OVER_GROUND_M - 1, false),
|
||||
true,
|
||||
);
|
||||
assert.equal(cloudsVisibleAt(CLOUDS_HIDE_OVER_GROUND_M, 0, true), true);
|
||||
assert.equal(
|
||||
cloudsVisibleAt(Number.NaN, 0, false),
|
||||
false,
|
||||
'unknown keeps it',
|
||||
);
|
||||
const layers = navaraScenePlan('cinematic').effects[1].clouds.cloudLayers;
|
||||
assert.ok(
|
||||
layers[0].altitude - CLOUDS_HIDE_OVER_GROUND_M >= 500,
|
||||
'cloud base well above the highest ground they show over',
|
||||
);
|
||||
test('cumulus layers rise over mountains, with sea-level bases from space', () => {
|
||||
assert.equal(cloudTerrainOffset(900, 10), 0);
|
||||
assert.equal(cloudTerrainOffset(4350, 3100), 4500);
|
||||
assert.equal(cloudTerrainOffset(4350, 3200), 4500);
|
||||
assert.equal(cloudTerrainOffset(8_000_000, 3100), 0);
|
||||
assert.equal(cloudTerrainOffset(4350, Number.NaN), 0);
|
||||
});
|
||||
|
||||
test('the sun date is stale once it drifts a minute from the clock', () => {
|
||||
@@ -221,8 +199,8 @@ test('stars show at night and from space, not in a daylit sky, with hysteresis',
|
||||
test('the clouds hide once the sun sets at the camera, with hysteresis', () => {
|
||||
assert.equal(cloudsSunlitAt(40, true), true);
|
||||
assert.equal(cloudsSunlitAt(CLOUDS_HIDE_SUN_BELOW_DEG - 0.1, true), false);
|
||||
assert.equal(cloudsSunlitAt(1, false), false, 'inside the gap');
|
||||
assert.equal(cloudsSunlitAt(1, true), true, 'inside the gap');
|
||||
assert.equal(cloudsSunlitAt(-5, false), false, 'inside the gap');
|
||||
assert.equal(cloudsSunlitAt(-5, true), true, 'inside the gap');
|
||||
assert.equal(cloudsSunlitAt(CLOUDS_SHOW_SUN_ABOVE_DEG + 0.1, false), true);
|
||||
assert.equal(cloudsSunlitAt(Number.NaN, false), false, 'unknown keeps it');
|
||||
});
|
||||
@@ -500,6 +500,8 @@ export class IntelHUD {
|
||||
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
|
||||
// i18n-ignore: sensor codes
|
||||
readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
||||
if (this.engine.host.container?.hasAttribute?.('data-terrain-daylight'))
|
||||
readouts.alt += ' · DAYLIGHT VIEW';
|
||||
|
||||
// Collection timestamp
|
||||
const now = new Date();
|
||||
|
||||
@@ -78,7 +78,7 @@ export const CABLE_LABEL_DEPTH_DECISION = Object.freeze({
|
||||
*/
|
||||
|
||||
export const CABLE_GLOBE_STACK_IDS = Object.freeze(
|
||||
new Set(['bing-aerial', 'bing-labels', 'esri-imagery', 'osm']),
|
||||
new Set(['bing-aerial', 'bing-labels', 'esri-imagery', 'osm', 'sentinel2']),
|
||||
);
|
||||
|
||||
/** EntityCluster's private marker collections and their required types. */
|
||||
|
||||
@@ -18,6 +18,7 @@ export const PRESENTED_MAP_STACK_IDS = Object.freeze([
|
||||
'bing-aerial',
|
||||
'bing-labels',
|
||||
'esri-imagery',
|
||||
'sentinel2',
|
||||
'osm',
|
||||
]);
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ const CONTROLLER_STACKS = [
|
||||
available: true,
|
||||
unavailableReason: null,
|
||||
},
|
||||
{ id: 'sentinel2', label: 'EOX Sentinel-2', available: true },
|
||||
{
|
||||
id: 'osm',
|
||||
label: 'OSM',
|
||||
@@ -77,21 +78,36 @@ function publishedChips(stacks, activeId) {
|
||||
return views.at(-1).chips;
|
||||
}
|
||||
|
||||
test('the row presents exactly the five owner-approved sources', () => {
|
||||
test('the row presents exactly the six map sources', () => {
|
||||
const chips = mapStackChipModels(CONTROLLER_STACKS, 'photoreal');
|
||||
assert.deepEqual(
|
||||
chips.map((chip) => chip.id),
|
||||
['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm'],
|
||||
[
|
||||
'photoreal',
|
||||
'bing-aerial',
|
||||
'bing-labels',
|
||||
'esri-imagery',
|
||||
'sentinel2',
|
||||
'osm',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
chips.map((chip) => chip.label),
|
||||
['Google 3D', 'Bing Aerial', 'Bing Labels', 'Esri Satellite', 'OSM'],
|
||||
[
|
||||
'Google 3D',
|
||||
'Bing Aerial',
|
||||
'Bing Labels',
|
||||
'Esri Satellite',
|
||||
'EOX Sentinel-2',
|
||||
'OSM',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(PRESENTED_MAP_STACK_IDS, [
|
||||
'photoreal',
|
||||
'bing-aerial',
|
||||
'bing-labels',
|
||||
'esri-imagery',
|
||||
'sentinel2',
|
||||
'osm',
|
||||
]);
|
||||
});
|
||||
@@ -105,7 +121,7 @@ test('internal and future stacks stay outside the approved presentation set', ()
|
||||
];
|
||||
const chips = mapStackChipModels(withHybrid, 'photoreal');
|
||||
|
||||
assert.equal(chips.length, 5);
|
||||
assert.equal(chips.length, 6);
|
||||
assert.ok(chips.every((chip) => chip.id !== 'hybrid'));
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,13 @@ export const MAP_STACKS = [
|
||||
kind: 'esri-imagery',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'sentinel2',
|
||||
label: 'EOX Sentinel-2', // i18n-ignore: imagery product name
|
||||
shortLabel: 'EOX',
|
||||
kind: 'sentinel2',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'osm',
|
||||
label: 'OSM', // i18n-ignore: imagery product name
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
SENTINEL2_CREDIT,
|
||||
SENTINEL2_MAX_ZOOM,
|
||||
SENTINEL2_URL,
|
||||
} from './sentinel2.js';
|
||||
|
||||
// Attribution and service rights are documented in DATA_SOURCES.md.
|
||||
export const ESRI_ATTRIBUTION_HTML =
|
||||
@@ -42,6 +47,13 @@ export function createIonImagery(style, accessToken) {
|
||||
* @returns {(request?: { signal?: AbortSignal }) => object | Promise<object>}
|
||||
*/
|
||||
export function imageryFactoryFor(descriptor, ionToken = '') {
|
||||
if (descriptor.kind === 'sentinel2')
|
||||
return () =>
|
||||
new Cesium.UrlTemplateImageryProvider({
|
||||
url: SENTINEL2_URL,
|
||||
maximumLevel: SENTINEL2_MAX_ZOOM,
|
||||
credit: new Cesium.Credit(SENTINEL2_CREDIT, true),
|
||||
});
|
||||
if (descriptor.kind === 'ion')
|
||||
return () => createIonImagery(descriptor.style, ionToken);
|
||||
return descriptor.kind === 'osm' ? createOsmImagery : createEsriImagery;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// EOX's Sentinel-2 cloudless 2020 mosaic, also used by Re:Earth Terrain.
|
||||
export const SENTINEL2_URL =
|
||||
'https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2020_3857/default/g/{z}/{y}/{x}.jpg';
|
||||
export const SENTINEL2_MAX_ZOOM = 14;
|
||||
export const SENTINEL2_CREDIT =
|
||||
'<a href="https://maps.eox.at/">EOX Maps</a> · <a href="https://s2maps.eu/">Sentinel-2 cloudless 2020</a> by EOX IT Services GmbH (contains modified Copernicus Sentinel data 2020)';
|
||||
@@ -111,6 +111,7 @@ export const SHARE_VIEW_ENUMS = Object.freeze({
|
||||
'bing-labels',
|
||||
'esri-imagery',
|
||||
'osm',
|
||||
'sentinel2',
|
||||
]),
|
||||
hudVariant: Object.freeze(['tactical', 'operator', 'minimal', 'cyber']),
|
||||
detectionMode: Object.freeze(['OFF', 'SPARSE', 'BALANCED', 'DENSE']),
|
||||
|
||||
@@ -37,6 +37,7 @@ export const VIEW_MAPS = Object.freeze([
|
||||
'bing-labels',
|
||||
'esri-imagery',
|
||||
'osm',
|
||||
'sentinel2',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -356,6 +356,7 @@ const schemas = [
|
||||
'bing-labels',
|
||||
'esri-imagery',
|
||||
'osm',
|
||||
'sentinel2',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -47,7 +47,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
|
||||
// is consolidated without changing either feature's arguments.
|
||||
// Snow leaves the set_visual_style enum.
|
||||
// The Operator HUD layout leaves set_hud's layout enum.
|
||||
'f47a7f78f13b1f79978fa524fad2618e32bd813e1a5dfd746bf35d29909fc91b',
|
||||
// EOX Sentinel-2 joins the set_map_stack enum.
|
||||
'c245fc20cb7927181d7dec212ec60279aaa1fdeb494720a7d2ff11e00008a6c6',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -116,6 +117,7 @@ test('all legacy action arguments are byte-identical after removing the delibera
|
||||
// layerManifest.test.mjs; the two shipped right-rail panels are additive.
|
||||
const property = (name) =>
|
||||
legacy.find((tool) => tool.name === name).parameters.properties;
|
||||
assert.equal(property('set_map_stack').stack.enum.pop(), 'sentinel2');
|
||||
delete property('control_cockpit').voicePanel;
|
||||
delete property('set_layer_visibility').layerId.enum;
|
||||
delete property('show_data_layers_menu').layerId.enum;
|
||||
|
||||
@@ -204,6 +204,9 @@ const STACK_ALIASES = new Map([
|
||||
['esri', 'esri-imagery'],
|
||||
['esri imagery', 'esri-imagery'],
|
||||
['esri satellite', 'esri-imagery'],
|
||||
['sentinel2', 'sentinel2'],
|
||||
['sentinel-2', 'sentinel2'],
|
||||
['eox', 'sentinel2'],
|
||||
['osm', 'osm'],
|
||||
['openstreetmap', 'osm'],
|
||||
['open street map', 'osm'],
|
||||
|
||||
Reference in new issue
Block a user