mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 10:46:03 +10:00
update to node site
This commit is contained in:
516
website/scripts/PinConnectionManager.js
Normal file
516
website/scripts/PinConnectionManager.js
Normal file
@@ -0,0 +1,516 @@
|
||||
import { getTypeColor } from "./getTypeColor.js";
|
||||
import { GraphConnection } from "./GraphConnection.js";
|
||||
|
||||
/**
|
||||
* @typedef {import('./NodeRenderer.js').NodeRenderer} NodeRenderer
|
||||
* @typedef {import('./WorkspaceNavigator.js').WorkspaceNavigator} WorkspaceNavigator
|
||||
* @typedef {import('./ConnectionRenderer.js').ConnectionRenderer} ConnectionRenderer
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{ nodeId: string, pinId: string }} PinRef
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* direction: 'input' | 'output',
|
||||
* source: PinRef | undefined,
|
||||
* target: PinRef | undefined,
|
||||
* path: SVGPathElement,
|
||||
* circle: SVGCircleElement,
|
||||
* anchorX: number,
|
||||
* anchorY: number,
|
||||
* lastPointerX: number,
|
||||
* lastPointerY: number,
|
||||
* }} PendingConnection
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handles pin-to-pin connection dragging in world-space.
|
||||
*
|
||||
* Mirrors the gesture flow from Picograph's BlueprintWorkspace:
|
||||
* pointerdown on handle → temp SVG path follows cursor → pointerup on
|
||||
* compatible pin → commit connection.
|
||||
*
|
||||
* Only active when {@link PinConnectionManager.enabled} is true.
|
||||
*/
|
||||
export class PinConnectionManager {
|
||||
/** @type {HTMLElement} */
|
||||
#canvas;
|
||||
|
||||
/** @type {SVGElement} */
|
||||
#svg;
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
#nodeLayer;
|
||||
|
||||
/** @type {NodeRenderer} */
|
||||
#nodeRenderer;
|
||||
|
||||
/** @type {WorkspaceNavigator} */
|
||||
#nav;
|
||||
|
||||
/** @type {ConnectionRenderer} */
|
||||
#connRenderer;
|
||||
|
||||
/** @type {PendingConnection | null} */
|
||||
#pending = null;
|
||||
|
||||
/** @type {boolean} */
|
||||
#enabled = false;
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} canvas - Workspace canvas element.
|
||||
* @param {SVGElement} svg - Connection layer SVG element.
|
||||
* @param {HTMLElement} nodeLayer - Node layer element.
|
||||
* @param {NodeRenderer} nodeRenderer
|
||||
* @param {WorkspaceNavigator} nav
|
||||
* @param {ConnectionRenderer} connRenderer
|
||||
*/
|
||||
constructor(canvas, svg, nodeLayer, nodeRenderer, nav, connRenderer) {
|
||||
this.#canvas = canvas;
|
||||
this.#svg = svg;
|
||||
this.#nodeLayer = nodeLayer;
|
||||
this.#nodeRenderer = nodeRenderer;
|
||||
this.#nav = nav;
|
||||
this.#connRenderer = connRenderer;
|
||||
|
||||
this.#nodeLayer.addEventListener("pointerdown", (e) => {
|
||||
if (!this.#enabled) return;
|
||||
this.#handleNodeLayerPointerDown(e);
|
||||
});
|
||||
|
||||
this.#nodeLayer.addEventListener("pointerenter", (e) => this.#handlePinHover(e, true), true);
|
||||
this.#nodeLayer.addEventListener("pointerleave", (e) => this.#handlePinHover(e, false), true);
|
||||
}
|
||||
|
||||
// ─── Public ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** @param {boolean} v */
|
||||
set enabled(v) { this.#enabled = v; }
|
||||
|
||||
/** @returns {boolean} */
|
||||
get enabled() { return this.#enabled; }
|
||||
|
||||
/** @returns {PendingConnection | null} */
|
||||
get pending() { return this.#pending; }
|
||||
|
||||
// ─── Private — event detection ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Highlights or clears connection wires when the pointer enters or leaves a pin handle.
|
||||
*
|
||||
* @param {PointerEvent} e
|
||||
* @param {boolean} entering
|
||||
*/
|
||||
#handlePinHover(e, entering) {
|
||||
const target = /** @type {HTMLElement} */ (e.target);
|
||||
const handle = target.closest(".pin-handle");
|
||||
if (!handle) return;
|
||||
|
||||
const pinContainer = handle.closest("[data-pin-id]");
|
||||
const nodeArticle = handle.closest("[data-node-id]");
|
||||
if (!pinContainer || !nodeArticle) return;
|
||||
|
||||
const nodeId = /** @type {HTMLElement} */ (nodeArticle).dataset.nodeId;
|
||||
const pinId = /** @type {HTMLElement} */ (pinContainer).dataset.pinId;
|
||||
if (!nodeId || !pinId) return;
|
||||
|
||||
if (entering) {
|
||||
this.#connRenderer.highlightPin(nodeId, pinId);
|
||||
} else {
|
||||
this.#connRenderer.clearHighlight();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} e
|
||||
*/
|
||||
#handleNodeLayerPointerDown(e) {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
const target = /** @type {HTMLElement} */ (e.target);
|
||||
const handle = target.closest(".pin-handle");
|
||||
if (!handle) return;
|
||||
|
||||
const pinContainer = handle.closest("[data-pin-id]");
|
||||
const nodeArticle = handle.closest("[data-node-id]");
|
||||
if (!pinContainer || !nodeArticle) return;
|
||||
|
||||
const nodeId = /** @type {HTMLElement} */ (nodeArticle).dataset.nodeId;
|
||||
const pinId = /** @type {HTMLElement} */ (pinContainer).dataset.pinId;
|
||||
const direction = /** @type {'input'|'output'} */ (
|
||||
/** @type {HTMLElement} */ (pinContainer).dataset.direction
|
||||
);
|
||||
|
||||
if (!nodeId || !pinId || !direction) return;
|
||||
|
||||
// Retrieve the pin kind so we can colour the temp wire
|
||||
const node = this.#nodeRenderer.getNodes().find(n => n.id === nodeId);
|
||||
if (!node) return;
|
||||
const pin = node.getPin(pinId);
|
||||
if (!pin) return;
|
||||
|
||||
this.#startPendingConnectionGesture(e, nodeId, pinId, direction, pin.kind);
|
||||
}
|
||||
|
||||
// ─── Private — gesture ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mirrors Picograph's `#startPendingConnectionGesture`.
|
||||
*
|
||||
* @param {PointerEvent} event
|
||||
* @param {string} nodeId
|
||||
* @param {string} pinId
|
||||
* @param {'input'|'output'} direction
|
||||
* @param {string} kind
|
||||
*/
|
||||
#startPendingConnectionGesture(event, nodeId, pinId, direction, kind) {
|
||||
if (this.#pending) this.#cancelPendingConnection();
|
||||
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const path = this.#createConnectionPath(kind);
|
||||
this.#svg.appendChild(path);
|
||||
|
||||
// Add endpoint circle for visual cursor alignment
|
||||
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||
circle.setAttribute("r", "4");
|
||||
circle.setAttribute("fill", getTypeColor(kind));
|
||||
circle.setAttribute("opacity", "0.9");
|
||||
circle.dataset.pending = "true";
|
||||
this.#svg.appendChild(circle);
|
||||
|
||||
// Compute initial anchor position in screen-space
|
||||
const canvasRect = this.#canvas.getBoundingClientRect();
|
||||
const startHandle = this.#nodeRenderer.getPinHandle(nodeId, pinId, direction);
|
||||
const startRect = startHandle ? startHandle.getBoundingClientRect() : null;
|
||||
const initAnchorX = startRect ? startRect.left - canvasRect.left + startRect.width / 2 : 0;
|
||||
const initAnchorY = startRect ? startRect.top - canvasRect.top + startRect.height / 2 : 0;
|
||||
|
||||
this.#pending = {
|
||||
direction,
|
||||
source: direction === "output" ? { nodeId, pinId } : undefined,
|
||||
target: direction === "input" ? { nodeId, pinId } : undefined,
|
||||
path,
|
||||
circle,
|
||||
anchorX: initAnchorX,
|
||||
anchorY: initAnchorY,
|
||||
lastPointerX: initAnchorX,
|
||||
lastPointerY: initAnchorY,
|
||||
};
|
||||
|
||||
const handlePointerMove = (/** @type {PointerEvent} */ moveE) => {
|
||||
if (moveE.pointerId !== event.pointerId) return;
|
||||
this.#updatePendingConnectionPath(moveE.clientX, moveE.clientY, nodeId, pinId, direction);
|
||||
};
|
||||
|
||||
const handlePointerUp = (/** @type {PointerEvent} */ upE) => {
|
||||
if (upE.pointerId !== event.pointerId) return;
|
||||
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
window.removeEventListener("pointercancel", handlePointerUp);
|
||||
|
||||
this.#tryFinalizeAtPoint(upE.clientX, upE.clientY);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Picograph's `#updatePendingConnectionPath`.
|
||||
*
|
||||
* @param {number} clientX
|
||||
* @param {number} clientY
|
||||
* @param {string} nodeId
|
||||
* @param {string} pinId
|
||||
* @param {'input'|'output'} direction
|
||||
*/
|
||||
#updatePendingConnectionPath(clientX, clientY, nodeId, pinId, direction) {
|
||||
if (!this.#pending) return;
|
||||
|
||||
const startHandle = this.#nodeRenderer.getPinHandle(nodeId, pinId, direction);
|
||||
if (!startHandle) return;
|
||||
|
||||
const canvasRect = this.#canvas.getBoundingClientRect();
|
||||
const startRect = startHandle.getBoundingClientRect();
|
||||
|
||||
// Screen-space coordinates
|
||||
const anchor = {
|
||||
x: startRect.left - canvasRect.left + startRect.width / 2,
|
||||
y: startRect.top - canvasRect.top + startRect.height / 2,
|
||||
};
|
||||
const pointer = {
|
||||
x: clientX - canvasRect.left,
|
||||
y: clientY - canvasRect.top,
|
||||
};
|
||||
|
||||
// Keep anchor and pointer up-to-date for springback animation
|
||||
this.#pending.anchorX = anchor.x;
|
||||
this.#pending.anchorY = anchor.y;
|
||||
this.#pending.lastPointerX = pointer.x;
|
||||
this.#pending.lastPointerY = pointer.y;
|
||||
|
||||
const start = direction === "output" ? anchor : pointer;
|
||||
const end = direction === "output" ? pointer : anchor;
|
||||
|
||||
const cpOffset = Math.max(60, Math.abs(end.x - start.x) * 0.5);
|
||||
|
||||
// Lerp pointer-side control point based on drag direction
|
||||
// When pointer.x >= anchor.x: factor = 0 (forward, offset = -cpOffset)
|
||||
// When pointer.x < anchor.x: factor increases (backward, offset lerps to +cpOffset)
|
||||
const dragDelta = pointer.x - anchor.x;
|
||||
const lerpFactor = Math.max(0, Math.min(1, -dragDelta / (cpOffset * 2)));
|
||||
const pointerCpOffset = -cpOffset + (lerpFactor * cpOffset * 2); // lerp from -cpOffset to +cpOffset
|
||||
|
||||
// Make the pointer-side control point curve toward the pin
|
||||
let cp1x, cp1y, cp2x, cp2y;
|
||||
if (direction === "output") {
|
||||
// start = anchor (pin), end = pointer
|
||||
// Anchor side always extends right, pointer side lerps
|
||||
cp1x = start.x + cpOffset;
|
||||
cp1y = start.y;
|
||||
cp2x = end.x + pointerCpOffset;
|
||||
cp2y = end.y + (anchor.y - pointer.y) * 0.5; // curve toward anchor
|
||||
} else {
|
||||
// start = pointer, end = anchor (pin)
|
||||
// Pointer side lerps, anchor side always extends left
|
||||
cp1x = start.x + pointerCpOffset;
|
||||
cp1y = start.y + (anchor.y - pointer.y) * 0.5; // curve toward anchor
|
||||
cp2x = end.x - cpOffset;
|
||||
cp2y = end.y;
|
||||
}
|
||||
|
||||
const d = `M ${start.x} ${start.y} C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${end.x} ${end.y}`;
|
||||
|
||||
this.#pending.path.dataset.active = "true";
|
||||
this.#pending.path.setAttribute("d", d);
|
||||
|
||||
// Update circle position to pointer location
|
||||
this.#pending.circle.setAttribute("cx", pointer.x.toString());
|
||||
this.#pending.circle.setAttribute("cy", pointer.y.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the elements under the pointer on release for a compatible pin.
|
||||
* Mirrors the drop detection pattern from Picograph's WorkspaceNodeRenderer's
|
||||
* `pointerup` handler on pin containers.
|
||||
*
|
||||
* @param {number} clientX
|
||||
* @param {number} clientY
|
||||
*/
|
||||
#tryFinalizeAtPoint(clientX, clientY) {
|
||||
if (!this.#pending) return;
|
||||
|
||||
const elements = document.elementsFromPoint(clientX, clientY);
|
||||
for (const el of elements) {
|
||||
if (!(el instanceof HTMLElement)) continue;
|
||||
|
||||
const pinContainer = el.closest("[data-pin-id]");
|
||||
const nodeArticle = el.closest("[data-node-id]");
|
||||
if (!pinContainer || !nodeArticle) continue;
|
||||
|
||||
const targetNodeId = /** @type {HTMLElement} */ (nodeArticle).dataset.nodeId;
|
||||
const targetPinId = /** @type {HTMLElement} */ (pinContainer).dataset.pinId;
|
||||
const targetDirection = /** @type {'input'|'output'} */ (
|
||||
/** @type {HTMLElement} */ (pinContainer).dataset.direction
|
||||
);
|
||||
|
||||
if (!targetNodeId || !targetPinId || !targetDirection) continue;
|
||||
|
||||
this.#finalizeConnection(targetNodeId, targetPinId, targetDirection);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#cancelPendingConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Picograph's `#finalizeConnection`.
|
||||
*
|
||||
* @param {string} nodeId
|
||||
* @param {string} pinId
|
||||
* @param {'input'|'output'} direction
|
||||
*/
|
||||
#finalizeConnection(nodeId, pinId, direction) {
|
||||
if (!this.#pending) return;
|
||||
|
||||
const { source, target } = this.#pending;
|
||||
|
||||
/** @type {PinRef | null} */
|
||||
let fromRef = null;
|
||||
/** @type {PinRef | null} */
|
||||
let toRef = null;
|
||||
|
||||
if (direction === "input" && source) {
|
||||
fromRef = source;
|
||||
toRef = { nodeId, pinId };
|
||||
} else if (direction === "output" && target) {
|
||||
fromRef = { nodeId, pinId };
|
||||
toRef = target;
|
||||
}
|
||||
|
||||
if (fromRef && toRef && fromRef.nodeId !== toRef.nodeId) {
|
||||
const fromNode = this.#nodeRenderer.getNodes().find(n => n.id === fromRef.nodeId);
|
||||
const toNode = this.#nodeRenderer.getNodes().find(n => n.id === toRef.nodeId);
|
||||
const fromPin = fromNode?.getPin(fromRef.pinId);
|
||||
const toPin = toNode?.getPin(toRef.pinId);
|
||||
|
||||
if (fromPin && toPin && this.#kindsCompatible(fromPin.kind, toPin.kind)) {
|
||||
const conn = new GraphConnection(fromRef, toRef, fromPin.kind);
|
||||
this.#nodeRenderer.addConnection(conn);
|
||||
this.#pending.path.remove();
|
||||
this.#pending.circle.remove();
|
||||
this.#pending = null;
|
||||
this.#connRenderer.render();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.#cancelPendingConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Picograph's `#cancelPendingConnection`.
|
||||
* Launches a springback animation before removing the wire.
|
||||
*/
|
||||
#cancelPendingConnection() {
|
||||
if (!this.#pending) return;
|
||||
|
||||
const { path, circle, direction, anchorX, anchorY, lastPointerX, lastPointerY } = this.#pending;
|
||||
this.#pending = null;
|
||||
|
||||
// Remove circle immediately (no springback needed)
|
||||
circle.remove();
|
||||
|
||||
this.#animateWireSpringback(path, direction, anchorX, anchorY, lastPointerX, lastPointerY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retracts the wire with a whip character: the free end snaps home quickly
|
||||
* while the bezier arch sags and decays at a slower rate, giving the visual
|
||||
* impression of a real cable being released.
|
||||
*
|
||||
* @param {SVGPathElement} path
|
||||
* @param {'input'|'output'} direction
|
||||
* @param {number} anchorX - Anchor screen-space X.
|
||||
* @param {number} anchorY - Anchor screen-space Y.
|
||||
* @param {number} fromX - Free-end X at release.
|
||||
* @param {number} fromY - Free-end Y at release.
|
||||
*/
|
||||
#animateWireSpringback(path, direction, anchorX, anchorY, fromX, fromY) {
|
||||
// Endpoint snaps home fast; arch shape collapses slower — two different rates
|
||||
// produce the non-linear whip character.
|
||||
const endpointDecay = 0.022; // px/ms half-life ~31ms — fast snap
|
||||
const archDecay = 0.007; // half-life ~99ms — slow arch collapse
|
||||
const wobbleDecay = 0.008; // wobble amplitude decay (slower than endpoint, faster than arch)
|
||||
|
||||
let px = fromX;
|
||||
let py = fromY;
|
||||
|
||||
// Initial sag magnitude: proportional to release distance, capped.
|
||||
const releaseDist = Math.sqrt((fromX - anchorX) ** 2 + (fromY - anchorY) ** 2);
|
||||
let sag = Math.min(releaseDist * 0.1, 30); // Reduced from 0.4/120
|
||||
let wobbleAmplitude = Math.min(releaseDist * 0.4, 80); // Increased from 0.3/60
|
||||
|
||||
let lastTime = performance.now();
|
||||
let phase = 0;
|
||||
const wobbleFreq = 0.018; // radians per ms (~2.8 Hz) - increased frequency
|
||||
|
||||
/** @param {number} now */
|
||||
const tick = (now) => {
|
||||
const dt = Math.min(now - lastTime, 32);
|
||||
lastTime = now;
|
||||
|
||||
// Endpoint: fast exponential decay toward anchor
|
||||
const ef = Math.exp(-endpointDecay * dt);
|
||||
px = anchorX + (px - anchorX) * ef;
|
||||
py = anchorY + (py - anchorY) * ef;
|
||||
|
||||
// Arch sag: slower exponential decay
|
||||
const af = Math.exp(-archDecay * dt);
|
||||
sag *= af;
|
||||
|
||||
// Wobble: amplitude decay + phase advance
|
||||
const wf = Math.exp(-wobbleDecay * dt);
|
||||
wobbleAmplitude *= wf;
|
||||
phase += wobbleFreq * dt;
|
||||
|
||||
const dx = anchorX - px;
|
||||
const dy = anchorY - py;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
const start = direction === "output" ? { x: anchorX, y: anchorY } : { x: px, y: py };
|
||||
const end = direction === "output" ? { x: px, y: py } : { x: anchorX, y: anchorY };
|
||||
|
||||
// Control points: standard horizontal tension + perpendicular sag offset + sine wave wobble
|
||||
const mx = (start.x + end.x) / 2;
|
||||
const my = (start.y + end.y) / 2;
|
||||
const len = Math.max(dist, 1);
|
||||
// Perpendicular unit vector (rotated 90°)
|
||||
const perpX = -(end.y - start.y) / len;
|
||||
const perpY = (end.x - start.x) / len;
|
||||
|
||||
const tension = Math.max(20, dist * 0.45);
|
||||
|
||||
// Apply sine wave wobble to anchor-side control point only
|
||||
const wobble = Math.sin(phase) * wobbleAmplitude;
|
||||
|
||||
// Anchor is at 'start' for output pins, 'end' for input pins
|
||||
const cp1x = start.x + tension + perpX * (sag + (direction === "output" ? wobble : 0));
|
||||
const cp1y = start.y + perpY * (sag + (direction === "output" ? wobble : 0));
|
||||
const cp2x = end.x - tension + perpX * (sag + (direction === "input" ? wobble : 0));
|
||||
const cp2y = end.y + perpY * (sag + (direction === "input" ? wobble : 0));
|
||||
|
||||
path.setAttribute("d",
|
||||
`M ${start.x} ${start.y} C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${end.x} ${end.y}`
|
||||
);
|
||||
|
||||
if (dist < 1 && sag < 1 && wobbleAmplitude < 0.5) {
|
||||
path.remove();
|
||||
} else {
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// ─── Private — helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates a styled SVG path element for the pending wire.
|
||||
* Matches Picograph's `#createConnectionPath`.
|
||||
*
|
||||
* @param {string} kind
|
||||
* @returns {SVGPathElement}
|
||||
*/
|
||||
#createConnectionPath(kind) {
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke-width", "2.5");
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
path.setAttribute("stroke-dasharray", "6 4");
|
||||
path.style.stroke = getTypeColor(kind);
|
||||
path.style.opacity = "0.8";
|
||||
path.dataset.kind = kind;
|
||||
path.dataset.pending = "true";
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if two pin kinds can be connected.
|
||||
* Mirrors Picograph's type compatibility (any pin accepts `any`).
|
||||
*
|
||||
* @param {string} a
|
||||
* @param {string} b
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#kindsCompatible(a, b) {
|
||||
return a === b || a === "any" || b === "any";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user