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