@jjlmoya/utils-forensic-science 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +3 -2
  2. package/src/category/i18n/es.ts +9 -9
  3. package/src/category/index.ts +3 -1
  4. package/src/entries.ts +5 -1
  5. package/src/index.ts +1 -0
  6. package/src/tests/locale_completeness.test.ts +2 -2
  7. package/src/tests/tool_validation.test.ts +2 -2
  8. package/src/tool/bloodstain-pattern-origin-analyzer/bibliography.astro +9 -0
  9. package/src/tool/bloodstain-pattern-origin-analyzer/bibliography.ts +7 -0
  10. package/src/tool/bloodstain-pattern-origin-analyzer/bloodstain-pattern-origin-analyzer.css +438 -0
  11. package/src/tool/bloodstain-pattern-origin-analyzer/component.astro +558 -0
  12. package/src/tool/bloodstain-pattern-origin-analyzer/entry.ts +32 -0
  13. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/de.ts +119 -0
  14. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/en.ts +119 -0
  15. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/es.ts +119 -0
  16. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/fr.ts +119 -0
  17. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/id.ts +119 -0
  18. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/it.ts +119 -0
  19. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/ja.ts +119 -0
  20. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/ko.ts +119 -0
  21. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/nl.ts +119 -0
  22. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/pl.ts +119 -0
  23. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/pt.ts +119 -0
  24. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/ru.ts +119 -0
  25. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/sv.ts +119 -0
  26. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/tr.ts +119 -0
  27. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/zh.ts +119 -0
  28. package/src/tool/bloodstain-pattern-origin-analyzer/index.ts +11 -0
  29. package/src/tool/bloodstain-pattern-origin-analyzer/logic.test.ts +23 -0
  30. package/src/tool/bloodstain-pattern-origin-analyzer/logic.ts +137 -0
  31. package/src/tool/bloodstain-pattern-origin-analyzer/seo.astro +10 -0
  32. package/src/tools.ts +3 -1
@@ -0,0 +1,558 @@
1
+ ---
2
+ import './bloodstain-pattern-origin-analyzer.css';
3
+ import { Icon } from 'astro-icon/components';
4
+
5
+ interface Props {
6
+ ui: Record<string, string>;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ ---
11
+
12
+ <div class="bpa-shell">
13
+ <div class="bpa-card">
14
+ <div class="bpa-toolbar">
15
+ <div class="bpa-segment" role="group" aria-label={ui.unitsLabel}>
16
+ <button type="button" class="is-active" data-unit="metric">{ui.metric}</button>
17
+ <button type="button" data-unit="imperial">{ui.imperial}</button>
18
+ </div>
19
+ <div class="bpa-active-strip">
20
+ <span id="bpa-active-dot"></span>
21
+ <strong id="bpa-active-stain">{ui.stainLabel} A</strong>
22
+ </div>
23
+ <div class="bpa-actions">
24
+ <button type="button" id="bpa-add"><Icon name="mdi:plus" aria-hidden="true" />{ui.addStain}</button>
25
+ <button type="button" id="bpa-reset"><Icon name="mdi:restore" aria-hidden="true" />{ui.reset}</button>
26
+ </div>
27
+ </div>
28
+
29
+ <div class="bpa-stage">
30
+ <div class="bpa-surface-panel">
31
+ <div class="bpa-surface-wrap">
32
+ <canvas id="bpa-surface" width="820" height="520" aria-label={ui.surface}></canvas>
33
+ </div>
34
+ </div>
35
+
36
+ <div class="bpa-view-panel">
37
+ <div id="bpa-three" class="bpa-three" aria-label={ui.viewport3dLabel}></div>
38
+ <p>{ui.rotateHint}</p>
39
+ </div>
40
+ </div>
41
+
42
+ <div class="bpa-focus-panel">
43
+ <div class="bpa-focus-title">
44
+ <span id="bpa-focus-swatch"></span>
45
+ <strong id="bpa-focus-name">{ui.stainLabel} A</strong>
46
+ </div>
47
+ <label>
48
+ <span>{ui.width}</span>
49
+ <input id="bpa-focus-width" type="range" min="0.2" max="3" step="0.1" />
50
+ <output id="bpa-focus-width-value">0 {ui.cm}</output>
51
+ </label>
52
+ <label>
53
+ <span>{ui.length}</span>
54
+ <input id="bpa-focus-length" type="range" min="0.5" max="6" step="0.1" />
55
+ <output id="bpa-focus-length-value">0 {ui.cm}</output>
56
+ </label>
57
+ <label>
58
+ <span>{ui.rotation}</span>
59
+ <input id="bpa-focus-rotation" type="range" min="-90" max="90" step="1" />
60
+ <output id="bpa-focus-rotation-value">0 {ui.degree}</output>
61
+ </label>
62
+ </div>
63
+
64
+ <div class="bpa-dashboard">
65
+ <section class="bpa-readout">
66
+ <span>{ui.origin}</span>
67
+ <strong id="bpa-origin">0, 0, 0 {ui.cm}</strong>
68
+ </section>
69
+ <section class="bpa-readout">
70
+ <span>{ui.spread}</span>
71
+ <strong id="bpa-spread">0 {ui.cm}</strong>
72
+ </section>
73
+ <section class="bpa-readout">
74
+ <span>{ui.confidence}</span>
75
+ <strong id="bpa-confidence">{ui.low}</strong>
76
+ </section>
77
+ <section class="bpa-readout">
78
+ <span>{ui.impact}</span>
79
+ <strong id="bpa-impact">0 {ui.degree}</strong>
80
+ </section>
81
+ </div>
82
+
83
+ <div class="bpa-table-wrap">
84
+ <table class="bpa-table">
85
+ <caption>{ui.table}</caption>
86
+ <thead>
87
+ <tr>
88
+ <th>{ui.x}</th>
89
+ <th>{ui.y}</th>
90
+ <th>{ui.width}</th>
91
+ <th>{ui.length}</th>
92
+ <th>{ui.rotation}</th>
93
+ <th></th>
94
+ </tr>
95
+ </thead>
96
+ <tbody id="bpa-rows"></tbody>
97
+ </table>
98
+ </div>
99
+
100
+ <p class="bpa-disclaimer">{ui.disclaimer}</p>
101
+ </div>
102
+ </div>
103
+
104
+ <template id="bpa-delete-icon">
105
+ <Icon name="mdi:trash-can-outline" aria-hidden="true" />
106
+ </template>
107
+ <script id="bpa-ui" type="application/json" set:html={JSON.stringify(ui)}></script>
108
+
109
+ <script>
110
+ import * as THREE from 'three';
111
+ import { analyzeBloodstains } from './logic';
112
+ import type { BloodstainInput, OriginEstimate } from './logic';
113
+
114
+ const ui = JSON.parse(document.getElementById('bpa-ui')?.textContent || '{}');
115
+ const surface = document.getElementById('bpa-surface') as HTMLCanvasElement | null;
116
+ const rows = document.getElementById('bpa-rows');
117
+ const threeHost = document.getElementById('bpa-three');
118
+ const originEl = document.getElementById('bpa-origin');
119
+ const spreadEl = document.getElementById('bpa-spread');
120
+ const confidenceEl = document.getElementById('bpa-confidence');
121
+ const impactEl = document.getElementById('bpa-impact');
122
+ const activeEl = document.getElementById('bpa-active-stain');
123
+ const activeDotEl = document.getElementById('bpa-active-dot');
124
+ const focusNameEl = document.getElementById('bpa-focus-name');
125
+ const focusSwatchEl = document.getElementById('bpa-focus-swatch');
126
+ const focusWidthEl = document.getElementById('bpa-focus-width') as HTMLInputElement | null;
127
+ const focusLengthEl = document.getElementById('bpa-focus-length') as HTMLInputElement | null;
128
+ const focusRotationEl = document.getElementById('bpa-focus-rotation') as HTMLInputElement | null;
129
+ const focusWidthValueEl = document.getElementById('bpa-focus-width-value');
130
+ const focusLengthValueEl = document.getElementById('bpa-focus-length-value');
131
+ const focusRotationValueEl = document.getElementById('bpa-focus-rotation-value');
132
+ const addBtn = document.getElementById('bpa-add');
133
+ const resetBtn = document.getElementById('bpa-reset');
134
+ const deleteIconTemplate = document.getElementById('bpa-delete-icon') as HTMLTemplateElement | null;
135
+ const unitButtons = Array.from(document.querySelectorAll<HTMLButtonElement>('.bpa-segment button'));
136
+ const STORAGE_KEY = 'bloodstain-pattern-origin-analyzer';
137
+ const INCH_PER_CM = 0.3937007874;
138
+ const palette = ['#b3203f', '#e05a47', '#8e1734', '#d93f76', '#aa2f23', '#f07a5f'];
139
+ let unit: 'metric' | 'imperial' = 'metric';
140
+ let selectedStainId = 'A';
141
+ let draggingStainId: string | null = null;
142
+ let stains: BloodstainInput[] = [
143
+ { id: 'A', xCm: -34, yCm: -18, widthCm: 1.1, lengthCm: 2.4, rotationDeg: 34 },
144
+ { id: 'B', xCm: 8, yCm: 9, widthCm: 1.3, lengthCm: 3.1, rotationDeg: 10 },
145
+ { id: 'C', xCm: 38, yCm: -7, widthCm: 1, lengthCm: 2.2, rotationDeg: -22 },
146
+ ];
147
+
148
+ const scene = new THREE.Scene();
149
+ const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 1200);
150
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
151
+ const group = new THREE.Group();
152
+ const originMesh = new THREE.Mesh(
153
+ new THREE.SphereGeometry(4.5, 32, 16),
154
+ new THREE.MeshStandardMaterial({ color: 0xf7d046, emissive: 0x4d3200, roughness: 0.36 })
155
+ );
156
+ let dragging3d = false;
157
+ let lastX = 0;
158
+
159
+ function display(valueCm: number): string {
160
+ const value = unit === 'metric' ? valueCm : valueCm * INCH_PER_CM;
161
+ const suffix = unit === 'metric' ? ui.cm : ui.inch;
162
+ return `${value.toFixed(value < 10 ? 1 : 0)} ${suffix}`;
163
+ }
164
+
165
+ function inputToCm(value: number): number {
166
+ return unit === 'metric' ? value : value / INCH_PER_CM;
167
+ }
168
+
169
+ function cmToInput(value: number): number {
170
+ return unit === 'metric' ? value : value * INCH_PER_CM;
171
+ }
172
+
173
+ function confidenceLabel(confidence: OriginEstimate['confidence']): string {
174
+ return ui[confidence] || confidence;
175
+ }
176
+
177
+ function selectedIndex(): number {
178
+ return Math.max(0, stains.findIndex((stain) => stain.id === selectedStainId));
179
+ }
180
+
181
+ function selectedStain(): BloodstainInput | undefined {
182
+ return stains.find((stain) => stain.id === selectedStainId) ?? stains[0];
183
+ }
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
+ function canvasMetrics() {
211
+ if (!surface) return null;
212
+ const rect = surface.getBoundingClientRect();
213
+ return {
214
+ rect,
215
+ cx: surface.width / 2,
216
+ cy: surface.height / 2,
217
+ scale: 4.6,
218
+ pointerScaleX: surface.width / Math.max(rect.width, 1),
219
+ pointerScaleY: surface.height / Math.max(rect.height, 1),
220
+ };
221
+ }
222
+
223
+ function stainScreenPoint(stain: BloodstainInput) {
224
+ const metrics = canvasMetrics();
225
+ if (!metrics) return { x: 0, y: 0 };
226
+ return {
227
+ x: metrics.cx + stain.xCm * metrics.scale,
228
+ y: metrics.cy - stain.yCm * metrics.scale,
229
+ };
230
+ }
231
+
232
+ function eventToSurface(event: PointerEvent) {
233
+ const metrics = canvasMetrics();
234
+ if (!metrics) return null;
235
+ return {
236
+ x: (event.clientX - metrics.rect.left) * metrics.pointerScaleX,
237
+ y: (event.clientY - metrics.rect.top) * metrics.pointerScaleY,
238
+ metrics,
239
+ };
240
+ }
241
+
242
+ function findStainAt(event: PointerEvent): BloodstainInput | null {
243
+ const pointer = eventToSurface(event);
244
+ if (!pointer) return null;
245
+ let best: { stain: BloodstainInput; distance: number } | null = null;
246
+ stains.forEach((stain) => {
247
+ const point = stainScreenPoint(stain);
248
+ const distance = Math.hypot(pointer.x - point.x, pointer.y - point.y);
249
+ if (distance < 30 && (!best || distance < best.distance)) {
250
+ best = { stain, distance };
251
+ }
252
+ });
253
+ return best?.stain ?? null;
254
+ }
255
+
256
+ function setupThree() {
257
+ if (!threeHost) return;
258
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
259
+ threeHost.appendChild(renderer.domElement);
260
+ scene.add(group);
261
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x3d2830, 2.5));
262
+ const light = new THREE.DirectionalLight(0xffffff, 2);
263
+ light.position.set(70, -120, 140);
264
+ scene.add(light);
265
+ const plane = new THREE.Mesh(
266
+ new THREE.PlaneGeometry(130, 90, 13, 9),
267
+ new THREE.MeshStandardMaterial({ color: 0x33282d, wireframe: true, transparent: true, opacity: 0.48 })
268
+ );
269
+ group.add(plane);
270
+ group.add(originMesh);
271
+ camera.position.set(0, -165, 95);
272
+ camera.lookAt(0, 0, 35);
273
+ resizeThree();
274
+ renderer.setAnimationLoop(() => renderer.render(scene, camera));
275
+ }
276
+
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
+ function clearDynamic3d() {
286
+ for (let index = group.children.length - 1; index >= 0; index--) {
287
+ const child = group.children[index];
288
+ if (child.userData.dynamic) group.remove(child);
289
+ }
290
+ }
291
+
292
+ function render3d(estimate: OriginEstimate) {
293
+ clearDynamic3d();
294
+ originMesh.position.set(estimate.point.x, estimate.point.y, estimate.point.z);
295
+ for (const [index, line] of estimate.lines.entries()) {
296
+ const color = new THREE.Color(palette[index % palette.length]);
297
+ const start = new THREE.Vector3(line.originPoint.x, line.originPoint.y, line.originPoint.z);
298
+ const end = new THREE.Vector3(
299
+ line.originPoint.x + line.direction.x * 155,
300
+ line.originPoint.y + line.direction.y * 155,
301
+ line.originPoint.z + line.direction.z * 155
302
+ );
303
+ const geometry = new THREE.BufferGeometry().setFromPoints([start, end]);
304
+ const mesh = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color, linewidth: 2 }));
305
+ mesh.userData.dynamic = true;
306
+ group.add(mesh);
307
+ const dot = new THREE.Mesh(
308
+ new THREE.SphereGeometry(2.4, 16, 8),
309
+ new THREE.MeshStandardMaterial({ color })
310
+ );
311
+ dot.position.copy(start);
312
+ dot.userData.dynamic = true;
313
+ group.add(dot);
314
+ }
315
+ }
316
+
317
+ function drawSurface(estimate: OriginEstimate) {
318
+ if (!surface) return;
319
+ const ctx = surface.getContext('2d');
320
+ if (!ctx) return;
321
+ const width = surface.width;
322
+ const height = surface.height;
323
+ ctx.clearRect(0, 0, width, height);
324
+ const dark = document.documentElement.classList.contains('theme-dark');
325
+ const gradient = ctx.createLinearGradient(0, 0, width, height);
326
+ gradient.addColorStop(0, dark ? '#1b2026' : '#fffaf7');
327
+ gradient.addColorStop(0.55, dark ? '#14181d' : '#fff3ef');
328
+ gradient.addColorStop(1, dark ? '#0f1216' : '#f9ebe6');
329
+ ctx.fillStyle = gradient;
330
+ ctx.fillRect(0, 0, width, height);
331
+ ctx.strokeStyle = dark ? 'rgba(255,255,255,.075)' : 'rgba(70,20,30,.105)';
332
+ for (let x = 40; x < width; x += 40) {
333
+ ctx.beginPath();
334
+ ctx.moveTo(x, 0);
335
+ ctx.lineTo(x, height);
336
+ ctx.stroke();
337
+ }
338
+ for (let y = 40; y < height; y += 40) {
339
+ ctx.beginPath();
340
+ ctx.moveTo(0, y);
341
+ ctx.lineTo(width, y);
342
+ ctx.stroke();
343
+ }
344
+ const cx = width / 2;
345
+ const cy = height / 2;
346
+ const scale = 4.6;
347
+ ctx.strokeStyle = dark ? 'rgba(125,211,252,.22)' : 'rgba(179,32,63,.2)';
348
+ ctx.lineWidth = 1.5;
349
+ ctx.beginPath();
350
+ ctx.moveTo(cx, 0);
351
+ ctx.lineTo(cx, height);
352
+ ctx.moveTo(0, cy);
353
+ ctx.lineTo(width, cy);
354
+ ctx.stroke();
355
+ estimate.lines.forEach((line, index) => {
356
+ const sx = cx + line.stain.xCm * scale;
357
+ const sy = cy - line.stain.yCm * scale;
358
+ const selected = line.stain.id === selectedStainId;
359
+ const color = palette[index % palette.length];
360
+ ctx.globalAlpha = selected ? 0.22 : 0.1;
361
+ ctx.strokeStyle = color;
362
+ ctx.lineWidth = selected ? 3 : 2;
363
+ ctx.beginPath();
364
+ ctx.moveTo(sx - line.direction.x * 35, sy + line.direction.y * 35);
365
+ ctx.lineTo(sx + line.direction.x * 170, sy - line.direction.y * 170);
366
+ ctx.stroke();
367
+ ctx.globalAlpha = 1;
368
+ ctx.save();
369
+ ctx.translate(sx, sy);
370
+ ctx.rotate(line.stain.rotationDeg * Math.PI / 180);
371
+ if (selected) {
372
+ ctx.strokeStyle = 'rgba(255,255,255,.95)';
373
+ ctx.lineWidth = 7;
374
+ ctx.beginPath();
375
+ ctx.ellipse(0, 0, line.stain.lengthCm * scale + 8, line.stain.widthCm * scale + 8, 0, 0, Math.PI * 2);
376
+ ctx.stroke();
377
+ }
378
+ ctx.fillStyle = color;
379
+ ctx.globalAlpha = selected ? 0.95 : 0.78;
380
+ ctx.beginPath();
381
+ ctx.ellipse(0, 0, line.stain.lengthCm * scale, line.stain.widthCm * scale, 0, 0, Math.PI * 2);
382
+ ctx.fill();
383
+ ctx.strokeStyle = dark ? 'rgba(226,232,240,.72)' : 'rgba(75,9,20,.28)';
384
+ ctx.lineWidth = 1;
385
+ ctx.stroke();
386
+ ctx.restore();
387
+ ctx.fillStyle = dark ? '#f8fafc' : '#2f121a';
388
+ ctx.font = '700 13px system-ui, sans-serif';
389
+ ctx.fillText(line.stain.id, sx + 11, sy - 10);
390
+ ctx.globalAlpha = 1;
391
+ });
392
+ }
393
+
394
+ function renderRows() {
395
+ if (!rows) return;
396
+ rows.innerHTML = '';
397
+ stains.forEach((stain) => {
398
+ const row = document.createElement('tr');
399
+ if (stain.id === selectedStainId) row.className = 'is-selected';
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>
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
+ `;
408
+ rows.appendChild(row);
409
+ });
410
+ }
411
+
412
+ function update() {
413
+ const estimate = analyzeBloodstains(stains);
414
+ const averageImpact = estimate.lines.reduce((sum, line) => sum + line.impactAngleDeg, 0) / Math.max(estimate.lines.length, 1);
415
+ if (originEl) originEl.textContent = `${display(estimate.point.x)}, ${display(estimate.point.y)}, ${display(estimate.point.z)}`;
416
+ if (spreadEl) spreadEl.textContent = display(estimate.spreadCm);
417
+ if (confidenceEl) confidenceEl.textContent = confidenceLabel(estimate.confidence);
418
+ if (confidenceEl) confidenceEl.dataset.confidence = estimate.confidence;
419
+ if (impactEl) impactEl.textContent = `${averageImpact.toFixed(1)} ${ui.degree}`;
420
+ if (activeEl) activeEl.textContent = `${ui.stainLabel} ${selectedStainId}`;
421
+ if (activeDotEl) activeDotEl.style.background = palette[selectedIndex() % palette.length];
422
+ syncFocusPanel();
423
+ drawSurface(estimate);
424
+ render3d(estimate);
425
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(stains));
426
+ }
427
+
428
+ function handleFocusInput() {
429
+ const stain = selectedStain();
430
+ if (!stain) return;
431
+ if (focusWidthEl) stain.widthCm = inputToCm(Number(focusWidthEl.value));
432
+ if (focusLengthEl) stain.lengthCm = Math.max(stain.widthCm, inputToCm(Number(focusLengthEl.value)));
433
+ if (focusRotationEl) stain.rotationDeg = Number(focusRotationEl.value);
434
+ renderRows();
435
+ update();
436
+ }
437
+
438
+ focusWidthEl?.addEventListener('input', handleFocusInput);
439
+ focusLengthEl?.addEventListener('input', handleFocusInput);
440
+ focusRotationEl?.addEventListener('input', handleFocusInput);
441
+
442
+ surface?.addEventListener('pointerdown', (event) => {
443
+ const stain = findStainAt(event);
444
+ if (!stain) return;
445
+ draggingStainId = stain.id;
446
+ selectedStainId = stain.id;
447
+ surface.setPointerCapture(event.pointerId);
448
+ surface.classList.add('is-dragging');
449
+ update();
450
+ });
451
+
452
+ surface?.addEventListener('pointermove', (event) => {
453
+ const hovered = findStainAt(event);
454
+ if (surface) surface.classList.toggle('can-grab', Boolean(hovered || draggingStainId));
455
+ if (!draggingStainId) return;
456
+ const pointer = eventToSurface(event);
457
+ const stain = stains.find((item) => item.id === draggingStainId);
458
+ if (!pointer || !stain) return;
459
+ stain.xCm = (pointer.x - pointer.metrics.cx) / pointer.metrics.scale;
460
+ stain.yCm = (pointer.metrics.cy - pointer.y) / pointer.metrics.scale;
461
+ renderRows();
462
+ update();
463
+ });
464
+
465
+ surface?.addEventListener('pointerup', () => {
466
+ draggingStainId = null;
467
+ surface.classList.remove('is-dragging');
468
+ });
469
+
470
+ surface?.addEventListener('pointercancel', () => {
471
+ draggingStainId = null;
472
+ surface.classList.remove('is-dragging');
473
+ });
474
+
475
+ rows?.addEventListener('input', (event) => {
476
+ const input = event.target as HTMLInputElement;
477
+ const id = input.dataset.id;
478
+ const field = input.dataset.field;
479
+ const stain = stains.find((item) => item.id === id);
480
+ if (!stain || !field) return;
481
+ selectedStainId = stain.id;
482
+ const value = Number(input.value);
483
+ if (field === 'rotationDeg') {
484
+ stain.rotationDeg = value;
485
+ } else if (field === 'xCm' || field === 'yCm' || field === 'widthCm' || field === 'lengthCm') {
486
+ stain[field] = inputToCm(value);
487
+ }
488
+ update();
489
+ });
490
+
491
+ rows?.addEventListener('click', (event) => {
492
+ const button = (event.target as HTMLElement).closest<HTMLButtonElement>('button[data-remove]');
493
+ const rowInput = (event.target as HTMLElement).closest<HTMLInputElement>('input[data-id]');
494
+ if (rowInput?.dataset.id) selectedStainId = rowInput.dataset.id;
495
+ if (!button) {
496
+ renderRows();
497
+ update();
498
+ return;
499
+ }
500
+ stains = stains.filter((stain) => stain.id !== button.dataset.remove);
501
+ if (!stains.some((stain) => stain.id === selectedStainId)) selectedStainId = stains[0]?.id ?? 'A';
502
+ renderRows();
503
+ update();
504
+ });
505
+
506
+ addBtn?.addEventListener('click', () => {
507
+ const id = String.fromCharCode(65 + stains.length);
508
+ stains = [...stains, { id, xCm: -20 + stains.length * 14, yCm: 16 - stains.length * 6, widthCm: 1, lengthCm: 2.4, rotationDeg: 18 - stains.length * 9 }];
509
+ selectedStainId = id;
510
+ renderRows();
511
+ update();
512
+ });
513
+
514
+ resetBtn?.addEventListener('click', () => {
515
+ localStorage.removeItem(STORAGE_KEY);
516
+ stains = [
517
+ { id: 'A', xCm: -34, yCm: -18, widthCm: 1.1, lengthCm: 2.4, rotationDeg: 34 },
518
+ { id: 'B', xCm: 8, yCm: 9, widthCm: 1.3, lengthCm: 3.1, rotationDeg: 10 },
519
+ { id: 'C', xCm: 38, yCm: -7, widthCm: 1, lengthCm: 2.2, rotationDeg: -22 },
520
+ ];
521
+ selectedStainId = 'A';
522
+ renderRows();
523
+ update();
524
+ });
525
+
526
+ unitButtons.forEach((button) => {
527
+ button.addEventListener('click', () => {
528
+ unit = button.dataset.unit === 'imperial' ? 'imperial' : 'metric';
529
+ unitButtons.forEach((item) => item.classList.toggle('is-active', item === button));
530
+ renderRows();
531
+ update();
532
+ });
533
+ });
534
+
535
+ renderer.domElement.addEventListener('pointerdown', (event) => {
536
+ dragging3d = true;
537
+ lastX = event.clientX;
538
+ renderer.domElement.setPointerCapture(event.pointerId);
539
+ });
540
+ renderer.domElement.addEventListener('pointermove', (event) => {
541
+ if (!dragging3d) return;
542
+ group.rotation.z += (event.clientX - lastX) * 0.008;
543
+ lastX = event.clientX;
544
+ });
545
+ renderer.domElement.addEventListener('pointerup', () => {
546
+ dragging3d = false;
547
+ });
548
+
549
+ try {
550
+ const saved = localStorage.getItem(STORAGE_KEY);
551
+ if (saved) stains = JSON.parse(saved);
552
+ } catch {}
553
+
554
+ setupThree();
555
+ renderRows();
556
+ update();
557
+ window.addEventListener('resize', resizeThree);
558
+ </script>
@@ -0,0 +1,32 @@
1
+ import type { ScienceToolEntry, ToolLocaleContent } from '../../types';
2
+
3
+ export interface BloodstainPatternUI {
4
+ [key: string]: string;
5
+ }
6
+
7
+ export type BloodstainPatternLocaleContent = ToolLocaleContent<BloodstainPatternUI>;
8
+
9
+ export const bloodstainPatternOriginAnalyzer: ScienceToolEntry<BloodstainPatternUI> = {
10
+ id: 'bloodstain-pattern-origin-analyzer',
11
+ icons: {
12
+ bg: 'mdi:axis-arrow',
13
+ fg: 'mdi:blood-bag',
14
+ },
15
+ i18n: {
16
+ en: () => import('./i18n/en').then((m) => m.content),
17
+ de: () => import('./i18n/de').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
+ };