308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
// This program has been developed by students from the bachelor Computer Science at Utrecht
|
|
// University within the Software Project course.
|
|
// © Copyright Utrecht University (Department of Information and Computing Sciences)
|
|
import {
|
|
type NodeProps,
|
|
Position,
|
|
type Node, useNodeConnections
|
|
} from '@xyflow/react';
|
|
import {useEffect, useRef} from "react";
|
|
import {type EditorWarning} from "../components/EditorWarnings.tsx";
|
|
import { Toolbar } from '../components/NodeComponents';
|
|
import styles from '../../VisProg.module.css';
|
|
import {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
|
import {allowOnlyConnectionsFromType, noSelfConnections} from "../HandleRules.ts";
|
|
import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry';
|
|
import useFlowStore from '../VisProgStores';
|
|
import { TextField } from '../../../../components/TextField';
|
|
|
|
/**
|
|
* The default data dot a phase node
|
|
* @param label: the label of this phase
|
|
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
|
* @param children: ID's of children of this node
|
|
* @param hasReduce: whether this node has reducing functionality (true by default)
|
|
* @param nextPhaseId:
|
|
*/
|
|
export type PhaseNodeData = {
|
|
label: string;
|
|
droppable: boolean;
|
|
children: string[];
|
|
hasReduce: boolean;
|
|
nextPhaseId: string | "end" | null;
|
|
isFirstPhase: boolean;
|
|
};
|
|
|
|
export type PhaseNode = Node<PhaseNodeData>
|
|
|
|
/**
|
|
* Defines how a phase node should be rendered
|
|
* @param props NodeProps, like id, label, children
|
|
* @returns React.JSX.Element
|
|
*/
|
|
export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
|
const data = props.data;
|
|
const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
|
|
const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value});
|
|
const label_input_id = `phase_${props.id}_label_input`;
|
|
|
|
const connections = useNodeConnections({
|
|
id: props.id,
|
|
handleType: "target",
|
|
handleId: 'data'
|
|
})
|
|
|
|
const phaseOutCons = useNodeConnections({
|
|
id: props.id,
|
|
handleType: "source",
|
|
handleId: 'source',
|
|
})
|
|
|
|
const phaseInCons = useNodeConnections({
|
|
id: props.id,
|
|
handleType: "target",
|
|
handleId: 'target',
|
|
})
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
const noConnectionWarning : EditorWarning = {
|
|
scope: {
|
|
id: props.id,
|
|
handleId: 'data'
|
|
},
|
|
type: 'MISSING_INPUT',
|
|
severity: "WARNING",
|
|
description: "the phaseNode has no incoming goals, norms, and/or triggers"
|
|
}
|
|
|
|
if (connections.length === 0) { registerWarning(noConnectionWarning); return; }
|
|
unregisterWarning(props.id, `${noConnectionWarning.type}:data`);
|
|
}, [connections.length, props.id, registerWarning, unregisterWarning]);
|
|
|
|
useEffect(() => {
|
|
const notConnectedInfo : EditorWarning = {
|
|
scope: {
|
|
id: props.id,
|
|
handleId: undefined,
|
|
},
|
|
type: 'NOT_CONNECTED_TO_PROGRAM',
|
|
severity: "INFO",
|
|
description: "The PhaseNode is not connected to other nodes"
|
|
};
|
|
const noIncomingPhaseWarning : EditorWarning = {
|
|
scope: {
|
|
id: props.id,
|
|
handleId: 'target'
|
|
},
|
|
type: 'MISSING_INPUT',
|
|
severity: "WARNING",
|
|
description: "the phaseNode has no incoming connection from a phase or the startNode"
|
|
}
|
|
const noOutgoingPhaseWarning : EditorWarning = {
|
|
scope: {
|
|
id: props.id,
|
|
handleId: 'source'
|
|
},
|
|
type: 'MISSING_OUTPUT',
|
|
severity: "WARNING",
|
|
description: "the phaseNode has no outgoing connection to a phase or the endNode"
|
|
}
|
|
|
|
// register relevant warning and unregister others
|
|
if (phaseInCons.length === 0 && phaseOutCons.length === 0) {
|
|
registerWarning(notConnectedInfo);
|
|
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
|
|
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
|
|
return;
|
|
}
|
|
if (phaseOutCons.length === 0) {
|
|
registerWarning(noOutgoingPhaseWarning);
|
|
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
|
|
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
|
|
return;
|
|
}
|
|
if (phaseInCons.length === 0) {
|
|
registerWarning(noIncomingPhaseWarning);
|
|
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
|
|
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
|
|
return;
|
|
}
|
|
// unregister all warnings if none should be present
|
|
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
|
|
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
|
|
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
|
|
}, [phaseInCons.length, phaseOutCons.length, props.id, registerWarning, unregisterWarning]);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (ref.current) {
|
|
const { width, height } = ref.current.getBoundingClientRect();
|
|
|
|
console.log('Node width:', width, 'height:', height);
|
|
}
|
|
}, []);
|
|
return (
|
|
<>
|
|
<Toolbar nodeId={props.id} allowDelete={true}/>
|
|
<div className={`${styles.defaultNode} ${styles.nodePhase}`}>
|
|
<div className={"flex-row gap-sm"}>
|
|
<label htmlFor={label_input_id}>Name:</label>
|
|
<TextField
|
|
id={label_input_id}
|
|
value={data.label}
|
|
setValue={updateLabel}
|
|
placeholder={"Phase ..."}
|
|
/>
|
|
</div>
|
|
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
|
|
noSelfConnections,
|
|
allowOnlyConnectionsFromType(["phase", "start"]),
|
|
]} title="Connect to a phase or the startNode"/>
|
|
<MultiConnectionHandle type="target" position={Position.Bottom} id="data" rules={[
|
|
allowOnlyConnectionsFromType(["norm", "goal", "trigger"])
|
|
]} title="Connect to any number of norm, goal, and TriggerNode(-s)"/>
|
|
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
|
noSelfConnections,
|
|
allowOnlyConnectionsFromType(["phase", "end"]),
|
|
]} title="Connect to a phase or the endNode"/>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Reduces each phase, including its children down into its relevant data.
|
|
* @param node the node which is being reduced
|
|
* @param nodes all the nodes currently in the flow.
|
|
* @returns A collection of all reduced nodes in this phase, starting with this phases' reduced data.
|
|
*/
|
|
export function PhaseReduce(node: Node, nodes: Node[]) {
|
|
const thisNode = node as PhaseNode;
|
|
const data = thisNode.data as PhaseNodeData;
|
|
|
|
// node typings that are not in phase
|
|
const nodesNotInPhase: string[] = Object.entries(NodesInPhase)
|
|
.filter(([, f]) => !f())
|
|
.map(([t]) => t);
|
|
|
|
// node typings that then are in phase
|
|
const nodesInPhase: string[] = Object.entries(NodeTypes)
|
|
.filter(([t]) => !nodesNotInPhase.includes(t))
|
|
.map(([t]) => t);
|
|
|
|
// children nodes - make sure to check for empty arrays
|
|
let childrenNodes: Node[] = [];
|
|
if (data.children)
|
|
childrenNodes = nodes.filter((node) => data.children.includes(node.id));
|
|
|
|
// Build the result object
|
|
const result: Record<string, unknown> = {
|
|
id: thisNode.id,
|
|
name: data.label,
|
|
};
|
|
|
|
nodesInPhase.forEach((type) => {
|
|
const typedChildren = childrenNodes.filter((child) => child.type == type);
|
|
const reducer = NodeReduces[type as keyof typeof NodeReduces];
|
|
if (!reducer) {
|
|
console.warn(`No reducer found for node type ${type}`);
|
|
result[type + "s"] = [];
|
|
} else {
|
|
result[type + "s"] = [];
|
|
for (const typedChild of typedChildren) {
|
|
(result[type + "s"] as object[]).push(reducer(typedChild, nodes))
|
|
}
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
export const PhaseTooltip = `
|
|
A phase is a single stage of the program, during a phase Pepper will behave
|
|
in accordance with any connected norms, goals and triggers`;
|
|
|
|
/**
|
|
* This function is called whenever a connection is made with this node type as the target (phase)
|
|
* @param _thisNode the node of this node type which function is called
|
|
* @param _sourceNodeId the source of the received connection
|
|
*/
|
|
export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
|
const data = _thisNode.data as PhaseNodeData
|
|
|
|
const nodes = useFlowStore.getState().nodes;
|
|
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)!
|
|
switch (sourceNode.type) {
|
|
case "phase": break;
|
|
case "start": data.isFirstPhase = true; break;
|
|
// we only add none phase or start nodes to the children
|
|
// endNodes cannot be the source of an outgoing connection
|
|
// so we don't need to cover them with a special case
|
|
// before handling the default behavior
|
|
default: data.children.push(_sourceNodeId); break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This function is called whenever a connection is made with this node type as the source (phase)
|
|
* @param _thisNode the node of this node type which function is called
|
|
* @param _targetNodeId the target of the created connection
|
|
*/
|
|
export function PhaseConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
|
const data = _thisNode.data as PhaseNodeData
|
|
const nodes = useFlowStore.getState().nodes;
|
|
|
|
const targetNode = nodes.find((node) => node.id === _targetNodeId)
|
|
if (!targetNode) {throw new Error("Source node not found")}
|
|
|
|
// we set the nextPhaseId to the next target's id if the target is a phaseNode,
|
|
// or "end" if the target node is the end node
|
|
switch (targetNode.type) {
|
|
case 'phase': data.nextPhaseId = _targetNodeId; break;
|
|
case 'end': data.nextPhaseId = "end"; break;
|
|
default: break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This function is called whenever a connection is disconnected with this node type as the target (phase)
|
|
* @param _thisNode the node of this node type which function is called
|
|
* @param _sourceNodeId the source of the disconnected connection
|
|
*/
|
|
export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
|
const data = _thisNode.data as PhaseNodeData
|
|
|
|
const nodes = useFlowStore.getState().nodes;
|
|
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)
|
|
const sourceType = sourceNode ? sourceNode.type : "deleted";
|
|
switch (sourceType) {
|
|
case "phase": break;
|
|
case "start": data.isFirstPhase = false; break;
|
|
// we only add none phase or start nodes to the children
|
|
// endNodes cannot be the source of an outgoing connection
|
|
// so we don't need to cover them with a special case
|
|
// before handling the default behavior
|
|
default:
|
|
data.children = data.children.filter((child) => { if (child != _sourceNodeId) return child; });
|
|
break;
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* This function is called whenever a connection is disconnected with this node type as the source (phase)
|
|
* @param _thisNode the node of this node type which function is called
|
|
* @param _targetNodeId the target of the diconnected connection
|
|
*/
|
|
export function PhaseDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
|
const data = _thisNode.data as PhaseNodeData
|
|
const nodes = useFlowStore.getState().nodes;
|
|
|
|
// if the target is a phase or end node set the nextPhaseId to null,
|
|
// as we are no longer connected to a subsequent phaseNode or to the endNode
|
|
if (nodes.some((node) => node.id === _targetNodeId && ['phase', 'end'].includes(node.type!))){
|
|
data.nextPhaseId = null;
|
|
}
|
|
} |