Bundle the pure core API for Node consumers (#6)
* feat: extract pure core api * refactor: centralize core api helpers * build: bundle core api output
This commit is contained in:
+2
-1
@@ -18,4 +18,5 @@ node.zip
|
||||
.changeset/
|
||||
.test-comfy/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright-report/
|
||||
lib/
|
||||
|
||||
@@ -12,6 +12,8 @@ Use `.changeset/plans/` only when prior design context is needed.
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm build:lib
|
||||
pnpm test:lib
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm setup:e2e
|
||||
@@ -30,6 +32,8 @@ Version bumping:
|
||||
- Keep `.python-version` and `uv.lock` in sync with the publish workflow.
|
||||
- Local `.changeset/*` files are intentionally gitignored. Do not commit them.
|
||||
- Verify runtime behavior in ComfyUI before changing adapter logic or making claims about ComfyUI APIs.
|
||||
- `src/core.ts` is the public library entrypoint. It must stay free of ComfyUI-specific imports such as `index.ts`, `adapter.ts`, `runtime.ts`, `settings.ts`, `debug.ts`, `bounds.ts`, or `utils.ts`.
|
||||
- Library output is emitted to `lib/` by `pnpm build:lib` / `tsc -p tsconfig.lib.json`. The `prepare` script runs that build automatically for GitHub dependency consumers.
|
||||
|
||||
## Runtime Facts
|
||||
|
||||
|
||||
@@ -52,12 +52,29 @@ Add tokens to group titles to control how nodes are arranged:
|
||||
- `pnpm typecheck`
|
||||
- `pnpm test`
|
||||
- `pnpm build`
|
||||
- `pnpm build:lib` emits the pure library entrypoint to `lib/`
|
||||
- `pnpm test:lib` smoke-tests the built `comfyui-node-organizer/core` export
|
||||
- `pnpm setup:e2e` provisions a dedicated ComfyUI instance for browser tests and installs the exact workflow-template package required by the pinned ComfyUI checkout
|
||||
- `pnpm test:e2e` runs the full Playwright suite against that instance
|
||||
- Broad installed-template coverage is discovered live at E2E runtime from the pinned test ComfyUI environment, with one correctness-invariant test per installed workflow template
|
||||
|
||||
Checked-in repo fixtures live in `tests/fixtures/`. Visual regression is graph-canvas scoped and runs as part of the normal E2E suite.
|
||||
Visual regression baselines are platform-specific: committed snapshots use `-win32` and `-linux` suffixes.
|
||||
|
||||
## Library API
|
||||
|
||||
Most users can ignore this section. The extension works entirely inside ComfyUI.
|
||||
|
||||
`comfyui-node-organizer/core` exists for developers who want to reuse the layout engine outside ComfyUI, for example in a script, service, test harness, or workflow conversion tool.
|
||||
|
||||
It exposes a small pure API:
|
||||
- `normalizeWorkflowGeometry(...)` lays out plain workflow data and returns absolute node and group rectangles
|
||||
- `inferGroupMembership(...)` infers direct group membership from node and group rectangles
|
||||
|
||||
In practice: if you want "organize this workflow JSON the same way the extension would" without loading ComfyUI, this is the entrypoint.
|
||||
|
||||
Build it with `pnpm build:lib`. `pnpm test:lib` verifies that the built `comfyui-node-organizer/core` package can be imported by Node.
|
||||
|
||||
### Local CI
|
||||
|
||||
Install [act](https://github.com/nektos/act) and Docker, then run:
|
||||
|
||||
@@ -3,11 +3,23 @@
|
||||
"version": "2.0.0",
|
||||
"description": "ComfyUI extension to organize nodes",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./core": {
|
||||
"import": "./lib/core.js",
|
||||
"types": "./lib/core.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:lib": "vite build -c vite.config.lib.ts && tsc -p tsconfig.lib.json",
|
||||
"test:lib": "pnpm build:lib && node scripts/smoke-core-package.mjs",
|
||||
"watch": "tsc && vite build --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepare": "pnpm build:lib",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const coreModule = await import("comfyui-node-organizer/core");
|
||||
|
||||
assert.equal(typeof coreModule.inferGroupMembership, "function");
|
||||
assert.equal(typeof coreModule.normalizeWorkflowGeometry, "function");
|
||||
assert.equal(typeof coreModule.DEFAULT_FRAMEWORK_CONFIG, "object");
|
||||
+39
-86
@@ -17,7 +17,14 @@ import type {
|
||||
} from "./layout/types";
|
||||
import { parseLayoutToken } from "./layout/tokens";
|
||||
import { debugLog } from "./debug";
|
||||
import { isGroupInsideGroup, isNodeCenterInsideGroup } from "./group-geometry";
|
||||
import {
|
||||
inferGroupMembership,
|
||||
type Rect as MembershipRect,
|
||||
} from "./group-membership";
|
||||
import {
|
||||
fromNumericGroupLayoutId,
|
||||
toGroupLayoutId,
|
||||
} from "./layout/group-ids";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structural types for ComfyUI runtime objects
|
||||
@@ -63,16 +70,6 @@ export interface GraphLike {
|
||||
setDirtyCanvas?(fg: boolean, bg: boolean): void;
|
||||
}
|
||||
|
||||
function toGroupLayoutId(groupId: number): string {
|
||||
return `group:${groupId}`;
|
||||
}
|
||||
|
||||
function fromGroupLayoutId(groupId: string): number | null {
|
||||
if (!groupId.startsWith("group:")) return null;
|
||||
const parsed = Number(groupId.slice("group:".length));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract layout input from a ComfyUI graph
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -142,83 +139,39 @@ function buildGroupsFromGraph(graph: GraphLike): LayoutGroup[] {
|
||||
const graphGroups = graph._groups;
|
||||
if (graphGroups.length === 0) return [];
|
||||
|
||||
const graphGroupById = new Map<number, GraphGroup>();
|
||||
for (const group of graphGroups) {
|
||||
graphGroupById.set(group.id, group);
|
||||
}
|
||||
const nodeRects: MembershipRect[] = graph._nodes.map((node) => ({
|
||||
id: String(node.id),
|
||||
x: Number(node.pos[0]),
|
||||
y: Number(node.pos[1]),
|
||||
width: Number(node.size[0]),
|
||||
height: Number(node.size[1]),
|
||||
}));
|
||||
const groupRects: MembershipRect[] = graphGroups.map((group) => ({
|
||||
id: String(group.id),
|
||||
x: Number(group.pos[0]),
|
||||
y: Number(group.pos[1]),
|
||||
width: Number(group.size[0]),
|
||||
height: Number(group.size[1]),
|
||||
}));
|
||||
const memberships = inferGroupMembership(nodeRects, groupRects);
|
||||
const membershipsByGroupId = new Map(
|
||||
memberships.map((membership) => [membership.groupId, membership]),
|
||||
);
|
||||
|
||||
const childGroupIds = new Map<number, number[]>();
|
||||
const groupAreas = new Map<number, number>();
|
||||
for (const group of graphGroups) {
|
||||
groupAreas.set(group.id, Number(group.size[0]) * Number(group.size[1]));
|
||||
}
|
||||
return graphGroups.map((group) => {
|
||||
const membership = membershipsByGroupId.get(String(group.id));
|
||||
const token: LayoutToken | null = parseLayoutToken(group.title);
|
||||
|
||||
// Determine the nearest containing parent for each group.
|
||||
for (const inner of graphGroups) {
|
||||
let nearestParent: GraphGroup | null = null;
|
||||
let nearestArea = Infinity;
|
||||
|
||||
for (const outer of graphGroups) {
|
||||
if (outer.id === inner.id) continue;
|
||||
if (!isGroupInside(inner, outer)) continue;
|
||||
|
||||
const outerArea = groupAreas.get(outer.id) ?? Infinity;
|
||||
if (outerArea < nearestArea) {
|
||||
nearestParent = outer;
|
||||
nearestArea = outerArea;
|
||||
}
|
||||
}
|
||||
|
||||
if (nearestParent) {
|
||||
const children = childGroupIds.get(nearestParent.id) ?? [];
|
||||
children.push(inner.id);
|
||||
childGroupIds.set(nearestParent.id, children);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the LayoutGroup array
|
||||
const result: LayoutGroup[] = [];
|
||||
|
||||
for (const g of graphGroups) {
|
||||
const memberIds: string[] = [];
|
||||
for (const node of graph._nodes) {
|
||||
if (!isNodeInsideGroup(node, g)) continue;
|
||||
|
||||
let insideDirectChild = false;
|
||||
for (const childGroupId of childGroupIds.get(g.id) ?? []) {
|
||||
const childGroup = graphGroupById.get(childGroupId);
|
||||
if (childGroup && isNodeInsideGroup(node, childGroup)) {
|
||||
insideDirectChild = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!insideDirectChild) {
|
||||
memberIds.push(String(node.id));
|
||||
}
|
||||
}
|
||||
|
||||
const token: LayoutToken | null = parseLayoutToken(g.title);
|
||||
|
||||
result.push({
|
||||
id: toGroupLayoutId(g.id),
|
||||
title: g.title,
|
||||
memberIds,
|
||||
childGroupIds: (childGroupIds.get(g.id) ?? []).map(toGroupLayoutId),
|
||||
return {
|
||||
id: toGroupLayoutId(group.id),
|
||||
title: group.title,
|
||||
memberIds: membership?.nodeIds ?? [],
|
||||
childGroupIds: (membership?.childGroupIds ?? []).map((childGroupId) =>
|
||||
toGroupLayoutId(Number(childGroupId)),
|
||||
),
|
||||
...(token ? { token } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Check if inner group is spatially inside outer group */
|
||||
function isGroupInside(inner: GraphGroup, outer: GraphGroup): boolean {
|
||||
return isGroupInsideGroup(inner, outer);
|
||||
}
|
||||
|
||||
function isNodeInsideGroup(node: GraphNode, group: GraphGroup): boolean {
|
||||
return isNodeCenterInsideGroup(node, group);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -395,7 +348,7 @@ export function anchorSelectedGroupLayoutResult(
|
||||
);
|
||||
|
||||
for (const rootGroupId of selectedRootIds) {
|
||||
const originalGraphGroupId = fromGroupLayoutId(rootGroupId);
|
||||
const originalGraphGroupId = fromNumericGroupLayoutId(rootGroupId);
|
||||
if (originalGraphGroupId === null) continue;
|
||||
|
||||
const originalGroup = graphGroupById.get(originalGraphGroupId);
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
createLayoutAlgorithm,
|
||||
type LayoutAlgorithmName,
|
||||
} from "./layout/algorithm-factory";
|
||||
import { layoutWithGroups } from "./layout/framework";
|
||||
import { toGroupLayoutId } from "./layout/group-ids";
|
||||
import { parseLayoutToken } from "./layout/tokens";
|
||||
import {
|
||||
DEFAULT_FRAMEWORK_CONFIG,
|
||||
type FrameworkConfig,
|
||||
type LayoutEdge as InternalLayoutEdge,
|
||||
type LayoutGroup as InternalLayoutGroup,
|
||||
type LayoutNode as InternalLayoutNode,
|
||||
} from "./layout/types";
|
||||
|
||||
export {
|
||||
inferGroupMembership,
|
||||
type GroupMembership,
|
||||
type Rect,
|
||||
} from "./group-membership";
|
||||
|
||||
export interface WorkflowNode {
|
||||
readonly id: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly kind?: "node" | "subgraph-input" | "subgraph-output";
|
||||
}
|
||||
|
||||
export interface WorkflowEdge {
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
}
|
||||
|
||||
export interface WorkflowGroup {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly memberIds: ReadonlyArray<string>;
|
||||
readonly childGroupIds: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export interface WorkflowRect {
|
||||
readonly id: string;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export interface WorkflowMembership {
|
||||
readonly groupId: string;
|
||||
readonly nodeIds: string[];
|
||||
readonly childGroupIds: string[];
|
||||
}
|
||||
|
||||
export interface NormalizeWorkflowResult {
|
||||
readonly nodes: WorkflowRect[];
|
||||
readonly groups: WorkflowRect[];
|
||||
readonly memberships: WorkflowMembership[];
|
||||
}
|
||||
|
||||
export interface NormalizeWorkflowOptions {
|
||||
readonly algorithm?: LayoutAlgorithmName;
|
||||
readonly config?: Partial<FrameworkConfig>;
|
||||
}
|
||||
|
||||
export { DEFAULT_FRAMEWORK_CONFIG, type FrameworkConfig } from "./layout/types";
|
||||
|
||||
export function normalizeWorkflowGeometry(
|
||||
input: {
|
||||
readonly nodes: ReadonlyArray<WorkflowNode>;
|
||||
readonly edges: ReadonlyArray<WorkflowEdge>;
|
||||
readonly groups: ReadonlyArray<WorkflowGroup>;
|
||||
},
|
||||
options?: NormalizeWorkflowOptions,
|
||||
): NormalizeWorkflowResult {
|
||||
const config: FrameworkConfig = {
|
||||
...DEFAULT_FRAMEWORK_CONFIG,
|
||||
...options?.config,
|
||||
};
|
||||
const algorithm = createLayoutAlgorithm(
|
||||
options?.algorithm ?? "sugiyama",
|
||||
config,
|
||||
);
|
||||
|
||||
const nodes: InternalLayoutNode[] = input.nodes.map((node) => {
|
||||
if (node.kind === "subgraph-input") {
|
||||
return {
|
||||
id: node.id,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
kind: node.kind,
|
||||
layerConstraint: "first",
|
||||
};
|
||||
}
|
||||
|
||||
if (node.kind === "subgraph-output") {
|
||||
return {
|
||||
id: node.id,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
kind: node.kind,
|
||||
layerConstraint: "last",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
};
|
||||
});
|
||||
|
||||
const edges: InternalLayoutEdge[] = input.edges.map((edge) => ({
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
}));
|
||||
|
||||
const groups: InternalLayoutGroup[] = input.groups.map((group) => {
|
||||
const token = parseLayoutToken(group.title);
|
||||
|
||||
return {
|
||||
id: toGroupLayoutId(group.id),
|
||||
title: group.title,
|
||||
memberIds: [...group.memberIds],
|
||||
childGroupIds: group.childGroupIds.map(toGroupLayoutId),
|
||||
...(token ? { token } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const result = layoutWithGroups(nodes, edges, groups, algorithm, config);
|
||||
|
||||
return {
|
||||
nodes: input.nodes.flatMap((node) => {
|
||||
const position = result.positions.get(node.id);
|
||||
return position
|
||||
? [
|
||||
{
|
||||
id: node.id,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
groups: input.groups.flatMap((group) => {
|
||||
const bounds = result.groupBounds.get(toGroupLayoutId(group.id));
|
||||
return bounds
|
||||
? [
|
||||
{
|
||||
id: group.id,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
memberships: input.groups.map((group) => ({
|
||||
groupId: group.id,
|
||||
nodeIds: [...group.memberIds],
|
||||
childGroupIds: [...group.childGroupIds],
|
||||
})),
|
||||
};
|
||||
}
|
||||
+42
-24
@@ -3,41 +3,59 @@ export interface PositionedItem {
|
||||
readonly size: ArrayLike<number>;
|
||||
}
|
||||
|
||||
export interface RectGeometry {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export function isGroupInsideGroup(
|
||||
inner: PositionedItem,
|
||||
outer: PositionedItem,
|
||||
): boolean {
|
||||
const ix = Number(inner.pos[0]);
|
||||
const iy = Number(inner.pos[1]);
|
||||
const iw = Number(inner.size[0]);
|
||||
const ih = Number(inner.size[1]);
|
||||
|
||||
const ox = Number(outer.pos[0]);
|
||||
const oy = Number(outer.pos[1]);
|
||||
const ow = Number(outer.size[0]);
|
||||
const oh = Number(outer.size[1]);
|
||||
|
||||
return (
|
||||
ix >= ox && iy >= oy && ix + iw <= ox + ow && iy + ih <= oy + oh
|
||||
);
|
||||
return isRectInsideRect(toRectGeometry(inner), toRectGeometry(outer));
|
||||
}
|
||||
|
||||
export function isNodeCenterInsideGroup(
|
||||
node: PositionedItem,
|
||||
group: PositionedItem,
|
||||
): boolean {
|
||||
const centerX = Number(node.pos[0]) + Number(node.size[0]) / 2;
|
||||
const centerY = Number(node.pos[1]) + Number(node.size[1]) / 2;
|
||||
|
||||
const gx = Number(group.pos[0]);
|
||||
const gy = Number(group.pos[1]);
|
||||
const gw = Number(group.size[0]);
|
||||
const gh = Number(group.size[1]);
|
||||
return isCenterInsideRect(toRectGeometry(node), toRectGeometry(group));
|
||||
}
|
||||
|
||||
export function isRectInsideRect(
|
||||
inner: RectGeometry,
|
||||
outer: RectGeometry,
|
||||
): boolean {
|
||||
return (
|
||||
centerX >= gx &&
|
||||
centerX <= gx + gw &&
|
||||
centerY >= gy &&
|
||||
centerY <= gy + gh
|
||||
inner.x >= outer.x &&
|
||||
inner.y >= outer.y &&
|
||||
inner.x + inner.width <= outer.x + outer.width &&
|
||||
inner.y + inner.height <= outer.y + outer.height
|
||||
);
|
||||
}
|
||||
|
||||
export function isCenterInsideRect(
|
||||
item: RectGeometry,
|
||||
container: RectGeometry,
|
||||
): boolean {
|
||||
const centerX = item.x + item.width / 2;
|
||||
const centerY = item.y + item.height / 2;
|
||||
|
||||
return (
|
||||
centerX >= container.x &&
|
||||
centerX <= container.x + container.width &&
|
||||
centerY >= container.y &&
|
||||
centerY <= container.y + container.height
|
||||
);
|
||||
}
|
||||
|
||||
function toRectGeometry(item: PositionedItem): RectGeometry {
|
||||
return {
|
||||
x: Number(item.pos[0]),
|
||||
y: Number(item.pos[1]),
|
||||
width: Number(item.size[0]),
|
||||
height: Number(item.size[1]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
isCenterInsideRect,
|
||||
isRectInsideRect,
|
||||
} from "./group-geometry";
|
||||
|
||||
export interface Rect {
|
||||
readonly id: string;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export interface GroupMembership {
|
||||
readonly groupId: string;
|
||||
readonly nodeIds: string[];
|
||||
readonly childGroupIds: string[];
|
||||
}
|
||||
|
||||
export function inferGroupMembership(
|
||||
nodes: ReadonlyArray<Rect>,
|
||||
groups: ReadonlyArray<Rect>,
|
||||
): GroupMembership[] {
|
||||
if (groups.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groupById = new Map<string, Rect>();
|
||||
const groupAreas = new Map<string, number>();
|
||||
for (const group of groups) {
|
||||
groupById.set(group.id, group);
|
||||
groupAreas.set(group.id, group.width * group.height);
|
||||
}
|
||||
|
||||
const childGroupIds = new Map<string, string[]>();
|
||||
for (const inner of groups) {
|
||||
let nearestParent: Rect | null = null;
|
||||
let nearestArea = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const outer of groups) {
|
||||
if (outer.id === inner.id || !isRectInsideRect(inner, outer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const outerArea = groupAreas.get(outer.id) ?? Number.POSITIVE_INFINITY;
|
||||
if (outerArea < nearestArea) {
|
||||
nearestParent = outer;
|
||||
nearestArea = outerArea;
|
||||
}
|
||||
}
|
||||
|
||||
if (nearestParent) {
|
||||
const children = childGroupIds.get(nearestParent.id) ?? [];
|
||||
children.push(inner.id);
|
||||
childGroupIds.set(nearestParent.id, children);
|
||||
}
|
||||
}
|
||||
|
||||
return groups.map((group) => {
|
||||
const directChildIds = childGroupIds.get(group.id) ?? [];
|
||||
const nodeIds: string[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!isCenterInsideRect(node, group)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let insideDirectChild = false;
|
||||
for (const childId of directChildIds) {
|
||||
const childGroup = groupById.get(childId);
|
||||
if (childGroup && isCenterInsideRect(node, childGroup)) {
|
||||
insideDirectChild = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!insideDirectChild) {
|
||||
nodeIds.push(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
groupId: group.id,
|
||||
nodeIds,
|
||||
childGroupIds: [...directChildIds],
|
||||
};
|
||||
});
|
||||
}
|
||||
+10
-16
@@ -6,6 +6,10 @@
|
||||
*/
|
||||
|
||||
import type { ComfyApp } from "@comfyorg/comfyui-frontend-types";
|
||||
import {
|
||||
createLayoutAlgorithm,
|
||||
type LayoutAlgorithmName,
|
||||
} from "./layout/algorithm-factory";
|
||||
import {
|
||||
SETTING_IDS,
|
||||
EXTENSION_NAME,
|
||||
@@ -24,9 +28,6 @@ import {
|
||||
} from "./adapter";
|
||||
import type { GraphLike } from "./adapter";
|
||||
import { layoutWithGroups } from "./layout/framework";
|
||||
import { createSugiyamaAlgorithm } from "./layout/algorithms/sugiyama";
|
||||
import { createHorizontalAlgorithm } from "./layout/algorithms/horizontal";
|
||||
import { createVerticalAlgorithm } from "./layout/algorithms/vertical";
|
||||
import type { LayoutAlgorithm, FrameworkConfig } from "./layout/types";
|
||||
import { computeGraphBounds } from "./bounds";
|
||||
import { DEFAULT_FRAMEWORK_CONFIG } from "./layout/types";
|
||||
@@ -39,18 +40,6 @@ declare global {
|
||||
}
|
||||
|
||||
const DEFAULT_ALGORITHM_NAME: DefaultAlgorithmName = "sugiyama";
|
||||
const algorithmFactories: Record<
|
||||
DefaultAlgorithmName,
|
||||
(config: Partial<FrameworkConfig>) => LayoutAlgorithm
|
||||
> = {
|
||||
sugiyama: (config) =>
|
||||
createSugiyamaAlgorithm({
|
||||
horizontalGap: config.horizontalGap,
|
||||
verticalGap: config.verticalGap,
|
||||
}),
|
||||
horizontal: (config) => createHorizontalAlgorithm(config.horizontalGap),
|
||||
vertical: (config) => createVerticalAlgorithm(config.verticalGap),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -70,7 +59,12 @@ export function createConfiguredDefaultAlgorithm(
|
||||
algorithmName: DefaultAlgorithmName,
|
||||
config: Partial<FrameworkConfig>,
|
||||
): LayoutAlgorithm {
|
||||
return algorithmFactories[algorithmName](config);
|
||||
return createLayoutAlgorithm(algorithmName as LayoutAlgorithmName, {
|
||||
horizontalGap:
|
||||
config.horizontalGap ?? DEFAULT_FRAMEWORK_CONFIG.horizontalGap,
|
||||
verticalGap:
|
||||
config.verticalGap ?? DEFAULT_FRAMEWORK_CONFIG.verticalGap,
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultAlgorithm(config: Partial<FrameworkConfig>): LayoutAlgorithm {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createHorizontalAlgorithm } from "./algorithms/horizontal";
|
||||
import { createSugiyamaAlgorithm } from "./algorithms/sugiyama";
|
||||
import { createVerticalAlgorithm } from "./algorithms/vertical";
|
||||
import type { FrameworkConfig, LayoutAlgorithm } from "./types";
|
||||
|
||||
export type LayoutAlgorithmName = "sugiyama" | "horizontal" | "vertical";
|
||||
|
||||
export function createLayoutAlgorithm(
|
||||
algorithmName: LayoutAlgorithmName,
|
||||
config: Pick<FrameworkConfig, "horizontalGap" | "verticalGap">,
|
||||
): LayoutAlgorithm {
|
||||
switch (algorithmName) {
|
||||
case "horizontal":
|
||||
return createHorizontalAlgorithm(config.horizontalGap);
|
||||
case "vertical":
|
||||
return createVerticalAlgorithm(config.verticalGap);
|
||||
case "sugiyama":
|
||||
return createSugiyamaAlgorithm({
|
||||
horizontalGap: config.horizontalGap,
|
||||
verticalGap: config.verticalGap,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const GROUP_LAYOUT_ID_PREFIX = "group:";
|
||||
|
||||
export function toGroupLayoutId(groupId: string | number): string {
|
||||
return `${GROUP_LAYOUT_ID_PREFIX}${groupId}`;
|
||||
}
|
||||
|
||||
export function fromGroupLayoutId(groupId: string): string | null {
|
||||
if (!groupId.startsWith(GROUP_LAYOUT_ID_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return groupId.slice(GROUP_LAYOUT_ID_PREFIX.length);
|
||||
}
|
||||
|
||||
export function fromNumericGroupLayoutId(groupId: string): number | null {
|
||||
const rawGroupId = fromGroupLayoutId(groupId);
|
||||
if (rawGroupId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(rawGroupId);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLayoutAlgorithm } from "../../src/layout/algorithm-factory";
|
||||
import type { LayoutInput, LayoutNode } from "../../src/layout/types";
|
||||
|
||||
describe("createLayoutAlgorithm", () => {
|
||||
it("creates a horizontal algorithm using horizontalGap", () => {
|
||||
const algorithm = createLayoutAlgorithm("horizontal", {
|
||||
horizontalGap: 123,
|
||||
verticalGap: 77,
|
||||
});
|
||||
|
||||
const positions = algorithm.layout({
|
||||
nodes: [
|
||||
{ id: "a", width: 10, height: 10 },
|
||||
{ id: "b", width: 10, height: 10 },
|
||||
],
|
||||
edges: [],
|
||||
}).positions;
|
||||
|
||||
expect(algorithm.name).toBe("horizontal");
|
||||
expect(positions.get("b")?.x).toBe(133);
|
||||
});
|
||||
|
||||
it("creates a vertical algorithm using verticalGap", () => {
|
||||
const algorithm = createLayoutAlgorithm("vertical", {
|
||||
horizontalGap: 123,
|
||||
verticalGap: 77,
|
||||
});
|
||||
|
||||
const positions = algorithm.layout({
|
||||
nodes: [
|
||||
{ id: "a", width: 10, height: 10 },
|
||||
{ id: "b", width: 10, height: 10 },
|
||||
],
|
||||
edges: [],
|
||||
}).positions;
|
||||
|
||||
expect(algorithm.name).toBe("vertical");
|
||||
expect(positions.get("b")?.y).toBe(87);
|
||||
});
|
||||
|
||||
it("creates a sugiyama algorithm using both spacing values", () => {
|
||||
const algorithm = createLayoutAlgorithm("sugiyama", {
|
||||
horizontalGap: 123,
|
||||
verticalGap: 77,
|
||||
});
|
||||
|
||||
const input: LayoutInput = {
|
||||
nodes: [
|
||||
{ id: "left-a", width: 10, height: 10 },
|
||||
{ id: "left-b", width: 10, height: 10 },
|
||||
{ id: "right", width: 10, height: 10 },
|
||||
] satisfies LayoutNode[],
|
||||
edges: [
|
||||
{ source: "left-a", target: "right" },
|
||||
{ source: "left-b", target: "right" },
|
||||
],
|
||||
};
|
||||
|
||||
const positions = algorithm.layout(input).positions;
|
||||
|
||||
expect(algorithm.name).toBe("sugiyama");
|
||||
expect(positions.get("right")?.x).toBe(133);
|
||||
expect(positions.get("left-b")?.y).toBe(87);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const sourceFiles = [
|
||||
new URL("../../src/core.ts", import.meta.url),
|
||||
new URL("../../src/group-membership.ts", import.meta.url),
|
||||
new URL("../../src/group-geometry.ts", import.meta.url),
|
||||
];
|
||||
|
||||
describe("pure core source imports", () => {
|
||||
it("keeps relative TypeScript imports extensionless", () => {
|
||||
for (const fileUrl of sourceFiles) {
|
||||
const source = readFileSync(fileUrl, "utf-8");
|
||||
expect(source).not.toMatch(/from "\.\/.*\.js"/);
|
||||
expect(source).not.toMatch(/from "\.\.\/.*\.js"/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inferGroupMembership, normalizeWorkflowGeometry } from "../../src/core";
|
||||
|
||||
function layoutNode(
|
||||
id: string,
|
||||
width = 100,
|
||||
height = 60,
|
||||
): { id: string; width: number; height: number } {
|
||||
return { id, width, height };
|
||||
}
|
||||
|
||||
function layoutEdge(source: string, target: string): { source: string; target: string } {
|
||||
return { source, target };
|
||||
}
|
||||
|
||||
describe("normalizeWorkflowGeometry", () => {
|
||||
it("lays out a single node", () => {
|
||||
const result = normalizeWorkflowGeometry({
|
||||
nodes: [layoutNode("A")],
|
||||
edges: [],
|
||||
groups: [],
|
||||
});
|
||||
|
||||
expect(result.nodes).toHaveLength(1);
|
||||
expect(result.nodes[0]).toMatchObject({
|
||||
id: "A",
|
||||
width: 100,
|
||||
height: 60,
|
||||
});
|
||||
expect(Number.isFinite(result.nodes[0]?.x)).toBe(true);
|
||||
expect(Number.isFinite(result.nodes[0]?.y)).toBe(true);
|
||||
expect(result.groups).toEqual([]);
|
||||
expect(result.memberships).toEqual([]);
|
||||
});
|
||||
|
||||
it("lays out a chain", () => {
|
||||
const result = normalizeWorkflowGeometry({
|
||||
nodes: [layoutNode("A"), layoutNode("B"), layoutNode("C")],
|
||||
edges: [layoutEdge("A", "B"), layoutEdge("B", "C")],
|
||||
groups: [],
|
||||
});
|
||||
|
||||
expect(result.nodes).toHaveLength(3);
|
||||
for (const node of result.nodes) {
|
||||
expect(Number.isFinite(node.x)).toBe(true);
|
||||
expect(Number.isFinite(node.y)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles groups with members", () => {
|
||||
const result = normalizeWorkflowGeometry({
|
||||
nodes: [layoutNode("n1"), layoutNode("n2"), layoutNode("n3")],
|
||||
edges: [layoutEdge("n1", "n2")],
|
||||
groups: [
|
||||
{
|
||||
id: "g1",
|
||||
title: "My Group",
|
||||
memberIds: ["n1", "n2"],
|
||||
childGroupIds: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.groups).toHaveLength(1);
|
||||
expect(result.groups[0]?.id).toBe("g1");
|
||||
expect(result.memberships).toEqual([
|
||||
{ groupId: "g1", nodeIds: ["n1", "n2"], childGroupIds: [] },
|
||||
]);
|
||||
expect((result.groups[0]?.width ?? 0) > 0).toBe(true);
|
||||
expect((result.groups[0]?.height ?? 0) > 0).toBe(true);
|
||||
});
|
||||
|
||||
it("parses layout tokens from group titles automatically", () => {
|
||||
const result = normalizeWorkflowGeometry({
|
||||
nodes: [layoutNode("n1"), layoutNode("n2"), layoutNode("n3")],
|
||||
edges: [],
|
||||
groups: [
|
||||
{
|
||||
id: "g1",
|
||||
title: "Row Group [HORIZONTAL]",
|
||||
memberIds: ["n1", "n2", "n3"],
|
||||
childGroupIds: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const groupNodes = result.nodes.filter((node) => ["n1", "n2", "n3"].includes(node.id));
|
||||
expect(new Set(groupNodes.map((node) => node.y)).size).toBe(1);
|
||||
});
|
||||
|
||||
it("accepts an algorithm option", () => {
|
||||
const input = {
|
||||
nodes: [layoutNode("A"), layoutNode("B")],
|
||||
edges: [layoutEdge("A", "B")],
|
||||
groups: [],
|
||||
};
|
||||
|
||||
expect(normalizeWorkflowGeometry(input, { algorithm: "sugiyama" }).nodes).toHaveLength(2);
|
||||
expect(normalizeWorkflowGeometry(input, { algorithm: "horizontal" }).nodes).toHaveLength(2);
|
||||
expect(normalizeWorkflowGeometry(input, { algorithm: "vertical" }).nodes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns deterministic results", () => {
|
||||
const input = {
|
||||
nodes: [layoutNode("A"), layoutNode("B"), layoutNode("C")],
|
||||
edges: [layoutEdge("A", "B"), layoutEdge("B", "C")],
|
||||
groups: [],
|
||||
};
|
||||
|
||||
expect(normalizeWorkflowGeometry(input)).toEqual(normalizeWorkflowGeometry(input));
|
||||
});
|
||||
|
||||
it("returns a JSON-serializable result", () => {
|
||||
const result = normalizeWorkflowGeometry({
|
||||
nodes: [layoutNode("A"), layoutNode("B")],
|
||||
edges: [layoutEdge("A", "B")],
|
||||
groups: [
|
||||
{
|
||||
id: "g1",
|
||||
title: "Group",
|
||||
memberIds: ["A", "B"],
|
||||
childGroupIds: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(JSON.parse(JSON.stringify(result))).toEqual(result);
|
||||
});
|
||||
|
||||
it("handles subgraph boundary nodes", () => {
|
||||
const result = normalizeWorkflowGeometry({
|
||||
nodes: [
|
||||
layoutNode("A"),
|
||||
{ id: "in", width: 80, height: 40, kind: "subgraph-input" as const },
|
||||
{ id: "out", width: 80, height: 40, kind: "subgraph-output" as const },
|
||||
],
|
||||
edges: [layoutEdge("in", "A"), layoutEdge("A", "out")],
|
||||
groups: [],
|
||||
});
|
||||
|
||||
expect(result.nodes.map((node) => node.id).sort()).toEqual(["A", "in", "out"]);
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
expect(
|
||||
normalizeWorkflowGeometry({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
groups: [],
|
||||
}),
|
||||
).toEqual({
|
||||
nodes: [],
|
||||
groups: [],
|
||||
memberships: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("core re-exports inferGroupMembership", () => {
|
||||
it("is callable from the core entrypoint", () => {
|
||||
expect(
|
||||
inferGroupMembership(
|
||||
[{ id: "n1", x: 10, y: 10, width: 50, height: 50 }],
|
||||
[{ id: "g1", x: 0, y: 0, width: 200, height: 200 }],
|
||||
),
|
||||
).toEqual([{ groupId: "g1", nodeIds: ["n1"], childGroupIds: [] }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
fromGroupLayoutId,
|
||||
fromNumericGroupLayoutId,
|
||||
toGroupLayoutId,
|
||||
} from "../../src/layout/group-ids";
|
||||
|
||||
describe("group layout ids", () => {
|
||||
it("namespaces string and numeric ids consistently", () => {
|
||||
expect(toGroupLayoutId("abc")).toBe("group:abc");
|
||||
expect(toGroupLayoutId(42)).toBe("group:42");
|
||||
});
|
||||
|
||||
it("parses raw ids back out of namespaced ids", () => {
|
||||
expect(fromGroupLayoutId("group:abc")).toBe("abc");
|
||||
expect(fromGroupLayoutId("node:abc")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses numeric group ids when they are valid numbers", () => {
|
||||
expect(fromNumericGroupLayoutId("group:42")).toBe(42);
|
||||
expect(fromNumericGroupLayoutId("group:abc")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
inferGroupMembership,
|
||||
type GroupMembership,
|
||||
type Rect,
|
||||
} from "../../src/group-membership";
|
||||
|
||||
function rect(id: string, x: number, y: number, width: number, height: number): Rect {
|
||||
return { id, x, y, width, height };
|
||||
}
|
||||
|
||||
function membership(groupId: string, nodeIds: string[], childGroupIds: string[]): GroupMembership {
|
||||
return { groupId, nodeIds, childGroupIds };
|
||||
}
|
||||
|
||||
describe("inferGroupMembership", () => {
|
||||
it("returns empty array when no groups", () => {
|
||||
const nodes = [rect("n1", 0, 0, 100, 60)];
|
||||
|
||||
expect(inferGroupMembership(nodes, [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("assigns node to group via center-point containment", () => {
|
||||
const nodes = [rect("n1", 0, 0, 100, 60)];
|
||||
const groups = [rect("g1", 0, 0, 200, 200)];
|
||||
|
||||
expect(inferGroupMembership(nodes, groups)).toEqual([
|
||||
membership("g1", ["n1"], []),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not assign node when center is outside group", () => {
|
||||
const nodes = [rect("n1", 300, 300, 100, 60)];
|
||||
const groups = [rect("g1", 0, 0, 200, 200)];
|
||||
|
||||
expect(inferGroupMembership(nodes, groups)).toEqual([
|
||||
membership("g1", [], []),
|
||||
]);
|
||||
});
|
||||
|
||||
it("infers nested group hierarchy via full-rect containment", () => {
|
||||
const nodes = [rect("n1", 50, 50, 80, 40)];
|
||||
const groups = [rect("outer", 0, 0, 400, 400), rect("inner", 20, 20, 200, 200)];
|
||||
|
||||
const result = inferGroupMembership(nodes, groups);
|
||||
|
||||
expect(result.find((entry) => entry.groupId === "outer")).toEqual(
|
||||
membership("outer", [], ["inner"]),
|
||||
);
|
||||
expect(result.find((entry) => entry.groupId === "inner")).toEqual(
|
||||
membership("inner", ["n1"], []),
|
||||
);
|
||||
});
|
||||
|
||||
it("assigns node to nearest containing group, not ancestor", () => {
|
||||
const nodes = [rect("n1", 50, 50, 60, 40)];
|
||||
const groups = [rect("outer", 0, 0, 500, 500), rect("inner", 30, 30, 200, 200)];
|
||||
|
||||
const result = inferGroupMembership(nodes, groups);
|
||||
|
||||
expect(result.find((entry) => entry.groupId === "outer")).toEqual(
|
||||
membership("outer", [], ["inner"]),
|
||||
);
|
||||
expect(result.find((entry) => entry.groupId === "inner")).toEqual(
|
||||
membership("inner", ["n1"], []),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles multiple nodes across multiple groups", () => {
|
||||
const nodes = [
|
||||
rect("n1", 10, 10, 50, 50),
|
||||
rect("n2", 310, 10, 50, 50),
|
||||
rect("n3", 600, 600, 50, 50),
|
||||
];
|
||||
const groups = [rect("g1", 0, 0, 200, 200), rect("g2", 300, 0, 200, 200)];
|
||||
|
||||
const result = inferGroupMembership(nodes, groups);
|
||||
|
||||
expect(result.find((entry) => entry.groupId === "g1")).toEqual(
|
||||
membership("g1", ["n1"], []),
|
||||
);
|
||||
expect(result.find((entry) => entry.groupId === "g2")).toEqual(
|
||||
membership("g2", ["n2"], []),
|
||||
);
|
||||
});
|
||||
|
||||
it("is deterministic across repeated calls", () => {
|
||||
const nodes = [rect("n1", 10, 10, 50, 50), rect("n2", 60, 60, 50, 50)];
|
||||
const groups = [rect("g1", 0, 0, 300, 300), rect("g2", 5, 5, 200, 200)];
|
||||
|
||||
expect(inferGroupMembership(nodes, groups)).toEqual(
|
||||
inferGroupMembership(nodes, groups),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import libConfig from "../../vite.config.lib.ts";
|
||||
|
||||
describe("vite library build config", () => {
|
||||
it("builds the core entrypoint in library mode", () => {
|
||||
const libBuild = libConfig.build?.lib;
|
||||
|
||||
expect(libBuild).toBeDefined();
|
||||
if (!libBuild) {
|
||||
throw new Error("expected Vite library mode to be configured");
|
||||
}
|
||||
|
||||
expect(libBuild.entry).toBe(
|
||||
fileURLToPath(new URL("../../src/core.ts", import.meta.url)),
|
||||
);
|
||||
expect(libConfig.build?.outDir).toBe("lib");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/core.ts", "src/group-membership.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
emptyOutDir: true,
|
||||
lib: {
|
||||
entry: fileURLToPath(new URL("./src/core.ts", import.meta.url)),
|
||||
fileName: () => "core.js",
|
||||
formats: ["es"],
|
||||
},
|
||||
outDir: "lib",
|
||||
rollupOptions: {
|
||||
external: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user