@johnmorrisdotca/wakusei-planets 0.6.0 → 0.11.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 +49 -0
- package/NOTICE.md +16 -1
- package/README.md +19 -4
- package/dist/bodies/all.d.ts +2 -0
- package/dist/bodies/all.js +2 -0
- package/dist/bodies/dwarfs.d.ts +1 -0
- package/dist/bodies/dwarfs.js +31 -0
- package/dist/bodies/moons-dwarf.d.ts +1 -0
- package/dist/bodies/moons-dwarf.js +6 -0
- package/dist/bodies/moons-neptune.js +1 -0
- package/dist/bodies/moons-pluto.js +1 -1
- package/dist/bodies/moons-saturn.js +3 -2
- package/dist/data/dwarf.d.ts +16 -0
- package/dist/data/dwarf.js +24 -0
- package/dist/data/orbits.d.ts +2 -2
- package/dist/data/orbits.js +4 -0
- package/dist/element.d.ts +4 -2
- package/dist/element.js +4 -0
- package/dist/embed.d.ts +35 -1
- package/dist/embed.js +78 -37
- package/dist/ephemeris.d.ts +6 -6
- package/dist/ephemeris.js +33 -12
- package/dist/facts.d.ts +44 -6
- package/dist/facts.js +32 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/line.js +7 -4
- package/dist/portrait/element.d.ts +77 -0
- package/dist/portrait/element.js +545 -0
- package/dist/portrait/model.d.ts +297 -0
- package/dist/portrait/model.js +711 -0
- package/dist/portrait/overlay.d.ts +50 -0
- package/dist/portrait/overlay.js +173 -0
- package/dist/portrait/pose.d.ts +13 -0
- package/dist/portrait/pose.js +31 -0
- package/dist/portrait/scene3d.d.ts +38 -0
- package/dist/portrait/scene3d.js +123 -0
- package/dist/pose.d.ts +7 -5
- package/dist/pose.js +25 -11
- package/dist/scene/body.d.ts +1 -1
- package/dist/scene/glsl.js +10 -1
- package/dist/scene/painter.d.ts +7 -0
- package/dist/scene/painter.js +9 -1
- package/dist/textures/ceres-1024.webp +0 -0
- package/dist/textures/ceres-512.webp +0 -0
- package/dist/textures/charon-1024.webp +0 -0
- package/dist/textures/charon-512.webp +0 -0
- package/dist/textures/dione-1024.webp +0 -0
- package/dist/textures/dione-512.webp +0 -0
- package/dist/textures/iapetus-1024.webp +0 -0
- package/dist/textures/iapetus-512.webp +0 -0
- package/dist/textures/tethys-1024.webp +0 -0
- package/dist/textures/tethys-512.webp +0 -0
- package/dist/textures/triton-1024.webp +0 -0
- package/dist/textures/triton-512.webp +0 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +18 -1
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
// The portrait builder's scene as plain data, and everything that can be worked out from it without a screen: where each body goes when the layout is automatic, the camera that fits them,
|
|
2
|
+
// which body is under a pointer, where a dragged body snaps, the order they are drawn in, the history for undo, and the short text that keeps a scene in the address. Pure: no DOM, no three.js.
|
|
3
|
+
import { ORBITS } from "../data/orbits.js";
|
|
4
|
+
import { heliocentric, moonVector } from "../ephemeris.js";
|
|
5
|
+
import { BODIES, DWARF_IDS, MOONS_OF, PLANET_IDS, ringReach } from "../facts.js";
|
|
6
|
+
export const ASPECTS = [["16:9", 16 / 9], ["4:3", 4 / 3], ["1:1", 1], ["3:4", 3 / 4]];
|
|
7
|
+
export const MAX_ITEMS = 40;
|
|
8
|
+
/** The Sun, then each planet and dwarf planet followed by its moons nearest first: the order a layout and a row of buttons use. */
|
|
9
|
+
export const ORDER = ["sun", ...[...PLANET_IDS, ...DWARF_IDS].flatMap((planet) => [planet, ...(planet in MOONS_OF ? [...MOONS_OF[planet]].sort(byDistance) : [])])];
|
|
10
|
+
function byDistance(a, b) {
|
|
11
|
+
return (ORBITS[a]?.a ?? 0) - (ORBITS[b]?.a ?? 0);
|
|
12
|
+
}
|
|
13
|
+
export const rankOf = (id) => ORDER.indexOf(id);
|
|
14
|
+
/**
|
|
15
|
+
* An empty scene with the usual settings: an automatic row, readable sizes, the stars behind, the Sun upper left.
|
|
16
|
+
*
|
|
17
|
+
* @returns A new scene with no bodies.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { defaultPortrait } from "@johnmorrisdotca/wakusei-planets";
|
|
22
|
+
*
|
|
23
|
+
* console.log(defaultPortrait().bg);
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function defaultPortrait() {
|
|
27
|
+
return {
|
|
28
|
+
items: [],
|
|
29
|
+
layout: "auto",
|
|
30
|
+
arrange: "row",
|
|
31
|
+
scale: "readable",
|
|
32
|
+
moons: "auto",
|
|
33
|
+
look: "painted",
|
|
34
|
+
bg: "stars",
|
|
35
|
+
aspect: 16 / 9,
|
|
36
|
+
light: { az: 300, el: 38 },
|
|
37
|
+
labels: true,
|
|
38
|
+
orbits: true,
|
|
39
|
+
shadows: true,
|
|
40
|
+
date: undefined,
|
|
41
|
+
camera: { x: 0, y: 0, h: 1 },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
45
|
+
const round = (v, places = 4) => Math.round(v * 10 ** places) / 10 ** places;
|
|
46
|
+
// --- sizes ---------------------------------------------------------------------------------------------------------------------------------------------
|
|
47
|
+
/** How far the picture of a body reaches past its disc, in its radii: Saturn's rings, the Sun's glow. */
|
|
48
|
+
export const reachOf = (id) => (id === "sun" ? 1.7 : Math.max(1, ringReach(BODIES[id]) * 1.04));
|
|
49
|
+
/** Raw sizes for the bodies, in any unit, in proportion: true to scale, or squeezed (a root of the radius) with the smallest lifted to a tenth of the largest so every body can be seen. */
|
|
50
|
+
export function sizesFor(ids, mode) {
|
|
51
|
+
if (mode === "true")
|
|
52
|
+
return ids.map((id) => BODIES[id].radiusKm);
|
|
53
|
+
const raw = ids.map((id) => BODIES[id].radiusKm ** 0.3);
|
|
54
|
+
const floor = 0.11 * Math.max(...raw, 0);
|
|
55
|
+
return raw.map((r) => Math.max(r, floor));
|
|
56
|
+
}
|
|
57
|
+
/** Groups: a planet with its moons that are in the scene, or a body alone. */
|
|
58
|
+
function groups(items, moons) {
|
|
59
|
+
const out = [];
|
|
60
|
+
const owner = new Map();
|
|
61
|
+
items.forEach((item, i) => {
|
|
62
|
+
const parent = BODIES[item.body].parent;
|
|
63
|
+
const home = moons === "auto" && parent !== undefined ? owner.get(parent) : undefined;
|
|
64
|
+
if (home !== undefined)
|
|
65
|
+
home.push(i);
|
|
66
|
+
else {
|
|
67
|
+
const group = [i];
|
|
68
|
+
out.push(group);
|
|
69
|
+
owner.set(item.body, group);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
// moons nearest first inside their planet's group
|
|
73
|
+
for (const group of out)
|
|
74
|
+
if (group.length > 1)
|
|
75
|
+
group.splice(1, group.length - 1, ...group.slice(1).sort((a, b) => byDistance(items[a].body, items[b].body)));
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** A planet at the left of its group and its moons in a line to the right of it, in orbit order, each clear of the last. */
|
|
79
|
+
function unitOf(members, radii, ids) {
|
|
80
|
+
const first = members[0];
|
|
81
|
+
const reachFirst = radii[first] * reachOf(ids[first]);
|
|
82
|
+
const at = [0];
|
|
83
|
+
let cursor = reachFirst;
|
|
84
|
+
let maxX = reachFirst;
|
|
85
|
+
let halfH = reachFirst;
|
|
86
|
+
for (const m of members.slice(1)) {
|
|
87
|
+
const r = radii[m];
|
|
88
|
+
const x = cursor + 0.22 * radii[first] + r;
|
|
89
|
+
at.push(x);
|
|
90
|
+
cursor = x + r;
|
|
91
|
+
maxX = Math.max(maxX, x + r);
|
|
92
|
+
halfH = Math.max(halfH, r);
|
|
93
|
+
}
|
|
94
|
+
return { members, at, minX: -reachFirst, maxX, halfH };
|
|
95
|
+
}
|
|
96
|
+
function arrangeUnits(units, how, aspect) {
|
|
97
|
+
const n = units.length;
|
|
98
|
+
const widths = units.map((u) => u.maxX - u.minX);
|
|
99
|
+
const tallest = Math.max(...units.map((u) => u.halfH * 2), 0);
|
|
100
|
+
const gap = 0.12 * Math.max(...widths, tallest, 1e-9);
|
|
101
|
+
if (how === "grid" && n > 1) {
|
|
102
|
+
const cols = Math.max(1, Math.ceil(Math.sqrt(n * aspect)));
|
|
103
|
+
const cellW = Math.max(...widths) + gap;
|
|
104
|
+
const cellH = tallest + gap;
|
|
105
|
+
return units.map((u, i) => {
|
|
106
|
+
const col = i % cols;
|
|
107
|
+
const row = Math.floor(i / cols);
|
|
108
|
+
const rows = Math.ceil(n / cols);
|
|
109
|
+
return { ox: (col - (cols - 1) / 2) * cellW - (u.minX + u.maxX) / 2, oy: ((rows - 1) / 2 - row) * cellH };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const total = widths.reduce((a, b) => a + b, 0) + gap * Math.max(0, n - 1);
|
|
113
|
+
let cursor = -total / 2;
|
|
114
|
+
const out = units.map((u, i) => {
|
|
115
|
+
const left = cursor;
|
|
116
|
+
cursor += widths[i] + gap;
|
|
117
|
+
return { ox: left - u.minX, oy: 0 };
|
|
118
|
+
});
|
|
119
|
+
if (how === "arc" && n > 1) {
|
|
120
|
+
const R = total * 0.85;
|
|
121
|
+
for (const o of out)
|
|
122
|
+
o.oy = (o.ox * o.ox) / (2 * R);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
// --- the true orbit distance layout ---------------------------------------------------------------------------------------------------------------------
|
|
127
|
+
const AU_KM = 149_597_870.7;
|
|
128
|
+
const J2000 = Date.UTC(2000, 0, 1, 12);
|
|
129
|
+
/**
|
|
130
|
+
* The orbit layout, in kilometres: the Sun (if it is in the scene) with the planets at their true distance, each planet with its moons at theirs; or each planet with its moons, side by side.
|
|
131
|
+
* With a date the bodies are where they really are, seen from above the ecliptic; without one they are in a line to the right of their centre. Sizes are true, or squeezed so the smallest can be seen.
|
|
132
|
+
*/
|
|
133
|
+
function orbitLayout(p) {
|
|
134
|
+
const items = p.items;
|
|
135
|
+
const ms = p.date === undefined ? undefined : Date.parse(p.date);
|
|
136
|
+
const when = ms !== undefined && Number.isFinite(ms) ? ms : undefined;
|
|
137
|
+
const index = new Map(items.map((i, k) => [i.body, k]));
|
|
138
|
+
const planetKind = (id) => BODIES[id].parent === undefined && id !== "sun";
|
|
139
|
+
const planetSpot = (id) => {
|
|
140
|
+
const v = heliocentric(id, when ?? J2000);
|
|
141
|
+
return when === undefined ? { x: Math.hypot(v[0], v[1], v[2]) * AU_KM, y: 0 } : { x: v[0] * AU_KM, y: v[1] * AU_KM };
|
|
142
|
+
};
|
|
143
|
+
const moonSpot = (id) => {
|
|
144
|
+
if (when === undefined)
|
|
145
|
+
return { x: ORBITS[id]?.a ?? 0, y: 0 };
|
|
146
|
+
const v = moonVector(id, when);
|
|
147
|
+
return { x: v[0], y: v[1] };
|
|
148
|
+
};
|
|
149
|
+
const systems = [];
|
|
150
|
+
const taken = new Set();
|
|
151
|
+
const sun = index.get("sun");
|
|
152
|
+
const attachMoons = (system, planet, at) => {
|
|
153
|
+
for (const [k, item] of items.entries()) {
|
|
154
|
+
if (BODIES[item.body].parent !== items[planet].body || taken.has(k))
|
|
155
|
+
continue;
|
|
156
|
+
const m = moonSpot(item.body);
|
|
157
|
+
system.members.push(k);
|
|
158
|
+
system.spot.set(k, { x: at.x + m.x, y: at.y + m.y });
|
|
159
|
+
system.centre.set(k, planet);
|
|
160
|
+
taken.add(k);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
if (sun !== undefined) {
|
|
164
|
+
const system = { members: [sun], spot: new Map([[sun, { x: 0, y: 0 }]]), centre: new Map() };
|
|
165
|
+
taken.add(sun);
|
|
166
|
+
for (const [k, item] of items.entries()) {
|
|
167
|
+
if (!planetKind(item.body))
|
|
168
|
+
continue;
|
|
169
|
+
const at = planetSpot(item.body);
|
|
170
|
+
system.members.push(k);
|
|
171
|
+
system.spot.set(k, at);
|
|
172
|
+
system.centre.set(k, sun);
|
|
173
|
+
taken.add(k);
|
|
174
|
+
attachMoons(system, k, at);
|
|
175
|
+
}
|
|
176
|
+
systems.push(system);
|
|
177
|
+
}
|
|
178
|
+
for (const [k, item] of items.entries()) {
|
|
179
|
+
if (taken.has(k) || !planetKind(item.body))
|
|
180
|
+
continue;
|
|
181
|
+
const system = { members: [k], spot: new Map([[k, { x: 0, y: 0 }]]), centre: new Map() };
|
|
182
|
+
taken.add(k);
|
|
183
|
+
if (p.moons === "auto")
|
|
184
|
+
attachMoons(system, k, { x: 0, y: 0 });
|
|
185
|
+
systems.push(system);
|
|
186
|
+
}
|
|
187
|
+
for (const [k] of items.entries()) {
|
|
188
|
+
if (taken.has(k))
|
|
189
|
+
continue;
|
|
190
|
+
systems.push({ members: [k], spot: new Map([[k, { x: 0, y: 0 }]]), centre: new Map() });
|
|
191
|
+
taken.add(k);
|
|
192
|
+
}
|
|
193
|
+
// sizes: true, or squeezed against the size of the picture the distances make
|
|
194
|
+
const ids = items.map((i) => i.body);
|
|
195
|
+
let span = 0;
|
|
196
|
+
for (const system of systems) {
|
|
197
|
+
for (const k of system.members) {
|
|
198
|
+
const c = system.centre.get(k);
|
|
199
|
+
if (c === undefined)
|
|
200
|
+
continue;
|
|
201
|
+
const a = system.spot.get(k);
|
|
202
|
+
const b = system.spot.get(c);
|
|
203
|
+
span = Math.max(span, Math.hypot(a.x - b.x, a.y - b.y));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const biggest = Math.max(...ids.map((id) => BODIES[id].radiusKm));
|
|
207
|
+
span = Math.max(span, biggest * 8);
|
|
208
|
+
const radius = (id) => (p.scale === "true" ? BODIES[id].radiusKm : Math.max(0.014 * span, 0.06 * span * (BODIES[id].radiusKm / biggest) ** 0.4));
|
|
209
|
+
const raw = items.map((item, index2) => ({ ...item, index: index2, x: 0, y: 0, r: radius(item.body), reach: radius(item.body) * reachOf(item.body) }));
|
|
210
|
+
// the circle each body goes round its centre on reaches this far every way
|
|
211
|
+
const extent = (system) => {
|
|
212
|
+
let minX = Infinity;
|
|
213
|
+
let maxX = -Infinity;
|
|
214
|
+
let minY = Infinity;
|
|
215
|
+
let maxY = -Infinity;
|
|
216
|
+
for (const k of system.members) {
|
|
217
|
+
const a = system.spot.get(k);
|
|
218
|
+
const reach = raw[k].reach;
|
|
219
|
+
minX = Math.min(minX, a.x - reach);
|
|
220
|
+
maxX = Math.max(maxX, a.x + reach);
|
|
221
|
+
minY = Math.min(minY, a.y - reach);
|
|
222
|
+
maxY = Math.max(maxY, a.y + reach);
|
|
223
|
+
const c = system.centre.get(k);
|
|
224
|
+
if (c !== undefined) {
|
|
225
|
+
const b = system.spot.get(c);
|
|
226
|
+
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
|
227
|
+
minX = Math.min(minX, b.x - d);
|
|
228
|
+
maxX = Math.max(maxX, b.x + d);
|
|
229
|
+
minY = Math.min(minY, b.y - d);
|
|
230
|
+
maxY = Math.max(maxY, b.y + d);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { minX, maxX, minY, maxY };
|
|
234
|
+
};
|
|
235
|
+
const extents = systems.map(extent);
|
|
236
|
+
const units = systems.map((s, k) => ({ members: s.members, at: [], minX: extents[k].minX, maxX: extents[k].maxX, halfH: Math.max(extents[k].maxY, -extents[k].minY) }));
|
|
237
|
+
const spots = arrangeUnits(units, "row", p.aspect);
|
|
238
|
+
const reachPoints = [];
|
|
239
|
+
systems.forEach((s, k) => {
|
|
240
|
+
const dy = (extents[k].minY + extents[k].maxY) / 2;
|
|
241
|
+
for (const m of s.members) {
|
|
242
|
+
const a = s.spot.get(m);
|
|
243
|
+
raw[m].x = spots[k].ox + a.x;
|
|
244
|
+
raw[m].y = a.y - dy;
|
|
245
|
+
}
|
|
246
|
+
reachPoints.push({ x: spots[k].ox + extents[k].minX, y: extents[k].minY - dy }, { x: spots[k].ox + extents[k].maxX, y: extents[k].maxY - dy });
|
|
247
|
+
});
|
|
248
|
+
return { placed: raw, reachPoints };
|
|
249
|
+
}
|
|
250
|
+
/** The camera that holds every body with a margin: its centre and half-height. */
|
|
251
|
+
export function fitCamera(placed, aspect, margin = 1.1, labels = false) {
|
|
252
|
+
if (placed.length === 0)
|
|
253
|
+
return { x: 0, y: 0, h: 1 };
|
|
254
|
+
let x0 = Infinity;
|
|
255
|
+
let x1 = -Infinity;
|
|
256
|
+
let y0 = Infinity;
|
|
257
|
+
let y1 = -Infinity;
|
|
258
|
+
for (const p of placed) {
|
|
259
|
+
x0 = Math.min(x0, p.x - p.reach);
|
|
260
|
+
x1 = Math.max(x1, p.x + p.reach);
|
|
261
|
+
y0 = Math.min(y0, p.y - p.reach);
|
|
262
|
+
y1 = Math.max(y1, p.y + p.reach);
|
|
263
|
+
}
|
|
264
|
+
if (labels)
|
|
265
|
+
y0 -= (y1 - y0) * 0.08;
|
|
266
|
+
const h = Math.max((x1 - x0) / 2 / aspect, (y1 - y0) / 2, 1e-9) * margin;
|
|
267
|
+
return { x: (x0 + x1) / 2, y: (y0 + y1) / 2, h };
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Where every body is and how big, and the camera that holds them. A manual scene gives what it stores. An automatic one works it out (a row, an arc or a grid, a planet's moons round it in orbit
|
|
271
|
+
* order) and fits the camera, so its units are the camera's half-height.
|
|
272
|
+
*
|
|
273
|
+
* @param scene - The scene.
|
|
274
|
+
* @returns Every body placed, back to front, and the camera.
|
|
275
|
+
*
|
|
276
|
+
* @example
|
|
277
|
+
* ```ts
|
|
278
|
+
* import { addBody, defaultPortrait, layoutPortrait } from "@johnmorrisdotca/wakusei-planets";
|
|
279
|
+
*
|
|
280
|
+
* const { placed } = layoutPortrait(addBody(addBody(defaultPortrait(), "earth"), "moon"));
|
|
281
|
+
* console.log(placed.map((item) => `${item.body} ${item.x.toFixed(2)}`).join(", "));
|
|
282
|
+
* ```
|
|
283
|
+
*/
|
|
284
|
+
export function resolve(scene) {
|
|
285
|
+
const p = scene;
|
|
286
|
+
if (p.layout === "manual" || p.items.length === 0) {
|
|
287
|
+
return { placed: p.items.map((item, index) => ({ ...item, index, reach: item.r * reachOf(item.body) })), camera: p.camera };
|
|
288
|
+
}
|
|
289
|
+
if (p.arrange === "orbit") {
|
|
290
|
+
const { placed: raw, reachPoints } = orbitLayout(p);
|
|
291
|
+
const cam = fitCamera([...raw, ...reachPoints.map((q) => ({ x: q.x, y: q.y, reach: 0 }))], p.aspect, 1.1, p.labels);
|
|
292
|
+
const k = 1 / cam.h;
|
|
293
|
+
return { placed: raw.map((it) => ({ ...it, x: (it.x - cam.x) * k, y: (it.y - cam.y) * k, r: it.r * k, reach: it.reach * k })), camera: { x: 0, y: 0, h: 1 } };
|
|
294
|
+
}
|
|
295
|
+
const ids = p.items.map((item) => item.body);
|
|
296
|
+
const sizes = sizesFor(ids, p.scale);
|
|
297
|
+
const grouped = groups(p.items, p.moons);
|
|
298
|
+
const units = grouped.map((members) => unitOf(members, sizes, ids));
|
|
299
|
+
const spots = arrangeUnits(units, p.arrange, p.aspect);
|
|
300
|
+
const raw = p.items.map((item, index) => ({ ...item, index, x: 0, y: 0, r: sizes[index], reach: sizes[index] * reachOf(item.body) }));
|
|
301
|
+
units.forEach((u, k) => {
|
|
302
|
+
u.members.forEach((m, j) => {
|
|
303
|
+
raw[m].x = spots[k].ox + u.at[j];
|
|
304
|
+
raw[m].y = spots[k].oy;
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
const cam = fitCamera(raw, p.aspect, 1.1, p.labels);
|
|
308
|
+
const k = 1 / cam.h;
|
|
309
|
+
return { placed: raw.map((it) => ({ ...it, x: (it.x - cam.x) * k, y: (it.y - cam.y) * k, r: it.r * k, reach: it.reach * k })), camera: { x: 0, y: 0, h: 1 } };
|
|
310
|
+
}
|
|
311
|
+
// --- changing a scene ----------------------------------------------------------------------------------------------------------------------------------
|
|
312
|
+
const copy = (p) => ({ ...p, items: p.items.map((i) => ({ ...i })), light: { ...p.light }, camera: { ...p.camera } });
|
|
313
|
+
/** A manual layout with every body where the automatic one has it now. */
|
|
314
|
+
export function freeze(p) {
|
|
315
|
+
if (p.layout === "manual")
|
|
316
|
+
return p;
|
|
317
|
+
const { placed, camera } = resolve(p);
|
|
318
|
+
const out = copy(p);
|
|
319
|
+
out.layout = "manual";
|
|
320
|
+
out.items = placed.map((q) => ({ body: q.body, x: q.x, y: q.y, r: q.r, spin: q.spin, turn: q.turn, ...(q.tilt !== undefined ? { tilt: q.tilt } : {}), ...(q.pitch !== undefined ? { pitch: q.pitch } : {}) }));
|
|
321
|
+
out.camera = camera;
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
/** The automatic layout again. */
|
|
325
|
+
export function auto(p) {
|
|
326
|
+
return { ...copy(p), layout: "auto" };
|
|
327
|
+
}
|
|
328
|
+
const fresh = (body) => ({ body, x: 0, y: 0, r: 0.3, spin: BODIES[body].showLon ?? 0, turn: 0 });
|
|
329
|
+
/** The factor between a stored radius and the size a body should be by the scene's scale, from the bodies already there (0.3 of the half-height for the largest, for an empty scene). */
|
|
330
|
+
function unitSize(p, adding) {
|
|
331
|
+
const have = p.items.filter((i) => i.r > 0);
|
|
332
|
+
if (have.length === 0)
|
|
333
|
+
return 0.34 / sizesFor([adding], p.scale)[0];
|
|
334
|
+
const sizes = sizesFor(have.map((i) => i.body), p.scale);
|
|
335
|
+
const k = have.map((i, n) => i.r / sizes[n]).sort((a, b) => a - b);
|
|
336
|
+
return k[Math.floor(k.length / 2)];
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Add a body to a scene (once), in its place in the order of the sky for an automatic layout, or at the front and to the right of the others for a manual one.
|
|
340
|
+
*
|
|
341
|
+
* @param scene - The scene to add to; it is not changed.
|
|
342
|
+
* @param body - The body's name.
|
|
343
|
+
* @returns A new scene with the body, or the same one when it is already there.
|
|
344
|
+
*
|
|
345
|
+
* @example
|
|
346
|
+
* ```ts
|
|
347
|
+
* import { addBody, defaultPortrait } from "@johnmorrisdotca/wakusei-planets";
|
|
348
|
+
*
|
|
349
|
+
* const scene = addBody(addBody(defaultPortrait(), "saturn"), "earth");
|
|
350
|
+
* console.log(scene.items.map((item) => item.body).join(", "));
|
|
351
|
+
* ```
|
|
352
|
+
*/
|
|
353
|
+
export function addBody(scene, body) {
|
|
354
|
+
const p = scene;
|
|
355
|
+
if (p.items.some((i) => i.body === body) || p.items.length >= MAX_ITEMS)
|
|
356
|
+
return p;
|
|
357
|
+
const out = copy(p);
|
|
358
|
+
const item = fresh(body);
|
|
359
|
+
if (p.layout === "auto") {
|
|
360
|
+
item.r = 0.3;
|
|
361
|
+
const at = out.items.findIndex((i) => rankOf(i.body) > rankOf(body));
|
|
362
|
+
out.items.splice(at < 0 ? out.items.length : at, 0, item);
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
const sized = sizesFor([body], p.scale)[0] * unitSize(p, body);
|
|
366
|
+
item.r = Math.max(0.006, sized);
|
|
367
|
+
const right = out.items.reduce((m, i) => Math.max(m, i.x + i.r), -Infinity);
|
|
368
|
+
item.x = out.items.length === 0 ? out.camera.x : right + item.r * 1.4 + 0.04;
|
|
369
|
+
item.y = out.items.length === 0 ? out.camera.y : out.items[out.items.length - 1].y;
|
|
370
|
+
out.items.push(item);
|
|
371
|
+
const visible = Math.abs(item.x - out.camera.x) + item.r < out.camera.h * out.aspect && Math.abs(item.y - out.camera.y) + item.r < out.camera.h;
|
|
372
|
+
if (!visible)
|
|
373
|
+
out.camera = fitCamera(resolve(out).placed, out.aspect, 1.1, out.labels);
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
export function removeBody(p, body) {
|
|
377
|
+
if (!p.items.some((i) => i.body === body))
|
|
378
|
+
return p;
|
|
379
|
+
const out = copy(p);
|
|
380
|
+
out.items = out.items.filter((i) => i.body !== body);
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
/** Add the moons of a planet that are not in the scene yet, in orbit order. */
|
|
384
|
+
export function addMoons(p, planet) {
|
|
385
|
+
const moons = planet in MOONS_OF ? [...MOONS_OF[planet]].sort(byDistance) : [];
|
|
386
|
+
let out = p;
|
|
387
|
+
for (const moon of moons)
|
|
388
|
+
out = addBody(out, moon);
|
|
389
|
+
if (out === p || p.layout === "auto")
|
|
390
|
+
return out;
|
|
391
|
+
// manual: lay the new moons out round their planet the way the automatic layout does
|
|
392
|
+
const host = out.items.find((i) => i.body === planet);
|
|
393
|
+
if (host === undefined)
|
|
394
|
+
return out;
|
|
395
|
+
const copyOut = copy(out);
|
|
396
|
+
let cursor = host.x + host.r * reachOf(host.body);
|
|
397
|
+
for (const moon of moons) {
|
|
398
|
+
const item = copyOut.items.find((i) => i.body === moon);
|
|
399
|
+
if (item === undefined || p.items.some((i) => i.body === moon))
|
|
400
|
+
continue;
|
|
401
|
+
item.x = cursor + 0.22 * host.r + item.r;
|
|
402
|
+
item.y = host.y;
|
|
403
|
+
cursor = item.x + item.r;
|
|
404
|
+
}
|
|
405
|
+
return copyOut;
|
|
406
|
+
}
|
|
407
|
+
/** Move one body to a new place in the list: the front is the end. */
|
|
408
|
+
export function moveInOrder(p, from, to) {
|
|
409
|
+
const target = clamp(to, 0, p.items.length - 1);
|
|
410
|
+
if (from === target || from < 0 || from >= p.items.length)
|
|
411
|
+
return p;
|
|
412
|
+
const out = copy(p);
|
|
413
|
+
const [item] = out.items.splice(from, 1);
|
|
414
|
+
out.items.splice(target, 0, item);
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
/** Change one body's fields. */
|
|
418
|
+
export function setItem(p, index, change) {
|
|
419
|
+
if (index < 0 || index >= p.items.length)
|
|
420
|
+
return p;
|
|
421
|
+
const out = copy(p);
|
|
422
|
+
out.items[index] = { ...out.items[index], ...change };
|
|
423
|
+
if (out.items[index].tilt === undefined)
|
|
424
|
+
delete out.items[index].tilt;
|
|
425
|
+
if (out.items[index].pitch === undefined)
|
|
426
|
+
delete out.items[index].pitch;
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
/** Change the portrait's own settings. A change to manual layout keeps every body where it is on the page. */
|
|
430
|
+
export function setPortrait(p, change) {
|
|
431
|
+
let out = copy(p);
|
|
432
|
+
if (change.layout === "manual")
|
|
433
|
+
out = freeze(out);
|
|
434
|
+
out = { ...out, ...change, light: { ...(change.light ?? out.light) }, camera: { ...(change.camera ?? out.camera) } };
|
|
435
|
+
out.aspect = clamp(out.aspect, 0.25, 4);
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
/** Every body sized by a scale, keeping the largest as it is (a manual layout's way to go back to true or readable sizes). */
|
|
439
|
+
export function rescale(p, mode) {
|
|
440
|
+
const out = copy(p);
|
|
441
|
+
out.scale = mode;
|
|
442
|
+
if (p.items.length === 0 || p.layout === "auto")
|
|
443
|
+
return out;
|
|
444
|
+
const largest = p.items.reduce((m, i) => (i.r > m.r ? i : m), p.items[0]);
|
|
445
|
+
const base = sizesFor([largest.body], mode)[0];
|
|
446
|
+
const sizes = sizesFor(p.items.map((i) => i.body), mode);
|
|
447
|
+
out.items.forEach((item, i) => {
|
|
448
|
+
item.r = Math.max(0.004, (sizes[i] / base) * largest.r);
|
|
449
|
+
});
|
|
450
|
+
return out;
|
|
451
|
+
}
|
|
452
|
+
/** Move the camera to the middle of the bodies, keeping its zoom. */
|
|
453
|
+
export function centreCamera(p) {
|
|
454
|
+
if (p.layout === "auto")
|
|
455
|
+
return p;
|
|
456
|
+
const fit = fitCamera(resolve(p).placed, p.aspect, 1.1, p.labels);
|
|
457
|
+
return { ...copy(p), camera: { ...p.camera, x: fit.x, y: fit.y } };
|
|
458
|
+
}
|
|
459
|
+
/** Zoom out or in so every body is in the picture. */
|
|
460
|
+
export function fitAll(p) {
|
|
461
|
+
if (p.layout === "auto")
|
|
462
|
+
return p;
|
|
463
|
+
return { ...copy(p), camera: fitCamera(resolve(p).placed, p.aspect, 1.1, p.labels) };
|
|
464
|
+
}
|
|
465
|
+
// --- looking at the picture -----------------------------------------------------------------------------------------------------------------------------
|
|
466
|
+
/** The scene's units to pixels: a body at the camera's centre is in the middle of a picture `width` by `height`. */
|
|
467
|
+
export function toScreen(camera, width, height) {
|
|
468
|
+
const scale = height / (2 * camera.h);
|
|
469
|
+
return { px: (x) => width / 2 + (x - camera.x) * scale, py: (y) => height / 2 - (y - camera.y) * scale, scale };
|
|
470
|
+
}
|
|
471
|
+
/** The inverse: a pixel to the scene's units. */
|
|
472
|
+
export function fromScreen(camera, width, height, x, y) {
|
|
473
|
+
const scale = height / (2 * camera.h);
|
|
474
|
+
return { x: (x - width / 2) / scale + camera.x, y: -(y - height / 2) / scale + camera.y };
|
|
475
|
+
}
|
|
476
|
+
/** The index of the body under a point (scene units), front first; a body smaller than `slop` is still hit within it. */
|
|
477
|
+
export function hitTest(placed, x, y, slop = 0) {
|
|
478
|
+
for (let i = placed.length - 1; i >= 0; i -= 1) {
|
|
479
|
+
const p = placed[i];
|
|
480
|
+
if (Math.hypot(x - p.x, y - p.y) <= Math.max(p.r, slop))
|
|
481
|
+
return i;
|
|
482
|
+
}
|
|
483
|
+
return -1;
|
|
484
|
+
}
|
|
485
|
+
/** Where a body being dragged snaps: its middle and edges to the middle and edges of the others and to the middle of the picture, within `tolerance` (scene units). */
|
|
486
|
+
export function snap(moving, others, centre, tolerance) {
|
|
487
|
+
const best = (own, targets) => {
|
|
488
|
+
let found;
|
|
489
|
+
for (const t of targets)
|
|
490
|
+
for (const o of own)
|
|
491
|
+
if (Math.abs(t - o) <= tolerance && (found === undefined || Math.abs(t - o) < Math.abs(found.delta)))
|
|
492
|
+
found = { delta: t - o, at: t };
|
|
493
|
+
return found;
|
|
494
|
+
};
|
|
495
|
+
const xs = [centre.x, ...others.flatMap((o) => [o.x - o.r, o.x, o.x + o.r])];
|
|
496
|
+
const ys = [centre.y, ...others.flatMap((o) => [o.y - o.r, o.y, o.y + o.r])];
|
|
497
|
+
const sx = best([moving.x - moving.r, moving.x, moving.x + moving.r], xs);
|
|
498
|
+
const sy = best([moving.y - moving.r, moving.y, moving.y + moving.r], ys);
|
|
499
|
+
const guides = [];
|
|
500
|
+
if (sx !== undefined)
|
|
501
|
+
guides.push({ axis: "x", at: sx.at });
|
|
502
|
+
if (sy !== undefined)
|
|
503
|
+
guides.push({ axis: "y", at: sy.at });
|
|
504
|
+
return { x: moving.x + (sx?.delta ?? 0), y: moving.y + (sy?.delta ?? 0), guides };
|
|
505
|
+
}
|
|
506
|
+
/** The Sun's direction as a unit vector in the picture's frame (x right, y up, z toward the viewer). */
|
|
507
|
+
export function lightVector(light) {
|
|
508
|
+
const az = (light.az * Math.PI) / 180;
|
|
509
|
+
const el = (light.el * Math.PI) / 180;
|
|
510
|
+
return [Math.sin(az) * Math.cos(el), Math.cos(az) * Math.cos(el), Math.sin(el)];
|
|
511
|
+
}
|
|
512
|
+
/** The states a scene has been through, to step back and forward over. A state is never changed, so each is kept as it is. */
|
|
513
|
+
export function createHistory(initial, limit = 100) {
|
|
514
|
+
const past = [];
|
|
515
|
+
let future = [];
|
|
516
|
+
let now = initial;
|
|
517
|
+
return {
|
|
518
|
+
get current() {
|
|
519
|
+
return now;
|
|
520
|
+
},
|
|
521
|
+
get canUndo() {
|
|
522
|
+
return past.length > 0;
|
|
523
|
+
},
|
|
524
|
+
get canRedo() {
|
|
525
|
+
return future.length > 0;
|
|
526
|
+
},
|
|
527
|
+
push(next) {
|
|
528
|
+
if (next === now)
|
|
529
|
+
return;
|
|
530
|
+
past.push(now);
|
|
531
|
+
if (past.length > limit)
|
|
532
|
+
past.shift();
|
|
533
|
+
future = [];
|
|
534
|
+
now = next;
|
|
535
|
+
},
|
|
536
|
+
replace(next) {
|
|
537
|
+
now = next;
|
|
538
|
+
future = [];
|
|
539
|
+
},
|
|
540
|
+
undo() {
|
|
541
|
+
const back = past.pop();
|
|
542
|
+
if (back !== undefined) {
|
|
543
|
+
future.push(now);
|
|
544
|
+
now = back;
|
|
545
|
+
}
|
|
546
|
+
return now;
|
|
547
|
+
},
|
|
548
|
+
redo() {
|
|
549
|
+
const forward = future.pop();
|
|
550
|
+
if (forward !== undefined) {
|
|
551
|
+
past.push(now);
|
|
552
|
+
now = forward;
|
|
553
|
+
}
|
|
554
|
+
return now;
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
// --- the address --------------------------------------------------------------------------------------------------------------------------------------
|
|
559
|
+
const LOOKS = ["line", "realistic", "painted"];
|
|
560
|
+
const BACKGROUNDS = ["black", "stars", "gradient", "transparent"];
|
|
561
|
+
const ARRANGES = ["row", "arc", "grid", "orbit"];
|
|
562
|
+
const num = (value, fallback) => {
|
|
563
|
+
if (value === undefined || value === "")
|
|
564
|
+
return fallback;
|
|
565
|
+
const n = Number(value);
|
|
566
|
+
return Number.isFinite(n) ? n : fallback;
|
|
567
|
+
};
|
|
568
|
+
const optional = (value) => {
|
|
569
|
+
if (value === undefined || value === "")
|
|
570
|
+
return undefined;
|
|
571
|
+
const n = Number(value);
|
|
572
|
+
return Number.isFinite(n) ? n : undefined;
|
|
573
|
+
};
|
|
574
|
+
/**
|
|
575
|
+
* The scene as one short piece of text that is safe in an address: fields joined by `~`, each `name:value`, then one `body_x_y_r_spin_tilt_pitch_turn` for each body, back to front. An empty scene is an empty string.
|
|
576
|
+
*
|
|
577
|
+
* @param scene - The scene.
|
|
578
|
+
* @returns The text.
|
|
579
|
+
*
|
|
580
|
+
* @example
|
|
581
|
+
* ```ts
|
|
582
|
+
* import { addBody, defaultPortrait, encodeScene } from "@johnmorrisdotca/wakusei-planets";
|
|
583
|
+
*
|
|
584
|
+
* console.log(encodeScene(addBody(defaultPortrait(), "mars")));
|
|
585
|
+
* ```
|
|
586
|
+
*/
|
|
587
|
+
export function encodeScene(scene) {
|
|
588
|
+
const p = scene;
|
|
589
|
+
const d = defaultPortrait();
|
|
590
|
+
const fields = [];
|
|
591
|
+
if (p.layout !== d.layout)
|
|
592
|
+
fields.push(`layout:${p.layout}`);
|
|
593
|
+
if (p.arrange !== d.arrange)
|
|
594
|
+
fields.push(`arrange:${p.arrange}`);
|
|
595
|
+
if (p.scale !== d.scale)
|
|
596
|
+
fields.push(`scale:${p.scale}`);
|
|
597
|
+
if (p.moons !== d.moons)
|
|
598
|
+
fields.push(`moons:${p.moons}`);
|
|
599
|
+
if (p.look !== d.look)
|
|
600
|
+
fields.push(`look:${p.look}`);
|
|
601
|
+
if (p.bg !== d.bg)
|
|
602
|
+
fields.push(`bg:${p.bg}`);
|
|
603
|
+
if (round(p.aspect, 3) !== round(d.aspect, 3))
|
|
604
|
+
fields.push(`ratio:${round(p.aspect, 3)}`);
|
|
605
|
+
if (p.light.az !== d.light.az || p.light.el !== d.light.el)
|
|
606
|
+
fields.push(`light:${round(p.light.az, 1)},${round(p.light.el, 1)}`);
|
|
607
|
+
if (!p.labels)
|
|
608
|
+
fields.push("labels:0");
|
|
609
|
+
if (!p.orbits)
|
|
610
|
+
fields.push("orbits:0");
|
|
611
|
+
if (!p.shadows)
|
|
612
|
+
fields.push("shadows:0");
|
|
613
|
+
if (p.date !== undefined)
|
|
614
|
+
fields.push(`date:${p.date}`);
|
|
615
|
+
if (p.layout === "manual")
|
|
616
|
+
fields.push(`cam:${round(p.camera.x)},${round(p.camera.y)},${round(p.camera.h)}`);
|
|
617
|
+
for (const i of p.items) {
|
|
618
|
+
const manual = p.layout === "manual";
|
|
619
|
+
fields.push([i.body, manual ? round(i.x) : "", manual ? round(i.y) : "", manual ? round(i.r) : "", round(i.spin, 1), i.tilt === undefined ? "" : round(i.tilt, 1), i.pitch === undefined ? "" : round(i.pitch, 1), i.turn === 0 ? "" : round(i.turn, 2)].join("_"));
|
|
620
|
+
}
|
|
621
|
+
return fields.join("~");
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* The scene from its text. Anything it does not understand is left at its default and a body it does not know is dropped, so any text gives a scene.
|
|
625
|
+
*
|
|
626
|
+
* @param text - Text from `encodeScene`, or anything.
|
|
627
|
+
* @returns The scene.
|
|
628
|
+
*
|
|
629
|
+
* @example
|
|
630
|
+
* ```ts
|
|
631
|
+
* import { decodeScene } from "@johnmorrisdotca/wakusei-planets";
|
|
632
|
+
*
|
|
633
|
+
* console.log(decodeScene("look:painted~jupiter_0_0_0.3_0").items[0]?.body);
|
|
634
|
+
* ```
|
|
635
|
+
*/
|
|
636
|
+
export function decodeScene(text) {
|
|
637
|
+
const p = defaultPortrait();
|
|
638
|
+
const items = [];
|
|
639
|
+
for (const field of text.split("~")) {
|
|
640
|
+
if (field === "")
|
|
641
|
+
continue;
|
|
642
|
+
const colon = field.indexOf(":");
|
|
643
|
+
if (colon > 0) {
|
|
644
|
+
const key = field.slice(0, colon);
|
|
645
|
+
const value = field.slice(colon + 1);
|
|
646
|
+
if (key === "layout")
|
|
647
|
+
p.layout = value === "manual" ? "manual" : "auto";
|
|
648
|
+
else if (key === "arrange" && ARRANGES.includes(value))
|
|
649
|
+
p.arrange = value;
|
|
650
|
+
else if (key === "scale")
|
|
651
|
+
p.scale = value === "true" ? "true" : "readable";
|
|
652
|
+
else if (key === "moons")
|
|
653
|
+
p.moons = value === "manual" ? "manual" : "auto";
|
|
654
|
+
else if (key === "look" && LOOKS.includes(value))
|
|
655
|
+
p.look = value;
|
|
656
|
+
else if (key === "bg" && BACKGROUNDS.includes(value))
|
|
657
|
+
p.bg = value;
|
|
658
|
+
else if (key === "ratio")
|
|
659
|
+
p.aspect = clamp(num(value, p.aspect), 0.25, 4);
|
|
660
|
+
else if (key === "light") {
|
|
661
|
+
const [az, el] = value.split(",");
|
|
662
|
+
p.light = { az: ((num(az, p.light.az) % 360) + 360) % 360, el: clamp(num(el, p.light.el), 0, 90) };
|
|
663
|
+
}
|
|
664
|
+
else if (key === "labels")
|
|
665
|
+
p.labels = value !== "0";
|
|
666
|
+
else if (key === "orbits")
|
|
667
|
+
p.orbits = value !== "0";
|
|
668
|
+
else if (key === "shadows")
|
|
669
|
+
p.shadows = value !== "0";
|
|
670
|
+
else if (key === "date")
|
|
671
|
+
p.date = Number.isFinite(Date.parse(value)) ? value : undefined;
|
|
672
|
+
else if (key === "cam") {
|
|
673
|
+
const [x, y, h] = value.split(",");
|
|
674
|
+
p.camera = { x: num(x, 0), y: num(y, 0), h: Math.max(1e-6, num(h, 1)) };
|
|
675
|
+
}
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
const [body, x, y, r, spin, tilt, pitch, turn] = field.split("_");
|
|
679
|
+
if (body === undefined || !(body in BODIES) || items.some((i) => i.body === body) || items.length >= MAX_ITEMS)
|
|
680
|
+
continue;
|
|
681
|
+
const item = { body: body, x: num(x, 0), y: num(y, 0), r: Math.max(0.0005, num(r, 0.3)), spin: num(spin, BODIES[body].showLon ?? 0), turn: clamp(num(turn, 0), -720, 720) };
|
|
682
|
+
const t = optional(tilt);
|
|
683
|
+
const pi = optional(pitch);
|
|
684
|
+
if (t !== undefined)
|
|
685
|
+
item.tilt = t;
|
|
686
|
+
if (pi !== undefined)
|
|
687
|
+
item.pitch = clamp(pi, -90, 90);
|
|
688
|
+
items.push(item);
|
|
689
|
+
}
|
|
690
|
+
p.items = items;
|
|
691
|
+
return p;
|
|
692
|
+
}
|
|
693
|
+
const fromList = (ids, change = {}) => ids.reduce((p, id) => addBody(p, id), { ...defaultPortrait(), ...change });
|
|
694
|
+
/**
|
|
695
|
+
* Ready-made scenes to start from: a family photo of the Sun and the planets, the gas giants to scale, Jupiter and the Galileans, the Earth and the Moon to scale (size and distance), Mars and its moons at their true distance, and the inner planets round the Sun.
|
|
696
|
+
*
|
|
697
|
+
* @example
|
|
698
|
+
* ```ts
|
|
699
|
+
* import { PORTRAIT_PRESETS } from "@johnmorrisdotca/wakusei-planets";
|
|
700
|
+
*
|
|
701
|
+
* console.log(PORTRAIT_PRESETS.map((preset) => preset.id).join(", "));
|
|
702
|
+
* ```
|
|
703
|
+
*/
|
|
704
|
+
export const PRESETS = [
|
|
705
|
+
{ id: "family", name: { en: "Family photo", ja: "家族写真" }, scene: fromList(["sun", ...PLANET_IDS], { arrange: "arc", scale: "readable", moons: "manual", orbits: false }) },
|
|
706
|
+
{ id: "giants", name: { en: "Gas giants", ja: "巨大ガス惑星" }, scene: fromList(["jupiter", "saturn", "uranus", "neptune"], { arrange: "row", scale: "true", moons: "manual", bg: "gradient" }) },
|
|
707
|
+
{ id: "galileans", name: { en: "Jupiter and the Galileans", ja: "木星とガリレオ衛星" }, scene: fromList(["jupiter", "io", "europa", "ganymede", "callisto"], { arrange: "row", scale: "readable", moons: "auto", orbits: true }) },
|
|
708
|
+
{ id: "earthmoon", name: { en: "Earth and Moon to scale", ja: "地球と月 (大きさと距離の比)" }, scene: fromList(["earth", "moon"], { arrange: "orbit", scale: "true", moons: "auto", orbits: true }) },
|
|
709
|
+
{ id: "marsmoons", name: { en: "Mars and its moons at true distance", ja: "火星と衛星 (本当の距離)" }, scene: fromList(["mars", "phobos", "deimos"], { arrange: "orbit", scale: "readable", moons: "auto", orbits: true }) },
|
|
710
|
+
{ id: "orrery", name: { en: "The inner planets round the Sun", ja: "太陽と内側の惑星 (本当の距離)" }, scene: fromList(["sun", "mercury", "venus", "earth", "mars"], { arrange: "orbit", scale: "readable", moons: "auto", orbits: true, bg: "black" }) },
|
|
711
|
+
];
|