fix(rail): a train snapshot's path.line is a frozen copy of its line, not the layer's own path

The copy keeps the path's fields with its number arrays as frozen arrays,
so pointAtS and projectOnPath read it as they read the path and nothing
written to it reaches the layer. It is built once per line and shared by
every snapshot on that line, so reading it on every frame costs nothing.
This commit is contained in:
Denis Groshev committed 2026-11-12 16:58:39 +01:00
1 parent 294ccc0ee8
commit a17346be6f
2 files changed
+48 -3

No files matched your search

+27 -3
View File
@@ -2,6 +2,29 @@ import { pointAtS } from '../../data/railPaths.js';
import { getRegisteredRailFeed } from '../../data/railFeeds.js';
import { displayMotion } from '../transit/movement.js';
/** Each line's read-only copy for a snapshot's `path.line` (lineOf). */
const lines = new WeakMap();
/**
* A line as a snapshot hands it out: a frozen copy of the layer's path, its
* number arrays frozen arrays, built once per line. `pointAtS` and
* `projectOnPath` read it as they read the path, and nothing written to it
* reaches the layer.
*/
function lineOf(path) {
let line = lines.get(path);
if (!line) {
const copy = {};
for (const [name, value] of Object.entries(path))
copy[name] = ArrayBuffer.isView(value)
? Object.freeze(Array.from(value))
: value;
line = Object.freeze(copy);
lines.set(path, line);
}
return line;
}
const NO_STOPS = Object.freeze([]);
/** Each stops list's frozen copy for a snapshot's `stops` (stopsOf). */
const stopLists = new WeakMap();
@@ -44,8 +67,9 @@ function reuse(previous, next) {
* 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. 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.
* and a part that did not change (its feed, its line, its stops) is the same
* object in the next one, so a view can read it on every frame. `path.line`
* is a frozen copy of the train's line for `pointAtS` and `projectOnPath`.
* `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
@@ -164,7 +188,7 @@ export function createRailCockpitSubject({
path.lengthM = binding.path.length;
path.via = binding.via;
path.builtAt = state._pathPacks.get(entry.feedId)?.pack?.builtAt ?? null;
path.line = binding.path;
path.line = lineOf(binding.path);
}
const motion = draft.motion;
+21
View File
@@ -12,6 +12,7 @@ import {
} from '../../testSupport/railLayerHarness.mjs';
import { RAIL_SELECTED_OVERLAY_SOURCE_ID } from './policy.js';
import { publicRailCatalog } from '../../data/railFeeds.js';
import { pointAtS } from '../../data/railPaths.js';
const M_PER_DEG = 111_320;
const LAT = 42.36;
@@ -154,6 +155,26 @@ test('a snapshot read again while nothing changed is the one read before', async
);
});
test('a snapshot’s line is a frozen copy: writing to it leaves the layer’s line', async (t) => {
const app = await onTheLine(t);
app.parts().selection.selectTrain(T1);
const { line, s } = app.layer.getSelectedTrain().path;
const own = app.entry(T1).path.path;
assert.notEqual(line, own, 'not the layer’s own');
assert.ok(Object.isFrozen(line));
for (const name of ['lat', 'lon', 'x', 'y', 's', 'box'])
assert.ok(Object.isFrozen(line[name]), name);
const lat = own.lat[1];
assert.throws(() => {
line.lat[1] = 0;
}, TypeError);
assert.equal(own.lat[1], lat, 'the layer’s line is as it was');
assert.deepEqual(pointAtS(line, s), pointAtS(own, s), 'it reads the same');
// Copied once per line: T2 runs on the same one.
app.parts().selection.selectTrain(T2);
assert.equal(app.layer.getSelectedTrain().path.line, line);
});
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);