@jjlmoya/utils-science 1.33.0 → 1.34.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 (33) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +3 -1
  4. package/src/index.ts +1 -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/three-body-problem/app.ts +274 -0
  10. package/src/tool/three-body-problem/bibliography.astro +14 -0
  11. package/src/tool/three-body-problem/bibliography.ts +16 -0
  12. package/src/tool/three-body-problem/component.astro +70 -0
  13. package/src/tool/three-body-problem/entry.ts +26 -0
  14. package/src/tool/three-body-problem/i18n/de.ts +162 -0
  15. package/src/tool/three-body-problem/i18n/en.ts +162 -0
  16. package/src/tool/three-body-problem/i18n/es.ts +162 -0
  17. package/src/tool/three-body-problem/i18n/fr.ts +162 -0
  18. package/src/tool/three-body-problem/i18n/id.ts +162 -0
  19. package/src/tool/three-body-problem/i18n/it.ts +162 -0
  20. package/src/tool/three-body-problem/i18n/ja.ts +162 -0
  21. package/src/tool/three-body-problem/i18n/ko.ts +162 -0
  22. package/src/tool/three-body-problem/i18n/nl.ts +162 -0
  23. package/src/tool/three-body-problem/i18n/pl.ts +162 -0
  24. package/src/tool/three-body-problem/i18n/pt.ts +162 -0
  25. package/src/tool/three-body-problem/i18n/ru.ts +162 -0
  26. package/src/tool/three-body-problem/i18n/sv.ts +162 -0
  27. package/src/tool/three-body-problem/i18n/tr.ts +162 -0
  28. package/src/tool/three-body-problem/i18n/zh.ts +162 -0
  29. package/src/tool/three-body-problem/index.ts +11 -0
  30. package/src/tool/three-body-problem/logic/ThreeBodyEngine.ts +179 -0
  31. package/src/tool/three-body-problem/seo.astro +15 -0
  32. package/src/tool/three-body-problem/three-body-problem-simulator.css +503 -0
  33. package/src/tools.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-science",
3
- "version": "1.33.0",
3
+ "version": "1.34.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -15,10 +15,11 @@ import { phaseDiagramCriticalPoints } from '../tool/phase-diagram-critical-point
15
15
  import { twinParadoxVisualizer } from '../tool/twin-paradox-visualizer/index';
16
16
  import { mandelbrotFractal } from '../tool/mandelbrot-fractal/index';
17
17
  import { planetAtmosphereSurvival } from '../tool/planet-atmosphere-survival/index';
18
+ import { threeBodyProblem } from '../tool/three-body-problem/index';
18
19
 
19
20
  export const scienceCategory: ScienceCategoryEntry = {
20
21
  icon: 'mdi:flask',
21
- tools: [colonyCounter, asteroidImpact, microwaveDetector, simulationProbability, cellularRenewal, cosmicInflation, temperatureTimeline, lorenzAttractor, stellarHabitabilityZone, radioactiveDecay, naturalSelectionDrift, entropySecondLaw, phaseDiagramCriticalPoints, twinParadoxVisualizer, mandelbrotFractal, planetAtmosphereSurvival],
22
+ tools: [colonyCounter, asteroidImpact, microwaveDetector, simulationProbability, cellularRenewal, cosmicInflation, temperatureTimeline, lorenzAttractor, stellarHabitabilityZone, radioactiveDecay, naturalSelectionDrift, entropySecondLaw, phaseDiagramCriticalPoints, twinParadoxVisualizer, mandelbrotFractal, planetAtmosphereSurvival, threeBodyProblem],
22
23
  i18n: {
23
24
  es: () => import('./i18n/es').then((m) => m.content),
24
25
  en: () => import('./i18n/en').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -15,6 +15,7 @@ export { phaseDiagramCriticalPoints } from './tool/phase-diagram-critical-points
15
15
  export { twinParadoxVisualizer } from './tool/twin-paradox-visualizer/entry';
16
16
  export { mandelbrotFractal } from './tool/mandelbrot-fractal/entry';
17
17
  export { planetAtmosphereSurvival } from './tool/planet-atmosphere-survival/entry';
18
+ export { threeBodyProblem } from './tool/three-body-problem/entry';
18
19
  export { scienceCategory } from './category';
19
20
  import { asteroidImpact } from './tool/asteroid-impact/entry';
20
21
  import { cellularRenewal } from './tool/cellular-renewal/entry';
@@ -32,4 +33,5 @@ import { phaseDiagramCriticalPoints } from './tool/phase-diagram-critical-points
32
33
  import { twinParadoxVisualizer } from './tool/twin-paradox-visualizer/entry';
33
34
  import { mandelbrotFractal } from './tool/mandelbrot-fractal/entry';
34
35
  import { planetAtmosphereSurvival } from './tool/planet-atmosphere-survival/entry';
35
- export const ALL_ENTRIES = [asteroidImpact, cellularRenewal, colonyCounter, microwaveDetector, simulationProbability, cosmicInflation, temperatureTimeline, lorenzAttractor, stellarHabitabilityZone, radioactiveDecay, naturalSelectionDrift, entropySecondLaw, phaseDiagramCriticalPoints, twinParadoxVisualizer, mandelbrotFractal, planetAtmosphereSurvival];
36
+ import { threeBodyProblem } from './tool/three-body-problem/entry';
37
+ export const ALL_ENTRIES = [asteroidImpact, cellularRenewal, colonyCounter, microwaveDetector, simulationProbability, cosmicInflation, temperatureTimeline, lorenzAttractor, stellarHabitabilityZone, radioactiveDecay, naturalSelectionDrift, entropySecondLaw, phaseDiagramCriticalPoints, twinParadoxVisualizer, mandelbrotFractal, planetAtmosphereSurvival, threeBodyProblem];
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export { PHASE_DIAGRAM_CRITICAL_POINTS_TOOL } from './tool/phase-diagram-critica
16
16
  export { TWIN_PARADOX_VISUALIZER_TOOL } from './tool/twin-paradox-visualizer/index';
17
17
  export { MANDELBROT_FRACTAL_TOOL } from './tool/mandelbrot-fractal/index';
18
18
  export { PLANET_ATMOSPHERE_SURVIVAL_TOOL } from './tool/planet-atmosphere-survival/index';
19
+ export { THREE_BODY_PROBLEM_TOOL } from './tool/three-body-problem/index';
19
20
 
20
21
  export type {
21
22
  KnownLocale,
@@ -18,7 +18,7 @@ describe('Locale Completeness Validation', () => {
18
18
  });
19
19
  });
20
20
 
21
- it('all 16 tools registered', () => {
22
- expect(ALL_TOOLS.length).toBe(16);
21
+ it('all 17 tools registered', () => {
22
+ expect(ALL_TOOLS.length).toBe(17);
23
23
  });
24
24
  });
@@ -4,8 +4,8 @@ import { scienceCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 16 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(16);
7
+ it('should have 17 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(17);
9
9
  });
10
10
 
11
11
  it('scienceCategory should be defined', () => {
@@ -8,7 +8,7 @@ interface Props {
8
8
  const { ui } = Astro.props;
9
9
  ---
10
10
 
11
- <div class="ns-app">
11
+ <div class="ns-app" data-alive-label={ui.aliveLabel} data-allele-default={ui.alleleDefault}>
12
12
  <section class="ns-card">
13
13
  <canvas id="simulation-canvas" class="ns-canvas"></canvas>
14
14
  <div class="ns-canvas-badge" id="alive-badge">0 {ui.aliveLabel}</div>
@@ -25,9 +25,19 @@ const { ui } = Astro.props;
25
25
 
26
26
  <section class="ns-alleles" id="allele-ranking" aria-label={ui.alleleCountsLabel}></section>
27
27
 
28
- <aside class="ns-console" aria-label="Simulation controls">
28
+ <button class="ns-console-trigger" id="ns-console-trigger" aria-label={ui.controls} aria-expanded="false">
29
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2 4h12M2 8h8M2 12h10" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
30
+ {ui.controls}
31
+ </button>
32
+
33
+ <aside class="ns-console" id="ns-console" aria-label="Simulation controls">
29
34
  <div class="ns-console-header">
30
- <span class="ns-console-kicker">{ui.evolutionConsole}</span>
35
+ <div class="ns-console-header-row">
36
+ <span class="ns-console-kicker">{ui.evolutionConsole}</span>
37
+ <button class="ns-console-close" id="ns-console-close" aria-label="Close controls">
38
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M1 1l12 12M13 1L1 13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
39
+ </button>
40
+ </div>
31
41
  <h3>{ui.title}</h3>
32
42
  </div>
33
43
 
@@ -74,9 +84,10 @@ const { ui } = Astro.props;
74
84
  </section>
75
85
  </div>
76
86
 
77
- <script type="module" define:vars={{ ui }}>
78
- import { createNaturalSelectionRuntime } from '/src/tool/natural-selection-drift/runtime';
87
+ <script>
88
+ import { createNaturalSelectionRuntime } from './runtime';
79
89
 
90
+ const root = document.querySelector('.ns-app');
80
91
  createNaturalSelectionRuntime({
81
92
  populationInput: document.getElementById('population'),
82
93
  generationsInput: document.getElementById('generations'),
@@ -99,6 +110,26 @@ const { ui } = Astro.props;
99
110
  diversityScore: document.getElementById('diversity-score'),
100
111
  populationScore: document.getElementById('population-score'),
101
112
  alleleRanking: document.getElementById('allele-ranking'),
102
- ui: { ...ui },
113
+ ui: {
114
+ aliveLabel: root?.dataset.aliveLabel ?? '',
115
+ alleleDefault: root?.dataset.alleleDefault ?? '',
116
+ },
103
117
  });
118
+
119
+ const trigger = document.getElementById('ns-console-trigger');
120
+ const closeBtn = document.getElementById('ns-console-close');
121
+ const console = document.getElementById('ns-console');
122
+
123
+ function openConsole() {
124
+ console?.classList.add('is-open');
125
+ trigger?.setAttribute('aria-expanded', 'true');
126
+ }
127
+
128
+ function closeConsole() {
129
+ console?.classList.remove('is-open');
130
+ trigger?.setAttribute('aria-expanded', 'false');
131
+ }
132
+
133
+ trigger?.addEventListener('click', openConsole);
134
+ closeBtn?.addEventListener('click', closeConsole);
104
135
  </script>
@@ -427,3 +427,137 @@
427
427
  grid-template-columns: minmax(0, 1fr);
428
428
  }
429
429
  }
430
+
431
+ .ns-console-trigger {
432
+ display: none;
433
+ }
434
+
435
+ .ns-console-header-row {
436
+ display: flex;
437
+ align-items: center;
438
+ justify-content: space-between;
439
+ gap: 0.5rem;
440
+ }
441
+
442
+ .ns-console-close {
443
+ display: none;
444
+ align-items: center;
445
+ justify-content: center;
446
+ width: 28px;
447
+ height: 28px;
448
+ border-radius: 50%;
449
+ border: 1px solid var(--ns-border);
450
+ background: var(--ns-panel);
451
+ color: var(--ns-muted);
452
+ cursor: pointer;
453
+ flex-shrink: 0;
454
+ transition: background 0.18s ease, color 0.18s ease;
455
+ }
456
+
457
+ .ns-console-close:hover {
458
+ background: rgba(239, 68, 68, 0.12);
459
+ color: #ef4444;
460
+ }
461
+
462
+ @media (max-width: 640px) {
463
+ .ns-console-trigger {
464
+ display: inline-flex;
465
+ align-items: center;
466
+ gap: 0.5rem;
467
+ position: absolute;
468
+ left: clamp(16px, 4vw, 24px);
469
+ bottom: clamp(16px, 4vw, 24px);
470
+ z-index: 3;
471
+ padding: 0.6rem 1rem;
472
+ border-radius: 999px;
473
+ border: 1px solid rgba(255, 255, 255, 0.14);
474
+ background: rgba(2, 6, 23, 0.72);
475
+ backdrop-filter: blur(18px);
476
+ color: rgba(255, 255, 255, 0.9);
477
+ font-size: 0.78rem;
478
+ font-weight: 700;
479
+ letter-spacing: 0.06em;
480
+ text-transform: uppercase;
481
+ cursor: pointer;
482
+ transition: background 0.18s ease, transform 0.18s ease, opacity 0.18s ease;
483
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
484
+ }
485
+
486
+ .theme-light .ns-console-trigger {
487
+ background: rgba(255, 255, 255, 0.88);
488
+ border-color: rgba(148, 163, 184, 0.22);
489
+ color: #0f172a;
490
+ box-shadow: 0 8px 24px rgba(148, 163, 184, 0.22);
491
+ }
492
+
493
+ .ns-console-trigger:hover {
494
+ transform: translateY(-1px);
495
+ }
496
+
497
+ .ns-console-trigger[aria-expanded="true"] {
498
+ opacity: 0;
499
+ pointer-events: none;
500
+ }
501
+
502
+ .ns-console {
503
+ left: 0;
504
+ right: 0;
505
+ top: auto;
506
+ bottom: 0;
507
+ width: 100%;
508
+ border-radius: 24px 24px 0 0;
509
+ border-bottom: none;
510
+ padding: 1.25rem clamp(16px, 4vw, 24px) calc(clamp(16px, 4vw, 24px) + env(safe-area-inset-bottom, 0px));
511
+ transform: translateY(110%);
512
+ transition: transform 0.32s cubic-bezier(0.4, 0, 0.2, 1);
513
+ background: rgba(7, 9, 13, 0.88);
514
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
515
+ backdrop-filter: blur(24px);
516
+ max-height: 80vh;
517
+ overflow-y: auto;
518
+ overscroll-behavior: contain;
519
+ }
520
+
521
+ .theme-light .ns-console {
522
+ background: rgba(255, 255, 255, 0.96);
523
+ border-top-color: rgba(148, 163, 184, 0.2);
524
+ }
525
+
526
+ .ns-console.is-open {
527
+ transform: translateY(0);
528
+ }
529
+
530
+ .ns-console-close {
531
+ display: inline-flex;
532
+ }
533
+
534
+ .ns-console::before {
535
+ content: "";
536
+ display: block;
537
+ width: 40px;
538
+ height: 4px;
539
+ border-radius: 999px;
540
+ background: rgba(255, 255, 255, 0.18);
541
+ margin: 0 auto 1rem;
542
+ }
543
+
544
+ .theme-light .ns-console::before {
545
+ background: rgba(15, 23, 42, 0.14);
546
+ }
547
+
548
+ .ns-alleles {
549
+ top: 72px;
550
+ right: clamp(12px, 3vw, 16px);
551
+ grid-template-columns: minmax(140px, 180px);
552
+ }
553
+
554
+ .ns-allele-chip {
555
+ padding: 0.28rem 0.4rem 0.28rem 0.6rem;
556
+ border-radius: 10px;
557
+ }
558
+
559
+ .ns-hud {
560
+ bottom: clamp(72px, 12vw, 96px);
561
+ }
562
+ }
563
+
@@ -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
+ ];
@@ -0,0 +1,70 @@
1
+ ---
2
+ import './three-body-problem-simulator.css';
3
+
4
+ interface Props {
5
+ ui: Record<string, string>;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ ---
10
+
11
+ <div
12
+ class="three-body-root"
13
+ id="three-body-root"
14
+ data-pause-label={ui.pauseButton}
15
+ data-play-label={ui.playButton}
16
+ ><section class="three-body-stage" aria-label={ui.canvasLabel}>
17
+ <div class="three-body-sky">
18
+ <canvas id="three-body-canvas"></canvas>
19
+ <div class="three-body-reticle" aria-hidden="true"></div>
20
+ </div>
21
+ <div class="three-body-readout">
22
+ <div>
23
+ <span>E</span>
24
+ <strong id="three-body-energy">0.00</strong>
25
+ </div>
26
+ <div>
27
+ <span>R</span>
28
+ <strong id="three-body-separation">0.00</strong>
29
+ </div>
30
+ <div>
31
+ <span>COM</span>
32
+ <strong id="three-body-center">0.00, 0.00</strong>
33
+ </div>
34
+ </div></section>
35
+ <section class="three-body-console">
36
+ <div class="three-body-global-controls">
37
+ <label class="three-body-scrub-row">
38
+ <span>dt</span>
39
+ <input id="three-body-speed" class="three-body-scrub-input" inputmode="decimal" data-min="0.25" data-max="2.5" data-step="0.05" value="1.00" />
40
+ </label>
41
+ <label class="three-body-scrub-row">
42
+ <span>trail</span>
43
+ <input id="three-body-trail" class="three-body-scrub-input" inputmode="numeric" data-min="120" data-max="900" data-step="20" value="720" />
44
+ </label>
45
+ </div>
46
+ <div class="three-body-body-tabs" aria-label="Body selector">
47
+ <button class="three-body-tab is-active" type="button" data-body-tab="0">A</button>
48
+ <button class="three-body-tab" type="button" data-body-tab="1">B</button>
49
+ <button class="three-body-tab" type="button" data-body-tab="2">C</button>
50
+ </div>
51
+ <div class="three-body-body-controls" id="three-body-controls"></div>
52
+ </section>
53
+ <div class="three-body-instrument-bar">
54
+ <div class="three-body-presets" aria-label={ui.presetsLabel}>
55
+ <button class="three-body-preset is-active" type="button" data-preset="figureEight"><span class="full">{ui.figureEightPreset}</span><span class="short">Fig 8</span></button>
56
+ <button class="three-body-preset" type="button" data-preset="lagrange"><span class="full">{ui.lagrangePreset}</span><span class="short">Lagrange</span></button>
57
+ <button class="three-body-preset" type="button" data-preset="slingshot"><span class="full">{ui.slingshotPreset}</span><span class="short">Shot</span></button>
58
+ </div>
59
+ <div class="three-body-actions">
60
+ <button class="three-body-action" type="button" id="three-body-toggle">{ui.pauseButton}</button>
61
+ <button class="three-body-action" type="button" id="three-body-reset">{ui.resetButton}</button>
62
+ </div></div></div>
63
+
64
+ <script>
65
+ import { initThreeBodyProblem } from './app';
66
+
67
+ initThreeBodyProblem();
68
+ </script>
69
+
70
+