@jjlmoya/utils-forensic-science 1.13.0 → 1.15.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 (145) hide show
  1. package/package.json +2 -1
  2. package/src/category/i18n/en.ts +3 -3
  3. package/src/category/i18n/es.ts +2 -2
  4. package/src/category/i18n/fr.ts +1 -1
  5. package/src/category/i18n/nl.ts +1 -1
  6. package/src/category/i18n/pt.ts +1 -1
  7. package/src/category/index.ts +61 -59
  8. package/src/category/seo.astro +1 -1
  9. package/src/components/PreviewNavSidebar.astro +1 -1
  10. package/src/components/PreviewToolbar.astro +1 -1
  11. package/src/data.ts +1 -1
  12. package/src/entries.ts +70 -68
  13. package/src/env.d.ts +1 -1
  14. package/src/icons/.gitkeep +1 -0
  15. package/src/index.ts +32 -31
  16. package/src/pages/[locale]/[slug].astro +161 -159
  17. package/src/pages/[locale].astro +8 -5
  18. package/src/pages/index.astro +1 -1
  19. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  20. package/src/tests/category_seo_quality.test.ts +76 -0
  21. package/src/tests/diacritics_density.test.ts +1 -1
  22. package/src/tests/faq_count.test.ts +19 -19
  23. package/src/tests/i18n_coverage.test.ts +36 -36
  24. package/src/tests/inverted_punctuation.test.ts +1 -1
  25. package/src/tests/locale_completeness.test.ts +2 -2
  26. package/src/tests/mocks/astro_mock.js +1 -1
  27. package/src/tests/no_h1_in_components.test.ts +1 -1
  28. package/src/tests/script_density.test.ts +94 -94
  29. package/src/tests/seo_length.test.ts +46 -46
  30. package/src/tests/seo_parity.test.ts +2 -7
  31. package/src/tests/seo_translation_completeness.test.ts +68 -0
  32. package/src/tests/seo_wellformed_export.test.ts +65 -0
  33. package/src/tests/tool_validation.test.ts +2 -2
  34. package/src/tests/translation_copy.test.ts +23 -24
  35. package/src/tool/bloodstain-pattern-origin-analyzer/component.astro +9 -9
  36. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/zh.ts +1 -1
  37. package/src/tool/dna-profile-match-probability-lab/bibliography.astro +14 -0
  38. package/src/tool/dna-profile-match-probability-lab/bibliography.ts +16 -0
  39. package/src/tool/dna-profile-match-probability-lab/component.astro +121 -0
  40. package/src/tool/dna-profile-match-probability-lab/controller.ts +159 -0
  41. package/src/tool/dna-profile-match-probability-lab/dna-profile-match-probability-lab.css +485 -0
  42. package/src/tool/dna-profile-match-probability-lab/dom-views.ts +93 -0
  43. package/src/tool/dna-profile-match-probability-lab/entry.ts +29 -0
  44. package/src/tool/dna-profile-match-probability-lab/evaluator.ts +19 -0
  45. package/src/tool/dna-profile-match-probability-lab/i18n/de.ts +43 -0
  46. package/src/tool/dna-profile-match-probability-lab/i18n/en.ts +196 -0
  47. package/src/tool/dna-profile-match-probability-lab/i18n/es.ts +40 -0
  48. package/src/tool/dna-profile-match-probability-lab/i18n/fr.ts +10 -0
  49. package/src/tool/dna-profile-match-probability-lab/i18n/id.ts +41 -0
  50. package/src/tool/dna-profile-match-probability-lab/i18n/it.ts +10 -0
  51. package/src/tool/dna-profile-match-probability-lab/i18n/ja.ts +40 -0
  52. package/src/tool/dna-profile-match-probability-lab/i18n/ko.ts +40 -0
  53. package/src/tool/dna-profile-match-probability-lab/i18n/nl.ts +29 -0
  54. package/src/tool/dna-profile-match-probability-lab/i18n/pl.ts +10 -0
  55. package/src/tool/dna-profile-match-probability-lab/i18n/pt.ts +41 -0
  56. package/src/tool/dna-profile-match-probability-lab/i18n/ru.ts +29 -0
  57. package/src/tool/dna-profile-match-probability-lab/i18n/sv.ts +41 -0
  58. package/src/tool/dna-profile-match-probability-lab/i18n/tr.ts +10 -0
  59. package/src/tool/dna-profile-match-probability-lab/i18n/zh.ts +29 -0
  60. package/src/tool/dna-profile-match-probability-lab/index.ts +11 -0
  61. package/src/tool/dna-profile-match-probability-lab/localize.ts +107 -0
  62. package/src/tool/dna-profile-match-probability-lab/logic.test.ts +56 -0
  63. package/src/tool/dna-profile-match-probability-lab/logic.ts +139 -0
  64. package/src/tool/dna-profile-match-probability-lab/seo.astro +15 -0
  65. package/src/tool/dna-profile-match-probability-lab/storage.ts +26 -0
  66. package/src/tool/dna-profile-match-probability-lab/ui.ts +3 -0
  67. package/src/tool/fire-pattern-origin-analyzer/component.astro +1 -1
  68. package/src/tool/fire-pattern-origin-analyzer/i18n/zh.ts +5 -1
  69. package/src/tool/fire-pattern-origin-analyzer/logic.ts +9 -2
  70. package/src/tool/fire-pattern-origin-analyzer/view-bindings.ts +8 -4
  71. package/src/tool/fire-pattern-origin-analyzer/view-model.ts +6 -2
  72. package/src/tool/forensic-age-estimator/bibliography.astro +1 -1
  73. package/src/tool/forensic-age-estimator/component.astro +1 -1
  74. package/src/tool/forensic-age-estimator/entry.ts +17 -17
  75. package/src/tool/forensic-age-estimator/i18n/de.ts +244 -244
  76. package/src/tool/forensic-age-estimator/i18n/en.ts +244 -244
  77. package/src/tool/forensic-age-estimator/i18n/es.ts +244 -244
  78. package/src/tool/forensic-age-estimator/i18n/fr.ts +244 -244
  79. package/src/tool/forensic-age-estimator/i18n/id.ts +244 -244
  80. package/src/tool/forensic-age-estimator/i18n/it.ts +244 -244
  81. package/src/tool/forensic-age-estimator/i18n/nl.ts +244 -244
  82. package/src/tool/forensic-age-estimator/i18n/pl.ts +244 -244
  83. package/src/tool/forensic-age-estimator/i18n/pt.ts +244 -244
  84. package/src/tool/forensic-age-estimator/i18n/ru.ts +244 -244
  85. package/src/tool/forensic-age-estimator/i18n/sv.ts +244 -244
  86. package/src/tool/forensic-age-estimator/i18n/tr.ts +244 -244
  87. package/src/tool/forensic-age-estimator/i18n/zh.ts +245 -245
  88. package/src/tool/forensic-age-estimator/index.ts +1 -1
  89. package/src/tool/forensic-age-estimator/seo.astro +1 -1
  90. package/src/tool/forensic-blood-test-simulator/component.astro +1 -1
  91. package/src/tool/forensic-fiber-comparison-microscope/component.astro +1 -1
  92. package/src/tool/forensic-fiber-comparison-microscope/render.ts +1 -1
  93. package/src/tool/forensic-fiber-comparison-microscope/view.ts +1 -1
  94. package/src/tool/forensic-fingerprint-minutiae-identifier/component.astro +2 -2
  95. package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/ja.ts +2 -1
  96. package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/zh.ts +2 -0
  97. package/src/tool/forensic-fingerprint-minutiae-identifier/renderer.ts +9 -8
  98. package/src/tool/forensic-glass-becke-line-simulator/component.astro +1 -1
  99. package/src/tool/forensic-glass-becke-line-simulator/view.ts +8 -6
  100. package/src/tool/forensic-image-authenticity-analyzer/component.astro +1 -1
  101. package/src/tool/forensic-image-authenticity-analyzer/i18n/de.ts +105 -105
  102. package/src/tool/forensic-image-authenticity-analyzer/i18n/en.ts +105 -105
  103. package/src/tool/forensic-image-authenticity-analyzer/i18n/es.ts +105 -105
  104. package/src/tool/forensic-image-authenticity-analyzer/i18n/fr.ts +105 -105
  105. package/src/tool/forensic-image-authenticity-analyzer/i18n/id.ts +95 -90
  106. package/src/tool/forensic-image-authenticity-analyzer/i18n/it.ts +105 -105
  107. package/src/tool/forensic-image-authenticity-analyzer/i18n/ja.ts +7 -2
  108. package/src/tool/forensic-image-authenticity-analyzer/i18n/ko.ts +4 -1
  109. package/src/tool/forensic-image-authenticity-analyzer/i18n/nl.ts +95 -92
  110. package/src/tool/forensic-image-authenticity-analyzer/i18n/pl.ts +95 -92
  111. package/src/tool/forensic-image-authenticity-analyzer/i18n/pt.ts +105 -105
  112. package/src/tool/forensic-image-authenticity-analyzer/i18n/ru.ts +97 -92
  113. package/src/tool/forensic-image-authenticity-analyzer/i18n/sv.ts +97 -93
  114. package/src/tool/forensic-image-authenticity-analyzer/i18n/tr.ts +98 -93
  115. package/src/tool/forensic-image-authenticity-analyzer/i18n/zh.ts +11 -0
  116. package/src/tool/forensic-microcrystal-drug-simulator/component.astro +1 -1
  117. package/src/tool/forensic-sex-determinator/component.astro +3 -2
  118. package/src/tool/forensic-stature-estimator/component.astro +1 -1
  119. package/src/tool/forensic-stature-estimator/i18n/id.ts +4 -0
  120. package/src/tool/forensic-stature-estimator/i18n/nl.ts +4 -0
  121. package/src/tool/forensic-stature-estimator/i18n/ru.ts +4 -0
  122. package/src/tool/forensic-tlc-ink-simulator/component.astro +1 -1
  123. package/src/tool/forensic-tlc-ink-simulator/i18n/zh.ts +1 -0
  124. package/src/tool/forensic-toolmark-striation-matcher/component.astro +1 -1
  125. package/src/tool/forensic-toolmark-striation-matcher/logic.ts +1 -1
  126. package/src/tool/forensic-toolmark-striation-matcher/renderer.ts +7 -6
  127. package/src/tool/forensic-toolmark-striation-matcher/view.ts +19 -20
  128. package/src/tool/gsr-dispersion-calculator/component.astro +1 -1
  129. package/src/tool/gsr-dispersion-calculator/logic.ts +1 -1
  130. package/src/tool/time-of-death-algor-mortis-calculator/entry.ts +2 -0
  131. package/src/tool/widmark-alcohol-simulator/component.astro +1 -1
  132. package/src/tool/widmark-alcohol-simulator/i18n/de.ts +5 -0
  133. package/src/tool/widmark-alcohol-simulator/i18n/fr.ts +2 -1
  134. package/src/tool/widmark-alcohol-simulator/i18n/id.ts +1 -0
  135. package/src/tool/widmark-alcohol-simulator/i18n/it.ts +4 -1
  136. package/src/tool/widmark-alcohol-simulator/i18n/ja.ts +4 -1
  137. package/src/tool/widmark-alcohol-simulator/i18n/nl.ts +5 -1
  138. package/src/tool/widmark-alcohol-simulator/i18n/pl.ts +5 -1
  139. package/src/tool/widmark-alcohol-simulator/i18n/pt.ts +5 -1
  140. package/src/tool/widmark-alcohol-simulator/i18n/ru.ts +4 -1
  141. package/src/tool/widmark-alcohol-simulator/i18n/sv.ts +5 -1
  142. package/src/tool/widmark-alcohol-simulator/i18n/tr.ts +4 -1
  143. package/src/tool/widmark-alcohol-simulator/i18n/zh.ts +4 -1
  144. package/src/tools.ts +37 -35
  145. package/src/types.ts +4 -4
@@ -0,0 +1,121 @@
1
+ ---
2
+ import './dna-profile-match-probability-lab.css';
3
+ import { PROFILE_PRESETS, calculateProfile } from './logic';
4
+ import type { DnaProfileMatchProbabilityUI } from './ui';
5
+
6
+ interface Props {
7
+ ui: DnaProfileMatchProbabilityUI;
8
+ }
9
+
10
+ const { ui } = Astro.props;
11
+ const initialPreset = PROFILE_PRESETS[0]!;
12
+ const initialResult = calculateProfile(initialPreset.loci);
13
+ ---
14
+
15
+ <div class="dna-match-tool" id="dna-profile-match-lab">
16
+ <section class="dna-match-controls" aria-label={ui.controls}>
17
+ <div class="dna-match-control-head">
18
+ <div>
19
+ <span class="dna-match-kicker">{ui.controlKicker}</span>
20
+ <p class="dna-match-helper">{ui.controlHelper}</p>
21
+ </div>
22
+ <span class="dna-match-locus-count" data-locus-count>{initialPreset.loci.length} {ui.loci}</span>
23
+ </div>
24
+
25
+ <div class="dna-match-presets" aria-label={ui.presets}>
26
+ {PROFILE_PRESETS.map((preset, index) => (
27
+ <button type="button" class:list={{ 'is-active': index === 0 }} data-preset={preset.id}>
28
+ <span>{ui[`preset${preset.id}`]}</span>
29
+ <small>{preset.loci.length} {ui.loci}</small>
30
+ </button>
31
+ ))}
32
+ </div>
33
+
34
+ <div class="dna-match-locus-list" data-locus-list>
35
+ {initialPreset.loci.map((locus, index) => (
36
+ <details class="dna-match-locus" data-locus-card open={index === 0}>
37
+ <summary>
38
+ <span data-locus-title>{locus.name}</span>
39
+ <span data-locus-summary>{locus.allele1} / {locus.allele2}</span>
40
+ </summary>
41
+ <div class="dna-match-locus-fields">
42
+ <label class="dna-match-field">
43
+ <span>{ui.locusName}</span>
44
+ <input data-field="name" type="text" value={locus.name} />
45
+ </label>
46
+ <label class="dna-match-field">
47
+ <span>{ui.alleleA}</span>
48
+ <input data-field="allele1" type="text" value={locus.allele1} />
49
+ </label>
50
+ <label class="dna-match-field">
51
+ <span>{ui.alleleB}</span>
52
+ <input data-field="allele2" type="text" value={locus.allele2} />
53
+ </label>
54
+ <label class="dna-match-field">
55
+ <span>{ui.frequencyA}</span>
56
+ <input data-field="frequency1" type="number" min="0.01" max="100" step="0.01" value={locus.frequency1 * 100} />
57
+ </label>
58
+ <label class="dna-match-field">
59
+ <span>{ui.frequencyB}</span>
60
+ <input data-field="frequency2" type="number" min="0.01" max="100" step="0.01" value={locus.frequency2 * 100} />
61
+ </label>
62
+ <button type="button" class="dna-match-remove" data-remove-locus>{ui.removeLocus}</button>
63
+ </div>
64
+ </details>
65
+ ))}
66
+ </div>
67
+
68
+ <button type="button" class="dna-match-add" data-add-locus>{ui.addLocus}</button>
69
+ <p class="dna-match-disclaimer">{ui.disclaimer}</p>
70
+ </section>
71
+
72
+ <section class="dna-match-scene" aria-label={ui.electropherogram}>
73
+ <div class="dna-match-scene-header">
74
+ <div>
75
+ <span class="dna-match-kicker">{ui.sceneKicker}</span>
76
+ <h2>{ui.electropherogram}</h2>
77
+ </div>
78
+ <span class="dna-match-scene-note">{ui.sceneNote}</span>
79
+ </div>
80
+ <div class="dna-match-electropherogram" data-electropherogram role="img" aria-label={ui.electropherogram}></div>
81
+ <div class="dna-match-legend">
82
+ <span><i class="dna-match-legend-dot dna-match-legend-dot-coral"></i>{ui.alleleA}</span>
83
+ <span><i class="dna-match-legend-dot dna-match-legend-dot-cyan"></i>{ui.alleleB}</span>
84
+ <span>{ui.peakHeight}</span>
85
+ </div>
86
+ </section>
87
+
88
+ <section class="dna-match-results" aria-label={ui.results}>
89
+ <div class="dna-match-result-lead">
90
+ <span class="dna-match-kicker">{ui.resultKicker}</span>
91
+ <output class="dna-match-combined" data-result="combined" aria-live="polite">-</output>
92
+ <p data-result="probability">-</p>
93
+ <span class="dna-match-badge" data-result="badge">-</span>
94
+ <p class="dna-match-result-detail" data-result="detail">-</p>
95
+ </div>
96
+ <div class="dna-match-table-wrap">
97
+ <table>
98
+ <caption>{ui.locusBreakdown}</caption>
99
+ <thead>
100
+ <tr><th scope="col">{ui.locus}</th><th scope="col">{ui.genotype}</th><th scope="col">{ui.rule}</th><th scope="col">{ui.frequency}</th></tr>
101
+ </thead>
102
+ <tbody data-result="table">
103
+ {initialResult.loci.map((locus) => (
104
+ <tr><td>{locus.name}</td><td>{locus.allele1} / {locus.allele2}</td><td>{locus.formula}</td><td>{(locus.genotypeFrequency * 100).toFixed(2)}%</td></tr>
105
+ ))}
106
+ </tbody>
107
+ </table>
108
+ </div>
109
+ </section>
110
+ </div>
111
+
112
+ <script is:inline id="dna-profile-match-ui" type="application/json" set:html={JSON.stringify(ui)}></script>
113
+
114
+ <script>
115
+ import { startDnaProfileMatch } from './controller';
116
+
117
+ const root = document.getElementById('dna-profile-match-lab');
118
+ const uiElement = document.getElementById('dna-profile-match-ui');
119
+ const ui = JSON.parse(uiElement?.textContent ?? '{}') as Record<string, string>;
120
+ if (root) startDnaProfileMatch(root, ui);
121
+ </script>
@@ -0,0 +1,159 @@
1
+ import {
2
+ DEFAULT_PRESET_ID,
3
+ MAX_LOCI,
4
+ MIN_LOCI,
5
+ calculateProfile,
6
+ getPreset,
7
+ type LocusInput,
8
+ } from './logic';
9
+ import { renderElectropherogram, renderResults, type ResultTargets } from './dom-views';
10
+ import { loadProfileState, saveProfileState, type SavedProfileState } from './storage';
11
+ import type { DnaProfileMatchProbabilityUI } from './ui';
12
+
13
+ function uiText(ui: DnaProfileMatchProbabilityUI, key: string): string {
14
+ return ui[key] ?? '';
15
+ }
16
+
17
+ function escapeHtml(value: string): string {
18
+ return value.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character] ?? '');
19
+ }
20
+
21
+ function inputValue(card: HTMLElement, field: string): string {
22
+ return card.querySelector<HTMLInputElement>(`[data-field="${field}"]`)?.value.trim() ?? '';
23
+ }
24
+
25
+ function readLocus(card: HTMLElement): LocusInput {
26
+ return {
27
+ name: inputValue(card, 'name'),
28
+ allele1: inputValue(card, 'allele1'),
29
+ allele2: inputValue(card, 'allele2'),
30
+ frequency1: Number(inputValue(card, 'frequency1')) / 100,
31
+ frequency2: Number(inputValue(card, 'frequency2')) / 100,
32
+ };
33
+ }
34
+
35
+ function readLoci(root: HTMLElement): LocusInput[] {
36
+ return [...root.querySelectorAll<HTMLElement>('[data-locus-card]')].map(readLocus);
37
+ }
38
+
39
+ function fieldMarkup(label: string, field: string, value: string, type: string): string {
40
+ return `<label class="dna-match-field"><span>${escapeHtml(label)}</span><input data-field="${field}" type="${type}" value="${escapeHtml(value)}" ${type === 'number' ? 'min="0.01" max="100" step="0.01"' : ''} /></label>`;
41
+ }
42
+
43
+ function locusMarkup(locus: LocusInput, index: number, ui: DnaProfileMatchProbabilityUI): string {
44
+ const name = locus.name || `${uiText(ui, 'locus')} ${index + 1}`;
45
+ return `<details class="dna-match-locus" data-locus-card ${index === 0 ? 'open' : ''}><summary><span data-locus-title>${escapeHtml(name)}</span><span data-locus-summary>${escapeHtml(locus.allele1)} / ${escapeHtml(locus.allele2)}</span></summary><div class="dna-match-locus-fields">${fieldMarkup(uiText(ui, 'locusName'), 'name', locus.name, 'text')}${fieldMarkup(uiText(ui, 'alleleA'), 'allele1', locus.allele1, 'text')}${fieldMarkup(uiText(ui, 'alleleB'), 'allele2', locus.allele2, 'text')}${fieldMarkup(uiText(ui, 'frequencyA'), 'frequency1', String(locus.frequency1 * 100), 'number')}${fieldMarkup(uiText(ui, 'frequencyB'), 'frequency2', String(locus.frequency2 * 100), 'number')}<button type="button" class="dna-match-remove" data-remove-locus>${escapeHtml(uiText(ui, 'removeLocus'))}</button></div></details>`;
46
+ }
47
+
48
+ function appendLocus(list: HTMLElement, locus: LocusInput, index: number, ui: DnaProfileMatchProbabilityUI): void {
49
+ list.insertAdjacentHTML('beforeend', locusMarkup(locus, index, ui));
50
+ }
51
+
52
+ function replaceLoci(list: HTMLElement, loci: LocusInput[], ui: DnaProfileMatchProbabilityUI): void {
53
+ list.replaceChildren();
54
+ loci.forEach((locus, index) => appendLocus(list, locus, index, ui));
55
+ }
56
+
57
+ function updateRemoveButtons(root: HTMLElement, ui: DnaProfileMatchProbabilityUI): void {
58
+ const disabled = root.querySelectorAll('[data-locus-card]').length <= MIN_LOCI;
59
+ root.querySelectorAll<HTMLButtonElement>('[data-remove-locus]').forEach((button) => {
60
+ button.disabled = disabled;
61
+ });
62
+ const count = root.querySelector<HTMLElement>('[data-locus-count]');
63
+ if (count) count.textContent = `${root.querySelectorAll('[data-locus-card]').length} ${uiText(ui, 'loci')}`;
64
+ }
65
+
66
+ function updateLocusSummaries(root: HTMLElement, ui: DnaProfileMatchProbabilityUI): void {
67
+ root.querySelectorAll<HTMLElement>('[data-locus-card]').forEach((card, index) => {
68
+ const name = inputValue(card, 'name') || `${uiText(ui, 'locus')} ${index + 1}`;
69
+ const alleles = `${inputValue(card, 'allele1') || '?'} / ${inputValue(card, 'allele2') || '?'}`;
70
+ const title = card.querySelector<HTMLElement>('[data-locus-title]');
71
+ const summary = card.querySelector<HTMLElement>('[data-locus-summary]');
72
+ if (title) title.textContent = name;
73
+ if (summary) summary.textContent = alleles;
74
+ });
75
+ }
76
+
77
+ function profilesMatch(first: LocusInput[], second: LocusInput[]): boolean {
78
+ return first.length === second.length && first.every((locus, index) => {
79
+ const other = second[index];
80
+ return other !== undefined && JSON.stringify(locus) === JSON.stringify(other);
81
+ });
82
+ }
83
+
84
+ function syncPresetButtons(root: HTMLElement, loci: LocusInput[]): void {
85
+ root.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => {
86
+ const preset = getPreset(button.dataset.preset ?? DEFAULT_PRESET_ID);
87
+ button.classList.toggle('is-active', profilesMatch(loci, preset.loci));
88
+ });
89
+ }
90
+
91
+ function resultTargets(root: HTMLElement): ResultTargets | null {
92
+ const badge = root.querySelector<HTMLElement>('[data-result="badge"]');
93
+ const detail = root.querySelector<HTMLElement>('[data-result="detail"]');
94
+ const combined = root.querySelector<HTMLElement>('[data-result="combined"]');
95
+ const probability = root.querySelector<HTMLElement>('[data-result="probability"]');
96
+ const table = root.querySelector<HTMLTableSectionElement>('[data-result="table"]');
97
+ if (!badge || !detail || !combined || !probability || !table) return null;
98
+ return { badge, detail, combined, probability, table };
99
+ }
100
+
101
+ function update(root: HTMLElement, ui: DnaProfileMatchProbabilityUI): void {
102
+ const loci = readLoci(root);
103
+ const result = calculateProfile(loci);
104
+ const scene = root.querySelector<HTMLElement>('[data-electropherogram]');
105
+ const targets = resultTargets(root);
106
+ if (scene) renderElectropherogram(scene, loci, ui);
107
+ if (targets) renderResults(targets, result, ui);
108
+ updateLocusSummaries(root, ui);
109
+ saveProfileState({ loci });
110
+ updateRemoveButtons(root, ui);
111
+ syncPresetButtons(root, loci);
112
+ }
113
+
114
+ function bindPresetButtons(root: HTMLElement, list: HTMLElement, ui: DnaProfileMatchProbabilityUI): void {
115
+ root.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => {
116
+ button.addEventListener('click', () => {
117
+ replaceLoci(list, getPreset(button.dataset.preset ?? DEFAULT_PRESET_ID).loci, ui);
118
+ root.querySelectorAll('[data-preset]').forEach((item) => item.classList.toggle('is-active', item === button));
119
+ update(root, ui);
120
+ });
121
+ });
122
+ }
123
+
124
+ function bindLocusActions(root: HTMLElement, list: HTMLElement, ui: DnaProfileMatchProbabilityUI): void {
125
+ list.addEventListener('input', () => update(root, ui));
126
+ list.addEventListener('click', (event) => {
127
+ const target = event.target as HTMLElement;
128
+ const remove = target.closest<HTMLButtonElement>('[data-remove-locus]');
129
+ if (!remove) return;
130
+ remove.closest('[data-locus-card]')?.remove();
131
+ update(root, ui);
132
+ });
133
+ }
134
+
135
+ function bindAddButton(root: HTMLElement, list: HTMLElement, ui: DnaProfileMatchProbabilityUI): void {
136
+ root.querySelector<HTMLButtonElement>('[data-add-locus]')?.addEventListener('click', () => {
137
+ if (list.querySelectorAll('[data-locus-card]').length >= MAX_LOCI) return;
138
+ const index = list.querySelectorAll('[data-locus-card]').length;
139
+ appendLocus(list, { name: `${uiText(ui, 'locus')} ${index + 1}`, allele1: '', allele2: '', frequency1: 0, frequency2: 0 }, index, ui);
140
+ update(root, ui);
141
+ });
142
+ }
143
+
144
+ function restore(list: HTMLElement, ui: DnaProfileMatchProbabilityUI): void {
145
+ const fallback: SavedProfileState = { loci: getPreset(DEFAULT_PRESET_ID).loci };
146
+ const saved = loadProfileState(fallback);
147
+ const loci = saved.loci.length >= MIN_LOCI && saved.loci.length <= MAX_LOCI ? saved.loci : fallback.loci;
148
+ replaceLoci(list, loci, ui);
149
+ }
150
+
151
+ export function startDnaProfileMatch(root: HTMLElement, ui: DnaProfileMatchProbabilityUI): void {
152
+ const list = root.querySelector<HTMLElement>('[data-locus-list]');
153
+ if (!list) return;
154
+ restore(list, ui);
155
+ bindPresetButtons(root, list, ui);
156
+ bindLocusActions(root, list, ui);
157
+ bindAddButton(root, list, ui);
158
+ update(root, ui);
159
+ }