UNPKG

graph-explorer

Version:

Graph Explorer can be used to explore and RDF graphs in SPARQL endpoints or on the web.

458 lines (414 loc) 12.8 kB
import * as cola from "webcola"; import { DiagramModel } from "../diagram/model"; import { boundsOf, Vector, computeGrouping, Size } from "../diagram/geometry"; import { Element } from "../diagram/elements"; import { EventObserver } from "./events"; import { getContentFittingBox } from "../diagram/paperArea"; export interface LayoutNode { id?: string; x: number; y: number; width: number; height: number; bounds?: any; fixed?: number; innerBounds?: any; } export interface LayoutLink { source: LayoutNode; target: LayoutNode; } export function groupForceLayout(params: { nodes: LayoutNode[]; links: LayoutLink[]; preferredLinkLength: number; avoidOvelaps?: boolean; }) { const layout = new cola.Layout() .nodes(params.nodes) .links(params.links) .avoidOverlaps(params.avoidOvelaps) .convergenceThreshold(1e-9) .jaccardLinkLengths(params.preferredLinkLength) .handleDisconnected(true); layout.start(30, 0, 10, undefined, false); } export function groupRemoveOverlaps(nodes: LayoutNode[]) { const nodeRectangles: cola.Rectangle[] = []; for (const node of nodes) { nodeRectangles.push( new cola.Rectangle( node.x, node.x + node.width, node.y, node.y + node.height ) ); } cola.removeOverlaps(nodeRectangles); for (let i = 0; i < nodeRectangles.length; i++) { const node = nodes[i]; const rectangle = nodeRectangles[i]; node.x = rectangle.x; node.y = rectangle.y; } } export function translateToPositiveQuadrant( positions: Map<string, Vector>, offset: Vector ) { let minX = Infinity, minY = Infinity; positions.forEach((position) => { minX = Math.min(minX, position.x); minY = Math.min(minY, position.y); }); const { x, y } = offset; positions.forEach((position, key) => { positions.set(key, { x: position.x - minX + x, y: position.y - minY + y, }); }); } export function uniformGrid(params: { rows: number; cellSize: Vector; }): (cellIndex: number) => LayoutNode { return (cellIndex) => { const row = Math.floor(cellIndex / params.rows); const column = cellIndex - row * params.rows; return { x: column * params.cellSize.x, y: row * params.cellSize.y, width: params.cellSize.x, height: params.cellSize.y, }; }; } export function padded( nodes: LayoutNode[], padding: { x: number; y: number } | undefined, transform: () => void ) { if (padding) { for (const node of nodes) { node.x -= padding.x; node.y -= padding.y; node.width += 2 * padding.x; node.height += 2 * padding.y; } } transform(); if (padding) { for (const node of nodes) { node.x += padding.x; node.y += padding.y; node.width -= 2 * padding.x; node.height -= 2 * padding.y; } } } export function biasFreePadded( nodes: LayoutNode[], padding: { x: number; y: number } | undefined, transform: () => void ) { const nodeSizeMap = new Map<string, Size>(); const possibleCompression = { x: Infinity, y: Infinity }; for (const node of nodes) { nodeSizeMap.set(node.id, { width: node.width, height: node.height }); const maxSide = Math.max(node.width, node.height); const compressionX = node.width ? maxSide / node.width : 1; const compressionY = node.height ? maxSide / node.height : 1; possibleCompression.x = Math.min( 1 + (compressionX - 1), possibleCompression.x ); possibleCompression.y = Math.min( 1 + (compressionY - 1), possibleCompression.y ); node.height = maxSide; node.width = maxSide; } padded(nodes, padding, () => transform()); const fittingBox = getContentFittingBoxForLayout(nodes); for (const node of nodes) { const size = nodeSizeMap.get(node.id); node.x = (node.x - fittingBox.x) / possibleCompression.x + fittingBox.x; node.y = (node.y - fittingBox.y) / possibleCompression.y + fittingBox.y; node.height = size.height; node.width = size.width; } } export type CalculatedLayout = object & { readonly layoutBrand: any }; export interface UnzippedCalculatedLayout extends CalculatedLayout { group?: string; keepAveragePosition: boolean; positions: Map<string, Vector>; nestedLayouts: UnzippedCalculatedLayout[]; } export function calculateLayout(params: { model: DiagramModel; layoutFunction: ( nodes: LayoutNode[], links: LayoutLink[], group: string ) => void; fixedElements?: ReadonlySet<Element>; group?: string; selectedElements?: ReadonlySet<Element>; }): CalculatedLayout { const grouping = computeGrouping(params.model.elements); const { layoutFunction, model, fixedElements, selectedElements } = params; if (selectedElements && selectedElements.size <= 1) { return { positions: new Map(), nestedLayouts: [], keepAveragePosition: false, } as UnzippedCalculatedLayout; } return internalRecursion(params.group); function internalRecursion(group: string): CalculatedLayout { const elementsToProcess = group ? grouping.get(group) : model.elements.filter((el) => el.group === undefined); const elements = selectedElements ? elementsToProcess.filter((el) => selectedElements.has(el)) : elementsToProcess; const nestedLayouts: CalculatedLayout[] = []; for (const element of elements) { if (grouping.has(element.id)) { nestedLayouts.push(internalRecursion(element.id)); } } const nodes: LayoutNode[] = []; const nodeById: Record<string, LayoutNode> = {}; for (const element of elements) { const { x, y, width, height } = boundsOf(element); const node: LayoutNode = { id: element.id, x, y, width, height, fixed: fixedElements && fixedElements.has(element) ? 1 : 0, }; nodeById[element.id] = node; nodes.push(node); } const links: LayoutLink[] = []; for (const link of model.links) { if (!model.isSourceAndTargetVisible(link)) { continue; } const source = model.sourceOf(link); const target = model.targetOf(link); const sourceNode = nodeById[source.id]; const targetNode = nodeById[target.id]; if (sourceNode && targetNode) { links.push({ source: sourceNode, target: targetNode }); } } layoutFunction(nodes, links, group); const positions = new Map<string, Vector>(); for (const node of nodes) { positions.set(node.id, { x: node.x, y: node.y }); } return { positions, group, nestedLayouts, keepAveragePosition: Boolean(selectedElements), } as UnzippedCalculatedLayout; } } export function applyLayout(model: DiagramModel, layout: CalculatedLayout) { const { positions, group, nestedLayouts, keepAveragePosition } = layout as UnzippedCalculatedLayout; const elements = model.elements.filter(({ id }) => positions.has(id)); for (const nestedLayout of nestedLayouts) { applyLayout(model, nestedLayout); } if (group) { const offset: Vector = getContentFittingBox(elements, []); translateToPositiveQuadrant(positions, offset); } const averagePosition = keepAveragePosition ? calculateAveragePosition(elements) : undefined; for (const element of elements) { element.setPosition(positions.get(element.id)); } if (keepAveragePosition) { const newAveragePosition = calculateAveragePosition(elements); const averageDiff = { x: averagePosition.x - newAveragePosition.x, y: averagePosition.y - newAveragePosition.y, }; positions.forEach((position, elementId) => { const element = model.getElement(elementId); element.setPosition({ x: position.x + averageDiff.x, y: position.y + averageDiff.y, }); }); } } export function calculateAveragePosition(position: readonly Element[]): Vector { let xSum = 0; let ySum = 0; for (const element of position) { xSum += element.position.x + element.size.width / 2; ySum += element.position.y + element.size.height / 2; } return { x: xSum / position.length, y: ySum / position.length, }; } export function placeElementsAround(params: { model: DiagramModel; elements: readonly Element[]; prefferedLinksLength: number; targetElement: Element; startAngle?: number; }) { const { model, elements, targetElement, prefferedLinksLength } = params; const targetElementBounds = boundsOf(targetElement); const targetPosition: Vector = { x: targetElementBounds.x + targetElementBounds.width / 2, y: targetElementBounds.y + targetElementBounds.height / 2, }; let outgoingAngle = 0; if (targetElement.links.length > 0) { const averageSourcePosition = calculateAveragePosition( targetElement.links.map((link) => { const linkSource = model.sourceOf(link); return linkSource !== targetElement ? linkSource : model.targetOf(link); }) ); const vectorDiff: Vector = { x: targetPosition.x - averageSourcePosition.x, y: targetPosition.y - averageSourcePosition.y, }; if (vectorDiff.x !== 0 || vectorDiff.y !== 0) { outgoingAngle = Math.atan2(vectorDiff.y, vectorDiff.x); } } const step = Math.min(Math.PI / elements.length, Math.PI / 6); const elementsSteck: Element[] = [].concat(elements); const placeElementFromSteck = (curAngle: number, element: Element) => { if (element) { const size = element.size; element.setPosition({ x: targetPosition.x + prefferedLinksLength * Math.cos(curAngle) - size.width / 2, y: targetPosition.y + prefferedLinksLength * Math.sin(curAngle) - size.height / 2, }); } }; const isOddLength = elementsSteck.length % 2 === 0; if (isOddLength) { for (let angle = step / 2; elementsSteck.length > 0; angle += step) { placeElementFromSteck(outgoingAngle - angle, elementsSteck.pop()); placeElementFromSteck(outgoingAngle + angle, elementsSteck.pop()); } } else { placeElementFromSteck(outgoingAngle, elementsSteck.pop()); for (let angle = step; elementsSteck.length > 0; angle += step) { placeElementFromSteck(outgoingAngle - angle, elementsSteck.pop()); placeElementFromSteck(outgoingAngle + angle, elementsSteck.pop()); } } return new Promise((resolve) => { const listener = new EventObserver(); listener.listen(model.events, "changeCells", () => { listener.stopListening(); removeOverlaps({ model, padding: { x: 15, y: 15 }, }); resolve(true); }); }); } export function removeOverlaps(params: { model: DiagramModel; fixedElements?: ReadonlySet<Element>; padding?: Vector; group?: string; selectedElements?: ReadonlySet<Element>; }): CalculatedLayout { const { padding, model, group, fixedElements, selectedElements } = params; return calculateLayout({ model, group, fixedElements, selectedElements, layoutFunction: (nodes) => { padded(nodes, padding, () => groupRemoveOverlaps(nodes)); }, }); } export function forceLayout(params: { model: DiagramModel; fixedElements?: ReadonlySet<Element>; group?: string; selectedElements?: ReadonlySet<Element>; }): CalculatedLayout { const { model, group, fixedElements, selectedElements } = params; return calculateLayout({ model, group, fixedElements, selectedElements, layoutFunction: (nodes, links) => { if (fixedElements && fixedElements.size > 0) { biasFreePadded(nodes, { x: 50, y: 50 }, () => groupForceLayout({ nodes, links, preferredLinkLength: 200, avoidOvelaps: true, }) ); } else { groupForceLayout({ nodes, links, preferredLinkLength: 200 }); biasFreePadded(nodes, { x: 50, y: 50 }, () => groupRemoveOverlaps(nodes) ); } }, }); } export function getContentFittingBoxForLayout(nodes: readonly LayoutNode[]): { x: number; y: number; width: number; height: number; } { let minX = Infinity, minY = Infinity; let maxX = -Infinity, maxY = -Infinity; for (const node of nodes) { const { x, y, width, height } = node; minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x + width); maxY = Math.max(maxY, y + height); } return { x: Number.isFinite(minX) ? minX : 0, y: Number.isFinite(minY) ? minY : 0, width: Number.isFinite(minX) && Number.isFinite(maxX) ? maxX - minX : 0, height: Number.isFinite(minY) && Number.isFinite(maxY) ? maxY - minY : 0, }; }