@jjlmoya/utils-forensic-science 1.20.0 → 1.21.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/scripts/validate-icons.mjs +57 -0
- package/src/category/index.ts +10 -10
- package/src/entries.ts +26 -25
- package/src/index.ts +7 -6
- package/src/tests/locale_completeness.test.ts +23 -23
- package/src/tests/tool_validation.test.ts +16 -16
- package/src/tool/forensic-evidence-scale-calculator/bibliography.astro +9 -0
- package/src/tool/forensic-evidence-scale-calculator/bibliography.ts +16 -0
- package/src/tool/forensic-evidence-scale-calculator/component.astro +154 -0
- package/src/tool/forensic-evidence-scale-calculator/entry.ts +29 -0
- package/src/tool/forensic-evidence-scale-calculator/forensic-evidence-scale-calculator.css +516 -0
- package/src/tool/forensic-evidence-scale-calculator/i18n/de.ts +3 -0
- package/src/tool/forensic-evidence-scale-calculator/i18n/en.ts +299 -0
- package/src/tool/forensic-evidence-scale-calculator/i18n/es.ts +3 -0
- package/src/tool/forensic-evidence-scale-calculator/i18n/fr.ts +3 -0
- package/src/tool/forensic-evidence-scale-calculator/i18n/id.ts +3 -0
- package/src/tool/forensic-evidence-scale-calculator/i18n/it.ts +3 -0
- package/src/tool/forensic-evidence-scale-calculator/i18n/ja.ts +3 -0
- package/src/tool/forensic-evidence-scale-calculator/i18n/ko.ts +3 -0
- package/src/tool/forensic-evidence-scale-calculator/i18n/localized-content.ts +1537 -0
- package/src/tool/forensic-evidence-scale-calculator/i18n/nl.ts +3 -0
- package/src/tool/forensic-evidence-scale-calculator/i18n/pl.ts +3 -0
- package/src/tool/forensic-evidence-scale-calculator/i18n/pt.ts +3 -0
- package/src/tool/forensic-evidence-scale-calculator/i18n/ru.ts +3 -0
- package/src/tool/forensic-evidence-scale-calculator/i18n/sv.ts +3 -0
- package/src/tool/forensic-evidence-scale-calculator/i18n/tr.ts +3 -0
- package/src/tool/forensic-evidence-scale-calculator/i18n/zh.ts +3 -0
- package/src/tool/forensic-evidence-scale-calculator/index.ts +11 -0
- package/src/tool/forensic-evidence-scale-calculator/logic.test.ts +55 -0
- package/src/tool/forensic-evidence-scale-calculator/logic.ts +107 -0
- package/src/tool/forensic-evidence-scale-calculator/seo.astro +10 -0
- package/src/tool/forensic-evidence-scale-calculator/ui.ts +3 -0
- package/src/tool/forensic-evidence-scale-calculator/unit-selector.ts +120 -0
- package/src/tool/forensic-evidence-scale-calculator/view.ts +203 -0
- package/src/tools.ts +8 -6
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { forensicEvidenceScaleCalculator } from "./entry";
|
|
2
|
+
import type { ToolDefinition } from "../../types";
|
|
3
|
+
|
|
4
|
+
export * from "./entry";
|
|
5
|
+
|
|
6
|
+
export const FORENSIC_EVIDENCE_SCALE_CALCULATOR_TOOL: ToolDefinition = {
|
|
7
|
+
entry: forensicEvidenceScaleCalculator,
|
|
8
|
+
Component: () => import("./component.astro"),
|
|
9
|
+
SEOComponent: () => import("./seo.astro"),
|
|
10
|
+
BibliographyComponent: () => import("./bibliography.astro"),
|
|
11
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { calculateEvidenceScale, formatMeasurement } from "./logic";
|
|
3
|
+
|
|
4
|
+
describe("calculateEvidenceScale", () => {
|
|
5
|
+
it("converts image pixels into a real-world size and propagates uncertainty", () => {
|
|
6
|
+
const result = calculateEvidenceScale({
|
|
7
|
+
referenceRealSize: 25,
|
|
8
|
+
referencePixelLength: 200,
|
|
9
|
+
evidencePixelLength: 80,
|
|
10
|
+
uncertaintyPercent: 5,
|
|
11
|
+
unit: "mm",
|
|
12
|
+
samePlane: true,
|
|
13
|
+
});
|
|
14
|
+
expect(result.valid).toBe(true);
|
|
15
|
+
expect(result.scalePerPixel).toBe(0.125);
|
|
16
|
+
expect(result.evidenceSize).toBe(10);
|
|
17
|
+
expect(result.uncertainty).toBe(0.5);
|
|
18
|
+
expect(result.lowerBound).toBe(9.5);
|
|
19
|
+
expect(result.upperBound).toBe(10.5);
|
|
20
|
+
expect(result.warnings).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("keeps the selected unit in the formula and warns about different planes", () => {
|
|
24
|
+
const result = calculateEvidenceScale({
|
|
25
|
+
referenceRealSize: 10,
|
|
26
|
+
referencePixelLength: 500,
|
|
27
|
+
evidencePixelLength: 125,
|
|
28
|
+
uncertaintyPercent: 2,
|
|
29
|
+
unit: "cm",
|
|
30
|
+
samePlane: false,
|
|
31
|
+
});
|
|
32
|
+
expect(result.formula).toBe("125 px x (10 cm / 500 px)");
|
|
33
|
+
expect(result.evidenceSize).toBe(2.5);
|
|
34
|
+
expect(result.warnings).toEqual(["perspective"]);
|
|
35
|
+
expect(formatMeasurement(result.evidenceSize, result.unit)).toBe("2.5 cm");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects missing or unsafe measurements", () => {
|
|
39
|
+
const result = calculateEvidenceScale({
|
|
40
|
+
referenceRealSize: 0,
|
|
41
|
+
referencePixelLength: 0,
|
|
42
|
+
evidencePixelLength: -1,
|
|
43
|
+
uncertaintyPercent: 101,
|
|
44
|
+
unit: "mm",
|
|
45
|
+
samePlane: true,
|
|
46
|
+
});
|
|
47
|
+
expect(result.valid).toBe(false);
|
|
48
|
+
expect(result.errors).toEqual([
|
|
49
|
+
"referenceRealSize",
|
|
50
|
+
"referencePixelLength",
|
|
51
|
+
"evidencePixelLength",
|
|
52
|
+
"uncertaintyPercent",
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export type MeasurementUnit = "mm" | "cm" | "in";
|
|
2
|
+
|
|
3
|
+
export interface ScaleInput {
|
|
4
|
+
referenceRealSize: number;
|
|
5
|
+
referencePixelLength: number;
|
|
6
|
+
evidencePixelLength: number;
|
|
7
|
+
uncertaintyPercent: number;
|
|
8
|
+
unit: MeasurementUnit;
|
|
9
|
+
samePlane: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ScaleResult {
|
|
13
|
+
valid: boolean;
|
|
14
|
+
errors: string[];
|
|
15
|
+
warnings: string[];
|
|
16
|
+
scalePerPixel: number;
|
|
17
|
+
evidenceSize: number;
|
|
18
|
+
uncertainty: number;
|
|
19
|
+
lowerBound: number;
|
|
20
|
+
upperBound: number;
|
|
21
|
+
formula: string;
|
|
22
|
+
unit: MeasurementUnit;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const UNIT_LABELS: Record<MeasurementUnit, string> = {
|
|
26
|
+
mm: "mm",
|
|
27
|
+
cm: "cm",
|
|
28
|
+
in: "in",
|
|
29
|
+
};
|
|
30
|
+
const UNITS: MeasurementUnit[] = ["mm", "cm", "in"];
|
|
31
|
+
const isFinitePositive = (value: number): boolean =>
|
|
32
|
+
Number.isFinite(value) && value > 0;
|
|
33
|
+
const isUnit = (value: string): value is MeasurementUnit =>
|
|
34
|
+
UNITS.includes(value as MeasurementUnit);
|
|
35
|
+
const round = (value: number, decimals = 6): number =>
|
|
36
|
+
Number(value.toFixed(decimals));
|
|
37
|
+
|
|
38
|
+
export const formatMeasurement = (
|
|
39
|
+
value: number,
|
|
40
|
+
unit: MeasurementUnit,
|
|
41
|
+
): string => {
|
|
42
|
+
if (!Number.isFinite(value)) return "-";
|
|
43
|
+
const absolute = Math.abs(value);
|
|
44
|
+
let decimals = 3;
|
|
45
|
+
if (absolute >= 100) decimals = 0;
|
|
46
|
+
else if (absolute >= 10) decimals = 2;
|
|
47
|
+
return `${value.toFixed(decimals).replace(/\.0+$|(?<=\.\d)0+$/, "")} ${UNIT_LABELS[unit]}`;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const validateInput = (input: ScaleInput): string[] => {
|
|
51
|
+
const errors: string[] = [];
|
|
52
|
+
if (!isFinitePositive(input.referenceRealSize))
|
|
53
|
+
errors.push("referenceRealSize");
|
|
54
|
+
if (!isFinitePositive(input.referencePixelLength))
|
|
55
|
+
errors.push("referencePixelLength");
|
|
56
|
+
if (!isFinitePositive(input.evidencePixelLength))
|
|
57
|
+
errors.push("evidencePixelLength");
|
|
58
|
+
if (
|
|
59
|
+
!Number.isFinite(input.uncertaintyPercent) ||
|
|
60
|
+
input.uncertaintyPercent < 0 ||
|
|
61
|
+
input.uncertaintyPercent > 100
|
|
62
|
+
) {
|
|
63
|
+
errors.push("uncertaintyPercent");
|
|
64
|
+
}
|
|
65
|
+
return errors;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const emptyResult = (
|
|
69
|
+
unit: MeasurementUnit,
|
|
70
|
+
errors: string[],
|
|
71
|
+
warnings: string[],
|
|
72
|
+
): ScaleResult => ({
|
|
73
|
+
valid: false,
|
|
74
|
+
errors,
|
|
75
|
+
warnings,
|
|
76
|
+
scalePerPixel: 0,
|
|
77
|
+
evidenceSize: 0,
|
|
78
|
+
uncertainty: 0,
|
|
79
|
+
lowerBound: 0,
|
|
80
|
+
upperBound: 0,
|
|
81
|
+
formula: "-",
|
|
82
|
+
unit,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
export const calculateEvidenceScale = (input: ScaleInput): ScaleResult => {
|
|
86
|
+
const unit = isUnit(input.unit) ? input.unit : "mm";
|
|
87
|
+
const errors = validateInput(input);
|
|
88
|
+
const warnings = input.samePlane ? [] : ["perspective"];
|
|
89
|
+
|
|
90
|
+
if (errors.length > 0) return emptyResult(unit, errors, warnings);
|
|
91
|
+
|
|
92
|
+
const scalePerPixel = input.referenceRealSize / input.referencePixelLength;
|
|
93
|
+
const evidenceSize = input.evidencePixelLength * scalePerPixel;
|
|
94
|
+
const uncertainty = evidenceSize * (input.uncertaintyPercent / 100);
|
|
95
|
+
return {
|
|
96
|
+
valid: true,
|
|
97
|
+
errors,
|
|
98
|
+
warnings,
|
|
99
|
+
scalePerPixel: round(scalePerPixel),
|
|
100
|
+
evidenceSize: round(evidenceSize),
|
|
101
|
+
uncertainty: round(uncertainty),
|
|
102
|
+
lowerBound: round(Math.max(0, evidenceSize - uncertainty)),
|
|
103
|
+
upperBound: round(evidenceSize + uncertainty),
|
|
104
|
+
formula: `${input.evidencePixelLength} px x (${input.referenceRealSize} ${unit} / ${input.referencePixelLength} px)`,
|
|
105
|
+
unit,
|
|
106
|
+
};
|
|
107
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { forensicEvidenceScaleCalculator } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
interface Props { locale?: KnownLocale; }
|
|
6
|
+
const { locale = 'en' } = Astro.props;
|
|
7
|
+
const content = await forensicEvidenceScaleCalculator.i18n[locale]?.();
|
|
8
|
+
if (!content) return null;
|
|
9
|
+
---
|
|
10
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { MeasurementUnit } from "./logic";
|
|
2
|
+
|
|
3
|
+
interface UnitSelector {
|
|
4
|
+
choice: HTMLElement;
|
|
5
|
+
trigger: HTMLButtonElement;
|
|
6
|
+
label: HTMLElement;
|
|
7
|
+
menu: HTMLElement;
|
|
8
|
+
options: HTMLButtonElement[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const getElement = <T extends Element>(
|
|
12
|
+
root: ParentNode,
|
|
13
|
+
selector: string,
|
|
14
|
+
): T | null => root.querySelector<T>(selector);
|
|
15
|
+
|
|
16
|
+
const getSelector = (root: HTMLElement): UnitSelector | null => {
|
|
17
|
+
const choice = getElement<HTMLElement>(root, "[data-unit-choice]");
|
|
18
|
+
const trigger = getElement<HTMLButtonElement>(root, "[data-unit-trigger]");
|
|
19
|
+
const label = getElement<HTMLElement>(root, "[data-unit-label]");
|
|
20
|
+
const menu = getElement<HTMLElement>(root, "[data-unit-menu]");
|
|
21
|
+
if (!choice || !trigger || !label || !menu) return null;
|
|
22
|
+
return {
|
|
23
|
+
choice,
|
|
24
|
+
trigger,
|
|
25
|
+
label,
|
|
26
|
+
menu,
|
|
27
|
+
options: Array.from(
|
|
28
|
+
menu.querySelectorAll<HTMLButtonElement>("[data-unit-option]"),
|
|
29
|
+
),
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const setUnit = (selector: UnitSelector, unit: MeasurementUnit): void => {
|
|
34
|
+
const hidden = getElement<HTMLInputElement>(selector.choice, '[name="unit"]');
|
|
35
|
+
if (hidden) hidden.value = unit;
|
|
36
|
+
selector.options.forEach((option) => {
|
|
37
|
+
const selected = option.dataset.unitOption === unit;
|
|
38
|
+
option.setAttribute("aria-selected", String(selected));
|
|
39
|
+
if (selected) selector.label.textContent = option.textContent?.trim() ?? "";
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const bindTrigger = (
|
|
44
|
+
selector: UnitSelector,
|
|
45
|
+
open: () => void,
|
|
46
|
+
close: () => void,
|
|
47
|
+
): void => {
|
|
48
|
+
selector.trigger.addEventListener("click", () => {
|
|
49
|
+
if (selector.menu.hidden) open();
|
|
50
|
+
else close();
|
|
51
|
+
});
|
|
52
|
+
selector.trigger.addEventListener("keydown", (event) => {
|
|
53
|
+
if (event.key !== "ArrowDown" && event.key !== "Enter" && event.key !== " ")
|
|
54
|
+
return;
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
open();
|
|
57
|
+
selector.options[0]?.focus();
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
interface OptionContext {
|
|
62
|
+
selector: UnitSelector;
|
|
63
|
+
close: () => void;
|
|
64
|
+
onChange: () => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const bindOption = (
|
|
68
|
+
{ selector, close, onChange }: OptionContext,
|
|
69
|
+
option: HTMLButtonElement,
|
|
70
|
+
index: number,
|
|
71
|
+
): void => {
|
|
72
|
+
option.addEventListener("click", () => {
|
|
73
|
+
setUnit(selector, option.dataset.unitOption as MeasurementUnit);
|
|
74
|
+
close();
|
|
75
|
+
onChange();
|
|
76
|
+
selector.trigger.focus();
|
|
77
|
+
});
|
|
78
|
+
option.addEventListener("keydown", (event) => {
|
|
79
|
+
if (event.key === "Escape") {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
close();
|
|
82
|
+
selector.trigger.focus();
|
|
83
|
+
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
const direction = event.key === "ArrowDown" ? 1 : -1;
|
|
86
|
+
selector.options[
|
|
87
|
+
(index + direction + selector.options.length) % selector.options.length
|
|
88
|
+
]?.focus();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const setupUnitSelector = (
|
|
94
|
+
root: HTMLElement,
|
|
95
|
+
onChange: () => void,
|
|
96
|
+
): void => {
|
|
97
|
+
const selector = getSelector(root);
|
|
98
|
+
if (!selector || selector.options.length === 0) return;
|
|
99
|
+
const close = (): void => {
|
|
100
|
+
selector.menu.hidden = true;
|
|
101
|
+
selector.trigger.setAttribute("aria-expanded", "false");
|
|
102
|
+
};
|
|
103
|
+
const open = (): void => {
|
|
104
|
+
selector.menu.hidden = false;
|
|
105
|
+
selector.trigger.setAttribute("aria-expanded", "true");
|
|
106
|
+
};
|
|
107
|
+
bindTrigger(selector, open, close);
|
|
108
|
+
selector.options.forEach((option, index) =>
|
|
109
|
+
bindOption({ selector, close, onChange }, option, index),
|
|
110
|
+
);
|
|
111
|
+
root.addEventListener("click", (event) => {
|
|
112
|
+
if (event.target instanceof Node && !selector.choice.contains(event.target))
|
|
113
|
+
close();
|
|
114
|
+
});
|
|
115
|
+
const initialUnit = getElement<HTMLInputElement>(
|
|
116
|
+
root,
|
|
117
|
+
'[name="unit"]',
|
|
118
|
+
)?.value;
|
|
119
|
+
setUnit(selector, (initialUnit ?? "mm") as MeasurementUnit);
|
|
120
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import {
|
|
2
|
+
calculateEvidenceScale,
|
|
3
|
+
formatMeasurement,
|
|
4
|
+
type MeasurementUnit,
|
|
5
|
+
type ScaleInput,
|
|
6
|
+
} from "./logic";
|
|
7
|
+
import type { ForensicEvidenceScaleUI } from "./ui";
|
|
8
|
+
import { setupUnitSelector } from "./unit-selector";
|
|
9
|
+
|
|
10
|
+
type ScaleResult = ReturnType<typeof calculateEvidenceScale>;
|
|
11
|
+
|
|
12
|
+
const getElement = <T extends Element>(
|
|
13
|
+
root: ParentNode,
|
|
14
|
+
selector: string,
|
|
15
|
+
): T | null => root.querySelector<T>(selector);
|
|
16
|
+
|
|
17
|
+
const readNumber = (root: ParentNode, name: string): number =>
|
|
18
|
+
Number(getElement<HTMLInputElement>(root, `[name="${name}"]`)?.value ?? NaN);
|
|
19
|
+
|
|
20
|
+
const readInput = (root: HTMLElement): ScaleInput => ({
|
|
21
|
+
referenceRealSize: readNumber(root, "referenceRealSize"),
|
|
22
|
+
referencePixelLength: readNumber(root, "referencePixelLength"),
|
|
23
|
+
evidencePixelLength: readNumber(root, "evidencePixelLength"),
|
|
24
|
+
uncertaintyPercent: readNumber(root, "uncertaintyPercent"),
|
|
25
|
+
unit: (getElement<HTMLInputElement>(root, '[name="unit"]')?.value ??
|
|
26
|
+
"mm") as MeasurementUnit,
|
|
27
|
+
samePlane:
|
|
28
|
+
getElement<HTMLInputElement>(root, '[name="samePlane"]')?.checked ?? false,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const setText = (root: ParentNode, selector: string, text: string): void => {
|
|
32
|
+
const element = getElement<HTMLElement>(root, selector);
|
|
33
|
+
if (element) element.textContent = text;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const uiText = (ui: ForensicEvidenceScaleUI, key: string): string =>
|
|
37
|
+
ui[key] ?? "";
|
|
38
|
+
|
|
39
|
+
interface ReadoutContext {
|
|
40
|
+
ui: ForensicEvidenceScaleUI;
|
|
41
|
+
result: ScaleResult;
|
|
42
|
+
unit: MeasurementUnit;
|
|
43
|
+
uncertaintyPercent: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const readoutValue = (
|
|
47
|
+
result: ScaleResult,
|
|
48
|
+
validText: string,
|
|
49
|
+
fallback: string,
|
|
50
|
+
): string => (result.valid ? validText : fallback);
|
|
51
|
+
|
|
52
|
+
const updateMainReadouts = (
|
|
53
|
+
root: HTMLElement,
|
|
54
|
+
context: ReadoutContext,
|
|
55
|
+
): void => {
|
|
56
|
+
const { ui, result } = context;
|
|
57
|
+
setText(
|
|
58
|
+
root,
|
|
59
|
+
'[data-result="size"]',
|
|
60
|
+
readoutValue(
|
|
61
|
+
result,
|
|
62
|
+
formatMeasurement(result.evidenceSize, result.unit),
|
|
63
|
+
uiText(ui, "notCalculated"),
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
setText(
|
|
67
|
+
root,
|
|
68
|
+
'[data-result="interval"]',
|
|
69
|
+
readoutValue(
|
|
70
|
+
result,
|
|
71
|
+
`${formatMeasurement(result.lowerBound, result.unit)} - ${formatMeasurement(result.upperBound, result.unit)}`,
|
|
72
|
+
uiText(ui, "enterValues"),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const updateScaleReadouts = (
|
|
78
|
+
root: HTMLElement,
|
|
79
|
+
context: ReadoutContext,
|
|
80
|
+
): void => {
|
|
81
|
+
const { result, unit, uncertaintyPercent } = context;
|
|
82
|
+
setText(
|
|
83
|
+
root,
|
|
84
|
+
'[data-result="scale"]',
|
|
85
|
+
readoutValue(result, `${result.scalePerPixel} ${unit}/px`, "-"),
|
|
86
|
+
);
|
|
87
|
+
setText(
|
|
88
|
+
root,
|
|
89
|
+
'[data-result="margin"]',
|
|
90
|
+
readoutValue(result, `${uncertaintyPercent}%`, "-"),
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const updateTraceReadouts = (
|
|
95
|
+
root: HTMLElement,
|
|
96
|
+
context: ReadoutContext,
|
|
97
|
+
): void => {
|
|
98
|
+
const { ui, result } = context;
|
|
99
|
+
setText(
|
|
100
|
+
root,
|
|
101
|
+
'[data-result="formula"]',
|
|
102
|
+
readoutValue(result, result.formula, uiText(ui, "formulaPending")),
|
|
103
|
+
);
|
|
104
|
+
setText(
|
|
105
|
+
root,
|
|
106
|
+
'[data-result="message"]',
|
|
107
|
+
readoutValue(
|
|
108
|
+
result,
|
|
109
|
+
uiText(ui, "calculatedMessage"),
|
|
110
|
+
uiText(ui, "enterValues"),
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const updateReadouts = (root: HTMLElement, context: ReadoutContext): void => {
|
|
116
|
+
updateMainReadouts(root, context);
|
|
117
|
+
updateScaleReadouts(root, context);
|
|
118
|
+
updateTraceReadouts(root, context);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const updateWarning = (
|
|
122
|
+
root: HTMLElement,
|
|
123
|
+
ui: ForensicEvidenceScaleUI,
|
|
124
|
+
result: ScaleResult,
|
|
125
|
+
): void => {
|
|
126
|
+
const warning = getElement<HTMLElement>(root, '[data-result="warning"]');
|
|
127
|
+
if (!warning) return;
|
|
128
|
+
warning.textContent =
|
|
129
|
+
result.warnings.length > 0
|
|
130
|
+
? uiText(ui, "perspectiveWarning")
|
|
131
|
+
: uiText(ui, "samePlaneMessage");
|
|
132
|
+
warning.dataset.visible = result.warnings.length > 0 ? "true" : "false";
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const referenceEnd = (input: ScaleInput): number =>
|
|
136
|
+
Math.min(
|
|
137
|
+
520,
|
|
138
|
+
220 +
|
|
139
|
+
Math.max(
|
|
140
|
+
45,
|
|
141
|
+
(input.referencePixelLength / Math.max(input.evidencePixelLength, 1)) *
|
|
142
|
+
70,
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const evidenceEnd = (input: ScaleInput): number =>
|
|
147
|
+
Math.min(
|
|
148
|
+
560,
|
|
149
|
+
250 +
|
|
150
|
+
Math.max(
|
|
151
|
+
34,
|
|
152
|
+
(input.evidencePixelLength / Math.max(input.referencePixelLength, 1)) *
|
|
153
|
+
260,
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const updateScene = (
|
|
158
|
+
root: HTMLElement,
|
|
159
|
+
input: ScaleInput,
|
|
160
|
+
result: ScaleResult,
|
|
161
|
+
): void => {
|
|
162
|
+
const referenceLine = getElement<SVGLineElement>(
|
|
163
|
+
root,
|
|
164
|
+
'[data-scene="reference-line"]',
|
|
165
|
+
);
|
|
166
|
+
const evidenceLine = getElement<SVGLineElement>(
|
|
167
|
+
root,
|
|
168
|
+
'[data-scene="evidence-line"]',
|
|
169
|
+
);
|
|
170
|
+
const scene = getElement<HTMLElement>(root, "[data-scene]");
|
|
171
|
+
if (scene)
|
|
172
|
+
scene.dataset.warning = result.warnings.length > 0 ? "true" : "false";
|
|
173
|
+
if (!result.valid) return;
|
|
174
|
+
if (referenceLine)
|
|
175
|
+
referenceLine.setAttribute("x2", String(referenceEnd(input)));
|
|
176
|
+
if (evidenceLine) evidenceLine.setAttribute("x2", String(evidenceEnd(input)));
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const render = (root: HTMLElement, ui: ForensicEvidenceScaleUI): void => {
|
|
180
|
+
const input = readInput(root);
|
|
181
|
+
const result = calculateEvidenceScale(input);
|
|
182
|
+
root.dataset.ready = result.valid ? "true" : "false";
|
|
183
|
+
updateReadouts(root, {
|
|
184
|
+
ui,
|
|
185
|
+
result,
|
|
186
|
+
unit: input.unit,
|
|
187
|
+
uncertaintyPercent: input.uncertaintyPercent,
|
|
188
|
+
});
|
|
189
|
+
updateWarning(root, ui, result);
|
|
190
|
+
updateScene(root, input, result);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const initForensicEvidenceScale = (
|
|
194
|
+
root: HTMLElement,
|
|
195
|
+
ui: ForensicEvidenceScaleUI,
|
|
196
|
+
): void => {
|
|
197
|
+
setupUnitSelector(root, () => render(root, ui));
|
|
198
|
+
root.querySelectorAll<HTMLInputElement>("input").forEach((field) => {
|
|
199
|
+
field.addEventListener("input", () => render(root, ui));
|
|
200
|
+
field.addEventListener("change", () => render(root, ui));
|
|
201
|
+
});
|
|
202
|
+
render(root, ui);
|
|
203
|
+
};
|
package/src/tools.ts
CHANGED
|
@@ -15,9 +15,10 @@ import { BLOODSTAIN_PATTERN_ORIGIN_ANALYZER_TOOL } from './tool/bloodstain-patte
|
|
|
15
15
|
import { FORENSIC_FINGERPRINT_MINUTIAE_IDENTIFIER_TOOL } from './tool/forensic-fingerprint-minutiae-identifier/index';
|
|
16
16
|
import { FIRE_PATTERN_ORIGIN_ANALYZER_TOOL } from './tool/fire-pattern-origin-analyzer/index';
|
|
17
17
|
import { FORENSIC_TOOLMARK_STRIATION_MATCHER_TOOL } from './tool/forensic-toolmark-striation-matcher/index';
|
|
18
|
-
import { TIME_OF_DEATH_ALGOR_MORTIS_CALCULATOR_TOOL } from './tool/time-of-death-algor-mortis-calculator/index';
|
|
19
|
-
import { VOICE_SPECTROGRAM_ANALYZER_TOOL } from './tool/voice-spectrogram-analyzer/index';
|
|
20
|
-
import { DNA_PROFILE_MATCH_PROBABILITY_LAB_TOOL } from './tool/dna-profile-match-probability-lab/index';
|
|
18
|
+
import { TIME_OF_DEATH_ALGOR_MORTIS_CALCULATOR_TOOL } from './tool/time-of-death-algor-mortis-calculator/index';
|
|
19
|
+
import { VOICE_SPECTROGRAM_ANALYZER_TOOL } from './tool/voice-spectrogram-analyzer/index';
|
|
20
|
+
import { DNA_PROFILE_MATCH_PROBABILITY_LAB_TOOL } from './tool/dna-profile-match-probability-lab/index';
|
|
21
|
+
import { FORENSIC_EVIDENCE_SCALE_CALCULATOR_TOOL } from './tool/forensic-evidence-scale-calculator/index';
|
|
21
22
|
|
|
22
23
|
export const ALL_TOOLS: ToolDefinition[] = [
|
|
23
24
|
FORENSIC_AGE_ESTIMATOR_TOOL,
|
|
@@ -35,7 +36,8 @@ export const ALL_TOOLS: ToolDefinition[] = [
|
|
|
35
36
|
FORENSIC_FINGERPRINT_MINUTIAE_IDENTIFIER_TOOL,
|
|
36
37
|
FIRE_PATTERN_ORIGIN_ANALYZER_TOOL,
|
|
37
38
|
FORENSIC_TOOLMARK_STRIATION_MATCHER_TOOL,
|
|
38
|
-
TIME_OF_DEATH_ALGOR_MORTIS_CALCULATOR_TOOL,
|
|
39
|
-
VOICE_SPECTROGRAM_ANALYZER_TOOL,
|
|
40
|
-
DNA_PROFILE_MATCH_PROBABILITY_LAB_TOOL
|
|
39
|
+
TIME_OF_DEATH_ALGOR_MORTIS_CALCULATOR_TOOL,
|
|
40
|
+
VOICE_SPECTROGRAM_ANALYZER_TOOL,
|
|
41
|
+
DNA_PROFILE_MATCH_PROBABILITY_LAB_TOOL,
|
|
42
|
+
FORENSIC_EVIDENCE_SCALE_CALCULATOR_TOOL
|
|
41
43
|
];
|