/*! * Copyright (c) 2023, 2025, Oracle and/or its affiliates. */ /** * @file * * Contains the actions (which result in a visual change) of the graph and the paper. */ import DiagramBuilderRouter from "../DiagramBuilderRouter.mjs"; import SubcontainerElement from "../cells/elements/SubcontainerElement.mjs"; import ContainerElement from "../cells/elements/ContainerElement.mjs"; import Element from "../cells/elements/Element.mjs"; import Link from "../cells/links/Link.mjs"; import { isPoint, populateRect } from "../utils/common.mjs"; import { getAnchorCoords, getLink, isContainer, isSubcontainer } from "../utils/graph.mjs"; import DiagramBuilderEvent from "../DiagramBuilderEvent.mjs"; const { g } = joint; export function scrollIntoView( paperScroller, cell, opt = {} ) { const { padding = 0, animation = true, strict = true, center = true } = opt; const { paper } = paperScroller.options; const visibleArea = populateRect( paperScroller.getVisibleArea() ); const visibleCenter = visibleArea.center(); let { x: scrollToX, y: scrollToY } = visibleCenter; if ( cell.isElement() ) { // We are not going to use paperScroller.isElementVisible because it wouldn't // scroll to el if even a tiny portion of the element was in the visible area. // Instead, we will focus on the element's center; const bbox = cell.getBBox(); populateRect( bbox ); if ( !paperScroller.isElementVisible( cell, { strict } ) ) { if ( center ) { paperScroller.scrollToElement( cell, { animation } ); } else { // is to the left of the scroller if ( bbox.x < visibleArea.x + padding ) { scrollToX = scrollToX + ( bbox.x - visibleArea.x ) - padding; } // is to the right of the scroller else if ( bbox.right > visibleArea.right - padding ) { scrollToX = scrollToX + bbox.right - visibleArea.right + padding; } // is above if ( bbox.y < visibleArea.y + padding ) { scrollToY = scrollToY + ( bbox.y - visibleArea.y ) - padding; } // is below else if ( bbox.bottom > visibleArea.bottom - padding ) { scrollToY = scrollToY + bbox.bottom - visibleArea.bottom + padding; } paperScroller.scroll( scrollToX, scrollToY, { animation } ); } } } else { const { ratio = 0 } = opt; const linkView = paper.requireView( cell ); const point = linkView.getPointAtRatio( ratio ); const { x, y } = point; if ( !paperScroller.isPointVisible( point, padding ) ) { if ( center ) { paperScroller.scrollToLink( cell, { animation } ); } else { // is to the left of the scroller if ( x < visibleArea.x + padding ) { scrollToX = scrollToX + ( x - visibleArea.x ) - padding; } // is to the right of the scroller else if ( x > visibleArea.right - padding ) { scrollToX = scrollToX + x - visibleArea.right + padding; } // is above if ( y < visibleArea.y + padding ) { scrollToY = scrollToY + ( y - visibleArea.y ) - padding; } // is below else if ( y > visibleArea.bottom - padding ) { scrollToY = scrollToY + y - visibleArea.bottom + padding; } paperScroller.scroll( scrollToX, scrollToY, { animation } ); } } } } export function updateConnections( paper, links = [] ) { paper.freeze(); links.forEach( link => { link.findView( paper ).requestConnectionUpdate(); } ); paper.unfreeze(); } export function updateAllConnections( paper ) { updateConnections( paper, paper.model.getLinks() ); } // updates only the automatic routing export function updateAutoRoutedConnections( paper ) { const R = DiagramBuilderRouter; const graph = paper.model; const defRouterName = paper.options.defaultRouter.name; const defRouterIsAuto = defRouterName === R.MANHATTAN || defRouterName === R.ADVANCED_MANHATTAN; const links = graph.getLinks().filter( link => { const routerName = link.router()?.name; if ( routerName == null && defRouterIsAuto ) { return true; } if ( routerName === R.MANHATTAN || routerName === R.ADVANCED_MANHATTAN ) { return true; } return false; } ); if ( links.length ) { updateConnections( paper, links ); } } export function addElementToLinkAtRatio( elementView, linkView, ratio = .5 ) { const paper = elementView.paper; const graph = paper.model; const element = elementView.model; const link = linkView.model; const { x, y } = linkView.getPointAtRatio( ratio ); const vertices = link.vertices(); const target = link.target(); const source = link.source(); let elementBBox = element.getBBox(); // already on the link if ( target.id === element.id || source.id === element.id ) { return; } paper.freeze(); // set the element's position, point should be in the center const p = g.Point( x - elementBBox.width / 2, y - elementBBox.height / 2 ).snapToGrid( paper.options.gridSize ); element.position( p.x, p.y ); let vertexRatios = vertices.map( vertex => { return { vertex, ratio: linkView.getClosestPointRatio( vertex ) }; } ); link.target( element ); const newLink = paper.getDefaultLink(); newLink.source( element ); newLink.target( target ); graph.addCell( newLink ); paper.requireView( element ); elementBBox = element.getBBox(); // remove the vertices that overlap the element vertexRatios = vertexRatios.filter( o => !elementBBox.containsPoint( o.vertex ) ); // add back the vertices newLink.vertices( vertexRatios.filter( o => o.ratio > ratio ).map( o => o.vertex ) ); link.vertices( vertexRatios.filter( o => o.ratio <= ratio ).map( o => o.vertex ) ); paper.unfreeze(); } export function label( graph, link, text = "", opt = {} ) { const { rewrite = false, ...restOpt } = opt; link = getLink( graph, link ); if ( !link ) { return; } let labels = link.labels(); let count = labels.length; // if first is a designation label, our "normal" label has to be appended at index 1 let hasDesignation = !!( labels[0]?.designation ); let index = 0; // there is designation - should be placed at 1 if ( hasDesignation ) { index = 1; // only designation label exists if ( count === 1 ) { link.label( index, Link.getLabelCfg( text, restOpt ) ); } // both designation and normal labels exist else { if ( rewrite ) { // opt: rewrite - disregard old properties, do not merge new props to old ones link.label( index, Link.getLabelCfg( text, restOpt ), { rewrite } ); } else { link.prop( `labels/${index}/${Link.getLabelTextPath()}`, text ); } } } // no designation - should be placed at 0 else { if ( !count ) { link.label( index, Link.getLabelCfg( text, restOpt ) ); } else { if ( rewrite ) { // opt: rewrite - disregard old properties, do not merge new props to old ones link.label( index, Link.getLabelCfg( text, restOpt ), { rewrite } ); } else { link.prop( `labels/${index}/${Link.getLabelTextPath()}`, text ); } } } } export function removeLabel( graph, link ) { link = getLink( graph, link ); if ( !link ) { return; } removeLabelAt( link, 0, graph ); } export function appendLabel( graph, link, text = "", opt = {} ) { link = getLink( link, graph ); if ( !link ) { return; } link.appendLabel( Link.getLabelCfg( text, opt ) ); } export function removeLabelAt( graph, link, index ) { link = getLink( link, graph ); if ( !link ) { return; } const first = link.label( index ); if ( first?.designation ) { index = 1; } link.removeLabel( index ); } export function removeCells( graph, cells ) { graph[Array.isArray( cells ) ? "removeCells" : "removeCell"]( cells ); } export function changeElementInPlace( graph, elToChange, newEl ) { const inboundLinks = graph.getConnectedLinks( elToChange, { inbound: true } ).map( link => { return { target: link.target(), vertices: link.vertices(), link }; } ); const outboundLinks = graph.getConnectedLinks( elToChange, { outbound: true } ).map( link => { return { source: link.source(), vertices: link.vertices(), link }; } ); const newElId = newEl.get( "id" ); // Must not be async! Otherwise the sorting will bug out. graph.addCell( newEl, { async: false } ); // Add the links to the new element before removing the old one. Otherwise the VC will // trigger an "alignment" target change again and set the placeholders! // Restore the links to their original anchors and routing. inboundLinks.forEach( ( { link, target, vertices } ) => { target.id = newElId; link.target( target ); link.vertices( vertices ); } ); outboundLinks.forEach( ( { link, source, vertices } ) => { source.id = newElId; link.source( source ); link.vertices( vertices ); } ); // at the very end, remove the old element elToChange.remove(); } export function toFront( cells, opt = {} ) { cells = Array.isArray( cells ) ? cells : [cells]; cells.forEach( cell => { if ( cell.isEmbedded() ) { toFrontInContainer( cell ); } else { cell.toFront( opt ); } } ); } export function toBack( cells, opt = {} ) { cells = Array.isArray( cells ) ? cells : [cells]; cells.forEach( cell => { if ( cell.isEmbedded() ) { toBackInContainer( cell ); } else { cell.toBack( opt ); } } ); } export function fitToContent( paper, padding = 30 ) { paper.fitToContent( { allowNewOrigin: "any", allowNegativeBottomRight: true, padding } ); } export function canRemoveElements( els = [] ) { els = Array.isArray( els ) ? els : [els]; let canRemove = els.every( el => el.allow( Element.ALLOW_REMOVE ) && !el.isReadOnly() ); // If it is possible to remove the els, check the links that these els "own": if a link // cannot be removed, its source/target changed, etc. - we will not allow the el to be // removed either. if ( canRemove ) { const processedLink = []; canRemove = els.every( el => { const { graph } = el; const connectedLinks = graph.getConnectedLinks( el ); return connectedLinks.every( link => { if ( processedLink.includes( link.id ) ) { return true; } processedLink.push( link.id ); const sourceEl = link.getSourceElement(); const targetEl = link.getTargetElement(); const sourceElToBeDeleted = sourceEl ? els.includes( sourceEl ) : false; const targetElToBeDeleted = targetEl ? els.includes( targetEl ) : false; // listing conditions separately for better readability // read only links if ( link.isReadOnly() ) { return false; } // if both source and target are gonna be deleted... if ( sourceElToBeDeleted && targetElToBeDeleted ) { // there is so many conditions here that we will just ignore link's allow_remove // because it will collide with everything else return true; } // can the link be without source el? if ( sourceElToBeDeleted && ( link.prop( "requireSourceElement" ) || !link.allow( Link.ALLOW_SOURCE_CHANGE ) ) ) { return false; } // can the link be without target el? if ( targetElToBeDeleted && ( link.prop( "requireTargetElement" ) || !link.allow( Link.ALLOW_TARGET_CHANGE ) ) ) { return false; } return true; } ); } ); } return canRemove; } export function canRemoveLinks( links = [] ) { links = Array.isArray( links ) ? links : [links]; return links.every( link => { return !link.isReadOnly() && link.allow( Link.ALLOW_REMOVE ); } ); } export function addSubcontainersToContainerElement( graph, containerElement, count = 1, opt = {} ) { let { index } = opt; const { arrangeSubcontainers: shouldArrange = true, deepArrange = true, rtl = false, size } = opt; if ( count < 1 ) { return; } if ( !isContainer( containerElement ) && !isSubcontainer( containerElement ) ) { throw new Error( "The provided element cannot be used as a container" ); } const existingSubs = containerElement.getEmbeddedCells(); index = ( index != null && index > -1 && index <= existingSubs.length ) ? index : existingSubs.length; for ( let i = 0; i < count; i++ ) { const subcontainer = new SubcontainerElement( { rtl } ); if ( size?.width || size?.height ) { const { width: currW, height: currH } = subcontainer.size(); subcontainer.size( size.width || currW, size.height || currH ); } subcontainer.positionIndex( index + i ); graph.addCell( subcontainer, { parentCell: containerElement } ); containerElement.embed( subcontainer ); } // if it was inserted at the index of a previous sub, we need to push the old ones if ( index < existingSubs.length ) { existingSubs.forEach( sub => { const subIndex = sub.positionIndex(); if ( subIndex >= index ) { sub.positionIndex( subIndex + count ); } } ); } if ( shouldArrange ) { arrangeSubcontainers( containerElement, { deep: deepArrange } ); } containerElement.resizeToChildren(); return containerElement.getEmbeddedCells(); } export function embedCellInto( cell, intoContainerElement, opt = {} ) { const { clipToParent } = opt; intoContainerElement.embed( cell ); if ( clipToParent ) { cell.set( "clip", true ); } } export function toBackInContainer( cell ) { const container = cell.getParentCell(); const children = container?.getEmbeddedCells(); if ( !children?.length ) { return; } const minZ = children.reduce( ( prev, child ) => { const z = child.get( "z" ); return z < prev ? z : prev; }, Number.MAX_SAFE_INTEGER ); // sort by z index ascending const sortedChildren = [...children].sort( ( c1, c2 ) => c1.get( "z" ) - c2.get( "z" ) ); const cellIndex = sortedChildren.findIndex( c => c === cell ); // cell not found or already has the lowest z-index if ( cellIndex <= 0 ) { return; } for ( let i = 0; i < cellIndex; i++ ) { sortedChildren[i].set( "z", sortedChildren[i + 1].get( "z" ) ); } cell.set( "z", minZ ); } export function toFrontInContainer( cell ) { const container = cell.getParentCell(); const children = container?.getEmbeddedCells(); if ( !children?.length ) { return; } const maxZ = children.reduce( ( prev, child ) => { const z = child.get( "z" ); return z > prev ? z : prev; }, 0 ); // sort by z index descending const sortedChildren = [...children].sort( ( c1, c2 ) => c2.get( "z" ) - c1.get( "z" ) ); const cellIndex = sortedChildren.findIndex( c => c === cell ); // cell not found or already has the lowest z-index if ( cellIndex <= 0 ) { return; } for ( let i = 0; i < cellIndex; i++ ) { sortedChildren[i].set( "z", sortedChildren[i + 1].get( "z" ) ); } cell.set( "z", maxZ ); } export function moveSubcontainer( subcontainer, direction ) { const index = subcontainer.positionIndex(); const parent = subcontainer.getParentCell(); const children = parent.getEmbeddedCells().sort( ( a, b ) => a.positionIndex() - b.positionIndex() ); const deep = true; let sibling; if ( direction !== "left" && direction !== "right" ) { throw new Error( "Direction must be either left or right." ); } if ( direction === "left" ) { sibling = children[children.findIndex( child => child === subcontainer ) - 1]; // No more left left. if ( !sibling ) { return; } subcontainer.positionIndex( index - 1 ); sibling.positionIndex( index ); const { x, y } = sibling.position(); subcontainer.position( x, y, { deep } ); const { width } = subcontainer.size(); sibling.position( x + width + ContainerElement.CHILDREN_SPACING, y, { deep } ); } else { sibling = children[children.findIndex( child => child === subcontainer ) + 1]; // No more elements on the right. if ( !sibling ) { return; } subcontainer.positionIndex( index + 1 ); sibling.positionIndex( index ); const { x, y } = subcontainer.position(); sibling.position( x, y, { deep } ); const { width } = sibling.size(); subcontainer.position( x + width + ContainerElement.CHILDREN_SPACING, y, { deep } ); } const ret = { subcontainer, sibling, direction }; subcontainer.graph.trigger( DiagramBuilderEvent.SUBCONTAINERS_SWAP, ret ); return ret; } export function arrangeSubcontainers( container, opt = {} ) { const { deep = true } = opt; const children = container.getEmbeddedCells(); if ( children.length ) { children.sort( ( a, b ) => { const aPosIndex = a.positionIndex(); const bPosIndex = b.positionIndex(); if ( aPosIndex === bPosIndex || aPosIndex == null || bPosIndex == null ) { return a.position().x - b.position().x; } return aPosIndex - bPosIndex; } ); children.forEach( ( child, index ) => { child.positionIndex( index ); if ( index === 0 ) { const { x, y } = container.position(); child.position( x + ContainerElement.CHILDREN_SPACING, y + 60 + ContainerElement.CHILDREN_SPACING, { deep } ); } else { const prevChild = children[index - 1]; const { x, y } = prevChild.position(); const { width } = prevChild.size(); child.position( x + width + ContainerElement.CHILDREN_SPACING, y, { deep } ); } } ); } } export function restrictToParent( view, opt = {} ) { const { transition = true, suppressEvent = false } = opt; const { paper, model } = view; paper.requireView( view ); const parent = model.getParentCell(); if ( !parent ) { return; } const restrictedArea = paper.getRestrictedArea( view ); if ( restrictedArea ) { if ( model.isElement() ) { const { x, y, width, height } = model.getBBox(); let dx = 0, dy = 0; if ( x - restrictedArea.x < 0 ) { dx = restrictedArea.x - x; } if ( y - restrictedArea.y < 0 ) { dy = restrictedArea.y - y; } if ( x + width > restrictedArea.x + restrictedArea.width ) { dx = restrictedArea.x + restrictedArea.width - ( x + width ); } if ( y + height > restrictedArea.y + restrictedArea.height ) { dy = restrictedArea.y + restrictedArea.height - ( y + height ); } if ( dx || dy ) { model.translate( dx, dy, { transition } ); if ( !suppressEvent ) { model.graph.trigger( DiagramBuilderEvent.ELEMENT_RESTRICT_POSITION_TO_PARENT, model, { x: x + dx, y: y + dy }, { dx, dy } ); } } } else { const source = model.source(); const target = model.target(); if ( isPoint( source ) ) { const { x, y } = source; let newX, newY; if ( x < restrictedArea.x ) { newX = restrictedArea.x; } else if ( x > restrictedArea.x + restrictedArea.width ) { newX = restrictedArea.x + restrictedArea.width; } if ( y < restrictedArea.y ) { newY = restrictedArea.y; } else if ( y > restrictedArea.y + restrictedArea.height ) { newY = restrictedArea.y + restrictedArea.height; } if ( newX !== null || newY !== null ) { model.source( { x: newX != null ? newX : x, y: newY != null ? newY : y } ); } } if ( isPoint( target ) ) { const { x, y } = target; let newX, newY; if ( x < restrictedArea.x ) { newX = restrictedArea.x; } else if ( x > restrictedArea.x + restrictedArea.width ) { newX = restrictedArea.x + restrictedArea.width; } if ( y < restrictedArea.y ) { newY = restrictedArea.y; } else if ( y > restrictedArea.y + restrictedArea.height ) { newY = restrictedArea.y + restrictedArea.height; } if ( newX !== null || newY !== null ) { model.target( { x: newX != null ? newX : x, y: newY != null ? newY : y } ); } } } } } export function createLoopingVertices( linkModel, elementModel, originSide ) { if ( !linkModel.hasLoop() ) { throw new Error( "The provided link's target and source are not the same." ); } const targetAnchor = linkModel.target().anchor; const v1 = {}, v2 = {}, v3 = {}; const bbox = elementModel.getBBox(); const targetPoint = getAnchorCoords( targetAnchor, bbox ); const offset = 30; // NOTE: Offset must be bigger than the router's padding! let targetSide; switch ( originSide ) { case "top": targetSide = getTargetSide( targetAnchor, bbox, "horizontal" ); v1.x = bbox.x + bbox.width / 2; v1.y = v2.y = bbox.y - offset; v2.x = v3.x = targetSide === "right" ? ( bbox.x + bbox.width + offset ) : ( bbox.x - offset ); v3.y = targetPoint.y; break; case "bottom": targetSide = getTargetSide( targetAnchor, bbox, "horizontal" ); v1.x = bbox.x + bbox.width / 2; v1.y = v2.y = bbox.y + bbox.height + offset; v2.x = v3.x = targetSide === "right" ? ( bbox.x + bbox.width + offset ) : ( bbox.x - offset ); v3.y = targetPoint.y; break; case "left": targetSide = getTargetSide( targetAnchor, bbox, "vertical" ); v1.x = v2.x = bbox.x - offset; v1.y = bbox.y + bbox.height / 2; v2.y = v3.y = targetSide === "top" ? ( bbox.y - offset ) : ( bbox.y + bbox.height + 20 ); v3.x = targetPoint.x; break; default: targetSide = getTargetSide( targetAnchor, bbox, "vertical" ); v1.x = v2.x = bbox.x + bbox.width + offset; v1.y = bbox.y + bbox.height / 2; v2.y = v3.y = targetSide === "top" ? ( bbox.y - offset ) : ( bbox.y + bbox.height + 20 ); v3.x = targetPoint.x; break; } linkModel.vertices( [v1, v2, v3] ); function getTargetSide( anchor, bbox, direction ) { if ( direction === "vertical" ) { const { dy } = anchor.args; return ( typeof dy == "string" ? parseFloat( dy ) >= 50 : ( dy >= bbox.y + bbox.height / 2 ) ) ? "bottom" : "top"; } // else const { dx } = anchor.args; return ( typeof dx == "string" ? parseFloat( dx ) >= 50 : ( dx >= bbox.x + bbox.width / 2 ) ) ? "right" : "left"; } }