refactor: centralize core api helpers
This commit is contained in:
@@ -13,6 +13,7 @@ Use `.changeset/plans/` only when prior design context is needed.
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm build:lib
|
||||
pnpm test:lib
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm setup:e2e
|
||||
|
||||
@@ -53,6 +53,7 @@ Add tokens to group titles to control how nodes are arranged:
|
||||
- `pnpm test`
|
||||
- `pnpm build`
|
||||
- `pnpm build:lib` emits the pure library entrypoint to `lib/`
|
||||
- `pnpm test:lib` smoke-tests the built `comfyui-node-organizer/core` export
|
||||
- `pnpm setup:e2e` provisions a dedicated ComfyUI instance for browser tests and installs the exact workflow-template package required by the pinned ComfyUI checkout
|
||||
- `pnpm test:e2e` runs the full Playwright suite against that instance
|
||||
- Broad installed-template coverage is discovered live at E2E runtime from the pinned test ComfyUI environment, with one correctness-invariant test per installed workflow template
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:lib": "tsc -p tsconfig.lib.json",
|
||||
"test:lib": "pnpm build:lib && tsx scripts/smoke-core-package.ts",
|
||||
"watch": "tsc && vite build --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepare": "pnpm build:lib",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const coreModule = await import("comfyui-node-organizer/core");
|
||||
|
||||
assert.equal(typeof coreModule.inferGroupMembership, "function");
|
||||
assert.equal(typeof coreModule.normalizeWorkflowGeometry, "function");
|
||||
assert.equal(typeof coreModule.DEFAULT_FRAMEWORK_CONFIG, "object");
|
||||
+5
-11
@@ -21,6 +21,10 @@ import {
|
||||
inferGroupMembership,
|
||||
type Rect as MembershipRect,
|
||||
} from "./group-membership";
|
||||
import {
|
||||
fromNumericGroupLayoutId,
|
||||
toGroupLayoutId,
|
||||
} from "./layout/group-ids";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structural types for ComfyUI runtime objects
|
||||
@@ -66,16 +70,6 @@ export interface GraphLike {
|
||||
setDirtyCanvas?(fg: boolean, bg: boolean): void;
|
||||
}
|
||||
|
||||
function toGroupLayoutId(groupId: number): string {
|
||||
return `group:${groupId}`;
|
||||
}
|
||||
|
||||
function fromGroupLayoutId(groupId: string): number | null {
|
||||
if (!groupId.startsWith("group:")) return null;
|
||||
const parsed = Number(groupId.slice("group:".length));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract layout input from a ComfyUI graph
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -354,7 +348,7 @@ export function anchorSelectedGroupLayoutResult(
|
||||
);
|
||||
|
||||
for (const rootGroupId of selectedRootIds) {
|
||||
const originalGraphGroupId = fromGroupLayoutId(rootGroupId);
|
||||
const originalGraphGroupId = fromNumericGroupLayoutId(rootGroupId);
|
||||
if (originalGraphGroupId === null) continue;
|
||||
|
||||
const originalGroup = graphGroupById.get(originalGraphGroupId);
|
||||
|
||||
+38
-55
@@ -1,43 +1,44 @@
|
||||
import { createHorizontalAlgorithm } from "./layout/algorithms/horizontal";
|
||||
import { createSugiyamaAlgorithm } from "./layout/algorithms/sugiyama";
|
||||
import { createVerticalAlgorithm } from "./layout/algorithms/vertical";
|
||||
import { layoutWithGroups } from "./layout/framework";
|
||||
import { parseLayoutToken } from "./layout/tokens";
|
||||
import {
|
||||
createLayoutAlgorithm,
|
||||
type LayoutAlgorithmName,
|
||||
} from "./layout/algorithm-factory.js";
|
||||
import { layoutWithGroups } from "./layout/framework.js";
|
||||
import { toGroupLayoutId } from "./layout/group-ids.js";
|
||||
import { parseLayoutToken } from "./layout/tokens.js";
|
||||
import {
|
||||
DEFAULT_FRAMEWORK_CONFIG,
|
||||
type FrameworkConfig,
|
||||
type LayoutAlgorithm,
|
||||
type LayoutEdge as InternalLayoutEdge,
|
||||
type LayoutGroup as InternalLayoutGroup,
|
||||
type LayoutNode as InternalLayoutNode,
|
||||
} from "./layout/types";
|
||||
} from "./layout/types.js";
|
||||
|
||||
export {
|
||||
inferGroupMembership,
|
||||
type GroupMembership,
|
||||
type Rect,
|
||||
} from "./group-membership";
|
||||
} from "./group-membership.js";
|
||||
|
||||
export interface LayoutNode {
|
||||
export interface WorkflowNode {
|
||||
readonly id: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly kind?: "node" | "subgraph-input" | "subgraph-output";
|
||||
}
|
||||
|
||||
export interface LayoutEdge {
|
||||
export interface WorkflowEdge {
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
}
|
||||
|
||||
export interface LayoutGroup {
|
||||
export interface WorkflowGroup {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly memberIds: ReadonlyArray<string>;
|
||||
readonly childGroupIds: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export interface PositionedRect {
|
||||
export interface WorkflowRect {
|
||||
readonly id: string;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
@@ -45,38 +46,41 @@ export interface PositionedRect {
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export interface NormalizeResult {
|
||||
readonly nodes: PositionedRect[];
|
||||
readonly groups: PositionedRect[];
|
||||
readonly memberships: Array<{
|
||||
readonly groupId: string;
|
||||
readonly nodeIds: string[];
|
||||
readonly childGroupIds: string[];
|
||||
}>;
|
||||
export interface WorkflowMembership {
|
||||
readonly groupId: string;
|
||||
readonly nodeIds: string[];
|
||||
readonly childGroupIds: string[];
|
||||
}
|
||||
|
||||
export interface NormalizeOptions {
|
||||
readonly algorithm?: "sugiyama" | "horizontal" | "vertical";
|
||||
export interface NormalizeWorkflowResult {
|
||||
readonly nodes: WorkflowRect[];
|
||||
readonly groups: WorkflowRect[];
|
||||
readonly memberships: WorkflowMembership[];
|
||||
}
|
||||
|
||||
export interface NormalizeWorkflowOptions {
|
||||
readonly algorithm?: LayoutAlgorithmName;
|
||||
readonly config?: Partial<FrameworkConfig>;
|
||||
}
|
||||
|
||||
export { DEFAULT_FRAMEWORK_CONFIG, type FrameworkConfig } from "./layout/types";
|
||||
|
||||
type AlgorithmName = NonNullable<NormalizeOptions["algorithm"]>;
|
||||
export { DEFAULT_FRAMEWORK_CONFIG, type FrameworkConfig } from "./layout/types.js";
|
||||
|
||||
export function normalizeWorkflowGeometry(
|
||||
input: {
|
||||
readonly nodes: ReadonlyArray<LayoutNode>;
|
||||
readonly edges: ReadonlyArray<LayoutEdge>;
|
||||
readonly groups: ReadonlyArray<LayoutGroup>;
|
||||
readonly nodes: ReadonlyArray<WorkflowNode>;
|
||||
readonly edges: ReadonlyArray<WorkflowEdge>;
|
||||
readonly groups: ReadonlyArray<WorkflowGroup>;
|
||||
},
|
||||
options?: NormalizeOptions,
|
||||
): NormalizeResult {
|
||||
options?: NormalizeWorkflowOptions,
|
||||
): NormalizeWorkflowResult {
|
||||
const config: FrameworkConfig = {
|
||||
...DEFAULT_FRAMEWORK_CONFIG,
|
||||
...options?.config,
|
||||
};
|
||||
const algorithm = createAlgorithm(options?.algorithm ?? "sugiyama", config);
|
||||
const algorithm = createLayoutAlgorithm(
|
||||
options?.algorithm ?? "sugiyama",
|
||||
config,
|
||||
);
|
||||
|
||||
const nodes: InternalLayoutNode[] = input.nodes.map((node) => {
|
||||
if (node.kind === "subgraph-input") {
|
||||
@@ -115,10 +119,10 @@ export function normalizeWorkflowGeometry(
|
||||
const token = parseLayoutToken(group.title);
|
||||
|
||||
return {
|
||||
id: toInternalGroupId(group.id),
|
||||
id: toGroupLayoutId(group.id),
|
||||
title: group.title,
|
||||
memberIds: [...group.memberIds],
|
||||
childGroupIds: group.childGroupIds.map(toInternalGroupId),
|
||||
childGroupIds: group.childGroupIds.map(toGroupLayoutId),
|
||||
...(token ? { token } : {}),
|
||||
};
|
||||
});
|
||||
@@ -141,7 +145,7 @@ export function normalizeWorkflowGeometry(
|
||||
: [];
|
||||
}),
|
||||
groups: input.groups.flatMap((group) => {
|
||||
const bounds = result.groupBounds.get(toInternalGroupId(group.id));
|
||||
const bounds = result.groupBounds.get(toGroupLayoutId(group.id));
|
||||
return bounds
|
||||
? [
|
||||
{
|
||||
@@ -161,24 +165,3 @@ export function normalizeWorkflowGeometry(
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createAlgorithm(
|
||||
algorithmName: AlgorithmName,
|
||||
config: FrameworkConfig,
|
||||
): LayoutAlgorithm {
|
||||
switch (algorithmName) {
|
||||
case "horizontal":
|
||||
return createHorizontalAlgorithm(config.horizontalGap);
|
||||
case "vertical":
|
||||
return createVerticalAlgorithm(config.verticalGap);
|
||||
case "sugiyama":
|
||||
return createSugiyamaAlgorithm({
|
||||
horizontalGap: config.horizontalGap,
|
||||
verticalGap: config.verticalGap,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toInternalGroupId(groupId: string): string {
|
||||
return `group:${groupId}`;
|
||||
}
|
||||
|
||||
+42
-24
@@ -3,41 +3,59 @@ export interface PositionedItem {
|
||||
readonly size: ArrayLike<number>;
|
||||
}
|
||||
|
||||
export interface RectGeometry {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export function isGroupInsideGroup(
|
||||
inner: PositionedItem,
|
||||
outer: PositionedItem,
|
||||
): boolean {
|
||||
const ix = Number(inner.pos[0]);
|
||||
const iy = Number(inner.pos[1]);
|
||||
const iw = Number(inner.size[0]);
|
||||
const ih = Number(inner.size[1]);
|
||||
|
||||
const ox = Number(outer.pos[0]);
|
||||
const oy = Number(outer.pos[1]);
|
||||
const ow = Number(outer.size[0]);
|
||||
const oh = Number(outer.size[1]);
|
||||
|
||||
return (
|
||||
ix >= ox && iy >= oy && ix + iw <= ox + ow && iy + ih <= oy + oh
|
||||
);
|
||||
return isRectInsideRect(toRectGeometry(inner), toRectGeometry(outer));
|
||||
}
|
||||
|
||||
export function isNodeCenterInsideGroup(
|
||||
node: PositionedItem,
|
||||
group: PositionedItem,
|
||||
): boolean {
|
||||
const centerX = Number(node.pos[0]) + Number(node.size[0]) / 2;
|
||||
const centerY = Number(node.pos[1]) + Number(node.size[1]) / 2;
|
||||
|
||||
const gx = Number(group.pos[0]);
|
||||
const gy = Number(group.pos[1]);
|
||||
const gw = Number(group.size[0]);
|
||||
const gh = Number(group.size[1]);
|
||||
return isCenterInsideRect(toRectGeometry(node), toRectGeometry(group));
|
||||
}
|
||||
|
||||
export function isRectInsideRect(
|
||||
inner: RectGeometry,
|
||||
outer: RectGeometry,
|
||||
): boolean {
|
||||
return (
|
||||
centerX >= gx &&
|
||||
centerX <= gx + gw &&
|
||||
centerY >= gy &&
|
||||
centerY <= gy + gh
|
||||
inner.x >= outer.x &&
|
||||
inner.y >= outer.y &&
|
||||
inner.x + inner.width <= outer.x + outer.width &&
|
||||
inner.y + inner.height <= outer.y + outer.height
|
||||
);
|
||||
}
|
||||
|
||||
export function isCenterInsideRect(
|
||||
item: RectGeometry,
|
||||
container: RectGeometry,
|
||||
): boolean {
|
||||
const centerX = item.x + item.width / 2;
|
||||
const centerY = item.y + item.height / 2;
|
||||
|
||||
return (
|
||||
centerX >= container.x &&
|
||||
centerX <= container.x + container.width &&
|
||||
centerY >= container.y &&
|
||||
centerY <= container.y + container.height
|
||||
);
|
||||
}
|
||||
|
||||
function toRectGeometry(item: PositionedItem): RectGeometry {
|
||||
return {
|
||||
x: Number(item.pos[0]),
|
||||
y: Number(item.pos[1]),
|
||||
width: Number(item.size[0]),
|
||||
height: Number(item.size[1]),
|
||||
};
|
||||
}
|
||||
|
||||
+5
-21
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
isCenterInsideRect,
|
||||
isRectInsideRect,
|
||||
} from "./group-geometry.js";
|
||||
|
||||
export interface Rect {
|
||||
readonly id: string;
|
||||
readonly x: number;
|
||||
@@ -81,24 +86,3 @@ export function inferGroupMembership(
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function isRectInsideRect(inner: Rect, outer: Rect): boolean {
|
||||
return (
|
||||
inner.x >= outer.x &&
|
||||
inner.y >= outer.y &&
|
||||
inner.x + inner.width <= outer.x + outer.width &&
|
||||
inner.y + inner.height <= outer.y + outer.height
|
||||
);
|
||||
}
|
||||
|
||||
function isCenterInsideRect(item: Rect, container: Rect): boolean {
|
||||
const centerX = item.x + item.width / 2;
|
||||
const centerY = item.y + item.height / 2;
|
||||
|
||||
return (
|
||||
centerX >= container.x &&
|
||||
centerX <= container.x + container.width &&
|
||||
centerY >= container.y &&
|
||||
centerY <= container.y + container.height
|
||||
);
|
||||
}
|
||||
|
||||
+10
-16
@@ -6,6 +6,10 @@
|
||||
*/
|
||||
|
||||
import type { ComfyApp } from "@comfyorg/comfyui-frontend-types";
|
||||
import {
|
||||
createLayoutAlgorithm,
|
||||
type LayoutAlgorithmName,
|
||||
} from "./layout/algorithm-factory";
|
||||
import {
|
||||
SETTING_IDS,
|
||||
EXTENSION_NAME,
|
||||
@@ -24,9 +28,6 @@ import {
|
||||
} from "./adapter";
|
||||
import type { GraphLike } from "./adapter";
|
||||
import { layoutWithGroups } from "./layout/framework";
|
||||
import { createSugiyamaAlgorithm } from "./layout/algorithms/sugiyama";
|
||||
import { createHorizontalAlgorithm } from "./layout/algorithms/horizontal";
|
||||
import { createVerticalAlgorithm } from "./layout/algorithms/vertical";
|
||||
import type { LayoutAlgorithm, FrameworkConfig } from "./layout/types";
|
||||
import { computeGraphBounds } from "./bounds";
|
||||
import { DEFAULT_FRAMEWORK_CONFIG } from "./layout/types";
|
||||
@@ -39,18 +40,6 @@ declare global {
|
||||
}
|
||||
|
||||
const DEFAULT_ALGORITHM_NAME: DefaultAlgorithmName = "sugiyama";
|
||||
const algorithmFactories: Record<
|
||||
DefaultAlgorithmName,
|
||||
(config: Partial<FrameworkConfig>) => LayoutAlgorithm
|
||||
> = {
|
||||
sugiyama: (config) =>
|
||||
createSugiyamaAlgorithm({
|
||||
horizontalGap: config.horizontalGap,
|
||||
verticalGap: config.verticalGap,
|
||||
}),
|
||||
horizontal: (config) => createHorizontalAlgorithm(config.horizontalGap),
|
||||
vertical: (config) => createVerticalAlgorithm(config.verticalGap),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -70,7 +59,12 @@ export function createConfiguredDefaultAlgorithm(
|
||||
algorithmName: DefaultAlgorithmName,
|
||||
config: Partial<FrameworkConfig>,
|
||||
): LayoutAlgorithm {
|
||||
return algorithmFactories[algorithmName](config);
|
||||
return createLayoutAlgorithm(algorithmName as LayoutAlgorithmName, {
|
||||
horizontalGap:
|
||||
config.horizontalGap ?? DEFAULT_FRAMEWORK_CONFIG.horizontalGap,
|
||||
verticalGap:
|
||||
config.verticalGap ?? DEFAULT_FRAMEWORK_CONFIG.verticalGap,
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultAlgorithm(config: Partial<FrameworkConfig>): LayoutAlgorithm {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createHorizontalAlgorithm } from "./algorithms/horizontal.js";
|
||||
import { createSugiyamaAlgorithm } from "./algorithms/sugiyama.js";
|
||||
import { createVerticalAlgorithm } from "./algorithms/vertical.js";
|
||||
import type { FrameworkConfig, LayoutAlgorithm } from "./types.js";
|
||||
|
||||
export type LayoutAlgorithmName = "sugiyama" | "horizontal" | "vertical";
|
||||
|
||||
export function createLayoutAlgorithm(
|
||||
algorithmName: LayoutAlgorithmName,
|
||||
config: Pick<FrameworkConfig, "horizontalGap" | "verticalGap">,
|
||||
): LayoutAlgorithm {
|
||||
switch (algorithmName) {
|
||||
case "horizontal":
|
||||
return createHorizontalAlgorithm(config.horizontalGap);
|
||||
case "vertical":
|
||||
return createVerticalAlgorithm(config.verticalGap);
|
||||
case "sugiyama":
|
||||
return createSugiyamaAlgorithm({
|
||||
horizontalGap: config.horizontalGap,
|
||||
verticalGap: config.verticalGap,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* Ignores edges (token layouts ignore DAG structure).
|
||||
*/
|
||||
|
||||
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types";
|
||||
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types.js";
|
||||
|
||||
const DEFAULT_GAP = 40;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Nodes are placed in input order; vertically centered by midpoint.
|
||||
*/
|
||||
|
||||
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types";
|
||||
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types.js";
|
||||
|
||||
const DEFAULT_GAP = 40;
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@ import type {
|
||||
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";
|
||||
} from "../types.js";
|
||||
import { breakCycles } from "./sugiyama/cycle-break.js";
|
||||
import { assignLayers } from "./sugiyama/layer-assign.js";
|
||||
import { minimizeCrossings } from "./sugiyama/crossing-minimize.js";
|
||||
import { assignCoordinates } from "./sugiyama/coordinate-assign.js";
|
||||
|
||||
export interface SugiyamaConfig {
|
||||
readonly horizontalGap: number;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* and Y coordinates by stacking nodes top-to-bottom within each layer.
|
||||
*/
|
||||
|
||||
import type { LayoutNode, Position } from "../../types";
|
||||
import type { LayoutNode, Position } from "../../types.js";
|
||||
|
||||
interface CoordinateConfig {
|
||||
readonly horizontalGap: number;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* their neighbors in the adjacent fixed layer.
|
||||
*/
|
||||
|
||||
import type { LayoutNode, LayoutEdge } from "../../types";
|
||||
import type { LayoutNode, LayoutEdge } from "../../types.js";
|
||||
|
||||
/**
|
||||
* Reorder nodes within each layer to minimize edge crossings.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* repeatedly extract sinks, sources, then the node with max (out - in).
|
||||
*/
|
||||
|
||||
import type { LayoutNode, LayoutEdge } from "../../types";
|
||||
import type { LayoutNode, LayoutEdge } from "../../types.js";
|
||||
|
||||
export interface CycleBreakResult {
|
||||
readonly edges: ReadonlyArray<LayoutEdge>;
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* Sugiyama sub-module barrel export.
|
||||
*/
|
||||
|
||||
export { breakCycles } from "./cycle-break";
|
||||
export type { CycleBreakResult } from "./cycle-break";
|
||||
export { breakCycles } from "./cycle-break.js";
|
||||
export type { CycleBreakResult } from "./cycle-break.js";
|
||||
|
||||
export { assignLayers } from "./layer-assign";
|
||||
export { assignLayers } from "./layer-assign.js";
|
||||
|
||||
export { minimizeCrossings } from "./crossing-minimize";
|
||||
export { minimizeCrossings } from "./crossing-minimize.js";
|
||||
|
||||
export { assignCoordinates } from "./coordinate-assign";
|
||||
export { assignCoordinates } from "./coordinate-assign.js";
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* path from any source to that node. Sources (in-degree 0) get layer 0.
|
||||
*/
|
||||
|
||||
import type { LayoutNode, LayoutEdge } from "../../types";
|
||||
import type { LayoutNode, LayoutEdge } from "../../types.js";
|
||||
|
||||
/**
|
||||
* Assign layer indices to each node using longest-path from sources.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Nodes are placed in input order; horizontally centered by midpoint.
|
||||
*/
|
||||
|
||||
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types";
|
||||
import type { LayoutAlgorithm, LayoutInput, LayoutOutput, Position } from "../types.js";
|
||||
|
||||
const DEFAULT_GAP = 40;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* All functions are pure: they return new maps, never mutate inputs.
|
||||
*/
|
||||
|
||||
import type { Position, LayoutNode } from "./types";
|
||||
import type { Position, LayoutNode } from "./types.js";
|
||||
|
||||
/**
|
||||
* Remove vertical gaps between nodes by shifting them up.
|
||||
|
||||
@@ -17,9 +17,9 @@ import type {
|
||||
FrameworkResult,
|
||||
Position,
|
||||
GroupBounds,
|
||||
} from "./types";
|
||||
import { DEFAULT_FRAMEWORK_CONFIG } from "./types";
|
||||
import { tokenToAlgorithm } from "./tokens";
|
||||
} from "./types.js";
|
||||
import { DEFAULT_FRAMEWORK_CONFIG } from "./types.js";
|
||||
import { tokenToAlgorithm } from "./tokens.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const GROUP_LAYOUT_ID_PREFIX = "group:";
|
||||
|
||||
export function toGroupLayoutId(groupId: string | number): string {
|
||||
return `${GROUP_LAYOUT_ID_PREFIX}${groupId}`;
|
||||
}
|
||||
|
||||
export function fromGroupLayoutId(groupId: string): string | null {
|
||||
if (!groupId.startsWith(GROUP_LAYOUT_ID_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return groupId.slice(GROUP_LAYOUT_ID_PREFIX.length);
|
||||
}
|
||||
|
||||
export function fromNumericGroupLayoutId(groupId: string): number | null {
|
||||
const rawGroupId = fromGroupLayoutId(groupId);
|
||||
if (rawGroupId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(rawGroupId);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
+4
-4
@@ -8,9 +8,9 @@ export {
|
||||
splitDisconnected,
|
||||
placeDisconnected,
|
||||
translatePositions,
|
||||
} from "./framework";
|
||||
} from "./framework.js";
|
||||
|
||||
export { compactVertically, compactHorizontally } from "./compact";
|
||||
export { compactVertically, compactHorizontally } from "./compact.js";
|
||||
|
||||
export type {
|
||||
Position,
|
||||
@@ -24,6 +24,6 @@ export type {
|
||||
GroupBounds,
|
||||
FrameworkResult,
|
||||
FrameworkConfig,
|
||||
} from "./types";
|
||||
} from "./types.js";
|
||||
|
||||
export { DEFAULT_FRAMEWORK_CONFIG } from "./types";
|
||||
export { DEFAULT_FRAMEWORK_CONFIG } from "./types.js";
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
* 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";
|
||||
import type { LayoutToken, LayoutAlgorithm } from "./types.js";
|
||||
import { createHorizontalAlgorithm } from "./algorithms/horizontal.js";
|
||||
import { createVerticalAlgorithm } from "./algorithms/vertical.js";
|
||||
import { createGridAlgorithm } from "./algorithms/grid.js";
|
||||
|
||||
/**
|
||||
* Regex to match layout tokens in group titles.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLayoutAlgorithm } from "../../src/layout/algorithm-factory";
|
||||
import type { LayoutInput, LayoutNode } from "../../src/layout/types";
|
||||
|
||||
describe("createLayoutAlgorithm", () => {
|
||||
it("creates a horizontal algorithm using horizontalGap", () => {
|
||||
const algorithm = createLayoutAlgorithm("horizontal", {
|
||||
horizontalGap: 123,
|
||||
verticalGap: 77,
|
||||
});
|
||||
|
||||
const positions = algorithm.layout({
|
||||
nodes: [
|
||||
{ id: "a", width: 10, height: 10 },
|
||||
{ id: "b", width: 10, height: 10 },
|
||||
],
|
||||
edges: [],
|
||||
}).positions;
|
||||
|
||||
expect(algorithm.name).toBe("horizontal");
|
||||
expect(positions.get("b")?.x).toBe(133);
|
||||
});
|
||||
|
||||
it("creates a vertical algorithm using verticalGap", () => {
|
||||
const algorithm = createLayoutAlgorithm("vertical", {
|
||||
horizontalGap: 123,
|
||||
verticalGap: 77,
|
||||
});
|
||||
|
||||
const positions = algorithm.layout({
|
||||
nodes: [
|
||||
{ id: "a", width: 10, height: 10 },
|
||||
{ id: "b", width: 10, height: 10 },
|
||||
],
|
||||
edges: [],
|
||||
}).positions;
|
||||
|
||||
expect(algorithm.name).toBe("vertical");
|
||||
expect(positions.get("b")?.y).toBe(87);
|
||||
});
|
||||
|
||||
it("creates a sugiyama algorithm using both spacing values", () => {
|
||||
const algorithm = createLayoutAlgorithm("sugiyama", {
|
||||
horizontalGap: 123,
|
||||
verticalGap: 77,
|
||||
});
|
||||
|
||||
const input: LayoutInput = {
|
||||
nodes: [
|
||||
{ id: "left-a", width: 10, height: 10 },
|
||||
{ id: "left-b", width: 10, height: 10 },
|
||||
{ id: "right", width: 10, height: 10 },
|
||||
] satisfies LayoutNode[],
|
||||
edges: [
|
||||
{ source: "left-a", target: "right" },
|
||||
{ source: "left-b", target: "right" },
|
||||
],
|
||||
};
|
||||
|
||||
const positions = algorithm.layout(input).positions;
|
||||
|
||||
expect(algorithm.name).toBe("sugiyama");
|
||||
expect(positions.get("right")?.x).toBe(133);
|
||||
expect(positions.get("left-b")?.y).toBe(87);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
fromGroupLayoutId,
|
||||
fromNumericGroupLayoutId,
|
||||
toGroupLayoutId,
|
||||
} from "../../src/layout/group-ids";
|
||||
|
||||
describe("group layout ids", () => {
|
||||
it("namespaces string and numeric ids consistently", () => {
|
||||
expect(toGroupLayoutId("abc")).toBe("group:abc");
|
||||
expect(toGroupLayoutId(42)).toBe("group:42");
|
||||
});
|
||||
|
||||
it("parses raw ids back out of namespaced ids", () => {
|
||||
expect(fromGroupLayoutId("group:abc")).toBe("abc");
|
||||
expect(fromGroupLayoutId("node:abc")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses numeric group ids when they are valid numbers", () => {
|
||||
expect(fromNumericGroupLayoutId("group:42")).toBe(42);
|
||||
expect(fromNumericGroupLayoutId("group:abc")).toBeNull();
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
|
||||
Reference in New Issue
Block a user