Heinrich's-law play: the signals we already compute but used to throw away
become a spatial risk map of the workplace, drawn over the camera's own frame.
Edge:
- near_fall (INFO): a track that reached FALLING (sustained prone) but recovered
before the FALLEN confirm. Treated as a full event per product decision -
same ingest, snapshots, dedup, ack flow as incidents. Fix en route: prone_s
used a truthiness check that turned prone_since=0.0 into "no timer".
- Every per-track event (fall/no_motion/near_fall/immobility/zone hits) now
carries the foot point in the zones' normalized 0..1 space (reusing the zone
detector's own coordinate math); ghost's pixel-space loss point is normalized
at emit. to_payload omits the key when absent, so an old server ignores it.
Server:
- move_events.spatial_x/spatial_y (nullable DOUBLE) + validator: optional
[x, y] finite-number pair; range deliberately NOT enforced (an off-by-a-hair
foot must never reject a FALL alert - renderers clamp).
- MIGRATION FIX: chk_events_type would have REJECTED the previous commit's
lone_worker/overcapacity/overstay inserts on MySQL 8.0.16+ - the constraint
is rebuilt with all 13 types (incl. near_fall).
- GET /api/heatmap/{camera}?days=N (1..90, default 30, 5000-point cap):
spatial points with the same fail-closed site scoping as the event list.
UI (zone editor): "위험 히트맵" toggle + day window. Additive radial blots in
an offscreen layer under the zone polygons; severity weighs the alpha so a
cluster of near-misses reads lighter than one confirmed fall at the same spot.
Events console/detail label near_fall in Korean.
Verified: pytest 222 pass / PHPUnit 130 pass + Chrome real-render (blue
near-miss cluster + red incident hotspot inside the restricted zone, amber
cluster in the warning zone, zones drawn on top; no console errors).
133 lines
4.9 KiB
JavaScript
133 lines
4.9 KiB
JavaScript
/**
|
||
* Incident detail view: parses the event id from /events/{id}/view, fetches
|
||
* GET /events/{id} (JSON {event, snapshots}), and renders the event metadata
|
||
* plus each snapshot as an <img src="/snap/{file_uuid}">.
|
||
*
|
||
* XSS-safe: every dynamic value is written via textContent or a validated URL
|
||
* attribute, never innerHTML. Severity is shown as icon + text (never color
|
||
* alone, per NF9). On 401 the browser is redirected to /login, mirroring app.js.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
// Mount prefix: '' at the web root, '/move_detect' when the app shares a www/ with another
|
||
// site. Injected by src/View/layout.php from the server's SCRIPT_NAME.
|
||
var B = window.APP_BASE || '';
|
||
|
||
var SEVERITY_LABELS = {
|
||
critical: { icon: '⛔', text: '위급', cls: 'sev-critical' },
|
||
high: { icon: '⚠', text: '높음', cls: 'sev-high' },
|
||
warning: { icon: '△', text: '경고', cls: 'sev-warning' },
|
||
info: { icon: 'ℹ', text: '정보', cls: 'sev-info' }
|
||
};
|
||
|
||
// Same wire-code -> Korean map as app.js (the two pages share no module system);
|
||
// an unknown code falls through as the raw code — never blank.
|
||
var TYPE_LABELS = {
|
||
fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
|
||
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
|
||
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
|
||
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
|
||
near_fall: '넘어질 뻔함 (니어미스)'
|
||
};
|
||
|
||
var meta = document.getElementById('event-detail-meta');
|
||
var snapBox = document.getElementById('event-detail-snapshots');
|
||
if (!meta) return;
|
||
|
||
var m = location.pathname.match(/\/events\/(\d+)\/view/);
|
||
if (!m) {
|
||
meta.textContent = '잘못된 주소입니다.';
|
||
meta.removeAttribute('aria-busy');
|
||
return;
|
||
}
|
||
var id = m[1];
|
||
|
||
function severityInfo(sev) {
|
||
return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' };
|
||
}
|
||
|
||
function formatTime(occurredAt) {
|
||
if (!occurredAt && occurredAt !== 0) return '-';
|
||
var d = new Date(Number(occurredAt) * 1000);
|
||
if (isNaN(d.getTime())) return '-';
|
||
return d.toLocaleString('ko-KR');
|
||
}
|
||
|
||
function buildBadge(sev) {
|
||
var info = severityInfo(sev);
|
||
var badge = document.createElement('span');
|
||
badge.className = 'severity-badge ' + info.cls;
|
||
var icon = document.createElement('span');
|
||
icon.className = 'icon';
|
||
icon.setAttribute('aria-hidden', 'true');
|
||
icon.textContent = info.icon;
|
||
var label = document.createElement('span');
|
||
label.className = 'label';
|
||
label.textContent = info.text;
|
||
badge.appendChild(icon);
|
||
badge.appendChild(label);
|
||
return badge;
|
||
}
|
||
|
||
function textNode(value) {
|
||
var span = document.createElement('span');
|
||
span.textContent = (value === null || value === undefined || value === '') ? '-' : String(value);
|
||
return span;
|
||
}
|
||
|
||
function addRow(label, valueNode) {
|
||
var row = document.createElement('div');
|
||
row.className = 'detail-row';
|
||
var k = document.createElement('span');
|
||
k.className = 'detail-label';
|
||
k.textContent = label + ': ';
|
||
row.appendChild(k);
|
||
row.appendChild(valueNode);
|
||
meta.appendChild(row);
|
||
}
|
||
|
||
function render(data) {
|
||
var ev = data.event || {};
|
||
meta.textContent = '';
|
||
meta.removeAttribute('aria-busy');
|
||
|
||
addRow('심각도', buildBadge(ev.severity));
|
||
addRow('유형', textNode(TYPE_LABELS[ev.event_type] || ev.event_type));
|
||
addRow('카메라', textNode(ev.camera_id));
|
||
addRow('발생 시각', textNode(formatTime(ev.occurred_at)));
|
||
addRow('지속(초)', textNode(ev.duration_s));
|
||
addRow('확인', textNode(ev.acknowledged_by || '미확인'));
|
||
|
||
if (!snapBox) return;
|
||
snapBox.textContent = '';
|
||
var snaps = data.snapshots || [];
|
||
if (snaps.length === 0) {
|
||
var none = document.createElement('p');
|
||
none.textContent = '스냅샷이 없습니다.';
|
||
snapBox.appendChild(none);
|
||
return;
|
||
}
|
||
snaps.forEach(function (s) {
|
||
if (!s || !s.file_uuid) return;
|
||
var img = document.createElement('img');
|
||
img.src = B + '/snap/' + encodeURIComponent(s.file_uuid);
|
||
img.alt = '사고 스냅샷';
|
||
img.className = 'detail-snapshot';
|
||
img.loading = 'lazy';
|
||
snapBox.appendChild(img);
|
||
});
|
||
}
|
||
|
||
fetch(B + '/events/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
|
||
.then(function (res) {
|
||
if (res.status === 401) { window.location.href = B + '/login'; return null; }
|
||
if (!res.ok) throw new Error('detail_failed:' + res.status);
|
||
return res.json();
|
||
})
|
||
.then(function (data) { if (data) render(data); })
|
||
.catch(function () {
|
||
meta.textContent = '불러오기 실패';
|
||
meta.removeAttribute('aria-busy');
|
||
});
|
||
})();
|