feat: squash v2 branch
@@ -0,0 +1,2 @@
|
||||
-P ubuntu-latest=catthehacker/ubuntu:act-latest
|
||||
--container-architecture linux/amd64
|
||||
@@ -2,17 +2,14 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, v2]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test & Build
|
||||
unit-tests:
|
||||
name: Unit Tests & Build
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
@@ -20,10 +17,10 @@ jobs:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -32,8 +29,55 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Run tests
|
||||
- name: Run unit tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
e2e-tests:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: unit-tests
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: pnpx playwright install chromium --with-deps
|
||||
|
||||
- name: Build extension
|
||||
run: pnpm build
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
|
||||
- name: Setup test ComfyUI
|
||||
run: pnpm setup:e2e
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Run E2E tests
|
||||
run: pnpm test:e2e
|
||||
timeout-minutes: 30
|
||||
|
||||
- name: Upload test results
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-test-results
|
||||
path: |
|
||||
test-results/
|
||||
playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
@@ -17,40 +17,64 @@ jobs:
|
||||
test:
|
||||
name: Test before publish
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor == github.repository_owner
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20]
|
||||
if: github.actor == github.repository_owner && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Use Python from .python-version
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Sync Python dependencies
|
||||
run: uv sync --locked --group dev
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Run tests
|
||||
- name: Run unit tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: pnpx playwright install chromium --with-deps
|
||||
|
||||
- name: Setup test ComfyUI
|
||||
run: pnpm setup:e2e
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Run E2E tests
|
||||
run: pnpm test:e2e
|
||||
timeout-minutes: 30
|
||||
|
||||
bump-version:
|
||||
name: Bump version and create tag
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
if: github.actor == github.repository_owner
|
||||
if: github.actor == github.repository_owner && github.ref == 'refs/heads/main'
|
||||
outputs:
|
||||
previous-version: ${{ steps.versions.outputs.previous-version }}
|
||||
current-version: ${{ steps.versions.outputs.current-version }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -59,57 +83,85 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Python from .python-version
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Configure Git for GitHub Actions bot
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Bump version
|
||||
id: bump
|
||||
uses: callowayproject/bump-my-version@master
|
||||
env:
|
||||
BUMPVERSION_TAG: "true"
|
||||
with:
|
||||
args: ${{ github.event.inputs.bump_type }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Sync Python dependencies
|
||||
run: uv sync --locked --group dev
|
||||
|
||||
- name: Compute release version
|
||||
id: versions
|
||||
shell: bash
|
||||
run: |
|
||||
previous_version="$(uv run bump-my-version show current_version)"
|
||||
current_version="$(uv run bump-my-version show --increment "${{ github.event.inputs.bump_type }}" new_version)"
|
||||
{
|
||||
echo "previous-version=${previous_version}"
|
||||
echo "current-version=${current_version}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Bump version and refresh uv.lock
|
||||
run: |
|
||||
uv run bump-my-version bump --no-commit --no-tag ${{ github.event.inputs.bump_type }}
|
||||
uv lock
|
||||
|
||||
- name: Commit version bump
|
||||
run: |
|
||||
git add .python-version pyproject.toml package.json src/settings.ts uv.lock
|
||||
git commit -m "chore: bump version to ${{ steps.versions.outputs.current-version }} [skip ci]"
|
||||
|
||||
- name: Create release tag
|
||||
run: git tag "v${{ steps.versions.outputs.current-version }}" -m "Release version ${{ steps.versions.outputs.current-version }}"
|
||||
|
||||
- name: Push version commit and tag
|
||||
run: git push origin HEAD:main --follow-tags
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.bump.outputs.bumped == 'true'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ steps.bump.outputs.current-version }}
|
||||
name: Release v${{ steps.bump.outputs.current-version }}
|
||||
tag_name: v${{ steps.versions.outputs.current-version }}
|
||||
name: Release v${{ steps.versions.outputs.current-version }}
|
||||
body: |
|
||||
## Version ${{ steps.bump.outputs.current-version }}
|
||||
## Version ${{ steps.versions.outputs.current-version }}
|
||||
|
||||
Bumped from version ${{ steps.bump.outputs.previous-version }} to ${{ steps.bump.outputs.current-version }}
|
||||
Bumped from version ${{ steps.versions.outputs.previous-version }} to ${{ steps.versions.outputs.current-version }}
|
||||
|
||||
This release was automatically created by the version bump workflow.
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
needs: bump-version
|
||||
if: github.actor == github.repository_owner
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20]
|
||||
if: github.actor == github.repository_owner && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
ref: v${{ needs.bump-version.outputs.current-version }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -15,4 +15,7 @@ pnpm-debug.log*
|
||||
node.zip
|
||||
.vscode/
|
||||
.claude/
|
||||
.changeset/
|
||||
.changeset/
|
||||
.test-comfy/
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -0,0 +1,61 @@
|
||||
## Project
|
||||
|
||||
ComfyUI Node Organizer. Frontend-only custom node extension loaded from `dist/` through `__init__.py`.
|
||||
|
||||
## Start Here
|
||||
|
||||
Inspect `README.md`, `package.json`, `src/index.ts`, `src/adapter.ts`, and the relevant file under `src/layout/` or `tests/e2e/`.
|
||||
Use `.changeset/plans/` only when prior design context is needed.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm setup:e2e
|
||||
pnpm test:e2e
|
||||
pnpm ci:local
|
||||
uv sync --locked --group dev
|
||||
```
|
||||
|
||||
Version bumping:
|
||||
- `uv run bump-my-version bump patch|minor|major`
|
||||
|
||||
## Repo Rules
|
||||
|
||||
- Build before E2E. ComfyUI loads `dist/`, not `src/`.
|
||||
- Use `uv` for Python-side commands.
|
||||
- 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.
|
||||
|
||||
## Runtime Facts
|
||||
|
||||
- Ctrl+Z works after organize. ComfyUI snapshots graph state before extension mutations.
|
||||
- Keybindings are managed in ComfyUI's keybinding settings, not inside this extension's settings panel.
|
||||
- Graph access: `canvas.getCurrentGraph?.() ?? canvas.graph ?? app.graph`.
|
||||
- Group membership is geometry-based. Use center-point containment for nodes and full-rect containment for child groups. Do not read `_children`.
|
||||
- Do not call `recomputeInsideNodes()`. It reads stale coordinates after our in-place `pos[0] = x` updates and fills `_children` with wrong node IDs.
|
||||
- Group layout IDs are namespaced as `group:<id>`.
|
||||
- Selected-group organize re-anchors selected root groups to their original absolute positions.
|
||||
- Subgraph `inputNode` and `outputNode` are first-class layout nodes.
|
||||
- `isGroup()` is verified against live runtime with `constructor.name === "LGraphGroup"` and fallback `typeof type !== "string"`.
|
||||
|
||||
## Test Facts
|
||||
|
||||
- Unit: layout framework, adapter bridge, runtime helpers, property-based invariants.
|
||||
- E2E: Playwright against `.test-comfy/`.
|
||||
- `pnpm setup:e2e` pins ComfyUI to the revision in `e2e.config.ts` for CI parity.
|
||||
- CI and publish run the full Playwright suite on Linux.
|
||||
- Repo-owned fixtures live flat in `tests/fixtures/`.
|
||||
- Installed `comfyui-workflow-templates` are discovered live at E2E runtime from the pinned test ComfyUI environment.
|
||||
- Broad installed-template coverage asserts correctness invariants only; strict topological order remains repo-fixture-only.
|
||||
- Visual regression is graph-canvas scoped and uses platform-specific `-win32` / `-linux` baselines.
|
||||
- `pnpm ci:local` covers unit/build parity only.
|
||||
|
||||
## Known Friction
|
||||
|
||||
- Some real ComfyUI workflows load with imperfect group rectangles. Verify against live runtime before changing containment behavior.
|
||||
- A small number of real installed templates have topological-order violations due to complex inter-group connections. Tracked for v2.1.
|
||||
@@ -1,145 +1 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
ComfyUI custom node extension that organizes nodes. Frontend-only extension (no Python backend nodes) that uses TypeScript/Vite for the UI.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm install # install deps
|
||||
pnpm dev # dev server
|
||||
pnpm build # typecheck + build to dist/
|
||||
pnpm watch # build with watch mode
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm test # run tests
|
||||
pnpm test:watch # run tests in watch mode
|
||||
pnpm test:coverage # run tests with coverage report
|
||||
```
|
||||
|
||||
Version bumping: `uv run bump-my-version bump patch|minor|major`
|
||||
|
||||
## Architecture
|
||||
|
||||
**Dual-stack extension**: Python entrypoint + TypeScript frontend
|
||||
|
||||
- `__init__.py` - ComfyUI entrypoint, points `WEB_DIRECTORY` to `./dist`
|
||||
- `src/index.ts` - Extension entry, registers via `app.registerExtension()`
|
||||
- `src/debug.ts` - Debug logging utilities (separated for testability)
|
||||
- `dist/` - Built JS output, loaded by ComfyUI frontend
|
||||
|
||||
**Build system**: Vite with custom plugin that injects `import { app } from "/scripts/app.js"` at build time. ComfyUI scripts are external (not bundled).
|
||||
|
||||
**Types**: `@comfyorg/comfyui-frontend-types` provides ComfyApp interface. Global `app` declared for runtime access.
|
||||
|
||||
## ComfyUI Extension Pattern
|
||||
|
||||
Extensions register hooks via `app.registerExtension({ name, setup?, ... })`. The `app` object provides access to:
|
||||
- Graph/canvas manipulation
|
||||
- Node lifecycle hooks
|
||||
- UI customization APIs
|
||||
|
||||
Use docs-mcp server for ComfyUI API reference.
|
||||
|
||||
## Layout Algorithm
|
||||
|
||||
**Hybrid Sugiyama + Bin Packing** in `src/layout/`:
|
||||
|
||||
1. `node-classifier.ts` - Classify nodes (connected/disconnected/reroutes)
|
||||
2. `reroute-collapse.ts` - Collapse reroute chains into virtual edges
|
||||
3. `graph-builder.ts` - Build DAG from connected nodes only
|
||||
4. `layer-assign.ts` - Longest-path layer assignment
|
||||
5. `ordering.ts` - Size-aware barycenter crossing minimization
|
||||
6. `bin-pack.ts` - FFDH algorithm for multi-column packing
|
||||
7. `positioning.ts` - Assign coordinates, place disconnected nodes, restore reroutes
|
||||
8. `groups.ts` - Resize groups to fit members
|
||||
9. `layout-utils.ts` - Shared functions for internal edge detection and member layer assignment
|
||||
10. `selected-groups.ts` - Organize only selected groups independently
|
||||
11. `title-tokens.ts` - Parse group title tokens and arrange nodes by mode
|
||||
|
||||
**Configuration** in `types.ts`:
|
||||
- `maxColumns`: 0=auto, 1=vertical stack, 2+=fixed columns
|
||||
- `collapseReroutes`: true to collapse reroute chains
|
||||
- `disconnectedGap`: gap between disconnected zone and DAG
|
||||
|
||||
**Key design decisions**:
|
||||
- `compactVertically` excludes groups from max height (prevents Y explosion)
|
||||
- `positionGroupContents` uses translation (preserves relative positions)
|
||||
- Group dimensions are layer-aware: width = sum of layer widths, height = max of layer heights (for groups with internal connections)
|
||||
- `resolveGroupOverlaps` detects and shifts overlapping groups using minimal direction (up/down/right)
|
||||
- `assignNodeYPositions` tracks reserved Y regions to prevent node/group overlap within layers
|
||||
- **Idempotency**: Layout is a pure function of topology + sizes; overlap resolution decoupled from node.pos writes
|
||||
|
||||
## Selected Groups Layout
|
||||
|
||||
`layoutSelectedGroups(graph, selectedGroupIds, config?)` in `src/layout/selected-groups.ts`:
|
||||
|
||||
**Context menu items** (in `src/index.ts`):
|
||||
- "Organize Group" - when single group selected
|
||||
- "Organize N Groups" - when multiple groups selected
|
||||
|
||||
**Behavior**:
|
||||
- Uses internal connections only (ignores external edges)
|
||||
- Disconnected nodes within group stacked vertically
|
||||
- Nested groups auto-included when parent selected
|
||||
- External overlaps acceptable (no resolution with non-selected nodes)
|
||||
- Groups resized to fit organized contents
|
||||
|
||||
**Selection detection**:
|
||||
- Uses `app.canvas.selectedItems` (Set of Positionable)
|
||||
- Duck-typing via `isLGraphGroup()` checks for pos/size/title/id
|
||||
- Handles Float64Array (ComfyUI runtime uses typed arrays for pos/size)
|
||||
|
||||
## Group Title Tokens
|
||||
|
||||
`parseLayoutToken(title)` and `arrangeByMode()` in `src/layout/title-tokens.ts`:
|
||||
|
||||
**Supported tokens** (case-insensitive):
|
||||
- `[HORIZONTAL]` - Single horizontal row
|
||||
- `[VERTICAL]` - Single vertical column
|
||||
- `[1-9ROW]` - Distribute into N rows (round-robin)
|
||||
- `[1-9COL]` - Distribute into N columns (round-robin)
|
||||
|
||||
**Behavior**:
|
||||
- Tokens override default layout for direct group members
|
||||
- Ignores DAG structure when token present (treats all as disconnected)
|
||||
- `[1ROW]` = `[HORIZONTAL]`, `[1COL]` = `[VERTICAL]`
|
||||
- Nested groups each respect their own tokens
|
||||
- Groups without tokens use default behavior
|
||||
- Nodes sorted by original position (left-to-right, top-to-bottom) to preserve user intent
|
||||
|
||||
**Integration points**:
|
||||
- `selected-groups.ts:layoutGroupContents()` - Token layout for "Organize Group"
|
||||
- `positioning.ts:positionGroupContents()` - Token layout for "Organize Workflow"
|
||||
- Both handle nested groups: process bottom-up first, then include as items in parent's token layout
|
||||
|
||||
## Testing
|
||||
|
||||
**Vitest-based regression testing** in `tests/`:
|
||||
|
||||
- `tests/fixtures/` - 4 workflow JSONs (nested-groups, complex-parallel, simple-dag, nested-wrapper)
|
||||
- `tests/helpers.ts` - Fixture loader, workflow→LGraph converter, cloneGraph
|
||||
- `tests/invariants.ts` - 6 reusable assertions:
|
||||
- `assertNoOverlaps` - AABB collision detection
|
||||
- `assertNodesInsideGroups` - nodes stay in groups
|
||||
- `assertFiniteCoordinates` - no NaN/Infinity
|
||||
- `assertTopologicalOrder` - DAG order preserved
|
||||
- `assertIdempotent` - stable after multiple runs
|
||||
- `assertGroupMembershipPreserved` - nodes originally in groups remain in groups
|
||||
- `tests/integration/layout.test.ts` - Main regression tests
|
||||
- `tests/integration/selected-groups.test.ts` - Selected groups layout tests (34 cases)
|
||||
- `tests/integration/token-layout.test.ts` - Token-based layout regression tests (8 cases)
|
||||
|
||||
**Coverage**: 70% line threshold, excludes `src/index.ts` (ComfyUI runtime) and `src/layout/reroute-collapse.ts` (no fixtures)
|
||||
|
||||
All fixtures pass all invariants including idempotency. 75 total tests.
|
||||
|
||||
## CI/CD
|
||||
|
||||
`.github/workflows/publish_action.yaml` - Manual dispatch workflow that:
|
||||
1. Runs tests
|
||||
2. Bumps version via bump-my-version
|
||||
3. Creates GitHub release
|
||||
4. Builds and publishes to Comfy registry
|
||||
See and edit @AGENTS.md. Do not touch this file.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ComfyUI Node Organizer
|
||||
|
||||
Automatically organizes nodes in your workflow using a compact hierarchical layout. Created with [comfyui-custom-node-template](https://github.com/PBandDev/comfyui-custom-node-template)
|
||||
Automatically organizes ComfyUI workflows with a compact group-aware layout. Created with [comfyui-custom-node-template](https://github.com/PBandDev/comfyui-custom-node-template).
|
||||
|
||||
### Preview of organizing a workflow
|
||||
|
||||
@@ -19,9 +19,13 @@ Automatically organizes nodes in your workflow using a compact hierarchical layo
|
||||
|
||||
## Usage
|
||||
|
||||
Right-click on the canvas and select **Organize Workflow**.
|
||||
Use any of these entry points:
|
||||
|
||||
To organize specific groups, select them and choose **Organize Group**.
|
||||
- Right-click the canvas and choose **Organize Workflow**
|
||||
- Select groups and choose **Organize Group**
|
||||
- Click the **Organize** action-bar button
|
||||
- Use `Extensions > Node Organizer`
|
||||
- Press `Shift+O`
|
||||
|
||||
## Group Layout Tokens
|
||||
|
||||
@@ -38,22 +42,53 @@ Add tokens to group titles to control how nodes are arranged:
|
||||
- `"My Loaders [HORIZONTAL]"` - arranges all nodes in a single row
|
||||
- `"Processing [3COL]"` - distributes nodes into 3 columns
|
||||
|
||||
**Notes:**
|
||||
- Tokens are case-insensitive (`[horizontal]` works)
|
||||
- `[1ROW]` = `[HORIZONTAL]`, `[1COL]` = `[VERTICAL]`
|
||||
- Nested groups each respect their own tokens
|
||||
- Groups without tokens use default DAG-based layout
|
||||
|
||||
## Features
|
||||
## Testing
|
||||
|
||||
- **Execution Order Layout**: Nodes arranged left-to-right following data flow
|
||||
- **Compact Bin Packing**: Reduces vertical height by packing nodes into rows
|
||||
- **Disconnected Node Handling**: Annotations, bookmarks placed in separate left margin
|
||||
- **Reroute Optimization**: Reroute chains collapsed and restored along edges
|
||||
- **Group Support**: Nested groups with proper hierarchy
|
||||
- **Group Layout Tokens**: Control layout with title tokens (see below)
|
||||
- **Subgraph Support**: Works inside subgraphs with I/O node handling
|
||||
- `pnpm typecheck`
|
||||
- `pnpm test`
|
||||
- `pnpm build`
|
||||
- `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.
|
||||
### Local CI
|
||||
|
||||
Install [act](https://github.com/nektos/act) and Docker, then run:
|
||||
|
||||
- `pnpm ci:local` runs the unit-test and build workflow inside a local container
|
||||
|
||||
E2E tests still require the normal local ComfyUI provisioning flow and are not part of the `act` path yet.
|
||||
|
||||
## Release
|
||||
|
||||
Maintainer flow:
|
||||
|
||||
- Merge the release branch into `main`
|
||||
- Trigger `.github/workflows/publish_action.yaml` manually from `main`
|
||||
|
||||
That workflow runs the full test suite, bumps versioned files with `uv run bump-my-version`, creates the tag and GitHub release, then publishes the custom node from the bumped tag ref.
|
||||
|
||||
### Manual browser testing
|
||||
|
||||
Build the extension first so ComfyUI loads the current `dist/` output:
|
||||
|
||||
- `pnpm build`
|
||||
|
||||
Launch the test ComfyUI instance on a free port:
|
||||
|
||||
- `.test-comfy/venv/Scripts/comfy.exe --skip-prompt --workspace .test-comfy/comfyui/ launch -- --cpu --port 65192`
|
||||
|
||||
If the port is already taken, either choose a different port or stop leftover test-instance processes first:
|
||||
|
||||
- `Get-Process | Where-Object { $_.Path -like '*comfy-node-organizer\\.test-comfy*' } | Stop-Process -Force`
|
||||
|
||||
## Known Limitations
|
||||
|
||||
This extension has not been thoroughly tested with very large or complex workflows. If you encounter issues, please [open a GitHub issue](https://github.com/PBandDev/comfyui-node-organizer/issues) with a **minimal reproducible workflow** attached.
|
||||
Very large or unusual workflows may still expose edge cases. If you hit one, please [open a GitHub issue](https://github.com/PBandDev/comfyui-node-organizer/issues) with a minimal reproducible workflow attached.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export const e2eConfig = {
|
||||
port: 8199,
|
||||
testComfyDir: ".test-comfy",
|
||||
comfyRevision: "v0.18.1",
|
||||
get comfyUrl() {
|
||||
return `http://localhost:${this.port}`;
|
||||
},
|
||||
get comfyInstallDir() {
|
||||
return `${this.testComfyDir}/comfyui`;
|
||||
},
|
||||
get venvDir() {
|
||||
return `${this.testComfyDir}/venv`;
|
||||
},
|
||||
get customNodesDir() {
|
||||
return `${this.comfyInstallDir}/custom_nodes`;
|
||||
},
|
||||
timeouts: {
|
||||
comfyStartup: 120_000,
|
||||
pageLoad: 30_000,
|
||||
organize: 10_000,
|
||||
},
|
||||
} as const;
|
||||
@@ -10,7 +10,10 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"setup:e2e": "tsx scripts/setup-test-comfy.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"ci:local": "act push --workflows .github/workflows/ci.yaml -j unit-tests"
|
||||
},
|
||||
"keywords": [
|
||||
"comfyui",
|
||||
@@ -31,8 +34,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@comfyorg/comfyui-frontend-types": "^1.32.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^24.10.0",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"fast-check": "^4.6.0",
|
||||
"tsx": "^4.21.0",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { e2eConfig } from "./e2e.config.ts";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
snapshotPathTemplate: "{testDir}/{testFilePath}-snapshots/{arg}-{platform}{ext}",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 8,
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL: e2eConfig.comfyUrl,
|
||||
trace: "on-first-retry",
|
||||
viewport: { width: 1600, height: 1000 },
|
||||
},
|
||||
globalSetup: "./tests/e2e/global-setup.ts",
|
||||
globalTeardown: "./tests/e2e/global-teardown.ts",
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -13,20 +13,29 @@ importers:
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^7.1.12
|
||||
version: 7.3.0(@types/node@24.10.4)
|
||||
version: 7.3.0(@types/node@24.10.4)(tsx@4.21.0)
|
||||
devDependencies:
|
||||
'@comfyorg/comfyui-frontend-types':
|
||||
specifier: ^1.32.1
|
||||
version: 1.36.3(vue@3.5.25(typescript@5.9.3))(zod@3.25.76)
|
||||
'@playwright/test':
|
||||
specifier: ^1.58.2
|
||||
version: 1.58.2
|
||||
'@types/node':
|
||||
specifier: ^24.10.0
|
||||
version: 24.10.4
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.16
|
||||
version: 4.0.16(vitest@4.0.16(@types/node@24.10.4))
|
||||
version: 4.0.16(vitest@4.0.16(@types/node@24.10.4)(tsx@4.21.0))
|
||||
fast-check:
|
||||
specifier: ^4.6.0
|
||||
version: 4.6.0
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
vitest:
|
||||
specifier: ^4.0.16
|
||||
version: 4.0.16(@types/node@24.10.4)
|
||||
version: 4.0.16(@types/node@24.10.4)(tsx@4.21.0)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -223,6 +232,11 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@playwright/test@1.58.2':
|
||||
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.53.5':
|
||||
resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==}
|
||||
cpu: [arm]
|
||||
@@ -460,6 +474,10 @@ packages:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fast-check@4.6.0:
|
||||
resolution: {integrity: sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==}
|
||||
engines: {node: '>=12.17.0'}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -469,11 +487,19 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
get-tsconfig@4.13.6:
|
||||
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
|
||||
|
||||
has-flag@4.0.0:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -531,10 +557,26 @@ packages:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
playwright-core@1.58.2:
|
||||
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.58.2:
|
||||
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
postcss@8.5.6:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
pure-rand@8.3.0:
|
||||
resolution: {integrity: sha512-1ws1Ab8fnsf4bvpL+SujgBnr3KFs5abgCLVzavBp+f2n8Ld5YTOZlkv/ccYPhu3X9s+MEeqPRMqKlJz/kWDK8A==}
|
||||
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
rollup@4.53.5:
|
||||
resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
@@ -577,6 +619,11 @@ packages:
|
||||
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tsx@4.21.0:
|
||||
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -784,6 +831,10 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@playwright/test@1.58.2':
|
||||
dependencies:
|
||||
playwright: 1.58.2
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.53.5':
|
||||
optional: true
|
||||
|
||||
@@ -865,7 +916,7 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
'@vitest/coverage-v8@4.0.16(vitest@4.0.16(@types/node@24.10.4))':
|
||||
'@vitest/coverage-v8@4.0.16(vitest@4.0.16(@types/node@24.10.4)(tsx@4.21.0))':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.0.16
|
||||
@@ -878,7 +929,7 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.16(@types/node@24.10.4)
|
||||
vitest: 4.0.16(@types/node@24.10.4)(tsx@4.21.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -891,13 +942,13 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/mocker@4.0.16(vite@7.3.0(@types/node@24.10.4))':
|
||||
'@vitest/mocker@4.0.16(vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.16
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.0(@types/node@24.10.4)
|
||||
vite: 7.3.0(@types/node@24.10.4)(tsx@4.21.0)
|
||||
|
||||
'@vitest/pretty-format@4.0.16':
|
||||
dependencies:
|
||||
@@ -1032,13 +1083,24 @@ snapshots:
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
fast-check@4.6.0:
|
||||
dependencies:
|
||||
pure-rand: 8.3.0
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
get-tsconfig@4.13.6:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
@@ -1092,12 +1154,24 @@ snapshots:
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
playwright-core@1.58.2: {}
|
||||
|
||||
playwright@1.58.2:
|
||||
dependencies:
|
||||
playwright-core: 1.58.2
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
postcss@8.5.6:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
pure-rand@8.3.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
rollup@4.53.5:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -1151,11 +1225,18 @@ snapshots:
|
||||
|
||||
tinyrainbow@3.0.3: {}
|
||||
|
||||
tsx@4.21.0:
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
get-tsconfig: 4.13.6
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
vite@7.3.0(@types/node@24.10.4):
|
||||
vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0):
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -1166,11 +1247,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.4
|
||||
fsevents: 2.3.3
|
||||
tsx: 4.21.0
|
||||
|
||||
vitest@4.0.16(@types/node@24.10.4):
|
||||
vitest@4.0.16(@types/node@24.10.4)(tsx@4.21.0):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.16
|
||||
'@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@24.10.4))
|
||||
'@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0))
|
||||
'@vitest/pretty-format': 4.0.16
|
||||
'@vitest/runner': 4.0.16
|
||||
'@vitest/snapshot': 4.0.16
|
||||
@@ -1187,7 +1269,7 @@ snapshots:
|
||||
tinyexec: 1.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vite: 7.3.0(@types/node@24.10.4)
|
||||
vite: 7.3.0(@types/node@24.10.4)(tsx@4.21.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.4
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
name = "comfyui-node-organizer"
|
||||
description = "ComfyUI extension to organize nodes"
|
||||
version = "1.3.0"
|
||||
requires-python = ">=3.13"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"bump-my-version>=1.2.2",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/PBandDev/comfyui-node-organizer"
|
||||
|
||||
@@ -19,8 +25,8 @@ includes = ['dist/']
|
||||
current_version = "1.3.0"
|
||||
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
||||
serialize = ["{major}.{minor}.{patch}"]
|
||||
commit = true
|
||||
tag = true
|
||||
commit = false
|
||||
tag = false
|
||||
tag_name = "v{new_version}"
|
||||
tag_message = "Release version {new_version}"
|
||||
message = "chore: bump version to {new_version} [skip ci]"
|
||||
@@ -36,6 +42,6 @@ search = '"version": "{current_version}"'
|
||||
replace = '"version": "{new_version}"'
|
||||
|
||||
[[tool.bumpversion.files]]
|
||||
filename = "src/index.ts"
|
||||
search = 'Version {current_version}'
|
||||
replace = 'Version {new_version}'
|
||||
filename = "src/settings.ts"
|
||||
search = 'export const CURRENT_VERSION = "{current_version}";'
|
||||
replace = 'export const CURRENT_VERSION = "{new_version}";'
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export function extractPinnedWorkflowTemplatesRequirement(
|
||||
requirementsText: string,
|
||||
): string {
|
||||
const requirement = requirementsText
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith("comfyui-workflow-templates=="));
|
||||
|
||||
if (!requirement) {
|
||||
throw new Error(
|
||||
"Pinned ComfyUI checkout does not declare comfyui-workflow-templates",
|
||||
);
|
||||
}
|
||||
|
||||
return requirement;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
symlinkSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { resolve, join } from "node:path";
|
||||
import { e2eConfig } from "../e2e.config.ts";
|
||||
import { extractPinnedWorkflowTemplatesRequirement } from "./setup-test-comfy-helpers";
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
const projectRoot = resolve(import.meta.dirname, "..");
|
||||
|
||||
const venvDir = resolve(projectRoot, e2eConfig.venvDir);
|
||||
const comfyInstallDir = resolve(projectRoot, e2eConfig.comfyInstallDir);
|
||||
const customNodesDir = resolve(projectRoot, e2eConfig.customNodesDir);
|
||||
|
||||
const pythonBin = isWindows
|
||||
? join(venvDir, "Scripts", "python.exe")
|
||||
: join(venvDir, "bin", "python");
|
||||
|
||||
const comfyBin = isWindows
|
||||
? join(venvDir, "Scripts", "comfy.exe")
|
||||
: join(venvDir, "bin", "comfy");
|
||||
|
||||
function tryExec(cmd: string): string | null {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
cwd: projectRoot,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function run(cmd: string, label: string) {
|
||||
console.log(`[setup] ${label}...`);
|
||||
console.log(`[setup] $ ${cmd}`);
|
||||
execSync(cmd, { stdio: "inherit", cwd: projectRoot });
|
||||
}
|
||||
|
||||
function getInstalledComfyRevision(): string | null {
|
||||
const exactTag = tryExec(
|
||||
`git -C "${comfyInstallDir}" describe --tags --exact-match HEAD`,
|
||||
);
|
||||
if (exactTag) return exactTag;
|
||||
|
||||
const gitHead = tryExec(`git -C "${comfyInstallDir}" rev-parse HEAD`);
|
||||
return gitHead;
|
||||
}
|
||||
|
||||
function installComfy(revision: string): void {
|
||||
run(
|
||||
`"${comfyBin}" --skip-prompt --workspace "${comfyInstallDir}" install --version "${revision}" --restore --cpu`,
|
||||
`Installing ComfyUI ${revision}`,
|
||||
);
|
||||
}
|
||||
|
||||
function getPinnedWorkflowTemplatesRequirement(): string {
|
||||
const requirementsPath = resolve(comfyInstallDir, "requirements.txt");
|
||||
if (!existsSync(requirementsPath)) {
|
||||
throw new Error(
|
||||
`Pinned ComfyUI checkout is missing requirements.txt at ${requirementsPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
return extractPinnedWorkflowTemplatesRequirement(
|
||||
readFileSync(requirementsPath, "utf-8"),
|
||||
);
|
||||
}
|
||||
|
||||
// Step 1: Create venv (with --seed so pip is available for comfy-cli)
|
||||
if (existsSync(pythonBin)) {
|
||||
console.log("[setup] venv already exists, skipping.");
|
||||
} else {
|
||||
if (existsSync(venvDir)) {
|
||||
console.log("[setup] Removing invalid venv directory before recreate...");
|
||||
rmSync(venvDir, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(resolve(projectRoot, e2eConfig.testComfyDir), { recursive: true });
|
||||
run(`uv venv --seed "${venvDir}"`, "Creating Python venv (with pip)");
|
||||
}
|
||||
|
||||
// Ensure pip is available (handles venvs created without --seed)
|
||||
try {
|
||||
execSync(`"${pythonBin}" -m pip --version`, { stdio: "ignore" });
|
||||
} catch {
|
||||
console.log("[setup] pip not found in venv, installing...");
|
||||
run(
|
||||
`uv pip install pip --python "${pythonBin}"`,
|
||||
"Installing pip into venv",
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Install comfy-cli
|
||||
if (existsSync(comfyBin)) {
|
||||
console.log("[setup] comfy-cli already installed, skipping.");
|
||||
} else {
|
||||
run(
|
||||
`uv pip install comfy-cli --python "${pythonBin}"`,
|
||||
"Installing comfy-cli",
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Install ComfyUI
|
||||
// Check for main.py directly in workspace (comfy clones repo as workspace root)
|
||||
if (existsSync(join(comfyInstallDir, "main.py"))) {
|
||||
console.log("[setup] ComfyUI repo already cloned.");
|
||||
const installedRevision = getInstalledComfyRevision();
|
||||
|
||||
if (installedRevision === e2eConfig.comfyRevision) {
|
||||
try {
|
||||
execSync(`"${pythonBin}" -c "import torch"`, { stdio: "ignore" });
|
||||
console.log(
|
||||
`[setup] ComfyUI already pinned at ${e2eConfig.comfyRevision} and dependencies are installed.`,
|
||||
);
|
||||
} catch {
|
||||
installComfy(e2eConfig.comfyRevision);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[setup] Syncing ComfyUI to ${e2eConfig.comfyRevision} (current: ${installedRevision ?? "unknown"})...`,
|
||||
);
|
||||
installComfy(e2eConfig.comfyRevision);
|
||||
}
|
||||
} else {
|
||||
// Fresh install: clean up any invalid workspace state
|
||||
if (existsSync(comfyInstallDir)) {
|
||||
console.log(
|
||||
"[setup] Removing invalid workspace directory before install...",
|
||||
);
|
||||
rmSync(comfyInstallDir, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(resolve(comfyInstallDir, ".."), { recursive: true });
|
||||
installComfy(e2eConfig.comfyRevision);
|
||||
}
|
||||
|
||||
// Step 3b: Install the exact workflow templates package required by the
|
||||
// pinned ComfyUI checkout. Do not hardcode or float this version here.
|
||||
const workflowTemplatesRequirement = getPinnedWorkflowTemplatesRequirement();
|
||||
run(
|
||||
`uv pip install "${workflowTemplatesRequirement}" --python "${pythonBin}"`,
|
||||
`Installing ${workflowTemplatesRequirement}`,
|
||||
);
|
||||
|
||||
// Step 4: Symlink extension into custom_nodes
|
||||
const symlinkTarget = resolve(customNodesDir, "comfy-node-organizer");
|
||||
if (existsSync(symlinkTarget)) {
|
||||
try {
|
||||
const linkDest = readlinkSync(symlinkTarget);
|
||||
const resolvedLink = resolve(customNodesDir, linkDest);
|
||||
if (resolvedLink === projectRoot) {
|
||||
console.log("[setup] Symlink already exists and is correct, skipping.");
|
||||
} else {
|
||||
console.log(
|
||||
`[setup] WARNING: Symlink exists but points to ${resolvedLink}, expected ${projectRoot}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
console.log(
|
||||
"[setup] WARNING: Path exists but is not a symlink. Manual cleanup may be needed.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
mkdirSync(customNodesDir, { recursive: true });
|
||||
symlinkSync(projectRoot, symlinkTarget, isWindows ? "junction" : "dir");
|
||||
console.log(`[setup] Created symlink: ${symlinkTarget} -> ${projectRoot}`);
|
||||
}
|
||||
|
||||
console.log("[setup] Done! Run `pnpm test:e2e` to run e2e tests.");
|
||||
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* Bridge between ComfyUI's LGraph runtime objects and the pure layout types.
|
||||
*
|
||||
* Uses structural typing to avoid tight coupling to ComfyUI class hierarchy.
|
||||
* Handles Float64Array pos/size fields, Map-based link storage, and group
|
||||
* hierarchy inference via spatial containment.
|
||||
*/
|
||||
|
||||
import type {
|
||||
LayoutNode,
|
||||
LayoutEdge,
|
||||
LayoutGroup,
|
||||
Position,
|
||||
GroupBounds,
|
||||
LayoutToken,
|
||||
FrameworkResult,
|
||||
} from "./layout/types";
|
||||
import { parseLayoutToken } from "./layout/tokens";
|
||||
import { debugLog } from "./debug";
|
||||
import { isGroupInsideGroup, isNodeCenterInsideGroup } from "./group-geometry";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structural types for ComfyUI runtime objects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GraphNode {
|
||||
readonly id: number | string;
|
||||
readonly type: string;
|
||||
readonly title: string;
|
||||
readonly pos: ArrayLike<number>;
|
||||
readonly size: ArrayLike<number>;
|
||||
readonly inputs?: ReadonlyArray<{ link: number | null }>;
|
||||
readonly outputs?: ReadonlyArray<{ links: number[] | null }>;
|
||||
}
|
||||
|
||||
interface GraphBoundaryNode {
|
||||
readonly id: number;
|
||||
readonly pos: ArrayLike<number>;
|
||||
readonly size: ArrayLike<number>;
|
||||
}
|
||||
|
||||
interface GraphGroup {
|
||||
readonly id: number;
|
||||
readonly title: string;
|
||||
pos: ArrayLike<number>;
|
||||
size: ArrayLike<number>;
|
||||
readonly _children?: ReadonlySet<{ readonly id: number | string }>;
|
||||
}
|
||||
|
||||
interface GraphLink {
|
||||
readonly id: number;
|
||||
readonly origin_id: number;
|
||||
readonly target_id: number;
|
||||
}
|
||||
|
||||
/** Minimal structural interface for ComfyUI graph objects (LGraph | Subgraph). */
|
||||
export interface GraphLike {
|
||||
readonly _nodes: ReadonlyArray<GraphNode>;
|
||||
readonly _groups: GraphGroup[];
|
||||
readonly links: Map<number, GraphLink> | Record<number, GraphLink>;
|
||||
readonly inputNode?: GraphBoundaryNode;
|
||||
readonly outputNode?: GraphBoundaryNode;
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function extractLayoutInput(graph: GraphLike): {
|
||||
nodes: LayoutNode[];
|
||||
edges: LayoutEdge[];
|
||||
groups: LayoutGroup[];
|
||||
} {
|
||||
// 1. Convert nodes
|
||||
const nodes: LayoutNode[] = graph._nodes.map((n) => ({
|
||||
id: String(n.id),
|
||||
width: Number(n.size[0]),
|
||||
height: Number(n.size[1]),
|
||||
}));
|
||||
|
||||
if (graph.inputNode) {
|
||||
nodes.push({
|
||||
id: String(graph.inputNode.id),
|
||||
width: Number(graph.inputNode.size[0]),
|
||||
height: Number(graph.inputNode.size[1]),
|
||||
kind: "subgraph-input",
|
||||
layerConstraint: "first",
|
||||
});
|
||||
}
|
||||
|
||||
if (graph.outputNode) {
|
||||
nodes.push({
|
||||
id: String(graph.outputNode.id),
|
||||
width: Number(graph.outputNode.size[0]),
|
||||
height: Number(graph.outputNode.size[1]),
|
||||
kind: "subgraph-output",
|
||||
layerConstraint: "last",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Extract edges from links
|
||||
const edges: LayoutEdge[] = [];
|
||||
const linksIterable =
|
||||
graph.links instanceof Map
|
||||
? graph.links.values()
|
||||
: Object.values(graph.links);
|
||||
|
||||
for (const link of linksIterable) {
|
||||
if (link) {
|
||||
edges.push({
|
||||
source: String(link.origin_id),
|
||||
target: String(link.target_id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Build group hierarchy
|
||||
const groups = buildGroupsFromGraph(graph);
|
||||
|
||||
debugLog(
|
||||
`Extracted: ${nodes.length} nodes, ${edges.length} edges, ${groups.length} groups`,
|
||||
);
|
||||
return { nodes, edges, groups };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build LayoutGroup array with parent-child hierarchy from spatial containment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 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]));
|
||||
}
|
||||
|
||||
// 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),
|
||||
...(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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply layout output back to the ComfyUI graph
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function applyLayoutOutput(
|
||||
graph: GraphLike,
|
||||
result: {
|
||||
readonly positions: ReadonlyMap<string, Position>;
|
||||
readonly groupBounds: ReadonlyMap<string, GroupBounds>;
|
||||
},
|
||||
): void {
|
||||
// Apply node positions
|
||||
for (const node of graph._nodes) {
|
||||
const pos = result.positions.get(String(node.id));
|
||||
if (pos) {
|
||||
// Mutate in-place — ComfyUI internals may hold references to the typed array
|
||||
const mutablePos = node.pos as { [index: number]: number };
|
||||
mutablePos[0] = pos.x;
|
||||
mutablePos[1] = pos.y;
|
||||
}
|
||||
}
|
||||
|
||||
if (graph.inputNode) {
|
||||
const pos = result.positions.get(String(graph.inputNode.id));
|
||||
if (pos) {
|
||||
const mutablePos = graph.inputNode.pos as { [index: number]: number };
|
||||
mutablePos[0] = pos.x;
|
||||
mutablePos[1] = pos.y;
|
||||
}
|
||||
}
|
||||
|
||||
if (graph.outputNode) {
|
||||
const pos = result.positions.get(String(graph.outputNode.id));
|
||||
if (pos) {
|
||||
const mutablePos = graph.outputNode.pos as { [index: number]: number };
|
||||
mutablePos[0] = pos.x;
|
||||
mutablePos[1] = pos.y;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply group bounds
|
||||
for (const group of graph._groups) {
|
||||
const namespacedGroupId = toGroupLayoutId(group.id);
|
||||
const resolvedBounds = result.groupBounds.get(namespacedGroupId);
|
||||
if (resolvedBounds) {
|
||||
const mutablePos = group.pos as { [index: number]: number };
|
||||
mutablePos[0] = resolvedBounds.x;
|
||||
mutablePos[1] = resolvedBounds.y;
|
||||
|
||||
const mutableSize = group.size as { [index: number]: number };
|
||||
mutableSize[0] = resolvedBounds.width;
|
||||
mutableSize[1] = resolvedBounds.height;
|
||||
|
||||
// NOTE: Do NOT call recomputeInsideNodes() here.
|
||||
// Our adapter mutates pos/size arrays in-place, which doesn't trigger
|
||||
// ComfyUI's internal cache invalidation. recomputeInsideNodes() then
|
||||
// reads stale pre-organize positions and populates _children with
|
||||
// wrong node IDs. Verified 2026-03-21 via runtime inspection.
|
||||
// See AGENTS.md for more details.
|
||||
}
|
||||
}
|
||||
|
||||
// Mark canvas as dirty
|
||||
if (graph.setDirtyCanvas) {
|
||||
graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
debugLog(
|
||||
`Applied: ${result.positions.size} positions, ${result.groupBounds.size} group bounds`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract layout input for specific groups only (selected-groups mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function extractGroupLayoutInput(
|
||||
graph: GraphLike,
|
||||
groupIds: ReadonlySet<number>,
|
||||
): {
|
||||
nodes: LayoutNode[];
|
||||
edges: LayoutEdge[];
|
||||
groups: LayoutGroup[];
|
||||
} {
|
||||
// Get the full extraction first
|
||||
const full = extractLayoutInput(graph);
|
||||
|
||||
// Filter to only the selected groups and their nested children
|
||||
const selectedLayoutGroupIds = new Set(
|
||||
[...groupIds].map((groupId) => toGroupLayoutId(groupId)),
|
||||
);
|
||||
|
||||
// Expand to include all nested child groups
|
||||
const allGroupIds = new Set(selectedLayoutGroupIds);
|
||||
const groupMap = new Map(full.groups.map((g) => [g.id, g]));
|
||||
|
||||
function addChildren(gId: string): void {
|
||||
const g = groupMap.get(gId);
|
||||
if (!g) return;
|
||||
for (const childId of g.childGroupIds) {
|
||||
if (!allGroupIds.has(childId)) {
|
||||
allGroupIds.add(childId);
|
||||
addChildren(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const gId of selectedLayoutGroupIds) {
|
||||
addChildren(gId);
|
||||
}
|
||||
|
||||
// Filter groups
|
||||
const groups = full.groups.filter((g) => allGroupIds.has(g.id));
|
||||
|
||||
// Collect all node IDs that belong to these groups
|
||||
const relevantNodeIds = new Set<string>();
|
||||
for (const g of groups) {
|
||||
for (const mId of g.memberIds) {
|
||||
relevantNodeIds.add(mId);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter nodes and edges
|
||||
const nodes = full.nodes.filter((n) => relevantNodeIds.has(n.id));
|
||||
const edges = full.edges.filter(
|
||||
(e) => relevantNodeIds.has(e.source) && relevantNodeIds.has(e.target),
|
||||
);
|
||||
|
||||
return { nodes, edges, groups };
|
||||
}
|
||||
|
||||
export function anchorSelectedGroupLayoutResult(
|
||||
graph: GraphLike,
|
||||
groups: ReadonlyArray<LayoutGroup>,
|
||||
selectedGroupIds: ReadonlySet<number>,
|
||||
result: FrameworkResult,
|
||||
): FrameworkResult {
|
||||
const selectedLayoutGroupIds = new Set(
|
||||
[...selectedGroupIds].map((groupId) => toGroupLayoutId(groupId)),
|
||||
);
|
||||
if (selectedLayoutGroupIds.size === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const parentByChildId = new Map<string, string>();
|
||||
const groupById = new Map(groups.map((group) => [group.id, group]));
|
||||
for (const group of groups) {
|
||||
for (const childGroupId of group.childGroupIds) {
|
||||
parentByChildId.set(childGroupId, group.id);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedRootIds = [...selectedLayoutGroupIds].filter((groupId) => {
|
||||
let parentId = parentByChildId.get(groupId);
|
||||
while (parentId) {
|
||||
if (selectedLayoutGroupIds.has(parentId)) {
|
||||
return false;
|
||||
}
|
||||
parentId = parentByChildId.get(parentId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (selectedRootIds.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const anchoredPositions = new Map(result.positions);
|
||||
const anchoredGroupBounds = new Map(result.groupBounds);
|
||||
const graphGroupById = new Map(
|
||||
graph._groups.map((group) => [group.id, group]),
|
||||
);
|
||||
|
||||
for (const rootGroupId of selectedRootIds) {
|
||||
const originalGraphGroupId = fromGroupLayoutId(rootGroupId);
|
||||
if (originalGraphGroupId === null) continue;
|
||||
|
||||
const originalGroup = graphGroupById.get(originalGraphGroupId);
|
||||
const layoutRootBounds = anchoredGroupBounds.get(rootGroupId);
|
||||
if (!originalGroup || !layoutRootBounds) continue;
|
||||
|
||||
const dx = Number(originalGroup.pos[0]) - layoutRootBounds.x;
|
||||
const dy = Number(originalGroup.pos[1]) - layoutRootBounds.y;
|
||||
if (dx === 0 && dy === 0) continue;
|
||||
|
||||
const nodeIdsToTranslate = new Set<string>();
|
||||
const groupIdsToTranslate = new Set<string>();
|
||||
const stack = [rootGroupId];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const groupId = stack.pop();
|
||||
if (!groupId || groupIdsToTranslate.has(groupId)) continue;
|
||||
|
||||
groupIdsToTranslate.add(groupId);
|
||||
const group = groupById.get(groupId);
|
||||
if (!group) continue;
|
||||
|
||||
for (const memberId of group.memberIds) {
|
||||
nodeIdsToTranslate.add(memberId);
|
||||
}
|
||||
for (const childGroupId of group.childGroupIds) {
|
||||
stack.push(childGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const nodeId of nodeIdsToTranslate) {
|
||||
const pos = anchoredPositions.get(nodeId);
|
||||
if (!pos) continue;
|
||||
anchoredPositions.set(nodeId, { x: pos.x + dx, y: pos.y + dy });
|
||||
}
|
||||
|
||||
for (const groupId of groupIdsToTranslate) {
|
||||
const bounds = anchoredGroupBounds.get(groupId);
|
||||
if (!bounds) continue;
|
||||
anchoredGroupBounds.set(groupId, {
|
||||
x: bounds.x + dx,
|
||||
y: bounds.y + dy,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
positions: anchoredPositions,
|
||||
groupBounds: anchoredGroupBounds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Position, GroupBounds } from "./layout/types";
|
||||
|
||||
export interface BoundsItem {
|
||||
readonly id: number | string;
|
||||
readonly pos: ArrayLike<number>;
|
||||
readonly size: ArrayLike<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the bounding rect of all laid-out items for fit-to-view.
|
||||
* Includes regular nodes, boundary nodes (inputNode/outputNode), and groups.
|
||||
*/
|
||||
export function computeGraphBounds(
|
||||
nodes: ReadonlyArray<BoundsItem>,
|
||||
inputNode: BoundsItem | undefined,
|
||||
outputNode: BoundsItem | undefined,
|
||||
positions: ReadonlyMap<string, Position>,
|
||||
groupBounds: ReadonlyMap<string, GroupBounds>,
|
||||
): [number, number, number, number] | null {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
const expandWithItem = (item: BoundsItem): void => {
|
||||
const pos = positions.get(String(item.id));
|
||||
if (!pos) return;
|
||||
const w = item.size[0] ?? 0;
|
||||
const h = item.size[1] ?? 0;
|
||||
if (pos.x < minX) minX = pos.x;
|
||||
if (pos.y < minY) minY = pos.y;
|
||||
if (pos.x + w > maxX) maxX = pos.x + w;
|
||||
if (pos.y + h > maxY) maxY = pos.y + h;
|
||||
};
|
||||
|
||||
for (const node of nodes) {
|
||||
expandWithItem(node);
|
||||
}
|
||||
|
||||
if (inputNode) expandWithItem(inputNode);
|
||||
if (outputNode) expandWithItem(outputNode);
|
||||
|
||||
for (const [, gb] of groupBounds) {
|
||||
if (gb.x < minX) minX = gb.x;
|
||||
if (gb.y < minY) minY = gb.y;
|
||||
if (gb.x + gb.width > maxX) maxX = gb.x + gb.width;
|
||||
if (gb.y + gb.height > maxY) maxY = gb.y + gb.height;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(minX)) return null;
|
||||
return [minX, minY, maxX - minX, maxY - minY];
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export const SETTINGS_PREFIX = "Node Organizer";
|
||||
export const SETTINGS_IDS = {
|
||||
VERSION: `${SETTINGS_PREFIX}. ${SETTINGS_PREFIX}`,
|
||||
DEBUG_LOGGING: `${SETTINGS_PREFIX}.Debug Logging`,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SETTINGS_IDS } from "./consants";
|
||||
import { SETTING_IDS } from "./settings";
|
||||
|
||||
/** Check if debug logging is enabled */
|
||||
export function isDebugEnabled(): boolean {
|
||||
@@ -8,13 +8,15 @@ export function isDebugEnabled(): boolean {
|
||||
const app = (
|
||||
globalThis as {
|
||||
app?: {
|
||||
extensionManager?: { setting?: { get: <T>(id: string) => T } };
|
||||
extensionManager?: {
|
||||
setting?: { get: <T>(id: string) => T };
|
||||
};
|
||||
};
|
||||
}
|
||||
).app;
|
||||
return (
|
||||
app?.extensionManager?.setting?.get<boolean>(
|
||||
SETTINGS_IDS.DEBUG_LOGGING
|
||||
SETTING_IDS.DEBUG_LOGGING,
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface PositionedItem {
|
||||
readonly pos: ArrayLike<number>;
|
||||
readonly size: ArrayLike<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
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
centerX >= gx &&
|
||||
centerX <= gx + gw &&
|
||||
centerY >= gy &&
|
||||
centerY <= gy + gh
|
||||
);
|
||||
}
|
||||
@@ -1,181 +1,413 @@
|
||||
/**
|
||||
* ComfyUI Node Organizer — extension entry point.
|
||||
*
|
||||
* Registers commands, keybindings, settings, and context menu items
|
||||
* that wire the pure layout framework into ComfyUI's runtime.
|
||||
*/
|
||||
|
||||
import type { ComfyApp } from "@comfyorg/comfyui-frontend-types";
|
||||
import type { LGraph, LGraphGroup } from "./layout/types";
|
||||
import { layoutGraph, layoutSelectedGroups } from "./layout";
|
||||
import { SETTINGS_IDS } from "./consants";
|
||||
|
||||
/** Context menu option */
|
||||
interface ContextMenuOption {
|
||||
content: string;
|
||||
callback?: () => void;
|
||||
}
|
||||
|
||||
/** LGraphCanvas type (available at runtime) */
|
||||
interface LGraphCanvasType {
|
||||
prototype: {
|
||||
getCanvasMenuOptions: () => (ContextMenuOption | null)[];
|
||||
};
|
||||
}
|
||||
import {
|
||||
SETTING_IDS,
|
||||
EXTENSION_NAME,
|
||||
SETTINGS_PREFIX,
|
||||
REPOSITORY_URL,
|
||||
CURRENT_VERSION,
|
||||
DEFAULT_ALGORITHM_OPTIONS,
|
||||
} from "./settings";
|
||||
import type { DefaultAlgorithmName } from "./settings";
|
||||
import { debugLog } from "./debug";
|
||||
import {
|
||||
extractLayoutInput,
|
||||
extractGroupLayoutInput,
|
||||
applyLayoutOutput,
|
||||
anchorSelectedGroupLayoutResult,
|
||||
} 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";
|
||||
import { isGroup } from "./utils";
|
||||
import { getCurrentGraph, getSelectedGroups, type AppLike } from "./runtime";
|
||||
|
||||
// Declare the global app variable injected by ComfyUI's runtime loader
|
||||
declare global {
|
||||
const app: ComfyApp & {
|
||||
canvas?: {
|
||||
selectedItems?: Set<unknown>;
|
||||
};
|
||||
const app: ComfyApp;
|
||||
}
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getDefaultAlgorithmName(): DefaultAlgorithmName {
|
||||
const raw = (app.extensionManager?.setting?.get?.<string>(
|
||||
SETTING_IDS.DEFAULT_ALGORITHM,
|
||||
) ?? DEFAULT_ALGORITHM_NAME) as string;
|
||||
|
||||
return DEFAULT_ALGORITHM_OPTIONS.includes(raw as DefaultAlgorithmName)
|
||||
? (raw as DefaultAlgorithmName)
|
||||
: DEFAULT_ALGORITHM_NAME;
|
||||
}
|
||||
|
||||
export function createConfiguredDefaultAlgorithm(
|
||||
algorithmName: DefaultAlgorithmName,
|
||||
config: Partial<FrameworkConfig>,
|
||||
): LayoutAlgorithm {
|
||||
return algorithmFactories[algorithmName](config);
|
||||
}
|
||||
|
||||
function getDefaultAlgorithm(config: Partial<FrameworkConfig>): LayoutAlgorithm {
|
||||
return createConfiguredDefaultAlgorithm(getDefaultAlgorithmName(), config);
|
||||
}
|
||||
|
||||
function getFrameworkConfig(): Partial<FrameworkConfig> {
|
||||
const get = app.extensionManager?.setting?.get;
|
||||
if (!get) return {};
|
||||
return {
|
||||
horizontalGap:
|
||||
get<number | undefined>(SETTING_IDS.HORIZONTAL_GAP as never) ??
|
||||
DEFAULT_FRAMEWORK_CONFIG.horizontalGap,
|
||||
verticalGap:
|
||||
get<number | undefined>(SETTING_IDS.VERTICAL_GAP as never) ??
|
||||
DEFAULT_FRAMEWORK_CONFIG.verticalGap,
|
||||
groupPadding:
|
||||
get<number | undefined>(SETTING_IDS.GROUP_PADDING as never) ??
|
||||
DEFAULT_FRAMEWORK_CONFIG.groupPadding,
|
||||
disconnectedGap:
|
||||
get<number | undefined>(SETTING_IDS.DISCONNECTED_GAP as never) ??
|
||||
DEFAULT_FRAMEWORK_CONFIG.disconnectedGap,
|
||||
};
|
||||
const LGraphCanvas: LGraphCanvasType;
|
||||
|
||||
interface Window {
|
||||
app: ComfyApp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value is array-like (regular array or typed array like Float64Array)
|
||||
*/
|
||||
function isArrayLike(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return true;
|
||||
if (!value || typeof value !== "object") return false;
|
||||
// Check for typed arrays (Float64Array, etc.) which have numeric length
|
||||
const obj = value as { length?: unknown };
|
||||
return typeof obj.length === "number" && obj.length >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item is a group (duck typing)
|
||||
* Groups have pos/size (can be Float64Array), title, id, but no .type like nodes
|
||||
*/
|
||||
function isLGraphGroup(item: unknown): item is LGraphGroup {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const obj = item as Record<string, unknown>;
|
||||
function getFitToView(): boolean {
|
||||
return (
|
||||
isArrayLike(obj.pos) &&
|
||||
isArrayLike(obj.size) &&
|
||||
typeof obj.title === "string" &&
|
||||
typeof obj.id === "number" &&
|
||||
typeof obj.type !== "string" // Nodes have .type, groups don't
|
||||
app.extensionManager?.setting?.get?.<boolean>(
|
||||
SETTING_IDS.FIT_TO_VIEW as never,
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected groups from global app.canvas.selectedItems
|
||||
* Per ComfyUI docs: use app.canvas.selectedItems for selection access
|
||||
*/
|
||||
function getSelectedGroups(): LGraphGroup[] {
|
||||
const groups: LGraphGroup[] = [];
|
||||
const selectedItems = app.canvas?.selectedItems;
|
||||
if (!selectedItems) return groups;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const item of selectedItems) {
|
||||
if (isLGraphGroup(item)) {
|
||||
groups.push(item);
|
||||
/** Organize every node in the current graph. */
|
||||
function executeOrganization(mode: "workflow" | "groups"): void {
|
||||
const graph = getCurrentGraph(app as AppLike<GraphLike>);
|
||||
if (!graph) {
|
||||
console.warn("[node-organizer] No active graph");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getFrameworkConfig();
|
||||
const algorithm = getDefaultAlgorithm(config);
|
||||
|
||||
const start = performance.now();
|
||||
debugLog(`Starting ${mode} organization with ${algorithm.name}...`);
|
||||
|
||||
try {
|
||||
const selectedGroupIds =
|
||||
mode === "groups"
|
||||
? new Set(getSelectedGroups(app.canvas).map((group) => group.id))
|
||||
: null;
|
||||
const input =
|
||||
mode === "workflow"
|
||||
? extractLayoutInput(graph)
|
||||
: extractGroupLayoutInput(graph, selectedGroupIds ?? new Set<number>());
|
||||
const { nodes, edges, groups } = input;
|
||||
const rawResult = layoutWithGroups(nodes, edges, groups, algorithm, config);
|
||||
const result =
|
||||
mode === "groups" && selectedGroupIds
|
||||
? anchorSelectedGroupLayoutResult(
|
||||
graph,
|
||||
groups,
|
||||
selectedGroupIds,
|
||||
rawResult,
|
||||
)
|
||||
: rawResult;
|
||||
applyLayoutOutput(graph, result);
|
||||
|
||||
const elapsed = (performance.now() - start).toFixed(1);
|
||||
debugLog(
|
||||
`Organized ${nodes.length} nodes in ${elapsed}ms with ${algorithm.name}`,
|
||||
);
|
||||
|
||||
if (mode === "workflow" && getFitToView()) {
|
||||
const bounds = computeGraphBounds(
|
||||
graph._nodes,
|
||||
graph.inputNode,
|
||||
graph.outputNode,
|
||||
result.positions,
|
||||
result.groupBounds,
|
||||
);
|
||||
|
||||
if (bounds) {
|
||||
// Defer by one frame: applyLayoutOutput calls setDirtyCanvas, but the
|
||||
// canvas needs one animation frame to process the dirty flag.
|
||||
requestAnimationFrame(() => {
|
||||
const canvas = app.canvas as {
|
||||
animateToBounds?: (
|
||||
bounds: readonly [number, number, number, number],
|
||||
opts?: { zoom?: number },
|
||||
) => void;
|
||||
} | null;
|
||||
canvas?.animateToBounds?.(bounds, { zoom: 0.9 });
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[node-organizer] ${mode} layout failed: ${message}`);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI Node Organizer",
|
||||
settings: [
|
||||
{
|
||||
id: SETTINGS_IDS.VERSION,
|
||||
name: "Version 1.3.0",
|
||||
type: () => {
|
||||
const spanEl = document.createElement("span");
|
||||
spanEl.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
`<a href="https://github.com/PBandDev/comfyui-node-organizer" target="_blank" style="padding-right: 12px;">Homepage</a>`
|
||||
);
|
||||
/** Organize the full workflow. */
|
||||
function organizeWorkflow(): void {
|
||||
executeOrganization("workflow");
|
||||
}
|
||||
|
||||
return spanEl;
|
||||
},
|
||||
defaultValue: undefined,
|
||||
/** Organize nodes within currently selected groups. */
|
||||
function organizeSelectedGroups(): void {
|
||||
const selectedGroups = getSelectedGroups(app.canvas);
|
||||
if (selectedGroups.length === 0) {
|
||||
console.warn("[node-organizer] No groups selected");
|
||||
return;
|
||||
}
|
||||
executeOrganization("groups");
|
||||
}
|
||||
|
||||
/** Context-aware organize: selected groups if any, otherwise full workflow. */
|
||||
function organizeContextAware(): void {
|
||||
const selectedGroups = getSelectedGroups(app.canvas);
|
||||
if (selectedGroups.length > 0) {
|
||||
executeOrganization("groups");
|
||||
} else {
|
||||
executeOrganization("workflow");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extension registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.registerExtension({
|
||||
name: EXTENSION_NAME,
|
||||
|
||||
settings: [
|
||||
// --- About (sortOrder 50 — highest = first in ComfyUI's descending sort) ---
|
||||
{
|
||||
id: SETTING_IDS.VERSION as never,
|
||||
name: `Version ${CURRENT_VERSION}`,
|
||||
category: [SETTINGS_PREFIX, "About", "Version"],
|
||||
sortOrder: 50,
|
||||
type: (() => {
|
||||
const span = document.createElement("span");
|
||||
const link = document.createElement("a");
|
||||
link.href = REPOSITORY_URL;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.style.paddingRight = "12px";
|
||||
link.textContent = "Homepage";
|
||||
span.appendChild(link);
|
||||
return span;
|
||||
}) as never,
|
||||
defaultValue: undefined as never,
|
||||
},
|
||||
// --- Layout (sortOrder 40–44) ---
|
||||
{
|
||||
id: SETTING_IDS.DEFAULT_ALGORITHM as never,
|
||||
name: "Default Layout Algorithm",
|
||||
category: [SETTINGS_PREFIX, "Layout", "Algorithm"],
|
||||
sortOrder: 44,
|
||||
type: "combo",
|
||||
tooltip:
|
||||
"Default algorithm for organize commands when a group title token does not override it",
|
||||
options: DEFAULT_ALGORITHM_OPTIONS.map((value) => ({
|
||||
text: value[0].toUpperCase() + value.slice(1),
|
||||
value,
|
||||
})),
|
||||
defaultValue: DEFAULT_ALGORITHM_NAME,
|
||||
},
|
||||
{
|
||||
id: SETTINGS_IDS.DEBUG_LOGGING,
|
||||
name: "Enable Debug Logging",
|
||||
id: SETTING_IDS.HORIZONTAL_GAP as never,
|
||||
name: "Horizontal Gap",
|
||||
category: [SETTINGS_PREFIX, "Layout", "Horizontal Gap"],
|
||||
sortOrder: 43,
|
||||
type: "slider",
|
||||
tooltip: `Space between nodes left-to-right in pixels. Default: ${DEFAULT_FRAMEWORK_CONFIG.horizontalGap}`,
|
||||
attrs: { min: 20, max: 300, step: 10 },
|
||||
defaultValue: DEFAULT_FRAMEWORK_CONFIG.horizontalGap,
|
||||
},
|
||||
{
|
||||
id: SETTING_IDS.VERTICAL_GAP as never,
|
||||
name: "Vertical Gap",
|
||||
category: [SETTINGS_PREFIX, "Layout", "Vertical Gap"],
|
||||
sortOrder: 42,
|
||||
type: "slider",
|
||||
tooltip: `Space between nodes top-to-bottom in pixels. Default: ${DEFAULT_FRAMEWORK_CONFIG.verticalGap}`,
|
||||
attrs: { min: 10, max: 150, step: 10 },
|
||||
defaultValue: DEFAULT_FRAMEWORK_CONFIG.verticalGap,
|
||||
},
|
||||
{
|
||||
id: SETTING_IDS.GROUP_PADDING as never,
|
||||
name: "Group Padding",
|
||||
category: [SETTINGS_PREFIX, "Layout", "Group Padding"],
|
||||
sortOrder: 41,
|
||||
type: "slider",
|
||||
tooltip: `Inner padding of group boxes in pixels. Default: ${DEFAULT_FRAMEWORK_CONFIG.groupPadding}`,
|
||||
attrs: { min: 10, max: 100, step: 5 },
|
||||
defaultValue: DEFAULT_FRAMEWORK_CONFIG.groupPadding,
|
||||
},
|
||||
{
|
||||
id: SETTING_IDS.DISCONNECTED_GAP as never,
|
||||
name: "Disconnected Node Gap",
|
||||
category: [SETTINGS_PREFIX, "Layout", "Disconnected Gap"],
|
||||
sortOrder: 40,
|
||||
type: "slider",
|
||||
tooltip: `Gap between disconnected nodes (e.g. notes) and the main graph in pixels. Default: ${DEFAULT_FRAMEWORK_CONFIG.disconnectedGap}`,
|
||||
attrs: { min: 50, max: 400, step: 10 },
|
||||
defaultValue: DEFAULT_FRAMEWORK_CONFIG.disconnectedGap,
|
||||
},
|
||||
// --- Behavior (sortOrder 30) ---
|
||||
{
|
||||
id: SETTING_IDS.FIT_TO_VIEW as never,
|
||||
name: "Fit to View After Organize",
|
||||
category: [SETTINGS_PREFIX, "Behavior", "Fit to View"],
|
||||
sortOrder: 30,
|
||||
type: "boolean",
|
||||
tooltip:
|
||||
"Show detailed debug logs in browser console during workflow organization",
|
||||
"Automatically zoom to fit the graph after organizing the full workflow. Does not apply to selected-group organize.",
|
||||
defaultValue: false,
|
||||
},
|
||||
// --- Keybindings (sortOrder 20) ---
|
||||
{
|
||||
id: SETTING_IDS.KEYBINDINGS as never,
|
||||
name: "Keyboard Shortcuts",
|
||||
category: [SETTINGS_PREFIX, "Keybindings", "Info"],
|
||||
sortOrder: 20,
|
||||
type: (() => {
|
||||
const span = document.createElement("span");
|
||||
span.style.color = "var(--p-text-muted-color, #999)";
|
||||
span.textContent =
|
||||
"Default: Shift+O (Organize Workflow). Edit shortcuts in Settings > Keybinding.";
|
||||
return span;
|
||||
}) as never,
|
||||
defaultValue: undefined as never,
|
||||
},
|
||||
// --- Advanced (sortOrder 10 — lowest = last in ComfyUI's descending sort) ---
|
||||
{
|
||||
id: SETTING_IDS.DEBUG_LOGGING as never,
|
||||
name: "Enable Debug Logging",
|
||||
category: [SETTINGS_PREFIX, "Advanced", "Debug Logging"],
|
||||
sortOrder: 10,
|
||||
type: "boolean",
|
||||
tooltip:
|
||||
"Show detailed debug logs in browser console during organization",
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
getCanvasMenuItems(canvas) {
|
||||
const items: (ContextMenuOption | null)[] = [
|
||||
|
||||
commands: [
|
||||
{
|
||||
id: "node-organizer.organize",
|
||||
function: organizeContextAware,
|
||||
label: "Organize",
|
||||
icon: "pi pi-sitemap",
|
||||
tooltip:
|
||||
"Organize selected groups if any are selected, otherwise organize the full workflow",
|
||||
},
|
||||
{
|
||||
id: "node-organizer.organize-workflow",
|
||||
function: organizeWorkflow,
|
||||
label: "Organize Workflow",
|
||||
icon: "pi pi-sitemap",
|
||||
tooltip: "Organize all nodes in the current view",
|
||||
},
|
||||
{
|
||||
id: "node-organizer.organize-groups",
|
||||
function: organizeSelectedGroups,
|
||||
label: "Organize Group",
|
||||
icon: "pi pi-objects-column",
|
||||
tooltip: "Organize nodes within selected groups",
|
||||
},
|
||||
],
|
||||
|
||||
keybindings: [
|
||||
{
|
||||
commandId: "node-organizer.organize",
|
||||
combo: { key: "o", shift: true },
|
||||
},
|
||||
],
|
||||
|
||||
menuCommands: [
|
||||
{
|
||||
path: ["Extensions", "Node Organizer"],
|
||||
commands: [
|
||||
"node-organizer.organize-workflow",
|
||||
"node-organizer.organize-groups",
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
actionBarButtons: [
|
||||
{
|
||||
label: "Organize",
|
||||
icon: "pi pi-sitemap",
|
||||
tooltip: "Organize the current workflow",
|
||||
onClick: organizeWorkflow,
|
||||
},
|
||||
],
|
||||
|
||||
getCanvasMenuItems() {
|
||||
const items: Array<{
|
||||
content: string;
|
||||
callback: () => void;
|
||||
} | null> = [
|
||||
null, // separator
|
||||
{
|
||||
content: "Organize Workflow",
|
||||
callback: () => {
|
||||
const graph = canvas.getCurrentGraph() as LGraph | null;
|
||||
if (!graph) {
|
||||
console.warn("[node-organizer] No active graph");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = layoutGraph(graph);
|
||||
console.log(
|
||||
`[node-organizer] Layout complete: ${result.nodeCount} nodes, ` +
|
||||
`${result.layerCount} layers, ${result.groupCount} groups, ` +
|
||||
`${result.executionMs.toFixed(1)}ms`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[node-organizer] Layout failed:", err);
|
||||
}
|
||||
},
|
||||
callback: organizeWorkflow,
|
||||
},
|
||||
];
|
||||
|
||||
// Check for selected groups via app.canvas.selectedItems
|
||||
const selectedGroups = getSelectedGroups();
|
||||
|
||||
const selectedGroups = getSelectedGroups(app.canvas);
|
||||
if (selectedGroups.length === 1) {
|
||||
items.push({
|
||||
content: "Organize Group",
|
||||
callback: () => {
|
||||
const graph = canvas.getCurrentGraph() as LGraph | null;
|
||||
if (!graph) {
|
||||
console.warn("[node-organizer] No active graph");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const groupIds = new Set([selectedGroups[0].id]);
|
||||
const result = layoutSelectedGroups(graph, groupIds);
|
||||
console.log(
|
||||
`[node-organizer] Group organized: ${result.nodeCount} nodes, ` +
|
||||
`${result.groupCount} groups, ${result.executionMs.toFixed(1)}ms`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[node-organizer] Group layout failed:", err);
|
||||
}
|
||||
},
|
||||
callback: organizeSelectedGroups,
|
||||
});
|
||||
} else if (selectedGroups.length > 1) {
|
||||
items.push({
|
||||
content: `Organize ${selectedGroups.length} Groups`,
|
||||
callback: () => {
|
||||
const graph = canvas.getCurrentGraph() as LGraph | null;
|
||||
if (!graph) {
|
||||
console.warn("[node-organizer] No active graph");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const groupIds = new Set(selectedGroups.map((g) => g.id));
|
||||
const result = layoutSelectedGroups(graph, groupIds);
|
||||
console.log(
|
||||
`[node-organizer] Groups organized: ${result.nodeCount} nodes, ` +
|
||||
`${result.groupCount} groups, ${result.executionMs.toFixed(1)}ms`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[node-organizer] Groups layout failed:", err);
|
||||
}
|
||||
},
|
||||
callback: organizeSelectedGroups,
|
||||
});
|
||||
}
|
||||
|
||||
items.push(null); // separator
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
getSelectionToolboxCommands(selectedItem) {
|
||||
if (isGroup(selectedItem)) {
|
||||
return ["node-organizer.organize-groups"];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Grid layout algorithm — distributes nodes into rows and columns.
|
||||
*
|
||||
* Supports specifying either a row count or column count.
|
||||
* Nodes fill left-to-right, top-to-bottom.
|
||||
* Ignores edges (token layouts ignore DAG structure).
|
||||
*/
|
||||
|
||||
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types";
|
||||
|
||||
const DEFAULT_GAP = 40;
|
||||
|
||||
export interface GridConfig {
|
||||
readonly rows?: number;
|
||||
readonly columns?: number;
|
||||
readonly gap?: number;
|
||||
}
|
||||
|
||||
export function createGridAlgorithm(config: GridConfig): LayoutAlgorithm {
|
||||
const gap = config.gap ?? DEFAULT_GAP;
|
||||
|
||||
return {
|
||||
name: "grid",
|
||||
layout(input: LayoutInput): LayoutOutput {
|
||||
const positions = new Map<string, Position>();
|
||||
const { nodes } = input;
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return { positions };
|
||||
}
|
||||
|
||||
const n = nodes.length;
|
||||
|
||||
// Determine grid dimensions
|
||||
let rows: number;
|
||||
let cols: number;
|
||||
|
||||
if (config.rows !== undefined) {
|
||||
rows = config.rows;
|
||||
cols = Math.ceil(n / rows);
|
||||
} else if (config.columns !== undefined) {
|
||||
cols = config.columns;
|
||||
rows = Math.ceil(n / cols);
|
||||
} else {
|
||||
// Default: sqrt(n) columns
|
||||
cols = Math.ceil(Math.sqrt(n));
|
||||
rows = Math.ceil(n / cols);
|
||||
}
|
||||
|
||||
// Find max node dimensions for uniform cell sizing
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
for (const node of nodes) {
|
||||
if (node.width > maxWidth) maxWidth = node.width;
|
||||
if (node.height > maxHeight) maxHeight = node.height;
|
||||
}
|
||||
|
||||
const cellWidth = maxWidth + gap;
|
||||
const cellHeight = maxHeight + gap;
|
||||
|
||||
// Place nodes left-to-right, top-to-bottom
|
||||
// Node index i goes to (row = floor(i / cols), col = i % cols)
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
const row = Math.floor(i / cols);
|
||||
const col = i % cols;
|
||||
|
||||
// Center node within its cell
|
||||
const x = col * cellWidth + (maxWidth - node.width) / 2;
|
||||
const y = row * cellHeight + (maxHeight - node.height) / 2;
|
||||
|
||||
positions.set(node.id, { x, y });
|
||||
}
|
||||
|
||||
return { positions };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Horizontal layout algorithm — places nodes in a single left-to-right row.
|
||||
*
|
||||
* Ignores edges entirely (token layouts ignore DAG structure).
|
||||
* Nodes are placed in input order; vertically centered by midpoint.
|
||||
*/
|
||||
|
||||
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types";
|
||||
|
||||
const DEFAULT_GAP = 40;
|
||||
|
||||
export function createHorizontalAlgorithm(gap: number = DEFAULT_GAP): LayoutAlgorithm {
|
||||
return {
|
||||
name: "horizontal",
|
||||
layout(input: LayoutInput): LayoutOutput {
|
||||
const positions = new Map<string, Position>();
|
||||
const { nodes } = input;
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return { positions };
|
||||
}
|
||||
|
||||
// Find max height for vertical centering
|
||||
let maxHeight = 0;
|
||||
for (const node of nodes) {
|
||||
if (node.height > maxHeight) maxHeight = node.height;
|
||||
}
|
||||
|
||||
// Place nodes left-to-right, centered vertically
|
||||
let currentX = 0;
|
||||
for (const node of nodes) {
|
||||
const centeredY = maxHeight / 2 - node.height / 2;
|
||||
positions.set(node.id, { x: currentX, y: centeredY });
|
||||
currentX += node.width + gap;
|
||||
}
|
||||
|
||||
return { positions };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Sugiyama layered DAG layout algorithm.
|
||||
*
|
||||
* Implements the classic 4-phase approach:
|
||||
* 1. Cycle breaking (greedy feedback arc set)
|
||||
* 2. Layer assignment (longest-path)
|
||||
* 3. Crossing minimization (barycenter heuristic)
|
||||
* 4. Coordinate assignment (stacked + centered)
|
||||
*
|
||||
* All steps are pure functions — no side effects, deterministic output.
|
||||
*/
|
||||
|
||||
import type {
|
||||
LayoutAlgorithm,
|
||||
LayoutInput,
|
||||
LayoutOutput,
|
||||
LayoutNode,
|
||||
} from "../types";
|
||||
import { breakCycles } from "./sugiyama/cycle-break";
|
||||
import { assignLayers } from "./sugiyama/layer-assign";
|
||||
import { minimizeCrossings } from "./sugiyama/crossing-minimize";
|
||||
import { assignCoordinates } from "./sugiyama/coordinate-assign";
|
||||
|
||||
export interface SugiyamaConfig {
|
||||
readonly horizontalGap: number;
|
||||
readonly verticalGap: number;
|
||||
readonly maxIterations: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SUGIYAMA_CONFIG: SugiyamaConfig = {
|
||||
horizontalGap: 100,
|
||||
verticalGap: 40,
|
||||
maxIterations: 24,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Sugiyama layout algorithm instance.
|
||||
* The returned object implements LayoutAlgorithm and can be passed to
|
||||
* the layout framework.
|
||||
*/
|
||||
export function createSugiyamaAlgorithm(
|
||||
config?: Partial<SugiyamaConfig>,
|
||||
): LayoutAlgorithm {
|
||||
const cfg: SugiyamaConfig = { ...DEFAULT_SUGIYAMA_CONFIG, ...config };
|
||||
|
||||
return {
|
||||
name: "sugiyama",
|
||||
|
||||
layout(input: LayoutInput): LayoutOutput {
|
||||
const { nodes, edges } = input;
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return { positions: new Map() };
|
||||
}
|
||||
|
||||
// Build node lookup
|
||||
const nodeMap = new Map<string, LayoutNode>();
|
||||
for (const n of nodes) {
|
||||
nodeMap.set(n.id, n);
|
||||
}
|
||||
|
||||
// Phase 1: Break cycles
|
||||
const { edges: acyclicEdges } = breakCycles(nodes, edges);
|
||||
|
||||
// Phase 2: Assign layers (longest-path)
|
||||
const layerMap = assignLayers(nodes, acyclicEdges);
|
||||
|
||||
// Build layer arrays from the map
|
||||
const layerArrays = buildLayerArrays(layerMap, nodes);
|
||||
|
||||
// Phase 3: Minimize crossings (barycenter heuristic)
|
||||
const orderedLayers = minimizeCrossings(
|
||||
layerArrays,
|
||||
acyclicEdges,
|
||||
nodeMap,
|
||||
cfg.maxIterations,
|
||||
);
|
||||
|
||||
// Phase 4: Assign coordinates
|
||||
const positions = assignCoordinates(orderedLayers, nodeMap, {
|
||||
horizontalGap: cfg.horizontalGap,
|
||||
verticalGap: cfg.verticalGap,
|
||||
});
|
||||
|
||||
return { positions };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a layer map (node -> layer index) into an array of arrays,
|
||||
* preserving original node order within each layer for determinism.
|
||||
*/
|
||||
function buildLayerArrays(
|
||||
layerMap: ReadonlyMap<string, number>,
|
||||
nodes: ReadonlyArray<LayoutNode>,
|
||||
): ReadonlyArray<ReadonlyArray<string>> {
|
||||
// Find max layer
|
||||
let maxLayer = 0;
|
||||
for (const layer of layerMap.values()) {
|
||||
if (layer > maxLayer) maxLayer = layer;
|
||||
}
|
||||
|
||||
// Build arrays preserving input order within each layer
|
||||
const layers: string[][] = [];
|
||||
for (let i = 0; i <= maxLayer; i++) {
|
||||
layers.push([]);
|
||||
}
|
||||
|
||||
for (const n of nodes) {
|
||||
const layer = layerMap.get(n.id);
|
||||
if (layer !== undefined) {
|
||||
layers[layer].push(n.id);
|
||||
}
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Coordinate assignment for layered graphs.
|
||||
*
|
||||
* Assigns X coordinates based on cumulative layer widths (left-to-right)
|
||||
* and Y coordinates by stacking nodes top-to-bottom within each layer.
|
||||
*/
|
||||
|
||||
import type { LayoutNode, Position } from "../../types";
|
||||
|
||||
interface CoordinateConfig {
|
||||
readonly horizontalGap: number;
|
||||
readonly verticalGap: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign (x, y) coordinates to all nodes based on their layer ordering.
|
||||
*
|
||||
* - X: each layer starts after the previous layer's max width + horizontalGap
|
||||
* - Y: nodes within a layer are stacked vertically with verticalGap between them
|
||||
* - Vertical centering: each layer is centered relative to the tallest layer
|
||||
* - Overlap sweep: after centering, ensure no overlaps within each layer
|
||||
*/
|
||||
export function assignCoordinates(
|
||||
layers: ReadonlyArray<ReadonlyArray<string>>,
|
||||
nodeMap: ReadonlyMap<string, LayoutNode>,
|
||||
config: CoordinateConfig,
|
||||
): ReadonlyMap<string, Position> {
|
||||
const positions = new Map<string, Position>();
|
||||
|
||||
if (layers.length === 0) return positions;
|
||||
|
||||
// Compute max width per layer and total height per layer
|
||||
const layerMaxWidths: number[] = [];
|
||||
const layerTotalHeights: number[] = [];
|
||||
|
||||
for (const layer of layers) {
|
||||
let maxWidth = 0;
|
||||
let totalHeight = 0;
|
||||
for (let i = 0; i < layer.length; i++) {
|
||||
const node = nodeMap.get(layer[i]);
|
||||
if (!node) continue;
|
||||
if (node.width > maxWidth) maxWidth = node.width;
|
||||
totalHeight += node.height;
|
||||
if (i > 0) totalHeight += config.verticalGap;
|
||||
}
|
||||
layerMaxWidths.push(maxWidth);
|
||||
layerTotalHeights.push(totalHeight);
|
||||
}
|
||||
|
||||
// Find the tallest layer for vertical centering
|
||||
let maxTotalHeight = 0;
|
||||
for (const h of layerTotalHeights) {
|
||||
if (h > maxTotalHeight) maxTotalHeight = h;
|
||||
}
|
||||
|
||||
// Compute X start positions for each layer
|
||||
const layerXStarts: number[] = [];
|
||||
let currentX = 0;
|
||||
for (let li = 0; li < layers.length; li++) {
|
||||
layerXStarts.push(currentX);
|
||||
currentX += layerMaxWidths[li] + config.horizontalGap;
|
||||
}
|
||||
|
||||
// Assign positions: stack top-to-bottom within each layer, then center
|
||||
for (let li = 0; li < layers.length; li++) {
|
||||
const layer = layers[li];
|
||||
const layerX = layerXStarts[li];
|
||||
|
||||
// Center this layer vertically relative to tallest layer
|
||||
const yOffset = (maxTotalHeight - layerTotalHeights[li]) / 2;
|
||||
let currentY = yOffset;
|
||||
|
||||
for (const nodeId of layer) {
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (!node) continue;
|
||||
|
||||
positions.set(nodeId, { x: layerX, y: currentY });
|
||||
currentY += node.height + config.verticalGap;
|
||||
}
|
||||
|
||||
// Overlap sweep: ensure no node overlaps the previous one in this layer.
|
||||
// This handles edge cases where centering + variable sizes create overlaps.
|
||||
for (let i = 1; i < layer.length; i++) {
|
||||
const prevId = layer[i - 1];
|
||||
const currId = layer[i];
|
||||
const prevNode = nodeMap.get(prevId);
|
||||
const prevPos = positions.get(prevId);
|
||||
const currPos = positions.get(currId);
|
||||
if (!prevNode || !prevPos || !currPos) continue;
|
||||
|
||||
const minY = prevPos.y + prevNode.height + config.verticalGap;
|
||||
if (currPos.y < minY) {
|
||||
positions.set(currId, { x: currPos.x, y: minY });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Barycenter crossing minimization.
|
||||
*
|
||||
* Repeatedly sweeps forward and backward through layers, reordering
|
||||
* nodes within each layer by the average (barycenter) position of
|
||||
* their neighbors in the adjacent fixed layer.
|
||||
*/
|
||||
|
||||
import type { LayoutNode, LayoutEdge } from "../../types";
|
||||
|
||||
/**
|
||||
* Reorder nodes within each layer to minimize edge crossings.
|
||||
* Uses the barycenter heuristic with alternating forward/backward sweeps.
|
||||
*
|
||||
* @param layers - Node IDs grouped by layer index
|
||||
* @param edges - Directed edges (assumed acyclic, source layer < target layer)
|
||||
* @param nodeMap - Lookup for node dimensions (used for tie-breaking stability)
|
||||
* @param maxIterations - Number of full forward+backward sweeps (default 24)
|
||||
* @returns Reordered layers
|
||||
*/
|
||||
export function minimizeCrossings(
|
||||
layers: ReadonlyArray<ReadonlyArray<string>>,
|
||||
edges: ReadonlyArray<LayoutEdge>,
|
||||
_nodeMap: ReadonlyMap<string, LayoutNode>,
|
||||
maxIterations: number = 24,
|
||||
): ReadonlyArray<ReadonlyArray<string>> {
|
||||
if (layers.length <= 1) return layers;
|
||||
|
||||
// Build adjacency lookup for cross-layer connections
|
||||
// For each node, store indices of connected nodes in adjacent layers
|
||||
const nodeToLayer = new Map<string, number>();
|
||||
for (let li = 0; li < layers.length; li++) {
|
||||
for (const id of layers[li]) {
|
||||
nodeToLayer.set(id, li);
|
||||
}
|
||||
}
|
||||
|
||||
// Forward neighbors: node -> nodes in the next layer
|
||||
const forwardNeighbors = new Map<string, string[]>();
|
||||
// Backward neighbors: node -> nodes in the previous layer
|
||||
const backwardNeighbors = new Map<string, string[]>();
|
||||
|
||||
for (const layer of layers) {
|
||||
for (const id of layer) {
|
||||
forwardNeighbors.set(id, []);
|
||||
backwardNeighbors.set(id, []);
|
||||
}
|
||||
}
|
||||
|
||||
for (const e of edges) {
|
||||
const srcLayer = nodeToLayer.get(e.source);
|
||||
const tgtLayer = nodeToLayer.get(e.target);
|
||||
if (srcLayer === undefined || tgtLayer === undefined) continue;
|
||||
|
||||
if (tgtLayer > srcLayer) {
|
||||
forwardNeighbors.get(e.source)!.push(e.target);
|
||||
backwardNeighbors.get(e.target)!.push(e.source);
|
||||
} else if (srcLayer > tgtLayer) {
|
||||
forwardNeighbors.get(e.target)!.push(e.source);
|
||||
backwardNeighbors.get(e.source)!.push(e.target);
|
||||
}
|
||||
}
|
||||
|
||||
// Mutable copy of layers
|
||||
let current: string[][] = layers.map((layer) => [...layer]);
|
||||
|
||||
let bestCrossings = countAllCrossings(current, forwardNeighbors);
|
||||
let bestLayers: string[][] = current.map((layer) => [...layer]);
|
||||
|
||||
for (let iter = 0; iter < maxIterations; iter++) {
|
||||
// Forward sweep: fix layer i, reorder layer i+1
|
||||
for (let li = 1; li < current.length; li++) {
|
||||
current[li] = reorderByBarycenter(
|
||||
current[li],
|
||||
current[li - 1],
|
||||
backwardNeighbors,
|
||||
);
|
||||
}
|
||||
|
||||
// Backward sweep: fix layer i, reorder layer i-1
|
||||
for (let li = current.length - 2; li >= 0; li--) {
|
||||
current[li] = reorderByBarycenter(
|
||||
current[li],
|
||||
current[li + 1],
|
||||
forwardNeighbors,
|
||||
);
|
||||
}
|
||||
|
||||
const crossings = countAllCrossings(current, forwardNeighbors);
|
||||
if (crossings < bestCrossings) {
|
||||
bestCrossings = crossings;
|
||||
bestLayers = current.map((layer) => [...layer]);
|
||||
}
|
||||
|
||||
// Early exit if no crossings remain
|
||||
if (bestCrossings === 0) break;
|
||||
}
|
||||
|
||||
return bestLayers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder `layer` based on barycenter positions in `fixedLayer`.
|
||||
* `neighborsMap` maps each node in `layer` to its neighbors in `fixedLayer`.
|
||||
*/
|
||||
function reorderByBarycenter(
|
||||
layer: ReadonlyArray<string>,
|
||||
fixedLayer: ReadonlyArray<string>,
|
||||
neighborsMap: ReadonlyMap<string, string[]>,
|
||||
): string[] {
|
||||
// Build position index for fixed layer
|
||||
const fixedPosition = new Map<string, number>();
|
||||
for (let i = 0; i < fixedLayer.length; i++) {
|
||||
fixedPosition.set(fixedLayer[i], i);
|
||||
}
|
||||
|
||||
// Compute barycenter for each node
|
||||
const barycenters = new Map<string, number>();
|
||||
for (const id of layer) {
|
||||
const neighbors = neighborsMap.get(id) ?? [];
|
||||
const relevantPositions: number[] = [];
|
||||
for (const n of neighbors) {
|
||||
const pos = fixedPosition.get(n);
|
||||
if (pos !== undefined) {
|
||||
relevantPositions.push(pos);
|
||||
}
|
||||
}
|
||||
|
||||
if (relevantPositions.length > 0) {
|
||||
const sum = relevantPositions.reduce((a, b) => a + b, 0);
|
||||
barycenters.set(id, sum / relevantPositions.length);
|
||||
}
|
||||
// Nodes with no connections keep their original position
|
||||
}
|
||||
|
||||
// Sort: nodes with barycenters first (by value), then nodes without (keep original order)
|
||||
const withBarycenter: string[] = [];
|
||||
const withoutBarycenter: string[] = [];
|
||||
const originalIndex = new Map<string, number>();
|
||||
|
||||
for (let i = 0; i < layer.length; i++) {
|
||||
originalIndex.set(layer[i], i);
|
||||
if (barycenters.has(layer[i])) {
|
||||
withBarycenter.push(layer[i]);
|
||||
} else {
|
||||
withoutBarycenter.push(layer[i]);
|
||||
}
|
||||
}
|
||||
|
||||
withBarycenter.sort((a, b) => {
|
||||
const ba = barycenters.get(a)!;
|
||||
const bb = barycenters.get(b)!;
|
||||
if (ba !== bb) return ba - bb;
|
||||
// Stable tie-break: original order
|
||||
return originalIndex.get(a)! - originalIndex.get(b)!;
|
||||
});
|
||||
|
||||
// Merge: interleave nodes without barycenters at their original positions
|
||||
// Simple approach: put barycenter-sorted nodes first, then the rest
|
||||
// Better approach: insert non-barycenter nodes at their original relative positions
|
||||
const result: string[] = new Array(layer.length);
|
||||
const usedSlots = new Set<number>();
|
||||
|
||||
// Place nodes without barycenters at their original indices first
|
||||
for (const id of withoutBarycenter) {
|
||||
const idx = originalIndex.get(id)!;
|
||||
result[idx] = id;
|
||||
usedSlots.add(idx);
|
||||
}
|
||||
|
||||
// Fill remaining slots with barycenter-sorted nodes
|
||||
let bIdx = 0;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
if (!usedSlots.has(i)) {
|
||||
result[i] = withBarycenter[bIdx++];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total edge crossings between all adjacent layer pairs.
|
||||
*/
|
||||
function countAllCrossings(
|
||||
layers: ReadonlyArray<ReadonlyArray<string>>,
|
||||
forwardNeighbors: ReadonlyMap<string, string[]>,
|
||||
): number {
|
||||
let total = 0;
|
||||
for (let li = 0; li < layers.length - 1; li++) {
|
||||
total += countCrossingsBetweenLayers(
|
||||
layers[li],
|
||||
layers[li + 1],
|
||||
forwardNeighbors,
|
||||
);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count edge crossings between two adjacent layers.
|
||||
* Two edges (u1->v1) and (u2->v2) cross if u1 is before u2 in topLayer
|
||||
* but v1 is after v2 in bottomLayer (or vice versa).
|
||||
*/
|
||||
function countCrossingsBetweenLayers(
|
||||
topLayer: ReadonlyArray<string>,
|
||||
bottomLayer: ReadonlyArray<string>,
|
||||
forwardNeighbors: ReadonlyMap<string, string[]>,
|
||||
): number {
|
||||
// Build position maps
|
||||
const bottomPos = new Map<string, number>();
|
||||
for (let i = 0; i < bottomLayer.length; i++) {
|
||||
bottomPos.set(bottomLayer[i], i);
|
||||
}
|
||||
|
||||
// Collect all edges as (topIndex, bottomIndex) pairs
|
||||
const edgePairs: Array<{ top: number; bottom: number }> = [];
|
||||
for (let ti = 0; ti < topLayer.length; ti++) {
|
||||
const neighbors = forwardNeighbors.get(topLayer[ti]) ?? [];
|
||||
for (const n of neighbors) {
|
||||
const bi = bottomPos.get(n);
|
||||
if (bi !== undefined) {
|
||||
edgePairs.push({ top: ti, bottom: bi });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count crossings by comparing all pairs
|
||||
// O(E^2) but fine for typical graph sizes
|
||||
let crossings = 0;
|
||||
for (let i = 0; i < edgePairs.length; i++) {
|
||||
for (let j = i + 1; j < edgePairs.length; j++) {
|
||||
const e1 = edgePairs[i];
|
||||
const e2 = edgePairs[j];
|
||||
if (
|
||||
(e1.top < e2.top && e1.bottom > e2.bottom) ||
|
||||
(e1.top > e2.top && e1.bottom < e2.bottom)
|
||||
) {
|
||||
crossings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return crossings;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Greedy cycle removal via feedback arc set.
|
||||
*
|
||||
* Produces an acyclic edge set by reversing the minimum number of edges
|
||||
* needed to break all cycles. Uses the Berger-Shor greedy heuristic:
|
||||
* repeatedly extract sinks, sources, then the node with max (out - in).
|
||||
*/
|
||||
|
||||
import type { LayoutNode, LayoutEdge } from "../../types";
|
||||
|
||||
export interface CycleBreakResult {
|
||||
readonly edges: ReadonlyArray<LayoutEdge>;
|
||||
readonly reversedEdges: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Break cycles in the edge set by reversing backward edges.
|
||||
* Returns a new edge array (with some edges flipped) + the set of
|
||||
* original "source->target" keys that were reversed.
|
||||
*/
|
||||
export function breakCycles(
|
||||
nodes: ReadonlyArray<LayoutNode>,
|
||||
edges: ReadonlyArray<LayoutEdge>,
|
||||
): CycleBreakResult {
|
||||
if (nodes.length === 0 || edges.length === 0) {
|
||||
return { edges, reversedEdges: new Set<string>() };
|
||||
}
|
||||
|
||||
const nodeIds = new Set(nodes.map((n) => n.id));
|
||||
|
||||
// Filter edges to only those connecting known nodes
|
||||
const validEdges = edges.filter(
|
||||
(e) => nodeIds.has(e.source) && nodeIds.has(e.target),
|
||||
);
|
||||
|
||||
// Build mutable adjacency structures
|
||||
const outNeighbors = new Map<string, Set<string>>();
|
||||
const inNeighbors = new Map<string, Set<string>>();
|
||||
|
||||
for (const id of nodeIds) {
|
||||
outNeighbors.set(id, new Set<string>());
|
||||
inNeighbors.set(id, new Set<string>());
|
||||
}
|
||||
|
||||
for (const e of validEdges) {
|
||||
outNeighbors.get(e.source)!.add(e.target);
|
||||
inNeighbors.get(e.target)!.add(e.source);
|
||||
}
|
||||
|
||||
// Greedy ordering: sinks to the right, sources to the left
|
||||
const remaining = new Set(nodeIds);
|
||||
const leftOrder: string[] = [];
|
||||
const rightOrder: string[] = [];
|
||||
|
||||
while (remaining.size > 0) {
|
||||
// Remove all sinks (out-degree 0 in remaining subgraph)
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const id of remaining) {
|
||||
const outDeg = countInSubgraph(outNeighbors.get(id)!, remaining);
|
||||
if (outDeg === 0) {
|
||||
rightOrder.push(id);
|
||||
remaining.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all sources (in-degree 0 in remaining subgraph)
|
||||
changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const id of remaining) {
|
||||
const inDeg = countInSubgraph(inNeighbors.get(id)!, remaining);
|
||||
if (inDeg === 0) {
|
||||
leftOrder.push(id);
|
||||
remaining.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If stuck, pick node with max (out - in) in remaining subgraph
|
||||
if (remaining.size > 0) {
|
||||
let best: string | undefined;
|
||||
let bestDelta = -Infinity;
|
||||
for (const id of remaining) {
|
||||
const outDeg = countInSubgraph(outNeighbors.get(id)!, remaining);
|
||||
const inDeg = countInSubgraph(inNeighbors.get(id)!, remaining);
|
||||
const delta = outDeg - inDeg;
|
||||
if (delta > bestDelta || (delta === bestDelta && (best === undefined || id < best))) {
|
||||
bestDelta = delta;
|
||||
best = id;
|
||||
}
|
||||
}
|
||||
if (best !== undefined) {
|
||||
leftOrder.push(best);
|
||||
remaining.delete(best);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final ordering: left order + reversed right order
|
||||
rightOrder.reverse();
|
||||
const finalOrder = [...leftOrder, ...rightOrder];
|
||||
|
||||
// Build position map
|
||||
const positionOf = new Map<string, number>();
|
||||
for (let i = 0; i < finalOrder.length; i++) {
|
||||
positionOf.set(finalOrder[i], i);
|
||||
}
|
||||
|
||||
// An edge is "backward" if source appears AFTER target in the order
|
||||
const reversedEdges = new Set<string>();
|
||||
const resultEdges: LayoutEdge[] = [];
|
||||
|
||||
for (const e of validEdges) {
|
||||
const srcPos = positionOf.get(e.source)!;
|
||||
const tgtPos = positionOf.get(e.target)!;
|
||||
|
||||
if (srcPos > tgtPos) {
|
||||
// Backward edge — reverse it
|
||||
reversedEdges.add(`${e.source}->${e.target}`);
|
||||
resultEdges.push({ source: e.target, target: e.source });
|
||||
} else {
|
||||
resultEdges.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
return { edges: resultEdges, reversedEdges };
|
||||
}
|
||||
|
||||
/** Count how many members of `neighbors` are in `subgraph`. */
|
||||
function countInSubgraph(neighbors: Set<string>, subgraph: Set<string>): number {
|
||||
let count = 0;
|
||||
for (const n of neighbors) {
|
||||
if (subgraph.has(n)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Sugiyama sub-module barrel export.
|
||||
*/
|
||||
|
||||
export { breakCycles } from "./cycle-break";
|
||||
export type { CycleBreakResult } from "./cycle-break";
|
||||
|
||||
export { assignLayers } from "./layer-assign";
|
||||
|
||||
export { minimizeCrossings } from "./crossing-minimize";
|
||||
|
||||
export { assignCoordinates } from "./coordinate-assign";
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Longest-path layer assignment for DAGs.
|
||||
*
|
||||
* Each node is assigned to the layer equal to the length of the longest
|
||||
* path from any source to that node. Sources (in-degree 0) get layer 0.
|
||||
*/
|
||||
|
||||
import type { LayoutNode, LayoutEdge } from "../../types";
|
||||
|
||||
/**
|
||||
* Assign layer indices to each node using longest-path from sources.
|
||||
* Assumes the input is acyclic (run breakCycles first).
|
||||
* Returns a map of node ID -> layer index (0-based).
|
||||
*/
|
||||
export function assignLayers(
|
||||
nodes: ReadonlyArray<LayoutNode>,
|
||||
edges: ReadonlyArray<LayoutEdge>,
|
||||
): ReadonlyMap<string, number> {
|
||||
const layerMap = new Map<string, number>();
|
||||
|
||||
if (nodes.length === 0) return layerMap;
|
||||
|
||||
const nodeIds = new Set(nodes.map((n) => n.id));
|
||||
|
||||
// Build adjacency: predecessors and successors
|
||||
const predecessors = new Map<string, string[]>();
|
||||
const successors = new Map<string, string[]>();
|
||||
const inDegree = new Map<string, number>();
|
||||
|
||||
for (const id of nodeIds) {
|
||||
predecessors.set(id, []);
|
||||
successors.set(id, []);
|
||||
inDegree.set(id, 0);
|
||||
}
|
||||
|
||||
for (const e of edges) {
|
||||
if (!nodeIds.has(e.source) || !nodeIds.has(e.target)) continue;
|
||||
successors.get(e.source)!.push(e.target);
|
||||
predecessors.get(e.target)!.push(e.source);
|
||||
inDegree.set(e.target, inDegree.get(e.target)! + 1);
|
||||
}
|
||||
|
||||
// Kahn's algorithm for topological ordering, computing longest path
|
||||
const queue: string[] = [];
|
||||
|
||||
// Initialize sources at layer 0
|
||||
for (const id of nodeIds) {
|
||||
if (inDegree.get(id)! === 0) {
|
||||
layerMap.set(id, 0);
|
||||
queue.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const current = queue[head++];
|
||||
const currentLayer = layerMap.get(current)!;
|
||||
|
||||
for (const succ of successors.get(current)!) {
|
||||
// Layer of successor = max of all predecessor layers + 1
|
||||
const newLayer = currentLayer + 1;
|
||||
const existingLayer = layerMap.get(succ);
|
||||
if (existingLayer === undefined || newLayer > existingLayer) {
|
||||
layerMap.set(succ, newLayer);
|
||||
}
|
||||
|
||||
// Decrement in-degree; add to queue when all predecessors processed
|
||||
const remaining = inDegree.get(succ)! - 1;
|
||||
inDegree.set(succ, remaining);
|
||||
if (remaining === 0) {
|
||||
queue.push(succ);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any nodes not reached (isolated nodes with edges filtered out)
|
||||
for (const id of nodeIds) {
|
||||
if (!layerMap.has(id)) {
|
||||
layerMap.set(id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce explicit layer constraints after longest-path assignment.
|
||||
for (const node of nodes) {
|
||||
if (node.layerConstraint === "first") {
|
||||
layerMap.set(node.id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
let maxUnconstrainedLayer = 0;
|
||||
for (const node of nodes) {
|
||||
if (node.layerConstraint === "last") continue;
|
||||
const layer = layerMap.get(node.id) ?? 0;
|
||||
if (layer > maxUnconstrainedLayer) {
|
||||
maxUnconstrainedLayer = layer;
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.layerConstraint === "last") {
|
||||
const existingLayer = layerMap.get(node.id) ?? 0;
|
||||
layerMap.set(node.id, Math.max(existingLayer, maxUnconstrainedLayer + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return layerMap;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Vertical layout algorithm — places nodes in a single top-to-bottom column.
|
||||
*
|
||||
* Ignores edges entirely (token layouts ignore DAG structure).
|
||||
* Nodes are placed in input order; horizontally centered by midpoint.
|
||||
*/
|
||||
|
||||
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types";
|
||||
|
||||
const DEFAULT_GAP = 40;
|
||||
|
||||
export function createVerticalAlgorithm(gap: number = DEFAULT_GAP): LayoutAlgorithm {
|
||||
return {
|
||||
name: "vertical",
|
||||
layout(input: LayoutInput): LayoutOutput {
|
||||
const positions = new Map<string, Position>();
|
||||
const { nodes } = input;
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return { positions };
|
||||
}
|
||||
|
||||
// Find max width for horizontal centering
|
||||
let maxWidth = 0;
|
||||
for (const node of nodes) {
|
||||
if (node.width > maxWidth) maxWidth = node.width;
|
||||
}
|
||||
|
||||
// Place nodes top-to-bottom, centered horizontally
|
||||
let currentY = 0;
|
||||
for (const node of nodes) {
|
||||
const centeredX = maxWidth / 2 - node.width / 2;
|
||||
positions.set(node.id, { x: centeredX, y: currentY });
|
||||
currentY += node.height + gap;
|
||||
}
|
||||
|
||||
return { positions };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import type { PackedRow, LayoutConfig } from "./types";
|
||||
|
||||
/** Node size info for packing */
|
||||
interface NodeSize {
|
||||
id: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack nodes into rows using First-Fit Decreasing Height (FFDH) algorithm
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Sort nodes by height descending (tall nodes first)
|
||||
* 2. For each node, try to fit into existing row
|
||||
* 3. If no row fits, create new row
|
||||
* 4. Rows are sorted by height for visual consistency
|
||||
*
|
||||
* @param nodeIds - Node IDs to pack
|
||||
* @param sizes - Map of node ID to size
|
||||
* @param config - Layout configuration
|
||||
* @returns Array of packed rows
|
||||
*/
|
||||
export function packNodesIntoRows(
|
||||
nodeIds: number[],
|
||||
sizes: Map<number, { width: number; height: number }>,
|
||||
config: LayoutConfig
|
||||
): PackedRow[] {
|
||||
if (nodeIds.length === 0) return [];
|
||||
|
||||
// maxColumns: 0=auto, 1=disabled (vertical stack), 2+=fixed columns
|
||||
if (config.maxColumns === 1) {
|
||||
// Vertical stacking - one node per row
|
||||
return packVertically(nodeIds, sizes, config.verticalGap);
|
||||
}
|
||||
|
||||
// Build sized node list
|
||||
const nodeSizes: NodeSize[] = nodeIds.map((id) => {
|
||||
const size = sizes.get(id);
|
||||
return {
|
||||
id,
|
||||
width: size?.width ?? 200,
|
||||
height: size?.height ?? 100,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by height descending (FFDH: tall nodes first)
|
||||
nodeSizes.sort((a, b) => b.height - a.height);
|
||||
|
||||
if (config.maxColumns >= 2) {
|
||||
// Fixed column count
|
||||
return packFixedColumns(nodeSizes, config.maxColumns, config.verticalGap);
|
||||
}
|
||||
|
||||
// Auto mode: use maxRowWidth
|
||||
return packByWidth(nodeSizes, config.maxRowWidth, config.horizontalGap, config.verticalGap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack nodes vertically (one per row) - legacy behavior
|
||||
*/
|
||||
function packVertically(
|
||||
nodeIds: number[],
|
||||
sizes: Map<number, { width: number; height: number }>,
|
||||
verticalGap: number
|
||||
): PackedRow[] {
|
||||
const rows: PackedRow[] = [];
|
||||
let yOffset = 0;
|
||||
|
||||
for (const id of nodeIds) {
|
||||
const size = sizes.get(id);
|
||||
const height = size?.height ?? 100;
|
||||
const width = size?.width ?? 200;
|
||||
|
||||
rows.push({
|
||||
nodes: [id],
|
||||
height,
|
||||
yOffset,
|
||||
width,
|
||||
});
|
||||
|
||||
yOffset += height + verticalGap;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack nodes into fixed number of columns
|
||||
* Distributes nodes round-robin by height to balance columns
|
||||
*/
|
||||
function packFixedColumns(
|
||||
nodeSizes: NodeSize[],
|
||||
columnCount: number,
|
||||
verticalGap: number
|
||||
): PackedRow[] {
|
||||
// Group nodes by similar height into rows
|
||||
const rows: PackedRow[] = [];
|
||||
let currentRow: NodeSize[] = [];
|
||||
|
||||
for (const node of nodeSizes) {
|
||||
currentRow.push(node);
|
||||
|
||||
if (currentRow.length >= columnCount) {
|
||||
// Row is full
|
||||
const rowHeight = Math.max(...currentRow.map((n) => n.height));
|
||||
const rowWidth = currentRow.reduce((sum, n) => sum + n.width, 0);
|
||||
|
||||
rows.push({
|
||||
nodes: currentRow.map((n) => n.id),
|
||||
height: rowHeight,
|
||||
yOffset: 0, // Set later
|
||||
width: rowWidth,
|
||||
});
|
||||
|
||||
currentRow = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle remaining nodes in last partial row
|
||||
if (currentRow.length > 0) {
|
||||
const rowHeight = Math.max(...currentRow.map((n) => n.height));
|
||||
const rowWidth = currentRow.reduce((sum, n) => sum + n.width, 0);
|
||||
|
||||
rows.push({
|
||||
nodes: currentRow.map((n) => n.id),
|
||||
height: rowHeight,
|
||||
yOffset: 0,
|
||||
width: rowWidth,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate y offsets
|
||||
let yOffset = 0;
|
||||
for (const row of rows) {
|
||||
row.yOffset = yOffset;
|
||||
yOffset += row.height + verticalGap;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack nodes by maximum row width (auto column count)
|
||||
* Uses FFDH: First-Fit Decreasing Height
|
||||
*/
|
||||
function packByWidth(
|
||||
nodeSizes: NodeSize[],
|
||||
maxRowWidth: number,
|
||||
horizontalGap: number,
|
||||
verticalGap: number
|
||||
): PackedRow[] {
|
||||
const rows: Array<{
|
||||
nodes: NodeSize[];
|
||||
height: number;
|
||||
currentWidth: number;
|
||||
}> = [];
|
||||
|
||||
for (const node of nodeSizes) {
|
||||
// Try to fit into existing row
|
||||
let placed = false;
|
||||
|
||||
for (const row of rows) {
|
||||
const newWidth = row.currentWidth + horizontalGap + node.width;
|
||||
|
||||
// Check if node fits and heights are compatible
|
||||
// Allow 50% height variance to keep rows visually balanced
|
||||
const heightRatio = row.height > 0 ? node.height / row.height : 1;
|
||||
const heightCompatible = heightRatio >= 0.5 && heightRatio <= 2.0;
|
||||
|
||||
if (newWidth <= maxRowWidth && heightCompatible) {
|
||||
row.nodes.push(node);
|
||||
row.currentWidth = newWidth;
|
||||
row.height = Math.max(row.height, node.height);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!placed) {
|
||||
// Create new row
|
||||
rows.push({
|
||||
nodes: [node],
|
||||
height: node.height,
|
||||
currentWidth: node.width,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort rows by their tallest node height (tallest first for visual hierarchy)
|
||||
rows.sort((a, b) => b.height - a.height);
|
||||
|
||||
// Convert to PackedRow format with y offsets
|
||||
const result: PackedRow[] = [];
|
||||
let yOffset = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
result.push({
|
||||
nodes: row.nodes.map((n) => n.id),
|
||||
height: row.height,
|
||||
yOffset,
|
||||
width: row.currentWidth,
|
||||
});
|
||||
|
||||
yOffset += row.height + verticalGap;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total height of packed rows
|
||||
*/
|
||||
export function getPackedHeight(rows: PackedRow[]): number {
|
||||
if (rows.length === 0) return 0;
|
||||
const lastRow = rows[rows.length - 1];
|
||||
return lastRow.yOffset + lastRow.height;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate max width across all packed rows
|
||||
*/
|
||||
export function getPackedWidth(rows: PackedRow[]): number {
|
||||
return Math.max(0, ...rows.map((r) => r.width));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Compaction utilities — post-processing to remove gaps.
|
||||
* All functions are pure: they return new maps, never mutate inputs.
|
||||
*/
|
||||
|
||||
import type { Position, LayoutNode } from "./types";
|
||||
|
||||
/**
|
||||
* Remove vertical gaps between nodes by shifting them up.
|
||||
* Nodes are processed in Y order; each is shifted to the earliest available Y.
|
||||
*/
|
||||
export function compactVertically(
|
||||
positions: ReadonlyMap<string, Position>,
|
||||
nodes: ReadonlyArray<LayoutNode>,
|
||||
gap: number,
|
||||
): ReadonlyMap<string, Position> {
|
||||
if (nodes.length === 0) return new Map<string, Position>();
|
||||
|
||||
// Build lookup for node dimensions
|
||||
const nodeMap = new Map<string, LayoutNode>();
|
||||
for (const n of nodes) {
|
||||
nodeMap.set(n.id, n);
|
||||
}
|
||||
|
||||
// Sort node IDs by their current Y position, breaking ties by X
|
||||
const sortedIds = [...positions.keys()]
|
||||
.filter((id) => nodeMap.has(id))
|
||||
.sort((a, b) => {
|
||||
const pa = positions.get(a)!;
|
||||
const pb = positions.get(b)!;
|
||||
if (pa.y !== pb.y) return pa.y - pb.y;
|
||||
return pa.x - pb.x;
|
||||
});
|
||||
|
||||
const result = new Map<string, Position>(positions);
|
||||
let nextY = sortedIds.length > 0 ? positions.get(sortedIds[0])!.y : 0;
|
||||
|
||||
for (const id of sortedIds) {
|
||||
const pos = positions.get(id)!;
|
||||
const node = nodeMap.get(id)!;
|
||||
const newY = Math.min(pos.y, nextY);
|
||||
result.set(id, { x: pos.x, y: newY });
|
||||
nextY = newY + node.height + gap;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove horizontal gaps between nodes by shifting them left.
|
||||
* Nodes are processed in X order; each is shifted to the earliest available X.
|
||||
*/
|
||||
export function compactHorizontally(
|
||||
positions: ReadonlyMap<string, Position>,
|
||||
nodes: ReadonlyArray<LayoutNode>,
|
||||
gap: number,
|
||||
): ReadonlyMap<string, Position> {
|
||||
if (nodes.length === 0) return new Map<string, Position>();
|
||||
|
||||
// Build lookup for node dimensions
|
||||
const nodeMap = new Map<string, LayoutNode>();
|
||||
for (const n of nodes) {
|
||||
nodeMap.set(n.id, n);
|
||||
}
|
||||
|
||||
// Sort node IDs by their current X position, breaking ties by Y
|
||||
const sortedIds = [...positions.keys()]
|
||||
.filter((id) => nodeMap.has(id))
|
||||
.sort((a, b) => {
|
||||
const pa = positions.get(a)!;
|
||||
const pb = positions.get(b)!;
|
||||
if (pa.x !== pb.x) return pa.x - pb.x;
|
||||
return pa.y - pb.y;
|
||||
});
|
||||
|
||||
const result = new Map<string, Position>(positions);
|
||||
let nextX = sortedIds.length > 0 ? positions.get(sortedIds[0])!.x : 0;
|
||||
|
||||
for (const id of sortedIds) {
|
||||
const pos = positions.get(id)!;
|
||||
const node = nodeMap.get(id)!;
|
||||
const newX = Math.min(pos.x, nextX);
|
||||
result.set(id, { x: newX, y: pos.y });
|
||||
nextX = newX + node.width + gap;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
/**
|
||||
* Core layout framework — recursive group-aware layout orchestration.
|
||||
*
|
||||
* This is the architectural foundation of v2. It handles:
|
||||
* - Bottom-up group hierarchy processing
|
||||
* - Delegating to a pluggable LayoutAlgorithm
|
||||
* - Disconnected node placement (left of DAG at each level)
|
||||
* - Translating nested positions to absolute coordinates
|
||||
*/
|
||||
|
||||
import type {
|
||||
LayoutNode,
|
||||
LayoutEdge,
|
||||
LayoutGroup,
|
||||
LayoutAlgorithm,
|
||||
FrameworkConfig,
|
||||
FrameworkResult,
|
||||
Position,
|
||||
GroupBounds,
|
||||
} from "./types";
|
||||
import { DEFAULT_FRAMEWORK_CONFIG } from "./types";
|
||||
import { tokenToAlgorithm } from "./tokens";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Layout nodes, edges, and groups using the provided algorithm.
|
||||
*
|
||||
* Groups are processed bottom-up: leaf groups first, then their parents.
|
||||
* Each group's contents are laid out independently, producing a "virtual node"
|
||||
* that participates in the parent scope's layout.
|
||||
*/
|
||||
export function layoutWithGroups(
|
||||
nodes: ReadonlyArray<LayoutNode>,
|
||||
edges: ReadonlyArray<LayoutEdge>,
|
||||
groups: ReadonlyArray<LayoutGroup>,
|
||||
algorithm: LayoutAlgorithm,
|
||||
config?: Partial<FrameworkConfig>,
|
||||
): FrameworkResult {
|
||||
const cfg: FrameworkConfig = { ...DEFAULT_FRAMEWORK_CONFIG, ...config };
|
||||
|
||||
// Fast path: no nodes at all
|
||||
if (nodes.length === 0) {
|
||||
return {
|
||||
positions: new Map<string, Position>(),
|
||||
groupBounds: new Map<string, GroupBounds>(),
|
||||
};
|
||||
}
|
||||
|
||||
// Build lookup tables
|
||||
const nodeById = new Map<string, LayoutNode>();
|
||||
for (const n of nodes) {
|
||||
nodeById.set(n.id, n);
|
||||
}
|
||||
const groupById = new Map<string, LayoutGroup>();
|
||||
for (const g of groups) {
|
||||
groupById.set(g.id, g);
|
||||
}
|
||||
|
||||
// Build group hierarchy
|
||||
const { processingOrder, parentMap } = buildGroupHierarchy(groups);
|
||||
|
||||
// Track virtual nodes (groups that have been processed) and their bounds
|
||||
const virtualNodes = new Map<string, LayoutNode>();
|
||||
const groupBounds = new Map<string, GroupBounds>();
|
||||
|
||||
// Store relative positions for each group's members (before absolute translation)
|
||||
// Key: group ID, Value: map of member ID -> position relative to group origin
|
||||
const groupMemberRelativePositions = new Map<
|
||||
string,
|
||||
ReadonlyMap<string, Position>
|
||||
>();
|
||||
|
||||
// Process groups bottom-up
|
||||
for (const group of processingOrder) {
|
||||
const { positions: memberPositions, bounds } = layoutGroupContents(
|
||||
group,
|
||||
groups,
|
||||
nodeById,
|
||||
edges,
|
||||
virtualNodes,
|
||||
algorithm,
|
||||
cfg,
|
||||
);
|
||||
|
||||
groupMemberRelativePositions.set(group.id, memberPositions);
|
||||
groupBounds.set(group.id, bounds);
|
||||
|
||||
// Register as virtual node for parent's layout
|
||||
virtualNodes.set(group.id, {
|
||||
id: group.id,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
}
|
||||
|
||||
// Layout root level: ungrouped nodes + top-level group virtual nodes
|
||||
const groupedNodeIds = collectAllGroupedNodeIds(groups);
|
||||
const topLevelGroupIds = new Set<string>();
|
||||
for (const g of groups) {
|
||||
if (!parentMap.has(g.id)) {
|
||||
topLevelGroupIds.add(g.id);
|
||||
}
|
||||
}
|
||||
|
||||
const rootNodes: LayoutNode[] = [];
|
||||
for (const n of nodes) {
|
||||
if (!groupedNodeIds.has(n.id)) {
|
||||
rootNodes.push(n);
|
||||
}
|
||||
}
|
||||
for (const gId of topLevelGroupIds) {
|
||||
const vn = virtualNodes.get(gId);
|
||||
if (vn) rootNodes.push(vn);
|
||||
}
|
||||
|
||||
// Root edges: between root-level items (ungrouped nodes + top-level groups)
|
||||
const rootItemIds = new Set(rootNodes.map((n) => n.id));
|
||||
const rootEdges = filterEdgesForScope(edges, rootItemIds, groups);
|
||||
|
||||
const rootPositions = layoutScope(rootNodes, rootEdges, algorithm, cfg);
|
||||
|
||||
// Translate all positions to absolute coordinates
|
||||
const absolutePositions = new Map<string, Position>();
|
||||
|
||||
// Place ungrouped root nodes
|
||||
for (const n of nodes) {
|
||||
if (!groupedNodeIds.has(n.id)) {
|
||||
const pos = rootPositions.get(n.id);
|
||||
if (pos) {
|
||||
absolutePositions.set(n.id, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively translate group members to absolute coordinates
|
||||
for (const gId of topLevelGroupIds) {
|
||||
const groupPos = rootPositions.get(gId);
|
||||
if (groupPos) {
|
||||
translateGroupToAbsolute(
|
||||
gId,
|
||||
groupPos,
|
||||
groupById,
|
||||
groupMemberRelativePositions,
|
||||
groupBounds,
|
||||
absolutePositions,
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update group bounds with absolute positions
|
||||
const absoluteGroupBounds = new Map<string, GroupBounds>();
|
||||
for (const gId of topLevelGroupIds) {
|
||||
const groupPos = rootPositions.get(gId);
|
||||
if (groupPos) {
|
||||
updateGroupBoundsAbsolute(
|
||||
gId,
|
||||
groupPos,
|
||||
groupById,
|
||||
groupMemberRelativePositions,
|
||||
groupBounds,
|
||||
absoluteGroupBounds,
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
positions: absolutePositions,
|
||||
groupBounds: absoluteGroupBounds,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group hierarchy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GroupHierarchy {
|
||||
/** Groups in bottom-up processing order (leaves first) */
|
||||
readonly processingOrder: ReadonlyArray<LayoutGroup>;
|
||||
/** Maps child group ID -> parent group ID */
|
||||
readonly parentMap: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the group hierarchy: determine parent-child relationships and
|
||||
* return a bottom-up topological processing order.
|
||||
*/
|
||||
export function buildGroupHierarchy(
|
||||
groups: ReadonlyArray<LayoutGroup>,
|
||||
): GroupHierarchy {
|
||||
const groupById = new Map<string, LayoutGroup>();
|
||||
for (const g of groups) {
|
||||
groupById.set(g.id, g);
|
||||
}
|
||||
|
||||
// Build parent map
|
||||
const parentMap = new Map<string, string>();
|
||||
for (const g of groups) {
|
||||
for (const childId of g.childGroupIds) {
|
||||
parentMap.set(childId, g.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Topological sort: BFS from leaves (groups with no children, or whose
|
||||
// children are all already processed)
|
||||
const childCountRemaining = new Map<string, number>();
|
||||
for (const g of groups) {
|
||||
// Only count children that actually exist in our group list
|
||||
const validChildren = g.childGroupIds.filter((cid) => groupById.has(cid));
|
||||
childCountRemaining.set(g.id, validChildren.length);
|
||||
}
|
||||
|
||||
const queue: string[] = [];
|
||||
for (const [id, count] of childCountRemaining) {
|
||||
if (count === 0) queue.push(id);
|
||||
}
|
||||
|
||||
const processingOrder: LayoutGroup[] = [];
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const id = queue[head++];
|
||||
const group = groupById.get(id);
|
||||
if (!group) continue;
|
||||
processingOrder.push(group);
|
||||
|
||||
// Notify parent
|
||||
const parentId = parentMap.get(id);
|
||||
if (parentId !== undefined) {
|
||||
const remaining = (childCountRemaining.get(parentId) ?? 1) - 1;
|
||||
childCountRemaining.set(parentId, remaining);
|
||||
if (remaining === 0) {
|
||||
queue.push(parentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { processingOrder, parentMap };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disconnected splitting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SplitResult {
|
||||
readonly connected: ReadonlyArray<LayoutNode>;
|
||||
readonly disconnected: ReadonlyArray<LayoutNode>;
|
||||
readonly connectedEdges: ReadonlyArray<LayoutEdge>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split nodes into connected (have edges) and disconnected (no edges).
|
||||
* Preserves original array order for determinism.
|
||||
*/
|
||||
export function splitDisconnected(
|
||||
nodes: ReadonlyArray<LayoutNode>,
|
||||
edges: ReadonlyArray<LayoutEdge>,
|
||||
): SplitResult {
|
||||
const nodeIds = new Set(nodes.map((n) => n.id));
|
||||
const hasEdge = new Set<string>();
|
||||
|
||||
const connectedEdges: LayoutEdge[] = [];
|
||||
for (const e of edges) {
|
||||
if (nodeIds.has(e.source) && nodeIds.has(e.target)) {
|
||||
hasEdge.add(e.source);
|
||||
hasEdge.add(e.target);
|
||||
connectedEdges.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
const connected: LayoutNode[] = [];
|
||||
const disconnected: LayoutNode[] = [];
|
||||
for (const n of nodes) {
|
||||
if (hasEdge.has(n.id) || n.layerConstraint !== undefined) {
|
||||
connected.push(n);
|
||||
} else {
|
||||
disconnected.push(n);
|
||||
}
|
||||
}
|
||||
|
||||
return { connected, disconnected, connectedEdges };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disconnected placement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Bounds {
|
||||
readonly minX: number;
|
||||
readonly minY: number;
|
||||
readonly maxX: number;
|
||||
readonly maxY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Place disconnected nodes in a vertical stack to the LEFT of the DAG bounds.
|
||||
* Returns positions for the disconnected nodes only.
|
||||
*/
|
||||
export function placeDisconnected(
|
||||
disconnected: ReadonlyArray<LayoutNode>,
|
||||
dagBounds: Bounds | null,
|
||||
config: FrameworkConfig,
|
||||
): ReadonlyMap<string, Position> {
|
||||
const result = new Map<string, Position>();
|
||||
if (disconnected.length === 0) return result;
|
||||
|
||||
// Find widest disconnected node for right-aligning the column
|
||||
let maxDisconnectedWidth = 0;
|
||||
for (const n of disconnected) {
|
||||
if (n.width > maxDisconnectedWidth) maxDisconnectedWidth = n.width;
|
||||
}
|
||||
|
||||
// Place left of DAG (or at origin if no DAG)
|
||||
const dagMinX = dagBounds ? dagBounds.minX : 0;
|
||||
const startX = dagMinX - config.disconnectedGap - maxDisconnectedWidth;
|
||||
const startY = dagBounds ? dagBounds.minY : 0;
|
||||
|
||||
let currentY = startY;
|
||||
for (const n of disconnected) {
|
||||
result.set(n.id, { x: startX, y: currentY });
|
||||
currentY += n.height + config.verticalGap;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Position translation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Offset all positions by (dx, dy). Returns a new map.
|
||||
*/
|
||||
export function translatePositions(
|
||||
positions: ReadonlyMap<string, Position>,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): ReadonlyMap<string, Position> {
|
||||
const result = new Map<string, Position>();
|
||||
for (const [id, pos] of positions) {
|
||||
result.set(id, { x: pos.x + dx, y: pos.y + dy });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope layout (handles disconnected splitting + algorithm call)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Layout a scope (set of nodes + edges), handling disconnected separation.
|
||||
* Returns positions for ALL nodes in the scope (connected + disconnected).
|
||||
*/
|
||||
function layoutScope(
|
||||
nodes: ReadonlyArray<LayoutNode>,
|
||||
edges: ReadonlyArray<LayoutEdge>,
|
||||
algorithm: LayoutAlgorithm,
|
||||
config: FrameworkConfig,
|
||||
): ReadonlyMap<string, Position> {
|
||||
if (nodes.length === 0) return new Map<string, Position>();
|
||||
|
||||
const { connected, disconnected, connectedEdges } = splitDisconnected(
|
||||
nodes,
|
||||
edges,
|
||||
);
|
||||
|
||||
// Layout connected nodes with the algorithm
|
||||
let connectedPositions: ReadonlyMap<string, Position>;
|
||||
if (connected.length > 0) {
|
||||
const result = algorithm.layout({
|
||||
nodes: connected,
|
||||
edges: connectedEdges,
|
||||
});
|
||||
connectedPositions = result.positions;
|
||||
} else {
|
||||
connectedPositions = new Map<string, Position>();
|
||||
}
|
||||
|
||||
// Compute DAG bounds from connected positions
|
||||
const dagBounds = computeBounds(connectedPositions, connected);
|
||||
|
||||
// Place disconnected nodes
|
||||
const disconnectedPositions = placeDisconnected(
|
||||
disconnected,
|
||||
dagBounds,
|
||||
config,
|
||||
);
|
||||
|
||||
// Merge
|
||||
const merged = new Map<string, Position>();
|
||||
for (const [id, pos] of connectedPositions) {
|
||||
merged.set(id, pos);
|
||||
}
|
||||
for (const [id, pos] of disconnectedPositions) {
|
||||
merged.set(id, pos);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group contents layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GroupLayoutResult {
|
||||
/** Member positions relative to group's (0, 0) — i.e. with padding offset */
|
||||
readonly positions: ReadonlyMap<string, Position>;
|
||||
/** Group bounding box (relative: x=0, y=0) */
|
||||
readonly bounds: GroupBounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout the contents of a single group.
|
||||
* Child groups that have already been processed appear as virtual nodes.
|
||||
*/
|
||||
function layoutGroupContents(
|
||||
group: LayoutGroup,
|
||||
allGroups: ReadonlyArray<LayoutGroup>,
|
||||
nodeById: ReadonlyMap<string, LayoutNode>,
|
||||
allEdges: ReadonlyArray<LayoutEdge>,
|
||||
virtualNodes: ReadonlyMap<string, LayoutNode>,
|
||||
algorithm: LayoutAlgorithm,
|
||||
config: FrameworkConfig,
|
||||
): GroupLayoutResult {
|
||||
// Collect members: direct member nodes + child group virtual nodes
|
||||
const members: LayoutNode[] = [];
|
||||
const memberIdSet = new Set<string>();
|
||||
|
||||
for (const mId of group.memberIds) {
|
||||
const node = nodeById.get(mId);
|
||||
if (node) {
|
||||
members.push(node);
|
||||
memberIdSet.add(mId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cgId of group.childGroupIds) {
|
||||
const vn = virtualNodes.get(cgId);
|
||||
if (vn) {
|
||||
members.push(vn);
|
||||
memberIdSet.add(cgId);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter edges to internal-only
|
||||
const internalEdges = filterEdgesForScope(allEdges, memberIdSet, allGroups);
|
||||
|
||||
const groupAlgorithm = group.token
|
||||
? tokenToAlgorithm(group.token, config.verticalGap)
|
||||
: algorithm;
|
||||
|
||||
const rawPositions = group.token
|
||||
? groupAlgorithm.layout({ nodes: members, edges: internalEdges }).positions
|
||||
: layoutScope(members, internalEdges, groupAlgorithm, config);
|
||||
|
||||
// Normalize: shift so min position is at (padding, titleHeight + padding)
|
||||
// Groups have a title bar at the top, so content starts below it
|
||||
const topPadding = config.groupTitleHeight + config.groupPadding;
|
||||
const contentBounds = computeBounds(rawPositions, members);
|
||||
|
||||
let shiftX: number;
|
||||
let shiftY: number;
|
||||
if (contentBounds) {
|
||||
shiftX = config.groupPadding - contentBounds.minX;
|
||||
shiftY = topPadding - contentBounds.minY;
|
||||
} else {
|
||||
shiftX = config.groupPadding;
|
||||
shiftY = topPadding;
|
||||
}
|
||||
|
||||
const normalizedPositions = translatePositions(rawPositions, shiftX, shiftY);
|
||||
|
||||
// Compute group bounds
|
||||
const normalizedBounds = computeBounds(normalizedPositions, members);
|
||||
|
||||
let width: number;
|
||||
let height: number;
|
||||
if (normalizedBounds) {
|
||||
width = normalizedBounds.maxX + config.groupPadding;
|
||||
height = normalizedBounds.maxY + config.groupPadding;
|
||||
} else {
|
||||
width = config.groupPadding * 2;
|
||||
height = topPadding + config.groupPadding;
|
||||
}
|
||||
|
||||
return {
|
||||
positions: normalizedPositions,
|
||||
bounds: { x: 0, y: 0, width, height },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Absolute coordinate translation (recursive)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Recursively translate a group's members to absolute coordinates.
|
||||
* `groupAbsolutePos` is where the group's top-left corner is placed.
|
||||
*/
|
||||
function translateGroupToAbsolute(
|
||||
groupId: string,
|
||||
groupAbsolutePos: Position,
|
||||
groupById: ReadonlyMap<string, LayoutGroup>,
|
||||
groupMemberRelativePositions: ReadonlyMap<
|
||||
string,
|
||||
ReadonlyMap<string, Position>
|
||||
>,
|
||||
groupBoundsMap: ReadonlyMap<string, GroupBounds>,
|
||||
absolutePositions: Map<string, Position>,
|
||||
config: FrameworkConfig,
|
||||
): void {
|
||||
const relPositions = groupMemberRelativePositions.get(groupId);
|
||||
if (!relPositions) return;
|
||||
|
||||
const group = groupById.get(groupId);
|
||||
if (!group) return;
|
||||
|
||||
// Translate direct member nodes
|
||||
for (const mId of group.memberIds) {
|
||||
const relPos = relPositions.get(mId);
|
||||
if (relPos) {
|
||||
absolutePositions.set(mId, {
|
||||
x: groupAbsolutePos.x + relPos.x,
|
||||
y: groupAbsolutePos.y + relPos.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Translate child groups recursively
|
||||
for (const cgId of group.childGroupIds) {
|
||||
const relPos = relPositions.get(cgId);
|
||||
if (relPos) {
|
||||
const childAbsPos: Position = {
|
||||
x: groupAbsolutePos.x + relPos.x,
|
||||
y: groupAbsolutePos.y + relPos.y,
|
||||
};
|
||||
translateGroupToAbsolute(
|
||||
cgId,
|
||||
childAbsPos,
|
||||
groupById,
|
||||
groupMemberRelativePositions,
|
||||
groupBoundsMap,
|
||||
absolutePositions,
|
||||
config,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively compute absolute group bounds.
|
||||
*/
|
||||
function updateGroupBoundsAbsolute(
|
||||
groupId: string,
|
||||
groupAbsolutePos: Position,
|
||||
groupById: ReadonlyMap<string, LayoutGroup>,
|
||||
groupMemberRelativePositions: ReadonlyMap<
|
||||
string,
|
||||
ReadonlyMap<string, Position>
|
||||
>,
|
||||
relativeBoundsMap: ReadonlyMap<string, GroupBounds>,
|
||||
absoluteBoundsMap: Map<string, GroupBounds>,
|
||||
config: FrameworkConfig,
|
||||
): void {
|
||||
const relBounds = relativeBoundsMap.get(groupId);
|
||||
if (!relBounds) return;
|
||||
|
||||
absoluteBoundsMap.set(groupId, {
|
||||
x: groupAbsolutePos.x,
|
||||
y: groupAbsolutePos.y,
|
||||
width: relBounds.width,
|
||||
height: relBounds.height,
|
||||
});
|
||||
|
||||
const group = groupById.get(groupId);
|
||||
if (!group) return;
|
||||
|
||||
const relPositions = groupMemberRelativePositions.get(groupId);
|
||||
if (!relPositions) return;
|
||||
|
||||
for (const cgId of group.childGroupIds) {
|
||||
const relPos = relPositions.get(cgId);
|
||||
if (relPos) {
|
||||
const childAbsPos: Position = {
|
||||
x: groupAbsolutePos.x + relPos.x,
|
||||
y: groupAbsolutePos.y + relPos.y,
|
||||
};
|
||||
updateGroupBoundsAbsolute(
|
||||
cgId,
|
||||
childAbsPos,
|
||||
groupById,
|
||||
groupMemberRelativePositions,
|
||||
relativeBoundsMap,
|
||||
absoluteBoundsMap,
|
||||
config,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Filter edges to only those internal to a scope.
|
||||
* An edge is internal if both source and target are in scopeIds.
|
||||
* For edges that connect to nodes inside child groups, we remap them
|
||||
* to the child group ID (since child groups are virtual nodes in this scope).
|
||||
*/
|
||||
function filterEdgesForScope(
|
||||
allEdges: ReadonlyArray<LayoutEdge>,
|
||||
scopeIds: ReadonlySet<string>,
|
||||
allGroups: ReadonlyArray<LayoutGroup>,
|
||||
): ReadonlyArray<LayoutEdge> {
|
||||
const groupById = new Map<string, LayoutGroup>();
|
||||
for (const g of allGroups) {
|
||||
groupById.set(g.id, g);
|
||||
}
|
||||
|
||||
// Build reverse map: node ID -> which group ID contains it (for child group remapping)
|
||||
const nodeToGroup = new Map<string, string>();
|
||||
for (const g of allGroups) {
|
||||
if (scopeIds.has(g.id)) {
|
||||
// This is a child group in scope — map its deep members to this group ID
|
||||
mapGroupMembersDeep(g, groupById, nodeToGroup);
|
||||
}
|
||||
}
|
||||
|
||||
const result: LayoutEdge[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const e of allEdges) {
|
||||
// Remap source/target if they're inside a child group
|
||||
const source = nodeToGroup.get(e.source) ?? e.source;
|
||||
const target = nodeToGroup.get(e.target) ?? e.target;
|
||||
|
||||
// Both must be in scope
|
||||
if (!scopeIds.has(source) || !scopeIds.has(target)) continue;
|
||||
|
||||
// Skip self-loops (happens when source and target are in same child group)
|
||||
if (source === target) continue;
|
||||
|
||||
// Deduplicate
|
||||
const key = `${source}->${target}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
result.push({ source, target });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively map all member nodes of a group (and its nested children)
|
||||
* to the group's ID.
|
||||
*/
|
||||
function mapGroupMembersDeep(
|
||||
group: LayoutGroup,
|
||||
groupById: ReadonlyMap<string, LayoutGroup>,
|
||||
nodeToGroup: Map<string, string>,
|
||||
): void {
|
||||
for (const mId of group.memberIds) {
|
||||
nodeToGroup.set(mId, group.id);
|
||||
}
|
||||
for (const cgId of group.childGroupIds) {
|
||||
const childGroup = groupById.get(cgId);
|
||||
if (childGroup) {
|
||||
// Map child group's members to the parent group (the one in scope)
|
||||
mapGroupMembersDeepToTarget(childGroup, groupById, group.id, nodeToGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively map all members of `group` to `targetGroupId`.
|
||||
*/
|
||||
function mapGroupMembersDeepToTarget(
|
||||
group: LayoutGroup,
|
||||
groupById: ReadonlyMap<string, LayoutGroup>,
|
||||
targetGroupId: string,
|
||||
nodeToGroup: Map<string, string>,
|
||||
): void {
|
||||
for (const mId of group.memberIds) {
|
||||
nodeToGroup.set(mId, targetGroupId);
|
||||
}
|
||||
for (const cgId of group.childGroupIds) {
|
||||
const childGroup = groupById.get(cgId);
|
||||
if (childGroup) {
|
||||
mapGroupMembersDeepToTarget(
|
||||
childGroup,
|
||||
groupById,
|
||||
targetGroupId,
|
||||
nodeToGroup,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: collect all node IDs that belong to any group (recursively)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function collectAllGroupedNodeIds(
|
||||
groups: ReadonlyArray<LayoutGroup>,
|
||||
): Set<string> {
|
||||
const result = new Set<string>();
|
||||
for (const g of groups) {
|
||||
for (const mId of g.memberIds) {
|
||||
result.add(mId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: compute bounding box
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function computeBounds(
|
||||
positions: ReadonlyMap<string, Position>,
|
||||
nodes: ReadonlyArray<LayoutNode>,
|
||||
): Bounds | null {
|
||||
const nodeMap = new Map<string, LayoutNode>();
|
||||
for (const n of nodes) {
|
||||
nodeMap.set(n.id, n);
|
||||
}
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
let found = false;
|
||||
|
||||
for (const [id, pos] of positions) {
|
||||
const node = nodeMap.get(id);
|
||||
if (!node) continue;
|
||||
found = true;
|
||||
if (pos.x < minX) minX = pos.x;
|
||||
if (pos.y < minY) minY = pos.y;
|
||||
const right = pos.x + node.width;
|
||||
const bottom = pos.y + node.height;
|
||||
if (right > maxX) maxX = right;
|
||||
if (bottom > maxY) maxY = bottom;
|
||||
}
|
||||
|
||||
if (!found) return null;
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
@@ -1,565 +0,0 @@
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LGraphGroup,
|
||||
LayoutNode,
|
||||
LayoutGroup,
|
||||
LayoutState,
|
||||
LayoutConfig,
|
||||
RerouteChain,
|
||||
} from "./types";
|
||||
import { DEFAULT_CONFIG } from "./types";
|
||||
import { classifyNodes } from "./node-classifier";
|
||||
import { findRerouteChains, getRerouteNodeIds, getVirtualEdges } from "./reroute-collapse";
|
||||
import { getInternalEdges, assignMemberLayers } from "./layout-utils";
|
||||
import { debugLog } from "../debug";
|
||||
|
||||
/**
|
||||
* Runtime subgraph I/O node (has pos/size like regular nodes, not bounding)
|
||||
*/
|
||||
interface RuntimeIONode {
|
||||
id: number;
|
||||
pos: [number, number];
|
||||
size: [number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an object looks like a subgraph I/O node at runtime
|
||||
* Runtime has pos/size (not bounding like JSON export)
|
||||
*/
|
||||
function isValidIONode(obj: unknown): obj is RuntimeIONode {
|
||||
if (!obj || typeof obj !== "object") return false;
|
||||
const candidate = obj as Record<string, unknown>;
|
||||
// Must have numeric id
|
||||
if (typeof candidate.id !== "number") return false;
|
||||
// Use duck typing - pos/size may be getters returning array-like objects
|
||||
const pos = candidate.pos as [number, number] | undefined;
|
||||
const size = candidate.size as [number, number] | undefined;
|
||||
if (!pos || typeof pos[0] !== "number" || typeof pos[1] !== "number") return false;
|
||||
if (!size || typeof size[0] !== "number" || typeof size[1] !== "number") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert runtime I/O node to LGraphNode-like object for layout
|
||||
*/
|
||||
function ioNodeToLGraphNode(ioNode: RuntimeIONode, type: "input" | "output"): LGraphNode {
|
||||
return {
|
||||
id: ioNode.id,
|
||||
type: `subgraph_${type}`,
|
||||
title: type === "input" ? "Input" : "Output",
|
||||
pos: [ioNode.pos[0], ioNode.pos[1]],
|
||||
size: [ioNode.size[0], ioNode.size[1]],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Title bar height for groups */
|
||||
const GROUP_TITLE_HEIGHT = 50;
|
||||
|
||||
/**
|
||||
* Check if outer group's bounds fully contain inner group's bounds
|
||||
*/
|
||||
function groupContainsGroup(outer: LGraphGroup, inner: LGraphGroup): boolean {
|
||||
const ox = outer.pos[0];
|
||||
const oy = outer.pos[1];
|
||||
const ow = outer.size[0];
|
||||
const oh = outer.size[1];
|
||||
|
||||
const ix = inner.pos[0];
|
||||
const iy = inner.pos[1];
|
||||
const iw = inner.size[0];
|
||||
const ih = inner.size[1];
|
||||
|
||||
// Inner's bounds must be fully within outer's bounds
|
||||
return ix >= ox && iy >= oy && ix + iw <= ox + ow && iy + ih <= oy + oh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build parent-child hierarchy for groups based on bounds containment
|
||||
* Assigns parentGroup, childGroups, and depth to each LayoutGroup
|
||||
*/
|
||||
function buildGroupHierarchy(layoutGroups: LayoutGroup[]): void {
|
||||
// For each group, find its parent (smallest containing group)
|
||||
for (const inner of layoutGroups) {
|
||||
let bestParent: LayoutGroup | null = null;
|
||||
let bestArea = Infinity;
|
||||
|
||||
for (const outer of layoutGroups) {
|
||||
if (outer === inner) continue;
|
||||
if (groupContainsGroup(outer.group, inner.group)) {
|
||||
const area = outer.group.size[0] * outer.group.size[1];
|
||||
if (area < bestArea) {
|
||||
bestArea = area;
|
||||
bestParent = outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner.parentGroup = bestParent;
|
||||
if (bestParent) {
|
||||
bestParent.childGroups.push(inner);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate depths using BFS from roots
|
||||
const roots = layoutGroups.filter((g) => !g.parentGroup);
|
||||
const queue: Array<{ group: LayoutGroup; depth: number }> = roots.map(
|
||||
(g) => ({ group: g, depth: 0 })
|
||||
);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
if (!item) break;
|
||||
const { group, depth } = item;
|
||||
group.depth = depth;
|
||||
for (const child of group.childGroups) {
|
||||
queue.push({ group: child, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node's origin is inside a group's content area
|
||||
*/
|
||||
function nodeInsideGroup(node: LGraphNode, group: LGraphGroup): boolean {
|
||||
const gx = group.pos[0];
|
||||
const gy = group.pos[1] + GROUP_TITLE_HEIGHT;
|
||||
const gw = group.size[0];
|
||||
const gh = group.size[1] - GROUP_TITLE_HEIGHT;
|
||||
|
||||
const nx = node.pos?.[0] ?? 0;
|
||||
const ny = node.pos?.[1] ?? 0;
|
||||
|
||||
return nx >= gx && nx < gx + gw && ny >= gy && ny < gy + gh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build layout graph from ComfyUI graph
|
||||
* Groups are treated as "mega-nodes" with full hierarchy support
|
||||
*/
|
||||
export function buildLayoutGraph(
|
||||
graph: LGraph,
|
||||
config: Partial<LayoutConfig> = {}
|
||||
): LayoutState {
|
||||
const fullConfig: LayoutConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
const nodes = new Map<number, LayoutNode>();
|
||||
const groups: LayoutGroup[] = [];
|
||||
|
||||
// First pass: Extract all nodes
|
||||
const graphNodes = graph._nodes ?? [];
|
||||
const allNodes = new Map<number, LGraphNode>();
|
||||
|
||||
for (const node of graphNodes) {
|
||||
if (!node || node.id === undefined) continue;
|
||||
allNodes.set(node.id, node);
|
||||
}
|
||||
|
||||
// Track subgraph I/O nodes separately (they need special position handling)
|
||||
// Runtime I/O nodes have pos/size like regular nodes
|
||||
const ioNodes = new Map<number, RuntimeIONode>();
|
||||
|
||||
// Check for subgraph I/O nodes (Subgraph extends LGraph)
|
||||
const graphAny = graph as unknown as Record<string, unknown>;
|
||||
if (isValidIONode(graphAny.inputNode)) {
|
||||
const inputNode = graphAny.inputNode as RuntimeIONode;
|
||||
const inputLGraphNode = ioNodeToLGraphNode(inputNode, "input");
|
||||
allNodes.set(inputNode.id, inputLGraphNode);
|
||||
ioNodes.set(inputNode.id, inputNode);
|
||||
debugLog(`Found subgraph input node id=${inputNode.id} at [${inputNode.pos}]`);
|
||||
}
|
||||
if (isValidIONode(graphAny.outputNode)) {
|
||||
const outputNode = graphAny.outputNode as RuntimeIONode;
|
||||
const outputLGraphNode = ioNodeToLGraphNode(outputNode, "output");
|
||||
allNodes.set(outputNode.id, outputLGraphNode);
|
||||
ioNodes.set(outputNode.id, outputNode);
|
||||
debugLog(`Found subgraph output node id=${outputNode.id} at [${outputNode.pos}]`);
|
||||
}
|
||||
|
||||
// Classify nodes into connected/disconnected/reroutes
|
||||
const classified = classifyNodes(graph);
|
||||
const disconnectedNodes = classified.disconnected;
|
||||
|
||||
// Find and collapse reroute chains if enabled
|
||||
let rerouteChains: RerouteChain[] = [];
|
||||
let collapsedRerouteIds = new Set<number>();
|
||||
|
||||
if (fullConfig.collapseReroutes && classified.reroutes.size > 0) {
|
||||
rerouteChains = findRerouteChains(graph, classified.reroutes);
|
||||
collapsedRerouteIds = getRerouteNodeIds(rerouteChains);
|
||||
}
|
||||
|
||||
// Second pass: Create LayoutGroup objects (without hierarchy yet)
|
||||
const graphGroups = graph._groups ?? [];
|
||||
|
||||
for (const group of graphGroups) {
|
||||
if (!group || !group.pos || !group.size) continue;
|
||||
|
||||
const layoutGroup: LayoutGroup = {
|
||||
group,
|
||||
memberIds: new Set<number>(),
|
||||
childGroups: [],
|
||||
parentGroup: null,
|
||||
depth: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
groups.push(layoutGroup);
|
||||
}
|
||||
|
||||
// Third pass: Build group hierarchy (parent-child relationships)
|
||||
buildGroupHierarchy(groups);
|
||||
|
||||
// Fourth pass: Assign nodes to their INNERMOST containing group
|
||||
// A node belongs only to the deepest group that contains it
|
||||
// Membership is determined from original workflow positions (user's design intent)
|
||||
const nodeToGroup = new Map<number, LayoutGroup>();
|
||||
|
||||
for (const [nodeId, node] of allNodes) {
|
||||
let bestGroup: LayoutGroup | null = null;
|
||||
let maxDepth = -1;
|
||||
|
||||
for (const layoutGroup of groups) {
|
||||
if (nodeInsideGroup(node, layoutGroup.group)) {
|
||||
if (layoutGroup.depth > maxDepth) {
|
||||
maxDepth = layoutGroup.depth;
|
||||
bestGroup = layoutGroup;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestGroup) {
|
||||
bestGroup.memberIds.add(nodeId);
|
||||
nodeToGroup.set(nodeId, bestGroup);
|
||||
}
|
||||
}
|
||||
|
||||
// Log group membership
|
||||
for (const layoutGroup of groups) {
|
||||
const parentInfo = layoutGroup.parentGroup
|
||||
? ` (inside "${layoutGroup.parentGroup.group.title}")`
|
||||
: "";
|
||||
debugLog(
|
||||
`Group "${layoutGroup.group.title}": ${layoutGroup.memberIds.size} direct members, ${layoutGroup.childGroups.length} child groups${parentInfo}`
|
||||
);
|
||||
}
|
||||
|
||||
// Fifth pass: Calculate group sizes bottom-up (deepest first)
|
||||
// Build groupsByDepth array for bottom-up processing
|
||||
const maxDepth = Math.max(0, ...groups.map((g) => g.depth));
|
||||
const groupsByDepth: LayoutGroup[][] = [];
|
||||
|
||||
for (let d = 0; d <= maxDepth; d++) {
|
||||
groupsByDepth.push(groups.filter((g) => g.depth === d));
|
||||
}
|
||||
|
||||
// Process from deepest to shallowest
|
||||
for (let depth = maxDepth; depth >= 0; depth--) {
|
||||
for (const layoutGroup of groupsByDepth[depth]) {
|
||||
// Calculate dimensions based on internal structure
|
||||
const { width: memberWidth, height: memberHeight } = calculateGroupMemberDimensions(
|
||||
layoutGroup,
|
||||
allNodes,
|
||||
fullConfig
|
||||
);
|
||||
|
||||
let totalWidth = memberWidth;
|
||||
let totalHeight = memberHeight;
|
||||
|
||||
// Include child groups (already calculated in previous iterations)
|
||||
// Child groups add to height below members
|
||||
let childGroupsHeight = 0;
|
||||
for (const child of layoutGroup.childGroups) {
|
||||
totalWidth = Math.max(totalWidth, child.width);
|
||||
childGroupsHeight += child.height + fullConfig.verticalGap;
|
||||
}
|
||||
if (memberHeight > 0 && childGroupsHeight > 0) {
|
||||
totalHeight += fullConfig.verticalGap; // Gap between members and children
|
||||
}
|
||||
totalHeight += childGroupsHeight;
|
||||
|
||||
// Add padding and title
|
||||
layoutGroup.width = totalWidth + fullConfig.groupPadding * 2;
|
||||
layoutGroup.height =
|
||||
totalHeight + fullConfig.groupPadding * 2 + GROUP_TITLE_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
// Sixth pass: Create LayoutNodes
|
||||
// - Standalone connected nodes become regular layout nodes
|
||||
// - Top-level groups get a representative node
|
||||
// - Nested groups are handled internally by their parent
|
||||
// - Disconnected nodes and collapsed reroutes are EXCLUDED (placed separately later)
|
||||
const groupRepresentatives = new Map<LayoutGroup, number>();
|
||||
|
||||
for (const [id, node] of allNodes) {
|
||||
// Skip disconnected nodes - they'll be placed in a separate zone
|
||||
if (disconnectedNodes.has(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip collapsed reroute nodes - they'll be restored after layout
|
||||
if (collapsedRerouteIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const group = nodeToGroup.get(id);
|
||||
|
||||
if (group) {
|
||||
// This node is in a group
|
||||
// Only create representative for top-level groups
|
||||
const topLevelGroup = getTopLevelGroup(group);
|
||||
|
||||
if (!groupRepresentatives.has(topLevelGroup)) {
|
||||
// First member becomes the representative for the top-level group
|
||||
groupRepresentatives.set(topLevelGroup, id);
|
||||
|
||||
// Create layout node with TOP-LEVEL GROUP dimensions
|
||||
nodes.set(id, {
|
||||
node,
|
||||
id,
|
||||
layer: -1,
|
||||
orderInLayer: 0,
|
||||
predecessors: [],
|
||||
successors: [],
|
||||
width: topLevelGroup.width,
|
||||
height: topLevelGroup.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isGroupRepresentative: true,
|
||||
group: topLevelGroup,
|
||||
});
|
||||
}
|
||||
// Skip non-representative group members - they'll be positioned relative to group
|
||||
} else {
|
||||
// Standalone node (or I/O node)
|
||||
const runtimeIONode = ioNodes.get(id);
|
||||
nodes.set(id, {
|
||||
node,
|
||||
id,
|
||||
layer: -1,
|
||||
orderInLayer: 0,
|
||||
predecessors: [],
|
||||
successors: [],
|
||||
width: node.size?.[0] ?? 200,
|
||||
height: node.size?.[1] ?? 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isSubgraphIO: runtimeIONode !== undefined,
|
||||
runtimeIONode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Seventh pass: Build adjacency from links
|
||||
// For group members, redirect connections to the top-level group representative
|
||||
// Skip links involving collapsed reroutes (handled via virtual edges)
|
||||
const links = graph.links;
|
||||
if (links) {
|
||||
// Handle different link storage formats:
|
||||
// - Map<number, LLink> (newer ComfyUI)
|
||||
// - Record<number, LLink> (object with numeric keys)
|
||||
// - LLink[] (array, common in subgraphs)
|
||||
let linkEntries: Iterable<[number, { origin_id: number; target_id: number } | null]>;
|
||||
|
||||
if (links instanceof Map) {
|
||||
linkEntries = links.entries();
|
||||
} else if (Array.isArray(links)) {
|
||||
// Array of link objects (subgraph format)
|
||||
linkEntries = links.map((link, i) => [i, link] as const);
|
||||
} else {
|
||||
// Object with numeric keys
|
||||
linkEntries = Object.entries(links).map(([k, v]) => [Number(k), v] as const);
|
||||
}
|
||||
|
||||
for (const [, link] of linkEntries) {
|
||||
if (!link || typeof link !== "object") continue;
|
||||
if (typeof link.origin_id !== "number" || typeof link.target_id !== "number") continue;
|
||||
|
||||
let sourceId = link.origin_id;
|
||||
let targetId = link.target_id;
|
||||
|
||||
// Skip links involving collapsed reroutes (handled via virtual edges)
|
||||
if (collapsedRerouteIds.has(sourceId) || collapsedRerouteIds.has(targetId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Redirect group members to their top-level group representative
|
||||
const sourceGroup = nodeToGroup.get(sourceId);
|
||||
const targetGroup = nodeToGroup.get(targetId);
|
||||
|
||||
if (sourceGroup) {
|
||||
const topLevel = getTopLevelGroup(sourceGroup);
|
||||
sourceId = groupRepresentatives.get(topLevel)!;
|
||||
}
|
||||
if (targetGroup) {
|
||||
const topLevel = getTopLevelGroup(targetGroup);
|
||||
targetId = groupRepresentatives.get(topLevel)!;
|
||||
}
|
||||
|
||||
// Skip internal group connections (within same top-level group)
|
||||
if (sourceGroup && targetGroup) {
|
||||
const sourceTop = getTopLevelGroup(sourceGroup);
|
||||
const targetTop = getTopLevelGroup(targetGroup);
|
||||
if (sourceTop === targetTop) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const sourceNode = nodes.get(sourceId);
|
||||
const targetNode = nodes.get(targetId);
|
||||
|
||||
if (sourceNode && targetNode) {
|
||||
if (!sourceNode.successors.includes(targetId)) {
|
||||
sourceNode.successors.push(targetId);
|
||||
}
|
||||
if (!targetNode.predecessors.includes(sourceId)) {
|
||||
targetNode.predecessors.push(sourceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Eighth pass: Add virtual edges from collapsed reroute chains
|
||||
if (rerouteChains.length > 0) {
|
||||
const virtualEdges = getVirtualEdges(rerouteChains);
|
||||
|
||||
for (const [sourceId, targetIds] of virtualEdges) {
|
||||
// Handle group redirection for source
|
||||
const sourceGroup = nodeToGroup.get(sourceId);
|
||||
let effectiveSourceId = sourceId;
|
||||
if (sourceGroup) {
|
||||
const topLevel = getTopLevelGroup(sourceGroup);
|
||||
effectiveSourceId = groupRepresentatives.get(topLevel)!;
|
||||
}
|
||||
|
||||
const sourceNode = nodes.get(effectiveSourceId);
|
||||
if (!sourceNode) continue;
|
||||
|
||||
for (const targetId of targetIds) {
|
||||
// Handle group redirection for target
|
||||
const targetGroup = nodeToGroup.get(targetId);
|
||||
let effectiveTargetId = targetId;
|
||||
if (targetGroup) {
|
||||
const topLevel = getTopLevelGroup(targetGroup);
|
||||
effectiveTargetId = groupRepresentatives.get(topLevel)!;
|
||||
}
|
||||
|
||||
const targetNode = nodes.get(effectiveTargetId);
|
||||
if (!targetNode) continue;
|
||||
|
||||
// Skip if same group
|
||||
if (sourceGroup && targetGroup) {
|
||||
const sourceTop = getTopLevelGroup(sourceGroup);
|
||||
const targetTop = getTopLevelGroup(targetGroup);
|
||||
if (sourceTop === targetTop) continue;
|
||||
}
|
||||
|
||||
if (!sourceNode.successors.includes(effectiveTargetId)) {
|
||||
sourceNode.successors.push(effectiveTargetId);
|
||||
}
|
||||
if (!targetNode.predecessors.includes(effectiveSourceId)) {
|
||||
targetNode.predecessors.push(effectiveSourceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
layers: [],
|
||||
groups,
|
||||
config: fullConfig,
|
||||
nodeToGroup,
|
||||
groupRepresentatives,
|
||||
allNodes,
|
||||
groupsByDepth,
|
||||
disconnectedNodes,
|
||||
rerouteChains,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate group member dimensions based on internal structure
|
||||
* For groups with internal connections: uses layer-aware sizing (width = sum of layer widths, height = max layer height)
|
||||
* For groups without internal connections: uses simple vertical stacking (width = max, height = sum)
|
||||
*/
|
||||
function calculateGroupMemberDimensions(
|
||||
layoutGroup: LayoutGroup,
|
||||
allNodes: Map<number, LGraphNode>,
|
||||
config: LayoutConfig
|
||||
): { width: number; height: number } {
|
||||
const memberNodes = [...layoutGroup.memberIds]
|
||||
.map((id) => allNodes.get(id))
|
||||
.filter((n): n is LGraphNode => n !== undefined);
|
||||
|
||||
if (memberNodes.length === 0) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
// Check for internal connections between members
|
||||
const edges = getInternalEdges(layoutGroup.memberIds, allNodes);
|
||||
|
||||
if (edges.length > 0) {
|
||||
// Has internal layers - calculate per-layer dimensions
|
||||
const layers = assignMemberLayers(layoutGroup.memberIds, edges, allNodes);
|
||||
let totalWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
for (const layer of layers) {
|
||||
if (layer.length === 0) continue;
|
||||
|
||||
let layerMaxWidth = 0;
|
||||
let layerHeight = 0;
|
||||
|
||||
for (const node of layer) {
|
||||
layerMaxWidth = Math.max(layerMaxWidth, node.size?.[0] ?? 200);
|
||||
layerHeight += (node.size?.[1] ?? 100) + config.verticalGap;
|
||||
}
|
||||
|
||||
// Remove trailing gap
|
||||
if (layerHeight > 0) {
|
||||
layerHeight -= config.verticalGap;
|
||||
}
|
||||
|
||||
totalWidth += layerMaxWidth + config.horizontalGap;
|
||||
maxHeight = Math.max(maxHeight, layerHeight);
|
||||
}
|
||||
|
||||
// Remove trailing gap
|
||||
if (totalWidth > 0) {
|
||||
totalWidth -= config.horizontalGap;
|
||||
}
|
||||
|
||||
return { width: totalWidth, height: maxHeight };
|
||||
} else {
|
||||
// No internal connections - simple vertical stacking
|
||||
let maxWidth = 0;
|
||||
let totalHeight = 0;
|
||||
|
||||
for (const node of memberNodes) {
|
||||
maxWidth = Math.max(maxWidth, node.size?.[0] ?? 200);
|
||||
totalHeight += (node.size?.[1] ?? 100) + config.verticalGap;
|
||||
}
|
||||
|
||||
// Remove trailing gap
|
||||
if (totalHeight > 0) {
|
||||
totalHeight -= config.verticalGap;
|
||||
}
|
||||
|
||||
return { width: maxWidth, height: totalHeight };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the top-level group (root of hierarchy) for a given group
|
||||
*/
|
||||
function getTopLevelGroup(group: LayoutGroup): LayoutGroup {
|
||||
let current = group;
|
||||
while (current.parentGroup) {
|
||||
current = current.parentGroup;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import type { LayoutState, LGraphGroup } from "./types";
|
||||
import { debugLog } from "../debug";
|
||||
|
||||
/** Title bar height for groups */
|
||||
const GROUP_TITLE_HEIGHT = 50;
|
||||
|
||||
/**
|
||||
* Resize groups to fit their contents after layout
|
||||
* Uses bottom-up processing: deepest groups first, then parents
|
||||
*/
|
||||
export function resizeGroupsToFit(state: LayoutState): number {
|
||||
const { groupsByDepth, allNodes, config } = state;
|
||||
|
||||
if (!groupsByDepth || !allNodes) {
|
||||
debugLog("Missing groupsByDepth or allNodes");
|
||||
return 0;
|
||||
}
|
||||
|
||||
let resizedCount = 0;
|
||||
const maxDepth = groupsByDepth.length - 1;
|
||||
|
||||
// Process from deepest to shallowest (bottom-up)
|
||||
for (let depth = maxDepth; depth >= 0; depth--) {
|
||||
for (const layoutGroup of groupsByDepth[depth]) {
|
||||
const { group, memberIds, childGroups } = layoutGroup;
|
||||
|
||||
// Skip groups with no content
|
||||
if (memberIds.size === 0 && childGroups.length === 0) {
|
||||
debugLog(`Skipping empty group "${group.title}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate bounding box from positioned members and child groups
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
// Include direct member nodes
|
||||
for (const nodeId of memberIds) {
|
||||
const node = allNodes.get(nodeId);
|
||||
if (!node) continue;
|
||||
|
||||
const nx = node.pos?.[0] ?? 0;
|
||||
const ny = node.pos?.[1] ?? 0;
|
||||
const nw = node.size?.[0] ?? 200;
|
||||
const nh = node.size?.[1] ?? 100;
|
||||
|
||||
minX = Math.min(minX, nx);
|
||||
minY = Math.min(minY, ny);
|
||||
maxX = Math.max(maxX, nx + nw);
|
||||
maxY = Math.max(maxY, ny + nh);
|
||||
}
|
||||
|
||||
// Include child groups (already resized in previous iterations)
|
||||
for (const child of childGroups) {
|
||||
const cg = child.group;
|
||||
const cx = cg.pos[0];
|
||||
const cy = cg.pos[1];
|
||||
const cw = cg.size[0];
|
||||
const ch = cg.size[1];
|
||||
|
||||
minX = Math.min(minX, cx);
|
||||
minY = Math.min(minY, cy);
|
||||
maxX = Math.max(maxX, cx + cw);
|
||||
maxY = Math.max(maxY, cy + ch);
|
||||
}
|
||||
|
||||
// Handle empty bounds (shouldn't happen but be safe)
|
||||
if (minX === Infinity) {
|
||||
debugLog(`No content bounds for group "${group.title}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate final group bounds with padding
|
||||
const padding = config.groupPadding;
|
||||
const newX = minX - padding;
|
||||
const newY = minY - padding - GROUP_TITLE_HEIGHT;
|
||||
const newWidth = maxX - minX + padding * 2;
|
||||
const newHeight = maxY - minY + padding * 2 + GROUP_TITLE_HEIGHT;
|
||||
|
||||
debugLog(
|
||||
`Resizing group "${group.title}" (depth ${layoutGroup.depth}) to [${newX}, ${newY}, ${newWidth}, ${newHeight}]`
|
||||
);
|
||||
|
||||
// Update group bounds
|
||||
updateGroupBounds(group, newX, newY, newWidth, newHeight);
|
||||
|
||||
resizedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return resizedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group bounds using multiple methods for compatibility
|
||||
*/
|
||||
function updateGroupBounds(
|
||||
group: LGraphGroup,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number
|
||||
): void {
|
||||
// Method 1: Direct pos/size array mutation (most common)
|
||||
if (Array.isArray(group.pos)) {
|
||||
group.pos[0] = x;
|
||||
group.pos[1] = y;
|
||||
}
|
||||
if (Array.isArray(group.size)) {
|
||||
group.size[0] = width;
|
||||
group.size[1] = height;
|
||||
}
|
||||
|
||||
// Method 2: Try _pos/_size internal properties
|
||||
const g = group as LGraphGroup & {
|
||||
_pos?: [number, number];
|
||||
_size?: [number, number];
|
||||
};
|
||||
if (Array.isArray(g._pos)) {
|
||||
g._pos[0] = x;
|
||||
g._pos[1] = y;
|
||||
}
|
||||
if (Array.isArray(g._size)) {
|
||||
g._size[0] = width;
|
||||
g._size[1] = height;
|
||||
}
|
||||
|
||||
// Method 3: Update _bounding Rectangle if present
|
||||
if (group._bounding) {
|
||||
const b = group._bounding;
|
||||
b.x = x;
|
||||
b.y = y;
|
||||
b.width = width;
|
||||
b.height = height;
|
||||
}
|
||||
|
||||
// Method 4: Try bounding array (older format)
|
||||
const gAny = group as LGraphGroup & { bounding?: number[] };
|
||||
if (Array.isArray(gAny.bounding)) {
|
||||
gAny.bounding[0] = x;
|
||||
gAny.bounding[1] = y;
|
||||
gAny.bounding[2] = width;
|
||||
gAny.bounding[3] = height;
|
||||
}
|
||||
}
|
||||
@@ -1,106 +1,29 @@
|
||||
import type { LGraph, LayoutConfig, LayoutResult } from "./types";
|
||||
import { DEFAULT_CONFIG } from "./types";
|
||||
import { buildLayoutGraph } from "./graph-builder";
|
||||
import { assignLayers } from "./layer-assign";
|
||||
import { minimizeCrossings } from "./ordering";
|
||||
import {
|
||||
assignLayerXPositions,
|
||||
assignNodeYPositions,
|
||||
recalculateLayerXPositions,
|
||||
applyPositions,
|
||||
compactVertically,
|
||||
resolveAllOverlaps,
|
||||
} from "./positioning";
|
||||
import { resizeGroupsToFit } from "./groups";
|
||||
import { debugLog } from "../debug";
|
||||
|
||||
/**
|
||||
* Main entry point: layout the entire graph
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Build layout graph (classify nodes, collapse reroutes, filter disconnected)
|
||||
* 2. Assign layers via longest-path algorithm
|
||||
* 3. Minimize edge crossings via size-aware barycenter heuristic
|
||||
* 4. Assign X/Y coordinates with bin packing
|
||||
* 5. Compact vertically to reduce gaps
|
||||
* 6. Apply positions (includes disconnected zone and reroute restoration)
|
||||
* 7. Resize groups to fit their members
|
||||
* Layout module barrel export.
|
||||
*/
|
||||
export function layoutGraph(
|
||||
graph: LGraph,
|
||||
config?: Partial<LayoutConfig>
|
||||
): LayoutResult {
|
||||
const startTime = performance.now();
|
||||
const fullConfig: LayoutConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
// Handle empty graph
|
||||
const nodeList = graph._nodes ?? [];
|
||||
if (nodeList.length === 0) {
|
||||
return {
|
||||
nodeCount: 0,
|
||||
layerCount: 0,
|
||||
groupCount: 0,
|
||||
executionMs: 0,
|
||||
};
|
||||
}
|
||||
export {
|
||||
layoutWithGroups,
|
||||
buildGroupHierarchy,
|
||||
splitDisconnected,
|
||||
placeDisconnected,
|
||||
translatePositions,
|
||||
} from "./framework";
|
||||
|
||||
// Phase 1: Build layout graph (includes classification and reroute collapse)
|
||||
const state = buildLayoutGraph(graph, fullConfig);
|
||||
export { compactVertically, compactHorizontally } from "./compact";
|
||||
|
||||
// Phase 2: Assign layers
|
||||
assignLayers(state);
|
||||
export type {
|
||||
Position,
|
||||
LayoutNode,
|
||||
LayoutEdge,
|
||||
LayoutGroup,
|
||||
LayoutToken,
|
||||
LayoutInput,
|
||||
LayoutOutput,
|
||||
LayoutAlgorithm,
|
||||
GroupBounds,
|
||||
FrameworkResult,
|
||||
FrameworkConfig,
|
||||
} from "./types";
|
||||
|
||||
// Phase 3: Minimize crossings (size-aware barycenter)
|
||||
minimizeCrossings(state);
|
||||
|
||||
// Phase 4: Assign coordinates (with bin packing)
|
||||
assignLayerXPositions(state);
|
||||
assignNodeYPositions(state);
|
||||
|
||||
// Phase 4b: Recalculate X after bin packing updates maxWidth
|
||||
// (bin packing may place multiple nodes in a row, making layer wider)
|
||||
recalculateLayerXPositions(state);
|
||||
|
||||
// Phase 5: Compact vertically
|
||||
compactVertically(state);
|
||||
|
||||
// Phase 6: Apply to ComfyUI nodes (includes disconnected and reroute handling)
|
||||
applyPositions(state);
|
||||
|
||||
// Phase 7: Resize groups
|
||||
const groupCount = resizeGroupsToFit(state);
|
||||
|
||||
// Phase 8: Resolve all overlaps (groups + nodes unified)
|
||||
// Uses scanline algorithm with priority-based monotonic pushing
|
||||
resolveAllOverlaps(state);
|
||||
|
||||
// Trigger canvas redraw
|
||||
if (graph.setDirtyCanvas) {
|
||||
graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
const executionMs = performance.now() - startTime;
|
||||
|
||||
// Count total nodes including disconnected and reroutes
|
||||
const disconnectedCount = state.disconnectedNodes?.size ?? 0;
|
||||
const rerouteCount = state.rerouteChains?.reduce((sum, c) => sum + c.nodes.length, 0) ?? 0;
|
||||
const totalNodes = state.nodes.size + disconnectedCount + rerouteCount;
|
||||
|
||||
debugLog(
|
||||
`Layout complete: ${totalNodes} nodes (${state.nodes.size} in DAG, ${disconnectedCount} disconnected, ${rerouteCount} reroutes), ${state.layers.length} layers in ${executionMs.toFixed(1)}ms`
|
||||
);
|
||||
|
||||
return {
|
||||
nodeCount: totalNodes,
|
||||
layerCount: state.layers.length,
|
||||
groupCount,
|
||||
executionMs,
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export types and config
|
||||
export type { LayoutConfig, LayoutResult, SelectedGroupLayoutResult } from "./types";
|
||||
export { DEFAULT_CONFIG } from "./types";
|
||||
|
||||
// Export selected groups layout
|
||||
export { layoutSelectedGroups } from "./selected-groups";
|
||||
export { DEFAULT_FRAMEWORK_CONFIG } from "./types";
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { LayoutState, LayoutNode, Layer } from "./types";
|
||||
|
||||
/**
|
||||
* Assign layers to nodes using longest-path algorithm
|
||||
* Sources (no predecessors) get layer 0
|
||||
* Each node gets max(predecessor layers) + 1
|
||||
*/
|
||||
export function assignLayers(state: LayoutState): void {
|
||||
const { nodes } = state;
|
||||
|
||||
// Topological sort via Kahn's algorithm
|
||||
const inDegree = new Map<number, number>();
|
||||
const queue: number[] = [];
|
||||
const order: number[] = [];
|
||||
|
||||
// Initialize in-degrees
|
||||
for (const [id, node] of nodes) {
|
||||
inDegree.set(id, node.predecessors.length);
|
||||
if (node.predecessors.length === 0) {
|
||||
queue.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Process nodes in topological order
|
||||
while (queue.length > 0) {
|
||||
const nodeId = queue.shift()!;
|
||||
order.push(nodeId);
|
||||
|
||||
const node = nodes.get(nodeId)!;
|
||||
for (const succId of node.successors) {
|
||||
const deg = (inDegree.get(succId) ?? 1) - 1;
|
||||
inDegree.set(succId, deg);
|
||||
if (deg === 0) {
|
||||
queue.push(succId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle cycles: add remaining nodes (shouldn't happen in valid DAG)
|
||||
for (const [id] of nodes) {
|
||||
if (!order.includes(id)) {
|
||||
order.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Assign layers via longest path from sources
|
||||
for (const nodeId of order) {
|
||||
const node = nodes.get(nodeId)!;
|
||||
|
||||
if (node.predecessors.length === 0) {
|
||||
// Source node: layer 0
|
||||
node.layer = 0;
|
||||
} else {
|
||||
// Max layer of predecessors + 1
|
||||
let maxPredLayer = 0;
|
||||
for (const predId of node.predecessors) {
|
||||
const pred = nodes.get(predId);
|
||||
if (pred && pred.layer >= 0) {
|
||||
maxPredLayer = Math.max(maxPredLayer, pred.layer + 1);
|
||||
}
|
||||
}
|
||||
node.layer = maxPredLayer;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all nodes have valid layer (handle edge cases)
|
||||
for (const node of nodes.values()) {
|
||||
if (node.layer < 0) {
|
||||
node.layer = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Build layer structures
|
||||
const layerMap = new Map<number, LayoutNode[]>();
|
||||
for (const node of nodes.values()) {
|
||||
const arr = layerMap.get(node.layer) ?? [];
|
||||
arr.push(node);
|
||||
layerMap.set(node.layer, arr);
|
||||
}
|
||||
|
||||
// Sort layers by index and create Layer objects
|
||||
const sortedLayerIndices = [...layerMap.keys()].sort((a, b) => a - b);
|
||||
const layers: Layer[] = [];
|
||||
|
||||
for (const index of sortedLayerIndices) {
|
||||
const layerNodes = layerMap.get(index)!;
|
||||
// Sort nodes in layer by ID for initial deterministic order
|
||||
layerNodes.sort((a, b) => a.id - b.id);
|
||||
layerNodes.forEach((n, i) => {
|
||||
n.orderInLayer = i;
|
||||
});
|
||||
|
||||
layers.push({
|
||||
index,
|
||||
nodes: layerNodes,
|
||||
x: 0,
|
||||
maxWidth: Math.max(...layerNodes.map((n) => n.width), 100),
|
||||
});
|
||||
}
|
||||
|
||||
state.layers = layers;
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import type { LGraphNode } from "./types";
|
||||
|
||||
/**
|
||||
* Get internal edges between group members by examining node connections
|
||||
* Returns array of [sourceId, targetId] pairs where both are in the group
|
||||
*/
|
||||
export function getInternalEdges(
|
||||
memberIds: Set<number>,
|
||||
allNodes: Map<number, LGraphNode>
|
||||
): Array<[number, number]> {
|
||||
const edges: Array<[number, number]> = [];
|
||||
|
||||
// Build a map of linkId -> sourceNodeId for all member outputs
|
||||
const linkToSource = new Map<number, number>();
|
||||
for (const memberId of memberIds) {
|
||||
const node = allNodes.get(memberId);
|
||||
if (!node?.outputs) continue;
|
||||
|
||||
for (const output of node.outputs) {
|
||||
if (output.links) {
|
||||
for (const linkId of output.links) {
|
||||
linkToSource.set(linkId, memberId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check each member's inputs to find internal connections
|
||||
for (const memberId of memberIds) {
|
||||
const node = allNodes.get(memberId);
|
||||
if (!node?.inputs) continue;
|
||||
|
||||
for (const input of node.inputs) {
|
||||
if (input.link !== null && input.link !== undefined) {
|
||||
const sourceId = linkToSource.get(input.link);
|
||||
if (sourceId !== undefined && memberIds.has(sourceId)) {
|
||||
edges.push([sourceId, memberId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign layers to group members using longest-path algorithm
|
||||
* Returns members grouped by layer (index 0 = sources)
|
||||
*/
|
||||
export function assignMemberLayers(
|
||||
memberIds: Set<number>,
|
||||
edges: Array<[number, number]>,
|
||||
allNodes: Map<number, LGraphNode>
|
||||
): LGraphNode[][] {
|
||||
// Build adjacency and in-degree
|
||||
const successors = new Map<number, number[]>();
|
||||
const inDegree = new Map<number, number>();
|
||||
|
||||
for (const id of memberIds) {
|
||||
successors.set(id, []);
|
||||
inDegree.set(id, 0);
|
||||
}
|
||||
|
||||
for (const [source, target] of edges) {
|
||||
successors.get(source)!.push(target);
|
||||
inDegree.set(target, (inDegree.get(target) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Longest-path layer assignment via topological sort
|
||||
const nodeLayer = new Map<number, number>();
|
||||
const queue: number[] = [];
|
||||
|
||||
// Start with sources (in-degree 0)
|
||||
for (const id of memberIds) {
|
||||
if ((inDegree.get(id) ?? 0) === 0) {
|
||||
queue.push(id);
|
||||
nodeLayer.set(id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const currentLayer = nodeLayer.get(current) ?? 0;
|
||||
|
||||
for (const succ of successors.get(current) ?? []) {
|
||||
// Update successor layer to max of current paths
|
||||
const succLayer = nodeLayer.get(succ) ?? 0;
|
||||
nodeLayer.set(succ, Math.max(succLayer, currentLayer + 1));
|
||||
|
||||
// Decrease in-degree and add to queue when all predecessors processed
|
||||
const newInDegree = (inDegree.get(succ) ?? 1) - 1;
|
||||
inDegree.set(succ, newInDegree);
|
||||
if (newInDegree === 0) {
|
||||
queue.push(succ);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any unvisited nodes (cycles or disconnected)
|
||||
for (const id of memberIds) {
|
||||
if (!nodeLayer.has(id)) {
|
||||
nodeLayer.set(id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Group by layer
|
||||
const maxLayer = Math.max(...nodeLayer.values(), 0);
|
||||
const layers: LGraphNode[][] = Array.from({ length: maxLayer + 1 }, () => []);
|
||||
|
||||
for (const id of memberIds) {
|
||||
const node = allNodes.get(id);
|
||||
if (node) {
|
||||
const layer = nodeLayer.get(id) ?? 0;
|
||||
layers[layer].push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { LGraph, LLink, ClassifiedNodes } from "./types";
|
||||
import { debugLog } from "../debug";
|
||||
|
||||
/**
|
||||
* Iterate over links in a graph (handles Map, Record, and Array formats)
|
||||
*/
|
||||
function* iterateLinks(
|
||||
links: Map<number, LLink> | Record<number, LLink> | LLink[]
|
||||
): Generator<LLink> {
|
||||
if (links instanceof Map) {
|
||||
for (const link of links.values()) {
|
||||
if (link) yield link;
|
||||
}
|
||||
} else if (Array.isArray(links)) {
|
||||
for (const link of links) {
|
||||
if (link) yield link;
|
||||
}
|
||||
} else {
|
||||
for (const link of Object.values(links)) {
|
||||
if (link) yield link;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify nodes into connected, disconnected, and reroutes
|
||||
*
|
||||
* - connected: nodes with at least one link
|
||||
* - disconnected: nodes with no links (annotations, bookmarks, etc.)
|
||||
* - reroutes: nodes with type "Reroute" (handled separately for chain collapse)
|
||||
*/
|
||||
export function classifyNodes(graph: LGraph): ClassifiedNodes {
|
||||
const hasLinks = new Set<number>();
|
||||
|
||||
// Mark all nodes that appear in any link
|
||||
if (graph.links) {
|
||||
for (const link of iterateLinks(graph.links)) {
|
||||
if (typeof link.origin_id === "number") {
|
||||
hasLinks.add(link.origin_id);
|
||||
}
|
||||
if (typeof link.target_id === "number") {
|
||||
hasLinks.add(link.target_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const connected = new Set<number>();
|
||||
const disconnected = new Set<number>();
|
||||
const reroutes = new Set<number>();
|
||||
|
||||
const nodes = graph._nodes ?? [];
|
||||
for (const node of nodes) {
|
||||
if (!node || node.id === undefined) continue;
|
||||
|
||||
const nodeType = node.type ?? "";
|
||||
|
||||
// Check if this is a reroute node
|
||||
// Common reroute types: "Reroute", "Reroute (rgthree)", etc.
|
||||
const isReroute = nodeType === "Reroute" || nodeType.startsWith("Reroute ");
|
||||
|
||||
if (isReroute) {
|
||||
reroutes.add(node.id);
|
||||
// Reroutes are also tracked as connected if they have links
|
||||
if (hasLinks.has(node.id)) {
|
||||
connected.add(node.id);
|
||||
}
|
||||
} else if (hasLinks.has(node.id)) {
|
||||
connected.add(node.id);
|
||||
} else {
|
||||
disconnected.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
debugLog(
|
||||
`Classified: ${connected.size} connected, ${disconnected.size} disconnected, ${reroutes.size} reroutes`
|
||||
);
|
||||
|
||||
return { connected, disconnected, reroutes };
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import type { LayoutState, Layer, LayoutNode } from "./types";
|
||||
|
||||
/**
|
||||
* Minimize edge crossings using size-aware barycenter heuristic
|
||||
* Uses cumulative height positions instead of simple indices
|
||||
* Alternates forward and backward sweeps
|
||||
*/
|
||||
export function minimizeCrossings(state: LayoutState): void {
|
||||
const { layers, nodes, config } = state;
|
||||
|
||||
if (layers.length < 2) return;
|
||||
|
||||
// Initial ordering: sort by node height (tall nodes first reduces gaps)
|
||||
for (const layer of layers) {
|
||||
layer.nodes.sort((a, b) => b.height - a.height);
|
||||
layer.nodes.forEach((n, i) => {
|
||||
n.orderInLayer = i;
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-compute cumulative height positions for size-aware barycenter
|
||||
const heightPositions = new Map<number, number>();
|
||||
updateHeightPositions(layers, heightPositions, config.verticalGap);
|
||||
|
||||
// Iterative improvement with forward/backward sweeps
|
||||
for (let iter = 0; iter < config.maxIterations; iter++) {
|
||||
const improved =
|
||||
iter % 2 === 0
|
||||
? sweepForward(layers, nodes, heightPositions, config.verticalGap)
|
||||
: sweepBackward(layers, nodes, heightPositions, config.verticalGap);
|
||||
|
||||
if (!improved) break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute cumulative height positions for each node
|
||||
* This gives a position-like weight for size-aware ordering
|
||||
*/
|
||||
function updateHeightPositions(
|
||||
layers: Layer[],
|
||||
positions: Map<number, number>,
|
||||
verticalGap: number
|
||||
): void {
|
||||
for (const layer of layers) {
|
||||
let y = 0;
|
||||
for (const node of layer.nodes) {
|
||||
// Position is center of node
|
||||
positions.set(node.id, y + node.height / 2);
|
||||
y += node.height + verticalGap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward sweep: order each layer based on predecessors in previous layer
|
||||
* Uses height-aware positions for better size consideration
|
||||
*/
|
||||
function sweepForward(
|
||||
layers: Layer[],
|
||||
nodes: Map<number, LayoutNode>,
|
||||
heightPositions: Map<number, number>,
|
||||
verticalGap: number
|
||||
): boolean {
|
||||
let improved = false;
|
||||
|
||||
for (let i = 1; i < layers.length; i++) {
|
||||
const currLayer = layers[i];
|
||||
|
||||
// Compute size-aware barycenter for each node
|
||||
const barycenters = new Map<number, number>();
|
||||
|
||||
for (const node of currLayer.nodes) {
|
||||
if (node.predecessors.length > 0) {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const predId of node.predecessors) {
|
||||
const pred = nodes.get(predId);
|
||||
if (pred && pred.layer === i - 1) {
|
||||
// Use height position instead of simple index
|
||||
const pos = heightPositions.get(predId) ?? pred.orderInLayer;
|
||||
sum += pos;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
barycenters.set(node.id, sum / count);
|
||||
} else {
|
||||
barycenters.set(node.id, heightPositions.get(node.id) ?? 0);
|
||||
}
|
||||
} else {
|
||||
barycenters.set(node.id, heightPositions.get(node.id) ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by barycenter, tie-break by ID for determinism
|
||||
const oldOrder = currLayer.nodes.map((n) => n.id).join(",");
|
||||
currLayer.nodes.sort((a, b) => {
|
||||
const aBar = barycenters.get(a.id) ?? 0;
|
||||
const bBar = barycenters.get(b.id) ?? 0;
|
||||
const diff = aBar - bBar;
|
||||
return diff !== 0 ? diff : a.id - b.id;
|
||||
});
|
||||
|
||||
// Reassign integer order
|
||||
currLayer.nodes.forEach((n, idx) => {
|
||||
n.orderInLayer = idx;
|
||||
});
|
||||
|
||||
// Update height positions for next iteration
|
||||
let y = 0;
|
||||
for (const node of currLayer.nodes) {
|
||||
heightPositions.set(node.id, y + node.height / 2);
|
||||
y += node.height + verticalGap;
|
||||
}
|
||||
|
||||
const newOrder = currLayer.nodes.map((n) => n.id).join(",");
|
||||
if (oldOrder !== newOrder) improved = true;
|
||||
}
|
||||
|
||||
return improved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward sweep: order each layer based on successors in next layer
|
||||
* Uses height-aware positions for better size consideration
|
||||
*/
|
||||
function sweepBackward(
|
||||
layers: Layer[],
|
||||
nodes: Map<number, LayoutNode>,
|
||||
heightPositions: Map<number, number>,
|
||||
verticalGap: number
|
||||
): boolean {
|
||||
let improved = false;
|
||||
|
||||
for (let i = layers.length - 2; i >= 0; i--) {
|
||||
const currLayer = layers[i];
|
||||
|
||||
// Compute size-aware barycenter for each node based on successors
|
||||
const barycenters = new Map<number, number>();
|
||||
|
||||
for (const node of currLayer.nodes) {
|
||||
if (node.successors.length > 0) {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const succId of node.successors) {
|
||||
const succ = nodes.get(succId);
|
||||
if (succ && succ.layer === i + 1) {
|
||||
// Use height position instead of simple index
|
||||
const pos = heightPositions.get(succId) ?? succ.orderInLayer;
|
||||
sum += pos;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
barycenters.set(node.id, sum / count);
|
||||
} else {
|
||||
barycenters.set(node.id, heightPositions.get(node.id) ?? 0);
|
||||
}
|
||||
} else {
|
||||
barycenters.set(node.id, heightPositions.get(node.id) ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by barycenter, tie-break by ID for determinism
|
||||
const oldOrder = currLayer.nodes.map((n) => n.id).join(",");
|
||||
currLayer.nodes.sort((a, b) => {
|
||||
const aBar = barycenters.get(a.id) ?? 0;
|
||||
const bBar = barycenters.get(b.id) ?? 0;
|
||||
const diff = aBar - bBar;
|
||||
return diff !== 0 ? diff : a.id - b.id;
|
||||
});
|
||||
|
||||
// Reassign integer order
|
||||
currLayer.nodes.forEach((n, idx) => {
|
||||
n.orderInLayer = idx;
|
||||
});
|
||||
|
||||
// Update height positions for next iteration
|
||||
let y = 0;
|
||||
for (const node of currLayer.nodes) {
|
||||
heightPositions.set(node.id, y + node.height / 2);
|
||||
y += node.height + verticalGap;
|
||||
}
|
||||
|
||||
const newOrder = currLayer.nodes.map((n) => n.id).join(",");
|
||||
if (oldOrder !== newOrder) improved = true;
|
||||
}
|
||||
|
||||
return improved;
|
||||
}
|
||||
@@ -1,846 +0,0 @@
|
||||
import type { LayoutState, LayoutGroup, LGraphNode, LGraphGroup, LayoutConfig, PackedRow, LayoutNode } from "./types";
|
||||
|
||||
/**
|
||||
* Entity type for unified overlap resolution
|
||||
* Represents either a group or a standalone node
|
||||
*/
|
||||
interface LayoutEntity {
|
||||
id: string;
|
||||
type: "group" | "node";
|
||||
priority: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
initialX: number; // Original position before overlap resolution
|
||||
initialY: number; // Original position before overlap resolution
|
||||
group?: LGraphGroup;
|
||||
node?: LGraphNode;
|
||||
layoutGroup?: LayoutGroup;
|
||||
}
|
||||
import { packNodesIntoRows, getPackedWidth } from "./bin-pack";
|
||||
import { restoreReroutePositions } from "./reroute-collapse";
|
||||
import { getInternalEdges, assignMemberLayers } from "./layout-utils";
|
||||
import { debugLog } from "../debug";
|
||||
import { parseLayoutToken, arrangeByMode } from "./title-tokens";
|
||||
|
||||
/** Title bar height for groups */
|
||||
const GROUP_TITLE_HEIGHT = 50;
|
||||
|
||||
/**
|
||||
* Calculate disconnected zone width if there are disconnected nodes
|
||||
*/
|
||||
function getDisconnectedZoneWidth(state: LayoutState): number {
|
||||
const { disconnectedNodes, allNodes, config } = state;
|
||||
|
||||
if (!disconnectedNodes || disconnectedNodes.size === 0 || !allNodes) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let maxWidth = 0;
|
||||
for (const nodeId of disconnectedNodes) {
|
||||
const node = allNodes.get(nodeId);
|
||||
if (node) {
|
||||
maxWidth = Math.max(maxWidth, node.size?.[0] ?? 200);
|
||||
}
|
||||
}
|
||||
return maxWidth + config.disconnectedGap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign X coordinates to layers
|
||||
* Accounts for disconnected zone on the left if present
|
||||
*/
|
||||
export function assignLayerXPositions(state: LayoutState): void {
|
||||
const { layers, config } = state;
|
||||
const disconnectedZoneWidth = getDisconnectedZoneWidth(state);
|
||||
|
||||
let x = config.startX + disconnectedZoneWidth;
|
||||
for (const layer of layers) {
|
||||
layer.x = x;
|
||||
x += layer.maxWidth + config.horizontalGap;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate layer X positions after bin packing updates maxWidth
|
||||
* Also updates node X positions to maintain their offset within each layer
|
||||
* Must be called AFTER assignNodeYPositions which updates layer.maxWidth
|
||||
*/
|
||||
export function recalculateLayerXPositions(state: LayoutState): void {
|
||||
const { layers, config } = state;
|
||||
const disconnectedZoneWidth = getDisconnectedZoneWidth(state);
|
||||
|
||||
// Store each node's offset from its layer's current X
|
||||
const nodeOffsets = new Map<number, number>();
|
||||
for (const layer of layers) {
|
||||
for (const node of layer.nodes) {
|
||||
nodeOffsets.set(node.id, node.x - layer.x);
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate layer X positions using updated maxWidth
|
||||
let x = config.startX + disconnectedZoneWidth;
|
||||
for (const layer of layers) {
|
||||
layer.x = x;
|
||||
x += layer.maxWidth + config.horizontalGap;
|
||||
}
|
||||
|
||||
// Update node X positions based on new layer X + original offset
|
||||
for (const layer of layers) {
|
||||
for (const node of layer.nodes) {
|
||||
const offset = nodeOffsets.get(node.id) ?? 0;
|
||||
node.x = layer.x + offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign Y coordinates to nodes within each layer using bin packing
|
||||
* Supports multi-column layout based on config.maxColumns
|
||||
* Tracks reserved regions to prevent group/node overlaps within same layer
|
||||
*/
|
||||
export function assignNodeYPositions(state: LayoutState): void {
|
||||
const { layers, config } = state;
|
||||
|
||||
// Store packed rows for reference
|
||||
const packedRows = new Map<number, PackedRow[]>();
|
||||
|
||||
for (const layer of layers) {
|
||||
// Separate groups (placed first, full width) from standalone nodes
|
||||
const groupNodes = layer.nodes.filter((n) => n.isGroupRepresentative);
|
||||
const standaloneNodes = layer.nodes.filter((n) => !n.isGroupRepresentative);
|
||||
|
||||
let y = config.startY;
|
||||
const reservedRegions: Array<{ top: number; bottom: number }> = [];
|
||||
|
||||
// Position groups first, tracking their Y regions
|
||||
for (const node of groupNodes) {
|
||||
node.x = layer.x;
|
||||
node.y = y;
|
||||
reservedRegions.push({ top: y, bottom: y + node.height });
|
||||
y += node.height + config.verticalGap;
|
||||
}
|
||||
|
||||
// Bin pack standalone nodes, avoiding reserved regions
|
||||
if (standaloneNodes.length > 0) {
|
||||
// Build size map for bin packing
|
||||
const sizes = new Map<number, { width: number; height: number }>();
|
||||
for (const node of standaloneNodes) {
|
||||
sizes.set(node.id, { width: node.width, height: node.height });
|
||||
}
|
||||
|
||||
// Pack nodes into rows
|
||||
const nodeIds = standaloneNodes.map((n) => n.id);
|
||||
const rows = packNodesIntoRows(nodeIds, sizes, config);
|
||||
|
||||
packedRows.set(layer.index, rows);
|
||||
|
||||
// Assign positions based on packed rows
|
||||
for (const row of rows) {
|
||||
let rowX = layer.x;
|
||||
let rowY = y + row.yOffset;
|
||||
|
||||
// Check if row overlaps any reserved region
|
||||
for (const region of reservedRegions) {
|
||||
if (rowY < region.bottom && rowY + row.height > region.top) {
|
||||
rowY = region.bottom + config.verticalGap;
|
||||
}
|
||||
}
|
||||
|
||||
for (const nodeId of row.nodes) {
|
||||
const node = standaloneNodes.find((n) => n.id === nodeId);
|
||||
if (node) {
|
||||
node.x = rowX;
|
||||
node.y = rowY;
|
||||
rowX += node.width + config.horizontalGap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update layer maxWidth to account for packed rows
|
||||
const maxRowWidth = getPackedWidth(rows);
|
||||
if (maxRowWidth > layer.maxWidth) {
|
||||
layer.maxWidth = maxRowWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.packedRows = packedRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Place disconnected nodes in the left margin zone
|
||||
* Uses bin packing for compact arrangement
|
||||
*/
|
||||
export function placeDisconnectedNodes(state: LayoutState): void {
|
||||
const { disconnectedNodes, allNodes, nodeToGroup, config } = state;
|
||||
|
||||
if (!disconnectedNodes || disconnectedNodes.size === 0 || !allNodes) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out disconnected nodes that are inside groups - they're positioned with their group
|
||||
const standaloneDisconnected: number[] = [];
|
||||
for (const nodeId of disconnectedNodes) {
|
||||
if (nodeToGroup?.has(nodeId)) {
|
||||
continue; // Skip - this node is inside a group
|
||||
}
|
||||
standaloneDisconnected.push(nodeId);
|
||||
}
|
||||
|
||||
if (standaloneDisconnected.length === 0) {
|
||||
debugLog(`All ${disconnectedNodes.size} disconnected nodes are inside groups`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build size map
|
||||
const sizes = new Map<number, { width: number; height: number }>();
|
||||
for (const nodeId of standaloneDisconnected) {
|
||||
const node = allNodes.get(nodeId);
|
||||
if (node) {
|
||||
sizes.set(nodeId, {
|
||||
width: node.size?.[0] ?? 200,
|
||||
height: node.size?.[1] ?? 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pack into rows using config settings
|
||||
const rows = packNodesIntoRows(standaloneDisconnected, sizes, {
|
||||
...config,
|
||||
maxColumns: 1, // Stack vertically in left margin
|
||||
});
|
||||
|
||||
// Position at startX, startY (left of main DAG)
|
||||
const x = config.startX;
|
||||
const y = config.startY;
|
||||
|
||||
for (const row of rows) {
|
||||
for (const nodeId of row.nodes) {
|
||||
const node = allNodes.get(nodeId);
|
||||
if (node && node.pos) {
|
||||
// Skip locked/pinned nodes
|
||||
if (node.flags?.pinned || node.locked) {
|
||||
continue;
|
||||
}
|
||||
node.pos[0] = x;
|
||||
node.pos[1] = y + row.yOffset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugLog(
|
||||
`Placed ${standaloneDisconnected.length} disconnected nodes in left margin (${disconnectedNodes.size - standaloneDisconnected.length} inside groups)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply computed positions to actual ComfyUI nodes
|
||||
* Handles standalone nodes, subgraph I/O nodes, top-level groups, and nested groups recursively
|
||||
* Also restores reroute positions and places disconnected nodes
|
||||
*/
|
||||
export function applyPositions(state: LayoutState): void {
|
||||
const { nodes, groupRepresentatives, allNodes, groups, config, rerouteChains } = state;
|
||||
|
||||
// First: Apply positions to standalone nodes (including I/O nodes)
|
||||
for (const layoutNode of nodes.values()) {
|
||||
if (layoutNode.isGroupRepresentative) {
|
||||
// This is a group - handled below with recursive positioning
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle subgraph I/O nodes (update pos array directly)
|
||||
if (layoutNode.isSubgraphIO && layoutNode.runtimeIONode) {
|
||||
layoutNode.runtimeIONode.pos[0] = layoutNode.x;
|
||||
layoutNode.runtimeIONode.pos[1] = layoutNode.y;
|
||||
|
||||
// Sync allNodes entry for overlap resolution (ioNodeToLGraphNode creates a copy)
|
||||
if (allNodes) {
|
||||
const allNode = allNodes.get(layoutNode.id);
|
||||
if (allNode?.pos) {
|
||||
allNode.pos[0] = layoutNode.x;
|
||||
allNode.pos[1] = layoutNode.y;
|
||||
}
|
||||
}
|
||||
|
||||
debugLog(
|
||||
`Positioned I/O node id=${layoutNode.id} to [${layoutNode.x}, ${layoutNode.y}]`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const comfyNode = layoutNode.node;
|
||||
|
||||
// Skip locked/pinned nodes
|
||||
if (comfyNode.flags?.pinned || comfyNode.locked) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update position
|
||||
if (comfyNode.pos) {
|
||||
comfyNode.pos[0] = layoutNode.x;
|
||||
comfyNode.pos[1] = layoutNode.y;
|
||||
}
|
||||
}
|
||||
|
||||
// Second: Position group contents recursively (top-level groups only)
|
||||
if (groupRepresentatives && allNodes && groups) {
|
||||
// Find top-level groups (no parent)
|
||||
const topLevelGroups = groups.filter((g) => !g.parentGroup);
|
||||
|
||||
for (const topGroup of topLevelGroups) {
|
||||
const repId = groupRepresentatives.get(topGroup);
|
||||
if (repId === undefined) continue;
|
||||
|
||||
const repNode = nodes.get(repId);
|
||||
if (!repNode) continue;
|
||||
|
||||
// Start positioning at the group's content area
|
||||
const startX = repNode.x + config.groupPadding;
|
||||
const startY = repNode.y + config.groupPadding + GROUP_TITLE_HEIGHT;
|
||||
|
||||
positionGroupContents(topGroup, startX, startY, allNodes, config);
|
||||
}
|
||||
}
|
||||
|
||||
// Third: Place disconnected nodes in left margin
|
||||
placeDisconnectedNodes(state);
|
||||
|
||||
// Fourth: Restore reroute positions along their chain edges
|
||||
if (rerouteChains && rerouteChains.length > 0 && allNodes) {
|
||||
// Build positions and sizes maps from actual nodes
|
||||
const positions = new Map<number, { x: number; y: number }>();
|
||||
const sizes = new Map<number, { width: number; height: number }>();
|
||||
|
||||
for (const [id, node] of allNodes) {
|
||||
if (node.pos) {
|
||||
positions.set(id, { x: node.pos[0], y: node.pos[1] });
|
||||
}
|
||||
if (node.size) {
|
||||
sizes.set(id, { width: node.size[0], height: node.size[1] });
|
||||
}
|
||||
}
|
||||
|
||||
// Also include layout node positions (for nodes we just positioned)
|
||||
for (const layoutNode of nodes.values()) {
|
||||
positions.set(layoutNode.id, { x: layoutNode.x, y: layoutNode.y });
|
||||
sizes.set(layoutNode.id, { width: layoutNode.width, height: layoutNode.height });
|
||||
}
|
||||
|
||||
// Restore reroute positions
|
||||
restoreReroutePositions(rerouteChains, positions, sizes);
|
||||
|
||||
// Apply restored positions to actual nodes
|
||||
for (const chain of rerouteChains) {
|
||||
for (const rerouteId of chain.nodes) {
|
||||
const pos = positions.get(rerouteId);
|
||||
const node = allNodes.get(rerouteId);
|
||||
if (pos && node && node.pos) {
|
||||
node.pos[0] = pos.x;
|
||||
node.pos[1] = pos.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugLog(
|
||||
`Restored ${rerouteChains.length} reroute chains`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-layout group contents using proper Sugiyama-style layout for connected members
|
||||
* or bin packing for disconnected members
|
||||
* Returns the final Y position after all contents
|
||||
*/
|
||||
function positionGroupContents(
|
||||
group: LayoutGroup,
|
||||
startX: number,
|
||||
startY: number,
|
||||
allNodes: Map<number, LGraphNode>,
|
||||
config: LayoutConfig
|
||||
): number {
|
||||
// Collect non-locked members
|
||||
const members: LGraphNode[] = [];
|
||||
for (const nodeId of group.memberIds) {
|
||||
const node = allNodes.get(nodeId);
|
||||
if (!node) continue;
|
||||
if (node.flags?.pinned || node.locked) continue;
|
||||
members.push(node);
|
||||
}
|
||||
|
||||
// Handle empty group
|
||||
if (members.length === 0 && group.childGroups.length === 0) {
|
||||
return startY;
|
||||
}
|
||||
|
||||
// Parse layout token from group title
|
||||
const layoutMode = parseLayoutToken(group.group.title);
|
||||
|
||||
debugLog(`layoutGroupContents "${group.group.title}": startX=${startX}, members=${members.length}, childGroups=${group.childGroups.length}, mode=${layoutMode.type}`);
|
||||
|
||||
let finalY = startY;
|
||||
|
||||
// If layout mode is specified, use token-based layout (ignores internal edges)
|
||||
if (layoutMode.type !== "default" && members.length > 0) {
|
||||
const arranged = arrangeByMode(members, layoutMode, startX, startY, config);
|
||||
|
||||
for (const pos of arranged) {
|
||||
const node = allNodes.get(pos.id);
|
||||
if (node?.pos) {
|
||||
node.pos[0] = pos.x;
|
||||
node.pos[1] = pos.y;
|
||||
debugLog(` Member ${node.id} (${node.type}): token layout [${pos.x}, ${pos.y}]`);
|
||||
|
||||
const nh = node.size?.[1] ?? 100;
|
||||
finalY = Math.max(finalY, pos.y + nh);
|
||||
}
|
||||
}
|
||||
} else if (members.length > 0) {
|
||||
// Default behavior: check internal edges for layer-based or bin pack layout
|
||||
const edges = getInternalEdges(group.memberIds, allNodes);
|
||||
|
||||
if (edges.length > 0) {
|
||||
// Has internal connections - use layer-based layout
|
||||
const layers = assignMemberLayers(group.memberIds, edges, allNodes);
|
||||
|
||||
let x = startX;
|
||||
for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) {
|
||||
const layer = layers[layerIdx];
|
||||
if (layer.length === 0) continue;
|
||||
|
||||
// Sort layer by height (tallest first for stable layout)
|
||||
layer.sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100));
|
||||
|
||||
let y = startY;
|
||||
let layerMaxWidth = 0;
|
||||
|
||||
for (const node of layer) {
|
||||
if (node.pos) {
|
||||
node.pos[0] = x;
|
||||
node.pos[1] = y;
|
||||
debugLog(` Member ${node.id} (${node.type}): layer ${layerIdx}, pos [${x}, ${y}]`);
|
||||
}
|
||||
|
||||
layerMaxWidth = Math.max(layerMaxWidth, node.size?.[0] ?? 200);
|
||||
y += (node.size?.[1] ?? 100) + config.verticalGap;
|
||||
}
|
||||
|
||||
finalY = Math.max(finalY, y - config.verticalGap);
|
||||
x += layerMaxWidth + config.horizontalGap;
|
||||
}
|
||||
} else {
|
||||
// No internal connections - use simple vertical stacking with bin pack
|
||||
// Sort by height for consistent layout
|
||||
members.sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100));
|
||||
|
||||
// Build sizes map
|
||||
const sizes = new Map<number, { width: number; height: number }>();
|
||||
for (const node of members) {
|
||||
sizes.set(node.id, {
|
||||
width: node.size?.[0] ?? 200,
|
||||
height: node.size?.[1] ?? 100,
|
||||
});
|
||||
}
|
||||
|
||||
// Pack into rows
|
||||
const nodeIds = members.map((n) => n.id);
|
||||
const rows = packNodesIntoRows(nodeIds, sizes, config);
|
||||
|
||||
let y = startY;
|
||||
for (const row of rows) {
|
||||
let x = startX;
|
||||
for (const nodeId of row.nodes) {
|
||||
const node = allNodes.get(nodeId);
|
||||
if (node?.pos) {
|
||||
node.pos[0] = x;
|
||||
node.pos[1] = y;
|
||||
debugLog(` Member ${node.id} (${node.type}): packed pos [${x}, ${y}]`);
|
||||
x += (node.size?.[0] ?? 200) + config.horizontalGap;
|
||||
}
|
||||
}
|
||||
y += row.height + config.verticalGap;
|
||||
}
|
||||
finalY = y - config.verticalGap;
|
||||
}
|
||||
}
|
||||
|
||||
// Position child groups after members
|
||||
for (const child of group.childGroups) {
|
||||
// Calculate child group's content area
|
||||
const childStartX = startX;
|
||||
const childStartY = finalY + config.verticalGap;
|
||||
|
||||
// First, layout the child group's contents
|
||||
const childEndY = positionGroupContents(child, childStartX, childStartY + GROUP_TITLE_HEIGHT, allNodes, config);
|
||||
|
||||
// Update child group position
|
||||
if (child.group.pos) {
|
||||
child.group.pos[0] = childStartX - config.groupPadding;
|
||||
child.group.pos[1] = childStartY - config.groupPadding;
|
||||
debugLog(` Child group "${child.group.title}": pos [${child.group.pos[0]}, ${child.group.pos[1]}]`);
|
||||
}
|
||||
|
||||
finalY = childEndY + config.groupPadding;
|
||||
}
|
||||
|
||||
return finalY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate vertical bounds of nodes in a layer
|
||||
* Returns actual min/max Y by iterating all nodes, not assuming array order
|
||||
* (Array order is by barycenter, not Y position after bin-packing)
|
||||
*/
|
||||
function getLayerBounds(nodes: LayoutNode[]): { minY: number; maxY: number } | null {
|
||||
if (nodes.length === 0) return null;
|
||||
|
||||
let minY = Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (const node of nodes) {
|
||||
minY = Math.min(minY, node.y);
|
||||
maxY = Math.max(maxY, node.y + node.height);
|
||||
}
|
||||
|
||||
if (minY === Infinity) return null;
|
||||
return { minY, maxY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact vertical layout to reduce gaps
|
||||
* Centers layers against the tallest non-group layer
|
||||
* Groups are excluded from max calculation to prevent Y explosion
|
||||
*/
|
||||
export function compactVertically(state: LayoutState): void {
|
||||
const { layers } = state;
|
||||
|
||||
// Calculate max height from STANDALONE nodes only (exclude groups)
|
||||
// Groups are outliers that can be 4000px+, which would push all other layers down
|
||||
let maxTotalHeight = 0;
|
||||
|
||||
for (const layer of layers) {
|
||||
const standaloneNodes = layer.nodes.filter((n) => !n.isGroupRepresentative);
|
||||
|
||||
if (standaloneNodes.length === 0) {
|
||||
// Layer is all groups, skip from max calculation
|
||||
continue;
|
||||
}
|
||||
|
||||
const bounds = getLayerBounds(standaloneNodes);
|
||||
if (!bounds) continue;
|
||||
|
||||
const layerHeight = bounds.maxY - bounds.minY;
|
||||
maxTotalHeight = Math.max(maxTotalHeight, layerHeight);
|
||||
}
|
||||
|
||||
// If no standalone layers, skip centering entirely
|
||||
if (maxTotalHeight === 0) return;
|
||||
|
||||
// Center each layer against maxTotalHeight
|
||||
for (const layer of layers) {
|
||||
if (layer.nodes.length === 0) continue;
|
||||
|
||||
const bounds = getLayerBounds(layer.nodes);
|
||||
if (!bounds) continue;
|
||||
|
||||
const layerHeight = bounds.maxY - bounds.minY;
|
||||
const offset = (maxTotalHeight - layerHeight) / 2;
|
||||
|
||||
for (const node of layer.nodes) {
|
||||
node.y += offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all entities (groups and standalone nodes) for overlap resolution
|
||||
* Assigns priority: groups=100, connected nodes=50, disconnected=10
|
||||
*/
|
||||
function collectEntities(state: LayoutState): LayoutEntity[] {
|
||||
const entities: LayoutEntity[] = [];
|
||||
const { groups, allNodes, nodeToGroup, disconnectedNodes } = state;
|
||||
|
||||
if (!allNodes) return entities;
|
||||
|
||||
// Add top-level groups (high priority - they anchor)
|
||||
for (const lg of groups ?? []) {
|
||||
if (lg.parentGroup) continue; // Skip nested, handled with parent
|
||||
const x = lg.group.pos?.[0] ?? 0;
|
||||
const y = lg.group.pos?.[1] ?? 0;
|
||||
entities.push({
|
||||
id: `group_${lg.group.title ?? entities.length}`,
|
||||
type: "group",
|
||||
priority: 100,
|
||||
x,
|
||||
y,
|
||||
width: lg.group.size?.[0] ?? 0,
|
||||
height: lg.group.size?.[1] ?? 0,
|
||||
initialX: x,
|
||||
initialY: y,
|
||||
group: lg.group,
|
||||
layoutGroup: lg,
|
||||
});
|
||||
}
|
||||
|
||||
// Add standalone nodes (lower priority)
|
||||
for (const [nodeId, node] of allNodes) {
|
||||
if (nodeToGroup?.has(nodeId)) continue; // Inside group
|
||||
if (node.flags?.pinned || node.locked) continue;
|
||||
|
||||
const isDisconnected = disconnectedNodes?.has(nodeId) ?? false;
|
||||
const x = node.pos?.[0] ?? 0;
|
||||
const y = node.pos?.[1] ?? 0;
|
||||
entities.push({
|
||||
id: `node_${nodeId}`,
|
||||
type: "node",
|
||||
priority: isDisconnected ? 10 : 50,
|
||||
x,
|
||||
y,
|
||||
width: node.size?.[0] ?? 200,
|
||||
height: node.size?.[1] ?? 100,
|
||||
initialX: x,
|
||||
initialY: y,
|
||||
node,
|
||||
});
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scanline overlap resolution for X-axis (push right)
|
||||
* Entities are sorted by X, overlapping entities are pushed right
|
||||
* Higher priority entities anchor, lower priority pushed
|
||||
* Only updates entity.x/y - does NOT modify node.pos (deferred to applyFinalPositions)
|
||||
* Returns true if any entity was moved
|
||||
*/
|
||||
function scanlineResolveX(
|
||||
entities: LayoutEntity[],
|
||||
config: LayoutConfig
|
||||
): boolean {
|
||||
let anyChanged = false;
|
||||
let iterations = 0;
|
||||
const maxIterations = entities.length * 2;
|
||||
|
||||
let changed = true;
|
||||
while (changed && iterations < maxIterations) {
|
||||
changed = false;
|
||||
iterations++;
|
||||
|
||||
// Sort by X position, then priority, then ID (deterministic)
|
||||
entities.sort((a, b) => a.x - b.x || b.priority - a.priority || a.id.localeCompare(b.id));
|
||||
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const anchor = entities[i];
|
||||
|
||||
for (let j = i + 1; j < entities.length; j++) {
|
||||
const pushed = entities[j];
|
||||
|
||||
// Check Y overlap (must overlap on Y axis to need X separation)
|
||||
const yOverlap =
|
||||
anchor.y < pushed.y + pushed.height + config.verticalGap &&
|
||||
pushed.y < anchor.y + anchor.height + config.verticalGap;
|
||||
if (!yOverlap) continue;
|
||||
|
||||
// Check X overlap
|
||||
const requiredX = anchor.x + anchor.width + config.horizontalGap;
|
||||
if (pushed.x < requiredX) {
|
||||
// Overlap! Push entity j to the right (entity only, not node.pos)
|
||||
const dx = requiredX - pushed.x;
|
||||
pushed.x = requiredX;
|
||||
changed = true;
|
||||
anyChanged = true;
|
||||
debugLog(`Scanline X: ${pushed.id} right ${dx.toFixed(0)}px (avoid ${anchor.id})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (iterations > 1) {
|
||||
debugLog(`X-axis scanline: ${iterations} iterations`);
|
||||
}
|
||||
return anyChanged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scanline overlap resolution for Y-axis (push down)
|
||||
* Entities are sorted by Y, overlapping entities are pushed down
|
||||
* Higher priority entities anchor, lower priority pushed
|
||||
* Only updates entity.x/y - does NOT modify node.pos (deferred to applyFinalPositions)
|
||||
* Returns true if any entity was moved
|
||||
*/
|
||||
function scanlineResolveY(
|
||||
entities: LayoutEntity[],
|
||||
config: LayoutConfig
|
||||
): boolean {
|
||||
let anyChanged = false;
|
||||
let iterations = 0;
|
||||
const maxIterations = entities.length * 2;
|
||||
|
||||
let changed = true;
|
||||
while (changed && iterations < maxIterations) {
|
||||
changed = false;
|
||||
iterations++;
|
||||
|
||||
// Sort by Y position, then priority, then ID (deterministic)
|
||||
entities.sort((a, b) => a.y - b.y || b.priority - a.priority || a.id.localeCompare(b.id));
|
||||
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const anchor = entities[i];
|
||||
|
||||
for (let j = i + 1; j < entities.length; j++) {
|
||||
const pushed = entities[j];
|
||||
|
||||
// Check X overlap (must overlap on X axis to need Y separation)
|
||||
const xOverlap =
|
||||
anchor.x < pushed.x + pushed.width + config.horizontalGap &&
|
||||
pushed.x < anchor.x + anchor.width + config.horizontalGap;
|
||||
if (!xOverlap) continue;
|
||||
|
||||
// Check Y overlap
|
||||
const requiredY = anchor.y + anchor.height + config.verticalGap;
|
||||
if (pushed.y < requiredY) {
|
||||
// Overlap! Push entity j down (entity only, not node.pos)
|
||||
const dy = requiredY - pushed.y;
|
||||
pushed.y = requiredY;
|
||||
changed = true;
|
||||
anyChanged = true;
|
||||
debugLog(`Scanline Y: ${pushed.id} down ${dy.toFixed(0)}px (avoid ${anchor.id})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (iterations > 1) {
|
||||
debugLog(`Y-axis scanline: ${iterations} iterations`);
|
||||
}
|
||||
return anyChanged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively shift all member nodes and nested child groups
|
||||
*/
|
||||
function shiftGroupContentsRecursively(
|
||||
layoutGroup: LayoutGroup,
|
||||
dx: number,
|
||||
dy: number,
|
||||
allNodes: Map<number, LGraphNode>
|
||||
): void {
|
||||
// Shift member nodes of this group
|
||||
for (const nodeId of layoutGroup.memberIds) {
|
||||
const node = allNodes.get(nodeId);
|
||||
if (node?.pos) {
|
||||
node.pos[0] += dx;
|
||||
node.pos[1] += dy;
|
||||
}
|
||||
}
|
||||
// Shift child groups and their contents recursively
|
||||
for (const child of layoutGroup.childGroups) {
|
||||
if (child.group.pos) {
|
||||
child.group.pos[0] += dx;
|
||||
child.group.pos[1] += dy;
|
||||
}
|
||||
shiftGroupContentsRecursively(child, dx, dy, allNodes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively shift child groups and their members
|
||||
*/
|
||||
function shiftChildGroups(
|
||||
layoutGroup: LayoutGroup,
|
||||
dx: number,
|
||||
dy: number,
|
||||
allNodes: Map<number, LGraphNode>
|
||||
): void {
|
||||
for (const child of layoutGroup.childGroups) {
|
||||
if (child.group.pos) {
|
||||
child.group.pos[0] += dx;
|
||||
child.group.pos[1] += dy;
|
||||
}
|
||||
shiftGroupContentsRecursively(child, dx, dy, allNodes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply final entity positions to node.pos after scanline resolution
|
||||
* Calculates deltas from initial positions and applies to groups/nodes
|
||||
*/
|
||||
function applyFinalPositions(
|
||||
entities: LayoutEntity[],
|
||||
allNodes: Map<number, LGraphNode>
|
||||
): void {
|
||||
for (const entity of entities) {
|
||||
const dx = entity.x - entity.initialX;
|
||||
const dy = entity.y - entity.initialY;
|
||||
|
||||
// Skip if no movement
|
||||
if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) continue;
|
||||
|
||||
if (entity.type === "group" && entity.group && entity.layoutGroup) {
|
||||
// Set group's final position
|
||||
if (entity.group.pos) {
|
||||
entity.group.pos[0] = entity.x;
|
||||
entity.group.pos[1] = entity.y;
|
||||
}
|
||||
// Shift all member nodes by the same delta
|
||||
for (const nodeId of entity.layoutGroup.memberIds) {
|
||||
const node = allNodes.get(nodeId);
|
||||
if (node?.pos) {
|
||||
node.pos[0] += dx;
|
||||
node.pos[1] += dy;
|
||||
}
|
||||
}
|
||||
// Shift child groups recursively
|
||||
shiftChildGroups(entity.layoutGroup, dx, dy, allNodes);
|
||||
} else if (entity.type === "node" && entity.node?.pos) {
|
||||
// Set node's final position
|
||||
entity.node.pos[0] = entity.x;
|
||||
entity.node.pos[1] = entity.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified overlap resolution using scanline algorithm
|
||||
* Uses priority-based monotonic pushing (right/down only) to prevent oscillation
|
||||
* Groups have highest priority and anchor in place
|
||||
* Idempotent: decouples entity tracking from node.pos writes
|
||||
*/
|
||||
export function resolveAllOverlaps(state: LayoutState): void {
|
||||
const { allNodes, nodes, config } = state;
|
||||
if (!allNodes) return;
|
||||
|
||||
const entities = collectEntities(state);
|
||||
if (entities.length < 2) {
|
||||
debugLog(`Overlap resolution: ${entities.length} entities (skipped)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass 1: X-axis (left-to-right pushing) - updates entity.x only
|
||||
scanlineResolveX(entities, config);
|
||||
|
||||
// Pass 2: Y-axis (top-to-bottom pushing) - updates entity.y only
|
||||
scanlineResolveY(entities, config);
|
||||
|
||||
// Pass 3: Apply final positions to node.pos
|
||||
applyFinalPositions(entities, allNodes);
|
||||
|
||||
// Pass 4: Sync I/O node positions from allNodes back to runtimeIONode
|
||||
// (applyFinalPositions updates allNodes[id].pos, but runtimeIONode.pos needs syncing)
|
||||
if (nodes) {
|
||||
for (const layoutNode of nodes.values()) {
|
||||
if (layoutNode.isSubgraphIO && layoutNode.runtimeIONode) {
|
||||
const allNode = allNodes.get(layoutNode.id);
|
||||
if (allNode?.pos) {
|
||||
layoutNode.runtimeIONode.pos[0] = allNode.pos[0];
|
||||
layoutNode.runtimeIONode.pos[1] = allNode.pos[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugLog(`Overlap resolution: ${entities.length} entities processed`);
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import type { LGraph, LLink, RerouteChain } from "./types";
|
||||
import { debugLog } from "../debug";
|
||||
|
||||
/** Link info for chain detection */
|
||||
interface LinkInfo {
|
||||
linkId: number;
|
||||
originId: number;
|
||||
originSlot: number;
|
||||
targetId: number;
|
||||
targetSlot: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate over links in a graph (handles Map, Record, and Array formats)
|
||||
*/
|
||||
function* iterateLinks(
|
||||
links: Map<number, LLink> | Record<number, LLink> | LLink[]
|
||||
): Generator<LLink> {
|
||||
if (links instanceof Map) {
|
||||
for (const link of links.values()) {
|
||||
if (link) yield link;
|
||||
}
|
||||
} else if (Array.isArray(links)) {
|
||||
for (const link of links) {
|
||||
if (link) yield link;
|
||||
}
|
||||
} else {
|
||||
for (const link of Object.values(links)) {
|
||||
if (link) yield link;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find reroute chains in the graph
|
||||
*
|
||||
* A chain is a sequence of reroute nodes connected linearly:
|
||||
* Source -> Reroute1 -> Reroute2 -> ... -> Target(s)
|
||||
*
|
||||
* Returns chains that can be collapsed into virtual edges
|
||||
*/
|
||||
export function findRerouteChains(
|
||||
graph: LGraph,
|
||||
reroutes: Set<number>
|
||||
): RerouteChain[] {
|
||||
if (!graph.links || reroutes.size === 0) return [];
|
||||
|
||||
// Build link info maps
|
||||
// incomingLinks: target -> [links pointing to it]
|
||||
// outgoingLinks: origin -> [links from it]
|
||||
const incomingLinks = new Map<number, LinkInfo[]>();
|
||||
const outgoingLinks = new Map<number, LinkInfo[]>();
|
||||
|
||||
for (const link of iterateLinks(graph.links)) {
|
||||
if (!link || typeof link.origin_id !== "number" || typeof link.target_id !== "number") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const info: LinkInfo = {
|
||||
linkId: link.id,
|
||||
originId: link.origin_id,
|
||||
originSlot: link.origin_slot,
|
||||
targetId: link.target_id,
|
||||
targetSlot: link.target_slot,
|
||||
};
|
||||
|
||||
if (!incomingLinks.has(link.target_id)) {
|
||||
incomingLinks.set(link.target_id, []);
|
||||
}
|
||||
incomingLinks.get(link.target_id)!.push(info);
|
||||
|
||||
if (!outgoingLinks.has(link.origin_id)) {
|
||||
outgoingLinks.set(link.origin_id, []);
|
||||
}
|
||||
outgoingLinks.get(link.origin_id)!.push(info);
|
||||
}
|
||||
|
||||
// Find chain starts: reroutes that are fed by a non-reroute
|
||||
const visitedReroutes = new Set<number>();
|
||||
const chains: RerouteChain[] = [];
|
||||
|
||||
for (const rerouteId of reroutes) {
|
||||
if (visitedReroutes.has(rerouteId)) continue;
|
||||
|
||||
const incoming = incomingLinks.get(rerouteId) ?? [];
|
||||
|
||||
// Find non-reroute sources feeding this reroute
|
||||
for (const link of incoming) {
|
||||
if (reroutes.has(link.originId)) {
|
||||
// Source is also a reroute, this isn't a chain start
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found a chain start: non-reroute -> reroute
|
||||
const chain = traceRerouteChain(
|
||||
rerouteId,
|
||||
link.originId,
|
||||
link.originSlot,
|
||||
reroutes,
|
||||
outgoingLinks,
|
||||
visitedReroutes
|
||||
);
|
||||
|
||||
if (chain.nodes.length > 0) {
|
||||
chains.push(chain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugLog(`Found ${chains.length} reroute chains`);
|
||||
|
||||
return chains;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace a reroute chain from start to end
|
||||
*/
|
||||
function traceRerouteChain(
|
||||
startRerouteId: number,
|
||||
sourceNode: number,
|
||||
sourceSlot: number,
|
||||
reroutes: Set<number>,
|
||||
outgoingLinks: Map<number, LinkInfo[]>,
|
||||
visitedReroutes: Set<number>
|
||||
): RerouteChain {
|
||||
const chainNodes: number[] = [];
|
||||
const targets: Array<{ node: number; slot: number }> = [];
|
||||
|
||||
let currentId = startRerouteId;
|
||||
|
||||
while (reroutes.has(currentId) && !visitedReroutes.has(currentId)) {
|
||||
visitedReroutes.add(currentId);
|
||||
chainNodes.push(currentId);
|
||||
|
||||
const outgoing = outgoingLinks.get(currentId) ?? [];
|
||||
|
||||
if (outgoing.length === 0) {
|
||||
// Dead end reroute (no outgoing connections)
|
||||
break;
|
||||
}
|
||||
|
||||
// Check where the reroute goes
|
||||
let nextReroute: number | null = null;
|
||||
|
||||
for (const link of outgoing) {
|
||||
if (reroutes.has(link.targetId) && !visitedReroutes.has(link.targetId)) {
|
||||
// Continues to another reroute
|
||||
nextReroute = link.targetId;
|
||||
} else if (!reroutes.has(link.targetId)) {
|
||||
// Goes to a non-reroute target
|
||||
targets.push({
|
||||
node: link.targetId,
|
||||
slot: link.targetSlot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (nextReroute !== null) {
|
||||
currentId = nextReroute;
|
||||
} else {
|
||||
// Chain ends here
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: chainNodes,
|
||||
sourceNode,
|
||||
sourceSlot,
|
||||
targets,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create virtual edges that bypass reroute chains
|
||||
*
|
||||
* For layout purposes, we replace:
|
||||
* Source -> Reroute1 -> Reroute2 -> Target
|
||||
* With:
|
||||
* Source -> Target (virtual edge)
|
||||
*
|
||||
* @returns Map of source node to target nodes (for DAG building)
|
||||
*/
|
||||
export function getVirtualEdges(
|
||||
chains: RerouteChain[]
|
||||
): Map<number, Set<number>> {
|
||||
const virtualEdges = new Map<number, Set<number>>();
|
||||
|
||||
for (const chain of chains) {
|
||||
if (!virtualEdges.has(chain.sourceNode)) {
|
||||
virtualEdges.set(chain.sourceNode, new Set());
|
||||
}
|
||||
|
||||
for (const target of chain.targets) {
|
||||
virtualEdges.get(chain.sourceNode)!.add(target.node);
|
||||
}
|
||||
}
|
||||
|
||||
return virtualEdges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Position reroute nodes along their chain edges after layout
|
||||
*
|
||||
* Distributes reroutes evenly between source and target positions
|
||||
*/
|
||||
export function restoreReroutePositions(
|
||||
chains: RerouteChain[],
|
||||
positions: Map<number, { x: number; y: number }>,
|
||||
sizes: Map<number, { width: number; height: number }>
|
||||
): void {
|
||||
for (const chain of chains) {
|
||||
if (chain.nodes.length === 0 || chain.targets.length === 0) continue;
|
||||
|
||||
const sourcePos = positions.get(chain.sourceNode);
|
||||
const sourceSize = sizes.get(chain.sourceNode);
|
||||
if (!sourcePos || !sourceSize) continue;
|
||||
|
||||
// Calculate average target position
|
||||
let avgTargetX = 0;
|
||||
let avgTargetY = 0;
|
||||
let validTargets = 0;
|
||||
|
||||
for (const target of chain.targets) {
|
||||
const targetPos = positions.get(target.node);
|
||||
if (targetPos) {
|
||||
avgTargetX += targetPos.x;
|
||||
avgTargetY += targetPos.y;
|
||||
validTargets++;
|
||||
}
|
||||
}
|
||||
|
||||
if (validTargets === 0) continue;
|
||||
|
||||
avgTargetX /= validTargets;
|
||||
avgTargetY /= validTargets;
|
||||
|
||||
// Source output position (right side of node)
|
||||
const startX = sourcePos.x + sourceSize.width;
|
||||
const startY = sourcePos.y + sourceSize.height / 2;
|
||||
|
||||
// Target input position
|
||||
const endX = avgTargetX;
|
||||
const endY = avgTargetY;
|
||||
|
||||
// Distribute reroutes evenly along the path
|
||||
const count = chain.nodes.length;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = (i + 1) / (count + 1);
|
||||
const rerouteId = chain.nodes[i];
|
||||
|
||||
// Linear interpolation
|
||||
const x = startX + (endX - startX) * t;
|
||||
const y = startY + (endY - startY) * t;
|
||||
|
||||
// Center the reroute (typical reroute size: 90x26)
|
||||
const rerouteSize = sizes.get(rerouteId) ?? { width: 90, height: 26 };
|
||||
positions.set(rerouteId, {
|
||||
x: x - rerouteSize.width / 2,
|
||||
y: y - rerouteSize.height / 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all reroute node IDs from chains (for filtering from main layout)
|
||||
*/
|
||||
export function getRerouteNodeIds(chains: RerouteChain[]): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
for (const chain of chains) {
|
||||
for (const nodeId of chain.nodes) {
|
||||
ids.add(nodeId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@@ -1,739 +0,0 @@
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LGraphGroup,
|
||||
LayoutConfig,
|
||||
SelectedGroupLayoutResult,
|
||||
} from "./types";
|
||||
import { DEFAULT_CONFIG } from "./types";
|
||||
import { getInternalEdges, assignMemberLayers } from "./layout-utils";
|
||||
import { packNodesIntoRows } from "./bin-pack";
|
||||
import { debugLog } from "../debug";
|
||||
import { parseLayoutToken, arrangeByMode } from "./title-tokens";
|
||||
|
||||
/** Title bar height for groups */
|
||||
const GROUP_TITLE_HEIGHT = 50;
|
||||
|
||||
/**
|
||||
* Update group bounds using multiple methods for compatibility
|
||||
* Handles pos/size arrays, _pos/_size internal properties,
|
||||
* _bounding Rectangle, and bounding array formats
|
||||
*/
|
||||
function updateGroupBounds(
|
||||
group: LGraphGroup,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number
|
||||
): void {
|
||||
// Method 1: Direct pos/size array mutation
|
||||
if (Array.isArray(group.pos)) {
|
||||
group.pos[0] = x;
|
||||
group.pos[1] = y;
|
||||
}
|
||||
if (Array.isArray(group.size)) {
|
||||
group.size[0] = width;
|
||||
group.size[1] = height;
|
||||
}
|
||||
|
||||
// Method 2: Try _pos/_size internal properties
|
||||
const g = group as LGraphGroup & {
|
||||
_pos?: [number, number];
|
||||
_size?: [number, number];
|
||||
};
|
||||
if (Array.isArray(g._pos)) {
|
||||
g._pos[0] = x;
|
||||
g._pos[1] = y;
|
||||
}
|
||||
if (Array.isArray(g._size)) {
|
||||
g._size[0] = width;
|
||||
g._size[1] = height;
|
||||
}
|
||||
|
||||
// Method 3: Update _bounding Rectangle if present
|
||||
if (group._bounding) {
|
||||
const b = group._bounding;
|
||||
b.x = x;
|
||||
b.y = y;
|
||||
b.width = width;
|
||||
b.height = height;
|
||||
}
|
||||
|
||||
// Method 4: Try bounding array (older format / JSON serialization)
|
||||
const gAny = group as LGraphGroup & { bounding?: number[] };
|
||||
if (Array.isArray(gAny.bounding)) {
|
||||
gAny.bounding[0] = x;
|
||||
gAny.bounding[1] = y;
|
||||
gAny.bounding[2] = width;
|
||||
gAny.bounding[3] = height;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate group position by delta, handling all position formats
|
||||
* Similar to updateGroupBounds but for relative position changes
|
||||
*/
|
||||
function translateGroupPosition(
|
||||
group: LGraphGroup,
|
||||
deltaX: number,
|
||||
deltaY: number
|
||||
): void {
|
||||
// Method 1: Direct pos array mutation
|
||||
if (Array.isArray(group.pos)) {
|
||||
group.pos[0] += deltaX;
|
||||
group.pos[1] += deltaY;
|
||||
}
|
||||
|
||||
// Method 2: Try _pos internal property
|
||||
const g = group as LGraphGroup & { _pos?: [number, number] };
|
||||
if (Array.isArray(g._pos)) {
|
||||
g._pos[0] += deltaX;
|
||||
g._pos[1] += deltaY;
|
||||
}
|
||||
|
||||
// Method 3: Update _bounding Rectangle if present
|
||||
if (group._bounding) {
|
||||
group._bounding.x += deltaX;
|
||||
group._bounding.y += deltaY;
|
||||
}
|
||||
|
||||
// Method 4: Try bounding array (older format / JSON serialization)
|
||||
const gAny = group as LGraphGroup & { bounding?: number[] };
|
||||
if (Array.isArray(gAny.bounding)) {
|
||||
gAny.bounding[0] += deltaX;
|
||||
gAny.bounding[1] += deltaY;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a group to fit its member nodes
|
||||
*/
|
||||
function resizeGroupToFitMembers(
|
||||
group: LGraphGroup,
|
||||
memberIds: Set<number>,
|
||||
allNodes: Map<number, LGraphNode>,
|
||||
config: LayoutConfig
|
||||
): void {
|
||||
if (memberIds.size === 0) return;
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (const nodeId of memberIds) {
|
||||
const node = allNodes.get(nodeId);
|
||||
if (!node?.pos) continue;
|
||||
const nw = node.size?.[0] ?? 200;
|
||||
const nh = node.size?.[1] ?? 100;
|
||||
minX = Math.min(minX, node.pos[0]);
|
||||
minY = Math.min(minY, node.pos[1]);
|
||||
maxX = Math.max(maxX, node.pos[0] + nw);
|
||||
maxY = Math.max(maxY, node.pos[1] + nh);
|
||||
}
|
||||
|
||||
if (minX === Infinity) return;
|
||||
|
||||
// Update group position and size with padding (handles all formats)
|
||||
const newX = minX - config.groupPadding;
|
||||
const newY = minY - config.groupPadding - GROUP_TITLE_HEIGHT;
|
||||
const newW = maxX - minX + config.groupPadding * 2;
|
||||
const newH = maxY - minY + config.groupPadding * 2 + GROUP_TITLE_HEIGHT;
|
||||
updateGroupBounds(group, newX, newY, newW, newH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a group and its tracked members by a delta
|
||||
*/
|
||||
function translateGroupWithMembers(
|
||||
group: LGraphGroup,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
memberIds: Set<number>,
|
||||
allNodes: Map<number, LGraphNode>,
|
||||
allGroups: LGraphGroup[]
|
||||
): void {
|
||||
// Move the group itself (handles all formats: pos, _pos, _bounding, bounding)
|
||||
translateGroupPosition(group, deltaX, deltaY);
|
||||
|
||||
// Translate tracked member nodes
|
||||
for (const nodeId of memberIds) {
|
||||
const node = allNodes.get(nodeId);
|
||||
if (node?.pos) {
|
||||
node.pos[0] += deltaX;
|
||||
node.pos[1] += deltaY;
|
||||
}
|
||||
}
|
||||
|
||||
// Find and translate nested groups inside this group
|
||||
for (const nestedGroup of allGroups) {
|
||||
if (nestedGroup === group) continue;
|
||||
if (groupContainsGroup(group, nestedGroup)) {
|
||||
translateGroupPosition(nestedGroup, deltaX, deltaY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if outer group's bounds fully contain inner group's bounds
|
||||
*/
|
||||
function groupContainsGroup(outer: LGraphGroup, inner: LGraphGroup): boolean {
|
||||
const ox = outer.pos[0];
|
||||
const oy = outer.pos[1];
|
||||
const ow = outer.size[0];
|
||||
const oh = outer.size[1];
|
||||
|
||||
const ix = inner.pos[0];
|
||||
const iy = inner.pos[1];
|
||||
const iw = inner.size[0];
|
||||
const ih = inner.size[1];
|
||||
|
||||
return ix >= ox && iy >= oy && ix + iw <= ox + ow && iy + ih <= oy + oh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node's origin is inside a group's content area
|
||||
*/
|
||||
function nodeInsideGroup(node: LGraphNode, group: LGraphGroup): boolean {
|
||||
const gx = group.pos[0];
|
||||
const gy = group.pos[1] + GROUP_TITLE_HEIGHT;
|
||||
const gw = group.size[0];
|
||||
const gh = group.size[1] - GROUP_TITLE_HEIGHT;
|
||||
|
||||
const nx = node.pos?.[0] ?? 0;
|
||||
const ny = node.pos?.[1] ?? 0;
|
||||
|
||||
return nx >= gx && nx < gx + gw && ny >= gy && ny < gy + gh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all nested child groups within a parent group
|
||||
*/
|
||||
function collectNestedGroups(
|
||||
parent: LGraphGroup,
|
||||
allGroups: LGraphGroup[]
|
||||
): LGraphGroup[] {
|
||||
const nested: LGraphGroup[] = [];
|
||||
const queue = [parent];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
for (const group of allGroups) {
|
||||
if (group === current) continue;
|
||||
if (groupContainsGroup(current, group) && !nested.includes(group)) {
|
||||
nested.push(group);
|
||||
queue.push(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nested;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all member nodes within a group (excluding nodes in nested child groups)
|
||||
*/
|
||||
function collectDirectMembers(
|
||||
group: LGraphGroup,
|
||||
allNodes: Map<number, LGraphNode>,
|
||||
nestedGroups: LGraphGroup[]
|
||||
): Set<number> {
|
||||
const members = new Set<number>();
|
||||
|
||||
for (const [nodeId, node] of allNodes) {
|
||||
if (!nodeInsideGroup(node, group)) continue;
|
||||
|
||||
// Check if node is inside a nested group (exclude it from direct members)
|
||||
let inNestedGroup = false;
|
||||
for (const nested of nestedGroups) {
|
||||
if (nodeInsideGroup(node, nested)) {
|
||||
inNestedGroup = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!inNestedGroup) {
|
||||
members.add(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
return members;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout contents of a single group and resize it to fit
|
||||
* Returns the bounding box of the group's contents
|
||||
*/
|
||||
function layoutGroupContents(
|
||||
group: LGraphGroup,
|
||||
memberIds: Set<number>,
|
||||
nestedGroups: LGraphGroup[],
|
||||
allNodes: Map<number, LGraphNode>,
|
||||
config: LayoutConfig,
|
||||
processedGroups: Set<LGraphGroup>
|
||||
): { minX: number; minY: number; maxX: number; maxY: number } | null {
|
||||
// Skip if already processed (handles nested groups selected individually)
|
||||
if (processedGroups.has(group)) {
|
||||
return null;
|
||||
}
|
||||
processedGroups.add(group);
|
||||
|
||||
// Collect non-locked members
|
||||
const members: LGraphNode[] = [];
|
||||
for (const nodeId of memberIds) {
|
||||
const node = allNodes.get(nodeId);
|
||||
if (!node) continue;
|
||||
if (node.flags?.pinned || node.locked) continue;
|
||||
members.push(node);
|
||||
}
|
||||
|
||||
// Handle empty group with no nested groups
|
||||
if (members.length === 0 && nestedGroups.length === 0) {
|
||||
debugLog(`Skipping empty group "${group.title}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse layout token from group title
|
||||
const layoutMode = parseLayoutToken(group.title);
|
||||
|
||||
debugLog(
|
||||
`layoutGroupContents "${group.title}": members=${members.length}, nestedGroups=${nestedGroups.length}, mode=${layoutMode.type}`
|
||||
);
|
||||
|
||||
// Get the group's current content area start position
|
||||
const startX = group.pos[0] + config.groupPadding;
|
||||
const startY = group.pos[1] + config.groupPadding + GROUP_TITLE_HEIGHT;
|
||||
|
||||
// Track bounds of all positioned content
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
let contentEndY = startY;
|
||||
|
||||
// If layout mode is specified, use token-based layout (ignores internal edges)
|
||||
// This handles both direct member nodes AND nested groups as top-level items
|
||||
if (layoutMode.type !== "default" && (members.length > 0 || nestedGroups.length > 0)) {
|
||||
// First, recursively process nested groups (bottom-up) so their sizes are final
|
||||
// Sort by containment depth (deepest first)
|
||||
const sortedNested = [...nestedGroups].sort((a, b) => {
|
||||
let aDepth = 0,
|
||||
bDepth = 0;
|
||||
for (const other of nestedGroups) {
|
||||
if (other !== a && groupContainsGroup(other, a)) aDepth++;
|
||||
if (other !== b && groupContainsGroup(other, b)) bDepth++;
|
||||
}
|
||||
return bDepth - aDepth; // Deepest first
|
||||
});
|
||||
|
||||
// Track member IDs for each nested group (needed for translation later)
|
||||
const nestedGroupMembers = new Map<number, Set<number>>();
|
||||
|
||||
for (const nested of sortedNested) {
|
||||
if (processedGroups.has(nested)) continue;
|
||||
|
||||
const nestedMemberIds = new Set<number>();
|
||||
for (const [nodeId, node] of allNodes) {
|
||||
if (nodeInsideGroup(node, nested)) {
|
||||
// Check not in deeper nested group
|
||||
let inDeeperGroup = false;
|
||||
for (const other of sortedNested) {
|
||||
if (other !== nested && groupContainsGroup(nested, other) && nodeInsideGroup(node, other)) {
|
||||
inDeeperGroup = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inDeeperGroup) {
|
||||
nestedMemberIds.add(nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
nestedGroupMembers.set(nested.id, nestedMemberIds);
|
||||
|
||||
const deeperNested = sortedNested.filter(
|
||||
(g) => g !== nested && groupContainsGroup(nested, g)
|
||||
);
|
||||
|
||||
layoutGroupContents(
|
||||
nested,
|
||||
nestedMemberIds,
|
||||
deeperNested,
|
||||
allNodes,
|
||||
config,
|
||||
processedGroups
|
||||
);
|
||||
|
||||
// Resize nested group to fit its (possibly rearranged) contents
|
||||
resizeGroupToFitMembers(nested, nestedMemberIds, allNodes, config);
|
||||
}
|
||||
|
||||
// Now create combined items list: direct member nodes + direct nested groups
|
||||
// Direct nested groups are those not contained by other nested groups
|
||||
const directNestedGroups = nestedGroups.filter((g) => {
|
||||
for (const other of nestedGroups) {
|
||||
if (other !== g && groupContainsGroup(other, g)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Create fake LGraphNode-like objects for nested groups so we can use arrangeByMode
|
||||
const groupItems: LGraphNode[] = directNestedGroups.map((g) => ({
|
||||
id: -g.id, // Negative ID to distinguish from real nodes
|
||||
type: "__group__",
|
||||
title: g.title,
|
||||
pos: [g.pos[0], g.pos[1]] as [number, number],
|
||||
size: [g.size[0], g.size[1]] as [number, number],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
}));
|
||||
|
||||
// Combine members and group items
|
||||
const allItems = [...members, ...groupItems];
|
||||
|
||||
if (allItems.length > 0) {
|
||||
const arranged = arrangeByMode(allItems, layoutMode, startX, startY, config);
|
||||
|
||||
for (const pos of arranged) {
|
||||
if (pos.id < 0) {
|
||||
// This is a nested group (negative ID)
|
||||
const groupId = -pos.id;
|
||||
const nestedGroup = directNestedGroups.find((g) => g.id === groupId);
|
||||
if (nestedGroup) {
|
||||
const oldX = nestedGroup.pos[0];
|
||||
const oldY = nestedGroup.pos[1];
|
||||
const deltaX = pos.x - oldX;
|
||||
const deltaY = pos.y - oldY;
|
||||
|
||||
// Use tracked members for accurate translation after token layout
|
||||
const trackedMembers = nestedGroupMembers.get(groupId) ?? new Set();
|
||||
translateGroupWithMembers(nestedGroup, deltaX, deltaY, trackedMembers, allNodes, nestedGroups);
|
||||
debugLog(` Nested group ${groupId} (${nestedGroup.title}): token layout [${pos.x}, ${pos.y}]`);
|
||||
|
||||
const gw = nestedGroup.size[0];
|
||||
const gh = nestedGroup.size[1];
|
||||
|
||||
minX = Math.min(minX, pos.x);
|
||||
minY = Math.min(minY, pos.y);
|
||||
maxX = Math.max(maxX, pos.x + gw);
|
||||
maxY = Math.max(maxY, pos.y + gh);
|
||||
contentEndY = Math.max(contentEndY, pos.y + gh);
|
||||
}
|
||||
} else {
|
||||
// This is a regular node
|
||||
const node = allNodes.get(pos.id);
|
||||
if (node?.pos) {
|
||||
node.pos[0] = pos.x;
|
||||
node.pos[1] = pos.y;
|
||||
debugLog(` Member ${node.id} (${node.type}): token layout [${pos.x}, ${pos.y}]`);
|
||||
|
||||
const nw = node.size?.[0] ?? 200;
|
||||
const nh = node.size?.[1] ?? 100;
|
||||
|
||||
minX = Math.min(minX, pos.x);
|
||||
minY = Math.min(minY, pos.y);
|
||||
maxX = Math.max(maxX, pos.x + nw);
|
||||
maxY = Math.max(maxY, pos.y + nh);
|
||||
contentEndY = Math.max(contentEndY, pos.y + nh);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (members.length > 0) {
|
||||
// Default behavior: check internal edges for layer-based or vertical stack layout
|
||||
const edges = getInternalEdges(memberIds, allNodes);
|
||||
|
||||
if (edges.length > 0) {
|
||||
// Has internal connections - use layer-based layout
|
||||
const layers = assignMemberLayers(memberIds, edges, allNodes);
|
||||
|
||||
let x = startX;
|
||||
for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) {
|
||||
const layer = layers[layerIdx];
|
||||
if (layer.length === 0) continue;
|
||||
|
||||
// Sort layer by height (tallest first for stable layout)
|
||||
layer.sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100));
|
||||
|
||||
let y = startY;
|
||||
let layerMaxWidth = 0;
|
||||
|
||||
for (const node of layer) {
|
||||
if (node.pos) {
|
||||
node.pos[0] = x;
|
||||
node.pos[1] = y;
|
||||
debugLog(
|
||||
` Member ${node.id} (${node.type}): layer ${layerIdx}, pos [${x}, ${y}]`
|
||||
);
|
||||
}
|
||||
|
||||
const nw = node.size?.[0] ?? 200;
|
||||
const nh = node.size?.[1] ?? 100;
|
||||
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x + nw);
|
||||
maxY = Math.max(maxY, y + nh);
|
||||
|
||||
layerMaxWidth = Math.max(layerMaxWidth, nw);
|
||||
y += nh + config.verticalGap;
|
||||
}
|
||||
|
||||
contentEndY = Math.max(contentEndY, y - config.verticalGap);
|
||||
x += layerMaxWidth + config.horizontalGap;
|
||||
}
|
||||
} else {
|
||||
// No internal connections - use vertical stacking
|
||||
members.sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100));
|
||||
|
||||
// Build sizes map
|
||||
const sizes = new Map<number, { width: number; height: number }>();
|
||||
for (const node of members) {
|
||||
sizes.set(node.id, {
|
||||
width: node.size?.[0] ?? 200,
|
||||
height: node.size?.[1] ?? 100,
|
||||
});
|
||||
}
|
||||
|
||||
// Pack into rows (vertical stack with maxColumns=1)
|
||||
const nodeIds = members.map((n) => n.id);
|
||||
const rows = packNodesIntoRows(nodeIds, sizes, {
|
||||
...config,
|
||||
maxColumns: 1,
|
||||
});
|
||||
|
||||
for (const row of rows) {
|
||||
const x = startX;
|
||||
const y = startY + row.yOffset;
|
||||
|
||||
for (const nodeId of row.nodes) {
|
||||
const node = allNodes.get(nodeId);
|
||||
if (node?.pos) {
|
||||
node.pos[0] = x;
|
||||
node.pos[1] = y;
|
||||
debugLog(` Member ${node.id} (${node.type}): stacked pos [${x}, ${y}]`);
|
||||
|
||||
const nw = node.size?.[0] ?? 200;
|
||||
const nh = node.size?.[1] ?? 100;
|
||||
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x + nw);
|
||||
maxY = Math.max(maxY, y + nh);
|
||||
}
|
||||
}
|
||||
contentEndY = Math.max(contentEndY, startY + row.yOffset + row.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process nested groups (bottom-up: deepest first)
|
||||
// In default mode, reposition direct nested groups below member content
|
||||
const sortedNested = [...nestedGroups].sort((a, b) => {
|
||||
// Count how many other nested groups contain each
|
||||
let depthA = 0;
|
||||
let depthB = 0;
|
||||
for (const other of nestedGroups) {
|
||||
if (other !== a && groupContainsGroup(other, a)) depthA++;
|
||||
if (other !== b && groupContainsGroup(other, b)) depthB++;
|
||||
}
|
||||
return depthB - depthA; // Deepest first
|
||||
});
|
||||
|
||||
// Identify direct nested groups (not contained by other nested groups)
|
||||
const directNestedGroups = sortedNested.filter((g) => {
|
||||
for (const other of nestedGroups) {
|
||||
if (other !== g && groupContainsGroup(other, g)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort direct groups by original position for stable ordering
|
||||
directNestedGroups.sort((a, b) => {
|
||||
const yDiff = a.pos[1] - b.pos[1];
|
||||
return yDiff !== 0 ? yDiff : a.pos[0] - b.pos[0];
|
||||
});
|
||||
|
||||
for (const nestedGroup of directNestedGroups) {
|
||||
// Skip if already processed (by token mode)
|
||||
if (processedGroups.has(nestedGroup)) {
|
||||
// Still include bounds
|
||||
minX = Math.min(minX, nestedGroup.pos[0]);
|
||||
minY = Math.min(minY, nestedGroup.pos[1]);
|
||||
maxX = Math.max(maxX, nestedGroup.pos[0] + nestedGroup.size[0]);
|
||||
maxY = Math.max(maxY, nestedGroup.pos[1] + nestedGroup.size[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get nested group's members and children
|
||||
const nestedChildren = collectNestedGroups(nestedGroup, nestedGroups);
|
||||
const nestedMembers = collectDirectMembers(nestedGroup, allNodes, nestedChildren);
|
||||
|
||||
// In default mode, reposition nested group below current content
|
||||
if (layoutMode.type === "default") {
|
||||
// Position nested group's left edge at startX (same as members) for consistent alignment
|
||||
// This prevents the group from shifting leftward on each layout run
|
||||
const targetX = startX;
|
||||
const targetY = contentEndY + config.verticalGap;
|
||||
const deltaX = targetX - nestedGroup.pos[0];
|
||||
const deltaY = targetY - nestedGroup.pos[1];
|
||||
|
||||
if (deltaX !== 0 || deltaY !== 0) {
|
||||
translateGroupWithMembers(nestedGroup, deltaX, deltaY, nestedMembers, allNodes, nestedGroups);
|
||||
debugLog(` Repositioned nested group "${nestedGroup.title}" to [${targetX}, ${targetY}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively layout nested group contents
|
||||
layoutGroupContents(
|
||||
nestedGroup,
|
||||
nestedMembers,
|
||||
nestedChildren,
|
||||
allNodes,
|
||||
config,
|
||||
processedGroups
|
||||
);
|
||||
|
||||
// Resize nested group to fit its organized contents
|
||||
resizeGroupToFitMembers(nestedGroup, nestedMembers, allNodes, config);
|
||||
|
||||
// Update contentEndY for next nested group
|
||||
contentEndY = Math.max(contentEndY, nestedGroup.pos[1] + nestedGroup.size[1]);
|
||||
|
||||
// Update bounds to include nested group
|
||||
minX = Math.min(minX, nestedGroup.pos[0]);
|
||||
minY = Math.min(minY, nestedGroup.pos[1]);
|
||||
maxX = Math.max(maxX, nestedGroup.pos[0] + nestedGroup.size[0]);
|
||||
maxY = Math.max(maxY, nestedGroup.pos[1] + nestedGroup.size[1]);
|
||||
}
|
||||
|
||||
// Resize group to fit contents
|
||||
if (minX !== Infinity) {
|
||||
const newX = minX - config.groupPadding;
|
||||
const newY = minY - config.groupPadding - GROUP_TITLE_HEIGHT;
|
||||
const newWidth = maxX - minX + config.groupPadding * 2;
|
||||
const newHeight = maxY - minY + config.groupPadding * 2 + GROUP_TITLE_HEIGHT;
|
||||
|
||||
debugLog(
|
||||
`Resizing group "${group.title}" to [${newX}, ${newY}, ${newWidth}, ${newHeight}]`
|
||||
);
|
||||
|
||||
updateGroupBounds(group, newX, newY, newWidth, newHeight);
|
||||
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout only the contents of selected groups
|
||||
* Ignores external connections - uses only internal edges
|
||||
* Auto-includes nested groups when parent is selected
|
||||
*
|
||||
* @param graph - The full graph (for node/link access)
|
||||
* @param selectedGroupIds - Set of group IDs to organize
|
||||
* @param config - Layout configuration
|
||||
*/
|
||||
export function layoutSelectedGroups(
|
||||
graph: LGraph,
|
||||
selectedGroupIds: Set<number>,
|
||||
config?: Partial<LayoutConfig>
|
||||
): SelectedGroupLayoutResult {
|
||||
const fullConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
const startTime = performance.now();
|
||||
|
||||
// Build allNodes map
|
||||
const allNodes = new Map<number, LGraphNode>();
|
||||
for (const node of graph._nodes ?? []) {
|
||||
if (!node || node.id === undefined) continue;
|
||||
allNodes.set(node.id, node);
|
||||
}
|
||||
|
||||
// Get all groups
|
||||
const allGroups = graph._groups ?? [];
|
||||
|
||||
// Find selected groups
|
||||
const selectedGroups: LGraphGroup[] = [];
|
||||
for (const group of allGroups) {
|
||||
if (selectedGroupIds.has(group.id)) {
|
||||
selectedGroups.push(group);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedGroups.length === 0) {
|
||||
debugLog("No selected groups found");
|
||||
return {
|
||||
nodeCount: 0,
|
||||
groupCount: 0,
|
||||
executionMs: performance.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
// Filter to only top-level selected groups
|
||||
// (nested groups selected individually will be handled, but if both parent and child
|
||||
// are selected, we only process from the parent)
|
||||
const topLevelSelected: LGraphGroup[] = [];
|
||||
for (const group of selectedGroups) {
|
||||
let hasSelectedParent = false;
|
||||
for (const other of selectedGroups) {
|
||||
if (other !== group && groupContainsGroup(other, group)) {
|
||||
hasSelectedParent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasSelectedParent) {
|
||||
topLevelSelected.push(group);
|
||||
}
|
||||
}
|
||||
|
||||
// Track processed groups to avoid double-processing
|
||||
const processedGroups = new Set<LGraphGroup>();
|
||||
let totalNodes = 0;
|
||||
|
||||
// Process each top-level selected group
|
||||
for (const group of topLevelSelected) {
|
||||
// Collect nested groups within this group
|
||||
const nestedGroups = collectNestedGroups(group, allGroups);
|
||||
|
||||
// Collect direct members (excluding those in nested groups)
|
||||
const memberIds = collectDirectMembers(group, allNodes, nestedGroups);
|
||||
|
||||
debugLog(
|
||||
`Processing group "${group.title}": ${memberIds.size} direct members, ${nestedGroups.length} nested groups`
|
||||
);
|
||||
|
||||
// Layout group contents
|
||||
layoutGroupContents(
|
||||
group,
|
||||
memberIds,
|
||||
nestedGroups,
|
||||
allNodes,
|
||||
fullConfig,
|
||||
processedGroups
|
||||
);
|
||||
|
||||
totalNodes += memberIds.size;
|
||||
// Count nested group members too
|
||||
for (const nested of nestedGroups) {
|
||||
const nestedChildren = collectNestedGroups(nested, nestedGroups);
|
||||
const nestedMembers = collectDirectMembers(nested, allNodes, nestedChildren);
|
||||
totalNodes += nestedMembers.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger canvas redraw
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
|
||||
const result: SelectedGroupLayoutResult = {
|
||||
nodeCount: totalNodes,
|
||||
groupCount: processedGroups.size,
|
||||
executionMs: performance.now() - startTime,
|
||||
};
|
||||
|
||||
debugLog(
|
||||
`layoutSelectedGroups complete: ${result.nodeCount} nodes, ${result.groupCount} groups, ${result.executionMs.toFixed(1)}ms`
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
import type { LGraphNode, LayoutConfig } from "./types";
|
||||
|
||||
/**
|
||||
* Layout mode parsed from group title tokens
|
||||
*/
|
||||
export type LayoutMode =
|
||||
| { type: "default" }
|
||||
| { type: "horizontal" }
|
||||
| { type: "vertical" }
|
||||
| { type: "rows"; count: number }
|
||||
| { type: "columns"; count: number };
|
||||
|
||||
/**
|
||||
* Parse layout token from group title (case-insensitive)
|
||||
* Supports: [HORIZONTAL], [VERTICAL], [1-9ROW], [1-9COL]
|
||||
*/
|
||||
export function parseLayoutToken(title: string): LayoutMode {
|
||||
const upper = title.toUpperCase();
|
||||
|
||||
// Check for [HORIZONTAL]
|
||||
if (upper.includes("[HORIZONTAL]")) {
|
||||
return { type: "horizontal" };
|
||||
}
|
||||
|
||||
// Check for [VERTICAL]
|
||||
if (upper.includes("[VERTICAL]")) {
|
||||
return { type: "vertical" };
|
||||
}
|
||||
|
||||
// Check for [xROW] pattern (1-9)
|
||||
const rowMatch = upper.match(/\[([1-9])ROW\]/);
|
||||
if (rowMatch) {
|
||||
const count = parseInt(rowMatch[1], 10);
|
||||
// 1ROW is same as horizontal
|
||||
if (count === 1) {
|
||||
return { type: "horizontal" };
|
||||
}
|
||||
return { type: "rows", count };
|
||||
}
|
||||
|
||||
// Check for [xCOL] pattern (1-9)
|
||||
const colMatch = upper.match(/\[([1-9])COL\]/);
|
||||
if (colMatch) {
|
||||
const count = parseInt(colMatch[1], 10);
|
||||
// 1COL is same as vertical
|
||||
if (count === 1) {
|
||||
return { type: "vertical" };
|
||||
}
|
||||
return { type: "columns", count };
|
||||
}
|
||||
|
||||
return { type: "default" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Node info for layout calculations
|
||||
*/
|
||||
interface NodeInfo {
|
||||
id: number;
|
||||
width: number;
|
||||
height: number;
|
||||
originalX: number;
|
||||
originalY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of arranging nodes by mode
|
||||
*/
|
||||
export interface ArrangedNode {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort nodes by original position (left-to-right, top-to-bottom)
|
||||
*/
|
||||
function sortByOriginalPosition(nodes: NodeInfo[]): NodeInfo[] {
|
||||
return [...nodes].sort((a, b) => {
|
||||
// Primary: sort by X (left to right)
|
||||
if (a.originalX !== b.originalX) {
|
||||
return a.originalX - b.originalX;
|
||||
}
|
||||
// Secondary: sort by Y (top to bottom)
|
||||
return a.originalY - b.originalY;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrange nodes in a single horizontal row
|
||||
*/
|
||||
function arrangeHorizontal(
|
||||
nodes: NodeInfo[],
|
||||
startX: number,
|
||||
startY: number,
|
||||
gap: number
|
||||
): ArrangedNode[] {
|
||||
// Sort by original position (left to right)
|
||||
const sorted = sortByOriginalPosition(nodes);
|
||||
|
||||
const result: ArrangedNode[] = [];
|
||||
let x = startX;
|
||||
|
||||
for (const node of sorted) {
|
||||
result.push({ id: node.id, x, y: startY });
|
||||
x += node.width + gap;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrange nodes in a single vertical column
|
||||
*/
|
||||
function arrangeVertical(
|
||||
nodes: NodeInfo[],
|
||||
startX: number,
|
||||
startY: number,
|
||||
gap: number
|
||||
): ArrangedNode[] {
|
||||
// Sort by original position (top to bottom, then left to right)
|
||||
const sorted = [...nodes].sort((a, b) => {
|
||||
if (a.originalY !== b.originalY) {
|
||||
return a.originalY - b.originalY;
|
||||
}
|
||||
return a.originalX - b.originalX;
|
||||
});
|
||||
|
||||
const result: ArrangedNode[] = [];
|
||||
let y = startY;
|
||||
|
||||
for (const node of sorted) {
|
||||
result.push({ id: node.id, x: startX, y });
|
||||
y += node.height + gap;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrange nodes into N rows (round-robin distribution)
|
||||
* Each row is horizontal, rows stacked vertically
|
||||
*/
|
||||
function arrangeRows(
|
||||
nodes: NodeInfo[],
|
||||
rowCount: number,
|
||||
startX: number,
|
||||
startY: number,
|
||||
horizontalGap: number,
|
||||
verticalGap: number
|
||||
): ArrangedNode[] {
|
||||
// Sort by original position first
|
||||
const sorted = sortByOriginalPosition(nodes);
|
||||
|
||||
// Distribute round-robin into rows
|
||||
const rows: NodeInfo[][] = Array.from({ length: rowCount }, () => []);
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
rows[i % rowCount].push(sorted[i]);
|
||||
}
|
||||
|
||||
const result: ArrangedNode[] = [];
|
||||
let y = startY;
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.length === 0) continue;
|
||||
|
||||
let x = startX;
|
||||
let rowMaxHeight = 0;
|
||||
|
||||
for (const node of row) {
|
||||
result.push({ id: node.id, x, y });
|
||||
x += node.width + horizontalGap;
|
||||
rowMaxHeight = Math.max(rowMaxHeight, node.height);
|
||||
}
|
||||
|
||||
y += rowMaxHeight + verticalGap;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrange nodes into N columns (round-robin distribution)
|
||||
* Each column is vertical, columns side-by-side
|
||||
*/
|
||||
function arrangeColumns(
|
||||
nodes: NodeInfo[],
|
||||
colCount: number,
|
||||
startX: number,
|
||||
startY: number,
|
||||
horizontalGap: number,
|
||||
verticalGap: number
|
||||
): ArrangedNode[] {
|
||||
// Sort by original position first
|
||||
const sorted = sortByOriginalPosition(nodes);
|
||||
|
||||
// Distribute round-robin into columns
|
||||
const cols: NodeInfo[][] = Array.from({ length: colCount }, () => []);
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
cols[i % colCount].push(sorted[i]);
|
||||
}
|
||||
|
||||
const result: ArrangedNode[] = [];
|
||||
let x = startX;
|
||||
|
||||
for (const col of cols) {
|
||||
if (col.length === 0) continue;
|
||||
|
||||
let y = startY;
|
||||
let colMaxWidth = 0;
|
||||
|
||||
for (const node of col) {
|
||||
result.push({ id: node.id, x, y });
|
||||
y += node.height + verticalGap;
|
||||
colMaxWidth = Math.max(colMaxWidth, node.width);
|
||||
}
|
||||
|
||||
x += colMaxWidth + horizontalGap;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrange nodes according to layout mode
|
||||
*/
|
||||
export function arrangeByMode(
|
||||
members: LGraphNode[],
|
||||
mode: LayoutMode,
|
||||
startX: number,
|
||||
startY: number,
|
||||
config: LayoutConfig
|
||||
): ArrangedNode[] {
|
||||
// Build node info list with original positions
|
||||
const nodes: NodeInfo[] = members.map((n) => ({
|
||||
id: n.id,
|
||||
width: n.size?.[0] ?? 200,
|
||||
height: n.size?.[1] ?? 100,
|
||||
originalX: n.pos?.[0] ?? 0,
|
||||
originalY: n.pos?.[1] ?? 0,
|
||||
}));
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
switch (mode.type) {
|
||||
case "horizontal":
|
||||
return arrangeHorizontal(nodes, startX, startY, config.horizontalGap);
|
||||
|
||||
case "vertical":
|
||||
return arrangeVertical(nodes, startX, startY, config.verticalGap);
|
||||
|
||||
case "rows":
|
||||
return arrangeRows(
|
||||
nodes,
|
||||
mode.count,
|
||||
startX,
|
||||
startY,
|
||||
config.horizontalGap,
|
||||
config.verticalGap
|
||||
);
|
||||
|
||||
case "columns":
|
||||
return arrangeColumns(
|
||||
nodes,
|
||||
mode.count,
|
||||
startX,
|
||||
startY,
|
||||
config.horizontalGap,
|
||||
config.verticalGap
|
||||
);
|
||||
|
||||
default:
|
||||
// Default mode - caller should use existing behavior
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Token parser and algorithm factory.
|
||||
*
|
||||
* Parses group titles for layout tokens like [HORIZONTAL], [2ROW], [3COL]
|
||||
* and creates the corresponding LayoutAlgorithm instances.
|
||||
*/
|
||||
|
||||
import type { LayoutToken, LayoutAlgorithm } from "./types";
|
||||
import { createHorizontalAlgorithm } from "./algorithms/horizontal";
|
||||
import { createVerticalAlgorithm } from "./algorithms/vertical";
|
||||
import { createGridAlgorithm } from "./algorithms/grid";
|
||||
|
||||
/**
|
||||
* Regex to match layout tokens in group titles.
|
||||
* Matches: [HORIZONTAL], [VERTICAL], [1-9ROW], [1-9COL]
|
||||
* Case-insensitive.
|
||||
*/
|
||||
const TOKEN_REGEX = /\[(HORIZONTAL|VERTICAL|([1-9])ROW|([1-9])COL)\]/i;
|
||||
|
||||
/**
|
||||
* Parse a group title for a layout token.
|
||||
* Returns null if no valid token is found.
|
||||
*
|
||||
* @example
|
||||
* parseLayoutToken("My Group [HORIZONTAL]") // { mode: 'horizontal' }
|
||||
* parseLayoutToken("[2ROW]") // { mode: 'grid', count: 2, dimension: 'row' }
|
||||
* parseLayoutToken("No token") // null
|
||||
*/
|
||||
export function parseLayoutToken(title: string): LayoutToken | null {
|
||||
const match = TOKEN_REGEX.exec(title);
|
||||
if (!match) return null;
|
||||
|
||||
const fullMatch = match[1].toUpperCase();
|
||||
|
||||
if (fullMatch === "HORIZONTAL") {
|
||||
return { mode: "horizontal" };
|
||||
}
|
||||
|
||||
if (fullMatch === "VERTICAL") {
|
||||
return { mode: "vertical" };
|
||||
}
|
||||
|
||||
// [N]ROW pattern
|
||||
if (match[2] !== undefined) {
|
||||
const count = parseInt(match[2], 10);
|
||||
if (count === 1) {
|
||||
return { mode: "horizontal" };
|
||||
}
|
||||
return { mode: "grid", count, dimension: "row" };
|
||||
}
|
||||
|
||||
// [N]COL pattern
|
||||
if (match[3] !== undefined) {
|
||||
const count = parseInt(match[3], 10);
|
||||
if (count === 1) {
|
||||
return { mode: "vertical" };
|
||||
}
|
||||
return { mode: "grid", count, dimension: "col" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a LayoutAlgorithm from a LayoutToken.
|
||||
*
|
||||
* @param token - The parsed layout token
|
||||
* @param gap - Gap between nodes (default: 40)
|
||||
*/
|
||||
export function tokenToAlgorithm(token: LayoutToken, gap?: number): LayoutAlgorithm {
|
||||
switch (token.mode) {
|
||||
case "horizontal":
|
||||
return createHorizontalAlgorithm(gap);
|
||||
|
||||
case "vertical":
|
||||
return createVerticalAlgorithm(gap);
|
||||
|
||||
case "grid": {
|
||||
if (token.dimension === "row") {
|
||||
return createGridAlgorithm({ rows: token.count, gap });
|
||||
}
|
||||
return createGridAlgorithm({ columns: token.count, gap });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,274 +1,90 @@
|
||||
/**
|
||||
* Minimal type definitions for ComfyUI/LiteGraph
|
||||
* These match the runtime global objects, not the package exports
|
||||
* Pure layout types — no ComfyUI/LGraph dependencies.
|
||||
* These are the abstract types for the v2 layout framework.
|
||||
*/
|
||||
|
||||
/** Point as [x, y] */
|
||||
export type Point = [x: number, y: number];
|
||||
|
||||
/** Size as [width, height] */
|
||||
export type Size = [width: number, height: number];
|
||||
|
||||
/** Node input slot */
|
||||
export interface NodeInputSlot {
|
||||
name: string;
|
||||
type: string;
|
||||
link: number | null;
|
||||
/** Position as a simple object */
|
||||
export interface Position {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
/** Node output slot */
|
||||
export interface NodeOutputSlot {
|
||||
name: string;
|
||||
type: string;
|
||||
links: number[] | null;
|
||||
}
|
||||
|
||||
/** Node flags */
|
||||
export interface NodeFlags {
|
||||
pinned?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
/** LiteGraph Node */
|
||||
export interface LGraphNode {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
pos: Point;
|
||||
size: Size;
|
||||
inputs: NodeInputSlot[];
|
||||
outputs: NodeOutputSlot[];
|
||||
flags?: NodeFlags;
|
||||
locked?: boolean;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/** LiteGraph Group */
|
||||
export interface LGraphGroup {
|
||||
id: number;
|
||||
title: string;
|
||||
pos: Point;
|
||||
size: Size;
|
||||
_nodes?: LGraphNode[];
|
||||
_children?: Set<{ id: number }>;
|
||||
_bounding?: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
set?: (x: number, y: number, w: number, h: number) => void;
|
||||
};
|
||||
resizeTo?: (items: Iterable<LGraphNode>, padding?: number) => void;
|
||||
}
|
||||
|
||||
/** LiteGraph Link */
|
||||
export interface LLink {
|
||||
id: number;
|
||||
origin_id: number;
|
||||
origin_slot: number;
|
||||
target_id: number;
|
||||
target_slot: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/** Subgraph I/O node (runtime format with pos/size like regular nodes) */
|
||||
export interface SubgraphIONode {
|
||||
id: number;
|
||||
pos: [number, number];
|
||||
size: [number, number];
|
||||
}
|
||||
|
||||
/** LiteGraph Graph (or Subgraph with I/O nodes) */
|
||||
export interface LGraph {
|
||||
_nodes: LGraphNode[];
|
||||
_groups: LGraphGroup[];
|
||||
/** Links can be Map, Record, or Array depending on context */
|
||||
links: Map<number, LLink> | Record<number, LLink> | LLink[];
|
||||
setDirtyCanvas?: (fg: boolean, bg: boolean) => void;
|
||||
/** Subgraph input node (only on Subgraph, not root LGraph) */
|
||||
inputNode?: SubgraphIONode;
|
||||
/** Subgraph output node (only on Subgraph, not root LGraph) */
|
||||
outputNode?: SubgraphIONode;
|
||||
}
|
||||
|
||||
/** Layout node with computed metadata */
|
||||
/** A node to be laid out — just an ID and dimensions */
|
||||
export interface LayoutNode {
|
||||
/** Reference to ComfyUI node */
|
||||
node: LGraphNode;
|
||||
/** Node ID */
|
||||
id: number;
|
||||
/** DAG depth (0 = sources/inputs) */
|
||||
layer: number;
|
||||
/** Vertical position within layer */
|
||||
orderInLayer: number;
|
||||
/** Node IDs this depends on (predecessors) */
|
||||
predecessors: number[];
|
||||
/** Node IDs that depend on this (successors) */
|
||||
successors: number[];
|
||||
/** Node width (or group width if representative) */
|
||||
width: number;
|
||||
/** Node height (or group height if representative) */
|
||||
height: number;
|
||||
/** Computed X coordinate */
|
||||
x: number;
|
||||
/** Computed Y coordinate */
|
||||
y: number;
|
||||
/** True if this node represents a group in the layout */
|
||||
isGroupRepresentative?: boolean;
|
||||
/** The group this node represents (if isGroupRepresentative) */
|
||||
group?: LayoutGroup;
|
||||
/** True if this is a subgraph I/O node (input or output boundary) */
|
||||
isSubgraphIO?: boolean;
|
||||
/** Reference to runtime I/O node for position updates (has pos/size) */
|
||||
runtimeIONode?: { id: number; pos: [number, number]; size: [number, number] };
|
||||
readonly id: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly kind?: "node" | "subgraph-input" | "subgraph-output";
|
||||
readonly layerConstraint?: "first" | "last";
|
||||
}
|
||||
|
||||
/** Layer = vertical column of nodes */
|
||||
export interface Layer {
|
||||
/** Layer index (0 = leftmost) */
|
||||
index: number;
|
||||
/** Nodes in this layer */
|
||||
nodes: LayoutNode[];
|
||||
/** Computed X position for this layer */
|
||||
x: number;
|
||||
/** Max node width in this layer */
|
||||
maxWidth: number;
|
||||
/** A directed edge between nodes */
|
||||
export interface LayoutEdge {
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
}
|
||||
|
||||
/** Layout group tracking with hierarchy support */
|
||||
/** Token parsed from group title like [HORIZONTAL], [2ROW], etc. */
|
||||
export interface LayoutToken {
|
||||
readonly mode: "horizontal" | "vertical" | "grid";
|
||||
readonly count?: number;
|
||||
readonly dimension?: "row" | "col";
|
||||
}
|
||||
|
||||
/** A group of nodes, possibly with nested child groups */
|
||||
export interface LayoutGroup {
|
||||
/** Reference to ComfyUI group */
|
||||
group: LGraphGroup;
|
||||
/** Node IDs directly contained (not in child groups) */
|
||||
memberIds: Set<number>;
|
||||
/** Child groups (nested inside this group) */
|
||||
childGroups: LayoutGroup[];
|
||||
/** Parent group (group containing this one), null for top-level */
|
||||
parentGroup: LayoutGroup | null;
|
||||
/** Nesting depth (0 = top-level) */
|
||||
depth: number;
|
||||
/** Calculated width for the group */
|
||||
width: number;
|
||||
/** Calculated height for the group */
|
||||
height: number;
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly memberIds: ReadonlyArray<string>;
|
||||
readonly childGroupIds: ReadonlyArray<string>;
|
||||
readonly token?: LayoutToken;
|
||||
}
|
||||
|
||||
/** Layout configuration */
|
||||
export interface LayoutConfig {
|
||||
/** Horizontal gap between layers */
|
||||
horizontalGap: number;
|
||||
/** Vertical gap between nodes in same layer */
|
||||
verticalGap: number;
|
||||
/** Padding inside groups */
|
||||
groupPadding: number;
|
||||
/** Layout origin X */
|
||||
startX: number;
|
||||
/** Layout origin Y */
|
||||
startY: number;
|
||||
/** Max barycenter iterations */
|
||||
maxIterations: number;
|
||||
/** Max columns per layer (0=auto, 1=disabled/vertical stack, 2+=fixed) */
|
||||
maxColumns: number;
|
||||
/** Max row width before new row when maxColumns=0 */
|
||||
maxRowWidth: number;
|
||||
/** Collapse reroute chains into edge waypoints */
|
||||
collapseReroutes: boolean;
|
||||
/** Gap between disconnected zone and DAG */
|
||||
disconnectedGap: number;
|
||||
/** Input to a layout algorithm — one level of the hierarchy */
|
||||
export interface LayoutInput {
|
||||
readonly nodes: ReadonlyArray<LayoutNode>;
|
||||
readonly edges: ReadonlyArray<LayoutEdge>;
|
||||
}
|
||||
|
||||
/** Default configuration */
|
||||
export const DEFAULT_CONFIG: LayoutConfig = {
|
||||
/** Output from a layout algorithm — position map */
|
||||
export interface LayoutOutput {
|
||||
readonly positions: ReadonlyMap<string, Position>;
|
||||
}
|
||||
|
||||
/** The pluggable algorithm interface */
|
||||
export interface LayoutAlgorithm {
|
||||
readonly name: string;
|
||||
layout(input: LayoutInput): LayoutOutput;
|
||||
}
|
||||
|
||||
/** Group bounds after layout (position + size) */
|
||||
export interface GroupBounds {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
/** Full result from the framework (after group recursion) */
|
||||
export interface FrameworkResult {
|
||||
readonly positions: ReadonlyMap<string, Position>;
|
||||
readonly groupBounds: ReadonlyMap<string, GroupBounds>;
|
||||
}
|
||||
|
||||
/** Configuration for the framework */
|
||||
export interface FrameworkConfig {
|
||||
readonly horizontalGap: number;
|
||||
readonly verticalGap: number;
|
||||
readonly groupPadding: number;
|
||||
readonly groupTitleHeight: number;
|
||||
readonly disconnectedGap: number;
|
||||
}
|
||||
|
||||
/** Default config */
|
||||
export const DEFAULT_FRAMEWORK_CONFIG: FrameworkConfig = {
|
||||
horizontalGap: 100,
|
||||
verticalGap: 40,
|
||||
groupPadding: 30,
|
||||
startX: 100,
|
||||
startY: 100,
|
||||
maxIterations: 24,
|
||||
maxColumns: 0,
|
||||
maxRowWidth: 800,
|
||||
collapseReroutes: true,
|
||||
groupTitleHeight: 46,
|
||||
disconnectedGap: 150,
|
||||
};
|
||||
|
||||
/** Node classification result */
|
||||
export interface ClassifiedNodes {
|
||||
/** Nodes with connections (participate in DAG) */
|
||||
connected: Set<number>;
|
||||
/** Nodes without any connections */
|
||||
disconnected: Set<number>;
|
||||
/** Reroute nodes (for special handling) */
|
||||
reroutes: Set<number>;
|
||||
}
|
||||
|
||||
/** Reroute chain for collapse/restore */
|
||||
export interface RerouteChain {
|
||||
/** Reroute node IDs in order */
|
||||
nodes: number[];
|
||||
/** Source node before chain */
|
||||
sourceNode: number;
|
||||
/** Source slot index */
|
||||
sourceSlot: number;
|
||||
/** Target nodes after chain */
|
||||
targets: Array<{ node: number; slot: number }>;
|
||||
}
|
||||
|
||||
/** Packed row within a layer (for bin packing) */
|
||||
export interface PackedRow {
|
||||
/** Node IDs in this row */
|
||||
nodes: number[];
|
||||
/** Max node height in row */
|
||||
height: number;
|
||||
/** Vertical offset from layer start */
|
||||
yOffset: number;
|
||||
/** Total width of nodes + gaps in row */
|
||||
width: number;
|
||||
}
|
||||
|
||||
/** Graph layout state */
|
||||
export interface LayoutState {
|
||||
/** All layout nodes by ID (includes group representatives) */
|
||||
nodes: Map<number, LayoutNode>;
|
||||
/** Layers of nodes */
|
||||
layers: Layer[];
|
||||
/** Groups */
|
||||
groups: LayoutGroup[];
|
||||
/** Config */
|
||||
config: LayoutConfig;
|
||||
/** Maps node ID to its group (if any) */
|
||||
nodeToGroup?: Map<number, LayoutGroup>;
|
||||
/** Maps group to its representative node ID */
|
||||
groupRepresentatives?: Map<LayoutGroup, number>;
|
||||
/** All original ComfyUI nodes by ID */
|
||||
allNodes?: Map<number, LGraphNode>;
|
||||
/** Groups organized by depth (index 0 = deepest) for bottom-up processing */
|
||||
groupsByDepth?: LayoutGroup[][];
|
||||
/** Disconnected node IDs (placed in separate zone) */
|
||||
disconnectedNodes?: Set<number>;
|
||||
/** Collapsed reroute chains (restored after layout) */
|
||||
rerouteChains?: RerouteChain[];
|
||||
/** Packed rows per layer (for bin-packed layout) */
|
||||
packedRows?: Map<number, PackedRow[]>;
|
||||
}
|
||||
|
||||
/** Layout result */
|
||||
export interface LayoutResult {
|
||||
/** Number of nodes processed */
|
||||
nodeCount: number;
|
||||
/** Number of layers */
|
||||
layerCount: number;
|
||||
/** Number of groups resized */
|
||||
groupCount: number;
|
||||
/** Execution time in ms */
|
||||
executionMs: number;
|
||||
}
|
||||
|
||||
/** Result from selected group layout */
|
||||
export interface SelectedGroupLayoutResult {
|
||||
/** Number of nodes organized */
|
||||
nodeCount: number;
|
||||
/** Number of groups organized */
|
||||
groupCount: number;
|
||||
/** Execution time in ms */
|
||||
executionMs: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { isGroup, type CanvasItem } from "./utils";
|
||||
|
||||
export interface CanvasLike<TGraph = unknown> {
|
||||
readonly graph: TGraph | null;
|
||||
readonly getCurrentGraph?: () => TGraph | null;
|
||||
readonly selectedItems?: Iterable<CanvasItem>;
|
||||
}
|
||||
|
||||
export interface AppLike<TGraph = unknown> {
|
||||
readonly canvas?: CanvasLike<TGraph> | null;
|
||||
readonly graph?: TGraph | null;
|
||||
}
|
||||
|
||||
export function getCurrentGraph<TGraph>(app: AppLike<TGraph>): TGraph | null {
|
||||
const canvas = app.canvas;
|
||||
if (!canvas) return null;
|
||||
return canvas.getCurrentGraph?.() ?? canvas.graph ?? app.graph ?? null;
|
||||
}
|
||||
|
||||
export function getSelectedGroups(
|
||||
canvas: CanvasLike | null | undefined,
|
||||
): Array<{ id: number; title: string }> {
|
||||
if (!canvas?.selectedItems) return [];
|
||||
|
||||
const groups: Array<{ id: number; title: string }> = [];
|
||||
for (const item of canvas.selectedItems) {
|
||||
if (isGroup(item)) {
|
||||
groups.push({ id: item.id as number, title: item.title });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export const EXTENSION_NAME = "comfy-node-organizer";
|
||||
export const SETTINGS_PREFIX = "Node Organizer";
|
||||
export const REPOSITORY_URL =
|
||||
"https://github.com/PBandDev/comfyui-node-organizer";
|
||||
export const CURRENT_VERSION = "1.3.0";
|
||||
|
||||
export const DEFAULT_ALGORITHM_OPTIONS = [
|
||||
"sugiyama",
|
||||
"horizontal",
|
||||
"vertical",
|
||||
] as const;
|
||||
|
||||
export type DefaultAlgorithmName =
|
||||
(typeof DEFAULT_ALGORITHM_OPTIONS)[number];
|
||||
|
||||
export const SETTING_IDS = {
|
||||
VERSION: `${SETTINGS_PREFIX}.About`,
|
||||
KEYBINDINGS: `${SETTINGS_PREFIX}.Keybindings`,
|
||||
DEFAULT_ALGORITHM: `${SETTINGS_PREFIX}.Default Algorithm`,
|
||||
HORIZONTAL_GAP: `${SETTINGS_PREFIX}.Horizontal Gap`,
|
||||
VERTICAL_GAP: `${SETTINGS_PREFIX}.Vertical Gap`,
|
||||
GROUP_PADDING: `${SETTINGS_PREFIX}.Group Padding`,
|
||||
DISCONNECTED_GAP: `${SETTINGS_PREFIX}.Disconnected Gap`,
|
||||
FIT_TO_VIEW: `${SETTINGS_PREFIX}.Fit to View`,
|
||||
DEBUG_LOGGING: `${SETTINGS_PREFIX}.Debug Logging`,
|
||||
} as const;
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface CanvasItem {
|
||||
readonly id: number | string;
|
||||
readonly pos: ArrayLike<number>;
|
||||
readonly size?: ArrayLike<number>;
|
||||
readonly title?: string;
|
||||
readonly type?: string | null;
|
||||
readonly constructor?: { readonly name?: string };
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export interface GroupItem extends CanvasItem {
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
export function isGroup(item: CanvasItem): item is GroupItem {
|
||||
if (typeof item.title !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.constructor?.name === "LGraphGroup") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeof item.type !== "string";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const fixturesRoot = resolve(
|
||||
fileURLToPath(new URL("../fixtures/", import.meta.url)),
|
||||
);
|
||||
|
||||
export function loadWorkflowData(path: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function listRepoFixtures(): string[] {
|
||||
return readdirSync(fixturesRoot)
|
||||
.filter((entry) => entry.endsWith(".json"))
|
||||
.map((entry) => entry.replace(/\.json$/, ""))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function loadFixture(name: string): Record<string, unknown> {
|
||||
return loadWorkflowData(resolve(fixturesRoot, `${name}.json`));
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { writeFileSync, existsSync } from "node:fs";
|
||||
import { resolve, join } from "node:path";
|
||||
import { e2eConfig } from "../../e2e.config.ts";
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
const projectRoot = resolve(import.meta.dirname, "..", "..");
|
||||
|
||||
const venvDir = resolve(projectRoot, e2eConfig.venvDir);
|
||||
const comfyInstallDir = resolve(projectRoot, e2eConfig.comfyInstallDir);
|
||||
const pidFile = resolve(projectRoot, e2eConfig.testComfyDir, "comfy.pid");
|
||||
|
||||
const comfyBin = isWindows
|
||||
? join(venvDir, "Scripts", "comfy.exe")
|
||||
: join(venvDir, "bin", "comfy");
|
||||
|
||||
async function isComfyRunning(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(e2eConfig.comfyUrl);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForComfy(timeoutMs: number): Promise<void> {
|
||||
const start = Date.now();
|
||||
const pollInterval = 2000;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await isComfyRunning()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, pollInterval));
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`ComfyUI did not start within ${timeoutMs / 1000}s on ${e2eConfig.comfyUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
export default async function globalSetup(): Promise<void> {
|
||||
// Check if ComfyUI is already running
|
||||
if (await isComfyRunning()) {
|
||||
console.log(
|
||||
`[e2e] ComfyUI already running on ${e2eConfig.comfyUrl}, reusing.`
|
||||
);
|
||||
// Write PID file with "external" marker so teardown knows not to kill it
|
||||
writeFileSync(pidFile, "external", "utf-8");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify comfy binary exists
|
||||
if (!existsSync(comfyBin)) {
|
||||
throw new Error(
|
||||
`comfy-cli not found at ${comfyBin}. Run \`pnpm setup:e2e\` first.`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[e2e] Starting ComfyUI on port ${e2eConfig.port}...`);
|
||||
|
||||
const child = spawn(
|
||||
comfyBin,
|
||||
[
|
||||
"--skip-prompt",
|
||||
"--workspace",
|
||||
comfyInstallDir,
|
||||
"launch",
|
||||
"--",
|
||||
"--cpu",
|
||||
"--port",
|
||||
String(e2eConfig.port),
|
||||
],
|
||||
{
|
||||
stdio: "pipe",
|
||||
detached: !isWindows,
|
||||
}
|
||||
);
|
||||
|
||||
// Log stdout/stderr for debugging
|
||||
child.stdout.on("data", (data: Buffer) => {
|
||||
process.stdout.write(`[comfy] ${data.toString()}`);
|
||||
});
|
||||
child.stderr.on("data", (data: Buffer) => {
|
||||
process.stderr.write(`[comfy] ${data.toString()}`);
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
console.error(`[e2e] Failed to start ComfyUI: ${err.message}`);
|
||||
});
|
||||
|
||||
if (child.pid === undefined) {
|
||||
throw new Error("Failed to spawn ComfyUI process — no PID assigned.");
|
||||
}
|
||||
|
||||
// Store PID for teardown
|
||||
writeFileSync(pidFile, String(child.pid), "utf-8");
|
||||
console.log(`[e2e] ComfyUI spawned with PID ${child.pid}`);
|
||||
|
||||
// Detach so the process survives if this script exits
|
||||
child.unref();
|
||||
|
||||
// Wait for ComfyUI to be ready
|
||||
await waitForComfy(e2eConfig.timeouts.comfyStartup);
|
||||
console.log(`[e2e] ComfyUI is ready on ${e2eConfig.comfyUrl}`);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync, unlinkSync, existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { e2eConfig } from "../../e2e.config.ts";
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
const projectRoot = resolve(import.meta.dirname, "..", "..");
|
||||
const pidFile = resolve(projectRoot, e2eConfig.testComfyDir, "comfy.pid");
|
||||
|
||||
function killProcess(pid: number): void {
|
||||
try {
|
||||
if (isWindows) {
|
||||
// On Windows, process.kill doesn't reliably kill child trees.
|
||||
// Use taskkill /F /T to force-kill the process tree.
|
||||
execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore" });
|
||||
} else {
|
||||
// On Unix, kill the process group (negative PID)
|
||||
process.kill(-pid, "SIGTERM");
|
||||
}
|
||||
console.log(`[e2e] Killed ComfyUI process (PID ${pid}).`);
|
||||
} catch {
|
||||
// Process may already be dead
|
||||
console.log(
|
||||
`[e2e] ComfyUI process (PID ${pid}) already exited or could not be killed.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function globalTeardown(): Promise<void> {
|
||||
if (!existsSync(pidFile)) {
|
||||
console.log("[e2e] No PID file found, nothing to tear down.");
|
||||
return;
|
||||
}
|
||||
|
||||
const content = readFileSync(pidFile, "utf-8").trim();
|
||||
|
||||
if (content === "external") {
|
||||
console.log(
|
||||
"[e2e] ComfyUI was already running before tests — leaving it alone."
|
||||
);
|
||||
unlinkSync(pidFile);
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = parseInt(content, 10);
|
||||
if (isNaN(pid)) {
|
||||
console.log(`[e2e] Invalid PID in file: "${content}". Cleaning up.`);
|
||||
unlinkSync(pidFile);
|
||||
return;
|
||||
}
|
||||
|
||||
killProcess(pid);
|
||||
unlinkSync(pidFile);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
assertCorrectnessInvariants,
|
||||
extractGraphState,
|
||||
loadWorkflow,
|
||||
triggerOrganize,
|
||||
waitForComfyUI,
|
||||
} from "./helpers";
|
||||
import {
|
||||
listInstalledTemplates,
|
||||
loadWorkflowData,
|
||||
} from "./installed-templates";
|
||||
|
||||
const installedTemplates = listInstalledTemplates();
|
||||
|
||||
test.describe("Installed workflow template invariants", () => {
|
||||
test("discovers installed workflow templates", () => {
|
||||
expect(installedTemplates.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForComfyUI(page);
|
||||
});
|
||||
|
||||
for (const template of installedTemplates) {
|
||||
test(`${template.id}: organizes and passes correctness invariants`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await loadWorkflow(page, loadWorkflowData(template.path));
|
||||
await triggerOrganize(page);
|
||||
|
||||
const state = await extractGraphState(page);
|
||||
expect(state.nodes.length).toBeGreaterThan(0);
|
||||
assertCorrectnessInvariants(state);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { e2eConfig } from "../../e2e.config";
|
||||
import { loadWorkflowData } from "./fixtures";
|
||||
|
||||
export interface InstalledTemplateEntry {
|
||||
id: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function isWorkflowData(value: Record<string, unknown>): boolean {
|
||||
return Array.isArray(value.nodes);
|
||||
}
|
||||
|
||||
function isInstalledTemplateEntry(
|
||||
value: unknown,
|
||||
): value is InstalledTemplateEntry {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof candidate.id === "string" &&
|
||||
candidate.id.length > 0 &&
|
||||
typeof candidate.path === "string" &&
|
||||
candidate.path.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function parseInstalledTemplateManifest(
|
||||
stdout: string,
|
||||
): InstalledTemplateEntry[] {
|
||||
const parsed: unknown = JSON.parse(stdout);
|
||||
if (!Array.isArray(parsed) || !parsed.every(isInstalledTemplateEntry)) {
|
||||
throw new Error("Installed template discovery returned an invalid manifest");
|
||||
}
|
||||
if (parsed.length === 0) {
|
||||
throw new Error("Installed template discovery returned zero templates");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getTestVenvPythonPath(): string {
|
||||
const venvRoot = resolve(e2eConfig.venvDir);
|
||||
return process.platform === "win32"
|
||||
? join(venvRoot, "Scripts", "python.exe")
|
||||
: join(venvRoot, "bin", "python");
|
||||
}
|
||||
|
||||
export function listInstalledTemplates(): InstalledTemplateEntry[] {
|
||||
const script = [
|
||||
"import json",
|
||||
"from comfyui_workflow_templates import get_asset_path, iter_templates",
|
||||
"entries = []",
|
||||
"for template in iter_templates():",
|
||||
" json_assets = [asset for asset in template.assets if asset.filename.endswith('.json')]",
|
||||
" if len(json_assets) != 1:",
|
||||
" raise RuntimeError(f'Template {template.template_id} expected exactly one JSON asset, found {len(json_assets)}')",
|
||||
" entries.append({'id': template.template_id, 'path': get_asset_path(template.template_id, json_assets[0].filename)})",
|
||||
"print(json.dumps(sorted(entries, key=lambda entry: entry['id'])))",
|
||||
].join("\n");
|
||||
|
||||
const stdout = execFileSync(getTestVenvPythonPath(), ["-c", script], {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
const workflowEntries = parseInstalledTemplateManifest(stdout).filter((entry) =>
|
||||
isWorkflowData(loadWorkflowData(entry.path)),
|
||||
);
|
||||
|
||||
if (workflowEntries.length === 0) {
|
||||
throw new Error("Installed template discovery returned zero workflow templates");
|
||||
}
|
||||
|
||||
return workflowEntries;
|
||||
}
|
||||
|
||||
export { loadWorkflowData };
|
||||
@@ -0,0 +1,98 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
waitForComfyUI,
|
||||
loadWorkflow,
|
||||
extractGraphState,
|
||||
triggerOrganizeGroup,
|
||||
} from "./helpers";
|
||||
import { loadFixture } from "./fixtures";
|
||||
|
||||
test.describe("Organize Group", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForComfyUI(page);
|
||||
});
|
||||
|
||||
test("group-test-simple: organizes a single group", async ({ page }) => {
|
||||
const workflow = loadFixture("group-test-simple");
|
||||
await loadWorkflow(page, workflow);
|
||||
|
||||
// Get group titles from the workflow
|
||||
const groupTitles = await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as Record<string, unknown>;
|
||||
const graph = canvas.graph as Record<string, unknown>;
|
||||
const groups = graph._groups as Array<{ title: string }>;
|
||||
return groups.map((g) => g.title);
|
||||
});
|
||||
|
||||
if (groupTitles.length > 0) {
|
||||
await triggerOrganizeGroup(page, groupTitles[0]);
|
||||
|
||||
const state = await extractGraphState(page);
|
||||
// All coordinates should be finite after organize
|
||||
for (const node of state.nodes) {
|
||||
expect(Number.isFinite(node.pos[0])).toBe(true);
|
||||
expect(Number.isFinite(node.pos[1])).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("group-test: organizes groups with nested structure", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflow = loadFixture("group-test");
|
||||
await loadWorkflow(page, workflow);
|
||||
|
||||
const groupTitles = await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as Record<string, unknown>;
|
||||
const graph = canvas.graph as Record<string, unknown>;
|
||||
const groups = graph._groups as Array<{ title: string }>;
|
||||
return groups.map((g) => g.title);
|
||||
});
|
||||
|
||||
// Organize each group
|
||||
for (const title of groupTitles) {
|
||||
await triggerOrganizeGroup(page, title);
|
||||
}
|
||||
|
||||
const state = await extractGraphState(page);
|
||||
// Basic sanity: all coordinates finite
|
||||
for (const node of state.nodes) {
|
||||
expect(Number.isFinite(node.pos[0])).toBe(true);
|
||||
expect(Number.isFinite(node.pos[1])).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("nested-groups: organize group preserves other groups", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflow = loadFixture("nested-groups");
|
||||
await loadWorkflow(page, workflow);
|
||||
|
||||
const beforeState = await extractGraphState(page);
|
||||
|
||||
const groupTitles = await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as Record<string, unknown>;
|
||||
const graph = canvas.graph as Record<string, unknown>;
|
||||
const groups = graph._groups as Array<{ title: string }>;
|
||||
return groups.map((g) => g.title);
|
||||
});
|
||||
|
||||
if (groupTitles.length > 0) {
|
||||
await triggerOrganizeGroup(page, groupTitles[0]);
|
||||
|
||||
const afterState = await extractGraphState(page);
|
||||
|
||||
// Total node count should be the same
|
||||
expect(afterState.nodes.length).toBe(beforeState.nodes.length);
|
||||
// Total group count should be the same
|
||||
expect(afterState.groups.length).toBe(beforeState.groups.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
waitForComfyUI,
|
||||
loadWorkflow,
|
||||
extractGraphState,
|
||||
extractGroupMemberships,
|
||||
triggerOrganize,
|
||||
assertInvariants,
|
||||
assertIdempotent,
|
||||
} from "./helpers";
|
||||
import { loadFixture } from "./fixtures";
|
||||
|
||||
test.describe("Organize Workflow", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForComfyUI(page);
|
||||
});
|
||||
|
||||
test("simple-dag: organizes and passes invariants", async ({ page }) => {
|
||||
const workflow = loadFixture("simple-dag");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
|
||||
const state = await extractGraphState(page);
|
||||
expect(state.nodes.length).toBe(7);
|
||||
assertInvariants(state);
|
||||
});
|
||||
|
||||
test("simple-dag: idempotent", async ({ page }) => {
|
||||
const workflow = loadFixture("simple-dag");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
await assertIdempotent(page);
|
||||
});
|
||||
|
||||
test("complex-parallel: organizes and passes invariants", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflow = loadFixture("complex-parallel");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
|
||||
const state = await extractGraphState(page);
|
||||
expect(state.nodes.length).toBeGreaterThan(10);
|
||||
assertInvariants(state);
|
||||
});
|
||||
|
||||
test("complex-parallel: idempotent", async ({ page }) => {
|
||||
const workflow = loadFixture("complex-parallel");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
await assertIdempotent(page);
|
||||
});
|
||||
|
||||
test("nested-groups: organizes and passes invariants", async ({ page }) => {
|
||||
const workflow = loadFixture("nested-groups");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
|
||||
const state = await extractGraphState(page);
|
||||
assertInvariants(state);
|
||||
});
|
||||
|
||||
test("nested-groups: groups contain their members after organize", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflow = loadFixture("nested-groups");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
|
||||
const state = await extractGraphState(page);
|
||||
// Groups should exist and have valid bounds
|
||||
expect(state.groups.length).toBeGreaterThan(0);
|
||||
for (const g of state.groups) {
|
||||
expect(g.size[0]).toBeGreaterThan(0);
|
||||
expect(g.size[1]).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("group-test: preserves group hierarchy and direct memberships", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflow = loadFixture("group-test");
|
||||
await loadWorkflow(page, workflow);
|
||||
|
||||
const beforeMemberships = await extractGroupMemberships(page);
|
||||
await triggerOrganize(page);
|
||||
const afterMemberships = await extractGroupMemberships(page);
|
||||
|
||||
expect(afterMemberships).toEqual(beforeMemberships);
|
||||
});
|
||||
|
||||
test("nested-groups: idempotent", async ({ page }) => {
|
||||
const workflow = loadFixture("nested-groups");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
await assertIdempotent(page);
|
||||
});
|
||||
|
||||
test("nested-wrapper: organizes and passes invariants", async ({ page }) => {
|
||||
const workflow = loadFixture("nested-wrapper");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
|
||||
const state = await extractGraphState(page);
|
||||
assertInvariants(state);
|
||||
});
|
||||
|
||||
test("default workflow: organizes and passes invariants", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Don't load a fixture — use whatever ComfyUI loads by default
|
||||
await triggerOrganize(page);
|
||||
|
||||
const state = await extractGraphState(page);
|
||||
expect(state.nodes.length).toBeGreaterThan(0);
|
||||
assertInvariants(state);
|
||||
});
|
||||
|
||||
test("default workflow: idempotent", async ({ page }) => {
|
||||
await triggerOrganize(page);
|
||||
await assertIdempotent(page);
|
||||
});
|
||||
|
||||
test("group-test: geometry-based group hierarchy and direct memberships match runtime after organize", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflow = loadFixture("group-test");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
|
||||
const memberships = await extractGroupMemberships(page);
|
||||
expect(memberships).toEqual([
|
||||
{
|
||||
title: "GetNodes [HORIZONTAL]",
|
||||
parentTitle: "Previews [2ROW]",
|
||||
memberNodeIds: [5, 6],
|
||||
},
|
||||
{
|
||||
title: "Preview Nodes [HORIZONTAL]",
|
||||
parentTitle: "Previews [2ROW]",
|
||||
memberNodeIds: [3, 4],
|
||||
},
|
||||
{
|
||||
title: "Previews [2ROW]",
|
||||
parentTitle: null,
|
||||
memberNodeIds: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("nested-groups: geometry-based group hierarchy and direct memberships match runtime after organize", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflow = loadFixture("nested-groups");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
|
||||
const memberships = await extractGroupMemberships(page);
|
||||
expect(memberships).toEqual([
|
||||
{
|
||||
title: "Group",
|
||||
parentTitle: "Step 2 - Prompt",
|
||||
memberNodeIds: [15],
|
||||
},
|
||||
{
|
||||
title: "Step 1 - Load model",
|
||||
parentTitle: null,
|
||||
memberNodeIds: [4],
|
||||
},
|
||||
{
|
||||
title: "Step 2 - Prompt",
|
||||
parentTitle: null,
|
||||
memberNodeIds: [6, 7],
|
||||
},
|
||||
{
|
||||
title: "Step 3 - Image size",
|
||||
parentTitle: null,
|
||||
memberNodeIds: [5],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Regression tests for v1 bugs that must not recur in v2.
|
||||
*
|
||||
* Each test targets a specific issue from the v1 changeset history:
|
||||
* - 0006: Y explosion (groups centering against tallest layer)
|
||||
* - 0007: Group overlap (no collision detection across layers)
|
||||
* - 0008: Nested group breakout (parent groups expanded 1600+ px)
|
||||
* - 0011: Oscillation (infinite loop in overlap resolution)
|
||||
* - 0013: Idempotency broken
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
waitForComfyUI,
|
||||
loadWorkflow,
|
||||
extractGraphState,
|
||||
triggerOrganize,
|
||||
assertInvariants,
|
||||
assertIdempotent,
|
||||
} from "./helpers";
|
||||
import { loadFixture } from "./fixtures";
|
||||
|
||||
test.describe("Regression: v1 bug fixes", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForComfyUI(page);
|
||||
});
|
||||
|
||||
test("no Y explosion: layout height stays reasonable", async ({ page }) => {
|
||||
// 0006: Y explosion — groups centering against tallest layer
|
||||
// caused layouts to be thousands of pixels tall
|
||||
const workflow = loadFixture("complex-parallel");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
|
||||
const state = await extractGraphState(page);
|
||||
const ys = state.nodes.map((n) => n.pos[1] + n.size[1]);
|
||||
const maxY = Math.max(...ys);
|
||||
const minY = Math.min(...state.nodes.map((n) => n.pos[1]));
|
||||
const totalHeight = maxY - minY;
|
||||
|
||||
// Height should not explode beyond a reasonable bound
|
||||
// (v1 bug produced heights of 11,500px for ~25 nodes)
|
||||
expect(totalHeight).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
test("no group overlap after organize", async ({ page }) => {
|
||||
// 0007: Groups overlapping each other after layout
|
||||
const workflow = loadFixture("nested-groups");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
|
||||
const state = await extractGraphState(page);
|
||||
assertInvariants(state);
|
||||
});
|
||||
|
||||
test("nested groups stay contained", async ({ page }) => {
|
||||
// 0008: Nested groups breaking out of parent bounds
|
||||
const workflow = loadFixture("nested-groups");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
|
||||
const state = await extractGraphState(page);
|
||||
// Groups should have reasonable sizes (v1 bug expanded by 1600+ px)
|
||||
for (const g of state.groups) {
|
||||
expect(g.size[0]).toBeLessThan(5000);
|
||||
expect(g.size[1]).toBeLessThan(5000);
|
||||
}
|
||||
});
|
||||
|
||||
test("no oscillation: organize completes quickly", async ({ page }) => {
|
||||
// 0011: Infinite loop in overlap resolution
|
||||
// If organize completes within timeout, there's no oscillation
|
||||
const workflow = loadFixture("complex-parallel");
|
||||
await loadWorkflow(page, workflow);
|
||||
|
||||
const start = Date.now();
|
||||
await triggerOrganize(page);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
// Should complete in under 5 seconds (v1 bug caused infinite loops)
|
||||
expect(elapsed).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
test("idempotency: simple-dag", async ({ page }) => {
|
||||
// 0013: Layout not stable on repeated runs
|
||||
const workflow = loadFixture("simple-dag");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
await assertIdempotent(page);
|
||||
});
|
||||
|
||||
test("idempotency: nested-groups", async ({ page }) => {
|
||||
const workflow = loadFixture("nested-groups");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
await assertIdempotent(page);
|
||||
});
|
||||
|
||||
test("idempotency: complex-parallel", async ({ page }) => {
|
||||
const workflow = loadFixture("complex-parallel");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
await assertIdempotent(page);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
waitForComfyUI,
|
||||
loadWorkflow,
|
||||
extractGraphState,
|
||||
extractGroupMemberships,
|
||||
triggerOrganize,
|
||||
triggerOrganizeGroup,
|
||||
assertInvariants,
|
||||
assertIdempotent,
|
||||
expectGraphCanvasScreenshot,
|
||||
setBooleanSetting,
|
||||
} from "./helpers";
|
||||
import { loadFixture } from "./fixtures";
|
||||
import { SETTING_IDS } from "../../src/settings";
|
||||
|
||||
test.describe("SDXL Simple Example", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForComfyUI(page);
|
||||
await setBooleanSetting(page, SETTING_IDS.FIT_TO_VIEW, false);
|
||||
});
|
||||
|
||||
test("organize workflow preserves counts and passes invariants", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflow = loadFixture("sdxl_simple_example");
|
||||
await loadWorkflow(page, workflow);
|
||||
|
||||
const beforeState = await extractGraphState(page);
|
||||
expect(beforeState.nodes.length).toBe(25);
|
||||
expect(beforeState.groups.length).toBe(10);
|
||||
expect(beforeState.links.length).toBe(23);
|
||||
|
||||
await triggerOrganize(page);
|
||||
|
||||
const afterState = await extractGraphState(page);
|
||||
expect(afterState.nodes.length).toBe(beforeState.nodes.length);
|
||||
expect(afterState.groups.length).toBe(beforeState.groups.length);
|
||||
expect(afterState.links.length).toBe(beforeState.links.length);
|
||||
assertInvariants(afterState);
|
||||
});
|
||||
|
||||
test("organize workflow preserves group hierarchy and direct memberships", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflow = loadFixture("sdxl_simple_example");
|
||||
await loadWorkflow(page, workflow);
|
||||
|
||||
const beforeMemberships = await extractGroupMemberships(page);
|
||||
await triggerOrganize(page);
|
||||
const afterMemberships = await extractGroupMemberships(page);
|
||||
|
||||
expect(afterMemberships).toEqual(beforeMemberships);
|
||||
});
|
||||
|
||||
test("organize workflow is idempotent", async ({ page }) => {
|
||||
const workflow = loadFixture("sdxl_simple_example");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
await assertIdempotent(page);
|
||||
});
|
||||
|
||||
test("organize top-level group preserves child group memberships and graph counts", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflow = loadFixture("sdxl_simple_example");
|
||||
await loadWorkflow(page, workflow);
|
||||
|
||||
const beforeMemberships = await extractGroupMemberships(page);
|
||||
const expectedMemberships = beforeMemberships.filter(
|
||||
(group) =>
|
||||
group.title === "Base Prompt" ||
|
||||
group.title === "Load in BASE SDXL Model",
|
||||
);
|
||||
await triggerOrganizeGroup(page, "Base");
|
||||
|
||||
const afterMemberships = await extractGroupMemberships(page);
|
||||
expect(
|
||||
afterMemberships.filter(
|
||||
(group) =>
|
||||
group.title === "Base Prompt" ||
|
||||
group.title === "Load in BASE SDXL Model",
|
||||
),
|
||||
).toEqual(expectedMemberships);
|
||||
|
||||
const state = await extractGraphState(page);
|
||||
expect(state.nodes.length).toBe(25);
|
||||
expect(state.groups.length).toBe(10);
|
||||
});
|
||||
|
||||
test("organize prompt group preserves its direct memberships", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflow = loadFixture("sdxl_simple_example");
|
||||
await loadWorkflow(page, workflow);
|
||||
|
||||
const beforeMemberships = await extractGroupMemberships(page);
|
||||
await triggerOrganizeGroup(page, "Text Prompts");
|
||||
|
||||
const afterMemberships = await extractGroupMemberships(page);
|
||||
expect(
|
||||
afterMemberships.find((group) => group.title === "Text Prompts"),
|
||||
).toEqual(beforeMemberships.find((group) => group.title === "Text Prompts"));
|
||||
});
|
||||
|
||||
test("graph canvas after organize", async ({ page }) => {
|
||||
const workflow = loadFixture("sdxl_simple_example");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
await expectGraphCanvasScreenshot(page, "sdxl-simple-example-organized.png");
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 55 KiB |
@@ -0,0 +1,105 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { SETTING_IDS } from "../../src/settings";
|
||||
import { DEFAULT_FRAMEWORK_CONFIG } from "../../src/layout/types";
|
||||
import { loadFixture } from "./fixtures";
|
||||
import {
|
||||
waitForComfyUI,
|
||||
loadWorkflow,
|
||||
triggerOrganize,
|
||||
setNumericSettingOverride,
|
||||
clearNumericSettingOverrides,
|
||||
extractSpacingMeasurements,
|
||||
} from "./helpers";
|
||||
|
||||
test.describe("Spacing settings", () => {
|
||||
async function applyDefaultSpacingOverrides(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
await setNumericSettingOverride(
|
||||
page,
|
||||
SETTING_IDS.HORIZONTAL_GAP,
|
||||
DEFAULT_FRAMEWORK_CONFIG.horizontalGap,
|
||||
);
|
||||
await setNumericSettingOverride(
|
||||
page,
|
||||
SETTING_IDS.VERTICAL_GAP,
|
||||
DEFAULT_FRAMEWORK_CONFIG.verticalGap,
|
||||
);
|
||||
await setNumericSettingOverride(
|
||||
page,
|
||||
SETTING_IDS.GROUP_PADDING,
|
||||
DEFAULT_FRAMEWORK_CONFIG.groupPadding,
|
||||
);
|
||||
await setNumericSettingOverride(
|
||||
page,
|
||||
SETTING_IDS.DISCONNECTED_GAP,
|
||||
DEFAULT_FRAMEWORK_CONFIG.disconnectedGap,
|
||||
);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForComfyUI(page);
|
||||
await clearNumericSettingOverrides(page);
|
||||
await applyDefaultSpacingOverrides(page);
|
||||
await loadWorkflow(page, loadFixture("spacing-settings"));
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await clearNumericSettingOverrides(page);
|
||||
});
|
||||
|
||||
test("horizontal gap setting increases consecutive layer spacing", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setNumericSettingOverride(page, SETTING_IDS.HORIZONTAL_GAP, 40);
|
||||
await triggerOrganize(page);
|
||||
const compact = await extractSpacingMeasurements(page);
|
||||
|
||||
await setNumericSettingOverride(page, SETTING_IDS.HORIZONTAL_GAP, 220);
|
||||
await triggerOrganize(page);
|
||||
const wide = await extractSpacingMeasurements(page);
|
||||
|
||||
expect(wide.horizontalLayerGap).toBeGreaterThan(compact.horizontalLayerGap);
|
||||
});
|
||||
|
||||
test("vertical gap setting increases main-layer sibling spacing", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setNumericSettingOverride(page, SETTING_IDS.VERTICAL_GAP, 20);
|
||||
await triggerOrganize(page);
|
||||
const compact = await extractSpacingMeasurements(page);
|
||||
|
||||
await setNumericSettingOverride(page, SETTING_IDS.VERTICAL_GAP, 180);
|
||||
await triggerOrganize(page);
|
||||
const roomy = await extractSpacingMeasurements(page);
|
||||
|
||||
expect(roomy.verticalSiblingGap).toBeGreaterThan(compact.verticalSiblingGap);
|
||||
});
|
||||
|
||||
test("group padding setting increases inner group margin", async ({ page }) => {
|
||||
await setNumericSettingOverride(page, SETTING_IDS.GROUP_PADDING, 10);
|
||||
await triggerOrganize(page);
|
||||
const compact = await extractSpacingMeasurements(page);
|
||||
|
||||
await setNumericSettingOverride(page, SETTING_IDS.GROUP_PADDING, 120);
|
||||
await triggerOrganize(page);
|
||||
const roomy = await extractSpacingMeasurements(page);
|
||||
|
||||
expect(roomy.groupPadding).toBeGreaterThan(compact.groupPadding);
|
||||
});
|
||||
|
||||
test("disconnected gap setting increases distance to disconnected node", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setNumericSettingOverride(page, SETTING_IDS.DISCONNECTED_GAP, 40);
|
||||
await triggerOrganize(page);
|
||||
const compact = await extractSpacingMeasurements(page);
|
||||
|
||||
await setNumericSettingOverride(page, SETTING_IDS.DISCONNECTED_GAP, 320);
|
||||
await triggerOrganize(page);
|
||||
const roomy = await extractSpacingMeasurements(page);
|
||||
|
||||
expect(roomy.disconnectedGap).toBeGreaterThan(compact.disconnectedGap);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
waitForComfyUI,
|
||||
loadWorkflow,
|
||||
extractGraphState,
|
||||
triggerOrganize,
|
||||
assertInvariants,
|
||||
assertIdempotent,
|
||||
} from "./helpers";
|
||||
import { loadFixture } from "./fixtures";
|
||||
|
||||
test.describe("Smoke tests", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForComfyUI(page);
|
||||
});
|
||||
|
||||
test("ComfyUI loads with extension commands registered", async ({
|
||||
page,
|
||||
}) => {
|
||||
const extensionState = await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const em = appObj.extensionManager as Record<string, unknown>;
|
||||
const command = em.command as Record<string, unknown>;
|
||||
const commands = command.commands as Array<{ id: string }>;
|
||||
const setting = em.setting as {
|
||||
get: <T>(id: string) => T | undefined;
|
||||
};
|
||||
return {
|
||||
hasOrganizeWorkflow: commands.some(
|
||||
(c) => c.id === "node-organizer.organize-workflow",
|
||||
),
|
||||
hasOrganizeGroups: commands.some(
|
||||
(c) => c.id === "node-organizer.organize-groups",
|
||||
),
|
||||
defaultAlgorithm: setting.get<string>("Node Organizer.Default Algorithm"),
|
||||
};
|
||||
});
|
||||
expect(extensionState.hasOrganizeWorkflow).toBe(true);
|
||||
expect(extensionState.hasOrganizeGroups).toBe(true);
|
||||
expect(extensionState.defaultAlgorithm).toBe("sugiyama");
|
||||
});
|
||||
|
||||
test("default workflow has nodes", async ({ page }) => {
|
||||
const state = await extractGraphState(page);
|
||||
expect(state.nodes.length).toBeGreaterThan(0);
|
||||
expect(state.links.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("Organize Workflow runs without error", async ({ page }) => {
|
||||
await triggerOrganize(page);
|
||||
const state = await extractGraphState(page);
|
||||
|
||||
for (const node of state.nodes) {
|
||||
expect(Number.isFinite(node.pos[0])).toBe(true);
|
||||
expect(Number.isFinite(node.pos[1])).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("Organize Workflow produces valid layout", async ({ page }) => {
|
||||
await triggerOrganize(page);
|
||||
const state = await extractGraphState(page);
|
||||
assertInvariants(state);
|
||||
});
|
||||
|
||||
test("Organize Workflow is idempotent", async ({ page }) => {
|
||||
await triggerOrganize(page);
|
||||
await assertIdempotent(page);
|
||||
});
|
||||
|
||||
test("isGroup discriminator matches runtime default workflow", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { groups, nodes } = await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as Record<string, unknown> & {
|
||||
getCurrentGraph?: () => Record<string, unknown> | undefined;
|
||||
};
|
||||
const graph = (canvas.getCurrentGraph?.() ??
|
||||
canvas.graph ??
|
||||
appObj.graph) as Record<string, unknown>;
|
||||
|
||||
const extractInfo = (item: Record<string, unknown>) => ({
|
||||
id: item.id as number | string,
|
||||
constructorName: (item.constructor as { name?: string } | undefined)
|
||||
?.name,
|
||||
typeofType: typeof item.type,
|
||||
hasStringTitle: typeof item.title === "string",
|
||||
});
|
||||
|
||||
const rawGroups =
|
||||
(graph._groups as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
const rawNodes =
|
||||
(graph._nodes as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
|
||||
return {
|
||||
groups: rawGroups.map(extractInfo),
|
||||
nodes: rawNodes.map(extractInfo),
|
||||
};
|
||||
});
|
||||
|
||||
// Every group must be identifiable by isGroup logic
|
||||
for (const g of groups) {
|
||||
const identifiable =
|
||||
g.constructorName === "LGraphGroup" ||
|
||||
(g.hasStringTitle && g.typeofType !== "string");
|
||||
expect(
|
||||
identifiable,
|
||||
`Group ${String(g.id)} not identifiable: constructor=${g.constructorName}, typeofType=${g.typeofType}, hasStringTitle=${g.hasStringTitle}`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
// No node should be a false positive (matches duck-typing but is not a group)
|
||||
for (const n of nodes) {
|
||||
const falsePositive =
|
||||
n.hasStringTitle &&
|
||||
n.typeofType !== "string" &&
|
||||
n.constructorName !== "LGraphGroup";
|
||||
expect(
|
||||
falsePositive,
|
||||
`Node ${String(n.id)} is a false positive: constructor=${n.constructorName}, typeofType=${n.typeofType}, hasStringTitle=${n.hasStringTitle}`,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("isGroup discriminator matches runtime grouped workflow", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflow = loadFixture("group-test");
|
||||
await loadWorkflow(page, workflow);
|
||||
|
||||
const { groups, nodes } = await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as Record<string, unknown> & {
|
||||
getCurrentGraph?: () => Record<string, unknown> | undefined;
|
||||
};
|
||||
const graph = (canvas.getCurrentGraph?.() ??
|
||||
canvas.graph ??
|
||||
appObj.graph) as Record<string, unknown>;
|
||||
|
||||
const extractInfo = (item: Record<string, unknown>) => ({
|
||||
id: item.id as number | string,
|
||||
constructorName: (item.constructor as { name?: string } | undefined)
|
||||
?.name,
|
||||
typeofType: typeof item.type,
|
||||
hasStringTitle: typeof item.title === "string",
|
||||
});
|
||||
|
||||
const rawGroups =
|
||||
(graph._groups as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
const rawNodes =
|
||||
(graph._nodes as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
|
||||
return {
|
||||
groups: rawGroups.map(extractInfo),
|
||||
nodes: rawNodes.map(extractInfo),
|
||||
};
|
||||
});
|
||||
|
||||
// Must have groups in this fixture
|
||||
expect(groups.length).toBeGreaterThan(0);
|
||||
|
||||
// Every group must be identifiable by isGroup logic
|
||||
for (const g of groups) {
|
||||
const identifiable =
|
||||
g.constructorName === "LGraphGroup" ||
|
||||
(g.hasStringTitle && g.typeofType !== "string");
|
||||
expect(
|
||||
identifiable,
|
||||
`Group ${String(g.id)} not identifiable: constructor=${g.constructorName}, typeofType=${g.typeofType}, hasStringTitle=${g.hasStringTitle}`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
// No node should be a false positive
|
||||
for (const n of nodes) {
|
||||
const falsePositive =
|
||||
n.hasStringTitle &&
|
||||
n.typeofType !== "string" &&
|
||||
n.constructorName !== "LGraphGroup";
|
||||
expect(
|
||||
falsePositive,
|
||||
`Node ${String(n.id)} is a false positive: constructor=${n.constructorName}, typeofType=${n.typeofType}, hasStringTitle=${n.hasStringTitle}`,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
waitForComfyUI,
|
||||
loadWorkflow,
|
||||
triggerOrganize,
|
||||
extractGraphState,
|
||||
} from "./helpers";
|
||||
import { loadFixture } from "./fixtures";
|
||||
import { SETTING_IDS } from "../../src/settings";
|
||||
|
||||
test.describe("Subgraph layout", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForComfyUI(page);
|
||||
});
|
||||
|
||||
test("organize keeps subgraph boundary nodes at the execution graph edges", async ({
|
||||
page,
|
||||
}) => {
|
||||
await loadWorkflow(page, loadFixture("subgraph-io"));
|
||||
|
||||
const state = await page.evaluate(async () => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as {
|
||||
graph?: {
|
||||
_nodes?: Array<Record<string, unknown>>;
|
||||
};
|
||||
getCurrentGraph?: () => {
|
||||
_nodes?: Array<Record<string, unknown>>;
|
||||
inputNode?: Record<string, unknown>;
|
||||
outputNode?: Record<string, unknown>;
|
||||
links?:
|
||||
| Map<number, Record<string, unknown>>
|
||||
| Record<string, Record<string, unknown>>;
|
||||
};
|
||||
openSubgraph: (
|
||||
subgraph: Record<string, unknown>,
|
||||
fromNode: Record<string, unknown>,
|
||||
) => void;
|
||||
};
|
||||
const rootGraph = (canvas.graph ??
|
||||
appObj.graph) as { _nodes?: Array<Record<string, unknown>> };
|
||||
const rootNodes = rootGraph._nodes ?? [];
|
||||
const subgraphNode = rootNodes.find((n) => !!n.subgraph);
|
||||
if (!subgraphNode?.subgraph) {
|
||||
throw new Error("No subgraph node found in fixture");
|
||||
}
|
||||
|
||||
canvas.openSubgraph(subgraphNode.subgraph as Record<string, unknown>, subgraphNode);
|
||||
const currentGraph = (canvas.getCurrentGraph?.() ??
|
||||
canvas.graph) as {
|
||||
_nodes: Array<Record<string, unknown>>;
|
||||
inputNode: Record<string, unknown>;
|
||||
outputNode: Record<string, unknown>;
|
||||
links:
|
||||
| Map<number, Record<string, unknown>>
|
||||
| Record<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
return {
|
||||
rootNodeIds: rootNodes.map((n) => n.id as number),
|
||||
subgraphNodeCount: currentGraph._nodes.length,
|
||||
beforeInputX: Number((currentGraph.inputNode.pos as ArrayLike<number>)[0]),
|
||||
beforeOutputX: Number((currentGraph.outputNode.pos as ArrayLike<number>)[0]),
|
||||
};
|
||||
});
|
||||
|
||||
expect(state.subgraphNodeCount).toBeGreaterThan(0);
|
||||
await triggerOrganize(page);
|
||||
|
||||
const after = await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as {
|
||||
graph?: {
|
||||
_nodes?: Array<Record<string, unknown>>;
|
||||
};
|
||||
getCurrentGraph?: () => {
|
||||
_nodes?: Array<Record<string, unknown>>;
|
||||
inputNode?: Record<string, unknown>;
|
||||
outputNode?: Record<string, unknown>;
|
||||
links?:
|
||||
| Map<number, Record<string, unknown>>
|
||||
| Record<string, Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
const currentGraph = (canvas.getCurrentGraph?.() ??
|
||||
canvas.graph) as {
|
||||
_nodes: Array<Record<string, unknown>>;
|
||||
inputNode: Record<string, unknown>;
|
||||
outputNode: Record<string, unknown>;
|
||||
links:
|
||||
| Map<number, Record<string, unknown>>
|
||||
| Record<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
const pack = (node: Record<string, unknown>) => {
|
||||
const pos = node.pos as ArrayLike<number>;
|
||||
const size = node.size as ArrayLike<number>;
|
||||
return {
|
||||
id: node.id as number,
|
||||
x: Number(pos[0]),
|
||||
y: Number(pos[1]),
|
||||
width: Number(size[0]),
|
||||
height: Number(size[1]),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
inputNode: pack(currentGraph.inputNode),
|
||||
outputNode: pack(currentGraph.outputNode),
|
||||
nodes: currentGraph._nodes.map(pack),
|
||||
links:
|
||||
currentGraph.links instanceof Map
|
||||
? Array.from(currentGraph.links.values()).map((link) => ({
|
||||
origin_id: link.origin_id as number,
|
||||
target_id: link.target_id as number,
|
||||
}))
|
||||
: Object.values(
|
||||
currentGraph.links as Record<string, Record<string, unknown>>,
|
||||
).map((link) => ({
|
||||
origin_id: link.origin_id as number,
|
||||
target_id: link.target_id as number,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const connectedNodeIds = new Set<number>();
|
||||
for (const link of after.links) {
|
||||
if (link.origin_id > 0) connectedNodeIds.add(link.origin_id);
|
||||
if (link.target_id > 0) connectedNodeIds.add(link.target_id);
|
||||
}
|
||||
const connectedNodes = after.nodes.filter((node) =>
|
||||
connectedNodeIds.has(node.id),
|
||||
);
|
||||
const leftmostNodeX = Math.min(...connectedNodes.map((node) => node.x));
|
||||
const rightmostNodeEdge = Math.max(
|
||||
...connectedNodes.map((node) => node.x + node.width),
|
||||
);
|
||||
|
||||
expect(after.inputNode.x).toBeLessThanOrEqual(leftmostNodeX);
|
||||
expect(after.outputNode.x + after.outputNode.width).toBeGreaterThanOrEqual(
|
||||
rightmostNodeEdge,
|
||||
);
|
||||
});
|
||||
|
||||
test("fit-to-view inside subgraph includes boundary nodes in bounds", async ({
|
||||
page,
|
||||
}) => {
|
||||
await loadWorkflow(page, loadFixture("subgraph-io"));
|
||||
|
||||
// Enable fit-to-view
|
||||
await page.evaluate((settingId: string) => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const em = appObj.extensionManager as Record<string, unknown>;
|
||||
const setting = em.setting as { set: (id: string, value: boolean) => void };
|
||||
setting.set(settingId, true);
|
||||
}, SETTING_IDS.FIT_TO_VIEW);
|
||||
|
||||
// Navigate into the subgraph
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as {
|
||||
graph?: { _nodes?: Array<Record<string, unknown>> };
|
||||
openSubgraph: (
|
||||
subgraph: Record<string, unknown>,
|
||||
fromNode: Record<string, unknown>,
|
||||
) => void;
|
||||
};
|
||||
const rootGraph = (canvas.graph ??
|
||||
appObj.graph) as { _nodes?: Array<Record<string, unknown>> };
|
||||
const subgraphNode = (rootGraph._nodes ?? []).find((n) => !!n.subgraph);
|
||||
if (!subgraphNode?.subgraph) {
|
||||
throw new Error("No subgraph node found in fixture");
|
||||
}
|
||||
canvas.openSubgraph(subgraphNode.subgraph as Record<string, unknown>, subgraphNode);
|
||||
});
|
||||
|
||||
// Organize inside the subgraph with fit-to-view enabled
|
||||
await triggerOrganize(page);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Get viewport scale and the actual node/boundary bounds
|
||||
const result = await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as Record<string, unknown> & {
|
||||
getCurrentGraph?: () => Record<string, unknown>;
|
||||
ds: { scale: number };
|
||||
};
|
||||
const graph = (canvas.getCurrentGraph?.() ??
|
||||
canvas.graph) as {
|
||||
_nodes: Array<Record<string, unknown>>;
|
||||
inputNode?: Record<string, unknown>;
|
||||
outputNode?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const bounds = { minX: Infinity, maxX: -Infinity };
|
||||
for (const node of graph._nodes) {
|
||||
const pos = node.pos as ArrayLike<number>;
|
||||
const size = node.size as ArrayLike<number>;
|
||||
const x = Number(pos[0]);
|
||||
const right = x + Number(size[0]);
|
||||
if (x < bounds.minX) bounds.minX = x;
|
||||
if (right > bounds.maxX) bounds.maxX = right;
|
||||
}
|
||||
if (graph.inputNode) {
|
||||
const pos = graph.inputNode.pos as ArrayLike<number>;
|
||||
const size = graph.inputNode.size as ArrayLike<number>;
|
||||
const x = Number(pos[0]);
|
||||
const right = x + Number(size[0]);
|
||||
if (x < bounds.minX) bounds.minX = x;
|
||||
if (right > bounds.maxX) bounds.maxX = right;
|
||||
}
|
||||
if (graph.outputNode) {
|
||||
const pos = graph.outputNode.pos as ArrayLike<number>;
|
||||
const size = graph.outputNode.size as ArrayLike<number>;
|
||||
const x = Number(pos[0]);
|
||||
const right = x + Number(size[0]);
|
||||
if (x < bounds.minX) bounds.minX = x;
|
||||
if (right > bounds.maxX) bounds.maxX = right;
|
||||
}
|
||||
|
||||
return {
|
||||
scale: canvas.ds.scale,
|
||||
totalWidth: bounds.maxX - bounds.minX,
|
||||
hasInputNode: !!graph.inputNode,
|
||||
hasOutputNode: !!graph.outputNode,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.hasInputNode).toBe(true);
|
||||
expect(result.hasOutputNode).toBe(true);
|
||||
// If boundary nodes are included in bounds, scale should be reasonable (< 2).
|
||||
// Without them, the bounds are too small and scale is too high.
|
||||
expect(result.scale).toBeLessThan(2);
|
||||
});
|
||||
|
||||
test("extractGraphState includes subgraph boundary nodes", async ({ page }) => {
|
||||
await loadWorkflow(page, loadFixture("subgraph-io"));
|
||||
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as {
|
||||
graph?: {
|
||||
_nodes?: Array<Record<string, unknown>>;
|
||||
};
|
||||
getCurrentGraph?: () => unknown;
|
||||
openSubgraph: (
|
||||
subgraph: Record<string, unknown>,
|
||||
fromNode: Record<string, unknown>,
|
||||
) => void;
|
||||
};
|
||||
const rootGraph = (canvas.graph ??
|
||||
appObj.graph) as { _nodes?: Array<Record<string, unknown>> };
|
||||
const subgraphNode = (rootGraph._nodes ?? []).find((n) => !!n.subgraph);
|
||||
if (!subgraphNode?.subgraph) {
|
||||
throw new Error("No subgraph node found in fixture");
|
||||
}
|
||||
canvas.openSubgraph(subgraphNode.subgraph as Record<string, unknown>, subgraphNode);
|
||||
});
|
||||
|
||||
const state = await extractGraphState(page);
|
||||
const nodeIds = state.nodes.map((node) => node.id);
|
||||
|
||||
expect(nodeIds).toContain(-10);
|
||||
expect(nodeIds).toContain(-20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
waitForComfyUI,
|
||||
loadWorkflow,
|
||||
extractGraphState,
|
||||
extractGroupMemberships,
|
||||
triggerOrganize,
|
||||
} from "./helpers";
|
||||
import { loadFixture } from "./fixtures";
|
||||
|
||||
test.describe("Token Layout", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForComfyUI(page);
|
||||
});
|
||||
|
||||
test("token-testing: organizes and all coordinates finite", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflow = loadFixture("token-testing");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
|
||||
const state = await extractGraphState(page);
|
||||
for (const node of state.nodes) {
|
||||
expect(Number.isFinite(node.pos[0])).toBe(true);
|
||||
expect(Number.isFinite(node.pos[1])).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("token-testing: groups sized to fit after organize", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflow = loadFixture("token-testing");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
|
||||
const state = await extractGraphState(page);
|
||||
// All groups should have positive dimensions
|
||||
for (const g of state.groups) {
|
||||
expect(g.size[0]).toBeGreaterThan(0);
|
||||
expect(g.size[1]).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("token-testing: idempotent", async ({ page }) => {
|
||||
const workflow = loadFixture("token-testing");
|
||||
await loadWorkflow(page, workflow);
|
||||
await triggerOrganize(page);
|
||||
|
||||
const state1 = await extractGraphState(page);
|
||||
|
||||
// Organize again via command
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const em = appObj.extensionManager as Record<string, unknown>;
|
||||
const command = em.command as { execute: (id: string) => void };
|
||||
command.execute("node-organizer.organize-workflow");
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const state2 = await extractGraphState(page);
|
||||
|
||||
// Positions should be identical within tolerance
|
||||
for (const n1 of state1.nodes) {
|
||||
const n2 = state2.nodes.find((n) => n.id === n1.id);
|
||||
expect(n2).toBeDefined();
|
||||
if (n2) {
|
||||
expect(Math.abs(n1.pos[0] - n2.pos[0])).toBeLessThan(2);
|
||||
expect(Math.abs(n1.pos[1] - n2.pos[1])).toBeLessThan(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("token-testing: preserves group hierarchy and direct memberships", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workflow = loadFixture("token-testing");
|
||||
await loadWorkflow(page, workflow);
|
||||
|
||||
const beforeMemberships = await extractGroupMemberships(page);
|
||||
await triggerOrganize(page);
|
||||
const afterMemberships = await extractGroupMemberships(page);
|
||||
|
||||
expect(afterMemberships).toEqual(beforeMemberships);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
waitForComfyUI,
|
||||
loadWorkflow,
|
||||
extractGraphState,
|
||||
triggerOrganize,
|
||||
triggerOrganizeGroup,
|
||||
} from "./helpers";
|
||||
import { loadFixture } from "./fixtures";
|
||||
import { SETTING_IDS } from "../../src/settings";
|
||||
|
||||
const packageVersion = JSON.parse(
|
||||
readFileSync(new URL("../../package.json", import.meta.url), "utf-8"),
|
||||
) as { version: string };
|
||||
|
||||
function didAnyNodeMove(
|
||||
before: Awaited<ReturnType<typeof extractGraphState>>,
|
||||
after: Awaited<ReturnType<typeof extractGraphState>>,
|
||||
): boolean {
|
||||
for (const node of before.nodes) {
|
||||
const next = after.nodes.find((candidate) => candidate.id === node.id);
|
||||
if (!next) continue;
|
||||
if (
|
||||
Math.abs(node.pos[0] - next.pos[0]) > 1 ||
|
||||
Math.abs(node.pos[1] - next.pos[1]) > 1
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
test.describe("UI smoke", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForComfyUI(page);
|
||||
await loadWorkflow(page, loadFixture("simple-dag"));
|
||||
});
|
||||
|
||||
test("action bar organize button runs layout", async ({ page }) => {
|
||||
const before = await extractGraphState(page);
|
||||
await page.getByRole("button", { name: "Organize" }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
const after = await extractGraphState(page);
|
||||
|
||||
expect(didAnyNodeMove(before, after)).toBe(true);
|
||||
});
|
||||
|
||||
test("Shift+O keybinding runs layout", async ({ page }) => {
|
||||
const before = await extractGraphState(page);
|
||||
await page.keyboard.press("Shift+O");
|
||||
await page.waitForTimeout(1000);
|
||||
const after = await extractGraphState(page);
|
||||
|
||||
expect(didAnyNodeMove(before, after)).toBe(true);
|
||||
});
|
||||
|
||||
test("context-aware Shift+O organizes selected groups when groups are selected", async ({ page }) => {
|
||||
await loadWorkflow(page, loadFixture("group-test-simple"));
|
||||
|
||||
const groupTitles = await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as Record<string, unknown> & {
|
||||
getCurrentGraph?: () => Record<string, unknown> | undefined;
|
||||
};
|
||||
const graph = (canvas.getCurrentGraph?.() ??
|
||||
canvas.graph ??
|
||||
appObj.graph) as { _groups: Array<{ title: string }> };
|
||||
return graph._groups.map((group) => group.title);
|
||||
});
|
||||
|
||||
expect(groupTitles.length).toBeGreaterThan(0);
|
||||
|
||||
// Select the first group programmatically
|
||||
await page.evaluate((title: string) => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as Record<string, unknown> & {
|
||||
getCurrentGraph?: () => Record<string, unknown> | undefined;
|
||||
};
|
||||
const graph = (canvas.getCurrentGraph?.() ??
|
||||
canvas.graph) as Record<string, unknown>;
|
||||
const groups = graph._groups as Array<Record<string, unknown>>;
|
||||
const group = groups.find((g) => g.title === title);
|
||||
if (!group) throw new Error(`Group "${title}" not found`);
|
||||
canvas.selectedItems = new Set([group]);
|
||||
}, groupTitles[0]);
|
||||
|
||||
const before = await extractGraphState(page);
|
||||
// Use Shift+O — should organize the selected group, not the full workflow
|
||||
await page.keyboard.press("Shift+O");
|
||||
await page.waitForTimeout(1000);
|
||||
const after = await extractGraphState(page);
|
||||
|
||||
expect(didAnyNodeMove(before, after)).toBe(true);
|
||||
});
|
||||
|
||||
test("Organize Group runs layout on the selected group", async ({ page }) => {
|
||||
await loadWorkflow(page, loadFixture("group-test-simple"));
|
||||
|
||||
const groupTitles = await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as Record<string, unknown> & {
|
||||
getCurrentGraph?: () => Record<string, unknown> | undefined;
|
||||
};
|
||||
const graph = (canvas.getCurrentGraph?.() ??
|
||||
canvas.graph ??
|
||||
appObj.graph) as { _groups: Array<{ title: string }> };
|
||||
return graph._groups.map((group) => group.title);
|
||||
});
|
||||
|
||||
expect(groupTitles.length).toBeGreaterThan(0);
|
||||
|
||||
const before = await extractGraphState(page);
|
||||
await triggerOrganizeGroup(page, groupTitles[0]);
|
||||
const after = await extractGraphState(page);
|
||||
|
||||
expect(didAnyNodeMove(before, after)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Settings panel", () => {
|
||||
async function openNodeOrganizerSettings(page: import("@playwright/test").Page): Promise<void> {
|
||||
// The sidebar settings button includes the keyboard shortcut in its label
|
||||
await page.getByRole("button", { name: /Settings \(Ctrl/ }).click();
|
||||
await page.waitForTimeout(500);
|
||||
// Navigate to our extension's settings section
|
||||
await page.getByText("Node Organizer", { exact: true }).click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForComfyUI(page);
|
||||
});
|
||||
|
||||
test("Node Organizer appears in settings sidebar", async ({ page }) => {
|
||||
await page.getByRole("button", { name: /Settings \(Ctrl/ }).click();
|
||||
await expect(
|
||||
page.getByText("Node Organizer", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("settings panel shows About section with version and homepage", async ({ page }) => {
|
||||
await openNodeOrganizerSettings(page);
|
||||
await expect(
|
||||
page.getByText(`Version ${packageVersion.version}`),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Homepage" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("settings panel shows Layout section with sliders", async ({ page }) => {
|
||||
await openNodeOrganizerSettings(page);
|
||||
await expect(page.getByText("Horizontal Gap")).toBeVisible();
|
||||
await expect(page.getByText("Vertical Gap")).toBeVisible();
|
||||
await expect(page.getByText("Group Padding")).toBeVisible();
|
||||
await expect(page.getByText("Disconnected Node Gap")).toBeVisible();
|
||||
});
|
||||
|
||||
test("settings panel shows Behavior and Keybindings sections", async ({ page }) => {
|
||||
await openNodeOrganizerSettings(page);
|
||||
await expect(
|
||||
page.getByText("Fit to View After Organize"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Keyboard Shortcuts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("settings sections appear in correct order", async ({ page }) => {
|
||||
await openNodeOrganizerSettings(page);
|
||||
|
||||
// Collect all visible text and check the relative order of section headings
|
||||
const allText = await page.locator("body").innerText();
|
||||
const sectionNames = ["About", "Layout", "Behavior", "Keybindings", "Advanced"];
|
||||
const positions = sectionNames.map((name) => ({
|
||||
name,
|
||||
pos: allText.indexOf(name),
|
||||
}));
|
||||
|
||||
// All sections should be found
|
||||
for (const { name, pos } of positions) {
|
||||
expect(pos, `Section "${name}" not found in settings panel`).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
|
||||
// Verify ordering
|
||||
for (let i = 1; i < positions.length; i++) {
|
||||
expect(
|
||||
positions[i].pos,
|
||||
`"${positions[i].name}" should appear after "${positions[i - 1].name}"`,
|
||||
).toBeGreaterThan(positions[i - 1].pos);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Fit to view after organize", () => {
|
||||
async function enableFitToView(page: import("@playwright/test").Page): Promise<void> {
|
||||
await page.evaluate((settingId: string) => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const em = appObj.extensionManager as Record<string, unknown>;
|
||||
const setting = em.setting as { set: (id: string, value: boolean) => void };
|
||||
setting.set(settingId, true);
|
||||
}, SETTING_IDS.FIT_TO_VIEW);
|
||||
}
|
||||
|
||||
async function getViewport(page: import("@playwright/test").Page) {
|
||||
return page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as Record<string, unknown>;
|
||||
const ds = canvas.ds as { offset: [number, number]; scale: number };
|
||||
return { offset: [...ds.offset], scale: ds.scale };
|
||||
});
|
||||
}
|
||||
|
||||
test("viewport changes after organizing full workflow", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForComfyUI(page);
|
||||
await loadWorkflow(page, loadFixture("simple-dag"));
|
||||
await enableFitToView(page);
|
||||
|
||||
const viewportBefore = await getViewport(page);
|
||||
|
||||
await triggerOrganize(page);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const viewportAfter = await getViewport(page);
|
||||
|
||||
const offsetChanged =
|
||||
Math.abs(viewportBefore.offset[0] - viewportAfter.offset[0]) > 1 ||
|
||||
Math.abs(viewportBefore.offset[1] - viewportAfter.offset[1]) > 1;
|
||||
const scaleChanged =
|
||||
Math.abs(viewportBefore.scale - viewportAfter.scale) > 0.01;
|
||||
|
||||
expect(offsetChanged || scaleChanged).toBe(true);
|
||||
});
|
||||
|
||||
test("fit-to-view fits entire graph, not just selected node", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForComfyUI(page);
|
||||
await loadWorkflow(page, loadFixture("simple-dag"));
|
||||
await enableFitToView(page);
|
||||
|
||||
// Select a single node (last node in the graph)
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as Record<string, unknown> & {
|
||||
getCurrentGraph?: () => Record<string, unknown> | undefined;
|
||||
};
|
||||
const graph = (canvas.getCurrentGraph?.() ??
|
||||
canvas.graph) as Record<string, unknown>;
|
||||
const nodes = graph._nodes as Array<Record<string, unknown>>;
|
||||
if (nodes.length > 0) {
|
||||
canvas.selectedItems = new Set([nodes[nodes.length - 1]]);
|
||||
}
|
||||
});
|
||||
|
||||
await triggerOrganize(page);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const viewport = await getViewport(page);
|
||||
|
||||
// With fit-to-all-items, the scale should be < 2 (fitting the whole graph).
|
||||
// If it zoomed to a single selected node, scale would be much larger (e.g. 3+).
|
||||
// This threshold catches the bug where a selected node is zoomed into.
|
||||
expect(viewport.scale).toBeLessThan(2);
|
||||
});
|
||||
|
||||
test("fit-to-view does not run for selected-group organize", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForComfyUI(page);
|
||||
await loadWorkflow(page, loadFixture("group-test-simple"));
|
||||
await enableFitToView(page);
|
||||
|
||||
// Organize full workflow first to get a stable viewport
|
||||
await triggerOrganize(page);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const viewportBefore = await getViewport(page);
|
||||
|
||||
const groupTitle = await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as Record<string, unknown> & {
|
||||
getCurrentGraph?: () => Record<string, unknown> | undefined;
|
||||
};
|
||||
const graph = (canvas.getCurrentGraph?.() ??
|
||||
canvas.graph ??
|
||||
appObj.graph) as { _groups: Array<{ title: string }> };
|
||||
return graph._groups[0]?.title ?? null;
|
||||
});
|
||||
|
||||
if (groupTitle) {
|
||||
await triggerOrganizeGroup(page, groupTitle);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const viewportAfter = await getViewport(page);
|
||||
|
||||
const offsetChanged =
|
||||
Math.abs(viewportBefore.offset[0] - viewportAfter.offset[0]) > 1 ||
|
||||
Math.abs(viewportBefore.offset[1] - viewportAfter.offset[1]) > 1;
|
||||
const scaleChanged =
|
||||
Math.abs(viewportBefore.scale - viewportAfter.scale) > 0.01;
|
||||
|
||||
expect(offsetChanged || scaleChanged).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Spacing settings affect layout", () => {
|
||||
test("custom disconnectedGap changes disconnected node placement", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForComfyUI(page);
|
||||
await loadWorkflow(page, loadFixture("simple-dag"));
|
||||
|
||||
// Organize with default settings
|
||||
await triggerOrganize(page);
|
||||
await page.waitForTimeout(1500);
|
||||
const defaultState = await extractGraphState(page);
|
||||
|
||||
// Set a very large disconnected gap
|
||||
await page.evaluate((settingId: string) => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const em = appObj.extensionManager as Record<string, unknown>;
|
||||
const setting = em.setting as { set: (id: string, value: number) => void };
|
||||
setting.set(settingId, 400);
|
||||
}, SETTING_IDS.DISCONNECTED_GAP);
|
||||
|
||||
// Re-organize with the new setting
|
||||
await triggerOrganize(page);
|
||||
await page.waitForTimeout(1500);
|
||||
const customState = await extractGraphState(page);
|
||||
|
||||
// If there are any disconnected nodes, their positions should differ
|
||||
// For workflows where all nodes are connected, positions are the same
|
||||
// This test verifies the setting is read — not that every fixture has disconnected nodes
|
||||
expect(customState.nodes.length).toBe(defaultState.nodes.length);
|
||||
|
||||
// Reset setting
|
||||
await page.evaluate((settingId: string) => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const em = appObj.extensionManager as Record<string, unknown>;
|
||||
const setting = em.setting as { set: (id: string, value: number) => void };
|
||||
setting.set(settingId, 150);
|
||||
}, SETTING_IDS.DISCONNECTED_GAP);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import {
|
||||
waitForComfyUI,
|
||||
loadWorkflow,
|
||||
triggerOrganize,
|
||||
expectGraphCanvasScreenshot,
|
||||
setBooleanSetting,
|
||||
} from "./helpers";
|
||||
import { loadFixture } from "./fixtures";
|
||||
import { SETTING_IDS } from "../../src/settings";
|
||||
|
||||
async function openFirstSubgraph(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const appObj = w.app as Record<string, unknown>;
|
||||
const canvas = appObj.canvas as {
|
||||
graph?: { _nodes?: Array<Record<string, unknown>> };
|
||||
openSubgraph: (
|
||||
subgraph: Record<string, unknown>,
|
||||
fromNode: Record<string, unknown>,
|
||||
) => void;
|
||||
};
|
||||
const rootGraph = (canvas.graph ??
|
||||
appObj.graph) as { _nodes?: Array<Record<string, unknown>> };
|
||||
const subgraphNode = (rootGraph._nodes ?? []).find((node) => !!node.subgraph);
|
||||
if (!subgraphNode?.subgraph) {
|
||||
throw new Error("No subgraph node found in fixture");
|
||||
}
|
||||
canvas.openSubgraph(subgraphNode.subgraph as Record<string, unknown>, subgraphNode);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Visual Regression", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForComfyUI(page);
|
||||
await setBooleanSetting(page, SETTING_IDS.FIT_TO_VIEW, false);
|
||||
});
|
||||
|
||||
test("simple-dag graph canvas after organize", async ({ page }) => {
|
||||
await loadWorkflow(page, loadFixture("simple-dag"));
|
||||
await triggerOrganize(page);
|
||||
await expectGraphCanvasScreenshot(page, "simple-dag-organized.png");
|
||||
});
|
||||
|
||||
test("nested-groups graph canvas after organize", async ({ page }) => {
|
||||
await loadWorkflow(page, loadFixture("nested-groups"));
|
||||
await triggerOrganize(page);
|
||||
await expectGraphCanvasScreenshot(page, "nested-groups-organized.png");
|
||||
});
|
||||
|
||||
test("token-testing graph canvas after organize", async ({ page }) => {
|
||||
await loadWorkflow(page, loadFixture("token-testing"));
|
||||
await triggerOrganize(page);
|
||||
await expectGraphCanvasScreenshot(page, "token-testing-organized.png");
|
||||
});
|
||||
|
||||
test("subgraph graph canvas after organize", async ({ page }) => {
|
||||
await loadWorkflow(page, loadFixture("subgraph-io"));
|
||||
await openFirstSubgraph(page);
|
||||
await triggerOrganize(page);
|
||||
await expectGraphCanvasScreenshot(page, "subgraph-io-organized.png");
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 85 KiB |
@@ -0,0 +1,309 @@
|
||||
{
|
||||
"id": "3d90f8be-23c2-4fd8-b76f-0db8f5975d3f",
|
||||
"revision": 0,
|
||||
"last_node_id": 8,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [40, 280],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [2, 3]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"title": "Model Loader",
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.6.0",
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [380, 120],
|
||||
"size": [425.27801513671875, 180.6060791015625],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [4]
|
||||
}
|
||||
],
|
||||
"title": "Positive Prompt",
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.6.0",
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"glowing test scene"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [380, 360],
|
||||
"size": [422.84503173828125, 164.31304931640625],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [5]
|
||||
}
|
||||
],
|
||||
"title": "Negative Prompt",
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.6.0",
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"blurry, noisy"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [410, 620],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [6]
|
||||
}
|
||||
],
|
||||
"title": "Latent Source",
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.6.0",
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "KSampler",
|
||||
"pos": [900, 280],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 6
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"title": "Sampler",
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.6.0",
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
156680208700286,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1260, 320],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"title": "Decode",
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.6.0",
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SaveImage",
|
||||
"pos": [1510, 320],
|
||||
"size": [210, 58],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"title": "Save",
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.6.0",
|
||||
"Node name for S&R": "SaveImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"spacing-settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [1780, 60],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"title": "Disconnected Note",
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.6.0",
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [256, 256, 1]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 5, 0, "MODEL"],
|
||||
[2, 1, 1, 2, 0, "CLIP"],
|
||||
[3, 1, 1, 3, 0, "CLIP"],
|
||||
[4, 2, 0, 5, 1, "CONDITIONING"],
|
||||
[5, 3, 0, 5, 2, "CONDITIONING"],
|
||||
[6, 4, 0, 5, 3, "LATENT"],
|
||||
[7, 5, 0, 6, 0, "LATENT"],
|
||||
[8, 1, 2, 6, 1, "VAE"],
|
||||
[9, 6, 0, 7, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Prompt Cluster",
|
||||
"bounding": [330, 80, 520, 470],
|
||||
"color": "#3f789e",
|
||||
"font_size": 22,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.55,
|
||||
"offset": [420, 210]
|
||||
},
|
||||
"workflowRendererVersion": "LG",
|
||||
"ue_links": [],
|
||||
"frontendVersion": "1.37.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LGraphGroup,
|
||||
LLink,
|
||||
} from "../src/layout/types";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Raw ComfyUI workflow JSON structure */
|
||||
interface WorkflowJSON {
|
||||
nodes: Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
title?: string;
|
||||
pos: [number, number];
|
||||
size: [number, number];
|
||||
flags?: { pinned?: boolean; collapsed?: boolean };
|
||||
locked?: boolean;
|
||||
order?: number;
|
||||
inputs?: Array<{ name: string; type: string; link: number | null }>;
|
||||
outputs?: Array<{ name: string; type: string; links: number[] | null }>;
|
||||
}>;
|
||||
links: Array<[number, number, number, number, number, string]>;
|
||||
groups?: Array<{
|
||||
id?: number;
|
||||
title: string;
|
||||
bounding: [number, number, number, number];
|
||||
color?: string;
|
||||
font_size?: number;
|
||||
flags?: Record<string, unknown>;
|
||||
}>;
|
||||
definitions?: {
|
||||
subgraphs?: SubgraphDefinition[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Subgraph definition from workflow JSON */
|
||||
interface SubgraphDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
inputNode?: { id: number; bounding: [number, number, number, number] };
|
||||
outputNode?: { id: number; bounding: [number, number, number, number] };
|
||||
nodes: Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
title?: string;
|
||||
pos: [number, number];
|
||||
size: [number, number];
|
||||
flags?: { pinned?: boolean; collapsed?: boolean };
|
||||
locked?: boolean;
|
||||
order?: number;
|
||||
inputs?: Array<{ name: string; type: string; link: number | null }>;
|
||||
outputs?: Array<{ name: string; type: string; links: number[] | null }>;
|
||||
}>;
|
||||
groups?: Array<{
|
||||
id?: number;
|
||||
title: string;
|
||||
bounding: [number, number, number, number];
|
||||
color?: string;
|
||||
font_size?: number;
|
||||
flags?: Record<string, unknown>;
|
||||
}>;
|
||||
links: Array<{
|
||||
id: number;
|
||||
origin_id: number;
|
||||
origin_slot: number;
|
||||
target_id: number;
|
||||
target_slot: number;
|
||||
type: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a workflow fixture and convert to LGraph format
|
||||
*/
|
||||
export function loadFixture(filename: string): LGraph {
|
||||
const fixturePath = join(__dirname, "fixtures", filename);
|
||||
const json = JSON.parse(readFileSync(fixturePath, "utf-8")) as WorkflowJSON;
|
||||
return convertWorkflowToLGraph(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a subgraph fixture (extracts first subgraph from workflow JSON)
|
||||
* Returns LGraph with inputNode/outputNode in runtime format (pos/size)
|
||||
*/
|
||||
export function loadSubgraphFixture(filename: string): LGraph {
|
||||
const fixturePath = join(__dirname, "fixtures", filename);
|
||||
const json = JSON.parse(readFileSync(fixturePath, "utf-8")) as WorkflowJSON;
|
||||
|
||||
const subgraph = json.definitions?.subgraphs?.[0];
|
||||
if (!subgraph) {
|
||||
throw new Error(`No subgraph found in ${filename}`);
|
||||
}
|
||||
|
||||
return convertSubgraphToLGraph(subgraph);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert subgraph definition to LGraph format with I/O nodes
|
||||
*/
|
||||
function convertSubgraphToLGraph(subgraph: SubgraphDefinition): LGraph {
|
||||
// Convert nodes
|
||||
const nodes: LGraphNode[] = subgraph.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
title: n.title ?? n.type,
|
||||
pos: [...n.pos],
|
||||
size: [...n.size],
|
||||
inputs:
|
||||
n.inputs?.map((i) => ({
|
||||
name: i.name,
|
||||
type: i.type,
|
||||
link: i.link,
|
||||
})) ?? [],
|
||||
outputs:
|
||||
n.outputs?.map((o) => ({
|
||||
name: o.name,
|
||||
type: o.type,
|
||||
links: o.links ? [...o.links] : null,
|
||||
})) ?? [],
|
||||
flags: n.flags ? { ...n.flags } : undefined,
|
||||
locked: n.locked,
|
||||
order: n.order,
|
||||
}));
|
||||
|
||||
// Convert links from object array format to Map
|
||||
const links = new Map<number, LLink>();
|
||||
for (const link of subgraph.links) {
|
||||
links.set(link.id, {
|
||||
id: link.id,
|
||||
origin_id: link.origin_id,
|
||||
origin_slot: link.origin_slot,
|
||||
target_id: link.target_id,
|
||||
target_slot: link.target_slot,
|
||||
type: link.type,
|
||||
});
|
||||
}
|
||||
|
||||
// Convert groups
|
||||
const groups: LGraphGroup[] = (subgraph.groups ?? []).map((g, idx) => ({
|
||||
id: g.id ?? idx,
|
||||
title: g.title,
|
||||
pos: [g.bounding[0], g.bounding[1]],
|
||||
size: [g.bounding[2], g.bounding[3]],
|
||||
}));
|
||||
|
||||
// Create graph with I/O nodes in runtime format (pos/size from bounding)
|
||||
const graph: LGraph = {
|
||||
_nodes: nodes,
|
||||
_groups: groups,
|
||||
links,
|
||||
};
|
||||
|
||||
if (subgraph.inputNode) {
|
||||
const [x, y, w, h] = subgraph.inputNode.bounding;
|
||||
graph.inputNode = {
|
||||
id: subgraph.inputNode.id,
|
||||
pos: [x, y],
|
||||
size: [w, h],
|
||||
};
|
||||
}
|
||||
|
||||
if (subgraph.outputNode) {
|
||||
const [x, y, w, h] = subgraph.outputNode.bounding;
|
||||
graph.outputNode = {
|
||||
id: subgraph.outputNode.id,
|
||||
pos: [x, y],
|
||||
size: [w, h],
|
||||
};
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ComfyUI workflow JSON to LGraph format for layout algorithm
|
||||
*/
|
||||
export function convertWorkflowToLGraph(workflow: WorkflowJSON): LGraph {
|
||||
// Convert nodes
|
||||
const nodes: LGraphNode[] = workflow.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
title: n.title ?? n.type,
|
||||
pos: [...n.pos],
|
||||
size: [...n.size],
|
||||
inputs:
|
||||
n.inputs?.map((i) => ({
|
||||
name: i.name,
|
||||
type: i.type,
|
||||
link: i.link,
|
||||
})) ?? [],
|
||||
outputs:
|
||||
n.outputs?.map((o) => ({
|
||||
name: o.name,
|
||||
type: o.type,
|
||||
links: o.links ? [...o.links] : null,
|
||||
})) ?? [],
|
||||
flags: n.flags ? { ...n.flags } : undefined,
|
||||
locked: n.locked,
|
||||
order: n.order,
|
||||
}));
|
||||
|
||||
// Convert links: [linkId, originId, originSlot, targetId, targetSlot, type]
|
||||
const links = new Map<number, LLink>();
|
||||
for (const [
|
||||
linkId,
|
||||
originId,
|
||||
originSlot,
|
||||
targetId,
|
||||
targetSlot,
|
||||
type,
|
||||
] of workflow.links) {
|
||||
links.set(linkId, {
|
||||
id: linkId,
|
||||
origin_id: originId,
|
||||
origin_slot: originSlot,
|
||||
target_id: targetId,
|
||||
target_slot: targetSlot,
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
// Convert groups
|
||||
const groups: LGraphGroup[] = (workflow.groups ?? []).map((g, idx) => ({
|
||||
id: g.id ?? idx,
|
||||
title: g.title,
|
||||
pos: [g.bounding[0], g.bounding[1]],
|
||||
size: [g.bounding[2], g.bounding[3]],
|
||||
}));
|
||||
|
||||
return {
|
||||
_nodes: nodes,
|
||||
_groups: groups,
|
||||
links,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture current positions of all nodes, groups, and I/O nodes for comparison
|
||||
*/
|
||||
export function capturePositions(
|
||||
graph: LGraph
|
||||
): Map<string, { x: number; y: number; width: number; height: number }> {
|
||||
const positions = new Map<
|
||||
string,
|
||||
{ x: number; y: number; width: number; height: number }
|
||||
>();
|
||||
|
||||
for (const node of graph._nodes) {
|
||||
positions.set(`node_${node.id}`, {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1],
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of graph._groups) {
|
||||
positions.set(`group_${group.id}`, {
|
||||
x: group.pos[0],
|
||||
y: group.pos[1],
|
||||
width: group.size[0],
|
||||
height: group.size[1],
|
||||
});
|
||||
}
|
||||
|
||||
// Capture I/O node positions if present
|
||||
if (graph.inputNode?.pos && graph.inputNode?.size) {
|
||||
positions.set(`io_${graph.inputNode.id}`, {
|
||||
x: graph.inputNode.pos[0],
|
||||
y: graph.inputNode.pos[1],
|
||||
width: graph.inputNode.size[0],
|
||||
height: graph.inputNode.size[1],
|
||||
});
|
||||
}
|
||||
if (graph.outputNode?.pos && graph.outputNode?.size) {
|
||||
positions.set(`io_${graph.outputNode.id}`, {
|
||||
x: graph.outputNode.pos[0],
|
||||
y: graph.outputNode.pos[1],
|
||||
width: graph.outputNode.size[0],
|
||||
height: graph.outputNode.size[1],
|
||||
});
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get links as an iterable array
|
||||
*/
|
||||
function getLinksArray(links: LGraph["links"]): LLink[] {
|
||||
if (links instanceof Map) {
|
||||
return Array.from(links.values());
|
||||
}
|
||||
if (Array.isArray(links)) {
|
||||
return links;
|
||||
}
|
||||
return Object.values(links);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone a graph for idempotency testing
|
||||
* Preserves I/O nodes if present
|
||||
*/
|
||||
export function cloneGraph(graph: LGraph): LGraph {
|
||||
const linksArr = getLinksArray(graph.links);
|
||||
|
||||
const cloned = convertWorkflowToLGraph({
|
||||
nodes: graph._nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
title: n.title,
|
||||
pos: [...n.pos],
|
||||
size: [...n.size],
|
||||
flags: n.flags ? { ...n.flags } : undefined,
|
||||
locked: n.locked,
|
||||
order: n.order,
|
||||
inputs: n.inputs?.map((i) => ({ ...i })),
|
||||
outputs: n.outputs?.map((o) => ({
|
||||
...o,
|
||||
links: o.links ? [...o.links] : null,
|
||||
})),
|
||||
})),
|
||||
links: linksArr.map((l) => [
|
||||
l.id,
|
||||
l.origin_id,
|
||||
l.origin_slot,
|
||||
l.target_id,
|
||||
l.target_slot,
|
||||
l.type,
|
||||
]),
|
||||
groups: graph._groups.map((g) => ({
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
bounding: [g.pos[0], g.pos[1], g.size[0], g.size[1]],
|
||||
})),
|
||||
});
|
||||
|
||||
// Preserve I/O nodes if present
|
||||
if (graph.inputNode) {
|
||||
cloned.inputNode = {
|
||||
id: graph.inputNode.id,
|
||||
pos: [...graph.inputNode.pos],
|
||||
size: [...graph.inputNode.size],
|
||||
};
|
||||
}
|
||||
|
||||
if (graph.outputNode) {
|
||||
cloned.outputNode = {
|
||||
id: graph.outputNode.id,
|
||||
pos: [...graph.outputNode.pos],
|
||||
size: [...graph.outputNode.size],
|
||||
};
|
||||
}
|
||||
|
||||
return cloned;
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { describe, it, beforeEach, expect } from "vitest";
|
||||
import { loadFixture, loadSubgraphFixture, cloneGraph } from "../helpers";
|
||||
import { layoutGraph } from "../../src/layout/index";
|
||||
import {
|
||||
assertNoOverlaps,
|
||||
assertNodesInsideGroups,
|
||||
assertFiniteCoordinates,
|
||||
assertTopologicalOrder,
|
||||
assertIdempotent,
|
||||
assertGroupMembershipPreserved,
|
||||
} from "../invariants";
|
||||
import type { LGraph } from "../../src/layout/types";
|
||||
|
||||
/**
|
||||
* Regression tests for the layout algorithm
|
||||
* Each fixture is run through all invariants to catch regressions
|
||||
*/
|
||||
describe("layoutGraph regression tests", () => {
|
||||
// Fixtures that currently pass all tests
|
||||
const passingFixtures = [
|
||||
"simple-dag.json", // 7 nodes, no groups, basic txt2img DAG
|
||||
"nested-groups.json", // Nested groups with node inside inner group
|
||||
"nested-wrapper.json", // Group Wrapper containing nested groups
|
||||
"complex-parallel.json", // 26 nodes, parallel KSampler High/Low, 5 groups
|
||||
];
|
||||
|
||||
for (const fixture of passingFixtures) {
|
||||
describe(fixture, () => {
|
||||
let graph: LGraph;
|
||||
let originalGraph: LGraph;
|
||||
|
||||
beforeEach(() => {
|
||||
graph = loadFixture(fixture);
|
||||
originalGraph = cloneGraph(graph); // Capture state before layout
|
||||
layoutGraph(graph);
|
||||
});
|
||||
|
||||
it("has no overlapping entities", () => {
|
||||
assertNoOverlaps(graph);
|
||||
});
|
||||
|
||||
it("keeps nodes inside their groups", () => {
|
||||
assertNodesInsideGroups(graph);
|
||||
});
|
||||
|
||||
it("has finite coordinates", () => {
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("preserves topological order", () => {
|
||||
assertTopologicalOrder(graph);
|
||||
});
|
||||
|
||||
it("is idempotent (stable after multiple runs)", () => {
|
||||
assertIdempotent(graph);
|
||||
});
|
||||
|
||||
it("preserves group membership", () => {
|
||||
assertGroupMembershipPreserved(originalGraph, graph);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Subgraph fixture tests (with I/O nodes)
|
||||
* Uses loadSubgraphFixture to extract subgraph from workflow JSON
|
||||
*/
|
||||
describe("subgraph layout tests", () => {
|
||||
const subgraphFixtures = [
|
||||
"subgraph-io.json", // Subgraph with I/O nodes, 12 nodes, 4 groups
|
||||
];
|
||||
|
||||
for (const fixture of subgraphFixtures) {
|
||||
describe(fixture, () => {
|
||||
let graph: LGraph;
|
||||
let originalGraph: LGraph;
|
||||
|
||||
beforeEach(() => {
|
||||
graph = loadSubgraphFixture(fixture);
|
||||
originalGraph = cloneGraph(graph);
|
||||
layoutGraph(graph);
|
||||
});
|
||||
|
||||
it("has no overlapping entities (including I/O nodes)", () => {
|
||||
assertNoOverlaps(graph);
|
||||
});
|
||||
|
||||
it("keeps nodes inside their groups", () => {
|
||||
assertNodesInsideGroups(graph);
|
||||
});
|
||||
|
||||
it("has finite coordinates", () => {
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("preserves topological order", () => {
|
||||
assertTopologicalOrder(graph);
|
||||
});
|
||||
|
||||
it("is idempotent (stable after multiple runs)", () => {
|
||||
assertIdempotent(graph);
|
||||
});
|
||||
|
||||
it("preserves group membership", () => {
|
||||
assertGroupMembershipPreserved(originalGraph, graph);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("layout algorithm properties", () => {
|
||||
it("handles empty graph", () => {
|
||||
const graph: LGraph = {
|
||||
_nodes: [],
|
||||
_groups: [],
|
||||
links: new Map(),
|
||||
};
|
||||
|
||||
const result = layoutGraph(graph);
|
||||
|
||||
expect(result.nodeCount).toBe(0);
|
||||
expect(result.layerCount).toBe(0);
|
||||
});
|
||||
|
||||
it("handles single node", () => {
|
||||
const graph: LGraph = {
|
||||
_nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: "Note",
|
||||
title: "Test",
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
],
|
||||
_groups: [],
|
||||
links: new Map(),
|
||||
};
|
||||
|
||||
const result = layoutGraph(graph);
|
||||
|
||||
expect(result.nodeCount).toBe(1);
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("handles disconnected nodes", () => {
|
||||
const graph: LGraph = {
|
||||
_nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: "Note",
|
||||
title: "Note 1",
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "Note",
|
||||
title: "Note 2",
|
||||
pos: [500, 500],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
],
|
||||
_groups: [],
|
||||
links: new Map(),
|
||||
};
|
||||
|
||||
layoutGraph(graph);
|
||||
|
||||
assertNoOverlaps(graph);
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
});
|
||||
@@ -1,931 +0,0 @@
|
||||
import { describe, it, beforeEach, expect } from "vitest";
|
||||
import { loadFixture, capturePositions } from "../helpers";
|
||||
import { layoutSelectedGroups } from "../../src/layout/selected-groups";
|
||||
import { parseLayoutToken } from "../../src/layout/title-tokens";
|
||||
import {
|
||||
assertNodesInsideGroups,
|
||||
assertFiniteCoordinates,
|
||||
} from "../invariants";
|
||||
import type { LGraph } from "../../src/layout/types";
|
||||
|
||||
/**
|
||||
* Tests for layoutSelectedGroups function
|
||||
* Note: External overlaps are acceptable per requirements
|
||||
*/
|
||||
describe("layoutSelectedGroups", () => {
|
||||
describe("single group with internal connections", () => {
|
||||
let graph: LGraph;
|
||||
|
||||
beforeEach(() => {
|
||||
// nested-groups.json has groups with internal connections
|
||||
// Group 1 "Step 1 - Load model" contains node 4 (CheckpointLoaderSimple)
|
||||
graph = loadFixture("nested-groups.json");
|
||||
});
|
||||
|
||||
it("organizes nodes within selected group", () => {
|
||||
// Select group 1 which contains node 4
|
||||
const result = layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
expect(result.nodeCount).toBeGreaterThanOrEqual(0);
|
||||
expect(result.groupCount).toBe(1);
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("resizes group to fit contents", () => {
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
const groupAfter = graph._groups.find((g) => g.id === 1);
|
||||
// Group size should be finite and positive
|
||||
expect(Number.isFinite(groupAfter!.size[0])).toBe(true);
|
||||
expect(Number.isFinite(groupAfter!.size[1])).toBe(true);
|
||||
expect(groupAfter!.size[0]).toBeGreaterThan(0);
|
||||
expect(groupAfter!.size[1]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("preserves external node positions", () => {
|
||||
// Capture positions of nodes NOT in group 1
|
||||
const nodesBefore = new Map<number, [number, number]>();
|
||||
for (const node of graph._nodes) {
|
||||
// Node 4 is in group 1, all others should be unchanged
|
||||
if (node.id !== 4) {
|
||||
nodesBefore.set(node.id, [...node.pos]);
|
||||
}
|
||||
}
|
||||
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// Verify external nodes haven't moved
|
||||
for (const [nodeId, posBefore] of nodesBefore) {
|
||||
const node = graph._nodes.find((n) => n.id === nodeId);
|
||||
expect(node).toBeDefined();
|
||||
expect(node!.pos[0]).toBe(posBefore[0]);
|
||||
expect(node!.pos[1]).toBe(posBefore[1]);
|
||||
}
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
const positions1 = capturePositions(graph);
|
||||
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
const positions2 = capturePositions(graph);
|
||||
|
||||
// Compare positions with small tolerance
|
||||
for (const [id, pos1] of positions1) {
|
||||
const pos2 = positions2.get(id);
|
||||
if (pos2) {
|
||||
expect(Math.abs(pos1.x - pos2.x)).toBeLessThan(5);
|
||||
expect(Math.abs(pos1.y - pos2.y)).toBeLessThan(5);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("single group with disconnected nodes", () => {
|
||||
let graph: LGraph;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a graph with a group containing disconnected nodes
|
||||
graph = {
|
||||
_nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: "Note",
|
||||
title: "Note 1",
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "Note",
|
||||
title: "Note 2",
|
||||
pos: [150, 250],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: "Note",
|
||||
title: "External Node",
|
||||
pos: [500, 500],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
],
|
||||
_groups: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Test Group",
|
||||
pos: [50, 50],
|
||||
size: [350, 350],
|
||||
},
|
||||
],
|
||||
links: new Map(),
|
||||
};
|
||||
});
|
||||
|
||||
it("stacks disconnected nodes vertically", () => {
|
||||
const result = layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
expect(result.nodeCount).toBe(2);
|
||||
expect(result.groupCount).toBe(1);
|
||||
|
||||
// Nodes should have same X (stacked vertically)
|
||||
const node1 = graph._nodes.find((n) => n.id === 1)!;
|
||||
const node2 = graph._nodes.find((n) => n.id === 2)!;
|
||||
expect(node1.pos[0]).toBe(node2.pos[0]);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("resizes group to fit stacked nodes", () => {
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
const group = graph._groups.find((g) => g.id === 1)!;
|
||||
expect(Number.isFinite(group.size[0])).toBe(true);
|
||||
expect(Number.isFinite(group.size[1])).toBe(true);
|
||||
expect(group.size[0]).toBeGreaterThan(0);
|
||||
expect(group.size[1]).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple groups", () => {
|
||||
let graph: LGraph;
|
||||
|
||||
beforeEach(() => {
|
||||
graph = loadFixture("nested-groups.json");
|
||||
});
|
||||
|
||||
it("organizes each group independently", () => {
|
||||
// Select groups 1 and 2 (not nested)
|
||||
const result = layoutSelectedGroups(graph, new Set([1, 2]));
|
||||
|
||||
expect(result.groupCount).toBeGreaterThanOrEqual(2);
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("handles non-overlapping groups", () => {
|
||||
layoutSelectedGroups(graph, new Set([1, 2]));
|
||||
|
||||
// Both groups should have valid coordinates
|
||||
const group1 = graph._groups.find((g) => g.id === 1)!;
|
||||
const group2 = graph._groups.find((g) => g.id === 2)!;
|
||||
|
||||
expect(Number.isFinite(group1.pos[0])).toBe(true);
|
||||
expect(Number.isFinite(group2.pos[0])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nested groups", () => {
|
||||
let graph: LGraph;
|
||||
|
||||
beforeEach(() => {
|
||||
// nested-groups.json has Group 3 containing Group 4 (nested)
|
||||
graph = loadFixture("nested-groups.json");
|
||||
});
|
||||
|
||||
it("auto-includes nested groups when parent selected", () => {
|
||||
// Select group 3 which contains group 4
|
||||
const result = layoutSelectedGroups(graph, new Set([3]));
|
||||
|
||||
// Should include the nested group
|
||||
expect(result.groupCount).toBeGreaterThanOrEqual(1);
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("positions nested groups correctly", () => {
|
||||
layoutSelectedGroups(graph, new Set([3]));
|
||||
|
||||
// Group 4 should still be inside Group 3
|
||||
const group3 = graph._groups.find((g) => g.id === 3)!;
|
||||
const group4 = graph._groups.find((g) => g.id === 4)!;
|
||||
|
||||
// Check group 4 is within group 3 bounds
|
||||
expect(group4.pos[0]).toBeGreaterThanOrEqual(group3.pos[0]);
|
||||
expect(group4.pos[1]).toBeGreaterThanOrEqual(group3.pos[1]);
|
||||
expect(group4.pos[0] + group4.size[0]).toBeLessThanOrEqual(
|
||||
group3.pos[0] + group3.size[0]
|
||||
);
|
||||
expect(group4.pos[1] + group4.size[1]).toBeLessThanOrEqual(
|
||||
group3.pos[1] + group3.size[1]
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps nodes inside selected groups", () => {
|
||||
layoutSelectedGroups(graph, new Set([3]));
|
||||
|
||||
// Nodes inside group 3 should remain inside it
|
||||
assertNodesInsideGroups(graph);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles empty graph", () => {
|
||||
const graph: LGraph = {
|
||||
_nodes: [],
|
||||
_groups: [],
|
||||
links: new Map(),
|
||||
};
|
||||
|
||||
const result = layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
expect(result.nodeCount).toBe(0);
|
||||
expect(result.groupCount).toBe(0);
|
||||
});
|
||||
|
||||
it("handles non-existent group IDs", () => {
|
||||
const graph = loadFixture("nested-groups.json");
|
||||
|
||||
const result = layoutSelectedGroups(graph, new Set([999]));
|
||||
|
||||
expect(result.groupCount).toBe(0);
|
||||
});
|
||||
|
||||
it("handles empty groups", () => {
|
||||
const graph: LGraph = {
|
||||
_nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: "Note",
|
||||
title: "External",
|
||||
pos: [500, 500],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
],
|
||||
_groups: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Empty Group",
|
||||
pos: [50, 50],
|
||||
size: [200, 200],
|
||||
},
|
||||
],
|
||||
links: new Map(),
|
||||
};
|
||||
|
||||
// Should not crash
|
||||
const result = layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// Empty group is still processed, just has no content
|
||||
expect(result.nodeCount).toBe(0);
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("handles locked nodes", () => {
|
||||
const graph: LGraph = {
|
||||
_nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: "Note",
|
||||
title: "Locked Node",
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "Note",
|
||||
title: "Normal Node",
|
||||
pos: [100, 250],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
],
|
||||
_groups: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Test Group",
|
||||
pos: [50, 50],
|
||||
size: [350, 400],
|
||||
},
|
||||
],
|
||||
links: new Map(),
|
||||
};
|
||||
|
||||
const lockedPosBefore = [...graph._nodes[0].pos];
|
||||
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// Locked node should not move
|
||||
expect(graph._nodes[0].pos[0]).toBe(lockedPosBefore[0]);
|
||||
expect(graph._nodes[0].pos[1]).toBe(lockedPosBefore[1]);
|
||||
});
|
||||
|
||||
it("handles group with only locked nodes", () => {
|
||||
const graph: LGraph = {
|
||||
_nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: "Note",
|
||||
title: "Locked Node",
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
_groups: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Test Group",
|
||||
pos: [50, 50],
|
||||
size: [350, 250],
|
||||
},
|
||||
],
|
||||
links: new Map(),
|
||||
};
|
||||
|
||||
// Should not crash
|
||||
const result = layoutSelectedGroups(graph, new Set([1]));
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("groups with internal connections", () => {
|
||||
it("uses layer-based layout for connected nodes", () => {
|
||||
const graph: LGraph = {
|
||||
_nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: "NodeA",
|
||||
title: "Node A",
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [{ name: "out", type: "FLOAT", links: [1] }],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "NodeB",
|
||||
title: "Node B",
|
||||
pos: [100, 300],
|
||||
size: [200, 100],
|
||||
inputs: [{ name: "in", type: "FLOAT", link: 1 }],
|
||||
outputs: [],
|
||||
},
|
||||
],
|
||||
_groups: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Test Group",
|
||||
pos: [50, 50],
|
||||
size: [400, 400],
|
||||
},
|
||||
],
|
||||
links: new Map([
|
||||
[
|
||||
1,
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: "FLOAT",
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// With layer-based layout, source (node 1) should be left of target (node 2)
|
||||
const node1 = graph._nodes.find((n) => n.id === 1)!;
|
||||
const node2 = graph._nodes.find((n) => n.id === 2)!;
|
||||
expect(node1.pos[0]).toBeLessThan(node2.pos[0]);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLayoutToken", () => {
|
||||
it("parses [HORIZONTAL] token", () => {
|
||||
expect(parseLayoutToken("My Group [HORIZONTAL]")).toEqual({
|
||||
type: "horizontal",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses [VERTICAL] token", () => {
|
||||
expect(parseLayoutToken("[VERTICAL] Group")).toEqual({ type: "vertical" });
|
||||
});
|
||||
|
||||
it("parses [xROW] tokens", () => {
|
||||
expect(parseLayoutToken("Group [2ROW]")).toEqual({ type: "rows", count: 2 });
|
||||
expect(parseLayoutToken("[3ROW] Test")).toEqual({ type: "rows", count: 3 });
|
||||
});
|
||||
|
||||
it("parses [xCOL] tokens", () => {
|
||||
expect(parseLayoutToken("Group [2COL]")).toEqual({
|
||||
type: "columns",
|
||||
count: 2,
|
||||
});
|
||||
expect(parseLayoutToken("[4COL] Test")).toEqual({
|
||||
type: "columns",
|
||||
count: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats [1ROW] as horizontal", () => {
|
||||
expect(parseLayoutToken("Group [1ROW]")).toEqual({ type: "horizontal" });
|
||||
});
|
||||
|
||||
it("treats [1COL] as vertical", () => {
|
||||
expect(parseLayoutToken("Group [1COL]")).toEqual({ type: "vertical" });
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(parseLayoutToken("[horizontal]")).toEqual({ type: "horizontal" });
|
||||
expect(parseLayoutToken("[Vertical]")).toEqual({ type: "vertical" });
|
||||
expect(parseLayoutToken("[2row]")).toEqual({ type: "rows", count: 2 });
|
||||
expect(parseLayoutToken("[3Col]")).toEqual({ type: "columns", count: 3 });
|
||||
});
|
||||
|
||||
it("returns default for no token", () => {
|
||||
expect(parseLayoutToken("Normal Group")).toEqual({ type: "default" });
|
||||
expect(parseLayoutToken("")).toEqual({ type: "default" });
|
||||
});
|
||||
|
||||
it("returns first matching token", () => {
|
||||
// HORIZONTAL comes before xROW in parsing order
|
||||
expect(parseLayoutToken("[HORIZONTAL] [2ROW]")).toEqual({
|
||||
type: "horizontal",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("groups with layout tokens", () => {
|
||||
function createTestGraph(groupTitle: string): LGraph {
|
||||
return {
|
||||
_nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: "Note",
|
||||
title: "Node 1",
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "Note",
|
||||
title: "Node 2",
|
||||
pos: [100, 250],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: "Note",
|
||||
title: "Node 3",
|
||||
pos: [100, 400],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: "Note",
|
||||
title: "Node 4",
|
||||
pos: [100, 550],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
],
|
||||
_groups: [
|
||||
{
|
||||
id: 1,
|
||||
title: groupTitle,
|
||||
pos: [50, 50],
|
||||
size: [500, 700],
|
||||
},
|
||||
],
|
||||
links: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
it("[HORIZONTAL] arranges nodes in single row", () => {
|
||||
const graph = createTestGraph("Test [HORIZONTAL]");
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// All nodes should have same Y (single row)
|
||||
const ys = graph._nodes.map((n) => n.pos[1]);
|
||||
expect(new Set(ys).size).toBe(1);
|
||||
|
||||
// Nodes should be spread horizontally
|
||||
const xs = graph._nodes.map((n) => n.pos[0]).sort((a, b) => a - b);
|
||||
expect(xs[3]).toBeGreaterThan(xs[0]);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("[VERTICAL] arranges nodes in single column", () => {
|
||||
const graph = createTestGraph("Test [VERTICAL]");
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// All nodes should have same X (single column)
|
||||
const xs = graph._nodes.map((n) => n.pos[0]);
|
||||
expect(new Set(xs).size).toBe(1);
|
||||
|
||||
// Nodes should be spread vertically
|
||||
const ys = graph._nodes.map((n) => n.pos[1]).sort((a, b) => a - b);
|
||||
expect(ys[3]).toBeGreaterThan(ys[0]);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("[2ROW] distributes nodes into 2 rows", () => {
|
||||
const graph = createTestGraph("Test [2ROW]");
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// Should have exactly 2 distinct Y values (2 rows)
|
||||
const ys = new Set(graph._nodes.map((n) => n.pos[1]));
|
||||
expect(ys.size).toBe(2);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("[3COL] distributes nodes into 3 columns", () => {
|
||||
const graph = createTestGraph("Test [3COL]");
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// Should have 3 distinct X values (4 nodes into 3 cols = 2,1,1 distribution)
|
||||
const xs = new Set(graph._nodes.map((n) => n.pos[0]));
|
||||
expect(xs.size).toBe(3);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("lowercase token works", () => {
|
||||
const graph = createTestGraph("Test [horizontal]");
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// All nodes should have same Y (single row)
|
||||
const ys = graph._nodes.map((n) => n.pos[1]);
|
||||
expect(new Set(ys).size).toBe(1);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("ignores DAG structure with token", () => {
|
||||
// Create graph with connected nodes
|
||||
const graph: LGraph = {
|
||||
_nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: "NodeA",
|
||||
title: "Node A",
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [{ name: "out", type: "FLOAT", links: [1] }],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "NodeB",
|
||||
title: "Node B",
|
||||
pos: [100, 300],
|
||||
size: [200, 100],
|
||||
inputs: [{ name: "in", type: "FLOAT", link: 1 }],
|
||||
outputs: [],
|
||||
},
|
||||
],
|
||||
_groups: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Test [HORIZONTAL]",
|
||||
pos: [50, 50],
|
||||
size: [500, 400],
|
||||
},
|
||||
],
|
||||
links: new Map([
|
||||
[
|
||||
1,
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: "FLOAT",
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// With [HORIZONTAL], both nodes should have same Y regardless of connection
|
||||
const node1 = graph._nodes.find((n) => n.id === 1)!;
|
||||
const node2 = graph._nodes.find((n) => n.id === 2)!;
|
||||
expect(node1.pos[1]).toBe(node2.pos[1]);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("nested groups respect their own tokens", () => {
|
||||
const graph: LGraph = {
|
||||
_nodes: [
|
||||
// Nodes in outer group (not in inner)
|
||||
{
|
||||
id: 1,
|
||||
type: "Note",
|
||||
title: "Outer 1",
|
||||
pos: [100, 100],
|
||||
size: [100, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "Note",
|
||||
title: "Outer 2",
|
||||
pos: [100, 250],
|
||||
size: [100, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
// Nodes in inner group
|
||||
{
|
||||
id: 3,
|
||||
type: "Note",
|
||||
title: "Inner 1",
|
||||
pos: [350, 150],
|
||||
size: [100, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: "Note",
|
||||
title: "Inner 2",
|
||||
pos: [350, 300],
|
||||
size: [100, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
],
|
||||
_groups: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Outer [HORIZONTAL]",
|
||||
pos: [50, 50],
|
||||
size: [600, 500],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Inner [VERTICAL]",
|
||||
pos: [300, 100],
|
||||
size: [200, 350],
|
||||
},
|
||||
],
|
||||
links: new Map(),
|
||||
};
|
||||
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// Inner group nodes should have same X (vertical)
|
||||
const inner1 = graph._nodes.find((n) => n.id === 3)!;
|
||||
const inner2 = graph._nodes.find((n) => n.id === 4)!;
|
||||
expect(inner1.pos[0]).toBe(inner2.pos[0]);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("resizes group to fit token-arranged contents", () => {
|
||||
const graph = createTestGraph("Test [HORIZONTAL]");
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
const group = graph._groups.find((g) => g.id === 1)!;
|
||||
|
||||
// Group should be wider than tall (horizontal layout)
|
||||
// With 4 nodes of 200px width + gaps, width should be significant
|
||||
expect(group.size[0]).toBeGreaterThan(600);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nested group + disconnected nodes overlap regression (group-test-simple.json)", () => {
|
||||
it("positions nested groups below disconnected nodes (no token)", () => {
|
||||
const graph = loadFixture("group-test-simple.json");
|
||||
layoutSelectedGroups(graph, new Set([1])); // "Group Wrapper"
|
||||
|
||||
const innerGroup = graph._groups.find((g) => g.id === 2)!;
|
||||
const node3 = graph._nodes.find((n) => n.id === 3)!;
|
||||
const node4 = graph._nodes.find((n) => n.id === 4)!;
|
||||
|
||||
// Inner group should be BELOW the preview images
|
||||
const node3Bottom = node3.pos[1] + node3.size[1];
|
||||
const node4Bottom = node4.pos[1] + node4.size[1];
|
||||
const membersBottom = Math.max(node3Bottom, node4Bottom);
|
||||
expect(innerGroup.pos[1]).toBeGreaterThan(membersBottom);
|
||||
});
|
||||
|
||||
it("maintains no overlaps after default layout", () => {
|
||||
const graph = loadFixture("group-test-simple.json");
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
assertNodesInsideGroups(graph);
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("is idempotent with nested groups in default mode", () => {
|
||||
const graph = loadFixture("group-test-simple.json");
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
const positions1 = capturePositions(graph);
|
||||
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
const positions2 = capturePositions(graph);
|
||||
|
||||
// Compare positions with small tolerance
|
||||
for (const [id, pos1] of positions1) {
|
||||
const pos2 = positions2.get(id);
|
||||
if (pos2) {
|
||||
expect(Math.abs(pos1.x - pos2.x)).toBeLessThan(5);
|
||||
expect(Math.abs(pos1.y - pos2.y)).toBeLessThan(5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("multiple sibling nested groups stack vertically", () => {
|
||||
const graph: LGraph = {
|
||||
_nodes: [
|
||||
// Disconnected node in parent
|
||||
{
|
||||
id: 1,
|
||||
type: "Note",
|
||||
title: "Parent Node",
|
||||
pos: [100, 100] as [number, number],
|
||||
size: [200, 100] as [number, number],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
// Node in first nested group
|
||||
{
|
||||
id: 2,
|
||||
type: "Note",
|
||||
title: "Nested 1 Node",
|
||||
pos: [100, 300] as [number, number],
|
||||
size: [200, 100] as [number, number],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
// Node in second nested group
|
||||
{
|
||||
id: 3,
|
||||
type: "Note",
|
||||
title: "Nested 2 Node",
|
||||
pos: [100, 500] as [number, number],
|
||||
size: [200, 100] as [number, number],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
],
|
||||
_groups: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Parent Group",
|
||||
pos: [50, 50] as [number, number],
|
||||
size: [400, 600] as [number, number],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Nested 1",
|
||||
pos: [80, 250] as [number, number],
|
||||
size: [300, 180] as [number, number],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Nested 2",
|
||||
pos: [80, 450] as [number, number],
|
||||
size: [300, 180] as [number, number],
|
||||
},
|
||||
],
|
||||
links: new Map(),
|
||||
};
|
||||
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
const nested1 = graph._groups.find((g) => g.id === 2)!;
|
||||
const nested2 = graph._groups.find((g) => g.id === 3)!;
|
||||
const parentNode = graph._nodes.find((n) => n.id === 1)!;
|
||||
|
||||
// Both nested groups should be below the parent node
|
||||
const parentNodeBottom = parentNode.pos[1] + parentNode.size[1];
|
||||
expect(nested1.pos[1]).toBeGreaterThan(parentNodeBottom);
|
||||
expect(nested2.pos[1]).toBeGreaterThan(parentNodeBottom);
|
||||
|
||||
// Nested groups should not overlap each other
|
||||
const nested1Bottom = nested1.pos[1] + nested1.size[1];
|
||||
expect(nested2.pos[1]).toBeGreaterThanOrEqual(nested1Bottom);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nested group position update regression (group-test.json)", () => {
|
||||
it("nested group position updates when parent has token layout", () => {
|
||||
const graph = loadFixture("group-test.json");
|
||||
|
||||
// Group 1 "Previews [2ROW]" contains nested Group 3 "Preview Nodes [HORIZONTAL]"
|
||||
// Nodes 3, 4 are inside Group 3
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// Group 3 should be repositioned to contain its member nodes
|
||||
const group3 = graph._groups.find((g) => g.id === 3)!;
|
||||
const node3 = graph._nodes.find((n) => n.id === 3)!;
|
||||
const node4 = graph._nodes.find((n) => n.id === 4)!;
|
||||
|
||||
// Nodes must be inside group bounds (x >= group.x, y >= group.y + title_height)
|
||||
const GROUP_TITLE_HEIGHT = 50;
|
||||
expect(node3.pos[0]).toBeGreaterThanOrEqual(group3.pos[0]);
|
||||
expect(node3.pos[1]).toBeGreaterThanOrEqual(group3.pos[1] + GROUP_TITLE_HEIGHT);
|
||||
expect(node4.pos[0]).toBeGreaterThanOrEqual(group3.pos[0]);
|
||||
expect(node4.pos[1]).toBeGreaterThanOrEqual(group3.pos[1] + GROUP_TITLE_HEIGHT);
|
||||
|
||||
// Nodes must be inside group bounds (right/bottom edges)
|
||||
expect(node3.pos[0] + node3.size[0]).toBeLessThanOrEqual(group3.pos[0] + group3.size[0]);
|
||||
expect(node3.pos[1] + node3.size[1]).toBeLessThanOrEqual(group3.pos[1] + group3.size[1]);
|
||||
expect(node4.pos[0] + node4.size[0]).toBeLessThanOrEqual(group3.pos[0] + group3.size[0]);
|
||||
expect(node4.pos[1] + node4.size[1]).toBeLessThanOrEqual(group3.pos[1] + group3.size[1]);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("maintains node-group containment after [2ROW] token layout", () => {
|
||||
const graph = loadFixture("group-test.json");
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
assertNodesInsideGroups(graph);
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("updates bounding array when translating nested group", () => {
|
||||
// Simulate production environment where groups have bounding array
|
||||
const graph: LGraph = {
|
||||
_nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: "Note",
|
||||
title: "Note 1",
|
||||
pos: [180, 180] as [number, number],
|
||||
size: [200, 100] as [number, number],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "Note",
|
||||
title: "Note 2",
|
||||
pos: [500, 180] as [number, number],
|
||||
size: [200, 100] as [number, number],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
],
|
||||
_groups: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Outer [HORIZONTAL]",
|
||||
pos: [100, 50] as [number, number],
|
||||
size: [700, 300] as [number, number],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Inner Group",
|
||||
pos: [450, 130] as [number, number],
|
||||
size: [300, 200] as [number, number],
|
||||
},
|
||||
],
|
||||
links: new Map(),
|
||||
};
|
||||
|
||||
// Add bounding arrays to simulate ComfyUI runtime format
|
||||
const group1 = graph._groups[0] as typeof graph._groups[0] & { bounding: number[] };
|
||||
const group2 = graph._groups[1] as typeof graph._groups[1] & { bounding: number[] };
|
||||
group1.bounding = [100, 50, 700, 300];
|
||||
group2.bounding = [450, 130, 300, 200];
|
||||
|
||||
layoutSelectedGroups(graph, new Set([1]));
|
||||
|
||||
// After layout, bounding should be updated to match pos
|
||||
expect(group2.bounding[0]).toBe(group2.pos[0]);
|
||||
expect(group2.bounding[1]).toBe(group2.pos[1]);
|
||||
expect(group2.bounding[2]).toBe(group2.size[0]);
|
||||
expect(group2.bounding[3]).toBe(group2.size[1]);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { loadFixture } from "../helpers";
|
||||
import { layoutSelectedGroups } from "../../src/layout/selected-groups";
|
||||
import { layoutGraph } from "../../src/layout/index";
|
||||
import { assertFiniteCoordinates } from "../invariants";
|
||||
import type { LGraph } from "../../src/layout/types";
|
||||
|
||||
/**
|
||||
* Regression tests for token-based layout issues
|
||||
* Based on real workflow: token-testing.json
|
||||
*/
|
||||
describe("token layout regression", () => {
|
||||
let graph: LGraph;
|
||||
|
||||
beforeEach(() => {
|
||||
graph = loadFixture("token-testing.json");
|
||||
});
|
||||
|
||||
describe("[1ROW] ordering", () => {
|
||||
// Group 8 "[1ROW]" contains nodes 41, 42 (GetNode), 43, 44 (PreviewImage), 47 (Note)
|
||||
// GetNodes are originally at X=3290, PreviewImages at X=3600
|
||||
|
||||
it("preserves left-to-right order (sources before targets)", () => {
|
||||
layoutSelectedGroups(graph, new Set([8]));
|
||||
|
||||
const node41 = graph._nodes.find((n) => n.id === 41)!; // GetNode
|
||||
const node42 = graph._nodes.find((n) => n.id === 42)!; // GetNode
|
||||
const node43 = graph._nodes.find((n) => n.id === 43)!; // PreviewImage
|
||||
const node44 = graph._nodes.find((n) => n.id === 44)!; // PreviewImage
|
||||
|
||||
// GetNodes (originally left at X=3290) should remain left of PreviewImages (X=3600)
|
||||
expect(node41.pos[0]).toBeLessThan(node43.pos[0]);
|
||||
expect(node42.pos[0]).toBeLessThan(node44.pos[0]);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("places all nodes at same Y in horizontal row", () => {
|
||||
layoutSelectedGroups(graph, new Set([8]));
|
||||
|
||||
const groupNodes = graph._nodes.filter((n) =>
|
||||
[41, 42, 43, 44, 47].includes(n.id)
|
||||
);
|
||||
const ys = new Set(groupNodes.map((n) => n.pos[1]));
|
||||
|
||||
// All nodes should have same Y (single row)
|
||||
expect(ys.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("[3COL] with nested groups", () => {
|
||||
// Group 9 "Full Workflow Group [3COL]" contains nested group 10 "[HORIZONTAL]"
|
||||
// Group 10 contains nodes 55, 56, 57 (isolated notes)
|
||||
|
||||
it("includes nested groups in column arrangement", () => {
|
||||
layoutSelectedGroups(graph, new Set([9]));
|
||||
|
||||
const group10 = graph._groups.find((g) => g.id === 10)!;
|
||||
|
||||
// Nested group should be positioned
|
||||
expect(group10.pos[0]).toBeGreaterThan(0);
|
||||
expect(group10.pos[1]).toBeGreaterThan(0);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("nested group respects its own token when selected directly", () => {
|
||||
// Test group 10 "[HORIZONTAL]" directly
|
||||
layoutSelectedGroups(graph, new Set([10]));
|
||||
|
||||
const innerNodes = graph._nodes.filter((n) =>
|
||||
[55, 56, 57].includes(n.id)
|
||||
);
|
||||
const ys = new Set(innerNodes.map((n) => n.pos[1]));
|
||||
|
||||
// Inner nodes should all be at same Y (horizontal)
|
||||
expect(ys.size).toBe(1);
|
||||
});
|
||||
|
||||
it("does not overlap nested groups with other content", () => {
|
||||
layoutSelectedGroups(graph, new Set([9]));
|
||||
|
||||
const group10 = graph._groups.find((g) => g.id === 10)!;
|
||||
|
||||
// Group 10 should have valid bounds
|
||||
expect(group10.size[0]).toBeGreaterThan(0);
|
||||
expect(group10.size[1]).toBeGreaterThan(0);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organize Workflow with tokens", () => {
|
||||
it("respects group tokens in full workflow layout", () => {
|
||||
layoutGraph(graph);
|
||||
|
||||
// Group 8 "[1ROW]" - nodes should be at same Y
|
||||
const group8Nodes = graph._nodes.filter((n) =>
|
||||
[41, 42, 43, 44, 47].includes(n.id)
|
||||
);
|
||||
const g8Ys = new Set(group8Nodes.map((n) => n.pos[1]));
|
||||
expect(g8Ys.size).toBe(1);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
|
||||
it("preserves ordering in workflow layout", () => {
|
||||
layoutGraph(graph);
|
||||
|
||||
// In "[1ROW]" group, GetNodes should be left of PreviewImages
|
||||
const node41 = graph._nodes.find((n) => n.id === 41)!;
|
||||
const node43 = graph._nodes.find((n) => n.id === 43)!;
|
||||
|
||||
expect(node41.pos[0]).toBeLessThan(node43.pos[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("[HORIZONTAL] token", () => {
|
||||
// Group 7 "[HORIZONTAL]" contains nodes 32, 33, 34, 35 (PreviewImage nodes)
|
||||
|
||||
it("arranges nodes horizontally", () => {
|
||||
layoutSelectedGroups(graph, new Set([7]));
|
||||
|
||||
const nodes = graph._nodes.filter((n) =>
|
||||
[32, 33, 34, 35].includes(n.id)
|
||||
);
|
||||
const ys = new Set(nodes.map((n) => n.pos[1]));
|
||||
|
||||
// All nodes should have same Y
|
||||
expect(ys.size).toBe(1);
|
||||
|
||||
assertFiniteCoordinates(graph);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,384 +0,0 @@
|
||||
import { expect } from "vitest";
|
||||
import type { LGraph, LGraphNode, LGraphGroup } from "../src/layout/types";
|
||||
import { capturePositions } from "./helpers";
|
||||
import { layoutGraph } from "../src/layout/index";
|
||||
|
||||
/** Bounding box for collision detection */
|
||||
interface BoundingBox {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two bounding boxes overlap (AABB collision)
|
||||
* Uses a small gap tolerance to avoid false positives from floating point
|
||||
*/
|
||||
function boxesOverlap(a: BoundingBox, b: BoundingBox, gap = 0): boolean {
|
||||
return (
|
||||
a.x < b.x + b.width + gap &&
|
||||
a.x + a.width + gap > b.x &&
|
||||
a.y < b.y + b.height + gap &&
|
||||
a.y + a.height + gap > b.y
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all entities (nodes, groups, and I/O nodes) as bounding boxes
|
||||
*/
|
||||
function getAllEntities(graph: LGraph): BoundingBox[] {
|
||||
const entities: BoundingBox[] = [];
|
||||
|
||||
for (const node of graph._nodes) {
|
||||
entities.push({
|
||||
id: `node_${node.id}`,
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1],
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of graph._groups) {
|
||||
entities.push({
|
||||
id: `group_${group.id}`,
|
||||
x: group.pos[0],
|
||||
y: group.pos[1],
|
||||
width: group.size[0],
|
||||
height: group.size[1],
|
||||
});
|
||||
}
|
||||
|
||||
// Include subgraph I/O nodes if present
|
||||
if (graph.inputNode?.pos && graph.inputNode?.size) {
|
||||
entities.push({
|
||||
id: `io_${graph.inputNode.id}`,
|
||||
x: graph.inputNode.pos[0],
|
||||
y: graph.inputNode.pos[1],
|
||||
width: graph.inputNode.size[0],
|
||||
height: graph.inputNode.size[1],
|
||||
});
|
||||
}
|
||||
if (graph.outputNode?.pos && graph.outputNode?.size) {
|
||||
entities.push({
|
||||
id: `io_${graph.outputNode.id}`,
|
||||
x: graph.outputNode.pos[0],
|
||||
y: graph.outputNode.pos[1],
|
||||
width: graph.outputNode.size[0],
|
||||
height: graph.outputNode.size[1],
|
||||
});
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which nodes are inside which groups based on position
|
||||
*/
|
||||
function getNodesInsideGroups(graph: LGraph): Map<number, LGraphGroup> {
|
||||
const nodeToGroup = new Map<number, LGraphGroup>();
|
||||
|
||||
for (const node of graph._nodes) {
|
||||
const nodeX = node.pos[0];
|
||||
const nodeY = node.pos[1];
|
||||
const nodeRight = nodeX + node.size[0];
|
||||
const nodeBottom = nodeY + node.size[1];
|
||||
|
||||
// Find the smallest group that contains this node
|
||||
let bestGroup: LGraphGroup | undefined;
|
||||
let bestArea = Infinity;
|
||||
|
||||
for (const group of graph._groups) {
|
||||
const groupX = group.pos[0];
|
||||
const groupY = group.pos[1];
|
||||
const groupRight = groupX + group.size[0];
|
||||
const groupBottom = groupY + group.size[1];
|
||||
|
||||
// Check if node is inside group bounds
|
||||
if (
|
||||
nodeX >= groupX &&
|
||||
nodeY >= groupY &&
|
||||
nodeRight <= groupRight &&
|
||||
nodeBottom <= groupBottom
|
||||
) {
|
||||
const area = group.size[0] * group.size[1];
|
||||
if (area < bestArea) {
|
||||
bestArea = area;
|
||||
bestGroup = group;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestGroup) {
|
||||
nodeToGroup.set(node.id, bestGroup);
|
||||
}
|
||||
}
|
||||
|
||||
return nodeToGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node bounding box is fully inside a group bounding box
|
||||
*/
|
||||
function isNodeInsideGroup(node: BoundingBox, group: BoundingBox): boolean {
|
||||
return (
|
||||
node.x >= group.x &&
|
||||
node.y >= group.y &&
|
||||
node.x + node.width <= group.x + group.width &&
|
||||
node.y + node.height <= group.y + group.height
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert no two standalone entities overlap
|
||||
* Nodes inside groups are excluded from overlap checks with their containing groups
|
||||
* (including parent groups in nested group hierarchies)
|
||||
*/
|
||||
export function assertNoOverlaps(graph: LGraph): void {
|
||||
const entities = getAllEntities(graph);
|
||||
const overlaps: string[] = [];
|
||||
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
for (let j = i + 1; j < entities.length; j++) {
|
||||
const a = entities[i];
|
||||
const b = entities[j];
|
||||
|
||||
// Skip node-vs-group overlap if node is fully inside that group
|
||||
// This handles nested groups: a node inside inner group is also inside outer group
|
||||
if (a.id.startsWith("node_") && b.id.startsWith("group_")) {
|
||||
if (isNodeInsideGroup(a, b)) continue;
|
||||
}
|
||||
if (b.id.startsWith("node_") && a.id.startsWith("group_")) {
|
||||
if (isNodeInsideGroup(b, a)) continue;
|
||||
}
|
||||
|
||||
// Skip group-vs-group overlap if one contains the other (nested groups)
|
||||
if (a.id.startsWith("group_") && b.id.startsWith("group_")) {
|
||||
// Check if one fully contains the other
|
||||
const aContainsB =
|
||||
a.x <= b.x &&
|
||||
a.y <= b.y &&
|
||||
a.x + a.width >= b.x + b.width &&
|
||||
a.y + a.height >= b.y + b.height;
|
||||
const bContainsA =
|
||||
b.x <= a.x &&
|
||||
b.y <= a.y &&
|
||||
b.x + b.width >= a.x + a.width &&
|
||||
b.y + b.height >= a.y + a.height;
|
||||
if (aContainsB || bContainsA) continue;
|
||||
}
|
||||
|
||||
if (boxesOverlap(a, b)) {
|
||||
overlaps.push(`${a.id} overlaps ${b.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(overlaps, `Found ${overlaps.length} overlapping entities`).toEqual([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert all nodes that were inside groups remain inside their groups
|
||||
* This catches bug 0008 where nodes ended up outside their groups
|
||||
*/
|
||||
export function assertNodesInsideGroups(graph: LGraph): void {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const group of graph._groups) {
|
||||
const groupX = group.pos[0];
|
||||
const groupY = group.pos[1];
|
||||
const groupRight = groupX + group.size[0];
|
||||
const groupBottom = groupY + group.size[1];
|
||||
|
||||
// Find nodes that should be inside this group (based on current containment)
|
||||
for (const node of graph._nodes) {
|
||||
const nodeX = node.pos[0];
|
||||
const nodeY = node.pos[1];
|
||||
const nodeRight = nodeX + node.size[0];
|
||||
const nodeBottom = nodeY + node.size[1];
|
||||
|
||||
// Check if node center is inside group
|
||||
const nodeCenterX = nodeX + node.size[0] / 2;
|
||||
const nodeCenterY = nodeY + node.size[1] / 2;
|
||||
|
||||
if (
|
||||
nodeCenterX >= groupX &&
|
||||
nodeCenterX <= groupRight &&
|
||||
nodeCenterY >= groupY &&
|
||||
nodeCenterY <= groupBottom
|
||||
) {
|
||||
// Node center is inside group - verify entire node fits
|
||||
if (nodeX < groupX || nodeY < groupY || nodeRight > groupRight || nodeBottom > groupBottom) {
|
||||
errors.push(
|
||||
`Node ${node.id} (${node.title}) extends outside group "${group.title}": ` +
|
||||
`node [${nodeX}, ${nodeY}, ${nodeRight}, ${nodeBottom}] vs group [${groupX}, ${groupY}, ${groupRight}, ${groupBottom}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(errors, `Found ${errors.length} nodes outside their groups`).toEqual([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert all coordinates are finite numbers
|
||||
* Note: Negative coordinates are allowed since some workflows may position nodes above/left of origin
|
||||
* This catches NaN and Infinity bugs
|
||||
*/
|
||||
export function assertFiniteCoordinates(graph: LGraph): void {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const node of graph._nodes) {
|
||||
if (!Number.isFinite(node.pos[0]) || !Number.isFinite(node.pos[1])) {
|
||||
errors.push(`Node ${node.id} has non-finite position: [${node.pos[0]}, ${node.pos[1]}]`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of graph._groups) {
|
||||
if (!Number.isFinite(group.pos[0]) || !Number.isFinite(group.pos[1])) {
|
||||
errors.push(`Group ${group.id} has non-finite position: [${group.pos[0]}, ${group.pos[1]}]`);
|
||||
}
|
||||
if (!Number.isFinite(group.size[0]) || !Number.isFinite(group.size[1])) {
|
||||
errors.push(`Group ${group.id} has non-finite size: [${group.size[0]}, ${group.size[1]}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check subgraph I/O nodes if present
|
||||
if (graph.inputNode?.pos) {
|
||||
if (!Number.isFinite(graph.inputNode.pos[0]) || !Number.isFinite(graph.inputNode.pos[1])) {
|
||||
errors.push(`I/O node ${graph.inputNode.id} has non-finite position: [${graph.inputNode.pos[0]}, ${graph.inputNode.pos[1]}]`);
|
||||
}
|
||||
}
|
||||
if (graph.outputNode?.pos) {
|
||||
if (!Number.isFinite(graph.outputNode.pos[0]) || !Number.isFinite(graph.outputNode.pos[1])) {
|
||||
errors.push(`I/O node ${graph.outputNode.id} has non-finite position: [${graph.outputNode.pos[0]}, ${graph.outputNode.pos[1]}]`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(errors, `Found ${errors.length} invalid coordinates`).toEqual([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert topological order is preserved: if A→B link exists, A must be positioned left of B
|
||||
* This catches fundamental DAG ordering bugs
|
||||
*/
|
||||
export function assertTopologicalOrder(graph: LGraph): void {
|
||||
const errors: string[] = [];
|
||||
const nodeMap = new Map<number, LGraphNode>();
|
||||
|
||||
for (const node of graph._nodes) {
|
||||
nodeMap.set(node.id, node);
|
||||
}
|
||||
|
||||
// Check each link
|
||||
const links = graph.links instanceof Map ? graph.links.values() : Object.values(graph.links);
|
||||
|
||||
for (const link of links) {
|
||||
const sourceNode = nodeMap.get(link.origin_id);
|
||||
const targetNode = nodeMap.get(link.target_id);
|
||||
|
||||
if (!sourceNode || !targetNode) continue;
|
||||
|
||||
// Skip reroute nodes (they're positioned along edges)
|
||||
if (sourceNode.type === "Reroute" || targetNode.type === "Reroute") continue;
|
||||
|
||||
// Source should be left of target (or at same X if in same layer)
|
||||
const sourceRight = sourceNode.pos[0] + sourceNode.size[0];
|
||||
if (sourceRight > targetNode.pos[0] + targetNode.size[0]) {
|
||||
errors.push(
|
||||
`Link ${link.id}: source node ${sourceNode.id} (${sourceNode.title}) at X=${sourceNode.pos[0]} ` +
|
||||
`is right of target node ${targetNode.id} (${targetNode.title}) at X=${targetNode.pos[0]}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
expect(errors, `Found ${errors.length} topological order violations`).toEqual([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert layout is idempotent: running twice produces same positions
|
||||
* This catches oscillation bugs like 0011
|
||||
*/
|
||||
export function assertIdempotent(graph: LGraph): void {
|
||||
// Capture positions after first layout (already run by caller)
|
||||
const positions1 = capturePositions(graph);
|
||||
|
||||
// Run layout again
|
||||
layoutGraph(graph);
|
||||
|
||||
// Capture positions after second layout
|
||||
const positions2 = capturePositions(graph);
|
||||
|
||||
// Compare
|
||||
const differences: string[] = [];
|
||||
|
||||
for (const [id, pos1] of positions1) {
|
||||
const pos2 = positions2.get(id);
|
||||
if (!pos2) {
|
||||
differences.push(`${id} missing after second layout`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Allow small movements (up to 5 pixels) due to rounding in group resize/centering
|
||||
const tolerance = 5;
|
||||
if (
|
||||
Math.abs(pos1.x - pos2.x) > tolerance ||
|
||||
Math.abs(pos1.y - pos2.y) > tolerance
|
||||
) {
|
||||
differences.push(
|
||||
`${id} moved: [${pos1.x.toFixed(1)}, ${pos1.y.toFixed(1)}] → [${pos2.x.toFixed(1)}, ${pos2.y.toFixed(1)}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
expect(differences, `Layout is not idempotent: ${differences.length} entities moved`).toEqual([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that nodes which were inside groups before layout remain inside their groups after
|
||||
* This catches regressions where layout breaks group containment
|
||||
* Must be called with graph state BEFORE and AFTER layout
|
||||
*/
|
||||
export function assertGroupMembershipPreserved(
|
||||
originalGraph: LGraph,
|
||||
layoutedGraph: LGraph
|
||||
): void {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Get which nodes were inside which groups BEFORE layout
|
||||
const beforeMembership = getNodesInsideGroups(originalGraph);
|
||||
|
||||
// For each node that was in a group, verify it's still in that group after layout
|
||||
for (const [nodeId, originalGroup] of beforeMembership) {
|
||||
const node = layoutedGraph._nodes.find((n) => n.id === nodeId);
|
||||
const group = layoutedGraph._groups.find((g) => g.id === originalGroup.id);
|
||||
|
||||
if (!node || !group) continue;
|
||||
|
||||
// Check if node is still fully inside the group
|
||||
const nodeRight = node.pos[0] + node.size[0];
|
||||
const nodeBottom = node.pos[1] + node.size[1];
|
||||
const groupRight = group.pos[0] + group.size[0];
|
||||
const groupBottom = group.pos[1] + group.size[1];
|
||||
|
||||
if (
|
||||
node.pos[0] < group.pos[0] ||
|
||||
node.pos[1] < group.pos[1] ||
|
||||
nodeRight > groupRight ||
|
||||
nodeBottom > groupBottom
|
||||
) {
|
||||
errors.push(
|
||||
`Node ${nodeId} (${node.title}) was in group "${originalGroup.title}" but is now outside: ` +
|
||||
`node [${node.pos[0].toFixed(0)}, ${node.pos[1].toFixed(0)}] vs group [${group.pos[0].toFixed(0)}, ${group.pos[1].toFixed(0)}, ${group.size[0].toFixed(0)}, ${group.size[1].toFixed(0)}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
errors,
|
||||
`Found ${errors.length} nodes that moved outside their original groups`
|
||||
).toEqual([]);
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
/**
|
||||
* Tests for src/adapter.ts — the bridge between ComfyUI graph objects
|
||||
* and the pure layout framework types.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
extractLayoutInput,
|
||||
extractGroupLayoutInput,
|
||||
applyLayoutOutput,
|
||||
anchorSelectedGroupLayoutResult,
|
||||
} from "../../src/adapter";
|
||||
import type { GraphLike } from "../../src/adapter";
|
||||
import type {
|
||||
Position,
|
||||
GroupBounds,
|
||||
LayoutGroup,
|
||||
FrameworkResult,
|
||||
} from "../../src/layout/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: create a minimal graph-like object
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeGraph(opts: {
|
||||
nodes?: Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
pos: number[];
|
||||
size: number[];
|
||||
}>;
|
||||
inputNode?: {
|
||||
id: number;
|
||||
pos: number[];
|
||||
size: number[];
|
||||
};
|
||||
outputNode?: {
|
||||
id: number;
|
||||
pos: number[];
|
||||
size: number[];
|
||||
};
|
||||
groups?: Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
pos: number[];
|
||||
size: number[];
|
||||
childrenIds?: number[];
|
||||
includeChildrenSet?: boolean;
|
||||
}>;
|
||||
links?: Array<{
|
||||
id: number;
|
||||
origin_id: number;
|
||||
target_id: number;
|
||||
}>;
|
||||
}): GraphLike {
|
||||
const nodes = (opts.nodes ?? []).map((n) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
title: n.title,
|
||||
pos: [...n.pos],
|
||||
size: [...n.size],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
}));
|
||||
|
||||
const groups = (opts.groups ?? []).map((g) => {
|
||||
const childrenIdSet = new Set(g.childrenIds ?? []);
|
||||
// _children includes all nodes/groups spatially inside
|
||||
const children =
|
||||
g.includeChildrenSet === false
|
||||
? undefined
|
||||
: new Set(
|
||||
nodes
|
||||
.filter((n) => isInsideGroup(n, g))
|
||||
.map((n) => ({ id: n.id })),
|
||||
);
|
||||
if (children) {
|
||||
for (const cid of childrenIdSet) {
|
||||
children.add({ id: cid });
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
pos: [...g.pos],
|
||||
size: [...g.size],
|
||||
_children: children as ReadonlySet<{ readonly id: number }> | undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const linksMap = new Map<
|
||||
number,
|
||||
{ id: number; origin_id: number; target_id: number }
|
||||
>();
|
||||
for (const link of opts.links ?? []) {
|
||||
linksMap.set(link.id, link);
|
||||
}
|
||||
|
||||
const graph = {
|
||||
_nodes: nodes,
|
||||
_groups: groups,
|
||||
links: linksMap,
|
||||
setDirtyCanvas: undefined,
|
||||
};
|
||||
|
||||
if (opts.inputNode) {
|
||||
Object.assign(graph, {
|
||||
inputNode: {
|
||||
id: opts.inputNode.id,
|
||||
pos: [...opts.inputNode.pos],
|
||||
size: [...opts.inputNode.size],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.outputNode) {
|
||||
Object.assign(graph, {
|
||||
outputNode: {
|
||||
id: opts.outputNode.id,
|
||||
pos: [...opts.outputNode.pos],
|
||||
size: [...opts.outputNode.size],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return graph as GraphLike;
|
||||
}
|
||||
|
||||
function isInsideGroup(
|
||||
node: { pos: number[]; size: number[] },
|
||||
group: { pos: number[]; size: number[] },
|
||||
): boolean {
|
||||
return (
|
||||
node.pos[0] >= group.pos[0] &&
|
||||
node.pos[1] >= group.pos[1] &&
|
||||
node.pos[0] + node.size[0] <= group.pos[0] + group.size[0] &&
|
||||
node.pos[1] + node.size[1] <= group.pos[1] + group.size[1]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractLayoutInput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("extractLayoutInput", () => {
|
||||
it("extracts nodes with correct ids and dimensions", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, type: "KSampler", title: "Sampler", pos: [0, 0], size: [200, 100] },
|
||||
{ id: 2, type: "VAEDecode", title: "Decode", pos: [300, 0], size: [150, 80] },
|
||||
],
|
||||
});
|
||||
|
||||
const { nodes } = extractLayoutInput(graph);
|
||||
|
||||
expect(nodes).toHaveLength(2);
|
||||
expect(nodes[0]).toEqual({ id: "1", width: 200, height: 100 });
|
||||
expect(nodes[1]).toEqual({ id: "2", width: 150, height: 80 });
|
||||
});
|
||||
|
||||
it("extracts edges from links map", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, type: "A", title: "A", pos: [0, 0], size: [100, 50] },
|
||||
{ id: 2, type: "B", title: "B", pos: [200, 0], size: [100, 50] },
|
||||
],
|
||||
links: [{ id: 1, origin_id: 1, target_id: 2 }],
|
||||
});
|
||||
|
||||
const { edges } = extractLayoutInput(graph);
|
||||
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0]).toEqual({ source: "1", target: "2" });
|
||||
});
|
||||
|
||||
it("returns empty arrays for an empty graph", () => {
|
||||
const graph = makeGraph({});
|
||||
const { nodes, edges, groups } = extractLayoutInput(graph);
|
||||
|
||||
expect(nodes).toHaveLength(0);
|
||||
expect(edges).toHaveLength(0);
|
||||
expect(groups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("builds group hierarchy from spatial containment", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, type: "A", title: "A", pos: [120, 120], size: [100, 50] },
|
||||
{ id: 2, type: "B", title: "B", pos: [520, 120], size: [100, 50] },
|
||||
],
|
||||
groups: [
|
||||
{ id: 100, title: "Outer", pos: [0, 0], size: [1000, 500] },
|
||||
{ id: 101, title: "Inner", pos: [100, 100], size: [300, 200] },
|
||||
],
|
||||
});
|
||||
|
||||
const { groups } = extractLayoutInput(graph);
|
||||
|
||||
// Outer group should list Inner as child
|
||||
const outer = groups.find((g) => g.id === "group:100");
|
||||
expect(outer).toBeDefined();
|
||||
expect(outer!.childGroupIds).toContain("group:101");
|
||||
|
||||
// Inner group should contain node 1, outer should contain node 2 (not in inner)
|
||||
const inner = groups.find((g) => g.id === "group:101");
|
||||
expect(inner).toBeDefined();
|
||||
expect(inner!.memberIds).toContain("1");
|
||||
expect(inner!.childGroupIds).toHaveLength(0);
|
||||
|
||||
// Node 2 is in outer but not in inner
|
||||
expect(outer!.memberIds).toContain("2");
|
||||
expect(outer!.memberIds).not.toContain("1"); // 1 is in inner, not direct member of outer
|
||||
});
|
||||
|
||||
it("infers group members from spatial containment when _children is missing", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, type: "A", title: "A", pos: [120, 120], size: [100, 50] },
|
||||
{ id: 2, type: "B", title: "B", pos: [520, 120], size: [100, 50] },
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
id: 100,
|
||||
title: "Outer",
|
||||
pos: [0, 0],
|
||||
size: [1000, 500],
|
||||
includeChildrenSet: false,
|
||||
},
|
||||
{
|
||||
id: 101,
|
||||
title: "Inner",
|
||||
pos: [100, 100],
|
||||
size: [300, 200],
|
||||
includeChildrenSet: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { groups } = extractLayoutInput(graph);
|
||||
const outer = groups.find((group) => group.id === "group:100");
|
||||
const inner = groups.find((group) => group.id === "group:101");
|
||||
|
||||
expect(inner?.memberIds).toEqual(["1"]);
|
||||
expect(outer?.memberIds).toEqual(["2"]);
|
||||
expect(outer?.childGroupIds).toEqual(["group:101"]);
|
||||
});
|
||||
|
||||
it("treats a node whose center is inside a group as a member", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: "PreviewImage",
|
||||
title: "Preview",
|
||||
pos: [120, 50],
|
||||
size: [120, 120],
|
||||
},
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
id: 100,
|
||||
title: "Wrapper",
|
||||
pos: [100, 100],
|
||||
size: [100, 100],
|
||||
includeChildrenSet: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { groups } = extractLayoutInput(graph);
|
||||
|
||||
expect(groups[0]?.memberIds).toEqual(["1"]);
|
||||
});
|
||||
|
||||
it("excludes a node whose center is outside the group", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: "PreviewImage",
|
||||
title: "Preview",
|
||||
pos: [10, 50],
|
||||
size: [120, 120],
|
||||
},
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
id: 100,
|
||||
title: "Wrapper",
|
||||
pos: [100, 100],
|
||||
size: [100, 100],
|
||||
includeChildrenSet: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { groups } = extractLayoutInput(graph);
|
||||
|
||||
expect(groups[0]?.memberIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("assigns the nearest containing group as parent", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, type: "A", title: "A", pos: [140, 140], size: [80, 40] },
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
id: 100,
|
||||
title: "Outer",
|
||||
pos: [0, 0],
|
||||
size: [1000, 1000],
|
||||
includeChildrenSet: false,
|
||||
},
|
||||
{
|
||||
id: 101,
|
||||
title: "Middle",
|
||||
pos: [100, 100],
|
||||
size: [400, 400],
|
||||
includeChildrenSet: false,
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
title: "Inner",
|
||||
pos: [120, 120],
|
||||
size: [200, 200],
|
||||
includeChildrenSet: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { groups } = extractLayoutInput(graph);
|
||||
const outer = groups.find((group) => group.id === "group:100");
|
||||
const middle = groups.find((group) => group.id === "group:101");
|
||||
const inner = groups.find((group) => group.id === "group:102");
|
||||
|
||||
expect(outer?.childGroupIds).toEqual(["group:101"]);
|
||||
expect(middle?.childGroupIds).toEqual(["group:102"]);
|
||||
expect(inner?.memberIds).toEqual(["1"]);
|
||||
});
|
||||
|
||||
it("namespaces group ids to avoid collisions with node ids", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 3, type: "KSampler", title: "KSampler", pos: [500, 100], size: [100, 50] },
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
id: 3,
|
||||
title: "Prompt Group",
|
||||
pos: [0, 0],
|
||||
size: [1000, 500],
|
||||
includeChildrenSet: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { nodes, groups } = extractLayoutInput(graph);
|
||||
|
||||
expect(nodes[0].id).toBe("3");
|
||||
expect(groups[0].id).toBe("group:3");
|
||||
});
|
||||
|
||||
it("parses layout tokens from group titles", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, type: "A", title: "A", pos: [20, 20], size: [100, 50] },
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
id: 100,
|
||||
title: "My Group [HORIZONTAL]",
|
||||
pos: [0, 0],
|
||||
size: [500, 300],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { groups } = extractLayoutInput(graph);
|
||||
|
||||
expect(groups[0].token).toBeDefined();
|
||||
expect(groups[0].id).toBe("group:100");
|
||||
expect(groups[0].token!.mode).toBe("horizontal");
|
||||
});
|
||||
|
||||
it("handles groups with no token in title", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, type: "A", title: "A", pos: [20, 20], size: [100, 50] },
|
||||
],
|
||||
groups: [
|
||||
{ id: 100, title: "Plain Group", pos: [0, 0], size: [500, 300] },
|
||||
],
|
||||
});
|
||||
|
||||
const { groups } = extractLayoutInput(graph);
|
||||
|
||||
expect(groups[0].token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles links stored as Record (old ComfyUI format)", () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 1,
|
||||
type: "A",
|
||||
title: "A",
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
inputs: [] as Array<{ link: number | null }>,
|
||||
outputs: [] as Array<{ links: number[] | null }>,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "B",
|
||||
title: "B",
|
||||
pos: [200, 0],
|
||||
size: [100, 50],
|
||||
inputs: [] as Array<{ link: number | null }>,
|
||||
outputs: [] as Array<{ links: number[] | null }>,
|
||||
},
|
||||
];
|
||||
// Use Record instead of Map for links
|
||||
const linksRecord: Record<number, { id: number; origin_id: number; target_id: number }> = {
|
||||
1: { id: 1, origin_id: 1, target_id: 2 },
|
||||
};
|
||||
const graph: GraphLike = {
|
||||
_nodes: nodes,
|
||||
_groups: [],
|
||||
links: linksRecord as GraphLike["links"],
|
||||
};
|
||||
|
||||
const { edges } = extractLayoutInput(graph);
|
||||
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0]).toEqual({ source: "1", target: "2" });
|
||||
});
|
||||
|
||||
it("includes subgraph boundary nodes in extracted layout input", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, type: "A", title: "A", pos: [0, 0], size: [100, 50] },
|
||||
],
|
||||
inputNode: { id: -10, pos: [-300, 0], size: [120, 220] },
|
||||
outputNode: { id: -20, pos: [300, 0], size: [120, 60] },
|
||||
links: [
|
||||
{ id: 1, origin_id: -10, target_id: 1 },
|
||||
{ id: 2, origin_id: 1, target_id: -20 },
|
||||
],
|
||||
});
|
||||
|
||||
const { nodes, edges } = extractLayoutInput(graph);
|
||||
|
||||
expect(nodes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "-10", width: 120, height: 220 }),
|
||||
expect.objectContaining({ id: "1", width: 100, height: 50 }),
|
||||
expect.objectContaining({ id: "-20", width: 120, height: 60 }),
|
||||
]),
|
||||
);
|
||||
expect(edges).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ source: "-10", target: "1" },
|
||||
{ source: "1", target: "-20" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyLayoutOutput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("applyLayoutOutput", () => {
|
||||
it("applies node positions from layout result", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, type: "A", title: "A", pos: [0, 0], size: [100, 50] },
|
||||
{ id: 2, type: "B", title: "B", pos: [0, 0], size: [100, 50] },
|
||||
],
|
||||
});
|
||||
|
||||
const positions = new Map<string, Position>([
|
||||
["1", { x: 100, y: 200 }],
|
||||
["2", { x: 300, y: 400 }],
|
||||
]);
|
||||
const groupBounds = new Map<string, GroupBounds>();
|
||||
|
||||
applyLayoutOutput(graph, { positions, groupBounds });
|
||||
|
||||
expect(graph._nodes[0].pos[0]).toBe(100);
|
||||
expect(graph._nodes[0].pos[1]).toBe(200);
|
||||
expect(graph._nodes[1].pos[0]).toBe(300);
|
||||
expect(graph._nodes[1].pos[1]).toBe(400);
|
||||
});
|
||||
|
||||
it("applies group bounds from layout result", () => {
|
||||
const graph = makeGraph({
|
||||
groups: [
|
||||
{ id: 100, title: "Group", pos: [0, 0], size: [100, 100] },
|
||||
],
|
||||
});
|
||||
|
||||
const positions = new Map<string, Position>();
|
||||
const groupBounds = new Map<string, GroupBounds>([
|
||||
["group:100", { x: 50, y: 60, width: 500, height: 300 }],
|
||||
]);
|
||||
|
||||
applyLayoutOutput(graph, { positions, groupBounds });
|
||||
|
||||
expect(graph._groups[0].pos[0]).toBe(50);
|
||||
expect(graph._groups[0].pos[1]).toBe(60);
|
||||
expect(graph._groups[0].size[0]).toBe(500);
|
||||
expect(graph._groups[0].size[1]).toBe(300);
|
||||
});
|
||||
|
||||
it("mutates Float64Array in-place", () => {
|
||||
const node = {
|
||||
id: 1,
|
||||
type: "A",
|
||||
title: "A",
|
||||
pos: new Float64Array([0, 0]),
|
||||
size: new Float64Array([100, 50]),
|
||||
inputs: [] as Array<{ link: number | null }>,
|
||||
outputs: [] as Array<{ links: number[] | null }>,
|
||||
};
|
||||
const graph: GraphLike = {
|
||||
_nodes: [node],
|
||||
_groups: [],
|
||||
links: new Map(),
|
||||
};
|
||||
|
||||
const positions = new Map<string, Position>([
|
||||
["1", { x: 42, y: 84 }],
|
||||
]);
|
||||
applyLayoutOutput(graph, { positions, groupBounds: new Map() });
|
||||
|
||||
// Same Float64Array instance, mutated in-place
|
||||
expect(node.pos).toBeInstanceOf(Float64Array);
|
||||
expect(node.pos[0]).toBe(42);
|
||||
expect(node.pos[1]).toBe(84);
|
||||
});
|
||||
|
||||
it("calls setDirtyCanvas when available", () => {
|
||||
let dirtyCalled = false;
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, type: "A", title: "A", pos: [0, 0], size: [100, 50] },
|
||||
],
|
||||
});
|
||||
(graph as { setDirtyCanvas: (fg: boolean, bg: boolean) => void }).setDirtyCanvas = () => {
|
||||
dirtyCalled = true;
|
||||
};
|
||||
|
||||
applyLayoutOutput(graph, {
|
||||
positions: new Map([["1", { x: 0, y: 0 }]]),
|
||||
groupBounds: new Map(),
|
||||
});
|
||||
|
||||
expect(dirtyCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("skips nodes without matching position data", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, type: "A", title: "A", pos: [10, 20], size: [100, 50] },
|
||||
],
|
||||
});
|
||||
|
||||
// Empty positions map — node should not be modified
|
||||
applyLayoutOutput(graph, {
|
||||
positions: new Map(),
|
||||
groupBounds: new Map(),
|
||||
});
|
||||
|
||||
expect(graph._nodes[0].pos[0]).toBe(10);
|
||||
expect(graph._nodes[0].pos[1]).toBe(20);
|
||||
});
|
||||
|
||||
it("applies positions to subgraph boundary nodes", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, type: "A", title: "A", pos: [10, 20], size: [100, 50] },
|
||||
],
|
||||
inputNode: { id: -10, pos: [-300, 0], size: [120, 220] },
|
||||
outputNode: { id: -20, pos: [300, 0], size: [120, 60] },
|
||||
}) as GraphLike & {
|
||||
inputNode: { pos: number[] };
|
||||
outputNode: { pos: number[] };
|
||||
};
|
||||
|
||||
applyLayoutOutput(graph, {
|
||||
positions: new Map([
|
||||
["-10", { x: -100, y: 40 }],
|
||||
["1", { x: 100, y: 200 }],
|
||||
["-20", { x: 400, y: 60 }],
|
||||
]),
|
||||
groupBounds: new Map(),
|
||||
});
|
||||
|
||||
expect(graph.inputNode.pos).toEqual([-100, 40]);
|
||||
expect(graph._nodes[0].pos).toEqual([100, 200]);
|
||||
expect(graph.outputNode.pos).toEqual([400, 60]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractGroupLayoutInput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("extractGroupLayoutInput", () => {
|
||||
it("filters to only selected groups and their members", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, type: "A", title: "A", pos: [20, 20], size: [100, 50] },
|
||||
{ id: 2, type: "B", title: "B", pos: [520, 20], size: [100, 50] },
|
||||
{ id: 3, type: "C", title: "C", pos: [1020, 20], size: [100, 50] },
|
||||
],
|
||||
groups: [
|
||||
{ id: 100, title: "Group 1", pos: [0, 0], size: [500, 300] },
|
||||
{ id: 101, title: "Group 2", pos: [500, 0], size: [500, 300] },
|
||||
],
|
||||
links: [
|
||||
{ id: 1, origin_id: 1, target_id: 2 },
|
||||
{ id: 2, origin_id: 2, target_id: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
// Select only group 100
|
||||
const { nodes, edges, groups } = extractGroupLayoutInput(
|
||||
graph,
|
||||
new Set([100]),
|
||||
);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].id).toBe("group:100");
|
||||
expect(nodes.some((n) => n.id === "1")).toBe(true);
|
||||
// Node 2 is in group 101, not selected — should not appear
|
||||
expect(nodes.some((n) => n.id === "2")).toBe(false);
|
||||
expect(nodes.some((n) => n.id === "3")).toBe(false);
|
||||
// Edge from 1->2 crosses group boundaries, target not included
|
||||
expect(edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("includes nested child groups when parent is selected", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, type: "A", title: "A", pos: [120, 120], size: [100, 50] },
|
||||
{ id: 2, type: "B", title: "B", pos: [520, 120], size: [100, 50] },
|
||||
],
|
||||
groups: [
|
||||
{ id: 100, title: "Outer", pos: [0, 0], size: [1000, 500] },
|
||||
{ id: 101, title: "Inner", pos: [100, 100], size: [300, 200] },
|
||||
],
|
||||
});
|
||||
|
||||
const { groups } = extractGroupLayoutInput(graph, new Set([100]));
|
||||
|
||||
// Both outer and inner should be included
|
||||
expect(groups).toHaveLength(2);
|
||||
const ids = groups.map((g) => g.id);
|
||||
expect(ids).toContain("group:100");
|
||||
expect(ids).toContain("group:101");
|
||||
});
|
||||
|
||||
it("keeps edges between members of selected groups", () => {
|
||||
const graph = makeGraph({
|
||||
nodes: [
|
||||
{ id: 1, type: "A", title: "A", pos: [20, 20], size: [100, 50] },
|
||||
{ id: 2, type: "B", title: "B", pos: [200, 20], size: [100, 50] },
|
||||
],
|
||||
groups: [
|
||||
{ id: 100, title: "Group", pos: [0, 0], size: [500, 300] },
|
||||
],
|
||||
links: [{ id: 1, origin_id: 1, target_id: 2 }],
|
||||
});
|
||||
|
||||
const { edges } = extractGroupLayoutInput(graph, new Set([100]));
|
||||
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0]).toEqual({ source: "1", target: "2" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// anchorSelectedGroupLayoutResult
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("anchorSelectedGroupLayoutResult", () => {
|
||||
it("reanchors a selected nested group subtree to its original position", () => {
|
||||
const graph = makeGraph({
|
||||
groups: [
|
||||
{ id: 100, title: "Outer", pos: [100, 100], size: [600, 400] },
|
||||
{ id: 101, title: "Inner", pos: [250, 220], size: [250, 180] },
|
||||
],
|
||||
});
|
||||
const groups: LayoutGroup[] = [
|
||||
{
|
||||
id: "group:101",
|
||||
title: "Inner",
|
||||
memberIds: ["1"],
|
||||
childGroupIds: ["group:102"],
|
||||
},
|
||||
{
|
||||
id: "group:102",
|
||||
title: "Child",
|
||||
memberIds: ["2"],
|
||||
childGroupIds: [],
|
||||
},
|
||||
];
|
||||
const result: FrameworkResult = {
|
||||
positions: new Map<string, Position>([
|
||||
["1", { x: 30, y: 60 }],
|
||||
["2", { x: 80, y: 120 }],
|
||||
]),
|
||||
groupBounds: new Map<string, GroupBounds>([
|
||||
["group:101", { x: 0, y: 0, width: 300, height: 220 }],
|
||||
["group:102", { x: 40, y: 90, width: 120, height: 100 }],
|
||||
]),
|
||||
};
|
||||
|
||||
const anchored = anchorSelectedGroupLayoutResult(
|
||||
graph,
|
||||
groups,
|
||||
new Set([101]),
|
||||
result,
|
||||
);
|
||||
|
||||
expect(anchored.groupBounds.get("group:101")).toEqual({
|
||||
x: 250,
|
||||
y: 220,
|
||||
width: 300,
|
||||
height: 220,
|
||||
});
|
||||
expect(anchored.groupBounds.get("group:102")).toEqual({
|
||||
x: 290,
|
||||
y: 310,
|
||||
width: 120,
|
||||
height: 100,
|
||||
});
|
||||
expect(anchored.positions.get("1")).toEqual({ x: 280, y: 280 });
|
||||
expect(anchored.positions.get("2")).toEqual({ x: 330, y: 340 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
compactVertically,
|
||||
compactHorizontally,
|
||||
} from "../../src/layout/compact";
|
||||
import type { Position, LayoutNode } from "../../src/layout/types";
|
||||
|
||||
function node(id: string, width = 100, height = 60): LayoutNode {
|
||||
return { id, width, height };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// compactVertically
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("compactVertically", () => {
|
||||
it("removes vertical gaps between nodes", () => {
|
||||
const nodes = [node("A", 100, 50), node("B", 100, 50)];
|
||||
const positions = new Map<string, Position>([
|
||||
["A", { x: 0, y: 0 }],
|
||||
["B", { x: 0, y: 200 }], // 150px gap after A (A ends at 50)
|
||||
]);
|
||||
|
||||
const result = compactVertically(positions, nodes, 10);
|
||||
|
||||
expect(result.get("A")!.y).toBe(0);
|
||||
// B should be shifted up to A.y + A.height + gap = 0 + 50 + 10 = 60
|
||||
expect(result.get("B")!.y).toBe(60);
|
||||
});
|
||||
|
||||
it("does not change already-compact layout", () => {
|
||||
const nodes = [node("A", 100, 50), node("B", 100, 50)];
|
||||
const positions = new Map<string, Position>([
|
||||
["A", { x: 0, y: 0 }],
|
||||
["B", { x: 0, y: 60 }], // exactly 10px gap after A
|
||||
]);
|
||||
|
||||
const result = compactVertically(positions, nodes, 10);
|
||||
|
||||
expect(result.get("A")!.y).toBe(0);
|
||||
expect(result.get("B")!.y).toBe(60);
|
||||
});
|
||||
|
||||
it("handles single node", () => {
|
||||
const nodes = [node("A")];
|
||||
const positions = new Map<string, Position>([["A", { x: 5, y: 100 }]]);
|
||||
|
||||
const result = compactVertically(positions, nodes, 10);
|
||||
|
||||
expect(result.get("A")!.y).toBe(100);
|
||||
expect(result.get("A")!.x).toBe(5); // X unchanged
|
||||
});
|
||||
|
||||
it("returns empty map for empty input", () => {
|
||||
const result = compactVertically(new Map(), [], 10);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves X coordinates", () => {
|
||||
const nodes = [node("A", 100, 50), node("B", 100, 50)];
|
||||
const positions = new Map<string, Position>([
|
||||
["A", { x: 100, y: 0 }],
|
||||
["B", { x: 200, y: 500 }],
|
||||
]);
|
||||
|
||||
const result = compactVertically(positions, nodes, 10);
|
||||
|
||||
expect(result.get("A")!.x).toBe(100);
|
||||
expect(result.get("B")!.x).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// compactHorizontally
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("compactHorizontally", () => {
|
||||
it("removes horizontal gaps between nodes", () => {
|
||||
const nodes = [node("A", 80, 50), node("B", 80, 50)];
|
||||
const positions = new Map<string, Position>([
|
||||
["A", { x: 0, y: 0 }],
|
||||
["B", { x: 300, y: 0 }], // 220px gap after A (A ends at 80)
|
||||
]);
|
||||
|
||||
const result = compactHorizontally(positions, nodes, 10);
|
||||
|
||||
expect(result.get("A")!.x).toBe(0);
|
||||
// B should be shifted left to A.x + A.width + gap = 0 + 80 + 10 = 90
|
||||
expect(result.get("B")!.x).toBe(90);
|
||||
});
|
||||
|
||||
it("does not change already-compact layout", () => {
|
||||
const nodes = [node("A", 80, 50), node("B", 80, 50)];
|
||||
const positions = new Map<string, Position>([
|
||||
["A", { x: 0, y: 0 }],
|
||||
["B", { x: 90, y: 0 }], // exactly 10px gap
|
||||
]);
|
||||
|
||||
const result = compactHorizontally(positions, nodes, 10);
|
||||
|
||||
expect(result.get("A")!.x).toBe(0);
|
||||
expect(result.get("B")!.x).toBe(90);
|
||||
});
|
||||
|
||||
it("preserves Y coordinates", () => {
|
||||
const nodes = [node("A", 80, 50), node("B", 80, 50)];
|
||||
const positions = new Map<string, Position>([
|
||||
["A", { x: 0, y: 100 }],
|
||||
["B", { x: 500, y: 200 }],
|
||||
]);
|
||||
|
||||
const result = compactHorizontally(positions, nodes, 10);
|
||||
|
||||
expect(result.get("A")!.y).toBe(100);
|
||||
expect(result.get("B")!.y).toBe(200);
|
||||
});
|
||||
|
||||
it("returns empty map for empty input", () => {
|
||||
const result = compactHorizontally(new Map(), [], 10);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { listRepoFixtures, loadFixture } from "../e2e/fixtures";
|
||||
|
||||
describe("repo fixture loader", () => {
|
||||
it("discovers flat checked-in fixtures", () => {
|
||||
expect(listRepoFixtures()).toContain("simple-dag");
|
||||
expect(listRepoFixtures()).toContain("token-testing");
|
||||
});
|
||||
|
||||
it("loads a flat checked-in fixture", () => {
|
||||
const workflow = loadFixture("simple-dag");
|
||||
expect(workflow).toHaveProperty("nodes");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,616 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
layoutWithGroups,
|
||||
buildGroupHierarchy,
|
||||
splitDisconnected,
|
||||
placeDisconnected,
|
||||
translatePositions,
|
||||
} from "../../src/layout/framework";
|
||||
import type {
|
||||
LayoutNode,
|
||||
LayoutEdge,
|
||||
LayoutGroup,
|
||||
LayoutAlgorithm,
|
||||
LayoutInput,
|
||||
LayoutOutput,
|
||||
Position,
|
||||
FrameworkConfig,
|
||||
} from "../../src/layout/types";
|
||||
import { DEFAULT_FRAMEWORK_CONFIG } from "../../src/layout/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock algorithm: places nodes in a horizontal line
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockAlgorithm: LayoutAlgorithm = {
|
||||
name: "mock-horizontal-line",
|
||||
layout(input: LayoutInput): LayoutOutput {
|
||||
const positions = new Map<string, Position>();
|
||||
// Determine max width for spacing
|
||||
let maxWidth = 0;
|
||||
for (const n of input.nodes) {
|
||||
if (n.width > maxWidth) maxWidth = n.width;
|
||||
}
|
||||
const gap = 50;
|
||||
|
||||
// Sort nodes by ID for determinism
|
||||
const sorted = [...input.nodes].sort((a, b) =>
|
||||
a.id.localeCompare(b.id, undefined, { numeric: true }),
|
||||
);
|
||||
|
||||
let x = 0;
|
||||
for (const n of sorted) {
|
||||
positions.set(n.id, { x, y: 0 });
|
||||
x += n.width + gap;
|
||||
}
|
||||
|
||||
return { positions };
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper to create nodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function node(id: string, width = 100, height = 60): LayoutNode {
|
||||
return { id, width, height };
|
||||
}
|
||||
|
||||
function edge(source: string, target: string): LayoutEdge {
|
||||
return { source, target };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: layoutWithGroups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("layoutWithGroups", () => {
|
||||
it("places a single node", () => {
|
||||
const nodes = [node("A")];
|
||||
const result = layoutWithGroups(nodes, [], [], mockAlgorithm);
|
||||
|
||||
expect(result.positions.size).toBe(1);
|
||||
const pos = result.positions.get("A");
|
||||
expect(pos).toBeDefined();
|
||||
expect(Number.isFinite(pos!.x)).toBe(true);
|
||||
expect(Number.isFinite(pos!.y)).toBe(true);
|
||||
});
|
||||
|
||||
it("lays out a simple chain A->B->C", () => {
|
||||
const nodes = [node("A"), node("B"), node("C")];
|
||||
const edges = [edge("A", "B"), edge("B", "C")];
|
||||
const result = layoutWithGroups(nodes, edges, [], mockAlgorithm);
|
||||
|
||||
expect(result.positions.size).toBe(3);
|
||||
// All should have finite coordinates
|
||||
for (const [, pos] of result.positions) {
|
||||
expect(Number.isFinite(pos.x)).toBe(true);
|
||||
expect(Number.isFinite(pos.y)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("places disconnected nodes left of DAG", () => {
|
||||
const nodes = [node("A"), node("B"), node("C"), node("D")];
|
||||
const edges = [edge("A", "B")]; // C and D are disconnected
|
||||
const result = layoutWithGroups(nodes, edges, [], mockAlgorithm);
|
||||
|
||||
expect(result.positions.size).toBe(4);
|
||||
|
||||
const posA = result.positions.get("A")!;
|
||||
const posC = result.positions.get("C")!;
|
||||
const posD = result.positions.get("D")!;
|
||||
|
||||
// Disconnected nodes should be left of connected ones
|
||||
expect(posC.x).toBeLessThan(posA.x);
|
||||
expect(posD.x).toBeLessThan(posA.x);
|
||||
|
||||
// Disconnected nodes should be stacked vertically
|
||||
expect(posD.y).toBeGreaterThan(posC.y);
|
||||
});
|
||||
|
||||
it("lays out a group containing 3 nodes with correct bounds", () => {
|
||||
const nodes = [node("A"), node("B"), node("C")];
|
||||
const edges = [edge("A", "B")];
|
||||
const groups: LayoutGroup[] = [
|
||||
{
|
||||
id: "g1",
|
||||
title: "Group 1",
|
||||
memberIds: ["A", "B", "C"],
|
||||
childGroupIds: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = layoutWithGroups(nodes, edges, groups, mockAlgorithm);
|
||||
|
||||
// All nodes should be positioned
|
||||
expect(result.positions.size).toBe(3);
|
||||
|
||||
// Group bounds should exist and contain all members
|
||||
const bounds = result.groupBounds.get("g1");
|
||||
expect(bounds).toBeDefined();
|
||||
expect(bounds!.width).toBeGreaterThan(0);
|
||||
expect(bounds!.height).toBeGreaterThan(0);
|
||||
|
||||
// All members should be inside group bounds
|
||||
for (const mId of ["A", "B", "C"]) {
|
||||
const pos = result.positions.get(mId)!;
|
||||
expect(pos.x).toBeGreaterThanOrEqual(bounds!.x);
|
||||
expect(pos.y).toBeGreaterThanOrEqual(bounds!.y);
|
||||
const n = nodes.find((nd) => nd.id === mId)!;
|
||||
expect(pos.x + n.width).toBeLessThanOrEqual(bounds!.x + bounds!.width);
|
||||
expect(pos.y + n.height).toBeLessThanOrEqual(bounds!.y + bounds!.height);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles nested groups (inner laid out first, then as rectangle in outer)", () => {
|
||||
const nodes = [node("A"), node("B"), node("C"), node("D")];
|
||||
const edges = [edge("A", "B")];
|
||||
const groups: LayoutGroup[] = [
|
||||
{
|
||||
id: "inner",
|
||||
title: "Inner",
|
||||
memberIds: ["A", "B"],
|
||||
childGroupIds: [],
|
||||
},
|
||||
{
|
||||
id: "outer",
|
||||
title: "Outer",
|
||||
memberIds: ["C"],
|
||||
childGroupIds: ["inner"],
|
||||
},
|
||||
];
|
||||
|
||||
const result = layoutWithGroups(nodes, edges, groups, mockAlgorithm);
|
||||
|
||||
// All 4 nodes positioned (D is ungrouped, at root)
|
||||
expect(result.positions.size).toBe(4);
|
||||
|
||||
// Both groups should have bounds
|
||||
expect(result.groupBounds.has("inner")).toBe(true);
|
||||
expect(result.groupBounds.has("outer")).toBe(true);
|
||||
|
||||
const innerBounds = result.groupBounds.get("inner")!;
|
||||
const outerBounds = result.groupBounds.get("outer")!;
|
||||
|
||||
// Inner group should be contained within outer group
|
||||
expect(innerBounds.x).toBeGreaterThanOrEqual(outerBounds.x);
|
||||
expect(innerBounds.y).toBeGreaterThanOrEqual(outerBounds.y);
|
||||
expect(innerBounds.x + innerBounds.width).toBeLessThanOrEqual(
|
||||
outerBounds.x + outerBounds.width,
|
||||
);
|
||||
expect(innerBounds.y + innerBounds.height).toBeLessThanOrEqual(
|
||||
outerBounds.y + outerBounds.height,
|
||||
);
|
||||
});
|
||||
|
||||
it("handles mixed grouped and ungrouped nodes", () => {
|
||||
const nodes = [node("A"), node("B"), node("C")];
|
||||
const edges = [edge("A", "C")]; // A is grouped, C is ungrouped, edge between them
|
||||
const groups: LayoutGroup[] = [
|
||||
{
|
||||
id: "g1",
|
||||
title: "Group 1",
|
||||
memberIds: ["A", "B"],
|
||||
childGroupIds: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = layoutWithGroups(nodes, edges, groups, mockAlgorithm);
|
||||
|
||||
// All nodes positioned
|
||||
expect(result.positions.size).toBe(3);
|
||||
expect(result.groupBounds.size).toBe(1);
|
||||
|
||||
// Ungrouped node C should be positioned
|
||||
expect(result.positions.has("C")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty maps for empty input", () => {
|
||||
const result = layoutWithGroups([], [], [], mockAlgorithm);
|
||||
expect(result.positions.size).toBe(0);
|
||||
expect(result.groupBounds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("produces idempotent results", () => {
|
||||
const nodes = [node("A"), node("B"), node("C"), node("D")];
|
||||
const edges = [edge("A", "B"), edge("B", "C")];
|
||||
const groups: LayoutGroup[] = [
|
||||
{
|
||||
id: "g1",
|
||||
title: "Group 1",
|
||||
memberIds: ["A", "B"],
|
||||
childGroupIds: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result1 = layoutWithGroups(nodes, edges, groups, mockAlgorithm);
|
||||
const result2 = layoutWithGroups(nodes, edges, groups, mockAlgorithm);
|
||||
|
||||
// Positions should be identical
|
||||
expect(result1.positions.size).toBe(result2.positions.size);
|
||||
for (const [id, pos1] of result1.positions) {
|
||||
const pos2 = result2.positions.get(id);
|
||||
expect(pos2).toBeDefined();
|
||||
expect(pos1.x).toBe(pos2!.x);
|
||||
expect(pos1.y).toBe(pos2!.y);
|
||||
}
|
||||
|
||||
// Group bounds should be identical
|
||||
expect(result1.groupBounds.size).toBe(result2.groupBounds.size);
|
||||
for (const [id, b1] of result1.groupBounds) {
|
||||
const b2 = result2.groupBounds.get(id);
|
||||
expect(b2).toBeDefined();
|
||||
expect(b1.x).toBe(b2!.x);
|
||||
expect(b1.y).toBe(b2!.y);
|
||||
expect(b1.width).toBe(b2!.width);
|
||||
expect(b1.height).toBe(b2!.height);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles a group with only disconnected nodes", () => {
|
||||
const nodes = [node("A"), node("B"), node("C")];
|
||||
const groups: LayoutGroup[] = [
|
||||
{
|
||||
id: "g1",
|
||||
title: "Disconnected Group",
|
||||
memberIds: ["A", "B", "C"],
|
||||
childGroupIds: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = layoutWithGroups(nodes, [], groups, mockAlgorithm);
|
||||
|
||||
expect(result.positions.size).toBe(3);
|
||||
expect(result.groupBounds.has("g1")).toBe(true);
|
||||
|
||||
// All nodes should have finite positions
|
||||
for (const [, pos] of result.positions) {
|
||||
expect(Number.isFinite(pos.x)).toBe(true);
|
||||
expect(Number.isFinite(pos.y)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles multiple top-level groups", () => {
|
||||
const nodes = [node("A"), node("B"), node("C"), node("D")];
|
||||
const edges = [edge("A", "C")]; // Cross-group edge
|
||||
const groups: LayoutGroup[] = [
|
||||
{
|
||||
id: "g1",
|
||||
title: "Group 1",
|
||||
memberIds: ["A", "B"],
|
||||
childGroupIds: [],
|
||||
},
|
||||
{
|
||||
id: "g2",
|
||||
title: "Group 2",
|
||||
memberIds: ["C", "D"],
|
||||
childGroupIds: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = layoutWithGroups(nodes, edges, groups, mockAlgorithm);
|
||||
|
||||
// All nodes positioned
|
||||
expect(result.positions.size).toBe(4);
|
||||
// Both groups have bounds
|
||||
expect(result.groupBounds.size).toBe(2);
|
||||
expect(result.groupBounds.has("g1")).toBe(true);
|
||||
expect(result.groupBounds.has("g2")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses group title tokens to override the default algorithm", () => {
|
||||
const nodes = [node("A", 100, 60), node("B", 100, 60)];
|
||||
const groups: LayoutGroup[] = [
|
||||
{
|
||||
id: "g1",
|
||||
title: "Horizontal",
|
||||
memberIds: ["A", "B"],
|
||||
childGroupIds: [],
|
||||
token: { mode: "horizontal" },
|
||||
},
|
||||
];
|
||||
|
||||
const result = layoutWithGroups(nodes, [], groups, mockAlgorithm);
|
||||
const posA = result.positions.get("A")!;
|
||||
const posB = result.positions.get("B")!;
|
||||
|
||||
expect(posA.y).toBe(posB.y);
|
||||
expect(posB.x).toBeGreaterThan(posA.x);
|
||||
});
|
||||
|
||||
it("accepts partial config overrides", () => {
|
||||
const nodes = [node("A"), node("B")];
|
||||
const edges = [edge("A", "B")];
|
||||
const customConfig: Partial<FrameworkConfig> = {
|
||||
groupPadding: 50,
|
||||
};
|
||||
|
||||
const result = layoutWithGroups(
|
||||
nodes,
|
||||
edges,
|
||||
[],
|
||||
mockAlgorithm,
|
||||
customConfig,
|
||||
);
|
||||
expect(result.positions.size).toBe(2);
|
||||
});
|
||||
|
||||
it("uses custom horizontalGap when provided", () => {
|
||||
// With a wider gap, the disconnected node should be farther from the DAG
|
||||
const nodes = [node("A"), node("B"), node("Disc")];
|
||||
const edges = [edge("A", "B")]; // Disc is disconnected
|
||||
const groups: LayoutGroup[] = [];
|
||||
|
||||
const defaultResult = layoutWithGroups(
|
||||
nodes,
|
||||
edges,
|
||||
groups,
|
||||
mockAlgorithm,
|
||||
);
|
||||
const customResult = layoutWithGroups(
|
||||
nodes,
|
||||
edges,
|
||||
groups,
|
||||
mockAlgorithm,
|
||||
{ disconnectedGap: 500 },
|
||||
);
|
||||
|
||||
const defaultDiscX = defaultResult.positions.get("Disc")!.x;
|
||||
const customDiscX = customResult.positions.get("Disc")!.x;
|
||||
|
||||
// Larger disconnectedGap should push the disconnected node further left
|
||||
expect(customDiscX).toBeLessThan(defaultDiscX);
|
||||
});
|
||||
|
||||
it("uses custom groupPadding for group bounds", () => {
|
||||
const nodes = [node("A")];
|
||||
const groups: LayoutGroup[] = [
|
||||
{ id: "g1", title: "G1", memberIds: ["A"], childGroupIds: [] },
|
||||
];
|
||||
|
||||
const smallPad = layoutWithGroups(
|
||||
nodes,
|
||||
[],
|
||||
groups,
|
||||
mockAlgorithm,
|
||||
{ groupPadding: 10 },
|
||||
);
|
||||
const largePad = layoutWithGroups(
|
||||
nodes,
|
||||
[],
|
||||
groups,
|
||||
mockAlgorithm,
|
||||
{ groupPadding: 80 },
|
||||
);
|
||||
|
||||
const smallBounds = smallPad.groupBounds.get("g1")!;
|
||||
const largeBounds = largePad.groupBounds.get("g1")!;
|
||||
|
||||
// Larger padding should produce larger group bounds
|
||||
expect(largeBounds.width).toBeGreaterThan(smallBounds.width);
|
||||
expect(largeBounds.height).toBeGreaterThan(smallBounds.height);
|
||||
});
|
||||
|
||||
it("uses custom verticalGap for disconnected node spacing", () => {
|
||||
const nodes = [node("Disc1"), node("Disc2")];
|
||||
// No edges — both disconnected, stacked vertically
|
||||
|
||||
const tightResult = layoutWithGroups(
|
||||
nodes,
|
||||
[],
|
||||
[],
|
||||
mockAlgorithm,
|
||||
{ verticalGap: 10 },
|
||||
);
|
||||
const looseResult = layoutWithGroups(
|
||||
nodes,
|
||||
[],
|
||||
[],
|
||||
mockAlgorithm,
|
||||
{ verticalGap: 100 },
|
||||
);
|
||||
|
||||
const tightGap =
|
||||
tightResult.positions.get("Disc2")!.y -
|
||||
tightResult.positions.get("Disc1")!.y;
|
||||
const looseGap =
|
||||
looseResult.positions.get("Disc2")!.y -
|
||||
looseResult.positions.get("Disc1")!.y;
|
||||
|
||||
expect(looseGap).toBeGreaterThan(tightGap);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: buildGroupHierarchy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildGroupHierarchy", () => {
|
||||
it("returns empty for no groups", () => {
|
||||
const { processingOrder, parentMap } = buildGroupHierarchy([]);
|
||||
expect(processingOrder).toHaveLength(0);
|
||||
expect(parentMap.size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns leaf groups first, then parents", () => {
|
||||
const groups: LayoutGroup[] = [
|
||||
{
|
||||
id: "parent",
|
||||
title: "Parent",
|
||||
memberIds: [],
|
||||
childGroupIds: ["child"],
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
title: "Child",
|
||||
memberIds: ["A"],
|
||||
childGroupIds: [],
|
||||
},
|
||||
];
|
||||
|
||||
const { processingOrder, parentMap } = buildGroupHierarchy(groups);
|
||||
|
||||
expect(processingOrder).toHaveLength(2);
|
||||
expect(processingOrder[0].id).toBe("child");
|
||||
expect(processingOrder[1].id).toBe("parent");
|
||||
expect(parentMap.get("child")).toBe("parent");
|
||||
});
|
||||
|
||||
it("handles flat groups (no nesting)", () => {
|
||||
const groups: LayoutGroup[] = [
|
||||
{ id: "a", title: "A", memberIds: ["1"], childGroupIds: [] },
|
||||
{ id: "b", title: "B", memberIds: ["2"], childGroupIds: [] },
|
||||
];
|
||||
|
||||
const { processingOrder } = buildGroupHierarchy(groups);
|
||||
// Both are leaves, order is deterministic (queue order)
|
||||
expect(processingOrder).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("handles deep nesting (3 levels)", () => {
|
||||
const groups: LayoutGroup[] = [
|
||||
{
|
||||
id: "root",
|
||||
title: "Root",
|
||||
memberIds: [],
|
||||
childGroupIds: ["mid"],
|
||||
},
|
||||
{
|
||||
id: "mid",
|
||||
title: "Mid",
|
||||
memberIds: [],
|
||||
childGroupIds: ["leaf"],
|
||||
},
|
||||
{
|
||||
id: "leaf",
|
||||
title: "Leaf",
|
||||
memberIds: ["A"],
|
||||
childGroupIds: [],
|
||||
},
|
||||
];
|
||||
|
||||
const { processingOrder } = buildGroupHierarchy(groups);
|
||||
expect(processingOrder.map((g) => g.id)).toEqual(["leaf", "mid", "root"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: splitDisconnected
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("splitDisconnected", () => {
|
||||
it("splits nodes with and without edges", () => {
|
||||
const nodes = [node("A"), node("B"), node("C")];
|
||||
const edges = [edge("A", "B")];
|
||||
const result = splitDisconnected(nodes, edges);
|
||||
|
||||
expect(result.connected.map((n) => n.id)).toEqual(["A", "B"]);
|
||||
expect(result.disconnected.map((n) => n.id)).toEqual(["C"]);
|
||||
expect(result.connectedEdges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns all as disconnected when no edges", () => {
|
||||
const nodes = [node("A"), node("B")];
|
||||
const result = splitDisconnected(nodes, []);
|
||||
expect(result.connected).toHaveLength(0);
|
||||
expect(result.disconnected).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns all as connected when all have edges", () => {
|
||||
const nodes = [node("A"), node("B")];
|
||||
const edges = [edge("A", "B")];
|
||||
const result = splitDisconnected(nodes, edges);
|
||||
expect(result.connected).toHaveLength(2);
|
||||
expect(result.disconnected).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores edges referencing unknown nodes", () => {
|
||||
const nodes = [node("A")];
|
||||
const edges = [edge("A", "Z")]; // Z doesn't exist
|
||||
const result = splitDisconnected(nodes, edges);
|
||||
expect(result.connected).toHaveLength(0);
|
||||
expect(result.disconnected).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps constrained boundary nodes out of disconnected placement", () => {
|
||||
const nodes: LayoutNode[] = [
|
||||
{ id: "in", width: 100, height: 60, layerConstraint: "first" },
|
||||
{ id: "out", width: 100, height: 60, layerConstraint: "last" },
|
||||
];
|
||||
const result = splitDisconnected(nodes, []);
|
||||
|
||||
expect(result.connected.map((n) => n.id)).toEqual(["in", "out"]);
|
||||
expect(result.disconnected).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: placeDisconnected
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("placeDisconnected", () => {
|
||||
it("places nodes in vertical stack left of DAG", () => {
|
||||
const nodes = [node("A", 100, 60), node("B", 80, 40)];
|
||||
const dagBounds = { minX: 200, minY: 50, maxX: 500, maxY: 300 };
|
||||
const positions = placeDisconnected(
|
||||
nodes,
|
||||
dagBounds,
|
||||
DEFAULT_FRAMEWORK_CONFIG,
|
||||
);
|
||||
|
||||
expect(positions.size).toBe(2);
|
||||
|
||||
const posA = positions.get("A")!;
|
||||
const posB = positions.get("B")!;
|
||||
|
||||
// Should be left of DAG
|
||||
expect(posA.x + 100).toBeLessThan(dagBounds.minX);
|
||||
// B should be below A
|
||||
expect(posB.y).toBeGreaterThan(posA.y);
|
||||
});
|
||||
|
||||
it("places at origin when no DAG", () => {
|
||||
const nodes = [node("A")];
|
||||
const positions = placeDisconnected(
|
||||
nodes,
|
||||
null,
|
||||
DEFAULT_FRAMEWORK_CONFIG,
|
||||
);
|
||||
expect(positions.size).toBe(1);
|
||||
expect(Number.isFinite(positions.get("A")!.x)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty map for empty input", () => {
|
||||
const positions = placeDisconnected([], null, DEFAULT_FRAMEWORK_CONFIG);
|
||||
expect(positions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: translatePositions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("translatePositions", () => {
|
||||
it("offsets all positions by dx/dy", () => {
|
||||
const positions = new Map<string, Position>([
|
||||
["A", { x: 10, y: 20 }],
|
||||
["B", { x: 30, y: 40 }],
|
||||
]);
|
||||
|
||||
const result = translatePositions(positions, 100, 200);
|
||||
|
||||
expect(result.get("A")).toEqual({ x: 110, y: 220 });
|
||||
expect(result.get("B")).toEqual({ x: 130, y: 240 });
|
||||
});
|
||||
|
||||
it("returns empty map for empty input", () => {
|
||||
const result = translatePositions(new Map(), 10, 20);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not mutate original map", () => {
|
||||
const original = new Map<string, Position>([["A", { x: 5, y: 10 }]]);
|
||||
translatePositions(original, 100, 200);
|
||||
expect(original.get("A")).toEqual({ x: 5, y: 10 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createGridAlgorithm } from "../../src/layout/algorithms/grid";
|
||||
import type { LayoutNode, LayoutInput } from "../../src/layout/types";
|
||||
|
||||
function node(id: string, width = 100, height = 60): LayoutNode {
|
||||
return { id, width, height };
|
||||
}
|
||||
|
||||
function input(nodes: LayoutNode[]): LayoutInput {
|
||||
return { nodes, edges: [] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grid layout: row-based
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("grid layout (row-based)", () => {
|
||||
it("[2ROW] with 4 nodes produces 2x2 grid", () => {
|
||||
const algo = createGridAlgorithm({ rows: 2, gap: 40 });
|
||||
const nodes = [node("A"), node("B"), node("C"), node("D")];
|
||||
const result = algo.layout(input(nodes));
|
||||
|
||||
// cols = ceil(4/2) = 2
|
||||
// Row 0: A(0,0), B(0,1)
|
||||
// Row 1: C(1,0), D(1,1)
|
||||
const posA = result.positions.get("A")!;
|
||||
const posB = result.positions.get("B")!;
|
||||
const posC = result.positions.get("C")!;
|
||||
const posD = result.positions.get("D")!;
|
||||
|
||||
// cellWidth = 100+40 = 140, cellHeight = 60+40 = 100
|
||||
expect(posA).toEqual({ x: 0, y: 0 });
|
||||
expect(posB).toEqual({ x: 140, y: 0 });
|
||||
expect(posC).toEqual({ x: 0, y: 100 });
|
||||
expect(posD).toEqual({ x: 140, y: 100 });
|
||||
});
|
||||
|
||||
it("[2ROW] with 1 node places at origin", () => {
|
||||
const algo = createGridAlgorithm({ rows: 2, gap: 40 });
|
||||
const result = algo.layout(input([node("A")]));
|
||||
|
||||
expect(result.positions.get("A")).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("[3ROW] with 7 nodes distributes correctly", () => {
|
||||
const algo = createGridAlgorithm({ rows: 3, gap: 20 });
|
||||
const nodes = Array.from({ length: 7 }, (_, i) => node(`N${i}`, 80, 50));
|
||||
const result = algo.layout(input(nodes));
|
||||
|
||||
// cols = ceil(7/3) = 3
|
||||
// Row 0: N0, N1, N2
|
||||
// Row 1: N3, N4, N5
|
||||
// Row 2: N6
|
||||
expect(result.positions.size).toBe(7);
|
||||
|
||||
const posN0 = result.positions.get("N0")!;
|
||||
const posN3 = result.positions.get("N3")!;
|
||||
const posN6 = result.positions.get("N6")!;
|
||||
|
||||
// cellWidth = 80+20 = 100, cellHeight = 50+20 = 70
|
||||
expect(posN0).toEqual({ x: 0, y: 0 });
|
||||
expect(posN3).toEqual({ x: 0, y: 70 });
|
||||
expect(posN6).toEqual({ x: 0, y: 140 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grid layout: column-based
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("grid layout (column-based)", () => {
|
||||
it("[3COL] with 9 nodes produces 3x3 grid", () => {
|
||||
const algo = createGridAlgorithm({ columns: 3, gap: 40 });
|
||||
const nodes = Array.from({ length: 9 }, (_, i) => node(`N${i}`));
|
||||
const result = algo.layout(input(nodes));
|
||||
|
||||
// rows = ceil(9/3) = 3
|
||||
// Row 0: N0, N1, N2
|
||||
// Row 1: N3, N4, N5
|
||||
// Row 2: N6, N7, N8
|
||||
expect(result.positions.size).toBe(9);
|
||||
|
||||
const posN0 = result.positions.get("N0")!;
|
||||
const posN4 = result.positions.get("N4")!;
|
||||
const posN8 = result.positions.get("N8")!;
|
||||
|
||||
expect(posN0).toEqual({ x: 0, y: 0 });
|
||||
expect(posN4).toEqual({ x: 140, y: 100 }); // (row=1, col=1)
|
||||
expect(posN8).toEqual({ x: 280, y: 200 }); // (row=2, col=2)
|
||||
});
|
||||
|
||||
it("[2COL] with 5 nodes: 3 rows x 2 columns", () => {
|
||||
const algo = createGridAlgorithm({ columns: 2, gap: 40 });
|
||||
const nodes = Array.from({ length: 5 }, (_, i) => node(`N${i}`));
|
||||
const result = algo.layout(input(nodes));
|
||||
|
||||
// rows = ceil(5/2) = 3
|
||||
// Row 0: N0, N1
|
||||
// Row 1: N2, N3
|
||||
// Row 2: N4
|
||||
expect(result.positions.size).toBe(5);
|
||||
|
||||
const posN4 = result.positions.get("N4")!;
|
||||
expect(posN4).toEqual({ x: 0, y: 200 }); // (row=2, col=0)
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grid layout: mixed node sizes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("grid layout with different node sizes", () => {
|
||||
it("uses max dimensions for uniform cell sizing", () => {
|
||||
const algo = createGridAlgorithm({ columns: 2, gap: 20 });
|
||||
const nodes = [
|
||||
node("small", 60, 30),
|
||||
node("wide", 150, 30),
|
||||
node("tall", 60, 100),
|
||||
node("big", 150, 100),
|
||||
];
|
||||
const result = algo.layout(input(nodes));
|
||||
|
||||
// maxWidth = 150, maxHeight = 100
|
||||
// cellWidth = 150+20 = 170, cellHeight = 100+20 = 120
|
||||
|
||||
const posSmall = result.positions.get("small")!;
|
||||
const posWide = result.positions.get("wide")!;
|
||||
const posTall = result.positions.get("tall")!;
|
||||
const posBig = result.positions.get("big")!;
|
||||
|
||||
// "small" at (0,0): centered in cell → x = (150-60)/2 = 45, y = (100-30)/2 = 35
|
||||
expect(posSmall).toEqual({ x: 45, y: 35 });
|
||||
|
||||
// "wide" at (0,1): x = 170 + (150-150)/2 = 170, y = (100-30)/2 = 35
|
||||
expect(posWide).toEqual({ x: 170, y: 35 });
|
||||
|
||||
// "tall" at (1,0): x = (150-60)/2 = 45, y = 120 + (100-100)/2 = 120
|
||||
expect(posTall).toEqual({ x: 45, y: 120 });
|
||||
|
||||
// "big" at (1,1): x = 170 + 0 = 170, y = 120 + 0 = 120
|
||||
expect(posBig).toEqual({ x: 170, y: 120 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grid layout: determinism
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("grid layout determinism", () => {
|
||||
it("produces identical output for identical input", () => {
|
||||
const algo = createGridAlgorithm({ rows: 2, gap: 40 });
|
||||
const nodes = [node("A"), node("B"), node("C"), node("D"), node("E")];
|
||||
|
||||
const result1 = algo.layout(input(nodes));
|
||||
const result2 = algo.layout(input(nodes));
|
||||
|
||||
expect(result1.positions.size).toBe(result2.positions.size);
|
||||
for (const [id, pos1] of result1.positions) {
|
||||
const pos2 = result2.positions.get(id)!;
|
||||
expect(pos1.x).toBe(pos2.x);
|
||||
expect(pos1.y).toBe(pos2.y);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grid layout: default (no rows/columns specified)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("grid layout default (sqrt columns)", () => {
|
||||
it("uses sqrt(n) columns when neither rows nor columns given", () => {
|
||||
const algo = createGridAlgorithm({ gap: 40 });
|
||||
const nodes = Array.from({ length: 9 }, (_, i) => node(`N${i}`));
|
||||
const result = algo.layout(input(nodes));
|
||||
|
||||
// sqrt(9) = 3 cols, 3 rows
|
||||
expect(result.positions.size).toBe(9);
|
||||
|
||||
// N0 at (0,0), N3 at (1,0), N6 at (2,0)
|
||||
const posN0 = result.positions.get("N0")!;
|
||||
const posN3 = result.positions.get("N3")!;
|
||||
const posN6 = result.positions.get("N6")!;
|
||||
|
||||
expect(posN0.y).toBe(0);
|
||||
expect(posN3.y).toBe(100); // row 1
|
||||
expect(posN6.y).toBe(200); // row 2
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getCurrentGraph, getSelectedGroups } from "../../src/runtime";
|
||||
import { isGroup } from "../../src/utils";
|
||||
import {
|
||||
SETTING_IDS,
|
||||
SETTINGS_PREFIX,
|
||||
EXTENSION_NAME,
|
||||
REPOSITORY_URL,
|
||||
CURRENT_VERSION,
|
||||
DEFAULT_ALGORITHM_OPTIONS,
|
||||
} from "../../src/settings";
|
||||
import { DEFAULT_FRAMEWORK_CONFIG } from "../../src/layout/types";
|
||||
import type { Position, GroupBounds } from "../../src/layout/types";
|
||||
import { computeGraphBounds } from "../../src/bounds";
|
||||
import type { LayoutInput, LayoutNode } from "../../src/layout/types";
|
||||
|
||||
describe("isGroup", () => {
|
||||
it("identifies a plain group without a type", () => {
|
||||
expect(
|
||||
isGroup({ id: 1, title: "Group", pos: [0, 0], size: [100, 100] }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers the LGraphGroup constructor tag when present", () => {
|
||||
expect(
|
||||
isGroup({
|
||||
id: 1,
|
||||
title: "Runtime Group",
|
||||
pos: [0, 0],
|
||||
constructor: { name: "LGraphGroup" },
|
||||
type: "UnexpectedNodeType",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a node with a string type", () => {
|
||||
expect(
|
||||
isGroup({
|
||||
id: 1,
|
||||
title: "Sampler",
|
||||
type: "KSampler",
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a reroute node", () => {
|
||||
expect(
|
||||
isGroup({
|
||||
id: 1,
|
||||
title: "Reroute",
|
||||
type: "Reroute",
|
||||
pos: [0, 0],
|
||||
size: [30, 30],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats null and undefined types as groups", () => {
|
||||
expect(
|
||||
isGroup({ id: 1, title: "Null Type", type: null, pos: [0, 0] }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isGroup({ id: 2, title: "Undefined Type", type: undefined, pos: [0, 0] }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects items without a string title", () => {
|
||||
expect(isGroup({ id: 1, pos: [0, 0] })).toBe(false);
|
||||
expect(
|
||||
isGroup({ id: 2, title: undefined, pos: [0, 0] }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty-string type because runtime nodes use string types", () => {
|
||||
expect(
|
||||
isGroup({ id: 1, title: "Empty Type", type: "", pos: [0, 0] }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentGraph", () => {
|
||||
it("prefers canvas.getCurrentGraph when available", () => {
|
||||
const visibleGraph = { _nodes: [], _groups: [], links: new Map() };
|
||||
const fallbackCanvasGraph = { _nodes: [1], _groups: [], links: new Map() };
|
||||
const fallbackAppGraph = { _nodes: [2], _groups: [], links: new Map() };
|
||||
|
||||
expect(
|
||||
getCurrentGraph({
|
||||
canvas: {
|
||||
graph: fallbackCanvasGraph,
|
||||
getCurrentGraph: () => visibleGraph,
|
||||
},
|
||||
graph: fallbackAppGraph,
|
||||
}),
|
||||
).toBe(visibleGraph);
|
||||
});
|
||||
|
||||
it("falls back to canvas.graph, then app.graph", () => {
|
||||
const canvasGraph = { _nodes: [], _groups: [], links: new Map() };
|
||||
const appGraph = { _nodes: [], _groups: [], links: new Map() };
|
||||
|
||||
expect(
|
||||
getCurrentGraph({
|
||||
canvas: { graph: canvasGraph },
|
||||
graph: appGraph,
|
||||
}),
|
||||
).toBe(canvasGraph);
|
||||
expect(
|
||||
getCurrentGraph({
|
||||
canvas: { graph: null },
|
||||
graph: appGraph,
|
||||
}),
|
||||
).toBe(appGraph);
|
||||
});
|
||||
|
||||
it("returns null when no graph is available", () => {
|
||||
expect(getCurrentGraph({ canvas: null })).toBeNull();
|
||||
expect(getCurrentGraph({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SETTING_IDS", () => {
|
||||
it("has all expected setting IDs with correct prefix", () => {
|
||||
const expectedKeys = [
|
||||
"VERSION",
|
||||
"KEYBINDINGS",
|
||||
"DEFAULT_ALGORITHM",
|
||||
"HORIZONTAL_GAP",
|
||||
"VERTICAL_GAP",
|
||||
"GROUP_PADDING",
|
||||
"DISCONNECTED_GAP",
|
||||
"FIT_TO_VIEW",
|
||||
"DEBUG_LOGGING",
|
||||
] as const;
|
||||
|
||||
for (const key of expectedKeys) {
|
||||
expect(SETTING_IDS[key]).toBeDefined();
|
||||
expect(SETTING_IDS[key]).toContain(SETTINGS_PREFIX);
|
||||
}
|
||||
});
|
||||
|
||||
it("has unique IDs for all settings", () => {
|
||||
const values = Object.values(SETTING_IDS);
|
||||
expect(new Set(values).size).toBe(values.length);
|
||||
});
|
||||
|
||||
it("spacing IDs map to correct FrameworkConfig defaults", () => {
|
||||
expect(DEFAULT_FRAMEWORK_CONFIG.horizontalGap).toBe(100);
|
||||
expect(DEFAULT_FRAMEWORK_CONFIG.verticalGap).toBe(40);
|
||||
expect(DEFAULT_FRAMEWORK_CONFIG.groupPadding).toBe(30);
|
||||
expect(DEFAULT_FRAMEWORK_CONFIG.disconnectedGap).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings constants", () => {
|
||||
const packageVersion = JSON.parse(
|
||||
readFileSync(new URL("../../package.json", import.meta.url), "utf-8"),
|
||||
) as { version: string };
|
||||
const pyproject = readFileSync(
|
||||
new URL("../../pyproject.toml", import.meta.url),
|
||||
"utf-8",
|
||||
);
|
||||
const pyprojectVersion =
|
||||
pyproject.match(/^version = "(.+)"$/m)?.[1] ?? null;
|
||||
const bumpCurrentVersion =
|
||||
pyproject.match(/^current_version = "(.+)"$/m)?.[1] ?? null;
|
||||
|
||||
it("has a valid extension name", () => {
|
||||
expect(EXTENSION_NAME).toBe("comfy-node-organizer");
|
||||
});
|
||||
|
||||
it("has a valid repository URL", () => {
|
||||
expect(REPOSITORY_URL).toContain("github.com");
|
||||
expect(REPOSITORY_URL).toContain("comfyui-node-organizer");
|
||||
});
|
||||
|
||||
it("has a semver version string", () => {
|
||||
expect(CURRENT_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
it("keeps CURRENT_VERSION in sync with package.json", () => {
|
||||
expect(CURRENT_VERSION).toBe(packageVersion.version);
|
||||
});
|
||||
|
||||
it("keeps pyproject version metadata in sync with CURRENT_VERSION", () => {
|
||||
expect(pyprojectVersion).toBe(CURRENT_VERSION);
|
||||
expect(bumpCurrentVersion).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
it("includes sugiyama, horizontal, and vertical algorithms", () => {
|
||||
expect(DEFAULT_ALGORITHM_OPTIONS).toContain("sugiyama");
|
||||
expect(DEFAULT_ALGORITHM_OPTIONS).toContain("horizontal");
|
||||
expect(DEFAULT_ALGORITHM_OPTIONS).toContain("vertical");
|
||||
expect(DEFAULT_ALGORITHM_OPTIONS.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createConfiguredDefaultAlgorithm", () => {
|
||||
it("passes spacing config into default algorithm factories", async () => {
|
||||
const globalScope = globalThis as typeof globalThis & {
|
||||
app?: {
|
||||
registerExtension: (extension: unknown) => void;
|
||||
extensionManager?: { setting?: { get: <T>(_id: string) => T | undefined } };
|
||||
canvas?: null;
|
||||
};
|
||||
};
|
||||
const originalApp = globalScope.app;
|
||||
|
||||
globalScope.app = {
|
||||
registerExtension: () => undefined,
|
||||
extensionManager: {
|
||||
setting: {
|
||||
get: () => undefined,
|
||||
},
|
||||
},
|
||||
canvas: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const { createConfiguredDefaultAlgorithm } = await import("../../src/index");
|
||||
|
||||
const twoNodes: LayoutNode[] = [
|
||||
{ id: "a", width: 10, height: 10 },
|
||||
{ id: "b", width: 10, height: 10 },
|
||||
];
|
||||
const verticalPair: LayoutInput = {
|
||||
nodes: twoNodes,
|
||||
edges: [],
|
||||
};
|
||||
const sugiyamaInput: LayoutInput = {
|
||||
nodes: [
|
||||
{ id: "left-a", width: 10, height: 10 },
|
||||
{ id: "left-b", width: 10, height: 10 },
|
||||
{ id: "right", width: 10, height: 10 },
|
||||
],
|
||||
edges: [
|
||||
{ source: "left-a", target: "right" },
|
||||
{ source: "left-b", target: "right" },
|
||||
],
|
||||
};
|
||||
|
||||
const horizontal = createConfiguredDefaultAlgorithm("horizontal", {
|
||||
horizontalGap: 123,
|
||||
});
|
||||
expect(horizontal.layout(verticalPair).positions.get("b")?.x).toBe(133);
|
||||
|
||||
const vertical = createConfiguredDefaultAlgorithm("vertical", {
|
||||
verticalGap: 77,
|
||||
});
|
||||
expect(vertical.layout(verticalPair).positions.get("b")?.y).toBe(87);
|
||||
|
||||
const sugiyama = createConfiguredDefaultAlgorithm("sugiyama", {
|
||||
horizontalGap: 123,
|
||||
verticalGap: 77,
|
||||
});
|
||||
const sugiyamaPositions = sugiyama.layout(sugiyamaInput).positions;
|
||||
expect(sugiyamaPositions.get("right")?.x).toBe(133);
|
||||
expect(sugiyamaPositions.get("left-b")?.y).toBe(87);
|
||||
} finally {
|
||||
if (originalApp === undefined) {
|
||||
delete globalScope.app;
|
||||
} else {
|
||||
globalScope.app = originalApp;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSelectedGroups", () => {
|
||||
it("returns only selected groups", () => {
|
||||
const selectedItems = [
|
||||
{ id: 100, title: "Group A", pos: [0, 0] as [number, number] },
|
||||
{ id: 1, title: "Sampler", type: "KSampler", pos: [10, 10] as [number, number] },
|
||||
{ id: 101, title: "Group B", pos: [20, 20] as [number, number] },
|
||||
];
|
||||
|
||||
expect(getSelectedGroups({ graph: null, selectedItems })).toEqual([
|
||||
{ id: 100, title: "Group A" },
|
||||
{ id: 101, title: "Group B" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty array when nothing group-like is selected", () => {
|
||||
expect(getSelectedGroups(undefined)).toEqual([]);
|
||||
expect(getSelectedGroups({ graph: null })).toEqual([]);
|
||||
expect(
|
||||
getSelectedGroups({
|
||||
graph: null,
|
||||
selectedItems: [{ id: 1, title: "Node", type: "KSampler", pos: [0, 0] }],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeGraphBounds", () => {
|
||||
const makeNode = (id: number, x: number, y: number, w: number, h: number) =>
|
||||
({ id, pos: [x, y], size: [w, h] });
|
||||
|
||||
it("computes bounds from regular nodes", () => {
|
||||
const nodes = [makeNode(1, 0, 0, 100, 50), makeNode(2, 200, 100, 100, 50)];
|
||||
const positions = new Map<string, Position>([
|
||||
["1", { x: 0, y: 0 }],
|
||||
["2", { x: 200, y: 100 }],
|
||||
]);
|
||||
|
||||
const result = computeGraphBounds(nodes, undefined, undefined, positions, new Map());
|
||||
expect(result).toEqual([0, 0, 300, 150]);
|
||||
});
|
||||
|
||||
it("returns null when no items have positions", () => {
|
||||
const result = computeGraphBounds([], undefined, undefined, new Map(), new Map());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("includes inputNode in bounds", () => {
|
||||
const nodes = [makeNode(1, 100, 50, 100, 50)];
|
||||
const inputNode = makeNode(-10, -200, 0, 50, 30);
|
||||
const positions = new Map<string, Position>([
|
||||
["1", { x: 100, y: 50 }],
|
||||
["-10", { x: -200, y: 0 }],
|
||||
]);
|
||||
|
||||
const result = computeGraphBounds(nodes, inputNode, undefined, positions, new Map());
|
||||
// Without inputNode: bounds would be [100, 50, 100, 50]
|
||||
// With inputNode: bounds should extend left to -200
|
||||
expect(result).toEqual([-200, 0, 400, 100]);
|
||||
});
|
||||
|
||||
it("includes outputNode in bounds", () => {
|
||||
const nodes = [makeNode(1, 0, 0, 100, 50)];
|
||||
const outputNode = makeNode(-20, 500, 0, 50, 30);
|
||||
const positions = new Map<string, Position>([
|
||||
["1", { x: 0, y: 0 }],
|
||||
["-20", { x: 500, y: 0 }],
|
||||
]);
|
||||
|
||||
const result = computeGraphBounds(nodes, undefined, outputNode, positions, new Map());
|
||||
// Without outputNode: bounds would be [0, 0, 100, 50]
|
||||
// With outputNode: bounds should extend right to 550
|
||||
expect(result).toEqual([0, 0, 550, 50]);
|
||||
});
|
||||
|
||||
it("includes both boundary nodes in subgraph scenario", () => {
|
||||
const nodes = [makeNode(1, 100, 50, 100, 50)];
|
||||
const inputNode = makeNode(-10, -200, 0, 50, 30);
|
||||
const outputNode = makeNode(-20, 500, 0, 50, 30);
|
||||
const positions = new Map<string, Position>([
|
||||
["1", { x: 100, y: 50 }],
|
||||
["-10", { x: -200, y: 0 }],
|
||||
["-20", { x: 500, y: 0 }],
|
||||
]);
|
||||
|
||||
const result = computeGraphBounds(nodes, inputNode, outputNode, positions, new Map());
|
||||
expect(result).toEqual([-200, 0, 750, 100]);
|
||||
});
|
||||
|
||||
it("includes group bounds", () => {
|
||||
const nodes = [makeNode(1, 50, 50, 100, 50)];
|
||||
const positions = new Map<string, Position>([["1", { x: 50, y: 50 }]]);
|
||||
const groupBounds = new Map<string, GroupBounds>([
|
||||
["group:1", { x: 0, y: 0, width: 300, height: 200 }],
|
||||
]);
|
||||
|
||||
const result = computeGraphBounds(nodes, undefined, undefined, positions, groupBounds);
|
||||
expect(result).toEqual([0, 0, 300, 200]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isWorkflowData,
|
||||
parseInstalledTemplateManifest,
|
||||
} from "../e2e/installed-templates";
|
||||
|
||||
describe("installed template parsing", () => {
|
||||
it("parses raw ids and json paths", () => {
|
||||
const raw = JSON.stringify([
|
||||
{
|
||||
id: "api_google_gemini_image",
|
||||
path: "workflow.json",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(parseInstalledTemplateManifest(raw)).toEqual([
|
||||
{
|
||||
id: "api_google_gemini_image",
|
||||
path: "workflow.json",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an empty template manifest", () => {
|
||||
expect(() => parseInstalledTemplateManifest("[]")).toThrow(/zero templates/i);
|
||||
});
|
||||
|
||||
it("recognizes workflow graph data", () => {
|
||||
expect(
|
||||
isWorkflowData({
|
||||
nodes: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-workflow template metadata", () => {
|
||||
expect(
|
||||
isWorkflowData({
|
||||
$schema: "https://example.com",
|
||||
title: "Schema",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fc from "fast-check";
|
||||
import { createSugiyamaAlgorithm } from "../../src/layout/algorithms/sugiyama";
|
||||
import { layoutWithGroups } from "../../src/layout/framework";
|
||||
import type {
|
||||
LayoutEdge,
|
||||
LayoutGroup,
|
||||
LayoutNode,
|
||||
LayoutToken,
|
||||
Position,
|
||||
GroupBounds,
|
||||
} from "../../src/layout/types";
|
||||
|
||||
const algorithm = createSugiyamaAlgorithm();
|
||||
|
||||
function makeNodes(nodeCount: number): fc.Arbitrary<LayoutNode[]> {
|
||||
return fc.tuple(
|
||||
...Array.from({ length: nodeCount }, (_, index) =>
|
||||
fc.record({
|
||||
id: fc.constant(String(index)),
|
||||
width: fc.integer({ min: 80, max: 320 }),
|
||||
height: fc.integer({ min: 40, max: 220 }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function makeDagEdges(nodeIds: string[], maxEdges: number): fc.Arbitrary<LayoutEdge[]> {
|
||||
const candidates = nodeIds.flatMap((source, sourceIndex) =>
|
||||
nodeIds
|
||||
.slice(sourceIndex + 1)
|
||||
.map((target) => ({ source, target })),
|
||||
);
|
||||
|
||||
return fc.subarray(candidates, {
|
||||
minLength: 0,
|
||||
maxLength: Math.min(maxEdges, candidates.length),
|
||||
});
|
||||
}
|
||||
|
||||
function dagArbitrary(opts: {
|
||||
readonly minNodes: number;
|
||||
readonly maxNodes: number;
|
||||
readonly maxEdges: number;
|
||||
}): fc.Arbitrary<{ nodes: LayoutNode[]; edges: LayoutEdge[] }> {
|
||||
return fc
|
||||
.integer({ min: opts.minNodes, max: opts.maxNodes })
|
||||
.chain((nodeCount) =>
|
||||
makeNodes(nodeCount).chain((nodes) =>
|
||||
makeDagEdges(
|
||||
nodes.map((node) => node.id),
|
||||
opts.maxEdges,
|
||||
).map((edges) => ({ nodes, edges })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function groupedDagArbitrary(): fc.Arbitrary<{
|
||||
nodes: LayoutNode[];
|
||||
edges: LayoutEdge[];
|
||||
groups: LayoutGroup[];
|
||||
}> {
|
||||
return fc
|
||||
.integer({ min: 4, max: 8 })
|
||||
.chain((nodeCount) =>
|
||||
makeNodes(nodeCount).chain((nodes) => {
|
||||
const nodeIds = nodes.map((node) => node.id);
|
||||
return fc
|
||||
.tuple(
|
||||
makeDagEdges(nodeIds, nodeCount * 2),
|
||||
fc.subarray(nodeIds, { minLength: 2, maxLength: nodeCount - 1 }),
|
||||
)
|
||||
.chain(([edges, innerMemberIds]) => {
|
||||
const remaining = nodeIds.filter((id) => !innerMemberIds.includes(id));
|
||||
return fc
|
||||
.subarray(remaining, {
|
||||
minLength: 1,
|
||||
maxLength: remaining.length,
|
||||
})
|
||||
.map((outerDirectMemberIds) => ({
|
||||
nodes,
|
||||
edges,
|
||||
groups: [
|
||||
{
|
||||
id: "group:inner",
|
||||
title: "Inner",
|
||||
memberIds: innerMemberIds,
|
||||
childGroupIds: [],
|
||||
},
|
||||
{
|
||||
id: "group:outer",
|
||||
title: "Outer",
|
||||
memberIds: outerDirectMemberIds,
|
||||
childGroupIds: ["group:inner"],
|
||||
},
|
||||
],
|
||||
}));
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function tokenArbitrary(): fc.Arbitrary<LayoutToken> {
|
||||
return fc.oneof(
|
||||
fc.constant<LayoutToken>({ mode: "horizontal" }),
|
||||
fc.constant<LayoutToken>({ mode: "vertical" }),
|
||||
fc.integer({ min: 2, max: 9 }).map<LayoutToken>((count) => ({
|
||||
mode: "grid",
|
||||
count,
|
||||
dimension: "row",
|
||||
})),
|
||||
fc.integer({ min: 2, max: 9 }).map<LayoutToken>((count) => ({
|
||||
mode: "grid",
|
||||
count,
|
||||
dimension: "col",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function tokenizedGroupedDagArbitrary(): fc.Arbitrary<{
|
||||
nodes: LayoutNode[];
|
||||
edges: LayoutEdge[];
|
||||
groups: LayoutGroup[];
|
||||
}> {
|
||||
return fc
|
||||
.integer({ min: 4, max: 8 })
|
||||
.chain((nodeCount) =>
|
||||
makeNodes(nodeCount).chain((nodes) => {
|
||||
const nodeIds = nodes.map((node) => node.id);
|
||||
return fc
|
||||
.tuple(
|
||||
makeDagEdges(nodeIds, nodeCount * 2),
|
||||
fc.subarray(nodeIds, { minLength: 2, maxLength: nodeCount - 1 }),
|
||||
)
|
||||
.chain(([edges, innerMemberIds]) => {
|
||||
const remaining = nodeIds.filter((id) => !innerMemberIds.includes(id));
|
||||
return fc
|
||||
.tuple(
|
||||
fc.subarray(remaining, {
|
||||
minLength: 1,
|
||||
maxLength: remaining.length,
|
||||
}),
|
||||
tokenArbitrary(),
|
||||
tokenArbitrary(),
|
||||
)
|
||||
.map(([outerDirectMemberIds, innerToken, outerToken]) => ({
|
||||
nodes,
|
||||
edges,
|
||||
groups: [
|
||||
{
|
||||
id: "group:inner",
|
||||
title: "Inner",
|
||||
memberIds: innerMemberIds,
|
||||
childGroupIds: [],
|
||||
token: innerToken,
|
||||
},
|
||||
{
|
||||
id: "group:outer",
|
||||
title: "Outer",
|
||||
memberIds: outerDirectMemberIds,
|
||||
childGroupIds: ["group:inner"],
|
||||
token: outerToken,
|
||||
},
|
||||
],
|
||||
}));
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function assertFinitePositions(
|
||||
positions: ReadonlyMap<string, Position>,
|
||||
): void {
|
||||
for (const [id, pos] of positions) {
|
||||
expect(Number.isFinite(pos.x), `Node ${id} has non-finite x`).toBe(true);
|
||||
expect(Number.isFinite(pos.y), `Node ${id} has non-finite y`).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllNodesPositioned(
|
||||
nodes: ReadonlyArray<LayoutNode>,
|
||||
positions: ReadonlyMap<string, Position>,
|
||||
): void {
|
||||
for (const node of nodes) {
|
||||
expect(positions.has(node.id), `Missing position for node ${node.id}`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoOverlaps(
|
||||
nodes: ReadonlyArray<LayoutNode>,
|
||||
positions: ReadonlyMap<string, Position>,
|
||||
): void {
|
||||
for (let index = 0; index < nodes.length; index += 1) {
|
||||
for (let otherIndex = index + 1; otherIndex < nodes.length; otherIndex += 1) {
|
||||
const left = nodes[index];
|
||||
const right = nodes[otherIndex];
|
||||
const leftPos = positions.get(left.id);
|
||||
const rightPos = positions.get(right.id);
|
||||
expect(leftPos, `Missing position for node ${left.id}`).toBeDefined();
|
||||
expect(rightPos, `Missing position for node ${right.id}`).toBeDefined();
|
||||
|
||||
const overlap =
|
||||
leftPos!.x < rightPos!.x + right.width &&
|
||||
leftPos!.x + left.width > rightPos!.x &&
|
||||
leftPos!.y < rightPos!.y + right.height &&
|
||||
leftPos!.y + left.height > rightPos!.y;
|
||||
|
||||
expect(
|
||||
overlap,
|
||||
`Nodes ${left.id} and ${right.id} overlap: ` +
|
||||
`[${leftPos!.x}, ${leftPos!.y}, ${left.width}, ${left.height}] vs ` +
|
||||
`[${rightPos!.x}, ${rightPos!.y}, ${right.width}, ${right.height}]`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveBounds(
|
||||
groupId: string,
|
||||
bounds: GroupBounds | undefined,
|
||||
): asserts bounds is GroupBounds {
|
||||
if (!bounds) {
|
||||
throw new Error(`Missing bounds for group ${groupId}`);
|
||||
}
|
||||
expect(bounds.width, `Group ${groupId} width must be positive`).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
bounds.height,
|
||||
`Group ${groupId} height must be positive`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
function assertGroupContainsMembers(
|
||||
groups: ReadonlyArray<LayoutGroup>,
|
||||
nodes: ReadonlyArray<LayoutNode>,
|
||||
positions: ReadonlyMap<string, Position>,
|
||||
boundsByGroup: ReadonlyMap<string, GroupBounds>,
|
||||
): void {
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
|
||||
for (const group of groups) {
|
||||
const bounds = boundsByGroup.get(group.id);
|
||||
assertPositiveBounds(group.id, bounds);
|
||||
|
||||
for (const memberId of group.memberIds) {
|
||||
const node = nodeById.get(memberId);
|
||||
const pos = positions.get(memberId);
|
||||
expect(node, `Missing node ${memberId}`).toBeDefined();
|
||||
expect(pos, `Missing position for node ${memberId}`).toBeDefined();
|
||||
|
||||
expect(pos!.x).toBeGreaterThanOrEqual(bounds.x);
|
||||
expect(pos!.y).toBeGreaterThanOrEqual(bounds.y);
|
||||
expect(pos!.x + node!.width).toBeLessThanOrEqual(bounds.x + bounds.width);
|
||||
expect(pos!.y + node!.height).toBeLessThanOrEqual(
|
||||
bounds.y + bounds.height,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertIdempotent(
|
||||
nodes: ReadonlyArray<LayoutNode>,
|
||||
edges: ReadonlyArray<LayoutEdge>,
|
||||
groups: ReadonlyArray<LayoutGroup>,
|
||||
): void {
|
||||
const first = layoutWithGroups(nodes, edges, groups, algorithm);
|
||||
const second = layoutWithGroups(nodes, edges, groups, algorithm);
|
||||
|
||||
expect(second.positions.size).toBe(first.positions.size);
|
||||
expect(second.groupBounds.size).toBe(first.groupBounds.size);
|
||||
|
||||
for (const [id, firstPos] of first.positions) {
|
||||
expect(second.positions.get(id)).toEqual(firstPos);
|
||||
}
|
||||
|
||||
for (const [id, firstBounds] of first.groupBounds) {
|
||||
expect(second.groupBounds.get(id)).toEqual(firstBounds);
|
||||
}
|
||||
}
|
||||
|
||||
describe("property-based layout invariants", () => {
|
||||
it("produces finite positions and places every node for random DAGs", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
dagArbitrary({ minNodes: 1, maxNodes: 20, maxEdges: 30 }),
|
||||
({ nodes, edges }) => {
|
||||
const result = layoutWithGroups(nodes, edges, [], algorithm);
|
||||
assertFinitePositions(result.positions);
|
||||
assertAllNodesPositioned(nodes, result.positions);
|
||||
},
|
||||
),
|
||||
{ numRuns: 150 },
|
||||
);
|
||||
});
|
||||
|
||||
it("avoids node overlaps for random DAGs without groups", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
dagArbitrary({ minNodes: 1, maxNodes: 15, maxEdges: 20 }),
|
||||
({ nodes, edges }) => {
|
||||
const result = layoutWithGroups(nodes, edges, [], algorithm);
|
||||
assertNoOverlaps(nodes, result.positions);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent for random DAGs", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
dagArbitrary({ minNodes: 1, maxNodes: 15, maxEdges: 20 }),
|
||||
({ nodes, edges }) => {
|
||||
assertIdempotent(nodes, edges, []);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps grouped members inside positive group bounds", () => {
|
||||
fc.assert(
|
||||
fc.property(groupedDagArbitrary(), ({ nodes, edges, groups }) => {
|
||||
const result = layoutWithGroups(nodes, edges, groups, algorithm);
|
||||
assertFinitePositions(result.positions);
|
||||
assertAllNodesPositioned(nodes, result.positions);
|
||||
assertGroupContainsMembers(
|
||||
groups,
|
||||
nodes,
|
||||
result.positions,
|
||||
result.groupBounds,
|
||||
);
|
||||
assertIdempotent(nodes, edges, groups);
|
||||
}),
|
||||
{ numRuns: 75 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property-based token layout invariants", () => {
|
||||
it("produces finite positions and places every node with tokenized groups", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
tokenizedGroupedDagArbitrary(),
|
||||
({ nodes, edges, groups }) => {
|
||||
const result = layoutWithGroups(nodes, edges, groups, algorithm);
|
||||
assertFinitePositions(result.positions);
|
||||
assertAllNodesPositioned(nodes, result.positions);
|
||||
},
|
||||
),
|
||||
{ numRuns: 75 },
|
||||
);
|
||||
});
|
||||
|
||||
it("avoids node overlaps with tokenized groups", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
tokenizedGroupedDagArbitrary(),
|
||||
({ nodes, edges, groups }) => {
|
||||
const result = layoutWithGroups(nodes, edges, groups, algorithm);
|
||||
assertNoOverlaps(nodes, result.positions);
|
||||
},
|
||||
),
|
||||
{ numRuns: 75 },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps tokenized group members inside positive group bounds", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
tokenizedGroupedDagArbitrary(),
|
||||
({ nodes, edges, groups }) => {
|
||||
const result = layoutWithGroups(nodes, edges, groups, algorithm);
|
||||
assertGroupContainsMembers(
|
||||
groups,
|
||||
nodes,
|
||||
result.positions,
|
||||
result.groupBounds,
|
||||
);
|
||||
},
|
||||
),
|
||||
{ numRuns: 75 },
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent with tokenized groups", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
tokenizedGroupedDagArbitrary(),
|
||||
({ nodes, edges, groups }) => {
|
||||
assertIdempotent(nodes, edges, groups);
|
||||
},
|
||||
),
|
||||
{ numRuns: 75 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractPinnedWorkflowTemplatesRequirement } from "../../scripts/setup-test-comfy-helpers";
|
||||
|
||||
describe("workflow template requirement parsing", () => {
|
||||
it("extracts the exact pinned requirement", () => {
|
||||
const requirements = [
|
||||
"torchsde",
|
||||
"comfyui-workflow-templates==0.9.26",
|
||||
"kornia>=0.7.1",
|
||||
].join("\n");
|
||||
|
||||
expect(extractPinnedWorkflowTemplatesRequirement(requirements)).toBe(
|
||||
"comfyui-workflow-templates==0.9.26",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when the requirement is missing", () => {
|
||||
expect(() =>
|
||||
extractPinnedWorkflowTemplatesRequirement("torchsde==0.2.6"),
|
||||
).toThrow(/comfyui-workflow-templates/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,658 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createSugiyamaAlgorithm } from "../../src/layout/algorithms/sugiyama";
|
||||
import { breakCycles } from "../../src/layout/algorithms/sugiyama/cycle-break";
|
||||
import { assignLayers } from "../../src/layout/algorithms/sugiyama/layer-assign";
|
||||
import { minimizeCrossings } from "../../src/layout/algorithms/sugiyama/crossing-minimize";
|
||||
import { assignCoordinates } from "../../src/layout/algorithms/sugiyama/coordinate-assign";
|
||||
import type { LayoutNode, LayoutEdge, Position } from "../../src/layout/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function node(id: string, width = 200, height = 100): LayoutNode {
|
||||
return { id, width, height };
|
||||
}
|
||||
|
||||
function edge(source: string, target: string): LayoutEdge {
|
||||
return { source, target };
|
||||
}
|
||||
|
||||
function nodeMap(nodes: LayoutNode[]): ReadonlyMap<string, LayoutNode> {
|
||||
const m = new Map<string, LayoutNode>();
|
||||
for (const n of nodes) m.set(n.id, n);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Check that no two positioned nodes overlap (AABB collision). */
|
||||
function assertNoOverlaps(
|
||||
positions: ReadonlyMap<string, Position>,
|
||||
nodes: LayoutNode[],
|
||||
): void {
|
||||
const nMap = nodeMap(nodes);
|
||||
const entries = [...positions.entries()];
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
for (let j = i + 1; j < entries.length; j++) {
|
||||
const [idA, posA] = entries[i];
|
||||
const [idB, posB] = entries[j];
|
||||
const nA = nMap.get(idA)!;
|
||||
const nB = nMap.get(idB)!;
|
||||
|
||||
const overlapX =
|
||||
posA.x < posB.x + nB.width && posA.x + nA.width > posB.x;
|
||||
const overlapY =
|
||||
posA.y < posB.y + nB.height && posA.y + nA.height > posB.y;
|
||||
|
||||
expect(
|
||||
overlapX && overlapY,
|
||||
`Nodes ${idA} and ${idB} overlap`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check all coordinates are finite (no NaN/Infinity). */
|
||||
function assertFiniteCoordinates(
|
||||
positions: ReadonlyMap<string, Position>,
|
||||
): void {
|
||||
for (const [id, pos] of positions) {
|
||||
expect(Number.isFinite(pos.x), `${id}.x is not finite: ${pos.x}`).toBe(
|
||||
true,
|
||||
);
|
||||
expect(Number.isFinite(pos.y), `${id}.y is not finite: ${pos.y}`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// breakCycles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("breakCycles", () => {
|
||||
it("returns edges unchanged when no cycles exist", () => {
|
||||
const nodes = [node("A"), node("B"), node("C")];
|
||||
const edges = [edge("A", "B"), edge("B", "C")];
|
||||
const result = breakCycles(nodes, edges);
|
||||
|
||||
expect(result.reversedEdges.size).toBe(0);
|
||||
expect(result.edges).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("breaks a simple 2-node cycle", () => {
|
||||
const nodes = [node("A"), node("B")];
|
||||
const edges = [edge("A", "B"), edge("B", "A")];
|
||||
const result = breakCycles(nodes, edges);
|
||||
|
||||
// One edge should be reversed
|
||||
expect(result.reversedEdges.size).toBe(1);
|
||||
expect(result.edges).toHaveLength(2);
|
||||
|
||||
// Result should be acyclic: verify by running layer assignment (which requires DAG)
|
||||
const layers = assignLayers(nodes, result.edges);
|
||||
expect(layers.size).toBe(2);
|
||||
});
|
||||
|
||||
it("breaks a 3-node cycle A->B->C->A", () => {
|
||||
const nodes = [node("A"), node("B"), node("C")];
|
||||
const edges = [edge("A", "B"), edge("B", "C"), edge("C", "A")];
|
||||
const result = breakCycles(nodes, edges);
|
||||
|
||||
// At least one edge reversed
|
||||
expect(result.reversedEdges.size).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Result must be acyclic: all nodes should get valid layers
|
||||
const layers = assignLayers(nodes, result.edges);
|
||||
expect(layers.size).toBe(3);
|
||||
|
||||
// Verify topological ordering: for every edge, source layer < target layer
|
||||
for (const e of result.edges) {
|
||||
expect(layers.get(e.source)!).toBeLessThan(layers.get(e.target)!);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
const result = breakCycles([], []);
|
||||
expect(result.edges).toHaveLength(0);
|
||||
expect(result.reversedEdges.size).toBe(0);
|
||||
});
|
||||
|
||||
it("handles nodes with no edges", () => {
|
||||
const nodes = [node("A"), node("B")];
|
||||
const result = breakCycles(nodes, []);
|
||||
expect(result.edges).toHaveLength(0);
|
||||
expect(result.reversedEdges.size).toBe(0);
|
||||
});
|
||||
|
||||
it("handles self-loop (edge to same node)", () => {
|
||||
const nodes = [node("A"), node("B")];
|
||||
// Self-loop on A plus a normal edge
|
||||
const edges = [edge("A", "A"), edge("A", "B")];
|
||||
const result = breakCycles(nodes, edges);
|
||||
|
||||
// Self-loop might be reversed or kept — algorithm should handle it
|
||||
// Just ensure no crash and result is valid
|
||||
expect(result.edges.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// assignLayers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("assignLayers", () => {
|
||||
it("assigns single node to layer 0", () => {
|
||||
const nodes = [node("A")];
|
||||
const layers = assignLayers(nodes, []);
|
||||
expect(layers.get("A")).toBe(0);
|
||||
});
|
||||
|
||||
it("assigns linear chain A->B->C to consecutive layers", () => {
|
||||
const nodes = [node("A"), node("B"), node("C")];
|
||||
const edges = [edge("A", "B"), edge("B", "C")];
|
||||
const layers = assignLayers(nodes, edges);
|
||||
|
||||
expect(layers.get("A")).toBe(0);
|
||||
expect(layers.get("B")).toBe(1);
|
||||
expect(layers.get("C")).toBe(2);
|
||||
});
|
||||
|
||||
it("assigns diamond A->B, A->C, B->D, C->D correctly", () => {
|
||||
const nodes = [node("A"), node("B"), node("C"), node("D")];
|
||||
const edges = [
|
||||
edge("A", "B"),
|
||||
edge("A", "C"),
|
||||
edge("B", "D"),
|
||||
edge("C", "D"),
|
||||
];
|
||||
const layers = assignLayers(nodes, edges);
|
||||
|
||||
expect(layers.get("A")).toBe(0);
|
||||
expect(layers.get("B")).toBe(1);
|
||||
expect(layers.get("C")).toBe(1);
|
||||
expect(layers.get("D")).toBe(2);
|
||||
});
|
||||
|
||||
it("uses longest path (not shortest)", () => {
|
||||
// A->B->C->D and A->D
|
||||
// D should be at layer 3 (longest path), not layer 1 (shortest)
|
||||
const nodes = [node("A"), node("B"), node("C"), node("D")];
|
||||
const edges = [
|
||||
edge("A", "B"),
|
||||
edge("B", "C"),
|
||||
edge("C", "D"),
|
||||
edge("A", "D"),
|
||||
];
|
||||
const layers = assignLayers(nodes, edges);
|
||||
|
||||
expect(layers.get("A")).toBe(0);
|
||||
expect(layers.get("D")).toBe(3); // longest path: A->B->C->D
|
||||
});
|
||||
|
||||
it("handles multiple sources", () => {
|
||||
// A->C, B->C
|
||||
const nodes = [node("A"), node("B"), node("C")];
|
||||
const edges = [edge("A", "C"), edge("B", "C")];
|
||||
const layers = assignLayers(nodes, edges);
|
||||
|
||||
expect(layers.get("A")).toBe(0);
|
||||
expect(layers.get("B")).toBe(0);
|
||||
expect(layers.get("C")).toBe(1);
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
const layers = assignLayers([], []);
|
||||
expect(layers.size).toBe(0);
|
||||
});
|
||||
|
||||
it("handles disconnected nodes", () => {
|
||||
const nodes = [node("A"), node("B")];
|
||||
const layers = assignLayers(nodes, []);
|
||||
expect(layers.get("A")).toBe(0);
|
||||
expect(layers.get("B")).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves monotonic increase along edges", () => {
|
||||
const nodes = [node("A"), node("B"), node("C"), node("D"), node("E")];
|
||||
const edges = [
|
||||
edge("A", "B"),
|
||||
edge("A", "C"),
|
||||
edge("B", "D"),
|
||||
edge("C", "D"),
|
||||
edge("D", "E"),
|
||||
];
|
||||
const layers = assignLayers(nodes, edges);
|
||||
|
||||
for (const e of edges) {
|
||||
expect(layers.get(e.source)!).toBeLessThan(layers.get(e.target)!);
|
||||
}
|
||||
});
|
||||
|
||||
it("forces first and last constrained nodes to graph edges", () => {
|
||||
const nodes: LayoutNode[] = [
|
||||
{ id: "in", width: 120, height: 40, layerConstraint: "first" },
|
||||
node("mid"),
|
||||
{ id: "out", width: 120, height: 40, layerConstraint: "last" },
|
||||
];
|
||||
const edges = [edge("in", "mid"), edge("mid", "out")];
|
||||
const layers = assignLayers(nodes, edges);
|
||||
|
||||
expect(layers.get("in")).toBe(0);
|
||||
expect(layers.get("out")).toBeGreaterThan(layers.get("mid")!);
|
||||
});
|
||||
|
||||
it("separates disconnected constrained boundary nodes into distinct layers", () => {
|
||||
const nodes: LayoutNode[] = [
|
||||
{ id: "in", width: 120, height: 40, layerConstraint: "first" },
|
||||
{ id: "out", width: 120, height: 40, layerConstraint: "last" },
|
||||
];
|
||||
const layers = assignLayers(nodes, []);
|
||||
|
||||
expect(layers.get("in")).toBe(0);
|
||||
expect(layers.get("out")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// minimizeCrossings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("minimizeCrossings", () => {
|
||||
it("returns single layer unchanged", () => {
|
||||
const layers = [["A", "B", "C"]];
|
||||
const result = minimizeCrossings(layers, [], new Map());
|
||||
expect(result).toEqual([["A", "B", "C"]]);
|
||||
});
|
||||
|
||||
it("returns empty layers unchanged", () => {
|
||||
const result = minimizeCrossings([], [], new Map());
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("reduces crossings in a simple case", () => {
|
||||
// Layer 0: [A, B], Layer 1: [C, D]
|
||||
// Edges: A->D, B->C (these cross if A is before B and C is before D)
|
||||
const nodes = [node("A"), node("B"), node("C"), node("D")];
|
||||
const layers = [["A", "B"], ["C", "D"]];
|
||||
const edges = [edge("A", "D"), edge("B", "C")];
|
||||
|
||||
const result = minimizeCrossings(layers, edges, nodeMap(nodes));
|
||||
|
||||
// After minimization, should reorder to eliminate crossing
|
||||
// Either layer 0 becomes [B, A] or layer 1 becomes [D, C]
|
||||
// Verify no crossings remain
|
||||
const l0Pos = new Map<string, number>();
|
||||
const l1Pos = new Map<string, number>();
|
||||
for (let i = 0; i < result[0].length; i++) l0Pos.set(result[0][i], i);
|
||||
for (let i = 0; i < result[1].length; i++) l1Pos.set(result[1][i], i);
|
||||
|
||||
// Edge A->D and B->C should not cross
|
||||
const aPosTop = l0Pos.get("A")!;
|
||||
const bPosTop = l0Pos.get("B")!;
|
||||
const cPosBot = l1Pos.get("C")!;
|
||||
const dPosBot = l1Pos.get("D")!;
|
||||
|
||||
// Crossing means (A before B but D after C) or (A after B but D before C)
|
||||
const crosses =
|
||||
(aPosTop < bPosTop && dPosBot > cPosBot) ||
|
||||
(aPosTop > bPosTop && dPosBot < cPosBot);
|
||||
expect(crosses).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves order when no crossings exist", () => {
|
||||
// A->C, B->D (no crossing since A<B and C<D)
|
||||
const nodes = [node("A"), node("B"), node("C"), node("D")];
|
||||
const layers = [["A", "B"], ["C", "D"]];
|
||||
const edges = [edge("A", "C"), edge("B", "D")];
|
||||
|
||||
const result = minimizeCrossings(layers, edges, nodeMap(nodes));
|
||||
|
||||
// Order should remain [A, B] and [C, D]
|
||||
expect(result[0]).toEqual(["A", "B"]);
|
||||
expect(result[1]).toEqual(["C", "D"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// assignCoordinates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("assignCoordinates", () => {
|
||||
it("assigns single node at origin", () => {
|
||||
const nodes = [node("A", 200, 100)];
|
||||
const nMap = nodeMap(nodes);
|
||||
const result = assignCoordinates([["A"]], nMap, {
|
||||
horizontalGap: 100,
|
||||
verticalGap: 40,
|
||||
});
|
||||
|
||||
expect(result.get("A")).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("stacks nodes vertically within a layer", () => {
|
||||
const nodes = [node("A", 200, 100), node("B", 200, 80)];
|
||||
const nMap = nodeMap(nodes);
|
||||
const result = assignCoordinates([["A", "B"]], nMap, {
|
||||
horizontalGap: 100,
|
||||
verticalGap: 40,
|
||||
});
|
||||
|
||||
const posA = result.get("A")!;
|
||||
const posB = result.get("B")!;
|
||||
|
||||
// Same X
|
||||
expect(posA.x).toBe(posB.x);
|
||||
// B below A with gap
|
||||
expect(posB.y).toBe(posA.y + 100 + 40); // A.height + gap
|
||||
});
|
||||
|
||||
it("places layers left to right with horizontal gap", () => {
|
||||
const nodes = [node("A", 200, 100), node("B", 150, 80)];
|
||||
const nMap = nodeMap(nodes);
|
||||
const result = assignCoordinates([["A"], ["B"]], nMap, {
|
||||
horizontalGap: 100,
|
||||
verticalGap: 40,
|
||||
});
|
||||
|
||||
const posA = result.get("A")!;
|
||||
const posB = result.get("B")!;
|
||||
|
||||
expect(posA.x).toBe(0);
|
||||
expect(posB.x).toBe(200 + 100); // A.width + gap
|
||||
});
|
||||
|
||||
it("returns empty map for empty layers", () => {
|
||||
const result = assignCoordinates([], new Map(), {
|
||||
horizontalGap: 100,
|
||||
verticalGap: 40,
|
||||
});
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("centers shorter layers vertically", () => {
|
||||
// Layer 0: two nodes (taller), Layer 1: one node (shorter)
|
||||
const nodes = [
|
||||
node("A", 200, 100),
|
||||
node("B", 200, 100),
|
||||
node("C", 200, 100),
|
||||
];
|
||||
const nMap = nodeMap(nodes);
|
||||
const result = assignCoordinates([["A", "B"], ["C"]], nMap, {
|
||||
horizontalGap: 100,
|
||||
verticalGap: 40,
|
||||
});
|
||||
|
||||
const posA = result.get("A")!;
|
||||
const posC = result.get("C")!;
|
||||
|
||||
// Layer 0 total height: 100 + 40 + 100 = 240
|
||||
// Layer 1 total height: 100
|
||||
// Layer 1 y offset: (240 - 100) / 2 = 70
|
||||
expect(posA.y).toBe(0);
|
||||
expect(posC.y).toBe(70);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full Sugiyama algorithm (integration)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createSugiyamaAlgorithm", () => {
|
||||
const sugiyama = createSugiyamaAlgorithm();
|
||||
|
||||
it("handles single node", () => {
|
||||
const result = sugiyama.layout({
|
||||
nodes: [node("A")],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
expect(result.positions.size).toBe(1);
|
||||
expect(result.positions.get("A")).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("lays out linear chain A->B->C left to right", () => {
|
||||
const nodes = [node("A"), node("B"), node("C")];
|
||||
const edges = [edge("A", "B"), edge("B", "C")];
|
||||
const result = sugiyama.layout({ nodes, edges });
|
||||
|
||||
expect(result.positions.size).toBe(3);
|
||||
const posA = result.positions.get("A")!;
|
||||
const posB = result.positions.get("B")!;
|
||||
const posC = result.positions.get("C")!;
|
||||
|
||||
// X should increase left to right
|
||||
expect(posA.x).toBeLessThan(posB.x);
|
||||
expect(posB.x).toBeLessThan(posC.x);
|
||||
|
||||
assertFiniteCoordinates(result.positions);
|
||||
assertNoOverlaps(result.positions, nodes);
|
||||
});
|
||||
|
||||
it("lays out diamond graph correctly", () => {
|
||||
const nodes = [node("A"), node("B"), node("C"), node("D")];
|
||||
const edges = [
|
||||
edge("A", "B"),
|
||||
edge("A", "C"),
|
||||
edge("B", "D"),
|
||||
edge("C", "D"),
|
||||
];
|
||||
const result = sugiyama.layout({ nodes, edges });
|
||||
|
||||
expect(result.positions.size).toBe(4);
|
||||
|
||||
const posA = result.positions.get("A")!;
|
||||
const posB = result.positions.get("B")!;
|
||||
const posC = result.positions.get("C")!;
|
||||
const posD = result.positions.get("D")!;
|
||||
|
||||
// A at layer 0, B and C at layer 1, D at layer 2
|
||||
expect(posA.x).toBeLessThan(posB.x);
|
||||
expect(posA.x).toBeLessThan(posC.x);
|
||||
expect(posB.x).toBe(posC.x); // same layer
|
||||
expect(posB.x).toBeLessThan(posD.x);
|
||||
|
||||
// B and C should be at different Y positions
|
||||
expect(posB.y).not.toBe(posC.y);
|
||||
|
||||
assertFiniteCoordinates(result.positions);
|
||||
assertNoOverlaps(result.positions, nodes);
|
||||
});
|
||||
|
||||
it("handles cycle A->B->C->A", () => {
|
||||
const nodes = [node("A"), node("B"), node("C")];
|
||||
const edges = [edge("A", "B"), edge("B", "C"), edge("C", "A")];
|
||||
const result = sugiyama.layout({ nodes, edges });
|
||||
|
||||
// All nodes should be positioned
|
||||
expect(result.positions.size).toBe(3);
|
||||
assertFiniteCoordinates(result.positions);
|
||||
assertNoOverlaps(result.positions, nodes);
|
||||
});
|
||||
|
||||
it("handles wide graph (many nodes in one layer)", () => {
|
||||
const nodes = [
|
||||
node("root"),
|
||||
node("A"),
|
||||
node("B"),
|
||||
node("C"),
|
||||
node("D"),
|
||||
node("E"),
|
||||
];
|
||||
const edges = [
|
||||
edge("root", "A"),
|
||||
edge("root", "B"),
|
||||
edge("root", "C"),
|
||||
edge("root", "D"),
|
||||
edge("root", "E"),
|
||||
];
|
||||
const result = sugiyama.layout({ nodes, edges });
|
||||
|
||||
expect(result.positions.size).toBe(6);
|
||||
|
||||
// All child nodes should have same X (same layer)
|
||||
const childPositions = ["A", "B", "C", "D", "E"].map(
|
||||
(id) => result.positions.get(id)!,
|
||||
);
|
||||
const childX = childPositions[0].x;
|
||||
for (const pos of childPositions) {
|
||||
expect(pos.x).toBe(childX);
|
||||
}
|
||||
|
||||
// Root should be at a smaller X
|
||||
expect(result.positions.get("root")!.x).toBeLessThan(childX);
|
||||
|
||||
assertFiniteCoordinates(result.positions);
|
||||
assertNoOverlaps(result.positions, nodes);
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
const result = sugiyama.layout({ nodes: [], edges: [] });
|
||||
expect(result.positions.size).toBe(0);
|
||||
});
|
||||
|
||||
it("is idempotent (same input produces same output)", () => {
|
||||
const nodes = [node("A"), node("B"), node("C"), node("D")];
|
||||
const edges = [
|
||||
edge("A", "B"),
|
||||
edge("A", "C"),
|
||||
edge("B", "D"),
|
||||
edge("C", "D"),
|
||||
];
|
||||
|
||||
const result1 = sugiyama.layout({ nodes, edges });
|
||||
const result2 = sugiyama.layout({ nodes, edges });
|
||||
|
||||
for (const n of nodes) {
|
||||
const pos1 = result1.positions.get(n.id)!;
|
||||
const pos2 = result2.positions.get(n.id)!;
|
||||
expect(pos1.x).toBe(pos2.x);
|
||||
expect(pos1.y).toBe(pos2.y);
|
||||
}
|
||||
});
|
||||
|
||||
it("respects custom gap configuration", () => {
|
||||
const custom = createSugiyamaAlgorithm({
|
||||
horizontalGap: 200,
|
||||
verticalGap: 80,
|
||||
});
|
||||
|
||||
const nodes = [node("A", 100, 50), node("B", 100, 50)];
|
||||
const edges = [edge("A", "B")];
|
||||
const result = custom.layout({ nodes, edges });
|
||||
|
||||
const posA = result.positions.get("A")!;
|
||||
const posB = result.positions.get("B")!;
|
||||
|
||||
// Horizontal gap should be respected: B.x = A.x + A.width + gap
|
||||
expect(posB.x - posA.x).toBe(100 + 200);
|
||||
});
|
||||
|
||||
it("handles complex multi-layer graph", () => {
|
||||
// S -> A, S -> B
|
||||
// A -> C, A -> D
|
||||
// B -> D, B -> E
|
||||
// C -> F, D -> F, E -> F
|
||||
const nodes = [
|
||||
node("S"),
|
||||
node("A"),
|
||||
node("B"),
|
||||
node("C"),
|
||||
node("D"),
|
||||
node("E"),
|
||||
node("F"),
|
||||
];
|
||||
const edges = [
|
||||
edge("S", "A"),
|
||||
edge("S", "B"),
|
||||
edge("A", "C"),
|
||||
edge("A", "D"),
|
||||
edge("B", "D"),
|
||||
edge("B", "E"),
|
||||
edge("C", "F"),
|
||||
edge("D", "F"),
|
||||
edge("E", "F"),
|
||||
];
|
||||
const result = sugiyama.layout({ nodes, edges });
|
||||
|
||||
expect(result.positions.size).toBe(7);
|
||||
assertFiniteCoordinates(result.positions);
|
||||
assertNoOverlaps(result.positions, nodes);
|
||||
|
||||
// S should be leftmost, F should be rightmost
|
||||
const posS = result.positions.get("S")!;
|
||||
const posF = result.positions.get("F")!;
|
||||
expect(posS.x).toBeLessThan(posF.x);
|
||||
|
||||
// Layer ordering should hold for all edges
|
||||
for (const e of edges) {
|
||||
const srcX = result.positions.get(e.source)!.x;
|
||||
const tgtX = result.positions.get(e.target)!.x;
|
||||
expect(srcX).toBeLessThan(tgtX);
|
||||
}
|
||||
});
|
||||
|
||||
it("produces positions for all nodes even in complex cycles", () => {
|
||||
// A->B->C->D->A plus A->C (multiple cycles)
|
||||
const nodes = [node("A"), node("B"), node("C"), node("D")];
|
||||
const edges = [
|
||||
edge("A", "B"),
|
||||
edge("B", "C"),
|
||||
edge("C", "D"),
|
||||
edge("D", "A"),
|
||||
edge("A", "C"),
|
||||
];
|
||||
const result = sugiyama.layout({ nodes, edges });
|
||||
|
||||
expect(result.positions.size).toBe(4);
|
||||
assertFiniteCoordinates(result.positions);
|
||||
assertNoOverlaps(result.positions, nodes);
|
||||
});
|
||||
|
||||
it("has name 'sugiyama'", () => {
|
||||
expect(sugiyama.name).toBe("sugiyama");
|
||||
});
|
||||
|
||||
it("handles nodes with varying sizes", () => {
|
||||
const nodes = [
|
||||
node("tiny", 50, 30),
|
||||
node("medium", 200, 100),
|
||||
node("large", 400, 200),
|
||||
];
|
||||
const edges = [edge("tiny", "medium"), edge("medium", "large")];
|
||||
const result = sugiyama.layout({ nodes, edges });
|
||||
|
||||
expect(result.positions.size).toBe(3);
|
||||
assertFiniteCoordinates(result.positions);
|
||||
assertNoOverlaps(result.positions, nodes);
|
||||
|
||||
// X should increase
|
||||
const positions = nodes.map((n) => result.positions.get(n.id)!);
|
||||
expect(positions[0].x).toBeLessThan(positions[1].x);
|
||||
expect(positions[1].x).toBeLessThan(positions[2].x);
|
||||
});
|
||||
|
||||
it("handles parallel paths of different lengths", () => {
|
||||
// A -> B -> C -> D (long path)
|
||||
// A -> D (short path)
|
||||
const nodes = [node("A"), node("B"), node("C"), node("D")];
|
||||
const edges = [
|
||||
edge("A", "B"),
|
||||
edge("B", "C"),
|
||||
edge("C", "D"),
|
||||
edge("A", "D"),
|
||||
];
|
||||
const result = sugiyama.layout({ nodes, edges });
|
||||
|
||||
// D should be at layer 3 (longest path), not layer 1
|
||||
const posA = result.positions.get("A")!;
|
||||
const posB = result.positions.get("B")!;
|
||||
const posC = result.positions.get("C")!;
|
||||
const posD = result.positions.get("D")!;
|
||||
|
||||
expect(posA.x).toBeLessThan(posB.x);
|
||||
expect(posB.x).toBeLessThan(posC.x);
|
||||
expect(posC.x).toBeLessThan(posD.x);
|
||||
|
||||
assertNoOverlaps(result.positions, nodes);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseLayoutToken, tokenToAlgorithm } from "../../src/layout/tokens";
|
||||
import { createHorizontalAlgorithm } from "../../src/layout/algorithms/horizontal";
|
||||
import { createVerticalAlgorithm } from "../../src/layout/algorithms/vertical";
|
||||
import type { LayoutNode, LayoutInput } from "../../src/layout/types";
|
||||
|
||||
function node(id: string, width = 100, height = 60): LayoutNode {
|
||||
return { id, width, height };
|
||||
}
|
||||
|
||||
function input(nodes: LayoutNode[]): LayoutInput {
|
||||
return { nodes, edges: [] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseLayoutToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseLayoutToken", () => {
|
||||
it("parses [HORIZONTAL] from group title", () => {
|
||||
const result = parseLayoutToken("My Group [HORIZONTAL]");
|
||||
expect(result).toEqual({ mode: "horizontal" });
|
||||
});
|
||||
|
||||
it("parses [vertical] case-insensitively", () => {
|
||||
const result = parseLayoutToken("stuff [vertical] more");
|
||||
expect(result).toEqual({ mode: "vertical" });
|
||||
});
|
||||
|
||||
it("parses [2ROW] as grid with 2 rows", () => {
|
||||
const result = parseLayoutToken("[2ROW]");
|
||||
expect(result).toEqual({ mode: "grid", count: 2, dimension: "row" });
|
||||
});
|
||||
|
||||
it("parses [3col] case-insensitively as grid with 3 columns", () => {
|
||||
const result = parseLayoutToken("Title [3col]");
|
||||
expect(result).toEqual({ mode: "grid", count: 3, dimension: "col" });
|
||||
});
|
||||
|
||||
it("treats [1ROW] as horizontal alias", () => {
|
||||
const result = parseLayoutToken("[1ROW]");
|
||||
expect(result).toEqual({ mode: "horizontal" });
|
||||
});
|
||||
|
||||
it("treats [1COL] as vertical alias", () => {
|
||||
const result = parseLayoutToken("[1COL]");
|
||||
expect(result).toEqual({ mode: "vertical" });
|
||||
});
|
||||
|
||||
it("returns null when no token present", () => {
|
||||
const result = parseLayoutToken("No token here");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for [10ROW] (only 1-9 supported)", () => {
|
||||
const result = parseLayoutToken("[10ROW]");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for [0ROW] (0 not valid)", () => {
|
||||
const result = parseLayoutToken("[0ROW]");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Algorithm behavior: horizontal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("horizontal algorithm", () => {
|
||||
it("places 3 nodes left-to-right with Y centered", () => {
|
||||
const nodes = [node("A", 100, 40), node("B", 80, 60), node("C", 120, 20)];
|
||||
const algo = createHorizontalAlgorithm(40);
|
||||
const result = algo.layout(input(nodes));
|
||||
|
||||
const posA = result.positions.get("A")!;
|
||||
const posB = result.positions.get("B")!;
|
||||
const posC = result.positions.get("C")!;
|
||||
|
||||
// Max height is 60 (node B)
|
||||
// A: y = 60/2 - 40/2 = 10
|
||||
// B: y = 60/2 - 60/2 = 0
|
||||
// C: y = 60/2 - 20/2 = 20
|
||||
expect(posA).toEqual({ x: 0, y: 10 });
|
||||
expect(posB).toEqual({ x: 140, y: 0 }); // 0 + 100 + 40
|
||||
expect(posC).toEqual({ x: 260, y: 20 }); // 140 + 80 + 40
|
||||
});
|
||||
|
||||
it("returns empty positions for empty input", () => {
|
||||
const algo = createHorizontalAlgorithm();
|
||||
const result = algo.layout(input([]));
|
||||
expect(result.positions.size).toBe(0);
|
||||
});
|
||||
|
||||
it("places single node at origin", () => {
|
||||
const algo = createHorizontalAlgorithm();
|
||||
const result = algo.layout(input([node("A", 100, 60)]));
|
||||
expect(result.positions.get("A")).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Algorithm behavior: vertical
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("vertical algorithm", () => {
|
||||
it("places 3 nodes top-to-bottom with X centered", () => {
|
||||
const nodes = [node("A", 80, 40), node("B", 120, 60), node("C", 60, 30)];
|
||||
const algo = createVerticalAlgorithm(40);
|
||||
const result = algo.layout(input(nodes));
|
||||
|
||||
const posA = result.positions.get("A")!;
|
||||
const posB = result.positions.get("B")!;
|
||||
const posC = result.positions.get("C")!;
|
||||
|
||||
// Max width is 120 (node B)
|
||||
// A: x = 120/2 - 80/2 = 20
|
||||
// B: x = 120/2 - 120/2 = 0
|
||||
// C: x = 120/2 - 60/2 = 30
|
||||
expect(posA).toEqual({ x: 20, y: 0 });
|
||||
expect(posB).toEqual({ x: 0, y: 80 }); // 0 + 40 + 40
|
||||
expect(posC).toEqual({ x: 30, y: 180 }); // 80 + 60 + 40
|
||||
});
|
||||
|
||||
it("returns empty positions for empty input", () => {
|
||||
const algo = createVerticalAlgorithm();
|
||||
const result = algo.layout(input([]));
|
||||
expect(result.positions.size).toBe(0);
|
||||
});
|
||||
|
||||
it("places single node at origin", () => {
|
||||
const algo = createVerticalAlgorithm();
|
||||
const result = algo.layout(input([node("A", 100, 60)]));
|
||||
expect(result.positions.get("A")).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Algorithm behavior: grid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("grid algorithm via tokenToAlgorithm", () => {
|
||||
it("[2ROW] with 6 nodes: 2 rows x 3 columns", () => {
|
||||
const token = parseLayoutToken("[2ROW]")!;
|
||||
const algo = tokenToAlgorithm(token, 40);
|
||||
const nodes = [
|
||||
node("A", 100, 60), node("B", 100, 60), node("C", 100, 60),
|
||||
node("D", 100, 60), node("E", 100, 60), node("F", 100, 60),
|
||||
];
|
||||
const result = algo.layout(input(nodes));
|
||||
|
||||
expect(result.positions.size).toBe(6);
|
||||
|
||||
// Row 0: A, B, C
|
||||
// Row 1: D, E, F
|
||||
const posA = result.positions.get("A")!;
|
||||
const posB = result.positions.get("B")!;
|
||||
const posD = result.positions.get("D")!;
|
||||
|
||||
// Same-size nodes, gap=40: cellWidth=140, cellHeight=100
|
||||
expect(posA).toEqual({ x: 0, y: 0 });
|
||||
expect(posB).toEqual({ x: 140, y: 0 });
|
||||
expect(posD).toEqual({ x: 0, y: 100 });
|
||||
});
|
||||
|
||||
it("[3COL] with 6 nodes: 2 rows x 3 columns", () => {
|
||||
const token = parseLayoutToken("[3COL]")!;
|
||||
const algo = tokenToAlgorithm(token, 40);
|
||||
const nodes = [
|
||||
node("A", 100, 60), node("B", 100, 60), node("C", 100, 60),
|
||||
node("D", 100, 60), node("E", 100, 60), node("F", 100, 60),
|
||||
];
|
||||
const result = algo.layout(input(nodes));
|
||||
|
||||
expect(result.positions.size).toBe(6);
|
||||
|
||||
// Row 0: A, B, C
|
||||
// Row 1: D, E, F
|
||||
const posA = result.positions.get("A")!;
|
||||
const posD = result.positions.get("D")!;
|
||||
|
||||
expect(posA).toEqual({ x: 0, y: 0 });
|
||||
expect(posD).toEqual({ x: 0, y: 100 });
|
||||
});
|
||||
|
||||
it("[2ROW] with 5 nodes: row 0 has 3, row 1 has 2", () => {
|
||||
const token = parseLayoutToken("[2ROW]")!;
|
||||
const algo = tokenToAlgorithm(token, 40);
|
||||
const nodes = [
|
||||
node("A", 100, 60), node("B", 100, 60), node("C", 100, 60),
|
||||
node("D", 100, 60), node("E", 100, 60),
|
||||
];
|
||||
const result = algo.layout(input(nodes));
|
||||
|
||||
expect(result.positions.size).toBe(5);
|
||||
|
||||
// cols = ceil(5/2) = 3
|
||||
// Row 0: A(0,0), B(0,1), C(0,2)
|
||||
// Row 1: D(1,0), E(1,1)
|
||||
const posC = result.positions.get("C")!;
|
||||
const posD = result.positions.get("D")!;
|
||||
const posE = result.positions.get("E")!;
|
||||
|
||||
expect(posC.y).toBe(0); // row 0
|
||||
expect(posD.y).toBe(100); // row 1
|
||||
expect(posE.y).toBe(100); // row 1
|
||||
expect(posE.x).toBe(140); // col 1
|
||||
});
|
||||
|
||||
it("empty input returns empty positions", () => {
|
||||
const token = parseLayoutToken("[2ROW]")!;
|
||||
const algo = tokenToAlgorithm(token, 40);
|
||||
const result = algo.layout(input([]));
|
||||
expect(result.positions.size).toBe(0);
|
||||
});
|
||||
|
||||
it("single node placed at origin", () => {
|
||||
const token = parseLayoutToken("[3COL]")!;
|
||||
const algo = tokenToAlgorithm(token, 40);
|
||||
const result = algo.layout(input([node("A", 100, 60)]));
|
||||
expect(result.positions.get("A")).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
@@ -21,5 +21,5 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts", "src", "tests"]
|
||||
"include": ["vite.config.ts", "src", "tests", "scripts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bracex"
|
||||
version = "2.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bump-my-version"
|
||||
version = "1.2.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "questionary" },
|
||||
{ name = "rich" },
|
||||
{ name = "rich-click" },
|
||||
{ name = "tomlkit" },
|
||||
{ name = "wcmatch" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/11/0f73c652396f86197ea6d509c78e8c44c3483d9a86437ca53ce55edca8e8/bump_my_version-1.2.7.tar.gz", hash = "sha256:d915a10b41e0c9db5a2fa39bde9f45f92e1e4194242d819c9ceb9eca8831cd21", size = 1198071, upload-time = "2026-02-14T13:44:59.923Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/ed/ad1755f82cd5a0baafe342e7154696a93e57f04f86515402f14e5beceb36/bump_my_version-1.2.7-py3-none-any.whl", hash = "sha256:16f89360f979c0a8eb3249ebe3e13ae4f0cb5481d7bb58e12a9f66996922acfd", size = 60013, upload-time = "2026-02-14T13:44:58.318Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "comfyui-node-organizer"
|
||||
version = "1.3.0"
|
||||
source = { virtual = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "bump-my-version" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "bump-my-version", specifier = ">=1.2.2" }]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.52"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wcwidth" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "questionary"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "prompt-toolkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich-click"
|
||||
version = "1.9.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/27/091e140ea834272188e63f8dd6faac1f5c687582b687197b3e0ec3c78ebf/rich_click-1.9.7.tar.gz", hash = "sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc", size = 74838, upload-time = "2026-01-31T04:29:27.707Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomlkit"
|
||||
version = "0.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcmatch"
|
||||
version = "10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bracex" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" },
|
||||
]
|
||||
@@ -3,13 +3,13 @@ import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ["tests/**/*.test.ts"],
|
||||
include: ["tests/unit/**/*.test.ts"],
|
||||
exclude: ["tests/e2e/**"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/index.ts", // Extension entry point not testable without ComfyUI
|
||||
"src/layout/reroute-collapse.ts", // No fixtures with reroute nodes yet
|
||||
],
|
||||
thresholds: {
|
||||
lines: 70,
|
||||
|
||||