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..b47df40 100644 --- a/src/components/drawing.tsx +++ b/src/components/drawing.tsx @@ -20,20 +20,28 @@ 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: _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 setSelectedNodeId = useCallback((id?: string, skipToggle?: boolean) => { + if (drawingMode === "select") { + _setSelectedNodeId(id, skipToggle); + } + }, [drawingMode, _setSelectedNodeId]); const sidebarRef = useRef(null); @@ -186,7 +194,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 +233,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 +287,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 +97,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 +235,84 @@ 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; + } + + const connectedNodeIds = graph.edges + .filter(e => e.from === selectedNodeId || e.to === selectedNodeId) + .map(e => e.from === selectedNodeId ? e.to: e.from) + .concat(selectedNodeId); + + // highlight selected node + svg + .selectAll("g") + .selectAll("ellipse") + .style("opacity", unselectedOpacity) + .filter((d: any) => connectedNodeIds.includes(d.id)) + .style("opacity", 1) + .filter((d: any) => selectedNodeId === d.id) + .attr("fill", selectedNodeColor); + + // make all lines have the unselected opacity + svg + .selectAll("line") + .style("opacity", unselectedOpacity) + .attr("marker-end", unselectedArrowUrl); + + // 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) + .attr("marker-end", unselectedLoopArrowUrl) + .filter((d: any) => selectedNodeId === d.id) + .attr("stroke", selectedLoopArrowColor) + .attr("stroke-dasharray", "") + .attr("marker-end", selectedLoopArrowUrl) + .style("opacity", 1); + + // highlight text + svg + .selectAll("g") + .selectAll("text") + .style("opacity", unselectedOpacity) + .filter((d: any) => connectedNodeIds.includes(d.id)) + .style("opacity", 1); + + + }, [selectedNodeId, animating, graph]); // calculate the svg dimensions useEffect(() => { @@ -297,36 +384,34 @@ 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, opacity?: number) => { + 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) + .style("fill-opacity", opacity ?? 1) + .style("stroke-opacity", opacity ?? 1); + }; - 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("selectedLoopArrow", selectedLoopArrowColor); + addArrowMarker("unselectedArrow", "black", unselectedOpacity); + addArrowMarker("unselectedLoopArrow", "black", unselectedOpacity / lineAndLoopOpacity); // 0 // draw nodes const nodes = svg @@ -339,9 +424,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 +471,21 @@ 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; + const now = Date.now(); + const timeDiff = now - (lastClickTimeRef.current ?? 0); + const sameNode = lastClickIdRef.current === d.id; + const withinDoubleClickTime = timeDiff <= 250; + const skipToggle = withinDoubleClickTime && d.id === selectedNodeId; + + lastClickTimeRef.current = now; + lastClickIdRef.current = d.id; + + if (withinDoubleClickTime && sameNode) { + setSelectedNodeId(d.id, true); onNodeDoubleClick?.(d.id); } else { - waitForDoubleRef.current = setTimeout(() => { - onNodeClick?.(d.id); - waitForDoubleRef.current = undefined; - }, 250); + setSelectedNodeId(d.id, skipToggle); + onNodeClick?.(d.id); } }) ; @@ -492,14 +585,14 @@ export const Graph = (props: Props) => { .append("line") .attr("class", "edge") .attr("stroke", "#999") - .attr("stroke-opacity", 0.6) + .attr("stroke-opacity", lineAndLoopOpacity) .attr("stroke-width", d => d.weight) .attr("stroke-dasharray", d => lineDashArray(d)) .attr("x1", d => d.sourceX) .attr("x2", d => d.targetX) .attr("y1", d => d.sourceY) .attr("y2", d => d.targetY) - .attr("marker-end", "url(#arrow)") + .attr("marker-end", arrowUrl) .attr("style", drawingMode === "delete" ? "pointer-events: none" : "") .on("click", (e, d) => { // this is not really needed as the pointer events are off @@ -534,10 +627,10 @@ export const Graph = (props: Props) => { .attr("class", "loop") .attr("d", nodeLoopPath) .attr("stroke", "#999") - .attr("stroke-opacity", 0.6) + .attr("stroke-opacity", lineAndLoopOpacity) .attr("fill-opacity", 0) .attr("stroke-width", d => d.loopWeight) - .attr("marker-end", "url(#arrow)") + .attr("marker-end", arrowUrl) .attr("style", loopStyle) .on("click", (e, d) => { onNodeClick?.(d.id, true); @@ -554,13 +647,13 @@ export const Graph = (props: Props) => { .append("line") .attr("class", "rubberband") .attr("stroke", "#999") - .attr("stroke-opacity", 0.6) + .attr("stroke-opacity", lineAndLoopOpacity) .attr("stroke-width", 2) .attr("x1", d => d.x1) .attr("x2", d => d.x2) .attr("y1", d => d.y1) .attr("y2", d => d.y2) - .attr("marker-end", "url(#arrow)"); + .attr("marker-end", arrowUrl); // add loopback "ghost" with background if (!rubberBandNode.loops) { @@ -588,11 +681,11 @@ export const Graph = (props: Props) => { .attr("class", "ghost-loop") .attr("d", nodeLoopPath) .attr("stroke", "#999") - .attr("stroke-opacity", 0.6) + .attr("stroke-opacity", lineAndLoopOpacity) .attr("stroke-dasharray", 4) .attr("fill-opacity", 0) .attr("stroke-width", 1) - .attr("marker-end", "url(#arrow)") + .attr("marker-end", arrowUrl) .attr("style", "cursor: pointer") .on("click", () => { onNodeClick?.(rubberBandNode.id); @@ -601,8 +694,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,35 +718,35 @@ 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 .selectAll("line") .attr("stroke", "#999") .attr("stroke-dasharray", (d: any) => lineDashArray(d)) - .attr("marker-end", "url(#arrow)") + .attr("marker-end", arrowUrl) .filter((d: any) => (( (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") .attr("stroke", "#999") .attr("stroke-dasharray", "") - .attr("marker-end", "url(#arrow)") + .attr("marker-end", arrowUrl) .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 +767,6 @@ export const Graph = (props: Props) => {