@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.
- package/package.json +2 -1
- package/src/category/i18n/en.ts +3 -3
- package/src/category/i18n/es.ts +2 -2
- package/src/category/i18n/fr.ts +1 -1
- package/src/category/i18n/nl.ts +1 -1
- package/src/category/i18n/pt.ts +1 -1
- package/src/category/index.ts +61 -59
- package/src/category/seo.astro +1 -1
- package/src/components/PreviewNavSidebar.astro +1 -1
- package/src/components/PreviewToolbar.astro +1 -1
- package/src/data.ts +1 -1
- package/src/entries.ts +70 -68
- package/src/env.d.ts +1 -1
- package/src/icons/.gitkeep +1 -0
- package/src/index.ts +32 -31
- package/src/pages/[locale]/[slug].astro +161 -159
- package/src/pages/[locale].astro +8 -5
- package/src/pages/index.astro +1 -1
- package/src/tests/bibliography_wellformed_export.test.ts +46 -0
- package/src/tests/category_seo_quality.test.ts +76 -0
- package/src/tests/diacritics_density.test.ts +1 -1
- package/src/tests/faq_count.test.ts +19 -19
- package/src/tests/i18n_coverage.test.ts +36 -36
- package/src/tests/inverted_punctuation.test.ts +1 -1
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/mocks/astro_mock.js +1 -1
- package/src/tests/no_h1_in_components.test.ts +1 -1
- package/src/tests/script_density.test.ts +94 -94
- package/src/tests/seo_length.test.ts +46 -46
- package/src/tests/seo_parity.test.ts +2 -7
- package/src/tests/seo_translation_completeness.test.ts +68 -0
- package/src/tests/seo_wellformed_export.test.ts +65 -0
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tests/translation_copy.test.ts +23 -24
- package/src/tool/bloodstain-pattern-origin-analyzer/component.astro +9 -9
- package/src/tool/bloodstain-pattern-origin-analyzer/i18n/zh.ts +1 -1
- package/src/tool/dna-profile-match-probability-lab/bibliography.astro +14 -0
- package/src/tool/dna-profile-match-probability-lab/bibliography.ts +16 -0
- package/src/tool/dna-profile-match-probability-lab/component.astro +121 -0
- package/src/tool/dna-profile-match-probability-lab/controller.ts +159 -0
- package/src/tool/dna-profile-match-probability-lab/dna-profile-match-probability-lab.css +485 -0
- package/src/tool/dna-profile-match-probability-lab/dom-views.ts +93 -0
- package/src/tool/dna-profile-match-probability-lab/entry.ts +29 -0
- package/src/tool/dna-profile-match-probability-lab/evaluator.ts +19 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/de.ts +43 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/en.ts +196 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/es.ts +40 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/fr.ts +10 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/id.ts +41 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/it.ts +10 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/ja.ts +40 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/ko.ts +40 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/nl.ts +29 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/pl.ts +10 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/pt.ts +41 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/ru.ts +29 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/sv.ts +41 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/tr.ts +10 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/zh.ts +29 -0
- package/src/tool/dna-profile-match-probability-lab/index.ts +11 -0
- package/src/tool/dna-profile-match-probability-lab/localize.ts +107 -0
- package/src/tool/dna-profile-match-probability-lab/logic.test.ts +56 -0
- package/src/tool/dna-profile-match-probability-lab/logic.ts +139 -0
- package/src/tool/dna-profile-match-probability-lab/seo.astro +15 -0
- package/src/tool/dna-profile-match-probability-lab/storage.ts +26 -0
- package/src/tool/dna-profile-match-probability-lab/ui.ts +3 -0
- package/src/tool/fire-pattern-origin-analyzer/component.astro +1 -1
- package/src/tool/fire-pattern-origin-analyzer/i18n/zh.ts +5 -1
- package/src/tool/fire-pattern-origin-analyzer/logic.ts +9 -2
- package/src/tool/fire-pattern-origin-analyzer/view-bindings.ts +8 -4
- package/src/tool/fire-pattern-origin-analyzer/view-model.ts +6 -2
- package/src/tool/forensic-age-estimator/bibliography.astro +1 -1
- package/src/tool/forensic-age-estimator/component.astro +1 -1
- package/src/tool/forensic-age-estimator/entry.ts +17 -17
- package/src/tool/forensic-age-estimator/i18n/de.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/en.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/es.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/fr.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/id.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/it.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/nl.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/pl.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/pt.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/ru.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/sv.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/tr.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/zh.ts +245 -245
- package/src/tool/forensic-age-estimator/index.ts +1 -1
- package/src/tool/forensic-age-estimator/seo.astro +1 -1
- package/src/tool/forensic-blood-test-simulator/component.astro +1 -1
- package/src/tool/forensic-fiber-comparison-microscope/component.astro +1 -1
- package/src/tool/forensic-fiber-comparison-microscope/render.ts +1 -1
- package/src/tool/forensic-fiber-comparison-microscope/view.ts +1 -1
- package/src/tool/forensic-fingerprint-minutiae-identifier/component.astro +2 -2
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/ja.ts +2 -1
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/zh.ts +2 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/renderer.ts +9 -8
- package/src/tool/forensic-glass-becke-line-simulator/component.astro +1 -1
- package/src/tool/forensic-glass-becke-line-simulator/view.ts +8 -6
- package/src/tool/forensic-image-authenticity-analyzer/component.astro +1 -1
- package/src/tool/forensic-image-authenticity-analyzer/i18n/de.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/en.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/es.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/fr.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/id.ts +95 -90
- package/src/tool/forensic-image-authenticity-analyzer/i18n/it.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/ja.ts +7 -2
- package/src/tool/forensic-image-authenticity-analyzer/i18n/ko.ts +4 -1
- package/src/tool/forensic-image-authenticity-analyzer/i18n/nl.ts +95 -92
- package/src/tool/forensic-image-authenticity-analyzer/i18n/pl.ts +95 -92
- package/src/tool/forensic-image-authenticity-analyzer/i18n/pt.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/ru.ts +97 -92
- package/src/tool/forensic-image-authenticity-analyzer/i18n/sv.ts +97 -93
- package/src/tool/forensic-image-authenticity-analyzer/i18n/tr.ts +98 -93
- package/src/tool/forensic-image-authenticity-analyzer/i18n/zh.ts +11 -0
- package/src/tool/forensic-microcrystal-drug-simulator/component.astro +1 -1
- package/src/tool/forensic-sex-determinator/component.astro +3 -2
- package/src/tool/forensic-stature-estimator/component.astro +1 -1
- package/src/tool/forensic-stature-estimator/i18n/id.ts +4 -0
- package/src/tool/forensic-stature-estimator/i18n/nl.ts +4 -0
- package/src/tool/forensic-stature-estimator/i18n/ru.ts +4 -0
- package/src/tool/forensic-tlc-ink-simulator/component.astro +1 -1
- package/src/tool/forensic-tlc-ink-simulator/i18n/zh.ts +1 -0
- package/src/tool/forensic-toolmark-striation-matcher/component.astro +1 -1
- package/src/tool/forensic-toolmark-striation-matcher/logic.ts +1 -1
- package/src/tool/forensic-toolmark-striation-matcher/renderer.ts +7 -6
- package/src/tool/forensic-toolmark-striation-matcher/view.ts +19 -20
- package/src/tool/gsr-dispersion-calculator/component.astro +1 -1
- package/src/tool/gsr-dispersion-calculator/logic.ts +1 -1
- package/src/tool/time-of-death-algor-mortis-calculator/entry.ts +2 -0
- package/src/tool/widmark-alcohol-simulator/component.astro +1 -1
- package/src/tool/widmark-alcohol-simulator/i18n/de.ts +5 -0
- package/src/tool/widmark-alcohol-simulator/i18n/fr.ts +2 -1
- package/src/tool/widmark-alcohol-simulator/i18n/id.ts +1 -0
- package/src/tool/widmark-alcohol-simulator/i18n/it.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/ja.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/nl.ts +5 -1
- package/src/tool/widmark-alcohol-simulator/i18n/pl.ts +5 -1
- package/src/tool/widmark-alcohol-simulator/i18n/pt.ts +5 -1
- package/src/tool/widmark-alcohol-simulator/i18n/ru.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/sv.ts +5 -1
- package/src/tool/widmark-alcohol-simulator/i18n/tr.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/zh.ts +4 -1
- package/src/tools.ts +37 -35
- package/src/types.ts +4 -4
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { content as enContent } from './en';
|
|
2
|
+
import { createDnaLocale } from '../localize';
|
|
3
|
+
|
|
4
|
+
export const content = createDnaLocale({
|
|
5
|
+
locale: 'ru',
|
|
6
|
+
slug: 'laboratoriya-veroyatnosti-sovpadeniya-dnk-profilya',
|
|
7
|
+
title: 'Лаборатория вероятности совпадения профиля ДНК',
|
|
8
|
+
description: 'Изучите, как частоты аллелей и правила Харди Вайнберга образуют учебную вероятность случайного совпадения профиля ДНК.',
|
|
9
|
+
ui: {
|
|
10
|
+
...enContent.ui,
|
|
11
|
+
controls: 'Настройки профиля', controlKicker: 'Создать профиль', controlHelper: 'Выберите учебный пресет или откройте каждый локус, чтобы изменить аллели и частоты в популяции.', presets: 'Учебные пресеты', presetbalanced: 'Сбалансированный', presethomozygote: 'Гомозиготы', presetclassroom: 'Простой класс', loci: 'локусов', locus: 'Локус', locusName: 'Название локуса', alleleA: 'Аллель A', alleleB: 'Аллель B', frequencyA: 'Частота A (%)', frequencyB: 'Частота B (%)', removeLocus: 'Удалить локус', addLocus: 'Добавить локус', disclaimer: 'Учебный симулятор. Он не идентифицирует людей, не оценивает дела и не заменяет лабораторный расчет.', sceneKicker: 'Сигнальный след', electropherogram: 'След профиля', sceneNote: 'Два пика показывают введенные аллели каждого локуса. Высота служит визуальной подсказкой частоты, а не лабораторным сигналом.', peakHeight: 'Высота пика зависит от частоты', results: 'Результаты вероятности', resultKicker: 'Вероятность случайного совпадения', locusBreakdown: 'Расчет по локусам', genotype: 'Генотип', rule: 'Правило', frequency: 'Частота генотипа', badgeNeedsData: 'Нужны данные', badgeExploratory: 'Исследовательский', badgeCalculated: 'Расчетная модель', needsData: 'Заполните каждый локус двумя аллелями и частотами от 0 до 100 процентов; сумма пары частот не должна превышать 100 процентов.', exploratoryResult: 'Этот небольшой профиль подходит для изучения метода. Дополнительные локусы могут сделать профиль более редким, но не превращают модель в вывод по делу.', calculatedResult: 'Произведение рассчитано по введенным частотам с упрощенным предположением о независимости.', notCalculated: 'Не рассчитано', completeFields: 'Заполните поля профиля, чтобы увидеть результат.', ofRandomProfiles: 'случайных профилей в этой учебной модели', missingValue: 'Локус без названия', pattern: 'Тип профиля', calculation: 'Расчет', meaning: 'Значение', homozygote: 'Два одинаковых аллеля', heterozygote: 'Два разных аллеля', independentLoci: 'Несколько независимых локусов', homozygoteMeaning: 'Частота аллеля умножается сама на себя.', heterozygoteMeaning: 'Две частоты аллелей перемножаются и удваиваются.', independentLociMeaning: 'Каждый локус вносит вклад в общую частоту профиля.',
|
|
12
|
+
},
|
|
13
|
+
seo: {
|
|
14
|
+
calculatorTitle: 'Калькулятор вероятности случайного совпадения профиля ДНК', intro: 'Эта лаборатория вероятности профиля ДНК помогает студентам и любознательным читателям понять, как небольшой аутосомный STR профиль становится вероятностью в упрощенной модели популяционной генетики. Введите два аллеля и их частоты для каждого локуса. Калькулятор применяет правило Харди Вайнберга и перемножает частоты локусов, показывая учебный пример случайного совпадения.', methodTitle: 'Как работает правило генотипа', method: 'Интерфейс показывает ход расчета: след отображает введенные аллели, таблица показывает частоту каждого генотипа, а итог записывается как один к степени десяти. Для гомозиготы используется p², для гетерозиготы 2pq. Пресеты содержат синтетические учебные значения, а не официальную популяционную базу.', populationTitle: 'Почему важны частоты популяции', population: 'Частоты аллелей являются оценками из популяционных баз данных. На интерпретацию влияют референсная популяция, отбор образцов, размер базы, родство, популяционная подструктура и валидация. Если частоты описывают нужную популяцию, профиль имеет один источник, а локусы считаются независимыми, произведение оценивает частоту профиля среди случайных неродственных людей. Изменение предположения меняет смысл результата.', interpretation: 'Результат является условным утверждением, зависящим от введенных значений. Модель не учитывает выбор конкретного человека, родство, смешанные профили или влияние поиска по базе данных. Поэтому учебный пример нужно рассматривать вместе с популяцией, методом и указанной неопределенностью.', application: 'Для полезного упражнения сначала выберите пресет, затем измените только один локус и наблюдайте за таблицей и итоговой вероятностью. Запишите введенные значения, источник частот и упрощенное предположение о независимости. Такой экран предназначен для обучения и самостоятельного изучения, а не для криминалистического заключения. Сравните несколько вариантов профиля и проверьте, как один редкий аллель меняет общий результат и его объяснение.', limitsTitle: 'Учебные ограничения', limits: 'В реальном деле также документируют отбор человека, базу данных, смешанные профили, подструктуру популяции и экспертную интерпретацию. Это приложение не анализирует электрофореграммы, не исправляет подструктуру, не вычисляет отношения правдоподобия и не ищет в базах ДНК.', tipTitle: 'Используйте как модель, а не как вердикт', tip: 'Малая вероятность случайного совпадения не доказывает личность или вину. Используйте инструмент для объяснения арифметики и сохраняйте видимыми источник данных, популяцию, предположения и неопределенность.', list: ['<strong>Одна популяция:</strong> Не смешивайте частоты из несопоставимых баз.', '<strong>Видимые предположения:</strong> Лаборатория предполагает неродственных людей и упрощенную независимость локусов.', '<strong>Разделяйте вероятность и личность:</strong> Редкий профиль не доказывает источник или вину.', '<strong>Документируйте данные:</strong> Формальный отчет требует источника, метода, неопределенности и интерпретации.'],
|
|
15
|
+
},
|
|
16
|
+
faq: [
|
|
17
|
+
{ question: 'Что означает вероятность случайного совпадения?', answer: 'Это оценка вероятности того, что случайно выбранный неродственный человек имеет введенный профиль при выбранных частотах и предположениях. Это не вероятность вины или происхождения конкретного образца.' },
|
|
18
|
+
{ question: 'Как рассчитывается частота генотипа?', answer: 'Для гомозиготы используется p², для гетерозиготы 2pq, после чего частоты локусов перемножаются.' },
|
|
19
|
+
{ question: 'Почему частоты пресетов синтетические?', answer: 'Это небольшие примеры для обучения. Реальная криминалистика требует валидированной, репрезентативной и документированной базы популяции.' },
|
|
20
|
+
{ question: 'Может ли инструмент сравнить двух людей?', answer: 'Нет. Он не принимает биологические образцы, не сравнивает профили и никого не идентифицирует; он показывает только математическую модель.' },
|
|
21
|
+
{ question: 'Почему малая вероятность не является вердиктом?', answer: 'Интерпретация зависит от базы, выбора человека, качества профиля, предположений и профессиональной оценки.' },
|
|
22
|
+
],
|
|
23
|
+
howTo: [
|
|
24
|
+
{ name: 'Выберите учебный пресет', text: 'Начните со сбалансированного пресета, гомозигот или простого класса.' },
|
|
25
|
+
{ name: 'Измените локус', text: 'Введите название, два аллеля и частоту каждого аллеля в процентах.' },
|
|
26
|
+
{ name: 'Прочитайте правило локуса', text: 'В таблице показаны p² для гомозигот и 2pq для гетерозигот.' },
|
|
27
|
+
{ name: 'Интерпретируйте осторожно', text: 'Считайте результат учебной моделью и держите предположения видимыми.' },
|
|
28
|
+
],
|
|
29
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { content as enContent } from './en';
|
|
2
|
+
import { createDnaLocale } from '../localize';
|
|
3
|
+
|
|
4
|
+
export const content = createDnaLocale({
|
|
5
|
+
locale: 'sv',
|
|
6
|
+
slug: 'laboratorium-sannolikhet-dna-profilmatchning',
|
|
7
|
+
title: 'Laboratorium för sannolikhet vid DNA profilmatchning',
|
|
8
|
+
description: 'Utforska hur allelfrekvenser och Hardy-Weinberg-regler skapar en pedagogisk sannolikhet för slumpmässig DNA-profilmatchning.',
|
|
9
|
+
ui: {
|
|
10
|
+
...enContent.ui,
|
|
11
|
+
controls: 'Profilinställningar', controlKicker: 'Bygg en profil', controlHelper: 'Välj en pedagogisk förinställning eller öppna varje locus för att ändra alleler och populationsfrekvenser.', presets: 'Pedagogiska förinställningar', presetbalanced: 'Balanserad', presethomozygote: 'Homozygoter', presetclassroom: 'Enkel klass', loci: 'loci', locus: 'Locus', locusName: 'Locusnamn', alleleA: 'Allel A', alleleB: 'Allel B', frequencyA: 'Frekvens A (%)', frequencyB: 'Frekvens B (%)', removeLocus: 'Ta bort locus', addLocus: 'Lägg till locus', disclaimer: 'Pedagogisk simulator. Den identifierar ingen person, validerar inget fall och ersätter inte en laboratorieberäkning.', sceneKicker: 'Signalspår', electropherogram: 'Profilspår', sceneNote: 'Två toppar visar de två angivna allelerna för varje locus. Höjden är en visuell frekvenssignal, inte en laboratoriesignal.', peakHeight: 'Toppens höjd följer angiven frekvens', results: 'Sannolikhetsresultat', resultKicker: 'Sannolikhet för slumpmässig matchning', locusBreakdown: 'Beräkning locus för locus', genotype: 'Genotyp', rule: 'Regel', frequency: 'Genotypfrekvens', badgeNeedsData: 'Data behövs', badgeExploratory: 'Utforskande', badgeCalculated: 'Beräknad modell', needsData: 'Fyll i varje locus med två alleler och frekvenser mellan 0 och 100 %; summan av ett frekvenspar får inte överstiga 100 %.', exploratoryResult: 'Den här lilla profilen är användbar för att lära sig metoden. Fler loci kan göra profilen ovanligare men gör inte modellen till en slutsats i ett fall.', calculatedResult: 'Produkten beräknas från de angivna frekvenserna med förenklade antaganden om oberoende.', notCalculated: 'Inte beräknad', completeFields: 'Fyll i profilfälten för att se ett resultat.', ofRandomProfiles: 'av slumpmässiga profiler enligt denna pedagogiska modell', missingValue: 'Namnlös locus', pattern: 'Profilmönster', calculation: 'Beräkning', meaning: 'Betydelse', homozygote: 'Två lika alleler', heterozygote: 'Två olika alleler', independentLoci: 'Flera oberoende loci', homozygoteMeaning: 'Allelfrekvensen multipliceras med sig själv.', heterozygoteMeaning: 'De två allelfrekvenserna multipliceras och fördubblas.', independentLociMeaning: 'Varje locus bidrar till profilens sammanlagda frekvens.',
|
|
12
|
+
},
|
|
13
|
+
seo: {
|
|
14
|
+
calculatorTitle: 'Kalkylator för sannolikhet vid slumpmässig DNA-profilmatchning',
|
|
15
|
+
intro: 'Det här laboratoriet för DNA-profilsannolikhet hjälper studenter och nyfikna läsare att utforska hur en liten autosomal STR-profil blir en sannolikhet i en förenklad populationsgenetisk modell. Ange två alleler och deras frekvenser för varje locus. Kalkylatorn använder Hardy-Weinberg-regeln och multiplicerar locusfrekvenserna för att visa en pedagogisk slumpmässig matchning.',
|
|
16
|
+
methodTitle: 'Så fungerar genotypregeln',
|
|
17
|
+
method: 'Gränssnittet gör beräkningen synlig: spåret visar de angivna allelerna, tabellen visar varje genotypfrekvens och slutresultatet skrivs som en på en tiopotens. En homozygot använder p² och en heterozygot 2pq. Förinställningarna innehåller syntetiska undervisningsvärden, inte en officiell populationsdatabas.',
|
|
18
|
+
populationTitle: 'Varför populationsfrekvenser spelar roll',
|
|
19
|
+
population: 'Allelfrekvenser är uppskattningar från populationsdatabaser. Referenspopulation, urval, databasens storlek, släktskap, populationsstruktur och validering påverkar tolkningen. Om frekvenserna beskriver den relevanta populationen, profilen är en ren enkelkälla och loci behandlas som oberoende, uppskattar produkten profilens frekvens bland slumpmässigt valda obesläktade personer. Ändrade antaganden ändrar resultatets betydelse.',
|
|
20
|
+
interpretation: 'Resultatet är ett villkorat påstående om de värden som angavs. Modellen tar inte hänsyn till personurval, släktskap, blandprofiler eller effekten av en databassökning. Ett undervisningsexempel bör därför läsas tillsammans med population, metod och angiven osäkerhet.',
|
|
21
|
+
application: 'En bra övning börjar med att välja en förinställning, ändra bara ett locus och sedan följa tabellen och den sammanlagda sannolikheten. Då blir det tydligt att sällsynta alleler kan ändra ett enskilt locus bidrag kraftigt. Skriv ner de angivna värdena, frekvensernas källa och det förenklade antagandet om oberoende. Vyn är avsedd för undervisning, självstudier och en tydlig förklaring av beräkningen, inte för ett forensiskt utlåtande.',
|
|
22
|
+
limitsTitle: 'Pedagogiska begränsningar',
|
|
23
|
+
limits: 'I ett verkligt fall måste även personurval, databas, blandprofiler, populationsstruktur och expertens tolkning dokumenteras. Verktyget analyserar inte elektroferogram, korrigerar inte populationsstruktur, beräknar inte likelihood-kvoter och söker inte i DNA-databaser.',
|
|
24
|
+
tipTitle: 'Använd som modell, inte som dom',
|
|
25
|
+
tip: 'En liten sannolikhet för slumpmässig matchning är inte ett påstående om identitet eller skuld. Använd verktyget för att förklara aritmetiken och håll källa, population, antaganden och osäkerhet synliga.',
|
|
26
|
+
list: ['<strong>En population:</strong> Blanda inte frekvenser från databaser som inte kan jämföras.', '<strong>Synliga antaganden:</strong> Laboratoriet antar obesläktade personer och förenklat oberoende mellan loci.', '<strong>Skilj sannolikhet från identitet:</strong> En ovanlig profil bevisar inte källa eller skuld.', '<strong>Dokumentera data:</strong> En formell rapport behöver källa, metod, osäkerhet och tolkning.'],
|
|
27
|
+
},
|
|
28
|
+
faq: [
|
|
29
|
+
{ question: 'Vad betyder sannolikhet för slumpmässig matchning?', answer: 'Det är en uppskattning av sannolikheten att en slumpmässigt vald obesläktad person har den angivna profilen enligt valda frekvenser och antaganden. Det är inte sannolikheten för skuld eller för att ett prov kommer från en viss person.' },
|
|
30
|
+
{ question: 'Hur beräknas en genotypfrekvens?', answer: 'För en homozygot används p². För en heterozygot används 2pq, varefter locusfrekvenserna multipliceras.' },
|
|
31
|
+
{ question: 'Varför är förinställningarnas frekvenser syntetiska?', answer: 'Det är små undervisningsexempel. Forensiskt arbete kräver en validerad, representativ och dokumenterad populationsdatabas.' },
|
|
32
|
+
{ question: 'Kan verktyget jämföra två personer?', answer: 'Nej. Det tar inte emot biologiska prover, jämför inte profiler och identifierar ingen; det visar bara en matematisk modell.' },
|
|
33
|
+
{ question: 'Varför är en mycket liten sannolikhet inte en dom?', answer: 'Tolkningen beror på databas, urval, profilkvalitet, antaganden och professionell bedömning.' },
|
|
34
|
+
],
|
|
35
|
+
howTo: [
|
|
36
|
+
{ name: 'Välj en pedagogisk förinställning', text: 'Börja med den balanserade, homozygota eller enkla klassprofilen.' },
|
|
37
|
+
{ name: 'Redigera ett locus', text: 'Ange namn, två alleler och varje allels frekvens i procent.' },
|
|
38
|
+
{ name: 'Läs locusregeln', text: 'Tabellen visar p² för homozygoter och 2pq för heterozygoter.' },
|
|
39
|
+
{ name: 'Tolka försiktigt', text: 'Se resultatet som en pedagogisk modell och behåll antagandena synliga.' },
|
|
40
|
+
],
|
|
41
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { content as enContent } from './en';
|
|
2
|
+
import { createDnaLocale } from '../localize';
|
|
3
|
+
|
|
4
|
+
export const content = createDnaLocale({
|
|
5
|
+
locale: 'tr', slug: 'dna-profili-eslesme-olasiligi-laboratuvari', title: "DNA Profili Eşleşme Olasılığı Laboratuvarı", description: "Alel frekanslarının ve Hardy Weinberg kurallarının eğitsel bir rastgele DNA profili eşleşme olasılığı oluşturmasını keşfedin.",
|
|
6
|
+
ui: { ...enContent.ui, controls: "Profil kontrolleri", controlKicker: "Profil oluştur", controlHelper: "Eğitsel bir hazır ayar kullanın veya her lokusu açarak alelleri ve toplum frekanslarını düzenleyin.", presets: "Eğitsel hazır ayarlar", presetbalanced: "Dengeli", presethomozygote: "Homozigotlar", presetclassroom: "Basit sınıf", disclaimer: "Eğitsel simülatör. Kişi tanımlamaz, vaka doğrulamaz ve laboratuvar hesabının yerini tutmaz.", results: "Olasılık sonuçları", resultKicker: "Rastgele eşleşme olasılığı", locusBreakdown: "Lokus bazında hesaplama", badgeNeedsData: "Veri gerekli", badgeExploratory: "Keşif amaçlı", badgeCalculated: "Hesaplanmış model", needsData: "Her lokusu iki alel ve yüzde 0 ile yüzde 100 arasında frekanslarla doldurun.", completeFields: "Sonucu görmek için profil alanlarını doldurun.", pattern: "Profil deseni", calculation: "Hesaplama", meaning: "Anlam", homozygote: "Aynı iki alel", heterozygote: "Farklı iki alel", independentLoci: "Birden çok bağımsız lokus", homozygoteMeaning: "Alel frekansı kendisiyle çarpılır.", heterozygoteMeaning: "İki alel frekansı çarpılır ve ikiyle çarpılır.", independentLociMeaning: "Her lokus birleşik profil frekansına katkıda bulunur." },
|
|
7
|
+
seo: { calculatorTitle: "DNA profili rastgele eşleşme olasılığı hesaplayıcısı", intro: "Bu DNA profili olasılık laboratuvarı, öğrencilerin ve meraklı okuyucuların küçük bir otozomal STR profilinin basitleştirilmiş toplum genetiği modelinde nasıl olasılığa dönüştüğünü anlamasına yardım eder. Her lokus için iki alel ve frekanslarını girin. Hesaplayıcı Hardy Weinberg kuralını uygular ve eğitsel bir rastgele eşleşme göstermek için lokus frekanslarını çarpar.", methodTitle: "Genotip kuralı nasıl çalışır", method: "Arayüz hesaplamayı görünür tutar: iz girilen alelleri, tablo her genotip frekansını, sonuç ise on kuvvetinde bir biçimini gösterir. Homozigot için p², heterozigot için 2pq kullanılır. Hazır ayarlar resmi bir toplum veri tabanı değil, sentetik eğitim değerleri içerir.", populationTitle: "Toplum frekansları neden önemlidir", population: "Alel frekansları toplum veri tabanlarından elde edilen tahminlerdir. Referans toplum, örnekleme, veri tabanı büyüklüğü, akrabalık, toplum yapısı ve doğrulama yorumu etkiler. Frekanslar ilgili toplumu anlatıyor, profil tek kaynaktan geliyor ve lokuslar bağımsız kabul ediliyorsa çarpım, profilin rastgele ve akraba olmayan kişilerdeki sıklığını tahmin eder. Varsayımı değiştirmek sonucu değiştirir.", interpretation: "Sonuç, girilen değerlere bağlı koşullu bir ifadedir. Model kişi seçimini, akrabalığı, karışık profilleri veya veri tabanı aramasının etkisini dikkate almaz. Bu nedenle eğitsel örnek toplum, yöntem ve belirsizlik ile birlikte okunmalıdır.", application: "İyi bir alıştırma için önce bir hazır ayar seçin, sonra yalnızca bir lokusu değiştirin ve tablo ile toplam olasılığı izleyin. Girilen değerleri, frekans kaynağını ve basitleştirilmiş bağımsızlık varsayımını kaydedin. Bu ekran öğrenme ve şeffaf hesap açıklaması içindir, adli bir görüş değildir. Birkaç profil seçeneğini karşılaştırın ve tek bir nadir alelin toplam sonuca etkisini açıkça inceleyin.", limitsTitle: "Eğitsel sınırlar", limits: "Gerçek bir vakada kişi seçimi, veri tabanı, karışık profiller, toplum yapısı ve uzman yorumu da belgelenir. Bu uygulama elektroferogram analiz etmez, toplum yapısını düzeltmez, olabilirlik oranı hesaplamaz ve DNA veri tabanında arama yapmaz.", tipTitle: "Model olarak kullanın, hüküm olarak değil", tip: "Küçük bir rastgele eşleşme olasılığı kimlik veya suçluluk kanıtı değildir. Hesabı açıklamak için aracı kullanın ve veri kaynağını, toplumu, varsayımları ve belirsizliği görünür tutun.", list: ["<strong>Tek toplum bağlamı:</strong> Karşılaştırılamayan veri tabanlarının frekanslarını karıştırmayın.", "<strong>Varsayımlar görünür:</strong> Laboratuvar akraba olmayan kişiler ve lokuslar arasında basitleştirilmiş bağımsızlık varsayar.", "<strong>Olasılığı kimlikten ayırın:</strong> Nadir profil kaynak veya suç kanıtı değildir.", "<strong>Veriyi belgeleyin:</strong> Resmi rapor kaynak, yöntem, belirsizlik ve yorum gerektirir."] },
|
|
8
|
+
faq: [ { question: "Rastgele eşleşme olasılığı ne demektir?", answer: "Seçilen frekans ve varsayımlara göre rastgele seçilen akraba olmayan bir kişinin girilen profile sahip olma tahminidir. Suçluluk olasılığı veya örneğin belirli bir kişiden gelme olasılığı değildir." }, { question: "Genotip frekansı nasıl hesaplanır?", answer: "Homozigot için p², heterozigot için 2pq kullanılır; ardından lokus frekansları çarpılır." }, { question: "Hazır ayar frekansları neden sentetiktir?", answer: "Bunlar öğrenme için küçük örneklerdir. Adli çalışma doğrulanmış, temsili ve belgelenmiş bir toplum veri tabanı gerektirir." }, { question: "Araç iki kişiyi karşılaştırabilir mi?", answer: "Hayır. Biyolojik örnek almaz, profilleri karşılaştırmaz ve kimseyi tanımlamaz; yalnızca matematiksel bir modeli gösterir." }, { question: "Küçük olasılık neden hüküm değildir?", answer: "Yorum veri tabanına, seçime, profil kalitesine, varsayımlara ve uzman değerlendirmesine bağlıdır." } ],
|
|
9
|
+
howTo: [ { name: "Eğitsel hazır ayar seçin", text: "Dengeli, homozigot veya basit sınıf profilinden başlayın." }, { name: "Bir lokusu düzenleyin", text: "Adı, iki aleli ve her alelin yüzde frekansını girin." }, { name: "Lokus kuralını okuyun", text: "Tablo homozigot için p² ve heterozigot için 2pq gösterir." }, { name: "Sonucu dikkatle yorumlayın", text: "Sonucu eğitsel model olarak görün ve varsayımları açık tutun." } ],
|
|
10
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { content as enContent } from './en';
|
|
2
|
+
import { createDnaLocale } from '../localize';
|
|
3
|
+
|
|
4
|
+
export const content = createDnaLocale({
|
|
5
|
+
locale: 'zh',
|
|
6
|
+
slug: 'dna-profile-match-probability-lab',
|
|
7
|
+
title: 'DNA档案随机匹配概率实验室',
|
|
8
|
+
description: '了解等位基因频率和哈代温伯格规则如何形成用于学习的DNA档案随机匹配概率。',
|
|
9
|
+
ui: {
|
|
10
|
+
...enContent.ui,
|
|
11
|
+
controls: '档案设置', controlKicker: '创建档案', controlHelper: '选择教学预设,或打开每个位点编辑等位基因和群体频率。', presets: '教学预设', presetbalanced: '平衡型', presethomozygote: '纯合子', presetclassroom: '简单课堂', loci: '个位点', locus: '位点', locusName: '位点名称', alleleA: '等位基因A', alleleB: '等位基因B', frequencyA: '频率A (%)', frequencyB: '频率B (%)', removeLocus: '删除位点', addLocus: '添加位点', disclaimer: '教学模拟器。不识别个人、不验证案件,也不能替代实验室计算。', sceneKicker: '信号轨迹', electropherogram: '档案轨迹', sceneNote: '每个位点用两个峰显示输入的两个等位基因。峰高只是频率的视觉提示,不是实验室信号。', peakHeight: '峰高随输入频率变化', results: '概率结果', resultKicker: '随机匹配概率', locusBreakdown: '逐个位点计算', genotype: '基因型', rule: '规则', frequency: '基因型频率', badgeNeedsData: '需要数据', badgeExploratory: '探索性', badgeCalculated: '计算模型', needsData: '请为每个位点填写两个等位基因和0到100%的频率;一对频率的总和不能超过100%。', exploratoryResult: '这个小型档案适合学习方法。增加位点可能让档案更罕见,但不会让模型成为案件结论。', calculatedResult: '结果根据输入频率和简化的独立性假设计算。', notCalculated: '未计算', completeFields: '填写档案字段后才能查看结果。', ofRandomProfiles: '在此教学模型的随机档案中', missingValue: '未命名位点', pattern: '档案模式', calculation: '计算', meaning: '含义', homozygote: '两个相同等位基因', heterozygote: '两个不同等位基因', independentLoci: '多个独立位点', homozygoteMeaning: '等位基因频率与自身相乘。', heterozygoteMeaning: '两个等位基因频率相乘后再加倍。', independentLociMeaning: '每个位点都会影响档案的综合频率。',
|
|
12
|
+
},
|
|
13
|
+
seo: {
|
|
14
|
+
calculatorTitle: 'DNA档案随机匹配概率计算器', intro: '这个DNA档案概率实验室帮助学生和好奇的读者了解,在简化的群体遗传学模型中,一个小型常染色体STR档案如何变成概率。为每个位点输入两个等位基因及其频率,计算器会应用哈代温伯格规则并相乘各个位点频率,显示一个教学用的随机匹配结果。', methodTitle: '基因型规则如何工作', method: '界面展示完整的计算过程:轨迹显示输入的等位基因,表格显示每个基因型频率,最终结果用十的幂的一分之一表示。纯合子使用p²,杂合子使用2pq。预设使用合成的教学数值,并不是官方群体数据库。', populationTitle: '群体频率为什么重要', population: '等位基因频率是从群体数据库中估计出来的。参考群体、抽样方式、数据库规模、亲缘关系、群体结构和验证都会影响解释。只有在频率代表相关群体、档案来自单一来源且位点可视为独立时,乘积才可估计该档案在随机无亲缘人群中的出现频率。改变假设也会改变结果的含义。', interpretation: '这个结果是依赖输入数值的条件性陈述。模型没有考虑人员选择、亲缘关系、混合档案或数据库搜索的影响。因此,教学示例应当和群体、方法以及不确定性一起阅读,而不应脱离这些条件单独解释。', application: '练习时可以先选择一个预设,只修改一个位点,然后观察表格和总概率的变化。这样可以看到罕见等位基因如何改变单个位点的贡献。请记录输入值、频率来源和独立性假设;这个界面用于教学和自学,不是法医学意见。', limitsTitle: '教学限制', limits: '在真实案件中,还需要记录人员选择、数据库、混合档案、群体结构和专家解释。本工具不分析电泳图、不修正群体结构、不计算似然比,也不搜索DNA数据库。', tipTitle: '把它当作模型,而不是结论', tip: '很小的随机匹配概率不能证明身份或有罪。请用它解释算术过程,并保留数据来源、群体、假设和不确定性。', list: ['<strong>一个群体:</strong> 不要混用无法比较的数据库频率。', '<strong>显示假设:</strong> 实验室假设人员无亲缘关系且位点之间为简化独立。', '<strong>区分概率和身份:</strong> 罕见档案不是来源或有罪的证明。', '<strong>记录数据:</strong> 正式报告需要来源、方法、不确定性和解释。'],
|
|
15
|
+
},
|
|
16
|
+
faq: [
|
|
17
|
+
{ question: '随机匹配概率是什么意思?', answer: '它估计在所选频率和假设下,随机选择的无亲缘关系人员拥有输入档案的概率。这不是有罪概率,也不是某个样本来自特定人员的概率。' },
|
|
18
|
+
{ question: '基因型频率如何计算?', answer: '纯合子使用p²,杂合子使用2pq,然后将各个位点频率相乘。' },
|
|
19
|
+
{ question: '为什么预设频率是合成的?', answer: '它们是用于学习的小型示例。真实法医学工作需要经过验证、具有代表性并且有记录的群体数据库。' },
|
|
20
|
+
{ question: '工具可以比较两个人吗?', answer: '不可以。它不接收生物样本、不比较档案、不搜索数据库,也不识别任何人,只展示数学模型。' },
|
|
21
|
+
{ question: '为什么很小的概率不是结论?', answer: '因为解释取决于数据库、人员选择、档案质量、模型假设和专业判断。' },
|
|
22
|
+
],
|
|
23
|
+
howTo: [
|
|
24
|
+
{ name: '选择教学预设', text: '从平衡型、纯合子或简单课堂预设开始。' },
|
|
25
|
+
{ name: '编辑一个位点', text: '输入名称、两个等位基因以及每个等位基因的百分比频率。' },
|
|
26
|
+
{ name: '阅读位点规则', text: '表格会显示纯合子的p²和杂合子的2pq。' },
|
|
27
|
+
{ name: '谨慎解释结果', text: '把结果当作教学模型,并保持假设可见。' },
|
|
28
|
+
],
|
|
29
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { dnaProfileMatchProbabilityLab } from './entry';
|
|
2
|
+
import type { ToolDefinition } from '../../types';
|
|
3
|
+
|
|
4
|
+
export * from './entry';
|
|
5
|
+
|
|
6
|
+
export const DNA_PROFILE_MATCH_PROBABILITY_LAB_TOOL: ToolDefinition = {
|
|
7
|
+
entry: dnaProfileMatchProbabilityLab,
|
|
8
|
+
Component: () => import('./component.astro'),
|
|
9
|
+
SEOComponent: () => import('./seo.astro'),
|
|
10
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
11
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { bibliography } from './bibliography';
|
|
2
|
+
import type { ToolLocaleContent } from '../../types';
|
|
3
|
+
import type { DnaProfileMatchProbabilityUI } from './ui';
|
|
4
|
+
|
|
5
|
+
interface DnaLocaleCopy {
|
|
6
|
+
locale: string;
|
|
7
|
+
slug: string;
|
|
8
|
+
title: string;
|
|
9
|
+
description: string;
|
|
10
|
+
ui: DnaProfileMatchProbabilityUI;
|
|
11
|
+
seo: {
|
|
12
|
+
calculatorTitle: string;
|
|
13
|
+
intro: string;
|
|
14
|
+
methodTitle: string;
|
|
15
|
+
method: string;
|
|
16
|
+
populationTitle: string;
|
|
17
|
+
population: string;
|
|
18
|
+
limitsTitle: string;
|
|
19
|
+
limits: string;
|
|
20
|
+
interpretation?: string;
|
|
21
|
+
application?: string;
|
|
22
|
+
tipTitle: string;
|
|
23
|
+
tip: string;
|
|
24
|
+
list: string[];
|
|
25
|
+
};
|
|
26
|
+
faq: { question: string; answer: string }[];
|
|
27
|
+
howTo: { name: string; text: string }[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function uiText(ui: DnaProfileMatchProbabilityUI, key: string): string {
|
|
31
|
+
return ui[key] ?? '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createDnaLocale(copy: DnaLocaleCopy): ToolLocaleContent<DnaProfileMatchProbabilityUI> {
|
|
35
|
+
return {
|
|
36
|
+
slug: copy.slug,
|
|
37
|
+
title: copy.title,
|
|
38
|
+
description: copy.description,
|
|
39
|
+
ui: copy.ui,
|
|
40
|
+
seo: buildSeo(copy),
|
|
41
|
+
faq: copy.faq,
|
|
42
|
+
bibliography,
|
|
43
|
+
howTo: copy.howTo,
|
|
44
|
+
schemas: buildSchemas(copy),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildSeo(copy: DnaLocaleCopy): ToolLocaleContent['seo'] {
|
|
49
|
+
const { seo, ui } = copy;
|
|
50
|
+
const sections: ToolLocaleContent['seo'] = [
|
|
51
|
+
{ type: 'title', text: seo.calculatorTitle, level: 2 },
|
|
52
|
+
{ type: 'paragraph', html: seo.intro },
|
|
53
|
+
{ type: 'title', text: seo.methodTitle, level: 3 },
|
|
54
|
+
{
|
|
55
|
+
type: 'table',
|
|
56
|
+
headers: [uiText(ui, 'pattern'), uiText(ui, 'calculation'), uiText(ui, 'meaning')],
|
|
57
|
+
rows: [
|
|
58
|
+
[uiText(ui, 'homozygote'), 'p²', uiText(ui, 'homozygoteMeaning')],
|
|
59
|
+
[uiText(ui, 'heterozygote'), '2pq', uiText(ui, 'heterozygoteMeaning')],
|
|
60
|
+
[uiText(ui, 'independentLoci'), '×', uiText(ui, 'independentLociMeaning')],
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
{ type: 'paragraph', html: seo.method },
|
|
64
|
+
{ type: 'title', text: seo.populationTitle, level: 3 },
|
|
65
|
+
{ type: 'paragraph', html: seo.population },
|
|
66
|
+
...optionalContext(seo),
|
|
67
|
+
{ type: 'list', items: seo.list },
|
|
68
|
+
{ type: 'title', text: seo.limitsTitle, level: 3 },
|
|
69
|
+
{ type: 'tip', title: seo.tipTitle, html: seo.tip },
|
|
70
|
+
{ type: 'paragraph', html: seo.limits },
|
|
71
|
+
];
|
|
72
|
+
return sections;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function optionalContext(seo: DnaLocaleCopy['seo']): ToolLocaleContent['seo'] {
|
|
76
|
+
const text = [seo.interpretation, seo.application].filter(Boolean).join(' ');
|
|
77
|
+
return text ? [{ type: 'paragraph', html: text }] : [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildSchemas(copy: DnaLocaleCopy): ToolLocaleContent['schemas'] {
|
|
81
|
+
return [
|
|
82
|
+
{
|
|
83
|
+
'@context': 'https://schema.org',
|
|
84
|
+
'@type': 'SoftwareApplication',
|
|
85
|
+
name: copy.title,
|
|
86
|
+
description: copy.description,
|
|
87
|
+
applicationCategory: 'EducationalApplication',
|
|
88
|
+
operatingSystem: 'Any',
|
|
89
|
+
inLanguage: copy.locale,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
'@context': 'https://schema.org',
|
|
93
|
+
'@type': 'FAQPage',
|
|
94
|
+
mainEntity: copy.faq.map((item) => ({
|
|
95
|
+
'@type': 'Question',
|
|
96
|
+
name: item.question,
|
|
97
|
+
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
|
98
|
+
})),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
'@context': 'https://schema.org',
|
|
102
|
+
'@type': 'HowTo',
|
|
103
|
+
name: copy.title,
|
|
104
|
+
step: copy.howTo.map((step) => ({ '@type': 'HowToStep', name: step.name, text: step.text })),
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
calculateProfile,
|
|
4
|
+
genotypeFrequency,
|
|
5
|
+
getPreset,
|
|
6
|
+
validateLocus,
|
|
7
|
+
} from './logic';
|
|
8
|
+
|
|
9
|
+
describe('DNA profile match probability logic', () => {
|
|
10
|
+
it('uses p squared for a homozygote', () => {
|
|
11
|
+
expect(genotypeFrequency({ name: 'D1', allele1: '10', allele2: '10', frequency1: 0.2, frequency2: 0.2 })).toBeCloseTo(0.04);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('uses 2pq for a heterozygote', () => {
|
|
15
|
+
expect(genotypeFrequency({ name: 'D1', allele1: '10', allele2: '11', frequency1: 0.2, frequency2: 0.3 })).toBe(0.12);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('multiplies independent loci in log space', () => {
|
|
19
|
+
const result = calculateProfile([
|
|
20
|
+
{ name: 'D1', allele1: '10', allele2: '10', frequency1: 0.2, frequency2: 0.2 },
|
|
21
|
+
{ name: 'D2', allele1: '12', allele2: '13', frequency1: 0.2, frequency2: 0.3 },
|
|
22
|
+
]);
|
|
23
|
+
expect(result.validLoci).toBe(2);
|
|
24
|
+
expect(result.combinedProbability).toBeCloseTo(0.0048);
|
|
25
|
+
expect(result.log10CombinedProbability).toBeCloseTo(Math.log10(0.0048));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('flags missing names and out of range frequencies', () => {
|
|
29
|
+
expect(validateLocus({ name: '', allele1: '10', allele2: '', frequency1: 0, frequency2: 1.2 })).toEqual([
|
|
30
|
+
'locusName',
|
|
31
|
+
'allele2',
|
|
32
|
+
'frequency1',
|
|
33
|
+
'frequency2',
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('rejects impossible frequency pairs and mismatched homozygote frequencies', () => {
|
|
38
|
+
expect(validateLocus({ name: 'D1', allele1: '10', allele2: '11', frequency1: 0.7, frequency2: 0.4 })).toContain('frequencySum');
|
|
39
|
+
expect(validateLocus({ name: 'D1', allele1: '10', allele2: '10', frequency1: 0.2, frequency2: 0.3 })).toContain('matchingFrequency');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('keeps invalid loci out of the combined product', () => {
|
|
43
|
+
const result = calculateProfile([
|
|
44
|
+
{ name: 'D1', allele1: '10', allele2: '11', frequency1: 0.2, frequency2: 0.3 },
|
|
45
|
+
{ name: '', allele1: '12', allele2: '13', frequency1: 0.2, frequency2: 0.3 },
|
|
46
|
+
]);
|
|
47
|
+
expect(result.validLoci).toBe(1);
|
|
48
|
+
expect(result.combinedProbability).toBeCloseTo(0.12);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns cloned preset data', () => {
|
|
52
|
+
const first = getPreset('balanced');
|
|
53
|
+
first.loci[0]!.name = 'Changed';
|
|
54
|
+
expect(getPreset('balanced').loci[0]!.name).toBe('D3S1358');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
export interface LocusInput {
|
|
2
|
+
name: string;
|
|
3
|
+
allele1: string;
|
|
4
|
+
allele2: string;
|
|
5
|
+
frequency1: number;
|
|
6
|
+
frequency2: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ProfilePreset {
|
|
10
|
+
id: string;
|
|
11
|
+
loci: LocusInput[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LocusResult extends LocusInput {
|
|
15
|
+
genotypeFrequency: number;
|
|
16
|
+
log10Frequency: number;
|
|
17
|
+
formula: 'p²' | '2pq';
|
|
18
|
+
errors: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ProfileMatchResult {
|
|
22
|
+
loci: LocusResult[];
|
|
23
|
+
validLoci: number;
|
|
24
|
+
totalLoci: number;
|
|
25
|
+
combinedProbability: number;
|
|
26
|
+
log10CombinedProbability: number;
|
|
27
|
+
errors: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const MIN_LOCI = 2;
|
|
31
|
+
export const MAX_LOCI = 8;
|
|
32
|
+
|
|
33
|
+
export const PROFILE_PRESETS: ProfilePreset[] = [
|
|
34
|
+
{
|
|
35
|
+
id: 'balanced',
|
|
36
|
+
loci: [
|
|
37
|
+
{ name: 'D3S1358', allele1: '15', allele2: '16', frequency1: 0.08, frequency2: 0.26 },
|
|
38
|
+
{ name: 'vWA', allele1: '16', allele2: '17', frequency1: 0.18, frequency2: 0.22 },
|
|
39
|
+
{ name: 'FGA', allele1: '21', allele2: '24', frequency1: 0.07, frequency2: 0.11 },
|
|
40
|
+
{ name: 'D8S1179', allele1: '12', allele2: '13', frequency1: 0.15, frequency2: 0.2 },
|
|
41
|
+
{ name: 'TH01', allele1: '6', allele2: '9.3', frequency1: 0.27, frequency2: 0.21 },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'homozygote',
|
|
46
|
+
loci: [
|
|
47
|
+
{ name: 'D3S1358', allele1: '15', allele2: '15', frequency1: 0.08, frequency2: 0.08 },
|
|
48
|
+
{ name: 'vWA', allele1: '16', allele2: '16', frequency1: 0.18, frequency2: 0.18 },
|
|
49
|
+
{ name: 'FGA', allele1: '21', allele2: '21', frequency1: 0.07, frequency2: 0.07 },
|
|
50
|
+
{ name: 'D8S1179', allele1: '12', allele2: '12', frequency1: 0.15, frequency2: 0.15 },
|
|
51
|
+
{ name: 'TH01', allele1: '6', allele2: '6', frequency1: 0.27, frequency2: 0.27 },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'classroom',
|
|
56
|
+
loci: [
|
|
57
|
+
{ name: 'Locus 1', allele1: 'A', allele2: 'B', frequency1: 0.5, frequency2: 0.3 },
|
|
58
|
+
{ name: 'Locus 2', allele1: 'B', allele2: 'C', frequency1: 0.3, frequency2: 0.2 },
|
|
59
|
+
{ name: 'Locus 3', allele1: 'A', allele2: 'A', frequency1: 0.5, frequency2: 0.5 },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
export const DEFAULT_PRESET_ID = 'balanced';
|
|
65
|
+
|
|
66
|
+
function sameAllele(first: string, second: string): boolean {
|
|
67
|
+
return first.trim().toLowerCase() === second.trim().toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validFrequency(value: number): boolean {
|
|
71
|
+
return Number.isFinite(value) && value > 0 && value <= 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hasValidFrequencyPair(locus: LocusInput): boolean {
|
|
75
|
+
return validFrequency(locus.frequency1) && validFrequency(locus.frequency2);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function exceedsFrequencyTotal(locus: LocusInput): boolean {
|
|
79
|
+
return hasValidFrequencyPair(locus) && locus.frequency1 + locus.frequency2 > 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasMismatchedHomozygoteFrequencies(locus: LocusInput): boolean {
|
|
83
|
+
return sameAllele(locus.allele1, locus.allele2) && Math.abs(locus.frequency1 - locus.frequency2) > 0.000001;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function validateLocus(locus: LocusInput): string[] {
|
|
87
|
+
const errors: string[] = [];
|
|
88
|
+
if (!locus.name.trim()) errors.push('locusName');
|
|
89
|
+
if (!locus.allele1.trim()) errors.push('allele1');
|
|
90
|
+
if (!locus.allele2.trim()) errors.push('allele2');
|
|
91
|
+
if (!validFrequency(locus.frequency1)) errors.push('frequency1');
|
|
92
|
+
if (!validFrequency(locus.frequency2)) errors.push('frequency2');
|
|
93
|
+
if (exceedsFrequencyTotal(locus)) errors.push('frequencySum');
|
|
94
|
+
if (hasMismatchedHomozygoteFrequencies(locus)) errors.push('matchingFrequency');
|
|
95
|
+
return errors;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function genotypeFrequency(locus: LocusInput): number {
|
|
99
|
+
if (sameAllele(locus.allele1, locus.allele2)) return locus.frequency1 ** 2;
|
|
100
|
+
return 2 * locus.frequency1 * locus.frequency2;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resultForLocus(locus: LocusInput): LocusResult {
|
|
104
|
+
const errors = validateLocus(locus);
|
|
105
|
+
const frequency = errors.length > 0 ? 0 : genotypeFrequency(locus);
|
|
106
|
+
return {
|
|
107
|
+
...locus,
|
|
108
|
+
genotypeFrequency: frequency,
|
|
109
|
+
log10Frequency: frequency > 0 ? Math.log10(frequency) : 0,
|
|
110
|
+
formula: sameAllele(locus.allele1, locus.allele2) ? 'p²' : '2pq',
|
|
111
|
+
errors,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function calculateProfile(loci: LocusInput[]): ProfileMatchResult {
|
|
116
|
+
const results = loci.map(resultForLocus);
|
|
117
|
+
const valid = results.filter((locus) => locus.errors.length === 0);
|
|
118
|
+
const errors = results.flatMap((locus) => locus.errors);
|
|
119
|
+
const log10CombinedProbability = valid.length > 0
|
|
120
|
+
? valid.reduce((sum, locus) => sum + locus.log10Frequency, 0)
|
|
121
|
+
: 0;
|
|
122
|
+
const combinedProbability = valid.length > 0 ? 10 ** log10CombinedProbability : 0;
|
|
123
|
+
return {
|
|
124
|
+
loci: results,
|
|
125
|
+
validLoci: valid.length,
|
|
126
|
+
totalLoci: results.length,
|
|
127
|
+
combinedProbability,
|
|
128
|
+
log10CombinedProbability,
|
|
129
|
+
errors,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getPreset(id: string): ProfilePreset {
|
|
134
|
+
const preset = PROFILE_PRESETS.find((item) => item.id === id) ?? PROFILE_PRESETS[0]!;
|
|
135
|
+
return {
|
|
136
|
+
id: preset.id,
|
|
137
|
+
loci: preset.loci.map((locus) => ({ ...locus })),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { dnaProfileMatchProbabilityLab } 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 dnaProfileMatchProbabilityLab.i18n[locale]?.();
|
|
12
|
+
if (!content) return null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { LocusInput } from './logic';
|
|
2
|
+
|
|
3
|
+
const storageKey = 'dna-profile-match-probability-lab:state';
|
|
4
|
+
|
|
5
|
+
export interface SavedProfileState {
|
|
6
|
+
loci: LocusInput[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function loadProfileState(fallback: SavedProfileState): SavedProfileState {
|
|
10
|
+
try {
|
|
11
|
+
const value = localStorage.getItem(storageKey);
|
|
12
|
+
if (!value) return fallback;
|
|
13
|
+
const saved = JSON.parse(value) as SavedProfileState;
|
|
14
|
+
if (!Array.isArray(saved.loci)) return fallback;
|
|
15
|
+
return saved;
|
|
16
|
+
} catch {
|
|
17
|
+
return fallback;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function saveProfileState(state: SavedProfileState): void {
|
|
22
|
+
try {
|
|
23
|
+
localStorage.setItem(storageKey, JSON.stringify(state));
|
|
24
|
+
} catch {
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -188,7 +188,7 @@ const { ui } = Astro.props;
|
|
|
188
188
|
</div>
|
|
189
189
|
</section>
|
|
190
190
|
|
|
191
|
-
<script id="fire-origin-ui" type="application/json" set:html={JSON.stringify(ui)}></script>
|
|
191
|
+
<script is:inline id="fire-origin-ui" type="application/json" set:html={JSON.stringify(ui)}></script>
|
|
192
192
|
<script>
|
|
193
193
|
import { initFirePatternOriginAnalyzer } from './view';
|
|
194
194
|
|
|
@@ -207,8 +207,12 @@ export const content: ToolLocaleContent = {
|
|
|
207
207
|
title: '不要将收敛变成确定性',
|
|
208
208
|
html: '紧密的交点簇仅在观察有效时才有用。实际调查必须考虑通风、闪燃、燃料包、电气系统、电器证据、目击者陈述、灭火和现场扰动。',
|
|
209
209
|
},
|
|
210
|
+
{ type: 'paragraph', html: '记录每个向量的来源表面、观察方向和置信度,并保留不纳入模型的观察结果。改变单个向量后重新计算,可以帮助判断起源区域是否稳定;如果少量测量就能大幅移动交点,应将结果视为不确定信号,而不是精确定位。' },
|
|
211
|
+
{ type: 'paragraph', html: '分析记录还应说明哪些现场条件没有进入模型,例如通风、燃料分布、灭火水流和结构变化。把几何结果与照片、材料痕迹及调查记录相互核对,才能判断一个交点簇是否具有实际意义。' },
|
|
210
212
|
|
|
211
|
-
{ type: 'paragraph', html: "结果可用于规划和比较方案,但不能替代官方计算或专业建议。" },
|
|
213
|
+
{ type: 'paragraph', html: "结果可用于规划和比较方案,但不能替代官方计算或专业建议。" },
|
|
214
|
+
{ type: 'paragraph', html: '记录每个向量的观察表面、方向、置信度以及未纳入模型的证据。把交点结果与通风、燃料分布、灭火影响、现场照片和材料痕迹进行核对;如果少量测量就能明显移动起源区域,应报告不确定性,而不是把几何中心当作精确定位。' },
|
|
215
|
+
{ type: 'paragraph', html: '在复核时,先检查每个向量是否真正代表火灾传播方向,再比较不同向量组合下的起源范围。模型不能代替现场勘验,也不能单独确定起火原因;它最适合用于教学、记录假设和测试测量变化带来的影响。' },],
|
|
212
216
|
faq,
|
|
213
217
|
bibliography,
|
|
214
218
|
howTo,
|
|
@@ -63,13 +63,20 @@ function usableVectors(vectors: FireVector[]): FireVector[] {
|
|
|
63
63
|
return vectors.filter((vector) => distance(vector.start, vector.end) >= 4);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function isUsableIntersection(point: FirePoint | null): point is FirePoint {
|
|
67
|
+
return Boolean(point && point.x >= -20 && point.x <= 120 && point.y >= -20 && point.y <= 120);
|
|
68
|
+
}
|
|
69
|
+
|
|
66
70
|
function collectIntersections(vectors: FireVector[]): FirePoint[] {
|
|
67
71
|
const intersections: FirePoint[] = [];
|
|
68
72
|
|
|
69
73
|
for (let i = 0; i < vectors.length; i += 1) {
|
|
70
74
|
for (let j = i + 1; j < vectors.length; j += 1) {
|
|
71
|
-
const
|
|
72
|
-
|
|
75
|
+
const first = vectors[i];
|
|
76
|
+
const second = vectors[j];
|
|
77
|
+
if (!first || !second) continue;
|
|
78
|
+
const point = lineIntersection(first, second);
|
|
79
|
+
if (!isUsableIntersection(point)) continue;
|
|
73
80
|
intersections.push({
|
|
74
81
|
x: clamp(point.x, 0, 100),
|
|
75
82
|
y: clamp(point.y, 0, 100),
|
|
@@ -17,6 +17,10 @@ import {
|
|
|
17
17
|
updatePointerOverlay,
|
|
18
18
|
} from './view-model';
|
|
19
19
|
|
|
20
|
+
function uiText(ui: Record<string, string>, key: string, fallback = ''): string {
|
|
21
|
+
return ui[key] ?? fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
20
24
|
export function bindLayerToggles(refs: ViewRefs, estimator: FireOriginEstimator, state: ViewState): void {
|
|
21
25
|
refs.root.querySelectorAll<HTMLButtonElement>('[data-fire-layer-toggle]').forEach((button) => {
|
|
22
26
|
button.addEventListener('click', () => {
|
|
@@ -41,7 +45,7 @@ export function bindRadialMenu(refs: ViewRefs, state: ViewState, ui: Record<stri
|
|
|
41
45
|
confidence: 78,
|
|
42
46
|
};
|
|
43
47
|
closeRadialMenu(refs);
|
|
44
|
-
refs.modeLabel.textContent = `${ui
|
|
48
|
+
refs.modeLabel.textContent = `${uiText(ui, 'refiningVector')}: ${uiText(ui, state.currentPattern, state.currentPattern)}`;
|
|
45
49
|
});
|
|
46
50
|
});
|
|
47
51
|
}
|
|
@@ -90,7 +94,7 @@ export function bindShortcuts(refs: ViewRefs, estimator: FireOriginEstimator, st
|
|
|
90
94
|
state.draftVector = null;
|
|
91
95
|
state.pendingStart = null;
|
|
92
96
|
closeRadialMenu(refs);
|
|
93
|
-
refs.modeLabel.textContent = ui
|
|
97
|
+
refs.modeLabel.textContent = uiText(ui, 'awaitingVector');
|
|
94
98
|
return;
|
|
95
99
|
}
|
|
96
100
|
const pattern = shortcutMap[event.key.toLowerCase()];
|
|
@@ -132,7 +136,7 @@ export function bindAuxiliaryActions(refs: ViewRefs, estimator: FireOriginEstima
|
|
|
132
136
|
const target = event.target as Node;
|
|
133
137
|
if (!refs.radialMenu.hidden && !refs.radialMenu.contains(target) && !refs.svg.contains(target)) {
|
|
134
138
|
closeRadialMenu(refs);
|
|
135
|
-
if (!state.draftVector) refs.modeLabel.textContent = ui
|
|
139
|
+
if (!state.draftVector) refs.modeLabel.textContent = uiText(ui, 'awaitingVector');
|
|
136
140
|
}
|
|
137
141
|
});
|
|
138
142
|
refs.exportButton.addEventListener('click', () => {
|
|
@@ -143,7 +147,7 @@ export function bindAuxiliaryActions(refs: ViewRefs, estimator: FireOriginEstima
|
|
|
143
147
|
state.draftVector = null;
|
|
144
148
|
state.pendingStart = null;
|
|
145
149
|
animateToState(refs, estimator, state);
|
|
146
|
-
refs.modeLabel.textContent = ui
|
|
150
|
+
refs.modeLabel.textContent = uiText(ui, 'awaitingVector');
|
|
147
151
|
});
|
|
148
152
|
}
|
|
149
153
|
|