From 606ad32e60253661344890cbf9c0fa35324ff71c Mon Sep 17 00:00:00 2001 From: CalTsai77 Date: Sat, 24 Dec 2022 23:42:09 -0500 Subject: [PATCH 1/2] Research into changing color for clicking on nodes --- react-app/src/components/custom-graph.js | 4 +- react-app/src/d3-event-help/graph.jsx | 683 +++++++++++++++++++ react-app/src/d3-event-help/link.jsx | 118 ++++ react-app/src/d3-event-help/marker.jsx | 27 + react-app/src/d3-event-help/node.jsx | 167 +++++ react-app/src/utils/graph/generate-data.js | 6 +- react-app/src/utils/graph/generate-events.js | 50 +- 7 files changed, 1046 insertions(+), 9 deletions(-) create mode 100644 react-app/src/d3-event-help/graph.jsx create mode 100644 react-app/src/d3-event-help/link.jsx create mode 100644 react-app/src/d3-event-help/marker.jsx create mode 100644 react-app/src/d3-event-help/node.jsx diff --git a/react-app/src/components/custom-graph.js b/react-app/src/components/custom-graph.js index a9a32f3..df2bc78 100644 --- a/react-app/src/components/custom-graph.js +++ b/react-app/src/components/custom-graph.js @@ -2,7 +2,7 @@ import { Graph } from "react-d3-graph" import useWindowDimensions from "../utils/window-dimensions" import generateConfig from "../utils/graph/generate-config" import generateData from "../utils/graph/generate-data" -import { onClickNode, onClickLink } from "../utils/graph/generate-events" +import { onClickNode, onRightClickNode, onClickLink, onRightClickLink} from "../utils/graph/generate-events" function CustomGraph() { const { height, width } = useWindowDimensions(); @@ -13,6 +13,8 @@ function CustomGraph() { config={generateConfig(height, width)} onClickNode={onClickNode} onClickLink={onClickLink} + onRightClickNode={onRightClickNode} + onRightClickLink={onRightClickLink} /> ; } diff --git a/react-app/src/d3-event-help/graph.jsx b/react-app/src/d3-event-help/graph.jsx new file mode 100644 index 0000000..536699b --- /dev/null +++ b/react-app/src/d3-event-help/graph.jsx @@ -0,0 +1,683 @@ +import React from "react"; + +import { drag as d3Drag } from "d3-drag"; +import { forceLink as d3ForceLink } from "d3-force"; +import { select as d3Select, selectAll as d3SelectAll, event as d3Event } from "d3-selection"; +import { zoom as d3Zoom } from "d3-zoom"; + +import CONST from "./graph.const"; +import DEFAULT_CONFIG from "./graph.config"; +import ERRORS from "../../err"; + +import { getTargetLeafConnections, toggleLinksMatrixConnections, toggleLinksConnections } from "./collapse.helper"; +import { + updateNodeHighlightedValue, + checkForGraphConfigChanges, + checkForGraphElementsChanges, + getCenterAndZoomTransformation, + initializeGraphState, + initializeNodes, +} from "./graph.helper"; +import { renderGraph } from "./graph.renderer"; +import { merge, debounce, throwErr } from "../../utils"; + +/** + * Graph component is the main component for react-d3-graph components, its interface allows its user + * to build the graph once the user provides the data, configuration (optional) and callback interactions (also optional). + * The code for the [live example](https://danielcaldas.github.io/react-d3-graph/sandbox/index.html) + * can be consulted [here](https://github.com/danielcaldas/react-d3-graph/blob/master/sandbox/Sandbox.jsx) + * @example + * import { Graph } from 'react-d3-graph'; + * + * // graph payload (with minimalist structure) + * const data = { + * nodes: [ + * {id: 'Harry'}, + * {id: 'Sally'}, + * {id: 'Alice'} + * ], + * links: [ + * {source: 'Harry', target: 'Sally'}, + * {source: 'Harry', target: 'Alice'}, + * ] + * }; + * + * // the graph configuration, you only need to pass down properties + * // that you want to override, otherwise default ones will be used + * const myConfig = { + * nodeHighlightBehavior: true, + * node: { + * color: 'lightgreen', + * size: 120, + * highlightStrokeColor: 'blue' + * }, + * link: { + * highlightColor: 'lightblue' + * } + * }; + * + * // Callback to handle click on the graph. + * // @param {Object} event click dom event + * const onClickGraph = function(event) { + * window.alert('Clicked the graph background'); + * }; + * + * const onClickNode = function(nodeId, node) { + * window.alert('Clicked node ${nodeId} in position (${node.x}, ${node.y})'); + * }; + * + * const onDoubleClickNode = function(nodeId, node) { + * window.alert('Double clicked node ${nodeId} in position (${node.x}, ${node.y})'); + * }; + * + * const onRightClickNode = function(event, nodeId, node) { + * window.alert('Right clicked node ${nodeId} in position (${node.x}, ${node.y})'); + * }; + * + * const onMouseOverNode = function(nodeId, node) { + * window.alert(`Mouse over node ${nodeId} in position (${node.x}, ${node.y})`); + * }; + * + * const onMouseOutNode = function(nodeId, node) { + * window.alert(`Mouse out node ${nodeId} in position (${node.x}, ${node.y})`); + * }; + * + * const onClickLink = function(source, target) { + * window.alert(`Clicked link between ${source} and ${target}`); + * }; + * + * const onRightClickLink = function(event, source, target) { + * window.alert('Right clicked link between ${source} and ${target}'); + * }; + * + * const onMouseOverLink = function(source, target) { + * window.alert(`Mouse over in link between ${source} and ${target}`); + * }; + * + * const onMouseOutLink = function(source, target) { + * window.alert(`Mouse out link between ${source} and ${target}`); + * }; + * + * const onNodePositionChange = function(nodeId, x, y) { + * window.alert(`Node ${nodeId} moved to new position x= ${x} y= ${y}`); + * }; + * + * // Callback that's called whenever the graph is zoomed in/out + * // @param {number} previousZoom the previous graph zoom + * // @param {number} newZoom the new graph zoom + * const onZoomChange = function(previousZoom, newZoom) { + * window.alert(`Graph is now zoomed at ${newZoom} from ${previousZoom}`); + * }; + * + * + * + */ +export default class Graph extends React.Component { + /** + * Obtain a set of properties which will be used to perform the focus and zoom animation if + * required. In case there's not a focus and zoom animation in progress, it should reset the + * transition duration to zero and clear transformation styles. + * @returns {Object} - Focus and zoom animation properties. + */ + _generateFocusAnimationProps = () => { + // In case an older animation was still not complete, clear previous timeout to ensure the new one is not cancelled + if (this.state.enableFocusAnimation) { + if (this.focusAnimationTimeout) { + clearTimeout(this.focusAnimationTimeout); + } + + this.focusAnimationTimeout = setTimeout( + () => this.setState({ enableFocusAnimation: false }), + this.state.config.focusAnimationDuration * 1000 + ); + } + + const transitionDuration = this.state.enableFocusAnimation ? this.state.config.focusAnimationDuration : 0; + + return { + style: { transitionDuration: `${transitionDuration}s` }, + transform: this.state.focusTransformation, + }; + }; + + /** + * This method runs {@link d3-force|https://github.com/d3/d3-force} + * against the current graph. + * @returns {undefined} + */ + _graphLinkForceConfig() { + const forceLink = d3ForceLink(this.state.d3Links) + .id(l => l.id) + .distance(this.state.config.d3.linkLength) + .strength(this.state.config.d3.linkStrength); + + this.state.simulation.force(CONST.LINK_CLASS_NAME, forceLink); + } + + /** + * This method runs {@link d3-drag|https://github.com/d3/d3-drag} + * against the current graph. + * @returns {undefined} + */ + _graphNodeDragConfig() { + const customNodeDrag = d3Drag() + .on("start", this._onDragStart) + .on("drag", this._onDragMove) + .on("end", this._onDragEnd); + + d3Select(`#${this.state.id}-${CONST.GRAPH_WRAPPER_ID}`) + .selectAll(".node") + .call(customNodeDrag); + } + + /** + * Sets d3 tick function and configures other d3 stuff such as forces and drag events. + * Whenever called binds Graph component state with d3. + * @returns {undefined} + */ + _graphBindD3ToReactComponent() { + if (!this.state.config.d3.disableLinkForce) { + this.state.simulation.nodes(this.state.d3Nodes).on("tick", this._tick); + this._graphLinkForceConfig(); + } + if (!this.state.config.freezeAllDragEvents) { + this._graphNodeDragConfig(); + } + } + + /** + * Handles d3 drag 'end' event. + * @returns {undefined} + */ + _onDragEnd = () => { + this.isDraggingNode = false; + + if (this.state.draggedNode) { + this.onNodePositionChange(this.state.draggedNode); + this._tick({ draggedNode: null }); + } + + !this.state.config.staticGraph && + this.state.config.automaticRearrangeAfterDropNode && + this.state.simulation.alphaTarget(this.state.config.d3.alphaTarget).restart(); + }; + + /** + * Handles d3 'drag' event. + * {@link https://github.com/d3/d3-drag/blob/master/README.md#drag_subject|more about d3 drag} + * @param {Object} ev - if not undefined it will contain event data. + * @param {number} index - index of the node that is being dragged. + * @param {Array.} nodeList - array of d3 nodes. This list of nodes is provided by d3, each + * node contains all information that was previously fed by rd3g. + * @returns {undefined} + */ + _onDragMove = (ev, index, nodeList) => { + const id = nodeList[index].id; + + if (!this.state.config.staticGraph) { + // this is where d3 and react bind + let draggedNode = this.state.nodes[id]; + + draggedNode.oldX = draggedNode.x; + draggedNode.oldY = draggedNode.y; + + draggedNode.x += d3Event.dx; + draggedNode.y += d3Event.dy; + + // set nodes fixing coords fx and fy + draggedNode["fx"] = draggedNode.x; + draggedNode["fy"] = draggedNode.y; + + this._tick({ draggedNode }); + } + }; + + /** + * Handles d3 drag 'start' event. + * @returns {undefined} + */ + _onDragStart = () => { + this.isDraggingNode = true; + this.pauseSimulation(); + + if (this.state.enableFocusAnimation) { + this.setState({ enableFocusAnimation: false }); + } + }; + + /** + * Sets nodes and links highlighted value. + * @param {string} id - the id of the node to highlight. + * @param {boolean} [value=false] - the highlight value to be set (true or false). + * @returns {undefined} + */ + _setNodeHighlightedValue = (id, value = false) => + this._tick(updateNodeHighlightedValue(this.state.nodes, this.state.links, this.state.config, id, value)); + + /** + * The tick function simply calls React set state in order to update component and render nodes + * along time as d3 calculates new node positioning. + * @param {Object} state - new state to pass on. + * @param {Function} [cb] - optional callback to fed in to {@link setState()|https://reactjs.org/docs/react-component.html#setstate}. + * @returns {undefined} + */ + _tick = (state = {}, cb) => (cb ? this.setState(state, cb) : this.setState(state)); + + /** + * Configures zoom upon graph with default or user provided values.
+ * NOTE: in order for users to be able to double click on nodes, we + * are disabling the native dblclick.zoom from d3 that performs a zoom + * whenever a user double clicks on top of the graph. + * {@link https://github.com/d3/d3-zoom#zoom} + * @returns {undefined} + */ + _zoomConfig = () => { + const selector = d3Select(`#${this.state.id}-${CONST.GRAPH_WRAPPER_ID}`); + + const zoomObject = d3Zoom().scaleExtent([this.state.config.minZoom, this.state.config.maxZoom]); + + if (!this.state.config.freezeAllDragEvents) { + zoomObject.on("zoom", this._zoomed); + } + + if (this.state.config.initialZoom !== null) { + zoomObject.scaleTo(selector, this.state.config.initialZoom); + } + + // avoid double click on graph to trigger zoom + // for more details consult: https://github.com/danielcaldas/react-d3-graph/pull/202 + selector.call(zoomObject).on("dblclick.zoom", null); + }; + + /** + * Handler for 'zoom' event within zoom config. + * @returns {Object} returns the transformed elements within the svg graph area. + */ + _zoomed = () => { + const transform = d3Event.transform; + + d3SelectAll(`#${this.state.id}-${CONST.GRAPH_CONTAINER_ID}`).attr("transform", transform); + + this.state.config.panAndZoom && this.setState({ transform: transform.k }); + + // only send zoom change events if the zoom has changed (_zoomed() also gets called when panning) + if (this.debouncedOnZoomChange && this.state.previousZoom !== transform.k) { + this.debouncedOnZoomChange(this.state.previousZoom, transform.k); + this.setState({ previousZoom: transform.k }); + } + }; + + /** + * Calls the callback passed to the component. + * @param {Object} e - The event of onClick handler. + * @returns {undefined} + */ + onClickGraph = e => { + if (this.state.enableFocusAnimation) { + this.setState({ enableFocusAnimation: false }); + } + + // Only trigger the graph onClickHandler, if not clicked a node or link. + // toUpperCase() is added as a precaution, as the documentation says tagName should always + // return in UPPERCASE, but chrome returns lowercase + const tagName = e.target && e.target.tagName; + const name = e?.target?.attributes?.name?.value; + const svgContainerName = `svg-container-${this.state.id}`; + + if (tagName.toUpperCase() === "SVG" && name === svgContainerName) { + this.props.onClickGraph && this.props.onClickGraph(e); + } + }; + + /** + * Collapses the nodes, then checks if the click is doubled and calls the callback passed to the component. + * @param {string} clickedNodeId - The id of the node where the click was performed. + * @returns {undefined} + */ + onClickNode = clickedNodeId => { + const clickedNode = this.state.nodes[clickedNodeId]; + + if (this.state.config.collapsible) { + const leafConnections = getTargetLeafConnections(clickedNodeId, this.state.links, this.state.config); + const links = toggleLinksMatrixConnections(this.state.links, leafConnections, this.state.config); + const d3Links = toggleLinksConnections(this.state.d3Links, links); + const firstLeaf = leafConnections?.["0"]; + + let isExpanding = false; + + if (firstLeaf) { + const visibility = links[firstLeaf.source][firstLeaf.target]; + + isExpanding = visibility === 1; + } + + this._tick( + { + links, + d3Links, + }, + () => { + this.props.onClickNode && this.props.onClickNode(clickedNodeId, clickedNode); + + if (isExpanding) { + this._graphNodeDragConfig(); + } + } + ); + } else { + if (!this.nodeClickTimer) { + this.nodeClickTimer = setTimeout(() => { + this.props.onClickNode && this.props.onClickNode(clickedNodeId, clickedNode); + this.nodeClickTimer = null; + }, CONST.TTL_DOUBLE_CLICK_IN_MS); + } else { + this.props.onDoubleClickNode && this.props.onDoubleClickNode(clickedNodeId, clickedNode); + this.nodeClickTimer = clearTimeout(this.nodeClickTimer); + } + } + }; + + /** + * Handles right click event on a node. + * @param {Object} event - Right click event. + * @param {string} id - id of the node that participates in the event. + * @returns {undefined} + */ + onRightClickNode = (event, id) => { + const clickedNode = this.state.nodes[id]; + this.props.onRightClickNode && this.props.onRightClickNode(event, id, clickedNode); + }; + + /** + * Handles mouse over node event. + * @param {string} id - id of the node that participates in the event. + * @returns {undefined} + */ + onMouseOverNode = id => { + if (this.isDraggingNode) { + return; + } + + const clickedNode = this.state.nodes[id]; + this.props.onMouseOverNode && this.props.onMouseOverNode(id, clickedNode); + + this.state.config.nodeHighlightBehavior && this._setNodeHighlightedValue(id, true); + }; + + /** + * Handles mouse out node event. + * @param {string} id - id of the node that participates in the event. + * @returns {undefined} + */ + onMouseOutNode = id => { + if (this.isDraggingNode) { + return; + } + + const clickedNode = this.state.nodes[id]; + this.props.onMouseOutNode && this.props.onMouseOutNode(id, clickedNode); + + this.state.config.nodeHighlightBehavior && this._setNodeHighlightedValue(id, false); + }; + + /** + * Handles mouse over link event. + * @param {string} source - id of the source node that participates in the event. + * @param {string} target - id of the target node that participates in the event. + * @returns {undefined} + */ + onMouseOverLink = (source, target) => { + this.props.onMouseOverLink && this.props.onMouseOverLink(source, target); + + if (this.state.config.linkHighlightBehavior) { + const highlightedLink = { source, target }; + + this._tick({ highlightedLink }); + } + }; + + /** + * Handles mouse out link event. + * @param {string} source - id of the source node that participates in the event. + * @param {string} target - id of the target node that participates in the event. + * @returns {undefined} + */ + onMouseOutLink = (source, target) => { + this.props.onMouseOutLink && this.props.onMouseOutLink(source, target); + + if (this.state.config.linkHighlightBehavior) { + const highlightedLink = undefined; + + this._tick({ highlightedLink }); + } + }; + + /** + * Handles node position change. + * @param {Object} node - an object holding information about the dragged node. + * @returns {undefined} + */ + onNodePositionChange = node => { + if (!this.props.onNodePositionChange) { + return; + } + + const { id, x, y } = node; + + this.props.onNodePositionChange(id, x, y); + }; + + /** + * Calls d3 simulation.stop().
+ * {@link https://github.com/d3/d3-force#simulation_stop} + * @returns {undefined} + */ + pauseSimulation = () => this.state.simulation.stop(); + + /** + * This method resets all nodes fixed positions by deleting the properties fx (fixed x) + * and fy (fixed y). Following this, a simulation is triggered in order to force nodes to go back + * to their original positions (or at least new positions according to the d3 force parameters). + * @returns {undefined} + */ + resetNodesPositions = () => { + if (!this.state.config.staticGraph) { + let initialNodesState = initializeNodes(this.props.data.nodes); + for (let nodeId in this.state.nodes) { + let node = this.state.nodes[nodeId]; + + if (node.fx && node.fy) { + Reflect.deleteProperty(node, "fx"); + Reflect.deleteProperty(node, "fy"); + } + + if (nodeId in initialNodesState) { + let initialNode = initialNodesState[nodeId]; + node.x = initialNode.x; + node.y = initialNode.y; + } + } + + this.state.simulation.alphaTarget(this.state.config.d3.alphaTarget).restart(); + + this._tick(); + } + }; + + /** + * Calls d3 simulation.restart().
+ * {@link https://github.com/d3/d3-force#simulation_restart} + * @returns {undefined} + */ + restartSimulation = () => !this.state.config.staticGraph && this.state.simulation.restart(); + + constructor(props) { + super(props); + + if (!this.props.id) { + throwErr(this.constructor.name, ERRORS.GRAPH_NO_ID_PROP); + } + + this.focusAnimationTimeout = null; + this.nodeClickTimer = null; + this.isDraggingNode = false; + this.state = initializeGraphState(this.props, this.state); + this.debouncedOnZoomChange = this.props.onZoomChange ? debounce(this.props.onZoomChange, 100) : null; + } + + /** + * @deprecated + * `componentWillReceiveProps` has a replacement method in react v16.3 onwards. + * that is getDerivedStateFromProps. + * But one needs to be aware that if an anti pattern of `componentWillReceiveProps` is + * in place for this implementation the migration might not be that easy. + * See {@link https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html}. + * @param {Object} nextProps - props. + * @returns {undefined} + */ + // eslint-disable-next-line + UNSAFE_componentWillReceiveProps(nextProps) { + const { graphElementsUpdated, newGraphElements } = checkForGraphElementsChanges(nextProps, this.state); + const state = graphElementsUpdated ? initializeGraphState(nextProps, this.state) : this.state; + const newConfig = nextProps.config || {}; + const { configUpdated, d3ConfigUpdated } = checkForGraphConfigChanges(nextProps, this.state); + const config = configUpdated ? merge(DEFAULT_CONFIG, newConfig) : this.state.config; + + // in order to properly update graph data we need to pause eventual d3 ongoing animations + newGraphElements && this.pauseSimulation(); + + const transform = newConfig.panAndZoom !== this.state.config.panAndZoom ? 1 : this.state.transform; + const focusedNodeId = nextProps.data.focusedNodeId; + const d3FocusedNode = this.state.d3Nodes.find(node => `${node.id}` === `${focusedNodeId}`); + const containerElId = `${this.state.id}-${CONST.GRAPH_WRAPPER_ID}`; + const focusTransformation = + getCenterAndZoomTransformation(d3FocusedNode, this.state.config, containerElId) || this.state.focusTransformation; + const enableFocusAnimation = this.props.data.focusedNodeId !== nextProps.data.focusedNodeId; + + // if we're given a function to call when the zoom changes, we create a debounced version of it + // this is because this function gets called in very rapid succession when zooming + if (nextProps.onZoomChange) { + this.debouncedOnZoomChange = debounce(nextProps.onZoomChange, 100); + } + + this.setState({ + ...state, + config, + configUpdated, + d3ConfigUpdated, + newGraphElements, + transform, + focusedNodeId, + enableFocusAnimation, + focusTransformation, + }); + } + + componentDidUpdate() { + // if the property staticGraph was activated we want to stop possible ongoing simulation + const shouldPause = this.state.config.staticGraph || this.state.config.staticGraphWithDragAndDrop; + + if (shouldPause) { + this.pauseSimulation(); + } + + if (!this.state.config.staticGraph && (this.state.newGraphElements || this.state.d3ConfigUpdated)) { + this._graphBindD3ToReactComponent(); + + if (!this.state.config.staticGraphWithDragAndDrop) { + this.restartSimulation(); + } + + this.setState({ newGraphElements: false, d3ConfigUpdated: false }); + } else if (this.state.configUpdated) { + this._graphNodeDragConfig(); + } + + if (this.state.configUpdated) { + this._zoomConfig(); + this.setState({ configUpdated: false }); + } + } + + componentDidMount() { + if (!this.state.config.staticGraph) { + this._graphBindD3ToReactComponent(); + } + + // graph zoom and drag&drop all network + this._zoomConfig(); + } + + componentWillUnmount() { + this.pauseSimulation(); + + if (this.nodeClickTimer) { + clearTimeout(this.nodeClickTimer); + this.nodeClickTimer = null; + } + + if (this.focusAnimationTimeout) { + clearTimeout(this.focusAnimationTimeout); + this.focusAnimationTimeout = null; + } + } + + render() { + const { nodes, links, defs } = renderGraph( + this.state.nodes, + { + onClickNode: this.onClickNode, + onDoubleClickNode: this.onDoubleClickNode, + onRightClickNode: this.onRightClickNode, + onMouseOverNode: this.onMouseOverNode, + onMouseOut: this.onMouseOutNode, + }, + this.state.d3Links, + this.state.links, + { + onClickLink: this.props.onClickLink, + onRightClickLink: this.props.onRightClickLink, + onMouseOverLink: this.onMouseOverLink, + onMouseOutLink: this.onMouseOutLink, + }, + this.state.config, + this.state.highlightedNode, + this.state.highlightedLink, + this.state.transform + ); + + const svgStyle = { + height: this.state.config.height, + width: this.state.config.width, + }; + + const containerProps = this._generateFocusAnimationProps(); + + return ( +
+ + {defs} + + {links} + {nodes} + + +
+ ); + } +} diff --git a/react-app/src/d3-event-help/link.jsx b/react-app/src/d3-event-help/link.jsx new file mode 100644 index 0000000..bf6e87e --- /dev/null +++ b/react-app/src/d3-event-help/link.jsx @@ -0,0 +1,118 @@ +import React from "react"; + +/** + * Link component is responsible for encapsulating link render. + * @example + * const onClickLink = function(source, target) { + * window.alert(`Clicked link between ${source} and ${target}`); + * }; + * + * const onRightClickLink = function(source, target) { + * window.alert(`Right clicked link between ${source} and ${target}`); + * }; + * + * const onMouseOverLink = function(source, target) { + * window.alert(`Mouse over in link between ${source} and ${target}`); + * }; + * + * const onMouseOutLink = function(source, target) { + * window.alert(`Mouse out link between ${source} and ${target}`); + * }; + * + * + */ +export default class Link extends React.Component { + /** + * Handle link click event. + * @returns {undefined} + */ + handleOnClickLink = () => this.props.onClickLink && this.props.onClickLink(this.props.source, this.props.target); + + /** + * Handle link right click event. + * @param {Object} event - native event. + * @returns {undefined} + */ + handleOnRightClickLink = event => + this.props.onRightClickLink && this.props.onRightClickLink(event, this.props.source, this.props.target); + + /** + * Handle mouse over link event. + * @returns {undefined} + */ + handleOnMouseOverLink = () => + this.props.onMouseOverLink && this.props.onMouseOverLink(this.props.source, this.props.target); + + /** + * Handle mouse out link event. + * @returns {undefined} + */ + handleOnMouseOutLink = () => + this.props.onMouseOutLink && this.props.onMouseOutLink(this.props.source, this.props.target); + + render() { + const lineStyle = { + strokeWidth: this.props.strokeWidth, + stroke: this.props.stroke, + opacity: this.props.opacity, + fill: "none", + cursor: this.props.mouseCursor, + strokeDasharray: this.props.strokeDasharray, + strokeDashoffset: this.props.strokeDasharray, + strokeLinecap: this.props.strokeLinecap, + }; + + const lineProps = { + className: this.props.className, + d: this.props.d, + onClick: this.handleOnClickLink, + onContextMenu: this.handleOnRightClickLink, + onMouseOut: this.handleOnMouseOutLink, + onMouseOver: this.handleOnMouseOverLink, + style: lineStyle, + }; + + if (this.props.markerId) { + lineProps.markerEnd = `url(#${this.props.markerId})`; + } + + const { label, id } = this.props; + const textProps = { + dy: -1, + style: { + fill: this.props.fontColor, + fontSize: this.props.fontSize, + fontWeight: this.props.fontWeight, + }, + }; + + return ( + + + {label && ( + + + {label} + + + )} + + ); + } +} diff --git a/react-app/src/d3-event-help/marker.jsx b/react-app/src/d3-event-help/marker.jsx new file mode 100644 index 0000000..82a7007 --- /dev/null +++ b/react-app/src/d3-event-help/marker.jsx @@ -0,0 +1,27 @@ +import React from "react"; + +/** + * Market component provides configurable interface to marker definition. + * @example + * + * + */ +export default class Marker extends React.Component { + render() { + return ( + + + + ); + } +} diff --git a/react-app/src/d3-event-help/node.jsx b/react-app/src/d3-event-help/node.jsx new file mode 100644 index 0000000..ef63d0d --- /dev/null +++ b/react-app/src/d3-event-help/node.jsx @@ -0,0 +1,167 @@ +import React from "react"; + +import nodeHelper from "./node.helper"; +import CONST from "./node.const"; +import { logWarning } from "../../utils"; + +/** + * Node component is responsible for encapsulating node render. + * @example + * const onClickNode = function(nodeId) { + * window.alert('Clicked node', nodeId); + * }; + * + * const onRightClickNode = function(nodeId) { + * window.alert('Right clicked node', nodeId); + * } + * + * const onMouseOverNode = function(nodeId) { + * window.alert('Mouse over node', nodeId); + * }; + * + * const onMouseOutNode = function(nodeId) { + * window.alert('Mouse out node', nodeId); + * }; + * + * const generateCustomNode(node) { + * return ; + * } + * + * + */ +export default class Node extends React.Component { + /** + * Handle click on the node. + * @returns {undefined} + */ + handleOnClickNode = () => this.props.onClickNode && this.props.onClickNode(this.props.id); + + /** + * Handle right click on the node. + * @param {Object} event - native event. + * @returns {undefined} + */ + handleOnRightClickNode = event => this.props.onRightClickNode && this.props.onRightClickNode(event, this.props.id); + + /** + * Handle mouse over node event. + * @returns {undefined} + */ + handleOnMouseOverNode = () => this.props.onMouseOverNode && this.props.onMouseOverNode(this.props.id); + + /** + * Handle mouse out node event. + * @returns {undefined} + */ + handleOnMouseOutNode = () => this.props.onMouseOut && this.props.onMouseOut(this.props.id); + + render() { + const nodeProps = { + cursor: this.props.cursor, + onClick: this.handleOnClickNode, + onContextMenu: this.handleOnRightClickNode, + onMouseOut: this.handleOnMouseOutNode, + onMouseOver: this.handleOnMouseOverNode, + opacity: this.props.opacity, + }; + + const textProps = { + ...nodeHelper.getLabelPlacementProps(this.props.dx, this.props.labelPosition), + fill: this.props.fontColor, + fontSize: this.props.fontSize, + fontWeight: this.props.fontWeight, + opacity: this.props.opacity, + }; + + let size = this.props.size; + const isSizeNumericalValue = typeof size !== "object"; + + let gtx = this.props.cx, + gty = this.props.cy, + label = null, + node = null; + + if (this.props.svg || this.props.viewGenerator) { + const height = isSizeNumericalValue ? size / 10 : size.height / 10; + const width = isSizeNumericalValue ? size / 10 : size.width / 10; + const tx = width / 2; + const ty = height / 2; + const transform = `translate(${tx},${ty})`; + + label = ( + + {this.props.label} + + ); + + // By default, if a view generator is set, it takes precedence over any svg image url + if (this.props.viewGenerator && !this.props.overrideGlobalViewGenerator) { + node = ( + + +
+ {this.props.viewGenerator(this.props)} +
+
+
+ ); + } else { + node = ; + } + + // svg offset transform regarding svg width/height + gtx -= tx; + gty -= ty; + } else { + if (!isSizeNumericalValue) { + logWarning("node.size should be a number when not using custom nodes."); + size = CONST.DEFAULT_NODE_SIZE; + } + nodeProps.d = nodeHelper.buildSvgSymbol(size, this.props.type); + nodeProps.fill = this.props.fill; + nodeProps.stroke = this.props.stroke; + nodeProps.strokeWidth = this.props.strokeWidth; + + label = {this.props.label}; + node = ; + } + + const gProps = { + className: this.props.className, + cx: this.props.cx, + cy: this.props.cy, + id: this.props.id, + transform: `translate(${gtx},${gty})`, + }; + + return ( + + {node} + {this.props.renderLabel && label} + + ); + } +} diff --git a/react-app/src/utils/graph/generate-data.js b/react-app/src/utils/graph/generate-data.js index 1b83d3b..c7fbc0b 100644 --- a/react-app/src/utils/graph/generate-data.js +++ b/react-app/src/utils/graph/generate-data.js @@ -15,9 +15,9 @@ function generateNodes(height, width) { for (let i in nodes) { let node = nodes[i]; console.log(node); - node.x = width/2; - node.y = Math.random()*(width-200) + 100; - node.y = 100 + (i * ((height-200)/nodes.length)); + node.x = width / 2; + node.y = Math.random() * (width - 200) + 100; + node.y = 100 + (i * ((height - 200) / nodes.length)); // node.color = "green"; -> only have this if node has no prereqs } return data; diff --git a/react-app/src/utils/graph/generate-events.js b/react-app/src/utils/graph/generate-events.js index 17cd5b7..59059a2 100644 --- a/react-app/src/utils/graph/generate-events.js +++ b/react-app/src/utils/graph/generate-events.js @@ -1,7 +1,47 @@ -export function onClickNode (nodeId) { - window.alert(`Clicked node ${nodeId}`); -}; // function which defines what happens to a node when clicked +// onClickNode(this.props.id) -> what happens to a node when clicked (show course name and description) +export function onClickNode(id, node) { + window.alert(`Clicked on ${node.dept_code} ${id}`); + // node.c = "green"; +}; +// onClick={() => this.props.onClick(i)} +// onRightClickNode(event, this.props.id) -> what happens to a node when right clicked: mark class as fulfilled (turn to green) +const funct = function(){console.log('hi')}; +export function onRightClickNode(f, id, node) { + window.alert(`Right clicked on ${node.dept_code} ${id}`); + funct(); +} -export function onClickLink (source, target) { +// onClickLink(this.props.source, this.props.target) -> what happens to a link when clicked (show src and dest course) +export function onClickLink(source, target) { window.alert(`Clicked link between ${source} and ${target}`) -}; \ No newline at end of file +}; + +// onRightClickLink(event, this.props.source, this.props.target) -> what happens to a link when right clicked: mark link as fulfilled (turn to green) +export function onRightClickLink(fd, source, target) { + window.alert(`Right clicked link between ${source} and ${target}`) +} + +// const reactRef = this; +// const onDoubleClickNode = function(nodeId) { +// let modData = { ...reactRef.state.data }; +// let selectNode = modData.nodes.filter(item => { +// return item.id === nodeId; +// }); +// selectNode.forEach(item => { +// if (item.color && item.color === "red") item.color = "blue"; +// else item.color = "red"; +// }); +// reactRef.setState({ data: modData }); +// }; + +// return ( +//
+//

Hello CodeSandbox

+// +//
+// ); \ No newline at end of file From c556397632f175d4e296801830c3da62fda25055 Mon Sep 17 00:00:00 2001 From: CalTsai77 Date: Sun, 25 Dec 2022 20:27:11 -0500 Subject: [PATCH 2/2] Add right click event, modify config, nodes with no prereqs become green, change color on click --- react-app/src/components/custom-graph.js | 3 +- react-app/src/utils/graph/generate-config.js | 12 ++++- react-app/src/utils/graph/generate-data.js | 5 ++ react-app/src/utils/graph/generate-events.js | 54 +++++--------------- 4 files changed, 30 insertions(+), 44 deletions(-) diff --git a/react-app/src/components/custom-graph.js b/react-app/src/components/custom-graph.js index df2bc78..b7a82ad 100644 --- a/react-app/src/components/custom-graph.js +++ b/react-app/src/components/custom-graph.js @@ -2,7 +2,7 @@ import { Graph } from "react-d3-graph" import useWindowDimensions from "../utils/window-dimensions" import generateConfig from "../utils/graph/generate-config" import generateData from "../utils/graph/generate-data" -import { onClickNode, onRightClickNode, onClickLink, onRightClickLink} from "../utils/graph/generate-events" +import {onClickNode, onRightClickNode, onClickLink} from "../utils/graph/generate-events" function CustomGraph() { const { height, width } = useWindowDimensions(); @@ -14,7 +14,6 @@ function CustomGraph() { onClickNode={onClickNode} onClickLink={onClickLink} onRightClickNode={onRightClickNode} - onRightClickLink={onRightClickLink} /> ; } diff --git a/react-app/src/utils/graph/generate-config.js b/react-app/src/utils/graph/generate-config.js index f43ae7c..1508440 100644 --- a/react-app/src/utils/graph/generate-config.js +++ b/react-app/src/utils/graph/generate-config.js @@ -5,11 +5,19 @@ export default function generateConfig(height, width) { height: height, width: width, d3 : { - gravity : -200 + gravity : -200, + linkLength: 125, + // disableLinkForce: true, }, node : { labelPosition: "center", - size: 600 + size: 1000, + fontSize: 9, + fontWeight: 'bold', // 'bold', 'bolder', 100, 900 + symbolType: 'circle', // 'diamond', 'square', 'star' + }, + link : { + color: 'black', } } // see https://danielcaldas.github.io/react-d3-graph/docs/#config-node } \ No newline at end of file diff --git a/react-app/src/utils/graph/generate-data.js b/react-app/src/utils/graph/generate-data.js index c7fbc0b..d87f9df 100644 --- a/react-app/src/utils/graph/generate-data.js +++ b/react-app/src/utils/graph/generate-data.js @@ -19,6 +19,11 @@ function generateNodes(height, width) { node.y = Math.random() * (width - 200) + 100; node.y = 100 + (i * ((height - 200) / nodes.length)); // node.color = "green"; -> only have this if node has no prereqs + if (node.prereq_count === 0) { + node.color = 'green'; + } else { + node.color = 'silver'; + } } return data; // return [ diff --git a/react-app/src/utils/graph/generate-events.js b/react-app/src/utils/graph/generate-events.js index 59059a2..382bd5c 100644 --- a/react-app/src/utils/graph/generate-events.js +++ b/react-app/src/utils/graph/generate-events.js @@ -1,47 +1,21 @@ // onClickNode(this.props.id) -> what happens to a node when clicked (show course name and description) export function onClickNode(id, node) { - window.alert(`Clicked on ${node.dept_code} ${id}`); - // node.c = "green"; + window.alert(`Clicked on ${node.dept_code} ${id}, will turn green`); + node.color = "green"; + console.log('Clicked on ' + node.dept_code + ' ' + id + ' with color: ' + node.color); }; -// onClick={() => this.props.onClick(i)} + // onRightClickNode(event, this.props.id) -> what happens to a node when right clicked: mark class as fulfilled (turn to green) -const funct = function(){console.log('hi')}; -export function onRightClickNode(f, id, node) { - window.alert(`Right clicked on ${node.dept_code} ${id}`); - funct(); +export function onRightClickNode(event, id, node) { + window.alert(`Right clicked on ${node.dept_code} ${id}, will turn orange`); + node.color = 'orange'; + console.log('Right clicked on ' + node.dept_code + ' ' + id + ' with color: ' + node.color); } // onClickLink(this.props.source, this.props.target) -> what happens to a link when clicked (show src and dest course) -export function onClickLink(source, target) { - window.alert(`Clicked link between ${source} and ${target}`) -}; - -// onRightClickLink(event, this.props.source, this.props.target) -> what happens to a link when right clicked: mark link as fulfilled (turn to green) -export function onRightClickLink(fd, source, target) { - window.alert(`Right clicked link between ${source} and ${target}`) -} - -// const reactRef = this; -// const onDoubleClickNode = function(nodeId) { -// let modData = { ...reactRef.state.data }; -// let selectNode = modData.nodes.filter(item => { -// return item.id === nodeId; -// }); -// selectNode.forEach(item => { -// if (item.color && item.color === "red") item.color = "blue"; -// else item.color = "red"; -// }); -// reactRef.setState({ data: modData }); -// }; - -// return ( -//
-//

Hello CodeSandbox

-// -//
-// ); \ No newline at end of file +export function onClickLink(source, target, link) { + window.alert(`Clicked link between ${source} and ${target}, will turn red`) + // this.color = 'red'; + console.log('Clicked on link between ' + source + ' and ' + target + ' with color: '); + // Trying to figure out how to change link color but don't think it's possible +}; \ No newline at end of file