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

dag-browser-widget

v0.2.0

Published

Dependency-free logic + a thin React view for browsing a DAG (polyhierarchy) as a collapsible tree, de-duplicating nodes that appear under multiple parents.

Readme

dag-browser-widget

npm license

Live demo →

Browse a DAG (polyhierarchy) — or any directed graph, cycles included — as a collapsible tree. A node that legitimately lives under several parents is unfolded once in full; its other parents become compact "★ also under …" links instead of duplicate subtrees. When an edge loops back to a node already on the path (a cycle, including a self-loop), that edge becomes a "⟲ loops back to …" marker instead of recursing forever. Selected nodes that scroll off-screen after a collapse get a "↳ reveal at …" breadcrumb back.

The package is two layers:

  • dag-browser-widget/core — ~400 lines of dependency-free TypeScript. No React, no DOM. Unfolds the DAG, computes which rows are visible under collapse, computes the rails (├ │ └), the also-under cross-references, and the reveal-at links. Fully unit-tested.
  • dag-browser-widget — a thin React view over the core. Plain React + inline styles + one small CSS file. No MUI, no router, no other UI deps.

Install

npm install dag-browser-widget

react and react-dom (>=18) are peer dependencies. Import the stylesheet once in your app:

import 'dag-browser-widget/styles.css'

Quick start

import { DagBrowser, type Node } from 'dag-browser-widget'
import 'dag-browser-widget/styles.css'

const nodes: Node[] = [
  { id: 'jazz', name: 'Jazz', parentIds: [] },
  { id: 'rock', name: 'Rock', parentIds: [] },
  // A node with two parents — this is what makes it a DAG, not a tree:
  { id: 'fusion', name: 'Jazz Fusion', parentIds: ['jazz', 'rock'] },
]

export function Example() {
  return <DagBrowser nodes={nodes} selected={['fusion']} />
}

The Node contract

The entire input is a flat list of nodes:

type Node = { id: string; name: string; parentIds: string[] }

A tree is just a DAG where every node has parentIds.length <= 1. Convert your own data into Node[]; the widget does the rest. parentIds pointing at ids not present in the list are ignored (those nodes become roots).

Who owns what (important)

The boundary between you (the consumer) and the widget is deliberately small:

| You own | The widget owns | | --- | --- | | selected — which node ids matter to your app | expanded / collapse state | | Row body: name, badges, highlight, buttons (renderRow) | rails, the toggle pill, child counts | | What "selecting" means and how it looks | the also-under and reveal-at links | | Your own click / hover / navigation handlers | the exit animation |

You never manipulate the widget's internal visibility directly. You pass selected, and the widget:

  1. always shows every root (top-level node),
  2. on mount, opens the path from a root down to each selected node, and
  3. shows a "↳ reveal at …" breadcrumb on the closest visible ancestor of any selected node that is currently collapsed off-screen — so a selection set from outside (a URL param, an external list) is never lost.

Changing selected at runtime updates the highlight and the reveal-at links but does not reset the user's expand/collapse state. (In practice you select by clicking a visible row, so there's nothing to reveal; the reveal-at breadcrumb is for selections that arrive from outside the widget.) With no selected, the widget opens at levelsExpanded levels (default: roots' children shown).

Expanding rows

Each row's toggle pill (//) controls its children:

  • Click — expand one level (show direct children) or collapse.
  • Double-click — expand the entire subtree below that row, when it runs deeper than its direct children. The tooltip shows the descendant count, e.g. "Click: show 4 children · double-click: show all 11".

Highlighting is your job

The widget does not style "selected" rows. renderRow receives isSelected; do the highlighting (and any select/deselect buttons) yourself:

<DagBrowser
  nodes={nodes}
  selected={selected}
  renderRow={({ node, isSelected }) => (
    <span style={{ background: isSelected ? '#fef3c7' : undefined }}>
      {node.name}
      <button onClick={() => toggleSelected(node.id)}>
        {isSelected ? 'unselect' : 'select'}
      </button>
    </span>
  )}
/>

This keeps the widget ignorant of badges, dialogs, routing, and selection semantics — all of which differ per app.

<DagBrowser> props

| Prop | Type | Default | Notes | | --- | --- | --- | --- | | nodes | Node[] | — | The DAG. Changing the array identity restarts the widget (see below). | | selected | string[] | [] | Node ids your app cares about (see above). | | renderRow | (ctx: RenderRowContext) => ReactNode | name only | Renders each row's body. | | levelsExpanded | number | 1 | How many levels to open on mount. 1 shows the roots' children; 0 = all collapsed. Initial-state only. | | animationMs | number | 220 | Collapse/expand duration; 0 disables. | | onMessage | (msg: DagBrowserMessage) => void | — | Feedback when a cross-ref link is followed (see below). | | className | string | — | Added to the root element. |

RenderRowContext = { node, isSelected, toggleState, depth, backedge? }. backedge is { path, selfLoop } on a cycle row (else undefined), so a custom renderRow can style loop-back rows itself.

Cross-reference links: arrows and onMessage

The italic links on a row — "★ also under …", "⟲ loops back to …", "↳ reveal at …" — all point at another copy/ancestor of that node elsewhere in the tree. Two affordances help you see and follow them:

  • Hover a row (or a single link) to draw a transient curved arrow from the row to its target(s). Hovering the row body shows every arrow that row has at once; hovering one link shows just that one. The arrows are drawn on hover and cleared on leave — no persistent layout tracking.
  • Click a link to follow it. If the target is off-screen it's pinned visible, scrolled to, and flashed; if it's already on screen the click just scrolls+flashes it (so it never looks like a dead click). Pass onMessage to substitute your own feedback (a toast/snackbar) — when you do, the built-in flash is suppressed so feedback isn't doubled. The widget still scrolls the target into view either way.
type DagBrowserMessage = {
  kind: 'already-visible' | 'revealed' // was the target already on screen?
  targetId: string                     // node id of the target copy
  targetPosIdx: number                 // its position in the unfolding
  targetPath: string                   // slash-joined path shown on the link
  direction: 'up' | 'down'             // is the target above or below the click?
}

nodes changes restart the widget

A new nodes array means "different data — start over": the widget remounts and re-seeds from scratch, discarding the current expand/collapse state. So pass a stable / memoized nodes and only build a new array when the data actually changes (rebuilding it every render would reset the widget every render). You do not need a React key for this — the widget keys itself internally.

Changing selected or levelsExpanded, by contrast, never resets anything the user has opened.

Using the core without React

The core is published separately so you can drive your own renderer (a different framework, a canvas, tests, server-side analysis):

import {
  buildGraph,
  fullUnfolding,
  computeVisible,
  decorateRows,
  seedFromSelected,
} from 'dag-browser-widget/core'

const graph = buildGraph(nodes)
const unfolding = fullUnfolding(graph)               // DAG -> rows (one per path)
const { forceVisible, expanded } = seedFromSelected(unfolding, ['fusion'])
const visible = computeVisible(unfolding, forceVisible, expanded)
const rows = decorateRows(unfolding, visible, graph, new Set(['fusion']))
// `rows` carries rails, toggle state, alsoUnderPaths, revealAt — ready to draw.

See src/core/*.ts for the full API and src/core/*.test.ts for worked examples of every function.

Demos

npm install
npm run dev      # opens the demo app

Three demos: a music-genre DAG (multi-parent genres show the "★ also under" de-duplication), a plain file tree (shows the widget degrades to an ordinary collapsible tree when the data has no multi-parent nodes), and a cyclic dependency graph (packages that depend on each other, plus a self-loop, show the "⟲ loops back to …" markers).

The genre demo's data is derived from MusicBrainz, whose core data is public domain (CC0 1.0).

Develop

npm run typecheck   # tsc --noEmit
npm test            # vitest (core unit tests)
npm run build       # builds dist/ (ESM + .d.ts + css)
npm run build:demo  # builds the demo app to dist-demo/

Performance note

Unfolding is eager: every path to every node becomes a row. For the small class/containment/taxonomy graphs this is built for, that's fine. A DAG with very high fan-in could blow up the path count — the visibility layer is decoupled from how rows are produced (decorateRows only needs rows with a parentIdx), so a lazy/bounded unfolder can be dropped in later without touching the visibility logic.

Cycles are safe. The unfolder never recurses into a node already on the current root-to-here path; instead it emits a single leaf back-edge row (a ⟲ loops back to … marker) pointing at the ancestor it would have re-entered. So a self-loop or a long cycle adds exactly one marker row per back-edge, not an infinite descent. A strongly-connected component with no parentless entry point (nothing outside it points in) would otherwise be unreachable from any root, so buildGraph promotes one node of each such orphaned component to a synthetic root — every node still renders.

License

GPL-3.0-or-later (see LICENSE).

If the copyleft terms don't fit your use, I'm happy to discuss alternatives — I can grant an individual/commercial license, or relicense the whole project under a permissive license (Apache-2.0 / MIT) on request. Open an issue or reach out.

The MusicBrainz-derived genre data used in the demo is public domain (CC0) and is not affected by this license.