From e9129ad930e94a927011da8eafc5db2c0720dd35 Mon Sep 17 00:00:00 2001 From: Rob Gordon Date: Fri, 21 Apr 2023 11:39:06 -0400 Subject: [PATCH 1/5] Initial 'fixed' node effect --- app/package.json | 2 +- app/src/components/Graph.tsx | 64 +++++++++++++++++++++++++++--------- app/src/lib/getLayout.ts | 2 +- app/src/lib/parsers.ts | 19 ++++++++++- pnpm-lock.yaml | 8 ++--- 5 files changed, 72 insertions(+), 23 deletions(-) diff --git a/app/package.json b/app/package.json index dddc8fc6..e6e227a7 100644 --- a/app/package.json +++ b/app/package.json @@ -81,7 +81,7 @@ "eslint": "^8.3.0", "file-saver": "^2.0.5", "framer-motion": "^4.1.17", - "graph-selector": "^0.8.6", + "graph-selector": "^0.9.1", "gray-matter": "^4.0.2", "highlight.js": "^11.7.0", "immer": "^9.0.16", diff --git a/app/src/components/Graph.tsx b/app/src/components/Graph.tsx index 666a4bf9..6421e535 100644 --- a/app/src/components/Graph.tsx +++ b/app/src/components/Graph.tsx @@ -3,6 +3,7 @@ import coseBilkent from "cytoscape-cose-bilkent"; import dagre from "cytoscape-dagre"; import klay from "cytoscape-klay"; import cytoscapeSvg from "cytoscape-svg"; +import { operate } from "graph-selector"; import throttle from "lodash.throttle"; import React, { memo, @@ -154,21 +155,40 @@ const Graph = memo(function Graph({ shouldResize }: { shouldResize: number }) { export default Graph; -function handleDragFree() { - const nodePositions = getNodePositionsFromCy(); - useDoc.setState( - (state) => { - return { - ...state, - meta: { - ...state.meta, - nodePositions, - }, - }; - }, - false, - "Graph/handleDragFree" - ); +function handleDragFree(event: cytoscape.EventObject) { + const { target } = event; + const position = target.position() as { x: number; y: number }; + const lineNumber = target.data("lineNumber"); + const text = useDoc.getState().text; + + // add "fixed" class if it doesn't exist + let newText = operate(text, { + lineNumber, + operation: ["addClassesToNode", { classNames: ["fixed"] }], + }); + // add x and y data attributes + newText = operate(newText, { + lineNumber, + operation: [ + "addDataAttributeToNode", + { name: "x", value: round(position.x) }, + ], + }); + newText = operate(newText, { + lineNumber, + operation: [ + "addDataAttributeToNode", + { name: "y", value: round(position.y) }, + ], + }); + useDoc.setState({ text: newText }, false, "Graph/handleDragFree"); +} + +/** + * This function is used to round numbers to 2 decimal places + */ +function round(num: number) { + return Math.round(num * 100) / 100; } /** @@ -197,6 +217,10 @@ function useInitializeGraph({ wheelSensitivity: 0.2, boxSelectionEnabled: true, // autoungrabify: true, + // DEFAULT LAYOUT MUST BE PRESET TO SUPPORT "FIXED" NODES + layout: { + name: "preset", + }, }); window.__cy = cy.current; const cyCurrent = cy.current; @@ -332,14 +356,22 @@ function getGraphUpdater({ isGraphInitialized.current && elements.length < 200 && isAnimationEnabled; + cy.current.elements; + cy.current + .elements("*") + .difference(".fixed") .layout({ animate: shouldAnimate, animationDuration: shouldAnimate ? 333 : 0, ...layout, padding: DEFAULT_GRAPH_PADDING, + fit: false, }) - .run(); + .run() + .listen("layoutstop", () => { + cy.current?.fit(undefined, DEFAULT_GRAPH_PADDING); + }); // Reinitialize to avoid missing errors cyErrorCatcher.current.destroy(); diff --git a/app/src/lib/getLayout.ts b/app/src/lib/getLayout.ts index bb9c5d28..8d7eb8f9 100644 --- a/app/src/lib/getLayout.ts +++ b/app/src/lib/getLayout.ts @@ -2,7 +2,7 @@ import { Doc } from "./useDoc"; export const defaultLayout: any = { name: "dagre", - fit: true, + // fit: true, animate: true, spacingFactor: 1.25, }; diff --git a/app/src/lib/parsers.ts b/app/src/lib/parsers.ts index 1470c945..0136865a 100644 --- a/app/src/lib/parsers.ts +++ b/app/src/lib/parsers.ts @@ -47,13 +47,30 @@ export function universalParse( ); } - return { + let node = { ...element, data: { ...element.data, ...size, }, }; + + // if class "fixed" and x & y are set, add position to node + if ( + element.classes?.includes("fixed") && + "x" in element.data && + "y" in element.data + ) { + node = { + ...node, + position: { + x: element.data.x, + y: element.data.y, + }, + }; + } + + return node; }); case "v1": return parseText(stripComments(text), getSize); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60027057..d0fed503 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,8 +249,8 @@ importers: specifier: ^4.1.17 version: 4.1.17(react-dom@17.0.2)(react@17.0.2) graph-selector: - specifier: ^0.8.6 - version: 0.8.6 + specifier: ^0.9.1 + version: 0.9.1 gray-matter: specifier: ^4.0.2 version: 4.0.3 @@ -9296,8 +9296,8 @@ packages: '@tone-row/strip-comments': 2.0.6 dev: false - /graph-selector@0.8.6: - resolution: {integrity: sha512-w8QSWtA/HNv6kVzdZaOO4A/zsRbKzBRl22Kc3ke4S8NlI+HEVF32wfncboNHr+/tB+NTObrvQh4GIke1pqVjAg==} + /graph-selector@0.9.1: + resolution: {integrity: sha512-ylfAyrSNLOx/qPqaoC+SKaywfN0a6NdwwFkyBnitfMcdOodfSytfDj343WgdbA2T/KQWr3+/gSXwL4mHLLU2Hg==} dependencies: '@tone-row/strip-comments': 2.0.6 html-entities: 2.3.3 From 9884c7e272a9a46931b8830737fa600db027c27d Mon Sep 17 00:00:00 2001 From: Rob Gordon Date: Fri, 21 Apr 2023 12:20:21 -0400 Subject: [PATCH 2/5] Fix layout test --- app/src/lib/getLayout.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/lib/getLayout.test.ts b/app/src/lib/getLayout.test.ts index aebacf82..09cf8ece 100644 --- a/app/src/lib/getLayout.test.ts +++ b/app/src/lib/getLayout.test.ts @@ -7,7 +7,6 @@ describe("getLayout", () => { const layout = getLayout(doc); expect(layout).toEqual({ name: "dagre", - fit: true, animate: true, spacingFactor: 1.25, rankDir: "TB", From 08d577b1db1f316ddc241a370a658ab645f719d4 Mon Sep 17 00:00:00 2001 From: Rob Gordon Date: Tue, 25 Apr 2023 10:04:46 -0400 Subject: [PATCH 3/5] Bring back frozen toggle & func --- app/src/components/Graph.tsx | 74 +++++++++++++++-------- app/src/components/GraphFloatingMenu.tsx | 19 +++--- app/src/components/Tabs/EditLayoutTab.tsx | 44 +++++++++++++- app/src/lib/getLayout.ts | 24 +++++++- app/src/lib/graphOptions.ts | 5 +- app/src/lib/parsers.ts | 17 +++--- app/src/lib/useIsFrozen.ts | 33 +++++++--- 7 files changed, 156 insertions(+), 60 deletions(-) diff --git a/app/src/components/Graph.tsx b/app/src/components/Graph.tsx index 6421e535..c4bd40b7 100644 --- a/app/src/components/Graph.tsx +++ b/app/src/components/Graph.tsx @@ -20,7 +20,11 @@ import { useDebouncedCallback } from "use-debounce"; import { buildStylesForGraph } from "../lib/buildStylesForGraph"; import { cytoscape } from "../lib/cytoscape"; import { getGetSize, TGetSize } from "../lib/getGetSize"; -import { getLayout } from "../lib/getLayout"; +import { + defaultLayout, + getLayout, + validLayoutsForFixedNodes, +} from "../lib/getLayout"; import { getUserStyle } from "../lib/getUserStyle"; import { DEFAULT_GRAPH_PADDING } from "../lib/graphOptions"; import { @@ -36,6 +40,7 @@ import { useContextMenuState } from "../lib/useContextMenuState"; import { Doc, useDoc, useParseError } from "../lib/useDoc"; import { useGraphStore } from "../lib/useGraphStore"; import { useHoverLine } from "../lib/useHoverLine"; +import { getIsFrozen } from "../lib/useIsFrozen"; import { Box } from "../slang"; import { getNodePositionsFromCy } from "./getNodePositionsFromCy"; import styles from "./Graph.module.css"; @@ -159,29 +164,44 @@ function handleDragFree(event: cytoscape.EventObject) { const { target } = event; const position = target.position() as { x: number; y: number }; const lineNumber = target.data("lineNumber"); + const id = target.id(); const text = useDoc.getState().text; + const isFrozen = getIsFrozen(); + + // get the current layout name + const layoutName = useGraphStore.getState().layout.name ?? ""; + + // change layout if it's not valid with fixed nodes + if (!validLayoutsForFixedNodes.includes(layoutName)) return; + + let newText = text; + + // only add fixed class if everything isn't frozen + if (!isFrozen) { + newText = operate(text, { + lineNumber, + operation: ["addClassesToNode", { classNames: ["fixed"] }], + }); + } - // add "fixed" class if it doesn't exist - let newText = operate(text, { - lineNumber, - operation: ["addClassesToNode", { classNames: ["fixed"] }], - }); - // add x and y data attributes - newText = operate(newText, { - lineNumber, - operation: [ - "addDataAttributeToNode", - { name: "x", value: round(position.x) }, - ], - }); - newText = operate(newText, { - lineNumber, - operation: [ - "addDataAttributeToNode", - { name: "y", value: round(position.y) }, - ], - }); - useDoc.setState({ text: newText }, false, "Graph/handleDragFree"); + // update x and y in meta + useDoc.setState( + (state) => { + return { + ...state, + text: newText, + meta: { + ...state.meta, + nodePositions: { + ...(state.meta?.nodePositions ?? {}), + [id]: { x: round(position.x), y: round(position.y) }, + }, + }, + }; + }, + false, + "Graph/handleDragFree" + ); } /** @@ -358,9 +378,13 @@ function getGraphUpdater({ isAnimationEnabled; cy.current.elements; - cy.current - .elements("*") - .difference(".fixed") + // If not using a layout which supports individually frozen + // nodes then run the layout on all nodes + const selection = validLayoutsForFixedNodes.includes(layout.name) + ? cy.current.elements("*").difference(".fixed") + : cy.current; + + selection .layout({ animate: shouldAnimate, animationDuration: shouldAnimate ? 333 : 0, diff --git a/app/src/components/GraphFloatingMenu.tsx b/app/src/components/GraphFloatingMenu.tsx index 7b6811e1..db65b43f 100644 --- a/app/src/components/GraphFloatingMenu.tsx +++ b/app/src/components/GraphFloatingMenu.tsx @@ -5,7 +5,7 @@ import { FaBomb, FaRegSnowflake } from "react-icons/fa"; import { MdFitScreen } from "react-icons/md"; import { DEFAULT_GRAPH_PADDING } from "../lib/graphOptions"; -import { unfreezeDoc, useIsFrozen } from "../lib/useIsFrozen"; +import { toggleDocFrozen, useIsFrozen } from "../lib/useIsFrozen"; import { useUnmountStore } from "../lib/useUnmountStore"; import { Tooltip } from "./Shared"; @@ -58,13 +58,12 @@ export function GraphFloatingMenu() { }); }} /> - {isFrozen ? ( - } - label={t`Unfreeze`} - onClick={unfreezeDoc} - /> - ) : null} + } + label={isFrozen ? t`Unfreeze` : t`Freeze`} + onClick={toggleDocFrozen} + data-state-active={isFrozen ? true : false} + /> ); } @@ -82,7 +81,9 @@ function CustomIconButton({ icon, label, ...props }: CustomIconButtonProps) { return ( ); } + +function FixedNodesWarning() { + return ( +
+
+
+ +

Contains Fixed Nodes

+
+

+ Your graph contains nodes with class fixed. Fixed nodes only + work correctly when using basic, deterministic layouts.{" "} + + . +

+
+
+ ); +} diff --git a/app/src/lib/getLayout.ts b/app/src/lib/getLayout.ts index 8d7eb8f9..f5e38ce4 100644 --- a/app/src/lib/getLayout.ts +++ b/app/src/lib/getLayout.ts @@ -58,12 +58,16 @@ export function getLayout(doc: Doc) { ...layout, }; - // Apply the preset layout if nodePositions is defined - if (meta?.nodePositions && typeof meta.nodePositions === "object") { - layoutToReturn.positions = { ...meta.nodePositions }; + // if isFrozen, change to preset layout + if (meta.isFrozen) { layoutToReturn.name = "preset"; } + // Forward nodePositions onto layout + if (meta.nodePositions && typeof meta.nodePositions === "object") { + layoutToReturn.positions = { ...meta.nodePositions }; + } + // Remove spacingFactor if using preset layout if (layoutToReturn.name === "preset" && layoutToReturn.spacingFactor) { delete layoutToReturn.spacingFactor; @@ -71,3 +75,17 @@ export function getLayout(doc: Doc) { return layoutToReturn; } + +/** + * Not all auto-layouts work when individual nodes are frozen + * + * Store the list of layout names that are valid with partially frozen nodes */ +export const validLayoutsForFixedNodes = [ + "dagre", + "klay", + "breadthfirst", + "concentric", + "circle", + "grid", + "preset", +]; diff --git a/app/src/lib/graphOptions.ts b/app/src/lib/graphOptions.ts index ca53a301..4360c292 100644 --- a/app/src/lib/graphOptions.ts +++ b/app/src/lib/graphOptions.ts @@ -16,14 +16,13 @@ export const layouts: SelectOption[] = [ { label: () => `Dagre`, value: "dagre" }, { label: () => `Klay`, value: "klay" }, { label: () => t`Breadthfirst`, value: "breadthfirst" }, - { label: () => `CoSE`, value: "cose" }, { label: () => t`Concentric`, value: "concentric" }, { label: () => t`Circle`, value: "circle" }, - { label: () => t`Random`, value: "random" }, { label: () => t`Grid`, value: "grid" }, + // Non-deterministic layouts + { label: () => `CoSE`, value: "cose" }, // Elk layouts { label: () => "Box", value: "elk-box", sponsorOnly: true }, - { label: () => "Force", value: "elk-force", sponsorOnly: true }, { label: () => "Layered", value: "elk-layered", sponsorOnly: true }, { label: () => "Tree", value: "elk-mrtree", sponsorOnly: true }, { label: () => "Stress", value: "elk-stress", sponsorOnly: true }, diff --git a/app/src/lib/parsers.ts b/app/src/lib/parsers.ts index 0136865a..cf2de608 100644 --- a/app/src/lib/parsers.ts +++ b/app/src/lib/parsers.ts @@ -32,7 +32,9 @@ export function universalParse( getSize: TGetSize ): ElementDefinition[] { switch (parser) { - case "graph-selector": + case "graph-selector": { + const nodePositions = (useDoc.getState().meta?.nodePositions ?? + {}) as Record; return toCytoscapeElements(parse(text)).map((element) => { let size: Record = {}; if ("w" in element.data || "h" in element.data) { @@ -56,22 +58,17 @@ export function universalParse( }; // if class "fixed" and x & y are set, add position to node - if ( - element.classes?.includes("fixed") && - "x" in element.data && - "y" in element.data - ) { + const id = element.data.id; + if (id && element.classes?.includes("fixed") && nodePositions[id]) { node = { ...node, - position: { - x: element.data.x, - y: element.data.y, - }, + position: nodePositions[id], }; } return node; }); + } case "v1": return parseText(stripComments(text), getSize); default: diff --git a/app/src/lib/useIsFrozen.ts b/app/src/lib/useIsFrozen.ts index e3ccd2d5..0d9b2719 100644 --- a/app/src/lib/useIsFrozen.ts +++ b/app/src/lib/useIsFrozen.ts @@ -1,13 +1,20 @@ import produce from "immer"; -import { getLayout } from "./getLayout"; +import { getNodePositionsFromCy } from "../components/getNodePositionsFromCy"; import { useDoc } from "./useDoc"; +import { useGraphStore } from "./useGraphStore"; -export function unfreezeDoc() { +export function toggleDocFrozen() { useDoc.setState( (state) => { return produce(state, (draft) => { - delete draft.meta.nodePositions; + if (draft.meta.isFrozen) { + delete draft.meta.isFrozen; + } else { + draft.meta.isFrozen = true; + // get node positions + draft.meta.nodePositions = getNodePositionsFromCy(); + } }); }, false, @@ -15,10 +22,22 @@ export function unfreezeDoc() { ); } +/** + * Whether the graph is fully frozen + */ export function useIsFrozen() { - const doc = useDoc(); - const rendered = getLayout(doc); - const frozen = "positions" in rendered; + return useDoc((state) => state.meta?.isFrozen ?? false); +} + +export function getIsFrozen() { + return useDoc.getState().meta?.isFrozen ?? false; +} - return frozen; +/** + * Whether the graph has individually-fixed nodes in it + */ +export function useHasFixedNodes() { + const elements = useGraphStore((state) => state.elements); + // check if any have the class fixed + return elements.some((el) => el.classes?.includes("fixed")); } From 3f3a822d6ad6358fe274610987cee5628a682a68 Mon Sep 17 00:00:00 2001 From: Rob Gordon Date: Tue, 25 Apr 2023 10:06:35 -0400 Subject: [PATCH 4/5] Add Frozen layout warning back --- app/src/components/Tabs/EditLayoutTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/components/Tabs/EditLayoutTab.tsx b/app/src/components/Tabs/EditLayoutTab.tsx index 961b79be..426ede07 100644 --- a/app/src/components/Tabs/EditLayoutTab.tsx +++ b/app/src/components/Tabs/EditLayoutTab.tsx @@ -71,7 +71,7 @@ export function EditLayoutTab() { spacingFactor = layout.spacingFactor; } - // if (isFrozen) return ; + if (isFrozen) return ; return ( From 0cf668c612cd7b71e6cc0d71919023e4d0f28a46 Mon Sep 17 00:00:00 2001 From: Rob Gordon Date: Tue, 25 Apr 2023 10:19:59 -0400 Subject: [PATCH 5/5] Fix getLayout test --- app/src/lib/getLayout.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/lib/getLayout.test.ts b/app/src/lib/getLayout.test.ts index 09cf8ece..e40b56e9 100644 --- a/app/src/lib/getLayout.test.ts +++ b/app/src/lib/getLayout.test.ts @@ -29,12 +29,13 @@ describe("getLayout", () => { expect(layout.elk).toEqual({ algorithm: "mrtree" }); }); - test("moves nodePositions into positions and makes layout 'preset'", () => { + test("makes layout 'preset' if isFrozen", () => { const doc = { ...initialDoc, meta: { layout: { name: "random" }, nodePositions: { a: { x: 1, y: 2 } }, + isFrozen: true, }, }; const layout = getLayout(doc);