rspace-online/lib/folk-arrow.ts

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,
};
}
}