diff --git a/src/layers/rail/cockpitSubject.js b/src/layers/rail/cockpitSubject.js index e076779b9..e6e7b3b9f 100644 --- a/src/layers/rail/cockpitSubject.js +++ b/src/layers/rail/cockpitSubject.js @@ -73,10 +73,15 @@ function reuse(previous, next) { * `setCockpitSubject(key)` holds a train: its marker, card and selected line * are hidden, it stays selected (so its stops stay fresh and its feed stays * polled at any distance) and clicks and Escape no longer change the - * selection. The hold ends with `setCockpitSubject(null)`, and on its own - * when the train is removed or the layer is turned off. A train removed while - * held leaves its feed polled until the next `setCockpitSubject` call, so a - * view that waits for it holds it again if the feed lists it again. + * selection. A held train keeps moving, hidden, and keeps the selection when + * its category is turned off, and is the copy of its train drawn though + * another feed's copy would outrank it (dedupe.js). Released, it is a train + * like any other: one in a category turned off is deselected and stops, and + * a better copy takes over at the next poll. The hold ends with + * `setCockpitSubject(null)`, and on its own when the train is removed or the + * layer is turned off. A train removed while held leaves its feed polled + * until the next `setCockpitSubject` call, so a view that waits for it holds + * it again if the feed lists it again. * `focusTrain(key)` selects a train and hands it the camera, as a click does. * A snapshot's `selectedAtMs` is when the user last selected the selected * train (a click, or `focusTrain`), even again, for a view's entry to tell a @@ -233,6 +238,13 @@ export function createRailCockpitSubject({ return snapshot; } + /** A train's marker and playback again, after a hold on it began or ended. */ + function replay(entry) { + parts.rendering.paint(entry); + parts.rendering.schedulePlayback(entry); + parts.rendering.syncRenderHold(); + } + const methods = { /** The selected train (see createRailCockpitSubject), or null. */ getSelectedTrain() { @@ -253,7 +265,16 @@ export function createRailCockpitSubject({ if (key === null || key === undefined) { state._cockpitSubjectKey = null; state._cockpitLostFeedId = null; - if (previous) parts.rendering.paint(previous); + if (previous) { + // Its category turned off meanwhile: deselected, as the toggle + // does to any other train (controls.js). + if ( + previous.key === state._selectedKey && + state._categories[previous.category] === false + ) + parts.selection.clearSelection(); + replay(previous); + } parts.selection.refreshSelectedCard(true); parts.routes.applyHold?.(); return true; @@ -267,8 +288,10 @@ export function createRailCockpitSubject({ } state._cockpitSubjectKey = key; state._cockpitLostFeedId = null; - if (previous && previous !== entry) parts.rendering.paint(previous); - parts.rendering.paint(entry); + // Another feed's copy drew it: the held copy is drawn now (dedupe.js). + if (entry.duplicate === true) parts.ingestion.duplicates?.refresh(); + if (previous && previous !== entry) replay(previous); + replay(entry); parts.selection.refreshSelectedCard(true); parts.routes.applyHold?.(); return true; diff --git a/src/layers/rail/cockpitSubject.test.mjs b/src/layers/rail/cockpitSubject.test.mjs index 2f9630007..35fa5a411 100644 --- a/src/layers/rail/cockpitSubject.test.mjs +++ b/src/layers/rail/cockpitSubject.test.mjs @@ -4,7 +4,12 @@ // hides its marker and card, keeps it selected and ends when it goes. import test from 'node:test'; import assert from 'node:assert/strict'; -import { RAIL_POLL_MS, STOP_REFRESH_MS, railTrainKey } from './policy.js'; +import { + RAIL_POLL_MS, + STOP_REFRESH_MS, + railTrainFollowable, + railTrainKey, +} from './policy.js'; import { BOSTON, railLayerHarness as harness, @@ -216,6 +221,128 @@ test('a held train hides its marker and card and keeps the selection', async (t) ); }); +const MADRID = { lat: 40.4168, lon: -3.7038 }; + +test('a held train stays the copy drawn when another feed’s copy outranks it, and keeps moving', async (t) => { + const app = harness(t, { at: MADRID }); + // One Alvia in two feeds: a station-event copy, held, and a poll later a + // GPS copy that would outrank it. + let step = 0; + let gpsCopy = false; + const lat = (offset) => MADRID.lat + offset + step * 0.003; + app.feeds.set('renfe-cercanias', () => [ + train('A1', lat(0), MADRID.lon, { + label: 'ALVIA 4073', + positionSource: 'event', + }), + ]); + app.feeds.set('renfe-larga-distancia', () => + gpsCopy ? [train('B1', lat(0.01), MADRID.lon, { label: '4073' })] : [], + ); + await app.enable(); + const a = railTrainKey('renfe-cercanias', 'A1'); + const b = railTrainKey('renfe-larga-distancia', 'B1'); + for (let i = 0; i < 12 && !railTrainFollowable(app.entry(a)); i += 1) { + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.ok(railTrainFollowable(app.entry(a)), 'the train is moving'); + assert.equal(app.layer.setCockpitSubject(a), true); + + gpsCopy = true; + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + // The view knows the train by its key, and the copies stand apart: the + // held copy stays drawn and selected, the other is the one hidden. + assert.equal(app.layer.getCockpitSubject()?.key, a); + assert.equal(app.state()._selectedKey, a); + assert.equal(app.entry(a).duplicate, false); + assert.equal(app.entry(b).duplicate, true); + assert.equal(app.entry(a).marker.show, false); + assert.equal(app.entry(b).marker.show, false, 'no other marker on it'); + assert.deepEqual( + app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [], + [], + 'no card', + ); + // It moves between polls, frame by frame. + assert.ok(app.state()._moving.has(app.entry(a)), 'still playing'); + const before = app.layer.getCockpitSubject().position.latitude; + app.run(2000); + assert.ok( + app.layer.getCockpitSubject().position.latitude > before, + 'still moving', + ); + + // Let go: the better copy takes over at the next poll, with the selection. + app.layer.setCockpitSubject(null); + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + assert.equal(app.entry(a).duplicate, true); + assert.equal(app.entry(b).duplicate, false); + assert.equal(app.state()._selectedKey, b); + assert.equal(app.entry(b).marker.show, true); + assert.equal( + (app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? []).length, + 1, + 'its card', + ); + + // A hold of the copy hidden draws it at once, in place of the other. + assert.equal(app.layer.setCockpitSubject(a), true); + assert.equal(app.state()._selectedKey, a); + assert.equal(app.entry(a).duplicate, false); + assert.equal(app.entry(b).duplicate, true); + assert.equal(app.entry(b).marker.show, false); + assert.ok(app.state()._moving.has(app.entry(a)), 'playing'); +}); + +test('a held train keeps the selection and moves, hidden, when its category is turned off', async (t) => { + const app = await onTheLine(t); + app.layer.setCockpitSubject(T1); + app.run(1000); + assert.ok(app.state()._moving.has(app.entry(T1)), 'playing'); + app.layer.setParams({ regional: false }); + assert.equal(app.state()._selectedKey, T1, 'still selected'); + assert.equal(app.layer.getCockpitSubject()?.key, T1); + assert.equal(app.entry(T1).marker.show, false); + assert.equal(app.entry(T2).marker.show, false, 'its category is hidden'); + assert.equal(app.state()._route.primitive.show, false, 'no line'); + assert.deepEqual( + app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [], + [], + 'no card', + ); + // It moves between polls, frame by frame. + assert.ok(app.state()._moving.has(app.entry(T1)), 'still playing'); + const before = app.layer.getCockpitSubject().path.s; + app.run(2000); + assert.ok(app.layer.getCockpitSubject().path.s > before, 'still moving'); + assert.equal(app.entry(T1).marker.show, false, 'and still hidden'); + + // Let go: a train of a hidden category, deselected and stopped as the + // toggle would have left it. + app.layer.setCockpitSubject(null); + assert.equal(app.state()._selectedKey, null); + assert.equal(app.state()._moving.has(app.entry(T1)), false); + assert.equal(app.entry(T1).marker.show, false); + const parked = app.entry(T1).path.drawS; + app.run(2000); + assert.equal(app.entry(T1).path.drawS, parked, 'stopped'); + // Held again, hidden, it plays at once; let go, it stops again. + assert.equal(app.layer.setCockpitSubject(T1), true); + assert.ok(app.state()._moving.has(app.entry(T1)), 'playing at once'); + app.layer.setCockpitSubject(null); + assert.equal(app.state()._moving.has(app.entry(T1)), false); + // Shown again, it is drawn and moves as any other. + app.layer.setParams({ regional: true }); + assert.equal(app.entry(T1).marker.show, true); + assert.ok(app.state()._moving.has(app.entry(T1)), 'playing again'); +}); + test('a held train that goes takes the hold with it; focusTrain selects', async (t) => { const app = await onTheLine(t); app.layer.setCockpitSubject(T1); diff --git a/src/layers/rail/controls.js b/src/layers/rail/controls.js index 0899ed8e9..f937cd6ae 100644 --- a/src/layers/rail/controls.js +++ b/src/layers/rail/controls.js @@ -91,7 +91,13 @@ export function createControls({ state, parts, timetable }) { return true; state._categories = next; const selected = state._trains.get(state._selectedKey); - if (selected && next[selected.category] === false) + // A held train keeps the selection until its view lets it go + // (cockpitSubject.js). + if ( + selected && + next[selected.category] === false && + selected.key !== state._cockpitSubjectKey + ) parts.selection.clearSelection(); parts.rendering.applyCategoryVisibility(); state._revision += 1; diff --git a/src/layers/rail/dedupe.js b/src/layers/rail/dedupe.js index e1ce79f78..f699c0c67 100644 --- a/src/layers/rail/dedupe.js +++ b/src/layers/rail/dedupe.js @@ -25,9 +25,13 @@ import { greatCircleKm } from '../../geoDistance.js'; * 2. the line: a copy bound to its operator's shape (a built pack), then * one without. * The copy drawn stays drawn until another scores strictly higher, so equal - * copies never trade places. A losing copy keeps its playback and is only - * hidden; when a feed leaves the viewport its copies' partners come back. - * The comparison runs once per poll, never per frame. + * copies never trade places. A copy a first-person view holds + * (cockpitSubject.js) stays drawn whatever the scores until the view lets + * go: the view knows the train by that copy's key and rides it where that + * copy's feed puts it, tens of kilometres from another feed's copy at + * times. A losing copy keeps its playback and is only hidden; when a feed + * leaves the viewport its copies' partners come back. The comparison runs + * once per poll, never per frame. */ /** @@ -288,10 +292,12 @@ function better(a, b) { * scores strictly higher. * @param {object[][]} groups From matchCopies. * @param {Map} [previous] copy key → key drawn last time. + * @param {string|null} [held] A copy drawn whatever the scores: the train a + * first-person view holds. * @returns {{winners: Map, hidden: Set}} * `winners`: every grouped copy's key → the key drawn for its train. */ -export function chooseCopies(groups, previous = new Map()) { +export function chooseCopies(groups, previous = new Map(), held = null) { const winners = new Map(); const hidden = new Set(); for (const group of groups) { @@ -305,6 +311,7 @@ export function chooseCopies(groups, previous = new Map()) { scored.reduce((a, b) => (better(b.score, a.score) ? b : a)); for (const candidate of scored) if (better(candidate.score, best.score)) best = candidate; + best = scored.find(({ copy }) => copy.key === held) ?? best; for (const { copy } of scored) { winners.set(copy.key, best.copy.key); if (copy.key !== best.copy.key) hidden.add(copy.key); @@ -318,7 +325,8 @@ export function chooseCopies(groups, previous = new Map()) { * loaded feed carries, marks the copies not drawn (`entry.duplicate`), and * moves a selection that sat on a copy now hidden to the copy drawn, so the * card follows the same physical train (its feed line names the new - * source). + * source). A held copy is never the one hidden, so the hold and the + * selection stay on it. * @param {{state: object, parts: object, * defer?: (fn: Function) => unknown}} context */ @@ -355,6 +363,7 @@ export function createDuplicateFilter({ const { winners, hidden } = chooseCopies( matchCopies(entries.map(copyOf)), previous, + state._cockpitSubjectKey ?? null, ); previous = winners; for (const entry of entries) { diff --git a/src/layers/rail/dedupe.test.mjs b/src/layers/rail/dedupe.test.mjs index ac3f61f6b..0d6538d3c 100644 --- a/src/layers/rail/dedupe.test.mjs +++ b/src/layers/rail/dedupe.test.mjs @@ -252,6 +252,32 @@ test('the filter hides the weaker copy and the selection follows its train', () assert.deepEqual(filter.stats(), { groups: 0, hidden: 0 }); }); +test('a copy a first-person view holds stays drawn, with the selection', () => { + const pair = [ + copy('a', 'f1', { number: '160', position: 'event' }), + copy('b', 'f2', { number: '160', position: 'gps' }), + ]; + assert.equal(chooseCopies([pair]).winners.get('a'), 'b'); + const held = chooseCopies([pair], new Map(), 'a'); + assert.equal(held.winners.get('b'), 'a', 'drawn whatever the scores'); + assert.deepEqual([...held.hidden], ['b']); + // Let go, the better copy takes over. + assert.equal(chooseCopies([pair], held.winners).winners.get('a'), 'b'); + + const layer = fakeLayer({ selected: 'feed-a:v160' }); + layer.state._cockpitSubjectKey = 'feed-a:v160'; + const filter = createDuplicateFilter({ ...layer, defer: (fn) => fn() }); + filter.refresh(); + const [unbound, operator] = layer.trains; + assert.equal(unbound.duplicate, false, 'the held copy'); + assert.equal(operator.duplicate, true); + assert.deepEqual(layer.calls.selected, [], 'the selection stays on it'); + layer.state._cockpitSubjectKey = null; + filter.refresh(); + assert.equal(unbound.duplicate, true); + assert.deepEqual(layer.calls.selected, [operator.key]); +}); + test('a short number after a local product names a line, not a train (S1, RB 1, U6)', () => { for (const label of ['S1', 'RB 1', 'U6', 'S 12', 'RE5']) assert.equal(recordTrainNumber({ label }), null, label); diff --git a/src/layers/rail/rendering.js b/src/layers/rail/rendering.js index 12ab9ab1a..7ec8b2425 100644 --- a/src/layers/rail/rendering.js +++ b/src/layers/rail/rendering.js @@ -516,6 +516,14 @@ export function createRendering({ state, services, parts }) { return categoryShown(entry) && entry.key !== state._cockpitSubjectKey; } + /** + * Whether the train's playback runs: it is drawn, or a first-person view + * rides it, which keeps it moving, hidden, in a category turned off. + */ + function playing(entry) { + return categoryShown(entry) || entry.key === state._cockpitSubjectKey; + } + /** * A train bound to its railway line is drawn on the line, at the arc its * playback has reached; any other train (or a bound train whose reports do @@ -602,7 +610,7 @@ export function createRendering({ state, services, parts }) { place(entry); const sample = entry.sample; const moving = - categoryShown(entry) && + playing(entry) && sample?.phase === 'playing' && sample.segmentSpeedMps > 0; if (moving) { @@ -610,7 +618,7 @@ export function createRendering({ state, services, parts }) { return; } state._moving.delete(entry); - if (categoryShown(entry) && Number.isFinite(sample?.nextWakeMonoMs)) { + if (playing(entry) && Number.isFinite(sample?.nextWakeMonoMs)) { entry.wakeTimer = setTimeout( () => { entry.wakeTimer = null;