mirror of
https://github.com/rio-labs/rio.git
synced 2026-04-28 15:09:34 -05:00
WIP: refactor client-side graph editor code
This commit is contained in:
@@ -22,7 +22,6 @@ import { ErrorPlaceholderComponent } from "./components/errorPlaceholder";
|
||||
import { FilePickerAreaComponent } from "./components/filePickerArea";
|
||||
import { FlowComponent as FlowContainerComponent } from "./components/flowContainer";
|
||||
import { FundamentalRootComponent } from "./components/fundamentalRootComponent";
|
||||
import { GraphEditorComponent } from "./components/graphEditor";
|
||||
import { GridComponent } from "./components/grid";
|
||||
import { HeadingListItemComponent } from "./components/headingListItem";
|
||||
import { HighLevelComponent as HighLevelComponent } from "./components/highLevelComponent";
|
||||
@@ -64,6 +63,7 @@ import { TextInputComponent } from "./components/textInput";
|
||||
import { ThemeContextSwitcherComponent } from "./components/themeContextSwitcher";
|
||||
import { TooltipComponent } from "./components/tooltip";
|
||||
import { WebsiteComponent } from "./components/website";
|
||||
import { GraphEditorComponent } from "./components/graphEditor/graphEditor";
|
||||
|
||||
const COMPONENT_CLASSES = {
|
||||
"Button-builtin": ButtonComponent,
|
||||
|
||||
@@ -1,593 +0,0 @@
|
||||
import { ComponentId } from "../dataModels";
|
||||
import { ComponentBase, ComponentState } from "./componentBase";
|
||||
import { pixelsPerRem } from "../app";
|
||||
import { componentsByElement, componentsById } from "../componentManagement";
|
||||
import { NodeInputComponent } from "./nodeInput";
|
||||
import { NodeOutputComponent } from "./nodeOutput";
|
||||
|
||||
export type GraphEditorState = ComponentState & {
|
||||
_type_: "GraphEditor-builtin";
|
||||
children?: ComponentId[];
|
||||
};
|
||||
|
||||
type NodeState = {
|
||||
id: ComponentId;
|
||||
title: string;
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
type ConnectionState = {
|
||||
fromNode: ComponentId;
|
||||
fromPort: ComponentId;
|
||||
toNode: ComponentId;
|
||||
toPort: ComponentId;
|
||||
};
|
||||
|
||||
type AugmentedNodeState = NodeState & {
|
||||
element: HTMLElement;
|
||||
};
|
||||
|
||||
type AugmentedConnectionState = ConnectionState & {
|
||||
element: SVGPathElement;
|
||||
};
|
||||
|
||||
/// A connection that is only connected to a single port, with the other end
|
||||
/// currently being dragged by the user.
|
||||
type LatentConnection = {
|
||||
fixedNodeId: ComponentId;
|
||||
fixedPortId: ComponentId;
|
||||
element: SVGPathElement;
|
||||
};
|
||||
|
||||
class GraphStore {
|
||||
/// Nodes are identified by their component ID. This maps component IDs to
|
||||
/// the stored information about the node.
|
||||
private idsToNodes: Map<number, AugmentedNodeState>;
|
||||
|
||||
/// This maps node IDs to connections that are connected to them.
|
||||
private nodeIdsToConnections: Map<number, AugmentedConnectionState[]>;
|
||||
|
||||
constructor() {
|
||||
this.idsToNodes = new Map();
|
||||
this.nodeIdsToConnections = new Map();
|
||||
}
|
||||
|
||||
/// Add a node to the store. The node must not already exist in the store.
|
||||
addNode(node: AugmentedNodeState): void {
|
||||
console.assert(
|
||||
!this.idsToNodes.has(node.id),
|
||||
`Cannot add node to GraphStore because of duplicate ID ${node.id}`
|
||||
);
|
||||
this.idsToNodes.set(node.id, node);
|
||||
}
|
||||
|
||||
/// Returns an array of all nodes in the store.
|
||||
allNodes(): AugmentedNodeState[] {
|
||||
return Array.from(this.idsToNodes.values());
|
||||
}
|
||||
|
||||
/// Get a node by its ID. Throws an error if the node does not exist.
|
||||
getNodeById(nodeId: number): AugmentedNodeState {
|
||||
let node = this.idsToNodes.get(nodeId);
|
||||
|
||||
if (node === undefined) {
|
||||
throw new Error(`NodeEditor has no node with id ${nodeId}`);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/// Add a connection to the store. The connection must not already exist in
|
||||
/// the store.
|
||||
addConnection(conn: AugmentedConnectionState): void {
|
||||
// Sanity checks
|
||||
console.assert(
|
||||
this.idsToNodes.has(conn.fromNode),
|
||||
`Cannot add connection to GraphStore because source node ${conn.fromNode} does not exist`
|
||||
);
|
||||
console.assert(
|
||||
this.idsToNodes.has(conn.toNode),
|
||||
`Cannot add connection to GraphStore because destination node ${conn.toNode} does not exist`
|
||||
);
|
||||
|
||||
// Source node
|
||||
let fromConnections = this.nodeIdsToConnections.get(conn.fromNode);
|
||||
|
||||
if (fromConnections === undefined) {
|
||||
fromConnections = [];
|
||||
this.nodeIdsToConnections.set(conn.fromNode, fromConnections);
|
||||
}
|
||||
|
||||
fromConnections.push(conn);
|
||||
|
||||
// Destination node
|
||||
let toConnections = this.nodeIdsToConnections.get(conn.toNode);
|
||||
|
||||
if (toConnections === undefined) {
|
||||
toConnections = [];
|
||||
this.nodeIdsToConnections.set(conn.toNode, toConnections);
|
||||
}
|
||||
|
||||
toConnections.push(conn);
|
||||
}
|
||||
|
||||
getConnectionsForNode(nodeId: number): AugmentedConnectionState[] {
|
||||
let connections = this.nodeIdsToConnections.get(nodeId);
|
||||
|
||||
if (connections === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return connections;
|
||||
}
|
||||
}
|
||||
|
||||
function devel_getComponentByKey(key: string): ComponentBase {
|
||||
// Temporary function to get a component by its key
|
||||
for (let component of Object.values(componentsById)) {
|
||||
if (component === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (component.state._key_ === key) {
|
||||
return component;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Could not find component with key ${key}`);
|
||||
}
|
||||
|
||||
/// Given a port component, return the node component that the port is part of.
|
||||
function getNodeForPort(
|
||||
port: NodeInputComponent | NodeOutputComponent
|
||||
): ComponentBase {
|
||||
let nodeElement = port.element.closest(
|
||||
".rio-graph-editor-node > div > .rio-component"
|
||||
) as HTMLElement;
|
||||
|
||||
let nodeComponent = componentsByElement.get(nodeElement) as ComponentBase;
|
||||
|
||||
return nodeComponent;
|
||||
}
|
||||
|
||||
export class GraphEditorComponent extends ComponentBase {
|
||||
declare state: Required<GraphEditorState>;
|
||||
|
||||
private htmlChild: HTMLElement;
|
||||
private svgChild: SVGSVGElement;
|
||||
|
||||
private selectionChild: HTMLElement;
|
||||
|
||||
private graphStore: GraphStore = new GraphStore();
|
||||
|
||||
// When clicking & dragging a port, a latent connection is created. This
|
||||
// connection is already connected at one end (either start or end), but the
|
||||
// change has not yet been committed to the graph store.
|
||||
private latentConnection: LatentConnection | null = null;
|
||||
|
||||
createElement(): HTMLElement {
|
||||
// Create the HTML
|
||||
let element = document.createElement("div");
|
||||
element.classList.add("rio-graph-editor");
|
||||
|
||||
this.svgChild = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"svg"
|
||||
);
|
||||
element.appendChild(this.svgChild);
|
||||
|
||||
this.htmlChild = document.createElement("div");
|
||||
element.appendChild(this.htmlChild);
|
||||
|
||||
this.selectionChild = document.createElement("div");
|
||||
this.selectionChild.classList.add("rio-graph-editor-selection");
|
||||
this.htmlChild.appendChild(this.selectionChild);
|
||||
|
||||
// Random gradient for testing
|
||||
const gradient = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"linearGradient"
|
||||
);
|
||||
gradient.setAttribute("id", "connectionGradient");
|
||||
gradient.setAttribute("x1", "0%");
|
||||
gradient.setAttribute("y1", "0%");
|
||||
gradient.setAttribute("x2", "100%");
|
||||
gradient.setAttribute("y2", "100%");
|
||||
|
||||
const stop1 = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"stop"
|
||||
);
|
||||
stop1.setAttribute("offset", "0%");
|
||||
stop1.setAttribute("stop-color", "red");
|
||||
gradient.appendChild(stop1);
|
||||
|
||||
const stop2 = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"stop"
|
||||
);
|
||||
stop2.setAttribute("offset", "100%");
|
||||
stop2.setAttribute("stop-color", "blue");
|
||||
gradient.appendChild(stop2);
|
||||
|
||||
const defs = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"defs"
|
||||
);
|
||||
defs.appendChild(gradient);
|
||||
this.svgChild.appendChild(defs);
|
||||
|
||||
// Detect clicks on ports. Just log them for now.
|
||||
//
|
||||
// This could of course be handled by the ports themselves, but then
|
||||
// they'd somehow have to pipe that event to the editor. Instead, just
|
||||
// detect the event here, and decline it if it's not on a port.
|
||||
this.addDragHandler({
|
||||
element: element,
|
||||
onStart: this._onDragPortStart.bind(this),
|
||||
onMove: this._onDragPortMove.bind(this),
|
||||
onEnd: this._onDragPortEnd.bind(this),
|
||||
capturing: true,
|
||||
});
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
updateElement(
|
||||
deltaState: GraphEditorState,
|
||||
latentComponents: Set<ComponentBase>
|
||||
): void {
|
||||
// Spawn some children for testing
|
||||
if (deltaState.children !== undefined) {
|
||||
// Spawn all nodes
|
||||
for (let ii = 0; ii < deltaState.children.length; ii++) {
|
||||
let childId = deltaState.children[ii];
|
||||
|
||||
let rawNode: NodeState = {
|
||||
id: childId,
|
||||
title: `Node ${ii}`,
|
||||
left: 10 + ii * 10,
|
||||
top: 10 + ii * 10,
|
||||
};
|
||||
let augmentedNode = this._makeNode(latentComponents, rawNode);
|
||||
this.graphStore.addNode(augmentedNode);
|
||||
}
|
||||
|
||||
// Connect some of them
|
||||
requestAnimationFrame(() => {
|
||||
let fromPortComponent = devel_getComponentByKey("out_1");
|
||||
let toPortComponent = devel_getComponentByKey("in_1");
|
||||
|
||||
let augmentedConn: AugmentedConnectionState = {
|
||||
// @ts-ignore
|
||||
fromNode: deltaState.children[1],
|
||||
fromPort: fromPortComponent.id,
|
||||
// @ts-ignore
|
||||
toNode: deltaState.children[2],
|
||||
toPort: toPortComponent.id,
|
||||
element: this._makeConnectionElement(),
|
||||
};
|
||||
this.graphStore.addConnection(augmentedConn);
|
||||
this._updateConnectionFromObject(augmentedConn);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onDragPortStart(event: PointerEvent): boolean {
|
||||
// Only care about left clicks
|
||||
if (event.button !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the port that was clicked
|
||||
let targetElement = event.target as HTMLElement;
|
||||
if (!targetElement.classList.contains("rio-graph-editor-port-circle")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let portElement = targetElement.parentElement as HTMLElement;
|
||||
console.assert(
|
||||
portElement.classList.contains("rio-graph-editor-port"),
|
||||
"Port element does not have the expected class"
|
||||
);
|
||||
|
||||
let portComponent = componentsByElement.get(portElement) as
|
||||
| NodeInputComponent
|
||||
| NodeOutputComponent;
|
||||
console.assert(
|
||||
portComponent !== undefined,
|
||||
"Port element does not have a corresponding component"
|
||||
);
|
||||
|
||||
// Create a latent connection
|
||||
this.latentConnection = {
|
||||
fixedNodeId: getNodeForPort(portComponent).id,
|
||||
fixedPortId: portComponent.id,
|
||||
element: this._makeConnectionElement(),
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_onDragPortMove(event: PointerEvent): void {
|
||||
// Make sure there is actually a latent connection. This may not always
|
||||
// be guaranteed if there are multiple pointer devices.
|
||||
if (this.latentConnection === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the latent connection
|
||||
const fixedPortComponent = componentsById[
|
||||
this.latentConnection.fixedPortId
|
||||
] as NodeInputComponent | NodeOutputComponent | undefined;
|
||||
|
||||
// The element could've been deleted in the meantime
|
||||
if (fixedPortComponent === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
let [x1, y1] = this._getPortPosition(fixedPortComponent);
|
||||
let [x2, y2] = [event.clientX, event.clientY];
|
||||
|
||||
// If dragging from an input port, flip the coordinates
|
||||
if (fixedPortComponent instanceof NodeInputComponent) {
|
||||
[x1, y1, x2, y2] = [x2, y2, x1, y1];
|
||||
}
|
||||
|
||||
// Update the SVG path
|
||||
this._updateConnectionFromCoordinates(
|
||||
this.latentConnection.element,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2
|
||||
);
|
||||
}
|
||||
|
||||
_onDragPortEnd(event: PointerEvent): void {
|
||||
// Once again, make sure there is actually a latent connection.
|
||||
if (this.latentConnection === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop the latent connection
|
||||
let latentConnection = this.latentConnection;
|
||||
this.latentConnection.element.remove();
|
||||
this.latentConnection = null;
|
||||
|
||||
// Was the pointer released on a port?
|
||||
let dropElement = event.target as HTMLElement;
|
||||
|
||||
if (!dropElement.classList.contains("rio-graph-editor-port-circle")) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dropPortComponent = componentsByElement.get(
|
||||
dropElement.parentElement as HTMLElement
|
||||
);
|
||||
|
||||
// Get the fixed port
|
||||
let fixedPortComponent = componentsById[
|
||||
latentConnection.fixedPortId
|
||||
] as NodeInputComponent | NodeOutputComponent;
|
||||
|
||||
// Each connection must connect exactly one input and one output
|
||||
let fromPortComponent: NodeOutputComponent;
|
||||
let toPortComponent: NodeInputComponent;
|
||||
|
||||
if (fixedPortComponent instanceof NodeOutputComponent) {
|
||||
if (dropPortComponent instanceof NodeOutputComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
fromPortComponent = fixedPortComponent;
|
||||
toPortComponent = dropPortComponent as NodeInputComponent;
|
||||
} else {
|
||||
if (dropPortComponent instanceof NodeInputComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
fromPortComponent = dropPortComponent as NodeOutputComponent;
|
||||
toPortComponent = fixedPortComponent;
|
||||
}
|
||||
|
||||
// Create a real connection between the two ports
|
||||
let augmentedConn: AugmentedConnectionState = {
|
||||
fromNode: getNodeForPort(fromPortComponent).id,
|
||||
fromPort: fromPortComponent.id,
|
||||
toNode: getNodeForPort(toPortComponent).id,
|
||||
toPort: toPortComponent.id,
|
||||
element: this._makeConnectionElement(),
|
||||
};
|
||||
this.graphStore.addConnection(augmentedConn);
|
||||
this._updateConnectionFromObject(augmentedConn);
|
||||
}
|
||||
|
||||
_onDragNodeStart(
|
||||
nodeState: NodeState,
|
||||
nodeElement: HTMLElement,
|
||||
event: PointerEvent
|
||||
): boolean {
|
||||
// Only care about left clicks
|
||||
if (event.button !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Make sure this node is on top
|
||||
nodeElement.style.zIndex = "1";
|
||||
|
||||
// Accept the drag
|
||||
return true;
|
||||
}
|
||||
|
||||
_onDragNodeMove(
|
||||
nodeState: NodeState,
|
||||
nodeElement: HTMLElement,
|
||||
event: PointerEvent
|
||||
): void {
|
||||
// Update the node state
|
||||
nodeState.left += event.movementX / pixelsPerRem;
|
||||
nodeState.top += event.movementY / pixelsPerRem;
|
||||
|
||||
// Move its element
|
||||
nodeElement.style.left = `${nodeState.left}rem`;
|
||||
nodeElement.style.top = `${nodeState.top}rem`;
|
||||
|
||||
// Update any connections
|
||||
let connections = this.graphStore.getConnectionsForNode(nodeState.id);
|
||||
|
||||
for (let conn of connections) {
|
||||
this._updateConnectionFromObject(conn);
|
||||
}
|
||||
}
|
||||
|
||||
_onDragNodeEnd(
|
||||
nodeState: NodeState,
|
||||
nodeElement: HTMLElement,
|
||||
event: PointerEvent
|
||||
): void {
|
||||
// The node no longer needs to be on top
|
||||
nodeElement.style.removeProperty("z-index");
|
||||
}
|
||||
|
||||
_makeNode(
|
||||
latentComponents: Set<ComponentBase>,
|
||||
nodeState: NodeState
|
||||
): AugmentedNodeState {
|
||||
// Build the node HTML
|
||||
const nodeElement = document.createElement("div");
|
||||
nodeElement.dataset.nodeId = nodeState.id.toString();
|
||||
nodeElement.classList.add(
|
||||
"rio-graph-editor-node",
|
||||
"rio-switcheroo-neutral"
|
||||
);
|
||||
nodeElement.style.left = `${nodeState.left}rem`;
|
||||
nodeElement.style.top = `${nodeState.top}rem`;
|
||||
this.htmlChild.appendChild(nodeElement);
|
||||
|
||||
// Header
|
||||
const header = document.createElement("div");
|
||||
header.classList.add("rio-graph-editor-node-header");
|
||||
header.innerText = nodeState.title;
|
||||
nodeElement.appendChild(header);
|
||||
|
||||
// Body
|
||||
const nodeBody = document.createElement("div");
|
||||
nodeBody.classList.add("rio-graph-editor-node-body");
|
||||
nodeElement.appendChild(nodeBody);
|
||||
|
||||
// Content
|
||||
this.replaceOnlyChild(latentComponents, nodeState.id, nodeBody);
|
||||
|
||||
// Allow dragging the node
|
||||
// @ts-ignore
|
||||
this.addDragHandler({
|
||||
element: header,
|
||||
onStart: (event: PointerEvent) =>
|
||||
this._onDragNodeStart(nodeState, nodeElement, event),
|
||||
onMove: (event: PointerEvent) =>
|
||||
this._onDragNodeMove(nodeState, nodeElement, event),
|
||||
onEnd: (event: PointerEvent) =>
|
||||
this._onDragNodeEnd(nodeState, nodeElement, event),
|
||||
});
|
||||
|
||||
// Build the augmented node state
|
||||
let augmentedNode = { ...nodeState } as AugmentedNodeState;
|
||||
augmentedNode.element = nodeElement;
|
||||
|
||||
return augmentedNode;
|
||||
}
|
||||
|
||||
/// Creates a SVG path element representing a connection and adds it to the
|
||||
/// SVG child. Does not position the connection in any way.
|
||||
_makeConnectionElement(): SVGPathElement {
|
||||
const svgPath = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"path"
|
||||
) as SVGPathElement;
|
||||
|
||||
svgPath.setAttribute("stroke", "var(--rio-local-text-color)");
|
||||
// svgPath.setAttribute("stroke", "url(#connectionGradient)");
|
||||
svgPath.setAttribute("stroke-opacity", "0.5");
|
||||
svgPath.setAttribute("stroke-width", "0.2rem");
|
||||
svgPath.setAttribute("fill", "none");
|
||||
this.svgChild.appendChild(svgPath);
|
||||
|
||||
return svgPath;
|
||||
}
|
||||
|
||||
/// Given a port component, return the coordinates of the port's socket.
|
||||
_getPortPosition(
|
||||
portComponent: NodeInputComponent | NodeOutputComponent
|
||||
): [number, number] {
|
||||
// Find the circle's HTML element
|
||||
let circleElement = portComponent.element.querySelector(
|
||||
".rio-graph-editor-port-circle"
|
||||
) as HTMLElement;
|
||||
|
||||
// Get the circle's bounding box
|
||||
let circleBox = circleElement.getBoundingClientRect();
|
||||
|
||||
// Return the center of the circle
|
||||
return [
|
||||
circleBox.left + circleBox.width * 0.5,
|
||||
circleBox.top + circleBox.height * 0.5,
|
||||
];
|
||||
}
|
||||
|
||||
_updateConnectionFromObject(
|
||||
connectionState: AugmentedConnectionState
|
||||
): void {
|
||||
// From Port
|
||||
let fromPortComponent = componentsById[
|
||||
connectionState.fromPort
|
||||
] as NodeOutputComponent;
|
||||
|
||||
const [x1, y1] = this._getPortPosition(fromPortComponent);
|
||||
|
||||
// To Port
|
||||
let toPortComponent = componentsById[
|
||||
connectionState.toPort
|
||||
] as NodeInputComponent;
|
||||
|
||||
const [x4, y4] = this._getPortPosition(toPortComponent);
|
||||
|
||||
// Update the SVG path
|
||||
this._updateConnectionFromCoordinates(
|
||||
connectionState.element,
|
||||
x1,
|
||||
y1,
|
||||
x4,
|
||||
y4
|
||||
);
|
||||
}
|
||||
|
||||
_updateConnectionFromCoordinates(
|
||||
connectionElement: SVGPathElement,
|
||||
x1: number,
|
||||
y1: number,
|
||||
x4: number,
|
||||
y4: number
|
||||
): void {
|
||||
// Control the curve's bend
|
||||
let signedDistance = Math.abs(x4 - x1);
|
||||
|
||||
let velocity = Math.pow(signedDistance * 10, 0.6);
|
||||
velocity = Math.max(velocity, 3 * pixelsPerRem);
|
||||
|
||||
// Calculate the intermediate points
|
||||
const x2 = x1 + velocity;
|
||||
const y2 = y1;
|
||||
|
||||
const x3 = x4 - velocity;
|
||||
const y3 = y4;
|
||||
|
||||
// Update the SVG path
|
||||
connectionElement.setAttribute(
|
||||
"d",
|
||||
`M${x1} ${y1} C ${x2} ${y2}, ${x3} ${y3}, ${x4} ${y4}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { componentsByElement, componentsById } from "../../componentManagement";
|
||||
import { ComponentId } from "../../dataModels";
|
||||
import { GraphEditorComponent } from "./graphEditor";
|
||||
import { NodeInputComponent } from "../nodeInput";
|
||||
import { NodeOutputComponent } from "../nodeOutput";
|
||||
import {
|
||||
getNodeFromPort,
|
||||
getPortFromCircle,
|
||||
getPortPosition,
|
||||
makeConnectionElement,
|
||||
updateConnectionFromCoordinates,
|
||||
updateConnectionFromObject,
|
||||
} from "./utils";
|
||||
import { AugmentedConnectionState } from "./graphStore";
|
||||
|
||||
/// The user is currently dragging a connection from a port. This means the
|
||||
/// connection is already connected to one port, with the other end dangling at
|
||||
/// the pointer.
|
||||
export class DraggingConnectionStrategy {
|
||||
fixedNodeId: ComponentId;
|
||||
fixedPortId: ComponentId;
|
||||
element: SVGPathElement;
|
||||
|
||||
constructor(
|
||||
fixedNodeId: ComponentId,
|
||||
fixedPortId: ComponentId,
|
||||
element: SVGPathElement
|
||||
) {
|
||||
this.fixedNodeId = fixedNodeId;
|
||||
this.fixedPortId = fixedPortId;
|
||||
this.element = element;
|
||||
}
|
||||
|
||||
onDragMove(this_: GraphEditorComponent, event: PointerEvent): void {
|
||||
// Update the latent connection
|
||||
const fixedPortComponent = componentsById[this.fixedPortId] as
|
||||
| NodeInputComponent
|
||||
| NodeOutputComponent
|
||||
| undefined;
|
||||
|
||||
// The element could've been deleted in the meantime
|
||||
if (fixedPortComponent === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
let [x1, y1] = getPortPosition(fixedPortComponent);
|
||||
let [x2, y2] = [event.clientX, event.clientY];
|
||||
|
||||
// If dragging from an input port, flip the coordinates
|
||||
if (fixedPortComponent instanceof NodeInputComponent) {
|
||||
[x1, y1, x2, y2] = [x2, y2, x1, y1];
|
||||
}
|
||||
|
||||
// Update the SVG path
|
||||
updateConnectionFromCoordinates(this.element, x1, y1, x2, y2);
|
||||
}
|
||||
|
||||
onDragEnd(this_: GraphEditorComponent, event: PointerEvent): void {
|
||||
// Remove the SVG path
|
||||
this.element.remove();
|
||||
|
||||
// Was the pointer released on a port?
|
||||
let dropElement = event.target as HTMLElement;
|
||||
|
||||
if (!dropElement.classList.contains("rio-graph-editor-port-circle")) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dropPortComponent = getPortFromCircle(dropElement);
|
||||
|
||||
// Get the fixed port
|
||||
let fixedPortComponent = componentsById[this.fixedPortId] as
|
||||
| NodeInputComponent
|
||||
| NodeOutputComponent
|
||||
| undefined;
|
||||
|
||||
// The element could've been deleted in the meantime
|
||||
if (fixedPortComponent === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Each connection must connect exactly one input and one output
|
||||
let fromPortComponent: NodeOutputComponent;
|
||||
let toPortComponent: NodeInputComponent;
|
||||
|
||||
if (fixedPortComponent instanceof NodeOutputComponent) {
|
||||
if (dropPortComponent instanceof NodeOutputComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
fromPortComponent = fixedPortComponent;
|
||||
toPortComponent = dropPortComponent as NodeInputComponent;
|
||||
} else {
|
||||
if (dropPortComponent instanceof NodeInputComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
fromPortComponent = dropPortComponent as NodeOutputComponent;
|
||||
toPortComponent = fixedPortComponent;
|
||||
}
|
||||
|
||||
// Create a real connection between the two ports
|
||||
let connectionElement = makeConnectionElement();
|
||||
this_.svgChild.appendChild(connectionElement);
|
||||
|
||||
let augmentedConn: AugmentedConnectionState = {
|
||||
fromNode: getNodeFromPort(fromPortComponent).id,
|
||||
fromPort: fromPortComponent.id,
|
||||
toNode: getNodeFromPort(toPortComponent).id,
|
||||
toPort: toPortComponent.id,
|
||||
element: connectionElement,
|
||||
};
|
||||
|
||||
this_.graphStore.addConnection(augmentedConn);
|
||||
updateConnectionFromObject(augmentedConn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/// A connection that is only connected to a single port, with the other end
|
||||
|
||||
import { componentsByElement } from "../../componentManagement";
|
||||
import { ComponentId } from "../../dataModels";
|
||||
import { GraphEditorComponent } from "./graphEditor";
|
||||
import { NodeInputComponent } from "../nodeInput";
|
||||
import { NodeOutputComponent } from "../nodeOutput";
|
||||
|
||||
export class DraggingNodesStrategy {
|
||||
onDragMove(this_: GraphEditorComponent, event: PointerEvent): void {
|
||||
// Update the node state
|
||||
nodeState.left += event.movementX / pixelsPerRem;
|
||||
nodeState.top += event.movementY / pixelsPerRem;
|
||||
|
||||
// Move its element
|
||||
nodeElement.style.left = `${nodeState.left}rem`;
|
||||
nodeElement.style.top = `${nodeState.top}rem`;
|
||||
|
||||
// Update any connections
|
||||
let connections = this.graphStore.getConnectionsForNode(nodeState.id);
|
||||
|
||||
for (let conn of connections) {
|
||||
this._updateConnectionFromObject(conn);
|
||||
}
|
||||
}
|
||||
|
||||
onDragEnd(this_: GraphEditorComponent, event: PointerEvent): void {
|
||||
// The node no longer needs to be on top
|
||||
nodeElement.style.removeProperty("z-index");
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/// A connection that is only connected to a single port, with the other end
|
||||
|
||||
import { componentsByElement } from "../../componentManagement";
|
||||
import { ComponentId } from "../../dataModels";
|
||||
import { GraphEditorComponent } from "./graphEditor";
|
||||
import { NodeInputComponent } from "../nodeInput";
|
||||
import { NodeOutputComponent } from "../nodeOutput";
|
||||
|
||||
export class DraggingSelectionStrategy {
|
||||
startPointX: number;
|
||||
startPointY: number;
|
||||
|
||||
constructor(startPointX: number, startPointY: number) {
|
||||
this.startPointX = startPointX;
|
||||
this.startPointY = startPointY;
|
||||
}
|
||||
|
||||
onDragMove(this_: GraphEditorComponent, event: PointerEvent): void {}
|
||||
onDragEnd(this_: GraphEditorComponent, event: PointerEvent): void {}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { ComponentId } from "../../dataModels";
|
||||
import { ComponentBase, ComponentState } from "../componentBase";
|
||||
import { pixelsPerRem } from "../../app";
|
||||
import { NodeInputComponent } from "../nodeInput";
|
||||
import { NodeOutputComponent } from "../nodeOutput";
|
||||
import {
|
||||
AugmentedConnectionState,
|
||||
AugmentedNodeState,
|
||||
GraphStore,
|
||||
NodeState,
|
||||
} from "./graphStore";
|
||||
import { DraggingConnectionStrategy } from "./draggingConnectionStrategy";
|
||||
import { DraggingSelectionStrategy } from "./draggingSelectionStrategy";
|
||||
import { DraggingNodesStrategy } from "./draggingNodesStrategy";
|
||||
import {
|
||||
devel_getComponentByKey,
|
||||
getNodeFromPort,
|
||||
getPortFromCircle,
|
||||
makeConnectionElement,
|
||||
updateConnectionFromObject,
|
||||
} from "./utils";
|
||||
|
||||
export type GraphEditorState = ComponentState & {
|
||||
_type_: "GraphEditor-builtin";
|
||||
children?: ComponentId[];
|
||||
};
|
||||
|
||||
export class GraphEditorComponent extends ComponentBase {
|
||||
declare state: Required<GraphEditorState>;
|
||||
|
||||
private htmlChild: HTMLElement;
|
||||
public svgChild: SVGSVGElement;
|
||||
|
||||
private selectionChild: HTMLElement;
|
||||
|
||||
public graphStore: GraphStore = new GraphStore();
|
||||
|
||||
// When clicking & dragging a variety of things can happen based on the
|
||||
// selection, mouse button, position and phase of the moon. This strategy
|
||||
// object is used in lieu of if-else chains.
|
||||
private dragStrategy:
|
||||
| DraggingConnectionStrategy
|
||||
| DraggingSelectionStrategy
|
||||
| DraggingNodesStrategy
|
||||
| null = null;
|
||||
|
||||
createElement(): HTMLElement {
|
||||
// Create the HTML
|
||||
let element = document.createElement("div");
|
||||
element.classList.add("rio-graph-editor");
|
||||
|
||||
this.svgChild = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"svg"
|
||||
);
|
||||
element.appendChild(this.svgChild);
|
||||
|
||||
this.htmlChild = document.createElement("div");
|
||||
element.appendChild(this.htmlChild);
|
||||
|
||||
this.selectionChild = document.createElement("div");
|
||||
this.selectionChild.classList.add("rio-graph-editor-selection");
|
||||
this.selectionChild.style.opacity = "0";
|
||||
this.htmlChild.appendChild(this.selectionChild);
|
||||
|
||||
// Listen for drag events. The exact nature of the drag event is
|
||||
// determined by the current drag strategy.
|
||||
this.addDragHandler({
|
||||
element: element,
|
||||
onStart: this._onDragStart.bind(this),
|
||||
onMove: this._onDragMove.bind(this),
|
||||
onEnd: this._onDragEnd.bind(this),
|
||||
capturing: true,
|
||||
});
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
updateElement(
|
||||
deltaState: GraphEditorState,
|
||||
latentComponents: Set<ComponentBase>
|
||||
): void {
|
||||
// Spawn some children for testing
|
||||
if (deltaState.children !== undefined) {
|
||||
// Spawn all nodes
|
||||
for (let ii = 0; ii < deltaState.children.length; ii++) {
|
||||
let childId = deltaState.children[ii];
|
||||
|
||||
let rawNode: NodeState = {
|
||||
id: childId,
|
||||
title: `Node ${ii}`,
|
||||
left: 10 + ii * 10,
|
||||
top: 10 + ii * 10,
|
||||
};
|
||||
let augmentedNode = this._makeNode(latentComponents, rawNode);
|
||||
this.graphStore.addNode(augmentedNode);
|
||||
}
|
||||
|
||||
// Connect some of them
|
||||
requestAnimationFrame(() => {
|
||||
let fromPortComponent = devel_getComponentByKey("out_1");
|
||||
let toPortComponent = devel_getComponentByKey("in_1");
|
||||
|
||||
let connectionElement = makeConnectionElement();
|
||||
this.svgChild.appendChild(connectionElement);
|
||||
|
||||
let augmentedConn: AugmentedConnectionState = {
|
||||
// @ts-ignore
|
||||
fromNode: deltaState.children[1],
|
||||
fromPort: fromPortComponent.id,
|
||||
// @ts-ignore
|
||||
toNode: deltaState.children[2],
|
||||
toPort: toPortComponent.id,
|
||||
element: connectionElement,
|
||||
};
|
||||
this.graphStore.addConnection(augmentedConn);
|
||||
updateConnectionFromObject(augmentedConn);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onDragStart(event: PointerEvent): boolean {
|
||||
// If a drag strategy is already active, ignore this event
|
||||
if (this.dragStrategy !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// FIXME: A lot of the strategies below are checking for the left mouse
|
||||
// button. This is wrong on touch and pointer devices.
|
||||
|
||||
// Find an applicable strategy
|
||||
//
|
||||
// Case: New connection from a port
|
||||
let targetElement = event.target as HTMLElement;
|
||||
console.assert(
|
||||
targetElement !== null,
|
||||
"Pointer event has no target element"
|
||||
);
|
||||
|
||||
if (
|
||||
event.button === 0 &&
|
||||
targetElement.classList.contains("rio-graph-editor-port-circle")
|
||||
) {
|
||||
let portComponent = getPortFromCircle(targetElement);
|
||||
|
||||
// Add a new connection to the SVG
|
||||
let connectionElement = makeConnectionElement();
|
||||
this.svgChild.appendChild(connectionElement);
|
||||
|
||||
// Store the strategy
|
||||
this.dragStrategy = new DraggingConnectionStrategy(
|
||||
getNodeFromPort(portComponent).id,
|
||||
portComponent.id,
|
||||
connectionElement
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Case: Move around the selected nodes
|
||||
if (event.button === 0 && false) {
|
||||
// Make sure all selected nodes are on top
|
||||
//
|
||||
// TODO
|
||||
//
|
||||
// nodeElement.style.zIndex = "1";
|
||||
|
||||
// Store the strategy
|
||||
this.dragStrategy = new DraggingNodesStrategy();
|
||||
|
||||
// Accept the drag
|
||||
return true;
|
||||
}
|
||||
|
||||
// Case: Rectangle selection
|
||||
if (event.button === 0) {
|
||||
// Store the strategy
|
||||
this.dragStrategy = new DraggingSelectionStrategy(
|
||||
event.clientX,
|
||||
event.clientY
|
||||
);
|
||||
|
||||
// Accept the drag
|
||||
return true;
|
||||
}
|
||||
|
||||
// No strategy found
|
||||
console.assert(
|
||||
this.dragStrategy === null,
|
||||
"A drag strategy was found, but the function hasn't returned"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
_onDragMove(event: PointerEvent): void {
|
||||
// If no drag strategy is active, there's nothing to do
|
||||
if (this.dragStrategy === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to the drag strategy
|
||||
this.dragStrategy.onDragMove(this, event);
|
||||
}
|
||||
|
||||
_onDragEnd(event: PointerEvent): void {
|
||||
// If no drag strategy is active, there's nothing to do
|
||||
if (this.dragStrategy === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to the drag strategy
|
||||
this.dragStrategy.onDragEnd(this, event);
|
||||
|
||||
// Clear the drag strategy
|
||||
this.dragStrategy = null;
|
||||
}
|
||||
|
||||
/// Creates a node element and adds it to the HTML child. Returns the node
|
||||
/// state, augmented with the HTML element.
|
||||
_makeNode(
|
||||
latentComponents: Set<ComponentBase>,
|
||||
nodeState: NodeState
|
||||
): AugmentedNodeState {
|
||||
// Build the node HTML
|
||||
const nodeElement = document.createElement("div");
|
||||
nodeElement.dataset.nodeId = nodeState.id.toString();
|
||||
nodeElement.classList.add(
|
||||
"rio-graph-editor-node",
|
||||
"rio-switcheroo-neutral"
|
||||
);
|
||||
nodeElement.style.left = `${nodeState.left}rem`;
|
||||
nodeElement.style.top = `${nodeState.top}rem`;
|
||||
this.htmlChild.appendChild(nodeElement);
|
||||
|
||||
// Header
|
||||
const header = document.createElement("div");
|
||||
header.classList.add("rio-graph-editor-node-header");
|
||||
header.innerText = nodeState.title;
|
||||
nodeElement.appendChild(header);
|
||||
|
||||
// Body
|
||||
const nodeBody = document.createElement("div");
|
||||
nodeBody.classList.add("rio-graph-editor-node-body");
|
||||
nodeElement.appendChild(nodeBody);
|
||||
|
||||
// Content
|
||||
this.replaceOnlyChild(latentComponents, nodeState.id, nodeBody);
|
||||
|
||||
// Build the augmented node state
|
||||
let augmentedNode = { ...nodeState } as AugmentedNodeState;
|
||||
augmentedNode.element = nodeElement;
|
||||
|
||||
return augmentedNode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { ComponentId } from "../../dataModels";
|
||||
import { ComponentBase, ComponentState } from "../componentBase";
|
||||
import { componentsByElement, componentsById } from "../../componentManagement";
|
||||
import { NodeInputComponent } from "../nodeInput";
|
||||
import { NodeOutputComponent } from "../nodeOutput";
|
||||
|
||||
export type GraphEditorState = ComponentState & {
|
||||
_type_: "GraphEditor-builtin";
|
||||
children?: ComponentId[];
|
||||
};
|
||||
|
||||
export type NodeState = {
|
||||
id: ComponentId;
|
||||
title: string;
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
export type ConnectionState = {
|
||||
fromNode: ComponentId;
|
||||
fromPort: ComponentId;
|
||||
toNode: ComponentId;
|
||||
toPort: ComponentId;
|
||||
};
|
||||
|
||||
export type AugmentedNodeState = NodeState & {
|
||||
element: HTMLElement;
|
||||
};
|
||||
|
||||
export type AugmentedConnectionState = ConnectionState & {
|
||||
element: SVGPathElement;
|
||||
};
|
||||
|
||||
export class GraphStore {
|
||||
/// Nodes are identified by their component ID. This maps component IDs to
|
||||
/// the stored information about the node.
|
||||
private idsToNodes: Map<number, AugmentedNodeState>;
|
||||
|
||||
/// This maps node IDs to connections that are connected to them.
|
||||
private nodeIdsToConnections: Map<number, AugmentedConnectionState[]>;
|
||||
|
||||
constructor() {
|
||||
this.idsToNodes = new Map();
|
||||
this.nodeIdsToConnections = new Map();
|
||||
}
|
||||
|
||||
/// Add a node to the store. The node must not already exist in the store.
|
||||
addNode(node: AugmentedNodeState): void {
|
||||
console.assert(
|
||||
!this.idsToNodes.has(node.id),
|
||||
`Cannot add node to GraphStore because of duplicate ID ${node.id}`
|
||||
);
|
||||
this.idsToNodes.set(node.id, node);
|
||||
}
|
||||
|
||||
/// Returns an array of all nodes in the store.
|
||||
allNodes(): AugmentedNodeState[] {
|
||||
return Array.from(this.idsToNodes.values());
|
||||
}
|
||||
|
||||
/// Get a node by its ID. Throws an error if the node does not exist.
|
||||
getNodeById(nodeId: number): AugmentedNodeState {
|
||||
let node = this.idsToNodes.get(nodeId);
|
||||
|
||||
if (node === undefined) {
|
||||
throw new Error(`NodeEditor has no node with id ${nodeId}`);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/// Add a connection to the store. The connection must not already exist in
|
||||
/// the store.
|
||||
addConnection(conn: AugmentedConnectionState): void {
|
||||
// Sanity checks
|
||||
console.assert(
|
||||
this.idsToNodes.has(conn.fromNode),
|
||||
`Cannot add connection to GraphStore because source node ${conn.fromNode} does not exist`
|
||||
);
|
||||
console.assert(
|
||||
this.idsToNodes.has(conn.toNode),
|
||||
`Cannot add connection to GraphStore because destination node ${conn.toNode} does not exist`
|
||||
);
|
||||
|
||||
// Source node
|
||||
let fromConnections = this.nodeIdsToConnections.get(conn.fromNode);
|
||||
|
||||
if (fromConnections === undefined) {
|
||||
fromConnections = [];
|
||||
this.nodeIdsToConnections.set(conn.fromNode, fromConnections);
|
||||
}
|
||||
|
||||
fromConnections.push(conn);
|
||||
|
||||
// Destination node
|
||||
let toConnections = this.nodeIdsToConnections.get(conn.toNode);
|
||||
|
||||
if (toConnections === undefined) {
|
||||
toConnections = [];
|
||||
this.nodeIdsToConnections.set(conn.toNode, toConnections);
|
||||
}
|
||||
|
||||
toConnections.push(conn);
|
||||
}
|
||||
|
||||
getConnectionsForNode(nodeId: number): AugmentedConnectionState[] {
|
||||
let connections = this.nodeIdsToConnections.get(nodeId);
|
||||
|
||||
if (connections === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return connections;
|
||||
}
|
||||
}
|
||||
|
||||
function devel_getComponentByKey(key: string): ComponentBase {
|
||||
// Temporary function to get a component by its key
|
||||
for (let component of Object.values(componentsById)) {
|
||||
if (component === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (component.state._key_ === key) {
|
||||
return component;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Could not find component with key ${key}`);
|
||||
}
|
||||
|
||||
/// Given the circle HTML element of a port, walk up the DOM to find the port
|
||||
/// component that contains it.A
|
||||
function getPortFromCircle(
|
||||
circleElement: HTMLElement
|
||||
): NodeInputComponent | NodeOutputComponent {
|
||||
let portElement = circleElement.parentElement as HTMLElement;
|
||||
console.assert(
|
||||
portElement.classList.contains("rio-graph-editor-port"),
|
||||
"Port element does not have the expected class"
|
||||
);
|
||||
|
||||
let portComponent = componentsByElement.get(portElement) as ComponentBase;
|
||||
console.assert(
|
||||
portComponent !== undefined,
|
||||
"Port element does not have a corresponding component"
|
||||
);
|
||||
|
||||
console.assert(
|
||||
portComponent instanceof NodeInputComponent ||
|
||||
portComponent instanceof NodeOutputComponent,
|
||||
"Port component is not of the expected type"
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
return portComponent;
|
||||
}
|
||||
|
||||
/// Given a port component, walk up the DOM to find the node component that
|
||||
/// contains it.
|
||||
function getNodeFromPort(
|
||||
port: NodeInputComponent | NodeOutputComponent
|
||||
): ComponentBase {
|
||||
let nodeElement = port.element.closest(
|
||||
".rio-graph-editor-node > div > .rio-component"
|
||||
) as HTMLElement;
|
||||
|
||||
let nodeComponent = componentsByElement.get(nodeElement) as ComponentBase;
|
||||
|
||||
return nodeComponent;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { pixelsPerRem } from "../../app";
|
||||
import { componentsByElement, componentsById } from "../../componentManagement";
|
||||
import { ComponentBase } from "../componentBase";
|
||||
import { NodeInputComponent } from "../nodeInput";
|
||||
import { NodeOutputComponent } from "../nodeOutput";
|
||||
import { AugmentedConnectionState } from "./graphStore";
|
||||
|
||||
/// Temporary function to get a component by its key
|
||||
///
|
||||
/// TODO / FIXME / REMOVEME
|
||||
export function devel_getComponentByKey(key: string): ComponentBase {
|
||||
for (let component of Object.values(componentsById)) {
|
||||
if (component === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (component.state._key_ === key) {
|
||||
return component;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Could not find component with key ${key}`);
|
||||
}
|
||||
|
||||
/// Given the circle HTML element of a port, walk up the DOM to find the port
|
||||
/// component that contains it.A
|
||||
export function getPortFromCircle(
|
||||
circleElement: HTMLElement
|
||||
): NodeInputComponent | NodeOutputComponent {
|
||||
let portElement = circleElement.parentElement as HTMLElement;
|
||||
console.assert(
|
||||
portElement.classList.contains("rio-graph-editor-port"),
|
||||
"Port element does not have the expected class"
|
||||
);
|
||||
|
||||
let portComponent = componentsByElement.get(portElement) as ComponentBase;
|
||||
console.assert(
|
||||
portComponent !== undefined,
|
||||
"Port element does not have a corresponding component"
|
||||
);
|
||||
|
||||
console.assert(
|
||||
portComponent instanceof NodeInputComponent ||
|
||||
portComponent instanceof NodeOutputComponent,
|
||||
"Port component is not of the expected type"
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
return portComponent;
|
||||
}
|
||||
|
||||
/// Given a port component, walk up the DOM to find the node component that
|
||||
/// contains it.
|
||||
export function getNodeFromPort(
|
||||
port: NodeInputComponent | NodeOutputComponent
|
||||
): ComponentBase {
|
||||
let nodeElement = port.element.closest(
|
||||
".rio-graph-editor-node > div > .rio-component"
|
||||
) as HTMLElement;
|
||||
|
||||
let nodeComponent = componentsByElement.get(nodeElement) as ComponentBase;
|
||||
|
||||
return nodeComponent;
|
||||
}
|
||||
|
||||
/// Creates a SVG path element representing a connection. Does not add it to
|
||||
/// the DOM.
|
||||
export function makeConnectionElement(): SVGPathElement {
|
||||
const svgPath = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"path"
|
||||
) as SVGPathElement;
|
||||
|
||||
svgPath.setAttribute("stroke", "var(--rio-local-text-color)");
|
||||
svgPath.setAttribute("stroke-opacity", "0.5");
|
||||
svgPath.setAttribute("stroke-width", "0.2rem");
|
||||
svgPath.setAttribute("fill", "none");
|
||||
|
||||
return svgPath;
|
||||
}
|
||||
|
||||
/// Given a port component, return the coordinates of the port's socket.
|
||||
export function getPortPosition(
|
||||
portComponent: NodeInputComponent | NodeOutputComponent
|
||||
): [number, number] {
|
||||
// Find the circle's HTML element
|
||||
let circleElement = portComponent.element.querySelector(
|
||||
".rio-graph-editor-port-circle"
|
||||
) as HTMLElement;
|
||||
|
||||
// Get the circle's bounding box
|
||||
let circleBox = circleElement.getBoundingClientRect();
|
||||
|
||||
// Return the center of the circle
|
||||
return [
|
||||
circleBox.left + circleBox.width * 0.5,
|
||||
circleBox.top + circleBox.height * 0.5,
|
||||
];
|
||||
}
|
||||
|
||||
/// Updates the SVG path of a connection based on the state of the
|
||||
/// connection. This is a convenience function which determines the start
|
||||
/// and end points and delegates to the more general function.
|
||||
export function updateConnectionFromObject(
|
||||
connectionState: AugmentedConnectionState
|
||||
): void {
|
||||
// From Port
|
||||
let fromPortComponent = componentsById[
|
||||
connectionState.fromPort
|
||||
] as NodeOutputComponent;
|
||||
|
||||
const [x1, y1] = getPortPosition(fromPortComponent);
|
||||
|
||||
// To Port
|
||||
let toPortComponent = componentsById[
|
||||
connectionState.toPort
|
||||
] as NodeInputComponent;
|
||||
|
||||
const [x4, y4] = getPortPosition(toPortComponent);
|
||||
|
||||
// Update the SVG path
|
||||
updateConnectionFromCoordinates(connectionState.element, x1, y1, x4, y4);
|
||||
}
|
||||
|
||||
/// Updates the SVG path of a connection based on the coordinates of the
|
||||
/// start and end points.
|
||||
export function updateConnectionFromCoordinates(
|
||||
connectionElement: SVGPathElement,
|
||||
x1: number,
|
||||
y1: number,
|
||||
x4: number,
|
||||
y4: number
|
||||
): void {
|
||||
// Control the curve's bend
|
||||
let signedDistance = Math.abs(x4 - x1);
|
||||
|
||||
let velocity = Math.pow(signedDistance * 10, 0.6);
|
||||
velocity = Math.max(velocity, 3 * pixelsPerRem);
|
||||
|
||||
// Calculate the intermediate points
|
||||
const x2 = x1 + velocity;
|
||||
const y2 = y1;
|
||||
|
||||
const x3 = x4 - velocity;
|
||||
const y3 = y4;
|
||||
|
||||
// Update the SVG path
|
||||
connectionElement.setAttribute(
|
||||
"d",
|
||||
`M${x1} ${y1} C ${x2} ${y2}, ${x3} ${y3}, ${x4} ${y4}`
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user