arrow with clip-path

This commit is contained in:
“chrisshank” 2024-09-27 19:23:29 -07:00
parent 5f33ef6edf
commit 83adb5926d
6 changed files with 317 additions and 6 deletions

61
demo/arrow.html Normal file
View File

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arrow</title>
<style>
* {
box-sizing: border-box;
}
html {
height: 100%;
}
body {
min-height: 100%;
position: relative;
margin: 0;
}
spatial-geometry {
border: 2px solid black;
}
scoped-propagator {
display: block;
position: absolute;
inset: 0 0 0 0;
}
scoped-propagator::before {
display: block;
position: absolute;
content: '';
top: 0;
left: 0;
width: 32px;
height: 32px;
background-color: #33fbb8;
z-index: 99;
/* clip-path: path(
'M -6.913368606024928e-7 27.51256501186839 Q -6.913368606024928e-7 27.51256501186839 1.359391610290277 24.43576272048253 2.7187839119174146 21.358960429096673 5.396027436211911 17.2753695050713 8.073270960506408 13.191778581045929 10.536655087669818 9.709110210676396 13.000039214833228 6.2264418403068635 15.453768935769713 3.035480486305171 17.9074986567062 -0.15548086769652114 22.182098508796855 0.007962989877089266 26.456698360887508 0.17140684745069967 27.371911373232294 4.903006516643652 28.287124385577076 9.634606185836605 28.926106472791236 13.605596897003824 29.565088560005396 17.576587608171042 30.15753125011536 22.47061103218726 30.74997394022532 27.36463445620348 31.279230358053507 33.121986214585036 31.808486775881693 38.879337972966596 31.804101202822718 38.88173937858506 31.799715629763742 38.884140784203524 31.795330056704767 38.88654218982199 31.79094448364579 38.88894359544045 30.80252394827323 35.37867790823896 29.814103412900668 31.86841222103747 28.474780728720454 27.518762878054673 27.13545804454024 23.169113535071872 25.69771304378478 18.851799317580724 24.259968043029325 14.534485100089574 23.939341836207902 9.73061829847805 23.618715629386475 4.926751496866526 20.696699964783946 7.690250568827012 17.77468430018142 10.453749640787498 14.807561162025006 13.314144648523126 11.840438023868591 16.174539656258755 8.503978911444339 19.686222841120102 5.167519799020085 23.19790602598145 2.591755850647223 25.361240453345594 0.015991902274360602 27.524574880709736 0.00799560546875 27.518569946289062 Z'
); */
}
</style>
</head>
<body>
<spatial-geometry id="box1" x="100" y="100" width="50" height="50"></spatial-geometry>
<spatial-geometry id="box2" x="200" y="200" width="50" height="50"></spatial-geometry>
<scoped-propagator source="#box1" target="#box2"></scoped-propagator>
<script type="module">
import { SpatialGeometry } from '../src/canvas/spatial-geometry.ts';
import { ScopedPropagator } from '../src/arrows/scoped-propagator.ts';
SpatialGeometry.register();
ScopedPropagator.register();
</script>
</body>
</html>

View File

@ -22,7 +22,9 @@
<li><a href="/shapes">Shapes</a></li>
<li><a href="/collision">Collision</a></li>
<li><a href="/maps">Maps</a></li>
<li><a href="/music">Music</a></li>
<li><a href="/ink">Ink</a></li>
<li><a href="/arrow">Arrow</a></li>
</ul>
</body>
</html>

9
package-lock.json generated
View File

@ -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",

View File

@ -36,6 +36,7 @@
},
"dependencies": {
"leaflet": "^1.9.4",
"perfect-arrows": "^0.3.7",
"perfect-freehand": "^1.1.0"
}
}

View File

@ -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);
}
// RamerDouglasPeucker 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;
}

View File

@ -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(' ');
}