WIP added initial graph editor

This commit is contained in:
Jakob Pinterits
2024-11-12 23:31:02 +01:00
parent e8829abb81
commit 5a175c21f0
8 changed files with 775 additions and 14 deletions

View File

@@ -1,5 +1,4 @@
import { AspectRatioContainerComponent } from "./components/aspectRatioContainer";
import { ErrorPlaceholderComponent } from "./components/errorPlaceholder";
import { ButtonComponent, IconButtonComponent } from "./components/buttons";
import { CalendarComponent } from "./components/calendar";
import { callRemoteMethodDiscardResponse } from "./rpc";
@@ -20,9 +19,11 @@ import { DevToolsConnectorComponent } from "./components/devToolsConnector";
import { DialogContainerComponent } from "./components/dialogContainer";
import { DrawerComponent } from "./components/drawer";
import { DropdownComponent } from "./components/dropdown";
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";
@@ -41,6 +42,7 @@ import { NodeInputComponent } from "./components/nodeInput";
import { NodeOutputComponent } from "./components/nodeOutput";
import { OverlayComponent } from "./components/overlay";
import { PlotComponent } from "./components/plot";
import { PointerEventListenerComponent } from "./components/pointerEventListener";
import { PopupComponent } from "./components/popup";
import { ProgressBarComponent } from "./components/progressBar";
import { ProgressCircleComponent } from "./components/progressCircle";
@@ -62,7 +64,6 @@ import { TextComponent } from "./components/text";
import { TextInputComponent } from "./components/textInput";
import { ThemeContextSwitcherComponent } from "./components/themeContextSwitcher";
import { TooltipComponent } from "./components/tooltip";
import { PointerEventListenerComponent } from "./components/pointerEventListener";
import { WebsiteComponent } from "./components/website";
const COMPONENT_CLASSES = {
@@ -87,6 +88,7 @@ const COMPONENT_CLASSES = {
"FilePickerArea-builtin": FilePickerAreaComponent,
"FlowContainer-builtin": FlowContainerComponent,
"FundamentalRootComponent-builtin": FundamentalRootComponent,
"GraphEditor-builtin": GraphEditorComponent,
"Grid-builtin": GridComponent,
"HeadingListItem-builtin": HeadingListItemComponent,
"HighLevelComponent-builtin": HighLevelComponent,

View File

@@ -0,0 +1,462 @@
import { ColorSet, ComponentId } from "../dataModels";
import { ComponentBase, ComponentState } from "./componentBase";
import { pixelsPerRem } from "../app";
import { componentsById } from "../componentManagement";
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;
};
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 idsToConnections: Map<number, AugmentedConnectionState[]>;
constructor() {
this.idsToNodes = new Map();
this.idsToConnections = 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.idsToConnections.get(conn.fromNode);
if (fromConnections === undefined) {
fromConnections = [];
this.idsToConnections.set(conn.fromNode, fromConnections);
}
fromConnections.push(conn);
// Destination node
let toConnections = this.idsToConnections.get(conn.toNode);
if (toConnections === undefined) {
toConnections = [];
this.idsToConnections.set(conn.toNode, toConnections);
}
toConnections.push(conn);
}
getConnectionsForNode(nodeId: number): AugmentedConnectionState[] {
let connections = this.idsToConnections.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}`);
}
export class GraphEditorComponent extends ComponentBase {
declare state: Required<GraphEditorState>;
private htmlChild: HTMLElement;
private svgChild: SVGSVGElement;
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 latentConnectionStartElement: HTMLElement | null;
private latentConnectionEndElement: HTMLElement | null;
private latentConnectionPath: SVGPathElement | null;
// This may be null even if a port is currently being dragged, if the latent
// connection is new, rather than one that already existed on the graph.
private latentConnection: AugmentedConnectionState | null;
// True if `updateElement` has been called at least once
private isInitialized: boolean = false;
createElement(): HTMLElement {
let element = document.createElement("div");
element.classList.add("rio-graph-editor");
element.innerHTML = `
<svg></svg>
<div></div>
`;
this.htmlChild = element.querySelector("div") as HTMLElement;
this.svgChild = element.querySelector("svg") as SVGSVGElement;
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 rawConn: ConnectionState = {
fromNode: deltaState.children[1],
fromPort: fromPortComponent.id,
toNode: deltaState.children[2],
toPort: toPortComponent.id,
};
let augmentedConn = this._makeConnection(rawConn);
this.graphStore.addConnection(augmentedConn);
});
}
// This component is now initialized
this.isInitialized = true;
}
_beginDragNode(
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;
}
_dragMoveNode(
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._updateConnection(conn);
}
}
_endDragNode(
nodeState: NodeState,
nodeElement: HTMLElement,
event: PointerEvent
): void {
// The node no longer needs to be on top
nodeElement.style.removeProperty("z-index");
}
_beginDragConnection(
isInput: boolean,
portElement: HTMLElement,
event: MouseEvent
): boolean {
// Only care about left clicks
if (event.button !== 0) {
return false;
}
// Create a latent connection
this.latentConnectionPath = document.createElementNS(
"http://www.w3.org/2000/svg",
"path"
) as SVGPathElement;
this.latentConnectionPath.setAttribute(
"stroke",
"var(--rio-local-text-color)"
);
this.latentConnectionPath.setAttribute("stroke-opacity", "0.5");
this.latentConnectionPath.setAttribute("stroke-width", "0.2rem");
this.latentConnectionPath.setAttribute("fill", "none");
this.latentConnectionPath.setAttribute("stroke-dasharray", "5,5");
this.svgChild.appendChild(this.latentConnectionPath);
if (isInput) {
this.latentConnectionStartElement = null;
this.latentConnectionEndElement = portElement;
} else {
this.latentConnectionStartElement = portElement;
this.latentConnectionEndElement = null;
}
// If a connection was already connected to this port, it is being
// dragged now.
this.latentConnection = null;
// TODO
// Accept the drag
return true;
}
_dragMoveConnection(event: MouseEvent): void {
// Update the latent connection
let x1, y1, x4, y4;
if (this.latentConnectionStartElement !== null) {
let startPoint =
this.latentConnectionStartElement!.getBoundingClientRect();
x1 = startPoint.left + startPoint.width * 0.5;
y1 = startPoint.top + startPoint.height * 0.5;
x4 = event.clientX;
y4 = event.clientY;
} else {
x1 = event.clientX;
y1 = event.clientY;
let endPoint =
this.latentConnectionEndElement!.getBoundingClientRect();
x4 = endPoint.left + endPoint.width * 0.5;
y4 = endPoint.top + endPoint.height * 0.5;
}
this._updateConnectionPath(this.latentConnectionPath!, x1, y1, x4, y4);
}
_endDragConnection(event: MouseEvent): void {
// Remove the latent connection
this.latentConnectionPath!.remove();
// Reset state
this.latentConnectionStartElement = null;
this.latentConnectionEndElement = null;
this.latentConnectionPath = null;
}
_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._beginDragNode(nodeState, nodeElement, event),
onMove: (event: PointerEvent) =>
this._dragMoveNode(nodeState, nodeElement, event),
onEnd: (event: PointerEvent) =>
this._endDragNode(nodeState, nodeElement, event),
});
// Build the augmented node state
let augmentedNode = { ...nodeState } as AugmentedNodeState;
augmentedNode.element = nodeElement;
return augmentedNode;
}
_makeConnection(
connectionState: ConnectionState
): AugmentedConnectionState {
// Create the SVG path. Don't worry about positioning it yet
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");
this.svgChild.appendChild(svgPath);
// Augment the connection state
let augmentedConn = connectionState as AugmentedConnectionState;
augmentedConn.element = svgPath;
// Update the connection
this._updateConnection(augmentedConn);
return augmentedConn;
}
_updateConnection(connectionState: AugmentedConnectionState): void {
// Get the port elements
let fromNodeState = this.graphStore.getNodeById(
connectionState.fromNode
);
let toNodeState = this.graphStore.getNodeById(connectionState.toNode);
console.log(fromNodeState, toNodeState);
let port1Element = fromNodeState.element.querySelector(
".rio-graph-editor-output > *:first-child"
) as HTMLElement;
let port2Element = toNodeState.element.querySelector(
".rio-graph-editor-input > *:first-child"
) as HTMLElement;
const box1 = port1Element.getBoundingClientRect();
const box2 = port2Element.getBoundingClientRect();
// Calculate the start and end points
const x1 = box1.left + box1.width * 0.5;
const y1 = box1.top + box1.height * 0.5;
const x4 = box2.left + box2.width * 0.5;
const y4 = box2.top + box2.height * 0.5;
// Update the SVG path
this._updateConnectionPath(connectionState.element, x1, y1, x4, y4);
}
_updateConnectionPath(
connectionElement: SVGPathElement,
x1: number,
y1: number,
x4: number,
y4: number
): void {
// Control the curve's bend
let signedDistance = x4 - x1;
let velocity = Math.sqrt(Math.abs(signedDistance * 20));
velocity = Math.max(velocity, 2 * 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}`
);
}
}

View File

@@ -17,19 +17,22 @@ export class NodeInputComponent extends ComponentBase {
createElement(): HTMLElement {
let element = document.createElement("div");
element.classList.add("rio-node-editor-port", "rio-node-editor-input");
element.classList.add(
"rio-graph-editor-port",
"rio-graph-editor-input"
);
element.innerHTML = `
<div class="rio-node-editor-port-circle"></div>
<div class="rio-node-editor-port-text"></div>
<div class="rio-graph-editor-port-circle"></div>
<div class="rio-graph-editor-port-text"></div>
`;
this.textElement = element.querySelector(
".rio-node-editor-port-text"
".rio-graph-editor-port-text"
) as HTMLElement;
this.circleElement = element.querySelector(
".rio-node-editor-port-circle"
".rio-graph-editor-port-circle"
) as HTMLElement;
return element;

View File

@@ -17,19 +17,22 @@ export class NodeOutputComponent extends ComponentBase {
createElement(): HTMLElement {
let element = document.createElement("div");
element.classList.add("rio-node-editor-port", "rio-node-editor-output");
element.classList.add(
"rio-graph-editor-port",
"rio-graph-editor-output"
);
element.innerHTML = `
<div class="rio-node-editor-port-circle"></div>
<div class="rio-node-editor-port-text"></div>
<div class="rio-graph-editor-port-circle"></div>
<div class="rio-graph-editor-port-text"></div>
`;
this.textElement = element.querySelector(
".rio-node-editor-port-text"
".rio-graph-editor-port-text"
) as HTMLElement;
this.circleElement = element.querySelector(
".rio-node-editor-port-circle"
".rio-graph-editor-port-circle"
) as HTMLElement;
return element;

View File

@@ -3951,3 +3951,167 @@ html.picking-component * {
.rio-file-picker-area > input {
display: none;
}
// Node Editor
$graph-editor-node-padding: 0.7rem;
$graph-editor-port-size: 1.4rem;
.rio-graph-editor > * {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.rio-graph-editor::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
background-image: radial-gradient(
circle,
var(--rio-local-fg) 12%,
transparent 12%
);
background-size: 1.3rem 1.3rem;
opacity: 0.15;
& > * {
position: absolute;
width: 100%;
height: 100%;
}
}
.rio-graph-editor-node {
pointer-events: auto;
display: flex;
flex-direction: column;
align-items: stretch;
position: absolute;
min-width: 8rem;
background-color: var(--rio-global-secondary-bg);
border-radius: var(--rio-global-corner-radius-small)
var(--rio-global-corner-radius-small)
var(--rio-global-corner-radius-medium)
var(--rio-global-corner-radius-medium);
box-shadow: 0 0.1rem 0.2rem var(--rio-global-shadow-color);
transition:
background-color 0.1s ease-in-out,
box-shadow 0.1s ease-in-out;
&:has(:nth-child(1):hover) {
background-color: var(--rio-global-secondary-bg-active);
box-shadow: 0 0.2rem 0.6rem var(--rio-global-shadow-color);
}
}
.rio-graph-editor-node-header {
user-select: none;
font-weight: bold;
text-align: center;
padding: 0.3rem $graph-editor-node-padding 0.3rem $graph-editor-node-padding;
cursor: move;
color: var(--rio-global-secondary-fg);
// transition: background-color 0.1s ease-in-out;
}
.rio-graph-editor-node-header:hover {
// background-color: var(--rio-global-secondary-bg-active);
}
.rio-graph-editor-node-body {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
padding: $graph-editor-node-padding;
border-radius: var(--rio-global-corner-radius-small);
background-color: var(--rio-local-bg);
}
.rio-graph-editor-port {
position: relative;
}
.rio-graph-editor-port-circle {
position: absolute;
pointer-events: auto;
cursor: pointer;
width: $graph-editor-port-size;
height: $graph-editor-port-size;
aspect-ratio: 1/1;
border-radius: 50%;
}
.rio-graph-editor-input {
text-align: left;
}
.rio-graph-editor-output {
text-align: right;
}
.rio-graph-editor-input > .rio-graph-editor-port-circle {
left: 0;
transform: translateX(
calc(-1 * ($graph-editor-port-size / 2 + $graph-editor-node-padding))
);
}
.rio-graph-editor-output > .rio-graph-editor-port-circle {
right: 0;
transform: translateX(
calc($graph-editor-port-size / 2 + $graph-editor-node-padding)
);
}
.rio-graph-editor-port-circle::after {
content: "";
position: absolute;
left: 25%;
top: 25%;
right: 25%;
bottom: 25%;
border-radius: 50%;
background-color: var(--port-color);
transition:
left 0.1s ease-in-out,
top 0.1s ease-in-out,
right 0.1s ease-in-out,
bottom 0.1s ease-in-out;
}
.rio-graph-editor-port-circle:hover::after {
left: 15%;
top: 15%;
right: 15%;
bottom: 15%;
}
.rio-graph-editor-port-text {
pointer-events: none;
}

View File

@@ -18,6 +18,7 @@ from .drawer import *
from .dropdown import *
from .file_picker_area import *
from .flow_container import *
from .graph_editor import *
from .grid import *
from .html import *
from .icon import *

View File

@@ -0,0 +1,126 @@
from __future__ import annotations
import typing as t
import rio
from .fundamental_component import FundamentalComponent
__all__ = [
"GraphEditor",
]
P = t.ParamSpec("P")
@t.final
class GraphStore:
def __init__(self) -> None:
# The graph currently connected to the store. At any point in time there
# can only be one graph connected to the store.
#
# It is the store's responsibility to keep JavaScript and Rio up-to-date
# with any changes to the graph.
self._graph: GraphEditor | None = None
# All node instances currently in the graph
self._nodes: list[rio.Component] = []
# All as-of-yet uninstantiated nodes. Since nodes can only be
# instantiated while in a `build` function, this list will be used to
# instantiate all nodes when the graph is being (re-) built.
self._uninstantiated_nodes: list[tuple[t.Callable, tuple, dict]] = []
def _set_editor(self, editor: GraphEditor) -> list[rio.Component]:
"""
Connects the graph editor to the store and returns the flat list of
children of the graph. This list will be shared between the two and
updated in-place as needed.
If the store was already previously connected to another graph editor,
it will disconnect from it first.
"""
# Already connected to an editor?
if self._graph is not None:
self._graph.children = []
self._graph.session._register_dirty_component(
self._graph,
include_children_recursively=False,
)
# Connect to the new editor
self._graph = editor
return self._nodes
def add_node(
self,
node_type: t.Callable[P],
*args: P.args,
**kwargs: P.kwargs,
) -> None:
"""
Creates a node of the given type and adds it to the graph.
"""
# Keep track of the node. It can't be instantiated yet, because Rio
# components can only be created inside of `build` functions.
self._uninstantiated_nodes.append((node_type, args, kwargs))
# Tell the graph editor to rebuild
if self._graph is not None:
self._graph.session._register_dirty_component(
self._graph,
include_children_recursively=False,
)
@t.final
class GraphEditor(FundamentalComponent):
children: list[rio.Component]
def __init__(
self,
*children: rio.Component,
key: str | int | None = None,
margin: float | None = None,
margin_x: float | None = None,
margin_y: float | None = None,
margin_left: float | None = None,
margin_top: float | None = None,
margin_right: float | None = None,
margin_bottom: float | None = None,
min_width: float = 0,
min_height: float = 0,
# MAX-SIZE-BRANCH max_width: float | None = None,
# MAX-SIZE-BRANCH max_height: float | None = None,
grow_x: bool = False,
grow_y: bool = False,
align_x: float | None = None,
align_y: float | None = None,
# SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never",
# SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never",
) -> None:
super().__init__(
key=key,
margin=margin,
margin_x=margin_x,
margin_y=margin_y,
margin_left=margin_left,
margin_top=margin_top,
margin_right=margin_right,
margin_bottom=margin_bottom,
min_width=min_width,
min_height=min_height,
# MAX-SIZE-BRANCH max_width=max_width,
# MAX-SIZE-BRANCH max_height=max_height,
grow_x=grow_x,
grow_y=grow_y,
align_x=align_x,
align_y=align_y,
# SCROLLING-REWORK scroll_x=scroll_x,
# SCROLLING-REWORK scroll_y=scroll_y,
)
self.children = list(children)
GraphEditor._unique_id_ = "GraphEditor-builtin"

View File

@@ -21,7 +21,7 @@ class _LinearContainer(FundamentalComponent):
proportions: t.Literal["homogeneous"] | t.Sequence[float] | None = None
# Don't let @dataclass generate a constructor
def __init__(self, *args, **kwargs):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
def add(self, child: rio.Component) -> te.Self:
@@ -299,7 +299,7 @@ class Column(_LinearContainer):
align_y: float | None = None,
# SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never",
# SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never",
):
) -> None:
super().__init__(
key=key,
margin=margin,