From 6072423299b68f6ca957a4ed8731404880bff0be Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Date: Mon, 19 Feb 2024 07:41:21 -0500 Subject: [PATCH] feat(pipelines): Support top to bottom pipelines --- .../demo-app-ts/src/demos/PipelineLayout.tsx | 18 +++-- .../src/demos/StatusConnectors.tsx | 3 +- .../src/utils/usePipelineOptions.tsx | 19 +++-- packages/module/src/elements/BaseGraph.ts | 11 +++ packages/module/src/layouts/BaseLayout.ts | 4 + packages/module/src/layouts/DagreLayout.ts | 5 +- .../anchors/TaskNodeSourceAnchor.ts | 16 +++- .../anchors/TaskNodeTargetAnchor.ts | 18 ++++- .../pipelines/components/edges/TaskEdge.tsx | 4 +- .../pipelines/components/nodes/TaskNode.tsx | 76 +++++++++++-------- .../pipelines/decorators/WhenDecorator.tsx | 64 +++++++++++----- .../pipelines/layouts/PipelineDagreLayout.ts | 4 +- .../module/src/pipelines/utils/draw-utils.ts | 61 ++++++++++++++- packages/module/src/types.ts | 3 + 14 files changed, 232 insertions(+), 74 deletions(-) diff --git a/packages/demo-app-ts/src/demos/PipelineLayout.tsx b/packages/demo-app-ts/src/demos/PipelineLayout.tsx index 0dab1ceb..19096ffe 100644 --- a/packages/demo-app-ts/src/demos/PipelineLayout.tsx +++ b/packages/demo-app-ts/src/demos/PipelineLayout.tsx @@ -17,7 +17,9 @@ import { getEdgesFromNodes, DEFAULT_EDGE_TYPE, DEFAULT_SPACER_NODE_TYPE, - DEFAULT_FINALLY_NODE_TYPE + DEFAULT_FINALLY_NODE_TYPE, + TOP_TO_BOTTOM, + LEFT_TO_RIGHT } from '@patternfly/react-topology'; import pipelineComponentFactory, { GROUPED_EDGE_TYPE } from '../components/pipelineComponentFactory'; import { usePipelineOptions } from '../utils/usePipelineOptions'; @@ -28,14 +30,15 @@ export const PIPELINE_NODE_SEPARATION_VERTICAL = 65; export const LAYOUT_TITLE = 'Layout'; +const GROUP_PREFIX = 'Grouped_'; +const VERTICAL_SUFFIX = '_Vertical'; const PIPELINE_LAYOUT = 'PipelineLayout'; -const GROUPED_PIPELINE_LAYOUT = 'GroupedPipelineLayout'; const TopologyPipelineLayout: React.FC = () => { const [selectedIds, setSelectedIds] = React.useState(); const controller = useVisualizationController(); - const { contextToolbar, showContextMenu, showBadges, showIcons, showGroups, badgeTooltips } = usePipelineOptions( + const { contextToolbar, showContextMenu, showBadges, showIcons, showGroups, badgeTooltips, verticalLayout } = usePipelineOptions( true ); const pipelineNodes = useDemoPipelineNodes( @@ -43,7 +46,7 @@ const TopologyPipelineLayout: React.FC = () => { showBadges, showIcons, badgeTooltips, - 'PipelineDagreLayout', + controller.getGraph().getLayout(), showGroups ); @@ -67,7 +70,7 @@ const TopologyPipelineLayout: React.FC = () => { type: 'graph', x: 25, y: 25, - layout: showGroups ? GROUPED_PIPELINE_LAYOUT : PIPELINE_LAYOUT + layout: `${showGroups ? GROUP_PREFIX : ''}${PIPELINE_LAYOUT}${verticalLayout ? VERTICAL_SUFFIX : ''}` }, nodes, edges @@ -75,7 +78,7 @@ const TopologyPipelineLayout: React.FC = () => { true ); controller.getGraph().layout(); - }, [controller, pipelineNodes, showGroups]); + }, [controller, pipelineNodes, showGroups, verticalLayout]); useEventListener(SELECTION_EVENT, ids => { setSelectedIds(ids); @@ -98,8 +101,9 @@ export const PipelineLayout = React.memo(() => { (type: string, graph: Graph): Layout | undefined => new PipelineDagreLayout(graph, { nodesep: PIPELINE_NODE_SEPARATION_VERTICAL, + rankdir: type.endsWith(VERTICAL_SUFFIX) ? TOP_TO_BOTTOM : LEFT_TO_RIGHT, ranksep: - type === GROUPED_PIPELINE_LAYOUT ? GROUPED_PIPELINE_NODE_SEPARATION_HORIZONTAL : NODE_SEPARATION_HORIZONTAL, + type.startsWith(GROUP_PREFIX) ? GROUPED_PIPELINE_NODE_SEPARATION_HORIZONTAL : NODE_SEPARATION_HORIZONTAL, ignoreGroups: true }) ); diff --git a/packages/demo-app-ts/src/demos/StatusConnectors.tsx b/packages/demo-app-ts/src/demos/StatusConnectors.tsx index e374b415..2a3975b8 100644 --- a/packages/demo-app-ts/src/demos/StatusConnectors.tsx +++ b/packages/demo-app-ts/src/demos/StatusConnectors.tsx @@ -8,6 +8,7 @@ import { Graph, Layout, LayoutFactory, + LEFT_TO_RIGHT, NODE_SEPARATION_HORIZONTAL, NodeShape, SELECTION_EVENT, @@ -52,7 +53,7 @@ const defaultLayoutFactory: LayoutFactory = (type: string, graph: Graph): Layout ranksep: NODE_SEPARATION_HORIZONTAL, edgesep: 100, ranker: 'longest-path', - rankdir: 'LR', + rankdir: LEFT_TO_RIGHT, marginx: 20, marginy: 20, }); diff --git a/packages/demo-app-ts/src/utils/usePipelineOptions.tsx b/packages/demo-app-ts/src/utils/usePipelineOptions.tsx index 9d2fb801..a28f07cd 100644 --- a/packages/demo-app-ts/src/utils/usePipelineOptions.tsx +++ b/packages/demo-app-ts/src/utils/usePipelineOptions.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Checkbox, ToolbarItem } from '@patternfly/react-core'; export const usePipelineOptions = ( - allowGroups = false + isLayout = false, ): { contextToolbar: React.ReactNode; showContextMenu: boolean; @@ -10,11 +10,13 @@ export const usePipelineOptions = ( showIcons: boolean; showGroups: boolean; badgeTooltips: boolean; + verticalLayout: boolean; } => { const [showContextMenu, setShowContextMenu] = React.useState(false); const [showBadges, setShowBadges] = React.useState(false); const [showIcons, setShowIcons] = React.useState(false); const [showGroups, setShowGroups] = React.useState(false); + const [verticalLayout, setVerticalLayout] = React.useState(false); const [badgeTooltips, setBadgeTooltips] = React.useState(false); const contextToolbar = ( @@ -31,13 +33,18 @@ export const usePipelineOptions = ( setShowContextMenu(checked)} label="Context menus" /> - {allowGroups ? ( - - setShowGroups(checked)} label="Show groups" /> - + {isLayout ? ( + <> + + setShowGroups(checked)} label="Show groups" /> + + + setVerticalLayout(checked)} label="Vertical layout" /> + + ) : null} ); - return { contextToolbar, showContextMenu, showBadges, showIcons, showGroups, badgeTooltips }; + return { contextToolbar, showContextMenu, showBadges, showIcons, showGroups, badgeTooltips, verticalLayout }; }; diff --git a/packages/module/src/elements/BaseGraph.ts b/packages/module/src/elements/BaseGraph.ts index 2543df04..86effc9d 100644 --- a/packages/module/src/elements/BaseGraph.ts +++ b/packages/module/src/elements/BaseGraph.ts @@ -19,6 +19,7 @@ import { ScaleDetailsThresholds } from '../types'; import BaseElement from './BaseElement'; +import { LayoutOptions } from '../layouts'; export default class BaseGraph extends BaseElement implements Graph { @@ -34,6 +35,8 @@ export default class BaseGraph exten private currentLayout?: Layout = undefined; + private layoutOptions?: LayoutOptions = undefined; + private scaleExtent: ScaleExtent = [0.25, 4]; constructor() { @@ -44,6 +47,7 @@ export default class BaseGraph exten | 'layers' | 'scale' | 'layoutType' + | 'layoutOptions' | 'dimensions' | 'position' | 'scaleExtent' @@ -55,6 +59,7 @@ export default class BaseGraph exten layers: observable.ref, scale: observable, layoutType: observable, + layoutOptions: observable.deep, dimensions: observable.ref, position: observable.ref, scaleExtent: observable.ref, @@ -175,6 +180,10 @@ export default class BaseGraph exten return this.layoutType; } + getLayoutOptions(): LayoutOptions | undefined { + return this.layoutOptions; + } + setLayout(layout: string | undefined): void { if (layout === this.layoutType) { return; @@ -182,10 +191,12 @@ export default class BaseGraph exten if (this.currentLayout) { this.currentLayout.destroy(); + this.layoutOptions = undefined; } this.layoutType = layout; this.currentLayout = layout ? this.getController().getLayout(layout) : undefined; + this.layoutOptions = this.currentLayout?.getLayoutOptions?.(); } layout(): void { diff --git a/packages/module/src/layouts/BaseLayout.ts b/packages/module/src/layouts/BaseLayout.ts index 97cfca0e..e8744962 100644 --- a/packages/module/src/layouts/BaseLayout.ts +++ b/packages/module/src/layouts/BaseLayout.ts @@ -82,6 +82,10 @@ export class BaseLayout implements Layout { this.startListening(); } + getLayoutOptions(): LayoutOptions { + return this.options; + } + protected onSimulationEnd = () => {}; destroy(): void { diff --git a/packages/module/src/layouts/DagreLayout.ts b/packages/module/src/layouts/DagreLayout.ts index 47eb11ae..c0967758 100644 --- a/packages/module/src/layouts/DagreLayout.ts +++ b/packages/module/src/layouts/DagreLayout.ts @@ -8,6 +8,9 @@ import { DagreNode } from './DagreNode'; import { DagreGroup } from './DagreGroup'; import { DagreLink } from './DagreLink'; +export const TOP_TO_BOTTOM = 'TB'; +export const LEFT_TO_RIGHT = 'LR'; + export type DagreLayoutOptions = LayoutOptions & dagre.GraphLabel & { ignoreGroups?: boolean }; export class DagreLayout extends BaseLayout implements Layout { @@ -23,7 +26,7 @@ export class DagreLayout extends BaseLayout implements Layout { nodesep: this.options.nodeDistance, edgesep: this.options.linkDistance, ranker: 'tight-tree', - rankdir: 'TB', + rankdir: TOP_TO_BOTTOM, ...options }; } diff --git a/packages/module/src/pipelines/components/anchors/TaskNodeSourceAnchor.ts b/packages/module/src/pipelines/components/anchors/TaskNodeSourceAnchor.ts index a03759cc..4083ae79 100644 --- a/packages/module/src/pipelines/components/anchors/TaskNodeSourceAnchor.ts +++ b/packages/module/src/pipelines/components/anchors/TaskNodeSourceAnchor.ts @@ -4,12 +4,14 @@ import { Node, ScaleDetailsLevel } from '../../../types'; export default class TaskNodeSourceAnchor extends AbstractAnchor { private detailsLevel: ScaleDetailsLevel; - private lowDetailsStatusIconWidth = 0; + private lowDetailsStatusIconSize = 0; + private vertical = false; - constructor(owner: E, detailsLevel: ScaleDetailsLevel, lowDetailsStatusIconWidth: number) { + constructor(owner: E, detailsLevel: ScaleDetailsLevel, lowDetailsStatusIconSize: number, vertical: boolean = false) { super(owner); this.detailsLevel = detailsLevel; - this.lowDetailsStatusIconWidth = lowDetailsStatusIconWidth; + this.lowDetailsStatusIconSize = lowDetailsStatusIconSize; + this.vertical = vertical; } getLocation(): Point { @@ -20,7 +22,13 @@ export default class TaskNodeSourceAnchor extends Abstrac const bounds = this.owner.getBounds(); if (this.detailsLevel !== ScaleDetailsLevel.high) { const scale = this.owner.getGraph().getScale(); - return new Point(bounds.x + this.lowDetailsStatusIconWidth * (1 / scale), bounds.y + bounds.height / 2); + if (this.vertical) { + return new Point(bounds.x + (this.lowDetailsStatusIconSize / 2 + 2) * (1 / scale), bounds.bottom()); + } + return new Point(bounds.x + this.lowDetailsStatusIconSize * (1 / scale), bounds.y + bounds.height / 2); + } + if (this.vertical) { + return new Point(bounds.x + bounds.width / 2, bounds.bottom()); } return new Point(bounds.right(), bounds.y + bounds.height / 2); } diff --git a/packages/module/src/pipelines/components/anchors/TaskNodeTargetAnchor.ts b/packages/module/src/pipelines/components/anchors/TaskNodeTargetAnchor.ts index 86b624a1..3894b65f 100644 --- a/packages/module/src/pipelines/components/anchors/TaskNodeTargetAnchor.ts +++ b/packages/module/src/pipelines/components/anchors/TaskNodeTargetAnchor.ts @@ -1,13 +1,19 @@ import { Point } from '../../../geom'; import { AbstractAnchor } from '../../../anchors'; -import { Node } from '../../../types'; +import { Node, ScaleDetailsLevel } from '../../../types'; export default class TaskNodeTargetAnchor extends AbstractAnchor { private whenOffset = 0; + private detailsLevel: ScaleDetailsLevel; + private lowDetailsStatusIconSize = 0; + private vertical = false; - constructor(owner: E, whenOffset: number) { + constructor(owner: E, whenOffset: number, detailsLevel = ScaleDetailsLevel.high, lowDetailsStatusIconSize = 0, vertical = false) { super(owner); this.whenOffset = whenOffset; + this.detailsLevel = detailsLevel; + this.lowDetailsStatusIconSize = lowDetailsStatusIconSize; + this.vertical = vertical; } getLocation(): Point { @@ -16,6 +22,14 @@ export default class TaskNodeTargetAnchor extends Abstrac getReferencePoint(): Point { const bounds = this.owner.getBounds(); + + if (this.vertical) { + if (this.detailsLevel !== ScaleDetailsLevel.high) { + const scale = this.owner.getGraph().getScale(); + return new Point(bounds.x + (this.lowDetailsStatusIconSize / 2 + 2) * (1 / scale), bounds.y); + } + return new Point(bounds.x + bounds.width / 2, bounds.y); + } return new Point(bounds.x + this.whenOffset, bounds.y + bounds.height / 2); } } diff --git a/packages/module/src/pipelines/components/edges/TaskEdge.tsx b/packages/module/src/pipelines/components/edges/TaskEdge.tsx index 9d2e2a23..f40700cb 100644 --- a/packages/module/src/pipelines/components/edges/TaskEdge.tsx +++ b/packages/module/src/pipelines/components/edges/TaskEdge.tsx @@ -4,6 +4,7 @@ import { css } from '@patternfly/react-styles'; import styles from '../../../css/topology-components'; import { Edge, GraphElement, isEdge } from '../../../types'; import { integralShapePath } from '../../utils'; +import { DagreLayoutOptions, TOP_TO_BOTTOM } from '../../../layouts'; interface TaskEdgeProps { /** The graph edge element to represent */ @@ -24,11 +25,12 @@ const TaskEdgeInner: React.FunctionComponent = observer(({ const endPoint = element.getEndPoint(); const groupClassName = css(styles.topologyEdge, className); const startIndent: number = element.getData()?.indent || 0; + const verticalLayout = (element.getGraph().getLayoutOptions?.() as DagreLayoutOptions)?.rankdir === TOP_TO_BOTTOM; return ( diff --git a/packages/module/src/pipelines/components/nodes/TaskNode.tsx b/packages/module/src/pipelines/components/nodes/TaskNode.tsx index 5edb0ac5..ce2c903d 100644 --- a/packages/module/src/pipelines/components/nodes/TaskNode.tsx +++ b/packages/module/src/pipelines/components/nodes/TaskNode.tsx @@ -23,6 +23,7 @@ import NodeShadows, { import LabelBadge from '../../../components/nodes/labels/LabelBadge'; import LabelIcon from '../../../components/nodes/labels/LabelIcon'; import { useScaleNode } from '../../../hooks'; +import { DagreLayoutOptions, TOP_TO_BOTTOM } from '../../../layouts'; const STATUS_ICON_SIZE = 16; const SCALE_UP_TIME = 200; @@ -88,7 +89,7 @@ export interface TaskNodeProps { toolTip?: React.ReactNode; /** Tooltip properties to pass along to the node's tooltip */ toolTipProps?: Omit; - /** Flag if the node has a 'when expression' */ + /** @deprecated Flag if the node has a 'when expression' */ hasWhenExpression?: boolean; /** Size of the when expression indicator */ whenSize?: number; @@ -170,30 +171,35 @@ const TaskNodeInner: React.FC = observer(({ const [actionSize, actionRef] = useSize([actionIcon, paddingX]); const [contextSize, contextRef] = useSize([onContextMenu, paddingX]); const detailsLevel = element.getGraph().getDetailsLevel(); + const verticalLayout = (element.getGraph().getLayoutOptions?.() as DagreLayoutOptions)?.rankdir === TOP_TO_BOTTOM; if (badgePopoverProps) { // eslint-disable-next-line no-console console.warn('badgePopoverProps is deprecated. Use badgePopoverParams instead.'); } + if (hasWhenExpression) { + // eslint-disable-next-line no-console + console.warn('hasWhenExpression is deprecated. Set whenSize and whenOffset only when showing the when expression.'); + } const textWidth = textSize?.width ?? 0; const textHeight = textSize?.height ?? 0; useAnchor( - // Include scaleNode to cause an update when it changes - // eslint-disable-next-line react-hooks/exhaustive-deps - React.useCallback((node: Node) => new TaskNodeSourceAnchor(node, detailsLevel, statusIconSize + 4), [ - detailsLevel, - statusIconSize, - scaleNode - ]), + React.useCallback((node: Node) => + new TaskNodeSourceAnchor(node, detailsLevel, statusIconSize + 4, verticalLayout), + // Include scaleNode to cause an update when it changes + // eslint-disable-next-line react-hooks/exhaustive-deps + [detailsLevel, statusIconSize, scaleNode, verticalLayout] + ), AnchorEnd.source ); useAnchor( - React.useCallback((node: Node) => new TaskNodeTargetAnchor(node, hasWhenExpression ? 0 : whenSize + whenOffset), [ - hasWhenExpression, - whenSize, - whenOffset - ]), + React.useCallback((node: Node) => + new TaskNodeTargetAnchor(node, whenSize + whenOffset, detailsLevel, statusIconSize + 4, verticalLayout), + // Include scaleNode to cause an update when it changes + // eslint-disable-next-line react-hooks/exhaustive-deps + [whenSize, whenOffset, detailsLevel, statusIconSize, scaleNode, verticalLayout] + ), AnchorEnd.target ); @@ -207,7 +213,8 @@ const TaskNodeInner: React.FC = observer(({ badgeStartX, iconWidth, iconStartX, - leadIconStartX + leadIconStartX, + offsetX } = React.useMemo(() => { if (!textSize) { return { @@ -249,6 +256,8 @@ const TaskNodeInner: React.FC = observer(({ const pillWidth = contextStartX + contextSpace + paddingX / 2; + const offsetX = verticalLayout ? (width - pillWidth) / 2 : 0; + return { height, statusStartX, @@ -259,7 +268,8 @@ const TaskNodeInner: React.FC = observer(({ iconWidth, iconStartX, leadIconStartX, - pillWidth + pillWidth, + offsetX, }; }, [ textSize, @@ -281,7 +291,9 @@ const TaskNodeInner: React.FC = observer(({ actionIcon, actionSize, onContextMenu, - contextSize + contextSize, + verticalLayout, + width ]); React.useEffect(() => { @@ -289,10 +301,13 @@ const TaskNodeInner: React.FC = observer(({ const sourceEdges = element.getSourceEdges(); sourceEdges.forEach(edge => { const data = edge.getData(); - edge.setData({ ...(data || {}), indent: detailsLevel === ScaleDetailsLevel.high ? width - pillWidth : 0 }); + edge.setData({ + ...(data || {}), + indent: detailsLevel === ScaleDetailsLevel.high && !verticalLayout ? width - pillWidth : 0 + }); }); })(); - }, [detailsLevel, element, pillWidth, width]); + }, [detailsLevel, element, pillWidth, verticalLayout, width]); const scale = element.getGraph().getScale(); const nodeScale = useScaleNode(scaleNode, scale, SCALE_UP_TIME); @@ -300,6 +315,7 @@ const TaskNodeInner: React.FC = observer(({ const nameLabel = ( = observer(({ const taskIconComponent = (taskIconClass || taskIcon) && ( = observer(({ = observer(({ return ( = observer(({ )} {status && showStatusState && ( - + = observer(({ )} {leadIcon && ( - + {leadIcon} )} @@ -454,15 +470,15 @@ const TaskNodeInner: React.FC = observer(({ <> = observer(({ <> = ({ +export const WhenDecorator: React.FC = observer(({ element, width = DEFAULT_WHEN_SIZE, height = DEFAULT_WHEN_SIZE, className, status, leftOffset = DEFAULT_WHEN_OFFSET, + topOffset = DEFAULT_WHEN_OFFSET, edgeLength = DEFAULT_WHEN_OFFSET, toolTip, disableTooltip = false }: WhenDecoratorProps) => { const nodeElement = element as Node; const diamondNodeRef = React.useRef(); - const { height: taskHeight } = nodeElement.getBounds(); - const y = taskHeight / 2 - height / 2; - const startX = -width - leftOffset; - const points = `${startX + width / 2} ${y} ${startX + width} ${y + height / 2} ${startX + width / 2} ${y + - height} ${startX} ${y + height / 2}`; + const { height: taskHeight, width: taskWidth } = nodeElement.getBounds(); + const verticalLayout = (element.getGraph().getLayoutOptions?.() as DagreLayoutOptions)?.rankdir === TOP_TO_BOTTOM; + + const points = React.useMemo(() => { + if (verticalLayout) { + const y = -topOffset; + const startX = taskWidth / 2; + + return ` + ${startX} ${y} + ${startX - width / 2} ${y - height / 2} + ${startX} ${y - height} + ${startX + width / 2} ${y - height / 2} + `; + } + const y = taskHeight / 2 - height / 2; + const startX = -width - leftOffset; + + return ` + ${startX + width / 2} ${y} + ${startX + width} ${y + height / 2} + ${startX + width / 2} ${y + height} + ${startX} ${y + height / 2} + `; + }, [height, leftOffset, taskHeight, taskWidth, topOffset, verticalLayout, width]); + + const linePoints = verticalLayout ? { + x1: taskWidth / 2, + y1: -topOffset, + x2: taskWidth / 2, + y2: -topOffset + edgeLength, + } : { + x1: -leftOffset, + y1: taskHeight / 2, + x2: -leftOffset + edgeLength, + y2: taskHeight / 2 + }; const diamondNode = ( = ({ ) : ( diamondNode ); -}; +}); WhenDecorator.displayName = 'WhenDecorator'; -export default observer(WhenDecorator); +export default WhenDecorator; diff --git a/packages/module/src/pipelines/layouts/PipelineDagreLayout.ts b/packages/module/src/pipelines/layouts/PipelineDagreLayout.ts index 72999f3c..991d400d 100644 --- a/packages/module/src/pipelines/layouts/PipelineDagreLayout.ts +++ b/packages/module/src/pipelines/layouts/PipelineDagreLayout.ts @@ -1,6 +1,6 @@ import { Graph, Layout } from '../../types'; import { NODE_SEPARATION_HORIZONTAL, NODE_SEPARATION_VERTICAL } from '../const'; -import { DagreLayout, DagreLayoutOptions } from '../../layouts/DagreLayout'; +import { DagreLayout, DagreLayoutOptions, LEFT_TO_RIGHT } from '../../layouts/DagreLayout'; export class PipelineDagreLayout extends DagreLayout implements Layout { constructor(graph: Graph, options?: Partial) { @@ -17,7 +17,7 @@ export class PipelineDagreLayout extends DagreLayout implements Layout { ranksep: NODE_SEPARATION_HORIZONTAL, edgesep: 50, ranker: 'longest-path', - rankdir: 'LR', + rankdir: LEFT_TO_RIGHT, marginx: 20, marginy: 20, ...options diff --git a/packages/module/src/pipelines/utils/draw-utils.ts b/packages/module/src/pipelines/utils/draw-utils.ts index 4cf21993..271b51d4 100644 --- a/packages/module/src/pipelines/utils/draw-utils.ts +++ b/packages/module/src/pipelines/utils/draw-utils.ts @@ -2,7 +2,7 @@ import { Point } from '../../geom'; import { DrawDesign, NODE_SEPARATION_HORIZONTAL } from '../const'; type SingleDraw = (p: Point) => string; -type DoubleDraw = (p1: Point, p2: Point, startIndentX?: number, junctionOffset?: number) => string; +type DoubleDraw = (p1: Point, p2: Point, startIndentX?: number, junctionOffset?: number, verticalLayout?: boolean) => string; type TripleDraw = (p1: Point, p2: Point, p3: Point, curveSize?: { x: number, y: number }) => string; type DetermineDirection = (p1: Point, p2: Point) => boolean; @@ -57,19 +57,74 @@ const curve: TripleDraw = (fromPoint, cornerPoint, toPoint, curveSize = CURVE_SI return ''; }; +const curveVertical: TripleDraw = (fromPoint, cornerPoint, toPoint, curveSize = CURVE_SIZE) => { + const leftToRight = leftRight(fromPoint, toPoint); + if (leftToRight) { + const rightAndDown = leftRight(fromPoint, cornerPoint) && topDown(cornerPoint, toPoint); + const downAndRight = topDown(fromPoint, cornerPoint) && leftRight(cornerPoint, toPoint); + if (rightAndDown) { + return join( + lineTo(cornerPoint.clone().translate(-curveSize.x, 0)), + quadTo(cornerPoint, cornerPoint.clone().translate(0, curveSize.y)) + ); + } + if (downAndRight) { + return join( + lineTo(cornerPoint.clone().translate(0, -curveSize.y)), + quadTo(cornerPoint, cornerPoint.clone().translate(curveSize.x, 0)) + ); + } + } else { + const leftAndDown = leftRight(toPoint, cornerPoint) && bottomUp(cornerPoint, fromPoint); + const downAndLeft = bottomUp(toPoint, cornerPoint) && leftRight(cornerPoint, fromPoint); + if (leftAndDown) { + return join( + lineTo(cornerPoint.clone().translate(0, -curveSize.y)), + quadTo(cornerPoint, cornerPoint.clone().translate(-curveSize.x, 0)) + ); + } + if (downAndLeft) { + return join( + lineTo(cornerPoint.clone().translate(curveSize.x, 0)), + quadTo(cornerPoint, cornerPoint.clone().translate(0, curveSize.y)) + ); + } + } + + return ''; +}; + export const straightPath: DoubleDraw = (start, finish) => join(moveTo(start), lineTo(finish)); export const integralShapePath: DoubleDraw = ( start, finish, startIndentX = 0, - nodeSeparation = NODE_SEPARATION_HORIZONTAL + nodeSeparation = NODE_SEPARATION_HORIZONTAL, + verticalLayout = false ) => { // Integral shape: ∫ let firstCurve: string = null; let secondCurve: string = null; - if (start.y !== finish.y) { + if (verticalLayout) { + if (start.x !== finish.x) { + const cornerY = Math.floor(start.y + nodeSeparation / 2); + const firstCorner = new Point(start.x, cornerY); + const secondCorner = new Point(finish.x, cornerY); + + if (Math.abs(start.x - finish.x) > CURVE_SIZE.x) { + firstCurve = curveVertical(start, firstCorner, secondCorner); + secondCurve = curveVertical(firstCorner, secondCorner, finish); + } else { + firstCurve = curveVertical(start, firstCorner, finish, { + x: Math.abs(start.x - finish.x), + y: CURVE_SIZE.y + }); + } + } + } + else if (start.y !== finish.y) { const cornerX = Math.floor(start.x + nodeSeparation / 2); const firstCorner = new Point(cornerX, start.y); const secondCorner = new Point(cornerX, finish.y); diff --git a/packages/module/src/types.ts b/packages/module/src/types.ts index d488513b..6212a823 100644 --- a/packages/module/src/types.ts +++ b/packages/module/src/types.ts @@ -3,6 +3,7 @@ import Point from './geom/Point'; import Dimensions from './geom/Dimensions'; import Rect from './geom/Rect'; import { Padding, Translatable } from './geom/types'; +import { LayoutOptions } from './layouts'; // x, y export type PointTuple = [number, number]; @@ -11,6 +12,7 @@ export interface Layout { layout(): void; stop(): void; destroy(): void; + getLayoutOptions?: () => LayoutOptions; } export interface Model { @@ -274,6 +276,7 @@ export interface Graph extends Graph getLayout(): string | undefined; setLayout(type: string | undefined): void; layout(): void; + getLayoutOptions?: () => LayoutOptions; getLayers(): string[]; setLayers(layers: string[]): void;