diff --git a/demo/wiggly.html b/demo/wiggly.html
new file mode 100644
index 0000000..070edfe
--- /dev/null
+++ b/demo/wiggly.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+ Wiggly
+
+
+
+
+ Hello World
+
+
+
+
+
diff --git a/src/arrows/abstract-arrow.ts b/src/arrows/abstract-arrow.ts
index 9f383d9..2d87502 100644
--- a/src/arrows/abstract-arrow.ts
+++ b/src/arrows/abstract-arrow.ts
@@ -1,12 +1,8 @@
+import { Vertex } from './utils';
import { VisualObserverEntry, VisualObserverManager } from './visual-observer';
const visualObserver = new VisualObserverManager();
-interface Vertex {
- x: number;
- y: number;
-}
-
const vertexRegex = /(?-?([0-9]*[.])?[0-9]+),\s*(?-?([0-9]*[.])?[0-9]+)/;
function parseVertex(str: string): Vertex | null {
@@ -38,7 +34,7 @@ export class AbstractArrow extends HTMLElement {
this.observeSource();
}
- #sourceRect!: DOMRectReadOnly;
+ #sourceRect: DOMRectReadOnly | undefined;
get sourceRect() {
return this.#sourceRect;
}
@@ -64,7 +60,7 @@ export class AbstractArrow extends HTMLElement {
this.observeTarget();
}
- #targetRect!: DOMRectReadOnly;
+ #targetRect: DOMRectReadOnly | undefined;
get targetRect() {
return this.#targetRect;
}
@@ -80,8 +76,8 @@ export class AbstractArrow extends HTMLElement {
};
connectedCallback() {
- this.source = this.getAttribute('source') || '';
- this.target = this.getAttribute('target') || '';
+ this.source = this.getAttribute('source') || this.#source;
+ this.target = this.getAttribute('target') || this.#target;
}
disconnectedCallback() {
@@ -103,14 +99,14 @@ export class AbstractArrow extends HTMLElement {
this.#sourceRect = DOMRectReadOnly.fromRect(vertex);
this.#update();
} else {
- const el = document.querySelector(this.source);
+ this.#sourceElement = document.querySelector(this.source);
- if (el === null) {
+ if (this.#sourceElement === null) {
throw new Error('source is not a valid element');
}
- this.#sourceElement = el;
visualObserver.observe(this.#sourceElement, this.#sourceCallback);
+ this.#sourceRect = this.#sourceElement.getBoundingClientRect();
}
}
@@ -136,6 +132,7 @@ export class AbstractArrow extends HTMLElement {
}
visualObserver.observe(this.#targetElement, this.#targetCallback);
+ this.#targetRect = this.#targetElement.getBoundingClientRect();
}
}
@@ -148,8 +145,9 @@ export class AbstractArrow extends HTMLElement {
#update() {
if (this.#sourceRect === undefined || this.#targetRect === undefined) return;
- this.render();
+ this.render(this.#sourceRect, this.#targetRect);
}
- render() {}
+ // @ts-ignore
+ render(sourceRect: DOMRectReadOnly, targetRect: DOMRectReadOnly) {}
}
diff --git a/src/arrows/fc-connection.ts b/src/arrows/fc-connection.ts
index 7fa883f..25e5203 100644
--- a/src/arrows/fc-connection.ts
+++ b/src/arrows/fc-connection.ts
@@ -56,6 +56,11 @@ export class FolkConnection extends AbstractArrow {
override render() {
const { sourceRect, targetRect } = this;
+ if (sourceRect === undefined || targetRect === undefined) {
+ this.style.clipPath = '';
+ return;
+ }
+
const [sx, sy, cx, cy, ex, ey] = getBoxToBoxArrow(
sourceRect.x,
sourceRect.y,
diff --git a/src/arrows/fc-rope.ts b/src/arrows/fc-rope.ts
new file mode 100644
index 0000000..a040b22
--- /dev/null
+++ b/src/arrows/fc-rope.ts
@@ -0,0 +1,357 @@
+import { AbstractArrow } from './abstract-arrow.ts';
+import { Vertex } from './utils.ts';
+//A small scaffold specifically to help me design code pen interactions
+
+//Math extensions
+const lerp = (first, second, percentage) => {
+ return first + (second - first) * percentage;
+};
+
+class Vector2 {
+ static zero() {
+ return { x: 0, y: 0 };
+ }
+
+ static sub(a, b) {
+ return { x: a.x - b.x, y: a.y - b.y };
+ }
+
+ static add(a, b) {
+ return { x: a.x + b.x, y: a.y + b.y };
+ }
+
+ static mult(a, b) {
+ return { x: a.x * b.x, y: a.y * b.y };
+ }
+
+ static scale(v, scaleFactor) {
+ return { x: v.x * scaleFactor, y: v.y * scaleFactor };
+ }
+
+ static mag(v) {
+ return Math.sqrt(v.x * v.x + v.y * v.y);
+ }
+
+ static normalized(v) {
+ const mag = Vector2.mag(v);
+
+ if (mag === 0) {
+ return Vector2.zero();
+ }
+ return { x: v.x / mag, y: v.y / mag };
+ }
+}
+
+class App {
+ constructor(window, canvas, context, updateHandler, drawHandler, frameRate = 60) {
+ this._window = window;
+ this._canvas = canvas;
+ this._context = context;
+ this._updateHandler = updateHandler;
+ this._drawHandler = drawHandler;
+ this._frameRate = frameRate;
+ this._lastTime = 0;
+ this._currentTime = 0;
+ this._deltaTime = 0;
+ this._interval = 0;
+ this.onMouseMoveHandler = (x, y) => {};
+ this.onMouseDownHandler = (x, y) => {};
+ this.start = this.start.bind(this);
+ this._onMouseEventHandlerWrapper = this._onMouseEventHandlerWrapper.bind(this);
+ this._onRequestAnimationFrame = this._onRequestAnimationFrame.bind(this);
+ }
+
+ start() {
+ this._lastTime = new Date().getTime();
+ this._currentTime = 0;
+ this._deltaTime = 0;
+ this._interval = 1000 / this._frameRate;
+
+ document.addEventListener(
+ 'mousemove',
+ (e) => {
+ this._onMouseEventHandlerWrapper(e, this.onMouseMoveHandler);
+ },
+ false
+ );
+
+ document.addEventListener(
+ 'mousedown',
+ (e) => {
+ this._onMouseEventHandlerWrapper(e, this.onMouseDownHandler);
+ },
+ false
+ );
+
+ this._onRequestAnimationFrame();
+ }
+
+ _onMouseEventHandlerWrapper(e, callback) {
+ let element = this._canvas;
+ let offsetX = 0;
+ let offsetY = 0;
+
+ if (element.offsetParent) {
+ do {
+ offsetX += element.offsetLeft;
+ offsetY += element.offsetTop;
+ } while ((element = element.offsetParent));
+ }
+
+ const x = e.pageX - offsetX;
+ const y = e.pageY - offsetY;
+
+ callback(x, y);
+ }
+
+ _onRequestAnimationFrame() {
+ this._window.requestAnimationFrame(this._onRequestAnimationFrame);
+
+ this._currentTime = new Date().getTime();
+ this._deltaTime = this._currentTime - this._lastTime;
+
+ if (this._deltaTime > this._interval) {
+ //delta time in seconds
+ const dts = this._deltaTime * 0.001;
+
+ this._updateHandler(dts);
+
+ this._context.clearRect(0, 0, this._canvas.width, this._canvas.height);
+ this._drawHandler(this._canvas, this._context, dts);
+
+ this._lastTime = this._currentTime - (this._deltaTime % this._interval);
+ }
+ }
+}
+//each rope part is one of these
+//uses a high precison varient of Störmer–Verlet integration
+//to keep the simulation consistant otherwise it would "explode"!
+class RopePoint {
+ //integrates motion equations per node without taking into account relationship
+ //with other nodes...
+ static integrate(point, gravity, dt, previousFrameDt) {
+ if (!point.isFixed) {
+ point.velocity = Vector2.sub(point.pos, point.oldPos);
+ point.oldPos = { ...point.pos };
+
+ //drastically improves stability
+ let timeCorrection = previousFrameDt != 0.0 ? dt / previousFrameDt : 0.0;
+
+ let accel = Vector2.add(gravity, { x: 0, y: point.mass });
+
+ const velCoef = timeCorrection * point.damping;
+ const accelCoef = Math.pow(dt, 2);
+
+ point.pos.x += point.velocity.x * velCoef + accel.x * accelCoef;
+ point.pos.y += point.velocity.y * velCoef + accel.y * accelCoef;
+ } else {
+ point.velocity = Vector2.zero();
+ point.oldPos = { ...point.pos };
+ }
+ }
+
+ //apply constraints related to other nodes next to it
+ //(keeps each node within distance)
+ static constrain(point) {
+ if (point.next) {
+ const delta = Vector2.sub(point.next.pos, point.pos);
+ const len = Vector2.mag(delta);
+ const diff = len - point.distanceToNextPoint;
+ const normal = Vector2.normalized(delta);
+
+ if (!point.isFixed) {
+ point.pos.x += normal.x * diff * 0.25;
+ point.pos.y += normal.y * diff * 0.25;
+ }
+
+ if (!point.next.isFixed) {
+ point.next.pos.x -= normal.x * diff * 0.25;
+ point.next.pos.y -= normal.y * diff * 0.25;
+ }
+ }
+ if (point.prev) {
+ const delta = Vector2.sub(point.prev.pos, point.pos);
+ const len = Vector2.mag(delta);
+ const diff = len - point.distanceToNextPoint;
+ const normal = Vector2.normalized(delta);
+
+ if (!point.isFixed) {
+ point.pos.x += normal.x * diff * 0.25;
+ point.pos.y += normal.y * diff * 0.25;
+ }
+
+ if (!point.prev.isFixed) {
+ point.prev.pos.x -= normal.x * diff * 0.25;
+ point.prev.pos.y -= normal.y * diff * 0.25;
+ }
+ }
+ }
+
+ constructor(initialPos, distanceToNextPoint) {
+ this.pos = initialPos;
+ this.distanceToNextPoint = distanceToNextPoint;
+ this.isFixed = false;
+ this.oldPos = { ...initialPos };
+ this.velocity = Vector2.zero();
+ this.mass = 1.0;
+ this.damping = 1.0;
+ this.prev = null;
+ this.next = null;
+ }
+}
+
+//manages a collection of rope points and executes
+//the integration
+class Rope {
+ //generate an array of points suitable for a dynamic
+ //rope contour
+ static generate(start, end, resolution, mass, damping) {
+ const delta = Vector2.sub(end, start);
+ const len = Vector2.mag(delta);
+
+ let points = [];
+ const pointsLen = len / resolution;
+
+ for (let i = 0; i < pointsLen; i++) {
+ const percentage = i / (pointsLen - 1);
+
+ const lerpX = lerp(start.x, end.x, percentage);
+ const lerpY = lerp(start.y, end.y, percentage);
+
+ points[i] = new RopePoint({ x: lerpX, y: lerpY }, resolution);
+ points[i].mass = mass;
+ points[i].damping = damping;
+ }
+
+ //Link nodes into a doubly linked list
+ for (let i = 0; i < pointsLen; i++) {
+ const prev = i != 0 ? points[i - 1] : null;
+ const curr = points[i];
+ const next = i != pointsLen - 1 ? points[i + 1] : null;
+
+ curr.prev = prev;
+ curr.next = next;
+ }
+
+ points[0].isFixed = points[points.length - 1].isFixed = true;
+
+ return points;
+ }
+
+ constructor(points, solverIterations) {
+ this._points = points;
+ this.update = this.update.bind(this);
+ this._prevDelta = 0;
+ this._solverIterations = solverIterations;
+
+ this.getPoint = this.getPoint.bind(this);
+ }
+
+ getPoint(index) {
+ return this._points[index];
+ }
+
+ update(gravity, dt) {
+ for (let i = 0; i < this._points.length; i++) {
+ let point = this._points[i];
+
+ let accel = { ...gravity };
+
+ RopePoint.integrate(point, accel, dt, this._prevDelta);
+ }
+
+ for (let iteration = 0; iteration < this._solverIterations; iteration++)
+ for (let i = 0; i < this._points.length; i++) {
+ let point = this._points[i];
+ RopePoint.constrain(point);
+ }
+
+ this._prevDelta = dt;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'fc-rope': FolkRope;
+ }
+}
+
+export class FolkRope extends AbstractArrow {
+ static override tagName = 'fc-rope';
+
+ #canvas = document.createElement('canvas');
+ #context = this.#canvas.getContext('2d')!;
+ #shadow = this.attachShadow({ mode: 'open' });
+ #args;
+
+ constructor() {
+ super();
+ const context = this.#context;
+ this.#canvas.width = this.clientWidth;
+ this.#canvas.height = this.clientHeight;
+
+ const gradient = this.#context.createLinearGradient(0, 0, 500, 0);
+ gradient.addColorStop(0, 'white');
+ gradient.addColorStop(0.25, 'yellow');
+ gradient.addColorStop(0.5, 'blue');
+ gradient.addColorStop(0.75, 'red');
+ gradient.addColorStop(1.0, 'white');
+
+ const args = (this.#args = {
+ start: { x: 100, y: this.#canvas.height / 2 },
+ end: { x: this.#canvas.width - 100, y: this.#canvas.height / 2 },
+ resolution: 10,
+ mass: 1,
+ damping: 0.99,
+ gravity: { x: 0, y: 3000 },
+ solverIterations: 600,
+ ropeColour: gradient,
+ ropeSize: 2,
+ });
+
+ const points = Rope.generate(args.start, args.end, args.resolution, args.mass, args.damping);
+
+ let rope = new Rope(points, args.solverIterations);
+
+ const tick = (dt) => {
+ rope.update(args.gravity, dt);
+ };
+
+ const drawRopePoints = (points, colour, width) => {
+ for (let i = 0; i < points.length; i++) {
+ let p = points[i];
+
+ const prev = i > 0 ? points[i - 1] : null;
+
+ if (prev) {
+ context.beginPath();
+ context.moveTo(prev.pos.x, prev.pos.y);
+ context.lineTo(p.pos.x, p.pos.y);
+ context.lineWidth = width;
+ context.strokeStyle = colour;
+ context.stroke();
+ }
+ }
+ };
+
+ //render a rope using the verlet points
+ const draw = (dt) => {
+ drawRopePoints(points, args.ropeColour, args.ropeSize);
+ };
+
+ const onMouseMove = (x, y) => {
+ let point = rope.getPoint(0);
+ point.pos.x = x;
+ point.pos.y = y;
+ };
+
+ const app = new App(window, this.#canvas, context, tick, draw);
+
+ app.onMouseMoveHandler = onMouseMove;
+ app.start();
+
+ this.#shadow.appendChild(this.#canvas);
+ }
+
+ render() {}
+}
diff --git a/src/arrows/utils.ts b/src/arrows/utils.ts
index 7be3261..dd17e8c 100644
--- a/src/arrows/utils.ts
+++ b/src/arrows/utils.ts
@@ -2,6 +2,11 @@
export type Point = [number, number];
+export interface Vertex {
+ x: number;
+ y: number;
+}
+
// distance between 2 points
function distance(p1: Point, p2: Point): number {
return Math.sqrt(distanceSq(p1, p2));