feat: implement a more complete floor plan webview impl
This commit is contained in:
@@ -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`
|
||||
<div style=${{
|
||||
position: 'absolute',
|
||||
left: `${x + 14}px`,
|
||||
top: `${y - 48}px`,
|
||||
background: '#ffffff',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
pointerEvents: 'auto',
|
||||
minWidth: '130px',
|
||||
fontFamily: 'Roboto, sans-serif',
|
||||
}}>
|
||||
<div style=${{ fontSize: '13px', fontWeight: '500', color: '#212121', marginBottom: '6px' }}>
|
||||
${name}
|
||||
</div>
|
||||
<div style=${{ display: 'flex', gap: '10px' }}>
|
||||
<button
|
||||
style=${{ fontSize: '12px', border: 'none', background: 'none', color: '#1565C0', cursor: 'pointer', padding: 0 }}
|
||||
onClick=${() => { notifyFlutter({ type: 'sensorTapped', id }); onClose(); }}>
|
||||
View details
|
||||
</button>
|
||||
<button
|
||||
style=${{ fontSize: '12px', border: 'none', background: 'none', color: '#9e9e9e', cursor: 'pointer', padding: 0 }}
|
||||
onClick=${onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [tooltip, setTip] = useState(null);
|
||||
_setTooltip = setTip;
|
||||
return tooltip
|
||||
? html`<${Tooltip} ...${tooltip} onClose=${() => setTip(null)} />`
|
||||
: null;
|
||||
}
|
||||
|
||||
render(html`<${App} />`, document.getElementById('overlay'));
|
||||
|
||||
// Dismiss tooltip and dimensions when tapping the stage background.
|
||||
stage.on('click tap', (e) => {
|
||||
if (e.target === stage) {
|
||||
_setTooltip(null);
|
||||
_clearDimensions();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
_setTooltip(null);
|
||||
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();
|
||||
},
|
||||
};
|
||||
+11
-183
@@ -2,193 +2,21 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #111827; overflow: hidden; }
|
||||
#container { width: 100vw; height: 100vh; }
|
||||
body { overflow: hidden; background: #f5f5f5; }
|
||||
#canvas-container { position: fixed; inset: 0; }
|
||||
#overlay { position: fixed; inset: 0; pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container"></div>
|
||||
|
||||
<!--
|
||||
For production: download konva.min.js and place it alongside this file
|
||||
so it loads offline. Get it from: https://konvajs.org/
|
||||
-->
|
||||
<script src="https://unpkg.com/konva@9/konva.min.js"></script>
|
||||
|
||||
<script>
|
||||
const stage = new Konva.Stage({
|
||||
container: 'container',
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
|
||||
// Layer order (bottom → top)
|
||||
const roomsLayer = new Konva.Layer(); // static floor plan geometry
|
||||
const sensorsLayer = new Konva.Layer(); // sensor icons
|
||||
const tagsLayer = new Konva.Layer(); // live tag dots
|
||||
const particlesLayer = new Konva.Layer(); // particle cloud
|
||||
|
||||
stage.add(roomsLayer, sensorsLayer, tagsLayer, particlesLayer);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
stage.width(window.innerWidth);
|
||||
stage.height(window.innerHeight);
|
||||
stage.batchDraw();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// companion API — called by Flutter via WebViewController.runJavaScript()
|
||||
// -----------------------------------------------------------------------
|
||||
window.companion = {
|
||||
|
||||
// positions: [{tagId, x, y}] (x, y normalised 0..1)
|
||||
updateTags(payload) {
|
||||
const positions = JSON.parse(payload);
|
||||
tagsLayer.destroyChildren();
|
||||
positions.forEach(({ tagId, x, y }) => {
|
||||
const dot = new Konva.Circle({
|
||||
x: x * stage.width(),
|
||||
y: y * stage.height(),
|
||||
radius: 8,
|
||||
fill: '#00d4ff',
|
||||
stroke: '#ffffff',
|
||||
strokeWidth: 1.5,
|
||||
});
|
||||
tagsLayer.add(dot);
|
||||
});
|
||||
tagsLayer.batchDraw();
|
||||
},
|
||||
|
||||
// particles: [{x, y, weight}] (x, y normalised 0..1, weight ≥ 0)
|
||||
updateCloud(payload) {
|
||||
const particles = JSON.parse(payload);
|
||||
particlesLayer.destroyChildren();
|
||||
particles.forEach(({ x, y, weight }) => {
|
||||
particlesLayer.add(new Konva.Circle({
|
||||
x: x * stage.width(),
|
||||
y: y * stage.height(),
|
||||
radius: 2,
|
||||
fill: `rgba(255, 180, 0, ${Math.min(weight, 1)})`,
|
||||
listening: false,
|
||||
}));
|
||||
});
|
||||
particlesLayer.batchDraw();
|
||||
},
|
||||
|
||||
// mode: 'view' | 'edit'
|
||||
setMode(mode) {
|
||||
sensorsLayer.find('Group').forEach(group => {
|
||||
group.draggable(mode === 'edit');
|
||||
});
|
||||
particlesLayer.visible(mode !== 'edit');
|
||||
stage.batchDraw();
|
||||
},
|
||||
|
||||
// Pan/zoom to a sensor and add a highlight ring. Pass null to clear.
|
||||
highlightSensor(sensorId) {
|
||||
sensorsLayer.find('.highlight').forEach(n => n.destroy());
|
||||
if (!sensorId) { sensorsLayer.batchDraw(); return; }
|
||||
const group = sensorsLayer.findOne(`#${sensorId}`);
|
||||
if (!group) return;
|
||||
const ring = new Konva.Circle({
|
||||
x: group.x(),
|
||||
y: group.y(),
|
||||
radius: 22,
|
||||
stroke: '#facc15',
|
||||
strokeWidth: 3,
|
||||
name: 'highlight',
|
||||
listening: false,
|
||||
});
|
||||
sensorsLayer.add(ring);
|
||||
sensorsLayer.batchDraw();
|
||||
// TODO: animate stage position to centre on sensor.
|
||||
},
|
||||
|
||||
// plan: the JSON representation of a FloorPlan (rooms with polygon vertices)
|
||||
loadFloorPlan(payload) {
|
||||
const plan = JSON.parse(payload);
|
||||
roomsLayer.destroyChildren();
|
||||
(plan.rooms || []).forEach(room => {
|
||||
if (!room.polygon || room.polygon.length < 2) return;
|
||||
const points = room.polygon.flatMap(p => [
|
||||
p.x * stage.width(),
|
||||
p.y * stage.height(),
|
||||
]);
|
||||
const poly = new Konva.Line({
|
||||
points,
|
||||
fill: '#1e293b',
|
||||
stroke: '#475569',
|
||||
strokeWidth: 2,
|
||||
closed: true,
|
||||
});
|
||||
const label = new Konva.Text({
|
||||
x: points[0],
|
||||
y: points[1],
|
||||
text: room.name,
|
||||
fill: '#94a3b8',
|
||||
fontSize: 12,
|
||||
listening: false,
|
||||
});
|
||||
roomsLayer.add(poly, label);
|
||||
});
|
||||
roomsLayer.batchDraw();
|
||||
},
|
||||
|
||||
// sensors: [{id, name, x, y}]
|
||||
loadSensors(payload) {
|
||||
const sensors = JSON.parse(payload);
|
||||
sensorsLayer.destroyChildren();
|
||||
sensors.forEach(({ id, name, x, y }) => {
|
||||
const px = x * stage.width();
|
||||
const py = y * stage.height();
|
||||
|
||||
const group = new Konva.Group({ id, x: px, y: py, draggable: false });
|
||||
|
||||
group.add(new Konva.Circle({
|
||||
radius: 14,
|
||||
fill: '#4f46e5',
|
||||
stroke: '#818cf8',
|
||||
strokeWidth: 2,
|
||||
}));
|
||||
group.add(new Konva.Text({
|
||||
text: name,
|
||||
fontSize: 10,
|
||||
fill: '#e2e8f0',
|
||||
offsetX: 20,
|
||||
y: 18,
|
||||
width: 40,
|
||||
align: 'center',
|
||||
listening: false,
|
||||
}));
|
||||
|
||||
group.on('click tap', () => {
|
||||
notifyFlutter('sensorTapped', { id });
|
||||
});
|
||||
group.on('dragend', () => {
|
||||
notifyFlutter('sensorMoved', {
|
||||
id,
|
||||
x: group.x() / stage.width(),
|
||||
y: group.y() / stage.height(),
|
||||
});
|
||||
});
|
||||
|
||||
sensorsLayer.add(group);
|
||||
});
|
||||
sensorsLayer.batchDraw();
|
||||
},
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Dart ← JS bridge
|
||||
// -----------------------------------------------------------------------
|
||||
function notifyFlutter(type, payload) {
|
||||
if (window.FlutterBridge) {
|
||||
FlutterBridge.postMessage(JSON.stringify({ type, ...payload }));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<div id="canvas-container"></div>
|
||||
<div id="overlay"></div>
|
||||
<script src="./vendor/konva.min.js"></script>
|
||||
<script src="./vendor/preact.min.umd.js"></script>
|
||||
<script src="./vendor/hooks.umd.js"></script>
|
||||
<script src="./vendor/htm.umd.js"></script>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
!function(n,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports,require("preact")):"function"==typeof define&&define.amd?define(["exports","preact"],t):t((n||self).preactHooks={},n.preact)}(this,function(n,t){var u,r,i,o,f=0,c=[],e=t.options,a=e.__b,v=e.__r,l=e.diffed,d=e.__c,s=e.unmount,p=e.__;function y(n,t){e.__h&&e.__h(r,n,f||t),f=0;var u=r.__H||(r.__H={__:[],__h:[]});return n>=u.__.length&&u.__.push({}),u.__[n]}function h(n){return f=1,m(j,n)}function m(n,t,i){var o=y(u++,2);if(o.t=n,!o.__c&&(o.__=[i?i(t):j(void 0,t),function(n){var t=o.__N?o.__N[0]:o.__[0],u=o.t(t,n);t!==u&&(o.__N=[u,o.__[1]],o.__c.setState({}))}],o.__c=r,!r.__f)){var f=function(n,t,u){if(!o.__c.__H)return!0;var r=o.__c.__H.__.filter(function(n){return n.__c});if(r.every(function(n){return!n.__N}))return!c||c.call(this,n,t,u);var i=o.__c.props!==n;return r.some(function(n){if(n.__N){var t=n.__[0];n.__=n.__N,n.__N=void 0,t!==n.__[0]&&(i=!0)}}),c&&c.call(this,n,t,u)||i};r.__f=!0;var c=r.shouldComponentUpdate,e=r.componentWillUpdate;r.componentWillUpdate=function(n,t,u){if(this.__e){var r=c;c=void 0,f(n,t,u),c=r}e&&e.call(this,n,t,u)},r.shouldComponentUpdate=f}return o.__N||o.__}function T(n,t){var i=y(u++,4);!e.__s&&g(i.__H,t)&&(i.__=n,i.u=t,r.__h.push(i))}function _(n,t){var r=y(u++,7);return g(r.__H,t)&&(r.__=n(),r.__H=t,r.__h=n),r.__}function b(){for(var n;n=c.shift();){var t=n.__H;if(n.__P&&t)try{t.__h.some(A),t.__h.some(F),t.__h=[]}catch(u){t.__h=[],e.__e(u,n.__v)}}}e.__b=function(n){r=null,a&&a(n)},e.__=function(n,t){n&&t.__k&&t.__k.__m&&(n.__m=t.__k.__m),p&&p(n,t)},e.__r=function(n){v&&v(n),u=0;var t=(r=n.__c).__H;t&&(i===r?(t.__h=[],r.__h=[],t.__.some(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.some(A),t.__h.some(F),t.__h=[],u=0)),i=r},e.diffed=function(n){l&&l(n);var t=n.__c;t&&t.__H&&(t.__H.__h.length&&(1!==c.push(t)&&o===e.requestAnimationFrame||((o=e.requestAnimationFrame)||x)(b)),t.__H.__.some(function(n){n.u&&(n.__H=n.u),n.u=void 0})),i=r=null},e.__c=function(n,t){t.some(function(n){try{n.__h.some(A),n.__h=n.__h.filter(function(n){return!n.__||F(n)})}catch(u){t.some(function(n){n.__h&&(n.__h=[])}),t=[],e.__e(u,n.__v)}}),d&&d(n,t)},e.unmount=function(n){s&&s(n);var t,u=n.__c;u&&u.__H&&(u.__H.__.some(function(n){try{A(n)}catch(n){t=n}}),u.__H=void 0,t&&e.__e(t,u.__v))};var q="function"==typeof requestAnimationFrame;function x(n){var t,u=function(){clearTimeout(r),q&&cancelAnimationFrame(t),setTimeout(n)},r=setTimeout(u,35);q&&(t=requestAnimationFrame(u))}function A(n){var t=r,u=n.__c;"function"==typeof u&&(n.__c=void 0,u()),r=t}function F(n){var t=r;n.__c=n.__(),r=t}function g(n,t){return!n||n.length!==t.length||t.some(function(t,u){return t!==n[u]})}function j(n,t){return"function"==typeof t?t(n):t}n.useCallback=function(n,t){return f=8,_(function(){return n},t)},n.useContext=function(n){var t=r.context[n.__c],i=y(u++,9);return i.c=n,t?(null==i.__&&(i.__=!0,t.sub(r)),t.props.value):n.__},n.useDebugValue=function(n,t){e.useDebugValue&&e.useDebugValue(t?t(n):n)},n.useEffect=function(n,t){var i=y(u++,3);!e.__s&&g(i.__H,t)&&(i.__=n,i.u=t,r.__H.__h.push(i))},n.useErrorBoundary=function(n){var t=y(u++,10),i=h();return t.__=n,r.componentDidCatch||(r.componentDidCatch=function(n,u){t.__&&t.__(n,u),i[1](n)}),[i[0],function(){i[1](void 0)}]},n.useId=function(){var n=y(u++,11);if(!n.__){for(var t=r.__v;null!==t&&!t.__m&&null!==t.__;)t=t.__;var i=t.__m||(t.__m=[0,0]);n.__="P"+i[0]+"-"+i[1]++}return n.__},n.useImperativeHandle=function(n,t,u){f=6,T(function(){if("function"==typeof n){var u=n(t());return function(){n(null),u&&"function"==typeof u&&u()}}if(n)return n.current=t(),function(){return n.current=null}},null==u?u:u.concat(n))},n.useLayoutEffect=T,n.useMemo=_,n.useReducer=m,n.useRef=function(n){return f=5,_(function(){return{current:n}},[])},n.useState=h});
|
||||
//# sourceMappingURL=hooks.umd.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
!function(n,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):n.htm=e()}(this,function(){var n=function(e,t,u,s){var r;t[0]=0;for(var p=1;p<t.length;p++){var h=t[p++],o=t[p]?(t[0]|=h?1:2,u[t[p++]]):t[++p];3===h?s[0]=o:4===h?s[1]=Object.assign(s[1]||{},o):5===h?(s[1]=s[1]||{})[t[++p]]=o:6===h?s[1][t[++p]]+=o+"":h?(r=e.apply(o,n(e,o,u,["",null])),s.push(r),o[0]?t[0]|=2:(t[p-2]=0,t[p]=r)):s.push(o)}return s},e=new Map;return function(t){var u=e.get(this);return u||(u=new Map,e.set(this,u)),(u=n(this,u.get(t)||(u.set(t,u=function(n){for(var e,t,u=1,s="",r="",p=[0],h=function(n){1===u&&(n||(s=s.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?p.push(0,n,s):3===u&&(n||s)?(p.push(3,n,s),u=2):2===u&&"..."===s&&n?p.push(4,n,0):2===u&&s&&!n?p.push(5,0,!0,s):u>=5&&((s||!n&&5===u)&&(p.push(u,0,s,t),u=6),n&&(p.push(u,n,0,t),u=6)),s=""},o=0;o<n.length;o++){o&&(1===u&&h(),h(o));for(var f=0;f<n[o].length;f++)e=n[o][f],1===u?"<"===e?(h(),p=[p],u=3):s+=e:4===u?"--"===s&&">"===e?(u=1,s=""):s=e+s[0]:r?e===r?r="":s+=e:'"'===e||"'"===e?r=e:">"===e?(h(),u=1):u&&("="===e?(u=5,t=s,s=""):"/"===e&&(u<5||">"===n[o][f+1])?(h(),3===u&&(p=p[0]),u=p,(p=p[0]).push(2,0,u),u=0):" "===e||"\t"===e||"\n"===e||"\r"===e?(h(),u=2):s+=e),3===u&&"!--"===s&&(u=4,p=p[0])}return h(),p}(t)),u),arguments,[])).length>1?u:u[0]}});
|
||||
Vendored
+54
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
@@ -48,3 +48,4 @@ flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/konva/
|
||||
- assets/konva/vendor/
|
||||
|
||||
Reference in New Issue
Block a user