@rr0/ufoathome 0.17.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -82,7 +82,8 @@ The lightweight component (~9KB): a canvas plus Play/Pause/Loop/seek controls. U
82
82
 
83
83
  Playback matches the observation's *real reported duration* when it's known: set `time`/`endTime`, or `time`/
84
84
  `durationSeconds`, in the [data format](#data-format) (`durationSeconds` takes precedence over `endTime` if both are
85
- given). Watching a 5-minute sighting then takes 5 real minutes, not however long the recording itself took to
85
+ given but in the recorder, editing either date clears an explicit `durationSeconds` the pair can replace, so the
86
+ 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
86
87
  author (e.g. a quick mouse drag) — drag the seek bar directly to skip ahead. The start/end labels around the seek
87
88
  bar show real clock times when `time` has an hour (e.g. `02:45` → `02:50`); otherwise they show `0:00` → the
88
89
  duration actually available (the declared one if known, else the recording's own length). Playback loops by
@@ -232,6 +233,15 @@ scale a case's witness list actually has. If a witness has no `witness.title`, i
232
233
  the URL itself as a last resort. A mismatched `caseId` across the listed witnesses logs a console warning (doesn't
233
234
  block) — likely means unrelated recordings got listed together by mistake.
234
235
 
236
+ **Where two witnesses described different things, the recordings have to show it.** Not that they
237
+ must differ — witnesses often agree, and two matching recordings are then simply true. But a
238
+ difference that exists in the record and not in the files makes the witness picker a control that
239
+ does nothing, and nothing *looks* broken. Chiles-Whitted shipped that way for months: one testimony
240
+ under two names. Its two pilots drew the object differently for Project Sign — the captain a slim
241
+ ribbed cigar with a pointed nose and no windows, the co-pilot a blunt cylinder with two rows of
242
+ lit windows — and only the co-pilot, in the right seat, saw the terminal phase (McDonald's 1968
243
+ cross-check). Each file now carries its own witness's account.
244
+
235
245
  | Member | Kind | Description |
236
246
  |---|---|---|
237
247
  | `src` | attribute | URL of a single `sighting.json` or a witness manifest (above), fetched automatically on connect and whenever the attribute changes |
@@ -290,7 +300,7 @@ interface SightingRecordingJson {
290
300
  endTime?: { year?: number, month?: number, day?: number, hour?: number, minute?: number, second?: number } // alternative to durationSeconds
291
301
  durationSeconds?: number // alternative to endTime; takes precedence if both are set
292
302
  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
293
- place?: { lat: number, lng: number }[]
303
+ place?: { lat: number, lng: number, name?: string }[] // `name` is the fully qualified place name the coordinates were resolved from — see Naming a place
294
304
  witness?: { id?: string, dirName?: string, title?: string, lastName?: string, firstNames?: string[] } // every field optional — supply whichever is known; omit entirely for an anonymous witness
295
305
  caseId?: string // shared by every witness's own sighting.json for the same case — see <rr0-eyewitness>
296
306
  description?: string
@@ -321,6 +331,8 @@ interface SightingRecordingJson {
321
331
  witnessTrack?: { keyframes: Array<{ t: number, pose: { lat?: number, lng?: number, elevationM: number, headingDeg?: number, pitchDeg: number, fovDeg: number } }> }
322
332
  weatherTrack?: { keyframes: Array<{ t: number, weather: Weather }> }
323
333
  weather?: Weather // legacy static fallback for recordings predating weatherTrack
334
+ 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
335
+ 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
324
336
  decor?: DecorObject[] // buildings, trees, streetlights, vehicles, other witnesses — see src/engine/model/Decor.ts
325
337
  }
326
338
  ```
@@ -329,6 +341,156 @@ A shape left out of a later keyframe is **held** at its last recorded state, not
329
341
  keyframe is at `t=5000` is already painted, in that state, from `t=0` (hold-first/hold-last at both ends of a
330
342
  source's own range). To make something stop being visible, keyframe it with `transparency: 1`.
331
343
 
344
+ ### What it sounded like
345
+
346
+ Half of what makes these accounts strange is the sound — most often its absence. `soundTrack` records it on the
347
+ same clock as the shapes, because a sound rarely starts when the object does: a craft sitting silently on the
348
+ ground and heard only as it lifts off is two keyframes, `kind: "none"` at the start and a hum at the instant it
349
+ took off.
350
+
351
+ `volume` (0..1, how loud the witness could describe it, never a dB figure) and `pitchHz` blend between keyframes;
352
+ `kind` and `src` are **held**, like every other discrete field in this format — so the example above really is
353
+ silent right up to that second keyframe. To record a sound emerging gradually instead, give it two keyframes of
354
+ its own kind (hum at volume 0, then hum at full).
355
+
356
+ `kind: "none"` is a statement — the witness reported hearing nothing. A recording with no `soundTrack` at all is
357
+ the different, weaker case: nobody was asked. Both replay as silence, and neither invents a noise.
358
+
359
+ Sounds are **synthesized** from that description (a drone, a whistle, a rumble, a crackle — `pitchHz` is the tone
360
+ itself for the pitched ones and where the noise sits for the others), exactly as a described shape is drawn from
361
+ its description, and at no cost in bundled assets. A recording that actually captured the sound can point `src` at
362
+ the audio file, which then plays instead — at the price of an embed that is no longer self-contained, and a URL
363
+ that must be CORS-readable.
364
+
365
+ Sound plays during playback only, and only after a real click somewhere in the player: browsers refuse to start
366
+ audio without one.
367
+
368
+ ### Naming a place
369
+
370
+ Testimony names a place. It says "on the Valensole plateau", "near Socorro", "over Montgomery" —
371
+ never 43.8379 / 5.9840. So the Location group leads with a **Place** field: type a name, press Enter
372
+ (or **Locate**), and the latitude and longitude below are filled from
373
+ [Nominatim](https://nominatim.openstreetmap.org/), OpenStreetMap's own geocoder — which, unlike the
374
+ gazetteer-style services, knows the hamlets, farms and airfields that cases actually happen at.
375
+ A name is often ambiguous, so every candidate stays listed in **Matches** and the best one is
376
+ applied straight away; picking another moves the witness. Results are named in the reader's own
377
+ language, and credited as their licence requires.
378
+
379
+ What gets stored is the *qualified* name the search resolved (`place[].name`), not the two words
380
+ typed — half the interesting cases happen near a village that shares its name with four others, and
381
+ a later reader needs to land on the same spot. A name no geocoder knows is kept as typed, so a
382
+ recording can still say "the lavender field east of the farm" beside coordinates entered by hand.
383
+
384
+ The field reads both ways: move the latitude or longitude by hand and a resolved name is re-derived
385
+ from the new coordinates, or cleared if there is no place there. A name left describing somewhere
386
+ the sighting is no longer at is worse than no name — the recording would state, in writing, that it
387
+ happened there. A name the witness typed themselves is never replaced.
388
+
389
+ **Altitude is above sea level**, and the ground at the location sets its floor: a witness in the
390
+ Alps is not at 0 m, and an editor that offers it invites a recording that says so. The ground's own
391
+ height is read from whichever elevation source is live and shown beside the field. What gets stored
392
+ is unchanged — `ObserverPose.elevationM` stays a height above the local ground, which is what the
393
+ terrain patch is built around.
394
+
395
+ ### Time zones
396
+
397
+ An hour of `utcOffsetHours` is an hour of Earth's rotation *and* a different row of the weather
398
+ record. Pick the witness's own zone (`Europe/Paris`, `America/Denver`, …) and the offset is derived
399
+ from that zone's rules **at the observation's date**: Valensole in July 1965 resolves to UTC+1, not
400
+ the UTC+2 the same place gives today — France only reintroduced summer time in 1976. Change the date
401
+ and it is derived again. The recording stores both: `timeZone` is the rule, `utcOffsetHours` is the
402
+ number it produced, and every consumer keeps reading only the number.
403
+
404
+ The rules come from the platform's own IANA database. What it cannot fix is a zone whose
405
+ *boundaries* are coarse: Montgomery, Alabama is `America/Chicago`, which observed summer time in
406
+ 1948 while Alabama did not. That is why the zone is chosen by the witness rather than derived from
407
+ the coordinates — and why the plain entered offset remains available for exactly those cases.
408
+
409
+ The search runs only when asked, never per keystroke: Nominatim's usage policy allows the first and
410
+ rules out the second.
411
+
412
+ ### Who answered is part of the answer
413
+
414
+ Every kind of real-world data this editor pulls in — places, weather, ground relief, aerial imagery
415
+ — is chosen by a picker sitting **where that data is reported**, with the attribution its licence
416
+ requires next to it:
417
+
418
+ > 2 places found according to `[Nominatim ▾]` © OpenStreetMap
419
+ >
420
+ > From `[ERA5 (Open-Meteo) ▾]` © Copernicus/ECMWF, 2026-08-21 15:30 UTC
421
+
422
+ Those pickers *are* the credits. Naming who the data comes from and letting it be chosen are the
423
+ same act: a static "© OpenStreetMap" tucked beside a field says where today's answer came from but
424
+ hides that it is a choice, and a picker with no attribution credits nobody. Relief and imagery have
425
+ no sentence to sit in, so they get a row under the coordinates whose ground they describe.
426
+
427
+ Most registries hold a single entry today (imagery holds two, Esri and EOX Sentinel-2 cloudless) —
428
+ which is the point: the seam is visible before it is used, and adding an implementation means adding
429
+ one entry to a registry (`placeSources.ts`, `weatherSources.ts`, `terrainSources.ts`), not new
430
+ markup.
431
+
432
+ A stale offset renders midnight over Paris and gives no clue why, so an offset that cannot belong to
433
+ the declared longitude is flagged on the field, with the meridian's own solar time in the tooltip.
434
+
435
+ Deliberately a wide net rather than a precise one. Legal time genuinely departs from solar time,
436
+ sometimes by hours (all of China runs on UTC+8), and the historical rules are worse — the check must
437
+ never cry wolf at a correct "France on UTC+1 in 1965". It flags only what no country has ever done,
438
+ and only as a warning: the recording states the witness's clock, and nothing here knows better than
439
+ the witness.
440
+
441
+ ### Weather is looked up, not remembered
442
+
443
+ The Circumstances group is the one part of this editor that isn't testimony. Weather is a
444
+ measurable fact about a place at an instant, and the recording already states both — so instead of
445
+ leaving a witness (or an author reconstructing a case decades later) to set a cloud-cover slider
446
+ from memory, the editor looks the conditions up from [ERA5](https://open-meteo.com/en/docs/historical-weather-api),
447
+ the ECMWF reanalysis, hourly and worldwide from 1940 on. The fields then show the record's own
448
+ values, **read-only**, above a line naming the dataset and the exact UTC instant they describe (a
449
+ wrong `utcOffsetHours` shows up there before it shows up in the rendered sky). The request that
450
+ produced them is kept in `weatherSource.url`, so the claim stays checkable years later.
451
+
452
+ Two of the fields have no direct counterpart in the record and are *derived* — `cloudDarkness`,
453
+ which is a look rather than a measurement (weighted by which layers hold the cloud, plus rain and
454
+ thunderstorm), and `cloudBaseM`, placed at the lowest deck holding a real share of the sky, from
455
+ Espy's temperature/dew-point spread for a low deck. Both are documented in
456
+ `src/engine/weather/providers/OpenMeteoWeatherProvider.ts`.
457
+
458
+ Unchecking **From weather records** hands the fields back to the witness: the looked-up values stay
459
+ as a starting point, `weatherSource` is dropped, and no later lookup may overwrite them — the same
460
+ "declared outranks deduced" rule [Behind a cloud](#behind-a-cloud) follows. A recording that names
461
+ a `weatherSource` is replayed exactly as authored and never looked up again, so a published case
462
+ file reads identically offline. Nothing is ever locked without a record to show for it: before 1940,
463
+ or with no network, the fields stay editable and the line says which of the two it is.
464
+
465
+ The `weatherTrack` follows the observation rather than flattening it: a keyframe at its start, one
466
+ at every whole hour it runs through, and one at its end. The record is read *between* its hourly
467
+ rows, not snapped to the nearest one — so Wilcox's two hours carry a cloud deck lifting from 800 m
468
+ to 913 m, and even a four-minute sighting gets a track that moves instead of one value repeated.
469
+ (For a short observation the record often genuinely says nothing changed. That is an answer, not a
470
+ missing feature.)
471
+
472
+ The keyframes are placed on the clock playback actually runs on — `timeline.duration` once
473
+ something has been recorded, and the observation's own declared length before that (the same rule
474
+ `Player.durationOverrideMs` implements). That clock *changes length as authoring proceeds*: the
475
+ first recorded shape turns a fifteen-hour span into a few seconds, so the track is re-derived
476
+ whenever it does. Getting either half wrong looks the same from outside — a sighting whose weather
477
+ never changes.
478
+
479
+ It follows the witness too, not just the clock. Half of aviation testimony is given from a cockpit,
480
+ and an aircraft under observation for an hour is a long way from where it started — so each sample
481
+ is looked up at the position the `witnessTrack` puts the witness at that instant. Positions inside
482
+ one ERA5 grid cell (~28 km) are one query, and several cells still travel in a single request, so
483
+ a stationary witness costs exactly what it always did.
484
+
485
+ `scripts/infer-case-weather.ts` runs the same lookup over case files on disk:
486
+
487
+ ```bash
488
+ npx tsx scripts/infer-case-weather.ts --dry-run path/to/sighting.json
489
+ ```
490
+
491
+ It rewrites only `weatherTrack` and `weatherSource`, splicing them into the file's own text so the
492
+ diff shows the weather and nothing else.
493
+
332
494
  ### Behind a cloud
333
495
 
334
496
  `behindCloud` is how a recording says "it disappeared into a cloud" — keyframed like any other
@@ -0,0 +1 @@
1
+ const e={oval:"Ovale",polygon:"Polygone",addVertex:"Ajouter un sommet",deleteVertex:"Supprimer le sommet",notAPolygon:"Sélectionnez une seule forme polygonale",tooFewVertices:"Une forme a besoin d'au moins 3 points",color:"Couleur",transparency:"Transparence",halo:"Halo",shape:"Forme",shapeTitle:"Nom",objectSize:"Taille réelle",objectDistance:"Distance",apparentSize:"soit {deg}° — {moons}× la Lune",utcOffset:"Fuseau horaire",utcOffsetPlaceholder:"d'après la longitude",objectSizePlaceholder:"largeur rapportée",objectDistancePlaceholder:"distance rapportée",addShape:"Ajouter une forme",deleteShape:"Supprimer la forme",play:"Lecture",pause:"Pause",noDuration:"Aucune durée d'observation",autoReplay:"Lecture automatique",group:"Grouper",ungroup:"Dégrouper",bringToFront:"Placer devant",sendToBack:"Placer derrière",contextMenuDelete:"Supprimer",confirmDeleteShape:"Supprimer {name} ? Cette action est irréversible.",confirmDeleteShapes:"Supprimer {count} formes ? Cette action est irréversible.",onlyOneShape:"Il n'y a qu'une seule forme",alreadyAtFront:"Cette forme est déjà la plus en avant",alreadyAtBack:"Cette forme est déjà la plus en arrière",needTwoShapesToGroup:"Sélectionnez au moins deux formes pour les grouper",notGrouped:"Cette forme ne fait pas partie d'un groupe",multipleShapesSelected:"Plusieurs formes sélectionnées — sélectionnez-en une seule pour modifier ceci",cloudBase:"Base des nuages",elevation:"Altitude",samplingRate:"Échantillonnage",duration:"Durée",durationPlaceholder:"durée de l'observation",durationImprecise:"Ces dates ne permettent pas de déterminer précisément une durée — saisissez-la manuellement.",export:"Exporter le JSON",importFile:"Charger un fichier JSON",importUrl:"Ou charger depuis une URL",importUrlPlaceholder:"https://…/sighting.json",importButton:"Charger",importError:"Impossible de charger cet enregistrement — vérifiez le fichier ou l'URL et réessayez.",record:"Enregistrer",stop:"Arrêter",according:"selon",sourceElevation:"Relief",sourceImagery:"Imagerie",placeName:"Lieu",placeNamePlaceholder:"Valensole, France",searchPlace:"Localiser",placeMatch:"Correspondances",placeSearching:"Recherche du lieu…",placeMatchFound:"lieu trouvé",placeMatchesFound:"lieux trouvés",placeNotFound:"Aucun lieu de ce nom",placeSearchFailed:"Recherche de lieu momentanément indisponible",utcOffsetImplausible:"Ce fuseau ne correspond pas à la longitude saisie, dont l'heure solaire est UTC{solar} — la scène et le relevé météo obéissent pourtant à ce qui est déclaré ici. Videz le champ pour le déduire de la longitude.",timeZoneManual:"décalage saisi",altitudeAboveSeaLevel:"Altitude du témoin au-dessus du niveau de la mer — le sol du lieu saisi en fixe le minimum : un témoin dans les Alpes n'est pas à 0 m.",groundAt:"sol à {m} m",latitude:"Latitude",longitude:"Longitude",heading:"Orientation",headingPlaceholder:"inconnu",pitch:"Inclinaison",observationTime:"Début de l'observation",observationEndTime:"Fin de l'observation",edtfInvalid:"Date/heure EDTF invalide (ex. 1965-07-01T05:00, 2025-06?, 2025~).",edtfPlaceholder:"AAAA-MM-JJThh:mm[?~%] ou hh:mm",observationTimeHint:"EDTF — ex. 1965-07-01T05:00, 2025-06? (incertain), 2025~ (approximatif), ou juste 05:00 si la date est inconnue",observationEndTimeHint:"EDTF — ex. 1965-07-01T05:10, 2025-06? (incertain), 2025~ (approximatif), ou juste 05:10 si la date est inconnue",presetsGroupLabel:"Forme de l'ovni",witnessId:"ID témoin",witnessDirName:"Répertoire du témoin",witnessTitle:"Titre du témoin",witnessLastName:"Nom de famille du témoin",witnessFirstNames:"Prénoms du témoin",caseId:"ID de l'affaire",description:"Description",tags:"Mots-clés",tagsPlaceholder:"séparés par des virgules",weather:"Météo",shapeGroup:"Forme",soundGroup:"Son",temporalGroup:"Date et heure",locationGroup:"Lieu",observationGroup:"Observation",witnessGroup:"Témoin",circumstancesGroup:"Circonstances",weatherInferred:"D'après les relevés",weatherInferredTitle:"La météo n'est pas un témoignage : c'est un fait mesurable, relevé pour la date, l'heure et le lieu de l'observation. Décochez pour saisir les conditions rapportées par le témoin, qui priment alors sur le relevé.",weatherFrom:"D'après",weatherLookingUp:"Recherche du relevé…",weatherNeedsDateAndPlace:"Date complète et lieu requis pour retrouver le relevé",weatherNoRecord:"Aucun relevé pour cette date et ce lieu",weatherLookupFailed:"Relevés météo momentanément inaccessibles",cloudCover:"Couverture nuageuse",cloudDarkness:"Obscurité des nuages",precipitationType:"Précipitations",precipitationNone:"Aucune",precipitationRain:"Pluie",precipitationSnow:"Neige",precipitationHail:"Grêle",precipitationIntensity:"Intensité",windDirection:"Direction du vent",windSpeed:"Force du vent",storm:"Orage",soundKind:"Son",soundNone:"Aucun (silencieux)",soundHum:"Bourdonnement",soundWhistle:"Sifflement",soundRumble:"Grondement",soundCrackle:"Crépitement",soundVolume:"Intensité sonore",soundPitch:"Hauteur",soundSrc:"Enregistrement",soundSrcPlaceholder:"URL d'un enregistrement réel",lensFlareBrightness:"Intensité lumineuse",cameraDevice:"Appareil photo/vidéo",decor:"Décor",decorBuilding:"Bâtiment",decorTree:"Arbre",decorStreetlight:"Lampadaire",decorVehicle:"Véhicule",decorWitness:"Autre témoin",addDecor:"Ajouter",deleteDecor:"Supprimer l'élément de décor",decorEast:"Distance à l'est",decorNorth:"Distance au nord",decorHeading:"Orientation",decorLit:"Allumé",decorTitle:"Nom",decorSightingUrl:"URL de l'enregistrement du témoin",viewTestimony:"Voir le témoignage",noWitnessRecording:"Aucune URL d'enregistrement définie pour ce témoin",masks:"Masque",addWitness:"Ajouter un témoin",decorFloors:"Étages",decorOccupiedFloor:"Étage occupé",decorWitnessSide:"Emplacement du témoin",decorWitnessSideNone:"Non présent",decorWindows:"Fenêtres",decorSideFront:"Avant",decorSideBehind:"Arrière",decorSideLeft:"Gauche",decorSideRight:"Droite",decorSideFrontLeft:"Avant-gauche",decorSideFrontRight:"Avant-droit",decorSideBehindLeft:"Arrière-gauche",decorSideBehindRight:"Arrière-droit"};export{e as ufoRecorderMessages_fr};