@jjlmoya/utils-science 1.34.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 +2 -1
- package/src/entries.ts +3 -1
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- 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/tools.ts +2 -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
|
+
};
|
package/src/tools.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { TWIN_PARADOX_VISUALIZER_TOOL } from './tool/twin-paradox-visualizer/ind
|
|
|
17
17
|
import { MANDELBROT_FRACTAL_TOOL } from './tool/mandelbrot-fractal/index';
|
|
18
18
|
import { PLANET_ATMOSPHERE_SURVIVAL_TOOL } from './tool/planet-atmosphere-survival/index';
|
|
19
19
|
import { THREE_BODY_PROBLEM_TOOL } from './tool/three-body-problem/index';
|
|
20
|
+
import { ROCHE_LIMIT_SATELLITE_DISRUPTION_TOOL } from './tool/roche-limit-satellite-disruption/index';
|
|
20
21
|
|
|
21
22
|
export const ALL_TOOLS: ToolDefinition[] = [
|
|
22
23
|
COLONY_COUNTER_TOOL,
|
|
@@ -36,4 +37,5 @@ export const ALL_TOOLS: ToolDefinition[] = [
|
|
|
36
37
|
MANDELBROT_FRACTAL_TOOL,
|
|
37
38
|
PLANET_ATMOSPHERE_SURVIVAL_TOOL,
|
|
38
39
|
THREE_BODY_PROBLEM_TOOL,
|
|
40
|
+
ROCHE_LIMIT_SATELLITE_DISRUPTION_TOOL,
|
|
39
41
|
];
|