@johnmorrisdotca/uchusen-rockets 0.10.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.
@@ -0,0 +1,447 @@
1
+ import { AU_KM, C, DAY_S, G0, GM_KM3_S2, PARKING_ALTITUDE_KM, PERIOD_DAYS } from "./constants.js";
2
+ import { accelerating, circularSpeed, escapeBurn, flipAndBurn, hohmann, transferPoint, wrapPi } from "./physics.js";
3
+ const SCREEN = { preheat: 6, ignition: 3, ascent: 7, burn: 6, coast: 22, flip: 4, brake: 7, accelerate: 22, decelerate: 22, arrival: 3 };
4
+ const ARRIVAL_S = 5;
5
+ const stopOf = (source, id) => {
6
+ const stop = source.stops.find((one) => one.id === id);
7
+ if (!stop)
8
+ throw new Error(`No such place: ${id}`);
9
+ return stop;
10
+ };
11
+ const muOf = (source, id) => stopOf(source, id).muKm3s2 ?? GM_KM3_S2[id] ?? NaN;
12
+ const periodOf = (source, id) => stopOf(source, id).periodDays ?? PERIOD_DAYS[id] ?? NaN;
13
+ /** A place's position in a frame, km: round the Sun for a planet, round the Earth for the Moon (and the Earth's own is the origin of the Earth's frame). */
14
+ export function positionKm(source, id, ms, frame) {
15
+ if (frame === "earth") {
16
+ if (id === "earth")
17
+ return [0, 0, 0];
18
+ return source.offsetKm(id, ms);
19
+ }
20
+ const au = source.positionAu(id, ms);
21
+ return [au[0] * AU_KM, au[1] * AU_KM, au[2] * AU_KM];
22
+ }
23
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
24
+ const len = (a) => Math.hypot(a[0], a[1], a[2]);
25
+ const flat = (a) => Math.hypot(a[0], a[1]);
26
+ const lon = (a) => Math.atan2(a[1], a[0]);
27
+ const quantity = (q, name, estimated) => {
28
+ if (!q)
29
+ return null;
30
+ if (q.estimate !== undefined)
31
+ estimated.push(name);
32
+ return q.value;
33
+ };
34
+ /** Parking orbit radius round a body, km. */
35
+ const parkRadius = (source, id) => stopOf(source, id).radiusKm + (id === "earth" ? PARKING_ALTITUDE_KM.earth : id === "moon" ? PARKING_ALTITUDE_KM.moon : PARKING_ALTITUDE_KM.other);
36
+ /** The transfer from a planet to another that leaves at a time: the radii at the two ends, the flight time (two passes, since the end moves), and how far short of 180 degrees the arrival is. */
37
+ function transferAt(source, from, to, t0) {
38
+ const p1 = positionKm(source, from, t0, "sun");
39
+ const r1 = flat(p1);
40
+ const mu = GM_KM3_S2.sun;
41
+ let p2 = positionKm(source, to, t0, "sun");
42
+ let seconds = hohmann(mu, r1, flat(p2)).seconds;
43
+ for (let i = 0; i < 4; i += 1) {
44
+ p2 = positionKm(source, to, t0 + seconds * 1000, "sun");
45
+ seconds = hohmann(mu, r1, flat(p2)).seconds;
46
+ }
47
+ const r2 = flat(p2);
48
+ const h = hohmann(mu, r1, r2);
49
+ return { r1, r2, seconds, theta0: lon(p1), f: wrapPi(lon(p2) - lon(p1) - Math.PI), dv1: h.dv1, dv2: h.dv2 };
50
+ }
51
+ /**
52
+ * The next dates a craft can leave one planet for another on a Hohmann transfer: the days on which the destination will be where the craft arrives. Found by scanning day by day and bisecting each crossing, from the planets' real positions.
53
+ * @param source Where the planets are.
54
+ * @param from The starting planet's id.
55
+ * @param to The destination planet's id.
56
+ * @param afterMs The earliest date, milliseconds since 1970.
57
+ * @param count How many windows.
58
+ * @returns The windows, soonest first.
59
+ * @example
60
+ * ```ts no-run
61
+ * import { windows, type BodySource } from "@johnmorrisdotca/uchusen-rockets/journey";
62
+ *
63
+ * declare const source: BodySource;
64
+ * console.log(windows(source, "earth", "mars", Date.UTC(2026, 10, 4), 2).map((one) => new Date(one.dateMs).toISOString().slice(0, 10)));
65
+ * ```
66
+ */
67
+ export function windows(source, from, to, afterMs, count) {
68
+ const synodic = 1 / Math.abs(1 / periodOf(source, from) - 1 / periodOf(source, to));
69
+ const end = afterMs + (count + 1) * synodic * DAY_S * 1000;
70
+ const found = [];
71
+ let t = afterMs;
72
+ let previous = transferAt(source, from, to, t);
73
+ while (t < end && found.length < count) {
74
+ const next = t + DAY_S * 1000;
75
+ const current = transferAt(source, from, to, next);
76
+ if (previous.f * current.f < 0 && Math.abs(previous.f) < 1.5 && Math.abs(current.f) < 1.5) {
77
+ let lo = t;
78
+ let hi = next;
79
+ let flo = previous.f;
80
+ for (let i = 0; i < 24; i += 1) {
81
+ const mid = (lo + hi) / 2;
82
+ const fm = transferAt(source, from, to, mid).f;
83
+ if (flo * fm <= 0)
84
+ hi = mid;
85
+ else {
86
+ lo = mid;
87
+ flo = fm;
88
+ }
89
+ }
90
+ const at = transferAt(source, from, to, hi);
91
+ if (hi >= afterMs)
92
+ found.push({ dateMs: Math.round(hi / 60_000) * 60_000, days: at.seconds / DAY_S, dv1: at.dv1, dv2: at.dv2 });
93
+ }
94
+ previous = current;
95
+ t = next;
96
+ }
97
+ return found;
98
+ }
99
+ /** The energy (km^2/s^2, the speed squared at infinity) each kind of payload figure is for. Lower is easier. */
100
+ export function c3Of(source, kind) {
101
+ const mu = muOf(source, "earth");
102
+ const r = parkRadius(source, "earth");
103
+ if (kind === "leo")
104
+ return -mu / r;
105
+ if (kind === "gto")
106
+ return -mu / ((r + 42_164) / 2);
107
+ const moon = 384_400;
108
+ const h = hohmann(mu, r, moon);
109
+ if (kind === "tli")
110
+ return (circularSpeed(mu, r) + h.dv1) ** 2 - (2 * mu) / r;
111
+ const sun = hohmann(GM_KM3_S2.sun, AU_KM, 1.52371034 * AU_KM);
112
+ return sun.dv1 ** 2;
113
+ }
114
+ const KIND_OF = { moon: "tli", mars: "mars" };
115
+ const WORDS = { leo: "low Earth orbit", gto: "geostationary transfer orbit", tli: "the Moon", mars: "Mars" };
116
+ const tonnes = (kg) => `${(kg / 1000).toLocaleString("en-US", { maximumFractionDigits: 1 })} t`;
117
+ /** What a ballistic craft can carry on a path of this energy: a published figure for it, or for a harder one (at least), or none. */
118
+ function capacityVerdict(source, craft, to, c3) {
119
+ const kind = KIND_OF[to];
120
+ const direct = kind ? craft.capacityKg[kind] : undefined;
121
+ if (kind && direct)
122
+ return { level: "rated", kg: direct.value, line: `Rated: ${tonnes(direct.value)} to ${WORDS[kind]}.` };
123
+ let best = null;
124
+ for (const [name, figure] of Object.entries(craft.capacityKg)) {
125
+ const energy = c3Of(source, name);
126
+ if (energy >= c3 - 0.01 && (best === null || energy < best.c3))
127
+ best = { kind: name, kg: figure.value, c3: energy };
128
+ }
129
+ if (best && best.kind !== "leo" && best.kind !== "gto")
130
+ return { level: "at-least", kg: best.kg, line: `Can do it: at least ${tonnes(best.kg)}, because it can send that much to ${WORDS[best.kind]}, which takes more energy.` };
131
+ return { level: "not-rated", line: `Not rated: no published payload figure covers a trip to ${stopName(source, to)}.` };
132
+ }
133
+ const stopName = (source, id) => stopOf(source, id).name.en;
134
+ const phase = (id, startS, endS, screenS) => ({ id, startS, endS, screenS });
135
+ const unavailable = (craft, from, to, departMs, line, because) => ({
136
+ from,
137
+ to,
138
+ craftId: craft.id,
139
+ drive: craft.drive,
140
+ frame: to === "moon" ? "earth" : "sun",
141
+ departMs,
142
+ liftoffBeforeS: 0,
143
+ phases: [],
144
+ totalS: 0,
145
+ verdict: { level: "cannot", line, because, playable: false },
146
+ estimated: [],
147
+ fuel: { usedT: null, carriedT: null, note: "No trip." },
148
+ payload: { kg: null, atLeast: false, note: "No trip." },
149
+ straightKm: 0,
150
+ pathKm: 0,
151
+ dv: { departure: 0, arrival: 0, total: 0 },
152
+ peakG: 0,
153
+ peakSpeed: 0,
154
+ peakGamma: 1,
155
+ shipS: 0,
156
+ frameS: 0,
157
+ });
158
+ /**
159
+ * Plans a trip: the transfer or the flip and burn, the burns, the times, and what the craft can and cannot do. A craft that cannot make the trip comes back with a verdict that says why in one line and no phases.
160
+ * @param source Where the places are.
161
+ * @param craft The craft.
162
+ * @param from The starting place's id.
163
+ * @param to The destination's id.
164
+ * @param departMs When the departure burn begins, milliseconds since 1970 (for a ballistic trip to a planet, one of `windows`).
165
+ * @returns The planned trip.
166
+ * @example
167
+ * ```ts no-run
168
+ * import { craftOf, planTrip, type BodySource } from "@johnmorrisdotca/uchusen-rockets/journey";
169
+ *
170
+ * declare const source: BodySource;
171
+ * const trip = planTrip(source, craftOf("saturn-v")!, "earth", "moon", Date.UTC(2026, 10, 5));
172
+ * console.log(trip.verdict.line, Math.round(trip.dv.departure));
173
+ * ```
174
+ */
175
+ export function planTrip(source, craft, from, to, departMs) {
176
+ if (from === to)
177
+ return unavailable(craft, from, to, departMs, "Pick two different places.", []);
178
+ return craft.drive === "ballistic" ? planBallistic(source, craft, from, to, departMs) : planContinuous(source, craft, from, to, departMs);
179
+ }
180
+ function planBallistic(source, craft, from, to, departMs) {
181
+ if (from !== "earth")
182
+ return unavailable(craft, from, to, departMs, "Rockets in this version lift off from the Earth only.", ["A rocket's payload figures are for launches from the Earth."]);
183
+ if (to === "sun")
184
+ return unavailable(craft, from, to, departMs, "No transfer is planned to the Sun.", []);
185
+ const estimated = [];
186
+ const toMoon = to === "moon";
187
+ const frame = toMoon ? "earth" : "sun";
188
+ const muEarth = muOf(source, "earth");
189
+ const rPark = parkRadius(source, "earth");
190
+ const parkSpeed = circularSpeed(muEarth, rPark);
191
+ let mu;
192
+ let r1;
193
+ let r2;
194
+ let transitS;
195
+ let theta0;
196
+ let vInf1;
197
+ let vInf2;
198
+ let burnEndSpeed;
199
+ let c3;
200
+ let straightKm;
201
+ if (toMoon) {
202
+ mu = muEarth;
203
+ r1 = rPark;
204
+ transitS = 0;
205
+ let moon = positionKm(source, "moon", departMs, "earth");
206
+ r2 = flat(moon);
207
+ for (let i = 0; i < 4; i += 1) {
208
+ transitS = hohmann(mu, r1, r2).seconds;
209
+ moon = positionKm(source, "moon", departMs + transitS * 1000, "earth");
210
+ r2 = flat(moon);
211
+ }
212
+ transitS = hohmann(mu, r1, r2).seconds;
213
+ theta0 = lon(moon) - Math.PI;
214
+ const h = hohmann(mu, r1, r2);
215
+ burnEndSpeed = parkSpeed + h.dv1;
216
+ c3 = burnEndSpeed ** 2 - (2 * mu) / r1;
217
+ vInf1 = Math.sqrt(Math.max(0, c3));
218
+ vInf2 = Math.abs(circularSpeed(mu, r2) - Math.sqrt(mu * (2 / r2 - 2 / (r1 + r2))));
219
+ straightKm = len(positionKm(source, "moon", departMs, "earth"));
220
+ }
221
+ else {
222
+ mu = GM_KM3_S2.sun;
223
+ const t = transferAt(source, from, to, departMs);
224
+ r1 = t.r1;
225
+ r2 = t.r2;
226
+ transitS = t.seconds;
227
+ theta0 = t.theta0;
228
+ vInf1 = t.dv1;
229
+ vInf2 = t.dv2;
230
+ burnEndSpeed = Math.sqrt(vInf1 ** 2 + (2 * muEarth) / rPark);
231
+ c3 = vInf1 ** 2;
232
+ straightKm = len(sub(positionKm(source, to, departMs, "sun"), positionKm(source, "earth", departMs, "sun")));
233
+ }
234
+ const dv1 = burnEndSpeed - parkSpeed;
235
+ const captureKm = parkRadius(source, to);
236
+ const muTo = muOf(source, to);
237
+ const captureSpeedIn = Math.sqrt(vInf2 ** 2 + (2 * muTo) / captureKm);
238
+ const captureSpeedOut = circularSpeed(muTo, captureKm);
239
+ const dv2 = escapeBurn(muTo, captureKm, vInf2);
240
+ const verdictCap = capacityVerdict(source, craft, to, c3);
241
+ const payload = craft.payload;
242
+ const thrust = quantity(payload.thrustN, "braking engine thrust", estimated);
243
+ const mass = quantity(payload.massKg, "payload mass", estimated);
244
+ const brakeG = thrust !== null && mass !== null ? thrust / mass / G0 : quantity(payload.brakeG, "braking acceleration", estimated) ?? 0.2;
245
+ const dvAvailable = payload.dvMs?.value ?? null;
246
+ const flyby = dvAvailable !== null && dvAvailable < dv2 * 1000;
247
+ const ascentS = quantity(craft.ascentS, "time to orbit", estimated) ?? 540;
248
+ let burnS = quantity(craft.departureBurnS, "departure burn length", estimated);
249
+ if (burnS === null) {
250
+ burnS = (dv1 * 1000) / (0.6 * G0);
251
+ estimated.push("departure burn length");
252
+ }
253
+ const flipS = quantity(craft.flipS, "turn-round time", estimated) ?? 120;
254
+ const brakeS = flyby ? 0 : (dv2 * 1000) / (brakeG * G0);
255
+ const arcSteps = 360;
256
+ const arc = [0];
257
+ let last = transferPoint(mu, r1, r2, theta0, 0);
258
+ for (let i = 1; i <= arcSteps; i += 1) {
259
+ const p = transferPoint(mu, r1, r2, theta0, (transitS * i) / arcSteps);
260
+ arc.push(arc[i - 1] + Math.hypot(p.r * Math.cos(p.theta) - last.r * Math.cos(last.theta), p.r * Math.sin(p.theta) - last.r * Math.sin(last.theta)));
261
+ last = p;
262
+ }
263
+ const pathKm = arc[arcSteps];
264
+ const t = { ascent: 0, burn: ascentS, coast: ascentS + burnS, flip: ascentS + burnS + transitS - flipS, brake: ascentS + burnS + transitS };
265
+ const phases = [
266
+ phase("preheat", -13, -3, SCREEN.preheat),
267
+ phase("ignition", -3, 0, SCREEN.ignition),
268
+ phase("ascent", t.ascent, t.burn, SCREEN.ascent),
269
+ phase("burn", t.burn, t.coast, SCREEN.burn),
270
+ phase("coast", t.coast, t.flip, SCREEN.coast),
271
+ phase("flip", t.flip, t.brake, SCREEN.flip),
272
+ ];
273
+ let end = t.brake;
274
+ if (!flyby) {
275
+ phases.push(phase("brake", end, end + brakeS, SCREEN.brake));
276
+ end += brakeS;
277
+ }
278
+ else
279
+ phases.push(phase("flyby", end, end + 60, SCREEN.brake));
280
+ if (flyby)
281
+ end += 60;
282
+ phases.push(phase("arrival", end, end + ARRIVAL_S, SCREEN.arrival));
283
+ end += ARRIVAL_S;
284
+ const peakSpeed = Math.max(burnEndSpeed, captureSpeedIn) * 1000;
285
+ const peakG = Math.max((dv1 * 1000) / burnS / G0, flyby ? 0 : brakeG);
286
+ let verdict;
287
+ const because = [verdictCap.line];
288
+ if (flyby) {
289
+ because.push(`${payload.name} can change its speed by ${Math.round(dvAvailable).toLocaleString("en-US")} m/s; stopping at ${stopName(source, to)} needs ${Math.round(dv2 * 1000).toLocaleString("en-US")} m/s.`);
290
+ verdict = { level: "flyby", line: `Flyby only: not enough fuel on board to stop at ${stopName(source, to)} (${Math.round(dv2 * 1000).toLocaleString("en-US")} m/s needed, ${Math.round(dvAvailable).toLocaleString("en-US")} m/s on board).`, because, playable: true };
291
+ }
292
+ else {
293
+ verdict = { level: verdictCap.level, line: verdictCap.line, because, playable: true };
294
+ if (verdictCap.level === "not-rated")
295
+ because.push("The burns below are worked from the physics; nothing says this craft can lift them.");
296
+ }
297
+ if (estimated.length > 0)
298
+ because.push(`Estimated, not from a source: ${[...new Set(estimated)].join(", ")}.`);
299
+ const isp = payload.ispS?.value ?? null;
300
+ const braking = flyby ? 0 : dv2 * 1000;
301
+ const fuel = isp !== null && mass !== null && !flyby
302
+ ? { usedT: (mass * (1 - Math.exp(-braking / (isp * G0)))) / 1000, carriedT: null, note: "the braking burn only, from the engine's specific impulse and the payload's mass" }
303
+ : { usedT: null, carriedT: null, note: flyby ? "it does not brake, so it burns no propellant at the far end" : "no source gives the specific impulse of this payload's engine" };
304
+ const toName = stopName(source, to);
305
+ const carries = verdictCap.kg !== undefined
306
+ ? { kg: verdictCap.kg, atLeast: verdictCap.level === "at-least", note: `Payload to ${toName}` }
307
+ : { kg: null, atLeast: false, note: `no published payload figure covers a trip to ${toName}` };
308
+ return {
309
+ from,
310
+ to,
311
+ craftId: craft.id,
312
+ drive: "ballistic",
313
+ frame,
314
+ departMs,
315
+ liftoffBeforeS: ascentS,
316
+ phases,
317
+ totalS: end,
318
+ verdict,
319
+ estimated: [...new Set(estimated)],
320
+ straightKm,
321
+ pathKm,
322
+ fuel,
323
+ payload: carries,
324
+ dv: { departure: dv1 * 1000, arrival: flyby ? 0 : dv2 * 1000, total: (dv1 + (flyby ? 0 : dv2)) * 1000 },
325
+ peakG,
326
+ peakSpeed,
327
+ peakGamma: 1,
328
+ shipS: end,
329
+ frameS: end,
330
+ ballistic: { mu, r1, r2, theta0, transitS, parkKm: rPark, parkSpeed, burnEndSpeed, captureKm, captureSpeedIn, captureSpeedOut, burnS, brakeS, flipS, ascentS, vInfDeparture: vInf1, vInfArrival: vInf2, arc, c3 },
331
+ };
332
+ }
333
+ function planContinuous(source, craft, from, to, departMs) {
334
+ const estimated = [];
335
+ const frame = from === "moon" || to === "moon" ? "earth" : "sun";
336
+ if ((from === "moon" || to === "moon") && from !== "earth" && to !== "earth")
337
+ return unavailable(craft, from, to, departMs, "The Moon can be reached from the Earth only, for now.", []);
338
+ const a = craft.g.value * G0;
339
+ const flipS = quantity(craft.flipS, "turn-round time", estimated) ?? 600;
340
+ const startAt = positionKm(source, from, departMs, frame);
341
+ let seconds = 2 * Math.sqrt((len(sub(positionKm(source, to, departMs, frame), startAt)) * 1000) / a);
342
+ let from3 = startAt;
343
+ let to3 = positionKm(source, to, departMs, frame);
344
+ let distanceM = 0;
345
+ let plan = flipAndBurn(1, a, 0);
346
+ for (let i = 0; i < 6; i += 1) {
347
+ to3 = positionKm(source, to, departMs + seconds * 1000, frame);
348
+ const line = sub(to3, startAt);
349
+ const d = len(line);
350
+ const unit = [line[0] / d, line[1] / d, line[2] / d];
351
+ const startRadius = parkRadius(source, from);
352
+ const endRadius = parkRadius(source, to);
353
+ from3 = [startAt[0] + unit[0] * startRadius, startAt[1] + unit[1] * startRadius, startAt[2] + unit[2] * startRadius];
354
+ const arrive = [to3[0] - unit[0] * endRadius, to3[1] - unit[1] * endRadius, to3[2] - unit[2] * endRadius];
355
+ to3 = arrive;
356
+ distanceM = len(sub(arrive, from3)) * 1000;
357
+ plan = flipAndBurn(distanceM, a, flipS);
358
+ seconds = plan.frameSeconds;
359
+ }
360
+ // The straight path must stay clear of the Sun.
361
+ let blockedBySun = false;
362
+ if (frame === "sun") {
363
+ const ab = sub(to3, from3);
364
+ const t = Math.max(0, Math.min(1, -(from3[0] * ab[0] + from3[1] * ab[1] + from3[2] * ab[2]) / (ab[0] ** 2 + ab[1] ** 2 + ab[2] ** 2)));
365
+ const closest = len([from3[0] + ab[0] * t, from3[1] + ab[1] * t, from3[2] + ab[2] * t]);
366
+ blockedBySun = closest < 0.1 * AU_KM;
367
+ }
368
+ const fullT = craft.dryT.value + craft.payloadT.value + craft.propellantT.value;
369
+ const ratio = Math.exp((2 * plan.rapidity) / craft.exhaustOfC.value);
370
+ const usedT = fullT - fullT / ratio;
371
+ const carried = craft.propellantT.value;
372
+ const tb = plan.burnSeconds;
373
+ const phases = [
374
+ phase("preheat", -13, -3, SCREEN.preheat),
375
+ phase("ignition", -3, 0, SCREEN.ignition),
376
+ phase("accelerate", 0, tb, SCREEN.accelerate),
377
+ phase("flip", tb, tb + flipS, SCREEN.flip),
378
+ phase("decelerate", tb + flipS, plan.frameSeconds, SCREEN.decelerate),
379
+ phase("arrival", plan.frameSeconds, plan.frameSeconds + ARRIVAL_S, SCREEN.arrival),
380
+ ];
381
+ const because = [`Needs ${Math.round(usedT).toLocaleString("en-US")} t of propellant; the ship carries ${Math.round(carried).toLocaleString("en-US")} t.`];
382
+ let verdict;
383
+ if (blockedBySun)
384
+ verdict = { level: "cannot", line: `The straight path passes too close to the Sun: pick another date.`, because, playable: false };
385
+ else if (usedT > carried)
386
+ verdict = { level: "cannot", line: `Not enough fuel: this trip needs ${Math.round(usedT).toLocaleString("en-US")} t of propellant and the ship carries ${Math.round(carried).toLocaleString("en-US")} t.`, because, playable: false };
387
+ else
388
+ verdict = { level: "ok", line: `Can do it: ${(usedT / carried * 100) >= 1 ? (usedT / carried * 100).toFixed(0) : (usedT / carried * 100).toPrecision(2)} per cent of the propellant, at ${craft.g.value} g.`, because, playable: true };
389
+ if (estimated.length > 0)
390
+ because.push(`Estimated, not from a source: ${[...new Set(estimated)].join(", ")}.`);
391
+ const peak = accelerating(a, tb);
392
+ return {
393
+ from,
394
+ to,
395
+ craftId: craft.id,
396
+ drive: "continuous",
397
+ frame,
398
+ departMs,
399
+ liftoffBeforeS: 0,
400
+ phases,
401
+ totalS: plan.frameSeconds + ARRIVAL_S,
402
+ verdict,
403
+ estimated: [...new Set(estimated)],
404
+ straightKm: len(sub(positionKm(source, to, departMs, frame), startAt)),
405
+ pathKm: distanceM / 1000,
406
+ dv: { departure: C * plan.rapidity, arrival: C * plan.rapidity, total: 2 * C * plan.rapidity },
407
+ peakG: craft.g.value,
408
+ peakSpeed: peak.v,
409
+ peakGamma: peak.gamma,
410
+ shipS: plan.properSeconds,
411
+ frameS: plan.frameSeconds,
412
+ propellantUsedT: usedT,
413
+ propellantCarriedT: carried,
414
+ fuel: { usedT, carriedT: carried, note: "the whole trip, from the exhaust speed" },
415
+ payload: { kg: craft.payloadT.value * 1000, atLeast: false, note: `Payload to ${stopName(source, to)}` },
416
+ continuous: { from: from3, to: to3, a, burnS: tb, flipS, peakRapidity: plan.rapidity, blockedBySun },
417
+ };
418
+ }
419
+ /**
420
+ * The departure date, for a craft that burns all the way, with the shortest trip in the next two years.
421
+ * @param source Where the places are.
422
+ * @param craft The craft.
423
+ * @param from The starting place's id.
424
+ * @param to The destination's id.
425
+ * @param afterMs The earliest date.
426
+ * @returns The date, milliseconds since 1970.
427
+ * @example
428
+ * ```ts no-run
429
+ * import { bestDate, craftOf, type BodySource } from "@johnmorrisdotca/uchusen-rockets/journey";
430
+ *
431
+ * declare const source: BodySource;
432
+ * console.log(new Date(bestDate(source, craftOf("kaminari")!, "earth", "mars", Date.UTC(2026, 10, 4))).toISOString());
433
+ * ```
434
+ */
435
+ export function bestDate(source, craft, from, to, afterMs) {
436
+ let best = afterMs;
437
+ let bestS = Infinity;
438
+ for (let day = 0; day <= 800; day += 4) {
439
+ const at = afterMs + day * DAY_S * 1000;
440
+ const trip = planTrip(source, craft, from, to, at);
441
+ if (trip.verdict.playable && trip.frameS < bestS) {
442
+ bestS = trip.frameS;
443
+ best = at;
444
+ }
445
+ }
446
+ return best;
447
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * A distance in kilometres, with groups of three digits.
3
+ * @param km Kilometres.
4
+ * @returns For example `149 597 871 km`.
5
+ * @example
6
+ * ```ts
7
+ * import { kilometres } from "@johnmorrisdotca/uchusen-rockets/journey";
8
+ *
9
+ * console.log(kilometres(149_597_870.7));
10
+ * ```
11
+ */
12
+ export declare const kilometres: (km: number) => string;
13
+ /**
14
+ * A distance in astronomical units.
15
+ * @param km Kilometres.
16
+ * @returns For example `1.000 AU`, or `0.00257 AU` for a small one.
17
+ * @example
18
+ * ```ts
19
+ * import { astronomicalUnits } from "@johnmorrisdotca/uchusen-rockets/journey";
20
+ *
21
+ * console.log(astronomicalUnits(149_597_870.7));
22
+ * ```
23
+ */
24
+ export declare const astronomicalUnits: (km: number) => string;
25
+ /**
26
+ * The time light takes to cross a distance, in the unit that reads best.
27
+ * @param km Kilometres.
28
+ * @returns For example `1.28 light-seconds`, `8.3 light-minutes` or `4.37 light-years`.
29
+ * @example
30
+ * ```ts
31
+ * import { lightTime } from "@johnmorrisdotca/uchusen-rockets/journey";
32
+ *
33
+ * console.log(lightTime(384_400), lightTime(149_597_870.7));
34
+ * ```
35
+ */
36
+ export declare function lightTime(km: number): string;
37
+ /**
38
+ * A length of time, in the units that read best.
39
+ * @param seconds Seconds.
40
+ * @returns For example `11 min 40 s`, `5 d 2 h` or `258 d 20 h`.
41
+ * @example
42
+ * ```ts
43
+ * import { duration } from "@johnmorrisdotca/uchusen-rockets/journey";
44
+ *
45
+ * console.log(duration(700), duration(258.8 * 86400));
46
+ * ```
47
+ */
48
+ export declare function duration(seconds: number): string;
49
+ /**
50
+ * A speed in metres a second as metres, kilometres or kilometres a second.
51
+ * @param ms Metres a second.
52
+ * @returns For example `7.79 km/s` or `340 m/s`.
53
+ * @example
54
+ * ```ts
55
+ * import { speedWords } from "@johnmorrisdotca/uchusen-rockets/journey";
56
+ *
57
+ * console.log(speedWords(7790), speedWords(340));
58
+ * ```
59
+ */
60
+ export declare function speedWords(ms: number): string;
61
+ /**
62
+ * The share of the speed of light, with enough digits that it is never rounded up to 100: `99.999 999 99 %` for a speed a hair under c.
63
+ * @param ms Metres a second.
64
+ * @returns The percent, in groups of three digits.
65
+ * @example
66
+ * ```ts
67
+ * import { percentOfLight } from "@johnmorrisdotca/uchusen-rockets/journey";
68
+ *
69
+ * console.log(percentOfLight(29_780), percentOfLight(299_792_457.9999));
70
+ * ```
71
+ */
72
+ export declare function percentOfLight(ms: number): string;
73
+ /**
74
+ * An acceleration in g.
75
+ * @param g Standard gravities (positive or negative).
76
+ * @returns For example `0.21 g`, `1.13 g`, `0.015 g`.
77
+ * @example
78
+ * ```ts
79
+ * import { gWords } from "@johnmorrisdotca/uchusen-rockets/journey";
80
+ *
81
+ * console.log(gWords(0.2138), gWords(0.015));
82
+ * ```
83
+ */
84
+ export declare function gWords(g: number): string;
85
+ /**
86
+ * A mass in tonnes.
87
+ * @param tonnes Tonnes.
88
+ * @returns For example `43.5 t`, `6.35 million t`.
89
+ * @example
90
+ * ```ts
91
+ * import { tonnesWords } from "@johnmorrisdotca/uchusen-rockets/journey";
92
+ *
93
+ * console.log(tonnesWords(43.5), tonnesWords(6_350_000));
94
+ * ```
95
+ */
96
+ export declare const tonnesWords: (tonnes: number) => string;
@@ -0,0 +1,144 @@
1
+ // Numbers as words for the panel: distances in kilometres, astronomical units and light time, times, speeds, and the share of the speed of light with as many digits as it needs. Pure; no language in it but the numbers.
2
+ import { AU_KM, C } from "./constants.js";
3
+ const GROUP = " ";
4
+ /** Whole digits in groups of three, with a narrow space between: 150 000 000. */
5
+ function group(digits) {
6
+ const [whole = "", fraction] = digits.split(".");
7
+ const spaced = whole.replace(/\B(?=(\d{3})+(?!\d))/g, GROUP);
8
+ if (fraction === undefined)
9
+ return spaced;
10
+ return `${spaced}.${fraction.length >= 6 ? fraction.replace(/(\d{3})(?=\d)/g, `$1${GROUP}`) : fraction}`;
11
+ }
12
+ /**
13
+ * A distance in kilometres, with groups of three digits.
14
+ * @param km Kilometres.
15
+ * @returns For example `149 597 871 km`.
16
+ * @example
17
+ * ```ts
18
+ * import { kilometres } from "@johnmorrisdotca/uchusen-rockets/journey";
19
+ *
20
+ * console.log(kilometres(149_597_870.7));
21
+ * ```
22
+ */
23
+ export const kilometres = (km) => `${group(Math.round(km).toString())} km`;
24
+ /**
25
+ * A distance in astronomical units.
26
+ * @param km Kilometres.
27
+ * @returns For example `1.000 AU`, or `0.00257 AU` for a small one.
28
+ * @example
29
+ * ```ts
30
+ * import { astronomicalUnits } from "@johnmorrisdotca/uchusen-rockets/journey";
31
+ *
32
+ * console.log(astronomicalUnits(149_597_870.7));
33
+ * ```
34
+ */
35
+ export const astronomicalUnits = (km) => {
36
+ const au = km / AU_KM;
37
+ return `${au >= 0.1 ? au.toFixed(3) : au.toPrecision(3)} AU`;
38
+ };
39
+ /**
40
+ * The time light takes to cross a distance, in the unit that reads best.
41
+ * @param km Kilometres.
42
+ * @returns For example `1.28 light-seconds`, `8.3 light-minutes` or `4.37 light-years`.
43
+ * @example
44
+ * ```ts
45
+ * import { lightTime } from "@johnmorrisdotca/uchusen-rockets/journey";
46
+ *
47
+ * console.log(lightTime(384_400), lightTime(149_597_870.7));
48
+ * ```
49
+ */
50
+ export function lightTime(km) {
51
+ const s = (km * 1000) / C;
52
+ if (s < 120)
53
+ return `${s.toFixed(2)} light-seconds`;
54
+ if (s < 7200)
55
+ return `${(s / 60).toFixed(1)} light-minutes`;
56
+ if (s < 172_800)
57
+ return `${(s / 3600).toFixed(1)} light-hours`;
58
+ if (s < 31_557_600)
59
+ return `${(s / 86_400).toFixed(1)} light-days`;
60
+ return `${(s / 31_557_600).toFixed(2)} light-years`;
61
+ }
62
+ /**
63
+ * A length of time, in the units that read best.
64
+ * @param seconds Seconds.
65
+ * @returns For example `11 min 40 s`, `5 d 2 h` or `258 d 20 h`.
66
+ * @example
67
+ * ```ts
68
+ * import { duration } from "@johnmorrisdotca/uchusen-rockets/journey";
69
+ *
70
+ * console.log(duration(700), duration(258.8 * 86400));
71
+ * ```
72
+ */
73
+ export function duration(seconds) {
74
+ const s = Math.max(0, seconds);
75
+ if (s < 60)
76
+ return `${s < 10 ? s.toFixed(1) : Math.round(s)} s`;
77
+ if (s < 3600)
78
+ return `${Math.floor(s / 60)} min ${Math.round(s % 60)} s`;
79
+ if (s < 172_800)
80
+ return `${Math.floor(s / 3600)} h ${Math.round((s % 3600) / 60)} min`;
81
+ if (s < 63_115_200)
82
+ return `${Math.floor(s / 86_400)} d ${Math.round((s % 86_400) / 3600)} h`;
83
+ return `${(s / 31_557_600).toFixed(2)} years`;
84
+ }
85
+ /**
86
+ * A speed in metres a second as metres, kilometres or kilometres a second.
87
+ * @param ms Metres a second.
88
+ * @returns For example `7.79 km/s` or `340 m/s`.
89
+ * @example
90
+ * ```ts
91
+ * import { speedWords } from "@johnmorrisdotca/uchusen-rockets/journey";
92
+ *
93
+ * console.log(speedWords(7790), speedWords(340));
94
+ * ```
95
+ */
96
+ export function speedWords(ms) {
97
+ if (Math.abs(ms) < 1000)
98
+ return `${Math.round(ms)} m/s`;
99
+ return `${group((ms / 1000).toFixed(ms < 100_000 ? 2 : 0))} km/s`;
100
+ }
101
+ /**
102
+ * The share of the speed of light, with enough digits that it is never rounded up to 100: `99.999 999 99 %` for a speed a hair under c.
103
+ * @param ms Metres a second.
104
+ * @returns The percent, in groups of three digits.
105
+ * @example
106
+ * ```ts
107
+ * import { percentOfLight } from "@johnmorrisdotca/uchusen-rockets/journey";
108
+ *
109
+ * console.log(percentOfLight(29_780), percentOfLight(299_792_457.9999));
110
+ * ```
111
+ */
112
+ export function percentOfLight(ms) {
113
+ const beta = ms / C;
114
+ const behind = 1 - beta;
115
+ const digits = behind > 0 && behind < 0.01 ? Math.min(14, Math.ceil(-Math.log10(behind)) + 1) : beta < 0.001 ? 4 : 3;
116
+ return `${group((beta * 100).toFixed(Math.max(2, digits)))} %`;
117
+ }
118
+ /**
119
+ * An acceleration in g.
120
+ * @param g Standard gravities (positive or negative).
121
+ * @returns For example `0.21 g`, `1.13 g`, `0.015 g`.
122
+ * @example
123
+ * ```ts
124
+ * import { gWords } from "@johnmorrisdotca/uchusen-rockets/journey";
125
+ *
126
+ * console.log(gWords(0.2138), gWords(0.015));
127
+ * ```
128
+ */
129
+ export function gWords(g) {
130
+ const a = Math.abs(g);
131
+ return `${a >= 1 ? a.toFixed(2) : a >= 0.1 ? a.toFixed(2) : a.toPrecision(2)} g`;
132
+ }
133
+ /**
134
+ * A mass in tonnes.
135
+ * @param tonnes Tonnes.
136
+ * @returns For example `43.5 t`, `6.35 million t`.
137
+ * @example
138
+ * ```ts
139
+ * import { tonnesWords } from "@johnmorrisdotca/uchusen-rockets/journey";
140
+ *
141
+ * console.log(tonnesWords(43.5), tonnesWords(6_350_000));
142
+ * ```
143
+ */
144
+ export const tonnesWords = (tonnes) => (tonnes >= 1e6 ? `${(tonnes / 1e6).toFixed(2)} million t` : `${group(tonnes >= 100 ? Math.round(tonnes).toString() : tonnes.toFixed(1))} t`);