diff --git a/src/layers/rail/cockpitSubject.js b/src/layers/rail/cockpitSubject.js index fe85d496e..c30228118 100644 --- a/src/layers/rail/cockpitSubject.js +++ b/src/layers/rail/cockpitSubject.js @@ -2,12 +2,50 @@ import { pointAtS } from '../../data/railPaths.js'; import { getRegisteredRailFeed } from '../../data/railFeeds.js'; import { displayMotion } from '../transit/movement.js'; +const NO_STOPS = Object.freeze([]); +/** Each stops list's frozen copy for a snapshot's `stops` (stopsOf). */ +const stopLists = new WeakMap(); + +/** + * A trip's stops as a snapshot hands them out: a frozen array of frozen + * copies, made once per list the stops provider returns. + */ +function stopsOf(list) { + if (!Array.isArray(list)) return NO_STOPS; + let stops = stopLists.get(list); + if (!stops) { + stops = Object.freeze( + list.map((stop) => + stop && typeof stop === 'object' ? Object.freeze({ ...stop }) : stop, + ), + ); + stopLists.set(list, stops); + } + return stops; +} + +/** + * `previous` when it holds each of `next`'s values, else a frozen copy of + * `next`: a part of a snapshot that did not change is the one read before. + * `next` is a draft, filled again on every read. + */ +function reuse(previous, next) { + if (!previous) return Object.freeze({ ...next }); + for (const name in next) + if (!Object.is(previous[name], next[name])) + return Object.freeze({ ...next }); + return previous; +} + /** * Read access to one train, and a hold on it for a first-person view. * * `getSelectedTrain()` and `getCockpitSubject()` describe the selected train * and the held one as a frozen snapshot, read again on each call: where it is - * along its line, how fast it moves and how that is known, and its stops. + * along its line, how fast it moves and how that is known, and its stops. A + * call while nothing in it changed returns the snapshot it returned before, + * and a part that did not change (its feed, its stops) is the same object in + * the next one, so a view can read it on every frame. * `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 @@ -36,26 +74,37 @@ export function createRailCockpitSubject({ const finiteOrNull = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null; const scratch = {}; + /** Each train's last snapshot, returned again while nothing in it changed. */ + const views = new WeakMap(); + /** The drafts trainView fills on every read (reuse). */ + const draft = { + view: {}, + feed: {}, + position: {}, + path: {}, + motion: {}, + provenance: {}, + }; /** The catalog's public entry for a feed, else the registry's. */ - function feedOf(feedId) { + function feedOf(feedId, previous) { const listed = state._catalog?.find((feed) => feed.id === feedId); const feed = listed ?? getRegisteredRailFeed(feedId); if (!feed) return null; - return Object.freeze({ - id: feed.id, - name: feed.name ?? null, - operator: feed.operator ?? null, - region: feed.region ?? null, - positions: listed?.positions ?? feed.positions ?? 'gps', - tripUpdates: - typeof listed?.tripUpdates === 'boolean' - ? listed.tripUpdates - : typeof feed.tripUpdatesUrl === 'string', - attribution: feed.attribution ?? null, - license: feed.license ?? null, - licenseUrl: feed.licenseUrl ?? null, - }); + const next = draft.feed; + next.id = feed.id; + next.name = feed.name ?? null; + next.operator = feed.operator ?? null; + next.region = feed.region ?? null; + next.positions = listed?.positions ?? feed.positions ?? 'gps'; + next.tripUpdates = + typeof listed?.tripUpdates === 'boolean' + ? listed.tripUpdates + : typeof feed.tripUpdatesUrl === 'string'; + next.attribution = feed.attribution ?? null; + next.license = feed.license ?? null; + next.licenseUrl = feed.licenseUrl ?? null; + return reuse(previous, next); } /** The route's short (else long) name in the feed's timetable, or null. */ @@ -87,6 +136,7 @@ export function createRailCockpitSubject({ /** The train as a frozen snapshot (see the module comment), or null. */ function trainView(entry) { if (!entry?.record) return null; + const previous = views.get(entry); const record = entry.record; const sample = entry.sample ?? null; const drawn = entry.drawnHeight ?? null; @@ -95,64 +145,68 @@ export function createRailCockpitSubject({ const derived = finiteOrNull(sample?.segmentSpeedMps); const trip = state._selectedKey === entry.key ? state._trip : null; const status = state._feedStatus.get(entry.feedId); - const latitude = + + const position = draft.position; + position.latitude = finiteOrNull(drawn?.lat) ?? finiteOrNull(sample?.lat) ?? record.lat; - const longitude = + position.longitude = finiteOrNull(drawn?.lon) ?? finiteOrNull(sample?.lon) ?? record.lon; - return Object.freeze({ - key: entry.key, - feedId: entry.feedId, - selectedAtMs: - state._selectedKey === entry.key ? state._selectedAtMs : null, - id: String(record.id ?? ''), - tripId: record.tripId || null, - routeId: record.routeId || null, - routeName: routeNameOf(entry), - label: record.label || null, - trainNumber: record.trainNumber || null, - headsign: trip?.headsign || null, - category: entry.category ?? null, - mode: entry.railMode ?? null, - feed: feedOf(entry.feedId), - position: Object.freeze({ - latitude, - longitude, - railHeightM: Number.isFinite(drawn?.h) ? drawn.h - liftM : null, - heightQuality: Number.isFinite(drawn?.q) ? drawn.q : 0, - atMs: finiteOrNull(drawn?.at), - }), - path: - binding && binding.path - ? Object.freeze({ - shapeIndex: binding.shapeIndex, - s: binding.drawS, - direction: directionOf(entry), - lengthM: binding.path.length, - via: binding.via, - builtAt: - state._pathPacks.get(entry.feedId)?.pack?.builtAt ?? null, - line: binding.path, - }) - : null, - motion: Object.freeze({ - reportedSpeedMps: reported, - derivedSpeedMps: derived, - speedSource: - reported !== null ? 'reported' : derived !== null ? 'derived' : null, - courseDeg: finiteOrNull(entry.courseDeg), - phase: sample?.phase ?? null, - word: displayMotion(entry, Date.now()).word, - reportAgeMs: finiteOrNull(sample?.latestReportAgeMs), - shownBehindMs: finiteOrNull(sample?.actualDelayMs), - feedStale: status?.stale === true, - }), - provenance: Object.freeze({ - positionSource: record.positionSource ?? 'gps', - timestampSource: record.timestampSource ?? null, - }), - stops: Object.freeze(Array.isArray(trip?.stops) ? [...trip.stops] : []), - match: trip?.match ?? null, - }); + position.railHeightM = Number.isFinite(drawn?.h) ? drawn.h - liftM : null; + position.heightQuality = Number.isFinite(drawn?.q) ? drawn.q : 0; + position.atMs = finiteOrNull(drawn?.at); + + let path = null; + if (binding && binding.path) { + path = draft.path; + path.shapeIndex = binding.shapeIndex; + path.s = binding.drawS; + path.direction = directionOf(entry); + path.lengthM = binding.path.length; + path.via = binding.via; + path.builtAt = state._pathPacks.get(entry.feedId)?.pack?.builtAt ?? null; + path.line = binding.path; + } + + const motion = draft.motion; + motion.reportedSpeedMps = reported; + motion.derivedSpeedMps = derived; + motion.speedSource = + reported !== null ? 'reported' : derived !== null ? 'derived' : null; + motion.courseDeg = finiteOrNull(entry.courseDeg); + motion.phase = sample?.phase ?? null; + motion.word = displayMotion(entry, Date.now()).word; + motion.reportAgeMs = finiteOrNull(sample?.latestReportAgeMs); + motion.shownBehindMs = finiteOrNull(sample?.actualDelayMs); + motion.feedStale = status?.stale === true; + + const provenance = draft.provenance; + provenance.positionSource = record.positionSource ?? 'gps'; + provenance.timestampSource = record.timestampSource ?? null; + + const view = draft.view; + view.key = entry.key; + view.feedId = entry.feedId; + view.selectedAtMs = + state._selectedKey === entry.key ? state._selectedAtMs : null; + view.id = String(record.id ?? ''); + view.tripId = record.tripId || null; + view.routeId = record.routeId || null; + view.routeName = routeNameOf(entry); + view.label = record.label || null; + view.trainNumber = record.trainNumber || null; + view.headsign = trip?.headsign || null; + view.category = entry.category ?? null; + view.mode = entry.railMode ?? null; + view.feed = feedOf(entry.feedId, previous?.feed); + view.position = reuse(previous?.position, position); + view.path = path && reuse(previous?.path, path); + view.motion = reuse(previous?.motion, motion); + view.provenance = reuse(previous?.provenance, provenance); + view.stops = stopsOf(trip?.stops); + view.match = trip?.match ?? null; + const snapshot = reuse(previous, view); + views.set(entry, snapshot); + return snapshot; } const methods = { diff --git a/src/layers/rail/cockpitSubject.test.mjs b/src/layers/rail/cockpitSubject.test.mjs index ad4af5061..a88052b2e 100644 --- a/src/layers/rail/cockpitSubject.test.mjs +++ b/src/layers/rail/cockpitSubject.test.mjs @@ -4,7 +4,7 @@ // 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, railTrainKey } from './policy.js'; +import { RAIL_POLL_MS, STOP_REFRESH_MS, railTrainKey } from './policy.js'; import { BOSTON, railLayerHarness as harness, @@ -37,8 +37,12 @@ const PACK = Object.freeze({ }); /** Two trains on the line, T1 running east and T2 west, a step a poll. */ -async function onTheLine(t) { - const app = harness(t, { paths: { 'mbta-rail': PACK }, at: BOSTON }); +async function onTheLine(t, options = {}) { + const app = harness(t, { + paths: { 'mbta-rail': PACK }, + at: BOSTON, + ...options, + }); let step = 0; app.feeds.set('mbta-rail', () => [ train('T1', LAT, LON0 + (1000 + step * 150) / KX, { speedMps: 15 }), @@ -100,6 +104,56 @@ test('the selected train reads as a snapshot of its place, motion and sources', ); }); +/** A trip's stops as a stops provider lists them. */ +const stopList = (...names) => + names.map((name, i) => ({ + stopId: name, + name, + lat: LAT, + lon: LON0 + (i * 2000) / KX, + state: 'upcoming', + })); + +test('a snapshot read again while nothing changed is the one read before', async (t) => { + let list = stopList('Alpha', 'Bravo', 'Charlie'); + const app = await onTheLine(t, { + stops: { stopsFor: () => ({ stops: list, headsign: 'Charlie' }) }, + }); + app.parts().selection.selectTrain(T1); + const first = app.layer.getSelectedTrain(); + assert.equal(app.layer.getSelectedTrain(), first, 'the same snapshot'); + // Its stops are frozen copies of the provider's. + assert.equal(first.stops.length, 3); + assert.ok(Object.isFrozen(first.stops)); + assert.ok(Object.isFrozen(first.stops[0])); + assert.notEqual(first.stops[0], list[0]); + assert.equal(first.stops[0].name, 'Alpha'); + + // It moves on: a new snapshot at its new place, what did not change kept. + app.run(RAIL_POLL_MS); + await app.poll(); + app.run(RAIL_POLL_MS); + const moved = app.layer.getSelectedTrain(); + assert.notEqual(moved, first, 'a new snapshot'); + assert.ok(Object.isFrozen(moved) && Object.isFrozen(moved.path)); + assert.ok(moved.path.s > first.path.s, 'at its new place'); + assert.equal(moved.stops, first.stops, 'the same stops'); + assert.equal(moved.feed, first.feed); + assert.equal(moved.provenance, first.provenance); + assert.equal(moved.path.line, first.path.line); + assert.equal(app.layer.getSelectedTrain(), moved); + + // The provider lists other stops: new ones. + list = stopList('Delta', 'Echo'); + app.run(STOP_REFRESH_MS); + const later = app.layer.getSelectedTrain(); + assert.notEqual(later.stops, first.stops); + assert.deepEqual( + later.stops.map((stop) => stop.name), + ['Delta', 'Echo'], + ); +}); + test('a held train hides its marker and card and keeps the selection', async (t) => { const app = await onTheLine(t); assert.equal(app.layer.setCockpitSubject('rail:mbta-rail:nope'), false); diff --git a/src/layers/transit/cockpitSubject.js b/src/layers/transit/cockpitSubject.js index 3061ef216..0439f0abf 100644 --- a/src/layers/transit/cockpitSubject.js +++ b/src/layers/transit/cockpitSubject.js @@ -4,17 +4,33 @@ import { } from '../../data/transitFeeds.js'; import { displayMotion } from './movement.js'; +/** + * `previous` when it holds each of `next`'s values, else a frozen copy of + * `next`: a part of a snapshot that did not change is the one read before. + * `next` is a draft, filled again on every read. + */ +function reuse(previous, next) { + if (!previous) return Object.freeze({ ...next }); + for (const name in next) + if (!Object.is(previous[name], next[name])) + return Object.freeze({ ...next }); + return previous; +} + /** * Read access to one vehicle, and a hold on it for a first-person view. * * `getSelectedVehicle()` and `getCockpitSubject()` describe the selected * vehicle and the held one as a frozen snapshot, read again on each call: - * where it is drawn, how fast it moves and how that is known. Transit has no - * route shapes or stops, so a snapshot names only the vehicle's last reported - * stop. `setCockpitSubject(key)` holds a vehicle: its sprite, card, trail - * and detection box are hidden, it keeps moving when it is out of the - * camera's view, its feed stays polled, and clicks and Escape no longer - * change the selection. The hold ends with `setCockpitSubject(null)`, and on + * where it is drawn, how fast it moves and how that is known. A call while + * nothing in it changed returns the snapshot it returned before, and a part + * that did not change (its feed, its stop) is the same object in the next + * one, so a view can read it on every frame. Transit has no route shapes or + * stops, so a snapshot names only the vehicle's last reported stop. + * `setCockpitSubject(key)` holds a vehicle: its sprite, card, trail and + * detection box are hidden, it keeps moving when it is out of the camera's + * view, its feed stays polled, and clicks and Escape no longer change the + * selection. The hold ends with `setCockpitSubject(null)`, and on * its own when the vehicle is removed or the layer is turned off. * `focusVehicle(key)` selects a vehicle, as a click does. A snapshot's * `selectedAtMs` is when the user last selected the selected vehicle (a @@ -31,84 +47,101 @@ export function createTransitCockpitSubject({ state, parts, services }) { : 0; const finiteOrNull = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null; + /** Each vehicle's last snapshot, returned again while nothing changed. */ + const views = new WeakMap(); + /** The drafts vehicleView fills on every read (reuse). */ + const draft = { + view: {}, + feed: {}, + position: {}, + motion: {}, + stop: {}, + provenance: {}, + }; /** The feed in use, else the registry's entry for it. */ function feedEntry(feedId) { return state._activeFeeds.get(feedId) || getRegisteredTransitFeed(feedId); } - function feedOf(feed) { + function feedOf(feed, previous) { if (!feed) return null; - return Object.freeze({ - id: feed.id, - name: feed.name ?? null, - operator: feed.operator ?? null, - region: feed.region ?? null, - attribution: feed.attribution ?? null, - license: feed.license ?? null, - licenseUrl: feed.licenseUrl ?? null, - }); + const next = draft.feed; + next.id = feed.id; + next.name = feed.name ?? null; + next.operator = feed.operator ?? null; + next.region = feed.region ?? null; + next.attribution = feed.attribution ?? null; + next.license = feed.license ?? null; + next.licenseUrl = feed.licenseUrl ?? null; + return reuse(previous, next); } /** The vehicle as a frozen snapshot (see the module comment), or null. */ function vehicleView(entry) { if (!entry?.record) return null; + const previous = views.get(entry); const record = entry.record; const sample = entry.sample ?? null; const feed = feedEntry(entry.feedId); const reported = finiteOrNull(record.speedMps); const derived = finiteOrNull(sample?.segmentSpeedMps); const status = state._feedStatus.get(entry.feedId); - const latitude = finiteOrNull(sample?.lat) ?? record.lat; - const longitude = finiteOrNull(sample?.lon) ?? record.lon; - return Object.freeze({ - key: entry.key, - feedId: entry.feedId, - selectedAtMs: - state._selectedKey === entry.key ? state._selectedAtMs : null, - id: String(record.id ?? ''), - routeId: record.routeId || null, - routeLabel: - (feed && record.routeId - ? transitRouteLabel(feed, record.routeId) - : null) || null, - tripId: record.tripId || null, - directionId: record.directionId ?? null, - label: record.label || null, - mode: entry.mode ?? null, - modeInferred: entry.modeInferred === true, - feed: feedOf(feed), - position: Object.freeze({ - latitude, - longitude, - groundHeightM: Number.isFinite(entry.heightM) - ? entry.heightM - liftM - : null, - atMs: finiteOrNull(sample?.displayT), - }), - motion: Object.freeze({ - reportedSpeedMps: reported, - derivedSpeedMps: derived, - speedSource: - reported !== null ? 'reported' : derived !== null ? 'derived' : null, - courseDeg: finiteOrNull(entry.courseDeg), - reportedBearingDeg: finiteOrNull(record.bearing), - segmentCourseDeg: finiteOrNull(sample?.segmentCourseDeg), - phase: sample?.phase ?? null, - word: displayMotion(entry, Date.now()).word, - reportAgeMs: finiteOrNull(sample?.latestReportAgeMs), - shownBehindMs: finiteOrNull(sample?.actualDelayMs), - feedStale: status?.stale === true, - }), - stop: Object.freeze({ - stopId: record.stopId || null, - status: record.status || null, - }), - provenance: Object.freeze({ - positionSource: 'gps', - timestampSource: record.timestampSource ?? null, - }), - }); + + const position = draft.position; + position.latitude = finiteOrNull(sample?.lat) ?? record.lat; + position.longitude = finiteOrNull(sample?.lon) ?? record.lon; + position.groundHeightM = Number.isFinite(entry.heightM) + ? entry.heightM - liftM + : null; + position.atMs = finiteOrNull(sample?.displayT); + + const motion = draft.motion; + motion.reportedSpeedMps = reported; + motion.derivedSpeedMps = derived; + motion.speedSource = + reported !== null ? 'reported' : derived !== null ? 'derived' : null; + motion.courseDeg = finiteOrNull(entry.courseDeg); + motion.reportedBearingDeg = finiteOrNull(record.bearing); + motion.segmentCourseDeg = finiteOrNull(sample?.segmentCourseDeg); + motion.phase = sample?.phase ?? null; + motion.word = displayMotion(entry, Date.now()).word; + motion.reportAgeMs = finiteOrNull(sample?.latestReportAgeMs); + motion.shownBehindMs = finiteOrNull(sample?.actualDelayMs); + motion.feedStale = status?.stale === true; + + const stop = draft.stop; + stop.stopId = record.stopId || null; + stop.status = record.status || null; + + const provenance = draft.provenance; + provenance.positionSource = 'gps'; + provenance.timestampSource = record.timestampSource ?? null; + + const view = draft.view; + view.key = entry.key; + view.feedId = entry.feedId; + view.selectedAtMs = + state._selectedKey === entry.key ? state._selectedAtMs : null; + view.id = String(record.id ?? ''); + view.routeId = record.routeId || null; + view.routeLabel = + (feed && record.routeId + ? transitRouteLabel(feed, record.routeId) + : null) || null; + view.tripId = record.tripId || null; + view.directionId = record.directionId ?? null; + view.label = record.label || null; + view.mode = entry.mode ?? null; + view.modeInferred = entry.modeInferred === true; + view.feed = feedOf(feed, previous?.feed); + view.position = reuse(previous?.position, position); + view.motion = reuse(previous?.motion, motion); + view.stop = reuse(previous?.stop, stop); + view.provenance = reuse(previous?.provenance, provenance); + const snapshot = reuse(previous, view); + views.set(entry, snapshot); + return snapshot; } /** Show or hide a vehicle's sprite and trail for the hold. */ diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index b17ab026b..d319da9e5 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -4196,6 +4196,27 @@ test('the selected vehicle reads as a snapshot of its place, motion and source', assert.ok(Number.isFinite(view.position.latitude)); }); +test('a vehicle read again while nothing changed is the snapshot read before', async (t) => { + const app = harness(t); + const key = await busRunningNorth(app); + app.layer._transitPartsForTest().selection.selectVehicle(key); + const first = app.layer.getSelectedVehicle(); + assert.equal(app.layer.getSelectedVehicle(), first, 'the same snapshot'); + // It moves on: a new snapshot at its new place, what did not change kept. + app.run(TRANSIT_POLL_MS); + await app.layer.update(); + app.settle(); + app.run(TRANSIT_POLL_MS); + const moved = app.layer.getSelectedVehicle(); + assert.notEqual(moved, first, 'a new snapshot'); + assert.ok(Object.isFrozen(moved) && Object.isFrozen(moved.position)); + assert.ok(moved.position.latitude > first.position.latitude, 'moved'); + assert.equal(moved.feed, first.feed); + assert.equal(moved.stop, first.stop); + assert.equal(moved.provenance, first.provenance); + assert.equal(app.layer.getSelectedVehicle(), moved); +}); + test('selectedAtMs is the user’s selection time; a hold and a deselection leave none', async (t) => { const app = harness(t); const key = await busRunningNorth(app);