@kieler/klighd-core
Version:
Core KLighD diagram visualization with Sprotty
494 lines (427 loc) • 18.5 kB
text/typescript
/*
* KIELER - Kiel Integrated Environment for Layout Eclipse RichClient
*
* http://rtsys.informatik.uni-kiel.de/kieler
*
* Copyright 2021-2024 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
*/
import { KNode, SKGraphElement } from '@kieler/klighd-interactive/lib/constraint-classes'
import { SChildElementImpl, SModelRootImpl } from 'sprotty'
import { Point, Viewport } from 'sprotty-protocol'
import {
FullDetailRelativeThreshold,
FullDetailScaleThreshold,
RenderOptionsRegistry,
} from './options/render-options-registry'
import { isContainerRendering, isRendering, KRendering } from './skgraph-models'
/**
* The possible detail level of a KNode as determined by the DepthMap
*/
export enum DetailLevel {
FullDetails = 2,
MinimalDetails = 1,
OutOfBounds = 0,
}
/**
* All DetailLevel where the children are visible
*/
type DetailWithChildren = DetailLevel.FullDetails
/**
* Type predicate to determine whether a DetailLevel is a DetailWithChildren level
*/
export function isDetailWithChildren(detail: DetailLevel): detail is DetailWithChildren {
return detail === DetailLevel.FullDetails
}
/**
* All DetailLevel where the children are not visible
*/
type DetailWithoutChildren = Exclude<DetailLevel, DetailWithChildren>
type RegionIndexEntry =
| { containingRegion: Region; providingRegion: undefined }
| { containingRegion: undefined; providingRegion: Region }
| { containingRegion: Region; providingRegion: Region }
/**
* Divides Model KNodes into regions. On these detail level actions
* are defined via the detailLevel. Also holds additional information to determine
* the appropriate detail level, visibility and title for regions.
*/
export class DepthMap {
/**
* The region for immediate children of the SModelRoot,
* aka. the root regions
*/
rootRegions: Region[]
/**
* The model for which the DepthMap is generated
*/
rootElement: SModelRootImpl
/**
* Maps a given node id to the containing/providing Region
* Root Child Nodes will have a providing region and no containing Region, while all
* other nodes will have at least a containing region
*/
protected regionIndexMap: Map<string, RegionIndexEntry>
/**
* The last viewport for which we updated the state of KNodes
*/
viewport?: Viewport
/**
* The threshold for which we updated the state of KNodes
*/
lastThreshold?: number
/**
* Set for handling regions, that need to be checked for detail level changes.
* Consists of the region that contain at least one child with a lower detail level.
*/
criticalRegions: Set<Region>
/** Singleton pattern */
private static instance?: DepthMap
/**
* @param rootElement The root element of the model.
*/
private constructor(rootElement: SModelRootImpl) {
this.rootElement = rootElement
this.rootRegions = []
this.criticalRegions = new Set()
this.regionIndexMap = new Map()
}
protected reset(modelRoot: SModelRootImpl): void {
this.rootElement = modelRoot
// rootRegions are reset below as we also want to remove the edges from the graph spanned by the regions
this.criticalRegions.clear()
this.viewport = undefined
this.lastThreshold = undefined
this.regionIndexMap.clear()
let currentRegions = this.rootRegions
this.rootRegions = []
let remainingRegions: Region[] = []
// Go through all regions and clear the references to other Regions and KNodes
while (currentRegions.length !== 0) {
for (const region of currentRegions) {
remainingRegions.concat(region.children)
region.children = []
region.parent = undefined
}
currentRegions = remainingRegions
remainingRegions = []
}
}
/**
* Returns the current DepthMap instance or undefined if its not initialized
* @returns DepthMap | undefined
*/
public static getDM(): DepthMap | undefined {
return DepthMap.instance
}
/**
* Returns the current DepthMap instance or returns a new one.
* @param rootElement The model root element.
*/
public static init(rootElement: SModelRootImpl): DepthMap {
if (!DepthMap.instance) {
// Create new DepthMap, when there is none
DepthMap.instance = new DepthMap(rootElement)
} else if (DepthMap.instance.rootElement !== rootElement) {
// Reset and reinitialize if the model changed
DepthMap.instance.reset(rootElement)
}
return DepthMap.instance
}
/**
* It is generally advised to initialize the elements from root to leaf
*
* @param element The KGraphElement to initialize for DepthMap usage
*/
public initKGraphElement(
element: SChildElementImpl & SKGraphElement,
viewport: Viewport,
renderingOptions: RenderOptionsRegistry
): RegionIndexEntry {
let entry = this.regionIndexMap.get(element.id)
if (entry) {
// KNode already initialized
return entry
}
const relativeThreshold = renderingOptions.getValueOrDefault(FullDetailRelativeThreshold)
const scaleThreshold = renderingOptions.getValueOrDefault(FullDetailScaleThreshold)
if (element.parent === element.root && element instanceof KNode) {
const providedRegion = new Region(element)
providedRegion.absolutePosition = element.bounds
entry = { providingRegion: providedRegion, containingRegion: undefined }
providedRegion.detail = this.computeDetailLevel(providedRegion, viewport, relativeThreshold, scaleThreshold)
this.rootRegions.push(providedRegion)
element.properties.absoluteScale = 1
element.properties.absoluteX = element.bounds.x
element.properties.absoluteY = element.bounds.y
} else {
// parent should always exist because we're traversing root to leaf
const parentEntry = this.initKGraphElement(element.parent as KNode, viewport, renderingOptions)
entry = {
containingRegion: parentEntry.providingRegion ?? parentEntry.containingRegion,
providingRegion: undefined,
}
const kRendering = this.findRendering(element)
const current = element.parent as KNode
// compute own absolute scale and absolute position based on parent position
const parentAbsoluteScale = (element.parent as any).properties.absoluteScale
const scaleFactor = (element.parent as any).properties['org.eclipse.elk.topdown.scaleFactor'] ?? 1
element.properties.absoluteScale = parentAbsoluteScale * scaleFactor
if (element instanceof KNode) {
element.properties.absoluteX =
(current.properties.absoluteX as number) +
element.bounds.x * (element.properties.absoluteScale as number)
element.properties.absoluteY =
(current.properties.absoluteY as number) +
element.bounds.y * (element.properties.absoluteScale as number)
}
if (
element instanceof KNode &&
kRendering &&
isContainerRendering(kRendering) &&
kRendering.children.length !== 0
) {
entry = { containingRegion: entry.containingRegion, providingRegion: new Region(element) }
entry.providingRegion.detail = this.computeDetailLevel(
entry.providingRegion,
viewport,
relativeThreshold,
scaleThreshold
)
entry.providingRegion.parent = entry.containingRegion
entry.containingRegion.children.push(entry.providingRegion)
entry.providingRegion.absolutePosition = {
x: element.properties.absoluteX as number,
y: element.properties.absoluteY as number,
}
}
}
this.regionIndexMap.set(element.id, entry)
return entry
}
/**
* Finds the KRendering in the given graph element.
* @param element The graph element to look up the rendering for.
* @returns The KRendering.
*/
findRendering(element: SKGraphElement): KRendering | undefined {
for (const data of element.data) {
if (data !== null && isRendering(data)) {
return data
}
}
return undefined
}
public getContainingRegion(
element: SChildElementImpl & SKGraphElement,
viewport: Viewport,
renderOptions: RenderOptionsRegistry
): Region | undefined {
// initKGraphELement already checks if it is already initialized and if it is returns the existing value
return this.initKGraphElement(element, viewport, renderOptions).containingRegion
}
public getProvidingRegion(
node: SChildElementImpl & KNode,
viewport: Viewport,
renderOptions: RenderOptionsRegistry
): Region | undefined {
// initKGraphElement already checks if it is already initialized and if it is returns the existing value
return this.initKGraphElement(node, viewport, renderOptions).providingRegion
}
/**
* Decides the appropriate detail level for regions based on their size in the viewport and applies that state.
*
* @param viewport The current viewport.
*/
updateDetailLevels(viewport: Viewport, renderingOptions: RenderOptionsRegistry): void {
const relativeThreshold = renderingOptions.getValueOrDefault(FullDetailRelativeThreshold)
const scaleThreshold = renderingOptions.getValueOrDefault(FullDetailScaleThreshold)
this.viewport = { zoom: viewport.zoom, scroll: viewport.scroll }
this.lastThreshold = relativeThreshold
// Initialize detail level on first run.
if (this.criticalRegions.size === 0) {
for (const region of this.rootRegions) {
const vis = this.computeDetailLevel(region, viewport, relativeThreshold, scaleThreshold)
if (vis === DetailLevel.FullDetails) {
this.updateRegionDetailLevel(region, vis, viewport, relativeThreshold, scaleThreshold)
}
}
} else {
this.checkCriticalRegions(viewport, relativeThreshold, scaleThreshold)
}
}
/**
* Set detail level for the given region and recursively determine and update the children's detail level
*
* @param region The root region
* @param viewport The current viewport
* @param relativeThreshold The detail level threshold
*/
updateRegionDetailLevel(
region: Region,
vis: DetailWithChildren,
viewport: Viewport,
relativeThreshold: number,
scaleThreshold: number
): void {
region.setDetailLevel(vis)
let isCritical = false
region.children.forEach((childRegion) => {
const childVis = this.computeDetailLevel(childRegion, viewport, relativeThreshold, scaleThreshold)
if (childVis < vis) {
isCritical = true
}
if (isDetailWithChildren(childVis)) {
this.updateRegionDetailLevel(childRegion, childVis, viewport, relativeThreshold, scaleThreshold)
} else {
this.recursiveSetOOB(childRegion, childVis)
}
})
if (isCritical) {
this.criticalRegions.add(region)
}
}
recursiveSetOOB(region: Region, vis: DetailWithoutChildren): void {
region.setDetailLevel(vis)
// region is not/no longer the parent of a detail level boundary as such it is not critical
this.criticalRegions.delete(region)
region.children.forEach((childRegion) => {
// bail early when child is less or equally detailed already
if (vis < childRegion.detail) {
this.recursiveSetOOB(childRegion, vis)
}
})
}
/**
* Looks for a change in detail level for all critical regions.
* Applies the level change and manages the critical regions.
*
* @param viewport The current viewport
* @param relativeThreshold The full detail threshold
*/
checkCriticalRegions(viewport: Viewport, relativeThreshold: number, scaleThreshold: number): void {
// All regions that are at a detail level boundary (child has lower detail level and parent is at a DetailWithChildren level).
let toBeProcessed: Set<Region> = new Set(this.criticalRegions)
// The regions that have become critical and therefore need to be checked as well
let nextToBeProcessed: Set<Region> = new Set()
while (toBeProcessed.size !== 0) {
for (const region of toBeProcessed) {
const vis = this.computeDetailLevel(region, viewport, relativeThreshold, scaleThreshold)
region.setDetailLevel(vis)
if (region.parent && vis !== region.parent.detail) {
nextToBeProcessed.add(region.parent)
this.criticalRegions.add(region.parent)
}
if (isDetailWithChildren(vis)) {
this.updateRegionDetailLevel(region, vis, viewport, relativeThreshold, scaleThreshold)
} else {
this.recursiveSetOOB(region, vis)
}
}
toBeProcessed = nextToBeProcessed
nextToBeProcessed = new Set()
}
}
/**
* Decides the appropriate detail level for a region
* based on their size in the viewport and visibility
*
* @param region The region in question
* @param viewport The current viewport
* @param relativeThreshold The full detail threshold
* @returns The appropriate detail level
*/
computeDetailLevel(
region: Region,
viewport: Viewport,
relativeThreshold: number,
scaleThreshold: number
): DetailLevel {
if (!this.isInBounds(region, viewport)) {
return DetailLevel.OutOfBounds
}
if (!region.parent) {
// Regions without parents should always be full detail if they are visible
return DetailLevel.FullDetails
}
const viewportSize = this.scaleMeasureInViewport(region.boundingRectangle, viewport)
const scale = viewport.zoom * (region.boundingRectangle.properties.absoluteScale as number)
// change to full detail when relative size threshold is reached or the scaling within the region is big enough to be readable.
if (viewportSize >= relativeThreshold || scale > scaleThreshold) {
return DetailLevel.FullDetails
}
return DetailLevel.MinimalDetails
}
/**
* Checks visibility of a region with position from browser coordinates in current viewport.
*
* @param region The region in question for visibility.
* @param viewport The current viewport.
* @returns Boolean value indicating the visibility of the region in the current viewport.
*/
isInBounds(region: Region, viewport: Viewport): boolean {
if (region.absolutePosition) {
const { canvasBounds } = this.rootElement
return (
region.absolutePosition.x + region.boundingRectangle.bounds.width - viewport.scroll.x >= 0 &&
region.absolutePosition.x - viewport.scroll.x <= canvasBounds.width / viewport.zoom &&
region.absolutePosition.y + region.boundingRectangle.bounds.height - viewport.scroll.y >= 0 &&
region.absolutePosition.y - viewport.scroll.y <= canvasBounds.height / viewport.zoom
)
}
// Better to assume it is visible, if information are not sufficient
return true
}
/**
* Compares the size of a node to the viewport and returns the smallest fraction of either height or width.
*
* @param node The KNode in question
* @param viewport The current viewport
* @returns the relative size of the KNodes shortest dimension
*/
scaleMeasureInViewport(node: KNode, viewport: Viewport): number {
const horizontal = node.bounds.width / (node.root.canvasBounds.width / viewport.zoom)
const vertical = node.bounds.height / (node.root.canvasBounds.height / viewport.zoom)
const absoluteScale = node.properties.absoluteScale as number
const scaleMeasure = Math.min(horizontal, vertical)
return scaleMeasure * absoluteScale
}
}
/**
* Combines KNodes into regions. These correspond to child areas. A region can correspond to
* a region or a super state in the model. Also manages the boundaries, title candidates,
* tree structure of the model and application of detail level of its KNodes.
*/
export class Region {
/** The rectangle of the child area in which the region lies. */
boundingRectangle: KNode
/** The absolute position of the boundingRectangle based on the layout information of the SModel. */
absolutePosition: Point
/** the regions current detail level that is used by all children */
detail: DetailLevel
/** The immediate parent region of this region. */
parent?: Region
/** All immediate child regions of this region */
children: Region[]
/** Constructor initializes element array for region. */
constructor(boundingRectangle: KNode) {
this.boundingRectangle = boundingRectangle
this.children = []
this.detail = DetailLevel.FullDetails
}
/**
* Applies the detail level to all elements of a region.
* @param level the detail level to apply
*/
setDetailLevel(level: DetailLevel): void {
this.detail = level
}
}