mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(transit): keep the real silhouettes under sensor presets, make the selected trail visible, put Transit in Movement (#596)
## Changes Follow-up to #587 after the owner's first look at the merged transit layer on Google 3D tiles. - **Sensor presets keep the real silhouettes.** Under NVG, thermal (white-hot, black-hot, Ironbow) and noir, vehicles are the same silhouettes as in normal view, filled opaque white with a 2 px dark halo, at the CRT display sizes (26 px fleet, 39 px selected). The solid rounded "signature" bodies are gone. - **The selected vehicle's trail is visible.** The retained history is now draped onto 3D tiles and terrain (`GroundPolylinePrimitive`, `ClassificationType.BOTH`, the way the traffic heat-lines are drawn), 3 px core over a 5 px dark backing, per-segment age colour, a short draped head clipped to the sprite. Two defects kept it off screen before: Cesium's per-instance attribute getters return copies, so the renderer's show/colour edits were discarded, and a marker did not recover after the camera jumped to it, which hid the trail with it. Trail geometry is now prepared on selection independent of floor resolution and marker visibility; sparse long intervals (rail) keep a bounded marker corridor instead of dropping the interval; classification survives map-source changes. - **Transit sits in the Movement panel group** between Street Traffic and Bike Share (the panel orders by `PANEL_GROUPS`, not by catalog order; the catalog is moved too). ## How it was verified - `npm run format:check` (854 files), `npm run check:boundaries`, `npm test` (4,062 passing), `npm run build` clean; tracking regression 109/109 in a real browser on Google 3D tiles. - Browser harness (`scripts/qa-transit.mjs`), keyed on Google 3D tiles and keyless: keyed 194 pass, with the trail pixel check passing at 300, 600 and 900 m; the remaining keyed failures are the per-frame allocation gate already recorded as a follow-up from #587 (CPU and frame time within budget), four readability lines at threshold edges, and the card-anchor check now counting frames where the selected marker is legitimately off screen; keyless 193 pass with the remaining misses being threshold edges on the thin tram silhouette (centre 0.83–0.88 against 0.85) and one bus ring 0.28 against 0.25 on a pure-white roof. - New harness checks: `Google 3D selected trail visible at 6 of 8 projected samples` (repeated off/on framebuffer captures at 300/600/900 m, samples outside the sprite footprint, a background-change negative control; fails on main, passes here), a camera-arrival check (select a moving vehicle, `setView` onto it, marker shown and trail body drawn within 2 s), `PANEL_ORDER` pinned, raster tests comparing every sensor raster's mask and aspect ratio to the normal silhouette. - Direct look on a keyed build: a live CapMetro bus at 30 m/s selected through the layer API draws its draped trail along the highway lane (harness capture `keyed-600m-trail-visible.jpg`). - Adversarial review by Codex Astra before the last two fix passes (stack-switch classification, marker corridor under the vertex cap, pixel-gate loophole), all three fixed with regressions. Docs: `docs/CURRENT-STATE.md` and `CHANGELOG.md` updated in this PR. No data-source changes.
This commit is contained in:
1 parent
cd22ca3559
commit
0bb61f53c6
24 files changed
+1945
-205
No files matched your search
@@ -117,9 +117,9 @@ export function createApplicationCatalog({
|
||||
createApplicationTraffic({ source: sources.traffic }),
|
||||
createApplicationCctv({ surface, source: sources.cctv }),
|
||||
createApplicationRadio({ surface, source: sources.radio }),
|
||||
createApplicationTransit({ surface, source: sources.transit }),
|
||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||
createApplicationDirections(),
|
||||
createApplicationTransit({ surface, source: sources.transit }),
|
||||
vessels,
|
||||
installations,
|
||||
createApplicationAwareness({
|
||||
|
||||
@@ -41,6 +41,11 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
});
|
||||
assert.equal(first.layers.length, 21);
|
||||
assert.ok(first.get('transit'));
|
||||
const order = first.layers.map(({ id }) => id);
|
||||
assert.deepEqual(
|
||||
order.slice(order.indexOf('traffic'), order.indexOf('directions') + 1),
|
||||
['traffic', 'cctv', 'radio', 'transit', 'bikeshare', 'directions'],
|
||||
);
|
||||
assert.ok(first.get('bhote-koshi-2026'));
|
||||
assert.ok(first.get('bhote-koshi-locator'));
|
||||
const lifecycle = new LayerLifecycle({});
|
||||
|
||||
@@ -21,23 +21,70 @@ export function trailAlpha(ageMs) {
|
||||
|
||||
/** One static history batch and one frequently updated head. Never entities. */
|
||||
export function createContactTrailRenderer(scene) {
|
||||
const heads = scene.primitives.add(new Cesium.PolylineCollection());
|
||||
const backing = heads.add({
|
||||
positions: [],
|
||||
width: 4,
|
||||
show: false,
|
||||
material: Cesium.Material.fromType('Color', {
|
||||
color: new Cesium.Color(0.02, 0.03, 0.05, 0.8),
|
||||
}),
|
||||
});
|
||||
const head = heads.add({
|
||||
positions: [],
|
||||
width: 2,
|
||||
show: false,
|
||||
material: Cesium.Material.fromType('Color', {
|
||||
color: Cesium.Color.WHITE.clone(),
|
||||
}),
|
||||
});
|
||||
const ground =
|
||||
!!scene.frameState?.context &&
|
||||
Cesium.GroundPolylinePrimitive.isSupported(scene);
|
||||
const collection = ground ? scene.groundPrimitives : scene.primitives;
|
||||
// One 25 m corridor is clipped in material coordinates. Only subdivision
|
||||
// crossings build geometry; frame updates write a scalar, never vertex buffers.
|
||||
const headMaterial = (alpha) =>
|
||||
new Cesium.Material({
|
||||
fabric: {
|
||||
type: 'TransitTrailHead',
|
||||
uniforms: {
|
||||
color: new Cesium.Color(0.37, 0.94, 0.54, alpha),
|
||||
fraction: 0,
|
||||
backing: true,
|
||||
},
|
||||
source: `czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
if (materialInput.st.s > fraction) discard;
|
||||
czm_material m = czm_getDefaultMaterial(materialInput);
|
||||
bool edge = abs(materialInput.st.t - 0.5) > 0.3;
|
||||
m.diffuse = czm_gammaCorrect(vec4(edge && backing ? vec3(0.02, 0.03, 0.05) : color.rgb, 1.0)).rgb;
|
||||
m.alpha = color.a;
|
||||
return m;
|
||||
}`,
|
||||
},
|
||||
translucent: true,
|
||||
});
|
||||
const material = headMaterial(0.8),
|
||||
depthMaterial = headMaterial(0.55);
|
||||
depthMaterial.uniforms.backing = false;
|
||||
const head = { positions: [], show: false, width: 3, material };
|
||||
const backing = { positions: [], show: false, width: 5 };
|
||||
let headPrimitive = null,
|
||||
headRebuilds = 0;
|
||||
function geometry(positions, width, material = false) {
|
||||
return ground
|
||||
? new Cesium.GroundPolylineGeometry({ positions, width, granularity: 0 })
|
||||
: new Cesium.PolylineGeometry({
|
||||
positions,
|
||||
width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
vertexFormat: material
|
||||
? Cesium.PolylineMaterialAppearance.VERTEX_FORMAT
|
||||
: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
});
|
||||
}
|
||||
function primitive(instances, appearance, depthFailAppearance) {
|
||||
return ground
|
||||
? new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
allowPicking: false,
|
||||
asynchronous: true,
|
||||
show: visible,
|
||||
})
|
||||
: new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
depthFailAppearance,
|
||||
allowPicking: false,
|
||||
asynchronous: false,
|
||||
show: visible,
|
||||
});
|
||||
}
|
||||
let body = null,
|
||||
segments = [],
|
||||
revision = -1,
|
||||
@@ -68,7 +115,10 @@ export function createContactTrailRenderer(scene) {
|
||||
completed = -1;
|
||||
lastSecond = -1;
|
||||
active = null;
|
||||
if (body) scene.primitives.remove(body);
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = null;
|
||||
head.show = backing.show = false;
|
||||
if (body) collection.remove(body);
|
||||
body = null;
|
||||
const instances = [];
|
||||
attributeIds.length = 0;
|
||||
@@ -80,18 +130,13 @@ export function createContactTrailRenderer(scene) {
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
id: `${i}:${lane}`,
|
||||
geometry: new Cesium.PolylineGeometry({
|
||||
positions: segment.positions,
|
||||
width: lane ? 2 : 4,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
geometry: geometry(segment.positions, lane ? 3 : 5),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint(0.8, !lane),
|
||||
),
|
||||
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint(0.8, !lane),
|
||||
tint(0.55),
|
||||
),
|
||||
show: new Cesium.ShowGeometryInstanceAttribute(false),
|
||||
},
|
||||
@@ -100,20 +145,12 @@ export function createContactTrailRenderer(scene) {
|
||||
}
|
||||
}
|
||||
if (instances.length) {
|
||||
const depthFailAppearance = new Cesium.PolylineColorAppearance({
|
||||
translucent: true,
|
||||
fragmentShaderSource:
|
||||
'in vec4 v_color; void main() { out_FragColor = czm_gammaCorrect(vec4(v_color.rgb, v_color.a * 0.2)); }',
|
||||
});
|
||||
body = scene.primitives.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
depthFailAppearance,
|
||||
allowPicking: false,
|
||||
asynchronous: false,
|
||||
show: visible,
|
||||
}),
|
||||
body = collection.add(
|
||||
primitive(
|
||||
instances,
|
||||
new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
),
|
||||
);
|
||||
rebuilds++;
|
||||
}
|
||||
@@ -122,6 +159,7 @@ export function createContactTrailRenderer(scene) {
|
||||
if (!sample || !renderedPosition) {
|
||||
head.show = false;
|
||||
backing.show = false;
|
||||
if (headPrimitive) headPrimitive.show = false;
|
||||
return;
|
||||
}
|
||||
const second = Math.floor(sample.displayT / 1000);
|
||||
@@ -152,8 +190,10 @@ export function createContactTrailRenderer(scene) {
|
||||
attributeIds[i][lane],
|
||||
);
|
||||
if (!attributes) continue;
|
||||
attributes.show[0] = i <= done ? 1 : 0;
|
||||
attributes.show = attributes.show;
|
||||
// Cesium getters return copies. Write back the array we changed.
|
||||
const show = attributes.show;
|
||||
show[0] = i <= done ? 1 : 0;
|
||||
attributes.show = show;
|
||||
}
|
||||
}
|
||||
completed = done;
|
||||
@@ -166,16 +206,20 @@ export function createContactTrailRenderer(scene) {
|
||||
attributeIds[i][lane],
|
||||
);
|
||||
if (!attributes) continue;
|
||||
const rgba = attributes.color;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(alpha, !lane),
|
||||
attributes.color,
|
||||
rgba,
|
||||
);
|
||||
attributes.color = attributes.color;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(alpha, !lane),
|
||||
attributes.depthFailColor,
|
||||
);
|
||||
attributes.depthFailColor = attributes.depthFailColor;
|
||||
attributes.color = rgba;
|
||||
if (!ground) {
|
||||
const depthRgba = attributes.depthFailColor;
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
tint(0.55),
|
||||
depthRgba,
|
||||
);
|
||||
attributes.depthFailColor = depthRgba;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastSecond = second;
|
||||
@@ -183,16 +227,43 @@ export function createContactTrailRenderer(scene) {
|
||||
const show = visible && !!segment && sample.fraction < 1;
|
||||
head.show = show;
|
||||
backing.show = show;
|
||||
if (headPrimitive) headPrimitive.show = show;
|
||||
if (!show) return;
|
||||
if (active !== segment) {
|
||||
active = segment;
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = collection.add(
|
||||
primitive(
|
||||
new Cesium.GeometryInstance({
|
||||
geometry: geometry(segment.positions, 5, true),
|
||||
}),
|
||||
new Cesium.PolylineMaterialAppearance({
|
||||
material,
|
||||
translucent: true,
|
||||
}),
|
||||
new Cesium.PolylineMaterialAppearance({
|
||||
material: depthMaterial,
|
||||
translucent: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
headRebuilds++;
|
||||
headPositions[0] = segment.positions[0];
|
||||
headPositions[1] = endpoint;
|
||||
}
|
||||
Cesium.Cartesian3.clone(renderedPosition, endpoint);
|
||||
head.positions = headPositions;
|
||||
backing.positions = headPositions;
|
||||
Cesium.Color.clone(tint(0.8), head.material.uniforms.color);
|
||||
material.uniforms.fraction = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(sample.displayT - segment.fromT) / (segment.toT - segment.fromT),
|
||||
),
|
||||
);
|
||||
depthMaterial.uniforms.fraction = material.uniforms.fraction;
|
||||
Cesium.Color.clone(tint(0.8), material.uniforms.color);
|
||||
Cesium.Color.clone(tint(0.55), depthMaterial.uniforms.color);
|
||||
}
|
||||
return {
|
||||
replaceHistory,
|
||||
@@ -205,12 +276,15 @@ export function createContactTrailRenderer(scene) {
|
||||
},
|
||||
setVisible(next) {
|
||||
visible = next;
|
||||
heads.show = next;
|
||||
if (headPrimitive) headPrimitive.show = next && head.show;
|
||||
if (body) body.show = next;
|
||||
},
|
||||
destroy() {
|
||||
if (body) scene.primitives.remove(body);
|
||||
scene.primitives.remove(heads);
|
||||
if (body) collection.remove(body);
|
||||
if (headPrimitive) collection.remove(headPrimitive);
|
||||
headPrimitive = null;
|
||||
material.destroy();
|
||||
depthMaterial.destroy();
|
||||
body = null;
|
||||
segments = [];
|
||||
},
|
||||
@@ -222,6 +296,12 @@ export function createContactTrailRenderer(scene) {
|
||||
body,
|
||||
head,
|
||||
backing,
|
||||
headPrimitive,
|
||||
headStrategy: ground
|
||||
? 'draped corridor with material clip'
|
||||
: 'depth-fail corridor with material clip',
|
||||
ground,
|
||||
headRebuilds,
|
||||
entitiesAdded: 0,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { TRAIL_VERTEX_LIMIT } from '../layers/transit/trails.js';
|
||||
import {
|
||||
createContactTrailRenderer,
|
||||
trailAlpha,
|
||||
} from './contactTrailRenderer.js';
|
||||
|
||||
function fixture(t) {
|
||||
function fixture(t, ground = false) {
|
||||
for (const key of [
|
||||
'HTMLCanvasElement',
|
||||
'HTMLImageElement',
|
||||
@@ -20,9 +22,21 @@ function fixture(t) {
|
||||
else globalThis[key] = prior;
|
||||
});
|
||||
}
|
||||
const width = Cesium.ContextLimits._maximumAliasedLineWidth;
|
||||
Cesium.ContextLimits._maximumAliasedLineWidth = 1;
|
||||
t.after(() => {
|
||||
Cesium.ContextLimits._maximumAliasedLineWidth = width;
|
||||
});
|
||||
const primitives = new Cesium.PrimitiveCollection();
|
||||
t.after(() => primitives.destroy());
|
||||
return { primitives };
|
||||
const groundPrimitives = new Cesium.PrimitiveCollection();
|
||||
t.after(() => groundPrimitives.destroy());
|
||||
return {
|
||||
primitives,
|
||||
groundPrimitives,
|
||||
frameState: { context: { depthTexture: ground } },
|
||||
globe: { show: false },
|
||||
};
|
||||
}
|
||||
test('body clips future subdivisions, head ends at exact marker, and frames retain geometry', (t) => {
|
||||
const scene = fixture(t),
|
||||
@@ -62,7 +76,9 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta
|
||||
for (const instance of body.geometryInstances) {
|
||||
assert.deepEqual(
|
||||
instance.attributes.depthFailColor.value,
|
||||
instance.attributes.color.value,
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55),
|
||||
),
|
||||
);
|
||||
}
|
||||
body._batchTableAttributeIndices = { color: 0, depthFailColor: 1 };
|
||||
@@ -81,7 +97,18 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta
|
||||
);
|
||||
// Simulate Cesium's completed upload; exercise public attribute writes.
|
||||
body._ready = true;
|
||||
body.getGeometryInstanceAttributes = (id) => attributes.get(id);
|
||||
body.getGeometryInstanceAttributes = (id) => {
|
||||
const stored = attributes.get(id),
|
||||
accessor = {};
|
||||
for (const name of ['show', 'color', 'depthFailColor'])
|
||||
Object.defineProperty(accessor, name, {
|
||||
get: () => stored[name].slice(),
|
||||
set: (value) => {
|
||||
stored[name] = value.slice();
|
||||
},
|
||||
});
|
||||
return accessor;
|
||||
};
|
||||
const marker = Cesium.Cartesian3.lerp(
|
||||
positions[1],
|
||||
positions[2],
|
||||
@@ -95,10 +122,19 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta
|
||||
[1, 1, 0, 0],
|
||||
);
|
||||
for (const a of attributes.values())
|
||||
assert.deepEqual(a.depthFailColor, a.color);
|
||||
assert.deepEqual(
|
||||
a.depthFailColor,
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55),
|
||||
),
|
||||
);
|
||||
const result = renderer.diagnostics();
|
||||
assert.equal(result.body, body);
|
||||
assert.equal(result.rebuilds, 1);
|
||||
assert.equal(result.headRebuilds, 1, '120 frames retain head geometry');
|
||||
assert.equal(result.head.material.uniforms.fraction, 0.5);
|
||||
assert.equal(result.head.width, 3);
|
||||
assert.equal(result.backing.width, 5);
|
||||
assert.ok(Cesium.Cartesian3.equals(result.head.positions.at(-1), marker));
|
||||
assert.equal(result.head.positions.length, 2);
|
||||
renderer.setDisplaySample(
|
||||
@@ -120,24 +156,130 @@ test('trail age curve uses the specified four alpha anchors', () => {
|
||||
assert.ok(Math.abs(trailAlpha(60000) - 0.625) < 1e-10);
|
||||
});
|
||||
|
||||
test('the maximum prepared body stays below the 2 MiB geometry budget', () => {
|
||||
const geometry = Cesium.PolylineGeometry.createGeometry(
|
||||
new Cesium.PolylineGeometry({
|
||||
positions: [
|
||||
Cesium.Cartesian3.fromDegrees(-71, 42, 11.5),
|
||||
Cesium.Cartesian3.fromDegrees(-71, 42.0002, 11.5),
|
||||
],
|
||||
width: 2,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
test('ground body and clipped head stay inside rebuild and frame geometry budgets', async () => {
|
||||
Cesium.ApproximateTerrainHeights._terrainHeights = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'../../node_modules/@cesium/engine/Source/Assets/approximateTerrainHeights.json',
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
);
|
||||
const bytes =
|
||||
Object.values(geometry.attributes).reduce(
|
||||
(total, attribute) => total + (attribute?.values?.byteLength || 0),
|
||||
const positions = [
|
||||
Cesium.Cartesian3.fromDegrees(-97.7431, 30.267),
|
||||
Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672),
|
||||
];
|
||||
const start = performance.now();
|
||||
const instances = async (count) =>
|
||||
Promise.all(
|
||||
Array.from(
|
||||
{ length: count },
|
||||
async (_, i) =>
|
||||
new Cesium.GeometryInstance({
|
||||
id: i,
|
||||
geometry: await Cesium.GroundPolylineGeometry.createGeometry(
|
||||
new Cesium.GroundPolylineGeometry({
|
||||
positions,
|
||||
width: i % 2 ? 3 : 5,
|
||||
granularity: 0,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const pipelineBytes = async (count) => {
|
||||
// Match GroundPolylinePrimitive's internal Primitive options and the
|
||||
// application's morph-capable scene, including batch IDs and encoding.
|
||||
const result = Cesium.PrimitivePipeline.combineGeometry({
|
||||
instances: await instances(count),
|
||||
projection: new Cesium.GeographicProjection(),
|
||||
ellipsoid: Cesium.Ellipsoid.WGS84,
|
||||
modelMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
|
||||
elementIndexUintSupported: true,
|
||||
scene3DOnly: false,
|
||||
vertexCacheOptimize: false,
|
||||
compressVertices: false,
|
||||
createPickOffsets: false,
|
||||
});
|
||||
return result.geometries.reduce(
|
||||
(sum, geometry) =>
|
||||
sum +
|
||||
Object.values(geometry.attributes).reduce(
|
||||
(n, a) => n + (a?.values?.byteLength || 0),
|
||||
0,
|
||||
) +
|
||||
geometry.indices.byteLength,
|
||||
0,
|
||||
) + geometry.indices.byteLength;
|
||||
assert.ok(bytes * 2 * 2047 < 2 * 1024 * 1024);
|
||||
);
|
||||
};
|
||||
const bytes = await pipelineBytes(1);
|
||||
const bodyInstances = 2 * (TRAIL_VERTEX_LIMIT - 1);
|
||||
const bodyBytes = await pipelineBytes(bodyInstances);
|
||||
assert.equal(bytes, 1576, 'measure final encoded and batched geometry');
|
||||
assert.equal(bodyBytes, 2014128);
|
||||
const instanceReserve = 64 * bodyInstances;
|
||||
assert.ok(bodyBytes + instanceReserve <= 2 * 1024 * 1024);
|
||||
assert.ok(
|
||||
bytes <= 2048,
|
||||
'subdivision crossings stay below 2 KiB of head geometry',
|
||||
);
|
||||
console.log(
|
||||
`trail final pipeline: ${bytes} bytes/head crossing, 0 bytes/ordinary head frame, ${bodyBytes} bytes/body (${bodyInstances} instances), ${instanceReserve} bytes instance reserve; geometry CPU ${(performance.now() - start).toFixed(3)} ms`,
|
||||
);
|
||||
});
|
||||
|
||||
test('supported scenes drape body and clipped head on 3D tiles and retain head geometry', (t) => {
|
||||
const scene = fixture(t, true),
|
||||
renderer = createContactTrailRenderer(scene);
|
||||
const positions = [
|
||||
Cesium.Cartesian3.fromDegrees(-97.7431, 30.267),
|
||||
Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672),
|
||||
];
|
||||
renderer.replaceHistory({
|
||||
revision: 1,
|
||||
segments: [{ fromSeq: 0, toSeq: 1, fromT: 0, toT: 20000, positions }],
|
||||
});
|
||||
const first = renderer.diagnostics();
|
||||
assert.ok(first.body instanceof Cesium.GroundPolylinePrimitive);
|
||||
assert.equal(first.body.classificationType, Cesium.ClassificationType.BOTH);
|
||||
assert.ok(scene.groundPrimitives.contains(first.body));
|
||||
for (const instance of first.body.geometryInstances)
|
||||
assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry);
|
||||
for (let i = 1; i <= 120; i++)
|
||||
renderer.setDisplaySample(
|
||||
{ displayT: i * 100, fromSeq: 0, toSeq: 1, fraction: i / 200 },
|
||||
positions[1],
|
||||
);
|
||||
const result = renderer.diagnostics();
|
||||
assert.equal(result.body, first.body);
|
||||
assert.equal(result.headRebuilds, 1);
|
||||
assert.ok(result.headPrimitive instanceof Cesium.GroundPolylinePrimitive);
|
||||
assert.equal(result.head.material.uniforms.fraction, 0.6);
|
||||
assert.match(result.head.material.shaderSource, /discard/);
|
||||
for (const stack of ['Google', 'OSM', 'Bing', 'Google']) {
|
||||
scene.globe.show = stack !== 'Google';
|
||||
renderer.setDisplaySample(
|
||||
{ displayT: 12000, fromSeq: 0, toSeq: 1, fraction: 0.6 },
|
||||
positions[1],
|
||||
);
|
||||
const retained = renderer.diagnostics();
|
||||
assert.equal(retained.body, result.body, `${stack}: retained history`);
|
||||
assert.equal(retained.headPrimitive, result.headPrimitive);
|
||||
for (const primitive of [retained.body, retained.headPrimitive]) {
|
||||
assert.equal(
|
||||
primitive.classificationType,
|
||||
Cesium.ClassificationType.BOTH,
|
||||
stack,
|
||||
);
|
||||
assert.equal(primitive.show, true, stack);
|
||||
}
|
||||
}
|
||||
renderer.setVisible(false);
|
||||
assert.equal(result.body.show, false);
|
||||
assert.equal(result.headPrimitive.show, false);
|
||||
renderer.destroy();
|
||||
assert.equal(scene.groundPrimitives.length, 0);
|
||||
assert.equal(scene.primitives.length, 0);
|
||||
});
|
||||
|
||||
test('subdivision crossings only write the changed show range and age colors once per second', (t) => {
|
||||
@@ -206,5 +348,10 @@ test('subdivision crossings only write the changed show range and age colors onc
|
||||
display(1200);
|
||||
assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 });
|
||||
for (const a of attributes.values())
|
||||
assert.deepEqual(a.color, a.depthFailColor);
|
||||
assert.deepEqual(
|
||||
a.depthFailColor,
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
Cesium.Color.fromCssColorString('#FF4538').withAlpha(0.55),
|
||||
),
|
||||
);
|
||||
});
|
||||
+13
-12
@@ -189,20 +189,21 @@ export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
|
||||
let uri = _iconCache.get(key);
|
||||
if (!uri) {
|
||||
const size = VIEW + 2 * frame.pad;
|
||||
// Sensor signatures use a broad rounded envelope, without dark interior
|
||||
// joints or a second outline eating into the hot core at display resolution.
|
||||
const halfW = k === 'bus' || k === 'ferry' || k === 'unknown' ? 48 : 47;
|
||||
const halfH = 48;
|
||||
const radius = k === 'subway' ? 8 : k === 'rail' ? 12 : 14;
|
||||
const capsule = `<path d="M${-halfW + radius},${-halfH} H${halfW - radius} Q${halfW},${-halfH} ${halfW},${-halfH + radius} V${halfH - radius} Q${halfW},${halfH} ${halfW - radius},${halfH} H${-halfW + radius} Q${-halfW},${halfH} ${-halfW},${halfH - radius} V${-halfH + radius} Q${-halfW},${-halfH} ${-halfW + radius},${-halfH} Z" fill="white" />`;
|
||||
const body = profile === 'mono' ? capsule : BODIES[k];
|
||||
// Reuse the exact outer path. Sensors fill its interior without panel ink.
|
||||
const silhouette = BODIES[k].match(/<path d="([^"]+)"/)[1];
|
||||
const body =
|
||||
profile === 'mono'
|
||||
? `<path d="${silhouette}" fill="white" />`
|
||||
: BODIES[k];
|
||||
const halo =
|
||||
frame.units > 0
|
||||
? body.replace(
|
||||
/<path d="([^"]+)"\s+fill="white" [^/]*\/>/,
|
||||
(_m, d) =>
|
||||
`<path d="${d}" fill="none" stroke="${profile === 'mono' ? '#000000' : '#05080C'}" stroke-opacity="${profile === 'mono' ? 1 : 0.95}" stroke-width="${frame.units.toFixed(2)}" stroke-linejoin="round"/>`,
|
||||
)
|
||||
? profile === 'mono'
|
||||
? `<path d="${silhouette}" fill="none" stroke="#05080C" stroke-opacity="1" stroke-width="${frame.units.toFixed(2)}" stroke-linejoin="round"/>`
|
||||
: body.replace(
|
||||
/<path d="([^"]+)"\s+fill="white" [^/]*\/>/,
|
||||
(_m, d) =>
|
||||
`<path d="${d}" fill="none" stroke="#05080C" stroke-opacity="0.95" stroke-width="${frame.units.toFixed(2)}" stroke-linejoin="round"/>`,
|
||||
)
|
||||
: '';
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(px * frame.ratio)}" height="${Math.round(px * frame.ratio)}" viewBox="${-frame.pad} ${-frame.pad} ${size} ${size}">` +
|
||||
|
||||
+128
-24
@@ -32,10 +32,10 @@ test('a haloed glyph draws the dark ring under the shipped white body', () => {
|
||||
assert.notEqual(plain, haloed);
|
||||
assert.match(
|
||||
haloed,
|
||||
/stroke="#000000" stroke-opacity="1" stroke-width="19\.20"/,
|
||||
/stroke="#05080C" stroke-opacity="1" stroke-width="14\.77"/,
|
||||
);
|
||||
assert.ok(
|
||||
haloed.indexOf('#000000') < haloed.indexOf('fill="white"'),
|
||||
haloed.indexOf('#05080C') < haloed.indexOf('fill="white"'),
|
||||
'ring first, body on top',
|
||||
);
|
||||
assert.match(haloed, /viewBox="-\d+ -\d+ \d+ \d+"/, 'padded frame');
|
||||
@@ -97,40 +97,144 @@ test('all 36 variants retain the specified final halo at both display sizes', as
|
||||
assert.equal(transitIconCacheSize(), 36);
|
||||
});
|
||||
|
||||
test('sensor bodies cover sixty percent of the padded display with opaque white', async () => {
|
||||
test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => {
|
||||
const sharp = (await import('sharp')).default;
|
||||
const { presetSpriteScale, presetSpriteOutlinePx } =
|
||||
await import('./transitPresetStyle.js');
|
||||
const rasterBody = async (svg) => {
|
||||
// Compare fills in the same frame; the separately tested external halo is excluded.
|
||||
const d =
|
||||
[...svg.matchAll(/<path d="([^"]+)" fill="white"/g)][0]?.[1] ??
|
||||
svg.match(/<path d="([^"]+)"\s+fill="white"/)[1];
|
||||
const { data, info } = await sharp(
|
||||
Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="-48 -48 96 96"><path d="${d}" fill="white"/></svg>`,
|
||||
),
|
||||
)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const mask = [];
|
||||
let minX = info.width,
|
||||
minY = info.height,
|
||||
maxX = 0,
|
||||
maxY = 0;
|
||||
for (let y = 0; y < info.height; y++)
|
||||
for (let x = 0; x < info.width; x++) {
|
||||
const alpha = data[(y * info.width + x) * 4 + 3];
|
||||
mask.push(alpha);
|
||||
if (alpha > 127) {
|
||||
minX = Math.min(minX, x);
|
||||
maxX = Math.max(maxX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
return { mask, aspect: (maxX - minX + 1) / (maxY - minY + 1) };
|
||||
};
|
||||
for (const selected of [false, true])
|
||||
for (const kind of TRANSIT_ICON_KINDS) {
|
||||
const normal = await rasterBody(
|
||||
decode(transitIcon(kind, selected ? 96 : 48, { style: 'normal' })),
|
||||
);
|
||||
for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) {
|
||||
const svg = decode(transitIcon(kind, selected ? 96 : 48, { style }));
|
||||
const mono = await rasterBody(svg);
|
||||
assert.deepEqual(
|
||||
mono.mask,
|
||||
normal.mask,
|
||||
`${kind}/${style}: no envelope`,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(mono.aspect / normal.aspect - 1) <= 0.05,
|
||||
`${kind}/${style}: aspect`,
|
||||
);
|
||||
const display =
|
||||
(selected ? 30 : 20) * presetSpriteScale(style, selected);
|
||||
const crt = (selected ? 30 : 20) * presetSpriteScale('retro', selected);
|
||||
assert.equal(display, crt);
|
||||
const frame = haloFrame(
|
||||
presetSpriteOutlinePx(style, selected),
|
||||
display,
|
||||
);
|
||||
const crtFrame = haloFrame(
|
||||
presetSpriteOutlinePx('retro', selected),
|
||||
crt,
|
||||
);
|
||||
assert.equal(
|
||||
(display * frame.ratio) / frame.ratio,
|
||||
(crt * crtFrame.ratio) / crtFrame.ratio,
|
||||
'unpadded silhouette matches CRT; only external halo padding grows',
|
||||
);
|
||||
assert.equal(
|
||||
(svg.match(/<path /g) || []).length,
|
||||
2,
|
||||
'only outer halo and opaque silhouette',
|
||||
);
|
||||
assert.match(svg, /fill="white" \/>/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('sensor raster halo stays dark across its screen-space band on a white roof', async () => {
|
||||
const sharp = (await import('sharp')).default;
|
||||
const { presetSpriteScale, presetSpriteOutlinePx } =
|
||||
await import('./transitPresetStyle.js');
|
||||
const { reduceSensorContrast } =
|
||||
await import('../layers/transit/qaMetrics.js');
|
||||
const sides = {
|
||||
bus: 17,
|
||||
tram: 10,
|
||||
subway: 11.5,
|
||||
rail: 10.5,
|
||||
ferry: 12.5,
|
||||
unknown: 10,
|
||||
};
|
||||
for (const selected of [false, true]) {
|
||||
const pixels = [];
|
||||
for (const kind of TRANSIT_ICON_KINDS) {
|
||||
const display =
|
||||
(selected ? 30 : 20) * presetSpriteScale('thermal', selected);
|
||||
assert.equal(display, selected ? 36 : 30);
|
||||
assert.equal(presetSpriteOutlinePx('thermal', selected), 3);
|
||||
const frame = haloFrame(
|
||||
presetSpriteOutlinePx('thermal', selected),
|
||||
display,
|
||||
);
|
||||
const size = Math.round(display * frame.ratio * 8);
|
||||
const svg = decode(
|
||||
transitIcon(kind, selected ? 96 : 48, { style: 'thermal' }),
|
||||
);
|
||||
const frame = haloFrame(3, display);
|
||||
const width = Math.round(display * frame.ratio);
|
||||
const { data, info } = await sharp(Buffer.from(svg))
|
||||
.resize(width, width)
|
||||
.ensureAlpha()
|
||||
.resize(size, size)
|
||||
.flatten({ background: '#ffffff' })
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
let white = 0;
|
||||
for (let i = 0; i < data.length; i += 4)
|
||||
if (
|
||||
data[i] >= 240 &&
|
||||
data[i + 1] >= 240 &&
|
||||
data[i + 2] >= 240 &&
|
||||
data[i + 3] >= 240
|
||||
)
|
||||
white++;
|
||||
assert.ok(
|
||||
white / (info.width * info.height) >= 0.6,
|
||||
`${kind}/${display}: white ${white / (info.width * info.height)}`,
|
||||
);
|
||||
const c = (Math.floor(width / 2) * width + Math.floor(width / 2)) * 4;
|
||||
assert.deepEqual([...data.subarray(c, c + 4)], [255, 255, 255, 255]);
|
||||
const sample = (offsetPx) => {
|
||||
const x = Math.floor(
|
||||
size / 2 + (offsetPx * size) / (display * frame.ratio),
|
||||
);
|
||||
const i = (Math.floor(size / 2) * info.width + x) * info.channels;
|
||||
return (
|
||||
(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255
|
||||
);
|
||||
};
|
||||
// The body stays at CRT size; the ring must cover this fixed external band.
|
||||
const ringMin = sample((sides[kind] * display) / 96 + 1.25);
|
||||
pixels.push({
|
||||
key: kind,
|
||||
verified: true,
|
||||
centre: sample(0),
|
||||
ringMin,
|
||||
ringMax: 1 - ringMin,
|
||||
background: 1,
|
||||
});
|
||||
}
|
||||
const white = reduceSensorContrast(pixels, 'white', 'thermal');
|
||||
assert.ok(white.pass, JSON.stringify({ selected, pixels, white }));
|
||||
const black = reduceSensorContrast(
|
||||
pixels.map((p) => ({ ...p, centre: 1 - p.centre })),
|
||||
'black',
|
||||
'thermal',
|
||||
);
|
||||
assert.ok(black.pass, JSON.stringify({ selected, black }));
|
||||
}
|
||||
});
|
||||
@@ -12,7 +12,7 @@
|
||||
* Encoding per profile, the traffic-dot precedent applied to a glyph:
|
||||
* - `mono` (surveillance/NVG, thermal/FLIR, noir, cockpit nvg/thermal): the
|
||||
* sprite is WHITE — the hottest end of grayscale FLIR and of the Ironbow
|
||||
* ramp, and the brightest phosphor under NVG — drawn larger, with a dark
|
||||
* ramp, and the brightest phosphor under NVG — at CRT size, with a thin dark
|
||||
* halo sized in SCREEN pixels so it keeps local contrast on a bright road
|
||||
* through any luma mapping. Black-hot FLIR inverts it, deliberately: a
|
||||
* black sprite with a light ring is what a hot object looks like there.
|
||||
@@ -52,8 +52,8 @@ const PROFILE_BY_STYLE = Object.freeze({
|
||||
const SPRITE_STYLE = Object.freeze({
|
||||
mono: Object.freeze({
|
||||
rgba: Object.freeze([255, 255, 255, 1]),
|
||||
scale: 1.5,
|
||||
outlinePx: 3,
|
||||
scale: 1.3,
|
||||
outlinePx: 2,
|
||||
}),
|
||||
crt: Object.freeze({
|
||||
scale: 1.3,
|
||||
@@ -111,8 +111,6 @@ export function presetSpriteRgba(styleName, mode) {
|
||||
* @returns {number} 1 under the normal profile.
|
||||
*/
|
||||
export function presetSpriteScale(styleName, selected = false) {
|
||||
if (transitStyleProfile(styleName) === 'mono')
|
||||
return selected ? 36 / 30 : 30 / 20;
|
||||
return SPRITE_STYLE[transitStyleProfile(styleName)]?.scale ?? 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,13 +45,13 @@ test('mono: every mode is a white core, larger, with a dark halo in screen pixel
|
||||
`${style} ${mode}`,
|
||||
);
|
||||
}
|
||||
assert.ok(presetSpriteScale(style) === 1.5, `${style} is drawn larger`);
|
||||
// At least a whole screen pixel and a half of ring around a five-pixel
|
||||
// body: thinner than that vanishes through blur and pixelation.
|
||||
assert.ok(
|
||||
presetSpriteOutlinePx(style) >= 1.5,
|
||||
`${style} halo is ${presetSpriteOutlinePx(style)} px`,
|
||||
);
|
||||
for (const selected of [false, true]) {
|
||||
assert.equal(
|
||||
presetSpriteScale(style, selected),
|
||||
presetSpriteScale('retro', selected),
|
||||
);
|
||||
assert.equal(presetSpriteOutlinePx(style, selected), 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2178,7 +2178,7 @@ test('under FLIR and NVG every sprite is white-hot with a dark halo, and selecti
|
||||
assert.notEqual(entry.marker.image, plainImage, 'a haloed raster');
|
||||
assert.match(
|
||||
Buffer.from(entry.marker.image.split(',')[1], 'base64').toString(),
|
||||
/#000000/,
|
||||
/#05080C/,
|
||||
'with the dark ring',
|
||||
);
|
||||
assert.ok(entry.marker.width > plainWidth * 1.2, 'and larger');
|
||||
@@ -2547,7 +2547,7 @@ test('selected trail and marker share revised start floors, with unknown history
|
||||
assert.ok(
|
||||
Cesium.Cartographic.fromCartesian(entry.marker.position).height > 30,
|
||||
);
|
||||
assert.ok(entry.trailVertices <= 2048);
|
||||
assert.ok(entry.trailVertices <= 640);
|
||||
parts.selection.clearSelection();
|
||||
assert.equal(parts.trails.diagnostics(), null);
|
||||
});
|
||||
@@ -2659,7 +2659,7 @@ test('a long selected history shortens its oldest geometry before sacrificing th
|
||||
parts.rendering.sampleIdle(entry);
|
||||
parts.selection.selectVehicle(entry.key);
|
||||
assert.equal(entry.trailTruncated, true);
|
||||
assert.ok(entry.trailVertices <= 2048);
|
||||
assert.ok(entry.trailVertices <= 640);
|
||||
assert.ok(
|
||||
entry.displayPaths.has(entry.sample.fromSeq),
|
||||
'active path is prepared first',
|
||||
@@ -2671,6 +2671,63 @@ test('a long selected history shortens its oldest geometry before sacrificing th
|
||||
);
|
||||
});
|
||||
|
||||
test('sparse rail reports preserve a bounded marker corridor with and without selection', async (t) => {
|
||||
const app = harness(t);
|
||||
const parts = app.layer._transitPartsForTest();
|
||||
const { seek } = await import('../../data/contactPlayback.js');
|
||||
let latitude = 42.36 - 16000 / 111320;
|
||||
let timestamp = reported();
|
||||
app.serve('mbta', () => ({
|
||||
status: 200,
|
||||
body: snapshot(
|
||||
'mbta',
|
||||
'MBTA',
|
||||
[vehicle('sparse', latitude, -71.06, timestamp, { routeId: 'CR-1' })],
|
||||
{ fetchedAt: Date.now() },
|
||||
),
|
||||
}));
|
||||
app.layer.enable(app.viewer);
|
||||
await app.layer.update();
|
||||
app.settle();
|
||||
app.advance(180000);
|
||||
latitude = 42.36;
|
||||
timestamp += 180;
|
||||
await app.layer.update();
|
||||
app.settle();
|
||||
const entry = app.vehicles()[0];
|
||||
assert.equal(entry.mode, 'rail');
|
||||
assert.equal(entry.track.count, 2, 'both 88.9 m/s reports pass admission');
|
||||
assert.equal(entry.fixes[1].t - entry.fixes[0].t, 180000);
|
||||
for (let i = 0; i < 1500; i++)
|
||||
app.ground._warm.set(app.ground._key(42.2 + i * 0.0002, -71.06), 12);
|
||||
seek(entry.track, entry.fixes[0].t + 90000, {
|
||||
wallNowMs: Date.now(),
|
||||
monoNowMs: performance.now(),
|
||||
});
|
||||
parts.rendering.sampleIdle(entry);
|
||||
for (const selected of [false, true, false]) {
|
||||
if (selected) parts.selection.selectVehicle(entry.key);
|
||||
else parts.selection.clearSelection();
|
||||
parts.trails.prepareEntry(entry);
|
||||
const path = entry.displayPaths.get(entry.sample.fromSeq);
|
||||
assert.ok(path, 'active corridor survives the body cap');
|
||||
assert.ok(path.positions.length <= 640);
|
||||
const position = parts.trails.samplePosition(
|
||||
entry,
|
||||
entry.sample,
|
||||
new Cesium.Cartesian3(),
|
||||
true,
|
||||
);
|
||||
assert.ok(
|
||||
position && [position.x, position.y, position.z].every(Number.isFinite),
|
||||
);
|
||||
parts.rendering.placeSample(entry, true);
|
||||
assert.equal(entry.marker.show, true);
|
||||
assert.ok(Cesium.Cartesian3.equals(entry.marker.position, position));
|
||||
assert.ok(entry.trailVertices <= 640);
|
||||
}
|
||||
});
|
||||
|
||||
test('missing rectangle still rejects the far side, and frustum rejection wins', async (t) => {
|
||||
const app = harness(t, { altitude: 100_000 });
|
||||
app.viewer.camera.computeViewRectangle = () => undefined;
|
||||
@@ -2783,13 +2840,13 @@ test('every styling path retains the mode palette and exact unpadded size', asyn
|
||||
parts.rendering.paintMode(entry, 'subway');
|
||||
check('#FF4538', 30);
|
||||
app.style('thermal');
|
||||
check('#FFFFFF', 36);
|
||||
check('#FFFFFF', 39);
|
||||
parts.selection.clearSelection();
|
||||
check('#FFFFFF', 30);
|
||||
check('#FFFFFF', 26);
|
||||
app.vision('nvg', true);
|
||||
check('#FFFFFF', 30);
|
||||
check('#FFFFFF', 26);
|
||||
parts.selection.selectVehicle(entry.key);
|
||||
check('#FFFFFF', 36);
|
||||
check('#FFFFFF', 39);
|
||||
app.vision('normal', false);
|
||||
check('#FF4538', 30);
|
||||
parts.selection.clearSelection();
|
||||
@@ -3673,3 +3730,131 @@ test('transit polls the supplied source and cancels it when disabled', async (t)
|
||||
await app.layer.update();
|
||||
assert.equal(calls.length, before);
|
||||
});
|
||||
|
||||
test('selected trail QA fixture survives repeated live CapMetro snapshots', async (t) => {
|
||||
const { TRAIL_VISIBILITY_FEED } =
|
||||
await import('../../../scripts/qa-transit-scenes.mjs');
|
||||
const { getRegisteredTransitFeed } =
|
||||
await import('../../data/transitFeeds.js');
|
||||
const app = harness(t, { at: { lat: 30.267, lon: -97.7431 } });
|
||||
app.layer.enable(app.viewer);
|
||||
const { ingestion, selection } = app.layer._transitPartsForTest();
|
||||
const now = Date.now();
|
||||
ingestion.applySnapshot(
|
||||
TRAIL_VISIBILITY_FEED,
|
||||
{
|
||||
fetchedAt: now,
|
||||
vehicles: [vehicle('trail-visible', 30.267, -97.7431, now / 1000)],
|
||||
},
|
||||
{ stale: false },
|
||||
);
|
||||
const key = `${TRAIL_VISIBILITY_FEED.id}:trail-visible`;
|
||||
selection.selectVehicle(key);
|
||||
assert.equal(app.state()._selectedKey, key);
|
||||
for (let poll = 0; poll <= MISSED_POLLS_TO_DROP; poll++) {
|
||||
ingestion.applySnapshot(
|
||||
getRegisteredTransitFeed('capmetro-austin'),
|
||||
{
|
||||
fetchedAt: now + poll,
|
||||
vehicles: [vehicle('live-bus', 30.268, -97.7431, now / 1000)],
|
||||
},
|
||||
{ stale: false },
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
app.state()._vehicles.has(key),
|
||||
'live polls must not evict the selected fixture',
|
||||
);
|
||||
assert.equal(app.state()._selectedKey, key);
|
||||
assert.ok(
|
||||
app.state()._vehicles.has('capmetro-austin:live-bus'),
|
||||
'live ingestion still runs',
|
||||
);
|
||||
});
|
||||
|
||||
for (const jump of ['setView', 'flyTo']) {
|
||||
test(`${jump} recovers a cold hidden vehicle within one visibility interval`, async (t) => {
|
||||
const app = harness(t, { floorAt: () => undefined });
|
||||
let floor;
|
||||
app.viewer.scene.globe.show = true;
|
||||
app.viewer.scene.globe.getHeight = () => floor;
|
||||
app.serve('mbta', () => ({
|
||||
status: 200,
|
||||
body: snapshot('mbta', 'MBTA', [
|
||||
vehicle('cold', 42.36, -71.06, reported()),
|
||||
]),
|
||||
}));
|
||||
app.layer.enable(app.viewer);
|
||||
await app.layer.update();
|
||||
const entry = app.vehicles()[0];
|
||||
assert.equal(entry.marker.show, false);
|
||||
assert.equal(entry.heightPending, true);
|
||||
// Surface tiles are now loaded at the camera destination. No poll, floor
|
||||
// timer or preRender is allowed to bootstrap the visibility recovery.
|
||||
clearTimeout(app.state()._floorTimer);
|
||||
app.state()._floorTimer = null;
|
||||
app.viewer.camera[jump] = () => {
|
||||
floor = 12;
|
||||
app.viewer.camera.changed.raiseEvent();
|
||||
};
|
||||
app.viewer.camera[jump]();
|
||||
app.advance(250);
|
||||
assert.equal(entry.marker.show, true);
|
||||
assert.equal(entry.heightPending, false);
|
||||
assert.equal(entry.surfaceReady, true);
|
||||
assert.equal(entry.visibility.heightPending, false);
|
||||
assert.equal(entry.visibility.surfaceReady, true);
|
||||
});
|
||||
}
|
||||
|
||||
test('selected history is prepared while its head is hidden and remains shown outside the head frustum', async (t) => {
|
||||
const app = harness(t);
|
||||
app.layer.enable(app.viewer);
|
||||
app.layer._loadTransitFleetForTest(1, BOSTON);
|
||||
const entry = app.vehicles()[0],
|
||||
parts = app.layer._transitPartsForTest();
|
||||
entry.marker.show = false;
|
||||
app.state()._visible.delete(entry);
|
||||
app.state()._moving.delete(entry);
|
||||
parts.selection.selectVehicle(entry.key);
|
||||
const trail = parts.trails.diagnostics();
|
||||
assert.ok(trail.segments > 0);
|
||||
assert.ok(trail.body);
|
||||
assert.equal(trail.body.show, true);
|
||||
parts.trails.update();
|
||||
assert.equal(trail.body.show, true);
|
||||
app.state()._vehicles.delete(entry.key);
|
||||
parts.trails.update();
|
||||
assert.equal(trail.body.show, false);
|
||||
app.state()._vehicles.set(entry.key, entry);
|
||||
});
|
||||
|
||||
test('selected draped history prepares geometry before surface heights resolve', async (t) => {
|
||||
const app = harness(t, { floorAt: () => undefined });
|
||||
app.layer.enable(app.viewer);
|
||||
app.layer._loadTransitFleetForTest(1, BOSTON);
|
||||
const entry = app.vehicles()[0],
|
||||
parts = app.layer._transitPartsForTest();
|
||||
delete entry.qaFloorM;
|
||||
entry.marker.show = false;
|
||||
entry.heightPending = true;
|
||||
// Exercise the real GroundPolylinePrimitive branch, without a WebGL context.
|
||||
const width = Cesium.ContextLimits._maximumAliasedLineWidth;
|
||||
Cesium.ContextLimits._maximumAliasedLineWidth = 1;
|
||||
t.after(() => {
|
||||
Cesium.ContextLimits._maximumAliasedLineWidth = width;
|
||||
});
|
||||
app.viewer.scene.frameState = { context: { depthTexture: true } };
|
||||
app.viewer.scene.groundPrimitives = app.viewer.scene.primitives;
|
||||
parts.selection.selectVehicle(entry.key);
|
||||
const trail = parts.trails.diagnostics();
|
||||
assert.equal(trail.ground, true);
|
||||
assert.ok(trail.body instanceof Cesium.GroundPolylinePrimitive);
|
||||
assert.ok(trail.segments > 0);
|
||||
assert.equal(trail.body.show, true);
|
||||
assert.equal(
|
||||
entry.displayPaths.size,
|
||||
0,
|
||||
'unresolved marker corridors remain unavailable',
|
||||
);
|
||||
});
|
||||
@@ -329,3 +329,75 @@ export function reduceTrailHead(anchor, target) {
|
||||
selection: anchor?.selection ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Repeated off/on patches isolate stable, trail-correlated framebuffer changes. */
|
||||
export function reduceTrailPixels(on, off, evidence = {}) {
|
||||
const { onAgain, offAgain, samples = [], sprite, tilesReady } = evidence;
|
||||
const finitePoint = (p) =>
|
||||
p && [p.x, p.y, p.radius].every(Number.isFinite) && p.radius > 0;
|
||||
const stable = [],
|
||||
eligible = [];
|
||||
const hits = Array.from({ length: 8 }, (_, i) => {
|
||||
const point = samples[i];
|
||||
eligible[i] = !!(
|
||||
tilesReady === true &&
|
||||
finitePoint(point) &&
|
||||
point.inFront === true &&
|
||||
finitePoint(sprite) &&
|
||||
Math.hypot(point.x - sprite.x, point.y - sprite.y) >
|
||||
point.radius + sprite.radius &&
|
||||
samples
|
||||
.slice(0, i)
|
||||
.every(
|
||||
(prior) =>
|
||||
!finitePoint(prior) ||
|
||||
Math.hypot(point.x - prior.x, point.y - prior.y) >
|
||||
point.radius + prior.radius,
|
||||
)
|
||||
);
|
||||
const a = on?.[i],
|
||||
b = off?.[i],
|
||||
c = onAgain?.[i],
|
||||
d = offAgain?.[i];
|
||||
stable[i] = false;
|
||||
if (
|
||||
!eligible[i] ||
|
||||
!a?.length ||
|
||||
a.length % 4 ||
|
||||
[b, c, d].some((p) => !p || p.length !== a.length)
|
||||
)
|
||||
return false;
|
||||
let matches = 0;
|
||||
const contrast = (lit, baseline, k) => {
|
||||
const delta = Math.hypot(
|
||||
lit[k] - baseline[k],
|
||||
lit[k + 1] - baseline[k + 1],
|
||||
lit[k + 2] - baseline[k + 2],
|
||||
);
|
||||
const green = lit[k + 1] > lit[k] + 30 && lit[k + 1] > lit[k + 2] + 18;
|
||||
const dark =
|
||||
lit[k] + lit[k + 1] + lit[k + 2] <
|
||||
0.6 * (baseline[k] + baseline[k + 1] + baseline[k + 2]);
|
||||
return delta >= 40 && (green || dark);
|
||||
};
|
||||
for (let k = 0; k < a.length; k += 4) {
|
||||
for (let channel = 0; channel < 3; channel++) {
|
||||
const j = k + channel;
|
||||
if (
|
||||
![a[j], b[j], c[j], d[j]].every(
|
||||
(v) => Number.isFinite(v) && v >= 0 && v <= 255,
|
||||
) ||
|
||||
Math.abs(b[j] - d[j]) > 8 ||
|
||||
Math.abs(a[j] - c[j]) > 8 ||
|
||||
Math.abs(a[j] - b[j] - (c[j] - d[j])) > 8
|
||||
)
|
||||
return false;
|
||||
}
|
||||
if (contrast(a, b, k) && contrast(c, d, k)) matches++;
|
||||
}
|
||||
stable[i] = true;
|
||||
return matches >= 2;
|
||||
});
|
||||
const present = hits.filter(Boolean).length;
|
||||
return { hits, stable, eligible, present, total: 8, pass: present >= 6 };
|
||||
}
|
||||
@@ -328,3 +328,106 @@ test('trail-head acceptance names every missing and failed condition', async ()
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
const trailPatch = (rgb) =>
|
||||
Array.from({ length: 49 }, () => [...rgb, 255]).flat();
|
||||
const trailEvidence = (on, off) => ({
|
||||
onAgain: on,
|
||||
offAgain: off,
|
||||
tilesReady: true,
|
||||
samples: Array.from({ length: 8 }, (_, i) => ({
|
||||
x: i * 20 + 20,
|
||||
y: 20,
|
||||
radius: 3,
|
||||
inFront: true,
|
||||
})),
|
||||
sprite: { x: 300, y: 20, radius: 24 },
|
||||
});
|
||||
|
||||
test('trail pixels require repeatable contrast at six distinct samples outside the sprite', async () => {
|
||||
const { reduceTrailPixels } = await import('./qaMetrics.js');
|
||||
const off = Array.from({ length: 8 }, () => trailPatch([150, 150, 150]));
|
||||
const on = off.map((p, i) => (i < 6 ? trailPatch([94, 240, 138]) : p));
|
||||
const evidence = trailEvidence(on, off);
|
||||
assert.equal(reduceTrailPixels(on, off, evidence).pass, true);
|
||||
const five = on.map((p, i) => (i === 5 ? off[i] : p));
|
||||
assert.equal(
|
||||
reduceTrailPixels(five, off, trailEvidence(five, off)).pass,
|
||||
false,
|
||||
);
|
||||
for (const rgb of [
|
||||
[5, 8, 12],
|
||||
[126, 126, 126],
|
||||
]) {
|
||||
const patches = off.map(() => trailPatch(rgb));
|
||||
assert.equal(
|
||||
reduceTrailPixels(patches, off, trailEvidence(patches, off)).present,
|
||||
rgb[0] === 5 ? 8 : 0,
|
||||
);
|
||||
assert.equal(
|
||||
reduceTrailPixels(patches, patches, trailEvidence(patches, patches))
|
||||
.present,
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
reduceTrailPixels(on, off).pass,
|
||||
false,
|
||||
'two reads are insufficient',
|
||||
);
|
||||
assert.equal(
|
||||
reduceTrailPixels(on, off, { ...evidence, tilesReady: false }).pass,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
reduceTrailPixels(on, off, {
|
||||
...evidence,
|
||||
samples: Array(8).fill(evidence.samples[0]),
|
||||
}).pass,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
reduceTrailPixels(on, off, {
|
||||
...evidence,
|
||||
sprite: { x: 80, y: 20, radius: 200 },
|
||||
}).pass,
|
||||
false,
|
||||
);
|
||||
assert.equal(reduceTrailPixels([], [], trailEvidence([], [])).pass, false);
|
||||
});
|
||||
|
||||
test('background refinement without a trail cannot pass repeated off/on acceptance', async () => {
|
||||
const { reduceTrailPixels } = await import('./qaMetrics.js');
|
||||
const grey = (n) => Array.from({ length: 8 }, () => trailPatch([n, n, n]));
|
||||
const off = grey(100),
|
||||
on = grey(30);
|
||||
const evidence = trailEvidence(on, off);
|
||||
assert.equal(
|
||||
reduceTrailPixels(on, off, { ...evidence, offAgain: grey(30) }).pass,
|
||||
false,
|
||||
'one background transition is not a trail',
|
||||
);
|
||||
assert.equal(
|
||||
reduceTrailPixels(on, off, {
|
||||
...evidence,
|
||||
offAgain: grey(170),
|
||||
onAgain: grey(100),
|
||||
}).pass,
|
||||
false,
|
||||
'equal deltas on a drifting baseline are not a trail',
|
||||
);
|
||||
assert.equal(
|
||||
reduceTrailPixels(on, off, { ...evidence, onAgain: off }).pass,
|
||||
false,
|
||||
'contrast must repeat',
|
||||
);
|
||||
const first = off.map((p) => p.map((v, i) => (i < 8 ? on[0][i] : v)));
|
||||
const second = off.map((p) =>
|
||||
p.map((v, i) => (i >= 8 && i < 16 ? on[0][i] : v)),
|
||||
);
|
||||
assert.equal(
|
||||
reduceTrailPixels(first, off, { ...evidence, onAgain: second }).pass,
|
||||
false,
|
||||
'the same pixels must change twice',
|
||||
);
|
||||
});
|
||||
@@ -97,6 +97,7 @@ export function createRendering({ state, services, parts }) {
|
||||
nearGround,
|
||||
);
|
||||
entry.surfaceReady = !!known;
|
||||
entry.heightPending = !known;
|
||||
entry.marker.show = !!known && vehicleInView(entry);
|
||||
if (known) {
|
||||
entry.marker.position = state._scratchCartesian;
|
||||
@@ -523,6 +524,10 @@ export function createRendering({ state, services, parts }) {
|
||||
else state._visible.delete(entry);
|
||||
if (visible && entry.track?.count) {
|
||||
if (!wasVisible && !entry.qaFixture) syncPlayback(entry, now);
|
||||
// Re-entry can advance into an unprepared corridor; loaded tiles can
|
||||
// also resolve a cold path without a poll or a render-loop wakeup.
|
||||
if (!wasVisible || entry.surfaceReady === false || entry.heightPending)
|
||||
parts.trails.prepareEntry(entry);
|
||||
// Surface completion must recover while requestRenderMode is idle.
|
||||
// Otherwise surfaceReady=false prevents the very frame that clears it.
|
||||
if (!state._moving.has(entry)) {
|
||||
@@ -532,6 +537,8 @@ export function createRendering({ state, services, parts }) {
|
||||
}
|
||||
entry.marker.show =
|
||||
visible && !entry.heightPending && entry.surfaceReady !== false;
|
||||
visibility.heightPending = !!entry.heightPending;
|
||||
visibility.surfaceReady = entry.surfaceReady ?? null;
|
||||
schedulePlayback(entry);
|
||||
changed ||= visible !== wasVisible || wasShown !== entry.marker.show;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ const MODE_COLOR = {
|
||||
ferry: '#5FD6FF',
|
||||
unknown: '#D8DDE5',
|
||||
};
|
||||
export const TRAIL_VERTEX_LIMIT = 2048;
|
||||
// Final Cesium batching uses 3,152 bytes per two-lane edge.
|
||||
// The 1,278-instance body plus 64 bytes/instance reserve stays below 2 MiB.
|
||||
export const TRAIL_VERTEX_LIMIT = 640;
|
||||
const STEP_M = 25;
|
||||
|
||||
/** Shared prepared paths. Floors are aligned to work with Google 3D tiles. */
|
||||
@@ -63,6 +65,7 @@ export function createTrails({ state, services, parts, source }) {
|
||||
entry.trailSegments = [];
|
||||
entry.trailVertices = 0;
|
||||
}
|
||||
const draped = entry === selected && ensureRenderer()?.diagnostics().ground;
|
||||
const paths = new Map(),
|
||||
all = [],
|
||||
a = {},
|
||||
@@ -113,16 +116,28 @@ export function createTrails({ state, services, parts, source }) {
|
||||
if (entry === selected)
|
||||
for (let i = activeIndex - 1; i >= 0; i--) order.push(i);
|
||||
entry.trailTruncated = false;
|
||||
let markerVertices = 0;
|
||||
let markerIntervals = Math.max(0, entry.track.count - 1 - activeIndex);
|
||||
for (const i of order) {
|
||||
readFix(entry.track, i, a);
|
||||
readFix(entry.track, i + 1, b);
|
||||
const markerCorridor = i >= activeIndex;
|
||||
if (markerCorridor) markerIntervals--;
|
||||
if (a.epoch !== b.epoch || b.flags & FIX_FLAGS.BREAK) continue;
|
||||
if (entry !== selected && b.t < entry.sample.displayT) continue;
|
||||
const n = Math.max(1, Math.ceil(fixDistanceM(a, b) / STEP_M));
|
||||
if (vertices + n + 1 > TRAIL_VERTEX_LIMIT) {
|
||||
entry.trailTruncated = true;
|
||||
continue;
|
||||
}
|
||||
const desired = Math.max(1, Math.ceil(fixDistanceM(a, b) / STEP_M));
|
||||
// Reserve endpoints for every future marker interval. Sparse reports
|
||||
// coarsen the active path instead of losing the vehicle to the body cap.
|
||||
const n = markerCorridor
|
||||
? Math.min(
|
||||
desired,
|
||||
TRAIL_VERTEX_LIMIT - markerVertices - 2 * markerIntervals - 1,
|
||||
)
|
||||
: desired;
|
||||
const retainBody = vertices + n + 1 <= TRAIL_VERTEX_LIMIT;
|
||||
if (!retainBody || n < desired) entry.trailTruncated = true;
|
||||
if (!markerCorridor && !retainBody) continue;
|
||||
if (markerCorridor) markerVertices += n + 1;
|
||||
const positions = [],
|
||||
heights = [];
|
||||
let valid = true;
|
||||
@@ -136,17 +151,21 @@ export function createTrails({ state, services, parts, source }) {
|
||||
heights.push(h);
|
||||
if (h === null) {
|
||||
valid = false;
|
||||
positions.push(null);
|
||||
// Ground polylines consume longitude/latitude and drape themselves.
|
||||
// Unknown floors still disqualify the marker corridor.
|
||||
positions.push(
|
||||
draped ? Cesium.Cartesian3.fromDegrees(point.lon, point.lat) : null,
|
||||
);
|
||||
} else
|
||||
positions.push(
|
||||
Cesium.Cartesian3.fromDegrees(point.lon, point.lat, h),
|
||||
);
|
||||
}
|
||||
signature += `${a.seq}/${b.seq}:${heights.join(',')};`;
|
||||
vertices += n + 1;
|
||||
if (!valid) continue;
|
||||
paths.set(a.seq, { positions, toSeq: b.seq });
|
||||
if (entry === selected && fixDistanceM(a, b) > 0) {
|
||||
if (retainBody) vertices += n + 1;
|
||||
if (valid) paths.set(a.seq, { positions, toSeq: b.seq });
|
||||
if (!valid && !draped) continue;
|
||||
if (entry === selected && retainBody && fixDistanceM(a, b) > 0) {
|
||||
for (let k = 0; k < n; k++)
|
||||
all.push({
|
||||
fromSeq: a.seq,
|
||||
@@ -216,7 +235,9 @@ export function createTrails({ state, services, parts, source }) {
|
||||
}
|
||||
function update() {
|
||||
if (!selected || !renderer) return;
|
||||
renderer.setVisible(state._enabled && selected.marker?.show !== false);
|
||||
renderer.setVisible(
|
||||
state._enabled && state._vehicles.get(selected.key) === selected,
|
||||
);
|
||||
renderer.setDisplaySample(selected.sample, selected.marker?.position);
|
||||
}
|
||||
function select(entry) {
|
||||
|
||||
@@ -328,6 +328,7 @@ test('sensor sampler waits for postRender and reads framebuffer pixels; stopped
|
||||
globalThis.Image = previousImage;
|
||||
});
|
||||
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
const readRows = [];
|
||||
let listener,
|
||||
reads = 0,
|
||||
render = true;
|
||||
@@ -336,6 +337,7 @@ test('sensor sampler waits for postRender and reads framebuffer pixels; stopped
|
||||
UNSIGNED_BYTE: 2,
|
||||
readPixels(x, y, w, h, format, type, buffer) {
|
||||
reads++;
|
||||
readRows.push(y);
|
||||
buffer.set([255, 255, 255, 255]);
|
||||
},
|
||||
};
|
||||
@@ -408,13 +410,70 @@ test('sensor sampler waits for postRender and reads framebuffer pixels; stopped
|
||||
},
|
||||
};
|
||||
const page = { evaluate: async (fn, ...args) => fn(...args) };
|
||||
const sampledLayer =
|
||||
window.__godsEyeView.dataManager.layers.get('transit').module;
|
||||
const originalState = sampledLayer._transitStateForTest;
|
||||
sampledLayer._transitStateForTest = () => {
|
||||
const state = originalState();
|
||||
state._vehicles.get('bus').marker.position.y = 0.0025; // Screen y = 199.5.
|
||||
return state;
|
||||
};
|
||||
const pixels = await sampleTransitPixels(page, {});
|
||||
sampledLayer._transitStateForTest = originalState;
|
||||
assert.deepEqual(
|
||||
readRows.slice(0, 9),
|
||||
[201, 200, 199, 201, 200, 199, 201, 200, 199],
|
||||
'fractional CSS coordinates must invert the floored framebuffer row',
|
||||
);
|
||||
assert.ok(
|
||||
reads >= 13,
|
||||
'centre, rings and background come from WebGL readPixels',
|
||||
);
|
||||
assert.ok(Math.abs(pixels[0].centre - 1) < 1e-6);
|
||||
assert.equal(listener, null);
|
||||
// An opaque window/panel is not a sensor core. Choose another solid patch,
|
||||
// and reject a raster with no white interior even though alpha/picking pass.
|
||||
for (let i = 0; i < raster.length; i += 4) raster.fill(0, i, i + 3);
|
||||
assert.equal((await sampleTransitPixels(page, {})).length, 0);
|
||||
raster.fill(255);
|
||||
for (let y = 6; y <= 9; y++)
|
||||
for (let x = 0; x < 16; x++) {
|
||||
const i = (y * 16 + x) * 4;
|
||||
raster.fill(0, i, i + 3);
|
||||
}
|
||||
assert.equal(
|
||||
(await sampleTransitPixels(page, {})).length,
|
||||
1,
|
||||
'a body patch away from the central panel remains eligible',
|
||||
);
|
||||
// A narrow centre can be opaque yet put the framebuffer sample on its
|
||||
// antialiased side. Prefer the wider solid band above it, before reading luma.
|
||||
raster.fill(0);
|
||||
for (let y = 0; y < 16; y++)
|
||||
for (let x = 0; x < 16; x++) {
|
||||
const wide = y >= 3 && y <= 5;
|
||||
if ((wide && x >= 3 && x <= 12) || (x >= 7 && x <= 9))
|
||||
raster.fill(255, (y * 16 + x) * 4, (y * 16 + x + 1) * 4);
|
||||
}
|
||||
const readStart = readRows.length;
|
||||
assert.equal((await sampleTransitPixels(page, {})).length, 1);
|
||||
assert.ok(
|
||||
readRows[readStart] > 204,
|
||||
'centre samples use the widest solid band',
|
||||
);
|
||||
raster.fill(255);
|
||||
sampledLayer._transitStateForTest = () => {
|
||||
const state = originalState();
|
||||
state._vehicles.get('bus').mode = 'tram';
|
||||
return state;
|
||||
};
|
||||
const tramStart = readRows.length;
|
||||
assert.equal((await sampleTransitPixels(page, {})).length, 1);
|
||||
assert.ok(
|
||||
readRows[tramStart] > 204,
|
||||
'equal-width tram bands prefer the upper solid body',
|
||||
);
|
||||
sampledLayer._transitStateForTest = originalState;
|
||||
scene.pick = () => ({ id: 'overlapping-label' });
|
||||
assert.equal(
|
||||
(await sampleTransitPixels(page, {})).length,
|
||||
|
||||
@@ -21,6 +21,7 @@ const PANEL_GROUPS = [
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'traffic',
|
||||
'transit',
|
||||
'bikeshare',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
|
||||
test('panel presentation places Transit between Street Traffic and Bike Share in Movement', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./layerPanel.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const declarations = source.slice(
|
||||
source.indexOf('const PANEL_GROUPS ='),
|
||||
source.indexOf('const PANEL_POSITIONS ='),
|
||||
);
|
||||
const order = JSON.parse(
|
||||
runInNewContext(`${declarations}\nJSON.stringify(PANEL_ORDER)`),
|
||||
);
|
||||
assert.deepEqual(
|
||||
order.filter(({ label }) => label === 'Movement').map(({ id }) => id),
|
||||
[
|
||||
'satellites',
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'traffic',
|
||||
'transit',
|
||||
'bikeshare',
|
||||
],
|
||||
);
|
||||
assert.equal(order.filter(({ id }) => id === 'transit').length, 1);
|
||||
});
|
||||
Reference in new issue
Block a user