@jjlmoya/utils-science 1.25.0 → 1.27.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 (57) 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 -1
  5. package/src/tests/locale_completeness.test.ts +2 -3
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/natural-selection-drift/bibliography.astro +14 -0
  8. package/src/tool/natural-selection-drift/bibliography.ts +16 -0
  9. package/src/tool/natural-selection-drift/component.astro +104 -0
  10. package/src/tool/natural-selection-drift/entry.ts +29 -0
  11. package/src/tool/natural-selection-drift/i18n/de.ts +65 -0
  12. package/src/tool/natural-selection-drift/i18n/en.ts +180 -0
  13. package/src/tool/natural-selection-drift/i18n/es.ts +64 -0
  14. package/src/tool/natural-selection-drift/i18n/fr.ts +204 -0
  15. package/src/tool/natural-selection-drift/i18n/id.ts +48 -0
  16. package/src/tool/natural-selection-drift/i18n/it.ts +203 -0
  17. package/src/tool/natural-selection-drift/i18n/ja.ts +48 -0
  18. package/src/tool/natural-selection-drift/i18n/ko.ts +48 -0
  19. package/src/tool/natural-selection-drift/i18n/nl.ts +53 -0
  20. package/src/tool/natural-selection-drift/i18n/pl.ts +48 -0
  21. package/src/tool/natural-selection-drift/i18n/pt.ts +52 -0
  22. package/src/tool/natural-selection-drift/i18n/ru.ts +48 -0
  23. package/src/tool/natural-selection-drift/i18n/sv.ts +48 -0
  24. package/src/tool/natural-selection-drift/i18n/tr.ts +48 -0
  25. package/src/tool/natural-selection-drift/i18n/zh.ts +48 -0
  26. package/src/tool/natural-selection-drift/index.ts +9 -0
  27. package/src/tool/natural-selection-drift/logic.ts +114 -0
  28. package/src/tool/natural-selection-drift/natural-selection-drift.css +429 -0
  29. package/src/tool/natural-selection-drift/render.ts +219 -0
  30. package/src/tool/natural-selection-drift/runtime.ts +89 -0
  31. package/src/tool/natural-selection-drift/seo.astro +15 -0
  32. package/src/tool/natural-selection-drift/simulation.ts +161 -0
  33. package/src/tool/radioactive-decay/bibliography.astro +15 -0
  34. package/src/tool/radioactive-decay/bibliography.ts +17 -0
  35. package/src/tool/radioactive-decay/component.astro +346 -0
  36. package/src/tool/radioactive-decay/entry.ts +26 -0
  37. package/src/tool/radioactive-decay/i18n/de.ts +78 -0
  38. package/src/tool/radioactive-decay/i18n/en.ts +223 -0
  39. package/src/tool/radioactive-decay/i18n/es.ts +106 -0
  40. package/src/tool/radioactive-decay/i18n/fr.ts +78 -0
  41. package/src/tool/radioactive-decay/i18n/id.ts +66 -0
  42. package/src/tool/radioactive-decay/i18n/it.ts +79 -0
  43. package/src/tool/radioactive-decay/i18n/ja.ts +65 -0
  44. package/src/tool/radioactive-decay/i18n/ko.ts +65 -0
  45. package/src/tool/radioactive-decay/i18n/nl.ts +72 -0
  46. package/src/tool/radioactive-decay/i18n/pl.ts +65 -0
  47. package/src/tool/radioactive-decay/i18n/pt.ts +78 -0
  48. package/src/tool/radioactive-decay/i18n/ru.ts +66 -0
  49. package/src/tool/radioactive-decay/i18n/sv.ts +66 -0
  50. package/src/tool/radioactive-decay/i18n/tr.ts +66 -0
  51. package/src/tool/radioactive-decay/i18n/zh.ts +65 -0
  52. package/src/tool/radioactive-decay/index.ts +12 -0
  53. package/src/tool/radioactive-decay/logic.test.ts +20 -0
  54. package/src/tool/radioactive-decay/logic.ts +120 -0
  55. package/src/tool/radioactive-decay/radioactive-decay-half-life-calculator.css +435 -0
  56. package/src/tool/radioactive-decay/seo.astro +16 -0
  57. package/src/tools.ts +4 -2
@@ -0,0 +1,89 @@
1
+ import { runSelectionDrift, summarizeOutcome } from './logic';
2
+ import { drawBackground, drawParticles, evolveParticles } from './render';
3
+ import {
4
+ readConfig,
5
+ seedParticles,
6
+ showActiveValue,
7
+ updateMetrics,
8
+ updateSliderValues,
9
+ type RuntimeElements,
10
+ } from './simulation';
11
+
12
+ function bindSliders(elements: RuntimeElements, runSimulation: () => void) {
13
+ const sliders = [
14
+ [elements.populationInput, elements.populationValue],
15
+ [elements.generationsInput, elements.generationsValue],
16
+ [elements.mutationInput, elements.mutationValue],
17
+ [elements.pressureInput, elements.pressureValue],
18
+ [elements.driftInput, elements.driftValue],
19
+ [elements.allelesInput, elements.allelesValue],
20
+ [elements.innovationInput, elements.innovationValue],
21
+ ] as const;
22
+
23
+ sliders.forEach(([input, valueNode]) => {
24
+ if (!input || !valueNode) return;
25
+ input.addEventListener('pointerdown', () => showActiveValue(input, valueNode));
26
+ input.addEventListener('input', () => {
27
+ updateSliderValues(elements);
28
+ runSimulation();
29
+ });
30
+ });
31
+ }
32
+
33
+ function startLoop(
34
+ canvas: HTMLCanvasElement,
35
+ ctx: CanvasRenderingContext2D,
36
+ elements: RuntimeElements,
37
+ state: { particles: never[]; outcome: { diversity: number } | null; runSeed: number }
38
+ ) {
39
+ let frameCount = 0;
40
+ const frame = () => {
41
+ frameCount += 1;
42
+ const dpr = window.devicePixelRatio || 1;
43
+ const rect = canvas.getBoundingClientRect();
44
+ canvas.width = rect.width * dpr;
45
+ canvas.height = rect.height * dpr;
46
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
47
+ ctx.clearRect(0, 0, rect.width, rect.height);
48
+ const config = readConfig(elements, state.runSeed);
49
+ drawBackground(ctx, rect, config.selectionPressure);
50
+ if (state.outcome) state.particles = evolveParticles(state.particles, config, state.outcome) as never[];
51
+ drawParticles(ctx, rect, state.particles, { pressure: config.selectionPressure, drift: config.driftIntensity });
52
+ if (frameCount % 6 === 0) updateMetrics(elements, state.particles);
53
+ requestAnimationFrame(frame);
54
+ };
55
+
56
+ requestAnimationFrame(frame);
57
+ return frame;
58
+ }
59
+
60
+ export function createNaturalSelectionRuntime(elements: RuntimeElements) {
61
+ const canvas = elements.canvas;
62
+ const ctx = canvas?.getContext('2d');
63
+ if (!canvas || !ctx) return () => {};
64
+
65
+ let runSeed = 0;
66
+ let particles = [];
67
+ let outcome = null;
68
+ const state = { particles, outcome, runSeed };
69
+
70
+ const runSimulation = () => {
71
+ runSeed += 1;
72
+ updateSliderValues(elements);
73
+ const config = readConfig(elements, runSeed);
74
+ const points = runSelectionDrift(config);
75
+ outcome = summarizeOutcome(points);
76
+ particles = seedParticles(config, outcome);
77
+ state.particles = particles;
78
+ state.outcome = outcome;
79
+ state.runSeed = runSeed;
80
+ updateMetrics(elements, particles);
81
+ };
82
+ bindSliders(elements, runSimulation);
83
+
84
+ window.addEventListener('resize', runSimulation);
85
+ runSimulation();
86
+ startLoop(canvas, ctx, elements, state);
87
+
88
+ return () => window.removeEventListener('resize', runSimulation);
89
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { naturalSelectionDrift } 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 naturalSelectionDrift.i18n[locale]?.();
12
+ if (!content) return null;
13
+ ---
14
+
15
+ {content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,161 @@
1
+ import type { summarizeOutcome } from './logic';
2
+
3
+ export type RuntimeElements = {
4
+ populationInput: HTMLInputElement | null;
5
+ generationsInput: HTMLInputElement | null;
6
+ mutationInput: HTMLInputElement | null;
7
+ pressureInput: HTMLInputElement | null;
8
+ driftInput: HTMLInputElement | null;
9
+ allelesInput: HTMLInputElement | null;
10
+ innovationInput: HTMLInputElement | null;
11
+ populationValue: HTMLElement | null;
12
+ generationsValue: HTMLElement | null;
13
+ mutationValue: HTMLElement | null;
14
+ pressureValue: HTMLElement | null;
15
+ driftValue: HTMLElement | null;
16
+ allelesValue: HTMLElement | null;
17
+ innovationValue: HTMLElement | null;
18
+ aliveBadge: HTMLElement | null;
19
+ dominantTrait: HTMLElement | null;
20
+ fitnessScore: HTMLElement | null;
21
+ diversityScore: HTMLElement | null;
22
+ populationScore: HTMLElement | null;
23
+ alleleRanking: HTMLElement | null;
24
+ ui: {
25
+ aliveLabel: string;
26
+ alleleDefault: string;
27
+ };
28
+ };
29
+
30
+ export type Particle = {
31
+ x: number;
32
+ y: number;
33
+ vx: number;
34
+ vy: number;
35
+ size: number;
36
+ allele: number;
37
+ alive: boolean;
38
+ hue: number;
39
+ energy: number;
40
+ bias: number;
41
+ age: number;
42
+ };
43
+
44
+ type Outcome = ReturnType<typeof summarizeOutcome>;
45
+ type SimConfig = ReturnType<typeof readConfig>;
46
+
47
+ const hues = [160, 208, 284, 28, 338, 118, 246, 52];
48
+ const palette = ['allele-1', 'allele-2', 'allele-3', 'allele-4', 'allele-5', 'allele-6', 'allele-7', 'allele-8'];
49
+
50
+ const readValue = (input: HTMLInputElement | null, fallback: number) => Number(input?.value || fallback);
51
+ const setText = (node: HTMLElement | null, value: string) => {
52
+ if (node) node.textContent = value;
53
+ };
54
+
55
+ export function readConfig(elements: RuntimeElements, runSeed: number) {
56
+ return {
57
+ population: readValue(elements.populationInput, 120),
58
+ generations: readValue(elements.generationsInput, 60),
59
+ mutationRate: readValue(elements.mutationInput, 12) / 100,
60
+ selectionPressure: readValue(elements.pressureInput, 55) / 100,
61
+ driftIntensity: readValue(elements.driftInput, 28) / 100,
62
+ alleleCount: readValue(elements.allelesInput, 3),
63
+ innovationRate: readValue(elements.innovationInput, 2) / 100,
64
+ seed: runSeed,
65
+ habitat: 'forest' as const,
66
+ trait: 'camouflage' as const,
67
+ };
68
+ }
69
+
70
+ export function updateSliderValues(elements: RuntimeElements) {
71
+ setText(elements.populationValue, String(readValue(elements.populationInput, 120)));
72
+ setText(elements.generationsValue, String(readValue(elements.generationsInput, 60)));
73
+ setText(elements.mutationValue, `${readValue(elements.mutationInput, 12)}%`);
74
+ setText(elements.pressureValue, `${readValue(elements.pressureInput, 55)}%`);
75
+ setText(elements.driftValue, `${readValue(elements.driftInput, 28)}%`);
76
+ setText(elements.allelesValue, String(readValue(elements.allelesInput, 3)));
77
+ setText(elements.innovationValue, `${readValue(elements.innovationInput, 2)}%`);
78
+ }
79
+
80
+ export function showActiveValue(input: HTMLInputElement | null, valueNode: HTMLElement | null) {
81
+ if (!input || !valueNode) return;
82
+ valueNode.classList.add('is-visible');
83
+ const update = () => {
84
+ const range = Number(input.max) - Number(input.min);
85
+ const value = Number(input.value) - Number(input.min);
86
+ valueNode.style.left = `${range > 0 ? (value / range) * 100 : 0}%`;
87
+ };
88
+ update();
89
+ input.addEventListener('input', update, { once: true });
90
+ input.addEventListener('pointerup', () => valueNode.classList.remove('is-visible'), { once: true });
91
+ input.addEventListener('blur', () => valueNode.classList.remove('is-visible'), { once: true });
92
+ }
93
+
94
+ export function seedParticles(config: SimConfig, result: Outcome) {
95
+ const total = Math.max(48, Math.min(180, Math.round(config.population / 3)));
96
+ const dominantBias = result.dominant === 'alleleA' ? 0.56 : 0.38;
97
+ const alleleCount = Math.max(2, Number(config.alleleCount || 3));
98
+ return Array.from({ length: total }, (_, index) => {
99
+ const allele = index % alleleCount;
100
+ const angle = Math.random() * Math.PI * 2;
101
+ const radius = 0.08 + Math.random() * 0.48;
102
+ return {
103
+ x: 0.5 + Math.cos(angle) * radius,
104
+ y: 0.5 + Math.sin(angle) * radius,
105
+ vx: (Math.random() - 0.5) * 0.003,
106
+ vy: (Math.random() - 0.5) * 0.003,
107
+ size: 1.8 + Math.random() * 3.2,
108
+ allele,
109
+ alive: true,
110
+ hue: hues[allele % hues.length],
111
+ energy: 0.45 + Math.random() * 0.55,
112
+ bias: dominantBias,
113
+ age: Math.random() * 20,
114
+ } satisfies Particle;
115
+ });
116
+ }
117
+
118
+ export function buildCountMap(particles: Particle[]) {
119
+ const counts = new Map<number, number>();
120
+ particles.forEach((p) => {
121
+ if (!p.alive) return;
122
+ counts.set(p.allele, (counts.get(p.allele) || 0) + 1);
123
+ });
124
+ return counts;
125
+ }
126
+
127
+ export function updateRanking(elements: RuntimeElements, particles: Particle[]) {
128
+ const counts = buildCountMap(particles);
129
+ const ranking = Array.from(counts.entries())
130
+ .map(([allele, count]) => ({ allele, count }))
131
+ .sort((a, b) => b.count - a.count);
132
+ setText(elements.dominantTrait, ranking.length > 0 ? `Allele ${ranking[0].allele + 1}` : elements.ui.alleleDefault);
133
+ const maxCount = Math.max(1, ...ranking.map((item) => item.count));
134
+ if (elements.alleleRanking) {
135
+ elements.alleleRanking.innerHTML = ranking
136
+ .map((item, index) => {
137
+ const fill = Math.max(0.1, item.count / maxCount);
138
+ const colorIndex = item.allele % palette.length;
139
+ return `<div class="ns-allele-chip ${palette[colorIndex]} is-updated" style="--rank-scale:${fill}" data-allele="${item.allele}">
140
+ <span>#${index + 1} Allele ${item.allele + 1}</span>
141
+ <strong>${item.count}</strong>
142
+ <div class="ns-allele-bar"><i></i></div>
143
+ </div>`;
144
+ })
145
+ .join('');
146
+ }
147
+ }
148
+
149
+ export function updateMetrics(elements: RuntimeElements, particles: Particle[]) {
150
+ const aliveCount = particles.filter((p) => p.alive).length;
151
+ setText(elements.aliveBadge, `${aliveCount} ${elements.ui.aliveLabel}`);
152
+ setText(elements.populationScore, String(aliveCount));
153
+ const liveRatio = particles.length ? Math.round((aliveCount / particles.length) * 100) : 0;
154
+ setText(elements.fitnessScore, `${liveRatio}%`);
155
+ const counts = buildCountMap(particles);
156
+ const values = Array.from(counts.values());
157
+ const total = values.reduce((sum, value) => sum + value, 0);
158
+ const spread = total > 0 && values.length > 0 ? 1 - Math.max(...values) / total : 0;
159
+ setText(elements.diversityScore, `${Math.max(0, Math.round(spread * 100))}%`);
160
+ updateRanking(elements, particles);
161
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { radioactiveDecay } 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 radioactiveDecay.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SharedBibliography links={content.bibliography} />}
15
+
@@ -0,0 +1,17 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Lawrence Berkeley National Laboratory, The ABCs of Nuclear Science: Radioactive Decay',
6
+ url: 'https://www2.lbl.gov/abc/wallchart/chapters/03/4.html',
7
+ },
8
+ {
9
+ name: 'NIST, Radionuclide Half-Life Measurements',
10
+ url: 'https://www.nist.gov/pml/radionuclide-half-life-measurements',
11
+ },
12
+ {
13
+ name: 'International Atomic Energy Agency, Radioisotopes in Medicine',
14
+ url: 'https://www.iaea.org/topics/nuclear-science/isotopes/radioisotopes',
15
+ },
16
+ ];
17
+
@@ -0,0 +1,346 @@
1
+ ---
2
+ import './radioactive-decay-half-life-calculator.css';
3
+
4
+ interface Props {
5
+ ui: Record<string, string>;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ ---
10
+
11
+ <div class="decay-lab" id="decay-lab">
12
+ <section class="decay-stage" aria-label={ui.atomField}>
13
+ <div class="decay-stage-header">
14
+ <div>
15
+ <p class="decay-kicker">{ui.expectedCurve}</p>
16
+ <h2>{ui.atomField}</h2>
17
+ </div>
18
+ <button class="decay-seed-button" id="decay-seed-button" type="button">{ui.resetSeed}</button>
19
+ </div>
20
+ <svg class="decay-curve" viewBox="0 0 640 150" aria-hidden="true">
21
+ <path class="decay-curve-ghost" id="decay-curve-ghost" d="M18 26 C 155 32, 236 78, 326 104 S 520 129, 622 137"></path>
22
+ <path class="decay-curve-line" id="decay-curve-line" d="M18 26 C 155 32, 236 78, 326 104 S 520 129, 622 137"></path>
23
+ <circle class="decay-curve-marker" id="decay-curve-marker" cx="169" cy="61" r="8"></circle>
24
+ </svg>
25
+ <svg class="decay-field-art" id="decay-field-art" viewBox="0 0 640 360" role="img" aria-label={ui.atomField}></svg>
26
+ <div class="decay-legend">
27
+ <span><i class="decay-dot decay-dot-live"></i>{ui.liveAtoms}</span>
28
+ <span><i class="decay-dot decay-dot-spent"></i>{ui.decayedAtoms}</span>
29
+ </div>
30
+ </section>
31
+
32
+ <section class="decay-console" aria-label="Radioactive decay controls">
33
+ <div class="decay-select-block">
34
+ <label class="decay-field decay-field--select" for="decay-isotope">
35
+ <span>{ui.isotope}</span>
36
+ <span class="decay-select-shell">
37
+ <select id="decay-isotope"></select>
38
+ </span>
39
+ </label>
40
+
41
+ <div class="decay-use" id="decay-use"></div>
42
+ </div>
43
+
44
+ <label class="decay-field" for="decay-atoms">
45
+ <span>{ui.sampleAtoms}</span>
46
+ <output id="decay-atoms-output">160</output>
47
+ <input id="decay-atoms" type="range" min="40" max="400" step="20" value="160" />
48
+ </label>
49
+
50
+ <label class="decay-field" for="decay-time">
51
+ <span>{ui.elapsedTime}</span>
52
+ <output id="decay-time-output">0</output>
53
+ <input id="decay-time" type="range" min="0" max="4" step="0.01" value="1" />
54
+ </label>
55
+
56
+ <div class="decay-jumps" aria-label="Half-life shortcuts">
57
+ <button type="button" data-half-lives="1">{ui.oneHalfLife}</button>
58
+ <button type="button" data-half-lives="2">{ui.twoHalfLives}</button>
59
+ <button type="button" data-half-lives="4">{ui.fourHalfLives}</button>
60
+ </div>
61
+
62
+ <div class="decay-results">
63
+ <div class="decay-metric">
64
+ <span>{ui.halfLife}</span>
65
+ <strong id="decay-half-life">-</strong>
66
+ </div>
67
+ <div class="decay-metric">
68
+ <span>{ui.remaining}</span>
69
+ <strong id="decay-remaining">-</strong>
70
+ </div>
71
+ <div class="decay-metric">
72
+ <span>{ui.decayed}</span>
73
+ <strong id="decay-decayed">-</strong>
74
+ </div>
75
+ <div class="decay-metric">
76
+ <span>{ui.activity}</span>
77
+ <strong id="decay-activity">-</strong>
78
+ </div>
79
+ </div>
80
+ </section>
81
+ </div>
82
+
83
+ <script>
84
+ import { ISOTOPE_PRESETS, simulateDecay } from './logic';
85
+ import type { IsotopePreset } from './logic';
86
+
87
+ const root = document.getElementById('decay-lab');
88
+ const isotopeSelect = document.getElementById('decay-isotope') as HTMLSelectElement | null;
89
+ const useText = document.getElementById('decay-use');
90
+ const atomsInput = document.getElementById('decay-atoms') as HTMLInputElement | null;
91
+ const atomsOutput = document.getElementById('decay-atoms-output');
92
+ const timeInput = document.getElementById('decay-time') as HTMLInputElement | null;
93
+ const timeOutput = document.getElementById('decay-time-output');
94
+ const atomField = document.getElementById('decay-field-art');
95
+ const halfLifeText = document.getElementById('decay-half-life');
96
+ const remainingText = document.getElementById('decay-remaining');
97
+ const decayedText = document.getElementById('decay-decayed');
98
+ const activityText = document.getElementById('decay-activity');
99
+ const marker = document.getElementById('decay-curve-marker') as SVGCircleElement | null;
100
+ const curveLine = document.getElementById('decay-curve-line') as SVGPathElement | null;
101
+ const curveGhost = document.getElementById('decay-curve-ghost') as SVGPathElement | null;
102
+ const seedButton = document.getElementById('decay-seed-button');
103
+
104
+ let seed = 1487;
105
+ let currentDecayed = new Set<number>();
106
+ const atomNodes = new Map<number, SVGCircleElement>();
107
+ const atomBase = new Map<number, ReturnType<typeof atomPosition>>();
108
+ const decayTimers = new Map<number, number>();
109
+ const svgNamespace = 'http://www.w3.org/2000/svg';
110
+ const curveState = {
111
+ progress: 0.25,
112
+ remainingFraction: 0.5,
113
+ targetProgress: 0.25,
114
+ targetRemainingFraction: 0.5,
115
+ frame: 0,
116
+ };
117
+
118
+ function getPreset(): IsotopePreset {
119
+ return ISOTOPE_PRESETS.find((preset) => preset.id === isotopeSelect?.value) ?? ISOTOPE_PRESETS[0];
120
+ }
121
+
122
+ function formatNumber(value: number): string {
123
+ if (Math.abs(value) >= 1_000_000) {
124
+ return new Intl.NumberFormat('en', {
125
+ notation: 'compact',
126
+ maximumFractionDigits: 2,
127
+ }).format(value);
128
+ }
129
+
130
+ return new Intl.NumberFormat('en', { maximumFractionDigits: 2 }).format(value);
131
+ }
132
+
133
+ function atomPosition(atomId: number) {
134
+ const columnCount = 20;
135
+ const row = Math.floor(atomId / columnCount);
136
+ const column = atomId % columnCount;
137
+ const jitterX = Math.sin((atomId + seed) * 12.9898) * 7;
138
+ const jitterY = Math.cos((atomId + seed) * 78.233) * 7;
139
+
140
+ return {
141
+ x: 30 + column * 30.6 + jitterX,
142
+ y: 30 + row * 25.5 + jitterY,
143
+ radius: 4.8 + Math.abs(Math.sin(atomId * 1.7)) * 3.2,
144
+ };
145
+ }
146
+
147
+ function setAtomBase(circle: SVGCircleElement, atomId: number) {
148
+ const position = atomPosition(atomId);
149
+ atomBase.set(atomId, position);
150
+ circle.setAttribute('cx', position.x.toFixed(2));
151
+ circle.setAttribute('cy', position.y.toFixed(2));
152
+ circle.setAttribute('r', position.radius.toFixed(2));
153
+ }
154
+
155
+ function createAtom(atomId: number) {
156
+ const circle = document.createElementNS(svgNamespace, 'circle');
157
+ circle.classList.add('decay-atom');
158
+ circle.style.setProperty('--delay', `${atomId % 37}ms`);
159
+ setAtomBase(circle, atomId);
160
+ atomField?.append(circle);
161
+ atomNodes.set(atomId, circle);
162
+
163
+ return circle;
164
+ }
165
+
166
+ function setAtomLive(circle: SVGCircleElement, atomId: number) {
167
+ const timer = decayTimers.get(atomId);
168
+ if (timer) {
169
+ window.clearTimeout(timer);
170
+ decayTimers.delete(atomId);
171
+ }
172
+
173
+ circle.classList.remove('decay-atom-decaying', 'decay-atom-spent');
174
+ }
175
+
176
+ function setAtomDecayed(circle: SVGCircleElement, atomId: number, justDecayed: boolean) {
177
+ if (!justDecayed) {
178
+ circle.classList.add('decay-atom-spent');
179
+ circle.classList.remove('decay-atom-decaying');
180
+ return;
181
+ }
182
+
183
+ circle.classList.remove('decay-atom-spent');
184
+ circle.classList.add('decay-atom-decaying');
185
+
186
+ const existingTimer = decayTimers.get(atomId);
187
+ if (existingTimer) window.clearTimeout(existingTimer);
188
+
189
+ const timer = window.setTimeout(() => {
190
+ if (!currentDecayed.has(atomId)) return;
191
+ circle.classList.remove('decay-atom-decaying');
192
+ circle.classList.add('decay-atom-spent');
193
+ decayTimers.delete(atomId);
194
+ }, 600);
195
+ decayTimers.set(atomId, timer);
196
+ }
197
+
198
+ function renderAtoms(result: ReturnType<typeof simulateDecay>) {
199
+ if (!atomField) return;
200
+
201
+ const nextDecayed = new Set(result.atoms.filter((atom) => atom.decayed).map((atom) => atom.id));
202
+ const liveIds = new Set(result.atoms.map((atom) => atom.id));
203
+
204
+ atomNodes.forEach((circle, atomId) => {
205
+ if (liveIds.has(atomId)) return;
206
+ circle.remove();
207
+ atomNodes.delete(atomId);
208
+ atomBase.delete(atomId);
209
+ currentDecayed.delete(atomId);
210
+ });
211
+
212
+ result.atoms.forEach((atom) => {
213
+ const circle = atomNodes.get(atom.id) ?? createAtom(atom.id);
214
+ if (!atomBase.has(atom.id)) setAtomBase(circle, atom.id);
215
+
216
+ if (atom.decayed) {
217
+ setAtomDecayed(circle, atom.id, !currentDecayed.has(atom.id));
218
+ } else {
219
+ setAtomLive(circle, atom.id);
220
+ }
221
+ });
222
+
223
+ currentDecayed = nextDecayed;
224
+ }
225
+
226
+ function curvePath(remainingFraction: number): string {
227
+ const lift = remainingFraction * 18;
228
+ const sag = (1 - remainingFraction) * 16;
229
+
230
+ return `M18 ${26 + sag} C 154 ${30 + lift}, 238 ${77 - lift}, 326 ${104 - lift * 0.35} S 520 ${128 + sag * 0.22}, 622 ${137 - lift * 0.12}`;
231
+ }
232
+
233
+ function drawCurve(progress: number, remainingFraction: number) {
234
+ if (!curveLine || !marker) return;
235
+
236
+ const path = curvePath(remainingFraction);
237
+ curveLine.setAttribute('d', path);
238
+ curveGhost?.setAttribute('d', path);
239
+
240
+ const totalLength = curveLine.getTotalLength();
241
+ const point = curveLine.getPointAtLength(totalLength * progress);
242
+
243
+ curveLine.style.strokeDasharray = `${totalLength}`;
244
+ curveLine.style.strokeDashoffset = `${totalLength * (1 - progress)}`;
245
+ marker.setAttribute('cx', point.x.toFixed(2));
246
+ marker.setAttribute('cy', point.y.toFixed(2));
247
+ marker.style.opacity = `${Math.max(0.42, remainingFraction)}`;
248
+ }
249
+
250
+ function animateCurve() {
251
+ const deltaProgress = curveState.targetProgress - curveState.progress;
252
+ const deltaRemaining = curveState.targetRemainingFraction - curveState.remainingFraction;
253
+ curveState.progress += deltaProgress * 0.18;
254
+ curveState.remainingFraction += deltaRemaining * 0.18;
255
+ drawCurve(curveState.progress, curveState.remainingFraction);
256
+
257
+ if (Math.abs(deltaProgress) > 0.001 || Math.abs(deltaRemaining) > 0.001) {
258
+ curveState.frame = window.requestAnimationFrame(animateCurve);
259
+ return;
260
+ }
261
+
262
+ curveState.progress = curveState.targetProgress;
263
+ curveState.remainingFraction = curveState.targetRemainingFraction;
264
+ drawCurve(curveState.progress, curveState.remainingFraction);
265
+ curveState.frame = 0;
266
+ }
267
+
268
+ function updateCurve(halfLifeCount: number, remainingFraction: number) {
269
+ curveState.targetProgress = Math.min(1, halfLifeCount / 4);
270
+ curveState.targetRemainingFraction = remainingFraction;
271
+
272
+ if (!curveState.frame) {
273
+ curveState.frame = window.requestAnimationFrame(animateCurve);
274
+ }
275
+ }
276
+
277
+ function floatAtoms(time: number) {
278
+ atomNodes.forEach((circle, atomId) => {
279
+ if (circle.classList.contains('decay-atom-spent')) return;
280
+
281
+ const base = atomBase.get(atomId);
282
+ if (!base) return;
283
+
284
+ const driftX = Math.sin(time * 0.0012 + atomId * 1.91) * 1.5;
285
+ const driftY = Math.cos(time * 0.0015 + atomId * 2.37) * 1.25;
286
+ circle.setAttribute('cx', (base.x + driftX).toFixed(2));
287
+ circle.setAttribute('cy', (base.y + driftY).toFixed(2));
288
+ });
289
+
290
+ window.requestAnimationFrame(floatAtoms);
291
+ }
292
+
293
+ function update() {
294
+ if (!root || !isotopeSelect || !atomsInput || !timeInput) return;
295
+
296
+ const preset = getPreset();
297
+ const halfLifeCount = Number(timeInput.value);
298
+ const elapsedTime = halfLifeCount * preset.halfLife;
299
+ const result = simulateDecay({
300
+ initialAtoms: Number(atomsInput.value),
301
+ halfLife: preset.halfLife,
302
+ elapsedTime,
303
+ seed,
304
+ });
305
+
306
+ atomsOutput!.textContent = atomsInput.value;
307
+ timeOutput!.textContent = `${formatNumber(elapsedTime)} ${preset.unit}`;
308
+ useText!.textContent = preset.useCase;
309
+ halfLifeText!.textContent = `${formatNumber(preset.halfLife)} ${preset.unit}`;
310
+ remainingText!.textContent = `${result.remainingAtoms} (${(result.remainingFraction * 100).toFixed(1)}%)`;
311
+ decayedText!.textContent = `${result.decayedAtoms} (${(result.decayedFraction * 100).toFixed(1)}%)`;
312
+ activityText!.textContent = `${(result.activityFraction * 100).toFixed(1)}%`;
313
+
314
+ updateCurve(halfLifeCount, result.remainingFraction);
315
+ renderAtoms(result);
316
+ }
317
+
318
+ ISOTOPE_PRESETS.forEach((preset) => {
319
+ const option = document.createElement('option');
320
+ option.value = preset.id;
321
+ option.textContent = preset.name;
322
+ isotopeSelect?.append(option);
323
+ });
324
+
325
+ isotopeSelect?.addEventListener('change', update);
326
+ atomsInput?.addEventListener('input', update);
327
+ timeInput?.addEventListener('input', update);
328
+ seedButton?.addEventListener('click', () => {
329
+ seed += 9973;
330
+ atomNodes.forEach((circle, atomId) => {
331
+ setAtomBase(circle, atomId);
332
+ });
333
+ update();
334
+ });
335
+
336
+ document.querySelectorAll<HTMLButtonElement>('[data-half-lives]').forEach((button) => {
337
+ button.addEventListener('click', () => {
338
+ if (!timeInput) return;
339
+ timeInput.value = button.dataset.halfLives ?? '1';
340
+ update();
341
+ });
342
+ });
343
+
344
+ update();
345
+ window.requestAnimationFrame(floatAtoms);
346
+ </script>
@@ -0,0 +1,26 @@
1
+ import type { ScienceToolEntry } from '../../types';
2
+
3
+ export const radioactiveDecay: ScienceToolEntry = {
4
+ id: 'radioactive-decay',
5
+ icons: {
6
+ bg: 'mdi:radioactive-circle-outline',
7
+ fg: 'mdi:chart-bell-curve-cumulative',
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
+ };