diff --git a/demo/arrow.html b/demo/arrow.html new file mode 100644 index 0000000..f562b96 --- /dev/null +++ b/demo/arrow.html @@ -0,0 +1,61 @@ + + + + + + Arrow + + + + + + + + + + diff --git a/demo/index.html b/demo/index.html index 6b6e43c..ab084d7 100644 --- a/demo/index.html +++ b/demo/index.html @@ -22,7 +22,9 @@
  • Shapes
  • Collision
  • Maps
  • +
  • Music
  • Ink
  • +
  • Arrow
  • diff --git a/package-lock.json b/package-lock.json index 29dfc52..83c227f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "leaflet": "^1.9.4", + "perfect-arrows": "^0.3.7", "perfect-freehand": "^1.1.0" }, "devDependencies": { @@ -700,6 +701,14 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/perfect-arrows": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/perfect-arrows/-/perfect-arrows-0.3.7.tgz", + "integrity": "sha512-wEN2gerTPVWl3yqoFEF8OeGbg3aRe2sxNUi9rnyYrCsL4JcI6K2tBDezRtqVrYG0BNtsWLdYiiTrYm+X//8yLQ==", + "engines": { + "node": ">=10" + } + }, "node_modules/perfect-freehand": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.2.tgz", diff --git a/package.json b/package.json index 4e554b5..a46173f 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ }, "dependencies": { "leaflet": "^1.9.4", + "perfect-arrows": "^0.3.7", "perfect-freehand": "^1.1.0" } } diff --git a/src/arrows/points-on-path.ts b/src/arrows/points-on-path.ts new file mode 100644 index 0000000..b1addaa --- /dev/null +++ b/src/arrows/points-on-path.ts @@ -0,0 +1,155 @@ +// Adopted from: https://github.com/pshihn/bezier-points/blob/master/src/index.ts + +export type Point = [number, number]; + +// distance between 2 points +function distance(p1: Point, p2: Point): number { + return Math.sqrt(distanceSq(p1, p2)); +} + +// distance between 2 points squared +function distanceSq(p1: Point, p2: Point): number { + return Math.pow(p1[0] - p2[0], 2) + Math.pow(p1[1] - p2[1], 2); +} + +// Distance squared from a point p to the line segment vw +function distanceToSegmentSq(p: Point, v: Point, w: Point): number { + const l2 = distanceSq(v, w); + if (l2 === 0) { + return distanceSq(p, v); + } + let t = ((p[0] - v[0]) * (w[0] - v[0]) + (p[1] - v[1]) * (w[1] - v[1])) / l2; + t = Math.max(0, Math.min(1, t)); + return distanceSq(p, lerp(v, w, t)); +} + +function lerp(a: Point, b: Point, t: number): Point { + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; +} + +// Adapted from https://seant23.wordpress.com/2010/11/12/offset-bezier-curves/ +function flatness(points: readonly Point[], offset: number): number { + const p1 = points[offset + 0]; + const p2 = points[offset + 1]; + const p3 = points[offset + 2]; + const p4 = points[offset + 3]; + + let ux = 3 * p2[0] - 2 * p1[0] - p4[0]; + ux *= ux; + let uy = 3 * p2[1] - 2 * p1[1] - p4[1]; + uy *= uy; + let vx = 3 * p3[0] - 2 * p4[0] - p1[0]; + vx *= vx; + let vy = 3 * p3[1] - 2 * p4[1] - p1[1]; + vy *= vy; + + if (ux < vx) { + ux = vx; + } + + if (uy < vy) { + uy = vy; + } + + return ux + uy; +} + +function getPointsOnBezierCurveWithSplitting( + points: readonly Point[], + offset: number, + tolerance: number, + newPoints?: Point[] +): Point[] { + const outPoints = newPoints || []; + if (flatness(points, offset) < tolerance) { + const p0 = points[offset + 0]; + if (outPoints.length) { + const d = distance(outPoints[outPoints.length - 1], p0); + if (d > 1) { + outPoints.push(p0); + } + } else { + outPoints.push(p0); + } + outPoints.push(points[offset + 3]); + } else { + // subdivide + const t = 0.5; + const p1 = points[offset + 0]; + const p2 = points[offset + 1]; + const p3 = points[offset + 2]; + const p4 = points[offset + 3]; + + const q1 = lerp(p1, p2, t); + const q2 = lerp(p2, p3, t); + const q3 = lerp(p3, p4, t); + + const r1 = lerp(q1, q2, t); + const r2 = lerp(q2, q3, t); + + const red = lerp(r1, r2, t); + + getPointsOnBezierCurveWithSplitting([p1, q1, r1, red], 0, tolerance, outPoints); + getPointsOnBezierCurveWithSplitting([red, r2, q3, p4], 0, tolerance, outPoints); + } + return outPoints; +} + +export function simplify(points: readonly Point[], distance: number): Point[] { + return simplifyPoints(points, 0, points.length, distance); +} + +// Ramer–Douglas–Peucker algorithm +// https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm +export function simplifyPoints( + points: readonly Point[], + start: number, + end: number, + epsilon: number, + newPoints?: Point[] +): Point[] { + const outPoints = newPoints || []; + + // find the most distance point from the endpoints + const s = points[start]; + const e = points[end - 1]; + let maxDistSq = 0; + let maxNdx = 1; + for (let i = start + 1; i < end - 1; ++i) { + const distSq = distanceToSegmentSq(points[i], s, e); + if (distSq > maxDistSq) { + maxDistSq = distSq; + maxNdx = i; + } + } + + // if that point is too far, split + if (Math.sqrt(maxDistSq) > epsilon) { + simplifyPoints(points, start, maxNdx + 1, epsilon, outPoints); + simplifyPoints(points, maxNdx, end, epsilon, outPoints); + } else { + if (!outPoints.length) { + outPoints.push(s); + } + outPoints.push(e); + } + + return outPoints; +} + +export function pointsOnBezierCurves( + points: readonly Point[], + tolerance: number = 0.15, + distance?: number +): Point[] { + const newPoints: Point[] = []; + const numSegments = (points.length - 1) / 3; + for (let i = 0; i < numSegments; i++) { + const offset = i * 3; + getPointsOnBezierCurveWithSplitting(points, offset, tolerance, newPoints); + } + if (distance && distance > 0) { + return simplifyPoints(newPoints, 0, newPoints.length, distance); + } + return newPoints; +} diff --git a/src/arrows/scoped-propagator.ts b/src/arrows/scoped-propagator.ts index a0fbc68..9b54e4b 100644 --- a/src/arrows/scoped-propagator.ts +++ b/src/arrows/scoped-propagator.ts @@ -1,12 +1,95 @@ +import { getBoxToBoxArrow } from 'perfect-arrows'; import { AbstractArrow } from './abstract-arrow'; +import { pointsOnBezierCurves } from './points-on-path'; +import getStroke, { StrokeOptions } from 'perfect-freehand'; + +export type Arrow = [ + /** The x position of the (padded) starting point. */ + sx: number, + /** The y position of the (padded) starting point. */ + sy: number, + /** The x position of the control point. */ + cx: number, + /** The y position of the control point. */ + cy: number, + /** The x position of the (padded) ending point. */ + ex: number, + /** The y position of the (padded) ending point. */ + ey: number, + /** The angle (in radians) for an ending arrowhead. */ + ae: number, + /** The angle (in radians) for a starting arrowhead. */ + as: number, + /** The angle (in radians) for a center arrowhead. */ + ac: number +]; export class ScopedPropagator extends AbstractArrow { static tagName = 'scoped-propagator'; - render( - sourceRect: DOMRectReadOnly, - targetRect: DOMRectReadOnly, - sourceElement: Element, - targetElement: Element - ) {} + #options: StrokeOptions = { + size: 10, + thinning: 0.5, + smoothing: 0.5, + streamline: 0.5, + simulatePressure: true, + // TODO: figure out how to expose these as attributes + easing: (t) => t, + start: { + taper: 50, + easing: (t) => t, + cap: true, + }, + end: { + taper: 0, + easing: (t) => t, + cap: true, + }, + }; + + render(sourceRect: DOMRectReadOnly, targetRect: DOMRectReadOnly) { + const [sx, sy, cx, cy, ex, ey, ae] = getBoxToBoxArrow( + sourceRect.x, + sourceRect.y, + sourceRect.width, + sourceRect.height, + targetRect.x, + targetRect.y, + targetRect.width, + targetRect.height + ) as Arrow; + + const points = pointsOnBezierCurves([ + [sx, sy], + [cx, cy], + [ex, ey], + [ex, ey], + ]); + + const stroke = getStroke(points, this.#options); + const path = getSvgPathFromStroke(stroke); + this.style.clipPath = `path('${path}')`; + this.style.backgroundColor = 'black'; + } +} + +function getSvgPathFromStroke(stroke: number[][]): string { + if (stroke.length === 0) return ''; + + for (const point of stroke) { + point[0] = Math.round(point[0] * 100) / 100; + point[1] = Math.round(point[1] * 100) / 100; + } + + const d = stroke.reduce( + (acc, [x0, y0], i, arr) => { + const [x1, y1] = arr[(i + 1) % arr.length]; + acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2); + return acc; + }, + ['M', ...stroke[0], 'Q'] + ); + + d.push('Z'); + return d.join(' '); }