@ssgc/floor-planner
v0.2.1
Published
Draw a floor plan in 2D, preview it in 3D, and pin cameras onto it. All I/O is injected by the host application.
Downloads
478
Readme
@ssgc/floor-planner
Draw a floor plan in 2D, preview it in 3D, and pin cameras onto it.
The package performs no network calls of its own. Everything it needs to
read or write is injected by the host through a FloorPlanPort prop, the same
way @ssgc/alert-console uses its ports — so auth, base URLs, retries and error
reporting stay the host's concern.
Install
npm install @ssgc/floor-plannerreact, react-dom, antd and @ant-design/icons are peer dependencies.
three is a regular dependency, left external at build time so your app ends up
with a single three.js instance.
Styling
Components are styled with Tailwind utility classes and, like alert-console,
expect the host to run Tailwind. Add the package to your content globs:
// tailwind.config.js
export default {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
"./node_modules/@ssgc/floor-planner/dist/*.js",
],
};Quick start
import { FloorPlannerWidget, type FloorPlanPort } from "@ssgc/floor-planner";
const api: FloorPlanPort = {
loadPlan: ({ facilityId }) =>
http.get(`/facilities/${facilityId}/floor-plan`),
savePlan: (plan, { facilityId }) =>
http.put(`/facilities/${facilityId}/floor-plan`, plan),
listCameras: ({ facilityId }) =>
http.get(`/facilities/${facilityId}/cameras`),
saveCameraPins: (pins, { facilityId }) =>
http.put(`/facilities/${facilityId}/camera-pins`, { pins }),
};
<FloorPlannerWidget
api={api}
planRef={{ facilityId }}
onError={(error, operation) => toast.error(`Floor plan ${operation} failed`)}
/>;The widget handles the whole lifecycle: load the plan, load the camera list, render the editor, and route Save Plan and Save Cameras back through the port.
Already have a data layer? Import FloorPlanner instead and pass the plan in:
<FloorPlanner
initialPlan={plan}
cameras={cameras}
onSave={save}
onSaveCameraPins={savePins}
/>The port
Only loadPlan and savePlan are required. The camera methods are needed just
for the Pin Camera feature; omit saveCameraPins and its Save button is hidden.
interface FloorPlanPort {
loadPlan(ref: FloorPlanRef): Promise<FloorPlan | null>;
savePlan(plan: FloorPlan, ref: FloorPlanRef): Promise<void>;
listCameras?(ref: FloorPlanRef): Promise<CameraOption[]>;
saveCameraPins?(pins: CameraPin[], ref: FloorPlanRef): Promise<void>;
}loadPlan returning null means "nothing saved yet" and opens a blank canvas —
it is not an error. Whatever it returns is run through sanitizePlan, so a
partial or stale document degrades instead of crashing the editor.
No backend yet? Local JSON
api is optional. Leave it out and the widget falls back to
createLocalPlanPort: the plan is kept in localStorage and moves between
machines through the editor's Import / Export buttons.
// Today — no plan service. Nothing but the missing `api` prop says so.
<FloorPlannerWidget planRef={{ facilityId }} cameras={cameras} />
// Once the API exists, pass the port. Everything else stays as it is.
<FloorPlannerWidget api={api} planRef={{ facilityId }} />The port can also be built explicitly — useful for seeding a first-run plan, or for keeping the branch in one place:
const api = useMemo(
() =>
PLAN_API_READY
? createHttpFloorPlanPort(http)
: createLocalPlanPort({ cameras, seedPlan: () => templatePlan }),
[],
);Export writes a versioned envelope — { kind, version, savedAt, ref, plan }
— and Import accepts either that or a bare FloorPlan, so a document copied
straight out of an API response imports as-is. Everything read from disk goes
through sanitizePlan; a truncated or hand-edited file loses the entities that
did not survive rather than failing the whole import. An import is an ordinary
edit, so Ctrl+Z puts back what was on the canvas before it.
Hide the buttons with enableFileIO={false}; serializePlan, parsePlanFile,
downloadPlanFile and readPlanFile are exported for hosts building their own
import/export.
What you can draw
| Group | Tools | | --------- | ------------------------------------------------- | | Walls | Outside (25cm), Partition (12cm), Glass (8cm) | | Openings | Door, Window — clicked directly onto a wall | | Furniture | Table, Chair | | Areas | Lobby, Bedroom, Bathroom, Dining, Lounge, Parking | | Cameras | Pin Camera |
Walls chain corner to corner; Shift locks to 45°, right-click or Esc ends the run, and new corners snap to existing ones so rooms close without hairline gaps. Alt suspends snapping anywhere.
Areas are labelled zones. Room types also stamp their perimeter as real walls plus a centred door, and those walls stay bound to the area — move, resize or rotate the zone and the room follows, with its door keeping its relative position along the wall. Parking is a wall-free zone with bay striping.
Camera pins are dropped on the plan, aimed by dragging the handle on their
sightline, and assigned to a real camera in the side panel. Save Cameras
sends every assigned pin in a single batched call; unassigned pins are flagged
in the panel and left out of the payload. The panel collapses to a narrow rail
via the chevron in its header — the canvas reclaims the width, and the
assigned/total count stays on the rail. Pass defaultCollapsed to start it
closed.
3D
The 3D view is deliberately cosmetic: a shaded, shadowed extrusion of the same document, with no textures and no reflections. It is a viewer, not an editing surface — the only interaction is orbiting.
Openings are not booleaned out of walls. Each wall is split into the solid rectangles that survive its doors and windows, so every piece is a plain box — cheap, robust, and indistinguishable from a CSG cut at preview quality.
Roll Call replay
FloorPlanReplay3D renders the same 3D plan with people walking through it,
each figure facing its direction of travel.
<FloorPlanReplay3D
plan={plan}
tracks={[
{
id: "resident-1",
label: "Resident 1",
points: [
{ t: 0, x: 120, y: 300 },
{ t: 2400, x: 480, y: 300 },
{ t: 5000, x: 480, y: 700 },
],
},
]}
playing
loop
/>Tracks are sparse — typically one point per detection — and are interpolated for
you. Heading comes from the direction of travel unless a point states its own
heading, which is what camera-derived tracks usually want.
To drive playback from your own timeline, pass time (ms) and leave playing
off; the component then follows your clock instead of running its own.
From detections to a walk
The backend reports sightings — { cameraUniqueId, timestamp } per person — and
nothing about the route between them. RollCallReplay takes those and does the
rest: reconstruct the path, play it, and float each camera's still over that
camera while the subject is in view. Clicking a still opens it full size.
<RollCallReplay
plan={plan}
subjects={[{ id: "resident-1", label: "Resident 1", sightings }]}
snapshotFor={(event) => ({ thumbnailSrc: url(event), caption: "…" })}
/>How the route is worked out, and why:
- The plan is rasterised into an occupancy grid (
buildNavGrid) — walls block, doors do not, furniture blocks by default. Windows block: a window is a hole in the 3D wall but not a way through. - The path is the shortest walk on that grid (
findPath), pulled straight so the result is the few corners a person would actually turn. Which door they used is then simply which door that path crossed (doorsAlongPath) — not the nearest door, which is very often the one leading away from wherever they turned up next. - The subject is placed in front of a camera, not on it. A pin is mounted on
a wall;
cameraAnchorsteps out along its aim until the view meets something. - Timing is fitted to the two timestamps. They must arrive when the second camera says they did, so the walk is placed at the end of the gap and the slack becomes a pause at the first camera. Crawling across a room for four minutes reads as broken; standing still and then walking reads as a person.
- Facing is derived from travel, except at a camera, where the subject faces it — that is what being seen means.
Inference is labelled, never hidden. A leg that could not have been walked in
the time reported is implausible (a detection is probably missing); one with no
walkable route at all is unrouted and crosses in a straight line. Detections
from a camera that is not pinned on the plan cannot be placed, so they are
returned as skipped and counted on screen rather than dropped silently.
Until the detections and stills come from an API, demoSightings(plan) invents
a walk from the plan's own pins and demoSnapshot(event) draws a camera still
as inline SVG. Both live in src/demo/ and nothing else depends on them.
Also exported
Editor pieces (Planner2DCanvas, Planner3DView, PlannerToolbar,
CameraPinPanel), the local port (createLocalPlanPort), the JSON file format
(serializePlan, parsePlanFile, downloadPlanFile, readPlanFile,
planFileName), the useFloorPlanner store, the document helpers
(stampArea, sanitizePlan, assignCameraToPin, …), geometry
(planBounds, buildWallPieces, …), scene construction (buildPlannerScene,
buildPersonFigure), track sampling (sampleTrack, replayDuration) and Roll
Call reconstruction (buildRollCall, buildNavGrid, findPath,
doorsAlongPath, cameraAnchor, eventsAt).
Model
One document, all lengths in centimetres, so the same numbers drive both views. Plan space is top-down with +x right and +y down, matching SVG.
type FloorPlan = {
walls: Wall[];
openings: Opening[];
furniture: FurnitureItem[];
areas: Area[];
cameras: CameraPin[];
};Development
npm install
npm run dev # dev harness with an in-memory port, at :5174
npm test # 203 unit tests
npm run lint
npm run build # tsc -b && vite build -> dist/src/App.tsx is the harness. It is not published, and its createDevApi is the
clearest worked example of what a host has to implement.
