@jjlmoya/utils-forensic-science 1.3.0 → 1.4.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 +3 -1
- package/src/entries.ts +5 -1
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/forensic-fingerprint-minutiae-identifier/bibliography.astro +6 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/bibliography.ts +16 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/component.astro +429 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/drawer.ts +94 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/entry.ts +32 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/forensic-fingerprint-minutiae-identifier.css +969 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/de.ts +298 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/en.ts +289 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/es.ts +291 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/fr.ts +298 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/id.ts +298 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/it.ts +298 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/ja.ts +281 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/ko.ts +281 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/nl.ts +298 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/pl.ts +298 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/pt.ts +298 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/ru.ts +298 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/sv.ts +298 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/tr.ts +298 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/zh.ts +281 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/index.ts +11 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/logic.ts +168 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/renderer.ts +116 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/seo.astro +15 -0
- package/src/tools.ts +3 -1
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { forensicMicrocrystalDrugSimulator } from '../tool/forensic-microcrystal
|
|
|
11
11
|
import { forensicGlassBeckeLineSimulator } from '../tool/forensic-glass-becke-line-simulator/index';
|
|
12
12
|
import { forensicFiberComparisonMicroscope } from '../tool/forensic-fiber-comparison-microscope/index';
|
|
13
13
|
import { bloodstainPatternOriginAnalyzer } from '../tool/bloodstain-pattern-origin-analyzer/index';
|
|
14
|
+
import { forensicFingerprintMinutiaeIdentifier } from '../tool/forensic-fingerprint-minutiae-identifier/index';
|
|
14
15
|
|
|
15
16
|
export const forensicCategory: ScienceCategoryEntry = {
|
|
16
17
|
icon: 'mdi:fingerprint',
|
|
@@ -26,7 +27,8 @@ export const forensicCategory: ScienceCategoryEntry = {
|
|
|
26
27
|
forensicMicrocrystalDrugSimulator,
|
|
27
28
|
forensicGlassBeckeLineSimulator,
|
|
28
29
|
forensicFiberComparisonMicroscope,
|
|
29
|
-
bloodstainPatternOriginAnalyzer
|
|
30
|
+
bloodstainPatternOriginAnalyzer,
|
|
31
|
+
forensicFingerprintMinutiaeIdentifier
|
|
30
32
|
],
|
|
31
33
|
i18n: {
|
|
32
34
|
de: () => import('./i18n/de').then((m) => m.content),
|
package/src/entries.ts
CHANGED
|
@@ -22,6 +22,8 @@ export { forensicFiberComparisonMicroscope } from './tool/forensic-fiber-compari
|
|
|
22
22
|
export type { FiberComparisonMicroscopeUI, FiberComparisonMicroscopeLocaleContent } from './tool/forensic-fiber-comparison-microscope/entry';
|
|
23
23
|
export { bloodstainPatternOriginAnalyzer } from './tool/bloodstain-pattern-origin-analyzer/entry';
|
|
24
24
|
export type { BloodstainPatternUI, BloodstainPatternLocaleContent } from './tool/bloodstain-pattern-origin-analyzer/entry';
|
|
25
|
+
export { forensicFingerprintMinutiaeIdentifier } from './tool/forensic-fingerprint-minutiae-identifier/entry';
|
|
26
|
+
export type { FingerprintMinutiaeUI, FingerprintMinutiaeLocaleContent } from './tool/forensic-fingerprint-minutiae-identifier/entry';
|
|
25
27
|
|
|
26
28
|
import { forensicAgeEstimator } from './tool/forensic-age-estimator/entry';
|
|
27
29
|
import { widmarkAlcoholSimulator } from './tool/widmark-alcohol-simulator/entry';
|
|
@@ -35,6 +37,7 @@ import { forensicMicrocrystalDrugSimulator } from './tool/forensic-microcrystal-
|
|
|
35
37
|
import { forensicGlassBeckeLineSimulator } from './tool/forensic-glass-becke-line-simulator/entry';
|
|
36
38
|
import { forensicFiberComparisonMicroscope } from './tool/forensic-fiber-comparison-microscope/entry';
|
|
37
39
|
import { bloodstainPatternOriginAnalyzer } from './tool/bloodstain-pattern-origin-analyzer/entry';
|
|
40
|
+
import { forensicFingerprintMinutiaeIdentifier } from './tool/forensic-fingerprint-minutiae-identifier/entry';
|
|
38
41
|
|
|
39
42
|
export const ALL_ENTRIES = [
|
|
40
43
|
forensicAgeEstimator,
|
|
@@ -48,5 +51,6 @@ export const ALL_ENTRIES = [
|
|
|
48
51
|
forensicMicrocrystalDrugSimulator,
|
|
49
52
|
forensicGlassBeckeLineSimulator,
|
|
50
53
|
forensicFiberComparisonMicroscope,
|
|
51
|
-
bloodstainPatternOriginAnalyzer
|
|
54
|
+
bloodstainPatternOriginAnalyzer,
|
|
55
|
+
forensicFingerprintMinutiaeIdentifier
|
|
52
56
|
];
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export { FORENSIC_MICROCRYSTAL_DRUG_SIMULATOR_TOOL } from './tool/forensic-micro
|
|
|
12
12
|
export { FORENSIC_GLASS_BECKE_LINE_SIMULATOR_TOOL } from './tool/forensic-glass-becke-line-simulator/index';
|
|
13
13
|
export { FORENSIC_FIBER_COMPARISON_MICROSCOPE_TOOL } from './tool/forensic-fiber-comparison-microscope/index';
|
|
14
14
|
export { BLOODSTAIN_PATTERN_ORIGIN_ANALYZER_TOOL } from './tool/bloodstain-pattern-origin-analyzer/index';
|
|
15
|
+
export { FORENSIC_FINGERPRINT_MINUTIAE_IDENTIFIER_TOOL } from './tool/forensic-fingerprint-minutiae-identifier/index';
|
|
15
16
|
|
|
16
17
|
export type {
|
|
17
18
|
KnownLocale,
|
|
@@ -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
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
7
|
+
it('should have 13 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(13);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('scienceCategory should be defined', () => {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'FBI. The Science of Fingerprints: Classification and Uses.',
|
|
6
|
+
url: 'https://www.ojp.gov/ncjrs/virtual-library/abstracts/science-fingerprints-classification-and-uses',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'NIST Special Publication 500-290: Data Format for the Interchange of Fingerprint, Facial & Other Biometric Information.',
|
|
10
|
+
url: 'https://www.nist.gov/publications/data-format-interchange-fingerprint-facial-other-biometric-information-ansinist-itl-1-1',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'SWGFAST. Standards for Examining Friction Ridge Impressions and Resulting Conclusions.',
|
|
14
|
+
url: 'https://www.nist.gov/system/files/documents/2016/10/26/swgfast_examinations-conclusions_2.0_130427.pdf',
|
|
15
|
+
},
|
|
16
|
+
];
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Icon } from 'astro-icon/components';
|
|
3
|
+
import './forensic-fingerprint-minutiae-identifier.css';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
ui: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { ui } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<section class="fp-shell" data-fingerprint-tool>
|
|
13
|
+
<div class="fp-board">
|
|
14
|
+
<div class="fp-workflow" aria-label={ui.workflow}>
|
|
15
|
+
<span data-fp-step="1" data-active="true"><b>1</b>{ui.stepUpload}</span>
|
|
16
|
+
<span data-fp-step="2"><b>2</b>{ui.stepClassify}</span>
|
|
17
|
+
<span data-fp-step="3"><b>3</b>{ui.stepMark}</span>
|
|
18
|
+
<span data-fp-step="4"><b>4</b>{ui.stepReview}</span>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div class="fp-canvas-panel">
|
|
22
|
+
<div class="fp-upload" id="fp-upload">
|
|
23
|
+
<Icon name="mdi:cloud-upload-outline" aria-hidden="true" />
|
|
24
|
+
<strong>{ui.uploadTitle}</strong>
|
|
25
|
+
<span>{ui.uploadHint}</span>
|
|
26
|
+
<input id="fp-file" type="file" accept="image/*" aria-label={ui.uploadTitle} />
|
|
27
|
+
</div>
|
|
28
|
+
<canvas id="fp-canvas" width="920" height="620" aria-label={ui.canvasAria}></canvas>
|
|
29
|
+
<div class="fp-glass" id="fp-glass" aria-hidden="true"></div>
|
|
30
|
+
<div class="fp-canvas-hint" id="fp-canvas-hint">
|
|
31
|
+
<strong id="fp-canvas-hint-title">-</strong>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="fp-canvas-actions">
|
|
34
|
+
<button type="button" id="fp-replace"><Icon name="mdi:image-sync-outline" aria-hidden="true" />{ui.replaceImage}</button>
|
|
35
|
+
<button type="button" id="fp-clear"><Icon name="mdi:eraser" aria-hidden="true" />{ui.clearMarks}</button>
|
|
36
|
+
<button type="button" id="fp-export"><Icon name="mdi:clipboard-text-outline" aria-hidden="true" />{ui.copyEvidence}</button>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div class="fp-controls">
|
|
41
|
+
<div class="fp-step-panel" data-fp-panel="1">
|
|
42
|
+
<div class="fp-step-heading">
|
|
43
|
+
<span id="fp-stage-label">-</span>
|
|
44
|
+
<strong id="fp-stage-title">-</strong>
|
|
45
|
+
<p id="fp-stage-copy">-</p>
|
|
46
|
+
</div>
|
|
47
|
+
<button type="button" class="fp-primary-action" id="fp-upload-action"><Icon name="mdi:cloud-upload-outline" aria-hidden="true" />{ui.uploadTitle}</button>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="fp-step-panel" data-fp-panel="2">
|
|
51
|
+
<div class="fp-step-heading">
|
|
52
|
+
<span>{ui.stepLabel} 2/4</span>
|
|
53
|
+
<strong>{ui.stage2Title}</strong>
|
|
54
|
+
<p>{ui.stage2Copy}</p>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div class="fp-selector" role="group" aria-label={ui.patternType}>
|
|
58
|
+
<button type="button" data-fp-pattern="arch">{ui.arch}</button>
|
|
59
|
+
<button type="button" data-fp-pattern="loop">{ui.loop}</button>
|
|
60
|
+
<button type="button" data-fp-pattern="whorl">{ui.whorl}</button>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div class="fp-compact-sliders">
|
|
64
|
+
<label class="fp-slider">
|
|
65
|
+
<span>{ui.coreOffset}</span>
|
|
66
|
+
<strong id="fp-core-label">0</strong>
|
|
67
|
+
<input id="fp-core" type="range" min="-36" max="36" value="12" />
|
|
68
|
+
</label>
|
|
69
|
+
|
|
70
|
+
<label class="fp-slider">
|
|
71
|
+
<span>{ui.deltaCount}</span>
|
|
72
|
+
<strong id="fp-delta-label">1</strong>
|
|
73
|
+
<input id="fp-delta" type="range" min="0" max="2" value="1" step="1" />
|
|
74
|
+
</label>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div class="fp-readouts">
|
|
78
|
+
<div>
|
|
79
|
+
<span>{ui.henryClass}</span>
|
|
80
|
+
<strong id="fp-class">-</strong>
|
|
81
|
+
</div>
|
|
82
|
+
<div>
|
|
83
|
+
<span>{ui.ridgeFlow}</span>
|
|
84
|
+
<strong id="fp-flow-compact">-</strong>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<button type="button" class="fp-primary-action" data-fp-next="3"><Icon name="mdi:arrow-right" aria-hidden="true" />{ui.continueMarking}</button>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div class="fp-step-panel" data-fp-panel="3">
|
|
92
|
+
<div class="fp-step-heading">
|
|
93
|
+
<span>{ui.stepLabel} 3/4</span>
|
|
94
|
+
<strong>{ui.stage3Title}</strong>
|
|
95
|
+
<p>{ui.stage3Copy}</p>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div class="fp-legend" aria-label={ui.legendAria}>
|
|
99
|
+
<button type="button" data-fp-minutia="ridgeEnding"><i></i><span>{ui.ridgeEnding}</span><small>{ui.ridgeEndingShort}</small><b id="fp-count-ridgeEnding">0/5</b></button>
|
|
100
|
+
<button type="button" data-fp-minutia="bifurcation"><i></i><span>{ui.bifurcation}</span><small>{ui.bifurcationShort}</small><b id="fp-count-bifurcation">0/3</b></button>
|
|
101
|
+
<button type="button" data-fp-minutia="island"><i></i><span>{ui.island}</span><small>{ui.islandShort}</small><b id="fp-count-island">0/2</b></button>
|
|
102
|
+
<button type="button" data-fp-minutia="dot"><i></i><span>{ui.dot}</span><small>{ui.dotShort}</small><b id="fp-count-dot">0/2</b></button>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div class="fp-active-minutia" id="fp-active-minutia">
|
|
106
|
+
<span id="fp-active-minutia-label">-</span>
|
|
107
|
+
<strong id="fp-active-minutia-title">-</strong>
|
|
108
|
+
<p id="fp-active-minutia-copy">-</p>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div class="fp-mark-actions">
|
|
112
|
+
<button type="button" id="fp-undo"><Icon name="mdi:undo" aria-hidden="true" />{ui.undoLast}</button>
|
|
113
|
+
<button type="button" id="fp-delete-selected"><Icon name="mdi:trash-can-outline" aria-hidden="true" />{ui.deleteSelected}</button>
|
|
114
|
+
<button type="button" id="fp-clear-step"><Icon name="mdi:eraser" aria-hidden="true" />{ui.clearMarks}</button>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div class="fp-readouts">
|
|
118
|
+
<div>
|
|
119
|
+
<span>{ui.validation}</span>
|
|
120
|
+
<strong id="fp-score">-</strong>
|
|
121
|
+
</div>
|
|
122
|
+
<div>
|
|
123
|
+
<span>{ui.totalMarks}</span>
|
|
124
|
+
<strong id="fp-total">0</strong>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<button type="button" class="fp-primary-action" data-fp-next="4" id="fp-review-action"><Icon name="mdi:clipboard-check-outline" aria-hidden="true" />{ui.reviewEvidence}</button>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div class="fp-step-panel" data-fp-panel="4">
|
|
132
|
+
<div class="fp-step-heading">
|
|
133
|
+
<span>{ui.stepLabel} 4/4</span>
|
|
134
|
+
<strong>{ui.stage4Title}</strong>
|
|
135
|
+
<p>{ui.stage4Copy}</p>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div class="fp-readouts">
|
|
139
|
+
<div>
|
|
140
|
+
<span>{ui.henryClass}</span>
|
|
141
|
+
<strong id="fp-class-review">-</strong>
|
|
142
|
+
</div>
|
|
143
|
+
<div>
|
|
144
|
+
<span>{ui.validation}</span>
|
|
145
|
+
<strong id="fp-score-review">-</strong>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
<button type="button" class="fp-primary-action" id="fp-export-panel"><Icon name="mdi:clipboard-text-outline" aria-hidden="true" />{ui.copyEvidence}</button>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div class="fp-step-nav">
|
|
152
|
+
<button type="button" data-fp-prev><Icon name="mdi:arrow-left" aria-hidden="true" />{ui.back}</button>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div class="fp-evidence">
|
|
157
|
+
<div class="fp-flow">
|
|
158
|
+
<span>{ui.ridgeFlow}</span>
|
|
159
|
+
<strong id="fp-flow">-</strong>
|
|
160
|
+
</div>
|
|
161
|
+
<table>
|
|
162
|
+
<thead>
|
|
163
|
+
<tr>
|
|
164
|
+
<th>{ui.type}</th>
|
|
165
|
+
<th>{ui.position}</th>
|
|
166
|
+
<th>{ui.action}</th>
|
|
167
|
+
</tr>
|
|
168
|
+
</thead>
|
|
169
|
+
<tbody id="fp-table"></tbody>
|
|
170
|
+
</table>
|
|
171
|
+
<p id="fp-checklist" class="fp-checklist">-</p>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</section>
|
|
175
|
+
|
|
176
|
+
<template id="fp-trash-icon">
|
|
177
|
+
<Icon name="mdi:trash-can-outline" aria-hidden="true" />
|
|
178
|
+
</template>
|
|
179
|
+
<script id="fp-ui" type="application/json" set:html={JSON.stringify(ui)}></script>
|
|
180
|
+
<script>
|
|
181
|
+
import { FingerprintClassifier, MinutiaeState, colors, limits } from './logic';
|
|
182
|
+
import type { FingerprintPattern, MinutiaType } from './logic';
|
|
183
|
+
import { drawPlaceholder, drawUploadedImage, drawMark } from './drawer';
|
|
184
|
+
import {
|
|
185
|
+
updateWorkflow,
|
|
186
|
+
updateUIStates,
|
|
187
|
+
updateActionButtons,
|
|
188
|
+
updateClassifierLabels,
|
|
189
|
+
updateMinutiaeLabels,
|
|
190
|
+
renderTable,
|
|
191
|
+
} from './renderer';
|
|
192
|
+
|
|
193
|
+
const ui = JSON.parse(document.getElementById('fp-ui')?.textContent || '{}');
|
|
194
|
+
const board = document.querySelector<HTMLElement>('[data-fingerprint-tool] .fp-board');
|
|
195
|
+
const canvas = document.getElementById('fp-canvas') as HTMLCanvasElement | null;
|
|
196
|
+
const context = canvas?.getContext('2d') ?? null;
|
|
197
|
+
const glass = document.getElementById('fp-glass');
|
|
198
|
+
const uploadPanel = document.getElementById('fp-upload');
|
|
199
|
+
const fileInput = document.getElementById('fp-file') as HTMLInputElement | null;
|
|
200
|
+
const coreInput = document.getElementById('fp-core') as HTMLInputElement | null, deltaInput = document.getElementById('fp-delta') as HTMLInputElement | null;
|
|
201
|
+
const tableBody = document.getElementById('fp-table'), trashIconTemplate = document.getElementById('fp-trash-icon') as HTMLTemplateElement | null;
|
|
202
|
+
const state = new MinutiaeState(), classifier = new FingerprintClassifier();
|
|
203
|
+
const STORAGE_KEY = 'forensic-fingerprint-minutiae-identifier';
|
|
204
|
+
let activePattern: FingerprintPattern = 'loop';
|
|
205
|
+
let selectedMinutia: MinutiaType = 'ridgeEnding';
|
|
206
|
+
let uploadedImage: HTMLImageElement | null = null;
|
|
207
|
+
let uploadedImageData = '', activeStep = 1, selectedMarkId = '';
|
|
208
|
+
let imageRect = { x: 0, y: 0, width: 0, height: 0 };
|
|
209
|
+
|
|
210
|
+
function activeMinutia(): MinutiaType {
|
|
211
|
+
return selectedMinutia;
|
|
212
|
+
}
|
|
213
|
+
function drawPattern(): void {
|
|
214
|
+
if (!canvas || !context) return;
|
|
215
|
+
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
216
|
+
if (uploadedImage) {
|
|
217
|
+
imageRect = drawUploadedImage(canvas, context, uploadedImage);
|
|
218
|
+
} else {
|
|
219
|
+
const core = Number(coreInput?.value ?? 12);
|
|
220
|
+
drawPlaceholder(canvas, context, activePattern, core);
|
|
221
|
+
}
|
|
222
|
+
state.list().forEach((mark) => {
|
|
223
|
+
const label = ui[`${mark.type}Label`] ?? { ridgeEnding: 'E', bifurcation: 'B', island: 'I', dot: 'D' }[mark.type];
|
|
224
|
+
drawMark({ canvas, context, mark, selectedMarkId, color: colors[mark.type], label });
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
function saveState(): void {
|
|
228
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
|
229
|
+
pattern: activePattern,
|
|
230
|
+
core: Number(coreInput?.value ?? 12),
|
|
231
|
+
delta: Number(deltaInput?.value ?? 1),
|
|
232
|
+
marks: state.list(),
|
|
233
|
+
uploadedImageData,
|
|
234
|
+
activeStep,
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
237
|
+
function clampStep(step: number): number {
|
|
238
|
+
if (!uploadedImage) return 1;
|
|
239
|
+
return Math.max(2, Math.min(4, step));
|
|
240
|
+
}
|
|
241
|
+
function updateMagnifier(): void {
|
|
242
|
+
if (canvas && glass) {
|
|
243
|
+
glass.style.backgroundImage = `url(${canvas.toDataURL('image/png')})`;
|
|
244
|
+
glass.style.backgroundSize = `${canvas.clientWidth * 2.4}px ${canvas.clientHeight * 2.4}px`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function render(): void {
|
|
248
|
+
const coreVal = Number(coreInput?.value ?? 12);
|
|
249
|
+
const deltaVal = Number(deltaInput?.value ?? 1);
|
|
250
|
+
const classification = classifier.classify(activePattern, coreVal, deltaVal);
|
|
251
|
+
const summary = state.summarize();
|
|
252
|
+
const selectedMark = state.list().find((mark) => mark.id === selectedMarkId);
|
|
253
|
+
updateUIStates(activeMinutia(), activePattern, limits, (type) => state.canAdd(type));
|
|
254
|
+
updateActionButtons(selectedMark, ui, state.lastMark() !== null);
|
|
255
|
+
updateClassifierLabels(classification, String(coreVal), String(deltaVal), ui);
|
|
256
|
+
updateMinutiaeLabels(summary, activeMinutia(), ui, limits);
|
|
257
|
+
updateWorkflow(activeStep, ui, board);
|
|
258
|
+
if (uploadPanel) uploadPanel.dataset.hasImage = uploadedImage ? 'true' : 'false';
|
|
259
|
+
drawPattern();
|
|
260
|
+
updateMagnifier();
|
|
261
|
+
renderTable({ marks: state.list(), colors, ui, tableBody, trashIconTemplate });
|
|
262
|
+
saveState();
|
|
263
|
+
}
|
|
264
|
+
function loadImage(src: string, clearMarks = true, targetStep = 2): void {
|
|
265
|
+
const image = new Image();
|
|
266
|
+
uploadedImageData = src;
|
|
267
|
+
image.onload = () => {
|
|
268
|
+
uploadedImage = image;
|
|
269
|
+
if (clearMarks) state.clear();
|
|
270
|
+
activeStep = targetStep;
|
|
271
|
+
render();
|
|
272
|
+
};
|
|
273
|
+
image.src = src;
|
|
274
|
+
}
|
|
275
|
+
function restoreInputValues(core?: number, delta?: number): void {
|
|
276
|
+
if (typeof core === 'number' && coreInput) coreInput.value = String(core);
|
|
277
|
+
if (typeof delta === 'number' && deltaInput) deltaInput.value = String(delta);
|
|
278
|
+
}
|
|
279
|
+
function restore(): void {
|
|
280
|
+
try {
|
|
281
|
+
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
|
|
282
|
+
if (saved.pattern) activePattern = saved.pattern;
|
|
283
|
+
restoreInputValues(saved.core, saved.delta);
|
|
284
|
+
if (saved.marks) state.replace(saved.marks);
|
|
285
|
+
if (typeof saved.uploadedImageData === 'string' && saved.uploadedImageData.startsWith('data:image/')) {
|
|
286
|
+
loadImage(saved.uploadedImageData, false, saved.activeStep);
|
|
287
|
+
}
|
|
288
|
+
} catch {
|
|
289
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function handleCanvasClick(canvasX: number, canvasY: number): void {
|
|
293
|
+
if (!canvas) return;
|
|
294
|
+
const existingMark = state.findMarkAt(canvasX, canvasY, canvas.width, canvas.height);
|
|
295
|
+
if (existingMark) {
|
|
296
|
+
selectedMarkId = existingMark.id;
|
|
297
|
+
selectedMinutia = existingMark.type;
|
|
298
|
+
render();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (!state.canAdd(activeMinutia())) return;
|
|
302
|
+
const inBounds = canvasX >= imageRect.x
|
|
303
|
+
&& canvasX <= imageRect.x + imageRect.width
|
|
304
|
+
&& canvasY >= imageRect.y
|
|
305
|
+
&& canvasY <= imageRect.y + imageRect.height;
|
|
306
|
+
if (!inBounds) return;
|
|
307
|
+
const mark = state.addMark(activeMinutia(), (canvasX / canvas.width) * 100, (canvasY / canvas.height) * 100);
|
|
308
|
+
selectedMarkId = mark.id;
|
|
309
|
+
render();
|
|
310
|
+
}
|
|
311
|
+
canvas?.addEventListener('click', (event) => {
|
|
312
|
+
if (!uploadedImage) return fileInput?.click();
|
|
313
|
+
if (activeStep !== 3 || !canvas) return;
|
|
314
|
+
const rect = canvas.getBoundingClientRect();
|
|
315
|
+
const canvasX = ((event.clientX - rect.left) / rect.width) * canvas.width;
|
|
316
|
+
const canvasY = ((event.clientY - rect.top) / rect.height) * canvas.height;
|
|
317
|
+
handleCanvasClick(canvasX, canvasY);
|
|
318
|
+
});
|
|
319
|
+
canvas?.addEventListener('mousemove', (event) => {
|
|
320
|
+
if (!glass || !uploadedImage || !canvas) return;
|
|
321
|
+
const rect = canvas.getBoundingClientRect();
|
|
322
|
+
const localX = event.clientX - rect.left;
|
|
323
|
+
const localY = event.clientY - rect.top;
|
|
324
|
+
glass.style.opacity = '1';
|
|
325
|
+
glass.style.left = `${localX}px`;
|
|
326
|
+
glass.style.top = `${localY}px`;
|
|
327
|
+
glass.style.backgroundPosition = `${-(localX * 2.4 - 62)}px ${-(localY * 2.4 - 62)}px`;
|
|
328
|
+
});
|
|
329
|
+
canvas?.addEventListener('mouseleave', () => {
|
|
330
|
+
if (glass) glass.style.opacity = '0';
|
|
331
|
+
});
|
|
332
|
+
function handleClear(): void {
|
|
333
|
+
state.clear();
|
|
334
|
+
selectedMarkId = '';
|
|
335
|
+
render();
|
|
336
|
+
}
|
|
337
|
+
async function handleExport(): Promise<void> {
|
|
338
|
+
const text = state.list().map((mark) => `${ui[mark.type] ?? mark.type} @ ${mark.x.toFixed(1)}, ${mark.y.toFixed(1)}`).join('\n');
|
|
339
|
+
await navigator.clipboard?.writeText(text);
|
|
340
|
+
}
|
|
341
|
+
const clickActions: Record<string, () => void | Promise<void>> = {
|
|
342
|
+
'fp-clear': handleClear,
|
|
343
|
+
'fp-clear-step': handleClear,
|
|
344
|
+
'fp-replace': () => fileInput?.click(),
|
|
345
|
+
'fp-upload-action': () => fileInput?.click(),
|
|
346
|
+
'fp-export': handleExport,
|
|
347
|
+
'fp-export-panel': handleExport,
|
|
348
|
+
'fp-undo': () => {
|
|
349
|
+
const mark = state.lastMark();
|
|
350
|
+
if (mark) {
|
|
351
|
+
state.removeMark(mark.id);
|
|
352
|
+
if (selectedMarkId === mark.id) selectedMarkId = '';
|
|
353
|
+
render();
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
'fp-delete-selected': () => {
|
|
357
|
+
if (selectedMarkId) {
|
|
358
|
+
state.removeMark(selectedMarkId);
|
|
359
|
+
selectedMarkId = '';
|
|
360
|
+
render();
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
function handlePatternClick(target: HTMLElement): boolean {
|
|
365
|
+
const pBtn = target.closest<HTMLButtonElement>('[data-fp-pattern]');
|
|
366
|
+
if (!pBtn) return false;
|
|
367
|
+
activePattern = (pBtn.dataset.fpPattern || 'loop') as FingerprintPattern;
|
|
368
|
+
render();
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
function handleMinutiaClick(target: HTMLElement): boolean {
|
|
372
|
+
const mBtn = target.closest<HTMLButtonElement>('[data-fp-minutia]');
|
|
373
|
+
if (!mBtn) return false;
|
|
374
|
+
const type = (mBtn.dataset.fpMinutia || 'ridgeEnding') as MinutiaType;
|
|
375
|
+
if (state.canAdd(type) || type === activeMinutia()) selectedMinutia = type;
|
|
376
|
+
render();
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
function handleRemoveClick(target: HTMLElement): boolean {
|
|
380
|
+
const removeBtn = target.closest<HTMLButtonElement>('[data-remove]');
|
|
381
|
+
if (!removeBtn) return false;
|
|
382
|
+
const id = removeBtn.getAttribute('data-remove');
|
|
383
|
+
if (id) {
|
|
384
|
+
state.removeMark(id);
|
|
385
|
+
render();
|
|
386
|
+
}
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
function handleWorkflowClick(target: HTMLElement): boolean {
|
|
390
|
+
const nextBtn = target.closest<HTMLButtonElement>('[data-fp-next]');
|
|
391
|
+
if (nextBtn) {
|
|
392
|
+
activeStep = clampStep(Number(nextBtn.dataset.fpNext ?? activeStep + 1));
|
|
393
|
+
render();
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
const prevBtn = target.closest<HTMLButtonElement>('[data-fp-prev]');
|
|
397
|
+
if (prevBtn) {
|
|
398
|
+
activeStep = clampStep(activeStep - 1);
|
|
399
|
+
render();
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
function checkHandlers(t: HTMLElement): boolean {
|
|
405
|
+
return handlePatternClick(t) || handleMinutiaClick(t);
|
|
406
|
+
}
|
|
407
|
+
board?.addEventListener('click', (event) => {
|
|
408
|
+
const target = event.target as HTMLElement;
|
|
409
|
+
if (checkHandlers(target) || handleRemoveClick(target) || handleWorkflowClick(target)) return;
|
|
410
|
+
const id = target.closest('[id]')?.id;
|
|
411
|
+
if (id && clickActions[id]) {
|
|
412
|
+
const result = clickActions[id]();
|
|
413
|
+
if (result instanceof Promise) result.catch(() => {});
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
fileInput?.addEventListener('change', () => {
|
|
417
|
+
const file = fileInput.files?.[0];
|
|
418
|
+
if (!file) return;
|
|
419
|
+
const reader = new FileReader();
|
|
420
|
+
reader.addEventListener('load', () => {
|
|
421
|
+
if (typeof reader.result === 'string') loadImage(reader.result);
|
|
422
|
+
});
|
|
423
|
+
reader.readAsDataURL(file);
|
|
424
|
+
});
|
|
425
|
+
coreInput?.addEventListener('input', render);
|
|
426
|
+
deltaInput?.addEventListener('input', render);
|
|
427
|
+
restore();
|
|
428
|
+
render();
|
|
429
|
+
</script>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { FingerprintPattern, MinutiaMark } from './logic';
|
|
2
|
+
|
|
3
|
+
interface RidgeParams {
|
|
4
|
+
x: number;
|
|
5
|
+
nx: number;
|
|
6
|
+
yBase: number;
|
|
7
|
+
pattern: FingerprintPattern;
|
|
8
|
+
i: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getRidgeY(p: RidgeParams): number {
|
|
12
|
+
let offset = 0;
|
|
13
|
+
if (p.pattern === 'arch') {
|
|
14
|
+
offset = -110 * Math.exp(-(p.nx * p.nx));
|
|
15
|
+
} else if (p.pattern === 'loop') {
|
|
16
|
+
offset = Math.sin(p.nx * Math.PI) * 68 * Math.exp(-Math.abs(p.i) / 12);
|
|
17
|
+
} else if (p.pattern === 'whorl') {
|
|
18
|
+
offset = Math.sin(Math.atan2(p.i, p.nx * 8) * 3 + Math.hypot(p.nx * 2.2, p.i / 4.8)) * 45;
|
|
19
|
+
}
|
|
20
|
+
return p.yBase + offset + Math.sin(p.x * 0.018 + p.i * 0.74) * 2.8;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function drawPlaceholder(
|
|
24
|
+
canvas: HTMLCanvasElement,
|
|
25
|
+
context: CanvasRenderingContext2D,
|
|
26
|
+
activePattern: FingerprintPattern,
|
|
27
|
+
core: number
|
|
28
|
+
): void {
|
|
29
|
+
const { width: w, height: h } = canvas;
|
|
30
|
+
const style = getComputedStyle(canvas);
|
|
31
|
+
context.fillStyle = style.getPropertyValue('--fp-paper').trim() || '#f8efe1';
|
|
32
|
+
context.fillRect(0, 0, w, h);
|
|
33
|
+
context.lineCap = 'round';
|
|
34
|
+
context.lineWidth = 4.8;
|
|
35
|
+
context.strokeStyle = style.getPropertyValue('--fp-ridge').trim() || '#253243';
|
|
36
|
+
context.shadowColor = style.getPropertyValue('--fp-ridge-glow').trim() || 'rgba(37, 50, 67, 0.18)';
|
|
37
|
+
context.shadowBlur = 5;
|
|
38
|
+
|
|
39
|
+
for (let i = -15; i <= 15; i += 1) {
|
|
40
|
+
context.beginPath();
|
|
41
|
+
for (let x = 78; x <= w - 78; x += 9) {
|
|
42
|
+
const nx = (x - w / 2 - core * 3) / 170;
|
|
43
|
+
const y = getRidgeY({ x, nx, yBase: h / 2 + i * 17, pattern: activePattern, i });
|
|
44
|
+
if (x === 78) context.moveTo(x, y);
|
|
45
|
+
else context.lineTo(x, y);
|
|
46
|
+
}
|
|
47
|
+
context.stroke();
|
|
48
|
+
}
|
|
49
|
+
context.shadowBlur = 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function drawUploadedImage(
|
|
53
|
+
canvas: HTMLCanvasElement,
|
|
54
|
+
context: CanvasRenderingContext2D,
|
|
55
|
+
uploadedImage: HTMLImageElement
|
|
56
|
+
): { x: number; y: number; width: number; height: number } {
|
|
57
|
+
const w = canvas.width;
|
|
58
|
+
const h = canvas.height;
|
|
59
|
+
const scale = Math.max(w / uploadedImage.naturalWidth, h / uploadedImage.naturalHeight);
|
|
60
|
+
const drawWidth = uploadedImage.naturalWidth * scale;
|
|
61
|
+
const drawHeight = uploadedImage.naturalHeight * scale;
|
|
62
|
+
const x = (w - drawWidth) / 2;
|
|
63
|
+
const y = (h - drawHeight) / 2;
|
|
64
|
+
context.fillStyle = getComputedStyle(canvas).getPropertyValue('--fp-paper').trim() || '#f8efe1';
|
|
65
|
+
context.fillRect(0, 0, w, h);
|
|
66
|
+
context.drawImage(uploadedImage, x, y, drawWidth, drawHeight);
|
|
67
|
+
return { x, y, width: drawWidth, height: drawHeight };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface DrawMarkParams {
|
|
71
|
+
canvas: HTMLCanvasElement;
|
|
72
|
+
context: CanvasRenderingContext2D;
|
|
73
|
+
mark: MinutiaMark;
|
|
74
|
+
selectedMarkId: string;
|
|
75
|
+
color: string;
|
|
76
|
+
label: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function drawMark(p: DrawMarkParams): void {
|
|
80
|
+
const x = (p.mark.x / 100) * p.canvas.width;
|
|
81
|
+
const y = (p.mark.y / 100) * p.canvas.height;
|
|
82
|
+
p.context.fillStyle = p.color;
|
|
83
|
+
p.context.strokeStyle = p.mark.id === p.selectedMarkId ? '#facc15' : '#ffffff';
|
|
84
|
+
p.context.lineWidth = p.mark.id === p.selectedMarkId ? 7 : 4;
|
|
85
|
+
p.context.beginPath();
|
|
86
|
+
p.context.arc(x, y, 13, 0, Math.PI * 2);
|
|
87
|
+
p.context.fill();
|
|
88
|
+
p.context.stroke();
|
|
89
|
+
p.context.fillStyle = '#ffffff';
|
|
90
|
+
p.context.font = '900 16px system-ui';
|
|
91
|
+
p.context.textAlign = 'center';
|
|
92
|
+
p.context.textBaseline = 'middle';
|
|
93
|
+
p.context.fillText(p.label, x, y);
|
|
94
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ScienceToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
|
|
3
|
+
export interface FingerprintMinutiaeUI {
|
|
4
|
+
[key: string]: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type FingerprintMinutiaeLocaleContent = ToolLocaleContent<FingerprintMinutiaeUI>;
|
|
8
|
+
|
|
9
|
+
export const forensicFingerprintMinutiaeIdentifier: ScienceToolEntry<FingerprintMinutiaeUI> = {
|
|
10
|
+
id: 'forensic-fingerprint-minutiae-identifier',
|
|
11
|
+
icons: {
|
|
12
|
+
bg: 'mdi:fingerprint',
|
|
13
|
+
fg: 'mdi:target',
|
|
14
|
+
},
|
|
15
|
+
i18n: {
|
|
16
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
17
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
18
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
19
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
20
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
21
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
22
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
23
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
24
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
25
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
26
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
27
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
28
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
29
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
30
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
31
|
+
},
|
|
32
|
+
};
|