@jjlmoya/utils-science 1.24.0 → 1.25.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 (36) 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/lorenz-attractor/i18n/es.ts +12 -4
  8. package/src/tool/lorenz-attractor/lorenz-attractor.css +56 -25
  9. package/src/tool/stellar-habitability-zone/bibliography.astro +14 -0
  10. package/src/tool/stellar-habitability-zone/bibliography.ts +12 -0
  11. package/src/tool/stellar-habitability-zone/component.astro +123 -0
  12. package/src/tool/stellar-habitability-zone/dom-updater.ts +94 -0
  13. package/src/tool/stellar-habitability-zone/entry.ts +26 -0
  14. package/src/tool/stellar-habitability-zone/i18n/de.ts +189 -0
  15. package/src/tool/stellar-habitability-zone/i18n/en.ts +189 -0
  16. package/src/tool/stellar-habitability-zone/i18n/es.ts +189 -0
  17. package/src/tool/stellar-habitability-zone/i18n/fr.ts +189 -0
  18. package/src/tool/stellar-habitability-zone/i18n/id.ts +189 -0
  19. package/src/tool/stellar-habitability-zone/i18n/it.ts +189 -0
  20. package/src/tool/stellar-habitability-zone/i18n/ja.ts +189 -0
  21. package/src/tool/stellar-habitability-zone/i18n/ko.ts +189 -0
  22. package/src/tool/stellar-habitability-zone/i18n/nl.ts +189 -0
  23. package/src/tool/stellar-habitability-zone/i18n/pl.ts +189 -0
  24. package/src/tool/stellar-habitability-zone/i18n/pt.ts +189 -0
  25. package/src/tool/stellar-habitability-zone/i18n/ru.ts +189 -0
  26. package/src/tool/stellar-habitability-zone/i18n/sv.ts +189 -0
  27. package/src/tool/stellar-habitability-zone/i18n/tr.ts +189 -0
  28. package/src/tool/stellar-habitability-zone/i18n/zh.ts +189 -0
  29. package/src/tool/stellar-habitability-zone/index.ts +11 -0
  30. package/src/tool/stellar-habitability-zone/interaction.ts +45 -0
  31. package/src/tool/stellar-habitability-zone/logic/StellarHabitabilityEngine.ts +158 -0
  32. package/src/tool/stellar-habitability-zone/renderer.ts +241 -0
  33. package/src/tool/stellar-habitability-zone/script.ts +273 -0
  34. package/src/tool/stellar-habitability-zone/seo.astro +15 -0
  35. package/src/tool/stellar-habitability-zone/stellar-habitability-zone.css +375 -0
  36. package/src/tools.ts +2 -0
@@ -0,0 +1,158 @@
1
+ export interface StellarPreset {
2
+ type: string;
3
+ name: string;
4
+ temperature: number;
5
+ luminosity: number;
6
+ mass: number;
7
+ radius: number;
8
+ color: string;
9
+ }
10
+
11
+ export interface HabitabilityLimits {
12
+ recentVenus: number;
13
+ runawayGreenhouse: number;
14
+ maximumGreenhouse: number;
15
+ earlyMars: number;
16
+ }
17
+
18
+ export interface SimulationResult {
19
+ hzLimits: HabitabilityLimits;
20
+ equilibriumTemperature: number;
21
+ surfaceTemperature: number;
22
+ orbitalPeriod: number;
23
+ orbitalVelocity: number;
24
+ stellarFlux: number;
25
+ status: 'too-hot' | 'habitable' | 'too-cold';
26
+ }
27
+
28
+ export const STELLAR_PRESETS: StellarPreset[] = [
29
+ {
30
+ type: 'O',
31
+ name: 'O-Type (Blue Hypergiant)',
32
+ temperature: 40000,
33
+ luminosity: 100000,
34
+ mass: 50,
35
+ radius: 15,
36
+ color: '#00bfff',
37
+ },
38
+ {
39
+ type: 'B',
40
+ name: 'B-Type (Blue Giant)',
41
+ temperature: 20000,
42
+ luminosity: 1000,
43
+ mass: 8,
44
+ radius: 4,
45
+ color: '#87cefa',
46
+ },
47
+ {
48
+ type: 'A',
49
+ name: 'A-Type (Sirius-like)',
50
+ temperature: 8500,
51
+ luminosity: 20,
52
+ mass: 2.1,
53
+ radius: 1.7,
54
+ color: '#e0ffff',
55
+ },
56
+ {
57
+ type: 'F',
58
+ name: 'F-Type (Procyon-like)',
59
+ temperature: 6500,
60
+ luminosity: 2.5,
61
+ mass: 1.4,
62
+ radius: 1.3,
63
+ color: '#f0fff0',
64
+ },
65
+ {
66
+ type: 'G',
67
+ name: 'G-Type (Sun-like)',
68
+ temperature: 5778,
69
+ luminosity: 1.0,
70
+ mass: 1.0,
71
+ radius: 1.0,
72
+ color: '#ffff00',
73
+ },
74
+ {
75
+ type: 'K',
76
+ name: 'K-Type (Orange Dwarf)',
77
+ temperature: 4500,
78
+ luminosity: 0.15,
79
+ mass: 0.7,
80
+ radius: 0.7,
81
+ color: '#ffa500',
82
+ },
83
+ {
84
+ type: 'M',
85
+ name: 'M-Type (Red Dwarf)',
86
+ temperature: 3200,
87
+ luminosity: 0.01,
88
+ mass: 0.2,
89
+ radius: 0.2,
90
+ color: '#ff4500',
91
+ },
92
+ ];
93
+
94
+ export interface SeffParams {
95
+ tEff: number;
96
+ seffSun: number;
97
+ a: number;
98
+ b: number;
99
+ c: number;
100
+ d: number;
101
+ }
102
+
103
+ export interface SimulateParams {
104
+ luminosity: number;
105
+ temperature: number;
106
+ mass: number;
107
+ distanceAu: number;
108
+ albedo: number;
109
+ greenhouseDeltaK: number;
110
+ }
111
+
112
+ export class StellarHabitabilityEngine {
113
+ private calculateSeff(params: SeffParams): number {
114
+ const tClamp = Math.max(2600, Math.min(7200, params.tEff));
115
+ const tStar = tClamp - 5780;
116
+ return params.seffSun + params.a * tStar + params.b * Math.pow(tStar, 2) + params.c * Math.pow(tStar, 3) + params.d * Math.pow(tStar, 4);
117
+ }
118
+
119
+ public calculateLimits(luminosity: number, temperature: number): HabitabilityLimits {
120
+ const rvSeff = this.calculateSeff({ tEff: temperature, seffSun: 1.776, a: 2.136e-4, b: 2.533e-8, c: -1.332e-11, d: -3.097e-15 });
121
+ const rgSeff = this.calculateSeff({ tEff: temperature, seffSun: 1.107, a: 1.332e-4, b: 1.587e-8, c: -8.308e-12, d: -1.931e-16 });
122
+ const mgSeff = this.calculateSeff({ tEff: temperature, seffSun: 0.356, a: 6.171e-5, b: 7.389e-9, c: -3.865e-12, d: -9.000e-17 });
123
+ const emSeff = this.calculateSeff({ tEff: temperature, seffSun: 0.320, a: 5.547e-5, b: 6.641e-9, c: -3.474e-12, d: -8.087e-17 });
124
+
125
+ return {
126
+ recentVenus: Math.sqrt(luminosity / rvSeff),
127
+ runawayGreenhouse: Math.sqrt(luminosity / rgSeff),
128
+ maximumGreenhouse: Math.sqrt(luminosity / mgSeff),
129
+ earlyMars: Math.sqrt(luminosity / emSeff),
130
+ };
131
+ }
132
+
133
+ public simulate(params: SimulateParams): SimulationResult {
134
+ const hzLimits = this.calculateLimits(params.luminosity, params.temperature);
135
+ const stellarFlux = params.luminosity / Math.pow(params.distanceAu, 2);
136
+ const equilibriumTemperature = 278.5 * Math.pow(stellarFlux * (1.0 - params.albedo), 0.25);
137
+ const surfaceTemperature = equilibriumTemperature + params.greenhouseDeltaK;
138
+ const orbitalPeriod = Math.sqrt(Math.pow(params.distanceAu, 3) / params.mass) * 365.255;
139
+ const orbitalVelocity = 29.78 / Math.sqrt(params.distanceAu);
140
+
141
+ let status: 'too-hot' | 'habitable' | 'too-cold' = 'habitable';
142
+ if (params.distanceAu < hzLimits.runawayGreenhouse) {
143
+ status = 'too-hot';
144
+ } else if (params.distanceAu > hzLimits.maximumGreenhouse) {
145
+ status = 'too-cold';
146
+ }
147
+
148
+ return {
149
+ hzLimits,
150
+ equilibriumTemperature,
151
+ surfaceTemperature,
152
+ orbitalPeriod,
153
+ orbitalVelocity,
154
+ stellarFlux,
155
+ status,
156
+ };
157
+ }
158
+ }
@@ -0,0 +1,241 @@
1
+ export interface RenderState {
2
+ cx: number;
3
+ cy: number;
4
+ runawayGreenhousePx: number;
5
+ maximumGreenhousePx: number;
6
+ planetDistPx: number;
7
+ isTooHot: boolean;
8
+ isTooCold: boolean;
9
+ curMass: number;
10
+ curLuminosity: number;
11
+ curTemp: number;
12
+ curDistanceAu: number;
13
+ }
14
+
15
+ interface BackgroundStar {
16
+ x: number;
17
+ y: number;
18
+ size: number;
19
+ speed: number;
20
+ offset: number;
21
+ baseOpacity: number;
22
+ }
23
+
24
+ interface CosmicDust {
25
+ distanceFactor: number;
26
+ angle: number;
27
+ speed: number;
28
+ size: number;
29
+ opacity: number;
30
+ }
31
+
32
+ export class StellarHabitabilityRenderer {
33
+ private canvas: HTMLCanvasElement;
34
+ private canvasContainer: HTMLElement;
35
+ private ctx: CanvasRenderingContext2D;
36
+ private bgStars: BackgroundStar[];
37
+ private dustParticles: CosmicDust[];
38
+ private orbitAngle = 0;
39
+
40
+ constructor(canvas: HTMLCanvasElement, canvasContainer: HTMLElement) {
41
+ this.canvas = canvas;
42
+ this.canvasContainer = canvasContainer;
43
+ this.ctx = canvas.getContext('2d')!;
44
+
45
+ this.bgStars = Array.from({ length: 45 }, () => ({
46
+ x: Math.random(),
47
+ y: Math.random(),
48
+ size: 0.6 + Math.random() * 0.9,
49
+ speed: 0.001 + Math.random() * 0.003,
50
+ offset: Math.random() * Math.PI * 2,
51
+ baseOpacity: 0.2 + Math.random() * 0.5,
52
+ }));
53
+
54
+ this.dustParticles = Array.from({ length: 60 }, () => ({
55
+ distanceFactor: 0.15 + Math.random() * 2.0,
56
+ angle: Math.random() * Math.PI * 2,
57
+ speed: 0.005 + Math.random() * 0.015,
58
+ size: 0.4 + Math.random() * 0.8,
59
+ opacity: 0.15 + Math.random() * 0.45,
60
+ }));
61
+ }
62
+
63
+ public getContext(): CanvasRenderingContext2D {
64
+ return this.ctx;
65
+ }
66
+
67
+ public resize(): { w: number; h: number } {
68
+ const dpr = window.devicePixelRatio || 1;
69
+ const container = this.canvas.parentElement as HTMLElement;
70
+ this.canvas.width = container.clientWidth * dpr;
71
+ this.canvas.height = container.clientHeight * dpr;
72
+ this.ctx.scale(dpr, dpr);
73
+ return {
74
+ w: container.clientWidth,
75
+ h: container.clientHeight,
76
+ };
77
+ }
78
+
79
+ public draw(state: RenderState, w: number, h: number) {
80
+ this.ctx.clearRect(0, 0, w, h);
81
+ this.drawBackground(w, h);
82
+ this.drawStars(w, h);
83
+ this.drawNebulae(w, h);
84
+ this.drawHabitableZone(state);
85
+ this.drawDust(state);
86
+ this.drawOrbitLine(state);
87
+ this.drawStar(state);
88
+ this.drawPlanet(state);
89
+ }
90
+
91
+ private drawBackground(w: number, h: number) {
92
+ this.ctx.fillStyle = '#050507';
93
+ this.ctx.fillRect(0, 0, w, h);
94
+ }
95
+
96
+ private drawStars(w: number, h: number) {
97
+ this.ctx.fillStyle = '#ffffff';
98
+ this.bgStars.forEach(s => {
99
+ const sx = s.x * w;
100
+ const sy = s.y * h;
101
+ const alpha = s.baseOpacity + Math.sin(Date.now() * s.speed + s.offset) * 0.25;
102
+ this.ctx.globalAlpha = Math.max(0.1, Math.min(1.0, alpha));
103
+ this.ctx.beginPath();
104
+ this.ctx.arc(sx, sy, s.size, 0, Math.PI * 2);
105
+ this.ctx.fill();
106
+ });
107
+ this.ctx.globalAlpha = 1.0;
108
+ }
109
+
110
+ private drawNebulae(w: number, h: number) {
111
+ const nebula1 = this.ctx.createRadialGradient(w * 0.25, h * 0.3, 0, w * 0.25, h * 0.3, 160);
112
+ nebula1.addColorStop(0, 'rgba(139, 92, 246, 0.04)');
113
+ nebula1.addColorStop(0.5, 'rgba(139, 92, 246, 0.01)');
114
+ nebula1.addColorStop(1, 'rgba(139, 92, 246, 0)');
115
+ this.ctx.fillStyle = nebula1;
116
+ this.ctx.beginPath();
117
+ this.ctx.arc(w * 0.25, h * 0.3, 160, 0, Math.PI * 2);
118
+ this.ctx.fill();
119
+
120
+ const nebula2 = this.ctx.createRadialGradient(w * 0.7, h * 0.65, 0, w * 0.7, h * 0.65, 200);
121
+ nebula2.addColorStop(0, 'rgba(14, 165, 233, 0.03)');
122
+ nebula2.addColorStop(0.5, 'rgba(14, 165, 233, 0.01)');
123
+ nebula2.addColorStop(1, 'rgba(14, 165, 233, 0)');
124
+ this.ctx.fillStyle = nebula2;
125
+ this.ctx.beginPath();
126
+ this.ctx.arc(w * 0.7, h * 0.65, 200, 0, Math.PI * 2);
127
+ this.ctx.fill();
128
+ }
129
+
130
+ private drawHabitableZone(state: RenderState) {
131
+ const fluctuation = Math.sin(Date.now() * 0.002) * 0.03;
132
+ const runawayFluct = state.runawayGreenhousePx * (1 + fluctuation);
133
+ const maxFluct = state.maximumGreenhousePx * (1 + fluctuation);
134
+
135
+ const grad = this.ctx.createRadialGradient(state.cx, state.cy, runawayFluct, state.cx, state.cy, maxFluct);
136
+ grad.addColorStop(0, 'rgba(16, 185, 129, 0.0)');
137
+ grad.addColorStop(0.2, 'rgba(16, 185, 129, 0.12)');
138
+ grad.addColorStop(0.5, 'rgba(16, 185, 129, 0.18)');
139
+ grad.addColorStop(0.8, 'rgba(16, 185, 129, 0.12)');
140
+ grad.addColorStop(1, 'rgba(16, 185, 129, 0.0)');
141
+
142
+ this.ctx.fillStyle = grad;
143
+ this.ctx.beginPath();
144
+ this.ctx.arc(state.cx, state.cy, maxFluct * 1.1, 0, Math.PI * 2);
145
+ this.ctx.fill();
146
+ }
147
+
148
+ private drawDust(state: RenderState) {
149
+ this.ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
150
+ const dustSpeedFactor = 0.5 / (Math.sqrt(state.curMass) || 1);
151
+ this.dustParticles.forEach(p => {
152
+ p.angle += p.speed * dustSpeedFactor;
153
+ const pxFactor = p.distanceFactor * (state.runawayGreenhousePx + state.maximumGreenhousePx) * 0.5;
154
+ const dx = state.cx + Math.cos(p.angle) * pxFactor;
155
+ const dy = state.cy + Math.sin(p.angle) * pxFactor;
156
+ this.ctx.globalAlpha = p.opacity * Math.min(1.0, state.curLuminosity);
157
+ this.ctx.beginPath();
158
+ this.ctx.arc(dx, dy, p.size, 0, Math.PI * 2);
159
+ this.ctx.fill();
160
+ });
161
+ this.ctx.globalAlpha = 1.0;
162
+ }
163
+
164
+ private drawOrbitLine(state: RenderState) {
165
+ this.ctx.lineWidth = 1.0;
166
+ if (state.isTooHot) {
167
+ const warningFlash = Math.floor(Date.now() / 250) % 2 === 0;
168
+ this.ctx.strokeStyle = warningFlash ? 'rgba(239, 68, 68, 0.8)' : 'rgba(239, 68, 68, 0.25)';
169
+ this.ctx.setLineDash([2, 2]);
170
+ } else if (state.isTooCold) {
171
+ this.ctx.strokeStyle = 'rgba(59, 130, 246, 0.45)';
172
+ this.ctx.setLineDash([1, 4]);
173
+ } else {
174
+ this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
175
+ this.ctx.setLineDash([]);
176
+ }
177
+
178
+ this.ctx.beginPath();
179
+ this.ctx.arc(state.cx, state.cy, state.planetDistPx, 0, Math.PI * 2);
180
+ this.ctx.stroke();
181
+ this.ctx.setLineDash([]);
182
+ }
183
+
184
+ private getStellarColor(temp: number): string {
185
+ if (temp >= 30000) return '#00bfff';
186
+ if (temp >= 10000) return '#87cefa';
187
+ if (temp >= 7500) return '#e0ffff';
188
+ if (temp >= 6000) return '#fffacd';
189
+ if (temp >= 5200) return '#ffff00';
190
+ if (temp >= 3700) return '#ffa500';
191
+ return '#ff4500';
192
+ }
193
+
194
+ private drawStar(state: RenderState) {
195
+ const starColor = this.getStellarColor(state.curTemp);
196
+ const baseRadius = Math.max(4, Math.min(24, 10 * Math.pow(state.curTemp / 5778, 0.5)));
197
+ const pulseSpeed = 0.003 * state.curTemp;
198
+ const pulseAmp = 0.06 * Math.min(2.5, Math.max(0.1, state.curLuminosity));
199
+ const pulse = 1 + Math.sin(Date.now() * pulseSpeed) * pulseAmp;
200
+ const starRadius = baseRadius * pulse;
201
+
202
+ this.ctx.save();
203
+ const starGrad = this.ctx.createRadialGradient(state.cx, state.cy, 0, state.cx, state.cy, starRadius * 2.5);
204
+ starGrad.addColorStop(0, starColor);
205
+ starGrad.addColorStop(0.3, starColor);
206
+ starGrad.addColorStop(1, 'rgba(255, 255, 255, 0)');
207
+ this.ctx.fillStyle = starGrad;
208
+ this.ctx.beginPath();
209
+ this.ctx.arc(state.cx, state.cy, starRadius * 2.5, 0, Math.PI * 2);
210
+ this.ctx.fill();
211
+ this.ctx.restore();
212
+ }
213
+
214
+ private drawPlanet(state: RenderState) {
215
+ const period = Math.sqrt(Math.pow(state.curDistanceAu, 3) / state.curMass) * 365.255;
216
+ const speedFactor = 0.5;
217
+ this.orbitAngle += (360 / (period || 1)) * speedFactor * (Math.PI / 180);
218
+
219
+ const px = state.cx + Math.cos(this.orbitAngle) * state.planetDistPx;
220
+ const py = state.cy + Math.sin(this.orbitAngle) * state.planetDistPx;
221
+
222
+ this.ctx.save();
223
+ if (state.isTooHot) {
224
+ this.ctx.fillStyle = '#ef4444';
225
+ } else if (state.isTooCold) {
226
+ this.ctx.fillStyle = '#93c5fd';
227
+ this.ctx.shadowBlur = 12;
228
+ this.ctx.shadowColor = '#3b82f6';
229
+ } else {
230
+ this.ctx.fillStyle = '#10b981';
231
+ }
232
+
233
+ this.ctx.strokeStyle = '#ffffff';
234
+ this.ctx.lineWidth = 1.5;
235
+ this.ctx.beginPath();
236
+ this.ctx.arc(px, py, 4, 0, Math.PI * 2);
237
+ this.ctx.fill();
238
+ this.ctx.stroke();
239
+ this.ctx.restore();
240
+ }
241
+ }
@@ -0,0 +1,273 @@
1
+ import { StellarHabitabilityEngine, STELLAR_PRESETS } from './logic/StellarHabitabilityEngine';
2
+ import { StellarHabitabilityRenderer } from './renderer';
3
+ import type { RenderState } from './renderer';
4
+ import { updateValTexts, updateResults, updateSliderFill } from './dom-updater';
5
+ import { setupCanvasInteraction } from './interaction';
6
+
7
+ const STORAGE_KEY = 'stellar-habitability-zone:config';
8
+ const KEYS = ['temp', 'lum', 'mass', 'rad', 'dist', 'albedo', 'green'];
9
+
10
+ function parseVal(val: string, fallback: number): number {
11
+ const parsed = parseFloat(val);
12
+ return isNaN(parsed) ? fallback : parsed;
13
+ }
14
+
15
+ export function initStellarSimulator() {
16
+ new StellarSimulator();
17
+ }
18
+
19
+ class StellarSimulator {
20
+ private curDistanceAu = 1.0; private curRunawayLimit = 0.95; private curMaxLimit = 1.67;
21
+ private curTemp = 5778; private curLuminosity = 1.0; private curMass = 1.0;
22
+ private prevStatus = 'habitable'; private isImperial = false;
23
+
24
+ private tempInput!: HTMLInputElement; private lumInput!: HTMLInputElement; private massInput!: HTMLInputElement;
25
+ private radInput!: HTMLInputElement; private distInput!: HTMLInputElement; private albedoInput!: HTMLInputElement;
26
+ private greenInput!: HTMLInputElement;
27
+
28
+ private tempVal!: HTMLElement | null; private lumVal!: HTMLElement | null; private massVal!: HTMLElement | null;
29
+ private radVal!: HTMLElement | null; private distVal!: HTMLElement | null; private albedoVal!: HTMLElement | null;
30
+ private greenVal!: HTMLElement | null;
31
+
32
+ private eqTempResult!: HTMLElement | null; private surfTempResult!: HTMLElement | null; private fluxResult!: HTMLElement | null;
33
+ private periodResult!: HTMLElement | null; private velocityResult!: HTMLElement | null; private innerResult!: HTMLElement | null;
34
+ private outerResult!: HTMLElement | null;
35
+
36
+ private statusBox!: HTMLElement | null; private statusTitle!: HTMLElement | null; private unitToggleBtn!: HTMLElement | null;
37
+
38
+ private canvas!: HTMLCanvasElement;
39
+ private canvasContainer!: HTMLElement;
40
+ private presetBtns!: NodeListOf<Element>;
41
+
42
+ private engine!: StellarHabitabilityEngine;
43
+ private renderer!: StellarHabitabilityRenderer;
44
+
45
+ constructor() {
46
+ const root = document.getElementById('hz-simulator-root');
47
+ if (!root) return;
48
+
49
+ this.getElements();
50
+ this.engine = new StellarHabitabilityEngine();
51
+ this.renderer = new StellarHabitabilityRenderer(this.canvas, this.canvasContainer);
52
+
53
+ this.bindEvents();
54
+ this.loadFromStorage();
55
+ this.update();
56
+ this.animate();
57
+ }
58
+
59
+ private getElements() {
60
+ const getEl = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
61
+ this.tempInput = getEl<HTMLInputElement>('hz-temperature');
62
+ this.lumInput = getEl<HTMLInputElement>('hz-luminosity');
63
+ this.massInput = getEl<HTMLInputElement>('hz-mass');
64
+ this.radInput = getEl<HTMLInputElement>('hz-radius');
65
+ this.distInput = getEl<HTMLInputElement>('hz-distance');
66
+ this.albedoInput = getEl<HTMLInputElement>('hz-albedo');
67
+ this.greenInput = getEl<HTMLInputElement>('hz-greenhouse');
68
+
69
+ this.tempVal = getEl('hz-temperature-val');
70
+ this.lumVal = getEl('hz-luminosity-val');
71
+ this.massVal = getEl('hz-mass-val');
72
+ this.radVal = getEl('hz-radius-val');
73
+ this.distVal = getEl('hz-distance-val');
74
+ this.albedoVal = getEl('hz-albedo-val');
75
+ this.greenVal = getEl('hz-greenhouse-val');
76
+
77
+ this.eqTempResult = getEl('hz-eq-temp');
78
+ this.surfTempResult = getEl('hz-surf-temp');
79
+ this.fluxResult = getEl('hz-stellar-flux');
80
+ this.periodResult = getEl('hz-orbit-period');
81
+ this.velocityResult = getEl('hz-orbit-velocity');
82
+ this.innerResult = getEl('hz-inner-limit');
83
+ this.outerResult = getEl('hz-outer-limit');
84
+
85
+ this.statusBox = getEl('hz-status-box');
86
+ this.statusTitle = getEl('hz-status-title');
87
+ this.unitToggleBtn = getEl('hz-unit-toggle');
88
+
89
+ this.canvas = getEl<HTMLCanvasElement>('hz-orbit-canvas');
90
+ this.canvasContainer = getEl('hz-canvas-placeholder');
91
+ this.presetBtns = document.querySelectorAll('.hz-preset-btn:not(#hz-unit-toggle)');
92
+ }
93
+
94
+ private bindEvents() {
95
+ setupCanvasInteraction({
96
+ canvasContainer: this.canvasContainer,
97
+ distInput: this.distInput,
98
+ presetBtns: this.presetBtns,
99
+ getCurDistanceAu: () => this.curDistanceAu,
100
+ getCurMaxLimit: () => this.curMaxLimit,
101
+ getCurRunawayLimit: () => this.curRunawayLimit,
102
+ onUpdate: () => this.update()
103
+ });
104
+
105
+ [this.tempInput, this.lumInput, this.massInput, this.radInput, this.distInput].forEach(inp => {
106
+ inp.addEventListener('input', () => {
107
+ this.presetBtns.forEach(b => b.classList.remove('active'));
108
+ this.update();
109
+ });
110
+ });
111
+
112
+ [this.albedoInput, this.greenInput].forEach(inp => inp.addEventListener('input', () => this.update()));
113
+ this.presetBtns.forEach(btn => btn.addEventListener('click', () => this.applyPreset(btn)));
114
+
115
+ if (this.unitToggleBtn) {
116
+ this.unitToggleBtn.addEventListener('click', () => {
117
+ this.isImperial = !this.isImperial;
118
+ this.update();
119
+ });
120
+ }
121
+ }
122
+
123
+ private applyPreset(btn: Element) {
124
+ this.presetBtns.forEach(b => b.classList.remove('active'));
125
+ btn.classList.add('active');
126
+ const type = btn.getAttribute('data-type');
127
+ const preset = STELLAR_PRESETS.find(p => p.type === type);
128
+ if (preset) {
129
+ this.tempInput.value = preset.temperature.toString();
130
+ this.lumInput.value = Math.log10(preset.luminosity).toString();
131
+ this.massInput.value = preset.mass.toString();
132
+ this.radInput.value = preset.radius.toString();
133
+
134
+ const hzLimits = this.engine.calculateLimits(preset.luminosity, preset.temperature);
135
+ this.distInput.value = Math.log10((hzLimits.runawayGreenhouse + hzLimits.maximumGreenhouse) / 2).toString();
136
+ this.update();
137
+ }
138
+ }
139
+
140
+ private getSelectedPresetType(): string | null {
141
+ const activeBtn = document.querySelector('.hz-preset-btn.active:not(#hz-unit-toggle)');
142
+ return activeBtn ? activeBtn.getAttribute('data-type') : null;
143
+ }
144
+
145
+ private saveToStorage() {
146
+ try {
147
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
148
+ temp: this.tempInput.value, lum: this.lumInput.value, mass: this.massInput.value, rad: this.radInput.value,
149
+ dist: this.distInput.value, albedo: this.albedoInput.value, green: this.greenInput.value,
150
+ preset: this.getSelectedPresetType(), imperial: this.isImperial,
151
+ }));
152
+ } catch {}
153
+ }
154
+
155
+ private loadFromStorage() {
156
+ try {
157
+ const stored = localStorage.getItem(STORAGE_KEY);
158
+ if (!stored) return;
159
+ const config = JSON.parse(stored);
160
+ const inputs: Record<string, HTMLInputElement> = {
161
+ temp: this.tempInput, lum: this.lumInput, mass: this.massInput, rad: this.radInput,
162
+ dist: this.distInput, albedo: this.albedoInput, green: this.greenInput
163
+ };
164
+ KEYS.forEach(key => {
165
+ const input = inputs[key];
166
+ if (input && config[key] !== undefined) input.value = config[key];
167
+ });
168
+ if (config.imperial !== undefined) this.isImperial = config.imperial;
169
+ this.presetBtns.forEach(b => {
170
+ const isPresetMatch = b.getAttribute('data-type') === config.preset;
171
+ b.classList.toggle('active', isPresetMatch);
172
+ });
173
+ } catch {}
174
+ }
175
+
176
+ private update() {
177
+ const temp = parseVal(this.tempInput.value, 5778);
178
+ const luminosity = Math.pow(10, parseVal(this.lumInput.value, 0));
179
+ const mass = parseVal(this.massInput.value, 1.0);
180
+ const radius = parseVal(this.radInput.value, 1.0);
181
+ const distanceAu = Math.pow(10, parseVal(this.distInput.value, 0));
182
+ const albedo = parseVal(this.albedoInput.value, 0.3);
183
+ const greenhouse = parseVal(this.greenInput.value, 33);
184
+
185
+ if (this.unitToggleBtn) {
186
+ this.unitToggleBtn.textContent = this.isImperial ? 'Units: Imperial' : 'Units: Scientific';
187
+ }
188
+
189
+ updateValTexts({
190
+ temp, luminosity, mass, radius, distanceAu, albedo, greenhouse, isImperial: this.isImperial,
191
+ tempVal: this.tempVal, lumVal: this.lumVal, massVal: this.massVal, radVal: this.radVal, distVal: this.distVal, albedoVal: this.albedoVal, greenVal: this.greenVal
192
+ });
193
+ [this.tempInput, this.lumInput, this.massInput, this.radInput, this.distInput, this.albedoInput, this.greenInput].forEach(updateSliderFill);
194
+
195
+ const result = this.engine.simulate({
196
+ luminosity, temperature: temp, mass, distanceAu, albedo, greenhouseDeltaK: greenhouse,
197
+ });
198
+
199
+ updateResults({
200
+ result, isImperial: this.isImperial, eqTempResult: this.eqTempResult, surfTempResult: this.surfTempResult,
201
+ fluxResult: this.fluxResult, periodResult: this.periodResult, velocityResult: this.velocityResult, innerResult: this.innerResult, outerResult: this.outerResult
202
+ });
203
+
204
+ this.checkStatusFlash(result.status);
205
+ this.updateStatusDisplay(result.status);
206
+ this.saveToStorage();
207
+ }
208
+
209
+ private checkStatusFlash(status: string) {
210
+ if (status !== this.prevStatus) {
211
+ if (this.eqTempResult && this.surfTempResult) {
212
+ this.eqTempResult.classList.remove('flash-cold', 'flash-hot');
213
+ this.surfTempResult.classList.remove('flash-cold', 'flash-hot');
214
+ void this.eqTempResult.offsetWidth;
215
+ void this.surfTempResult.offsetWidth;
216
+ const flashClass = status === 'too-cold' ? 'flash-cold' : 'flash-hot';
217
+ if (status !== 'habitable') {
218
+ this.eqTempResult.classList.add(flashClass);
219
+ this.surfTempResult.classList.add(flashClass);
220
+ }
221
+ }
222
+ this.prevStatus = status;
223
+ }
224
+ }
225
+
226
+ private updateStatusDisplay(status: string) {
227
+ if (this.statusBox && this.statusTitle) {
228
+ this.statusBox.className = `hz-status-box ${status}`;
229
+ let text = 'HABITABLE (Liquid water possible)';
230
+ if (status === 'too-hot') {
231
+ text = 'TOO HOT (Water vaporizes)';
232
+ } else if (status === 'too-cold') {
233
+ text = 'TOO COLD (Water freezes)';
234
+ }
235
+ this.statusTitle.textContent = text;
236
+ }
237
+ }
238
+
239
+ private animate() {
240
+ const targetDist = Math.pow(10, parseVal(this.distInput.value, 0));
241
+ const targetTemp = parseVal(this.tempInput.value, 5778);
242
+ const targetLuminosity = Math.pow(10, parseVal(this.lumInput.value, 0));
243
+ const targetMass = parseVal(this.massInput.value, 1.0);
244
+ const hzLimits = this.engine.calculateLimits(targetLuminosity, targetTemp);
245
+
246
+ this.curDistanceAu += (targetDist - this.curDistanceAu) * 0.12;
247
+ this.curRunawayLimit += (hzLimits.runawayGreenhouse - this.curRunawayLimit) * 0.12;
248
+ this.curMaxLimit += (hzLimits.maximumGreenhouse - this.curMaxLimit) * 0.12;
249
+ this.curTemp += (targetTemp - this.curTemp) * 0.12;
250
+ this.curLuminosity += (targetLuminosity - this.curLuminosity) * 0.12;
251
+ this.curMass += (targetMass - this.curMass) * 0.12;
252
+
253
+ const size = this.renderer.resize();
254
+ const canvasRect = this.canvas.getBoundingClientRect();
255
+ const containerRect = this.canvasContainer.getBoundingClientRect();
256
+ const cx = (containerRect.left - canvasRect.left) + containerRect.width / 2;
257
+ const cy = (containerRect.top - canvasRect.top) + containerRect.height / 2;
258
+ const maxDist = Math.max(this.curDistanceAu * 1.3, this.curMaxLimit * 1.25);
259
+ const scale = (Math.min(containerRect.width, containerRect.height) * 0.4) / maxDist;
260
+
261
+ this.renderer.draw({
262
+ cx, cy,
263
+ runawayGreenhousePx: this.curRunawayLimit * scale,
264
+ maximumGreenhousePx: this.curMaxLimit * scale,
265
+ planetDistPx: this.curDistanceAu * scale,
266
+ isTooHot: this.curDistanceAu < this.curRunawayLimit,
267
+ isTooCold: this.curDistanceAu > this.curMaxLimit,
268
+ curMass: this.curMass, curLuminosity: this.curLuminosity, curTemp: this.curTemp, curDistanceAu: this.curDistanceAu,
269
+ }, size.w, size.h);
270
+ requestAnimationFrame(() => this.animate());
271
+ }
272
+ }
273
+ export { type RenderState };
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { stellarHabitabilityZone } 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 stellarHabitabilityZone.i18n[locale]?.();
12
+ if (!content) return null;
13
+ ---
14
+
15
+ {content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}