567 lines
17 KiB
JavaScript
567 lines
17 KiB
JavaScript
/* 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());
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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); }
|
|
});
|
|
}
|
|
|
|
// 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));
|
|
},
|
|
};
|