Skip to content

Core: stop requesting a time-enabled layer outside its declared data coverage #409

Description

@sandesh-sp

Core: stop requesting a time-enabled layer outside its declared data coverage

Motivation

A time-enabled layer requests tiles for every instant the timeline visits, including instants its data does not cover. A layer with data on twelve days of 2020 still issues a full viewport of requests on each of the other 354 days, and every one comes back empty, missing, or transparent. The cost is paid per tile, per layer, per time step, and it is at its worst in the case that matters most — scrubbing the timeline.

Layer configurations can already declare their coverage: a Data Start Time, a Data End Time, and an optional list of Data Dates. Today the timeline draws them and nothing else reads them. The Configure page even describes them as "for display purposes only".

How it should work

A time-enabled layer that declares coverage issues no requests while the timeline's window lies outside that coverage — on either map engine, and whether the layer was switched on before, during, or after the window moved out. When the window comes back into coverage the layer returns, showing that instant's data, with no wasted round of requests for a stale time.

A layer that declares no coverage behaves exactly as it does today. There is no opt-in switch: a layer declaring coverage it does not have is a configuration bug, not a mode.

Coverage means:

  • Data Dates listed — the layer is sparse. It has data during the listed entries and nowhere else, even between them. This wins over the start/end extent when both are set.
    • Each entry is written only as precisely as the data is, and covers the whole of what it names: 2020 a year, 2020-03 a month, 2020-W10 an ISO week, 2020-03-04 a day, 2020-03-04T14 an hour. All in UTC.
    • The hour is the finest unit, because it is the finest step the app takes. An entry written to the minute, like 2020-03-04T14:30, covers its hour.
    • Every entry also keeps one timestamp, with any part left out filled with its start: 2020-03 is 1 March 00:00, and 14:30 stays 14:30. That timestamp is where moving the timeline to the entry lands.
    • Entries that overlap or nest, like a month and a day inside it, are all kept, since each is its own place to move to. Only an exact repeat is merged.
  • Start and/or end set, no dates — the layer is continuous across that span. A missing bound leaves that direction open, so a layer naming only a start is gated before it and unconstrained after it. An end of now means the current moment.
    • Bounds follow the same rule as entries: a start opens at the beginning of what it names and an end closes at the end of it, so an end of 2020-03 runs to the last moment of March. Values that are not ISO 8601 are read as the exact moment they name.
  • A start after its end — treated as no coverage, with a console warning. Ignoring a self-contradictory constraint is recoverable; blanking the layer at every instant is not.
  • When in doubt, request. A window that cannot be read, a layer with no time block, a bug in the check — all resolve to "has data". The gate only ever suppresses on positive evidence of absence.

The core owns coverage and serves it. Any plugin can ask for a layer's coverage record over the message bus — the verdict, whether it is sparse or continuous, the spans, and the window the layer would have requested — and subscribe to changes. Each span from a listed entry also carries the entry's unit and its timestamp, so a consumer can name it ("in March 2020") or move to it without re-reading configuration. The record is complete enough that a consumer never has to reach back into core state or re-parse layer configuration. Two consumers are tracked as sub-issues: the Layers panel warning (#410) and the Timeline reading coverage from core (#411), which retires the plugin's own parsing so the two can never disagree about which days exist.

The Configure page's descriptions for the three fields say plainly that the layer will not be requested outside the declared coverage, that an empty field leaves that direction open, and how an entry's precision sets what it covers. The fields appear on every time-enabled layer type, not only tile and vector layers.

Done when

  • With the network panel open, stepping the timeline across a gap in a sparse layer's dates produces no requests from that layer on the Leaflet engine, and none on the deck.gl engine.
  • The same holds for a continuous layer outside its start/end, for a layer with only a start or only an end, for a layer switched on while already out of coverage, and for a layer whose page loaded with the time bar outside its coverage.
  • A layer listing entries at mixed precision — a month, a day, an hour — is requested exactly while the window touches one of them, and a minute-level entry keeps its layer requested for its whole hour.
  • Moving the window back into coverage restores the layer showing that instant's data, without a preceding round of requests for the previous time.
  • Reordering layers, toggling other layers, or the remaining layers finishing their load does not bring a suppressed layer back onto the map.
  • A layer with no coverage declared, and a layer with an inverted extent, are requested exactly as before; the inverted one logs a warning.
  • Layers whose time is driven externally ("controlled") are reported as out of coverage but are not moved on or off the map by the gate.
  • A plugin can request one layer's coverage record, or all of them, and subscribe to changes; scrubbing the timeline across many steps where nothing changes for a layer does not flood subscribers with identical events.
  • With every plugin removed from the build, the suppression still happens.
  • On the Configure page the three field descriptions state the constraint, and the Data Time Extent fields are available on vector tile, query, and velocity layers.
  • Unit tests cover the coverage rules above: precedence, every unit from year to hour, finer entries covering their hour, the stored timestamp, offsets, nested and repeated entries, partial-date bounds, open bounds, now, inverted extent, inclusive overlap at both edges, and every fail-open case.

Out of scope

  • The Layers panel warning icon and popover, tracked as Layer Manager: flag a layer that has no data at the current time #410.
  • The Timeline migration off its own coverage parsing, tracked as Timeline: draw layer bars and navigation stops from the coverage core serves #411. Required before this work is considered complete, not optional.
  • The 3D globe: it keeps today's behavior and is gated separately later.
  • Side-by-side comparison views pinned to their own time; they substitute times into layer URLs on their own path and keep today's behavior.
  • An opt-in flag, dimming or disabling the row, or any change to what the timeline scrubber does.
  • Gating on the current instant rather than the whole requested window. A year-wide window that overlaps a sparse layer's days is correctly not suppressed; the saving is realised when stepping narrow windows.
Draft implementation plan — written as of 7aa7a25 on 2026-09-10. Rough guide; re-verify against latest code.

Full design: docs/superpowers/specs/2026-09-09-layer-data-coverage-request-gating-design.md. Task-by-task plan: docs/superpowers/plans/2026-09-09-layer-data-coverage-request-gating.md — Tasks 1–7 are this issue, Tasks 8–10 are #410, Tasks 11–12 are #411. This section summarises the design and adds traps found while checking it against the code.

Current behavior

TimeControl.reloadLayer in src/essence/Basics/TimeControl_/TimeControl.js is the time-change choke point: reloadTimeLayers calls it for every time-enabled layer on every time step, and it runs performTimeUrlReplacements (which can hit an external API) before refreshing the engine's layer. The requested window is [layer.time.start, layer.time.end] = [TimeControl.startTime, TimeControl.currentTime], set by updateLayersTime; the current instant is the window's end.

Coverage fields (time.dataStartTime, time.dataEndTime, time.dataDates) are read only by the Timeline plugin — resolveListedDays and resolveLayerExtent in src/essence/Tools/Timeline/lib/utils/timeUtils.ts, consumed by resolveLayerTimeRanges and layerNavigation.ts. The Timeline gets layer configs over the bus via layers:getAllConfigs.

Visibility is one engine lever, setLayerVisibility on IMapEngine. Leaflet adds/removes the layer from the map (idempotent). deck.gl clones the layer with visible and re-syncs; _syncLayers filters visible === false layers out of what deck holds, which is what stops fetching — deck's own TileLayer has no visible guard.

Per-layer core state with a bus surface already has a pattern: L_.layers.loadStatus + L_.setLayerLoadStatus + layers:getLoadStatus / layers:loadStatusChanged, and layers:getCogCapabilities, all in src/essence/Basics/Layers_/Layers_.js. Typed plugin wrappers live in src/essence/Tools/_shared/adapters/mmgisAPI.ts.

Where the change lands & rough plan

  1. A pure module src/essence/Basics/TimeControl_/layerDataCoverage.js: config in, spans out (epoch ms, open bounds as ±Infinity, null = unconstrained), kind (sparse | continuous | null), and an inclusive window-overlap check that fails open. No prose, no engine, no L_. Each entry is parsed strictly as ISO 8601 with moment, whose creationData().format names the precision the entry was written at (YYYY-MM → month, GGGG-[W]WW → week, anything with HH → hour); the span is startOf/endOf that unit, isoWeek for weeks, and at is the parsed value itself. Sparse spans are { start, end, at, unit }, continuous ones { start, end }.
  2. Gate points in core: reloadLayer (early return before URL work, publish state, hide/restore), L_.toggleLayerHelper's on-path (the literal setLayerVisibility(name, true) after catchUpLayerTime becomes the coverage check — note there are two such sites, one per layer-type branch), and handOffToEngine in Map_.js (visible = on && has data, so a layer built out of coverage never fetches once).
  3. State: L_.layers.dataCoverage written only through L_.setLayerDataCoverage, cleared in both places loadStatus is cleared; layers:dataCoverageChanged event and layers:getDataCoverage provider next to getCogCapabilities; typed wrapper in mmgisAPI.ts.
  4. Layers tool: .noDataWarning icon next to .refreshWarning in LayersTool.js, tippy popover, wording done in the tool from kind + spans, initial sync via request plus live subscription, handles kept and destroyed on rebuild and teardown.
  5. Configure: rewrite descriptions in layer-tile-config.json and layer-vector-config.json — the start/end ones carry the "display purposes only" sentence, and the Data Dates one still says "Format: YYYY-MM-DD … Day granularity only" — so all three state the constraint and the precision rule with examples; add the Data Time Extent subsection to layer-vectortile-config.json, layer-query-config.json, layer-velocity-config.json, each of which already has a Time section.

⚠️ Gotcha: L_.addVisible (Layers_.js) calls setLayerVisibility(name, true) for every layer that is on. It runs on allLayersLoaded, on lazy layer loads, and after a re-order. The three gate points in the spec do not cover it, so without a coverage check there a suppressed layer is resurrected — and starts fetching — the moment layers are reordered. Either gate it too or route every "show" through one helper.

⚠️ Gotcha: the spec's reloadLayer snippet restores visibility before the existing refresh body runs. A layer coming back into coverage is then added to the map with its previous URL, fetches a round of stale tiles, and refetches once the URL is rewritten — the exact waste this feature exists to remove. Restore after the refresh. Both engines refresh a hidden layer fine: Leaflet's tile setUrl redraws only when on the map, and deck's refresher clones the held layer.

⚠️ Gotcha: deck.gl's setLayerVisibility is not a no-op when the value is unchanged — it clones the layer and re-syncs every held layer on every call. The spec calls it on the has-data path for every time-enabled layer on every time step. Call it only on a transition (suppressed ↔ not), or the scrub becomes N full re-syncs per step.

⚠️ Gotcha: the spec asks the setter to drop no-op emissions by comparing the record with the previous one, but the record includes requestedWindow, which changes on every step, and spans for a dataEndTime: 'now' layer changes every call. A naive deep compare never dedups. Emit on a change of verdict/kind/spans-shape only, and let a consumer that needs the exact instant (the popover) ask for it on open via the request handler.

⚠️ Gotcha: resolveCoverageKind(coverage) as specified takes the span array, but the design also says kind "is not recoverable from the spans alone". Have the resolver return { kind, spans } (or take the config) and compute the whole record once per layer per step — the snippet as written also resolves coverage twice per call and references an undefined parseWindow.

⚠️ Gotcha: change detection has to compare each listed span's at and unit exactly. Two entries in the same hour share their bounds, so bounds alone cannot show one of them moving — and navigation depends on that timestamp.

⚠️ Gotcha: catchUpLayerTime decides whether a switched-on layer needs a reload by comparing layer.time.current to the current time. The suppressed path must stamp it under the same condition the existing willRefresh uses — never unconditionally (an off layer would look current forever) and never skipped (every later toggle would reload for nothing).

References

  • Design spec: docs/superpowers/specs/2026-09-09-layer-data-coverage-request-gating-design.md
  • Gate choke point and window semantics: src/essence/Basics/TimeControl_/TimeControl.js (reloadLayer, updateLayersTime, reloadTimeLayers)
  • Visibility lever: src/essence/Basics/MapEngines/Adapters/LeafletAdapter.ts, DeckGLAdapter.ts (setLayerVisibility, _syncLayers, refreshLayer)
  • State/bus precedent: L_.setLayerLoadStatus, layers:getCogCapabilities in src/essence/Basics/Layers_/Layers_.js; src/essence/Tools/_shared/adapters/mmgisAPI.ts
  • Timeline's current parsing and tests: src/essence/Tools/Timeline/lib/utils/timeUtils.ts, layerNavigation.ts, __tests__/layerTimeRanges.spec.ts, __tests__/layerNavigation.spec.ts
  • Comparison-side time substitution (why comparison views are out of scope): src/essence/Basics/Map_/comparisonTimePins.ts

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions