@johnmorrisdotca/wakusei-planets 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,578 @@
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 { BODIES, MOONS_OF, PLANET_IDS, ringReach } from "../facts.js";
5
+ export const ASPECTS = [["16:9", 16 / 9], ["4:3", 4 / 3], ["1:1", 1], ["3:4", 3 / 4]];
6
+ export const MAX_ITEMS = 40;
7
+ /** The Sun, then each planet followed by its moons nearest first: the order a layout and a row of buttons use. */
8
+ export const ORDER = ["sun", ...PLANET_IDS.flatMap((planet) => [planet, ...(planet in MOONS_OF ? [...MOONS_OF[planet]].sort(byDistance) : [])])];
9
+ function byDistance(a, b) {
10
+ return (ORBITS[a]?.a ?? 0) - (ORBITS[b]?.a ?? 0);
11
+ }
12
+ export const rankOf = (id) => ORDER.indexOf(id);
13
+ /**
14
+ * An empty scene with the usual settings: an automatic row, readable sizes, the stars behind, the Sun upper left.
15
+ *
16
+ * @returns A new scene with no bodies.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * import { defaultPortrait } from "@johnmorrisdotca/wakusei-planets";
21
+ *
22
+ * console.log(defaultPortrait().bg);
23
+ * ```
24
+ */
25
+ export function defaultPortrait() {
26
+ return {
27
+ items: [],
28
+ layout: "auto",
29
+ arrange: "row",
30
+ scale: "readable",
31
+ moons: "auto",
32
+ look: "painted",
33
+ bg: "stars",
34
+ aspect: 16 / 9,
35
+ light: { az: 300, el: 38 },
36
+ labels: true,
37
+ orbits: true,
38
+ shadows: true,
39
+ date: undefined,
40
+ camera: { x: 0, y: 0, h: 1 },
41
+ };
42
+ }
43
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
44
+ const round = (v, places = 4) => Math.round(v * 10 ** places) / 10 ** places;
45
+ // --- sizes ---------------------------------------------------------------------------------------------------------------------------------------------
46
+ /** How far the picture of a body reaches past its disc, in its radii: Saturn's rings, the Sun's glow. */
47
+ export const reachOf = (id) => (id === "sun" ? 1.7 : Math.max(1, ringReach(BODIES[id]) * 1.04));
48
+ /** 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. */
49
+ export function sizesFor(ids, mode) {
50
+ if (mode === "true")
51
+ return ids.map((id) => BODIES[id].radiusKm);
52
+ const raw = ids.map((id) => BODIES[id].radiusKm ** 0.3);
53
+ const floor = 0.11 * Math.max(...raw, 0);
54
+ return raw.map((r) => Math.max(r, floor));
55
+ }
56
+ /** Groups: a planet with its moons that are in the scene, or a body alone. */
57
+ function groups(items, moons) {
58
+ const out = [];
59
+ const owner = new Map();
60
+ items.forEach((item, i) => {
61
+ const parent = BODIES[item.body].parent;
62
+ const home = moons === "auto" && parent !== undefined ? owner.get(parent) : undefined;
63
+ if (home !== undefined)
64
+ home.push(i);
65
+ else {
66
+ const group = [i];
67
+ out.push(group);
68
+ owner.set(item.body, group);
69
+ }
70
+ });
71
+ // moons nearest first inside their planet's group
72
+ for (const group of out)
73
+ if (group.length > 1)
74
+ group.splice(1, group.length - 1, ...group.slice(1).sort((a, b) => byDistance(items[a].body, items[b].body)));
75
+ return out;
76
+ }
77
+ /** 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. */
78
+ function unitOf(members, radii, ids) {
79
+ const first = members[0];
80
+ const reachFirst = radii[first] * reachOf(ids[first]);
81
+ const at = [0];
82
+ let cursor = reachFirst;
83
+ let maxX = reachFirst;
84
+ let halfH = reachFirst;
85
+ for (const m of members.slice(1)) {
86
+ const r = radii[m];
87
+ const x = cursor + 0.22 * radii[first] + r;
88
+ at.push(x);
89
+ cursor = x + r;
90
+ maxX = Math.max(maxX, x + r);
91
+ halfH = Math.max(halfH, r);
92
+ }
93
+ return { members, at, minX: -reachFirst, maxX, halfH };
94
+ }
95
+ function arrangeUnits(units, how, aspect) {
96
+ const n = units.length;
97
+ const widths = units.map((u) => u.maxX - u.minX);
98
+ const tallest = Math.max(...units.map((u) => u.halfH * 2), 0);
99
+ const gap = 0.12 * Math.max(...widths, tallest, 1e-9);
100
+ if (how === "grid" && n > 1) {
101
+ const cols = Math.max(1, Math.ceil(Math.sqrt(n * aspect)));
102
+ const cellW = Math.max(...widths) + gap;
103
+ const cellH = tallest + gap;
104
+ return units.map((u, i) => {
105
+ const col = i % cols;
106
+ const row = Math.floor(i / cols);
107
+ const rows = Math.ceil(n / cols);
108
+ return { ox: (col - (cols - 1) / 2) * cellW - (u.minX + u.maxX) / 2, oy: ((rows - 1) / 2 - row) * cellH };
109
+ });
110
+ }
111
+ const total = widths.reduce((a, b) => a + b, 0) + gap * Math.max(0, n - 1);
112
+ let cursor = -total / 2;
113
+ const out = units.map((u, i) => {
114
+ const left = cursor;
115
+ cursor += widths[i] + gap;
116
+ return { ox: left - u.minX, oy: 0 };
117
+ });
118
+ if (how === "arc" && n > 1) {
119
+ const R = total * 0.85;
120
+ for (const o of out)
121
+ o.oy = (o.ox * o.ox) / (2 * R);
122
+ }
123
+ return out;
124
+ }
125
+ /** The camera that holds every body with a margin: its centre and half-height. */
126
+ export function fitCamera(placed, aspect, margin = 1.1, labels = false) {
127
+ if (placed.length === 0)
128
+ return { x: 0, y: 0, h: 1 };
129
+ let x0 = Infinity;
130
+ let x1 = -Infinity;
131
+ let y0 = Infinity;
132
+ let y1 = -Infinity;
133
+ for (const p of placed) {
134
+ x0 = Math.min(x0, p.x - p.reach);
135
+ x1 = Math.max(x1, p.x + p.reach);
136
+ y0 = Math.min(y0, p.y - p.reach);
137
+ y1 = Math.max(y1, p.y + p.reach);
138
+ }
139
+ if (labels)
140
+ y0 -= (y1 - y0) * 0.08;
141
+ const h = Math.max((x1 - x0) / 2 / aspect, (y1 - y0) / 2, 1e-9) * margin;
142
+ return { x: (x0 + x1) / 2, y: (y0 + y1) / 2, h };
143
+ }
144
+ /**
145
+ * 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
146
+ * order) and fits the camera, so its units are the camera's half-height.
147
+ *
148
+ * @param scene - The scene.
149
+ * @returns Every body placed, back to front, and the camera.
150
+ *
151
+ * @example
152
+ * ```ts
153
+ * import { addBody, defaultPortrait, layoutPortrait } from "@johnmorrisdotca/wakusei-planets";
154
+ *
155
+ * const { placed } = layoutPortrait(addBody(addBody(defaultPortrait(), "earth"), "moon"));
156
+ * console.log(placed.map((item) => `${item.body} ${item.x.toFixed(2)}`).join(", "));
157
+ * ```
158
+ */
159
+ export function resolve(scene) {
160
+ const p = scene;
161
+ if (p.layout === "manual" || p.items.length === 0) {
162
+ return { placed: p.items.map((item, index) => ({ ...item, index, reach: item.r * reachOf(item.body) })), camera: p.camera };
163
+ }
164
+ const ids = p.items.map((item) => item.body);
165
+ const sizes = sizesFor(ids, p.scale);
166
+ const grouped = groups(p.items, p.moons);
167
+ const units = grouped.map((members) => unitOf(members, sizes, ids));
168
+ const spots = arrangeUnits(units, p.arrange, p.aspect);
169
+ const raw = p.items.map((item, index) => ({ ...item, index, x: 0, y: 0, r: sizes[index], reach: sizes[index] * reachOf(item.body) }));
170
+ units.forEach((u, k) => {
171
+ u.members.forEach((m, j) => {
172
+ raw[m].x = spots[k].ox + u.at[j];
173
+ raw[m].y = spots[k].oy;
174
+ });
175
+ });
176
+ const cam = fitCamera(raw, p.aspect, 1.1, p.labels);
177
+ const k = 1 / cam.h;
178
+ 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 } };
179
+ }
180
+ // --- changing a scene ----------------------------------------------------------------------------------------------------------------------------------
181
+ const copy = (p) => ({ ...p, items: p.items.map((i) => ({ ...i })), light: { ...p.light }, camera: { ...p.camera } });
182
+ /** A manual layout with every body where the automatic one has it now. */
183
+ export function freeze(p) {
184
+ if (p.layout === "manual")
185
+ return p;
186
+ const { placed, camera } = resolve(p);
187
+ const out = copy(p);
188
+ out.layout = "manual";
189
+ 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 } : {}) }));
190
+ out.camera = camera;
191
+ return out;
192
+ }
193
+ /** The automatic layout again. */
194
+ export function auto(p) {
195
+ return { ...copy(p), layout: "auto" };
196
+ }
197
+ const fresh = (body) => ({ body, x: 0, y: 0, r: 0.3, spin: BODIES[body].showLon ?? 0, turn: 0 });
198
+ /** 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). */
199
+ function unitSize(p, adding) {
200
+ const have = p.items.filter((i) => i.r > 0);
201
+ if (have.length === 0)
202
+ return 0.34 / sizesFor([adding], p.scale)[0];
203
+ const sizes = sizesFor(have.map((i) => i.body), p.scale);
204
+ const k = have.map((i, n) => i.r / sizes[n]).sort((a, b) => a - b);
205
+ return k[Math.floor(k.length / 2)];
206
+ }
207
+ /**
208
+ * 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.
209
+ *
210
+ * @param scene - The scene to add to; it is not changed.
211
+ * @param body - The body's name.
212
+ * @returns A new scene with the body, or the same one when it is already there.
213
+ *
214
+ * @example
215
+ * ```ts
216
+ * import { addBody, defaultPortrait } from "@johnmorrisdotca/wakusei-planets";
217
+ *
218
+ * const scene = addBody(addBody(defaultPortrait(), "saturn"), "earth");
219
+ * console.log(scene.items.map((item) => item.body).join(", "));
220
+ * ```
221
+ */
222
+ export function addBody(scene, body) {
223
+ const p = scene;
224
+ if (p.items.some((i) => i.body === body) || p.items.length >= MAX_ITEMS)
225
+ return p;
226
+ const out = copy(p);
227
+ const item = fresh(body);
228
+ if (p.layout === "auto") {
229
+ item.r = 0.3;
230
+ const at = out.items.findIndex((i) => rankOf(i.body) > rankOf(body));
231
+ out.items.splice(at < 0 ? out.items.length : at, 0, item);
232
+ return out;
233
+ }
234
+ const sized = sizesFor([body], p.scale)[0] * unitSize(p, body);
235
+ item.r = Math.max(0.006, sized);
236
+ const right = out.items.reduce((m, i) => Math.max(m, i.x + i.r), -Infinity);
237
+ item.x = out.items.length === 0 ? out.camera.x : right + item.r * 1.4 + 0.04;
238
+ item.y = out.items.length === 0 ? out.camera.y : out.items[out.items.length - 1].y;
239
+ out.items.push(item);
240
+ 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;
241
+ if (!visible)
242
+ out.camera = fitCamera(resolve(out).placed, out.aspect, 1.1, out.labels);
243
+ return out;
244
+ }
245
+ export function removeBody(p, body) {
246
+ if (!p.items.some((i) => i.body === body))
247
+ return p;
248
+ const out = copy(p);
249
+ out.items = out.items.filter((i) => i.body !== body);
250
+ return out;
251
+ }
252
+ /** Add the moons of a planet that are not in the scene yet, in orbit order. */
253
+ export function addMoons(p, planet) {
254
+ const moons = planet in MOONS_OF ? [...MOONS_OF[planet]].sort(byDistance) : [];
255
+ let out = p;
256
+ for (const moon of moons)
257
+ out = addBody(out, moon);
258
+ if (out === p || p.layout === "auto")
259
+ return out;
260
+ // manual: lay the new moons out round their planet the way the automatic layout does
261
+ const host = out.items.find((i) => i.body === planet);
262
+ if (host === undefined)
263
+ return out;
264
+ const copyOut = copy(out);
265
+ let cursor = host.x + host.r * reachOf(host.body);
266
+ for (const moon of moons) {
267
+ const item = copyOut.items.find((i) => i.body === moon);
268
+ if (item === undefined || p.items.some((i) => i.body === moon))
269
+ continue;
270
+ item.x = cursor + 0.22 * host.r + item.r;
271
+ item.y = host.y;
272
+ cursor = item.x + item.r;
273
+ }
274
+ return copyOut;
275
+ }
276
+ /** Move one body to a new place in the list: the front is the end. */
277
+ export function moveInOrder(p, from, to) {
278
+ const target = clamp(to, 0, p.items.length - 1);
279
+ if (from === target || from < 0 || from >= p.items.length)
280
+ return p;
281
+ const out = copy(p);
282
+ const [item] = out.items.splice(from, 1);
283
+ out.items.splice(target, 0, item);
284
+ return out;
285
+ }
286
+ /** Change one body's fields. */
287
+ export function setItem(p, index, change) {
288
+ if (index < 0 || index >= p.items.length)
289
+ return p;
290
+ const out = copy(p);
291
+ out.items[index] = { ...out.items[index], ...change };
292
+ if (out.items[index].tilt === undefined)
293
+ delete out.items[index].tilt;
294
+ if (out.items[index].pitch === undefined)
295
+ delete out.items[index].pitch;
296
+ return out;
297
+ }
298
+ /** Change the portrait's own settings. A change to manual layout keeps every body where it is on the page. */
299
+ export function setPortrait(p, change) {
300
+ let out = copy(p);
301
+ if (change.layout === "manual")
302
+ out = freeze(out);
303
+ out = { ...out, ...change, light: { ...(change.light ?? out.light) }, camera: { ...(change.camera ?? out.camera) } };
304
+ out.aspect = clamp(out.aspect, 0.25, 4);
305
+ return out;
306
+ }
307
+ /** 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). */
308
+ export function rescale(p, mode) {
309
+ const out = copy(p);
310
+ out.scale = mode;
311
+ if (p.items.length === 0 || p.layout === "auto")
312
+ return out;
313
+ const largest = p.items.reduce((m, i) => (i.r > m.r ? i : m), p.items[0]);
314
+ const base = sizesFor([largest.body], mode)[0];
315
+ const sizes = sizesFor(p.items.map((i) => i.body), mode);
316
+ out.items.forEach((item, i) => {
317
+ item.r = Math.max(0.004, (sizes[i] / base) * largest.r);
318
+ });
319
+ return out;
320
+ }
321
+ /** Move the camera to the middle of the bodies, keeping its zoom. */
322
+ export function centreCamera(p) {
323
+ if (p.layout === "auto")
324
+ return p;
325
+ const fit = fitCamera(resolve(p).placed, p.aspect, 1.1, p.labels);
326
+ return { ...copy(p), camera: { ...p.camera, x: fit.x, y: fit.y } };
327
+ }
328
+ /** Zoom out or in so every body is in the picture. */
329
+ export function fitAll(p) {
330
+ if (p.layout === "auto")
331
+ return p;
332
+ return { ...copy(p), camera: fitCamera(resolve(p).placed, p.aspect, 1.1, p.labels) };
333
+ }
334
+ // --- looking at the picture -----------------------------------------------------------------------------------------------------------------------------
335
+ /** The scene's units to pixels: a body at the camera's centre is in the middle of a picture `width` by `height`. */
336
+ export function toScreen(camera, width, height) {
337
+ const scale = height / (2 * camera.h);
338
+ return { px: (x) => width / 2 + (x - camera.x) * scale, py: (y) => height / 2 - (y - camera.y) * scale, scale };
339
+ }
340
+ /** The inverse: a pixel to the scene's units. */
341
+ export function fromScreen(camera, width, height, x, y) {
342
+ const scale = height / (2 * camera.h);
343
+ return { x: (x - width / 2) / scale + camera.x, y: -(y - height / 2) / scale + camera.y };
344
+ }
345
+ /** The index of the body under a point (scene units), front first; a body smaller than `slop` is still hit within it. */
346
+ export function hitTest(placed, x, y, slop = 0) {
347
+ for (let i = placed.length - 1; i >= 0; i -= 1) {
348
+ const p = placed[i];
349
+ if (Math.hypot(x - p.x, y - p.y) <= Math.max(p.r, slop))
350
+ return i;
351
+ }
352
+ return -1;
353
+ }
354
+ /** 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). */
355
+ export function snap(moving, others, centre, tolerance) {
356
+ const best = (own, targets) => {
357
+ let found;
358
+ for (const t of targets)
359
+ for (const o of own)
360
+ if (Math.abs(t - o) <= tolerance && (found === undefined || Math.abs(t - o) < Math.abs(found.delta)))
361
+ found = { delta: t - o, at: t };
362
+ return found;
363
+ };
364
+ const xs = [centre.x, ...others.flatMap((o) => [o.x - o.r, o.x, o.x + o.r])];
365
+ const ys = [centre.y, ...others.flatMap((o) => [o.y - o.r, o.y, o.y + o.r])];
366
+ const sx = best([moving.x - moving.r, moving.x, moving.x + moving.r], xs);
367
+ const sy = best([moving.y - moving.r, moving.y, moving.y + moving.r], ys);
368
+ const guides = [];
369
+ if (sx !== undefined)
370
+ guides.push({ axis: "x", at: sx.at });
371
+ if (sy !== undefined)
372
+ guides.push({ axis: "y", at: sy.at });
373
+ return { x: moving.x + (sx?.delta ?? 0), y: moving.y + (sy?.delta ?? 0), guides };
374
+ }
375
+ /** The Sun's direction as a unit vector in the picture's frame (x right, y up, z toward the viewer). */
376
+ export function lightVector(light) {
377
+ const az = (light.az * Math.PI) / 180;
378
+ const el = (light.el * Math.PI) / 180;
379
+ return [Math.sin(az) * Math.cos(el), Math.cos(az) * Math.cos(el), Math.sin(el)];
380
+ }
381
+ /** 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. */
382
+ export function createHistory(initial, limit = 100) {
383
+ const past = [];
384
+ let future = [];
385
+ let now = initial;
386
+ return {
387
+ get current() {
388
+ return now;
389
+ },
390
+ get canUndo() {
391
+ return past.length > 0;
392
+ },
393
+ get canRedo() {
394
+ return future.length > 0;
395
+ },
396
+ push(next) {
397
+ if (next === now)
398
+ return;
399
+ past.push(now);
400
+ if (past.length > limit)
401
+ past.shift();
402
+ future = [];
403
+ now = next;
404
+ },
405
+ replace(next) {
406
+ now = next;
407
+ future = [];
408
+ },
409
+ undo() {
410
+ const back = past.pop();
411
+ if (back !== undefined) {
412
+ future.push(now);
413
+ now = back;
414
+ }
415
+ return now;
416
+ },
417
+ redo() {
418
+ const forward = future.pop();
419
+ if (forward !== undefined) {
420
+ past.push(now);
421
+ now = forward;
422
+ }
423
+ return now;
424
+ },
425
+ };
426
+ }
427
+ // --- the address --------------------------------------------------------------------------------------------------------------------------------------
428
+ const LOOKS = ["line", "realistic", "painted"];
429
+ const BACKGROUNDS = ["black", "stars", "gradient", "transparent"];
430
+ const ARRANGES = ["row", "arc", "grid", "orbit"];
431
+ const num = (value, fallback) => {
432
+ if (value === undefined || value === "")
433
+ return fallback;
434
+ const n = Number(value);
435
+ return Number.isFinite(n) ? n : fallback;
436
+ };
437
+ const optional = (value) => {
438
+ if (value === undefined || value === "")
439
+ return undefined;
440
+ const n = Number(value);
441
+ return Number.isFinite(n) ? n : undefined;
442
+ };
443
+ /**
444
+ * 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.
445
+ *
446
+ * @param scene - The scene.
447
+ * @returns The text.
448
+ *
449
+ * @example
450
+ * ```ts
451
+ * import { addBody, defaultPortrait, encodeScene } from "@johnmorrisdotca/wakusei-planets";
452
+ *
453
+ * console.log(encodeScene(addBody(defaultPortrait(), "mars")));
454
+ * ```
455
+ */
456
+ export function encodeScene(scene) {
457
+ const p = scene;
458
+ const d = defaultPortrait();
459
+ const fields = [];
460
+ if (p.layout !== d.layout)
461
+ fields.push(`layout:${p.layout}`);
462
+ if (p.arrange !== d.arrange)
463
+ fields.push(`arrange:${p.arrange}`);
464
+ if (p.scale !== d.scale)
465
+ fields.push(`scale:${p.scale}`);
466
+ if (p.moons !== d.moons)
467
+ fields.push(`moons:${p.moons}`);
468
+ if (p.look !== d.look)
469
+ fields.push(`look:${p.look}`);
470
+ if (p.bg !== d.bg)
471
+ fields.push(`bg:${p.bg}`);
472
+ if (round(p.aspect, 3) !== round(d.aspect, 3))
473
+ fields.push(`ratio:${round(p.aspect, 3)}`);
474
+ if (p.light.az !== d.light.az || p.light.el !== d.light.el)
475
+ fields.push(`light:${round(p.light.az, 1)},${round(p.light.el, 1)}`);
476
+ if (!p.labels)
477
+ fields.push("labels:0");
478
+ if (!p.orbits)
479
+ fields.push("orbits:0");
480
+ if (!p.shadows)
481
+ fields.push("shadows:0");
482
+ if (p.date !== undefined)
483
+ fields.push(`date:${p.date}`);
484
+ if (p.layout === "manual")
485
+ fields.push(`cam:${round(p.camera.x)},${round(p.camera.y)},${round(p.camera.h)}`);
486
+ for (const i of p.items) {
487
+ const manual = p.layout === "manual";
488
+ 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("_"));
489
+ }
490
+ return fields.join("~");
491
+ }
492
+ /**
493
+ * 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.
494
+ *
495
+ * @param text - Text from `encodeScene`, or anything.
496
+ * @returns The scene.
497
+ *
498
+ * @example
499
+ * ```ts
500
+ * import { decodeScene } from "@johnmorrisdotca/wakusei-planets";
501
+ *
502
+ * console.log(decodeScene("look:painted~jupiter_0_0_0.3_0").items[0]?.body);
503
+ * ```
504
+ */
505
+ export function decodeScene(text) {
506
+ const p = defaultPortrait();
507
+ const items = [];
508
+ for (const field of text.split("~")) {
509
+ if (field === "")
510
+ continue;
511
+ const colon = field.indexOf(":");
512
+ if (colon > 0) {
513
+ const key = field.slice(0, colon);
514
+ const value = field.slice(colon + 1);
515
+ if (key === "layout")
516
+ p.layout = value === "manual" ? "manual" : "auto";
517
+ else if (key === "arrange" && ARRANGES.includes(value))
518
+ p.arrange = value;
519
+ else if (key === "scale")
520
+ p.scale = value === "true" ? "true" : "readable";
521
+ else if (key === "moons")
522
+ p.moons = value === "manual" ? "manual" : "auto";
523
+ else if (key === "look" && LOOKS.includes(value))
524
+ p.look = value;
525
+ else if (key === "bg" && BACKGROUNDS.includes(value))
526
+ p.bg = value;
527
+ else if (key === "ratio")
528
+ p.aspect = clamp(num(value, p.aspect), 0.25, 4);
529
+ else if (key === "light") {
530
+ const [az, el] = value.split(",");
531
+ p.light = { az: ((num(az, p.light.az) % 360) + 360) % 360, el: clamp(num(el, p.light.el), 0, 90) };
532
+ }
533
+ else if (key === "labels")
534
+ p.labels = value !== "0";
535
+ else if (key === "orbits")
536
+ p.orbits = value !== "0";
537
+ else if (key === "shadows")
538
+ p.shadows = value !== "0";
539
+ else if (key === "date")
540
+ p.date = Number.isFinite(Date.parse(value)) ? value : undefined;
541
+ else if (key === "cam") {
542
+ const [x, y, h] = value.split(",");
543
+ p.camera = { x: num(x, 0), y: num(y, 0), h: Math.max(1e-6, num(h, 1)) };
544
+ }
545
+ continue;
546
+ }
547
+ const [body, x, y, r, spin, tilt, pitch, turn] = field.split("_");
548
+ if (body === undefined || !(body in BODIES) || items.some((i) => i.body === body) || items.length >= MAX_ITEMS)
549
+ continue;
550
+ 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) };
551
+ const t = optional(tilt);
552
+ const pi = optional(pitch);
553
+ if (t !== undefined)
554
+ item.tilt = t;
555
+ if (pi !== undefined)
556
+ item.pitch = clamp(pi, -90, 90);
557
+ items.push(item);
558
+ }
559
+ p.items = items;
560
+ return p;
561
+ }
562
+ const fromList = (ids, change = {}) => ids.reduce((p, id) => addBody(p, id), { ...defaultPortrait(), ...change });
563
+ /**
564
+ * Ready-made scenes to start from: a family photo of the Sun and the planets, the gas giants to scale, Jupiter and the Galileans, and the Earth and the Moon to scale.
565
+ *
566
+ * @example
567
+ * ```ts
568
+ * import { PORTRAIT_PRESETS } from "@johnmorrisdotca/wakusei-planets";
569
+ *
570
+ * console.log(PORTRAIT_PRESETS.map((preset) => preset.id).join(", "));
571
+ * ```
572
+ */
573
+ export const PRESETS = [
574
+ { id: "family", name: { en: "Family photo", ja: "家族写真" }, scene: fromList(["sun", ...PLANET_IDS], { arrange: "arc", scale: "readable", moons: "manual", orbits: false }) },
575
+ { id: "giants", name: { en: "Gas giants", ja: "巨大ガス惑星" }, scene: fromList(["jupiter", "saturn", "uranus", "neptune"], { arrange: "row", scale: "true", moons: "manual", bg: "gradient" }) },
576
+ { id: "galileans", name: { en: "Jupiter and the Galileans", ja: "木星とガリレオ衛星" }, scene: fromList(["jupiter", "io", "europa", "ganymede", "callisto"], { arrange: "row", scale: "readable", moons: "auto", orbits: true }) },
577
+ { id: "earthmoon", name: { en: "Earth and Moon to scale", ja: "地球と月 (大きさの比)" }, scene: fromList(["earth", "moon"], { arrange: "row", scale: "true", moons: "auto", orbits: true }) },
578
+ ];
@@ -0,0 +1,50 @@
1
+ import { type Look } from "../facts.ts";
2
+ import { type Background, type Guide, type Portrait, type Resolved } from "./model.ts";
3
+ export interface Shapes {
4
+ circles: {
5
+ cx: number;
6
+ cy: number;
7
+ r: number;
8
+ kind: "orbit" | "select";
9
+ }[];
10
+ lines: {
11
+ x1: number;
12
+ y1: number;
13
+ x2: number;
14
+ y2: number;
15
+ }[];
16
+ texts: {
17
+ x: number;
18
+ y: number;
19
+ text: string;
20
+ size: number;
21
+ }[];
22
+ }
23
+ export interface Star {
24
+ x: number;
25
+ y: number;
26
+ /** Radius as a fraction of the picture's height. */
27
+ r: number;
28
+ a: number;
29
+ }
30
+ /** The sky's stars: the same ones every time, so a picture and its export agree. */
31
+ export declare function starfield(count?: number): Star[];
32
+ /** The CSS behind the picture for a background (the stars are separate gradients, so the page draws them without a script). */
33
+ export declare function backgroundCss(bg: Background): string;
34
+ /** Paint the background onto a 2D canvas the size of the picture. */
35
+ export declare function paintBackground(ctx: CanvasRenderingContext2D, bg: Background, width: number, height: number): void;
36
+ /** The orbit rings and the names for a picture of this size, and the selected body's outline and the guides when asked. */
37
+ export declare function shapesOf(p: Portrait, resolved: Resolved, width: number, height: number, lang?: "en" | "ja", edit?: {
38
+ selected?: number;
39
+ guides?: readonly Guide[];
40
+ }): Shapes;
41
+ /** The shapes as SVG elements (no wrapper), in the picture's pixels. */
42
+ export declare function shapesSvg(s: Shapes): string;
43
+ /** The shapes on a 2D canvas. */
44
+ export declare function drawShapes(ctx: CanvasRenderingContext2D, s: Shapes, ratio?: number): void;
45
+ /**
46
+ * The portrait in the line look: one SVG, the bodies drawn one over another in the order of the list, each as a nested picture sized so its disc has the radius the layout gave it.
47
+ * Anything a body's rings or glow need past the disc is inside its own picture.
48
+ */
49
+ export declare function lineScene(p: Portrait, resolved: Resolved, width: number, height: number, poses: readonly import("../ephemeris.ts").Pose[], lang?: "en" | "ja"): string;
50
+ export type { Look };