diff --git a/web/src/components/CaseBuilder/Button.module.css b/web/src/components/CaseBuilder/Button.module.css new file mode 100644 index 0000000..fd956dd --- /dev/null +++ b/web/src/components/CaseBuilder/Button.module.css @@ -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; +} \ No newline at end of file diff --git a/web/src/components/CaseBuilder/CaseBuilder.tsx b/web/src/components/CaseBuilder/CaseBuilder.tsx index 9c22818..fa219ab 100644 --- a/web/src/components/CaseBuilder/CaseBuilder.tsx +++ b/web/src/components/CaseBuilder/CaseBuilder.tsx @@ -95,7 +95,7 @@ const CaseBuilder = () => { if (!center) return prev; return { ...prev, - centers: { + Centers: { ...prev.Centers, [centerName]: { ...center, input: productName }, }, @@ -120,7 +120,7 @@ const CaseBuilder = () => { return { ...prevData, - plants: { + Plants: { ...prevData.Plants, [plantName]: updatedPlant, @@ -140,7 +140,7 @@ const CaseBuilder = () => { return { ...prevData, - plants: { + Plants: { ...prevData.Plants, [plantName]: { ...plant, @@ -159,7 +159,7 @@ const CaseBuilder = () => { const updatedOutputs = [...center.output, productName]; return { ...prev, - centers: { + Centers: { ...prev.Centers, [centerName]: { ...center, output: updatedOutputs }, }, @@ -195,48 +195,20 @@ const CaseBuilder = () => { }); }; - const onRenamePlant = (uniqueId: string, newName: string) => { - setScenario((prev) => { - const plant = prev.Plants[uniqueId]; - if (!plant) return prev; - const next = { - ...prev, - plants: { - ...prev.Plants, - [uniqueId]: { ...plant, name: newName }, - }, - }; - return next; - }); - }; + const onRenameNode = (type: EntityKey, uniqueId: string, newName: string) => { + setScenario((prevData) => { + const entities = prevData[type]; + const node = entities[uniqueId]; - const onRenameProduct = (uniqueId: string, newName: string) => { - setScenario((prev) => { - const product = prev.Products[uniqueId]; - if (!product) return prev; - const next = { - ...prev, - products: { - ...prev.Products, - [uniqueId]: { ...product, name: newName }, - }, - }; - return next; - }); - }; + if (!node) return prevData; - const onRenameCenter = (uniqueId: string, newName: string) => { - setScenario((prev) => { - const center = prev.Centers[uniqueId]; - if (!center) return prev; - const next = { - ...prev, - centers: { - ...prev.Centers, - [uniqueId]: { ...center, name: newName }, + return { + ...prevData, + [type]: { + ...entities, + [uniqueId]: { ...node, name: newName }, }, }; - return next; }); }; @@ -263,9 +235,9 @@ const CaseBuilder = () => { onRemovePlant={(id) => onRemoveNode("Plants", id)} onRemoveProduct={(id) => onRemoveNode("Products", id)} onRemoveCenter={(id) => onRemoveNode("Centers", id)} - onRenamePlant={onRenamePlant} - onRenameProduct={onRenameProduct} - onRenameCenter={onRenameCenter} + onRenamePlant={(id, name) => onRenameNode("Plants", id, name)} + onRenameProduct={(id, name) => onRenameNode("Products", id, name)} + onRenameCenter={(id, name) => onRenameNode("Centers", id, name)} /> diff --git a/web/src/components/CaseBuilder/PipelineBlock.tsx b/web/src/components/CaseBuilder/PipelineBlock.tsx index f1bc585..e1bde71 100644 --- a/web/src/components/CaseBuilder/PipelineBlock.tsx +++ b/web/src/components/CaseBuilder/PipelineBlock.tsx @@ -1,17 +1,28 @@ -import React, { useEffect, useCallback, useRef } from 'react'; -import dagre from 'dagre'; +import React, { useEffect, useCallback, useRef, useState } from "react"; +import dagre from "dagre"; import { - ReactFlow,ReactFlowProvider, useNodesState, useEdgesState, Background, - Controls, Node, Edge, Connection, MarkerType, + ReactFlow, + ReactFlowInstance, + ReactFlowProvider, + useNodesState, + useEdgesState, + Background, + Controls, + Node, + Edge, + Connection, + addEdge, + MarkerType, getNodesBounds, getViewportForBounds, - useReactFlow} from '@xyflow/react'; -import { PlantNode, ProductNode, CenterNode } from './CircularData'; -import CustomNode, { CustomNodeData } from './NodesAndEdges'; -import Section from '../Common/Section'; -import Card from '../Common/Card'; -import styles from './PipelineBlock.module.css'; -import { toPng } from 'html-to-image'; +} from "@xyflow/react"; +import { PlantNode, ProductNode, CenterNode } from "./CircularData"; +import CustomNode, { CustomNodeData } from "./NodesAndEdges"; +import Section from "../Common/Section"; +import Card from "../Common/Card"; +import styles from "./PipelineBlock.module.css"; +import buttonStyles from "./Button.module.css"; +import HelpButton from "../Common/Buttons/HelpButton.module.css"; interface PipelineBlockProps { onAddPlant: () => void; @@ -34,90 +45,94 @@ interface PipelineBlockProps { plants: Record; centers: Record; } - + function getLayouted( nodes: Node[], - edges: Edge[] + edges: Edge[], ): { nodes: Node[]; edges: Edge[] } { - const W = 125, H = 45; + const W = 125, + H = 45; const g = new dagre.graphlib.Graph(); g.setDefaultEdgeLabel(() => ({})); - g.setGraph({ rankdir: 'LR' }); - nodes.forEach(n => g.setNode(n.id, { width: W, height: H })); - edges.forEach(e => g.setEdge(e.source, e.target)); + g.setGraph({ rankdir: "LR" }); + nodes.forEach((n) => g.setNode(n.id, { width: W, height: H })); + edges.forEach((e) => g.setEdge(e.source, e.target)); dagre.layout(g); return { - nodes: nodes.map(n => { + nodes: nodes.map((n) => { 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 = props => { - const mapRef = useRef>({}); +const PipelineBlock: React.FC = (props) => { + const mapRef = useRef>({}); const flowWrapper = useRef(null); + const [rfInstance, setRfInstance] = useState(null); const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); - + const [edges, setEdges, onEdgesChange] = useEdgesState>( + [], + ); + const rebuild = useCallback(() => { - const m: Record = {}; + const m: Record = {}; const newNodes: Node[] = []; const newEdges: Edge[] = []; Object.entries(props.products).forEach(([key, p]) => { - m[key] = 'product'; + m[key] = "product"; newNodes.push({ id: p.uid, - type: 'default', - data: { label: p.name, type: 'product' }, + type: "default", + data: { label: p.name, type: "product" }, position: { x: p.x, y: p.y }, - className: 'ProductNode' + className: "ProductNode", }); }); Object.entries(props.plants).forEach(([key, pl]) => { - m[key] = 'plant'; + m[key] = "plant"; newNodes.push({ id: pl.uid, - type: 'default', - data: { label: pl.name, type: 'plant' }, + type: "default", + data: { label: pl.name, type: "plant" }, position: { x: pl.x, y: pl.y }, - className: 'PlantNode' + className: "PlantNode", }); - pl.inputs.forEach(input => { + pl.inputs.forEach((input) => { newEdges.push({ id: `${input}-${key}-in`, source: input, target: key, animated: true, - style: { stroke: 'black' }, - markerEnd: { type: MarkerType.ArrowClosed } + style: { stroke: "black" }, + markerEnd: { type: MarkerType.ArrowClosed }, }); }); - pl.outputs.forEach(output => { + pl.outputs.forEach((output) => { newEdges.push({ id: `${key}-${output}-out`, source: key, target: output, animated: true, - style: { stroke: 'black' }, - markerEnd: { type: MarkerType.ArrowClosed } + style: { stroke: "black" }, + markerEnd: { type: MarkerType.ArrowClosed }, }); }); }); Object.entries(props.centers).forEach(([key, c]) => { - m[key] = 'center'; + m[key] = "center"; newNodes.push({ id: c.uid, - type: 'default', - data: { label: c.name, type: 'center' }, + type: "default", + data: { label: c.name, type: "center" }, position: { x: c.x, y: c.y }, - className: 'CenterNode' + className: "CenterNode", }); if (c.input) { @@ -126,145 +141,185 @@ const PipelineBlock: React.FC = props => { source: c.input, target: key, animated: true, - style: { stroke: 'black' }, - markerEnd: { type: MarkerType.ArrowClosed } + style: { stroke: "black" }, + markerEnd: { type: MarkerType.ArrowClosed }, }); - } - c.output.forEach(o => { + c.output.forEach((o) => { newEdges.push({ id: `${key}-${o}-out`, source: key, target: o, animated: true, - style: { stroke: 'black' }, - markerEnd: { type: MarkerType.ArrowClosed } + style: { stroke: "black" }, + markerEnd: { type: MarkerType.ArrowClosed }, }); }); }); mapRef.current = m; setNodes(newNodes); setEdges(newEdges); - }, [ + }, [props.products, props.plants, props.centers, setNodes, setEdges]); - props.products, - props.plants, - props.centers, - setNodes, - setEdges - ]); - - useEffect(() => { rebuild(); }, [rebuild]); - const onConnect = (c: Connection) => { - const s = c.source!, t = c.target!; - const st = mapRef.current[s], tt = mapRef.current[t]; - if (st==='product' && tt==='plant') props.onSetPlantInput(t, s); - else if (st==='plant' && tt==='product') props.onAddPlantOutput(s, t); - else if (st==='product' && tt==='center') props.onAddCenterInput(t, s); - else if (st==='center' && tt==='product') props.onAddCenterOutput(s, t); + useEffect(() => { + rebuild(); + }, [rebuild]); + const onConnect = (connection: Connection) => { + const { source: s, target: t } = connection; + const st = mapRef.current[s!], + tt = mapRef.current[t!]; + + if (st === "product" && tt === "plant") props.onSetPlantInput(t!, s!); + else if (st === "plant" && tt === "product") props.onAddPlantOutput(s!, t!); + else if (st === "product" && tt === "center") + props.onAddCenterInput(t!, s!); + else if (st === "center" && tt === "product") + props.onAddCenterOutput(s!, t); }; - + const onNodeDragStop = (_: any, n: Node) => { const { id, position, data } = n; - 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==='center') props.onMoveCenter(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 === "center") props.onMoveCenter(id, position.x, position.y); }; - - const handleNodesDelete = useCallback((deleted: Node[]) => { - 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[]) => { + 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) => { const oldName = n.data.label; - const newName = window.prompt('Enter new name', oldName); - console.log('after rename', newName); + const newName = window.prompt("Enter new name", oldName); + console.log("after rename", newName); const uniqueId = n.id; - if (!newName || newName===oldName) return; - if (n.data.type==='plant') props.onRenamePlant(uniqueId, newName); - if (n.data.type==='product') props.onRenameProduct(uniqueId, newName); - if (n.data.type==='center') props.onRenameCenter(uniqueId, newName); + if (!newName || newName === oldName) return; + if (n.data.type === "plant") props.onRenamePlant(uniqueId, newName); + if (n.data.type === "product") props.onRenameProduct(uniqueId, newName); + if (n.data.type === "center") props.onRenameCenter(uniqueId, newName); }; function DownloadButton() { const onDownload = async () => { - if (!flowWrapper.current) return; - const node = flowWrapper.current; - const { width, height } = node.getBoundingClientRect(); - const dataUrl = await toPng(node, { - backgroundColor: '#fff', - width: Math.round(width), - height: Math.round(height) - }); - const downloadLink = document.createElement('a'); - downloadLink.href = dataUrl; - downloadLink.download = 'pipeline.png'; - downloadLink.click(); + if (!rfInstance || !flowWrapper.current) return; + + const minZoom = 0.1; + const maxZoom = 2; + const padding = 0.1; + + const nodes = rfInstance.getNodes(); + const bounds = getNodesBounds(nodes); + + const { clientWidth: width, clientHeight: height } = flowWrapper.current; + const { x, y, zoom } = getViewportForBounds( + 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 ; - }; - + + return ( + + ); + } + const onLayout = () => { const { nodes: ln, edges: le } = getLayouted(nodes, edges); - ln.forEach(n => { + ln.forEach((n) => { const { id, position, data } = n; - if (data.type==='plant') props.onMovePlant(id, position.x, position.y); - else if (data.type==='product') props.onMoveProduct(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 props.onMoveCenter(id, position.x, position.y); }); }; - - return ( -<> -
- - -
- - - - -
- -
- - - - - - -
-
-
- + return ( + <> +
+ + +
+ + + + +
+
+ + + + + + +
+
+
+ ); - }; - -export default PipelineBlock; - \ No newline at end of file +export default PipelineBlock; diff --git a/web/src/components/Common/Buttons/HelpButton.module.css b/web/src/components/Common/Buttons/HelpButton.module.css index c26d1e5..f265c3f 100644 --- a/web/src/components/Common/Buttons/HelpButton.module.css +++ b/web/src/components/Common/Buttons/HelpButton.module.css @@ -5,14 +5,12 @@ */ .tooltip { - visibility: hidden; background-color: var(--contrast-80); color: var(--contrast-10); opacity: 0; width: 250px; margin-top: 36px; margin-left: -250px; - position: absolute; z-index: 100; font-size: 14px; border-radius: var(--border-radius); @@ -22,6 +20,7 @@ font-weight: normal; text-align: left; padding: 6px 12px; + position: relative; } .icon { @@ -34,6 +33,7 @@ border: 0; background-color: transparent; cursor: pointer; + position: relative; } .HelpButton:hover .tooltip { diff --git a/web/tsconfig.json b/web/tsconfig.json index 13cc76e..beab08f 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": false, + "allowJs": true, "allowSyntheticDefaultImports": true, "alwaysStrict": true, "esModuleInterop": true, @@ -29,7 +29,7 @@ "noUncheckedIndexedAccess": true, "noUnusedLocals": false, "noUnusedParameters": false, - "checkJs": false, + "checkJs": true, "allowImportingTsExtensions": true }, "include": ["src"]