mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 07:12:21 +00:00
fix(rail): a held train stays the copy drawn of its train and keeps moving, hidden, when its category is turned off
The duplicate filter hid a held copy when another feed's copy outranked it and moved the selection away, so the held train stopped playing and the other copy's marker and card came back over the view. A copy a first-person view holds is now drawn whatever the scores until the view lets go; the better copy takes over at the next poll after the release. Holding a copy that another feed drew draws it at once. A category toggle no longer deselects a held train, and a held train keeps playing, its marker hidden, while its category is off. Released, it is deselected and stops as the toggle leaves any other train.
This commit is contained in:
1 parent
fe7590d66a
commit
4ae67fcb35
6 files changed
+215
-16
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+7
-1
@@ -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;
|
||||
|
||||
@@ -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<string, string>} [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<string, string>, hidden: Set<string>}}
|
||||
* `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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user