Skip to content

Export a self-contained legend band with layer dates on shared maps - #383

Draft
CarsonDavis wants to merge 51 commits into
feat/veda-stac-layer-sourcefrom
feature/328-export-legend
Draft

Export a self-contained legend band with layer dates on shared maps#383
CarsonDavis wants to merge 51 commits into
feat/veda-stac-layer-sourcefrom
feature/328-export-legend

Conversation

@CarsonDavis

Copy link
Copy Markdown
Collaborator

Line breakdown

Category Lines added %
Tests 3154 61.6%
Production code 1450 28.3%
Carried from #337 246 4.8%
Docs 181 3.5%
Config/build 54 1.1%
Generated 39 0.8%

Exported maps now carry a legend band under the image, so a PNG or PDF pulled out of the app can be read without the app: which layers were on, what their colours mean, and what dates the data is from.

Closes #328

Based on feat/veda-stac-layer-source (PR #338), because the per-layer period comes from the fields that PR writes. The diff also shows PR #337's commits until that PR merges.

What this does

The exported image has a legend under the map

Before, a user who clicked Export got the map pixels and nothing else. The recipient could not tell which layers were on or what a colour meant. Now the PNG comes out taller, with a white band appended below the map: the mission name, a row per layer, and the dates. The PDF export embeds the same composed image, so it carries the band too.

The band says when the picture was taken and where the time slider was

The header prints Exported <time> on every export, and Time cursor <time> when the mission has time enabled. Both go through the mission's configured time format, so they look like the dates in the app's own Time Control.

Every toggled-on layer gets a row

A row is the layer's display name plus its graphic: a gradient bar with min and max labels and a unit, or a set of colour swatches, or just the name when the layer has no legend to draw. Bars show the live session state, so a colormap or rescale the user changed in the app is what appears. Only layers at opacity zero are left out.

Every row says what dates its data is from

A bare date range on a figure reads as "this is when the imagery was taken," which is the one thing the app usually cannot know. So each date line carries a noun saying what it is:

  • Showing 2025-06 for a layer with a period length (time.interval): the period the cursor sits in, snapped to the calendar for daily, monthly, and yearly layers, or stepped from the layer's authored start for other lengths.
  • Requested <start> → <cursor> for a time-enabled layer with no period length: the span the map actually asked the tile server for. When the Time Control is in Point mode the window opens at 1970, and the line reads Requested up to <cursor> instead of printing a span since the epoch.
  • Collected <start> → <end> for a layer that ignores the slider: its authored Data Time Extent. No line at all when nothing was authored.

docs/DATES.md is new and explains where each of these values comes from in the code.

A checkbox turns the band off

Both the ShareExport and MapControl tool configs gain Include Legend On Exports, default on. If the legend fails to build for any reason, the export still downloads the plain map and a warning goes to the console.

Time formatting is a bus service

Two new handlers, time:getCurrentFormatted and time:formatTime, render a time through the mission's format so plugins never carry their own copy of that rule. The mission Time Format now accepts moment tokens; a string containing % is still read as d3.

Could surprise an existing mission

  • Time Format default changed to YYYY-MM-DDTHH:mm:ss[Z], and a format string without % is now read as moment tokens rather than d3.
  • Unconfigured COG rescale bounds are null, not 0 and 255, so the in-app gradient labels go blank instead of showing an invented range.
  • Legend typing is stricter. A legend that mixes scale entries with swatches, or whose values are not numbers, draws as swatches instead of a ramp, and hideFromLegend is honoured for every legend type.
  • A derived legend loses to live COG data when a layer has both.
  • When core reports no visibility map, layers are kept instead of all being filtered out.

Decisions to review

  1. Per-layer date lines and rows for graphics-less layers follow a product decision that supersedes the issue text, which had put per-layer time out of scope and omitted layers with nothing to draw. The wording rules and the Point-mode heuristic (a window start within a day of 1970-01-01 means "no start was asked for") are new and worth reading closely.
  2. time:formatTime is a permanent core handler and is deliberately not gated on the Time Control being enabled, since the time comes from the caller. The alternative was formatting plugin-side from time:getCurrent plus a config read.
  3. COG bounds default to null in the shared model, which the in-app LayerManager also uses, rather than keeping 0 and 255 there and blanking only on export.
  4. _legendAutoGenerated is a flag on the global layer store. LayersTool.js stamps it and buildLayerLegendData.ts reads it so live COG data beats a derived legend. It couples a classic tool to the shared model and changes precedence in the existing panel too.
  5. Bundled js-colormaps is authoritative and TiTiler is consulted only for unknown names, so a server-side override of a standard name would render one ramp on the map and another in the legend.
  6. Legend failure is silent. The export falls back to the bare map with a console warning; nothing in the ShareExport UI tells the user the legend was dropped.
  7. LayerManager's portable lib/ may now import four modules from _shared/legend, held by an allowlist in libBoundary.spec.js.

BhattaraiSijan and others added 30 commits August 25, 2026 14:00
…uration offsets)

A layer's dataStartTime/dataEndTime may now be a policy re-evaluated at load instead of a fixed timestamp, so configs for growing collections never go stale. One pure resolver evaluates 'now' and 'now +/- <ISO duration>'; per veda-ui the result is the raw current moment, never rounded. Unparseable values resolve to null so consumers keep their fallbacks. Spec'd with an injected clock. Mechanism half of #332.
The per-layer timeline bar previously hand-parsed data times with an inline 'now' special case for the end only. It now resolves both ends through the time-policy resolver, so open-ended and offset policies render correctly on either end and reach the current moment on every load. The bus layer-config type documents the policy vocabulary plus the carried granularity/periodicity facts (interval, isPeriodic). Completes #332's visible half.
The Data Start/End Time field descriptions said ISO 8601 datetime only, so an admin had no way to discover that now, or now offset by an ISO 8601 duration, is a legal value. The tile and vector layer forms both say so now. Description text only; no behaviour change. Completes #332's documentation criterion.
Nothing in this PR writes or reads either field;
The Timeline tool declared no defaults block, so the generator excluded it from every generated mission. Declaring one opts it in; the demo profile then lists it and gains a bottom panel for it to mount into, since it only sits at the bottom.
paintRamp threw on an empty stop color (buildGradientFields' entry.color ||
'' with an unset legend row), which failed the entire band away instead of
just that stop. Fall back to the same neutral swatch the categorical path
already uses for a missing color.

Also clip the header, row titles, and category labels to the band width with
a measureText-based ellipsis so a long mission name or label can't overflow
the band; category labels are clipped identically in the measure and draw
passes so line wrapping stays in agreement between them.

Tests: a blank stop mid-ramp still renders, the neutral-ramp fallback is
asserted by its actual gradient colors (not just an incidental fillRect
count), overflow clipping for header/title/category label, and a pinned
regression that no drawn primitive extends past the measured band height
across gradient/wrapping-categorical/mixed models at scale 1 and 2.
…ientGraphic

formatLegendValue('') read via Number('') as 0, so a missing bound rendered
as '0' instead of blank, and formatLegendBound appended the unit even to a
bound that was already a unit-carrying string (e.g. '<0.1 ppm' + 'ppm' ->
'<0.1 ppm ppm'). formatLegendValue/formatLegendBound now render
null/undefined/blank as '', and only append a unit to a bound that actually
rendered as a number. getExportLegendModel passes null instead of '' for a
missing authored min/max to get that blank rendering.

GradientGraphic's formatTooltipValue duplicated formatLegendBound's exact
rules (including the 0-case and the exponential fallback); collapsed to call
the shared function directly.
… data

LayersTool.populateCogScale writes a derived colormap snapshot into the same
layer.data._legend field a mission author's variables.legend/CSV uses.
buildLayerLegendData couldn't tell the two apart, so once a raster had been
rendered once, its stale snapshot won the "authored beats cog" precedence in
the export instead of the live cog min/max/colormap.

populateCogScale now also sets _legendAutoGenerated on the layer data;
buildLayerLegendData treats a marked _legend as if there were none, falling
through to the cog block.
…legend files

The libBoundary spec exempted all of _shared/legend/ as "as portable as lib/
itself," but that's false: getVisibleLayersWithLegends and
getExportLegendModel import mmgisAPI (a host bus client), and
resolveColormapColors makes a relative import into src/external/ — all
host-coupled. Only types/format/colormaps/colormapCache are actually free of
MMGIS and network dependencies.

Scoped the exemption to that allowlist and corrected the comment. Added a
test proving a lib/ import of the excluded modules would now fail the
existing "no relative import escapes lib/" check.
…allback

resolveColormapColors' header promised it never throws, but a rejecting
fetchColormapColors dependency would have propagated — only colormapCache's
own internal catch protected the real implementation. Wrapped the TiTiler
call in try/catch.

Also: an unknown colormap name (neither js-colormaps nor TiTiler recognizes
it) used to resolve null, drawing the renderer's neutral gray backstop, while
deckRaster's colormapLUT falls back to viridis for the same case — so an
export could show gray for a layer the map painted in color. Both now share
one case-insensitive key-matching helper (colormaps.ts' findColormapKey),
and resolveColormapColors falls back to the local viridis ramp after both
lookups miss, keeping the neutral ramp as pure last-ditch backstop in the
renderer for a genuinely null/empty colors list.

Raised LOCAL_SAMPLES from 32 to 256 to match TiTiler's own granularity, and
switched the reversed-ramp path to reverse the sampled array instead of
resampling with evaluate_cmap's reverse flag — floating-point rounding at
256 samples could otherwise put a reversed ramp a few units off its exact
mirror.

Removed the unused hasLocalColormap export (dead outside its own test;
findLocalKey already covers the "is there a local ramp" check internally).
getExportLegendModel passed viewState.time straight through as a raw ISO
string; the mission's display format (globalTimeFormat, built from
L_.configData.time.format) lives in core and wasn't exposed over the bus, so
the exported legend's header disagreed with what TimeControl's own UI shows.

Added a core provider, time:getCurrentFormatted, registered in
TimeControl.init() alongside the existing time:* handlers — returns the
current time through globalTimeFormat, null when time is disabled or not yet
seeded. getExportLegendModel consumes it via mmgisGetCurrentTimeFormatted
and falls back to the raw viewState.time string against an older core that
doesn't register the handler.
…trol

ShareExport's resolveIncludeLegend used `!== false`, while MapControl's
inline check used the wider falsy set (false/'false'/0/'0'). Configure's
checkbox field can persist any of those forms, so the two adapters could
disagree on the same saved value — one leaving the legend band on, the other
turning it off.

Moved the resolver to _shared/share/resolveIncludeLegend, using MapControl's
wider semantics, and pointed both adapters at it. ShareExport's own
shareConfig.ts keeps only resolveShareFormats (PNG/PDF), which isn't shared
with MapControl.
composeExportImage is pure canvas work for the legend band, not an
MMGIS-facing adapter — moved from _shared/adapters/ to _shared/legend/
(git mv) and updated its own imports plus shareActions.ts's.

It also allocated a second, throwaway canvas just to measure the band before
resizing the real one. canvas.width/height resets all context state, but
drawLegendBand sets its own fonts before it draws, so nothing depends on
state surviving the resize — measuring on the same context first (before the
resize) works and drops the extra canvas.

Also collapsed shareActions.ts's two separate import statements from
getExportLegendModel into one with an inline `type` specifier, and
strengthened composeExportImage.spec.js's ctx stub to record fillStyle at
call time and assert the gradient bar actually carries the model's colors,
not just an incidental fillRect count.
…port path

- Normalized _shared/legend/types.ts to the repo's 4-space indent
  (whitespace-only change).
- Noted in renderLegendBand.ts that the band's hardcoded white
  background/dark text is deliberate — an exported PNG/PDF is a
  printable/shareable artifact, not a themed UI surface.
- LayerManagerPanel.spec.tsx now imports Layer from _shared/legend/types
  directly instead of through the lib/types shim, matching
  MMGISLayerManagerAdapter.tsx's pattern for non-lib consumers.
Mission time.format strings are moment tokens (e.g. 'YYYY-MM-DDTHH:mm:ss[Z]'),
but the provider ran them through d3's utcFormat, which emits non-% patterns
verbatim — the export header showed the literal format string instead of a
date. Format via moment.utc(...).format(...) instead, matching how the rest
of the app (TimeUI.js) already formats times, and fall back to
'YYYY-MM-DDTHH:mm:ss[Z]' when a mission has time enabled but no configured
format.
buildGradientFields sniffed the unit with /[\d.]+\s*(.+)/, which backtracks
into the numeric part on values like '-0.1' and captures '1' as a fake unit
(bounds rendered as '-0.1 1'). Anchor the numeric prefix match at the start
instead, so the unit is only whatever literal text follows it — a purely
numeric value now yields no unit.
…port

getVisibleLayersWithLegends only checked layers:getVisible, so the export
legend listed every toggled-on layer regardless of opacity, zoom, or whether
it was anywhere near the current view. Add filterLayersForExportView,
applied only in getExportLegendModel (the LayerManager panel keeps showing
every toggled-on layer), which drops a layer on positive evidence only:
opacity 0, out of its configured min/maxZoom or legacy visibilitycutoff
range, or bounds that provably don't intersect the viewport. Any signal core
can't answer — no viewport, no zoom, no bounds for that layer — keeps it.

Bounds intersection normalizes both a world-wrapped viewport (map:getBounds
can report longitudes past ±180 after repeated panning) and antimeridian-
straddling extents on either side before comparing.
boundsIntersect normalized a layer's west/east bounds the same way it
normalizes a west-to-east interval, so a global layer like
[[-90,-180],[90,180]] collapsed to the single antimeridian point
(normalizeLng(180) === -180) and read as never intersecting any viewport
not touching the dateline. Mirror the viewport's >=360deg whole-world guard
on the layer side too: a layer spanning the full globe intersects whenever
latitudes overlap, without going through longitude segmenting at all.
The Configure Time tab field description and its docs page still described
time.format with D3 time-format specifiers and the %Y-%m-%dT%H:%M:%SZ
default. The provider now formats via moment (matching TimeUI and real
mission configs), so point both at moment.js tokens with the
YYYY-MM-DDTHH:mm:ss[Z] default it actually falls back to.
getExportLegendModel and getVisibleLayersWithLegends each requested
layers:getAllConfigs independently, hitting the bus twice per export for
the same data. getVisibleLayersWithLegends now accepts an optional
layerConfigs to use in place of its own fetch, and the export model fetches
it once and threads it through.
Two live-diagnosed defects: a raster (COG) commonly paints well outside its
declared bbox (observed: ~40% of the footprint span past the edge), but
boundsIntersect tested that bbox as a hard edge, dropping legend rows for
layers with real painted pixels on screen. Separately, core's
enforceVisibilityCutoffs only zoom-gates type === 'vector' layers — rasters
overzoom and keep painting past their configured maxZoom — but our zoom gate
applied to every type, dropping an overzoomed raster's row too.

Fix both by type: only a vector layer is zoom-gated, and only a vector
layer's bounds are a hard edge. Every other type's footprint is padded
(padFootprint: each axis grows by 100% of its own span per side, 0.5°
minimum) before the same intersection test, reusing the existing
wrap/whole-world guard when padding itself pushes the span past 360°.

Also: when this filtering empties a non-empty layer set (the fail-open path
that renders no band at all), log which layers were dropped and why —
previously silent, which is what made the regression invisible.
Config boundingBox is author metadata that routinely disagrees with where a layer paints, and a vector layer's deck.gl bounds fall back to that same box, so no viewport test can exclude a layer on evidence. Only a zero-opacity layer, which provably paints nothing, is dropped.
CarsonDavis and others added 21 commits August 26, 2026 21:41
A machine-derived legend is ignored in favour of live cog data. With no cog data to prefer, discarding it drops the only legend the layer has - velocity layers lost their legend entirely, in the export and the layer panel.
A '%' anywhere in the configured string selects d3 specifiers, otherwise the string is read as moment tokens, so a mission configured under either convention formats correctly. An unusable format warns and falls back to the default rather than breaking the export.
The ShareExport manifest gained an includeLegend default; the generated config had not been regenerated to match, which the config-generation check fails on.
Reference for any feature needing to know whether a layer is showing something: presence versus transparency, what each engine and layer kind can answer, and the gating signals to check first.
The deck raster renderer and the export legend must agree on how a colormap name reads, so the primitives live in core and the shared legend module imports them. A boundary test fails if anything under Basics imports from Tools.
A ramp is produced only when every visible entry is a continuous or discreet shape, every value reads as a number, and the entries agree on one unit; anything else renders as labelled swatches. Entries marked hideFromLegend are dropped before either path. A categorical value of zero prints, and an unrescaled raster's bounds render blank rather than as an invented 0-255.
Each gradient bound is clipped to half the bar and the upper label is clamped to the bar's left edge, so long units cannot overlap or run off the canvas. A visibility map core could not answer is unknown, not empty, so every layer keeps its row.
Composed canvas dimensions, the visible-only and header and listed guards, the single-colour ramp, the includeLegend flag reaching the export call, and the time bus name were all unasserted; each mutation was verified to fail its new test.
The now / now ± duration vocabulary is a core-owned meaning of dataStartTime/dataEndTime. Core resolves it at call time behind one provider; the Timeline plugin reads dates and no longer imports a resolver. The Layers tool's set-to-extent action goes through the same path, which it previously did not (a 'now' produced NaN there).
Replace the single time label in the band's header with a time range line under each row. A local layer shows its own time.start to time.end; global and requery layers show the Time Control's window. Ranges are formatted through a new time:formatTime bus handler so a plugin-held time renders exactly as TimeControl's UI would, and the shared window is formatted once and reused across rows.
Add docs/DATES.md: the three kinds of date (acquisition, interface, export), where each value lives in code, how the cursor reaches a tile request, the two time.format settings, the Data Time Extent fields and their policy strings, and what the export legend reads today.
…durations

parseISODuration and addDuration are now exported so plugin-shared code can step periods with the same calendar-safe arithmetic core uses for "now - P1D" policies. A duration whose every unit is zero now parses as null instead of passing the presence check on the string "0".
… and export time

Every toggled-on layer now gets a row, including layers with no legend graphics. Each row carries a labelled date line: a time-enabled layer with a period length shows the period holding the cursor ("Showing 2025-06"), one without shows the span the map requested ("Requested start → cursor", or "up to" when Point mode opened the window at the epoch), and a layer that ignores the slider shows its authored Data Time Extent ("Collected a → b"). The header prints the time cursor and the export time. The URL placeholder test is gone: time.enabled is the signal, since core appends datetime for STAC, COG, and TiTiler layers with no placeholder in the URL.
…lies

A row's dates now print at the precision set by the smallest unit in the layer's time.interval: years as 2026, months as 2026-07, days or weeks as 2026-07-03, hours as 2026-07-03 06:00Z, and minutes or seconds as the full timestamp. A layer with no interval prints at day precision. Ranges print both ends inclusively at that precision, and a period whose ends collapse to one label prints it once. An interval under an hour no longer counts as a period, so such a layer shows the requested span at full precision. Only the header still goes through the mission's time format.
The five VEDA time-enabled layers carry the interval, isPeriodic, dataStartTime, and dataEndTime their collections report, and the Sentinel-2 dNBR layer carries the acquisition day of the STAC item its URL names. With these in the blueprint the export legend shows Showing and Collected lines on the demo, and the values survive the boot-time converge.
A time-enabled layer's date line is now Collected over the overlap of the request span with the layer's Data Time Extent, narrowed to the cursor's period when the layer has a cadence and that period holds data, and clipped so it never names a day the layer has no data for. A layer covering 2015 to 2016 with the cursor on 2024 prints "Collected 2015 → 2016" instead of a period the layer never had. Requested remains only for layers with no coverage, or whose request span misses it entirely. The Showing label is gone.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants