355 lines
7.7 KiB
TypeScript
355 lines
7.7 KiB
TypeScript
import { getBoxToBoxArrow } from "perfect-arrows";
|
|
import { getStroke, type StrokeOptions } from "perfect-freehand";
|
|
import { FolkElement } from "./folk-element";
|
|
import { css } from "./tags";
|
|
|
|
// Point interface for bezier curves
|
|
interface Point {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
// Utility functions for bezier curve rendering
|
|
function lerp(a: Point, b: Point, t: number): Point {
|
|
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
|
}
|
|
|
|
function distanceSquared(a: Point, b: Point): number {
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
return dx * dx + dy * dy;
|
|
}
|
|
|
|
function distance(a: Point, b: Point): number {
|
|
return Math.sqrt(distanceSquared(a, b));
|
|
}
|
|
|
|
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.x - 2 * p1.x - p4.x;
|
|
ux *= ux;
|
|
let uy = 3 * p2.y - 2 * p1.y - p4.y;
|
|
uy *= uy;
|
|
let vx = 3 * p3.x - 2 * p4.x - p1.x;
|
|
vx *= vx;
|
|
let vy = 3 * p3.y - 2 * p4.y - p1.y;
|
|
vy *= vy;
|
|
|
|
if (ux < vx) ux = vx;
|
|
if (uy < vy) uy = vy;
|
|
|
|
return ux + uy;
|
|
}
|
|
|
|
function getPointsOnBezierCurveWithSplitting(
|
|
points: readonly Point[],
|
|
offset: number,
|
|
tolerance: number,
|
|
outPoints: Point[] = [],
|
|
): Point[] {
|
|
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 {
|
|
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;
|
|
}
|
|
|
|
function pointsOnBezierCurves(points: readonly Point[], tolerance: number = 0.15): 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);
|
|
}
|
|
return newPoints;
|
|
}
|
|
|
|
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"] as (string | number)[],
|
|
);
|
|
|
|
d.push("Z");
|
|
return d.join(" ");
|
|
}
|
|
|
|
const styles = css`
|
|
:host {
|
|
display: block;
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
z-index: -1;
|
|
}
|
|
`;
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"folk-arrow": FolkArrow;
|
|
}
|
|
}
|
|
|
|
export class FolkArrow extends FolkElement {
|
|
static override tagName = "folk-arrow";
|
|
static styles = styles;
|
|
|
|
#sourceSelector: string = "";
|
|
#targetSelector: string = "";
|
|
#sourceElement: Element | null = null;
|
|
#targetElement: Element | null = null;
|
|
#sourceRect: DOMRect | null = null;
|
|
#targetRect: DOMRect | null = null;
|
|
#resizeObserver: ResizeObserver;
|
|
#color: string = "#374151";
|
|
#strokeWidth: number = 3;
|
|
|
|
#options: StrokeOptions = {
|
|
size: 7,
|
|
thinning: 0.5,
|
|
smoothing: 0.5,
|
|
streamline: 0.5,
|
|
simulatePressure: true,
|
|
easing: (t) => t,
|
|
start: {
|
|
taper: 50,
|
|
easing: (t) => t,
|
|
cap: true,
|
|
},
|
|
end: {
|
|
taper: 0,
|
|
easing: (t) => t,
|
|
cap: true,
|
|
},
|
|
};
|
|
|
|
constructor() {
|
|
super();
|
|
this.#resizeObserver = new ResizeObserver(() => this.#updateArrow());
|
|
}
|
|
|
|
get source() {
|
|
return this.#sourceSelector;
|
|
}
|
|
|
|
set source(value: string) {
|
|
this.#sourceSelector = value;
|
|
this.#observeSource();
|
|
this.requestUpdate("source");
|
|
}
|
|
|
|
get target() {
|
|
return this.#targetSelector;
|
|
}
|
|
|
|
set target(value: string) {
|
|
this.#targetSelector = value;
|
|
this.#observeTarget();
|
|
this.requestUpdate("target");
|
|
}
|
|
|
|
get sourceId() {
|
|
return this.#sourceSelector.replace("#", "");
|
|
}
|
|
|
|
set sourceId(value: string) {
|
|
this.source = `#${value}`;
|
|
}
|
|
|
|
get targetId() {
|
|
return this.#targetSelector.replace("#", "");
|
|
}
|
|
|
|
set targetId(value: string) {
|
|
this.target = `#${value}`;
|
|
}
|
|
|
|
get color() {
|
|
return this.#color;
|
|
}
|
|
|
|
set color(value: string) {
|
|
this.#color = value;
|
|
this.#updateArrow();
|
|
}
|
|
|
|
get strokeWidth() {
|
|
return this.#strokeWidth;
|
|
}
|
|
|
|
set strokeWidth(value: number) {
|
|
this.#strokeWidth = value;
|
|
this.#options.size = value * 2 + 1;
|
|
this.#updateArrow();
|
|
}
|
|
|
|
override connectedCallback() {
|
|
super.connectedCallback();
|
|
|
|
// Parse attributes
|
|
const sourceAttr = this.getAttribute("source");
|
|
const targetAttr = this.getAttribute("target");
|
|
const colorAttr = this.getAttribute("color");
|
|
const strokeAttr = this.getAttribute("stroke-width");
|
|
|
|
if (sourceAttr) this.source = sourceAttr;
|
|
if (targetAttr) this.target = targetAttr;
|
|
if (colorAttr) this.#color = colorAttr;
|
|
if (strokeAttr) this.strokeWidth = parseFloat(strokeAttr);
|
|
|
|
// Start animation frame loop for position updates
|
|
this.#startPositionTracking();
|
|
}
|
|
|
|
override disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this.#resizeObserver.disconnect();
|
|
this.#stopPositionTracking();
|
|
}
|
|
|
|
#animationFrameId: number | null = null;
|
|
|
|
#startPositionTracking() {
|
|
const track = () => {
|
|
this.#updateRects();
|
|
this.#updateArrow();
|
|
this.#animationFrameId = requestAnimationFrame(track);
|
|
};
|
|
this.#animationFrameId = requestAnimationFrame(track);
|
|
}
|
|
|
|
#stopPositionTracking() {
|
|
if (this.#animationFrameId !== null) {
|
|
cancelAnimationFrame(this.#animationFrameId);
|
|
this.#animationFrameId = null;
|
|
}
|
|
}
|
|
|
|
#observeSource() {
|
|
if (this.#sourceElement) {
|
|
this.#resizeObserver.unobserve(this.#sourceElement);
|
|
}
|
|
|
|
if (this.#sourceSelector) {
|
|
this.#sourceElement = document.querySelector(this.#sourceSelector);
|
|
if (this.#sourceElement) {
|
|
this.#resizeObserver.observe(this.#sourceElement);
|
|
}
|
|
}
|
|
}
|
|
|
|
#observeTarget() {
|
|
if (this.#targetElement) {
|
|
this.#resizeObserver.unobserve(this.#targetElement);
|
|
}
|
|
|
|
if (this.#targetSelector) {
|
|
this.#targetElement = document.querySelector(this.#targetSelector);
|
|
if (this.#targetElement) {
|
|
this.#resizeObserver.observe(this.#targetElement);
|
|
}
|
|
}
|
|
}
|
|
|
|
#updateRects() {
|
|
if (this.#sourceElement) {
|
|
this.#sourceRect = this.#sourceElement.getBoundingClientRect();
|
|
}
|
|
if (this.#targetElement) {
|
|
this.#targetRect = this.#targetElement.getBoundingClientRect();
|
|
}
|
|
}
|
|
|
|
#updateArrow() {
|
|
if (!this.#sourceRect || !this.#targetRect) {
|
|
this.style.clipPath = "";
|
|
this.style.display = "none";
|
|
return;
|
|
}
|
|
|
|
this.style.display = "";
|
|
|
|
const [sx, sy, cx, cy, ex, ey] = getBoxToBoxArrow(
|
|
this.#sourceRect.x,
|
|
this.#sourceRect.y,
|
|
this.#sourceRect.width,
|
|
this.#sourceRect.height,
|
|
this.#targetRect.x,
|
|
this.#targetRect.y,
|
|
this.#targetRect.width,
|
|
this.#targetRect.height,
|
|
);
|
|
|
|
const points = pointsOnBezierCurves([
|
|
{ x: sx, y: sy },
|
|
{ x: cx, y: cy },
|
|
{ x: ex, y: ey },
|
|
{ x: ex, y: ey },
|
|
]);
|
|
|
|
const stroke = getStroke(points, this.#options);
|
|
const path = getSvgPathFromStroke(stroke);
|
|
|
|
this.style.clipPath = `path('${path}')`;
|
|
this.style.backgroundColor = this.#color;
|
|
}
|
|
|
|
override createRenderRoot() {
|
|
const root = super.createRenderRoot();
|
|
this.#updateArrow();
|
|
return root;
|
|
}
|
|
|
|
toJSON() {
|
|
return {
|
|
type: "folk-arrow",
|
|
id: this.id,
|
|
sourceId: this.sourceId,
|
|
targetId: this.targetId,
|
|
color: this.#color,
|
|
strokeWidth: this.#strokeWidth,
|
|
};
|
|
}
|
|
}
|