@jjlmoya/utils-science 1.30.0 → 1.32.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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +5 -1
  4. package/src/index.ts +2 -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/mandelbrot-fractal/bibliography.astro +14 -0
  8. package/src/tool/mandelbrot-fractal/bibliography.ts +16 -0
  9. package/src/tool/mandelbrot-fractal/component.astro +242 -0
  10. package/src/tool/mandelbrot-fractal/entry.ts +26 -0
  11. package/src/tool/mandelbrot-fractal/i18n/de.ts +159 -0
  12. package/src/tool/mandelbrot-fractal/i18n/en.ts +160 -0
  13. package/src/tool/mandelbrot-fractal/i18n/es.ts +159 -0
  14. package/src/tool/mandelbrot-fractal/i18n/fr.ts +159 -0
  15. package/src/tool/mandelbrot-fractal/i18n/id.ts +159 -0
  16. package/src/tool/mandelbrot-fractal/i18n/it.ts +159 -0
  17. package/src/tool/mandelbrot-fractal/i18n/ja.ts +159 -0
  18. package/src/tool/mandelbrot-fractal/i18n/ko.ts +159 -0
  19. package/src/tool/mandelbrot-fractal/i18n/nl.ts +159 -0
  20. package/src/tool/mandelbrot-fractal/i18n/pl.ts +159 -0
  21. package/src/tool/mandelbrot-fractal/i18n/pt.ts +159 -0
  22. package/src/tool/mandelbrot-fractal/i18n/ru.ts +159 -0
  23. package/src/tool/mandelbrot-fractal/i18n/sv.ts +159 -0
  24. package/src/tool/mandelbrot-fractal/i18n/tr.ts +159 -0
  25. package/src/tool/mandelbrot-fractal/i18n/zh.ts +159 -0
  26. package/src/tool/mandelbrot-fractal/index.ts +11 -0
  27. package/src/tool/mandelbrot-fractal/logic/MandelbrotEngine.ts +67 -0
  28. package/src/tool/mandelbrot-fractal/mandelbrot-fractal-calculator.css +357 -0
  29. package/src/tool/mandelbrot-fractal/seo.astro +15 -0
  30. package/src/tool/twin-paradox-visualizer/bibliography.astro +14 -0
  31. package/src/tool/twin-paradox-visualizer/bibliography.ts +12 -0
  32. package/src/tool/twin-paradox-visualizer/component.astro +205 -0
  33. package/src/tool/twin-paradox-visualizer/entry.ts +26 -0
  34. package/src/tool/twin-paradox-visualizer/i18n/de.ts +168 -0
  35. package/src/tool/twin-paradox-visualizer/i18n/en.ts +168 -0
  36. package/src/tool/twin-paradox-visualizer/i18n/es.ts +168 -0
  37. package/src/tool/twin-paradox-visualizer/i18n/fr.ts +168 -0
  38. package/src/tool/twin-paradox-visualizer/i18n/id.ts +168 -0
  39. package/src/tool/twin-paradox-visualizer/i18n/it.ts +168 -0
  40. package/src/tool/twin-paradox-visualizer/i18n/ja.ts +168 -0
  41. package/src/tool/twin-paradox-visualizer/i18n/ko.ts +168 -0
  42. package/src/tool/twin-paradox-visualizer/i18n/nl.ts +168 -0
  43. package/src/tool/twin-paradox-visualizer/i18n/pl.ts +168 -0
  44. package/src/tool/twin-paradox-visualizer/i18n/pt.ts +168 -0
  45. package/src/tool/twin-paradox-visualizer/i18n/ru.ts +168 -0
  46. package/src/tool/twin-paradox-visualizer/i18n/sv.ts +168 -0
  47. package/src/tool/twin-paradox-visualizer/i18n/tr.ts +168 -0
  48. package/src/tool/twin-paradox-visualizer/i18n/zh.ts +168 -0
  49. package/src/tool/twin-paradox-visualizer/index.ts +11 -0
  50. package/src/tool/twin-paradox-visualizer/logic.ts +33 -0
  51. package/src/tool/twin-paradox-visualizer/seo.astro +15 -0
  52. package/src/tool/twin-paradox-visualizer/twin-paradox-visualizer.css +395 -0
  53. package/src/tools.ts +4 -0
@@ -0,0 +1,357 @@
1
+ :root {
2
+ --mandel-bg: #f4f5f2;
3
+ --mandel-ink: #141820;
4
+ --mandel-muted: #5b6472;
5
+ --mandel-line: rgba(20, 24, 32, 0.12);
6
+ --mandel-panel: rgba(255, 255, 255, 0.86);
7
+ --mandel-hot: #e24d2f;
8
+ --mandel-active: #c9361f;
9
+ --mandel-cool: #159a9c;
10
+ --mandel-shadow: rgba(18, 24, 38, 0.14);
11
+ --mandel-radius: 10px;
12
+ }
13
+
14
+ .theme-dark {
15
+ --mandel-bg: #07090d;
16
+ --mandel-ink: #f2f6ff;
17
+ --mandel-muted: #8f98a8;
18
+ --mandel-line: rgba(242, 246, 255, 0.08);
19
+ --mandel-panel: rgba(10, 12, 16, 0.8);
20
+ --mandel-hot: #ff6b4a;
21
+ --mandel-active: #43d8c9;
22
+ --mandel-cool: #43d8c9;
23
+ --mandel-shadow: rgba(0, 0, 0, 0.36);
24
+ }
25
+
26
+ .mandelbrot-root {
27
+ display: grid;
28
+ gap: 1rem;
29
+ max-width: 1120px;
30
+ margin: 0 auto;
31
+ padding: 0.75rem;
32
+ color: var(--mandel-ink);
33
+ background:
34
+ radial-gradient(circle at 8% 0%, color-mix(in srgb, var(--mandel-hot) 16%, transparent), transparent 34%),
35
+ radial-gradient(circle at 92% 100%, color-mix(in srgb, var(--mandel-cool) 14%, transparent), transparent 36%),
36
+ var(--mandel-bg);
37
+ border: 1px solid var(--mandel-line);
38
+ border-radius: var(--mandel-radius);
39
+ }
40
+
41
+ .mandelbrot-stage {
42
+ position: relative;
43
+ min-height: 380px;
44
+ overflow: hidden;
45
+ background: #07080b;
46
+ border: 1px solid color-mix(in srgb, var(--mandel-cool) 24%, var(--mandel-line));
47
+ border-radius: calc(var(--mandel-radius) - 2px);
48
+ box-shadow:
49
+ 0 0 0 1px rgba(255, 255, 255, 0.02) inset,
50
+ 0 24px 70px var(--mandel-shadow),
51
+ 0 0 70px color-mix(in srgb, var(--mandel-hot) 18%, transparent);
52
+ }
53
+
54
+ .mandelbrot-canvas {
55
+ display: block;
56
+ width: 100%;
57
+ height: 100%;
58
+ min-height: 380px;
59
+ cursor: crosshair;
60
+ }
61
+
62
+ .mandelbrot-reticle {
63
+ position: absolute;
64
+ inset: 50% auto auto 50%;
65
+ width: 72px;
66
+ height: 72px;
67
+ border: 1px solid rgba(255, 255, 255, 0.55);
68
+ border-radius: 50%;
69
+ transform: translate(-50%, -50%);
70
+ pointer-events: none;
71
+ }
72
+
73
+ .mandelbrot-reticle::before,
74
+ .mandelbrot-reticle::after {
75
+ position: absolute;
76
+ content: "";
77
+ background: rgba(255, 255, 255, 0.5);
78
+ }
79
+
80
+ .mandelbrot-reticle::before {
81
+ left: 50%;
82
+ top: -18px;
83
+ width: 1px;
84
+ height: 108px;
85
+ }
86
+
87
+ .mandelbrot-reticle::after {
88
+ left: -18px;
89
+ top: 50%;
90
+ width: 108px;
91
+ height: 1px;
92
+ }
93
+
94
+ .mandelbrot-coordinate-strip {
95
+ position: absolute;
96
+ left: 0.75rem;
97
+ right: 0.75rem;
98
+ bottom: 0.75rem;
99
+ display: flex;
100
+ flex-wrap: wrap;
101
+ gap: 0.35rem 0.75rem;
102
+ justify-content: space-between;
103
+ padding: 0.75rem;
104
+ color: #f8fafc;
105
+ background: rgba(7, 8, 11, 0.74);
106
+ border: 1px solid rgba(255, 255, 255, 0.14);
107
+ border-radius: calc(var(--mandel-radius) - 2px);
108
+ backdrop-filter: blur(14px);
109
+ }
110
+
111
+ .mandelbrot-coordinate-strip span,
112
+ .mandelbrot-readouts span,
113
+ .mandelbrot-control span {
114
+ font-size: 0.65rem;
115
+ font-weight: 700;
116
+ letter-spacing: 0.12em;
117
+ text-transform: uppercase;
118
+ color: var(--mandel-muted);
119
+ }
120
+
121
+ .mandelbrot-coordinate-strip span {
122
+ color: rgba(255, 255, 255, 0.72);
123
+ }
124
+
125
+ .mandelbrot-coordinate-strip strong {
126
+ font-size: 0.9rem;
127
+ font-weight: 450;
128
+ color: #fff;
129
+ overflow-wrap: anywhere;
130
+ }
131
+
132
+ .mandelbrot-panel {
133
+ display: flex;
134
+ flex-direction: column;
135
+ gap: 2rem;
136
+ padding: 1.25rem;
137
+ background: var(--mandel-panel);
138
+ border: 1px solid var(--mandel-line);
139
+ border-radius: calc(var(--mandel-radius) - 2px);
140
+ backdrop-filter: blur(12px);
141
+ }
142
+
143
+ .mandelbrot-presets {
144
+ display: grid;
145
+ grid-template-columns: 1fr;
146
+ gap: 0;
147
+ overflow: hidden;
148
+ border: 1px solid var(--mandel-line);
149
+ border-radius: calc(var(--mandel-radius) - 2px);
150
+ }
151
+
152
+ .mandelbrot-preset {
153
+ min-height: 42px;
154
+ padding: 0.7rem 0.85rem;
155
+ color: var(--mandel-ink);
156
+ background: transparent;
157
+ border: 0;
158
+ border-bottom: 1px solid var(--mandel-line);
159
+ font-size: 0.78rem;
160
+ font-weight: 750;
161
+ letter-spacing: 0.02em;
162
+ cursor: pointer;
163
+ }
164
+
165
+ .mandelbrot-preset:last-child {
166
+ border-bottom: 0;
167
+ }
168
+
169
+ .mandelbrot-preset.active,
170
+ .mandelbrot-preset:hover {
171
+ color: var(--mandel-ink);
172
+ background: color-mix(in srgb, var(--mandel-active) 11%, transparent);
173
+ box-shadow: inset 0 -2px 0 var(--mandel-active);
174
+ }
175
+
176
+ .theme-dark .mandelbrot-preset.active,
177
+ .theme-dark .mandelbrot-preset:hover {
178
+ color: #fff;
179
+ background: rgba(255, 255, 255, 0.06);
180
+ box-shadow: inset 0 -2px 0 var(--mandel-active);
181
+ }
182
+
183
+ .mandelbrot-reset-button {
184
+ align-self: flex-start;
185
+ min-height: 30px;
186
+ padding: 0;
187
+ color: var(--mandel-muted);
188
+ background: transparent;
189
+ border: 0;
190
+ border-bottom: 1px solid transparent;
191
+ font-size: 0.68rem;
192
+ font-weight: 700;
193
+ letter-spacing: 0.12em;
194
+ text-transform: uppercase;
195
+ cursor: pointer;
196
+ }
197
+
198
+ .mandelbrot-reset-button:hover {
199
+ color: var(--mandel-ink);
200
+ border-bottom-color: var(--mandel-active);
201
+ }
202
+
203
+ .mandelbrot-readouts {
204
+ display: grid;
205
+ grid-template-columns: 1fr;
206
+ gap: 1.15rem;
207
+ }
208
+
209
+ .mandelbrot-readout {
210
+ display: grid;
211
+ gap: 0.3rem;
212
+ padding-bottom: 1.1rem;
213
+ border-bottom: 1px solid var(--mandel-line);
214
+ }
215
+
216
+ .mandelbrot-readouts strong {
217
+ font-size: 1rem;
218
+ font-weight: 400;
219
+ line-height: 1.1;
220
+ letter-spacing: 0;
221
+ color: var(--mandel-ink);
222
+ overflow-wrap: anywhere;
223
+ }
224
+
225
+ .mandelbrot-readout-hero strong {
226
+ font-size: clamp(2rem, 9vw, 2.65rem);
227
+ font-weight: 300;
228
+ letter-spacing: -0.04em;
229
+ color: var(--mandel-ink);
230
+ text-shadow: 0 0 20px rgba(255, 255, 255, 0.1);
231
+ }
232
+
233
+ .theme-dark .mandelbrot-readout-hero strong {
234
+ color: #fff;
235
+ }
236
+
237
+ .mandelbrot-controls {
238
+ display: grid;
239
+ gap: 1.1rem;
240
+ }
241
+
242
+ .mandelbrot-control {
243
+ display: grid;
244
+ gap: 0.4rem;
245
+ }
246
+
247
+ .mandelbrot-control input,
248
+ .mandelbrot-control select {
249
+ width: 100%;
250
+ min-height: 42px;
251
+ }
252
+
253
+ .mandelbrot-control input[type="range"] {
254
+ -webkit-appearance: none;
255
+ appearance: none;
256
+ min-height: 28px;
257
+ background: transparent;
258
+ cursor: pointer;
259
+ }
260
+
261
+ .mandelbrot-control input[type="range"]::-webkit-slider-runnable-track {
262
+ height: 2px;
263
+ background: var(--mandel-line);
264
+ border-radius: 999px;
265
+ }
266
+
267
+ .mandelbrot-control input[type="range"]::-moz-range-track {
268
+ height: 2px;
269
+ background: var(--mandel-line);
270
+ border-radius: 999px;
271
+ }
272
+
273
+ .mandelbrot-control input[type="range"]::-webkit-slider-thumb {
274
+ -webkit-appearance: none;
275
+ appearance: none;
276
+ width: 8px;
277
+ height: 8px;
278
+ margin-top: -3px;
279
+ background: var(--mandel-ink);
280
+ border: 0;
281
+ border-radius: 50%;
282
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--mandel-ink) 10%, transparent);
283
+ }
284
+
285
+ .mandelbrot-control input[type="range"]::-moz-range-thumb {
286
+ width: 8px;
287
+ height: 8px;
288
+ background: var(--mandel-ink);
289
+ border: 0;
290
+ border-radius: 50%;
291
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--mandel-ink) 10%, transparent);
292
+ }
293
+
294
+ .theme-dark .mandelbrot-control input[type="range"]::-webkit-slider-thumb {
295
+ background: #fff;
296
+ box-shadow: 0 0 14px rgba(255, 255, 255, 0.45);
297
+ }
298
+
299
+ .theme-dark .mandelbrot-control input[type="range"]::-moz-range-thumb {
300
+ background: #fff;
301
+ box-shadow: 0 0 14px rgba(255, 255, 255, 0.45);
302
+ }
303
+
304
+ .mandelbrot-control select {
305
+ -webkit-appearance: none;
306
+ -moz-appearance: none;
307
+ appearance: none;
308
+ padding: 0 1.85rem 0 0;
309
+ color: var(--mandel-ink);
310
+ background-color: transparent;
311
+ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23141820' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>");
312
+ background-position: right 0.05rem center;
313
+ background-repeat: no-repeat;
314
+ border: 0;
315
+ border-bottom: 1px solid var(--mandel-line);
316
+ border-radius: 0;
317
+ font-size: 0.95rem;
318
+ }
319
+
320
+ .theme-dark .mandelbrot-control select {
321
+ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>");
322
+ }
323
+
324
+ .mandelbrot-control select option {
325
+ color: #141820;
326
+ background: #fff;
327
+ }
328
+
329
+ .theme-dark .mandelbrot-control select option {
330
+ color: #f2f6ff;
331
+ background: #111722;
332
+ }
333
+
334
+ @media (min-width: 760px) {
335
+ .mandelbrot-root {
336
+ grid-template-columns: minmax(0, 1.55fr) minmax(300px, 0.45fr);
337
+ padding: 1rem;
338
+ }
339
+
340
+ .mandelbrot-stage,
341
+ .mandelbrot-canvas {
342
+ min-height: 640px;
343
+ }
344
+
345
+ .mandelbrot-presets {
346
+ grid-template-columns: repeat(3, 1fr);
347
+ }
348
+
349
+ .mandelbrot-preset {
350
+ border-right: 1px solid var(--mandel-line);
351
+ border-bottom: 0;
352
+ }
353
+
354
+ .mandelbrot-preset:last-child {
355
+ border-right: 0;
356
+ }
357
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { mandelbrotFractal } 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 mandelbrotFractal.i18n[locale]?.();
12
+ if (!content) return null;
13
+ ---
14
+
15
+ {content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { twinParadoxVisualizer } 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 twinParadoxVisualizer.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Twin Paradox without One-Way Velocity Assumptions',
6
+ url: 'https://link.springer.com/article/10.1023/A:1018748702460',
7
+ },
8
+ {
9
+ name: 'Spacetime Physics: Introduction to Special Relativity',
10
+ url: 'https://ia800503.us.archive.org/22/items/SpacetimePhysicsIntroductionToSpecialRelativityTaylorWheelerPDF/Spacetime%20Physics%20-%20Introduction%20to%20Special%20Relativity%20%5BTaylor-Wheeler%5DPDF.pdf',
11
+ },
12
+ ];
@@ -0,0 +1,205 @@
1
+ ---
2
+ import './twin-paradox-visualizer.css';
3
+
4
+ interface Props {
5
+ ui: Record<string, string>;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ ---
10
+
11
+ <div class="twin-lab" id="twin-lab" data-years-unit={ui.yearsUnit} data-years-short-unit={ui.yearsShortUnit}>
12
+ <section class="twin-viewport" aria-label={ui.visualTitle}>
13
+ <div class="twin-orbit">
14
+ <div class="twin-earth">
15
+ <span>{ui.earthTwin}</span>
16
+ <strong id="twin-earth-age">30.0</strong>
17
+ </div>
18
+ <div class="twin-path" aria-hidden="true">
19
+ <div class="twin-turnaround" id="twin-turnaround"></div>
20
+ <div class="twin-ship" id="twin-ship"></div>
21
+ </div>
22
+ <div class="twin-ship-clock">
23
+ <span>{ui.travelingTwin}</span>
24
+ <strong id="twin-ship-age">30.0</strong>
25
+ </div>
26
+ </div>
27
+ <svg class="twin-worldline" id="twin-worldline" viewBox="0 0 640 260" role="img" aria-label={ui.worldlineLabel}>
28
+ <g class="twin-lab-grid" id="twin-lab-grid" aria-hidden="true"></g>
29
+ <path class="twin-earth-line" d="M112 220 V38"></path>
30
+ <path class="twin-ship-line" id="twin-ship-line" d="M112 220 L350 74 L588 220"></path>
31
+ <circle class="twin-event" cx="112" cy="220" r="7"></circle>
32
+ <circle class="twin-event twin-event-return" id="twin-return-event" cx="588" cy="220" r="7"></circle>
33
+ </svg>
34
+ </section>
35
+
36
+ <section class="twin-console" aria-label={ui.controlsTitle}>
37
+ <div class="twin-presets">
38
+ <button type="button" data-velocity="0.50" data-years="12">{ui.presetCruise}</button>
39
+ <button type="button" data-velocity="0.86" data-years="20" class="active">{ui.presetRelativistic}</button>
40
+ <button type="button" data-velocity="0.98" data-years="40">{ui.presetExtreme}</button>
41
+ </div>
42
+
43
+ <label class="twin-field" for="twin-velocity">
44
+ <span>{ui.velocityLabel}</span>
45
+ <output id="twin-velocity-output">0.86c</output>
46
+ <input id="twin-velocity" type="range" min="0.05" max="0.99" value="0.86" step="0.01" />
47
+ </label>
48
+
49
+ <label class="twin-field" for="twin-years">
50
+ <span>{ui.earthYearsLabel}</span>
51
+ <output id="twin-years-output">20 {ui.yearsUnit}</output>
52
+ <input id="twin-years" type="range" min="2" max="80" value="20" step="1" />
53
+ </label>
54
+
55
+ <div class="twin-readout">
56
+ <div class="twin-reunion">
57
+ <span>{ui.ageGapLabel}</span>
58
+ <strong><span id="twin-age-gap-value">-</span><small>{ui.yearsShortUnit}</small></strong>
59
+ </div>
60
+ <div class="twin-results">
61
+ <div class="twin-metric">
62
+ <span>{ui.gammaLabel}</span>
63
+ <strong id="twin-gamma">-</strong>
64
+ </div>
65
+ <div class="twin-metric">
66
+ <span>{ui.shipTimeLabel}</span>
67
+ <strong id="twin-ship-time">-</strong>
68
+ </div>
69
+ <div class="twin-metric">
70
+ <span>{ui.distanceLabel}</span>
71
+ <strong id="twin-distance">-</strong>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </section>
76
+ </div>
77
+
78
+ <script>
79
+ import { calculateTwinParadox } from './logic';
80
+
81
+ const root = document.getElementById('twin-lab');
82
+ const velocityInput = document.getElementById('twin-velocity') as HTMLInputElement | null;
83
+ const yearsInput = document.getElementById('twin-years') as HTMLInputElement | null;
84
+ const velocityOutput = document.getElementById('twin-velocity-output');
85
+ const yearsOutput = document.getElementById('twin-years-output');
86
+ const earthAge = document.getElementById('twin-earth-age');
87
+ const shipAge = document.getElementById('twin-ship-age');
88
+ const ageGap = document.getElementById('twin-age-gap-value');
89
+ const gammaText = document.getElementById('twin-gamma');
90
+ const shipTime = document.getElementById('twin-ship-time');
91
+ const distanceText = document.getElementById('twin-distance');
92
+ const ship = document.getElementById('twin-ship');
93
+ const turnaround = document.getElementById('twin-turnaround');
94
+ const shipLine = document.getElementById('twin-ship-line');
95
+ const returnEvent = document.getElementById('twin-return-event');
96
+ const grid = document.getElementById('twin-lab-grid');
97
+ const worldline = document.getElementById('twin-worldline');
98
+ const presetButtons = document.querySelectorAll<HTMLButtonElement>('.twin-presets button');
99
+ const departureAge = 30;
100
+ const svgNamespace = 'http://www.w3.org/2000/svg';
101
+ const yearsUnit = root?.dataset.yearsUnit ?? '';
102
+ const yearsShortUnit = root?.dataset.yearsShortUnit ?? '';
103
+
104
+ function formatYears(value: number): string {
105
+ return value.toFixed(value >= 10 ? 1 : 2);
106
+ }
107
+
108
+ function setFill(input: HTMLInputElement) {
109
+ const min = Number(input.min);
110
+ const max = Number(input.max);
111
+ const value = Number(input.value);
112
+ const progress = ((value - min) / (max - min)) * 100;
113
+ input.style.setProperty('--fill', `${progress}%`);
114
+ }
115
+
116
+ function buildGrid() {
117
+ if (!grid) return;
118
+
119
+ for (let y = 44; y <= 220; y += 22) {
120
+ for (let x = 92; x <= 592; x += 28) {
121
+ const dot = document.createElementNS(svgNamespace, 'circle');
122
+ dot.setAttribute('cx', x.toString());
123
+ dot.setAttribute('cy', y.toString());
124
+ dot.setAttribute('r', ((x + y) % 3 === 0 ? 1.55 : 1.05).toString());
125
+ dot.dataset.x = x.toString();
126
+ dot.dataset.y = y.toString();
127
+ grid.append(dot);
128
+ }
129
+ }
130
+ }
131
+
132
+ function warpGrid(velocity: number, gamma: number) {
133
+ if (!grid || !worldline) return;
134
+
135
+ const contraction = 1 / gamma;
136
+ const skew = velocity * 0.32;
137
+ grid.querySelectorAll<SVGCircleElement>('circle').forEach((dot) => {
138
+ const rawX = Number(dot.dataset.x);
139
+ const rawY = Number(dot.dataset.y);
140
+ const centerX = 112;
141
+ const centerY = 220;
142
+ const dx = rawX - centerX;
143
+ const dy = rawY - centerY;
144
+ const warpedX = centerX + dx * (0.72 + contraction * 0.28) + dy * skew;
145
+ const warpedY = centerY + dy * (1.04 - velocity * 0.16);
146
+ dot.setAttribute('cx', warpedX.toFixed(2));
147
+ dot.setAttribute('cy', warpedY.toFixed(2));
148
+ dot.style.opacity = `${0.12 + velocity * 0.25}`;
149
+ });
150
+
151
+ worldline.style.setProperty('--grid-compression', contraction.toFixed(3));
152
+ }
153
+
154
+ function update() {
155
+ if (!velocityInput || !yearsInput) return;
156
+
157
+ const velocity = Number(velocityInput.value);
158
+ const earthYears = Number(yearsInput.value);
159
+ const result = calculateTwinParadox({ earthYears, velocityFractionC: velocity });
160
+ const shipProgress = Math.min(94, Math.max(8, 9 + velocity * 72));
161
+ const bendY = Math.max(52, 168 - velocity * 104);
162
+ const returnX = Math.min(598, 270 + earthYears * 4);
163
+
164
+ velocityOutput!.textContent = `${velocity.toFixed(2)}c`;
165
+ yearsOutput!.textContent = `${earthYears} ${yearsUnit}`;
166
+ earthAge!.textContent = (departureAge + earthYears).toFixed(1);
167
+ shipAge!.textContent = (departureAge + result.shipYears).toFixed(1);
168
+ ageGap!.textContent = formatYears(result.ageGapYears);
169
+ gammaText!.textContent = `${result.gamma.toFixed(3)}x`;
170
+ shipTime!.textContent = `${formatYears(result.shipYears)} ${yearsShortUnit}`;
171
+ distanceText!.textContent = `${result.earthDistanceLightYears.toFixed(1)} ly`;
172
+
173
+ ship?.style.setProperty('--ship-progress', `${shipProgress}%`);
174
+ ship?.style.setProperty('--clock-rate', `"${result.shipClockRatePercent.toFixed(1)}%"`);
175
+ turnaround?.style.setProperty('--turnaround-progress', `${shipProgress}%`);
176
+ shipLine?.setAttribute('d', `M112 220 L350 ${bendY.toFixed(1)} L${returnX.toFixed(1)} 220`);
177
+ returnEvent?.setAttribute('cx', returnX.toFixed(1));
178
+ warpGrid(velocity, result.gamma);
179
+ setFill(velocityInput);
180
+ setFill(yearsInput);
181
+ }
182
+
183
+ velocityInput?.addEventListener('input', () => {
184
+ presetButtons.forEach((button) => button.classList.remove('active'));
185
+ update();
186
+ });
187
+ yearsInput?.addEventListener('input', () => {
188
+ presetButtons.forEach((button) => button.classList.remove('active'));
189
+ update();
190
+ });
191
+
192
+ presetButtons.forEach((button) => {
193
+ button.addEventListener('click', () => {
194
+ if (!velocityInput || !yearsInput) return;
195
+ presetButtons.forEach((item) => item.classList.remove('active'));
196
+ button.classList.add('active');
197
+ velocityInput.value = button.dataset.velocity ?? velocityInput.value;
198
+ yearsInput.value = button.dataset.years ?? yearsInput.value;
199
+ update();
200
+ });
201
+ });
202
+
203
+ buildGrid();
204
+ update();
205
+ </script>
@@ -0,0 +1,26 @@
1
+ import type { ScienceToolEntry } from '../../types';
2
+
3
+ export const twinParadoxVisualizer: ScienceToolEntry = {
4
+ id: 'twin-paradox-visualizer',
5
+ icons: {
6
+ bg: 'mdi:clock-time-eight-outline',
7
+ fg: 'mdi:rocket-outline',
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
+ };