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

floorist

v0.2.2

Published

Dependency-free web component for drawing and editing real-world floor plans — multi-floor buildings, tables, chairs, doors, windows, A/C, custom images — with attachable actions, doors/windows that snap to walls, and a custom JSON format.

Readme

🏠 floorist

build types license

coverage

A dependency-free web component for drawing and editing real-world floor plans — tables, chairs, doors, windows, A/C units, plants, entrances, custom images and more. Elements carry domain data and attachable actions (toggle a door, cycle a table's status, open a link), so a plan models the real space, not just a picture of it.

  • Zero runtime dependencies. Ships as plain ES modules + Canvas 2D, with TypeScript types out of the box.
  • One custom element: <floor-plan>.
  • Its own JSON format (.floorist.json) — a building with one or more floors.
  • Multi-floor buildings. Switch storeys; share the whole building or one floor.
  • View & edit modes. Pan/zoom, click-to-act, drag, resize, marquee, undo/redo.
  • 3 door types (single / double / sliding) and windows that snap to walls automatically — release each one per-element.
  • Listeners by id, custom HTML/canvas hover, room grouping.
  • Import / export / share (file, clipboard, data-URL link, embed snippet).
  • Extensible. Register your own element types and action handlers.
<floor-plan mode="view"></floor-plan>
<script type="module">
  import 'floorist/component';
  document.querySelector('floor-plan').load(myFloorPlan);
</script>

Demo

npm install      # one-off, just installs the TypeScript dev dependency
npm run dev      # compiles src → dist, then serves http://localhost:5173/demo.html
# or
npm run build    # compile + tests + coverage + regenerates the README block
npm run compile  # tsc only — emits dist/*.js + dist/*.d.ts (+ source maps)

The demo lets you pick a sample (restaurant / office / classroom / café / blank), toggle View ↔ Edit, drop elements from a palette, edit properties, and export the document as JSON.

Install / use

This is a native-ESM package — no bundler needed.

// registers <floor-plan> as a side effect + exposes the API
import 'floorist';
// or just the element:
import 'floorist/component';
// advanced / headless building blocks:
import { FloorPlanModel, registerType, registerActionHandler } from 'floorist';

CDN / single <script> tag (no build, no bundler)

Drop this on any HTML page — <floor-plan> registers itself and the helpers live on window.Floorist:

<floor-plan id="plan" style="width:800px;height:480px"></floor-plan>

<!-- one tag, no type="module" — works in any browser -->
<script src="https://cdn.jsdelivr.net/npm/floorist/dist/floorist.global.min.js"></script>
<script>
  document.getElementById('plan').load(myBuilding);
  // optional: extend the registry
  Floorist.registerType({ type: 'piano', category: 'furniture', label: 'Piano',
    defaults: { width: 120, height: 70, style: { fill: '#222' } },
    draw(ctx, el) { ctx.fillStyle = el.style.fill; ctx.fillRect(0, 0, el.width, el.height); } });
</script>

Prefer ESM? Use the source modules directly:

<script type="module">
  import 'https://cdn.jsdelivr.net/npm/floorist/dist/index.js';
  document.querySelector('floor-plan').load(myBuilding);
</script>

| File | What | Size (≈) | |-------------------------------------|----------------------------------------|---------:| | dist/index.js | ESM entry — import 'floorist' | 1 KB | | dist/floorist.global.js | IIFE bundle, readable | 85 KB | | dist/floorist.global.min.js | IIFE bundle, minified (production) | 48 KB |

Pin a version ([email protected]) on CDN URLs for production; the unpinned form above resolves to the latest published release.

The <floor-plan> element

Attributes

| Attribute | Values | Description | |--------------|-------------------|----------------------------------------------| | mode | view | edit | Interaction mode (default view). | | src | URL | Fetch + load a .floorist.json document. | | grid-snap | number (px) | Snap step while editing (0 = off). | | readonly | boolean attr | Disable mutations even in edit mode. |

Properties & methods

el.data            // get/set the document object
el.model           // the FloorPlanModel (advanced)
el.load(doc)
el.getDocument()
el.exportJSON(pretty = true)
el.addElement(partial)            // add anywhere
el.addElementAtCenter(partial)    // add centered in the current view
el.setMode('view' | 'edit')
el.select(ids); el.getSelection(); el.clearSelection()
el.undo(); el.redo()
el.fitToContent(); el.zoomIn(); el.zoomOut(); el.resetZoom()

// floors (storeys)
el.getFloors()                     // [{ id, name, level, count }]
el.getActiveFloorId()
el.setActiveFloor(id)
el.addFloor({ name }); el.removeFloor(id); el.duplicateFloor(id)
el.getRooms()                      // floor/room elements on the active floor
el.focusElement(id)                // frame an element (e.g. focus a room)

// imperative per-element listeners (see below)
el.on(id, type, handler)   // returns an unsubscribe fn; id '*' = all elements
el.off(id, type, handler)
el.setOverlayRenderer(fn)          // custom CANVAS overlay (e.g. hover ring)
el.setHoverContent(fn)             // custom HTML on hover (fn(el) → string|Node)
el.getElementScreenRect(id)        // viewport rect of an element, for menus

Events (all CustomEvent, bubble + composed)

| Event | detail | |--------------------|--------------------------------------------| | ready | — | | change | { reason, ids } | | element-click | { id, element } | | element-dblclick | { id, element } | | element-action | { id, kind, ... } (after an action runs) | | selection-change | { ids } | | hover-change | { id } | | element-change | { ids, reason } (move/resize/delete) | | element-contextmenu | { id, element, screen, client, canvasRect } | | floor-change | { floorId } (active storey switched) | | zoom-change | { zoom } |

Element events (element-click, element-dblclick, element-contextmenu, element-action) carry positions so you can place DOM tooltips/menus: detail.client (viewport x/y), detail.screen (relative to the canvas) and detail.canvasRect.

el.addEventListener('element-action', (e) => {
  if (e.detail.kind === 'link') window.open(e.detail.url, e.detail.target);
});

Listening by element id (and custom hover render)

Give an element a stable id in the JSON, then attach behaviour from JS — no need to encode everything as data-driven actions:

// JSON:  { "id": "vip-table", "type": "table-round", ... }
el.on('vip-table', 'click', (e) => openReservationModal(e.id, e.client));
el.on('vip-table', 'hover', (e) => showInfo(e.element));
const off = el.on('*', 'contextmenu', (e) => openMenu(e.id, e.client)); // all elements
// off();  // unsubscribe

// Open your own DOM menu on right-click, positioned at the cursor:
el.addEventListener('element-contextmenu', (e) => {
  menu.style.left = e.detail.client.x + 'px';
  menu.style.top  = e.detail.client.y + 'px';
  menu.show(e.detail.element);
});

Listener types: click, dblclick, contextmenu, action, hover, hoverout. The id '*' matches every element.

Custom canvas rendering on hoversetOverlayRenderer runs every frame after the scene (world transform active) so you can paint highlights, ranges, labels, heatmaps, etc.:

el.setOverlayRenderer((ctx, info) => {
  const hovered = info.hoverElement;
  if (!hovered) return;
  // world-space ring around the hovered element
  ctx.strokeStyle = '#2f7df6';
  ctx.lineWidth = 2 / info.camera.zoom;
  ctx.strokeRect(hovered.x - 5, hovered.y - 5, hovered.width + 10, hovered.height + 10);
  // for screen-space drawing: ctx.setTransform(info.dpr,0,0,info.dpr,0,0)
});

info = { camera, dpr, hoverId, hoverElement, selectedIds, model, cssWidth, cssHeight }.

The document format (.floorist.json)

A document is a building with one or more floors (storeys). Element and layer operations always target the active floor.

{
  "version": "2.0",
  "meta": { "name": "Office Building", "units": "m", "scale": 50 },
  "activeFloor": "ground",
  "floors": [
    {
      "id": "ground",
      "name": "Ground · Reception",
      "level": 0,
      "size": { "width": 1000, "height": 680 },
      "background": { "color": "#fbfaf6", "grid": { "enabled": true, "size": 25, "color": "#ecebe3" } },
      "layers": [
        { "id": "furniture", "name": "Furniture", "visible": true, "locked": false, "opacity": 1 }
      ],
      "elements": [
        {
          "id": "t1",
          "type": "table-round",
          "layer": "furniture",
          "x": 130, "y": 110, "width": 90, "height": 90, "rotation": 0,
          "label": "T1", "showLabel": true,
          "style": { "fill": "#d9b38c", "stroke": "#9c7b54" },
          "props": { "seats": 4, "status": "available", "tooltip": "<b>Table 1</b>" },
          "actions": [
            { "on": "click", "do": "cycle", "prop": "status",
              "values": ["available", "reserved", "occupied"] }
          ]
        }
      ]
    }
  ]
}

Missing fields are filled in from each type's defaults during load(), so hand-written documents can be terse. Coordinates are in pixels; meta.scale (pixels per real-world unit) lets you map them back to metres/feet.

  • showLabel is opt-in: an element's label is only drawn when showLabel: true. (text and wc always render their text.)
  • props.tooltip provides default HTML shown on hover (overridable via el.setHoverContent).
  • Legacy single-plan documents (top-level elements/layers/size, no floors) are auto-migrated into a one-floor building on load — older files keep working.

Built-in element types

floor (a rectangular room/container with solid wall borders — use this instead of assembling four wall bars), room (dashed zone), wall, door (single-leaf swing), door-double (two leaves), door-slide (sliding on the wall), window, stairs, entrance (entrance/exit), table-round, table-rect, chair, sofa, ac, plant, wc, text, image (custom PNG/SVG via props.src), plus generic rect / circle.

Doors and windows snap to walls

Doors and windows are wall-mounted by default: dragging one snaps it to the nearest wall and rotates it to match the wall's angle. Walls come from three sources on the active floor: floor element perimeters, room element borders and standalone wall elements (use those for partitions inside a room).

Per-element override — set props.snap = false to free a door/window from the walls (or true on any other type to make it snap too):

{ "type": "door-double", "x": 350, "y": 0, "width": 140, "height": 70,
  "props": { "open": true, "snap": false /* free placement */ } }

Programmatic API:

import { getWallSegments, snapToWalls, snapsToWall } from 'floorist';
const segs = getWallSegments(model.activeFloor);
const hit  = snapToWalls({ x: 700, y: 50 }, segs); // { point, angleDeg, segment }
// a whole room as ONE element (walls = the rectangle's border)
{ "type": "floor", "x": 40, "y": 40, "width": 920, "height": 600,
  "label": "Dining Hall",
  "style": { "fill": "#fdfbf6", "stroke": "#42423d", "wall": 14, "radius": 6 } }

Actions

Attach behaviours to any element via its actions array. Built-in do handlers:

| do | params | effect | |----------|------------------------------|----------------------------------------| | toggle | prop | flip a boolean prop (door open, ac on) | | set | prop, value | set a prop to a fixed value | | cycle | prop, values[] | advance a prop through a list | | link | url, target | host opens a URL (via element-action)| | emit | name, payload | notify the host only |

on can be click, dblclick or hover.

Floors & rooms

el.getFloors();                 // [{ id, name, level, count }]
el.setActiveFloor('f1');        // switch storey (fires "floor-change")
el.addFloor({ name: 'Roof' });
el.duplicateFloor('ground');
el.removeFloor('f2');           // keeps at least one floor

// rooms = floor/room elements on the active floor
el.getRooms();                  // [{ id, type, label }]
el.focusElement(roomId);        // frame/zoom the camera onto it

In edit mode, clicking a room/floor container selects it and the elements inside it, so the whole room moves as a group.

Import / export / share

el.getDocument();               // the whole building (serializable)
el.exportJSON();                // pretty-printed JSON string
el.model.exportFloor('f1');     // a building containing only that floor

import { buildShareUrl, buildEmbedCode, parseShareHash, encodeShare, decodeShare } from 'floorist';

const link  = buildShareUrl(el.getDocument());          // ...#data=<base64>&mode=view
const embed = buildEmbedCode(el.model.exportFloor('f1')); // <iframe src="...#data=...">

// on load, hydrate from a share link (no backend needed):
const { data, mode } = parseShareHash(location.hash);
if (data) { el.load(data); if (mode) el.setMode(mode); }

Import is just el.load(JSON.parse(fileText)). The data travels inside the URL hash, so links and embeds work without any server.

Extending

import { registerType, registerActionHandler } from 'floorist';

// custom element type — draw in the element's LOCAL frame (0,0 = top-left)
registerType({
  type: 'piano',
  category: 'furniture',
  label: 'Piano',
  icon: '🎹',
  defaults: { width: 120, height: 70, style: { fill: '#222' } },
  draw(ctx, el /*, env */) {
    ctx.fillStyle = el.style.fill;
    ctx.fillRect(0, 0, el.width, el.height);
  },
});

// custom action handler
registerActionHandler('reserve', ({ model, el }) => {
  model.updateElement(el.id, { props: { status: 'reserved' } });
  return { mutated: true, effect: { kind: 'reserved' } };
});

Headless / framework use

FloorPlanModel is UI-free — generate, validate and serialize plans on a server or in tests:

import { FloorPlanModel } from 'floorist';
const model = new FloorPlanModel(doc);
model.addElement({ type: 'table-round', x: 100, y: 100 });
const json = JSON.stringify(model.toJSON());

Editor shortcuts (edit mode)

Drag move · corner handles resize · Delete/Backspace remove · ⌘/Ctrl+D duplicate · ⌘/Ctrl+Z / ⇧⌘/Ctrl+Z undo/redo · arrows nudge (Shift+arrow = 10px) · Space+drag or middle-mouse pan · wheel zoom · marquee-drag on empty space to multi-select.

Project layout

src/
  index.js                 public API (registers the element)
  component/floor-plan.js   the <floor-plan> custom element
  core/   schema · model · geometry · actions · share
  render/ renderer · camera · shapes
  elements/registry.js      extensible element types
  editor/controller.js      pointer/keyboard interactions
demo/samples.js             example floor plans
demo.html                   interactive demo

Testing & coverage

Headless unit tests run with Node's built-in test runner — no extra dev dependency on Jest/Vitest. Coverage is produced by Node itself (--experimental-test-coverage) and the summary below is regenerated by npm run build (or npm run coverage).

npm test          # tsc + node --test test/
npm run coverage  # tests with coverage + updates the table at the top

The overall percentage shown in the badge at the top of this README is regenerated on every npm run build.

Publishing

The npm package ships only the compiled dist/ (JS + .d.ts + source maps), README.md and LICENSE. To publish:

npm run build              # compiles src/*.ts → dist/
npm pack --dry-run         # sanity-check the file list
npm publish --access public

prepublishOnly runs clean + build so the published artifact always matches the current source.

License

MIT