Files
companion/assets/konva/app.js
T

666 lines
20 KiB
JavaScript

/* globals: Konva */
// ---------------------------------------------------------------------------
// Constants & mutable state
// ---------------------------------------------------------------------------
const PPM = 60; // pixels per meter
const BASE_LABEL_SIZE = 12;
const MAX_LABEL_SIZE = 24;
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 });
_updateRoomLabelSizes(newScale);
}
function _updateRoomLabelSizes(scale) {
const s = scale ?? stage.scaleX();
const fs = Math.min(MAX_LABEL_SIZE, Math.max(BASE_LABEL_SIZE, BASE_LABEL_SIZE / s));
roomsLayer.find('.room-label').forEach(t => t.fontSize(fs));
roomsLayer.batchDraw();
}
// ---------------------------------------------------------------------------
// 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: false,
});
// 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({
name: 'room-label',
width: rw, height: rh,
text: room.name,
fontSize: Math.min(MAX_LABEL_SIZE, Math.max(BASE_LABEL_SIZE, BASE_LABEL_SIZE / stage.scaleX())),
fill: 'rgba(0,0,0,0.45)',
align: 'center',
verticalAlign: 'middle',
letterSpacing: 0.8,
listening: false,
}));
group.on('click tap', () => {
if (mode === 'edit') {
roomsLayer.find('Group').forEach(g => g.draggable(false));
group.draggable(true);
_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;
roomsLayer.find('Group').forEach(g => g.draggable(false));
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() {
const activeIds = new Set(
sensors
.filter(s => s.room_id != null && s.floor_x != null)
.map(s => `sensor-${s.id}`)
);
// Remove groups for sensors that are now unplaced or gone.
sensorsLayer.find('Group').forEach(g => {
if (!activeIds.has(g.id())) g.destroy();
});
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 existing = sensorsLayer.findOne(`#sensor-${sensor.id}`);
if (existing) {
// Update in-place — avoids the destroy-rebuild flash on drag end.
if (!existing.isDragging()) existing.position({ x: absX, y: absY });
existing.draggable(mode === 'sensorMove');
existing.findOne('Text')?.text(label);
existing.findOne('.sensor-dot')?.fill(sensor.confirmed ? '#1565C0' : '#9E9E9E');
return;
}
_buildSensorGroup(sensor, absX, absY, label);
});
sensorsLayer.batchDraw();
}
function _buildSensorGroup(sensor, absX, absY, label) {
const group = new Konva.Group({
id: `sensor-${sensor.id}`,
x: absX, y: absY,
draggable: mode === 'sensorMove',
});
// Visible dot — hitFunc expands the touch target without changing appearance.
group.add(new Konva.Circle({
name: 'sensor-dot',
radius: 8,
fill: sensor.confirmed ? '#1565C0' : '#9E9E9E',
stroke: '#ffffff',
strokeWidth: 2,
hitFunc(ctx, shape) {
ctx.beginPath();
ctx.arc(0, 0, 20, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fillStrokeShape(shape);
},
}));
// 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', () => {
if (mode === 'view') {
notifyFlutter({ type: 'sensorTapped', id: String(sensor.id) });
}
});
let pressTimer = null;
group.on('mousedown touchstart', () => {
if (mode === 'view') return;
pressTimer = setTimeout(() => {
notifyFlutter({ type: 'sensorTapped', id: String(sensor.id) });
pressTimer = null;
}, 500);
});
group.on('mouseup touchend touchcancel dragstart', () => {
clearTimeout(pressTimer);
pressTimer = null;
});
// Look up the current sensor data at dragend so room_id is always fresh.
group.on('dragend', () => {
const s = sensors.find(ss => ss.id === sensor.id) ?? sensor;
const originalRoom = rooms.find(r => r.id === s.room_id) ?? rooms[0];
_onSensorDragEnd(s, group, originalRoom);
});
sensorsLayer.add(group);
}
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;
// Optimistically update the local sensors array so any renderSensors()
// call that arrives before Flutter confirms the new position via loadSensors
// doesn't snap the group back to the old coordinates.
sensor.room_id = target.id;
sensor.floor_x = newAbsX - target.x;
sensor.floor_y = newAbsY - target.y;
notifyFlutter({
type: 'sensorMoved',
id: String(sensor.id),
roomId: target.id,
x: sensor.floor_x,
y: sensor.floor_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();
if (particles.length > 0) {
const maxW = Math.max(...particles.map(p => p.weight));
particles.forEach(({ x, y, weight }) => {
particlesLayer.add(new Konva.Circle({
x: x * PPM, y: y * PPM,
radius: 2,
fill: `rgba(255,152,0,${maxW > 0 ? weight / maxW : 0})`,
listening: false,
}));
});
}
particlesLayer.batchDraw();
},
setMode(newMode) {
mode = newMode;
roomsLayer.find('Group').forEach(g => g.draggable(false));
sensorsLayer.find('Group').forEach(g => g.draggable(newMode === 'sensorMove'));
tagsLayer.visible(newMode === 'view');
particlesLayer.visible(newMode === 'view');
_clearDimensions();
stage.batchDraw();
},
setRepositioningSensor(id) {
sensorsLayer.find('Group').forEach(g => {
g.draggable(mode === 'sensorMove' && (id == null || g.id() === `sensor-${id}`));
});
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));
},
};