Skip to content

Commit

Permalink
Merge pull request #615 from tuanchauict/connector-js
Browse files Browse the repository at this point in the history
Port Line Connector to TS
  • Loading branch information
tuanchauict authored Dec 10, 2024
2 parents 2d52b4d + 8837599 commit f39b122
Show file tree
Hide file tree
Showing 5 changed files with 502 additions and 0 deletions.
5 changes: 5 additions & 0 deletions monosketch-svelte/src/lib/libs/graphics-geo/point.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ export class PointF implements IPoint {
) {
}

static create({ left, top }: { left: number; top: number }): PointF {
return new PointF(left, top);
}


get row(): number {
return this.top;
}
Expand Down
208 changes: 208 additions & 0 deletions monosketch-svelte/src/lib/mono/shape/connector/line-connector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/*
* Copyright (c) 2024, tuanchauict
*/

import { DirectedPoint, Direction } from "$libs/graphics-geo/point";
import { Around, ShapeConnectorUseCase } from "$mono/shape/connector/line-connector";
import { describe, it, expect } from 'vitest';
import { Rect } from '$libs/graphics-geo/rect';

type PointAroundTuple = [DirectedPoint, Around];

describe('ShapeConnectorUseCase', () => {
it('testGetAround_notAround', () => {
const rect = Rect.byLTRB(4, 20, 10, 30);

const nonAroundPoints: DirectedPoint[] = [
// Around left edge
DirectedPoint.ofF(Direction.HORIZONTAL, 2, 25),
DirectedPoint.ofF(Direction.HORIZONTAL, 6, 25),
// Around right edge
DirectedPoint.ofF(Direction.HORIZONTAL, 8, 25),
DirectedPoint.ofF(Direction.HORIZONTAL, 12, 25),
// Around top edge
DirectedPoint.ofF(Direction.HORIZONTAL, 6, 18),
DirectedPoint.ofF(Direction.HORIZONTAL, 6, 22),
// Around bottom edge
DirectedPoint.ofF(Direction.HORIZONTAL, 6, 28),
DirectedPoint.ofF(Direction.HORIZONTAL, 6, 32),
// On left edge - outside top
DirectedPoint.ofF(Direction.HORIZONTAL, 3, 18),
DirectedPoint.ofF(Direction.HORIZONTAL, 4, 18),
DirectedPoint.ofF(Direction.HORIZONTAL, 5, 18),
// On left edge - outside bottom
DirectedPoint.ofF(Direction.HORIZONTAL, 3, 32),
DirectedPoint.ofF(Direction.HORIZONTAL, 4, 32),
DirectedPoint.ofF(Direction.HORIZONTAL, 5, 32),
// On right edge - outside top
DirectedPoint.ofF(Direction.HORIZONTAL, 9, 18),
DirectedPoint.ofF(Direction.HORIZONTAL, 10, 18),
DirectedPoint.ofF(Direction.HORIZONTAL, 11, 18),
// On right edge - outside bottom
DirectedPoint.ofF(Direction.HORIZONTAL, 9, 32),
DirectedPoint.ofF(Direction.HORIZONTAL, 10, 32),
DirectedPoint.ofF(Direction.HORIZONTAL, 11, 32),
// On top edge - outside left
DirectedPoint.ofF(Direction.HORIZONTAL, 2, 19),
DirectedPoint.ofF(Direction.HORIZONTAL, 2, 20),
DirectedPoint.ofF(Direction.HORIZONTAL, 2, 21),
// On top edge - outside right
DirectedPoint.ofF(Direction.HORIZONTAL, 12, 19),
DirectedPoint.ofF(Direction.HORIZONTAL, 12, 20),
DirectedPoint.ofF(Direction.HORIZONTAL, 12, 21),
// On bottom edge - outside left
DirectedPoint.ofF(Direction.HORIZONTAL, 2, 29),
DirectedPoint.ofF(Direction.HORIZONTAL, 2, 30),
DirectedPoint.ofF(Direction.HORIZONTAL, 2, 31),
// On bottom edge - outside right
DirectedPoint.ofF(Direction.HORIZONTAL, 12, 29),
DirectedPoint.ofF(Direction.HORIZONTAL, 12, 30),
DirectedPoint.ofF(Direction.HORIZONTAL, 12, 31),
];

for (const point of nonAroundPoints) {
console.log(point);
expect(ShapeConnectorUseCase.getAround(point, rect)).toBeNull();
}
})

it('testGetAround_Left', () => {
const rect = Rect.byLTRB(4, 20, 10, 30);
const leftPointsToExpectedAround: PointAroundTuple[] = [
// No conflict
[DirectedPoint.ofF(Direction.HORIZONTAL, 5, 25), Around.LEFT],
[DirectedPoint.ofF(Direction.VERTICAL, 3, 25), Around.LEFT],
[DirectedPoint.ofF(Direction.VERTICAL, 4, 25), Around.LEFT],
[DirectedPoint.ofF(Direction.VERTICAL, 5, 25), Around.LEFT],
// Conflict
// Horizontal, top edge -> left
[DirectedPoint.ofF(Direction.HORIZONTAL, 3, 20), Around.LEFT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 4, 20), Around.LEFT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 5, 20), Around.LEFT],
// Vertical, top edge -> top
[DirectedPoint.ofF(Direction.VERTICAL, 3, 20), Around.TOP],
[DirectedPoint.ofF(Direction.VERTICAL, 4, 20), Around.TOP],
[DirectedPoint.ofF(Direction.VERTICAL, 5, 20), Around.TOP],
// Horizontal, bottom edge -> left
[DirectedPoint.ofF(Direction.HORIZONTAL, 3, 30), Around.LEFT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 4, 30), Around.LEFT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 5, 30), Around.LEFT],
// Vertical, bottom edge -> bottom
[DirectedPoint.ofF(Direction.VERTICAL, 3, 30), Around.BOTTOM],
[DirectedPoint.ofF(Direction.VERTICAL, 4, 30), Around.BOTTOM],
[DirectedPoint.ofF(Direction.VERTICAL, 5, 30), Around.BOTTOM],
];

for (const [point, expected] of leftPointsToExpectedAround) {
console.log(point);
expect(ShapeConnectorUseCase.getAround(point, rect)).toBe(expected);
}
});

it('testGetAround_Right', () => {
const rect = Rect.byLTRB(4, 20, 10, 30);
const rightPointsToExpectedAround: PointAroundTuple[] = [
// No conflict
[DirectedPoint.ofF(Direction.HORIZONTAL, 9, 25), Around.RIGHT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 10, 25), Around.RIGHT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 11, 25), Around.RIGHT],
[DirectedPoint.ofF(Direction.VERTICAL, 9, 25), Around.RIGHT],
[DirectedPoint.ofF(Direction.VERTICAL, 10, 25), Around.RIGHT],
[DirectedPoint.ofF(Direction.VERTICAL, 11, 25), Around.RIGHT],
// Conflict
// Horizontal, top edge -> left
[DirectedPoint.ofF(Direction.HORIZONTAL, 9, 20), Around.RIGHT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 10, 20), Around.RIGHT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 11, 20), Around.RIGHT],
// Vertical, top edge -> top
[DirectedPoint.ofF(Direction.VERTICAL, 9, 20), Around.TOP],
[DirectedPoint.ofF(Direction.VERTICAL, 10, 20), Around.TOP],
[DirectedPoint.ofF(Direction.VERTICAL, 11, 20), Around.TOP],
// Horizontal, bottom edge -> left
[DirectedPoint.ofF(Direction.HORIZONTAL, 9, 30), Around.RIGHT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 10, 30), Around.RIGHT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 11, 30), Around.RIGHT],
// Vertical, bottom edge -> bottom
[DirectedPoint.ofF(Direction.VERTICAL, 9, 30), Around.BOTTOM],
[DirectedPoint.ofF(Direction.VERTICAL, 10, 30), Around.BOTTOM],
[DirectedPoint.ofF(Direction.VERTICAL, 11, 30), Around.BOTTOM],
];

for (const [point, expected] of rightPointsToExpectedAround) {
console.log(point);
expect(ShapeConnectorUseCase.getAround(point as DirectedPoint, rect)).toBe(expected);
}
});

it('testGetAround_Top', () => {
const rect = Rect.byLTRB(4, 20, 10, 30);
const topPointsToExpectedAround: PointAroundTuple[] = [
// No conflict
[DirectedPoint.ofF(Direction.HORIZONTAL, 6, 19), Around.TOP],
[DirectedPoint.ofF(Direction.HORIZONTAL, 6, 20), Around.TOP],
[DirectedPoint.ofF(Direction.HORIZONTAL, 6, 21), Around.TOP],
[DirectedPoint.ofF(Direction.VERTICAL, 6, 19), Around.TOP],
[DirectedPoint.ofF(Direction.VERTICAL, 6, 20), Around.TOP],
[DirectedPoint.ofF(Direction.VERTICAL, 6, 21), Around.TOP],
// Conflict
// Vertical, left edge -> top
[DirectedPoint.ofF(Direction.VERTICAL, 4, 19), Around.TOP],
[DirectedPoint.ofF(Direction.VERTICAL, 4, 20), Around.TOP],
[DirectedPoint.ofF(Direction.VERTICAL, 4, 21), Around.TOP],
// Horizontal, left edge -> left
[DirectedPoint.ofF(Direction.HORIZONTAL, 4, 19), Around.LEFT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 4, 20), Around.LEFT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 4, 21), Around.LEFT],
// Vertical, right edge -> top
[DirectedPoint.ofF(Direction.VERTICAL, 10, 19), Around.TOP],
[DirectedPoint.ofF(Direction.VERTICAL, 10, 20), Around.TOP],
[DirectedPoint.ofF(Direction.VERTICAL, 10, 21), Around.TOP],
// Horizontal, right edge -> right
[DirectedPoint.ofF(Direction.HORIZONTAL, 10, 19), Around.RIGHT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 10, 20), Around.RIGHT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 10, 21), Around.RIGHT],
];

for (const [point, expected] of topPointsToExpectedAround) {
console.log(point);
expect(ShapeConnectorUseCase.getAround(point, rect)).toBe(expected);
}
});

it('testGetAround_Bottom', () => {
const rect = Rect.byLTRB(4, 20, 10, 30);
const bottomPointsToExpectedAround: PointAroundTuple[] = [
// No conflict
[DirectedPoint.ofF(Direction.HORIZONTAL, 6, 29), Around.BOTTOM],
[DirectedPoint.ofF(Direction.HORIZONTAL, 6, 30), Around.BOTTOM],
[DirectedPoint.ofF(Direction.HORIZONTAL, 6, 31), Around.BOTTOM],
[DirectedPoint.ofF(Direction.VERTICAL, 6, 29), Around.BOTTOM],
[DirectedPoint.ofF(Direction.VERTICAL, 6, 30), Around.BOTTOM],
[DirectedPoint.ofF(Direction.VERTICAL, 6, 31), Around.BOTTOM],
// Conflict
// Vertical, left edge -> bottom
[DirectedPoint.ofF(Direction.VERTICAL, 4, 29), Around.BOTTOM],
[DirectedPoint.ofF(Direction.VERTICAL, 4, 30), Around.BOTTOM],
[DirectedPoint.ofF(Direction.VERTICAL, 4, 31), Around.BOTTOM],
// Horizontal, left edge -> left
[DirectedPoint.ofF(Direction.HORIZONTAL, 4, 29), Around.LEFT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 4, 30), Around.LEFT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 4, 31), Around.LEFT],
// Vertical, right edge -> bottom
[DirectedPoint.ofF(Direction.VERTICAL, 10, 29), Around.BOTTOM],
[DirectedPoint.ofF(Direction.VERTICAL, 10, 30), Around.BOTTOM],
[DirectedPoint.ofF(Direction.VERTICAL, 10, 31), Around.BOTTOM],
// Horizontal, right edge -> right
[DirectedPoint.ofF(Direction.HORIZONTAL, 10, 29), Around.RIGHT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 10, 30), Around.RIGHT],
[DirectedPoint.ofF(Direction.HORIZONTAL, 10, 31), Around.RIGHT],
];

for (const [point, expected] of bottomPointsToExpectedAround) {
console.log(point);
expect(ShapeConnectorUseCase.getAround(point, rect)).toBe(expected);
}
});

// TODO: Add unit test for calculateRatio and calculateOffset
});
166 changes: 166 additions & 0 deletions monosketch-svelte/src/lib/mono/shape/connector/line-connector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* Copyright (c) 2024, tuanchauict
*/


import { DirectedPoint, Direction, Point, PointF } from "$libs/graphics-geo/point";
import type { Rect } from "$libs/graphics-geo/rect";
import type { Identifier } from "$mono/shape/collection/identifier";
import type { AbstractShape } from "$mono/shape/shape/abstract-shape";
import type { LineAnchor } from "$mono/shape/shape/linehelper";
import { Rectangle } from "$mono/shape/shape/rectangle";

/**
* A [Identifier] of Line's connector.
* This is a minimal version of [LineConnector] that only has required information to identify
* the connector.
*/
export class ConnectorIdentifier implements Identifier {
id: string;

constructor(lineId: string, anchor: LineAnchor) {
this.id = toId(lineId, anchor);
}
}

/**
* A connector for [Line].
*
* @property lineId The id of the target of this connector
* @property anchor The extra information for identifying which head of the line (start or end)
* @property ratio The relative position of the connector based on the size of the box.
* @property offset The absolute offset of the connector to the box
*/
export class LineConnector extends ConnectorIdentifier {
constructor(
public lineId: string,
public anchor: LineAnchor,
public ratio: PointF,
public offset: Point,
) {
super(lineId, anchor);
}
}

export enum Around {
LEFT, TOP, RIGHT, BOTTOM
}

const toId = (lineId: string, anchor: LineAnchor): string => `${lineId}_${anchor}`;

/**
* A Use case for shape connector
*/
class ShapeConnectorUseCaseImpl {
private static readonly MAX_DISTANCE = 1;

getConnectableShape(point: Point, candidates: Iterable<AbstractShape>): AbstractShape | null {
for (const shape of Array.from(candidates).reverse()) {
if (this.canConnect(point, shape)) {
return shape;
}
}
return null;
}

private canConnect(point: Point, shape: AbstractShape): boolean {
const canHaveConnector = shape instanceof Rectangle || shape instanceof Text;
if (!canHaveConnector) {
return false;
}
return this.detectAround(point, shape.bound).some(Boolean);
}

getAround(anchorPoint: DirectedPoint, boxBound: Rect): Around | null {
const [isAroundLeft, isAroundRight, isAroundTop, isAroundBottom] = this.detectAround(anchorPoint.point, boxBound);

const isHorizontal = anchorPoint.direction === Direction.HORIZONTAL;
if (isAroundLeft) {
if (isAroundTop && !isHorizontal) return Around.TOP;
if (isAroundBottom && !isHorizontal) return Around.BOTTOM;
return Around.LEFT;
}

if (isAroundTop) {
if (isAroundRight && isHorizontal) return Around.RIGHT;
return Around.TOP;
}

if (isAroundRight) {
if (isAroundBottom && !isHorizontal) return Around.BOTTOM;
return Around.RIGHT;
}

if (isAroundBottom) return Around.BOTTOM;

return null;
}

private detectAround(point: Point, boxBound: Rect): boolean[] {
const isAroundLeft = this.isAround(point.left, boxBound.left) &&
this.isAround(point.top, boxBound.top, boxBound.bottom);
const isAroundRight = this.isAround(point.left, boxBound.right) &&
this.isAround(point.top, boxBound.top, boxBound.bottom);
const isAroundTop = this.isAround(point.top, boxBound.top) &&
this.isAround(point.left, boxBound.left, boxBound.right);
const isAroundBottom = this.isAround(point.top, boxBound.bottom) &&
this.isAround(point.left, boxBound.left, boxBound.right);
return [isAroundLeft, isAroundRight, isAroundTop, isAroundBottom];
}

private isAround(value: number, lower: number, upper: number = lower, distance: number = ShapeConnectorUseCaseImpl.MAX_DISTANCE): boolean {
return value >= (lower - distance) && value <= (upper + distance);
}

calculateRatio(around: Around, anchorPoint: DirectedPoint, boxBound: Rect): PointF {
const leftRatio = (anchorPoint.left - boxBound.left) / this.adjustSizeValue(boxBound.width);
const topRatio = (anchorPoint.top - boxBound.top) / this.adjustSizeValue(boxBound.height);
switch (around) {
case Around.LEFT:
return PointF.create({ left: 0.0, top: Math.min(Math.max(topRatio, 0.0), 1.0) });
case Around.TOP:
return PointF.create({ left: Math.min(Math.max(leftRatio, 0.0), 1.0), top: 0.0 });
case Around.RIGHT:
return PointF.create({ left: 1.0, top: Math.min(Math.max(topRatio, 0.0), 1.0) });
case Around.BOTTOM:
return PointF.create({ left: Math.min(Math.max(leftRatio, 0.0), 1.0), top: 1.0 });
}
}

calculateOffset(around: Around, anchorPoint: DirectedPoint, boxBound: Rect): Point {
switch (around) {
case Around.LEFT:
return new Point(anchorPoint.left - boxBound.left, this.offsetToRange(anchorPoint.top, boxBound.top, boxBound.bottom));
case Around.TOP:
return new Point(this.offsetToRange(anchorPoint.left, boxBound.left, boxBound.right), anchorPoint.top - boxBound.top);
case Around.RIGHT:
return new Point(anchorPoint.left - boxBound.right, this.offsetToRange(anchorPoint.top, boxBound.top, boxBound.bottom));
case Around.BOTTOM:
return new Point(this.offsetToRange(anchorPoint.left, boxBound.left, boxBound.right), anchorPoint.top - boxBound.bottom);
}
}

getPointInNewBound(lineConnector: LineConnector, direction: Direction, boxBound: Rect): DirectedPoint {
return DirectedPoint.ofF(direction, this.getLeftInNewBound(lineConnector, boxBound), this.getTopInNewBound(lineConnector, boxBound));
}

private getLeftInNewBound(lineConnector: LineConnector, boxBound: Rect): number {
return Math.round(boxBound.left + (boxBound.width - 1) * lineConnector.ratio.left + lineConnector.offset.left);
}

private getTopInNewBound(lineConnector: LineConnector, boxBound: Rect): number {
return Math.round(boxBound.top + (boxBound.height - 1) * lineConnector.ratio.top + lineConnector.offset.top);
}

private offsetToRange(value: number, lower: number, upper: number): number {
if (value < lower) return value - lower;
if (value > upper) return value - upper;
return 0;
}

private adjustSizeValue(value: number): number {
return Math.max(value - 1, 1);
}
}

export const ShapeConnectorUseCase = new ShapeConnectorUseCaseImpl();
Loading

0 comments on commit f39b122

Please sign in to comment.