@rr0/ufoathome 0.66.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 +146 -1046
- package/dist-embed-scene/UfoMessages_fr-_6zR9Sw1.js +1 -0
- package/dist-embed-scene/rr0-scene.mjs +340 -285
- package/dist-embed-sighting/CoverageAssessor-CZfxbN9A.js +1 -0
- package/dist-embed-sighting/HynekAssessor-DPsLpEGQ.js +1 -0
- package/dist-embed-sighting/SightingMessages_fr-BQ0559gi.js +1 -0
- package/dist-embed-sighting/UfoMessages_fr-_6zR9Sw1.js +1 -0
- package/dist-embed-sighting/rr0-sighting.mjs +347 -292
- package/dist-embed-sighting-editor/{BodyEditor-eKnfK1A2.js → BodyEditor-Q5FX46YD.js} +2 -2
- package/dist-embed-sighting-editor/CoverageAssessor-C_QKvYFk.js +1 -0
- package/dist-embed-sighting-editor/HynekAssessor-Onzg_YHp.js +1 -0
- package/dist-embed-sighting-editor/SightingEditorMessages_fr-TAMdgr_N.js +1 -0
- package/dist-embed-sighting-editor/UfoMessages_fr-_6zR9Sw1.js +1 -0
- package/dist-embed-sighting-editor/rr0-sighting-editor.mjs +424 -365
- package/dist-embed-sighting-editor/sightingSchema-sxXkPJIy.js +1 -0
- package/package.json +3 -1
- package/dist-embed-scene/UfoMessages_fr-BbAWUSSI.js +0 -1
- package/dist-embed-sighting/CoverageAssessor-mVlehblF.js +0 -1
- package/dist-embed-sighting/HynekAssessor-BiP2di32.js +0 -1
- package/dist-embed-sighting/SightingMessages_fr-KwK5zJGF.js +0 -1
- package/dist-embed-sighting/UfoMessages_fr-BbAWUSSI.js +0 -1
- package/dist-embed-sighting-editor/CoverageAssessor-FIt771Wz.js +0 -1
- package/dist-embed-sighting-editor/HynekAssessor-DUqRa3Su.js +0 -1
- package/dist-embed-sighting-editor/SightingEditorMessages_fr-D2QBeLp7.js +0 -1
- package/dist-embed-sighting-editor/UfoMessages_fr-BbAWUSSI.js +0 -1
- package/dist-embed-sighting-editor/sightingSchema-DGLfQ-J1.js +0 -1
package/README.md
CHANGED
|
@@ -2,1101 +2,201 @@
|
|
|
2
2
|
|
|
3
3
|
# UFO@home
|
|
4
4
|
|
|
5
|
-
**UFO@home** lets a UFO
|
|
6
|
-
instead of relying only on a written or spoken account.
|
|
7
|
-
recommendation](https://rr0.org/time/1/9/6/8/07/29/Symposium/Shepard/index_fr.html) that a visual reconstruction of a
|
|
8
|
-
testimony 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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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-witnesses>` — see below) is the
|
|
26
|
-
standard way to display any real sighting, whether it has one witness or several: a witness 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
|
-
|
|
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
|
-
|
|
23
|
+
Node ≥ 20 (the site is built on Node 22, pinned in [`netlify.toml`](netlify.toml)).
|
|
37
24
|
|
|
38
25
|
```bash
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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/witness-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 witness'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-witness-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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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 witness 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 witness's own pose — geographic position, elevation, and viewing heading/pitch/field of view — can vary over
|
|
226
|
-
the sighting's timeline via `witnessTrack` in the sighting JSON (a keyframe array of `{ t, pose }` alongside
|
|
227
|
-
`timeline` — the model class is `ObserverTrack`, but the serialized key is `witnessTrack`, 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 `witnessTrack` 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 witness'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 `witnessTrack`
|
|
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 `witnessTrack` 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 witness, 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 witness 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
|
-
witness'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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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 witness 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 witness 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 witness 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 witness or several — renamed from
|
|
363
|
-
`<rr0-ufo-witnesses>` once it stopped being just a multi-witness selector (see [Naming](#naming)). It composes a
|
|
364
|
-
nested `<rr0-scene>` the same way `<rr0-sighting-editor>` does, since a witness 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 witness's `sighting.json` directly or a **case**: RR0's own `case.json`, whose
|
|
372
|
-
`events` of `eventType: "sighting"` each point at one witness'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": "witness-chiles.json" },
|
|
382
|
-
{ "type": "event", "eventType": "sighting", "url": "witness-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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
case id grouping them together are read from that witness's *own* file (`witness`/`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 witness's recording is fetched upfront (to read its name), not lazily on selection — fine at the
|
|
393
|
-
scale a case's witness list actually has. If a witness has no `witness.title`, its `witness.id` is shown instead, or
|
|
394
|
-
the URL itself as a last resort. A mismatched `caseId` across the listed witnesses logs a console warning (doesn't
|
|
395
|
-
block) — likely means unrelated recordings got listed together by mistake.
|
|
396
|
-
|
|
397
|
-
**Where two witnesses described different things, the recordings have to show it.** Not that they
|
|
398
|
-
must differ — witnesses 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 witness picker a control that
|
|
400
|
-
does nothing, and nothing *looks* broken. Chiles-Whitted shipped that way for months: one testimony
|
|
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 witness'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
|
-
| `witnessUrls` | 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 witness'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 "Testimony by <witness>" sentence on the left, and a round "?" info
|
|
415
|
-
button on the right. The witness portion is plain text for a single witness (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 witness loads automatically once the list is known; switching the
|
|
418
|
-
selector loads that witness's already-fetched recording into the nested `<rr0-scene>` (no re-fetch). Setting
|
|
419
|
-
`witnessUrls` again (e.g. a case re-read) keeps the current selection if that witness 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 witness's observation metadata (date, location, case id, description,
|
|
429
|
-
tags — whichever are actually present in that witness's own `sighting.json`; the witness's own name isn't repeated
|
|
430
|
-
here, since it's already in the toolbar's testimony line). The date is shown on the WITNESS'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/witness-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
|
-
|
|
456
|
-
translated (English/French) the same way as the playback layer's own labels.
|
|
117
|
+
## Debugging
|
|
457
118
|
|
|
458
|
-
|
|
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 witness'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
|
-
witness?: { id?: string, dirName?: string, title?: string, lastName?: string, firstNames?: string[] } // every field optional — supply whichever is known; omit entirely for an anonymous witness
|
|
471
|
-
caseId?: string // shared by every witness'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 testimony 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
|
-
witnessTrack?: { 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 witness'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 witness heard — see What it sounded like
|
|
502
|
-
decor?: DecorObject[] // buildings, trees, streetlights, vehicles, other witnesses — 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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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 witness 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 witness 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 witness'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
|
-
witness'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 witness's eye.
|
|
557
|
-
|
|
558
|
-
**Lining it up.** The editor's Pictures group types the registration, copies the witness'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 witness'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 Witness group is the
|
|
567
|
-
witness'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 witness'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
|
-
Testimony 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 witness. 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 witness 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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
An hour of `utcOffsetHours` is an hour of Earth's rotation *and* a different row of the weather
|
|
601
|
-
record. Pick the witness'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 witness 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 witness's clock, and nothing here knows better than
|
|
642
|
-
the witness.
|
|
643
|
-
|
|
644
|
-
### Weather is looked up, not remembered
|
|
645
|
-
|
|
646
|
-
The Circumstances group is the one part of this editor that isn't testimony. Weather is a
|
|
647
|
-
measurable fact about a place at an instant, and the recording already states both — so instead of
|
|
648
|
-
leaving a witness (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 witness: 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 witness too, not just the clock. Half of aviation testimony 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 `witnessTrack` puts the witness 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 witness 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
|
-
|
|
134
|
+
npm run build:site
|
|
135
|
+
npm run site:serve # dist-site on http://localhost:5181
|
|
691
136
|
```
|
|
692
137
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
### Apparent size — and why there is no real one
|
|
697
|
-
|
|
698
|
-
A witness 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 testimony, 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 testimony — "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 testimony 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
|
-
|
|
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
|
-
|
|
811
|
-
|
|
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 witness 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
|
-
|
|
146
|
+
### Performance
|
|
818
147
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 witness 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 testimony can support is an **inequality**, and only where the witness 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 witness, 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 witness'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 (witness 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
|
|
985
|
-
npm run
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
|
1011
|
-
|
|
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
|
-
|
|
1017
|
-
|
|
1018
|
-
MIT
|
|
1019
|
-
|
|
1020
|
-
### Cloud layers
|
|
179
|
+
### The site
|
|
1021
180
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|
-
|
|
1044
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1098
|
-
|
|
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/).
|