@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering.
671 lines (561 loc) • 19.6 kB
text/typescript
import { Point } from './point'
import { Rectangle } from './rectangle'
import { Line } from './line'
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]
}