Exact positions inside a room, fused from radar and BLE
The 3D floorplan could say which room somebody was in. This makes it able to
say where in the room — from a different sensor, kept deliberately separate
from the one that knows who they are.
Room-level presence comes from BLE, which cannot give coordinates: RSSI-to-
distance is noisy enough that trilateration in a house lands in the wrong
room. Coordinates come from mmWave radar (LD2450-class), which tracks moving
targets and reports x/y — and cannot say who anybody is, because it sees a
moving blob.
So the two are fused, by a rule that refuses far more often than it commits:
exactly one occupant in the room AND exactly one target in the room
-> that target is that person
anything else
-> targets stay anonymous, occupants stay unpositioned
Two people in a room are two blobs that cannot be told apart. Guessing which
is which would put a name on the wrong person, and a display that does that
occasionally is worse than one that never tries — its wrong answers are
indistinguishable from its right ones. position_ambiguous says so out loud.
Which room a target is in is computed from the polygon rather than from which
sensor saw it: a radar in an open-plan kitchen sees into the living room, and
attributing by sensor would put people through walls.
identity
floorplan_levels gains metres_wide; without it positions are not computed
and the API reports that rather than guessing a scale. New floorplan_sensors
table holds where each radar sits on the plan and which way it faces —
drawn by a human, because a wrong rotation mirrors every target it reports
and the result looks plausible rather than broken. Targets at exactly (0,0)
are dropped: that is how these radars say "nothing here", and treating it
as a detection grows a phantom person on top of every sensor.
render/floorplan-3d
A fused person is drawn at their coordinate with a footprint dot, since a
marker floated above the floor otherwise reads as further back in the room.
An unattributed target is a hollow dashed puck with a question mark — no
colour, no initial, because every visual language here for a person is
reserved for people the system can name. A radar target lights the room even
with nobody named: somebody is in there, and that the house cannot say who
is a fact about the house.
Hardware: HLK-LD2450 added to components.md, ~EUR 15-25 per room, with the
advice to buy one and check its facing before buying more.
Position maths and the fusion rule are unit-tested headlessly. No radar has
been bought, mounted or read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FanS1vyE2gLhGkqKq6HtYj
main
parent
a948f4b375
commit
483cbce728
|
|
@ -309,6 +309,24 @@ use, and it is the one thing worth checking on the listing before buying.
|
||||||
after the first is a repeat of a solved problem; the first one is where you find out
|
after the first is a repeat of a solved problem; the first one is where you find out
|
||||||
whether the model was the right pick.
|
whether the model was the right pick.
|
||||||
|
|
||||||
|
#mmWave presence radar — need 1 to start, then 1 per room you care about
|
||||||
|
For **exact positions inside a room** on the 3D floorplan. BLE answers "which room";
|
||||||
|
this answers "where in it", and the two are fused in `identity` — see
|
||||||
|
`docs/endpoint-surfaces.md` for the rule and for why it is deliberately timid.
|
||||||
|
- [HLK-LD2450 24GHz mmWave radar module](https://www.amazon.com/Waveshare-Millimeter-Detection-Multi-Target-Trajectory/dp/B0CX5QNXH2)
|
||||||
|
(~€15–25 each) — tracks up to **three moving targets** and reports each one's x/y,
|
||||||
|
which is the specific capability that makes this worth doing at all. Native ESPHome
|
||||||
|
support (`ld2450`), so it rides the same firmware pipeline as the other ESP nodes and
|
||||||
|
needs an ESP32 per sensor (the RuView boards in this list are a separate job — do not
|
||||||
|
double up firmware on one chip).
|
||||||
|
- **It cannot tell you who.** A radar sees a moving blob. Identity still comes from BLE,
|
||||||
|
and with two people in one room neither blob gets a name — by design, because guessing
|
||||||
|
is worse than not knowing.
|
||||||
|
- **Buy one, mount it, and check the rotation before buying more.** Getting the facing
|
||||||
|
wrong mirrors every position it reports, and the failure looks plausible rather than
|
||||||
|
broken. One room proves the whole chain (sensor → ESPHome → HA → identity's fusion →
|
||||||
|
the 3D plan) for ~€20.
|
||||||
|
|
||||||
#Fixed BLE tags — need ~4 to start
|
#Fixed BLE tags — need ~4 to start
|
||||||
For household members who don't (or shouldn't have to) carry a phone for presence to
|
For household members who don't (or shouldn't have to) carry a phone for presence to
|
||||||
work — a grandmother without a smartphone is the concrete case, but this is also just
|
work — a grandmother without a smartphone is the concrete case, but this is also just
|
||||||
|
|
@ -395,14 +413,15 @@ local pricing/VAT/shipping.
|
||||||
| innr RB 285 C Zigbee RGB bulb | ~16 (TBD) | €15–20 | €240–320 |
|
| innr RB 285 C Zigbee RGB bulb | ~16 (TBD) | €15–20 | €240–320 |
|
||||||
| iTag-Tiny fixed BLE tag | 4 | €3–8 | €12–32 |
|
| iTag-Tiny fixed BLE tag | 4 | €3–8 | €12–32 |
|
||||||
| Espressif ESP32-S3-DevKitC-1-N8R2 (RuView) | 6 | €8–12 | €48–72 |
|
| Espressif ESP32-S3-DevKitC-1-N8R2 (RuView) | 6 | €8–12 | €48–72 |
|
||||||
|
| HLK-LD2450 mmWave radar *(buy 1 first — see that section)* | ~3 | €15–25 | €45–75 |
|
||||||
| Aqara Zigbee door contact sensor (appliance doors) | ~3 | €10–15 | €30–45 |
|
| Aqara Zigbee door contact sensor (appliance doors) | ~3 | €10–15 | €30–45 |
|
||||||
| Tapo C120 doorway camera *(buy 1 first — see that section)* | ~2 | €30–40 | €60–80 |
|
| Tapo C120 doorway camera *(buy 1 first — see that section)* | ~2 | €30–40 | €60–80 |
|
||||||
| Tapo C120 network camera (rooms/workshop view) | ~3 | €30–40 | €90–120 |
|
| Tapo C120 network camera (rooms/workshop view) | ~3 | €30–40 | €90–120 |
|
||||||
| Workshop monitor/TV *(reuse one first if you have it)* | 1 | €120–250 | €120–250 |
|
| Workshop monitor/TV *(reuse one first if you have it)* | 1 | €120–250 | €120–250 |
|
||||||
| USB inspection/macro camera (workshop) | 1 | €30–60 | €30–60 |
|
| USB inspection/macro camera (workshop) | 1 | €30–60 | €30–60 |
|
||||||
|
|
||||||
**Subtotal (excludes the optional beamer mount): ~€2,244–2,846**
|
**Subtotal (excludes the optional beamer mount): ~€2,289–2,921**
|
||||||
**Subtotal, including the optional beamer mount: ~€2,259–2,871**
|
**Subtotal, including the optional beamer mount: ~€2,304–2,946**
|
||||||
|
|
||||||
The workshop line items are the ones to sequence rather than buy at once: the macro
|
The workshop line items are the ones to sequence rather than buy at once: the macro
|
||||||
camera is cheap and decides whether the OCR-first identification works at all, the
|
camera is cheap and decides whether the OCR-first identification works at all, the
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,52 @@ a face — is gone:
|
||||||
showing everyone who is home but unlocatable. They are the people you are most often
|
showing everyone who is home but unlocatable. They are the people you are most often
|
||||||
looking for.
|
looking for.
|
||||||
|
|
||||||
|
### Exact positions within a room
|
||||||
|
|
||||||
|
Room-level presence answers *which room*. Exact positions need a sensor that reports
|
||||||
|
**coordinates**, and BLE is not that sensor: RSSI-to-distance is noisy enough that
|
||||||
|
trilateration in a house lands in the wrong room often enough to be useless.
|
||||||
|
|
||||||
|
The realistic source is **mmWave radar** (LD2450-class, ~€15–25 per room, ESPHome
|
||||||
|
native), which tracks up to three moving targets and publishes each one's x/y in
|
||||||
|
millimetres. What it cannot do is say **who** — it sees a moving blob.
|
||||||
|
|
||||||
|
So position and identity come from different sensors, and the whole design is in how
|
||||||
|
they are combined:
|
||||||
|
|
||||||
|
| Signal | Source | Gives |
|
||||||
|
|---|---|---|
|
||||||
|
| who | BLE / Bermuda | a person, resolved to a room |
|
||||||
|
| where | mmWave | a coordinate, with no name |
|
||||||
|
|
||||||
|
**The fusion rule is deliberately timid** (`floorplan_presence()` in `identity`):
|
||||||
|
|
||||||
|
> exactly one occupant in the room **and** exactly one target in the room → that target
|
||||||
|
> is that person. Anything else → the targets stay anonymous and the occupants stay
|
||||||
|
> unpositioned.
|
||||||
|
|
||||||
|
Two people in a room produce two blobs that cannot be told apart. Guessing which is
|
||||||
|
which would put a name on the wrong person, and a presence display that does that
|
||||||
|
occasionally is worse than one that never tries — you cannot tell the wrong answers
|
||||||
|
from the right ones. `position_ambiguous` says out loud when this has happened.
|
||||||
|
|
||||||
|
What the plan needs before any of it works, both drawn by a human because nothing can
|
||||||
|
infer them:
|
||||||
|
|
||||||
|
- **`metres_wide` per level** — the real-world width of the 0–1 extent. Without it a
|
||||||
|
reading in metres cannot become a point on the plan, and the API reports that rather
|
||||||
|
than guessing a scale.
|
||||||
|
- **A sensor placement per radar** — where it is on the plan and *which way it faces*.
|
||||||
|
A wrong rotation mirrors every target it reports, which is the single most likely way
|
||||||
|
to get positions that look plausible and are wrong.
|
||||||
|
|
||||||
|
Rendering follows the same honesty: a named person at a fused position gets their
|
||||||
|
marker with a footprint dot on the floor (without it, a marker floated above the floor
|
||||||
|
reads as being further back in the room); an unattributed target gets a **hollow dashed
|
||||||
|
puck with a question mark** — no colour, no initial, because every visual language this
|
||||||
|
view has for a person is reserved for people it can name. A radar target lights the
|
||||||
|
room even with nobody named, because somebody *is* in there.
|
||||||
|
|
||||||
### The honest constraint — and how it was resolved
|
### The honest constraint — and how it was resolved
|
||||||
|
|
||||||
The plan was to vendor three.js and call it a deliberate break with the
|
The plan was to vendor three.js and call it a deliberate break with the
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,44 @@ applies to replacing one.
|
||||||
A person with no picture is not a gap in a UI: their initial on their colour is the
|
A person with no picture is not a gap in a UI: their initial on their colour is the
|
||||||
fallback everywhere, and on the small screens it is the *preferred* rendering anyway.
|
fallback everywhere, and on the small screens it is the *preferred* rendering anyway.
|
||||||
|
|
||||||
|
## Exact positions inside a room
|
||||||
|
|
||||||
|
`/floorplan/presence` answers "which room" from BLE. It can also answer "**where in
|
||||||
|
it**", from a different sensor, and the two are kept apart on purpose.
|
||||||
|
|
||||||
|
| Signal | Source | Gives |
|
||||||
|
|---|---|---|
|
||||||
|
| who | BLE / Bermuda | a person, resolved to a room |
|
||||||
|
| where | mmWave radar (LD2450-class) | a coordinate, **with no name** |
|
||||||
|
|
||||||
|
**The fusion rule is deliberately timid:**
|
||||||
|
|
||||||
|
> exactly one occupant in the room **and** exactly one target in the room → that target
|
||||||
|
> is that person (`position.source: "fused"`). Anything else → targets stay anonymous
|
||||||
|
> in `room.targets`, occupants stay unpositioned, and `position_ambiguous` is true.
|
||||||
|
|
||||||
|
Two people in a room are two blobs that cannot be told apart. Guessing would put a name
|
||||||
|
on the wrong person, and a display that does that occasionally is worse than one that
|
||||||
|
never tries — you can't tell its wrong answers from its right ones.
|
||||||
|
|
||||||
|
Which room a target is in is computed **from the polygon**, never from which sensor saw
|
||||||
|
it: a radar in an open-plan kitchen sees into the living room, and attributing by sensor
|
||||||
|
puts people through walls.
|
||||||
|
|
||||||
|
Two things have to be drawn by a human first, because nothing can infer them:
|
||||||
|
|
||||||
|
- **`metres_wide` on the level** — the real width of its 0–1 extent. Without it,
|
||||||
|
positions are simply not computed, and the API says so rather than guessing a scale.
|
||||||
|
- **A sensor placement** (`POST /floorplan/sensors`): where the radar sits on the plan,
|
||||||
|
and **which way it faces**. A wrong `rotation_deg` mirrors every target it reports —
|
||||||
|
the most likely way to get positions that look plausible and are wrong.
|
||||||
|
|
||||||
|
Readings are assumed to be **millimetres** (ESPHome's LD2450 default);
|
||||||
|
`POSITION_UNIT_DIVISOR` overrides that, because "which unit is this number in" differs
|
||||||
|
between integrations and getting it wrong scales everything by a thousand instead of
|
||||||
|
failing visibly. A target at exactly (0,0) is how these radars say *nothing here*, so
|
||||||
|
those are dropped — otherwise every sensor grows a phantom person sitting on top of it.
|
||||||
|
|
||||||
## People without a device
|
## People without a device
|
||||||
|
|
||||||
Two paths, distinct on purpose because they solve different problems:
|
Two paths, distinct on purpose because they solve different problems:
|
||||||
|
|
@ -615,6 +653,7 @@ not network placement.
|
||||||
| `GET /people/<id>/photo` | the person's profile picture (raw JPEG) |
|
| `GET /people/<id>/photo` | the person's profile picture (raw JPEG) |
|
||||||
| `POST /people/<id>/photo` | raw image bytes -> set the profile picture directly, without a walk to the door panel |
|
| `POST /people/<id>/photo` | raw image bytes -> set the profile picture directly, without a walk to the door panel |
|
||||||
| `GET /person-colors` | the eight-colour palette the admin panel offers (see below) |
|
| `GET /person-colors` | the eight-colour palette the admin panel offers (see below) |
|
||||||
|
| `GET/POST /floorplan/sensors`, `DELETE /floorplan/sensors/<id>` | where each position radar sits on the plan, and which way it faces |
|
||||||
| `POST /people/<id>/identifiers` | `{"entity_id"}` — attach an identifier by hand (a fixed BLE tag not in range yet). Still enforces `TRUSTED_ENTITY_PREFIXES` |
|
| `POST /people/<id>/identifiers` | `{"entity_id"}` — attach an identifier by hand (a fixed BLE tag not in range yet). Still enforces `TRUSTED_ENTITY_PREFIXES` |
|
||||||
| `DELETE /people/<id>/identifiers/<id>` | revoke a mistaken or compromised identifier |
|
| `DELETE /people/<id>/identifiers/<id>` | revoke a mistaken or compromised identifier |
|
||||||
| `DELETE /people/<id>` | remove a person entirely (their identifiers, grants and visits go with them) |
|
| `DELETE /people/<id>` | remove a person entirely (their identifiers, grants and visits go with them) |
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
@ -378,8 +379,40 @@ def init_db() -> None:
|
||||||
-- PDF, a photo of a sketch). Rooms can be drawn on bare canvas without
|
-- PDF, a photo of a sketch). Rooms can be drawn on bare canvas without
|
||||||
-- one; it just makes drawing them accurate rather than approximate.
|
-- one; it just makes drawing them accurate rather than approximate.
|
||||||
image_path TEXT,
|
image_path TEXT,
|
||||||
|
-- How wide this level is in REAL METRES, across its whole 0..1 extent.
|
||||||
|
-- Required for exact positions and for nothing else: a sensor reports
|
||||||
|
-- "target at 2.1m", and turning that into a point on a normalised plan
|
||||||
|
-- needs a scale. NULL means positions cannot be computed for this level,
|
||||||
|
-- which is reported honestly rather than guessed at.
|
||||||
|
metres_wide REAL,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
-- WHERE A POSITION SENSOR PHYSICALLY SITS, on the plan.
|
||||||
|
--
|
||||||
|
-- A room-level presence signal answers "which room". Exact positions need a
|
||||||
|
-- sensor that reports coordinates — mmWave (LD2450-class) is the realistic
|
||||||
|
-- one — and its readings are RELATIVE TO ITSELF. So the plan has to know
|
||||||
|
-- where each sensor is and which way it faces, or the coordinates land
|
||||||
|
-- somewhere arbitrary. Drawn by a human in the admin panel, exactly like the
|
||||||
|
-- rooms, because nothing here can infer it.
|
||||||
|
CREATE TABLE IF NOT EXISTS floorplan_sensors (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
level_id INTEGER NOT NULL REFERENCES floorplan_levels(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
-- The HA entity prefix its targets appear under. An ESPHome LD2450
|
||||||
|
-- publishes sensor.<prefix>_target_1_x / _y and so on; this stores
|
||||||
|
-- <prefix> and the reader walks the targets.
|
||||||
|
ha_entity_prefix TEXT NOT NULL,
|
||||||
|
-- Its own spot on the plan, normalised 0..1 like the room polygons.
|
||||||
|
x REAL NOT NULL,
|
||||||
|
y REAL NOT NULL,
|
||||||
|
-- Which way it faces, degrees clockwise from "up" on the plan. Getting
|
||||||
|
-- this wrong mirrors or rotates every target it reports, which is the
|
||||||
|
-- single most likely reason positions look plausible but wrong.
|
||||||
|
rotation_deg REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS floorplan_sensors_level ON floorplan_sensors (level_id);
|
||||||
CREATE TABLE IF NOT EXISTS floorplan_rooms (
|
CREATE TABLE IF NOT EXISTS floorplan_rooms (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
level_id INTEGER NOT NULL REFERENCES floorplan_levels(id) ON DELETE CASCADE,
|
level_id INTEGER NOT NULL REFERENCES floorplan_levels(id) ON DELETE CASCADE,
|
||||||
|
|
@ -440,6 +473,7 @@ def init_db() -> None:
|
||||||
_ensure_column(conn, "people", "notify_topic", "TEXT")
|
_ensure_column(conn, "people", "notify_topic", "TEXT")
|
||||||
_ensure_column(conn, "people", "digest_sections", "TEXT")
|
_ensure_column(conn, "people", "digest_sections", "TEXT")
|
||||||
_ensure_column(conn, "people", "color", "TEXT")
|
_ensure_column(conn, "people", "color", "TEXT")
|
||||||
|
_ensure_column(conn, "floorplan_levels", "metres_wide", "REAL")
|
||||||
_backfill_colors(conn)
|
_backfill_colors(conn)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1997,6 +2031,75 @@ def _validate_points(raw) -> tuple[list[list[float]] | None, str]:
|
||||||
return cleaned, ""
|
return cleaned, ""
|
||||||
|
|
||||||
|
|
||||||
|
# How many targets to read per sensor. LD2450-class radars track three; asking for more
|
||||||
|
# is free and simply finds nothing.
|
||||||
|
POSITION_TARGETS_PER_SENSOR = int(os.environ.get("POSITION_TARGETS_PER_SENSOR", "3"))
|
||||||
|
# Readings are published in millimetres by ESPHome's LD2450 component. Overridable
|
||||||
|
# because "which unit is this number in" is exactly the kind of thing that differs
|
||||||
|
# between one integration and the next, and getting it wrong scales every position by
|
||||||
|
# a thousand rather than failing visibly.
|
||||||
|
POSITION_UNIT_DIVISOR = float(os.environ.get("POSITION_UNIT_DIVISOR", "1000"))
|
||||||
|
|
||||||
|
|
||||||
|
def _sensor_targets(states_by_id: dict, prefix: str) -> list[dict]:
|
||||||
|
"""The live targets one sensor reports, in metres relative to itself.
|
||||||
|
|
||||||
|
A target at exactly (0, 0) is how these radars say "nothing here" — they publish
|
||||||
|
zero rather than going unavailable — so those are dropped. Treating them as a real
|
||||||
|
detection would put a phantom person on top of every sensor on the plan.
|
||||||
|
"""
|
||||||
|
targets = []
|
||||||
|
for index in range(1, POSITION_TARGETS_PER_SENSOR + 1):
|
||||||
|
raw_x = states_by_id.get(f"sensor.{prefix}_target_{index}_x")
|
||||||
|
raw_y = states_by_id.get(f"sensor.{prefix}_target_{index}_y")
|
||||||
|
try:
|
||||||
|
x = float(raw_x) / POSITION_UNIT_DIVISOR
|
||||||
|
y = float(raw_y) / POSITION_UNIT_DIVISOR
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if abs(x) < 0.01 and abs(y) < 0.01:
|
||||||
|
continue
|
||||||
|
targets.append({"x": x, "y": y})
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_position(sensor, target, metres_wide: float) -> dict | None:
|
||||||
|
"""A sensor-relative reading, placed on the normalised plan.
|
||||||
|
|
||||||
|
Rotate by the sensor's own bearing, scale metres into plan units, offset by where
|
||||||
|
the sensor sits. Returns None when the result lands outside the plan, which is the
|
||||||
|
honest outcome for a bad rotation or a wrong scale — better a missing marker than a
|
||||||
|
confident one in the garden.
|
||||||
|
"""
|
||||||
|
if not metres_wide or metres_wide <= 0:
|
||||||
|
return None
|
||||||
|
angle = math.radians(sensor["rotation_deg"] or 0)
|
||||||
|
# Plan y grows downward, so a target "in front of" the sensor moves it up-plan.
|
||||||
|
rx = target["x"] * math.cos(angle) - target["y"] * math.sin(angle)
|
||||||
|
ry = target["x"] * math.sin(angle) + target["y"] * math.cos(angle)
|
||||||
|
x = sensor["x"] + rx / metres_wide
|
||||||
|
y = sensor["y"] - ry / metres_wide
|
||||||
|
if not (-0.05 <= x <= 1.05 and -0.05 <= y <= 1.05):
|
||||||
|
return None
|
||||||
|
return {"x": round(x, 4), "y": round(y, 4)}
|
||||||
|
|
||||||
|
|
||||||
|
def _point_in_polygon(x: float, y: float, points: list) -> bool:
|
||||||
|
"""Standard ray casting. Which room a target is in has to be computed rather than
|
||||||
|
assumed from which sensor saw it: a radar in an open-plan kitchen sees into the
|
||||||
|
living room, and attributing by sensor would put people through walls."""
|
||||||
|
inside = False
|
||||||
|
n = len(points)
|
||||||
|
for i in range(n):
|
||||||
|
x1, y1 = points[i]
|
||||||
|
x2, y2 = points[(i + 1) % n]
|
||||||
|
if (y1 > y) != (y2 > y):
|
||||||
|
xin = (x2 - x1) * (y - y1) / ((y2 - y1) or 1e-9) + x1
|
||||||
|
if x < xin:
|
||||||
|
inside = not inside
|
||||||
|
return inside
|
||||||
|
|
||||||
|
|
||||||
def list_floorplan() -> dict:
|
def list_floorplan() -> dict:
|
||||||
with _db_lock, _db() as conn:
|
with _db_lock, _db() as conn:
|
||||||
levels = conn.execute(
|
levels = conn.execute(
|
||||||
|
|
@ -2014,6 +2117,7 @@ def list_floorplan() -> dict:
|
||||||
"name": level["name"],
|
"name": level["name"],
|
||||||
"sort_order": level["sort_order"],
|
"sort_order": level["sort_order"],
|
||||||
"has_image": level["image_path"] is not None,
|
"has_image": level["image_path"] is not None,
|
||||||
|
"metres_wide": level["metres_wide"],
|
||||||
"rooms": [
|
"rooms": [
|
||||||
{
|
{
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
|
|
@ -2029,22 +2133,24 @@ def list_floorplan() -> dict:
|
||||||
return {"levels": result, "generated_at": _now()}
|
return {"levels": result, "generated_at": _now()}
|
||||||
|
|
||||||
|
|
||||||
def save_level(level_id: int | None, name: str, sort_order: int) -> dict:
|
def save_level(level_id: int | None, name: str, sort_order: int,
|
||||||
|
metres_wide: float | None = None) -> dict:
|
||||||
name = (name or "").strip()
|
name = (name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
return {"ok": False, "reason": "bad_name", "message": "A level needs a name."}
|
return {"ok": False, "reason": "bad_name", "message": "A level needs a name."}
|
||||||
with _db_lock, _db() as conn:
|
with _db_lock, _db() as conn:
|
||||||
if level_id:
|
if level_id:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"UPDATE floorplan_levels SET name = ?, sort_order = ? WHERE id = ?",
|
"UPDATE floorplan_levels SET name = ?, sort_order = ?, metres_wide = ? WHERE id = ?",
|
||||||
(name, sort_order, level_id),
|
(name, sort_order, metres_wide, level_id),
|
||||||
)
|
)
|
||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
return {"ok": False, "reason": "not_found", "message": "No such level."}
|
return {"ok": False, "reason": "not_found", "message": "No such level."}
|
||||||
else:
|
else:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO floorplan_levels (name, sort_order, created_at) VALUES (?, ?, ?)",
|
"INSERT INTO floorplan_levels (name, sort_order, metres_wide, created_at) "
|
||||||
(name, sort_order, _now()),
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(name, sort_order, metres_wide, _now()),
|
||||||
)
|
)
|
||||||
level_id = cur.lastrowid
|
level_id = cur.lastrowid
|
||||||
return {"ok": True, "level_id": level_id, "name": name}
|
return {"ok": True, "level_id": level_id, "name": name}
|
||||||
|
|
@ -2147,6 +2253,24 @@ def floorplan_presence() -> dict:
|
||||||
resolved at all, which is the normal case without room-level BLE — come back under
|
resolved at all, which is the normal case without room-level BLE — come back under
|
||||||
`unplaced` rather than being dropped, so the UI can show "3 people home, 1 not
|
`unplaced` rather than being dropped, so the UI can show "3 people home, 1 not
|
||||||
locatable" instead of quietly losing two of them.
|
locatable" instead of quietly losing two of them.
|
||||||
|
|
||||||
|
EXACT POSITIONS ARE A SECOND, WEAKER SIGNAL, AND ARE KEPT SEPARATE
|
||||||
|
------------------------------------------------------------------
|
||||||
|
If the level has position sensors and a metre scale, each room also gets `targets`:
|
||||||
|
coordinates where a radar can see *somebody*. Those are ANONYMOUS — an mmWave sensor
|
||||||
|
reports a moving blob, not a name — and identity comes from BLE, which is room-level.
|
||||||
|
|
||||||
|
So attribution follows one rule, and the rule is deliberately timid:
|
||||||
|
|
||||||
|
exactly one occupant in the room AND exactly one target in the room
|
||||||
|
-> that target is that person, marked `source: "fused"`
|
||||||
|
anything else
|
||||||
|
-> the targets stay anonymous and the occupants stay unpositioned
|
||||||
|
|
||||||
|
Two people in a room produce two blobs that cannot be told apart, and guessing
|
||||||
|
which is which would put a name on a stranger — the failure that makes a presence
|
||||||
|
display worse than none. `position_ambiguous` says out loud when that has happened,
|
||||||
|
so a UI can show two unnamed dots rather than implying it knows.
|
||||||
"""
|
"""
|
||||||
plan = list_floorplan()
|
plan = list_floorplan()
|
||||||
people = presence().get("people", [])
|
people = presence().get("people", [])
|
||||||
|
|
@ -2172,11 +2296,29 @@ def floorplan_presence() -> dict:
|
||||||
else:
|
else:
|
||||||
unplaced.append(entry)
|
unplaced.append(entry)
|
||||||
|
|
||||||
|
# Live targets, per level, projected onto the plan. Best-effort: no sensors, no
|
||||||
|
# scale, or an unreachable HA all end in the same place — rooms with no `targets`,
|
||||||
|
# which the renderer draws exactly as it always did.
|
||||||
|
targets_by_level = _live_targets(plan)
|
||||||
|
|
||||||
placed_areas = set()
|
placed_areas = set()
|
||||||
for level in plan["levels"]:
|
for level in plan["levels"]:
|
||||||
|
level_targets = targets_by_level.get(level["id"], [])
|
||||||
for room in level["rooms"]:
|
for room in level["rooms"]:
|
||||||
occupants = by_area.get(room["ha_area_id"] or "", [])
|
occupants = by_area.get(room["ha_area_id"] or "", [])
|
||||||
room["occupants"] = occupants
|
room["occupants"] = occupants
|
||||||
|
|
||||||
|
# Which room a target is in is computed from the polygon, never from which
|
||||||
|
# sensor saw it — a radar in an open-plan kitchen sees into the living room.
|
||||||
|
inside = [t for t in level_targets if _point_in_polygon(t["x"], t["y"], room["points"])]
|
||||||
|
room["targets"] = inside
|
||||||
|
room["position_ambiguous"] = len(inside) > 1 or (len(inside) >= 1 and len(occupants) > 1)
|
||||||
|
|
||||||
|
if len(inside) == 1 and len(occupants) == 1:
|
||||||
|
occupants[0]["position"] = {**inside[0], "source": "fused"}
|
||||||
|
# Attributed, so it is no longer an unnamed dot for the renderer to draw.
|
||||||
|
room["targets"] = []
|
||||||
|
|
||||||
if room["ha_area_id"] and occupants:
|
if room["ha_area_id"] and occupants:
|
||||||
placed_areas.add(room["ha_area_id"])
|
placed_areas.add(room["ha_area_id"])
|
||||||
|
|
||||||
|
|
@ -2188,9 +2330,100 @@ def floorplan_presence() -> dict:
|
||||||
|
|
||||||
plan["unplaced"] = unplaced
|
plan["unplaced"] = unplaced
|
||||||
plan["unmapped_areas"] = sorted(unmapped)
|
plan["unmapped_areas"] = sorted(unmapped)
|
||||||
|
plan["positions_available"] = any(targets_by_level.values())
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def _live_targets(plan: dict) -> dict[int, list[dict]]:
|
||||||
|
"""{level_id: [{x, y}]} — every position sensor's current targets, on the plan.
|
||||||
|
|
||||||
|
Read from the same HA state dump presence() uses. Fails soft in every direction:
|
||||||
|
an unreachable HA, a level with no metre scale, a sensor whose entities do not
|
||||||
|
exist yet — all produce no targets, and the floorplan renders room-level as before.
|
||||||
|
"""
|
||||||
|
result: dict[int, list[dict]] = {}
|
||||||
|
with _db_lock, _db() as conn:
|
||||||
|
sensors = conn.execute("SELECT * FROM floorplan_sensors").fetchall()
|
||||||
|
if not sensors:
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
states = _ha_get("/api/states")
|
||||||
|
except Exception:
|
||||||
|
LOG.warning("identity: could not read HA states for position sensors", exc_info=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
states_by_id = {s.get("entity_id"): s.get("state") for s in states if isinstance(s, dict)}
|
||||||
|
scale_by_level = {level["id"]: level.get("metres_wide") for level in plan["levels"]}
|
||||||
|
|
||||||
|
for sensor in sensors:
|
||||||
|
metres_wide = scale_by_level.get(sensor["level_id"])
|
||||||
|
if not metres_wide:
|
||||||
|
# A sensor on a level with no scale cannot be placed. Logged once per run
|
||||||
|
# rather than silently skipped, because the fix is one number in the editor.
|
||||||
|
LOG.info("identity: level %s has no metres_wide, so sensor %r cannot be placed",
|
||||||
|
sensor["level_id"], sensor["name"])
|
||||||
|
continue
|
||||||
|
for target in _sensor_targets(states_by_id, sensor["ha_entity_prefix"]):
|
||||||
|
point = _plan_position(sensor, target, float(metres_wide))
|
||||||
|
if point:
|
||||||
|
result.setdefault(sensor["level_id"], []).append(point)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def save_sensor(payload: dict) -> dict:
|
||||||
|
"""Place (or move) a position sensor on the plan. Same human-drawn discipline as
|
||||||
|
the rooms: nothing here can infer where a radar is bolted to a wall."""
|
||||||
|
try:
|
||||||
|
level_id = int(payload.get("level_id"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {"ok": False, "reason": "bad_field", "message": "'level_id' is required."}
|
||||||
|
name = str(payload.get("name") or "").strip()
|
||||||
|
prefix = str(payload.get("ha_entity_prefix") or "").strip()
|
||||||
|
if not name or not prefix:
|
||||||
|
return {"ok": False, "reason": "bad_field",
|
||||||
|
"message": "'name' and 'ha_entity_prefix' are both required. The prefix is "
|
||||||
|
"the part before _target_1_x in the sensor's entity ids."}
|
||||||
|
try:
|
||||||
|
x = float(payload.get("x"))
|
||||||
|
y = float(payload.get("y"))
|
||||||
|
rotation = float(payload.get("rotation_deg", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {"ok": False, "reason": "bad_field", "message": "'x', 'y' must be numbers."}
|
||||||
|
if not (0 <= x <= 1 and 0 <= y <= 1):
|
||||||
|
return {"ok": False, "reason": "bad_field",
|
||||||
|
"message": "x and y are normalised 0..1, the same coordinate space the "
|
||||||
|
"room polygons use."}
|
||||||
|
|
||||||
|
with _db_lock, _db() as conn:
|
||||||
|
sensor_id = payload.get("id")
|
||||||
|
if sensor_id:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE floorplan_sensors SET name = ?, ha_entity_prefix = ?, x = ?, y = ?, "
|
||||||
|
"rotation_deg = ? WHERE id = ?",
|
||||||
|
(name, prefix, x, y, rotation, int(sensor_id)),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO floorplan_sensors (level_id, name, ha_entity_prefix, x, y, "
|
||||||
|
"rotation_deg, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(level_id, name, prefix, x, y, rotation, _now()),
|
||||||
|
)
|
||||||
|
sensor_id = cur.lastrowid
|
||||||
|
return {"ok": True, "id": sensor_id}
|
||||||
|
|
||||||
|
|
||||||
|
def list_sensors() -> dict:
|
||||||
|
with _db_lock, _db() as conn:
|
||||||
|
rows = conn.execute("SELECT * FROM floorplan_sensors ORDER BY level_id, name").fetchall()
|
||||||
|
return {"sensors": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_sensor(sensor_id: int) -> bool:
|
||||||
|
with _db_lock, _db() as conn:
|
||||||
|
return conn.execute("DELETE FROM floorplan_sensors WHERE id = ?", (sensor_id,)).rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
def area_suggestions() -> dict:
|
def area_suggestions() -> dict:
|
||||||
"""Every area value HA is currently reporting on a trusted entity, so the room
|
"""Every area value HA is currently reporting on a trusted entity, so the room
|
||||||
editor can offer a pick-list instead of asking someone to retype an area_id from
|
editor can offer a pick-list instead of asking someone to retype an area_id from
|
||||||
|
|
@ -2367,6 +2600,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._respond(HTTPStatus.OK, floorplan_presence())
|
self._respond(HTTPStatus.OK, floorplan_presence())
|
||||||
elif path == "/floorplan/areas":
|
elif path == "/floorplan/areas":
|
||||||
self._respond(HTTPStatus.OK, area_suggestions())
|
self._respond(HTTPStatus.OK, area_suggestions())
|
||||||
|
elif path == "/floorplan/sensors":
|
||||||
|
self._respond(HTTPStatus.OK, list_sensors())
|
||||||
elif path == "/person-colors":
|
elif path == "/person-colors":
|
||||||
# Served rather than duplicated in the admin panel's JS, so the palette
|
# Served rather than duplicated in the admin panel's JS, so the palette
|
||||||
# has one definition — see PERSON_COLORS for what makes these eight
|
# has one definition — see PERSON_COLORS for what makes these eight
|
||||||
|
|
@ -2515,6 +2750,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._handle_save_level()
|
self._handle_save_level()
|
||||||
elif path == "/floorplan/rooms":
|
elif path == "/floorplan/rooms":
|
||||||
self._handle_save_room()
|
self._handle_save_room()
|
||||||
|
elif path == "/floorplan/sensors":
|
||||||
|
self._handle_save_sensor()
|
||||||
elif post_level_image_match:
|
elif post_level_image_match:
|
||||||
self._handle_upload_level_image(int(post_level_image_match.group(1)))
|
self._handle_upload_level_image(int(post_level_image_match.group(1)))
|
||||||
elif person_photo_match:
|
elif person_photo_match:
|
||||||
|
|
@ -2598,13 +2835,28 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
self._respond(HTTPStatus.BAD_REQUEST, {"error": "'id' and 'sort_order' must be integers"})
|
self._respond(HTTPStatus.BAD_REQUEST, {"error": "'id' and 'sort_order' must be integers"})
|
||||||
return
|
return
|
||||||
result = save_level(level_id, str(payload.get("name", "")), sort_order)
|
# The real-world width of the level, needed for exact positions and nothing
|
||||||
|
# else. Absent leaves it NULL, which reports honestly as "positions cannot be
|
||||||
|
# computed here" rather than guessing a scale.
|
||||||
|
try:
|
||||||
|
metres_wide = float(payload["metres_wide"]) if payload.get("metres_wide") else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self._respond(HTTPStatus.BAD_REQUEST, {"error": "'metres_wide' must be a number"})
|
||||||
|
return
|
||||||
|
result = save_level(level_id, str(payload.get("name", "")), sort_order, metres_wide)
|
||||||
self._respond(
|
self._respond(
|
||||||
HTTPStatus.OK if result.get("ok")
|
HTTPStatus.OK if result.get("ok")
|
||||||
else (HTTPStatus.NOT_FOUND if result.get("reason") == "not_found" else HTTPStatus.CONFLICT),
|
else (HTTPStatus.NOT_FOUND if result.get("reason") == "not_found" else HTTPStatus.CONFLICT),
|
||||||
result,
|
result,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _handle_save_sensor(self) -> None:
|
||||||
|
payload = self._json_body()
|
||||||
|
if payload is None:
|
||||||
|
return
|
||||||
|
result = save_sensor(payload)
|
||||||
|
self._respond(HTTPStatus.OK if result.get("ok") else HTTPStatus.BAD_REQUEST, result)
|
||||||
|
|
||||||
def _handle_save_room(self) -> None:
|
def _handle_save_room(self) -> None:
|
||||||
payload = self._json_body()
|
payload = self._json_body()
|
||||||
if payload is None:
|
if payload is None:
|
||||||
|
|
@ -2659,6 +2911,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
return
|
return
|
||||||
path = urlsplit(self.path).path
|
path = urlsplit(self.path).path
|
||||||
|
|
||||||
|
sensor_match = re.match(r"^/floorplan/sensors/(\d+)$", path)
|
||||||
|
if sensor_match:
|
||||||
|
ok = delete_sensor(int(sensor_match.group(1)))
|
||||||
|
self._respond(HTTPStatus.OK if ok else HTTPStatus.NOT_FOUND,
|
||||||
|
{"ok": True} if ok else {"error": "no such sensor"})
|
||||||
|
return
|
||||||
|
|
||||||
id_match = re.match(r"^/people/(\d+)/identifiers/(\d+)$", path)
|
id_match = re.match(r"^/people/(\d+)/identifiers/(\d+)$", path)
|
||||||
if id_match:
|
if id_match:
|
||||||
ok = delete_identifier(int(id_match.group(1)), int(id_match.group(2)))
|
ok = delete_identifier(int(id_match.group(1)), int(id_match.group(2)))
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,14 @@ and divergence would mean one of them is lying:
|
||||||
the watch and in the admin panel.
|
the watch and in the admin panel.
|
||||||
- `unplaced` people get a **visible shelf**. They are who you are most often looking for.
|
- `unplaced` people get a **visible shelf**. They are who you are most often looking for.
|
||||||
|
|
||||||
|
**Exact positions**, when the level has a metre scale and mmWave sensors placed on it:
|
||||||
|
a person fused to a single radar target is drawn *at* that coordinate with a footprint
|
||||||
|
dot on the floor (without it, a marker floated above the floor reads as further back in
|
||||||
|
the room). An unattributed target is a **hollow dashed puck with a question mark** — no
|
||||||
|
colour, no initial, because every visual language here for a *person* is reserved for
|
||||||
|
people the system can name. A radar target lights the room even with nobody named:
|
||||||
|
somebody is in there, and that the house can't say who is a fact about the house.
|
||||||
|
|
||||||
Drag to orbit; pitch is clamped and there is no free-fly, because an unconstrained
|
Drag to orbit; pitch is clamped and there is no free-fly, because an unconstrained
|
||||||
camera on a wall panel is something people knock askew and cannot get back.
|
camera on a wall panel is something people knock askew and cannot get back.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,20 @@ function load() {
|
||||||
(n, room) => n + (room.occupants || []).length, 0
|
(n, room) => n + (room.occupants || []).length, 0
|
||||||
);
|
);
|
||||||
const unplaced = (data.unplaced || []).length;
|
const unplaced = (data.unplaced || []).length;
|
||||||
$("summary").textContent = unplaced
|
const rooms = level ? level.rooms || [] : [];
|
||||||
? `${placed} placed · ${unplaced} home but not locatable`
|
const exact = rooms.reduce(
|
||||||
: `${placed} placed`;
|
(n, room) => n + (room.occupants || []).filter((p) => p.position).length, 0
|
||||||
|
);
|
||||||
|
const anonymous = rooms.reduce((n, room) => n + (room.targets || []).length, 0);
|
||||||
|
|
||||||
|
// Every number the view actually knows, said out loud. A summary that reported
|
||||||
|
// only "3 placed" would imply the picture is complete when two of the dots are
|
||||||
|
// unnamed blobs and one person is somewhere unknown.
|
||||||
|
const parts = [`${placed} placed`];
|
||||||
|
if (exact) parts.push(`${exact} exact`);
|
||||||
|
if (anonymous) parts.push(`${anonymous} unidentified`);
|
||||||
|
if (unplaced) parts.push(`${unplaced} home but not locatable`);
|
||||||
|
$("summary").textContent = parts.join(" · ");
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
$("error").hidden = false;
|
$("error").hidden = false;
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,12 @@
|
||||||
// reports is neither occupied nor confirmed-empty, and drawing it as empty is a
|
// 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
|
// quiet lie — "nobody is in the study" and "nothing can see the study" are
|
||||||
// different sentences.
|
// different sentences.
|
||||||
if ((room.occupants || []).length) return "occupied";
|
//
|
||||||
|
// 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";
|
if (!room.ha_area_id) return "unknown";
|
||||||
return "empty";
|
return "empty";
|
||||||
}
|
}
|
||||||
|
|
@ -291,12 +296,35 @@
|
||||||
|
|
||||||
_drawOccupants(room, points, height) {
|
_drawOccupants(room, points, height) {
|
||||||
const occupants = room.occupants || [];
|
const occupants = room.occupants || [];
|
||||||
if (!occupants.length) return;
|
const targets = room.targets || [];
|
||||||
const centre = this._centroid(points, height);
|
|
||||||
const radius = 17;
|
const radius = 17;
|
||||||
const spread = Math.min(occupants.length - 1, 3) * (radius + 4);
|
|
||||||
|
|
||||||
occupants.slice(0, 4).forEach((person, index) => {
|
// 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);
|
const x = centre.x - spread / 2 + index * (radius + 4);
|
||||||
// Floated above the floor so the marker reads as standing in the room rather
|
// 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,
|
// than as painted on it — a flat sprite on the floor is unreadable at a glance,
|
||||||
|
|
@ -304,14 +332,57 @@
|
||||||
this._drawMarker(person, x, centre.y - radius - 14, radius);
|
this._drawMarker(person, x, centre.y - radius - 14, radius);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (occupants.length > 4) {
|
if (unpositioned.length > 4) {
|
||||||
const { ctx } = this;
|
const { ctx } = this;
|
||||||
ctx.fillStyle = "rgba(30,18,45,0.8)";
|
ctx.fillStyle = "rgba(30,18,45,0.8)";
|
||||||
ctx.font = "600 11px system-ui, sans-serif";
|
ctx.font = "600 11px system-ui, sans-serif";
|
||||||
ctx.fillText(`+${occupants.length - 4}`, centre.x + spread / 2 + radius, centre.y - radius - 10);
|
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) {
|
_drawMarker(person, x, y, radius) {
|
||||||
const { ctx } = this;
|
const { ctx } = this;
|
||||||
const colour = person.color || "#c084fc";
|
const colour = person.color || "#c084fc";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue