@jjlmoya/utils-science 1.44.0 → 1.45.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/exoplanet-biosignature-detector/bibliography.astro +14 -0
  8. package/src/tool/exoplanet-biosignature-detector/bibliography.ts +16 -0
  9. package/src/tool/exoplanet-biosignature-detector/component.astro +351 -0
  10. package/src/tool/exoplanet-biosignature-detector/entry.ts +26 -0
  11. package/src/tool/exoplanet-biosignature-detector/exoplanet-biosignature-detector.css +516 -0
  12. package/src/tool/exoplanet-biosignature-detector/i18n/de.ts +196 -0
  13. package/src/tool/exoplanet-biosignature-detector/i18n/en.ts +196 -0
  14. package/src/tool/exoplanet-biosignature-detector/i18n/es.ts +196 -0
  15. package/src/tool/exoplanet-biosignature-detector/i18n/fr.ts +196 -0
  16. package/src/tool/exoplanet-biosignature-detector/i18n/id.ts +196 -0
  17. package/src/tool/exoplanet-biosignature-detector/i18n/it.ts +196 -0
  18. package/src/tool/exoplanet-biosignature-detector/i18n/ja.ts +196 -0
  19. package/src/tool/exoplanet-biosignature-detector/i18n/ko.ts +196 -0
  20. package/src/tool/exoplanet-biosignature-detector/i18n/nl.ts +196 -0
  21. package/src/tool/exoplanet-biosignature-detector/i18n/pl.ts +149 -0
  22. package/src/tool/exoplanet-biosignature-detector/i18n/pt.ts +149 -0
  23. package/src/tool/exoplanet-biosignature-detector/i18n/ru.ts +149 -0
  24. package/src/tool/exoplanet-biosignature-detector/i18n/sv.ts +149 -0
  25. package/src/tool/exoplanet-biosignature-detector/i18n/tr.ts +149 -0
  26. package/src/tool/exoplanet-biosignature-detector/i18n/zh.ts +196 -0
  27. package/src/tool/exoplanet-biosignature-detector/index.ts +11 -0
  28. package/src/tool/exoplanet-biosignature-detector/logic.ts +169 -0
  29. package/src/tool/exoplanet-biosignature-detector/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.44.0",
3
+ "version": "1.45.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -25,10 +25,11 @@ import { crystalLatticeStructureFinder } from '../tool/crystal-lattice-structure
25
25
  import { fermiParadoxFilterLab } from '../tool/fermi-paradox-filter-lab/index';
26
26
  import { epidemicSirSimulator } from '../tool/epidemic-sir-simulator/index';
27
27
  import { blackHoleEventHorizonSimulator } from '../tool/black-hole-event-horizon-simulator/index';
28
+ import { exoplanetBiosignatureDetector } from '../tool/exoplanet-biosignature-detector/index';
28
29
 
29
30
  export const scienceCategory: ScienceCategoryEntry = {
30
31
  icon: 'mdi:flask',
31
- tools: [colonyCounter, asteroidImpact, microwaveDetector, simulationProbability, cellularRenewal, cosmicInflation, temperatureTimeline, lorenzAttractor, stellarHabitabilityZone, radioactiveDecay, naturalSelectionDrift, entropySecondLaw, doubleSlitDecoherence, phaseDiagramCriticalPoints, twinParadoxVisualizer, mandelbrotFractal, planetAtmosphereSurvival, threeBodyProblem, rocheLimitSatelliteDisruption, dysonSphereEnergyCapture, globalAlbedoSnowballSimulator, conwayLifeRuleLab, crystalLatticeStructureFinder, fermiParadoxFilterLab, epidemicSirSimulator, blackHoleEventHorizonSimulator],
32
+ tools: [colonyCounter, asteroidImpact, microwaveDetector, simulationProbability, cellularRenewal, cosmicInflation, temperatureTimeline, lorenzAttractor, stellarHabitabilityZone, radioactiveDecay, naturalSelectionDrift, entropySecondLaw, doubleSlitDecoherence, phaseDiagramCriticalPoints, twinParadoxVisualizer, mandelbrotFractal, planetAtmosphereSurvival, threeBodyProblem, rocheLimitSatelliteDisruption, dysonSphereEnergyCapture, globalAlbedoSnowballSimulator, conwayLifeRuleLab, crystalLatticeStructureFinder, fermiParadoxFilterLab, epidemicSirSimulator, blackHoleEventHorizonSimulator, exoplanetBiosignatureDetector],
32
33
  i18n: {
33
34
  es: () => import('./i18n/es').then((m) => m.content),
34
35
  en: () => import('./i18n/en').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -25,6 +25,7 @@ export { crystalLatticeStructureFinder } from './tool/crystal-lattice-structure-
25
25
  export { fermiParadoxFilterLab } from './tool/fermi-paradox-filter-lab/entry';
26
26
  export { epidemicSirSimulator } from './tool/epidemic-sir-simulator/entry';
27
27
  export { blackHoleEventHorizonSimulator } from './tool/black-hole-event-horizon-simulator/entry';
28
+ export { exoplanetBiosignatureDetector } from './tool/exoplanet-biosignature-detector/entry';
28
29
  export { scienceCategory } from './category';
29
30
  import { asteroidImpact } from './tool/asteroid-impact/entry';
30
31
  import { cellularRenewal } from './tool/cellular-renewal/entry';
@@ -52,4 +53,5 @@ import { crystalLatticeStructureFinder } from './tool/crystal-lattice-structure-
52
53
  import { fermiParadoxFilterLab } from './tool/fermi-paradox-filter-lab/entry';
53
54
  import { epidemicSirSimulator } from './tool/epidemic-sir-simulator/entry';
54
55
  import { blackHoleEventHorizonSimulator } from './tool/black-hole-event-horizon-simulator/entry';
55
- export const ALL_ENTRIES = [asteroidImpact, cellularRenewal, colonyCounter, microwaveDetector, simulationProbability, cosmicInflation, temperatureTimeline, lorenzAttractor, stellarHabitabilityZone, radioactiveDecay, naturalSelectionDrift, entropySecondLaw, doubleSlitDecoherence, phaseDiagramCriticalPoints, twinParadoxVisualizer, mandelbrotFractal, planetAtmosphereSurvival, threeBodyProblem, rocheLimitSatelliteDisruption, dysonSphereEnergyCapture, globalAlbedoSnowballSimulator, conwayLifeRuleLab, crystalLatticeStructureFinder, fermiParadoxFilterLab, epidemicSirSimulator, blackHoleEventHorizonSimulator];
56
+ import { exoplanetBiosignatureDetector } from './tool/exoplanet-biosignature-detector/entry';
57
+ export const ALL_ENTRIES = [asteroidImpact, cellularRenewal, colonyCounter, microwaveDetector, simulationProbability, cosmicInflation, temperatureTimeline, lorenzAttractor, stellarHabitabilityZone, radioactiveDecay, naturalSelectionDrift, entropySecondLaw, doubleSlitDecoherence, phaseDiagramCriticalPoints, twinParadoxVisualizer, mandelbrotFractal, planetAtmosphereSurvival, threeBodyProblem, rocheLimitSatelliteDisruption, dysonSphereEnergyCapture, globalAlbedoSnowballSimulator, conwayLifeRuleLab, crystalLatticeStructureFinder, fermiParadoxFilterLab, epidemicSirSimulator, blackHoleEventHorizonSimulator, exoplanetBiosignatureDetector];
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export { CRYSTAL_LATTICE_STRUCTURE_FINDER_TOOL } from './tool/crystal-lattice-st
26
26
  export { FERMI_PARADOX_FILTER_LAB_TOOL } from './tool/fermi-paradox-filter-lab/index';
27
27
  export { EPIDEMIC_SIR_SIMULATOR_TOOL } from './tool/epidemic-sir-simulator/index';
28
28
  export { BLACK_HOLE_EVENT_HORIZON_SIMULATOR_TOOL } from './tool/black-hole-event-horizon-simulator/index';
29
+ export { EXOPLANET_BIOSIGNATURE_DETECTOR_TOOL } from './tool/exoplanet-biosignature-detector/index';
29
30
 
30
31
  export type {
31
32
  KnownLocale,
@@ -18,7 +18,7 @@ describe('Locale Completeness Validation', () => {
18
18
  });
19
19
  });
20
20
 
21
- it('all 26 tools registered', () => {
22
- expect(ALL_TOOLS.length).toBe(26);
21
+ it('all 27 tools registered', () => {
22
+ expect(ALL_TOOLS.length).toBe(27);
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 26 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(26);
7
+ it('should have 27 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(27);
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 { exoplanetBiosignatureDetector } 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 exoplanetBiosignatureDetector.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: 'The photochemistry of habitable planets',
6
+ url: 'https://www.annualreviews.org/content/journals/10.1146/annurev-astro-081817-051856',
7
+ },
8
+ {
9
+ name: 'Exoplanet biosignatures: a review of remotely detectable signs of life',
10
+ url: 'https://www.liebertpub.com/doi/10.1089/ast.2017.1729',
11
+ },
12
+ {
13
+ name: 'The Habitable Worlds Observatory final report',
14
+ url: 'https://science.nasa.gov/astrophysics/programs/habitable-worlds-observatory/',
15
+ },
16
+ ];
@@ -0,0 +1,351 @@
1
+ ---
2
+ import './exoplanet-biosignature-detector.css';
3
+
4
+ interface Props {
5
+ ui: Record<string, string>;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ ---
10
+
11
+ <div
12
+ class="biosig-root"
13
+ id="biosig-root"
14
+ data-verdict-weak={ui.verdictWeak}
15
+ data-verdict-promising={ui.verdictPromising}
16
+ data-verdict-strong={ui.verdictStrong}
17
+ data-verdict-ambiguous={ui.verdictAmbiguous}
18
+ data-percent-unit={ui.percentUnit}
19
+ data-nanometer-unit={ui.nanometerUnit}
20
+ >
21
+ <div class="biosig-live biosig-live-top"><span></span>{ui.dataLive}</div>
22
+ <section class="biosig-scope" aria-label={ui.visualization}>
23
+ <div class="biosig-planet">
24
+ <canvas id="biosig-planet-canvas" width="640" height="640" aria-hidden="true"></canvas>
25
+ <span></span>
26
+ </div>
27
+ <div class="biosig-float biosig-float-bio">
28
+ <span>{ui.biologicalIndex}</span>
29
+ <strong id="biosig-bio">{ui.pendingValue}</strong>
30
+ </div>
31
+ <div class="biosig-float biosig-float-tech">
32
+ <span>{ui.technologicalIndex}</span>
33
+ <strong id="biosig-tech">{ui.pendingValue}</strong>
34
+ </div>
35
+ <div class="biosig-float biosig-float-risk">
36
+ <span>{ui.falsePositiveRisk}</span>
37
+ <strong id="biosig-risk">{ui.pendingValue}</strong>
38
+ </div>
39
+ <div class="biosig-spectrum" id="biosig-spectrum">
40
+ <i class="biosig-line biosig-line-o2"></i>
41
+ <i class="biosig-line biosig-line-ch4"></i>
42
+ <i class="biosig-line biosig-line-o3"></i>
43
+ <b id="biosig-marker-o2"></b>
44
+ <b id="biosig-marker-ch4"></b>
45
+ <b id="biosig-marker-o3"></b>
46
+ </div>
47
+ <div class="biosig-readout biosig-float">
48
+ <span>{ui.habitabilityIndex}</span>
49
+ <strong id="biosig-habitability">{ui.pendingValue}</strong>
50
+ </div>
51
+ </section>
52
+
53
+ <section class="biosig-console">
54
+ <div class="biosig-controls">
55
+ <label class="biosig-select-row">
56
+ <span>{ui.starClass}</span>
57
+ <select id="biosig-star">
58
+ <option value="k-dwarf">{ui.starK}</option>
59
+ <option value="sun-like" selected>{ui.starSun}</option>
60
+ <option value="m-dwarf">{ui.starM}</option>
61
+ </select>
62
+ </label>
63
+ <label>
64
+ <span>{ui.noise}</span>
65
+ <strong id="biosig-noise-value">{ui.percentUnit.replace('{value}', '18')}</strong>
66
+ <input id="biosig-noise" type="range" min="0" max="70" value="18" step="1" />
67
+ </label>
68
+ <label>
69
+ <span>{ui.oxygenLine}</span>
70
+ <strong id="biosig-o2-value">{ui.nanometerUnit.replace('{value}', '760')}</strong>
71
+ <input id="biosig-o2" type="range" min="630" max="820" value="760" step="1" />
72
+ </label>
73
+ <label>
74
+ <span>{ui.methaneLine}</span>
75
+ <strong id="biosig-ch4-value">{ui.nanometerUnit.replace('{value}', '1660')}</strong>
76
+ <input id="biosig-ch4" type="range" min="820" max="1780" value="1660" step="2" />
77
+ </label>
78
+ <label>
79
+ <span>{ui.ozoneLine}</span>
80
+ <strong id="biosig-o3-value">{ui.nanometerUnit.replace('{value}', '960')}</strong>
81
+ <input id="biosig-o3" type="range" min="230" max="1040" value="960" step="1" />
82
+ </label>
83
+ <div class="biosig-context">
84
+ <label>
85
+ <span>{ui.waterVapor}</span>
86
+ <strong id="biosig-water-value">{ui.percentUnit.replace('{value}', '64')}</strong>
87
+ <input id="biosig-water" type="range" min="0" max="100" value="64" step="1" />
88
+ </label>
89
+ <label>
90
+ <span>{ui.carbonDioxide}</span>
91
+ <strong id="biosig-co2-value">{ui.percentUnit.replace('{value}', '34')}</strong>
92
+ <input id="biosig-co2" type="range" min="0" max="100" value="34" step="1" />
93
+ </label>
94
+ </div>
95
+ </div>
96
+
97
+ <div class="biosig-evidence">
98
+ <div class="biosig-conclusion">
99
+ <span>{ui.conclusion}</span>
100
+ <p id="biosig-verdict">{ui.statusReady}</p>
101
+ </div>
102
+ <div class="biosig-evidence-grid">
103
+ <div><span>{ui.oxygenShort}</span><i id="biosig-o2-bar"></i><b id="biosig-o2-pct">{ui.pendingValue}</b></div>
104
+ <div><span>{ui.methaneShort}</span><i id="biosig-ch4-bar"></i><b id="biosig-ch4-pct">{ui.pendingValue}</b></div>
105
+ <div><span>{ui.ozoneShort}</span><i id="biosig-o3-bar"></i><b id="biosig-o3-pct">{ui.pendingValue}</b></div>
106
+ </div>
107
+ </div>
108
+ <div class="biosig-live biosig-live-bottom"><span></span>{ui.dataLive}</div>
109
+ </section>
110
+ </div>
111
+
112
+ <script>
113
+ import { analyzeBiosignatures } from './logic';
114
+ import type { StarClass } from './logic';
115
+
116
+ const root = document.getElementById('biosig-root');
117
+ if (root) {
118
+ const star = document.getElementById('biosig-star') as HTMLSelectElement;
119
+ const noise = document.getElementById('biosig-noise') as HTMLInputElement;
120
+ const oxygen = document.getElementById('biosig-o2') as HTMLInputElement;
121
+ const methane = document.getElementById('biosig-ch4') as HTMLInputElement;
122
+ const ozone = document.getElementById('biosig-o3') as HTMLInputElement;
123
+ const water = document.getElementById('biosig-water') as HTMLInputElement;
124
+ const co2 = document.getElementById('biosig-co2') as HTMLInputElement;
125
+
126
+ const setText = (id: string, text: string) => {
127
+ const node = document.getElementById(id);
128
+ if (node) node.textContent = text;
129
+ };
130
+
131
+ const formatUnit = (template: string | undefined, value: string) => (template || '{value}').replace('{value}', value);
132
+
133
+ const setBar = (id: string, value: number) => {
134
+ const node = document.getElementById(id);
135
+ if (node) node.style.setProperty('--meter-value', `${Math.min(100, Math.max(0, value))}%`);
136
+ };
137
+
138
+ const setMarker = (id: string, value: number, min: number, max: number) => {
139
+ const node = document.getElementById(id);
140
+ if (node) node.style.left = `${((value - min) / (max - min)) * 100}%`;
141
+ };
142
+
143
+ const setControlFill = (input: HTMLInputElement) => {
144
+ const min = Number(input.min || 0);
145
+ const max = Number(input.max || 100);
146
+ const value = Number(input.value);
147
+ input.style.setProperty('--control-fill', `${((value - min) / (max - min)) * 100}%`);
148
+ };
149
+
150
+ const planetCanvas = document.getElementById('biosig-planet-canvas') as HTMLCanvasElement | null;
151
+ const planetContext = planetCanvas?.getContext('2d') ?? null;
152
+ let planetState = {
153
+ habitability: 55,
154
+ risk: 18,
155
+ noise: 18,
156
+ };
157
+ let animationFrame = 0;
158
+
159
+ function clamp01(value: number) {
160
+ return Math.max(0, Math.min(1, value));
161
+ }
162
+
163
+ function getPlanetPalette(time: number) {
164
+ const vitality = clamp01(planetState.habitability / 100);
165
+ const warning = clamp01(planetState.risk / 100);
166
+ const noiseLevel = clamp01(planetState.noise / 70);
167
+
168
+ return {
169
+ vitality,
170
+ warning,
171
+ noiseLevel,
172
+ pulse: Math.sin(time / 900) * 0.5 + 0.5,
173
+ orbit: time / 2200,
174
+ hue: 150 - (warning * 70) + (vitality * 46),
175
+ saturation: 22 + (vitality * 66) - (warning * 12),
176
+ light: 26 + (vitality * 34) - (warning * 8),
177
+ };
178
+ }
179
+
180
+ function paintAtmosphere(centerX: number, centerY: number, radius: number, palette: ReturnType<typeof getPlanetPalette>) {
181
+ if (!planetContext) return;
182
+
183
+ const atmosphere = planetContext.createRadialGradient(centerX, centerY, radius * 0.72, centerX, centerY, radius * 1.42);
184
+ atmosphere.addColorStop(0, `hsla(${palette.hue}, 92%, 70%, ${0.22 + palette.vitality * 0.34 + palette.pulse * 0.08})`);
185
+ atmosphere.addColorStop(0.62, `hsla(${palette.hue}, 86%, 62%, ${0.12 + palette.vitality * 0.2})`);
186
+ atmosphere.addColorStop(1, 'rgba(255,255,255,0)');
187
+ planetContext.fillStyle = atmosphere;
188
+ planetContext.beginPath();
189
+ planetContext.arc(centerX, centerY, radius * (1.32 + palette.pulse * 0.1), 0, Math.PI * 2);
190
+ planetContext.fill();
191
+ }
192
+
193
+ function paintBody(centerX: number, centerY: number, radius: number, palette: ReturnType<typeof getPlanetPalette>) {
194
+ if (!planetContext) return;
195
+
196
+ const body = planetContext.createRadialGradient(centerX - radius * 0.38, centerY - radius * 0.28, radius * 0.05, centerX, centerY, radius);
197
+ body.addColorStop(0, `hsl(${palette.hue + 42}, ${palette.saturation + 8}%, ${Math.min(82, palette.light + 26)}%)`);
198
+ body.addColorStop(0.26, `hsl(${palette.hue}, ${palette.saturation}%, ${palette.light}%)`);
199
+ body.addColorStop(0.58, `hsl(${palette.hue - 32}, ${Math.max(22, palette.saturation - 18)}%, ${Math.max(19, palette.light - 18)}%)`);
200
+ body.addColorStop(1, `hsl(${220 + palette.warning * 22}, 42%, ${8 + palette.vitality * 4}%)`);
201
+ planetContext.fillStyle = body;
202
+ planetContext.fillRect(centerX - radius, centerY - radius, radius * 2, radius * 2);
203
+ }
204
+
205
+ function paintShadow(centerX: number, centerY: number, radius: number, palette: ReturnType<typeof getPlanetPalette>) {
206
+ if (!planetContext) return;
207
+
208
+ const shadow = planetContext.createRadialGradient(centerX + radius * 0.28, centerY + radius * 0.12, radius * 0.2, centerX + radius * 0.48, centerY + radius * 0.32, radius * 1.08);
209
+ shadow.addColorStop(0, 'rgba(0,0,0,0)');
210
+ shadow.addColorStop(0.55, `rgba(0,0,0,${0.18 + palette.warning * 0.14})`);
211
+ shadow.addColorStop(1, `rgba(0,0,0,${0.68 + palette.warning * 0.16})`);
212
+ planetContext.fillStyle = shadow;
213
+ planetContext.fillRect(centerX - radius, centerY - radius, radius * 2, radius * 2);
214
+ }
215
+
216
+ function paintCloudBands(centerX: number, centerY: number, radius: number, palette: ReturnType<typeof getPlanetPalette>) {
217
+ if (!planetContext) return;
218
+
219
+ for (let i = 0; i < 8; i += 1) {
220
+ const drift = Math.sin(palette.orbit + i * 0.9) * radius * 0.035;
221
+ planetContext.beginPath();
222
+ planetContext.ellipse(centerX - radius * (0.27 + i * 0.035) + drift, centerY - radius * (0.18 - i * 0.047), radius * (0.26 - i * 0.016), radius * (0.048 + palette.noiseLevel * 0.018), -0.55, 0, Math.PI * 2);
223
+ planetContext.fillStyle = `hsla(${palette.hue + 68}, 78%, 80%, ${0.08 + palette.vitality * 0.16 - palette.warning * 0.03})`;
224
+ planetContext.fill();
225
+ }
226
+ }
227
+
228
+ function paintAtmosphericSparkles(centerX: number, centerY: number, radius: number, palette: ReturnType<typeof getPlanetPalette>) {
229
+ if (!planetContext) return;
230
+
231
+ planetContext.globalCompositeOperation = 'screen';
232
+ for (let i = 0; i < 18; i += 1) {
233
+ const angle = palette.orbit * 0.32 + i * 2.399;
234
+ const distance = radius * (0.18 + ((i * 37) % 71) / 100);
235
+ planetContext.beginPath();
236
+ planetContext.arc(centerX + Math.cos(angle) * distance, centerY + Math.sin(angle * 1.17) * distance * 0.72, 1.2 + palette.noiseLevel * 1.8, 0, Math.PI * 2);
237
+ planetContext.fillStyle = `hsla(${palette.hue + 20}, 90%, 78%, ${0.04 + palette.vitality * 0.08 + palette.noiseLevel * 0.06})`;
238
+ planetContext.fill();
239
+ }
240
+ planetContext.globalCompositeOperation = 'source-over';
241
+ }
242
+
243
+ function drawPlanet(time: number) {
244
+ if (!planetCanvas || !planetContext) return;
245
+
246
+ const width = planetCanvas.width;
247
+ const height = planetCanvas.height;
248
+ const centerX = width * 0.5;
249
+ const centerY = height * 0.5;
250
+ const radius = width * 0.38;
251
+ const palette = getPlanetPalette(time);
252
+
253
+ planetContext.clearRect(0, 0, width, height);
254
+ paintAtmosphere(centerX, centerY, radius, palette);
255
+ planetContext.save();
256
+ planetContext.beginPath();
257
+ planetContext.arc(centerX, centerY, radius, 0, Math.PI * 2);
258
+ planetContext.clip();
259
+ paintBody(centerX, centerY, radius, palette);
260
+ paintShadow(centerX, centerY, radius, palette);
261
+ paintCloudBands(centerX, centerY, radius, palette);
262
+ paintAtmosphericSparkles(centerX, centerY, radius, palette);
263
+ planetContext.restore();
264
+ }
265
+
266
+ function animatePlanet(time: number) {
267
+ drawPlanet(time);
268
+ animationFrame = requestAnimationFrame(animatePlanet);
269
+ }
270
+
271
+ function renderControlValues() {
272
+ setText('biosig-noise-value', formatUnit(root.dataset.percentUnit, noise.value));
273
+ setText('biosig-o2-value', formatUnit(root.dataset.nanometerUnit, oxygen.value));
274
+ setText('biosig-ch4-value', formatUnit(root.dataset.nanometerUnit, methane.value));
275
+ setText('biosig-o3-value', formatUnit(root.dataset.nanometerUnit, ozone.value));
276
+ setText('biosig-water-value', formatUnit(root.dataset.percentUnit, water.value));
277
+ setText('biosig-co2-value', formatUnit(root.dataset.percentUnit, co2.value));
278
+ [noise, oxygen, methane, ozone, water, co2].forEach(setControlFill);
279
+ }
280
+
281
+ function renderScores(result: ReturnType<typeof analyzeBiosignatures>) {
282
+ setText('biosig-habitability', `${result.habitabilityIndex.toFixed(0)}`);
283
+ setText('biosig-bio', `${result.biologicalIndex.toFixed(0)}`);
284
+ setText('biosig-tech', `${result.technologicalIndex.toFixed(0)}`);
285
+ setText('biosig-risk', formatUnit(root.dataset.percentUnit, result.falsePositiveRisk.toFixed(0)));
286
+ setText('biosig-verdict', root.dataset[`verdict${result.verdict.charAt(0).toUpperCase()}${result.verdict.slice(1)}`] || '');
287
+ }
288
+
289
+ function evidenceConfidence(score: ReturnType<typeof analyzeBiosignatures>['evidence'][number] | undefined) {
290
+ return score?.confidence ?? 0;
291
+ }
292
+
293
+ function renderEvidence(result: ReturnType<typeof analyzeBiosignatures>) {
294
+ [
295
+ ['biosig-o2-bar', 'biosig-o2-pct', result.evidence[0]],
296
+ ['biosig-ch4-bar', 'biosig-ch4-pct', result.evidence[1]],
297
+ ['biosig-o3-bar', 'biosig-o3-pct', result.evidence[2]],
298
+ ].forEach(([barId, textId, evidence]) => {
299
+ const confidence = evidenceConfidence(evidence);
300
+ setBar(barId, confidence);
301
+ setText(textId, formatUnit(root.dataset.percentUnit, confidence.toFixed(0)));
302
+ });
303
+ }
304
+
305
+ function renderMarkers() {
306
+ setMarker('biosig-marker-o2', Number(oxygen.value), 630, 820);
307
+ setMarker('biosig-marker-ch4', Number(methane.value), 820, 1780);
308
+ setMarker('biosig-marker-o3', Number(ozone.value), 230, 1040);
309
+ }
310
+
311
+ function updatePlanetState(result: ReturnType<typeof analyzeBiosignatures>) {
312
+ root.dataset.verdict = result.verdict;
313
+ root.style.setProperty('--biosig-noise', `${Number(noise.value) / 100}`);
314
+ root.style.setProperty('--biosig-life', `${result.biologicalIndex}%`);
315
+ root.style.setProperty('--biosig-atmo-hue', `${172 - (result.falsePositiveRisk * 0.42) + (result.habitabilityIndex * 0.18)}`);
316
+ root.style.setProperty('--biosig-atmo-alpha', `${0.18 + (result.habitabilityIndex / 420)}`);
317
+ planetState = {
318
+ habitability: result.habitabilityIndex,
319
+ risk: result.falsePositiveRisk,
320
+ noise: Number(noise.value),
321
+ };
322
+ }
323
+
324
+ function update() {
325
+ const result = analyzeBiosignatures({
326
+ oxygenAlignmentNm: Number(oxygen.value),
327
+ methaneAlignmentNm: Number(methane.value),
328
+ ozoneAlignmentNm: Number(ozone.value),
329
+ noisePercent: Number(noise.value),
330
+ stellarClass: star.value as StarClass,
331
+ waterVaporPercent: Number(water.value),
332
+ carbonDioxidePercent: Number(co2.value),
333
+ });
334
+
335
+ renderControlValues();
336
+ renderScores(result);
337
+ renderEvidence(result);
338
+ renderMarkers();
339
+ updatePlanetState(result);
340
+ }
341
+
342
+ [star, noise, oxygen, methane, ozone, water, co2].forEach((control) => {
343
+ control.addEventListener('input', update);
344
+ control.addEventListener('change', update);
345
+ });
346
+
347
+ update();
348
+ cancelAnimationFrame(animationFrame);
349
+ animationFrame = requestAnimationFrame(animatePlanet);
350
+ }
351
+ </script>
@@ -0,0 +1,26 @@
1
+ import type { ScienceToolEntry } from '../../types';
2
+
3
+ export const exoplanetBiosignatureDetector: ScienceToolEntry = {
4
+ id: 'exoplanet-biosignature-detector',
5
+ icons: {
6
+ bg: 'mdi:planet',
7
+ fg: 'mdi:sine-wave',
8
+ },
9
+ i18n: {
10
+ en: () => import('./i18n/en').then((m) => m.content),
11
+ de: () => import('./i18n/de').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
+ };