diff --git a/public/styles.css b/public/styles.css index 89a3ee1..31ccf7d 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1,5 +1,5 @@ -.link { - +.link, .linkLegend { + stroke-opacity: 0.6; stroke-width: 3; } @@ -18,4 +18,4 @@ text { select, option { width: 250px; -} +} \ No newline at end of file diff --git a/src/NetworkGraph.js b/src/NetworkGraph.js index b46e9b7..ef1d8d8 100644 --- a/src/NetworkGraph.js +++ b/src/NetworkGraph.js @@ -1,22 +1,22 @@ import React, { useState, useEffect, useRef } from 'react'; import * as d3 from 'd3'; -import { Button, Typography } from '@mui/material'; +import { Alert, Button, FormControlLabel, FormGroup, Switch, Typography } from '@mui/material'; import { MenuItem, Select, FormControl, InputLabel } from '@mui/material'; -import { Add } from '@mui/icons-material'; +import { Add, ErrorOutline, Visibility } from '@mui/icons-material'; import NodePopover from './NodePopover'; import LinkPopover from './LinkPopover'; import Navbar from './Navbar'; import { useSlider } from './SliderContext'; import exportSvg from './ExportSvg'; // Import the function -const width = window.innerWidth * 0.9, - height = 600, - radius = 15; + const NetworkGraph = () => { + let width = window.innerWidth * 0.9, + height = window.innerHeight * 0.9; const initialNodes = [ - { id: 0, name: 'start', type:'start' ,shape: 'diamond', size: 10, color: 'green', fx: 50, fy: height / 2, fixed: true, assesses: null, isPartOf: null, comesAfter: null }, // Fixed position for start node - { id: 54321, name: 'end',type: 'end', shape: 'diamond', size: 10, color: 'green', fx: width - 50, fy: height / 2, fixed: true, assesses: null, isPartOf: null, comesAfter: null } // Fixed position for end node + { id: 0, name: 'start', type: 'start', shape: 'diamond', size: 10, color: 'green', fx: 50, fy: height / 2, fixed: true, assesses: null, isPartOf: null, comesAfter: null }, // Fixed position for start node + { id: 54321, name: 'end', type: 'end', shape: 'diamond', size: 10, color: 'green', fx: width - 50, fy: height / 2, fixed: true, assesses: null, isPartOf: null, comesAfter: null } // Fixed position for end node ]; const initialLinks = []; @@ -30,16 +30,20 @@ const NetworkGraph = () => { const [anchorElMultiNode, setAnchorElMultiNode] = useState(null); const [anchorElLink, setAnchorElLink] = useState(null); const [linkingNode, setLinkingNode] = useState(null); - // const [nodeMap, setNodeMap] = useState(new Map()); + const [legendToggled, setLegendToggled] = useState(false); + const [labelsToggled, setLabelsToggled] = useState(false); const [linkingMessage, setLinkingMessage] = useState(''); const [filterType, setFilterType] = useState('3'); const [hoveredNode, setHoveredNode] = useState(null); + const [isAlertError, setIsAlertError] = useState(false); const svgRef = useRef(null); const color = d3.scaleOrdinal(d3.schemeCategory10); const linkingNodeRef = useRef(linkingNode); const { sliderValue, setSliderValue, aERSliderValue ,setaERSliderValue,iERSliderValue,setIERSliderValue,rERSliderValue, setrERSliderValue,atomicSliderValue, setatomicSliderValue} = useSlider(); const shiftRef = useRef(shiftPressed); + + const radius = 15; useEffect(() => { shiftRef.current = shiftPressed; }, [shiftPressed]); @@ -58,74 +62,74 @@ const NetworkGraph = () => { useEffect(() => { // Update nodes sizes with slider const updatedNodes = nodes.map(node => { - if (node.shape !== 'diamond') { - return { ...node, size: sliderValue }; // Update size for non-diamond nodes - } - return node; // Keep diamond nodes unchanged + if (node.shape !== 'diamond') { + return { ...node, size: sliderValue }; // Update size for non-diamond nodes + } + return node; // Keep diamond nodes unchanged }); - + setNodes(updatedNodes); - }, [sliderValue]); // Dependency on sliderValue + }, [sliderValue]); // Dependency on sliderValue - useEffect(() => { + useEffect(() => { // Update nodes sizes with slider const updatedNodes = nodes.map(node => { - if (node.shape !== 'diamond') { - return { ...node, size: sliderValue }; // Update size for non-diamond nodes - } - return node; // Keep diamond nodes unchanged + if (node.shape !== 'diamond') { + return { ...node, size: sliderValue }; // Update size for non-diamond nodes + } + return node; // Keep diamond nodes unchanged }); - + setNodes(updatedNodes); - }, [sliderValue]); // Dependency on sliderValue + }, [sliderValue]); // Dependency on sliderValue - useEffect(() => { + useEffect(() => { // Update nodes sizes with slider const updatedNodes = nodes.map(node => { - if (node.shape == 'aER') { - return { ...node, size: aERSliderValue }; // Update size for non-diamond nodes - } - return node; // Keep diamond nodes unchanged + if (node.shape == 'aER') { + return { ...node, size: aERSliderValue }; // Update size for non-diamond nodes + } + return node; // Keep diamond nodes unchanged }); - + setNodes(updatedNodes); - }, [aERSliderValue]); // Dependency on aERSliderValue + }, [aERSliderValue]); // Dependency on aERSliderValue - useEffect(() => { + useEffect(() => { // Update nodes sizes with slider const updatedNodes = nodes.map(node => { - if (node.shape == 'iER') { - return { ...node, size: iERSliderValue }; // Update size for non-diamond nodes - } - return node; // Keep diamond nodes unchanged + if (node.shape == 'iER') { + return { ...node, size: iERSliderValue }; // Update size for non-diamond nodes + } + return node; // Keep diamond nodes unchanged }); - + setNodes(updatedNodes); - }, [iERSliderValue]); // Dependency on aERSliderValue + }, [iERSliderValue]); // Dependency on aERSliderValue - useEffect(() => { + useEffect(() => { // Update nodes sizes with slider const updatedNodes = nodes.map(node => { - if (node.shape == 'rER') { - return { ...node, size: rERSliderValue }; // Update size for non-diamond nodes - } - return node; // Keep diamond nodes unchanged + if (node.shape == 'rER') { + return { ...node, size: rERSliderValue }; // Update size for non-diamond nodes + } + return node; // Keep diamond nodes unchanged }); - + setNodes(updatedNodes); - }, [rERSliderValue]); // Dependency on aERSliderValue + }, [rERSliderValue]); // Dependency on aERSliderValue - useEffect(() => { + useEffect(() => { // Update nodes sizes with slider const updatedNodes = nodes.map(node => { - if (node.shape == 'Atomic ER') { - return { ...node, size: atomicSliderValue }; // Update size for non-diamond nodes - } - return node; // Keep diamond nodes unchanged + if (node.shape == 'Atomic ER') { + return { ...node, size: atomicSliderValue }; // Update size for non-diamond nodes + } + return node; // Keep diamond nodes unchanged }); - + setNodes(updatedNodes); - }, [atomicSliderValue]); // Dependency on aERSliderValue + }, [atomicSliderValue]); // Dependency on aERSliderValue const handleKeyDown = (event) => { @@ -166,6 +170,9 @@ const NetworkGraph = () => { useEffect(() => { const svg = d3.select(svgRef.current); + height = svg.node().getBoundingClientRect().height * 0.9; + width = svg.node().getBoundingClientRect().width * 0.9; + const ticked = () => { svg.selectAll('.node') @@ -174,17 +181,12 @@ const NetworkGraph = () => { .attr("cy", (d) => { return d.y = Math.max(radius, Math.min(height - radius, d.y)); }); - // let dimensions = new Map(); svg.selectAll('.nodeShape') .attr('d', d => getShapePath(d.shape)) // Update node shape path .attr('fill', d => d.color || color(d.type)) .attr('transform', d => `scale(${getNodeScale(d.size)})`); svg.selectAll('.link') - // .attr("x1", d => d.source.x) - // .attr("y1", d => d.source.y) - // .attr("x2", d => d.target.x) - // .attr("y2", d => d.target.y) .attr("points", d => (d.source.fx || d.source.x) + "," + (d.source.fy || d.source.y) + " " + ((d.source.fx || d.source.x) + (d.target.fx || d.target.x)) / 2 + "," + ((d.source.fy || d.source.y) + (d.target.fy || d.target.y)) / 2 + " " + (d.target.fx || d.target.x) + "," + (d.target.fy || d.target.y)) @@ -212,38 +214,67 @@ const NetworkGraph = () => { return '#df0d0d'; } }) + .attr('orient', d => { + switch (d.type) { + case 'Assesses': + return 'auto'; + case 'Comes After': + return '1'; + case 'Is Part Of': + return 'auto'; + default: + return 'auto'; + } + }); // for correcting the orientation of the marker + svg.selectAll('.nodeLabel') .text(d => d.name) // Update node's label text .attr('style', 'font-weight: bold; font-size: 8px;') // ** WIP ** - // svg.selectAll('.edgepath').attr('d', (d) => ( - // `M ${d.source.x} ${d.source.y} L ${d.target.x} ${d.target.y}` - // )); + const distance = 10; + svg.selectAll('.edgepath').attr('d', (d) => { + let x1 = d.source.x - (distance * Math.sin(Math.atan2(d.source.y - d.target.y, d.source.x - d.target.x))); + let y1 = d.source.y + (distance * Math.cos(Math.atan2(d.source.y - d.target.y, d.source.x - d.target.x))); + let x2 = d.target.x - (distance * Math.sin(Math.atan2(d.source.y - d.target.y, d.source.x - d.target.x))); + let y2 = d.target.y + (distance * Math.cos(Math.atan2(d.source.y - d.target.y, d.source.x - d.target.x))); + return `M ${x1} ${y1} L ${x2} ${y2}` + }); - // svg.selectAll('edgelabel').attr('transform', function (d) { - // let bbox = this.getBBox(); + svg.selectAll('.edgelabel').attr('transform', function (d) { - // let rx = bbox.x + bbox.width / 2; - // let ry = bbox.y + bbox.height / 2; - // return 'rotate(180 ' + rx + ' ' + ry + ')'; + let x = (d.source.x + d.target.x) / 2; + let y = (d.source.y + d.target.y) / 2; + return 'rotate( ' + (d.source.x > d.target.x ? 180 : 0) + ', ' + x + ', ' + y + ')'; - // }); + }).attr('visibility', labelsToggled ? 'visible' : 'hidden'); }; // Create a zoom behavior - // const zoom = d3.zoom() - // .scaleExtent([0.5, 5]) // Set the zoom scale limits - // .on('zoom', (event) => { - // svg.select('g').attr('transform', event.transform); - // }); - // svg.call(zoom); - + const zoom = d3.zoom() + .scaleExtent([0.5, 5]) // Set the zoom scale limits + .on('zoom', (event) => { + svg.select('#main').attr('transform', event.transform); + svg.call(function (d) { + svg.style("cursor", "grabbing"); + }); + }) + .on('end', () => { + const recenterButton = document.getElementById('recenterButton'); + recenterButton.addEventListener('click', () => { + svg.transition().duration(750).call(zoom.transform, d3.zoomIdentity); + }); + svg.call(function (d) { + svg.style("cursor", "default"); + }); + }); + svg.call(zoom); + // Function to create a force that keeps nodes at a fixed vertical position - const verticalForce = (nodes, strength) => { + const verticalForce = (nodes, strength) => { return (alpha) => { nodes.forEach(node => { if (node.comesAfter !== null && node.comesAfter !== undefined) { @@ -253,18 +284,21 @@ const NetworkGraph = () => { }); }; }; - + // todo: filter based on ER type and selected view/FilterType (maybe eligibilty function type switching) + // maybe also add ids to links so u kno which ones to keep -- might have to change the way links are stored (temporary view ones and permanent ones) const simulation = d3.forceSimulation(nodes.filter(n => !n.hidden)) - .force('link', d3.forceLink(links.filter(l => !l.hidden)).id(d => d.id).distance(100)) // Link force + .force('link', d3.forceLink(links.filter(l => !l.hidden)).id(d => d.id).distance(85)) // Link force .force('charge', d3.forceManyBody().strength(-2000).distanceMax(175).distanceMin(0.01)) // Charge force to repel nodes .force('center', d3.forceCenter(width / 2, height / 2)) // Centering force .force('y', verticalForce(nodes, 1)) // Custom vertical force .on('tick', ticked); + + const defaultMarkers = [{ type: 'Assesses', source: { id: -1 }, target: { id: -1 } }, { type: 'Comes After', source: { id: -2 }, target: { id: -2 } }, { type: 'Is Part Of', source: { id: -3 }, target: { id: -3 } }] const marker = svg.select("defs") .selectAll(".markerDef") // Assign a marker per link, instead of one per class. - .data(links.filter(l => !l.hidden), function (d) { return d.source.id + "-" + d.target.id; }); + .data(() => { let tmp = links.filter(l => !l.hidden); tmp.push(...defaultMarkers); return tmp }, function (d) { return d.source.id + "-" + d.target.id; }); marker.exit().remove(); const markerEnter = marker .enter() @@ -300,7 +334,47 @@ const NetworkGraph = () => { .append("path") .attr("d", "M0,-5L10,0L0,5"); - const link = svg.select('g').selectAll('.link') + const legendRect = svg.select('#legend').select('rect') + .attr('x', width - 330) + .attr('y', height - 130) + .attr('width', 200) + .attr('height', 100) + .style('fill', 'white') + .style('opacity', 0.8) + .style('stroke', 'lightgrey') + const legendLinks = svg.select('#legend').selectAll('.linkLegend') + .data(...[defaultMarkers]) + const legendText = svg.select('#legend').selectAll('text') + .data(...[defaultMarkers]) + + legendText.exit().remove(); + legendLinks.exit().remove(); + + const legendLinksEnter = legendLinks.enter().append('polyline') + .attr('class', 'linkLegend') + .attr('stroke', d => { + switch (d.type) { + case 'Assesses': + return 'lightblue'; + case 'Comes After': + return 'red'; + case 'Is Part Of': + return 'grey'; + } + }) + .attr('points', (d, i) => { + return `${width - 325},${height - 105 + (i * 20)} ${width - 255},${height - 105 + (i * 20)} ${width - 185},${height - 105 + (i * 20)}` + }) + .attr("marker-mid", function (d) { return "url(#" + (d.source.id + "-" + d.target.id).replace(/\s+/g, '') + ")"; }); + + const legendTextEnter = legendText.enter().append('text') + .attr("x", width - 182) + .attr("y", (d, i) => { + return `${height - 103 + (i * 20)}` + }) + .text((d) => d.type) + .style('font-size', 9); + const link = svg.select('#main').selectAll('.link') .data(links.filter(l => !l.hidden), d => `${d.source.id}-${d.target.id}`); link.exit().remove(); @@ -321,40 +395,40 @@ const NetworkGraph = () => { } }) .attr("marker-mid", function (d) { return "url(#" + (d.source.id + "-" + d.target.id).replace(/\s+/g, '') + ")"; }) - // ** WIP ** - // .append("title").text(function (d) { return d.type; }); - - // const edgepaths = svg.selectAll(".edgepath") - // .data(links.filter(l => !l.hidden), function (d) { return d ? `p${d.source.id}-${d.target.id}` : this.id }); - // edgepaths.exit().remove(); - // const edgPathEnter = edgepaths.enter() - // .append('path') - // .attr('class', 'edgepath') - // .attr('fill-opacity', 0) - // .attr('stroke-opacity', 0) - // .attr('id', (d, i) => 'edgepath' + i) - // .style("pointer-events", "none"); - - - // const edgelabels = svg.selectAll(".edgelabel") - // .data(links.filter(l => !l.hidden), function (d) { return d ? `l${d.source.id}-${d.target.id}` : this.id }); - // edgelabels.exit().remove(); - // const enterEdgLabels = edgelabels - // .enter() - // .append('text') - // .style("pointer-events", "none") - // .attr('class', 'edgelabel') - // .attr('id', (d, i) => 'edgelabel' + i) - // .attr('font-size', 10) - // .attr('fill', '#aaa') - // .append('textPath') - // .attr('xlink:href', (d, i) => '#edgepath' + i) - // .style("text-anchor", "middle") - // .style("pointer-events", "none") - // .attr("startOffset", "50%") - // .text(d => d.type); - - const node = svg.select('g').selectAll('.node') + + const edgepaths = svg.select('#main').selectAll(".edgepath") + .data(links.filter(l => !l.hidden), function (d) { return d ? `p${d.source.id}-${d.target.id}` : this.id }); + edgepaths.exit().remove(); + const edgPathEnter = edgepaths.enter() + .append('path') + .attr('class', 'edgepath') + .attr('fill-opacity', 0) + .attr('stroke-opacity', 0) + .attr('id', (d, i) => 'edgepath' + i) + .style("pointer-events", "none"); + + + const edgelabels = svg.select('#main').selectAll(".edgelabel") + .data(links.filter(l => !l.hidden), function (d) { return d ? `l${d.source.id}-${d.target.id}` : this.id }); + edgelabels.exit().remove(); + const enterEdgLabels = edgelabels + .enter() + .append('text') + .style("pointer-events", "none") + .attr('class', 'edgelabel') + .attr('id', (d, i) => 'edgelabel' + i) + .attr('fill', '#aaa') + .append('textPath') + .attr('font-size', 9) + .style('stroke', 'black') + .style('stroke-width', 0.3) + .attr('xlink:href', (d, i) => '#edgepath' + i) + .style("text-anchor", "middle") + .style("pointer-events", "none") + .attr("startOffset", "50%") // Adjust the startOffset value to move the edgeLabels further from the edgepath + .text(d => d.type); + + const node = svg.select('#main').selectAll('.node') .data(nodes.filter(n => !n.hidden), d => d.id); @@ -487,10 +561,12 @@ const NetworkGraph = () => { svg.selectAll('.node').remove(); svg.selectAll('.link').remove(); svg.selectAll('.markerDef').remove(); + svg.selectAll('.edgepath').remove(); + svg.selectAll('.edgelabel').remove(); simulation.stop(); // Stop simulation on component unmount }; - }, [links]); + }, [links, legendToggled, labelsToggled]); const handleCloseNode = () => { setSelectedNode(null); @@ -672,43 +748,6 @@ const NetworkGraph = () => { if (!existingLink) { currLN.comesAfter = d.id - // if (currLN.shape === 'Atomic ER' && (d.shape === 'diamond' || d.shape === 'Atomic ER')) { - // currLN.comesAfter ? d.comesAfter = currLN.id : currLN.comesAfter = d.id - // } else if (currLN.shape === 'Atomic ER' && d.shape === 'iER') { - // currLN.isPartOf = d.id - // } else if (currLN.shape === 'Atomic ER' && d.shape === 'aER') { - // currLN.isPartOf = d.id - // } else if (currLN.shape === 'Atomic ER' && d.shape === 'rER') { - // // setLinkingMessage('Atomic ER cannot link with rER'); - // // setTimeout(() => setLinkingMessage(''), 2000); - // currLN.isPartOf = d.id - // } else if (currLN.shape === 'diamond' && d.shape === 'Atomic ER') { - // currLN.comesAfter ? d.comesAfter = currLN.id : currLN.comesAfter = d.id - // } else if (currLN.shape === 'iER' && d.shape === 'Atomic ER') { - // d.isPartOf = currLN.id - // } else if (currLN.shape === 'iER' && d.shape === 'aER') { - // d.isPartOf = currLN.id - // } else if (currLN.shape === 'iER' && d.shape === 'rER') { - // // setLinkingMessage('iER cannot link with rER'); - // // setTimeout(() => setLinkingMessage(''), 2000); - // currLN.comesAfter = d.id - // } else if (currLN.shape === 'iER' && d.shape === 'diamond') { - // currLN.comesAfter ? d.comesAfter = currLN.id : currLN.comesAfter = d.id - // } else if (currLN.shape === 'aER' && d.shape === 'iER') { - // currLN.comesAfter = d.id - // } else if (currLN.shape === 'aER' && d.shape === 'rER') { - // d.assesses = currLN.id - // } else if (currLN.shape === 'rER' && d.shape === 'aER') { - // currLN.assesses = d.id - // } else if (currLN.shape === 'rER' && d.shape !== 'aER') { - // // setLinkingMessage('Only aER can be linked with rER'); - // // setTimeout(() => setLinkingMessage(''), 2000); - // currLN.assesses = d.id - // } else { - // setLinkingMessage('Linking between selected nodes is not allowed'); - // setTimeout(() => setLinkingMessage(''), 2000); - // } - setNodes([...nodes]); // setLinks(prevLinks => [...prevLinks, { source: currentLinkingNode, target: d, type: 'Comes After' }]); } @@ -732,8 +771,15 @@ const NetworkGraph = () => { if (file) { const reader = new FileReader(); reader.onload = (e) => { - const text = e.target.result; - parseCSV(text); + try { + const text = e.target.result; + parseCSV(text); + } catch (error) { + // Display error popup + setIsAlertError(true); + //set back to false after 5 seconds + setTimeout(() => setIsAlertError(false), 5000); + } }; reader.readAsText(file); } @@ -758,7 +804,7 @@ const NetworkGraph = () => { }) // Remove nodes with title "start" or "end" (case insensitive) - parsedData = parsedData.filter(node => { + parsedData = parsedData.filter(node => { const title = node.name ? node.name.toLowerCase() : ''; return title !== 'start' && title !== 'end'; }); @@ -994,41 +1040,9 @@ for (let i = nodes.length - 1; i >= 0; i--) { }; }); setNodes([...updatedNodes]); - // const newLinks = filterType ? processLinks(nodes.filter(n => !n.hidden)) : []; - // setLinks(newLinks); - // handleFilterLinks(filterType); }; - // const handleFilterLinks = (filterType) => { - // const updatedLinks = links.map(link => { - // let hidden = false; - - // switch (filterType) { - // case "1": - // hidden = (link.source.shape === 'iER' || link.source.shape === 'Atomic ER' || link.target.shape === 'Atomic ER' || link.target.shape === 'iER'); - // break; - // case "2": - // hidden = (link.source.shape === 'Atomic ER' || link.target.shape === 'Atomic ER'); - // break; - // case "3": - // hidden = false; // Show all links - // break; - // case "4": - // // Implement your logic for View 4 - // break; - // default: - // hidden = false; - // } - - // return { - // ...link, - // hidden - // }; - // }); - - // setLinks(updatedLinks); // Update state for links - // }; const handleNodeHover = (event, d) => { // Set the hovered node in state @@ -1068,15 +1082,15 @@ for (let i = nodes.length - 1; i >= 0; i--) { function downloadCSV() { let csvContent = "ID,name,alternative title,target URL,type,isPartOf,assesses,comesAfter\n"; nodes.forEach(node => { - if(node.alternativeTitle == undefined){ - node.alternativeTitle ="" + if (node.alternativeTitle == undefined) { + node.alternativeTitle = "" } - if(node.targetURL == undefined){ - node.targetURL ="" + if (node.targetURL == undefined) { + node.targetURL = "" } csvContent += `${node.id},${node.name},${node.alternativeTitle},${node.targetURL},${node.type},${node.isPartOf || ""},${node.assesses || ""},${node.comesAfter || ""}\n`; }); - + let blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); let link = document.createElement("a"); if (link.download !== undefined) { @@ -1090,52 +1104,60 @@ for (let i = nodes.length - 1; i >= 0; i--) { } } - function handleExportClick () { exportSvg(svgRef.current, 'my-d3-graph.svg'); }; - - return (
- -
- - - -
- {/* */} - - Views - - -
- - + +
+ + + + + setLabelsToggled(!labelsToggled)} />} + label={`${labelsToggled ? 'Hide' : 'Show'} Labels`} + /> + setLegendToggled(!legendToggled)} />} + label={`${legendToggled ? 'Hide' : 'Show'} Legend`} + /> +
+ {/* */} + + + Views + + +
+ + + {legendToggled && ()} {linkingMessage && ( @@ -1154,7 +1176,7 @@ for (let i = nodes.length - 1; i >= 0; i--) { {linkingMessage} )} - + {selectedNode && ( // console.log(anchorElNode), = 0; i--) { /> )} + {isAlertError && } severity="error"> + There was an error processing the file. Please try again. + } ); };