Compare commits

..
Author SHA1 Message Date
German Khokhlov def87c4dc0 Recent Imagery: drape the next ranked day when the automatic day is empty
When the automatic day probes empty, pruneEmpty() hands it over to the
next ranked day and follows that day until its probe answers. The
thumbnail subscriber previews a followed day only while it is focused,
but focus did not move with the hand-over: autoPreview() sets focus only
when there is none, and reconcileFocus() moves focus off a hidden card
to the nearest card, not to the next ranked day. With empty days shown,
focus stayed on the empty day; with them hidden, it could land on an
older neighbour. Either way the successor probed present and never
reached the map.

autoPreview() now takes the empty automatic day, and focus still on it
moves to the successor. Focus the user moved elsewhere stays put. Two
layer tests cover both cases; both fail without the change.
2026-11-13 18:45:37 +04:00
3 changed files with 38 additions and 6 deletions

No files matched your search

+1 -1
View File
@@ -7274,7 +7274,7 @@ pins and share links already supply complete boxes.
- The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room.
- DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes.
- **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height.
- **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied.
- **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. When that day probes empty the next ranked day takes over, and focus still on the empty day moves with it, so the successor drapes once its probe says present. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied.
- **Rendering.** `rendering.js` owns at most two GIBS layers on the host `src/maps/imageryHost.js` resolves: `viewer.imageryLayers` while the globe shows, the photoreal tileset's `imageryLayers` while it is hidden, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes.
- **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape.
- **Panel.** `#recent-imagery-panel` is rail markup in the Context view (`src/ui/views/ContextView.vue`) beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`; opening DETAILS then scrolls its card into view once.
+10 -5
View File
@@ -285,12 +285,17 @@ export function createRecentImageryLayer({
const rank = (list) =>
rankLatest(list.map(withAvailability), { truncated: _truncated });
/** Focus the START HERE day and preview it when the mode's slot is empty. */
function autoPreview() {
/**
* Focus the START HERE day and preview it when the mode's slot is empty.
* @param {string | null} [emptyKey] The empty automatic day handed over;
* focus still on it moves too, since a followed day previews only while
* it is focused.
*/
function autoPreview(emptyKey = null) {
const ranked = rank(sourcedCandidates());
if (!ranked.candidate) return;
const key = ranked.candidate.key;
if (!_focusKey) _focusKey = key;
if (!_focusKey || _focusKey === emptyKey) _focusKey = key;
if (!previewSlot() || activeSlotOf(key)) return;
_auto = { key, reason: ranked.reason, certain: ranked.certain };
if (drapable(candidateFor(key))) _previewKey = key;
@@ -317,7 +322,7 @@ export function createRecentImageryLayer({
_previewKey = null;
_auto = null;
changed = true;
if (wasAuto) autoPreview();
if (wasAuto) autoPreview(preview.key);
}
// A followed day that turns out empty is dropped; an automatic one hands
// over to the next ranked day.
@@ -327,7 +332,7 @@ export function createRecentImageryLayer({
_followKey = null;
if (wasAuto) {
_auto = null;
autoPreview();
autoPreview(followed.key);
}
changed = true;
}
+27
View File
@@ -1128,6 +1128,33 @@ test('an automatic overview that probes empty hands over to the next ranked day'
assert.deepEqual(f.drapes(), { a: `${V15}|none`, b: null });
});
test('with empty days shown, focus leaves an empty automatic overview with the hand-over and its successor drapes', async () => {
const f = fixture();
f.layer.setSources({ hls: false });
f.layer.setShowUnavailable(true);
await f.ready();
assert.equal(f.diag().following, V21, 'today is followed');
f.thumbnails.probe(V21, 'empty');
assert.equal(f.diag().following, V15, 'yesterday takes over');
assert.equal(f.snap().focus.key, V15, 'focus goes with it');
f.thumbnails.probe(V15, 'present');
assert.equal(f.snap().preview.key, V15);
assert.equal(f.snap().preview.pending, null);
assert.deepEqual(f.drapes(), { a: `${V15}|none`, b: null });
});
test('focus on an empty automatic day moves to the next ranked day, not the nearest card, and it drapes once present', async () => {
const f = fixture();
await f.ready([CANDIDATES[0], CANDIDATES[1], CANDIDATES[3]]);
assert.equal(f.snap().preview.key, S18);
f.thumbnails.probe(S18, 'empty');
assert.equal(f.diag().following, V21, 'the newest overview takes over');
assert.equal(f.snap().focus.key, V21, 'not the older neighbour V15');
f.thumbnails.probe(V21, 'present');
assert.equal(f.snap().preview.key, V21);
assert.deepEqual(f.drapes(), { a: `${V21}|none`, b: null });
});
test('a pinned day still being probed drapes once the probe says present', async () => {
const f = fixture();
f.layer.setParams({ ...LINK_BOX, a: V21, mode: 0 });