UFO@home lets a UFO witness record the shape, appearance and movement of what they saw — and replay it like a VCR — instead of relying only on a written or spoken account. The approach follows Roger Shepard's recommendation that a visual reconstruction of a testimony is more faithful than an oral or written one.
Originally a Java applet (2003), the project has been rewritten from scratch in TypeScript: a small,
dependency-light engine (keyframe timeline, recording, playback, Canvas2D rendering) wrapped in four vanilla
Web Components — no UI framework, no build step
required by the consuming page. Two of the four (<rr0-scene>, and <rr0-eyewitness> which always composes it) pull
in Three.js for the 3D backdrop — see <rr0-scene> below for why
that's an isolated, opt-in bundle rather than a project-wide dependency.
<rr0-ufo> is the UFO's own 2D shape/appearance/movement layer — no "player" suffix, since read-only playback is
its default behavior and <rr0-ufo-recorder> is the one that needs a qualifier (it adds recording on top).
<rr0-scene> is named without "ufo" on purpose: it only renders a generic 3D decor (sky/horizon/stars) from a
real-world time and place, with no UFO-specific logic of its own — today it composes a nested <rr0-ufo> for the
common case (see its section below), but the decor itself could back other kinds of reconstructions later. A fully
generic version (accepting arbitrary overlay content instead of always creating its own <rr0-ufo>) is a natural
follow-up, not implemented yet. <rr0-eyewitness> (renamed from <rr0-ufo-witnesses> — see below) is the standard
way to display any real sighting, whether it has one witness or several: a witness account always implies a real
place and time, so it always composes <rr0-scene>, never a bare <rr0-ufo>.
See the Wiki for the project's history, and a live example embedded in rr0.org's UFO@home page and in its Chiles-Whitted case reconstruction.
npm install @rr0/ufoathomeFour self-contained, pre-built ES modules are published — each self-registers its custom element as soon as it's imported, no explicit setup call needed:
<script type="module" src="/node_modules/@rr0/ufoathome/dist-embed-ufo/rr0-ufo.mjs"></script>
<script type="module" src="/node_modules/@rr0/ufoathome/dist-embed/rr0-ufo-recorder.mjs"></script>
<script type="module" src="/node_modules/@rr0/ufoathome/dist-embed-scene/rr0-scene.mjs"></script>
<script type="module" src="/node_modules/@rr0/ufoathome/dist-embed-eyewitness/rr0-eyewitness.mjs"></script>or, from a bundler:
import "@rr0/ufoathome/ufo" // registers <rr0-ufo>
import "@rr0/ufoathome/recorder" // registers <rr0-ufo-recorder> (and <rr0-scene>, which it composes)
import "@rr0/ufoathome/scene" // registers <rr0-scene> (and <rr0-ufo>, which it composes)
import "@rr0/ufoathome/eyewitness" // registers <rr0-eyewitness> (and <rr0-scene>, which it composes)Only load the one(s) a given page actually needs — rr0-scene.mjs and rr0-eyewitness.mjs in particular pull in
Three.js and are far heavier than the other two (see their sections below), so pages that just need playback of an
already-drawn shape with no astronomy backdrop should stick to rr0-ufo.mjs.
The lightweight component (~9KB): a canvas plus Play/Pause/Loop/seek controls. Use it wherever a page only needs to replay an already-recorded sighting — this is the one to embed in content pages.
<rr0-ufo src="sighting.json"></rr0-ufo>| Member | Kind | Description |
|---|---|---|
src |
attribute | URL of a SightingRecordingJson file, fetched automatically on connect and whenever the attribute changes |
sightingData |
property (get/set) | The current recording as a plain SightingRecordingJson object |
sighting |
property (readonly) | The live Sighting model (real-world time/place + the recording's Timeline) |
canvasElement |
property (readonly) | The underlying <canvas> element |
renderer |
property (readonly) | The CanvasRenderer instance painting onto that canvas |
refresh() |
method | Re-reads the timeline's duration into the seek slider and repaints the current frame — call after externally mutating sighting.timeline |
loadFromSrc(url) |
method (async) | What the src attribute triggers internally; can be called directly too |
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-ufo-recorder>. |
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>. |
Playback matches the observation's real reported duration when it's known: set time/endTime, or time/
durationSeconds, in the data format (durationSeconds takes precedence over endTime if both are
given — but in the recorder, editing either date clears an explicit durationSeconds the pair can replace, so the
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
author (e.g. a quick mouse drag) — drag the seek bar directly to skip ahead. The start/end labels around the seek
bar show real clock times when time has an hour (e.g. 02:45 → 02:50); otherwise they show 0:00 → the
duration actually available (the declared one if known, else the recording's own length). Playback loops by
default — click the loop button (pressed = looping) to play once and stop instead.
Clicking anywhere on the canvas also toggles Play/Pause (not just the button), matching common video-player UX.
While playing, the toolbar and the fullscreen button (top-right, semi-transparent over the content) auto-hide and
only reappear on hover — always shown while paused/stopped. The fullscreen button uses the standard Fullscreen API
(requestFullscreen/exitFullscreen); exiting with Escape is native browser behavior, nothing custom.
Labels (Play/Pause, Auto-replay, Current position, Duration, Fullscreen) are translated (English/French) based on
the visitor's navigator.languages, falling back to English — there's no language-picker UI, this is the only
mechanism.
The authoring component (~540KB gzip — see below for why): everything <rr0-ufo> has, plus a shape/appearance
toolbar (oval/polygon presets, color, transparency, halo, and the object's real reported
size/distance — see Apparent size) and drag-to-record. It composes a nested
<rr0-scene> internally — not a bare <rr0-ufo> — so the shape being drawn is always seen against the
sighting's own real sky, computed live from whatever latitude/longitude/heading/orientation/observation-time
fields the toolbar currently holds (see Architecture). This absorbs <rr0-scene>'s own
Three.js/astronomy-engine weight on top of the authoring-only code this element already carried (Recorder
engine, SamplingClock, appearance toolbar) — a page that only needs to play a sighting (the common case: an
rr0.org case dossier) should still embed the much lighter <rr0-ufo> (or <rr0-scene> alone) directly, never
this heavier authoring component.
<rr0-ufo-recorder></rr0-ufo-recorder>
<rr0-ufo-recorder src="sighting.json"></rr0-ufo-recorder>With src, the editor opens on an existing recording instead of an empty canvas — the same
attribute the three other elements take. That is what makes an editor URL per observation possible:
rr0.org's own editor page maps its ?sighting= parameter onto it, and
ufoathome.org redirects any path it is given into that parameter, so
https://ufoathome.org/science/crypto/ufo/enquete/dossier/Socorro/sighting.json, or simplyhttps://ufoathome.org/Socorrofor a case dossier of that site (a value with no/is expanded to/science/crypto/ufo/enquete/dossier/<name>/sighting.json),
opens that observation for editing. The page only accepts recordings from its own origin: a shared link must not be able to display a recording fabricated elsewhere inside an rr0.org page. To load one from anywhere else, use the editor's own Load from URL field, which is an explicit gesture by whoever is sitting at the keyboard.
Usage: click Record, move the pointer over the canvas to draw the UFO's path, click Stop, then Play to
replay it. The nested <rr0-ufo>'s enableClickToPlay is set to false here — a completed recording drag also
fires a native "click" on the canvas, which would otherwise spuriously toggle playback right after recording.
All of the toolbar's own labels (shape presets, Color/Transparency/Halo, Add shape, Record/Stop, Export JSON,
Duration) are translated (English/French) the same way <rr0-ufo>'s own labels are — based on
navigator.languages, no picker UI.
| Member | Kind | Description |
|---|---|---|
src |
attribute | URL of a SightingRecordingJson to open in the editor, fetched on connect and whenever the attribute changes |
sightingData |
property (get/set) | Delegates to the nested <rr0-ufo>'s sightingData |
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 |
The environmental variant (~530KB gzip — Three.js plus
astronomy-engine's planetary/lunar position tables, which don't
tree-shake since they're one shared data table used internally for every body — this is by far the heaviest
of the four bundles, load it only on pages that want it): everything <rr0-ufo> has, composited over a 3D
sky/horizon/starfield backdrop instead of a plain background. Same markup and members as <rr0-ufo> (src,
sightingData, loadFromSrc, enableClickToPlay) — it's a drop-in upgrade, including click-to-play/pause
anywhere on the scene (the nested <rr0-ufo>'s transparent canvas covers the whole stage). The fullscreen button
fullscreens the whole scene (3D backdrop included), not just the nested <rr0-ufo>'s own overlay — it sets the
nested element's fullscreenTarget to its own outer stage for this.
<rr0-scene src="sighting.json"></rr0-scene>Real astronomy for misidentification spotting. A recurring cause of UFO reports is a mundane astronomical
object or atmospheric optical effect — Venus (by far the most commonly misreported "UFO"), other planets, the
Moon, lens flare, or halo phenomena like sun dogs/moon dogs. <rr0-scene> renders the sky astronomically: real
Sun/Moon/Venus/Mars/Jupiter/Saturn positions and the Moon's phase via
astronomy-engine (see src/engine/astronomy/CelestialPositions.ts),
and a real star catalog (see below) instead of a randomized field, filtered to naked-eye visibility
(magnitude ≤ 7.5) since these are human eyewitness observations, not instrument-assisted ones. The sky's
darkness/color follows the sun's altitude (day/twilight bands/night), and its dawn/dusk glow is anchored on the
sun's real compass direction, not spread uniformly around the horizon — see src/render3d/skyColors.ts.
The witness's own pose — geographic position, elevation, and viewing heading/pitch/field of view — can vary over
the sighting's timeline via observerTrack in the sighting JSON (a keyframe array alongside timeline, same
hold-last/interpolated-lookup shape — see src/engine/model/ObserverTrack.ts), driving both the camera's own
orientation and which real-world instant the astronomy is computed for as playback advances. Older recordings
with no observerTrack fall back to the legacy static place[0] (see resolveObserverPoseAt in
src/engine/model/Sighting.ts) — usable for sky darkness/color and camera pitch/fov, but with no compass heading
to orient the camera by.
src/engine/astronomy/SunPosition.ts (the original vanilla, dependency-free NOAA/Spencer solar position
approximation) stays in the repo, tested, and still backs skyBrightness()'s twilight-band classification — but
the live rendering path now uses astronomy-engine for the Sun too, for a single source of truth and to get the
Sun's azimuth from the same call used for the sky's directional glow.
<rr0-ufo-recorder> has editor fields for the witness's latitude/longitude/heading and the observation's start
date/time (all optional) — filling in lat+lng writes both the legacy place and a single t=0 observerTrack
keyframe (elevation/pitch/field of view stay at neutral defaults; there's no UI yet for authoring the observer
moving over time, only a single static pose per recording).
Not yet done: sun dog/moon dog/halo rendering, a real (non-flat) ground/terrain, precipitation and other optical
effects (lens flare, mirage), and a multi-keyframe observerTrack authoring UI (today the recorder can only set
one static pose; an observer that moves/re-orients mid-recording still needs hand-authored or scripted JSON). The
Moon's phase currently only dims/brightens its disc's overall
color rather than rendering a geometrically accurate crescent shape — a natural follow-up.
Regenerating the star catalog. src/assets/stars-mag7.5.bin (a compact binary asset, four concatenated
Float32Array sections: ra/dec/mag/ci — see src/render3d/StarCatalog.ts for the exact layout) is generated
from the HYG Database v4.1 (CC BY-SA), filtered to magnitude ≤ 7.5.
To regenerate it: download hyg/CURRENT/hygdata_v41.csv from that repo into scripts/data/hygdata_v41.csv
(gitignored — not checked in, ~34MB), then run npm run build:stars. The generated .bin/.json pair is
checked in (~400KB) since it's small and doesn't need regenerating on every install.
What else was in that sky. Beside the Sun, Moon, planets and stars, the scene states — and where it can, draws — the things that were genuinely up there and are genuinely mistaken for something else. Two so far, chosen because their record is complete for every date this project can reconstruct, with no lookup, no key and no coverage floor:
-
Meteor showers (
src/engine/astronomy/MeteorShowers.ts). A shower is a position in the Earth's own orbit, so the Perseids of 1948 are the Perseids of today. Rates are corrected for the radiant's real altitude, and the strongest statement is the negative one: a radiant below the horizon can have produced nothing. -
Comets (
src/engine/astronomy/Comets.ts). Twenty-three naked-eye apparitions from Halley 1910 to Tsuchinshan-ATLAS 2024, each with the orbit it was actually on that year. Positions come from a universal-variable Kepler propagation (Orbit.ts) checked against JPL Horizons to about a thousandth of a degree; brightness is modelled from magnitudes recorded at the time, and the tail is a real length in space, projected — so it shortens when it points away from the observer instead of across their sky. Half the apparitions have no recorded tail length and are drawn with no tail at all. -
Satellites (
src/engine/astronomy/Satellites.ts). Not which one — historical orbital elements cannot be obtained, and propagating today's back to 1965 would invent a precise, confident pass. What is complete is the ILLUMINATION:h = R(sec B - 1)gives how high the Earth's shadow stood above the witness, so deep in the night nothing in low orbit is lit and a light crossing the sky then was not a satellite. Being lit and being seen are kept apart — everything in orbit is sunlit by day, and an Iridium flare at magnitude -8 was genuinely watched at noon. Also complete, and from CelesTrak's SATCAT: how many tracked objects were in orbit that month, and when each named class existed (Echo balloons, Iridium flares, ISS, Starlink trains).
All three appear in the recorder's read-only "Sky:" line, with a button to turn the witness toward the meteor or the comet.
Regenerating the satellite catalog. src/engine/astronomy/satelliteCatalog.ts is generated by
npm run build:satellites from CelesTrak's SATCAT (CC BY
4.0), cached under scripts/data/ (gitignored). Only its LAUNCH_DATE and DECAY_DATE columns are
read: the orbital fields hold each object's current state, which for anything that has re-entered
is its state on the way down — Echo 1 is listed at 419 x 394 km and spent its life near 1500 — so
using them to describe a historical orbit would be quietly wrong. Which classes are worth naming,
and how bright they got, stay hand-entered in the script; every date is derived.
Regenerating the comet catalog. src/engine/astronomy/cometCatalog.ts is generated by
npm run build:comets, which asks JPL Horizons for osculating elements at
each apparition's own perihelion and caches the answers under scripts/data/horizons/ (gitignored). The list of
apparitions, and the peak magnitudes and tail lengths recorded at the time, are hand-entered in
scripts/build-comet-catalog.ts — the orbits are looked up, the brightness is an observation, and the script's
own doc comment explains why the two cannot come from the same place. The generated file is checked in.
The UFO shape itself deliberately stays a 2D overlay on top of the 3D decor, never "upgraded" to a 3D object: it's what the witness reported — possibly a misidentification or optical effect — not something to interpret as a real 3D shape. Only the surrounding environment, independently computable from real astronomy, is rendered in 3D.
The standard way to display any real sighting, whether it has one witness or several — renamed from
<rr0-ufo-witnesses> once it stopped being just a multi-witness selector (see Naming). It composes a
nested <rr0-scene> (not a bare <rr0-ufo>) the same way <rr0-ufo-recorder> does, since a witness recording is
always a real sighting and always needs the real sky/ground backdrop.
<rr0-eyewitness src="sighting.json"></rr0-eyewitness>src accepts either a single witness's sighting.json directly (the common case — no extra file needed) or, for
a case with several witnesses, a small manifest: a plain JSON array of each witness's own SightingRecordingJson
URL (typically relative to the case's own page, same as <rr0-ufo>'s own src):
["chiles-sighting.json", "whitted-sighting.json"]The two shapes are told apart automatically — a fetched JSON array is a manifest, a plain object is one witness's
own recording. No labels or ids are duplicated in a manifest itself — each witness's display name and the shared
case id grouping them together are read from that witness's own file (witness/caseId, see
Data format), so there's a single source of truth and nothing to drift out of sync. This means
every listed witness's recording is fetched upfront (to read its name), not lazily on selection — fine at the
scale a case's witness list actually has. If a witness has no witness.title, its witness.id is shown instead, or
the URL itself as a last resort. A mismatched caseId across the listed witnesses logs a console warning (doesn't
block) — likely means unrelated recordings got listed together by mistake.
Where two witnesses described different things, the recordings have to show it. Not that they must differ — witnesses often agree, and two matching recordings are then simply true. But a difference that exists in the record and not in the files makes the witness picker a control that does nothing, and nothing looks broken. Chiles-Whitted shipped that way for months: one testimony under two names. Its two pilots drew the object differently for Project Sign — the captain a slim ribbed cigar with a pointed nose and no windows, the co-pilot a blunt cylinder with two rows of lit windows — and only the co-pilot, in the right seat, saw the terminal phase (McDonald's 1968 cross-check). Each file now carries its own witness's account.
| Member | Kind | Description |
|---|---|---|
src |
attribute | URL of a single sighting.json or a witness manifest (above), fetched automatically on connect and whenever the attribute changes |
witnessUrls |
property (get/set) | The manifest as a plain array of URLs, for programmatic use instead of src |
loadFromSrc(url) |
method (async) | What the src attribute triggers internally; can be called directly too |
A toolbar row sits above the scene: a "Testimony by <witness>" sentence on the left, and a round "?" info
button on the right. The witness portion is plain text for a single witness (a one-option <select> would be
pointless); once there's more than one, it becomes the live <select> instead — but the sentence itself, and the
info button, stay visible either way. The first witness loads automatically once the list is known; switching the
selector loads that witness's already-fetched recording into the nested <rr0-scene> (no re-fetch). Setting
witnessUrls again (e.g. a manifest refresh) keeps the current selection if that witness is still present, instead
of resetting back to the first.
Clicking "?" opens a panel anchored under the button (it never shifts the canvas below it). Where the browser has
the popover API the panel is a top-layer popover="auto" — the one placement a host page's own overflow: hidden
wrapper cannot clip, which is what rr0.org's layout was doing to it — kept under the button by CSS anchor
positioning, flipping above it or centring in the viewport when that side is too short, and closing on Escape or a
click outside. Browsers without the API get the plain absolutely-positioned overlay instead.
Its main content is the currently-selected witness's observation metadata (date, location, case id, description,
tags — whichever are actually present in that witness's own sighting.json; the witness's own name isn't repeated
here, since it's already in the toolbar's testimony line). The date is shown on the WITNESS's own clock, never
converted into the reader's time zone (see utcOffsetHours in Data format).
A footer row holds the app's own name/version on the left — linking to that very observation in the editor (see
<rr0-ufo-recorder>'s own src), not to the application's home page — and two
fold-outs on the right, both closed until asked for:
-
Embed hands out the two self-contained lines it takes to put this observation on any other page, either as a replay (
<rr0-eyewitness>) or as the editor (<rr0-ufo-recorder>), with absolute URLs and a copy button:<script type="module" src="https://rr0.org/science/crypto/ufo/rr0-eyewitness.mjs"></script> <rr0-eyewitness src="https://rr0.org/science/crypto/ufo/enquete/dossier/Socorro/sighting.json"></rr0-eyewitness>
The script URL is derived from where the running bundle was itself loaded from (
import.meta.url), never hardcoded, so a snippet generated from a local or staging copy points back at that copy. Pasting it into a site of your own needs the bundle and the recording to be readable cross-origin (rr0.org serves/science/crypto/ufo/*withAccess-Control-Allow-Origin: *for exactly this). -
Credits reveals third-party credits (the live terrain imagery attribution, once a real relief patch has resolved, plus the bundled thunder sound's own required attribution — see
CREDITS.md).
All of this component's own labels (Testimony by, About, Close, Observation/Date/Location/Case, Credits) are
translated (English/French) the same way as <rr0-ufo>'s own labels.
Both components read/write a plain, JSON-serializable SightingRecordingJson:
interface SightingRecordingJson {
version: 1
time?: { year?: number, month?: number, day?: number, hour?: number, minute?: number, second?: number }
endTime?: { year?: number, month?: number, day?: number, hour?: number, minute?: number, second?: number } // alternative to durationSeconds
durationSeconds?: number // alternative to endTime; takes precedence if both are set
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
place?: { lat: number, lng: number, name?: string }[] // `name` is the fully qualified place name the coordinates were resolved from — see Naming a place
witness?: { id?: string, dirName?: string, title?: string, lastName?: string, firstNames?: string[] } // every field optional — supply whichever is known; omit entirely for an anonymous witness
caseId?: string // shared by every witness's own sighting.json for the same case — see <rr0-eyewitness>
description?: string
tags?: string[]
timeline: {
keyframes: Array<{
t: number // milliseconds since recording start
shapes: Array<{
sourceId: string // e.g. "ufo-1" — lets several shapes (a UFO, a landmark, a trailing flame...) share one timeline
shape: {
kind: "oval" | "polygon"
bounds: { x: number, y: number, width: number, height: number }
color: string // CSS color
angle: number // radians
transparency: number // 0 = opaque, 1 = fully transparent
haloScale: number // 0 = no glow
selected: boolean
title?: string // shown as an on-canvas tooltip when hovered
behindCloud?: boolean // the witness reported it behind cloud at this instant — stated, never deduced (see below)
angular?: { widthDeg: number, heightDeg: number } // how big it LOOKED — the only size a testimony holds, see Apparent size
points?: { x: number, y: number }[] // "polygon" shapes only
}
}>
}>
order?: string[] // back-to-front paint/hit-test order; absent = first-appearance order
groups?: string[][] // each inner array is one group's member sourceIds
}
witnessTrack?: { keyframes: Array<{ t: number, pose: { lat?: number, lng?: number, elevationM: number, headingDeg?: number, pitchDeg: number, fovDeg: number } }> }
weatherTrack?: { keyframes: Array<{ t: number, weather: Weather }> }
weather?: Weather // legacy static fallback for recordings predating weatherTrack
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
instrument?: "eye" | "rectilinear-lens" // what it was observed THROUGH — see Instrument. Absent = the naked eye
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
decor?: DecorObject[] // buildings, trees, streetlights, vehicles, other witnesses — see src/engine/model/Decor.ts
}A shape left out of a later keyframe is held at its last recorded state, not hidden — and one whose first
keyframe is at t=5000 is already painted, in that state, from t=0 (hold-first/hold-last at both ends of a
source's own range). To make something stop being visible, keyframe it with transparency: 1.
Half of what makes these accounts strange is the sound — most often its absence. soundTrack records it on the
same clock as the shapes, because a sound rarely starts when the object does: a craft sitting silently on the
ground and heard only as it lifts off is two keyframes, kind: "none" at the start and a hum at the instant it
took off.
volume (0..1, how loud the witness could describe it, never a dB figure) and pitchHz blend between keyframes;
kind and src are held, like every other discrete field in this format — so the example above really is
silent right up to that second keyframe. To record a sound emerging gradually instead, give it two keyframes of
its own kind (hum at volume 0, then hum at full).
kind: "none" is a statement — the witness reported hearing nothing. A recording with no soundTrack at all is
the different, weaker case: nobody was asked. Both replay as silence, and neither invents a noise.
Sounds are synthesized from that description (a drone, a whistle, a rumble, a crackle — pitchHz is the tone
itself for the pitched ones and where the noise sits for the others), exactly as a described shape is drawn from
its description, and at no cost in bundled assets. A recording that actually captured the sound can point src at
the audio file, which then plays instead — at the price of an embed that is no longer self-contained, and a URL
that must be CORS-readable.
Sound plays during playback only, and only after a real click somewhere in the player: browsers refuse to start audio without one.
The same rule governs the whole scene, not just the object's own sound: paused is paused. Falling precipitation and its splashes, twinkling stars, lightning flashes, the sun's lens flare and the weather's own ambient beds all stop with the player and resume with it, leaving the frozen frame on screen. A paused replay is one instant of a sighting — weather still going on over it would be the reader's own room, not the witness's evening. (The cloud deck is not in that list because it does not move at all: its noise field is fixed, with no time of its own. Real drifting cloud is part of the volumetric-cloud work still to come.)
Testimony names a place. It says "on the Valensole plateau", "near Socorro", "over Montgomery" — never 43.8379 / 5.9840. So the Location group leads with a Place field: type a name, press Enter (or Locate), and the latitude and longitude below are filled from Nominatim, OpenStreetMap's own geocoder — which, unlike the gazetteer-style services, knows the hamlets, farms and airfields that cases actually happen at. A name is often ambiguous, so every candidate stays listed in Matches and the best one is applied straight away; picking another moves the witness. Results are named in the reader's own language, and credited as their licence requires.
What gets stored is the qualified name the search resolved (place[].name), not the two words
typed — half the interesting cases happen near a village that shares its name with four others, and
a later reader needs to land on the same spot. A name no geocoder knows is kept as typed, so a
recording can still say "the lavender field east of the farm" beside coordinates entered by hand.
The field reads both ways: move the latitude or longitude by hand and a resolved name is re-derived from the new coordinates, or cleared if there is no place there. A name left describing somewhere the sighting is no longer at is worse than no name — the recording would state, in writing, that it happened there. A name the witness typed themselves is never replaced.
Altitude is above sea level, and the ground at the location sets its floor: a witness in the
Alps is not at 0 m, and an editor that offers it invites a recording that says so. The ground's own
height is read from whichever elevation source is live and shown beside the field. What gets stored
is unchanged — ObserverPose.elevationM stays a height above the local ground, which is what the
terrain patch is built around.
An hour of utcOffsetHours is an hour of Earth's rotation and a different row of the weather
record. Pick the witness's own zone (Europe/Paris, America/Denver, …) and the offset is derived
from that zone's rules at the observation's date: Valensole in July 1965 resolves to UTC+1, not
the UTC+2 the same place gives today — France only reintroduced summer time in 1976. Change the date
and it is derived again. The recording stores both: timeZone is the rule, utcOffsetHours is the
number it produced, and every consumer keeps reading only the number.
The rules come from the platform's own IANA database. What it cannot fix is a zone whose
boundaries are coarse: Montgomery, Alabama is America/Chicago, which observed summer time in
1948 while Alabama did not. That is why the zone is chosen by the witness rather than derived from
the coordinates — and why the plain entered offset remains available for exactly those cases.
The search runs only when asked, never per keystroke: Nominatim's usage policy allows the first and rules out the second.
Every kind of real-world data this editor pulls in — places, weather, ground relief, aerial imagery — is chosen by a picker sitting where that data is reported, with the attribution its licence requires next to it:
2 places found according to
[Nominatim ▾]© OpenStreetMapFrom
[ERA5 (Open-Meteo) ▾]© Copernicus/ECMWF, 2026-08-21 15:30 UTC
Those pickers are the credits. Naming who the data comes from and letting it be chosen are the same act: a static "© OpenStreetMap" tucked beside a field says where today's answer came from but hides that it is a choice, and a picker with no attribution credits nobody. Relief and imagery have no sentence to sit in, so they get a row under the coordinates whose ground they describe.
Most registries hold a single entry today (imagery holds two, Esri and EOX Sentinel-2 cloudless) —
which is the point: the seam is visible before it is used, and adding an implementation means adding
one entry to a registry (placeSources.ts, weatherSources.ts, terrainSources.ts), not new
markup.
A stale offset renders midnight over Paris and gives no clue why, so an offset that cannot belong to the declared longitude is flagged on the field, with the meridian's own solar time in the tooltip.
Deliberately a wide net rather than a precise one. Legal time genuinely departs from solar time, sometimes by hours (all of China runs on UTC+8), and the historical rules are worse — the check must never cry wolf at a correct "France on UTC+1 in 1965". It flags only what no country has ever done, and only as a warning: the recording states the witness's clock, and nothing here knows better than the witness.
The Circumstances group is the one part of this editor that isn't testimony. Weather is a
measurable fact about a place at an instant, and the recording already states both — so instead of
leaving a witness (or an author reconstructing a case decades later) to set a cloud-cover slider
from memory, the editor looks the conditions up from ERA5,
the ECMWF reanalysis, hourly and worldwide from 1940 on. The fields then show the record's own
values, read-only, above a line naming the dataset and the exact UTC instant they describe (a
wrong utcOffsetHours shows up there before it shows up in the rendered sky). The request that
produced them is kept in weatherSource.url, so the claim stays checkable years later.
Two of the fields have no direct counterpart in the record and are derived — cloudDarkness,
which is a look rather than a measurement (weighted by which layers hold the cloud, plus rain and
thunderstorm), and cloudBaseM, placed at the lowest deck holding a real share of the sky, from
Espy's temperature/dew-point spread for a low deck. Both are documented in
src/engine/weather/providers/OpenMeteoWeatherProvider.ts.
Unchecking From weather records hands the fields back to the witness: the looked-up values stay
as a starting point, weatherSource is dropped, and no later lookup may overwrite them — the same
"declared outranks deduced" rule Behind a cloud follows. A recording that names
a weatherSource is replayed exactly as authored and never looked up again, so a published case
file reads identically offline. Nothing is ever locked without a record to show for it: before 1940,
or with no network, the fields stay editable and the line says which of the two it is.
The weatherTrack follows the observation rather than flattening it: a keyframe at its start, one
at every whole hour it runs through, and one at its end. The record is read between its hourly
rows, not snapped to the nearest one — so Wilcox's two hours carry a cloud deck lifting from 800 m
to 913 m, and even a four-minute sighting gets a track that moves instead of one value repeated.
(For a short observation the record often genuinely says nothing changed. That is an answer, not a
missing feature.)
The keyframes are placed on the clock playback actually runs on — timeline.duration once
something has been recorded, and the observation's own declared length before that (the same rule
Player.durationOverrideMs implements). That clock changes length as authoring proceeds: the
first recorded shape turns a fifteen-hour span into a few seconds, so the track is re-derived
whenever it does. Getting either half wrong looks the same from outside — a sighting whose weather
never changes.
It follows the witness too, not just the clock. Half of aviation testimony is given from a cockpit,
and an aircraft under observation for an hour is a long way from where it started — so each sample
is looked up at the position the witnessTrack puts the witness at that instant. Positions inside
one ERA5 grid cell (~28 km) are one query, and several cells still travel in a single request, so
a stationary witness costs exactly what it always did.
scripts/infer-case-weather.ts runs the same lookup over case files on disk:
npx tsx scripts/infer-case-weather.ts --dry-run path/to/sighting.jsonIt rewrites only weatherTrack and weatherSource, splicing them into the file's own text so the
diff shows the weather and nothing else.
behindCloud is how a recording says "it disappeared into a cloud" — keyframed like any other
appearance field, and held rather than blended. It is stated, for the same reason
DecorObject.occludesSourceIds is: this format describes a 2D appearance on the witness's own field
of view, not where an object was in space, so nothing in it can deduce whether cloud came between
them. A recording holds no distance at all (see Apparent size below), and the sky's own gaps are
procedural noise — leaving the question to geometry means tuning the weather until the reported
disappearance happens to occur. So the witness's account is the whole answer: no behindCloud, no
cloud.
There used to be a geometric fallback here, for a recording that stated a real distance and made no claim about cloud. It went when stated distances did, and it had earned it: the one case it fired on was Chiles-Whitted, where "it disappeared into the cloud deck" turned out to be an interrogator's reconstruction that Whitted himself denied to McDonald in 1968.
A witness never perceives meters. They perceive an angle: the thing covered a thumbnail at arm's length, or a fifth
of the windshield, or two full Moons. "About thirty meters long" is a conclusion they drew from a distance they
could not perceive either, and the two errors multiply. So a recording stores angular — how wide and how tall the
object looked, in degrees — and stores no real size and no real distance anywhere.
bounds is that angle projected onto the fixed 640x360 canvas at the pose's own field of view and through the
recording's own instrument (see below), which is what every editing gesture, hit-test and renderer keeps working
on. The angle is authoritative: SightingShapes (src/engine/persistence/SightingShapes.ts) re-derives bounds
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
of instrument, and if the two ever disagree the angle wins. ImageProjection
(src/engine/instrument/ImageProjection.ts) owns the conversion itself.
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 at 90m is 13px wide, not the 90px an author reaches for unaided. That is why the editor's Try a size / at a distance of fields exist: type a hypothesis, get the angle it implies on the canvas, and the meters are forgotten the moment they have been applied. They are an authoring aid, never testimony.
DecorObject is scenery whose position is known, and two things it can now also be:
track— where it is over time, altitude included. Scenery stays put; an aircraft crossing the sky or a car driving past states a few keyframes andresolveDecorPlacementAtinterpolates between them (heading is held, not blended: nothing here knows which way round a turn was flown).lights— its individual lamps, each with a place on the body, a colour, and a pattern: steady, or flashing atperMinutewith adutyCycleand aphase. A square wave, never a fade.
The rates are real and regulated — aircraft anticollision lights flash 40–100 times a minute, road-vehicle hazard
flashers 60–120 — and LIGHT_RIGS (src/engine/model/LightRig.ts) holds ready-made sets: airliner, helicopter,
car headlights, car hazards, emergency beacons, streetlamp. A catalogue of specific aircraft is more entries there,
never more code. Nothing about this is aircraft-specific: what dots a long exposure for an airliner's beacon dots
it for a car's hazards too, at a different rate.
That rate is the point. On a long exposure the spacing of the dots along a streak is the flash rate times the
object's angular speed, which is exactly how a photograph of a passing airliner is told from a photograph of
something that does not blink — and it is why the model exposes lightOnFractionBetween rather than only "is it
on?". A wingtip strobe is lit for a hundredth of its cycle; sampled instant by instant it would be missed almost
every time, and the dots that did appear would be an artefact of the sampling rate. Integrating the fraction of
each interval is exact however coarsely it is sampled.
An aircraft in a scene is a hypothesis, not testimony — "here is what a flight at that altitude and heading would have looked like" — and belongs to the decor for that reason, next to the buildings and trees whose positions are likewise known rather than reported.
instrument says what the observation was made through, and it changes the geometry of every frame.
A camera lens maps a direction to its sensor as r = f·tan θ: straight lines stay straight, and everything away
from the axis is stretched by sec²θ — 42% at 33° off-centre, 105% at the corner of a 16:9 frame with a 60°
vertical field. That is correct for a photograph and only looks right from the projection centre, about half an
image-width from the screen. An eye does no such thing: it perceives an angle as an angle wherever it falls, so
instrument: "eye" renders r = f·θ — image distance proportional to angle, which is what lets a ruler held to
the screen mean something. (A slight tangential stretch of θ/sin θ remains, 6% at 33°; no flat image escapes
trading one distortion for another.)
three.js's camera can only do the pinhole, so EquidistantProjectionPass renders the scene into an offscreen
target with a deliberately wider field and resamples it in one fullscreen pass. Everything that aims at the scene
rather than drawing it — the decor raycasts behind isScreenPointOccluded and decorDistancesAt — goes through
directionFor, since a point on the visible image no longer means what the pinhole camera thinks it means.
This is also why a change of instrument moves shapes and not just resizes them (SightingShapes.reproject): a
pixel only names a direction once a projection is named. Leaving positions alone is exactly how an object drawn in
several parts comes apart — the fuselage grows and its row of windows stays put.
Every case file here declares eye, because every one of them was watched rather than filmed. Until this existed
they were all rendered as photographs, which is what made Socorro's dynamite shack read as twice the size it
subtends.
One residual worth naming: an angular extent is stored as its on-axis value, and applied to a shape wherever it sits. For an object 9° off-axis subtending 9°, that is about 2.5% out. The overlay draws axis-aligned boxes and cannot express more; it is a fifth of the error it replaces, and it shrinks towards the centre of the frame.
The only real distance a testimony can support is an inequality, and only where the witness saw the object
cross something whose position is known: it passed behind that hangar (at least that far), or in front of that
tree (at most that far). DecorObject.eastM/northM give the decor its real position, occludesSourceIds says
which side of it the object was on, and SceneRenderer.decorDistancesAt raycasts the exact line of sight to
measure the crossing.
An angle plus a distance is a size, and a size does not change as the object flies — so every crossing narrows the
object's real width from one side, for the whole recording, and the narrowed width reads back as a distance at
every other instant. SizeEstimate (src/engine/shape/SizeEstimate.ts) accumulates that, reports a contradiction
rather than clamping one, and the editor prints the result under the apparent size.
Most sightings constrain nothing at all — a light in an empty night sky crosses nothing — and the readout then says so. "Unknown" is the honest answer for a majority of cases, and saying it out loud is the entire point of not storing a number instead.
This format is deliberately independent of @rr0/data's RR0Event/@rr0/time's
Level2Date/@rr0/place's Place classes, even though its time/place fields are structurally aligned with
them — importing those classes into browser-bundled code pulls in Node-only file-scanning dependencies that break a
vite build. src/engine/interop/rr0Data.ts converts between the two for Node-side tooling (e.g. generating a
case's sighting.json from its RR0Event).
src/engine/— framework-agnostic core:model/(Shape,Timeline,Sighting),record/(Recorder,SamplingClock),playback/(Player),persistence/(JSON (de)serialization),astronomy/(vanilla solar position),interop/(real@rr0/dataconversion, Node-only).src/render/CanvasRenderer.ts— paints shapes onto a<canvas>2D context.src/render3d/— the Three.js decor renderer (SceneRenderer) and its pure, dependency-free color logic (skyColors.ts), kept separate so the latter is unit-testable without a WebGL context.src/component/— the four Web Components.UfoElement(<rr0-ufo>) owns the canvas/playback;SceneElement(<rr0-scene>) composes it directly (viadocument.createElement, not an inline template tag — see the comment at that call site) rather than duplicating it, adding the 3D decor on top.UfoRecorderElementandEyewitnessElement(<rr0-eyewitness>) both compose aSceneElementin turn (notUfoElementdirectly) — the recorder reaches through to its publicufoElementproperty for the actual canvas/timeline/appearance work (the toolbar edits the exact sameSightinginstance the nested scene renders from, so an observer/time/ appearance change needs no separate sync step to reach the sky), whileEyewitnessElementreaches through to its publicsightingData/currentTerrainAttributionfor its own toolbar (witness picker) and info panel.- Playback linearly interpolates shapes between a source's surrounding keyframes for smooth motion
(
Timeline.getInterpolatedShapeAt/Shape.lerpShape), holding at the ends of its recorded range. - Recording samples the pointer position at a configurable rate via
requestAnimationFrame, not on everypointermoveevent.
npm install
npm run dev # local demo (record + play), Vite dev server
npm test # vitest
npm run build # type-check + build the demo
npm run build:embed # build dist-embed/rr0-ufo-recorder.mjs
npm run build:embed-ufo # build dist-embed-ufo/rr0-ufo.mjs
npm run build:embed-scene # build dist-embed-scene/rr0-scene.mjs
npm run build:embed-eyewitness # build dist-embed-eyewitness/rr0-eyewitness.mjs
npm run build:all # all four
npm run build:comets # regenerate the comet catalog from JPL Horizons
npm run build:satellites # regenerate the satellite catalog from CelesTrak's SATCATMIT
