@jjlmoya/utils-forensic-science 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +2 -2
  2. package/src/category/index.ts +6 -1
  3. package/src/entries.ts +8 -1
  4. package/src/tests/locale_completeness.test.ts +2 -2
  5. package/src/tests/tool_validation.test.ts +2 -2
  6. package/src/tool/time-of-death-algor-mortis-calculator/bibliography.astro +14 -0
  7. package/src/tool/time-of-death-algor-mortis-calculator/bibliography.ts +7 -0
  8. package/src/tool/time-of-death-algor-mortis-calculator/component.astro +133 -0
  9. package/src/tool/time-of-death-algor-mortis-calculator/controller.ts +254 -0
  10. package/src/tool/time-of-death-algor-mortis-calculator/dom-views.ts +144 -0
  11. package/src/tool/time-of-death-algor-mortis-calculator/entry.ts +29 -0
  12. package/src/tool/time-of-death-algor-mortis-calculator/evaluator.ts +34 -0
  13. package/src/tool/time-of-death-algor-mortis-calculator/i18n/de.ts +139 -0
  14. package/src/tool/time-of-death-algor-mortis-calculator/i18n/en.ts +136 -0
  15. package/src/tool/time-of-death-algor-mortis-calculator/i18n/es.ts +136 -0
  16. package/src/tool/time-of-death-algor-mortis-calculator/i18n/fr.ts +136 -0
  17. package/src/tool/time-of-death-algor-mortis-calculator/i18n/id.ts +139 -0
  18. package/src/tool/time-of-death-algor-mortis-calculator/i18n/it.ts +136 -0
  19. package/src/tool/time-of-death-algor-mortis-calculator/i18n/ja.ts +136 -0
  20. package/src/tool/time-of-death-algor-mortis-calculator/i18n/ko.ts +136 -0
  21. package/src/tool/time-of-death-algor-mortis-calculator/i18n/nl.ts +139 -0
  22. package/src/tool/time-of-death-algor-mortis-calculator/i18n/pl.ts +139 -0
  23. package/src/tool/time-of-death-algor-mortis-calculator/i18n/pt.ts +136 -0
  24. package/src/tool/time-of-death-algor-mortis-calculator/i18n/ru.ts +139 -0
  25. package/src/tool/time-of-death-algor-mortis-calculator/i18n/sv.ts +139 -0
  26. package/src/tool/time-of-death-algor-mortis-calculator/i18n/tr.ts +139 -0
  27. package/src/tool/time-of-death-algor-mortis-calculator/i18n/zh.ts +136 -0
  28. package/src/tool/time-of-death-algor-mortis-calculator/index.ts +11 -0
  29. package/src/tool/time-of-death-algor-mortis-calculator/logic.test.ts +77 -0
  30. package/src/tool/time-of-death-algor-mortis-calculator/logic.ts +177 -0
  31. package/src/tool/time-of-death-algor-mortis-calculator/seo.astro +15 -0
  32. package/src/tool/time-of-death-algor-mortis-calculator/storage.ts +21 -0
  33. package/src/tool/time-of-death-algor-mortis-calculator/time-of-death-algor-mortis-calculator.css +596 -0
  34. package/src/tool/time-of-death-algor-mortis-calculator/ui.ts +60 -0
  35. package/src/tools.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-forensic-science",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -28,7 +28,7 @@
28
28
  "check": "astro check",
29
29
  "type-check": "astro check",
30
30
  "test": "vitest run",
31
- "preversion": "npm run lint && npm run test",
31
+ "preversion": "npm run lint && npm run test && npm run build",
32
32
  "postversion": "git push && git push --tags",
33
33
  "patch": "npm version patch",
34
34
  "minor": "npm version minor",
@@ -14,6 +14,7 @@ import { bloodstainPatternOriginAnalyzer } from '../tool/bloodstain-pattern-orig
14
14
  import { forensicFingerprintMinutiaeIdentifier } from '../tool/forensic-fingerprint-minutiae-identifier/entry';
15
15
  import { firePatternOriginAnalyzer } from '../tool/fire-pattern-origin-analyzer/entry';
16
16
  import { forensicToolmarkStriationMatcher } from '../tool/forensic-toolmark-striation-matcher/entry';
17
+ import { timeOfDeathAlgorMortisCalculator } from '../tool/time-of-death-algor-mortis-calculator/entry';
17
18
 
18
19
  export const forensicCategory: ScienceCategoryEntry = {
19
20
  icon: 'mdi:fingerprint',
@@ -32,8 +33,12 @@ export const forensicCategory: ScienceCategoryEntry = {
32
33
  bloodstainPatternOriginAnalyzer,
33
34
  forensicFingerprintMinutiaeIdentifier,
34
35
  firePatternOriginAnalyzer,
35
- forensicToolmarkStriationMatcher
36
+ forensicToolmarkStriationMatcher,
37
+ timeOfDeathAlgorMortisCalculator
36
38
  ],
39
+
40
+
41
+
37
42
  i18n: {
38
43
  de: () => import('./i18n/de').then((m) => m.content),
39
44
  en: () => import('./i18n/en').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -28,6 +28,8 @@ export { firePatternOriginAnalyzer } from './tool/fire-pattern-origin-analyzer/e
28
28
  export type { FirePatternOriginAnalyzerUI, FirePatternOriginAnalyzerLocaleContent } from './tool/fire-pattern-origin-analyzer/entry';
29
29
  export { forensicToolmarkStriationMatcher } from './tool/forensic-toolmark-striation-matcher/entry';
30
30
  export type { ToolmarkStriationMatcherUI, ToolmarkStriationMatcherLocaleContent } from './tool/forensic-toolmark-striation-matcher/entry';
31
+ export { timeOfDeathAlgorMortisCalculator } from './tool/time-of-death-algor-mortis-calculator/entry';
32
+ export type { TimeOfDeathAlgorMortisUI, TimeOfDeathAlgorMortisLocaleContent } from './tool/time-of-death-algor-mortis-calculator/entry';
31
33
 
32
34
  import { forensicAgeEstimator } from './tool/forensic-age-estimator/entry';
33
35
  import { widmarkAlcoholSimulator } from './tool/widmark-alcohol-simulator/entry';
@@ -44,6 +46,7 @@ import { bloodstainPatternOriginAnalyzer } from './tool/bloodstain-pattern-origi
44
46
  import { forensicFingerprintMinutiaeIdentifier } from './tool/forensic-fingerprint-minutiae-identifier/entry';
45
47
  import { firePatternOriginAnalyzer } from './tool/fire-pattern-origin-analyzer/entry';
46
48
  import { forensicToolmarkStriationMatcher } from './tool/forensic-toolmark-striation-matcher/entry';
49
+ import { timeOfDeathAlgorMortisCalculator } from './tool/time-of-death-algor-mortis-calculator/entry';
47
50
 
48
51
  export const ALL_ENTRIES = [
49
52
  forensicAgeEstimator,
@@ -60,5 +63,9 @@ export const ALL_ENTRIES = [
60
63
  bloodstainPatternOriginAnalyzer,
61
64
  forensicFingerprintMinutiaeIdentifier,
62
65
  firePatternOriginAnalyzer,
63
- forensicToolmarkStriationMatcher
66
+ forensicToolmarkStriationMatcher,
67
+ timeOfDeathAlgorMortisCalculator
64
68
  ];
69
+
70
+
71
+
@@ -18,7 +18,7 @@ describe('Locale Completeness Validation', () => {
18
18
  });
19
19
  });
20
20
 
21
- it('all 15 tools registered', () => {
22
- expect(ALL_TOOLS.length).toBe(15);
21
+ it('all 16 tools registered', () => {
22
+ expect(ALL_TOOLS.length).toBe(16);
23
23
  });
24
24
  });
@@ -4,8 +4,8 @@ import { scienceCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 15 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(15);
7
+ it('should have 16 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(16);
9
9
  });
10
10
 
11
11
  it('scienceCategory should be defined', () => {
@@ -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
+ }