@rr0/ufoathome 0.67.0 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,1101 +2,201 @@
2
2
 
3
3
  # UFO@home
4
4
 
5
- **UFO@home** lets a UFO observer record the shape, appearance and movement of what they saw — and replay it like a VCR —
6
- instead of relying only on a written or spoken account. The approach follows [Roger Shepard's
7
- recommendation](https://rr0.org/time/1/9/6/8/07/29/Symposium/Shepard/index_fr.html) that a visual reconstruction of a
8
- account is more faithful than an oral or written one.
5
+ **UFO@home** lets a UFO observer record the shape, appearance and movement of what they saw, and replay it
6
+ against the real sky, weather and place of the observation, instead of relying only on a written or spoken account.
9
7
 
10
- Originally a Java applet (2003), the project has been rewritten from scratch in TypeScript: a small,
11
- dependency-light engine (keyframe timeline, recording, playback, a three.js scene) wrapped in three vanilla
12
- [Web Components](https://developer.mozilla.org/en-US/docs/Web/API/Web_components) — no UI framework, no build step
13
- required by the consuming page. All three pull in [Three.js](https://threejs.org/): the phenomenon itself stands in
14
- the scene, so there is no lighter, sky-less variant — see [`<rr0-scene>`](#rr0-scene--the-scene-and-playback) below
15
- for what that buys.
8
+ Everything about **using** it lives on **[ufoathome.org](https://ufoathome.org)**, which is built from this
9
+ repository (see [The site](#the-site)) so that it always describes the version it ships with:
16
10
 
17
- ### Naming
11
+ - [Demos](https://ufoathome.org/demos/): every published reconstruction, running.
12
+ - [Player](https://ufoathome.org/play/) and [editor](https://ufoathome.org/edit/).
13
+ - [Documentation](https://ufoathome.org/docs/): embedding the components (`<rr0-sighting>`, `<rr0-scene>`,
14
+ `<rr0-sighting-editor>`), the recording format, the data sources, sharing a recording.
15
+ - [Roadmap](https://ufoathome.org/roadmap/) and [FAQ](https://ufoathome.org/faq/).
18
16
 
19
- `<rr0-scene>` is named without "ufo" on purpose: it renders a generic 3D scene (sky/horizon/stars/decor) from a
20
- real-world time and place, and stands the observer's own phenomenon in it. Under it lives a playback layer —
21
- `UfoElement`, the timeline, the controls, the canvas the pointer works on — reached as `scene.ufoElement`; it was a
22
- component of its own (`<rr0-ufo>`, the shape painted on a bare background) until 0.54.0, when the phenomenon moved
23
- into the scene and a shape with no sky stopped being a thing this project draws. Read-only playback needs no
24
- "player" suffix, since it is every component's default behavior, and `<rr0-sighting-editor>` is the one that needs
25
- a qualifier (it *adds* recording on top). `<rr0-sighting>` (renamed from `<rr0-ufo-observers>` — see below) is the
26
- standard way to display any real sighting, whether it has one observer or several: a observer account always implies
27
- a real place and time, so it always composes `<rr0-scene>`.
17
+ The components are published on npm as [`@rr0/ufoathome`](https://www.npmjs.com/package/@rr0/ufoathome).
18
+ This README only covers what a **contributor** needs: installing, fetching the data, testing, debugging, releasing.
19
+ The project's history is in the [Wiki](https://github.com/RR0/UfoAtHome/wiki).
28
20
 
29
- The project's own site is **[ufoathome.org](https://ufoathome.org)**: [the demos](https://ufoathome.org/demos/)
30
- (every reconstruction running side by side, including the same sighting through three different instruments),
31
- [the player](https://ufoathome.org/play/) for any recording you can give it an address for,
32
- [the editor](https://ufoathome.org/edit/), and [the documentation](https://ufoathome.org/docs/) — which quotes a
33
- whole recording file and hands out the two lines that embed one. See the
34
- [Wiki](https://github.com/RR0/UfoAtHome/wiki) for the project's history.
21
+ ## Setup
35
22
 
36
- ## Install
23
+ Node ≥ 20 (the site is built on Node 22, pinned in [`netlify.toml`](netlify.toml)).
37
24
 
38
25
  ```bash
39
- npm install @rr0/ufoathome
40
- ```
41
-
42
- Three self-contained, pre-built ES modules are published — each self-registers its custom element as soon as it's
43
- imported, no explicit setup call needed:
44
-
45
- ```html
46
- <script type="module" src="/node_modules/@rr0/ufoathome/dist-embed-sighting-editor/rr0-sighting-editor.mjs"></script>
47
- <script type="module" src="/node_modules/@rr0/ufoathome/dist-embed-scene/rr0-scene.mjs"></script>
48
- <script type="module" src="/node_modules/@rr0/ufoathome/dist-embed-sighting/rr0-sighting.mjs"></script>
26
+ git clone https://github.com/RR0/UfoAtHome.git
27
+ cd UfoAtHome
28
+ npm install
29
+ npm test
49
30
  ```
50
31
 
51
- or, from a bundler:
32
+ Nothing else is needed to develop, test or build: every catalogue the browser uses is committed (see
33
+ [Data](#data)). No `.env` either: the only keys the project knows of are the ones a reader types into the
34
+ editor themselves (e.g. an Anthropic key to draft a recording from its description), which never leave their
35
+ browser.
36
+
37
+ ## Layout
38
+
39
+ | Path | What |
40
+ |---|---|
41
+ | `src/engine/` | Framework-agnostic core: model (`Shape`, `Timeline`, `Sighting`), recording, playback, persistence, astronomy, atmosphere, weather, place, assessment. No DOM, no three.js. |
42
+ | `src/render/` | 2D canvas drawing: the texture of each phenomenon plane, editing handles, the observer map. |
43
+ | `src/render3d/` | The three.js scene (`SceneRenderer`) and its systems: sky, terrain, decor, bodies, clouds, optics, phenomena. |
44
+ | `src/component/` | The Web Components. `SceneElement` (`<rr0-scene>`) composes the playback layer `UfoElement`; `SightingElement` and `SightingEditorElement` compose a `SceneElement`. Vanilla custom elements, no framework. |
45
+ | `src/assets/` | The star catalogue tiers (generated) and sounds. The other catalogues are generated modules in `src/engine/astronomy/`. |
46
+ | `src/generated/` | Derived from the TypeScript by `npm run build:schema`; gitignored, never edited. |
47
+ | `public/demo-data/` | The published recordings (`observer-*.json`, `case-*.json`) and the examples the docs quote. |
48
+ | `public/models/`, `public/roads/`, `public/tle/` | Archived glTF models, road networks and orbital elements, served next to the bundles (see [`public/models/README.md`](public/models/README.md)). |
49
+ | `site/` | The ufoathome.org generator (see [The site](#the-site)). |
50
+ | `scripts/` | One-off data builds, case maintenance, GPU checks and performance harnesses. |
51
+ | `test/` | Vitest suites mirroring `src/` (`engine`, `render`, `render3d`, `component`, `site`). |
52
+ | `checks/` | Pages that must run on a real GPU (see [Debugging](#debugging)). |
53
+ | `docs/` | Developer notes: [performance](docs/performance.md), past audits. |
54
+ | `doc/web/` | The 2003 Java applet's documentation, kept for history. |
55
+
56
+ ## Data
57
+
58
+ Every data build is a **one-off** step: its output is committed, and neither `build`, `test` nor `prepublishOnly`
59
+ runs it. Re-run one only when its source or its script changes, and commit the result. Raw inputs go under
60
+ `scripts/data/`, which is gitignored (except `scripts/data/novae/`, kept so that catalogue stays reproducible).
61
+ Each script's header comment says what it takes, what it deliberately leaves out, and why.
62
+
63
+ | Command | Source | Input | Output |
64
+ |---|---|---|---|
65
+ | `npm run build:stars` | [HYG Database v4.1](https://github.com/astronexus/HYG-Database) | download `hyg/CURRENT/hygdata_v41.csv` to `scripts/data/` | `src/assets/stars-mag7.5*` |
66
+ | `npm run build:comets` | [JPL Horizons](https://ssd.jpl.nasa.gov/horizons/) (network) | cached in `scripts/data/horizons/` | `src/engine/astronomy/cometCatalog.ts` |
67
+ | `npm run build:novae` | AAVSO/Strope 2010 curves, SN 1987A | committed in `scripts/data/novae/` | `src/engine/astronomy/novaCatalog.ts` |
68
+ | `npm run build:satellites` | [CelesTrak SATCAT](https://celestrak.org/pub/satcat.csv) (network) | cached as `scripts/data/satcat.csv` | `src/engine/astronomy/satelliteCatalog.ts` |
69
+ | `npm run build:tle` | [Laurent Chabin's TLE archive](https://ufowaves.org/gp/my_tles/) | a local copy in `scripts/data/tle/<year>/`, plus `qs.mag` and Stellarium's `satellites.json` | `public/tle/` |
70
+ | `npm run build:roads` | OpenStreetMap via Overpass (network) | one query per recording, cached in `scripts/data/roads/` | `public/roads/` |
71
+ | `npm run build:sky-reference` | Monte Carlo (a few minutes) | none | `test/engine/atmosphere/sky-reference.json` |
72
+ | `npm run build:schema` | the recording's TypeScript types | none | `src/generated/sightingSchema.json` (run automatically before `dev`, tests and builds) |
73
+
74
+ `build:novae` and `build:tle` run on plain `node` (it strips the types), the others on `tsx`.
75
+ `build:tle` calls `unzip` and `build:roads` calls `curl`: on Windows, run those two from Git Bash or WSL.
76
+
77
+ The stand-in 3D models of the Socorro, Valensole and Chiles-Whitted crafts are also built from the accounts,
78
+ by scripts with no npm alias:
52
79
 
53
- ```ts
54
- import "@rr0/ufoathome/editor" // registers <rr0-sighting-editor> (and <rr0-scene>, which it composes)
55
- import "@rr0/ufoathome/scene" // registers <rr0-scene>
56
- import "@rr0/ufoathome/sighting" // registers <rr0-sighting> (and <rr0-scene>, which it composes)
80
+ ```bash
81
+ npx tsx scripts/build-socorro-craft.ts
57
82
  ```
58
83
 
59
- Only load the one a given page actually needs: each is self-contained and each carries Three.js and a star
60
- catalogue, which is what a real sky costs. A page that loaded the former `rr0-ufo.mjs` still works — ufoathome.org
61
- forwards that address to `rr0-scene.mjs`, whose bundle registers the tag as its own inner layer.
84
+ ### Case recordings
62
85
 
63
- ## `<rr0-sighting-editor>` — full editor
86
+ The recordings under `public/demo-data/` are also served by rr0.org's case pages, which embed them by a relative
87
+ address. Keep both copies identical with:
64
88
 
65
- The authoring component (~540KB gzip — see below for why): everything `<rr0-scene>` has, plus a shape/appearance
66
- toolbar (oval/polygon presets, color, transparency, halo, and the object's real reported
67
- size/distance — see [Apparent size](#apparent-size)) and drag-to-record. It composes a nested
68
- `<rr0-scene>` internally, so the shape being drawn is always seen against the
69
- sighting's own real sky, computed live from whatever latitude/longitude/heading/orientation/observation-time
70
- fields the toolbar currently holds (see [Architecture](#architecture)). This absorbs `<rr0-scene>`'s own
71
- Three.js/`astronomy-engine` weight on top of the authoring-only code this element already carried (Recorder
72
- engine, SamplingClock, appearance toolbar) — a page that only needs to *play* a sighting (the common case: an
73
- rr0.org case dossier) should embed `<rr0-sighting>` (or `<rr0-scene>` alone) directly, never
74
- this heavier authoring component.
75
-
76
- ```html
77
- <rr0-sighting-editor></rr0-sighting-editor>
78
- <rr0-sighting-editor src="sighting.json"></rr0-sighting-editor>
89
+ ```bash
90
+ npm run sync:cases # copy into ../rr0.org's case dossiers, reporting what changed
91
+ npm run sync:cases -- --check # change nothing, fail if anything drifted
79
92
  ```
80
93
 
81
- With `src`, the editor opens on an existing recording instead of an empty canvas — the same
82
- attribute the three other elements take. That is what makes an address per observation possible:
83
- [ufoathome.org's editor](https://ufoathome.org/edit/) maps its own `?sighting=` parameter onto
84
- this attribute, and [its player](https://ufoathome.org/play/) does the same for read-only replay.
85
- Any path the site does not otherwise serve becomes that parameter, so
86
-
87
- - `https://ufoathome.org/play/?sighting=/demo-data/observer-socorro.json`, or simply
88
- - `https://ufoathome.org/Socorro`
89
-
90
- open that observation. A bare name with no `/` is looked for among the site's own demos first, then
91
- as an rr0.org case directory — the shape the links that predate that site were written in, kept
92
- working. Either page also takes a full address of your own; the editor additionally has a
93
- **Load from URL** field, which is an explicit gesture by whoever is sitting at the keyboard.
94
-
95
- Usage: click **Record**, move the pointer over the canvas to draw the UFO's path, click **Stop**, then **Play** to
96
- replay it. The playback layer's `enableClickToPlay` is set to `false` here — a completed recording drag also
97
- fires a native "click" on the canvas, which would otherwise spuriously toggle playback right after recording.
98
-
99
- All of the toolbar's own labels (shape presets, Color/Transparency/Halo, Add shape, Record/Stop, Export,
100
- Duration) are translated (English/French) the same way the playback layer's own labels are — based on
101
- the host page's own `lang` then `navigator.languages`, no picker UI.
102
-
103
- | Member | Kind | Description |
104
- |---|---|---|
105
- | `src` | attribute | URL of a `SightingRecordingJson` to open in the editor, fetched on connect and whenever the attribute changes |
106
- | `sightingData` | property (get/set) | Delegates to the nested scene's `sightingData` |
107
- | `appearance` | property (get/set, accepts a partial object on set) | `{ presetId: "oval" \| "polygon", color: string, transparency: number, haloScale: number }` — the UFO's appearance used for the next recording |
108
-
109
- ## `<rr0-scene>` — the scene, and playback
110
-
111
- The element the two others build on (~530KB gzip — [Three.js](https://threejs.org/) plus
112
- [`astronomy-engine`](https://github.com/cosinekitty/astronomy)'s planetary/lunar position tables, which don't
113
- tree-shake since they're one shared data table used internally for every body): the observer's own phenomenon
114
- standing in a 3D sky/horizon/starfield/decor scene computed from the recording's real time and place, with the
115
- playback controls under it. Its own members are `src`, `sightingData`, `loadFromSrc`, `enableClickToPlay` (forwarded
116
- to the playback layer), `ufoElement` (that layer), `sceneRenderer`, and the attributes `show-compass`,
117
- `show-observer-map` and `hide-milestones`. Click-to-play/pause works anywhere on the scene (the playback layer's
118
- transparent canvas covers the whole stage), and the fullscreen button fullscreens the *whole* scene — it sets the
119
- layer's `fullscreenTarget` to its own outer stage for this.
120
-
121
- ### Playback, on `ufoElement`
122
-
123
- Everything about replaying a recording lives one property down, on the playback layer every component composes —
124
- `scene.ufoElement.play()`, and `sighting.scene.ufoElement.play()` from the outermost. The layer is the former
125
- `<rr0-ufo>` (see [Naming](#naming)): the timeline, the controls, the seek bar and the canvas the pointer works on,
126
- which since 0.54.0 draws nothing but the editing handles — the shape itself stands in the scene.
127
-
128
- | Member | Kind | Description |
129
- |---|---|---|
130
- | `src` | attribute | URL of a [`SightingRecordingJson`](#data-format) file, fetched automatically on connect and whenever the attribute changes |
131
- | `sightingData` | property (get/set) | The current recording as a plain [`SightingRecordingJson`](#data-format) object |
132
- | `sighting` | property (readonly) | The live `Sighting` model (real-world time/place + the recording's `Timeline`) |
133
- | `canvasElement` | property (readonly) | The underlying `<canvas>` element |
134
- | `renderer` | property (readonly) | The `CanvasRenderer` instance painting onto that canvas |
135
- | `refresh()` | method | Re-reads the timeline's duration into the seek slider and repaints the current frame — call after externally mutating `sighting.timeline` |
136
- | `loadFromSrc(url)` | method (async) | What the `src` attribute triggers internally; can be called directly too |
137
- | `enableClickToPlay` | property (get/set, default `true`) | Whether clicking the canvas toggles Play/Pause (see below). Composing elements that need the canvas's own click for something else set this to `false` — see `<rr0-sighting-editor>`. |
138
- | `fullscreenTarget` | property (get/set, default: the component's own stage) | The element the fullscreen button requests fullscreen on. Composing elements that need a *different* element fullscreened set this — see `<rr0-scene>`. |
139
- | `play()` / `pause()` | method | Start or stop playback. Alongside `togglePlayPause()` because a caller sequencing several recordings needs to say which state it wants, not flip whatever the current one happens to be |
140
- | `autoReplayEnabled` | property (get/set, default `false`) | Looping. Off by default: a replay plays once and stops, firing `ended`; a page that wants a loop turns it **on** |
141
- | `playbackState` | property (readonly) | `"stopped"`, `"playing"` or `"paused"` |
142
- | `currentTime` / `seekableDuration` | property | The playhead and its range, in the timeline's own units (see `positionLabel` for why those are not real milliseconds) |
143
-
144
- Three events. `ended` fires once, when playback runs off the end of a recording **without** looping — not on a
145
- pause, and not on a scrub to the end (`Player.onEnded` is the hook, precisely so that neither of those can be
146
- mistaken for one: a single tick can carry the playhead from well inside the recording to past its end, so there is
147
- no "last playing frame" to compare against). It is `bubbles`/`composed`, unlike `timeupdate`, so a page can listen
148
- for it on the outermost element — that is how ufoathome.org's front page plays one reconstruction after another.
149
- `timeupdate` fires on every playback tick and every seek, with `detail.time`, and is meant for the composing
150
- elements. `timedisplaychange` fires when the counters switch between clock time and elapsed time.
151
-
152
- Playback matches the observation's *real reported duration* when it's known: set `time`/`endTime`, or `time`/
153
- `durationSeconds`, in the [data format](#data-format) (`durationSeconds` takes precedence over `endTime` if both are
154
- given — but in the editor, editing either date clears an explicit `durationSeconds` the pair can replace, so the
155
- more recent edit is the one that wins rather than being silently outranked). Watching a 5-minute sighting then takes 5 real minutes, not however long the recording itself took to
156
- author (e.g. a quick mouse drag) — drag the seek bar directly to skip ahead. The start/end labels around the seek
157
- bar show real clock times when `time` has an hour (e.g. `02:45` → `02:50`); otherwise they show `0:00` → the
158
- duration actually available (the declared one if known, else the recording's own length). Playback plays once and
159
- stops by default; a page that wants it to loop sets `autoReplayEnabled = true` (there is no button for it).
94
+ A recording keeps the weather record's answer rather than a link to it. To ask again:
160
95
 
161
- Clicking anywhere on the canvas also toggles Play/Pause (not just the button), and DOUBLE-clicking it toggles
162
- fullscreen — both matching common video-player UX. A double-click is two clicks first, so click-to-play has
163
- already fired twice by the time it arrives; playback is put back where it stood rather than left wherever that
164
- pair happened to leave it (a recording stopped at its own end is restarted by the first of them). Both gestures
165
- are governed by `enableClickToPlay`: where a composing element has taken the canvas over for something else — the
166
- editor edits shapes on it — neither belongs to playback.
167
- While playing, the toolbar and the fullscreen button (top-right, semi-transparent over the content) auto-hide and
168
- only reappear on hover — always shown while paused/stopped. The fullscreen button uses the standard Fullscreen API
169
- (`requestFullscreen`/`exitFullscreen`); exiting with Escape is native browser behavior, nothing custom.
170
-
171
- Labels (Play/Pause, Current position, Duration, Fullscreen) are translated (English/French) by
172
- detection, falling back to English — there's no language-picker UI, and there deliberately isn't one. What is
173
- detected is the **host page's own declared language first** (the nearest `lang` attribute, so `<html lang="fr">`
174
- gets French labels), then `navigator.languages` — see `HostLocale.preferencesFor`. A page states what language its
175
- reader is reading it in, and a bilingual site that serves the same article at two URLs states it per URL, which
176
- `navigator.languages` cannot know. A page that declares nothing falls through to the browser's list exactly as
177
- before.
178
-
179
-
180
- ```html
181
- <rr0-scene src="sighting.json"></rr0-scene>
96
+ ```bash
97
+ npx tsx scripts/infer-case-weather.ts public/demo-data/observer-socorro.json --dry-run
98
+ npx tsx scripts/refresh-case-weather.ts # every recording whose weather came from a record
182
99
  ```
183
100
 
184
- **What the sighting was made through.** An eye and a camera are not the same claim, and the difference
185
- is geometric: a lens maps a direction as `tan θ` (a building 33 degrees off-centre comes out 42% wider
186
- than the angle it subtends), while an eye perceives an angle as an angle wherever it falls — so the
187
- scene renders a naked-eye sighting through an equidistant resampling pass and a photographed one
188
- through the ordinary pinhole projection (`src/engine/instrument/`, `src/render3d/EquidistantProjectionPass.ts`).
189
- A camera also has a FORMAT, which is part of what a photograph carries as evidence: an instrument
190
- states the image it exposes and the lens in front of it, in millimetres, so the ratio is the shape of
191
- the picture (a square 126 frame, a phone held upright) and `2·atan(h/2f)` is the field it takes in.
192
- And it has SETTINGS, each read-only where the device fixed it (an Instamatic's owner had one aperture,
193
- one shutter speed and one focal length). The aperture, the focal length and the focus sit on the POSE
194
- beside the heading and the pitch — the same camera is set differently from one photograph to the next
195
- — while the shutter belongs to the RECORDING as a whole (`Sighting.exposureSeconds`): one observation
196
- was photographed one way, and a shutter speed that changed halfway through would be a second
197
- photograph rather than a moment of this one. The four:
198
- the focal length, which is the field written the way a photographer writes it; the aperture, which
199
- decides both the depth of field and whether the diaphragm's blades throw a star at all; the shutter,
200
- which accumulates a moving light into a STREAK and a blinking one into a dashed streak — and, once the pose is
201
- long enough, turns every star into the arc the turning Earth draws (see [A pose long enough draws the
202
- sky](#a-pose-long-enough-draws-the-sky)); and where the lens was focused. Two of them are drawn rather than merely stated — the blur (`DepthOfFieldPass.ts`,
203
- from the thin-lens geometry in `DepthOfField.ts`) and the streak — because that is what makes them
204
- evidence a reader can compare against a photograph: a sharp object bounds its own distance, and a
205
- blurred one in a sharp frame was close.
206
- The frame is letterboxed inside the widget's own box rather than resizing it, and the field it
207
- implies becomes the recording's default — a recording that states its own (a zoom, binoculars) keeps
208
- it. An eye has no rectangle and no format, and neither has a camera nobody identified: both fall back
209
- to the scene's own 16:9 at the 60° vertical field this project draws an unaided observer through
210
- (about the middle half of a real human field, which is where acuity actually is). The catalogue is
211
- dated, so the picker offers what existed: no telephone in 1964.
212
-
213
- **Real astronomy for misidentification spotting.** A recurring cause of UFO reports is a mundane astronomical
214
- object or atmospheric optical effect — Venus (by far the most commonly misreported "UFO"), other planets, the
215
- Moon, lens flare, or halo phenomena like sun dogs/moon dogs. `<rr0-scene>` renders the sky astronomically: real
216
- Sun/Moon/Venus/Mars/Jupiter/Saturn positions and the Moon's phase via
217
- [`astronomy-engine`](https://github.com/cosinekitty/astronomy) (see `src/engine/astronomy/CelestialPositions.ts`),
218
- and a real star catalog (see below) instead of a randomized field. How deep it is drawn follows the
219
- INSTRUMENT rather than a constant: magnitude 6.5 is what a dark-adapted human eye reaches, and a recording made
220
- through a camera reaches somewhere else entirely — 4.2 through a box camera at a ninetieth of a second, 9.7
221
- through a 50 mm at f/2 for twenty seconds (see `src/engine/instrument/LimitingMagnitude.ts`). The sky's
222
- darkness/color follows the sun's altitude (day/twilight bands/night), and its dawn/dusk glow is anchored on the
223
- sun's real compass direction, not spread uniformly around the horizon — see `src/render3d/skyColors.ts`.
224
-
225
- The observer's own pose — geographic position, elevation, and viewing heading/pitch/field of view — can vary over
226
- the sighting's timeline via `observerTrack` in the sighting JSON (a keyframe array of `{ t, pose }` alongside
227
- `timeline` — the model class is `ObserverTrack`, but the serialized key is `observerTrack`, and writing the class's
228
- name into a file is a mistake that costs an afternoon; same
229
- hold-last/interpolated-lookup shape — see `src/engine/model/ObserverTrack.ts`), driving both the camera's own
230
- orientation and which real-world instant the astronomy is computed for as playback advances. Older recordings
231
- with no `observerTrack` fall back to the legacy static `place[0]` (see `resolveObserverPoseAt` in
232
- `src/engine/model/Sighting.ts`) — usable for sky darkness/color and camera pitch/fov, but with no compass heading
233
- to orient the camera by.
234
-
235
- `src/engine/astronomy/SunPosition.ts` (the original vanilla, dependency-free NOAA/Spencer solar position
236
- approximation) stays in the repo, tested, and still backs `skyBrightness()`'s twilight-band classification — but
237
- the live rendering path now uses `astronomy-engine` for the Sun too, for a single source of truth and to get the
238
- Sun's azimuth from the same call used for the sky's directional glow.
239
-
240
- `<rr0-sighting-editor>` has editor fields for the observer's latitude/longitude/heading and the observation's start
241
- date/time (all optional) — filling in lat+lng writes both the legacy `place` and a single t=0 `observerTrack`
242
- keyframe (elevation/pitch/field of view stay at neutral defaults; there's no UI yet for authoring the observer
243
- *moving* over time, only a single static pose per recording).
244
-
245
- Not yet done: a mirage, the supernumerary arcs crowded inside a bright rainbow and the corona round a Sun seen
246
- through a thin water cloud (all three are interference, and nothing here models the wave — see `WaterDrop.ts`),
247
- and a multi-keyframe `observerTrack` authoring UI (today the editor can only set
248
- one static pose; an observer that moves/re-orients mid-recording still needs hand-authored or scripted JSON). The
249
- Moon's phase currently only dims/brightens its disc's overall
250
- color rather than rendering a geometrically accurate crescent shape — a natural follow-up.
251
-
252
- **Regenerating the star catalog.** Two tiers, both compact binary assets of four concatenated `Float32Array`
253
- sections (ra/dec/mag/ci — see `src/render3d/StarCatalog.ts` for the exact layout), both sorted brightest first,
254
- both generated from the [HYG Database v4.1](https://github.com/astronexus/HYG-Database) (CC BY-SA):
255
-
256
- - `src/assets/stars-mag7.5.bin` — 25 791 stars to magnitude 7.5, ~400KB, loaded by every scene;
257
- - `src/assets/stars-mag7.5-9.bin` — the 57 688 *further* stars between 7.5 and 9, ~900KB, fetched only by a
258
- recording whose own optics reach past 7.5 (see `StarCatalogs.upTo`, and `deep-star-catalog-src` to host your
259
- own copy). A delta, not a second catalogue: no star is downloaded twice.
260
-
261
- Nine is where HYG stops being a sky and starts being a catalogue running out — its counts multiply by 3.1 per
262
- magnitude up to 7, then 2.7 to 8, 2.0 to 9 and 1.3 to 10. An observation that outran it is told so on the
263
- editor's Sky line rather than quietly handed an emptier sky than it recorded; going deeper would mean Tycho-2 or
264
- Gaia, which is a different order of download.
265
-
266
- To regenerate them: download `hyg/CURRENT/hygdata_v41.csv` from that repo into `scripts/data/hygdata_v41.csv`
267
- (gitignored — not checked in, ~34MB), then run `npm run build:stars`. The generated `.bin`/`.json` pairs *are*
268
- checked in, since they don't need regenerating on every install.
269
-
270
- **What else was in that sky.** Beside the Sun, Moon, planets and stars, the scene states — and where it can,
271
- draws — the things that were genuinely up there and are genuinely mistaken for something else. Each is here
272
- because its record is complete for every date this project can reconstruct, with no lookup, no key and no
273
- coverage floor:
274
-
275
- - **Meteor showers** (`src/engine/astronomy/MeteorShowers.ts`). A shower is a position in the Earth's own orbit,
276
- so the Perseids of 1948 are the Perseids of today. Rates are corrected for the radiant's real altitude, and the
277
- strongest statement is the negative one: a radiant below the horizon can have produced nothing.
278
- - **Comets** (`src/engine/astronomy/Comets.ts`). Twenty-three naked-eye apparitions from Halley 1910 to
279
- Tsuchinshan-ATLAS 2024, each with the orbit it was actually on that year. Positions come from a universal-variable
280
- Kepler propagation (`Orbit.ts`) checked against JPL Horizons to about a thousandth of a degree; brightness is
281
- modelled from magnitudes recorded at the time, and the tail is a real length in space, projected — so it
282
- shortens when it points away from the observer instead of across their sky. Half the apparitions have no
283
- recorded tail length and are drawn with no tail at all.
284
-
285
- - **Satellites** (`src/engine/astronomy/Satellites.ts`). For every date, the ILLUMINATION: `h = R(sec B - 1)` gives how high the Earth's shadow stood
286
- above the observer, so deep in the night nothing in low orbit is lit and a light crossing the sky
287
- then was not a satellite. Being lit and being seen are kept apart — everything in orbit is sunlit
288
- by day, and an Iridium flare at magnitude -8 was genuinely watched at noon. Also complete, and
289
- from CelesTrak's SATCAT: how many tracked objects were in orbit that month, and when each named
290
- class existed (Echo balloons, Iridium flares, ISS, Starlink trains).
291
- And from February 2021, WHICH ones (`TleArchive.ts`, `SatellitePasses.ts`): Laurent Chabin (SCEAU)
292
- has saved the public element sets once or twice a day since then. They are propagated with SGP4
293
- (satellite.js), lit through the Earth's umbra and penumbra, and given the magnitudes that were
294
- measured — McCants' standard magnitudes, then Stellarium's for later objects, Mallama's for
295
- Starlink, with the orbit-raising value that makes a train as bright as it is reported. A set is
296
- never used more than a week from its epoch; the archive's holes are reported as holes. Before
297
- 2021 no pass is drawn: propagating today's elements back to 1965 would invent a precise,
298
- confident pass.
299
-
300
- - **What the air itself did to the light** (`src/engine/atmosphere/`). Two families, and neither is drawn form by
301
- form. The ICE one traces sunlight through hexagonal prisms in a cirrus deck (`IceCrystal.ts`, `HaloSky.ts`) and
302
- the 22° ring, the 46° ring, the sundogs, the tangent arc, the parhelic circle, the circumzenithal arc and the
303
- pillar come out of the answer at whatever brightness the physics gives each — the one thing no record holds is
304
- how steadily the crystals were falling, so that stays a stated condition (`Weather.iceCrystalAlignment`). The
305
- WATER one sweeps a ray across a raindrop (`WaterDrop.ts`, `RainbowSky.ts`) and the primary bow at 42°, the
306
- reversed secondary at 51°, Alexander's dark band between them and the bright sky inside the first come out the
307
- same way. Nothing about a rainbow has to be assumed: a drop is a sphere, so there is no orientation to state,
308
- and both of its ingredients — falling water, and a source that reaches it — are in the weather record. Each
309
- family keeps a second, independent derivation in closed form (`IceHalos.ts`, `Rainbows.ts`) whose only job is to
310
- disagree with the trace; that check has already caught one shipped error.
311
-
312
- All of them appear in the editor's read-only "Sky:" line, with a button to turn the observer toward
313
- the meteor or the comet. The bow line is said only when rain was reported — everybody knows whether it was
314
- raining, so the interesting answers are the negative ones: a Sun higher than 42° puts every bow below a ground
315
- observer's horizon, and an unbroken deck between the Sun and the rain is the missing half of the famous
316
- condition.
101
+ Only `weatherTrack` and `weatherSource` are rewritten, spliced into the file's text so the diff shows nothing
102
+ else. Run `sync:cases` afterwards.
317
103
 
318
- **Regenerating the satellite catalog.** `src/engine/astronomy/satelliteCatalog.ts` is generated by
319
- `npm run build:satellites` from [CelesTrak's SATCAT](https://celestrak.org/pub/satcat.csv) (CC BY
320
- 4.0), cached under `scripts/data/` (gitignored). Only its LAUNCH_DATE and DECAY_DATE columns are
321
- read: the orbital fields hold each object's *current* state, which for anything that has re-entered
322
- is its state on the way down — Echo 1 is listed at 419 x 394 km and spent its life near 1500 — so
323
- using them to describe a historical orbit would be quietly wrong. Which classes are worth naming,
324
- and how bright they got, stay hand-entered in the script; every date is derived.
104
+ ## Testing
325
105
 
326
- **Rebuilding the orbital element archive.** `npm run build:tle` reads a local copy of
327
- [ufowaves.org/gp/my_tles](https://ufowaves.org/gp/my_tles/) in `scripts/data/tle/<year>/` (the
328
- `Visible` zips and the `tle/*_visual.tle` and `*_starlink.tle` files), plus `scripts/data/satcat.csv`
329
- for launch dates and `scripts/data/qs.mag` ([McCants](https://www.mmccants.org/programs/qsmag.zip))
330
- and `scripts/data/stellarium-satellites.json` for magnitudes, and writes `public/tle/` (committed, served by ufoathome.org at `/tle/`):
331
- weekly 44-byte-record bins per list, the object names, and an index with the archive's gaps. Every
332
- day is kept for naked-eye objects and for Starlinks in their first 60 days; one set a week otherwise.
333
- 838 MB of snapshots become 44 MB. Rebuild and commit it when the archive has grown.
334
-
335
- **Regenerating the comet catalog.** `src/engine/astronomy/cometCatalog.ts` is generated by
336
- `npm run build:comets`, which asks [JPL Horizons](https://ssd.jpl.nasa.gov/horizons/) for osculating elements at
337
- each apparition's own perihelion and caches the answers under `scripts/data/horizons/` (gitignored). The list of
338
- apparitions, and the peak magnitudes and tail lengths recorded at the time, are hand-entered in
339
- `scripts/build-comet-catalog.ts` — the orbits are looked up, the brightness is an observation, and the script's
340
- own doc comment explains why the two cannot come from the same place. The generated file *is* checked in.
341
-
342
- **The phenomenon stands in the scene, and is still only what the observer saw.** The shape used to be painted on a
343
- 2D canvas laid over the 3D scene, so that nothing about it could be read as a claim about a solid at a distance. It
344
- is now a plane *in* the three.js scene (`src/render3d/PhenomenonSystem.ts`), facing the observer and scaled so that
345
- it subtends exactly the angle the recording states — which makes it look the same from their eye at any distance
346
- whatever, and is what keeps the claim where it was: the recording states angles and nothing else, and the distance
347
- the plane is drawn at is a parameter of the picture (`src/engine/shape/PhenomenonDepth.ts`, see *Where the shape
348
- is drawn* below). What the plane carries is the very picture the overlay painted — the same `CanvasRenderer` draws
349
- the same halo, blur, veil and spikes into its texture. What changed is who decides what hides it: the decor's own
350
- depth, per pixel, so a patrol car in front of it hides exactly the part of it a patrol car would, where the overlay
351
- sampled nine points and hid the shape whole or not at all. The ground and the terrain are kept out of that on
352
- purpose — the phenomena are drawn in a pass of their own, depth-tested against the decor alone — because a relief
353
- patch at thirty-metre resolution deciding what a observer saw would not be a reconstruction (Socorro's craft, a
354
- hundred feet away in the arroyo below the road, sank two metres under one). It also means the phenomenon goes
355
- through the instrument's own projection like everything else in the scene, and through the same long-exposure
356
- accumulation, instead of an approximation of each on a separate layer. The overlay keeps the pointer's business:
357
- selection handles, outlines, hit-testing. A body in the round — an ovoid, a real model standing in for the shape
358
- to test "was it a helicopter" — is a different statement, and a different object, for a recording that makes it.
359
-
360
- ## `<rr0-sighting>` — standard sighting view
361
-
362
- The standard way to display any real sighting, whether it has one observer or several — renamed from
363
- `<rr0-ufo-observers>` once it stopped being just a multi-observer selector (see [Naming](#naming)). It composes a
364
- nested `<rr0-scene>` the same way `<rr0-sighting-editor>` does, since a observer recording is
365
- always a real sighting and always needs the real sky/ground backdrop.
366
-
367
- ```html
368
- <rr0-sighting src="sighting.json"></rr0-sighting>
369
- ```
370
-
371
- `src` accepts either a single observer's `sighting.json` directly or a **case**: RR0's own `case.json`, whose
372
- `events` of `eventType: "sighting"` each point at one observer's `SightingRecordingJson` by `url`, read relative to
373
- the case file itself (so the same case works from its dossier's page and from anywhere else). A case's other events
374
- (analyses, articles, films, confessions) are not replayed. See `CaseJson` in `src/engine/persistence/caseJson.ts`:
375
-
376
- ```json
377
- {
378
- "id": "ChilesWhitted",
379
- "title": "Chiles et Whitted",
380
- "events": [
381
- { "type": "event", "eventType": "sighting", "url": "observer-chiles.json" },
382
- { "type": "event", "eventType": "sighting", "url": "observer-whitted.json" }
383
- ]
384
- }
106
+ ```bash
107
+ npm test # vitest, jsdom, test/**/*.test.ts
108
+ npm run test:watch
109
+ npx vitest run test/engine/Timeline.test.ts # one file
110
+ npx vitest run -t "interpolates" # tests whose name matches
385
111
  ```
386
112
 
387
- The two shapes are told apart automatically — an object with `events` and no `timeline` is a case, anything else
388
- one observer's own recording. A bare JSON array (the observer manifest read before cases) is refused with an error
389
- naming `case.json`. No labels are duplicated in the case — each observer's display name and the shared
390
- case id grouping them together are read from that observer's *own* file (`observer`/`caseId`, see
391
- [Data format](#data-format)), so there's a single source of truth and nothing to drift out of sync. This means
392
- every listed observer's recording is fetched upfront (to read its name), not lazily on selection — fine at the
393
- scale a case's observer list actually has. If a observer has no `observer.title`, its `observer.id` is shown instead, or
394
- the URL itself as a last resort. A mismatched `caseId` across the listed observers logs a console warning (doesn't
395
- block) — likely means unrelated recordings got listed together by mistake.
396
-
397
- **Where two observers described different things, the recordings have to show it.** Not that they
398
- must differ — observers often agree, and two matching recordings are then simply true. But a
399
- difference that exists in the record and not in the files makes the observer picker a control that
400
- does nothing, and nothing *looks* broken. Chiles-Whitted shipped that way for months: one account
401
- under two names. Its two pilots drew the object differently for Project Sign — the captain a slim
402
- ribbed cigar with a pointed nose and no windows, the co-pilot a blunt cylinder with two rows of
403
- lit windows — and only the co-pilot, in the right seat, saw the terminal phase (McDonald's 1968
404
- cross-check). Each file now carries its own observer's account.
405
-
406
- | Member | Kind | Description |
407
- |---|---|---|
408
- | `src` | attribute | URL of a single `sighting.json` or a `case.json` (above), fetched automatically on connect and whenever the attribute changes |
409
- | `observerUrls` | property (get/set) | The recordings to show as a plain array of URLs, for programmatic use instead of `src` |
410
- | `sightingData` | property (get/set) | One observer's recording, set directly instead of fetched — for a page holding one in memory (text pasted into a form, a file the reader picked). Its entry carries no URL, so the info panel's editor link and embed lines fall back to the bare application, which is the honest answer for something published nowhere |
411
- | `scene` | property (readonly) | The `<rr0-scene>` this composes — and through `scene.ufoElement`, the playback members above |
412
- | `loadFromSrc(url)` | method (async) | What the `src` attribute triggers internally; can be called directly too |
413
-
414
- A toolbar row sits above the scene: a "Account by &lt;observer&gt;" sentence on the left, and a round "?" info
415
- button on the right. The observer portion is plain text for a single observer (a one-option `<select>` would be
416
- pointless); once there's more than one, it becomes the live `<select>` instead — but the sentence itself, and the
417
- info button, stay visible either way. The first observer loads automatically once the list is known; switching the
418
- selector loads that observer's already-fetched recording into the nested `<rr0-scene>` (no re-fetch). Setting
419
- `observerUrls` again (e.g. a case re-read) keeps the current selection if that observer is still present, instead
420
- of resetting back to the first.
421
-
422
- Clicking "?" opens a panel anchored under the button (it never shifts the canvas below it). Where the browser has
423
- the popover API the panel is a top-layer `popover="auto"` — the one placement a host page's own `overflow: hidden`
424
- wrapper cannot clip, which is what rr0.org's layout was doing to it — kept under the button by CSS anchor
425
- positioning, flipping above it or centring in the viewport when that side is too short, and closing on Escape or a
426
- click outside. Browsers without the API get the plain absolutely-positioned overlay instead.
427
-
428
- Its main content is the currently-selected observer's observation metadata (date, location, case id, description,
429
- tags — whichever are actually present in that observer's own `sighting.json`; the observer's own name isn't repeated
430
- here, since it's already in the toolbar's account line). The date is shown on the OBSERVER's own clock, never
431
- converted into the reader's time zone (see `utcOffsetHours` in [Data format](#data-format)).
432
-
433
- A footer row holds the app's own name/version on the left — linking to that very observation in the editor (see
434
- [`<rr0-sighting-editor>`](#rr0-sighting-editor--full-editor)'s own `src`), not to the application's home page — and two
435
- fold-outs on the right, both closed until asked for:
436
-
437
- - **Embed** hands out the two self-contained lines it takes to put this observation on any other page, either as a
438
- replay (`<rr0-sighting>`) or as the editor (`<rr0-sighting-editor>`), with absolute URLs and a copy button:
439
-
440
- ```html
441
- <script type="module" src="https://ufoathome.org/lib/rr0-sighting.mjs"></script>
442
- <rr0-sighting src="https://ufoathome.org/demo-data/observer-socorro.json"></rr0-sighting>
443
- ```
444
-
445
- The script URL is derived from where the running bundle was itself loaded from (`import.meta.url`), never
446
- hardcoded, so a snippet generated from a local or staging copy points back at that copy. Pasting it into a site
447
- of your own needs the bundle and the recording to be readable cross-origin (ufoathome.org serves `/lib/*` and
448
- `/demo-data/*` with `Access-Control-Allow-Origin: *` for exactly this). The four entry modules under `/lib` are
449
- revalidated on every visit rather than cached for a week, because their names carry no content hash: a page that
450
- kept last release's component in front of this release's recording is a page showing `[object Object]` where the
451
- account should be.
452
- - **Credits** reveals third-party credits (the live terrain imagery attribution, once a real relief patch has
453
- resolved, plus the bundled thunder sound's own required attribution — see [`CREDITS.md`](CREDITS.md)).
113
+ jsdom has no WebGL: `render3d` tests exercise three.js objects, shaders' inputs and pure logic, not pixels.
114
+ What only a real graphics card can tell is checked by the pages under `checks/` (see below). A test that passes
115
+ both with and without the change it guards protects nothing: check it fails first.
454
116
 
455
- All of this component's own labels (Account by, About, Close, Observation/Date/Location/Case, Credits) are
456
- translated (English/French) the same way as the playback layer's own labels.
117
+ ## Debugging
457
118
 
458
- ## Data format
459
-
460
- Both components read/write a plain, JSON-serializable `SightingRecordingJson`:
461
-
462
- ```ts
463
- interface SightingRecordingJson {
464
- version: 1
465
- time?: { year?: number, month?: number, day?: number, hour?: number, minute?: number, second?: number }
466
- endTime?: { year?: number, month?: number, day?: number, hour?: number, minute?: number, second?: number } // alternative to durationSeconds
467
- durationSeconds?: number // alternative to endTime; takes precedence if both are set
468
- utcOffsetHours?: number // the LEGAL time zone the observer's clock was on (+1 for France in 1965, -7 for New Mexico in April 1964). Absent = approximated from the longitude, which cannot know legal time or a daylight-saving switch
469
- place?: { lat: number, lng: number, name?: string }[] // `name` is the fully qualified place name the coordinates were resolved from — see Naming a place
470
- observer?: { id?: string, dirName?: string, title?: string, lastName?: string, firstNames?: string[] } // every field optional — supply whichever is known; omit entirely for an anonymous observer
471
- caseId?: string // shared by every observer's own sighting.json for the same case — see <rr0-sighting>
472
- description?: string
473
- tags?: string[]
474
- timeline: {
475
- keyframes: Array<{
476
- t: number // milliseconds since recording start
477
- shapes: Array<{
478
- sourceId: string // e.g. "ufo-1" — lets several shapes (a UFO, a landmark, a trailing flame...) share one timeline
479
- shape: {
480
- kind: "oval" | "polygon"
481
- bounds: { x: number, y: number, width: number, height: number }
482
- color: string // CSS color
483
- angle: number // radians
484
- transparency: number // 0 = opaque, 1 = fully transparent
485
- haloScale: number // 0 = no glow
486
- selected: boolean
487
- title?: string // shown as an on-canvas tooltip when hovered
488
- angular?: { widthDeg: number, heightDeg: number } // how big it LOOKED — the only size a account holds, see Apparent size
489
- points?: { x: number, y: number }[] // "polygon" shapes only
490
- }
491
- }>
492
- }>
493
- order?: string[] // back-to-front paint/hit-test order; absent = first-appearance order
494
- groups?: string[][] // each inner array is one group's member sourceIds
495
- }
496
- observerTrack?: { keyframes: Array<{ t: number, pose: { lat?: number, lng?: number, elevationM: number, headingDeg?: number, pitchDeg: number, fovDeg: number } }> }
497
- weatherTrack?: { keyframes: Array<{ t: number, weather: Weather }> }
498
- weather?: Weather // legacy static fallback for recordings predating weatherTrack
499
- weatherSource?: { id: string, name: string, url: string } // the meteorological record weatherTrack was looked up from — see Weather is looked up, not remembered. Absent = the observer's own account
500
- instrument?: "eye" | "rectilinear-lens" // what it was observed THROUGH — see Instrument. Absent = the naked eye
501
- soundTrack?: { keyframes: Array<{ t: number, sound: { kind: "none" | "hum" | "whistle" | "rumble" | "crackle", volume: number, pitchHz: number, src?: string } }> } // what the observer heard — see What it sounded like
502
- decor?: DecorObject[] // buildings, trees, streetlights, vehicles, other observers — see src/engine/model/Decor.ts
503
- references?: SceneReference[] // pictures of the place laid over the scene, each at a registered heading/pitch/roll/field — see Pictures of the place
504
- }
119
+ ```bash
120
+ npm run dev
505
121
  ```
506
122
 
507
- A shape left out of a later keyframe is **held** at its last recorded state, not hidden — and one whose first
508
- keyframe is at `t=5000` is already painted, in that state, from `t=0` (hold-first/hold-last at both ends of a
509
- source's own range). To make something stop being visible, keyframe it with `transparency: 1`.
510
-
511
- ### What it sounded like
512
-
513
- Half of what makes these accounts strange is the sound — most often its absence. `soundTrack` records it on the
514
- same clock as the shapes, because a sound rarely starts when the object does: a craft sitting silently on the
515
- ground and heard only as it lifts off is two keyframes, `kind: "none"` at the start and a hum at the instant it
516
- took off.
517
-
518
- `volume` (0..1, how loud the observer could describe it, never a dB figure) and `pitchHz` blend between keyframes;
519
- `kind` and `src` are **held**, like every other discrete field in this format — so the example above really is
520
- silent right up to that second keyframe. To record a sound emerging gradually instead, give it two keyframes of
521
- its own kind (hum at volume 0, then hum at full).
522
-
523
- `kind: "none"` is a statement — the observer reported hearing nothing. A recording with no `soundTrack` at all is
524
- the different, weaker case: nobody was asked. Both replay as silence, and neither invents a noise.
525
-
526
- Sounds are **synthesized** from that description (a drone, a whistle, a rumble, a crackle — `pitchHz` is the tone
527
- itself for the pitched ones and where the noise sits for the others), exactly as a described shape is drawn from
528
- its description, and at no cost in bundled assets. A recording that actually captured the sound can point `src` at
529
- the audio file, which then plays instead — at the price of an embed that is no longer self-contained, and a URL
530
- that must be CORS-readable.
531
-
532
- Sound plays during playback only, and only after a real click somewhere in the player: browsers refuse to start
533
- audio without one.
534
-
535
- The same rule governs the whole scene, not just the object's own sound: **paused is paused**. Falling
536
- precipitation and its splashes, twinkling stars, lightning flashes, the sun's lens flare and the weather's own
537
- ambient beds all stop with the player and resume with it, leaving the frozen frame on screen. A paused replay is
538
- one instant of a sighting — weather still going on over it would be the reader's own room, not the observer's
539
- evening. Clouds likewise use the recording's timeline: wind advection stops on pause and is
540
- recomputed deterministically when seeking.
541
-
542
- ### Pictures of the place
543
-
544
- Everything the scene draws is computed, and a reader has no way to tell a faithful reconstruction from a plausible
545
- one. A photograph of the same place does: `references` lays pictures over the render, each at the direction it was
546
- registered in (`registration`: heading, pitch, roll and vertical field, angles and nothing else), at an opacity the
547
- reader varies with the slider beside the 🖼 button — all picture, all render, and every step between, with the
548
- observer's own phenomenon drawn over both. A `photo` is a flat panel at its lens's field, a `panorama` an
549
- equirectangular sphere. `src` is an address (rr0.org's case pictures are served to any origin; the bytes must be, since
550
- WebGL draws them) or the picture itself as a `data:` URL when it was added from a disk, which keeps the recording
551
- self-contained at the price of its size. `credit` and `creditUrl` are shown in the info panel's credits, `t` says it
552
- was taken during the observation at that instant, and `drawing` that somebody drew on it — Cussac's own view from the
553
- spot carries the sphere and its spiral by hand.
554
-
555
- Nothing in the scene hides a picture and a picture hides nothing: it is a field of directions from one point, valid
556
- from that point alone, and the reconstruction stands it at the observer's eye.
557
-
558
- **Lining it up.** The editor's Pictures group types the registration, copies the observer's pose into it, and while that
559
- group is open the canvas belongs to the selected picture: a drag turns it, the wheel changes its field, and *Add a
560
- landmark* arms two clicks that name a detail on the picture, then the same detail in the render. Landmarks are kept in
561
- the recording with a name ("Arbre masquant", "barrière") and listed with how far each still is from fitting; either end
562
- of one can be dragged, and the selected one is bolder on the canvas — two landmarks turn the picture to fit them, three
563
- or more fit its field too (`PictureRegistration`, a triad first guess refined by Gauss-Newton on the rotation), and the
564
- status line says how far off the landmarks still are. *Adopt as the observer's pose* then writes the fitted heading,
565
- pitch and roll into the pose at the playhead as a **measurement**, with a `derived` provenance naming the picture and
566
- the residual — a heading read off a picture that fits the relief, where the one typed in the Observer group is the
567
- observer's word. *Street-level pictures nearby* asks Panoramax (open imagery, CC BY-SA, served to any origin) for
568
- pictures taken within 300 m of the observer's spot; each comes with where it was taken from and which way it looked, so
569
- it arrives registered in heading, a full turn arriving as a panorama.
570
-
571
- ### Naming a place
572
-
573
- Account names a place. It says "on the Valensole plateau", "near Socorro", "over Montgomery" —
574
- never 43.8379 / 5.9840. So the Location group leads with a **Place** field: type a name, press Enter
575
- (or **Locate**), and the latitude and longitude below are filled from
576
- [Nominatim](https://nominatim.openstreetmap.org/), OpenStreetMap's own geocoder — which, unlike the
577
- gazetteer-style services, knows the hamlets, farms and airfields that cases actually happen at.
578
- A name is often ambiguous, so every candidate stays listed in **Matches** and the best one is
579
- applied straight away; picking another moves the observer. Results are named in the reader's own
580
- language, and credited as their licence requires.
581
-
582
- What gets stored is the *qualified* name the search resolved (`place[].name`), not the two words
583
- typed — half the interesting cases happen near a village that shares its name with four others, and
584
- a later reader needs to land on the same spot. A name no geocoder knows is kept as typed, so a
585
- recording can still say "the lavender field east of the farm" beside coordinates entered by hand.
586
-
587
- The field reads both ways: move the latitude or longitude by hand and a resolved name is re-derived
588
- from the new coordinates, or cleared if there is no place there. A name left describing somewhere
589
- the sighting is no longer at is worse than no name — the recording would state, in writing, that it
590
- happened there. A name the observer typed themselves is never replaced.
123
+ opens the **engine demo** (`index.html`, Vite on 5173, or `PORT` if set): an editor wired to the sources with hot
124
+ reload, a sample recording, and a synthetic cloud scenario with the volume/surface renderer switch. Pages under
125
+ `checks/` are served by the same server:
591
126
 
592
- **Altitude is above sea level**, and the ground at the location sets its floor: a observer in the
593
- Alps is not at 0 m, and an editor that offers it invites a recording that says so. The ground's own
594
- height is read from whichever elevation source is live and shown beside the field. What gets stored
595
- is unchanged — `ObserverPose.elevationM` stays a height above the local ground, which is what the
596
- terrain patch is built around.
127
+ - `/checks/atmosphere.html`: the sky's GPU tables against the CPU reference and the Monte Carlo skies. Run it after
128
+ any change to `AtmosphereProfile`, `SkyScattering` or `AtmosphereTables`, or from a terminal with
129
+ `node scripts/checks/atmosphere-gpu.mjs`.
597
130
 
598
- ### Time zones
599
-
600
- An hour of `utcOffsetHours` is an hour of Earth's rotation *and* a different row of the weather
601
- record. Pick the observer's own zone (`Europe/Paris`, `America/Denver`, …) and the offset is derived
602
- from that zone's rules **at the observation's date**: Valensole in July 1965 resolves to UTC+1, not
603
- the UTC+2 the same place gives today — France only reintroduced summer time in 1976. Change the date
604
- and it is derived again. The recording stores both: `timeZone` is the rule, `utcOffsetHours` is the
605
- number it produced, and every consumer keeps reading only the number.
606
-
607
- The rules come from the platform's own IANA database. What it cannot fix is a zone whose
608
- *boundaries* are coarse: Montgomery, Alabama is `America/Chicago`, which observed summer time in
609
- 1948 while Alabama did not. That is why the zone is chosen by the observer rather than derived from
610
- the coordinates — and why the plain entered offset remains available for exactly those cases.
611
-
612
- The search runs only when asked, never per keystroke: Nominatim's usage policy allows the first and
613
- rules out the second.
614
-
615
- ### Who answered is part of the answer
616
-
617
- Every kind of real-world data this editor pulls in — places, weather, ground relief, aerial imagery
618
- — is chosen by a picker sitting **where that data is reported**, with the attribution its licence
619
- requires next to it:
620
-
621
- > 2 places found according to `[Nominatim ▾]` © OpenStreetMap
622
- >
623
- > From `[ERA5 (Open-Meteo) ▾]` © Copernicus/ECMWF, 2026-08-21 15:30 UTC
624
-
625
- Those pickers *are* the credits. Naming who the data comes from and letting it be chosen are the
626
- same act: a static "© OpenStreetMap" tucked beside a field says where today's answer came from but
627
- hides that it is a choice, and a picker with no attribution credits nobody. Relief and imagery have
628
- no sentence to sit in, so they get a row under the coordinates whose ground they describe.
629
-
630
- Most registries hold a single entry today (imagery holds two, Esri and EOX Sentinel-2 cloudless) —
631
- which is the point: the seam is visible before it is used, and adding an implementation means adding
632
- one entry to a registry (`placeSources.ts`, `weatherSources.ts`, `terrainSources.ts`), not new
633
- markup.
634
-
635
- A stale offset renders midnight over Paris and gives no clue why, so an offset that cannot belong to
636
- the declared longitude is flagged on the field, with the meridian's own solar time in the tooltip.
637
-
638
- Deliberately a wide net rather than a precise one. Legal time genuinely departs from solar time,
639
- sometimes by hours (all of China runs on UTC+8), and the historical rules are worse — the check must
640
- never cry wolf at a correct "France on UTC+1 in 1965". It flags only what no country has ever done,
641
- and only as a warning: the recording states the observer's clock, and nothing here knows better than
642
- the observer.
643
-
644
- ### Weather is looked up, not remembered
645
-
646
- The Circumstances group is the one part of this editor that isn't account. Weather is a
647
- measurable fact about a place at an instant, and the recording already states both — so instead of
648
- leaving a observer (or an author reconstructing a case decades later) to set a cloud-cover slider
649
- from memory, the editor looks the conditions up from [ERA5](https://open-meteo.com/en/docs/historical-weather-api),
650
- the ECMWF reanalysis, hourly and worldwide from 1940 on. The fields then show the record's own
651
- values, **read-only**, above a line naming the dataset and the exact UTC instant they describe (a
652
- wrong `utcOffsetHours` shows up there before it shows up in the rendered sky). The request that
653
- produced them is kept in `weatherSource.url`, so the claim stays checkable years later.
654
-
655
- Two inputs have no direct counterpart in the record. Layer `darkness` starts from a derived visual
656
- estimate weighted by cloud level, rain and thunderstorm. A low `baseM` starts from Espy's
657
- temperature/dew-point spread. Both derivations are documented in
658
- `src/engine/weather/providers/OpenMeteoWeatherProvider.ts`; crystal alignment is never inferred.
659
-
660
- Unchecking **From weather records** hands the fields back to the observer: the looked-up values stay
661
- as a starting point, `weatherSource` is dropped, and no later lookup may overwrite them — the same
662
- "declared outranks deduced" rule [Behind a cloud](#behind-a-cloud) follows. A recording that names
663
- a `weatherSource` is replayed exactly as authored and never looked up again, so a published case
664
- file reads identically offline. Nothing is ever locked without a record to show for it: before 1940,
665
- or with no network, the fields stay editable and the line says which of the two it is.
666
-
667
- The `weatherTrack` follows the observation rather than flattening it: a keyframe at its start, one
668
- at every whole hour it runs through, and one at its end. The record is read *between* its hourly
669
- rows, not snapped to the nearest one — so Wilcox's two hours carry a cloud deck lifting from 800 m
670
- to 913 m, and even a four-minute sighting gets a track that moves instead of one value repeated.
671
- (For a short observation the record often genuinely says nothing changed. That is an answer, not a
672
- missing feature.)
673
-
674
- The keyframes are placed on the clock playback actually runs on — `timeline.duration` once
675
- something has been recorded, and the observation's own declared length before that (the same rule
676
- `Player.durationOverrideMs` implements). That clock *changes length as authoring proceeds*: the
677
- first recorded shape turns a fifteen-hour span into a few seconds, so the track is re-derived
678
- whenever it does. Getting either half wrong looks the same from outside — a sighting whose weather
679
- never changes.
680
-
681
- It follows the observer too, not just the clock. Half of aviation account is given from a cockpit,
682
- and an aircraft under observation for an hour is a long way from where it started — so each sample
683
- is looked up at the position the `observerTrack` puts the observer at that instant. Positions inside
684
- one ERA5 grid cell (~28 km) are one query, and several cells still travel in a single request, so
685
- a stationary observer costs exactly what it always did.
686
-
687
- `scripts/infer-case-weather.ts` runs the same lookup over case files on disk:
131
+ To debug a published recording in the real site rather than in the demo page:
688
132
 
689
133
  ```bash
690
- npx tsx scripts/infer-case-weather.ts --dry-run path/to/sighting.json
134
+ npm run build:site
135
+ npm run site:serve # dist-site on http://localhost:5181
691
136
  ```
692
137
 
693
- It rewrites only `weatherTrack` and `weatherSource`, splicing them into the file's own text so the
694
- diff shows the weather and nothing else.
695
-
696
- ### Apparent size — and why there is no real one
697
-
698
- A observer never perceives meters. They perceive an angle: the thing covered a thumbnail at arm's length, or a fifth
699
- of the windshield, or two full Moons. "About thirty meters long" is a conclusion they drew from a distance they
700
- could not perceive either, and the two errors multiply. So a recording stores `angular` — how wide and how tall the
701
- object *looked*, in degrees — and stores no real size and no real distance anywhere.
702
-
703
- `bounds` is that angle projected onto the fixed 640x360 canvas at the pose's own field of view **and through the
704
- recording's own instrument** (see below), which is what every editing gesture, hit-test and renderer keeps working
705
- on. The angle is authoritative: `SightingShapes` (`src/engine/persistence/SightingShapes.ts`) re-derives `bounds`
706
- from it on load and reads it back from `bounds` on save, so a file survives a change of canvas, of field of view or
707
- of instrument, and if the two ever disagree the angle wins. `ImageProjection`
708
- (`src/engine/instrument/ImageProjection.ts`) owns the conversion itself.
709
-
710
- Through an eye at 60° across 360px, one degree is exactly 6px and the full Moon about 3.1px — so an object of 3.5m
711
- at 90m is 13px wide, not the 90px an author reaches for unaided. That is why the editor shows the three readings of
712
- that one relation side by side — **apparent width**, **real width**, **distance** — kept in step: edit any one and
713
- one of the other two follows, and the one the **Hold** select pins never moves. With nothing held the real width
714
- is what a thing keeps: moving it further makes it look smaller, and editing either width moves the other at the
715
- distance it stands (the shape is resized on the canvas). Hold the apparent width instead and a distance edit
716
- leaves the shape looking the same, stood elsewhere — which is what asking what the decor would hide of it needs.
717
- Only the angle is kept; the metres are an authoring aid, never account, and the distance is where the scene
718
- *draws* the shape (see *Where the shape is drawn*).
719
-
720
- ### Decor that moves, and lights that blink
721
-
722
- `DecorObject` is scenery whose position is known, and two things it can now also be:
723
-
724
- - **`track`** — where it is over time, altitude included. Scenery stays put; an aircraft crossing the sky or a car
725
- driving past states a few keyframes and `resolveDecorPlacementAt` interpolates between them (heading is *held*,
726
- not blended: nothing here knows which way round a turn was flown).
727
- - **`lights`** — its individual lamps, each with a place on the body, a colour, and a **pattern**: steady, or
728
- flashing at `perMinute` with a `dutyCycle` and a `phase`. A square wave, never a fade.
729
-
730
- The rates are real and regulated — aircraft anticollision lights flash 40–100 times a minute, road-vehicle hazard
731
- flashers 60–120 — and `LIGHT_RIGS` (`src/engine/model/LightRig.ts`) holds ready-made sets: airliner, helicopter,
732
- car headlights, car hazards, emergency beacons, streetlamp. A catalogue of specific aircraft is more entries there,
733
- never more code. Nothing about this is aircraft-specific: what dots a long exposure for an airliner's beacon dots
734
- it for a car's hazards too, at a different rate.
735
-
736
- That rate is the point, and it is now DRAWN. On a long exposure the spacing of the dots along a streak is the flash
737
- rate times the object's angular speed, which is exactly how a photograph of a passing airliner is told from a
738
- photograph of something that does not blink — and it is why the model exposes `lightOnFractionBetween` rather than
739
- only "is it on?". A wingtip strobe is lit for a hundredth of its cycle; sampled instant by instant it would be
740
- missed almost every time, and the dots that did appear would be an artefact of the sampling rate. Integrating the
741
- fraction of each interval is exact however coarsely it is sampled.
742
-
743
- The photograph this reproduces is Gennevilliers, 5 November 1990 (`/science/crypto/ufo/enquete/meprise/aeronef/
744
- avion/`): an airliner on a ten- or thirty-second pose, published as a monumental craft — its steady lamps drawing
745
- LINES and its flashing ones DOTS AT REGULAR INTERVALS. Reconstructed here at 6.7 km on a 20 s pose through a 50 mm
746
- lens, an aircraft leaves an 800 px streak carrying 21 dots a median 41 px apart, where 60 flashes a minute over
747
- 20 s across 800 px predicts one every 40. `ExposureSampling` is what makes that possible: the sky drifts a single
748
- pixel in ten seconds and would ask for two instants, so the pose is sampled instead for what MOVES in the scene
749
- (an instant per two pixels of travel) and for what FLASHES in it (two instants per flash, which is what tells one
750
- dot from the next rather than dotting the line at the sampler's own rate). A lamp is also written in real units —
751
- `LAMP_RADIANCE`, about twenty times white for a position light, and `DecorLight.intensity` as a ratio of peak
752
- candela (a wingtip strobe really is some twenty times one) — because a pose spreads a lamp's light over hundreds
753
- of pixels, and at white a whole aircraft trail came out at a thousandth of white, i.e. invisible.
754
-
755
- An aircraft in a scene is a **hypothesis**, not account — "here is what a flight at that altitude and heading
756
- would have looked like" — and belongs to the decor for that reason, next to the buildings and trees whose
757
- positions are likewise known rather than reported.
758
-
759
- ### How big it was, and what it looked like
760
-
761
- `DecorObject` used to have no size at all. Every building was a six-metre cube per storey, every vehicle the same
762
- 1.8 × 4.35 × 2.0 box, whatever the account said. In a project that will not store an angle nobody perceived (see
763
- *Apparent size*, above), that was the last place a number was invented rather than stated: Zamora's Pontiac and a
764
- delivery van were the same object, and "the dynamite shack" was a warehouse.
765
-
766
- - **`sizeM`** — the object's real size in metres along its OWN axes: `widthM` across it, `lengthM` along the way its
767
- heading faces, `heightM` up. **Each axis is separately optional**, and an absent one means nobody measured it: the
768
- built-in shape keeps its own proportion there. Pacing out the length of a shed states one number, and must not
769
- oblige anyone to invent a width. The editor shows what the built-in shape measures as a grey **placeholder**, which
770
- is the honest way to say "this is what you are looking at, and it is not a measurement".
771
- - **`model`** — which real 3D model stands in for the built-in shape, if one does. Named by catalogue **`id`** (see
772
- below), or by a direct **`url`** that wins over it — an escape hatch the editor keeps collapsed, since naming a
773
- glTF file by hand means also carrying the credit that has to travel with it.
774
-
775
- A model **never decides how big the object is**. It is fitted to the stated size, uniformly, along the length: the
776
- length is the axis heading is defined by and the one a vehicle, an airframe or a building is most reliably described
777
- by, so it sets the scale and the model's own proportions decide the rest. A model whose proportions then disagree
778
- with the measured width or height is the wrong model for the object — a curation problem, not something to hide by
779
- squashing it. The measurement stays in the data and the model only says what shape fills it, so swapping the model
780
- can never quietly change an angle a reader is measuring.
781
-
782
- ### Where the 3D models come from
783
-
784
- `DecorModelProvider` (`src/render3d/decor/`) is a catalogue, in the same lineage as `ElevationProvider` and
785
- `ImageryProvider` and registered the same way — the picker in **Data sources** *is* the credit. Its whole job is to
786
- answer "what does this `id` mean today, and who has to be credited for it", which is the part that is allowed to
787
- change under a recording that never does. A reconstruction saying a 1964 patrol car stood eight metres away should
788
- not have to be edited when a better model of one turns up.
789
-
790
- `UfoAtHomeModelCatalogue` is the one implementation, and it reads `/models/index.json` **beside the page first and
791
- from ufoathome.org otherwise**: a host that mirrors the models serves its own, and every other page embedding
792
- `<rr0-scene>` — an rr0.org case dossier above all — gets them from here, with no configuration.
793
-
794
- They are hosted rather than linked because of what a survey of the alternatives found. The catalogues readable
795
- cross-origin hold test objects and props: Khronos's sample assets exist to exercise glTF features (a toy car; a milk
796
- truck carrying Cesium's trademark), and Poly Haven's 521 models are tools, bottles and food, with no vehicle and no
797
- building among them. The CC0 kits that *do* contain a car, an airframe or a lamp post live mainly on third-party
798
- mirrors whose permanence and stated licence vary. So the rule is the one this project already applies to data it did
799
- not produce: when a source is not guaranteed to last, take a copy, keep the credit with it, and serve it from
800
- somewhere that will. See `public/models/README.md` for the catalogue's own format and for what a model has to satisfy
801
- to go in it.
138
+ and open `/play/?sighting=/demo-data/<file>.json` (or `/edit/?sighting=…`). `npm run site:watch` rebuilds the pages
139
+ when `site/` or `public/demo-data/` change.
802
140
 
803
- What is in it today is at least one stylised low-poly model for every decor kind — two cars, a house, two trees and a
804
- street lamp from Kenney's CC0 kits, and a narrow-body airliner from Google's Poly archive under CC BY 3.0 — each named
805
- in the picker as the placeholder it is: right KIND, no particular model, make or year. That is the whole of the claim,
806
- and it is worth making: a car-shaped silhouette at forty metres in evening light reads as a car, where a rectangular
807
- prism reads as a building, which is where this started. Socorro's own patrol car uses one of them, at the real
808
- Pontiac's dimensions and with the substitution stated in the recording's description.
141
+ Tips:
809
142
 
810
- One thing a model does NOT yet do is let the observer look out from inside it. A recording can place them inside a
811
- building or a vehicle, and what they then look at is the room the built-in shape builds around them — walls, window
812
- openings sized from the data, the pillar between two door windows. A downloaded model is a hull with none of that, so
813
- while the observer is inside an object the built-in shape is kept. Looking out through a real model is the objective
814
- (it is what makes "how much of the sky did the windscreen pillar hide?" answerable) and needs models with interiors,
815
- glazing made genuinely transparent, and the viewpoint taken from the model rather than from the primitive's seat.
143
+ - WebGL screenshots of a browser tab are unreliable (the drawing buffer is cleared after compositing); read pixels
144
+ with `gl.readPixels()` right after a frame instead.
816
145
 
817
- Three things follow, and all three are deliberate:
146
+ ### Performance
818
147
 
819
- - **`GLTFLoader` is imported only when a recording actually names a model.** It is a hundred kilobytes of addon, and
820
- almost every recording has none.
821
- - **A model with no credit is not drawn.** An unattributed model is not a licence, it is a hope. The credits that
822
- *are* complete appear in the info panel beside the recording's own sources.
823
- - **Every failure ends with the built-in shape.** A catalogue that is offline, an address that has rotted, a file
824
- that is not valid glTF are all "no model today". Scenery that vanished because a CDN was down would be worse than
825
- scenery drawn as boxes.
148
+ The harnesses under `scripts/perf/` drive a real Chromium through Playwright, which is **not** a dependency:
149
+ point `PLAYWRIGHT_MODULE` at an installation. They run headed, since a headless Chromium renders on the CPU and
150
+ measures nothing about the card.
826
151
 
827
- ### Instrument — an eye is not a lens
152
+ | Script | Measures |
153
+ |---|---|
154
+ | `scripts/perf/profile-scene.mjs <recording> <out>` | a CPU profile of one scene playing (dev server) |
155
+ | `scripts/perf/demo-table.mjs [recordings…]` | fps, draws, CPU/GPU time per recording (built site) |
156
+ | `scripts/perf/gpu-breakdown.mjs <recordings…>` | GPU time of a still frame, one scene part switched off at a time |
157
+ | `scripts/perf/page-scroll.mjs <url> <label> <seconds>` | frame intervals and long tasks while scrolling a page |
828
158
 
829
- `instrument` says what the observation was made through, and it changes the geometry of every frame.
159
+ Output goes to `perf-out/` (gitignored). Each script's header lists its variables. Measure before and after any
160
+ rendering change, on the same scene, size and pixel ratio. See [`docs/performance.md`](docs/performance.md) for the
161
+ render contract and past findings.
830
162
 
831
- A camera lens maps a direction to its sensor as `r = f·tan θ`: straight lines stay straight, and everything away
832
- from the axis is stretched by `sec²θ` — 42% at 33° off-centre, 105% at the corner of a 16:9 frame with a 60°
833
- vertical field. That is *correct* for a photograph and only looks right from the projection centre, about half an
834
- image-width from the screen. An eye does no such thing: it perceives an angle as an angle wherever it falls, so
835
- `instrument: "eye"` renders `r = f·θ` — image distance proportional to angle, which is what lets a ruler held to
836
- the screen mean something. (A slight tangential stretch of `θ/sin θ` remains, 6% at 33°; no flat image escapes
837
- trading one distortion for another.)
838
-
839
- three.js's camera can only do the pinhole, so `EquidistantProjectionPass` renders the scene into an offscreen
840
- target with a deliberately wider field and resamples it in one fullscreen pass. Everything that *aims* at the scene
841
- rather than drawing it — the decor raycasts behind `decorDistancesAt`, and the direction each phenomenon is stood
842
- along — goes through `directionFor`, since a point on the visible image no longer means what the pinhole camera
843
- thinks it means.
844
-
845
- This is also why a change of instrument **moves** shapes and not just resizes them (`SightingShapes.reproject`): a
846
- pixel only names a direction once a projection is named. Leaving positions alone is exactly how an object drawn in
847
- several parts comes apart — the fuselage grows and its row of windows stays put.
848
-
849
- Every case file here declares `eye`, because every one of them was watched rather than filmed. Until this existed
850
- they were all rendered as photographs, which is what made Socorro's dynamite shack read as twice the size it
851
- subtends.
852
-
853
- One residual worth naming: an angular extent is stored as its *on-axis* value, and the plane that carries a
854
- shape is sized by that same on-axis conversion wherever it stands. For an object 9° off-axis subtending 9°, that
855
- is about 2.5% out; it is a fifth of the error it replaces, and it shrinks towards the centre of the frame.
856
-
857
- #### A pose long enough draws the sky
858
-
859
- The shutter accumulates the object (`UfoElement.exposureInstants`), and past a certain length it accumulates the
860
- **sky** too: the Earth turns under it at 15.041° an hour — a sidereal day, not a solar one — so every star is drawn
861
- out into the arc a tripod really records. A photograph of "lights that moved" is quite often exactly this, the
862
- lights having held perfectly still while the camera did not.
863
-
864
- `SkyDrift` (`src/engine/astronomy/SkyDrift.ts`) states what the SKY did, in pixels, and how many instants that
865
- takes to draw — `ExposureSampling` (`src/engine/model/ExposureSampling.ts`) answers the same question for what
866
- stands against it (see [Decor that moves](#decor-that-moves-and-lights-that-blink)), and the pose is drawn at
867
- whichever asks for more: one per pixel of the longest trail, so the arc lands on touching pixels instead of dashing, and
868
- **one** — no accumulation at all — whenever the sky moved less than a pixel, which is every ordinary frame (a
869
- snapshot renders in a tenth of a millisecond and never comes near this). Each instant is a whole sky RECOMPUTED at
870
- its own moment (`SceneElement.applySceneAt`, pushed through `SceneRenderer.setExposure`) rather than one sky nudged
871
- sideways, so the arcs curve towards the pole exactly as they should and the Moon and the planets travel their own
872
- way through the frame. `ExposureAccumulation` adds them on a half-float film in linear light and bends the sRGB
873
- curve only once, at the end — the same rule `colorSpace.ts` states, and the reason both fullscreen passes can now
874
- be told not to encode what they hand to it.
875
-
876
- The pose itself is one setting for the whole recording (`Sighting.exposureSeconds`, held to the device's own range),
877
- not a keyframed one: what the shutter did is how this observation was photographed, and a value that varied along
878
- the timeline would make the same recording two different photographs at two instants. A file written while it lived
879
- on each pose is read back through the first pose that stated one.
880
-
881
- A pose is drawn in two moves, because a sky costs about 8 ms to rebuild and a five-minute pose is 37 of them: the
882
- **viewfinder** immediately (one instant, a twentieth of a millisecond) and the **photograph** as the scene settles,
883
- a dozen milliseconds of instants per animation frame, shown as the film fills and gained up to stay properly
884
- exposed — so it goes from beady to smooth rather than from black to bright. Anything the observer does interrupts it
885
- and starts it again, which is why the editor stays answerable while a long pose is on. Doing it all inside one call
886
- is what made it crawl: the dozen setters a single tick touches would each rebuild the whole pose, and the per-frame
887
- animation loop (twinkle, rain, lightning — none of which survives a pose of minutes anyway) restarted it sixty
888
- times a second, so the picture never finished at all.
889
-
890
- Each instant carries its share of the light rather than its whole, so the picture stays exposed as it was taken and
891
- the movement shows as a trail. That has a consequence worth stating plainly, because it is the physics and not a
892
- shortcoming: a trail is FAINT, and the longer it is the fainter it gets — one star's light spread over more and
893
- more pixels. Measured against a night sky of 30.5: a first-magnitude star peaks at 181 as a point at 1/250 s, 52
894
- after ten minutes, 39 after thirty, 35 after an hour. This project draws what the pose collected, not what would
895
- read well.
896
-
897
- Only the 35 mm SLRs offer poses like that, and they offer them because they really had them: **B**, where the
898
- shutter stays open as long as it is held. An Instamatic had one shutter speed and a phone's night mode stops at ten
899
- seconds — neither can draw a trail, and neither is offered one: a pose typed past what the device could do is
900
- brought back inside its range rather than kept, since it would put a trail in the picture that the camera named on
901
- the same recording could never have drawn.
902
-
903
- Two consequences of drawing a pose rather than an instant, both of which cost an evening to find:
904
-
905
- - **What is on screen is not where the object is at the playhead**, so that is what a pointer has to be aimed at.
906
- `UfoElement.shapeAt` hit-tests every instant of the pose, and without it an object with a ten-second pose became
907
- impossible to select at all — the click fell straight through the streak onto the landscape behind. The selection
908
- handles stay on the playhead's own instant: where they sit against the streak is the answer to "which moment am I
909
- editing".
910
- - **How finely the pose is sampled is a distance, not a duration.** Counting time alone (a painting every fiftieth
911
- of a second) leaves a fast object visibly BEADED — 48 paintings across three hundred pixels land six apart, and a
912
- reader sees the paintings instead of the streak. The object's own travel decides it, one instant per two pixels.
913
-
914
- #### Where meters do come back
915
-
916
- The only real distance a account can support is an **inequality**, and only where the observer saw the object
917
- cross something whose position is known: it passed *behind* that hangar (at least that far), or *in front of* that
918
- tree (at most that far). `DecorObject.eastM/northM` give the decor its real position, `occludesSourceIds` says
919
- which side of it the object was on, and `SceneRenderer.decorDistancesAt` raycasts the exact line of sight to
920
- measure the crossing.
921
-
922
- An angle plus a distance is a size, and a size does not change as the object flies — so every crossing narrows the
923
- object's real width from one side, for the whole recording, and the narrowed width reads back as a distance at
924
- every other instant. `SizeEstimate` (`src/engine/shape/SizeEstimate.ts`) accumulates that, reports a contradiction
925
- rather than clamping one, and the editor prints the result under the apparent size.
926
-
927
- #### Where the shape is drawn
928
-
929
- A plane facing the observer, scaled to the stated angle, looks the same from their eye at any distance — so the
930
- scene has to put it *somewhere*, and where is a parameter of the picture, never a fact the recording states.
931
- `PhenomenonDepth` (`src/engine/shape/PhenomenonDepth.ts`) is the one place that decides it, from five sources in
932
- the order they outrank each other: a distance the recording **states** (none does yet — the tier exists for the
933
- day a close encounter is written as a body in metres); a **hypothesis** the reader is trying; what the observer's
934
- own walk **derives** (`ShapeDistance`, see below); what the crossings **bound**, the geometric middle of the
935
- interval they leave, since any distance in it draws every crossing correctly; and, for the majority that
936
- establishes nothing, a **conventional** few metres — in front of everything that was not declared to hide it,
937
- which is what "not declared" has always meant here, and near rather than far so a shape drawn low in the frame
938
- does not go under a distant ground. A shape drawn wholly inside another stands where that one stands, a hair
939
- nearer: Socorro's insignia is painted on its craft, and a craft tried at five hundred metres must take its
940
- insignia with it.
941
-
942
- The hypothesis is the editor's **Distance** field, and it outranks what the data establishes on purpose: a
943
- hypothesis is tested by watching it fail. Hold the apparent width, put the craft at five hundred metres and it goes
944
- behind the patrol car it was drawn in front of; bring it back and it comes out. It is never saved. The line under
945
- the fields says where the shape is drawn right now and on what basis, and the cross withdraws the hypothesis.
946
-
947
- Most sightings constrain nothing at all — a light in an empty night sky crosses nothing — and the readout then says
948
- so. "Unknown" is the honest answer for a majority of cases, and saying it out loud is the entire point of not
949
- storing a number instead.
950
-
951
- This format is deliberately independent of [`@rr0/data`](https://github.com/RR0/data)'s `RR0Event`/`@rr0/time`'s
952
- `Level2Date`/`@rr0/place`'s `Place` classes, even though its `time`/`place` fields are structurally aligned with
953
- them — importing those classes into browser-bundled code pulls in Node-only file-scanning dependencies that break a
954
- `vite build`. `src/engine/interop/rr0Data.ts` converts between the two for Node-side tooling (e.g. generating a
955
- case's `sighting.json` from its `RR0Event`).
956
-
957
- ## Architecture
958
-
959
- - `src/engine/` — framework-agnostic core: `model/` (`Shape`, `Timeline`, `Sighting`), `record/` (`Recorder`,
960
- `SamplingClock`), `playback/` (`Player`), `persistence/` (JSON (de)serialization), `astronomy/` (vanilla solar
961
- position), `interop/` (real `@rr0/data` conversion, Node-only).
962
- - `src/render/CanvasRenderer.ts` — paints shapes onto a `<canvas>` 2D context: the texture of each plane the
963
- scene stands a shape on, the editing handles on the overlay, and the recording brush.
964
- - `src/render3d/` — the Three.js decor renderer (`SceneRenderer`), the phenomena standing in it
965
- (`PhenomenonSystem.ts`, drawn in their own decor-depth-tested pass), and its pure, dependency-free color logic
966
- (`skyColors.ts`), kept separate so the latter is unit-testable without a WebGL context.
967
- - `src/component/` — the three Web Components and the playback layer under them. `UfoElement` (registered as
968
- `rr0-ufo`, an inner layer and not a published component since 0.54.0) owns the timeline, the controls and the
969
- pointer's canvas; `SceneElement` (`<rr0-scene>`) composes it directly (via `document.createElement`, not an inline
970
- template tag — see the comment at that call site), standing the phenomena in its scene. `SightingEditorElement` and
971
- `SightingElement` (`<rr0-sighting>`) both compose a `SceneElement` in turn (not `UfoElement` directly) —
972
- the editor reaches through to its public `ufoElement` property for the actual canvas/timeline/appearance work
973
- (the toolbar edits the exact same `Sighting` instance the nested scene renders from, so an observer/time/
974
- appearance change needs no separate sync step to reach the sky), while `SightingElement` reaches through to
975
- its public `sightingData`/`currentTerrainAttribution` for its own toolbar (observer picker) and info panel.
976
- - Playback linearly interpolates shapes between a source's surrounding keyframes for smooth motion
977
- (`Timeline.getInterpolatedShapeAt`/`Shape.lerpShape`), holding at the ends of its recorded range.
978
- - Recording samples the pointer position at a configurable rate via `requestAnimationFrame`, not on every
979
- `pointermove` event.
980
-
981
- ## Development
163
+ ## Building
982
164
 
983
165
  ```bash
984
- npm install
985
- npm run dev # local demo (record + play), Vite dev server
986
- npm test # vitest
987
- npm run build # type-check + build the demo
988
- npm run build:embed # build dist-embed/rr0-sighting-editor.mjs
989
- npm run build:embed-scene # build dist-embed-scene/rr0-scene.mjs
990
- npm run build:embed-sighting # build dist-embed-sighting/rr0-sighting.mjs
991
- npm run build:all # all three
992
- npm run build:site # ufoathome.org, into dist-site/
993
- npm run build:comets # regenerate the comet catalog from JPL Horizons
994
- npm run build:satellites # regenerate the satellite catalog from CelesTrak's SATCAT
995
- npm run build:tle # rebuild public/tle/ from a local copy of the orbital element archive
166
+ npm run build:all # schema, engine demo, and the three embeds
167
+ npm run build:site # the embeds, then ufoathome.org into dist-site/
996
168
  ```
997
169
 
998
- ## The site
999
-
1000
- [`ufoathome.org`](https://ufoathome.org) is built from `site/` in this repository, so the tool's documentation, its
1001
- demo catalogue and its roadmap stay in step with the version they describe. `npm run build:site` builds the four
1002
- embed bundles, then generates the pages into `dist-site/`, which is what Netlify deploys.
1003
-
1004
- It is **not** a Vite build. Its pages import the bundles `build:embed*` already produces and have nothing else to
1005
- bundle; running them through Vite would re-emit those bundles under hashed names, which is the opposite of what a
1006
- page handing out a copy-pasteable `<script src>` needs. So `site/build.ts` generates the HTML, copies the bundles
1007
- as they are, and copies `public/demo-data/` alongside them. The one exception is `site/scripts/jsonEditor.ts` (the
1008
- Player page's paste panel, which pulls in CodeMirror): it gets its own Vite config, and the page loads it lazily.
170
+ | Bundle | Config | Output | Registers |
171
+ |---|---|---|---|
172
+ | `@rr0/ufoathome/sighting` | `vite.embed-sighting.config.ts` | `dist-embed-sighting/rr0-sighting.mjs` | `<rr0-sighting>` (+ `<rr0-scene>`) |
173
+ | `@rr0/ufoathome/scene` | `vite.embed-scene.config.ts` | `dist-embed-scene/rr0-scene.mjs` | `<rr0-scene>` |
174
+ | `@rr0/ufoathome/editor` | `vite.embed-sighting-editor.config.ts` | `dist-embed-sighting-editor/rr0-sighting-editor.mjs` | `<rr0-sighting-editor>` (+ `<rr0-scene>`) |
1009
175
 
1010
- Each page is one module under `site/content/` holding **both** languages, because they are translations of each
1011
- other and keeping a sentence next to its counterpart is what stops the two from drifting. English is at the root
1012
- and is the fallback; French lives under `/fr/` with its own slugs. Like the components, the site detects and never
1013
- offers a picker — Netlify's own `Language=` rules do the detection, and `hreflang` declares the pairing to search
1014
- engines.
176
+ Each is self-contained (three.js and a star catalogue each) and self-registers on import. Heavy optional parts
177
+ (the body editor, the JSON editor) are dynamic imports: check they stay separate chunks.
1015
178
 
1016
- ## License
1017
-
1018
- MIT
1019
-
1020
- ### Cloud layers
179
+ ### The site
1021
180
 
1022
- `weatherTrack.keyframes[].weather.cloudLayers` overrides the historical cloud fields. An absent
1023
- array adapts the old lower/cirrus values; an empty array means clear sky. Layers are matched by
1024
- stable `id`, not array position. Added and removed layers fade their coverage; altitude, thickness,
1025
- size, density and wind interpolate. Type and seed change at the destination keyframe.
1026
-
1027
- ```json
1028
- {
1029
- "id": "low",
1030
- "type": "cumulus",
1031
- "baseM": 1500,
1032
- "thicknessM": 800,
1033
- "coverage": 0.55,
1034
- "sizeM": 1400,
1035
- "density": 1,
1036
- "darkness": 0.2,
1037
- "seed": 17,
1038
- "windDirectionDeg": 90,
1039
- "windSpeed": 5
1040
- }
1041
- ```
181
+ [ufoathome.org](https://ufoathome.org) is generated by `site/build.ts` from `site/content/`, one module per page
182
+ holding **both** languages side by side (English at the root, French under `/fr/`), so a sentence and its
183
+ translation cannot drift apart. It is not a Vite build: pages load the embed bundles as they are, under stable
184
+ names under `/lib/`, because they hand out copy-pasteable `<script src>` lines. The one Vite-built piece is the
185
+ Player's JSON editor (`site/scripts/jsonEditor.ts`, CodeMirror, loaded lazily). `_redirects` and `_headers` are
186
+ generated into `dist-site/` too; do not add them to `netlify.toml`.
1042
187
 
1043
- Base altitude is relative to the recording's reference ground; it must not follow the observer.
1044
- Wind overrides are optional and otherwise use the general weather wind. Advection integrates the
1045
- weather timeline from zero; seeking and pausing reproduce the same field. Coverage controls the
1046
- fraction of the generated horizontal field occupied by cloud, independently of characteristic
1047
- cloud size and optical density.
188
+ A change to the components' API or to the recording format is documented **there**, in `DocsComponentPage.ts`,
189
+ `DocsFormatPage.ts` and friends, in the same commit.
1048
190
 
1049
- Volumetric rendering is the default, for recordings written before there were layers too (their
1050
- cover, base and darkness are adapted into one layer). `SceneElement.setCloudRendering("surface")`
1051
- selects the lightweight flat deck, available for comparison on the development page under the cloud
1052
- test controls.
1053
- The thick layers use a 64³ byte noise texture, shared within a renderer, 48 view samples and up to
1054
- three light samples per occupied view sample. Continuous weather changes update uniforms without
1055
- recreating meshes or textures. Cirrus still use the lightweight surface renderer.
191
+ ## Releasing
1056
192
 
1057
- The volume intersects concentric spherical layers around Earth and shades density along the ray,
1058
- including from inside or above a layer. Distant detail converges toward average coverage and its
1059
- colour toward atmospheric haze. Phenomenon textures sample the same density, stopping at each
1060
- fragment's distance, and celestial attenuation uses a CPU twin of the field.
193
+ 1. `npm test`, then `npm run build:site` and check the result with `npm run site:serve`.
194
+ 2. `npm version <patch|minor> --no-git-tag-version`, commit the bump as `<version>`.
195
+ 3. `npm publish` (`prepublishOnly` rebuilds the schema and the three embeds, and runs the tests).
196
+ 4. `git push`: Netlify rebuilds ufoathome.org from `master` ([`netlify.toml`](netlify.toml)).
197
+ 5. If demo recordings changed, `npm run sync:cases` and deploy rr0.org.
1061
198
 
1062
- Current limits: full-resolution rendering (no temporal reconstruction or adaptive quality yet),
1063
- back-to-front composition of separate layers (overlapping volumes need joint integration), and
1064
- terrain occlusion still uses the proxy shell's depth rather than a metre-based depth pre-pass.
1065
- Thin cirrus use the lighter surface field and share that exact veil with the halo renderer. Crystal
1066
- alignment belongs to the cirrus layer that carries it, rather than to the whole sky.
1067
-
1068
- Layers can also contain optional `instances`: individual clouds with stable IDs, east/north
1069
- positions, base altitude, thickness, width, depth, rotation, density and an optional darkness
1070
- override. An individual cloud is a piece of its layer's own field — the same noise at the same
1071
- scale, seed and offset, under the same coverage threshold, lifted to a full cloud inside its
1072
- ellipsoid and carved out of the layer's deck there — so it is one of its neighbours, told apart by
1073
- nothing but where it stands and how big it is. It drifts with its layer's wind, attenuates
1074
- phenomena and celestial bodies, and remains present at zero layer coverage. A layer that holds
1075
- any is drawn as a volume in both rendering modes: a volume set among a surface's flat texture is
1076
- a thing of another kind. Instance properties interpolate on the weather timeline.
1077
-
1078
- The volume's coverage threshold is a quantile like the surface deck's, but fitted to what a ray
1079
- finds rather than to the field's values at a point (see `coverageThreshold` in
1080
- `VolumetricClouds.ts`), so a layer's coverage is about the fraction of sky it covers.
1081
- The weather panel in the recording editor lets you add and remove layers, and add, select, edit
1082
- and delete their individual clouds using numeric controls. Its optional
1083
- canvas manipulation mode selects individual clouds by density and drags them in a plane parallel
1084
- to the image, updating horizontal position and altitude. Drag increments preserve position
1085
- differences between keyframes and use the same projection as the rendered scene. Valid edits
1086
- save immediately and pause playback; the default scope is the current weather time. The whole-observation scope explicitly applies the
1087
- edited property across all weather keyframes, preserving other properties and winds. Layer wind
1088
- overrides may be left empty to inherit the general wind. Cloud edits take ownership from inferred
1089
- weather, preventing a pending lookup from replacing the edits. The normal recording export and
1090
- import retain the layers and instances. The development page only supplies the synthetic scenario
1091
- and surface/volume comparison; all cloud authoring uses the recording editor.
1092
-
1093
- `darkness` is stored per layer. An individual cloud may override it or omit it to inherit its
1094
- parent. `iceCrystalAlignment` is offered only on cirrus and remains author-editable when ERA5 owns
1095
- the measured fields, because no reanalysis records crystal orientation.
199
+ ## License
1096
200
 
1097
- For inferred Open-Meteo weather, the low, middle and high cloud fractions remain three separate
1098
- layers with stable identities. They inherit the reported wind and evolve on the weather timeline.
1099
- The existing total-cover value remains the dataset's total rather than a sum of the bands. Low
1100
- base altitude is estimated from the temperature/dew-point spread; other dimensions and morphology
1101
- are rendering assumptions, not observations of individual clouds. Editing clouds manually keeps
1102
- the established weather-ownership behavior; selecting weather records again restores inference.
201
+ MIT. Third-party data and models keep their own licences: see [CREDITS.md](CREDITS.md) and
202
+ [ufoathome.org/docs/sources](https://ufoathome.org/docs/sources/).