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

@prnt/dagr-render

v0.1.4

Published

WebGPU renderer for Dagr: a three.js WebGPURenderer scene with SDF shapes and spring animation.

Readme

@prnt/dagr-render

The renderer behind Dagr: a three.js WebGPURenderer scene drawing nodes as signed distance fields, instanced, with a DOM overlay for the content that has to be readable and springs for the content that has to move.

pnpm add @prnt/dagr-render three

three is a peerDependency (>=0.180.0 <1.0.0), so you install it yourself and there is exactly one copy of it. The peer is marked optional, because the core entry below does not need it, so a package manager will not install it for you: the full entry fails to import without it.

import { createRenderer } from '@prnt/dagr-render';

const renderer = await createRenderer({ canvas });
renderer.setNodes([
  {
    id: 'chapter-3',
    shape: 'roundedRect',
    center: { x: 0, y: 0 },
    size: { width: 200, height: 80 },
    cornerRadius: 16,
    fillColor: 0xfb8500,
    glowColor: 0xffb703,
    glowWorld: 20,
  },
]);
renderer.render();

Read this first: setNodes does not take a LayoutResult

It takes scene nodes with their own centres, sizes, shapes and colours, and that is deliberate rather than an omission. Naming a LayoutResult would make @prnt/dagr-layout a dependency of this package, and the y-down to y-up conversion belongs to whoever owns the layout, not to the thing drawing it. @prnt/dagr-react is where the two are joined; if you are wiring them yourself, that conversion is your one line.

A node keeps its instance handle across setNodes calls, which keeps its instance-buffer identity stable while dense slots move. Springs and picking ids use the caller's node id instead, so they also survive a shape change that has to replace the handle.

Springs, the scene, and the loop

createNodeMotion is the delta consumer. It holds one spring per node, keyed by your node id and never by a renderer handle, because where a node is on its way to is a fact about the node rather than about the slot it draws from:

import { createNodeMotion } from '@prnt/dagr-render';

const motion = createNodeMotion();
motion.resync([
  { id: 'plan', center: { x: 0, y: 0 } },
  { id: 'draft', center: { x: 0, y: 80 } },
]);
motion.apply({
  added: [{ id: 'ship', center: { x: 0, y: 120 } }],
  moved: [{ id: 'plan', center: { x: 0, y: 40 } }],
  removed: ['draft'],
});
const frame = motion.advance(dtSeconds);
frame.settled; // true when nothing is moving any more
for (const node of frame.nodes) {
  node.center; // where the spring has got to. Read it, do not mutate it
  node.departing; // removed, and still on its way out
}

apply takes a NodeMotionDelta, which is @prnt/dagr-layout's delta in this package's coordinates: centres in world units, y up. The conversion is yours, for the same reason setNodes takes no LayoutResult. It follows the same three rules the layout delta does, so absent means unchanged and a node you do not name does not move.

createEdgeMotion is the same thing for routes, and it takes the delta's edge lists. An edge is a polyline whose vertex count changes between two routes, so it resamples both onto the union of their own arc-length parameters first: every vertex of each route survives exactly, and the settled drawing is the layout's answer to the bit. alignRoutes is that correspondence on its own if you would rather animate edges your own way. A shared edge whose route changes animates, while a removed and added edge under one id is seeded at rest on the new directed route because it is a replacement, not a reroute.

createSceneMotion drives both, plus the drawing's box, from one delta and one clock, and createMotionLoop is the clock:

import { createMotionLoop, createSceneMotion } from '@prnt/dagr-render';

const motion = createSceneMotion();
motion.resync({ nodes, edges, bounds }); // once, from the first layout

const loop = createMotionLoop({
  frame(dtSeconds) {
    const frame = motion.advance(dtSeconds);
    renderer.setNodes(frame.nodes.map(dress));
    renderer.setEdges('flow', frame.edges.map(draw));
    renderer.render();
    return frame.settled; // the loop stops asking for frames when this is true
  },
});

// on every relayout:
motion.apply({ nodes: nodeDelta, edges: edgeDelta, bounds: newBox });
loop.wake();

A scene delta is applied across nodes, edges and the box or not at all. A loop is woken rather than started: a wake while it is running is the frame it was going to run anyway, and the first frame after every wake steps by zero so an idle hour is not a jump. Already have a coalesced requestAnimationFrame of your own? Pass it as scheduler and there is one loop, not two. The box is sprung as a centre and two half-extents so it never turns inside out on the way; the camera does not read it unless you call fitBounds on it yourself.

Two things worth knowing about the springs. A settled spring snaps exactly onto its target rather than stopping within a tolerance, because a permanent residual does not read as one node slightly misplaced, it reads as a rank of aligned nodes ending a hundredth of a unit apart. And applying a delta is all or nothing: a half-applied delta would hand you a desync signal after already moving the thing you would resync from, so a bad delta raises MotionDesyncError (MOTION_DESYNC) having changed nothing.

Backends

WebGPU where the browser has an adapter, WebGL2 otherwise, and renderer.backend says which one actually drew. Do not probe 'gpu' in navigator and branch on it: that is true on machines where requestAdapter() then returns null, so a capability probe before init() is a lie. Let createRenderer resolve and read the answer back.

To choose a non-GPU fallback UI before mounting, await detectBackendSupport() returns { webgpu, webgl2, preferred }. It requests an actual adapter (so a device exposing navigator.gpu with none reports webgpu: false), tries one throwaway WebGL 2 context, and never throws. preferred === null means neither backend can start. It says a backend can start, not how fast the scene runs.

Wheel and key arithmetic

wheelZoomFactor, canvasPoint and keyCommand are the pure functions between a DOM event and Camera2D.zoomAtScreen / panByScreen, the same ones <DagrCanvas navigation> uses. See the renderer page.

The overlay

createRichNodes places DOM over the canvas in world coordinates, in tiers gated by zoom, so names appear before cards do. Elements are pooled, which means a tier must clear its own per-node state on every bind.

The three-free entry

@prnt/dagr-render/core exports Camera2D, fitZoom and shapeEdgePath, with their types, from modules that never import three: not at runtime, and not in their type declarations.

import { Camera2D, shapeEdgePath } from '@prnt/dagr-render/core';

Use it when you want the camera or the edge-path arithmetic and no renderer: an SVG or DOM drawing, or a server render. The full entry imports three/webgpu at module scope, so a server that externalizes its dependencies loads three.js to import it, and the core entry is how you avoid that.

They are the same objects the full entry exports. A camera built from one entry is an instanceof the other's Camera2D. three is an optional peer, so a consumer of only the core entry does not install it at all.

Documentation

The scene model, the shapes, the instancing, the overlay tiers and the measurements are on the renderer page.

MIT © prnt.design

Node groups

createNodeGroupLayer({ parent, camera }) draws transparent labeled boundaries around explicit node sets. nodeGroupBounds(nodes, group) computes their world bounds for camera focus. See the grouping guide for synchronization, accessibility, and layout limitations.

Content navigation limits

Call camera.setContentBounds(bounds, nodeSize, padding, nodes) with y-up world bounds and a representative node size to constrain every camera mutation, including wheel anchors, direct setters, fitting, and resize. The default node size is 160 by 80 and padding is 0.05. The zoom range runs from full content to a single node. Smaller axes stay centered; other axes pan inside the padded bounds. Optional nodes supplies node bounds and keeps part of a node visible even in sparse regions. These bounds also expand the content bounds when needed. At a boundary, keeping content visible takes precedence over cursor anchoring. setContentBounds(null) clears these constraints and restores the explicit numeric zoom range. Explicit numeric limits can further restrict content zoom; when their ranges do not overlap, the larger minimum wins.

DagrCanvas supplies these bounds and real node sizes automatically.

Edge paths

shapeEdgePath(points, { style: 'smooth' }) returns sampled points for curved edges. Use style: 'orthogonal' for axis-aligned doglegs, or the default polyline for routed segments. direction defaults to horizontal and also accepts vertical; smoothing tolerance defaults to 0.5 world units. Endpoints and route anchors are preserved. This shapes presentation, without obstacle avoidance. Pass the result to SVG paths or renderer edges using curve: 'polyline' so they are not smoothed twice.