mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
From the re-shot Russian screenshots:
- Translated: weather frame/imagery status errors, HUD summary provenance
("НЕДОСТУПНО: СУДА AIS ОНЛАЙН"), Bhote Koshi map labels, cockpit signal
distances (КМ). The HUD summary fields are separated with " · " in every
language, so the style name and view band no longer read as one phrase.
- Numbers follow the locale: cockpit altitude ("26 175"), precipitation.
- No breaks inside codes and dates: ADS-B and similar in layer rows,
Sentinel-2, cyclone issue dates (word joiner; the UI font has no U+2011),
no-break space after label colons in cyclone details.
- Layout: count lines under ALPR, short unavailable chip (context
"status chip"), roomier draw segments, tighter CCTV calibration chips,
two-line beat titles and media card titles, wrapping weather status.
- Wording: ru advisory is «сводка» (was «бюллетень»; "Advisory center" is
«Центр циклона по сводке»), Street View is «Просмотр улиц», OPACITY
«НЕПРОЗР.», synced «синхр.», Area «Зона»; the import dialog no longer says
"this scene" for several scenes. Glossary updated.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
894 lines
29 KiB
JavaScript
894 lines
29 KiB
JavaScript
import { acquireWeatherImage } from './infraredImage.js';
|
||
import { imageryHostStatus } from './imageryHost.js';
|
||
import { WEATHER_DETAIL_SIZE, WEATHER_IMAGE_SIZES } from './source.js';
|
||
import { msg } from '../../i18n/index.js';
|
||
|
||
/** Metres above the ellipsoid. Lower shells draw first, so lightning draws last. */
|
||
export const WEATHER_SHELL_HEIGHTS = Object.freeze({
|
||
wind: 5_000,
|
||
clouds: 5_500,
|
||
'clouds-regional': 5_800,
|
||
radar: 6_200,
|
||
lightning: 6_600,
|
||
});
|
||
// Decoded canvases per renderer, full-extent and detail images alike: the shown
|
||
// frame and the warmed next frame, each full-extent and detail (four 4096×2048
|
||
// canvases); nothing older survives them. Cesium holds the shown canvases
|
||
// through the material uniforms in any case.
|
||
export const WEATHER_SHELL_CACHE_BYTES = 128 * 1024 * 1024;
|
||
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
|
||
const MATERIAL_TYPE = 'WeatherFrame';
|
||
// Renders after an image swap: one queues the upload, one uploads and draws, one spare.
|
||
const UPLOAD_FRAMES = 3;
|
||
// An empty detail window: the full-extent image shows everywhere.
|
||
const NO_WINDOW = Object.freeze({ west: 0, south: 0, east: -1, north: -1 });
|
||
// Detail windows, in degrees.
|
||
const DETAIL_MIN_WIDTH = 6;
|
||
const DETAIL_GRID = 0.5;
|
||
const shellHeights = new WeakMap();
|
||
// From far away Google's coarse 3D tiles rise tens of kilometres above the
|
||
// ground and hide the shells, so the shells rise with the camera: 4 m per
|
||
// kilometre of camera height, at most 60 km, in 500 m steps.
|
||
const LIFT_PER_METRE = 0.004;
|
||
const MAX_LIFT = 60_000;
|
||
const LIFT_STEP = 500;
|
||
// Cesium binds a 1×1 white texture to an image uniform until its first upload:
|
||
// draw nothing until the full-extent image has arrived, and sample the detail
|
||
// image only once it has arrived. The window is west, south, east, north in the
|
||
// full-extent image's texture coordinates.
|
||
const MATERIAL_SOURCE = `czm_material czm_getMaterial(czm_materialInput materialInput)
|
||
{
|
||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||
vec2 st = materialInput.st;
|
||
vec4 coarse = texture(image, st);
|
||
vec2 dst = (st - window.xy) / max(window.zw - window.xy, vec2(1e-6));
|
||
float inside = float(all(greaterThanEqual(st, window.xy)) && all(lessThanEqual(st, window.zw)));
|
||
float useDetail = inside * step(1.5, float(detailDimensions.x));
|
||
vec4 c = mix(coarse, texture(detail, clamp(dst, 0.0, 1.0)), useDetail);
|
||
material.diffuse = czm_gammaCorrect(c.rgb);
|
||
material.alpha = c.a * alpha * step(1.5, float(imageDimensions.x));
|
||
return material;
|
||
}
|
||
`;
|
||
|
||
function registerMaterial(cesium) {
|
||
const cache = cesium.Material._materialCache;
|
||
if (cache.getMaterial(MATERIAL_TYPE)) return;
|
||
// A plain template: building the first material from a fabric would make that
|
||
// instance Cesium's shared template and keep its last image alive.
|
||
cache.addMaterial(MATERIAL_TYPE, {
|
||
fabric: {
|
||
type: MATERIAL_TYPE,
|
||
uniforms: {
|
||
image: cesium.Material.DefaultImageId,
|
||
detail: cesium.Material.DefaultImageId,
|
||
alpha: 1,
|
||
window: { type: 'vec4', x: 0, y: 0, z: -1, w: -1 },
|
||
},
|
||
// Diffuse and alpha share these samples, so one source, not components.
|
||
source: MATERIAL_SOURCE,
|
||
},
|
||
translucent: false,
|
||
});
|
||
}
|
||
|
||
const sameEdges = (a, b) =>
|
||
a.west === b.west &&
|
||
a.south === b.south &&
|
||
a.east === b.east &&
|
||
a.north === b.north;
|
||
|
||
/** Metres every shell rises for a camera this high above the ellipsoid. */
|
||
export function shellLift(cameraHeight) {
|
||
if (!(cameraHeight > 0)) return 0;
|
||
const lift = Math.min(MAX_LIFT, cameraHeight * LIFT_PER_METRE);
|
||
return Math.round(lift / LIFT_STEP) * LIFT_STEP;
|
||
}
|
||
|
||
/** Keep weather shells first in the primitive collection, in ascending height:
|
||
* higher shells draw over lower ones and other opaque content draws over both. */
|
||
export function orderWeatherShells(primitives, primitive, height) {
|
||
shellHeights.set(primitive, height);
|
||
if (typeof primitives.lowerToBottom !== 'function') return;
|
||
const shells = [];
|
||
for (let i = 0; i < primitives.length; i++) {
|
||
const item = primitives.get(i);
|
||
if (shellHeights.has(item)) shells.push(item);
|
||
}
|
||
const ordered = [...shells].sort(
|
||
(a, b) => shellHeights.get(a) - shellHeights.get(b),
|
||
);
|
||
if (ordered.every((item, i) => primitives.get(i) === item)) return;
|
||
for (const item of ordered.reverse()) primitives.lowerToBottom(item);
|
||
}
|
||
|
||
/** One raised rectangle drawing one full-extent image and, inside a window of
|
||
* it, an optional detail image. The owner calls destroy(). */
|
||
export function createShellSurface({
|
||
viewer,
|
||
cesium,
|
||
rectangle,
|
||
height,
|
||
onSettled = () => {},
|
||
}) {
|
||
registerMaterial(cesium);
|
||
const scene = viewer.scene;
|
||
const primitives = scene.primitives;
|
||
const material = new cesium.Material({
|
||
fabric: { type: MATERIAL_TYPE },
|
||
translucent: false,
|
||
});
|
||
// Flat shading keeps the product colours independent of the viewing angle.
|
||
const appearance = new cesium.EllipsoidSurfaceAppearance({
|
||
aboveGround: true,
|
||
flat: true,
|
||
translucent: false,
|
||
material,
|
||
});
|
||
// Cesium orders its translucent pass itself (weighted OIT or distance), which
|
||
// cannot honour stacked heights. Draw in the opaque pass in scene.primitives
|
||
// order, alpha-blended and without depth writes, so higher shells draw over
|
||
// lower ones and nothing is hidden or picked through a shell.
|
||
appearance.getRenderState = () => ({
|
||
depthTest: { enabled: true },
|
||
depthMask: false,
|
||
blending: cesium.BlendingState.ALPHA_BLEND,
|
||
});
|
||
const primitive = new cesium.Primitive({
|
||
geometryInstances: new cesium.GeometryInstance({
|
||
geometry: new cesium.RectangleGeometry({
|
||
rectangle,
|
||
height,
|
||
granularity: cesium.Math.toRadians(0.5),
|
||
vertexFormat: cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT,
|
||
}),
|
||
}),
|
||
appearance,
|
||
asynchronous: true,
|
||
allowPicking: false,
|
||
});
|
||
primitives.add(primitive);
|
||
orderWeatherShells(primitives, primitive, height);
|
||
// A uniform scale about the Earth's centre lifts every shell alike and keeps
|
||
// their order.
|
||
let lift = 0;
|
||
const offLift = scene.preRender?.addEventListener(() => {
|
||
const next = shellLift(scene.camera?.positionCartographic?.height);
|
||
if (next === lift) return;
|
||
lift = next;
|
||
primitive.modelMatrix = cesium.Matrix4.fromUniformScale(
|
||
1 + lift / cesium.Ellipsoid.WGS84.maximumRadius,
|
||
);
|
||
});
|
||
let image = null;
|
||
let frames = 0;
|
||
// The detail image, its own upload count and a window waiting for it to draw.
|
||
let detail = null;
|
||
let detailFrames = 0;
|
||
let pendingWindow = null;
|
||
let offRender = null;
|
||
let destroyed = false;
|
||
|
||
const fits = (size, value) =>
|
||
!size || (size.x === value.width && size.y === value.height);
|
||
const uploaded = () =>
|
||
image !== null && fits(material.uniforms.imageDimensions, image);
|
||
const detailDrawn = () =>
|
||
detail !== null &&
|
||
detailFrames === 0 &&
|
||
fits(material.uniforms.detailDimensions, detail);
|
||
const drawn = () =>
|
||
primitive.ready &&
|
||
uploaded() &&
|
||
frames === 0 &&
|
||
(detail === null || (detailDrawn() && pendingWindow === null));
|
||
function appliedWindow() {
|
||
const { x, y, z, w } = material.uniforms.window;
|
||
return z < x ? null : { west: x, south: y, east: z, north: w };
|
||
}
|
||
function setWindow(rect) {
|
||
const next = rect ?? NO_WINDOW;
|
||
const { x, y, z, w } = material.uniforms.window;
|
||
if (
|
||
x === next.west &&
|
||
y === next.south &&
|
||
z === next.east &&
|
||
w === next.north
|
||
)
|
||
return;
|
||
material.uniforms.window = new cesium.Cartesian4(
|
||
next.west,
|
||
next.south,
|
||
next.east,
|
||
next.north,
|
||
);
|
||
scene.requestRender();
|
||
}
|
||
// The render governor may idle the scene; request frames only until the
|
||
// asynchronous geometry is ready and the latest images have been drawn.
|
||
function tick() {
|
||
// Cesium updates a material only while its primitive is shown, so hidden
|
||
// frames upload nothing and count for nothing.
|
||
if (primitive.ready && primitive.show) {
|
||
if (frames > 0) frames--;
|
||
if (detailFrames > 0) detailFrames--;
|
||
// A texture is never shown at another window's place.
|
||
if (pendingWindow && detailDrawn()) {
|
||
setWindow(pendingWindow);
|
||
pendingWindow = null;
|
||
}
|
||
}
|
||
if (primitive.show && !drawn()) {
|
||
scene.requestRender();
|
||
return;
|
||
}
|
||
offRender?.();
|
||
offRender = null;
|
||
if (drawn()) onSettled();
|
||
}
|
||
function wake() {
|
||
if (destroyed || !primitive.show || image === null || drawn()) return;
|
||
offRender ??= scene.postRender.addEventListener(tick);
|
||
scene.requestRender();
|
||
}
|
||
|
||
return {
|
||
setImage(next) {
|
||
if (destroyed || next === image) return;
|
||
image = next;
|
||
material.uniforms.image = next;
|
||
frames = UPLOAD_FRAMES;
|
||
wake();
|
||
},
|
||
setAlpha(value) {
|
||
if (destroyed || material.uniforms.alpha === value) return;
|
||
material.uniforms.alpha = value;
|
||
scene.requestRender();
|
||
},
|
||
/** Draw `next` inside `rect` (west, south, east, north in the full-extent
|
||
* image's texture coordinates, 0–1); null clears it. In the same window only
|
||
* the image changes, and Cesium keeps drawing the previous texture until
|
||
* this one is uploaded. A different window applies once its image has
|
||
* drawn; until then the full-extent image covers it. */
|
||
setDetail(next, rect) {
|
||
if (destroyed) return;
|
||
if (!next) {
|
||
if (detail === null) return;
|
||
detail = null;
|
||
detailFrames = 0;
|
||
pendingWindow = null;
|
||
material.uniforms.detail = cesium.Material.DefaultImageId;
|
||
setWindow(null);
|
||
scene.requestRender();
|
||
return;
|
||
}
|
||
if (next !== detail) {
|
||
detail = next;
|
||
material.uniforms.detail = next;
|
||
detailFrames = UPLOAD_FRAMES;
|
||
}
|
||
const applied = appliedWindow();
|
||
if (applied && sameEdges(applied, rect)) {
|
||
pendingWindow = null;
|
||
} else {
|
||
setWindow(null);
|
||
pendingWindow = rect;
|
||
}
|
||
wake();
|
||
},
|
||
/** Returns whether visibility changed. */
|
||
setShow(value) {
|
||
const show = Boolean(value);
|
||
if (destroyed || primitive.show === show) return false;
|
||
primitive.show = show;
|
||
wake();
|
||
scene.requestRender();
|
||
return true;
|
||
},
|
||
destroy() {
|
||
if (destroyed) return;
|
||
destroyed = true;
|
||
offRender?.();
|
||
offRender = null;
|
||
offLift?.();
|
||
if (!primitives.isDestroyed?.() && primitives.contains(primitive))
|
||
primitives.remove(primitive);
|
||
if (!primitive.isDestroyed()) primitive.destroy();
|
||
// Primitive.destroy leaves the material and its texture alive.
|
||
if (!material.isDestroyed()) material.destroy();
|
||
image = null;
|
||
detail = null;
|
||
pendingWindow = null;
|
||
scene.requestRender();
|
||
},
|
||
getDiagnostics() {
|
||
return {
|
||
height,
|
||
ready: Boolean(primitive.ready),
|
||
uploaded: uploaded(),
|
||
show: primitive.show,
|
||
alpha: material.uniforms.alpha,
|
||
rendering: offRender !== null,
|
||
// `uploaded` once the detail image has drawn; the window applies after.
|
||
detail: { uploaded: detailDrawn(), window: appliedWindow() },
|
||
};
|
||
},
|
||
};
|
||
}
|
||
|
||
function fitTexture(cesium, { width, height }) {
|
||
const limit = cesium.ContextLimits?.maximumTextureSize;
|
||
// Halve, down to 1024 px wide, on devices with a smaller texture limit.
|
||
while (limit > 0 && width > limit && width > 1024) {
|
||
width /= 2;
|
||
height /= 2;
|
||
}
|
||
return { width, height };
|
||
}
|
||
|
||
/** The detail window for a view footprint over product bounds (both in degrees;
|
||
* a footprint across the antimeridian has west > east), or null when the
|
||
* full-extent image is already the best available. The window is centred on
|
||
* the footprint, max(2 × its longitude span, 6°) wide and half as tall, with
|
||
* edges on a 0.5° grid inside the bounds. It is enabled only when narrower than
|
||
* half the product's longitude extent and when the footprint overlaps the
|
||
* product. `previous` is returned unchanged while the footprint centre stays in
|
||
* its inner half and the wanted width is within 50 % of its width. */
|
||
export function detailWindow(footprint, bounds, previous = null) {
|
||
if (!footprint || !bounds) return null;
|
||
const across = footprint.east < footprint.west;
|
||
const span = footprint.east - footprint.west + (across ? 360 : 0);
|
||
let lon = footprint.west + span / 2;
|
||
if (lon > 180) lon -= 360;
|
||
const lat = (footprint.south + footprint.north) / 2;
|
||
const wanted = Math.max(2 * span, DETAIL_MIN_WIDTH);
|
||
if (previous) {
|
||
const width = previous.east - previous.west;
|
||
const height = previous.north - previous.south;
|
||
if (
|
||
Math.abs(lon - (previous.west + previous.east) / 2) <= width / 4 &&
|
||
Math.abs(lat - (previous.south + previous.north) / 2) <= height / 4 &&
|
||
Math.abs(wanted - width) <= width / 2
|
||
)
|
||
return previous;
|
||
}
|
||
const inner = {
|
||
west: Math.ceil(bounds.west / DETAIL_GRID) * DETAIL_GRID,
|
||
south: Math.ceil(bounds.south / DETAIL_GRID) * DETAIL_GRID,
|
||
east: Math.floor(bounds.east / DETAIL_GRID) * DETAIL_GRID,
|
||
north: Math.floor(bounds.north / DETAIL_GRID) * DETAIL_GRID,
|
||
};
|
||
// Whole degrees keep both the edges and the 2:1 height on the grid; the
|
||
// tolerance absorbs radian round trips.
|
||
const width = Math.ceil(wanted - 1e-9);
|
||
const height = width / 2;
|
||
const overlaps =
|
||
footprint.south < bounds.north &&
|
||
footprint.north > bounds.south &&
|
||
(across
|
||
? footprint.west < bounds.east || footprint.east > bounds.west
|
||
: footprint.west < bounds.east && footprint.east > bounds.west);
|
||
if (
|
||
!overlaps ||
|
||
width >= (bounds.east - bounds.west) / 2 ||
|
||
width > inner.east - inner.west ||
|
||
height > inner.north - inner.south
|
||
)
|
||
return null;
|
||
const snap = (value) => Math.round(value / DETAIL_GRID) * DETAIL_GRID;
|
||
const west = Math.min(
|
||
Math.max(snap(lon - width / 2), inner.west),
|
||
inner.east - width,
|
||
);
|
||
const south = Math.min(
|
||
Math.max(snap(lat - height / 2), inner.south),
|
||
inner.north - height,
|
||
);
|
||
const next = { west, south, east: west + width, north: south + height };
|
||
return previous && sameEdges(previous, next) ? previous : next;
|
||
}
|
||
|
||
/** Observed weather on 3D Tiles: one raised shell per product and one
|
||
* full-extent image per frame. The previous image stays until the next is ready.
|
||
* Inside a window around the view the same surface samples a sharper image of
|
||
* that area, so the two images share one mesh and never blend. */
|
||
export function createWeatherShell({
|
||
viewer,
|
||
cesium,
|
||
product,
|
||
height = WEATHER_SHELL_HEIGHTS[product],
|
||
getHost = () => ({ collection: null, kind: 'tileset' }),
|
||
onChange = () => {},
|
||
timeoutMs = 25_000,
|
||
now = () => performance.now(),
|
||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||
decodeImage,
|
||
createCanvas = () => document.createElement('canvas'),
|
||
cacheBytes = WEATHER_SHELL_CACHE_BYTES,
|
||
}) {
|
||
if (!Object.hasOwn(WEATHER_IMAGE_SIZES, product))
|
||
throw new TypeError('Unknown weather product');
|
||
const scene = viewer.scene;
|
||
const size = fitTexture(cesium, WEATHER_IMAGE_SIZES[product]);
|
||
const detailSize = fitTexture(cesium, WEATHER_DETAIL_SIZE);
|
||
const infrared = product === 'clouds' || product === 'clouds-regional';
|
||
// Global infrared contrast depends on the requested extent, so a window would
|
||
// not match the image around it; the globe host also shows one mosaic.
|
||
const detailed = product !== 'clouds';
|
||
let surface = null;
|
||
let current = null;
|
||
let incoming = null;
|
||
let alpha = 0.7;
|
||
let hidden = false;
|
||
let lastError = null;
|
||
const images = new Map();
|
||
let imageBytes = 0;
|
||
let prefetchJob = null;
|
||
let prefetchedKey = null;
|
||
// The detail window for the shown bounds, the image drawn in it and its fetch.
|
||
let view = null;
|
||
let viewBounds = null;
|
||
let detail = null;
|
||
let detailJob = null;
|
||
let offCamera = null;
|
||
|
||
const suspended = () => imageryHostStatus(getHost()) !== null;
|
||
const visible = () => !hidden && !suspended();
|
||
const boundsKey = ({ west, south, east, north }) =>
|
||
`${west},${south},${east},${north}`;
|
||
const cacheKey = (time, mode, box = null) =>
|
||
`${time}|${infrared ? mode : 'none'}${box ? `|${boundsKey(box)}` : ''}`;
|
||
const wantedDetail = () =>
|
||
current && view ? cacheKey(current.time, current.infrared, view) : null;
|
||
|
||
function evict(keep = []) {
|
||
const kept = new Set([...keep, current?.key, detail?.key]);
|
||
for (const [key, entry] of images) {
|
||
if (imageBytes <= cacheBytes) return;
|
||
if (kept.has(key)) continue;
|
||
images.delete(key);
|
||
imageBytes -= entry.bytes;
|
||
}
|
||
}
|
||
async function acquire(
|
||
time,
|
||
mode,
|
||
signal,
|
||
{ box = null, keep = [], onFetched } = {},
|
||
) {
|
||
signal.throwIfAborted();
|
||
const key = cacheKey(time, mode, box);
|
||
const entry = images.get(key);
|
||
if (entry) {
|
||
images.delete(key);
|
||
images.set(key, entry);
|
||
return { texture: entry.image, decodeMs: 0, cached: true };
|
||
}
|
||
const result = await acquireWeatherImage(product, time, {
|
||
signal,
|
||
mode,
|
||
size: box ? detailSize : size,
|
||
bbox: box,
|
||
maxBytes: MAX_IMAGE_BYTES,
|
||
createCanvas,
|
||
fetchImpl,
|
||
decodeImage,
|
||
now,
|
||
onFetched,
|
||
});
|
||
// An aborted decode may still finish; never repopulate a cleared instance.
|
||
signal.throwIfAborted();
|
||
const previous = images.get(key);
|
||
if (previous) imageBytes -= previous.bytes;
|
||
images.delete(key);
|
||
const bytes = result.texture.width * result.texture.height * 4;
|
||
images.set(key, { image: result.texture, bytes });
|
||
imageBytes += bytes;
|
||
evict([key, ...keep]);
|
||
return { ...result, cached: false };
|
||
}
|
||
/** The detail box in the full-extent image's texture coordinates. */
|
||
function detailRect() {
|
||
const b = current.extent;
|
||
const w = detail.box;
|
||
const x = b.east - b.west;
|
||
const y = b.north - b.south;
|
||
return {
|
||
west: (w.west - b.west) / x,
|
||
south: (w.south - b.south) / y,
|
||
east: (w.east - b.west) / x,
|
||
north: (w.north - b.south) / y,
|
||
};
|
||
}
|
||
function syncDetail() {
|
||
if (detail && current) surface?.setDetail(detail.image, detailRect());
|
||
else surface?.setDetail(null);
|
||
}
|
||
function applyVisibility() {
|
||
return surface?.setShow(visible()) ?? false;
|
||
}
|
||
function cancelDetail() {
|
||
clearTimeout(detailJob?.timeout);
|
||
detailJob?.controller.abort();
|
||
detailJob = null;
|
||
}
|
||
function dropDetail() {
|
||
detail = null;
|
||
// The full-extent image covers the window in the same turn.
|
||
surface?.setDetail(null);
|
||
}
|
||
function installDetail(job, image) {
|
||
if (detailJob !== job) return;
|
||
clearTimeout(job.timeout);
|
||
detailJob = null;
|
||
detail = { box: job.box, key: job.key, image, stale: false };
|
||
syncDetail();
|
||
evict();
|
||
scene.requestRender();
|
||
}
|
||
function failDetail(job) {
|
||
if (detailJob !== job) return;
|
||
clearTimeout(job.timeout);
|
||
detailJob = null;
|
||
if (detail?.stale) dropDetail();
|
||
scene.requestRender();
|
||
}
|
||
/** Bring the detail to the shown frame and window. The previous detail image
|
||
* stays until the next is decoded unless the window itself moved, and over at
|
||
* most one newer frame (see commit). */
|
||
function refreshDetail() {
|
||
const key = wantedDetail();
|
||
if (!key) {
|
||
cancelDetail();
|
||
dropDetail();
|
||
return;
|
||
}
|
||
if (detail && boundsKey(detail.box) !== boundsKey(view)) dropDetail();
|
||
syncDetail();
|
||
if (detail?.key === key) {
|
||
detail.stale = false;
|
||
cancelDetail();
|
||
return;
|
||
}
|
||
// Another frame's detail: the next commit drops it unless replaced first.
|
||
if (detail) detail.stale = true;
|
||
if (!visible()) {
|
||
cancelDetail();
|
||
return;
|
||
}
|
||
if (detailJob?.key === key) return;
|
||
cancelDetail();
|
||
const job = { key, box: view, controller: new AbortController() };
|
||
detailJob = job;
|
||
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
|
||
void acquire(current.time, current.infrared, job.controller.signal, {
|
||
box: view,
|
||
})
|
||
.then(({ texture }) => installDetail(job, texture))
|
||
.catch(() => failDetail(job));
|
||
}
|
||
/** Recompute the window from the camera's ground footprint; returns whether it changed. */
|
||
function computeView() {
|
||
const rectangle = detailed
|
||
? viewer.camera?.computeViewRectangle?.(scene.globe?.ellipsoid)
|
||
: null;
|
||
const degrees = cesium.Math.toDegrees;
|
||
const footprint = rectangle && {
|
||
west: degrees(rectangle.west),
|
||
south: degrees(rectangle.south),
|
||
east: degrees(rectangle.east),
|
||
north: degrees(rectangle.north),
|
||
};
|
||
const next = detailWindow(
|
||
footprint,
|
||
current.extent,
|
||
viewBounds === current.bounds ? view : null,
|
||
);
|
||
viewBounds = current.bounds;
|
||
if (next === view) return false;
|
||
view = next;
|
||
return true;
|
||
}
|
||
function watchCamera() {
|
||
if (offCamera || !detailed) return;
|
||
offCamera =
|
||
viewer.camera?.moveEnd?.addEventListener(() => {
|
||
if (current && computeView()) refreshDetail();
|
||
}) ?? null;
|
||
}
|
||
function cancelPrefetch() {
|
||
clearTimeout(prefetchJob?.timeout);
|
||
prefetchJob?.controller.abort();
|
||
prefetchJob = null;
|
||
prefetchedKey = null;
|
||
}
|
||
function watch(frame, signal) {
|
||
if (!signal) return;
|
||
frame.owned = true;
|
||
const abort = () => {
|
||
if (incoming === frame) cancelIncoming();
|
||
scene.requestRender();
|
||
};
|
||
signal.addEventListener('abort', abort, { once: true });
|
||
frame.offAbort = () => signal.removeEventListener('abort', abort);
|
||
}
|
||
function close(frame) {
|
||
frame.closed = true;
|
||
frame.controller.abort();
|
||
clearTimeout(frame.timeout);
|
||
frame.offAbort?.();
|
||
frame.surface?.destroy();
|
||
frame.surface = null;
|
||
}
|
||
function cancelIncoming() {
|
||
if (!incoming) return;
|
||
const frame = incoming;
|
||
incoming = null;
|
||
close(frame);
|
||
frame.resolve(false);
|
||
}
|
||
function fail(frame) {
|
||
if (incoming !== frame) return;
|
||
incoming = null;
|
||
close(frame);
|
||
lastError = msg('Weather tiles unavailable · previous frame retained');
|
||
frame.resolve(false);
|
||
scene.requestRender();
|
||
onChange();
|
||
}
|
||
function commit(frame) {
|
||
if (incoming !== frame) return;
|
||
incoming = null;
|
||
clearTimeout(frame.timeout);
|
||
frame.offAbort?.();
|
||
if (frame.surface) {
|
||
surface?.destroy();
|
||
surface = frame.surface;
|
||
frame.surface = null;
|
||
}
|
||
const { west, south, east, north } = frame.snapshot.bounds;
|
||
current = {
|
||
time: frame.time,
|
||
product: frame.product,
|
||
infrared: frame.infrared,
|
||
bounds: frame.bounds,
|
||
extent: { west, south, east, north },
|
||
key: frame.key,
|
||
mosaic: frame.mosaic,
|
||
loadMs: now() - frame.startedAt,
|
||
};
|
||
surface.setAlpha(alpha);
|
||
applyVisibility();
|
||
lastError = null;
|
||
evict();
|
||
// The full-extent image swaps first; the detail follows for the same time.
|
||
watchCamera();
|
||
if (viewBounds !== current.bounds) computeView();
|
||
// An older frame's detail stays over at most one newer frame.
|
||
if (detail?.stale && detail.key !== wantedDetail()) dropDetail();
|
||
refreshDetail();
|
||
frame.resolve(true);
|
||
scene.requestRender();
|
||
onChange();
|
||
}
|
||
function install(frame, image) {
|
||
if (frame.closed || incoming !== frame) return;
|
||
if (surface?.bounds === frame.bounds) {
|
||
// Cesium keeps drawing the previous texture until this one is uploaded.
|
||
surface.setImage(image);
|
||
commit(frame);
|
||
return;
|
||
}
|
||
// First frame or a new extent: stage an invisible surface and keep the
|
||
// previous one until the new geometry and texture are drawable.
|
||
const { west, south, east, north } = frame.snapshot.bounds;
|
||
const next = createShellSurface({
|
||
viewer,
|
||
cesium,
|
||
height,
|
||
rectangle: cesium.Rectangle.fromDegrees(west, south, east, north),
|
||
onSettled: () => commit(frame),
|
||
});
|
||
next.bounds = frame.bounds;
|
||
frame.surface = next;
|
||
next.setAlpha(0);
|
||
next.setImage(image);
|
||
}
|
||
|
||
return {
|
||
product,
|
||
rehome() {
|
||
if (suspended()) {
|
||
cancelPrefetch();
|
||
cancelIncoming();
|
||
cancelDetail();
|
||
}
|
||
const changed = applyVisibility();
|
||
if (current && !suspended()) {
|
||
computeView();
|
||
refreshDetail();
|
||
}
|
||
if (changed) scene.requestRender();
|
||
return changed;
|
||
},
|
||
cancelPrefetch,
|
||
async prefetch(snapshot, time, { infrared: mode = 'filtered' } = {}) {
|
||
let job;
|
||
try {
|
||
if (
|
||
hidden ||
|
||
incoming ||
|
||
suspended() ||
|
||
snapshot.product !== product ||
|
||
!snapshot.times.includes(time)
|
||
)
|
||
return false;
|
||
// Warm the next frame's detail window too while the window stays put.
|
||
const box =
|
||
view && viewBounds === boundsKey(snapshot.bounds) ? view : null;
|
||
const coarse = cacheKey(time, mode);
|
||
const key = box ? cacheKey(time, mode, box) : coarse;
|
||
if (prefetchJob?.key === key || prefetchedKey === key) return false;
|
||
cancelPrefetch();
|
||
job = { key, controller: new AbortController() };
|
||
prefetchJob = job;
|
||
const { signal } = job.controller;
|
||
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
|
||
// Both images at once: one after the other would outlast playback's step.
|
||
await Promise.all([
|
||
acquire(time, mode, signal, { keep: box ? [key] : [] }),
|
||
box ? acquire(time, mode, signal, { box, keep: [coarse] }) : null,
|
||
]);
|
||
signal.throwIfAborted();
|
||
if (prefetchJob === job) prefetchedKey = key;
|
||
return true;
|
||
} catch {
|
||
job?.controller.abort();
|
||
return false;
|
||
} finally {
|
||
clearTimeout(job?.timeout);
|
||
if (job && prefetchJob === job) prefetchJob = null;
|
||
}
|
||
},
|
||
async setFrame(
|
||
snapshot,
|
||
time,
|
||
{ signal, infrared: mode = 'filtered' } = {},
|
||
) {
|
||
signal?.throwIfAborted();
|
||
cancelPrefetch();
|
||
const bounds = boundsKey(snapshot.bounds);
|
||
const same = (frame) =>
|
||
frame?.time === time &&
|
||
frame.product === snapshot.product &&
|
||
frame.infrared === mode &&
|
||
frame.bounds === bounds;
|
||
// A request for the frame a host switch is restaging joins that work.
|
||
if (same(incoming) && !incoming.owned && !suspended()) {
|
||
watch(incoming, signal);
|
||
return incoming.result;
|
||
}
|
||
cancelIncoming();
|
||
applyVisibility();
|
||
if (suspended()) return false;
|
||
if (same(current)) return true;
|
||
lastError = null;
|
||
const frame = {
|
||
snapshot,
|
||
time,
|
||
infrared: mode,
|
||
bounds,
|
||
product: snapshot.product,
|
||
key: cacheKey(time, mode),
|
||
mosaic: { fetched: false, decodeMs: null, cached: false },
|
||
controller: new AbortController(),
|
||
startedAt: now(),
|
||
surface: null,
|
||
owned: false,
|
||
closed: false,
|
||
resolve: null,
|
||
};
|
||
incoming = frame;
|
||
frame.result = new Promise((resolve) => {
|
||
frame.resolve = resolve;
|
||
});
|
||
watch(frame, signal);
|
||
frame.timeout = setTimeout(() => fail(frame), timeoutMs);
|
||
void acquire(time, mode, frame.controller.signal, {
|
||
onFetched: () => {
|
||
frame.mosaic.fetched = true;
|
||
},
|
||
})
|
||
.then(({ texture, decodeMs, cached }) => {
|
||
frame.mosaic.decodeMs = decodeMs;
|
||
frame.mosaic.cached = cached;
|
||
install(frame, texture);
|
||
})
|
||
.catch(() => fail(frame));
|
||
scene.requestRender();
|
||
onChange();
|
||
return frame.result;
|
||
},
|
||
setHidden(value) {
|
||
hidden = Boolean(value);
|
||
if (hidden) {
|
||
cancelPrefetch();
|
||
cancelIncoming();
|
||
cancelDetail();
|
||
}
|
||
applyVisibility();
|
||
if (!hidden) refreshDetail();
|
||
scene.requestRender();
|
||
},
|
||
setAlpha(value) {
|
||
alpha = value;
|
||
surface?.setAlpha(alpha);
|
||
scene.requestRender();
|
||
},
|
||
clear() {
|
||
cancelPrefetch();
|
||
cancelIncoming();
|
||
cancelDetail();
|
||
offCamera?.();
|
||
offCamera = null;
|
||
dropDetail();
|
||
surface?.destroy();
|
||
surface = null;
|
||
current = null;
|
||
view = null;
|
||
viewBounds = null;
|
||
hidden = false;
|
||
lastError = null;
|
||
images.clear();
|
||
imageBytes = 0;
|
||
scene.requestRender();
|
||
},
|
||
getDiagnostics() {
|
||
const drawn = surface?.getDiagnostics();
|
||
const applied = drawn?.detail.window;
|
||
return {
|
||
host: 'shell',
|
||
height,
|
||
imageSize: { ...size },
|
||
shell: drawn
|
||
? {
|
||
...drawn,
|
||
detail: {
|
||
bbox: view
|
||
? [view.west, view.south, view.east, view.north]
|
||
: null,
|
||
size: { ...detailSize },
|
||
// The wanted window is applied and its image drawn.
|
||
ready: Boolean(
|
||
detail &&
|
||
current &&
|
||
detail.key === wantedDetail() &&
|
||
drawn.ready &&
|
||
drawn.show &&
|
||
drawn.detail.uploaded &&
|
||
applied &&
|
||
sameEdges(applied, detailRect()),
|
||
),
|
||
enabled: view !== null,
|
||
},
|
||
}
|
||
: null,
|
||
cache: {
|
||
mosaics: images.size,
|
||
bytes: imageBytes,
|
||
prefetching: !!prefetchJob,
|
||
},
|
||
imageryCount: Number(!!surface) + Number(!!incoming?.surface),
|
||
mosaic: (incoming || current)?.mosaic,
|
||
infrared: (incoming || current)?.infrared ?? 'filtered',
|
||
loading: !!incoming,
|
||
time: hidden ? null : (current?.time ?? null),
|
||
hidden,
|
||
product: current?.product ?? null,
|
||
frameLoadMs: current?.loadMs ?? null,
|
||
error: imageryHostStatus(getHost()) || lastError,
|
||
};
|
||
},
|
||
};
|
||
}
|