move_detect/server/public/assets/zone-draw.js
유창욱 9c0cfd2fea feat(alert): 아무도 발행하지 않던 device_fault·monitoring_degraded 구현
두 유형은 타입 정의(edge/events.py)·서버 검증(EventValidator)·콘솔 필터·
통보 라벨까지 전 구간에 있었으나 생성하는 코드가 어디에도 없었다. 실제로
값을 넣는 것은 seed_demo.php 뿐이었고, 소개서가 내세운 "17종"은 15종이었다.

앞서 고친 EventValidator 누락과 정확히 반대 방향의 같은 병이다. 그때는
감지기가 있는데 서버가 막았고, 이번엔 서버·UI가 열려 있는데 감지기가 없다.
어느 쪽이든 화면은 멀쩡해 보인다.

엣지가 낼 수 없는 유형이라 서버에 둔다 — 전원·회선이 끊긴 노드는 자기가
죽었다고 신고할 수 없다. 하트비트의 부재로만 관측된다.

App\Alert\DeviceHealthWatch:
- dispatch_alerts.php 1분 크론의 1단계. 에스컬레이션·통보보다 앞에 두어
  같은 실행 안에서 발행된 장치 이벤트가 곧바로 통보 대상이 되게 했다.
- 멱등은 EscalationWatch와 같은 방식(event_uuid UNIQUE 충돌)이되, 장치
  건전성은 연속 상태라 uuid 앵커를 '장애 구간의 시작'으로 잡았다.
  device_fault는 last_heartbeat_at(장애 내내 불변) → 장애당 정확히 1건.
  monitoring_degraded는 창 안의 마지막 정상 하트비트 ts. 창 전체가 저하면
  시작을 볼 수 없으므로 창 버킷으로 폴백해 시간당 1회 재고지한다 —
  의도된 리마인더이고, 매 틱 재발행하는 앵커 드리프트가 아니다.
- 성능 저하는 지속을 요구(기본 5분). 한 번 튄 값에 울리면 운영자가 이
  유형을 무시하게 되고, 그러면 감시가 멈춘 날에도 아무도 보지 않는다.
  엣지 감지기들이 쓰는 규율과 같다.
- 하트비트를 한 번도 보낸 적 없는 장치는 장애가 아니다(미설치 노드).
  새 현장 첫날이 빨간 벽이 되면 그 사이트는 이 유형을 영영 신뢰하지 않는다.
- 죽은 노드에 degraded를 겹쳐 내지 않는다 — 한 고장에 두 알람은 소음이다.
- fps NULL을 0으로 강압하지 않는다(측정 실패 ≠ 정지). PdoDeviceMetric
  Repository가 NULL을 지키는 이유와 같다.
- 억제(정비 모드)는 여기서 보지 않는다. 기록은 남기고 통보 여부는
  AlertDispatcher가 정한다 — 정비 창은 전화를 끄는 것이지 노드가 죽었던
  기록을 지우는 것이 아니다.

camera_id는 NOT NULL인데 장치 단위 이벤트에는 카메라가 없다. '__device__'
센티넬을 쓰고, 콘솔은 "장치 전체"로 표시, 구역 편집기 카메라 목록에서는
제외한다(카메라 목록은 보고된 이벤트에서 파생되므로, 두면 존재하지 않는
카메라에 구역을 그리려는 일이 생긴다).

설정 4종 추가(device_stale_seconds 180 · degraded_sustain_seconds 300 ·
degraded_fps_ratio 0.6 · target_fps 12) — 두 config 템플릿에 한국어 설명과
함께. 낮은 프레임으로 운용하는 현장이 target_fps를 안 맞추면 전 노드가
영구 저하로 보고되므로 그 경고를 템플릿에 적었다.

검증: pytest 308 passed(3 skip: 모델 미동봉·shellcheck 미설치·AF_UNIX,
기존과 동일) · phpunit 240 passed(신규 16). dist 재생성으로 패리티 회복.
2026-08-25 10:36:48 +09:00

537 lines
24 KiB
JavaScript

/**
* Zone editor: canvas-based polygon drawing for camera zones.
* - Click the canvas to add points to the polygon currently being drawn.
* - Choose a level (warning/restricted/pinch), then Save -> POST /api/zones.
* - "Load" fetches existing zones for the camera via GET /api/zones/{camera}
* and redraws them; each has a Delete button -> POST /api/zones/{id}/delete.
* - "현재 화면 불러오기" fetches the camera's newest snapshot uuid via
* GET /api/cameras/{camera}/latest-snapshot and draws it as the canvas
* background, so zones are drawn over what the camera actually sees.
* - A `?snapshot={uuid}` query param does the same for one specific image
* (the event-detail page links here that way); otherwise the canvas keeps
* its plain dark backdrop.
*
* XSS-safe: all dynamic text is set via textContent, never innerHTML.
*/
(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 canvas = document.getElementById('zone-canvas');
if (!canvas) return;
var ctx = canvas.getContext('2d');
var deviceInput = document.getElementById('device-id');
var cameraInput = document.getElementById('camera-id');
var levelSelect = document.getElementById('zone-level');
var loadBtn = document.getElementById('zone-load-btn');
var frameBtn = document.getElementById('zone-frame-btn');
var clearBtn = document.getElementById('zone-clear-btn');
var saveBtn = document.getElementById('zone-save-btn');
var listEl = document.getElementById('zone-list');
var statusEl = document.getElementById('zone-status');
var ruleMinInput = document.getElementById('rule-min-workers');
var ruleMaxInput = document.getElementById('rule-max-workers');
var ruleDwellInput = document.getElementById('rule-max-dwell');
var heatmapBtn = document.getElementById('heatmap-btn');
var heatmapDaysInput = document.getElementById('heatmap-days');
// Zone styling stays inside the app's severity ramp (app.css --warn/--crit/--high). The old
// table used a raw #a855f7 for 협착 — a purple that appears nowhere else in the product — and
// drew every zone as a bare 2px outline, which disappears against a real factory frame. Each
// level now carries a translucent FILL plus a stroke, and 협착 is separated by a dashed
// stroke rather than by an alien hue: shape does the work colour was being stretched to do.
var LEVEL_STYLE = {
warning: { stroke: '#b7820a', fill: 'rgba(183,130,10,.16)', label: '경고', dash: null },
restricted: { stroke: '#dc2626', fill: 'rgba(220,38,38,.16)', label: '제한', dash: null },
pinch: { stroke: '#e2560c', fill: 'rgba(226,86,12,.18)', label: '협착', dash: [9, 5] }
};
var DRAFT_STYLE = { stroke: '#0b6b7a', fill: 'rgba(11,107,122,.14)', label: '편집중', dash: [5, 4] };
var backgroundImage = null;
var existingZones = []; // [{zone_id, level, points: [[x,y],...]}]
var currentPoints = []; // in-progress polygon being drawn
var csrfToken = null;
var heatmapOn = false;
var heatmapPoints = []; // [{x, y, severity}] normalized 0..1 (same space as zones)
var heatmapLayer = null; // offscreen canvas, rebuilt when points change
function setStatus(msg) {
statusEl.textContent = msg;
}
function loadCsrfToken() {
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) { if (data && data.token) csrfToken = data.token; })
.catch(function () { /* save/delete will fail with 403 if the token is missing */ });
}
// WIRE CONTRACT: zone polygons are stored NORMALIZED (0..1 of the image), so the edge can
// compare them against a foot point normalized by the camera's own resolution regardless of
// how big the camera is. On this canvas we draw in canvas pixels, so convert at the two wire
// boundaries only: denormalize on load (here), normalize on save (saveZone).
function parsePolygon(zone) {
try {
var pts = JSON.parse(zone.polygon_json || '[]');
if (!Array.isArray(pts)) return [];
return pts.map(function (p) {
return [p[0] * canvas.width, p[1] * canvas.height];
});
} catch (e) {
return [];
}
}
function drawPolygon(points, style, label, closed) {
if (points.length === 0) return;
ctx.save();
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
for (var i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
if (closed && points.length > 2) ctx.closePath();
// Fill first so the stroke sits on top of its own tint.
if (closed && points.length > 2 && style.fill) {
ctx.fillStyle = style.fill;
ctx.fill();
}
ctx.strokeStyle = style.stroke;
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
if (style.dash) ctx.setLineDash(style.dash);
ctx.stroke();
ctx.setLineDash([]);
// Vertex handles: white-cored so they stay visible on both a bright floor and a dark shadow.
points.forEach(function (p) {
ctx.beginPath();
ctx.arc(p[0], p[1], 4, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = style.stroke;
ctx.stroke();
});
if (label) drawLabelChip(label, points[0][0], points[0][1], style.stroke);
ctx.restore();
}
// A plain fillText label vanished into whatever the camera happened to be looking at.
// Draw it on its own solid chip so the level is legible over any frame.
function drawLabelChip(text, x, y, bg) {
ctx.save();
ctx.font = '600 13px system-ui, sans-serif';
ctx.textBaseline = 'middle';
var padX = 7, h = 20;
var w = ctx.measureText(text).width + padX * 2;
var cx = Math.min(Math.max(x + 8, 2), canvas.width - w - 2);
var cy = Math.min(Math.max(y - 14, h / 2 + 2), canvas.height - h / 2 - 2);
ctx.fillStyle = bg;
if (ctx.roundRect) {
ctx.beginPath();
ctx.roundRect(cx, cy - h / 2, w, h, 5);
ctx.fill();
} else {
ctx.fillRect(cx, cy - h / 2, w, h);
}
ctx.fillStyle = '#ffffff';
ctx.fillText(text, cx + padX, cy);
ctx.restore();
}
// ---- 위험 히트맵 오버레이 -------------------------------------------------------
// Density of everything this camera detected (incidents AND near-misses) over the window,
// drawn as additive radial blots UNDER the zone polygons. Severity weighs the alpha:
// a cluster of near-misses reads lighter than one confirmed fall at the same spot.
var HEAT_STYLE = {
critical: { color: '220,38,38', alpha: 0.34 },
high: { color: '220,38,38', alpha: 0.26 },
warning: { color: '217,119,6', alpha: 0.18 },
info: { color: '37,99,235', alpha: 0.11 }
};
function buildHeatmapLayer() {
var layer = document.createElement('canvas');
layer.width = canvas.width;
layer.height = canvas.height;
var lctx = layer.getContext('2d');
var radius = Math.round(canvas.width * 0.03); // ~29px at 960: a footprint, not a pin
heatmapPoints.forEach(function (p) {
var s = HEAT_STYLE[p.severity] || HEAT_STYLE.info;
var x = Math.min(1, Math.max(0, p.x)) * layer.width;
var y = Math.min(1, Math.max(0, p.y)) * layer.height;
var g = lctx.createRadialGradient(x, y, 0, x, y, radius);
g.addColorStop(0, 'rgba(' + s.color + ',' + s.alpha + ')');
g.addColorStop(1, 'rgba(' + s.color + ',0)');
lctx.fillStyle = g;
lctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
});
return layer;
}
function loadHeatmap() {
var camera = cameraInput.value.trim();
if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; }
var days = Math.max(1, Math.min(90, parseInt(heatmapDaysInput && heatmapDaysInput.value, 10) || 30));
setStatus('히트맵을 불러오는 중...');
fetch(B + '/api/heatmap/' + encodeURIComponent(camera) + '?days=' + days,
{ headers: { Accept: 'application/json' } })
.then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('heatmap_failed:' + res.status);
return res.json();
})
.then(function (data) {
if (!data) return;
heatmapPoints = data.points || [];
heatmapLayer = buildHeatmapLayer();
redraw();
setStatus(heatmapPoints.length
? '최근 ' + data.days + '일 감지 ' + heatmapPoints.length + '건을 표시합니다.'
: '최근 ' + data.days + '일 동안 좌표가 기록된 감지가 없습니다.');
})
.catch(function () { setStatus('히트맵을 불러오지 못했습니다.'); });
}
function toggleHeatmap() {
heatmapOn = !heatmapOn;
if (heatmapBtn) heatmapBtn.setAttribute('aria-pressed', heatmapOn ? 'true' : 'false');
if (heatmapOn) loadHeatmap();
else { heatmapLayer = null; heatmapPoints = []; redraw(); setStatus('히트맵을 껐습니다.'); }
}
function redraw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (backgroundImage) {
ctx.drawImage(backgroundImage, 0, 0, canvas.width, canvas.height);
}
if (heatmapOn && heatmapLayer) {
ctx.drawImage(heatmapLayer, 0, 0);
}
existingZones.forEach(function (z) {
var style = LEVEL_STYLE[z.level] || { stroke: '#626a79', fill: 'rgba(98,106,121,.14)', label: z.level, dash: null };
drawPolygon(z.points, style, style.label, true);
});
drawPolygon(currentPoints, DRAFT_STYLE, currentPoints.length ? DRAFT_STYLE.label : '', false);
if (!backgroundImage && !existingZones.length && !currentPoints.length) drawCanvasHint();
}
// An empty black rectangle told the operator nothing about what to do with it.
function drawCanvasHint() {
ctx.save();
ctx.fillStyle = 'rgba(255,255,255,.5)';
ctx.font = '500 15px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('① 왼쪽에서 카메라를 고르고 "카메라 화면을 배경으로"를 누르세요.',
canvas.width / 2, canvas.height / 2 - 10);
ctx.fillText('② 그다음 이 위를 클릭해 구역 폴리곤을 그립니다.',
canvas.width / 2, canvas.height / 2 + 16);
ctx.restore();
}
// Occupancy rules the operator typed (dwell in MINUTES on screen, seconds on the wire).
// Only filled fields are sent; an empty form means "no rules" and stores NULL.
function rulesFromInputs() {
var rules = {};
var minW = parseInt(ruleMinInput && ruleMinInput.value, 10);
var maxW = parseInt(ruleMaxInput && ruleMaxInput.value, 10);
var dwellMin = parseInt(ruleDwellInput && ruleDwellInput.value, 10);
if (minW > 0) rules.min_workers = minW;
if (maxW > 0) rules.max_workers = maxW;
if (dwellMin > 0) rules.max_dwell_s = dwellMin * 60;
return rules;
}
function rulesLabel(z) {
var r;
try { r = JSON.parse(z.rules_json || 'null') || {}; } catch (e) { return ''; }
var parts = [];
if (r.min_workers) parts.push('최소 ' + r.min_workers + '인');
if (r.max_workers) parts.push('최대 ' + r.max_workers + '인');
if (r.max_dwell_s) parts.push('체류 ' + Math.round(r.max_dwell_s / 60) + '분');
return parts.length ? ' · ' + parts.join(' · ') : '';
}
function renderZoneList() {
listEl.textContent = '';
if (existingZones.length === 0) {
var none = document.createElement('li');
none.className = 'zone-empty';
none.textContent = '이 카메라에 저장된 구역이 없습니다.';
listEl.appendChild(none);
return;
}
existingZones.forEach(function (z) {
var li = document.createElement('li');
li.className = 'zone-item lv-' + z.level;
var label = document.createElement('span');
label.className = 'zone-item-label';
var swatch = document.createElement('span');
swatch.className = 'zone-swatch';
swatch.setAttribute('aria-hidden', 'true');
// Inline colour is the ONE place a raw hex belongs: it must match what the canvas drew.
swatch.style.background = (LEVEL_STYLE[z.level] || {}).fill || 'transparent';
swatch.style.borderColor = (LEVEL_STYLE[z.level] || {}).stroke || '#626a79';
label.appendChild(swatch);
label.appendChild(document.createTextNode(
(LEVEL_STYLE[z.level] || { label: z.level }).label + ' (' + z.points.length + '점)' + rulesLabel(z)));
li.appendChild(label);
// Two-step delete. A zone is what makes 침입 alarms fire; it takes minutes to draw and
// the server has no restore. A single unconfirmed click was the least reversible action
// in the product and also the cheapest one to trigger by accident.
var del = document.createElement('button');
del.type = 'button';
del.className = 'zone-del';
del.textContent = '삭제';
var armed = false, armTimer = null;
del.addEventListener('click', function () {
if (!armed) {
armed = true;
del.classList.add('is-armed');
del.textContent = '정말 삭제?';
setStatus('한 번 더 누르면 이 구역이 삭제됩니다. 되돌릴 수 없습니다.');
armTimer = setTimeout(function () {
armed = false; del.classList.remove('is-armed'); del.textContent = '삭제';
}, 4000);
return;
}
if (armTimer) clearTimeout(armTimer);
deleteZone(z.zone_id);
});
li.appendChild(del);
listEl.appendChild(li);
});
}
// --- 장치 · 카메라 선택기 ---------------------------------------------------------
// The editor used to demand two free-text ids the operator had to remember, while the
// events console already knew both. Devices come from /api/devices; the cameras under each
// device are derived from the events they have reported (there is no camera registry).
// A camera that has never sent an event cannot appear here, which is what the manual
// fallback in the view is for.
var deviceSelect = document.getElementById('device-select');
var cameraSelect = document.getElementById('camera-select');
var manualBox = document.getElementById('zone-manual');
var camerasByDevice = {};
function option(value, text) {
var o = document.createElement('option');
o.value = value;
o.textContent = text;
return o;
}
function fillCameraSelect(deviceId) {
if (!cameraSelect) return;
cameraSelect.textContent = '';
var cams = camerasByDevice[deviceId] || [];
if (!deviceId) { cameraSelect.appendChild(option('', '장치를 먼저 선택하세요')); return; }
if (cams.length === 0) {
cameraSelect.appendChild(option('', '이 장치가 보고한 카메라 없음'));
return;
}
cameraSelect.appendChild(option('', '카메라를 선택하세요'));
cams.forEach(function (c) { cameraSelect.appendChild(option(c, c)); });
}
function applySelection() {
if (deviceSelect) deviceInput.value = deviceSelect.value;
if (cameraSelect) cameraInput.value = cameraSelect.value;
}
function loadPickers() {
var devices = fetch(B + '/api/devices', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; });
var events = fetch(B + '/api/events?limit=200', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; });
return Promise.all([devices, events]).then(function (res) {
var devs = (res[0] && res[0].devices) || [];
var evs = (res[1] && res[1].events) || [];
evs.forEach(function (ev) {
// '__device__' is the sentinel on node-scope events (device_fault / monitoring_degraded),
// not a camera. Offering it here would let an operator draw zones onto nothing.
if (!ev.camera_id || ev.camera_id === '__device__') return;
var d = String(ev.device_id || '');
if (!camerasByDevice[d]) camerasByDevice[d] = [];
if (camerasByDevice[d].indexOf(ev.camera_id) === -1) camerasByDevice[d].push(ev.camera_id);
});
Object.keys(camerasByDevice).forEach(function (d) { camerasByDevice[d].sort(); });
if (!deviceSelect) return;
deviceSelect.textContent = '';
if (devs.length === 0) {
deviceSelect.appendChild(option('', '등록된 장치가 없습니다'));
if (manualBox) manualBox.open = true;
return;
}
deviceSelect.appendChild(option('', '장치를 선택하세요'));
devs.forEach(function (d) {
deviceSelect.appendChild(option(d.device_id, (d.name || d.device_id) + ' · ' + d.device_id));
});
// Honour a deep link (?device=&camera=) or a value already typed into the manual fields.
if (deviceInput.value) {
deviceSelect.value = deviceInput.value;
fillCameraSelect(deviceInput.value);
if (cameraInput.value) cameraSelect.value = cameraInput.value;
}
});
}
function loadBackground(uuid) {
if (!uuid) { backgroundImage = null; redraw(); return; }
var img = new Image();
img.onload = function () { backgroundImage = img; redraw(); };
img.onerror = function () { backgroundImage = null; redraw(); };
img.src = B + '/snap/' + encodeURIComponent(uuid);
}
// Load the camera's most recent frame as the drawing background. The uuid comes from the
// API; the image itself still goes through /snap/{uuid}, which re-checks session + site scope.
function loadLatestFrame() {
var camera = cameraInput.value.trim();
if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; }
setStatus('현재 화면을 불러오는 중...');
fetch(B + '/api/cameras/' + encodeURIComponent(camera) + '/latest-snapshot', { credentials: 'same-origin' })
.then(function (r) {
if (r.status === 404) throw new Error('이 카메라의 저장된 화면이 아직 없습니다. 이벤트가 한 번 발생해야 배경이 생깁니다.');
if (!r.ok) throw new Error('불러오기 실패 (' + r.status + ')');
return r.json();
})
.then(function (data) { loadBackground(data.uuid); setStatus('현재 화면을 배경으로 불러왔습니다.'); })
.catch(function (e) { setStatus(e.message); });
}
function loadZones(doneMsg) {
var camera = cameraInput.value.trim();
if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; }
setStatus('불러오는 중...');
fetch(B + '/api/zones/' + encodeURIComponent(camera), { headers: { Accept: 'application/json' } })
.then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('load_failed:' + res.status);
return res.json();
})
.then(function (data) {
if (!data) return;
existingZones = (data.zones || []).map(function (z) {
return { zone_id: z.zone_id, level: z.level, points: parsePolygon(z), rules_json: z.rules_json };
});
currentPoints = [];
renderZoneList();
redraw();
setStatus(doneMsg || (existingZones.length
? '저장된 구역 ' + existingZones.length + '개를 불러왔습니다.'
: '이 카메라에는 저장된 구역이 없습니다. 캔버스를 클릭해 새로 그리세요.'));
})
.catch(function () { setStatus('불러오기에 실패했습니다.'); });
}
function saveZone() {
var camera = cameraInput.value.trim();
var device = deviceInput.value.trim();
if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; }
if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; }
setStatus('저장 중...');
fetch(B + '/api/zones', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrfToken || '' },
body: JSON.stringify({
device_id: device,
camera_id: camera,
level: levelSelect.value,
// Normalize canvas pixels -> 0..1 of the image (the edge un-normalizes with the
// camera's own resolution). See the parsePolygon contract note above.
polygon: currentPoints.map(function (p) {
return [p[0] / canvas.width, p[1] / canvas.height];
}),
rules: rulesFromInputs()
})
})
.then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('save_failed:' + res.status);
return res.json();
})
.then(function (data) {
if (!data) return;
currentPoints = [];
loadZones('구역을 저장했습니다. 다음 설정 동기화 때 장치로 내려갑니다.');
})
.catch(function () { setStatus('저장에 실패했습니다.'); });
}
function deleteZone(zoneId) {
fetch(B + '/api/zones/' + encodeURIComponent(zoneId) + '/delete', {
method: 'POST',
headers: { 'X-Csrf-Token': csrfToken || '' }
})
.then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return; }
if (!res.ok) throw new Error('delete_failed:' + res.status);
loadZones('구역을 삭제했습니다.');
})
.catch(function () { setStatus('삭제에 실패했습니다.'); });
}
canvas.addEventListener('click', function (evt) {
var rect = canvas.getBoundingClientRect();
var scaleX = canvas.width / rect.width;
var scaleY = canvas.height / rect.height;
var x = Math.round((evt.clientX - rect.left) * scaleX);
var y = Math.round((evt.clientY - rect.top) * scaleY);
currentPoints.push([x, y]);
redraw();
});
levelSelect.addEventListener('change', redraw);
loadBtn.addEventListener('click', function () { applySelection(); loadZones(); });
if (frameBtn) frameBtn.addEventListener('click', function () { applySelection(); loadLatestFrame(); });
if (heatmapBtn) heatmapBtn.addEventListener('click', function () { applySelection(); toggleHeatmap(); });
if (heatmapDaysInput) heatmapDaysInput.addEventListener('change', function () { if (heatmapOn) loadHeatmap(); });
clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('그리던 폴리곤을 지웠습니다.'); });
saveBtn.addEventListener('click', function () { applySelection(); saveZone(); });
if (deviceSelect) {
deviceSelect.addEventListener('change', function () {
deviceInput.value = deviceSelect.value;
fillCameraSelect(deviceSelect.value);
cameraInput.value = '';
setStatus(deviceSelect.value ? '카메라를 선택하세요.' : '장치를 선택하세요.');
});
}
if (cameraSelect) {
// Selecting a camera is the operator's intent to work on it: load its zones and its latest
// frame at once, instead of making them press two more buttons to see anything at all.
cameraSelect.addEventListener('change', function () {
cameraInput.value = cameraSelect.value;
if (!cameraSelect.value) return;
currentPoints = [];
loadZones();
loadLatestFrame();
});
}
// Typing into the manual fields wins over the selects (and clears them, so the panel never
// shows a camera other than the one being edited).
[deviceInput, cameraInput].forEach(function (inp) {
inp.addEventListener('input', function () {
if (deviceSelect && deviceSelect.value !== deviceInput.value) deviceSelect.value = '';
if (cameraSelect && cameraSelect.value !== cameraInput.value) cameraSelect.value = '';
});
});
var params = new URLSearchParams(window.location.search);
if (params.get('camera')) cameraInput.value = params.get('camera');
if (params.get('device')) deviceInput.value = params.get('device');
if (params.get('camera') || params.get('device')) { if (manualBox) manualBox.open = false; }
loadBackground(params.get('snapshot'));
loadCsrfToken();
loadPickers();
renderZoneList(); // paint the "저장된 구역 없음" placeholder before any camera is chosen
redraw();
if (cameraInput.value) { loadZones(); loadLatestFrame(); }
})();