@jjlmoya/utils-science 1.33.0 → 1.35.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/package.json +1 -1
- package/src/category/index.ts +3 -1
- package/src/entries.ts +5 -1
- package/src/index.ts +2 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/natural-selection-drift/component.astro +37 -6
- package/src/tool/natural-selection-drift/natural-selection-drift.css +134 -0
- package/src/tool/roche-limit-satellite-disruption/bibliography.astro +14 -0
- package/src/tool/roche-limit-satellite-disruption/bibliography.ts +16 -0
- package/src/tool/roche-limit-satellite-disruption/component.astro +97 -0
- package/src/tool/roche-limit-satellite-disruption/entry.ts +28 -0
- package/src/tool/roche-limit-satellite-disruption/i18n/de.ts +229 -0
- package/src/tool/roche-limit-satellite-disruption/i18n/en.ts +229 -0
- package/src/tool/roche-limit-satellite-disruption/i18n/es.ts +229 -0
- package/src/tool/roche-limit-satellite-disruption/i18n/fr.ts +229 -0
- package/src/tool/roche-limit-satellite-disruption/i18n/id.ts +229 -0
- package/src/tool/roche-limit-satellite-disruption/i18n/it.ts +229 -0
- package/src/tool/roche-limit-satellite-disruption/i18n/ja.ts +229 -0
- package/src/tool/roche-limit-satellite-disruption/i18n/ko.ts +229 -0
- package/src/tool/roche-limit-satellite-disruption/i18n/nl.ts +229 -0
- package/src/tool/roche-limit-satellite-disruption/i18n/pl.ts +229 -0
- package/src/tool/roche-limit-satellite-disruption/i18n/pt.ts +229 -0
- package/src/tool/roche-limit-satellite-disruption/i18n/ru.ts +229 -0
- package/src/tool/roche-limit-satellite-disruption/i18n/sv.ts +229 -0
- package/src/tool/roche-limit-satellite-disruption/i18n/tr.ts +229 -0
- package/src/tool/roche-limit-satellite-disruption/i18n/zh.ts +229 -0
- package/src/tool/roche-limit-satellite-disruption/index.ts +11 -0
- package/src/tool/roche-limit-satellite-disruption/logic.ts +102 -0
- package/src/tool/roche-limit-satellite-disruption/particle-system.ts +66 -0
- package/src/tool/roche-limit-satellite-disruption/roche-limit-satellite-disruption-calculator.css +568 -0
- package/src/tool/roche-limit-satellite-disruption/script.ts +274 -0
- package/src/tool/roche-limit-satellite-disruption/seo.astro +15 -0
- package/src/tool/roche-limit-satellite-disruption/storage.ts +28 -0
- package/src/tool/roche-limit-satellite-disruption/visual-data.ts +16 -0
- package/src/tool/three-body-problem/app.ts +274 -0
- package/src/tool/three-body-problem/bibliography.astro +14 -0
- package/src/tool/three-body-problem/bibliography.ts +16 -0
- package/src/tool/three-body-problem/component.astro +70 -0
- package/src/tool/three-body-problem/entry.ts +26 -0
- package/src/tool/three-body-problem/i18n/de.ts +162 -0
- package/src/tool/three-body-problem/i18n/en.ts +162 -0
- package/src/tool/three-body-problem/i18n/es.ts +162 -0
- package/src/tool/three-body-problem/i18n/fr.ts +162 -0
- package/src/tool/three-body-problem/i18n/id.ts +162 -0
- package/src/tool/three-body-problem/i18n/it.ts +162 -0
- package/src/tool/three-body-problem/i18n/ja.ts +162 -0
- package/src/tool/three-body-problem/i18n/ko.ts +162 -0
- package/src/tool/three-body-problem/i18n/nl.ts +162 -0
- package/src/tool/three-body-problem/i18n/pl.ts +162 -0
- package/src/tool/three-body-problem/i18n/pt.ts +162 -0
- package/src/tool/three-body-problem/i18n/ru.ts +162 -0
- package/src/tool/three-body-problem/i18n/sv.ts +162 -0
- package/src/tool/three-body-problem/i18n/tr.ts +162 -0
- package/src/tool/three-body-problem/i18n/zh.ts +162 -0
- package/src/tool/three-body-problem/index.ts +11 -0
- package/src/tool/three-body-problem/logic/ThreeBodyEngine.ts +179 -0
- package/src/tool/three-body-problem/seo.astro +15 -0
- package/src/tool/three-body-problem/three-body-problem-simulator.css +503 -0
- package/src/tools.ts +4 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { PRIMARY_BODIES, SATELLITE_BODIES, calculateRocheLimit } from './logic';
|
|
2
|
+
import type { PrimaryId, RocheResult, SatelliteId } from './logic';
|
|
3
|
+
import { animateParticles, resizeParticleCanvas } from './particle-system';
|
|
4
|
+
import type { VisualState } from './particle-system';
|
|
5
|
+
import { clearState, restoreState, saveState } from './storage';
|
|
6
|
+
import { planetPalettes, satelliteStyles } from './visual-data';
|
|
7
|
+
|
|
8
|
+
const root = document.getElementById('roche-console') as HTMLElement;
|
|
9
|
+
const ui = JSON.parse(root.dataset.ui ?? '{}');
|
|
10
|
+
const $ = <T extends HTMLElement | SVGElement>(id: string) => document.getElementById(id) as T;
|
|
11
|
+
const refs = {
|
|
12
|
+
primaryName: $('roche-primary-name'),
|
|
13
|
+
primaryDensity: $('roche-primary-density'),
|
|
14
|
+
satelliteName: $('roche-satellite-name'),
|
|
15
|
+
satelliteDensity: $('roche-satellite-density'),
|
|
16
|
+
primaryPicker: $('roche-primary-picker'),
|
|
17
|
+
satellitePicker: $('roche-satellite-picker'),
|
|
18
|
+
primaryTrigger: $('roche-primary-trigger'),
|
|
19
|
+
satelliteTrigger: $('roche-satellite-trigger'),
|
|
20
|
+
primaryMenu: $('roche-primary-menu'),
|
|
21
|
+
satelliteMenu: $('roche-satellite-menu'),
|
|
22
|
+
distanceInput: $('roche-distance') as HTMLInputElement,
|
|
23
|
+
distanceOutput: $('roche-distance-output'),
|
|
24
|
+
verdict: $('roche-verdict'),
|
|
25
|
+
particleCanvas: $('roche-particle-canvas') as HTMLCanvasElement,
|
|
26
|
+
moon: $('roche-moon') as unknown as SVGGElement,
|
|
27
|
+
moonBody: $('roche-moon-body') as unknown as SVGEllipseElement,
|
|
28
|
+
moonScar: $('roche-moon-scar') as unknown as SVGPathElement,
|
|
29
|
+
planet: $('roche-planet') as unknown as SVGCircleElement,
|
|
30
|
+
planetStops: [$('roche-planet-stop-a'), $('roche-planet-stop-b'), $('roche-planet-stop-c')] as SVGElement[],
|
|
31
|
+
orbit: $('roche-orbit') as unknown as SVGCircleElement,
|
|
32
|
+
boundary: $('roche-boundary') as unknown as SVGCircleElement,
|
|
33
|
+
fragments: $('roche-fragments') as unknown as SVGGElement,
|
|
34
|
+
activeLimit: $('roche-active-limit'),
|
|
35
|
+
safetyRatio: $('roche-safety-ratio'),
|
|
36
|
+
period: $('roche-period'),
|
|
37
|
+
ringProgress: $('roche-ring-progress'),
|
|
38
|
+
fluidLimit: $('roche-fluid-limit'),
|
|
39
|
+
rigidLimit: $('roche-rigid-limit'),
|
|
40
|
+
fluidBar: $('roche-fluid-bar'),
|
|
41
|
+
rigidBar: $('roche-rigid-bar'),
|
|
42
|
+
density: $('roche-density'),
|
|
43
|
+
cohesion: $('roche-cohesion'),
|
|
44
|
+
radius: $('roche-radius'),
|
|
45
|
+
mapLabel: $('roche-map-label') as unknown as SVGTextElement,
|
|
46
|
+
closePass: $('roche-close-pass'),
|
|
47
|
+
reset: $('roche-reset'),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let selectedPrimary: PrimaryId = 'saturn';
|
|
51
|
+
let selectedSatellite: SatelliteId = 'icy-moon';
|
|
52
|
+
let latestMoon = { x: 0, y: 0, orbitRadius: 160, progress: 0 };
|
|
53
|
+
let latestResult = calculateRocheLimit({ primaryId: selectedPrimary, satelliteId: selectedSatellite, orbitDistanceKm: 140000 });
|
|
54
|
+
let orbitPhase = -35;
|
|
55
|
+
let visualState: VisualState = 'orbiting';
|
|
56
|
+
const verdictLabels: Record<RocheResult['verdict'], string> = {
|
|
57
|
+
stable: ui.stable,
|
|
58
|
+
grazing: ui.grazing,
|
|
59
|
+
fragmenting: ui.fragmenting,
|
|
60
|
+
ring: ui.ring,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function formatKm(value: number): string {
|
|
64
|
+
return `${Math.round(value).toLocaleString('en')} ${ui.km}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatValueUnit(value: string, unit: string): string {
|
|
68
|
+
return `<span>${value}</span><small>${unit}</small>`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function setDistanceRange(): void {
|
|
72
|
+
const primary = PRIMARY_BODIES.find((item) => item.id === selectedPrimary) ?? PRIMARY_BODIES[0];
|
|
73
|
+
refs.distanceInput.min = `${Math.round(primary.radiusKm * 1.08)}`;
|
|
74
|
+
refs.distanceInput.max = `${Math.round(primary.radiusKm * 6.2)}`;
|
|
75
|
+
const value = Number(refs.distanceInput.value);
|
|
76
|
+
if (value < Number(refs.distanceInput.min) || value > Number(refs.distanceInput.max)) refs.distanceInput.value = `${Math.round(primary.radiusKm * 3.8)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function updateBodyVisuals(): void {
|
|
80
|
+
const palette = planetPalettes[selectedPrimary];
|
|
81
|
+
const satelliteStyle = satelliteStyles[selectedSatellite];
|
|
82
|
+
refs.planetStops.forEach((stop, index) => stop.setAttribute('stop-color', palette.stops[index] ?? palette.stops[0]));
|
|
83
|
+
refs.moonBody.style.fill = satelliteStyle.fill;
|
|
84
|
+
refs.moonBody.style.stroke = satelliteStyle.stroke;
|
|
85
|
+
refs.moonScar.style.stroke = satelliteStyle.scar;
|
|
86
|
+
root.style.setProperty('--planet-line-rgb', palette.line);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function visualStateFor(result: RocheResult, stretch: number): VisualState {
|
|
90
|
+
if (result.ringProgress >= 0.6) return 'ringFormed';
|
|
91
|
+
if (result.ringProgress > 0.18) return 'disrupting';
|
|
92
|
+
if (stretch > 0.2) return 'deforming';
|
|
93
|
+
return 'orbiting';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function drawFragments(progress: number, orbitRadius: number): void {
|
|
97
|
+
refs.fragments.textContent = '';
|
|
98
|
+
for (let index = 0; index < 18 + Math.round(progress * 54); index += 1) {
|
|
99
|
+
const angle = index * 137.5 + progress * 80;
|
|
100
|
+
const lane = (index % 7) - 3;
|
|
101
|
+
const point = fragmentPoint(angle, orbitRadius, lane, progress);
|
|
102
|
+
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
103
|
+
dot.setAttribute('cx', `${point.x.toFixed(2)}`);
|
|
104
|
+
dot.setAttribute('cy', `${point.y.toFixed(2)}`);
|
|
105
|
+
dot.setAttribute('r', `${(1.6 + (index % 4) * 0.7 + progress * 2.4).toFixed(2)}`);
|
|
106
|
+
dot.classList.add('roche-fragment');
|
|
107
|
+
dot.style.setProperty('--delay', `${index * -80}ms`);
|
|
108
|
+
refs.fragments.append(dot);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function fragmentPoint(angle: number, orbitRadius: number, lane: number, progress: number): { x: number; y: number } {
|
|
113
|
+
const radians = angle * Math.PI / 180;
|
|
114
|
+
const spread = progress * (18 + Math.abs(lane) * 6);
|
|
115
|
+
return {
|
|
116
|
+
x: 280 + Math.cos(radians) * (orbitRadius + lane * spread),
|
|
117
|
+
y: 280 + Math.sin(radians) * (orbitRadius * 0.31 + lane * spread * 0.22),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function updateMoon(result: RocheResult, orbitRadius: number, stretch: number): void {
|
|
122
|
+
const radians = orbitPhase * Math.PI / 180;
|
|
123
|
+
const moonX = 280 + Math.cos(radians) * orbitRadius;
|
|
124
|
+
const moonY = 280 + Math.sin(radians) * orbitRadius * 0.35;
|
|
125
|
+
const moonRx = 22 + stretch * 19;
|
|
126
|
+
const moonRy = 22 - stretch * 8;
|
|
127
|
+
const scale = visualState === 'ringFormed' ? 0.01 : satelliteStyles[selectedSatellite].size * Math.max(0.42, 1 - result.ringProgress * 0.65);
|
|
128
|
+
refs.moon.setAttribute('transform', `translate(${moonX.toFixed(2)} ${moonY.toFixed(2)}) rotate(${(orbitPhase + stretch * 3).toFixed(2)}) scale(${scale.toFixed(2)})`);
|
|
129
|
+
refs.moonBody.setAttribute('rx', `${moonRx.toFixed(2)}`);
|
|
130
|
+
refs.moonBody.setAttribute('ry', `${moonRy.toFixed(2)}`);
|
|
131
|
+
refs.moonScar.setAttribute('d', `M ${(-moonRx * 0.42).toFixed(1)} ${(-moonRy * 0.62).toFixed(1)} C ${(moonRx * 0.2).toFixed(1)} ${(-moonRy * 0.28).toFixed(1)} ${(moonRx * 0.42).toFixed(1)} ${(moonRy * 0.18).toFixed(1)} ${(moonRx * 0.18).toFixed(1)} ${(moonRy * 0.78).toFixed(1)}`);
|
|
132
|
+
setMoonDepth(radians, result.ringProgress);
|
|
133
|
+
latestMoon = { x: moonX, y: moonY, orbitRadius, progress: result.ringProgress };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function setMoonDepth(radians: number, ringProgress: number): void {
|
|
137
|
+
const rearOrbit = Math.sin(radians) < 0;
|
|
138
|
+
if (rearOrbit) refs.planet.parentNode?.insertBefore(refs.moon, refs.planet);
|
|
139
|
+
if (!rearOrbit) refs.mapLabel.parentNode?.insertBefore(refs.moon, refs.mapLabel);
|
|
140
|
+
refs.moon.classList.toggle('is-rear', rearOrbit);
|
|
141
|
+
refs.moon.style.opacity = visualState === 'ringFormed' ? '0' : `${Math.max(rearOrbit ? 0.1 : 0.18, (rearOrbit ? 0.38 : 1) - ringProgress * 0.82)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function updateReadouts(result: RocheResult): void {
|
|
145
|
+
refs.distanceOutput.textContent = formatKm(Number(refs.distanceInput.value));
|
|
146
|
+
refs.verdict.textContent = verdictLabels[result.verdict];
|
|
147
|
+
refs.activeLimit.innerHTML = formatValueUnit(Math.round(result.selectedLimitKm).toLocaleString('en'), ui.km);
|
|
148
|
+
refs.safetyRatio.innerHTML = formatValueUnit(result.safetyRatio.toFixed(2), 'x');
|
|
149
|
+
refs.period.innerHTML = formatValueUnit(result.orbitalPeriodHours.toFixed(1), ui.hours);
|
|
150
|
+
refs.ringProgress.innerHTML = formatValueUnit(`${Math.round(result.ringProgress * 100)}`, '%');
|
|
151
|
+
refs.fluidLimit.textContent = formatKm(result.fluidLimitKm);
|
|
152
|
+
refs.rigidLimit.textContent = formatKm(result.rigidLimitKm);
|
|
153
|
+
refs.density.textContent = `${ui.density}: ${result.satellite.densityGcm3} g/cm3`;
|
|
154
|
+
refs.cohesion.textContent = `${ui.cohesion}: ${result.satellite.cohesion}`;
|
|
155
|
+
refs.radius.textContent = `${ui.planetRadius}: ${formatKm(result.primary.radiusKm)}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function update(persist = true): void {
|
|
159
|
+
setDistanceRange();
|
|
160
|
+
const result = calculateRocheLimit({ primaryId: selectedPrimary, satelliteId: selectedSatellite, orbitDistanceKm: Number(refs.distanceInput.value) });
|
|
161
|
+
const orbitRadius = Math.max(104, Math.min(238, Number(refs.distanceInput.value) * (205 / Number(refs.distanceInput.max))));
|
|
162
|
+
const stretch = Math.min(1, Math.max(0, (1.14 - result.safetyRatio) / 0.42));
|
|
163
|
+
visualState = visualStateFor(result, stretch);
|
|
164
|
+
latestResult = result;
|
|
165
|
+
root.dataset.verdict = result.verdict;
|
|
166
|
+
refs.orbit.setAttribute('r', `${orbitRadius.toFixed(1)}`);
|
|
167
|
+
refs.boundary.setAttribute('r', `${Math.max(96, Math.min(236, result.selectedLimitKm * (205 / Number(refs.distanceInput.max)))).toFixed(1)}`);
|
|
168
|
+
refs.fluidBar.style.width = `${Math.max(8, result.fluidLimitKm / Math.max(result.fluidLimitKm, result.rigidLimitKm) * 100)}%`;
|
|
169
|
+
refs.rigidBar.style.width = `${Math.max(8, result.rigidLimitKm / Math.max(result.fluidLimitKm, result.rigidLimitKm) * 100)}%`;
|
|
170
|
+
refs.mapLabel.textContent = result.ringProgress > 0.35 ? ui.debrisTrack : ui.moonTrack;
|
|
171
|
+
root.style.setProperty('--ring-opacity', `${0.12 + result.ringProgress * 0.78}`);
|
|
172
|
+
root.style.setProperty('--stress', `${Math.min(1, result.tidalStressIndex / 1.4)}`);
|
|
173
|
+
updateBodyVisuals();
|
|
174
|
+
updateMoon(result, orbitRadius, stretch);
|
|
175
|
+
updateReadouts(result);
|
|
176
|
+
drawFragments(result.ringProgress, orbitRadius);
|
|
177
|
+
if (persist) saveState(selectedPrimary, selectedSatellite, Number(refs.distanceInput.value));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function syncPickers(): void {
|
|
181
|
+
const primary = PRIMARY_BODIES.find((body) => body.id === selectedPrimary) ?? PRIMARY_BODIES[0];
|
|
182
|
+
const satellite = SATELLITE_BODIES.find((body) => body.id === selectedSatellite) ?? SATELLITE_BODIES[0];
|
|
183
|
+
refs.primaryName.textContent = primary.name;
|
|
184
|
+
refs.primaryDensity.textContent = `${primary.densityGcm3} g/cm3`;
|
|
185
|
+
refs.satelliteName.textContent = satellite.name;
|
|
186
|
+
refs.satelliteDensity.textContent = `${satellite.densityGcm3} g/cm3`;
|
|
187
|
+
refs.primaryMenu.querySelectorAll<HTMLButtonElement>('button').forEach((button) => button.classList.toggle('is-selected', button.dataset.value === selectedPrimary));
|
|
188
|
+
refs.satelliteMenu.querySelectorAll<HTMLButtonElement>('button').forEach((button) => button.classList.toggle('is-selected', button.dataset.value === selectedSatellite));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function setPickerOpen(picker: HTMLElement, trigger: HTMLElement, open: boolean): void {
|
|
192
|
+
picker.classList.toggle('is-open', open);
|
|
193
|
+
trigger.setAttribute('aria-expanded', `${open}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function closePickers(): void {
|
|
197
|
+
setPickerOpen(refs.primaryPicker, refs.primaryTrigger, false);
|
|
198
|
+
setPickerOpen(refs.satellitePicker, refs.satelliteTrigger, false);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function fillMenus(): void {
|
|
202
|
+
PRIMARY_BODIES.forEach((body) => appendOption({ menu: refs.primaryMenu, value: body.id, name: body.name, meta: `${body.densityGcm3} g/cm3`, select: () => {
|
|
203
|
+
selectedPrimary = body.id;
|
|
204
|
+
} }));
|
|
205
|
+
SATELLITE_BODIES.forEach((body) => appendOption({ menu: refs.satelliteMenu, value: body.id, name: body.name, meta: `${body.densityGcm3} g/cm3`, select: () => {
|
|
206
|
+
selectedSatellite = body.id;
|
|
207
|
+
} }));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function appendOption(option: { menu: HTMLElement; value: string; name: string; meta: string; select: () => void }): void {
|
|
211
|
+
const button = document.createElement('button');
|
|
212
|
+
button.type = 'button';
|
|
213
|
+
button.dataset.value = option.value;
|
|
214
|
+
button.innerHTML = `<strong>${option.name}</strong><small>${option.meta}</small>`;
|
|
215
|
+
button.addEventListener('click', () => {
|
|
216
|
+
option.select();
|
|
217
|
+
closePickers();
|
|
218
|
+
syncPickers();
|
|
219
|
+
update();
|
|
220
|
+
});
|
|
221
|
+
option.menu.append(button);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function bindEvents(): void {
|
|
225
|
+
refs.distanceInput.addEventListener('input', () => update());
|
|
226
|
+
window.addEventListener('resize', () => resizeParticleCanvas(refs.particleCanvas));
|
|
227
|
+
refs.primaryTrigger.addEventListener('click', () => togglePicker(refs.primaryPicker, refs.primaryTrigger));
|
|
228
|
+
refs.satelliteTrigger.addEventListener('click', () => togglePicker(refs.satellitePicker, refs.satelliteTrigger));
|
|
229
|
+
document.addEventListener('click', (event) => {
|
|
230
|
+
if (!root.contains(event.target as Node)) closePickers();
|
|
231
|
+
});
|
|
232
|
+
refs.closePass.addEventListener('click', closePass);
|
|
233
|
+
refs.reset.addEventListener('click', reset);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function togglePicker(picker: HTMLElement, trigger: HTMLElement): void {
|
|
237
|
+
const willOpen = !picker.classList.contains('is-open');
|
|
238
|
+
closePickers();
|
|
239
|
+
setPickerOpen(picker, trigger, willOpen);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function closePass(): void {
|
|
243
|
+
refs.distanceInput.value = `${Math.round(latestResult.selectedLimitKm * 0.86)}`;
|
|
244
|
+
update();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function reset(): void {
|
|
248
|
+
selectedPrimary = 'saturn';
|
|
249
|
+
selectedSatellite = 'icy-moon';
|
|
250
|
+
refs.distanceInput.value = '140000';
|
|
251
|
+
clearState();
|
|
252
|
+
syncPickers();
|
|
253
|
+
update();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function animateOrbit(): void {
|
|
257
|
+
const speed = visualState === 'ringFormed' ? 0 : 0.14 + Math.min(0.34, Math.pow(Math.max(0.2, 1 / latestResult.safetyRatio), 1.5) * 0.08);
|
|
258
|
+
orbitPhase = (orbitPhase + speed) % 360;
|
|
259
|
+
update(false);
|
|
260
|
+
window.requestAnimationFrame(animateOrbit);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
fillMenus(); refs.distanceInput.value = '140000';
|
|
264
|
+
const restoredState = restoreState();
|
|
265
|
+
if (restoredState.primaryId) selectedPrimary = restoredState.primaryId;
|
|
266
|
+
if (restoredState.satelliteId) selectedSatellite = restoredState.satelliteId;
|
|
267
|
+
if (restoredState.orbitDistanceKm) refs.distanceInput.value = `${restoredState.orbitDistanceKm}`;
|
|
268
|
+
setDistanceRange();
|
|
269
|
+
bindEvents();
|
|
270
|
+
syncPickers();
|
|
271
|
+
resizeParticleCanvas(refs.particleCanvas);
|
|
272
|
+
update();
|
|
273
|
+
window.requestAnimationFrame(() => animateParticles(refs.particleCanvas, () => ({ moon: latestMoon, visualState })));
|
|
274
|
+
window.requestAnimationFrame(animateOrbit);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { rocheLimitSatelliteDisruption } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const content = await rocheLimitSatelliteDisruption.i18n[locale]?.();
|
|
12
|
+
if (!content) return null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { PRIMARY_BODIES, SATELLITE_BODIES } from './logic';
|
|
2
|
+
import type { PrimaryId, SatelliteId } from './logic';
|
|
3
|
+
|
|
4
|
+
const storageKey = 'roche-limit-satellite-disruption-state';
|
|
5
|
+
|
|
6
|
+
export function saveState(primaryId: PrimaryId, satelliteId: SatelliteId, orbitDistanceKm: number): void {
|
|
7
|
+
window.localStorage.setItem(storageKey, JSON.stringify({ primaryId, satelliteId, orbitDistanceKm }));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function clearState(): void {
|
|
11
|
+
window.localStorage.removeItem(storageKey);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function restoreState(): { primaryId?: PrimaryId; satelliteId?: SatelliteId; orbitDistanceKm?: number } {
|
|
15
|
+
const rawState = window.localStorage.getItem(storageKey);
|
|
16
|
+
if (!rawState) return {};
|
|
17
|
+
try {
|
|
18
|
+
const state = JSON.parse(rawState);
|
|
19
|
+
return {
|
|
20
|
+
primaryId: PRIMARY_BODIES.some((body) => body.id === state.primaryId) ? state.primaryId : undefined,
|
|
21
|
+
satelliteId: SATELLITE_BODIES.some((body) => body.id === state.satelliteId) ? state.satelliteId : undefined,
|
|
22
|
+
orbitDistanceKm: Number.isFinite(state.orbitDistanceKm) ? state.orbitDistanceKm : undefined,
|
|
23
|
+
};
|
|
24
|
+
} catch {
|
|
25
|
+
clearState();
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PrimaryId, SatelliteId } from './logic';
|
|
2
|
+
|
|
3
|
+
export const planetPalettes: Record<PrimaryId, { stops: [string, string, string]; line: string }> = {
|
|
4
|
+
earth: { stops: ['#e6fbff', '#3c91b8', '#233a74'], line: '60 145 184' },
|
|
5
|
+
mars: { stops: ['#ffe0ba', '#c9653f', '#4a2621'], line: '201 101 63' },
|
|
6
|
+
jupiter: { stops: ['#fff4d6', '#d29b62', '#6d5148'], line: '210 155 98' },
|
|
7
|
+
saturn: { stops: ['#f7efe2', '#d19a66', '#63425f'], line: '209 154 102' },
|
|
8
|
+
neptune: { stops: ['#d8f4ff', '#4f8fcf', '#16295f'], line: '79 143 207' },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const satelliteStyles: Record<SatelliteId, { fill: string; stroke: string; scar: string; size: number }> = {
|
|
12
|
+
'icy-moon': { fill: '#dbefff', stroke: '#7aa9c7', scar: '#9ec9df', size: 0.92 },
|
|
13
|
+
'rocky-moon': { fill: '#c9beb2', stroke: '#75695f', scar: '#93867a', size: 1.08 },
|
|
14
|
+
'rubble-pile': { fill: '#b9aa95', stroke: '#6f6253', scar: '#8e806e', size: 0.62 },
|
|
15
|
+
'iron-core': { fill: '#b9bec8', stroke: '#656d7a', scar: '#818a98', size: 0.98 },
|
|
16
|
+
};
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { ThreeBodyEngine, THREE_BODY_PRESETS, type BodyState } from './logic/ThreeBodyEngine';
|
|
2
|
+
|
|
3
|
+
export function initThreeBodyProblem() {
|
|
4
|
+
const root = document.getElementById('three-body-root');
|
|
5
|
+
if (root) {
|
|
6
|
+
const canvas = document.getElementById('three-body-canvas') as HTMLCanvasElement;
|
|
7
|
+
const controls = document.getElementById('three-body-controls');
|
|
8
|
+
const toggleButton = document.getElementById('three-body-toggle');
|
|
9
|
+
const resetButton = document.getElementById('three-body-reset');
|
|
10
|
+
const speedInput = document.getElementById('three-body-speed') as HTMLInputElement;
|
|
11
|
+
const trailInput = document.getElementById('three-body-trail') as HTMLInputElement;
|
|
12
|
+
const energyValue = document.getElementById('three-body-energy');
|
|
13
|
+
const separationValue = document.getElementById('three-body-separation');
|
|
14
|
+
const centerValue = document.getElementById('three-body-center');
|
|
15
|
+
const presetButtons = Array.from(document.querySelectorAll('.three-body-preset'));
|
|
16
|
+
const tabButtons = Array.from(document.querySelectorAll('.three-body-tab'));
|
|
17
|
+
const engine = new ThreeBodyEngine();
|
|
18
|
+
let activePreset = THREE_BODY_PRESETS[0];
|
|
19
|
+
let bodies = engine.cloneBodies(activePreset.bodies);
|
|
20
|
+
let trails = bodies.map(() => [] as Array<{ x: number; y: number }>);
|
|
21
|
+
let isRunning = true;
|
|
22
|
+
let animationFrame = 0;
|
|
23
|
+
let zoom = activePreset.zoom;
|
|
24
|
+
let activeBodyIndex = 0;
|
|
25
|
+
const labels = {
|
|
26
|
+
pause: root.dataset.pauseLabel ?? 'Pause',
|
|
27
|
+
play: root.dataset.playLabel ?? 'Play',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function setPreset(presetId: string) {
|
|
31
|
+
const selectedPreset = THREE_BODY_PRESETS.find((preset) => preset.id === presetId) ?? THREE_BODY_PRESETS[0];
|
|
32
|
+
activePreset = selectedPreset;
|
|
33
|
+
bodies = engine.cloneBodies(selectedPreset.bodies);
|
|
34
|
+
trails = bodies.map(() => []);
|
|
35
|
+
zoom = selectedPreset.zoom;
|
|
36
|
+
trailInput.value = selectedPreset.trailLength.toString();
|
|
37
|
+
renderControls();
|
|
38
|
+
updatePresetButtons(presetId);
|
|
39
|
+
}
|
|
40
|
+
function updatePresetButtons(presetId: string) {
|
|
41
|
+
presetButtons.forEach((button) => {
|
|
42
|
+
button.classList.toggle('is-active', button.getAttribute('data-preset') === presetId);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function renderControls() {
|
|
46
|
+
if (!controls) return;
|
|
47
|
+
controls.innerHTML = '';
|
|
48
|
+
bodies.forEach((body, index) => {
|
|
49
|
+
controls.appendChild(createBodyPanel(body, index));
|
|
50
|
+
});
|
|
51
|
+
bindBodyInputs(controls.querySelectorAll('input'));
|
|
52
|
+
bindScrubbableInputs(controls.querySelectorAll('.three-body-scrub-input'));
|
|
53
|
+
}
|
|
54
|
+
function createBodyPanel(body: BodyState, index: number) {
|
|
55
|
+
const panel = document.createElement('article');
|
|
56
|
+
panel.className = 'three-body-panel';
|
|
57
|
+
panel.classList.toggle('is-active', index === activeBodyIndex);
|
|
58
|
+
panel.style.setProperty('--body-color', body.color);
|
|
59
|
+
panel.dataset.bodyLabel = body.label;
|
|
60
|
+
panel.dataset.bodyIndex = index.toString();
|
|
61
|
+
panel.innerHTML = `
|
|
62
|
+
<div class="three-body-panel-title">
|
|
63
|
+
<strong style="color: ${body.color}">${body.label}</strong>
|
|
64
|
+
</div>
|
|
65
|
+
${renderScrubInput({ index, field: 'mass', label: 'm', min: 0.2, max: 4, value: body.mass, step: 0.05, color: body.color })}
|
|
66
|
+
${renderScrubInput({ index, field: 'vx', label: 'v_x', min: -1.6, max: 1.6, value: body.vx, step: 0.01, color: body.color })}
|
|
67
|
+
${renderScrubInput({ index, field: 'vy', label: 'v_y', min: -1.6, max: 1.6, value: body.vy, step: 0.01, color: body.color })}
|
|
68
|
+
`;
|
|
69
|
+
return panel;
|
|
70
|
+
}
|
|
71
|
+
function bindBodyInputs(inputs: NodeListOf<Element>) {
|
|
72
|
+
inputs.forEach((input) => {
|
|
73
|
+
input.addEventListener('input', () => {
|
|
74
|
+
const slider = input as HTMLInputElement;
|
|
75
|
+
const bodyIndex = Number(slider.dataset.bodyIndex);
|
|
76
|
+
const field = slider.dataset.field as keyof BodyState;
|
|
77
|
+
const value = clampToInput(slider, Number(slider.value));
|
|
78
|
+
if (Number.isFinite(bodyIndex) && field) {
|
|
79
|
+
bodies[bodyIndex] = { ...bodies[bodyIndex], [field]: value };
|
|
80
|
+
trails[bodyIndex] = [];
|
|
81
|
+
slider.value = formatInputValue(value, slider);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function setActiveBody(index: number) {
|
|
87
|
+
activeBodyIndex = index;
|
|
88
|
+
tabButtons.forEach((button) => {
|
|
89
|
+
button.classList.toggle('is-active', Number((button as HTMLElement).dataset.bodyTab) === index);
|
|
90
|
+
});
|
|
91
|
+
controls?.querySelectorAll('.three-body-panel').forEach((panel) => {
|
|
92
|
+
panel.classList.toggle('is-active', Number((panel as HTMLElement).dataset.bodyIndex) === index);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
interface ScrubInputConfig {
|
|
96
|
+
index: number;
|
|
97
|
+
field: string;
|
|
98
|
+
label: string;
|
|
99
|
+
min: number;
|
|
100
|
+
max: number;
|
|
101
|
+
value: number;
|
|
102
|
+
step: number;
|
|
103
|
+
color: string;
|
|
104
|
+
}
|
|
105
|
+
function renderScrubInput(config: ScrubInputConfig) {
|
|
106
|
+
const value = formatInputValue(config.value, { dataset: { step: config.step.toString() } } as HTMLInputElement);
|
|
107
|
+
return `
|
|
108
|
+
<label class="three-body-scrub-row">
|
|
109
|
+
<span>${config.label}</span>
|
|
110
|
+
<input class="three-body-scrub-input" style="color: ${config.color}" data-body-index="${config.index}" data-field="${config.field}" data-min="${config.min}" data-max="${config.max}" data-step="${config.step}" inputmode="decimal" value="${value}" />
|
|
111
|
+
</label>
|
|
112
|
+
`;
|
|
113
|
+
}
|
|
114
|
+
function formatInputValue(value: number, input: HTMLInputElement) {
|
|
115
|
+
const step = Number(input.dataset.step) || 0.01;
|
|
116
|
+
const decimals = step >= 1 ? 0 : Math.max(0, step.toString().split('.')[1]?.length ?? 2);
|
|
117
|
+
return value.toFixed(decimals);
|
|
118
|
+
}
|
|
119
|
+
function clampToInput(input: HTMLInputElement, value: number) {
|
|
120
|
+
const min = Number(input.dataset.min);
|
|
121
|
+
const max = Number(input.dataset.max);
|
|
122
|
+
if (!Number.isFinite(value)) return Number(input.value) || 0;
|
|
123
|
+
return Math.min(max, Math.max(min, value));
|
|
124
|
+
}
|
|
125
|
+
function bindScrubbableInputs(inputs: NodeListOf<Element>) {
|
|
126
|
+
inputs.forEach((inputElement) => {
|
|
127
|
+
const input = inputElement as HTMLInputElement;
|
|
128
|
+
let startX = 0;
|
|
129
|
+
let startValue = 0;
|
|
130
|
+
let dragged = false;
|
|
131
|
+
|
|
132
|
+
input.addEventListener('pointerdown', (event) => {
|
|
133
|
+
startX = event.clientX;
|
|
134
|
+
startValue = Number(input.value) || 0;
|
|
135
|
+
dragged = false;
|
|
136
|
+
input.setPointerCapture(event.pointerId);
|
|
137
|
+
});
|
|
138
|
+
input.addEventListener('pointermove', (event) => {
|
|
139
|
+
if (!input.hasPointerCapture(event.pointerId)) return;
|
|
140
|
+
const delta = event.clientX - startX;
|
|
141
|
+
if (Math.abs(delta) < 2) return;
|
|
142
|
+
dragged = true;
|
|
143
|
+
const step = Number(input.dataset.step) || 0.01;
|
|
144
|
+
const nextValue = clampToInput(input, startValue + delta * step);
|
|
145
|
+
input.value = formatInputValue(nextValue, input);
|
|
146
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
147
|
+
});
|
|
148
|
+
input.addEventListener('pointerup', (event) => {
|
|
149
|
+
if (input.hasPointerCapture(event.pointerId)) input.releasePointerCapture(event.pointerId);
|
|
150
|
+
if (dragged) input.blur();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
function draw() {
|
|
155
|
+
const context = canvas.getContext('2d');
|
|
156
|
+
if (!context) return;
|
|
157
|
+
|
|
158
|
+
const dpr = window.devicePixelRatio || 1;
|
|
159
|
+
const width = canvas.clientWidth;
|
|
160
|
+
const height = canvas.clientHeight;
|
|
161
|
+
canvas.width = width * dpr;
|
|
162
|
+
canvas.height = height * dpr;
|
|
163
|
+
context.scale(dpr, dpr);
|
|
164
|
+
context.clearRect(0, 0, width, height);
|
|
165
|
+
|
|
166
|
+
const originX = width / 2;
|
|
167
|
+
const originY = height / 2;
|
|
168
|
+
const origin = { x: originX, y: originY };
|
|
169
|
+
drawGrid(context, width, height, origin);
|
|
170
|
+
bodies.forEach((body, index) => drawBody(context, body, trails[index], origin));
|
|
171
|
+
}
|
|
172
|
+
function drawBody(context: CanvasRenderingContext2D, body: BodyState, trail: Array<{ x: number; y: number }>, origin: { x: number; y: number }) {
|
|
173
|
+
drawTrail(context, body, trail, origin);
|
|
174
|
+
drawBodyGlow(context, body, origin);
|
|
175
|
+
}
|
|
176
|
+
function drawTrail(context: CanvasRenderingContext2D, body: BodyState, trail: Array<{ x: number; y: number }>, origin: { x: number; y: number }) {
|
|
177
|
+
context.beginPath();
|
|
178
|
+
trail.forEach((point, pointIndex) => {
|
|
179
|
+
const screenX = origin.x + point.x * zoom;
|
|
180
|
+
const screenY = origin.y + point.y * zoom;
|
|
181
|
+
if (pointIndex === 0) context.moveTo(screenX, screenY);
|
|
182
|
+
else context.lineTo(screenX, screenY);
|
|
183
|
+
});
|
|
184
|
+
context.strokeStyle = body.color;
|
|
185
|
+
context.globalAlpha = 0.82;
|
|
186
|
+
context.lineWidth = 1.85;
|
|
187
|
+
context.shadowColor = body.color;
|
|
188
|
+
context.shadowBlur = 2.5;
|
|
189
|
+
context.stroke();
|
|
190
|
+
context.shadowBlur = 0;
|
|
191
|
+
context.globalAlpha = 1;
|
|
192
|
+
}
|
|
193
|
+
function drawBodyGlow(context: CanvasRenderingContext2D, body: BodyState, origin: { x: number; y: number }) {
|
|
194
|
+
const x = origin.x + body.x * zoom;
|
|
195
|
+
const y = origin.y + body.y * zoom;
|
|
196
|
+
const radius = Math.max(4, Math.sqrt(body.mass) * 5);
|
|
197
|
+
const glow = context.createRadialGradient(x, y, 2, x, y, radius * 3);
|
|
198
|
+
glow.addColorStop(0, body.color);
|
|
199
|
+
glow.addColorStop(1, 'rgba(255,255,255,0)');
|
|
200
|
+
context.fillStyle = glow;
|
|
201
|
+
context.beginPath();
|
|
202
|
+
context.arc(x, y, radius * 3, 0, Math.PI * 2);
|
|
203
|
+
context.fill();
|
|
204
|
+
context.fillStyle = body.color;
|
|
205
|
+
context.beginPath();
|
|
206
|
+
context.arc(x, y, radius, 0, Math.PI * 2);
|
|
207
|
+
context.fill();
|
|
208
|
+
}
|
|
209
|
+
function drawGrid(context: CanvasRenderingContext2D, width: number, height: number, origin: { x: number; y: number }) {
|
|
210
|
+
const isDark = document.documentElement.classList.contains('theme-dark') || document.body.classList.contains('theme-dark');
|
|
211
|
+
context.strokeStyle = isDark ? 'rgba(255,255,255,0.09)' : 'rgba(67, 56, 43, 0.12)';
|
|
212
|
+
context.lineWidth = 1;
|
|
213
|
+
for (let x = origin.x % 48; x < width; x += 48) {
|
|
214
|
+
context.beginPath();
|
|
215
|
+
context.moveTo(x, 0);
|
|
216
|
+
context.lineTo(x, height);
|
|
217
|
+
context.stroke();
|
|
218
|
+
}
|
|
219
|
+
for (let y = origin.y % 48; y < height; y += 48) {
|
|
220
|
+
context.beginPath();
|
|
221
|
+
context.moveTo(0, y);
|
|
222
|
+
context.lineTo(width, y);
|
|
223
|
+
context.stroke();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function updateReadout() {
|
|
227
|
+
const metrics = engine.calculateMetrics(bodies);
|
|
228
|
+
if (energyValue) energyValue.textContent = metrics.totalEnergy.toFixed(3);
|
|
229
|
+
if (separationValue) separationValue.textContent = `${metrics.minSeparation.toFixed(2)} - ${metrics.maxSeparation.toFixed(2)}`;
|
|
230
|
+
if (centerValue) centerValue.textContent = `${metrics.centerOfMassX.toFixed(2)}, ${metrics.centerOfMassY.toFixed(2)}`;
|
|
231
|
+
}
|
|
232
|
+
function tick() {
|
|
233
|
+
if (isRunning) {
|
|
234
|
+
const speed = Number(speedInput.value) || 1;
|
|
235
|
+
for (let i = 0; i < 3; i += 1) {
|
|
236
|
+
const snapshot = engine.step(bodies, activePreset.timeStep * speed);
|
|
237
|
+
bodies = snapshot.bodies;
|
|
238
|
+
}
|
|
239
|
+
const trailLimit = Number(trailInput.value) || activePreset.trailLength;
|
|
240
|
+
trails.forEach((trail, index) => {
|
|
241
|
+
trail.push({ x: bodies[index].x, y: bodies[index].y });
|
|
242
|
+
if (trail.length > trailLimit) trail.shift();
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
updateReadout();
|
|
246
|
+
draw();
|
|
247
|
+
animationFrame = window.requestAnimationFrame(tick);
|
|
248
|
+
}
|
|
249
|
+
presetButtons.forEach((button) => {
|
|
250
|
+
button.addEventListener('click', () => {
|
|
251
|
+
setPreset(button.getAttribute('data-preset') ?? 'figureEight');
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
tabButtons.forEach((button) => {
|
|
255
|
+
button.addEventListener('click', () => {
|
|
256
|
+
setActiveBody(Number((button as HTMLElement).dataset.bodyTab) || 0);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
toggleButton?.addEventListener('click', () => {
|
|
260
|
+
isRunning = !isRunning;
|
|
261
|
+
toggleButton.textContent = isRunning ? labels.pause : labels.play;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
resetButton?.addEventListener('click', () => {
|
|
265
|
+
setPreset(activePreset.id);
|
|
266
|
+
});
|
|
267
|
+
bindScrubbableInputs(document.querySelectorAll('.three-body-global-controls .three-body-scrub-input'));
|
|
268
|
+
setPreset('figureEight');
|
|
269
|
+
tick();
|
|
270
|
+
document.addEventListener('astro:before-swap', () => {
|
|
271
|
+
window.cancelAnimationFrame(animationFrame);
|
|
272
|
+
}, { once: true });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { threeBodyProblem } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const content = await threeBodyProblem.i18n[locale]?.();
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
{content && <SharedBibliography links={content.bibliography} />}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'A remarkable periodic solution of the three-body problem in the case of equal masses',
|
|
6
|
+
url: 'https://arxiv.org/abs/math/0011268',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'Three-body problem',
|
|
10
|
+
url: 'https://en.wikipedia.org/wiki/Three-body_problem',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'Solar System Dynamics',
|
|
14
|
+
url: 'https://www.solarsystemdynamics.info/',
|
|
15
|
+
},
|
|
16
|
+
];
|