diff --git a/assets/konva/app.js b/assets/konva/app.js new file mode 100644 index 0000000..ef1e192 --- /dev/null +++ b/assets/konva/app.js @@ -0,0 +1,599 @@ +/* globals: Konva, preact, preactHooks, htm */ +const { h, render } = preact; +const { useState } = preactHooks; +const html = htm.bind(h); + +// --------------------------------------------------------------------------- +// 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; + +// Exposed so the Preact overlay can update its tooltip imperatively. +let _setTooltip = (_v) => {}; + +// --------------------------------------------------------------------------- +// 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()); + } + }); + + 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()); + } +}); + +// --------------------------------------------------------------------------- +// 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 + 4, y: ry + rh / 2 - 4, + text: `${room.height.toFixed(1)} m`, + fontSize: 9, fill: c, listening: false, + })); + } 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 - 22, y: ry + rh / 2 - 4, + text: `${room.height.toFixed(1)} m`, + fontSize: 9, fill: c, listening: false, + })); + } + + 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); } + }); + } + + // 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, + })); + + group.on('click tap', () => _onSensorTap(sensor, group, label)); + group.on('dragend', () => _onSensorDragEnd(sensor, group, room)); + sensorsLayer.add(group); + }); + sensorsLayer.batchDraw(); +} + +function _onSensorTap(sensor, group, label) { + if (mode === 'view') { + const scale = stage.scaleX(); + _setTooltip({ + id: String(sensor.id), + name: label, + x: group.x() * scale + stage.x(), + y: group.y() * scale + stage.y(), + }); + } else { + notifyFlutter({ type: 'sensorTapped', id: String(sensor.id) }); + } +} + +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, + }); +} + +// --------------------------------------------------------------------------- +// Preact overlay — sensor tooltip +// --------------------------------------------------------------------------- + +function Tooltip({ id, name, x, y, onClose }) { + return html` +