diff --git a/changelog.d/1745-timeline-one-control-and-marks.md b/changelog.d/1745-timeline-one-control-and-marks.md index 3915b6087..2585b4760 100644 --- a/changelog.d/1745-timeline-one-control-and-marks.md +++ b/changelog.d/1745-timeline-one-control-and-marks.md @@ -6,11 +6,12 @@ mark, and the handle then tells the mark's label with the time to screen readers and shows it over the track while focused. A layer's world clock coverage may give each mark as `{ time, label, id, kind }` as well as a - bare time (a label over 60 characters, `WORLD_CLOCK_MARK_LABEL_LENGTH`, is - cut, ending in `…`); `worldClock.coverage()` keeps `marks` as the sorted - times and adds `markDetails`, the merged marks themselves. A zoom or a pan clears the - hovered label, and a label is never wider than the track (samehkhamis, - #1745). + bare time. `worldClock.coverage()` keeps `marks` as the sorted times and + adds `markDetails`, the merged marks themselves, merged on their whole + label; a label longer than `WORLD_CLOCK_MARK_LABEL_LENGTH` (60 characters, + exported from `gods-eye-view/time`) is then cut, ending in `…`, never + splitting an emoji. A zoom or a pan clears the hovered label, and a label + is never wider than the track (samehkhamis, #1745). - The timeline marks only events: launches, and earthquakes of magnitude 6 and up while the Earthquakes layer is on (about one every two days worldwide). It no longer draws a tick for every weather frame, which came @@ -36,11 +37,13 @@ first. A weather row's `play` and `latest` params move the world clock as the timeline does: `play` runs at the timeline's chosen speed, from the start when live or at the end, and does nothing while every product the - row shows is suspended. The world clock keeps a chosen rate while paused - and live (`goLive()` no longer sets it back to 1), so any play runs at it. - A weather layer made without a weather clock builds its own when first - enabled, and destroying it leaves the world clock's time where it is - (`createWeatherClock({ shared: false })`). **Breaking:** the catalog's + row shows is suspended or while there is no time to show. The world clock + keeps a chosen rate while paused and live (`goLive()` no longer sets it + back to 1), so any play runs at it, and the timeline's speed shows the + clock's rate whoever set it (the nearest offered speed for another rate). + A weather layer made without a weather clock leaves the world clock alone + until it is first enabled, does nothing once destroyed, and destroying it + leaves the world clock's time where it is. **Breaking:** the catalog's `weatherClock` (`gods-eye-view/application/layers`) no longer has `setTarget`, `step`, `latest`, `play`, `pause` or `togglePlay`, its state no longer has `playing`, and it no longer attaches itself to the world diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 98b964b54..2ba3165f0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -8545,8 +8545,10 @@ that timeline but are not marks on it: the timeline marks only events (launches, and earthquakes of magnitude 6 and up while that layer is on), so its arrow keys step to those, or along the track where there are none. A mark may carry a label (a launch's mission name, cut to 27 characters in capitals as the mission -index shows names; an earthquake's magnitude and place) of up to 60 characters; -a longer one ends in `…`, and a label is never wider than the track. A zoom or +index shows names; an earthquake's magnitude and place). Marks merge on their +whole label; one longer than 60 characters (graphemes, so an emoji is never +split) then shows cut, ending in `…`, and a label is never wider than the +track. A zoom or a pan clears a hovered label. The pointer within 6 px of a tick shows its label over the track, and a tap there goes to the mark's time exactly; a tap elsewhere goes to the time under it and a drag @@ -8579,12 +8581,15 @@ The catalog exposes `weatherClock` and destroys it with its lifetime signal. It has no time controls of its own: `weatherClock.world` is the world clock it follows, where time moves. A weather row's `play`, `latest` and `step` params move that world clock as the timeline does: `play` runs at the speed chosen on -the timeline (the world clock keeps its rate while paused and live), from the -start of the covered time when live or at its end, and does nothing while -every product the row shows is suspended. A weather layer built without a +the timeline (the world clock keeps its rate while paused and live, and the +timeline's speed selector shows the clock's rate, or the nearest offered speed +for a rate a host set), from the start of the covered time when live or at its +end, and does nothing while every product the row shows is suspended or while +there is no covered time. A weather layer built without a shared clock builds one of its own on the page's world clock when first enabled (`createWeatherClock({ shared: false })`) and keeps it for its -lifetime; destroying it leaves the world's time where it is, and only the last +lifetime; destroying it leaves the world's time where it is, and enabling it +after that does nothing, and only the last shared weather clock destroyed sends the world live. Neither has a frame replay of its own. diff --git a/src/annotations/screenAnnotationRenderer.test.mjs b/src/annotations/screenAnnotationRenderer.test.mjs index ce1fb4058..00188c527 100644 --- a/src/annotations/screenAnnotationRenderer.test.mjs +++ b/src/annotations/screenAnnotationRenderer.test.mjs @@ -216,7 +216,7 @@ test('outline upgrade preserves the existing SVG group identity', async (t) => { assert.ok(after === before, 'the rendered group is mutated, not replaced'); assert.deepEqual(sampledAnchors.at(-1), [-97.5, 31.2], 'the existing group projects from the re-seated anchor'); assert.equal(groups().length, 1); - assert.equal(after.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place'); + assert.equal(after.querySelector('.gev-anno-ring') === null, true, 'reticle rings are removed in place'); assert.ok(after.querySelector('.gev-anno-callout') === callout, 'the original callout remains in the group'); assert.ok(after.querySelector('.gev-anno-dot') === dot, 'the anchor dot remains in the group'); assert.ok(svg()); @@ -330,7 +330,7 @@ test('a removed mark fades out, then leaves the board', async (t) => { assert.equal(group.isConnected, false); renderer.destroy(); await settle(); - assert.equal(document.querySelector('.gev-screen-whiteboard'), null); + assert.equal(document.querySelector('.gev-screen-whiteboard') === null, true); }); // ── Partial-add unwind (review round 2) ─────────────────────────────────────── diff --git a/src/cockpitSignalFocus.test.mjs b/src/cockpitSignalFocus.test.mjs index 89ee1cac1..009ebfbf9 100644 --- a/src/cockpitSignalFocus.test.mjs +++ b/src/cockpitSignalFocus.test.mjs @@ -209,7 +209,7 @@ test('rows render the signals the controller presents', async (t) => { ); assert.equal(status.className, 'warning'); assert.equal(status.querySelector('div > strong').textContent, 'UNKNOWN'); - assert.equal(status.querySelector('button'), null); + assert.equal(status.querySelector('button') === null, true); }); test('actual Context refresh retains the focused signal button across unchanged and changed details', async (t) => { @@ -315,7 +315,7 @@ test('removing the focused identity uses the shown briefing tab once and permits assert.equal(document.activeElement === f.signalTab(), true); assert.equal(f.focusCalls(), calls + 1); assert.equal(f.losses(), 0); - assert.equal(f.signalTab().closest('[hidden]'), null); + assert.equal(f.signalTab().closest('[hidden]') === null, true); // Traversal carries on forward from the footer tab. f.tab(); assert.equal(document.activeElement === f.pageTab(1), true); diff --git a/src/firstRunLauncherView.test.mjs b/src/firstRunLauncherView.test.mjs index 633d9de30..16c76e1d9 100644 --- a/src/firstRunLauncherView.test.mjs +++ b/src/firstRunLauncherView.test.mjs @@ -165,7 +165,7 @@ test('the DISMISS button closes the card the way ESC does', async (t) => { assert.notEqual(sessionStorageRef.read(), null, 'dismissed for the session'); root.dispatchEvent(new Event('transitionend')); await nextTick(); - assert.equal(document.getElementById('first-run-launcher'), null); + assert.equal(document.getElementById('first-run-launcher') === null, true); }); test('the scroll fade clears when the tiles fit after a resize', async (t) => { @@ -191,5 +191,5 @@ test('ESC closes the card, returns focus, and removes it without a transition', // Reduced motion: no transitionend, so the bounded fallback removes it. t.mock.timers.tick(400); await nextTick(); - assert.equal(document.getElementById('first-run-launcher'), null); + assert.equal(document.getElementById('first-run-launcher') === null, true); }); diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 6f5dfcda2..ec6fea7f0 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -58,8 +58,8 @@ const utc = (value) => /** * Move `world`, the world clock, as its timeline does: `play` toggles play at * the world clock's rate (the speed the timeline chose), from the start of the - * covered time when live or at its end, unless `canPlay` is false; `latest` - * goes live. `step` (-1 or 1) goes to the previous or next of `frames`, the + * covered time when live or at its end, unless `canPlay` is false or there + * is no covered time (no timeline to play); `latest` goes live. `step` (-1 or 1) goes to the previous or next of `frames`, the * weather frames; the timeline does not mark them, so this is the row's own * way to step through them. */ @@ -68,13 +68,16 @@ function moveWorldTime(world, frames, { play, latest, step }, canPlay) { if (play === true) { const state = world.getState(); if (state.playing) world.pause(); - else if (canPlay) { - const covered = world.coverage(); - // The covered time reaches to now, as the timeline's track does. - const end = covered && Math.max(covered.end, world.wallNow()); - if (covered && (state.live || state.timeMs >= end)) - world.seek(Math.min(covered.start, state.timeMs)); - world.play({ continuous: true }); + else { + // With no time to show there is no timeline, and nothing to play. + const covered = canPlay && state.supported ? world.coverage() : null; + if (covered) { + // The covered time reaches to now, as the timeline's track does. + const end = Math.max(covered.end, world.wallNow()); + if (state.live || state.timeMs >= end) + world.seek(Math.min(covered.start, state.timeMs)); + world.play({ continuous: true }); + } } } if (latest === true) world.goLive(); @@ -153,6 +156,7 @@ export function createWeatherLayer({ manifest = null, request = null, listener = null; + let destroyed = false; let enabled = false, loading = false; let generation = 0, @@ -366,6 +370,8 @@ export function createWeatherLayer({ notify(); }, enable() { + // Destroyed, the layer stays off: no clock is built for it again. + if (destroyed) return; enabled = true; ensureClock(); if (!removeCamera) { @@ -870,6 +876,7 @@ export function createWeatherLayer({ }; }, destroy() { + destroyed = true; layer.disable(); unsubscribeClock?.(); unsubscribeClock = null; diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 6d83a8161..309b51165 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -660,6 +660,31 @@ test('a layer without a clock leaves the world clock alone until enabled, and ne assert.equal(worldClock.now(), past); }); +test('destroyed, a layer without a clock stays off: enabling it builds no clock', (t) => { + const subscribe = t.mock.method(worldClock, 'subscribe'); + const h = layerHarness(); + assert.equal(subscribe.mock.callCount(), 1, 'its clock, built on enable'); + h.layer.destroy(); + harnessed.delete(h.layer); + h.layer.enable(); + assert.equal(subscribe.mock.callCount(), 1, 'no clock built after destroy'); + assert.equal(worldClock.follows('weather-radar'), false); + h.layer.setParams({ play: true }); + assert.equal(worldClock.isPlaying(), false); +}); + +test('with no frames yet there is no timeline, and a row plays nothing', () => { + worldClock.setRate(604_800); + const h = layerHarness({ + feed: { getSnapshot: () => new Promise(() => {}) }, + }); + void h.layer.update(); + assert.equal(worldClock.coverage(), null); + h.layer.setParams({ play: true }); + assert.equal(worldClock.isLive(), true); + assert.equal(worldClock.isPlaying(), false); +}); + test('a row plays at the speed the world clock keeps, from the start at the end of the covered time', async () => { const h = layerHarness(); const update = h.layer.update(); diff --git a/src/scenes/dataPacks/presentationView.test.mjs b/src/scenes/dataPacks/presentationView.test.mjs index c6c173d89..73b38877a 100644 --- a/src/scenes/dataPacks/presentationView.test.mjs +++ b/src/scenes/dataPacks/presentationView.test.mjs @@ -66,7 +66,11 @@ test('a loaded pack shows its attribution card in the viewer container', async ( card.querySelector('div').textContent, 'clip · Synthetic · CC0-1.0', ); - assert.equal(card.querySelector('a'), null, 'no link without a source URL'); + assert.equal( + card.querySelector('a') === null, + true, + 'no link without a source URL', + ); const video = card.querySelector('video'); assert.equal(video.controls, true); assert.equal(video.preload, 'none'); diff --git a/src/time/worldClock.js b/src/time/worldClock.js index 17795ce1b..9beff2d93 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -33,9 +33,37 @@ */ const MARK_TEXT = ['label', 'id', 'kind']; -/** The longest label a mark carries, ellipsis and all. */ +/** + * The longest label a mark carries, ellipsis and all, in characters as a + * reader sees them (graphemes where the runtime can tell them, otherwise code + * points), so an emoji or an accented letter is never split. + */ export const WORLD_CLOCK_MARK_LABEL_LENGTH = 60; +const graphemes = + typeof Intl?.Segmenter === 'function' + ? (() => { + const segmenter = new Intl.Segmenter(undefined, { + granularity: 'grapheme', + }); + return (text) => + Array.from(segmenter.segment(text), ({ segment }) => segment); + })() + : (text) => Array.from(text); + +/** `mark` with its label cut to WORLD_CLOCK_MARK_LABEL_LENGTH, ending in `…`. */ +function shownMark(mark) { + if (!mark.label || mark.label.length <= WORLD_CLOCK_MARK_LABEL_LENGTH) + return mark; + const parts = graphemes(mark.label); + if (parts.length <= WORLD_CLOCK_MARK_LABEL_LENGTH) return mark; + const label = `${parts + .slice(0, WORLD_CLOCK_MARK_LABEL_LENGTH - 1) + .join('') + .trimEnd()}…`; + return Object.freeze({ ...mark, label }); +} + /** * A mark (a number, or `{time, label?, id?, kind?}`) in its one shape, or * null for one with no finite time. @@ -53,10 +81,6 @@ function readMark(input) { const value = input[key]; if (typeof value === 'string' && value.trim()) mark[key] = value.trim(); } - if (mark.label?.length > WORLD_CLOCK_MARK_LABEL_LENGTH) - mark.label = `${mark.label - .slice(0, WORLD_CLOCK_MARK_LABEL_LENGTH - 1) - .trimEnd()}…`; return Object.freeze(mark); } @@ -484,9 +508,12 @@ export function createWorldClock({ const told = new Set(); for (const mark of details.values()) if (Object.keys(mark).length > 1) told.add(mark.time); + // Marks merge on their whole label, and are cut for showing after, so + // two whose labels only differ past the cut stay two. const markDetails = [...details.values()] .filter((mark) => Object.keys(mark).length > 1 || !told.has(mark.time)) - .sort(byMark); + .sort(byMark) + .map(shownMark); const marks = [...new Set(markDetails.map((mark) => mark.time))]; return { start, end, marks, markDetails }; }, diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs index ffa0142d7..8c0cbe5b8 100644 --- a/src/time/worldClock.test.mjs +++ b/src/time/worldClock.test.mjs @@ -489,3 +489,48 @@ test('a mark label longer than the timeline shows is cut, ending in an ellipsis' assert.ok(cut.label.length <= WORLD_CLOCK_MARK_LABEL_LENGTH); assert.equal(whole.label, 'B'.repeat(WORLD_CLOCK_MARK_LABEL_LENGTH)); }); + +test('a cut label never splits an emoji, and counts it as one character', () => { + const clock = createWorldClock(fakeTime().options); + const a = 'A'.repeat(58); + clock.register({ + id: 'emoji', + coverage: () => ({ + start: 1, + end: 3, + marks: [ + { time: 1, label: `${a}🚀 LIFTOFF` }, + { time: 2, label: `${a}👩‍🚀 CREW` }, + // Sixty characters, though more UTF-16 units: whole. + { time: 3, label: `${'A'.repeat(59)}🚀` }, + ], + }), + }); + const [rocket, crew, whole] = clock.coverage().markDetails; + assert.equal(rocket.label, `${a}🚀…`); + assert.equal(crew.label, `${a}👩‍🚀…`); + assert.equal(whole.label, `${'A'.repeat(59)}🚀`); + for (const { label } of [rocket, crew, whole]) + assert.equal(label.isWellFormed(), true, 'no lone surrogate'); +}); + +test('marks merge on their whole label, so labels alike up to the cut stay two', () => { + const clock = createWorldClock(fakeTime().options); + const stem = 'C'.repeat(WORLD_CLOCK_MARK_LABEL_LENGTH - 1); + for (const id of ['one', 'two']) + clock.register({ + id, + coverage: () => ({ + start: 5, + end: 5, + marks: [{ time: 5, label: `${stem}${id === 'one' ? ' FIRST' : ' SECOND'}` }], + }), + }); + const covered = clock.coverage(); + assert.deepEqual(covered.marks, [5]); + assert.equal(covered.markDetails.length, 2); + for (const mark of covered.markDetails) { + assert.equal(mark.label, `${stem}…`); + assert.equal(Object.isFrozen(mark), true); + } +}); diff --git a/src/ui/awarenessPanel.test.mjs b/src/ui/awarenessPanel.test.mjs index 53cf0ad94..f68fb8515 100644 --- a/src/ui/awarenessPanel.test.mjs +++ b/src/ui/awarenessPanel.test.mjs @@ -280,7 +280,7 @@ test('a tracked flight’s photo shows under its name, credited and linked to it test('no photo renders without one', async (t) => { const { panel, publish } = fixture(t); await publish({ ...results(), photo: null }); - assert.equal(panel().querySelector('.military-awareness-photo'), null); + assert.equal(panel().querySelector('.military-awareness-photo') === null, true); }); test('a photo whose image fails to load is hidden until another arrives', async (t) => { @@ -290,10 +290,10 @@ test('a photo whose image fails to load is hidden until another arrives', async .querySelector('.military-awareness-photo img') .dispatchEvent(new Event('error')); await nextTick(); - assert.equal(panel().querySelector('.military-awareness-photo'), null); + assert.equal(panel().querySelector('.military-awareness-photo') === null, true); // A repaint with the same photo keeps it hidden. await publish({ ...results({ previousDisabled: false }), photo: photo() }); - assert.equal(panel().querySelector('.military-awareness-photo'), null); + assert.equal(panel().querySelector('.military-awareness-photo') === null, true); // Another plane's photo shows. const next = photo({ src: 'https://t.plnspttrs.net/2/def456_280.jpg' }); await publish({ ...results(), photo: next }); diff --git a/src/ui/cockpitBridge.test.mjs b/src/ui/cockpitBridge.test.mjs index b58e53684..e6c038d7d 100644 --- a/src/ui/cockpitBridge.test.mjs +++ b/src/ui/cockpitBridge.test.mjs @@ -839,7 +839,7 @@ test('arrows do nothing new in an aircraft cockpit', async (t) => { near(after.pitchDeg, before.pitchDeg, 'the aircraft pitch', 1e-6); assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 }); assert.equal(store.instruments.look.offset, false); - assert.equal(document.querySelector('.cockpit-look'), null); + assert.equal(document.querySelector('.cockpit-look') === null, true); // The tape stays on the aircraft's heading, with no bow or stern marks. const centre = store.instruments.compass.find( (division) => division.slot === 0, @@ -849,7 +849,7 @@ test('arrows do nothing new in an aircraft cockpit', async (t) => { prograde: null, retrograde: null, }); - assert.equal(document.querySelector('.cockpit-compass-marker'), null); + assert.equal(document.querySelector('.cockpit-compass-marker') === null, true); assert.doesNotMatch( document.querySelector('.cockpit-help').textContent, /ARROWS LOOK/, @@ -1248,7 +1248,7 @@ test('a ship with neither heading nor course makes up no direction', async (t) = prograde: null, retrograde: null, }); - assert.equal(document.querySelector('.cockpit-compass-marker'), null); + assert.equal(document.querySelector('.cockpit-compass-marker') === null, true); assert.match( $('.cockpit-visor-status-right').textContent, /NO HEADING · AIS/, diff --git a/src/ui/cockpitOrbital.test.mjs b/src/ui/cockpitOrbital.test.mjs index 987527c28..7b3ad959e 100644 --- a/src/ui/cockpitOrbital.test.mjs +++ b/src/ui/cockpitOrbital.test.mjs @@ -45,7 +45,7 @@ function fixture(t) { test('the orbital HUD is hidden until a satellite view is published', async (t) => { const f = fixture(t); assert.equal(f.section().hidden, true); - assert.equal(f.section().querySelector('#cockpit-orbital-zoom-in'), null); + assert.equal(f.section().querySelector('#cockpit-orbital-zoom-in') === null, true); }); test('a published orbital view renders its readouts and zoom controls', async (t) => { @@ -97,7 +97,7 @@ test('an unknown velocity leaves its row out', async (t) => { const f = fixture(t); f.store.publishOrbital({ ...VIEW, velocityMps: null }); await nextTick(); - assert.equal(f.section().querySelector('.cockpit-orbital-velocity'), null); + assert.equal(f.section().querySelector('.cockpit-orbital-velocity') === null, true); assert.equal(f.text('.cockpit-orbital-altitude'), '418 KM'); }); @@ -145,7 +145,7 @@ test('publishing null hides the orbital HUD again', async (t) => { f.store.publishOrbital(null); await nextTick(); assert.equal(f.section().hidden, true); - assert.equal(f.section().querySelector('.cockpit-orbital-name'), null); + assert.equal(f.section().querySelector('.cockpit-orbital-name') === null, true); }); const ISS_TLE = Object.freeze({ @@ -160,7 +160,7 @@ test('the place below shows at the top once known', async (t) => { const f = fixture(t); f.store.publishOrbital({ ...VIEW, place: undefined }); await nextTick(); - assert.equal(f.section().querySelector('#cockpit-orbital-over'), null); + assert.equal(f.section().querySelector('#cockpit-orbital-over') === null, true); const over = () => f.section().querySelector('#cockpit-orbital-over strong')?.textContent; f.store.publishOrbital({ @@ -195,7 +195,7 @@ test('the place below shows at the top once known', async (t) => { // says nothing rather than call land open ocean. f.store.publishOrbital({ ...VIEW, place: null }); await nextTick(); - assert.equal(document.querySelector('#cockpit-orbital-over'), null); + assert.equal(document.querySelector('#cockpit-orbital-over') === null, true); }); test('an owner country shows its flag from the sheet, or its code if the sheet will not load', async (t) => { @@ -216,7 +216,7 @@ test('an owner country shows its flag from the sheet, or its code if the sheet w owner: { countryCode: 'US', label: 'USA' }, }); await nextTick(); - assert.equal(f.section().querySelector('.cockpit-orbital-flag'), null); + assert.equal(f.section().querySelector('.cockpit-orbital-flag') === null, true); assert.equal(images.length, 1, 'the sheet is asked for'); images[0].onerror(); await settle(); @@ -244,7 +244,7 @@ test('an owner country shows its flag from the sheet, or its code if the sheet w flag.style.backgroundPosition, ownerFlagStyle('FR').backgroundPosition, ); - assert.equal(f.section().querySelector('.cockpit-orbital-owner-badge'), null); + assert.equal(f.section().querySelector('.cockpit-orbital-owner-badge') === null, true); }); test('the readout names the owner, operator and element set', async (t) => { @@ -327,7 +327,7 @@ test('unknown identity fields leave their rows out', async (t) => { '.cockpit-orbital-ident', '#cockpit-orbital-tle', ]) - assert.equal(f.section().querySelector(selector), null, selector); + assert.equal(f.section().querySelector(selector) === null, true, selector); // An element set with no NORAD id still dates itself. f.store.publishOrbital({ ...VIEW, tle: ISS_TLE }); await nextTick(); diff --git a/src/ui/commandDock.test.mjs b/src/ui/commandDock.test.mjs index c70007a17..733c34425 100644 --- a/src/ui/commandDock.test.mjs +++ b/src/ui/commandDock.test.mjs @@ -142,7 +142,7 @@ test('the map source row renders the published chips and selects through the con assert.equal(osm.classList.contains('active'), true); assert.equal(osm.getAttribute('aria-pressed'), 'true'); assert.equal(osm.hasAttribute('aria-label'), false); - assert.equal(esri.querySelector('.map-stack-chip-req'), null); + assert.equal(esri.querySelector('.map-stack-chip-req') === null, true); bing.click(); esri.click(); assert.deepEqual(selected, ['esri-imagery']); diff --git a/src/ui/layerFinder.test.mjs b/src/ui/layerFinder.test.mjs index 8cd61ff1a..b93821cb3 100644 --- a/src/ui/layerFinder.test.mjs +++ b/src/ui/layerFinder.test.mjs @@ -146,7 +146,7 @@ test('Escape in the search field closes it without reaching the panel', async (t ); await flush(); assert.equal(reached, false); - assert.equal(document.getElementById('layer-finder-input'), null); + assert.equal(document.getElementById('layer-finder-input') === null, true); assert.equal(document.activeElement?.id, 'layer-finder-search-btn'); }); @@ -167,7 +167,7 @@ test('the filter menu takes focus, moves with the arrows and closes on Escape', new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), ); await flush(); - assert.equal(document.getElementById('layer-finder-menu'), null); + assert.equal(document.getElementById('layer-finder-menu') === null, true); assert.equal( document.activeElement, document.querySelector('[aria-controls="layer-finder-menu"]'), diff --git a/src/ui/localePickerView.test.mjs b/src/ui/localePickerView.test.mjs index a047178da..430cdd5b2 100644 --- a/src/ui/localePickerView.test.mjs +++ b/src/ui/localePickerView.test.mjs @@ -340,12 +340,12 @@ test('the provider settings corner shows the picker only while the shell publish app.mount(host); t.after(() => app.unmount()); const store = useLocaleStore(pinia); - assert.equal(document.getElementById('locale-picker'), null); + assert.equal(document.getElementById('locale-picker') === null, true); store.publishPicker({ current: 'de', onSelect() {} }); await nextTick(); const code = document.querySelector('#corner-actions .locale-picker-code'); assert.equal(code?.textContent, 'DE'); store.publishPicker(null); await nextTick(); - assert.equal(document.getElementById('locale-picker'), null); + assert.equal(document.getElementById('locale-picker') === null, true); }); diff --git a/src/ui/newsPanel.test.mjs b/src/ui/newsPanel.test.mjs index 1bf4ee7b7..d0e50346c 100644 --- a/src/ui/newsPanel.test.mjs +++ b/src/ui/newsPanel.test.mjs @@ -156,7 +156,7 @@ test('the panel shows the last 24 h as event cards and opens once', () => { assert.equal(img.getAttribute('referrerpolicy'), 'no-referrer'); assert.equal(first.querySelector('.news-card__credit').textContent, 'A'); // A lone report has no picture. - assert.equal(second.querySelector('.news-card__thumb'), null); + assert.equal(second.querySelector('.news-card__thumb') === null, true); // A later update does not reopen a panel the reader collapsed. panel.classList.add('collapsed'); news.update(view()); @@ -192,8 +192,8 @@ test('a card click flies there; open, it links the source and closes', () => { test('filters, the Local section and empty or failed reads', () => { const { news, panel, calls } = fixture(); news.update(view()); - assert.equal(panel.querySelector('[aria-label="Local"]'), null); - assert.equal(panel.querySelector('.news-toggle'), null); + assert.equal(panel.querySelector('[aria-label="Local"]') === null, true); + assert.equal(panel.querySelector('.news-toggle') === null, true); const severity = panel.querySelector('select[aria-label="Severity"]'); assert.equal(severity.value, '2'); severity.value = '3'; @@ -214,7 +214,7 @@ test('filters, the Local section and empty or failed reads', () => { panel.textContent, /No news matches these filters in the last 24 h/, ); - assert.equal(panel.querySelector('.news-readout__footer button'), null); + assert.equal(panel.querySelector('.news-readout__footer button') === null, true); // Local: no hotspot chips or severity floor, and its own empty states. const localView = (inView) => @@ -225,9 +225,9 @@ test('filters, the Local section and empty or failed reads', () => { news.update(localView({ active: false })); const section = panel.querySelector('[aria-label="Local"]'); assert.match(section.textContent, /Zoom in to see local news/); - assert.equal(panel.querySelector('[data-chip-id="ua-ru-war"]'), null); - assert.equal(panel.querySelector('select[aria-label="Severity"]'), null); - assert.equal(panel.querySelector('[aria-label="Latest news"]'), null); + assert.equal(panel.querySelector('[data-chip-id="ua-ru-war"]') === null, true); + assert.equal(panel.querySelector('select[aria-label="Severity"]') === null, true); + assert.equal(panel.querySelector('[aria-label="Latest news"]') === null, true); assert.equal(document.getElementById('news-panel-count').textContent, '0'); news.update( localView({ active: true, items: [feedCardView(report('near'), NOW)] }), @@ -320,7 +320,7 @@ test('a card credits the service its report came through', () => { credited.querySelector('.news-card__via').textContent, 'via GDELT Project', ); - assert.equal(own.querySelector('.news-card__via'), null); + assert.equal(own.querySelector('.news-card__via') === null, true); news.destroy(); }); @@ -370,7 +370,7 @@ test('scrolling to the end asks once per page, again after a filter change or a test('the window choice offers shorter windows; the longest is the server window', () => { const { news, panel, calls } = fixture(); news.update(view({ windowHours: 12, windowChoices: [12, 6, 3, 1] })); - assert.equal(panel.querySelector('.news-readout__window'), null); + assert.equal(panel.querySelector('.news-readout__window') === null, true); const choice = panel.querySelector('select[aria-label="Time window"]'); assert.deepEqual( [...choice.options].map((option) => option.textContent), @@ -410,7 +410,7 @@ test('a clip waits behind its picture and plays, on request, in the open card', news.update(view({ items: [feedCardView(clipped, NOW)] })); const card = panel.querySelector('.news-card'); assert.equal(card.querySelector('.news-card__play').textContent, '▶'); - assert.equal(card.querySelector('video'), null); + assert.equal(card.querySelector('video') === null, true); news.update( view({ items: [feedCardView(clipped, NOW)], @@ -436,6 +436,6 @@ test('a clip waits behind its picture and plays, on request, in the open card', selected: feedCardView(clipped, NOW), }), ); - assert.equal(panel.querySelector('.news-card.is-open video'), null); + assert.equal(panel.querySelector('.news-card.is-open video') === null, true); news.destroy(); }); diff --git a/src/ui/panelChromeButtons.test.mjs b/src/ui/panelChromeButtons.test.mjs index ae3a212c0..75678f8e6 100644 --- a/src/ui/panelChromeButtons.test.mjs +++ b/src/ui/panelChromeButtons.test.mjs @@ -166,8 +166,8 @@ test('UiPanel without collapsible has no button, and glow is optional', (t) => { }); const panel = document.getElementById('plain-panel'); assert.equal(panel.tagName, 'SECTION'); - assert.equal(panel.querySelector('.panel-glow'), null); - assert.equal(panel.querySelector('.panel-collapse-btn'), null); + assert.equal(panel.querySelector('.panel-glow') === null, true); + assert.equal(panel.querySelector('.panel-collapse-btn') === null, true); }); test('RailPanel follows the store, forwards clicks, and never restores a class its owner removed', async (t) => { diff --git a/src/ui/providerSettings.test.mjs b/src/ui/providerSettings.test.mjs index 117a63390..b0c8641ec 100644 --- a/src/ui/providerSettings.test.mjs +++ b/src/ui/providerSettings.test.mjs @@ -135,8 +135,8 @@ test('the chip counts what is missing and the rows render from the status', asyn ); assert.equal(field.type, 'password'); assert.equal(field.placeholder, 'paste GOOGLE_MAPS_API_KEY'); - assert.equal(google.querySelector('.key-setup-remove'), null); - assert.equal(google.querySelector('.key-setup-cloud-auth'), null); + assert.equal(google.querySelector('.key-setup-remove') === null, true); + assert.equal(google.querySelector('.key-setup-cloud-auth') === null, true); const openai = $('[data-key-id="openai"]'); assert.equal(openai.dataset.managed, 'file'); @@ -159,7 +159,7 @@ test('the chip counts what is missing and the rows render from the status', asyn firms.querySelector('.key-setup-external').textContent, 'configured externally', ); - assert.equal(firms.querySelector('.key-setup-fields'), null); + assert.equal(firms.querySelector('.key-setup-fields') === null, true); }); test('the chip opens the dialog onto its first field; closing fades it and restores the status line', async (t) => { @@ -337,12 +337,12 @@ test('Cyber moves the chip into the top toolbar and other themes return it to th await wait(); await nextTick(); assert.equal(toolbar.lastElementChild?.id, 'key-setup-chip'); - assert.equal(corner.querySelector('#key-setup-chip'), null); + assert.equal(corner.querySelector('#key-setup-chip') === null, true); document.documentElement.dataset.uiTheme = 'tactical'; await wait(); await nextTick(); assert.equal(corner.firstElementChild?.id, 'key-setup-chip'); - assert.equal(toolbar.querySelector('#key-setup-chip'), null); + assert.equal(toolbar.querySelector('#key-setup-chip') === null, true); delete document.documentElement.dataset.uiTheme; }); diff --git a/src/ui/quebecCctvOverlay.test.mjs b/src/ui/quebecCctvOverlay.test.mjs index 3dd715fe4..2ce76a9de 100644 --- a/src/ui/quebecCctvOverlay.test.mjs +++ b/src/ui/quebecCctvOverlay.test.mjs @@ -188,5 +188,5 @@ test('the refresh timer reloads with a cache-buster, skips hidden tabs, and stop ui.overlay.dispose(); assert.equal(ui.intervals.size, 0); assert.equal(ui.renderListeners.size, 0); - assert.equal(ui.window.document.querySelector('.qci-panel'), null); + assert.equal(ui.window.document.querySelector('.qci-panel') === null, true); }); diff --git a/src/ui/renderFailureView.test.mjs b/src/ui/renderFailureView.test.mjs index 89213bffa..1c54ac56d 100644 --- a/src/ui/renderFailureView.test.mjs +++ b/src/ui/renderFailureView.test.mjs @@ -72,7 +72,7 @@ test('a lost context offers Reload, and a panel that cannot reload offers nothin document.getElementById('render-failure-message').textContent, /3D graphics/, ); - assert.equal(notice().querySelector('.render-failure-detail'), null); + assert.equal(notice().querySelector('.render-failure-detail') === null, true); assert.deepEqual(buttons(), ['Reload', 'Reset view']); store.publishRenderFailure({ reason: 'context-lost', @@ -82,7 +82,7 @@ test('a lost context offers Reload, and a panel that cannot reload offers nothin }); await nextTick(); assert.deepEqual(buttons(), []); - assert.equal(notice().querySelector('.render-failure-hint'), null); + assert.equal(notice().querySelector('.render-failure-hint') === null, true); }); const failure = () => ({ diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index 0a8a335f5..57d7da55a 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -96,13 +96,20 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { span: spanOf(state), }); const time = shallowRef(read()); - // The speed the world clock keeps, where it is one of those offered. - const speed = shallowRef( - Object.keys(WORLD_TIME_SPEEDS).find( - (key) => WORLD_TIME_SPEEDS[key] === worldClock.getRate(), - ) ?? 'real', - ); - const rate = () => WORLD_TIME_SPEEDS[speed.value]; + // The speed shown follows the world clock's rate, whoever set it: a rate + // not among those offered shows as the nearest that is (by ratio), while + // play still runs at the clock's own rate. + const speedFor = (clockRate) => { + let best = 'real'; + for (const key of Object.keys(WORLD_TIME_SPEEDS)) + if ( + Math.abs(Math.log(WORLD_TIME_SPEEDS[key] / clockRate)) < + Math.abs(Math.log(WORLD_TIME_SPEEDS[best] / clockRate)) + ) + best = key; + return best; + }; + const speed = shallowRef(speedFor(worldClock.getRate())); let ticker = null; const syncTicker = () => { // Playing, the clock tells of the time itself. @@ -117,6 +124,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { } }; const unsubscribe = worldClock.subscribe((state) => { + if (speed.value !== speedFor(state.rate)) + speed.value = speedFor(state.rate); if (stopAtEnd(state)) return; time.value = read(state); syncTicker(); @@ -131,7 +140,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { return { time, /** - * Play runs the track at the speed chosen, from the start when it is at + * Play runs the track at the world clock's rate (the speed chosen, which + * sets it), from the start when it is at * its end (at live, say), as a video plays again from the top. It runs * the clock itself: weather follows the time frame by frame like every * other layer the track covers. @@ -142,7 +152,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { const covered = reach(); if (span && covered && (live || timeMs >= covered.end)) worldClock.seek(span.start); - worldClock.play({ rate: rate(), continuous: true }); + // At the clock's rate: the speed chosen, or a host's own. + worldClock.play({ continuous: true }); }, speed, /** @@ -154,10 +165,12 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { setSpeed(next) { if (!Object.hasOwn(WORLD_TIME_SPEEDS, next)) return; speed.value = next; + // The clock's rate first: the speed shown follows it from here on. + worldClock.setRate(WORLD_TIME_SPEEDS[next]); const { playing, stepping } = worldClock.getState(); - if (!playing || !stepping) return worldClock.setRate(rate()); + if (!playing || !stepping) return; worldClock.pause(); - worldClock.play({ rate: rate(), continuous: true }); + worldClock.play({ continuous: true }); }, goLive: () => worldClock.goLive(), /** diff --git a/src/ui/views/mountApplicationViews.test.mjs b/src/ui/views/mountApplicationViews.test.mjs index a93e4ba14..ba472f9c0 100644 --- a/src/ui/views/mountApplicationViews.test.mjs +++ b/src/ui/views/mountApplicationViews.test.mjs @@ -25,7 +25,7 @@ test('omitted views lose their hosts and the rest render', () => { page(['welcome', 'provider-settings']); mountApplicationViews(document, { omit: ['provider-settings'] }); assert.deepEqual(hostNames(), ['welcome']); - assert.equal(document.getElementById('key-setup-chip'), null); + assert.equal(document.getElementById('key-setup-chip') === null, true); assert.ok( document.querySelector('[data-gev-view="welcome"]').children.length, ); @@ -37,7 +37,7 @@ test('a view an earlier call rendered is unmounted when a later call omits it', assert.ok(document.getElementById('key-setup-chip')); mountApplicationViews(document, { omit: ['provider-settings'] }); assert.deepEqual(hostNames(), ['welcome']); - assert.equal(document.getElementById('key-setup-chip'), null); + assert.equal(document.getElementById('key-setup-chip') === null, true); }); test('unknown view names are refused, in the page or in the omit list', () => { diff --git a/src/ui/views/panels/DrawMarkList.test.mjs b/src/ui/views/panels/DrawMarkList.test.mjs index 9eaf952db..b30f643b3 100644 --- a/src/ui/views/panels/DrawMarkList.test.mjs +++ b/src/ui/views/panels/DrawMarkList.test.mjs @@ -77,7 +77,7 @@ test('an application\'s row actions sit before fly to, labelled, and act on thei const note = row('a1').querySelector('.draw-mark-note'); assert.equal(note.getAttribute('role'), 'status'); assert.equal(note.textContent, ''); - assert.equal(row('a2').querySelector('.draw-mark-note'), null, 'a row without actions has no note'); + assert.equal(row('a2').querySelector('.draw-mark-note') === null, true, 'a row without actions has no note'); const line = { ...LINE, path: [[0, 0], [1, 0]] }; store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks([line], drawMarkActions([tag]), new Map([['a1', { busy: true, text: '' }]])) }); await nextTick(); diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 4588a71d3..f496d6d86 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -19,6 +19,8 @@ function mount(t) { // The store outlives the view; dispose it with its clock subscription. useWorldTimeStore(pinia).$dispose(); worldClock.goLive(); + // The clock keeps its rate; the next test starts at real time. + worldClock.setRate(1); }); return () => document.getElementById('world-timeline'); } @@ -79,16 +81,16 @@ test('the timeline hides while no layer with time to show is on', async (t) => { const bar = mount(t); const at = Date.UTC(2026, 9, 30, 6, 15, 42); await nextTick(); - assert.equal(bar(), null, 'live, nothing to play'); + assert.equal(bar() === null, true, 'live, nothing to play'); worldClock.seek(at); await nextTick(); - assert.equal(bar(), null, 'away from live, but nothing to play'); + assert.equal(bar() === null, true, 'away from live, but nothing to play'); const remove = supportingLayer(t, at); await nextTick(); assert.ok(bar()); remove(); await nextTick(); - assert.equal(bar(), null, 'the layer went off'); + assert.equal(bar() === null, true, 'the layer went off'); }); test('the track runs over the time the layers show, to now, and scrubs', async (t) => { @@ -465,7 +467,7 @@ test('play held for a layer to load shows a spinner once the hold lasts', async // A clock tick (a quarter second) tells of the hold; the spinner waits on. await new Promise((resolve) => setTimeout(resolve, 250)); await nextTick(); - assert.equal(bubble().querySelector('.world-timeline-spinner'), null, 'not for a quick frame'); + assert.equal(bubble().querySelector('.world-timeline-spinner') === null, true, 'not for a quick frame'); // The clock tells of the hold on its next tick, and the bar shows it after. const until = async (shown) => { for (let waited = 0; waited < 3_000; waited += 50) { @@ -615,7 +617,7 @@ test('a tick shows its label on hover, and a tap by it goes to its mark', async track.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId })); const tip = () => bar().querySelector('.world-timeline-mark-label'); assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 3, 'ticks stay ticks'); - assert.equal(tip(), null); + assert.equal(tip()?.textContent ?? null, null); // Hovering within 6 px of a tick shows its label, in place of the track's hint. press('pointermove', 205); @@ -628,15 +630,15 @@ test('a tick shows its label on hover, and a tap by it goes to its mark', async // Further off, or on a tick with no label, nothing shows. press('pointermove', 690); await nextTick(); - assert.equal(tip(), null); + assert.equal(tip()?.textContent ?? null, null); assert.equal(track.hasAttribute('title'), true); press('pointermove', 2); await nextTick(); - assert.equal(tip(), null, 'an unlabelled mark has no label'); + assert.equal(tip()?.textContent ?? null, null, 'an unlabelled mark has no label'); press('pointermove', 205); track.dispatchEvent(new PointerEvent('pointerleave', { clientX: 1_100, pointerId: 1 })); await nextTick(); - assert.equal(tip(), null, 'leaving the track hides it'); + assert.equal(tip()?.textContent ?? null, null, 'leaving the track hides it'); // A tap by a tick goes to its mark exactly; elsewhere, to the time there. press('pointerdown', 704); @@ -667,7 +669,7 @@ test('a tick shows its label on hover, and a tap by it goes to its mark', async assert.equal(tip()?.textContent, 'GAUNTLET PAYLOAD'); input.dispatchEvent(new Event('blur')); await nextTick(); - assert.equal(tip(), null); + assert.equal(tip()?.textContent ?? null, null); }); test('the pointer shows the nearer of two ticks within 6 px, and a zoom or a pan clears it', async (t) => { @@ -717,7 +719,7 @@ test('the pointer shows the nearer of two ticks within 6 px, and a zoom or a pan track.dispatchEvent(new WheelEvent('wheel', { deltaY: -200, cancelable: true })); await nextTick(); assert.equal(track.classList.contains('is-zoomed'), true); - assert.equal(tip(), null, 'a zoom clears the label'); + assert.equal(tip()?.textContent ?? null, null, 'a zoom clears the label'); // So does a pan, by wheel or by drag. pointer('pointermove', firstAt()); await nextTick(); @@ -726,7 +728,7 @@ test('the pointer shows the nearer of two ticks within 6 px, and a zoom or a pan new WheelEvent('wheel', { deltaY: 40, shiftKey: true, cancelable: true }), ); await nextTick(); - assert.equal(tip(), null, 'a pan clears the label'); + assert.equal(tip()?.textContent ?? null, null, 'a pan clears the label'); // A tap between them goes to the nearer mark. track.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); @@ -737,3 +739,38 @@ test('the pointer shows the nearer of two ticks within 6 px, and a zoom or a pan await nextTick(); assert.equal(worldClock.now(), second, 'a tap goes to the nearer mark'); }); + +test('the speed shown follows the world clock, and Play runs at its rate', async (t) => { + const bar = mount(t); + const now = Date.now(); + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + coverage: () => ({ start: now - 10 * 86_400_000, end: now - 86_400_000 }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const select = () => bar().querySelector('select.world-timeline-speed'); + assert.equal(select().value, 'real'); + // A host sets the rate: the selector shows it. + worldClock.setRate(86_400); + await nextTick(); + assert.equal(select().value, 'day'); + // A rate not offered shows as the nearest offered speed, and Play keeps it. + worldClock.setRate(5_000); + await nextTick(); + assert.equal(select().value, 'hour'); + bar().querySelector('.world-timeline-play').click(); + assert.equal(worldClock.isPlaying(), true); + assert.equal(worldClock.getRate(), 5_000, 'Play runs at the clock\'s own rate'); + worldClock.pause(); + // Chosen on the selector while paused, the clock takes it at once. + select().value = 'week'; + select().dispatchEvent(new Event('change')); + await nextTick(); + assert.equal(worldClock.getRate(), 604_800); + assert.equal(select().value, 'week'); +}); diff --git a/src/ui/wildlifeSightingCard.test.mjs b/src/ui/wildlifeSightingCard.test.mjs index f685c75e0..fb9123c7c 100644 --- a/src/ui/wildlifeSightingCard.test.mjs +++ b/src/ui/wildlifeSightingCard.test.mjs @@ -309,7 +309,7 @@ test('dot, icon and ambient photo clicks open one contained medium-photo card at true, 'the loaded medium photo is painted inside the card', ); - assert.equal(card.querySelector('[role=status]'), null); + assert.equal(card.querySelector('[role=status]') === null, true); const style = readFileSync( new URL('./views/panels/WildlifeSightingCard.vue', import.meta.url), 'utf8', @@ -343,11 +343,11 @@ test('missing, restricted, obscured and Acartia photos all use the same compact const f = fixture(context, { ...row, ...extra }); await f.click(); const card = f.cardElement(); - assert.equal(card.querySelector('img'), null); - assert.equal(card.querySelector('[role=status]'), null); + assert.equal(card.querySelector('img') === null, true); + assert.equal(card.querySelector('[role=status]') === null, true); assert.equal(card.querySelector('h2').textContent, row.commonName); assert.equal(card.querySelector('i').textContent, row.scientificName); - assert.equal(document.querySelector('dialog'), null); + assert.equal(document.querySelector('dialog') === null, true); if (extra.obscured) { assert.doesNotMatch(card.textContent, /ago|Photographer/); assert.ok( @@ -676,7 +676,7 @@ test('date-only and unsafe-link rows remain readable, and no sighting rail or li link: 'javascript:alert(1)', }); await f.click(); - assert.equal(f.cardElement().querySelector('a'), null); + assert.equal(f.cardElement().querySelector('a') === null, true); assert.equal( f.cardElement().querySelector('time').getAttribute('datetime'), row.observedOn,