diff --git a/src/components/app.tsx b/src/components/app.tsx index d27b4ea..74f9874 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -1,9 +1,9 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { clsx } from "clsx"; import { useCODAP } from "../hooks/use-codap"; import { useGraph } from "../hooks/use-graph"; -import { Graph, orangeColor } from "./graph"; +import { Graph } from "./graph"; import { useGenerator } from "../hooks/use-generator"; import { Edge, Node } from "../type"; import { Drawing } from "./drawing"; @@ -58,11 +58,11 @@ export const App = () => { const [delimiter, setDelimiter] = useState(""); const [startingState, setStartingState] = useState(""); const [sequenceGroups, setSequenceGroups] = useState([]); + const [selectedNodeId, _setSelectedNodeId] = useState(); const [highlightNode, setHighlightNode] = useState(); const [highlightLoopOnNode, setHighlightLoopOnNode] = useState(); const [highlightEdge, setHighlightEdge] = useState(); const [highlightAllNextNodes, setHighlightAllNextNodes] = useState(false); - const [highlightColor, setHighlightColor] = useState(orangeColor); const [generationMode, setGenerationMode] = useState("ready"); const prevAnimatedSequenceGroups = useRef([]); const currentAnimatedSequenceGroup = useRef(); @@ -80,6 +80,20 @@ export const App = () => { const { generate } = useGenerator(); const innerOutputRef = useRef(null); + const animating = useMemo(() => { + return generationMode !== "ready"; + }, [generationMode]); + + const setSelectedNodeId = useCallback((id?: string, skipToggle?: boolean) => { + if (!animating) { + if ((!id || (id === selectedNodeId)) && !skipToggle) { + _setSelectedNodeId(undefined); + } else { + _setSelectedNodeId(id); + } + } + }, [_setSelectedNodeId, selectedNodeId, animating]); + useEffect(() => { if (viewMode === "drawing") { notifyStateIsDirty(); @@ -133,7 +147,6 @@ export const App = () => { if (inBeforeStep()) { setHighlightNode(currentNode); setHighlightEdge(undefined); - setHighlightColor(orangeColor); // highlight all the possible edges if we have a next node setHighlightAllNextNodes(!!nextNode); setHighlightLoopOnNode(currentNode); @@ -143,7 +156,6 @@ export const App = () => { : undefined; setHighlightEdge(edge); setHighlightNode(nextNode); - setHighlightColor(orangeColor); setHighlightAllNextNodes(false); setHighlightLoopOnNode(nextNode === currentNode ? nextNode : undefined); } @@ -408,23 +420,27 @@ export const App = () => { highlightNode={highlightNode} highlightLoopOnNode={highlightLoopOnNode} highlightEdge={highlightEdge} - highlightColor={highlightColor} highlightAllNextNodes={highlightAllNextNodes} + selectedNodeId={selectedNodeId} + animating={animating} setGraph={setGraph} setHighlightNode={setHighlightNode} + setSelectedNodeId={setSelectedNodeId} /> : + mode="dataset" + graph={graph} + highlightNode={highlightNode} + highlightLoopOnNode={highlightLoopOnNode} + highlightEdge={highlightEdge} + highlightAllNextNodes={highlightAllNextNodes} + selectedNodeId={selectedNodeId} + animating={animating} + allowDragging={true && !animating} + autoArrange={true} + setSelectedNodeId={setSelectedNodeId} + /> }
diff --git a/src/components/drawing.tsx b/src/components/drawing.tsx index e07e48e..cc9f17f 100644 --- a/src/components/drawing.tsx +++ b/src/components/drawing.tsx @@ -20,20 +20,22 @@ interface Props { highlightNode?: Node, highlightLoopOnNode?: Node, highlightEdge?: Edge, - highlightColor: string highlightAllNextNodes: boolean; graph: GraphData; + selectedNodeId?: string; + animating: boolean; setGraph: React.Dispatch>; setHighlightNode: React.Dispatch> + setSelectedNodeId: (id?: string, skipToggle?: boolean) => void; } export const Drawing = (props: Props) => { - const {highlightNode, highlightLoopOnNode, highlightEdge, highlightColor, highlightAllNextNodes, - graph, setGraph, setHighlightNode} = props; + const {highlightNode, highlightLoopOnNode, highlightEdge, highlightAllNextNodes, + graph, setGraph, setHighlightNode, setSelectedNodeId, selectedNodeId, animating} = props; const [drawingMode, setDrawingMode] = useState("select"); const [firstEdgeNode, setFirstEdgeNode] = useState(undefined); const [rubberBand, setRubberBand] = useState(undefined); - const [selectedNode, setSelectedNode] = useState(undefined); + const [selectedNodeForModal, setSelectedNodeForModal] = useState(undefined); const sidebarRef = useRef(null); @@ -186,7 +188,7 @@ export const Drawing = (props: Props) => { const handleNodeDoubleClicked = useCallback((id: string) => { if (drawingMode === "select") { - setSelectedNode(getNode(id)); + setSelectedNodeForModal(getNode(id)); } if (drawingMode === "addEdge") { addEdge({from: id, to: id}); @@ -225,7 +227,7 @@ export const Drawing = (props: Props) => { }); }, [setGraph]); - const handleClearSelectedNode = useCallback(() => setSelectedNode(undefined), [setSelectedNode]); + const handleClearSelectedNode = useCallback(() => setSelectedNodeForModal(undefined), [setSelectedNodeForModal]); const handleChangeNode = useCallback((id: string, newNode: Node, newEdges: Edge[]) => { setGraph(prev => { @@ -279,22 +281,24 @@ export const Drawing = (props: Props) => { graph={graph} highlightNode={highlightNode} highlightEdge={highlightEdge} - highlightColor={highlightColor} highlightAllNextNodes={highlightAllNextNodes} highlightLoopOnNode={highlightLoopOnNode} - allowDragging={drawingMode === "select"} + allowDragging={drawingMode === "select" && !animating} autoArrange={false} rubberBand={rubberBand} + selectedNodeId={selectedNodeId} + animating={animating} onClick={handleClicked} onMouseUp={handleMouseUp} onNodeClick={handleNodeClicked} onNodeDoubleClick={handleNodeDoubleClicked} onEdgeClick={handleEdgeClicked} onDragStop={handleDragStop} + setSelectedNodeId={setSelectedNodeId} /> ) => void; onMouseUp?: (e: React.MouseEvent) => void; onNodeClick?: (id: string, onLoop?: boolean) => void; onNodeDoubleClick?: (id: string) => void; onEdgeClick?: (options: {from: string, to: string}) => void; onDragStop?: (id: string, pos: Point) => void; + setSelectedNodeId: (id?: string, skipToggle?: boolean) => void; }; type D3Node = { @@ -77,8 +92,6 @@ type FindPointOnEllipseArgs = { x1: number, y1: number, x2: number, y2: number, cx: number, cy: number, a: number, b: number, angleDelta: number }; -export const orangeColor = "#FF9900"; - const startLoopAngle = 0.25 * Math.PI; const endLoopAngle = 1.75 * Math.PI; const bidirectionalEdgeAngle = 10 * (Math.PI / 180); @@ -217,15 +230,76 @@ const lineDashArray = (edge: D3Edge) => edge.value ? "" : "4"; export const Graph = (props: Props) => { const {graph, highlightNode, highlightLoopOnNode, highlightEdge, highlightAllNextNodes, - highlightColor, allowDragging, autoArrange, mode, rubberBand, drawingMode, - onClick, onMouseUp, onNodeClick, onNodeDoubleClick, onEdgeClick, onDragStop} = props; + allowDragging, autoArrange, mode, rubberBand, drawingMode, + onClick, onMouseUp, onNodeClick, onNodeDoubleClick, onEdgeClick, onDragStop, + selectedNodeId, setSelectedNodeId, animating} = props; const svgRef = useRef(null); const wrapperRef = useRef(null); const dimensions = useResizeObserver(wrapperRef); const [width, setWidth] = useState(0); const [height, setHeight] = useState(0); const [d3Graph, setD3Graph] = useState({nodes: [], edges: []}); - const waitForDoubleRef = useRef(undefined); + const lastClickTimeRef = useRef(undefined); + const lastClickIdRef = useRef(undefined); + const draggedRef = useRef(false); + + const highlightSelected = useCallback((svg: d3.Selection) => { + if (animating || !selectedNodeId) { + return; + } + + // highlight selected node + svg + .selectAll("g") + .selectAll("ellipse") + .style("opacity", unselectedOpacity) + .filter((d: any) => selectedNodeId === d.id) + .attr("fill", selectedNodeColor) + .style("opacity", 1); + + // make all lines have the unselected opacity + svg + .selectAll("line") + .style("opacity", unselectedOpacity); + + // highlight selected incoming edges + svg + .selectAll("line") + .filter((d: any) => (( + (d.value > 0) && (selectedNodeId === d.target?.id)))) + .attr("stroke", incomingArrowColor) + .attr("marker-end", incomingArrowUrl) + .style("opacity", 1); + + // highlight selected outgoing edges + svg + .selectAll("line") + .filter((d: any) => (( + (d.value > 0) && (selectedNodeId === d.source?.id)))) + .attr("stroke", outgoingArrowColor) + .attr("marker-end", outgoingArrowUrl) + .style("opacity", 1); + + // highlight loops + svg + .selectAll("path.loop") + .style("opacity", unselectedOpacity) + .filter((d: any) => selectedNodeId === d.id) + .attr("stroke", selectedLoopColor) + .attr("stroke-dasharray", "") + .attr("marker-end", selectedLoopUrl) + .style("opacity", 1); + + // highlight text + svg + .selectAll("g") + .selectAll("text") + .style("opacity", unselectedOpacity) + .filter((d: any) => selectedNodeId === d.id) + .style("opacity", 1); + + + }, [selectedNodeId, animating]); // calculate the svg dimensions useEffect(() => { @@ -297,36 +371,30 @@ export const Graph = (props: Props) => { // clear the existing items svg.selectAll("*").remove(); - // add edge arrows - svg - .append("svg:defs") - .append("svg:marker") - .attr("id", "arrow") - .attr("refX", 12) - .attr("refY", 6) - .attr("markerWidth", 30) - .attr("markerHeight", 30) - .attr("markerUnits","userSpaceOnUse") - .attr("orient", "auto") - .append("path") - .attr("d", "M 0 0 12 6 0 12 3 6 0 0") - .style("fill", "black"); + const addArrowMarker = (id: string, color: string) => { + svg + .append("svg:defs") + .append("svg:marker") + .attr("id", id) + .attr("refX", 12) + .attr("refY", 6) + .attr("markerWidth", 30) + .attr("markerHeight", 30) + .attr("markerUnits","userSpaceOnUse") + .attr("orient", "auto") + .append("path") + .attr("d", "M 0 0 12 6 0 12 3 6 0 0") + .attr("stroke", color) + .style("fill", color); + }; - svg - .append("svg:defs") - .append("svg:marker") - .attr("id", "highlightOrangeArrow") - .attr("refX", 12) - .attr("refY", 6) - .attr("markerWidth", 30) - .attr("markerHeight", 30) - .attr("markerUnits","userSpaceOnUse") - .attr("orient", "auto") - .append("path") - .attr("d", "M 0 0 12 6 0 12 3 6 0 0") - .attr("stroke", orangeColor) - .attr("stroke-width", 2) - .style("fill", orangeColor); + // add arrows markers + addArrowMarker("arrow", "black"); + addArrowMarker("loopArrow", "black"); + addArrowMarker("animatedArrow", animatedArrowColor); + addArrowMarker("incomingArrow", incomingArrowColor); + addArrowMarker("outgoingArrow", outgoingArrowColor); + addArrowMarker("selectedLoop", selectedLoopColor); // draw nodes const nodes = svg @@ -339,9 +407,11 @@ export const Graph = (props: Props) => { simulation?.alphaTarget(0.5).restart(); d.fx = d.x; d.fy = d.y; + draggedRef.current = false; }; const dragging = (event: any, d: any) => { + draggedRef.current = true; // simulation.alpha(0.5).restart() if (autoArrange) { d.fx = event.x; @@ -384,15 +454,20 @@ export const Graph = (props: Props) => { .attr("cy", d => d.y) .attr("style", drawingMode !== "addNode" ? "cursor: pointer" : "") .on("click", (e, d) => { - if (waitForDoubleRef.current) { - clearTimeout(waitForDoubleRef.current); - waitForDoubleRef.current = undefined; - onNodeDoubleClick?.(d.id); + const now = Date.now(); + const timeDiff = now - (lastClickTimeRef.current ?? 0); + const sameNode = lastClickIdRef.current === d.id; + + lastClickTimeRef.current = now; + lastClickIdRef.current = d.id; + + if (timeDiff <= 250) { + if (sameNode) { + onNodeDoubleClick?.(d.id); + } } else { - waitForDoubleRef.current = setTimeout(() => { - onNodeClick?.(d.id); - waitForDoubleRef.current = undefined; - }, 250); + setSelectedNodeId(d.id, true); + onNodeClick?.(d.id); } }) ; @@ -601,8 +676,10 @@ export const Graph = (props: Props) => { } } + highlightSelected(svg); + }, [svgRef, d3Graph, allowDragging, autoArrange, rubberBand, drawingMode, - onNodeClick, onNodeDoubleClick, onEdgeClick, onDragStop]); + onNodeClick, onNodeDoubleClick, onEdgeClick, onDragStop, setSelectedNodeId, selectedNodeId, highlightSelected]); // animate the node if needed useEffect(() => { @@ -623,9 +700,7 @@ export const Graph = (props: Props) => { .selectAll("g") .selectAll("ellipse") .filter((d: any) => highlightNode?.id === d.id) - .attr("fill", highlightColor); - - const arrowUrl = "url(#highlightOrangeArrow)"; + .attr("fill", animatedNodeColor); // highlight animated edges svg @@ -637,9 +712,9 @@ export const Graph = (props: Props) => { (d.value > 0) && ( (highlightNode?.id === d.source?.id && highlightAllNextNodes) || (highlightEdge?.from === d.source?.id && highlightEdge?.to === d.target?.id))))) - .attr("stroke", highlightColor) + .attr("stroke", animatedArrowColor) .attr("stroke-dasharray", highlightAllNextNodes ? "4" : "") - .attr("marker-end", arrowUrl); + .attr("marker-end", animatedArrowUrl); svg .selectAll("path.loop") @@ -647,11 +722,13 @@ export const Graph = (props: Props) => { .attr("stroke-dasharray", "") .attr("marker-end", "url(#arrow)") .filter((d: any) => highlightLoopOnNode?.id === d.id) - .attr("stroke", highlightColor) + .attr("stroke", animatedArrowColor) .attr("stroke-dasharray", highlightAllNextNodes ? "4" : "") - .attr("marker-end", arrowUrl); + .attr("marker-end", animatedArrowUrl); - }, [svgRef, d3Graph.nodes, highlightNode, highlightLoopOnNode, highlightEdge, highlightAllNextNodes, highlightColor]); + highlightSelected(svg); + }, [svgRef, d3Graph.nodes, selectedNodeId, highlightNode, highlightLoopOnNode, + highlightEdge, highlightAllNextNodes, highlightSelected]); const handleClick = useCallback((e: React.MouseEvent) => { if (!autoArrange && onClick) { @@ -672,7 +749,6 @@ export const Graph = (props: Props) => {