diff --git a/.vscode/launch.json b/.vscode/launch.json index 7164569d..68e8d08b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,6 +14,19 @@ "outFiles": ["${workspaceFolder}/applications/klighd-vscode/dist/**/*.js"], "preLaunchTask": "${defaultBuildTask}" }, + { + "name": "Launch VS Code Extension (socket)", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}/applications/klighd-vscode"], + "env": { + "KEITH_LS_PORT": "5007" + }, + "sourceMaps": true, + "smartStep": true, + "outFiles": ["${workspaceFolder}/applications/klighd-vscode/dist/**/*.js"], + "preLaunchTask": "${defaultBuildTask}" + }, { "name": "Start CLI Webserver", "type": "node", diff --git a/applications/klighd-cli/package.json b/applications/klighd-cli/package.json index 9ffee47d..f6bd4382 100644 --- a/applications/klighd-cli/package.json +++ b/applications/klighd-cli/package.json @@ -20,7 +20,7 @@ "clean": "rm -rf lib dist bin", "lint": "eslint .", "build": "run-p --print-label \"build:*\"", - "build:app": "webpack --mode production --devtool hidden-source-map", + "build:app": "webpack --mode development --devtool eval-source-map", "build:server": "tsc -p ./tsconfig.server.json", "watch": "run-p --print-label \"watch:*\"", "watch:app": "webpack --watch", diff --git a/packages/klighd-core/src/bookmarks/bookmark-panel.tsx b/packages/klighd-core/src/bookmarks/bookmark-panel.tsx index 47ba8ac0..ef8269cc 100644 --- a/packages/klighd-core/src/bookmarks/bookmark-panel.tsx +++ b/packages/klighd-core/src/bookmarks/bookmark-panel.tsx @@ -3,7 +3,7 @@ * KIELER - Kiel Integrated Environment for Layout Eclipse RichClient * * http://rtsys.informatik.uni-kiel.de/kieler - * + * * Copyright 2021 by * + Kiel University * + Department of Computer Science @@ -172,8 +172,8 @@ export class BookmarkPanel extends SidebarPanel { const save = document.getElementById(bookmark.saveId); if (save && bookmark.bookmarkIndex !== undefined) { save.classList.toggle("options__hidden", true) - const new_name = save.getElementsByTagName("input")[0].value ?? undefined; - this.actionDispatcher.dispatch(RenameBookmarkAction.create(bookmark.bookmarkIndex, new_name)); + const newName = save.getElementsByTagName("input")[0].value ?? undefined; + this.actionDispatcher.dispatch(RenameBookmarkAction.create(bookmark.bookmarkIndex, newName)); } } diff --git a/packages/klighd-core/src/bookmarks/bookmark-registry.ts b/packages/klighd-core/src/bookmarks/bookmark-registry.ts index cc841059..835c7719 100644 --- a/packages/klighd-core/src/bookmarks/bookmark-registry.ts +++ b/packages/klighd-core/src/bookmarks/bookmark-registry.ts @@ -2,7 +2,7 @@ * KIELER - Kiel Integrated Environment for Layout Eclipse RichClient * * http://rtsys.informatik.uni-kiel.de/kieler - * + * * Copyright 2021 by * + Kiel University * + Department of Computer Science @@ -27,7 +27,7 @@ import { AddBookmarkAction, Bookmark, DeleteBookmarkAction, GoToBookmarkAction, * A simple {@link Registry} that holds a list of all added Bookmarks * * Handles CreateBookmark and GoToBookmark actions - * + * */ @injectable() export class BookmarkRegistry extends Registry { @@ -50,11 +50,11 @@ export class BookmarkRegistry extends Registry { } else if (DeleteBookmarkAction.isThisAction(action)) { - this.deleteBookmark(action.bookmark_index) + this.deleteBookmark(action.bookmarkIndex) } else if (RenameBookmarkAction.isThisAction(action)) { - this.updateBookmarkName(action.bookmark_index, action.new_name) + this.updateBookmarkName(action.bookmarkIndex, action.newName) } else if (AddBookmarkAction.isThisAction(action)) { @@ -69,19 +69,19 @@ export class BookmarkRegistry extends Registry { this.notifyListeners(); } - protected deleteBookmark(bookmark_index: number): void { - const index = this._bookmarks.findIndex((value) => value.bookmarkIndex === bookmark_index); + protected deleteBookmark(bookmarkIndex: number): void { + const index = this._bookmarks.findIndex((value) => value.bookmarkIndex === bookmarkIndex); this._bookmarks.splice(index, 1) this.notifyListeners(); } - protected updateBookmarkName(bookmark_index: number, new_name: string): void { - const bm = this._bookmarks.find((bm) => bm.bookmarkIndex === bookmark_index) + protected updateBookmarkName(bookmarkIndex: number, newName: string): void { + const bm = this._bookmarks.find((bm) => bm.bookmarkIndex === bookmarkIndex) if (bm) { - if (new_name === "") { + if (newName === "") { bm.name = undefined } else { - bm.name = new_name + bm.name = newName } this.notifyListeners(); } diff --git a/packages/klighd-core/src/bookmarks/bookmark.ts b/packages/klighd-core/src/bookmarks/bookmark.ts index 3f8a61ad..04a11e6c 100644 --- a/packages/klighd-core/src/bookmarks/bookmark.ts +++ b/packages/klighd-core/src/bookmarks/bookmark.ts @@ -2,7 +2,7 @@ * KIELER - Kiel Integrated Environment for Layout Eclipse RichClient * * http://rtsys.informatik.uni-kiel.de/kieler - * + * * Copyright 2021 by * + Kiel University * + Department of Computer Science @@ -168,16 +168,16 @@ export namespace AddBookmarkAction { */ export interface DeleteBookmarkAction extends Action { kind: typeof DeleteBookmarkAction.KIND - bookmark_index: number + bookmarkIndex: number } export namespace DeleteBookmarkAction { export const KIND = 'delete-bookmark' - export function create(bookmark_index: number): DeleteBookmarkAction { + export function create(bookmarkIndex: number): DeleteBookmarkAction { return { kind: KIND, - bookmark_index, + bookmarkIndex, } } @@ -189,18 +189,18 @@ export namespace DeleteBookmarkAction { export interface RenameBookmarkAction extends Action { kind: typeof RenameBookmarkAction.KIND - bookmark_index: number - new_name: string + bookmarkIndex: number + newName: string } export namespace RenameBookmarkAction { export const KIND = 'rename-bookmark' - export function create(bookmark_index: number, new_name: string): RenameBookmarkAction { + export function create(bookmarkIndex: number, newName: string): RenameBookmarkAction { return { kind: KIND, - bookmark_index, - new_name, + bookmarkIndex, + newName, } } @@ -280,4 +280,4 @@ export namespace SetInitialBookmarkAction { export function isThisAction(action: Action): action is SetInitialBookmarkAction { return action.kind === SetInitialBookmarkAction.KIND; } -} \ No newline at end of file +} diff --git a/packages/klighd-core/src/depth-map.ts b/packages/klighd-core/src/depth-map.ts deleted file mode 100644 index c273edff..00000000 --- a/packages/klighd-core/src/depth-map.ts +++ /dev/null @@ -1,458 +0,0 @@ -/* - * KIELER - Kiel Integrated Environment for Layout Eclipse RichClient - * - * http://rtsys.informatik.uni-kiel.de/kieler - * - * Copyright 2021 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 { KGraphElement, KNode } from "@kieler/klighd-interactive/lib/constraint-classes"; -import { SChildElement, SModelRoot } from "sprotty"; -import { Point, Viewport } from "sprotty-protocol"; -import { RenderOptionsRegistry, FullDetailRelativeThreshold, FullDetailScaleThreshold } 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 - */ -function isDetailWithChildren(detail: DetailLevel): detail is DetailWithChildren { - return detail === DetailLevel.FullDetails -} - -/** - * All DetailLevel where the children are not visible - */ -type DetailWithoutChildren = Exclude - -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: SModelRoot; - - /** - * 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; - - /** - * 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; - - /** Singleton pattern */ - private static instance?: DepthMap; - - /** - * @param rootElement The root element of the model. - */ - private constructor(rootElement: SModelRoot) { - this.rootElement = rootElement - this.rootRegions = [] - this.criticalRegions = new Set() - this.regionIndexMap = new Map() - } - - protected reset(model_root: SModelRoot): void { - this.rootElement = model_root - // 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 current_regions = this.rootRegions - this.rootRegions = [] - - let remaining_regions: Region[] = [] - - // Go through all regions and clear the references to other Regions and KNodes - while (current_regions.length !== 0) { - for (const region of current_regions) { - remaining_regions.concat(region.children) - region.children = [] - region.parent = undefined - } - current_regions = remaining_regions - remaining_regions = [] - } - - } - - /** - * 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: SModelRoot): void { - 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) - } - } - - /** - * It is generally advised to initialize the elements from root to leaf - * - * @param element The KGraphElement to initialize for DepthMap usage - */ - public initKGraphElement(element: SChildElement & KGraphElement, 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) - - } else { - - const parentEntry = this.initKGraphElement(element.parent as KNode, viewport, renderingOptions); - - entry = { containingRegion: parentEntry.providingRegion ?? parentEntry.containingRegion, providingRegion: undefined } - - const kRendering = this.findRendering(element) - 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); - - let current = element.parent as KNode; - let offsetX = 0; - let offsetY = 0; - - let currentEntry = this.regionIndexMap.get(current.id) - - while (current && currentEntry && !currentEntry.providingRegion) { - offsetX += current.bounds.x - offsetY += current.bounds.y - current = current.parent as KNode - currentEntry = this.regionIndexMap.get(current.id) - } - - offsetX += currentEntry?.providingRegion?.absolutePosition?.x ?? 0 - offsetY += currentEntry?.providingRegion?.absolutePosition?.y ?? 0 - - entry.providingRegion.absolutePosition = { - x: offsetX + element.bounds.x, - y: offsetY + element.bounds.y - } - } - - } - - 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: KGraphElement): KRendering | undefined { - for (const data of element.data) { - if (data === null) - continue - if (isRendering(data)) { - return data - } - } - return undefined - } - - public getContainingRegion(element: SChildElement & KGraphElement, 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: SChildElement & 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 = new Set(this.criticalRegions) - - // The regions that have become critical and therefore need to be checked as well - let nextToBeProcessed: Set = new Set() - - while (toBeProcessed.size !== 0) { - toBeProcessed.forEach(region => { - 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 - } else if (!region.parent) { - // Regions without parents should always be full detail if they are visible - return DetailLevel.FullDetails - } else { - const viewportSize = this.sizeInViewport(region.boundingRectangle, viewport) - const scale = viewport.zoom - // 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 - } else { - 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.canvasBounds - - 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) - } else { - // 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 - */ - sizeInViewport(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) - return horizontal < vertical ? horizontal : vertical - } -} - - -/** - * 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[] - /** Contains the height of the title of the region, if there is one. */ - regionTitleHeight?: number - /** Indentation of region title. */ - regionTitleIndentation?: number - /** 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 - } -} diff --git a/packages/klighd-core/src/hierarchy/depth-map.ts b/packages/klighd-core/src/hierarchy/depth-map.ts new file mode 100644 index 00000000..4da58a0a --- /dev/null +++ b/packages/klighd-core/src/hierarchy/depth-map.ts @@ -0,0 +1,321 @@ +/* + * KIELER - Kiel Integrated Environment for Layout Eclipse RichClient + * + * http://rtsys.informatik.uni-kiel.de/kieler + * + * Copyright 2021-2022 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 { KGraphElement } from "@kieler/klighd-interactive/lib/constraint-classes"; +import { SChildElement, SModelRoot } from "sprotty"; +import { SKGraphModelRenderer } from "../skgraph-model-renderer"; +import { isContainerRendering, isRendering, KRendering, SKNode } from "../skgraph-models"; +import { Region } from "./region"; + +/** + * 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 KChildElement = SChildElement & KGraphElement; + +/** + * 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 + +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 + */ + private rootRegions: Region[]; + + /** + * The model for which the DepthMap is generated + */ + private rootElement: SModelRoot; + + /** + * 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 + */ + private regionIndexMap: Map; + + /** + * 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. + */ + private criticalRegions: Set; + + /** Singleton pattern */ + private static instance?: DepthMap; + + /** + * @param rootElement The root element of the model. + */ + private constructor(rootElement: SModelRoot) { + this.rootElement = rootElement + this.rootRegions = [] + this.criticalRegions = new Set() + this.regionIndexMap = new Map() + } + + protected reset(modelRoot: SModelRoot): 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.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: SModelRoot): void { + 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) + } + } + + /** + * It is generally advised to initialize the elements from root to leaf + * + * @param element The KChildElement to initialize for DepthMap usage + * @param ctx The rendering context + */ + public initKGraphElement(element: KChildElement, ctx: SKGraphModelRenderer): RegionIndexEntry { + + let entry = this.regionIndexMap.get(element.id) + if (entry) { + // KNode already initialized + return entry + } + + if (element.parent === element.root && element instanceof SKNode) { + const providedRegion = new Region(element) + + entry = { providingRegion: providedRegion, containingRegion: undefined } + + element.calculateScaledBounds(ctx) + providedRegion.detail = providedRegion.computeDetailLevel(ctx) + + this.rootRegions.push(providedRegion) + + } else { + + const parentEntry = this.initKGraphElement(element.parent as KChildElement, ctx); + + entry = { containingRegion: parentEntry.providingRegion ?? parentEntry.containingRegion, providingRegion: undefined } + + const kRendering = this.findRendering(element) + if (element instanceof SKNode && kRendering && isContainerRendering(kRendering) && kRendering.children.length !== 0) { + + + entry = { containingRegion: entry.containingRegion, providingRegion: new Region(element) } + + entry.providingRegion.parent = entry.containingRegion + entry.containingRegion.children.push(entry.providingRegion); + + element.calculateScaledBounds(ctx) + entry.providingRegion.detail = entry.providingRegion.computeDetailLevel(ctx) + } + + } + + 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: KGraphElement): KRendering | undefined { + for (const data of element.data) { + if (data === null) + continue + if (isRendering(data)) { + return data + } + } + return undefined + } + + public getContainingRegion(element: KChildElement, ctx: SKGraphModelRenderer): Region | undefined { + // initKGraphELement already checks if it is already initialized and if it is returns the existing value + return this.initKGraphElement(element, ctx).containingRegion + } + + public getProvidingRegion(node: SKNode, ctx: SKGraphModelRenderer): Region | undefined { + // initKGraphElement already checks if it is already initialized and if it is returns the existing value + return this.initKGraphElement(node, ctx).providingRegion + } + + /** + * Decides the appropriate detail level for regions based on their size in the viewport and applies that state. + * + * @param ctx The current rendering context + */ + updateDetailLevels(ctx: SKGraphModelRenderer): void { + + // Initialize detail level on first run. + if (this.criticalRegions.size == 0) { + for (const region of this.rootRegions) { + const vis = region.computeDetailLevel(ctx) + if (vis === DetailLevel.FullDetails) { + this.updateRegionDetailLevel(region, vis, ctx) + } + } + } else { + this.checkCriticalRegions(ctx) + } + } + + /** + * Set detail level for the given region and recursively determine and update the children's detail level + * + * @param region The root region + * @param vis the detail level to apply + * @param ctx The current rendering context + */ + updateRegionDetailLevel(region: Region, vis: DetailWithChildren, ctx: SKGraphModelRenderer): void { + region.setDetailLevel(vis) + let isCritical = false; + + region.children.forEach(childRegion => { + const childVis = childRegion.computeDetailLevel(ctx); + if (childVis < vis) { + isCritical = true + } + if (isDetailWithChildren(childVis)) { + this.updateRegionDetailLevel(childRegion, childVis, ctx) + } 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 ctx The current rendering context + */ + checkCriticalRegions(ctx: SKGraphModelRenderer): 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 = new Set(this.criticalRegions) + + // The regions that have become critical and therefore need to be checked as well + let nextToBeProcessed: Set = new Set() + + while (toBeProcessed.size !== 0) { + toBeProcessed.forEach(region => { + const vis = region.computeDetailLevel(ctx); + 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, ctx) + } else { + this.recursiveSetOOB(region, vis) + } + + }) + + toBeProcessed = nextToBeProcessed; + nextToBeProcessed = new Set(); + } + + } + +} diff --git a/packages/klighd-core/src/hierarchy/region.ts b/packages/klighd-core/src/hierarchy/region.ts new file mode 100644 index 00000000..9010f99a --- /dev/null +++ b/packages/klighd-core/src/hierarchy/region.ts @@ -0,0 +1,101 @@ +import { FullDetailRelativeThreshold, FullDetailScaleThreshold } from "../options/render-options-registry"; +import { SKGraphModelRenderer } from "../skgraph-model-renderer"; +import { SKNode } from "../skgraph-models"; +import { DetailLevel } from "./depth-map"; + +/** + * 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: SKNode; + /** 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[]; + /** Contains the height of the title of the region, if there is one. */ + regionTitleHeight?: number; + /** Indentation of region title. */ + regionTitleIndentation?: number; + /** Constructor initializes element array for region. */ + constructor(boundingRectangle: SKNode) { + this.boundingRectangle = boundingRectangle; + this.children = []; + this.detail = DetailLevel.FullDetails; + } + + /** + * Checks visibility of a region with position from browser coordinates in current viewport. + * + * @param ctx The current rendering context + * @returns Boolean value indicating the visibility of the region in the current viewport. + */ + isInBounds(ctx: SKGraphModelRenderer): boolean { + const { absoluteBounds: bounds } = this.boundingRectangle.calculateScaledBounds(ctx) + const canvasBounds = this.boundingRectangle.root.canvasBounds; + + return bounds.x + bounds.width - ctx.viewport.scroll.x >= 0 + && bounds.x - ctx.viewport.scroll.x <= (canvasBounds.width / ctx.viewport.zoom) + && bounds.y + bounds.height - ctx.viewport.scroll.y >= 0 + && bounds.y - ctx.viewport.scroll.y <= (canvasBounds.height / ctx.viewport.zoom); + + } + + /** + * Compares the size of a region to the viewport and returns the smallest fraction of either height or width. + * + * @param ctx The current rendering context + * @returns the relative size of the region's shortest dimension + */ + sizeInViewport(ctx: SKGraphModelRenderer): number { + const { absoluteBounds: bounds } = this.boundingRectangle.calculateScaledBounds(ctx) + + const canvasBounds = this.boundingRectangle.root.canvasBounds; + + const horizontal = bounds.width / (canvasBounds.width / ctx.viewport.zoom); + const vertical = bounds.height / (canvasBounds.height / ctx.viewport.zoom); + return horizontal < vertical ? horizontal : vertical; + } + + /** + * Decides the appropriate detail level for a region + * based on their size in the viewport and visibility + * + * @param ctx The current rendering context + * @returns The appropriate detail level + */ + computeDetailLevel(ctx: SKGraphModelRenderer): DetailLevel { + + const relativeThreshold = ctx.renderOptionsRegistry.getValueOrDefault(FullDetailRelativeThreshold); + const scaleThreshold = ctx.renderOptionsRegistry.getValueOrDefault(FullDetailScaleThreshold); + + if (!this.isInBounds(ctx)) { + return DetailLevel.OutOfBounds; + } else if (!this.parent) { + // Regions without parents should always be full detail if they are visible + return DetailLevel.FullDetails; + } else { + const viewportSize = this.sizeInViewport(ctx); + + const scale = (this.boundingRectangle.parent as SKNode).calculateScaledBounds(ctx).effectiveChildScale * ctx.viewport.zoom; + // 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; + } else { + return DetailLevel.MinimalDetails; + } + } + } + + /** + * Applies the detail level to all elements of a region. + * @param level the detail level to apply + */ + setDetailLevel(level: DetailLevel): void { + this.detail = level; + } +} diff --git a/packages/klighd-core/src/options/render-options-registry.ts b/packages/klighd-core/src/options/render-options-registry.ts index 36fafd3f..d61b6019 100644 --- a/packages/klighd-core/src/options/render-options-registry.ts +++ b/packages/klighd-core/src/options/render-options-registry.ts @@ -71,6 +71,7 @@ export class ShowConstraintOption implements RenderOption { export class UseSmartZoom implements RenderOption { static readonly ID: string = 'use-smart-zoom' static readonly NAME: string = 'Smart Zoom' + static readonly DEFAULT: boolean = false readonly id: string = UseSmartZoom.ID readonly name: string = UseSmartZoom.NAME readonly type: TransformationOptionType = TransformationOptionType.CHECK @@ -154,6 +155,20 @@ export class TextSimplificationThreshold implements RangeOption { currentValue = 3 } +/** + * Boolean option to enable and disable the scaling of titles + */ +export class ScaleTitles implements RenderOption { + static readonly ID: string = 'use-title-scaling' + static readonly NAME: string = 'Scale Titles' + static readonly DEFAULT: boolean = false + readonly id: string = ScaleTitles.ID + readonly name: string = ScaleTitles.NAME + readonly type: TransformationOptionType = TransformationOptionType.CHECK + readonly initialValue: boolean = true + currentValue = true +} + /** * The factor by which titles of collapsed regions get scaled by * in relation to their size at native resolution. @@ -207,6 +222,61 @@ export class MinimumLineWidth implements RangeOption { readonly initialValue: number = 0.5 currentValue = 0.5 } +/** + * Boolean option to toggle the scaling of lines based on zoom level. + */ +export class ScaleNodes implements RenderOption { + static readonly ID: string = 'use-node-scaling' + static readonly NAME: string = 'Scale Nodes' + static readonly DEFAULT: boolean = false + readonly id: string = ScaleNodes.ID + readonly name: string = ScaleNodes.NAME + readonly type: TransformationOptionType = TransformationOptionType.CHECK + readonly initialValue: boolean = true + currentValue = true +} + +/** + * The factor by which titles of collapsed regions get scaled by + * in relation to their size at native resolution. + */ +export class NodeScalingFactor implements RangeOption { + static readonly ID: string = 'node-scaling-factor' + static readonly NAME: string = 'Node Scaling Factor' + static readonly DEFAULT: number = 1 + readonly id: string = NodeScalingFactor.ID + readonly name: string = NodeScalingFactor.NAME + readonly type: TransformationOptionType = TransformationOptionType.RANGE + readonly values: any[] = [] + readonly range = { + first: 0.5, + second: 3 + } + readonly stepSize = 0.01 + readonly initialValue: number = NodeScalingFactor.DEFAULT + currentValue = 1 +} + +/** + * The factor by which titles of collapsed regions get scaled by + * in relation to their size at native resolution. + */ +export class NodeMargin implements RangeOption { + static readonly ID: string = 'node-margin' + static readonly NAME: string = 'Node Margin' + static readonly DEFAULT: number = 1 + readonly id: string = NodeMargin.ID + readonly name: string = NodeMargin.NAME + readonly type: TransformationOptionType = TransformationOptionType.RANGE + readonly values: any[] = [] + readonly range = { + first: 0, + second: 20 + } + readonly stepSize = 0.5 + readonly initialValue: number = NodeMargin.DEFAULT + currentValue = 10 +} /** * The style shadows should be drawn in, either the paper mode shadows (nice, but slow in @@ -269,8 +339,14 @@ export class RenderOptionsRegistry extends Registry { this.register(SimplifySmallText); this.register(TextSimplificationThreshold); + this.register(ScaleTitles); this.register(TitleScalingFactor); + this.register(ScaleNodes); + this.register(NodeScalingFactor); + this.register(NodeMargin); + + this.register(UseMinimumLineWidth); this.register(MinimumLineWidth); diff --git a/packages/klighd-core/src/scaling-util.ts b/packages/klighd-core/src/scaling-util.ts new file mode 100644 index 00000000..0fafdf18 --- /dev/null +++ b/packages/klighd-core/src/scaling-util.ts @@ -0,0 +1,429 @@ + +import { NodeMargin, NodeScalingFactor } from './options/render-options-registry'; +import { KPolyline, K_POLYGON, K_POLYLINE, K_ROUNDED_BENDS_POLYLINE, K_SPLINE, SKEdge, SKNode } from './skgraph-models'; +import { SKGraphModelRenderer } from './skgraph-model-renderer'; +import { PointToPointLine } from 'sprotty' +import { Bounds, Dimension, Point } from 'sprotty-protocol' +import { BoundsAndTransformation } from './views-common'; + +/** + * A class for some helper methods used calculate the scale to be used for graphs elements and + * for actually scaling them. + */ +export class ScalingUtil { + + private constructor() { + // private constructor as this class should not be instantiated + } + + /** + * @param node the bounds of the node to scale + * @param parent the dimensions of the parent of the node to scale + * @param margin the margin node shall retain to parent + * @returns the maximum scale at which node retains the margin to parent + */ + public static maxParentScale(node: Bounds, parent: Dimension, margin: number): number { + // the maximum scale that keeps the node in bounds height wise + const maxHeightScale = (parent.height - 2 * margin) / node.height + // the maximum scale that keeps the node in bounds width wise + const maxWidthScale = (parent.width - 2 * margin) / node.width + + return Math.min(maxHeightScale, maxWidthScale) + } + + /** For some elements a and b determine the maximum scale to which they can be scaled without violating the margin in one dimension + * this is used by maxSiblingScaling to determine the max scale for width and height separately + * + * @param offset_a the offset of a + * @param length_a the length of a + * @param offset_b the offset of b + * @param length_b the length of b + * @param available the available space for both a and b + * @param margin the margin to preserve between a and b + * @returns the calculated maximum scale + */ + private static maxSiblingScaleDimension(offset_a: number, length_a: number, offset_b: number, length_b: number, available: number, margin: number): number { + + // There are three scenarios that can happen + // either a is before b, + // b is before a, + // or a and b overlap + // + // In the last case we can just use one as we never want a scale below one and we take the maximum of both dimensions outside this function + // In the other two cases we need to solve one of the following two equations for scale + // result_a.offset = result_b.offset + result_b.length + margin + // result_b.offset = result_a.offset + result_a.length + margin + // both with + // result_a = scaleDimension(offset_a, length_a, available, scale) + // result_b = scaleDimension(offset_b, length_b, available, scale) + // + // result_a and result_b should only be positive and larger than one if that is the case present + // below we have solve both equations and take the maximum of the solution to all three cases as the result. + + const fa = (offset_a * length_a) / (available - length_a) + const fb = (offset_b * length_b) / (available - length_b) + + const numerator = offset_a + fa - offset_b - fb + + const result_1 = (numerator - margin) / (fa - fb + length_b) + const result_2 = (-numerator - margin) / (fb - fa + length_a) + + return Math.max(result_1, result_2, 1) + } + + + /** + * Calculate the maximum scale at which node and sibling retain the given margin between them + * @param node the bounds of the node to scale + * @param parent the dimensions of the parent of the node to scale + * @param sibling a sibling of the node to scale + * @param margin the margin node and sibling shall retain when both scaled by the result + * @returns the maximum scale at which node and sibling retain margin between them + */ + public static maxSiblingScale(node: Bounds, parent: Dimension, sibling: Bounds, margin: number): number { + + // calculate the scale for each dimension at which we reach our sibling + const result_1 = ScalingUtil.maxSiblingScaleDimension(node.x, node.width, sibling.x, sibling.width, parent.width, margin) + const result_2 = ScalingUtil.maxSiblingScaleDimension(node.y, node.height, sibling.y, sibling.height, parent.height, margin) + + // take the max as that which ever is further is relevant for bounding us, but should be at least 1 + return Math.max(result_1, result_2, 1) + } + /** + * Scale bounds in the specified dimensions by the specified scale + * @param originalBounds the bounds to scale + * @param availableSpace the space available to scale the bounds + * @param scale the scale factor by which to scale + * @returns the scaled bounds + */ + private static calculateScaledBounds(originalBounds: Bounds, availableSpace: Dimension, scale: number): Bounds { + const originalWidth = originalBounds.width + const originalHeight = originalBounds.height + const originalX = originalBounds.x + const originalY = originalBounds.y + + // Calculate the new x offset and width: + const { length: newWidth, offset: newX } = ScalingUtil.scaleDimension(originalX, originalWidth, availableSpace.width, scale) + + // Same for y offset and height + const { length: newHeight, offset: newY } = ScalingUtil.scaleDimension(originalY, originalHeight, availableSpace.height, scale) + + return { x: newX, y: newY, width: newWidth, height: newHeight } + } + + /** Scale along one axis taking up space before and after the element at an equal ratio + * + * @param offset the start of the element in the scaled dimension + * @param length the length of the element in the scaled dimension + * @param available the space available in the scaled dimension + * @param scale the factor by which to scale the element + * @returns the scaled length and adjusted offset + */ + private static scaleDimension(offset: number, length: number, available: number, scale: number): { offset: number, length: number } { + // calculate the scaled length + const newLength = length * scale; + // space before the element to be scaped + const prefix = offset + // space after the element to be scaled + const postfix = available - offset - length + // new offset after taking space from before and after the scaled element at an equal ratio + const newOffset = offset - prefix * (newLength - length) / (prefix + postfix) + return { offset: newOffset, length: newLength } + } + + /** + * Calculate where a point ends up after scaling some bound + * @param originalBounds The original bounds before scaling + * @param newBounds The bounds after scaling + * @param originalPoint The point before scaling + * @returns The point after scaling + */ + public static calculateScaledPoint(originalBounds: Bounds, newBounds: Bounds, originalPoint: Point): Point { + + let newX + let newY + + if (originalBounds.width == 0 || newBounds.width == 0) { + newX = originalPoint.x - originalBounds.x + newBounds.x + } else { + const relativeX = originalBounds.width == 0 ? 0 : (originalPoint.x - originalBounds.x) / originalBounds.width + newX = newBounds.x + relativeX * newBounds.width + } + + if (originalBounds.height == 0 || newBounds.height == 0) { + newY = originalPoint.y - originalBounds.y + newBounds.y + } else { + const relativeY = originalBounds.height == 0 ? 0 : (originalPoint.y - originalBounds.y) / originalBounds.height + newY = newBounds.y + relativeY * newBounds.height + } + + return { x: newX, y: newY } + } + + static calculateUpscale(effectiveScale: number, maxScale: number, childBounds: Bounds, parentBounds: Dimension, margin: number, siblings: Bounds[] = []): number { + // we want that the effectiveScale * desiredScale = maxScale + // so that the we effectively up scale to maxScale + const desiredScale = maxScale / effectiveScale; + + if (desiredScale < 1) { + return 1; + } + + // the maximum scale at which the child still fits into the parent + const parentScaling = ScalingUtil.maxParentScale(childBounds, parentBounds, margin) + + let preferredScale = Math.min(desiredScale, parentScaling) + + for (const sibling of siblings) { + if (preferredScale <= 1) { + return 1 + } + const siblingScaling = ScalingUtil.maxSiblingScale(childBounds, parentBounds, sibling, margin) + preferredScale = Math.min(preferredScale, siblingScaling) + } + + // we never want to scale down + return Math.max(1, preferredScale) + } + + /** + * Calculate upscaled bounds for a graph element + * + * @param effectiveScale the effective scale at the position the element will be rendered + * @param maxScale the maximum factor to upscale the element by + * @param childBounds the bounds of the element to scale + * @param parentBounds the bounds of the parent of the element to scale + * @param margin the margin to keep between the element and its parent as well as it and its siblings, + * it is assumed that the element does not violate this margin at normal scale (1) + * @param siblings the bounds of the elements siblings that should be taken into account while scaling + * @returns the upscaled local bounds and local scale + */ + public static upscaleBounds(effectiveScale: number, maxScale: number, childBounds: Bounds, parentBounds: Dimension, margin: number, siblings: Bounds[] = []): { bounds: Bounds, scale: number } { + + const scalingFactor = ScalingUtil.calculateUpscale(effectiveScale, maxScale, childBounds, parentBounds, margin, siblings) + const newBounds = ScalingUtil.calculateScaledBounds(childBounds, parentBounds, scalingFactor) + + return { bounds: newBounds, scale: scalingFactor } + } + + /** + * Determine the intersections between the bounding box of bounds and the line + * @param bounds the bounding box to intersect + * @param line the line to intersect + * @returns the intersection points (0 - 4), might contain duplicates when going through a corner + */ + public static intersections(bounds: Bounds, line: PointToPointLine): Point[] { + + const tl = bounds as Point + const tr = Point.add(bounds, { x: 0, y: bounds.height }) + const bl = Point.add(bounds, { x: bounds.width, y: 0 }) + const br = Point.add(bounds, { x: bounds.width, y: bounds.height }) + + const top = new PointToPointLine(tl, tr) + const bottom = new PointToPointLine(bl, br) + const left = new PointToPointLine(tl, bl) + const right = new PointToPointLine(tr, br) + + return [line.intersection(top), line.intersection(bottom), line.intersection(left), line.intersection(right)].filter(p => p !== undefined).map(p => p as Point) + } + + /** + * + * @param point the point to calculate the distance to + * @returns Function that can be used to sort by distance to point + */ + public static sortByDist(point: Point): (a: Point, b: Point) => number { + return (a: Point, b: Point) => { + const a_dist = Point.euclideanDistance(a, point) + const b_dist = Point.euclideanDistance(b, point) + return a_dist - b_dist + } + } + + /** + * For lines calculate new line points to account for node scaling + * For polygons (arrow head) adjusts position to new line end point and perform scaling + */ + public static performLineScaling(rendering: KPolyline, + edge: SKEdge, + parent: SKNode, + source: SKNode, + target: SKNode, + boundsAndTransformation: BoundsAndTransformation, + context: SKGraphModelRenderer, + gAttrs: { transform?: string | undefined }, + points: Point[] + ): Point[] { + const sourceScaled = source.calculateScaledBounds(context); + const targetScaled = target.calculateScaledBounds(context); + const parentScaled = parent.calculateScaledBounds(context); + + + // the last previous point that is not a bend point, but an actual coordinate point + let lastPointInSource = points[0]; + let lastPointOutTarget = points[0]; + + const scaledStart = ScalingUtil.calculateScaledPoint(source.bounds, sourceScaled.relativeBounds, points[0]) + const scaledEnd = ScalingUtil.calculateScaledPoint(target.bounds, targetScaled.relativeBounds, points[points.length - 1]) + + let maxCoordPerPoint = 1 + switch (rendering.type) { + case K_SPLINE: + maxCoordPerPoint = 3 + // fallthrough + case K_ROUNDED_BENDS_POLYLINE: + case K_POLYLINE: { + + const newPoints: Point[] = [] + + let i = 1 + + // skip points in the start node + out1: while (i < points.length) { + const remainingPoints = points.length - i + const z = Math.min(maxCoordPerPoint, remainingPoints) + const p = points[i + z - 1] + + if (Bounds.includes(sourceScaled.relativeBounds, p)) { + // the coordinate is inbounds of the source so skip this set of points + lastPointInSource = p + i += z; + continue; + } + + for (let j = 1; j < z; j++) { + if (Bounds.includes(sourceScaled.relativeBounds, points[i + j])) { + // one of the bendpoints of this coordinate is in bounds of source so we skip this set of points + i += z; + continue out1; + } + } + + // neither the coordinate nor its bendpoints are in bounds for source so we can keep it for now + + break; + } + + lastPointOutTarget = lastPointInSource + + const remainingPoints = points.length - i + const z = Math.min(maxCoordPerPoint, remainingPoints) + const p = points[i + z - 1] + + // determine new start point + const startChoice = calculateEndPoint(lastPointInSource, p, i, newPoints, true) ?? scaledStart; + + newPoints.push(startChoice) + + // keep points not in end node + out2: while (i < points.length) { + const remainingPoints = points.length - i + const z = Math.min(maxCoordPerPoint, remainingPoints) + const p = points[i + z - 1] + + if (Bounds.includes(targetScaled.relativeBounds, p)) { + // the coordinate is inbounds of the target so we stop including points + break; + } + + + lastPointOutTarget = p; + + const tmp = [] + + for (let j = 1; j < z; j++) { + if (Bounds.includes(targetScaled.relativeBounds, points[i + j])) { + // one of the bendpoints of this coordinate is in bounds of target so we skip this set of points + i += z; + continue out2; + } + tmp.push(points[i + j]) + } + + // the point and all its bendpoints are not in bound for target + for (const b of tmp) { + newPoints.push(b) + } + newPoints.push(p) + i += z; + + } + + // determine new end point + const remainingPoints2 = points.length - i + const z2 = Math.min(maxCoordPerPoint, remainingPoints2) + const p2 = points[i + z2 - 1] + + const endChoice = calculateEndPoint(lastPointOutTarget, p2, i, newPoints, false) ?? scaledEnd; + + newPoints.push(endChoice) + + edge.movedEndsBy = { start: Point.subtract(startChoice, points[0]), end: Point.subtract(endChoice, points[points.length - 1]) } + return newPoints; + } + case K_POLYGON: { + // this is reached for the arrow heads of edges + // it adjusts the arrow head to match the changed edge position and also scales the arrow head similar to how nodes are scaled + if (edge.movedEndsBy && edge.routingPoints.length > 0) { + let newPoint = boundsAndTransformation.bounds as Point + + if (Bounds.includes(boundsAndTransformation.bounds, edge.routingPoints[0])) { + newPoint = Point.add(edge.movedEndsBy.start, boundsAndTransformation.bounds) + } else if (Bounds.includes(boundsAndTransformation.bounds, edge.routingPoints[edge.routingPoints.length - 1])) { + newPoint = Point.add(edge.movedEndsBy.end, boundsAndTransformation.bounds) + } + + const targetScale = context.renderOptionsRegistry.getValueOrDefault(NodeScalingFactor) + const margin = context.renderOptionsRegistry.getValueOrDefault(NodeMargin) + + const parentScale = ScalingUtil.maxParentScale(boundsAndTransformation.bounds, parent.bounds, margin) + + const desiredScale = targetScale / (parentScaled.effectiveChildScale * context.viewport.zoom) + const preferredScale = Math.min(desiredScale, parentScale) + + const scale = Math.max(preferredScale, 1) + + // translate arrow head to origin, then scale, then translate to new position + gAttrs.transform = "translate(" + newPoint.x + "," + newPoint.y + ") scale(" + scale + ") translate(" + -boundsAndTransformation.bounds.x + "," + -boundsAndTransformation.bounds.y + ") " + (gAttrs.transform ?? "") + } + break + } + default: + console.error("Unexpected Line Type: ", rendering.type) + } + return points + + function calculateEndPoint(prev: Point, next: Point, i: number, newPoints: any[], start: boolean): Point | void { + + let choice; + + const edge = new PointToPointLine(prev, next); + + const target = (start ? sourceScaled : targetScaled).relativeBounds + + const intersections = ScalingUtil.intersections(target, edge); + + intersections.sort(ScalingUtil.sortByDist(start ? next : prev)); + + if (intersections.length > 0) { + choice = intersections[0]; + } + + // keep the control points of the current point if they are not in the target + if (!start) { + const remainingPoints = points.length - i + const z = Math.min(maxCoordPerPoint, remainingPoints) + + if (z >= 2 && !Bounds.includes(target, points[i])) { + newPoints.push(points[i]); + if (z == 3 && !Bounds.includes(target, points[i + 1])) { + newPoints.push(points[i + 1]); + } + } + } + + return choice; + } + + } +} diff --git a/packages/klighd-core/src/skgraph-model-renderer.ts b/packages/klighd-core/src/skgraph-model-renderer.ts index 6b70a9a5..2907cd49 100644 --- a/packages/klighd-core/src/skgraph-model-renderer.ts +++ b/packages/klighd-core/src/skgraph-model-renderer.ts @@ -19,7 +19,7 @@ import { KlighdInteractiveMouseListener } from '@kieler/klighd-interactive/lib/k import { VNode } from 'snabbdom'; import { IVNodePostprocessor, ModelRenderer, RenderingTargetKind, SParentElement, ViewRegistry } from 'sprotty'; import { Viewport } from 'sprotty-protocol'; -import { DepthMap } from './depth-map'; +import { DepthMap } from './hierarchy/depth-map'; import { RenderOptionsRegistry } from './options/render-options-registry'; import { KRenderingLibrary, EDGE_TYPE, LABEL_TYPE, NODE_TYPE, PORT_TYPE, SKGraphElement } from './skgraph-models'; @@ -40,9 +40,35 @@ export class SKGraphModelRenderer extends ModelRenderer { positions: string[] renderingDefs: Map renderOptionsRegistry: RenderOptionsRegistry - titles: VNode[][] + private titles: VNode[][] = [] viewport: Viewport - + private _effectiveZoom: number[] = [1] + + get effectiveZoom(): number { + return this._effectiveZoom[this._effectiveZoom.length - 1] + } + + enterTitleScope() : void { + this.titles.push([]) + } + + // leaves the current title scope and returns the titles collected in the left scope + exitTitleScope() : VNode[] { + return this.titles.pop() ?? [] + } + + pushTitle(title: VNode): void { + this.titles[this.titles.length - 1].push(title) + } + + pushEffectiveZoom(zoom: number): void { + this._effectiveZoom.push(zoom) + } + + popEffectiveZoom(): number | undefined { + return this._effectiveZoom.pop() + } + /** * Renders all children of the SKGraph that should be rendered within the child area of the element. @@ -77,4 +103,4 @@ export class SKGraphModelRenderer extends ModelRenderer { }) .filter(vnode => vnode !== undefined) as VNode[] } -} \ No newline at end of file +} diff --git a/packages/klighd-core/src/skgraph-models.ts b/packages/klighd-core/src/skgraph-models.ts index ccbe5ab7..e5e9c4eb 100644 --- a/packages/klighd-core/src/skgraph-models.ts +++ b/packages/klighd-core/src/skgraph-models.ts @@ -16,7 +16,11 @@ */ import { KEdge, KGraphData, KGraphElement, KNode } from '@kieler/klighd-interactive/lib/constraint-classes'; -import { Bounds, boundsFeature, moveFeature, Point, popupFeature, RectangularPort, RGBColor, selectFeature, SLabel, SModelElement } from 'sprotty'; +import { boundsFeature, moveFeature, popupFeature, RectangularPort, RGBColor, selectFeature, SLabel, SModelElement, SShapeElement } from 'sprotty'; +import { Point, Bounds } from 'sprotty-protocol' +import { NodeMargin, NodeScalingFactor, ScaleNodes } from './options/render-options-registry'; +import { ScalingUtil } from './scaling-util'; +import { SKGraphModelRenderer } from './skgraph-model-renderer'; /** * This is the superclass of all elements of a graph such as nodes, edges, ports, @@ -34,16 +38,120 @@ export const EDGE_TYPE = 'edge' export const PORT_TYPE = 'port' export const LABEL_TYPE = 'label' +type NodeScaleBoundsResult = { + /** + * The nodes scaled bounds relative to it's parent + */ + relativeBounds: Bounds, + /** + * the nodes scale relative to its parent and its original size + */ + relativeScale: number, + /** + * the nodes absolute scaled bounds + */ + absoluteBounds: Bounds, + /** + * the scale children inherit form this node and its ancestors, not including the viewport zoom + */ + effectiveChildScale: number +} + /** * Represents the Sprotty version of its java counterpart in KLighD. */ -export class SKNode extends KNode { +export class SKNode extends KNode implements SKGraphElement { tooltip?: string + + private _scaleNodesCacheKey?: boolean + private _minScaleCacheKey?: number + private _zoomCacheKey?: number + private _marginKey?: boolean + private _nodeScaledBounds?: NodeScaleBoundsResult + hasFeature(feature: symbol): boolean { return feature === selectFeature || (feature === moveFeature && (this.parent as SKNode).properties && (this.parent as SKNode).properties['org.eclipse.elk.interactiveLayout'] as boolean) || feature === popupFeature } + properties: Record + + /** + * calculate the rendered bounds of the node + */ + calculateScaledBounds(ctx: SKGraphModelRenderer): NodeScaleBoundsResult { + const performNodeScaling = ctx.renderOptionsRegistry.getValueOrDefault(ScaleNodes); + const minNodeScale = ctx.renderOptionsRegistry.getValueOrDefault(NodeScalingFactor); + const margin = ctx.renderOptionsRegistry.getValueOrDefault(NodeMargin); + + // has the cached result been invalidated + const needsUpdate = this._scaleNodesCacheKey !== performNodeScaling + || this._marginKey !== margin + || this._minScaleCacheKey !== minNodeScale + || this._zoomCacheKey !== ctx.viewport.zoom + + if (this._nodeScaledBounds === undefined || needsUpdate) { + // no valid cached result available + + if (this.parent && this.parent instanceof SKNode) { + const parentScaled = this.parent.calculateScaledBounds(ctx) + + if (performNodeScaling) { + // not the root node and node scaling enabled + + const effectiveZoom = parentScaled.effectiveChildScale * ctx.viewport.zoom + const siblings: Bounds[] = this.parent.children.filter((sibling) => sibling != this && sibling.type == NODE_TYPE).map((sibling) => (sibling as SShapeElement).bounds) + + const upscale = ScalingUtil.upscaleBounds(effectiveZoom, minNodeScale, this.bounds, this.parent.bounds, margin, siblings); + + let absoluteBounds = { + x: upscale.bounds.x * parentScaled.effectiveChildScale, + y: upscale.bounds.y * parentScaled.effectiveChildScale, + width: upscale.bounds.width * parentScaled.effectiveChildScale, + height: upscale.bounds.height * parentScaled.effectiveChildScale + } + + absoluteBounds = Bounds.translate(absoluteBounds, parentScaled.absoluteBounds) + + this._nodeScaledBounds = { + relativeBounds: upscale.bounds, + relativeScale: upscale.scale, + absoluteBounds: absoluteBounds, + effectiveChildScale: parentScaled.effectiveChildScale * upscale.scale + } + } else { + // node scaling is not enabled + + const absoluteBounds = Bounds.translate(this.bounds, parentScaled.absoluteBounds) + + this._nodeScaledBounds = { + relativeBounds: this.bounds, + relativeScale: 1, + absoluteBounds: absoluteBounds, + effectiveChildScale: 1 + } + } + } else { + // this is the root node, therefore node scaling should never be applied + // or we break zooming out + + this._nodeScaledBounds = { + relativeBounds: this.bounds, + relativeScale: 1, + absoluteBounds: this.bounds, + effectiveChildScale: 1 + } + } + } + + this._scaleNodesCacheKey = performNodeScaling + this._marginKey = margin + this._zoomCacheKey = ctx.viewport.zoom + this._minScaleCacheKey = minNodeScale + + return this._nodeScaledBounds + } + } /** @@ -81,8 +189,11 @@ export class SKLabel extends SLabel implements SKGraphElement { /** * Represents the Sprotty version of its java counterpart in KLighD. */ -export class SKEdge extends KEdge { +export class SKEdge extends KEdge implements SKGraphElement { tooltip?: string + + movedEndsBy?: { start: Point, end: Point } + hasFeature(feature: symbol): boolean { return feature === selectFeature || feature === popupFeature } @@ -709,4 +820,4 @@ export function isSKGraphElement(test: unknown): test is SKGraphElement { && (test as any)['areNonChildAreaChildrenRendered'] !== undefined && (test as any)['opacity'] !== undefined && (test as any)['data'] !== undefined -} \ No newline at end of file +} diff --git a/packages/klighd-core/src/update/update-depthmap-model.ts b/packages/klighd-core/src/update/update-depthmap-model.ts index 1ae8898a..1bc8b8b4 100644 --- a/packages/klighd-core/src/update/update-depthmap-model.ts +++ b/packages/klighd-core/src/update/update-depthmap-model.ts @@ -2,7 +2,7 @@ * KIELER - Kiel Integrated Environment for Layout Eclipse RichClient * * http://rtsys.informatik.uni-kiel.de/kieler - * + * * Copyright 2021 by * + Kiel University * + Department of Computer Science @@ -19,7 +19,7 @@ import { inject, injectable } from 'inversify'; import { Command, TYPES } from 'sprotty' import { CommandExecutionContext, CommandReturn } from 'sprotty'; import { Action } from 'sprotty-protocol'; -import { DepthMap } from '../depth-map'; +import { DepthMap } from '../hierarchy/depth-map'; /** * Simple UpdateDepthMapAction fires the UpdateDepthMapModelCommand @@ -63,4 +63,4 @@ export class UpdateDepthMapModelCommand extends Command { redo(context: CommandExecutionContext): CommandReturn { return context.root } -} \ No newline at end of file +} diff --git a/packages/klighd-core/src/views-rendering.tsx b/packages/klighd-core/src/views-rendering.tsx index 53bfe9a9..b2f047f2 100644 --- a/packages/klighd-core/src/views-rendering.tsx +++ b/packages/klighd-core/src/views-rendering.tsx @@ -17,10 +17,10 @@ /** @jsx svg */ import { VNode } from 'snabbdom'; import { svg } from 'sprotty'; // eslint-disable-line @typescript-eslint/no-unused-vars -import { Bounds } from 'sprotty-protocol'; +import { Bounds, almostEquals } from 'sprotty-protocol'; import { KGraphData, KNode } from '@kieler/klighd-interactive/lib/constraint-classes'; -import { DetailLevel } from './depth-map'; -import { PaperShadows, SimplifySmallText, TextSimplificationThreshold, TitleScalingFactor } from './options/render-options-registry'; +import { DetailLevel } from './hierarchy/depth-map'; +import { PaperShadows, SimplifySmallText, TextSimplificationThreshold, TitleScalingFactor, UseSmartZoom, ScaleTitles, NodeMargin, ScaleNodes } from './options/render-options-registry'; import { SKGraphModelRenderer } from './skgraph-model-renderer'; import { Arc, HorizontalAlignment, isRendering, KArc, KChildArea, KContainerRendering, KForeground, KHorizontalAlignment, KImage, KPolyline, KRendering, KRenderingLibrary, KRenderingRef, KRoundedBendsPolyline, @@ -32,6 +32,7 @@ import { ColorStyles, DEFAULT_CLICKABLE_FILL, DEFAULT_FILL, getKStyles, getSvgColorStyle, getSvgColorStyles, getSvgLineStyles, getSvgShadowStyles, getSvgTextStyles, isInvisible, KStyles, LineStyles } from './views-styles'; +import { ScalingUtil } from './scaling-util'; // ----------------------------- Functions for rendering different KRendering as VNodes in svg -------------------------------------------- @@ -202,17 +203,16 @@ export function renderRectangularShape( } if (element && context.depthMap) { - const region = context.depthMap.getProvidingRegion(parent as KNode, context.viewport, context.renderOptionsRegistry) + const region = context.depthMap.getProvidingRegion(parent as SKNode, context) if (region && region.detail !== DetailLevel.FullDetails && parent.children.length >= 1) { const offsetY = region.regionTitleHeight ?? 0 const offsetX = region.regionTitleIndentation ?? 0 const bounds = Math.min(region.boundingRectangle.bounds.height - offsetY, region.boundingRectangle.bounds.width - offsetX) const size = 50 let scalingFactor = Math.max(bounds, 0) / size + // Use zoom for constant size in viewport. - if (context.viewport) { - scalingFactor = Math.min(1 / context.viewport.zoom, scalingFactor) - } + scalingFactor = Math.min(1 / context.effectiveZoom, scalingFactor) const y = scalingFactor > 0 ? offsetY / scalingFactor : 0 const x = scalingFactor > 0 ? offsetX / scalingFactor : 0 @@ -270,13 +270,24 @@ export function renderLine(rendering: KPolyline, const shadowStyles = paperShadows ? getSvgShadowStyles(styles, context) : undefined const lineStyles = getSvgLineStyles(styles, parent, context) - const points = getPoints(parent, rendering, boundsAndTransformation) + let points = getPoints(parent, rendering, boundsAndTransformation) if (points.length === 0) { return {renderChildRenderings(rendering, parent, stylesToPropagate, context, childOfNodeTitle)} } + const performScaling = context.renderOptionsRegistry.getValueOrDefault(ScaleNodes) + + if (performScaling + && parent instanceof SKEdge + && parent.source instanceof SKNode + && parent.target instanceof SKNode + && parent.parent instanceof SKNode + ) { + points = ScalingUtil.performLineScaling(rendering, parent, parent.parent, parent.source, parent.target, boundsAndTransformation, context, gAttrs, points) + } + // now define the line's path. let path = '' switch (rendering.type) { @@ -422,8 +433,7 @@ export function renderKText(rendering: KText, const simplificationThreshold = context.renderOptionsRegistry.getValueOrDefault(TextSimplificationThreshold) const proportionalHeight = 0.5 // height of replacement compared to full text height - if (context.viewport && rendering.properties['klighd.calculated.text.bounds'] as Bounds - && (rendering.properties['klighd.calculated.text.bounds'] as Bounds).height * context.viewport.zoom <= simplificationThreshold) { + if (rendering.properties['klighd.calculated.text.bounds'] as Bounds && (rendering.properties['klighd.calculated.text.bounds'] as Bounds).height * context.effectiveZoom <= simplificationThreshold) { const replacements: VNode[] = [] lines.forEach((line, index) => { const xPos = boundsAndTransformation && boundsAndTransformation.bounds.x ? boundsAndTransformation.bounds.x : 0 @@ -527,7 +537,8 @@ export function renderKText(rendering: KText, export function renderChildRenderings(parentRendering: KContainerRendering, parentElement: SKGraphElement, propagatedStyles: KStyles, context: SKGraphModelRenderer, childOfNodeTitle?: boolean): (VNode | undefined)[] { // children only should be rendered if the parentElement is not a shadow - if (!(parentElement instanceof SKNode) || !parentElement.shadow) { + const isShadow = (parentElement instanceof SKNode) && parentElement.shadow + if (!isShadow && parentRendering.children) { const renderings: (VNode | undefined)[] = [] for (const childRendering of parentRendering.children) { const rendering = getRendering([childRendering], parentElement, propagatedStyles, context, childOfNodeTitle) @@ -550,7 +561,7 @@ export function renderError(rendering: KRendering): VNode { * Renders some SVG shape, possibly with an added shadow, as given by the svgFunction. If a simple shadow * should be added, it is added as four copies of the SVG shape with rgba(0,0,0,0.1) and the * offsets defined by the kShadow, if a nice shadow should be added, it is added via SVG filter. - * + * * @param kShadow The shadow definition for the rendering, or undefined if no shadow should be added * @param shadowStyles specific shadow filter ID, if this element should be drawn with a smooth shadow and no simple one. * @param svgFunction The callback function rendering the wanted SVG shape. x and y are the offsets @@ -590,7 +601,7 @@ export function renderWithShadow( /** * Renders a rectangle with all given information. - * + * * @param bounds bounds data calculated for this rectangle. * @param rx rx parameter of SVG rect * @param ry ry parameter of SVG rect @@ -608,7 +619,7 @@ export function renderSVGRect(bounds: Bounds, rx: number, ry: number, lineStyles * Renders a rectangle with all given information. * If the rendering is a shadow (has a kShadow parameter), all stroke attributes are ignored (no stroke on the shadow) and a * black fill with 0.1 alpha is returned. - * + * * @param x x offset of the rectangle, to be used for shadows only. * @param y y offset of the rectangle, to be used for shadows only. * @param shadowStyles specific shadow filter ID, if this element should be drawn with a smooth shadow and no simple one. @@ -646,7 +657,7 @@ export function renderSingleSVGRect(x: number | undefined, y: number | undefined /** * Renders an image with all given information. - * + * * @param bounds bounds data calculated for this image. * @param image The image href string * @param kShadow shadow information. @@ -659,7 +670,7 @@ export function renderSVGImage(bounds: Bounds, shadowStyles: string | undefined, /** * Renders an image with all given information. * If the rendering is a shadow, a shadow rect is drawn instead. - * + * * @param x x offset of the image, to be used for shadows only. * @param y y offset of the image, to be used for shadows only. * @param kShadow shadow information. Controls what this method does. @@ -692,7 +703,7 @@ export function renderSingleSVGImage(x: number | undefined, y: number | undefine /** * Renders an arc with all given information. - * + * * @param lineStyles style information for lines (stroke etc.) * @param colorStyles style information for color * @param shadowStyles specific shadow filter ID, if this element should be drawn with a smooth shadow and no simple one. @@ -708,7 +719,7 @@ export function renderSVGArc(lineStyles: LineStyles, colorStyles: ColorStyles, s * Renders an arc with all given information. * If the rendering is a shadow (has a kShadow parameter), all stroke attributes are ignored (no stroke on the shadow) and a * black fill with 0.1 alpha is returned. - * + * * @param x x offset of the arc, to be used for shadows only. * @param y y offset of the arc, to be used for shadows only. * @param shadowStyles specific shadow filter ID, if this element should be drawn with a smooth shadow and no simple one. @@ -740,7 +751,7 @@ export function renderSingleSVGArc(x: number | undefined, y: number | undefined, /** * Renders an ellipse with all given information. - * + * * @param lineStyles style information for lines (stroke etc.) * @param colorStyles style information for color * @param shadowStyles specific shadow filter ID, if this element should be drawn with a smooth shadow and no simple one. @@ -755,7 +766,7 @@ export function renderSVGEllipse(bounds: Bounds, lineStyles: LineStyles, colorSt * Renders an ellipse with all given information. * If the rendering is a shadow (has a kShadow parameter), all stroke attributes are ignored (no stroke on the shadow) and a * black fill with 0.1 alpha is returned. - * + * * @param x x offset of the ellipse, to be used for shadows only. * @param y y offset of the ellipse, to be used for shadows only. * @param shadowStyles specific shadow filter ID, if this element should be drawn with a smooth shadow and no simple one. @@ -790,7 +801,7 @@ export function renderSingleSVGEllipse(x: number | undefined, y: number | undefi /** * Renders a rendering with a specific path (polyline, polygon, etc.) with all given information. - * + * * @param lineStyles style information for lines (stroke etc.) * @param colorStyles style information for color * @param shadowStyles specific shadow filter ID, if this element should be drawn with a smooth shadow and no simple one. @@ -806,7 +817,7 @@ export function renderSVGLine(lineStyles: LineStyles, colorStyles: ColorStyles, * Renders a rendering with a specific path (polyline, polygon, etc.) with all given information. * If the rendering is a shadow (has a kShadow parameter), all stroke attributes are ignored (no stroke on the shadow) and a * black fill with 0.1 alpha is returned. - * + * * @param x x offset of the line, to be used for shadows only. * @param y y offset of the line, to be used for shadows only. * @param shadowStyles specific shadow filter ID, if this element should be drawn with a smooth shadow and no simple one. @@ -890,7 +901,7 @@ export function renderKRendering(kRendering: KRendering, return renderError(kRendering) } - const providingRegion = context.depthMap?.getProvidingRegion(parent as KNode, context.viewport, context.renderOptionsRegistry) + const providingRegion = context.depthMap?.getProvidingRegion(parent as SKNode, context) // Check if this is a title rendering. If we have a title, create that rendering, remember where it should be and how much space it has. // If we are zoomed in far enough, return that rendering, otherwise put it into the list to be rendered on top by the element rendering. @@ -900,63 +911,51 @@ export function renderKRendering(kRendering: KRendering, // remembers if this rendering is a title rendering and should therefore be rendered overlaying the other renderings. let isOverlay = false + const applyTitleScaling = context.renderOptionsRegistry.getValueOrDefault(UseSmartZoom) && context.renderOptionsRegistry.getValueOrDefault(ScaleTitles) + const margin = context.renderOptionsRegistry.getValueOrDefault(NodeMargin); + // If this rendering is the main title rendering of the element, either render it usually if // zoomed in far enough or remember it to be rendered later scaled up and overlayed on top of the parent rendering. - if (context.depthMap && boundsAndTransformation.bounds.width && boundsAndTransformation.bounds.height && kRendering.properties['klighd.isNodeTitle'] as boolean) { + if ( boundsAndTransformation.bounds.width && boundsAndTransformation.bounds.height && kRendering.properties['klighd.isNodeTitle'] as boolean) { + // Scale to limit of bounding box or max size. const titleScalingFactorOption = context.renderOptionsRegistry.getValueOrDefault(TitleScalingFactor) as number - let maxScale = titleScalingFactorOption - if (context.viewport) { - maxScale = maxScale / context.viewport.zoom - } - if (providingRegion && providingRegion.detail !== DetailLevel.FullDetails && parent.children.length > 1 - || kRendering.properties['klighd.lsp.calculated.bounds'] as Bounds && (kRendering.properties['klighd.lsp.calculated.bounds'] as Bounds).height * context.viewport.zoom <= titleScalingFactorOption * (kRendering.properties['klighd.lsp.calculated.bounds'] as Bounds).height) { - isOverlay = true + const maxScale = titleScalingFactorOption - let boundingBox = boundsAndTransformation.bounds - // For KTexts the x and y coordinates define the origin of the baseline, not the bounding box. - if (kRendering.type === K_TEXT) { - boundingBox = findBoundsAndTransformationData(kRendering, styles, parent, context, isEdge, true)?.bounds ?? boundingBox - } + const tooSmall = context.effectiveZoom <= titleScalingFactorOption + const notFullDetail = providingRegion && providingRegion.detail !== DetailLevel.FullDetails + const multipleChildren = parent.children.length > 1 + let boundingBox = boundsAndTransformation.bounds + // For KTexts the x and y coordinates define the origin of the baseline, not the bounding box. + if (kRendering.type === K_TEXT) { + boundingBox = findBoundsAndTransformationData(kRendering, styles, parent, context, isEdge, true)?.bounds ?? boundingBox + } + if (providingRegion) { + providingRegion.regionTitleHeight = boundingBox.height + } + + if ( applyTitleScaling && ((notFullDetail && multipleChildren) || tooSmall) ) { + isOverlay = true const parentBounds = providingRegion ? providingRegion.boundingRectangle.bounds : (parent as KNode).bounds - const originalWidth = boundingBox.width - const originalHeight = boundingBox.height - const originalX = boundingBox.x - const originalY = boundingBox.y - - const maxScaleX = parentBounds.width / originalWidth - const maxScaleY = parentBounds.height / originalHeight - // Don't let scalingfactor get too big. - let scalingFactor = Math.min(maxScaleX, maxScaleY, maxScale) - // Make sure we never scale down. - scalingFactor = Math.max(scalingFactor, 1) - - // Calculate the new x and y indentation: - // width required of scaled rendering - const newWidth = originalWidth * scalingFactor - // space to the left of the rendering without scaling... - const spaceL = originalX - // ...and to its right - const spaceR = parentBounds.width - originalX - originalWidth - // New x value after taking space off both sides at an equal ratio - const newX = originalX - spaceL * (newWidth - originalWidth) / (spaceL + spaceR) - - // Same for y axis, just with switched dimensional variables. - const newHeight = originalHeight * scalingFactor - const spaceT = originalY - const spaceB = parentBounds.height - originalY - originalHeight - const newY = originalY - spaceT * (newHeight - originalHeight) / (spaceT + spaceB) + const originalBounds = boundingBox + + const {bounds: newBounds, scale: scalingFactor} = ScalingUtil.upscaleBounds(context.effectiveZoom, maxScale, originalBounds, parentBounds, margin); + context.pushEffectiveZoom(context.effectiveZoom * scalingFactor) // Apply the new bounds and scaling as the element's transformation. - const translateAndScale = `translate(${newX},${newY})scale(${scalingFactor})` + const translateAndScale = `translate(${newBounds.x},${newBounds.y})scale(${scalingFactor})` if (!providingRegion) { // Add the transformations necessary for correct placement const positionOffset = context.positions[context.positions.length - 1] boundsAndTransformation.transformation = positionOffset + translateAndScale } else { boundsAndTransformation.transformation = translateAndScale + + // Store exact height of title text + providingRegion.regionTitleHeight = newBounds.height + providingRegion.regionTitleIndentation = newBounds.x } // For text renderings, recalculate the required bounds the text needs with the updated data. if (kRendering.type === K_TEXT && (kRendering as KText).properties['klighd.calculated.text.bounds'] as Bounds) { @@ -972,42 +971,42 @@ export function renderKRendering(kRendering: KRendering, verticalAlignment: VerticalAlignment.CENTER } as KVerticalAlignment boundsAndTransformation.bounds = { - x: calculateX(0, originalWidth, styles.kHorizontalAlignment, textWidth), - y: originalHeight * 0.5, - width: originalWidth, - height: originalHeight + x: calculateX(0, originalBounds.width, styles.kHorizontalAlignment, textWidth), + y: originalBounds.height * 0.5, + width: originalBounds.width, + height: originalBounds.height } } else { // Offsets are already applied in the transformation, so set them to 0 here. boundsAndTransformation.bounds = { x: 0, y: 0, - width: originalWidth, - height: originalHeight + width: originalBounds.width, + height: originalBounds.height } } - if (providingRegion) { - // Store exact height of title text - providingRegion.regionTitleHeight = newHeight - providingRegion.regionTitleIndentation = newX - } + + // Don't draw if the rendering is an empty KText + const isEmptyText = kRendering.type === K_TEXT && (kRendering as KText).text === "" + const almostEqual = almostEquals(originalBounds.width, newBounds.width) && almostEquals(originalBounds.height, newBounds.height) // Draw white background for overlaying titles - if (context.depthMap && kRendering.properties['klighd.isNodeTitle'] as boolean && ((providingRegion && providingRegion.detail === DetailLevel.FullDetails) || !providingRegion) - && kRendering.properties['klighd.lsp.calculated.bounds'] as Bounds && (kRendering.properties['klighd.lsp.calculated.bounds'] as Bounds).height * context.viewport.zoom <= titleScalingFactorOption * (kRendering.properties['klighd.lsp.calculated.bounds'] as Bounds).height - // Don't draw if the rendering is an empty KText - && (kRendering.type !== K_TEXT || (kRendering as KText).text !== "")) { - overlayRectangle = + if ((!providingRegion || providingRegion.detail === DetailLevel.FullDetails) && (tooSmall && !almostEqual) && !isEmptyText) { + overlayRectangle = } + } else { + context.pushEffectiveZoom(context.effectiveZoom) } + } else { + context.pushEffectiveZoom(context.effectiveZoom) } - // Add the transformations to be able to positon the title correctly and above other elements + // Add the transformations to be able to position the title correctly and above other elements context.positions[context.positions.length - 1] += (boundsAndTransformation?.transformation ?? "") - let svgRendering: VNode + let svgRendering: VNode | undefined switch (kRendering.type) { case K_CONTAINER_RENDERING: { console.error('A rendering can not be a ' + kRendering.type + ' by itself, it needs to be a subclass of it.') - return undefined + break } case K_CHILD_AREA: { svgRendering = renderChildArea(kRendering as KChildArea, parent, propagatedStyles, context) @@ -1016,7 +1015,7 @@ export function renderKRendering(kRendering: KRendering, case K_CUSTOM_RENDERING: { console.error('The rendering for ' + kRendering.type + ' is not implemented yet.') // data as KCustomRendering - return undefined + break } case K_ARC: case K_ELLIPSE: @@ -1039,16 +1038,21 @@ export function renderKRendering(kRendering: KRendering, } default: { console.error('The rendering is of an unknown type:' + kRendering.type) - return undefined + break } } // Put the rectangle for the overlay behind the rendering itself. - if (overlayRectangle) { + if (overlayRectangle && svgRendering) { svgRendering.children?.unshift(overlayRectangle) } + context.popEffectiveZoom() + if (!svgRendering) { + return undefined + } + if (isOverlay) { // Don't render this now if we have an overlay, but remember it to be put on top by the node rendering. - context.titles[context.titles.length - 1].push(svgRendering) + context.pushTitle(svgRendering) return } else { return svgRendering @@ -1143,4 +1147,4 @@ export function getJunctionPointRenderings(edge: SKEdge, context: SKGraphModelRe renderings.push(junctionPointVNode) }) return renderings -} \ No newline at end of file +} diff --git a/packages/klighd-core/src/views-styles.tsx b/packages/klighd-core/src/views-styles.tsx index 4c43d1dd..99a96496 100644 --- a/packages/klighd-core/src/views-styles.tsx +++ b/packages/klighd-core/src/views-styles.tsx @@ -16,7 +16,7 @@ */ /** @jsx svg */ import { VNode } from 'snabbdom'; -import { getZoom, isSelectable, svg } from 'sprotty'; // eslint-disable-line @typescript-eslint/no-unused-vars +import { isSelectable, SChildElement, svg } from 'sprotty'; // eslint-disable-line @typescript-eslint/no-unused-vars import { MinimumLineWidth, UseMinimumLineWidth } from './options/render-options-registry'; import { SKGraphModelRenderer } from './skgraph-model-renderer'; import { @@ -677,7 +677,7 @@ export function isInvisible(styles: KStyles): boolean { * 'stroke-miterlimit' has to be set to the miterLimit style. (This is not a string, but a number.) * @param styles The KStyles of the rendering. * @param target The target of the line - * @param context The current rendering context + * @param context The current rendering context */ export function getSvgLineStyles(styles: KStyles, target: SKGraphElement, context: SKGraphModelRenderer): LineStyles { // The line width as requested by the element @@ -689,7 +689,15 @@ export function getSvgLineStyles(styles: KStyles, target: SKGraphElement, contex // The line witdh in px that the drawn line should not be less than. const minimumLineWidth = context.renderOptionsRegistry.getValueOrDefault(MinimumLineWidth) // The line width the requested one would have when rendered in the current zoom level. - const realLineWidth = lineWidth * getZoom(target) + + + let effectiveZoom = context.effectiveZoom; + + if(target instanceof SChildElement && target.parent instanceof SKNode) { + effectiveZoom *= target.parent.calculateScaledBounds(context).effectiveChildScale + } + + const realLineWidth = lineWidth * effectiveZoom if (styles.kLineWidth.lineWidth == 0) { lineWidth = 0 } else if (realLineWidth < minimumLineWidth) { @@ -779,4 +787,4 @@ export interface TextStyles { fontWeight: string | undefined, textDecorationLine: string | undefined, textDecorationStyle: string | undefined, -} \ No newline at end of file +} diff --git a/packages/klighd-core/src/views.tsx b/packages/klighd-core/src/views.tsx index f8c58cf1..f9fcec67 100644 --- a/packages/klighd-core/src/views.tsx +++ b/packages/klighd-core/src/views.tsx @@ -20,13 +20,14 @@ import { renderConstraints, renderInteractiveLayout } from '@kieler/klighd-inter import { KlighdInteractiveMouseListener } from '@kieler/klighd-interactive/lib/klighd-interactive-mouselistener'; import { inject, injectable } from 'inversify'; import { VNode } from 'snabbdom'; -import { findParentByFeature, isViewport, IView, RenderingContext, SGraph, svg } from 'sprotty'; // eslint-disable-line @typescript-eslint/no-unused-vars -import { DepthMap, DetailLevel } from './depth-map'; +import { findParentByFeature, isViewport, IView, RenderingContext, SChildElement, SGraph, svg } from 'sprotty'; // eslint-disable-line @typescript-eslint/no-unused-vars +import { Bounds } from 'sprotty-protocol' +import { DepthMap, DetailLevel, isDetailWithChildren } from './hierarchy/depth-map'; import { DISymbol } from './di.symbols'; import { overpass_mono_regular_style, overpass_regular_style } from './fonts/overpass'; -import { RenderOptionsRegistry, ShowConstraintOption, UseSmartZoom } from './options/render-options-registry'; +import { RenderOptionsRegistry, ShowConstraintOption, UseSmartZoom, ScaleNodes } from './options/render-options-registry'; import { SKGraphModelRenderer } from './skgraph-model-renderer'; -import { SKEdge, SKLabel, SKNode, SKPort } from './skgraph-models'; +import { SKEdge, SKLabel, SKNode, SKPort, LABEL_TYPE, NODE_TYPE } from './skgraph-models'; import { getJunctionPointRenderings, getRendering } from './views-rendering'; import { KStyles } from './views-styles'; @@ -50,33 +51,33 @@ export class SKGraphView implements IView { const viewport = findParentByFeature(model, isViewport) if (viewport) { ctx.viewport = viewport + ctx.pushEffectiveZoom(ctx.effectiveZoom * viewport.zoom) + } else { + ctx.pushEffectiveZoom(ctx.effectiveZoom) } - ctx.titles = [] ctx.positions = [] - - // Add depthMap to context for rendering, when required. - const smartZoomOption = ctx.renderOptionsRegistry.getValue(UseSmartZoom) - - // Only enable, if option is found. - const useSmartZoom = smartZoomOption ?? false + const useSmartZoom = this.renderOptionsRegistry.getValueOrDefault(UseSmartZoom) if (useSmartZoom && ctx.targetKind !== 'hidden') { ctx.depthMap = DepthMap.getDM() if (ctx.viewport && ctx.depthMap) { - ctx.depthMap.updateDetailLevels(ctx.viewport, ctx.renderOptionsRegistry) + ctx.depthMap.updateDetailLevels(ctx) } } else { ctx.depthMap = undefined } const transform = `scale(${model.zoom}) translate(${-model.scroll.x},${-model.scroll.y})`; - return + + const rendered = - {context.renderChildren(model)} - - ; + {context.renderChildren(model)} + + ; + ctx.popEffectiveZoom() + return rendered; } } @@ -91,8 +92,8 @@ export class KNodeView implements IView { const ctx = context as SKGraphModelRenderer if (ctx.depthMap) { - const containingRegion = ctx.depthMap.getContainingRegion(node, ctx.viewport, ctx.renderOptionsRegistry) - if (ctx.depthMap && containingRegion && containingRegion.detail !== DetailLevel.FullDetails) { + const containingRegion = ctx.depthMap.getContainingRegion(node, ctx) + if (ctx.depthMap && containingRegion && !isDetailWithChildren(containingRegion.detail)) { // Make sure this node and its children are not drawn as long as it is not on full details. node.areChildAreaChildrenRendered = true node.areNonChildAreaChildrenRendered = true @@ -100,7 +101,7 @@ export class KNodeView implements IView { } } - ctx.titles.push([]) + ctx.enterTitleScope() ctx.positions.push("") // reset these properties, if the diagram is drawn a second time node.areChildAreaChildrenRendered = false @@ -113,6 +114,32 @@ export class KNodeView implements IView { let interactiveNodes = undefined let interactiveConstraints = undefined + + + const performNodeScaling = ctx.renderOptionsRegistry.getValueOrDefault(ScaleNodes); + + let transformation: string; + + + // we push a new effective zoom in all cases so we can pop later without checking whether we pushed + if (node.parent && performNodeScaling) { + + const {relativeBounds: newBounds, relativeScale: scalingFactor} = node.calculateScaledBounds(ctx) + + if(Number.isNaN(newBounds.x) || Number.isNaN(newBounds.y) || Number.isNaN(scalingFactor)){ + // On initial load node.parent.bounds has all fields as 0 causing a division by 0 + transformation = "" + ctx.pushEffectiveZoom(ctx.effectiveZoom) + } else { + // Apply the new bounds and scaling as the element's transformation. + transformation = `translate(${newBounds.x - node.bounds.x },${newBounds.y - node.bounds.y})scale(${scalingFactor})` + ctx.pushEffectiveZoom(ctx.effectiveZoom * scalingFactor) + } + } else { + transformation = "" + ctx.pushEffectiveZoom(ctx.effectiveZoom) + } + if (isShadow) { // Render shadow of the node shadow = getRendering(node.data, node, new KStyles, ctx) @@ -174,8 +201,9 @@ export class KNodeView implements IView { result.push(interactiveNodes) } result.push(...children) - result.push(...(ctx.titles.pop() ?? [])) + result.push(...ctx.exitTitleScope()) ctx.positions.pop() + ctx.popEffectiveZoom() return {...result} } @@ -187,10 +215,13 @@ export class KNodeView implements IView { result.push(rendering) } else { ctx.positions.pop() - return - {ctx.titles.pop() ?? []} - {ctx.renderChildren(node)} - + const titles = ctx.exitTitleScope() + const childRenderings = ctx.renderChildren(node) + ctx.popEffectiveZoom() + return + {titles} + {childRenderings} + } if (interactiveNodes) { result.push(interactiveNodes) @@ -204,9 +235,10 @@ export class KNodeView implements IView { } else if (!node.areNonChildAreaChildrenRendered) { result.push(...ctx.renderNonChildAreaChildren(node)) } - result.push(...(ctx.titles.pop() ?? [])) + result.push(...ctx.exitTitleScope()) ctx.positions.pop() - return {...result} + ctx.popEffectiveZoom() + return {...result} } } @@ -222,7 +254,7 @@ export class KPortView implements IView { const ctx = context as SKGraphModelRenderer if (ctx.depthMap) { - const containingRegion = ctx.depthMap.getContainingRegion(port, ctx.viewport, ctx.renderOptionsRegistry) + const containingRegion = ctx.depthMap.getContainingRegion(port, ctx) if (ctx.depthMap && containingRegion && containingRegion.detail !== DetailLevel.FullDetails) { port.areChildAreaChildrenRendered = true port.areNonChildAreaChildrenRendered = true @@ -230,15 +262,15 @@ export class KPortView implements IView { } } - ctx.titles.push([]) + ctx.enterTitleScope() ctx.positions.push("") port.areChildAreaChildrenRendered = false port.areNonChildAreaChildrenRendered = false const rendering = getRendering(port.data, port, new KStyles, ctx) // If no rendering could be found, just render its children. if (rendering === undefined) { - const element = - {ctx.titles.pop() ?? []} + const element = + {ctx.exitTitleScope()} {ctx.renderChildren(port)} @@ -250,19 +282,19 @@ export class KPortView implements IView { if (!port.areChildAreaChildrenRendered) { element = {rendering} - {ctx.titles.pop() ?? []} + {ctx.exitTitleScope()} {ctx.renderChildren(port)} } else if (!port.areNonChildAreaChildrenRendered) { element = {rendering} - {ctx.titles.pop() ?? []} + {ctx.exitTitleScope()} {ctx.renderNonChildAreaChildren(port)} } else { element = {rendering} - {ctx.titles.pop() ?? []} + {ctx.exitTitleScope()} } @@ -282,14 +314,14 @@ export class KLabelView implements IView { const ctx = context as SKGraphModelRenderer if (ctx.depthMap) { - const containingRegion = ctx.depthMap.getContainingRegion(label, ctx.viewport, ctx.renderOptionsRegistry) + const containingRegion = ctx.depthMap.getContainingRegion(label, ctx) if (ctx.depthMap && containingRegion && containingRegion.detail !== DetailLevel.FullDetails) { label.areChildAreaChildrenRendered = true label.areNonChildAreaChildrenRendered = true return undefined } } - ctx.titles.push([]) + ctx.enterTitleScope() ctx.positions.push("") label.areChildAreaChildrenRendered = false label.areNonChildAreaChildrenRendered = false @@ -304,7 +336,7 @@ export class KLabelView implements IView { // If no rendering could be found, just render its children. if (rendering === undefined) { const element = - {ctx.renderChildren(label).push(...ctx.titles.pop() ?? [])} + {ctx.renderChildren(label).push(...ctx.exitTitleScope())} ctx.positions.pop() @@ -315,19 +347,19 @@ export class KLabelView implements IView { if (!label.areChildAreaChildrenRendered) { element = {rendering} - {ctx.titles.pop() ?? []} + {ctx.exitTitleScope()} {ctx.renderChildren(label)} } else if (!label.areNonChildAreaChildrenRendered) { element = {rendering} - {ctx.titles.pop() ?? []} + {ctx.exitTitleScope()} {ctx.renderNonChildAreaChildren(label)} } else { element = {rendering} - {ctx.titles.pop() ?? []} + {ctx.exitTitleScope()} } @@ -346,7 +378,7 @@ export class KEdgeView implements IView { const ctx = context as SKGraphModelRenderer if (ctx.depthMap) { - const containingRegion = ctx.depthMap.getContainingRegion(edge, ctx.viewport, ctx.renderOptionsRegistry) + const containingRegion = ctx.depthMap.getContainingRegion(edge, ctx) if (ctx.depthMap && containingRegion && containingRegion.detail !== DetailLevel.FullDetails) { edge.areChildAreaChildrenRendered = true edge.areNonChildAreaChildrenRendered = true @@ -364,11 +396,16 @@ export class KEdgeView implements IView { if (s === undefined || t === undefined) { return } + + + // edge should be greyed out if the source or target is moved if (s !== undefined && t !== undefined && s instanceof SKNode && t instanceof SKNode) { edge.moved = (s.selected || t.selected) && ctx.mListener.hasDragged + } + let rendering = undefined if (!ctx.mListener.hasDragged || isChildSelected(edge.parent as SKNode)) { // edge should only be visible if it is in the same hierarchical level as @@ -382,16 +419,22 @@ export class KEdgeView implements IView { // If no rendering could be found, just render its children. if (rendering === undefined) { + const childrenRendered = filterEdgeChildren(edge, ctx).map(elem => ctx.renderElement(elem)) + .filter(elem => elem !== undefined); return - {ctx.renderChildren(edge)} + {childrenRendered} {...junctionPointRenderings} } // Default case. If no child area children or no non-child area children are already rendered within the rendering, add the children by default. if (!edge.areChildAreaChildrenRendered) { + + const childrenRendered = filterEdgeChildren(edge, ctx).map(elem => ctx.renderElement(elem)) + .filter(elem => elem !== undefined); + return {rendering} - {ctx.renderChildren(edge)} + {childrenRendered} {...junctionPointRenderings} } else if (!edge.areNonChildAreaChildrenRendered) { @@ -409,6 +452,37 @@ export class KEdgeView implements IView { } } +function filterEdgeChildren(edge: Readonly, ctx: SKGraphModelRenderer): readonly SChildElement[] { + if (ctx.renderOptionsRegistry.getValueOrDefault(ScaleNodes)) { + const intersects = function (a: Bounds, b: Bounds): boolean { + return (a.x < b.x + b.width + && a.y < b.y + b.height + && b.x < a.x + a.width + && b.y < a.y + a.height) + } + + const labelBounds = edge.children.filter(elem => elem.type === LABEL_TYPE) + .map(elem => (elem as SKEdge).bounds).reduce(Bounds.combine, Bounds.EMPTY); + + const siblings = edge.parent.children.filter(elem => elem.type === NODE_TYPE).map(elem => elem as SKNode); + + let keepLabels = true; + + for (const sibling of siblings) { + const sib = sibling.calculateScaledBounds(ctx).relativeBounds + + if (intersects(sib, labelBounds)) { + keepLabels = false; + break + } + } + + return edge.children.filter(elem => (elem.type !== LABEL_TYPE) || keepLabels) + } else { + return edge.children + } +} + function fontDefinition(): VNode { // TODO: maybe find a way to only include the font if it is used in the SVG. return