This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
|
||||
|
||||
module.exports = defineFlatConfig({
|
||||
preset: 'web',
|
||||
packageRoot: __dirname,
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@flowgram.ai/background-plugin",
|
||||
"version": "0.1.8",
|
||||
"homepage": "https://flowgram.ai/",
|
||||
"repository": "https://github.com/bytedance/flowgram.ai",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/esm/index.js",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/esm/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build:fast -- --dts-resolve",
|
||||
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
|
||||
"build:watch": "npm run build:fast -- --dts-resolve",
|
||||
"clean": "rimraf dist",
|
||||
"test": "exit 0",
|
||||
"test:cov": "exit 0",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@flowgram.ai/core": "workspace:*",
|
||||
"@flowgram.ai/utils": "workspace:*",
|
||||
"inversify": "^6.0.1",
|
||||
"reflect-metadata": "~0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flowgram.ai/eslint-config": "workspace:*",
|
||||
"@flowgram.ai/ts-config": "workspace:*",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.0.0",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { domUtils } from '@flowgram.ai/utils';
|
||||
import { Layer, observeEntity, PlaygroundConfigEntity, SCALE_WIDTH } from '@flowgram.ai/core';
|
||||
|
||||
interface BackgroundScaleUnit {
|
||||
realSize: number;
|
||||
renderSize: number;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
const PATTERN_PREFIX = 'gedit-background-pattern-';
|
||||
const DEFAULT_RENDER_SIZE = 20;
|
||||
const DEFAULT_DOT_SIZE = 1;
|
||||
let id = 0;
|
||||
|
||||
export const BackgroundConfig = Symbol('BackgroundConfig');
|
||||
export interface BackgroundLayerOptions {
|
||||
/** 网格间距,默认 20px */
|
||||
gridSize?: number;
|
||||
/** 点的大小,默认 1px */
|
||||
dotSize?: number;
|
||||
/** 点的颜色,默认 "#eceeef" */
|
||||
dotColor?: string;
|
||||
/** 点的透明度,默认 0.5 */
|
||||
dotOpacity?: number;
|
||||
/** 背景颜色,默认透明 */
|
||||
backgroundColor?: string;
|
||||
/** 点的填充颜色,默认与stroke颜色相同 */
|
||||
dotFillColor?: string;
|
||||
/** Logo 配置 */
|
||||
logo?: {
|
||||
/** Logo 文本内容 */
|
||||
text?: string;
|
||||
/** Logo 图片 URL */
|
||||
imageUrl?: string;
|
||||
/** Logo 位置,默认 'center' */
|
||||
position?: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
/** Logo 大小,默认 'medium' */
|
||||
size?: 'small' | 'medium' | 'large' | number;
|
||||
/** Logo 透明度,默认 0.1 */
|
||||
opacity?: number;
|
||||
/** Logo 颜色(仅文本),默认 "#cccccc" */
|
||||
color?: string;
|
||||
/** Logo 字体大小(仅文本),默认根据 size 计算 */
|
||||
fontSize?: number;
|
||||
/** Logo 字体家族(仅文本),默认 'Arial, sans-serif' */
|
||||
fontFamily?: string;
|
||||
/** Logo 字体粗细(仅文本),默认 'normal' */
|
||||
fontWeight?: 'normal' | 'bold' | 'lighter' | number;
|
||||
/** 自定义偏移 */
|
||||
offset?: { x: number; y: number };
|
||||
/** 新拟态(Neumorphism)效果配置 */
|
||||
neumorphism?: {
|
||||
/** 是否启用新拟态效果,默认 false */
|
||||
enabled?: boolean;
|
||||
/** 主要文字颜色,应该与背景色接近,默认自动计算 */
|
||||
textColor?: string;
|
||||
/** 亮色阴影颜色,默认自动计算(背景色的亮色版本) */
|
||||
lightShadowColor?: string;
|
||||
/** 暗色阴影颜色,默认自动计算(背景色的暗色版本) */
|
||||
darkShadowColor?: string;
|
||||
/** 阴影偏移距离,默认 6 */
|
||||
shadowOffset?: number;
|
||||
/** 阴影模糊半径,默认 12 */
|
||||
shadowBlur?: number;
|
||||
/** 效果强度(0-1),影响阴影的透明度,默认 0.3 */
|
||||
intensity?: number;
|
||||
/** 凸起效果(true)还是凹陷效果(false),默认 true */
|
||||
raised?: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* dot 网格背景
|
||||
*/
|
||||
export class BackgroundLayer extends Layer<BackgroundLayerOptions> {
|
||||
static type = 'WorkflowBackgroundLayer';
|
||||
|
||||
@observeEntity(PlaygroundConfigEntity)
|
||||
protected playgroundConfigEntity: PlaygroundConfigEntity;
|
||||
|
||||
private _patternId = `${PATTERN_PREFIX}${id++}`;
|
||||
|
||||
node = domUtils.createDivWithClass('gedit-flow-background-layer');
|
||||
|
||||
grid: HTMLElement = document.createElement('div');
|
||||
|
||||
/**
|
||||
* 获取网格大小配置
|
||||
*/
|
||||
private get gridSize(): number {
|
||||
return this.options.gridSize ?? DEFAULT_RENDER_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取点大小配置
|
||||
*/
|
||||
private get dotSize(): number {
|
||||
return this.options.dotSize ?? DEFAULT_DOT_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取点颜色配置
|
||||
*/
|
||||
private get dotColor(): string {
|
||||
return this.options.dotColor ?? '#eceeef';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取点透明度配置
|
||||
*/
|
||||
private get dotOpacity(): number {
|
||||
return this.options.dotOpacity ?? 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取背景颜色配置
|
||||
*/
|
||||
private get backgroundColor(): string {
|
||||
return this.options.backgroundColor ?? 'transparent';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取点填充颜色配置
|
||||
*/
|
||||
private get dotFillColor(): string {
|
||||
return this.options.dotFillColor ?? this.dotColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Logo配置
|
||||
*/
|
||||
private get logoConfig() {
|
||||
return this.options.logo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前缩放比
|
||||
*/
|
||||
get zoom(): number {
|
||||
return this.config.finalScale;
|
||||
}
|
||||
|
||||
onReady() {
|
||||
const { firstChild } = this.pipelineNode;
|
||||
// 背景插入到最下边
|
||||
this.pipelineNode.insertBefore(this.node, firstChild);
|
||||
// 初始化设置最大 200% 最小 10% 缩放
|
||||
this.playgroundConfigEntity.updateConfig({
|
||||
minZoom: 0.1,
|
||||
maxZoom: 2,
|
||||
});
|
||||
// 确保点的位置在线条的下方
|
||||
this.grid.style.zIndex = '-1';
|
||||
this.grid.style.position = 'relative';
|
||||
this.node.appendChild(this.grid);
|
||||
this.grid.className = 'gedit-grid-svg';
|
||||
|
||||
// 设置背景颜色
|
||||
if (this.backgroundColor !== 'transparent') {
|
||||
this.node.style.backgroundColor = this.backgroundColor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 最小单元格大小
|
||||
*/
|
||||
getScaleUnit(): BackgroundScaleUnit {
|
||||
const { zoom } = this;
|
||||
|
||||
return {
|
||||
realSize: this.gridSize, // 使用配置的网格大小
|
||||
renderSize: Math.round(this.gridSize * zoom * 100) / 100, // 一个单元格渲染的大小值
|
||||
zoom, // 缩放比
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制
|
||||
*/
|
||||
autorun(): void {
|
||||
const playgroundConfig = this.playgroundConfigEntity.config;
|
||||
const scaleUnit = this.getScaleUnit();
|
||||
const mod = scaleUnit.renderSize * 10;
|
||||
const viewBoxWidth = playgroundConfig.width + mod * 2;
|
||||
const viewBoxHeight = playgroundConfig.height + mod * 2;
|
||||
const { scrollX } = playgroundConfig;
|
||||
const { scrollY } = playgroundConfig;
|
||||
const scrollXDelta = this.getScrollDelta(scrollX, mod);
|
||||
const scrollYDelta = this.getScrollDelta(scrollY, mod);
|
||||
domUtils.setStyle(this.node, {
|
||||
left: scrollX - SCALE_WIDTH,
|
||||
top: scrollY - SCALE_WIDTH,
|
||||
});
|
||||
this.drawGrid(scaleUnit, viewBoxWidth, viewBoxHeight);
|
||||
// 设置网格
|
||||
this.setSVGStyle(this.grid, {
|
||||
width: viewBoxWidth,
|
||||
height: viewBoxHeight,
|
||||
left: SCALE_WIDTH - scrollXDelta - mod,
|
||||
top: SCALE_WIDTH - scrollYDelta - mod,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算Logo位置
|
||||
*/
|
||||
private calculateLogoPosition(
|
||||
viewBoxWidth: number,
|
||||
viewBoxHeight: number
|
||||
): { x: number; y: number } {
|
||||
if (!this.logoConfig) return { x: 0, y: 0 };
|
||||
|
||||
const { position = 'center', offset = { x: 0, y: 0 } } = this.logoConfig;
|
||||
const playgroundConfig = this.playgroundConfigEntity.config;
|
||||
const scaleUnit = this.getScaleUnit();
|
||||
const mod = scaleUnit.renderSize * 10;
|
||||
|
||||
// 计算SVG内的相对位置,使Logo相对于可视区域固定
|
||||
const { scrollX, scrollY } = playgroundConfig;
|
||||
const scrollXDelta = this.getScrollDelta(scrollX, mod);
|
||||
const scrollYDelta = this.getScrollDelta(scrollY, mod);
|
||||
|
||||
// 可视区域的基准点(相对于SVG坐标系)
|
||||
const visibleLeft = mod + scrollXDelta;
|
||||
const visibleTop = mod + scrollYDelta;
|
||||
const visibleCenterX = visibleLeft + playgroundConfig.width / 2;
|
||||
const visibleCenterY = visibleTop + playgroundConfig.height / 2;
|
||||
|
||||
let x = 0,
|
||||
y = 0;
|
||||
|
||||
switch (position) {
|
||||
case 'center':
|
||||
x = visibleCenterX;
|
||||
y = visibleCenterY;
|
||||
break;
|
||||
case 'top-left':
|
||||
x = visibleLeft + 100;
|
||||
y = visibleTop + 100;
|
||||
break;
|
||||
case 'top-right':
|
||||
x = visibleLeft + playgroundConfig.width - 100;
|
||||
y = visibleTop + 100;
|
||||
break;
|
||||
case 'bottom-left':
|
||||
x = visibleLeft + 100;
|
||||
y = visibleTop + playgroundConfig.height - 100;
|
||||
break;
|
||||
case 'bottom-right':
|
||||
x = visibleLeft + playgroundConfig.width - 100;
|
||||
y = visibleTop + playgroundConfig.height - 100;
|
||||
break;
|
||||
}
|
||||
|
||||
return { x: x + offset.x, y: y + offset.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Logo大小
|
||||
*/
|
||||
private getLogoSize(): number {
|
||||
if (!this.logoConfig) return 0;
|
||||
|
||||
const { size = 'medium' } = this.logoConfig;
|
||||
|
||||
if (typeof size === 'number') {
|
||||
return size;
|
||||
}
|
||||
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 24;
|
||||
case 'medium':
|
||||
return 48;
|
||||
case 'large':
|
||||
return 72;
|
||||
default:
|
||||
return 48;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 颜色工具函数:将十六进制颜色转换为RGB
|
||||
*/
|
||||
private hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 颜色工具函数:调整颜色亮度
|
||||
*/
|
||||
private adjustBrightness(hex: string, percent: number): string {
|
||||
const rgb = this.hexToRgb(hex);
|
||||
if (!rgb) return hex;
|
||||
|
||||
const adjust = (value: number) => {
|
||||
const adjusted = Math.round(value + (255 - value) * percent);
|
||||
return Math.max(0, Math.min(255, adjusted));
|
||||
};
|
||||
|
||||
return `#${adjust(rgb.r).toString(16).padStart(2, '0')}${adjust(rgb.g)
|
||||
.toString(16)
|
||||
.padStart(2, '0')}${adjust(rgb.b).toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成新拟态阴影滤镜
|
||||
*/
|
||||
private generateNeumorphismFilter(
|
||||
filterId: string,
|
||||
lightShadow: string,
|
||||
darkShadow: string,
|
||||
offset: number,
|
||||
blur: number,
|
||||
intensity: number,
|
||||
raised: boolean
|
||||
): string {
|
||||
const lightOffset = raised ? -offset : offset;
|
||||
const darkOffset = raised ? offset : -offset;
|
||||
|
||||
return `
|
||||
<defs>
|
||||
<filter id="${filterId}" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="${lightOffset}" dy="${lightOffset}" stdDeviation="${blur}" flood-color="${lightShadow}" flood-opacity="${intensity}"/>
|
||||
<feDropShadow dx="${darkOffset}" dy="${darkOffset}" stdDeviation="${blur}" flood-color="${darkShadow}" flood-opacity="${intensity}"/>
|
||||
</filter>
|
||||
</defs>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制Logo SVG内容
|
||||
*/
|
||||
private generateLogoSVG(viewBoxWidth: number, viewBoxHeight: number): string {
|
||||
if (!this.logoConfig) return '';
|
||||
|
||||
const {
|
||||
text,
|
||||
imageUrl,
|
||||
opacity = 0.1,
|
||||
color = '#cccccc',
|
||||
fontSize,
|
||||
fontFamily = 'Arial, sans-serif',
|
||||
fontWeight = 'normal',
|
||||
neumorphism,
|
||||
} = this.logoConfig;
|
||||
const position = this.calculateLogoPosition(viewBoxWidth, viewBoxHeight);
|
||||
const logoSize = this.getLogoSize();
|
||||
|
||||
let logoSVG = '';
|
||||
|
||||
if (imageUrl) {
|
||||
// 图片Logo(暂不支持3D效果)
|
||||
logoSVG = `
|
||||
<image
|
||||
href="${imageUrl}"
|
||||
x="${position.x - logoSize / 2}"
|
||||
y="${position.y - logoSize / 2}"
|
||||
width="${logoSize}"
|
||||
height="${logoSize}"
|
||||
opacity="${opacity}"
|
||||
/>`;
|
||||
} else if (text) {
|
||||
// 文本Logo
|
||||
const actualFontSize = fontSize ?? Math.max(logoSize / 2, 12);
|
||||
|
||||
// 检查是否启用新拟态效果
|
||||
if (neumorphism?.enabled) {
|
||||
const {
|
||||
textColor,
|
||||
lightShadowColor,
|
||||
darkShadowColor,
|
||||
shadowOffset = 6,
|
||||
shadowBlur = 12,
|
||||
intensity = 0.3,
|
||||
raised = true,
|
||||
} = neumorphism;
|
||||
|
||||
// 自动计算颜色(如果未提供)
|
||||
const bgColor = this.backgroundColor !== 'transparent' ? this.backgroundColor : '#f0f0f0';
|
||||
const finalTextColor = textColor || bgColor;
|
||||
const finalLightShadow = lightShadowColor || this.adjustBrightness(bgColor, 0.2);
|
||||
const finalDarkShadow = darkShadowColor || this.adjustBrightness(bgColor, -0.2);
|
||||
|
||||
const filterId = `neumorphism-${this._patternId}`;
|
||||
|
||||
// 添加新拟态滤镜定义
|
||||
logoSVG += this.generateNeumorphismFilter(
|
||||
filterId,
|
||||
finalLightShadow,
|
||||
finalDarkShadow,
|
||||
shadowOffset,
|
||||
shadowBlur,
|
||||
intensity,
|
||||
raised
|
||||
);
|
||||
|
||||
// 创建新拟态文本
|
||||
logoSVG += `
|
||||
<text
|
||||
x="${position.x}"
|
||||
y="${position.y}"
|
||||
font-family="${fontFamily}"
|
||||
font-size="${actualFontSize}"
|
||||
font-weight="${fontWeight}"
|
||||
fill="${finalTextColor}"
|
||||
opacity="${opacity}"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
filter="url(#${filterId})"
|
||||
>${text}</text>`;
|
||||
} else {
|
||||
// 普通文本(无3D效果)
|
||||
logoSVG = `
|
||||
<text
|
||||
x="${position.x}"
|
||||
y="${position.y}"
|
||||
font-family="${fontFamily}"
|
||||
font-size="${actualFontSize}"
|
||||
font-weight="${fontWeight}"
|
||||
fill="${color}"
|
||||
opacity="${opacity}"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
>${text}</text>`;
|
||||
}
|
||||
}
|
||||
|
||||
return logoSVG;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制网格
|
||||
*/
|
||||
protected drawGrid(unit: BackgroundScaleUnit, viewBoxWidth: number, viewBoxHeight: number): void {
|
||||
const minor = unit.renderSize;
|
||||
if (!this.grid) {
|
||||
return;
|
||||
}
|
||||
const patternSize = this.dotSize * this.zoom;
|
||||
|
||||
// 构建SVG内容,根据是否有背景颜色决定是否添加背景矩形
|
||||
let svgContent = `<svg width="100%" height="100%">`;
|
||||
|
||||
// 如果设置了背景颜色,先绘制背景矩形
|
||||
if (this.backgroundColor !== 'transparent') {
|
||||
svgContent += `<rect width="100%" height="100%" fill="${this.backgroundColor}"/>`;
|
||||
}
|
||||
|
||||
// 添加点阵图案
|
||||
// 构建圆圈属性,保持与原始实现的兼容性
|
||||
const circleAttributes = [
|
||||
`cx="${patternSize}"`,
|
||||
`cy="${patternSize}"`,
|
||||
`r="${patternSize}"`,
|
||||
`stroke="${this.dotColor}"`,
|
||||
// 只有当 dotFillColor 被明确设置且与 dotColor 不同时才添加 fill 属性
|
||||
this.options.dotFillColor && this.dotFillColor !== this.dotColor
|
||||
? `fill="${this.dotFillColor}"`
|
||||
: '',
|
||||
`fill-opacity="${this.dotOpacity}"`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
svgContent += `
|
||||
<pattern id="${this._patternId}" width="${minor}" height="${minor}" patternUnits="userSpaceOnUse">
|
||||
<circle ${circleAttributes} />
|
||||
</pattern>
|
||||
<rect width="100%" height="100%" fill="url(#${this._patternId})"/>`;
|
||||
|
||||
// 添加Logo
|
||||
const logoSVG = this.generateLogoSVG(viewBoxWidth, viewBoxHeight);
|
||||
if (logoSVG) {
|
||||
svgContent += logoSVG;
|
||||
}
|
||||
|
||||
svgContent += `</svg>`;
|
||||
|
||||
this.grid.innerHTML = svgContent;
|
||||
}
|
||||
|
||||
protected setSVGStyle(
|
||||
svgElement: HTMLElement | undefined,
|
||||
style: { width: number; height: number; left: number; top: number }
|
||||
): void {
|
||||
if (!svgElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
svgElement.style.width = `${style.width}px`;
|
||||
svgElement.style.height = `${style.height}px`;
|
||||
svgElement.style.left = `${style.left}px`;
|
||||
svgElement.style.top = `${style.top}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取相对滚动距离
|
||||
* @param realScroll
|
||||
* @param mod
|
||||
*/
|
||||
protected getScrollDelta(realScroll: number, mod: number): number {
|
||||
// 正向滚动不用补差
|
||||
if (realScroll >= 0) {
|
||||
return realScroll % mod;
|
||||
}
|
||||
return mod - (Math.abs(realScroll) % mod);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { definePluginCreator } from '@flowgram.ai/core';
|
||||
|
||||
import { BackgroundConfig, BackgroundLayer, BackgroundLayerOptions } from './background-layer';
|
||||
|
||||
/**
|
||||
* 点位背景插件
|
||||
*/
|
||||
export const createBackgroundPlugin = definePluginCreator<BackgroundLayerOptions>({
|
||||
onBind: (bindConfig, opts) => {
|
||||
bindConfig.bind(BackgroundConfig).toConstantValue(opts);
|
||||
},
|
||||
onInit: (ctx, opts) => {
|
||||
ctx.playground.registerLayer(BackgroundLayer, opts);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './background-layer';
|
||||
export * from './create-background-plugin';
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
mockReset: false,
|
||||
environment: 'jsdom',
|
||||
setupFiles: [path.resolve(__dirname, './vitest.setup.ts')],
|
||||
include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'],
|
||||
exclude: [
|
||||
'**/__mocks__**',
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/lib/**', // lib 编译结果忽略掉
|
||||
'**/cypress/**',
|
||||
'**/.{idea,git,cache,output,temp}/**',
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
|
||||
|
||||
module.exports = defineFlatConfig({
|
||||
preset: 'web',
|
||||
packageRoot: __dirname,
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@flowgram.ai/export-plugin",
|
||||
"version": "0.1.8",
|
||||
"homepage": "https://flowgram.ai/",
|
||||
"repository": "https://github.com/bytedance/flowgram.ai",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/esm/index.js",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/esm/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build:fast -- --dts-resolve",
|
||||
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
|
||||
"build:watch": "npm run build:fast -- --dts-resolve",
|
||||
"clean": "rimraf dist",
|
||||
"test": "exit 0",
|
||||
"test:cov": "exit 0",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@flowgram.ai/core": "workspace:*",
|
||||
"@flowgram.ai/document": "workspace:*",
|
||||
"@flowgram.ai/utils": "workspace:*",
|
||||
"inversify": "^6.0.1",
|
||||
"reflect-metadata": "~0.2.2",
|
||||
"nanoid": "^5.0.9",
|
||||
"modern-screenshot": "4.6.7",
|
||||
"lodash-es": "^4.17.21",
|
||||
"js-yaml": "^4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flowgram.ai/eslint-config": "workspace:*",
|
||||
"@flowgram.ai/ts-config": "workspace:*",
|
||||
"@types/bezier-js": "4.1.3",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.0.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4",
|
||||
"@types/js-yaml": "^4.0.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export enum FlowDownloadFormat {
|
||||
JSON = 'json',
|
||||
YAML = 'yaml',
|
||||
PNG = 'png',
|
||||
JPEG = 'jpeg',
|
||||
SVG = 'svg',
|
||||
}
|
||||
|
||||
export const FlowImageFormats = [
|
||||
FlowDownloadFormat.PNG,
|
||||
FlowDownloadFormat.JPEG,
|
||||
FlowDownloadFormat.SVG,
|
||||
];
|
||||
|
||||
export const FlowDataFormats = [FlowDownloadFormat.JSON, FlowDownloadFormat.YAML];
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { definePluginCreator, PluginContext } from '@flowgram.ai/core';
|
||||
|
||||
import { CreateDownloadPluginOptions } from './type';
|
||||
import { WorkflowExportImageService } from './export-image-service';
|
||||
import { FlowDownloadService } from './download-service';
|
||||
|
||||
export const createDownloadPlugin = definePluginCreator<CreateDownloadPluginOptions>({
|
||||
onBind: ({ bind }) => {
|
||||
bind(WorkflowExportImageService).toSelf().inSingletonScope();
|
||||
bind(FlowDownloadService).toSelf().inSingletonScope();
|
||||
},
|
||||
onInit: (ctx: PluginContext, opts: CreateDownloadPluginOptions) => {
|
||||
ctx.get(FlowDownloadService).init(opts);
|
||||
},
|
||||
onDispose: (ctx: PluginContext) => {
|
||||
ctx.get(FlowDownloadService).dispose();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { FlowDownloadService } from './service';
|
||||
export { DownloadServiceOptions } from './type';
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
import { nanoid } from 'nanoid';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { DisposableCollection, Emitter } from '@flowgram.ai/utils';
|
||||
import { FlowDocument } from '@flowgram.ai/document';
|
||||
|
||||
import type { DownloadServiceOptions, WorkflowDownloadParams } from './type';
|
||||
import { WorkflowExportImageService } from '../export-image-service';
|
||||
import { FlowDataFormats, FlowDownloadFormat, FlowImageFormats } from '../constant';
|
||||
|
||||
@injectable()
|
||||
export class FlowDownloadService {
|
||||
@inject(FlowDocument) private readonly document: FlowDocument;
|
||||
|
||||
@inject(WorkflowExportImageService)
|
||||
private readonly exportImageService: WorkflowExportImageService;
|
||||
|
||||
private toDispose: DisposableCollection = new DisposableCollection();
|
||||
|
||||
public downloading = false;
|
||||
|
||||
private onDownloadingChangeEmitter = new Emitter<boolean>();
|
||||
|
||||
private options: DownloadServiceOptions = {};
|
||||
|
||||
public onDownloadingChange = this.onDownloadingChangeEmitter.event;
|
||||
|
||||
public init(options?: Partial<DownloadServiceOptions>) {
|
||||
this.options = options ?? {};
|
||||
this.toDispose.push(this.onDownloadingChangeEmitter);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.toDispose.dispose();
|
||||
}
|
||||
|
||||
public async download(params: WorkflowDownloadParams): Promise<void> {
|
||||
if (this.downloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { format } = params;
|
||||
|
||||
if (FlowImageFormats.includes(format)) {
|
||||
await this.handleImageDownload(format);
|
||||
} else if (FlowDataFormats.includes(format)) {
|
||||
await this.handleDataDownload(format);
|
||||
}
|
||||
}
|
||||
|
||||
public setDownloading(value: boolean) {
|
||||
this.downloading = value;
|
||||
this.onDownloadingChangeEmitter.fire(value);
|
||||
}
|
||||
|
||||
private async handleImageDownload(format: FlowDownloadFormat): Promise<void> {
|
||||
this.setDownloading(true);
|
||||
try {
|
||||
await this.downloadImage(format);
|
||||
} finally {
|
||||
this.setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDataDownload(format: FlowDownloadFormat): Promise<void> {
|
||||
this.setDownloading(true);
|
||||
try {
|
||||
await this.downloadData(format);
|
||||
} finally {
|
||||
this.setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadData(format: FlowDownloadFormat): Promise<void> {
|
||||
const json = this.document.toJSON();
|
||||
const { content, mimeType } = await this.formatDataContent(json, format);
|
||||
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const filename = this.getFileName(format);
|
||||
|
||||
this.downloadFile(url, filename);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
private async formatDataContent(
|
||||
json: unknown,
|
||||
format: FlowDownloadFormat
|
||||
): Promise<{ content: string; mimeType: string }> {
|
||||
if (format === FlowDownloadFormat.YAML) {
|
||||
const yaml = await import('js-yaml');
|
||||
return {
|
||||
content: yaml.dump(json, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
}),
|
||||
mimeType: 'application/x-yaml',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: JSON.stringify(json, null, 2),
|
||||
mimeType: 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async downloadImage(format: FlowDownloadFormat): Promise<void> {
|
||||
const imageUrl = await this.exportImageService.export({
|
||||
format,
|
||||
watermarkSVG: this.options.watermarkSVG,
|
||||
});
|
||||
if (!imageUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = this.getFileName(format);
|
||||
this.downloadFile(imageUrl, filename);
|
||||
}
|
||||
|
||||
private getFileName(format: FlowDownloadFormat): string {
|
||||
if (this.options.getFilename) {
|
||||
return this.options.getFilename(format);
|
||||
}
|
||||
return `flowgram-${nanoid(5)}.${format}`;
|
||||
}
|
||||
|
||||
private downloadFile(href: string, filename: string): void {
|
||||
const link = document.createElement('a');
|
||||
link.href = href;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FlowDownloadFormat } from '../constant';
|
||||
|
||||
export interface WorkflowDownloadParams {
|
||||
format: FlowDownloadFormat;
|
||||
}
|
||||
|
||||
export interface DownloadServiceOptions {
|
||||
getFilename?: (format: FlowDownloadFormat) => string;
|
||||
watermarkSVG?: string;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { FlowExportImageService as WorkflowExportImageService } from './service';
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { FlowDocument } from '@flowgram.ai/document';
|
||||
|
||||
import { getWorkflowRect } from './utils';
|
||||
import { type IFlowExportImageService, type ExportImageOptions } from './type';
|
||||
import {
|
||||
IN_SAFARI,
|
||||
IN_FIREFOX,
|
||||
EXPORT_IMAGE_WATERMARK_SVG,
|
||||
EXPORT_IMAGE_STYLE_PROPERTIES,
|
||||
} from './constant';
|
||||
import { FlowDownloadFormat } from '../constant';
|
||||
|
||||
const PADDING_X = 58;
|
||||
const PADDING_Y = 138;
|
||||
|
||||
@injectable()
|
||||
export class FlowExportImageService implements IFlowExportImageService {
|
||||
private modernScreenshot: any;
|
||||
|
||||
@inject(FlowDocument)
|
||||
private document: FlowDocument;
|
||||
|
||||
public async export(options: ExportImageOptions): Promise<string | undefined> {
|
||||
try {
|
||||
const imgUrl = await this.doExport(options);
|
||||
return imgUrl;
|
||||
} catch (e) {
|
||||
console.error('Export image failed:', e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadModernScreenshot() {
|
||||
if (this.modernScreenshot) {
|
||||
return this.modernScreenshot;
|
||||
}
|
||||
|
||||
const modernScreenshot = await import('modern-screenshot');
|
||||
this.modernScreenshot = modernScreenshot;
|
||||
}
|
||||
|
||||
private async doExport(exportOptions: ExportImageOptions): Promise<string | undefined> {
|
||||
if (this.document.layout.name.includes('fixed-layout')) {
|
||||
return await this.doFixedExport(exportOptions);
|
||||
}
|
||||
return await this.doFreeExport(exportOptions);
|
||||
}
|
||||
|
||||
private async doFreeExport(exportOptions: ExportImageOptions): Promise<string | undefined> {
|
||||
const { format } = exportOptions;
|
||||
// const el = this.stackingContextManager.node as HTMLElement;
|
||||
const renderLayer = window.document.querySelector('.gedit-flow-render-layer') as HTMLElement;
|
||||
|
||||
if (!renderLayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { width, height, x, y } = getWorkflowRect(this.document);
|
||||
|
||||
await this.loadModernScreenshot();
|
||||
const { domToPng, domToForeignObjectSvg, domToJpeg } = this.modernScreenshot;
|
||||
let imgUrl: string;
|
||||
const options = {
|
||||
scale: 2,
|
||||
includeStyleProperties: IN_SAFARI || IN_FIREFOX ? EXPORT_IMAGE_STYLE_PROPERTIES : undefined,
|
||||
width: width + PADDING_X * 2,
|
||||
height: height + PADDING_Y * 2,
|
||||
onCloneEachNode: (cloned: HTMLElement) => {
|
||||
this.handleFreeClone(cloned, { width, height, x, y, options: exportOptions });
|
||||
},
|
||||
};
|
||||
switch (format) {
|
||||
case FlowDownloadFormat.PNG:
|
||||
imgUrl = await domToPng(renderLayer, options);
|
||||
break;
|
||||
case FlowDownloadFormat.SVG: {
|
||||
const svg = await domToForeignObjectSvg(renderLayer, options);
|
||||
imgUrl = await this.svgToDataURL(svg);
|
||||
break;
|
||||
}
|
||||
case FlowDownloadFormat.JPEG:
|
||||
imgUrl = await domToJpeg(renderLayer, options);
|
||||
break;
|
||||
default:
|
||||
imgUrl = await domToPng(renderLayer, options);
|
||||
}
|
||||
return imgUrl;
|
||||
}
|
||||
|
||||
private async doFixedExport(exportOptions: ExportImageOptions): Promise<string | undefined> {
|
||||
const { format } = exportOptions;
|
||||
|
||||
const el = window.document.querySelector('.gedit-flow-nodes-layer') as HTMLElement;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { width, height, x, y } = getWorkflowRect(this.document);
|
||||
await this.loadModernScreenshot();
|
||||
const { domToPng, domToForeignObjectSvg, domToJpeg } = this.modernScreenshot;
|
||||
let imgUrl: string;
|
||||
const options = {
|
||||
scale: 2,
|
||||
includeStyleProperties: IN_SAFARI || IN_FIREFOX ? EXPORT_IMAGE_STYLE_PROPERTIES : undefined,
|
||||
width: width + PADDING_X * 2,
|
||||
height: height + PADDING_Y * 2,
|
||||
onCloneEachNode: (cloned: HTMLElement) => {
|
||||
this.handleFixedClone(cloned, { width, height, x, y, options: exportOptions });
|
||||
},
|
||||
};
|
||||
switch (format) {
|
||||
case FlowDownloadFormat.PNG:
|
||||
imgUrl = await domToPng(el, options);
|
||||
break;
|
||||
case FlowDownloadFormat.SVG: {
|
||||
const svg = await domToForeignObjectSvg(el, options);
|
||||
imgUrl = await this.svgToDataURL(svg);
|
||||
break;
|
||||
}
|
||||
case FlowDownloadFormat.JPEG:
|
||||
imgUrl = await domToJpeg(el, options);
|
||||
break;
|
||||
default:
|
||||
imgUrl = await domToPng(el, options);
|
||||
}
|
||||
return imgUrl;
|
||||
}
|
||||
|
||||
private async svgToDataURL(svg: SVGElement): Promise<string> {
|
||||
return Promise.resolve()
|
||||
.then(() => new XMLSerializer().serializeToString(svg))
|
||||
.then(encodeURIComponent)
|
||||
.then((html) => `data:image/svg+xml;charset=utf-8,${html}`);
|
||||
}
|
||||
|
||||
// 处理克隆节点
|
||||
private handleFreeClone(
|
||||
cloned: HTMLElement,
|
||||
{
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
options,
|
||||
}: { width: number; height: number; x: number; y: number; options: ExportImageOptions }
|
||||
) {
|
||||
if (
|
||||
cloned?.classList?.contains('gedit-flow-activity-node') ||
|
||||
cloned?.classList?.contains('gedit-flow-activity-line')
|
||||
) {
|
||||
this.handlePosition(cloned, x, y);
|
||||
}
|
||||
|
||||
if (cloned?.classList?.contains('gedit-flow-render-layer')) {
|
||||
this.handleCanvas(cloned, width, height, options);
|
||||
}
|
||||
|
||||
this.handleTextareaValue(cloned);
|
||||
}
|
||||
|
||||
private handleTextareaValue(cloned: HTMLElement) {
|
||||
if (cloned.tagName !== 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
const textarea = cloned as HTMLTextAreaElement;
|
||||
const value = textarea.getAttribute('value') || textarea.value || '';
|
||||
textarea.textContent = value;
|
||||
}
|
||||
|
||||
// 处理克隆节点
|
||||
private handleFixedClone(
|
||||
cloned: HTMLElement,
|
||||
{
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
options,
|
||||
}: { width: number; height: number; x: number; y: number; options: ExportImageOptions }
|
||||
) {
|
||||
if (
|
||||
cloned?.classList?.contains('gedit-flow-activity-node') ||
|
||||
cloned?.classList?.contains('gedit-flow-activity-line')
|
||||
) {
|
||||
this.handlePosition(cloned, x, y);
|
||||
}
|
||||
|
||||
if (cloned?.classList?.contains('gedit-flow-nodes-layer')) {
|
||||
const linesLayer = window.document
|
||||
.querySelector('.gedit-flow-lines-layer')
|
||||
?.cloneNode(true) as HTMLElement;
|
||||
this.handleLines(linesLayer, width, height);
|
||||
cloned.appendChild(linesLayer);
|
||||
this.handleCanvas(cloned, width, height, options);
|
||||
}
|
||||
|
||||
this.handleTextareaValue(cloned);
|
||||
}
|
||||
|
||||
// 处理节点位置
|
||||
private handlePosition(cloned: HTMLElement, x: number, y: number) {
|
||||
cloned.style.transform = `translate(${-x + PADDING_X}px, ${-y + PADDING_Y}px)`;
|
||||
}
|
||||
|
||||
// 处理画布
|
||||
private handleLines(cloned: HTMLElement, width: number, height: number) {
|
||||
cloned.style.position = 'absolute';
|
||||
cloned.style.width = `${width}px`;
|
||||
cloned.style.height = `${height}px`;
|
||||
cloned.style.left = `${width / 2 - PADDING_X}px`;
|
||||
cloned.style.top = `${PADDING_Y}px`;
|
||||
cloned.style.transform = 'none';
|
||||
cloned.style.backgroundColor = 'transparent';
|
||||
cloned.querySelector('.flow-lines-container')!.setAttribute('viewBox', `0 0 1000 1000`);
|
||||
}
|
||||
|
||||
// 处理画布
|
||||
private handleCanvas(
|
||||
cloned: HTMLElement,
|
||||
width: number,
|
||||
height: number,
|
||||
options: ExportImageOptions
|
||||
) {
|
||||
cloned.style.width = `${width + PADDING_X * 2}px`;
|
||||
cloned.style.height = `${height + PADDING_Y * 2}px`;
|
||||
cloned.style.transform = 'none';
|
||||
cloned.style.backgroundColor = '#ECECEE';
|
||||
this.handleWaterMark(cloned, options);
|
||||
}
|
||||
|
||||
// 添加水印节点
|
||||
private handleWaterMark(element: HTMLElement, options: ExportImageOptions) {
|
||||
const watermarkNode = document.createElement('div');
|
||||
// 水印svg
|
||||
watermarkNode.innerHTML = options?.watermarkSVG ?? EXPORT_IMAGE_WATERMARK_SVG;
|
||||
watermarkNode.style.position = 'absolute';
|
||||
watermarkNode.style.bottom = '32px';
|
||||
watermarkNode.style.right = '32px';
|
||||
watermarkNode.style.zIndex = '999999';
|
||||
element.appendChild(watermarkNode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FlowDownloadFormat } from '../constant';
|
||||
|
||||
/**
|
||||
* 导出图片服务
|
||||
*/
|
||||
export interface IFlowExportImageService {
|
||||
/**
|
||||
* 导出
|
||||
*/
|
||||
export: (options: ExportImageOptions) => Promise<string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出图片选项
|
||||
*/
|
||||
export interface ExportImageOptions {
|
||||
/**
|
||||
* 导出的格式
|
||||
*/
|
||||
format: FlowDownloadFormat;
|
||||
watermarkSVG?: string;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FlowDocument, FlowNodeEntity } from '@flowgram.ai/document';
|
||||
import { TransformData } from '@flowgram.ai/core';
|
||||
|
||||
const getNodesRect = (nodes: FlowNodeEntity[]) => {
|
||||
const rects = nodes
|
||||
.map((node) => node.getData<TransformData>(TransformData)?.bounds)
|
||||
.filter(Boolean);
|
||||
const x1 = Math.min(...rects.map((rect) => rect.x));
|
||||
const x2 = Math.max(...rects.map((rect) => rect.x + rect.width));
|
||||
const y1 = Math.min(...rects.map((rect) => rect.y));
|
||||
const y2 = Math.max(...rects.map((rect) => rect.y + rect.height));
|
||||
|
||||
const width = x2 - x1;
|
||||
const height = y2 - y1;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
x: x1,
|
||||
y: y1,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取流程所有节点矩形坐标
|
||||
*/
|
||||
export const getWorkflowRect = (document: FlowDocument) => getNodesRect(document.getAllNodes());
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { createDownloadPlugin } from './create-plugin';
|
||||
export { FlowDownloadService, type DownloadServiceOptions } from './download-service';
|
||||
export { type CreateDownloadPluginOptions } from './type';
|
||||
export { FlowDownloadFormat } from './constant';
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { DownloadServiceOptions } from './download-service';
|
||||
|
||||
export interface CreateDownloadPluginOptions extends Partial<DownloadServiceOptions> {}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
|
||||
"compilerOptions": {
|
||||
},
|
||||
"include": ["./src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
mockReset: false,
|
||||
environment: 'jsdom',
|
||||
setupFiles: [path.resolve(__dirname, './vitest.setup.ts')],
|
||||
include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'],
|
||||
exclude: [
|
||||
'**/__mocks__**',
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/lib/**', // lib 编译结果忽略掉
|
||||
'**/cypress/**',
|
||||
'**/.{idea,git,cache,output,temp}/**',
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
|
||||
|
||||
module.exports = defineFlatConfig({
|
||||
preset: 'web',
|
||||
packageRoot: __dirname,
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@flowgram.ai/fixed-drag-plugin",
|
||||
"version": "0.1.8",
|
||||
"homepage": "https://flowgram.ai/",
|
||||
"repository": "https://github.com/bytedance/flowgram.ai",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/esm/index.js",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/esm/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build:fast -- --dts-resolve",
|
||||
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
|
||||
"build:watch": "npm run build:fast -- --dts-resolve",
|
||||
"clean": "rimraf dist",
|
||||
"test": "exit 0",
|
||||
"test:cov": "exit 0",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@flowgram.ai/core": "workspace:*",
|
||||
"@flowgram.ai/utils": "workspace:*",
|
||||
"@flowgram.ai/document": "workspace:*",
|
||||
"@flowgram.ai/renderer": "workspace:*",
|
||||
"inversify": "^6.0.1",
|
||||
"reflect-metadata": "~0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flowgram.ai/eslint-config": "workspace:*",
|
||||
"@flowgram.ai/ts-config": "workspace:*",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.0.0",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { type Xor } from '@flowgram.ai/utils';
|
||||
import { FlowDragLayer } from '@flowgram.ai/renderer';
|
||||
import { FlowNodeEntity, FlowNodeJSON } from '@flowgram.ai/document';
|
||||
import { definePluginCreator, PluginContext } from '@flowgram.ai/core';
|
||||
|
||||
// import { SelectorBounds } from './selector-bounds';
|
||||
|
||||
export interface FixDragPluginOptions<CTX extends PluginContext = PluginContext> {
|
||||
enable?: boolean;
|
||||
/**
|
||||
* Callback when drag drop
|
||||
*/
|
||||
onDrop?: (ctx: CTX, dropData: { dragNodes: FlowNodeEntity[]; dropNode: FlowNodeEntity }) => void;
|
||||
/**
|
||||
* Check can drop
|
||||
* @param ctx
|
||||
* @param dropData
|
||||
*/
|
||||
canDrop?: (
|
||||
ctx: CTX,
|
||||
dropData: {
|
||||
dropNode: FlowNodeEntity;
|
||||
isBranch?: boolean;
|
||||
} & Xor<
|
||||
{
|
||||
dragNodes: FlowNodeEntity[];
|
||||
},
|
||||
{
|
||||
dragJSON: FlowNodeJSON;
|
||||
}
|
||||
>
|
||||
) => boolean;
|
||||
}
|
||||
|
||||
export const createFixedDragPlugin = definePluginCreator<FixDragPluginOptions<any>>({
|
||||
onInit(ctx, opts): void {
|
||||
// 默认可用,所以强制判断 false
|
||||
if (opts.enable !== false) {
|
||||
ctx.playground.registerLayer(FlowDragLayer, {
|
||||
onDrop: opts.onDrop ? opts.onDrop.bind(null, ctx) : undefined,
|
||||
canDrop: opts.canDrop ? opts.canDrop.bind(null, ctx) : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { FlowDragLayer } from '@flowgram.ai/renderer';
|
||||
import { usePlayground } from '@flowgram.ai/core';
|
||||
|
||||
export function useStartDragNode() {
|
||||
const playground = usePlayground();
|
||||
|
||||
const dragLayer = playground.getLayer(FlowDragLayer);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
startDrag: dragLayer ? dragLayer.startDrag.bind(dragLayer) : (e: any) => {},
|
||||
dragOffset: dragLayer
|
||||
? dragLayer.dragOffset
|
||||
: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
}),
|
||||
[dragLayer]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './create-fixed-drag-plugin';
|
||||
export { useStartDragNode } from './hooks/use-start-drag-node';
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
|
||||
"compilerOptions": {
|
||||
},
|
||||
"include": ["./src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
mockReset: false,
|
||||
environment: 'jsdom',
|
||||
setupFiles: [path.resolve(__dirname, './vitest.setup.ts')],
|
||||
include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'],
|
||||
exclude: [
|
||||
'**/__mocks__**',
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/lib/**', // lib 编译结果忽略掉
|
||||
'**/cypress/**',
|
||||
'**/.{idea,git,cache,output,temp}/**',
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
|
||||
|
||||
module.exports = defineFlatConfig({
|
||||
preset: 'web',
|
||||
packageRoot: __dirname,
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@flowgram.ai/fixed-history-plugin",
|
||||
"version": "0.1.8",
|
||||
"homepage": "https://flowgram.ai/",
|
||||
"repository": "https://github.com/bytedance/flowgram.ai",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/esm/index.js",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/esm/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build:fast -- --dts-resolve",
|
||||
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
|
||||
"build:watch": "npm run build:fast -- --dts-resolve",
|
||||
"clean": "rimraf dist",
|
||||
"test": "exit 0",
|
||||
"test:cov": "exit 0",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@flowgram.ai/core": "workspace:*",
|
||||
"@flowgram.ai/document": "workspace:*",
|
||||
"@flowgram.ai/form-core": "workspace:*",
|
||||
"@flowgram.ai/history": "workspace:*",
|
||||
"@flowgram.ai/utils": "workspace:*",
|
||||
"inversify": "^6.0.1",
|
||||
"reflect-metadata": "~0.2.2",
|
||||
"lodash-es": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flowgram.ai/eslint-config": "workspace:*",
|
||||
"@flowgram.ai/ts-config": "workspace:*",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.0.0",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { interfaces } from 'inversify';
|
||||
import { bindContributions } from '@flowgram.ai/utils';
|
||||
import { HistoryContainerModule, OperationService } from '@flowgram.ai/history';
|
||||
import { OperationContribution } from '@flowgram.ai/history';
|
||||
import { FlowOperationBaseService } from '@flowgram.ai/document';
|
||||
import { FlowDocument } from '@flowgram.ai/document';
|
||||
import { definePluginCreator } from '@flowgram.ai/core';
|
||||
|
||||
import { FixedHistoryPluginOptions } from './types';
|
||||
import { FixedHistoryService } from './services/fixed-history-service';
|
||||
import { FixedHistoryOperationService } from './services/fixed-history-operation-service';
|
||||
import { FixedHistoryFormDataService } from './services';
|
||||
import { FixedHistoryRegisters } from './fixed-history-registers';
|
||||
import { FixedHistoryConfig } from './fixed-history-config';
|
||||
|
||||
export function registerHistory(bind: interfaces.Bind, rebind: interfaces.Rebind) {
|
||||
bindContributions(bind, FixedHistoryRegisters, [OperationContribution]);
|
||||
bind(FixedHistoryService).toSelf().inSingletonScope();
|
||||
bind(FixedHistoryFormDataService).toSelf().inSingletonScope();
|
||||
bind(FixedHistoryConfig).toSelf().inSingletonScope();
|
||||
rebind(FlowOperationBaseService).to(FixedHistoryOperationService).inSingletonScope();
|
||||
}
|
||||
|
||||
export const createFixedHistoryPlugin = definePluginCreator<FixedHistoryPluginOptions<any>>({
|
||||
onBind: ({ bind, rebind }) => {
|
||||
registerHistory(bind, rebind);
|
||||
},
|
||||
onInit(ctx, opts): void {
|
||||
const fixedHistoryService = ctx.get<FixedHistoryService>(FixedHistoryService);
|
||||
fixedHistoryService.setSource(ctx);
|
||||
const document = ctx.get<FlowDocument>(FlowDocument);
|
||||
|
||||
if (opts?.uri) {
|
||||
fixedHistoryService.historyService.context.uri = opts.uri;
|
||||
}
|
||||
|
||||
if (opts?.getDocumentJSON) {
|
||||
fixedHistoryService.historyService.config.getSnapshot = opts.getDocumentJSON(ctx);
|
||||
} else {
|
||||
fixedHistoryService.historyService.config.getSnapshot = () => document.toJSON();
|
||||
}
|
||||
|
||||
const config = fixedHistoryService.config;
|
||||
config.init(ctx, opts);
|
||||
|
||||
if (opts?.operationMetas) {
|
||||
fixedHistoryService.registerOperationMetas(opts.operationMetas);
|
||||
}
|
||||
|
||||
if (opts.onApply) {
|
||||
ctx.get(OperationService).onApply(opts.onApply.bind(null, ctx));
|
||||
}
|
||||
},
|
||||
containerModules: [HistoryContainerModule],
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { injectable } from 'inversify';
|
||||
import { FlowNodeEntity } from '@flowgram.ai/document';
|
||||
import { FlowNodeJSON } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
|
||||
import {
|
||||
FixedHistoryPluginOptions,
|
||||
GetBlockLabel,
|
||||
GetNodeLabel,
|
||||
GetNodeLabelById,
|
||||
GetNodeURI,
|
||||
NodeToJson,
|
||||
} from './types';
|
||||
|
||||
@injectable()
|
||||
export class FixedHistoryConfig {
|
||||
init(ctx: PluginContext, options: FixedHistoryPluginOptions) {
|
||||
if (options.nodeToJSON) {
|
||||
this.nodeToJSON = options.nodeToJSON(ctx);
|
||||
}
|
||||
|
||||
if (options.getNodeLabelById) {
|
||||
this.getNodeLabelById = options.getNodeLabelById(ctx);
|
||||
}
|
||||
|
||||
if (options.getNodeLabel) {
|
||||
this.getNodeLabel = options.getNodeLabel(ctx);
|
||||
}
|
||||
|
||||
if (options.getBlockLabel) {
|
||||
this.getBlockLabel = options.getBlockLabel(ctx);
|
||||
}
|
||||
|
||||
if (options.getNodeURI) {
|
||||
this.getNodeURI = options.getNodeURI(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
nodeToJSON: NodeToJson = (node: FlowNodeEntity) => node.toJSON();
|
||||
|
||||
getNodeLabelById: GetNodeLabelById = (id: string) => id;
|
||||
|
||||
getNodeLabel: GetNodeLabel = (node: FlowNodeJSON) => node.id;
|
||||
|
||||
getBlockLabel: GetBlockLabel = (node: FlowNodeJSON) => node.id;
|
||||
|
||||
getNodeURI: GetNodeURI = (id: string) => `node:${id}`;
|
||||
|
||||
getParentName(parentId?: string) {
|
||||
return parentId ? this.getNodeLabelById(parentId) : 'root';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { injectable } from 'inversify';
|
||||
import { OperationContribution, OperationRegistry } from '@flowgram.ai/history';
|
||||
|
||||
import { operationMetas } from './operation-metas';
|
||||
|
||||
@injectable()
|
||||
export class FixedHistoryRegisters implements OperationContribution {
|
||||
registerOperationMeta(operationRegistry: OperationRegistry): void {
|
||||
operationMetas.forEach(operationMeta => {
|
||||
operationRegistry.registerOperationMeta(operationMeta);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { createFixedHistoryPlugin } from './create-fixed-history-plugin';
|
||||
export * from './types';
|
||||
export * from './services';
|
||||
export * from '@flowgram.ai/history';
|
||||
export * from './fixed-history-config';
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { AddOrDeleteBlockValue, OperationType } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { FixedHistoryConfig } from '../fixed-history-config';
|
||||
import { baseOperationMeta } from './base';
|
||||
|
||||
export const addBlockOperationMeta: OperationMeta<AddOrDeleteBlockValue, PluginContext, void> = {
|
||||
...baseOperationMeta,
|
||||
type: OperationType.addBlock,
|
||||
inverse: op => ({ ...op, type: OperationType.deleteBlock }),
|
||||
getLabel: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
return `Create ${config.getBlockLabel(value.blockData)}`;
|
||||
},
|
||||
getDescription: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
const branchName = config.getBlockLabel(value.blockData);
|
||||
const targetName = config.getNodeLabelById(value.targetId);
|
||||
const position = typeof value.index !== 'undefined' ? `position ${value.index}` : 'the end';
|
||||
return `Create branch ${branchName} in ${targetName} at ${position}`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FlowNodeEntity, OperationType } from '@flowgram.ai/document';
|
||||
import { AddOrDeleteChildNodeValue } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { FixedHistoryConfig } from '../fixed-history-config';
|
||||
import { baseOperationMeta } from './base';
|
||||
|
||||
export const addChildNodeOperationMeta: OperationMeta<
|
||||
AddOrDeleteChildNodeValue,
|
||||
PluginContext,
|
||||
FlowNodeEntity
|
||||
> = {
|
||||
...baseOperationMeta,
|
||||
type: OperationType.addChildNode,
|
||||
inverse: op => ({ ...op, type: OperationType.deleteChildNode }),
|
||||
getLabel: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
return `Create ${config.getNodeLabel(value.data)}`;
|
||||
},
|
||||
getDescription: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
const nodeName = config.getNodeLabel(value.data);
|
||||
const parentName = config.getParentName(value.parentId);
|
||||
const position = typeof value.index !== 'undefined' ? `position ${value.index}` : 'the end';
|
||||
return `Create ${value.data.type} node ${nodeName} in ${parentName} at ${position}`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import {
|
||||
type AddOrDeleteFromNodeOperationValue,
|
||||
type FlowNodeEntity,
|
||||
OperationType,
|
||||
} from '@flowgram.ai/document';
|
||||
import { type PluginContext } from '@flowgram.ai/core';
|
||||
import { type OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { FixedHistoryConfig } from '../fixed-history-config';
|
||||
import { baseOperationMeta } from './base';
|
||||
|
||||
export const addFromNodeOperationMeta: OperationMeta<
|
||||
AddOrDeleteFromNodeOperationValue,
|
||||
PluginContext,
|
||||
FlowNodeEntity
|
||||
> = {
|
||||
...baseOperationMeta,
|
||||
type: OperationType.addFromNode,
|
||||
inverse: op => ({ ...op, type: OperationType.deleteFromNode }),
|
||||
getLabel: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const { value } = op;
|
||||
return `Create ${config.getNodeLabel(value.data)}`;
|
||||
},
|
||||
getDescription: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const { value } = op;
|
||||
const nodeName = config.getNodeLabel(value.data);
|
||||
const fromName = config.getNodeLabelById(value.fromId);
|
||||
return `Create ${value.data.type} node ${nodeName} after ${fromName}`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FlowNodeEntity, FlowOperationBaseService, OperationType } from '@flowgram.ai/document';
|
||||
import { AddOrDeleteNodeValue } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { FixedHistoryConfig } from '../fixed-history-config';
|
||||
|
||||
export const addNodeOperationMeta: OperationMeta<
|
||||
AddOrDeleteNodeValue,
|
||||
PluginContext,
|
||||
FlowNodeEntity
|
||||
> = {
|
||||
type: OperationType.addNode,
|
||||
inverse: op => ({ ...op, type: OperationType.deleteNode }),
|
||||
apply: ({ value: { data, parentId, index, hidden } }, ctx) =>
|
||||
ctx.get<FlowOperationBaseService>(FlowOperationBaseService).addNode(data, {
|
||||
parent: parentId,
|
||||
index,
|
||||
hidden,
|
||||
}),
|
||||
getLabel: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
return `Create ${config.getNodeLabel(value.data)}`;
|
||||
},
|
||||
getDescription: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
const nodeName = config.getNodeLabel(value.data);
|
||||
const parentName = config.getParentName(value.parentId);
|
||||
const position = typeof value.index !== 'undefined' ? `position ${value.index}` : 'the end';
|
||||
return `Create ${value.data.type} node ${nodeName} in ${parentName} at ${position}`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { AddOrDeleteNodesOperationValue, OperationType } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { FixedHistoryConfig } from '../fixed-history-config';
|
||||
import { baseOperationMeta } from './base';
|
||||
|
||||
export const addNodesOperationMeta: OperationMeta<
|
||||
AddOrDeleteNodesOperationValue,
|
||||
PluginContext,
|
||||
void
|
||||
> = {
|
||||
...baseOperationMeta,
|
||||
type: OperationType.addNodes,
|
||||
inverse: op => ({
|
||||
...op,
|
||||
type: OperationType.deleteNodes,
|
||||
}),
|
||||
getLabel: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
return `${value.nodes.map(node => `Create ${config.getNodeLabel(node)}`).join(';')}`;
|
||||
},
|
||||
getDescription: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
const fromName = config.getNodeLabelById(value.fromId);
|
||||
return `${value.nodes
|
||||
.map(node => `Create ${node.type} node ${config.getNodeLabel(node)} after ${fromName}`)
|
||||
.join(';')}`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FlowOperation, FlowOperationBaseService } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { FixedHistoryOperationService } from '../services';
|
||||
|
||||
export const baseOperationMeta: Pick<OperationMeta, 'apply'> = {
|
||||
apply: (operation, ctx: PluginContext) => {
|
||||
const fixedHistoryOperationService = ctx.get(
|
||||
FlowOperationBaseService,
|
||||
) as FixedHistoryOperationService;
|
||||
|
||||
return fixedHistoryOperationService.originApply(operation as FlowOperation);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { createOrUngroupValue, OperationType } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { baseOperationMeta } from './base';
|
||||
|
||||
export const createGroupOperationMeta: OperationMeta<createOrUngroupValue, PluginContext, void> = {
|
||||
...baseOperationMeta,
|
||||
type: OperationType.createGroup,
|
||||
inverse: op => ({ ...op, type: OperationType.ungroup }),
|
||||
getLabel: (op, ctx) => {
|
||||
const value = op.value;
|
||||
return `Create group ${value.groupId} from ${value.targetId}`;
|
||||
},
|
||||
getDescription: (op, ctx) => {
|
||||
const value = op.value;
|
||||
return `Create group with nodes ${value.nodeIds.join(', ')}`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { AddOrDeleteBlockValue, OperationType } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { FixedHistoryConfig } from '../fixed-history-config';
|
||||
import { baseOperationMeta } from './base';
|
||||
|
||||
export const deleteBlockOperationMeta: OperationMeta<AddOrDeleteBlockValue, PluginContext, void> = {
|
||||
...baseOperationMeta,
|
||||
type: OperationType.deleteBlock,
|
||||
inverse: op => ({ ...op, type: OperationType.addBlock }),
|
||||
getLabel: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
return `Delete ${config.getBlockLabel(value.blockData)}`;
|
||||
},
|
||||
getDescription: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
const branchName = config.getBlockLabel(value.blockData);
|
||||
const targetName = config.getNodeLabelById(value.targetId);
|
||||
const position = typeof value.index !== 'undefined' ? `position ${value.index}` : 'the end';
|
||||
return `Delete branch ${branchName} in ${targetName} at ${position}`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FlowNodeEntity, OperationType } from '@flowgram.ai/document';
|
||||
import { AddOrDeleteChildNodeValue } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { FixedHistoryConfig } from '../fixed-history-config';
|
||||
import { baseOperationMeta } from './base';
|
||||
|
||||
export const deleteChildNodeOperationMeta: OperationMeta<
|
||||
AddOrDeleteChildNodeValue,
|
||||
PluginContext,
|
||||
FlowNodeEntity
|
||||
> = {
|
||||
...baseOperationMeta,
|
||||
type: OperationType.deleteChildNode,
|
||||
inverse: op => ({ ...op, type: OperationType.addChildNode }),
|
||||
getLabel: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
return `Delete ${config.getNodeLabel(value.data)}`;
|
||||
},
|
||||
getDescription: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
const nodeName = config.getNodeLabel(value.data);
|
||||
const parentName = config.getParentName(value.parentId);
|
||||
const position = typeof value.index !== 'undefined' ? `position ${value.index}` : 'the end';
|
||||
return `Delete ${value.data.type} node ${nodeName} in ${parentName} at ${position}`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { AddOrDeleteFromNodeOperationValue, OperationType } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { FixedHistoryConfig } from '../fixed-history-config';
|
||||
import { baseOperationMeta } from './base';
|
||||
|
||||
export const deleteFromNodeOperationMeta: OperationMeta<
|
||||
AddOrDeleteFromNodeOperationValue,
|
||||
PluginContext,
|
||||
void
|
||||
> = {
|
||||
...baseOperationMeta,
|
||||
type: OperationType.deleteFromNode,
|
||||
inverse: op => ({ ...op, type: OperationType.addFromNode }),
|
||||
getLabel: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
return `Delete ${config.getNodeLabel(value.data)}`;
|
||||
},
|
||||
getDescription: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
const nodeName = config.getNodeLabel(value.data);
|
||||
const parentName = config.getNodeLabelById(value.fromId);
|
||||
return `Delete ${value.data.type} node ${nodeName} after ${parentName}`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FlowOperationBaseService, OperationType } from '@flowgram.ai/document';
|
||||
import { AddOrDeleteNodeValue } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { FixedHistoryConfig } from '../fixed-history-config';
|
||||
import { baseOperationMeta } from './base';
|
||||
|
||||
export const deleteNodeOperationMeta: OperationMeta<AddOrDeleteNodeValue, PluginContext, void> = {
|
||||
...baseOperationMeta,
|
||||
type: OperationType.deleteNode,
|
||||
inverse: op => ({ ...op, type: OperationType.addNode }),
|
||||
apply: ({ value: { data } }, ctx) =>
|
||||
ctx.get<FlowOperationBaseService>(FlowOperationBaseService).deleteNode(data.id),
|
||||
getLabel: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
return `Create ${config.getNodeLabel(value.data)}`;
|
||||
},
|
||||
getDescription: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
const nodeName = config.getNodeLabel(value.data);
|
||||
const parentName = config.getParentName(value.parentId);
|
||||
const position = typeof value.index !== 'undefined' ? `position ${value.index}` : 'the end';
|
||||
return `Delete ${value.data.type} node ${nodeName} in ${parentName} at ${position}`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { AddOrDeleteNodesOperationValue, OperationType } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { FixedHistoryConfig } from '../fixed-history-config';
|
||||
import { baseOperationMeta } from './base';
|
||||
|
||||
export const deleteNodesOperationMeta: OperationMeta<
|
||||
AddOrDeleteNodesOperationValue,
|
||||
PluginContext,
|
||||
void
|
||||
> = {
|
||||
...baseOperationMeta,
|
||||
type: OperationType.deleteNodes,
|
||||
inverse: op => ({
|
||||
...op,
|
||||
type: OperationType.addNodes,
|
||||
}),
|
||||
getLabel: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
return value.nodes.map(node => `Delete ${config.getNodeLabel(node)}`).join(';');
|
||||
},
|
||||
getDescription: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
const fromName = config.getNodeLabelById(value.fromId);
|
||||
return value.nodes
|
||||
.map(node => `Delete ${node.type} node ${config.getNodeLabel(node)} after ${fromName}`)
|
||||
.join(';');
|
||||
},
|
||||
shouldMerge: (op, prev, stackItem) => {
|
||||
if (!prev) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
// 合并500ms内的操作, 如分组内最后一个节点会联动删除分组
|
||||
Date.now() - stackItem.getTimestamp() <
|
||||
500
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './add-from-node';
|
||||
import { ungroupOperationMeta } from './ungroup';
|
||||
import { moveNodesOperationMeta } from './move-nodes';
|
||||
import { moveChildNodesOperationMeta } from './move-child-nodes';
|
||||
import { moveBlockOperationMeta } from './move-block';
|
||||
import { deleteNodesOperationMeta } from './delete-nodes';
|
||||
import { deleteNodeOperationMeta } from './delete-node';
|
||||
import { deleteFromNodeOperationMeta } from './delete-from-node';
|
||||
import { deleteChildNodeOperationMeta } from './delete-child-node';
|
||||
import { deleteBlockOperationMeta } from './delete-block';
|
||||
import { createGroupOperationMeta } from './create-group';
|
||||
import { addNodesOperationMeta } from './add-nodes';
|
||||
import { addNodeOperationMeta } from './add-node';
|
||||
import { addFromNodeOperationMeta } from './add-from-node';
|
||||
import { addChildNodeOperationMeta } from './add-child-node';
|
||||
import { addBlockOperationMeta } from './add-block';
|
||||
|
||||
export const operationMetas = [
|
||||
deleteFromNodeOperationMeta,
|
||||
addFromNodeOperationMeta,
|
||||
addBlockOperationMeta,
|
||||
deleteBlockOperationMeta,
|
||||
createGroupOperationMeta,
|
||||
ungroupOperationMeta,
|
||||
moveNodesOperationMeta,
|
||||
deleteNodesOperationMeta,
|
||||
addNodesOperationMeta,
|
||||
moveBlockOperationMeta,
|
||||
addChildNodeOperationMeta,
|
||||
deleteChildNodeOperationMeta,
|
||||
moveChildNodesOperationMeta,
|
||||
addNodeOperationMeta,
|
||||
deleteNodeOperationMeta,
|
||||
];
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { MoveBlockOperationValue, OperationType } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { FixedHistoryConfig } from '../fixed-history-config';
|
||||
import { baseOperationMeta } from './base';
|
||||
|
||||
export const moveBlockOperationMeta: OperationMeta<MoveBlockOperationValue, PluginContext, void> = {
|
||||
...baseOperationMeta,
|
||||
type: OperationType.moveBlock,
|
||||
inverse: op => ({
|
||||
...op,
|
||||
value: {
|
||||
...op.value,
|
||||
fromIndex: op.value.toIndex,
|
||||
toIndex: op.value.fromIndex,
|
||||
fromParentId: op.value.toParentId,
|
||||
toParentId: op.value.fromParentId,
|
||||
},
|
||||
}),
|
||||
getLabel: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
return `Move ${config.getNodeLabelById(value.nodeId)}`;
|
||||
},
|
||||
getDescription: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
const position = typeof value.toIndex !== 'undefined' ? `position ${value.toIndex}` : 'the end';
|
||||
return `Move branch ${config.getNodeLabelById(value.nodeId)} to ${position}`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { MoveChildNodesOperationValue, OperationType } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { FixedHistoryConfig } from '../fixed-history-config';
|
||||
import { baseOperationMeta } from './base';
|
||||
|
||||
export const moveChildNodesOperationMeta: OperationMeta<
|
||||
MoveChildNodesOperationValue,
|
||||
PluginContext,
|
||||
void
|
||||
> = {
|
||||
...baseOperationMeta,
|
||||
type: OperationType.moveChildNodes,
|
||||
inverse: op => ({
|
||||
...op,
|
||||
value: {
|
||||
...op.value,
|
||||
fromIndex: op.value.toIndex,
|
||||
toIndex: op.value.fromIndex,
|
||||
fromParentId: op.value.toParentId,
|
||||
toParentId: op.value.fromParentId,
|
||||
},
|
||||
}),
|
||||
getLabel: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
return `Move ${value.nodeIds.map(id => config.getNodeLabelById(id)).join(',')}`;
|
||||
},
|
||||
getDescription: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
const position = typeof value.toIndex !== 'undefined' ? `position ${value.toIndex}` : 'the end';
|
||||
return `Move nodes ${value.nodeIds
|
||||
.map(id => config.getNodeLabelById(id))
|
||||
.join(',')} to ${position}`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { MoveNodesOperationValue, OperationType } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { FixedHistoryConfig } from '../fixed-history-config';
|
||||
import { baseOperationMeta } from './base';
|
||||
|
||||
export const moveNodesOperationMeta: OperationMeta<MoveNodesOperationValue, PluginContext, void> = {
|
||||
...baseOperationMeta,
|
||||
type: OperationType.moveNodes,
|
||||
inverse: op => ({
|
||||
...op,
|
||||
value: {
|
||||
...op.value,
|
||||
fromId: op.value.toId,
|
||||
toId: op.value.fromId,
|
||||
},
|
||||
}),
|
||||
getLabel: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
return `${value.nodeIds.map(id => `Move ${config.getNodeLabelById(id)}`).join(';')}`;
|
||||
},
|
||||
getDescription: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const value = op.value;
|
||||
return `${value.nodeIds
|
||||
.map(id => `Move ${config.getNodeLabelById(id)} to ${config.getNodeLabelById(value.toId)}`)
|
||||
.join(';')}`;
|
||||
},
|
||||
getURI: (op, ctx) => {
|
||||
const config = ctx.get<FixedHistoryConfig>(FixedHistoryConfig);
|
||||
const nodeIds = op.value.nodeIds;
|
||||
if (nodeIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
return config.getNodeURI(nodeIds[0]);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { createOrUngroupValue, OperationType } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
|
||||
import { baseOperationMeta } from './base';
|
||||
|
||||
export const ungroupOperationMeta: OperationMeta<createOrUngroupValue, PluginContext, void> = {
|
||||
...baseOperationMeta,
|
||||
type: OperationType.ungroup,
|
||||
inverse: op => ({ ...op, type: OperationType.createGroup }),
|
||||
getLabel: (op, ctx) => {
|
||||
const value = op.value;
|
||||
return `Ungroup ${value.groupId}`;
|
||||
},
|
||||
getDescription: (op, ctx) => {
|
||||
const value = op.value;
|
||||
return `Ungroup with nodes ${value.nodeIds.join(', ')}`;
|
||||
},
|
||||
};
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { Disposable, Emitter } from '@flowgram.ai/utils';
|
||||
import { FlowNodeFormData, FormModel } from '@flowgram.ai/form-core';
|
||||
import { FlowDocument } from '@flowgram.ai/document';
|
||||
|
||||
@injectable()
|
||||
export class FixedHistoryFormDataService implements Disposable {
|
||||
@inject(FlowDocument) document: FlowDocument;
|
||||
|
||||
private _cache = new Map<FlowNodeFormData, Map<string, any>>();
|
||||
|
||||
private _formValueChangeByHistoryEmitter = new Emitter<{
|
||||
formData: FlowNodeFormData;
|
||||
value: any;
|
||||
path: string;
|
||||
}>();
|
||||
|
||||
onFormValueChangeByHistory = this._formValueChangeByHistoryEmitter.event;
|
||||
|
||||
resetCache(flowNodeFormData: FlowNodeFormData, value: any) {
|
||||
Object.keys(value).forEach((key) => {
|
||||
this.setCache(flowNodeFormData, key, value[key]);
|
||||
});
|
||||
}
|
||||
|
||||
setCache(flowNodeFormData: FlowNodeFormData, prop: string, value: any) {
|
||||
if (!this._cache.has(flowNodeFormData)) {
|
||||
this._cache.set(flowNodeFormData, new Map());
|
||||
}
|
||||
|
||||
const formData = this._cache.get(flowNodeFormData)!;
|
||||
formData.set(prop, cloneDeep(value));
|
||||
}
|
||||
|
||||
getCache(flowNodeFormData: FlowNodeFormData, prop: string): any {
|
||||
if (!this._cache.has(flowNodeFormData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = this._cache.get(flowNodeFormData)!;
|
||||
return formData.get(prop);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表单数据
|
||||
* @param id node id
|
||||
* @returns 表单数据
|
||||
*/
|
||||
getFormDataByNodeId(id: string) {
|
||||
const node = this.document.getNode(id);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const formData = node.getData<FlowNodeFormData>(FlowNodeFormData);
|
||||
return formData;
|
||||
}
|
||||
|
||||
getFormItemValue(formData: FlowNodeFormData, path: string) {
|
||||
const formItem = this.getFormItem(formData, path);
|
||||
|
||||
if (!formItem) {
|
||||
return;
|
||||
}
|
||||
return formItem.value;
|
||||
}
|
||||
|
||||
setFormItemValue(formData: FlowNodeFormData, path: string, value: any) {
|
||||
const formItem = this.getFormItem(formData, path);
|
||||
|
||||
if (formItem) {
|
||||
formItem.value = value;
|
||||
this._formValueChangeByHistoryEmitter.fire({
|
||||
formData,
|
||||
path,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getFormItem(formData: FlowNodeFormData, path: string) {
|
||||
if (typeof path === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (path.endsWith('/')) {
|
||||
path = path.slice(0, -1);
|
||||
}
|
||||
|
||||
if (!path.startsWith('/')) {
|
||||
path = '/' + path;
|
||||
}
|
||||
|
||||
const formItem = formData.getFormModel<FormModel>().getFormItemByPath(path);
|
||||
|
||||
return formItem;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._formValueChangeByHistoryEmitter.dispose();
|
||||
this._cache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { FlowOperation, FlowOperationBaseServiceImpl } from '@flowgram.ai/document';
|
||||
import { HistoryService } from '@flowgram.ai/history';
|
||||
|
||||
@injectable()
|
||||
export class FixedHistoryOperationService extends FlowOperationBaseServiceImpl {
|
||||
@inject(HistoryService) historyService: HistoryService;
|
||||
|
||||
apply(operation: FlowOperation): any {
|
||||
return this.historyService.pushOperation(operation);
|
||||
}
|
||||
|
||||
originApply(operation: FlowOperation): any {
|
||||
return super.apply(operation);
|
||||
}
|
||||
|
||||
transact(transaction: () => void): void {
|
||||
this.historyService.transact(transaction);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { injectable, inject } from 'inversify';
|
||||
import { HistoryService, Operation } from '@flowgram.ai/history';
|
||||
import { OperationRegistry } from '@flowgram.ai/history';
|
||||
import { OperationMeta } from '@flowgram.ai/history';
|
||||
import {
|
||||
AddOrDeleteBlockValue,
|
||||
AddOrDeleteChildNodeValue,
|
||||
AddOrDeleteFromNodeOperationValue,
|
||||
FlowNodeEntity,
|
||||
FlowNodeJSON,
|
||||
OperationType,
|
||||
} from '@flowgram.ai/document';
|
||||
import { FlowDocument } from '@flowgram.ai/document';
|
||||
import { FlowOperationBaseService } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
|
||||
import { IHistoryDocument } from '../types';
|
||||
import { FixedHistoryConfig } from '../fixed-history-config';
|
||||
import { FixedHistoryOperationService } from './fixed-history-operation-service';
|
||||
|
||||
@injectable()
|
||||
export class FixedHistoryService implements IHistoryDocument {
|
||||
@inject(HistoryService) historyService: HistoryService;
|
||||
|
||||
@inject(OperationRegistry) operationRegistry: OperationRegistry;
|
||||
|
||||
@inject(FlowOperationBaseService)
|
||||
fixedHistoryOperationService: FixedHistoryOperationService;
|
||||
|
||||
@inject(FlowDocument) document: FlowDocument;
|
||||
|
||||
@inject(FixedHistoryConfig) config: FixedHistoryConfig;
|
||||
|
||||
setSource(source: PluginContext) {
|
||||
this.historyService.context.source = source;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册操作
|
||||
* @param operationMetas
|
||||
*/
|
||||
public registerOperationMetas(operationMetas: OperationMeta[]) {
|
||||
operationMetas.forEach((operationMeta) => {
|
||||
this.operationRegistry.registerOperationMeta(operationMeta);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 事务
|
||||
* @param transaction
|
||||
*/
|
||||
public transact(transaction: () => void) {
|
||||
this.historyService.transact(transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销
|
||||
*/
|
||||
async undo() {
|
||||
await this.historyService.undo();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重做
|
||||
*/
|
||||
async redo() {
|
||||
await this.historyService.redo();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否可重做
|
||||
*/
|
||||
canRedo() {
|
||||
return this.historyService.canRedo();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否可撤销
|
||||
*/
|
||||
canUndo() {
|
||||
return this.historyService.canUndo();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一个操作
|
||||
* @param operation
|
||||
*/
|
||||
pushHistoryOperation(operation: Operation) {
|
||||
return this.historyService.pushOperation(operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史操作
|
||||
*/
|
||||
getHistoryOperations() {
|
||||
return this.historyService.getHistoryOperations();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加节点
|
||||
* @param value 添加节点配置
|
||||
* @returns
|
||||
* @deprecated 请使用 `FlowOperationService.addFromNode` 代替
|
||||
*/
|
||||
addFromNode(fromNode: FlowNodeEntity | string, json: FlowNodeJSON): FlowNodeEntity {
|
||||
const value: AddOrDeleteFromNodeOperationValue = {
|
||||
fromId: typeof fromNode === 'string' ? fromNode : fromNode.id,
|
||||
data: json,
|
||||
};
|
||||
return this.historyService.pushOperation({
|
||||
type: OperationType.addFromNode,
|
||||
value,
|
||||
uri: this.config.getNodeURI(json.id),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除节点
|
||||
* @param node 节点
|
||||
* @returns
|
||||
* @deprecated 请使用 `FlowOperationService.deleteNode` 代替
|
||||
*/
|
||||
deleteNode(node: FlowNodeEntity): void {
|
||||
const { originParent, parent } = node;
|
||||
const uri = this.config.getNodeURI(node.id);
|
||||
let nodeJSON = this.config.nodeToJSON(node);
|
||||
|
||||
// 非数据节点
|
||||
if (!nodeJSON) {
|
||||
nodeJSON = {
|
||||
id: node.id,
|
||||
type: node.flowNodeType,
|
||||
};
|
||||
}
|
||||
|
||||
if (parent) {
|
||||
const index = parent.children.findIndex((child) => child === node);
|
||||
|
||||
if (originParent) {
|
||||
// 分支节点
|
||||
let parentId: string | undefined = originParent.id;
|
||||
if (!parentId) {
|
||||
console.warn('no parent found');
|
||||
return;
|
||||
}
|
||||
|
||||
const value: AddOrDeleteBlockValue = {
|
||||
targetId: parentId,
|
||||
blockData: nodeJSON,
|
||||
};
|
||||
|
||||
if (index >= 0) {
|
||||
value.index = index;
|
||||
}
|
||||
|
||||
return this.historyService.pushOperation({
|
||||
type: OperationType.deleteBlock,
|
||||
value,
|
||||
uri,
|
||||
});
|
||||
} else {
|
||||
// Reactor节点
|
||||
const value: AddOrDeleteChildNodeValue = {
|
||||
data: nodeJSON,
|
||||
parentId: parent.id,
|
||||
};
|
||||
|
||||
if (index >= 0) {
|
||||
value.index = index;
|
||||
}
|
||||
|
||||
return this.historyService.pushOperation({
|
||||
type: OperationType.deleteChildNode,
|
||||
value,
|
||||
uri,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 普通节点
|
||||
if (!node.pre) {
|
||||
console.warn('no pre found');
|
||||
return;
|
||||
}
|
||||
|
||||
return this.historyService.pushOperation({
|
||||
type: OperationType.deleteFromNode,
|
||||
value: {
|
||||
fromId: node.pre.id,
|
||||
data: nodeJSON,
|
||||
uri,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加子节点
|
||||
* @param data
|
||||
* @param parent
|
||||
* @param index
|
||||
* @param originParent
|
||||
* @returns
|
||||
* @deprecated 请使用 `FlowOperationService.addNode` 代替
|
||||
*/
|
||||
addChildNode(
|
||||
data: FlowNodeJSON,
|
||||
parent?: FlowNodeEntity,
|
||||
index?: number,
|
||||
originParent?: FlowNodeEntity
|
||||
) {
|
||||
const value: AddOrDeleteChildNodeValue = {
|
||||
data,
|
||||
parentId: parent?.id,
|
||||
originParentId: originParent?.id,
|
||||
index,
|
||||
};
|
||||
|
||||
return this.historyService.pushOperation({
|
||||
type: OperationType.addChildNode,
|
||||
value: value,
|
||||
uri: this.config.getNodeURI(data.id),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除
|
||||
* @param nodes
|
||||
* @deprecated 请使用 `FlowOperationService.deleteNodes` 代替
|
||||
*/
|
||||
deleteNodes(nodes: FlowNodeEntity[]) {
|
||||
if (nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.historyService.transact(() => {
|
||||
nodes.reverse().forEach((node) => {
|
||||
this.deleteNode(node);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加
|
||||
* @param from
|
||||
* @param nodes
|
||||
*/
|
||||
addFromNodes(from: FlowNodeEntity, nodes: FlowNodeEntity[]) {
|
||||
if (nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
return this.historyService.pushOperation({
|
||||
type: OperationType.addNodes,
|
||||
value: {
|
||||
fromId: from.id,
|
||||
nodes: nodes.map((node) => this.config.nodeToJSON(node)),
|
||||
uri: this.config.getNodeURI(nodes[0].id),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加块级元素
|
||||
* @param target 目标
|
||||
* @param blockData 块数据
|
||||
* @param parent 父级
|
||||
* @returns
|
||||
* @deprecated 请使用 `FlowOperationService.addBlock` 代替
|
||||
*/
|
||||
addBlock(
|
||||
target: string | FlowNodeEntity,
|
||||
blockData: FlowNodeJSON,
|
||||
parent?: FlowNodeEntity | undefined,
|
||||
index?: number
|
||||
): FlowNodeEntity {
|
||||
const value: AddOrDeleteBlockValue = {
|
||||
targetId: typeof target === 'string' ? target : target.id,
|
||||
blockData,
|
||||
index,
|
||||
};
|
||||
|
||||
if (parent) {
|
||||
value.parentId = parent.id;
|
||||
}
|
||||
|
||||
return this.historyService.pushOperation({
|
||||
type: OperationType.addBlock,
|
||||
value,
|
||||
uri: this.config.getNodeURI(value.blockData.id),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动节点
|
||||
* @param node 被移动的节点
|
||||
* @param toNode 被放置的节点
|
||||
* @returns
|
||||
*/
|
||||
moveNode(node: FlowNodeEntity, toNode: FlowNodeEntity) {
|
||||
return this.fixedHistoryOperationService.dragNodes({
|
||||
dropNode: toNode,
|
||||
nodes: [node],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { FixedHistoryOperationService } from './fixed-history-operation-service';
|
||||
export { FixedHistoryService } from './fixed-history-service';
|
||||
export { FixedHistoryFormDataService } from './fixed-history-form-data-service';
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { HistoryPluginOptions, OperationMeta } from '@flowgram.ai/history';
|
||||
import { FlowNodeEntity, FlowNodeJSON } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
|
||||
export interface IHistoryDocument {
|
||||
addFromNode(fromNode: FlowNodeEntity | string, json: FlowNodeJSON): FlowNodeEntity;
|
||||
addBlock(
|
||||
target: FlowNodeEntity | string,
|
||||
blockData: FlowNodeJSON,
|
||||
parent?: FlowNodeEntity,
|
||||
index?: number
|
||||
): FlowNodeEntity;
|
||||
deleteNode(fromNode: FlowNodeEntity): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将node转成json
|
||||
*/
|
||||
export type NodeToJson = (node: FlowNodeEntity) => FlowNodeJSON;
|
||||
/**
|
||||
* 根据节点id获取label
|
||||
*/
|
||||
export type GetNodeLabelById = (id: string) => string;
|
||||
/**
|
||||
* 根据节点获取label
|
||||
*/
|
||||
export type GetNodeLabel = (node: FlowNodeJSON) => string;
|
||||
/**
|
||||
* 根据分支获取label
|
||||
*/
|
||||
export type GetBlockLabel = (node: FlowNodeJSON) => string;
|
||||
/**
|
||||
* 根据节点获取URI
|
||||
*/
|
||||
export type GetNodeURI = (id: string) => string | any;
|
||||
/**
|
||||
* 获取文档JSON
|
||||
*/
|
||||
export type GetDocumentJSON = () => unknown;
|
||||
|
||||
/**
|
||||
* 插件配置
|
||||
*/
|
||||
export interface FixedHistoryPluginOptions<CTX extends PluginContext = PluginContext>
|
||||
extends HistoryPluginOptions<CTX> {
|
||||
nodeToJSON?: (ctx: CTX) => NodeToJson;
|
||||
getDocumentJSON?: (ctx: CTX) => GetDocumentJSON;
|
||||
getNodeLabelById?: (ctx: CTX) => GetNodeLabelById;
|
||||
getNodeLabel?: (ctx: CTX) => GetNodeLabel;
|
||||
getBlockLabel?: (ctx: CTX) => GetBlockLabel;
|
||||
getNodeURI?: (ctx: CTX) => GetNodeURI;
|
||||
operationMetas?: OperationMeta[];
|
||||
uri?: string | any;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
|
||||
"compilerOptions": {
|
||||
},
|
||||
"include": ["./src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
mockReset: false,
|
||||
environment: 'jsdom',
|
||||
setupFiles: [path.resolve(__dirname, './vitest.setup.ts')],
|
||||
include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'],
|
||||
exclude: [
|
||||
'**/__mocks__**',
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/lib/**', // lib 编译结果忽略掉
|
||||
'**/cypress/**',
|
||||
'**/.{idea,git,cache,output,temp}/**',
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
|
||||
|
||||
module.exports = defineFlatConfig({
|
||||
preset: 'web',
|
||||
packageRoot: __dirname,
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@flowgram.ai/free-auto-layout-plugin",
|
||||
"version": "0.1.8",
|
||||
"homepage": "https://flowgram.ai/",
|
||||
"repository": "https://github.com/bytedance/flowgram.ai",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/esm/index.js",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/esm/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build:fast -- --dts-resolve",
|
||||
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
|
||||
"build:watch": "npm run build:fast -- --dts-resolve",
|
||||
"clean": "rimraf dist",
|
||||
"test": "exit 0",
|
||||
"test:cov": "exit 0",
|
||||
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/graphlib": "2.2.2",
|
||||
"@flowgram.ai/core": "workspace:*",
|
||||
"@flowgram.ai/document": "workspace:*",
|
||||
"@flowgram.ai/free-layout-core": "workspace:*",
|
||||
"@flowgram.ai/utils": "workspace:*",
|
||||
"inversify": "^6.0.1",
|
||||
"reflect-metadata": "~0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flowgram.ai/eslint-config": "workspace:*",
|
||||
"@flowgram.ai/ts-config": "workspace:*",
|
||||
"@types/bezier-js": "4.1.3",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/styled-components": "^5",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.0.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"styled-components": "^5",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8",
|
||||
"styled-components": ">=5"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { definePluginCreator } from '@flowgram.ai/core';
|
||||
|
||||
import { AutoLayoutOptions } from './type';
|
||||
import { AutoLayoutService } from './services';
|
||||
|
||||
/**
|
||||
* Auto layout plugin - 自动布局插件
|
||||
* https://flowgram.ai/guide/plugin/free-auto-layout-plugin.html
|
||||
*/
|
||||
export const createFreeAutoLayoutPlugin = definePluginCreator<AutoLayoutOptions>({
|
||||
onBind: ({ bind }) => {
|
||||
bind(AutoLayoutService).toSelf().inSingletonScope();
|
||||
},
|
||||
onInit: (ctx, opts) => {
|
||||
ctx.get(AutoLayoutService).init(opts);
|
||||
},
|
||||
singleton: true,
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { LayoutGraph } from './graph';
|
||||
|
||||
/**
|
||||
* DFS去环算法
|
||||
* @param graph 布局图实例
|
||||
* @returns 反馈弧集(需要反转的边的ID数组)
|
||||
*/
|
||||
const dfsFAS = (graph: LayoutGraph): string[] => {
|
||||
const visited: { [key: string]: boolean } = {};
|
||||
const stack: { [key: string]: boolean } = {};
|
||||
const fas: string[] = [];
|
||||
|
||||
/**
|
||||
* DFS遍历
|
||||
* @param nodeId 当前节点ID
|
||||
*/
|
||||
const dfs = (nodeId: string): void => {
|
||||
visited[nodeId] = true;
|
||||
stack[nodeId] = true;
|
||||
|
||||
const outEdges = graph.edges.filter((edge) => edge.from === nodeId);
|
||||
outEdges.forEach((edge) => {
|
||||
if (!visited[edge.to]) {
|
||||
dfs(edge.to);
|
||||
} else if (stack[edge.to]) {
|
||||
// 发现环,将该边添加到反馈弧集
|
||||
fas.push(edge.id);
|
||||
}
|
||||
});
|
||||
|
||||
stack[nodeId] = false;
|
||||
};
|
||||
|
||||
// 对每个未访问的节点进行DFS
|
||||
graph.nodes.forEach((node) => {
|
||||
if (!visited[node.id]) {
|
||||
dfs(node.id);
|
||||
}
|
||||
});
|
||||
|
||||
return fas;
|
||||
};
|
||||
|
||||
/**
|
||||
* 贪心去环算法
|
||||
* @param graph 布局图实例
|
||||
* @returns 反馈弧集(需要反转的边的ID数组)
|
||||
*/
|
||||
const greedyFAS = (graph: LayoutGraph): string[] => {
|
||||
const fas: string[] = [];
|
||||
const nodeOrder: string[] = [];
|
||||
|
||||
// 计算节点的入度和出度
|
||||
const inDegree: { [key: string]: number } = {};
|
||||
const outDegree: { [key: string]: number } = {};
|
||||
|
||||
graph.nodes.forEach((node) => {
|
||||
inDegree[node.id] = 0;
|
||||
outDegree[node.id] = 0;
|
||||
});
|
||||
|
||||
graph.edges.forEach((edge) => {
|
||||
inDegree[edge.to]++;
|
||||
outDegree[edge.from]++;
|
||||
});
|
||||
|
||||
// 贪心选择节点
|
||||
while (nodeOrder.length < graph.nodes.length) {
|
||||
let maxDiff = -Infinity;
|
||||
let bestNode: string | null = null;
|
||||
|
||||
graph.nodes.forEach((node) => {
|
||||
if (!nodeOrder.includes(node.id)) {
|
||||
const diff = outDegree[node.id] - inDegree[node.id];
|
||||
if (diff > maxDiff) {
|
||||
maxDiff = diff;
|
||||
bestNode = node.id;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (bestNode) {
|
||||
nodeOrder.push(bestNode);
|
||||
// 更新相邻节点的入度和出度
|
||||
graph.edges.forEach((edge) => {
|
||||
if (edge.from === bestNode) {
|
||||
inDegree[edge.to]--;
|
||||
}
|
||||
if (edge.to === bestNode) {
|
||||
outDegree[edge.from]--;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 根据节点顺序确定需要反转的边
|
||||
graph.edges.forEach((edge) => {
|
||||
if (nodeOrder.indexOf(edge.from) > nodeOrder.indexOf(edge.to)) {
|
||||
fas.push(edge.id);
|
||||
}
|
||||
});
|
||||
|
||||
return fas;
|
||||
};
|
||||
|
||||
/**
|
||||
* 去环
|
||||
*/
|
||||
export const acyclic = (graph: LayoutGraph, acyclicer: 'dfs' | 'greedy' = 'dfs'): LayoutGraph => {
|
||||
// 使用DFS或贪心算法获取反馈弧集
|
||||
const fas = acyclicer === 'dfs' ? dfsFAS(graph) : greedyFAS(graph);
|
||||
|
||||
// 反转反馈弧集中的边
|
||||
fas.forEach((edgeId) => {
|
||||
const edge = graph.edges.find((e) => e.id === edgeId);
|
||||
if (edge) {
|
||||
const { from, to } = edge;
|
||||
graph.removeEdge(edgeId);
|
||||
graph.addLayoutEdge({ id: edgeId, from: to, to: from });
|
||||
}
|
||||
});
|
||||
|
||||
return graph;
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import type { WorkflowLineEntity, WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
|
||||
import { TransformData } from '@flowgram.ai/core';
|
||||
|
||||
import type { ILayoutGraph, LayoutEdge, LayoutNode } from './type';
|
||||
|
||||
export class LayoutGraph implements ILayoutGraph {
|
||||
public readonly store: {
|
||||
nodes: Map<string, LayoutNode>;
|
||||
edges: Map<string, LayoutEdge>;
|
||||
} = {
|
||||
nodes: new Map(),
|
||||
edges: new Map(),
|
||||
};
|
||||
|
||||
public get nodes(): LayoutNode[] {
|
||||
return Array.from(this.store.nodes.values());
|
||||
}
|
||||
|
||||
public get edges(): LayoutEdge[] {
|
||||
return Array.from(this.store.edges.values());
|
||||
}
|
||||
|
||||
public getNode(id: string): LayoutNode | undefined {
|
||||
return this.store.nodes.get(id);
|
||||
}
|
||||
|
||||
public hasNode(id: string): boolean {
|
||||
return this.store.nodes.has(id);
|
||||
}
|
||||
|
||||
public addNode(nodeEntity: WorkflowNodeEntity): LayoutNode {
|
||||
const transform = nodeEntity.getData(TransformData);
|
||||
const layoutNode: LayoutNode = {
|
||||
id: nodeEntity.id,
|
||||
node: nodeEntity,
|
||||
rank: -1,
|
||||
order: -1,
|
||||
position: { x: transform.position.x, y: transform.position.y },
|
||||
size: { width: transform.bounds.width, height: transform.bounds.height },
|
||||
};
|
||||
this.store.nodes.set(layoutNode.id, layoutNode);
|
||||
return layoutNode;
|
||||
}
|
||||
|
||||
public addLayoutNode(layoutNode: LayoutNode): void {
|
||||
this.store.nodes.set(layoutNode.id, layoutNode);
|
||||
}
|
||||
|
||||
public addEdge(edgeEntity: WorkflowLineEntity): LayoutEdge {
|
||||
const layoutEdge: LayoutEdge = {
|
||||
id: edgeEntity.id,
|
||||
from: edgeEntity.from.id,
|
||||
to: edgeEntity.to!.id,
|
||||
};
|
||||
this.store.edges.set(layoutEdge.id, layoutEdge);
|
||||
return layoutEdge;
|
||||
}
|
||||
|
||||
public addLayoutEdge(layoutEdge: LayoutEdge): void {
|
||||
this.store.edges.set(layoutEdge.id, layoutEdge);
|
||||
}
|
||||
|
||||
public removeEdge(id: string): void {
|
||||
this.store.edges.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './acyclic';
|
||||
export * from './rank';
|
||||
export * from './order';
|
||||
export * from './layout';
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import {
|
||||
WorkflowLineEntity,
|
||||
WorkflowNodeEntity,
|
||||
WorkflowNodeLinesData,
|
||||
} from '@flowgram.ai/free-layout-core';
|
||||
import { TransformData } from '@flowgram.ai/core';
|
||||
|
||||
import { LayoutNode } from './type';
|
||||
import { LayoutGraph } from './graph';
|
||||
import { acyclic, feasibleTree, longestPath, networkSimplex, normalizeRanks, order } from './';
|
||||
|
||||
/**
|
||||
* 布局算法
|
||||
* 参考 dagre.js 的实现 https://github.com/dagrejs/dagre
|
||||
*/
|
||||
export namespace DagreLayout {
|
||||
const getNextEdges = (node: WorkflowNodeEntity): WorkflowLineEntity[] => {
|
||||
const linesData = node.getData<WorkflowNodeLinesData>(WorkflowNodeLinesData);
|
||||
return linesData.outputLines.filter((line) => line.from && line.to);
|
||||
};
|
||||
|
||||
const getPrevEdges = (node: WorkflowNodeEntity): WorkflowLineEntity[] => {
|
||||
const linesData = node.getData<WorkflowNodeLinesData>(WorkflowNodeLinesData);
|
||||
return linesData.inputLines.filter((line) => line.from && line.to);
|
||||
};
|
||||
|
||||
/** 添加节点 */
|
||||
const createData = (params: {
|
||||
node: WorkflowNodeEntity;
|
||||
depth: number;
|
||||
graph: LayoutGraph;
|
||||
}): LayoutGraph => {
|
||||
const { node, depth, graph } = params;
|
||||
if (graph.hasNode(node.id)) {
|
||||
return graph;
|
||||
}
|
||||
graph.addNode(node);
|
||||
const prevEdges = getPrevEdges(node);
|
||||
const nextEdges = getNextEdges(node);
|
||||
prevEdges.forEach((prevEdge) => {
|
||||
graph.addEdge(prevEdge);
|
||||
createData({ node: prevEdge.from, depth: depth - 1, graph });
|
||||
});
|
||||
nextEdges.forEach((nextEdge) => {
|
||||
graph.addEdge(nextEdge);
|
||||
createData({ node: nextEdge.to!, depth: depth + 1, graph });
|
||||
});
|
||||
return graph;
|
||||
};
|
||||
|
||||
// 定义一些常量
|
||||
const NODE_SPACING = 100; // 同层级节点之间的垂直间距
|
||||
const RANK_SPACING = 100; // 层级之间的水平间距
|
||||
|
||||
/** 计算图中所有节点的坐标 */
|
||||
const calcCoordinates = (graph: LayoutGraph): LayoutGraph => {
|
||||
// 按rank对节点进行分组
|
||||
const rankGroups = groupNodesByRank(graph.nodes);
|
||||
|
||||
// 计算每个rank的最大高度
|
||||
const rankHeights = calculateRankHeights(rankGroups);
|
||||
|
||||
// 计算每个节点的坐标
|
||||
let currentX = 0;
|
||||
rankGroups.forEach((nodesInRank, rank) => {
|
||||
const rankHeight = rankHeights[rank];
|
||||
|
||||
nodesInRank.forEach((node) => {
|
||||
// 计算X坐标
|
||||
node.position.x = currentX + node.size.width / 2;
|
||||
|
||||
// 计算Y坐标
|
||||
const totalHeightOfRank = nodesInRank.reduce((sum, n) => sum + n.size.height, 0);
|
||||
const totalSpacing = (nodesInRank.length - 1) * NODE_SPACING;
|
||||
const startY = (rankHeight - totalHeightOfRank - totalSpacing) / 2;
|
||||
|
||||
let currentY = startY;
|
||||
for (let i = 0; i < node.order; i++) {
|
||||
currentY += nodesInRank[i].size.height + NODE_SPACING;
|
||||
}
|
||||
node.position.y = currentY + node.size.height / 2;
|
||||
});
|
||||
|
||||
// 更新X坐标为下一个rank的起始位置
|
||||
currentX += rankHeight + RANK_SPACING;
|
||||
});
|
||||
return graph;
|
||||
};
|
||||
|
||||
/** 按rank对节点进行分组 */
|
||||
const groupNodesByRank = (nodes: LayoutNode[]): LayoutNode[][] => {
|
||||
const groups: LayoutNode[][] = [];
|
||||
nodes.forEach((node) => {
|
||||
if (!groups[node.rank]) {
|
||||
groups[node.rank] = [];
|
||||
}
|
||||
groups[node.rank].push(node);
|
||||
});
|
||||
return groups;
|
||||
};
|
||||
|
||||
/** 计算每个rank的最大高度 */
|
||||
const calculateRankHeights = (rankGroups: LayoutNode[][]): number[] =>
|
||||
rankGroups.map((nodesInRank) => Math.max(...nodesInRank.map((node) => node.size.width)));
|
||||
|
||||
const positioning = (graph: LayoutGraph): LayoutGraph => {
|
||||
graph.nodes.forEach((node) => {
|
||||
const transform = node.node.getData(TransformData);
|
||||
transform.update({
|
||||
position: node.position,
|
||||
});
|
||||
});
|
||||
return graph;
|
||||
};
|
||||
|
||||
const rank = (
|
||||
graph: LayoutGraph,
|
||||
ranker: 'longest-path' | 'network-simplex' | 'tight-tree' = 'network-simplex'
|
||||
): LayoutGraph => {
|
||||
if (ranker === 'longest-path') {
|
||||
longestPath(graph);
|
||||
} else if (ranker === 'network-simplex') {
|
||||
networkSimplex(graph);
|
||||
} else if (ranker === 'tight-tree') {
|
||||
feasibleTree(graph);
|
||||
}
|
||||
return graph;
|
||||
};
|
||||
|
||||
export const applyLayout = (graph: LayoutGraph): void => {
|
||||
acyclic(graph); // 去环
|
||||
rank(graph); // 分层
|
||||
normalizeRanks(graph); // 归一化 rank 值
|
||||
order(graph); // 重心法对同层级节点进行排序
|
||||
calcCoordinates(graph); // 分配坐标
|
||||
positioning(graph); // 应用布局
|
||||
};
|
||||
|
||||
/** 创建布局图 */
|
||||
export const createGraph = (node: WorkflowNodeEntity): LayoutGraph => {
|
||||
const graph = new LayoutGraph();
|
||||
createData({ node, depth: 0, graph });
|
||||
applyLayout(graph);
|
||||
return graph;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { LayoutGraph } from './graph';
|
||||
|
||||
// 辅助函数:获取图中的最大rank
|
||||
const getMaxRank = (graph: LayoutGraph): number =>
|
||||
Math.max(...graph.nodes.map((node) => node.rank));
|
||||
|
||||
// 辅助函数:根据rank构建层级图
|
||||
const buildLayerGraph = (
|
||||
graph: LayoutGraph,
|
||||
rank: number,
|
||||
edgeType: 'inEdges' | 'outEdges'
|
||||
): LayoutGraph => {
|
||||
const layerGraph = new LayoutGraph();
|
||||
|
||||
graph.nodes
|
||||
.filter((node) => node.rank === rank)
|
||||
.forEach((node) => {
|
||||
layerGraph.addLayoutNode(node);
|
||||
});
|
||||
|
||||
graph.edges.forEach((edge) => {
|
||||
const sourceNode = graph.getNode(edge.from);
|
||||
const targetNode = graph.getNode(edge.to);
|
||||
if (!sourceNode || !targetNode) return;
|
||||
|
||||
if (edgeType === 'inEdges' && targetNode.rank === rank) {
|
||||
layerGraph.addLayoutEdge(edge);
|
||||
} else if (edgeType === 'outEdges' && sourceNode.rank === rank) {
|
||||
layerGraph.addLayoutEdge(edge);
|
||||
}
|
||||
});
|
||||
|
||||
return layerGraph;
|
||||
};
|
||||
|
||||
// 辅助函数:初始化order
|
||||
const initOrder = (graph: LayoutGraph): { [key: number]: string[] } => {
|
||||
const layering: { [key: number]: string[] } = {};
|
||||
graph.nodes.forEach((node) => {
|
||||
if (!layering[node.rank]) {
|
||||
layering[node.rank] = [];
|
||||
}
|
||||
layering[node.rank].push(node.id);
|
||||
});
|
||||
return layering;
|
||||
};
|
||||
|
||||
// 辅助函数:分配order
|
||||
const assignOrder = (graph: LayoutGraph, layering: { [key: number]: string[] }): void => {
|
||||
Object.entries(layering).forEach(([rank, layer]) => {
|
||||
layer.forEach((nodeId, index) => {
|
||||
const node = graph.getNode(nodeId);
|
||||
if (node) {
|
||||
node.order = index;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 辅助函数:计算交叉数
|
||||
const crossCount = (graph: LayoutGraph, layering: { [key: number]: string[] }): number => {
|
||||
let cc = 0;
|
||||
const layers = Object.values(layering);
|
||||
|
||||
for (let i = 1; i < layers.length; i++) {
|
||||
const northLayer = layers[i - 1];
|
||||
const southLayer = layers[i];
|
||||
|
||||
for (let j = 0; j < northLayer.length; j++) {
|
||||
for (let k = j + 1; k < northLayer.length; k++) {
|
||||
const v = graph.getNode(northLayer[j]);
|
||||
const w = graph.getNode(northLayer[k]);
|
||||
if (!v || !w) continue;
|
||||
|
||||
// 获取v和w的南向邻居
|
||||
const vNeighbors = graph.edges
|
||||
.filter((e) => e.from === v.id)
|
||||
.map((e) => graph.getNode(e.to));
|
||||
const wNeighbors = graph.edges
|
||||
.filter((e) => e.from === w.id)
|
||||
.map((e) => graph.getNode(e.to));
|
||||
|
||||
for (const vNeighbor of vNeighbors) {
|
||||
for (const wNeighbor of wNeighbors) {
|
||||
if (!vNeighbor || !wNeighbor) continue;
|
||||
if (southLayer.indexOf(vNeighbor.id) > southLayer.indexOf(wNeighbor.id)) {
|
||||
cc++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cc;
|
||||
};
|
||||
|
||||
// 辅助函数:构建复合图
|
||||
const buildCompoundGraph = (): LayoutGraph => new LayoutGraph();
|
||||
|
||||
// 辅助函数:添加子图约束
|
||||
const addSubgraphConstraints = (layerGraph: LayoutGraph, cg: LayoutGraph, vs: string[]): void => {
|
||||
const prev: { [key: string]: string } = {};
|
||||
let root = layerGraph.nodes[0]?.id;
|
||||
vs.forEach((v) => {
|
||||
let prevV = prev[root];
|
||||
if (prevV) {
|
||||
cg.addLayoutEdge({ id: `${prevV}-${v}`, from: prevV, to: v, weight: 0 });
|
||||
}
|
||||
prev[root] = v;
|
||||
});
|
||||
};
|
||||
|
||||
// 辅助函数:对子图进行排序
|
||||
const sortSubgraph = (
|
||||
layerGraph: LayoutGraph,
|
||||
root: string,
|
||||
cg: LayoutGraph,
|
||||
biasRight: boolean
|
||||
): { vs: string[] } => {
|
||||
const vs: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
const nodeData = new Map<string, { barycenter: number; weight: number }>();
|
||||
|
||||
const dfs = (v: string) => {
|
||||
if (visited.has(v)) return;
|
||||
visited.add(v);
|
||||
|
||||
let barycenter = 0;
|
||||
let weight = 0;
|
||||
|
||||
const node = layerGraph.getNode(v);
|
||||
if (node) {
|
||||
const edges = biasRight
|
||||
? layerGraph.edges.filter((e) => e.to === v)
|
||||
: layerGraph.edges.filter((e) => e.from === v);
|
||||
|
||||
edges.forEach((edge) => {
|
||||
const w = biasRight ? edge.from : edge.to;
|
||||
const otherNode = layerGraph.getNode(w);
|
||||
if (otherNode) {
|
||||
const edgeWeight = edge.weight || 1;
|
||||
weight += edgeWeight;
|
||||
barycenter += (otherNode.order || 0) * edgeWeight;
|
||||
}
|
||||
});
|
||||
|
||||
if (weight > 0) {
|
||||
barycenter /= weight;
|
||||
}
|
||||
}
|
||||
|
||||
nodeData.set(v, { barycenter, weight });
|
||||
vs.push(v);
|
||||
|
||||
const neighbors = layerGraph.edges
|
||||
.filter((e) => e.from === v || e.to === v)
|
||||
.map((e) => (e.from === v ? e.to : e.from));
|
||||
neighbors.sort((a, b) => {
|
||||
const nodeA = layerGraph.getNode(a);
|
||||
const nodeB = layerGraph.getNode(b);
|
||||
return (nodeA?.order || 0) - (nodeB?.order || 0);
|
||||
});
|
||||
neighbors.forEach(dfs);
|
||||
};
|
||||
|
||||
dfs(root);
|
||||
|
||||
// 根据重心值和权重排序
|
||||
vs.sort((a, b) => {
|
||||
const aData = nodeData.get(a);
|
||||
const bData = nodeData.get(b);
|
||||
if (aData && bData) {
|
||||
if (Math.abs(aData.barycenter - bData.barycenter) < 0.001) {
|
||||
return bData.weight - aData.weight;
|
||||
}
|
||||
return aData.barycenter - bData.barycenter;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return { vs };
|
||||
};
|
||||
|
||||
// 新增:局部搜索优化
|
||||
const localSearch = (graph: LayoutGraph, layering: { [key: number]: string[] }): void => {
|
||||
const ranks = Object.keys(layering).map(Number);
|
||||
ranks.forEach((rank) => {
|
||||
const layer = layering[rank];
|
||||
for (let i = 0; i < layer.length - 1; i++) {
|
||||
for (let j = i + 1; j < layer.length; j++) {
|
||||
const currentCC = crossCount(graph, layering);
|
||||
// 交换两个节点的位置
|
||||
[layer[i], layer[j]] = [layer[j], layer[i]];
|
||||
const newCC = crossCount(graph, layering);
|
||||
// 如果交叉数增加,则恢复交换
|
||||
if (newCC > currentCC) {
|
||||
[layer[i], layer[j]] = [layer[j], layer[i]];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 优化:sweepLayerGraphs 函数
|
||||
const sweepLayerGraphs = (layerGraphs: LayoutGraph[], biasRight: boolean): void => {
|
||||
const cg = buildCompoundGraph();
|
||||
layerGraphs.forEach((lg) => {
|
||||
const root = lg.nodes[0]?.id;
|
||||
if (root) {
|
||||
const sorted = sortSubgraph(lg, root, cg, biasRight);
|
||||
sorted.vs.forEach((v, i) => {
|
||||
const node = lg.getNode(v);
|
||||
if (node) {
|
||||
node.order = i;
|
||||
}
|
||||
});
|
||||
addSubgraphConstraints(lg, cg, sorted.vs);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 更新主函数 order
|
||||
export const order = (graph: LayoutGraph): LayoutGraph => {
|
||||
const maxRank = getMaxRank(graph);
|
||||
const downLayerGraphs = Array.from({ length: maxRank + 1 }, (_, i) =>
|
||||
buildLayerGraph(graph, i, 'inEdges')
|
||||
);
|
||||
const upLayerGraphs = Array.from({ length: maxRank + 1 }, (_, i) =>
|
||||
buildLayerGraph(graph, maxRank - i, 'outEdges')
|
||||
);
|
||||
|
||||
let layering = initOrder(graph);
|
||||
assignOrder(graph, layering);
|
||||
|
||||
let bestCC = Number.POSITIVE_INFINITY;
|
||||
let bestLayering = layering;
|
||||
|
||||
// 增加迭代次数
|
||||
for (let i = 0, lastBest = 0; lastBest < 8; ++i, ++lastBest) {
|
||||
sweepLayerGraphs(i % 2 ? downLayerGraphs : upLayerGraphs, i % 4 >= 2);
|
||||
|
||||
layering = initOrder(graph);
|
||||
localSearch(graph, layering); // 应用局部搜索
|
||||
const cc = crossCount(graph, layering);
|
||||
if (cc < bestCC) {
|
||||
lastBest = 0;
|
||||
bestLayering = JSON.parse(JSON.stringify(layering));
|
||||
bestCC = cc;
|
||||
}
|
||||
}
|
||||
|
||||
assignOrder(graph, bestLayering);
|
||||
|
||||
return graph;
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { LayoutEdge } from '../type';
|
||||
import { LayoutGraph } from '../graph';
|
||||
|
||||
/**
|
||||
* 计算边的松弛度
|
||||
*/
|
||||
const calculateSlack = (graph: LayoutGraph, edge: LayoutEdge): number => {
|
||||
const sourceNode = graph.getNode(edge.from);
|
||||
const targetNode = graph.getNode(edge.to);
|
||||
if (!sourceNode || !targetNode) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
return targetNode.rank - sourceNode.rank - (edge.minlen || 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* 深度优先搜索构建紧致树
|
||||
*/
|
||||
const dfs = (graph: LayoutGraph, tightTree: LayoutGraph, nodeId: string): void => {
|
||||
const edges = graph.edges.filter((e) => e.from === nodeId || e.to === nodeId);
|
||||
edges.forEach((edge) => {
|
||||
const neighborId = edge.from === nodeId ? edge.to : edge.from;
|
||||
if (!tightTree.hasNode(neighborId) && calculateSlack(graph, edge) === 0) {
|
||||
const neighborNode = graph.getNode(neighborId);
|
||||
if (neighborNode) {
|
||||
tightTree.addLayoutNode({ ...neighborNode });
|
||||
tightTree.addLayoutEdge({ ...edge });
|
||||
dfs(graph, tightTree, neighborId);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建最大紧致树
|
||||
*/
|
||||
const buildTightTree = (graph: LayoutGraph, tightTree: LayoutGraph): number => {
|
||||
tightTree.nodes.forEach((node) => dfs(graph, tightTree, node.id));
|
||||
return tightTree.nodes.length;
|
||||
};
|
||||
|
||||
/**
|
||||
* 找到具有最小松弛度的边
|
||||
*/
|
||||
const findMinSlackEdge = (graph: LayoutGraph, tightTree: LayoutGraph): LayoutEdge | undefined => {
|
||||
let minSlack = Number.POSITIVE_INFINITY;
|
||||
let minSlackEdge: LayoutEdge | undefined;
|
||||
|
||||
graph.edges.forEach((edge) => {
|
||||
const hasSource = tightTree.hasNode(edge.from);
|
||||
const hasTarget = tightTree.hasNode(edge.to);
|
||||
if (hasSource !== hasTarget) {
|
||||
const slack = calculateSlack(graph, edge);
|
||||
if (slack < minSlack) {
|
||||
minSlack = slack;
|
||||
minSlackEdge = edge;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return minSlackEdge;
|
||||
};
|
||||
|
||||
/**
|
||||
* 调整rank值
|
||||
*/
|
||||
const shiftRanks = (graph: LayoutGraph, tightTree: LayoutGraph, delta: number): void => {
|
||||
tightTree.nodes.forEach((node) => {
|
||||
const graphNode = graph.getNode(node.id);
|
||||
if (graphNode) {
|
||||
graphNode.rank += delta;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建紧致生成树
|
||||
*/
|
||||
export const feasibleTree = (graph: LayoutGraph): LayoutGraph => {
|
||||
const tightTree = new LayoutGraph();
|
||||
|
||||
// 选择任意节点作为起始节点
|
||||
const startNode = graph.nodes[0];
|
||||
tightTree.addLayoutNode({ ...startNode });
|
||||
|
||||
while (buildTightTree(graph, tightTree) < graph.nodes.length) {
|
||||
const minSlackEdge = findMinSlackEdge(graph, tightTree);
|
||||
if (minSlackEdge) {
|
||||
const delta = tightTree.hasNode(minSlackEdge.from)
|
||||
? calculateSlack(graph, minSlackEdge)
|
||||
: -calculateSlack(graph, minSlackEdge);
|
||||
shiftRanks(graph, tightTree, delta);
|
||||
}
|
||||
}
|
||||
|
||||
return tightTree;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { feasibleTree } from './feasible-tree';
|
||||
export { longestPath } from './longest-path';
|
||||
export { networkSimplex } from './network-simplex';
|
||||
export { normalizeRanks } from './normalize-ranks';
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { LayoutGraph } from '../graph';
|
||||
|
||||
/**
|
||||
* 计算图中节点的最长路径
|
||||
*/
|
||||
export const longestPath = (graph: LayoutGraph): LayoutGraph => {
|
||||
// 用于记录已访问的节点
|
||||
const visited: Record<string, boolean> = {};
|
||||
|
||||
/**
|
||||
* 深度优先搜索计算节点的层级
|
||||
* @param nodeId 当前节点ID
|
||||
* @returns 计算得到的层级
|
||||
*/
|
||||
const dfs = (nodeId: string): number => {
|
||||
const node = graph.getNode(nodeId);
|
||||
|
||||
// 如果节点不存在,返回 -1
|
||||
if (!node) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 如果节点已访问且已经计算过rank,直接返回其rank
|
||||
if (visited[nodeId] && node.rank !== -1) {
|
||||
return node.rank;
|
||||
}
|
||||
|
||||
// 标记节点为已访问
|
||||
visited[nodeId] = true;
|
||||
|
||||
// 获取所有以当前节点为起点的边
|
||||
const outgoingEdges = graph.edges.filter((edge) => edge.from === nodeId);
|
||||
|
||||
// 如果没有出边,说明是叶子节点,rank为0
|
||||
if (outgoingEdges.length === 0) {
|
||||
node.rank = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 计算所有子节点的最大rank
|
||||
let maxChildRank = -1;
|
||||
outgoingEdges.forEach((edge) => {
|
||||
const childRank = dfs(edge.to);
|
||||
const minlen = edge.minlen || 1; // 使用默认最小长度1,如果未指定
|
||||
maxChildRank = Math.max(maxChildRank, childRank + minlen);
|
||||
});
|
||||
|
||||
// 当前节点的rank为子节点最大rank + 1
|
||||
node.rank = maxChildRank;
|
||||
return node.rank;
|
||||
};
|
||||
|
||||
// 从每个没有入边的节点(源节点)开始DFS
|
||||
const sourceNodes = graph.nodes.filter(
|
||||
(node) => !graph.edges.some((edge) => edge.to === node.id)
|
||||
);
|
||||
|
||||
sourceNodes.forEach((node) => dfs(node.id));
|
||||
|
||||
// 确保所有节点都被访问到
|
||||
graph.nodes.forEach((node) => {
|
||||
if (node.rank === -1) {
|
||||
dfs(node.id);
|
||||
}
|
||||
});
|
||||
|
||||
// 反转rank值,使得源节点的rank最小
|
||||
const maxRank = Math.max(...graph.nodes.map((node) => node.rank));
|
||||
graph.nodes.forEach((node) => {
|
||||
node.rank = maxRank - node.rank;
|
||||
});
|
||||
|
||||
return graph;
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { LayoutEdge, LayoutNode } from '../type';
|
||||
import { LayoutGraph } from '../graph';
|
||||
import { longestPath } from './longest-path';
|
||||
import { feasibleTree } from './feasible-tree';
|
||||
|
||||
/**
|
||||
* 网络单纯形
|
||||
* 参考 https://github.com/dagrejs/dagre/blob/master/lib/rank/network-simplex.js
|
||||
*/
|
||||
export const networkSimplex = (g: LayoutGraph): LayoutGraph => {
|
||||
longestPath(g); // 初始化层级
|
||||
const t = feasibleTree(g); // 构建紧致生成树
|
||||
initLowLimValues(t);
|
||||
initCutValues(t, g);
|
||||
|
||||
let e: LayoutEdge | undefined;
|
||||
while ((e = leaveEdge(t))) {
|
||||
const f = enterEdge(t, g, e);
|
||||
if (f) {
|
||||
exchangeEdges(t, g, e, f);
|
||||
}
|
||||
}
|
||||
return g;
|
||||
};
|
||||
|
||||
const initLowLimValues = (t: LayoutGraph): void => {
|
||||
const root = t.nodes[0];
|
||||
if (root) {
|
||||
dfsAssignLowLim(t, new Map<string, boolean>(), { nextLim: 1 }, root.id);
|
||||
}
|
||||
};
|
||||
|
||||
const dfsAssignLowLim = (
|
||||
t: LayoutGraph,
|
||||
visited: Map<string, boolean>,
|
||||
state: { nextLim: number },
|
||||
v: string,
|
||||
parent?: string
|
||||
): void => {
|
||||
const node = t.getNode(v);
|
||||
if (!node) return;
|
||||
|
||||
const low = state.nextLim;
|
||||
visited.set(v, true);
|
||||
|
||||
t.edges
|
||||
.filter((e) => e.from === v || e.to === v)
|
||||
.forEach((e) => {
|
||||
const w = e.from === v ? e.to : e.from;
|
||||
if (!visited.get(w)) {
|
||||
dfsAssignLowLim(t, visited, state, w, v);
|
||||
}
|
||||
});
|
||||
|
||||
node.low = low;
|
||||
node.lim = state.nextLim++;
|
||||
if (parent) {
|
||||
node.parent = parent;
|
||||
} else {
|
||||
delete node.parent;
|
||||
}
|
||||
};
|
||||
|
||||
const initCutValues = (t: LayoutGraph, g: LayoutGraph): void => {
|
||||
const vs = postorder(t);
|
||||
vs.slice(0, -1).forEach((v) => assignCutValue(t, g, v));
|
||||
};
|
||||
|
||||
const postorder = (t: LayoutGraph): string[] => {
|
||||
const visited = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
||||
const dfs = (nodeId: string): void => {
|
||||
const node = t.getNode(nodeId);
|
||||
if (!node || visited.has(nodeId)) return;
|
||||
|
||||
visited.add(nodeId);
|
||||
|
||||
t.edges
|
||||
.filter((e) => e.from === nodeId || e.to === nodeId)
|
||||
.forEach((e) => {
|
||||
const neighborId = e.from === nodeId ? e.to : e.from;
|
||||
dfs(neighborId);
|
||||
});
|
||||
|
||||
result.push(nodeId);
|
||||
};
|
||||
|
||||
t.nodes.forEach((node) => dfs(node.id));
|
||||
return result;
|
||||
};
|
||||
|
||||
const assignCutValue = (t: LayoutGraph, g: LayoutGraph, childId: string): void => {
|
||||
const child = t.getNode(childId);
|
||||
if (!child || !child.parent) return;
|
||||
|
||||
const edge = t.edges.find((e) => e.from === childId && e.to === child.parent);
|
||||
if (edge) {
|
||||
edge.cutvalue = calcCutValue(t, g, childId);
|
||||
}
|
||||
};
|
||||
|
||||
const calcCutValue = (t: LayoutGraph, g: LayoutGraph, childId: string): number => {
|
||||
const child = t.getNode(childId);
|
||||
if (!child || !child.parent) return 0;
|
||||
|
||||
let cutValue = 0;
|
||||
const graphEdge = g.edges.find(
|
||||
(e) =>
|
||||
(e.from === childId && e.to === child.parent) || (e.from === child.parent && e.to === childId)
|
||||
);
|
||||
|
||||
if (graphEdge) {
|
||||
cutValue = graphEdge.weight || 0;
|
||||
}
|
||||
|
||||
g.edges
|
||||
.filter((e) => e.from === childId || e.to === childId)
|
||||
.forEach((e) => {
|
||||
const otherId = e.from === childId ? e.to : e.from;
|
||||
if (otherId !== child.parent) {
|
||||
const otherWeight = e.weight || 0;
|
||||
cutValue += e.from === childId ? otherWeight : -otherWeight;
|
||||
if (isTreeEdge(t, childId, otherId)) {
|
||||
const treeEdge = t.edges.find(
|
||||
(te) =>
|
||||
(te.from === childId && te.to === otherId) ||
|
||||
(te.from === otherId && te.to === childId)
|
||||
);
|
||||
if (treeEdge && treeEdge.cutvalue !== undefined) {
|
||||
cutValue += e.from === childId ? -treeEdge.cutvalue : treeEdge.cutvalue;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return cutValue;
|
||||
};
|
||||
|
||||
const isTreeEdge = (t: LayoutGraph, u: string, v: string): boolean =>
|
||||
t.edges.some((e) => (e.from === u && e.to === v) || (e.from === v && e.to === u));
|
||||
|
||||
const leaveEdge = (t: LayoutGraph): LayoutEdge | undefined =>
|
||||
t.edges.find((e) => (e.cutvalue || 0) < 0);
|
||||
|
||||
const enterEdge = (t: LayoutGraph, g: LayoutGraph, edge: LayoutEdge): LayoutEdge | undefined => {
|
||||
const vLabel = t.getNode(edge.from);
|
||||
const wLabel = t.getNode(edge.to);
|
||||
if (!vLabel || !wLabel) return undefined;
|
||||
|
||||
const tailLabel = vLabel.lim! > wLabel.lim! ? wLabel : vLabel;
|
||||
const flip = tailLabel === wLabel;
|
||||
|
||||
const candidates = g.edges.filter((e) => {
|
||||
const vNode = t.getNode(e.from);
|
||||
const wNode = t.getNode(e.to);
|
||||
return (
|
||||
vNode &&
|
||||
wNode &&
|
||||
flip === isDescendant(t, vNode, tailLabel) &&
|
||||
flip !== isDescendant(t, wNode, tailLabel)
|
||||
);
|
||||
});
|
||||
|
||||
return candidates.reduce((acc, e) => {
|
||||
if (slack(g, e) < slack(g, acc)) {
|
||||
return e;
|
||||
}
|
||||
return acc;
|
||||
});
|
||||
};
|
||||
|
||||
const isDescendant = (t: LayoutGraph, vLabel: LayoutNode, rootLabel: LayoutNode): boolean =>
|
||||
(rootLabel.low || 0) <= (vLabel.lim || 0) && (vLabel.lim || 0) <= (rootLabel.lim || 0);
|
||||
|
||||
const slack = (g: LayoutGraph, edge: LayoutEdge): number => {
|
||||
const source = g.getNode(edge.from);
|
||||
const target = g.getNode(edge.to);
|
||||
if (!source || !target) return Number.POSITIVE_INFINITY;
|
||||
return Math.abs(target.rank - source.rank) - (edge.minlen || 1);
|
||||
};
|
||||
|
||||
const exchangeEdges = (t: LayoutGraph, g: LayoutGraph, e: LayoutEdge, f: LayoutEdge): void => {
|
||||
t.removeEdge(e.id);
|
||||
t.addLayoutEdge(f);
|
||||
initLowLimValues(t);
|
||||
initCutValues(t, g);
|
||||
updateRanks(t, g);
|
||||
};
|
||||
|
||||
const updateRanks = (t: LayoutGraph, g: LayoutGraph): void => {
|
||||
const root = t.nodes.find((v) => !v.parent);
|
||||
if (!root) return;
|
||||
|
||||
const vs = preorder(t, root.id);
|
||||
vs.slice(1).forEach((v) => {
|
||||
const node = t.getNode(v);
|
||||
const parent = t.getNode(node?.parent || '');
|
||||
if (!node || !parent) return;
|
||||
|
||||
const edge = g.edges.find(
|
||||
(e) => (e.from === v && e.to === node.parent) || (e.from === node.parent && e.to === v)
|
||||
);
|
||||
if (!edge) return;
|
||||
|
||||
const flipped = edge.from === node.parent;
|
||||
node.rank = parent.rank + (flipped ? edge.minlen || 1 : -(edge.minlen || 1));
|
||||
});
|
||||
};
|
||||
|
||||
const preorder = (t: LayoutGraph, root: string): string[] => {
|
||||
const result: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
const dfs = (nodeId: string): void => {
|
||||
if (visited.has(nodeId)) return;
|
||||
visited.add(nodeId);
|
||||
result.push(nodeId);
|
||||
|
||||
t.edges
|
||||
.filter((e) => e.from === nodeId || e.to === nodeId)
|
||||
.forEach((e) => {
|
||||
const neighborId = e.from === nodeId ? e.to : e.from;
|
||||
dfs(neighborId);
|
||||
});
|
||||
};
|
||||
|
||||
dfs(root);
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { LayoutGraph } from '../graph';
|
||||
|
||||
export const normalizeRanks = (graph: LayoutGraph): LayoutGraph => {
|
||||
// 获取所有节点的 rank 值
|
||||
const nodeRanks: number[] = graph.nodes.map((node) => {
|
||||
const rank: number = node.rank;
|
||||
return rank === -1 ? Number.MAX_VALUE : rank;
|
||||
});
|
||||
|
||||
// 找出最小的 rank 值
|
||||
const minRank: number = Math.min(...nodeRanks);
|
||||
|
||||
// 调整所有节点的 rank 值
|
||||
graph.nodes.forEach((node) => {
|
||||
if (node.rank !== -1) {
|
||||
node.rank -= minRank;
|
||||
}
|
||||
});
|
||||
|
||||
return graph;
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import type { WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
|
||||
|
||||
export interface LayoutNode {
|
||||
id: string;
|
||||
node: WorkflowNodeEntity;
|
||||
/** 层级 */
|
||||
rank: number;
|
||||
/** 同层级索引 */
|
||||
order: number;
|
||||
/** 位置 */
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
/** 宽高 */
|
||||
size: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
low?: number;
|
||||
lim?: number;
|
||||
parent?: string;
|
||||
}
|
||||
|
||||
export interface LayoutEdge {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
|
||||
cutvalue?: number;
|
||||
minlen?: number;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
export interface ILayoutGraph {
|
||||
nodes: LayoutNode[];
|
||||
edges: LayoutEdge[];
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import greedyFAS from './greedy-fas';
|
||||
import { uniqueId } from './util';
|
||||
|
||||
export const acyclic = {
|
||||
run,
|
||||
undo,
|
||||
};
|
||||
|
||||
export default acyclic;
|
||||
|
||||
function run(g) {
|
||||
let fas = g.graph().acyclicer === 'greedy' ? greedyFAS(g, weightFn(g)) : dfsFAS(g);
|
||||
fas.forEach((e) => {
|
||||
let label = g.edge(e);
|
||||
g.removeEdge(e);
|
||||
label.forwardName = e.name;
|
||||
label.reversed = true;
|
||||
g.setEdge(e.w, e.v, label, uniqueId('rev'));
|
||||
});
|
||||
|
||||
function weightFn(g) {
|
||||
return (e) => {
|
||||
return g.edge(e).weight;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function dfsFAS(g) {
|
||||
let fas = [];
|
||||
let stack = {};
|
||||
let visited = {};
|
||||
|
||||
function dfs(v) {
|
||||
if (Object.hasOwn(visited, v)) {
|
||||
return;
|
||||
}
|
||||
visited[v] = true;
|
||||
stack[v] = true;
|
||||
g.outEdges(v).forEach((e) => {
|
||||
if (Object.hasOwn(stack, e.w)) {
|
||||
fas.push(e);
|
||||
} else {
|
||||
dfs(e.w);
|
||||
}
|
||||
});
|
||||
delete stack[v];
|
||||
}
|
||||
|
||||
g.nodes().forEach(dfs);
|
||||
return fas;
|
||||
}
|
||||
|
||||
function undo(g) {
|
||||
g.edges().forEach((e) => {
|
||||
let label = g.edge(e);
|
||||
if (label.reversed) {
|
||||
g.removeEdge(e);
|
||||
|
||||
let forwardName = label.forwardName;
|
||||
delete label.reversed;
|
||||
delete label.forwardName;
|
||||
g.setEdge(e.w, e.v, label, forwardName);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { util } from './util';
|
||||
|
||||
export { addBorderSegments };
|
||||
export default addBorderSegments;
|
||||
|
||||
function addBorderSegments(g) {
|
||||
function dfs(v) {
|
||||
let children = g.children(v);
|
||||
let node = g.node(v);
|
||||
if (children.length) {
|
||||
children.forEach(dfs);
|
||||
}
|
||||
|
||||
if (Object.hasOwn(node, 'minRank')) {
|
||||
node.borderLeft = [];
|
||||
node.borderRight = [];
|
||||
for (let rank = node.minRank, maxRank = node.maxRank + 1; rank < maxRank; ++rank) {
|
||||
addBorderNode(g, 'borderLeft', '_bl', v, node, rank);
|
||||
addBorderNode(g, 'borderRight', '_br', v, node, rank);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g.children().forEach(dfs);
|
||||
}
|
||||
|
||||
function addBorderNode(g, prop, prefix, sg, sgNode, rank) {
|
||||
let label = { width: 0, height: 0, rank: rank, borderType: prop };
|
||||
let prev = sgNode[prop][rank - 1];
|
||||
let curr = util.addDummyNode(g, 'border', label, prefix);
|
||||
sgNode[prop][rank] = curr;
|
||||
g.setParent(curr, sg);
|
||||
if (prev) {
|
||||
g.setEdge(prev, curr, { weight: 1 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
export const coordinateSystem = {
|
||||
adjust,
|
||||
undo,
|
||||
};
|
||||
|
||||
export default coordinateSystem;
|
||||
|
||||
function adjust(g) {
|
||||
let rankDir = g.graph().rankdir.toLowerCase();
|
||||
if (rankDir === 'lr' || rankDir === 'rl') {
|
||||
swapWidthHeight(g);
|
||||
}
|
||||
}
|
||||
|
||||
function undo(g) {
|
||||
let rankDir = g.graph().rankdir.toLowerCase();
|
||||
if (rankDir === 'bt' || rankDir === 'rl') {
|
||||
reverseY(g);
|
||||
}
|
||||
|
||||
if (rankDir === 'lr' || rankDir === 'rl') {
|
||||
swapXY(g);
|
||||
swapWidthHeight(g);
|
||||
}
|
||||
}
|
||||
|
||||
function swapWidthHeight(g) {
|
||||
g.nodes().forEach((v) => swapWidthHeightOne(g.node(v)));
|
||||
g.edges().forEach((e) => swapWidthHeightOne(g.edge(e)));
|
||||
}
|
||||
|
||||
function swapWidthHeightOne(attrs) {
|
||||
let w = attrs.width;
|
||||
attrs.width = attrs.height;
|
||||
attrs.height = w;
|
||||
}
|
||||
|
||||
function reverseY(g) {
|
||||
g.nodes().forEach((v) => reverseYOne(g.node(v)));
|
||||
|
||||
g.edges().forEach((e) => {
|
||||
let edge = g.edge(e);
|
||||
edge.points.forEach(reverseYOne);
|
||||
if (Object.hasOwn(edge, 'y')) {
|
||||
reverseYOne(edge);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function reverseYOne(attrs) {
|
||||
attrs.y = -attrs.y;
|
||||
}
|
||||
|
||||
function swapXY(g) {
|
||||
g.nodes().forEach((v) => swapXYOne(g.node(v)));
|
||||
|
||||
g.edges().forEach((e) => {
|
||||
let edge = g.edge(e);
|
||||
edge.points.forEach(swapXYOne);
|
||||
if (Object.hasOwn(edge, 'x')) {
|
||||
swapXYOne(edge);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function swapXYOne(attrs) {
|
||||
let x = attrs.x;
|
||||
attrs.x = attrs.y;
|
||||
attrs.y = x;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
/*
|
||||
* Simple doubly linked list implementation derived from Cormen, et al.,
|
||||
* "Introduction to Algorithms".
|
||||
*/
|
||||
|
||||
class List {
|
||||
constructor() {
|
||||
let sentinel = {};
|
||||
sentinel._next = sentinel._prev = sentinel;
|
||||
this._sentinel = sentinel;
|
||||
}
|
||||
|
||||
dequeue() {
|
||||
let sentinel = this._sentinel;
|
||||
let entry = sentinel._prev;
|
||||
if (entry !== sentinel) {
|
||||
unlink(entry);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
enqueue(entry) {
|
||||
let sentinel = this._sentinel;
|
||||
if (entry._prev && entry._next) {
|
||||
unlink(entry);
|
||||
}
|
||||
entry._next = sentinel._next;
|
||||
sentinel._next._prev = entry;
|
||||
sentinel._next = entry;
|
||||
entry._prev = sentinel;
|
||||
}
|
||||
|
||||
toString() {
|
||||
let strs = [];
|
||||
let sentinel = this._sentinel;
|
||||
let curr = sentinel._prev;
|
||||
while (curr !== sentinel) {
|
||||
strs.push(JSON.stringify(curr, filterOutLinks));
|
||||
curr = curr._prev;
|
||||
}
|
||||
return '[' + strs.join(', ') + ']';
|
||||
}
|
||||
}
|
||||
|
||||
function unlink(entry) {
|
||||
entry._prev._next = entry._next;
|
||||
entry._next._prev = entry._prev;
|
||||
delete entry._next;
|
||||
delete entry._prev;
|
||||
}
|
||||
|
||||
function filterOutLinks(k, v) {
|
||||
if (k !== '_next' && k !== '_prev') {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
export default List;
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { util } from './util';
|
||||
import { Graph } from '@dagrejs/graphlib';
|
||||
|
||||
export { debugOrdering };
|
||||
|
||||
/* istanbul ignore next */
|
||||
function debugOrdering(g) {
|
||||
let layerMatrix = util.buildLayerMatrix(g);
|
||||
|
||||
let h = new Graph({ compound: true, multigraph: true }).setGraph({});
|
||||
|
||||
g.nodes().forEach((v) => {
|
||||
h.setNode(v, { label: v });
|
||||
h.setParent(v, 'layer' + g.node(v).rank);
|
||||
});
|
||||
|
||||
g.edges().forEach((e) => h.setEdge(e.v, e.w, {}, e.name));
|
||||
|
||||
layerMatrix.forEach((layer, i) => {
|
||||
let layerV = 'layer' + i;
|
||||
h.setNode(layerV, { rank: 'same' });
|
||||
layer.reduce((u, v) => {
|
||||
h.setEdge(u, v, { style: 'invis' });
|
||||
return v;
|
||||
});
|
||||
});
|
||||
|
||||
return h;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { Graph } from '@dagrejs/graphlib';
|
||||
import List from './data/list';
|
||||
|
||||
/*
|
||||
* A greedy heuristic for finding a feedback arc set for a graph. A feedback
|
||||
* arc set is a set of edges that can be removed to make a graph acyclic.
|
||||
* The algorithm comes from: P. Eades, X. Lin, and W. F. Smyth, "A fast and
|
||||
* effective heuristic for the feedback arc set problem." This implementation
|
||||
* adjusts that from the paper to allow for weighted edges.
|
||||
*/
|
||||
export { greedyFAS };
|
||||
export default greedyFAS;
|
||||
|
||||
let DEFAULT_WEIGHT_FN = () => 1;
|
||||
|
||||
function greedyFAS(g, weightFn) {
|
||||
if (g.nodeCount() <= 1) {
|
||||
return [];
|
||||
}
|
||||
let state = buildState(g, weightFn || DEFAULT_WEIGHT_FN);
|
||||
let results = doGreedyFAS(state.graph, state.buckets, state.zeroIdx);
|
||||
|
||||
// Expand multi-edges
|
||||
return results.flatMap((e) => g.outEdges(e.v, e.w));
|
||||
}
|
||||
|
||||
function doGreedyFAS(g, buckets, zeroIdx) {
|
||||
let results = [];
|
||||
let sources = buckets[buckets.length - 1];
|
||||
let sinks = buckets[0];
|
||||
|
||||
let entry;
|
||||
while (g.nodeCount()) {
|
||||
while ((entry = sinks.dequeue())) {
|
||||
removeNode(g, buckets, zeroIdx, entry);
|
||||
}
|
||||
while ((entry = sources.dequeue())) {
|
||||
removeNode(g, buckets, zeroIdx, entry);
|
||||
}
|
||||
if (g.nodeCount()) {
|
||||
for (let i = buckets.length - 2; i > 0; --i) {
|
||||
entry = buckets[i].dequeue();
|
||||
if (entry) {
|
||||
results = results.concat(removeNode(g, buckets, zeroIdx, entry, true));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function removeNode(g, buckets, zeroIdx, entry, collectPredecessors) {
|
||||
let results = collectPredecessors ? [] : undefined;
|
||||
|
||||
g.inEdges(entry.v).forEach((edge) => {
|
||||
let weight = g.edge(edge);
|
||||
let uEntry = g.node(edge.v);
|
||||
|
||||
if (collectPredecessors) {
|
||||
results.push({ v: edge.v, w: edge.w });
|
||||
}
|
||||
|
||||
uEntry.out -= weight;
|
||||
assignBucket(buckets, zeroIdx, uEntry);
|
||||
});
|
||||
|
||||
g.outEdges(entry.v).forEach((edge) => {
|
||||
let weight = g.edge(edge);
|
||||
let w = edge.w;
|
||||
let wEntry = g.node(w);
|
||||
wEntry['in'] -= weight;
|
||||
assignBucket(buckets, zeroIdx, wEntry);
|
||||
});
|
||||
|
||||
g.removeNode(entry.v);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function buildState(g, weightFn) {
|
||||
let fasGraph = new Graph();
|
||||
let maxIn = 0;
|
||||
let maxOut = 0;
|
||||
|
||||
g.nodes().forEach((v) => {
|
||||
fasGraph.setNode(v, { v: v, in: 0, out: 0 });
|
||||
});
|
||||
|
||||
// Aggregate weights on nodes, but also sum the weights across multi-edges
|
||||
// into a single edge for the fasGraph.
|
||||
g.edges().forEach((e) => {
|
||||
let prevWeight = fasGraph.edge(e.v, e.w) || 0;
|
||||
let weight = weightFn(e);
|
||||
let edgeWeight = prevWeight + weight;
|
||||
fasGraph.setEdge(e.v, e.w, edgeWeight);
|
||||
maxOut = Math.max(maxOut, (fasGraph.node(e.v).out += weight));
|
||||
maxIn = Math.max(maxIn, (fasGraph.node(e.w)['in'] += weight));
|
||||
});
|
||||
|
||||
let buckets = range(maxOut + maxIn + 3).map(() => new List());
|
||||
let zeroIdx = maxIn + 1;
|
||||
|
||||
fasGraph.nodes().forEach((v) => {
|
||||
assignBucket(buckets, zeroIdx, fasGraph.node(v));
|
||||
});
|
||||
|
||||
return { graph: fasGraph, buckets: buckets, zeroIdx: zeroIdx };
|
||||
}
|
||||
|
||||
function assignBucket(buckets, zeroIdx, entry) {
|
||||
if (!entry.out) {
|
||||
buckets[0].enqueue(entry);
|
||||
} else if (!entry['in']) {
|
||||
buckets[buckets.length - 1].enqueue(entry);
|
||||
} else {
|
||||
buckets[entry.out - entry['in'] + zeroIdx].enqueue(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function range(limit) {
|
||||
const range = [];
|
||||
for (let i = 0; i < limit; i++) {
|
||||
range.push(i);
|
||||
}
|
||||
|
||||
return range;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Dagre DAG 布局库
|
||||
* 开源协议 - MIT
|
||||
* 源码 - https://github.com/dagrejs/dagre
|
||||
* 论文 - https://graphviz.org/documentation/TSE93.pdf
|
||||
*/
|
||||
|
||||
import acyclic from './acyclic';
|
||||
import normalize from './normalize';
|
||||
import rank from './rank';
|
||||
import { normalizeRanks, removeEmptyRanks } from './util';
|
||||
import parentDummyChains from './parent-dummy-chains';
|
||||
import nestingGraph from './nesting-graph';
|
||||
import addBorderSegments from './add-border-segments';
|
||||
import coordinateSystem from './coordinate-system';
|
||||
import order from './order';
|
||||
import position from './position';
|
||||
import { util } from './util';
|
||||
|
||||
import {
|
||||
layout,
|
||||
buildLayoutGraph,
|
||||
updateInputGraph,
|
||||
makeSpaceForEdgeLabels,
|
||||
removeSelfEdges,
|
||||
injectEdgeLabelProxies,
|
||||
assignRankMinMax,
|
||||
removeEdgeLabelProxies,
|
||||
insertSelfEdges,
|
||||
positionSelfEdges,
|
||||
removeBorderNodes,
|
||||
fixupEdgeLabelCoords,
|
||||
translateGraph,
|
||||
assignNodeIntersects,
|
||||
reversePointsForReversedEdges,
|
||||
} from './layout';
|
||||
|
||||
const dagreLib = {
|
||||
layout,
|
||||
buildLayoutGraph,
|
||||
updateInputGraph,
|
||||
makeSpaceForEdgeLabels,
|
||||
removeSelfEdges,
|
||||
acyclic,
|
||||
nestingGraph,
|
||||
rank,
|
||||
util,
|
||||
injectEdgeLabelProxies,
|
||||
removeEmptyRanks,
|
||||
normalizeRanks,
|
||||
assignRankMinMax,
|
||||
removeEdgeLabelProxies,
|
||||
normalize,
|
||||
parentDummyChains,
|
||||
addBorderSegments,
|
||||
order,
|
||||
insertSelfEdges,
|
||||
coordinateSystem,
|
||||
position,
|
||||
positionSelfEdges,
|
||||
removeBorderNodes,
|
||||
fixupEdgeLabelCoords,
|
||||
translateGraph,
|
||||
assignNodeIntersects,
|
||||
reversePointsForReversedEdges,
|
||||
};
|
||||
|
||||
export { dagreLib };
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import acyclic from './acyclic';
|
||||
import normalize from './normalize';
|
||||
import rank from './rank';
|
||||
import { normalizeRanks, removeEmptyRanks, util } from './util';
|
||||
import parentDummyChains from './parent-dummy-chains';
|
||||
import nestingGraph from './nesting-graph';
|
||||
import addBorderSegments from './add-border-segments';
|
||||
import coordinateSystem from './coordinate-system';
|
||||
import order from './order';
|
||||
import position from './position';
|
||||
import { Graph } from '@dagrejs/graphlib';
|
||||
|
||||
export {
|
||||
layout,
|
||||
buildLayoutGraph,
|
||||
updateInputGraph,
|
||||
makeSpaceForEdgeLabels,
|
||||
removeSelfEdges,
|
||||
injectEdgeLabelProxies,
|
||||
assignRankMinMax,
|
||||
removeEdgeLabelProxies,
|
||||
insertSelfEdges,
|
||||
positionSelfEdges,
|
||||
removeBorderNodes,
|
||||
fixupEdgeLabelCoords,
|
||||
translateGraph,
|
||||
assignNodeIntersects,
|
||||
reversePointsForReversedEdges,
|
||||
};
|
||||
|
||||
function layout(g, opts) {
|
||||
let time = opts && opts.debugTiming ? util.time : util.notime;
|
||||
time('layout', () => {
|
||||
let layoutGraph = time(' buildLayoutGraph', () => buildLayoutGraph(g));
|
||||
time(' runLayout', () => runLayout(layoutGraph, time, opts));
|
||||
time(' updateInputGraph', () => updateInputGraph(g, layoutGraph));
|
||||
});
|
||||
}
|
||||
|
||||
function runLayout(g, time, opts) {
|
||||
time(' makeSpaceForEdgeLabels', () => makeSpaceForEdgeLabels(g));
|
||||
time(' removeSelfEdges', () => removeSelfEdges(g));
|
||||
time(' acyclic', () => acyclic.run(g));
|
||||
time(' nestingGraph.run', () => nestingGraph.run(g));
|
||||
time(' rank', () => rank(util.asNonCompoundGraph(g)));
|
||||
time(' injectEdgeLabelProxies', () => injectEdgeLabelProxies(g));
|
||||
time(' removeEmptyRanks', () => removeEmptyRanks(g));
|
||||
time(' nestingGraph.cleanup', () => nestingGraph.cleanup(g));
|
||||
time(' normalizeRanks', () => normalizeRanks(g));
|
||||
time(' assignRankMinMax', () => assignRankMinMax(g));
|
||||
time(' removeEdgeLabelProxies', () => removeEdgeLabelProxies(g));
|
||||
time(' normalize.run', () => normalize.run(g));
|
||||
time(' parentDummyChains', () => parentDummyChains(g));
|
||||
time(' addBorderSegments', () => addBorderSegments(g));
|
||||
time(' order', () => order(g, opts));
|
||||
time(' insertSelfEdges', () => insertSelfEdges(g));
|
||||
time(' adjustCoordinateSystem', () => coordinateSystem.adjust(g));
|
||||
time(' position', () => position(g));
|
||||
time(' positionSelfEdges', () => positionSelfEdges(g));
|
||||
time(' removeBorderNodes', () => removeBorderNodes(g));
|
||||
time(' normalize.undo', () => normalize.undo(g));
|
||||
time(' fixupEdgeLabelCoords', () => fixupEdgeLabelCoords(g));
|
||||
time(' undoCoordinateSystem', () => coordinateSystem.undo(g));
|
||||
time(' translateGraph', () => translateGraph(g));
|
||||
time(' assignNodeIntersects', () => assignNodeIntersects(g));
|
||||
time(' reversePoints', () => reversePointsForReversedEdges(g));
|
||||
time(' acyclic.undo', () => acyclic.undo(g));
|
||||
}
|
||||
|
||||
/*
|
||||
* Copies final layout information from the layout graph back to the input
|
||||
* graph. This process only copies whitelisted attributes from the layout graph
|
||||
* to the input graph, so it serves as a good place to determine what
|
||||
* attributes can influence layout.
|
||||
*/
|
||||
function updateInputGraph(inputGraph, layoutGraph) {
|
||||
inputGraph.nodes().forEach((v) => {
|
||||
let inputLabel = inputGraph.node(v);
|
||||
let layoutLabel = layoutGraph.node(v);
|
||||
|
||||
if (inputLabel) {
|
||||
inputLabel.x = layoutLabel.x;
|
||||
inputLabel.y = layoutLabel.y;
|
||||
inputLabel.rank = layoutLabel.rank;
|
||||
|
||||
if (layoutGraph.children(v).length) {
|
||||
inputLabel.width = layoutLabel.width;
|
||||
inputLabel.height = layoutLabel.height;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
inputGraph.edges().forEach((e) => {
|
||||
let inputLabel = inputGraph.edge(e);
|
||||
let layoutLabel = layoutGraph.edge(e);
|
||||
|
||||
inputLabel.points = layoutLabel.points;
|
||||
if (Object.hasOwn(layoutLabel, 'x')) {
|
||||
inputLabel.x = layoutLabel.x;
|
||||
inputLabel.y = layoutLabel.y;
|
||||
}
|
||||
});
|
||||
|
||||
inputGraph.graph().width = layoutGraph.graph().width;
|
||||
inputGraph.graph().height = layoutGraph.graph().height;
|
||||
}
|
||||
|
||||
let graphNumAttrs = ['nodesep', 'edgesep', 'ranksep', 'marginx', 'marginy'];
|
||||
let graphDefaults = { ranksep: 50, edgesep: 20, nodesep: 50, rankdir: 'tb' };
|
||||
let graphAttrs = ['acyclicer', 'ranker', 'rankdir', 'align'];
|
||||
let nodeNumAttrs = ['width', 'height'];
|
||||
let nodeDefaults = { width: 0, height: 0 };
|
||||
let edgeNumAttrs = ['minlen', 'weight', 'width', 'height', 'labeloffset'];
|
||||
let edgeDefaults = {
|
||||
minlen: 1,
|
||||
weight: 1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
labeloffset: 10,
|
||||
labelpos: 'r',
|
||||
};
|
||||
let edgeAttrs = ['labelpos'];
|
||||
|
||||
/*
|
||||
* Constructs a new graph from the input graph, which can be used for layout.
|
||||
* This process copies only whitelisted attributes from the input graph to the
|
||||
* layout graph. Thus this function serves as a good place to determine what
|
||||
* attributes can influence layout.
|
||||
*/
|
||||
function buildLayoutGraph(inputGraph) {
|
||||
let g = new Graph({ multigraph: true, compound: true });
|
||||
let graph = canonicalize(inputGraph.graph());
|
||||
|
||||
g.setGraph(
|
||||
Object.assign(
|
||||
{},
|
||||
graphDefaults,
|
||||
selectNumberAttrs(graph, graphNumAttrs),
|
||||
util.pick(graph, graphAttrs)
|
||||
)
|
||||
);
|
||||
|
||||
inputGraph.nodes().forEach((v) => {
|
||||
let node = canonicalize(inputGraph.node(v));
|
||||
const newNode = selectNumberAttrs(node, nodeNumAttrs);
|
||||
Object.keys(nodeDefaults).forEach((k) => {
|
||||
if (newNode[k] === undefined) {
|
||||
newNode[k] = nodeDefaults[k];
|
||||
}
|
||||
});
|
||||
|
||||
g.setNode(v, newNode);
|
||||
g.setParent(v, inputGraph.parent(v));
|
||||
});
|
||||
|
||||
inputGraph.edges().forEach((e) => {
|
||||
let edge = canonicalize(inputGraph.edge(e));
|
||||
g.setEdge(
|
||||
e,
|
||||
Object.assign(
|
||||
{},
|
||||
edgeDefaults,
|
||||
selectNumberAttrs(edge, edgeNumAttrs),
|
||||
util.pick(edge, edgeAttrs)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
/*
|
||||
* This idea comes from the Gansner paper: to account for edge labels in our
|
||||
* layout we split each rank in half by doubling minlen and halving ranksep.
|
||||
* Then we can place labels at these mid-points between nodes.
|
||||
*
|
||||
* We also add some minimal padding to the width to push the label for the edge
|
||||
* away from the edge itself a bit.
|
||||
*/
|
||||
function makeSpaceForEdgeLabels(g) {
|
||||
let graph = g.graph();
|
||||
graph.ranksep /= 2;
|
||||
g.edges().forEach((e) => {
|
||||
let edge = g.edge(e);
|
||||
edge.minlen *= 2;
|
||||
if (edge.labelpos.toLowerCase() !== 'c') {
|
||||
if (graph.rankdir === 'TB' || graph.rankdir === 'BT') {
|
||||
edge.width += edge.labeloffset;
|
||||
} else {
|
||||
edge.height += edge.labeloffset;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Creates temporary dummy nodes that capture the rank in which each edge's
|
||||
* label is going to, if it has one of non-zero width and height. We do this
|
||||
* so that we can safely remove empty ranks while preserving balance for the
|
||||
* label's position.
|
||||
*/
|
||||
function injectEdgeLabelProxies(g) {
|
||||
g.edges().forEach((e) => {
|
||||
let edge = g.edge(e);
|
||||
if (edge.width && edge.height) {
|
||||
let v = g.node(e.v);
|
||||
let w = g.node(e.w);
|
||||
let label = { rank: (w.rank - v.rank) / 2 + v.rank, e: e };
|
||||
util.addDummyNode(g, 'edge-proxy', label, '_ep');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function assignRankMinMax(g) {
|
||||
let maxRank = 0;
|
||||
g.nodes().forEach((v) => {
|
||||
let node = g.node(v);
|
||||
if (node.borderTop) {
|
||||
node.minRank = g.node(node.borderTop).rank;
|
||||
node.maxRank = g.node(node.borderBottom).rank;
|
||||
maxRank = Math.max(maxRank, node.maxRank);
|
||||
}
|
||||
});
|
||||
g.graph().maxRank = maxRank;
|
||||
}
|
||||
|
||||
function removeEdgeLabelProxies(g) {
|
||||
g.nodes().forEach((v) => {
|
||||
let node = g.node(v);
|
||||
if (node.dummy === 'edge-proxy') {
|
||||
g.edge(node.e).labelRank = node.rank;
|
||||
g.removeNode(v);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function translateGraph(g) {
|
||||
let minX = Number.POSITIVE_INFINITY;
|
||||
let maxX = 0;
|
||||
let minY = Number.POSITIVE_INFINITY;
|
||||
let maxY = 0;
|
||||
let graphLabel = g.graph();
|
||||
let marginX = graphLabel.marginx || 0;
|
||||
let marginY = graphLabel.marginy || 0;
|
||||
|
||||
function getExtremes(attrs) {
|
||||
let x = attrs.x;
|
||||
let y = attrs.y;
|
||||
let w = attrs.width;
|
||||
let h = attrs.height;
|
||||
minX = Math.min(minX, x - w / 2);
|
||||
maxX = Math.max(maxX, x + w / 2);
|
||||
minY = Math.min(minY, y - h / 2);
|
||||
maxY = Math.max(maxY, y + h / 2);
|
||||
}
|
||||
|
||||
g.nodes().forEach((v) => getExtremes(g.node(v)));
|
||||
g.edges().forEach((e) => {
|
||||
let edge = g.edge(e);
|
||||
if (Object.hasOwn(edge, 'x')) {
|
||||
getExtremes(edge);
|
||||
}
|
||||
});
|
||||
|
||||
minX -= marginX;
|
||||
minY -= marginY;
|
||||
|
||||
g.nodes().forEach((v) => {
|
||||
let node = g.node(v);
|
||||
node.x -= minX;
|
||||
node.y -= minY;
|
||||
});
|
||||
|
||||
g.edges().forEach((e) => {
|
||||
let edge = g.edge(e);
|
||||
edge.points.forEach((p) => {
|
||||
p.x -= minX;
|
||||
p.y -= minY;
|
||||
});
|
||||
if (Object.hasOwn(edge, 'x')) {
|
||||
edge.x -= minX;
|
||||
}
|
||||
if (Object.hasOwn(edge, 'y')) {
|
||||
edge.y -= minY;
|
||||
}
|
||||
});
|
||||
|
||||
graphLabel.width = maxX - minX + marginX;
|
||||
graphLabel.height = maxY - minY + marginY;
|
||||
}
|
||||
|
||||
function assignNodeIntersects(g) {
|
||||
g.edges().forEach((e) => {
|
||||
let edge = g.edge(e);
|
||||
let nodeV = g.node(e.v);
|
||||
let nodeW = g.node(e.w);
|
||||
let p1, p2;
|
||||
if (!edge.points) {
|
||||
edge.points = [];
|
||||
p1 = nodeW;
|
||||
p2 = nodeV;
|
||||
} else {
|
||||
p1 = edge.points[0];
|
||||
p2 = edge.points[edge.points.length - 1];
|
||||
}
|
||||
edge.points.unshift(util.intersectRect(nodeV, p1));
|
||||
edge.points.push(util.intersectRect(nodeW, p2));
|
||||
});
|
||||
}
|
||||
|
||||
function fixupEdgeLabelCoords(g) {
|
||||
g.edges().forEach((e) => {
|
||||
let edge = g.edge(e);
|
||||
if (Object.hasOwn(edge, 'x')) {
|
||||
if (edge.labelpos === 'l' || edge.labelpos === 'r') {
|
||||
edge.width -= edge.labeloffset;
|
||||
}
|
||||
switch (edge.labelpos) {
|
||||
case 'l':
|
||||
edge.x -= edge.width / 2 + edge.labeloffset;
|
||||
break;
|
||||
case 'r':
|
||||
edge.x += edge.width / 2 + edge.labeloffset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function reversePointsForReversedEdges(g) {
|
||||
g.edges().forEach((e) => {
|
||||
let edge = g.edge(e);
|
||||
if (edge.reversed) {
|
||||
edge.points.reverse();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeBorderNodes(g) {
|
||||
g.nodes().forEach((v) => {
|
||||
if (g.children(v).length) {
|
||||
let node = g.node(v);
|
||||
let t = g.node(node.borderTop);
|
||||
let b = g.node(node.borderBottom);
|
||||
let l = g.node(node.borderLeft[node.borderLeft.length - 1]);
|
||||
let r = g.node(node.borderRight[node.borderRight.length - 1]);
|
||||
|
||||
node.width = Math.abs(r.x - l.x);
|
||||
node.height = Math.abs(b.y - t.y);
|
||||
node.x = l.x + node.width / 2;
|
||||
node.y = t.y + node.height / 2;
|
||||
}
|
||||
});
|
||||
|
||||
g.nodes().forEach((v) => {
|
||||
if (g.node(v).dummy === 'border') {
|
||||
g.removeNode(v);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeSelfEdges(g) {
|
||||
g.edges().forEach((e) => {
|
||||
if (e.v === e.w) {
|
||||
var node = g.node(e.v);
|
||||
if (!node.selfEdges) {
|
||||
node.selfEdges = [];
|
||||
}
|
||||
node.selfEdges.push({ e: e, label: g.edge(e) });
|
||||
g.removeEdge(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function insertSelfEdges(g) {
|
||||
var layers = util.buildLayerMatrix(g);
|
||||
layers.forEach((layer) => {
|
||||
var orderShift = 0;
|
||||
layer.forEach((v, i) => {
|
||||
var node = g.node(v);
|
||||
node.order = i + orderShift;
|
||||
(node.selfEdges || []).forEach((selfEdge) => {
|
||||
util.addDummyNode(
|
||||
g,
|
||||
'selfedge',
|
||||
{
|
||||
width: selfEdge.label.width,
|
||||
height: selfEdge.label.height,
|
||||
rank: node.rank,
|
||||
order: i + ++orderShift,
|
||||
e: selfEdge.e,
|
||||
label: selfEdge.label,
|
||||
},
|
||||
'_se'
|
||||
);
|
||||
});
|
||||
delete node.selfEdges;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function positionSelfEdges(g) {
|
||||
g.nodes().forEach((v) => {
|
||||
var node = g.node(v);
|
||||
if (node.dummy === 'selfedge') {
|
||||
var selfNode = g.node(node.e.v);
|
||||
var x = selfNode.x + selfNode.width / 2;
|
||||
var y = selfNode.y;
|
||||
var dx = node.x - x;
|
||||
var dy = selfNode.height / 2;
|
||||
g.setEdge(node.e, node.label);
|
||||
g.removeNode(v);
|
||||
node.label.points = [
|
||||
{ x: x + (2 * dx) / 3, y: y - dy },
|
||||
{ x: x + (5 * dx) / 6, y: y - dy },
|
||||
{ x: x + dx, y: y },
|
||||
{ x: x + (5 * dx) / 6, y: y + dy },
|
||||
{ x: x + (2 * dx) / 3, y: y + dy },
|
||||
];
|
||||
node.label.x = node.x;
|
||||
node.label.y = node.y;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function selectNumberAttrs(obj, attrs) {
|
||||
return util.mapValues(util.pick(obj, attrs), Number);
|
||||
}
|
||||
|
||||
function canonicalize(attrs) {
|
||||
var newAttrs = {};
|
||||
if (attrs) {
|
||||
Object.entries(attrs).forEach(([k, v]) => {
|
||||
if (typeof k === 'string') {
|
||||
k = k.toLowerCase();
|
||||
}
|
||||
|
||||
newAttrs[k] = v;
|
||||
});
|
||||
}
|
||||
return newAttrs;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { util } from './util';
|
||||
|
||||
export const nestingGraph = {
|
||||
run,
|
||||
cleanup,
|
||||
};
|
||||
|
||||
export default nestingGraph;
|
||||
|
||||
/*
|
||||
* A nesting graph creates dummy nodes for the tops and bottoms of subgraphs,
|
||||
* adds appropriate edges to ensure that all cluster nodes are placed between
|
||||
* these boundaries, and ensures that the graph is connected.
|
||||
*
|
||||
* In addition we ensure, through the use of the minlen property, that nodes
|
||||
* and subgraph border nodes to not end up on the same rank.
|
||||
*
|
||||
* Preconditions:
|
||||
*
|
||||
* 1. Input graph is a DAG
|
||||
* 2. Nodes in the input graph has a minlen attribute
|
||||
*
|
||||
* Postconditions:
|
||||
*
|
||||
* 1. Input graph is connected.
|
||||
* 2. Dummy nodes are added for the tops and bottoms of subgraphs.
|
||||
* 3. The minlen attribute for nodes is adjusted to ensure nodes do not
|
||||
* get placed on the same rank as subgraph border nodes.
|
||||
*
|
||||
* The nesting graph idea comes from Sander, "Layout of Compound Directed
|
||||
* Graphs."
|
||||
*/
|
||||
function run(g) {
|
||||
let root = util.addDummyNode(g, 'root', {}, '_root');
|
||||
let depths = treeDepths(g);
|
||||
let depthsArr = Object.values(depths);
|
||||
let height = util.applyWithChunking(Math.max, depthsArr) - 1; // Note: depths is an Object not an array
|
||||
let nodeSep = 2 * height + 1;
|
||||
|
||||
g.graph().nestingRoot = root;
|
||||
|
||||
// Multiply minlen by nodeSep to align nodes on non-border ranks.
|
||||
g.edges().forEach((e) => (g.edge(e).minlen *= nodeSep));
|
||||
|
||||
// Calculate a weight that is sufficient to keep subgraphs vertically compact
|
||||
let weight = sumWeights(g) + 1;
|
||||
|
||||
// Create border nodes and link them up
|
||||
g.children().forEach((child) => dfs(g, root, nodeSep, weight, height, depths, child));
|
||||
|
||||
// Save the multiplier for node layers for later removal of empty border
|
||||
// layers.
|
||||
g.graph().nodeRankFactor = nodeSep;
|
||||
}
|
||||
|
||||
function dfs(g, root, nodeSep, weight, height, depths, v) {
|
||||
let children = g.children(v);
|
||||
if (!children.length) {
|
||||
if (v !== root) {
|
||||
g.setEdge(root, v, { weight: 0, minlen: nodeSep });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let top = util.addBorderNode(g, '_bt');
|
||||
let bottom = util.addBorderNode(g, '_bb');
|
||||
let label = g.node(v);
|
||||
|
||||
g.setParent(top, v);
|
||||
label.borderTop = top;
|
||||
g.setParent(bottom, v);
|
||||
label.borderBottom = bottom;
|
||||
|
||||
children.forEach((child) => {
|
||||
dfs(g, root, nodeSep, weight, height, depths, child);
|
||||
|
||||
let childNode = g.node(child);
|
||||
let childTop = childNode.borderTop ? childNode.borderTop : child;
|
||||
let childBottom = childNode.borderBottom ? childNode.borderBottom : child;
|
||||
let thisWeight = childNode.borderTop ? weight : 2 * weight;
|
||||
let minlen = childTop !== childBottom ? 1 : height - depths[v] + 1;
|
||||
|
||||
g.setEdge(top, childTop, {
|
||||
weight: thisWeight,
|
||||
minlen: minlen,
|
||||
nestingEdge: true,
|
||||
});
|
||||
|
||||
g.setEdge(childBottom, bottom, {
|
||||
weight: thisWeight,
|
||||
minlen: minlen,
|
||||
nestingEdge: true,
|
||||
});
|
||||
});
|
||||
|
||||
if (!g.parent(v)) {
|
||||
g.setEdge(root, top, { weight: 0, minlen: height + depths[v] });
|
||||
}
|
||||
}
|
||||
|
||||
function treeDepths(g) {
|
||||
var depths = {};
|
||||
function dfs(v, depth) {
|
||||
var children = g.children(v);
|
||||
if (children && children.length) {
|
||||
children.forEach((child) => dfs(child, depth + 1));
|
||||
}
|
||||
depths[v] = depth;
|
||||
}
|
||||
g.children().forEach((v) => dfs(v, 1));
|
||||
return depths;
|
||||
}
|
||||
|
||||
function sumWeights(g) {
|
||||
return g.edges().reduce((acc, e) => acc + g.edge(e).weight, 0);
|
||||
}
|
||||
|
||||
function cleanup(g) {
|
||||
var graphLabel = g.graph();
|
||||
g.removeNode(graphLabel.nestingRoot);
|
||||
delete graphLabel.nestingRoot;
|
||||
g.edges().forEach((e) => {
|
||||
var edge = g.edge(e);
|
||||
if (edge.nestingEdge) {
|
||||
g.removeEdge(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { util } from './util';
|
||||
|
||||
export const normalize = {
|
||||
run,
|
||||
undo,
|
||||
};
|
||||
|
||||
export default normalize;
|
||||
|
||||
/*
|
||||
* Breaks any long edges in the graph into short segments that span 1 layer
|
||||
* each. This operation is undoable with the denormalize function.
|
||||
*
|
||||
* Pre-conditions:
|
||||
*
|
||||
* 1. The input graph is a DAG.
|
||||
* 2. Each node in the graph has a "rank" property.
|
||||
*
|
||||
* Post-condition:
|
||||
*
|
||||
* 1. All edges in the graph have a length of 1.
|
||||
* 2. Dummy nodes are added where edges have been split into segments.
|
||||
* 3. The graph is augmented with a "dummyChains" attribute which contains
|
||||
* the first dummy in each chain of dummy nodes produced.
|
||||
*/
|
||||
function run(g) {
|
||||
g.graph().dummyChains = [];
|
||||
g.edges().forEach((edge) => normalizeEdge(g, edge));
|
||||
}
|
||||
|
||||
function normalizeEdge(g, e) {
|
||||
let v = e.v;
|
||||
let vRank = g.node(v).rank;
|
||||
let w = e.w;
|
||||
let wRank = g.node(w).rank;
|
||||
let name = e.name;
|
||||
let edgeLabel = g.edge(e);
|
||||
let labelRank = edgeLabel.labelRank;
|
||||
|
||||
if (wRank === vRank + 1) return;
|
||||
|
||||
g.removeEdge(e);
|
||||
|
||||
let dummy, attrs, i;
|
||||
for (i = 0, ++vRank; vRank < wRank; ++i, ++vRank) {
|
||||
edgeLabel.points = [];
|
||||
attrs = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
edgeLabel: edgeLabel,
|
||||
edgeObj: e,
|
||||
rank: vRank,
|
||||
};
|
||||
dummy = util.addDummyNode(g, 'edge', attrs, '_d');
|
||||
if (vRank === labelRank) {
|
||||
attrs.width = edgeLabel.width;
|
||||
attrs.height = edgeLabel.height;
|
||||
attrs.dummy = 'edge-label';
|
||||
attrs.labelpos = edgeLabel.labelpos;
|
||||
}
|
||||
g.setEdge(v, dummy, { weight: edgeLabel.weight }, name);
|
||||
if (i === 0) {
|
||||
g.graph().dummyChains.push(dummy);
|
||||
}
|
||||
v = dummy;
|
||||
}
|
||||
|
||||
g.setEdge(v, w, { weight: edgeLabel.weight }, name);
|
||||
}
|
||||
|
||||
function undo(g) {
|
||||
g.graph().dummyChains.forEach((v) => {
|
||||
let node = g.node(v);
|
||||
let origLabel = node.edgeLabel;
|
||||
let w;
|
||||
g.setEdge(node.edgeObj, origLabel);
|
||||
while (node.dummy) {
|
||||
w = g.successors(v)[0];
|
||||
g.removeNode(v);
|
||||
origLabel.points.push({ x: node.x, y: node.y });
|
||||
if (node.dummy === 'edge-label') {
|
||||
origLabel.x = node.x;
|
||||
origLabel.y = node.y;
|
||||
origLabel.width = node.width;
|
||||
origLabel.height = node.height;
|
||||
}
|
||||
v = w;
|
||||
node = g.node(v);
|
||||
}
|
||||
});
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { addSubgraphConstraints };
|
||||
export default addSubgraphConstraints;
|
||||
|
||||
function addSubgraphConstraints(g, cg, vs) {
|
||||
let prev = {},
|
||||
rootPrev;
|
||||
|
||||
vs.forEach((v) => {
|
||||
let child = g.parent(v),
|
||||
parent,
|
||||
prevChild;
|
||||
while (child) {
|
||||
parent = g.parent(child);
|
||||
if (parent) {
|
||||
prevChild = prev[parent];
|
||||
prev[parent] = child;
|
||||
} else {
|
||||
prevChild = rootPrev;
|
||||
rootPrev = child;
|
||||
}
|
||||
if (prevChild && prevChild !== child) {
|
||||
cg.setEdge(prevChild, child);
|
||||
return;
|
||||
}
|
||||
child = parent;
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
function dfs(v) {
|
||||
var children = v ? g.children(v) : g.children();
|
||||
if (children.length) {
|
||||
var min = Number.POSITIVE_INFINITY,
|
||||
subgraphs = [];
|
||||
children.forEach(function(child) {
|
||||
var childMin = dfs(child);
|
||||
if (g.children(child).length) {
|
||||
subgraphs.push({ v: child, order: childMin });
|
||||
}
|
||||
min = Math.min(min, childMin);
|
||||
});
|
||||
_.sortBy(subgraphs, "order").reduce(function(prev, curr) {
|
||||
cg.setEdge(prev.v, curr.v);
|
||||
return curr;
|
||||
});
|
||||
return min;
|
||||
}
|
||||
return g.node(v).order;
|
||||
}
|
||||
dfs(undefined);
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { barycenter };
|
||||
export default barycenter;
|
||||
|
||||
function barycenter(g, movable = []) {
|
||||
return movable.map((v) => {
|
||||
let inV = g.inEdges(v);
|
||||
if (!inV.length) {
|
||||
return { v: v };
|
||||
} else {
|
||||
let result = inV.reduce(
|
||||
(acc, e) => {
|
||||
let edge = g.edge(e),
|
||||
nodeU = g.node(e.v);
|
||||
return {
|
||||
sum: acc.sum + edge.weight * nodeU.order,
|
||||
weight: acc.weight + edge.weight,
|
||||
};
|
||||
},
|
||||
{ sum: 0, weight: 0 }
|
||||
);
|
||||
|
||||
return {
|
||||
v: v,
|
||||
barycenter: result.sum / result.weight,
|
||||
weight: result.weight,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { Graph } from '@dagrejs/graphlib';
|
||||
import { util } from '../util';
|
||||
|
||||
export { buildLayerGraph };
|
||||
export default buildLayerGraph;
|
||||
|
||||
/*
|
||||
* Constructs a graph that can be used to sort a layer of nodes. The graph will
|
||||
* contain all base and subgraph nodes from the request layer in their original
|
||||
* hierarchy and any edges that are incident on these nodes and are of the type
|
||||
* requested by the "relationship" parameter.
|
||||
*
|
||||
* Nodes from the requested rank that do not have parents are assigned a root
|
||||
* node in the output graph, which is set in the root graph attribute. This
|
||||
* makes it easy to walk the hierarchy of movable nodes during ordering.
|
||||
*
|
||||
* Pre-conditions:
|
||||
*
|
||||
* 1. Input graph is a DAG
|
||||
* 2. Base nodes in the input graph have a rank attribute
|
||||
* 3. Subgraph nodes in the input graph has minRank and maxRank attributes
|
||||
* 4. Edges have an assigned weight
|
||||
*
|
||||
* Post-conditions:
|
||||
*
|
||||
* 1. Output graph has all nodes in the movable rank with preserved
|
||||
* hierarchy.
|
||||
* 2. Root nodes in the movable layer are made children of the node
|
||||
* indicated by the root attribute of the graph.
|
||||
* 3. Non-movable nodes incident on movable nodes, selected by the
|
||||
* relationship parameter, are included in the graph (without hierarchy).
|
||||
* 4. Edges incident on movable nodes, selected by the relationship
|
||||
* parameter, are added to the output graph.
|
||||
* 5. The weights for copied edges are aggregated as need, since the output
|
||||
* graph is not a multi-graph.
|
||||
*/
|
||||
function buildLayerGraph(g, rank, relationship) {
|
||||
let root = createRootNode(g),
|
||||
result = new Graph({ compound: true })
|
||||
.setGraph({ root: root })
|
||||
.setDefaultNodeLabel((v) => g.node(v));
|
||||
|
||||
g.nodes().forEach((v) => {
|
||||
let node = g.node(v),
|
||||
parent = g.parent(v);
|
||||
|
||||
if (node.rank === rank || (node.minRank <= rank && rank <= node.maxRank)) {
|
||||
result.setNode(v);
|
||||
result.setParent(v, parent || root);
|
||||
|
||||
// This assumes we have only short edges!
|
||||
g[relationship](v).forEach((e) => {
|
||||
let u = e.v === v ? e.w : e.v,
|
||||
edge = result.edge(u, v),
|
||||
weight = edge !== undefined ? edge.weight : 0;
|
||||
result.setEdge(u, v, { weight: g.edge(e).weight + weight });
|
||||
});
|
||||
|
||||
if (Object.hasOwn(node, 'minRank')) {
|
||||
result.setNode(v, {
|
||||
borderLeft: node.borderLeft[rank],
|
||||
borderRight: node.borderRight[rank],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function createRootNode(g) {
|
||||
var v;
|
||||
while (g.hasNode((v = util.uniqueId('_root'))));
|
||||
return v;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { zipObject } from '../util';
|
||||
|
||||
export { crossCount };
|
||||
export default crossCount;
|
||||
|
||||
/*
|
||||
* A function that takes a layering (an array of layers, each with an array of
|
||||
* ordererd nodes) and a graph and returns a weighted crossing count.
|
||||
*
|
||||
* Pre-conditions:
|
||||
*
|
||||
* 1. Input graph must be simple (not a multigraph), directed, and include
|
||||
* only simple edges.
|
||||
* 2. Edges in the input graph must have assigned weights.
|
||||
*
|
||||
* Post-conditions:
|
||||
*
|
||||
* 1. The graph and layering matrix are left unchanged.
|
||||
*
|
||||
* This algorithm is derived from Barth, et al., "Bilayer Cross Counting."
|
||||
*/
|
||||
function crossCount(g, layering) {
|
||||
let cc = 0;
|
||||
for (let i = 1; i < layering.length; ++i) {
|
||||
cc += twoLayerCrossCount(g, layering[i - 1], layering[i]);
|
||||
}
|
||||
return cc;
|
||||
}
|
||||
|
||||
function twoLayerCrossCount(g, northLayer, southLayer) {
|
||||
// Sort all of the edges between the north and south layers by their position
|
||||
// in the north layer and then the south. Map these edges to the position of
|
||||
// their head in the south layer.
|
||||
let southPos = zipObject(
|
||||
southLayer,
|
||||
southLayer.map((v, i) => i)
|
||||
);
|
||||
let southEntries = northLayer.flatMap((v) => {
|
||||
return g
|
||||
.outEdges(v)
|
||||
.map((e) => {
|
||||
return { pos: southPos[e.w], weight: g.edge(e).weight };
|
||||
})
|
||||
.sort((a, b) => a.pos - b.pos);
|
||||
});
|
||||
|
||||
// Build the accumulator tree
|
||||
let firstIndex = 1;
|
||||
while (firstIndex < southLayer.length) firstIndex <<= 1;
|
||||
let treeSize = 2 * firstIndex - 1;
|
||||
firstIndex -= 1;
|
||||
let tree = new Array(treeSize).fill(0);
|
||||
|
||||
// Calculate the weighted crossings
|
||||
let cc = 0;
|
||||
southEntries.forEach((entry) => {
|
||||
let index = entry.pos + firstIndex;
|
||||
tree[index] += entry.weight;
|
||||
let weightSum = 0;
|
||||
while (index > 0) {
|
||||
if (index % 2) {
|
||||
weightSum += tree[index + 1];
|
||||
}
|
||||
index = (index - 1) >> 1;
|
||||
tree[index] += entry.weight;
|
||||
}
|
||||
cc += entry.weight * weightSum;
|
||||
});
|
||||
|
||||
return cc;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import initOrder from './init-order';
|
||||
import crossCount from './cross-count';
|
||||
import sortSubgraph from './sort-subgraph';
|
||||
import buildLayerGraph from './build-layer-graph';
|
||||
import addSubgraphConstraints from './add-subgraph-constraints';
|
||||
import { Graph } from '@dagrejs/graphlib';
|
||||
import { util } from '../util';
|
||||
|
||||
export default order;
|
||||
|
||||
/*
|
||||
* Applies heuristics to minimize edge crossings in the graph and sets the best
|
||||
* order solution as an order attribute on each node.
|
||||
*
|
||||
* Pre-conditions:
|
||||
*
|
||||
* 1. Graph must be DAG
|
||||
* 2. Graph nodes must be objects with a "rank" attribute
|
||||
* 3. Graph edges must have the "weight" attribute
|
||||
*
|
||||
* Post-conditions:
|
||||
*
|
||||
* 1. Graph nodes will have an "order" attribute based on the results of the
|
||||
* algorithm.
|
||||
*/
|
||||
function order(g, opts) {
|
||||
if (opts && typeof opts.customOrder === 'function') {
|
||||
opts.customOrder(g, order);
|
||||
return;
|
||||
}
|
||||
|
||||
let maxRank = util.maxRank(g),
|
||||
downLayerGraphs = buildLayerGraphs(g, util.range(1, maxRank + 1), 'inEdges'),
|
||||
upLayerGraphs = buildLayerGraphs(g, util.range(maxRank - 1, -1, -1), 'outEdges');
|
||||
|
||||
let layering = initOrder(g);
|
||||
assignOrder(g, layering);
|
||||
|
||||
if (opts && opts.disableOptimalOrderHeuristic) {
|
||||
return;
|
||||
}
|
||||
|
||||
let bestCC = Number.POSITIVE_INFINITY,
|
||||
best;
|
||||
|
||||
for (let i = 0, lastBest = 0; lastBest < 4; ++i, ++lastBest) {
|
||||
sweepLayerGraphs(i % 2 ? downLayerGraphs : upLayerGraphs, i % 4 >= 2);
|
||||
|
||||
layering = util.buildLayerMatrix(g);
|
||||
let cc = crossCount(g, layering);
|
||||
if (cc < bestCC) {
|
||||
lastBest = 0;
|
||||
best = Object.assign({}, layering);
|
||||
bestCC = cc;
|
||||
}
|
||||
}
|
||||
|
||||
assignOrder(g, best);
|
||||
}
|
||||
|
||||
function buildLayerGraphs(g, ranks, relationship) {
|
||||
return ranks.map(function (rank) {
|
||||
return buildLayerGraph(g, rank, relationship);
|
||||
});
|
||||
}
|
||||
|
||||
function sweepLayerGraphs(layerGraphs, biasRight) {
|
||||
let cg = new Graph();
|
||||
layerGraphs.forEach(function (lg) {
|
||||
let root = lg.graph().root;
|
||||
let sorted = sortSubgraph(lg, root, cg, biasRight);
|
||||
sorted.vs.forEach((v, i) => (lg.node(v).order = i));
|
||||
addSubgraphConstraints(lg, cg, sorted.vs);
|
||||
});
|
||||
}
|
||||
|
||||
function assignOrder(g, layering) {
|
||||
Object.values(layering).forEach((layer) => layer.forEach((v, i) => (g.node(v).order = i)));
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { util } from '../util';
|
||||
|
||||
export { initOrder };
|
||||
export default initOrder;
|
||||
|
||||
/*
|
||||
* Assigns an initial order value for each node by performing a DFS search
|
||||
* starting from nodes in the first rank. Nodes are assigned an order in their
|
||||
* rank as they are first visited.
|
||||
*
|
||||
* This approach comes from Gansner, et al., "A Technique for Drawing Directed
|
||||
* Graphs."
|
||||
*
|
||||
* Returns a layering matrix with an array per layer and each layer sorted by
|
||||
* the order of its nodes.
|
||||
*/
|
||||
function initOrder(g) {
|
||||
let visited = {};
|
||||
let simpleNodes = g.nodes().filter((v) => !g.children(v).length);
|
||||
let simpleNodesRanks = simpleNodes.map((v) => g.node(v).rank);
|
||||
let maxRank = util.applyWithChunking(Math.max, simpleNodesRanks);
|
||||
let layers = util.range(maxRank + 1).map(() => []);
|
||||
|
||||
function dfs(v) {
|
||||
if (visited[v]) return;
|
||||
visited[v] = true;
|
||||
let node = g.node(v);
|
||||
layers[node.rank].push(v);
|
||||
g.successors(v).forEach(dfs);
|
||||
}
|
||||
|
||||
let orderedVs = simpleNodes.sort((a, b) => g.node(a).rank - g.node(b).rank);
|
||||
orderedVs.forEach(dfs);
|
||||
|
||||
return layers;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import util from '../util';
|
||||
|
||||
export { resolveConflicts };
|
||||
export default resolveConflicts;
|
||||
|
||||
/*
|
||||
* Given a list of entries of the form {v, barycenter, weight} and a
|
||||
* constraint graph this function will resolve any conflicts between the
|
||||
* constraint graph and the barycenters for the entries. If the barycenters for
|
||||
* an entry would violate a constraint in the constraint graph then we coalesce
|
||||
* the nodes in the conflict into a new node that respects the contraint and
|
||||
* aggregates barycenter and weight information.
|
||||
*
|
||||
* This implementation is based on the description in Forster, "A Fast and
|
||||
* Simple Hueristic for Constrained Two-Level Crossing Reduction," thought it
|
||||
* differs in some specific details.
|
||||
*
|
||||
* Pre-conditions:
|
||||
*
|
||||
* 1. Each entry has the form {v, barycenter, weight}, or if the node has
|
||||
* no barycenter, then {v}.
|
||||
*
|
||||
* Returns:
|
||||
*
|
||||
* A new list of entries of the form {vs, i, barycenter, weight}. The list
|
||||
* `vs` may either be a singleton or it may be an aggregation of nodes
|
||||
* ordered such that they do not violate constraints from the constraint
|
||||
* graph. The property `i` is the lowest original index of any of the
|
||||
* elements in `vs`.
|
||||
*/
|
||||
function resolveConflicts(entries, cg) {
|
||||
let mappedEntries = {};
|
||||
entries.forEach((entry, i) => {
|
||||
let tmp = (mappedEntries[entry.v] = {
|
||||
indegree: 0,
|
||||
in: [],
|
||||
out: [],
|
||||
vs: [entry.v],
|
||||
i: i,
|
||||
});
|
||||
if (entry.barycenter !== undefined) {
|
||||
tmp.barycenter = entry.barycenter;
|
||||
tmp.weight = entry.weight;
|
||||
}
|
||||
});
|
||||
|
||||
cg.edges().forEach((e) => {
|
||||
let entryV = mappedEntries[e.v];
|
||||
let entryW = mappedEntries[e.w];
|
||||
if (entryV !== undefined && entryW !== undefined) {
|
||||
entryW.indegree++;
|
||||
entryV.out.push(mappedEntries[e.w]);
|
||||
}
|
||||
});
|
||||
|
||||
let sourceSet = Object.values(mappedEntries).filter((entry) => !entry.indegree);
|
||||
|
||||
return doResolveConflicts(sourceSet);
|
||||
}
|
||||
|
||||
function doResolveConflicts(sourceSet) {
|
||||
let entries = [];
|
||||
|
||||
function handleIn(vEntry) {
|
||||
return (uEntry) => {
|
||||
if (uEntry.merged) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
uEntry.barycenter === undefined ||
|
||||
vEntry.barycenter === undefined ||
|
||||
uEntry.barycenter >= vEntry.barycenter
|
||||
) {
|
||||
mergeEntries(vEntry, uEntry);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleOut(vEntry) {
|
||||
return (wEntry) => {
|
||||
wEntry['in'].push(vEntry);
|
||||
if (--wEntry.indegree === 0) {
|
||||
sourceSet.push(wEntry);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
while (sourceSet.length) {
|
||||
let entry = sourceSet.pop();
|
||||
entries.push(entry);
|
||||
entry['in'].reverse().forEach(handleIn(entry));
|
||||
entry.out.forEach(handleOut(entry));
|
||||
}
|
||||
|
||||
return entries
|
||||
.filter((entry) => !entry.merged)
|
||||
.map((entry) => {
|
||||
return util.pick(entry, ['vs', 'i', 'barycenter', 'weight']);
|
||||
});
|
||||
}
|
||||
|
||||
function mergeEntries(target, source) {
|
||||
let sum = 0;
|
||||
let weight = 0;
|
||||
|
||||
if (target.weight) {
|
||||
sum += target.barycenter * target.weight;
|
||||
weight += target.weight;
|
||||
}
|
||||
|
||||
if (source.weight) {
|
||||
sum += source.barycenter * source.weight;
|
||||
weight += source.weight;
|
||||
}
|
||||
|
||||
target.vs = source.vs.concat(target.vs);
|
||||
target.barycenter = sum / weight;
|
||||
target.weight = weight;
|
||||
target.i = Math.min(source.i, target.i);
|
||||
source.merged = true;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import barycenter from './barycenter';
|
||||
import resolveConflicts from './resolve-conflicts';
|
||||
import sort from './sort';
|
||||
|
||||
export { sortSubgraph };
|
||||
export default sortSubgraph;
|
||||
|
||||
function sortSubgraph(g, v, cg, biasRight) {
|
||||
let movable = g.children(v);
|
||||
let node = g.node(v);
|
||||
let bl = node ? node.borderLeft : undefined;
|
||||
let br = node ? node.borderRight : undefined;
|
||||
let subgraphs = {};
|
||||
|
||||
if (bl) {
|
||||
movable = movable.filter((w) => w !== bl && w !== br);
|
||||
}
|
||||
|
||||
let barycenters = barycenter(g, movable);
|
||||
barycenters.forEach((entry) => {
|
||||
if (g.children(entry.v).length) {
|
||||
let subgraphResult = sortSubgraph(g, entry.v, cg, biasRight);
|
||||
subgraphs[entry.v] = subgraphResult;
|
||||
if (Object.hasOwn(subgraphResult, 'barycenter')) {
|
||||
mergeBarycenters(entry, subgraphResult);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let entries = resolveConflicts(barycenters, cg);
|
||||
expandSubgraphs(entries, subgraphs);
|
||||
|
||||
let result = sort(entries, biasRight);
|
||||
|
||||
if (bl) {
|
||||
result.vs = [bl, result.vs, br].flat(true);
|
||||
if (g.predecessors(bl).length) {
|
||||
let blPred = g.node(g.predecessors(bl)[0]),
|
||||
brPred = g.node(g.predecessors(br)[0]);
|
||||
if (!Object.hasOwn(result, 'barycenter')) {
|
||||
result.barycenter = 0;
|
||||
result.weight = 0;
|
||||
}
|
||||
result.barycenter =
|
||||
(result.barycenter * result.weight + blPred.order + brPred.order) / (result.weight + 2);
|
||||
result.weight += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function expandSubgraphs(entries, subgraphs) {
|
||||
entries.forEach((entry) => {
|
||||
entry.vs = entry.vs.flatMap((v) => {
|
||||
if (subgraphs[v]) {
|
||||
return subgraphs[v].vs;
|
||||
}
|
||||
return v;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mergeBarycenters(target, other) {
|
||||
if (target.barycenter !== undefined) {
|
||||
target.barycenter =
|
||||
(target.barycenter * target.weight + other.barycenter * other.weight) /
|
||||
(target.weight + other.weight);
|
||||
target.weight += other.weight;
|
||||
} else {
|
||||
target.barycenter = other.barycenter;
|
||||
target.weight = other.weight;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import util from '../util';
|
||||
|
||||
export { sort };
|
||||
export default sort;
|
||||
|
||||
function sort(entries, biasRight) {
|
||||
let parts = util.partition(entries, (entry) => {
|
||||
return Object.hasOwn(entry, 'barycenter');
|
||||
});
|
||||
let sortable = parts.lhs,
|
||||
unsortable = parts.rhs.sort((a, b) => b.i - a.i),
|
||||
vs = [],
|
||||
sum = 0,
|
||||
weight = 0,
|
||||
vsIndex = 0;
|
||||
|
||||
sortable.sort(compareWithBias(!!biasRight));
|
||||
|
||||
vsIndex = consumeUnsortable(vs, unsortable, vsIndex);
|
||||
|
||||
sortable.forEach((entry) => {
|
||||
vsIndex += entry.vs.length;
|
||||
vs.push(entry.vs);
|
||||
sum += entry.barycenter * entry.weight;
|
||||
weight += entry.weight;
|
||||
vsIndex = consumeUnsortable(vs, unsortable, vsIndex);
|
||||
});
|
||||
|
||||
let result = { vs: vs.flat(true) };
|
||||
if (weight) {
|
||||
result.barycenter = sum / weight;
|
||||
result.weight = weight;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function consumeUnsortable(vs, unsortable, index) {
|
||||
let last;
|
||||
while (unsortable.length && (last = unsortable[unsortable.length - 1]).i <= index) {
|
||||
unsortable.pop();
|
||||
vs.push(last.vs);
|
||||
index++;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function compareWithBias(bias) {
|
||||
return (entryV, entryW) => {
|
||||
if (entryV.barycenter < entryW.barycenter) {
|
||||
return -1;
|
||||
} else if (entryV.barycenter > entryW.barycenter) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return !bias ? entryV.i - entryW.i : entryW.i - entryV.i;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { parentDummyChains };
|
||||
export default parentDummyChains;
|
||||
|
||||
function parentDummyChains(g) {
|
||||
let postorderNums = postorder(g);
|
||||
|
||||
g.graph().dummyChains.forEach((v) => {
|
||||
let node = g.node(v);
|
||||
let edgeObj = node.edgeObj;
|
||||
let pathData = findPath(g, postorderNums, edgeObj.v, edgeObj.w);
|
||||
let path = pathData.path;
|
||||
let lca = pathData.lca;
|
||||
let pathIdx = 0;
|
||||
let pathV = path[pathIdx];
|
||||
let ascending = true;
|
||||
|
||||
while (v !== edgeObj.w) {
|
||||
node = g.node(v);
|
||||
|
||||
if (ascending) {
|
||||
while ((pathV = path[pathIdx]) !== lca && g.node(pathV).maxRank < node.rank) {
|
||||
pathIdx++;
|
||||
}
|
||||
|
||||
if (pathV === lca) {
|
||||
ascending = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ascending) {
|
||||
while (
|
||||
pathIdx < path.length - 1 &&
|
||||
g.node((pathV = path[pathIdx + 1])).minRank <= node.rank
|
||||
) {
|
||||
pathIdx++;
|
||||
}
|
||||
pathV = path[pathIdx];
|
||||
}
|
||||
|
||||
g.setParent(v, pathV);
|
||||
v = g.successors(v)[0];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find a path from v to w through the lowest common ancestor (LCA). Return the
|
||||
// full path and the LCA.
|
||||
function findPath(g, postorderNums, v, w) {
|
||||
let vPath = [];
|
||||
let wPath = [];
|
||||
let low = Math.min(postorderNums[v].low, postorderNums[w].low);
|
||||
let lim = Math.max(postorderNums[v].lim, postorderNums[w].lim);
|
||||
let parent;
|
||||
let lca;
|
||||
|
||||
// Traverse up from v to find the LCA
|
||||
parent = v;
|
||||
do {
|
||||
parent = g.parent(parent);
|
||||
vPath.push(parent);
|
||||
} while (parent && (postorderNums[parent].low > low || lim > postorderNums[parent].lim));
|
||||
lca = parent;
|
||||
|
||||
// Traverse from w to LCA
|
||||
parent = w;
|
||||
while ((parent = g.parent(parent)) !== lca) {
|
||||
wPath.push(parent);
|
||||
}
|
||||
|
||||
return { path: vPath.concat(wPath.reverse()), lca: lca };
|
||||
}
|
||||
|
||||
function postorder(g) {
|
||||
let result = {};
|
||||
let lim = 0;
|
||||
|
||||
function dfs(v) {
|
||||
let low = lim;
|
||||
g.children(v).forEach(dfs);
|
||||
result[v] = { low: low, lim: lim++ };
|
||||
}
|
||||
g.children().forEach(dfs);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { Graph } from '@dagrejs/graphlib';
|
||||
import { util } from '../util';
|
||||
|
||||
/*
|
||||
* This module provides coordinate assignment based on Brandes and Köpf, "Fast
|
||||
* and Simple Horizontal Coordinate Assignment."
|
||||
*/
|
||||
|
||||
export {
|
||||
positionX,
|
||||
findType1Conflicts,
|
||||
findType2Conflicts,
|
||||
addConflict,
|
||||
hasConflict,
|
||||
verticalAlignment,
|
||||
horizontalCompaction,
|
||||
alignCoordinates,
|
||||
findSmallestWidthAlignment,
|
||||
balance,
|
||||
};
|
||||
|
||||
/*
|
||||
* Marks all edges in the graph with a type-1 conflict with the "type1Conflict"
|
||||
* property. A type-1 conflict is one where a non-inner segment crosses an
|
||||
* inner segment. An inner segment is an edge with both incident nodes marked
|
||||
* with the "dummy" property.
|
||||
*
|
||||
* This algorithm scans layer by layer, starting with the second, for type-1
|
||||
* conflicts between the current layer and the previous layer. For each layer
|
||||
* it scans the nodes from left to right until it reaches one that is incident
|
||||
* on an inner segment. It then scans predecessors to determine if they have
|
||||
* edges that cross that inner segment. At the end a final scan is done for all
|
||||
* nodes on the current rank to see if they cross the last visited inner
|
||||
* segment.
|
||||
*
|
||||
* This algorithm (safely) assumes that a dummy node will only be incident on a
|
||||
* single node in the layers being scanned.
|
||||
*/
|
||||
function findType1Conflicts(g, layering) {
|
||||
let conflicts = {};
|
||||
|
||||
function visitLayer(prevLayer, layer) {
|
||||
let // last visited node in the previous layer that is incident on an inner
|
||||
// segment.
|
||||
k0 = 0,
|
||||
// Tracks the last node in this layer scanned for crossings with a type-1
|
||||
// segment.
|
||||
scanPos = 0,
|
||||
prevLayerLength = prevLayer.length,
|
||||
lastNode = layer[layer.length - 1];
|
||||
|
||||
layer.forEach((v, i) => {
|
||||
let w = findOtherInnerSegmentNode(g, v),
|
||||
k1 = w ? g.node(w).order : prevLayerLength;
|
||||
|
||||
if (w || v === lastNode) {
|
||||
layer.slice(scanPos, i + 1).forEach((scanNode) => {
|
||||
g.predecessors(scanNode).forEach((u) => {
|
||||
let uLabel = g.node(u),
|
||||
uPos = uLabel.order;
|
||||
if ((uPos < k0 || k1 < uPos) && !(uLabel.dummy && g.node(scanNode).dummy)) {
|
||||
addConflict(conflicts, u, scanNode);
|
||||
}
|
||||
});
|
||||
});
|
||||
scanPos = i + 1;
|
||||
k0 = k1;
|
||||
}
|
||||
});
|
||||
|
||||
return layer;
|
||||
}
|
||||
|
||||
layering.length && layering.reduce(visitLayer);
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
function findType2Conflicts(g, layering) {
|
||||
let conflicts = {};
|
||||
|
||||
function scan(south, southPos, southEnd, prevNorthBorder, nextNorthBorder) {
|
||||
let v;
|
||||
util.range(southPos, southEnd).forEach((i) => {
|
||||
v = south[i];
|
||||
if (g.node(v).dummy) {
|
||||
g.predecessors(v).forEach((u) => {
|
||||
let uNode = g.node(u);
|
||||
if (uNode.dummy && (uNode.order < prevNorthBorder || uNode.order > nextNorthBorder)) {
|
||||
addConflict(conflicts, u, v);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function visitLayer(north, south) {
|
||||
let prevNorthPos = -1,
|
||||
nextNorthPos,
|
||||
southPos = 0;
|
||||
|
||||
south.forEach((v, southLookahead) => {
|
||||
if (g.node(v).dummy === 'border') {
|
||||
let predecessors = g.predecessors(v);
|
||||
if (predecessors.length) {
|
||||
nextNorthPos = g.node(predecessors[0]).order;
|
||||
scan(south, southPos, southLookahead, prevNorthPos, nextNorthPos);
|
||||
southPos = southLookahead;
|
||||
prevNorthPos = nextNorthPos;
|
||||
}
|
||||
}
|
||||
scan(south, southPos, south.length, nextNorthPos, north.length);
|
||||
});
|
||||
|
||||
return south;
|
||||
}
|
||||
|
||||
layering.length && layering.reduce(visitLayer);
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
function findOtherInnerSegmentNode(g, v) {
|
||||
if (g.node(v).dummy) {
|
||||
return g.predecessors(v).find((u) => g.node(u).dummy);
|
||||
}
|
||||
}
|
||||
|
||||
function addConflict(conflicts, v, w) {
|
||||
if (v > w) {
|
||||
let tmp = v;
|
||||
v = w;
|
||||
w = tmp;
|
||||
}
|
||||
|
||||
let conflictsV = conflicts[v];
|
||||
if (!conflictsV) {
|
||||
conflicts[v] = conflictsV = {};
|
||||
}
|
||||
conflictsV[w] = true;
|
||||
}
|
||||
|
||||
function hasConflict(conflicts, v, w) {
|
||||
if (v > w) {
|
||||
let tmp = v;
|
||||
v = w;
|
||||
w = tmp;
|
||||
}
|
||||
return !!conflicts[v] && Object.hasOwn(conflicts[v], w);
|
||||
}
|
||||
|
||||
/*
|
||||
* Try to align nodes into vertical "blocks" where possible. This algorithm
|
||||
* attempts to align a node with one of its median neighbors. If the edge
|
||||
* connecting a neighbor is a type-1 conflict then we ignore that possibility.
|
||||
* If a previous node has already formed a block with a node after the node
|
||||
* we're trying to form a block with, we also ignore that possibility - our
|
||||
* blocks would be split in that scenario.
|
||||
*/
|
||||
function verticalAlignment(g, layering, conflicts, neighborFn) {
|
||||
let root = {},
|
||||
align = {},
|
||||
pos = {};
|
||||
|
||||
// We cache the position here based on the layering because the graph and
|
||||
// layering may be out of sync. The layering matrix is manipulated to
|
||||
// generate different extreme alignments.
|
||||
layering.forEach((layer) => {
|
||||
layer.forEach((v, order) => {
|
||||
root[v] = v;
|
||||
align[v] = v;
|
||||
pos[v] = order;
|
||||
});
|
||||
});
|
||||
|
||||
layering.forEach((layer) => {
|
||||
let prevIdx = -1;
|
||||
layer.forEach((v) => {
|
||||
let ws = neighborFn(v);
|
||||
if (ws.length) {
|
||||
ws = ws.sort((a, b) => pos[a] - pos[b]);
|
||||
let mp = (ws.length - 1) / 2;
|
||||
for (let i = Math.floor(mp), il = Math.ceil(mp); i <= il; ++i) {
|
||||
let w = ws[i];
|
||||
if (align[v] === v && prevIdx < pos[w] && !hasConflict(conflicts, v, w)) {
|
||||
align[w] = v;
|
||||
align[v] = root[v] = root[w];
|
||||
prevIdx = pos[w];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { root: root, align: align };
|
||||
}
|
||||
|
||||
function horizontalCompaction(g, layering, root, align, reverseSep) {
|
||||
// This portion of the algorithm differs from BK due to a number of problems.
|
||||
// Instead of their algorithm we construct a new block graph and do two
|
||||
// sweeps. The first sweep places blocks with the smallest possible
|
||||
// coordinates. The second sweep removes unused space by moving blocks to the
|
||||
// greatest coordinates without violating separation.
|
||||
let xs = {},
|
||||
blockG = buildBlockGraph(g, layering, root, reverseSep),
|
||||
borderType = reverseSep ? 'borderLeft' : 'borderRight';
|
||||
|
||||
function iterate(setXsFunc, nextNodesFunc) {
|
||||
let stack = blockG.nodes();
|
||||
let elem = stack.pop();
|
||||
let visited = {};
|
||||
while (elem) {
|
||||
if (visited[elem]) {
|
||||
setXsFunc(elem);
|
||||
} else {
|
||||
visited[elem] = true;
|
||||
stack.push(elem);
|
||||
stack = stack.concat(nextNodesFunc(elem));
|
||||
}
|
||||
|
||||
elem = stack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
// First pass, assign smallest coordinates
|
||||
function pass1(elem) {
|
||||
xs[elem] = blockG.inEdges(elem).reduce((acc, e) => {
|
||||
return Math.max(acc, xs[e.v] + blockG.edge(e));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Second pass, assign greatest coordinates
|
||||
function pass2(elem) {
|
||||
let min = blockG.outEdges(elem).reduce((acc, e) => {
|
||||
return Math.min(acc, xs[e.w] - blockG.edge(e));
|
||||
}, Number.POSITIVE_INFINITY);
|
||||
|
||||
let node = g.node(elem);
|
||||
if (min !== Number.POSITIVE_INFINITY && node.borderType !== borderType) {
|
||||
xs[elem] = Math.max(xs[elem], min);
|
||||
}
|
||||
}
|
||||
|
||||
iterate(pass1, blockG.predecessors.bind(blockG));
|
||||
iterate(pass2, blockG.successors.bind(blockG));
|
||||
|
||||
// Assign x coordinates to all nodes
|
||||
Object.keys(align).forEach((v) => (xs[v] = xs[root[v]]));
|
||||
|
||||
return xs;
|
||||
}
|
||||
|
||||
function buildBlockGraph(g, layering, root, reverseSep) {
|
||||
let blockGraph = new Graph(),
|
||||
graphLabel = g.graph(),
|
||||
sepFn = sep(graphLabel.nodesep, graphLabel.edgesep, reverseSep);
|
||||
|
||||
layering.forEach((layer) => {
|
||||
let u;
|
||||
layer.forEach((v) => {
|
||||
let vRoot = root[v];
|
||||
blockGraph.setNode(vRoot);
|
||||
if (u) {
|
||||
var uRoot = root[u],
|
||||
prevMax = blockGraph.edge(uRoot, vRoot);
|
||||
blockGraph.setEdge(uRoot, vRoot, Math.max(sepFn(g, v, u), prevMax || 0));
|
||||
}
|
||||
u = v;
|
||||
});
|
||||
});
|
||||
|
||||
return blockGraph;
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns the alignment that has the smallest width of the given alignments.
|
||||
*/
|
||||
function findSmallestWidthAlignment(g, xss) {
|
||||
return Object.values(xss).reduce(
|
||||
(currentMinAndXs, xs) => {
|
||||
let max = Number.NEGATIVE_INFINITY;
|
||||
let min = Number.POSITIVE_INFINITY;
|
||||
|
||||
Object.entries(xs).forEach(([v, x]) => {
|
||||
let halfWidth = width(g, v) / 2;
|
||||
|
||||
max = Math.max(x + halfWidth, max);
|
||||
min = Math.min(x - halfWidth, min);
|
||||
});
|
||||
|
||||
const newMin = max - min;
|
||||
if (newMin < currentMinAndXs[0]) {
|
||||
currentMinAndXs = [newMin, xs];
|
||||
}
|
||||
return currentMinAndXs;
|
||||
},
|
||||
[Number.POSITIVE_INFINITY, null]
|
||||
)[1];
|
||||
}
|
||||
|
||||
/*
|
||||
* Align the coordinates of each of the layout alignments such that
|
||||
* left-biased alignments have their minimum coordinate at the same point as
|
||||
* the minimum coordinate of the smallest width alignment and right-biased
|
||||
* alignments have their maximum coordinate at the same point as the maximum
|
||||
* coordinate of the smallest width alignment.
|
||||
*/
|
||||
function alignCoordinates(xss, alignTo) {
|
||||
let alignToVals = Object.values(alignTo),
|
||||
alignToMin = util.applyWithChunking(Math.min, alignToVals),
|
||||
alignToMax = util.applyWithChunking(Math.max, alignToVals);
|
||||
|
||||
['u', 'd'].forEach((vert) => {
|
||||
['l', 'r'].forEach((horiz) => {
|
||||
let alignment = vert + horiz,
|
||||
xs = xss[alignment];
|
||||
|
||||
if (xs === alignTo) return;
|
||||
|
||||
let xsVals = Object.values(xs);
|
||||
let delta = alignToMin - util.applyWithChunking(Math.min, xsVals);
|
||||
if (horiz !== 'l') {
|
||||
delta = alignToMax - util.applyWithChunking(Math.max, xsVals);
|
||||
}
|
||||
|
||||
if (delta) {
|
||||
xss[alignment] = util.mapValues(xs, (x) => x + delta);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function balance(xss, align) {
|
||||
return util.mapValues(xss.ul, (num, v) => {
|
||||
if (align) {
|
||||
return xss[align.toLowerCase()][v];
|
||||
} else {
|
||||
let xs = Object.values(xss)
|
||||
.map((xs) => xs[v])
|
||||
.sort((a, b) => a - b);
|
||||
return (xs[1] + xs[2]) / 2;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function positionX(g) {
|
||||
let layering = util.buildLayerMatrix(g);
|
||||
let conflicts = Object.assign(findType1Conflicts(g, layering), findType2Conflicts(g, layering));
|
||||
|
||||
let xss = {};
|
||||
let adjustedLayering;
|
||||
['u', 'd'].forEach((vert) => {
|
||||
adjustedLayering = vert === 'u' ? layering : Object.values(layering).reverse();
|
||||
['l', 'r'].forEach((horiz) => {
|
||||
if (horiz === 'r') {
|
||||
adjustedLayering = adjustedLayering.map((inner) => {
|
||||
return Object.values(inner).reverse();
|
||||
});
|
||||
}
|
||||
|
||||
let neighborFn = (vert === 'u' ? g.predecessors : g.successors).bind(g);
|
||||
let align = verticalAlignment(g, adjustedLayering, conflicts, neighborFn);
|
||||
let xs = horizontalCompaction(g, adjustedLayering, align.root, align.align, horiz === 'r');
|
||||
if (horiz === 'r') {
|
||||
xs = util.mapValues(xs, (x) => -x);
|
||||
}
|
||||
xss[vert + horiz] = xs;
|
||||
});
|
||||
});
|
||||
|
||||
let smallestWidth = findSmallestWidthAlignment(g, xss);
|
||||
alignCoordinates(xss, smallestWidth);
|
||||
return balance(xss, g.graph().align);
|
||||
}
|
||||
|
||||
function sep(nodeSep, edgeSep, reverseSep) {
|
||||
return (g, v, w) => {
|
||||
let vLabel = g.node(v);
|
||||
let wLabel = g.node(w);
|
||||
let sum = 0;
|
||||
let delta;
|
||||
|
||||
sum += vLabel.width / 2;
|
||||
if (Object.hasOwn(vLabel, 'labelpos')) {
|
||||
switch (vLabel.labelpos.toLowerCase()) {
|
||||
case 'l':
|
||||
delta = -vLabel.width / 2;
|
||||
break;
|
||||
case 'r':
|
||||
delta = vLabel.width / 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (delta) {
|
||||
sum += reverseSep ? delta : -delta;
|
||||
}
|
||||
delta = 0;
|
||||
|
||||
sum += (vLabel.dummy ? edgeSep : nodeSep) / 2;
|
||||
sum += (wLabel.dummy ? edgeSep : nodeSep) / 2;
|
||||
|
||||
sum += wLabel.width / 2;
|
||||
if (Object.hasOwn(wLabel, 'labelpos')) {
|
||||
switch (wLabel.labelpos.toLowerCase()) {
|
||||
case 'l':
|
||||
delta = wLabel.width / 2;
|
||||
break;
|
||||
case 'r':
|
||||
delta = -wLabel.width / 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (delta) {
|
||||
sum += reverseSep ? delta : -delta;
|
||||
}
|
||||
delta = 0;
|
||||
|
||||
return sum;
|
||||
};
|
||||
}
|
||||
|
||||
function width(g, v) {
|
||||
return g.node(v).width;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import util from '../util';
|
||||
import { positionX } from './bk';
|
||||
|
||||
export { position };
|
||||
export default position;
|
||||
|
||||
function position(g) {
|
||||
g = util.asNonCompoundGraph(g);
|
||||
|
||||
positionY(g);
|
||||
Object.entries(positionX(g)).forEach(([v, x]) => (g.node(v).x = x));
|
||||
}
|
||||
|
||||
function positionY(g) {
|
||||
let layering = util.buildLayerMatrix(g);
|
||||
let rankSep = g.graph().ranksep;
|
||||
let prevY = 0;
|
||||
layering.forEach((layer) => {
|
||||
const maxHeight = layer.reduce((acc, v) => {
|
||||
const height = g.node(v).height;
|
||||
if (acc > height) {
|
||||
return acc;
|
||||
} else {
|
||||
return height;
|
||||
}
|
||||
}, 0);
|
||||
layer.forEach((v) => (g.node(v).y = prevY + maxHeight / 2));
|
||||
prevY += maxHeight + rankSep;
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user