meeting 7/22 progress

pull/33/head
Khwaja 2 months ago
parent 052cd374c0
commit 41a51d44e5

@ -0,0 +1,67 @@
.Button {
padding: 6px 36px;
margin: 12px 6px;
line-height: 24px;
border: var(--box-border);
/* background-color: white; */
box-shadow: var(--box-shadow);
border-radius: var(--border-radius);
cursor: pointer;
color: rgba(0, 0, 0, 0.8);
text-transform: uppercase;
font-weight: bold;
font-size: 12px;
background: linear-gradient(rgb(255, 255, 255) 25%, rgb(245, 245, 245) 100%);
}
.Button:hover {
background: rgb(245, 245, 245);
}
.Button:active {
background: rgba(220, 220, 220);
}
.inline {
padding: 0 12px;
margin: 2px 4px 2px 0;
height: 32px;
font-size: 11px;
}
/* .inline:last-child {
margin: 2px 1px;
} */
.tooltip {
visibility: hidden;
background-color: #333;
color: white;
opacity: 0%;
width: 180px;
margin-top: 36px;
margin-left: -180px;
position: absolute;
z-index: 100;
text-transform: none;
font-size: 13px;
border-radius: 4px;
box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.25);
line-height: 18px;
padding: 6px;
transition: opacity 0.5s;
font-weight: normal;
text-align: left;
padding: 6px 12px;
}
.Button:hover .tooltip {
visibility: visible;
opacity: 100%;
transition: opacity 0.5s;
}
.Button:disabled {
color: rgba(0, 0, 0, 0.25);
cursor: default;
}

@ -95,7 +95,7 @@ const CaseBuilder = () => {
if (!center) return prev; if (!center) return prev;
return { return {
...prev, ...prev,
centers: { Centers: {
...prev.Centers, ...prev.Centers,
[centerName]: { ...center, input: productName }, [centerName]: { ...center, input: productName },
}, },
@ -120,7 +120,7 @@ const CaseBuilder = () => {
return { return {
...prevData, ...prevData,
plants: { Plants: {
...prevData.Plants, ...prevData.Plants,
[plantName]: updatedPlant, [plantName]: updatedPlant,
@ -140,7 +140,7 @@ const CaseBuilder = () => {
return { return {
...prevData, ...prevData,
plants: { Plants: {
...prevData.Plants, ...prevData.Plants,
[plantName]: { [plantName]: {
...plant, ...plant,
@ -159,7 +159,7 @@ const CaseBuilder = () => {
const updatedOutputs = [...center.output, productName]; const updatedOutputs = [...center.output, productName];
return { return {
...prev, ...prev,
centers: { Centers: {
...prev.Centers, ...prev.Centers,
[centerName]: { ...center, output: updatedOutputs }, [centerName]: { ...center, output: updatedOutputs },
}, },
@ -195,48 +195,20 @@ const CaseBuilder = () => {
}); });
}; };
const onRenamePlant = (uniqueId: string, newName: string) => { const onRenameNode = (type: EntityKey, uniqueId: string, newName: string) => {
setScenario((prev) => { setScenario((prevData) => {
const plant = prev.Plants[uniqueId]; const entities = prevData[type];
if (!plant) return prev; const node = entities[uniqueId];
const next = {
...prev,
plants: {
...prev.Plants,
[uniqueId]: { ...plant, name: newName },
},
};
return next;
});
};
const onRenameProduct = (uniqueId: string, newName: string) => { if (!node) return prevData;
setScenario((prev) => {
const product = prev.Products[uniqueId];
if (!product) return prev;
const next = {
...prev,
products: {
...prev.Products,
[uniqueId]: { ...product, name: newName },
},
};
return next;
});
};
const onRenameCenter = (uniqueId: string, newName: string) => { return {
setScenario((prev) => { ...prevData,
const center = prev.Centers[uniqueId]; [type]: {
if (!center) return prev; ...entities,
const next = { [uniqueId]: { ...node, name: newName },
...prev,
centers: {
...prev.Centers,
[uniqueId]: { ...center, name: newName },
}, },
}; };
return next;
}); });
}; };
@ -263,9 +235,9 @@ const CaseBuilder = () => {
onRemovePlant={(id) => onRemoveNode("Plants", id)} onRemovePlant={(id) => onRemoveNode("Plants", id)}
onRemoveProduct={(id) => onRemoveNode("Products", id)} onRemoveProduct={(id) => onRemoveNode("Products", id)}
onRemoveCenter={(id) => onRemoveNode("Centers", id)} onRemoveCenter={(id) => onRemoveNode("Centers", id)}
onRenamePlant={onRenamePlant} onRenamePlant={(id, name) => onRenameNode("Plants", id, name)}
onRenameProduct={onRenameProduct} onRenameProduct={(id, name) => onRenameNode("Products", id, name)}
onRenameCenter={onRenameCenter} onRenameCenter={(id, name) => onRenameNode("Centers", id, name)}
/> />
</div> </div>
</div> </div>

@ -1,17 +1,28 @@
import React, { useEffect, useCallback, useRef } from 'react'; import React, { useEffect, useCallback, useRef, useState } from "react";
import dagre from 'dagre'; import dagre from "dagre";
import { import {
ReactFlow,ReactFlowProvider, useNodesState, useEdgesState, Background, ReactFlow,
Controls, Node, Edge, Connection, MarkerType, ReactFlowInstance,
ReactFlowProvider,
useNodesState,
useEdgesState,
Background,
Controls,
Node,
Edge,
Connection,
addEdge,
MarkerType,
getNodesBounds, getNodesBounds,
getViewportForBounds, getViewportForBounds,
useReactFlow} from '@xyflow/react'; } from "@xyflow/react";
import { PlantNode, ProductNode, CenterNode } from './CircularData'; import { PlantNode, ProductNode, CenterNode } from "./CircularData";
import CustomNode, { CustomNodeData } from './NodesAndEdges'; import CustomNode, { CustomNodeData } from "./NodesAndEdges";
import Section from '../Common/Section'; import Section from "../Common/Section";
import Card from '../Common/Card'; import Card from "../Common/Card";
import styles from './PipelineBlock.module.css'; import styles from "./PipelineBlock.module.css";
import { toPng } from 'html-to-image'; import buttonStyles from "./Button.module.css";
import HelpButton from "../Common/Buttons/HelpButton.module.css";
interface PipelineBlockProps { interface PipelineBlockProps {
onAddPlant: () => void; onAddPlant: () => void;
@ -34,90 +45,94 @@ interface PipelineBlockProps {
plants: Record<string, PlantNode>; plants: Record<string, PlantNode>;
centers: Record<string, CenterNode>; centers: Record<string, CenterNode>;
} }
function getLayouted( function getLayouted(
nodes: Node<CustomNodeData>[], nodes: Node<CustomNodeData>[],
edges: Edge[] edges: Edge[],
): { nodes: Node<CustomNodeData>[]; edges: Edge[] } { ): { nodes: Node<CustomNodeData>[]; edges: Edge[] } {
const W = 125, H = 45; const W = 125,
H = 45;
const g = new dagre.graphlib.Graph(); const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({})); g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: 'LR' }); g.setGraph({ rankdir: "LR" });
nodes.forEach(n => g.setNode(n.id, { width: W, height: H })); nodes.forEach((n) => g.setNode(n.id, { width: W, height: H }));
edges.forEach(e => g.setEdge(e.source, e.target)); edges.forEach((e) => g.setEdge(e.source, e.target));
dagre.layout(g); dagre.layout(g);
return { return {
nodes: nodes.map(n => { nodes: nodes.map((n) => {
const d = g.node(n.id)!; const d = g.node(n.id)!;
return { ...n, position: { x: d.x - W/2, y: d.y - H/2 } }; return { ...n, position: { x: d.x - W / 2, y: d.y - H / 2 } };
}), }),
edges edges,
}; };
} }
const PipelineBlock: React.FC<PipelineBlockProps> = props => { const PipelineBlock: React.FC<PipelineBlockProps> = (props) => {
const mapRef = useRef<Record<string, 'plant'|'product'|'center'>>({}); const mapRef = useRef<Record<string, "plant" | "product" | "center">>({});
const flowWrapper = useRef<HTMLDivElement>(null); const flowWrapper = useRef<HTMLDivElement>(null);
const [rfInstance, setRfInstance] = useState<ReactFlowInstance | null>(null);
const [nodes, setNodes, onNodesChange] = useNodesState([]); const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState<Edge<CustomNodeData>>(
[],
);
const rebuild = useCallback(() => { const rebuild = useCallback(() => {
const m: Record<string, 'plant'|'product'|'center'> = {}; const m: Record<string, "plant" | "product" | "center"> = {};
const newNodes: Node<CustomNodeData>[] = []; const newNodes: Node<CustomNodeData>[] = [];
const newEdges: Edge[] = []; const newEdges: Edge[] = [];
Object.entries(props.products).forEach(([key, p]) => { Object.entries(props.products).forEach(([key, p]) => {
m[key] = 'product'; m[key] = "product";
newNodes.push({ newNodes.push({
id: p.uid, id: p.uid,
type: 'default', type: "default",
data: { label: p.name, type: 'product' }, data: { label: p.name, type: "product" },
position: { x: p.x, y: p.y }, position: { x: p.x, y: p.y },
className: 'ProductNode' className: "ProductNode",
}); });
}); });
Object.entries(props.plants).forEach(([key, pl]) => { Object.entries(props.plants).forEach(([key, pl]) => {
m[key] = 'plant'; m[key] = "plant";
newNodes.push({ newNodes.push({
id: pl.uid, id: pl.uid,
type: 'default', type: "default",
data: { label: pl.name, type: 'plant' }, data: { label: pl.name, type: "plant" },
position: { x: pl.x, y: pl.y }, position: { x: pl.x, y: pl.y },
className: 'PlantNode' className: "PlantNode",
}); });
pl.inputs.forEach(input => { pl.inputs.forEach((input) => {
newEdges.push({ newEdges.push({
id: `${input}-${key}-in`, id: `${input}-${key}-in`,
source: input, source: input,
target: key, target: key,
animated: true, animated: true,
style: { stroke: 'black' }, style: { stroke: "black" },
markerEnd: { type: MarkerType.ArrowClosed } markerEnd: { type: MarkerType.ArrowClosed },
}); });
}); });
pl.outputs.forEach(output => { pl.outputs.forEach((output) => {
newEdges.push({ newEdges.push({
id: `${key}-${output}-out`, id: `${key}-${output}-out`,
source: key, source: key,
target: output, target: output,
animated: true, animated: true,
style: { stroke: 'black' }, style: { stroke: "black" },
markerEnd: { type: MarkerType.ArrowClosed } markerEnd: { type: MarkerType.ArrowClosed },
}); });
}); });
}); });
Object.entries(props.centers).forEach(([key, c]) => { Object.entries(props.centers).forEach(([key, c]) => {
m[key] = 'center'; m[key] = "center";
newNodes.push({ newNodes.push({
id: c.uid, id: c.uid,
type: 'default', type: "default",
data: { label: c.name, type: 'center' }, data: { label: c.name, type: "center" },
position: { x: c.x, y: c.y }, position: { x: c.x, y: c.y },
className: 'CenterNode' className: "CenterNode",
}); });
if (c.input) { if (c.input) {
@ -126,145 +141,185 @@ const PipelineBlock: React.FC<PipelineBlockProps> = props => {
source: c.input, source: c.input,
target: key, target: key,
animated: true, animated: true,
style: { stroke: 'black' }, style: { stroke: "black" },
markerEnd: { type: MarkerType.ArrowClosed } markerEnd: { type: MarkerType.ArrowClosed },
}); });
} }
c.output.forEach(o => { c.output.forEach((o) => {
newEdges.push({ newEdges.push({
id: `${key}-${o}-out`, id: `${key}-${o}-out`,
source: key, source: key,
target: o, target: o,
animated: true, animated: true,
style: { stroke: 'black' }, style: { stroke: "black" },
markerEnd: { type: MarkerType.ArrowClosed } markerEnd: { type: MarkerType.ArrowClosed },
}); });
}); });
}); });
mapRef.current = m; mapRef.current = m;
setNodes(newNodes); setNodes(newNodes);
setEdges(newEdges); setEdges(newEdges);
}, [ }, [props.products, props.plants, props.centers, setNodes, setEdges]);
props.products, useEffect(() => {
props.plants, rebuild();
props.centers, }, [rebuild]);
setNodes, const onConnect = (connection: Connection) => {
setEdges const { source: s, target: t } = connection;
]); const st = mapRef.current[s!],
tt = mapRef.current[t!];
useEffect(() => { rebuild(); }, [rebuild]);
const onConnect = (c: Connection) => { if (st === "product" && tt === "plant") props.onSetPlantInput(t!, s!);
const s = c.source!, t = c.target!; else if (st === "plant" && tt === "product") props.onAddPlantOutput(s!, t!);
const st = mapRef.current[s], tt = mapRef.current[t]; else if (st === "product" && tt === "center")
if (st==='product' && tt==='plant') props.onSetPlantInput(t, s); props.onAddCenterInput(t!, s!);
else if (st==='plant' && tt==='product') props.onAddPlantOutput(s, t); else if (st === "center" && tt === "product")
else if (st==='product' && tt==='center') props.onAddCenterInput(t, s); props.onAddCenterOutput(s!, t);
else if (st==='center' && tt==='product') props.onAddCenterOutput(s, t);
}; };
const onNodeDragStop = (_: any, n: Node<CustomNodeData>) => { const onNodeDragStop = (_: any, n: Node<CustomNodeData>) => {
const { id, position, data } = n; const { id, position, data } = n;
if (data.type==='plant') props.onMovePlant(id, position.x, position.y); if (data.type === "plant") props.onMovePlant(id, position.x, position.y);
if (data.type==='product') props.onMoveProduct(id, position.x, position.y); if (data.type === "product")
if (data.type==='center') props.onMoveCenter(id, position.x, position.y); props.onMoveProduct(id, position.x, position.y);
if (data.type === "center") props.onMoveCenter(id, position.x, position.y);
}; };
const handleNodesDelete = useCallback((deleted: Node<CustomNodeData>[]) => {
deleted.forEach(n => {
const t = mapRef.current[n.id];
if (t==='plant') props.onRemovePlant(n.id);
if (t==='product') props.onRemoveProduct(n.id);
if (t==='center') props.onRemoveCenter(n.id);
});
}, [props]); const handleNodesDelete = useCallback(
(deleted: Node<CustomNodeData>[]) => {
deleted.forEach((n) => {
const t = mapRef.current[n.id];
if (t === "plant") props.onRemovePlant(n.id);
if (t === "product") props.onRemoveProduct(n.id);
if (t === "center") props.onRemoveCenter(n.id);
});
},
[props],
);
const onNodeDoubleClick = (_: React.MouseEvent, n: Node<CustomNodeData>) => { const onNodeDoubleClick = (_: React.MouseEvent, n: Node<CustomNodeData>) => {
const oldName = n.data.label; const oldName = n.data.label;
const newName = window.prompt('Enter new name', oldName); const newName = window.prompt("Enter new name", oldName);
console.log('after rename', newName); console.log("after rename", newName);
const uniqueId = n.id; const uniqueId = n.id;
if (!newName || newName===oldName) return; if (!newName || newName === oldName) return;
if (n.data.type==='plant') props.onRenamePlant(uniqueId, newName); if (n.data.type === "plant") props.onRenamePlant(uniqueId, newName);
if (n.data.type==='product') props.onRenameProduct(uniqueId, newName); if (n.data.type === "product") props.onRenameProduct(uniqueId, newName);
if (n.data.type==='center') props.onRenameCenter(uniqueId, newName); if (n.data.type === "center") props.onRenameCenter(uniqueId, newName);
}; };
function DownloadButton() { function DownloadButton() {
const onDownload = async () => { const onDownload = async () => {
if (!flowWrapper.current) return; if (!rfInstance || !flowWrapper.current) return;
const node = flowWrapper.current;
const { width, height } = node.getBoundingClientRect(); const minZoom = 0.1;
const dataUrl = await toPng(node, { const maxZoom = 2;
backgroundColor: '#fff', const padding = 0.1;
width: Math.round(width),
height: Math.round(height) const nodes = rfInstance.getNodes();
}); const bounds = getNodesBounds(nodes);
const downloadLink = document.createElement('a');
downloadLink.href = dataUrl; const { clientWidth: width, clientHeight: height } = flowWrapper.current;
downloadLink.download = 'pipeline.png'; const { x, y, zoom } = getViewportForBounds(
downloadLink.click(); bounds,
width,
height,
minZoom,
maxZoom,
padding,
);
rfInstance.setViewport({ x, y, zoom });
await new Promise((r) => setTimeout(r, 50));
const svgString = rfInstance.toSvg();
const blob = new Blob([svgString], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "pipeline.svg";
a.click();
URL.revokeObjectURL(url);
}; };
return <button style={{ margin: '0 8px' }} onClick={onDownload}>Export Pipeline</button>;
}; return (
<button className={buttonStyles.Button} onClick={onDownload}>
Export Pipeline
</button>
);
}
const onLayout = () => { const onLayout = () => {
const { nodes: ln, edges: le } = getLayouted(nodes, edges); const { nodes: ln, edges: le } = getLayouted(nodes, edges);
ln.forEach(n => { ln.forEach((n) => {
const { id, position, data } = n; const { id, position, data } = n;
if (data.type==='plant') props.onMovePlant(id, position.x, position.y); if (data.type === "plant") props.onMovePlant(id, position.x, position.y);
else if (data.type==='product') props.onMoveProduct(id, position.x, position.y); else if (data.type === "product")
props.onMoveProduct(id, position.x, position.y);
else props.onMoveCenter(id, position.x, position.y); else props.onMoveCenter(id, position.x, position.y);
}); });
}; };
return (
<>
<Section title="Pipeline" />
<Card>
<ReactFlowProvider>
<div ref={flowWrapper} className={styles.PipelineBlock} style={{ width: '100%', height: 600 }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeDoubleClick={onNodeDoubleClick}
onNodeDragStop={onNodeDragStop}
onNodesDelete={handleNodesDelete}
deleteKeyCode="Delete"
maxZoom={1.25}
minZoom={0.5}
snapToGrid
preventScrolling
nodeTypes={{ default: CustomNode }}
>
<Background />
<Controls showInteractive={false} />
</ReactFlow>
</div>
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
<button style={{ margin: '0 8px' }} onClick={props.onAddProduct}>Add product</button>
<button style={{ margin: '0 8px' }} onClick={props.onAddPlant}>Add plant</button>
<button style={{ margin: '0 8px' }} onClick={props.onAddCenter}>Add center</button>
<button style={{ margin: '0 8px' }} onClick={onLayout}>Auto Layout</button>
<DownloadButton />
<button style={{ margin: '0 8px' }} title="Drag & connect. Double-click to rename. Delete to remove.">?</button>
</div> return (
</ReactFlowProvider> <>
</Card> <Section title="Pipeline" />
</> <Card>
<ReactFlowProvider>
<div
ref={flowWrapper}
className={styles.PipelineBlock}
style={{ width: "100%", height: 600 }}
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeDoubleClick={onNodeDoubleClick}
onNodeDragStop={onNodeDragStop}
onNodesDelete={handleNodesDelete}
deleteKeyCode="Delete"
maxZoom={1.25}
minZoom={0.5}
snapToGrid
preventScrolling
nodeTypes={{ default: CustomNode }}
>
<Background />
<Controls showInteractive={false} />
</ReactFlow>
</div>
<div style={{ textAlign: "center", marginTop: "1rem" }}>
<button
className={buttonStyles.Button}
onClick={props.onAddProduct}
>
Add product
</button>
<button className={buttonStyles.Button} onClick={props.onAddPlant}>
Add plant
</button>
<button className={buttonStyles.Button} onClick={props.onAddCenter}>
Add center
</button>
<button className={buttonStyles.Button} onClick={onLayout}>
Auto Layout
</button>
<DownloadButton />
<button className={`buttonStyles.Button} ${HelpButton.HelpButton}`}>
?
<span className={HelpButton.tooltip}>
"Drag & connect. Double-click to rename. Delete to remove."
</span>
</button>
</div>
</ReactFlowProvider>
</Card>
</>
); );
}; };
export default PipelineBlock;
export default PipelineBlock;

@ -5,14 +5,12 @@
*/ */
.tooltip { .tooltip {
visibility: hidden;
background-color: var(--contrast-80); background-color: var(--contrast-80);
color: var(--contrast-10); color: var(--contrast-10);
opacity: 0; opacity: 0;
width: 250px; width: 250px;
margin-top: 36px; margin-top: 36px;
margin-left: -250px; margin-left: -250px;
position: absolute;
z-index: 100; z-index: 100;
font-size: 14px; font-size: 14px;
border-radius: var(--border-radius); border-radius: var(--border-radius);
@ -22,6 +20,7 @@
font-weight: normal; font-weight: normal;
text-align: left; text-align: left;
padding: 6px 12px; padding: 6px 12px;
position: relative;
} }
.icon { .icon {
@ -34,6 +33,7 @@
border: 0; border: 0;
background-color: transparent; background-color: transparent;
cursor: pointer; cursor: pointer;
position: relative;
} }
.HelpButton:hover .tooltip { .HelpButton:hover .tooltip {

@ -2,7 +2,7 @@
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es5",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false, "allowJs": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"alwaysStrict": true, "alwaysStrict": true,
"esModuleInterop": true, "esModuleInterop": true,
@ -29,7 +29,7 @@
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"checkJs": false, "checkJs": true,
"allowImportingTsExtensions": true "allowImportingTsExtensions": true
}, },
"include": ["src"] "include": ["src"]

Loading…
Cancel
Save