feat: add geometry library

This commit is contained in:
bubkoo
2021-03-23 09:22:56 +08:00
committed by 问崖
parent 848d5a3963
commit dbba32eebc
31 changed files with 8771 additions and 0 deletions

View File

@ -0,0 +1 @@
# x6-geometry

View File

@ -0,0 +1,10 @@
module.exports = {
transform: {
'^.+\\.ts$': 'ts-jest',
},
testMatch: ['**/src/**/*.test.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
collectCoverage: true,
coverageDirectory: './test/coverage',
coverageReporters: ['lcov', 'text-summary'],
}

View File

@ -0,0 +1,119 @@
{
"private": true,
"version": "1.0.0",
"name": "@antv/x6-geometry",
"description": "Some useful geometry operations.",
"main": "lib/index.js",
"module": "es/index.js",
"unpkg": "dist/x6-geometry.js",
"jsdelivr": "dist/x6-geometry.js",
"types": "lib/index.d.ts",
"files": [
"dist",
"es",
"lib"
],
"keywords": [
"geometry",
"shape",
"x6",
"antv"
],
"scripts": {
"clean:build": "rimraf dist es lib",
"clean:coverage": "rimraf ./test/coverage",
"clean": "run-p clean:build clean:coverage",
"lint": "eslint 'src/**/*.{js,ts}?(x)' --fix",
"build:esm": "tsc --module esnext --target es2015 --outDir ./es",
"build:cjs": "tsc --module commonjs --target es5 --outDir ./lib",
"build:umd": "rollup -c",
"build:version": "node ../../scripts/version.js",
"build:watch": "yarn build:esm --w",
"build:watch:esm": "yarn build:esm --w",
"build:watch:cjs": "yarn build:cjs --w",
"build:dev": "run-p build:cjs build:esm",
"build": "run-p build:version build:dev build:umd",
"prebuild": "run-s lint clean",
"test": "jest",
"coveralls": "cat ./test/coverage/lcov.info | coveralls",
"pretest": "run-p clean:coverage",
"prepare": "run-s build:version test build",
"precommit": "lint-staged"
},
"lint-staged": {
"src/**/*.ts": [
"eslint --fix"
]
},
"inherits": [
"@antv/x6-package-json/cli.json",
"@antv/x6-package-json/jest.json",
"@antv/x6-package-json/eslint.json",
"@antv/x6-package-json/rollup.json"
],
"devDependencies": {
"@rollup/plugin-commonjs": "^17.1.0",
"@rollup/plugin-node-resolve": "^11.2.0",
"@rollup/plugin-replace": "^2.4.1",
"@rollup/plugin-typescript": "^8.2.0",
"@types/jest": "^26.0.21",
"@types/node": "^14.14.35",
"@typescript-eslint/eslint-plugin": "^4.18.0",
"@typescript-eslint/parser": "^4.18.0",
"coveralls": "^3.1.0",
"eslint": "^7.22.0",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "^24.3.2",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-promise": "^4.3.1",
"eslint-plugin-react": "^7.22.0",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-plugin-unicorn": "^29.0.0",
"fs-extra": "^9.1.0",
"jest": "^26.6.3",
"lint-staged": "^10.5.4",
"npm-run-all": "^4.1.5",
"postcss": "^8.2.8",
"prettier": "^2.2.1",
"pretty-quick": "^3.1.0",
"rimraf": "^3.0.2",
"rollup": "^2.42.2",
"rollup-plugin-auto-external": "^2.0.0",
"rollup-plugin-filesize": "^9.1.1",
"rollup-plugin-postcss": "^4.0.0",
"rollup-plugin-progress": "^1.1.2",
"rollup-plugin-terser": "^7.0.2",
"ts-jest": "^26.5.4",
"ts-node": "^9.1.1",
"tslib": "^2.1.0",
"typescript": "^4.2.3"
},
"browserslist": [
"last 2 versions",
"Firefox ESR",
"> 1%"
],
"author": {
"name": "bubkoo",
"email": "bubkoo.wy@gmail.com"
},
"contributors": [],
"license": "MIT",
"homepage": "https://github.com/antvis/x6",
"bugs": {
"url": "https://github.com/antvis/x6/issues"
},
"repository": {
"type": "git",
"url": "ssh://git@github.com/antvis/x6.git",
"directory": "packages/x6"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
}
}

View File

@ -0,0 +1,12 @@
import config from '../../configs/rollup-config'
export default config({
output: [
{
name: 'X6Geometry',
format: 'umd',
file: 'dist/x6-geometry.js',
sourcemap: true,
},
],
})

View File

@ -0,0 +1,26 @@
export namespace Angle {
/**
* Converts radian angle to degree angle.
* @param rad The radians to convert.
*/
export function toDeg(rad: number) {
return ((180 * rad) / Math.PI) % 360
}
/**
* Converts degree angle to radian angle.
* @param deg The degree angle to convert.
* @param over360
*/
export const toRad = function (deg: number, over360 = false) {
const d = over360 ? deg : deg % 360
return (d * Math.PI) / 180
}
/**
* Returns the angle in degrees and clamps its value between `0` and `360`.
*/
export function normalize(angle: number) {
return (angle % 360) + (angle < 0 ? 360 : 0)
}
}

View File

@ -0,0 +1,936 @@
import { Line } from './line'
import { Point } from './point'
import { Polyline } from './polyline'
import { Rectangle } from './rectangle'
import { Geometry } from './geometry'
export class Curve extends Geometry {
start: Point
end: Point
controlPoint1: Point
controlPoint2: Point
PRECISION = 3
protected get [Symbol.toStringTag]() {
return Curve.toStringTag
}
constructor(
start: Point.PointLike | Point.PointData,
controlPoint1: Point.PointLike | Point.PointData,
controlPoint2: Point.PointLike | Point.PointData,
end: Point.PointLike | Point.PointData,
) {
super()
this.start = Point.create(start)
this.controlPoint1 = Point.create(controlPoint1)
this.controlPoint2 = Point.create(controlPoint2)
this.end = Point.create(end)
}
bbox() {
const start = this.start
const controlPoint1 = this.controlPoint1
const controlPoint2 = this.controlPoint2
const end = this.end
const x0 = start.x
const y0 = start.y
const x1 = controlPoint1.x
const y1 = controlPoint1.y
const x2 = controlPoint2.x
const y2 = controlPoint2.y
const x3 = end.x
const y3 = end.y
const points = [] // local extremes
const tvalues = [] // t values of local extremes
const bounds: [number[], number[]] = [[], []]
let a
let b
let c
let t
let t1
let t2
let b2ac
let sqrtb2ac
for (let i = 0; i < 2; i += 1) {
if (i === 0) {
b = 6 * x0 - 12 * x1 + 6 * x2
a = -3 * x0 + 9 * x1 - 9 * x2 + 3 * x3
c = 3 * x1 - 3 * x0
} else {
b = 6 * y0 - 12 * y1 + 6 * y2
a = -3 * y0 + 9 * y1 - 9 * y2 + 3 * y3
c = 3 * y1 - 3 * y0
}
if (Math.abs(a) < 1e-12) {
if (Math.abs(b) < 1e-12) {
continue
}
t = -c / b
if (t > 0 && t < 1) tvalues.push(t)
continue
}
b2ac = b * b - 4 * c * a
sqrtb2ac = Math.sqrt(b2ac)
if (b2ac < 0) continue
t1 = (-b + sqrtb2ac) / (2 * a)
if (t1 > 0 && t1 < 1) tvalues.push(t1)
t2 = (-b - sqrtb2ac) / (2 * a)
if (t2 > 0 && t2 < 1) tvalues.push(t2)
}
let x
let y
let mt
let j = tvalues.length
const jlen = j
while (j) {
j -= 1
t = tvalues[j]
mt = 1 - t
x =
mt * mt * mt * x0 +
3 * mt * mt * t * x1 +
3 * mt * t * t * x2 +
t * t * t * x3
bounds[0][j] = x
y =
mt * mt * mt * y0 +
3 * mt * mt * t * y1 +
3 * mt * t * t * y2 +
t * t * t * y3
bounds[1][j] = y
points[j] = { X: x, Y: y }
}
tvalues[jlen] = 0
tvalues[jlen + 1] = 1
points[jlen] = { X: x0, Y: y0 }
points[jlen + 1] = { X: x3, Y: y3 }
bounds[0][jlen] = x0
bounds[1][jlen] = y0
bounds[0][jlen + 1] = x3
bounds[1][jlen + 1] = y3
tvalues.length = jlen + 2
bounds[0].length = jlen + 2
bounds[1].length = jlen + 2
points.length = jlen + 2
const left = Math.min.apply(null, bounds[0])
const top = Math.min.apply(null, bounds[1])
const right = Math.max.apply(null, bounds[0])
const bottom = Math.max.apply(null, bounds[1])
return new Rectangle(left, top, right - left, bottom - top)
}
closestPoint(
p: Point.PointLike | Point.PointData,
options: Curve.Options = {},
) {
return this.pointAtT(this.closestPointT(p, options))
}
closestPointLength(
p: Point.PointLike | Point.PointData,
options: Curve.Options = {},
) {
const opts = this.getOptions(options)
return this.lengthAtT(this.closestPointT(p, opts), opts)
}
closestPointNormalizedLength(
p: Point.PointLike | Point.PointData,
options: Curve.Options = {},
) {
const opts = this.getOptions(options)
const cpLength = this.closestPointLength(p, opts)
if (!cpLength) {
return 0
}
const length = this.length(opts)
if (length === 0) {
return 0
}
return cpLength / length
}
closestPointT(
p: Point.PointLike | Point.PointData,
options: Curve.Options = {},
) {
const precision = this.getPrecision(options)
const subdivisions = this.getDivisions(options)
const precisionRatio = Math.pow(10, -precision) // eslint-disable-line
let investigatedSubdivision: Curve | null = null
let investigatedSubdivisionStartT = 0
let investigatedSubdivisionEndT = 0
let distFromStart = 0
let distFromEnd = 0
let chordLength = 0
let minSumDist: number | null = null
const count = subdivisions.length
let piece = count > 0 ? 1 / count : 0
subdivisions.forEach((division, i) => {
const startDist = division.start.distance(p)
const endDist = division.end.distance(p)
const sumDist = startDist + endDist
if (minSumDist == null || sumDist < minSumDist) {
investigatedSubdivision = division
investigatedSubdivisionStartT = i * piece
investigatedSubdivisionEndT = (i + 1) * piece
distFromStart = startDist
distFromEnd = endDist
minSumDist = sumDist
chordLength = division.endpointDistance()
}
})
// Recursively divide investigated subdivision, until distance between
// baselinePoint and closest path endpoint is within `10^(-precision)`,
// then return the closest endpoint of that final subdivision.
// eslint-disable-next-line
while (true) {
// check if we have reached at least one required observed precision
// - calculated as: the difference in distances from point to start and end divided by the distance
// - note that this function is not monotonic = it doesn't converge stably but has "teeth"
// - the function decreases while one of the endpoints is fixed but "jumps" whenever we switch
// - this criterion works well for points lying far away from the curve
const startPrecisionRatio = distFromStart
? Math.abs(distFromStart - distFromEnd!) / distFromStart
: 0
const endPrecisionRatio =
distFromEnd != null
? Math.abs(distFromStart! - distFromEnd) / distFromEnd
: 0
const hasRequiredPrecision =
startPrecisionRatio < precisionRatio ||
endPrecisionRatio < precisionRatio
// check if we have reached at least one required minimal distance
// - calculated as: the subdivision chord length multiplied by precisionRatio
// - calculation is relative so it will work for arbitrarily large/small curves and their subdivisions
// - this is a backup criterion that works well for points lying "almost at" the curve
const hasMiniStartDistance = distFromStart
? distFromStart < chordLength * precisionRatio
: true
const hasMiniEndDistance = distFromEnd
? distFromEnd < chordLength * precisionRatio
: true
const hasMiniDistance = hasMiniStartDistance || hasMiniEndDistance
if (hasRequiredPrecision || hasMiniDistance) {
return distFromStart <= distFromEnd
? investigatedSubdivisionStartT
: investigatedSubdivisionEndT
}
// otherwise, set up for next iteration
const divided: [Curve, Curve] = investigatedSubdivision!.divide(0.5)
piece /= 2
const startDist1 = divided[0].start.distance(p)
const endDist1 = divided[0].end.distance(p)
const sumDist1 = startDist1 + endDist1
const startDist2 = divided[1].start.distance(p)
const endDist2 = divided[1].end.distance(p)
const sumDist2 = startDist2 + endDist2
if (sumDist1 <= sumDist2) {
investigatedSubdivision = divided[0]
investigatedSubdivisionEndT -= piece
distFromStart = startDist1
distFromEnd = endDist1
} else {
investigatedSubdivision = divided[1]
investigatedSubdivisionStartT += piece
distFromStart = startDist2
distFromEnd = endDist2
}
}
}
closestPointTangent(
p: Point.PointLike | Point.PointData,
options: Curve.Options = {},
) {
return this.tangentAtT(this.closestPointT(p, options))
}
containsPoint(
p: Point.PointLike | Point.PointData,
options: Curve.Options = {},
) {
const polyline = this.toPolyline(options)
return polyline.containsPoint(p)
}
divideAt(ratio: number, options: Curve.Options = {}): [Curve, Curve] {
if (ratio <= 0) {
return this.divideAtT(0)
}
if (ratio >= 1) {
return this.divideAtT(1)
}
const t = this.tAt(ratio, options)
return this.divideAtT(t)
}
divideAtLength(length: number, options: Curve.Options = {}): [Curve, Curve] {
const t = this.tAtLength(length, options)
return this.divideAtT(t)
}
divide(t: number) {
return this.divideAtT(t)
}
divideAtT(t: number): [Curve, Curve] {
const start = this.start
const controlPoint1 = this.controlPoint1
const controlPoint2 = this.controlPoint2
const end = this.end
if (t <= 0) {
return [
new Curve(start, start, start, start),
new Curve(start, controlPoint1, controlPoint2, end),
]
}
if (t >= 1) {
return [
new Curve(start, controlPoint1, controlPoint2, end),
new Curve(end, end, end, end),
]
}
const dividerPoints = this.getSkeletonPoints(t)
const startControl1 = dividerPoints.startControlPoint1
const startControl2 = dividerPoints.startControlPoint2
const divider = dividerPoints.divider
const dividerControl1 = dividerPoints.dividerControlPoint1
const dividerControl2 = dividerPoints.dividerControlPoint2
return [
new Curve(start, startControl1, startControl2, divider),
new Curve(divider, dividerControl1, dividerControl2, end),
]
}
endpointDistance() {
return this.start.distance(this.end)
}
getSkeletonPoints(t: number) {
const start = this.start
const control1 = this.controlPoint1
const control2 = this.controlPoint2
const end = this.end
// shortcuts for `t` values that are out of range
if (t <= 0) {
return {
startControlPoint1: start.clone(),
startControlPoint2: start.clone(),
divider: start.clone(),
dividerControlPoint1: control1.clone(),
dividerControlPoint2: control2.clone(),
}
}
if (t >= 1) {
return {
startControlPoint1: control1.clone(),
startControlPoint2: control2.clone(),
divider: end.clone(),
dividerControlPoint1: end.clone(),
dividerControlPoint2: end.clone(),
}
}
const midpoint1 = new Line(start, control1).pointAt(t)
const midpoint2 = new Line(control1, control2).pointAt(t)
const midpoint3 = new Line(control2, end).pointAt(t)
const subControl1 = new Line(midpoint1, midpoint2).pointAt(t)
const subControl2 = new Line(midpoint2, midpoint3).pointAt(t)
const divideLine = new Line(subControl1, subControl2).pointAt(t)
return {
startControlPoint1: midpoint1,
startControlPoint2: subControl1,
divider: divideLine,
dividerControlPoint1: subControl2,
dividerControlPoint2: midpoint3,
}
}
getSubdivisions(options: Curve.Options = {}): Curve[] {
const precision = this.getPrecision(options)
let subdivisions = [
new Curve(this.start, this.controlPoint1, this.controlPoint2, this.end),
]
if (precision === 0) {
return subdivisions
}
let previousLength = this.endpointDistance()
const precisionRatio = Math.pow(10, -precision) // eslint-disable-line
// Recursively divide curve at `t = 0.5`, until the difference between
// observed length at subsequent iterations is lower than precision.
let iteration = 0
// eslint-disable-next-line
while (true) {
iteration += 1
const divisions: Curve[] = []
subdivisions.forEach((c) => {
// dividing at t = 0.5 (not at middle length!)
const divided = c.divide(0.5)
divisions.push(divided[0], divided[1])
})
// measure new length
const length = divisions.reduce(
(memo, c) => memo + c.endpointDistance(),
0,
)
// check if we have reached required observed precision
// sine-like curves may have the same observed length in iteration 0 and 1 - skip iteration 1
// not a problem for further iterations because cubic curves cannot have more than two local extrema
// (i.e. cubic curves cannot intersect the baseline more than once)
// therefore two subsequent iterations cannot produce sampling with equal length
const ratio = length !== 0 ? (length - previousLength) / length : 0
if (iteration > 1 && ratio < precisionRatio) {
return divisions
}
subdivisions = divisions
previousLength = length
}
}
length(options: Curve.Options = {}) {
const divisions = this.getDivisions(options)
return divisions.reduce((memo, c) => {
return memo + c.endpointDistance()
}, 0)
}
lengthAtT(t: number, options: Curve.Options = {}) {
if (t <= 0) {
return 0
}
const precision =
options.precision === undefined ? this.PRECISION : options.precision
const subCurve = this.divide(t)[0]
return subCurve.length({ precision })
}
pointAt(ratio: number, options: Curve.Options = {}) {
if (ratio <= 0) {
return this.start.clone()
}
if (ratio >= 1) {
return this.end.clone()
}
const t = this.tAt(ratio, options)
return this.pointAtT(t)
}
pointAtLength(length: number, options: Curve.Options = {}) {
const t = this.tAtLength(length, options)
return this.pointAtT(t)
}
pointAtT(t: number) {
if (t <= 0) {
return this.start.clone()
}
if (t >= 1) {
return this.end.clone()
}
return this.getSkeletonPoints(t).divider
}
isDifferentiable() {
const start = this.start
const control1 = this.controlPoint1
const control2 = this.controlPoint2
const end = this.end
return !(
start.equals(control1) &&
control1.equals(control2) &&
control2.equals(end)
)
}
tangentAt(ratio: number, options: Curve.Options = {}) {
if (!this.isDifferentiable()) return null
if (ratio < 0) {
ratio = 0 // eslint-disable-line
} else if (ratio > 1) {
ratio = 1 // eslint-disable-line
}
const t = this.tAt(ratio, options)
return this.tangentAtT(t)
}
tangentAtLength(length: number, options: Curve.Options = {}) {
if (!this.isDifferentiable()) {
return null
}
const t = this.tAtLength(length, options)
return this.tangentAtT(t)
}
tangentAtT(t: number) {
if (!this.isDifferentiable()) {
return null
}
if (t < 0) {
t = 0 // eslint-disable-line
}
if (t > 1) {
t = 1 // eslint-disable-line
}
const skeletonPoints = this.getSkeletonPoints(t)
const p1 = skeletonPoints.startControlPoint2
const p2 = skeletonPoints.dividerControlPoint1
const tangentStart = skeletonPoints.divider
const tangentLine = new Line(p1, p2)
// move so that tangent line starts at the point requested
tangentLine.translate(tangentStart.x - p1.x, tangentStart.y - p1.y)
return tangentLine
}
protected getPrecision(options: Curve.Options = {}) {
return options.precision == null ? this.PRECISION : options.precision
}
protected getDivisions(options: Curve.Options = {}) {
if (options.subdivisions != null) {
return options.subdivisions
}
const precision = this.getPrecision(options)
return this.getSubdivisions({ precision })
}
protected getOptions(options: Curve.Options = {}): Curve.Options {
const precision = this.getPrecision(options)
const subdivisions = this.getDivisions(options)
return { precision, subdivisions }
}
protected tAt(ratio: number, options: Curve.Options = {}) {
if (ratio <= 0) {
return 0
}
if (ratio >= 1) {
return 1
}
const opts = this.getOptions(options)
const total = this.length(opts)
const length = total * ratio
return this.tAtLength(length, opts)
}
protected tAtLength(length: number, options: Curve.Options = {}) {
let fromStart = true
if (length < 0) {
fromStart = false
length = -length // eslint-disable-line
}
const precision = this.getPrecision(options)
const subdivisions = this.getDivisions(options)
const opts = { precision, subdivisions }
let investigatedSubdivision: Curve | null = null
let investigatedSubdivisionStartT: number
let investigatedSubdivisionEndT: number
let baselinePointDistFromStart = 0
let baselinePointDistFromEnd = 0
let memo = 0
const count = subdivisions.length
let piece = count > 0 ? 1 / count : 0
for (let i = 0; i < count; i += 1) {
const index = fromStart ? i : count - 1 - i
const division = subdivisions[i]
const dist = division.endpointDistance()
if (length <= memo + dist) {
investigatedSubdivision = division
investigatedSubdivisionStartT = index * piece
investigatedSubdivisionEndT = (index + 1) * piece
baselinePointDistFromStart = fromStart
? length - memo
: dist + memo - length
baselinePointDistFromEnd = fromStart
? dist + memo - length
: length - memo
break
}
memo += dist
}
if (investigatedSubdivision == null) {
return fromStart ? 1 : 0
}
// note that precision affects what length is recorded
// (imprecise measurements underestimate length by up to 10^(-precision) of the precise length)
// e.g. at precision 1, the length may be underestimated by up to 10% and cause this function to return 1
const total = this.length(opts)
const precisionRatio = Math.pow(10, -precision) // eslint-disable-line
// recursively divide investigated subdivision:
// until distance between baselinePoint and closest path endpoint is within 10^(-precision)
// then return the closest endpoint of that final subdivision
// eslint-disable-next-line
while (true) {
let ratio
ratio = total !== 0 ? baselinePointDistFromStart / total : 0
if (ratio < precisionRatio) {
return investigatedSubdivisionStartT!
}
ratio = total !== 0 ? baselinePointDistFromEnd / total : 0
if (ratio < precisionRatio) {
return investigatedSubdivisionEndT!
}
// otherwise, set up for next iteration
let newBaselinePointDistFromStart
let newBaselinePointDistFromEnd
const divided: [Curve, Curve] = investigatedSubdivision.divide(0.5)
piece /= 2
const baseline1Length = divided[0].endpointDistance()
const baseline2Length = divided[1].endpointDistance()
if (baselinePointDistFromStart <= baseline1Length) {
investigatedSubdivision = divided[0]
investigatedSubdivisionEndT! -= piece
newBaselinePointDistFromStart = baselinePointDistFromStart
newBaselinePointDistFromEnd =
baseline1Length - newBaselinePointDistFromStart
} else {
investigatedSubdivision = divided[1]
investigatedSubdivisionStartT! += piece
newBaselinePointDistFromStart =
baselinePointDistFromStart - baseline1Length
newBaselinePointDistFromEnd =
baseline2Length - newBaselinePointDistFromStart
}
baselinePointDistFromStart = newBaselinePointDistFromStart
baselinePointDistFromEnd = newBaselinePointDistFromEnd
}
}
toPoints(options: Curve.Options = {}) {
const subdivisions = this.getDivisions(options)
const points = [subdivisions[0].start.clone()]
subdivisions.forEach((c) => points.push(c.end.clone()))
return points
}
toPolyline(options: Curve.Options = {}) {
return new Polyline(this.toPoints(options))
}
scale(sx: number, sy: number, origin?: Point.PointLike | Point.PointData) {
this.start.scale(sx, sy, origin)
this.controlPoint1.scale(sx, sy, origin)
this.controlPoint2.scale(sx, sy, origin)
this.end.scale(sx, sy, origin)
return this
}
rotate(angle: number, origin?: Point.PointLike | Point.PointData) {
this.start.rotate(angle, origin)
this.controlPoint1.rotate(angle, origin)
this.controlPoint2.rotate(angle, origin)
this.end.rotate(angle, origin)
return this
}
translate(tx: number, ty: number): this
translate(p: Point.PointLike | Point.PointData): this
translate(tx: number | Point.PointLike | Point.PointData, ty?: number) {
if (typeof tx === 'number') {
this.start.translate(tx, ty as number)
this.controlPoint1.translate(tx, ty as number)
this.controlPoint2.translate(tx, ty as number)
this.end.translate(tx, ty as number)
} else {
this.start.translate(tx)
this.controlPoint1.translate(tx)
this.controlPoint2.translate(tx)
this.end.translate(tx)
}
return this
}
equals(c: Curve) {
return (
c != null &&
this.start.equals(c.start) &&
this.controlPoint1.equals(c.controlPoint1) &&
this.controlPoint2.equals(c.controlPoint2) &&
this.end.equals(c.end)
)
}
clone() {
return new Curve(
this.start,
this.controlPoint1,
this.controlPoint2,
this.end,
)
}
toJSON() {
return {
start: this.start.toJSON(),
controlPoint1: this.controlPoint1.toJSON(),
controlPoint2: this.controlPoint2.toJSON(),
end: this.end.toJSON(),
}
}
serialize() {
return [
this.start.serialize(),
this.controlPoint1.serialize(),
this.controlPoint2.serialize(),
this.end.serialize(),
].join(' ')
}
}
export namespace Curve {
export const toStringTag = `X6.Geometry.${Curve.name}`
export function isCurve(instance: any): instance is Curve {
if (instance == null) {
return false
}
if (instance instanceof Curve) {
return true
}
const tag = instance[Symbol.toStringTag]
const curve = instance as Curve
if (
(tag == null || tag === toStringTag) &&
Point.isPoint(curve.start) &&
Point.isPoint(curve.controlPoint1) &&
Point.isPoint(curve.controlPoint2) &&
Point.isPoint(curve.end) &&
typeof curve.toPoints === 'function' &&
typeof curve.toPolyline === 'function'
) {
return true
}
return false
}
}
export namespace Curve {
export interface Options {
precision?: number
subdivisions?: Curve[]
}
}
export namespace Curve {
function getFirstControlPoints(rhs: number[]) {
const n = rhs.length
const x = [] // `x` is a solution vector.
const tmp = []
let b = 2.0
x[0] = rhs[0] / b
// Decomposition and forward substitution.
for (let i = 1; i < n; i += 1) {
tmp[i] = 1 / b
b = (i < n - 1 ? 4.0 : 3.5) - tmp[i]
x[i] = (rhs[i] - x[i - 1]) / b
}
for (let i = 1; i < n; i += 1) {
// Backsubstitution.
x[n - i - 1] -= tmp[n - i] * x[n - i]
}
return x
}
function getCurveControlPoints(
points: (Point.PointLike | Point.PointData)[],
) {
const knots = points.map((p) => Point.clone(p))
const firstControlPoints = []
const secondControlPoints = []
const n = knots.length - 1
// Special case: Bezier curve should be a straight line.
if (n === 1) {
// 3P1 = 2P0 + P3
firstControlPoints[0] = new Point(
(2 * knots[0].x + knots[1].x) / 3,
(2 * knots[0].y + knots[1].y) / 3,
)
// P2 = 2P1 P0
secondControlPoints[0] = new Point(
2 * firstControlPoints[0].x - knots[0].x,
2 * firstControlPoints[0].y - knots[0].y,
)
return [firstControlPoints, secondControlPoints]
}
// Calculate first Bezier control points.
// Right hand side vector.
const rhs = []
// Set right hand side X values.
for (let i = 1; i < n - 1; i += 1) {
rhs[i] = 4 * knots[i].x + 2 * knots[i + 1].x
}
rhs[0] = knots[0].x + 2 * knots[1].x
rhs[n - 1] = (8 * knots[n - 1].x + knots[n].x) / 2.0
// Get first control points X-values.
const x = getFirstControlPoints(rhs)
// Set right hand side Y values.
for (let i = 1; i < n - 1; i += 1) {
rhs[i] = 4 * knots[i].y + 2 * knots[i + 1].y
}
rhs[0] = knots[0].y + 2 * knots[1].y
rhs[n - 1] = (8 * knots[n - 1].y + knots[n].y) / 2.0
// Get first control points Y-values.
const y = getFirstControlPoints(rhs)
// Fill output arrays.
for (let i = 0; i < n; i += 1) {
// First control point.
firstControlPoints.push(new Point(x[i], y[i]))
// Second control point.
if (i < n - 1) {
secondControlPoints.push(
new Point(
2 * knots[i + 1].x - x[i + 1],
2 * knots[i + 1].y - y[i + 1],
),
)
} else {
secondControlPoints.push(
new Point((knots[n].x + x[n - 1]) / 2, (knots[n].y + y[n - 1]) / 2),
)
}
}
return [firstControlPoints, secondControlPoints]
}
export function throughPoints(points: (Point.PointLike | Point.PointData)[]) {
if (points == null || (Array.isArray(points) && points.length < 2)) {
throw new Error('At least 2 points are required')
}
const controlPoints = getCurveControlPoints(points)
const curves = []
for (let i = 0, ii = controlPoints[0].length; i < ii; i += 1) {
const controlPoint1 = new Point(
controlPoints[0][i].x,
controlPoints[0][i].y,
)
const controlPoint2 = new Point(
controlPoints[1][i].x,
controlPoints[1][i].y,
)
curves.push(
new Curve(points[i], controlPoint1, controlPoint2, points[i + 1]),
)
}
return curves
}
}

View File

@ -0,0 +1,262 @@
import { Ellipse } from './ellipse'
import { Line } from './line'
import { Point } from './point'
import { Rectangle } from './rectangle'
describe('ellipse', () => {
describe('#constructor', () => {
it('should create an ellipse instance', () => {
expect(new Ellipse()).toBeInstanceOf(Ellipse)
expect(new Ellipse(1)).toBeInstanceOf(Ellipse)
expect(new Ellipse(1, 2)).toBeInstanceOf(Ellipse)
expect(new Ellipse(1, 2, 3)).toBeInstanceOf(Ellipse)
expect(new Ellipse(1, 2, 3, 4)).toBeInstanceOf(Ellipse)
expect(new Ellipse(1, 2, 3, 4).x).toEqual(1)
expect(new Ellipse(1, 2, 3, 4).y).toEqual(2)
expect(new Ellipse(1, 2, 3, 4).a).toEqual(3)
expect(new Ellipse(1, 2, 3, 4).b).toEqual(4)
expect(new Ellipse().equals(new Ellipse(0, 0, 0, 0)))
})
it('should return the center point', () => {
const ellipse = new Ellipse(1, 2, 3, 4)
expect(ellipse.getCenter().equals({ x: 1, y: 2 }))
expect(ellipse.center.equals({ x: 1, y: 2 }))
})
})
describe('#Ellipse.create', () => {
it('should create an ellipse from number', () => {
const ellipse = Ellipse.create(1, 2, 3, 4)
expect(ellipse.x).toEqual(1)
expect(ellipse.y).toEqual(2)
expect(ellipse.a).toEqual(3)
expect(ellipse.b).toEqual(4)
})
it('should create an ellipse from Ellipse instance', () => {
const ellipse = Ellipse.create(new Ellipse(1, 2, 3, 4))
expect(ellipse.x).toEqual(1)
expect(ellipse.y).toEqual(2)
expect(ellipse.a).toEqual(3)
expect(ellipse.b).toEqual(4)
})
it('should create an ellipse from EllipseLike', () => {
const ellipse = Ellipse.create({ x: 1, y: 2, a: 3, b: 4 })
expect(ellipse.x).toEqual(1)
expect(ellipse.y).toEqual(2)
expect(ellipse.a).toEqual(3)
expect(ellipse.b).toEqual(4)
})
it('should create an ellipse from EllipseData', () => {
const ellipse = Ellipse.create([1, 2, 3, 4])
expect(ellipse.x).toEqual(1)
expect(ellipse.y).toEqual(2)
expect(ellipse.a).toEqual(3)
expect(ellipse.b).toEqual(4)
})
})
describe('#Ellipse.fromRect', () => {
it('should create an ellipse from a rectangle instance', () => {
const ellipse = Ellipse.fromRect(new Rectangle(1, 2, 3, 4))
expect(ellipse.x).toEqual(2.5)
expect(ellipse.y).toEqual(4)
expect(ellipse.a).toEqual(1.5)
expect(ellipse.b).toEqual(2)
})
})
describe('#bbox', () => {
it('should return the bbox', () => {
expect(new Ellipse(1, 2, 3, 4).bbox().toJSON()).toEqual({
x: -2,
y: -2,
width: 6,
height: 8,
})
})
})
describe('#inflate', () => {
it('should inflate with the given `amount`', () => {
expect(new Ellipse(1, 2, 3, 4).inflate(2).toJSON()).toEqual({
x: 1,
y: 2,
a: 7,
b: 8,
})
})
it('should inflate with the given `dx` and `dy`', () => {
expect(new Ellipse(1, 2, 3, 4).inflate(2, 3).toJSON()).toEqual({
x: 1,
y: 2,
a: 7,
b: 10,
})
})
})
describe('#normalizedDistance', () => {
it('should return a normalized distance', () => {
const ellipse = new Ellipse(0, 0, 3, 4)
expect(ellipse.normalizedDistance(1, 1) < 1).toBeTruthy()
expect(ellipse.normalizedDistance({ x: 5, y: 5 }) > 1).toBeTruthy()
expect(ellipse.normalizedDistance([0, 4]) === 1).toBeTruthy()
})
})
describe('#containsPoint', () => {
const ellipse = new Ellipse(0, 0, 3, 4)
it('shoule return true when ellipse contains the given point', () => {
expect(ellipse.containsPoint(1, 1)).toBeTruthy()
})
it('shoule return true when the given point is on the boundary of the ellipse', () => {
expect(ellipse.containsPoint([0, 4])).toBeTruthy()
})
it('shoule return true when ellipse not contains the given point', () => {
expect(ellipse.containsPoint({ x: 5, y: 5 })).toBeFalsy()
})
})
describe('#intersectsWithLine', () => {
const ellipse = new Ellipse(0, 0, 3, 4)
it('should return the intersections with line', () => {
expect(
Point.equalPoints(ellipse.intersectsWithLine(new Line(0, -5, 0, 5))!, [
{ x: 0, y: -4 },
{ x: 0, y: 4 },
]),
).toBeTruthy()
expect(
Point.equalPoints(ellipse.intersectsWithLine(new Line(0, 0, 0, 5))!, [
{ x: 0, y: 4 },
]),
).toBeTruthy()
expect(
Point.equalPoints(ellipse.intersectsWithLine(new Line(3, 0, 3, 4))!, [
{ x: 3, y: 0 },
]),
).toBeTruthy()
})
it('should return null when not intersections', () => {
expect(ellipse.intersectsWithLine(new Line(0, 0, 1, 1))).toBeNull()
expect(ellipse.intersectsWithLine(new Line(3, 5, 3, 6))).toBeNull()
expect(ellipse.intersectsWithLine(new Line(-6, -6, -6, 100))).toBeNull()
expect(ellipse.intersectsWithLine(new Line(6, 6, 100, 100))).toBeNull()
})
})
describe('#intersectsWithLineFromCenterToPoint', () => {
const ellipse = new Ellipse(0, 0, 3, 4)
it('should return the intersection point when the given point is outside of the ellipse', () => {
expect(
ellipse
.intersectsWithLineFromCenterToPoint({ x: 0, y: 6 })
.round()
.toJSON(),
).toEqual({ x: 0, y: 4 })
expect(
ellipse
.intersectsWithLineFromCenterToPoint({ x: 6, y: 0 })
.round()
.toJSON(),
).toEqual({ x: 3, y: 0 })
expect(
ellipse
.intersectsWithLineFromCenterToPoint({ x: 0, y: 6 }, 90)
.round()
.toJSON(),
).toEqual({ x: 0, y: 3 })
})
it('should return the intersection point when the given point is inside of the ellipse', () => {
expect(
ellipse
.intersectsWithLineFromCenterToPoint({ x: 0, y: 0 }, 90)
.round()
.toJSON(),
).toEqual({ x: -0, y: -3 })
expect(
ellipse
.intersectsWithLineFromCenterToPoint({ x: 0, y: 2 })
.round()
.toJSON(),
).toEqual({ x: 0, y: 4 })
expect(
ellipse
.intersectsWithLineFromCenterToPoint({ x: 0, y: 2 }, 90)
.round()
.toJSON(),
).toEqual({ x: 0, y: 3 })
expect(
ellipse
.intersectsWithLineFromCenterToPoint({ x: 2, y: 0 }, 90)
.round()
.toJSON(),
).toEqual({ x: 4, y: -0 })
})
})
describe('#tangentTheta', () => {
it('should return the tangent theta', () => {
const ellipse = new Ellipse(0, 0, 3, 4)
expect(ellipse.tangentTheta({ x: 3, y: 0 })).toEqual(270)
expect(ellipse.tangentTheta({ x: -3, y: 0 })).toEqual(90)
expect(ellipse.tangentTheta({ x: 0, y: 4 })).toEqual(180)
expect(ellipse.tangentTheta({ x: 0, y: -4 })).toEqual(-0)
})
})
describe('#scale', () => {
it('should scale the ellipse with the given `sx` and `sy`', () => {
const ellipse = new Ellipse(1, 2, 3, 4).scale(3, 4)
expect(ellipse.x).toEqual(1)
expect(ellipse.y).toEqual(2)
expect(ellipse.a).toEqual(9)
expect(ellipse.b).toEqual(16)
})
})
describe('#translate', () => {
it('should translate the ellipse with the given `dx` and `dy`', () => {
const ellipse = new Ellipse(1, 2, 3, 4).translate(3, 4)
expect(ellipse.x).toEqual(4)
expect(ellipse.y).toEqual(6)
expect(ellipse.a).toEqual(3)
expect(ellipse.b).toEqual(4)
})
})
describe('#clone', () => {
it('should return the cloned ellipse', () => {
const ellipse = new Ellipse(1, 2, 3, 4).clone()
expect(ellipse.x).toEqual(1)
expect(ellipse.y).toEqual(2)
expect(ellipse.a).toEqual(3)
expect(ellipse.b).toEqual(4)
})
})
describe('#serialize', () => {
it('should return the serialized string', () => {
expect(new Ellipse(1, 2, 3, 4).serialize()).toEqual('1 2 3 4')
})
})
})

View File

@ -0,0 +1,350 @@
import { Line } from './line'
import { Point } from './point'
import { Rectangle } from './rectangle'
import { Geometry } from './geometry'
export class Ellipse extends Geometry implements Ellipse.EllipseLike {
public x: number
public y: number
public a: number
public b: number
protected get [Symbol.toStringTag]() {
return Ellipse.toStringTag
}
get center() {
return new Point(this.x, this.y)
}
constructor(x?: number, y?: number, a?: number, b?: number) {
super()
this.x = x == null ? 0 : x
this.y = y == null ? 0 : y
this.a = a == null ? 0 : a
this.b = b == null ? 0 : b
}
/**
* Returns a rectangle that is the bounding box of the ellipse.
*/
bbox() {
return Rectangle.fromEllipse(this)
}
/**
* Returns a point that is the center of the ellipse.
*/
getCenter() {
return this.center
}
/**
* Returns ellipse inflated in axis-x by `2 * amount` and in axis-y by
* `2 * amount`.
*/
inflate(amount: number): this
/**
* Returns ellipse inflated in axis-x by `2 * dx` and in axis-y by `2 * dy`.
*/
inflate(dx: number, dy: number): this
inflate(dx: number, dy?: number): this {
const w = dx
const h = dy != null ? dy : dx
this.a += 2 * w
this.b += 2 * h
return this
}
/**
* Returns a normalized distance from the ellipse center to point `p`.
* Returns `n < 1` for points inside the ellipse, `n = 1` for points
* lying on the ellipse boundary and `n > 1` for points outside the ellipse.
*/
normalizedDistance(x: number, y: number): number
normalizedDistance(p: Point.PointLike | Point.PointData): number
normalizedDistance(
x: number | Point.PointLike | Point.PointData,
y?: number,
) {
const ref = Point.create(x, y)
const dx = ref.x - this.x
const dy = ref.y - this.y
const a = this.a
const b = this.b
return (dx * dx) / (a * a) + (dy * dy) / (b * b)
}
/**
* Returns `true` if the point `p` is inside the ellipse (inclusive).
* Returns `false` otherwise.
*/
containsPoint(x: number, y: number): boolean
containsPoint(p: Point.PointLike | Point.PointData): boolean
containsPoint(x: number | Point.PointLike | Point.PointData, y?: number) {
return this.normalizedDistance(x as number, y as number) <= 1
}
/**
* Returns an array of the intersection points of the ellipse and the line.
* Returns `null` if no intersection exists.
*/
intersectsWithLine(line: Line) {
const intersections = []
const rx = this.a
const ry = this.b
const a1 = line.start
const a2 = line.end
const dir = line.vector()
const diff = a1.diff(new Point(this.x, this.y))
const mDir = new Point(dir.x / (rx * rx), dir.y / (ry * ry))
const mDiff = new Point(diff.x / (rx * rx), diff.y / (ry * ry))
const a = dir.dot(mDir)
const b = dir.dot(mDiff)
const c = diff.dot(mDiff) - 1.0
const d = b * b - a * c
if (d < 0) {
return null
}
if (d > 0) {
const root = Math.sqrt(d)
const ta = (-b - root) / a
const tb = (-b + root) / a
if ((ta < 0 || ta > 1) && (tb < 0 || tb > 1)) {
// outside
return null
}
if (ta >= 0 && ta <= 1) {
intersections.push(a1.lerp(a2, ta))
}
if (tb >= 0 && tb <= 1) {
intersections.push(a1.lerp(a2, tb))
}
} else {
const t = -b / a
if (t >= 0 && t <= 1) {
intersections.push(a1.lerp(a2, t))
} else {
// outside
return null
}
}
return intersections
}
/**
* Returns the point on the boundary of the ellipse that is the
* intersection of the ellipse with a line starting in the center
* of the ellipse ending in the point `p`.
*
* If angle is specified, the intersection will take into account
* the rotation of the ellipse by angle degrees around its center.
*/
intersectsWithLineFromCenterToPoint(
p: Point.PointLike | Point.PointData,
angle = 0,
) {
const ref = Point.clone(p)
if (angle) {
ref.rotate(angle, this.getCenter())
}
const dx = ref.x - this.x
const dy = ref.y - this.y
let result
if (dx === 0) {
result = this.bbox().getNearestPointToPoint(ref)
if (angle) {
return result.rotate(-angle, this.getCenter())
}
return result
}
const m = dy / dx
const mSquared = m * m
const aSquared = this.a * this.a
const bSquared = this.b * this.b
let x = Math.sqrt(1 / (1 / aSquared + mSquared / bSquared))
x = dx < 0 ? -x : x
const y = m * x
result = new Point(this.x + x, this.y + y)
if (angle) {
return result.rotate(-angle, this.getCenter())
}
return result
}
/**
* Returns the angle between the x-axis and the tangent from a point. It is
* valid for points lying on the ellipse boundary only.
*/
tangentTheta(p: Point.PointLike | Point.PointData) {
const ref = Point.clone(p)
const x0 = ref.x
const y0 = ref.y
const a = this.a
const b = this.b
const center = this.bbox().center
const cx = center.x
const cy = center.y
const refPointDelta = 30
const q1 = x0 > center.x + a / 2
const q3 = x0 < center.x - a / 2
let x
let y
if (q1 || q3) {
y = x0 > center.x ? y0 - refPointDelta : y0 + refPointDelta
x =
(a * a) / (x0 - cx) -
(a * a * (y0 - cy) * (y - cy)) / (b * b * (x0 - cx)) +
cx
} else {
x = y0 > center.y ? x0 + refPointDelta : x0 - refPointDelta
y =
(b * b) / (y0 - cy) -
(b * b * (x0 - cx) * (x - cx)) / (a * a * (y0 - cy)) +
cy
}
return new Point(x, y).theta(ref)
}
scale(sx: number, sy: number) {
this.a *= sx
this.b *= sy
return this
}
rotate(angle: number, origin?: Point.PointLike | Point.PointData) {
const rect = Rectangle.fromEllipse(this)
rect.rotate(angle, origin)
const ellipse = Ellipse.fromRect(rect)
this.a = ellipse.a
this.b = ellipse.b
this.x = ellipse.x
this.y = ellipse.y
return this
}
translate(dx: number, dy: number): this
translate(p: Point.PointLike | Point.PointData): this
translate(dx: number | Point.PointLike | Point.PointData, dy?: number): this {
const p = Point.create(dx, dy)
this.x += p.x
this.y += p.y
return this
}
equals(ellipse: Ellipse) {
return (
ellipse != null &&
ellipse.x === this.x &&
ellipse.y === this.y &&
ellipse.a === this.a &&
ellipse.b === this.b
)
}
clone() {
return new Ellipse(this.x, this.y, this.a, this.b)
}
toJSON() {
return { x: this.x, y: this.y, a: this.a, b: this.b }
}
serialize() {
return `${this.x} ${this.y} ${this.a} ${this.b}`
}
}
export namespace Ellipse {
export const toStringTag = `X6.Geometry.${Ellipse.name}`
export function isEllipse(instance: any): instance is Ellipse {
if (instance == null) {
return false
}
if (instance instanceof Ellipse) {
return true
}
const tag = instance[Symbol.toStringTag]
const ellipse = instance as Ellipse
if (
(tag == null || tag === toStringTag) &&
typeof ellipse.x === 'number' &&
typeof ellipse.y === 'number' &&
typeof ellipse.a === 'number' &&
typeof ellipse.b === 'number' &&
typeof ellipse.inflate === 'function' &&
typeof ellipse.normalizedDistance === 'function'
) {
return true
}
return false
}
}
export namespace Ellipse {
export interface EllipseLike extends Point.PointLike {
x: number
y: number
a: number
b: number
}
export type EllipseData = [number, number, number, number]
}
export namespace Ellipse {
export function create(
x?: number | Ellipse | EllipseLike | EllipseData,
y?: number,
a?: number,
b?: number,
): Ellipse {
if (x == null || typeof x === 'number') {
return new Ellipse(x, y, a, b)
}
return parse(x)
}
export function parse(e: Ellipse | EllipseLike | EllipseData) {
if (Ellipse.isEllipse(e)) {
return e.clone()
}
if (Array.isArray(e)) {
return new Ellipse(e[0], e[1], e[2], e[3])
}
return new Ellipse(e.x, e.y, e.a, e.b)
}
export function fromRect(rect: Rectangle) {
const center = rect.center
return new Ellipse(center.x, center.y, rect.width / 2, rect.height / 2)
}
}

View File

@ -0,0 +1,35 @@
import { Point } from './point'
import { JSONObject, JSONArray } from './types'
export abstract class Geometry {
abstract scale(
sx: number,
sy: number,
origin?: Point.PointLike | Point.PointData,
): this
abstract rotate(
angle: number,
origin?: Point.PointLike | Point.PointData,
): this
abstract translate(tx: number, ty: number): this
abstract translate(p: Point.PointLike | Point.PointData): this
abstract equals(g: any): boolean
abstract clone(): Geometry
abstract toJSON(): JSONObject | JSONArray
abstract serialize(): string
valueOf() {
return this.toJSON()
}
toString() {
return JSON.stringify(this.toJSON())
}
}

View File

@ -0,0 +1,9 @@
export * from './version'
export * from './angle'
export * from './point'
export * from './line'
export * from './ellipse'
export * from './rectangle'
export * from './path'
export * from './curve'
export * from './polyline'

View File

@ -0,0 +1,548 @@
import { Path } from './path'
import { Point } from './point'
import { Ellipse } from './ellipse'
import { Geometry } from './geometry'
import { Polyline } from './polyline'
import { Rectangle } from './rectangle'
export class Line extends Geometry {
public start: Point
public end: Point
protected get [Symbol.toStringTag]() {
return Line.toStringTag
}
get center() {
return new Point(
(this.start.x + this.end.x) / 2,
(this.start.y + this.end.y) / 2,
)
}
constructor(x1: number, y1: number, x2: number, y2: number)
constructor(
p1: Point.PointLike | Point.PointData,
p2: Point.PointLike | Point.PointData,
)
constructor(
x1: number | Point.PointLike | Point.PointData,
y1: number | Point.PointLike | Point.PointData,
x2?: number,
y2?: number,
) {
super()
if (typeof x1 === 'number' && typeof y1 === 'number') {
this.start = new Point(x1, y1)
this.end = new Point(x2, y2)
} else {
this.start = Point.create(x1)
this.end = Point.create(y1)
}
}
getCenter() {
return this.center
}
/**
* Rounds the line to the given `precision`.
*/
round(precision = 0) {
this.start.round(precision)
this.end.round(precision)
return this
}
translate(tx: number, ty: number): this
translate(p: Point.PointLike | Point.PointData): this
translate(tx: number | Point.PointLike | Point.PointData, ty?: number) {
if (typeof tx === 'number') {
this.start.translate(tx, ty as number)
this.end.translate(tx, ty as number)
} else {
this.start.translate(tx)
this.end.translate(tx)
}
return this
}
/**
* Rotate the line by `angle` around `origin`.
*/
rotate(angle: number, origin?: Point.PointLike | Point.PointData) {
this.start.rotate(angle, origin)
this.end.rotate(angle, origin)
return this
}
/**
* Scale the line by `sx` and `sy` about the given `origin`. If origin is not
* specified, the line is scaled around `0,0`.
*/
scale(sx: number, sy: number, origin?: Point.PointLike | Point.PointData) {
this.start.scale(sx, sy, origin)
this.end.scale(sx, sy, origin)
return this
}
/**
* Returns the length of the line.
*/
length() {
return Math.sqrt(this.squaredLength())
}
/**
* Useful for distance comparisons in which real length is not necessary
* (saves one `Math.sqrt()` operation).
*/
squaredLength() {
const dx = this.start.x - this.end.x
const dy = this.start.y - this.end.y
return dx * dx + dy * dy
}
/**
* Scale the line so that it has the requested length. The start point of
* the line is preserved.
*/
setLength(length: number) {
const total = this.length()
if (!total) {
return this
}
const scale = length / total
return this.scale(scale, scale, this.start)
}
parallel(distance: number) {
const line = this.clone()
if (!line.isDifferentiable()) {
return line
}
const { start, end } = line
const eRef = start.clone().rotate(270, end)
const sRef = end.clone().rotate(90, start)
start.move(sRef, distance)
end.move(eRef, distance)
return line
}
/**
* Returns the vector of the line with length equal to length of the line.
*/
vector() {
return new Point(this.end.x - this.start.x, this.end.y - this.start.y)
}
/**
* Returns the angle of incline of the line.
*
* The function returns `NaN` if the start and end endpoints of the line
* both lie at the same coordinates(it is impossible to determine the angle
* of incline of a line that appears to be a point). The
* `line.isDifferentiable()` function may be used in advance to determine
* whether the angle of incline can be computed for a given line.
*/
angle() {
const horizontal = new Point(this.start.x + 1, this.start.y)
return this.start.angleBetween(this.end, horizontal)
}
/**
* Returns a rectangle that is the bounding box of the line.
*/
bbox() {
const left = Math.min(this.start.x, this.end.x)
const top = Math.min(this.start.y, this.end.y)
const right = Math.max(this.start.x, this.end.x)
const bottom = Math.max(this.start.y, this.end.y)
return new Rectangle(left, top, right - left, bottom - top)
}
/**
* Returns the bearing (cardinal direction) of the line.
*
* The return value is one of the following strings:
* 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW' and 'N'.
*
* The function returns 'N' if the two endpoints of the line are coincident.
*/
bearing() {
return this.start.bearing(this.end)
}
/**
* Returns the point on the line that lies closest to point `p`.
*/
closestPoint(p: Point.PointLike | Point.PointData) {
return this.pointAt(this.closestPointNormalizedLength(p))
}
/**
* Returns the length of the line up to the point that lies closest to point `p`.
*/
closestPointLength(p: Point.PointLike | Point.PointData) {
return this.closestPointNormalizedLength(p) * this.length()
}
/**
* Returns a line that is tangent to the line at the point that lies closest
* to point `p`.
*/
closestPointTangent(p: Point.PointLike | Point.PointData) {
return this.tangentAt(this.closestPointNormalizedLength(p))
}
/**
* Returns the normalized length (distance from the start of the line / total
* line length) of the line up to the point that lies closest to point.
*/
closestPointNormalizedLength(p: Point.PointLike | Point.PointData) {
const product = this.vector().dot(new Line(this.start, p).vector())
const normalized = Math.min(1, Math.max(0, product / this.squaredLength()))
// normalized returns `NaN` if this line has zero length
if (Number.isNaN(normalized)) {
return 0
}
return normalized
}
/**
* Returns a point on the line that lies `rate` (normalized length) away from
* the beginning of the line.
*/
pointAt(ratio: number) {
const start = this.start
const end = this.end
if (ratio <= 0) {
return start.clone()
}
if (ratio >= 1) {
return end.clone()
}
return start.lerp(end, ratio)
}
/**
* Returns a point on the line that lies length away from the beginning of
* the line.
*/
pointAtLength(length: number) {
const start = this.start
const end = this.end
let fromStart = true
if (length < 0) {
fromStart = false // start calculation from end point
length = -length // eslint-disable-line
}
const total = this.length()
if (length >= total) {
return fromStart ? end.clone() : start.clone()
}
const rate = (fromStart ? length : total - length) / total
return this.pointAt(rate)
}
/**
* Divides the line into two lines at the point that lies `rate` (normalized
* length) away from the beginning of the line.
*/
divideAt(ratio: number) {
const dividerPoint = this.pointAt(ratio)
return [
new Line(this.start, dividerPoint),
new Line(dividerPoint, this.end),
]
}
/**
* Divides the line into two lines at the point that lies length away from
* the beginning of the line.
*/
divideAtLength(length: number) {
const dividerPoint = this.pointAtLength(length)
return [
new Line(this.start, dividerPoint),
new Line(dividerPoint, this.end),
]
}
/**
* Returns `true` if the point `p` lies on the line. Return `false` otherwise.
*/
containsPoint(p: Point.PointLike | Point.PointData) {
const start = this.start
const end = this.end
// cross product of 0 indicates that this line and
// the vector to `p` are collinear.
if (start.cross(p, end) !== 0) {
return false
}
const length = this.length()
if (new Line(start, p).length() > length) {
return false
}
if (new Line(p, end).length() > length) {
return false
}
return true
}
/**
* Returns an array of the intersection points of the line with another
* geometry shape.
*/
intersect(shape: Line | Rectangle | Polyline | Ellipse): Point[] | null
intersect(shape: Path, options?: Path.Options): Point[] | null
intersect(
shape: Line | Rectangle | Polyline | Ellipse | Path,
options?: Path.Options,
): Point[] | null {
const ret = shape.intersectsWithLine(this, options)
if (ret) {
return Array.isArray(ret) ? ret : [ret]
}
return null
}
/**
* Returns the intersection point of the line with another line. Returns
* `null` if no intersection exists.
*/
intersectsWithLine(line: Line) {
const pt1Dir = new Point(
this.end.x - this.start.x,
this.end.y - this.start.y,
)
const pt2Dir = new Point(
line.end.x - line.start.x,
line.end.y - line.start.y,
)
const det = pt1Dir.x * pt2Dir.y - pt1Dir.y * pt2Dir.x
const deltaPt = new Point(
line.start.x - this.start.x,
line.start.y - this.start.y,
)
const alpha = deltaPt.x * pt2Dir.y - deltaPt.y * pt2Dir.x
const beta = deltaPt.x * pt1Dir.y - deltaPt.y * pt1Dir.x
if (det === 0 || alpha * det < 0 || beta * det < 0) {
return null
}
if (det > 0) {
if (alpha > det || beta > det) {
return null
}
} else if (alpha < det || beta < det) {
return null
}
return new Point(
this.start.x + (alpha * pt1Dir.x) / det,
this.start.y + (alpha * pt1Dir.y) / det,
)
}
/**
* Returns `true` if a tangent line can be found for the line.
*
* Tangents cannot be found if both of the line endpoints are coincident
* (the line appears to be a point).
*/
isDifferentiable() {
return !this.start.equals(this.end)
}
/**
* Returns the perpendicular distance between the line and point. The
* distance is positive if the point lies to the right of the line, negative
* if the point lies to the left of the line, and `0` if the point lies on
* the line.
*/
pointOffset(p: Point.PointLike | Point.PointData) {
const ref = Point.clone(p)
const start = this.start
const end = this.end
const determinant =
(end.x - start.x) * (ref.y - start.y) -
(end.y - start.y) * (ref.x - start.x)
return determinant / this.length()
}
/**
* Returns the squared distance between the line and the point.
*/
pointSquaredDistance(x: number, y: number): number
pointSquaredDistance(p: Point.PointLike | Point.PointData): number
pointSquaredDistance(
x: number | Point.PointLike | Point.PointData,
y?: number,
) {
const p = Point.create(x, y)
return this.closestPoint(p).squaredDistance(p)
}
/**
* Returns the distance between the line and the point.
*/
pointDistance(x: number, y: number): number
pointDistance(p: Point.PointLike | Point.PointData): number
pointDistance(x: number | Point.PointLike | Point.PointData, y?: number) {
const p = Point.create(x, y)
return this.closestPoint(p).distance(p)
}
/**
* Returns a line tangent to the line at point that lies `rate` (normalized
* length) away from the beginning of the line.
*/
tangentAt(ratio: number) {
if (!this.isDifferentiable()) {
return null
}
const start = this.start
const end = this.end
const tangentStart = this.pointAt(ratio)
const tangentLine = new Line(start, end)
tangentLine.translate(tangentStart.x - start.x, tangentStart.y - start.y)
return tangentLine
}
/**
* Returns a line tangent to the line at point that lies `length` away from
* the beginning of the line.
*/
tangentAtLength(length: number) {
if (!this.isDifferentiable()) {
return null
}
const start = this.start
const end = this.end
const tangentStart = this.pointAtLength(length)
const tangentLine = new Line(start, end)
tangentLine.translate(tangentStart.x - start.x, tangentStart.y - start.y)
return tangentLine
}
/**
* Returns which direction the line would have to rotate in order to direct
* itself at a point.
*
* Returns 1 if the given point on the right side of the segment, 0 if its
* on the segment, and -1 if the point is on the left side of the segment.
*
* @see https://softwareengineering.stackexchange.com/questions/165776/what-do-ptlinedist-and-relativeccw-do
*/
relativeCcw(x: number, y: number): -1 | 0 | 1
relativeCcw(p: Point.PointLike | Point.PointData): -1 | 0 | 1
relativeCcw(x: number | Point.PointLike | Point.PointData, y?: number) {
const ref = Point.create(x, y)
let dx1 = ref.x - this.start.x
let dy1 = ref.y - this.start.y
const dx2 = this.end.x - this.start.x
const dy2 = this.end.y - this.start.y
let ccw = dx1 * dy2 - dy1 * dx2
if (ccw === 0) {
ccw = dx1 * dx2 + dy1 * dy2
if (ccw > 0.0) {
dx1 -= dx2
dy1 -= dy2
ccw = dx1 * dx2 + dy1 * dy2
if (ccw < 0.0) {
ccw = 0.0
}
}
}
return ccw < 0.0 ? -1 : ccw > 0.0 ? 1 : 0
}
/**
* Return `true` if the line equals the other line.
*/
equals(l: Line) {
return (
l != null &&
this.start.x === l.start.x &&
this.start.y === l.start.y &&
this.end.x === l.end.x &&
this.end.y === l.end.y
)
}
/**
* Returns another line which is a clone of the line.
*/
clone() {
return new Line(this.start, this.end)
}
toJSON() {
return { start: this.start.toJSON(), end: this.end.toJSON() }
}
serialize() {
return [this.start.serialize(), this.end.serialize()].join(' ')
}
}
export namespace Line {
export const toStringTag = `X6.Geometry.${Line.name}`
export function isLine(instance: any): instance is Line {
if (instance == null) {
return false
}
if (instance instanceof Line) {
return true
}
const tag = instance[Symbol.toStringTag]
const line = instance as Line
if (
(tag == null || tag === toStringTag) &&
Point.isPoint(line.start) &&
Point.isPoint(line.end) &&
typeof line.vector === 'function' &&
typeof line.bearing === 'function' &&
typeof line.parallel === 'function' &&
typeof line.intersect === 'function'
) {
return true
}
return false
}
}

View File

@ -0,0 +1,137 @@
import { Line } from '../line'
import { Point } from '../point'
import { LineTo } from './lineto'
import { Segment } from './segment'
export class Close extends Segment {
get end() {
if (!this.subpathStartSegment) {
throw new Error(
'Missing subpath start segment. (This segment needs a subpath ' +
'start segment (e.g. MoveTo), or segment has not yet been added' +
' to a path.)',
)
}
return this.subpathStartSegment.end
}
get type() {
return 'Z'
}
get line() {
return new Line(this.start, this.end)
}
bbox() {
return this.line.bbox()
}
closestPoint(p: Point.PointLike | Point.PointData) {
return this.line.closestPoint(p)
}
closestPointLength(p: Point.PointLike | Point.PointData) {
return this.line.closestPointLength(p)
}
closestPointNormalizedLength(p: Point.PointLike | Point.PointData) {
return this.line.closestPointNormalizedLength(p)
}
closestPointTangent(p: Point.PointLike | Point.PointData) {
return this.line.closestPointTangent(p)
}
length() {
return this.line.length()
}
divideAt(ratio: number): [Segment, Segment] {
const divided = this.line.divideAt(ratio)
return [
// do not actually cut into the segment, first divided part can stay as Z
divided[1].isDifferentiable() ? new LineTo(divided[0]) : this.clone(),
new LineTo(divided[1]),
]
}
divideAtLength(length: number): [Segment, Segment] {
const divided = this.line.divideAtLength(length)
return [
divided[1].isDifferentiable() ? new LineTo(divided[0]) : this.clone(),
new LineTo(divided[1]),
]
}
getSubdivisions() {
return []
}
pointAt(ratio: number) {
return this.line.pointAt(ratio)
}
pointAtLength(length: number) {
return this.line.pointAtLength(length)
}
tangentAt(ratio: number) {
return this.line.tangentAt(ratio)
}
tangentAtLength(length: number) {
return this.line.tangentAtLength(length)
}
isDifferentiable() {
if (!this.previousSegment || !this.subpathStartSegment) {
return false
}
return !this.start.equals(this.end)
}
scale() {
return this
}
rotate() {
return this
}
translate() {
return this
}
equals(s: Segment) {
return (
this.type === s.type &&
this.start.equals(s.start) &&
this.end.equals(s.end)
)
}
clone() {
return new Close()
}
toJSON() {
return {
type: this.type,
start: this.start.toJSON(),
end: this.end.toJSON(),
}
}
serialize() {
return this.type
}
}
export namespace Close {
export function create(): Close {
return new Close()
}
}

View File

@ -0,0 +1,276 @@
import { Curve } from '../curve'
import { Point } from '../point'
import { Segment } from './segment'
export class CurveTo extends Segment {
controlPoint1: Point
controlPoint2: Point
constructor(curve: Curve)
constructor(
x1: number,
y1: number,
x2: number,
y2: number,
x: number,
y: number,
)
constructor(
p1: Point.PointLike | Point.PointData,
p2: Point.PointLike | Point.PointData,
p3: Point.PointLike | Point.PointData,
)
constructor(
arg0: number | Curve | (Point.PointLike | Point.PointData),
arg1?: number | (Point.PointLike | Point.PointData),
arg2?: number | (Point.PointLike | Point.PointData),
arg3?: number,
arg4?: number,
arg5?: number,
) {
super()
if (Curve.isCurve(arg0)) {
this.controlPoint1 = arg0.controlPoint1.clone().round(2)
this.controlPoint2 = arg0.controlPoint2.clone().round(2)
this.endPoint = arg0.end.clone().round(2)
} else if (typeof arg0 === 'number') {
this.controlPoint1 = new Point(arg0, arg1 as number).round(2)
this.controlPoint2 = new Point(arg2 as number, arg3).round(2)
this.endPoint = new Point(arg4, arg5).round(2)
} else {
this.controlPoint1 = Point.create(arg0).round(2)
this.controlPoint2 = Point.create(arg1).round(2)
this.endPoint = Point.create(arg2).round(2)
}
}
get type() {
return 'C'
}
get curve() {
return new Curve(
this.start,
this.controlPoint1,
this.controlPoint2,
this.end,
)
}
bbox() {
return this.curve.bbox()
}
closestPoint(p: Point.PointLike | Point.PointData) {
return this.curve.closestPoint(p)
}
closestPointLength(p: Point.PointLike | Point.PointData) {
return this.curve.closestPointLength(p)
}
closestPointNormalizedLength(p: Point.PointLike | Point.PointData) {
return this.curve.closestPointNormalizedLength(p)
}
closestPointTangent(p: Point.PointLike | Point.PointData) {
return this.curve.closestPointTangent(p)
}
length() {
return this.curve.length()
}
divideAt(ratio: number, options: Segment.Options = {}): [Segment, Segment] {
// TODO: fix options
const divided = this.curve.divideAt(ratio, options as any)
return [new CurveTo(divided[0]), new CurveTo(divided[1])]
}
divideAtLength(
length: number,
options: Segment.Options = {},
): [Segment, Segment] {
// TODO: fix options
const divided = this.curve.divideAtLength(length, options as any)
return [new CurveTo(divided[0]), new CurveTo(divided[1])]
}
divideAtT(t: number): [Segment, Segment] {
const divided = this.curve.divideAtT(t)
return [new CurveTo(divided[0]), new CurveTo(divided[1])]
}
getSubdivisions() {
return []
}
pointAt(ratio: number) {
return this.curve.pointAt(ratio)
}
pointAtLength(length: number) {
return this.curve.pointAtLength(length)
}
tangentAt(ratio: number) {
return this.curve.tangentAt(ratio)
}
tangentAtLength(length: number) {
return this.curve.tangentAtLength(length)
}
isDifferentiable() {
if (!this.previousSegment) {
return false
}
const start = this.start
const control1 = this.controlPoint1
const control2 = this.controlPoint2
const end = this.end
return !(
start.equals(control1) &&
control1.equals(control2) &&
control2.equals(end)
)
}
scale(sx: number, sy: number, origin?: Point.PointLike | Point.PointData) {
this.controlPoint1.scale(sx, sy, origin)
this.controlPoint2.scale(sx, sy, origin)
this.end.scale(sx, sy, origin)
return this
}
rotate(angle: number, origin?: Point.PointLike | Point.PointData) {
this.controlPoint1.rotate(angle, origin)
this.controlPoint2.rotate(angle, origin)
this.end.rotate(angle, origin)
return this
}
translate(tx: number, ty: number): this
translate(p: Point.PointLike | Point.PointData): this
translate(tx: number | Point.PointLike | Point.PointData, ty?: number): this {
if (typeof tx === 'number') {
this.controlPoint1.translate(tx, ty as number)
this.controlPoint2.translate(tx, ty as number)
this.end.translate(tx, ty as number)
} else {
this.controlPoint1.translate(tx)
this.controlPoint2.translate(tx)
this.end.translate(tx)
}
return this
}
equals(s: Segment) {
return (
this.start.equals(s.start) &&
this.end.equals(s.end) &&
this.controlPoint1.equals((s as CurveTo).controlPoint1) &&
this.controlPoint2.equals((s as CurveTo).controlPoint2)
)
}
clone() {
return new CurveTo(this.controlPoint1, this.controlPoint2, this.end)
}
toJSON() {
return {
type: this.type,
start: this.start.toJSON(),
controlPoint1: this.controlPoint1.toJSON(),
controlPoint2: this.controlPoint2.toJSON(),
end: this.end.toJSON(),
}
}
serialize() {
const c1 = this.controlPoint1
const c2 = this.controlPoint2
const end = this.end
return [this.type, c1.x, c1.y, c2.x, c2.y, end.x, end.y].join(' ')
}
}
export namespace CurveTo {
export function create(
x1: number,
y1: number,
x2: number,
y2: number,
x: number,
y: number,
): CurveTo
export function create(
x1: number,
y1: number,
x2: number,
y2: number,
x: number,
y: number,
...coords: number[]
): CurveTo[]
export function create(
c1: Point.PointLike,
c2: Point.PointLike,
p: Point.PointLike,
): CurveTo
export function create(
c1: Point.PointLike,
c2: Point.PointLike,
p: Point.PointLike,
...points: Point.PointLike[]
): CurveTo[]
export function create(...args: any[]): CurveTo | CurveTo[] {
const len = args.length
const arg0 = args[0]
// curve provided
if (Curve.isCurve(arg0)) {
return new CurveTo(arg0)
}
// points provided
if (Point.isPointLike(arg0)) {
if (len === 3) {
return new CurveTo(args[0], args[1], args[2])
}
// this is a poly-bezier segment
const segments: CurveTo[] = []
for (let i = 0; i < len; i += 3) {
segments.push(new CurveTo(args[i], args[i + 1], args[i + 2]))
}
return segments
}
// coordinates provided
if (len === 6) {
return new CurveTo(args[0], args[1], args[2], args[3], args[4], args[5])
}
// this is a poly-bezier segment
const segments: CurveTo[] = []
for (let i = 0; i < len; i += 6) {
segments.push(
new CurveTo(
args[i],
args[i + 1],
args[i + 2],
args[i + 3],
args[i + 4],
args[i + 5],
),
)
}
return segments
}
}

View File

@ -0,0 +1,2 @@
export * from './path'
export * from './segment'

View File

@ -0,0 +1,181 @@
import { Line } from '../line'
import { Point } from '../point'
import { Segment } from './segment'
export class LineTo extends Segment {
constructor(line: Line)
constructor(x: number, y: number)
constructor(p: Point.PointLike | Point.PointData)
constructor(
x: number | Line | (Point.PointLike | Point.PointData),
y?: number,
) {
super()
if (Line.isLine(x)) {
this.endPoint = x.end.clone().round(2)
} else {
this.endPoint = Point.create(x, y).round(2)
}
}
get type() {
return 'L'
}
get line() {
return new Line(this.start, this.end)
}
bbox() {
return this.line.bbox()
}
closestPoint(p: Point.PointLike | Point.PointData) {
return this.line.closestPoint(p)
}
closestPointLength(p: Point.PointLike | Point.PointData) {
return this.line.closestPointLength(p)
}
closestPointNormalizedLength(p: Point.PointLike | Point.PointData) {
return this.line.closestPointNormalizedLength(p)
}
closestPointTangent(p: Point.PointLike | Point.PointData) {
return this.line.closestPointTangent(p)
}
length() {
return this.line.length()
}
divideAt(ratio: number): [Segment, Segment] {
const divided = this.line.divideAt(ratio)
return [new LineTo(divided[0]), new LineTo(divided[1])]
}
divideAtLength(length: number): [Segment, Segment] {
const divided = this.line.divideAtLength(length)
return [new LineTo(divided[0]), new LineTo(divided[1])]
}
getSubdivisions() {
return []
}
pointAt(ratio: number) {
return this.line.pointAt(ratio)
}
pointAtLength(length: number) {
return this.line.pointAtLength(length)
}
tangentAt(ratio: number) {
return this.line.tangentAt(ratio)
}
tangentAtLength(length: number) {
return this.line.tangentAtLength(length)
}
isDifferentiable() {
if (this.previousSegment == null) {
return false
}
return !this.start.equals(this.end)
}
clone() {
return new LineTo(this.end)
}
scale(sx: number, sy: number, origin?: Point.PointLike | Point.PointData) {
this.end.scale(sx, sy, origin)
return this
}
rotate(angle: number, origin?: Point.PointLike | Point.PointData) {
this.end.rotate(angle, origin)
return this
}
translate(tx: number, ty: number): this
translate(p: Point.PointLike | Point.PointData): this
translate(tx: number | Point.PointLike | Point.PointData, ty?: number): this {
if (typeof tx === 'number') {
this.end.translate(tx, ty as number)
} else {
this.end.translate(tx)
}
return this
}
equals(s: Segment) {
return (
this.type === s.type &&
this.start.equals(s.start) &&
this.end.equals(s.end)
)
}
toJSON() {
return {
type: this.type,
start: this.start.toJSON(),
end: this.end.toJSON(),
}
}
serialize() {
const end = this.end
return `${this.type} ${end.x} ${end.y}`
}
}
export namespace LineTo {
export function create(line: Line): LineTo
export function create(point: Point.PointLike): LineTo
export function create(x: number, y: number): LineTo
export function create(
point: Point.PointLike,
...points: Point.PointLike[]
): LineTo[]
export function create(x: number, y: number, ...coords: number[]): LineTo[]
export function create(...args: any[]): LineTo | LineTo[] {
const len = args.length
const arg0 = args[0]
// line provided
if (Line.isLine(arg0)) {
return new LineTo(arg0)
}
// points provided
if (Point.isPointLike(arg0)) {
if (len === 1) {
return new LineTo(arg0)
}
// poly-line segment
return args.map((arg) => new LineTo(arg as Point.PointLike))
}
// coordinates provided
if (len === 2) {
return new LineTo(+args[0], +args[1])
}
// poly-line segment
const segments: LineTo[] = []
for (let i = 0; i < len; i += 2) {
const x = +args[i]
const y = +args[i + 1]
segments.push(new LineTo(x, y))
}
return segments
}
}

View File

@ -0,0 +1,213 @@
import { Line } from '../line'
import { Curve } from '../curve'
import { Point } from '../point'
import { LineTo } from './lineto'
import { Segment } from './segment'
export class MoveTo extends Segment {
constructor(line: Line)
constructor(curve: Curve)
constructor(x: number, y: number)
constructor(p: Point.PointLike | Point.PointData)
constructor(
x: number | Curve | Line | (Point.PointLike | Point.PointData),
y?: number,
) {
super()
this.isVisible = false
this.isSubpathStart = true
if (Line.isLine(x) || Curve.isCurve(x)) {
this.endPoint = x.end.clone().round(2)
} else {
this.endPoint = Point.create(x, y).round(2)
}
}
get start(): Point {
throw new Error(
'Illegal access. Moveto segments should not need a start property.',
)
}
get type() {
return 'M'
}
bbox() {
return null
}
closestPoint() {
return this.end.clone()
}
closestPointLength() {
return 0
}
closestPointNormalizedLength() {
return 0
}
closestPointT() {
return 1
}
closestPointTangent() {
return null
}
length() {
return 0
}
lengthAtT() {
return 0
}
divideAt(): [Segment, Segment] {
return [this.clone(), this.clone()]
}
divideAtLength(): [Segment, Segment] {
return [this.clone(), this.clone()]
}
getSubdivisions() {
return []
}
pointAt() {
return this.end.clone()
}
pointAtLength() {
return this.end.clone()
}
pointAtT() {
return this.end.clone()
}
tangentAt() {
return null
}
tangentAtLength() {
return null
}
tangentAtT() {
return null
}
isDifferentiable() {
return false
}
scale(sx: number, sy: number, origin?: Point.PointLike | Point.PointData) {
this.end.scale(sx, sy, origin)
return this
}
rotate(angle: number, origin?: Point.PointLike | Point.PointData) {
this.end.rotate(angle, origin)
return this
}
translate(tx: number, ty: number): this
translate(p: Point.PointLike | Point.PointData): this
translate(tx: number | Point.PointLike | Point.PointData, ty?: number) {
if (typeof tx === 'number') {
this.end.translate(tx, ty as number)
} else {
this.end.translate(tx)
}
return this
}
clone() {
return new MoveTo(this.end)
}
equals(s: Segment) {
return this.type === s.type && this.end.equals(s.end)
}
toJSON() {
return {
type: this.type,
end: this.end.toJSON(),
}
}
serialize() {
const end = this.end
return `${this.type} ${end.x} ${end.y}`
}
}
export namespace MoveTo {
export function create(line: Line): MoveTo
export function create(curve: Curve): MoveTo
export function create(point: Point.PointLike): MoveTo
export function create(x: number, y: number): MoveTo
export function create(
point: Point.PointLike,
...points: Point.PointLike[]
): Segment[]
export function create(x: number, y: number, ...coords: number[]): Segment[]
export function create(...args: any[]): MoveTo | Segment[] {
const len = args.length
const arg0 = args[0]
// line provided
if (Line.isLine(arg0)) {
return new MoveTo(arg0)
}
// curve provided
if (Curve.isCurve(arg0)) {
return new MoveTo(arg0)
}
// points provided
if (Point.isPointLike(arg0)) {
if (len === 1) {
return new MoveTo(arg0)
}
// this is a moveto-with-subsequent-poly-line segment
const segments: Segment[] = []
// points come one by one
for (let i = 0; i < len; i += 1) {
if (i === 0) {
segments.push(new MoveTo(args[i]))
} else {
segments.push(new LineTo(args[i]))
}
}
return segments
}
// coordinates provided
if (len === 2) {
return new MoveTo(+args[0], +args[1])
}
// this is a moveto-with-subsequent-poly-line segment
const segments: Segment[] = []
for (let i = 0; i < len; i += 2) {
const x = +args[i]
const y = +args[i + 1]
if (i === 0) {
segments.push(new MoveTo(x, y))
} else {
segments.push(new LineTo(x, y))
}
}
return segments
}
}

View File

@ -0,0 +1,85 @@
import { Path } from './path'
import { normalizePathData } from './normalize'
describe('Path', () => {
describe('#normalizePathData', () => {
const paths = [
['M 10 10 H 20', 'M 10 10 L 20 10'],
['M 10 10 V 20', 'M 10 10 L 10 20'],
[
'M 10 20 C 10 10 25 10 25 20 S 40 30 40 20',
'M 10 20 C 10 10 25 10 25 20 C 25 30 40 30 40 20',
],
['M 20 20 Q 40 0 60 20', 'M 20 20 C 33.33 6.67 46.67 6.67 60 20'],
[
'M 20 20 Q 40 0 60 20 T 100 20',
'M 20 20 C 33.33 6.67 46.67 6.67 60 20 C 73.33 33.33 86.67 33.33 100 20',
],
['M 30 15 A 15 15 0 0 0 15 30', 'M 30 15 C 21.72 15 15 21.72 15 30'],
['m 10 10', 'M 10 10'],
['M 10 10 m 10 10', 'M 10 10 M 20 20'],
['M 10 10 l 10 10', 'M 10 10 L 20 20'],
['M 10 10 c 0 10 10 10 10 0', 'M 10 10 C 10 20 20 20 20 10'],
['M 10 10 z', 'M 10 10 Z'],
['M 10 10 20 20', 'M 10 10 L 20 20'],
['M 10 10 L 20 20 30 30', 'M 10 10 L 20 20 L 30 30'],
[
'M 10 10 C 10 20 20 20 20 10 20 0 30 0 30 10',
'M 10 10 C 10 20 20 20 20 10 C 20 0 30 0 30 10',
],
// edge cases
['L 10 10', 'M 0 0 L 10 10'],
['C 10 20 20 20 20 10', 'M 0 0 C 10 20 20 20 20 10'],
['Z', 'M 0 0 Z'],
['M 10 10 Z L 20 20', 'M 10 10 Z L 20 20'],
['M 10 10 Z C 10 20 20 20 20 10', 'M 10 10 Z C 10 20 20 20 20 10'],
['M 10 10 Z Z', 'M 10 10 Z Z'],
['', 'M 0 0'], // empty string
['X', 'M 0 0'], // invalid command
['M', 'M 0 0'], // no arguments for a command that needs them
['M 10', 'M 0 0'], // too few arguments
['M 10 10 20', 'M 10 10'], // too many arguments
['X M 10 10', 'M 10 10'], // mixing invalid and valid commands
// invalid commands interspersed with valid commands
['X M 10 10 X L 20 20', 'M 10 10 L 20 20'],
['A 0 3 0 0 1 10 15', 'M 0 0 L 10 15'], // 0 x radius
['A 3 0 0 0 1 10 15', 'M 0 0 L 10 15'], // 0 y radius
['A 0 0 0 0 1 10 15', 'M 0 0 L 10 15'], // 0 x and y radii
// Make sure this does not throw an error because of
// recursion in a2c() exceeding the maximum stack size
['M 0 0 A 1 1 0 1 0 -1 -1'],
['M 14.4 29.52 a .72 .72 0 1 0 -.72 -.72 A .72 .72 0 0 0 14.4 29.52Z'],
]
it('should normalize path data', () => {
paths.forEach((path) => {
if (path[1]) {
expect(normalizePathData(path[0])).toEqual(path[1])
} else {
normalizePathData(path[0])
}
})
})
it('should parsed by Path', () => {
const path1 = 'M 10 10'
const normalizedPath1 = normalizePathData(path1)
const reconstructedPath1 = Path.parse(path1).serialize()
expect(normalizedPath1).toEqual(reconstructedPath1)
const path2 = 'M 100 100 C 100 100 0 150 100 200 Z'
const normalizedPath2 = normalizePathData(path2)
const reconstructedPath2 = Path.parse(path2).serialize()
expect(normalizedPath2).toEqual(reconstructedPath2)
const path3 =
'M285.8,83V52.7h8.3v31c0,3.2-1,5.8-3,7.7c-2,1.9-4.4,2.8-7.2,2.8c-2.9,0-5.6-1.2-8.1-3.5l3.8-6.1c1.1,1.3,2.3,1.9,3.7,1.9c0.7,0,1.3-0.3,1.8-0.9C285.5,85,285.8,84.2,285.8,83z'
const normalizedPath3 = normalizePathData(path3)
const reconstructedPath3 = Path.parse(path3).serialize()
expect(normalizedPath3).toEqual(reconstructedPath3)
})
})
})

View File

@ -0,0 +1,503 @@
import { Util } from '../util'
type Segment = [string, ...number[]]
function rotate(x: number, y: number, rad: number) {
return {
x: x * Math.cos(rad) - y * Math.sin(rad),
y: x * Math.sin(rad) + y * Math.cos(rad),
}
}
function q2c(
x1: number,
y1: number,
ax: number,
ay: number,
x2: number,
y2: number,
) {
const v13 = 1 / 3
const v23 = 2 / 3
return [
v13 * x1 + v23 * ax,
v13 * y1 + v23 * ay,
v13 * x2 + v23 * ax,
v13 * y2 + v23 * ay,
x2,
y2,
]
}
function a2c(
x1: number,
y1: number,
rx: number,
ry: number,
angle: number,
largeArcFlag: number,
sweepFlag: number,
x2: number,
y2: number,
recursive?: [number, number, number, number],
): any[] {
// for more information of where this math came from visit:
// http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes
const v120 = (Math.PI * 120) / 180
const rad = (Math.PI / 180) * (+angle || 0)
let res = []
let xy
let f1
let f2
let cx
let cy
if (!recursive) {
xy = rotate(x1, y1, -rad)
x1 = xy.x // eslint-disable-line
y1 = xy.y // eslint-disable-line
xy = rotate(x2, y2, -rad)
x2 = xy.x // eslint-disable-line
y2 = xy.y // eslint-disable-line
const x = (x1 - x2) / 2
const y = (y1 - y2) / 2
let h = (x * x) / (rx * rx) + (y * y) / (ry * ry)
if (h > 1) {
h = Math.sqrt(h)
rx = h * rx // eslint-disable-line
ry = h * ry // eslint-disable-line
}
const rx2 = rx * rx
const ry2 = ry * ry
const k =
(largeArcFlag === sweepFlag ? -1 : 1) *
Math.sqrt(
Math.abs(
(rx2 * ry2 - rx2 * y * y - ry2 * x * x) / (rx2 * y * y + ry2 * x * x),
),
)
cx = (k * rx * y) / ry + (x1 + x2) / 2
cy = (k * -ry * x) / rx + (y1 + y2) / 2
f1 = Math.asin((y1 - cy) / ry)
f2 = Math.asin((y2 - cy) / ry)
f1 = x1 < cx ? Math.PI - f1 : f1
f2 = x2 < cx ? Math.PI - f2 : f2
if (f1 < 0) {
f1 = Math.PI * 2 + f1
}
if (f2 < 0) {
f2 = Math.PI * 2 + f2
}
if (sweepFlag && f1 > f2) {
f1 -= Math.PI * 2
}
if (!sweepFlag && f2 > f1) {
f2 -= Math.PI * 2
}
} else {
f1 = recursive[0]
f2 = recursive[1]
cx = recursive[2]
cy = recursive[3]
}
let df = f2 - f1
if (Math.abs(df) > v120) {
const f2old = f2
const x2old = x2
const y2old = y2
f2 = f1 + v120 * (sweepFlag && f2 > f1 ? 1 : -1)
x2 = cx + rx * Math.cos(f2) // eslint-disable-line
y2 = cy + ry * Math.sin(f2) // eslint-disable-line
res = a2c(x2, y2, rx, ry, angle, 0, sweepFlag, x2old, y2old, [
f2,
f2old,
cx,
cy,
])
}
df = f2 - f1
const c1 = Math.cos(f1)
const s1 = Math.sin(f1)
const c2 = Math.cos(f2)
const s2 = Math.sin(f2)
const t = Math.tan(df / 4)
const hx = (4 / 3) * (rx * t)
const hy = (4 / 3) * (ry * t)
const m1 = [x1, y1]
const m2 = [x1 + hx * s1, y1 - hy * c1]
const m3 = [x2 + hx * s2, y2 - hy * c2]
const m4 = [x2, y2]
m2[0] = 2 * m1[0] - m2[0]
m2[1] = 2 * m1[1] - m2[1]
if (recursive) {
return [m2, m3, m4].concat(res)
}
{
res = [m2, m3, m4].concat(res).join().split(',')
const newres = []
const ii = res.length
for (let i = 0; i < ii; i += 1) {
newres[i] =
i % 2
? rotate(+res[i - 1], +res[i], rad).y
: rotate(+res[i], +res[i + 1], rad).x
}
return newres
}
}
function parse(pathData: string) {
if (!pathData) {
return null
}
const spaces =
'\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029'
// https://regexper.com/#%28%5Ba-z%5D%29%5B%5Cs%2C%5D*%28%28-%3F%5Cd*%5C.%3F%5C%5Cd*%28%3F%3Ae%5B%5C-%2B%5D%3F%5Cd%2B%29%3F%5B%5Cs%5D*%2C%3F%5B%5Cs%5D*%29%2B%29
const segmentReg = new RegExp(
`([a-z])[${spaces},]*((-?\\d*\\.?\\d*(?:e[\\-+]?\\d+)?[${spaces}]*,?[${spaces}]*)+)`, // eslint-disable-line
'ig',
)
// https://regexper.com/#%28-%3F%5Cd*%5C.%3F%5Cd*%28%3F%3Ae%5B%5C-%2B%5D%3F%5Cd%2B%29%3F%29%5B%5Cs%5D*%2C%3F%5B%5Cs%5D*
const commandParamReg = new RegExp(
// eslint-disable-next-line
`(-?\\d*\\.?\\d*(?:e[\\-+]?\\d+)?)[${spaces}]*,?[${spaces}]*`,
'ig',
)
const paramsCount = {
a: 7,
c: 6,
h: 1,
l: 2,
m: 2,
q: 4,
s: 4,
t: 2,
v: 1,
z: 0,
}
const segmetns: Segment[] = []
pathData.replace(segmentReg, (input: string, cmd: string, args: string) => {
const params: number[] = []
let command = cmd.toLowerCase()
args.replace(commandParamReg, (a: string, b: string) => {
if (b) {
params.push(+b)
}
return a
})
if (command === 'm' && params.length > 2) {
segmetns.push([cmd, ...params.splice(0, 2)])
command = 'l'
cmd = cmd === 'm' ? 'l' : 'L' // eslint-disable-line
}
const count = paramsCount[command as keyof typeof paramsCount]
while (params.length >= count) {
segmetns.push([cmd, ...params.splice(0, count)])
if (!count) {
break
}
}
return input
})
return segmetns
}
function abs(pathString: string) {
const pathArray = parse(pathString)
// if invalid string, return 'M 0 0'
if (!pathArray || !pathArray.length) {
return [['M', 0, 0]]
}
let x = 0
let y = 0
let mx = 0
let my = 0
const segments = []
for (let i = 0, ii = pathArray.length; i < ii; i += 1) {
const r: any = []
segments.push(r)
const segment = pathArray[i]
const command = segment[0]
if (command !== command.toUpperCase()) {
r[0] = command.toUpperCase()
switch (r[0]) {
case 'A':
r[1] = segment[1]
r[2] = segment[2]
r[3] = segment[3]
r[4] = segment[4]
r[5] = segment[5]
r[6] = +segment[6] + x
r[7] = +segment[7] + y
break
case 'V':
r[1] = +segment[1] + y
break
case 'H':
r[1] = +segment[1] + x
break
case 'M':
mx = +segment[1] + x
my = +segment[2] + y
for (let j = 1, jj = segment.length; j < jj; j += 1) {
r[j] = +segment[j] + (j % 2 ? x : y)
}
break
default:
for (let j = 1, jj = segment.length; j < jj; j += 1) {
r[j] = +segment[j] + (j % 2 ? x : y)
}
break
}
} else {
for (let j = 0, jj = segment.length; j < jj; j += 1) {
r[j] = segment[j]
}
}
switch (r[0]) {
case 'Z':
x = +mx
y = +my
break
case 'H':
x = r[1]
break
case 'V':
y = r[1]
break
case 'M':
mx = r[r.length - 2]
my = r[r.length - 1]
x = r[r.length - 2]
y = r[r.length - 1]
break
default:
x = r[r.length - 2]
y = r[r.length - 1]
break
}
}
return segments
}
function normalize(path: string) {
const pathArray = abs(path)
const attrs = { x: 0, y: 0, bx: 0, by: 0, X: 0, Y: 0, qx: null, qy: null }
function processPath(path: any[], d: any, pcom: string) {
let nx
let ny
if (!path) {
return ['C', d.x, d.y, d.x, d.y, d.x, d.y]
}
if (!(path[0] in { T: 1, Q: 1 })) {
d.qx = null
d.qy = null
}
switch (path[0]) {
case 'M':
d.X = path[1]
d.Y = path[2]
break
case 'A':
if (parseFloat(path[1]) === 0 || parseFloat(path[2]) === 0) {
// https://www.w3.org/TR/SVG/paths.html#ArcOutOfRangeParameters
// "If either rx or ry is 0, then this arc is treated as a
// straight line segment (a "lineto") joining the endpoints."
return ['L', path[6], path[7]]
}
return ['C'].concat(a2c.apply(0, [d.x, d.y].concat(path.slice(1))))
case 'S':
if (pcom === 'C' || pcom === 'S') {
// In 'S' case we have to take into account, if the previous command is C/S.
nx = d.x * 2 - d.bx // And reflect the previous
ny = d.y * 2 - d.by // command's control point relative to the current point.
} else {
// or some else or nothing
nx = d.x
ny = d.y
}
return ['C', nx, ny].concat(path.slice(1))
case 'T':
if (pcom === 'Q' || pcom === 'T') {
// In 'T' case we have to take into account, if the previous command is Q/T.
d.qx = d.x * 2 - d.qx // And make a reflection similar
d.qy = d.y * 2 - d.qy // to case 'S'.
} else {
// or something else or nothing
d.qx = d.x
d.qy = d.y
}
return ['C'].concat(
q2c(d.x, d.y, d.qx, d.qy, path[1], path[2]) as any[],
)
case 'Q':
d.qx = path[1]
d.qy = path[2]
return ['C'].concat(
q2c(d.x, d.y, path[1], path[2], path[3], path[4]) as any[],
)
case 'H':
return ['L'].concat(path[1], d.y)
case 'V':
return ['L'].concat(d.x, path[1])
case 'L':
break
case 'Z':
break
default:
break
}
return path
}
function fixArc(pp: any[], i: number) {
if (pp[i].length > 7) {
pp[i].shift()
const pi = pp[i]
while (pi.length) {
// if created multiple 'C's, their original seg is saved
commands[i] = 'A'
i += 1 // eslint-disable-line
pp.splice(i, 0, ['C'].concat(pi.splice(0, 6)))
}
pp.splice(i, 1)
ii = pathArray.length
}
}
const commands = [] // path commands of original path p
let prevCommand = '' // holder for previous path command of original path
let ii = pathArray.length
for (let i = 0; i < ii; i += 1) {
let command = '' // temporary holder for original path command
if (pathArray[i]) {
command = pathArray[i][0] // save current path command
}
if (command !== 'C') {
// C is not saved yet, because it may be result of conversion
commands[i] = command // Save current path command
if (i > 0) {
prevCommand = commands[i - 1] // Get previous path command pcom
}
}
// Previous path command is inputted to processPath
pathArray[i] = processPath(pathArray[i], attrs, prevCommand)
if (commands[i] !== 'A' && command === 'C') {
commands[i] = 'C' // 'A' is the only command
}
// which may produce multiple 'C's
// so we have to make sure that 'C' is also 'C' in original path
fixArc(pathArray, i) // fixArc adds also the right amount of 'A's to pcoms
const seg = pathArray[i]
const seglen = seg.length
attrs.x = seg[seglen - 2]
attrs.y = seg[seglen - 1]
attrs.bx = parseFloat(seg[seglen - 4]) || attrs.x
attrs.by = parseFloat(seg[seglen - 3]) || attrs.y
}
// make sure normalized path data string starts with an M segment
if (!pathArray[0][0] || pathArray[0][0] !== 'M') {
pathArray.unshift(['M', 0, 0])
}
return pathArray
}
/**
* Converts provided SVG path data string into a normalized path data string.
*
* The normalization uses a restricted subset of path commands; all segments
* are translated into lineto, curveto, moveto, and closepath segments.
*
* Relative path commands are changed into their absolute counterparts,
* and chaining of coordinates is disallowed.
*
* The function will always return a valid path data string; if an input
* string cannot be normalized, 'M 0 0' is returned.
*/
export function normalizePathData(pathData: string) {
return normalize(pathData)
.map((segment: Segment) =>
segment.map((item) =>
typeof item === 'string' ? item : Util.round(item, 2),
),
)
.join(',')
.split(',')
.join(' ')
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,135 @@
import { Line } from '../line'
import { Point } from '../point'
import { Rectangle } from '../rectangle'
import { Geometry } from '../geometry'
export abstract class Segment extends Geometry {
isVisible = true
isSegment = true
isSubpathStart = false
nextSegment: Segment | null
previousSegment: Segment | null
subpathStartSegment: Segment | null
protected endPoint: Point
get end() {
return this.endPoint
}
get start() {
if (this.previousSegment == null) {
throw new Error(
'Missing previous segment. (This segment cannot be the ' +
'first segment of a path, or segment has not yet been ' +
'added to a path.)',
)
}
return this.previousSegment.end
}
abstract get type(): string
abstract bbox(): Rectangle | null
abstract closestPoint(p: Point.PointLike | Point.PointData): Point
abstract closestPointLength(p: Point.PointLike | Point.PointData): number
abstract closestPointNormalizedLength(
p: Point.PointLike | Point.PointData,
): number
closestPointT(
p: Point.PointLike | Point.PointData,
options?: Segment.Options, // eslint-disable-line
) {
if (this.closestPointNormalizedLength) {
return this.closestPointNormalizedLength(p)
}
throw new Error(
'Neither `closestPointT` nor `closestPointNormalizedLength` method is implemented.',
)
}
abstract closestPointTangent(
p: Point.PointLike | Point.PointData,
): Line | null
abstract length(options?: Segment.Options): number
// eslint-disable-next-line
lengthAtT(t: number, options?: Segment.Options) {
if (t <= 0) {
return 0
}
const length = this.length()
if (t >= 1) {
return length
}
return length * t
}
abstract divideAt(
ratio: number,
options?: Segment.Options,
): [Segment, Segment]
abstract divideAtLength(
length: number,
options?: Segment.Options,
): [Segment, Segment]
divideAtT(t: number) {
if (this.divideAt) {
return this.divideAt(t)
}
throw new Error('Neither `divideAtT` nor `divideAt` method is implemented.')
}
abstract getSubdivisions(options?: Segment.Options): Segment[]
abstract pointAt(ratio: number): Point
abstract pointAtLength(length: number, options?: Segment.Options): Point
pointAtT(t: number): Point {
if (this.pointAt) {
return this.pointAt(t)
}
throw new Error('Neither `pointAtT` nor `pointAt` method is implemented.')
}
abstract tangentAt(ratio: number): Line | null
abstract tangentAtLength(
length: number,
options?: Segment.Options,
): Line | null
tangentAtT(t: number): Line | null {
if (this.tangentAt) {
return this.tangentAt(t)
}
throw new Error(
'Neither `tangentAtT` nor `tangentAt` method is implemented.',
)
}
abstract isDifferentiable(): boolean
abstract clone(): Segment
}
export namespace Segment {
export interface Options {
precision?: number
subdivisions?: Segment[]
}
}

View File

@ -0,0 +1,304 @@
import { Point } from '../point'
const regexSupportedData = new RegExp(`^[\\s\\dLMCZz,.]*$`)
export function isValid(data: any) {
if (typeof data !== 'string') {
return false
}
return regexSupportedData.test(data)
}
/**
* Returns the remainder of division of `n` by `m`. You should use this
* instead of the built-in operation as the built-in operation does not
* properly handle negative numbers.
*/
function mod(n: number, m: number) {
return ((n % m) + m) % m
}
export interface DrawPointsOptions {
round?: number
initialMove?: boolean
close?: boolean
exclude?: number[]
}
function draw(
points: Point.PointLike[],
round?: number,
initialMove?: boolean,
close?: boolean,
exclude?: number[],
) {
const data: (string | number)[] = []
const end = points[points.length - 1]
const rounded = round != null && round > 0
const arcSize = round || 0
// Adds virtual waypoint in the center between start and end point
if (close && rounded) {
points = points.slice() // eslint-disable-line
const p0 = points[0]
const wp = new Point(end.x + (p0.x - end.x) / 2, end.y + (p0.y - end.y) / 2)
points.splice(0, 0, wp)
}
let pt = points[0]
let i = 1
// Draws the line segments
if (initialMove) {
data.push('M', pt.x, pt.y)
} else {
data.push('L', pt.x, pt.y)
}
while (i < (close ? points.length : points.length - 1)) {
let tmp = points[mod(i, points.length)]
let dx = pt.x - tmp.x
let dy = pt.y - tmp.y
if (
rounded &&
(dx !== 0 || dy !== 0) &&
(exclude == null || exclude.indexOf(i - 1) < 0)
) {
// Draws a line from the last point to the current
// point with a spacing of size off the current point
// into direction of the last point
let dist = Math.sqrt(dx * dx + dy * dy)
const nx1 = (dx * Math.min(arcSize, dist / 2)) / dist
const ny1 = (dy * Math.min(arcSize, dist / 2)) / dist
const x1 = tmp.x + nx1
const y1 = tmp.y + ny1
data.push('L', x1, y1)
// Draws a curve from the last point to the current
// point with a spacing of size off the current point
// into direction of the next point
let next = points[mod(i + 1, points.length)]
// Uses next non-overlapping point
while (
i < points.length - 2 &&
Math.round(next.x - tmp.x) === 0 &&
Math.round(next.y - tmp.y) === 0
) {
next = points[mod(i + 2, points.length)]
i += 1
}
dx = next.x - tmp.x
dy = next.y - tmp.y
dist = Math.max(1, Math.sqrt(dx * dx + dy * dy))
const nx2 = (dx * Math.min(arcSize, dist / 2)) / dist
const ny2 = (dy * Math.min(arcSize, dist / 2)) / dist
const x2 = tmp.x + nx2
const y2 = tmp.y + ny2
data.push('Q', tmp.x, tmp.y, x2, y2)
tmp = new Point(x2, y2)
} else {
data.push('L', tmp.x, tmp.y)
}
pt = tmp
i += 1
}
if (close) {
data.push('Z')
} else {
data.push('L', end.x, end.y)
}
return data.map((v) => (typeof v === 'string' ? v : +v.toFixed(3))).join(' ')
}
export function drawPoints(
points: (Point.PointLike | Point.PointData)[],
options: DrawPointsOptions = {},
) {
const pts: Point.PointLike[] = []
if (points && points.length) {
points.forEach((p) => {
if (Array.isArray(p)) {
pts.push({ x: p[0], y: p[1] })
} else {
pts.push({ x: p.x, y: p.y })
}
})
}
return draw(
pts,
options.round,
options.initialMove == null || options.initialMove,
options.close,
options.exclude,
)
}
/**
* Converts the given arc to a series of curves.
*/
export function arcToCurves(
x0: number,
y0: number,
r1: number,
r2: number,
angle = 0,
largeArcFlag = 0,
sweepFlag = 0,
x: number,
y: number,
) {
if (r1 === 0 || r2 === 0) {
return []
}
x -= x0 // eslint-disable-line
y -= y0 // eslint-disable-line
r1 = Math.abs(r1) // eslint-disable-line
r2 = Math.abs(r2) // eslint-disable-line
const ctx = -x / 2
const cty = -y / 2
const cpsi = Math.cos((angle * Math.PI) / 180)
const spsi = Math.sin((angle * Math.PI) / 180)
const rxd = cpsi * ctx + spsi * cty
const ryd = -1 * spsi * ctx + cpsi * cty
const rxdd = rxd * rxd
const rydd = ryd * ryd
const r1x = r1 * r1
const r2y = r2 * r2
const lamda = rxdd / r1x + rydd / r2y
let sds
if (lamda > 1) {
r1 = Math.sqrt(lamda) * r1 // eslint-disable-line
r2 = Math.sqrt(lamda) * r2 // eslint-disable-line
sds = 0
} else {
let seif = 1
if (largeArcFlag === sweepFlag) {
seif = -1
}
sds =
seif *
Math.sqrt(
(r1x * r2y - r1x * rydd - r2y * rxdd) / (r1x * rydd + r2y * rxdd),
)
}
const txd = (sds * r1 * ryd) / r2
const tyd = (-1 * sds * r2 * rxd) / r1
const tx = cpsi * txd - spsi * tyd + x / 2
const ty = spsi * txd + cpsi * tyd + y / 2
let rad = Math.atan2((ryd - tyd) / r2, (rxd - txd) / r1) - Math.atan2(0, 1)
let s1 = rad >= 0 ? rad : 2 * Math.PI + rad
rad =
Math.atan2((-ryd - tyd) / r2, (-rxd - txd) / r1) -
Math.atan2((ryd - tyd) / r2, (rxd - txd) / r1)
let dr = rad >= 0 ? rad : 2 * Math.PI + rad
if (sweepFlag === 0 && dr > 0) {
dr -= 2 * Math.PI
} else if (sweepFlag !== 0 && dr < 0) {
dr += 2 * Math.PI
}
const sse = (dr * 2) / Math.PI
const seg = Math.ceil(sse < 0 ? -1 * sse : sse)
const segr = dr / seg
const t =
((8 / 3) * Math.sin(segr / 4) * Math.sin(segr / 4)) / Math.sin(segr / 2)
const cpsir1 = cpsi * r1
const cpsir2 = cpsi * r2
const spsir1 = spsi * r1
const spsir2 = spsi * r2
let mc = Math.cos(s1)
let ms = Math.sin(s1)
let x2 = -t * (cpsir1 * ms + spsir2 * mc)
let y2 = -t * (spsir1 * ms - cpsir2 * mc)
let x3 = 0
let y3 = 0
const result = []
for (let n = 0; n < seg; n += 1) {
s1 += segr
mc = Math.cos(s1)
ms = Math.sin(s1)
x3 = cpsir1 * mc - spsir2 * ms + tx
y3 = spsir1 * mc + cpsir2 * ms + ty
const dx = -t * (cpsir1 * ms + spsir2 * mc)
const dy = -t * (spsir1 * ms - cpsir2 * mc)
// CurveTo updates x0, y0 so need to restore it
const index = n * 6
result[index] = Number(x2 + x0)
result[index + 1] = Number(y2 + y0)
result[index + 2] = Number(x3 - dx + x0)
result[index + 3] = Number(y3 - dy + y0)
result[index + 4] = Number(x3 + x0)
result[index + 5] = Number(y3 + y0)
x2 = x3 + dx
y2 = y3 + dy
}
return result.map((num) => +num.toFixed(2))
}
export function drawArc(
startX: number,
startY: number,
rx: number,
ry: number,
xAxisRotation = 0,
largeArcFlag: 0 | 1 = 0,
sweepFlag: 0 | 1 = 0,
stopX: number,
stopY: number,
) {
const data: (string | number)[] = []
const points = arcToCurves(
startX,
startY,
rx,
ry,
xAxisRotation,
largeArcFlag,
sweepFlag,
stopX,
stopY,
)
if (points != null) {
for (let i = 0, ii = points.length; i < ii; i += 6) {
data.push(
'C',
points[i],
points[i + 1],
points[i + 2],
points[i + 3],
points[i + 4],
points[i + 5],
)
}
}
return data.join(' ')
}

View File

@ -0,0 +1,432 @@
import { Point } from './point'
describe('point', () => {
describe('#constructor', () => {
it('should create a point instance', () => {
expect(new Point()).toBeInstanceOf(Point)
expect(new Point(1)).toBeInstanceOf(Point)
expect(new Point(1, 2)).toBeInstanceOf(Point)
expect(new Point(1, 2).x).toEqual(1)
expect(new Point(1, 2).y).toEqual(2)
expect(new Point().equals(new Point(0, 0)))
})
})
describe('#Point.random', () => {
it('should create random point', () => {
const p = Point.random(1, 5, 2, 6)
expect(p.x >= 1 && p.x <= 5).toBe(true)
expect(p.y >= 2 && p.y <= 6).toBe(true)
})
})
describe('#Point.isPointLike', () => {
it('should return true when the given object is a point-like object', () => {
expect(Point.isPointLike({ x: 1, y: 2 })).toBeTruthy()
expect(Point.isPointLike({ x: 1, y: 2, z: 10 })).toBeTruthy()
expect(Point.isPointLike({ x: 1, y: 2, z: 10, s: 's' })).toBeTruthy()
})
it('should return false when the given object is a point-like object', () => {
expect(Point.isPointLike({ x: 1 })).toBeFalsy()
expect(Point.isPointLike({ y: 2 })).toBeFalsy()
expect(Point.isPointLike({})).toBeFalsy()
expect(Point.isPointLike(null)).toBeFalsy()
expect(Point.isPointLike(false)).toBeFalsy()
expect(Point.isPointLike(1)).toBeFalsy()
expect(Point.isPointLike('s')).toBeFalsy()
})
})
describe('#Point.isPointData', () => {
it('should return true when the given object is a point-data array', () => {
expect(Point.isPointData([1, 2])).toBeTruthy()
})
it('should return false when the given object is a point-data array', () => {
expect(Point.isPointData({ x: 1, y: 2 })).toBeFalsy()
expect(Point.isPointData([1])).toBeFalsy()
expect(Point.isPointData([1, 2, 3])).toBeFalsy()
expect(Point.isPointData(null)).toBeFalsy()
expect(Point.isPointData(false)).toBeFalsy()
expect(Point.isPointData(1)).toBeFalsy()
expect(Point.isPointData('s')).toBeFalsy()
})
})
describe('#Point.toJSON', () => {
it('should conver the given point to json', () => {
expect(Point.toJSON([1, 2])).toEqual({ x: 1, y: 2 })
expect(Point.toJSON({ x: 1, y: 2 })).toEqual({ x: 1, y: 2 })
expect(Point.toJSON(new Point(1, 2))).toEqual({ x: 1, y: 2 })
})
})
describe('#Point.equals', () => {
it('should return true when the given two points are equal', () => {
const p1 = new Point(1, 2)
expect(Point.equals(p1, p1)).toBeTruthy()
expect(Point.equals(p1, { x: 1, y: 2 })).toBeTruthy()
})
it('should return false when the given two points are not equal', () => {
const p1 = new Point(1, 2)
const p2 = new Point(2, 2)
expect(Point.equals(p1, p2)).toBeFalsy()
expect(Point.equals(p1, null as any)).toBeFalsy()
expect(Point.equals(p1, { x: 2, y: 2 })).toBeFalsy()
})
})
describe('#Point.equalPoints', () => {
it('should return true when the given points array are equal', () => {
const p1 = new Point(1, 2)
expect(Point.equalPoints([p1], [p1])).toBeTruthy()
expect(Point.equalPoints([p1], [{ x: 1, y: 2 }])).toBeTruthy()
})
it('should return false when the given points array are not equal', () => {
const p1 = new Point(1, 2)
const p2 = new Point(2, 2)
expect(Point.equalPoints([p1], [p2])).toBeFalsy()
expect(Point.equalPoints(null as any, [p2])).toBeFalsy()
expect(Point.equalPoints([p1], null as any)).toBeFalsy()
expect(Point.equalPoints([p1, p2], [p2])).toBeFalsy()
expect(Point.equalPoints([p1, p2], [p2, p1])).toBeFalsy()
expect(Point.equalPoints([p1], [{ x: 2, y: 2 }])).toBeFalsy()
})
})
describe('#round', () => {
it('should round x and y properties to the given precision', () => {
const point = new Point(17.231, 4.01)
point.round(2)
expect(point.serialize()).toEqual('17.23 4.01')
point.round(0)
expect(point.serialize()).toEqual('17 4')
})
})
describe('#add', () => {
it('should add x and y width the given amount', () => {
const source = new Point(4, 17)
const target = new Point(20, 20)
expect(source.clone().add(16, 3)).toEqual(target)
expect(source.clone().add([16, 3])).toEqual(target)
expect(source.clone().add({ x: 16, y: 3 })).toEqual(target)
})
})
describe('#update', () => {
it('should update the values of x and y', () => {
const source = new Point(4, 17)
const target = new Point(16, 24)
expect(source.clone().update(16, 24)).toEqual(target)
expect(source.clone().update([16, 24])).toEqual(target)
expect(source.clone().update({ x: 16, y: 24 })).toEqual(target)
expect(source.clone().update(target)).toEqual(target)
})
})
describe('#translate', () => {
it('should translate x and y by adding the given dx and dy values respectively', () => {
const point = new Point(0, 0)
point.translate(2, 3)
expect(point.toJSON()).toEqual({ x: 2, y: 3 })
point.translate(new Point(-2, 4))
expect(point.toJSON()).toEqual({ x: 0, y: 7 })
})
})
describe('#rotate', () => {
const rotate = (p: Point, angle: number, center?: Point) =>
p.clone().rotate(angle, center).round(3).serialize()
it('should return a rotated version of self', () => {
const point = new Point(5, 5)
let angle
const zeroPoint = new Point(0, 0)
const arbitraryPoint = new Point(14, 6)
angle = 0
expect(rotate(point, angle)).toEqual('5 5')
expect(rotate(point, angle, zeroPoint)).toEqual('5 5')
expect(rotate(point, angle, point)).toEqual('5 5')
expect(rotate(point, angle, arbitraryPoint)).toEqual('5 5')
angle = 154
expect(rotate(point, angle)).toEqual('-2.302 -6.686')
expect(rotate(point, angle, zeroPoint)).toEqual('-2.302 -6.686')
expect(rotate(point, angle, point)).toEqual('5 5')
expect(rotate(point, angle, arbitraryPoint)).toEqual('21.651 10.844')
})
})
describe('#scale', () => {
it('should scale point with the given amount', () => {
expect(new Point(20, 30).scale(2, 3)).toEqual(new Point(40, 90))
})
it('should scale point with the given amount and center ', () => {
expect(new Point(20, 30).scale(2, 3, new Point(40, 45))).toEqual(
new Point(0, 0),
)
})
})
describe('#closest', () => {
it('should return the closest point', () => {
const a = new Point(10, 10)
const b = { x: 20, y: 20 }
const c = { x: 30, y: 30 }
expect(a.closest([])).toBeNull()
expect(a.closest([b])).toBeInstanceOf(Point)
expect(a.closest([b])!.toJSON()).toEqual(b)
expect(a.closest([b, c])!.toJSON()).toEqual(b)
expect(a.closest([b, c])!.toJSON()).toEqual(b)
})
})
describe('#distance', () => {
it('should return the distance between me and the given point', () => {
const source = new Point(1, 2)
const target = new Point(4, 6)
expect(source.distance(target)).toEqual(5)
})
})
describe('#squaredDistance', () => {
it('should return the squared distance between me and the given point', () => {
const source = new Point(1, 2)
const target = new Point(4, 6)
expect(source.squaredDistance(target)).toEqual(25)
})
})
describe('#manhattanDistance', () => {
it('should return the manhattan distance between me and the given point', () => {
const source = new Point(1, 2)
const target = new Point(4, 6)
expect(source.manhattanDistance(target)).toEqual(7)
})
})
describe('#magnitude', () => {
it('should return the magnitude of the given point', () => {
expect(new Point(3, 4).magnitude()).toEqual(5)
})
it('should return `0.01` when the given point is `{0, 0}`', () => {
expect(new Point(0, 0).magnitude()).toEqual(0.01)
})
})
describe('#theta', () => {
it('should return the angle between me and p and the x-axis.', () => {
const me = new Point(1, 1)
expect(me.theta(me)).toBe(-0)
expect(me.theta(new Point(2, 1))).toBe(-0)
expect(me.theta(new Point(2, 0))).toBe(45)
expect(me.theta(new Point(1, 0))).toBe(90)
expect(me.theta(new Point(0, 0))).toBe(135)
expect(me.theta(new Point(0, 1))).toBe(180)
expect(me.theta(new Point(0, 2))).toBe(225)
expect(me.theta(new Point(1, 2))).toBe(270)
expect(me.theta(new Point(2, 2))).toBe(315)
})
})
describe('#angleBetween', () => {
it('should returns the angle between vector from me to `p1` and the vector from me to `p2`.', () => {
const me = new Point(1, 2)
const p1 = new Point(2, 4)
const p2 = new Point(4, 3)
const PRECISION = 10
expect(me.angleBetween(me, me)).toBeNaN()
expect(me.angleBetween(p1, me)).toBeNaN()
expect(me.angleBetween(me, p2)).toBeNaN()
expect(me.angleBetween(p1, p2).toFixed(PRECISION)).toBe('45.0000000000')
expect(me.angleBetween(p2, p1).toFixed(PRECISION)).toBe('315.0000000000')
})
})
describe('#changeInAngle', () => {
it('should return the change in angle from my previous position `-dx, -dy` to my new position relative to origin point', () => {
const p = new Point(1, 1)
expect(p.changeInAngle(1, 0)).toEqual(-45)
})
it('should return the change in angle from my previous position `-dx, -dy` to my new position relative to `ref` point', () => {
const p = new Point(2, 2)
expect(p.changeInAngle(1, 0, { x: 1, y: 1 })).toEqual(-45)
})
})
describe('#adhereToRect', () => {
it('should return `p` when `p` is contained in `rect`', () => {
const p = new Point(2, 2)
const rect = { x: 1, y: 1, width: 4, height: 4 }
expect(p.adhereToRect(rect).equals(p)).toBeTruthy()
})
it('should adhere to target `rect` when `p` is outside of `rect`', () => {
const p = new Point(2, 8)
const rect = { x: 1, y: 1, width: 4, height: 4 }
expect(p.adhereToRect(rect).equals({ x: 2, y: 5 })).toBeTruthy()
})
})
describe('#bearing', () => {
it('should return the bearing between me and the given point.', () => {
const p = new Point(2, 8)
expect(p.bearing(new Point())).toEqual('S')
})
})
describe('#vectorAngle', () => {
it('should return the angle between the vector from `0,0` to me and the vector from `0,0` to `p`', () => {
const p0 = new Point(1, 2)
const p = new Point(3, 1)
const zero = new Point(0, 0)
const PRECISION = 10
expect(zero.vectorAngle(zero)).toBeNaN()
expect(p0.vectorAngle(zero)).toBeNaN()
expect(p.vectorAngle(zero)).toBeNaN()
expect(zero.vectorAngle(p0)).toBeNaN()
expect(zero.vectorAngle(p)).toBeNaN()
expect(p0.vectorAngle(p).toFixed(PRECISION)).toBe('45.0000000000')
expect(p.vectorAngle(p0).toFixed(PRECISION)).toBe('315.0000000000')
})
})
describe('#diff', () => {
it('should return the diff as a point with the given point', () => {
expect(new Point(0, 10).diff(4, 8)).toEqual(new Point(-4, 2))
expect(new Point(5, 8).diff({ x: 5, y: 10 })).toEqual(new Point(0, -2))
})
})
describe('#lerp', () => {
it('should return an interpolation between me and the given point `p`', () => {
expect(new Point(1, 1).lerp({ x: 3, y: 3 }, 0.5)).toEqual(new Point(2, 2))
})
})
describe('#normalize', () => {
it('should scale x and y such that the distance between the point and the origin (0,0) is equal to the given length', () => {
expect(new Point(0, 10).normalize(20).serialize()).toEqual('0 20')
expect(new Point(2, 0).normalize(4).serialize()).toEqual('4 0')
})
})
describe('#move', () => {
it('should move the point on a line that leads to another point `ref` by a certain `distance`.', () => {
expect(new Point(1, 1).move({ x: 1, y: 0 }, 5).round(0)).toEqual(
new Point(1, 6),
)
})
})
describe('#reflection', () => {
it('should return a point that is the reflection of me with the center of inversion in `ref` point.', () => {
expect(new Point(1, 0).reflection({ x: 1, y: 1 }).round(0)).toEqual(
new Point(1, 2),
)
})
})
describe('#cross', () => {
it('should return the cross product of the vector from me to `p1` and the vector from me to `p2`', () => {
const p0 = new Point(3, 15)
const p1 = new Point(4, 17)
const p2 = new Point(2, 10)
expect(p0.cross(p1, p2)).toBe(3)
expect(p0.cross(p2, p1)).toBe(-3)
})
it('shoule return `NAN` when any of the given point is null', () => {
expect(new Point().cross(null as any, new Point(1, 2))).toBeNaN()
})
})
describe('#dot', () => {
it('should return the dot product of `p`', () => {
const p1 = new Point(4, 17)
const p2 = new Point(2, 10)
expect(p1.dot(p2)).toBe(178)
expect(p2.dot(p1)).toBe(178)
})
})
describe('#equals', () => {
it('should return the true when have same coord', () => {
const p1 = new Point(4, 17)
const p2 = p1.clone()
expect(p1.equals(p2)).toBe(true)
})
})
describe('#toJSON', () => {
it('should return json-object', () => {
const p1 = new Point(4, 17)
const p2 = p1.toJSON()
expect(p1.equals(p2)).toBe(true)
})
})
describe('#toPolar', () => {
it('should convert rectangular to polar coordinates.', () => {
const p = new Point(4, 3).toPolar()
expect(p.x).toBe(5)
})
})
describe('#fromPolar', () => {
it('should convert polar to rectangular coordinates.', () => {
const p1 = new Point(4, 3).toPolar()
const p2 = Point.fromPolar(p1.x, p1.y)
expect(Math.round(p2.x)).toBe(4)
expect(Math.round(p2.y)).toBe(3)
const p3 = new Point(-4, 3).toPolar()
const p4 = Point.fromPolar(p3.x, p3.y)
expect(Math.round(p4.x)).toBe(-4)
expect(Math.round(p4.y)).toBe(3)
const p5 = new Point(4, -3).toPolar()
const p6 = Point.fromPolar(p5.x, p5.y)
expect(Math.round(p6.x)).toBe(4)
expect(Math.round(p6.y)).toBe(-3)
const p7 = new Point(-4, -3).toPolar()
const p8 = Point.fromPolar(p7.x, p7.y)
expect(Math.round(p8.x)).toBe(-4)
expect(Math.round(p8.y)).toBe(-3)
})
})
describe('#snapToGrid', () => {
it('should snap to grid', () => {
const p1 = new Point(4, 17)
const p2 = p1.clone().snapToGrid(10)
const p3 = p1.clone().snapToGrid(3, 5)
expect(p2.x).toBe(0)
expect(p2.y).toBe(20)
expect(p3.x).toBe(3)
expect(p3.y).toBe(15)
})
})
})

View File

@ -0,0 +1,595 @@
import { Util } from './util'
import { Angle } from './angle'
import { Geometry } from './geometry'
import { Rectangle } from './rectangle'
export class Point extends Geometry implements Point.PointLike {
public x: number
public y: number
protected get [Symbol.toStringTag]() {
return Point.toStringTag
}
constructor(x?: number, y?: number) {
super()
this.x = x == null ? 0 : x
this.y = y == null ? 0 : y
}
/**
* Rounds the point to the given precision.
*/
round(precision = 0) {
this.x = Util.round(this.x, precision)
this.y = Util.round(this.y, precision)
return this
}
add(x: number, y: number): this
add(p: Point.PointLike | Point.PointData): this
add(x: number | Point.PointLike | Point.PointData, y?: number): this {
const p = Point.create(x, y)
this.x += p.x
this.y += p.y
return this
}
/**
* Update the point's `x` and `y` coordinates with new values and return the
* point itself. Useful for chaining.
*/
update(x: number, y: number): this
update(p: Point.PointLike | Point.PointData): this
update(x: number | Point.PointLike | Point.PointData, y?: number): this {
const p = Point.create(x, y)
this.x = p.x
this.y = p.y
return this
}
translate(dx: number, dy: number): this
translate(p: Point.PointLike | Point.PointData): this
translate(dx: number | Point.PointLike | Point.PointData, dy?: number): this {
const t = Point.create(dx, dy)
this.x += t.x
this.y += t.y
return this
}
/**
* Rotate the point by `degree` around `center`.
*/
rotate(degree: number, center?: Point.PointLike | Point.PointData): this {
const p = Point.rotate(this, degree, center)
this.x = p.x
this.y = p.y
return this
}
/**
* Scale point by `sx` and `sy` around the given `origin`. If origin is not
* specified, the point is scaled around `0,0`.
*/
scale(
sx: number,
sy: number,
origin: Point.PointLike | Point.PointData = new Point(),
) {
const ref = Point.create(origin)
this.x = ref.x + sx * (this.x - ref.x)
this.y = ref.y + sy * (this.y - ref.y)
return this
}
/**
* Chooses the point closest to this point from among `points`. If `points`
* is an empty array, `null` is returned.
*/
closest(points: (Point.PointLike | Point.PointData)[]) {
if (points.length === 1) {
return Point.create(points[0])
}
let ret: Point.PointLike | Point.PointData | null = null
let min = Infinity
points.forEach((p) => {
const dist = this.squaredDistance(p)
if (dist < min) {
ret = p
min = dist
}
})
return ret ? Point.create(ret) : null
}
/**
* Returns the distance between the point and another point `p`.
*/
distance(p: Point.PointLike | Point.PointData) {
return Math.sqrt(this.squaredDistance(p))
}
/**
* Returns the squared distance between the point and another point `p`.
*
* Useful for distance comparisons in which real distance is not necessary
* (saves one `Math.sqrt()` operation).
*/
squaredDistance(p: Point.PointLike | Point.PointData) {
const ref = Point.create(p)
const dx = this.x - ref.x
const dy = this.y - ref.y
return dx * dx + dy * dy
}
manhattanDistance(p: Point.PointLike | Point.PointData) {
const ref = Point.create(p)
return Math.abs(ref.x - this.x) + Math.abs(ref.y - this.y)
}
/**
* Returns the magnitude of the point vector.
*
* @see http://en.wikipedia.org/wiki/Magnitude_(mathematics)
*/
magnitude() {
return Math.sqrt(this.x * this.x + this.y * this.y) || 0.01
}
/**
* Returns the angle(in degrees) between vector from this point to `p` and
* the x-axis.
*/
theta(p: Point.PointLike | Point.PointData = new Point()): number {
const ref = Point.create(p)
const y = -(ref.y - this.y) // invert the y-axis.
const x = ref.x - this.x
let rad = Math.atan2(y, x)
// Correction for III. and IV. quadrant.
if (rad < 0) {
rad = 2 * Math.PI + rad
}
return (180 * rad) / Math.PI
}
/**
* Returns the angle(in degrees) between vector from this point to `p1` and
* the vector from this point to `p2`.
*
* The ordering of points `p1` and `p2` is important.
*
* The function returns a value between `0` and `180` when the angle (in the
* direction from `p1` to `p2`) is clockwise, and a value between `180` and
* `360` when the angle is counterclockwise.
*
* Returns `NaN` if either of the points `p1` and `p2` is equal with this point.
*/
angleBetween(
p1: Point.PointLike | Point.PointData,
p2: Point.PointLike | Point.PointData,
) {
if (this.equals(p1) || this.equals(p2)) {
return NaN
}
let angle = this.theta(p2) - this.theta(p1)
if (angle < 0) {
angle += 360
}
return angle
}
/**
* Returns the angle(in degrees) between the line from `(0,0)` and this point
* and the line from `(0,0)` to `p`.
*
* The function returns a value between `0` and `180` when the angle (in the
* direction from this point to `p`) is clockwise, and a value between `180`
* and `360` when the angle is counterclockwise. Returns `NaN` if called from
* point `(0,0)` or if `p` is `(0,0)`.
*/
vectorAngle(p: Point.PointLike | Point.PointData) {
const zero = new Point(0, 0)
return zero.angleBetween(this, p)
}
/**
* Converts rectangular to polar coordinates.
*/
toPolar(origin?: Point.PointLike | Point.PointData) {
this.update(Point.toPolar(this, origin))
return this
}
/**
* Returns the change in angle(in degrees) that is the result of moving the
* point from its previous position to its current position.
*
* More specifically, this function computes the angle between the line from
* the ref point to the previous position of this point(i.e. current position
* `-dx`, `-dy`) and the line from the `ref` point to the current position of
* this point.
*
* The function returns a positive value between `0` and `180` when the angle
* (in the direction from previous position of this point to its current
* position) is clockwise, and a negative value between `0` and `-180` when
* the angle is counterclockwise.
*
* The function returns `0` if the previous and current positions of this
* point are the same (i.e. both `dx` and `dy` are `0`).
*/
changeInAngle(
dx: number,
dy: number,
ref: Point.PointLike | Point.PointData = new Point(),
) {
// Revert the translation and measure the change in angle around x-axis.
return this.clone().translate(-dx, -dy).theta(ref) - this.theta(ref)
}
/**
* If the point lies outside the rectangle `rect`, adjust the point so that
* it becomes the nearest point on the boundary of `rect`.
*/
adhereToRect(rect: Rectangle.RectangleLike) {
if (!Util.containsPoint(rect, this)) {
this.x = Math.min(Math.max(this.x, rect.x), rect.x + rect.width)
this.y = Math.min(Math.max(this.y, rect.y), rect.y + rect.height)
}
return this
}
/**
* Returns the bearing(cardinal direction) between me and the given point.
*
* @see https://en.wikipedia.org/wiki/Cardinal_direction
*/
bearing(p: Point.PointLike | Point.PointData) {
const ref = Point.create(p)
const lat1 = Angle.toRad(this.y)
const lat2 = Angle.toRad(ref.y)
const lon1 = this.x
const lon2 = ref.x
const dLon = Angle.toRad(lon2 - lon1)
const y = Math.sin(dLon) * Math.cos(lat2)
const x =
Math.cos(lat1) * Math.sin(lat2) -
Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon)
const brng = Angle.toDeg(Math.atan2(y, x))
const bearings = ['NE', 'E', 'SE', 'S', 'SW', 'W', 'NW', 'N']
let index = brng - 22.5
if (index < 0) {
index += 360
}
index = parseInt((index / 45) as any, 10)
return bearings[index] as Point.Bearing
}
/**
* Returns the cross product of the vector from me to `p1` and the vector
* from me to `p2`.
*
* The left-hand rule is used because the coordinate system is left-handed.
*/
cross(
p1: Point.PointLike | Point.PointData,
p2: Point.PointLike | Point.PointData,
) {
if (p1 != null && p2 != null) {
const a = Point.create(p1)
const b = Point.create(p2)
return (b.x - this.x) * (a.y - this.y) - (b.y - this.y) * (a.x - this.x)
}
return NaN
}
/**
* Returns the dot product of this point with given other point.
*/
dot(p: Point.PointLike | Point.PointData) {
const ref = Point.create(p)
return this.x * ref.x + this.y * ref.y
}
/**
* Returns a point that has coordinates computed as a difference between the
* point and another point with coordinates `dx` and `dy`.
*
* If only `dx` is specified and is a number, `dy` is considered to be zero.
* If only `dx` is specified and is an object, it is considered to be another
* point or an object in the form `{ x: [number], y: [number] }`
*/
diff(dx: number, dy: number): Point
diff(p: Point.PointLike | Point.PointData): Point
diff(dx: number | Point.PointLike | Point.PointData, dy?: number): Point {
if (typeof dx === 'number') {
return new Point(this.x - dx, this.y - dy!)
}
const p = Point.create(dx)
return new Point(this.x - p.x, this.y - p.y)
}
/**
* Returns an interpolation between me and point `p` for a parametert in
* the closed interval `[0, 1]`.
*/
lerp(p: Point.PointLike | Point.PointData, t: number) {
const ref = Point.create(p)
return new Point((1 - t) * this.x + t * ref.x, (1 - t) * this.y + t * ref.y)
}
/**
* Normalize the point vector, scale the line segment between `(0, 0)`
* and the point in order for it to have the given length. If length is
* not specified, it is considered to be `1`; in that case, a unit vector
* is computed.
*/
normalize(length = 1) {
const scale = length / this.magnitude()
return this.scale(scale, scale)
}
/**
* Moves this point along the line starting from `ref` to this point by a
* certain `distance`.
*/
move(ref: Point.PointLike | Point.PointData, distance: number) {
const p = Point.create(ref)
const rad = Angle.toRad(p.theta(this))
return this.translate(Math.cos(rad) * distance, -Math.sin(rad) * distance)
}
/**
* Returns a point that is the reflection of me with the center of inversion
* in `ref` point.
*/
reflection(ref: Point.PointLike | Point.PointData) {
return Point.create(ref).move(this, this.distance(ref))
}
/**
* Snaps the point(change its x and y coordinates) to a grid of size `gridSize`
* (or `gridSize` x `gridSizeY` for non-uniform grid).
*/
snapToGrid(gridSize: number): this
snapToGrid(gx: number, gy: number): this
snapToGrid(gx: number, gy?: number): this
snapToGrid(gx: number, gy?: number): this {
this.x = Util.snapToGrid(this.x, gx)
this.y = Util.snapToGrid(this.y, gy == null ? gx : gy)
return this
}
equals(p: Point.PointLike | Point.PointData) {
const ref = Point.create(p)
return ref != null && ref.x === this.x && ref.y === this.y
}
clone() {
return Point.clone(this)
}
/**
* Returns the point as a simple JSON object. For example: `{ x: 0, y: 0 }`.
*/
toJSON() {
return Point.toJSON(this)
}
serialize() {
return `${this.x} ${this.y}`
}
}
export namespace Point {
export const toStringTag = `X6.Geometry.${Point.name}`
export function isPoint(instance: any): instance is Point {
if (instance == null) {
return false
}
if (instance instanceof Point) {
return true
}
const tag = instance[Symbol.toStringTag]
const point = instance as Point
if (
(tag == null || tag === toStringTag) &&
typeof point.x === 'number' &&
typeof point.y === 'number' &&
typeof point.toPolar === 'function'
) {
return true
}
return false
}
}
export namespace Point {
export interface PointLike {
x: number
y: number
}
export type PointData = [number, number]
export type Bearing = 'NE' | 'E' | 'SE' | 'S' | 'SW' | 'W' | 'NW' | 'N'
export function isPointLike(p: any): p is PointLike {
return (
p != null &&
typeof p === 'object' &&
typeof p.x === 'number' &&
typeof p.y === 'number'
)
}
export function isPointData(p: any): p is PointData {
return (
p != null &&
Array.isArray(p) &&
p.length === 2 &&
typeof p[0] === 'number' &&
typeof p[1] === 'number'
)
}
}
export namespace Point {
export function create(
x?: number | Point | PointLike | PointData,
y?: number,
): Point {
if (x == null || typeof x === 'number') {
return new Point(x, y)
}
return clone(x)
}
export function clone(p: Point | PointLike | PointData) {
if (Point.isPoint(p)) {
return new Point(p.x, p.y)
}
if (Array.isArray(p)) {
return new Point(p[0], p[1])
}
return new Point(p.x, p.y)
}
export function toJSON(p: Point | PointLike | PointData) {
if (Point.isPoint(p)) {
return { x: p.x, y: p.y }
}
if (Array.isArray(p)) {
return { x: p[0], y: p[1] }
}
return { x: p.x, y: p.y }
}
/**
* Returns a new Point object from the given polar coordinates.
* @see http://en.wikipedia.org/wiki/Polar_coordinate_system
*/
export function fromPolar(
r: number,
rad: number,
origin: Point | PointLike | PointData = new Point(),
) {
let x = Math.abs(r * Math.cos(rad))
let y = Math.abs(r * Math.sin(rad))
const org = clone(origin)
const deg = Angle.normalize(Angle.toDeg(rad))
if (deg < 90) {
y = -y
} else if (deg < 180) {
x = -x
y = -y
} else if (deg < 270) {
x = -x
}
return new Point(org.x + x, org.y + y)
}
/**
* Converts rectangular to polar coordinates.
*/
export function toPolar(
point: Point | PointLike | PointData,
origin: Point | PointLike | PointData = new Point(),
) {
const p = clone(point)
const o = clone(origin)
const dx = p.x - o.x
const dy = p.y - o.y
return new Point(
Math.sqrt(dx * dx + dy * dy), // r
Angle.toRad(o.theta(p)),
)
}
export function equals(p1?: Point.PointLike, p2?: Point.PointLike) {
if (p1 === p2) {
return true
}
if (p1 != null && p2 != null) {
return p1.x === p2.x && p1.y === p2.y
}
return false
}
export function equalPoints(p1: Point.PointLike[], p2: Point.PointLike[]) {
if (
(p1 == null && p2 != null) ||
(p1 != null && p2 == null) ||
(p1 != null && p2 != null && p1.length !== p2.length)
) {
return false
}
if (p1 != null && p2 != null) {
for (let i = 0, ii = p1.length; i < ii; i += 1) {
if (!equals(p1[i], p2[i])) {
return false
}
}
}
return true
}
/**
* Returns a point with random coordinates that fall within the range
* `[x1, x2]` and `[y1, y2]`.
*/
export function random(x1: number, x2: number, y1: number, y2: number) {
return new Point(Util.random(x1, x2), Util.random(y1, y2))
}
export function rotate(
point: Point | PointLike | PointData,
angle: number,
center?: Point | PointLike | PointData,
) {
const rad = Angle.toRad(Angle.normalize(-angle))
const sin = Math.sin(rad)
const cos = Math.cos(rad)
return rotateEx(point, cos, sin, center)
}
export function rotateEx(
point: Point | PointLike | PointData,
cos: number,
sin: number,
center: Point | PointLike | PointData = new Point(),
) {
const source = clone(point)
const origin = clone(center)
const dx = source.x - origin.x
const dy = source.y - origin.y
const x1 = dx * cos - dy * sin
const y1 = dy * cos + dx * sin
return new Point(x1 + origin.x, y1 + origin.y)
}
}

View File

@ -0,0 +1,670 @@
import { Line } from './line'
import { Point } from './point'
import { Rectangle } from './rectangle'
import { Geometry } from './geometry'
export class Polyline extends Geometry {
points: Point[]
protected get [Symbol.toStringTag]() {
return Polyline.toStringTag
}
get start() {
if (this.points.length === 0) {
return null
}
return this.points[0]
}
get end() {
if (this.points.length === 0) {
return null
}
return this.points[this.points.length - 1]
}
constructor(points?: (Point.PointLike | Point.PointData)[] | string) {
super()
if (points != null) {
if (typeof points === 'string') {
return Polyline.parse(points)
}
this.points = points.map((p) => Point.create(p))
} else {
this.points = []
}
}
scale(
sx: number,
sy: number,
origin: Point.PointLike | Point.PointData = new Point(),
) {
this.points.forEach((p) => p.scale(sx, sy, origin))
return this
}
rotate(angle: number, origin?: Point.PointLike | Point.PointData) {
this.points.forEach((p) => p.rotate(angle, origin))
return this
}
translate(dx: number, dy: number): this
translate(p: Point.PointLike | Point.PointData): this
translate(dx: number | Point.PointLike | Point.PointData, dy?: number): this {
const t = Point.create(dx, dy)
this.points.forEach((p) => p.translate(t.x, t.y))
return this
}
bbox() {
if (this.points.length === 0) {
return new Rectangle()
}
let x1 = Infinity
let x2 = -Infinity
let y1 = Infinity
let y2 = -Infinity
const points = this.points
for (let i = 0, ii = points.length; i < ii; i += 1) {
const point = points[i]
const x = point.x
const y = point.y
if (x < x1) x1 = x
if (x > x2) x2 = x
if (y < y1) y1 = y
if (y > y2) y2 = y
}
return new Rectangle(x1, y1, x2 - x1, y2 - y1)
}
closestPoint(p: Point.PointLike | Point.PointData) {
const cpLength = this.closestPointLength(p)
return this.pointAtLength(cpLength)
}
closestPointLength(p: Point.PointLike | Point.PointData) {
const points = this.points
const count = points.length
if (count === 0 || count === 1) {
return 0
}
let length = 0
let cpLength = 0
let minSqrDistance = Infinity
for (let i = 0, ii = count - 1; i < ii; i += 1) {
const line = new Line(points[i], points[i + 1])
const lineLength = line.length()
const cpNormalizedLength = line.closestPointNormalizedLength(p)
const cp = line.pointAt(cpNormalizedLength)
const sqrDistance = cp.squaredDistance(p)
if (sqrDistance < minSqrDistance) {
minSqrDistance = sqrDistance
cpLength = length + cpNormalizedLength * lineLength
}
length += lineLength
}
return cpLength
}
closestPointNormalizedLength(p: Point.PointLike | Point.PointData) {
const cpLength = this.closestPointLength(p)
if (cpLength === 0) {
return 0
}
const length = this.length()
if (length === 0) {
return 0
}
return cpLength / length
}
closestPointTangent(p: Point.PointLike | Point.PointData) {
const cpLength = this.closestPointLength(p)
return this.tangentAtLength(cpLength)
}
containsPoint(p: Point.PointLike | Point.PointData) {
if (this.points.length === 0) {
return false
}
const ref = Point.clone(p)
const x = ref.x
const y = ref.y
const points = this.points
const count = points.length
let startIndex = count - 1
let intersectionCount = 0
for (let endIndex = 0; endIndex < count; endIndex += 1) {
const start = points[startIndex]
const end = points[endIndex]
if (ref.equals(start)) {
return true
}
const segment = new Line(start, end)
if (segment.containsPoint(p)) {
return true
}
// do we have an intersection?
if ((y <= start.y && y > end.y) || (y > start.y && y <= end.y)) {
// this conditional branch IS NOT entered when `segment` is collinear/coincident with `ray`
// (when `y === start.y === end.y`)
// this conditional branch IS entered when `segment` touches `ray` at only one point
// (e.g. when `y === start.y !== end.y`)
// since this branch is entered again for the following segment, the two touches cancel out
const xDifference = start.x - x > end.x - x ? start.x - x : end.x - x
if (xDifference >= 0) {
// segment lies at least partially to the right of `p`
const rayEnd = new Point(x + xDifference, y) // right
const ray = new Line(p, rayEnd)
if (segment.intersectsWithLine(ray)) {
// an intersection was detected to the right of `p`
intersectionCount += 1
}
} // else: `segment` lies completely to the left of `p` (i.e. no intersection to the right)
}
// move to check the next polyline segment
startIndex = endIndex
}
// returns `true` for odd numbers of intersections (even-odd algorithm)
return intersectionCount % 2 === 1
}
intersectsWithLine(line: Line) {
const intersections = []
for (let i = 0, n = this.points.length - 1; i < n; i += 1) {
const a = this.points[i]
const b = this.points[i + 1]
const int = line.intersectsWithLine(new Line(a, b))
if (int) {
intersections.push(int)
}
}
return intersections.length > 0 ? intersections : null
}
isDifferentiable() {
for (let i = 0, ii = this.points.length - 1; i < ii; i += 1) {
const a = this.points[i]
const b = this.points[i + 1]
const line = new Line(a, b)
if (line.isDifferentiable()) {
return true
}
}
return false
}
length() {
let len = 0
for (let i = 0, ii = this.points.length - 1; i < ii; i += 1) {
const a = this.points[i]
const b = this.points[i + 1]
len += a.distance(b)
}
return len
}
pointAt(ratio: number) {
const points = this.points
const count = points.length
if (count === 0) {
return null
}
if (count === 1) {
return points[0].clone()
}
if (ratio <= 0) {
return points[0].clone()
}
if (ratio >= 1) {
return points[count - 1].clone()
}
const total = this.length()
const length = total * ratio
return this.pointAtLength(length)
}
pointAtLength(length: number) {
const points = this.points
const count = points.length
if (count === 0) {
return null
}
if (count === 1) {
return points[0].clone()
}
let fromStart = true
if (length < 0) {
fromStart = false
length = -length // eslint-disable-line
}
let tmp = 0
for (let i = 0, ii = count - 1; i < ii; i += 1) {
const index = fromStart ? i : ii - 1 - i
const a = points[index]
const b = points[index + 1]
const l = new Line(a, b)
const d = a.distance(b)
if (length <= tmp + d) {
return l.pointAtLength((fromStart ? 1 : -1) * (length - tmp))
}
tmp += d
}
const lastPoint = fromStart ? points[count - 1] : points[0]
return lastPoint.clone()
}
tangentAt(ratio: number) {
const points = this.points
const count = points.length
if (count === 0 || count === 1) {
return null
}
if (ratio < 0) {
ratio = 0 // eslint-disable-line
}
if (ratio > 1) {
ratio = 1 // eslint-disable-line
}
const total = this.length()
const length = total * ratio
return this.tangentAtLength(length)
}
tangentAtLength(length: number) {
const points = this.points
const count = points.length
if (count === 0 || count === 1) {
return null
}
let fromStart = true
if (length < 0) {
fromStart = false
length = -length // eslint-disable-line
}
let lastValidLine
let tmp = 0
for (let i = 0, ii = count - 1; i < ii; i += 1) {
const index = fromStart ? i : ii - 1 - i
const a = points[index]
const b = points[index + 1]
const l = new Line(a, b)
const d = a.distance(b)
if (l.isDifferentiable()) {
// has a tangent line (line length is not 0)
if (length <= tmp + d) {
return l.tangentAtLength((fromStart ? 1 : -1) * (length - tmp))
}
lastValidLine = l
}
tmp += d
}
if (lastValidLine) {
const ratio = fromStart ? 1 : 0
return lastValidLine.tangentAt(ratio)
}
return null
}
simplify(
// TODO: Accept startIndex and endIndex to specify where to start and end simplification
options: {
/**
* The max distance of middle point from chord to be simplified.
*/
threshold?: number
} = {},
) {
const points = this.points
// we need at least 3 points
if (points.length < 3) {
return this
}
const threshold = options.threshold || 0
// start at the beginning of the polyline and go forward
let currentIndex = 0
// we need at least one intermediate point (3 points) in every iteration
// as soon as that stops being true, we know we reached the end of the polyline
while (points[currentIndex + 2]) {
const firstIndex = currentIndex
const middleIndex = currentIndex + 1
const lastIndex = currentIndex + 2
const firstPoint = points[firstIndex]
const middlePoint = points[middleIndex]
const lastPoint = points[lastIndex]
const chord = new Line(firstPoint, lastPoint) // = connection between first and last point
const closestPoint = chord.closestPoint(middlePoint) // = closest point on chord from middle point
const closestPointDistance = closestPoint.distance(middlePoint)
if (closestPointDistance <= threshold) {
// middle point is close enough to the chord = simplify
// 1) remove middle point:
points.splice(middleIndex, 1)
// 2) in next iteration, investigate the newly-created triplet of points
// - do not change `currentIndex`
// = (first point stays, point after removed point becomes middle point)
} else {
// middle point is far from the chord
// 1) preserve middle point
// 2) in next iteration, move `currentIndex` by one step:
currentIndex += 1
// = (point after first point becomes first point)
}
}
// `points` array was modified in-place
return this
}
toHull() {
const points = this.points
const count = points.length
if (count === 0) {
return new Polyline()
}
// Step 1: find the starting point -- point with
// the lowest y (if equality, highest x).
let startPoint: Point = points[0]
for (let i = 1; i < count; i += 1) {
if (points[i].y < startPoint.y) {
startPoint = points[i]
} else if (points[i].y === startPoint.y && points[i].x > startPoint.x) {
startPoint = points[i]
}
}
// Step 2: sort the list of points by angle between line
// from start point to current point and the x-axis (theta).
// Step 2a: create the point records = [point, originalIndex, angle]
const sortedRecords: Types.HullRecord[] = []
for (let i = 0; i < count; i += 1) {
let angle = startPoint.theta(points[i])
if (angle === 0) {
// Give highest angle to start point.
// The start point will end up at end of sorted list.
// The start point will end up at beginning of hull points list.
angle = 360
}
sortedRecords.push([points[i], i, angle])
}
// Step 2b: sort the list in place
sortedRecords.sort((record1, record2) => {
let ret = record1[2] - record2[2]
if (ret === 0) {
ret = record2[1] - record1[1]
}
return ret
})
// Step 2c: duplicate start record from the top of
// the stack to the bottom of the stack.
if (sortedRecords.length > 2) {
const startPoint = sortedRecords[sortedRecords.length - 1]
sortedRecords.unshift(startPoint)
}
// Step 3
// ------
// Step 3a: go through sorted points in order and find those with
// right turns, and we want to get our results in clockwise order.
// Dictionary of points with left turns - cannot be on the hull.
const insidePoints: { [key: string]: Point } = {}
// Stack of records with right turns - hull point candidates.
const hullRecords: Types.HullRecord[] = []
const getKey = (record: Types.HullRecord) =>
`${record[0].toString()}@${record[1]}`
while (sortedRecords.length !== 0) {
const currentRecord = sortedRecords.pop()!
const currentPoint = currentRecord[0]
// Check if point has already been discarded.
if (insidePoints[getKey(currentRecord)]) {
continue
}
let correctTurnFound = false
while (!correctTurnFound) {
if (hullRecords.length < 2) {
// Not enough points for comparison, just add current point.
hullRecords.push(currentRecord)
correctTurnFound = true
} else {
const lastHullRecord = hullRecords.pop()!
const lastHullPoint = lastHullRecord[0]
const secondLastHullRecord = hullRecords.pop()!
const secondLastHullPoint = secondLastHullRecord[0]
const crossProduct = secondLastHullPoint.cross(
lastHullPoint,
currentPoint,
)
if (crossProduct < 0) {
// Found a right turn.
hullRecords.push(secondLastHullRecord)
hullRecords.push(lastHullRecord)
hullRecords.push(currentRecord)
correctTurnFound = true
} else if (crossProduct === 0) {
// the three points are collinear
// three options:
// there may be a 180 or 0 degree angle at lastHullPoint
// or two of the three points are coincident
// we have to take rounding errors into account
const THRESHOLD = 1e-10
const angleBetween = lastHullPoint.angleBetween(
secondLastHullPoint,
currentPoint,
)
if (Math.abs(angleBetween - 180) < THRESHOLD) {
// rouding around 180 to 180
// if the cross product is 0 because the angle is 180 degrees
// discard last hull point (add to insidePoints)
// insidePoints.unshift(lastHullPoint);
insidePoints[getKey(lastHullRecord)] = lastHullPoint
// reenter second-to-last hull point (will be last at next iter)
hullRecords.push(secondLastHullRecord)
// do not do anything with current point
// correct turn not found
} else if (
lastHullPoint.equals(currentPoint) ||
secondLastHullPoint.equals(lastHullPoint)
) {
// if the cross product is 0 because two points are the same
// discard last hull point (add to insidePoints)
// insidePoints.unshift(lastHullPoint);
insidePoints[getKey(lastHullRecord)] = lastHullPoint
// reenter second-to-last hull point (will be last at next iter)
hullRecords.push(secondLastHullRecord)
// do not do anything with current point
// correct turn not found
} else if (Math.abs(((angleBetween + 1) % 360) - 1) < THRESHOLD) {
// rounding around 0 and 360 to 0
// if the cross product is 0 because the angle is 0 degrees
// remove last hull point from hull BUT do not discard it
// reenter second-to-last hull point (will be last at next iter)
hullRecords.push(secondLastHullRecord)
// put last hull point back into the sorted point records list
sortedRecords.push(lastHullRecord)
// we are switching the order of the 0deg and 180deg points
// correct turn not found
}
} else {
// found a left turn
// discard last hull point (add to insidePoints)
// insidePoints.unshift(lastHullPoint);
insidePoints[getKey(lastHullRecord)] = lastHullPoint
// reenter second-to-last hull point (will be last at next iter of loop)
hullRecords.push(secondLastHullRecord)
// do not do anything with current point
// correct turn not found
}
}
}
}
// At this point, hullPointRecords contains the output points in clockwise order
// the points start with lowest-y,highest-x startPoint, and end at the same point
// Step 3b: remove duplicated startPointRecord from the end of the array
if (hullRecords.length > 2) {
hullRecords.pop()
}
// Step 4: find the lowest originalIndex record and put it at the beginning of hull
let lowestHullIndex // the lowest originalIndex on the hull
let indexOfLowestHullIndexRecord = -1 // the index of the record with lowestHullIndex
for (let i = 0, n = hullRecords.length; i < n; i += 1) {
const currentHullIndex = hullRecords[i][1]
if (lowestHullIndex === undefined || currentHullIndex < lowestHullIndex) {
lowestHullIndex = currentHullIndex
indexOfLowestHullIndexRecord = i
}
}
let hullPointRecordsReordered = []
if (indexOfLowestHullIndexRecord > 0) {
const newFirstChunk = hullRecords.slice(indexOfLowestHullIndexRecord)
const newSecondChunk = hullRecords.slice(0, indexOfLowestHullIndexRecord)
hullPointRecordsReordered = newFirstChunk.concat(newSecondChunk)
} else {
hullPointRecordsReordered = hullRecords
}
const hullPoints = []
for (let i = 0, n = hullPointRecordsReordered.length; i < n; i += 1) {
hullPoints.push(hullPointRecordsReordered[i][0])
}
return new Polyline(hullPoints)
}
equals(p: Polyline) {
if (p == null) {
return false
}
if (p.points.length !== this.points.length) {
return false
}
return p.points.every((a, i) => a.equals(this.points[i]))
}
clone() {
return new Polyline(this.points.map((p) => p.clone()))
}
toJSON() {
return this.points.map((p) => p.toJSON())
}
serialize() {
return this.points.map((p) => `${p.x}, ${p.y}`).join(' ')
}
}
export namespace Polyline {
export const toStringTag = `X6.Geometry.${Polyline.name}`
export function isPolyline(instance: any): instance is Polyline {
if (instance == null) {
return false
}
if (instance instanceof Polyline) {
return true
}
const tag = instance[Symbol.toStringTag]
const polyline = instance as Polyline
if (
(tag == null || tag === toStringTag) &&
typeof polyline.toHull === 'function' &&
typeof polyline.simplify === 'function'
) {
return true
}
return false
}
}
export namespace Polyline {
export function parse(svgString: string) {
const str = svgString.trim()
if (str === '') {
return new Polyline()
}
const points = []
const coords = str.split(/\s*,\s*|\s+/)
for (let i = 0, ii = coords.length; i < ii; i += 2) {
points.push({ x: +coords[i], y: +coords[i + 1] })
}
return new Polyline(points)
}
}
namespace Types {
export type HullRecord = [Point, number, number]
}

View File

@ -0,0 +1,545 @@
import { Ellipse } from './ellipse'
import { Line } from './line'
import { Point } from './point'
import { Rectangle } from './rectangle'
describe('rectangle', () => {
describe('#constructor', () => {
it('should create a rectangle instance', () => {
expect(new Rectangle()).toBeInstanceOf(Rectangle)
expect(new Rectangle(1)).toBeInstanceOf(Rectangle)
expect(new Rectangle(1, 2)).toBeInstanceOf(Rectangle)
expect(new Rectangle(1, 2).x).toEqual(1)
expect(new Rectangle(1, 2).y).toEqual(2)
expect(new Rectangle(1, 2).width).toEqual(0)
expect(new Rectangle(1, 2).height).toEqual(0)
expect(new Rectangle().equals(new Rectangle(0, 0, 0, 0)))
})
it('should work with key points', () => {
const rect = new Rectangle(1, 2, 3, 4)
expect(rect.origin.equals({ x: 1, y: 2 })).toBeTruthy()
expect(rect.topLeft.equals({ x: 1, y: 2 })).toBeTruthy()
expect(rect.topCenter.equals({ x: 2.5, y: 2 })).toBeTruthy()
expect(rect.topRight.equals({ x: 4, y: 2 })).toBeTruthy()
expect(rect.center.equals({ x: 2.5, y: 4 })).toBeTruthy()
expect(rect.bottomLeft.equals({ x: 1, y: 6 })).toBeTruthy()
expect(rect.bottomCenter.equals({ x: 2.5, y: 6 })).toBeTruthy()
expect(rect.bottomRight.equals({ x: 4, y: 6 })).toBeTruthy()
expect(rect.corner.equals({ x: 4, y: 6 })).toBeTruthy()
expect(rect.leftMiddle.equals({ x: 1, y: 4 })).toBeTruthy()
expect(rect.rightMiddle.equals({ x: 4, y: 4 })).toBeTruthy()
})
it('should return the key points', () => {
const rect = new Rectangle(1, 2, 3, 4)
expect(rect.getOrigin().equals({ x: 1, y: 2 })).toBeTruthy()
expect(rect.getTopLeft().equals({ x: 1, y: 2 })).toBeTruthy()
expect(rect.getTopCenter().equals({ x: 2.5, y: 2 })).toBeTruthy()
expect(rect.getTopRight().equals({ x: 4, y: 2 })).toBeTruthy()
expect(rect.getCenter().equals({ x: 2.5, y: 4 })).toBeTruthy()
expect(rect.getBottomLeft().equals({ x: 1, y: 6 })).toBeTruthy()
expect(rect.getBottomCenter().equals({ x: 2.5, y: 6 })).toBeTruthy()
expect(rect.getBottomRight().equals({ x: 4, y: 6 })).toBeTruthy()
expect(rect.getCorner().equals({ x: 4, y: 6 })).toBeTruthy()
expect(rect.getLeftMiddle().equals({ x: 1, y: 4 })).toBeTruthy()
expect(rect.getRightMiddle().equals({ x: 4, y: 4 })).toBeTruthy()
expect(rect.getCenterX()).toEqual(2.5)
expect(rect.getCenterY()).toEqual(4)
})
it('should work with key lines', () => {
const rect = new Rectangle(1, 2, 3, 4)
expect(rect.topLine.equals(new Line(1, 2, 4, 2)))
expect(rect.rightLine.equals(new Line(4, 2, 4, 6)))
expect(rect.bottomLine.equals(new Line(1, 6, 4, 6)))
expect(rect.leftLine.equals(new Line(1, 2, 1, 6)))
})
it('should return the key lines', () => {
const rect = new Rectangle(1, 2, 3, 4)
expect(rect.getTopLine().equals(new Line(1, 2, 4, 2)))
expect(rect.getRightLine().equals(new Line(4, 2, 4, 6)))
expect(rect.getBottomLine().equals(new Line(1, 6, 4, 6)))
expect(rect.getLeftLine().equals(new Line(1, 2, 1, 6)))
})
})
describe('#Rectangle.clone', () => {
it('should clone rectangle', () => {
const obj = { x: 1, y: 2, width: 3, height: 4 }
expect(Rectangle.clone(new Rectangle(1, 2, 3, 4)).toJSON()).toEqual(obj)
expect(Rectangle.clone(obj).toJSON()).toEqual(obj)
expect(Rectangle.clone([1, 2, 3, 4]).toJSON()).toEqual(obj)
})
})
describe('#Rectangle.isRectangleLike', () => {
it('should return true if the given object is a rectangle-like object', () => {
const obj = { x: 1, y: 2, width: 3, height: 4 }
expect(Rectangle.isRectangleLike(obj)).toBeTruthy()
expect(Rectangle.isRectangleLike({ ...obj, z: 10 })).toBeTruthy()
expect(Rectangle.isRectangleLike({ ...obj, z: 10, s: 's' })).toBeTruthy()
})
it('should return false if the given object is a rectangle-like object', () => {
expect(Rectangle.isRectangleLike({ x: 1 })).toBeFalsy()
expect(Rectangle.isRectangleLike({ y: 2 })).toBeFalsy()
expect(Rectangle.isRectangleLike({})).toBeFalsy()
expect(Rectangle.isRectangleLike(null)).toBeFalsy()
expect(Rectangle.isRectangleLike(false)).toBeFalsy()
expect(Rectangle.isRectangleLike(1)).toBeFalsy()
expect(Rectangle.isRectangleLike('s')).toBeFalsy()
})
})
describe('#Rectangle.fromSize', () => {
it('should create a rectangle from the given size', () => {
expect(Rectangle.fromSize({ width: 10, height: 8 }).toJSON()).toEqual({
x: 0,
y: 0,
width: 10,
height: 8,
})
})
})
describe('#Rectangle.fromPositionAndSize', () => {
it('should create a rectangle from the given position and size', () => {
expect(
Rectangle.fromPositionAndSize(
{ x: 2, y: 5 },
{ width: 10, height: 8 },
).toJSON(),
).toEqual({
x: 2,
y: 5,
width: 10,
height: 8,
})
})
})
describe('#Rectangle.fromEllipse', () => {
it('should create a rectangle from the given ellipse', () => {
expect(Rectangle.fromEllipse(new Ellipse(1, 2, 3, 4)).toJSON()).toEqual({
x: -2,
y: -2,
width: 6,
height: 8,
})
})
})
describe('#valueOf', () => {
it('should return JSON object', () => {
const obj = { x: 1, y: 2, width: 3, height: 4 }
const rect = Rectangle.create(obj)
expect(rect.valueOf()).toEqual(obj)
})
})
describe('#toString', () => {
it('should return JSON string', () => {
const obj = { x: 1, y: 2, width: 3, height: 4 }
const rect = Rectangle.create(obj)
expect(rect.toString()).toEqual(JSON.stringify(obj))
})
})
describe('#bbox', () => {
it('should return a rectangle that is the bounding box of the rectangle.', () => {
const rect = new Rectangle(1, 2, 3, 4)
expect(rect.bbox().equals(rect)).toBeTruthy()
})
it('should rotate the rectangle if angle specified.', () => {
const rect = new Rectangle(0, 0, 2, 4)
expect(
rect.bbox(90).round().equals(new Rectangle(-1, 1, 4, 2)),
).toBeTruthy()
})
})
describe('#add', () => {
it('should add the given `rect` to me', () => {
const rect = new Rectangle(1, 2, 3, 4)
expect(rect.add(0, 0, 0, 0)).toEqual(new Rectangle(0, 0, 4, 6))
})
})
describe('#update', () => {
it('should update me with the given `rect`', () => {
const rect = new Rectangle(1, 2, 3, 4)
expect(rect.update(0, 0, 1, 1)).toEqual(new Rectangle(0, 0, 1, 1))
})
})
describe('#inflate', () => {
it('should inflate me with the given `amount`', () => {
const rect = new Rectangle(1, 2, 3, 4)
expect(rect.inflate(2)).toEqual(new Rectangle(-1, 0, 7, 8))
})
it('should inflate me with the given `dx` and `dy`', () => {
const rect = new Rectangle(1, 2, 3, 4)
expect(rect.inflate(2, 1)).toEqual(new Rectangle(-1, 1, 7, 6))
})
})
describe('#snapToGrid', () => {
it('should snap to grid', () => {
const rect1 = new Rectangle(2, 6, 33, 44)
const rect2 = rect1.clone().snapToGrid(10)
const rect3 = rect1.clone().snapToGrid(3, 5)
expect(rect2.equals({ x: 0, y: 10, width: 40, height: 40 })).toBeTruthy()
expect(rect3.equals({ x: 3, y: 5, width: 33, height: 45 })).toBeTruthy()
})
})
describe('#translate', () => {
it('should translate x and y by adding the given `dx` and `dy` values respectively', () => {
const rect = new Rectangle(1, 2, 3, 4)
expect(rect.clone().translate(2, 3).toJSON()).toEqual({
x: 3,
y: 5,
width: 3,
height: 4,
})
expect(rect.clone().translate(new Point(-2, 4)).toJSON()).toEqual({
x: -1,
y: 6,
width: 3,
height: 4,
})
})
})
describe('#scale', () => {
it('should scale point with the given amount', () => {
expect(new Rectangle(1, 2, 3, 4).scale(2, 3).toJSON()).toEqual({
x: 2,
y: 6,
width: 6,
height: 12,
})
})
it('should scale point with the given amount and center ', () => {
expect(
new Rectangle(20, 30, 10, 20).scale(2, 3, new Point(40, 45)).toJSON(),
).toEqual({ x: 0, y: 0, width: 20, height: 60 })
})
})
describe('#rotate', () => {
it('should rorate the rect by the given angle', () => {
expect(new Rectangle(1, 2, 3, 4).rotate(180).round().toJSON()).toEqual({
x: 1,
y: 2,
width: 3,
height: 4,
})
})
it('should keep the same when the given angle is `0`', () => {
expect(new Rectangle(1, 2, 3, 4).rotate(0).toJSON()).toEqual({
x: 1,
y: 2,
width: 3,
height: 4,
})
})
})
describe('#rotate90', () => {
it("should rorate the rect by 90deg around it's center", () => {
expect(new Rectangle(1, 2, 3, 4).rotate90().toJSON()).toEqual({
x: 0.5,
y: 2.5,
width: 4,
height: 3,
})
})
})
describe('#getMaxScaleToFit', () => {
it('should return the scale amount', () => {
const scale1 = new Rectangle(1, 2, 3, 4).getMaxScaleToFit([5, 6, 7, 8])
const scale2 = new Rectangle(1, 2, 3, 4).getMaxScaleToFit([0, 0, 7, 8])
expect(scale1.sx.toFixed(2)).toEqual('0.16')
expect(scale1.sy.toFixed(2)).toEqual('0.20')
expect(scale2.sx.toFixed(2)).toEqual('0.33')
expect(scale2.sy.toFixed(2)).toEqual('0.50')
})
})
describe('#getMaxUniformScaleToFit', () => {
it('should return the scale amount', () => {
const s1 = new Rectangle(1, 2, 3, 4).getMaxUniformScaleToFit([5, 6, 7, 8])
const s2 = new Rectangle(1, 2, 3, 4).getMaxUniformScaleToFit([0, 0, 7, 8])
expect(s1.toFixed(2)).toEqual('0.16')
expect(s2.toFixed(2)).toEqual('0.33')
})
})
describe('#moveAndExpand', () => {
it('should translate and expand me by the given `rect`', () => {
expect(
new Rectangle(1, 2, 3, 4)
.moveAndExpand(new Rectangle(1, 2, 3, 4))
.toJSON(),
).toEqual({ x: 2, y: 4, width: 6, height: 8 })
expect(
new Rectangle(1, 2, 3, 4).moveAndExpand(new Rectangle()).toJSON(),
).toEqual({ x: 1, y: 2, width: 3, height: 4 })
})
})
describe('#containsPoint', () => {
it('should return true when rect contains the given point', () => {
expect(new Rectangle(50, 50, 100, 100).containsPoint(60, 60)).toBeTruthy()
})
})
describe('#containsRect', () => {
it('should return true when rect is completely inside the other rect', () => {
expect(
new Rectangle(50, 50, 100, 100).containsRect(60, 60, 80, 80),
).toBeTruthy()
})
it('should return true when rect is equal the other rect', () => {
expect(
new Rectangle(50, 50, 100, 100).containsRect({
x: 50,
y: 50,
width: 100,
height: 100,
}),
).toBeTruthy()
})
it('should return false when rect is not inside the other rect', () => {
expect(
new Rectangle(50, 50, 100, 100).containsRect(20, 20, 200, 200),
).toBeFalsy()
expect(
new Rectangle(50, 50, 100, 100).containsRect(40, 40, 100, 100),
).toBeFalsy()
expect(
new Rectangle(50, 50, 100, 100).containsRect(60, 60, 100, 40),
).toBeFalsy()
expect(
new Rectangle(50, 50, 100, 100).containsRect(60, 60, 100, 100),
).toBeFalsy()
expect(
new Rectangle(50, 50, 100, 100).containsRect(60, 60, 40, 100),
).toBeFalsy()
})
it('should return false when one of the dimensions is `0`', () => {
expect(
new Rectangle(50, 50, 100, 100).containsRect(75, 75, 0, 0),
).toBeFalsy()
expect(new Rectangle(50, 50, 0, 0).containsRect(50, 50, 0, 0)).toBeFalsy()
})
})
describe('#intersectsWithRect', () => {
it('should return the intersection', () => {
// inside
expect(
new Rectangle(20, 20, 100, 100)
.intersectsWithRect([40, 40, 20, 20])
?.toJSON(),
).toEqual({ x: 40, y: 40, width: 20, height: 20 })
expect(
new Rectangle(20, 20, 100, 100)
.intersectsWithRect([0, 0, 100, 100])
?.toJSON(),
).toEqual({ x: 20, y: 20, width: 80, height: 80 })
expect(
new Rectangle(20, 20, 100, 100)
.intersectsWithRect([40, 40, 100, 100])
?.toJSON(),
).toEqual({ x: 40, y: 40, width: 80, height: 80 })
})
it('should return null when no intersection', () => {
expect(
new Rectangle(20, 20, 100, 100).intersectsWithRect([140, 140, 20, 20]),
).toBeNull()
})
})
describe('#intersectsWithLine', () => {
it('should return the intersection points', () => {
const points1 = new Rectangle(0, 0, 4, 4).intersectsWithLine(
new Line(2, 2, 2, 8),
)
const points2 = new Rectangle(0, 0, 4, 4).intersectsWithLine(
new Line(2, -2, 2, 8),
)
expect(Point.equalPoints(points1!, [{ x: 2, y: 4 }]))
expect(
Point.equalPoints(points2!, [
{ x: 2, y: 0 },
{ x: 2, y: 4 },
]),
)
})
it('should return null when no intersection exists', () => {
expect(
new Rectangle(0, 0, 4, 4).intersectsWithLine(new Line(-2, -2, -2, -8)),
).toBeNull()
})
})
describe('#intersectsWithLineFromCenterToPoint', () => {
it('should return the intersection point', () => {
expect(
new Rectangle(0, 0, 4, 4)
.intersectsWithLineFromCenterToPoint([2, 8])
?.equals([2, 4]),
).toBeTruthy()
expect(
new Rectangle(0, 0, 4, 4)
.intersectsWithLineFromCenterToPoint([2, 8], 90)
?.round()
.equals([2, 4]),
).toBeTruthy()
})
it('should return null when no intersection exists', () => {
expect(
new Rectangle(0, 0, 4, 4).intersectsWithLineFromCenterToPoint([3, 3]),
).toBeNull()
})
})
describe('#normalize', () => {
it('should keep the same when width and height is positive', () => {
expect(new Rectangle(1, 2, 3, 4).normalize().toJSON()).toEqual({
x: 1,
y: 2,
width: 3,
height: 4,
})
})
it('should make the width positive', () => {
expect(new Rectangle(1, 2, -3, 4).normalize().toJSON()).toEqual({
x: -2,
y: 2,
width: 3,
height: 4,
})
})
it('should make the height positive', () => {
expect(new Rectangle(1, 2, 3, -4).normalize().toJSON()).toEqual({
x: 1,
y: -2,
width: 3,
height: 4,
})
})
})
describe('#union', () => {})
describe('#getNearestSideToPoint', () => {
it('should return the nearest side to point when point is on the side', () => {
const rect = new Rectangle(0, 0, 4, 4)
expect(rect.getNearestSideToPoint({ x: 0, y: 0 })).toEqual('left')
expect(rect.getNearestSideToPoint({ x: 4, y: 4 })).toEqual('right')
expect(rect.getNearestSideToPoint({ x: 0, y: 4 })).toEqual('left')
expect(rect.getNearestSideToPoint({ x: 4, y: 0 })).toEqual('right')
expect(rect.getNearestSideToPoint({ x: 2, y: 0 })).toEqual('top')
expect(rect.getNearestSideToPoint({ x: 0, y: 2 })).toEqual('left')
expect(rect.getNearestSideToPoint({ x: 4, y: 2 })).toEqual('right')
expect(rect.getNearestSideToPoint({ x: 2, y: 4 })).toEqual('bottom')
})
it('should return the nearest side to point when point is outside', () => {
const rect = new Rectangle(0, 0, 4, 4)
expect(rect.getNearestSideToPoint({ x: 5, y: 5 })).toEqual('right')
expect(rect.getNearestSideToPoint({ x: 5, y: 4 })).toEqual('right')
expect(rect.getNearestSideToPoint({ x: 5, y: 2 })).toEqual('right')
expect(rect.getNearestSideToPoint({ x: 5, y: 0 })).toEqual('right')
expect(rect.getNearestSideToPoint({ x: 5, y: -1 })).toEqual('right')
expect(rect.getNearestSideToPoint({ x: -1, y: 5 })).toEqual('left')
expect(rect.getNearestSideToPoint({ x: -1, y: 4 })).toEqual('left')
expect(rect.getNearestSideToPoint({ x: -1, y: 2 })).toEqual('left')
expect(rect.getNearestSideToPoint({ x: -1, y: 0 })).toEqual('left')
expect(rect.getNearestSideToPoint({ x: -1, y: -1 })).toEqual('left')
expect(rect.getNearestSideToPoint({ x: 0, y: 5 })).toEqual('bottom')
expect(rect.getNearestSideToPoint({ x: 2, y: 5 })).toEqual('bottom')
expect(rect.getNearestSideToPoint({ x: 4, y: 5 })).toEqual('bottom')
expect(rect.getNearestSideToPoint({ x: 0, y: -1 })).toEqual('top')
expect(rect.getNearestSideToPoint({ x: 2, y: -1 })).toEqual('top')
expect(rect.getNearestSideToPoint({ x: 4, y: -1 })).toEqual('top')
})
it('should return the nearest side to point when point is inside', () => {
const rect = new Rectangle(0, 0, 4, 4)
expect(rect.getNearestSideToPoint({ x: 2, y: 1 })).toEqual('top')
expect(rect.getNearestSideToPoint({ x: 3, y: 2 })).toEqual('right')
expect(rect.getNearestSideToPoint({ x: 2, y: 3 })).toEqual('bottom')
expect(rect.getNearestSideToPoint({ x: 1, y: 2 })).toEqual('left')
})
})
describe('#getNearestPointToPoint', () => {
it('should return the nearest point to point when point is inside the rect', () => {
const rect = new Rectangle(0, 0, 4, 4)
// left
expect(rect.getNearestPointToPoint({ x: 1, y: 2 }).toJSON()).toEqual({
x: 0,
y: 2,
})
// right
expect(rect.getNearestPointToPoint({ x: 3, y: 2 }).toJSON()).toEqual({
x: 4,
y: 2,
})
// top
expect(rect.getNearestPointToPoint({ x: 2, y: 1 }).toJSON()).toEqual({
x: 2,
y: 0,
})
// bottom
expect(rect.getNearestPointToPoint({ x: 2, y: 3 }).toJSON()).toEqual({
x: 2,
y: 4,
})
})
it('should return the nearest point to point when point is outside the rect', () => {
const rect = new Rectangle(0, 0, 4, 4)
expect(rect.getNearestPointToPoint({ x: 5, y: 5 }).toJSON()).toEqual({
x: 4,
y: 4,
})
expect(rect.getNearestPointToPoint({ x: -1, y: -1 }).toJSON()).toEqual({
x: 0,
y: 0,
})
})
})
describe('#serialize', () => {
it('should return the serialized string', () => {
expect(new Rectangle(1, 2, 3, 4).serialize()).toEqual('1 2 3 4')
})
})
})

View File

@ -0,0 +1,855 @@
import { Util } from './util'
import { Angle } from './angle'
import { Line } from './line'
import { Point } from './point'
import { Ellipse } from './ellipse'
import { Geometry } from './geometry'
export class Rectangle extends Geometry implements Rectangle.RectangleLike {
x: number
y: number
width: number
height: number
protected get [Symbol.toStringTag]() {
return Rectangle.toStringTag
}
get left() {
return this.x
}
get top() {
return this.y
}
get right() {
return this.x + this.width
}
get bottom() {
return this.y + this.height
}
get origin() {
return new Point(this.x, this.y)
}
get topLeft() {
return new Point(this.x, this.y)
}
get topCenter() {
return new Point(this.x + this.width / 2, this.y)
}
get topRight() {
return new Point(this.x + this.width, this.y)
}
get center() {
return new Point(this.x + this.width / 2, this.y + this.height / 2)
}
get bottomLeft() {
return new Point(this.x, this.y + this.height)
}
get bottomCenter() {
return new Point(this.x + this.width / 2, this.y + this.height)
}
get bottomRight() {
return new Point(this.x + this.width, this.y + this.height)
}
get corner() {
return new Point(this.x + this.width, this.y + this.height)
}
get rightMiddle() {
return new Point(this.x + this.width, this.y + this.height / 2)
}
get leftMiddle() {
return new Point(this.x, this.y + this.height / 2)
}
get topLine() {
return new Line(this.topLeft, this.topRight)
}
get rightLine() {
return new Line(this.topRight, this.bottomRight)
}
get bottomLine() {
return new Line(this.bottomLeft, this.bottomRight)
}
get leftLine() {
return new Line(this.topLeft, this.bottomLeft)
}
constructor(x?: number, y?: number, width?: number, height?: number) {
super()
this.x = x == null ? 0 : x
this.y = y == null ? 0 : y
this.width = width == null ? 0 : width
this.height = height == null ? 0 : height
}
getOrigin() {
return this.origin
}
getTopLeft() {
return this.topLeft
}
getTopCenter() {
return this.topCenter
}
getTopRight() {
return this.topRight
}
getCenter() {
return this.center
}
getCenterX() {
return this.x + this.width / 2
}
getCenterY() {
return this.y + this.height / 2
}
getBottomLeft() {
return this.bottomLeft
}
getBottomCenter() {
return this.bottomCenter
}
getBottomRight() {
return this.bottomRight
}
getCorner() {
return this.corner
}
getRightMiddle() {
return this.rightMiddle
}
getLeftMiddle() {
return this.leftMiddle
}
getTopLine() {
return this.topLine
}
getRightLine() {
return this.rightLine
}
getBottomLine() {
return this.bottomLine
}
getLeftLine() {
return this.leftLine
}
/**
* Returns a rectangle that is the bounding box of the rectangle.
*
* If `angle` is specified, the bounding box calculation will take into
* account the rotation of the rectangle by angle degrees around its center.
*/
bbox(angle?: number) {
if (!angle) {
return this.clone()
}
const rad = Angle.toRad(angle)
const st = Math.abs(Math.sin(rad))
const ct = Math.abs(Math.cos(rad))
const w = this.width * ct + this.height * st
const h = this.width * st + this.height * ct
return new Rectangle(
this.x + (this.width - w) / 2,
this.y + (this.height - h) / 2,
w,
h,
)
}
round(precision = 0) {
this.x = Util.round(this.x, precision)
this.y = Util.round(this.y, precision)
this.width = Util.round(this.width, precision)
this.height = Util.round(this.height, precision)
return this
}
add(x: number, y: number, width: number, height: number): this
add(rect: Rectangle.RectangleLike | Rectangle.RectangleData): this
add(
x: number | Rectangle.RectangleLike | Rectangle.RectangleData,
y?: number,
width?: number,
height?: number,
): this {
const rect = Rectangle.create(x, y, width, height)
const minX = Math.min(this.x, rect.x)
const minY = Math.min(this.y, rect.y)
const maxX = Math.max(this.x + this.width, rect.x + rect.width)
const maxY = Math.max(this.y + this.height, rect.y + rect.height)
this.x = minX
this.y = minY
this.width = maxX - minX
this.height = maxY - minY
return this
}
update(x: number, y: number, width: number, height: number): this
update(rect: Rectangle.RectangleLike | Rectangle.RectangleData): this
update(
x: number | Rectangle.RectangleLike | Rectangle.RectangleData,
y?: number,
width?: number,
height?: number,
): this {
const rect = Rectangle.create(x, y, width, height)
this.x = rect.x
this.y = rect.y
this.width = rect.width
this.height = rect.height
return this
}
inflate(amount: number): this
/**
* Returns a rectangle inflated in axis-x by `2*dx` and in axis-y by `2*dy`.
*/
inflate(dx: number, dy: number): this
inflate(dx: number, dy?: number): this {
const w = dx
const h = dy != null ? dy : dx
this.x -= w
this.y -= h
this.width += 2 * w
this.height += 2 * h
return this
}
/**
* Adjust the position and dimensions of the rectangle such that its edges
* are on the nearest increment of `gx` on the x-axis and `gy` on the y-axis.
*/
snapToGrid(gridSize: number): this
snapToGrid(gx: number, gy: number): this
snapToGrid(gx: number, gy?: number): this
snapToGrid(gx: number, gy?: number): this {
const origin = this.origin.snapToGrid(gx, gy)
const corner = this.corner.snapToGrid(gx, gy)
this.x = origin.x
this.y = origin.y
this.width = corner.x - origin.x
this.height = corner.y - origin.y
return this
}
translate(tx: number, ty: number): this
translate(p: Point.PointLike | Point.PointData): this
translate(tx: number | Point.PointLike | Point.PointData, ty?: number): this {
const p = Point.create(tx, ty)
this.x += p.x
this.y += p.y
return this
}
scale(
sx: number,
sy: number,
origin: Point.PointLike | Point.PointData = new Point(),
) {
const pos = this.origin.scale(sx, sy, origin)
this.x = pos.x
this.y = pos.y
this.width *= sx
this.height *= sy
return this
}
rotate(
degree: number,
center: Point.PointLike | Point.PointData = this.getCenter(),
) {
if (degree !== 0) {
const rad = Angle.toRad(degree)
const cos = Math.cos(rad)
const sin = Math.sin(rad)
let p1 = this.getOrigin()
let p2 = this.getTopRight()
let p3 = this.getBottomRight()
let p4 = this.getBottomLeft()
p1 = Point.rotateEx(p1, cos, sin, center)
p2 = Point.rotateEx(p2, cos, sin, center)
p3 = Point.rotateEx(p3, cos, sin, center)
p4 = Point.rotateEx(p4, cos, sin, center)
const rect = new Rectangle(p1.x, p1.y, 0, 0)
rect.add(p2.x, p2.y, 0, 0)
rect.add(p3.x, p3.y, 0, 0)
rect.add(p4.x, p4.y, 0, 0)
this.update(rect)
}
return this
}
rotate90() {
const t = (this.width - this.height) / 2
this.x += t
this.y -= t
const tmp = this.width
this.width = this.height
this.height = tmp
return this
}
/**
* Translates the rectangle by `rect.x` and `rect.y` and expand it by
* `rect.width` and `rect.height`.
*/
moveAndExpand(rect: Rectangle.RectangleLike | Rectangle.RectangleData) {
const ref = Rectangle.clone(rect)
this.x += ref.x || 0
this.y += ref.y || 0
this.width += ref.width || 0
this.height += ref.height || 0
return this
}
/**
* Returns an object where `sx` and `sy` give the maximum scaling that can be
* applied to the rectangle so that it would still fit into `limit`. If
* `origin` is specified, the rectangle is scaled around it; otherwise, it is
* scaled around its center.
*/
getMaxScaleToFit(
limit: Rectangle.RectangleLike | Rectangle.RectangleData,
origin: Point = this.center,
) {
const rect = Rectangle.clone(limit)
const ox = origin.x
const oy = origin.y
// Find the maximal possible scale for all corners, so when the scale
// is applied the point is still inside the rectangle.
let sx1 = Infinity
let sx2 = Infinity
let sx3 = Infinity
let sx4 = Infinity
let sy1 = Infinity
let sy2 = Infinity
let sy3 = Infinity
let sy4 = Infinity
// Top Left
const p1 = rect.topLeft
if (p1.x < ox) {
sx1 = (this.x - ox) / (p1.x - ox)
}
if (p1.y < oy) {
sy1 = (this.y - oy) / (p1.y - oy)
}
// Bottom Right
const p2 = rect.bottomRight
if (p2.x > ox) {
sx2 = (this.x + this.width - ox) / (p2.x - ox)
}
if (p2.y > oy) {
sy2 = (this.y + this.height - oy) / (p2.y - oy)
}
// Top Right
const p3 = rect.topRight
if (p3.x > ox) {
sx3 = (this.x + this.width - ox) / (p3.x - ox)
}
if (p3.y < oy) {
sy3 = (this.y - oy) / (p3.y - oy)
}
// Bottom Left
const p4 = rect.bottomLeft
if (p4.x < ox) {
sx4 = (this.x - ox) / (p4.x - ox)
}
if (p4.y > oy) {
sy4 = (this.y + this.height - oy) / (p4.y - oy)
}
return {
sx: Math.min(sx1, sx2, sx3, sx4),
sy: Math.min(sy1, sy2, sy3, sy4),
}
}
/**
* Returns a number that specifies the maximum scaling that can be applied to
* the rectangle along both axes so that it would still fit into `limit`. If
* `origin` is specified, the rectangle is scaled around it; otherwise, it is
* scaled around its center.
*/
getMaxUniformScaleToFit(
limit: Rectangle.RectangleLike | Rectangle.RectangleData,
origin: Point = this.center,
) {
const scale = this.getMaxScaleToFit(limit, origin)
return Math.min(scale.sx, scale.sy)
}
/**
* Returns `true` if the point is inside the rectangle (inclusive).
* Returns `false` otherwise.
*/
containsPoint(x: number, y: number): boolean
containsPoint(point: Point.PointLike | Point.PointData): boolean
containsPoint(
x: number | Point.PointLike | Point.PointData,
y?: number,
): boolean {
return Util.containsPoint(this, Point.create(x, y))
}
/**
* Returns `true` if the rectangle is (completely) inside the
* rectangle (inclusive). Returns `false` otherwise.
*/
containsRect(x: number, y: number, w: number, h: number): boolean
containsRect(rect: Rectangle.RectangleLike | Rectangle.RectangleData): boolean
containsRect(
x: number | Rectangle.RectangleLike | Rectangle.RectangleData,
y?: number,
width?: number,
height?: number,
) {
const b = Rectangle.create(x, y, width, height)
const x1 = this.x
const y1 = this.y
const w1 = this.width
const h1 = this.height
const x2 = b.x
const y2 = b.y
const w2 = b.width
const h2 = b.height
// one of the dimensions is 0
if (w1 === 0 || h1 === 0 || w2 === 0 || h2 === 0) {
return false
}
return x2 >= x1 && y2 >= y1 && x2 + w2 <= x1 + w1 && y2 + h2 <= y1 + h1
}
/**
* Returns an array of the intersection points of the rectangle and the line.
* Return `null` if no intersection exists.
*/
intersectsWithLine(line: Line) {
const rectLines = [
this.topLine,
this.rightLine,
this.bottomLine,
this.leftLine,
]
const points: Point[] = []
const dedupeArr: string[] = []
rectLines.forEach((l) => {
const p = line.intersectsWithLine(l)
if (p !== null && dedupeArr.indexOf(p.toString()) < 0) {
points.push(p)
dedupeArr.push(p.toString())
}
})
return points.length > 0 ? points : null
}
/**
* Returns the point on the boundary of the rectangle that is the intersection
* of the rectangle with a line starting in the center the rectangle ending in
* the point `p`.
*
* If `angle` is specified, the intersection will take into account the
* rotation of the rectangle by `angle` degrees around its center.
*/
intersectsWithLineFromCenterToPoint(
p: Point.PointLike | Point.PointData,
angle?: number,
) {
const ref = Point.clone(p)
const center = this.center
let result: Point | null = null
if (angle != null && angle !== 0) {
ref.rotate(angle, center)
}
const sides = [this.topLine, this.rightLine, this.bottomLine, this.leftLine]
const connector = new Line(center, ref)
for (let i = sides.length - 1; i >= 0; i -= 1) {
const intersection = sides[i].intersectsWithLine(connector)
if (intersection !== null) {
result = intersection
break
}
}
if (result && angle != null && angle !== 0) {
result.rotate(-angle, center)
}
return result
}
/**
* Returns a rectangle that is a subtraction of the two rectangles if such an
* object exists (the two rectangles intersect). Returns `null` otherwise.
*/
intersectsWithRect(
x: number,
y: number,
w: number,
h: number,
): Rectangle | null
intersectsWithRect(
rect: Rectangle.RectangleLike | Rectangle.RectangleData,
): Rectangle | null
intersectsWithRect(
x: number | Rectangle.RectangleLike | Rectangle.RectangleData,
y?: number,
width?: number,
height?: number,
) {
const ref = Rectangle.create(x, y, width, height)
// no intersection
if (!this.isIntersectWithRect(ref)) {
return null
}
const myOrigin = this.origin
const myCorner = this.corner
const rOrigin = ref.origin
const rCorner = ref.corner
const xx = Math.max(myOrigin.x, rOrigin.x)
const yy = Math.max(myOrigin.y, rOrigin.y)
return new Rectangle(
xx,
yy,
Math.min(myCorner.x, rCorner.x) - xx,
Math.min(myCorner.y, rCorner.y) - yy,
)
}
isIntersectWithRect(x: number, y: number, w: number, h: number): boolean
isIntersectWithRect(
rect: Rectangle.RectangleLike | Rectangle.RectangleData,
): boolean
isIntersectWithRect(
x: number | Rectangle.RectangleLike | Rectangle.RectangleData,
y?: number,
width?: number,
height?: number,
) {
const ref = Rectangle.create(x, y, width, height)
const myOrigin = this.origin
const myCorner = this.corner
const rOrigin = ref.origin
const rCorner = ref.corner
if (
rCorner.x <= myOrigin.x ||
rCorner.y <= myOrigin.y ||
rOrigin.x >= myCorner.x ||
rOrigin.y >= myCorner.y
) {
return false
}
return true
}
/**
* Normalize the rectangle, i.e. make it so that it has non-negative
* width and height. If width is less than `0`, the function swaps left and
* right corners and if height is less than `0`, the top and bottom corners
* are swapped.
*/
normalize() {
let newx = this.x
let newy = this.y
let newwidth = this.width
let newheight = this.height
if (this.width < 0) {
newx = this.x + this.width
newwidth = -this.width
}
if (this.height < 0) {
newy = this.y + this.height
newheight = -this.height
}
this.x = newx
this.y = newy
this.width = newwidth
this.height = newheight
return this
}
/**
* Returns a rectangle that is a union of this rectangle and rectangle `rect`.
*/
union(rect: Rectangle.RectangleLike | Rectangle.RectangleData) {
const ref = Rectangle.clone(rect)
const myOrigin = this.origin
const myCorner = this.corner
const rOrigin = ref.origin
const rCorner = ref.corner
const originX = Math.min(myOrigin.x, rOrigin.x)
const originY = Math.min(myOrigin.y, rOrigin.y)
const cornerX = Math.max(myCorner.x, rCorner.x)
const cornerY = Math.max(myCorner.y, rCorner.y)
return new Rectangle(originX, originY, cornerX - originX, cornerY - originY)
}
/**
* Returns a string ("top", "left", "right" or "bottom") denoting the side of
* the rectangle which is nearest to the point `p`.
*/
getNearestSideToPoint(p: Point.PointLike | Point.PointData): Rectangle.Side {
const ref = Point.clone(p)
const distLeft = ref.x - this.x
const distRight = this.x + this.width - ref.x
const distTop = ref.y - this.y
const distBottom = this.y + this.height - ref.y
let closest = distLeft
let side: Rectangle.Side = 'left'
if (distRight < closest) {
closest = distRight
side = 'right'
}
if (distTop < closest) {
closest = distTop
side = 'top'
}
if (distBottom < closest) {
side = 'bottom'
}
return side
}
/**
* Returns a point on the boundary of the rectangle nearest to the point `p`.
*/
getNearestPointToPoint(p: Point.PointLike | Point.PointData) {
const ref = Point.clone(p)
if (this.containsPoint(ref)) {
const side = this.getNearestSideToPoint(ref)
switch (side) {
case 'right':
return new Point(this.x + this.width, ref.y)
case 'left':
return new Point(this.x, ref.y)
case 'bottom':
return new Point(ref.x, this.y + this.height)
case 'top':
return new Point(ref.x, this.y)
default:
break
}
}
return ref.adhereToRect(this)
}
equals(rect: Rectangle.RectangleLike) {
return (
rect != null &&
rect.x === this.x &&
rect.y === this.y &&
rect.width === this.width &&
rect.height === this.height
)
}
clone() {
return new Rectangle(this.x, this.y, this.width, this.height)
}
toJSON() {
return { x: this.x, y: this.y, width: this.width, height: this.height }
}
serialize() {
return `${this.x} ${this.y} ${this.width} ${this.height}`
}
}
export namespace Rectangle {
export const toStringTag = `X6.Geometry.${Rectangle.name}`
export function isRectangle(instance: any): instance is Rectangle {
if (instance == null) {
return false
}
if (instance instanceof Rectangle) {
return true
}
const tag = instance[Symbol.toStringTag]
const rect = instance as Rectangle
if (
(tag == null || tag === toStringTag) &&
typeof rect.x === 'number' &&
typeof rect.y === 'number' &&
typeof rect.width === 'number' &&
typeof rect.height === 'number' &&
typeof rect.inflate === 'function' &&
typeof rect.moveAndExpand === 'function'
) {
return true
}
return false
}
}
export namespace Rectangle {
export type RectangleData = [number, number, number, number]
export interface RectangleLike extends Point.PointLike {
x: number
y: number
width: number
height: number
}
export function isRectangleLike(o: any): o is RectangleLike {
return (
o != null &&
typeof o === 'object' &&
typeof o.x === 'number' &&
typeof o.y === 'number' &&
typeof o.width === 'number' &&
typeof o.height === 'number'
)
}
export type Side = 'left' | 'right' | 'top' | 'bottom'
export type KeyPoint =
| 'center'
| 'origin'
| 'corner'
| 'topLeft'
| 'topCenter'
| 'topRight'
| 'bottomLeft'
| 'bottomCenter'
| 'bottomRight'
| 'rightMiddle'
| 'leftMiddle'
}
export namespace Rectangle {
export function create(rect: RectangleLike | RectangleData): Rectangle
export function create(
x?: number,
y?: number,
width?: number,
height?: number,
): Rectangle
export function create(
x?: number | RectangleLike | RectangleData,
y?: number,
width?: number,
height?: number,
): Rectangle
export function create(
x?: number | RectangleLike | RectangleData,
y?: number,
width?: number,
height?: number,
): Rectangle {
if (x == null || typeof x === 'number') {
return new Rectangle(x, y, width, height)
}
return clone(x)
}
export function clone(rect: RectangleLike | RectangleData) {
if (Rectangle.isRectangle(rect)) {
return rect.clone()
}
if (Array.isArray(rect)) {
return new Rectangle(rect[0], rect[1], rect[2], rect[3])
}
return new Rectangle(rect.x, rect.y, rect.width, rect.height)
}
/**
* Returns a new rectangle from the given ellipse.
*/
export function fromEllipse(ellipse: Ellipse) {
return new Rectangle(
ellipse.x - ellipse.a,
ellipse.y - ellipse.b,
2 * ellipse.a,
2 * ellipse.b,
)
}
interface Size {
width: number
height: number
}
export function fromSize(size: Size) {
return new Rectangle(0, 0, size.width, size.height)
}
export function fromPositionAndSize(pos: Point.PointLike, size: Size) {
return new Rectangle(pos.x, pos.y, size.width, size.height)
}
}

View File

@ -0,0 +1,21 @@
/**
* A type alias for a JSON primitive.
*/
export type JSONPrimitive = boolean | number | string | null | undefined
/**
* A type alias for a JSON value.
*/
export type JSONValue = JSONPrimitive | JSONObject | JSONArray
/**
* A type definition for a JSON object.
*/
export interface JSONObject {
[key: string]: JSONValue
}
/**
* A type definition for a JSON array.
*/
export interface JSONArray extends Array<JSONValue> {}

View File

@ -0,0 +1,78 @@
import type { Point } from './point'
import type { Rectangle } from './rectangle'
export namespace Util {
export function round(num: number, precision = 0) {
return Number.isInteger(num) ? num : +num.toFixed(precision)
}
export function random(): number
export function random(max: number): number
export function random(min: number, max: number): number
export function random(min?: number, max?: number): number {
let mmin
let mmax
if (max == null) {
mmax = min == null ? 1 : min
mmin = 0
} else {
mmax = max
mmin = min == null ? 0 : min
}
if (mmax < mmin) {
const temp = mmin
mmin = mmax
mmax = temp
}
return Math.floor(Math.random() * (mmax - mmin + 1) + mmin)
}
export function clamp(value: number, min: number, max: number) {
if (Number.isNaN(value)) {
return NaN
}
if (Number.isNaN(min) || Number.isNaN(max)) {
return 0
}
return min < max
? value < min
? min
: value > max
? max
: value
: value < max
? max
: value > min
? min
: value
}
export function snapToGrid(value: number, gridSize: number) {
return gridSize * Math.round(value / gridSize)
}
export function containsPoint(
rect: Rectangle.RectangleLike,
point: Point.PointLike,
) {
return (
point != null &&
rect != null &&
point.x >= rect.x &&
point.x <= rect.x + rect.width &&
point.y >= rect.y &&
point.y <= rect.y + rect.height
)
}
export function squaredLength(p1: Point.PointLike, p2: Point.PointLike) {
const dx = p1.x - p2.x
const dy = p1.y - p2.y
return dx * dx + dy * dy
}
}

View File

@ -0,0 +1,9 @@
import { version } from './version'
describe('version', () => {
it('should match the `version` field of package.json', () => {
// eslint-disable-next-line
const expected = require('../package.json').version
expect(version).toBe(expected)
})
})

View File

@ -0,0 +1,7 @@
/* eslint-disable */
/**
* Auto generated version file, do not modify it!
*/
const version = '1.0.0'
export { version }

View File

@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}