@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 +42 -14
- package/NOTICE.md +11 -0
- package/README.md +14 -0
- package/dist/realistic/textures.d.ts +3 -0
- package/dist/satellites/index.d.ts +3 -0
- package/dist/satellites/index.js +3 -0
- package/dist/satellites/limits.d.ts +22 -0
- package/dist/satellites/limits.js +23 -0
- package/dist/satellites/track.d.ts +50 -0
- package/dist/satellites/track.js +227 -0
- package/dist/satellites/types.d.ts +161 -0
- package/dist/satellites/types.js +3 -0
- package/dist/space/element.d.ts +45 -0
- package/dist/space/element.js +156 -9
- package/dist/space/satellite-layer.d.ts +53 -0
- package/dist/space/satellite-layer.js +230 -0
- package/dist/space/scene.d.ts +15 -0
- package/dist/space/scene.js +25 -0
- package/package.json +20 -4
package/dist/space/element.d.ts
CHANGED
|
@@ -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 {
|
package/dist/space/element.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
280
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/space/scene.d.ts
CHANGED
|
@@ -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
|
}
|