From 16628b46555f6cf30f23aa5bab569a308981c381 Mon Sep 17 00:00:00 2001 From: Henriette Lien Rebnor <122604807+henriettelienrebnor@users.noreply.github.com> Date: Mon, 2 Dec 2024 15:11:10 +0100 Subject: [PATCH] Commissioing package react components (#45) ### [AB#201347](https://dev.azure.com/EquinorASA/bb9bd8cb-74f7-4ffa-b0cb-60eff0a0be58/_workitems/edit/201347) ## Aim of the PR - [x] Make it possible to select equipment, piping components as boundary or internal component - [ ] Make it possible to select piping lines as boundary or internal component - [x] Based on the boundary and the internal component the resulting commissioning package is highlighted - [ ] The piping lines is highlighted as well #### Suggestion I did not reach every goal for this PR. I decided to give up on implementing the highlighting and selection of the piping lines as this was too difficult. In order to achieve highlighting on the piping lines we need to assign the correct rdf IRI on these lines. The logic for which IRI a line should have is connected with what preceding and following siblings the CenterLine element has in the XML. We do not have access to this information anymore since the XML is parsed to react components. I suggest that we address this issue when creating the new RDF format for the front-end. Every component on the new format needs to have the RDF IRI attached to it. With this in place it should be no issue to also add highlighting on the centerlines. ## Implementation ### New components #### CommissioningpackageContext - Added internalIds, boundaryIds as well as state setters to update them. #### handleAddInternal and handleAddBoundary When a component is selected as internal or boundary then these functions are triggered. - They will add the id of the selected component to borderIds or boundaryIds on the commissioning context. - Makes sure that a component cant be selected as internal or boundary at the same time - Updates rdfox #### ClickableComponentProps - Properties for dealing with clickable components, as well as two functions that is used by clickable components for determining highlight color and what actions to take when a component is clicked. #### StyledSvgElement - Creates a svg element with highlighting. ### Logic The functions for creating the PipingComponent and Equipment react components now accepts ClickableComponentProps as argument. The ClickableComponentProps holds information on which function should be triggered on click, and on shift click. The functions handleAddInternal and handleAddBoundary is used as arguments here. The will also be created if the equipment or piping component is internal, boundary or in the commissioning package. This creates the highlighting. When the context for the commissioning package changes a call is made to rdfox to check if there are any nodes in the commissioning package, if this is true then the context is updated with these new ids. When the context is updated then they will be highlighted in yellow. Upon refresh of the page rdfox is wiped, and the commissioning package context is reset. ## Type of change - [ ] Bug fix - [x] New feature - [ ] Breaking change - [ ] This change requires a documentation update ## How Has This Been Tested? Tested the solution manually by selecting equipment and piping components as both boundary and internal. Checked that the highlighting appeared on the correct elements. Also checked that rdfox and the front-end was in sync by querying rdfox in between selecting boundaries and internals. ## Additional Changes - Previously the SvgElement function required a height as argument. The height parameter has been removed from this function. I therefore removed this argument from where this function was called. - Some refactoring in Triplestore.ts -> moved logic for querying and updating the Triplestore to one function. This function has a method param, when the method is GET it queries rdfox, when the method is POST it will delete or insert new data. - Deleted file named CommissioningPackage.ts -> this information was already present in CommissioningPackageContext. --- www/src/components/ActuatingSystem.tsx | 1 - www/src/components/Equipment.tsx | 49 +++---- www/src/components/Pandid.tsx | 122 ++++++++++++++++-- www/src/components/StyledSvgElement.tsx | 54 ++++++++ www/src/components/piping/PipeSegment.tsx | 18 +-- www/src/components/piping/PipeSystem.tsx | 10 -- www/src/components/piping/PipingComponent.tsx | 63 ++++++--- .../context/CommissioningPackageContext.tsx | 27 ++-- www/src/types/ClickableComponentProps.ts | 57 ++++++++ www/src/types/CommissioningPackage.ts | 5 - www/src/utils/HelperFunctions.ts | 3 + www/src/utils/Triplestore.ts | 77 +++++++---- 12 files changed, 351 insertions(+), 135 deletions(-) create mode 100644 www/src/components/StyledSvgElement.tsx create mode 100644 www/src/types/ClickableComponentProps.ts delete mode 100644 www/src/types/CommissioningPackage.ts create mode 100644 www/src/utils/HelperFunctions.ts diff --git a/www/src/components/ActuatingSystem.tsx b/www/src/components/ActuatingSystem.tsx index 0fc5faf..fefcdd6 100644 --- a/www/src/components/ActuatingSystem.tsx +++ b/www/src/components/ActuatingSystem.tsx @@ -13,7 +13,6 @@ export default function ActuatingSystem(props: ActuatingSystemProps) { {actuatingSystemComponents.map((component, index: number) => ( Promise; - isBoundary: boolean; + clickableComponent: ClickableComponentProps } export default function Equipment({ equipment, - onClick, - isBoundary, -}: EquipmentComponentProps) { + clickableComponent +}: EquipmentClickableProps) { + const packageContext = useCommissioningPackageContext() const height = useContext(PandidContext).height; const svg = useSerializeNodeSvg( equipment.ComponentName, equipment.GenericAttributes[0], ); + const color = getHighlightColor(equipment.ID, packageContext); const nozzles: NozzleProps[] = equipment.Nozzle; return ( - onClick(equipment.ID, BoundaryActions.Insert, BoundaryParts.Boundary) - } + onClick={handleClick(clickableComponent, packageContext, equipment.ID)} > - {svg && ( + {svg && color && ( <> - {isBoundary && ( - - )} + diff --git a/www/src/components/Pandid.tsx b/www/src/components/Pandid.tsx index 441ee4a..06c4979 100644 --- a/www/src/components/Pandid.tsx +++ b/www/src/components/Pandid.tsx @@ -3,18 +3,25 @@ import Equipment from "./Equipment.tsx"; import { useCallback, useEffect, useState } from "react"; import ProcessInstrumentationFunction from "./ProcessInstrumentationFunction.tsx"; import { EquipmentProps, XMLProps } from "../types/diagram/Diagram.ts"; -import { PipingNetworkSystemProps } from "../types/diagram/Piping.ts"; +import { PipingComponentProps, PipingNetworkSegmentProps, PipingNetworkSystemProps } from "../types/diagram/Piping.ts"; import { ProcessInstrumentationFunctionProps } from "../types/diagram/ProcessInstrumentationFunction.ts"; +import { ensureArray } from "../utils/HelperFunctions.ts"; import { ActuatingSystemProps } from "../types/diagram/ActuatingSystem.ts"; import ActuatingSystem from "./ActuatingSystem.tsx"; import PandidContext from "../context/PandidContext.ts"; import PipeSystem from "./piping/PipeSystem.tsx"; +import PipeSegment from "./piping/PipeSegment.tsx"; +import React from "react"; import { BoundaryActions, BoundaryParts, makeSparqlAndUpdateStore, + getNodeIdsInCommissioningPackage, + cleanTripleStore, } from "../utils/Triplestore.ts"; import { useCommissioningPackageContext } from "../hooks/useCommissioningPackageContext.tsx"; +import PipingComponent from "./piping/PipingComponent.tsx"; +import { CommissioningPackageProps } from "../context/CommissioningPackageContext.tsx"; export default function Pandid() { const [xmlData, setXmlData] = useState(null); @@ -33,6 +40,7 @@ export default function Pandid() { attributeNamePrefix: "", }); + // Read XML file from disk, parse as XMLProps (TypeScript interface) useEffect(() => { fetch("/DISC_EXAMPLE-02-02.xml") @@ -43,6 +51,34 @@ export default function Pandid() { }); }, []); + //Clean triplestore on render + useEffect(() => { + (async () => { + await cleanTripleStore(); + context.setCommissioningPackages([]); + context.setboundaryIds([]); + context.setInternalIds([]); + })() + }, []) + + useEffect(() => { + (async () => { + const nodeIds = await getNodeIdsInCommissioningPackage(); + //TODO: This logic needs to be improved when introducing multiple commissioning packages. + // Default package name "asset:Package1" used. + if (context.commissioningPackages.length < 1) { + const newPackage: CommissioningPackageProps = { + id: "asset:Package1", + idsInPackage: nodeIds + } + context.setCommissioningPackages([newPackage]); + context.setActivePackageId(newPackage.id); + } else { + context.setCommissioningPackages(getUpdatedCommissioningPackages(nodeIds)) + } + })(); + }, [context]); + // When XML data is loaded, set all component states useEffect(() => { if (!xmlData) return; @@ -54,17 +90,56 @@ export default function Pandid() { setActuatingSystem(xmlData.PlantModel.ActuatingSystem); }, [xmlData]); + const handleAddInternal = useCallback( + async (id: string, action: BoundaryActions) => { + context.setInternalIds((prev) => + prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]); + await makeSparqlAndUpdateStore(id, action, BoundaryParts.InsideBoundary); + + if (context.boundaryIds.includes(id)) { + context.setboundaryIds(prev => prev.filter((item) => item !== id)); + await makeSparqlAndUpdateStore(id, BoundaryActions.Delete, BoundaryParts.Boundary); + } + }, + [context], + ); + const handleAddBoundary = useCallback( - async (id: string, action: BoundaryActions, type: BoundaryParts) => { - context.setBorderIds((prev) => + async (id: string, action: BoundaryActions) => { + context.setboundaryIds((prev) => prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id], ); - await makeSparqlAndUpdateStore(id, action, type); - }, - [], + if(context.internalIds.includes(id)) { + context.setInternalIds(prev => prev.filter((item) => item !== id)); + await makeSparqlAndUpdateStore(id, BoundaryActions.Delete, BoundaryParts.InsideBoundary); + } + + await makeSparqlAndUpdateStore(id, action, BoundaryParts.Boundary); + }, + [context], ); + useEffect(() => { + console.log(`internals: ${context.internalIds}`); + console.log(`boundaries: ${context.boundaryIds}`); + }, [context.boundaryIds, context.internalIds]) + + + const getUpdatedCommissioningPackages = (ids: string[]) => { + return context.commissioningPackages.map(pkg => { + if (pkg.id === context.activePackageId) { + const updatedPackage: CommissioningPackageProps = { + id: "asset:Package1", + idsInPackage: ids + }; + return updatedPackage; + } else { + return pkg; + }; + }) + }; + return ( <> {xmlData && ( @@ -80,17 +155,38 @@ export default function Pandid() { equipments.map((equipment: EquipmentProps, index: number) => ( ))} {pipingNetworkSystems && - pipingNetworkSystems.map( - (piping: PipingNetworkSystemProps, index: number) => ( - - ), - )} + pipingNetworkSystems.map((pipingNetworkSystem: PipingNetworkSystemProps, index: number) => ( + + + + {ensureArray(pipingNetworkSystem.PipingNetworkSegment).map((pipingNetworkSegment: PipingNetworkSegmentProps, segmentIndex: number) => ( + + + + {pipingNetworkSegment.PipingComponent && + ensureArray(pipingNetworkSegment.PipingComponent).map((pipingComponent: PipingComponentProps, componentIndex: number) => ( + + ))} + + ))} + + )) + } {processInstrumentationFunction && processInstrumentationFunction.map( ( diff --git a/www/src/components/StyledSvgElement.tsx b/www/src/components/StyledSvgElement.tsx new file mode 100644 index 0000000..e93a0dd --- /dev/null +++ b/www/src/components/StyledSvgElement.tsx @@ -0,0 +1,54 @@ +import { + PositionProps, +} from "../types/diagram/Common.ts"; +import calculateAngleAndRotation from "../utils/Transformation.ts"; +import { useContext } from "react"; +import PandidContext from "../context/PandidContext.ts"; +import styled from "styled-components" + +interface StyledSvgElementProps { + id: string; + position?: PositionProps; + svg: string; + color: string; +} + +const StyledG = styled.g` +path { + stroke: ${(props) => props.color}; + stroke-width: 5; + opacity: 0.5 ; +} +` + +export default function StyledSvgElement({ + id, + position, + svg, + color +}: StyledSvgElementProps) { + const height = useContext(PandidContext).height; + + return ( + <> + {svg && ( + + )} + + ); +} diff --git a/www/src/components/piping/PipeSegment.tsx b/www/src/components/piping/PipeSegment.tsx index d660320..48e069a 100644 --- a/www/src/components/piping/PipeSegment.tsx +++ b/www/src/components/piping/PipeSegment.tsx @@ -1,10 +1,8 @@ import CenterLine from "../CenterLine.tsx"; import { - PipingComponentProps, PipingNetworkSegmentProps, } from "../../types/diagram/Piping.ts"; import { CenterLineProps } from "../../types/diagram/Common.ts"; -import PipingComponent from "./PipingComponent.tsx"; import { useContext } from "react"; import PandidContext from "../../context/PandidContext.ts"; import SvgElement from "../SvgElement.tsx"; @@ -16,25 +14,13 @@ export default function PipeSegment(props: PipingNetworkSegmentProps) { const centerlines: CenterLineProps[] = Array.isArray(props.CenterLine) ? props.CenterLine : [props.CenterLine]; - const pipingComponents: PipingComponentProps[] = Array.isArray( - props.PipingComponent, - ) - ? props.PipingComponent - : [props.PipingComponent]; + return ( <> - {pipingComponents && - pipingComponents[0] !== undefined && - pipingComponents.map( - (pipingComponent: PipingComponentProps, index: number) => ( - - ), - )} {props.PipeSlopeSymbol && ( @@ -59,7 +44,6 @@ export default function PipeSegment(props: PipingNetworkSegmentProps) { )} {props.PipeOffPageConnector && ( - {pipingSegments.map((pipe: PipingNetworkSegmentProps, index: number) => ( - - ))} {props.Label && ( - {componentName && ( - - )} - {label && ( + + {componentName && svg && ( <> + {color && + + } + + + )} + {label && ( + <> {label.PolyLine && ( )} - + ); } diff --git a/www/src/context/CommissioningPackageContext.tsx b/www/src/context/CommissioningPackageContext.tsx index cb5fa12..817332f 100644 --- a/www/src/context/CommissioningPackageContext.tsx +++ b/www/src/context/CommissioningPackageContext.tsx @@ -1,19 +1,21 @@ import React, { useState, createContext } from "react"; export interface CommissioningPackageProps { - id: number; - name: string; // Example fields, adjust to your data model + id: string; + idsInPackage: string[]; // Example fields, adjust to your data model } -interface CommissioningPackageContextProps { - activePackageId: number; - setActivePackageId: React.Dispatch>; +export interface CommissioningPackageContextProps { + activePackageId: string; + setActivePackageId: React.Dispatch>; commissioningPackages: CommissioningPackageProps[]; setCommissioningPackages: React.Dispatch< React.SetStateAction >; - borderIds: string[]; - setBorderIds: React.Dispatch>; + boundaryIds: string[]; + setboundaryIds: React.Dispatch>; + internalIds: string[]; + setInternalIds: React.Dispatch>; } const CommissioningPackageContext = createContext< @@ -26,16 +28,19 @@ export const CommissioningPackageProvider: React.FC<{ const [commissioningPackages, setCommissioningPackages] = useState< CommissioningPackageProps[] >([]); - const [borderIds, setBorderIds] = useState([]); - const [activePackageId, setActivePackageId] = useState(0); + const [boundaryIds, setboundaryIds] = useState([]); + const [activePackageId, setActivePackageId] = useState(""); + const [internalIds, setInternalIds] = useState([]); const contextValue: CommissioningPackageContextProps = { activePackageId, setActivePackageId, commissioningPackages, setCommissioningPackages, - borderIds, - setBorderIds, + boundaryIds, + setboundaryIds, + internalIds, + setInternalIds }; return ( diff --git a/www/src/types/ClickableComponentProps.ts b/www/src/types/ClickableComponentProps.ts new file mode 100644 index 0000000..ff60861 --- /dev/null +++ b/www/src/types/ClickableComponentProps.ts @@ -0,0 +1,57 @@ +import { BoundaryActions, assetIri } from "../utils/Triplestore.ts"; +import { CommissioningPackageContextProps } from "../context/CommissioningPackageContext.tsx"; + +export interface ClickableComponentProps { + onClick: ( + id: string, + action: BoundaryActions, + ) => Promise; + onShiftClick: ( + id: string, + action: BoundaryActions + ) => Promise; +} + +const isBoundary = (id: string, context: CommissioningPackageContextProps) => context.boundaryIds.includes(id); +const isInternal = (id: string, context: CommissioningPackageContextProps) => context.internalIds.includes(id); +const isInPackage = (id: string, context: CommissioningPackageContextProps) => { + const activePackage = context.commissioningPackages.find(pkg => pkg.id === context.activePackageId); + return activePackage?.idsInPackage.includes(assetIri(id)) || false; +} + +export const getHighlightColor = (id: string, context: CommissioningPackageContextProps) => { + let color = "" + if (isInternal(id, context)) { + color = "green" + } + else if (isBoundary(id, context)) { + color = "red" + } + else if (isInPackage(id, context)) { + color = "yellow" + } + return color; +} + +export const handleClick = (component: ClickableComponentProps, context: CommissioningPackageContextProps, id: string) => + (event: React.MouseEvent) => { + event.preventDefault() + if (event.ctrlKey) { + event.preventDefault(); + if (isInternal(id, context)) { + component.onShiftClick(id, BoundaryActions.Delete); + } + else { + component.onShiftClick(id, BoundaryActions.Insert); + } + } + else { + if (isBoundary(id, context)) { + component.onClick(id, BoundaryActions.Delete); + } + else { + component.onClick(id, BoundaryActions.Insert); + } + } + } + diff --git a/www/src/types/CommissioningPackage.ts b/www/src/types/CommissioningPackage.ts deleted file mode 100644 index 12de025..0000000 --- a/www/src/types/CommissioningPackage.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default interface CommissioningPackageProps { - id: number; - name: string; - ids: string[]; -} diff --git a/www/src/utils/HelperFunctions.ts b/www/src/utils/HelperFunctions.ts new file mode 100644 index 0000000..ce18ae8 --- /dev/null +++ b/www/src/utils/HelperFunctions.ts @@ -0,0 +1,3 @@ +export function ensureArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value]; + } \ No newline at end of file diff --git a/www/src/utils/Triplestore.ts b/www/src/utils/Triplestore.ts index efc6750..f2b7a4f 100644 --- a/www/src/utils/Triplestore.ts +++ b/www/src/utils/Triplestore.ts @@ -10,43 +10,60 @@ export enum BoundaryParts { Boundary = 'comp:isBoundaryOf' } +export enum Method { + Post = 'POST', + Get = 'GET' +} + export async function makeSparqlAndUpdateStore(nodeId: string, action: string, type: string) { - const sparql = `${action} { <${nodeId}> ${type} ${completionPackageIri} . }`; - try { - await fetch('http://localhost:12110/datastores/boundaries/sparql', { - method: 'POST', - headers: {'Content-Type': 'application/x-www-form-urlencoded'}, - body: `update=${sparql}` - }); - } catch (error) { - console.error('Error:', error); - } + const sparql = `${action} { <${assetIri(nodeId)}> ${type} ${completionPackageIri} . }`; + await queryTripleStore(sparql, Method.Post); } -export async function queryTripleStore(sparql: string) { - try { - const encoded = encodeURIComponent(sparql); - const response = await fetch(`http://localhost:12110/datastores/boundaries/sparql?query=${encoded}`, { - method: 'GET', - }); - return await response.text(); - } catch (error) { - console.error('Error:', error); - } +export async function cleanTripleStore() { + const deleteBoundary = `DELETE WHERE { ?boundary comp:isBoundaryOf ?p . }`; + const deleteInternal = `DELETE WHERE { ?internal comp:isInPackage ?p . }`; + await queryTripleStore(deleteBoundary, Method.Post); + await queryTripleStore(deleteInternal, Method.Post); +} + +export async function getNodeIdsInCommissioningPackage() { + const query = 'SELECT ?node WHERE{?node comp:isInPackage ' + completionPackageIri + ' .}'; + const result = await queryTripleStore(query, Method.Get); + return parseNodeIds(result!); } +export async function queryTripleStore(sparql: string, method: Method.Get | Method.Post) { + if (method === Method.Get) { + try { + const encoded = encodeURIComponent(sparql); + const response = await fetch(`http://localhost:12110/datastores/boundaries/sparql?query=${encoded}`, { + method: 'GET', + }); + return await response.text(); + } catch (error) { + console.error('Error:', error); + } + } else if (method === Method.Post) { + try { + await fetch('http://localhost:12110/datastores/boundaries/sparql', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `update=${sparql}` + }); + } catch (error) { + console.error('Error:', error); + } + } +}; + export async function adjacentToInternal(pipeIri: string) { const query = `SELECT ?node WHERE { <${pipeIri}> imf:adjacentTo ?node . ?node comp:isInPackage ?p .}`; - const result = await queryTripleStore(query); + const result = await queryTripleStore(query, Method.Get); const internalNeighbours = parseNodeIds(result!); return internalNeighbours.length > 0 } -export async function getNodeIdsInCommissioningPackage() { - const query = 'SELECT ?node WHERE{?node comp:isInPackage ' + completionPackageIri + ' .}'; - const result = await queryTripleStore(query); - return parseNodeIds(result!); -} export async function updateTable() { const queryInside = ` @@ -74,8 +91,8 @@ export async function updateTable() { } } `; - let resultInside = parseNodeIds(await queryTripleStore(queryInside) as string); - const resultBoundary = parseNodeIds(await queryTripleStore(queryBoundary) as string); + let resultInside = parseNodeIds(await queryTripleStore(queryInside, Method.Get) as string); + const resultBoundary = parseNodeIds(await queryTripleStore(queryBoundary, Method.Get) as string); if (resultInside.length > 0 || resultBoundary.length > 0) { // Remove elements that are in both inside boundary and boundary @@ -89,6 +106,10 @@ export async function updateTable() { } } +export const assetIri = (id: string) => { + return `https://assetid.equinor.com/plantx#${id}`; +} + function parseNodeIds(result: string) { const lines = result.split('\n').filter(line => line.trim() !== ''); return lines.slice(1).map(line => line.replace(/[<>]/g, ''));