@jjlmoya/utils-science 1.26.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 (33) 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/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/tools.ts +2 -1
@@ -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
+ }
package/src/tools.ts CHANGED
@@ -10,6 +10,7 @@ import { TEMPERATURE_TIMELINE_TOOL } from './tool/temperature-timeline/index';
10
10
  import { LORENZ_ATTRACTOR_TOOL } from './tool/lorenz-attractor/index';
11
11
  import { STELLAR_HABITABILITY_ZONE_TOOL } from './tool/stellar-habitability-zone/index';
12
12
  import { RADIOACTIVE_DECAY_TOOL } from './tool/radioactive-decay/index';
13
+ import { NATURAL_SELECTION_DRIFT_TOOL } from './tool/natural-selection-drift/index';
13
14
 
14
15
  export const ALL_TOOLS: ToolDefinition[] = [
15
16
  COLONY_COUNTER_TOOL,
@@ -22,5 +23,5 @@ export const ALL_TOOLS: ToolDefinition[] = [
22
23
  LORENZ_ATTRACTOR_TOOL,
23
24
  STELLAR_HABITABILITY_ZONE_TOOL,
24
25
  RADIOACTIVE_DECAY_TOOL,
26
+ NATURAL_SELECTION_DRIFT_TOOL,
25
27
  ];
26
-