195 lines
6.0 KiB
HTML
195 lines
6.0 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { background: #111827; overflow: hidden; }
|
|
#container { width: 100vw; height: 100vh; }
|
|
</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>
|
|
</body>
|
|
</html>
|