/* * floorplan-3d — the drawn floorplan, extruded, with who is in each room. * * The same `GET /floorplan/presence` payload the Pebble watchapp renders, at the * fidelity a real screen allows. If the two ever disagree, one of them is lying. * * WHY THIS IS CANVAS 2D AND NOT THREE.JS * --------------------------------------- * docs/endpoint-surfaces.md planned to vendor three.js and called it a deliberate break * with the dependency-free rule. Building it made the cheaper answer obvious, so this * takes it instead: * * - The scene is prisms standing on a plane. There is no camera motion beyond an * orbit, no lighting model worth the name, no textures, no physics. An isometric * projection with painter's-algorithm sorting produces exactly that picture in * ~200 lines of canvas 2D. * - The door panel and kitchen panel are small machines. Canvas 2D redraws this in * under a millisecond; a WebGL context on integrated graphics is a much less * predictable proposition, and "the floorplan is smooth on the TV and juddery on * the panel" is a bad outcome for a view whose whole job is a glance. * - A megabyte of vendored library is a megabyte to keep patched, and it would be the * only dependency in the entire frontend. * * If this ever grows real lighting or a model import, three.js becomes the right answer * and this file becomes the fallback. Until then it is not a compromise, it is the * smaller correct tool. * * WHAT IT INHERITS FROM THE WATCHAPP, DELIBERATELY * ------------------------------------------------- * - Occupied rooms lit, empty rooms dark, and a THIRD state for rooms HA never * reports on. Rendering "no data" as "empty" is a quiet lie on any screen. * - Occupants as their colour + initial, upgraded to their photo where there is room. * The colour ring stays even with a photo: it is what ties this marker to the same * person on the watch and in the admin panel. * - `unplaced` people get a visible shelf, not a hidden list. They are who you are * most often looking for. */ "use strict"; (function (global) { const DEG = Math.PI / 180; class Floorplan3D { /** * @param {HTMLCanvasElement} canvas * @param {object} options { wallHeight, pitch, yaw, photoUrl } * photoUrl(personId) -> a URL for that person's picture, or null. */ constructor(canvas, options) { this.canvas = canvas; this.ctx = canvas.getContext("2d"); this.options = Object.assign( { wallHeight: 0.16, pitch: 34, yaw: 30, photoUrl: null }, options || {} ); this.level = null; this.unplaced = []; this.photos = new Map(); this.hover = null; this._resize(); window.addEventListener("resize", () => this._resize()); this._bindOrbit(); } _resize() { const rect = this.canvas.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); this.canvas.width = Math.max(1, Math.round(rect.width * dpr)); this.canvas.height = Math.max(1, Math.round(rect.height * dpr)); this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.width = rect.width; this.height = rect.height; this.draw(); } /** * Drag to orbit. Deliberately yaw-only with a clamped pitch, and NO free-fly: an * unconstrained camera on a wall panel is something people knock askew and cannot * get back, and there is no keyboard next to it to reset with. */ _bindOrbit() { let dragging = false; let lastX = 0; let lastY = 0; const start = (x, y) => { dragging = true; lastX = x; lastY = y; }; const move = (x, y) => { if (!dragging) return; this.options.yaw += (x - lastX) * 0.4; this.options.pitch = Math.max(12, Math.min(70, this.options.pitch - (y - lastY) * 0.25)); lastX = x; lastY = y; this.draw(); }; const end = () => { dragging = false; }; this.canvas.addEventListener("pointerdown", (e) => start(e.clientX, e.clientY)); this.canvas.addEventListener("pointermove", (e) => move(e.clientX, e.clientY)); this.canvas.addEventListener("pointerup", end); this.canvas.addEventListener("pointerleave", end); } /** Feed it a level out of GET /floorplan/presence, plus the payload's `unplaced`. */ setLevel(level, unplaced) { this.level = level; this.unplaced = unplaced || []; if (this.options.photoUrl) this._preloadPhotos(); this.draw(); } _preloadPhotos() { const people = []; for (const room of (this.level && this.level.rooms) || []) { for (const person of room.occupants || []) people.push(person); } people.push(...this.unplaced); for (const person of people) { if (!person.has_photo || this.photos.has(person.id)) continue; const url = this.options.photoUrl(person.id); if (!url) continue; const image = new Image(); image.crossOrigin = "anonymous"; image.onload = () => { this.photos.set(person.id, image); this.draw(); }; // A missing photo is not an error: the initial-on-colour fallback is the // designed rendering, not a degraded one. image.onerror = () => this.photos.set(person.id, null); image.src = url; this.photos.set(person.id, undefined); // in flight } } /** Normalised plan coords (0..1, plus a height) -> screen pixels. */ _project(x, y, z) { const yaw = this.options.yaw * DEG; const pitch = this.options.pitch * DEG; // Centre the plan on the origin so orbiting rotates the house rather than // swinging it out of frame. const cx = x - 0.5; const cy = y - 0.5; const rx = cx * Math.cos(yaw) - cy * Math.sin(yaw); const ry = cx * Math.sin(yaw) + cy * Math.cos(yaw); const scale = Math.min(this.width, this.height) * 0.78; return { x: this.width / 2 + rx * scale, y: this.height / 2 + ry * scale * Math.sin(pitch) - (z || 0) * scale * Math.cos(pitch), }; } /** Depth key for painter's-algorithm sorting: further from the camera draws first. */ _depth(points) { const yaw = this.options.yaw * DEG; let sum = 0; for (const [x, y] of points) { sum += (x - 0.5) * Math.sin(yaw) + (y - 0.5) * Math.cos(yaw); } return sum / points.length; } _roomState(room) { // THREE states, never two. A room the plan has drawn but whose area HA never // reports is neither occupied nor confirmed-empty, and drawing it as empty is a // quiet lie — "nobody is in the study" and "nothing can see the study" are // different sentences. // // A radar target counts as occupied even with nobody named. Somebody IS in that // room; that the house cannot say who is a fact about the house, not about the // room being empty — and a guest, or a household member whose phone is flat, is // exactly the case where lighting the room matters most. if ((room.occupants || []).length || (room.targets || []).length) return "occupied"; if (!room.ha_area_id) return "unknown"; return "empty"; } draw() { const { ctx } = this; ctx.clearRect(0, 0, this.width, this.height); if (!this.level || !(this.level.rooms || []).length) { ctx.fillStyle = "rgba(234,220,255,0.5)"; ctx.font = "16px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.fillText("No rooms drawn on this level yet.", this.width / 2, this.height / 2); return; } const height = this.options.wallHeight; const rooms = (this.level.rooms || []) .map((room) => { let points = []; try { points = typeof room.points === "string" ? JSON.parse(room.points) : room.points || []; } catch (err) { points = []; } return { room, points, depth: this._depth(points.length ? points : [[0.5, 0.5]]) }; }) .filter((entry) => entry.points.length >= 3) .sort((a, b) => a.depth - b.depth); for (const { room, points } of rooms) { const state = this._roomState(room); this._drawWalls(points, height, state); this._drawFloor(points, height, state, room); } // Markers last and in the same order, so a marker is never hidden behind a wall // drawn after it. for (const { room, points } of rooms) { this._drawOccupants(room, points, height); } this._drawUnplacedShelf(); } _drawWalls(points, height, state) { const { ctx } = this; // Each wall quad gets its own depth so the far ones do not paint over the near. const walls = []; for (let i = 0; i < points.length; i++) { const a = points[i]; const b = points[(i + 1) % points.length]; walls.push({ a, b, depth: this._depth([a, b]) }); } walls.sort((p, q) => p.depth - q.depth); for (const { a, b } of walls) { const p1 = this._project(a[0], a[1], 0); const p2 = this._project(b[0], b[1], 0); const p3 = this._project(b[0], b[1], height); const p4 = this._project(a[0], a[1], height); ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.lineTo(p3.x, p3.y); ctx.lineTo(p4.x, p4.y); ctx.closePath(); ctx.fillStyle = state === "occupied" ? "rgba(210,190,240,0.30)" : "rgba(150,130,190,0.13)"; ctx.fill(); ctx.strokeStyle = state === "occupied" ? "rgba(255,255,255,0.55)" : "rgba(160,140,200,0.28)"; ctx.lineWidth = 1; ctx.stroke(); } } _drawFloor(points, height, state, room) { const { ctx } = this; ctx.beginPath(); points.forEach(([x, y], i) => { const p = this._project(x, y, height); if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); }); ctx.closePath(); // Occupancy reads in LIGHTNESS, not hue — the occupant dots already use colour to // mean *who*, and making the room fill compete on the same channel is how both // stop being readable. if (state === "occupied") ctx.fillStyle = "rgba(255,252,245,0.90)"; else ctx.fillStyle = "rgba(28,18,44,0.85)"; ctx.fill(); ctx.strokeStyle = state === "occupied" ? "rgba(255,255,255,0.9)" : "rgba(160,140,200,0.45)"; ctx.lineWidth = state === "occupied" ? 2 : 1; // The third state is drawn dashed: one extra call, and it is the difference // between "empty" and "nothing can see this room". ctx.setLineDash(state === "unknown" ? [5, 4] : []); ctx.stroke(); ctx.setLineDash([]); const centre = this._centroid(points, height); ctx.fillStyle = state === "occupied" ? "rgba(30,18,45,0.75)" : "rgba(200,180,235,0.45)"; ctx.font = "600 11px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.fillText(room.name || "", centre.x, centre.y + 4); } _centroid(points, z) { let x = 0; let y = 0; for (const [px, py] of points) { x += px; y += py; } return this._project(x / points.length, y / points.length, z); } _drawOccupants(room, points, height) { const occupants = room.occupants || []; const targets = room.targets || []; const radius = 17; // ANONYMOUS TARGETS FIRST. A radar sees a moving blob, not a name. These are // drawn where they actually are and left unlabelled, because identity comes from // BLE (room-level) and putting a name on the wrong blob is the failure that makes // a presence display worse than none. for (const target of targets) { this._drawAnonymousTarget(target, height); } if (!occupants.length) return; // Anyone the server could fuse to a single target is drawn AT that position. // Everyone else falls back to the room centroid, which is the room-level answer // this view has always given. const positioned = occupants.filter((p) => p.position); const unpositioned = occupants.filter((p) => !p.position); for (const person of positioned) { const p = this._project(person.position.x, person.position.y, height); this._drawFootprint(p); this._drawMarker(person, p.x, p.y - radius - 12, radius); } if (!unpositioned.length) return; const centre = this._centroid(points, height); const spread = Math.min(unpositioned.length - 1, 3) * (radius + 4); unpositioned.slice(0, 4).forEach((person, index) => { const x = centre.x - spread / 2 + index * (radius + 4); // Floated above the floor so the marker reads as standing in the room rather // than as painted on it — a flat sprite on the floor is unreadable at a glance, // which is the only way this view is ever used. this._drawMarker(person, x, centre.y - radius - 14, radius); }); if (unpositioned.length > 4) { const { ctx } = this; ctx.fillStyle = "rgba(30,18,45,0.8)"; ctx.font = "600 11px system-ui, sans-serif"; ctx.fillText(`+${unpositioned.length - 4}`, centre.x + spread / 2 + radius, centre.y - radius - 10); } } /** * A dot on the floor under a positioned marker, so the eye can tell WHERE the * person is from where the disc merely floats. Without it a marker lifted 30px off * the floor reads as being 30px further back in the room. */ _drawFootprint(p) { const { ctx } = this; ctx.beginPath(); ctx.ellipse(p.x, p.y, 7, 3.5, 0, 0, Math.PI * 2); ctx.fillStyle = "rgba(30,18,45,0.35)"; ctx.fill(); ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x, p.y - 12); ctx.strokeStyle = "rgba(30,18,45,0.35)"; ctx.lineWidth = 1; ctx.stroke(); } /** * Somebody is here, and the system does not know who. Drawn as a hollow puck — no * initial, no colour — because every visual language this view has for a PERSON is * reserved for people it can actually name. */ _drawAnonymousTarget(target, height) { const { ctx } = this; const p = this._project(target.x, target.y, height); this._drawFootprint(p); ctx.beginPath(); ctx.arc(p.x, p.y - 20, 11, 0, Math.PI * 2); ctx.fillStyle = "rgba(120,100,160,0.35)"; ctx.fill(); ctx.strokeStyle = "rgba(200,180,235,0.75)"; ctx.lineWidth = 2; ctx.setLineDash([3, 3]); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = "rgba(30,18,45,0.8)"; ctx.font = "700 12px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.fillText("?", p.x, p.y - 16); } _drawMarker(person, x, y, radius) { const { ctx } = this; const colour = person.color || "#c084fc"; const photo = this.photos.get(person.id); ctx.save(); ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.closePath(); if (photo) { ctx.clip(); ctx.drawImage(photo, x - radius, y - radius, radius * 2, radius * 2); ctx.restore(); } else { ctx.fillStyle = colour; ctx.fill(); ctx.restore(); // Dark glyph on the person's colour — every value in identity's palette is // mid-to-bright, so a white letter would vanish on the amber. ctx.fillStyle = "#101014"; ctx.font = `700 ${Math.round(radius)}px system-ui, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(person.initial || "?", x, y + 1); ctx.textBaseline = "alphabetic"; } // The ring stays even behind a photo: it is what ties this marker to the same // person's marker on the watch and in the admin panel. ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.strokeStyle = colour; ctx.lineWidth = 3; ctx.stroke(); } /** * Everyone who is home but not locatable, along the bottom. A shelf rather than a * hidden list because these are the people you are most often looking for — the * whole reason somebody walks up to this screen. */ _drawUnplacedShelf() { if (!this.unplaced.length) return; const { ctx } = this; const radius = 14; const y = this.height - radius - 10; let x = radius + 12; ctx.fillStyle = "rgba(234,220,255,0.55)"; ctx.font = "12px system-ui, sans-serif"; ctx.textAlign = "left"; ctx.fillText("Home, room unknown:", 12, y - radius - 6); for (const person of this.unplaced.slice(0, 8)) { this._drawMarker(person, x, y, radius); x += radius * 2 + 8; } } } global.Floorplan3D = Floorplan3D; })(typeof window !== "undefined" ? window : globalThis);