Skip to content

Freeze Individual Nodes #500

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 62 additions & 6 deletions app/src/components/Graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,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 {
Expand All @@ -35,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";
Expand Down Expand Up @@ -154,15 +160,42 @@ const Graph = memo(function Graph({ shouldResize }: { shouldResize: number }) {

export default Graph;

function handleDragFree() {
const nodePositions = getNodePositionsFromCy();
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"] }],
});
}

// update x and y in meta
useDoc.setState(
(state) => {
return {
...state,
text: newText,
meta: {
...state.meta,
nodePositions,
nodePositions: {
...(state.meta?.nodePositions ?? {}),
[id]: { x: round(position.x), y: round(position.y) },
},
},
};
},
Expand All @@ -171,6 +204,13 @@ function handleDragFree() {
);
}

/**
* This function is used to round numbers to 2 decimal places
*/
function round(num: number) {
return Math.round(num * 100) / 100;
}

/**
* This function sets up cytoscape and initializes the graph
* but it doesn't set
Expand All @@ -197,6 +237,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;
Expand Down Expand Up @@ -332,14 +376,26 @@ function getGraphUpdater({
isGraphInitialized.current &&
elements.length < 200 &&
isAnimationEnabled;
cy.current
cy.current.elements;

// 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,
...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();
Expand Down
19 changes: 10 additions & 9 deletions app/src/components/GraphFloatingMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -58,13 +58,12 @@ export function GraphFloatingMenu() {
});
}}
/>
{isFrozen ? (
<CustomIconButton
icon={<FaRegSnowflake size={22} />}
label={t`Unfreeze`}
onClick={unfreezeDoc}
/>
) : null}
<CustomIconButton
icon={<FaRegSnowflake size={22} />}
label={isFrozen ? t`Unfreeze` : t`Freeze`}
onClick={toggleDocFrozen}
data-state-active={isFrozen ? true : false}
/>
</div>
);
}
Expand All @@ -82,7 +81,9 @@ function CustomIconButton({ icon, label, ...props }: CustomIconButtonProps) {
return (
<Tooltip label={label} aria-label={label} className={`slang-type size-0`}>
<button
className="w-9 h-9 grid content-center justify-center bg-white text-neutral-900 hover:bg-neutral-100 active:bg-neutral-200 focus:outline-none focus:shadow-none"
className={
"w-9 h-9 grid content-center justify-center bg-white text-neutral-900 hover:bg-neutral-100 data-[state-active=true]:bg-neutral-200 data-[state-active=true]:hover:bg-neutral-300 active:bg-neutral-200 focus:outline-none focus:shadow-none"
}
data-testid={label}
{...props}
>
Expand Down
42 changes: 40 additions & 2 deletions app/src/components/Tabs/EditLayoutTab.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { t, Trans } from "@lingui/macro";
import produce from "immer";
import { PushPin } from "phosphor-react";
import { FaRegSnowflake } from "react-icons/fa";

import { GraphOptionsObject } from "../../lib/constants";
Expand All @@ -8,7 +9,11 @@ import { directions, layouts } from "../../lib/graphOptions";
import { hasOwnProperty } from "../../lib/helpers";
import { useIsValidSponsor } from "../../lib/hooks";
import { useDoc } from "../../lib/useDoc";
import { unfreezeDoc, useIsFrozen } from "../../lib/useIsFrozen";
import {
toggleDocFrozen,
useHasFixedNodes,
useIsFrozen,
} from "../../lib/useIsFrozen";
import styles from "./EditLayoutTab.module.css";
import {
CustomSelect,
Expand Down Expand Up @@ -41,6 +46,7 @@ export function EditLayoutTab() {
layouts.find((l) => l.value === layoutName)?.label() ?? "???";

const isFrozen = useIsFrozen();
const hasFixedNodes = useHasFixedNodes();

let direction = layout?.["rankDir"] ?? graphLayout.rankDir;

Expand Down Expand Up @@ -181,6 +187,7 @@ export function EditLayoutTab() {
</div>
</OptionWithLabel>
)}
{hasFixedNodes && <FixedNodesWarning />}
</TabOptionsGrid>
{!isValidSponsor && (
<LargeLink href="/pricing">
Expand All @@ -200,9 +207,40 @@ function FrozenLayout() {
</span>
<FaRegSnowflake />
</h2>
<button onClick={unfreezeDoc}>
<button onClick={toggleDocFrozen}>
<Trans>Unfreeze</Trans>
</button>
</div>
);
}

function FixedNodesWarning() {
return (
<div className="bg-neutral-50">
<div className="p-4 px-0 grid gap-2">
<div className="flex gap-2 items-center">
<PushPin size={16} />
<h4 className="text-neutral-700 font-bold">Contains Fixed Nodes</h4>
</div>
<p className="text-sm text-neutral-500">
Your graph contains nodes with class <em>fixed</em>. Fixed nodes only
work correctly when using basic, deterministic layouts.{" "}
<button
className="inline underline text-neutral-800"
onClick={() => {
useDoc.setState((state) => {
return {
...state,
text: state.text.replace(/\.fixed\b/g, ""),
};
});
}}
>
Remove fixed class from all nodes
</button>
.
</p>
</div>
</div>
);
}
4 changes: 2 additions & 2 deletions app/src/lib/getLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ describe("getLayout", () => {
const layout = getLayout(doc);
expect(layout).toEqual({
name: "dagre",
fit: true,
animate: true,
spacingFactor: 1.25,
rankDir: "TB",
Expand All @@ -30,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);
Expand Down
26 changes: 22 additions & 4 deletions app/src/lib/getLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Doc } from "./useDoc";

export const defaultLayout: any = {
name: "dagre",
fit: true,
// fit: true,
animate: true,
spacingFactor: 1.25,
};
Expand Down Expand Up @@ -58,16 +58,34 @@ 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;
}

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",
];
5 changes: 2 additions & 3 deletions app/src/lib/graphOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
18 changes: 16 additions & 2 deletions app/src/lib/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { x: number; y: number }>;
return toCytoscapeElements(parse(text)).map((element) => {
let size: Record<string, string | number> = {};
if ("w" in element.data || "h" in element.data) {
Expand All @@ -47,14 +49,26 @@ export function universalParse(
);
}

return {
let node = {
...element,
data: {
...element.data,
...size,
},
};

// if class "fixed" and x & y are set, add position to node
const id = element.data.id;
if (id && element.classes?.includes("fixed") && nodePositions[id]) {
node = {
...node,
position: nodePositions[id],
};
}

return node;
});
}
case "v1":
return parseText(stripComments(text), getSize);
default:
Expand Down
Loading