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

@spatial-kit/core

v0.2.0

Published

Framework-agnostic scene engine for interactive 2D editors: entities, viewport, events, hit testing and selection.

Readme

@spatial-kit/core

Scene engine for interactive 2D editors. Manages entities, viewport transforms, event pub/sub, hit testing, and selection. Framework-agnostic — no DOM, no browser APIs.

Installation

npm install @spatial-kit/core
# or
pnpm add @spatial-kit/core

Quick start

import { createCore, createEmptyScene } from '@spatial-kit/core';
import type { EntityId, LayerId } from '@spatial-kit/core';

const scene = createEmptyScene(
  { width: 2000, height: 1500 },
  ['background' as LayerId, 'objects' as LayerId],
);

const core = createCore({
  scene,
  viewport: {
    zoom: 1,
    pan: { x: 0, y: 0 },
    screenSize: { width: 800, height: 600 },
  },
});

core.add({
  id: 'item-1' as EntityId,
  layer: 'objects' as LayerId,
  bounds: { x: 100, y: 100, width: 60, height: 60 },
  selectable: true,
  data: { type: 'chair' },
});

core.on('selection:change', ({ selection }) => {
  console.log('selected ids:', selection);
});

core.setSelection(['item-1' as EntityId], 'replace');

API reference

createEmptyScene(size, layers)

Creates an empty scene with no entities.

const scene = createEmptyScene(
  { width: 2000, height: 1500 }, // world dimensions
  ['floor' as LayerId, 'furniture' as LayerId],
);

Layers are ordered bottom → top — index 0 is the bottommost layer. The order determines both rendering z-order and click hit-test priority (top layer wins).


createCore(props)

Creates the core engine instance.

const core = createCore({
  scene,
  viewport: {
    zoom: 1,
    pan: { x: 0, y: 0 },
    screenSize: { width: 800, height: 600 },
    minZoom: 0.1,  // optional, default 0.1
    maxZoom: 8,    // optional, default 8
  },
});

SpatialCore interface

The object returned by createCore. Pass this to renderers and adapters.

State properties

core.scene      // Scene — current scene (entities, layers, size)
core.viewport   // Viewport — current zoom, pan, screenSize
core.selection  // Set<EntityId> — current selection

All three are mutable references. The scene and viewport are updated in-place; the selection Set is cleared and refilled (the reference stays stable).


Coordinate transforms

core.worldToScreen(point: Vec2): Vec2
core.screenToWorld(point: Vec2): Vec2
worldToScreen:  screen = (world − pan) × zoom
screenToWorld:  world  = screen / zoom + pan

Use screenToWorld to convert a mouse position (screen coords) to a world position before hit testing.


Viewport methods

All viewport methods emit "viewport:change" after applying the change.

core.zoomAt(screenPoint: Vec2, delta: number): void

Multiplies the current zoom by (1 + delta), keeping screenPoint fixed in world space. delta = 0.1 = +10%, delta = -0.1 = −10%. Clamped to [minZoom, maxZoom].

core.panBy(deltaScreen: Vec2): void

Translates the viewport by a screen-space delta. Positive x shifts content left (viewport moves right in world space).

core.fitToBounds(bounds: Bounds, padding?: number): void

Adjusts zoom and pan so bounds fills the screen, with optional padding in world units.

core.fitToScene(padding?: number): void

fitToBounds using the union of all entity bounds. Falls back to scene.size if the scene is empty.

core.centerOn(id: EntityId, padding?: number): void

fitToBounds on a single entity's bounds. No-op if the entity does not exist.


Entity methods

All entity methods emit "entities:changed" after the operation.

core.add(entity: Entity): void

Adds an entity to the scene. The entity must have a unique id.

core.update(id: EntityId, patch: Partial<Entity>): void

Merges patch into the existing entity. The id field in patch is ignored — the entity id is immutable. No-op if the entity does not exist.

core.updateMany(patches: { id: EntityId; patch: Partial<Entity> }[]): void

Applies multiple patches in a single call and emits one "entities:changed" event with all updated ids. Equivalent to calling update in a loop but more efficient — use it when moving a group of selected entities together.

core.remove(id: EntityId): void

Removes an entity. If the entity was selected, also emits "selection:change" after removing it from the selection.


Selection

core.setSelection(ids: EntityId[], mode: SelectionMode): void

Updates the current selection and emits "selection:change".

| Mode | Behaviour | |------|-----------| | 'replace' | Clears current selection, adds all ids | | 'add' | Keeps current selection, adds all ids | | 'toggle' | Keeps current selection; adds each id if absent, removes if present |


Hit testing

core.hitTest(pointWorld: Vec2): EntityId | null

Returns the id of the topmost selectable entity whose bounds contain pointWorld, or null if none.

Z-order: iterates layers in reverse (top layer first), and entities within each layer in reverse insertion order (last-added is on top). Only entities with selectable: true are considered.

Always call core.screenToWorld on a mouse position before passing it to hitTest.


Event bus

core.on(event: string, handler: (payload) => void): () => void

Subscribes to an event. Returns an unsubscribe function — call it to remove the listener.

core.emit(event: string, payload: unknown): void

Emits a custom event. Built-in events are emitted automatically by core methods.


Events

| Event | Payload | Emitted by | |-------|---------|------------| | "viewport:change" | { viewport: Viewport } | zoomAt, panBy, fitToBounds, fitToScene, centerOn | | "entities:changed" | { type: "add" \| "update" \| "remove", ids: EntityId[] } | add, update, remove | | "selection:change" | { selection: EntityId[] } | setSelection, remove (when selected entity is removed) |

You can also emit and subscribe to custom events using the same core.on / core.emit API.


Types

type Vec2   = { x: number; y: number };
type Bounds = { x: number; y: number; width: number; height: number };

// Branded strings — never substitute with plain string
type EntityId = string & { __brand: 'EntityId' };
type LayerId  = string & { __brand: 'LayerId' };

interface Entity {
  id:          EntityId;
  layer:       LayerId;
  bounds:      Bounds;       // world-space bounding box
  selectable?: boolean;      // must be true to be returned by hitTest
  data?:       unknown;      // application payload — cast at callsite
}

interface Scene {
  size:     { width: number; height: number };
  layers:   LayerId[];              // ordered bottom-to-top
  entities: Map<EntityId, Entity>;
}

interface Viewport {
  zoom:       number;
  pan:        Vec2;
  screenSize: { width: number; height: number };
  minZoom?:   number;  // default 0.1
  maxZoom?:   number;  // default 8
}

type SelectionMode = 'replace' | 'add' | 'toggle';

Branded types: Cast string literals with 'my-id' as EntityId and 'my-layer' as LayerId. This prevents accidental mixing of ids across entity types.


Utility functions

These are exported for use in renderers and application code:

import {
  clamp,
  containsPointAABB,
  expandBounds,
  unionBounds,
  snapToGrid,
  hitTestResizeHandles,
  applyResizeDelta,
} from '@spatial-kit/core';

clamp(value, min, max)                  // numeric clamp
containsPointAABB(bounds, point)        // true if point is inside bounds (inclusive)
expandBounds(bounds, padding)           // grows bounds by padding in all directions
unionBounds(a, b)                       // smallest AABB containing both bounds
snapToGrid(value, gridSize)             // rounds value to the nearest multiple of gridSize

Resizing

Renderers use these to implement resize handles. They are exported so custom renderers can reuse the same behavior.

import type { ResizeDirection } from '@spatial-kit/core';
// 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw'

hitTestResizeHandles(selectedEntities, worldToScreen, screenPoint, handleSizePx = 8)
// → { entityId, direction } | null
// Checks the 8 handles (corners and edge midpoints) of each entity, in screen space.

applyResizeDelta(originalBounds, direction, worldDelta, snapSize?)
// → Bounds
// Moves the edges named by `direction` by `worldDelta`. When `snapSize` is set, the moved edges
// snap to the grid. Width and height never go below 1.

Example: resize the bottom-right corner of an entity by a pointer delta.

const handle = hitTestResizeHandles(
  [...core.selection].map((id) => core.scene.entities.get(id)!),
  (p) => core.worldToScreen(p),
  pointerScreen,
);

if (handle) {
  const start = core.scene.entities.get(handle.entityId)!.bounds;
  const bounds = applyResizeDelta(start, handle.direction, { x: 30, y: 10 }, 10);
  core.update(handle.entityId, { bounds });
}

Patterns

Creating branded ids

import type { EntityId, LayerId } from '@spatial-kit/core';

// Option 1: cast at point of use
core.add({ id: 'table-1' as EntityId, layer: 'furniture' as LayerId, ... });

// Option 2: helper factory
const entityId = (s: string) => s as EntityId;
const layerId  = (s: string) => s as LayerId;

Subscribing to events and cleaning up

const off = core.on('entities:changed', ({ type, ids }) => {
  if (type === 'remove') syncWithServer(ids);
});

// Later — unsubscribe
off();

Fitting the view on load

// After adding all initial entities
core.fitToScene(32); // 32px world-unit padding

// Or focus on a specific entity
core.centerOn('room-101' as EntityId, 16);

Accessing entity data

interface TableData {
  label: string;
  seats: number;
}

const entity = core.scene.entities.get('table-1' as EntityId);
const data = entity?.data as TableData | undefined;

License

MIT