localeye.cam

← Back

static/map.js

floor_plan version 1.0 · 45 KB

1/*
2 * Floor plan (LocalEye custom module): the map, its areas and cameras.
3 *
4 * Positions are stored as fractions of the map (0..1 across and down, mapdata.py); on the page they are drawn in "map units":
5 * W = 1000 across and H = 1000 x (height / width). A camera's view is a slice of a circle: its angle (0 = up, clockwise), how wide
6 * it sees (fov) and how far (range, a fraction of the width). An area's cameras are the ones whose view overlaps it, unless an
7 * admin picked them by hand.
8 *
9 * Everyone: click an area or a camera -> its cameras live (sub-stream first) + a link to each one's recording at a chosen time;
10 * areas and cameras glow while their camera sees motion (/api/motion/now). Admins: Edit -> draw areas, place and aim cameras,
11 * floor plan picture, undo, Save (the map's version stops two people overwriting each other).
12 */
13(() => {
14 const $ = (id) => document.getElementById(id);
15 const esc = JP.esc;
16 const API = `/api/x/${WM.id}`;
17 const W = 1000;
18 const COLORS = ['#ff6a3d', '#4fa3ff', '#7fd68f', '#e8b552', '#c77dff', '#ff5c8a', '#3dd6c6', '#b0b6a8'];
19 const NS = 'http://www.w3.org/2000/svg';
20 const svg = $('wmSvg');
21
22 let maps = [], cams = [], camName = {}, viewOnly = new Set(), map = null, data = null, H = 600;
23 const VO = ' (view only)';
24 let editing = false, tool = 'select', sel = null, dirty = false, undoStack = [], draft = null, view = null;
25 let motion = {}, drag = null, hover = null;
26 const players = [];
27
28 // ---------------------------------------------------------------- geometry
29 const rad = (d) => d * Math.PI / 180;
30 const dir = (a) => [Math.sin(rad(a)), -Math.cos(rad(a))];
31 const secPts = (s) => s.points.map(([x, y]) => [x * W, y * H]);
32 const camXY = (c) => [c.x * W, c.y * H];
33 const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
34
35 function inPoly([px, py], pts) {
36 let inside = false;
37 for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
38 const [xi, yi] = pts[i], [xj, yj] = pts[j];
39 if ((yi > py) !== (yj > py) && px < (xj - xi) * (py - yi) / (yj - yi) + xi) inside = !inside;
40 }
41 return inside;
42 }
43 function inSector([px, py], c) {
44 const [cx, cy] = camXY(c), r = c.range * W, dx = px - cx, dy = py - cy;
45 if (Math.hypot(dx, dy) > r) return false;
46 if (c.fov >= 359.9) return true;
47 const ang = Math.atan2(dx, -dy) * 180 / Math.PI;
48 return Math.abs(((ang - c.angle) % 360 + 540) % 360 - 180) <= c.fov / 2;
49 }
50 // does this camera's view reach into this area? (corners in the view, the camera inside, or sampled edges / view edges)
51 function sees(c, s) {
52 const pts = secPts(s), [cx, cy] = camXY(c), r = c.range * W;
53 if (pts.some((p) => inSector(p, c)) || inPoly([cx, cy], pts)) return true;
54 const half = c.fov >= 359.9 ? 180 : c.fov / 2;
55 for (let k = 0; k <= 12; k++) { // the view's edges and arc
56 const a = c.angle - half + (2 * half) * k / 12, [dx, dy] = dir(a);
57 for (const t of [0.25, 0.5, 0.75, 1]) if (inPoly([cx + dx * r * t, cy + dy * r * t], pts)) return true;
58 }
59 for (let i = 0; i < pts.length; i++) { // the area's edges
60 const [ax, ay] = pts[i], [bx, by] = pts[(i + 1) % pts.length];
61 for (let k = 1; k < 8; k++) if (inSector([ax + (bx - ax) * k / 8, ay + (by - ay) * k / 8], c)) return true;
62 }
63 return false;
64 }
65 const autoCams = (s) => [...new Set(data.cameras.filter((c) => sees(c, s)).map((c) => c.stream))];
66 const areaCams = (s) => (s.cams ? s.cams.filter((st) => camName[st]) : autoCams(s));
67 function centroid(pts) {
68 let x = 0, y = 0;
69 pts.forEach(([a, b]) => { x += a; y += b; });
70 return [x / pts.length, y / pts.length];
71 }
72 function sectorPath(c) {
73 const [cx, cy] = camXY(c), r = c.range * W;
74 if (c.fov >= 359.9) return `M${cx - r},${cy}a${r},${r} 0 1,0 ${2 * r},0a${r},${r} 0 1,0 ${-2 * r},0`;
75 const [ax, ay] = dir(c.angle - c.fov / 2), [bx, by] = dir(c.angle + c.fov / 2);
76 return `M${cx},${cy}L${cx + ax * r},${cy + ay * r}A${r},${r} 0 ${c.fov > 180 ? 1 : 0},1 ${cx + bx * r},${cy + by * r}Z`;
77 }
78
79 // ---------------------------------------------------------------- snapping (on by default, remembered; Alt = place freely)
80 // A corner / edge being placed snaps to another area's edge or the map's border when it's within SNAP_PX on the screen (a dashed
81 // guide shows it), otherwise to a grid that gets finer as you zoom in. A rectangle stays a rectangle when its corner or side is dragged.
82 const SNAP_PX = 10;
83 let snapOn = true, free = false, guides = [];
84 try { snapOn = localStorage.getItem('wm.snap') !== '0'; } catch (e) { /* default: on */ }
85 const gridStep = () => [5, 10, 25, 50, 100].find((s) => s >= 8 * unitsPerPx()) || 100;
86 function lines(exceptId) {
87 const xs = [0, W], ys = [0, H];
88 for (const s of data.sections) if (s.id !== exceptId) for (const [x, y] of secPts(s)) { xs.push(x); ys.push(y); }
89 return { xs, ys };
90 }
91 function near(v, ls, tol) {
92 let best = null, bd = tol;
93 for (const l of ls) { const d = Math.abs(l - v); if (d <= bd) { bd = d; best = l; } }
94 return best;
95 }
96 function snapPt([x, y], exceptId) {
97 if (!snapOn || free) return [clamp(x, 0, W), clamp(y, 0, H)];
98 const tol = SNAP_PX * unitsPerPx(), step = gridStep(), { xs, ys } = lines(exceptId);
99 const lx = near(x, xs, tol), ly = near(y, ys, tol);
100 if (lx !== null && lx > 0 && lx < W) guides.push({ x: lx });
101 if (ly !== null && ly > 0 && ly < H) guides.push({ y: ly });
102 return [clamp(lx !== null ? lx : Math.round(x / step) * step, 0, W), clamp(ly !== null ? ly : Math.round(y / step) * step, 0, H)];
103 }
104 // a whole area moved by (dx, dy): its box's sides snap to lines (else its top-left corner to the grid); stays inside the map
105 function snapShift(pts, dx, dy, exceptId) {
106 const xs = pts.map((p) => p[0] + dx), ys = pts.map((p) => p[1] + dy);
107 let x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
108 if (snapOn && !free) {
109 const tol = SNAP_PX * unitsPerPx(), step = gridStep(), L = lines(exceptId);
110 const fix = (lo, hi, ls, axis) => {
111 const a = near(lo, ls, tol), b = near(hi, ls, tol);
112 const da = a === null ? Infinity : a - lo, db = b === null ? Infinity : b - hi;
113 if (Math.abs(da) <= Math.abs(db) && da !== Infinity) { if (a > 0 && a < (axis === 'x' ? W : H)) guides.push({ [axis]: a }); return da; }
114 if (db !== Infinity) { if (b > 0 && b < (axis === 'x' ? W : H)) guides.push({ [axis]: b }); return db; }
115 return Math.round(lo / step) * step - lo;
116 };
117 const ax = fix(x0, x1, L.xs, 'x'), ay = fix(y0, y1, L.ys, 'y');
118 dx += ax; dy += ay; x0 += ax; x1 += ax; y0 += ay; y1 += ay;
119 }
120 dx -= Math.min(0, x0) + Math.max(0, x1 - W);
121 dy -= Math.min(0, y0) + Math.max(0, y1 - H);
122 return [dx, dy];
123 }
124 // a rectangle: 4 corners, every side straight across or straight up and down
125 function isRect(s) {
126 if (s.points.length !== 4) return false;
127 return s.points.every((p, i) => { const q = s.points[(i + 1) % 4]; return Math.abs(p[0] - q[0]) < 1e-6 || Math.abs(p[1] - q[1]) < 1e-6; });
128 }
129 const norm = (pts) => pts.map(([x, y]) => [clamp(x / W, 0, 1), clamp(y / H, 0, 1)]);
130
131 // ---------------------------------------------------------------- the view (zoom / pan)
132 const fitView = () => { view = { x: -20, y: -20, w: W + 40, h: H + 40 }; };
133 function applyView() { svg.setAttribute('viewBox', `${view.x} ${view.y} ${view.w} ${view.h}`); }
134 const unitsPerPx = () => { const m = svg.getScreenCTM(); return m ? 1 / m.a : view.w / (svg.clientWidth || 1000); };
135 function toMap(e) {
136 const p = svg.createSVGPoint();
137 p.x = e.clientX; p.y = e.clientY;
138 const m = svg.getScreenCTM();
139 if (!m) return [0, 0];
140 const q = p.matrixTransform(m.inverse());
141 return [q.x, q.y];
142 }
143 function zoom(f, at) {
144 const [ax, ay] = at || [view.x + view.w / 2, view.y + view.h / 2];
145 const w = clamp(view.w * f, 40, (W + 40) * 4), k = w / view.w;
146 view = { x: ax - (ax - view.x) * k, y: ay - (ay - view.y) * k, w, h: view.h * k };
147 draw();
148 }
149
150 // ---------------------------------------------------------------- drawing
151 const el = (tag, attrs = {}, parent) => {
152 const n = document.createElementNS(NS, tag);
153 for (const [k, v] of Object.entries(attrs)) if (v !== null && v !== undefined) n.setAttribute(k, v);
154 if (parent) parent.appendChild(n);
155 return n;
156 };
157 const hot = (stream) => !!(motion[stream] && (motion[stream].active || motion[stream].shared));
158
159 function draw() {
160 if (!map) return;
161 applyView();
162 svg.textContent = '';
163 const u = unitsPerPx(), fs = 13 * u;
164 const defs = el('defs', {}, svg);
165 const pat = el('pattern', { id: 'wmGrid', width: 50, height: 50, patternUnits: 'userSpaceOnUse' }, defs);
166 el('path', { d: 'M50 0H0V50', class: 'wm-grid-line' }, pat);
167 el('rect', { x: 0, y: 0, width: W, height: H, class: 'wm-floor' }, svg);
168 if (map.plan) el('image', { href: map.plan, x: 0, y: 0, width: W, height: H, preserveAspectRatio: 'none', class: 'wm-plan' }, svg);
169 else el('rect', { x: 0, y: 0, width: W, height: H, fill: 'url(#wmGrid)' }, svg);
170 el('rect', { x: 0, y: 0, width: W, height: H, class: 'wm-edge' }, svg);
171
172 const gs = el('g', {}, svg), gc = el('g', {}, svg), gl = el('g', {}, svg), gh = el('g', {}, svg);
173 for (const s of data.sections) {
174 const pts = secPts(s), isHot = areaCams(s).some(hot);
175 el('polygon', { points: pts.map((p) => p.join(',')).join(' '), class: `wm-area${sel && sel.id === s.id ? ' sel' : ''}${isHot ? ' hot' : ''}${hover === s.id ? ' hover' : ''}`,
176 style: `--c:${s.color}`, 'data-kind': 'section', 'data-id': s.id }, gs);
177 const [lx, ly] = centroid(pts), xs = pts.map((q) => q[0]), ys = pts.map((q) => q[1]);
178 const bw = Math.max(...xs) - Math.min(...xs), bh = Math.max(...ys) - Math.min(...ys), ang = labelAngle(s, bw, bh);
179 const t = el('text', { x: lx, y: ly, class: 'wm-label', 'font-size': fs * 1.1, 'text-anchor': 'middle', 'dominant-baseline': 'middle',
180 transform: ang ? `rotate(${ang === 270 ? -90 : 90} ${lx} ${ly})` : null }, gl);
181 t.textContent = s.name;
182 fitLabel(t, (ang ? bh : bw) - 10 * u, fs * 1.1);
183 }
184 for (const c of data.cameras) {
185 const [cx, cy] = camXY(c), isSel = sel && sel.id === c.id;
186 el('path', { d: sectorPath(c), class: `wm-cone${hot(c.stream) ? ' hot' : ''}${isSel ? ' sel' : ''}${hover === c.id ? ' hover' : ''}`, 'data-kind': 'camera', 'data-id': c.id }, gc);
187 el('circle', { cx, cy, r: 9 * u, class: `wm-cam${hot(c.stream) ? ' hot' : ''}${isSel ? ' sel' : ''}`, 'data-kind': 'camera', 'data-id': c.id }, gc);
188 const [dx, dy] = dir(c.angle);
189 el('line', { x1: cx, y1: cy, x2: cx + dx * 14 * u, y2: cy + dy * 14 * u, class: 'wm-cam-dir' }, gc);
190 const t = el('text', { x: cx, y: cy + 22 * u, class: 'wm-label wm-cam-label', 'font-size': fs * 0.9, 'text-anchor': 'middle' }, gl);
191 t.textContent = camName[c.stream] || c.stream;
192 }
193 if (editing && sel) { // handles for what's picked
194 if (sel.kind === 'section') {
195 const s = data.sections.find((x) => x.id === sel.id);
196 if (s) {
197 const pts = secPts(s);
198 if (isRect(s)) { // the middle of each side: move just that side
199 pts.forEach(([x, y], i) => {
200 const [x2, y2] = pts[(i + 1) % 4], flat = Math.abs(y - y2) < 1e-6;
201 el('rect', { x: (x + x2) / 2 - 6 * u, y: (y + y2) / 2 - 6 * u, width: 12 * u, height: 12 * u, rx: 2 * u,
202 class: `wm-handle edge ${flat ? 'ns' : 'ew'}`, 'data-kind': 'edge', 'data-id': s.id, 'data-i': i }, gh);
203 });
204 }
205 pts.forEach(([x, y], i) => el('circle', { cx: x, cy: y, r: 7 * u, class: 'wm-handle', 'data-kind': 'vertex', 'data-id': s.id, 'data-i': i }, gh));
206 }
207 } else {
208 const c = data.cameras.find((x) => x.id === sel.id);
209 if (c) {
210 const [cx, cy] = camXY(c), [dx, dy] = dir(c.angle), r = c.range * W;
211 el('line', { x1: cx, y1: cy, x2: cx + dx * r, y2: cy + dy * r, class: 'wm-aim-line' }, gh);
212 el('circle', { cx: cx + dx * r, cy: cy + dy * r, r: 8 * u, class: 'wm-handle aim', 'data-kind': 'aim', 'data-id': c.id }, gh);
213 }
214 }
215 }
216 for (const g of (drag && drag.moved) || draft ? guides : []) { // what it lined up with
217 if (g.x !== undefined) el('line', { x1: g.x, y1: 0, x2: g.x, y2: H, class: 'wm-guide' }, gh);
218 else el('line', { x1: 0, y1: g.y, x2: W, y2: g.y, class: 'wm-guide' }, gh);
219 }
220 if (draft) { // an area being drawn
221 const pts = draft.kind === 'rect' ? rectPts(draft.a, draft.b) : draft.pts.concat(draft.cursor ? [draft.cursor] : []);
222 if (pts.length > 1) el(draft.kind === 'rect' ? 'polygon' : 'polyline', { points: pts.map((p) => p.join(',')).join(' '), class: 'wm-draft' }, gh);
223 if (draft.kind === 'poly') draft.pts.forEach(([x, y], i) => el('circle', { cx: x, cy: y, r: (i ? 5 : 8) * u, class: 'wm-handle' + (i ? '' : ' first') }, gh));
224 }
225 }
226 const rectPts = ([ax, ay], [bx, by]) => [[ax, ay], [bx, ay], [bx, by], [ax, by]];
227 // an area's name: its own setting, or auto = sideways (reading upwards) when the area is much taller than wide
228 function labelAngle(s, bw, bh) {
229 if (s.label_angle === 0 || s.label_angle === 90 || s.label_angle === 270) return s.label_angle;
230 return bh > bw * 1.4 ? 270 : 0;
231 }
232 // a name longer than its area shrinks to fit (never below 60 %)
233 function fitLabel(t, room, size) {
234 let len = 0;
235 try { len = t.getComputedTextLength(); } catch (e) { return; }
236 if (len > room && room > 0) t.setAttribute('font-size', size * Math.max(0.6, room / len));
237 }
238
239 // only the "glow" classes change when motion does: no full redraw
240 function paintMotion() {
241 svg.querySelectorAll('[data-kind="camera"]').forEach((n) => {
242 const c = data && data.cameras.find((x) => x.id === n.dataset.id);
243 if (c) n.classList.toggle('hot', hot(c.stream));
244 });
245 svg.querySelectorAll('polygon[data-kind="section"]').forEach((n) => {
246 const s = data && data.sections.find((x) => x.id === n.dataset.id);
247 if (s) n.classList.toggle('hot', areaCams(s).some(hot));
248 });
249 document.querySelectorAll('#wmSide [data-hot-area]').forEach((n) => {
250 const s = data.sections.find((x) => x.id === n.dataset.hotArea);
251 n.classList.toggle('hot', !!s && areaCams(s).some(hot));
252 });
253 }
254
255 // ---------------------------------------------------------------- editing: changes, undo, save
256 function snapshot() {
257 undoStack.push(JSON.stringify(data));
258 if (undoStack.length > 60) undoStack.shift();
259 $('undoBtn') && ($('undoBtn').disabled = false);
260 }
261 function changed() {
262 dirty = true;
263 $('saveBtn').disabled = false;
264 $('dirtyNote').textContent = 'Not saved yet';
265 draw();
266 }
267 function undo() {
268 if (!undoStack.length) return;
269 data = JSON.parse(undoStack.pop());
270 if (sel && !findSel()) sel = null;
271 $('undoBtn').disabled = !undoStack.length;
272 changed();
273 side();
274 }
275 const findSel = () => sel && (sel.kind === 'section' ? data.sections : data.cameras).find((x) => x.id === sel.id);
276 const newId = () => Math.random().toString(36).slice(2, 10);
277
278 async function save() {
279 if (!dirty || !map) return;
280 $('saveBtn').disabled = true;
281 try {
282 const r = await JP.api(`${API}/maps/${map.id}`, { method: 'PUT', body: { version: map.version, name: map.name, data } });
283 map = r.map;
284 data = map.data;
285 dirty = false;
286 $('dirtyNote').textContent = 'Saved';
287 JP.toast('Map saved', 'good');
288 const m = maps.find((x) => x.id === map.id);
289 if (m) m.name = map.name;
290 fillPicker();
291 if (sel && !findSel()) sel = null;
292 draw();
293 side();
294 } catch (e) {
295 $('saveBtn').disabled = false;
296 JP.toast(e.message, 'error');
297 }
298 }
299 function remove() {
300 if (!sel) return;
301 snapshot();
302 if (sel.kind === 'section') data.sections = data.sections.filter((s) => s.id !== sel.id);
303 else data.cameras = data.cameras.filter((c) => c.id !== sel.id);
304 sel = null;
305 changed();
306 side();
307 }
308 function setTool(t) {
309 tool = t;
310 draft = null;
311 document.querySelectorAll('#wmTools [data-tool]').forEach((b) => b.classList.toggle('on', b.dataset.tool === t));
312 svg.classList.toggle('drawing', t !== 'select');
313 hint();
314 side();
315 draw();
316 }
317 function hint() {
318 const h = $('wmHint');
319 const text = !editing ? '' : tool === 'rect' ? 'Drag across the map to draw an area'
320 : tool === 'poly' ? (draft && draft.pts.length ? 'Click the next corner - click the first corner (or press Enter) to finish, Esc to stop' : 'Click the first corner of the area')
321 : tool === 'camera' ? 'Click where the camera is mounted' : '';
322 h.textContent = text;
323 h.hidden = !text;
324 }
325 function finishPoly() {
326 if (!draft || draft.kind !== 'poly' || draft.pts.length < 3) return;
327 addSection(draft.pts);
328 }
329 function addSection(pts) {
330 snapshot();
331 const s = { id: newId(), name: `Area ${data.sections.length + 1}`, color: COLORS[data.sections.length % COLORS.length],
332 points: pts.map(([x, y]) => [clamp(x / W, 0, 1), clamp(y / H, 0, 1)]), cams: null, label_angle: null };
333 data.sections.push(s);
334 draft = null;
335 sel = { kind: 'section', id: s.id };
336 setTool('select');
337 changed();
338 const name = document.querySelector('#wmSide [data-f="name"]');
339 if (name) { name.focus(); name.select(); }
340 }
341
342 // ---------------------------------------------------------------- pointer
343 svg.addEventListener('pointerdown', (e) => {
344 if (!map || e.button > 0) return;
345 const p = toMap(e), t = e.target.closest('[data-kind]'), kind = t && t.dataset.kind, id = t && t.dataset.id;
346 svg.setPointerCapture(e.pointerId);
347 drag = { start: p, last: p, sx: e.clientX, sy: e.clientY, moved: false, kind: 'pan', target: t };
348 if (!editing) return;
349 free = e.altKey;
350 guides = [];
351 if (tool === 'rect') { const a = snapPt(p); draft = { kind: 'rect', a, b: a }; drag.kind = 'rect'; drag.start = a; return; }
352 if (tool === 'poly') {
353 if (!draft) draft = { kind: 'poly', pts: [], cursor: null };
354 const first = draft.pts[0];
355 if (first && draft.pts.length >= 3 && Math.hypot(p[0] - first[0], p[1] - first[1]) < 12 * unitsPerPx()) finishPoly();
356 else { draft.pts.push(snapPt(p)); guides = []; hint(); draw(); }
357 drag = null;
358 return;
359 }
360 if (tool === 'camera') {
361 const stream = ($('newCam') || {}).value;
362 if (!stream) { JP.toast('Pick which camera first (on the right)', 'error'); drag = null; return; }
363 snapshot();
364 const c = { id: newId(), stream, x: clamp(p[0] / W, 0, 1), y: clamp(p[1] / H, 0, 1), angle: 180, fov: 70, range: 0.2 };
365 data.cameras.push(c);
366 sel = { kind: 'camera', id: c.id };
367 drag = null;
368 setTool('select');
369 changed();
370 return;
371 }
372 if (kind === 'vertex') { snapshot(); drag.kind = 'vertex'; drag.id = id; drag.i = +t.dataset.i; return; }
373 if (kind === 'edge') { snapshot(); drag.kind = 'edge'; drag.id = id; drag.i = +t.dataset.i; return; }
374 if (kind === 'aim') { snapshot(); drag.kind = 'aim'; drag.id = id; return; }
375 if (kind === 'section' || kind === 'camera') {
376 sel = { kind, id };
377 drag.kind = 'move'; drag.id = id; drag.what = kind; drag.snap = false;
378 if (kind === 'section') drag.orig = secPts(data.sections.find((x) => x.id === id));
379 draw();
380 side();
381 return;
382 }
383 if (sel) { sel = null; draw(); side(); }
384 });
385
386 svg.addEventListener('pointermove', (e) => {
387 const p = toMap(e);
388 free = e.altKey;
389 guides = [];
390 if (draft && draft.kind === 'poly' && !drag) { draft.cursor = editing ? snapPt(p) : p; draw(); return; }
391 if (!drag) {
392 const t = e.target.closest('[data-kind="section"],[data-kind="camera"]');
393 const h = t ? t.dataset.id : null;
394 if (h !== hover) { hover = h; svg.querySelectorAll('.hover').forEach((n) => n.classList.remove('hover')); if (t) svg.querySelectorAll(`[data-id="${CSS.escape(h)}"]`).forEach((n) => n.classList.add('hover')); }
395 return;
396 }
397 if (!drag.moved && Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) < 4) return;
398 drag.moved = true;
399 const dx = p[0] - drag.last[0], dy = p[1] - drag.last[1];
400 if (drag.kind === 'pan') {
401 const m = svg.getScreenCTM();
402 view.x -= (e.clientX - drag.sx) / m.a; view.y -= (e.clientY - drag.sy) / m.a;
403 drag.sx = e.clientX; drag.sy = e.clientY;
404 applyView();
405 return;
406 }
407 if (drag.kind === 'rect') { draft.b = snapPt(p); draw(); return; }
408 if (drag.kind === 'move') {
409 if (!drag.snap) { snapshot(); drag.snap = true; }
410 if (drag.what === 'section') {
411 const s = data.sections.find((x) => x.id === drag.id);
412 const [sx, sy] = snapShift(drag.orig, p[0] - drag.start[0], p[1] - drag.start[1], s.id);
413 s.points = norm(drag.orig.map(([x, y]) => [x + sx, y + sy]));
414 } else {
415 const c = data.cameras.find((x) => x.id === drag.id);
416 c.x = clamp(c.x + dx / W, 0, 1); c.y = clamp(c.y + dy / H, 0, 1);
417 }
418 } else if (drag.kind === 'vertex') {
419 const s = data.sections.find((x) => x.id === drag.id), q = snapPt(p, s.id);
420 if (isRect(s)) { // a rectangle's corner: resize it, the opposite corner stays put
421 const opp = secPts(s)[(drag.i + 2) % 4];
422 s.points = norm(rectPts(opp, q));
423 drag.i = 2;
424 } else s.points[drag.i] = [q[0] / W, q[1] / H];
425 } else if (drag.kind === 'edge') {
426 const s = data.sections.find((x) => x.id === drag.id), q = snapPt(p, s.id), a = drag.i, b = (drag.i + 1) % 4;
427 if (Math.abs(s.points[a][1] - s.points[b][1]) < 1e-6) { s.points[a][1] = q[1] / H; s.points[b][1] = q[1] / H; }
428 else { s.points[a][0] = q[0] / W; s.points[b][0] = q[0] / W; }
429 } else if (drag.kind === 'aim') {
430 const c = data.cameras.find((x) => x.id === drag.id), [cx, cy] = camXY(c);
431 c.angle = Math.round(((Math.atan2(p[0] - cx, -(p[1] - cy)) * 180 / Math.PI) + 360) % 360);
432 c.range = clamp(Math.hypot(p[0] - cx, p[1] - cy) / W, 0.02, 1.5);
433 }
434 drag.last = p;
435 changed();
436 sideValues();
437 });
438
439 svg.addEventListener('pointerup', (e) => {
440 const d = drag;
441 drag = null;
442 guides = [];
443 if (!d) return;
444 if (d.moved && d.kind !== 'pan' && editing) draw();
445 if (d.kind === 'rect') {
446 const [ax, ay] = d.start, [bx, by] = draft ? draft.b : d.start;
447 draft = null;
448 if (Math.abs(bx - ax) > 8 * unitsPerPx() && Math.abs(by - ay) > 8 * unitsPerPx()) addSection(rectPts([ax, ay], [bx, by]));
449 else draw();
450 return;
451 }
452 if (!d.moved && !editing && d.target) { // a click (not a drag) in view mode: open what was clicked
453 const id = d.target.dataset.id;
454 if (d.target.dataset.kind === 'section') openArea(data.sections.find((s) => s.id === id));
455 if (d.target.dataset.kind === 'camera') openCamera(data.cameras.find((c) => c.id === id));
456 }
457 });
458 svg.addEventListener('pointerleave', () => { if (draft && draft.kind === 'poly') { draft.cursor = null; draw(); } });
459 svg.addEventListener('wheel', (e) => { e.preventDefault(); zoom(e.deltaY > 0 ? 1.15 : 1 / 1.15, toMap(e)); }, { passive: false });
460 $('zIn').onclick = () => zoom(1 / 1.3);
461 $('zOut').onclick = () => zoom(1.3);
462 $('zFit').onclick = () => { fitView(); draw(); };
463 window.addEventListener('resize', () => draw());
464
465 document.addEventListener('keydown', (e) => {
466 if (!editing) { if (e.key === 'Escape') closeModal(); return; }
467 const typing = /^(INPUT|TEXTAREA|SELECT)$/.test((e.target || {}).tagName || '');
468 if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault(); save(); return; }
469 if (typing) return;
470 if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); undo(); return; }
471 if (e.key === 'Escape') { if (draft) { draft = null; hint(); draw(); } else if (sel) { sel = null; draw(); side(); } return; }
472 if (e.key === 'Enter' && draft && draft.kind === 'poly') { finishPoly(); return; }
473 if ((e.key === 'Delete' || e.key === 'Backspace') && sel) { e.preventDefault(); remove(); }
474 });
475
476 // ---------------------------------------------------------------- the side panel
477 const camOptions = (current) => cams.map((c) => `<option value="${esc(c.stream)}"${c.stream === current ? ' selected' : ''}>${esc(c.name)}${c.view_only ? VO : ''}</option>`).join('');
478
479 function side() {
480 const box = $('wmSide');
481 if (!map) { box.innerHTML = ''; return; }
482 if (!editing) {
483 const areas = data.sections.slice().sort((a, b) => a.name.localeCompare(b.name));
484 const onMap = data.cameras.slice().sort((a, b) => (camName[a.stream] || '').localeCompare(camName[b.stream] || ''));
485 box.innerHTML = `<h3>Areas</h3>${areas.length ? `<div class="wm-list">${areas.map((s) => {
486 const n = areaCams(s).length;
487 return `<button type="button" class="wm-item" data-open-area="${esc(s.id)}" data-hot-area="${esc(s.id)}"><span class="wm-swatch" style="background:${esc(s.color)}"></span>
488 <span class="wm-item-name">${esc(s.name)}</span><span class="muted small">${n} camera${n === 1 ? '' : 's'}</span></button>`;
489 }).join('')}</div>` : '<p class="muted small">No areas yet.</p>'}
490 <h3>Cameras</h3>${onMap.length ? `<div class="wm-list">${onMap.map((c) => `<button type="button" class="wm-item" data-open-cam="${esc(c.id)}">đź“·
491 <span class="wm-item-name">${esc(camName[c.stream] || c.stream)}</span></button>`).join('')}</div>` : '<p class="muted small">No cameras on this map yet.</p>'}
492 <p class="muted small wm-note">Areas and cameras glow while a camera sees motion.</p>`;
493 paintMotion();
494 return;
495 }
496 const s = sel && sel.kind === 'section' && findSel(), c = sel && sel.kind === 'camera' && findSel();
497 if (tool === 'camera') {
498 const placed = new Set(data.cameras.map((x) => x.stream));
499 const order = cams.slice().sort((a, b) => placed.has(a.stream) - placed.has(b.stream));
500 box.innerHTML = `<h3>Place a camera</h3><label class="wm-field">Which camera
501 <select id="newCam">${order.map((x) => `<option value="${esc(x.stream)}">${esc(x.name)}${x.view_only ? VO : ''}${placed.has(x.stream) ? ' (already on the map)' : ''}</option>`).join('')}</select></label>
502 <p class="muted small">Then click where it's mounted. Drag its round handle to point it and set how far it sees.</p>`;
503 return;
504 }
505 if (s) {
506 const auto = autoCams(s);
507 box.innerHTML = `<h3>Area</h3>
508 <label class="wm-field">Name <input data-f="name" maxlength="60" value="${esc(s.name)}" placeholder="e.g. Lobby, Office 2, Aisle B"></label>
509 <label class="wm-field">Colour <input type="color" data-f="color" value="${esc(s.color)}"></label>
510 <label class="wm-field">Label <select data-f="label">
511 ${[['auto', 'Auto (tall areas turn it sideways)'], ['0', 'Across'], ['270', 'Sideways, reading up'], ['90', 'Sideways, reading down']].map(([v, l]) =>
512 `<option value="${v}"${String(s.label_angle == null ? 'auto' : s.label_angle) === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label>
513 <div class="wm-field"><span>Cameras for this area</span>
514 <label class="wm-radio"><input type="radio" name="camMode" value="auto"${s.cams ? '' : ' checked'}> The cameras that see it</label>
515 <label class="wm-radio"><input type="radio" name="camMode" value="manual"${s.cams ? ' checked' : ''}> Pick them myself</label>
516 <div id="camPick"></div></div>
517 <p class="muted small">Drag the area to move it${isRect(s) ? ', a corner to resize it, the middle of a side to move just that side' : ', its corners to reshape it'}.
518 ${snapOn ? 'Things snap into line (hold Alt to place freely).' : ''}</p>
519 ${s.points.length === 4 && !isRect(s) ? '<button type="button" class="btn secondary small" data-act="square" title="Make it a straight-sided rectangle around where it is now">â–­ Square it up</button> ' : ''}
520 <button type="button" class="btn danger small" data-act="delete">Delete area</button>`;
521 const sq = box.querySelector('[data-act="square"]');
522 if (sq) sq.onclick = () => {
523 snapshot();
524 const pts = secPts(s), xs = pts.map((q) => q[0]), ys = pts.map((q) => q[1]);
525 s.points = norm(rectPts([Math.min(...xs), Math.min(...ys)], [Math.max(...xs), Math.max(...ys)]));
526 changed();
527 side();
528 };
529 const pick = () => {
530 $('camPick').innerHTML = s.cams
531 ? cams.map((x) => `<label class="wm-check"><input type="checkbox" value="${esc(x.stream)}"${s.cams.includes(x.stream) ? ' checked' : ''}> ${esc(x.name)}${x.view_only ? VO : ''}</label>`).join('')
532 : (auto.length ? `<div class="small">${auto.map((st) => esc(camName[st] || st)).join(', ')}</div>` : '<div class="muted small">No camera on the map sees it yet.</div>');
533 };
534 pick();
535 let typed = false; // one undo step for a whole rename, taken on the first key (not on focus)
536 box.querySelector('[data-f="name"]').oninput = (e) => { if (!typed) { snapshot(); typed = true; } s.name = e.target.value.slice(0, 60); changed(); };
537 box.querySelector('[data-f="color"]').onchange = (e) => { snapshot(); s.color = e.target.value; changed(); };
538 box.querySelector('[data-f="label"]').onchange = (e) => { snapshot(); s.label_angle = e.target.value === 'auto' ? null : +e.target.value; changed(); };
539 box.querySelectorAll('input[name="camMode"]').forEach((r) => { r.onchange = () => { snapshot(); s.cams = r.value === 'manual' ? autoCams(s) : null; changed(); pick(); }; });
540 $('camPick').onchange = (e) => {
541 if (e.target.type !== 'checkbox') return;
542 snapshot();
543 s.cams = [...$('camPick').querySelectorAll('input:checked')].map((i) => i.value);
544 changed();
545 };
546 } else if (c) {
547 box.innerHTML = `<h3>Camera</h3>
548 <label class="wm-field">Which camera <select data-f="stream">${camOptions(c.stream)}</select></label>
549 <label class="wm-field">Points <span class="wm-val" data-v="angle"></span><input type="range" min="0" max="359" data-f="angle"></label>
550 <label class="wm-field">Sees across <span class="wm-val" data-v="fov"></span><input type="range" min="10" max="360" data-f="fov"></label>
551 <label class="wm-field">Sees as far as <span class="wm-val" data-v="range"></span><input type="range" min="2" max="150" data-f="range"></label>
552 <p class="muted small">Drag the camera to move it, its round handle to point it.</p>
553 <button type="button" class="btn danger small" data-act="delete">Remove camera</button>`;
554 box.querySelector('[data-f="stream"]').onchange = (e) => { snapshot(); c.stream = e.target.value; changed(); };
555 for (const f of ['angle', 'fov', 'range']) {
556 const inp = box.querySelector(`[data-f="${f}"]`);
557 inp.addEventListener('pointerdown', snapshot);
558 inp.addEventListener('keydown', snapshot, { once: true });
559 inp.oninput = () => { c[f] = f === 'range' ? +inp.value / 100 : +inp.value; changed(); sideValues(); };
560 }
561 sideValues();
562 } else {
563 box.innerHTML = `<h3>This map</h3>
564 <label class="wm-field">Name <input data-f="mapname" maxlength="60" value="${esc(map.name)}"></label>
565 <p class="muted small">${map.plan ? `Floor plan: ${map.plan_size[0]} Ă— ${map.plan_size[1]} pixels.` : 'No floor plan yet - you can draw areas and place cameras now and add the plan later (everything keeps its place relative to the map).'}</p>
566 <p class="muted small"><b>Draw</b>: ▭ Area for rooms, aisles and other square areas; ⬠ Shape for odd-shaped areas. <b>Cameras</b>: 📷, then drag the round handle to point it.
567 Click something to change or delete it.</p>
568 <button type="button" class="btn danger small" data-act="deletemap">Delete this map</button>`;
569 const nm = box.querySelector('[data-f="mapname"]');
570 nm.oninput = () => { map.name = nm.value.slice(0, 60) || map.name; changed(); };
571 }
572 box.querySelectorAll('[data-act="delete"]').forEach((b) => { b.onclick = remove; });
573 const dm = box.querySelector('[data-act="deletemap"]');
574 if (dm) dm.onclick = deleteMap;
575 $('delBtn').disabled = !sel;
576 }
577 function sideValues() {
578 const c = sel && sel.kind === 'camera' && findSel(), box = $('wmSide');
579 if (!c || !box.querySelector('[data-f="angle"]')) return;
580 const set = (f, v, txt) => { const i = box.querySelector(`[data-f="${f}"]`); if (document.activeElement !== i) i.value = v; box.querySelector(`[data-v="${f}"]`).textContent = txt; };
581 const compass = ['up', 'up-right', 'right', 'down-right', 'down', 'down-left', 'left', 'up-left'][Math.round(c.angle / 45) % 8];
582 set('angle', Math.round(c.angle), `${Math.round(c.angle)}° (${compass})`);
583 set('fov', Math.round(c.fov), `${Math.round(c.fov)}°`);
584 set('range', Math.round(c.range * 100), `${Math.round(c.range * 100)}% of the map's width`);
585 }
586
587 $('wmSide').addEventListener('click', (e) => {
588 const a = e.target.closest('[data-open-area]'), c = e.target.closest('[data-open-cam]');
589 if (a) openArea(data.sections.find((s) => s.id === a.dataset.openArea));
590 if (c) openCamera(data.cameras.find((x) => x.id === c.dataset.openCam));
591 });
592 $('wmSide').addEventListener('pointerover', (e) => {
593 const a = e.target.closest('[data-open-area],[data-open-cam]');
594 const id = a ? (a.dataset.openArea || a.dataset.openCam) : null;
595 svg.querySelectorAll('.hover').forEach((n) => n.classList.remove('hover'));
596 if (id) svg.querySelectorAll(`[data-id="${CSS.escape(id)}"]`).forEach((n) => n.classList.add('hover'));
597 });
598
599 // ---------------------------------------------------------------- watching: an area's / a camera's cameras
600 const pad = (n) => String(n).padStart(2, '0');
601 const localValue = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
602
603 function openArea(s) {
604 if (!s) return;
605 showModal(s.name, s.cams ? 'Cameras picked for this area' : 'Cameras that see this area', areaCams(s));
606 }
607 function openCamera(c) {
608 if (!c) return;
609 showModal(camName[c.stream] || c.stream, 'Camera', [c.stream]);
610 }
611 function showModal(title, sub, streams) {
612 closePlayers();
613 $('wmModalTitle').textContent = title;
614 $('wmModalSub').textContent = streams.length ? `${sub} · ${streams.length}` : sub;
615 const at = new Date(Date.now() - 5 * 60000);
616 $('wmAt').value = localValue(at);
617 const shown = streams.slice(0, 9);
618 $('wmTiles').innerHTML = streams.length ? shown.map((st) => `<div class="wm-tile" data-stream="${esc(st)}">
619 <div class="wm-video"><video muted playsinline autoplay></video><span class="wm-tile-state">Connecting…</span></div>
620 <div class="wm-tile-foot"><b>${esc(camName[st] || st)}</b>
621 <span><a href="/live/${encodeURIComponent(st)}" target="_blank" rel="noopener">Live ↗</a>
622 ${viewOnly.has(st) ? '<span class="muted small" title="This camera is view only: it doesn\'t record">View only</span>' : '<a data-rec href="#" target="_blank" rel="noopener">⏵ Recording</a>'}</span></div></div>`).join('')
623 + (streams.length > shown.length ? `<p class="muted small">…and ${streams.length - shown.length} more: ${streams.slice(9).map((st) => esc(camName[st] || st)).join(', ')}</p>` : '')
624 : '<p class="muted">No camera covers this area yet' + (WM.canEdit ? ' - edit the map to place and aim one, or pick them by hand.' : '.') + '</p>';
625 recLinks();
626 $('wmModal').hidden = false;
627 document.body.classList.add('no-scroll');
628 document.querySelectorAll('#wmTiles .wm-tile').forEach(startTile);
629 }
630 function recLinks() {
631 const v = $('wmAt').value;
632 const at = v.length === 16 ? v + ':00' : v;
633 document.querySelectorAll('#wmTiles [data-rec]').forEach((a) => {
634 const st = a.closest('.wm-tile').dataset.stream;
635 a.href = `/recordings?stream=${encodeURIComponent(st)}${at ? `&at=${encodeURIComponent(at)}` : ''}`;
636 });
637 }
638 $('wmAt').addEventListener('input', recLinks);
639
640 function startTile(tile) {
641 const video = tile.querySelector('video'), state = tile.querySelector('.wm-tile-state'), stream = tile.dataset.stream;
642 const p = { tile, handle: null, timer: null, tries: 0, sub: true, closed: false };
643 players.push(p);
644 const go = () => {
645 if (p.closed) return;
646 state.hidden = false;
647 p.handle = JPLive.play(video, p.sub ? `${stream}_sub` : stream, {
648 onPlaying: () => { state.hidden = true; p.tries = 0; },
649 onFail: () => {
650 if (p.closed) return;
651 if (p.sub) { p.sub = false; go(); return; } // no sub-stream: the main one
652 p.tries += 1;
653 state.textContent = p.tries > 3 ? "Can't play this camera right now" : 'Reconnecting…';
654 if (p.tries <= 3) p.timer = setTimeout(go, 3000 * p.tries);
655 },
656 });
657 };
658 go();
659 }
660 function closePlayers() {
661 for (const p of players) {
662 p.closed = true;
663 clearTimeout(p.timer);
664 try { if (p.handle) p.handle.close(); } catch (e) { /* already closed */ }
665 const v = p.tile.querySelector('video');
666 if (v) { v.pause(); v.removeAttribute('src'); v.load(); }
667 }
668 players.length = 0;
669 }
670 function closeModal() {
671 if ($('wmModal').hidden) return;
672 closePlayers();
673 $('wmModal').hidden = true;
674 document.body.classList.remove('no-scroll');
675 }
676 $('wmModalClose').onclick = closeModal;
677 $('wmModal').addEventListener('click', (e) => { if (e.target === $('wmModal')) closeModal(); });
678
679 // ---------------------------------------------------------------- motion: glow while a camera sees it
680 async function pollMotion() {
681 if (document.hidden || !map || editing) return;
682 try {
683 const d = await JP.api('/api/motion/now');
684 const next = d.cameras || {};
685 if (JSON.stringify(next) !== JSON.stringify(motion)) { motion = next; paintMotion(); }
686 } catch (e) { /* recorder unreachable: no glow */ }
687 }
688 setInterval(pollMotion, 2000);
689
690 // ---------------------------------------------------------------- maps: pick, make, delete, floor plan
691 function fillPicker() {
692 const pick = $('mapPick');
693 pick.innerHTML = maps.map((m) => `<option value="${m.id}"${map && m.id === map.id ? ' selected' : ''}>${esc(m.name)}</option>`).join('');
694 pick.hidden = maps.length < 2;
695 }
696 function empty() {
697 $('wmLayout').hidden = true;
698 $('editToggle') && ($('editToggle').hidden = true);
699 $('wmEmpty').hidden = false;
700 $('wmEmpty').innerHTML = WM.canEdit
701 ? `<h3>No map yet</h3><p class="muted">Make one now: you can draw the areas and place the cameras straight away and add the floor plan picture later.</p>
702 <button class="btn" type="button" id="firstMap">Make the first map</button>`
703 : '<h3>No map yet</h3><p class="muted">An admin hasn\'t made a map of the building yet.</p>';
704 if ($('firstMap')) $('firstMap').onclick = newMap;
705 }
706 async function loadMap(id) {
707 const r = await JP.api(`${API}/maps/${id}`);
708 map = r.map;
709 data = map.data;
710 H = W * map.aspect;
711 sel = null; draft = null; undoStack = []; dirty = false;
712 if ($('undoBtn')) $('undoBtn').disabled = true;
713 if ($('saveBtn')) { $('saveBtn').disabled = true; $('dirtyNote').textContent = ''; }
714 if ($('planRemove')) $('planRemove').hidden = !map.plan;
715 try { localStorage.setItem(`wm.map.${WM.id}`, String(map.id)); } catch (e) { /* not remembered */ }
716 $('wmEmpty').hidden = true;
717 $('wmLayout').hidden = false;
718 if ($('editToggle')) $('editToggle').hidden = false;
719 $('wmSvg').style.aspectRatio = `${W + 40} / ${H + 40}`;
720 fitView();
721 fillPicker();
722 draw();
723 side();
724 pollMotion();
725 }
726 async function start() {
727 try {
728 const r = await JP.api(`${API}/maps`);
729 maps = r.maps;
730 cams = r.cameras;
731 camName = Object.fromEntries(cams.map((c) => [c.stream, c.name]));
732 viewOnly = new Set(cams.filter((c) => c.view_only).map((c) => c.stream));
733 if (!maps.length) { empty(); return; }
734 let want = null;
735 try { want = +localStorage.getItem(`wm.map.${WM.id}`); } catch (e) { /* default */ }
736 await loadMap((maps.find((m) => m.id === want) || maps[0]).id);
737 } catch (e) {
738 $('wmEmpty').hidden = false;
739 $('wmEmpty').innerHTML = `<p class="bad">${esc(e.message)}</p>`;
740 }
741 }
742 const leaveOk = () => !dirty || confirm('This map has changes that aren\'t saved. Leave them?');
743 $('mapPick').onchange = async (e) => {
744 if (!leaveOk()) { e.target.value = String(map.id); return; }
745 dirty = false;
746 if (editing) setEditing(false);
747 await loadMap(+e.target.value);
748 };
749 async function newMap() {
750 if (!leaveOk()) return;
751 const name = prompt('Name of the new map (e.g. Main floor, Building 2, Yard):', maps.length ? '' : 'Main floor');
752 if (name === null) return;
753 try {
754 const r = await JP.api(`${API}/maps`, { method: 'POST', body: { name } });
755 maps.push(r.map);
756 maps.sort((a, b) => a.name.localeCompare(b.name));
757 dirty = false;
758 await loadMap(r.map.id);
759 if (!editing) setEditing(true);
760 } catch (e) { JP.toast(e.message, 'error'); }
761 }
762 async function deleteMap() {
763 if (!confirm(`Delete the map "${map.name}" with its areas, cameras and floor plan? This can't be undone.`)) return;
764 try {
765 await JP.api(`${API}/maps/${map.id}`, { method: 'DELETE' });
766 maps = maps.filter((m) => m.id !== map.id);
767 dirty = false;
768 if (editing) setEditing(false);
769 map = null;
770 if (maps.length) await loadMap(maps[0].id); else empty();
771 fillPicker();
772 JP.toast('Map deleted', 'good');
773 } catch (e) { JP.toast(e.message, 'error'); }
774 }
775 function toggleEdit() {
776 if (!editing) { setEditing(true); return; }
777 if (!leaveOk()) return;
778 const unsaved = dirty;
779 dirty = false;
780 setEditing(false);
781 if (unsaved) loadMap(map.id); // left without saving: back to what's saved
782 }
783 function setEditing(on) {
784 editing = on;
785 $('wmTools').hidden = !editing;
786 $('editToggle').textContent = editing ? '✓ Done editing' : '✎ Edit map';
787 $('wmLead').textContent = editing ? 'Editing - draw areas, place cameras, then Save' : 'Click an area or a camera to watch it';
788 document.body.classList.toggle('wm-editing', editing);
789 sel = null; draft = null;
790 if (editing) { closeModal(); motion = {}; }
791 setTool('select');
792 pollMotion();
793 }
794 if (WM.canEdit) {
795 $('mapNew').onclick = newMap;
796 $('editToggle').onclick = toggleEdit;
797 $('saveBtn').onclick = save;
798 $('undoBtn').onclick = undo;
799 $('delBtn').onclick = remove;
800 $('snapOn').checked = snapOn;
801 $('snapOn').onchange = (e) => { snapOn = e.target.checked; try { localStorage.setItem('wm.snap', snapOn ? '1' : '0'); } catch (err) { /* not remembered */ } side(); };
802 document.querySelectorAll('#wmTools [data-tool]').forEach((b) => { b.onclick = () => setTool(b.dataset.tool); });
803 $('planFile').onchange = async (e) => {
804 const f = e.target.files[0];
805 e.target.value = '';
806 if (!f || !map) return;
807 const fd = new FormData();
808 fd.append('plan', f);
809 try {
810 const res = await fetch(`${API}/maps/${map.id}/plan`, { method: 'POST', body: fd, credentials: 'same-origin' });
811 const r = await res.json().catch(() => ({}));
812 if (!res.ok || !r.ok) throw new Error(r.error || `Upload failed (${res.status})`);
813 const keep = data; // unsaved drawing stays; only the picture and the version change
814 map = Object.assign(r.map, { data: keep });
815 H = W * map.aspect;
816 $('wmSvg').style.aspectRatio = `${W + 40} / ${H + 40}`;
817 $('planRemove').hidden = false;
818 fitView(); draw(); side();
819 JP.toast('Floor plan added', 'good');
820 } catch (err) { JP.toast(err.message, 'error'); }
821 };
822 $('planRemove').onclick = async () => {
823 if (!confirm('Remove the floor plan picture? The areas and cameras stay where they are.')) return;
824 try {
825 const r = await JP.api(`${API}/maps/${map.id}/plan`, { method: 'DELETE' });
826 map = Object.assign(r.map, { data });
827 $('planRemove').hidden = true;
828 draw(); side();
829 } catch (err) { JP.toast(err.message, 'error'); }
830 };
831 }
832 window.addEventListener('beforeunload', (e) => { if (dirty) { e.preventDefault(); e.returnValue = ''; } });
833
834 window.WMap = { get map() { return map; }, get data() { return data; }, get editing() { return editing; }, sees, areaCams, isRect, W, get H() { return H; } };
835 start();
836})();