@johnmorrisdotca/chikyu-earth 0.4.0 → 0.7.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/NOTICE.md +39 -1
  3. package/README.md +199 -24
  4. package/dist/countries.d.ts +11 -0
  5. package/dist/countries.js +11 -0
  6. package/dist/element.d.ts +24 -1
  7. package/dist/element.js +70 -5
  8. package/dist/globe.d.ts +15 -1
  9. package/dist/globe.js +15 -1
  10. package/dist/index.d.ts +12 -1
  11. package/dist/index.js +12 -1
  12. package/dist/markers/cluster.d.ts +38 -0
  13. package/dist/markers/cluster.js +42 -0
  14. package/dist/markers/index.d.ts +5 -0
  15. package/dist/markers/index.js +6 -0
  16. package/dist/markers/labels.d.ts +64 -0
  17. package/dist/markers/labels.js +70 -0
  18. package/dist/markers/layer.d.ts +129 -0
  19. package/dist/markers/layer.js +455 -0
  20. package/dist/markers/project.d.ts +82 -0
  21. package/dist/markers/project.js +93 -0
  22. package/dist/markers/types.d.ts +83 -0
  23. package/dist/markers/types.js +1 -0
  24. package/dist/realistic/earth.d.ts +81 -0
  25. package/dist/realistic/earth.js +163 -0
  26. package/dist/realistic/element.d.ts +93 -0
  27. package/dist/realistic/element.js +284 -0
  28. package/dist/realistic/gate.d.ts +64 -0
  29. package/dist/realistic/gate.js +72 -0
  30. package/dist/realistic/index.d.ts +5 -0
  31. package/dist/realistic/index.js +6 -0
  32. package/dist/realistic/shaders.d.ts +10 -0
  33. package/dist/realistic/shaders.js +124 -0
  34. package/dist/realistic/shared.d.ts +23 -0
  35. package/dist/realistic/shared.js +51 -0
  36. package/dist/realistic/sun.d.ts +44 -0
  37. package/dist/realistic/sun.js +65 -0
  38. package/dist/realistic/textures.d.ts +40 -0
  39. package/dist/realistic/textures.js +141 -0
  40. package/dist/realistic/tiers.d.ts +94 -0
  41. package/dist/realistic/tiers.js +94 -0
  42. package/dist/rotate.d.ts +59 -4
  43. package/dist/rotate.js +59 -4
  44. package/dist/space/element.d.ts +134 -0
  45. package/dist/space/element.js +394 -0
  46. package/dist/space/ephemeris.d.ts +151 -0
  47. package/dist/space/ephemeris.js +162 -0
  48. package/dist/space/index.d.ts +3 -0
  49. package/dist/space/index.js +5 -0
  50. package/dist/space/scale.d.ts +124 -0
  51. package/dist/space/scale.js +141 -0
  52. package/dist/space/scene.d.ts +131 -0
  53. package/dist/space/scene.js +295 -0
  54. package/dist/styles.d.ts +65 -2
  55. package/dist/styles.js +43 -2
  56. package/dist/textures/clouds-1024.webp +0 -0
  57. package/dist/textures/clouds-2048.webp +0 -0
  58. package/dist/textures/day-1024.webp +0 -0
  59. package/dist/textures/day-2048.webp +0 -0
  60. package/dist/textures/day-4096.webp +0 -0
  61. package/dist/textures/moon-1024.webp +0 -0
  62. package/dist/textures/moon-2048.webp +0 -0
  63. package/dist/textures/night-1024.webp +0 -0
  64. package/dist/textures/night-2048.webp +0 -0
  65. package/dist/textures/water-1024.webp +0 -0
  66. package/dist/textures/water-2048.webp +0 -0
  67. package/dist/types.d.ts +55 -5
  68. package/dist/view.d.ts +64 -3
  69. package/dist/view.js +56 -3
  70. package/package.json +93 -8
@@ -0,0 +1,134 @@
1
+ import type { GeoPoint } from "../types.ts";
2
+ import { setThreeLoader } from "../realistic/shared.ts";
3
+ import { type ScaleMode } from "./scale.ts";
4
+ import { BODY_NAMES, type BodyName, type BodyPainter } from "./scene.ts";
5
+ export { BODY_NAMES, type BodyName };
6
+ /**
7
+ * Change how the Sun and the planets are drawn (before the first element connects): the space view keeps their places, sizes, light, names and orbits and asks
8
+ * the painter only for what to draw at each place (see `BodyPainter`). Left alone, they are plain spheres in the right colours.
9
+ *
10
+ * @param next - The painter to use, or `undefined` for the plain one.
11
+ *
12
+ * @example
13
+ * ```ts no-run
14
+ * import { plainPainter } from "@johnmorrisdotca/chikyu-earth/space";
15
+ * import { setBodyPainter } from "@johnmorrisdotca/chikyu-earth/space/element";
16
+ *
17
+ * setBodyPainter({ make: (three, name) => plainPainter.make(three, name) });
18
+ * ```
19
+ */
20
+ export declare function setBodyPainter(next: BodyPainter | undefined): void;
21
+ /**
22
+ * What a page can tell a reader about a body now.
23
+ *
24
+ * @example
25
+ * ```ts no-run
26
+ * import type { BodyInfo } from "@johnmorrisdotca/chikyu-earth/space/element";
27
+ *
28
+ * const info: BodyInfo = { name: "Mars", radiusKm: 3389.5, distanceKm: 225_000_000 };
29
+ * console.log(`${info.name}: ${info.radiusKm} km`);
30
+ * ```
31
+ */
32
+ export interface BodyInfo {
33
+ name: BodyName;
34
+ /** Mean radius, kilometres. */
35
+ radiusKm: number;
36
+ /** Distance from the Earth's centre now, kilometres (0 for the Earth). */
37
+ distanceKm: number;
38
+ }
39
+ export { setThreeLoader };
40
+ /**
41
+ * What the space view is doing: `loading`, `live`, `unavailable` (no WebGL), `lost` (the GPU took the context; it comes back by itself) or `idle` (another view has the page's one context).
42
+ *
43
+ * @example
44
+ * ```ts no-run
45
+ * import type { ChikyuSpace, SpaceState } from "@johnmorrisdotca/chikyu-earth/space/element";
46
+ *
47
+ * const stateOf = (space: ChikyuSpace): SpaceState => space.stats.state;
48
+ * console.log(typeof stateOf);
49
+ * ```
50
+ */
51
+ export type SpaceState = "loading" | "live" | "unavailable" | "lost" | "idle";
52
+ /**
53
+ * A named distance for `zoomFor`: the Earth, the Moon, the Sun, the inner planets or the whole solar system.
54
+ *
55
+ * @example
56
+ * ```ts no-run
57
+ * import type { ChikyuSpace, SpacePreset } from "@johnmorrisdotca/chikyu-earth/space/element";
58
+ *
59
+ * const zoomTo = (space: ChikyuSpace, preset: SpacePreset): number => space.zoomFor(preset);
60
+ * console.log(typeof zoomTo);
61
+ * ```
62
+ */
63
+ export type SpacePreset = "earth" | "moon" | "sun" | "inner" | "solar-system";
64
+ /**
65
+ * The `<chikyu-space>` custom element: the Earth, the Moon, the Sun and the planets where they are at a date and time, drawn with three.js through the shared renderer. It takes `center`, `time` (`now` or an ISO date), `speed` (1 is real time, 0 holds, 86400 is a day a second, negative runs backwards), `scale` (`compressed` or `true`), `labels` and `orbits` (`off` hides them); positions are computed in the browser by astronomy-engine, an optional peer dependency. It draws only when something changes, and nothing while it is out of sight.
66
+ *
67
+ * @example
68
+ * ```ts no-run
69
+ * import type { ChikyuSpace } from "@johnmorrisdotca/chikyu-earth/space/element";
70
+ *
71
+ * const space = document.createElement("chikyu-space") as ChikyuSpace;
72
+ * space.setAttribute("time", "2026-11-02T12:00Z");
73
+ * space.setAttribute("speed", "0");
74
+ * document.body.append(space);
75
+ * ```
76
+ */
77
+ export declare class ChikyuSpace extends HTMLElement {
78
+ #private;
79
+ static observedAttributes: string[];
80
+ connectedCallback(): void;
81
+ disconnectedCallback(): void;
82
+ attributeChangedCallback(name: string, _old: string | null, value: string | null): void;
83
+ get centre(): GeoPoint;
84
+ set centre(value: GeoPoint);
85
+ /** The moment shown: milliseconds since the epoch, or "now" to follow the clock. Setting it points the camera at the same place on the ground again. */
86
+ get time(): number;
87
+ set time(value: number | Date | "now");
88
+ /** Seconds of game time per second: 1 is real time, 0 holds still, negative runs backwards. */
89
+ get speed(): number;
90
+ set speed(value: number);
91
+ get scale(): ScaleMode;
92
+ set scale(value: ScaleMode);
93
+ get labels(): boolean;
94
+ set labels(value: boolean);
95
+ get orbits(): boolean;
96
+ set orbits(value: boolean);
97
+ /** The shared controls' camera: yaw and tilt turn the view, zoom brings the camera in (1 shows the Earth, less shows the sky round it). Pan is not used. */
98
+ setView(view: {
99
+ yaw: number;
100
+ tilt: number;
101
+ zoom: number;
102
+ }): void;
103
+ /** The body the camera looks at: at zoom 1 it fills the view; the yaw and tilt turn the camera round it. */
104
+ get body(): BodyName;
105
+ set body(name: BodyName);
106
+ /** The radius and the distance from the Earth of a body now, for a page's own caption. */
107
+ bodyInfo(name: BodyName): BodyInfo;
108
+ /** The zoom that shows a part of the sky: the Earth alone, the Moon's orbit, the Sun, the planets out to Mars, or all of them, at this scale and moment. */
109
+ zoomFor(preset: SpacePreset): number;
110
+ /** What the element is doing, for a page or a test. */
111
+ get stats(): {
112
+ state: SpaceState;
113
+ draws: number;
114
+ contexts: number;
115
+ canvas: {
116
+ width: number;
117
+ height: number;
118
+ pixels: number;
119
+ };
120
+ tiers: Record<string, number>;
121
+ distance: number;
122
+ assets: {
123
+ entries: number;
124
+ refs: number;
125
+ };
126
+ time: number;
127
+ lookups: number;
128
+ };
129
+ }
130
+ declare global {
131
+ interface HTMLElementTagNameMap {
132
+ "chikyu-space": ChikyuSpace;
133
+ }
134
+ }
@@ -0,0 +1,394 @@
1
+ import { createGate, frameCap } from "../realistic/gate.js";
2
+ import { setThreeLoader, takeRenderer } from "../realistic/shared.js";
3
+ import { MAX_TEXTURE } from "../realistic/tiers.js";
4
+ import { AU_IN_EARTH_RADII, EARTH_RADIUS_KM, frameAt } from "./ephemeris.js";
5
+ import { placeAround, placeMoon, placeSun } from "./scale.js";
6
+ import { BODY_NAMES, createSpaceItem, FIT_DISTANCE, newSpaceModel } from "./scene.js";
7
+ export { BODY_NAMES };
8
+ let painter;
9
+ /**
10
+ * Change how the Sun and the planets are drawn (before the first element connects): the space view keeps their places, sizes, light, names and orbits and asks
11
+ * the painter only for what to draw at each place (see `BodyPainter`). Left alone, they are plain spheres in the right colours.
12
+ *
13
+ * @param next - The painter to use, or `undefined` for the plain one.
14
+ *
15
+ * @example
16
+ * ```ts no-run
17
+ * import { plainPainter } from "@johnmorrisdotca/chikyu-earth/space";
18
+ * import { setBodyPainter } from "@johnmorrisdotca/chikyu-earth/space/element";
19
+ *
20
+ * setBodyPainter({ make: (three, name) => plainPainter.make(three, name) });
21
+ * ```
22
+ */
23
+ export function setBodyPainter(next) {
24
+ painter = next;
25
+ }
26
+ /** Mean radii in kilometres (IAU), for what a click tells. */
27
+ const RADIUS_KM = { Sun: 695_700, Mercury: 2439.7, Venus: 6051.8, Earth: 6371, Moon: 1737.4, Mars: 3389.5, Jupiter: 69_911, Saturn: 58_232, Uranus: 25_362, Neptune: 24_622 };
28
+ export { setThreeLoader };
29
+ const RAD = Math.PI / 180;
30
+ /** The Earth's own turn, degrees a second. */
31
+ const EARTH_RATE = 360 / 86_164.0905;
32
+ const REAL_TIME_TICK_MS = 60_000;
33
+ const readCentre = (value) => {
34
+ if (!value)
35
+ return null;
36
+ const [lon, lat] = value.split(",").map((part) => Number(part.trim()));
37
+ if (!Number.isFinite(lon) || !Number.isFinite(lat) || Math.abs(lon) > 180 || Math.abs(lat) > 90)
38
+ return null;
39
+ return { lon: lon, lat: lat };
40
+ };
41
+ const readTime = (value) => {
42
+ if (value === null || value === "" || value === "now")
43
+ return "now";
44
+ const ms = Date.parse(value);
45
+ return Number.isFinite(ms) ? ms : "now";
46
+ };
47
+ /**
48
+ * The `<chikyu-space>` custom element: the Earth, the Moon, the Sun and the planets where they are at a date and time, drawn with three.js through the shared renderer. It takes `center`, `time` (`now` or an ISO date), `speed` (1 is real time, 0 holds, 86400 is a day a second, negative runs backwards), `scale` (`compressed` or `true`), `labels` and `orbits` (`off` hides them); positions are computed in the browser by astronomy-engine, an optional peer dependency. It draws only when something changes, and nothing while it is out of sight.
49
+ *
50
+ * @example
51
+ * ```ts no-run
52
+ * import type { ChikyuSpace } from "@johnmorrisdotca/chikyu-earth/space/element";
53
+ *
54
+ * const space = document.createElement("chikyu-space") as ChikyuSpace;
55
+ * space.setAttribute("time", "2026-11-02T12:00Z");
56
+ * space.setAttribute("speed", "0");
57
+ * document.body.append(space);
58
+ * ```
59
+ */
60
+ export class ChikyuSpace extends HTMLElement {
61
+ static observedAttributes = ["center", "time", "speed", "scale", "labels", "orbits"];
62
+ #model = newSpaceModel();
63
+ #mode = "now";
64
+ #speed = 1;
65
+ #view;
66
+ #gate;
67
+ #taken;
68
+ #stage;
69
+ #labelBox;
70
+ #state = "loading";
71
+ #timer;
72
+ #last = 0;
73
+ #pool = new Map();
74
+ #places;
75
+ #tiers = {};
76
+ connectedCallback() {
77
+ this.#model.centre = readCentre(this.getAttribute("center")) ?? this.#model.centre;
78
+ this.#mode = readTime(this.getAttribute("time"));
79
+ this.#model.time = this.#mode === "now" ? Date.now() : this.#mode;
80
+ this.#speed = this.#readSpeed();
81
+ this.#model.mode = this.getAttribute("scale") === "true" ? "true" : "compressed";
82
+ this.#model.labels = this.getAttribute("labels") !== "off";
83
+ this.#model.orbits = this.getAttribute("orbits") !== "off";
84
+ this.#model.stepMs = Math.max(1000, Math.abs(this.#speed) * 1000);
85
+ if (!this.hasAttribute("role"))
86
+ this.setAttribute("role", "img");
87
+ if (!this.hasAttribute("aria-label"))
88
+ this.setAttribute("aria-label", "The Earth, the Moon, the Sun and the planets in their places for the date shown");
89
+ const root = this.shadowRoot ?? this.attachShadow({ mode: "open" });
90
+ root.innerHTML = `<style>:host{display:block;aspect-ratio:1/1;position:relative;overflow:hidden}#stage,#labels{position:absolute;inset:0}#labels{pointer-events:none;overflow:hidden}.sp-label{position:absolute;left:0;top:0;margin:0;padding:0;border:0;background:none;font:12px/16px system-ui,-apple-system,sans-serif;color:#fff;white-space:nowrap;text-shadow:0 0 3px #000,0 0 3px #000;will-change:transform;pointer-events:none}.sp-label:focus-visible{outline:2px solid #6cd0ff;outline-offset:2px;border-radius:4px}.sp-label::before{content:"";display:inline-block;width:5px;height:5px;margin:0 5px 1px -2px;border-radius:50%;background:#fff}.sp-label[hidden]{display:none}</style><div id="stage"></div><div id="labels" aria-hidden="true"></div>`;
91
+ this.#stage = root.getElementById("stage");
92
+ this.#labelBox = root.getElementById("labels");
93
+ this.#pool.clear();
94
+ const nav = navigator;
95
+ this.#gate = createGate(1000 / frameCap(30, { deviceMemory: nav.deviceMemory, hardwareConcurrency: nav.hardwareConcurrency }), () => this.#view?.invalidate());
96
+ this.#stage.addEventListener("webglcontextlost", this.#lost, true);
97
+ this.#stage.addEventListener("webglcontextrestored", this.#restored, true);
98
+ const taken = takeRenderer((state) => {
99
+ if (state === "unavailable")
100
+ this.#fail("WebGL is not available");
101
+ });
102
+ this.#taken = taken;
103
+ const item = createSpaceItem(this.#model, {
104
+ assets: taken.renderer.assets,
105
+ textureBase: () => this.#textureBase(),
106
+ cap: () => this.#textureCap(),
107
+ invalidate: () => this.#gate?.request(),
108
+ onError: (error) => this.#fail(error instanceof Error ? error.message : String(error)),
109
+ onTier: (layer, width) => {
110
+ this.#tiers[layer] = width;
111
+ },
112
+ onLabels: (places) => this.#placeLabels(places),
113
+ ...(painter ? { painter } : {}),
114
+ });
115
+ this.#view = taken.renderer.view(this.#stage, item, { still: null });
116
+ this.#set("loading");
117
+ void this.#view.focus().then((live) => {
118
+ if (live) {
119
+ this.#set("live");
120
+ this.dispatchEvent(new CustomEvent("chikyu-ready"));
121
+ }
122
+ else if (this.#state === "loading")
123
+ this.#set("unavailable");
124
+ });
125
+ this.#last = performance.now();
126
+ this.#schedule();
127
+ this.addEventListener("pointerdown", this.#down);
128
+ this.#labelBox.addEventListener("click", this.#labelClick);
129
+ this.#labelBox.addEventListener("focusin", this.#labelFocus);
130
+ }
131
+ disconnectedCallback() {
132
+ this.removeEventListener("pointerdown", this.#down);
133
+ window.removeEventListener("pointerup", this.#up, true);
134
+ clearTimeout(this.#timer);
135
+ this.#timer = undefined;
136
+ this.#gate?.cancel();
137
+ this.#view?.dispose();
138
+ this.#view = undefined;
139
+ this.#taken?.release();
140
+ this.#taken = undefined;
141
+ }
142
+ attributeChangedCallback(name, _old, value) {
143
+ if (name === "center")
144
+ this.centre = readCentre(value) ?? { lon: 0, lat: 0 };
145
+ else if (name === "time")
146
+ this.time = readTime(value);
147
+ else if (name === "speed")
148
+ this.speed = this.#readSpeed();
149
+ else if (name === "scale")
150
+ this.scale = value === "true" ? "true" : "compressed";
151
+ else if (name === "labels")
152
+ this.labels = value !== "off";
153
+ else if (name === "orbits")
154
+ this.orbits = value !== "off";
155
+ }
156
+ get centre() {
157
+ return { ...this.#model.centre };
158
+ }
159
+ set centre(value) {
160
+ this.#model.centre = { ...value };
161
+ this.#gate?.request();
162
+ }
163
+ /** The moment shown: milliseconds since the epoch, or "now" to follow the clock. Setting it points the camera at the same place on the ground again. */
164
+ get time() {
165
+ return this.#model.time;
166
+ }
167
+ set time(value) {
168
+ this.#mode = value instanceof Date ? value.getTime() : value;
169
+ this.#model.time = this.#mode === "now" ? Date.now() : this.#mode;
170
+ this.#model.cameraSpin = null;
171
+ this.#last = performance.now();
172
+ this.#schedule();
173
+ this.#gate?.request();
174
+ }
175
+ /** Seconds of game time per second: 1 is real time, 0 holds still, negative runs backwards. */
176
+ get speed() {
177
+ return this.#speed;
178
+ }
179
+ set speed(value) {
180
+ this.#speed = Number.isFinite(value) ? value : 1;
181
+ this.#model.stepMs = Math.max(1000, Math.abs(this.#speed) * 1000);
182
+ this.#last = performance.now();
183
+ this.#schedule();
184
+ this.#gate?.request();
185
+ }
186
+ get scale() {
187
+ return this.#model.mode;
188
+ }
189
+ set scale(value) {
190
+ this.#model.mode = value;
191
+ this.#gate?.request();
192
+ }
193
+ get labels() {
194
+ return this.#model.labels;
195
+ }
196
+ set labels(value) {
197
+ this.#model.labels = value;
198
+ if (!value)
199
+ for (const label of this.#pool.values())
200
+ label.hidden = true;
201
+ this.#gate?.request();
202
+ }
203
+ get orbits() {
204
+ return this.#model.orbits;
205
+ }
206
+ set orbits(value) {
207
+ this.#model.orbits = value;
208
+ this.#gate?.request();
209
+ }
210
+ /** The shared controls' camera: yaw and tilt turn the view, zoom brings the camera in (1 shows the Earth, less shows the sky round it). Pan is not used. */
211
+ setView(view) {
212
+ const now = this.#model.view;
213
+ if (view.yaw === now.yaw && view.tilt === now.tilt && view.zoom === now.zoom)
214
+ return;
215
+ this.#model.view = { yaw: view.yaw, tilt: view.tilt, zoom: view.zoom };
216
+ this.#gate?.request();
217
+ }
218
+ /** The body the camera looks at: at zoom 1 it fills the view; the yaw and tilt turn the camera round it. */
219
+ get body() {
220
+ return this.#model.focus;
221
+ }
222
+ set body(name) {
223
+ if (!BODY_NAMES.includes(name))
224
+ return;
225
+ this.#model.focus = name;
226
+ this.#gate?.request();
227
+ }
228
+ /** The radius and the distance from the Earth of a body now, for a page's own caption. */
229
+ bodyInfo(name) {
230
+ const frame = this.#model.drawn?.frame ?? frameAt(this.#model.time);
231
+ const geo = name === "Earth" ? [0, 0, 0] : name === "Sun" ? frame.sun : name === "Moon" ? frame.moon : frame.planets[name];
232
+ return { name, radiusKm: RADIUS_KM[name], distanceKm: Math.hypot(geo[0], geo[1], geo[2]) * (AU_IN_EARTH_RADII * EARTH_RADIUS_KM) };
233
+ }
234
+ /** The zoom that shows a part of the sky: the Earth alone, the Moon's orbit, the Sun, the planets out to Mars, or all of them, at this scale and moment. */
235
+ zoomFor(preset) {
236
+ const frame = this.#model.drawn?.frame ?? frameAt(this.#model.time);
237
+ const mode = this.#model.mode;
238
+ const sun = placeSun(mode, frame.sun);
239
+ const around = (name) => Math.hypot(...placeAround(mode, sun, [frame.planets[name][0] - frame.sun[0], frame.planets[name][1] - frame.sun[1], frame.planets[name][2] - frame.sun[2]]));
240
+ const reach = {
241
+ earth: 0,
242
+ moon: Math.hypot(...placeMoon(mode, frame.moon)),
243
+ sun: Math.hypot(...sun),
244
+ inner: Math.max(around("Mercury"), around("Venus"), around("Mars"), Math.hypot(...sun)),
245
+ "solar-system": Math.max(...Object.keys(frame.planets).map(around), Math.hypot(...sun)),
246
+ }[preset];
247
+ return preset === "earth" ? 1 : FIT_DISTANCE / Math.max(FIT_DISTANCE, reach * 3.4);
248
+ }
249
+ /** What the element is doing, for a page or a test. */
250
+ get stats() {
251
+ const stats = this.#taken?.renderer.stats;
252
+ return {
253
+ state: this.#state,
254
+ draws: stats?.draws ?? 0,
255
+ contexts: stats?.contexts ?? 0,
256
+ canvas: stats?.canvas ?? { width: 0, height: 0, pixels: 0 },
257
+ tiers: { ...this.#tiers },
258
+ distance: this.#model.drawn?.distance ?? 0,
259
+ assets: { entries: stats?.assets.entries ?? 0, refs: stats?.assets.refs ?? 0 },
260
+ time: this.#model.time,
261
+ lookups: this.#model.lookups,
262
+ };
263
+ }
264
+ // --- the game clock ----------------------------------------------------------------------------------------------------------------------------
265
+ #readSpeed() {
266
+ const raw = this.getAttribute("speed");
267
+ const value = raw === null || raw === "" ? 1 : Number(raw);
268
+ return Number.isFinite(value) ? value : 1;
269
+ }
270
+ /** While time runs, draw as often as it shows: real time once a minute; faster time as often as the Earth's turn moves a pixel (1 to 30 times a second). */
271
+ #schedule() {
272
+ clearTimeout(this.#timer);
273
+ this.#timer = undefined;
274
+ if (!this.isConnected || this.#speed === 0)
275
+ return;
276
+ const realTime = this.#speed === 1 && this.#mode === "now";
277
+ const radius = this.#model.drawn?.radiusPx ?? 200;
278
+ const pixelsPerSecond = EARTH_RATE * RAD * Math.abs(this.#speed) * radius;
279
+ const perSecond = Math.min(30, Math.max(1, pixelsPerSecond));
280
+ const wait = realTime || this.#speed === 1 ? REAL_TIME_TICK_MS : 1000 / perSecond;
281
+ this.#timer = setTimeout(this.#tick, wait);
282
+ }
283
+ #tick = () => {
284
+ const now = performance.now();
285
+ if (this.#speed === 1 && this.#mode === "now")
286
+ this.#model.time = Date.now();
287
+ else
288
+ this.#model.time += (now - this.#last) * this.#speed;
289
+ this.#last = now;
290
+ this.#gate?.request();
291
+ this.#schedule();
292
+ this.dispatchEvent(new CustomEvent("chikyu-time", { detail: { time: this.#model.time } }));
293
+ };
294
+ // --- labels --------------------------------------------------------------------------------------------------------------------------------------
295
+ #placeLabels(places) {
296
+ this.#places = places;
297
+ const box = this.#labelBox;
298
+ if (!box)
299
+ return;
300
+ for (const name of BODY_NAMES) {
301
+ let label = this.#pool.get(name);
302
+ const at = places[name];
303
+ if (!label) {
304
+ if (!at)
305
+ continue;
306
+ const button = box.ownerDocument.createElement("button");
307
+ button.type = "button";
308
+ button.className = "sp-label";
309
+ button.dataset["body"] = name;
310
+ button.textContent = name;
311
+ button.setAttribute("aria-label", `${name}: choose to look at it`);
312
+ box.append(button);
313
+ this.#pool.set(name, button);
314
+ label = button;
315
+ }
316
+ if (!at) {
317
+ if (!label.hidden)
318
+ label.hidden = true;
319
+ continue;
320
+ }
321
+ if (label.hidden)
322
+ label.hidden = false;
323
+ label.style.transform = `translate(${Math.round(at.x + Math.max(4, Math.min(at.r, 40)))}px,${Math.round(at.y - 8)}px)`;
324
+ }
325
+ }
326
+ // A body is chosen by pressing on it or on its name (and by Tab and Enter on the name); a drag that turns the view is not a choice.
327
+ #downX = 0;
328
+ #downY = 0;
329
+ #down = (event) => {
330
+ if (event.button !== 0)
331
+ return;
332
+ this.#downX = event.clientX;
333
+ this.#downY = event.clientY;
334
+ window.addEventListener("pointerup", this.#up, { capture: true, once: true });
335
+ };
336
+ #up = (event) => {
337
+ if (Math.hypot(event.clientX - this.#downX, event.clientY - this.#downY) > 6 || !this.#places)
338
+ return;
339
+ const rect = this.getBoundingClientRect();
340
+ const x = event.clientX - rect.left;
341
+ const y = event.clientY - rect.top;
342
+ let best;
343
+ let bestD = Infinity;
344
+ for (const name of BODY_NAMES) {
345
+ const at = this.#places[name];
346
+ if (!at)
347
+ continue;
348
+ const d = Math.hypot(x - at.x, y - at.y);
349
+ if (d <= Math.max(at.r, 14) && d < bestD) {
350
+ best = name;
351
+ bestD = d;
352
+ }
353
+ }
354
+ if (best)
355
+ this.dispatchEvent(new CustomEvent("chikyu-body-click", { detail: this.bodyInfo(best), bubbles: true, composed: true }));
356
+ };
357
+ #labelClick = (event) => {
358
+ const name = event.target?.dataset?.["body"];
359
+ if (name)
360
+ this.dispatchEvent(new CustomEvent("chikyu-body-click", { detail: this.bodyInfo(name), bubbles: true, composed: true }));
361
+ };
362
+ #labelFocus = (event) => {
363
+ const name = event.target?.dataset?.["body"];
364
+ if (name)
365
+ this.dispatchEvent(new CustomEvent("chikyu-body-focus", { detail: this.bodyInfo(name), bubbles: true, composed: true }));
366
+ };
367
+ // --- the rest --------------------------------------------------------------------------------------------------------------------------------------
368
+ #textureBase() {
369
+ const given = this.getAttribute("texture-base");
370
+ const base = given ?? new URL("../textures/", import.meta.url).href;
371
+ return base.endsWith("/") ? base : `${base}/`;
372
+ }
373
+ #textureCap() {
374
+ const given = Number(this.getAttribute("max-texture"));
375
+ const memory = navigator.deviceMemory;
376
+ const cap = Number.isFinite(given) && given > 0 ? Math.min(given, MAX_TEXTURE) : MAX_TEXTURE;
377
+ return memory !== undefined && memory <= 2 ? Math.min(cap, 2048) : cap;
378
+ }
379
+ #lost = () => this.#set("lost");
380
+ #restored = () => this.#set("live");
381
+ #set(state) {
382
+ if (this.#state === state)
383
+ return;
384
+ this.#state = state;
385
+ this.dataset["state"] = state;
386
+ }
387
+ #fail(reason) {
388
+ this.#set("unavailable");
389
+ this.dispatchEvent(new CustomEvent("chikyu-unavailable", { detail: { reason } }));
390
+ }
391
+ }
392
+ if (typeof customElements !== "undefined" && !customElements.get("chikyu-space")) {
393
+ customElements.define("chikyu-space", ChikyuSpace);
394
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * A position or direction as `[x, y, z]`, in the scene's axes (x toward the vernal equinox, y north).
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * import type { Vec3 } from "@johnmorrisdotca/chikyu-earth/space";
7
+ *
8
+ * const origin: Vec3 = [0, 0, 0];
9
+ * console.log(origin);
10
+ * ```
11
+ */
12
+ export type Vec3 = [number, number, number];
13
+ /**
14
+ * The planets drawn, nearest the Sun first.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import { PLANETS } from "@johnmorrisdotca/chikyu-earth/space";
19
+ *
20
+ * console.log(PLANETS.join(", "));
21
+ * ```
22
+ */
23
+ export declare const PLANETS: readonly ["Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune"];
24
+ /**
25
+ * One of the seven planets drawn, by name.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * import { orbitPath, type PlanetName } from "@johnmorrisdotca/chikyu-earth/space";
30
+ *
31
+ * const planet: PlanetName = "Mars";
32
+ * console.log(orbitPath(planet, Date.UTC(2026, 10, 2), 8).length);
33
+ * ```
34
+ */
35
+ export type PlanetName = (typeof PLANETS)[number];
36
+ /**
37
+ * Where everything is at one moment: positions in astronomical units from the Earth's centre, in scene axes (x to the vernal equinox, y north, z toward -y of the equator).
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * import { frameAt, type Frame } from "@johnmorrisdotca/chikyu-earth/space";
42
+ *
43
+ * const frame: Frame = frameAt(Date.UTC(2026, 10, 2, 12));
44
+ * console.log(Object.keys(frame.planets).length, frame.spin.toFixed(0));
45
+ * ```
46
+ */
47
+ export interface Frame {
48
+ ms: number;
49
+ sun: Vec3;
50
+ moon: Vec3;
51
+ planets: Record<PlanetName, Vec3>;
52
+ /** Greenwich sidereal time, in degrees: how far the Earth has turned against the stars. */
53
+ spin: number;
54
+ }
55
+ /**
56
+ * The Earth's radius in kilometres, the unit of the scene's distances.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * import { EARTH_RADIUS_KM } from "@johnmorrisdotca/chikyu-earth/space";
61
+ *
62
+ * console.log(EARTH_RADIUS_KM);
63
+ * ```
64
+ */
65
+ export declare const EARTH_RADIUS_KM = 6371;
66
+ /**
67
+ * Earth radii in one astronomical unit.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * import { AU_IN_EARTH_RADII } from "@johnmorrisdotca/chikyu-earth/space";
72
+ *
73
+ * console.log(Math.round(AU_IN_EARTH_RADII));
74
+ * ```
75
+ */
76
+ export declare const AU_IN_EARTH_RADII: number;
77
+ /**
78
+ * The state of the sky at `ms`: nine vectors and an angle, in a few tenths of a millisecond. It needs astronomy-engine (an optional peer dependency).
79
+ *
80
+ * @param ms - A moment, in milliseconds since the Unix epoch.
81
+ * @returns The Sun, the Moon and the planets as seen from the Earth's centre, and how far the Earth has turned.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * import { frameAt } from "@johnmorrisdotca/chikyu-earth/space";
86
+ *
87
+ * const frame = frameAt(Date.UTC(2026, 10, 2, 12));
88
+ * console.log(frame.moon.map((n) => n.toFixed(4)));
89
+ * ```
90
+ */
91
+ export declare function frameAt(ms: number): Frame;
92
+ /**
93
+ * A frame between two others, `t` from 0 to 1. The spin goes the short way round, so a step under half a turn is right.
94
+ *
95
+ * @param a - The earlier frame.
96
+ * @param b - The later frame.
97
+ * @param t - How far from `a` to `b`, from 0 to 1.
98
+ * @returns The frame at that fraction.
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * import { blend, frameAt } from "@johnmorrisdotca/chikyu-earth/space";
103
+ *
104
+ * const start = Date.UTC(2026, 10, 2);
105
+ * const middle = blend(frameAt(start), frameAt(start + 1000), 0.5);
106
+ * console.log(middle.ms - start);
107
+ * ```
108
+ */
109
+ export declare function blend(a: Frame, b: Frame, t: number): Frame;
110
+ /**
111
+ * Answers for any moment from two real ones: `at(ms)` asks astronomy-engine only when `ms` leaves the span the last two answers cover, and then looks
112
+ * `stepMs` ahead, so playing time at a steady rate costs one pair of lookups every `stepMs` and every draw in between is arithmetic.
113
+ *
114
+ * @param initialStepMs - How far ahead the second answer looks, in milliseconds.
115
+ * @returns A track: `at(ms)` for a frame, `lookups` for how many times the ephemeris was asked, `reset()` and `setStep(ms)`.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * import { createTrack } from "@johnmorrisdotca/chikyu-earth/space";
120
+ *
121
+ * const track = createTrack(1000);
122
+ * const start = Date.UTC(2026, 10, 2);
123
+ * for (let step = 0; step < 10; step += 1) track.at(start + step * 50);
124
+ * console.log(track.lookups);
125
+ * ```
126
+ */
127
+ export declare function createTrack(initialStepMs?: number): {
128
+ at(ms: number): Frame;
129
+ /** How many times astronomy-engine has been asked for a frame. */
130
+ readonly lookups: number;
131
+ reset(): void;
132
+ /** Look further ahead (a faster game clock): the next lookup uses it. */
133
+ setStep(ms: number): void;
134
+ };
135
+ /** The path a planet takes round the Sun, as `count` points around the orbit that starts where the planet is at `ms`, relative to the Sun (scene axes, AU).
136
+ *
137
+ * @param name - The planet.
138
+ * @param ms - The moment the orbit starts at, in milliseconds since the Unix epoch.
139
+ * @param count - How many segments to cut the orbit into.
140
+ * @returns `count + 1` points, the last on the first.
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * import { orbitPath } from "@johnmorrisdotca/chikyu-earth/space";
145
+ *
146
+ * const points = orbitPath("Jupiter", Date.UTC(2026, 10, 2), 12);
147
+ * console.log(points.length);
148
+ * ```
149
+ */
150
+ export declare function orbitPath(name: PlanetName, ms: number, count?: number): Vec3[];
151
+ /** The Earth's own place relative to the Sun, for placing the orbits round the Sun's place in the Earth-centred scene. */