@jjlmoya/utils-forensic-science 1.8.0 → 1.10.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 -2
- package/src/category/index.ts +6 -1
- package/src/entries.ts +8 -1
- package/src/tests/diacritics_density.test.ts +8 -8
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/seo_parity.test.ts +60 -0
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tests/translation_copy.test.ts +124 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/zh.ts +2 -1
- package/src/tool/forensic-blood-test-simulator/i18n/fr.ts +13 -13
- package/src/tool/forensic-fiber-comparison-microscope/i18n/de.ts +1 -5
- package/src/tool/forensic-fiber-comparison-microscope/i18n/id.ts +0 -4
- package/src/tool/forensic-fiber-comparison-microscope/i18n/ja.ts +0 -4
- package/src/tool/forensic-fiber-comparison-microscope/i18n/pl.ts +0 -4
- package/src/tool/forensic-fiber-comparison-microscope/i18n/pt.ts +0 -4
- package/src/tool/forensic-fiber-comparison-microscope/i18n/ru.ts +1 -5
- package/src/tool/forensic-fiber-comparison-microscope/i18n/sv.ts +1 -5
- package/src/tool/forensic-fiber-comparison-microscope/i18n/zh.ts +0 -4
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/de.ts +1 -10
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/fr.ts +0 -9
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/id.ts +1 -10
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/it.ts +1 -10
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/ja.ts +3 -1
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/ko.ts +3 -1
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/nl.ts +1 -10
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/pl.ts +1 -10
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/pt.ts +1 -10
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/ru.ts +1 -10
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/sv.ts +1 -10
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/tr.ts +1 -10
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/zh.ts +3 -1
- package/src/tool/forensic-image-authenticity-analyzer/i18n/id.ts +20 -1
- package/src/tool/forensic-image-authenticity-analyzer/i18n/ja.ts +24 -1
- package/src/tool/forensic-image-authenticity-analyzer/i18n/ko.ts +24 -1
- package/src/tool/forensic-image-authenticity-analyzer/i18n/nl.ts +20 -1
- package/src/tool/forensic-image-authenticity-analyzer/i18n/pl.ts +20 -1
- package/src/tool/forensic-image-authenticity-analyzer/i18n/ru.ts +20 -1
- package/src/tool/forensic-image-authenticity-analyzer/i18n/sv.ts +20 -1
- package/src/tool/forensic-image-authenticity-analyzer/i18n/tr.ts +20 -1
- package/src/tool/forensic-image-authenticity-analyzer/i18n/zh.ts +24 -1
- package/src/tool/forensic-tlc-ink-simulator/i18n/ja.ts +3 -1
- package/src/tool/forensic-tlc-ink-simulator/i18n/ko.ts +3 -1
- package/src/tool/forensic-tlc-ink-simulator/i18n/zh.ts +3 -1
- package/src/tool/time-of-death-algor-mortis-calculator/bibliography.astro +14 -0
- package/src/tool/time-of-death-algor-mortis-calculator/bibliography.ts +7 -0
- package/src/tool/time-of-death-algor-mortis-calculator/component.astro +133 -0
- package/src/tool/time-of-death-algor-mortis-calculator/controller.ts +254 -0
- package/src/tool/time-of-death-algor-mortis-calculator/dom-views.ts +144 -0
- package/src/tool/time-of-death-algor-mortis-calculator/entry.ts +29 -0
- package/src/tool/time-of-death-algor-mortis-calculator/evaluator.ts +34 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/de.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/en.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/es.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/fr.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/id.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/it.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/ja.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/ko.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/nl.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/pl.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/pt.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/ru.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/sv.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/tr.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/zh.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/index.ts +11 -0
- package/src/tool/time-of-death-algor-mortis-calculator/logic.test.ts +77 -0
- package/src/tool/time-of-death-algor-mortis-calculator/logic.ts +177 -0
- package/src/tool/time-of-death-algor-mortis-calculator/seo.astro +15 -0
- package/src/tool/time-of-death-algor-mortis-calculator/storage.ts +21 -0
- package/src/tool/time-of-death-algor-mortis-calculator/time-of-death-algor-mortis-calculator.css +596 -0
- package/src/tool/time-of-death-algor-mortis-calculator/ui.ts +60 -0
- package/src/tool/widmark-alcohol-simulator/i18n/de.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/es.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/fr.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/id.ts +4 -1
- 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/ko.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/nl.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/pl.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/pt.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/ru.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/sv.ts +4 -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 +3 -1
|
@@ -196,7 +196,9 @@ export const content: ToolLocaleContent = {
|
|
|
196
196
|
title: '切勿夸大墨水色谱分析的论证作用',
|
|
197
197
|
html: '浏览器模拟无法检查纸张、墨水年龄、提取化学过程、薄层板质量、污染或进行仪器确证。使用它来学习测量逻辑和解释限制,而在处理实际案件证据时,请完全依赖经过验证的专业法医文书检验鉴定方法。',
|
|
198
198
|
},
|
|
199
|
-
|
|
199
|
+
|
|
200
|
+
{ type: 'paragraph', html: "使用前请结合计算器显示的假设一起解读结果。" },
|
|
201
|
+
{ type: 'paragraph', html: "请检查输入值、单位、四舍五入方式、日期和司法辖区,因为每项都可能改变估算。" },],
|
|
200
202
|
faq,
|
|
201
203
|
bibliography,
|
|
202
204
|
howTo,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { timeOfDeathAlgorMortisCalculator } 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 timeOfDeathAlgorMortisCalculator.i18n[locale]?.();
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
{content && <SharedBibliography links={content.bibliography} />}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{ name: 'Henssge, C. - Death time estimation in case work by the means of a nomogram', url: 'https://pubmed.ncbi.nlm.nih.gov/3192144/' },
|
|
5
|
+
{ name: 'Henssge, C. & Madea, B. - Estimation of the time since death in the early post-mortem period', url: 'https://pubmed.ncbi.nlm.nih.gov/15364387/' },
|
|
6
|
+
{ name: 'National Center for Biotechnology Information - Postmortem Interval and Algor Mortis Review', url: 'https://www.ncbi.nlm.nih.gov/books/NBK554464/' }
|
|
7
|
+
];
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { KnownLocale } from '../../types';
|
|
3
|
+
import type { TimeOfDeathAlgorMortisUI } from './ui';
|
|
4
|
+
import { timeOfDeathAlgorMortisCalculator } from './entry';
|
|
5
|
+
import './time-of-death-algor-mortis-calculator.css';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
ui?: TimeOfDeathAlgorMortisUI;
|
|
9
|
+
locale?: KnownLocale;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { ui: propUi, locale = 'en' } = Astro.props;
|
|
13
|
+
let ui = propUi;
|
|
14
|
+
if (!ui) {
|
|
15
|
+
const content = await timeOfDeathAlgorMortisCalculator.i18n[locale]?.();
|
|
16
|
+
ui = content?.ui;
|
|
17
|
+
}
|
|
18
|
+
if (!ui) return null;
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
<div class="sc-algor-cockpit" id="algor-cockpit-root" data-i18n={JSON.stringify(ui)}>
|
|
22
|
+
<div class="sc-cockpit-sidebar">
|
|
23
|
+
<div>
|
|
24
|
+
<div class="sc-header-row">
|
|
25
|
+
<div class="sc-panel-title">{ui.presetsHeader}</div>
|
|
26
|
+
<div class="sc-unit-toggle">
|
|
27
|
+
<button type="button" class="sc-unit-btn active" data-unit="metric">{ui.unitMetricLabel || 'Metric'}</button>
|
|
28
|
+
<button type="button" class="sc-unit-btn" data-unit="imperial">{ui.unitImperialLabel || 'Imperial'}</button>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="sc-presets-wrap">
|
|
32
|
+
<button type="button" class="sc-preset-chip active" data-preset="dressedIndoor">{ui.presetDressedIndoor}</button>
|
|
33
|
+
<button type="button" class="sc-preset-chip" data-preset="nakedCalm">{ui.presetNakedCalm}</button>
|
|
34
|
+
<button type="button" class="sc-preset-chip" data-preset="winterOutdoor">{ui.presetWinterOutdoor}</button>
|
|
35
|
+
<button type="button" class="sc-preset-chip" data-preset="submergedWater">{ui.presetSubmergedWater}</button>
|
|
36
|
+
<button type="button" class="sc-preset-chip" data-preset="heavyDuvet">{ui.presetHeavyDuvet}</button>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div class="sc-input-group">
|
|
41
|
+
<div class="sc-input-label-row">
|
|
42
|
+
<label class="sc-label" for="slider-rectalTemp">{ui.rectalTempLabel}</label>
|
|
43
|
+
<span class="sc-label" id="label-unit-temp-1">{ui.celsiusUnit}</span>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="sc-tactile-row">
|
|
46
|
+
<input type="range" id="slider-rectalTemp" class="sc-slider" data-key="rectalTemp" min="10" max="40" step="0.1" value="30.5" />
|
|
47
|
+
<input type="number" id="num-rectalTemp" class="sc-num-input" data-key="rectalTemp" aria-label={ui.rectalTempLabel} min="10" max="40" step="0.1" value="30.5" />
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<div class="sc-input-group">
|
|
52
|
+
<div class="sc-input-label-row">
|
|
53
|
+
<label class="sc-label" for="slider-ambientTemp">{ui.ambientTempLabel}</label>
|
|
54
|
+
<span class="sc-label" id="label-unit-temp-2">{ui.celsiusUnit}</span>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="sc-tactile-row">
|
|
57
|
+
<input type="range" id="slider-ambientTemp" class="sc-slider" data-key="ambientTemp" min="-10" max="38" step="0.1" value="19.5" />
|
|
58
|
+
<input type="number" id="num-ambientTemp" class="sc-num-input" data-key="ambientTemp" aria-label={ui.ambientTempLabel} min="-10" max="38" step="0.1" value="19.5" />
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div class="sc-input-group">
|
|
63
|
+
<div class="sc-input-label-row">
|
|
64
|
+
<label class="sc-label" for="slider-bodyWeight">{ui.bodyWeightLabel}</label>
|
|
65
|
+
<span class="sc-label" id="label-unit-weight">{ui.kgUnit}</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="sc-tactile-row">
|
|
68
|
+
<input type="range" id="slider-bodyWeight" class="sc-slider" data-key="bodyWeight" min="20" max="180" step="0.5" value="75" />
|
|
69
|
+
<input type="number" id="num-bodyWeight" class="sc-num-input" data-key="bodyWeight" aria-label={ui.bodyWeightLabel} min="20" max="180" step="0.5" value="75" />
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div class="sc-input-group">
|
|
74
|
+
<label class="sc-label" for="factor-select-trigger">{ui.factorLabel}</label>
|
|
75
|
+
<div class="sc-custom-select" id="factor-custom-select">
|
|
76
|
+
<button type="button" class="sc-select-trigger" id="factor-select-trigger" aria-haspopup="listbox">
|
|
77
|
+
<span class="sc-select-val">{ui.factorLightClothes} (1.1)</span>
|
|
78
|
+
<svg class="sc-select-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
79
|
+
<polyline points="6 9 12 15 18 9"></polyline>
|
|
80
|
+
</svg>
|
|
81
|
+
</button>
|
|
82
|
+
<div class="sc-select-dropdown" id="factor-select-dropdown" role="listbox">
|
|
83
|
+
<div class="sc-select-option" data-factor-val="1.0">{ui.factorNaked} (1.0)</div>
|
|
84
|
+
<div class="sc-select-option" data-factor-val="1.1">{ui.factorLightClothes} (1.1)</div>
|
|
85
|
+
<div class="sc-select-option" data-factor-val="1.2">{ui.factorStandardClothes} (1.2)</div>
|
|
86
|
+
<div class="sc-select-option" data-factor-val="1.4">{ui.factorHeavyWinter} (1.4)</div>
|
|
87
|
+
<div class="sc-select-option" data-factor-val="1.3">{ui.factorLightBlanket} (1.3)</div>
|
|
88
|
+
<div class="sc-select-option" data-factor-val="1.8">{ui.factorHeavyDuvet} (1.8)</div>
|
|
89
|
+
<div class="sc-select-option" data-factor-val="0.5">{ui.factorStillWater} (0.5)</div>
|
|
90
|
+
<div class="sc-select-option" data-factor-val="0.35">{ui.factorFlowingWater} (0.35)</div>
|
|
91
|
+
<div class="sc-select-option" data-factor-val="0.7">{ui.factorWetClothing} (0.7)</div>
|
|
92
|
+
<div class="sc-select-option" data-factor-val="0.75">{ui.factorMovingAir} (0.75)</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div class="sc-input-group">
|
|
98
|
+
<label class="sc-label" for="input-time">{ui.measurementTimeLabel}</label>
|
|
99
|
+
<div class="sc-actions-row">
|
|
100
|
+
<div class="sc-time-wrap">
|
|
101
|
+
<input type="time" id="input-time" class="sc-time-input" value="14:00" />
|
|
102
|
+
<svg class="sc-time-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
103
|
+
<circle cx="12" cy="12" r="10"></circle>
|
|
104
|
+
<polyline points="12 6 12 12 16 14"></polyline>
|
|
105
|
+
</svg>
|
|
106
|
+
</div>
|
|
107
|
+
<button type="button" id="btn-now" class="sc-btn sc-btn-primary">{ui.nowBtn}</button>
|
|
108
|
+
<button type="button" id="btn-reset" class="sc-btn">{ui.resetBtn}</button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div class="sc-warning-card">
|
|
113
|
+
<div class="sc-warning-title">{ui.disclaimerTitle || ui.inputsHeader}</div>
|
|
114
|
+
<div class="sc-warning-text">{ui.disclaimer}</div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div class="sc-cockpit-stage">
|
|
119
|
+
<div id="telemetry-stage"></div>
|
|
120
|
+
<div class="sc-chart-stage">
|
|
121
|
+
<div class="sc-panel-title">{ui.chartHeader}</div>
|
|
122
|
+
<svg id="nomogram-svg" class="sc-nomogram-svg"></svg>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<script>
|
|
128
|
+
import { AlgorMortisController } from './controller';
|
|
129
|
+
const root = document.getElementById('algor-cockpit-root');
|
|
130
|
+
if (root) {
|
|
131
|
+
new AlgorMortisController(root);
|
|
132
|
+
}
|
|
133
|
+
</script>
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { TimeOfDeathAlgorMortisUI } from './ui';
|
|
2
|
+
import { calculateAlgorMortis, celsiusToFahrenheit, fahrenheitToCelsius, kgToLb, lbToKg, type AlgorMortisInputs } from './logic';
|
|
3
|
+
import { loadSavedState, saveState } from './storage';
|
|
4
|
+
import { renderTelemetry, renderSvgNomogram } from './dom-views';
|
|
5
|
+
|
|
6
|
+
interface SliderRange {
|
|
7
|
+
min: number;
|
|
8
|
+
max: number;
|
|
9
|
+
step: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface PresetDef {
|
|
13
|
+
nameKey: string;
|
|
14
|
+
rectal: number;
|
|
15
|
+
ambient: number;
|
|
16
|
+
weight: number;
|
|
17
|
+
factor: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PRESETS: Record<string, PresetDef> = {
|
|
21
|
+
dressedIndoor: { nameKey: 'presetDressedIndoor', rectal: 30.5, ambient: 19.5, weight: 75, factor: 1.1 },
|
|
22
|
+
nakedCalm: { nameKey: 'presetNakedCalm', rectal: 28.5, ambient: 20.0, weight: 70, factor: 1.0 },
|
|
23
|
+
winterOutdoor: { nameKey: 'presetWinterOutdoor', rectal: 22.0, ambient: 4.0, weight: 80, factor: 1.4 },
|
|
24
|
+
submergedWater: { nameKey: 'presetSubmergedWater', rectal: 24.0, ambient: 12.0, weight: 70, factor: 0.5 },
|
|
25
|
+
heavyDuvet: { nameKey: 'presetHeavyDuvet', rectal: 33.0, ambient: 18.0, weight: 85, factor: 1.8 }
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export class AlgorMortisController {
|
|
29
|
+
private container: HTMLElement;
|
|
30
|
+
private ui: TimeOfDeathAlgorMortisUI;
|
|
31
|
+
private inputs: AlgorMortisInputs;
|
|
32
|
+
|
|
33
|
+
constructor(container: HTMLElement) {
|
|
34
|
+
this.container = container;
|
|
35
|
+
this.ui = JSON.parse(container.dataset.i18n || '{}');
|
|
36
|
+
this.inputs = this.getInitialInputs();
|
|
37
|
+
this.updateUnitUI();
|
|
38
|
+
this.syncAllFormControls();
|
|
39
|
+
this.bindUnitToggle();
|
|
40
|
+
this.bindInputs();
|
|
41
|
+
this.bindPresets();
|
|
42
|
+
this.bindCustomSelect();
|
|
43
|
+
this.bindActionButtons();
|
|
44
|
+
this.update();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private getInitialInputs(): AlgorMortisInputs {
|
|
48
|
+
const now = new Date();
|
|
49
|
+
const hh = String(now.getHours()).padStart(2, '0');
|
|
50
|
+
const mm = String(now.getMinutes()).padStart(2, '0');
|
|
51
|
+
const defaults: AlgorMortisInputs = {
|
|
52
|
+
rectalTemp: 30.5,
|
|
53
|
+
ambientTemp: 19.5,
|
|
54
|
+
bodyWeight: 75,
|
|
55
|
+
correctionFactor: 1.1,
|
|
56
|
+
measurementTime: `${hh}:${mm}`,
|
|
57
|
+
unitSystem: 'metric'
|
|
58
|
+
};
|
|
59
|
+
return Object.assign(defaults, loadSavedState() || {});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private bindUnitToggle(): void {
|
|
63
|
+
this.container.querySelectorAll<HTMLButtonElement>('.sc-unit-btn').forEach((btn) => {
|
|
64
|
+
btn.addEventListener('click', () => {
|
|
65
|
+
const target = btn.dataset.unit as 'metric' | 'imperial';
|
|
66
|
+
if (target !== this.inputs.unitSystem) this.switchUnitSystem(target);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private switchUnitSystem(targetUnit: 'metric' | 'imperial'): void {
|
|
72
|
+
const isTargetImp = targetUnit === 'imperial';
|
|
73
|
+
this.inputs.rectalTemp = isTargetImp ? celsiusToFahrenheit(this.inputs.rectalTemp) : fahrenheitToCelsius(this.inputs.rectalTemp);
|
|
74
|
+
this.inputs.ambientTemp = isTargetImp ? celsiusToFahrenheit(this.inputs.ambientTemp) : fahrenheitToCelsius(this.inputs.ambientTemp);
|
|
75
|
+
this.inputs.bodyWeight = isTargetImp ? kgToLb(this.inputs.bodyWeight) : lbToKg(this.inputs.bodyWeight);
|
|
76
|
+
this.inputs.unitSystem = targetUnit;
|
|
77
|
+
this.updateUnitUI();
|
|
78
|
+
this.syncAllFormControls();
|
|
79
|
+
this.update();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private updateUnitUI(): void {
|
|
83
|
+
const isImp = this.inputs.unitSystem === 'imperial';
|
|
84
|
+
this.container.querySelectorAll<HTMLButtonElement>('.sc-unit-btn').forEach((b) => {
|
|
85
|
+
b.classList.toggle('active', b.dataset.unit === this.inputs.unitSystem);
|
|
86
|
+
});
|
|
87
|
+
this.setText('#label-unit-temp-1', isImp ? this.ui.fahrenheitUnit : this.ui.celsiusUnit);
|
|
88
|
+
this.setText('#label-unit-temp-2', isImp ? this.ui.fahrenheitUnit : this.ui.celsiusUnit);
|
|
89
|
+
this.setText('#label-unit-weight', isImp ? this.ui.lbUnit : this.ui.kgUnit);
|
|
90
|
+
this.setSliderBounds('#slider-rectalTemp', '#num-rectalTemp', isImp ? { min: 50, max: 104, step: 0.2 } : { min: 10, max: 40, step: 0.1 });
|
|
91
|
+
this.setSliderBounds('#slider-ambientTemp', '#num-ambientTemp', isImp ? { min: 14, max: 100, step: 0.2 } : { min: -10, max: 38, step: 0.1 });
|
|
92
|
+
this.setSliderBounds('#slider-bodyWeight', '#num-bodyWeight', isImp ? { min: 44, max: 400, step: 1 } : { min: 20, max: 180, step: 0.5 });
|
|
93
|
+
this.updatePresetLabels();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private updatePresetLabels(): void {
|
|
97
|
+
const isImp = this.inputs.unitSystem === 'imperial';
|
|
98
|
+
this.container.querySelectorAll<HTMLButtonElement>('.sc-preset-chip').forEach((chip) => {
|
|
99
|
+
const p = PRESETS[chip.dataset.preset || ''];
|
|
100
|
+
if (!p) return;
|
|
101
|
+
const rawText = this.ui[p.nameKey];
|
|
102
|
+
const baseName = rawText ? rawText.split('(')[0]?.trim() : p.nameKey;
|
|
103
|
+
const tempDisplay = isImp ? `${celsiusToFahrenheit(p.ambient)}°F` : `${p.ambient}°C`;
|
|
104
|
+
chip.textContent = `${baseName} (${tempDisplay})`;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private setSliderBounds(sId: string, nId: string, range: SliderRange): void {
|
|
109
|
+
const s = this.container.querySelector<HTMLInputElement>(sId);
|
|
110
|
+
const n = this.container.querySelector<HTMLInputElement>(nId);
|
|
111
|
+
[s, n].forEach((el) => {
|
|
112
|
+
if (el) {
|
|
113
|
+
el.min = String(range.min);
|
|
114
|
+
el.max = String(range.max);
|
|
115
|
+
el.step = String(range.step);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private setText(selector: string, val: string): void {
|
|
121
|
+
const el = this.container.querySelector<HTMLElement>(selector);
|
|
122
|
+
if (el) el.textContent = val;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private bindInputs(): void {
|
|
126
|
+
this.container.querySelectorAll<HTMLInputElement>('.sc-slider').forEach((slider) => {
|
|
127
|
+
slider.addEventListener('input', () => this.handleSliderSync(slider));
|
|
128
|
+
});
|
|
129
|
+
this.container.querySelectorAll<HTMLInputElement>('.sc-num-input').forEach((input) => {
|
|
130
|
+
input.addEventListener('input', () => this.handleNumSync(input));
|
|
131
|
+
});
|
|
132
|
+
const timeInput = this.container.querySelector<HTMLInputElement>('#input-time');
|
|
133
|
+
timeInput?.addEventListener('input', () => {
|
|
134
|
+
this.inputs.measurementTime = timeInput.value;
|
|
135
|
+
this.update();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private handleSliderSync(slider: HTMLInputElement): void {
|
|
140
|
+
const key = slider.dataset.key as keyof AlgorMortisInputs;
|
|
141
|
+
if (!key) return;
|
|
142
|
+
const val = parseFloat(slider.value) || 0;
|
|
143
|
+
(this.inputs[key] as number) = val;
|
|
144
|
+
const numInput = this.container.querySelector<HTMLInputElement>(`#num-${key}`);
|
|
145
|
+
if (numInput) numInput.value = String(val);
|
|
146
|
+
this.update();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private handleNumSync(input: HTMLInputElement): void {
|
|
150
|
+
const key = input.dataset.key as keyof AlgorMortisInputs;
|
|
151
|
+
if (!key) return;
|
|
152
|
+
const val = parseFloat(input.value) || 0;
|
|
153
|
+
(this.inputs[key] as number) = val;
|
|
154
|
+
const slider = this.container.querySelector<HTMLInputElement>(`#slider-${key}`);
|
|
155
|
+
if (slider) slider.value = String(val);
|
|
156
|
+
this.update();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private bindPresets(): void {
|
|
160
|
+
this.container.querySelectorAll<HTMLButtonElement>('.sc-preset-chip').forEach((chip) => {
|
|
161
|
+
chip.addEventListener('click', () => {
|
|
162
|
+
const presetKey = chip.dataset.preset;
|
|
163
|
+
if (!presetKey || !PRESETS[presetKey]) return;
|
|
164
|
+
const p = PRESETS[presetKey];
|
|
165
|
+
const isImp = this.inputs.unitSystem === 'imperial';
|
|
166
|
+
this.inputs.rectalTemp = isImp ? celsiusToFahrenheit(p.rectal) : p.rectal;
|
|
167
|
+
this.inputs.ambientTemp = isImp ? celsiusToFahrenheit(p.ambient) : p.ambient;
|
|
168
|
+
this.inputs.bodyWeight = isImp ? kgToLb(p.weight) : p.weight;
|
|
169
|
+
this.inputs.correctionFactor = p.factor;
|
|
170
|
+
this.syncAllFormControls();
|
|
171
|
+
this.updatePresetChips(presetKey);
|
|
172
|
+
this.update();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private updatePresetChips(activeKey?: string): void {
|
|
178
|
+
this.container.querySelectorAll<HTMLButtonElement>('.sc-preset-chip').forEach((chip) => {
|
|
179
|
+
chip.classList.toggle('active', chip.dataset.preset === activeKey);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private bindCustomSelect(): void {
|
|
184
|
+
const trigger = this.container.querySelector<HTMLButtonElement>('#factor-select-trigger');
|
|
185
|
+
const dropdown = this.container.querySelector<HTMLElement>('#factor-select-dropdown');
|
|
186
|
+
trigger?.addEventListener('click', (e) => {
|
|
187
|
+
e.stopPropagation();
|
|
188
|
+
dropdown?.classList.toggle('open');
|
|
189
|
+
});
|
|
190
|
+
document.addEventListener('click', () => dropdown?.classList.remove('open'));
|
|
191
|
+
this.container.querySelectorAll<HTMLElement>(`[data-factor-val]`).forEach((opt) => {
|
|
192
|
+
opt.addEventListener('click', () => {
|
|
193
|
+
this.inputs.correctionFactor = parseFloat(opt.dataset.factorVal || '1.1');
|
|
194
|
+
const span = trigger?.querySelector<HTMLElement>('.sc-select-val');
|
|
195
|
+
if (span) span.textContent = opt.textContent || '';
|
|
196
|
+
dropdown?.classList.remove('open');
|
|
197
|
+
this.update();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private bindActionButtons(): void {
|
|
203
|
+
this.container.querySelector<HTMLButtonElement>('#btn-now')?.addEventListener('click', () => {
|
|
204
|
+
const now = new Date();
|
|
205
|
+
const hh = String(now.getHours()).padStart(2, '0');
|
|
206
|
+
const mm = String(now.getMinutes()).padStart(2, '0');
|
|
207
|
+
this.inputs.measurementTime = `${hh}:${mm}`;
|
|
208
|
+
const timeInput = this.container.querySelector<HTMLInputElement>('#input-time');
|
|
209
|
+
if (timeInput) timeInput.value = this.inputs.measurementTime;
|
|
210
|
+
this.update();
|
|
211
|
+
});
|
|
212
|
+
this.container.querySelector<HTMLButtonElement>('#btn-reset')?.addEventListener('click', () => {
|
|
213
|
+
const isImp = this.inputs.unitSystem === 'imperial';
|
|
214
|
+
this.inputs.rectalTemp = isImp ? 86.9 : 30.5;
|
|
215
|
+
this.inputs.ambientTemp = isImp ? 67.1 : 19.5;
|
|
216
|
+
this.inputs.bodyWeight = isImp ? 165 : 75;
|
|
217
|
+
this.inputs.correctionFactor = 1.1;
|
|
218
|
+
this.inputs.measurementTime = '14:00';
|
|
219
|
+
this.syncAllFormControls();
|
|
220
|
+
this.updatePresetChips();
|
|
221
|
+
this.update();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private syncAllFormControls(): void {
|
|
226
|
+
const syncPair = (key: string, val: number) => {
|
|
227
|
+
const slider = this.container.querySelector<HTMLInputElement>(`#slider-${key}`);
|
|
228
|
+
const num = this.container.querySelector<HTMLInputElement>(`#num-${key}`);
|
|
229
|
+
if (slider) slider.value = String(val);
|
|
230
|
+
if (num) num.value = String(val);
|
|
231
|
+
};
|
|
232
|
+
syncPair('rectalTemp', this.inputs.rectalTemp);
|
|
233
|
+
syncPair('ambientTemp', this.inputs.ambientTemp);
|
|
234
|
+
syncPair('bodyWeight', this.inputs.bodyWeight);
|
|
235
|
+
const timeInput = this.container.querySelector<HTMLInputElement>('#input-time');
|
|
236
|
+
if (timeInput) timeInput.value = this.inputs.measurementTime;
|
|
237
|
+
const trigger = this.container.querySelector<HTMLElement>('#factor-select-trigger .sc-select-val');
|
|
238
|
+
const matchingOpt = this.container.querySelector<HTMLElement>(`[data-factor-val="${this.inputs.correctionFactor}"]`);
|
|
239
|
+
if (trigger && matchingOpt) trigger.textContent = matchingOpt.textContent || '';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private update(): void {
|
|
243
|
+
saveState(this.inputs);
|
|
244
|
+
const result = calculateAlgorMortis(this.inputs);
|
|
245
|
+
const telemetryContainer = this.container.querySelector<HTMLElement>('#telemetry-stage');
|
|
246
|
+
if (telemetryContainer) {
|
|
247
|
+
renderTelemetry(telemetryContainer, result, this.inputs, this.ui);
|
|
248
|
+
}
|
|
249
|
+
const svgEl = this.container.querySelector<SVGSVGElement>('#nomogram-svg');
|
|
250
|
+
if (svgEl) {
|
|
251
|
+
renderSvgNomogram(svgEl, { result, inputs: this.inputs, ui: this.ui });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { TimeOfDeathAlgorMortisUI } from './ui';
|
|
2
|
+
import type { AlgorMortisResult, AlgorMortisInputs, CurvePoint } from './logic';
|
|
3
|
+
import { evaluateCoolingStatus } from './evaluator';
|
|
4
|
+
|
|
5
|
+
export interface SvgRenderConfig {
|
|
6
|
+
result: AlgorMortisResult;
|
|
7
|
+
inputs: AlgorMortisInputs;
|
|
8
|
+
ui: TimeOfDeathAlgorMortisUI;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface PlotBounds {
|
|
12
|
+
w: number;
|
|
13
|
+
h: number;
|
|
14
|
+
padLeft: number;
|
|
15
|
+
padBottom: number;
|
|
16
|
+
minTemp: number;
|
|
17
|
+
maxTemp: number;
|
|
18
|
+
maxTime: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatDuration(decimalHours: number, ui: TimeOfDeathAlgorMortisUI): string {
|
|
22
|
+
const h = Math.floor(decimalHours);
|
|
23
|
+
const m = Math.round((decimalHours - h) * 60);
|
|
24
|
+
if (m === 60) return `${h + 1} ${ui.hoursUnit}`;
|
|
25
|
+
if (h === 0) return `${m} ${ui.minutesUnit}`;
|
|
26
|
+
return `${h} ${ui.hoursUnit} ${m} ${ui.minutesUnit}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function renderTelemetry(container: HTMLElement, result: AlgorMortisResult, inputs: AlgorMortisInputs, ui: TimeOfDeathAlgorMortisUI): void {
|
|
30
|
+
const isImp = inputs.unitSystem === 'imperial';
|
|
31
|
+
const tempUnit = isImp ? ui.fahrenheitUnit : ui.celsiusUnit;
|
|
32
|
+
const rateUnit = isImp ? ui.fahrenheitPerHour : ui.celsiusPerHour;
|
|
33
|
+
const status = evaluateCoolingStatus({ result, ui, rectalTemp: result.rectalTempDisplay, ambientTemp: result.ambientTempDisplay, refTemp: result.refTempDisplay });
|
|
34
|
+
|
|
35
|
+
container.innerHTML = `
|
|
36
|
+
<div class="sc-hero-card">
|
|
37
|
+
<div class="sc-hero-header">
|
|
38
|
+
<span class="sc-hero-title">${ui.estimatedPmiLabel}</span>
|
|
39
|
+
<span class="sc-badge ${status.badgeClass}">${status.phaseLabel}</span>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="sc-hero-val">${formatDuration(result.pmiHours, ui)}</div>
|
|
42
|
+
<div class="sc-hero-sub">${ui.deathWindowLabel}: <strong>${result.timeOfDeathMin} - ${result.timeOfDeathMax}</strong> (${result.timeOfDeathEstimated} est.)</div>
|
|
43
|
+
<div class="sc-gauge-wrap">
|
|
44
|
+
<div class="sc-gauge-track">
|
|
45
|
+
<div class="sc-gauge-bar ${status.badgeClass}" style="width: ${status.gaugePercent}%"></div>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="sc-gauge-labels">
|
|
48
|
+
<span>${result.refTempDisplay}${tempUnit} ${ui.referenceBodyTempLabel || ''}</span>
|
|
49
|
+
<span>${result.ambientTempDisplay}${tempUnit} ${ui.baselineAmbientLabel || ''}</span>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="sc-stats-grid">
|
|
54
|
+
<div class="sc-stat-card"><span class="sc-stat-label">${ui.confidenceMarginLabel}</span><span class="sc-stat-val">± ${formatDuration(result.confidenceMarginHours, ui)}</span><span class="sc-stat-sub">${ui.confidenceMarginSub || '95%'}</span></div>
|
|
55
|
+
<div class="sc-stat-card"><span class="sc-stat-label">${ui.coolingRateLabel}</span><span class="sc-stat-val">${result.coolingRateDisplay} ${rateUnit}</span><span class="sc-stat-sub">${ui.coolingRateSub || 'ΔT/h'}</span></div>
|
|
56
|
+
<div class="sc-stat-card"><span class="sc-stat-label">${ui.glaisterEstimateLabel}</span><span class="sc-stat-val">${formatDuration(result.glaisterPmiHours, ui)}</span><span class="sc-stat-sub">${ui.glaisterEstimateSub || 'Glaister'}</span></div>
|
|
57
|
+
</div>
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildPathString(points: CurvePoint[], b: PlotBounds): string {
|
|
62
|
+
const span = Math.max(1, b.maxTemp - b.minTemp);
|
|
63
|
+
const plotW = b.w - b.padLeft - 30;
|
|
64
|
+
const plotH = b.h - b.padBottom - 25;
|
|
65
|
+
return points.reduce((acc, pt, i) => {
|
|
66
|
+
const x = b.padLeft + (pt.time / b.maxTime) * plotW;
|
|
67
|
+
const y = 25 + plotH - ((pt.temp - b.minTemp) / span) * plotH;
|
|
68
|
+
return i === 0 ? `M ${x.toFixed(1)} ${y.toFixed(1)}` : `${acc} L ${x.toFixed(1)} ${y.toFixed(1)}`;
|
|
69
|
+
}, '');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildBandPolygon(points: CurvePoint[], b: PlotBounds): string {
|
|
73
|
+
const span = Math.max(1, b.maxTemp - b.minTemp);
|
|
74
|
+
const plotW = b.w - b.padLeft - 30;
|
|
75
|
+
const plotH = b.h - b.padBottom - 25;
|
|
76
|
+
const upper = points.map((pt) => `${(b.padLeft + (pt.time / b.maxTime) * plotW).toFixed(1)},${(25 + plotH - ((pt.upperTemp - b.minTemp) / span) * plotH).toFixed(1)}`);
|
|
77
|
+
const lower = points.slice().reverse().map((pt) => `${(b.padLeft + (pt.time / b.maxTime) * plotW).toFixed(1)},${(25 + plotH - ((pt.lowerTemp - b.minTemp) / span) * plotH).toFixed(1)}`);
|
|
78
|
+
return `${upper.join(' ')} ${lower.join(' ')}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderAxesAndGrid(b: PlotBounds, tempUnit: string): string {
|
|
82
|
+
const plotW = b.w - b.padLeft - 30;
|
|
83
|
+
const plotH = b.h - b.padBottom - 25;
|
|
84
|
+
const span = Math.max(1, b.maxTemp - b.minTemp);
|
|
85
|
+
|
|
86
|
+
const yVals = [b.maxTemp, b.minTemp + span * 0.66, b.minTemp + span * 0.33, b.minTemp];
|
|
87
|
+
const yTicks = yVals.map((val) => {
|
|
88
|
+
const y = 25 + plotH - ((val - b.minTemp) / span) * plotH;
|
|
89
|
+
return `<line x1="${b.padLeft}" y1="${y}" x2="${b.w - 30}" y2="${y}" class="sc-chart-grid" /><text x="${b.padLeft - 10}" y="${y + 4}" text-anchor="end" class="sc-chart-axis-label">${Math.round(val)}${tempUnit}</text>`;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const xSteps = [0, b.maxTime * 0.25, b.maxTime * 0.5, b.maxTime * 0.75, b.maxTime].map((t) => {
|
|
93
|
+
const x = b.padLeft + (t / b.maxTime) * plotW;
|
|
94
|
+
return `<line x1="${x}" y1="25" x2="${x}" y2="${25 + plotH}" class="sc-chart-grid" /><text x="${x}" y="${b.h - 15}" text-anchor="middle" class="sc-chart-axis-label">${Math.round(t)}h</text>`;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return `${yTicks.join('')}${xSteps.join('')}<line x1="${b.padLeft}" y1="25" x2="${b.padLeft}" y2="${25 + plotH}" class="sc-chart-axis" /><line x1="${b.padLeft}" y1="${25 + plotH}" x2="${b.w - 30}" y2="${25 + plotH}" class="sc-chart-axis" />`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildSvgContent(cfg: SvgRenderConfig, b: PlotBounds, tUnit: string): string {
|
|
101
|
+
const { result, ui } = cfg;
|
|
102
|
+
const plotW = b.w - b.padLeft - 30;
|
|
103
|
+
const plotH = b.h - b.padBottom - 25;
|
|
104
|
+
const span = Math.max(1, b.maxTemp - b.minTemp);
|
|
105
|
+
const pathD = buildPathString(result.curvePoints, b);
|
|
106
|
+
const bandPoly = buildBandPolygon(result.curvePoints, b);
|
|
107
|
+
const ptX = b.padLeft + (result.pmiHours / b.maxTime) * plotW;
|
|
108
|
+
const ptY = 25 + plotH - ((result.rectalTempDisplay - b.minTemp) / span) * plotH;
|
|
109
|
+
const ambY = 25 + plotH - ((result.ambientTempDisplay - b.minTemp) / span) * plotH;
|
|
110
|
+
const plateauW = (2.5 / boundsOrDefault(b.maxTime)) * plotW;
|
|
111
|
+
|
|
112
|
+
return `
|
|
113
|
+
<defs><linearGradient id="sc-curve-grad" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="var(--n-accent-cyan)" /><stop offset="100%" stop-color="var(--n-primary)" /></linearGradient></defs>
|
|
114
|
+
${renderAxesAndGrid(b, tUnit)}
|
|
115
|
+
<rect x="${b.padLeft}" y="25" width="${plateauW}" height="${plotH}" class="sc-chart-plateau" />
|
|
116
|
+
<polygon points="${bandPoly}" class="sc-chart-band" />
|
|
117
|
+
<line x1="${b.padLeft}" y1="${ambY}" x2="${b.w - 30}" y2="${ambY}" class="sc-chart-amb" />
|
|
118
|
+
<path d="${pathD}" class="sc-chart-line" />
|
|
119
|
+
<circle cx="${ptX}" cy="${ptY}" r="9" class="sc-chart-pulse" />
|
|
120
|
+
<circle cx="${ptX}" cy="${ptY}" r="5" class="sc-chart-core" />
|
|
121
|
+
<text x="${b.padLeft + plateauW / 2}" y="45" text-anchor="middle" class="sc-chart-tag">${ui.chartPlateauMarker}</text>
|
|
122
|
+
<text x="${b.w - 35}" y="${ambY - 8}" text-anchor="end" class="sc-chart-tag">${result.ambientTempDisplay}${tUnit} ${ui.ambientTempLabel}</text>
|
|
123
|
+
<text x="${Math.min(b.w - 110, ptX + 14)}" y="${Math.max(45, ptY - 12)}" class="sc-chart-pt-label">${result.rectalTempDisplay}${tUnit} (${result.pmiHours.toFixed(1)}h)</text>
|
|
124
|
+
`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function boundsOrDefault(maxTime: number): number {
|
|
128
|
+
return Math.max(1, maxTime);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function renderSvgNomogram(svg: SVGSVGElement, cfg: SvgRenderConfig): void {
|
|
132
|
+
const isImp = cfg.inputs.unitSystem === 'imperial';
|
|
133
|
+
const bounds: PlotBounds = {
|
|
134
|
+
w: 700,
|
|
135
|
+
h: 340,
|
|
136
|
+
padLeft: 70,
|
|
137
|
+
padBottom: 45,
|
|
138
|
+
minTemp: Math.min(cfg.result.ambientTempDisplay, cfg.result.rectalTempDisplay) - (isImp ? 4 : 2),
|
|
139
|
+
maxTemp: cfg.result.refTempDisplay + (isImp ? 3 : 1.5),
|
|
140
|
+
maxTime: Math.max(24, Math.ceil(cfg.result.pmiHours + 6))
|
|
141
|
+
};
|
|
142
|
+
svg.setAttribute('viewBox', `0 0 ${bounds.w} ${bounds.h}`);
|
|
143
|
+
svg.innerHTML = buildSvgContent(cfg, bounds, isImp ? cfg.ui.fahrenheitUnit : cfg.ui.celsiusUnit);
|
|
144
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ScienceToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { TimeOfDeathAlgorMortisUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type TimeOfDeathAlgorMortisLocaleContent = ToolLocaleContent<TimeOfDeathAlgorMortisUI>;
|
|
5
|
+
|
|
6
|
+
export const timeOfDeathAlgorMortisCalculator: ScienceToolEntry<TimeOfDeathAlgorMortisUI> = {
|
|
7
|
+
id: 'time-of-death-algor-mortis-calculator',
|
|
8
|
+
icons: {
|
|
9
|
+
bg: 'mdi:thermometer',
|
|
10
|
+
fg: 'mdi:timer-outline'
|
|
11
|
+
},
|
|
12
|
+
i18n: {
|
|
13
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
14
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
15
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
16
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
17
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
18
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
19
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
20
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
21
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
22
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
23
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
24
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
25
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
26
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
27
|
+
zh: () => import('./i18n/zh').then((m) => m.content)
|
|
28
|
+
}
|
|
29
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { TimeOfDeathAlgorMortisUI } from './ui';
|
|
2
|
+
import type { AlgorMortisResult } from './logic';
|
|
3
|
+
|
|
4
|
+
export interface EvaluatedStatus {
|
|
5
|
+
phaseLabel: string;
|
|
6
|
+
badgeClass: string;
|
|
7
|
+
gaugePercent: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface StatusEvaluationParams {
|
|
11
|
+
result: AlgorMortisResult;
|
|
12
|
+
ui: TimeOfDeathAlgorMortisUI;
|
|
13
|
+
rectalTemp: number;
|
|
14
|
+
ambientTemp: number;
|
|
15
|
+
refTemp: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function evaluateCoolingStatus(params: StatusEvaluationParams): EvaluatedStatus {
|
|
19
|
+
const { result, ui, rectalTemp, ambientTemp, refTemp } = params;
|
|
20
|
+
const totalSpan = Math.max(1, refTemp - ambientTemp);
|
|
21
|
+
const dropped = Math.max(0, refTemp - rectalTemp);
|
|
22
|
+
const gaugePercent = Math.min(100, Math.max(0, Math.round((dropped / totalSpan) * 100)));
|
|
23
|
+
|
|
24
|
+
if (result.coolingPhase === 'hyperthermia') {
|
|
25
|
+
return { phaseLabel: ui.phaseHyperthermia, badgeClass: 'badge-danger', gaugePercent: 0 };
|
|
26
|
+
}
|
|
27
|
+
if (result.coolingPhase === 'equilibrium') {
|
|
28
|
+
return { phaseLabel: ui.phaseEquilibrium, badgeClass: 'badge-warning', gaugePercent: 100 };
|
|
29
|
+
}
|
|
30
|
+
if (result.coolingPhase === 'plateau') {
|
|
31
|
+
return { phaseLabel: ui.phasePlateau, badgeClass: 'badge-info', gaugePercent };
|
|
32
|
+
}
|
|
33
|
+
return { phaseLabel: ui.phaseDescent, badgeClass: 'badge-success', gaugePercent };
|
|
34
|
+
}
|