@jjlmoya/utils-forensic-science 1.6.0 → 1.8.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 +4 -2
- package/src/category/index.ts +15 -15
- package/src/layouts/PreviewLayout.astro +7 -2
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tool/bloodstain-pattern-origin-analyzer/component.astro +39 -39
- package/src/tool/forensic-blood-test-simulator/dom-utils.ts +3 -3
- package/src/tool/forensic-fiber-comparison-microscope/component.astro +2 -2
- package/src/tool/forensic-fiber-comparison-microscope/view.ts +3 -2
- package/src/tool/forensic-fingerprint-minutiae-identifier/component.astro +8 -4
- package/src/tool/forensic-sex-determinator/component.astro +1 -1
- package/src/tool/forensic-stature-estimator/components/OsteometricSelector.astro +1 -1
- package/src/tool/widmark-alcohol-simulator/component.astro +6 -6
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-forensic-science",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./data": "./src/data.ts",
|
|
10
|
-
"./entries": "./src/entries.ts"
|
|
10
|
+
"./entries": "./src/entries.ts",
|
|
11
|
+
"./runtime/*": "./src/tool/*/index.ts",
|
|
12
|
+
"./category-seo": "./src/category/seo.astro"
|
|
11
13
|
},
|
|
12
14
|
"files": [
|
|
13
15
|
"src",
|
package/src/category/index.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import type { ScienceCategoryEntry } from '../types';
|
|
2
|
-
import { forensicAgeEstimator } from '../tool/forensic-age-estimator/
|
|
3
|
-
import { widmarkAlcoholSimulator } from '../tool/widmark-alcohol-simulator/
|
|
4
|
-
import { forensicSexDeterminator } from '../tool/forensic-sex-determinator/
|
|
5
|
-
import { forensicStatureEstimator } from '../tool/forensic-stature-estimator/
|
|
6
|
-
import { forensicBloodTestSimulator } from '../tool/forensic-blood-test-simulator/
|
|
7
|
-
import { forensicImageAuthenticityAnalyzer } from '../tool/forensic-image-authenticity-analyzer/
|
|
8
|
-
import { gsrDispersionCalculator } from '../tool/gsr-dispersion-calculator/
|
|
9
|
-
import { forensicTlcInkSimulator } from '../tool/forensic-tlc-ink-simulator/
|
|
10
|
-
import { forensicMicrocrystalDrugSimulator } from '../tool/forensic-microcrystal-drug-simulator/
|
|
11
|
-
import { forensicGlassBeckeLineSimulator } from '../tool/forensic-glass-becke-line-simulator/
|
|
12
|
-
import { forensicFiberComparisonMicroscope } from '../tool/forensic-fiber-comparison-microscope/
|
|
13
|
-
import { bloodstainPatternOriginAnalyzer } from '../tool/bloodstain-pattern-origin-analyzer/
|
|
14
|
-
import { forensicFingerprintMinutiaeIdentifier } from '../tool/forensic-fingerprint-minutiae-identifier/
|
|
15
|
-
import { firePatternOriginAnalyzer } from '../tool/fire-pattern-origin-analyzer/
|
|
16
|
-
import { forensicToolmarkStriationMatcher } from '../tool/forensic-toolmark-striation-matcher/
|
|
2
|
+
import { forensicAgeEstimator } from '../tool/forensic-age-estimator/entry';
|
|
3
|
+
import { widmarkAlcoholSimulator } from '../tool/widmark-alcohol-simulator/entry';
|
|
4
|
+
import { forensicSexDeterminator } from '../tool/forensic-sex-determinator/entry';
|
|
5
|
+
import { forensicStatureEstimator } from '../tool/forensic-stature-estimator/entry';
|
|
6
|
+
import { forensicBloodTestSimulator } from '../tool/forensic-blood-test-simulator/entry';
|
|
7
|
+
import { forensicImageAuthenticityAnalyzer } from '../tool/forensic-image-authenticity-analyzer/entry';
|
|
8
|
+
import { gsrDispersionCalculator } from '../tool/gsr-dispersion-calculator/entry';
|
|
9
|
+
import { forensicTlcInkSimulator } from '../tool/forensic-tlc-ink-simulator/entry';
|
|
10
|
+
import { forensicMicrocrystalDrugSimulator } from '../tool/forensic-microcrystal-drug-simulator/entry';
|
|
11
|
+
import { forensicGlassBeckeLineSimulator } from '../tool/forensic-glass-becke-line-simulator/entry';
|
|
12
|
+
import { forensicFiberComparisonMicroscope } from '../tool/forensic-fiber-comparison-microscope/entry';
|
|
13
|
+
import { bloodstainPatternOriginAnalyzer } from '../tool/bloodstain-pattern-origin-analyzer/entry';
|
|
14
|
+
import { forensicFingerprintMinutiaeIdentifier } from '../tool/forensic-fingerprint-minutiae-identifier/entry';
|
|
15
|
+
import { firePatternOriginAnalyzer } from '../tool/fire-pattern-origin-analyzer/entry';
|
|
16
|
+
import { forensicToolmarkStriationMatcher } from '../tool/forensic-toolmark-striation-matcher/entry';
|
|
17
17
|
|
|
18
18
|
export const forensicCategory: ScienceCategoryEntry = {
|
|
19
19
|
icon: 'mdi:fingerprint',
|
|
@@ -10,7 +10,12 @@ interface Props {
|
|
|
10
10
|
hasSidebar?: boolean;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
const {
|
|
13
|
+
const {
|
|
14
|
+
title,
|
|
15
|
+
currentLocale = "es",
|
|
16
|
+
localeUrls = {},
|
|
17
|
+
hasSidebar = false,
|
|
18
|
+
} = Astro.props;
|
|
14
19
|
---
|
|
15
20
|
|
|
16
21
|
<!doctype html>
|
|
@@ -78,6 +83,7 @@ const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Ast
|
|
|
78
83
|
transition:
|
|
79
84
|
background-color 0.3s ease,
|
|
80
85
|
color 0.3s ease;
|
|
86
|
+
font-family: Inter, sans-serif;
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
main {
|
|
@@ -114,4 +120,3 @@ const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Ast
|
|
|
114
120
|
}
|
|
115
121
|
}
|
|
116
122
|
</style>
|
|
117
|
-
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync } from 'fs';
|
|
3
|
+
import { join, relative } from 'path';
|
|
4
|
+
import { ALL_TOOLS } from '../tools';
|
|
5
|
+
import type { SEOSection, ToolLocaleContent } from '../types';
|
|
6
|
+
|
|
7
|
+
const srcDir = join(process.cwd(), 'src');
|
|
8
|
+
const toolDir = join(srcDir, 'tool');
|
|
9
|
+
const geometryReads = [
|
|
10
|
+
'offsetWidth',
|
|
11
|
+
'offsetHeight',
|
|
12
|
+
'offsetTop',
|
|
13
|
+
'offsetLeft',
|
|
14
|
+
'clientWidth',
|
|
15
|
+
'clientHeight',
|
|
16
|
+
'clientTop',
|
|
17
|
+
'clientLeft',
|
|
18
|
+
'scrollWidth',
|
|
19
|
+
'scrollHeight',
|
|
20
|
+
'scrollTop',
|
|
21
|
+
'scrollLeft',
|
|
22
|
+
'getBoundingClientRect',
|
|
23
|
+
'getClientRects',
|
|
24
|
+
'computedStyle',
|
|
25
|
+
'getComputedStyle',
|
|
26
|
+
];
|
|
27
|
+
const domWrites = [
|
|
28
|
+
'.style.',
|
|
29
|
+
'.classList.add',
|
|
30
|
+
'.classList.remove',
|
|
31
|
+
'.classList.toggle',
|
|
32
|
+
'.appendChild',
|
|
33
|
+
'.insertBefore',
|
|
34
|
+
'.prepend',
|
|
35
|
+
'.append',
|
|
36
|
+
'.remove',
|
|
37
|
+
'.innerHTML',
|
|
38
|
+
'.textContent',
|
|
39
|
+
'.setAttribute',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function findFiles(dir: string, extensions: string[]): string[] {
|
|
43
|
+
const files: string[] = [];
|
|
44
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
45
|
+
const fullPath = join(dir, entry.name);
|
|
46
|
+
if (entry.isDirectory()) files.push(...findFiles(fullPath, extensions));
|
|
47
|
+
else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(fullPath);
|
|
48
|
+
}
|
|
49
|
+
return files;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function relativePath(file: string): string {
|
|
53
|
+
return relative(process.cwd(), file).replace(/\\/g, '/');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findFormControls(content: string, tagName: 'input' | 'select'): RegExpMatchArray[] {
|
|
57
|
+
return Array.from(content.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function attrValue(tag: string, attr: string): string | null {
|
|
61
|
+
const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{([^}]+)\\})`, 'i'));
|
|
62
|
+
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function booleanAttr(tag: string, attr: string): boolean {
|
|
66
|
+
return new RegExp(`\\b${attr}\\b`, 'i').test(tag);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function controlStartIndex(content: string, tag: RegExpMatchArray): number {
|
|
70
|
+
return tag.index ?? content.indexOf(tag[0]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hasWrappingLabel(content: string, tag: RegExpMatchArray): boolean {
|
|
74
|
+
const index = controlStartIndex(content, tag);
|
|
75
|
+
const before = content.slice(0, index);
|
|
76
|
+
const labelOpen = before.lastIndexOf('<label');
|
|
77
|
+
const labelClose = before.lastIndexOf('</label>');
|
|
78
|
+
const nextLabelClose = content.indexOf('</label>', index + tag[0].length);
|
|
79
|
+
return labelOpen > labelClose && nextLabelClose !== -1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasAccessibleName(content: string, tag: RegExpMatchArray): boolean {
|
|
83
|
+
const source = tag[0];
|
|
84
|
+
if (attrValue(source, 'aria-label')) return true;
|
|
85
|
+
if (attrValue(source, 'aria-labelledby')) return true;
|
|
86
|
+
const id = attrValue(source, 'id');
|
|
87
|
+
if (id && hasExplicitLabel(content, id)) return true;
|
|
88
|
+
return hasWrappingLabel(content, tag);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isVisuallyHiddenFileInput(tag: string): boolean {
|
|
92
|
+
const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
|
|
93
|
+
const attributes = `${attrValue(tag, 'style') ?? ''} ${attrValue(tag, 'class') ?? ''}`.toLowerCase();
|
|
94
|
+
const hiddenPatterns = ['display:none', 'display: none', 'file-input'];
|
|
95
|
+
return type === 'file' && hiddenPatterns.some((pattern) => attributes.includes(pattern));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isIgnoredInput(tag: string): boolean {
|
|
99
|
+
const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
|
|
100
|
+
return ['hidden', 'button', 'submit', 'reset'].includes(type) || booleanAttr(tag, 'aria-hidden') || isVisuallyHiddenFileInput(tag);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function controlFailures(content: string, tagName: 'input' | 'select'): string[] {
|
|
104
|
+
return findFormControls(content, tagName)
|
|
105
|
+
.filter((tag) => tagName !== 'input' || !isIgnoredInput(tag[0]))
|
|
106
|
+
.filter((tag) => !hasAccessibleName(content, tag))
|
|
107
|
+
.map((tag) => tag[0]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function explicitLabelMessage(tagName: string, path: string, failures: string[]): string {
|
|
111
|
+
return `${tagName} controls without label, wrapping label, aria-label or aria-labelledby in ${path}:\n${failures.join('\n')}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hasExplicitLabel(content: string, id: string): boolean {
|
|
115
|
+
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
116
|
+
return (
|
|
117
|
+
new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*["']${escapedId}["'][^>]*>`, 'i').test(content)
|
|
118
|
+
|| new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*\\{${escapedId}\\}[^>]*>`, 'i').test(content)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function headingLevels(sections: SEOSection[]): number[] {
|
|
123
|
+
return sections
|
|
124
|
+
.filter((section) => section.type === 'title')
|
|
125
|
+
.map((section) => Number('level' in section ? section.level : 0))
|
|
126
|
+
.filter((level) => Number.isInteger(level) && level > 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function findHeadingLevelJumps(levels: number[]): string[] {
|
|
130
|
+
const failures: string[] = [];
|
|
131
|
+
levels.forEach((level, index) => {
|
|
132
|
+
const previous = index === 0 ? 1 : levels[index - 1];
|
|
133
|
+
if (previous && level > previous + 1) {
|
|
134
|
+
failures.push(`h${previous} -> h${level}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
return failures;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function hasDomWriteBeforeGeometryRead(content: string): boolean {
|
|
141
|
+
const normalized = content.replace(/\s+/g, ' ');
|
|
142
|
+
return domWrites.some((write) => {
|
|
143
|
+
const writeIndex = normalized.indexOf(write);
|
|
144
|
+
if (writeIndex === -1) return false;
|
|
145
|
+
return geometryReads.some((read) => normalized.indexOf(read, writeIndex + write.length) !== -1);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe('PageSpeed best-practice guards', () => {
|
|
150
|
+
const astroToolFiles = findFiles(toolDir, ['.astro']);
|
|
151
|
+
const scriptFiles = findFiles(toolDir, ['.astro', '.ts', '.js']);
|
|
152
|
+
|
|
153
|
+
astroToolFiles.forEach((file) => {
|
|
154
|
+
const displayPath = relativePath(file);
|
|
155
|
+
|
|
156
|
+
it(`${displayPath} labels every input with an explicit label`, () => {
|
|
157
|
+
const content = readFileSync(file, 'utf-8');
|
|
158
|
+
const failures = controlFailures(content, 'input');
|
|
159
|
+
|
|
160
|
+
expect(failures, explicitLabelMessage('Input', displayPath, failures)).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it(`${displayPath} labels every select with an explicit label`, () => {
|
|
164
|
+
const content = readFileSync(file, 'utf-8');
|
|
165
|
+
const failures = controlFailures(content, 'select');
|
|
166
|
+
|
|
167
|
+
expect(failures, explicitLabelMessage('Select', displayPath, failures)).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
ALL_TOOLS.forEach((tool) => {
|
|
172
|
+
Object.entries(tool.entry.i18n).forEach(([locale, loader]) => {
|
|
173
|
+
it(`${tool.entry.id}/${locale} keeps SEO headings sequential`, async () => {
|
|
174
|
+
if (!loader) return;
|
|
175
|
+
const content = (await loader()) as ToolLocaleContent;
|
|
176
|
+
const levels = headingLevels(content.seo);
|
|
177
|
+
const failures = findHeadingLevelJumps(levels);
|
|
178
|
+
|
|
179
|
+
expect(
|
|
180
|
+
failures,
|
|
181
|
+
`SEO headings in ${tool.entry.id}/${locale} skip levels: ${failures.join(', ')}`,
|
|
182
|
+
).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
scriptFiles.forEach((file) => {
|
|
188
|
+
const displayPath = relativePath(file);
|
|
189
|
+
|
|
190
|
+
it(`${displayPath} avoids static forced-reflow patterns`, () => {
|
|
191
|
+
const content = readFileSync(file, 'utf-8');
|
|
192
|
+
expect(
|
|
193
|
+
hasDomWriteBeforeGeometryRead(content),
|
|
194
|
+
`${displayPath} appears to read layout geometry after DOM/style mutations. Split writes and reads across frames or measure before mutating.`,
|
|
195
|
+
).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -111,7 +111,7 @@ const { ui } = Astro.props;
|
|
|
111
111
|
import { analyzeBloodstains } from './logic';
|
|
112
112
|
import type { BloodstainInput, OriginEstimate } from './logic';
|
|
113
113
|
|
|
114
|
-
const ui = JSON.parse(document.getElementById('bpa-ui')?.
|
|
114
|
+
const ui = JSON.parse((document.getElementById('bpa-ui') as HTMLElement | null)?.innerText || '{}');
|
|
115
115
|
const surface = document.getElementById('bpa-surface') as HTMLCanvasElement | null;
|
|
116
116
|
const rows = document.getElementById('bpa-rows');
|
|
117
117
|
const threeHost = document.getElementById('bpa-three');
|
|
@@ -182,31 +182,6 @@ const { ui } = Astro.props;
|
|
|
182
182
|
return stains.find((stain) => stain.id === selectedStainId) ?? stains[0];
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
function configureFocusRanges() {
|
|
186
|
-
if (!focusWidthEl || !focusLengthEl) return;
|
|
187
|
-
focusWidthEl.min = cmToInput(0.2).toString();
|
|
188
|
-
focusWidthEl.max = cmToInput(3).toString();
|
|
189
|
-
focusWidthEl.step = unit === 'metric' ? '0.1' : '0.05';
|
|
190
|
-
focusLengthEl.min = cmToInput(0.5).toString();
|
|
191
|
-
focusLengthEl.max = cmToInput(6).toString();
|
|
192
|
-
focusLengthEl.step = unit === 'metric' ? '0.1' : '0.05';
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function syncFocusPanel() {
|
|
196
|
-
const stain = selectedStain();
|
|
197
|
-
if (!stain) return;
|
|
198
|
-
const color = palette[selectedIndex() % palette.length];
|
|
199
|
-
if (focusNameEl) focusNameEl.textContent = `${ui.stainLabel} ${stain.id}`;
|
|
200
|
-
if (focusSwatchEl) focusSwatchEl.style.background = color;
|
|
201
|
-
configureFocusRanges();
|
|
202
|
-
if (focusWidthEl) focusWidthEl.value = cmToInput(stain.widthCm).toString();
|
|
203
|
-
if (focusLengthEl) focusLengthEl.value = cmToInput(stain.lengthCm).toString();
|
|
204
|
-
if (focusRotationEl) focusRotationEl.value = stain.rotationDeg.toString();
|
|
205
|
-
if (focusWidthValueEl) focusWidthValueEl.textContent = display(stain.widthCm);
|
|
206
|
-
if (focusLengthValueEl) focusLengthValueEl.textContent = display(stain.lengthCm);
|
|
207
|
-
if (focusRotationValueEl) focusRotationValueEl.textContent = `${stain.rotationDeg.toFixed(0)} ${ui.degree}`;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
185
|
function canvasMetrics() {
|
|
211
186
|
if (!surface) return null;
|
|
212
187
|
const rect = surface.getBoundingClientRect();
|
|
@@ -239,6 +214,39 @@ const { ui } = Astro.props;
|
|
|
239
214
|
};
|
|
240
215
|
}
|
|
241
216
|
|
|
217
|
+
function resizeThree() {
|
|
218
|
+
if (!threeHost) return;
|
|
219
|
+
const rect = threeHost.getBoundingClientRect();
|
|
220
|
+
renderer.setSize(rect.width, rect.height, false);
|
|
221
|
+
camera.aspect = rect.width / Math.max(rect.height, 1);
|
|
222
|
+
camera.updateProjectionMatrix();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function configureFocusRanges() {
|
|
226
|
+
if (!focusWidthEl || !focusLengthEl) return;
|
|
227
|
+
focusWidthEl.min = cmToInput(0.2).toString();
|
|
228
|
+
focusWidthEl.max = cmToInput(3).toString();
|
|
229
|
+
focusWidthEl.step = unit === 'metric' ? '0.1' : '0.05';
|
|
230
|
+
focusLengthEl.min = cmToInput(0.5).toString();
|
|
231
|
+
focusLengthEl.max = cmToInput(6).toString();
|
|
232
|
+
focusLengthEl.step = unit === 'metric' ? '0.1' : '0.05';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function syncFocusPanel() {
|
|
236
|
+
const stain = selectedStain();
|
|
237
|
+
if (!stain) return;
|
|
238
|
+
const color = palette[selectedIndex() % palette.length];
|
|
239
|
+
if (focusNameEl) focusNameEl.textContent = `${ui.stainLabel} ${stain.id}`;
|
|
240
|
+
if (focusSwatchEl) focusSwatchEl.style.background = color;
|
|
241
|
+
configureFocusRanges();
|
|
242
|
+
if (focusWidthEl) focusWidthEl.value = cmToInput(stain.widthCm).toString();
|
|
243
|
+
if (focusLengthEl) focusLengthEl.value = cmToInput(stain.lengthCm).toString();
|
|
244
|
+
if (focusRotationEl) focusRotationEl.value = stain.rotationDeg.toString();
|
|
245
|
+
if (focusWidthValueEl) focusWidthValueEl.textContent = display(stain.widthCm);
|
|
246
|
+
if (focusLengthValueEl) focusLengthValueEl.textContent = display(stain.lengthCm);
|
|
247
|
+
if (focusRotationValueEl) focusRotationValueEl.textContent = `${stain.rotationDeg.toFixed(0)} ${ui.degree}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
242
250
|
function findStainAt(event: PointerEvent): BloodstainInput | null {
|
|
243
251
|
const pointer = eventToSurface(event);
|
|
244
252
|
if (!pointer) return null;
|
|
@@ -274,14 +282,6 @@ const { ui } = Astro.props;
|
|
|
274
282
|
renderer.setAnimationLoop(() => renderer.render(scene, camera));
|
|
275
283
|
}
|
|
276
284
|
|
|
277
|
-
function resizeThree() {
|
|
278
|
-
if (!threeHost) return;
|
|
279
|
-
const rect = threeHost.getBoundingClientRect();
|
|
280
|
-
renderer.setSize(rect.width, rect.height, false);
|
|
281
|
-
camera.aspect = rect.width / Math.max(rect.height, 1);
|
|
282
|
-
camera.updateProjectionMatrix();
|
|
283
|
-
}
|
|
284
|
-
|
|
285
285
|
function clearDynamic3d() {
|
|
286
286
|
for (let index = group.children.length - 1; index >= 0; index--) {
|
|
287
287
|
const child = group.children[index];
|
|
@@ -398,11 +398,11 @@ const { ui } = Astro.props;
|
|
|
398
398
|
const row = document.createElement('tr');
|
|
399
399
|
if (stain.id === selectedStainId) row.className = 'is-selected';
|
|
400
400
|
row.innerHTML = `
|
|
401
|
-
<td><input data-field="xCm" data-id="${stain.id}" type="number" step="0.5" value="${cmToInput(stain.xCm).toFixed(1)}"></td>
|
|
402
|
-
<td><input data-field="yCm" data-id="${stain.id}" type="number" step="0.5" value="${cmToInput(stain.yCm).toFixed(1)}"></td>
|
|
403
|
-
<td><input data-field="widthCm" data-id="${stain.id}" type="number" min="0.1" step="0.1" value="${cmToInput(stain.widthCm).toFixed(1)}"></td>
|
|
404
|
-
<td><input data-field="lengthCm" data-id="${stain.id}" type="number" min="0.1" step="0.1" value="${cmToInput(stain.lengthCm).toFixed(1)}"></td>
|
|
405
|
-
<td><input data-field="rotationDeg" data-id="${stain.id}" type="number" step="1" value="${stain.rotationDeg.toFixed(0)}"></td>
|
|
401
|
+
<td><input aria-label="${ui.x} ${stain.id}" data-field="xCm" data-id="${stain.id}" type="number" step="0.5" value="${cmToInput(stain.xCm).toFixed(1)}"></td>
|
|
402
|
+
<td><input aria-label="${ui.y} ${stain.id}" data-field="yCm" data-id="${stain.id}" type="number" step="0.5" value="${cmToInput(stain.yCm).toFixed(1)}"></td>
|
|
403
|
+
<td><input aria-label="${ui.width} ${stain.id}" data-field="widthCm" data-id="${stain.id}" type="number" min="0.1" step="0.1" value="${cmToInput(stain.widthCm).toFixed(1)}"></td>
|
|
404
|
+
<td><input aria-label="${ui.length} ${stain.id}" data-field="lengthCm" data-id="${stain.id}" type="number" min="0.1" step="0.1" value="${cmToInput(stain.lengthCm).toFixed(1)}"></td>
|
|
405
|
+
<td><input aria-label="${ui.rotation} ${stain.id}" data-field="rotationDeg" data-id="${stain.id}" type="number" step="1" value="${stain.rotationDeg.toFixed(0)}"></td>
|
|
406
406
|
<td><button type="button" class="bpa-icon-button" data-remove="${stain.id}" aria-label="${ui.remove} ${stain.id}">${deleteIconTemplate?.innerHTML || ui.remove}</button></td>
|
|
407
407
|
`;
|
|
408
408
|
rows.appendChild(row);
|
|
@@ -12,9 +12,9 @@ export function animateDrop(dropEl: HTMLElement | null, dishEl: HTMLElement | nu
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
dropEl.classList.remove('animating');
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
requestAnimationFrame(() => {
|
|
16
|
+
dropEl.classList.add('animating');
|
|
17
|
+
});
|
|
18
18
|
|
|
19
19
|
setTimeout(() => {
|
|
20
20
|
dropEl.classList.remove('animating');
|
|
@@ -24,7 +24,7 @@ const { ui } = Astro.props;
|
|
|
24
24
|
<aside class="fiber-controls">
|
|
25
25
|
<div class="fiber-field">
|
|
26
26
|
<span>{ui.questionedSample}</span>
|
|
27
|
-
<select id="fiber-left" class="fiber-native-select" aria-hidden="true" tabindex="-1">
|
|
27
|
+
<select id="fiber-left" class="fiber-native-select" aria-hidden="true" aria-label={ui.questionedSample} tabindex="-1">
|
|
28
28
|
<option value="questionedCotton">{ui.questionedCotton}</option>
|
|
29
29
|
</select>
|
|
30
30
|
<div class="fiber-builder-head">
|
|
@@ -73,7 +73,7 @@ const { ui } = Astro.props;
|
|
|
73
73
|
|
|
74
74
|
<div class="fiber-field">
|
|
75
75
|
<span>{ui.knownSample}</span>
|
|
76
|
-
<select id="fiber-right" class="fiber-native-select" aria-hidden="true" tabindex="-1">
|
|
76
|
+
<select id="fiber-right" class="fiber-native-select" aria-hidden="true" aria-label={ui.knownSample} tabindex="-1">
|
|
77
77
|
<option value="suspectCotton">{ui.suspectCotton}</option>
|
|
78
78
|
<option value="wool">{ui.wool}</option>
|
|
79
79
|
<option value="polyester">{ui.polyester}</option>
|
|
@@ -249,8 +249,9 @@ class FiberComparisonView {
|
|
|
249
249
|
if (!panel) return;
|
|
250
250
|
panel.dataset.verdict = state;
|
|
251
251
|
panel.classList.remove('is-updating');
|
|
252
|
-
|
|
253
|
-
|
|
252
|
+
requestAnimationFrame(() => {
|
|
253
|
+
panel.classList.add('is-updating');
|
|
254
|
+
});
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
private statusKey(verdictKey: Result['verdictKey']): string {
|
|
@@ -190,7 +190,7 @@ const { ui } = Astro.props;
|
|
|
190
190
|
renderTable,
|
|
191
191
|
} from './renderer';
|
|
192
192
|
|
|
193
|
-
const ui = JSON.parse(document.getElementById('fp-ui')?.
|
|
193
|
+
const ui = JSON.parse((document.getElementById('fp-ui') as HTMLElement | null)?.innerText || '{}');
|
|
194
194
|
const board = document.querySelector<HTMLElement>('[data-fingerprint-tool] .fp-board');
|
|
195
195
|
const canvas = document.getElementById('fp-canvas') as HTMLCanvasElement | null;
|
|
196
196
|
const context = canvas?.getContext('2d') ?? null;
|
|
@@ -238,10 +238,12 @@ const { ui } = Astro.props;
|
|
|
238
238
|
if (!uploadedImage) return 1;
|
|
239
239
|
return Math.max(2, Math.min(4, step));
|
|
240
240
|
}
|
|
241
|
+
function getCanvasRect(): DOMRect | null { return canvas ? canvas.getBoundingClientRect() : null; }
|
|
241
242
|
function updateMagnifier(): void {
|
|
242
243
|
if (canvas && glass) {
|
|
244
|
+
const w = canvas.clientWidth, h = canvas.clientHeight;
|
|
243
245
|
glass.style.backgroundImage = `url(${canvas.toDataURL('image/png')})`;
|
|
244
|
-
glass.style.backgroundSize = `${
|
|
246
|
+
glass.style.backgroundSize = `${w * 2.4}px ${h * 2.4}px`;
|
|
245
247
|
}
|
|
246
248
|
}
|
|
247
249
|
function render(): void {
|
|
@@ -311,14 +313,16 @@ const { ui } = Astro.props;
|
|
|
311
313
|
canvas?.addEventListener('click', (event) => {
|
|
312
314
|
if (!uploadedImage) return fileInput?.click();
|
|
313
315
|
if (activeStep !== 3 || !canvas) return;
|
|
314
|
-
const rect =
|
|
316
|
+
const rect = getCanvasRect();
|
|
317
|
+
if (!rect) return;
|
|
315
318
|
const canvasX = ((event.clientX - rect.left) / rect.width) * canvas.width;
|
|
316
319
|
const canvasY = ((event.clientY - rect.top) / rect.height) * canvas.height;
|
|
317
320
|
handleCanvasClick(canvasX, canvasY);
|
|
318
321
|
});
|
|
319
322
|
canvas?.addEventListener('mousemove', (event) => {
|
|
320
323
|
if (!glass || !uploadedImage || !canvas) return;
|
|
321
|
-
const rect =
|
|
324
|
+
const rect = getCanvasRect();
|
|
325
|
+
if (!rect) return;
|
|
322
326
|
const localX = event.clientX - rect.left;
|
|
323
327
|
const localY = event.clientY - rect.top;
|
|
324
328
|
glass.style.opacity = '1';
|
|
@@ -103,7 +103,7 @@ const regions = [
|
|
|
103
103
|
<div class="forensic-sex-field-body">
|
|
104
104
|
<div class="forensic-sex-control-side">
|
|
105
105
|
<div class="forensic-sex-slider-container">
|
|
106
|
-
<input type="range" id={`input-${field.id}`} name={field.id} min="0" max="5" value="0" class="forensic-sex-slider" />
|
|
106
|
+
<input type="range" id={`input-${field.id}`} name={field.id} min="0" max="5" value="0" class="forensic-sex-slider" aria-label={field.label} />
|
|
107
107
|
<div class="forensic-sex-slider-labels">
|
|
108
108
|
<span>Ø</span>
|
|
109
109
|
<span>1</span>
|
|
@@ -61,7 +61,7 @@ const ancestries = [
|
|
|
61
61
|
<label for="input-length-val" id="length-label-text">{ui.lengthLabel} ({ui.unitsCm})</label>
|
|
62
62
|
<div class="length-input-container">
|
|
63
63
|
<input type="number" id="input-length-val" class="selector-number" step="0.1" />
|
|
64
|
-
<input type="range" id="input-length" class="selector-range" step="0.1" />
|
|
64
|
+
<input type="range" id="input-length" class="selector-range" step="0.1" aria-label={`${ui.lengthLabel} ${ui.unitsCm}`} />
|
|
65
65
|
</div>
|
|
66
66
|
</div>
|
|
67
67
|
|
|
@@ -16,7 +16,7 @@ const { ui } = Astro.props;
|
|
|
16
16
|
|
|
17
17
|
<div class="widmark-field">
|
|
18
18
|
<span>{ui.weight}</span>
|
|
19
|
-
<input type="number" id="widmark-weight" class="widmark-input" min="30" max="250" value="80" />
|
|
19
|
+
<input type="number" id="widmark-weight" class="widmark-input" min="30" max="250" value="80" aria-label={ui.weight} />
|
|
20
20
|
</div>
|
|
21
21
|
|
|
22
22
|
<div class="widmark-field">
|
|
@@ -35,7 +35,7 @@ const { ui } = Astro.props;
|
|
|
35
35
|
|
|
36
36
|
<div class="widmark-field">
|
|
37
37
|
<span>{ui.hydration}</span>
|
|
38
|
-
<select id="widmark-hydration" class="widmark-select">
|
|
38
|
+
<select id="widmark-hydration" class="widmark-select" aria-label={ui.hydration}>
|
|
39
39
|
<option value="low">{ui.hydrationLow}</option>
|
|
40
40
|
<option value="normal" selected>{ui.hydrationNormal}</option>
|
|
41
41
|
<option value="high">{ui.hydrationHigh}</option>
|
|
@@ -44,7 +44,7 @@ const { ui } = Astro.props;
|
|
|
44
44
|
|
|
45
45
|
<div class="widmark-field">
|
|
46
46
|
<span>{ui.stomachState}</span>
|
|
47
|
-
<select id="widmark-stomach" class="widmark-select">
|
|
47
|
+
<select id="widmark-stomach" class="widmark-select" aria-label={ui.stomachState}>
|
|
48
48
|
<option value="empty">{ui.stomachEmpty}</option>
|
|
49
49
|
<option value="light" selected>{ui.stomachLight}</option>
|
|
50
50
|
<option value="full">{ui.stomachFull}</option>
|
|
@@ -64,15 +64,15 @@ const { ui } = Astro.props;
|
|
|
64
64
|
<div class="widmark-drink-form-grid">
|
|
65
65
|
<div class="widmark-field">
|
|
66
66
|
<span>{ui.drinkVolume}</span>
|
|
67
|
-
<input type="number" id="widmark-drink-vol" class="widmark-input" value="330" min="10" max="2000" />
|
|
67
|
+
<input type="number" id="widmark-drink-vol" class="widmark-input" value="330" min="10" max="2000" aria-label={ui.drinkVolume} />
|
|
68
68
|
</div>
|
|
69
69
|
<div class="widmark-field">
|
|
70
70
|
<span>{ui.drinkAbv}</span>
|
|
71
|
-
<input type="number" id="widmark-drink-abv" class="widmark-input" value="5" min="0.1" max="100" step="0.1" />
|
|
71
|
+
<input type="number" id="widmark-drink-abv" class="widmark-input" value="5" min="0.1" max="100" step="0.1" aria-label={ui.drinkAbv} />
|
|
72
72
|
</div>
|
|
73
73
|
<div class="widmark-field">
|
|
74
74
|
<span>{ui.drinkTime}</span>
|
|
75
|
-
<input type="number" id="widmark-drink-time" class="widmark-input" value="0" min="0" max="11.9" step="0.1" />
|
|
75
|
+
<input type="number" id="widmark-drink-time" class="widmark-input" value="0" min="0" max="11.9" step="0.1" aria-label={ui.drinkTime} />
|
|
76
76
|
</div>
|
|
77
77
|
<button type="button" id="widmark-add-drink" class="widmark-btn-primary">
|
|
78
78
|
<span>{ui.addDrink}</span>
|