mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
The cockpit turns Cesium's camera controls off and moves the camera on its own, so a click, drag or wheel on the canvas moved nothing yet still counted as the user handling the map: the cockpit's next move woke a nine-second pulse burst. Input now counts only while the camera controls take it, and a gesture still pending when another owner takes the camera is dropped.
1126 lines
42 KiB
JavaScript
1126 lines
42 KiB
JavaScript
// src/layers/awareness/rangeRing.test.mjs — the 250 km Global Context range
|
|
// ring is a haloed polyline that stays legible on light and dark ground,
|
|
// sits where the old ellipse outline did, and follows the subject.
|
|
// Dim sonar pulses grow slowly from the subject to the ring, each with an
|
|
// inward-fading gradient behind it, drawn on one unpickable disc; the ring
|
|
// dims and flashes as each one arrives and the overlay's echo field follows
|
|
// the subject. The pulses run for a bounded time after a selection or camera
|
|
// move, then rest with the ring steady. Under reduced motion the ring simply
|
|
// stays on screen.
|
|
import '../../testSupport/registerDom.mjs';
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import * as Cesium from 'cesium';
|
|
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
|
|
import {
|
|
AWARENESS_RANGE_ECHO,
|
|
AWARENESS_RANGE_PULSE,
|
|
AWARENESS_RANGE_RING_STYLE,
|
|
CONTEXT_RIM_HEIGHT_M,
|
|
} from './policy.js';
|
|
import { CYBER_SONAR_RENDER_INTERVAL_MS } from '../../cyberSonar.js';
|
|
import {
|
|
AWARENESS_PULSE_GRADIENT_MAX,
|
|
awarenessPulseLevel,
|
|
awarenessPulseState,
|
|
awarenessRangeRingPositions,
|
|
awarenessRingFlash,
|
|
awarenessRingGeometryOptions,
|
|
createRendering,
|
|
wakeAwarenessPulses,
|
|
} from './rendering.js';
|
|
import { getAwarenessEchoField } from '../../awarenessEcho.js';
|
|
import { createLifecycle } from './lifecycle.js';
|
|
import { createState } from './state.js';
|
|
|
|
/** WCAG relative luminance of a Cesium colour, ignoring alpha. */
|
|
function luminance(color) {
|
|
const channel = (c) =>
|
|
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
return (
|
|
0.2126 * channel(color.red) +
|
|
0.7152 * channel(color.green) +
|
|
0.0722 * channel(color.blue)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The rendering part over a bare entity collection, counting render asks and
|
|
* by reason. `reducedMotion` stubs the media query for the length of the
|
|
* test; `motion.set(on)` flips it later and tells its listeners, as the
|
|
* browser does.
|
|
*/
|
|
function ringRendering(t, { reducedMotion = false, legacyQuery = false } = {}) {
|
|
const previous = globalThis.matchMedia;
|
|
const listeners = new Set();
|
|
// `legacyQuery` is a MediaQueryList as Safari before 14 shipped it, with
|
|
// only addListener / removeListener.
|
|
const query = legacyQuery
|
|
? {
|
|
get matches() {
|
|
return reducedMotion;
|
|
},
|
|
addListener: (listener) => listeners.add(listener),
|
|
removeListener: (listener) => listeners.delete(listener),
|
|
}
|
|
: {
|
|
get matches() {
|
|
return reducedMotion;
|
|
},
|
|
addEventListener(type, listener) {
|
|
if (type === 'change') listeners.add(listener);
|
|
},
|
|
removeEventListener(type, listener) {
|
|
if (type === 'change') listeners.delete(listener);
|
|
},
|
|
};
|
|
globalThis.matchMedia = (media) =>
|
|
media === '(prefers-reduced-motion: reduce)'
|
|
? query
|
|
: { matches: false, addEventListener() {}, removeEventListener() {} };
|
|
t.after(() => {
|
|
globalThis.matchMedia = previous;
|
|
});
|
|
const motion = {
|
|
set(on) {
|
|
reducedMotion = on;
|
|
for (const listener of [...listeners]) listener({ matches: on });
|
|
},
|
|
/** Change the preference without a change event, as old browsers do. */
|
|
setQuietly(on) {
|
|
reducedMotion = on;
|
|
},
|
|
listeners: () => listeners.size,
|
|
};
|
|
const state = createState({ services: {} });
|
|
const entities = new Cesium.EntityCollection();
|
|
const scene = {
|
|
primitives: new Cesium.PrimitiveCollection(),
|
|
preRender: new Cesium.Event(),
|
|
postRender: new Cesium.Event(),
|
|
};
|
|
state.viewer = { entities, scene };
|
|
const reasons = [];
|
|
const rendering = createRendering({
|
|
state,
|
|
services: {
|
|
geometry: {},
|
|
render: {
|
|
governorRequestRender(reason) {
|
|
reasons.push(reason);
|
|
},
|
|
},
|
|
},
|
|
parts: {},
|
|
});
|
|
// A shown ring keeps a pulse timer; never leave one running past a test.
|
|
t.after(() => rendering.clearVisual());
|
|
return {
|
|
state,
|
|
entities,
|
|
scene,
|
|
rendering,
|
|
motion,
|
|
reasons,
|
|
renders: () => reasons.filter((r) => r === 'awareness-visual').length,
|
|
pulseFrames: () => reasons.filter((r) => r === 'awareness-pulse').length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A request-render scene on a fake clock: `performance.now()` and the
|
|
* timers advance together, and any render ask is drawn (preRender, then
|
|
* postRender) on the next millisecond, as Cesium's request-render mode does.
|
|
* `draw()` draws a frame someone else asked for.
|
|
*/
|
|
function frameLoop(t, ring, { date = false } = {}) {
|
|
let clock = 1_000_000;
|
|
t.mock.method(performance, 'now', () => clock);
|
|
t.mock.timers.enable({
|
|
apis: date
|
|
? ['setTimeout', 'setInterval', 'Date']
|
|
: ['setTimeout', 'setInterval'],
|
|
now: clock,
|
|
});
|
|
let drawn = 0;
|
|
const draw = () => {
|
|
drawn = ring.reasons.length;
|
|
ring.scene.preRender.raiseEvent(ring.scene);
|
|
ring.scene.postRender.raiseEvent(ring.scene);
|
|
};
|
|
return {
|
|
now: () => clock,
|
|
draw,
|
|
advance(ms) {
|
|
for (let step = 0; step < ms; step++) {
|
|
clock++;
|
|
t.mock.timers.tick(1);
|
|
if (ring.reasons.length > drawn) draw();
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/** The ring and the pulse disc among scene primitives. */
|
|
function pulsePrimitives(scene) {
|
|
const all = Array.from({ length: scene.primitives.length }, (_, i) =>
|
|
scene.primitives.get(i),
|
|
);
|
|
const ring = all.find(
|
|
(p) => p.appearance instanceof Cesium.PolylineMaterialAppearance,
|
|
);
|
|
const discs = all.filter((p) => p instanceof Cesium.Primitive && p !== ring);
|
|
return { ring, disc: discs[0], count: discs.length };
|
|
}
|
|
|
|
/** Where a disc built around `built` sits after its model matrix. */
|
|
function discCenter(disc, built) {
|
|
return Cesium.Matrix4.multiplyByPoint(
|
|
disc.modelMatrix,
|
|
built,
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
}
|
|
|
|
/** The ring's material uniforms as the next drawn frame sets them. */
|
|
function ringColors(scene) {
|
|
scene.preRender.raiseEvent(scene);
|
|
return pulsePrimitives(scene).ring.appearance.material.uniforms;
|
|
}
|
|
|
|
const flight = (lon) => ({
|
|
layerId: 'flights',
|
|
id: 'abc123',
|
|
position: Cesium.Cartesian3.fromDegrees(lon, 27, 9000),
|
|
});
|
|
|
|
/** Surface distance from `center` to each ring position. */
|
|
function surfaceDistances(center, positions) {
|
|
const from = Cesium.Cartographic.fromCartesian(center);
|
|
return positions.map((position) => {
|
|
const to = Cesium.Cartographic.fromCartesian(position);
|
|
return new Cesium.EllipsoidGeodesic(from, to).surfaceDistance;
|
|
});
|
|
}
|
|
|
|
for (const [place, lat, lon] of [
|
|
['equator', 0, 0],
|
|
['Sahara', 27, 3],
|
|
['Greenland', 72, -40],
|
|
['antimeridian', 10, 179.9],
|
|
]) {
|
|
test(`ring positions stay 250 km from the subject at ${place}`, () => {
|
|
const center = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
|
|
const positions = awarenessRangeRingPositions(
|
|
center,
|
|
AWARENESS_RADIUS_M,
|
|
CONTEXT_RIM_HEIGHT_M,
|
|
);
|
|
assert.equal(positions.length, AWARENESS_RANGE_RING_STYLE.segments + 1);
|
|
assert.ok(
|
|
Cesium.Cartesian3.equalsEpsilon(positions[0], positions.at(-1), 0, 1e-6),
|
|
'the ring closes on itself',
|
|
);
|
|
// Within 100 m of 250 km on the WGS84 ellipsoid.
|
|
for (const distance of surfaceDistances(center, positions))
|
|
assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100, `${distance} m`);
|
|
for (const position of positions)
|
|
assert.ok(
|
|
Math.abs(
|
|
Cesium.Cartographic.fromCartesian(position).height -
|
|
CONTEXT_RIM_HEIGHT_M,
|
|
) < 1e-3,
|
|
);
|
|
});
|
|
}
|
|
|
|
test('the ring core and halo contrast on light and dark ground', () => {
|
|
const core = Cesium.Color.fromCssColorString(
|
|
AWARENESS_RANGE_RING_STYLE.color,
|
|
);
|
|
const halo = Cesium.Color.fromCssColorString(
|
|
AWARENESS_RANGE_RING_STYLE.outlineColor,
|
|
);
|
|
// Core against halo is the edge that survives bright desert and snow, and
|
|
// the monochrome styles that map everything to luminance.
|
|
const ratio = (luminance(core) + 0.05) / (luminance(halo) + 0.05);
|
|
assert.ok(ratio >= 4.5, `core:halo contrast ${ratio.toFixed(2)}`);
|
|
assert.ok(core.alpha >= 0.9 && halo.alpha >= 0.7);
|
|
// A visible halo on each side of a core at least 2 px wide.
|
|
const { width, outlineWidth } = AWARENESS_RANGE_RING_STYLE;
|
|
assert.ok(outlineWidth >= 1);
|
|
assert.ok(width - 2 * outlineWidth >= 2);
|
|
});
|
|
|
|
test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) => {
|
|
const ring = ringRendering(t);
|
|
const { entities, scene, state, rendering } = ring;
|
|
frameLoop(t, ring); // freeze the clock: the pulses have not faded in yet
|
|
rendering.renderVisual(flight(3));
|
|
// A polyline primitive, never an entity or an ellipse outline.
|
|
assert.equal(entities.values.length, 0);
|
|
const ringPrimitive = pulsePrimitives(scene).ring;
|
|
assert.ok(ringPrimitive);
|
|
const geometry = ringPrimitive.geometryInstances.geometry;
|
|
assert.ok(geometry instanceof Cesium.PolylineGeometry);
|
|
// 4 px wide, straight chords between surface-following positions, as the
|
|
// entity ring was drawn.
|
|
const options = awarenessRingGeometryOptions(state.visual.ringPositions);
|
|
assert.equal(options.width, 4);
|
|
assert.equal(options.width, AWARENESS_RANGE_RING_STYLE.width);
|
|
assert.equal(options.arcType, Cesium.ArcType.NONE);
|
|
assert.equal(options.positions, state.visual.ringPositions);
|
|
assert.equal(
|
|
options.vertexFormat,
|
|
Cesium.PolylineMaterialAppearance.VERTEX_FORMAT,
|
|
);
|
|
const { material } = ringPrimitive.appearance;
|
|
assert.equal(material.type, 'PolylineOutline');
|
|
// The ring's hue is fixed; its opacity flashes with arriving pulses, and
|
|
// at the moment it appears, before the pulses fade in, it is at full.
|
|
const core = Cesium.Color.fromCssColorString(
|
|
AWARENESS_RANGE_RING_STYLE.color,
|
|
);
|
|
const { color, outlineWidth } = ringColors(scene);
|
|
assert.equal(color.red, core.red);
|
|
assert.equal(color.blue, core.blue);
|
|
assert.ok(Math.abs(color.alpha - core.alpha) < 1e-9, `${color.alpha}`);
|
|
assert.equal(outlineWidth, AWARENESS_RANGE_RING_STYLE.outlineWidth);
|
|
const ground = Cesium.Cartesian3.fromDegrees(3, 27, 0);
|
|
for (const distance of surfaceDistances(ground, state.visual.ringPositions))
|
|
assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100);
|
|
});
|
|
|
|
test('the ring and pulses follow the subject past 250 m and ignore smaller drift', (t) => {
|
|
const { scene, state, rendering, renders } = ringRendering(t);
|
|
const subject = flight;
|
|
rendering.renderVisual(subject(3));
|
|
const ringBefore = pulsePrimitives(scene).ring;
|
|
const first = state.visual.ringPositions;
|
|
const discBefore = pulsePrimitives(scene).disc;
|
|
const rendersAfterAdd = renders();
|
|
|
|
// ~100 m east: under the threshold, nothing is rebuilt or rendered.
|
|
rendering.renderVisual(subject(3.001));
|
|
assert.equal(pulsePrimitives(scene).ring, ringBefore);
|
|
assert.equal(state.visual.ringPositions, first);
|
|
assert.equal(renders(), rendersAfterAdd);
|
|
|
|
// ~1 km east: the ring is rebuilt around the new centre, keeping its
|
|
// material, so a flash carries on.
|
|
rendering.renderVisual(subject(3.01));
|
|
const ringAfter = pulsePrimitives(scene).ring;
|
|
assert.notEqual(ringAfter, ringBefore);
|
|
assert.ok(ringBefore.isDestroyed());
|
|
assert.equal(ringAfter.appearance.material, ringBefore.appearance.material);
|
|
assert.equal(ringAfter.allowPicking, false);
|
|
const moved = state.visual.ringPositions;
|
|
assert.notEqual(moved, first);
|
|
for (const distance of surfaceDistances(
|
|
Cesium.Cartesian3.fromDegrees(3.01, 27, 0),
|
|
moved,
|
|
))
|
|
assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100);
|
|
assert.equal(renders(), rendersAfterAdd + 1);
|
|
// The pulses are drawn on a disc that follows to the NEW centre by its
|
|
// model matrix, so they stay concentric with the ring without a rebuild.
|
|
assert.equal(pulsePrimitives(scene).disc, discBefore);
|
|
assert.ok(
|
|
Cesium.Cartesian3.distance(
|
|
discCenter(discBefore, Cesium.Cartesian3.fromDegrees(3, 27, 0)),
|
|
Cesium.Cartesian3.fromDegrees(3.01, 27, 0),
|
|
) < 1,
|
|
);
|
|
|
|
rendering.clearVisual();
|
|
assert.equal(scene.primitives.length, 0);
|
|
});
|
|
|
|
test('a pulse grows slowly from the subject and arrives dim at the ring', () => {
|
|
const {
|
|
periodMs,
|
|
count,
|
|
startFraction,
|
|
fadeInFraction,
|
|
peakOpacity,
|
|
endOpacityFraction,
|
|
} = AWARENESS_RANGE_PULSE;
|
|
const at = (phase) => awarenessPulseState(phase * periodMs, 0);
|
|
// Starts small and invisible, so nothing pops in at the centre.
|
|
assert.equal(at(0).radiusM, AWARENESS_RADIUS_M * startFraction);
|
|
assert.equal(at(0).alpha, 0);
|
|
// Dim: never brighter than `peakOpacity`, which sits well under the ring.
|
|
let brightest = 0;
|
|
for (let phase = 0; phase < 1; phase += 0.005) {
|
|
const { alpha } = at(phase);
|
|
assert.ok(alpha >= 0 && alpha <= peakOpacity, `alpha ${alpha} at ${phase}`);
|
|
brightest = Math.max(brightest, alpha);
|
|
}
|
|
assert.ok(Math.abs(brightest - at(fadeInFraction).alpha) < 0.01);
|
|
assert.ok(peakOpacity <= 0.6);
|
|
// Fading as it travels, but still visible when it reaches the ring, where
|
|
// the ring takes over with its flash.
|
|
assert.ok(at(0.5).alpha < at(0.25).alpha);
|
|
assert.ok(at(0.9).alpha < at(0.5).alpha);
|
|
assert.ok(
|
|
Math.abs(at(0.999).alpha - peakOpacity * endOpacityFraction) < 0.01,
|
|
);
|
|
assert.ok(at(0.999).alpha >= 0.15, 'the arriving pulse is still visible');
|
|
assert.ok(Math.abs(at(0.999).radiusM - AWARENESS_RADIUS_M) < 300);
|
|
// Constant speed: equal time steps add equal radius.
|
|
const step = at(0.4).radiusM - at(0.3).radiusM;
|
|
assert.ok(Math.abs(at(0.7).radiusM - at(0.6).radiusM - step) < 1e-6);
|
|
// Calm: a new pulse at most every 2.5 s, crossing 250 km in 8 s or more.
|
|
assert.ok(periodMs / count >= 2500);
|
|
assert.ok(periodMs >= 8000);
|
|
// Pulses are evenly staggered across one period, and the cycle repeats.
|
|
const near = (a, b) =>
|
|
Math.abs(a.radiusM - b.radiusM) < 1e-6 &&
|
|
Math.abs(a.alpha - b.alpha) < 1e-9;
|
|
for (let index = 1; index < count; index++)
|
|
assert.ok(
|
|
near(
|
|
awarenessPulseState(0, index),
|
|
awarenessPulseState((index / count) * periodMs, 0),
|
|
),
|
|
);
|
|
assert.ok(
|
|
near(
|
|
awarenessPulseState(periodMs * 7 + 123, 1),
|
|
awarenessPulseState(123, 1),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('pulses ask for a frame 80 ms after each drawn frame instead of holding continuous rendering', (t) => {
|
|
const ring = ringRendering(t);
|
|
const { rendering, pulseFrames } = ring;
|
|
const loop = frameLoop(t, ring);
|
|
rendering.renderVisual(flight(3));
|
|
// A Contacts view repaints at the Cyber sonar cadence, ~12.5 fps, while
|
|
// the pulses run, never every display frame.
|
|
assert.equal(pulseFrames(), 0);
|
|
loop.advance(1); // the frame the new ring asked for arms the first request
|
|
loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10);
|
|
assert.ok(Math.abs(pulseFrames() - 10) <= 1, `${pulseFrames()} frames`);
|
|
const frames = pulseFrames();
|
|
rendering.clearVisual();
|
|
assert.equal(ring.scene.primitives.length, 0);
|
|
assert.equal(ring.scene.postRender.numberOfListeners, 0);
|
|
loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10);
|
|
assert.equal(pulseFrames(), frames, 'no frames once the ring is gone');
|
|
});
|
|
|
|
test('a parked view stops asking for frames once the pulses rest', (t) => {
|
|
const ring = ringRendering(t);
|
|
const { scene, rendering, pulseFrames } = ring;
|
|
const loop = frameLoop(t, ring);
|
|
const { activeMs, count, periodMs } = AWARENESS_RANGE_PULSE;
|
|
// Long enough for three ring flashes, then quiet, and short: a burst is
|
|
// seconds, not minutes.
|
|
assert.ok(activeMs >= 3 * (periodMs / count));
|
|
assert.ok(activeMs <= 15_000, `${activeMs} ms burst`);
|
|
rendering.renderVisual(flight(3));
|
|
loop.advance(activeMs + 500);
|
|
const frames = pulseFrames();
|
|
assert.ok(frames >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2);
|
|
assert.ok(
|
|
frames <= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS + 2,
|
|
`${frames} frames in one burst`,
|
|
);
|
|
// Parked: a minute later not one more request.
|
|
loop.advance(60_000);
|
|
assert.equal(pulseFrames(), frames, 'render requests stop after the bound');
|
|
// At rest the ring stands on screen at full, as without pulses, and the
|
|
// pulses, gradient and echoes are hidden.
|
|
const core = Cesium.Color.fromCssColorString(
|
|
AWARENESS_RANGE_RING_STYLE.color,
|
|
);
|
|
assert.ok(Math.abs(ringColors(scene).color.alpha - core.alpha) < 1e-9);
|
|
assert.equal(pulsePrimitives(scene).disc.show, false);
|
|
assert.equal(getAwarenessEchoField().level(loop.now()), 0);
|
|
});
|
|
|
|
test('a camera move wakes resting pulses for another bounded run', (t) => {
|
|
const ring = ringRendering(t);
|
|
const { rendering, pulseFrames } = ring;
|
|
const loop = frameLoop(t, ring);
|
|
const { activeMs } = AWARENESS_RANGE_PULSE;
|
|
rendering.renderVisual(flight(3));
|
|
loop.advance(activeMs + 500);
|
|
const rested = pulseFrames();
|
|
// The camera moves: lifecycle wakes the pulses inside the frame it draws,
|
|
// and that frame arms the next request. Waking asks for none of its own.
|
|
rendering.wakePulses();
|
|
assert.equal(pulseFrames(), rested);
|
|
loop.draw();
|
|
loop.advance(activeMs + 500);
|
|
const woke = pulseFrames() - rested;
|
|
assert.ok(woke >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2, `${woke}`);
|
|
loop.advance(60_000);
|
|
assert.equal(pulseFrames(), rested + woke, 'and rest again');
|
|
// The subject moving does not wake them: a flying subject moves on every
|
|
// refresh, which would bring the endless repaint back.
|
|
rendering.renderVisual(flight(3.01));
|
|
loop.advance(10_000);
|
|
assert.equal(pulseFrames(), rested + woke);
|
|
});
|
|
|
|
test('a camera move seen by the lifecycle wakes resting pulses; a parked camera does not', (t) => {
|
|
const ring = ringRendering(t);
|
|
const { state, rendering, pulseFrames } = ring;
|
|
const loop = frameLoop(t, ring, { date: true });
|
|
const { activeMs, inputWakeMs } = AWARENESS_RANGE_PULSE;
|
|
state.enabled = true;
|
|
state.viewer.canvas = new EventTarget();
|
|
const touch = () =>
|
|
state.viewer.canvas.dispatchEvent(new Event('pointerdown'));
|
|
state.viewer.camera = {
|
|
positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000),
|
|
heading: 0,
|
|
pitch: -1,
|
|
roll: 0,
|
|
};
|
|
const lifecycle = createLifecycle({
|
|
state,
|
|
services: {
|
|
render: { holdContinuousRender() {}, releaseContinuousRender() {} },
|
|
},
|
|
parts: {
|
|
rendering,
|
|
model: {
|
|
awarenessRefreshDecision: () => ({ moving: false, refresh: false }),
|
|
awarenessNeedsContinuousRender: () => false,
|
|
},
|
|
},
|
|
});
|
|
lifecycle.attachRuntimeListeners();
|
|
t.after(() => lifecycle.detachRuntimeListeners());
|
|
rendering.renderVisual(flight(3));
|
|
loop.advance(activeMs + 500);
|
|
const rested = pulseFrames();
|
|
// Parked: frames other owners draw do not wake the pulses.
|
|
loop.draw();
|
|
loop.advance(activeMs);
|
|
assert.equal(pulseFrames(), rested);
|
|
// The user drags the camera 50 km: the next frame wakes them for another
|
|
// run.
|
|
touch();
|
|
state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
|
3.5,
|
|
20,
|
|
800_000,
|
|
);
|
|
loop.draw();
|
|
loop.advance(activeMs + 500);
|
|
assert.ok(
|
|
pulseFrames() - rested >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2,
|
|
);
|
|
const woke = pulseFrames();
|
|
loop.advance(60_000);
|
|
assert.equal(pulseFrames(), woke, 'and rest again once it parks');
|
|
// The app moving the camera, with no hand on it, does not wake them: a
|
|
// tracking follow, the cockpit view, an orbit or a fly-to all move it on
|
|
// every frame.
|
|
const follow = (lon) => {
|
|
state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
|
lon,
|
|
20,
|
|
800_000,
|
|
);
|
|
loop.draw();
|
|
loop.advance(500);
|
|
};
|
|
for (let lon = 3.6; lon < 5.6; lon += 0.1) follow(lon);
|
|
assert.equal(pulseFrames(), woke, 'app camera moves do not wake them');
|
|
// Input long past (beyond `inputWakeMs`) does not count either; a hover
|
|
// without a button pressed is not handling the camera.
|
|
touch();
|
|
loop.advance(inputWakeMs + 100);
|
|
state.viewer.canvas.dispatchEvent(
|
|
Object.assign(new Event('pointermove'), { buttons: 0 }),
|
|
);
|
|
follow(5.7);
|
|
assert.equal(pulseFrames(), woke, 'stale input does not wake them');
|
|
// Each way of handling the map counts: a wheel, a touch, a drag; keys
|
|
// typed anywhere in the window do not.
|
|
const panWith = (handle, lon) => {
|
|
loop.advance(activeMs + 500);
|
|
const before = pulseFrames();
|
|
handle();
|
|
follow(lon);
|
|
loop.advance(activeMs);
|
|
return pulseFrames() > before;
|
|
};
|
|
const onCanvas = (event) => () => state.viewer.canvas.dispatchEvent(event);
|
|
assert.equal(panWith(onCanvas(new Event('wheel')), 5.8), true, 'wheel');
|
|
assert.equal(panWith(onCanvas(new Event('touchstart')), 5.9), true, 'touch');
|
|
assert.equal(
|
|
panWith(
|
|
onCanvas(Object.assign(new Event('pointermove'), { buttons: 1 })),
|
|
6.0,
|
|
),
|
|
true,
|
|
'drag',
|
|
);
|
|
assert.equal(
|
|
panWith(() => window.dispatchEvent(new Event('keydown')), 6.1),
|
|
false,
|
|
'keys do not count',
|
|
);
|
|
// Detached, the canvas listeners are gone.
|
|
lifecycle.detachRuntimeListeners();
|
|
touch();
|
|
assert.equal(state.lastCameraInputMs < Date.now() - inputWakeMs, true);
|
|
});
|
|
|
|
/**
|
|
* The lifecycle's camera-input listeners over `ring`, with a camera the test
|
|
* moves by longitude (`pan`) and a camera controller whose inputs it can
|
|
* switch off, as the cockpit view does. Pulses start resting.
|
|
*/
|
|
function inputLifecycle(t, ring) {
|
|
const { state, rendering } = ring;
|
|
const loop = frameLoop(t, ring, { date: true });
|
|
state.enabled = true;
|
|
state.viewer.canvas = new EventTarget();
|
|
state.viewer.scene.screenSpaceCameraController = { enableInputs: true };
|
|
state.viewer.camera = {
|
|
positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000),
|
|
heading: 0,
|
|
pitch: -1,
|
|
roll: 0,
|
|
};
|
|
const lifecycle = createLifecycle({
|
|
state,
|
|
services: {
|
|
render: { holdContinuousRender() {}, releaseContinuousRender() {} },
|
|
},
|
|
parts: {
|
|
rendering,
|
|
model: {
|
|
awarenessRefreshDecision: () => ({ moving: false, refresh: false }),
|
|
awarenessNeedsContinuousRender: () => false,
|
|
},
|
|
},
|
|
});
|
|
lifecycle.attachRuntimeListeners();
|
|
t.after(() => lifecycle.detachRuntimeListeners());
|
|
rendering.renderVisual(flight(3));
|
|
loop.advance(AWARENESS_RANGE_PULSE.activeMs + 500);
|
|
let lon = 3;
|
|
return {
|
|
loop,
|
|
lifecycle,
|
|
controller: state.viewer.scene.screenSpaceCameraController,
|
|
dispatch: (type, init = {}) =>
|
|
state.viewer.canvas.dispatchEvent(Object.assign(new Event(type), init)),
|
|
pan(stepDeg = 0.1) {
|
|
lon += stepDeg;
|
|
state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
|
lon,
|
|
20,
|
|
800_000,
|
|
);
|
|
loop.draw();
|
|
},
|
|
/** Whether the pulses drew frames during the next `ms`. */
|
|
pulsing(ms = AWARENESS_RANGE_PULSE.activeMs) {
|
|
const before = ring.pulseFrames();
|
|
loop.advance(ms);
|
|
return ring.pulseFrames() > before;
|
|
},
|
|
};
|
|
}
|
|
|
|
test('input the camera controller ignores, as in the cockpit, does not wake the pulses', (t) => {
|
|
const ring = ringRendering(t);
|
|
const { activeMs } = AWARENESS_RANGE_PULSE;
|
|
const { controller, dispatch, pan, pulsing, loop } = inputLifecycle(t, ring);
|
|
// The cockpit takes the camera: clicks, drags and wheels on the canvas
|
|
// move nothing, while the cockpit itself moves the camera every frame.
|
|
controller.enableInputs = false;
|
|
for (const [type, init] of [
|
|
['pointerdown', {}],
|
|
['pointermove', { buttons: 1 }],
|
|
['wheel', {}],
|
|
['touchstart', {}],
|
|
]) {
|
|
dispatch(type, init);
|
|
pan();
|
|
assert.equal(pulsing(), false, `${type} in the cockpit`);
|
|
}
|
|
// A gesture that began just before the cockpit took the camera is
|
|
// dropped too, rather than waking the pulses on the cockpit's first move.
|
|
controller.enableInputs = true;
|
|
loop.advance(activeMs);
|
|
dispatch('pointerdown');
|
|
controller.enableInputs = false;
|
|
pan();
|
|
assert.equal(pulsing(), false, 'a gesture cut off by the cockpit');
|
|
controller.enableInputs = true;
|
|
pan();
|
|
assert.equal(pulsing(), false, 'and stays dropped once it hands back');
|
|
// Back in the user's hands, a drag wakes them again.
|
|
dispatch('pointerdown');
|
|
pan();
|
|
assert.equal(pulsing(), true, 'a drag after the cockpit');
|
|
});
|
|
|
|
test('disabling the layer tears the pulses down completely', (t) => {
|
|
const ring = ringRendering(t);
|
|
const { state, scene, rendering, motion, pulseFrames } = ring;
|
|
const loop = frameLoop(t, ring);
|
|
state.viewer.camera = {
|
|
positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000),
|
|
heading: 0,
|
|
pitch: -1,
|
|
roll: 0,
|
|
};
|
|
const lifecycle = createLifecycle({
|
|
state,
|
|
services: {
|
|
render: { holdContinuousRender() {}, releaseContinuousRender() {} },
|
|
},
|
|
parts: {
|
|
rendering,
|
|
model: {
|
|
awarenessRefreshDecision: () => ({ moving: false, refresh: false }),
|
|
awarenessNeedsContinuousRender: () => false,
|
|
},
|
|
panel: {
|
|
startAwarenessPageRotation() {},
|
|
stopAwarenessPageRotation() {},
|
|
ensurePanel() {},
|
|
hidePanel() {},
|
|
},
|
|
dependencies: {
|
|
activateOperationalContext: () => true,
|
|
releaseOwnedDependencies: () => true,
|
|
},
|
|
},
|
|
});
|
|
state.passive = true;
|
|
lifecycle.methods.enable();
|
|
state.subject = flight(3);
|
|
rendering.renderVisual(state.subject);
|
|
loop.advance(1000);
|
|
assert.ok(pulseFrames() > 0);
|
|
assert.equal(scene.primitives.length, 2);
|
|
lifecycle.methods.disable();
|
|
assert.equal(scene.primitives.length, 0, 'ring and disc removed');
|
|
assert.equal(scene.preRender.numberOfListeners, 0);
|
|
assert.equal(scene.postRender.numberOfListeners, 0);
|
|
assert.equal(motion.listeners(), 0, 'no media-query listener');
|
|
assert.equal(getAwarenessEchoField(), null);
|
|
assert.equal(state.visual, null);
|
|
const frames = pulseFrames();
|
|
loop.advance(60_000);
|
|
assert.equal(
|
|
pulseFrames(),
|
|
frames,
|
|
'no awareness-pulse requests after disable',
|
|
);
|
|
});
|
|
|
|
test('pulse requests wait 80 ms after any frame, so they cannot beat the Cyber sonar', (t) => {
|
|
const ring = ringRendering(t);
|
|
const { rendering, pulseFrames } = ring;
|
|
const loop = frameLoop(t, ring);
|
|
rendering.renderVisual(flight(3));
|
|
loop.advance(1);
|
|
const frames = pulseFrames();
|
|
// Another owner (the Cyber sonar, a data update) draws 67 ms after ours.
|
|
loop.advance(67);
|
|
loop.draw();
|
|
loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS - 1);
|
|
assert.equal(pulseFrames(), frames, 'no request 13 ms after that frame');
|
|
loop.advance(1);
|
|
assert.equal(pulseFrames(), frames + 1, 'one request 80 ms after it');
|
|
});
|
|
|
|
test('the pulse level fades in, holds, fades out and keeps its level when woken', () => {
|
|
const { activeMs, settleMs } = AWARENESS_RANGE_PULSE;
|
|
const wake = wakeAwarenessPulses(
|
|
{ startMs: -Infinity, untilMs: -Infinity },
|
|
0,
|
|
);
|
|
assert.equal(awarenessPulseLevel(0, wake), 0);
|
|
assert.ok(Math.abs(awarenessPulseLevel(settleMs / 2, wake) - 0.5) < 1e-9);
|
|
assert.equal(awarenessPulseLevel(activeMs / 2, wake), 1);
|
|
assert.ok(
|
|
Math.abs(awarenessPulseLevel(activeMs - settleMs / 4, wake) - 0.25) < 1e-9,
|
|
);
|
|
assert.equal(awarenessPulseLevel(activeMs, wake), 0);
|
|
assert.equal(awarenessPulseLevel(activeMs * 10, wake), 0);
|
|
// Woken mid-fade-out: no jump back to full, then a fresh run.
|
|
const at = activeMs - settleMs / 4;
|
|
wakeAwarenessPulses(wake, at);
|
|
assert.ok(Math.abs(awarenessPulseLevel(at, wake) - 0.25) < 1e-9);
|
|
assert.equal(awarenessPulseLevel(at + settleMs, wake), 1);
|
|
assert.equal(awarenessPulseLevel(at + activeMs, wake), 0);
|
|
});
|
|
|
|
test('a scene without primitives or preRender gets the static ring only', (t) => {
|
|
const { state, entities, rendering, pulseFrames } = ringRendering(t);
|
|
for (const scene of [
|
|
{},
|
|
{ primitives: { add() {} } },
|
|
{ primitives: { add() {} }, preRender: new Cesium.Event() },
|
|
undefined,
|
|
]) {
|
|
state.viewer = { entities, scene };
|
|
rendering.clearVisual();
|
|
rendering.renderVisual(flight(3));
|
|
// No usable primitive collection: the ring falls back to an entity.
|
|
assert.equal(entities.values.length, 1);
|
|
assert.equal(entities.values[0].polyline.positions.isConstant, true);
|
|
assert.equal(entities.values[0].polyline.material.color.isConstant, true);
|
|
}
|
|
rendering.clearVisual();
|
|
assert.equal(pulseFrames(), 0);
|
|
});
|
|
|
|
test('a gradient disc behind the pulses fades inward and follows the subject', (t) => {
|
|
const ring = ringRendering(t);
|
|
const { scene, rendering } = ring;
|
|
const loop = frameLoop(t, ring);
|
|
rendering.renderVisual(flight(3));
|
|
assert.ok(AWARENESS_RANGE_PULSE.count <= AWARENESS_PULSE_GRADIENT_MAX);
|
|
assert.equal(scene.primitives.length, 2, 'the ring and the pulse disc');
|
|
const { disc, count } = pulsePrimitives(scene);
|
|
assert.equal(count, 1, 'the gradient disc');
|
|
assert.ok(disc);
|
|
// Never a click or tap target, and see-through.
|
|
assert.equal(disc.allowPicking, false);
|
|
assert.equal(disc.appearance.translucent, true);
|
|
const { material } = disc.appearance;
|
|
assert.ok(
|
|
Math.abs(
|
|
material.uniforms.depth - AWARENESS_RANGE_PULSE.gradientDepthFraction,
|
|
) < 1e-9,
|
|
);
|
|
assert.ok(
|
|
Math.abs(
|
|
material.uniforms.color.alpha - AWARENESS_RANGE_PULSE.gradientOpacity,
|
|
) < 1e-6,
|
|
);
|
|
// The pulse lines are drawn in the ring's colours and widths.
|
|
const core = Cesium.Color.fromCssColorString(
|
|
AWARENESS_RANGE_RING_STYLE.color,
|
|
);
|
|
const halo = Cesium.Color.fromCssColorString(
|
|
AWARENESS_RANGE_RING_STYLE.outlineColor,
|
|
);
|
|
assert.ok(material.uniforms.lineColor.equalsEpsilon(core, 1e-6));
|
|
assert.ok(material.uniforms.haloColor.equalsEpsilon(halo, 1e-6));
|
|
assert.equal(material.uniforms.lineWidth, AWARENESS_RANGE_PULSE.width);
|
|
assert.equal(material.uniforms.haloWidth, AWARENESS_RANGE_PULSE.outlineWidth);
|
|
// Each frame writes the pulses' radius (as a fraction of the range) and
|
|
// opacity into the shader, from the same curve the lines use, once the
|
|
// pulses have faded in.
|
|
assert.equal(scene.preRender.numberOfListeners, 1);
|
|
loop.advance(AWARENESS_RANGE_PULSE.settleMs + 500);
|
|
scene.preRender.raiseEvent(scene);
|
|
assert.equal(disc.show, true);
|
|
const { radii, alphas } = material.uniforms;
|
|
for (let index = 0; index < AWARENESS_RANGE_PULSE.count; index++) {
|
|
const radius = Cesium.Cartesian4.pack(radii, [])[index];
|
|
const alpha = Cesium.Cartesian4.pack(alphas, [])[index];
|
|
const expected = awarenessPulseState(loop.now(), index);
|
|
assert.ok(Math.abs(radius - expected.radiusM / AWARENESS_RADIUS_M) < 1e-6);
|
|
assert.ok(alpha >= 0 && alpha <= AWARENESS_RANGE_PULSE.peakOpacity);
|
|
assert.ok(Math.abs(alpha - expected.alpha) < 1e-6);
|
|
}
|
|
// Unused shader slots stay empty.
|
|
assert.equal(
|
|
Cesium.Cartesian4.pack(alphas, [])[AWARENESS_PULSE_GRADIENT_MAX - 1],
|
|
0,
|
|
);
|
|
|
|
// The disc follows by its model matrix and is rebuilt only past
|
|
// `gradientRebuildM` (20 km), keeping its material.
|
|
const built = Cesium.Cartesian3.fromDegrees(3, 27, 0);
|
|
for (const lon of [3.001, 3.01, 3.1, 3.19]) {
|
|
rendering.renderVisual(flight(lon));
|
|
assert.equal(pulsePrimitives(scene).disc, disc, `${lon}`);
|
|
assert.ok(!disc.isDestroyed());
|
|
// Concentric with the ring wherever the subject went, within a metre
|
|
// across the ground; the ellipsoid curves away from the rotation's
|
|
// sphere by tens of metres in height at most.
|
|
const at = Cesium.Cartographic.fromCartesian(discCenter(disc, built));
|
|
const want = Cesium.Cartographic.fromCartesian(ring.state.visual.center);
|
|
const across = new Cesium.EllipsoidGeodesic(at, want).surfaceDistance;
|
|
assert.ok(across < 1, `${across} m off at ${lon}`);
|
|
assert.ok(Math.abs(at.height) < 50, `${at.height} m off the ground`);
|
|
}
|
|
rendering.renderVisual(flight(3.25)); // ~25 km from where it was built
|
|
assert.equal(pulsePrimitives(scene).count, 1);
|
|
const moved = pulsePrimitives(scene).disc;
|
|
assert.notEqual(moved, disc);
|
|
assert.ok(disc.isDestroyed());
|
|
assert.equal(moved.appearance.material, material);
|
|
assert.equal(moved.allowPicking, false);
|
|
|
|
rendering.clearVisual();
|
|
assert.equal(scene.primitives.length, 0);
|
|
assert.equal(scene.preRender.numberOfListeners, 0);
|
|
});
|
|
|
|
test("switching subjects keeps one pulse material in Cesium's global cache", (t) => {
|
|
const ring = ringRendering(t);
|
|
const { scene, rendering } = ring;
|
|
frameLoop(t, ring);
|
|
// Cesium keeps every new material type for the page's lifetime, so an
|
|
// unnamed fabric (a fresh GUID type each time) would grow it per subject.
|
|
const cached = () =>
|
|
Object.keys(Cesium.Material._materialCache._materials).length;
|
|
rendering.renderVisual({ ...flight(3), id: 'subject-0' });
|
|
const first = pulsePrimitives(scene).disc.appearance.material;
|
|
const size = cached();
|
|
let last = first;
|
|
for (let index = 1; index <= 6; index++) {
|
|
rendering.renderVisual({ ...flight(3 + index), id: `subject-${index}` });
|
|
last = pulsePrimitives(scene).disc.appearance.material;
|
|
}
|
|
assert.equal(cached(), size, 'subject switches add no cache entries');
|
|
rendering.clearVisual();
|
|
rendering.renderVisual(flight(3));
|
|
assert.equal(cached(), size, 'nor does a reselect');
|
|
// A material built from the cached template still owns its uniform values.
|
|
const again = pulsePrimitives(scene).disc.appearance.material;
|
|
assert.notEqual(again, last);
|
|
assert.notEqual(again.uniforms.radii, last.uniforms.radii);
|
|
assert.notEqual(again.uniforms.alphas, first.uniforms.alphas);
|
|
assert.ok(again.uniforms.radii instanceof Cesium.Cartesian4);
|
|
assert.ok(again.uniforms.lineColor instanceof Cesium.Color);
|
|
assert.equal(again.uniforms.lineWidth, AWARENESS_RANGE_PULSE.width);
|
|
});
|
|
|
|
test('a resting pulse disc is not rebuilt as the subject moves, and catches up on wake', (t) => {
|
|
const ring = ringRendering(t);
|
|
const { scene, rendering } = ring;
|
|
const loop = frameLoop(t, ring);
|
|
rendering.renderVisual(flight(3));
|
|
loop.advance(AWARENESS_RANGE_PULSE.activeMs + 500);
|
|
const resting = pulsePrimitives(scene).disc;
|
|
const restingMatrix = Cesium.Matrix4.clone(resting.modelMatrix);
|
|
// A flying subject keeps moving while the pulses rest: the hidden disc is
|
|
// neither moved nor rebuilt, even well past the rebuild distance.
|
|
for (let lon = 3.01; lon < 3.4; lon += 0.01)
|
|
rendering.renderVisual(flight(lon));
|
|
assert.equal(pulsePrimitives(scene).disc, resting);
|
|
assert.ok(!resting.isDestroyed());
|
|
assert.ok(Cesium.Matrix4.equals(resting.modelMatrix, restingMatrix));
|
|
// Woken, the disc catches up at once: rebuilt around the subject.
|
|
rendering.wakePulses();
|
|
const woken = pulsePrimitives(scene).disc;
|
|
assert.notEqual(woken, resting);
|
|
assert.ok(resting.isDestroyed());
|
|
assert.equal(pulsePrimitives(scene).count, 1);
|
|
// Woken again without moving: the disc stays.
|
|
rendering.wakePulses();
|
|
assert.equal(pulsePrimitives(scene).disc, woken);
|
|
});
|
|
|
|
test('the ring and pulses never catch a click: no entities, every primitive unpickable', (t) => {
|
|
for (const reducedMotion of [false, true]) {
|
|
const { entities, scene, rendering } = ringRendering(t, { reducedMotion });
|
|
rendering.renderVisual(flight(3));
|
|
// Cesium always picks entities, so the awareness visual adds none.
|
|
assert.equal(entities.values.length, 0);
|
|
assert.ok(scene.primitives.length >= 1);
|
|
for (let i = 0; i < scene.primitives.length; i++)
|
|
assert.equal(scene.primitives.get(i).allowPicking, false);
|
|
rendering.clearVisual();
|
|
}
|
|
});
|
|
|
|
test('a camera below the rim height hides the pulse disc instead of sweeping the sky', (t) => {
|
|
const ring = ringRendering(t);
|
|
const { scene, rendering } = ring;
|
|
const loop = frameLoop(t, ring);
|
|
rendering.renderVisual(flight(3));
|
|
loop.advance(AWARENESS_RANGE_PULSE.settleMs + 500);
|
|
const cameraAt = (height) => {
|
|
scene.camera = {
|
|
positionCartographic: new Cesium.Cartographic(0, 0, height),
|
|
};
|
|
scene.preRender.raiseEvent(scene);
|
|
return pulsePrimitives(scene).disc.show;
|
|
};
|
|
assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M + 500), true);
|
|
assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M - 500), false, 'cockpit view');
|
|
// Below the disc the pulses rest with it: the ring is steady at full, no
|
|
// echo lights and no frame is asked for.
|
|
const core = Cesium.Color.fromCssColorString(
|
|
AWARENESS_RANGE_RING_STYLE.color,
|
|
);
|
|
assert.ok(Math.abs(ringColors(scene).color.alpha - core.alpha) < 1e-9);
|
|
assert.equal(getAwarenessEchoField().level(loop.now()), 0);
|
|
const frames = ring.pulseFrames();
|
|
loop.draw();
|
|
loop.advance(2000);
|
|
assert.equal(ring.pulseFrames(), frames, 'no requests below the rim');
|
|
assert.equal(cameraAt(800_000), true);
|
|
});
|
|
|
|
test('reduced motion keeps the static ring and drops the pulses', (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
|
const { scene, rendering, pulseFrames } = ringRendering(t, {
|
|
reducedMotion: true,
|
|
});
|
|
rendering.renderVisual(flight(3));
|
|
assert.ok(pulsePrimitives(scene).ring);
|
|
t.mock.timers.tick(1000);
|
|
assert.equal(pulseFrames(), 0);
|
|
assert.equal(pulsePrimitives(scene).count, 0, 'no pulse disc');
|
|
assert.equal(scene.preRender.numberOfListeners, 0);
|
|
assert.equal(scene.postRender.numberOfListeners, 0);
|
|
});
|
|
|
|
test('turning reduced motion on mid-session stops the pulses, and off restarts them', (t) => {
|
|
const ring = ringRendering(t);
|
|
const { scene, rendering, motion, state } = ring;
|
|
const loop = frameLoop(t, ring);
|
|
state.subject = flight(3);
|
|
rendering.renderVisual(state.subject);
|
|
assert.equal(pulsePrimitives(scene).count, 1);
|
|
loop.advance(1000);
|
|
// The OS setting changes while the same aircraft stays tracked.
|
|
motion.set(true);
|
|
assert.ok(pulsePrimitives(scene).ring);
|
|
assert.equal(pulsePrimitives(scene).count, 0);
|
|
assert.equal(scene.preRender.numberOfListeners, 0);
|
|
assert.equal(scene.postRender.numberOfListeners, 0);
|
|
assert.equal(getAwarenessEchoField(), null);
|
|
const frames = ring.pulseFrames();
|
|
loop.advance(5000);
|
|
assert.equal(ring.pulseFrames(), frames);
|
|
motion.set(false);
|
|
assert.equal(pulsePrimitives(scene).count, 1);
|
|
assert.ok(getAwarenessEchoField());
|
|
// One media-query listener per visual; none once the ring is gone.
|
|
assert.equal(motion.listeners(), 1);
|
|
rendering.clearVisual();
|
|
assert.equal(motion.listeners(), 0);
|
|
});
|
|
|
|
test('a reduced-motion change reaches older Safari through addListener', (t) => {
|
|
const { scene, rendering, motion, state } = ringRendering(t, {
|
|
legacyQuery: true,
|
|
});
|
|
state.subject = flight(3);
|
|
rendering.renderVisual(state.subject);
|
|
assert.equal(pulsePrimitives(scene).count, 1);
|
|
assert.equal(motion.listeners(), 1);
|
|
motion.set(true);
|
|
assert.equal(pulsePrimitives(scene).count, 0);
|
|
assert.equal(scene.preRender.numberOfListeners, 0);
|
|
rendering.clearVisual();
|
|
assert.equal(motion.listeners(), 0);
|
|
});
|
|
|
|
test('a refresh after reduced motion changed rebuilds the visual too', (t) => {
|
|
const { scene, rendering, motion } = ringRendering(t);
|
|
rendering.renderVisual(flight(3));
|
|
assert.equal(pulsePrimitives(scene).count, 1);
|
|
// No change event: the next same-subject refresh notices on its own.
|
|
motion.setQuietly(true);
|
|
rendering.renderVisual(flight(3));
|
|
assert.ok(pulsePrimitives(scene).ring);
|
|
assert.equal(pulsePrimitives(scene).count, 0);
|
|
assert.equal(scene.preRender.numberOfListeners, 0);
|
|
});
|
|
|
|
test('while pulses run the ring flashes as each one arrives, then rests', (t) => {
|
|
const ring = ringRendering(t);
|
|
const { scene, rendering } = ring;
|
|
const loop = frameLoop(t, ring);
|
|
rendering.renderVisual(flight(3));
|
|
const core = Cesium.Color.fromCssColorString(
|
|
AWARENESS_RANGE_RING_STYLE.color,
|
|
);
|
|
const halo = Cesium.Color.fromCssColorString(
|
|
AWARENESS_RANGE_RING_STYLE.outlineColor,
|
|
);
|
|
const { periodMs, count } = AWARENESS_RANGE_PULSE;
|
|
const { flashMs, restOpacity } = AWARENESS_RANGE_RING_STYLE;
|
|
// Full the moment a front reaches 250 km, gone `flashMs` later.
|
|
const arrival = periodMs * 6; // pulse 0 wraps here
|
|
assert.ok(Math.abs(awarenessRingFlash(arrival) - 1) < 1e-6);
|
|
assert.ok(awarenessRingFlash(arrival + flashMs / 2) > 0.2);
|
|
assert.equal(awarenessRingFlash(arrival + flashMs + 1), 0);
|
|
// Between flashes the 250 km edge stays readable, dimmed, not gone.
|
|
assert.ok(restOpacity >= 0.35 && restOpacity <= 0.5, `${restOpacity}`);
|
|
// Once per arriving pulse: `count` flashes in any one-period window that
|
|
// starts after a flash has faded.
|
|
let flashes = 0;
|
|
let wasLit = false;
|
|
const from = arrival + flashMs + 1;
|
|
for (let ms = 0; ms < periodMs; ms += 10) {
|
|
const lit = awarenessRingFlash(from + ms) > 0.5;
|
|
if (lit && !wasLit) flashes++;
|
|
wasLit = lit;
|
|
}
|
|
assert.equal(flashes, count);
|
|
// Once the pulses have faded in, the live colour tracks the flash, and
|
|
// never drops below `restOpacity`.
|
|
loop.advance(AWARENESS_RANGE_PULSE.settleMs);
|
|
for (let step = 0; step < 40; step++) {
|
|
loop.advance(100);
|
|
const { color, outlineColor } = ringColors(scene);
|
|
const flash = Math.max(restOpacity, awarenessRingFlash(loop.now()));
|
|
const expected = core.alpha * flash;
|
|
assert.ok(
|
|
Math.abs(color.alpha - expected) < 1e-9,
|
|
`${color.alpha} vs ${expected}`,
|
|
);
|
|
assert.ok(Math.abs(outlineColor.alpha - halo.alpha * flash) < 1e-9);
|
|
}
|
|
});
|
|
|
|
test('without pulses the ring stays on screen at full strength', (t) => {
|
|
const { scene, rendering } = ringRendering(t, { reducedMotion: true });
|
|
rendering.renderVisual(flight(3));
|
|
assert.equal(scene.preRender.numberOfListeners, 0);
|
|
assert.equal(
|
|
ringColors(scene).color.toCssColorString(),
|
|
Cesium.Color.fromCssColorString(
|
|
AWARENESS_RANGE_RING_STYLE.color,
|
|
).toCssColorString(),
|
|
);
|
|
assert.equal(getAwarenessEchoField(), null);
|
|
});
|
|
|
|
test('the echo field follows the subject while pulses run and clears with the ring', (t) => {
|
|
const { rendering } = ringRendering(t);
|
|
rendering.renderVisual(flight(3));
|
|
const field = getAwarenessEchoField();
|
|
assert.ok(field);
|
|
assert.equal(field.radiusM, AWARENESS_RADIUS_M);
|
|
assert.equal(field.pulse, AWARENESS_RANGE_PULSE);
|
|
assert.equal(field.echo, AWARENESS_RANGE_ECHO);
|
|
assert.ok(
|
|
Cesium.Cartesian3.equalsEpsilon(
|
|
field.center,
|
|
Cesium.Cartesian3.fromDegrees(3, 27, 0),
|
|
0,
|
|
1e-3,
|
|
),
|
|
);
|
|
// ~1 km later the same field has moved with the subject.
|
|
rendering.renderVisual(flight(3.01));
|
|
assert.equal(getAwarenessEchoField(), field);
|
|
assert.ok(
|
|
Cesium.Cartesian3.equalsEpsilon(
|
|
field.center,
|
|
Cesium.Cartesian3.fromDegrees(3.01, 27, 0),
|
|
0,
|
|
1e-3,
|
|
),
|
|
);
|
|
rendering.clearVisual();
|
|
assert.equal(getAwarenessEchoField(), null);
|
|
});
|