A module graph
A graph reached through a tree. The focus is a tagged union, and a module’s scene is radial (focusId): what it imports as discs, what imports it as squares, the edges drawn. Every neighbour drills into its own neighbourhood, so you can walk round the import cycles. It logs every event, and has a toggle that turns activation into “open details”.
The frame below runs the example built from exactly these files. Start with source.ts. Every
example shares one small page shell (packages/core/examples/shared/ExampleShell.tsx): it keeps the focus in the
URL hash, builds a breadcrumb from onSceneChange, and shows the selected item from onNodeSelect.
import { createSceneSource, type ExplorerCluster, type ExplorerEdge, type ExplorerNode, type ExplorerScene, type SceneSource,} from "@data-xplorer/core";import { delay } from "../shared/delay";import { importersOf, IN_CYCLE, MODULES, modulesIn, PACKAGES, type Module } from "./data";
/** * A graph, reached through a tree. The focus is a small tagged union, which the explorer treats * as opaque: * * - `null`: every package, as clusters (packed); * - a package: its modules, as nodes (packed); * - a module: its neighbourhood (radial, because the scene sets `focusId`): the module in the * middle, what it imports as discs, what imports it as squares, joined by typed edges. * * Every neighbour drills into its own neighbourhood, so you can walk the graph sideways forever. * Cycles cost nothing: the explorer only loads the scenes you actually visit, each once. */export type Focus = { kind: "package"; pkg: string } | { kind: "module"; id: string };
const href = (focus: Focus) => `#${encodeFocus(focus)}`;
export const encodeFocus = (focus: Focus) => focus.kind === "package" ? `package:${focus.pkg}` : `module:${focus.id}`;
export function decodeFocus(hash: string): Focus | null { const [kind, value] = [hash.slice(0, hash.indexOf(":")), hash.slice(hash.indexOf(":") + 1)]; if (kind === "package" && PACKAGES.includes(value)) return { kind: "package", pkg: value }; if (kind === "module" && MODULES.has(value)) return { kind: "module", id: value }; return null;}
function marksFor(module: Module): string[] { return [ `${module.lines} lines`, `imports ${module.imports.length}`, `imported by ${importersOf(module.id).length}`, ...(IN_CYCLE.has(module.id) ? ["on an import cycle"] : []), ];}
function moduleNode(module: Module, shape: ExplorerNode<Focus>["shape"]): ExplorerNode<Focus> { const focus: Focus = { kind: "module", id: module.id }; return { id: module.id, label: module.name, sublabel: module.pkg, weight: module.lines, marks: marksFor(module), href: href(focus), drillsInto: focus, shape, // No ramp: each module keeps a stable colour of its own wherever it appears. ramp: null, emphasis: IN_CYCLE.has(module.id) ? ["ring"] : [], };}
function packageCluster(pkg: string): ExplorerCluster<Focus> { const modules = modulesIn(pkg); const focus: Focus = { kind: "package", pkg }; return { id: pkg, label: pkg, sublabel: `${modules.length} modules`, weight: modules.length, marks: [`${modules.length} modules`], href: href(focus), drillsInto: focus, shape: "cloud", truncated: null, };}
/** US071 — packed scenes have edges too: a package depends on another when any of its modules * imports one of the other's. The packed layout ignores edges; they are only drawn. */function packageEdges(): ExplorerEdge[] { const pairs = new Set<string>(); for (const module of MODULES.values()) { for (const id of module.imports) { const other = MODULES.get(id)!.pkg; if (other !== module.pkg) pairs.add(`${module.pkg}->${other}`); } } return [...pairs].sort().map((pair) => { const [sourceId, targetId] = pair.split("->") as [string, string]; return { id: pair, sourceId, targetId, kind: "depends-on" }; });}
/** Imports between modules of the same package. */function importsWithin(pkg: string): ExplorerEdge[] { return modulesIn(pkg).flatMap((module) => module.imports .filter((id) => MODULES.get(id)!.pkg === pkg) .map((id) => ({ id: `${module.id}->${id}`, sourceId: module.id, targetId: id, kind: "imports" })), );}
function buildScene(focus: Focus | null): ExplorerScene<Focus> { if (focus === null) { return { id: "root", heading: "All packages", clusters: PACKAGES.map(packageCluster), nodes: [], edges: packageEdges(), }; } if (focus.kind === "package") { return { id: focus.pkg, heading: focus.pkg, clusters: [], nodes: modulesIn(focus.pkg).map((m) => moduleNode(m, "disc")), edges: importsWithin(focus.pkg), }; }
const centre = MODULES.get(focus.id)!; const imports = centre.imports.map((id) => MODULES.get(id)!); // A module that both imports and is imported by the centre is drawn once, as an import. const importers = importersOf(centre.id).filter((m) => !centre.imports.includes(m.id)); const edges: ExplorerEdge[] = [ ...imports.map((m) => ({ id: `${centre.id}->${m.id}`, sourceId: centre.id, targetId: m.id, kind: "imports", })), ...importers.map((m) => ({ id: `${m.id}->${centre.id}`, sourceId: m.id, targetId: centre.id, kind: "imported-by", })), ]; return { id: centre.id, heading: centre.id, focusId: centre.id, // this is what makes the scene radial clusters: [], nodes: [ moduleNode(centre, "disc"), ...imports.map((m) => moduleNode(m, "disc")), ...importers.map((m) => moduleNode(m, "square")), ], edges, };}
export function moduleGraphSource(): SceneSource<Focus> { return createSceneSource<Focus>({ keyOf: (focus) => (focus === null ? "root" : encodeFocus(focus)), load: async (focus) => { await delay(120); return buildScene(focus); }, parentOf(focus) { if (focus.kind === "package") return { kind: "anchored", focus: null, anchorId: focus.pkg }; // A module lives in exactly one package, and appears there under its own id. (Walking // sideways and then zooming out retraces your steps instead: the explorer remembers.) return { kind: "anchored", focus: { kind: "package", pkg: MODULES.get(focus.id)!.pkg }, anchorId: focus.id, }; }, });}import { useRef, useState } from "react";import { createRoot } from "react-dom/client";import type { ExplorerEdges, ExplorerItem, ExplorerSceneEvent } from "@data-xplorer/core";import { ExampleShell } from "../shared/ExampleShell";import { MODULES } from "./data";import { decodeFocus, encodeFocus, moduleGraphSource, type Focus } from "./source";
const source = moduleGraphSource();const LOG_LINES = 8;
/** US071 — the edges, styled per kind: imports solid with an arrow, imported-by dashed and bowed so * a pair that imports both ways reads as two lines, and package dependencies faint. */const EDGE_STYLES: ExplorerEdges["styles"] = { imports: { arrow: "end", width: 1.5, alpha: 0.6 }, "imported-by": { dash: [5, 4], arrow: "end", curve: 0.15, alpha: 0.5 }, "depends-on": { arrow: "end", width: 2, alpha: 0.35, curve: 0.1 },};
// Where a scene lives, not the path walked to it: a module sits in its package.function trailOf(focus: Focus | null): string[] { if (focus === null) return ["All packages"]; if (focus.kind === "package") return ["All packages", focus.pkg]; const module = MODULES.get(focus.id)!; return ["All packages", module.pkg, module.name];}
function ModuleGraph() { // US071 — `onNodeActivate` is cancellable: with this on, a double-click (or Enter in the // outline) opens the details panel instead of entering the module. const [detailsOnActivate, setDetailsOnActivate] = useState(false); const [showEdges, setShowEdges] = useState(true); const [details, setDetails] = useState<ExplorerItem<Focus> | null>(null); const [log, setLog] = useState<string[]>([]); const [camera, setCamera] = useState(""); const counter = useRef(0);
function note(line: string) { counter.current += 1; const numbered = `${counter.current}. ${line}`; setLog((lines) => [numbered, ...lines].slice(0, LOG_LINES)); } const scene = (event: ExplorerSceneEvent<Focus>) => `${event.heading} (${event.direction})`;
return ( <ExampleShell title="A module dependency graph" intro={ <p> An app's imports. Packages hold modules; enter a module to see its neighbourhood, with what it imports as discs and what imports it as squares. Every neighbour can be entered in turn, so you can walk the graph sideways. Ringed modules sit on an import cycle: try{" "} <a href="#module:core/store">core/store</a> and follow its imports round. Edges are drawn in every scene, styled by kind, and listed for screen readers as links. The log below shows the explorer's events as they fire. </p> } source={source} encode={encodeFocus} decode={decodeFocus} trailOf={trailOf} edges={{ visible: showEdges, styles: EDGE_STYLES }} toolbar={ <> <button type="button" aria-pressed={showEdges} onClick={() => setShowEdges(!showEdges)}> Edges {showEdges ? "on" : "off"} </button> <button type="button" aria-pressed={detailsOnActivate} onClick={() => setDetailsOnActivate(!detailsOnActivate)}> Double-click: {detailsOnActivate ? "open details" : "enter"} </button> </> } events={{ onNodeHover: (item) => note(item ? `hover ${item.label}` : "hover nothing"), onNodeActivate: (event) => { note(`activate ${event.item.label} (${event.source})${detailsOnActivate ? ", cancelled" : ""}`); if (!detailsOnActivate) return; event.preventDefault(); setDetails(event.item); }, onLeave: (event) => note(`leave ${scene(event)}`), onEnter: (event) => note(`enter ${scene(event)}`), onBackgroundClick: ({ world }) => note(`background at ${Math.round(world.x)}, ${Math.round(world.y)}`), onCameraChange: ({ x, y, scale }) => setCamera(`${Math.round(x)}, ${Math.round(y)} at ×${scale.toFixed(2)}`), }} footer={ <section className="example__events" aria-label="Events"> {details && ( <div className="example__details"> <h2>{details.label}</h2> {details.sublabel && <p>{details.sublabel}</p>} <p> Opened by a cancelled activate. <a href={details.href ?? "#"}>Its own page</a>{" "} <button type="button" onClick={() => setDetails(null)}> Close </button> </p> </div> )} <p className="example__camera">Camera {camera || "not moved yet"}</p> <ol className="example__log"> {log.map((line) => ( <li key={line}>{line}</li> ))} </ol> </section> } /> );}
createRoot(document.getElementById("root")!).render(<ModuleGraph />);/** * A made-up app's import graph: modules grouped into packages, each importing others. It is not a * tree: `core/store` → `core/events` → `core/logger` → `core/store` is a cycle, and so is * `ui/Form` ↔ `ui/Field`. */export interface Module { id: string; // "package/name" pkg: string; name: string; lines: number; imports: string[];}
const raw: [id: string, lines: number, imports: string[]][] = [ ["core/store", 420, ["core/events", "core/logger"]], ["core/events", 180, ["core/logger"]], ["core/logger", 90, ["core/store"]], ["core/config", 120, []], ["core/http", 310, ["core/config", "core/logger"]], ["ui/Button", 140, ["ui/theme"]], ["ui/Field", 210, ["ui/theme", "ui/Form"]], ["ui/Form", 380, ["ui/Field", "ui/Button", "core/store"]], ["ui/Modal", 260, ["ui/Button", "ui/theme"]], ["ui/theme", 75, ["core/config"]], ["ui/Table", 520, ["ui/theme", "core/store"]], ["features/login", 290, ["ui/Form", "ui/Button", "core/http", "core/store"]], ["features/search", 340, ["ui/Field", "ui/Table", "core/http"]], ["features/settings", 230, ["ui/Form", "ui/Modal", "core/config"]], ["features/reports", 610, ["ui/Table", "ui/Modal", "core/http", "core/events"]], ["app/main", 60, ["app/router", "core/config", "core/logger"]], [ "app/router", 150, ["features/login", "features/search", "features/settings", "features/reports"], ],];
export const MODULES = new Map<string, Module>( raw.map(([id, lines, imports]) => { const [pkg, name] = id.split("/") as [string, string]; return [id, { id, pkg, name, lines, imports }]; }),);
export const PACKAGES = [...new Set([...MODULES.values()].map((m) => m.pkg))];
export const modulesIn = (pkg: string) => [...MODULES.values()].filter((m) => m.pkg === pkg);
export const importersOf = (id: string) => [...MODULES.values()].filter((m) => m.imports.includes(id));
/** * Which modules sit on an import cycle (Tarjan's strongly connected components). Computed on read * from the graph, never stored: it is something this example asserts, not something the data said. */export const IN_CYCLE: ReadonlySet<string> = (() => { let counter = 0; const index = new Map<string, number>(); const low = new Map<string, number>(); const stack: string[] = []; const onStack = new Set<string>(); const result = new Set<string>();
function visit(id: string) { index.set(id, counter); low.set(id, counter++); stack.push(id); onStack.add(id); for (const next of MODULES.get(id)!.imports) { if (!index.has(next)) { visit(next); low.set(id, Math.min(low.get(id)!, low.get(next)!)); } else if (onStack.has(next)) { low.set(id, Math.min(low.get(id)!, index.get(next)!)); } } if (low.get(id) === index.get(id)) { const component: string[] = []; let member: string; do { member = stack.pop()!; onStack.delete(member); component.push(member); } while (member !== id); if (component.length > 1) component.forEach((m) => result.add(m)); } }
for (const id of MODULES.keys()) if (!index.has(id)) visit(id); return result;})();