diff --git a/src/components/GraphViewer/GraphViewer.js b/src/components/GraphViewer/GraphViewer.js index 06a045bb..d54400c3 100644 --- a/src/components/GraphViewer/GraphViewer.js +++ b/src/components/GraphViewer/GraphViewer.js @@ -327,7 +327,16 @@ const GraphViewer = (props) => { linkCanvasObjectMode={'replace'} onLinkHover={handleLinkHover} // Override drawing of canvas objects, draw an image as a node - nodeCanvasObject={(node, ctx) => paintNode(node, ctx, hoverNode, selectedNode, nodeSelected, previouslySelectedNodes)} + nodeCanvasObject={(node, ctx) => + paintNode( + node, + ctx, + hoverNode, + selectedNode, + nodeSelected, + previouslySelectedNodes, + selectedLayout.layout === LEFT_RIGHT.layout + )} nodeCanvasObjectMode={node => 'replace'} nodeVal = { node => { if ( selectedLayout.layout === TOP_DOWN.layout ){ diff --git a/src/components/NodeDetailView/Details/SubjectDetails.js b/src/components/NodeDetailView/Details/SubjectDetails.js index 652ce87c..1b78d4ae 100644 --- a/src/components/NodeDetailView/Details/SubjectDetails.js +++ b/src/components/NodeDetailView/Details/SubjectDetails.js @@ -41,7 +41,7 @@ const SubjectDetails = (props) => { if ( property.isGroup ){ return ( {property.label} - + ) } diff --git a/src/components/NodeDetailView/Details/Views/SimpleChip.js b/src/components/NodeDetailView/Details/Views/SimpleChip.js index 2db276fc..31e879da 100644 --- a/src/components/NodeDetailView/Details/Views/SimpleChip.js +++ b/src/components/NodeDetailView/Details/Views/SimpleChip.js @@ -1,14 +1,78 @@ -import React from "react"; -import { - Box, - Chip -} from "@material-ui/core"; +import React, { useState } from "react"; +import { Box, Chip, Menu, MenuItem } from "@material-ui/core"; + +const SimpleChip = ({ chips }) => { + const [anchorEl, setAnchorEl] = useState(null); + const [selectedItem, setSelectedItem] = useState(null); + + const isUrl = (value) => { + if (!value) return false; + try { + new URL(value); + return true; + } catch { + return false; + } + }; + + const normalize = (item) => + typeof item === "string" ? { value: item } : item; + + const handleClick = (item) => { + const url = item?.link || (isUrl(item?.value) ? item?.value : null); + if (url) { + window.open(url, "_blank"); + } + }; + + const handleContextMenu = (event, item) => { + event.preventDefault(); + setAnchorEl(event.currentTarget); + setSelectedItem(item); + }; + + const handleMenuClose = () => { + setAnchorEl(null); + setSelectedItem(null); + }; + + const handleCopyId = () => { + const copyValue = selectedItem?.id || selectedItem?.link || selectedItem?.value; + if (copyValue) { + navigator.clipboard.writeText(copyValue); + } + handleMenuClose(); + }; + + const handleOpenNewTab = () => { + const url = selectedItem?.link || selectedItem?.id || selectedItem?.value; + if (isUrl(url)) { + window.open(url, "_blank"); + } + handleMenuClose(); + }; -const SimpleChip = ({chips}) => { return ( - { chips?.map((item, index) => ) } + {chips?.map((rawItem, index) => { + const item = normalize(rawItem); + return ( + handleClick(item)} + onContextMenu={(e) => handleContextMenu(e, item)} + /> + ); + })} + + Copy ID + {(selectedItem?.link || isUrl(selectedItem?.id) || isUrl(selectedItem?.value)) && ( + Open in new tab + )} + - ) -} + ); +}; + export default SimpleChip; diff --git a/src/components/NodeDetailView/Details/Views/SimpleLinkedChip.js b/src/components/NodeDetailView/Details/Views/SimpleLinkedChip.js index fd6b7a8c..ce6e6aae 100644 --- a/src/components/NodeDetailView/Details/Views/SimpleLinkedChip.js +++ b/src/components/NodeDetailView/Details/Views/SimpleLinkedChip.js @@ -1,45 +1,108 @@ -import React from "react"; +import React, { useState } from "react"; import { Box, - Chip + Chip, + Menu, + MenuItem } from "@material-ui/core"; import { useDispatch } from 'react-redux'; import { selectGroup } from '../../../../redux/actions'; import { GRAPH_SOURCE } from '../../../../constants'; -const SimpleChip = ({chips, node}) => { +const SimpleChip = ({ chips, node }) => { const dispatch = useDispatch(); + const [anchorEl, setAnchorEl] = useState(null); + const [selectedItem, setSelectedItem] = useState(null); + + const isUrl = (value) => { + if (!value) return false; + try { + new URL(value); + return true; + } catch { + return false; + } + }; + const handleClick = (item, node) => { - if ( item.link ){ - window.open(item.link, '_blank') - } else if ( item.value ) { - let urlCheck = new RegExp("([a-zA-Z0-9]+://)?([a-zA-Z0-9_]+:[a-zA-Z0-9_]+@)?([a-zA-Z0-9.-]+\\.[A-Za-z]{2,4})(:[0-9]+)?(/.*)?"); - if(urlCheck.test(item.value)) { - window.open(item.value, '_blank') - } else { - if ( node ) { - dispatch(selectGroup({ + if (item.link) { + window.open(item.link, '_blank'); + } else if (item.value) { + if (isUrl(item.value)) { + window.open(item.value, '_blank'); + } else if (node) { + dispatch( + selectGroup({ dataset_id: node.dataset_id, graph_node: node?.id, tree_node: node?.tree_reference?.id, - source: GRAPH_SOURCE - })); - } + source: GRAPH_SOURCE, + }) + ); } } - } + }; + + const handleContextMenu = (event, item) => { + event.preventDefault(); + setAnchorEl(event.currentTarget); + setSelectedItem(item); + }; + + const handleMenuClose = () => { + setAnchorEl(null); + setSelectedItem(null); + }; + + const handleCopyId = () => { + const copyValue = selectedItem?.id || selectedItem?.link || selectedItem?.value; + if (copyValue) { + navigator.clipboard.writeText(copyValue); + } + handleMenuClose(); + }; + + const handleOpenNewTab = () => { + const url = selectedItem?.link || selectedItem?.id || selectedItem?.value; + if (url) { + window.open(url, '_blank'); + } + handleMenuClose(); + }; return ( - { chips?.map((item, index) => ( node === undefined - ? item.link ? - ( handleClick(item, null)}/>) - : () - : ( handleClick(item, node)}/>))) - } + {chips?.map((item, index) => + node === undefined ? ( + item.link ? ( + handleClick(item, null)} + onContextMenu={(e) => handleContextMenu(e, item)} + /> + ) : ( + handleContextMenu(e, item)} + /> + ) + ) : ( + handleClick(item, node)} + onContextMenu={(e) => handleContextMenu(e, item)} + /> + ) + )} + + {(selectedItem?.link || isUrl(selectedItem?.id) || isUrl(selectedItem?.value)) && ( + Open in new tab + )} + - ) -} + ); +}; + export default SimpleChip; diff --git a/src/components/Sidebar/Sidebar.js b/src/components/Sidebar/Sidebar.js index 9d9fcfb4..4f972d57 100644 --- a/src/components/Sidebar/Sidebar.js +++ b/src/components/Sidebar/Sidebar.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { Box } from '@material-ui/core'; import SidebarHeader from './Header'; import SidebarContent from './List'; @@ -7,9 +7,39 @@ import SidebarFooter from './Footer'; const Sidebar = (props) => { const [expand, setExpand] = useState(true); const [searchTerm, setSearchTerm] = useState(''); + const [width, setWidth] = useState(300); + const [isResizing, setIsResizing] = useState(false); + const sidebarRef = useRef(null); + + const startResizing = (e) => { + setIsResizing(true); + e.preventDefault(); + }; + + useEffect(() => { + const handleMouseMove = (e) => { + if (!isResizing || !expand) return; + const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left || 0; + const newWidth = e.clientX - sidebarLeft; + if (newWidth > 200) { + setWidth(newWidth); + } + }; + const stopResizing = () => setIsResizing(false); + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', stopResizing); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', stopResizing); + }; + }, [isResizing, expand]); return ( - + { expand={expand} setOpenDatasetsListDialog={props.setOpenDatasetsListDialog} /> + {expand && } ); }; diff --git a/src/theme.js b/src/theme.js index 03a26d75..51a632b7 100644 --- a/src/theme.js +++ b/src/theme.js @@ -490,6 +490,7 @@ const theme = createTheme({ display: 'flex', flexDirection: 'column', transition: primaryTransition, + position: 'relative', '&.shrink': { width: '4.125rem', transition: primaryTransition, @@ -664,14 +665,14 @@ const theme = createTheme({ marginRight: '0.625rem', flexShrink: 0, }, - '& .labelText': { + '& .labelText': { fontWeight: 'normal', flexGrow: 1, fontSize: '0.8125rem', lineHeight: '1rem', color: whiteColor, - }, - '& .MuiTreeItem-group': { + }, + '& .MuiTreeItem-group': { paddingLeft: '1.4375rem', margin: 0, }, @@ -830,7 +831,7 @@ const theme = createTheme({ }, }, - '& .no-instance': { + '& .no-instance': { fontSize: '0.75rem', display: 'flex', alignItems: 'center', @@ -842,6 +843,14 @@ const theme = createTheme({ textAlign: 'center', }, }, + '&-resizer': { + width: '0.3125rem', + cursor: 'col-resize', + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + }, '&-footer': { boxShadow: `0 -4.75rem 3.0625rem -2.5625rem ${secondaryColor}`, borderTop: `0.0625rem solid ${lightBorderColor}`, diff --git a/src/utils/GraphViewerHelper.js b/src/utils/GraphViewerHelper.js index 15b94569..a309ec8e 100644 --- a/src/utils/GraphViewerHelper.js +++ b/src/utils/GraphViewerHelper.js @@ -68,7 +68,15 @@ const roundRect = (ctx, x, y, width, height, radius, color, alpha) => { ctx.fill(); }; -export const paintNode = (node, ctx, hoverNode, selectedNode, nodeSelected, previouslySelectedNodes) => { +export const paintNode = ( + node, + ctx, + hoverNode, + selectedNode, + nodeSelected, + previouslySelectedNodes, + showFullName = false +) => { const size = 7.5; const nodeImageSize = [size * 2.4, size * 2.4]; const hoverRectDimensions = [size * 4.2, size * 4.2]; @@ -105,10 +113,11 @@ export const paintNode = (node, ctx, hoverNode, selectedNode, nodeSelected, prev ctx.textAlign = 'center'; ctx.textBaseline = 'top'; let nodeName = node.name; - if (nodeName.length > 10) { + if (Array.isArray(nodeName)) { + nodeName = nodeName[0]; + } + if (!showFullName && nodeName?.length > 10) { nodeName = nodeName.substr(0, 9).concat('...'); - } else if ( Array.isArray(nodeName) ){ - nodeName = nodeName[0]?.substr(0, 9).concat('...'); } const textProps = [nodeName, node.x, textHoverPosition[1]]; if (node === hoverNode || node?.id === selectedNode?.id || node?.id === nodeSelected?.id ) { diff --git a/src/utils/Splinter.js b/src/utils/Splinter.js index 5b29c093..8a6f9ec2 100644 --- a/src/utils/Splinter.js +++ b/src/utils/Splinter.js @@ -455,14 +455,15 @@ class Splinter { } replaceNode(a) { - let newNode = {"value": a}; - if( a?.includes(rdfTypes.NCBITaxon.key) || a?.includes(rdfTypes.PATO.key) || a?.includes(rdfTypes.UBERON.key) || a?.includes(rdfTypes.RRID.key) ) { - let node = this.nodes.get(a); + let newNode = { "value": a }; + if (typeof a === 'string') { + const node = this.nodes.get(a); if (node) { - newNode = {"value": node?.attributes.label[0], "link": node?.id}; + newNode = { "value": node?.attributes.label[0], "link": node?.id }; + } else if (a.startsWith('http')) { + newNode = { "value": a, "link": a }; } } - return newNode; } @@ -501,22 +502,42 @@ class Splinter { let updatedAbout = []; dataset_node.level = 1; let that = this; - dataset_node?.attributes?.isAbout?.forEach( (a) => { - updatedAbout.push(that.replaceNode(a)); + dataset_node?.attributes?.isAbout?.forEach((a) => { + if ( + typeof a === 'string' && ( + a.startsWith('http') || + a?.includes(rdfTypes.NCBITaxon.key) || + a?.includes(rdfTypes.PATO.key) || + a?.includes(rdfTypes.UBERON.key) || + a?.includes(rdfTypes.RRID.key) + ) + ) { + updatedAbout.push(that.replaceNode(a)); + } else { + updatedAbout.push({ value: a }); + } }); dataset_node.attributes.isAbout = updatedAbout; let updateTechniques = []; - dataset_node.attributes.protocolEmploysTechnique?.forEach( (a) => { - if( a.includes(rdfTypes.NCBITaxon.key) || a.includes(rdfTypes.PATO.key) || a.includes(rdfTypes.UBERON.key) ) { + dataset_node.attributes.protocolEmploysTechnique?.forEach((a) => { + if ( + typeof a === 'string' && ( + a.startsWith('http') || + a?.includes(rdfTypes.NCBITaxon.key) || + a?.includes(rdfTypes.PATO.key) || + a?.includes(rdfTypes.UBERON.key) || + a?.includes(rdfTypes.RRID.key) + ) + ) { let node = this.nodes.get(a); if (node) { - updateTechniques.push({"value": node?.attributes.label[0], "link": node?.id}); + updateTechniques.push({ value: node?.attributes.label[0], link: node?.id }); } else { - updateTechniques.push({"value": a}); + updateTechniques.push({ value: a, link: a }); } } else { - updateTechniques.push({"value": a}); + updateTechniques.push({ value: a }); } }); dataset_node.attributes.protocolEmploysTechnique = updateTechniques; @@ -539,34 +560,37 @@ class Splinter { organise_subjects(target_node, link, groups, k, type){ let parent = this.nodes.get(k); let keys = Object.keys(config.groups.order); - keys.forEach( key => { + keys.forEach(key => { let group = config.groups.order[key]; - if ( target_node.attributes[key]?.[0] ) { - let source = this.nodes.get(target_node.attributes[key]?.[0]); - if ( source !== undefined ) { - target_node.attributes[key][0] = source.attributes.label[0]; + if (target_node.attributes[key]?.[0]) { + // attributes may already be objects from previous passes + const raw = target_node.attributes[key][0]; + const originalValue = typeof raw === "object" ? raw.value : raw; + + let source = this.nodes.get(originalValue); + let label = originalValue; + if (source !== undefined) { + label = source.attributes.label[0]; } - - const groupID = parent.id + "_" + target_node.attributes[key]?.[0].replace(/\s/g, ""); - if ( this.nodes.get(groupID) === undefined ) { - let name = target_node.attributes[key]?.[0]; + const groupID = parent.id + "_" + ("" + label).replace(/\s/g, ""); + if (this.nodes.get(groupID) === undefined) { const groupNode = { id: groupID, - name: name, + name: label, type: typesModel.NamedIndividual.group.type, properties: key, - parent : parent, + parent: parent, proxies: [], level: parent.level + 1, tree_reference: null, children_counter: 0, - collapsed : false, - childLinks : [], - samples : 0, - subjects : 0, - publishedURI : "", - dataset_id : this.dataset_id + collapsed: false, + childLinks: [], + samples: 0, + subjects: 0, + publishedURI: "", + dataset_id: this.dataset_id }; let nodeF = this.factory.createNode(groupNode); const img = new Image(); @@ -577,11 +601,14 @@ class Splinter { source: parent.id, target: nodeF.id }); - this.groups[key] ? this.groups[key][nodeF.name] = nodeF : this.groups[key] = {[nodeF.name] : nodeF}; + this.groups[key] ? this.groups[key][nodeF.name] = nodeF : this.groups[key] = {[nodeF.name]: nodeF}; parent = groupNode; } else { parent = this.nodes.get(groupID); } + + // preserve original identifier for chips + target_node.attributes[key] = this.replaceNode(target_node.attributes[key][0]); } else { console.error("The group node already exists!", group.tag); } @@ -955,6 +982,7 @@ class Splinter { } } }); + }