fbd-editor/src/App.jsx

967 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useCallback } from 'react'
import { ReactFlow, Controls, Background, useNodesState, useEdgesState, addEdge, BackgroundVariant, getNodesBounds } from '@xyflow/react'
import { toSvg } from 'html-to-image'
import '@xyflow/react/dist/style.css'
import './App.css'
import Toolbar from './components/Toolbar';
import ControlPanel from './components/ControlPanel';
import CustomBlock from './components/CustomBlock';
import ModalData from './components/ModalData'
const nodeTypes = {
fbdBlock: CustomBlock
}
const EDGE_STYLES = {
bool: { stroke: '#000', strokeWidth: 2 },
int: { stroke: '#040089', strokeWidth: 3 },
dint: { stroke: '#771061', strokeWidth: 3 },
real: { stroke: '#EB3B13', strokeWidth: 3 },
time: { stroke: '#AB7001', strokeWidth: 2.5 },
string: { stroke: '#58E9D0', strokeWidth: 2.5 }
}
function App() {
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
const [flowInstance, setFlowInstance] = useState(null)
const [edgeType, setEdgeType] = useState('bool')
const [modalData, setModalData] = useState(null)
function checkEdge(edge, currentNodes) {
const sourceNode = currentNodes.find(node => node.id === edge.source)
const targetNode = currentNodes.find(node => node.id === edge.target)
if (!sourceNode || !targetNode) return null
const sourceIndex = parseInt(edge.sourceHandle.split('-')[1], 10)
const targetIndex = parseInt(edge.targetHandle.split('-')[1], 10)
const sourceType = sourceNode.data.outputs[sourceIndex].replace('$/', '') || 'INT'
const targetType = targetNode.data.inputs[targetIndex].replace('$/', '') || 'INT'
if (edge.source == 1000000006)
if (sourceType === targetType && edge.data.type === sourceType.toLowerCase())
return null
let generalType = ''
for (let t of sourceType.split('/'))
if (targetType.includes(t))
generalType += t + '/'
if (generalType.endsWith('/'))
generalType = generalType.slice(0, -1)
if (!generalType)
generalType = sourceType.split('/')[0]
return generalType
}
function updateTwoNode(edge, newType, currentNodes, currentEdges) {
const sourceIndex = parseInt(edge.sourceHandle.split('-')[1], 10)
const targetIndex = parseInt(edge.targetHandle.split('-')[1], 10)
let change = false
const nextNodes = currentNodes.map(node => {
if (node.id === edge.source) {
let newInputs = [...node.data.inputs]
let newOutputs = [...node.data.outputs]
if (node.data.outputs[sourceIndex]?.startsWith('$')) {
for (let ind = 0; ind < newInputs.length; ind++)
if (newInputs[ind].startsWith('$') && ('$/' + newType) !== newInputs[ind]) {
newInputs[ind] = '$/' + newType
change = true
}
for (let ind = 0; ind < newOutputs.length; ind++)
if (newOutputs[ind].startsWith('$') && ('$/' + newType) !== newOutputs[ind]) {
newOutputs[ind] = '$/' + newType
change = true
}
}
else
newOutputs[sourceIndex] = newType
return {
...node,
data: {
...node.data,
inputs: newInputs,
outputs: newOutputs
}
}
}
else if (node.id === edge.target) {
let newInputs = [...node.data.inputs]
let newOutputs = [...node.data.outputs]
if (node.data.inputs[targetIndex]?.startsWith('$')) {
for (let ind = 0; ind < newInputs.length; ind++)
if (newInputs[ind].startsWith('$') && ('$/' + newType) !== newInputs[ind]) {
newInputs[ind] = '$/' + newType
change = true
}
for (let ind = 0; ind < newOutputs.length; ind++)
if (newOutputs[ind].startsWith('$') && ('$/' + newType) !== newOutputs[ind]) {
newOutputs[ind] = '$/' + newType
change = true
}
}
else
newInputs[targetIndex] = newType
return {
...node,
data: {
...node.data,
inputs: newInputs,
outputs: newOutputs
}
}
}
return node
})
const nextEdges = currentEdges.map(ed => {
if (edge.id === ed.id) {
const currentStyle = EDGE_STYLES[newType.toLowerCase().split('/')[0]] || EDGE_STYLES['bool']
return {
...ed,
style: currentStyle,
data: {type: newType.toLowerCase().split('/')[0]}
}
}
return ed
})
return {nextNodes, nextEdges, change}
}
function updateGraphTypes(currentEdges, currentNodes) {
let globalChange = true
while (globalChange) {
globalChange = false
for (const edge of currentEdges) {
const newType = checkEdge(edge, currentNodes)
if (newType) {
const { nextNodes, nextEdges, change } = updateTwoNode(edge, newType, currentNodes, currentEdges)
currentNodes = nextNodes
currentEdges = nextEdges
globalChange = globalChange || change
}
}
}
return {currentNodes, currentEdges}
}
const onConnect = useCallback(
(params) => {
const sourceNode = nodes.find(node => node.id === params.source)
const sourceIndex = parseInt(params.sourceHandle.split('-')[1], 10)
const sourceType = sourceNode?.data?.outputs[sourceIndex].replace('$/', '') || 'INT'
const newEdge = {
...params,
id: `e_${Date.now()}`,
type: 'smoothstep',
style: EDGE_STYLES[sourceType.toLowerCase().split('/')[0]] || EDGE_STYLES['bool'],
data: { type: sourceType.toLowerCase().split('/')[0] }
}
const allEdges = [...edges, newEdge]
const { currentNodes, currentEdges } = updateGraphTypes(allEdges, [...nodes])
setNodes(currentNodes)
setEdges(currentEdges)
}, [nodes, setNodes, setEdges, edgeType]
)
const onDragOver = useCallback((event) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}, [])
const onDrop = useCallback(
(event) => {
event.preventDefault()
if (!flowInstance)
return
const dataStr = event.dataTransfer.getData('application/reactflow')
if (!dataStr)
return
const {type, label, width, height, inputs = [], outputs = [],
inputsLabels = [], outputsLabels = [], offsetX = 0, offsetY = 0} = JSON.parse(dataStr)
const position = flowInstance.screenToFlowPosition({
x: event.clientX - offsetX,
y: event.clientY - offsetY
})
if (['const', 'switch_type', 'input', 'output'].includes(type)) {
setModalData({ type, label, width, height, position, inputs, outputs, inputsLabels, outputsLabels })
return
}
const newNode = {
id: `${Date.now()}`,
type: 'fbdBlock',
position,
data: { type, label, width, height, inputs, outputs, inputsLabels, outputsLabels }
}
setNodes((currentNodes) => [...currentNodes, newNode])
}, [flowInstance, setNodes]
)
function handleSaveModalData(formData) {
if (!modalData)
return
let finalLabel = modalData.label
let inputs = []
let outputs = []
if (modalData.type === 'const') {
finalLabel = `${formData.value} (${formData.toType})`
outputs = [formData.fromType]
}
else if (modalData.type === 'switch_type') {
finalLabel = `${formData.fromType}_TO_${formData.toType}`
inputs = [formData.fromType]
outputs = [formData.toType]
}
else if (modalData.type === 'input') {
finalLabel = `${formData.value} (${formData.fromType})`
outputs = [formData.fromType]
}
else if (modalData.type === 'output') {
finalLabel = `${formData.value} (${formData.fromType})`
inputs = [formData.fromType]
}
const newNode = {
id: `${Date.now()}`,
type: 'fbdBlock',
position: modalData.position,
data: {
type: modalData.type,
label: finalLabel,
width: modalData.width,
height: modalData.height,
value: formData.value,
inputs,
outputs,
inputsLabels: modalData.inputsLabels,
outputsLabels: modalData.outputsLabels,
metaFromType: formData.fromType,
metaToType: formData.toType
}
}
setNodes((currentNodes) => [...currentNodes, newNode])
setModalData(null)
}
const isValidConnection = useCallback((connection) => {
if (connection.source === connection.target)
return false
function checkIfCycleExists(sourceId, targetId) {
const visited = new Set()
function dfs(currentId) {
if (currentId === sourceId)
return true
if (visited.has(currentId))
return false
visited.add(currentId)
const outgoingEdges = edges.filter(edge => edge.source === currentId)
for (const edge of outgoingEdges)
if (dfs(edge.target))
return true
return false
}
return dfs(targetId)
}
if (checkIfCycleExists(connection.source, connection.target))
return false
const sourceNode = nodes.find(node => node.id === connection.source)
const targetNode = nodes.find(node => node.id === connection.target)
if (!sourceNode || !targetNode)
return false
const sourceIndex = parseInt(connection.sourceHandle.split('-')[1], 10)
const targetIndex = parseInt(connection.targetHandle.split('-')[1], 10)
const sourceType = sourceNode.data.outputs[sourceIndex].replace('$/', '')
const targetType = targetNode.data.inputs[targetIndex].replace('$/', '')
for (let t of sourceType.split('/'))
if (targetType.includes(t))
return true
return false
}, [nodes, edges])
function handleClear() {
if ((nodes.length > 0 || edges.length > 0) && window.confirm("Очистить холст?")) {
setNodes([])
setEdges([])
}
}
const handleExportSvg = useCallback(() => {
const reactFlowViewport = document.querySelector('.react-flow__viewport')
if (!reactFlowViewport || !nodes.length > 0) {
alert('Не удалось найти холст для экспорта')
return
}
const bounds = getNodesBounds(nodes)
const padding = 30
const widthExportWindow = bounds.width + padding * 2
const heightExportWindow = bounds.height + padding * 2
toSvg(reactFlowViewport, {
backgroundColor: 'white',
width: widthExportWindow,
height: heightExportWindow,
style: {
width: `${widthExportWindow}px`,
height: `${heightExportWindow}px`,
transform: `translate(${-bounds.x + padding}px, ${-bounds.y + padding}px) scale(1)`,
transformOrigin: 'top left'
}
}).then(async (dataUrl) => {
if ('showSaveFilePicker' in window) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: 'fbd-scheme.svg',
types: [{
description: 'Векторная диаграмма SVG',
accept: {'image/svg+xml': ['.svg']}
}]
})
const writable = await handle.createWritable()
const scheme = await fetch(dataUrl)
const file = await scheme.blob()
await writable.write(file)
await writable.close()
}
catch (err) { console.log('Пользователь отменил сохранение или произошла ошибка: ', err) }
}
else {
const link = document.createElement('a')
link.download = 'fbd-scheme.svg'
link.href = dataUrl
link.click()
}
}).catch((error) => {
console.error('Ошибка генерации SVG-файла: ', error)
alert('Не удалось сгененировать SVG-файл')
})
}, [nodes])
const handleExportXml = useCallback(async () => {
if (nodes.length === 0) {
alert('На холсте нет блоков для экспорта')
return
}
let xml = `<?xml version="1.0" encoding="utf-8"?>\n`
xml += `<project xmlns="http://www.plcopen.org/xml/tc6_0201" `
xml += `xmlns:xsd="http://www.w3.org/2001/XMLSchema-instance" `
xml += `xmlns:ns1="http://www.plcopen.org/xml/tc6_0201" `
xml += `xmlns:xhtml="http://www.w3.org/1999/xhtml\">\n`
xml += ` <fileHeader companyName="University" productName="FBD Editor" productVersion="1" creationDateTime="${new Date().toISOString().split('.')[0] + 'Z'}"/>\n`
xml += ` <contentHeader name="diagram" modificationDateTime="${new Date().toISOString().split('.')[0] + 'Z'}">\n`
xml += ` <coordinateInfo>\n`
xml += ` <fbd><scaling x="1" y="1"/></fbd>\n`
xml += ` <ld><scaling x="1" y="1"/></ld>\n`
xml += ` <sfc><scaling x="1" y="1"/></sfc>\n`
xml += ` </coordinateInfo>\n`
xml += ` </contentHeader>\n`
xml += ` <types>\n`
xml += ` <dataTypes/>\n`
xml += ` <pous>\n`
xml += ` <pou name="main" pouType="program">\n`
function getPortType(type) {
const newType = type.toUpperCase().replace('$/', '')
if (!newType.includes('/'))
return newType
const types = newType.split('/')
const hasAll = (...neededTypes) => neededTypes.every(t => types.includes(t))
if (hasAll('BOOL', 'INT', 'DINT', 'REAL', 'TIME', 'STRING'))
return 'ANY'
if (hasAll('INT', 'DINT', 'REAL', 'TIME'))
return 'ANY_MAGNITUDE'
if (hasAll('INT', 'DINT', 'REAL'))
return 'ANY_NUM'
if (hasAll('INT', 'DINT'))
return 'ANY_INT'
return 'ANY'
}
let dopInd = 1
const nodesIndexes = new Map()
nodes.forEach(node => {
let counts = 0
if ((['input', 'const'].includes(node.data.type)) && node.data.value) {
edges.forEach(edge => {
if (edge.source === node.id)
counts += 1
})
}
else if (node.data.type === 'output' && node.data.value) {
edges.forEach(edge => {
if (edge.target === node.id)
counts += 1
})
}
else
counts += 1
if (counts !== 0) {
nodesIndexes.set(node.id, [1000000000 + dopInd])
dopInd += 1
}
for (let ind = 1; ind < counts; ind++, dopInd++)
nodesIndexes.get(node.id).push(1000000000 + dopInd)
})
function getNewBlockId(id) {
const newInd = nodesIndexes.get(id)
if (newInd.length > 1) {
const t = nodesIndexes.get(id).splice(0, 1)[0]
return t
}
else if (newInd)
return newInd[0]
dopInd += 1
return 1000000000 + dopInd
}
xml += ` <interface>\n`
const valuesIn = new Map()
const valuesOut = new Map()
nodes.forEach(node => {
if (node.data.type === 'input' && node.data.value)
valuesIn.set(node.data.value, node.data.metaFromType || 'ANY')
else if (node.data.type === 'output' && node.data.value)
valuesOut.set(node.data.value, node.data.metaFromType || 'ANY')
})
if (valuesIn) {
xml += ` <inputVars>\n`
valuesIn.forEach((type, value) => {
xml += ` <variable name="${value}">\n`
xml += ` <type><${getPortType(type)}/></type>\n`
xml += ` </variable>\n`
})
xml += ` </inputVars>\n`
}
if (valuesOut) {
xml += ` <outputVars>\n`
valuesOut.forEach((type, value) => {
xml += ` <variable name="${value}">\n`
xml += ` <type><${getPortType(type)}/></type>\n`
xml += ` </variable>\n`
})
xml += ` </outputVars>\n`
}
xml += ` </interface>\n`
xml += ` <body>\n`
xml += ` <FBD>\n\n`
nodes.forEach(node => {
const newInd = nodesIndexes.get(node.id)
if (node && node.data.type === 'input' || node.data.type === 'const') {
for (let i = 0; i < newInd.length; i++) {
xml += ` <inVariable localId="${newInd[i]}" width="${node.data.width}" height="${node.data.height}">\n`
xml += ` <position x="${Math.round(node.position.x)}" y="${Math.round(node.position.y)}"/>\n`
xml += ` <connectionPointOut/>\n`
xml += ` <expression>${node.data.value}</expression>\n`
xml += ` </inVariable>\n\n`
}
}
})
nodes.forEach(node => {
const newInd = nodesIndexes.get(node.id)
if (node.data.type === 'output') {
for (let i = 0; i < newInd.length; i++) {
xml += ` <outVariable localId="${newInd[i]}" width="${node.data.width}" height="${node.data.height}">\n`
xml += ` <position x="${Math.round(node.position.x)}" y="${Math.round(node.position.y)}"/>\n`
xml += ` <connectionPointIn>\n`
edges.forEach(edge => {
if (edge.target === node.id) {
const targetIndex = parseInt(edge.targetHandle.split('-')[1], 10)
const targetLabel = node.data.inputsLabels[targetIndex]
if (targetLabel === 'IN') {
const sourceNode = nodes.find(node => node.id === edge.source)
if (['const', 'input'].includes(sourceNode.data.type))
xml += ` <connection refLocalId="${getNewBlockId(edge.source)}"/>\n`
else {
const sourceIndex = parseInt(edge.sourceHandle.split('-')[1], 10)
const sourceLabel = sourceNode.data.outputsLabels[sourceIndex]
xml += ` <connection refLocalId="${getNewBlockId(edge.source)}" formalParameter="${sourceLabel}"/>\n`
}
}
}
})
xml += ` </connectionPointIn>\n`
xml += ` <expression>${node.data.value}</expression>\n`
xml += ` </outVariable>\n\n`
}
}
})
nodes.forEach(node => {
if (!['output', 'input', 'const'].includes(node.data.type)) {
xml += ` <block localId="${getNewBlockId(node.id)}" typeName="${node.data.type === 'switch_type' ? node.data.label.toUpperCase() : node.data.type.toUpperCase()}" width="${node.data.width}" height="${node.data.height}">\n`
xml += ` <position x="${Math.round(node.position.x)}" y="${Math.round(node.position.y)}"/>\n`
xml += ` <inputVariables>\n`
for (const portType of node.data.inputsLabels) {
xml += ` <variable formalParameter="${portType}">\n`
xml += ` <connectionPointIn>\n`
edges.forEach(edge => {
if (edge.target === node.id) {
const targetIndex = parseInt(edge.targetHandle.split('-')[1], 10)
const targetLabel = node.data.inputsLabels[targetIndex]
if (targetLabel === portType) {
const sourceNode = nodes.find(node => node.id === edge.source)
if (['const', 'input'].includes(sourceNode.data.type))
xml += ` <connection refLocalId="${getNewBlockId(edge.source)}"/>\n`
else {
const sourceIndex = parseInt(edge.sourceHandle.split('-')[1], 10)
const sourceLabel = sourceNode.data.outputsLabels[sourceIndex]
xml += ` <connection refLocalId="${getNewBlockId(edge.source)}" formalParameter="${sourceLabel}"/>\n`
}
}
}
})
xml += ` </connectionPointIn>\n`
xml += ` </variable>\n`
}
xml += ` </inputVariables>\n`
xml += ` <inOutVariables/>\n`
xml += ` <outputVariables>\n`
for (const portType of node.data.outputsLabels) {
xml += ` <variable formalParameter="${portType}">\n`
xml += ` <connectionPointOut/>\n`
xml += ` </variable>\n`
}
xml += ` </outputVariables>\n`
xml += ` </block>\n\n`
}
})
xml += ` </FBD>\n`
xml += ` </body>\n`
xml += ` </pou>\n`
xml += ` </pous>\n`
xml += ` </types>\n`
xml += ` <instances>\n`
xml += ` <configurations/>\n`
xml += ` </instances>\n`
xml += `</project>`
const file = new Blob([xml], {type: 'application/xml'})
if ('showSaveFilePicker' in window) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: 'fbd-scheme.xml',
types: [{
description: 'FBD диаграмма в XML',
accept: {'application/xml': ['.xml']}
}]
})
const writable = await handle.createWritable()
await writable.write(file)
await writable.close()
}
catch (err) {
console.log('Сохранение XML отменено или произошла ошибка:', err)
}
}
else {
const link = document.createElement('a')
link.download = 'fbd-program.xml'
link.href = URL.createObjectURL(file)
link.click()
URL.revokeObjectURL(link.href)
}
}, [nodes, edges])
const handleImportXml = useCallback((event) => {
const file = event.target.files[0]
if (!file)
return
const reader = new FileReader()
reader.onload = (e) => {
const xmlText = e.target.result
const parser = new DOMParser()
const xml = parser.parseFromString(xmlText, 'application/xml')
if (xml.querySelector('parsererror')) {
alert('Ошибка импорта (невалидный XML-код)')
return
}
const importedNodes = []
const importedEdges = []
const xmlIdToReactId = new Map()
const interfaceVars = new Map() // name: { dataType, initialValue, varGroup }
const xmlVariables = xml.querySelectorAll('interface * > variable')
xmlVariables.forEach(v => {
const name = v.getAttribute('name')
const varType = v.parentElement.tagName.toLowerCase()
const type = v.querySelector('type *')
const value = v.querySelector('initialValue simpleValue')?.getAttribute('value') || ''
if (name && type)
interfaceVars.set(name, {
dataType: type.tagName.toUpperCase(),
value,
varType
})
})
function restoreType(recivedType) {
const type = recivedType.toUpperCase()
if (type === 'ANY') return 'BOOl/INT/DINT/REAL/TIME/STRING'
if (type === 'ANY_MAGNITUDE') return 'INT/DINT/REAL/TIME'
if (type === 'ANY_NUM') return 'INT/DINT/REAL'
if (type === 'ANY_INT') return 'INT/DINT'
return type
}
const xmlInVariables = xml.querySelectorAll('inVariable')
xmlInVariables.forEach(inVar => {
const xmlId = inVar.getAttribute('localId')
const width = parseFloat(inVar.getAttribute('width') || '80')
const height = parseFloat(inVar.getAttribute('height') || '40')
const pos = inVar.querySelector('position')
const x = parseFloat(pos?.getAttribute('x') || '0')
const y = parseFloat(pos?.getAttribute('y') || '0')
const value = inVar.querySelector('expression')?.textContent || ''
function isItConst(recivedValue) {
const value = recivedValue.toLowerCase().trim()
const timeRegex = /^(t|time)#(-)?\d+(d|h|m|s|ms)(\d+(d|h|m|s|ms))*/
const realRegex = /^-?\d+\.\d+$/
const intRegex = /^-?\d+$/
if (value === 'true' || value === 'false')
return 'BOOL'
if (timeRegex.test(value))
return 'TIME'
if (value.startsWith("'") && value.endsWith("'"))
return 'STRING'
if (realRegex.test(value))
return 'REAL'
if (intRegex.test(value)) {
const num = Number(value)
if (num >= -32768 && num <= 32767)
return 'INT'
if (num >= -2147483648 && num <= 2147483647)
return 'DINT'
}
return null
}
let isConst = isItConst(value) ? true : false
let varInfo = interfaceVars.get(value)
const outputPortType = varInfo?.dataType || isItConst(value) || 'ANY'
if (varInfo?.varType === 'tempvars')
isConst = true
const finalType = isConst ? 'const' : 'input'
let finalValue = value
if (varInfo?.varType === 'tempvars' && varInfo.value)
finalValue = varInfo.value
let existingNode = importedNodes.find(node => node.position.x === x && node.position.y === y && node.data.value === finalValue)
if (!existingNode) {
const newId = `${xmlId}`
const label = finalType === 'const' ? `${finalValue} (${outputPortType})` : `${finalValue} (${outputPortType})`
existingNode = {
id: newId,
type: 'fbdBlock',
position: {x, y},
data: {
type: finalType, label,
value: finalValue,
metaFromType: outputPortType,
width, height,
inputs: [],
outputs: [restoreType(outputPortType)],
inputsLabels: [],
outputsLabels: ['OUT']
}
}
importedNodes.push(existingNode)
}
xmlIdToReactId.set(xmlId, {reactId: existingNode.id, portIndex: 0, handleType: 'out'})
})
const xmlOutVariables = xml.querySelectorAll('outVariable')
xmlOutVariables.forEach(outVar => {
const xmlId = outVar.getAttribute('localId')
const width = parseFloat(outVar.getAttribute('width') || '80')
const height = parseFloat(outVar.getAttribute('height') || '40')
const pos = outVar.querySelector('position')
const x = parseFloat(pos?.getAttribute('x') || '0')
const y = parseFloat(pos?.getAttribute('y') || '0')
const value = outVar.querySelector('expression')?.textContent || ''
let varInfo = interfaceVars.get(value)
const dataType = varInfo?.dataType || 'ANY'
let existingNode = importedNodes.find(node => node.position.x === x && node.position.y === y && node.data.value === value)
if (!existingNode) {
existingNode = {
id: xmlId,
type: 'fbdBlock',
position: {x, y},
data: {
type: 'output',
label: `${value} (${dataType})`,
value,
metaFromType: dataType,
width, height,
inputs: [restoreType(dataType)],
outputs: [],
inputsLabels: ['IN'],
outputsLabels: []
}
}
importedNodes.push(existingNode)
}
xmlIdToReactId.set(xmlId, {reactId: existingNode.id, portIndex: 0, handleType: 'in'})
const connection = outVar.querySelector('connectionPointIn connection')
if (connection) {
const sourceNode = connection.getAttribute('refLocalId')
const sourcePortLabel = connection.getAttribute('formalParameter')
importedEdges.push({
xmlSourceNode: sourceNode,
xmlSourcePortLabel: sourcePortLabel,
reactTargetId: existingNode.id,
reactTargetPort: 'in-0'
})
}
})
const libraryBlocks = {
and: { label: 'AND', inputs: ['BOOL', 'BOOL'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
or: { label: 'OR', inputs: ['BOOL', 'BOOL'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
not: { label: 'NOT', inputs: ['BOOL'], outputs: ['BOOL'], inputsLabels: ['IN'], outputsLabels: ['OUT'] },
xor: { label: 'XOR', inputs: ['BOOL', 'BOOL'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
add: { label: 'ADD', inputs: ['$/INT/DINT/REAL', '$/INT/DINT/REAL'], outputs: ['$/INT/DINT/REAL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
sub: { label: 'SUB', inputs: ['$/INT/DINT/REAL', '$/INT/DINT/REAL'], outputs: ['$/INT/DINT/REAL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
mul: { label: 'MUL', inputs: ['$/INT/DINT/REAL', '$/INT/DINT/REAL'], outputs: ['$/INT/DINT/REAL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
div: { label: 'DIV', inputs: ['$/INT/DINT/REAL', '$/INT/DINT/REAL'], outputs: ['$/INT/DINT/REAL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
ton: { label: 'TON', inputs: ['BOOL', 'TIME'], outputs: ['BOOL', 'TIME'], inputsLabels: ['IN', 'PT'], outputsLabels: ['Q', 'ET'] },
tof: { label: 'TOF', inputs: ['BOOL', 'TIME'], outputs: ['BOOL', 'TIME'], inputsLabels: ['IN', 'PT'], outputsLabels: ['Q', 'ET'] },
tp: { label: 'TP', inputs: ['BOOL', 'TIME'], outputs: ['BOOL', 'TIME'], inputsLabels: ['IN', 'PT'], outputsLabels: ['Q', 'ET'] },
ctu: { label: 'CTU', inputs: ['BOOL', 'BOOL', '$/INT/DINT'], outputs: ['BOOL', '$/INT/DINT'], inputsLabels: ['CU', 'R', 'PV'], outputsLabels: ['Q', 'CV'] },
ctd: { label: 'CTD', inputs: ['BOOL', 'BOOL', '$/INT/DINT'], outputs: ['BOOL', '$/INT/DINT'], inputsLabels: ['CD', 'LD', 'PV'], outputsLabels: ['Q', 'CV'] },
ctud: { label: 'CTUD', inputs: ['BOOL', 'BOOL', 'BOOL', 'BOOL', '$/INT/DINT'], outputs: ['BOOL', 'BOOL', '$/INT/DINT'], inputsLabels: ['CU', 'CD', 'R', 'LD', 'PV'], outputsLabels: ['QU', 'QD', 'CV'] },
gt: { label: '>', inputs: ['$/INT/DINT/REAL/TIME', '$/INT/DINT/REAL/TIME'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
lt: { label: '<', inputs: ['$/INT/DINT/REAL/TIME', '$/INT/DINT/REAL/TIME'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
eq: { label: '=', inputs: ['$/BOOl/INT/DINT/REAL/TIME/STRING', '$/BOOl/INT/DINT/REAL/TIME/STRING'], outputs: ['BOOL'], inputsLabels: ['IN1', 'IN2'], outputsLabels: ['OUT'] },
sr: { label: 'SR', inputs: ['BOOL', 'BOOL'], outputs: ['BOOL'], inputsLabels: ['S1', 'R'], outputsLabels: ['Q1'] },
rs: { label: 'RS', inputs: ['BOOL', 'BOOL'], outputs: ['BOOL'], inputsLabels: ['S', 'R1'], outputsLabels: ['Q'] },
move: { label: 'MOVE', inputs: ['$/BOOl/INT/DINT/REAL/TIME/STRING'], outputs: ['$/BOOl/INT/DINT/REAL/TIME/STRING'], inputsLabels: ['IN'], outputsLabels: ['OUT'] },
switch_type: { label: '*_TO_**', inputs: ['BOOl/INT/DINT/REAL/TIME/STRING'], outputs: ['BOOl/INT/DINT/REAL/TIME/STRING'], inputsLabels: ['IN'], outputsLabels: ['OUT'] }
}
const xmlBlocks = xml.querySelectorAll('FBD > block')
xmlBlocks.forEach(block => {
const xmlId = block.getAttribute('localId')
const xmlType = block.getAttribute('typeName')
const pos = block.querySelector('position')
const x = parseFloat(pos?.getAttribute('x') || '0')
const y = parseFloat(pos?.getAttribute('y') || '0')
const label = block.querySelector('content')?.textContent || libraryBlocks[xmlType.toLowerCase()]?.label || xmlType
const value = block.querySelector('additionalData')?.getAttribute('value') || ''
let inputsLabels = Array.from(block.querySelectorAll('inputVariables variable')).map(v => v.getAttribute('formalParameter'))
let inputs = Array.from(block.querySelectorAll('inputVariables variable')).map(v => restoreType(v.getAttribute('dataType') || 'ANY'))
let outputsLabels = Array.from(block.querySelectorAll('outputVariables variable')).map(v => v.getAttribute('formalParameter'))
let outputs = Array.from(block.querySelectorAll('outputVariables variable')).map(v => restoreType(v.getAttribute('dataType') || 'ANY'))
if (inputsLabels.length === libraryBlocks[xmlType.toLowerCase()]?.inputsLabels.length)
inputsLabels = libraryBlocks[xmlType.toLowerCase()].inputsLabels
if (outputsLabels.length === libraryBlocks[xmlType.toLowerCase()]?.outputsLabels.length)
outputsLabels = libraryBlocks[xmlType.toLowerCase()].outputsLabels
if (inputs.length === libraryBlocks[xmlType.toLowerCase()]?.inputs.length)
inputs = libraryBlocks[xmlType.toLowerCase()].inputs
if (outputs.length === libraryBlocks[xmlType.toLowerCase()]?.outputs.length)
outputs = libraryBlocks[xmlType.toLowerCase()].outputs
const isSwitchType = xmlType.includes('_TO_')
const finalType = isSwitchType ? 'switch_type' : xmlType.toLowerCase()
const width = parseFloat(block.getAttribute('width') || (isSwitchType ? '80' : 50 + (Math.max(inputs.length, outputs.length, 2) - 1) * 34))
const height = parseFloat(block.getAttribute('height') || (isSwitchType ? '40' : '60'))
const newNode = {
id: xmlId,
type: 'fbdBlock',
position: {x, y},
data: { type: finalType, label, value, width, height, inputs, outputs, inputsLabels, outputsLabels}
}
importedNodes.push(newNode)
xmlIdToReactId.set(xmlId, { reactId: xmlId, inputsLabels, outputsLabels })
const variable = block.querySelectorAll('inputVariables variable')
variable.forEach((v, inputIndex) => {
const connection = v.querySelector('connectionPointIn connection')
if (connection) {
const sourceNode = connection.getAttribute('refLocalId')
const sourcePortLabel = connection.getAttribute('formalParameter')
importedEdges.push({
xmlSourceNode: sourceNode,
xmlSourcePortLabel: sourcePortLabel,
reactTargetId: xmlId,
reactTargetPort: `in-${inputIndex}`
})
}
})
})
const finalEdges = importedEdges.map((edgeData, index) => {
const sourceMeta = xmlIdToReactId.get(edgeData.xmlSourceNode)
if (!sourceMeta)
return null
let sourceHandle = 'out-0'
if (sourceMeta.outputsLabels) {
const outIndex = sourceMeta.outputsLabels.indexOf(edgeData.xmlSourcePortLabel)
sourceHandle = `out-${outIndex >= 0 ? outIndex : 0}`
}
const sourceNode = importedNodes.find(node => node.id === sourceMeta.reactId)
const sourceIndex = parseInt(sourceHandle.split('-')[1], 10)
const sourceType = sourceNode?.data?.outputs[sourceIndex] || 'ANY'
return {
id: `e_${Date.now()}_${index}`,
source: sourceMeta.reactId,
target: edgeData.reactTargetId,
sourceHandle: sourceHandle,
targetHandle: edgeData.reactTargetPort,
type: 'smoothstep',
style: EDGE_STYLES[sourceType.toLowerCase().split('/')[0]] || EDGE_STYLES['bool'],
data: {type: sourceType.toLowerCase().split('/')[0]}
}
}).filter(Boolean)
const finalNodes = importedNodes.map(node => {
if (['add', 'sub', 'mul', 'div', 'move'].includes(node.data.type))
return {
...node,
data: {
...node.data,
inputs: node.data.inputs.map(i => '$/' + i),
outputs: node.data.outputs.map(i => '$/' + i)
}
}
else if (['ctu', 'ctd', 'ctud'].includes(node.data.type))
return {
...node,
data: {
...node.data,
inputs: node.data.inputs.map((i, ind) => {
if (node.data.inputsLabels.toLowerCase() === 'pv')
return '$/' + i
else return i
}),
outputs: node.data.outputs.map((i, ind) => {
if (node.data.inputsLabels.toLowerCase() === 'cv')
return '$/' + i
else return i
})
}
}
else if (['gt', 'lt', 'eq'].includes(node.data.type))
return {
...node,
data: {
...node.data,
inputs: node.data.inputs.map(i => '$/' + i)
}
}
else return node
})
try {
const { currentNodes, currentEdges } = updateGraphTypes(finalEdges, finalNodes)
setNodes(currentNodes)
setEdges(currentEdges)
}
catch {
alert('Произошла ошибка при обработке соединений')
setEdges([])
setNodes([finalNodes])
}
}
reader.readAsText(file)
event.target.value = ''
}, [nodes, edges])
return(
<div className="App">
<ControlPanel
onClear = {handleClear}
onExportSvg = {handleExportSvg}
onExportXml = {handleExportXml}
onImportXml = {handleImportXml}
/>
<div className = "main-container">
<Toolbar/>
<div className = "main-canvas-container">
<div className="canvas-container">
<ReactFlow
nodes = {nodes}
edges = {edges}
onNodesChange = {onNodesChange}
onEdgesChange = {onEdgesChange}
onConnect = {onConnect}
nodeTypes = {nodeTypes}
onInit = {setFlowInstance}
onDrop = {onDrop}
onDragOver = {onDragOver}
isValidConnection = {isValidConnection}
//fitView
>
<Controls/>
<Background variant = {BackgroundVariant.Lines} color = "#eee" gap = {10}/>
</ReactFlow>
</div>
</div>
</div>
<ModalData
modalData = {modalData}
onClose = {() => setModalData(null)}
onSave = {handleSaveModalData}
/>
</div>
)
}
export default App