@jjlmoya/utils-forensic-science 1.3.0 → 1.5.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-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 +5 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
2
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
3
|
+
|
|
4
|
+
const slug = 'fire-pattern-origin-analyzer';
|
|
5
|
+
const title = '火灾模式起火点分析器';
|
|
6
|
+
const description = '在房间平面图上绘制V形模式、深层炭化、烟尘阴影和清洁燃烧指标,然后投射火灾蔓延向量以估算最可能的起火区域。';
|
|
7
|
+
|
|
8
|
+
const howTo = [
|
|
9
|
+
{
|
|
10
|
+
name: '导入现场平面图',
|
|
11
|
+
text: '在放置证据向量之前,将平面图图像拖入工作区或从磁盘加载。',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: '点击打开径向证据选择器',
|
|
15
|
+
text: '将指针放在燃烧痕迹上,打开上下文径向菜单,直接在地图上选择指标类型。',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: '拖动以细化向量并观察收敛',
|
|
19
|
+
text: '通过画布吸附延长每条传播向量,并随着证据积累实时观察概率场的重新计算。',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: '导出技术快照',
|
|
23
|
+
text: '生成包含起源坐标、置信度、方差、图层状态和完整向量集的结构化报告,供后续审查。',
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const faq = [
|
|
28
|
+
{
|
|
29
|
+
question: '燃烧模式能证明火灾的确切起火点吗?',
|
|
30
|
+
answer: '不能。火灾模式有助于生成和检验起源假设,但通风、灭火、燃料负荷、闪燃和火灾后干扰可能扭曲模式。真正的起火点确定需要系统调查。',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
question: '为什么V形模式在火灾调查中很重要?',
|
|
34
|
+
answer: 'V形模式通常反映火焰在垂直表面上向上和向外蔓延。它们可能指向较低的起火区域,但在得出结论之前必须与其他证据进行比较。',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
question: '深层炭化表明什么?',
|
|
38
|
+
answer: '深层炭化可能表明加热时间更长、热通量更高或燃料效应。这是有用的背景信息,但由于材料炭化速率不同,不会自动标记起火点。',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
question: '为什么要使用多个向量而不是一个箭头?',
|
|
42
|
+
answer: '单一模式可能会产生误导。多个独立向量减少了一面损坏墙壁、一个燃料包或一条通风路径的影响。',
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export const content: ToolLocaleContent = {
|
|
47
|
+
slug,
|
|
48
|
+
title,
|
|
49
|
+
description,
|
|
50
|
+
ui: {
|
|
51
|
+
planAria: '用于绘制火灾蔓延向量的交互式法庭科学工作区',
|
|
52
|
+
vPattern: 'V形模式',
|
|
53
|
+
deepChar: '深层炭化',
|
|
54
|
+
sootShadow: '烟尘阴影',
|
|
55
|
+
cleanBurn: '清洁燃烧',
|
|
56
|
+
loadPlan: '加载平面图',
|
|
57
|
+
generateReport: '生成报告',
|
|
58
|
+
pointerLabel: '指针',
|
|
59
|
+
resetVectors: '重置',
|
|
60
|
+
estimatedOrigin: '估算起火点',
|
|
61
|
+
visibleLayers: '可见证据图层',
|
|
62
|
+
vectorsLabel: '向量',
|
|
63
|
+
confidenceLabel: '置信度',
|
|
64
|
+
varianceLabel: '方差',
|
|
65
|
+
modeLabel: '模式',
|
|
66
|
+
guideSummary: '如何使用此工具',
|
|
67
|
+
flowPanelTitle: '工作流程',
|
|
68
|
+
legendPanelTitle: '证据图例',
|
|
69
|
+
readoutPanelTitle: '结果面板',
|
|
70
|
+
flow1Kicker: '第1步',
|
|
71
|
+
flow1Icon: '1',
|
|
72
|
+
flow1Title: '加载',
|
|
73
|
+
flow1Text: '上传现场平面图',
|
|
74
|
+
flow2Kicker: '第2步',
|
|
75
|
+
flow2Icon: '2',
|
|
76
|
+
flow2Title: '选择线索',
|
|
77
|
+
flow2Text: '点击燃烧痕迹',
|
|
78
|
+
flow3Kicker: '第3步',
|
|
79
|
+
flow3Icon: '3',
|
|
80
|
+
flow3Title: '拖动向量',
|
|
81
|
+
flow3Text: '瞄准火灾方向',
|
|
82
|
+
flow4Kicker: '第4步',
|
|
83
|
+
flow4Icon: '4',
|
|
84
|
+
flow4Title: '读取结果',
|
|
85
|
+
flow4Text: '检查底部面板并导出',
|
|
86
|
+
basicsTitle: '首先应该做什么',
|
|
87
|
+
basicsIntro: '加载清晰的平面图,只标记你能合理解释的模式,并在不同表面上分布向量,而不是重复相同的线索。',
|
|
88
|
+
indicatorsTitle: '每种证据类型的含义',
|
|
89
|
+
vPatternShort: '墙面燃烧向上敞开',
|
|
90
|
+
vPatternExplain: 'V形模式是常见于墙壁上的向上和向外燃烧形状。它可能表明热量和火焰从较低区域上升,但不是确切起火点的自动证明。',
|
|
91
|
+
deepCharShort: '区域燃烧更剧烈或更持久',
|
|
92
|
+
deepCharExplain: '深层炭化意味着某个区域的材料比周围看起来更焦黑或耗损。这可能表明加热时间更长或更强烈,但燃料类型和材料厚度影响很大。',
|
|
93
|
+
sootShadowShort: '烟气被阻挡或转向',
|
|
94
|
+
sootShadowExplain: '烟尘阴影是一个受保护或不同暗度的区域,表明物体、表面或气流改变了烟尘沉积方式。它有助于还原当时存在什么或烟气如何移动。',
|
|
95
|
+
cleanBurnShort: '热量或气流导致烟尘减少',
|
|
96
|
+
cleanBurnExplain: '清洁燃烧指烟尘看起来更淡、被清除或缺失的区域,因为热量、通风或直接火焰暴露对其产生了不同影响。这很有用,但并非每个清洁区域都标记起火点。',
|
|
97
|
+
resultsTitle: '如何读取结果',
|
|
98
|
+
resultsIntro: '将底部面板用作绘图的快速健康检查,而非最终的法庭科学结论。',
|
|
99
|
+
resultsPoint1: '有多少条证据线处于活动状态。',
|
|
100
|
+
resultsPoint2: '活动向量的一致程度。',
|
|
101
|
+
resultsPoint3: '起火区域的宽度有多大。',
|
|
102
|
+
resultsPoint4: '工具期望你接下来做什么。',
|
|
103
|
+
awaitingVector: '等待向量',
|
|
104
|
+
selectIndicator: '选择指标',
|
|
105
|
+
refiningVector: '细化向量中',
|
|
106
|
+
needsMoreVectors: '需要更多向量',
|
|
107
|
+
wideArea: '宽阔的起火区域',
|
|
108
|
+
focusedArea: '集中的起火区域',
|
|
109
|
+
dropPlan: '拖放平面图图像或点击上传',
|
|
110
|
+
needsMoreVectorsText: '至少绘制两条明确的方向箭头。三个或更多独立观察通常会使收敛更容易解释。',
|
|
111
|
+
wideAreaText: '投影线相交,但散布仍然很宽。在实际现场中,缩小起火区域前应比较通风、燃料负荷、灭火效果和目击者信息。',
|
|
112
|
+
focusedAreaText: '向量汇聚到一个紧凑区域。将此视为用于起火点假设开发的有力教学信号,而非最终的法庭科学结论。',
|
|
113
|
+
disclaimer: '仅供教育模拟。实际的火灾起源和原因调查应遵循公认的指南、现场安全规程、文档标准和合格的专家审查。',
|
|
114
|
+
},
|
|
115
|
+
seo: [
|
|
116
|
+
{
|
|
117
|
+
type: 'title',
|
|
118
|
+
text: '基于燃烧模式向量的交互式火灾起源分析',
|
|
119
|
+
level: 2,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
type: 'diagnostic',
|
|
123
|
+
variant: 'info',
|
|
124
|
+
icon: 'mdi:fire-alert',
|
|
125
|
+
badge: '教学模拟器',
|
|
126
|
+
title: '本火灾模式起源分析器教什么',
|
|
127
|
+
html: '此浏览器工具演示调查人员如何从物理火灾模式推断可能的<strong>起火区域</strong>。用户绘制可见证据、绘制方向性传播向量,并观察投影线汇聚或分散。该模型有意采用教育性设计:教授几何学和模式解读,而非法律原因判定。',
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
type: 'stats',
|
|
131
|
+
columns: 4,
|
|
132
|
+
items: [
|
|
133
|
+
{ value: '4个图层', label: 'V形模式、炭化、烟尘、清洁燃烧', icon: 'mdi:layers-triple' },
|
|
134
|
+
{ value: '二维平面图', label: '结构房间地图', icon: 'mdi:floor-plan' },
|
|
135
|
+
{ value: '3+个箭头', label: '建议观察数量', icon: 'mdi:vector-line' },
|
|
136
|
+
{ value: '0-100%', label: '置信度输入', icon: 'mdi:gauge' },
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
type: 'summary',
|
|
141
|
+
title: '火灾模式解读的严谨工作流程',
|
|
142
|
+
items: [
|
|
143
|
+
'在解读模式之前,从安全、现场保护、拍照、草图和系统文档开始。',
|
|
144
|
+
'将模式类型分开,避免将V形损伤、炭化深度、烟尘沉积和清洁燃烧归为一个假设。',
|
|
145
|
+
'仅在物理模式支持可辩护的传播方向处绘制方向指示器。',
|
|
146
|
+
'寻找独立观察之间的收敛,而非依赖最显眼的损伤。',
|
|
147
|
+
'将估算的起火区域作为假设,对照燃料、点火源、通风、电气证据和目击者陈述进行检验。',
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
type: 'table',
|
|
152
|
+
headers: ['模式', '潜在价值', '主要注意事项'],
|
|
153
|
+
rows: [
|
|
154
|
+
['V形模式', '可能表明从较低区域向上和向外的火焰传播。', '可能因通风、闪燃、墙壁几何和灭火而改变。'],
|
|
155
|
+
['深层炭化', '可能表明持续受热或强烈燃烧。', '燃料类型和材料厚度对炭化深度影响很大。'],
|
|
156
|
+
['烟尘阴影', '可揭示受保护区域、物体位置或气流效应。', '移动家具或灭火活动可能改变解读。'],
|
|
157
|
+
['清洁燃烧', '可显示高温、通风或燃烧后期阶段。', '不会自动识别最先引燃的材料。'],
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
type: 'comparative',
|
|
162
|
+
columns: 2,
|
|
163
|
+
items: [
|
|
164
|
+
{
|
|
165
|
+
title: '更好的起火点假设',
|
|
166
|
+
icon: 'mdi:check-circle-outline',
|
|
167
|
+
highlight: true,
|
|
168
|
+
description: '最佳假设能同时解释多个观察。',
|
|
169
|
+
points: ['独立向量汇聚', '证据与已知通风路径吻合', '燃料包已考虑', '积极检验替代起火点'],
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
title: '薄弱的起火点假设',
|
|
173
|
+
icon: 'mdi:alert-circle-outline',
|
|
174
|
+
description: '薄弱假设通常依赖单一模式而不检验扭曲因素。',
|
|
175
|
+
points: ['将一处剧烈炭化区域视为证据', '忽视闪燃', '灭火损伤未记录', '点火源证据被假定'],
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
type: 'title',
|
|
181
|
+
text: '向量模型如何工作',
|
|
182
|
+
level: 3,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
type: 'paragraph',
|
|
186
|
+
html: '每支箭头被视为火灾传播的投影线。估算器计算成对线交点,过滤合理平面边界内的点,对剩余交叉点取平均,并根据这些交点的散布报告半径。小半径配合多个向量产生更强的收敛信号。',
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
type: 'paragraph',
|
|
190
|
+
html: '这种几何方法反映了课堂绘图练习:帮助学生理解为什么当观察来自不同表面和模式类型时,起源分析会改善。它也揭示了一个常见问题:即使底层解读不佳,箭头仍可能相交,因此数学中心绝不能取代火灾科学判断。',
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
type: 'glossary',
|
|
194
|
+
items: [
|
|
195
|
+
{ term: '起火区域', definition: '现有证据表明火灾开始的总体区域。' },
|
|
196
|
+
{ term: 'V形模式', definition: '一种火灾模式,通常在垂直表面呈现为向上和向外的损伤。' },
|
|
197
|
+
{ term: '清洁燃烧', definition: '在高温或气流条件下,烟尘被烧掉或未沉积形成的较亮或较干净的区域。' },
|
|
198
|
+
{ term: '烟尘阴影', definition: '一个受保护或沉积状态不同的烟尘区域,可保存有关物体、气流或热暴露的信息。' },
|
|
199
|
+
{ term: '向量收敛', definition: '投影方向指示器围绕共同区域的聚集。' },
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
type: 'diagnostic',
|
|
204
|
+
variant: 'warning',
|
|
205
|
+
icon: 'mdi:scale-balance',
|
|
206
|
+
badge: '法庭科学警示',
|
|
207
|
+
title: '不要将收敛变成确定性',
|
|
208
|
+
html: '紧密的交点簇仅在观察有效时才有用。实际调查必须考虑通风、闪燃、燃料包、电气系统、电器证据、目击者陈述、灭火和现场扰动。',
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
faq,
|
|
212
|
+
bibliography,
|
|
213
|
+
howTo,
|
|
214
|
+
schemas: [
|
|
215
|
+
{
|
|
216
|
+
'@context': 'https://schema.org',
|
|
217
|
+
'@type': 'SoftwareApplication',
|
|
218
|
+
name: title,
|
|
219
|
+
description,
|
|
220
|
+
applicationCategory: 'ForensicApplication',
|
|
221
|
+
operatingSystem: 'Any',
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
'@context': 'https://schema.org',
|
|
225
|
+
'@type': 'FAQPage',
|
|
226
|
+
mainEntity: faq.map((item) => ({
|
|
227
|
+
'@type': 'Question',
|
|
228
|
+
name: item.question,
|
|
229
|
+
acceptedAnswer: {
|
|
230
|
+
'@type': 'Answer',
|
|
231
|
+
text: item.answer,
|
|
232
|
+
},
|
|
233
|
+
})),
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
'@context': 'https://schema.org',
|
|
237
|
+
'@type': 'HowTo',
|
|
238
|
+
name: title,
|
|
239
|
+
step: howTo.map((step) => ({
|
|
240
|
+
'@type': 'HowToStep',
|
|
241
|
+
name: step.name,
|
|
242
|
+
text: step.text,
|
|
243
|
+
})),
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { firePatternOriginAnalyzer } from './entry';
|
|
2
|
+
import type { ToolDefinition } from '../../types';
|
|
3
|
+
|
|
4
|
+
export * from './entry';
|
|
5
|
+
|
|
6
|
+
export const FIRE_PATTERN_ORIGIN_ANALYZER_TOOL: ToolDefinition = {
|
|
7
|
+
entry: firePatternOriginAnalyzer,
|
|
8
|
+
Component: () => import('./component.astro'),
|
|
9
|
+
SEOComponent: () => import('./seo.astro'),
|
|
10
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
11
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export interface FirePoint {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface FireVector {
|
|
7
|
+
id: string;
|
|
8
|
+
start: FirePoint;
|
|
9
|
+
end: FirePoint;
|
|
10
|
+
pattern: 'vPattern' | 'deepChar' | 'sootShadow' | 'cleanBurn';
|
|
11
|
+
confidence: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FireOriginEstimate {
|
|
15
|
+
origin: FirePoint;
|
|
16
|
+
radius: number;
|
|
17
|
+
confidence: number;
|
|
18
|
+
intersections: FirePoint[];
|
|
19
|
+
vectorCount: number;
|
|
20
|
+
statusKey: 'needsMoreVectors' | 'wideArea' | 'focusedArea';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function clamp(value: number, min: number, max: number): number {
|
|
24
|
+
return Math.max(min, Math.min(max, value));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function distance(a: FirePoint, b: FirePoint): number {
|
|
28
|
+
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function average(points: FirePoint[]): FirePoint {
|
|
32
|
+
if (points.length === 0) return { x: 50, y: 50 };
|
|
33
|
+
const total = points.reduce(
|
|
34
|
+
(sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y }),
|
|
35
|
+
{ x: 0, y: 0 },
|
|
36
|
+
);
|
|
37
|
+
return { x: total.x / points.length, y: total.y / points.length };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function lineIntersection(a: FireVector, b: FireVector): FirePoint | null {
|
|
41
|
+
const x1 = a.start.x;
|
|
42
|
+
const y1 = a.start.y;
|
|
43
|
+
const x2 = a.end.x;
|
|
44
|
+
const y2 = a.end.y;
|
|
45
|
+
const x3 = b.start.x;
|
|
46
|
+
const y3 = b.start.y;
|
|
47
|
+
const x4 = b.end.x;
|
|
48
|
+
const y4 = b.end.y;
|
|
49
|
+
const denominator = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
|
|
50
|
+
|
|
51
|
+
if (Math.abs(denominator) < 0.0001) return null;
|
|
52
|
+
|
|
53
|
+
const determinantA = x1 * y2 - y1 * x2;
|
|
54
|
+
const determinantB = x3 * y4 - y3 * x4;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
x: ((determinantA * (x3 - x4)) - ((x1 - x2) * determinantB)) / denominator,
|
|
58
|
+
y: ((determinantA * (y3 - y4)) - ((y1 - y2) * determinantB)) / denominator,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function usableVectors(vectors: FireVector[]): FireVector[] {
|
|
63
|
+
return vectors.filter((vector) => distance(vector.start, vector.end) >= 4);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function collectIntersections(vectors: FireVector[]): FirePoint[] {
|
|
67
|
+
const intersections: FirePoint[] = [];
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < vectors.length; i += 1) {
|
|
70
|
+
for (let j = i + 1; j < vectors.length; j += 1) {
|
|
71
|
+
const point = lineIntersection(vectors[i], vectors[j]);
|
|
72
|
+
if (!point || point.x < -20 || point.x > 120 || point.y < -20 || point.y > 120) continue;
|
|
73
|
+
intersections.push({
|
|
74
|
+
x: clamp(point.x, 0, 100),
|
|
75
|
+
y: clamp(point.y, 0, 100),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return intersections;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function estimateWithoutIntersections(vectors: FireVector[]): FireOriginEstimate {
|
|
84
|
+
return {
|
|
85
|
+
origin: average(vectors.map((vector) => vector.end)),
|
|
86
|
+
radius: 24,
|
|
87
|
+
confidence: vectors.length >= 2 ? 28 : 12,
|
|
88
|
+
intersections: [],
|
|
89
|
+
vectorCount: vectors.length,
|
|
90
|
+
statusKey: 'needsMoreVectors',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function estimateWithIntersections(vectors: FireVector[], intersections: FirePoint[]): FireOriginEstimate {
|
|
95
|
+
const origin = average(intersections);
|
|
96
|
+
const spread = intersections.reduce((sum, point) => sum + distance(point, origin), 0) / intersections.length;
|
|
97
|
+
const radius = clamp(spread + (vectors.length < 3 ? 14 : 6), 5, 30);
|
|
98
|
+
const meanConfidence = vectors.reduce((sum, vector) => sum + vector.confidence, 0) / vectors.length;
|
|
99
|
+
const convergenceScore = clamp(100 - radius * 2.4, 18, 96);
|
|
100
|
+
const confidence = Math.round((convergenceScore * 0.68) + (meanConfidence * 0.32));
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
origin,
|
|
104
|
+
radius,
|
|
105
|
+
confidence,
|
|
106
|
+
intersections,
|
|
107
|
+
vectorCount: vectors.length,
|
|
108
|
+
statusKey: confidence >= 68 && radius <= 15 ? 'focusedArea' : 'wideArea',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class FireOriginEstimator {
|
|
113
|
+
estimate(vectors: FireVector[]): FireOriginEstimate {
|
|
114
|
+
const filteredVectors = usableVectors(vectors);
|
|
115
|
+
const intersections = collectIntersections(filteredVectors);
|
|
116
|
+
|
|
117
|
+
if (intersections.length === 0) {
|
|
118
|
+
return estimateWithoutIntersections(filteredVectors);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return estimateWithIntersections(filteredVectors, intersections);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { FirePoint, FireVector } from './logic';
|
|
2
|
+
|
|
3
|
+
export const WIDTH = 1000;
|
|
4
|
+
export const HEIGHT = 680;
|
|
5
|
+
export const TRANSITION_MS = 300;
|
|
6
|
+
|
|
7
|
+
interface HeatmapFrame {
|
|
8
|
+
point: FirePoint;
|
|
9
|
+
radius: number;
|
|
10
|
+
confidence: number;
|
|
11
|
+
intersections: FirePoint[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
15
|
+
return Math.max(min, Math.min(max, value));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function lerp(start: number, end: number, progress: number): number {
|
|
19
|
+
return start + (end - start) * progress;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ease(progress: number): number {
|
|
23
|
+
return 1 - Math.pow(1 - progress, 3);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function pointFromPointer(svg: SVGSVGElement, event: PointerEvent): FirePoint {
|
|
27
|
+
const point = svg.createSVGPoint();
|
|
28
|
+
point.x = event.clientX;
|
|
29
|
+
point.y = event.clientY;
|
|
30
|
+
const matrix = svg.getScreenCTM();
|
|
31
|
+
if (!matrix) return { x: 50, y: 50 };
|
|
32
|
+
const local = point.matrixTransform(matrix.inverse());
|
|
33
|
+
return { x: clamp((local.x / WIDTH) * 100, 0, 100), y: clamp((local.y / HEIGHT) * 100, 0, 100) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function toCanvas(point: FirePoint): FirePoint {
|
|
37
|
+
return { x: (point.x / 100) * WIDTH, y: (point.y / 100) * HEIGHT };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function snapPoint(start: FirePoint, point: FirePoint): FirePoint {
|
|
41
|
+
const dx = point.x - start.x;
|
|
42
|
+
const dy = point.y - start.y;
|
|
43
|
+
const angle = Math.atan2(dy, dx);
|
|
44
|
+
const distance = Math.hypot(dx, dy);
|
|
45
|
+
const snappedAngle = Math.round(angle / (Math.PI / 12)) * (Math.PI / 12);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
x: clamp(start.x + Math.cos(snappedAngle) * distance, 0, 100),
|
|
49
|
+
y: clamp(start.y + Math.sin(snappedAngle) * distance, 0, 100),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function patternColor(pattern: FireVector['pattern']): string {
|
|
54
|
+
switch (pattern) {
|
|
55
|
+
case 'deepChar':
|
|
56
|
+
return '#5b524c';
|
|
57
|
+
case 'sootShadow':
|
|
58
|
+
return '#35516b';
|
|
59
|
+
case 'cleanBurn':
|
|
60
|
+
return '#d17d10';
|
|
61
|
+
default:
|
|
62
|
+
return '#b12618';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function drawArrowHead(
|
|
67
|
+
ctx: CanvasRenderingContext2D,
|
|
68
|
+
point: FirePoint,
|
|
69
|
+
angle: number,
|
|
70
|
+
color: string,
|
|
71
|
+
): void {
|
|
72
|
+
ctx.save();
|
|
73
|
+
ctx.translate(point.x, point.y);
|
|
74
|
+
ctx.rotate(angle);
|
|
75
|
+
ctx.fillStyle = color;
|
|
76
|
+
ctx.beginPath();
|
|
77
|
+
ctx.moveTo(0, 0);
|
|
78
|
+
ctx.lineTo(-18, -10);
|
|
79
|
+
ctx.lineTo(-12, 0);
|
|
80
|
+
ctx.lineTo(-18, 10);
|
|
81
|
+
ctx.closePath();
|
|
82
|
+
ctx.fill();
|
|
83
|
+
ctx.restore();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function drawVector(ctx: CanvasRenderingContext2D, vector: FireVector, alpha = 1): void {
|
|
87
|
+
const start = toCanvas(vector.start);
|
|
88
|
+
const end = toCanvas(vector.end);
|
|
89
|
+
const dx = end.x - start.x;
|
|
90
|
+
const dy = end.y - start.y;
|
|
91
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
92
|
+
const nx = -dy / length;
|
|
93
|
+
const ny = dx / length;
|
|
94
|
+
const bend = clamp(length * 0.12, 20, 46);
|
|
95
|
+
const c1 = { x: lerp(start.x, end.x, 0.35) + nx * bend, y: lerp(start.y, end.y, 0.35) + ny * bend };
|
|
96
|
+
const c2 = { x: lerp(start.x, end.x, 0.78) - nx * bend * 0.66, y: lerp(start.y, end.y, 0.78) - ny * bend * 0.66 };
|
|
97
|
+
const color = patternColor(vector.pattern);
|
|
98
|
+
|
|
99
|
+
ctx.save();
|
|
100
|
+
ctx.globalAlpha = alpha;
|
|
101
|
+
ctx.strokeStyle = color;
|
|
102
|
+
ctx.lineWidth = 6;
|
|
103
|
+
ctx.lineCap = 'round';
|
|
104
|
+
ctx.lineJoin = 'round';
|
|
105
|
+
ctx.shadowBlur = 12;
|
|
106
|
+
ctx.shadowColor = `${color}66`;
|
|
107
|
+
ctx.beginPath();
|
|
108
|
+
ctx.moveTo(start.x, start.y);
|
|
109
|
+
ctx.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, end.x, end.y);
|
|
110
|
+
ctx.stroke();
|
|
111
|
+
drawArrowHead(ctx, end, Math.atan2(end.y - c2.y, end.x - c2.x), color);
|
|
112
|
+
ctx.restore();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function drawIntersections(ctx: CanvasRenderingContext2D, intersections: FirePoint[]): void {
|
|
116
|
+
intersections.forEach((intersection) => {
|
|
117
|
+
const dot = toCanvas(intersection);
|
|
118
|
+
ctx.save();
|
|
119
|
+
ctx.fillStyle = 'rgba(29, 50, 74, 0.9)';
|
|
120
|
+
ctx.beginPath();
|
|
121
|
+
ctx.arc(dot.x, dot.y, 3.6, 0, Math.PI * 2);
|
|
122
|
+
ctx.fill();
|
|
123
|
+
ctx.restore();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function drawHeatmap(ctx: CanvasRenderingContext2D, frame: HeatmapFrame, now: number): void {
|
|
128
|
+
const center = toCanvas(frame.point);
|
|
129
|
+
const pulse = 1 + Math.sin(now / 240) * (0.03 + frame.confidence / 5000);
|
|
130
|
+
const rx = (frame.radius / 100) * WIDTH * pulse;
|
|
131
|
+
const ry = rx * 0.76;
|
|
132
|
+
|
|
133
|
+
ctx.save();
|
|
134
|
+
ctx.globalCompositeOperation = 'screen';
|
|
135
|
+
ctx.filter = `blur(${Math.max(18, rx * 0.12).toFixed(1)}px)`;
|
|
136
|
+
const gradient = ctx.createRadialGradient(center.x, center.y, rx * 0.06, center.x, center.y, rx);
|
|
137
|
+
gradient.addColorStop(0, `rgba(255, 236, 210, ${0.32 + frame.confidence / 420})`);
|
|
138
|
+
gradient.addColorStop(0.36, `rgba(255, 138, 61, ${0.22 + frame.confidence / 800})`);
|
|
139
|
+
gradient.addColorStop(1, 'rgba(177, 38, 24, 0)');
|
|
140
|
+
ctx.fillStyle = gradient;
|
|
141
|
+
ctx.beginPath();
|
|
142
|
+
ctx.ellipse(center.x, center.y, rx, ry, Math.sin(now / 520) * 0.14, 0, Math.PI * 2);
|
|
143
|
+
ctx.fill();
|
|
144
|
+
ctx.restore();
|
|
145
|
+
drawIntersections(ctx, frame.intersections);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function drawOriginMarker(ctx: CanvasRenderingContext2D, point: FirePoint): void {
|
|
149
|
+
const dot = toCanvas(point);
|
|
150
|
+
ctx.save();
|
|
151
|
+
ctx.fillStyle = '#161e28';
|
|
152
|
+
ctx.strokeStyle = '#fff7ed';
|
|
153
|
+
ctx.lineWidth = 3;
|
|
154
|
+
ctx.beginPath();
|
|
155
|
+
ctx.arc(dot.x, dot.y, 7, 0, Math.PI * 2);
|
|
156
|
+
ctx.fill();
|
|
157
|
+
ctx.stroke();
|
|
158
|
+
ctx.restore();
|
|
159
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { firePatternOriginAnalyzer } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const content = await firePatternOriginAnalyzer.i18n[locale]?.();
|
|
12
|
+
if (!content) return null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content.seo && content.seo.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|