@kieler/klighd-core
Version:
Core KLighD diagram visualization with Sprotty
1,011 lines (913 loc) • 39.5 kB
text/typescript
/*
* KIELER - Kiel Integrated Environment for Layout Eclipse RichClient
*
* http://rtsys.informatik.uni-kiel.de/kieler
*
* Copyright 2022-2025 by
* + Kiel University
* + Department of Computer Science
* + Real-Time and Embedded Systems Group
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* SPDX-License-Identifier: EPL-2.0
*/
// We follow Sprotty's way of redeclaring the interface and its create function, so disable this lint check for this file.
/* eslint-disable no-redeclare */
import { KGraphData, SKGraphElement } from '@kieler/klighd-interactive/lib/constraint-classes'
import { injectable } from 'inversify'
import { VNode } from 'snabbdom'
import {
ActionHandlerRegistry,
IActionHandler,
IActionHandlerInitializer,
ICommand,
isSelectable,
ModelIndexImpl,
SModelRootImpl,
} from 'sprotty'
import {
Action,
Bounds,
isBounds,
Dimension,
Point,
SetModelAction,
UpdateModelAction,
Viewport,
} from 'sprotty-protocol'
import { SendModelContextAction } from '../actions/actions'
import { isSKGraphElement, SKEdge, SKLabel, SKNode, SKPort } from '../skgraph-models'
/* global document, Element, SVGElement */
/** Suffix of a proxy's ID. */
export const PROXY_SUFFIX = '$proxy'
/// ///// Interfaces ////////
/**
* Contains all attributes used in defining a VNode's transform attribute.
* @example (x, y, width, height, scale, rotation)
*/
export interface TransformAttributes extends Bounds {
readonly scale?: number
readonly rotation?: number
readonly rotationPoint?: Point
}
/**
* Contains all canvas-related attributes.
* VRF - Viewport Reference Frame.
* The viewport reference frames is the coordinate system defined by the viewport bounds.
* The position (0,0) is in the top left corner of the viewport on the canvas. This means
* that scroll and zoom are already accounted for.
* CRF - Canvas Reference Frame.
* The canvas reference frame is the coordinate system defined by the entire svg canvas. The
* position (0,0) is the top left corner of the svg. All coordinates are absolute
* positions in the svg.
* @example (x, y, width, height, scroll, zoom)
*/
export interface Canvas extends Viewport, Bounds {
/**
* Whether the canvas is in CRF, i.e. not in VRF.
* Usually doesn't need to be set explicitly - handled by translation methods.
* When the canvas hasn't been translated yet, this should be `undefined` or `false`,
* as the canvas should be in the VRF.
*/
isInCRF?: boolean
}
export namespace Canvas {
/// / Get Canvas ////
/**
* Creates a canvas in VRF.
* @param boundsOrRoot The canvas' bounds or the root element.
* @param viewport The viewport.
* @returns The canvas.
*/
export function of(boundsOrRoot: Bounds | SModelRootImpl, viewport: Viewport): Canvas {
const canvasBounds = isBounds(boundsOrRoot) ? boundsOrRoot : boundsOrRoot.canvasBounds
return { ...canvasBounds, scroll: viewport.scroll, zoom: viewport.zoom }
}
/// / Translation ////
/**
* Returns the bounds translated from the CRF to the VRF.
* E.g. calculates its position & width/height according to scroll and zoom.
* Inverse to {@link translateToCRF()}.
* @param originalBounds The bounds/point/dimension in the CRF.
* @param canvas The canvas.
* @returns The bounds translated to the VRF.
*/
export function translateToVRF(originalBounds: Bounds | Point | Dimension, canvas: Canvas): Bounds {
const bounds = asBounds(originalBounds)
const { scroll } = canvas
const { zoom } = canvas
return {
x: (bounds.x - scroll.x) * zoom,
y: (bounds.y - scroll.y) * zoom,
width: bounds.width * zoom,
height: bounds.height * zoom,
}
}
/**
* Returns the bounds translated from the VRF to the CRF.
* Inverse to {@link translateToVRF()}.
* @param originalBounds The bounds/point/dimension in the VRF.
* @param canvas The canvas.
* @returns The bounds translated to the CRF.
*/
export function translateToCRF(originalBounds: Bounds | Point | Dimension, canvas: Canvas): Bounds {
const bounds = asBounds(originalBounds)
const { scroll } = canvas
const { zoom } = canvas
return {
x: bounds.x / zoom + scroll.x,
y: bounds.y / zoom + scroll.y,
width: bounds.width / zoom,
height: bounds.height / zoom,
}
}
/**
* Adds `p1` and `p2` and translates the result to the VRF.
* @param p1 The first point.
* @param p2 The second point.
* @param canvas The canvas.
*/
export function translateToVRFAdd(p1: Point, p2: Point, canvas: Canvas): Point {
return translateToVRF(Point.add(p1, p2), canvas)
}
/**
* Translates the canvas from the CRF to the VRF, if not already in VRF.
* Inverse to {@link translateCanvasToCRF()}.
* @param canvas The canvas.
* @returns The canvas translated to the VRF.
*/
export function translateCanvasToVRF(canvas: Canvas): Canvas {
if (canvas.isInCRF) {
return { ...canvas, ...translateToVRF(canvas, canvas), isInCRF: false }
}
return canvas
}
/**
* Translates the canvas from the VRF to the CRF, if not already in CRF.
* Inverse to {@link translateCanvasToVRF()}.
* @param canvas The canvas.
* @returns The canvas translated to the CRF.
*/
export function translateCanvasToCRF(canvas: Canvas): Canvas {
if (canvas.isInCRF) {
return canvas
}
return { ...canvas, ...translateToCRF(canvas, canvas), isInCRF: true }
}
/// / Functions invariant to Reference Frame ////
/**
* Returns the distance between the bounds and the canvas.
* @see {@link distanceBetweenBounds()} for an explanation on how the distance is calculated.
* Note that `bounds` and `canvas` need to be in the same Reference Frame.
*
* @param bounds The bounds/point to calculate the distance to the canvas for.
* @param canvas The canvas.
* @returns The distance between the bounds and the canvas.
*/
export function distance(bounds: Bounds | Point, canvas: Canvas): number {
const dist = distanceBetweenBounds(bounds, canvas)
return dist * (canvas.isInCRF ? canvas.zoom : 1)
}
/**
* Performs along-border-routing from `from` to `to`, both of which need to be at their respective border already.
* Note that `from` and `to` are not part of the returned path.
* @param from The bounds/point to route along the border from.
* @param fromBorder The border for `from`.
* @param to The bounds/point to route along the border to.
* @param toBorder The border for `to`.
* @param preferLeft Whether routing left should be preferred when `from` and `to` are vertically opposite of each other.
* @param preferTop Whether routing top should be preferred when `from` and `to` are horizontally opposite of each other.
* @returns A path along the border from `from` to `to`. Exclusive, e.g. (from, to).
*/
export function routeAlongBorder(
from: Bounds | Point,
fromBorder: Bounds,
to: Bounds | Point,
toBorder: Bounds,
preferLeft = true,
preferTop = false
): Point[] {
const res = []
const toRect = Rect.fromBounds(asBounds(to))
const toBorderRect = Rect.fromBounds(toBorder)
const fromRect = Rect.fromBounds(asBounds(from))
const fromBorderRect = Rect.fromBounds(fromBorder)
let x
let y
if (fromRect.left === fromBorderRect.left) {
// from at the left
x = fromBorderRect.left
if (toRect.left === toBorderRect.left) {
// to at the left
// Nothing to do
} else if (toRect.right === toBorderRect.right) {
// to at the right
if (toRect.top === toBorderRect.top) {
// to at the top, add a point to top left
y = fromBorderRect.top
} else if (toRect.bottom === toBorderRect.bottom) {
// to at the bottom, add a point to bottom left
y = fromBorderRect.bottom
} else {
// to in between top and bottom
// Need 2 routing points, choose the preferred one
y = preferTop ? fromBorderRect.top : fromBorderRect.bottom
res.push({ x, y })
// 2nd routing point
x = fromBorderRect.right
}
} else {
// to in between left and right
// eslint-disable-next-line no-lonely-if
if (toRect.top === toBorderRect.top) {
// to at the top, add a point to top left
y = fromBorderRect.top
} else if (toRect.bottom === toBorderRect.bottom) {
// to at the bottom, add a point to bottom left
y = fromBorderRect.bottom
} else {
// Should never be the case, would be hovering somewhere
throw new Error('Invalid case in routeAlongBorder reached.')
}
}
} else if (fromRect.right === fromBorderRect.right) {
// from at the right
x = fromBorderRect.right
if (toRect.left === toBorderRect.left) {
// to at the left
if (toRect.top === toBorderRect.top) {
// to at the top, add a point to top right
y = fromBorderRect.top
} else if (toRect.bottom === toBorderRect.bottom) {
// to at the bottom, add a point to bottom right
y = fromBorderRect.bottom
} else {
// to in between top and bottom
// Need 2 routing points, choose the preferred one
y = preferTop ? fromBorderRect.top : fromBorderRect.bottom
res.push({ x, y })
// 2nd routing point
x = fromBorderRect.left
}
} else if (toRect.right === toBorderRect.right) {
// to at the right
// Nothing to do
} else {
// to in between left and right
// eslint-disable-next-line no-lonely-if
if (toRect.top === toBorderRect.top) {
// to at the top, add a point to top right
y = fromBorderRect.top
} else if (toRect.bottom === toBorderRect.bottom) {
// to at the bottom, add a point to bottom right
y = fromBorderRect.bottom
} else {
// Should never be the case, would be hovering somewhere
throw new Error('Invalid case in routeAlongBorder reached.')
}
}
} else if (fromRect.top === fromBorderRect.top) {
// from at the top
y = fromBorderRect.top
if (toRect.top === toBorderRect.top) {
// to at the top
// Nothing to do
} else if (toRect.bottom === toBorderRect.bottom) {
// to at the bottom
if (toRect.left === toBorderRect.left) {
// to at the left, add a point to top left
x = fromBorderRect.left
} else if (toRect.right === toBorderRect.right) {
// to at the right, add a point to top right
x = fromBorderRect.right
} else {
// to in between left and right
// Need 2 routing points, choose the preferred one
x = preferLeft ? fromBorderRect.left : fromBorderRect.right
res.push({ x, y })
// 2nd routing point
y = fromBorderRect.bottom
}
} else {
// to in between top and bottom
// eslint-disable-next-line no-lonely-if
if (toRect.left === toBorderRect.left) {
// to at the left, add a point to top left
x = fromBorderRect.left
} else if (toRect.right === toBorderRect.right) {
// to at the right, add a point to top right
x = fromBorderRect.right
} else {
// Should never be the case, would be hovering somewhere
throw new Error('Invalid case in routeAlongBorder reached.')
}
}
} else if (fromRect.bottom === fromBorderRect.bottom) {
// from at the bottom
y = fromBorderRect.bottom
if (toRect.top === toBorderRect.top) {
// to at the top
if (toRect.left === toBorderRect.left) {
// to at the left, add a point to bottom left
x = fromBorderRect.left
} else if (toRect.right === toBorderRect.right) {
// to at the right, add a point to bottom right
x = fromBorderRect.right
} else {
// to in between left and right
// Need 2 routing points, choose the preferred one
x = preferLeft ? fromBorderRect.left : fromBorderRect.right
res.push({ x, y })
// 2nd routing point
y = fromBorderRect.top
}
} else if (toRect.bottom === toBorderRect.bottom) {
// to at the bottom
// Nothing to do
} else {
// to in between top and bottom
// eslint-disable-next-line no-lonely-if
if (toRect.left === toBorderRect.left) {
// to at the left, add a point to top left
x = fromBorderRect.left
} else if (toRect.right === toBorderRect.right) {
// to at the right, add a point to top right
x = fromBorderRect.right
} else {
// Should never be the case, would be hovering somewhere
throw new Error('Invalid case in routeAlongBorder reached.')
}
}
}
if (x && y) {
// Add the border routing point
res.push({ x, y })
}
return res
}
/**
* Offsets the canvas by the given values.
* @param canvas The canvas.
* @param offset The offset. Values `>0` reduce the canvas size.
* @returns An offset canvas.
*/
export function offsetCanvas(canvas: Canvas, offset: number | Rect): Canvas {
const rectOffset =
typeof offset === 'number' ? { left: offset, right: offset, top: offset, bottom: offset } : offset
const x = canvas.x + rectOffset.left
const width = canvas.width - rectOffset.right - rectOffset.left
const y = canvas.y + rectOffset.top
const height = canvas.height - rectOffset.bottom - rectOffset.top
return { ...canvas, x, y, width, height }
}
/// / VRF Functions ////
/**
* Returns the given bounds capped to the canvas border w.r.t. the sidebar if enabled.
* Note that `bounds` and `canvas` need to be in VRF.
* Also, `bounds` has to contain the absolute position (not relative to parent).
* @param bounds The bounds/point to cap to the canvas border, absolute.
* @param canvas The canvas.
* @param capToSidebar Whether the bounds should also be capped to the sidebar.
* @returns The given bounds capped to the canvas border w.r.t. the sidebar if enabled.
*/
export function capToCanvas(bounds: Bounds | Point, canvas: Bounds, capToSidebar = true): Bounds {
const originalBounds = asBounds(bounds)
// Cap bounds at canvas border
let x = capNumber(originalBounds.x, canvas.x, canvas.x + canvas.width - originalBounds.width)
const y = capNumber(originalBounds.y, canvas.y, canvas.y + canvas.height - originalBounds.height)
if (capToSidebar) {
// TODO: May be useful to cache the sidebar, since calling document.querySelector()
// can cause overhead if this function is called often
// Make sure the proxies aren't rendered behind the sidebar buttons at the top right
// If the sidebar is locked open, the proxies are also capped to its edge
// TODO: The logic for checking whether proxies should be displayed at all still doesn't
// consider the edge of the sidebar but rather the edge of the viewport which can
// be underneath the sidebar. This should probably be changed in the future.
const rect = document.querySelector('.sidebar__toggle-container')?.getBoundingClientRect()
const sidebarRect = document.querySelector('.sidebar__content')?.getBoundingClientRect()
const isSidebarOpen = document.querySelector('.sidebar--open')
if (rect) {
if (y < rect.y + rect.height && x > rect.x - originalBounds.width) {
x = rect.x - originalBounds.width
} else if (sidebarRect && isSidebarOpen && x > sidebarRect.x - originalBounds.width) {
x = sidebarRect.x - originalBounds.width
}
}
}
return { x, y, width: originalBounds.width, height: originalBounds.height }
}
}
/** Like {@link Bounds} but contains coordinates instead of width and height. */
export interface Rect {
readonly left: number
readonly right: number
readonly top: number
readonly bottom: number
}
export namespace Rect {
/** A Rect with all coordinates as zeros. */
export const EMPTY: Rect = Object.freeze({ left: 0, right: 0, top: 0, bottom: 0 })
/**
* Returns `bounds` as a Rect.
* @param bounds The Bounds/Dimension to transform into a Rect.
* @returns The Rect corresponding to `bounds`.
*/
export function fromBounds(bounds: Bounds | Dimension): Rect {
const b = asBounds(bounds)
return { left: b.x, right: b.x + b.width, top: b.y, bottom: b.y + b.height }
}
/**
* Returns `rect` as Bounds.
* @param rect The Rect to transform into Bounds.
* @returns The Bounds corresponding to `rect`.
*/
export function toBounds(rect: Rect): Bounds {
return { x: rect.left, y: rect.top, width: rect.right - rect.left, height: rect.bottom - rect.top }
}
}
/** A VNode containing some additional information to be used only by the {@link ProxyView}. */
export interface ProxyVNode extends VNode {
/** Whether this vnode is selected. */
selected?: boolean
/** Indicates that this vnode is for a proxy. */
proxy?: boolean
}
/** KGraphData containing some additional information to be used only by the {@link ProxyView}. */
export interface ProxyKGraphData extends KGraphData {
/** The proxy's scale. */
proxyScale: number
/** Whether title scaling should be used if smart zoom is enabled. */
useTitleScaling: boolean
}
/// ///// Functions ////////
/**
* Checks if this vnode is for a proxy.
* @param vnode The vnode to check this property for.
* @returns If the vnode is for a proxy.
*/
export function isProxy(vnode: VNode): vnode is ProxyVNode {
return 'proxy' in vnode
}
/**
* Determines if the SVG element is a proxy rendering for the given node ID.
* @param element The SVG element to check.
* @param nodeId The ID of the node to check.
* @returns if the SVG element is a proxy rendering for the given node ID.
*/
export function isProxyRendering(element: SVGElement, nodeId: string): boolean {
let currentElement: Element | null = element
while (currentElement instanceof SVGElement) {
if (currentElement.id.endsWith(nodeId + PROXY_SUFFIX)) {
return true
}
currentElement = currentElement.parentElement
}
return false
}
/** Appends {@link PROXY_SUFFIX} to the given id if the given id isn't already a proxy's id. */
export function getProxyId(id: string): string {
return id.endsWith(PROXY_SUFFIX) ? id : id + PROXY_SUFFIX
}
/** Removes {@link PROXY_SUFFIX} from the given id if the given id is a proxy's id. */
export function getNodeId(id: string): string {
return id.endsWith(PROXY_SUFFIX) ? id.substring(0, id.length - PROXY_SUFFIX.length) : id
}
/**
* Checks if `b1` is (partially) in `b2`.
* @returns `true` if `b1` is (partially) in `b2`.
*/
export function isInBounds(b1: Bounds, b2: Bounds): boolean {
const horizontalOverlap = b1.x + b1.width >= b2.x && b1.x <= b2.x + b2.width
const verticalOverlap = b1.y + b1.height >= b2.y && b1.y <= b2.y + b2.height
return horizontalOverlap && verticalOverlap
}
/**
* Checks if the given bounds overlap.
* @returns `true` if there is overlap.
*/
export function checkOverlap(b1: Bounds, b2: Bounds): boolean {
return isInBounds(b1, b2) || isInBounds(b2, b1)
}
/**
* Returns `bpd` if given bounds, otherwise fills the empty attributes with zeros.
* @param bpd The bounds/point/dimension.
*/
export function asBounds(bpd: Bounds | Point | Dimension): Bounds {
return isBounds(bpd) ? bpd : { x: 0, y: 0, width: 0, height: 0, ...bpd }
}
/**
* Returns `n` capped to the range given by `rangeExtreme1` and `rangeExtreme2`
* (inclusive), e.g. `n` in `[rangeExtreme1, rangeExtreme2]`.
* @param n The number to cap.
* @param rangeExtreme1 The lower bound of the range.
* @param rangeExtreme2 The upper bound of the range.
* @returns `n` capped to the given range. If `rangeExtreme1 > rangeExtreme2`,
* the two are swapped.
*/
export function capNumber(n: number, rangeExtreme1: number, rangeExtreme2: number): number {
if (rangeExtreme1 > rangeExtreme2) {
return capNumber(n, rangeExtreme2, rangeExtreme1)
}
return Math.max(rangeExtreme1, Math.min(rangeExtreme2, n))
}
/**
* Returns the distance between two bounds. If given two points, this
* basically just calculates the euclidean distance between the two.
* Explanation on how the distance is calculated:
*
* Partition the plane into 9 segments (as in tic-tac-toe):
*
* 1 | 2 | 3
*
* --+---+--
*
* 4 | 5 | 6
*
* --+---+--
*
* 7 | 8 | 9
*
* Now 5 correlates to b2.
*
* Using the other segments we can figure out the distance from b1 to b2:
*
* 1,3,7,9: calculate euclidean distance to nearest corner of 5
*
* 2,8: only take y-coordinate into consideration for calculating the distance
*
* 4,6: only take x-coordinate into consideration for calculating the distance
*
* @param bounds1 The first bounds/point to calculate the distance for.
* @param bounds2 The second bounds/point to calculate the distance for.
* @returns The distance between the two bounds.
*/
export function distanceBetweenBounds(bounds1: Bounds | Point, bounds2: Bounds | Point): number {
const b1 = asBounds(bounds1)
const b2 = asBounds(bounds2)
const b1Left = b1.x
const b1Right = b1Left + b1.width
const b1Top = b1.y
const b1Bottom = b1Top + b1.height
const b2Left = b2.x
const b2Right = b2Left + b2.width
const b2Top = b2.y
const b2Bottom = b2Top + b2.height
let dist = 0
if (b1Bottom < b2Top) {
// b1 above b2 (1,2,3)
if (b1Right < b2Left) {
// b1 top left of b2 (1)
dist = Point.euclideanDistance({ y: b1Bottom, x: b1Right }, { y: b2Top, x: b2Left })
} else if (b1Left > b2Right) {
// b1 top right of b2 (3)
dist = Point.euclideanDistance({ y: b1Bottom, x: b1Left }, { y: b2Top, x: b2Right })
} else {
// b1 top middle of b2 (2)
dist = b2Top - b1Bottom
}
} else if (b1Top > b2Bottom) {
// b1 below b2 (7,8,9)
if (b1Right < b2Left) {
// b1 bottom left of b2 (7)
dist = Point.euclideanDistance({ y: b1Top, x: b1Right }, { y: b2Bottom, x: b2Left })
} else if (b1Left > b2Right) {
// b1 bottom right of b2 (9)
dist = Point.euclideanDistance({ y: b1Top, x: b1Left }, { y: b2Bottom, x: b2Right })
} else {
// b1 bottom middle of b2 (8)
dist = b1Top - b2Bottom
}
} else {
// b1 same height as b2 (4,5,6)
// eslint-disable-next-line no-lonely-if
if (b1Right < b2Left) {
// b1 left of b2 (4)
dist = b2Left - b1Right
} else if (b1Left > b2Right) {
// b1 right of b2 (6)
dist = b1Left - b2Right
} else {
// b1 on b2 (overlap) (5)
dist = 0
}
}
return dist
}
/**
* Returns the intersection between the line spanning from `p1` to `p2` and `bounds`.
* @param p1 The start of the line.
* @param p2 The end of the line.
* @param bounds The bounds.
* @returns The intersection between the line and bounds or `undefined` if there is none.
*/
export function getIntersection(p1: Point, p2: Point, bounds: Bounds): Point | undefined {
// Intersection iff one of [p1, p2] in bounds and the other one out of bounds
if (Bounds.includes(bounds, p1)) {
// Intersection if p2 out of bounds
if (p2.x < bounds.x || p2.x > bounds.x + bounds.width) {
// Intersection at x, find y
const leftOrRight = p2.x < bounds.x ? bounds.x : bounds.x + bounds.width
// Scalar of line equation, must be in [0,1] as to not be before p1 or after p2, could be +-inf
const scalar = capNumber((leftOrRight - p1.x) / (p2.x - p1.x), 0, 1)
// Intersection point, cap to canvas with offset (and to sidebar aswell)
const intersectY = p1.y + scalar * (p2.y - p1.y)
return { x: leftOrRight, y: intersectY }
}
if (p2.y < bounds.y || p2.y > bounds.y + bounds.height) {
// Intersection at y, find x
const topOrBottom = p2.y < bounds.y ? bounds.y : bounds.y + bounds.height
// Scalar of line equation, must be in [0,1] as to not be before p1 or after p2, could be +-inf
const scalar = capNumber((topOrBottom - p1.y) / (p2.y - p1.y), 0, 1)
// Intersection point
const intersectX = p1.x + scalar * (p2.x - p1.x)
return { x: intersectX, y: topOrBottom }
}
} else if (Bounds.includes(bounds, p2)) {
// p1 out of bounds and p2 in bounds, definitely an intersection
if (p1.x < bounds.x || p1.x > bounds.x + bounds.width) {
// Intersection at x, find y
const leftOrRight = p1.x < bounds.x ? bounds.x : bounds.x + bounds.width
// Scalar of line equation, must be in [0,1] as to not be before p2 or after p1, could be +-inf
const scalar = capNumber((leftOrRight - p2.x) / (p1.x - p2.x), 0, 1)
// Intersection point, cap to canvas with offset (and to sidebar aswell)
const intersectY = p2.y + scalar * (p1.y - p2.y)
return { x: leftOrRight, y: intersectY }
}
// Intersection at y, find x
const topOrBottom = p1.y < bounds.y ? bounds.y : bounds.y + bounds.height
// Scalar of line equation, must be in [0,1] as to not be before p2 or after p1, could be +-inf
const scalar = capNumber((topOrBottom - p2.y) / (p1.y - p2.y), 0, 1)
// Intersection point
const intersectX = p2.x + scalar * (p1.x - p2.x)
return { x: intersectX, y: topOrBottom }
}
// No intersection
return undefined
}
/**
* Updates a VNode's transform attribute.
* @param vnode The VNode.
* @param transform The TransformAttributes.
* @param baseDiv The base div ID of the root node, from the viewerOptions.
*/
export function updateTransform(vnode: VNode, transform: TransformAttributes, baseDiv: string): void {
// Just changing the VNode's transform attribute is insufficient
// as it doesn't change the document's transform attribute while on the canvas
if (vnode.data) {
if (!vnode.data.attrs) {
vnode.data.attrs = {}
}
let transformString = `translate(${transform.x}, ${transform.y})`
if (transform.scale) {
transformString += ` scale(${transform.scale})`
}
if (transform.rotation) {
transformString += ` rotate(${transform.rotation}`
if (transform.rotationPoint) {
transformString += `, ${transform.rotationPoint.x}, ${transform.rotationPoint.y}`
}
transformString += ')'
}
// Update transform while off the canvas
vnode.data.attrs.transform = transformString
// Update transform while on the canvas
document.getElementById(`${baseDiv}_${vnode.key?.toString()}`)?.setAttribute('transform', transformString)
}
}
/**
* Updates a VNode's opacity.
* @param vnode The VNode.
* @param opacity The new opacity.
* @param baseDiv The base div ID of the root node, from the viewerOptions.
*/
export function updateOpacity(vnode: VNode, opacity: number, baseDiv: string): void {
// Just changing the VNode's opacity is insufficient
// as it doesn't change the document's opacity while on the canvas
if (vnode.data) {
if (!vnode.data.style) {
vnode.data.style = {}
}
// Update opacity while off the canvas
vnode.data.style.opacity = opacity.toString()
// Update opacity while on the canvas
const element = document.getElementById(`${baseDiv}_${vnode.key?.toString()}`)
if (element) {
element.style.opacity = opacity.toString()
}
}
}
/**
* Updates a VNode's pointer-events to make it click-through.
* @param vnode The VNode.
* @param clickThrough Whether the VNode should be click-through.
* @param baseDiv The base div ID of the root node, from the viewerOptions.
*/
export function updateClickThrough(vnode: VNode, clickThrough: boolean, baseDiv: string): void {
// Just changing the VNode's pointer-events is insufficient
// as it doesn't change the document's pointer-events while on the canvas
if (vnode.data) {
if (!vnode.data.style) {
vnode.data.style = {}
}
const pointerEvent = clickThrough ? 'none' : 'auto'
// Update pointer-events while off the canvas
vnode.data.style['pointer-events'] = pointerEvent
// Update pointer-events while on the canvas
const element = document.getElementById(`${baseDiv}_${vnode.key?.toString()}`)
if (element) {
element.style.pointerEvents = pointerEvent
}
}
}
/**
* Checks if `item` is contained in any (nested) group, e.g. if `item` is contained in a flattened version of `groups`.
* @returns `true` if `item` is contained in any (nested) group.
* @example anyContains([[0, 1], [1, 2]], 2) === true
*/
export function anyContains<T>(groups: T[][], item: T): boolean {
return groups.reduce((acc, group) => acc.concat(group), []).includes(item)
}
/**
* Join groups containing at least 1 same element. Transitive joining applies:
* @example joinTransitiveGroups([[0, 1], [1, 2], [2, 3]]) === [[0, 1, 2, 3]]
*/
export function joinTransitiveGroups<T>(groups: T[][]): T[][] {
const res = []
while (groups.length > 0) {
// Use a set for removing duplicates
let firstGroup = Array.from(new Set(groups.shift()))
let remainingGroups = [...groups]
let prevLength = -1
while (firstGroup.length > prevLength) {
// Iterate until no group can be joined with firstGroup anymore
prevLength = firstGroup.length
const nextRemainingGroups = []
for (const group of remainingGroups) {
if (new Set([...firstGroup].filter((x) => group.includes(x))).size > 0) {
// Intersection of firstGroup and group is not empty, join both groups
firstGroup = Array.from(new Set(firstGroup.concat(group)))
} else {
// firstGroup and group share no element
nextRemainingGroups.push(group)
}
}
remainingGroups = nextRemainingGroups
}
// firstGroup has been fully joined, add to res and continue with remainingGroups
res.push(Array.from(new Set(firstGroup)))
groups = remainingGroups
}
return res
}
/**
* Checks if `node` has an incoming edge to at least one of the other given `nodes`.
* @returns `true` if `node` has an incoming edge to at least one of the other given `nodes`.
*/
export function isIncomingToAny(node: SKNode, nodes: SKNode[]): boolean {
const ids = nodes.map((theNode) => theNode.id)
return ids.length > 0 && (node.incomingEdges as SKEdge[]).some((edge) => ids.includes(edge.sourceId))
}
/**
* Checks if `node` has an outgoing edge to at least one of the other given `nodes`.
* @returns `true` if `node` has an outgoing edge to at least one of the other given `nodes`.
*/
export function isOutgoingToAny(node: SKNode, nodes: SKNode[]): boolean {
const ids = nodes.map((theNode) => theNode.id)
return ids.length > 0 && (node.outgoingEdges as SKEdge[]).some((edge) => ids.includes(edge.targetId))
}
/**
* Checks if `node` is connected to at least one of the other given `nodes`.
* @returns `true` if `node` is connected to at least one of the other given `nodes`.
*/
export function isConnectedToAny(node: SKNode, nodes: SKNode[]): boolean {
return isIncomingToAny(node, nodes) || isOutgoingToAny(node, nodes)
}
/** Checks if `node` is selected or connected to any selected element. */
export function isSelectedOrConnectedToSelected(node: SKNode): boolean {
const selectedNodes = SelectedElementsUtil.getSelectedNodes()
return node.selected || isConnectedToAny(node, selectedNodes)
}
/// ///// Classes ////////
/** Util class for easily accessing the currently selected elements. */
export class SelectedElementsUtil {
/** The model index for looking up elements. */
private static index?: ModelIndexImpl
/** The currently selected elements. */
private static selectedElements: SKGraphElement[]
/** Cache of {@link selectedElements} containing only selected nodes. */
private static nodeCache?: SKNode[]
/** Cache of {@link selectedElements} containing only selected edges. */
private static edgeCache?: SKEdge[]
/** Cache of {@link selectedElements} containing only selected labels. */
private static labelCache?: SKLabel[]
/** Cache of {@link selectedElements} containing only selected ports. */
private static portCache?: SKPort[]
/**
* Clears all caches for stored element types.
*/
private static clearCaches(): void {
this.nodeCache = undefined
this.edgeCache = undefined
this.labelCache = undefined
this.portCache = undefined
}
/**
* Recalculates the selected elements.
*/
static recalculateSelection(): void {
this.clearCaches()
this.selectedElements = []
this.index?.all().forEach((element) => {
if (isSelectable(element) && element.selected && isSKGraphElement(element)) {
this.selectedElements.push(element)
}
})
}
/** Checks if the current index is reset. */
static isReset(): boolean {
return this.index === undefined
}
/** Resets the current index elements. */
static resetModel(): void {
this.index = undefined
this.selectedElements = []
}
/** Sets the current root. */
static setRoot(root: SModelRootImpl): void {
this.index = root.index
// calculate the selected elements.
this.recalculateSelection()
}
/// / Util methods ////
/** Returns the currently selected elements. */
static getSelectedElements(): SKGraphElement[] {
return this.selectedElements
}
/** Returns whether there are any currently selected elements. */
static areElementsSelected(): boolean {
return this.getSelectedElements().length > 0
}
/** Returns the currently selected nodes. */
static getSelectedNodes(): SKNode[] {
this.nodeCache = this.nodeCache ?? (this.selectedElements.filter((node) => node instanceof SKNode) as SKNode[])
return this.nodeCache
}
/** Returns whether there are any currently selected nodes. */
static areNodesSelected(): boolean {
return this.getSelectedNodes().length > 0
}
/** Returns the currently selected edges. */
static getSelectedEdges(): SKEdge[] {
this.edgeCache = this.edgeCache ?? (this.selectedElements.filter((node) => node instanceof SKEdge) as SKEdge[])
return this.edgeCache
}
/** Returns whether there are any currently selected edges. */
static areEdgesSelected(): boolean {
return this.getSelectedEdges().length > 0
}
/** Returns the currently selected labels. */
static getSelectedLabels(): SKLabel[] {
this.labelCache =
this.labelCache ?? (this.selectedElements.filter((node) => node instanceof SKLabel) as SKLabel[])
return this.labelCache
}
/** Returns whether there are any currently selected labels. */
static areLabelsSelected(): boolean {
return this.getSelectedLabels().length > 0
}
/** Returns the currently selected ports. */
static getSelectedPorts(): SKPort[] {
this.portCache = this.portCache ?? (this.selectedElements.filter((node) => node instanceof SKPort) as SKPort[])
return this.portCache
}
/** Returns whether there are any currently selected ports. */
static arePortsSelected(): boolean {
return this.getSelectedPorts().length > 0
}
}
/** Handles all actions regarding the {@link SelectedElementsUtil}. */
()
export class SelectedElementsUtilActionHandler implements IActionHandler, IActionHandlerInitializer {
handle(action: Action): void | Action | ICommand {
if (action.kind === SetModelAction.KIND || action.kind === UpdateModelAction.KIND) {
// Reset
SelectedElementsUtil.resetModel()
} else if (action.kind === SendModelContextAction.KIND) {
SelectedElementsUtil.recalculateSelection()
if (SelectedElementsUtil.isReset()) {
// Set new root
const sMCAction = action as SendModelContextAction
SelectedElementsUtil.setRoot(sMCAction.model.root)
}
}
}
initialize(registry: ActionHandlerRegistry): void {
// New model
registry.register(SetModelAction.KIND, this)
registry.register(UpdateModelAction.KIND, this)
registry.register(SendModelContextAction.KIND, this)
}
}