diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index 3aa85f72c..003b15248 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -195,6 +195,12 @@ signal })` returns one tile or null for a transparent tile, each as an `ImageBitmap`, `ImageData`, canvas or `VideoFrame` of exactly the requested size, which the layer draws once and closes. Without them the layers read the image and tile routes (`WeatherFrameFeed` in `src/layers/weather/source.js`). +It may also name its source with an optional `sourceName(kind)`, `kind` being +`'radar'`, `'lightning'` or `'satellite'`: the layer's row shows that name until +a manifest names its own, and NOAA nowCOAST when the source names none. The +name is read once, when the layer is built, and held to the same rule as a +manifest's `display.source`. The map credit still comes from each manifest's +`display.credit`, so a source that is not nowCOAST sets that too. `application/services` accepts boundary, terrain, regional-context, weather and summary services; its weather service supplies cockpit local conditions, not the diff --git a/src/layers/weather/display.js b/src/layers/weather/display.js index 13e117570..42a6c6a63 100644 --- a/src/layers/weather/display.js +++ b/src/layers/weather/display.js @@ -39,6 +39,15 @@ function text(value, max) { : undefined; } +/** + * A source name a weather feed supplies itself (`sourceName` on the feed), + * held to the same rule as a manifest's `display.source`; undefined when it + * is not a fit name. + */ +export function weatherSourceName(value) { + return text(value, TEXT_LIMITS.source); +} + /** * Whether a label the legend formats as a number reads back exactly as given: * within the safe integer range and with no digits lost to rounding, so diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 9f159f3ea..55593a437 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -2,7 +2,7 @@ import * as Cesium from 'cesium'; import { createWeatherRendering } from './rendering.js'; import { weatherFrameSource } from './source.js'; import { imageryHostStatus } from './imageryHost.js'; -import { weatherDisplay, insideExtent } from './display.js'; +import { weatherDisplay, weatherSourceName, insideExtent } from './display.js'; import { RADAR_MAX_GAP_MS, REGIONAL_INFRARED_MAX_GAP_MS, @@ -55,7 +55,9 @@ const utc = (value) => /** A per-application observation layer, using the existing layer lifecycle and * row controls. Its time is the world clock's, so a link made away from live - * opens its imagery at that time; one made live opens the latest. */ + * opens its imagery at that time; one made live opens the latest. The feed + * may also name its source with `sourceName(kind)`, `kind` being 'radar', + * 'lightning' or 'satellite'; NOAA nowCOAST is named otherwise. */ export function createWeatherLayer({ feed, clock, @@ -72,6 +74,20 @@ export function createWeatherLayer({ const radar = id === 'weather-radar'; const lightning = id === 'weather-lightning'; const satellite = !radar && !lightning; + // A feed may name the source behind each product ('radar', 'lightning' or + // 'satellite'); its row then says so before a manifest names it. A name + // that fails, like a manifest's, leaves the built-in one. + let sourceName = null; + try { + sourceName = + weatherSourceName( + feed.sourceName?.( + radar ? 'radar' : lightning ? 'lightning' : 'satellite', + ), + ) ?? null; + } catch (cause) { + console.warn('[weather] source name unavailable:', cause); + } let product = radar ? 'radar' : lightning ? 'lightning' : 'clouds-regional'; let opacity = 'strong'; let infrared = 100; @@ -301,7 +317,7 @@ export function createWeatherLayer({ ? 'Lightning density' : 'Satellite clouds', icon: radar ? '◉' : lightning ? 'ϟ' : '☁', - source: 'NOAA nowCOAST · OBSERVED', + source: sourceName ?? 'NOAA nowCOAST · OBSERVED', updateInterval: lightning ? 600_000 : 120_000, init(nextViewer) { viewer = nextViewer; @@ -870,10 +886,10 @@ export function createWeatherLayer({ loading, error: error || rendering?.getDiagnostics().error || null, stale: Boolean(manifest?.stale || observationDelayed()), - ...(source === undefined + ...(source === undefined && sourceName === null ? { source: 'NOAA nowCOAST' } : // Source text is data: the panel shows it untranslated. - { source, sourceVerbatim: true }), + { source: source ?? sourceName, sourceVerbatim: true }), observedAt: shownTime(), }; }, diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index aad1d0973..e1ba553e4 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -2694,3 +2694,40 @@ test('the layer hands rendering a frame source only when its feed is one', () => assert.equal(typeof seen[1].getTile, 'function'); assert.equal(seen[1].getImage, undefined); }); + +test('a feed names the source behind each product, before any manifest does', (t) => { + t.mock.method(console, 'warn', () => {}); + const quiet = { + documentRef: target({ hidden: false }), + eventTarget: target(), + matchMedia: () => target({ matches: false }), + }; + const asked = []; + const feed = { + getSnapshot: async () => snapshot, + sourceName(kind) { + asked.push(kind); + if (kind === 'satellite') throw new Error('no name'); + return kind === 'radar' ? ' NOAA MRMS ' : 'NOAA\u200b GLM'; + }, + }; + const radar = createWeatherLayer({ ...quiet, feed, id: 'weather-radar' }); + assert.equal(radar.source, 'NOAA MRMS'); + const stats = radar.getStats(); + assert.equal(stats.source, 'NOAA MRMS'); + assert.equal(stats.sourceVerbatim, true); + // A name a manifest could not carry, or a failing call, leaves nowCOAST, as + // does a feed with no names. + for (const id of ['weather-lightning', 'weather-satellite']) { + const layer = createWeatherLayer({ ...quiet, feed, id }); + assert.equal(layer.source, 'NOAA nowCOAST · OBSERVED', id); + assert.equal(layer.getStats().source, 'NOAA nowCOAST', id); + assert.equal(layer.getStats().sourceVerbatim, undefined, id); + } + assert.deepEqual(asked, ['radar', 'lightning', 'satellite']); + const unnamed = createWeatherLayer({ + ...quiet, + feed: { getSnapshot: async () => snapshot }, + }); + assert.equal(unnamed.source, 'NOAA nowCOAST · OBSERVED'); +});