@reactodia/workspace
Version:
Reactodia Workspace -- library for visual interaction with graphs in a form of a diagram.
200 lines (180 loc) • 7.77 kB
text/typescript
import type { LinkRouter, RoutedLinks } from './customization';
import type { GraphStructure } from './model';
import type { Link } from './elements';
import { SizeProvider, Vector, Rect, computePolyline, boundsOf } from './geometry';
/**
* Options for {@link DefaultLinkRouter}.
*/
export interface DefaultLinkRouterOptions {
/**
* Margin to put between the middle parts of links to move
* them apart of each other.
*
* @default 20
*/
gap?: number;
}
/**
* Default link router which moves links with same source and target apart.
*
* @category Core
*/
export class DefaultLinkRouter implements LinkRouter {
private readonly gap: number;
constructor(options: DefaultLinkRouterOptions = {}) {
const {gap = 20} = options;
this.gap = gap;
}
route(model: GraphStructure, sizeProvider: SizeProvider): RoutedLinks {
const routings: RoutedLinks = new Map();
for (const link of model.links) {
if (routings.has(link.id)) {
continue;
}
// The cell is a link. Let's find its source and target models.
const {sourceId, targetId} = link;
if (!sourceId || !targetId) {
continue;
} else if (sourceId === targetId) {
this.routeFeedbackSiblingLinks(sourceId, model, sizeProvider, routings);
} else {
this.routeNormalSiblingLinks(sourceId, targetId, model, sizeProvider, routings);
}
}
return routings;
}
private routeFeedbackSiblingLinks(
elementId: string,
model: GraphStructure,
sizeProvider: SizeProvider,
routings: RoutedLinks
) {
const element = model.getElement(elementId)!;
const shape = sizeProvider.getElementShape(element);
const {x, y} = shape.bounds;
const center = Rect.center(shape.bounds);
let index = 0;
for (const sibling of model.getElementLinks(element)) {
const {sourceId, targetId} = sibling;
if (
routings.has(sibling.id) ||
model.getLinkVisibility(sibling.typeId) === 'hidden' ||
sourceId !== targetId
) {
continue;
}
if (sibling.vertices.length === 0) {
const offset = this.gap * (index + 1);
const vertices: Vector[] = [
{x: x - offset, y: y + offset},
{x: x - offset, y: y - offset},
{x: x + offset, y: y - offset},
];
routings.set(sibling.id, {linkId: sibling.id, vertices});
index++;
} else if (sibling.vertices.length === 1) {
const [pivot] = sibling.vertices;
const pivotTarget: Rect = {x: pivot.x, y: pivot.y, width: 0, height: 0};
// Find the point on the shape border closest to pivot
const [intersection] = computePolyline(shape, pivotTarget, []);
const offset = Math.min(
Math.max(this.gap, Vector.length(Vector.subtract(pivot, intersection)) * 0.75),
shape.bounds.width,
shape.bounds.height
);
const ray = Vector.normalize(Vector.subtract(pivot, center));
const shifted = Vector.add(pivot, Vector.scale(ray, -offset));
const rotated: Vector = {x: -ray.y, y: ray.x};
const vertices: Vector[] = [
Vector.add(shifted, Vector.scale(rotated, offset)),
pivot,
Vector.add(shifted, Vector.scale(rotated, -offset)),
];
routings.set(sibling.id, {linkId: sibling.id, vertices});
}
}
}
private routeNormalSiblingLinks(
sourceId: string,
targetId: string,
model: GraphStructure,
sizeProvider: SizeProvider,
routings: RoutedLinks
): void {
const source = model.getElement(sourceId)!;
const target = model.getElement(targetId)!;
const sourceCenter = Rect.center(boundsOf(source, sizeProvider));
const targetCenter = Rect.center(boundsOf(target, sizeProvider));
const midPoint = {
x: (sourceCenter.x + targetCenter.x) / 2,
y: (sourceCenter.y + targetCenter.y) / 2,
};
const direction = Vector.normalize({
x: targetCenter.x - sourceCenter.x,
y: targetCenter.y - sourceCenter.y,
});
const siblings = model.getElementLinks(source).filter(link =>
(link.sourceId === targetId || link.targetId === targetId) &&
!routings.has(link.id) &&
!hasUserPlacedVertices(link) &&
model.getLinkVisibility(link.typeId) !== 'hidden'
);
if (siblings.length <= 1) {
return;
}
const indexModifier = siblings.length % 2 ? 0 : 1;
siblings.forEach((sibling, siblingIndex) => {
// For more beautiful positioning
const index = siblingIndex + indexModifier;
// We want the offset values to be calculated as follows 0, 50, 50, 100, 100, 150, 150 ..
const offset = this.gap * Math.ceil(index / 2) - (indexModifier ? this.gap / 2 : 0);
// Now we need the vertices to be placed at points which are 'offset' pixels distant
// from the first link and forms a perpendicular angle to it. And as index goes up
// alternate left and right.
//
// ^ odd indexes
// |
// |----> index 0 line (straight line between a source center and a target center.
// |
// v even indexes
const offsetDirection = index % 2
? {x: -direction.y, y: direction.x} // rotate by 90 degrees counter-clockwise
: {x: direction.y, y: -direction.x}; // rotate by 90 degrees clockwise
// We found the vertex.
const vertex = {
x: midPoint.x + offsetDirection.x * offset,
y: midPoint.y + offsetDirection.y * offset,
};
routings.set(sibling.id, {
linkId: sibling.id,
vertices: [vertex],
labelTextAnchor: this.getLabelAlignment(direction, siblingIndex, siblings.length),
});
});
}
private getLabelAlignment(
connectionDirection: Vector,
siblingIndex: number,
siblingCount: number,
): 'start' | 'middle' | 'end' {
// offset direction angle in [0; 2 Pi] interval
const angle = Math.atan2(connectionDirection.y, connectionDirection.x);
const absoluteAngle = Math.abs(angle);
const isHorizontal = absoluteAngle < Math.PI * 1 / 8 || absoluteAngle > Math.PI * 7 / 8;
const isTop = angle < 0;
const isBottom = angle > 0;
const firstOuter = siblingCount - 2;
const secondOuter = siblingCount - 1;
if (!isHorizontal) {
if (isTop && siblingIndex === secondOuter || isBottom && siblingIndex === firstOuter) {
return 'end';
} else if (isTop && siblingIndex === firstOuter || isBottom && siblingIndex === secondOuter) {
return 'start';
}
}
return 'middle';
}
}
function hasUserPlacedVertices(link: Link) {
return link.vertices.length > 0;
}