tldraw/utils/utils.ts

891 lines
21 KiB
TypeScript
Raw Normal View History

2021-05-12 22:08:53 +00:00
import { Data, Bounds } from "types"
2021-05-09 13:04:42 +00:00
import * as svg from "./svg"
import * as vec from "./vec"
2021-05-09 21:22:25 +00:00
export function screenToWorld(point: number[], data: Data) {
2021-05-10 20:44:17 +00:00
return vec.sub(vec.div(point, data.camera.zoom), data.camera.point)
2021-05-09 13:04:42 +00:00
}
2021-05-12 22:08:53 +00:00
/**
* Get a bounding box that includes two bounding boxes.
* @param a Bounding box
* @param b Bounding box
* @returns
*/
export function getExpandedBounds(a: Bounds, b: Bounds) {
const minX = Math.min(a.minX, b.minX),
minY = Math.min(a.minY, b.minY),
maxX = Math.max(a.maxX, b.maxX),
maxY = Math.max(a.maxY, b.maxY),
width = Math.abs(maxX - minX),
height = Math.abs(maxY - minY)
return { minX, minY, maxX, maxY, width, height }
}
/**
* Get the common bounds of a group of bounds.
* @returns
*/
export function getCommonBounds(...b: Bounds[]) {
if (b.length < 2) return b[0]
let bounds = b[0]
for (let i = 1; i < b.length; i++) {
bounds = getExpandedBounds(bounds, b[i])
}
return bounds
}
2021-05-10 12:16:57 +00:00
export function getBoundsFromPoints(a: number[], b: number[]) {
const minX = Math.min(a[0], b[0])
const maxX = Math.max(a[0], b[0])
const minY = Math.min(a[1], b[1])
const maxY = Math.max(a[1], b[1])
return {
minX,
maxX,
minY,
maxY,
width: maxX - minX,
height: maxY - minY,
}
}
2021-05-09 13:04:42 +00:00
// A helper for getting tangents.
export function getCircleTangentToPoint(
A: number[],
r0: number,
P: number[],
side: number
) {
const B = vec.lrp(A, P, 0.5),
r1 = vec.dist(A, B),
delta = vec.sub(B, A),
d = vec.len(delta)
if (!(d <= r0 + r1 && d >= Math.abs(r0 - r1))) {
return
}
const a = (r0 * r0 - r1 * r1 + d * d) / (2.0 * d),
n = 1 / d,
p = vec.add(A, vec.mul(delta, a * n)),
h = Math.sqrt(r0 * r0 - a * a),
k = vec.mul(vec.per(delta), h * n)
return side === 0 ? vec.add(p, k) : vec.sub(p, k)
}
export function circleCircleIntersections(a: number[], b: number[]) {
const R = a[2],
r = b[2]
let dx = b[0] - a[0],
dy = b[1] - a[1]
const d = Math.sqrt(dx * dx + dy * dy),
x = (d * d - r * r + R * R) / (2 * d),
y = Math.sqrt(R * R - x * x)
dx /= d
dy /= d
return [
[a[0] + dx * x - dy * y, a[1] + dy * x + dx * y],
[a[0] + dx * x + dy * y, a[1] + dy * x - dx * y],
]
}
export function getClosestPointOnCircle(
C: number[],
r: number,
P: number[],
padding = 0
) {
const v = vec.sub(C, P)
return vec.sub(C, vec.mul(vec.div(v, vec.len(v)), r + padding))
}
export function projectPoint(p0: number[], a: number, d: number) {
return [Math.cos(a) * d + p0[0], Math.sin(a) * d + p0[1]]
}
function shortAngleDist(a0: number, a1: number) {
const max = Math.PI * 2
const da = (a1 - a0) % max
return ((2 * da) % max) - da
}
export function lerpAngles(a0: number, a1: number, t: number) {
return a0 + shortAngleDist(a0, a1) * t
}
export function getBezierCurveSegments(points: number[][], tension = 0.4) {
const len = points.length,
cpoints: number[][] = [...points]
if (len < 2) {
throw Error("Curve must have at least two points.")
}
for (let i = 1; i < len - 1; i++) {
const p0 = points[i - 1],
p1 = points[i],
p2 = points[i + 1]
const pdx = p2[0] - p0[0],
pdy = p2[1] - p0[1],
pd = Math.hypot(pdx, pdy),
nx = pdx / pd, // normalized x
ny = pdy / pd, // normalized y
dp = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]), // Distance to previous
dn = Math.hypot(p1[0] - p2[0], p1[1] - p2[1]) // Distance to next
cpoints[i] = [
// tangent start
p1[0] - nx * dp * tension,
p1[1] - ny * dp * tension,
// tangent end
p1[0] + nx * dn * tension,
p1[1] + ny * dn * tension,
// normal
nx,
ny,
]
}
// TODO: Reflect the nearest control points, not average them
const d0 = Math.hypot(points[0][0] + cpoints[1][0])
cpoints[0][2] = (points[0][0] + cpoints[1][0]) / 2
cpoints[0][3] = (points[0][1] + cpoints[1][1]) / 2
cpoints[0][4] = (cpoints[1][0] - points[0][0]) / d0
cpoints[0][5] = (cpoints[1][1] - points[0][1]) / d0
const d1 = Math.hypot(points[len - 1][1] + cpoints[len - 1][1])
cpoints[len - 1][0] = (points[len - 1][0] + cpoints[len - 2][2]) / 2
cpoints[len - 1][1] = (points[len - 1][1] + cpoints[len - 2][3]) / 2
cpoints[len - 1][4] = (cpoints[len - 2][2] - points[len - 1][0]) / -d1
cpoints[len - 1][5] = (cpoints[len - 2][3] - points[len - 1][1]) / -d1
const results: {
start: number[]
tangentStart: number[]
normalStart: number[]
pressureStart: number
end: number[]
tangentEnd: number[]
normalEnd: number[]
pressureEnd: number
}[] = []
for (let i = 1; i < cpoints.length; i++) {
results.push({
start: points[i - 1].slice(0, 2),
tangentStart: cpoints[i - 1].slice(2, 4),
normalStart: cpoints[i - 1].slice(4, 6),
pressureStart: 2 + ((i - 1) % 2 === 0 ? 1.5 : 0),
end: points[i].slice(0, 2),
tangentEnd: cpoints[i].slice(0, 2),
normalEnd: cpoints[i].slice(4, 6),
pressureEnd: 2 + (i % 2 === 0 ? 1.5 : 0),
})
}
return results
}
export function cubicBezier(
tx: number,
x1: number,
y1: number,
x2: number,
y2: number
) {
// Inspired by Don Lancaster's two articles
// http://www.tinaja.com/glib/cubemath.pdf
// http://www.tinaja.com/text/bezmath.html
// Set start and end point
const x0 = 0,
y0 = 0,
x3 = 1,
y3 = 1,
// Convert the coordinates to equation space
A = x3 - 3 * x2 + 3 * x1 - x0,
B = 3 * x2 - 6 * x1 + 3 * x0,
C = 3 * x1 - 3 * x0,
D = x0,
E = y3 - 3 * y2 + 3 * y1 - y0,
F = 3 * y2 - 6 * y1 + 3 * y0,
G = 3 * y1 - 3 * y0,
H = y0,
// Variables for the loop below
iterations = 5
let i: number,
slope: number,
x: number,
t = tx
// Loop through a few times to get a more accurate time value, according to the Newton-Raphson method
// http://en.wikipedia.org/wiki/Newton's_method
for (i = 0; i < iterations; i++) {
// The curve's x equation for the current time value
x = A * t * t * t + B * t * t + C * t + D
// The slope we want is the inverse of the derivate of x
slope = 1 / (3 * A * t * t + 2 * B * t + C)
// Get the next estimated time value, which will be more accurate than the one before
t -= (x - tx) * slope
t = t > 1 ? 1 : t < 0 ? 0 : t
}
// Find the y value through the curve's y equation, with the now more accurate time value
return Math.abs(E * t * t * t + F * t * t + G * t * H)
}
export function copyToClipboard(string: string) {
let textarea: HTMLTextAreaElement
let result: boolean
try {
navigator.clipboard.writeText(string)
} catch (e) {
try {
textarea = document.createElement("textarea")
textarea.setAttribute("position", "fixed")
textarea.setAttribute("top", "0")
textarea.setAttribute("readonly", "true")
textarea.setAttribute("contenteditable", "true")
textarea.style.position = "fixed" // prevent scroll from jumping to the bottom when focus is set.
textarea.value = string
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
const range = document.createRange()
range.selectNodeContents(textarea)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
textarea.setSelectionRange(0, textarea.value.length)
result = document.execCommand("copy")
} catch (err) {
result = null
} finally {
document.body.removeChild(textarea)
}
}
return !!result
}
/**
* Get a bezier curve data to for a spline that fits an array of points.
* @param points An array of points formatted as [x, y]
* @param k Tension
* @returns An array of points as [cp1x, cp1y, cp2x, cp2y, px, py].
*/
export function getSpline(pts: number[][], k = 0.5) {
let p0: number[],
[p1, p2, p3] = pts
const results: number[][] = []
for (let i = 1, len = pts.length; i < len; i++) {
p0 = p1
p1 = p2
p2 = p3
p3 = pts[i + 2] ? pts[i + 2] : p2
results.push([
p1[0] + ((p2[0] - p0[0]) / 6) * k,
p1[1] + ((p2[1] - p0[1]) / 6) * k,
p2[0] - ((p3[0] - p1[0]) / 6) * k,
p2[1] - ((p3[1] - p1[1]) / 6) * k,
pts[i][0],
pts[i][1],
])
}
return results
}
export function getCurvePoints(
pts: number[][],
tension = 0.5,
isClosed = false,
numOfSegments = 3
) {
const _pts = [...pts],
len = pts.length,
res: number[][] = [] // results
let t1x: number, // tension vectors
t2x: number,
t1y: number,
t2y: number,
c1: number, // cardinal points
c2: number,
c3: number,
c4: number,
st: number,
st2: number,
st3: number
// The algorithm require a previous and next point to the actual point array.
// Check if we will draw closed or open curve.
// If closed, copy end points to beginning and first points to end
// If open, duplicate first points to befinning, end points to end
if (isClosed) {
_pts.unshift(_pts[len - 1])
_pts.push(_pts[0])
} else {
//copy 1. point and insert at beginning
_pts.unshift(_pts[0])
_pts.push(_pts[len - 1])
// _pts.push(_pts[len - 1])
}
// For each point, calculate a segment
for (let i = 1; i < _pts.length - 2; i++) {
// Calculate points along segment and add to results
for (let t = 0; t <= numOfSegments; t++) {
// Step
st = t / numOfSegments
st2 = Math.pow(st, 2)
st3 = Math.pow(st, 3)
// Cardinals
c1 = 2 * st3 - 3 * st2 + 1
c2 = -(2 * st3) + 3 * st2
c3 = st3 - 2 * st2 + st
c4 = st3 - st2
// Tension
t1x = (_pts[i + 1][0] - _pts[i - 1][0]) * tension
t2x = (_pts[i + 2][0] - _pts[i][0]) * tension
t1y = (_pts[i + 1][1] - _pts[i - 1][1]) * tension
t2y = (_pts[i + 2][1] - _pts[i][1]) * tension
// Control points
res.push([
c1 * _pts[i][0] + c2 * _pts[i + 1][0] + c3 * t1x + c4 * t2x,
c1 * _pts[i][1] + c2 * _pts[i + 1][1] + c3 * t1y + c4 * t2y,
])
}
}
res.push(pts[pts.length - 1])
return res
}
export function angleDelta(a0: number, a1: number) {
return shortAngleDist(a0, a1)
}
/**
* Rotate a point around a center.
* @param x The x-axis coordinate of the point.
* @param y The y-axis coordinate of the point.
* @param cx The x-axis coordinate of the point to rotate round.
* @param cy The y-axis coordinate of the point to rotate round.
* @param angle The distance (in radians) to rotate.
*/
export function rotatePoint(A: number[], B: number[], angle: number) {
const s = Math.sin(angle)
const c = Math.cos(angle)
const px = A[0] - B[0]
const py = A[1] - B[1]
const nx = px * c - py * s
const ny = px * s + py * c
return [nx + B[0], ny + B[1]]
}
export function degreesToRadians(d: number) {
return (d * Math.PI) / 180
}
export function radiansToDegrees(r: number) {
return (r * 180) / Math.PI
}
export function getArcLength(C: number[], r: number, A: number[], B: number[]) {
const sweep = getSweep(C, A, B)
return r * (2 * Math.PI) * (sweep / (2 * Math.PI))
}
export function getArcDashOffset(
C: number[],
r: number,
A: number[],
B: number[],
step: number
) {
const del0 = getSweep(C, A, B)
const len0 = getArcLength(C, r, A, B)
const off0 = del0 < 0 ? len0 : 2 * Math.PI * C[2] - len0
return -off0 / 2 + step
}
export function getEllipseDashOffset(A: number[], step: number) {
const c = 2 * Math.PI * A[2]
return -c / 2 + -step
}
export function getSweep(C: number[], A: number[], B: number[]) {
return angleDelta(vec.angle(C, A), vec.angle(C, B))
}
export function deepCompareArrays<T>(a: T[], b: T[]) {
if (a?.length !== b?.length) return false
return deepCompare(a, b)
}
export function deepCompare<T>(a: T, b: T) {
return a === b || JSON.stringify(a) === JSON.stringify(b)
}
/**
* Get outer tangents of two circles.
* @param x0
* @param y0
* @param r0
* @param x1
* @param y1
* @param r1
* @returns [lx0, ly0, lx1, ly1, rx0, ry0, rx1, ry1]
*/
export function getOuterTangents(
C0: number[],
r0: number,
C1: number[],
r1: number
) {
const a0 = vec.angle(C0, C1)
const d = vec.dist(C0, C1)
// Circles are overlapping, no tangents
if (d < Math.abs(r1 - r0)) return
const a1 = Math.acos((r0 - r1) / d),
t0 = a0 + a1,
t1 = a0 - a1
return [
[C0[0] + r0 * Math.cos(t1), C0[1] + r0 * Math.sin(t1)],
[C1[0] + r1 * Math.cos(t1), C1[1] + r1 * Math.sin(t1)],
[C0[0] + r0 * Math.cos(t0), C0[1] + r0 * Math.sin(t0)],
[C1[0] + r1 * Math.cos(t0), C1[1] + r1 * Math.sin(t0)],
]
}
export function arrsIntersect<T, K>(
a: T[],
b: K[],
fn?: (item: K) => T
): boolean
export function arrsIntersect<T>(a: T[], b: T[]): boolean
export function arrsIntersect<T>(
a: T[],
b: unknown[],
fn?: (item: unknown) => T
) {
return a.some((item) => b.includes(fn ? fn(item) : item))
}
// /**
// * Will mutate an array to remove items.
// * @param arr
// * @param item
// */
// export function pull<T>(arr: T[], ...items: T[]) {
// for (let item of items) {
// arr.splice(arr.indexOf(item), 1)
// }
// return arr
// }
// /**
// * Will mutate an array to remove items, based on a function
// * @param arr
// * @param fn
// * @returns
// */
// export function pullWith<T>(arr: T[], fn: (item: T) => boolean) {
// pull(arr, ...arr.filter((item) => fn(item)))
// return arr
// }
// export function rectContainsRect(
// x0: number,
// y0: number,
// x1: number,
// y1: number,
// box: { x: number; y: number; width: number; height: number }
// ) {
// return !(
// x0 > box.x ||
// x1 < box.x + box.width ||
// y0 > box.y ||
// y1 < box.y + box.height
// )
// }
export function getTouchDisplay() {
return (
"ontouchstart" in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)
}
const rounds = [1, 10, 100, 1000]
export function round(n: number, p = 2) {
return Math.floor(n * rounds[p]) / rounds[p]
}
/**
* Linear interpolation betwen two numbers.
* @param y1
* @param y2
* @param mu
*/
export function lerp(y1: number, y2: number, mu: number) {
mu = clamp(mu, 0, 1)
return y1 * (1 - mu) + y2 * mu
}
/**
* Modulate a value between two ranges.
* @param value
* @param rangeA from [low, high]
* @param rangeB to [low, high]
* @param clamp
*/
export function modulate(
value: number,
rangeA: number[],
rangeB: number[],
clamp = false
) {
const [fromLow, fromHigh] = rangeA
const [v0, v1] = rangeB
const result = v0 + ((value - fromLow) / (fromHigh - fromLow)) * (v1 - v0)
return clamp
? v0 < v1
? Math.max(Math.min(result, v1), v0)
: Math.max(Math.min(result, v0), v1)
: result
}
/**
* Clamp a value into a range.
* @param n
* @param min
*/
export function clamp(n: number, min: number): number
export function clamp(n: number, min: number, max: number): number
export function clamp(n: number, min: number, max?: number): number {
return Math.max(min, typeof max !== "undefined" ? Math.min(n, max) : n)
}
// CURVES
// Mostly adapted from https://github.com/Pomax/bezierjs
export function computePointOnCurve(t: number, points: number[][]) {
// shortcuts
if (t === 0) {
return points[0]
}
const order = points.length - 1
if (t === 1) {
return points[order]
}
const mt = 1 - t
let p = points // constant?
if (order === 0) {
return points[0]
} // linear?
if (order === 1) {
return [mt * p[0][0] + t * p[1][0], mt * p[0][1] + t * p[1][1]]
} // quadratic/cubic curve?
if (order < 4) {
const mt2 = mt * mt,
t2 = t * t
let a: number,
b: number,
c: number,
d = 0
if (order === 2) {
p = [p[0], p[1], p[2], [0, 0]]
a = mt2
b = mt * t * 2
c = t2
} else if (order === 3) {
a = mt2 * mt
b = mt2 * t * 3
c = mt * t2 * 3
d = t * t2
}
return [
a * p[0][0] + b * p[1][0] + c * p[2][0] + d * p[3][0],
a * p[0][1] + b * p[1][1] + c * p[2][1] + d * p[3][1],
]
} // higher order curves: use de Casteljau's computation
}
function distance2(p: DOMPoint, point: number[]) {
const dx = p.x - point[0],
dy = p.y - point[1]
return dx * dx + dy * dy
}
/**
* Find the closest point on a path to an off-path point.
* @param pathNode
* @param point
* @returns
*/
export function getClosestPointOnPath(
pathNode: SVGPathElement,
point: number[]
) {
const pathLen = pathNode.getTotalLength()
let p = 8,
best: DOMPoint,
bestLen: number,
bestDist = Infinity,
bl: number,
al: number
// linear scan for coarse approximation
for (
let scan: DOMPoint, scanLen = 0, scanDist: number;
scanLen <= pathLen;
scanLen += p
) {
if (
(scanDist = distance2(
(scan = pathNode.getPointAtLength(scanLen)),
point
)) < bestDist
) {
;(best = scan), (bestLen = scanLen), (bestDist = scanDist)
}
}
// binary search for precise estimate
p /= 2
while (p > 0.5) {
let before: DOMPoint, after: DOMPoint, bd: number, ad: number
if (
(bl = bestLen - p) >= 0 &&
(bd = distance2((before = pathNode.getPointAtLength(bl)), point)) <
bestDist
) {
;(best = before), (bestLen = bl), (bestDist = bd)
} else if (
(al = bestLen + p) <= pathLen &&
(ad = distance2((after = pathNode.getPointAtLength(al)), point)) <
bestDist
) {
;(best = after), (bestLen = al), (bestDist = ad)
} else {
p /= 2
}
}
return {
point: [best.x, best.y],
distance: bestDist,
length: (bl + al) / 2,
t: (bl + al) / 2 / pathLen,
}
}
export function det(
a: number,
b: number,
c: number,
d: number,
e: number,
f: number,
g: number,
h: number,
i: number
) {
return a * e * i + b * f * g + c * d * h - a * f * h - b * d * i - c * e * g
}
/**
* Get a circle from three points.
* @param p0
* @param p1
* @param center
* @returns
*/
export function circleFromThreePoints(A: number[], B: number[], C: number[]) {
const a = det(A[0], A[1], 1, B[0], B[1], 1, C[0], C[1], 1)
const bx = -det(
A[0] * A[0] + A[1] * A[1],
A[1],
1,
B[0] * B[0] + B[1] * B[1],
B[1],
1,
C[0] * C[0] + C[1] * C[1],
C[1],
1
)
const by = det(
A[0] * A[0] + A[1] * A[1],
A[0],
1,
B[0] * B[0] + B[1] * B[1],
B[0],
1,
C[0] * C[0] + C[1] * C[1],
C[0],
1
)
const c = -det(
A[0] * A[0] + A[1] * A[1],
A[0],
A[1],
B[0] * B[0] + B[1] * B[1],
B[0],
B[1],
C[0] * C[0] + C[1] * C[1],
C[0],
C[1]
)
return [
-bx / (2 * a),
-by / (2 * a),
Math.sqrt(bx * bx + by * by - 4 * a * c) / (2 * Math.abs(a)),
]
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function throttle<P extends any[], T extends (...args: P) => any>(
fn: T,
wait: number,
preventDefault?: boolean
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let inThrottle: boolean, lastFn: any, lastTime: number
return function(...args: P) {
if (preventDefault) args[0].preventDefault()
// eslint-disable-next-line @typescript-eslint/no-this-alias
const context = this
if (!inThrottle) {
fn.apply(context, args)
lastTime = Date.now()
inThrottle = true
} else {
clearTimeout(lastFn)
lastFn = setTimeout(function() {
if (Date.now() - lastTime >= wait) {
fn.apply(context, args)
lastTime = Date.now()
}
}, Math.max(wait - (Date.now() - lastTime), 0))
}
}
}
export function pointInRect(
point: number[],
minX: number,
minY: number,
maxX: number,
maxY: number
) {
return !(
point[0] < minX ||
point[0] > maxX ||
point[1] < minY ||
point[1] > maxY
)
}
/**
* Get the intersection of two rays, with origin points p0 and p1, and direction vectors n0 and n1.
* @param p0 The origin point of the first ray
* @param n0 The direction vector of the first ray
* @param p1 The origin point of the second ray
* @param n1 The direction vector of the second ray
* @returns
*/
export function getRayRayIntersection(
p0: number[],
n0: number[],
p1: number[],
n1: number[]
) {
const p0e = vec.add(p0, n0),
p1e = vec.add(p1, n1),
m0 = (p0e[1] - p0[1]) / (p0e[0] - p0[0]),
m1 = (p1e[1] - p1[1]) / (p1e[0] - p1[0]),
b0 = p0[1] - m0 * p0[0],
b1 = p1[1] - m1 * p1[0],
x = (b1 - b0) / (m0 - m1),
y = m0 * x + b0
return [x, y]
}
export async function postJsonToEndpoint(
endpoint: string,
data: { [key: string]: unknown }
) {
const d = await fetch(
`${process.env.NEXT_PUBLIC_BASE_API_URL}/api/${endpoint}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}
)
return await d.json()
}
2021-05-10 12:16:57 +00:00
2021-05-12 21:11:17 +00:00
export function getPointerEventInfo(
e: PointerEvent | React.PointerEvent | WheelEvent
) {
2021-05-10 12:16:57 +00:00
const { shiftKey, ctrlKey, metaKey, altKey } = e
return { point: [e.clientX, e.clientY], shiftKey, ctrlKey, metaKey, altKey }
}
2021-05-12 11:27:33 +00:00
2021-05-12 21:11:17 +00:00
export function getKeyboardEventInfo(e: KeyboardEvent | React.KeyboardEvent) {
2021-05-12 11:27:33 +00:00
const { shiftKey, ctrlKey, metaKey, altKey } = e
return { key: e.key, shiftKey, ctrlKey, metaKey, altKey }
}