/* globals: Konva */ // --------------------------------------------------------------------------- // Constants & mutable state // --------------------------------------------------------------------------- const PPM = 60; // pixels per meter let rooms = []; // [{id, name, x, y, width, height}] let sensors = []; // [{id, sensor_id, name?, floor_x, floor_y, room_id}] let mode = 'view'; let _fittedOnce = false; let _dragging = false; // true while any room group is being dragged // Room currently showing dimension overlay (follows during drag). let _dimRoom = null; let _dimGroup = null; // --------------------------------------------------------------------------- // Stage & layers // --------------------------------------------------------------------------- const stage = new Konva.Stage({ container: 'canvas-container', width: window.innerWidth, height: window.innerHeight, }); stage.draggable(true); Konva.hitOnDragEnabled = true; const roomsLayer = new Konva.Layer(); const dimensionsLayer = new Konva.Layer(); const sensorsLayer = new Konva.Layer(); const tagsLayer = new Konva.Layer(); const particlesLayer = new Konva.Layer(); stage.add(roomsLayer, dimensionsLayer, sensorsLayer, tagsLayer, particlesLayer); new ResizeObserver(() => { stage.width(window.innerWidth); stage.height(window.innerHeight); }).observe(document.body); // --------------------------------------------------------------------------- // Pinch-to-zoom & mouse wheel zoom // --------------------------------------------------------------------------- let _lastPinchDist = 0; stage.on('touchmove', (e) => { e.evt.preventDefault(); const touches = e.evt.touches; if (touches.length !== 2) return; stage.stopDrag(); const dist = Math.hypot( touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY, ); if (_lastPinchDist === 0) { _lastPinchDist = dist; return; } _applyZoom( { x: (touches[0].clientX + touches[1].clientX) / 2, y: (touches[0].clientY + touches[1].clientY) / 2 }, dist / _lastPinchDist, ); _lastPinchDist = dist; }); stage.on('touchend touchcancel', () => { _lastPinchDist = 0; }); stage.on('wheel', (e) => { e.evt.preventDefault(); _applyZoom(stage.getPointerPosition(), e.evt.deltaY < 0 ? 1.06 : 1 / 1.06); }); function _applyZoom(center, factor) { const oldScale = stage.scaleX(); const newScale = Math.max(0.1, Math.min(20, oldScale * factor)); const anchorX = (center.x - stage.x()) / oldScale; const anchorY = (center.y - stage.y()) / oldScale; stage.scale({ x: newScale, y: newScale }); stage.position({ x: center.x - anchorX * newScale, y: center.y - anchorY * newScale }); } // --------------------------------------------------------------------------- // Room rendering // --------------------------------------------------------------------------- function renderRooms() { if (_dragging) return; // never destroy groups while a drag is live roomsLayer.destroyChildren(); rooms.forEach(_buildRoomGroup); roomsLayer.batchDraw(); } function _buildRoomGroup(room) { const rw = room.width * PPM; const rh = room.height * PPM; const group = new Konva.Group({ id: `room-${room.id}`, x: room.x * PPM, y: room.y * PPM, draggable: mode === 'edit', }); // Floor area group.add(new Konva.Rect({ width: rw, height: rh, fill: '#ffffff', stroke: '#444444', strokeWidth: 1.5, })); // Room label group.add(new Konva.Text({ width: rw, height: rh, text: room.name, fontSize: 12, fill: 'rgba(0,0,0,0.45)', align: 'center', verticalAlign: 'middle', letterSpacing: 0.8, listening: false, })); group.on('click tap', () => { if (mode === 'edit') { _dimRoom = room; _dimGroup = group; _showDimensions(room, group.x(), group.y()); notifyFlutter({ type: 'roomTapped', id: room.id }); } }); group.on('dragstart', () => { _dragging = true; if (mode === 'edit') { _dimRoom = room; _dimGroup = group; _showDimensions(room, group.x(), group.y()); } }); group.on('dragend', () => { _dragging = false; _onRoomDragEnd(room, group); }); roomsLayer.add(group); } // --------------------------------------------------------------------------- // Room edge snapping during drag // --------------------------------------------------------------------------- const SNAP_THRESHOLD = 10; // screen pixels; scaled by stage zoom for layer coords roomsLayer.on('dragmove', (e) => { if (mode !== 'edit') return; const node = e.target; if (!node.id().startsWith('room-')) return; const roomId = parseInt(node.id().replace('room-', ''), 10); const draggedRoom = rooms.find(r => r.id === roomId); if (!draggedRoom) return; const threshold = SNAP_THRESHOLD / stage.scaleX(); const x = node.x(), y = node.y(); const w = draggedRoom.width * PPM, h = draggedRoom.height * PPM; let bestX = null, bestY = null, dX = threshold, dY = threshold; rooms.forEach(r => { if (r.id === draggedRoom.id) return; const rx = r.x * PPM, ry = r.y * PPM; const rw = r.width * PPM, rh = r.height * PPM; // Four X snap candidates: left↔left, right↔right, right→left, left←right [[x, rx], [x + w, rx + rw], [x + w, rx], [x, rx + rw]].forEach(([a, b]) => { const d = Math.abs(a - b); if (d < dX) { dX = d; bestX = x + (b - a); } }); // Four Y snap candidates [[y, ry], [y + h, ry + rh], [y + h, ry], [y, ry + rh]].forEach(([a, b]) => { const d = Math.abs(a - b); if (d < dY) { dY = d; bestY = y + (b - a); } }); }); if (bestX !== null) node.x(bestX); if (bestY !== null) node.y(bestY); if (_dimRoom && _dimRoom.id === draggedRoom.id) { _showDimensions(_dimRoom, node.x(), node.y()); } // Move sensors belonging to this room so they keep their relative position. const newRoomX = node.x() / PPM; const newRoomY = node.y() / PPM; sensors.forEach(s => { if (s.room_id !== roomId || s.floor_x == null) return; const sg = sensorsLayer.findOne(`#sensor-${s.id}`); if (!sg) return; sg.x((newRoomX + s.floor_x) * PPM); sg.y((newRoomY + s.floor_y) * PPM); }); sensorsLayer.batchDraw(); }); // --------------------------------------------------------------------------- // Dimension overlay (tap-to-show in edit mode) // --------------------------------------------------------------------------- function _showDimensions(room, lx, ly) { dimensionsLayer.destroyChildren(); const c = '#1565C0'; const DIM = 26; const EXT = 5; const rx = lx; const ry = ly; const rw = room.width * PPM; const rh = room.height * PPM; // Determine which outer sides are free from adjacent rooms (use stored positions). let widthBelow = true; let heightRight = true; rooms.forEach(r => { if (r.id === room.id) return; if (Math.abs(r.y * PPM - (ry + rh)) < DIM + EXT + 4) widthBelow = false; if (Math.abs(r.x * PPM - (rx + rw)) < DIM + EXT + 4) heightRight = false; }); const arrowCfg = { stroke: c, fill: c, strokeWidth: 1, pointerLength: 5, pointerWidth: 4, pointerAtBeginning: true, listening: false, }; // Width dimension if (widthBelow) { const wy = ry + rh + DIM; dimensionsLayer.add(new Konva.Arrow({ ...arrowCfg, points: [rx, wy, rx + rw, wy] })); dimensionsLayer.add(new Konva.Line({ points: [rx, ry + rh, rx, wy + EXT], stroke: c, strokeWidth: 0.8, listening: false })); dimensionsLayer.add(new Konva.Line({ points: [rx + rw, ry + rh, rx + rw, wy + EXT], stroke: c, strokeWidth: 0.8, listening: false })); dimensionsLayer.add(new Konva.Text({ x: rx + rw / 2 - 20, y: wy - 13, width: 40, text: `${room.width.toFixed(1)} m`, fontSize: 9, fill: c, align: 'center', listening: false, })); } else { // Inside the room near the bottom edge const wy = ry + rh - 18; dimensionsLayer.add(new Konva.Arrow({ ...arrowCfg, points: [rx + 4, wy, rx + rw - 4, wy] })); dimensionsLayer.add(new Konva.Text({ x: rx + rw / 2 - 20, y: wy - 12, width: 40, text: `${room.width.toFixed(1)} m`, fontSize: 9, fill: c, align: 'center', listening: false, })); } // Height dimension if (heightRight) { const hx = rx + rw + DIM; dimensionsLayer.add(new Konva.Arrow({ ...arrowCfg, points: [hx, ry, hx, ry + rh] })); dimensionsLayer.add(new Konva.Line({ points: [rx + rw, ry, hx + EXT, ry], stroke: c, strokeWidth: 0.8, listening: false })); dimensionsLayer.add(new Konva.Line({ points: [rx + rw, ry + rh, hx + EXT, ry + rh], stroke: c, strokeWidth: 0.8, listening: false })); dimensionsLayer.add(new Konva.Text({ x: hx - 10, y: ry + rh / 2, width: rh, align: 'center', text: `${room.height.toFixed(1)} m`, fontSize: 9, fill: c, listening: false, rotation: -90, offsetX: rh / 2, offsetY: 4.5, })); } else { // Inside the room near the right edge const hx = rx + rw - 18; dimensionsLayer.add(new Konva.Arrow({ ...arrowCfg, points: [hx, ry + 4, hx, ry + rh - 4] })); dimensionsLayer.add(new Konva.Text({ x: hx - 10, y: ry + rh / 2, width: rh, align: 'center', text: `${room.height.toFixed(1)} m`, fontSize: 9, fill: c, listening: false, rotation: -90, offsetX: rh / 2, offsetY: 4.5, })); } dimensionsLayer.batchDraw(); } function _clearDimensions() { _dimRoom = null; _dimGroup = null; dimensionsLayer.destroyChildren(); dimensionsLayer.batchDraw(); } // --------------------------------------------------------------------------- // Room drag end // --------------------------------------------------------------------------- function _onRoomDragEnd(room, group) { room.x = group.x() / PPM; room.y = group.y() / PPM; // Normalize: shift all rooms so the top-left of the bounding box is (0,0). const minX = Math.min(...rooms.map(r => r.x)); const minY = Math.min(...rooms.map(r => r.y)); if (minX !== 0 || minY !== 0) { rooms.forEach(r => { r.x -= minX; r.y -= minY; }); rooms.forEach(r => { const g = roomsLayer.findOne(`#room-${r.id}`); if (g) { g.x(r.x * PPM); g.y(r.y * PPM); } }); } // Reposition sensor groups to reflect any normalization shift. sensors.forEach(s => { if (s.floor_x == null) return; const r = rooms.find(r => r.id === s.room_id); if (!r) return; const sg = sensorsLayer.findOne(`#sensor-${s.id}`); if (!sg) return; sg.x((r.x + s.floor_x) * PPM); sg.y((r.y + s.floor_y) * PPM); }); sensorsLayer.batchDraw(); // Keep dimensions visible at the final position after normalization. if (_dimRoom && _dimRoom.id === room.id) { _showDimensions(room, group.x(), group.y()); } notifyFlutter({ type: 'roomsUpdated', rooms, }); roomsLayer.batchDraw(); } // --------------------------------------------------------------------------- // Sensor rendering // --------------------------------------------------------------------------- function renderSensors() { sensorsLayer.destroyChildren(); sensors.forEach(sensor => { if (sensor.room_id == null || sensor.floor_x == null) return; const room = rooms.find(r => r.id === sensor.room_id); if (!room) return; const absX = (room.x + sensor.floor_x) * PPM; const absY = (room.y + sensor.floor_y) * PPM; const label = sensor.name ?? sensor.sensor_id; const group = new Konva.Group({ id: `sensor-${sensor.id}`, x: absX, y: absY, draggable: mode === 'edit', }); group.add(new Konva.Circle({ radius: 8, fill: '#1565C0', stroke: '#ffffff', strokeWidth: 2, })); // Highlight ring — shown by highlightSensor(). group.add(new Konva.Circle({ name: 'highlight-ring', radius: 14, stroke: '#FDD835', strokeWidth: 3, visible: false, listening: false, })); group.add(new Konva.Text({ text: label, fontSize: 10, fill: '#424242', y: 11, offsetX: 30, width: 60, align: 'center', listening: false, })); let pressTimer = null; group.on('mousedown touchstart', () => { pressTimer = setTimeout(() => { notifyFlutter({ type: 'sensorTapped', id: String(sensor.id) }); pressTimer = null; }, 500); }); group.on('mouseup touchend touchcancel dragstart', () => { clearTimeout(pressTimer); pressTimer = null; }); group.on('dragend', () => _onSensorDragEnd(sensor, group, room)); sensorsLayer.add(group); }); sensorsLayer.batchDraw(); } function _onSensorDragEnd(sensor, group, originalRoom) { const newAbsX = group.x() / PPM; const newAbsY = group.y() / PPM; const target = rooms.find(r => newAbsX >= r.x && newAbsX <= r.x + r.width && newAbsY >= r.y && newAbsY <= r.y + r.height, ) ?? originalRoom; notifyFlutter({ type: 'sensorMoved', id: String(sensor.id), roomId: target.id, x: newAbsX - target.x, y: newAbsY - target.y, }); } // --------------------------------------------------------------------------- // Tag rendering (chips grouped by room) // --------------------------------------------------------------------------- function renderTags(tags) { tagsLayer.destroyChildren(); const byRoom = {}; tags.forEach(t => { if (t.roomId != null) (byRoom[t.roomId] ??= []).push(t); }); Object.entries(byRoom).forEach(([roomId, roomTags]) => { const room = rooms.find(r => r.id === Number(roomId)); if (!room) return; const chipW = 64, chipH = 22, chipGap = 4; const totalW = roomTags.length * chipW + (roomTags.length - 1) * chipGap; const cx = (room.x + room.width / 2) * PPM; const cy = (room.y + room.height / 2) * PPM; roomTags.forEach((tag, i) => { const gx = cx - totalW / 2 + i * (chipW + chipGap); const g = new Konva.Group({ x: gx, y: cy - chipH / 2, listening: false }); g.add(new Konva.Rect({ width: chipW, height: chipH, cornerRadius: 4, fill: tag.color || '#546E7A' })); g.add(new Konva.Text({ width: chipW, height: chipH, text: tag.name, fontSize: 10, fill: '#ffffff', align: 'center', verticalAlign: 'middle', })); tagsLayer.add(g); }); }); tagsLayer.batchDraw(); } // --------------------------------------------------------------------------- // Fit stage to show all rooms // --------------------------------------------------------------------------- function _fitToRooms() { if (rooms.length === 0) return; const maxX = Math.max(...rooms.map(r => r.x + r.width)); const maxY = Math.max(...rooms.map(r => r.y + r.height)); const pad = 60; // px padding const scale = Math.min( (stage.width() - pad * 2) / (maxX * PPM), (stage.height() - pad * 2) / (maxY * PPM), 2, ); stage.scale({ x: scale, y: scale }); stage.position({ x: (stage.width() - maxX * PPM * scale) / 2, y: (stage.height() - maxY * PPM * scale) / 2, }); } // Dismiss dimensions when tapping the stage background. stage.on('click tap', (e) => { if (e.target === stage) { _clearDimensions(); notifyFlutter({ type: 'selectionCleared' }); } }); // --------------------------------------------------------------------------- // Flutter → JS bridge // --------------------------------------------------------------------------- function notifyFlutter(msg) { if (window.FlutterBridge) FlutterBridge.postMessage(JSON.stringify(msg)); } // --------------------------------------------------------------------------- // Public API — called from Flutter via runJavaScript() // --------------------------------------------------------------------------- window.companion = { loadFloorPlan(roomsData) { rooms = roomsData; renderRooms(); renderSensors(); if (!_fittedOnce && rooms.length > 0) { _fitToRooms(); _fittedOnce = true; } _clearDimensions(); }, loadSensors(sensorsData) { sensors = sensorsData; renderSensors(); }, updateTags(tags) { renderTags(tags); }, updateCloud(particles) { particlesLayer.destroyChildren(); particles.forEach(({ x, y, weight }) => { particlesLayer.add(new Konva.Circle({ x: x * PPM, y: y * PPM, radius: 2, fill: `rgba(255,152,0,${Math.max(0, Math.min(1, weight))})`, listening: false, })); }); particlesLayer.batchDraw(); }, setMode(newMode) { mode = newMode; const edit = newMode === 'edit'; roomsLayer.find('Group').forEach(g => g.draggable(edit)); sensorsLayer.find('Group').forEach(g => g.draggable(edit)); tagsLayer.visible(!edit); particlesLayer.visible(!edit); _clearDimensions(); stage.batchDraw(); }, highlightSensor(id) { sensorsLayer.find('.highlight-ring').forEach(r => r.visible(false)); if (id) { const group = sensorsLayer.findOne(`#sensor-${id}`); if (group) { group.findOne('.highlight-ring')?.visible(true); const s = stage.scaleX(); stage.position({ x: stage.width() / 2 - group.x() * s, y: stage.height() / 2 - group.y() * s, }); } } sensorsLayer.batchDraw(); }, addRoom() { const s = stage.scaleX(); const cx = (stage.width() / 2 - stage.x()) / s / PPM; const cy = (stage.height() / 2 - stage.y()) / s / PPM; notifyFlutter({ type: 'roomAdded', x: cx, y: cy }); }, fitToRooms() { _fitToRooms(); }, getPositionAtCenter() { const s = stage.scaleX(); const cx = (stage.width() / 2 - stage.x()) / s / PPM; const cy = (stage.height() / 2 - stage.y()) / s / PPM; let result = { type: 'positionAtCenter', roomId: null }; for (const room of rooms) { if (cx >= room.x && cx <= room.x + room.width && cy >= room.y && cy <= room.y + room.height) { result = { type: 'positionAtCenter', roomId: room.id, x: cx - room.x, y: cy - room.y, }; break; } } FlutterBridge.postMessage(JSON.stringify(result)); }, };