@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.
@@ -1,5 +1,7 @@
1
1
  import type { GeoPoint } from "../types.ts";
2
2
  import { setThreeLoader } from "../realistic/shared.ts";
3
+ import type { SatelliteInfo, SatelliteRef, Satellites } from "../satellites/types.ts";
4
+ import { type SatelliteStyle } from "./satellite-layer.ts";
3
5
  import { type ScaleMode } from "./scale.ts";
4
6
  import { BODY_NAMES, type BodyName, type BodyPainter } from "./scene.ts";
5
7
  export { BODY_NAMES, type BodyName };
@@ -61,6 +63,18 @@ export type SpaceState = "loading" | "live" | "unavailable" | "lost" | "idle";
61
63
  * ```
62
64
  */
63
65
  export type SpacePreset = "earth" | "moon" | "sun" | "inner" | "solar-system";
66
+ type SatelliteStats = {
67
+ groups: number;
68
+ placed: number;
69
+ tooFast: boolean;
70
+ propagations: number;
71
+ lastBatchMs: number;
72
+ maxBatchMs: number;
73
+ updateMs: number;
74
+ maxUpdateMs: number;
75
+ averageUpdateMs: number;
76
+ updates: number;
77
+ };
64
78
  /**
65
79
  * 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
80
  *
@@ -103,6 +117,36 @@ export declare class ChikyuSpace extends HTMLElement {
103
117
  /** The body the camera looks at: at zoom 1 it fills the view; the yaw and tilt turn the camera round it. */
104
118
  get body(): BodyName;
105
119
  set body(name: BodyName);
120
+ /**
121
+ * Show a group of satellites (made by `createSatellites`) under a name, or take the group away with `null`. A group is one draw call however many satellites it holds. Giving the name of a group
122
+ * that is already shown replaces it. `style`: `color` and `size` (CSS pixels) of the dots, and `orbits` to draw every orbit (only for a group of 40 or fewer, such as the stations).
123
+ * The groups are placed by SGP4 about once a second and interpolated between, and are hidden while the clock runs faster than `MAX_SATELLITE_SPEED`.
124
+ *
125
+ * @param group - The group's name, for `showSatellites`, `selectSatellite` and the events.
126
+ * @param satellites - The group, or `null` to remove it.
127
+ * @param style - How to draw it.
128
+ */
129
+ setSatellites(group: string, satellites: Satellites | null, style?: Partial<SatelliteStyle>): void;
130
+ /** Show or hide a group without losing it. */
131
+ showSatellites(group: string, on: boolean): void;
132
+ /** The groups now set, with how many satellites each holds and whether it is shown. */
133
+ get satelliteGroups(): {
134
+ name: string;
135
+ count: number;
136
+ visible: boolean;
137
+ }[];
138
+ /** Mark one satellite: a larger ring round it, its orbit drawn, and its name beside it. `null` lets it go. */
139
+ selectSatellite(ref: SatelliteRef | null): void;
140
+ /** The satellite marked by `selectSatellite` or by a press on it, if any. */
141
+ get selectedSatellite(): SatelliteRef | null;
142
+ /** Facts about one satellite at the moment shown (name, number, element age, height, speed, period); undefined if it cannot be placed then. */
143
+ satelliteInfo(ref: SatelliteRef): SatelliteInfo | undefined;
144
+ /** The words shown beside each body, for a page in another language (`{ Mars: "火星" }`). A body left out keeps its English name. */
145
+ get bodyNames(): Partial<Record<BodyName, string>>;
146
+ set bodyNames(value: Partial<Record<BodyName, string>>);
147
+ /** What a screen reader hears after a body's name: "choose to look at it" by default, or the page's own words. */
148
+ get bodyHint(): string;
149
+ set bodyHint(value: string);
106
150
  /** The radius and the distance from the Earth of a body now, for a page's own caption. */
107
151
  bodyInfo(name: BodyName): BodyInfo;
108
152
  /** 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. */
@@ -125,6 +169,7 @@ export declare class ChikyuSpace extends HTMLElement {
125
169
  };
126
170
  time: number;
127
171
  lookups: number;
172
+ satellites: SatelliteStats;
128
173
  };
129
174
  }
130
175
  declare global {
@@ -1,7 +1,9 @@
1
1
  import { createGate, frameCap } from "../realistic/gate.js";
2
2
  import { setThreeLoader, takeRenderer } from "../realistic/shared.js";
3
3
  import { MAX_TEXTURE } from "../realistic/tiers.js";
4
+ import { MAX_SATELLITE_SPEED } from "../satellites/limits.js";
4
5
  import { AU_IN_EARTH_RADII, EARTH_RADIUS_KM, frameAt } from "./ephemeris.js";
6
+ import { MAX_ORBIT_GROUP } from "./satellite-layer.js";
5
7
  import { placeAround, placeMoon, placeSun } from "./scale.js";
6
8
  import { BODY_NAMES, createSpaceItem, FIT_DISTANCE, newSpaceModel } from "./scene.js";
7
9
  export { BODY_NAMES };
@@ -29,6 +31,9 @@ export { setThreeLoader };
29
31
  const RAD = Math.PI / 180;
30
32
  /** The Earth's own turn, degrees a second. */
31
33
  const EARTH_RATE = 360 / 86_164.0905;
34
+ /** The quickest a satellite goes round, degrees a second (a low orbit: 88 minutes). */
35
+ const SATELLITE_RATE = 360 / (88 * 60);
36
+ const SATELLITE_COLORS = ["#ffd54f", "#7fd4ff", "#ff8fa3", "#9ef0a8"];
32
37
  const REAL_TIME_TICK_MS = 60_000;
33
38
  const readCentre = (value) => {
34
39
  if (!value)
@@ -72,6 +77,9 @@ export class ChikyuSpace extends HTMLElement {
72
77
  #last = 0;
73
78
  #pool = new Map();
74
79
  #places;
80
+ #chosenLabel;
81
+ #names = {};
82
+ #hint = "choose to look at it";
75
83
  #tiers = {};
76
84
  connectedCallback() {
77
85
  this.#model.centre = readCentre(this.getAttribute("center")) ?? this.#model.centre;
@@ -82,12 +90,13 @@ export class ChikyuSpace extends HTMLElement {
82
90
  this.#model.labels = this.getAttribute("labels") !== "off";
83
91
  this.#model.orbits = this.getAttribute("orbits") !== "off";
84
92
  this.#model.stepMs = Math.max(1000, Math.abs(this.#speed) * 1000);
93
+ this.#model.speed = this.#speed;
85
94
  if (!this.hasAttribute("role"))
86
95
  this.setAttribute("role", "img");
87
96
  if (!this.hasAttribute("aria-label"))
88
97
  this.setAttribute("aria-label", "The Earth, the Moon, the Sun and the planets in their places for the date shown");
89
98
  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>`;
99
+ 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}.sp-sat{position:absolute;left:0;top:0;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-sat[hidden]{display:none}</style><div id="stage"></div><div id="labels" aria-hidden="true"></div>`;
91
100
  this.#stage = root.getElementById("stage");
92
101
  this.#labelBox = root.getElementById("labels");
93
102
  this.#pool.clear();
@@ -110,6 +119,8 @@ export class ChikyuSpace extends HTMLElement {
110
119
  this.#tiers[layer] = width;
111
120
  },
112
121
  onLabels: (places) => this.#placeLabels(places),
122
+ onSelected: (place) => this.#placeChosen(place),
123
+ onSatellitesError: (error) => this.dispatchEvent(new CustomEvent("chikyu-satellites-unavailable", { detail: { reason: error instanceof Error ? error.message : String(error) } })),
113
124
  ...(painter ? { painter } : {}),
114
125
  });
115
126
  this.#view = taken.renderer.view(this.#stage, item, { still: null });
@@ -178,6 +189,7 @@ export class ChikyuSpace extends HTMLElement {
178
189
  }
179
190
  set speed(value) {
180
191
  this.#speed = Number.isFinite(value) ? value : 1;
192
+ this.#model.speed = this.#speed;
181
193
  this.#model.stepMs = Math.max(1000, Math.abs(this.#speed) * 1000);
182
194
  this.#last = performance.now();
183
195
  this.#schedule();
@@ -225,6 +237,85 @@ export class ChikyuSpace extends HTMLElement {
225
237
  this.#model.focus = name;
226
238
  this.#gate?.request();
227
239
  }
240
+ // --- satellites ---------------------------------------------------------------------------------------------------------------------------------
241
+ /**
242
+ * Show a group of satellites (made by `createSatellites`) under a name, or take the group away with `null`. A group is one draw call however many satellites it holds. Giving the name of a group
243
+ * that is already shown replaces it. `style`: `color` and `size` (CSS pixels) of the dots, and `orbits` to draw every orbit (only for a group of 40 or fewer, such as the stations).
244
+ * The groups are placed by SGP4 about once a second and interpolated between, and are hidden while the clock runs faster than `MAX_SATELLITE_SPEED`.
245
+ *
246
+ * @param group - The group's name, for `showSatellites`, `selectSatellite` and the events.
247
+ * @param satellites - The group, or `null` to remove it.
248
+ * @param style - How to draw it.
249
+ */
250
+ setSatellites(group, satellites, style = {}) {
251
+ const groups = this.#model.satellites.groups;
252
+ if (satellites === null) {
253
+ groups.delete(group);
254
+ if (this.#model.satellites.selected?.group === group)
255
+ this.#model.satellites.selected = null;
256
+ }
257
+ else {
258
+ const was = groups.get(group);
259
+ groups.set(group, {
260
+ satellites,
261
+ color: style.color ?? was?.color ?? SATELLITE_COLORS[groups.size % SATELLITE_COLORS.length],
262
+ size: style.size ?? was?.size ?? 3,
263
+ orbits: (style.orbits ?? was?.orbits ?? false) && satellites.count <= MAX_ORBIT_GROUP,
264
+ visible: was?.visible ?? true,
265
+ });
266
+ }
267
+ this.#last = performance.now();
268
+ this.#schedule();
269
+ this.#gate?.request();
270
+ }
271
+ /** Show or hide a group without losing it. */
272
+ showSatellites(group, on) {
273
+ const found = this.#model.satellites.groups.get(group);
274
+ if (!found || found.visible === on)
275
+ return;
276
+ found.visible = on;
277
+ this.#schedule();
278
+ this.#gate?.request();
279
+ }
280
+ /** The groups now set, with how many satellites each holds and whether it is shown. */
281
+ get satelliteGroups() {
282
+ return [...this.#model.satellites.groups].map(([name, group]) => ({ name, count: group.satellites.count, visible: group.visible }));
283
+ }
284
+ /** Mark one satellite: a larger ring round it, its orbit drawn, and its name beside it. `null` lets it go. */
285
+ selectSatellite(ref) {
286
+ const found = ref ? this.#model.satellites.groups.get(ref.group) : undefined;
287
+ this.#model.satellites.selected = ref && found && ref.index >= 0 && ref.index < found.satellites.count ? { group: ref.group, index: ref.index } : null;
288
+ if (this.#chosenLabel)
289
+ this.#chosenLabel.hidden = true;
290
+ this.#gate?.request();
291
+ }
292
+ /** The satellite marked by `selectSatellite` or by a press on it, if any. */
293
+ get selectedSatellite() {
294
+ const chosen = this.#model.satellites.selected;
295
+ return chosen ? { ...chosen } : null;
296
+ }
297
+ /** Facts about one satellite at the moment shown (name, number, element age, height, speed, period); undefined if it cannot be placed then. */
298
+ satelliteInfo(ref) {
299
+ return this.#model.satellites.groups.get(ref.group)?.satellites.info(ref.index, this.#model.time);
300
+ }
301
+ /** The words shown beside each body, for a page in another language (`{ Mars: "火星" }`). A body left out keeps its English name. */
302
+ get bodyNames() {
303
+ return { ...this.#names };
304
+ }
305
+ set bodyNames(value) {
306
+ this.#names = { ...value };
307
+ for (const [name, button] of this.#pool)
308
+ this.#nameLabel(button, name);
309
+ }
310
+ /** What a screen reader hears after a body's name: "choose to look at it" by default, or the page's own words. */
311
+ get bodyHint() {
312
+ return this.#hint;
313
+ }
314
+ set bodyHint(value) {
315
+ this.#hint = value;
316
+ for (const [name, button] of this.#pool)
317
+ this.#nameLabel(button, name);
318
+ }
228
319
  /** The radius and the distance from the Earth of a body now, for a page's own caption. */
229
320
  bodyInfo(name) {
230
321
  const frame = this.#model.drawn?.frame ?? frameAt(this.#model.time);
@@ -259,25 +350,49 @@ export class ChikyuSpace extends HTMLElement {
259
350
  assets: { entries: stats?.assets.entries ?? 0, refs: stats?.assets.refs ?? 0 },
260
351
  time: this.#model.time,
261
352
  lookups: this.#model.lookups,
353
+ satellites: this.#satelliteStats(),
262
354
  };
263
355
  }
356
+ #satelliteStats() {
357
+ const drawn = this.#model.satellites.drawn;
358
+ let propagations = 0;
359
+ let lastBatchMs = 0;
360
+ let maxBatchMs = 0;
361
+ for (const group of this.#model.satellites.groups.values()) {
362
+ propagations += group.satellites.propagations;
363
+ lastBatchMs = Math.max(lastBatchMs, group.satellites.timing.lastBatchMs);
364
+ maxBatchMs = Math.max(maxBatchMs, group.satellites.timing.maxBatchMs);
365
+ }
366
+ return { groups: this.#model.satellites.groups.size, placed: drawn.placed, tooFast: drawn.tooFast, propagations, lastBatchMs, maxBatchMs, updateMs: drawn.updateMs, maxUpdateMs: drawn.maxUpdateMs, averageUpdateMs: drawn.updates ? drawn.totalMs / drawn.updates : 0, updates: drawn.updates };
367
+ }
264
368
  // --- the game clock ----------------------------------------------------------------------------------------------------------------------------
265
369
  #readSpeed() {
266
370
  const raw = this.getAttribute("speed");
267
371
  const value = raw === null || raw === "" ? 1 : Number(raw);
268
372
  return Number.isFinite(value) ? value : 1;
269
373
  }
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). */
374
+ /** Is anything on show that moves faster than the Earth turns: a group of satellites the clock is slow enough to place? */
375
+ #satellitesMoving() {
376
+ if (Math.abs(this.#speed) > MAX_SATELLITE_SPEED)
377
+ return false;
378
+ for (const group of this.#model.satellites.groups.values())
379
+ if (group.visible && group.satellites.count > 0)
380
+ return true;
381
+ return false;
382
+ }
383
+ /** 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). Satellites go round in 90 minutes, so with a group on show real time draws once a second, and 1 a second is all a reader who asked for reduced motion gets. */
271
384
  #schedule() {
272
385
  clearTimeout(this.#timer);
273
386
  this.#timer = undefined;
274
387
  if (!this.isConnected || this.#speed === 0)
275
388
  return;
276
- const realTime = this.#speed === 1 && this.#mode === "now";
389
+ const satellites = this.#satellitesMoving();
277
390
  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;
391
+ const pixelsPerSecond = (satellites ? SATELLITE_RATE : EARTH_RATE) * RAD * Math.abs(this.#speed) * radius;
392
+ let perSecond = Math.min(30, Math.max(1, pixelsPerSecond));
393
+ if (satellites && matchMedia("(prefers-reduced-motion: reduce)").matches)
394
+ perSecond = 1;
395
+ const wait = !satellites && this.#speed === 1 ? REAL_TIME_TICK_MS : 1000 / perSecond;
281
396
  this.#timer = setTimeout(this.#tick, wait);
282
397
  }
283
398
  #tick = () => {
@@ -307,8 +422,7 @@ export class ChikyuSpace extends HTMLElement {
307
422
  button.type = "button";
308
423
  button.className = "sp-label";
309
424
  button.dataset["body"] = name;
310
- button.textContent = name;
311
- button.setAttribute("aria-label", `${name}: choose to look at it`);
425
+ this.#nameLabel(button, name);
312
426
  box.append(button);
313
427
  this.#pool.set(name, button);
314
428
  label = button;
@@ -323,6 +437,32 @@ export class ChikyuSpace extends HTMLElement {
323
437
  label.style.transform = `translate(${Math.round(at.x + Math.max(4, Math.min(at.r, 40)))}px,${Math.round(at.y - 8)}px)`;
324
438
  }
325
439
  }
440
+ #placeChosen(place) {
441
+ const box = this.#labelBox;
442
+ if (!box)
443
+ return;
444
+ if (!place) {
445
+ if (this.#chosenLabel)
446
+ this.#chosenLabel.hidden = true;
447
+ return;
448
+ }
449
+ const chosen = this.#model.satellites.selected;
450
+ const name = chosen ? this.satelliteInfo(chosen)?.name : undefined;
451
+ if (!this.#chosenLabel) {
452
+ this.#chosenLabel = box.ownerDocument.createElement("div");
453
+ this.#chosenLabel.className = "sp-sat";
454
+ box.append(this.#chosenLabel);
455
+ }
456
+ if (this.#chosenLabel.textContent !== (name ?? ""))
457
+ this.#chosenLabel.textContent = name ?? "";
458
+ this.#chosenLabel.hidden = !name;
459
+ this.#chosenLabel.style.transform = `translate(${Math.round(place.x + 10)}px,${Math.round(place.y - 8)}px)`;
460
+ }
461
+ #nameLabel(button, name) {
462
+ const text = this.#names[name] ?? name;
463
+ button.textContent = text;
464
+ button.setAttribute("aria-label", `${text}: ${this.#hint}`);
465
+ }
326
466
  // 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
467
  #downX = 0;
328
468
  #downY = 0;
@@ -334,11 +474,18 @@ export class ChikyuSpace extends HTMLElement {
334
474
  window.addEventListener("pointerup", this.#up, { capture: true, once: true });
335
475
  };
336
476
  #up = (event) => {
337
- if (Math.hypot(event.clientX - this.#downX, event.clientY - this.#downY) > 6 || !this.#places)
477
+ if (Math.hypot(event.clientX - this.#downX, event.clientY - this.#downY) > 6)
338
478
  return;
339
479
  const rect = this.getBoundingClientRect();
340
480
  const x = event.clientX - rect.left;
341
481
  const y = event.clientY - rect.top;
482
+ const satellite = this.#model.pick?.(x, y, 12);
483
+ if (satellite) {
484
+ this.dispatchEvent(new CustomEvent("chikyu-satellite-click", { detail: { ...satellite, info: this.satelliteInfo(satellite) }, bubbles: true, composed: true }));
485
+ return;
486
+ }
487
+ if (!this.#places)
488
+ return;
342
489
  let best;
343
490
  let bestD = Infinity;
344
491
  for (const name of BODY_NAMES) {
@@ -0,0 +1,53 @@
1
+ import type * as THREE from "three";
2
+ import type { SatelliteRef, Satellites } from "../satellites/types.ts";
3
+ import type { ThreeParts } from "../realistic/textures.ts";
4
+ type Three = ThreeParts;
5
+ /** How a group looks: the colour of its dots and their size in CSS pixels, and whether every orbit is drawn (only for a small group). */
6
+ export interface SatelliteStyle {
7
+ color: string;
8
+ size: number;
9
+ orbits: boolean;
10
+ }
11
+ export interface SatelliteGroupState extends SatelliteStyle {
12
+ satellites: Satellites;
13
+ visible: boolean;
14
+ }
15
+ /** The part of the space view's model the satellites read and write. */
16
+ export interface SatelliteState {
17
+ groups: Map<string, SatelliteGroupState>;
18
+ selected: SatelliteRef | null;
19
+ /** Written at each draw: satellites placed, whether the clock is too fast for the groups, and the milliseconds the last update took. */
20
+ drawn: {
21
+ placed: number;
22
+ tooFast: boolean;
23
+ updateMs: number;
24
+ maxUpdateMs: number;
25
+ updates: number;
26
+ totalMs: number;
27
+ };
28
+ }
29
+ export declare const newSatelliteState: () => SatelliteState;
30
+ /** The most orbits a group may draw all at once, one line each. */
31
+ export declare const MAX_ORBIT_GROUP = 40;
32
+ export interface SatelliteLayer {
33
+ /** Place every shown group for `model.time` and the chosen satellite exactly. */
34
+ update(time: number, speed: number): void;
35
+ /** The satellite nearest to a point of the view (CSS pixels) within `radius`, if one is in front of the Earth. */
36
+ pick(x: number, y: number, camera: THREE.PerspectiveCamera, size: {
37
+ width: number;
38
+ height: number;
39
+ }, radius: number): SatelliteRef | null;
40
+ /** Where the chosen satellite is in the view, if it is. */
41
+ place(camera: THREE.PerspectiveCamera, size: {
42
+ width: number;
43
+ height: number;
44
+ }): {
45
+ x: number;
46
+ y: number;
47
+ } | null;
48
+ /** The chosen satellite's position and velocity now (Earth radii, km/s), or false. */
49
+ exact(ref: SatelliteRef, time: number, out: Float64Array): boolean;
50
+ dispose(): void;
51
+ }
52
+ export declare function createSatelliteLayer(three: Three, scene: THREE.Scene, state: SatelliteState, ratio: () => number): SatelliteLayer;
53
+ export {};
@@ -0,0 +1,230 @@
1
+ import { MAX_SATELLITE_SPEED } from "../satellites/limits.js";
2
+ export const newSatelliteState = () => ({ groups: new Map(), selected: null, drawn: { placed: 0, tooFast: false, updateMs: 0, maxUpdateMs: 0, updates: 0, totalMs: 0 } });
3
+ /** The most orbits a group may draw all at once, one line each. */
4
+ export const MAX_ORBIT_GROUP = 40;
5
+ const ORBIT_POINTS = 120;
6
+ const ORBIT_REFRESH_GAME_MS = 30 * 60_000;
7
+ const ORBIT_REFRESH_WALL_MS = 1000;
8
+ const POINT_VERT = /* glsl */ `
9
+ uniform float size;
10
+ void main() {
11
+ gl_PointSize = size;
12
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
13
+ }`;
14
+ const POINT_FRAG = /* glsl */ `
15
+ uniform vec3 tint;
16
+ uniform float ring;
17
+ void main() {
18
+ float d = length(gl_PointCoord - vec2(0.5));
19
+ if (d > 0.5) discard;
20
+ float a = ring > 0.5 ? smoothstep(0.30, 0.38, d) * (1.0 - smoothstep(0.44, 0.5, d)) : smoothstep(0.5, 0.2, d);
21
+ gl_FragColor = vec4(tint, a);
22
+ }`;
23
+ export function createSatelliteLayer(three, scene, state, ratio) {
24
+ if (!three.Points)
25
+ throw new Error("Satellites need `Points` from three.js: add it to the parts the three loader hands over (see setThreeLoader)");
26
+ const Points = three.Points;
27
+ const drawn = new Map();
28
+ const tmp = new Float64Array(6);
29
+ const projected = new three.Vector3();
30
+ const selectedMaterial = new three.ShaderMaterial({ vertexShader: POINT_VERT, fragmentShader: POINT_FRAG, uniforms: { size: { value: 14 }, tint: { value: new three.Color("#ffffff") }, ring: { value: 1 } }, transparent: true, depthWrite: false });
31
+ const selectedPosition = new Float32Array(3);
32
+ const selectedGeometry = new three.BufferGeometry();
33
+ selectedGeometry.setAttribute("position", new three.BufferAttribute(selectedPosition, 3));
34
+ const selectedPoints = new Points(selectedGeometry, selectedMaterial);
35
+ selectedPoints.frustumCulled = false;
36
+ selectedPoints.renderOrder = 6;
37
+ selectedPoints.visible = false;
38
+ scene.add(selectedPoints);
39
+ const selectedLine = { positions: new Float32Array(ORBIT_POINTS * 3), geometry: new three.BufferGeometry(), material: new three.LineBasicMaterial({ color: "#ffffff", transparent: true, opacity: 0.85 }) };
40
+ selectedLine.geometry.setAttribute("position", new three.BufferAttribute(selectedLine.positions, 3));
41
+ const selectedLoop = new three.LineLoop(selectedLine.geometry, selectedLine.material);
42
+ selectedLoop.frustumCulled = false;
43
+ selectedLoop.visible = false;
44
+ scene.add(selectedLoop);
45
+ let selectedKey = "";
46
+ let selectedAt = 0;
47
+ let selectedWall = 0;
48
+ const drop = (entry) => {
49
+ scene.remove(entry.points);
50
+ entry.geometry.dispose();
51
+ entry.material.dispose();
52
+ for (const line of entry.lines) {
53
+ scene.remove(line.loop);
54
+ line.geometry.dispose();
55
+ line.loop.material.dispose();
56
+ }
57
+ };
58
+ const orbitsOf = (entry, group, time, wall) => {
59
+ const wanted = group.orbits && group.satellites.count <= MAX_ORBIT_GROUP;
60
+ if (!wanted) {
61
+ for (const line of entry.lines)
62
+ line.loop.visible = false;
63
+ return;
64
+ }
65
+ if (entry.lines.length === group.satellites.count && Math.abs(time - entry.linesAt) < ORBIT_REFRESH_GAME_MS && wall - entry.linesWall < 60_000) {
66
+ for (const line of entry.lines)
67
+ line.loop.visible = true;
68
+ return;
69
+ }
70
+ if (entry.lines.length === group.satellites.count && wall - entry.linesWall < ORBIT_REFRESH_WALL_MS)
71
+ return;
72
+ while (entry.lines.length < group.satellites.count) {
73
+ const positions = new Float32Array(ORBIT_POINTS * 3);
74
+ const geometry = new three.BufferGeometry();
75
+ geometry.setAttribute("position", new three.BufferAttribute(positions, 3));
76
+ const material = new three.LineBasicMaterial({ color: group.color, transparent: true, opacity: 0.45 });
77
+ const loop = new three.LineLoop(geometry, material);
78
+ loop.frustumCulled = false;
79
+ scene.add(loop);
80
+ entry.lines.push({ loop, geometry, positions });
81
+ }
82
+ for (let i = 0; i < group.satellites.count; i += 1) {
83
+ const line = entry.lines[i];
84
+ const path = group.satellites.orbit(i, time, ORBIT_POINTS);
85
+ line.positions.set(path.subarray(0, ORBIT_POINTS * 3));
86
+ line.geometry.getAttribute("position").needsUpdate = true;
87
+ line.loop.visible = group.satellites.visible(i) || path.some((v) => v !== 0);
88
+ }
89
+ entry.linesAt = time;
90
+ entry.linesWall = wall;
91
+ };
92
+ const find = (ref) => state.groups.get(ref.group)?.satellites;
93
+ return {
94
+ update(time, speed) {
95
+ const started = performance.now();
96
+ const tooFast = Math.abs(speed) > MAX_SATELLITE_SPEED;
97
+ const step = Math.min(60_000, Math.max(1000, Math.abs(speed) * 1000));
98
+ for (const [name, entry] of drawn) {
99
+ if (state.groups.get(name)?.satellites !== entry.source) {
100
+ drop(entry);
101
+ drawn.delete(name);
102
+ }
103
+ }
104
+ let placed = 0;
105
+ for (const [name, group] of state.groups) {
106
+ let entry = drawn.get(name);
107
+ if (!entry) {
108
+ const positions = new Float32Array(group.satellites.count * 3);
109
+ const geometry = new three.BufferGeometry();
110
+ const attribute = new three.BufferAttribute(positions, 3);
111
+ geometry.setAttribute("position", attribute);
112
+ const material = new three.ShaderMaterial({ vertexShader: POINT_VERT, fragmentShader: POINT_FRAG, uniforms: { size: { value: group.size * ratio() }, tint: { value: new three.Color(group.color) }, ring: { value: 0 } }, transparent: true, depthWrite: false });
113
+ const points = new Points(geometry, material);
114
+ points.frustumCulled = false;
115
+ points.renderOrder = 5;
116
+ scene.add(points);
117
+ entry = { source: group.satellites, points, geometry, material, positions, attribute, lines: [], linesAt: Number.NaN, linesWall: 0, style: "" };
118
+ drawn.set(name, entry);
119
+ }
120
+ const style = `${group.color}|${group.size}|${ratio()}`;
121
+ if (style !== entry.style) {
122
+ entry.style = style;
123
+ entry.material.uniforms["size"].value = group.size * ratio();
124
+ entry.material.uniforms["tint"].value.set(group.color);
125
+ }
126
+ const show = group.visible && !tooFast;
127
+ entry.points.visible = show;
128
+ if (!show) {
129
+ for (const line of entry.lines)
130
+ line.loop.visible = false;
131
+ continue;
132
+ }
133
+ group.satellites.setStep(step);
134
+ group.satellites.at(time, entry.positions);
135
+ entry.attribute.needsUpdate = true;
136
+ placed += group.satellites.placed;
137
+ orbitsOf(entry, group, time, started);
138
+ }
139
+ const chosen = state.selected;
140
+ const set = chosen ? find(chosen) : undefined;
141
+ if (chosen && set && set.exact(chosen.index, time, tmp)) {
142
+ selectedPosition[0] = tmp[0];
143
+ selectedPosition[1] = tmp[1];
144
+ selectedPosition[2] = tmp[2];
145
+ selectedGeometry.getAttribute("position").needsUpdate = true;
146
+ selectedPoints.visible = true;
147
+ const key = `${chosen.group}:${chosen.index}`;
148
+ if (key !== selectedKey || (Math.abs(time - selectedAt) > ORBIT_REFRESH_GAME_MS && started - selectedWall > ORBIT_REFRESH_WALL_MS)) {
149
+ selectedKey = key;
150
+ selectedAt = time;
151
+ selectedWall = started;
152
+ selectedLine.positions.set(set.orbit(chosen.index, time, ORBIT_POINTS).subarray(0, ORBIT_POINTS * 3));
153
+ selectedLine.geometry.getAttribute("position").needsUpdate = true;
154
+ }
155
+ selectedLoop.visible = true;
156
+ selectedMaterial.uniforms["size"].value = 14 * ratio();
157
+ }
158
+ else {
159
+ selectedPoints.visible = false;
160
+ selectedLoop.visible = false;
161
+ selectedKey = "";
162
+ }
163
+ state.drawn.placed = placed;
164
+ state.drawn.tooFast = tooFast;
165
+ state.drawn.updateMs = performance.now() - started;
166
+ if (state.drawn.updateMs > state.drawn.maxUpdateMs)
167
+ state.drawn.maxUpdateMs = state.drawn.updateMs;
168
+ state.drawn.updates += 1;
169
+ state.drawn.totalMs += state.drawn.updateMs;
170
+ },
171
+ pick(x, y, camera, size, radius) {
172
+ let best = null;
173
+ let bestD = radius * radius;
174
+ const c = camera.position;
175
+ for (const [name, entry] of drawn) {
176
+ const group = state.groups.get(name);
177
+ if (!group || !entry.points.visible)
178
+ continue;
179
+ const p = entry.positions;
180
+ for (let i = 0; i < group.satellites.count; i += 1) {
181
+ if (!group.satellites.visible(i))
182
+ continue;
183
+ projected.set(p[i * 3], p[i * 3 + 1], p[i * 3 + 2]).project(camera);
184
+ if (projected.z > 1 || projected.z < -1)
185
+ continue;
186
+ const dx = ((projected.x + 1) / 2) * size.width - x;
187
+ const dy = ((1 - projected.y) / 2) * size.height - y;
188
+ const d = dx * dx + dy * dy;
189
+ if (d > bestD)
190
+ continue;
191
+ // behind the Earth: the line from the camera to the satellite passes through the unit sphere before it gets there
192
+ const px = p[i * 3] - c.x;
193
+ const py = p[i * 3 + 1] - c.y;
194
+ const pz = p[i * 3 + 2] - c.z;
195
+ const len2 = px * px + py * py + pz * pz;
196
+ const t = Math.max(0, Math.min(1, -(c.x * px + c.y * py + c.z * pz) / len2));
197
+ const nx = c.x + px * t;
198
+ const ny = c.y + py * t;
199
+ const nz = c.z + pz * t;
200
+ if (nx * nx + ny * ny + nz * nz < 0.998)
201
+ continue;
202
+ best = { group: name, index: i };
203
+ bestD = d;
204
+ }
205
+ }
206
+ return best;
207
+ },
208
+ place(camera, size) {
209
+ if (!selectedPoints.visible)
210
+ return null;
211
+ projected.set(selectedPosition[0], selectedPosition[1], selectedPosition[2]).project(camera);
212
+ if (projected.z > 1 || projected.z < -1)
213
+ return null;
214
+ return { x: ((projected.x + 1) / 2) * size.width, y: ((1 - projected.y) / 2) * size.height };
215
+ },
216
+ exact(ref, time, out) {
217
+ return find(ref)?.exact(ref.index, time, out) ?? false;
218
+ },
219
+ dispose() {
220
+ for (const entry of drawn.values())
221
+ drop(entry);
222
+ drawn.clear();
223
+ scene.remove(selectedPoints, selectedLoop);
224
+ selectedGeometry.dispose();
225
+ selectedMaterial.dispose();
226
+ selectedLine.geometry.dispose();
227
+ selectedLine.material.dispose();
228
+ },
229
+ };
230
+ }
@@ -4,6 +4,8 @@ import { type ThreeParts } from "../realistic/textures.ts";
4
4
  import type { Layer } from "../realistic/tiers.ts";
5
5
  import { type Frame, type PlanetName, type Vec3 } from "./ephemeris.ts";
6
6
  import { type ScaleMode } from "./scale.ts";
7
+ import type { SatelliteRef } from "../satellites/types.ts";
8
+ import { type SatelliteState } from "./satellite-layer.ts";
7
9
  type Three = ThreeParts;
8
10
  /** The camera's distance, in Earth radii, at which the Earth fills the view at zoom 1. */
9
11
  export declare const FIT_DISTANCE = 3.4;
@@ -61,6 +63,12 @@ export interface SpaceModel {
61
63
  } | null;
62
64
  /** How many times astronomy-engine has been asked for a frame. */
63
65
  lookups: number;
66
+ /** Game seconds a second (the clock's speed). */
67
+ speed: number;
68
+ /** The satellites shown, the one chosen, and what the last draw did with them. */
69
+ satellites: SatelliteState;
70
+ /** The satellite at a point of the view (CSS pixels), within a radius, if a draw has been made and one is there. */
71
+ pick: ((x: number, y: number, radius: number) => SatelliteRef | null) | null;
64
72
  ratio(): number;
65
73
  }
66
74
  export declare const newSpaceModel: () => SpaceModel;
@@ -113,6 +121,13 @@ export interface SpaceDeps {
113
121
  onTier?: (layer: Layer, width: number) => void;
114
122
  /** Where each body is on the screen, in CSS pixels in the box, or null when it is behind the camera. Called at each draw. */
115
123
  onLabels?: (places: Record<BodyName, BodyPlace | null>) => void;
124
+ /** Where the chosen satellite is on the screen, or null. Called at each draw while a satellite is chosen (and once when it is let go). */
125
+ onSelected?: (place: {
126
+ x: number;
127
+ y: number;
128
+ } | null) => void;
129
+ /** The satellites could not be drawn (the three.js parts handed over lack `Points`). */
130
+ onSatellitesError?: (error: unknown) => void;
116
131
  /** How the Sun and the planets are drawn; left out, plain spheres in the right colours (see `BodyPainter`). */
117
132
  painter?: BodyPainter;
118
133
  }