Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions docs/3d-flight-replay-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ viewer was built from) as background.
## 1. What was built

- A page `/replay` showing all ~32 pilots of Corryong Cup 2026 Task 1
with synchronized playback, pilot identity, altitude/vario colouring, task
with synchronized playback, pilot identity, trail colouring (pilot /
altitude / vertical speed [default] / **speed** / **glide ratio**), task
geometry, gaggle detection, and a selectable backdrop (abstract vs Mapbox
terrain).
terrain). Speed and glide colour from a per-vertex smoothed ground speed
computed at load (prefix-sum path length over the same ±3-fix window as
vario). The glide ramp is **logarithmic** over `GLIDE_LO..GLIDE_HI` (2…32 —
a 4→8 improvement reads as strongly as 16→32); climbing/level segments
render flat in the themed vario-zero colour (glide is effectively ∞ there).
- **Per-pilot live metrics** (see §5.15): rank badges pinned to the marker
cones, and a draggable metrics callout (altitude / climb / ground speed /
glide ratio) with a leader line to the pilot's cone. There is deliberately
Expand Down Expand Up @@ -372,6 +377,25 @@ get it for free; `depthTest: false` keeps it over the terrain like the rings.
and this matches the 2D analysis map exactly. Don't "fix" it to start at the
SSS edge; it's intentional and rule-correct.

### 5.14b Backdrop/theme switches hand the camera over (ViewState)

Switching abstract ↔ map (or rebuilding for a theme change) used to snap to
each backend's default framing — a jarring loss of continuity. Both camera
models reduce to the same five numbers, so `rebuild()` captures a
backend-agnostic **`ViewState`** from the outgoing backend and seeds the
incoming one (`setInitialView` before `mount()`, applied instead of the
default whole-task framing): look-at point in local ENU (`y` = 0 from the
map side — it has no elevated look-at), `bearingDeg`, `pitchDeg` (0 =
straight down, Mapbox convention; abstract's polar angle maps 1:1, clamped
to Mapbox's 85° max), and **`mpp`** (metres per pixel at the view centre) —
which carries the zoom without either side knowing the other's projection.
Abstract: camera distance `= mpp·viewportH / (2·tan(fov/2))`, azimuth
`θ = −bearing` (θ grows counter-clockwise, bearing clockwise). Map:
`zoom = log2(40075016.686·cos(lat) / mpp) − 9` — the exact inverse of its
`getMetresPerPixel`. Round-trip pinned by `terrain-view.test.ts`; the
abstract path is exercised by every theme switch (verified: bearing and
scale-bar width survive a rebuild).

### 5.15 Per-pilot metrics overlays — DOM, not in-scene sprites

Rank badges on the cones and the follow callout are **DOM elements positioned
Expand Down Expand Up @@ -431,7 +455,7 @@ remains only for the gaggle-ribbon hovers (GaggleUI).

**Theme (dark / light / auto)**: a switch in the control drawer, stored as
`theme` in the same `glidecomp:preferences` record ('system' = auto, follows
`prefers-color-scheme` live). Light mode uses an **off-white** background
`prefers-color-scheme` live; **auto is the default** when nothing is stored). Light mode uses an **off-white** background
(`#f2f0e9`, deliberately not full white). Mechanics: main.ts toggles a
`light` class on `<html>`; the page's own `<style>` defines `--rp-*` tokens
plus scoped remaps of the dark slate utilities the markup uses (the page is
Expand Down
6 changes: 4 additions & 2 deletions web/frontend/src/replay.html
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,11 @@ <h3 class="text-[11px] uppercase tracking-wide text-lime-400 font-semibold">View
<label class="block text-[11px] uppercase tracking-wide text-slate-400 mb-1">Theme</label>
<div class="grid grid-cols-3 gap-1">
<button id="thDark"
class="rounded-md border border-slate-600 px-2 py-1 text-xs transition-colors bg-lime-500 text-slate-900">Dark</button>
class="rounded-md border border-slate-600 px-2 py-1 text-xs transition-colors bg-slate-800 text-slate-300 hover:bg-slate-700">Dark</button>
<button id="thLight"
class="rounded-md border border-slate-600 px-2 py-1 text-xs transition-colors bg-slate-800 text-slate-300 hover:bg-slate-700">Light</button>
<button id="thAuto"
class="rounded-md border border-slate-600 px-2 py-1 text-xs transition-colors bg-slate-800 text-slate-300 hover:bg-slate-700"
class="rounded-md border border-slate-600 px-2 py-1 text-xs transition-colors bg-lime-500 text-slate-900"
title="Follow the system colour scheme">Auto</button>
</div>
</div>
Expand All @@ -212,6 +212,8 @@ <h3 class="text-[11px] uppercase tracking-wide text-lime-400 font-semibold">View
<option value="pilot">Pilot</option>
<option value="altitude">Altitude</option>
<option value="vario" selected>Vertical speed</option>
<option value="speed">Speed</option>
<option value="glide">Glide ratio</option>
</select>
<!-- colour scale legend (shown for altitude / vertical speed) -->
<div id="colorLegend" class="hidden mt-1.5">
Expand Down
47 changes: 45 additions & 2 deletions web/frontend/src/replay/abstract-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import type { Backend, ScreenPoint } from './backend';
import type { Backend, ScreenPoint, ViewState } from './backend';
import type { FlightScene, MarkerSample } from './flight-scene';

export class AbstractBackend implements Backend {
Expand All @@ -20,6 +20,8 @@ export class AbstractBackend implements Backend {
private onPointerDown!: (e: PointerEvent) => void;
private vScale = 3;
private v = new THREE.Vector3(); // scratch for projection / bearing
/** Camera pose to adopt on mount instead of the default framing. */
private initialView: ViewState | null = null;

// follow state: track the pilot's frame-to-frame movement rather than snapping
// the camera onto it, so the user can pan/orbit/zoom while following.
Expand Down Expand Up @@ -89,7 +91,8 @@ export class AbstractBackend implements Backend {
this.scene.add(this.flight.group);
this.scene.add(this.flight.markers);
this.buildGround();
this.resetCamera();
if (this.initialView) this.applyView(this.initialView);
else this.resetCamera();

this.resizeObs = new ResizeObserver(() => this.resize());
this.resizeObs.observe(this.container);
Expand Down Expand Up @@ -173,6 +176,46 @@ export class AbstractBackend implements Backend {
this.vScale = v;
}

setInitialView(view: ViewState): void {
this.initialView = view;
}

getViewState(): ViewState {
const offset = this.v.subVectors(this.camera.position, this.controls.target);
const r = offset.length();
// polar angle from +Y: 0 = camera straight above (top-down) — matches
// Mapbox's pitch convention directly.
const pitchDeg = (Math.acos(Math.max(-1, Math.min(1, offset.y / r))) * 180) / Math.PI;
return {
x: this.controls.target.x,
y: this.controls.target.y,
z: this.controls.target.z,
bearingDeg: this.getBearingDeg(),
pitchDeg,
mpp: this.getMetresPerPixel(),
};
}

/**
* Adopt a handed-over camera pose: look at (x, y, z) from the bearing/pitch,
* at a distance that reproduces the same metres-per-pixel at the target
* (inverse of getMetresPerPixel). Azimuth θ = −bearing: θ = 0 puts the
* camera due south looking north (bearing 0), and bearing grows clockwise
* while θ grows counter-clockwise.
*/
private applyView(v: ViewState): void {
const h = this.container.clientHeight || 600;
const dist = (v.mpp * h) / (2 * Math.tan((this.camera.fov * Math.PI) / 360));
const phi = Math.max(
0.02,
Math.min(this.controls.maxPolarAngle, (v.pitchDeg * Math.PI) / 180),
);
this.controls.target.set(v.x, v.y, v.z);
this.sph.set(dist, phi, (-v.bearingDeg * Math.PI) / 180);
this.camera.position.copy(this.controls.target).add(this.v.setFromSpherical(this.sph));
this.controls.update();
}

followTo(sample: MarkerSample | null): void {
if (!sample) {
// explicit stop
Expand Down
27 changes: 27 additions & 0 deletions web/frontend/src/replay/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,40 @@ export interface ScreenPoint {
visible: boolean;
}

/**
* Backend-agnostic camera pose, used to hand the view over when the backdrop
* (or theme) switches so the user keeps visual continuity. Both camera models
* reduce to the same five numbers:
* - the look-at point in local ENU metres (`x`, `y`, `z`; the map backend has
* no elevated look-at, so it reports/uses y = 0),
* - `bearingDeg` (compass heading, same convention as getBearingDeg),
* - `pitchDeg` (0 = straight down, Mapbox-style),
* - `mpp` — metres per pixel at the view centre, which carries the zoom
* without either side needing the other's projection model.
*/
export interface ViewState {
x: number;
y: number;
z: number;
bearingDeg: number;
pitchDeg: number;
mpp: number;
}

export interface Backend {
/** Async because the terrain backend must wait for the map style to load. */
mount(): Promise<void>;
/** Draw the current scene state. */
render(): void;
/** Frame the whole task. */
resetCamera(): void;
/** Current camera pose for handover to another backend. */
getViewState(): ViewState;
/**
* Seed the camera pose to adopt on mount (instead of the default whole-task
* framing). Must be called before mount().
*/
setInitialView(view: ViewState): void;
/** Spin the view so north is up, keeping zoom/pitch and any active follow. */
faceNorth(): void;
/** Orient straight down (north up), keeping any active follow. */
Expand Down
54 changes: 49 additions & 5 deletions web/frontend/src/replay/flight-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { samplePilot, type LoadedTracks } from './track-data';
import { type GaggleResult } from './gaggles';
import { GaggleLayer } from './gaggle-layer';

export type ColorMode = 'pilot' | 'altitude' | 'vario';
export type ColorMode = 'pilot' | 'altitude' | 'vario' | 'speed' | 'glide';

const UP = new THREE.Vector3(0, 1, 0);
const WALL_HEIGHT = 1400; // metres, task-cylinder walls (pre vertical exaggeration)
Expand All @@ -32,6 +32,23 @@ const TASK_LINE_COLOR = 0x6366f1; // indigo — matches the 2D analysis optimise
/** Vertical-speed colour mode saturates at ±this many m/s. */
export const VARIO_MAX = 4;

/**
* Speed colour mode ramps from SPEED_MIN to SPEED_MAX m/s (≈18–108 km/h).
* The floor isn't 0 because nobody flies below stall speed — starting the
* ramp there would waste its bottom third and compress the useful range.
*/
export const SPEED_MIN = 5;
export const SPEED_MAX = 30;

/**
* Glide-ratio colour mode: the ramp runs logarithmically from GLIDE_LO to
* GLIDE_HI (ratios span a large range, and 4 → 8 matters as much as 16 → 32).
* Climbing / level segments render as a flat colour — glide ratio is
* effectively infinite there (the shader reuses the themed vario-zero colour).
*/
export const GLIDE_LO = 2;
export const GLIDE_HI = 32;

/** One pilot's interpolated marker state in local ENU metres (y already exaggerated). */
export interface MarkerSample {
pilot: number;
Expand Down Expand Up @@ -123,6 +140,7 @@ export class FlightScene {
geom.setAttribute('aTime', new THREE.BufferAttribute(time, 1));
geom.setAttribute('aPilot', new THREE.BufferAttribute(pilotIndex, 1));
geom.setAttribute('aVario', new THREE.BufferAttribute(this.tracks.vario, 1));
geom.setAttribute('aSpeed', new THREE.BufferAttribute(this.tracks.speed, 1));
geom.setIndex(new THREE.BufferAttribute(index, 1));

const nPilots = manifest.pilots.length;
Expand All @@ -145,6 +163,8 @@ export class FlightScene {
uAltMin: { value: manifest.altMin },
uAltMax: { value: manifest.altMax },
uVarioMax: { value: VARIO_MAX },
uSpeedMin: { value: SPEED_MIN },
uSpeedMax: { value: SPEED_MAX },
uWidth: { value: this.width * pixelRatio() },
// vario-ramp "zero": pale on the dark backdrop, slate on the light one
uVarioZero: {
Expand All @@ -168,7 +188,7 @@ export class FlightScene {
// Round points carry the adjustable width (gl.LINES width is capped at 1px on
// most platforms). Same attributes, no index (one point per fix), shared uniforms.
const pgeom = new THREE.BufferGeometry();
for (const name of ['position', 'aTime', 'aPilot', 'aVario']) {
for (const name of ['position', 'aTime', 'aPilot', 'aVario', 'aSpeed']) {
pgeom.setAttribute(name, geom.getAttribute(name));
}
const pointMat = new THREE.ShaderMaterial({
Expand Down Expand Up @@ -493,7 +513,8 @@ export class FlightScene {
}

setColorMode(mode: ColorMode): void {
this.trailMat.uniforms.uColorMode.value = mode === 'altitude' ? 1 : mode === 'vario' ? 2 : 0;
const modes: Record<ColorMode, number> = { pilot: 0, altitude: 1, vario: 2, speed: 3, glide: 4 };
this.trailMat.uniforms.uColorMode.value = modes[mode] ?? 0;
}

/** Trail width in CSS px (drives the round-points pass). */
Expand Down Expand Up @@ -593,16 +614,19 @@ function trailVertexShader(nPilots: number): string {
attribute float aTime;
attribute float aPilot;
attribute float aVario;
attribute float aSpeed;
uniform float uTime;
uniform vec3 uColors[${nPilots}];
uniform float uVisible[${nPilots}];
uniform int uColorMode; // 0 = pilot, 1 = altitude, 2 = vertical speed
uniform int uColorMode; // 0 pilot, 1 altitude, 2 vertical speed, 3 speed, 4 glide ratio
uniform int uHighlight; // -1 = none
uniform float uVScale; // vertical exaggeration
uniform float uAltMin;
uniform float uAltMax;
uniform float uVarioMax;
uniform vec3 uVarioZero; // vario-ramp centre colour (theme-dependent)
uniform float uSpeedMin;
uniform float uSpeedMax;
uniform vec3 uVarioZero; // vario-ramp centre / glide "infinity" colour (theme-dependent)
uniform float uWidth; // point size (px); ignored by LineSegments
varying float vAge;
varying vec3 vColor;
Expand All @@ -627,6 +651,14 @@ function trailVertexShader(nPilots: number): string {
return t < 0.5 ? mix(sink, uVarioZero, t / 0.5) : mix(uVarioZero, climb, (t - 0.5) / 0.5);
}

// sequential: red (poor glide) -> yellow -> green (good glide)
vec3 glideRamp(float t) {
vec3 poor = vec3(0.86, 0.24, 0.20);
vec3 mid = vec3(0.95, 0.80, 0.20);
vec3 good = vec3(0.16, 0.68, 0.38);
return t < 0.5 ? mix(poor, mid, t / 0.5) : mix(mid, good, (t - 0.5) / 0.5);
}

void main() {
int pid = int(aPilot + 0.5);
vAge = uTime - aTime;
Expand All @@ -638,6 +670,18 @@ function trailVertexShader(nPilots: number): string {
} else if (uColorMode == 2) {
float a = clamp(aVario / uVarioMax * 0.5 + 0.5, 0.0, 1.0);
vColor = varioRamp(a);
} else if (uColorMode == 3) {
vColor = altRamp(clamp((aSpeed - uSpeedMin) / (uSpeedMax - uSpeedMin), 0.0, 1.0));
} else if (uColorMode == 4) {
if (aVario > -0.05) {
vColor = uVarioZero; // climbing/level: glide is infinite — flat colour
} else {
// log scale over GLIDE_LO..GLIDE_HI (${GLIDE_LO}..${GLIDE_HI}):
// a 4 -> 8 improvement reads as strongly as 16 -> 32
float g = aSpeed / -aVario;
float t = clamp(log2(g / ${GLIDE_LO.toFixed(1)}) / ${Math.log2(GLIDE_HI / GLIDE_LO).toFixed(1)}, 0.0, 1.0);
vColor = glideRamp(t);
}
}
if (uVisible[pid] < 0.5) {
gl_Position = vec4(0.0, 0.0, 0.0, -1.0);
Expand Down
20 changes: 17 additions & 3 deletions web/frontend/src/replay/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
type UnitPreferences,
} from '../analysis/units-browser';
import { MAP_STYLES, DEFAULT_MAP_STYLE } from './map-styles';
import { VARIO_MAX } from './flight-scene';
import { GLIDE_HI, GLIDE_LO, SPEED_MAX, SPEED_MIN, VARIO_MAX } from './flight-scene';
import { GaggleUI } from './gaggle-ui';
import type { GaggleResult } from './gaggles';
import type { TrackManifest } from '@glidecomp/engine';
Expand Down Expand Up @@ -126,6 +126,8 @@ const ALT_GRADIENT =
// vario "zero" is pale on dark, slate on light — mirrors uVarioZero in the trail shader
const VARIO_GRADIENT = 'linear-gradient(to right, rgb(51,115,242), rgb(217,217,224), rgb(242,64,51))';
const VARIO_GRADIENT_LIGHT = 'linear-gradient(to right, rgb(51,115,242), rgb(107,115,128), rgb(242,64,51))';
// mirrors glideRamp in the trail shader (red = poor glide → green = good)
const GLIDE_GRADIENT = 'linear-gradient(to right, rgb(219,61,51), rgb(242,204,51), rgb(41,173,97))';

/** Round n down to a "nice" 1/2/5×10^k value for the scale bar. */
function niceNumber(n: number): number {
Expand All @@ -148,7 +150,8 @@ async function main(): Promise<void> {

// Apply the saved theme to the chrome immediately so a light-mode user never
// sees a dark flash while the tracks load; the full switch is wired below.
const savedTheme = config.getPreferences().theme ?? 'dark';
// Default is auto ('system') — follow the OS unless the user chose a mode.
const savedTheme = config.getPreferences().theme ?? 'system';
document.documentElement.classList.toggle(
'light',
savedTheme === 'system'
Expand Down Expand Up @@ -905,7 +908,7 @@ async function main(): Promise<void> {
system: $('thAuto'),
};
const prefersLight = window.matchMedia('(prefers-color-scheme: light)');
let themeMode: ThemeMode = config.getPreferences().theme ?? 'dark';
let themeMode: ThemeMode = config.getPreferences().theme ?? 'system';
function applyThemeMode(): void {
const light = themeMode === 'system' ? prefersLight.matches : themeMode === 'light';
document.documentElement.classList.toggle('light', light);
Expand Down Expand Up @@ -946,6 +949,17 @@ async function main(): Promise<void> {
$('legendLo').textContent = formatAltitude(alt0 + manifest.altMin).withUnit;
$('legendMid').textContent = '';
$('legendHi').textContent = formatAltitude(alt0 + manifest.altMax).withUnit;
} else if (mode === 'speed') {
$('legendBar').style.background = ALT_GRADIENT; // same sequential ramp as the shader
$('legendLo').textContent = `≤${formatSpeed(SPEED_MIN).withUnit}`;
$('legendMid').textContent = '';
$('legendHi').textContent = `≥${formatSpeed(SPEED_MAX).withUnit}`;
} else if (mode === 'glide') {
$('legendBar').style.background = GLIDE_GRADIENT;
$('legendLo').textContent = `≤${GLIDE_LO}`;
// log-scaled ramp: the midpoint is the geometric mean, not the average
$('legendMid').textContent = String(Math.round(Math.sqrt(GLIDE_LO * GLIDE_HI)));
$('legendHi').textContent = `≥${GLIDE_HI} · climb = grey`;
} else {
$('legendBar').style.background = document.documentElement.classList.contains('light')
? VARIO_GRADIENT_LIGHT
Expand Down
4 changes: 4 additions & 0 deletions web/frontend/src/replay/replay-viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,12 +202,16 @@ export class ReplayViewer {
private async rebuild(mode: Backdrop): Promise<void> {
this.switching = true;
try {
// Hand the camera pose over so switching backdrop (or theme) keeps
// visual continuity instead of snapping to each backend's default frame.
const view = this.backend.getViewState();
this.backend.dispose();
this.scene.dispose();

this.scene = new FlightScene(this.tracks, this.gaggles, this.sceneLight(mode));
this.applySceneState();
this.backend = await this.makeBackend(mode);
this.backend.setInitialView(view);
this.backend.setVScale(this.vScale);
await this.backend.mount();
this.backdrop = mode;
Expand Down
Loading
Loading