diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c4e2b11bc..be0d26204 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4957,7 +4957,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. -- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc is rebuilt only after the subject moves 500 m, and not at all while the pulses rest, and it is hidden while the camera is below its 2.5 km height, where it would sweep across the sky. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. +- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc is rebuilt only after the subject moves 500 m, and not at all while the pulses rest, and it is hidden while the camera is below its 2.5 km height, where it would sweep across the sky. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index 9acabb822..11fb866ae 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -50,6 +50,8 @@ export const CONTEXT_RIM_HEIGHT_M = 2500; * luminance contrast on light and dark ground alike, and luminance is what * the monochrome styles keep. The core is the Context panel's blue; the halo * is the map-card ink. Widths are screen pixels; `outlineWidth` is per side. + * It is drawn as an unpickable primitive, so clicks on its edge reach the + * contacts underneath. * While the sonar pulses run, the ring dims to `restOpacity` and flashes * to full as each pulse reaches it, fading over `flashMs`. `restOpacity` * keeps the 250 km edge readable between flashes, in screenshots and for diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 01beac378..f005d3b21 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -25,6 +25,7 @@ import { awarenessPulseState, awarenessRangeRingPositions, awarenessRingFlash, + awarenessRingGeometryOptions, createRendering, wakeAwarenessPulses, } from './rendering.js'; @@ -155,15 +156,22 @@ function frameLoop(t, ring) { }; } -/** The pulse gradient disc and the echo collection among scene primitives. */ +/** 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), ); - return { - disc: all.find((p) => p instanceof Cesium.Primitive), - count: all.length, - }; + 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 }; +} + +/** 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) => ({ @@ -231,56 +239,65 @@ test('the ring core and halo contrast on light and dark ground', () => { }); test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) => { - const { entities, rendering } = ringRendering(t); + const { entities, scene, state, rendering } = ringRendering(t); rendering.renderVisual(flight(3)); - assert.equal(entities.values.length, 1); - const [entity] = entities.values; - assert.equal(entity.ellipse, undefined); - const now = Cesium.JulianDate.now(); - assert.equal(entity.polyline.width.getValue(now), 4); - assert.equal(entity.polyline.arcType.getValue(now), Cesium.ArcType.NONE); - const material = entity.polyline.material; - assert.ok(material instanceof Cesium.PolylineOutlineMaterialProperty); + // A polyline primitive, never an entity or an ellipse outline. + assert.equal(entities.values.length, 0); + const { ring } = pulsePrimitives(scene); + assert.ok(ring); + const geometry = ring.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 } = ring.appearance; + assert.equal(material.type, 'PolylineOutline'); // The ring's hue is fixed; its opacity flashes with arriving pulses. const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); - const color = material.color.getValue(now); + const { color, outlineWidth } = ringColors(scene); assert.equal(color.red, core.red); assert.equal(color.blue, core.blue); assert.ok(color.alpha >= 0 && color.alpha <= core.alpha + 1e-9); - assert.equal( - material.outlineWidth.getValue(now), - AWARENESS_RANGE_RING_STYLE.outlineWidth, - ); + assert.equal(outlineWidth, AWARENESS_RANGE_RING_STYLE.outlineWidth); const ground = Cesium.Cartesian3.fromDegrees(3, 27, 0); - for (const distance of surfaceDistances( - ground, - entity.polyline.positions.getValue(now), - )) + 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 { entities, scene, rendering, renders } = ringRendering(t); + const { scene, state, rendering, renders } = ringRendering(t); const subject = flight; rendering.renderVisual(subject(3)); - const [entity] = entities.values; - const now = Cesium.JulianDate.now(); - const first = entity.polyline.positions.getValue(now); + 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(entity.polyline.positions.getValue(now), first); + assert.equal(pulsePrimitives(scene).ring, ringBefore); + assert.equal(state.visual.ringPositions, first); assert.equal(renders(), rendersAfterAdd); - // ~1 km east: the same entity moves to the new centre. + // ~1 km east: the ring is rebuilt around the new centre, keeping its + // material, so a flash carries on. rendering.renderVisual(subject(3.01)); - assert.equal(entities.values.length, 1); - assert.equal(entities.values[0], entity); - const moved = entity.polyline.positions.getValue(now); + 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), @@ -293,7 +310,7 @@ test('the ring and pulses follow the subject past 250 m and ignore smaller drift assert.notEqual(pulsePrimitives(scene).disc, discBefore); rendering.clearVisual(); - assert.equal(entities.values.length, 0); + assert.equal(scene.primitives.length, 0); }); test('a pulse grows slowly from the subject and arrives dim at the ring', () => { @@ -354,7 +371,7 @@ test('a pulse grows slowly from the subject and arrives dim at the ring', () => test('pulses ask for a frame 80 ms after each drawn frame instead of holding continuous rendering', (t) => { const ring = ringRendering(t); - const { entities, rendering, pulseFrames } = ring; + 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 @@ -365,7 +382,7 @@ test('pulses ask for a frame 80 ms after each drawn frame instead of holding con assert.ok(Math.abs(pulseFrames() - 10) <= 1, `${pulseFrames()} frames`); const frames = pulseFrames(); rendering.clearVisual(); - assert.equal(entities.values.length, 0); + 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'); @@ -373,7 +390,7 @@ test('pulses ask for a frame 80 ms after each drawn frame instead of holding con test('a parked view stops asking for frames once the pulses rest', (t) => { const ring = ringRendering(t); - const { entities, scene, rendering, pulseFrames } = ring; + 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. @@ -387,17 +404,10 @@ test('a parked view stops asking for frames once the pulses rest', (t) => { 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 now = Cesium.JulianDate.now(); const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); - const [ringEntity] = entities.values; - assert.ok( - Math.abs( - ringEntity.polyline.material.color.getValue(now).alpha - core.alpha, - ) < 1e-9, - ); - scene.preRender.raiseEvent(scene); + 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); }); @@ -526,8 +536,10 @@ test('a scene without primitives or preRender gets the static ring only', (t) => 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); @@ -539,6 +551,7 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( 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); @@ -632,14 +645,17 @@ test('a resting pulse disc is not rebuilt as the subject moves, and catches up o assert.equal(pulsePrimitives(scene).disc, woken); }); -test('pulses never catch a click: only the ring is an entity, and the disc is unpickable', (t) => { - const { entities, scene, rendering } = ringRendering(t); - rendering.renderVisual(flight(3)); - // The ring is #1518's entity; the pulses add no entity of their own. - assert.equal(entities.values.length, 1); - assert.equal(entities.values[0].polyline.positions.isConstant, true); - for (let i = 0; i < scene.primitives.length; i++) - assert.equal(scene.primitives.get(i).allowPicking, false); +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) => { @@ -662,32 +678,30 @@ test('a camera below the rim height hides the pulse disc instead of sweeping the test('reduced motion keeps the static ring and drops the pulses', (t) => { t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); - const { entities, scene, rendering, pulseFrames } = ringRendering(t, { + const { scene, rendering, pulseFrames } = ringRendering(t, { reducedMotion: true, }); rendering.renderVisual(flight(3)); - assert.equal(entities.values.length, 1); - assert.equal(entities.values[0].polyline.positions.isConstant, true); + assert.ok(pulsePrimitives(scene).ring); t.mock.timers.tick(1000); assert.equal(pulseFrames(), 0); - assert.equal(scene.primitives.length, 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 { entities, scene, rendering, motion, state } = ring; + const { scene, rendering, motion, state } = ring; const loop = frameLoop(t, ring); state.subject = flight(3); rendering.renderVisual(state.subject); - assert.equal(scene.primitives.length, 1); + assert.equal(pulsePrimitives(scene).count, 1); loop.advance(1000); // The OS setting changes while the same aircraft stays tracked. motion.set(true); - assert.equal(entities.values.length, 1); - assert.equal(entities.values[0].polyline.material.color.isConstant, true); - assert.equal(scene.primitives.length, 0); + 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); @@ -695,7 +709,7 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t loop.advance(5000); assert.equal(ring.pulseFrames(), frames); motion.set(false); - assert.equal(scene.primitives.length, 1); + 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); @@ -704,41 +718,43 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t }); test('a reduced-motion change reaches older Safari through addListener', (t) => { - const { entities, scene, rendering, motion, state } = ringRendering(t, { + const { scene, rendering, motion, state } = ringRendering(t, { legacyQuery: true, }); state.subject = flight(3); rendering.renderVisual(state.subject); - assert.equal(scene.primitives.length, 1); + assert.equal(pulsePrimitives(scene).count, 1); assert.equal(motion.listeners(), 1); motion.set(true); - assert.equal(scene.primitives.length, 0); - assert.equal(entities.values[0].polyline.material.color.isConstant, 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 { entities, rendering, motion } = ringRendering(t); + const { scene, rendering, motion } = ringRendering(t); rendering.renderVisual(flight(3)); - assert.equal(entities.values[0].polyline.material.color.isConstant, false); + 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.equal(entities.values.length, 1); - assert.equal(entities.values[0].polyline.material.color.isConstant, true); + 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 { entities, rendering } = ring; + const { scene, rendering } = ring; const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); - const ringEntity = entities.values[0]; const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); - assert.equal(ringEntity.polyline.material.color.isConstant, false); + 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. @@ -764,22 +780,23 @@ test('while pulses run the ring flashes as each one arrives, then rests', (t) => loop.advance(AWARENESS_RANGE_PULSE.settleMs); for (let step = 0; step < 40; step++) { loop.advance(100); - const alpha = ringEntity.polyline.material.color.getValue( - Cesium.JulianDate.now(), - ).alpha; - const expected = - core.alpha * Math.max(restOpacity, awarenessRingFlash(loop.now())); - assert.ok(Math.abs(alpha - expected) < 1e-9, `${alpha} vs ${expected}`); + 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 { entities, rendering } = ringRendering(t, { reducedMotion: true }); + const { scene, rendering } = ringRendering(t, { reducedMotion: true }); rendering.renderVisual(flight(3)); - const color = entities.values[0].polyline.material.color; - assert.equal(color.isConstant, true); + assert.equal(scene.preRender.numberOfListeners, 0); assert.equal( - color.getValue(Cesium.JulianDate.now()).toCssColorString(), + ringColors(scene).color.toCssColorString(), Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ).toCssColorString(), diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 3e929752b..5a5b5689b 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -267,6 +267,47 @@ function createPulseGradient(groundCenter, material) { }); } +/** + * The ring's polyline geometry: `AWARENESS_RANGE_RING_STYLE.width` screen + * pixels, straight chords between the given positions (they already follow + * the surface), in the material appearance's vertex format. + * @param {Cesium.Cartesian3[]} positions Ring positions. + * @returns {object} PolylineGeometry options. + */ +export function awarenessRingGeometryOptions(positions) { + return { + positions, + width: AWARENESS_RANGE_RING_STYLE.width, + arcType: Cesium.ArcType.NONE, + vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT, + }; +} + +/** + * The 250 km ring as an unpickable polyline primitive in the ring's outline + * material, so a click or tap on its edge reaches the contact underneath. + * Its positions are geometry, so a moved subject rebuilds it; the material + * is shared between builds so a flash keeps going. + * @param {Cesium.Cartesian3[]} positions Ring positions. + * @param {Cesium.Material} material Shared PolylineOutline material. + * @returns {Cesium.Primitive} + */ +function createRingPrimitive(positions, material) { + return new Cesium.Primitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry: new Cesium.PolylineGeometry( + awarenessRingGeometryOptions(positions), + ), + }), + appearance: new Cesium.PolylineMaterialAppearance({ + material, + translucent: true, + }), + asynchronous: false, + allowPicking: false, + }); +} + /** Camera height above the ellipsoid, or NaN when the scene cannot say. */ function cameraHeightM(scene) { return scene.camera?.positionCartographic?.height ?? Number.NaN; @@ -570,6 +611,12 @@ export function createRendering({ layerState.visual?.removeMotionListener?.(); if (layerState.visual?.gradient && layerState.viewer) layerState.viewer.scene.primitives.remove(layerState.visual.gradient); + if (layerState.visual?.ringPrimitive && layerState.viewer) { + layerState.viewer.scene.primitives.remove?.( + layerState.visual.ringPrimitive, + ); + governorRequestRender('awareness-visual'); + } clearAwarenessEchoField(); if (layerState.visual?.entities && layerState.viewer) { for (const entity of layerState.visual.entities) @@ -610,7 +657,15 @@ export function createRendering({ // The echo field reads `visual.center` in place and follows on its // own. The pulse disc is geometry, so it is rebuilt, but only every // `gradientRebuildM`: a build costs a few milliseconds. - layerState.visual.ring.polyline.positions.setValue(positions); + layerState.visual.ringPositions = positions; + const { ringPrimitive } = layerState.visual; + if (ringPrimitive) { + const primitives = layerState.viewer.scene.primitives; + layerState.visual.ringPrimitive = primitives.add( + createRingPrimitive(positions, ringPrimitive.appearance.material), + ); + primitives.remove(ringPrimitive); + } else layerState.visual.ring.polyline.positions.setValue(positions); Cesium.Cartesian3.clone(groundCenter, layerState.visual.center); // A resting disc is hidden, so it catches up when the pulses wake. if (performance.now() < layerState.visual.wake?.untilMs) @@ -647,36 +702,48 @@ export function createRendering({ const ring = AWARENESS_RANGE_RING_STYLE; const core = Cesium.Color.fromCssColorString(ring.color); const halo = Cesium.Color.fromCssColorString(ring.outlineColor); + const ringPositions = awarenessRangeRingPositions( + groundCenter, + AWARENESS_RADIUS_M, + CONTEXT_RIM_HEIGHT_M, + ); // While pulses run, the ring flashes as each one arrives and otherwise // dims to `restOpacity`; as they settle it returns to full and stays. - const flashing = (base) => { - const color = base.clone(); - return new Cesium.CallbackProperty(() => { - const nowMs = performance.now(); - const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs)); - color.alpha = base.alpha * (1 - levelAt(nowMs) * (1 - pulsed)); - return color; - }, false); + const ringAlpha = (nowMs) => { + const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs)); + return 1 - levelAt(nowMs) * (1 - pulsed); }; - const ringEntity = layerState.viewer.entities.add({ - polyline: { - positions: new Cesium.ConstantProperty( - awarenessRangeRingPositions( - groundCenter, - AWARENESS_RADIUS_M, - CONTEXT_RIM_HEIGHT_M, - ), - ), - width: ring.width, - arcType: Cesium.ArcType.NONE, - material: new Cesium.PolylineOutlineMaterialProperty({ - color: animate ? flashing(core) : core, - outlineColor: animate ? flashing(halo) : halo, - outlineWidth: ring.outlineWidth, - }), - }, - }); - entities.push(ringEntity); + let ringEntity = null; + let ringMaterial = null; + let ringPrimitive = null; + if (scene?.primitives?.add) { + ringMaterial = Cesium.Material.fromType('PolylineOutline', { + color: core.clone(), + outlineColor: halo.clone(), + outlineWidth: ring.outlineWidth, + }); + ringPrimitive = + scene.primitives.add( + createRingPrimitive(ringPositions, ringMaterial), + ) ?? null; + } + if (!ringPrimitive) { + // Headless tools and partial stubs have no primitive collection; they + // get the static ring as an entity. + ringEntity = layerState.viewer.entities.add({ + polyline: { + positions: new Cesium.ConstantProperty(ringPositions), + width: ring.width, + arcType: Cesium.ArcType.NONE, + material: new Cesium.PolylineOutlineMaterialProperty({ + color: core, + outlineColor: halo, + outlineWidth: ring.outlineWidth, + }), + }, + }); + entities.push(ringEntity); + } const center = Cesium.Cartesian3.clone(groundCenter); let gradient = null; @@ -707,6 +774,9 @@ export function createRendering({ const removePreRender = scene.preRender.addEventListener(() => { const nowMs = performance.now(); const level = levelAt(nowMs); + const alpha = ringAlpha(nowMs); + ringMaterial.uniforms.color.alpha = core.alpha * alpha; + ringMaterial.uniforms.outlineColor.alpha = halo.alpha * alpha; // The disc is looked up each frame: a subject move swaps it. From a // camera below the rim height it would sweep across the sky, so it // is hidden there. @@ -767,6 +837,8 @@ export function createRendering({ key, entities, ring: ringEntity, + ringPrimitive, + ringPositions, center, gradient, gradientCenter: Cesium.Cartesian3.clone(groundCenter),