refactor(maps): separate source factories and scene coordination

This commit is contained in:
Sameh Khamis committed 2026-09-13 18:59:29 -07:00
1 parent 561a781099
commit e59282dc9c
22 files changed
+1807 -798

No files matched your search

+312 -149
View File
@@ -34,7 +34,9 @@ function makeElement(tagName = 'div') {
children: [],
classList: {
toggle(name, force) {
const classes = new Set(String(element.className).split(/\s+/).filter(Boolean));
const classes = new Set(
String(element.className).split(/\s+/).filter(Boolean),
);
const next = force === undefined ? !classes.has(name) : !!force;
if (next) classes.add(name);
else classes.delete(name);
@@ -44,15 +46,30 @@ function makeElement(tagName = 'div') {
return String(element.className).split(/\s+/).includes(name);
},
},
appendChild(child) { element.children.push(child); return child; },
setAttribute(name, value) { element.attributes[name] = String(value); },
getAttribute(name) { return element.attributes[name] ?? null; },
addEventListener(type, handler) { (element.listeners[type] ||= []).push(handler); },
click() { for (const handler of element.listeners.click || []) handler(); },
appendChild(child) {
element.children.push(child);
return child;
},
setAttribute(name, value) {
element.attributes[name] = String(value);
},
getAttribute(name) {
return element.attributes[name] ?? null;
},
addEventListener(type, handler) {
(element.listeners[type] ||= []).push(handler);
},
click() {
for (const handler of element.listeners.click || []) handler();
},
};
Object.defineProperty(element, 'innerHTML', {
get() { return ''; },
set() { element.children.length = 0; },
get() {
return '';
},
set() {
element.children.length = 0;
},
});
return element;
}
@@ -60,37 +77,93 @@ function makeElement(tagName = 'div') {
const doc = { createElement: (tagName) => makeElement(tagName) };
/** Text a chip renders, label + optional requirement badge. */
const chipText = (chip) => chip.children.map((child) => child.textContent).join(' ');
const chipText = (chip) =>
chip.children.map((child) => child.textContent).join(' ');
// Shaped exactly like MapStackController.getStacks() output.
const CONTROLLER_STACKS = [
{ id: 'photoreal', label: 'Google 3D', requiresIon: false, available: true, unavailableReason: null },
{ id: 'bing-aerial', label: 'Bing Aerial', requiresIon: true, available: true, unavailableReason: null },
{ id: 'bing-labels', label: 'Bing Labels', requiresIon: true, available: true, unavailableReason: null },
{ id: 'esri-imagery', label: 'Esri Satellite', requiresIon: false, available: true, unavailableReason: null },
{ id: 'osm', label: 'OSM', requiresIon: false, available: true, unavailableReason: null },
{
id: 'photoreal',
label: 'Google 3D',
requiresIon: false,
available: true,
unavailableReason: null,
},
{
id: 'bing-aerial',
label: 'Bing Aerial',
requiresIon: true,
available: true,
unavailableReason: null,
},
{
id: 'bing-labels',
label: 'Bing Labels',
requiresIon: true,
available: true,
unavailableReason: null,
},
{
id: 'esri-imagery',
label: 'Esri Satellite',
requiresIon: false,
available: true,
unavailableReason: null,
},
{
id: 'osm',
label: 'OSM',
requiresIon: false,
available: true,
unavailableReason: null,
},
];
test('the row renders exactly the five owner-approved sources', () => {
const container = makeElement();
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
renderMapStackChips(container, CONTROLLER_STACKS, {
activeId: 'photoreal',
doc,
});
assert.deepEqual(container.children.map((chip) => chip.dataset.stackId), [
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm',
]);
assert.deepEqual(
container.children.map((chip) => chip.dataset.stackId),
['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm'],
);
assert.deepEqual(container.children.map(chipText), [
'Google 3D', 'Bing Aerial', 'Bing Labels', 'Esri Satellite', 'OSM',
'Google 3D',
'Bing Aerial',
'Bing Labels',
'Esri Satellite',
'OSM',
]);
assert.deepEqual(PRESENTED_MAP_STACK_IDS, ['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm']);
assert.ok(container.children.every((chip) => chip.tagName === 'button' && chip.type === 'button'));
assert.ok(container.children.every((chip) => chip.classList.contains(MAP_STACK_CHIP_CLASS)));
assert.deepEqual(PRESENTED_MAP_STACK_IDS, [
'photoreal',
'bing-aerial',
'bing-labels',
'esri-imagery',
'osm',
]);
assert.ok(
container.children.every(
(chip) => chip.tagName === 'button' && chip.type === 'button',
),
);
assert.ok(
container.children.every((chip) =>
chip.classList.contains(MAP_STACK_CHIP_CLASS),
),
);
});
test('internal and future stacks stay outside the approved presentation set', () => {
const container = makeElement();
// A future Hybrid stack may land in the controller, but it must not appear
// until the owner-approved presentation allowlist explicitly includes it.
const withHybrid = [...CONTROLLER_STACKS, { id: 'hybrid', label: 'Hybrid', available: true }];
const withHybrid = [
...CONTROLLER_STACKS,
{ id: 'hybrid', label: 'Hybrid', available: true },
];
renderMapStackChips(container, withHybrid, { activeId: 'photoreal', doc });
assert.equal(container.children.length, 5);
@@ -99,7 +172,10 @@ test('internal and future stacks stay outside the approved presentation set', ()
test('re-rendering replaces the previous chips instead of stacking a second row', () => {
const container = makeElement();
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
renderMapStackChips(container, CONTROLLER_STACKS, {
activeId: 'photoreal',
doc,
});
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'osm', doc });
assert.equal(container.children.length, PRESENTED_MAP_STACK_IDS.length);
@@ -122,19 +198,32 @@ test('clicking a chip dispatches that stack id — the same selection the dropdo
test('the active chip is the pressed chip, and exactly one is pressed', () => {
const container = makeElement();
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'bing-labels', doc });
renderMapStackChips(container, CONTROLLER_STACKS, {
activeId: 'bing-labels',
doc,
});
const pressed = container.children.filter((chip) => chip.getAttribute('aria-pressed') === 'true');
assert.deepEqual(pressed.map((chip) => chip.dataset.stackId), ['bing-labels']);
const pressed = container.children.filter(
(chip) => chip.getAttribute('aria-pressed') === 'true',
);
assert.deepEqual(
pressed.map((chip) => chip.dataset.stackId),
['bing-labels'],
);
assert.ok(pressed[0].classList.contains('active'));
assert.ok(container.children
.filter((chip) => chip.dataset.stackId !== 'bing-labels')
.every((chip) => !chip.classList.contains('active')));
assert.ok(
container.children
.filter((chip) => chip.dataset.stackId !== 'bing-labels')
.every((chip) => !chip.classList.contains('active')),
);
});
test('the lit chip tracks controller state, not the click', () => {
const container = makeElement();
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
renderMapStackChips(container, CONTROLLER_STACKS, {
activeId: 'photoreal',
doc,
});
// A rejected/superseded switch reports the stack that is genuinely active.
syncMapStackChips(container, 'photoreal');
@@ -151,11 +240,16 @@ test('the lit chip tracks controller state, not the click', () => {
test('keyless ion stacks stay focusable, aria-disabled, and say why', () => {
const container = makeElement();
const keyless = CONTROLLER_STACKS.map((stack) => (stack.requiresIon ? {
...stack,
available: false,
unavailableReason: 'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
} : stack));
const keyless = CONTROLLER_STACKS.map((stack) =>
stack.requiresIon
? {
...stack,
available: false,
unavailableReason:
'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
}
: stack,
);
const selected = [];
renderMapStackChips(container, keyless, {
activeId: 'photoreal',
@@ -164,59 +258,110 @@ test('keyless ion stacks stay focusable, aria-disabled, and say why', () => {
});
const bingAerial = container.children[1];
assert.equal(bingAerial.disabled, false, 'unavailable sources remain keyboard-reachable');
assert.equal(
bingAerial.disabled,
false,
'unavailable sources remain keyboard-reachable',
);
assert.equal(bingAerial.getAttribute('aria-disabled'), 'true');
assert.equal(
bingAerial.getAttribute('aria-label'),
'Bing Aerial unavailable: Needs CESIUM_ION_TOKEN — add it in Provider Settings',
);
assert.ok(bingAerial.classList.contains('unavailable'));
assert.equal(bingAerial.title, 'Needs CESIUM_ION_TOKEN — add it in Provider Settings');
assert.equal(
bingAerial.title,
'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
);
assert.equal(chipText(bingAerial), 'Bing Aerial ION');
bingAerial.click();
assert.deepEqual(selected, [], 'an unavailable stack must not reach the switch path');
assert.deepEqual(
selected,
[],
'an unavailable stack must not reach the switch path',
);
assert.equal(container.children[3].getAttribute('aria-disabled'), 'false', 'OSM stays selectable');
assert.equal(
container.children[3].getAttribute('aria-disabled'),
'false',
'OSM stays selectable',
);
});
test('a non-ion stack that fails never claims an ion token is required', () => {
// The startup fallback-to-OSM case: Google 3D tiles failed to load, so
// photoreal is unavailable for a reason that has nothing to do with ion.
const container = makeElement();
const tilesFailed = CONTROLLER_STACKS.map((stack) => (stack.id === 'photoreal' ? {
...stack,
available: false,
unavailableReason: 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
} : stack));
const tilesFailed = CONTROLLER_STACKS.map((stack) =>
stack.id === 'photoreal'
? {
...stack,
available: false,
unavailableReason:
'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
}
: stack,
);
renderMapStackChips(container, tilesFailed, { activeId: 'osm', doc });
const google = container.children[0];
assert.equal(google.getAttribute('aria-disabled'), 'true');
assert.equal(google.getAttribute('aria-label'), 'Google 3D unavailable: Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings');
assert.equal(chipText(google), 'Google 3D', 'no ION badge on a stack that does not need ion');
assert.equal(google.title, 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings');
assert.equal(chipText(container.children[1]), 'Bing Aerial', 'available ion stacks stay unbadged');
assert.equal(
google.getAttribute('aria-label'),
'Google 3D unavailable: Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
);
assert.equal(
chipText(google),
'Google 3D',
'no ION badge on a stack that does not need ion',
);
assert.equal(
google.title,
'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
);
assert.equal(
chipText(container.children[1]),
'Bing Aerial',
'available ion stacks stay unbadged',
);
});
test('models carry the stack\'s own reason and never invent an active chip', () => {
assert.deepEqual(mapStackChipModels([{ id: 'osm', label: 'OSM' }], null), [{
id: 'osm',
label: 'OSM',
available: true,
active: false,
requiresIon: false,
requirement: '',
unavailableHint: '',
title: 'OSM',
}]);
test("models carry the stack's own reason and never invent an active chip", () => {
assert.deepEqual(mapStackChipModels([{ id: 'osm', label: 'OSM' }], null), [
{
id: 'osm',
label: 'OSM',
available: true,
active: false,
requiresIon: false,
requirement: '',
unavailableHint: '',
title: 'OSM',
},
]);
// A stack list without a controller-supplied reason still explains itself.
assert.deepEqual(
[
mapStackChipModel({ id: 'bing-aerial', label: 'Bing Aerial', requiresIon: true, available: false }, null),
mapStackChipModel({ id: 'hybrid', label: 'Hybrid', available: false }, null),
].map(({ requirement, unavailableHint, title }) => ({ requirement, unavailableHint, title })),
mapStackChipModel(
{
id: 'bing-aerial',
label: 'Bing Aerial',
requiresIon: true,
available: false,
},
null,
),
mapStackChipModel(
{ id: 'hybrid', label: 'Hybrid', available: false },
null,
),
].map(({ requirement, unavailableHint, title }) => ({
requirement,
unavailableHint,
title,
})),
[
{
requirement: 'ION',
@@ -234,31 +379,12 @@ test('models carry the stack\'s own reason and never invent an active chip', ()
assert.deepEqual(mapStackChipModels(undefined, 'osm'), []);
});
test('the controller is the single source of the unavailability reason', () => {
const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
assert.doesNotMatch(
controller,
/bing-road/,
'Bing Road is retired: an old map=bing-road link must take the unknown-id photoreal fallback',
);
assert.match(
controller,
/getStacks\(\) \{[\s\S]*?unavailableReason: available \? null : this\._unavailableReason\(stack\)/,
'getStacks() must expose why a stack is unavailable, not just that it is',
);
assert.match(
controller,
/if \(!this\.isStackAvailable\(stack\.id\)\) \{\s*const message = this\._unavailableReason\(stack\);/,
'the toast and the chip tooltip must read the same reason',
);
assert.match(controller, /_unavailableReason\(stack\) \{[\s\S]*?stack\?\.requiresIon/);
});
test('a missing row or document is inert rather than throwing during boot', () => {
assert.deepEqual(renderMapStackChips(null, CONTROLLER_STACKS, { doc }), []);
assert.deepEqual(renderMapStackChips(makeElement(), CONTROLLER_STACKS, { doc: {} }), []);
assert.deepEqual(
renderMapStackChips(makeElement(), CONTROLLER_STACKS, { doc: {} }),
[],
);
assert.doesNotThrow(() => syncMapStackChips(null, 'osm'));
});
@@ -268,8 +394,14 @@ test('the active cyan survives hover', () => {
const active = css.indexOf('.map-stack-chip.active {');
const unavailable = css.indexOf('.map-stack-chip.unavailable');
assert.ok(hover > 0 && active > hover, 'active must follow hover so it wins at equal specificity');
assert.ok(unavailable > active, 'unavailable must follow both so a keyless chip never lights up');
assert.ok(
hover > 0 && active > hover,
'active must follow hover so it wins at equal specificity',
);
assert.ok(
unavailable > active,
'unavailable must follow both so a keyless chip never lights up',
);
assert.doesNotMatch(
css.slice(hover, active),
/:not\(/,
@@ -285,18 +417,25 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
// chip-state rule sets.
const css = readStylesheet(new URL('../style.css', import.meta.url));
const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '');
const chipRules = [...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g)]
.map(([, selector, body], order) => ({
selector: stripComments(selector).trim(),
body: stripComments(body),
order,
}));
const chipRules = [
...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g),
].map(([, selector, body], order) => ({
selector: stripComments(selector).trim(),
body: stripComments(body),
order,
}));
assert.ok(chipRules.length >= 5, 'expected the chip state rules to be found');
const ringIndex = chipRules.findIndex(({ selector, body }) => selector.includes(':focus-visible')
&& /outline:\s*(?!none)\S/.test(body)
&& /outline-offset:/.test(body));
assert.ok(ringIndex >= 0, 'a :focus-visible rule must draw a real outline ring');
const ringIndex = chipRules.findIndex(
({ selector, body }) =>
selector.includes(':focus-visible') &&
/outline:\s*(?!none)\S/.test(body) &&
/outline-offset:/.test(body),
);
assert.ok(
ringIndex >= 0,
'a :focus-visible rule must draw a real outline ring',
);
// Nothing after it may touch outline again, so the ring cannot be erased by
// .active, :disabled, or anything added later.
@@ -307,7 +446,10 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
`"${rule.selector}" must not touch outline — it would erase the focus ring`,
);
}
for (const selector of ['.map-stack-chip.active', '.map-stack-chip.unavailable']) {
for (const selector of [
'.map-stack-chip.active',
'.map-stack-chip.unavailable',
]) {
assert.ok(
chipRules.some((rule) => rule.selector.includes(selector)),
`expected a ${selector} rule to exist for this check to mean anything`,
@@ -317,83 +459,104 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
// `transition: all` animates outline-width off the `outline: none` base, and
// Chrome parks that transition at 0px for chips on a wrapped line — the ring
// never appeared on rows 2 and 3. The base rule must list its properties.
const base = chipRules.find((rule) => rule.selector.endsWith('.map-stack-chip'));
const base = chipRules.find((rule) =>
rule.selector.endsWith('.map-stack-chip'),
);
assert.ok(base, 'expected the base .map-stack-chip rule');
assert.doesNotMatch(
base.body,
/transition:\s*all\b/,
'transition: all animates outline-width and kills the focus ring on wrapped rows',
);
assert.doesNotMatch(base.body, /transition:[^;]*outline/, 'the focus ring must not be animated');
assert.match(base.body, /transition:\s*\n?\s*color/, 'the hover/active treatment still animates');
assert.doesNotMatch(
base.body,
/transition:[^;]*outline/,
'the focus ring must not be animated',
);
assert.match(
base.body,
/transition:\s*\n?\s*color/,
'the hover/active treatment still animates',
);
});
test('the Visual Presets tray owns Map Source and the retired left panel is absent', () => {
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const ui = readFileSync(
new URL('./ui/applicationShell.js', import.meta.url),
'utf8',
);
assert.doesNotMatch(html, /map-stack-select/, 'the SOURCE dropdown is replaced by the chip row');
assert.doesNotMatch(
html,
/map-stack-select/,
'the SOURCE dropdown is replaced by the chip row',
);
assert.match(
html,
/<section class="map-source-section"[\s\S]*?<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"><\/div>/,
);
assert.doesNotMatch(html, /id="stack-panel"/, 'the duplicate left MAP STACK panel is retired');
assert.match(html, /id="map-source-label">MAP SOURCE<[\s\S]*?id="map-stack-status"/);
assert.doesNotMatch(
html,
/id="stack-panel"/,
'the duplicate left MAP STACK panel is retired',
);
assert.match(
html,
/id="map-source-label">MAP SOURCE<[\s\S]*?id="map-stack-status"/,
);
assert.match(
html,
/<button id="control-panel-toggle"[\s\S]*?data-dock-toggle-target="control-panel"[\s\S]*?aria-controls="control-panel-popover"/,
'the compact wing must expose a semantic keyboard disclosure',
);
const panels = readFileSync(new URL('./ui/panelDisclosure.js', import.meta.url), 'utf8');
const panels = readFileSync(
new URL('./ui/panelDisclosure.js', import.meta.url),
'utf8',
);
assert.match(panels, /event\.key !== 'Escape'[\s\S]*?disclosure\?\.focus/);
assert.match(ui, /querySelector\(\s*'\.map-stack-chip\.active',?\s*\)\s*\|\|\s*panel\.querySelector\('\.map-stack-chip'\)/);
const controls = readFileSync(new URL('./ui/mapSourceControls.js', import.meta.url), 'utf8');
assert.match(ui, /return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/,
'the application action uses the component selection path');
assert.match(controls, /onSelect: [\s\S]*?select\(id\)/,
'chip clicks use the same component selection path');
assert.match(controls, /await controller\.setStack\(stackId\)/,
'selection still delegates source loading to the map controller');
assert.match(controls, /render\(controller\.getState\(\)\)/,
'the active chip is re-synced from actual controller state');
assert.match(ui, /window\.addEventListener\('gev:map-stack-changed', onChange\)/,
'provider-driven fallback reaches the component without a user click');
assert.match(ui, /window\.removeEventListener\('gev:map-stack-changed', onChange\)/,
'the provider-driven subscription has an explicit remover');
assert.match(controls, /unsubscribe\?\.\(\)/,
'component destruction releases its subscription');
});
test('Esri fallbacks report and attribute the imagery source actually rendered', () => {
const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
assert.match(
controller,
/effectiveStackId = 'osm';[\s\S]*?fallbackMessage = 'Esri Satellite is unavailable; using OSM'/,
'construction failure must resolve truthfully to OSM',
ui,
/querySelector\(\s*'\.map-stack-chip\.active',?\s*\)\s*\|\|\s*panel\.querySelector\('\.map-stack-chip'\)/,
);
const controls = readFileSync(
new URL('./ui/mapSourceControls.js', import.meta.url),
'utf8',
);
assert.match(
controller,
/this\._activeId = activation\?\.effectiveStackId \|\| stack\.id/,
'the active chip must follow the effective provider, not the requested stack',
ui,
/return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/,
'the application action uses the component selection path',
);
assert.match(
controller,
/this\._syncEsriAttribution\(resolution\.effectiveStackId\)/,
'Esri credit must follow the effective provider',
controls,
/onSelect: [\s\S]*?select\(id\)/,
'chip clicks use the same component selection path',
);
});
test('repeated active Esri tile failures fall back to OSM and one transient does not', () => {
const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
assert.match(controller, /let failures = 0/);
assert.match(controller, /if \(failures < 2 \|\| this\._esriFallbackPending\) return/);
assert.match(controller, /this\.setStack\('osm', \{ silent: true \}\)/);
assert.match(controller, /state\?\.activeId === 'osm'[\s\S]*?this\._emitChange\('error'\)/);
assert.match(
controller,
/gen !== this\._switchGen \|\| this\._activeImageryProvider !== resolution\.provider/,
'a stale provider error must not replace a newer user selection',
controls,
/await controller\.setStack\(stackId\)/,
'selection still delegates source loading to the map controller',
);
assert.match(
controls,
/render\(controller\.getState\(\)\)/,
'the active chip is re-synced from actual controller state',
);
assert.match(
ui,
/window\.addEventListener\('gev:map-stack-changed', onChange\)/,
'provider-driven fallback reaches the component without a user click',
);
assert.match(
ui,
/window\.removeEventListener\('gev:map-stack-changed', onChange\)/,
'the provider-driven subscription has an explicit remover',
);
assert.match(
controls,
/unsubscribe\?\.\(\)/,
'component destruction releases its subscription',
);
});
+25 -487
View File
@@ -1,494 +1,32 @@
import * as Cesium from 'cesium';
import { MapSourceController } from './maps/controller.js';
import { createDefaultMapSources } from './maps/defaultSources.js';
import { governorRequestRender } from './renderGovernor.js';
import { keySetupRequirement } from './keySetupCore.mjs';
export { MAP_STACKS } from './maps/catalog.js';
export { photorealUnavailableReason } from './maps/availability.js';
/**
* Why Google 3D is unavailable, phrased so the tooltip and toast recommend the
* RIGHT fix. With no credentials the fix is a key (or the ion route); with a
* key or ion token configured, the tileset failed for another reason —
* restrictions, quota, an EEA-billed key, or the network — and telling the
* user to add a key they already added is the wrong advice.
* @param {boolean} hasCredentials
* @returns {string}
*/
export function photorealUnavailableReason(hasCredentials) {
if (hasCredentials) return 'Google 3D tiles unavailable — check the key\'s API restrictions, quota, or network';
return `${keySetupRequirement('google-maps')} — or a Cesium ion token for the ion-hosted route`;
}
export const MAP_STACKS = [
{
id: 'photoreal',
label: 'Google 3D',
shortLabel: '3D',
kind: 'photoreal',
requiresIon: false,
},
{
id: 'bing-aerial',
label: 'Bing Aerial',
shortLabel: 'Aerial',
kind: 'ion',
style: Cesium.IonWorldImageryStyle.AERIAL,
requiresIon: true,
},
{
id: 'bing-labels',
label: 'Bing Labels',
shortLabel: 'Labels',
kind: 'ion',
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
requiresIon: true,
},
{
id: 'esri-imagery',
label: 'Esri Satellite',
shortLabel: 'SAT',
kind: 'esri-imagery',
requiresIon: false,
},
{
id: 'osm',
label: 'OSM',
shortLabel: 'OSM',
kind: 'osm',
requiresIon: false,
},
];
const DEFAULT_OSM_CREDIT = '© OpenStreetMap contributors';
// Esri World Imagery — the keyless satellite basemap and the default keyless
// landing (a spy-satellite simulator should open on satellite imagery, not a
// street map). The classic ArcGIS Online tile service answers without a key;
// attribution is required and the provider carries the service's own credit
// line. Terms note in DATA_SOURCES.md.
const ESRI_WORLD_IMAGERY_URL =
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer';
const ESRI_IMAGERY_CREDIT =
'Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community';
// The on-screen notice Esri requires when a third-party library draws its
// service. Rendered via an explicit static credit (see _syncEsriAttribution) —
// the provider's own `credit` option is ignored for tiled ArcGIS servers.
const ESRI_ATTRIBUTION_HTML =
'<a href="https://www.esri.com" target="_blank" rel="noopener">Powered by Esri</a>';
// Keyless global ellipsoidal terrain (Re:Earth Terrain / Mapterhorn, CC BY 4.0,
// EGM2008 geoid via NGA) — quantized-mesh 1.0, `ellipsoid` data-type. Fixes
// regime C (keyless globe stacks previously rendered a flat
// EllipsoidTerrainProvider — see docs/superpowers/specs/2026-07-05-entity-height-datum-design.md
// §1a). Constructed via `.fromUrl()`, never a hand-built `{z}/{x}/{y}.terrain`
// URL (review correction, spec §1a).
const REEARTH_TERRAIN_URL = 'https://terrain.reearth.land/cesium-mesh/ellipsoid';
/**
* Controls the active globe/map stack. Google Photorealistic 3D Tiles remain
* the cinematic default, while Cesium ion world imagery and OSM run as globe
* imagery stacks.
*/
export class MapStackController {
constructor(viewer, {
googleTileset = null,
cesiumToken = '',
initialStack = 'photoreal',
onChange = null,
onError = null,
} = {}) {
this.viewer = viewer;
this.googleTileset = googleTileset;
this.cesiumToken = String(cesiumToken || '').trim();
this._onChange = onChange;
this._onError = onError;
this._activeId = googleTileset ? initialStack : 'esri-imagery';
this._imageryLayer = null;
this._activeImageryProvider = null;
this._removeImageryErrorListener = null;
this._esriFallbackPending = false;
this._imageryProviders = new Map();
this._isSwitching = false;
this._lastError = null;
// Tracks which terrain PROVIDER is actually installed on the scene, not
// just an ion-available boolean: 'world' (Cesium World Terrain, ion
// token), 'keyless' (Re:Earth or its Ellipsoid fallback), or null (never
// set yet — Cesium's own startup default). Using a tri-state here (rather
// than the `enabled` boolean `_setWorldTerrainEnabled` receives) matters
// because both the "never set" and "keyless" states pass `enabled=false`;
// collapsing them to a boolean would make the first real keyless switch
// a no-op against the initial `false` default and leave Cesium's built-in
// provider in place instead of installing Re:Earth terrain.
this._terrainMode = null;
// Cache of the constructed keyless Re:Earth CesiumTerrainProvider, so
// repeat switches into a keyless globe stack don't refetch `layer.json`.
// Lives independently of `_switchGen` — construction is async and racy
// switches are guarded where it's awaited (`_setWorldTerrainEnabled`).
this._reearthTerrainProvider = null;
// Monotonic switch counter. setStack() awaits network-bound provider
// creation; a rapid A→B switch where A (e.g. slow Bing) resolves AFTER B
// (fast OSM) would otherwise revert the user's last choice (M7). Each call
// captures a generation and aborts its own commit once superseded.
this._switchGen = 0;
if (!this.getStack(this._activeId) || !this.isStackAvailable(this._activeId)) {
this._activeId = googleTileset ? 'photoreal' : 'esri-imagery';
}
}
getStacks() {
return MAP_STACKS.map((stack) => {
const available = this.isStackAvailable(stack.id);
return {
...stack,
available,
// Why this stack can't be picked, from the ONE place that decides it.
// A stack can be unavailable for reasons other than a missing ion
// token (photoreal is unavailable when the Google tileset failed to
// load), so callers must not infer the reason from `available` alone.
unavailableReason: available ? null : this._unavailableReason(stack),
};
/** Preserve the standalone entry point; applications can compose the source controller directly. */
export class MapStackController extends MapSourceController {
constructor(viewer, options = {}) {
const googleApiKey =
typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
const registry = createDefaultMapSources({ ...options, googleApiKey });
super(viewer, {
registry,
initialStack: options.googleTileset
? options.initialStack || 'photoreal'
: registry.defaultId,
...options,
requestRender: governorRequestRender,
});
this.googleTileset = options.googleTileset || null;
this.cesiumToken = String(options.cesiumToken || '').trim();
}
/**
* Human-readable reason a stack can't be activated. Shared by `getStacks()`
* and `setStack()` so the tooltip and the toast never drift apart.
* @param {object} stack - Stack descriptor.
* @returns {string}
*/
_unavailableReason(stack) {
if (stack?.requiresIon) return keySetupRequirement('cesium-ion');
if (stack?.kind === 'photoreal') return photorealUnavailableReason(this._hasPhotorealCredentials());
return `${stack?.label || 'This map stack'} is unavailable`;
}
/** A direct Google key or an ion token is enough to attempt Google 3D. */
_hasPhotorealCredentials() {
const googleKey = typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
return Boolean(String(googleKey || '').trim()) || Boolean(String(this.cesiumToken || '').trim());
}
getStack(id) {
return MAP_STACKS.find((stack) => stack.id === id) || null;
}
getActiveId() {
return this._activeId;
}
/**
* Monotonic id of the most recently STARTED switch.
*
* A switch is only superseded by another `setStack()` — nothing else moves
* this number — so a caller that must know whether the globe it is looking
* at is still the one IT asked for can compare this across its own await.
* Unchanged (or advanced by exactly its own call) means no newer switch has
* claimed the globe.
* @returns {number}
*/
getSwitchGeneration() {
return this._switchGen;
}
getActiveStack() {
return this.getStack(this._activeId);
}
isStackAvailable(id) {
const stack = this.getStack(id);
if (!stack) return false;
if (stack.kind === 'photoreal') return !!this.googleTileset;
if (stack.requiresIon) return !!this.cesiumToken;
return true;
}
/** Invalidate pending switches before the viewer is destroyed. */
destroy() {
if (this._destroyed) return;
this._destroyed = true;
this._switchGen++;
this._isSwitching = false;
this._removeImageryErrorListener?.();
this._removeImageryErrorListener = null;
this._onChange = null;
this._onError = null;
}
async setStack(id, { silent = false } = {}) {
if (this._destroyed) return this.getState();
const stack = this.getStack(id) || this.getStack('photoreal');
if (!stack) return null;
if (!this.isStackAvailable(stack.id)) {
const message = this._unavailableReason(stack);
this._lastError = message;
this._onError?.(message, stack);
return this.getState();
}
const gen = ++this._switchGen;
this._isSwitching = true;
this._lastError = null;
if (!silent) this._emitChange('switching');
try {
let activation = null;
if (stack.kind === 'photoreal') {
await this._activatePhotoreal(gen);
} else {
activation = await this._activateGlobeStack(stack, gen);
}
// A newer switch started while we were awaiting the provider — that call
// owns the final state now, so don't commit ours or emit a stale 'ready'.
if (gen !== this._switchGen) return this.getState();
this._activeId = activation?.effectiveStackId || stack.id;
if (activation?.fallbackMessage) {
this._lastError = activation.fallbackMessage;
this._onError?.(activation.fallbackMessage, stack);
}
// Show/hide of tilesets + imagery swaps need a frame in idle mode;
// subsequent tile loads self-request via Cesium. (perf wave 2)
governorRequestRender('map-stack');
if (!silent) this._emitChange('ready');
} catch (error) {
if (gen !== this._switchGen) return this.getState();
const message = error?.message || String(error);
this._lastError = message;
this._onError?.(message, stack);
if (this.googleTileset) {
await this._activatePhotoreal(gen);
if (gen !== this._switchGen) return this.getState();
this._activeId = 'photoreal';
}
if (!silent) this._emitChange('error');
} finally {
// Only the latest switch clears the switching flag; a superseded call
// must not stomp a newer switch that is still in progress.
if (gen === this._switchGen) this._isSwitching = false;
}
return this.getState();
}
getState(status = this._isSwitching ? 'switching' : 'ready') {
return {
activeId: this._activeId,
activeStack: this.getActiveStack(),
stacks: this.getStacks(),
status,
lastError: this._lastError,
hasCesiumIonToken: !!this.cesiumToken,
};
}
async _activatePhotoreal(gen) {
this._removeImageryLayer();
this._syncEsriAttribution(null); // Esri is no longer on screen.
if (this.googleTileset) this.googleTileset.show = true;
this.viewer.scene.globe.show = false;
// Terrain is left UNTOUCHED here. The photoreal globe is hidden
// (`globe.show = false`), so the terrain provider is inert — it renders and
// streams nothing. Routing this through `_setWorldTerrainEnabled(false)`
// would make the DEFAULT startup stack await a keyless Re:Earth `layer.json`
// fetch it can't use, delaying photoreal boot on a slow/blocked network and
// (on failure) caching the flat `EllipsoidTerrainProvider` fallback for
// later OSM switches. The Re:Earth fetch is therefore lazy: it happens on
// the first switch to an actual globe stack (`_activateGlobeStack`).
// `_terrainMode` is intentionally not changed — every globe-stack transition
// re-derives the correct provider from it (null/'world'/'keyless'), so
// leaving it as-is keeps the next switch correct without a photoreal fetch.
void gen;
}
async _activateGlobeStack(stack, gen) {
const resolution = await this._getImageryProvider(stack);
// A newer switch started while the provider was resolving — don't touch the
// scene's imagery layers, the winning switch already owns them (M7).
if (gen != null && gen !== this._switchGen) return;
this._removeImageryLayer();
this._imageryLayer = new Cesium.ImageryLayer(resolution.provider);
this._activeImageryProvider = resolution.provider;
this.viewer.imageryLayers.add(this._imageryLayer, 0);
this._syncEsriAttribution(resolution.effectiveStackId);
this._watchEsriProvider(resolution, gen);
if (this.googleTileset) this.googleTileset.show = false;
this.viewer.scene.globe.show = true;
await this._setWorldTerrainEnabled(!!this.cesiumToken, gen);
return resolution;
}
/**
* Show or hide the required "Powered by Esri" notice with the Esri layer's
* own lifecycle.
*
* This cannot ride on the provider's `credit` option: Cesium IGNORES that
* option for tiled ArcGIS MapServer sources, so passing it there displays
* nothing and the app would be using the service without the attribution
* Esri requires of third-party libraries. It is an ON-SCREEN credit (not the
* lightbox, where per-layer data credits live) because that is what the
* requirement asks for, and it is removed when another stack takes over so
* the globe never claims a source it is not showing.
*/
_syncEsriAttribution(activeStackId) {
const creditDisplay = this.viewer?.scene?.frameState?.creditDisplay;
if (!creditDisplay) return;
const wanted = activeStackId === 'esri-imagery';
if (wanted === !!this._esriCreditShown) return;
if (!this._esriCredit) {
this._esriCredit = new Cesium.Credit(ESRI_ATTRIBUTION_HTML, true);
}
try {
if (wanted) creditDisplay.addStaticCredit(this._esriCredit);
else creditDisplay.removeStaticCredit(this._esriCredit);
this._esriCreditShown = wanted;
} catch {
// A Cesium build without static-credit removal must not break switching.
}
}
async _getImageryProvider(stack) {
if (this._imageryProviders.has(stack.id)) {
return this._imageryProviders.get(stack.id);
}
let provider;
let effectiveStackId = stack.id;
let fallbackMessage = null;
if (stack.kind === 'ion') {
provider = await Cesium.createWorldImageryAsync({ style: stack.style });
} else if (stack.kind === 'esri-imagery') {
try {
provider = await Cesium.ArcGisMapServerImageryProvider.fromUrl(ESRI_WORLD_IMAGERY_URL, {
credit: ESRI_IMAGERY_CREDIT,
enablePickFeatures: false,
});
} catch (error) {
// The keyless DEFAULT landing must never strand a first run on a blank
// globe because Esri is unreachable — fall back to OSM tiles for this
// session. (The fallback is cached under this stack id like any other
// provider, so the session won't re-probe Esri; a restart does.)
console.warn('[MapStack] Esri World Imagery unavailable, falling back to OSM:', error?.message || error);
provider = new Cesium.OpenStreetMapImageryProvider({
url: 'https://tile.openstreetmap.org/',
credit: DEFAULT_OSM_CREDIT,
});
effectiveStackId = 'osm';
fallbackMessage = 'Esri Satellite is unavailable; using OSM';
}
} else if (stack.kind === 'osm') {
provider = new Cesium.OpenStreetMapImageryProvider({
url: 'https://tile.openstreetmap.org/',
credit: DEFAULT_OSM_CREDIT,
});
} else {
throw new Error(`Unsupported map stack: ${stack.id}`);
}
const resolution = { provider, effectiveStackId, fallbackMessage };
this._imageryProviders.set(stack.id, resolution);
if (effectiveStackId === 'osm' && !this._imageryProviders.has('osm')) {
this._imageryProviders.set('osm', { provider, effectiveStackId: 'osm', fallbackMessage: null });
}
return resolution;
}
/**
* Esri provider construction can succeed while its first tile requests fail.
* Two failures for the active provider trigger the same truthful OSM fallback
* as a construction failure; one transient error is left to Cesium's retry.
*/
_watchEsriProvider(resolution, gen) {
if (resolution.effectiveStackId !== 'esri-imagery') return;
const errorEvent = resolution.provider?.errorEvent;
if (!errorEvent?.addEventListener) return;
let failures = 0;
this._removeImageryErrorListener = errorEvent.addEventListener((error) => {
if (gen !== this._switchGen || this._activeImageryProvider !== resolution.provider) return;
const retryCount = Number(error?.timesRetried);
failures = Number.isInteger(retryCount) && retryCount >= 0
? Math.max(failures + 1, retryCount + 1)
: failures + 1;
if (failures < 2 || this._esriFallbackPending) return;
this._esriFallbackPending = true;
const message = 'Esri Satellite tile requests failed; using OSM';
this._onError?.(message, this.getStack('esri-imagery'));
void this.setStack('osm', { silent: true }).then((state) => {
if (state?.activeId === 'osm') {
this._lastError = message;
this._emitChange('error');
}
}).finally(() => {
this._esriFallbackPending = false;
});
});
}
_removeImageryLayer() {
if (this._removeImageryErrorListener) {
this._removeImageryErrorListener();
this._removeImageryErrorListener = null;
}
if (!this._imageryLayer) return;
this.viewer.imageryLayers.remove(this._imageryLayer, false);
this._imageryLayer = null;
this._activeImageryProvider = null;
}
/**
* Sets the scene's terrain provider for the current globe stack.
*
* `enabled` selects Cesium World Terrain (ion token present — regime B,
* unchanged). Disabled/keyless (regime C: OSM or any globe stack without an
* ion token) now tries the keyless Re:Earth ellipsoidal terrain instead of
* the flat `EllipsoidTerrainProvider`, falling back to the flat provider
* (today's behavior) if construction fails — no worse than before this fix.
*
* `CesiumTerrainProvider.fromUrl()` is async (fetches `layer.json`), so this
* method is async-safe: `gen` is the caller's switch generation (from
* `setStack`'s `_switchGen`, threaded through `_activatePhotoreal` /
* `_activateGlobeStack`, mirroring the M7 pattern in `_activateGlobeStack`
* for imagery providers). If a newer switch starts while the Re:Earth
* fetch is in flight, this call's result is discarded instead of
* clobbering the newer switch's terrain.
* @param {boolean} enabled
* @param {number} [gen] — switch generation this call belongs to
*/
async _setWorldTerrainEnabled(enabled, gen) {
const targetMode = enabled ? 'world' : 'keyless';
if (targetMode === this._terrainMode) return;
if (enabled) {
this.viewer.scene.setTerrain(Cesium.Terrain.fromWorldTerrain({
requestVertexNormals: true,
}));
} else {
const provider = await this._getKeylessTerrainProvider();
// A newer switch started while the Re:Earth layer.json fetch was in
// flight — that call owns terrain now; don't stomp it (M7 pattern).
if (gen != null && gen !== this._switchGen) return;
this.viewer.terrainProvider = provider;
}
this._terrainMode = targetMode;
}
/**
* Resolves (and caches) the keyless terrain provider for globe stacks
* without an ion token: Re:Earth ellipsoidal quantized-mesh terrain, or
* `EllipsoidTerrainProvider` (flat — current/prior behavior) if the
* Re:Earth endpoint can't be constructed. Never throws.
* @returns {Promise<Cesium.TerrainProvider>}
*/
async _getKeylessTerrainProvider() {
if (this._reearthTerrainProvider) return this._reearthTerrainProvider;
try {
this._reearthTerrainProvider = await Cesium.CesiumTerrainProvider.fromUrl(REEARTH_TERRAIN_URL);
} catch (error) {
console.warn('[mapStackController] Re:Earth terrain unavailable, falling back to flat ellipsoid terrain:', error);
this._reearthTerrainProvider = new Cesium.EllipsoidTerrainProvider();
}
return this._reearthTerrainProvider;
}
_emitChange(status) {
this._onChange?.(this.getState(status));
const googleKey =
typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
return (
Boolean(String(googleKey || '').trim()) ||
Boolean(String(this.cesiumToken || '').trim())
);
}
}
+8 -2
View File
@@ -1,9 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { MapStackController, photorealUnavailableReason } from './mapStackController.js';
import {
MapStackController,
photorealUnavailableReason,
} from './mapStackController.js';
test('missing photoreal credentials explain both supported setup routes', () => {
assert.match(photorealUnavailableReason(false), /Needs GOOGLE_MAPS_API_KEY.*Provider Settings/);
assert.match(
photorealUnavailableReason(false),
/Needs GOOGLE_MAPS_API_KEY.*Provider Settings/,
);
assert.match(photorealUnavailableReason(false), /Cesium ion token/);
});
+4 -51
View File
@@ -1,51 +1,4 @@
const clean = (value) => String(value || '').trim();
/**
* Decide which map provider can deliver the best startup experience.
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
* @returns {'google-direct'|'google-ion'|'osm'}
*/
export function selectMapStartupRoute({ googleApiKey = '', cesiumToken = '' } = {}) {
if (clean(googleApiKey)) return 'google-direct';
if (clean(cesiumToken)) return 'google-ion';
return 'osm';
}
/**
* Load Google Photorealistic 3D Tiles through direct Google access when
* configured, otherwise through Cesium ion's hosted Google asset. If the
* direct request fails and an ion token is available, ion is the recovery path.
*
* @param {object} Cesium
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
*/
export async function loadPhotorealisticTileset(
Cesium,
{ googleApiKey = '', cesiumToken = '' } = {},
) {
const googleKey = clean(googleApiKey);
const ionToken = clean(cesiumToken);
const errors = [];
if (ionToken) Cesium.Ion.defaultAccessToken = ionToken;
const attempts = [];
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
for (const attempt of attempts) {
Cesium.GoogleMaps.defaultApiKey = attempt.googleKey;
try {
const tileset = await Cesium.createGooglePhotorealistic3DTileset({
onlyUsingWithGoogleGeocoder: true,
});
return { tileset, route: attempt.route, errors };
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
Cesium.GoogleMaps.defaultApiKey = undefined;
return { tileset: null, route: 'osm', errors };
}
export {
selectMapStartupRoute,
loadPhotorealisticTileset,
} from './maps/google3d.js';
+50 -12
View File
@@ -7,25 +7,42 @@ import {
function fakeCesium(outcomes = []) {
const calls = [];
const next = () => {
const outcome = outcomes.shift();
if (outcome instanceof Error) throw outcome;
return outcome;
};
return {
calls,
Ion: { defaultAccessToken: undefined },
GoogleMaps: { defaultApiKey: undefined },
async createGooglePhotorealistic3DTileset(options) {
calls.push({
options,
googleKey: this.GoogleMaps.defaultApiKey,
ionToken: this.Ion.defaultAccessToken,
});
const outcome = outcomes.shift();
if (outcome instanceof Error) throw outcome;
return outcome;
calls.push({ options, googleKey: options.key });
return next();
},
IonResource: {
async fromAssetId(assetId, options) {
return { assetId, ...options };
},
},
Cesium3DTileset: {
async fromUrl(resource, options) {
calls.push({
options,
ionToken: resource.accessToken,
assetId: resource.assetId,
});
return next();
},
},
};
}
test('map startup route reflects the best configured provider', () => {
assert.equal(selectMapStartupRoute({ googleApiKey: 'google', cesiumToken: 'ion' }), 'google-direct');
assert.equal(
selectMapStartupRoute({ googleApiKey: 'google', cesiumToken: 'ion' }),
'google-direct',
);
assert.equal(selectMapStartupRoute({ cesiumToken: 'ion' }), 'google-ion');
assert.equal(selectMapStartupRoute(), 'osm');
});
@@ -53,13 +70,17 @@ test('a direct Google key is preferred', async () => {
test('an ion-only setup loads the hosted Google 3D asset', async () => {
const tileset = { id: 'ion' };
const Cesium = fakeCesium([tileset]);
const result = await loadPhotorealisticTileset(Cesium, { cesiumToken: 'ion-secret' });
const result = await loadPhotorealisticTileset(Cesium, {
cesiumToken: 'ion-secret',
});
assert.equal(result.tileset, tileset);
assert.equal(result.route, 'google-ion');
assert.equal(Cesium.calls.length, 1);
assert.equal(Cesium.calls[0].googleKey, undefined);
assert.equal(Cesium.calls[0].ionToken, 'ion-secret');
assert.equal(Cesium.Ion.defaultAccessToken, 'ion-secret');
assert.equal(Cesium.Ion.defaultAccessToken, undefined);
assert.equal(Cesium.calls[0].assetId, 2275207);
assert.equal(Cesium.calls[0].options.enableCollision, true);
});
test('a failed direct request retries through ion before falling back', async () => {
@@ -91,7 +112,10 @@ test('a failed direct-only request does not consume an implicit Cesium token', a
});
test('failed direct and ion requests preserve the keyless OSM fallback', async () => {
const Cesium = fakeCesium([new Error('direct denied'), new Error('ion denied')]);
const Cesium = fakeCesium([
new Error('direct denied'),
new Error('ion denied'),
]);
const result = await loadPhotorealisticTileset(Cesium, {
googleApiKey: 'google-secret',
cesiumToken: 'ion-secret',
@@ -102,3 +126,17 @@ test('failed direct and ion requests preserve the keyless OSM fallback', async (
assert.equal(Cesium.calls.length, 2);
assert.equal(Cesium.GoogleMaps.defaultApiKey, undefined);
});
test('independent source configurations never mutate shared SDK credentials', async () => {
const Cesium = fakeCesium([{ id: 'a' }, { id: 'b' }]);
Cesium.Ion.defaultAccessToken = 'untouched-ion';
Cesium.GoogleMaps.defaultApiKey = 'untouched-google';
await Promise.all([
loadPhotorealisticTileset(Cesium, { googleApiKey: 'source-a' }),
loadPhotorealisticTileset(Cesium, { cesiumToken: 'source-b' }),
]);
assert.equal(Cesium.calls[0].googleKey, 'source-a');
assert.equal(Cesium.calls[1].ionToken, 'source-b');
assert.equal(Cesium.Ion.defaultAccessToken, 'untouched-ion');
assert.equal(Cesium.GoogleMaps.defaultApiKey, 'untouched-google');
});
+15
View File
@@ -0,0 +1,15 @@
import { keySetupRequirement } from '../keySetupCore.mjs';
/**
* Why Google 3D is unavailable, phrased so the tooltip and toast recommend the
* RIGHT fix. With no credentials the fix is a key (or the ion route); with a
* key or ion token configured, the tileset failed for another reason —
* restrictions, quota, an EEA-billed key, or the network — and telling the
* user to add a key they already added is the wrong advice.
* @param {boolean} hasCredentials
* @returns {string}
*/
export function photorealUnavailableReason(hasCredentials) {
if (hasCredentials)
return "Google 3D tiles unavailable — check the key's API restrictions, quota, or network";
return `${keySetupRequirement('google-maps')} — or a Cesium ion token for the ion-hosted route`;
}
+40
View File
@@ -0,0 +1,40 @@
import * as Cesium from 'cesium';
export const MAP_STACKS = [
{
id: 'photoreal',
label: 'Google 3D',
shortLabel: '3D',
kind: 'photoreal',
requiresIon: false,
},
{
id: 'bing-aerial',
label: 'Bing Aerial',
shortLabel: 'Aerial',
kind: 'ion',
style: Cesium.IonWorldImageryStyle.AERIAL,
requiresIon: true,
},
{
id: 'bing-labels',
label: 'Bing Labels',
shortLabel: 'Labels',
kind: 'ion',
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
requiresIon: true,
},
{
id: 'esri-imagery',
label: 'Esri Satellite',
shortLabel: 'SAT',
kind: 'esri-imagery',
requiresIon: false,
},
{
id: 'osm',
label: 'OSM',
shortLabel: 'OSM',
kind: 'osm',
requiresIon: false,
},
];
+335
View File
@@ -0,0 +1,335 @@
import { indexMapSources } from './registry.js';
import * as Cesium from 'cesium';
import { createMapCredits } from './credits.js';
/** Coordinate source lifetimes and scene changes; the registry owns provider choices. */
export class MapSourceController {
constructor(
viewer,
{
registry,
initialStack,
onChange = null,
onError = null,
requestRender = () => viewer?.scene?.requestRender?.(),
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
},
) {
this.viewer = viewer;
this._registry = registry;
this._sources = indexMapSources(registry.sources);
this._activeId = this.isStackAvailable(initialStack)
? initialStack
: registry.defaultId;
this._onChange = onChange;
this._onError = onError;
this._requestRender = requestRender;
this._createImageryLayer = createImageryLayer;
this._credits = createMapCredits(viewer);
this._abort = new AbortController();
this._imageryProviders = new Map();
this._terrainProviders = new Map();
this._tilesets = new Map();
this._ownedTilesets = new Set();
this._disposed = new WeakSet();
this._switchGen = 0;
this._isSwitching = false;
this._lastError = null;
this._imageryLayer = null;
this._activeImageryProvider = null;
this._removeImageryErrorListener = null;
this._terrainMode = null;
this._destroyed = false;
}
getStack(id) {
return this._sources.get(id)?.descriptor || null;
}
getStacks() {
return [...this._sources.values()].map(({ descriptor }) => {
const stack = descriptor;
const available = this.isStackAvailable(stack.id);
return {
...stack,
available,
unavailableReason: available ? null : this._unavailableReason(stack),
};
});
}
isStackAvailable(id) {
const source = this._sources.get(id);
return Boolean(source && source.available !== false);
}
_unavailableReason(stack) {
return (
this._sources.get(stack?.id)?.unavailableReason ||
`${stack?.label || 'This map stack'} is unavailable`
);
}
getActiveId() {
return this._activeId;
}
getActiveStack() {
return this.getStack(this._activeId);
}
getSwitchGeneration() {
return this._switchGen;
}
getState(status = this._isSwitching ? 'switching' : 'ready') {
return {
activeId: this._activeId,
activeStack: this.getActiveStack(),
stacks: this.getStacks(),
status,
lastError: this._lastError,
...this._registry.state,
};
}
_emitChange(status) {
this._onChange?.(this.getState(status));
}
async setStack(id, { silent = false } = {}) {
if (this._destroyed) return this.getState();
const stack = this.getStack(id) || this.getStack(this._registry.unknownId);
if (!stack) return null;
if (!this.isStackAvailable(stack.id)) {
const message = this._unavailableReason(stack);
this._lastError = message;
this._onError?.(message, stack);
return this.getState();
}
const gen = ++this._switchGen;
this._isSwitching = true;
this._lastError = null;
if (!silent) this._emitChange('switching');
try {
const activation = await this._activate(stack, gen);
if (gen !== this._switchGen) return this.getState();
this._activeId = activation?.effectiveStackId || stack.id;
if (activation?.fallbackMessage) {
this._lastError = activation.fallbackMessage;
this._onError?.(activation.fallbackMessage, stack);
}
this._requestRender('map-stack');
if (!silent) this._emitChange('ready');
} catch (error) {
if (gen !== this._switchGen) return this.getState();
const message = error?.message || String(error);
this._lastError = message;
this._onError?.(message, stack);
const recovery = this.getStack(this._registry.recoveryId);
if (
recovery &&
recovery.id !== stack.id &&
this.isStackAvailable(recovery.id)
) {
await this._activate(recovery, gen);
if (gen !== this._switchGen) return this.getState();
this._activeId = recovery.id;
}
if (!silent) this._emitChange('error');
} finally {
if (gen === this._switchGen) this._isSwitching = false;
}
return this.getState();
}
_activate(stack, gen) {
const source = this._sources.get(stack.id);
return source.imagery
? this._activateGlobeStack(stack, gen)
: this._activateTileset(source, gen);
}
async _activateTileset(source, gen) {
let tileset = source.tileset;
if (!tileset) {
if (!source.createTileset)
throw new Error(`Missing 3D source: ${source.descriptor.id}`);
tileset = await this._cached(
this._tilesets,
source.descriptor.id,
async () => {
const value = await source.createTileset({
signal: this._abort.signal,
});
if (this._destroyed) {
this._dispose(value);
return value;
}
value.show = false;
this.viewer.scene.primitives.add(value);
this._ownedTilesets.add(value);
return value;
},
);
}
if (gen !== this._switchGen) return;
this._removeImageryLayer();
this._credits.show(source.credit || null);
this._showTileset(tileset);
this.viewer.scene.globe.show = false;
// Terrain is intentionally untouched while the globe is hidden.
}
_showTileset(active) {
for (const source of this._sources.values())
if (source.tileset) source.tileset.show = source.tileset === active;
for (const tileset of this._ownedTilesets)
tileset.show = tileset === active;
}
async _activateGlobeStack(stack, gen) {
const resolution = await this._getImageryProvider(stack);
if (gen !== this._switchGen) return;
this._removeImageryLayer();
this._imageryLayer = this._createImageryLayer(resolution.provider);
this._activeImageryProvider = resolution.provider;
this.viewer.imageryLayers.add(this._imageryLayer, 0);
const source = this._sources.get(resolution.effectiveStackId);
this._credits.show(source?.credit || null);
this._watchProvider(resolution, gen);
this._showTileset(null);
this.viewer.scene.globe.show = true;
this._activeId = resolution.effectiveStackId;
if (source?.terrain && source.terrain.id !== this._terrainMode) {
const terrain = source.terrain;
const result = await this._cached(
this._terrainProviders,
terrain.id,
() => terrain.create({ signal: this._abort.signal }),
);
if (gen !== this._switchGen) return;
if (result.terrain) this.viewer.scene.setTerrain(result.terrain);
else this.viewer.terrainProvider = result.provider;
this._terrainMode = terrain.id;
}
return resolution;
}
_cached(cache, id, create) {
if (cache.has(id)) return cache.get(id);
const promise = Promise.resolve()
.then(() => {
this._abort.signal.throwIfAborted();
return create();
})
.catch((error) => {
if (cache.get(id) === promise) cache.delete(id);
throw error;
});
cache.set(id, promise);
return promise;
}
_getImageryProvider(stack, visited = new Set()) {
if (visited.has(stack.id))
return Promise.reject(new Error('Map source fallback cycle'));
return this._cached(this._imageryProviders, stack.id, async () => {
const source = this._sources.get(stack.id);
try {
const provider = await source.imagery({ signal: this._abort.signal });
if (this._destroyed) this._dispose(provider);
return { provider, effectiveStackId: stack.id, fallbackMessage: null };
} catch (error) {
const fallback = source.constructionFallback;
if (this._destroyed || !fallback || !this.isStackAvailable(fallback.id))
throw error;
const next = new Set(visited).add(stack.id);
const resolution = await this._getImageryProvider(
this.getStack(fallback.id),
next,
);
return { ...resolution, fallbackMessage: fallback.message };
}
});
}
_watchProvider(resolution, gen) {
const fallback = this._sources.get(
resolution.effectiveStackId,
)?.tileFailureFallback;
const errorEvent = resolution.provider?.errorEvent;
if (!fallback || !errorEvent?.addEventListener) return;
let failures = 0;
let pending = false;
this._removeImageryErrorListener = errorEvent.addEventListener((error) => {
if (
gen !== this._switchGen ||
this._activeImageryProvider !== resolution.provider
)
return;
const retryCount = Number(error?.timesRetried);
failures =
Number.isInteger(retryCount) && retryCount >= 0
? Math.max(failures + 1, retryCount + 1)
: failures + 1;
if (failures < fallback.threshold || pending) return;
pending = true;
this._onError?.(
fallback.message,
this.getStack(resolution.effectiveStackId),
);
const expectedGen = this._switchGen + 1;
void this.setStack(fallback.id, { silent: true })
.then((state) => {
if (
!this._destroyed &&
this._switchGen === expectedGen &&
state?.activeId === fallback.id
) {
this._lastError = fallback.message;
this._emitChange('error');
}
})
.finally(() => {
pending = false;
});
});
}
_removeImageryLayer() {
this._removeImageryErrorListener?.();
this._removeImageryErrorListener = null;
if (this._imageryLayer)
this.viewer.imageryLayers.remove(this._imageryLayer, true);
this._imageryLayer = null;
this._activeImageryProvider = null;
}
_dispose(value) {
if (!value || this._disposed.has(value)) return;
this._disposed.add(value);
if (typeof value.destroy === 'function' && !value.isDestroyed?.())
value.destroy();
}
destroy() {
if (this._destroyed) return;
this._destroyed = true;
this._switchGen++;
this._abort.abort();
this._isSwitching = false;
this._removeImageryLayer();
this._credits.destroy();
for (const promise of this._imageryProviders.values())
void Promise.resolve(promise).then(
(value) => this._dispose(value.provider),
() => {},
);
for (const promise of this._terrainProviders.values())
void Promise.resolve(promise).then(
(value) => this._dispose(value.provider),
() => {},
);
for (const tileset of this._ownedTilesets) {
this.viewer.scene.primitives.remove(tileset);
this._dispose(tileset);
}
this._imageryProviders.clear();
this._terrainProviders.clear();
this._tilesets.clear();
this._ownedTilesets.clear();
this._onChange = null;
this._onError = null;
}
}
+300
View File
@@ -0,0 +1,300 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { MapSourceController } from './controller.js';
import { createDefaultMapSources } from './defaultSources.js';
function event() {
const listeners = new Set();
return {
addEventListener(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
raise(value = {}) {
for (const fn of [...listeners]) fn(value);
},
get size() {
return listeners.size;
},
};
}
function fixture(registry, options = {}) {
const imagery = [],
credits = new Set(),
primitives = [],
removed = [],
changes = [];
const viewer = {
scene: {
globe: { show: true },
requestRender() {},
frameState: {
creditDisplay: {
addStaticCredit(value) {
credits.add(value);
},
removeStaticCredit(value) {
credits.delete(value);
},
},
},
primitives: {
add(value) {
primitives.push(value);
},
remove(value) {
primitives.splice(primitives.indexOf(value), 1);
value.destroy?.();
},
},
},
imageryLayers: {
add(value) {
imagery.push(value);
},
remove(value, destroy) {
imagery.splice(imagery.indexOf(value), 1);
removed.push({ value, destroy });
},
},
};
const controller = new MapSourceController(viewer, {
registry,
createImageryLayer: (provider) => ({ provider }),
onChange: (state) => changes.push(state),
...options,
});
return { viewer, controller, imagery, credits, primitives, removed, changes };
}
const settle = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
const descriptor = (id) => ({ id, label: id, kind: 'imagery' });
function publicFixture() {
const tileset = { show: true };
const registry = createDefaultMapSources({ googleTileset: tileset });
const providers = new Map();
for (const source of registry.sources) {
if (!source.imagery) continue;
const provider = { id: source.descriptor.id, errorEvent: event() };
providers.set(source.descriptor.id, provider);
source.imagery = async () => provider;
source.terrain = {
id: 'keyless',
create: async () => ({ provider: { id: 'terrain' } }),
};
}
return { ...fixture(registry), registry, providers, tileset };
}
test('an additional imagery source needs no controller branch and owns its cached resources', async () => {
let reads = 0,
destroys = 0,
terrainReads = 0;
const provider = {
destroy() {
destroys++;
},
};
const terrain = {
id: 'custom-floor',
create: async () => {
terrainReads++;
return { provider: { id: 'terrain' } };
},
};
const registry = {
defaultId: 'custom',
sources: [
{
descriptor: descriptor('custom'),
imagery: async () => {
reads++;
return provider;
},
terrain,
},
],
};
const env = fixture(registry);
await env.controller.setStack('custom');
await env.controller.setStack('custom');
assert.equal(env.controller.getActiveId(), 'custom');
assert.equal(env.viewer.terrainProvider.id, 'terrain');
assert.equal(reads, 1);
assert.equal(terrainReads, 1);
assert.equal(env.imagery.length, 1);
assert.equal(env.removed[0].destroy, true);
env.controller.destroy();
env.controller.destroy();
await settle();
assert.equal(env.imagery.length, 0);
assert.equal(destroys, 1);
});
test('a destroyed controller aborts creation and disposes a late provider without touching the scene', async () => {
let resolve,
signal,
destroyed = 0;
const env = fixture({
defaultId: 'slow',
sources: [
{
descriptor: descriptor('slow'),
imagery: (request) => {
signal = request.signal;
return new Promise((done) => {
resolve = done;
});
},
},
],
});
const loading = env.controller.setStack('slow');
await settle();
env.controller.destroy();
assert.equal(signal.aborted, true);
resolve({
destroy() {
destroyed++;
},
});
await loading;
await settle();
assert.equal(destroyed, 1);
assert.equal(env.imagery.length, 0);
assert.deepEqual(
env.changes.map((state) => state.status),
['switching'],
);
});
test('slow terrain cannot overwrite a newer source and a 3D view never starts unused terrain', async () => {
const env = publicFixture();
let resolve,
reads = 0;
const osm = env.registry.sources.find(
(source) => source.descriptor.id === 'osm',
);
osm.terrain = {
id: 'slow-floor',
create: () => {
reads++;
return new Promise((done) => {
resolve = done;
});
},
};
await env.controller.setStack('photoreal');
assert.equal(reads, 0);
const loading = env.controller.setStack('osm');
await settle();
await env.controller.setStack('photoreal');
resolve({ provider: { id: 'late-terrain' } });
await loading;
assert.equal(env.viewer.terrainProvider, undefined);
assert.equal(env.viewer.scene.globe.show, false);
assert.equal(env.tileset.show, true);
assert.equal(env.controller.getActiveId(), 'photoreal');
env.controller.destroy();
});
test('Esri construction fallback reports and attributes the source actually rendered', async () => {
const env = publicFixture();
env.registry.sources.find(
(source) => source.descriptor.id === 'esri-imagery',
).imagery = async () => {
throw new Error('unreachable');
};
await env.controller.setStack('esri-imagery');
assert.equal(env.controller.getActiveId(), 'osm');
assert.equal(
env.controller.getState().lastError,
'Esri Satellite is unavailable; using OSM',
);
assert.equal(env.imagery[0].provider, env.providers.get('osm'));
assert.equal(env.credits.size, 0);
env.controller.destroy();
});
test('one Esri tile failure stays put, two fall back, and stale errors cannot replace a selection', async () => {
const env = publicFixture();
await env.controller.setStack('esri-imagery');
assert.equal(env.credits.size, 1);
const errorEvent = env.providers.get('esri-imagery').errorEvent;
errorEvent.raise();
await settle();
assert.equal(env.controller.getActiveId(), 'esri-imagery');
errorEvent.raise();
await settle();
assert.equal(env.controller.getActiveId(), 'osm');
assert.equal(env.credits.size, 0);
assert.equal(
env.controller.getState().lastError,
'Esri Satellite tile requests failed; using OSM',
);
assert.equal(errorEvent.size, 0);
await env.controller.setStack('photoreal');
errorEvent.raise({ timesRetried: 9 });
await settle();
assert.equal(env.controller.getActiveId(), 'photoreal');
env.controller.destroy();
});
test('tooltips and rejected selection share the registry reason, including retired map IDs', async () => {
const errors = [],
env = publicFixture();
env.controller._onError = (message) => errors.push(message);
const reason = env.controller
.getStacks()
.find((stack) => stack.id === 'bing-aerial').unavailableReason;
await env.controller.setStack('bing-aerial');
assert.deepEqual(errors, [reason]);
assert.match(reason, /CESIUM_ION_TOKEN/);
await env.controller.setStack('bing-road');
assert.equal(env.controller.getActiveId(), 'photoreal');
env.controller.destroy();
});
test('a factory-owned 3D tileset is released after late completion and supplied tilesets stay caller-owned', async () => {
let resolve,
destroyed = 0;
const supplied = {
show: true,
destroy() {
assert.fail('caller-owned tileset');
},
};
const env = fixture({
defaultId: 'three',
sources: [
{
descriptor: { id: 'three' },
createTileset: () =>
new Promise((done) => {
resolve = done;
}),
},
{ descriptor: { id: 'supplied' }, tileset: supplied },
],
});
const loading = env.controller.setStack('three');
await settle();
env.controller.destroy();
resolve({
destroy() {
destroyed++;
},
});
await loading;
assert.equal(destroyed, 1);
assert.equal(env.primitives.length, 0);
});
test('invalid source graphs fail before constructing or caching a provider', () => {
const a = { descriptor: descriptor('a'), constructionFallback: { id: 'b' } };
const b = { descriptor: descriptor('b'), constructionFallback: { id: 'a' } };
assert.throws(() => fixture({ sources: [a, b] }), /fallback cycle/);
assert.throws(() => fixture({ sources: [a, a] }), /unique/);
assert.throws(() => fixture({ sources: [a] }), /Unknown map fallback/);
});
+25
View File
@@ -0,0 +1,25 @@
import * as Cesium from 'cesium';
/** Static credits follow the source actually on screen, including fallback. */
export function createMapCredits(viewer) {
let active = null;
let markup = null;
return {
show(html) {
if (html === markup) return;
const display = viewer?.scene?.frameState?.creditDisplay;
if (!display) return;
try {
if (active) display.removeStaticCredit(active);
active = html ? new Cesium.Credit(html, true) : null;
markup = html;
if (active) display.addStaticCredit(active);
} catch {
/* Switching remains usable on older credit displays. */
}
},
destroy() {
this.show(null);
},
};
}
+74
View File
@@ -0,0 +1,74 @@
import { MAP_STACKS } from './catalog.js';
import { photorealUnavailableReason } from './availability.js';
import { keySetupRequirement } from '../keySetupCore.mjs';
import {
createOsmImagery,
createEsriImagery,
createIonImagery,
ESRI_ATTRIBUTION_HTML,
} from './imagery.js';
import { createWorldTerrain, createKeylessTerrain } from './terrain.js';
/** Select sources and setup guidance without putting provider branches in the controller. */
export function createDefaultMapSources({
googleTileset = null,
cesiumToken = '',
googleApiKey = '',
} = {}) {
const ionToken = String(cesiumToken || '').trim();
const hasIon = Boolean(ionToken);
const hasGoogle = Boolean(String(googleApiKey || '').trim());
const terrain = {
id: hasIon ? 'world' : 'keyless',
create: hasIon
? (request) => createWorldTerrain(ionToken, request)
: createKeylessTerrain,
};
return {
defaultId: googleTileset ? 'photoreal' : 'esri-imagery',
unknownId: 'photoreal',
recoveryId: googleTileset ? 'photoreal' : null,
state: { hasCesiumIonToken: hasIon },
sources: MAP_STACKS.map((descriptor) => {
const common = {
descriptor,
available: !descriptor.requiresIon || hasIon,
unavailableReason: descriptor.requiresIon
? keySetupRequirement('cesium-ion')
: null,
};
if (descriptor.kind === 'photoreal')
return {
...common,
available: Boolean(googleTileset),
unavailableReason: photorealUnavailableReason(hasIon || hasGoogle),
tileset: googleTileset,
};
const imagery =
descriptor.kind === 'ion'
? () => createIonImagery(descriptor.style, ionToken)
: descriptor.id === 'osm'
? createOsmImagery
: createEsriImagery;
return {
...common,
imagery,
terrain,
...(descriptor.id === 'esri-imagery'
? {
credit: ESRI_ATTRIBUTION_HTML,
constructionFallback: {
id: 'osm',
message: 'Esri Satellite is unavailable; using OSM',
},
tileFailureFallback: {
id: 'osm',
threshold: 2,
message: 'Esri Satellite tile requests failed; using OSM',
},
}
: {}),
};
}),
};
}
+81
View File
@@ -0,0 +1,81 @@
const clean = (value) => String(value || '').trim();
/**
* Decide which map provider can deliver the best startup experience.
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
* @returns {'google-direct'|'google-ion'|'osm'}
*/
export function selectMapStartupRoute({
googleApiKey = '',
cesiumToken = '',
} = {}) {
if (clean(googleApiKey)) return 'google-direct';
if (clean(cesiumToken)) return 'google-ion';
return 'osm';
}
/**
* Load Google Photorealistic 3D Tiles through direct Google access when
* configured, otherwise through Cesium ion's hosted Google asset. If the
* direct request fails and an ion token is available, ion is the recovery path.
*
* @param {object} Cesium
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
*/
export async function loadPhotorealisticTileset(
Cesium,
{ googleApiKey = '', cesiumToken = '' } = {},
) {
const googleKey = clean(googleApiKey);
const ionToken = clean(cesiumToken);
const errors = [];
const attempts = [];
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
for (const attempt of attempts) {
try {
const tileset = attempt.googleKey
? await createGoogleDirectTileset(Cesium, attempt.googleKey)
: await createGoogleIonTileset(Cesium, ionToken);
return { tileset, route: attempt.route, errors };
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
return { tileset: null, route: 'osm', errors };
}
/** Pass credentials to the source instead of changing SDK-wide defaults. */
export function createGoogleDirectTileset(Cesium, key) {
key = clean(key);
if (!key) throw new Error('Google 3D requires an explicit browser key');
return Cesium.createGooglePhotorealistic3DTileset({
key,
onlyUsingWithGoogleGeocoder: true,
});
}
export async function createGoogleIonTileset(
Cesium,
accessToken,
{ signal } = {},
) {
accessToken = clean(accessToken);
if (!accessToken)
throw new Error('Google 3D through ion requires an explicit token');
signal?.throwIfAborted();
const resource = await Cesium.IonResource.fromAssetId(2275207, {
accessToken,
});
signal?.throwIfAborted();
// Match the installed SDK's Google helper rendering/cache defaults.
return Cesium.Cesium3DTileset.fromUrl(resource, {
cacheBytes: 1536 * 1024 * 1024,
maximumCacheOverflowBytes: 1024 * 1024 * 1024,
enableCollision: true,
});
}
+29
View File
@@ -0,0 +1,29 @@
import * as Cesium from 'cesium';
// Attribution and service rights are documented in DATA_SOURCES.md.
export const ESRI_ATTRIBUTION_HTML =
'<a href="https://www.esri.com" target="_blank" rel="noopener">Powered by Esri</a>';
export function createOsmImagery() {
return new Cesium.OpenStreetMapImageryProvider({
url: 'https://tile.openstreetmap.org/',
credit: '© OpenStreetMap contributors',
});
}
export function createEsriImagery() {
return Cesium.ArcGisMapServerImageryProvider.fromUrl(
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer',
{
credit:
'Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community',
enablePickFeatures: false,
},
);
}
export function createIonImagery(style, accessToken) {
accessToken = String(accessToken || '').trim();
if (!accessToken) throw new Error('Ion imagery requires an explicit token');
return Cesium.IonImageryProvider.fromAssetId(style, { accessToken });
}
+21
View File
@@ -0,0 +1,21 @@
/** Catch invalid source graphs before a cached fallback can wait on itself. */
export function indexMapSources(sources) {
const index = new Map();
for (const source of sources) {
const id = source?.descriptor?.id;
if (typeof id !== 'string' || !id || index.has(id))
throw new TypeError('Map source IDs must be nonempty and unique');
index.set(id, source);
}
for (const id of index.keys()) {
const seen = new Set();
let next = id;
while (next) {
if (seen.has(next)) throw new TypeError('Map source fallback cycle');
if (!index.has(next)) throw new TypeError('Unknown map fallback source');
seen.add(next);
next = index.get(next).constructionFallback?.id;
}
}
return index;
}
+68
View File
@@ -0,0 +1,68 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createIonImagery } from './imagery.js';
import { createWorldTerrain } from './terrain.js';
test('imagery and terrain pass their own ion token without relying on SDK defaults', async () => {
const originalImagery = Cesium.IonImageryProvider.fromAssetId;
const originalResource = Cesium.IonResource.fromAssetId;
const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;
const calls = [];
const defaultToken = Cesium.Ion.defaultAccessToken;
try {
Cesium.IonImageryProvider.fromAssetId = async (id, options) => {
calls.push({ kind: 'imagery', id, options });
return { id };
};
Cesium.IonResource.fromAssetId = async (id, options) => {
calls.push({ kind: 'resource', id, options });
return { id };
};
Cesium.CesiumTerrainProvider.fromUrl = async (resource, options) => {
calls.push({ kind: 'terrain', resource, options });
return { id: 'terrain' };
};
await createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, 'imagery-token');
const result = await createWorldTerrain('terrain-token');
assert.equal(calls[0].options.accessToken, 'imagery-token');
assert.equal(calls[1].options.accessToken, 'terrain-token');
assert.equal(calls[1].id, 1);
assert.equal(calls[2].options.requestVertexNormals, true);
assert.equal(result.provider.id, 'terrain');
assert.equal(Cesium.Ion.defaultAccessToken, defaultToken);
} finally {
Cesium.IonImageryProvider.fromAssetId = originalImagery;
Cesium.IonResource.fromAssetId = originalResource;
Cesium.CesiumTerrainProvider.fromUrl = originalTerrain;
}
});
test('cancellation after ion metadata prevents terrain construction', async () => {
const originalResource = Cesium.IonResource.fromAssetId;
const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;
const controller = new AbortController();
try {
Cesium.IonResource.fromAssetId = async () => {
controller.abort();
return {};
};
Cesium.CesiumTerrainProvider.fromUrl = () =>
assert.fail('cancelled terrain construction');
await assert.rejects(
createWorldTerrain('test-token', { signal: controller.signal }),
{ name: 'AbortError' },
);
} finally {
Cesium.IonResource.fromAssetId = originalResource;
Cesium.CesiumTerrainProvider.fromUrl = originalTerrain;
}
});
test('credentialed source factories reject an omitted token instead of consuming an SDK default', async () => {
assert.throws(
() => createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, ''),
/explicit token/,
);
await assert.rejects(createWorldTerrain(' '), /explicit ion token/);
});
+35
View File
@@ -0,0 +1,35 @@
import * as Cesium from 'cesium';
/** These factories are lazy: a hidden globe must not trigger terrain loading. */
export async function createWorldTerrain(accessToken, { signal } = {}) {
accessToken = String(accessToken || '').trim();
if (!accessToken)
throw new Error('World terrain requires an explicit ion token');
signal?.throwIfAborted();
const resource = await Cesium.IonResource.fromAssetId(1, { accessToken });
signal?.throwIfAborted();
return {
provider: await Cesium.CesiumTerrainProvider.fromUrl(resource, {
requestVertexNormals: true,
requestWaterMask: false,
ellipsoid: Cesium.Ellipsoid.WGS84,
}),
};
}
export async function createKeylessTerrain() {
try {
// Re:Earth / Mapterhorn ellipsoidal quantized mesh, CC BY 4.0.
return {
provider: await Cesium.CesiumTerrainProvider.fromUrl(
'https://terrain.reearth.land/cesium-mesh/ellipsoid',
),
};
} catch (error) {
console.warn(
'[MapStack] Re:Earth terrain unavailable, falling back to flat ellipsoid terrain:',
error,
);
return { provider: new Cesium.EllipsoidTerrainProvider() };
}
}