@reactodia/workspace
Version:
Reactodia Workspace -- library for visual interaction with graphs in a form of a diagram.
546 lines (502 loc) • 16.7 kB
text/typescript
import type { Element, Link } from './elements';
/**
* Represents a floating-point 2D vector.
*
* @category Geometry
*/
export interface Vector {
readonly x: number;
readonly y: number;
}
/**
* Utility functions to operate on 2D vectors.
*
* @category Geometry
*/
export namespace Vector {
/**
* Adds two vectors component-wise.
*/
export function add(a: Vector, b: Vector): Vector {
return {
x: a.x + b.x,
y: a.y + b.y,
};
}
/**
* Subtracts two vectors component-wise.
*/
export function subtract(a: Vector, b: Vector): Vector {
return {
x: a.x - b.x,
y: a.y - b.y,
};
}
/**
* Multiplies each vector component by a scalar number.
*/
export function scale(v: Vector, factor: number): Vector {
return {x: v.x * factor, y: v.y * factor};
}
/**
* Returns `true` if two vectors are the same, otherwise `false`.
*/
export function equals(a: Vector, b: Vector): boolean {
return a.x === b.x && a.y === b.y;
}
/**
* Computes the length of a vector (L2 norm).
*/
export function length({x, y}: Vector): number {
return Math.sqrt(x * x + y * y);
}
/**
* Normalizes the vector by dividing by its length to get a unit vector
* with the same direction as the original one.
*/
export function normalize({x, y}: Vector): Vector {
if (x === 0 && y === 0) { return {x, y}; }
const inverseLength = 1 / Math.sqrt(x * x + y * y);
return {x: x * inverseLength, y: y * inverseLength};
}
/**
* Computes dot-product of two vectors.
*/
export function dot({x: x1, y: y1}: Vector, {x: x2, y: y2}: Vector): number {
return x1 * x2 + y1 * y2;
}
/**
* Computes 2D cross-product of two vectors.
*/
export function cross2D({x: x1, y: y1}: Vector, {x: x2, y: y2}: Vector): number {
return x1 * y2 - y1 * x2;
}
}
/**
* Represents a 2D rectangular size.
*
* @category Geometry
*/
export interface Size {
readonly width: number;
readonly height: number;
}
/**
* Represents a 2D axis-aligned rectangle.
*
* @category Geometry
*/
export interface Rect {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
/**
* Utility functions to operate on 2D axis-aligned rectangles.
*
* @category Geometry
*/
export namespace Rect {
/**
* Returns `true` if two rectangles are the same, otherwise `false`.
*/
export function equals(a: Rect, b: Rect): boolean {
return (
a.x === b.x &&
a.y === b.y &&
a.width === b.width &&
a.height === b.height
);
}
/**
* Computes the center point of a rectangle.
*/
export function center({x, y, width, height}: Rect): Vector {
return {x: x + width / 2, y: y + height / 2};
}
/**
* Returns `true` if two rectangles intersects each other, otherwise `false`.
*
* Rectangles sharing an edge are considered as intersecting as well.
*/
export function intersects(a: Rect, b: Rect): 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)
);
}
}
/**
* Provides sizes for the diagram content items.
*
* @category Geometry
*/
export interface SizeProvider {
/**
* Gets current size for the specified element.
*/
getElementSize(element: Element): Size | undefined;
/**
* Gets element shape based on its template and the current bounds.
*/
getElementShape(element: Element): ShapeGeometry;
}
/**
* Computes bounding rectangle from an element's position and size.
*
* @category Geometry
*/
export function boundsOf(element: Element, sizeProvider: SizeProvider): Rect {
const {x, y} = element.position;
const size = sizeProvider.getElementSize(element);
return {
x, y,
width: size ? size.width : 0,
height: size ? size.height : 0,
};
}
/**
* Describes a basic 2D shape with a specific bounds (position and size).
*/
export interface ShapeGeometry {
/**
* Basic 2D shape type.
*/
readonly type: 'rect' | 'ellipse';
/**
* Shape bounds (position and size).
*/
readonly bounds: Rect;
}
function intersectRayFromShape(geometry: ShapeGeometry, target: Vector): Vector | undefined {
switch (geometry.type) {
case 'ellipse': {
return intersectRayFromEllipse(geometry.bounds, target);
}
case 'rect':
default: {
return intersectRayFromRect(geometry.bounds, target);
}
}
}
function intersectRayFromRect(rect: Rect, target: Vector) {
if (
rect.width === 0 || rect.height === 0 ||
target.x > rect.x && target.x < (rect.x + rect.width) &&
target.y > rect.y && target.y < (rect.y + rect.height)
) {
return undefined;
}
const halfWidth = rect.width / 2;
const halfHeight = rect.height / 2;
const center: Vector = {
x: rect.x + halfWidth,
y: rect.y + halfHeight,
};
const direction = Vector.normalize({
x: target.x - center.x,
y: target.y - center.y,
});
const rightDirection = {x: Math.abs(direction.x), y: direction.y};
const isHorizontal =
Vector.cross2D({x: halfWidth, y: -halfHeight}, rightDirection) > 0 &&
Vector.cross2D({x: halfWidth, y: halfHeight}, rightDirection) < 0;
if (isHorizontal) {
return {
x: center.x + halfWidth * Math.sign(direction.x),
y: center.y + halfWidth * direction.y / Math.abs(direction.x),
};
} else {
return {
x: center.x + halfHeight * direction.x / Math.abs(direction.y),
y: center.y + halfHeight * Math.sign(direction.y),
};
}
}
function intersectRayFromEllipse(bounds: Rect, target: Vector): Vector | undefined {
const center = Rect.center(bounds);
const pointer = Vector.subtract(target, center);
const normal = Vector.normalize(pointer);
const intersection: Vector = {
x: normal.x * bounds.width * 0.5,
y: normal.y * bounds.height * 0.5,
};
return Vector.length(pointer) >= Vector.length(intersection)
? Vector.add(center, intersection) : undefined;
}
/**
* Returns `true` is two line geometries (vertex sequences) are the same,
* otherwise `false`.
*
* @category Geometry
*/
export function isPolylineEqual(left: ReadonlyArray<Vector>, right: ReadonlyArray<Vector>) {
if (left === right) { return true; }
if (left.length !== right.length) { return false; }
for (let i = 0; i < left.length; i++) {
const a = left[i];
const b = right[i];
if (!(a.x === b.x && a.y === b.y)) {
return false;
}
}
return true;
}
/**
* Computes line geometry between two shapes clipped at each
* ones border with intermediate points in-between.
*
* It is assumed that the line starts at source shape center,
* ends at target shape center and goes through each vertex in the array.
*
* @category Geometry
*/
export function computePolyline(
source: ShapeGeometry | Rect,
target: ShapeGeometry | Rect,
vertices: ReadonlyArray<Vector>
): Vector[] {
const sourceShape: ShapeGeometry = 'type' in source ? source : {type: 'rect', bounds: source};
const targetShape: ShapeGeometry = 'type' in target ? target : {type: 'rect', bounds: target};
let start: Vector | undefined;
for (let i = 0; i < vertices.length; i++) {
start = intersectRayFromShape(sourceShape, vertices[i]);
if (start) {
break;
}
}
if (!start) {
start = intersectRayFromShape(sourceShape, Rect.center(targetShape.bounds))
?? Rect.center(sourceShape.bounds);
}
let end: Vector | undefined;
for (let i = vertices.length - 1; i >= 0; i--) {
end = intersectRayFromShape(targetShape, vertices[i]);
if (end) {
break;
}
}
if (!end) {
end = intersectRayFromShape(targetShape, Rect.center(sourceShape.bounds))
?? Rect.center(targetShape.bounds);
}
return [start, ...vertices, end];
}
/**
* Computes length of linear line geometry.
*
* @category Geometry
* @see {@link getPointAlongPolyline}
*/
export function computePolylineLength(polyline: ReadonlyArray<Vector>): number {
let previous: Vector;
return polyline.reduce((acc, point) => {
const segmentLength = previous ? Vector.length({x: point.x - previous.x, y: point.y - previous.y}) : 0;
previous = point;
return acc + segmentLength;
}, 0);
}
/**
* Computes position at the specified `offset` along a linear line geometry
* relative to the start of the line.
*
* If `offset` value is less than 0 or greater than line geometry length,
* the the first or last point of the line will be returned correspondingly.
*
* @category Geometry
* @see {@link computePolylineLength}
*/
export function getPointAlongPolyline(polyline: ReadonlyArray<Vector>, offset: number): Vector {
if (polyline.length === 0) {
throw new Error('Cannot compute a point for an empty polyline');
}
if (offset < 0) {
return polyline[0];
}
let currentOffset = 0;
for (let i = 1; i < polyline.length; i++) {
const previous = polyline[i - 1];
const point = polyline[i];
const segment = {x: point.x - previous.x, y: point.y - previous.y};
const segmentLength = Vector.length(segment);
const newOffset = currentOffset + segmentLength;
if (offset < newOffset) {
const leftover = (offset - currentOffset) / segmentLength;
return {
x: previous.x + leftover * segment.x,
y: previous.y + leftover * segment.y,
};
} else {
currentOffset = newOffset;
}
}
return polyline[polyline.length - 1];
}
/**
* Searches for a closest segment of a linear line geometry.
*
* @returns index of start point for the closes line segment, or 0 if line is empty.
* @category Geometry
* @see {@link getPointAlongPolyline}
*/
export function findNearestSegmentIndex(polyline: ReadonlyArray<Vector>, location: Vector): number {
let minDistance = Infinity;
let foundIndex = 0;
for (let i = 0; i < polyline.length - 1; i++) {
const pivot = polyline[i];
const next = polyline[i + 1];
const target = {x: location.x - pivot.x, y: location.y - pivot.y};
const segment = {x: next.x - pivot.x, y: next.y - pivot.y};
const segmentLength = Vector.length(segment);
const projectionToSegment = Vector.dot(target, segment) / segmentLength;
if (projectionToSegment < 0 || projectionToSegment > segmentLength) {
continue;
}
const distanceToSegment = Math.abs(Vector.cross2D(target, segment)) / segmentLength;
if (distanceToSegment < minDistance) {
minDistance = distanceToSegment;
foundIndex = i;
}
}
return foundIndex;
}
export interface SplineGeometry {
readonly type: 'straight' | 'smooth';
readonly points: ReadonlyArray<Vector>;
readonly source: Vector;
readonly target: Vector;
}
export class Spline {
private constructor(readonly geometry: SplineGeometry) {
if (geometry.points.length < 2) {
throw new Error('Spline must consists of at least two points');
}
}
static readonly defaultType: SplineGeometry['type'] = 'smooth';
static create(geometry: SplineGeometry) {
return new Spline(geometry);
}
toPath(): string {
const {type, points, source, target} = this.geometry;
if (type === 'smooth' && points.length >= 3) {
const smoothness = 0.25;
const parts = [`M${points[0].x} ${points[0].y}`];
let previousTangent = Vector.normalize(Vector.subtract(source, points[0]));
for (let i = 1; i < points.length; i++) {
const previous = points[i - 1];
const p = points[i];
const next = points[i + 1];
const tangent = next
? Vector.normalize(Vector.subtract(previous, next))
: Vector.normalize(Vector.subtract(p, target));
const length = Vector.length(Vector.subtract(previous, p));
const c0 = Vector.subtract(previous, Vector.scale(previousTangent, length * smoothness));
const c1 = Vector.add(p, Vector.scale(tangent, length * smoothness));
previousTangent = tangent;
parts.push(` C${c0.x} ${c0.y} ${c1.x} ${c1.y} ${p.x} ${p.y}`);
}
return parts.join('');
}
return pathFromPolyline(points);
}
}
/**
* Converts linear line geometry into an SVG path.
*
* @category Geometry
*/
export function pathFromPolyline(
polyline: ReadonlyArray<Vector>
): string {
return 'M' + polyline.map(({x, y}) => `${x} ${y}`).join(' L');
}
/**
* Returns the first element from specified `elements` which bounding box
* includes the specified `point`.
*
* If the specified `point` is at the edge of a bounding box, it is considered
* to be part of it.
*
* @param elements an array of diagram elements to search
* @param point point on a diagram in paper coordinates
* @param sizeProvider element size provider to compute bounding boxes
* @category Geometry
*/
export function findElementAtPoint(
elements: ReadonlyArray<Element>,
point: Vector,
sizeProvider: SizeProvider
): Element | undefined {
for (let i = elements.length - 1; i >= 0; i--) {
const element = elements[i];
const {x, y, width, height} = boundsOf(element, sizeProvider);
if (width === 0 && height === 0) {
// Skip void and other zero-sized elements
continue;
}
if (point.x >= x && point.x <= x + width && point.y >= y && point.y <= y + height) {
return element;
}
}
return undefined;
}
/**
* Computes complete bounding box for the specified `elements` and `links`.
*
* @category Geometry
*/
export function getContentFittingBox(
elements: Iterable<Element>,
links: Iterable<Link>,
sizeProvider: Pick<SizeProvider, 'getElementSize'>
): Rect {
let minX = Infinity, minY = Infinity;
let maxX = -Infinity, maxY = -Infinity;
for (const element of elements) {
const {x, y} = element.position;
const size = sizeProvider.getElementSize(element);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + (size ? size.width : 0));
maxY = Math.max(maxY, y + (size ? size.height : 0));
}
for (const link of links) {
const vertices = link.vertices || [];
for (const {x, y} of vertices) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
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,
};
}
/**
* Computes average center position of element bounding boxes.
*
* @category Geometry
*/
export function calculateAveragePosition(
elements: ReadonlyArray<Element>,
sizeProvider: SizeProvider
): Vector {
let xSum = 0;
let ySum = 0;
for (const element of elements) {
const {x, y, width, height} = boundsOf(element, sizeProvider);
xSum += x + width / 2;
ySum += y + height / 2;
}
return {
x: xSum / elements.length,
y: ySum / elements.length,
};
}