npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-planner

react, 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; cameraAnchor steps 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.