@johnmorrisdotca/chikyu-earth 0.7.0 → 0.8.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/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ Satellites on the space view, the space view in the embed, and the demo's space controls in Japanese.
6
+
7
+ - **Satellites** (`@johnmorrisdotca/chikyu-earth/satellites`, new, and `<chikyu-space>`): `createSatellites(elements)` turns orbital elements into positions with SGP4, and `space.setSatellites("stations", group)` draws them: a group is **one draw call** however many it holds
8
+ (`THREE.Points`), each shown or hidden by name (`showSatellites`), with their orbits (`orbits: true` for a group of 40 or fewer), a ring and an orbit for a chosen one (`selectSatellite`, `satelliteInfo`) and `chikyu-satellite-click` for a press on one
9
+ (not on one behind the Earth). **The package fetches nothing and ships no orbital data**: your page passes in elements in CelesTrak's GP JSON format (`ElementSet`), and the guide has the rules for getting them (CelesTrak's usage policy; Space-Track's
10
+ agreement does not let an account holder pass data on, so it is not used). Elements more than 14 days from the moment shown (`maxAgeDays`) are left out. [docs/satellites.md](docs/satellites.md), the [satellites guide](docs/guides/satellites.md) (and in Japanese).
11
+ - **SGP4 about once a second, not once a frame**: satellite.js 6.0.2 (MIT, a new optional peer dependency; 10 KB gzipped, plain JavaScript; version 7 pulls WASM helper files that bundlers choke on) is run for the whole group at two moments a step apart, and every
12
+ position between is an interpolation (a metre at 1 second, a few kilometres at 60). Above 60 times real time the groups are hidden and SGP4 does not run (`MAX_SATELLITE_SPEED`); a chosen satellite is still placed exactly. The package's own part is 2.7 KB minified
13
+ for the maths and 5.1 KB for the drawing (3.6 KB gzipped together). A unit test checks that a satellite's longitude from the scene's axes and sidereal time agrees with satellite.js's own within 0.02 degree, and an end-to-end test centres the view under the ISS (worked
14
+ out in the test) and finds it in the middle of the Earth's disc.
15
+ - **Budget, each line tested** (`e2e/satellites.gl.ts`, 6,000 made-up satellites spread round their orbits, together with the demo's 2,252 real ones): paused, 0 GL draw calls, 0 frame requests and 0 SGP4 calls in 3.5 s; at 60 times real time 29.0 frames a second, 8,242 SGP4 calls a second (one for
16
+ each satellite), 34.2 GL draw calls a frame against 33.4 without the 6,000 (the group is one), 0.09 ms of JavaScript a frame on average and 2.6 ms for the longest SGP4 batch of 8,252; at real time 1.00 frames a second and 2,249 SGP4 calls a second for 2,252
17
+ satellites; at 3,600 times real time the groups are hidden, 0 SGP4 calls and 29 frames a second; with reduced motion 1.00 frames a second at 60 times; nothing drawn out of sight or in a hidden tab; a group taken away frees its buffer; a lost context comes back with the
18
+ satellites. The freeze test has a third look, the space view with satellites on, and its look-changing rounds show the stations and Starlink every time (GPU objects flat at 85 after the warm-up round). The ten-minute soak adds and removes the 6,000, chooses satellites and runs
19
+ past the speed limit: 964 steps, JS heap 13.73 to 14.04 MB after garbage collection (while the 6,000 are on it is 20.4 MB, and it returns), GPU objects 123 to 123, 28.5 frames a second on average, one WebGL context. The soak and the new tests now run with no motion preference, at the full 30 frames a second.
20
+ - **Demo**: a "Satellites" row (Stations, Starlink, and Find: ISS, Tiangong) in the space look, and a line about what is chosen (name, height, speed, period, age of its elements). It uses **one snapshot of CelesTrak's lists from 2 November 2026** in a file of the demo site's own
21
+ (`demo/celestrak-snapshot.json`, 330 KB, 105 KB gzipped, fetched only when a group is asked for): all 20 stations and every fifth Starlink satellite (2,232 of 11,156), credited on the page and in NOTICE.md. A snapshot more than 14 days old shows the day it was taken instead of today.
22
+ `scripts/make-satellite-snapshot.mjs` makes the file from two downloaded lists and fetches nothing.
23
+ - **The space view in the embed** (`embed.html?look=space`): `time`, `speed` (still by default), `scale`, `body`, `zoom` (earth, moon, sun, inner, solar-system), `orbits`, `labels`, and `satellites=stations,starlink` with `sat=25544`. One context, a capped canvas, no controls, nothing but its own files, 0 draws at rest,
24
+ at most 30 a second at a day a second, nothing in a hidden tab (`e2e/embed.gl.ts`). The embed guide, in both languages, lists them.
25
+ - **Japanese for the demo's space controls**: Space, Time, Scale, Go to, Look at, every body's name, Orbits, Names, the scale note, the body and satellite facts and the notices, through `demo/words.js`; the names drawn beside the bodies change too (`element.bodyNames`, `element.bodyHint`). A browser test opens the Japanese
26
+ page and fails on any English word left in a control, apart from ISS, Starlink, CelesTrak, SGP4 and km.
27
+ - **Docs**: a satellites guide in English and Japanese, a page for the new entry point and every export (TSDoc and an example each), a usage example run by `pnpm test:usage` (a Vite page with the ISS on the space view, opened in a browser and made to place it; and the same elements in Node), README section, and screenshots.
28
+ - `ThreeParts` has an optional `Points`: a three.js subset handed over by `setThreeLoader` needs it only for satellites (the demo's `three-subset.ts` has it).
29
+ - The CHANGELOG of 0.7.0, whose published tarball showed the family-parts notes under the 0.6.0 heading, is as fixed in 6f26012; this entry is under its own heading.
30
+
3
31
  ## 0.7.0
4
32
 
5
33
  The space view can look at any body, bodies can be chosen, and how the Sun and the planets are drawn is a seam another package can fill.
@@ -9,6 +37,20 @@ The space view can look at any body, bodies can be chosen, and how the Sun and t
9
37
  - **A seam for drawing the bodies** (`BodyPainter`, `setBodyPainter`, `plainPainter` from `@johnmorrisdotca/chikyu-earth/space`): the space view keeps each body's place, size, light direction, name and orbit, and asks a painter only for what to draw there (an object of radius 1; `light(toSun)` each draw; the Sun's turns to face the camera). The default is what 0.6.0 drew: spheres in the planets' colours and a glow for the Sun. John chose a separate package (wakusei-planets) for the bodies' own looks, so chikyu ships no planet pictures and no rings; the Earth and the Moon keep theirs.
10
38
  - Tests: e2e (a body looked at, its name chosen by keyboard, a press on it chooses it, a drag that turns the view does not) and a unit test of the default painter.
11
39
 
40
+ ### The family's published parts: demo, embed, docs and usage
41
+
42
+ Chikyu Earth now has what every package of the family has, and it was missing from 0.3.0 to 0.5.0.
43
+
44
+ - **Demo on GitHub Pages**, set as the repository's and the package's homepage: <https://johnmorrisdotca.github.io/chikyu-earth/>, with the family header (the kazoku-ui language picker, English and Japanese, and theme switch, the table cloths and the Help switch), light and dark, and the family footer. `pnpm site` builds it, and the Pages workflow publishes it on every push to `main`.
45
+ - **An embed page**, `embed.html`, for an `<iframe>` with no script of your own: `?look=line|thick|realistic`, `center`, `graticule`, `turn=0|1|auto`, `time`, `clouds`, `lights`. Still by default, no controls, no location prompt; it draws only when something changes and nothing while hidden. Tested in Chromium and WebKit, and on the realistic look: one WebGL context, canvas inside its budget, nothing drawn at rest.
46
+ - **A documentation site** at `/docs/`: a page for every export (from its TSDoc, with an example that is run when the site is built), ten guides in English and in Japanese, one language to a page, search, `llms.txt` and the family's header. Every export of every entry point has a summary, `@param`, `@returns` and an `@example` that imports the package by its name; `pnpm check` fails when one does not.
47
+ - **Every way to use it, written down and tested** (`pnpm test:usage`): a script tag from jsDelivr and from unpkg, the realistic Earth from a CDN, the shared controls, Vite, webpack, React (and a server render of it), Vue, Svelte, Angular, an iframe, TypeScript types and Node. Each example is built or run against the package as npm packs it, opened in a browser and made to show a globe; the guides and the README must hold the same text word for word.
48
+ - **`look` attribute**: `<chikyu-earth look="thick">` is the new name of `style`, which frameworks read as CSS (React refuses a string `style`). `style` still works.
49
+ - **Types for the tags**: `document.querySelector("chikyu-earth")` is a `ChikyuEarth` (and `"chikyu-earth-realistic"`, `"chikyu-space"`) once the element's entry is imported.
50
+ - **A freeze test in the release** (`e2e/freeze.demo.ts`, `pnpm test:freeze`): at rest nothing is drawn and no frame asked for, turning draws at most once for each frame of the page and asks for one frame at a time, out of sight or in a hidden tab nothing is drawn, the canvas stays inside 6 million pixels and 4096 a side on a 5K screen at a pixel ratio of 2 in the Earth from space and the space view, and six rounds of changing the look leave the GPU objects flat in one WebGL context and let the context go at the end. Every Chromium project draws WebGL in software, never on the machine's graphics card. The release workflow runs it (`freeze-command`, kazoku-ui 0.9.1).
51
+ - **The family's must-have list**, checked: `scripts/check-standard.mjs` (kazoku-ui's, unchanged) fails `pnpm check` when a package lacks its demo, embed page, docs site with Japanese twins, family files or tested usage.
52
+ - **Family files**: `family-template.mjs` and `family-readme.mjs` of 2026-11-02 with Chikyu Earth added to the family's list (33 packages), `family.css`, and the README's "The family" block. Added to the list here only; the other packages take the new files when they are next touched.
53
+
12
54
  ## 0.6.0 (never published on its own: it ships in 0.7.0)
13
55
 
14
56
  The Sun, the Moon and the planets: `<chikyu-space>`, an optional entry point, under the same performance budget as the Earth from space.
@@ -28,20 +70,6 @@ The Sun, the Moon and the planets: `<chikyu-space>`, an optional entry point, un
28
70
  - **Research, no code** ([docs/satellite-sources.md](docs/satellite-sources.md), MODULES-85): CelesTrak (grey), Space-Track (red for a package), the r/SpaceX API (archived, offline), Launch Library 2 (grey), Wikipedia and Wikidata
29
71
  (green) for launches; the recommendation is a satellite view that takes elements the page supplies. John chooses before MODULES-86 and MODULES-87 start.
30
72
 
31
- ### The family's published parts: demo, embed, docs and usage
32
-
33
- Chikyu Earth now has what every package of the family has, and it was missing from 0.3.0 to 0.5.0.
34
-
35
- - **Demo on GitHub Pages**, set as the repository's and the package's homepage: <https://johnmorrisdotca.github.io/chikyu-earth/>, with the family header (the kazoku-ui language picker, English and Japanese, and theme switch, the table cloths and the Help switch), light and dark, and the family footer. `pnpm site` builds it, and the Pages workflow publishes it on every push to `main`.
36
- - **An embed page**, `embed.html`, for an `<iframe>` with no script of your own: `?look=line|thick|realistic`, `center`, `graticule`, `turn=0|1|auto`, `time`, `clouds`, `lights`. Still by default, no controls, no location prompt; it draws only when something changes and nothing while hidden. Tested in Chromium and WebKit, and on the realistic look: one WebGL context, canvas inside its budget, nothing drawn at rest.
37
- - **A documentation site** at `/docs/`: a page for every export (from its TSDoc, with an example that is run when the site is built), ten guides in English and in Japanese, one language to a page, search, `llms.txt` and the family's header. Every export of every entry point has a summary, `@param`, `@returns` and an `@example` that imports the package by its name; `pnpm check` fails when one does not.
38
- - **Every way to use it, written down and tested** (`pnpm test:usage`): a script tag from jsDelivr and from unpkg, the realistic Earth from a CDN, the shared controls, Vite, webpack, React (and a server render of it), Vue, Svelte, Angular, an iframe, TypeScript types and Node. Each example is built or run against the package as npm packs it, opened in a browser and made to show a globe; the guides and the README must hold the same text word for word.
39
- - **`look` attribute**: `<chikyu-earth look="thick">` is the new name of `style`, which frameworks read as CSS (React refuses a string `style`). `style` still works.
40
- - **Types for the tags**: `document.querySelector("chikyu-earth")` is a `ChikyuEarth` (and `"chikyu-earth-realistic"`, `"chikyu-space"`) once the element's entry is imported.
41
- - **A freeze test in the release** (`e2e/freeze.demo.ts`, `pnpm test:freeze`): at rest nothing is drawn and no frame asked for, turning draws at most once for each frame of the page and asks for one frame at a time, out of sight or in a hidden tab nothing is drawn, the canvas stays inside 6 million pixels and 4096 a side on a 5K screen at a pixel ratio of 2 in the Earth from space and the space view, and six rounds of changing the look leave the GPU objects flat in one WebGL context and let the context go at the end. Every Chromium project draws WebGL in software, never on the machine's graphics card. The release workflow runs it (`freeze-command`, kazoku-ui 0.9.1).
42
- - **The family's must-have list**, checked: `scripts/check-standard.mjs` (kazoku-ui's, unchanged) fails `pnpm check` when a package lacks its demo, embed page, docs site with Japanese twins, family files or tested usage.
43
- - **Family files**: `family-template.mjs` and `family-readme.mjs` of 2026-11-02 with Chikyu Earth added to the family's list (33 packages), `family.css`, and the README's "The family" block. Added to the list here only; the other packages take the new files when they are next touched.
44
-
45
73
  ## 0.5.0
46
74
 
47
75
  The Earth from space, and markers for any place on either globe, both under a hard performance budget.
package/NOTICE.md CHANGED
@@ -44,6 +44,17 @@ item to: NASA's Scientific Visualization Studio" (Visualizer Ernie Wright, USRA;
44
44
  `<chikyu-space>` computes positions with **astronomy-engine** (https://github.com/cosinekitty/astronomy, MIT, copyright Don Cross), the page's own optional peer dependency: this package does not
45
45
  bundle or ship it. The planets are drawn as plain colours, from no picture.
46
46
 
47
+ ## Satellites
48
+
49
+ `<chikyu-space>` draws satellites from orbital elements that **your page** passes in; this package fetches and ships none. SGP4 is run by **satellite.js** (https://github.com/shashwatak/satellite-js, MIT, by
50
+ Shashwat Kandadai and contributors), the page's own optional peer dependency: this package does not bundle or ship it.
51
+
52
+ The demo's file `demo/celestrak-snapshot.json` (the demo site only, not the npm package) holds element sets from **CelesTrak** (https://celestrak.org, T.S. Kelso), which serves the U.S. Space Force's
53
+ 18th Space Defense Squadron's general-perturbation (GP) data and, for Starlink, SpaceX's own supplemental elements. It is one download of the `stations` list and one of the `starlink` list on 2026-11-02
54
+ (about 17:07 UTC), within CelesTrak's usage policy (https://celestrak.org/usage-policy.php, read 2026-11-02: one download per update per list, nothing fetched at a visitor's request), with every fifth Starlink satellite
55
+ kept (2,232 of 11,156), as numbers only. CelesTrak states no licence or credit rule for the data; it is credited on the demo page, and the demo shows the age of the elements. The tier is "grey" in John's
56
+ terms: a snapshot with credit, never a live fetch. Space-Track's data are not used: its user agreement does not let an account holder pass the data on.
57
+
47
58
  ## Sun position
48
59
 
49
60
  `src/realistic/sun.ts` uses the low-precision solar position formulas of NOAA's solar calculator (https://gml.noaa.gov/grad/solcalc/, after Jean Meeus,
package/README.md CHANGED
@@ -135,6 +135,20 @@ computed in the browser by astronomy-engine (MIT), nothing is fetched. [docs/spa
135
135
 
136
136
  <p align="center"><img alt="The solar system on 2 November 2026 in the compressed scale: orbits round the Sun, the planets named" src="docs/images/space-solar.png" width="420"></p>
137
137
 
138
+ ## Satellites
139
+
140
+ The ISS, a space station or thousands of Starlink satellites at the places SGP4 puts them for the date and time shown, on `<chikyu-space>`: a group is one draw call however many it holds, press one to get its name, height and speed, and its orbit is drawn. Chikyu fetches nothing: your page passes in orbital elements (CelesTrak's JSON, within its usage policy), and satellite.js (MIT, an optional peer) does the maths about once a second, with every frame between interpolated. A group is hidden while the clock runs faster than 60 times real time. The guide has the rules for getting elements and the budget, each line tested: [satellites guide](docs/guides/satellites.md), [docs/satellites.md](docs/satellites.md).
141
+
142
+ <p align="center"><img alt="The ISS, ringed, over South Africa on 3 November 2026, with its orbit and the stations' orbits" src="docs/images/space-sat-iss.png" width="420"></p>
143
+
144
+ ```ts no-run
145
+ import "@johnmorrisdotca/chikyu-earth/space/element";
146
+ import { createSatellites, type ElementSet } from "@johnmorrisdotca/chikyu-earth/satellites";
147
+
148
+ const elements: ElementSet[] = await (await fetch("/my-celestrak-copy.json")).json();
149
+ document.querySelector("chikyu-space")!.setSatellites("stations", createSatellites(elements), { orbits: true });
150
+ ```
151
+
138
152
  ## Markers
139
153
 
140
154
  Mark any place at `[lon, lat]` with a label, colour or icon, in groups you can show or hide, on either look. Chikyu ships no places; yours move with the globe, fade on the far side, make room
@@ -14,6 +14,9 @@ import { type Layer } from "./tiers.ts";
14
14
  */
15
15
  export type ThreeParts = {
16
16
  [Name in "Texture" | "BufferGeometry" | "BufferAttribute" | "ShaderMaterial" | "Group" | "Mesh" | "Scene" | "OrthographicCamera" | "PerspectiveCamera" | "Vector3" | "WebGLRenderer" | "RepeatWrapping" | "ClampToEdgeWrapping" | "LinearMipmapLinearFilter" | "LinearFilter" | "BackSide" | "DoubleSide" | "LineLoop" | "LineBasicMaterial" | "Color"]: (typeof THREE)[Name];
17
+ } & {
18
+ /** Only the satellites use it, so a loader that hands over a subset of three.js without it still draws everything else. */
19
+ Points?: typeof THREE.Points;
17
20
  };
18
21
  /** Milliseconds a lower texture size must be enough before the larger one is let go. */
19
22
  export declare const DOWNGRADE_AFTER_MS = 4000;
@@ -0,0 +1,3 @@
1
+ export { createSatellites, type SatellitesOptions } from "./track.ts";
2
+ export { DEFAULT_MAX_AGE_DAYS, MAX_SATELLITE_SPEED } from "./limits.ts";
3
+ export type { ElementSet, RefusedElementSet, SatelliteInfo, SatelliteRef, Satellites } from "./types.ts";
@@ -0,0 +1,3 @@
1
+ // The satellites' pure parts: orbital elements in, positions out (SGP4 by satellite.js, an optional peer). They import on a server. The space view takes a group with `setSatellites`.
2
+ export { createSatellites } from "./track.js";
3
+ export { DEFAULT_MAX_AGE_DAYS, MAX_SATELLITE_SPEED } from "./limits.js";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * How old, in days, an element set may be at the moment shown before the satellite is left out: SGP4's error grows by a few kilometres a day, so a fortnight is as far as a picture should trust.
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * import { DEFAULT_MAX_AGE_DAYS } from "@johnmorrisdotca/chikyu-earth/satellites";
7
+ *
8
+ * console.log(DEFAULT_MAX_AGE_DAYS);
9
+ * ```
10
+ */
11
+ export declare const DEFAULT_MAX_AGE_DAYS = 14;
12
+ /**
13
+ * The fastest clock, in game seconds a second, at which the space view still places a group of satellites: above it the two moments SGP4 is run at would be too far apart to interpolate, so the group is hidden rather than drawn wrongly. A satellite that was chosen with `selectSatellite` or ridden along is still placed exactly, one SGP4 call a frame.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import { MAX_SATELLITE_SPEED } from "@johnmorrisdotca/chikyu-earth/satellites";
18
+ *
19
+ * console.log(MAX_SATELLITE_SPEED);
20
+ * ```
21
+ */
22
+ export declare const MAX_SATELLITE_SPEED = 60;
@@ -0,0 +1,23 @@
1
+ // The satellites' two limits, apart from the SGP4 code so the space view can read them without needing satellite.js.
2
+ /**
3
+ * How old, in days, an element set may be at the moment shown before the satellite is left out: SGP4's error grows by a few kilometres a day, so a fortnight is as far as a picture should trust.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * import { DEFAULT_MAX_AGE_DAYS } from "@johnmorrisdotca/chikyu-earth/satellites";
8
+ *
9
+ * console.log(DEFAULT_MAX_AGE_DAYS);
10
+ * ```
11
+ */
12
+ export const DEFAULT_MAX_AGE_DAYS = 14;
13
+ /**
14
+ * The fastest clock, in game seconds a second, at which the space view still places a group of satellites: above it the two moments SGP4 is run at would be too far apart to interpolate, so the group is hidden rather than drawn wrongly. A satellite that was chosen with `selectSatellite` or ridden along is still placed exactly, one SGP4 call a frame.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import { MAX_SATELLITE_SPEED } from "@johnmorrisdotca/chikyu-earth/satellites";
19
+ *
20
+ * console.log(MAX_SATELLITE_SPEED);
21
+ * ```
22
+ */
23
+ export const MAX_SATELLITE_SPEED = 60;
@@ -0,0 +1,50 @@
1
+ import type { ElementSet, Satellites } from "./types.ts";
2
+ /**
3
+ * What `createSatellites` can be told.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * import type { SatellitesOptions } from "@johnmorrisdotca/chikyu-earth/satellites";
8
+ *
9
+ * const options: SatellitesOptions = { maxAgeDays: 7 };
10
+ * console.log(options.maxAgeDays);
11
+ * ```
12
+ */
13
+ export interface SatellitesOptions {
14
+ /** The oldest an element set may be at the moment shown, in days (14 by default). */
15
+ maxAgeDays?: number;
16
+ }
17
+ /**
18
+ * Make a group of satellites from their element sets. Each starts SGP4 once here; one that does not (bad numbers, decayed at its own epoch) is listed in `refused` and left out.
19
+ * It needs satellite.js (MIT, an optional peer dependency) and imports in a browser or on a server.
20
+ *
21
+ * @param sets - Element sets in CelesTrak's JSON format (`ElementSet`), fetched by your page.
22
+ * @param options - `maxAgeDays`: the oldest an element set may be at the moment shown.
23
+ * @returns The group: `at(ms, out)` for positions, `exact`, `orbit`, `info`, and counters.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * import { createSatellites } from "@johnmorrisdotca/chikyu-earth/satellites";
28
+ *
29
+ * const group = createSatellites([
30
+ * {
31
+ * OBJECT_NAME: "ISS (ZARYA)",
32
+ * OBJECT_ID: "1998-067A",
33
+ * EPOCH: "2026-11-02T10:25:21.580320",
34
+ * MEAN_MOTION: 15.49150046,
35
+ * ECCENTRICITY: 0.0007139,
36
+ * INCLINATION: 51.6332,
37
+ * RA_OF_ASC_NODE: 335.9757,
38
+ * ARG_OF_PERICENTER: 339.3739,
39
+ * MEAN_ANOMALY: 20.6961,
40
+ * NORAD_CAT_ID: 25544,
41
+ * BSTAR: 0.00012,
42
+ * MEAN_MOTION_DOT: 0.0001,
43
+ * MEAN_MOTION_DDOT: 0,
44
+ * },
45
+ * ]);
46
+ * const noon = Date.UTC(2026, 10, 2, 12);
47
+ * console.log(group.count, group.info(0, noon)?.altitudeKm.toFixed(0));
48
+ * ```
49
+ */
50
+ export declare function createSatellites(sets: readonly ElementSet[], options?: SatellitesOptions): Satellites;
@@ -0,0 +1,227 @@
1
+ // SGP4 for a group of satellites, kept cheap: the whole group is run at two moments a step apart and every position in between is an interpolation, so the cost is one batch a step
2
+ // (about once a second), never one a frame. satellite.js (MIT) does the SGP4 itself; this file never touches the screen and imports on a server.
3
+ import { json2satrec, sgp4 } from "satellite.js";
4
+ import { DEFAULT_MAX_AGE_DAYS } from "./limits.js";
5
+ /** The Earth's mean radius in kilometres: the unit of every position here (the same as the space view's). */
6
+ const EARTH_RADIUS_KM = 6371;
7
+ const DAY_MS = 86_400_000;
8
+ const MIN_STEP_MS = 1000;
9
+ const MAX_STEP_MS = 60_000;
10
+ const epochOf = (text) => Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(text) ? text : `${text}Z`);
11
+ /**
12
+ * Make a group of satellites from their element sets. Each starts SGP4 once here; one that does not (bad numbers, decayed at its own epoch) is listed in `refused` and left out.
13
+ * It needs satellite.js (MIT, an optional peer dependency) and imports in a browser or on a server.
14
+ *
15
+ * @param sets - Element sets in CelesTrak's JSON format (`ElementSet`), fetched by your page.
16
+ * @param options - `maxAgeDays`: the oldest an element set may be at the moment shown.
17
+ * @returns The group: `at(ms, out)` for positions, `exact`, `orbit`, `info`, and counters.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * import { createSatellites } from "@johnmorrisdotca/chikyu-earth/satellites";
22
+ *
23
+ * const group = createSatellites([
24
+ * {
25
+ * OBJECT_NAME: "ISS (ZARYA)",
26
+ * OBJECT_ID: "1998-067A",
27
+ * EPOCH: "2026-11-02T10:25:21.580320",
28
+ * MEAN_MOTION: 15.49150046,
29
+ * ECCENTRICITY: 0.0007139,
30
+ * INCLINATION: 51.6332,
31
+ * RA_OF_ASC_NODE: 335.9757,
32
+ * ARG_OF_PERICENTER: 339.3739,
33
+ * MEAN_ANOMALY: 20.6961,
34
+ * NORAD_CAT_ID: 25544,
35
+ * BSTAR: 0.00012,
36
+ * MEAN_MOTION_DOT: 0.0001,
37
+ * MEAN_MOTION_DDOT: 0,
38
+ * },
39
+ * ]);
40
+ * const noon = Date.UTC(2026, 10, 2, 12);
41
+ * console.log(group.count, group.info(0, noon)?.altitudeKm.toFixed(0));
42
+ * ```
43
+ */
44
+ export function createSatellites(sets, options = {}) {
45
+ const maxAgeDays = options.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;
46
+ const maxAgeMs = maxAgeDays * DAY_MS;
47
+ const recs = [];
48
+ const usable = [];
49
+ const epochs = [];
50
+ const refused = [];
51
+ for (const set of sets) {
52
+ const epoch = epochOf(set.EPOCH);
53
+ const refuse = (reason) => void refused.push({ name: set.OBJECT_NAME, noradId: set.NORAD_CAT_ID, reason });
54
+ if (!Number.isFinite(epoch)) {
55
+ refuse("the epoch is not a date");
56
+ continue;
57
+ }
58
+ try {
59
+ const rec = json2satrec(set);
60
+ const start = sgp4(rec, 0);
61
+ if (!start || !start.position || !start.velocity || rec.error !== 0) {
62
+ refuse("SGP4 could not start from these elements");
63
+ continue;
64
+ }
65
+ recs.push(rec);
66
+ usable.push(set);
67
+ epochs.push(epoch);
68
+ }
69
+ catch (error) {
70
+ refuse(error instanceof Error ? error.message : "SGP4 could not start from these elements");
71
+ }
72
+ }
73
+ const n = recs.length;
74
+ let a = new Float32Array(n * 3);
75
+ let b = new Float32Array(n * 3);
76
+ let okA = new Uint8Array(n);
77
+ let okB = new Uint8Array(n);
78
+ const shown = new Uint8Array(n);
79
+ let aMs = 0;
80
+ let bMs = 0;
81
+ let ready = false;
82
+ let stepMs = MIN_STEP_MS;
83
+ let propagations = 0;
84
+ let placed = 0;
85
+ const timing = { lastBatchMs: 0, maxBatchMs: 0 };
86
+ /** SGP4 for the whole group at one moment, into `buffer` in the space view's axes (TEME x, z, -y: x to the vernal equinox, y north). */
87
+ const sample = (buffer, ok, ms) => {
88
+ const started = performance.now();
89
+ for (let i = 0; i < n; i += 1) {
90
+ const epoch = epochs[i];
91
+ if (Math.abs(ms - epoch) > maxAgeMs) {
92
+ ok[i] = 0;
93
+ continue;
94
+ }
95
+ propagations += 1;
96
+ const result = sgp4(recs[i], (ms - epoch) / 60_000);
97
+ const p = result && result.position;
98
+ if (!p || !Number.isFinite(p.x)) {
99
+ ok[i] = 0;
100
+ continue;
101
+ }
102
+ ok[i] = 1;
103
+ buffer[i * 3] = p.x / EARTH_RADIUS_KM;
104
+ buffer[i * 3 + 1] = p.z / EARTH_RADIUS_KM;
105
+ buffer[i * 3 + 2] = -p.y / EARTH_RADIUS_KM;
106
+ }
107
+ timing.lastBatchMs = performance.now() - started;
108
+ if (timing.lastBatchMs > timing.maxBatchMs)
109
+ timing.maxBatchMs = timing.lastBatchMs;
110
+ };
111
+ const exactAt = (index, ms, out) => {
112
+ const epoch = epochs[index];
113
+ if (epoch === undefined || Math.abs(ms - epoch) > maxAgeMs)
114
+ return false;
115
+ propagations += 1;
116
+ const result = sgp4(recs[index], (ms - epoch) / 60_000);
117
+ if (!result || !result.position || !result.velocity || !Number.isFinite(result.position.x))
118
+ return false;
119
+ out[0] = result.position.x / EARTH_RADIUS_KM;
120
+ out[1] = result.position.z / EARTH_RADIUS_KM;
121
+ out[2] = -result.position.y / EARTH_RADIUS_KM;
122
+ out[3] = result.velocity.x;
123
+ out[4] = result.velocity.z;
124
+ out[5] = -result.velocity.y;
125
+ return true;
126
+ };
127
+ return {
128
+ count: n,
129
+ sets: usable,
130
+ refused,
131
+ maxAgeDays,
132
+ get propagations() {
133
+ return propagations;
134
+ },
135
+ timing,
136
+ get placed() {
137
+ return placed;
138
+ },
139
+ setStep(ms) {
140
+ const next = Math.min(MAX_STEP_MS, Math.max(MIN_STEP_MS, Math.round(ms)));
141
+ if (next !== stepMs) {
142
+ stepMs = next;
143
+ ready = false;
144
+ }
145
+ },
146
+ at(ms, out) {
147
+ if (n === 0)
148
+ return;
149
+ if (!ready || ms < aMs || ms > bMs) {
150
+ if (ready && ms > bMs && ms - bMs < stepMs) {
151
+ [a, b] = [b, a];
152
+ [okA, okB] = [okB, okA];
153
+ aMs = bMs;
154
+ bMs = aMs + stepMs;
155
+ sample(b, okB, bMs);
156
+ }
157
+ else if (ready && ms < aMs && aMs - ms < stepMs) {
158
+ [a, b] = [b, a];
159
+ [okA, okB] = [okB, okA];
160
+ bMs = aMs;
161
+ aMs = bMs - stepMs;
162
+ sample(a, okA, aMs);
163
+ }
164
+ else {
165
+ aMs = ms;
166
+ bMs = ms + stepMs;
167
+ sample(a, okA, aMs);
168
+ sample(b, okB, bMs);
169
+ ready = true;
170
+ }
171
+ }
172
+ const t = (ms - aMs) / (bMs - aMs);
173
+ let count = 0;
174
+ for (let i = 0; i < n; i += 1) {
175
+ const k = i * 3;
176
+ if (okA[i] === 1 && okB[i] === 1) {
177
+ out[k] = a[k] + (b[k] - a[k]) * t;
178
+ out[k + 1] = a[k + 1] + (b[k + 1] - a[k + 1]) * t;
179
+ out[k + 2] = a[k + 2] + (b[k + 2] - a[k + 2]) * t;
180
+ shown[i] = 1;
181
+ count += 1;
182
+ }
183
+ else {
184
+ out[k] = 0;
185
+ out[k + 1] = 0;
186
+ out[k + 2] = 0;
187
+ shown[i] = 0;
188
+ }
189
+ }
190
+ placed = count;
191
+ },
192
+ visible: (index) => shown[index] === 1,
193
+ exact: exactAt,
194
+ orbit(index, ms, points) {
195
+ const path = new Float32Array((points + 1) * 3);
196
+ const period = 1440 / (usable[index]?.MEAN_MOTION || 15);
197
+ const one = new Float64Array(6);
198
+ for (let k = 0; k < points; k += 1) {
199
+ if (exactAt(index, ms + (k / points) * period * 60_000, one))
200
+ path.set(one.subarray(0, 3), k * 3);
201
+ else if (k > 0)
202
+ path.copyWithin(k * 3, (k - 1) * 3, k * 3);
203
+ }
204
+ path.copyWithin(points * 3, 0, 3);
205
+ return path;
206
+ },
207
+ info(index, ms) {
208
+ const set = usable[index];
209
+ const one = new Float64Array(6);
210
+ if (!set || !exactAt(index, ms, one))
211
+ return undefined;
212
+ const info = {
213
+ index,
214
+ name: set.OBJECT_NAME,
215
+ noradId: set.NORAD_CAT_ID,
216
+ objectId: set.OBJECT_ID,
217
+ epochMs: epochs[index],
218
+ ageDays: (ms - epochs[index]) / DAY_MS,
219
+ altitudeKm: Math.hypot(one[0], one[1], one[2]) * EARTH_RADIUS_KM - EARTH_RADIUS_KM,
220
+ speedKmS: Math.hypot(one[3], one[4], one[5]),
221
+ periodMinutes: 1440 / set.MEAN_MOTION,
222
+ inclinationDeg: set.INCLINATION,
223
+ };
224
+ return info;
225
+ },
226
+ };
227
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * One satellite's orbital elements in the "GP" format that CelesTrak and Space-Track both serve as JSON (CCSDS OMM, one object per satellite). Only these fields are read.
3
+ * The package fetches nothing: your page gets the elements (see the satellites guide for the rules of the sources) and passes them in.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * import type { ElementSet } from "@johnmorrisdotca/chikyu-earth/satellites";
8
+ *
9
+ * const iss: ElementSet = {
10
+ * OBJECT_NAME: "ISS (ZARYA)",
11
+ * OBJECT_ID: "1998-067A",
12
+ * EPOCH: "2026-11-02T10:25:21.580320",
13
+ * MEAN_MOTION: 15.49150046,
14
+ * ECCENTRICITY: 0.0007139,
15
+ * INCLINATION: 51.6332,
16
+ * RA_OF_ASC_NODE: 335.9757,
17
+ * ARG_OF_PERICENTER: 339.3739,
18
+ * MEAN_ANOMALY: 20.6961,
19
+ * NORAD_CAT_ID: 25544,
20
+ * BSTAR: 0.00012,
21
+ * MEAN_MOTION_DOT: 0.0001,
22
+ * MEAN_MOTION_DDOT: 0,
23
+ * };
24
+ * console.log(iss.OBJECT_NAME);
25
+ * ```
26
+ */
27
+ export interface ElementSet {
28
+ OBJECT_NAME: string;
29
+ OBJECT_ID?: string;
30
+ /** The moment the elements describe, ISO 8601 in UTC (a trailing `Z` is optional). */
31
+ EPOCH: string;
32
+ /** Revolutions a day. */
33
+ MEAN_MOTION: number;
34
+ ECCENTRICITY: number;
35
+ /** Degrees. */
36
+ INCLINATION: number;
37
+ /** Degrees. */
38
+ RA_OF_ASC_NODE: number;
39
+ /** Degrees. */
40
+ ARG_OF_PERICENTER: number;
41
+ /** Degrees. */
42
+ MEAN_ANOMALY: number;
43
+ NORAD_CAT_ID: number;
44
+ BSTAR: number;
45
+ MEAN_MOTION_DOT: number;
46
+ MEAN_MOTION_DDOT: number;
47
+ EPHEMERIS_TYPE?: number;
48
+ CLASSIFICATION_TYPE?: string;
49
+ ELEMENT_SET_NO?: number;
50
+ REV_AT_EPOCH?: number;
51
+ }
52
+ /**
53
+ * What a page can tell a reader about one satellite at a moment: its name, its number, how old its elements are, how high and how fast it is.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * import type { SatelliteInfo } from "@johnmorrisdotca/chikyu-earth/satellites";
58
+ *
59
+ * const describe = (info: SatelliteInfo): string => `${info.name}: ${info.altitudeKm.toFixed(0)} km up, ${info.speedKmS.toFixed(2)} km/s`;
60
+ * console.log(typeof describe);
61
+ * ```
62
+ */
63
+ export interface SatelliteInfo {
64
+ /** Where it is in its group. */
65
+ index: number;
66
+ name: string;
67
+ /** The NORAD catalogue number. */
68
+ noradId: number;
69
+ /** The international designator (for example `1998-067A`), when the elements carry one. */
70
+ objectId: string | undefined;
71
+ /** The moment of its elements, in milliseconds since the Unix epoch. */
72
+ epochMs: number;
73
+ /** Days between the elements' moment and the moment asked about (negative before it). */
74
+ ageDays: number;
75
+ /** Height above the mean Earth radius (6371 km) at that moment. */
76
+ altitudeKm: number;
77
+ /** Speed against the stars, kilometres a second. */
78
+ speedKmS: number;
79
+ /** One revolution, minutes. */
80
+ periodMinutes: number;
81
+ /** Degrees. */
82
+ inclinationDeg: number;
83
+ }
84
+ /**
85
+ * A satellite chosen from a group: which group and which place in it.
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * import type { SatelliteRef } from "@johnmorrisdotca/chikyu-earth/satellites";
90
+ *
91
+ * const first: SatelliteRef = { group: "stations", index: 0 };
92
+ * console.log(first.group);
93
+ * ```
94
+ */
95
+ export interface SatelliteRef {
96
+ group: string;
97
+ index: number;
98
+ }
99
+ /**
100
+ * A satellite the group could not use, and why: its elements did not start SGP4 (bad numbers), or it had decayed at its own epoch.
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * import type { RefusedElementSet } from "@johnmorrisdotca/chikyu-earth/satellites";
105
+ *
106
+ * const show = (one: RefusedElementSet): string => `${one.name}: ${one.reason}`;
107
+ * console.log(typeof show);
108
+ * ```
109
+ */
110
+ export interface RefusedElementSet {
111
+ name: string;
112
+ noradId: number;
113
+ reason: string;
114
+ }
115
+ /**
116
+ * A group of satellites, ready to give positions for any moment: what `createSatellites` returns, and what `<chikyu-space>` takes in `setSatellites`.
117
+ * Positions are in Earth radii (6371 km) from the Earth's centre, in the space view's axes (x to the vernal equinox, y north); a satellite whose elements
118
+ * are older than `maxAgeDays` at the moment asked about is not placed (its place stays at the centre and `visible(index)` is false).
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * import type { Satellites } from "@johnmorrisdotca/chikyu-earth/satellites";
123
+ *
124
+ * const first = (group: Satellites): string => group.sets[0]?.OBJECT_NAME ?? "none";
125
+ * console.log(typeof first);
126
+ * ```
127
+ */
128
+ export interface Satellites {
129
+ /** Satellites that started SGP4. */
130
+ readonly count: number;
131
+ /** Their elements, in the order of their places. */
132
+ readonly sets: readonly ElementSet[];
133
+ /** The ones left out, with the reason. */
134
+ readonly refused: readonly RefusedElementSet[];
135
+ /** The oldest an element set may be at the moment shown, in days. */
136
+ readonly maxAgeDays: number;
137
+ /** SGP4 calls made so far, for a page or a test to count. */
138
+ readonly propagations: number;
139
+ /** Milliseconds the last batch of SGP4 took, and the longest so far. */
140
+ readonly timing: {
141
+ lastBatchMs: number;
142
+ maxBatchMs: number;
143
+ };
144
+ /**
145
+ * Fill `out` (3 numbers a satellite) with the positions at `ms`: SGP4 is run for the whole group at two moments `step` apart and the positions between are interpolated, so a
146
+ * steady clock costs one batch a step and every call in between is arithmetic.
147
+ */
148
+ at(ms: number, out: Float32Array): void;
149
+ /** True if satellite `index` was placed by the last `at` (its elements are not too old at that moment). */
150
+ visible(index: number): boolean;
151
+ /** How many of them the last `at` placed. */
152
+ readonly placed: number;
153
+ /** The game time between the two moments SGP4 is run at (1000 to 60000 ms); the space view sets it from the clock's speed. */
154
+ setStep(ms: number): void;
155
+ /** One satellite at exactly `ms` (no interpolation): position in Earth radii and velocity in km/s into `out` (6 numbers). False if its elements are too old or SGP4 gave up. */
156
+ exact(index: number, ms: number, out: Float64Array): boolean;
157
+ /** The path of satellite `index` for one revolution from `ms`, `points` + 1 points in order along it (the last on the first), into a new array of Earth radii. */
158
+ orbit(index: number, ms: number, points: number): Float32Array;
159
+ /** Facts about one satellite at `ms`; undefined if it cannot be placed then. */
160
+ info(index: number, ms: number): SatelliteInfo | undefined;
161
+ }
@@ -0,0 +1,3 @@
1
+ // What the satellites are made of and what the space view asks of them. Types only: nothing here imports satellite.js, so the space element can use them
2
+ // without needing it.
3
+ export {};