Skip to content

Commit

Permalink
[Layout Quality] first draft
Browse files Browse the repository at this point in the history
  • Loading branch information
paulgirard committed Jun 13, 2024
1 parent 904cbea commit 6fda4be
Show file tree
Hide file tree
Showing 9 changed files with 146 additions and 20 deletions.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"graphology-layout-force": "^0.2.4",
"graphology-layout-forceatlas2": "^0.10.1",
"graphology-layout-noverlap": "^0.4.2",
"graphology-metrics": "^2.2.0",
"graphology-metrics": "^2.3.0",
"graphology-operators": "^1.6.0",
"highlight.js": "^11.9.0",
"http-proxy-middleware": "^3.0.0",
Expand Down Expand Up @@ -113,4 +113,4 @@
"prettier": "^3.3.1",
"vite-plugin-checker": "^0.6.4"
}
}
}
31 changes: 31 additions & 0 deletions src/components/GraphCaption/LayoutQualityCaption.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { FC } from "react";
import { useTranslation } from "react-i18next";

import { useLayoutState, usePreferences } from "../../core/context/dataContexts";
import { LayoutsIcon } from "../common-icons";

export const LayoutQualityCaption: FC = () => {
const { t } = useTranslation();
const { quality } = useLayoutState();
const { locale } = usePreferences();

if (!quality.enabled) return null;

return (
<div className="graph-caption-item">
<div className="d-flex align-items-center mb-1">
<LayoutsIcon title="Layout" className="fs-4 me-1" />
<div className="d-flex flex-column justify-content-center m-2">
<span className="text-muted caption-item-label">{t("layouts.quality.title")}</span>
<h6 className="m-0 d-flex align-items-center">Connected Closeness</h6>
</div>
</div>
<div className="caption text-center">
{quality.metric?.ePercentOfDeltaMax
? Math.round(quality.metric.ePercentOfDeltaMax * 100).toLocaleString(locale, { compactDisplay: "short" }) +
"%"
: "N/A"}{" "}
</div>
</div>
);
};
11 changes: 7 additions & 4 deletions src/components/GraphCaption/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import { useTranslation } from "react-i18next";
import { AiFillQuestionCircle } from "react-icons/ai";
import { BiCollapseAlt } from "react-icons/bi";

import { useAppearance, useFilteredGraph, useGraphDataset } from "../../core/context/dataContexts";
import { useAppearance, useFilteredGraph, useGraphDataset, useLayoutState } from "../../core/context/dataContexts";
import { ItemData } from "../../core/graph/types";
import { ItemsColorCaption } from "./ItemColorCaption";
import ItemSizeCaption from "./ItemSizeCaption";
import { LayoutQualityCaption } from "./LayoutQualityCaption";

export interface GraphCaptionProps {
minimal?: boolean;
Expand Down Expand Up @@ -65,18 +66,19 @@ const GraphCaption: FC<GraphCaptionProps> = ({ minimal }) => {
const { nodeData, edgeData } = useGraphDataset();
const { t } = useTranslation();
const [collapsed, setCollapsed] = useState<boolean>(false);

const { quality } = useLayoutState();
const [enabled, setEnabled] = useState<boolean>(true);

useEffect(() => {
const enable =
["ranking", "partition"].includes(appearance.nodesColor.type) ||
["ranking", "partition", "source", "target"].includes(appearance.edgesColor.type) ||
appearance.edgesSize.type === "ranking" ||
appearance.nodesSize.type === "ranking";
appearance.nodesSize.type === "ranking" ||
quality.enabled;

setEnabled(enable);
}, [appearance]);
}, [appearance, quality.enabled]);

// min-max values for ranking caption items
const vizAttributesExtends = useMemo(() => {
Expand Down Expand Up @@ -203,6 +205,7 @@ const GraphCaption: FC<GraphCaptionProps> = ({ minimal }) => {
itemsSize={appearance.edgesSize}
extend={edgeSizeExtends && "min" in edgeSizeExtends ? edgeSizeExtends : undefined}
/>
<LayoutQualityCaption />
</div>
</>
)}
Expand Down
38 changes: 38 additions & 0 deletions src/components/forms/LayoutQualityForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { FC } from "react";
import { useTranslation } from "react-i18next";

import { useLayoutActions } from "../../core/context/dataContexts";
import { layoutStateAtom } from "../../core/layouts";

export const LayoutQualityForm: FC = () => {
const { t } = useTranslation();
const { quality } = layoutStateAtom.get();
const { setQuality } = useLayoutActions();

return (
<div className="panel-block">
{t("layouts.quality.title")}
<p className="text-muted small d-none d-md-block">{t("layouts.quality.description")}</p>
<div className="form-check">
<input
className="form-check-input"
id="qualityEnabled"
checked={quality.enabled}
type="checkbox"
onChange={(e) => setQuality({ ...quality, enabled: e.target.checked })}
/>
<label htmlFor="qualityEnabled">{t("layouts.quality.enable")}</label>
</div>
<div className="form-check">
<input
className="form-check-input"
id="qualityGrid"
checked={quality.showGrid}
type="checkbox"
onChange={(e) => setQuality({ ...quality, showGrid: e.target.checked })}
/>
<label htmlFor="qualityGrid">{t("layouts.quality.showGrid")}</label>
</div>
</div>
);
};
53 changes: 46 additions & 7 deletions src/core/layouts/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import connectedCloseness from "graphology-metrics/layout-quality/connected-closeness";

import { graphDatasetActions, graphDatasetAtom, sigmaGraphAtom } from "../graph";
import { dataGraphToFullGraph } from "../graph/utils";
import { resetCamera } from "../sigma";
import { atom } from "../utils/atoms";
import { asyncAction } from "../utils/producers";
import { Producer, asyncAction, producerToAction } from "../utils/producers";
import { LAYOUTS } from "./collection";
import { LayoutMapping, LayoutState } from "./types";
import { LayoutMapping, LayoutQuality, LayoutState } from "./types";

function getEmptyLayoutState(): LayoutState {
return { type: "idle" };
return { quality: { enabled: false, showGrid: true }, type: "idle" };
}

/**
Expand All @@ -23,13 +25,15 @@ export const layoutStateAtom = atom<LayoutState>(getEmptyLayoutState());
export const startLayout = asyncAction(async (id: string, params: unknown) => {
const { setNodePositions } = graphDatasetActions;
const dataset = graphDatasetAtom.get();
const { quality } = layoutStateAtom.get();
const { computeLayoutQualityMetric } = layoutActions;

// search the layout
const layout = LAYOUTS.find((l) => l.id === id);

// Sync layout
if (layout && layout.type === "sync") {
layoutStateAtom.set({ type: "running", layoutId: id });
layoutStateAtom.set((prev) => ({ ...prev, type: "running", layoutId: id }));

// generate positions
const fullGraph = dataGraphToFullGraph(dataset);
Expand All @@ -41,7 +45,8 @@ export const startLayout = asyncAction(async (id: string, params: unknown) => {
// To prevent resetting the camera before sigma receives new data, we
// need to wait a frame, and also wait for it to trigger a refresh:
setTimeout(() => {
layoutStateAtom.set({ type: "idle" });
if (quality.enabled) computeLayoutQualityMetric();
layoutStateAtom.set((prev) => ({ ...prev, type: "idle" }));
resetCamera({ forceRefresh: false });
}, 0);
}
Expand All @@ -50,7 +55,7 @@ export const startLayout = asyncAction(async (id: string, params: unknown) => {
if (layout && layout.type === "worker") {
const worker = new layout.supervisor(sigmaGraphAtom.get(), { settings: params });
worker.start();
layoutStateAtom.set({ type: "running", layoutId: id, supervisor: worker });
layoutStateAtom.set((prev) => ({ ...prev, type: "running", layoutId: id, supervisor: worker }));
}
});

Expand All @@ -71,10 +76,44 @@ export const stopLayout = asyncAction(async () => {
setNodePositions(positions);
}

layoutStateAtom.set({ type: "idle" });
layoutStateAtom.set((prev) => ({ ...prev, type: "idle" }));
});

export const setQuality: Producer<LayoutState, [LayoutQuality]> = (quality) => {
return (state) => ({ ...state, quality });
};

export const computeLayoutQualityMetric: Producer<LayoutState, []> = () => {
const sigmaGraph = sigmaGraphAtom.get();
const metric = connectedCloseness(sigmaGraph);

return (state) => ({ ...state, quality: { ...state.quality, metric } });
};

export const layoutActions = {
startLayout,
stopLayout,
setQuality: producerToAction(setQuality, layoutStateAtom),
computeLayoutQualityMetric: producerToAction(computeLayoutQualityMetric, layoutStateAtom),
};

layoutStateAtom.bind((layoutState, prevState) => {
const updatedQualityKeys = new Set(
(Object.keys(layoutState.quality) as (keyof LayoutState["quality"])[]).filter(
(key) => layoutState.quality[key] !== prevState.quality[key],
),
);

const { computeLayoutQualityMetric } = layoutActions;

if (updatedQualityKeys.has("enabled")) {
if (layoutState.quality.enabled) {
computeLayoutQualityMetric();
sigmaGraphAtom.get().on("nodeAttributesUpdated", computeLayoutQualityMetric);
sigmaGraphAtom.get().on("eachNodeAttributesUpdated", computeLayoutQualityMetric);
} else {
sigmaGraphAtom.get().off("eachNodeAttributesUpdated", computeLayoutQualityMetric);
sigmaGraphAtom.get().off("nodeAttributesUpdated", computeLayoutQualityMetric);
}
}
});
12 changes: 9 additions & 3 deletions src/core/layouts/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Graph from "graphology";
import { ConnectedClosenessResult } from "graphology-metrics/layout-quality/connected-closeness";
import { Coordinates } from "sigma/types";

import { DataGraph, ItemData } from "../graph/types";
Expand Down Expand Up @@ -92,7 +93,12 @@ export interface WorkerLayout<P = any> {
}

export type Layout = WorkerLayout | SyncLayout;

export type LayoutState =
export interface LayoutQuality {
showGrid: boolean;
enabled: boolean;
metric?: ConnectedClosenessResult;
}
export type LayoutState = { quality: LayoutQuality } & (
| { type: "idle" }
| { type: "running"; layoutId: string; supervisor?: WorkerSupervisorInterface };
| { type: "running"; layoutId: string; supervisor?: WorkerSupervisorInterface }
);
6 changes: 6 additions & 0 deletions src/locales/dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,12 @@
"title": "Custom layout",
"description": "Write your own layout by creating a function that returns a {x,y} object for each node",
"parameters": {}
},
"quality": {
"title": "Layout quality",
"description": "Calculate and show the layout quality metric",
"enable": "enable",
"showGrid": "show grid"
}
},
"file": {
Expand Down
3 changes: 3 additions & 0 deletions src/views/graphPage/LayoutsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { LoaderFill } from "../../components/Loader";
import MessageTooltip from "../../components/MessageTooltip";
import { CodeEditorIcon, LayoutsIcon } from "../../components/common-icons";
import { DEFAULT_SELECT_PROPS } from "../../components/consts";
import { LayoutQualityForm } from "../../components/forms/LayoutQualityForm";
import { BooleanInput, EnumInput, NumberInput } from "../../components/forms/TypedInputs";
import { useGraphDataset, useLayoutActions, useLayoutState, useSigmaGraph } from "../../core/context/dataContexts";
import { FieldModel } from "../../core/graph/types";
Expand Down Expand Up @@ -373,6 +374,8 @@ export const LayoutsPanel: FC = () => {
/>
</>
)}
<hr className="m-0" />
<LayoutQualityForm />
</>
);
};

0 comments on commit 6fda4be

Please sign in to comment.