-
-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #615 from tuanchauict/connector-js
Port Line Connector to TS
- Loading branch information
Showing
5 changed files
with
502 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
208 changes: 208 additions & 0 deletions
208
monosketch-svelte/src/lib/mono/shape/connector/line-connector.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
166
monosketch-svelte/src/lib/mono/shape/connector/line-connector.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |
Oops, something went wrong.