@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.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +5 -1
  4. package/src/index.ts +2 -0
  5. package/src/tests/locale_completeness.test.ts +2 -2
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/natural-selection-drift/component.astro +37 -6
  8. package/src/tool/natural-selection-drift/natural-selection-drift.css +134 -0
  9. package/src/tool/roche-limit-satellite-disruption/bibliography.astro +14 -0
  10. package/src/tool/roche-limit-satellite-disruption/bibliography.ts +16 -0
  11. package/src/tool/roche-limit-satellite-disruption/component.astro +97 -0
  12. package/src/tool/roche-limit-satellite-disruption/entry.ts +28 -0
  13. package/src/tool/roche-limit-satellite-disruption/i18n/de.ts +229 -0
  14. package/src/tool/roche-limit-satellite-disruption/i18n/en.ts +229 -0
  15. package/src/tool/roche-limit-satellite-disruption/i18n/es.ts +229 -0
  16. package/src/tool/roche-limit-satellite-disruption/i18n/fr.ts +229 -0
  17. package/src/tool/roche-limit-satellite-disruption/i18n/id.ts +229 -0
  18. package/src/tool/roche-limit-satellite-disruption/i18n/it.ts +229 -0
  19. package/src/tool/roche-limit-satellite-disruption/i18n/ja.ts +229 -0
  20. package/src/tool/roche-limit-satellite-disruption/i18n/ko.ts +229 -0
  21. package/src/tool/roche-limit-satellite-disruption/i18n/nl.ts +229 -0
  22. package/src/tool/roche-limit-satellite-disruption/i18n/pl.ts +229 -0
  23. package/src/tool/roche-limit-satellite-disruption/i18n/pt.ts +229 -0
  24. package/src/tool/roche-limit-satellite-disruption/i18n/ru.ts +229 -0
  25. package/src/tool/roche-limit-satellite-disruption/i18n/sv.ts +229 -0
  26. package/src/tool/roche-limit-satellite-disruption/i18n/tr.ts +229 -0
  27. package/src/tool/roche-limit-satellite-disruption/i18n/zh.ts +229 -0
  28. package/src/tool/roche-limit-satellite-disruption/index.ts +11 -0
  29. package/src/tool/roche-limit-satellite-disruption/logic.ts +102 -0
  30. package/src/tool/roche-limit-satellite-disruption/particle-system.ts +66 -0
  31. package/src/tool/roche-limit-satellite-disruption/roche-limit-satellite-disruption-calculator.css +568 -0
  32. package/src/tool/roche-limit-satellite-disruption/script.ts +274 -0
  33. package/src/tool/roche-limit-satellite-disruption/seo.astro +15 -0
  34. package/src/tool/roche-limit-satellite-disruption/storage.ts +28 -0
  35. package/src/tool/roche-limit-satellite-disruption/visual-data.ts +16 -0
  36. package/src/tool/three-body-problem/app.ts +274 -0
  37. package/src/tool/three-body-problem/bibliography.astro +14 -0
  38. package/src/tool/three-body-problem/bibliography.ts +16 -0
  39. package/src/tool/three-body-problem/component.astro +70 -0
  40. package/src/tool/three-body-problem/entry.ts +26 -0
  41. package/src/tool/three-body-problem/i18n/de.ts +162 -0
  42. package/src/tool/three-body-problem/i18n/en.ts +162 -0
  43. package/src/tool/three-body-problem/i18n/es.ts +162 -0
  44. package/src/tool/three-body-problem/i18n/fr.ts +162 -0
  45. package/src/tool/three-body-problem/i18n/id.ts +162 -0
  46. package/src/tool/three-body-problem/i18n/it.ts +162 -0
  47. package/src/tool/three-body-problem/i18n/ja.ts +162 -0
  48. package/src/tool/three-body-problem/i18n/ko.ts +162 -0
  49. package/src/tool/three-body-problem/i18n/nl.ts +162 -0
  50. package/src/tool/three-body-problem/i18n/pl.ts +162 -0
  51. package/src/tool/three-body-problem/i18n/pt.ts +162 -0
  52. package/src/tool/three-body-problem/i18n/ru.ts +162 -0
  53. package/src/tool/three-body-problem/i18n/sv.ts +162 -0
  54. package/src/tool/three-body-problem/i18n/tr.ts +162 -0
  55. package/src/tool/three-body-problem/i18n/zh.ts +162 -0
  56. package/src/tool/three-body-problem/index.ts +11 -0
  57. package/src/tool/three-body-problem/logic/ThreeBodyEngine.ts +179 -0
  58. package/src/tool/three-body-problem/seo.astro +15 -0
  59. package/src/tool/three-body-problem/three-body-problem-simulator.css +503 -0
  60. 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
+ ];