SmartestHome/render/floorplan-3d/floorplan3d.js

378 lines
14 KiB
JavaScript

/*
* 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.
if ((room.occupants || []).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 || [];
if (!occupants.length) return;
const centre = this._centroid(points, height);
const radius = 17;
const spread = Math.min(occupants.length - 1, 3) * (radius + 4);
occupants.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 (occupants.length > 4) {
const { ctx } = this;
ctx.fillStyle = "rgba(30,18,45,0.8)";
ctx.font = "600 11px system-ui, sans-serif";
ctx.fillText(`+${occupants.length - 4}`, centre.x + spread / 2 + radius, centre.y - radius - 10);
}
}
_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);