Export a self-contained legend band with layer dates on shared maps - #383
Draft
CarsonDavis wants to merge 51 commits into
Draft
Export a self-contained legend band with layer dates on shared maps#383CarsonDavis wants to merge 51 commits into
CarsonDavis wants to merge 51 commits into
Conversation
…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.
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).
… feature/328-export-legend
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.
…o feature/328-export-legend
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Line breakdown
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, andTime 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-06for 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 readsRequested 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.mdis 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:getCurrentFormattedandtime: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
YYYY-MM-DDTHH:mm:ss[Z], and a format string without%is now read as moment tokens rather than d3.hideFromLegendis honoured for every legend type.Decisions to review
time:formatTimeis 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 fromtime:getCurrentplus a config read._legendAutoGeneratedis a flag on the global layer store.LayersTool.jsstamps it andbuildLayerLegendData.tsreads 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.lib/may now import four modules from_shared/legend, held by an allowlist inlibBoundary.spec.js.