@jjlmoya/utils-science 1.32.0 → 1.33.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 (30) 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/planet-atmosphere-survival/bibliography.astro +14 -0
  8. package/src/tool/planet-atmosphere-survival/bibliography.ts +16 -0
  9. package/src/tool/planet-atmosphere-survival/component.astro +404 -0
  10. package/src/tool/planet-atmosphere-survival/entry.ts +26 -0
  11. package/src/tool/planet-atmosphere-survival/i18n/de.ts +255 -0
  12. package/src/tool/planet-atmosphere-survival/i18n/en.ts +255 -0
  13. package/src/tool/planet-atmosphere-survival/i18n/es.ts +255 -0
  14. package/src/tool/planet-atmosphere-survival/i18n/fr.ts +255 -0
  15. package/src/tool/planet-atmosphere-survival/i18n/id.ts +255 -0
  16. package/src/tool/planet-atmosphere-survival/i18n/it.ts +255 -0
  17. package/src/tool/planet-atmosphere-survival/i18n/ja.ts +255 -0
  18. package/src/tool/planet-atmosphere-survival/i18n/ko.ts +255 -0
  19. package/src/tool/planet-atmosphere-survival/i18n/nl.ts +255 -0
  20. package/src/tool/planet-atmosphere-survival/i18n/pl.ts +255 -0
  21. package/src/tool/planet-atmosphere-survival/i18n/pt.ts +255 -0
  22. package/src/tool/planet-atmosphere-survival/i18n/ru.ts +255 -0
  23. package/src/tool/planet-atmosphere-survival/i18n/sv.ts +255 -0
  24. package/src/tool/planet-atmosphere-survival/i18n/tr.ts +255 -0
  25. package/src/tool/planet-atmosphere-survival/i18n/zh.ts +255 -0
  26. package/src/tool/planet-atmosphere-survival/index.ts +11 -0
  27. package/src/tool/planet-atmosphere-survival/logic.ts +201 -0
  28. package/src/tool/planet-atmosphere-survival/planet-atmosphere-survival-calculator.css +494 -0
  29. package/src/tool/planet-atmosphere-survival/seo.astro +15 -0
  30. 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.32.0",
3
+ "version": "1.33.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -14,10 +14,11 @@ import { entropySecondLaw } from '../tool/entropy-second-law/index';
14
14
  import { phaseDiagramCriticalPoints } from '../tool/phase-diagram-critical-points/index';
15
15
  import { twinParadoxVisualizer } from '../tool/twin-paradox-visualizer/index';
16
16
  import { mandelbrotFractal } from '../tool/mandelbrot-fractal/index';
17
+ import { planetAtmosphereSurvival } from '../tool/planet-atmosphere-survival/index';
17
18
 
18
19
  export const scienceCategory: ScienceCategoryEntry = {
19
20
  icon: 'mdi:flask',
20
- tools: [colonyCounter, asteroidImpact, microwaveDetector, simulationProbability, cellularRenewal, cosmicInflation, temperatureTimeline, lorenzAttractor, stellarHabitabilityZone, radioactiveDecay, naturalSelectionDrift, entropySecondLaw, phaseDiagramCriticalPoints, twinParadoxVisualizer, mandelbrotFractal],
21
+ tools: [colonyCounter, asteroidImpact, microwaveDetector, simulationProbability, cellularRenewal, cosmicInflation, temperatureTimeline, lorenzAttractor, stellarHabitabilityZone, radioactiveDecay, naturalSelectionDrift, entropySecondLaw, phaseDiagramCriticalPoints, twinParadoxVisualizer, mandelbrotFractal, planetAtmosphereSurvival],
21
22
  i18n: {
22
23
  es: () => import('./i18n/es').then((m) => m.content),
23
24
  en: () => import('./i18n/en').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -14,6 +14,7 @@ export { entropySecondLaw } from './tool/entropy-second-law/entry';
14
14
  export { phaseDiagramCriticalPoints } from './tool/phase-diagram-critical-points/entry';
15
15
  export { twinParadoxVisualizer } from './tool/twin-paradox-visualizer/entry';
16
16
  export { mandelbrotFractal } from './tool/mandelbrot-fractal/entry';
17
+ export { planetAtmosphereSurvival } from './tool/planet-atmosphere-survival/entry';
17
18
  export { scienceCategory } from './category';
18
19
  import { asteroidImpact } from './tool/asteroid-impact/entry';
19
20
  import { cellularRenewal } from './tool/cellular-renewal/entry';
@@ -30,4 +31,5 @@ import { entropySecondLaw } from './tool/entropy-second-law/entry';
30
31
  import { phaseDiagramCriticalPoints } from './tool/phase-diagram-critical-points/entry';
31
32
  import { twinParadoxVisualizer } from './tool/twin-paradox-visualizer/entry';
32
33
  import { mandelbrotFractal } from './tool/mandelbrot-fractal/entry';
33
- export const ALL_ENTRIES = [asteroidImpact, cellularRenewal, colonyCounter, microwaveDetector, simulationProbability, cosmicInflation, temperatureTimeline, lorenzAttractor, stellarHabitabilityZone, radioactiveDecay, naturalSelectionDrift, entropySecondLaw, phaseDiagramCriticalPoints, twinParadoxVisualizer, mandelbrotFractal];
34
+ 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];
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export { ENTROPY_SECOND_LAW_TOOL } from './tool/entropy-second-law/index';
15
15
  export { PHASE_DIAGRAM_CRITICAL_POINTS_TOOL } from './tool/phase-diagram-critical-points/index';
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
+ export { PLANET_ATMOSPHERE_SURVIVAL_TOOL } from './tool/planet-atmosphere-survival/index';
18
19
 
19
20
  export type {
20
21
  KnownLocale,
@@ -18,7 +18,7 @@ describe('Locale Completeness Validation', () => {
18
18
  });
19
19
  });
20
20
 
21
- it('all 15 tools registered', () => {
22
- expect(ALL_TOOLS.length).toBe(15);
21
+ it('all 16 tools registered', () => {
22
+ expect(ALL_TOOLS.length).toBe(16);
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 15 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(15);
7
+ it('should have 16 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(16);
9
9
  });
10
10
 
11
11
  it('scienceCategory should be defined', () => {
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { planetAtmosphereSurvival } 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 planetAtmosphereSurvival.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: 'NASA Space Science Data Coordinated Archive, Planetary Fact Sheets',
6
+ url: 'https://science.gsfc.nasa.gov/solarsystem/dataarchives/projects/629/',
7
+ },
8
+ {
9
+ name: 'NASA, Human Integration Design Handbook',
10
+ url: 'https://www.nasa.gov/human-integration-design-handbook/',
11
+ },
12
+ {
13
+ name: 'NOAA National Weather Service, Wind Chill Temperature Index',
14
+ url: 'https://www.weather.gov/ddc/windchillddc',
15
+ },
16
+ ];
@@ -0,0 +1,404 @@
1
+ ---
2
+ import './planet-atmosphere-survival-calculator.css';
3
+
4
+ interface Props {
5
+ ui: Record<string, string>;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ const uiData = JSON.stringify(ui);
10
+ ---
11
+
12
+ <div class="survival-console" id="survival-console" data-ui={uiData}>
13
+ <section class="survival-summary-card" aria-label={ui.exposureSummary}>
14
+ <div>
15
+ <span class="survival-summary-kicker">{ui.atmosphericModel}</span>
16
+ <h2 id="survival-summary-title">{ui.atmosphericModel}</h2>
17
+ </div>
18
+ <div class="survival-summary-readouts">
19
+ <div>
20
+ <span>{ui.survival}</span>
21
+ <strong id="survival-summary-time">-</strong>
22
+ </div>
23
+ <div>
24
+ <span>{ui.limitingFactor}</span>
25
+ <strong id="survival-summary-limiter">-</strong>
26
+ </div>
27
+ <div>
28
+ <span>{ui.mode}</span>
29
+ <strong id="survival-summary-mode">{ui.metric}</strong>
30
+ </div>
31
+ </div>
32
+ </section>
33
+
34
+ <section class="survival-visor" aria-label={ui.riskMap}>
35
+ <div class="survival-planet">
36
+ <canvas class="survival-particle-canvas" id="survival-particle-canvas" aria-hidden="true"></canvas>
37
+ <svg class="survival-orbit" viewBox="0 0 460 460" role="img" aria-label={ui.riskMap}>
38
+ <defs>
39
+ <linearGradient id="survival-glow" x1="0" x2="1" y1="0" y2="1">
40
+ <stop offset="0%" stop-color="#8ee6d8"></stop>
41
+ <stop offset="55%" stop-color="#f6c85f"></stop>
42
+ <stop offset="100%" stop-color="#f05d5e"></stop>
43
+ </linearGradient>
44
+ </defs>
45
+ <circle class="survival-shell" cx="230" cy="230" r="172"></circle>
46
+ <circle class="survival-shell survival-shell-outer" cx="230" cy="230" r="205"></circle>
47
+ <g class="survival-particles" id="survival-particles"></g>
48
+ <path class="survival-risk-ring" id="survival-risk-ring" d=""></path>
49
+ <g id="survival-bars"></g>
50
+ <circle class="survival-core" cx="230" cy="230" r="92"></circle>
51
+ <text class="survival-core-time" id="survival-core-time" x="230" y="221">-</text>
52
+ <text class="survival-core-label" id="survival-core-label" x="230" y="252">-</text>
53
+ </svg>
54
+ </div>
55
+
56
+ <div class="survival-timeline" aria-label={ui.timeline}>
57
+ <svg viewBox="0 0 720 170" preserveAspectRatio="none">
58
+ <path class="survival-timeline-grid" d="M20 138 H700 M20 98 H700 M20 58 H700"></path>
59
+ <path class="survival-timeline-fill" id="survival-timeline-fill" d=""></path>
60
+ <path class="survival-timeline-cold" id="survival-timeline-cold" d=""></path>
61
+ <path class="survival-timeline-line" id="survival-timeline-line" d=""></path>
62
+ <text class="survival-axis-label survival-axis-y" x="28" y="30">{ui.vitalStress}</text>
63
+ <text class="survival-axis-label survival-axis-x" x="650" y="124">{ui.timeLabel}</text>
64
+ <text x="20" y="160">0</text>
65
+ <text x="160" y="160">20 s</text>
66
+ <text x="300" y="160">40 s</text>
67
+ <text x="440" y="160">60 s</text>
68
+ <text x="580" y="160">80 s</text>
69
+ <text x="690" y="160">100 s</text>
70
+ </svg>
71
+ </div>
72
+ </section>
73
+
74
+ <section class="survival-panel" aria-label={ui.controls}>
75
+ <div class="survival-panel-top">
76
+ <label class="survival-field survival-field-select" for="survival-body">
77
+ <span>{ui.destination}</span>
78
+ <select id="survival-body"></select>
79
+ </label>
80
+ <div class="survival-unit-toggle" aria-label={ui.unitSystem}>
81
+ <button class="is-active active" type="button" data-units="metric">{ui.metric}</button>
82
+ <button type="button" data-units="imperial">{ui.imperial}</button>
83
+ </div>
84
+ </div>
85
+
86
+ <p class="survival-note" id="survival-note"></p>
87
+
88
+ <div class="survival-grid">
89
+ <label class="survival-field" for="survival-pressure">
90
+ <span>{ui.pressure}</span>
91
+ <output id="survival-pressure-output"></output>
92
+ <input id="survival-pressure" type="range" min="0" max="9200" step="1" />
93
+ </label>
94
+
95
+ <label class="survival-field" for="survival-temperature">
96
+ <span>{ui.temperature}</span>
97
+ <output id="survival-temperature-output"></output>
98
+ <input id="survival-temperature" type="range" min="-190" max="470" step="1" />
99
+ </label>
100
+
101
+ <label class="survival-field" for="survival-oxygen">
102
+ <span>{ui.oxygen}</span>
103
+ <output id="survival-oxygen-output"></output>
104
+ <input id="survival-oxygen" type="range" min="0" max="25" step="0.1" />
105
+ </label>
106
+
107
+ <label class="survival-field" for="survival-co2">
108
+ <span>{ui.co2}</span>
109
+ <output id="survival-co2-output"></output>
110
+ <input id="survival-co2" type="range" min="0" max="98" step="0.1" />
111
+ </label>
112
+ </div>
113
+
114
+ <div class="survival-metrics">
115
+ <div>
116
+ <span>{ui.limitingFactor}</span>
117
+ <strong id="survival-limiter">-</strong>
118
+ </div>
119
+ <div>
120
+ <span>{ui.verdict}</span>
121
+ <strong id="survival-verdict">-</strong>
122
+ </div>
123
+ </div>
124
+ </section>
125
+ </div>
126
+
127
+ <script>
128
+ import { ATMOSPHERE_PRESETS, estimateSurvival } from './logic';
129
+ import type { AtmosphereHazard, PlanetAtmospherePreset, SurvivalTimelinePoint } from './logic';
130
+
131
+ const bodySelect = document.getElementById('survival-body') as HTMLSelectElement | null;
132
+ const note = document.getElementById('survival-note');
133
+ const pressureInput = document.getElementById('survival-pressure') as HTMLInputElement | null;
134
+ const temperatureInput = document.getElementById('survival-temperature') as HTMLInputElement | null;
135
+ const oxygenInput = document.getElementById('survival-oxygen') as HTMLInputElement | null;
136
+ const co2Input = document.getElementById('survival-co2') as HTMLInputElement | null;
137
+ const pressureOutput = document.getElementById('survival-pressure-output');
138
+ const temperatureOutput = document.getElementById('survival-temperature-output');
139
+ const oxygenOutput = document.getElementById('survival-oxygen-output');
140
+ const co2Output = document.getElementById('survival-co2-output');
141
+ const particleCanvas = document.getElementById('survival-particle-canvas') as HTMLCanvasElement | null;
142
+ const coreTime = document.getElementById('survival-core-time');
143
+ const coreLabel = document.getElementById('survival-core-label');
144
+ const limiter = document.getElementById('survival-limiter');
145
+ const verdict = document.getElementById('survival-verdict');
146
+ const summaryTitle = document.getElementById('survival-summary-title');
147
+ const summaryTime = document.getElementById('survival-summary-time');
148
+ const summaryLimiter = document.getElementById('survival-summary-limiter');
149
+ const summaryMode = document.getElementById('survival-summary-mode');
150
+ const bars = document.getElementById('survival-bars');
151
+ const particles = document.getElementById('survival-particles');
152
+ const riskRing = document.getElementById('survival-risk-ring');
153
+ const timelineLine = document.getElementById('survival-timeline-line');
154
+ const timelineFill = document.getElementById('survival-timeline-fill');
155
+ const timelineCold = document.getElementById('survival-timeline-cold');
156
+ const unitButtons = document.querySelectorAll<HTMLButtonElement>('[data-units]');
157
+ let unitSystem: 'metric' | 'imperial' = 'metric';
158
+ const particleContext = particleCanvas?.getContext('2d') ?? null;
159
+ const particleState = {
160
+ pressure: 0.64,
161
+ wind: 18,
162
+ stress: 1,
163
+ particles: Array.from({ length: 180 }, (_, index) => ({
164
+ angle: index * 2.399,
165
+ radius: 0.18 + (index % 90) / 120,
166
+ speed: 0.001 + (index % 11) * 0.00016,
167
+ lane: index % 7,
168
+ })),
169
+ };
170
+ const uiData = JSON.parse(document.getElementById('survival-console')!.dataset.ui!);
171
+
172
+ const hazardLabels: Record<AtmosphereHazard, string> = { pressure: uiData.hazardPressure, temperature: uiData.hazardTemperature, oxygen: uiData.hazardOxygen, toxicity: uiData.hazardToxicity, wind: uiData.hazardWind };
173
+ const presetData: Record<string, { name: string; note: string }> = { mars: { name: uiData.presetMars, note: uiData.noteMars }, venus: { name: uiData.presetVenus, note: uiData.noteVenus }, titan: { name: uiData.presetTitan, note: uiData.noteTitan }, jupiter: { name: uiData.presetJupiter, note: uiData.noteJupiter }, 'earth-everest': { name: uiData.presetEverest, note: uiData.noteEverest } };
174
+ const verdictLabels: Record<string, string> = { seconds: uiData.verdictSeconds, minutes: uiData.verdictMinutes, hours: uiData.verdictHours, extended: uiData.verdictExtended };
175
+ function presetName(id: string): string { return presetData[id]?.name ?? id; }
176
+ function presetNote(id: string): string { return presetData[id]?.note ?? ''; }
177
+
178
+ function formatDuration(seconds: number): string {
179
+ if (seconds < 60) return `${Math.round(seconds)} s`;
180
+ if (seconds < 3600) return `${Math.round(seconds / 60)} min`;
181
+ if (seconds < 86400) return `${(seconds / 3600).toFixed(1)} h`;
182
+ return '24 h+';
183
+ }
184
+
185
+ function formatPressure(kpa: number): string {
186
+ if (unitSystem === 'imperial') return `${(kpa * 0.145038).toLocaleString('en', { maximumFractionDigits: 1 })} psi`;
187
+ return `${kpa.toLocaleString('en', { maximumFractionDigits: 0 })} kPa`;
188
+ }
189
+
190
+ function formatTemperature(celsius: number): string {
191
+ if (unitSystem === 'imperial') return `${(celsius * 9 / 5 + 32).toFixed(1)} \u00b0F`;
192
+ return `${Math.round(celsius)} \u00b0C`;
193
+ }
194
+
195
+ function polarPoint(angle: number, radius: number) {
196
+ const radians = (angle - 90) * Math.PI / 180;
197
+ return {
198
+ x: 230 + Math.cos(radians) * radius,
199
+ y: 230 + Math.sin(radians) * radius,
200
+ };
201
+ }
202
+
203
+ function arcPath(progress: number): string {
204
+ const endAngle = Math.max(0.01, progress) * 359.8;
205
+ const start = polarPoint(0, 188);
206
+ const end = polarPoint(endAngle, 188);
207
+ const largeArc = endAngle > 180 ? 1 : 0;
208
+ return `M ${start.x} ${start.y} A 188 188 0 ${largeArc} 1 ${end.x} ${end.y}`;
209
+ }
210
+
211
+ function drawBars(result: ReturnType<typeof estimateSurvival>) {
212
+ if (!bars) return;
213
+ bars.textContent = '';
214
+
215
+ result.hazards.forEach((hazard, index) => {
216
+ const angle = index * 72;
217
+ const inner = polarPoint(angle, 111);
218
+ const outer = polarPoint(angle, 118 + hazard.score * 84);
219
+ const label = polarPoint(angle, 214);
220
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
221
+ line.setAttribute('x1', `${inner.x}`);
222
+ line.setAttribute('y1', `${inner.y}`);
223
+ line.setAttribute('x2', `${outer.x}`);
224
+ line.setAttribute('y2', `${outer.y}`);
225
+ line.classList.add('survival-hazard-spoke');
226
+ bars.append(line);
227
+
228
+ const node = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
229
+ node.setAttribute('cx', `${outer.x}`);
230
+ node.setAttribute('cy', `${outer.y}`);
231
+ node.setAttribute('r', `${3.2 + hazard.score * 2.8}`);
232
+ node.classList.add('survival-hazard-node');
233
+ bars.append(node);
234
+
235
+ const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
236
+ text.setAttribute('x', `${label.x}`);
237
+ text.setAttribute('y', `${label.y}`);
238
+ text.textContent = hazardLabels[hazard.hazard];
239
+ text.classList.add('survival-spoke-label');
240
+ bars.append(text);
241
+ });
242
+ }
243
+
244
+ function drawParticles(result: ReturnType<typeof estimateSurvival>) {
245
+ if (!particles) return;
246
+ particles.textContent = '';
247
+
248
+ const stress = Math.max(...result.hazards.map((hazard) => hazard.score));
249
+ particles.style.setProperty('--particle-speed', `${Math.max(9, 24 - stress * 13)}s`);
250
+ particles.style.setProperty('--particle-chaos', `${stress * 8}px`);
251
+ for (let index = 0; index < 210; index += 1) {
252
+ const lane = index % 9;
253
+ const angle = index * 17.8 + lane * 11;
254
+ const radius = 124 + lane * 10 + Math.sin(index * 1.37) * 8;
255
+ const point = polarPoint(angle, radius);
256
+ const tangent = polarPoint(angle + 7 + stress * 12, radius + 6);
257
+ const dash = document.createElementNS('http://www.w3.org/2000/svg', 'line');
258
+ dash.setAttribute('x1', `${point.x.toFixed(2)}`);
259
+ dash.setAttribute('y1', `${point.y.toFixed(2)}`);
260
+ dash.setAttribute('x2', `${tangent.x.toFixed(2)}`);
261
+ dash.setAttribute('y2', `${tangent.y.toFixed(2)}`);
262
+ dash.style.setProperty('--particle-opacity', `${0.16 + ((index % 7) / 12) + stress * 0.16}`);
263
+ dash.style.setProperty('--particle-delay', `${index * -42}ms`);
264
+ dash.classList.add('survival-particle');
265
+ particles.append(dash);
266
+ }
267
+ }
268
+
269
+ function resizeParticleCanvas() {
270
+ if (!particleCanvas) return;
271
+ const rect = particleCanvas.getBoundingClientRect();
272
+ const scale = window.devicePixelRatio || 1;
273
+ particleCanvas.width = Math.max(1, Math.floor(rect.width * scale));
274
+ particleCanvas.height = Math.max(1, Math.floor(rect.height * scale));
275
+ }
276
+
277
+ function animateParticleCanvas() {
278
+ if (!particleCanvas || !particleContext) return;
279
+
280
+ const width = particleCanvas.width;
281
+ const height = particleCanvas.height;
282
+ const centerX = width / 2;
283
+ const centerY = height / 2;
284
+ const orbitRadius = Math.min(width, height) * 0.36;
285
+ const density = Math.min(1.4, 0.55 + Math.log10(Math.max(1, particleState.pressure + 1)) * 0.18);
286
+ const chaos = particleState.stress * 0.014 + particleState.wind * 0.00008;
287
+
288
+ particleContext.clearRect(0, 0, width, height);
289
+ particleContext.lineCap = 'round';
290
+ particleContext.strokeStyle = `rgba(133, 231, 217, ${0.18 + particleState.stress * 0.16})`;
291
+ particleContext.lineWidth = Math.max(1, width / 520);
292
+
293
+ particleState.particles.forEach((particle) => {
294
+ particle.angle += particle.speed * (1 + particleState.wind / 32 + particleState.stress * 2.4);
295
+ const wave = Math.sin(particle.angle * 3 + particle.lane) * chaos;
296
+ const radius = orbitRadius * (particle.radius * density + wave);
297
+ const nextRadius = radius + 7 + particleState.wind * 0.045;
298
+ const x = centerX + Math.cos(particle.angle) * radius;
299
+ const y = centerY + Math.sin(particle.angle) * radius;
300
+ const x2 = centerX + Math.cos(particle.angle + 0.055 + chaos) * nextRadius;
301
+ const y2 = centerY + Math.sin(particle.angle + 0.055 + chaos) * nextRadius;
302
+
303
+ particleContext.beginPath();
304
+ particleContext.moveTo(x, y);
305
+ particleContext.lineTo(x2, y2);
306
+ particleContext.stroke();
307
+ });
308
+
309
+ window.requestAnimationFrame(animateParticleCanvas);
310
+ }
311
+
312
+ function drawTimeline(points: SurvivalTimelinePoint[]) {
313
+ const width = 680;
314
+ const height = 118;
315
+ const startX = 20;
316
+ const startY = 138;
317
+ const step = width / Math.max(1, points.length - 1);
318
+ const coordinates = points.map((point, index) => {
319
+ const x = startX + step * index;
320
+ const y = startY - point.total * height;
321
+ return `${x.toFixed(1)} ${y.toFixed(1)}`;
322
+ });
323
+ timelineLine?.setAttribute('d', `M ${coordinates.join(' L ')}`);
324
+ timelineFill?.setAttribute('d', `M ${startX} ${startY} L ${coordinates.join(' L ')} L 700 ${startY} Z`);
325
+ const coldCoordinates = points.map((point, index) => {
326
+ const x = startX + step * index;
327
+ const y = startY - Math.max(point.temperature, point.oxygen) * height * 0.74;
328
+ return `${x.toFixed(1)} ${y.toFixed(1)}`;
329
+ });
330
+ timelineCold?.setAttribute('d', `M ${startX} ${startY} L ${coldCoordinates.join(' L ')} L 700 ${startY} Z`);
331
+ }
332
+
333
+ function setPreset(preset: PlanetAtmospherePreset) {
334
+ if (!pressureInput || !temperatureInput || !oxygenInput || !co2Input) return;
335
+ pressureInput.value = `${preset.pressureKpa}`;
336
+ temperatureInput.value = `${preset.temperatureC}`;
337
+ oxygenInput.value = `${preset.oxygenFraction * 100}`;
338
+ co2Input.value = `${preset.co2Fraction * 100}`;
339
+ note!.textContent = presetNote(preset.id);
340
+ } function findPreset(): PlanetAtmospherePreset {
341
+ return ATMOSPHERE_PRESETS.find((p) => p.id === bodySelect?.value) ?? ATMOSPHERE_PRESETS[0];
342
+ }
343
+
344
+ function update() {
345
+ if (!(pressureInput && temperatureInput && oxygenInput && co2Input)) return;
346
+ const selectedPreset = findPreset();
347
+ const result = estimateSurvival({
348
+ pressureKpa: Number(pressureInput!.value),
349
+ temperatureC: Number(temperatureInput!.value),
350
+ oxygenFraction: Number(oxygenInput!.value) / 100,
351
+ co2Fraction: Number(co2Input!.value) / 100,
352
+ toxicIndex: selectedPreset.toxicIndex,
353
+ windMps: selectedPreset.windMps,
354
+ });
355
+ particleState.pressure = Number(pressureInput!.value);
356
+ particleState.wind = selectedPreset.windMps;
357
+ particleState.stress = Math.max(...result.hazards.map((h) => h.score));
358
+ pressureOutput!.textContent = formatPressure(Number(pressureInput!.value)); temperatureOutput!.textContent = formatTemperature(Number(temperatureInput!.value));
359
+ oxygenOutput!.textContent = `${Number(oxygenInput!.value).toFixed(1)}%`;
360
+ co2Output!.textContent = `${Number(co2Input!.value).toFixed(1)}%`;
361
+ coreTime!.textContent = formatDuration(result.survivalSeconds);
362
+ coreLabel!.textContent = uiData.estimatedSurvival;
363
+ limiter!.textContent = hazardLabels[result.limitingHazard];
364
+ verdict!.textContent = verdictLabels[result.verdict];
365
+ summaryTitle!.textContent = `${presetName(selectedPreset.id)} ${uiData.survivalEnvelope}`;
366
+ summaryTime!.textContent = formatDuration(result.survivalSeconds);
367
+ summaryLimiter!.textContent = hazardLabels[result.limitingHazard];
368
+ summaryMode!.textContent = unitSystem === 'imperial' ? uiData.imperial : uiData.metric;
369
+ riskRing?.setAttribute('d', arcPath(Math.min(1, result.survivalSeconds / 3600)));
370
+ drawBars(result);
371
+ drawParticles(result);
372
+ drawTimeline(result.timeline);
373
+ }
374
+
375
+ ATMOSPHERE_PRESETS.forEach((preset) => {
376
+ const option = document.createElement('option');
377
+ option.value = preset.id;
378
+ option.textContent = presetName(preset.id);
379
+ bodySelect?.append(option);
380
+ });
381
+
382
+ bodySelect?.addEventListener('change', () => {
383
+ const preset = ATMOSPHERE_PRESETS.find((item) => item.id === bodySelect.value) ?? ATMOSPHERE_PRESETS[0];
384
+ setPreset(preset);
385
+ update();
386
+ });
387
+
388
+ [pressureInput, temperatureInput, oxygenInput, co2Input].forEach((input) => input?.addEventListener('input', update));
389
+ window.addEventListener('resize', resizeParticleCanvas);
390
+ unitButtons.forEach((button) => {
391
+ button.addEventListener('click', () => {
392
+ unitSystem = button.dataset.units === 'imperial' ? 'imperial' : 'metric';
393
+ unitButtons.forEach((item) => {
394
+ item.classList.toggle('is-active', item === button);
395
+ item.classList.toggle('active', item === button);
396
+ });
397
+ update();
398
+ });
399
+ });
400
+ setPreset(ATMOSPHERE_PRESETS[0]);
401
+ resizeParticleCanvas();
402
+ window.requestAnimationFrame(animateParticleCanvas);
403
+ update();
404
+ </script>
@@ -0,0 +1,26 @@
1
+ import type { ScienceToolEntry } from '../../types';
2
+
3
+ export const planetAtmosphereSurvival: ScienceToolEntry = {
4
+ id: 'planet-atmosphere-survival',
5
+ icons: {
6
+ bg: 'mdi:planet',
7
+ fg: 'mdi:lungs',
8
+ },
9
+ i18n: {
10
+ de: () => import('./i18n/de').then((m) => m.content),
11
+ en: () => import('./i18n/en').then((m) => m.content),
12
+ es: () => import('./i18n/es').then((m) => m.content),
13
+ fr: () => import('./i18n/fr').then((m) => m.content),
14
+ id: () => import('./i18n/id').then((m) => m.content),
15
+ it: () => import('./i18n/it').then((m) => m.content),
16
+ ja: () => import('./i18n/ja').then((m) => m.content),
17
+ ko: () => import('./i18n/ko').then((m) => m.content),
18
+ nl: () => import('./i18n/nl').then((m) => m.content),
19
+ pl: () => import('./i18n/pl').then((m) => m.content),
20
+ pt: () => import('./i18n/pt').then((m) => m.content),
21
+ ru: () => import('./i18n/ru').then((m) => m.content),
22
+ sv: () => import('./i18n/sv').then((m) => m.content),
23
+ tr: () => import('./i18n/tr').then((m) => m.content),
24
+ zh: () => import('./i18n/zh').then((m) => m.content),
25
+ },
26
+ };