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
text/typescript
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,
};
}