@jjlmoya/utils-forensic-science 1.4.0 → 1.6.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 +1 -1
- package/src/category/index.ts +5 -1
- package/src/entries.ts +9 -1
- package/src/index.ts +2 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/fire-pattern-origin-analyzer/bibliography.astro +6 -0
- package/src/tool/fire-pattern-origin-analyzer/bibliography.ts +12 -0
- package/src/tool/fire-pattern-origin-analyzer/component.astro +196 -0
- package/src/tool/fire-pattern-origin-analyzer/entry.ts +32 -0
- package/src/tool/fire-pattern-origin-analyzer/fire-pattern-origin-analyzer.css +681 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/de.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/en.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/es.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/fr.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/id.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/it.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/ja.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/ko.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/nl.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/pl.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/pt.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/ru.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/sv.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/tr.ts +250 -0
- package/src/tool/fire-pattern-origin-analyzer/i18n/zh.ts +246 -0
- package/src/tool/fire-pattern-origin-analyzer/index.ts +11 -0
- package/src/tool/fire-pattern-origin-analyzer/logic.ts +123 -0
- package/src/tool/fire-pattern-origin-analyzer/render.ts +159 -0
- package/src/tool/fire-pattern-origin-analyzer/seo.astro +15 -0
- package/src/tool/fire-pattern-origin-analyzer/view-bindings.ts +171 -0
- package/src/tool/fire-pattern-origin-analyzer/view-model.ts +244 -0
- package/src/tool/fire-pattern-origin-analyzer/view.ts +30 -0
- package/src/tool/forensic-toolmark-striation-matcher/bibliography.astro +6 -0
- package/src/tool/forensic-toolmark-striation-matcher/bibliography.ts +16 -0
- package/src/tool/forensic-toolmark-striation-matcher/component.astro +121 -0
- package/src/tool/forensic-toolmark-striation-matcher/entry.ts +32 -0
- package/src/tool/forensic-toolmark-striation-matcher/forensic-toolmark-striation-matcher.css +580 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/de.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/en.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/es.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/fr.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/id.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/it.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/ja.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/ko.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/nl.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/pl.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/pt.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/ru.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/sv.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/tr.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/i18n/zh.ts +301 -0
- package/src/tool/forensic-toolmark-striation-matcher/index.ts +11 -0
- package/src/tool/forensic-toolmark-striation-matcher/logic.ts +71 -0
- package/src/tool/forensic-toolmark-striation-matcher/renderer.ts +237 -0
- package/src/tool/forensic-toolmark-striation-matcher/seo.astro +15 -0
- package/src/tool/forensic-toolmark-striation-matcher/view.ts +291 -0
- package/src/tools.ts +5 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { FireOriginEstimator, FireVector } from './logic';
|
|
2
|
+
import { drawHeatmap, drawOriginMarker, drawVector, pointFromPointer, snapPoint, WIDTH, HEIGHT } from './render';
|
|
3
|
+
import {
|
|
4
|
+
animateToState,
|
|
5
|
+
closeRadialMenu,
|
|
6
|
+
commitDraft,
|
|
7
|
+
exportReport,
|
|
8
|
+
filteredVectors,
|
|
9
|
+
interpolatedState,
|
|
10
|
+
loadPlanFile,
|
|
11
|
+
openRadialMenu,
|
|
12
|
+
syncLayers,
|
|
13
|
+
type Pattern,
|
|
14
|
+
type ViewRefs,
|
|
15
|
+
type ViewState,
|
|
16
|
+
updateOriginOverlay,
|
|
17
|
+
updatePointerOverlay,
|
|
18
|
+
} from './view-model';
|
|
19
|
+
|
|
20
|
+
export function bindLayerToggles(refs: ViewRefs, estimator: FireOriginEstimator, state: ViewState): void {
|
|
21
|
+
refs.root.querySelectorAll<HTMLButtonElement>('[data-fire-layer-toggle]').forEach((button) => {
|
|
22
|
+
button.addEventListener('click', () => {
|
|
23
|
+
const pattern = button.dataset.fireLayerToggle as Pattern;
|
|
24
|
+
state.visibleLayers[pattern] = !state.visibleLayers[pattern];
|
|
25
|
+
syncLayers(refs, state);
|
|
26
|
+
animateToState(refs, estimator, state);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function bindRadialMenu(refs: ViewRefs, state: ViewState, ui: Record<string, string>): void {
|
|
32
|
+
refs.radialMenu.querySelectorAll<HTMLButtonElement>('[data-fire-pattern]').forEach((button) => {
|
|
33
|
+
button.addEventListener('click', () => {
|
|
34
|
+
if (!state.pendingStart) return;
|
|
35
|
+
state.currentPattern = button.dataset.firePattern as Pattern;
|
|
36
|
+
state.draftVector = {
|
|
37
|
+
id: `vector-${Date.now()}`,
|
|
38
|
+
start: state.pendingStart,
|
|
39
|
+
end: state.pendingStart,
|
|
40
|
+
pattern: state.currentPattern,
|
|
41
|
+
confidence: 78,
|
|
42
|
+
};
|
|
43
|
+
closeRadialMenu(refs);
|
|
44
|
+
refs.modeLabel.textContent = `${ui.refiningVector}: ${ui[state.currentPattern] || state.currentPattern}`;
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function handlePointerMove(refs: ViewRefs, state: ViewState, event: PointerEvent): void {
|
|
50
|
+
state.pointer = pointFromPointer(refs.svg, event);
|
|
51
|
+
state.pointerClient = { x: event.offsetX, y: event.offsetY };
|
|
52
|
+
updatePointerOverlay(refs, state);
|
|
53
|
+
if (state.draftVector && state.pendingStart) {
|
|
54
|
+
state.draftVector = { ...state.draftVector, end: snapPoint(state.pendingStart, state.pointer) };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function bindCanvasInteractions(
|
|
59
|
+
refs: ViewRefs,
|
|
60
|
+
estimator: FireOriginEstimator,
|
|
61
|
+
state: ViewState,
|
|
62
|
+
ui: Record<string, string>,
|
|
63
|
+
): void {
|
|
64
|
+
refs.svg.addEventListener('pointermove', (event) => {
|
|
65
|
+
handlePointerMove(refs, state, event);
|
|
66
|
+
});
|
|
67
|
+
refs.svg.addEventListener('pointerdown', (event) => {
|
|
68
|
+
state.pointer = pointFromPointer(refs.svg, event);
|
|
69
|
+
state.pointerClient = { x: event.offsetX, y: event.offsetY };
|
|
70
|
+
updatePointerOverlay(refs, state);
|
|
71
|
+
if (state.draftVector) {
|
|
72
|
+
commitDraft(refs, estimator, state, ui);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
openRadialMenu(refs, state, ui, state.pointer);
|
|
76
|
+
});
|
|
77
|
+
refs.svg.addEventListener('pointerleave', () => {
|
|
78
|
+
refs.pointerOverlay.style.opacity = '0';
|
|
79
|
+
});
|
|
80
|
+
refs.svg.addEventListener('pointerenter', () => {
|
|
81
|
+
refs.pointerOverlay.style.opacity = '1';
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function bindShortcuts(refs: ViewRefs, estimator: FireOriginEstimator, state: ViewState, ui: Record<string, string>): void {
|
|
86
|
+
const shortcutMap: Record<string, Pattern> = { v: 'vPattern', d: 'deepChar', s: 'sootShadow', c: 'cleanBurn' };
|
|
87
|
+
|
|
88
|
+
document.addEventListener('keydown', (event) => {
|
|
89
|
+
if (event.key === 'Escape') {
|
|
90
|
+
state.draftVector = null;
|
|
91
|
+
state.pendingStart = null;
|
|
92
|
+
closeRadialMenu(refs);
|
|
93
|
+
refs.modeLabel.textContent = ui.awaitingVector;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const pattern = shortcutMap[event.key.toLowerCase()];
|
|
97
|
+
if (!state.hasLoadedPlan || !pattern) return;
|
|
98
|
+
state.visibleLayers[pattern] = !state.visibleLayers[pattern];
|
|
99
|
+
syncLayers(refs, state);
|
|
100
|
+
animateToState(refs, estimator, state);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function bindUpload(refs: ViewRefs, estimator: FireOriginEstimator, state: ViewState): void {
|
|
105
|
+
refs.upload.addEventListener('change', () => {
|
|
106
|
+
const file = refs.upload.files?.[0];
|
|
107
|
+
if (file) loadPlanFile(refs, estimator, state, file);
|
|
108
|
+
});
|
|
109
|
+
refs.dropzone.addEventListener('click', () => {
|
|
110
|
+
refs.upload.click();
|
|
111
|
+
});
|
|
112
|
+
['dragenter', 'dragover'].forEach((eventName) => {
|
|
113
|
+
refs.svg.addEventListener(eventName, (event) => {
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
refs.dropzone.hidden = false;
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
['dragleave', 'drop'].forEach((eventName) => {
|
|
119
|
+
refs.svg.addEventListener(eventName, (event) => {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
refs.dropzone.hidden = true;
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
refs.svg.addEventListener('drop', (event) => {
|
|
125
|
+
const file = event.dataTransfer?.files?.[0];
|
|
126
|
+
if (file) loadPlanFile(refs, estimator, state, file);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function bindAuxiliaryActions(refs: ViewRefs, estimator: FireOriginEstimator, state: ViewState, ui: Record<string, string>): void {
|
|
131
|
+
document.addEventListener('click', (event) => {
|
|
132
|
+
const target = event.target as Node;
|
|
133
|
+
if (!refs.radialMenu.hidden && !refs.radialMenu.contains(target) && !refs.svg.contains(target)) {
|
|
134
|
+
closeRadialMenu(refs);
|
|
135
|
+
if (!state.draftVector) refs.modeLabel.textContent = ui.awaitingVector;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
refs.exportButton.addEventListener('click', () => {
|
|
139
|
+
exportReport(state);
|
|
140
|
+
});
|
|
141
|
+
refs.resetButton.addEventListener('click', () => {
|
|
142
|
+
state.vectors = [];
|
|
143
|
+
state.draftVector = null;
|
|
144
|
+
state.pendingStart = null;
|
|
145
|
+
animateToState(refs, estimator, state);
|
|
146
|
+
refs.modeLabel.textContent = ui.awaitingVector;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function startRenderLoop(refs: ViewRefs, state: ViewState): void {
|
|
151
|
+
const render = (now: number) => {
|
|
152
|
+
refs.ctx.clearRect(0, 0, WIDTH, HEIGHT);
|
|
153
|
+
if (state.hasLoadedPlan) {
|
|
154
|
+
const frame = interpolatedState(state, now);
|
|
155
|
+
filteredVectors(state).forEach((vector: FireVector) => drawVector(refs.ctx, vector, 0.96));
|
|
156
|
+
if (state.draftVector) drawVector(refs.ctx, state.draftVector, 0.52);
|
|
157
|
+
drawHeatmap(refs.ctx, {
|
|
158
|
+
point: frame.origin,
|
|
159
|
+
radius: frame.radius,
|
|
160
|
+
confidence: frame.confidence,
|
|
161
|
+
intersections: frame.intersections,
|
|
162
|
+
}, now);
|
|
163
|
+
drawOriginMarker(refs.ctx, frame.origin);
|
|
164
|
+
updateOriginOverlay(refs, frame.origin);
|
|
165
|
+
}
|
|
166
|
+
state.animationFrame = requestAnimationFrame(render);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
state.animationFrame = requestAnimationFrame(render);
|
|
170
|
+
window.addEventListener('beforeunload', () => cancelAnimationFrame(state.animationFrame), { once: true });
|
|
171
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { FireOriginEstimator, FirePoint, FireVector } from './logic';
|
|
2
|
+
import { HEIGHT, TRANSITION_MS, WIDTH, clamp, ease, lerp, patternColor, toCanvas } from './render';
|
|
3
|
+
|
|
4
|
+
export type Pattern = FireVector['pattern'];
|
|
5
|
+
type PatternState = Record<Pattern, boolean>;
|
|
6
|
+
|
|
7
|
+
export interface AnimatedState {
|
|
8
|
+
origin: FirePoint;
|
|
9
|
+
radius: number;
|
|
10
|
+
confidence: number;
|
|
11
|
+
intersections: FirePoint[];
|
|
12
|
+
vectorCount: number;
|
|
13
|
+
statusKey: 'needsMoreVectors' | 'wideArea' | 'focusedArea';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ViewRefs {
|
|
17
|
+
root: HTMLElement;
|
|
18
|
+
svg: SVGSVGElement;
|
|
19
|
+
ctx: CanvasRenderingContext2D;
|
|
20
|
+
radialMenu: HTMLDivElement;
|
|
21
|
+
pointerOverlay: HTMLDivElement;
|
|
22
|
+
originOverlay: HTMLDivElement;
|
|
23
|
+
pointerCoordinate: HTMLElement;
|
|
24
|
+
originCoordinate: HTMLElement;
|
|
25
|
+
vectorCount: HTMLElement;
|
|
26
|
+
confidenceScore: HTMLElement;
|
|
27
|
+
variance: HTMLElement;
|
|
28
|
+
modeLabel: HTMLElement;
|
|
29
|
+
upload: HTMLInputElement;
|
|
30
|
+
exportButton: HTMLButtonElement;
|
|
31
|
+
resetButton: HTMLButtonElement;
|
|
32
|
+
dropzone: HTMLButtonElement;
|
|
33
|
+
planImage: SVGImageElement;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ViewState {
|
|
37
|
+
visibleLayers: PatternState;
|
|
38
|
+
vectors: FireVector[];
|
|
39
|
+
pendingStart: FirePoint | null;
|
|
40
|
+
draftVector: FireVector | null;
|
|
41
|
+
pointer: FirePoint;
|
|
42
|
+
pointerClient: FirePoint;
|
|
43
|
+
hasLoadedPlan: boolean;
|
|
44
|
+
currentPattern: Pattern;
|
|
45
|
+
importedPlanName: string;
|
|
46
|
+
renderStart: number;
|
|
47
|
+
animationFrame: number;
|
|
48
|
+
previousState: AnimatedState | null;
|
|
49
|
+
currentState: AnimatedState;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DEFAULT_STATE: AnimatedState = {
|
|
53
|
+
origin: { x: 50, y: 50 },
|
|
54
|
+
radius: 24,
|
|
55
|
+
confidence: 12,
|
|
56
|
+
intersections: [],
|
|
57
|
+
vectorCount: 0,
|
|
58
|
+
statusKey: 'needsMoreVectors',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function required<T>(value: T | null): T {
|
|
62
|
+
if (!value) throw new Error('Missing fire analyzer DOM node');
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function download(name: string, content: string, type: string): void {
|
|
67
|
+
const blob = new Blob([content], { type });
|
|
68
|
+
const url = URL.createObjectURL(blob);
|
|
69
|
+
const anchor = document.createElement('a');
|
|
70
|
+
anchor.href = url;
|
|
71
|
+
anchor.download = name;
|
|
72
|
+
anchor.click();
|
|
73
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getRefs(): ViewRefs | null {
|
|
77
|
+
try {
|
|
78
|
+
const canvas = required(document.getElementById('fire-overlay-canvas') as HTMLCanvasElement | null);
|
|
79
|
+
return {
|
|
80
|
+
root: required(document.querySelector<HTMLElement>('[data-fire-origin-tool]')),
|
|
81
|
+
svg: required(document.getElementById('fire-plan-svg') as SVGSVGElement | null),
|
|
82
|
+
ctx: required(canvas.getContext('2d')),
|
|
83
|
+
radialMenu: required(document.getElementById('fire-radial-menu') as HTMLDivElement | null),
|
|
84
|
+
pointerOverlay: required(document.getElementById('fire-pointer-overlay') as HTMLDivElement | null),
|
|
85
|
+
originOverlay: required(document.getElementById('fire-origin-overlay') as HTMLDivElement | null),
|
|
86
|
+
pointerCoordinate: required(document.getElementById('fire-pointer-coordinate')),
|
|
87
|
+
originCoordinate: required(document.getElementById('fire-origin-coordinate')),
|
|
88
|
+
vectorCount: required(document.getElementById('fire-vector-count')),
|
|
89
|
+
confidenceScore: required(document.getElementById('fire-confidence-score')),
|
|
90
|
+
variance: required(document.getElementById('fire-origin-variance')),
|
|
91
|
+
modeLabel: required(document.getElementById('fire-mode-label')),
|
|
92
|
+
upload: required(document.getElementById('fire-plan-upload') as HTMLInputElement | null),
|
|
93
|
+
exportButton: required(document.getElementById('fire-export') as HTMLButtonElement | null),
|
|
94
|
+
resetButton: required(document.getElementById('fire-reset') as HTMLButtonElement | null),
|
|
95
|
+
dropzone: required(document.getElementById('fire-dropzone') as HTMLButtonElement | null),
|
|
96
|
+
planImage: required(document.getElementById('fire-plan-image') as SVGImageElement | null),
|
|
97
|
+
};
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function createState(): ViewState {
|
|
104
|
+
return {
|
|
105
|
+
visibleLayers: { vPattern: true, deepChar: true, sootShadow: true, cleanBurn: true },
|
|
106
|
+
vectors: [],
|
|
107
|
+
pendingStart: null,
|
|
108
|
+
draftVector: null,
|
|
109
|
+
pointer: { x: 50, y: 50 },
|
|
110
|
+
pointerClient: { x: 0, y: 0 },
|
|
111
|
+
hasLoadedPlan: false,
|
|
112
|
+
currentPattern: 'vPattern',
|
|
113
|
+
importedPlanName: 'room-plan',
|
|
114
|
+
renderStart: performance.now(),
|
|
115
|
+
animationFrame: 0,
|
|
116
|
+
previousState: null,
|
|
117
|
+
currentState: { ...DEFAULT_STATE },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function filteredVectors(state: ViewState): FireVector[] {
|
|
122
|
+
if (!state.hasLoadedPlan) return [];
|
|
123
|
+
return state.vectors.filter((vector) => state.visibleLayers[vector.pattern]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function syncControls(refs: ViewRefs, enabled: boolean): void {
|
|
127
|
+
refs.resetButton.disabled = !enabled;
|
|
128
|
+
refs.exportButton.disabled = !enabled;
|
|
129
|
+
refs.root.dataset.hasPlan = enabled ? 'true' : 'false';
|
|
130
|
+
refs.pointerOverlay.hidden = !enabled;
|
|
131
|
+
refs.originOverlay.hidden = !enabled;
|
|
132
|
+
refs.root.querySelectorAll<HTMLButtonElement>('[data-fire-layer-toggle]').forEach((button) => {
|
|
133
|
+
button.disabled = !enabled;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function syncLayers(refs: ViewRefs, state: ViewState): void {
|
|
138
|
+
(Object.keys(state.visibleLayers) as Pattern[]).forEach((pattern) => {
|
|
139
|
+
refs.root.querySelectorAll<HTMLElement>(`[data-fire-layer-toggle="${pattern}"]`).forEach((node) => {
|
|
140
|
+
node.dataset.active = state.visibleLayers[pattern] ? 'true' : 'false';
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function animateToState(refs: ViewRefs, estimator: FireOriginEstimator, state: ViewState): void {
|
|
146
|
+
state.previousState = state.currentState;
|
|
147
|
+
state.currentState = estimator.estimate(filteredVectors(state));
|
|
148
|
+
state.renderStart = performance.now();
|
|
149
|
+
refs.vectorCount.textContent = String(state.currentState.vectorCount);
|
|
150
|
+
refs.confidenceScore.textContent = `${state.currentState.confidence.toFixed(1)}%`;
|
|
151
|
+
refs.variance.textContent = `+-${(state.currentState.radius / 10).toFixed(1)} m`;
|
|
152
|
+
refs.originCoordinate.textContent = `${state.currentState.origin.x.toFixed(1)}, ${state.currentState.origin.y.toFixed(1)}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function interpolatedState(state: ViewState, now: number): AnimatedState {
|
|
156
|
+
if (!state.previousState) return state.currentState;
|
|
157
|
+
const progress = ease(clamp((now - state.renderStart) / TRANSITION_MS, 0, 1));
|
|
158
|
+
return {
|
|
159
|
+
origin: {
|
|
160
|
+
x: lerp(state.previousState.origin.x, state.currentState.origin.x, progress),
|
|
161
|
+
y: lerp(state.previousState.origin.y, state.currentState.origin.y, progress),
|
|
162
|
+
},
|
|
163
|
+
radius: lerp(state.previousState.radius, state.currentState.radius, progress),
|
|
164
|
+
confidence: lerp(state.previousState.confidence, state.currentState.confidence, progress),
|
|
165
|
+
intersections: state.currentState.intersections,
|
|
166
|
+
vectorCount: state.currentState.vectorCount,
|
|
167
|
+
statusKey: state.currentState.statusKey,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function updatePointerOverlay(refs: ViewRefs, state: ViewState): void {
|
|
172
|
+
refs.pointerCoordinate.textContent = `${state.pointer.x.toFixed(1)}, ${state.pointer.y.toFixed(1)}`;
|
|
173
|
+
refs.pointerOverlay.style.left = `${state.pointerClient.x + 18}px`;
|
|
174
|
+
refs.pointerOverlay.style.top = `${state.pointerClient.y - 24}px`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function updateOriginOverlay(refs: ViewRefs, point: FirePoint): void {
|
|
178
|
+
const screen = toCanvas(point);
|
|
179
|
+
refs.originOverlay.style.left = `${screen.x + 18}px`;
|
|
180
|
+
refs.originOverlay.style.top = `${screen.y + 18}px`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function closeRadialMenu(refs: ViewRefs): void {
|
|
184
|
+
refs.radialMenu.hidden = true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function openRadialMenu(refs: ViewRefs, state: ViewState, ui: Record<string, string>, point: FirePoint): void {
|
|
188
|
+
if (!state.hasLoadedPlan) return;
|
|
189
|
+
state.pendingStart = point;
|
|
190
|
+
const canvasPoint = toCanvas(point);
|
|
191
|
+
refs.radialMenu.style.left = `${canvasPoint.x}px`;
|
|
192
|
+
refs.radialMenu.style.top = `${canvasPoint.y}px`;
|
|
193
|
+
refs.radialMenu.hidden = false;
|
|
194
|
+
refs.modeLabel.textContent = ui.selectIndicator;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function commitDraft(refs: ViewRefs, estimator: FireOriginEstimator, state: ViewState, ui: Record<string, string>): void {
|
|
198
|
+
if (!state.draftVector) return;
|
|
199
|
+
state.vectors.push(state.draftVector);
|
|
200
|
+
state.draftVector = null;
|
|
201
|
+
state.pendingStart = null;
|
|
202
|
+
animateToState(refs, estimator, state);
|
|
203
|
+
refs.modeLabel.textContent = ui.awaitingVector;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function loadPlanFile(refs: ViewRefs, estimator: FireOriginEstimator, state: ViewState, file: File): void {
|
|
207
|
+
state.importedPlanName = file.name.replace(/\.[^.]+$/, '') || 'room-plan';
|
|
208
|
+
refs.planImage.setAttribute('href', URL.createObjectURL(file));
|
|
209
|
+
state.hasLoadedPlan = true;
|
|
210
|
+
refs.dropzone.hidden = true;
|
|
211
|
+
refs.dropzone.setAttribute('aria-hidden', 'true');
|
|
212
|
+
syncControls(refs, true);
|
|
213
|
+
animateToState(refs, estimator, state);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function exportReport(state: ViewState): void {
|
|
217
|
+
const payload = {
|
|
218
|
+
exportedAt: new Date().toISOString(),
|
|
219
|
+
plan: state.importedPlanName,
|
|
220
|
+
origin: state.currentState.origin,
|
|
221
|
+
confidence: Number(state.currentState.confidence.toFixed(1)),
|
|
222
|
+
varianceMeters: Number((state.currentState.radius / 10).toFixed(1)),
|
|
223
|
+
vectorCount: state.currentState.vectorCount,
|
|
224
|
+
vectors: state.vectors,
|
|
225
|
+
visibleLayers: state.visibleLayers,
|
|
226
|
+
};
|
|
227
|
+
const vectorMarkup = state.vectors.map((vector) => {
|
|
228
|
+
const start = toCanvas(vector.start);
|
|
229
|
+
const end = toCanvas(vector.end);
|
|
230
|
+
return `<line x1="${start.x.toFixed(2)}" y1="${start.y.toFixed(2)}" x2="${end.x.toFixed(2)}" y2="${end.y.toFixed(2)}" stroke="${patternColor(vector.pattern)}" stroke-width="4" stroke-linecap="round" />`;
|
|
231
|
+
}).join('');
|
|
232
|
+
const origin = toCanvas(state.currentState.origin);
|
|
233
|
+
const report = `<?xml version="1.0" encoding="UTF-8"?>
|
|
234
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${HEIGHT}">
|
|
235
|
+
<metadata>${JSON.stringify(payload)}</metadata>
|
|
236
|
+
<rect width="${WIDTH}" height="${HEIGHT}" fill="#f7f2ea" />
|
|
237
|
+
${vectorMarkup}
|
|
238
|
+
<circle cx="${origin.x.toFixed(2)}" cy="${origin.y.toFixed(2)}" r="10" fill="#161e28" stroke="#fff7ed" stroke-width="3" />
|
|
239
|
+
<text x="24" y="${HEIGHT - 26}" font-family="IBM Plex Mono, monospace" font-size="16" fill="#16314a">
|
|
240
|
+
VECTORS ${state.currentState.vectorCount} | CONFIDENCE ${state.currentState.confidence.toFixed(1)}% | VARIANCE +-${(state.currentState.radius / 10).toFixed(1)} m
|
|
241
|
+
</text>
|
|
242
|
+
</svg>`;
|
|
243
|
+
download(`${state.importedPlanName}-fire-origin-report.svg`, report, 'image/svg+xml');
|
|
244
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { FireOriginEstimator } from './logic';
|
|
2
|
+
import {
|
|
3
|
+
bindAuxiliaryActions,
|
|
4
|
+
bindCanvasInteractions,
|
|
5
|
+
bindLayerToggles,
|
|
6
|
+
bindRadialMenu,
|
|
7
|
+
bindShortcuts,
|
|
8
|
+
bindUpload,
|
|
9
|
+
startRenderLoop,
|
|
10
|
+
} from './view-bindings';
|
|
11
|
+
import { createState, getRefs, syncControls, syncLayers, updatePointerOverlay } from './view-model';
|
|
12
|
+
|
|
13
|
+
export function initFirePatternOriginAnalyzer(ui: Record<string, string>): void {
|
|
14
|
+
const refs = getRefs();
|
|
15
|
+
if (!refs) return;
|
|
16
|
+
|
|
17
|
+
const estimator = new FireOriginEstimator();
|
|
18
|
+
const state = createState();
|
|
19
|
+
|
|
20
|
+
syncControls(refs, false);
|
|
21
|
+
syncLayers(refs, state);
|
|
22
|
+
updatePointerOverlay(refs, state);
|
|
23
|
+
bindLayerToggles(refs, estimator, state);
|
|
24
|
+
bindRadialMenu(refs, state, ui);
|
|
25
|
+
bindCanvasInteractions(refs, estimator, state, ui);
|
|
26
|
+
bindShortcuts(refs, estimator, state, ui);
|
|
27
|
+
bindUpload(refs, estimator, state);
|
|
28
|
+
bindAuxiliaryActions(refs, estimator, state, ui);
|
|
29
|
+
startRenderLoop(refs, state);
|
|
30
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'AFTE Theory of Identification as it Relates to Toolmarks.',
|
|
6
|
+
url: 'https://temp.afte.org/about-us/what-is-afte/afte-theory-of-identification',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'OSAC Registry: Firearms and Toolmarks Subcommittee standards and guidelines.',
|
|
10
|
+
url: 'https://www.nist.gov/organization-scientific-area-committees-forensic-science/firearms-toolmarks-subcommittee',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'National Research Council. Strengthening Forensic Science in the United States: A Path Forward.',
|
|
14
|
+
url: 'https://nap.nationalacademies.org/catalog/12589/strengthening-forensic-science-in-the-united-states-a-path-forward',
|
|
15
|
+
},
|
|
16
|
+
];
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './forensic-toolmark-striation-matcher.css';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
ui: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { ui } = Astro.props;
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<section class="toolmark-shell" data-toolmark-tool>
|
|
12
|
+
<div class="toolmark-stage">
|
|
13
|
+
<div class="toolmark-view-wrap">
|
|
14
|
+
<div class="toolmark-view" aria-label={ui.viewerAria}>
|
|
15
|
+
<canvas id="toolmark-canvas" width="980" height="640"></canvas>
|
|
16
|
+
<input id="toolmark-split" class="toolmark-split" type="range" min="22" max="78" value="50" aria-label={ui.splitLabel} />
|
|
17
|
+
<div class="toolmark-handle" aria-hidden="true">
|
|
18
|
+
<span></span>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="toolmark-score" id="toolmark-score-card">
|
|
21
|
+
<span>{ui.correlation}</span>
|
|
22
|
+
<strong id="toolmark-correlation">{ui.emptyValue}</strong>
|
|
23
|
+
<small id="toolmark-verdict">{ui.emptyValue}</small>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="toolmark-strip" aria-hidden="true">
|
|
27
|
+
<span></span>
|
|
28
|
+
<span></span>
|
|
29
|
+
<span></span>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<aside class="toolmark-controls">
|
|
34
|
+
<div class="toolmark-panel">
|
|
35
|
+
<span class="toolmark-panel-label">{ui.loadImages}</span>
|
|
36
|
+
<label class="toolmark-upload">
|
|
37
|
+
<span>{ui.uploadKnown}</span>
|
|
38
|
+
<strong id="toolmark-known-name">{ui.noFile}</strong>
|
|
39
|
+
<input id="toolmark-known-file" type="file" accept="image/*" />
|
|
40
|
+
</label>
|
|
41
|
+
<label class="toolmark-upload">
|
|
42
|
+
<span>{ui.uploadQuestioned}</span>
|
|
43
|
+
<strong id="toolmark-questioned-name">{ui.noFile}</strong>
|
|
44
|
+
<input id="toolmark-questioned-file" type="file" accept="image/*" />
|
|
45
|
+
</label>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="toolmark-panel">
|
|
49
|
+
<div class="toolmark-panel-head">
|
|
50
|
+
<span class="toolmark-panel-label">{ui.alignImage}</span>
|
|
51
|
+
<div class="toolmark-unit-toggle" role="group" aria-label={ui.units}>
|
|
52
|
+
<button type="button" data-toolmark-unit="metric" data-active="true">{ui.metric}</button>
|
|
53
|
+
<button type="button" data-toolmark-unit="imperial" data-active="false">{ui.imperial}</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
<label class="toolmark-slider">
|
|
57
|
+
<span><b>{ui.offset}</b><strong id="toolmark-offset-label">-</strong></span>
|
|
58
|
+
<input id="toolmark-offset" type="range" min="-180" max="180" value="34" step="1" />
|
|
59
|
+
</label>
|
|
60
|
+
|
|
61
|
+
<label class="toolmark-slider">
|
|
62
|
+
<span><b>{ui.rotation}</b><strong id="toolmark-rotation-label">-</strong></span>
|
|
63
|
+
<input id="toolmark-rotation" type="range" min="-12" max="12" value="2.5" step="0.1" />
|
|
64
|
+
</label>
|
|
65
|
+
|
|
66
|
+
<label class="toolmark-slider">
|
|
67
|
+
<span><b>{ui.zoom}</b><strong id="toolmark-zoom-label">-</strong></span>
|
|
68
|
+
<input id="toolmark-zoom" type="range" min="50" max="260" value="100" step="2" />
|
|
69
|
+
</label>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div class="toolmark-panel">
|
|
73
|
+
<span class="toolmark-panel-label">{ui.adjustImage}</span>
|
|
74
|
+
<div class="toolmark-tune">
|
|
75
|
+
<label>
|
|
76
|
+
<span>{ui.contrast}</span>
|
|
77
|
+
<input id="toolmark-contrast" type="range" min="50" max="180" value="108" step="1" />
|
|
78
|
+
</label>
|
|
79
|
+
<label>
|
|
80
|
+
<span>{ui.brightness}</span>
|
|
81
|
+
<input id="toolmark-brightness" type="range" min="55" max="150" value="100" step="1" />
|
|
82
|
+
</label>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div class="toolmark-actions" aria-label={ui.outputControls}>
|
|
87
|
+
<button type="button" id="toolmark-reset">{ui.center}</button>
|
|
88
|
+
<button type="button" id="toolmark-grid" data-active="true">{ui.grid}</button>
|
|
89
|
+
<button type="button" id="toolmark-export">{ui.exportImage}</button>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<details class="toolmark-demo">
|
|
93
|
+
<summary>{ui.demoOptions}</summary>
|
|
94
|
+
<div class="toolmark-profile" role="group" aria-label={ui.toolProfile}>
|
|
95
|
+
<button type="button" data-toolmark-profile="screwdriver" data-active="true"><span>{ui.screwdriver}</span></button>
|
|
96
|
+
<button type="button" data-toolmark-profile="prybar" data-active="false"><span>{ui.prybar}</span></button>
|
|
97
|
+
<button type="button" data-toolmark-profile="boltCutter" data-active="false"><span>{ui.boltCutter}</span></button>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="toolmark-bars">
|
|
100
|
+
<div>
|
|
101
|
+
<span>{ui.phaseFit}<strong id="toolmark-phase-label">{ui.zeroPercent}</strong></span>
|
|
102
|
+
<meter id="toolmark-phase" min="0" max="100" value="0"></meter>
|
|
103
|
+
</div>
|
|
104
|
+
<div>
|
|
105
|
+
<span>{ui.rotationFit}<strong id="toolmark-rotation-fit-label">{ui.zeroPercent}</strong></span>
|
|
106
|
+
<meter id="toolmark-rotation-fit" min="0" max="100" value="0"></meter>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</details>
|
|
110
|
+
|
|
111
|
+
<p class="toolmark-interpretation" id="toolmark-interpretation" aria-live="polite">{ui.emptyValue}</p>
|
|
112
|
+
</aside>
|
|
113
|
+
</div>
|
|
114
|
+
</section>
|
|
115
|
+
|
|
116
|
+
<script id="toolmark-ui" type="application/json" set:html={JSON.stringify(ui)}></script>
|
|
117
|
+
<script>
|
|
118
|
+
import { initToolmarkStriationMatcher } from './view';
|
|
119
|
+
|
|
120
|
+
initToolmarkStriationMatcher(JSON.parse(document.getElementById('toolmark-ui')?.textContent || '{}'));
|
|
121
|
+
</script>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ScienceToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
|
|
3
|
+
export interface ToolmarkStriationMatcherUI {
|
|
4
|
+
[key: string]: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type ToolmarkStriationMatcherLocaleContent = ToolLocaleContent<ToolmarkStriationMatcherUI>;
|
|
8
|
+
|
|
9
|
+
export const forensicToolmarkStriationMatcher: ScienceToolEntry<ToolmarkStriationMatcherUI> = {
|
|
10
|
+
id: 'forensic-toolmark-striation-matcher',
|
|
11
|
+
icons: {
|
|
12
|
+
bg: 'mdi:tools',
|
|
13
|
+
fg: 'mdi:vector-polyline',
|
|
14
|
+
},
|
|
15
|
+
i18n: {
|
|
16
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
17
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
18
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
19
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
20
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
21
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
22
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
23
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
24
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
25
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
26
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
27
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
28
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
29
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
30
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
31
|
+
},
|
|
32
|
+
};
|