NodeGraph: Add node graph algorithm layout option (#102760)
* Add layout buttons * Add config for node graph panel * Tests * Update test * Updates * Move grid button and cache nodes * Remove limit and add warning * Update default
This commit is contained in:
+10
@@ -26,6 +26,12 @@ export enum ZoomMode {
|
||||
Greedy = 'greedy',
|
||||
}
|
||||
|
||||
export enum LayoutAlgorithm {
|
||||
Force = 'force',
|
||||
Grid = 'grid',
|
||||
Layered = 'layered',
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
edges?: {
|
||||
/**
|
||||
@@ -37,6 +43,10 @@ export interface Options {
|
||||
*/
|
||||
secondaryStatUnit?: string;
|
||||
};
|
||||
/**
|
||||
* How to layout the nodes in the node graph
|
||||
*/
|
||||
layoutAlgorithm?: LayoutAlgorithm;
|
||||
nodes?: {
|
||||
/**
|
||||
* Unit for the main stat to override what ever is set in the data frame.
|
||||
|
||||
@@ -1,9 +1,44 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { getDefaultTimeRange, MutableDataFrame } from '@grafana/data';
|
||||
import { NodeDatum } from 'app/plugins/panel/nodeGraph/types';
|
||||
|
||||
import { UnconnectedNodeGraphContainer } from './NodeGraphContainer';
|
||||
|
||||
jest.mock('../../../plugins/panel/nodeGraph/createLayoutWorker', () => {
|
||||
const createMockWorker = () => {
|
||||
const onmessage = jest.fn();
|
||||
const postMessage = jest.fn();
|
||||
const terminate = jest.fn();
|
||||
|
||||
const worker = {
|
||||
onmessage: onmessage,
|
||||
postMessage: postMessage,
|
||||
terminate: terminate,
|
||||
};
|
||||
|
||||
postMessage.mockImplementation((data) => {
|
||||
if (worker.onmessage) {
|
||||
const event = {
|
||||
data: {
|
||||
nodes: (data.nodes || []).map((n: NodeDatum) => ({ ...n, x: 0, y: 0 })),
|
||||
edges: data.edges || [],
|
||||
},
|
||||
};
|
||||
setTimeout(() => worker.onmessage(event), 0);
|
||||
}
|
||||
});
|
||||
|
||||
return worker;
|
||||
};
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
createWorker: createMockWorker,
|
||||
createMsaglWorker: createMockWorker,
|
||||
};
|
||||
});
|
||||
|
||||
describe('NodeGraphContainer', () => {
|
||||
it('is collapsed if shown with traces', () => {
|
||||
const { container } = render(
|
||||
|
||||
@@ -6,8 +6,10 @@ import { useToggle, useWindowSize } from 'react-use';
|
||||
import { applyFieldOverrides, DataFrame, GrafanaTheme2, SplitOpen } from '@grafana/data';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { useStyles2, useTheme2, PanelChrome } from '@grafana/ui';
|
||||
import { layeredLayoutThreshold } from 'app/plugins/panel/nodeGraph/NodeGraph';
|
||||
|
||||
import { NodeGraph } from '../../../plugins/panel/nodeGraph';
|
||||
import { LayoutAlgorithm } from '../../../plugins/panel/nodeGraph/panelcfg.gen';
|
||||
import { useCategorizeFrames } from '../../../plugins/panel/nodeGraph/useCategorizeFrames';
|
||||
import { StoreState } from '../../../types';
|
||||
import { useLinks } from '../utils/links';
|
||||
@@ -57,6 +59,10 @@ export function UnconnectedNodeGraphContainer(props: Props) {
|
||||
const { nodes } = useCategorizeFrames(frames);
|
||||
const [collapsed, toggleCollapsed] = useToggle(true);
|
||||
|
||||
// Determine default layout algorithm based on node count
|
||||
const nodeCount = nodes[0]?.length || 0;
|
||||
const layoutAlgorithm = nodeCount > layeredLayoutThreshold ? LayoutAlgorithm.Force : LayoutAlgorithm.Layered;
|
||||
|
||||
const toggled = () => {
|
||||
toggleCollapsed();
|
||||
reportInteraction('grafana_traces_node_graph_panel_clicked', {
|
||||
@@ -103,7 +109,7 @@ export function UnconnectedNodeGraphContainer(props: Props) {
|
||||
}
|
||||
}
|
||||
>
|
||||
<NodeGraph dataFrames={frames} getLinks={getLinks} />
|
||||
<NodeGraph dataFrames={frames} getLinks={getLinks} layoutAlgorithm={layoutAlgorithm} />
|
||||
</div>
|
||||
</PanelChrome>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,20 @@ import { render, screen, fireEvent, waitFor, getByText } from '@testing-library/
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { NodeGraph } from './NodeGraph';
|
||||
import { ZoomMode } from './panelcfg.gen';
|
||||
import { LayoutAlgorithm, ZoomMode } from './panelcfg.gen';
|
||||
import { makeEdgesDataFrame, makeNodesDataFrame } from './utils';
|
||||
|
||||
jest.mock('./layout', () => {
|
||||
const actual = jest.requireActual('./layout');
|
||||
return {
|
||||
...actual,
|
||||
defaultConfig: {
|
||||
...actual.defaultConfig,
|
||||
layoutAlgorithm: 'force',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-use/lib/useMeasure', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
@@ -26,6 +37,7 @@ describe('NodeGraph', () => {
|
||||
<NodeGraph
|
||||
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]}
|
||||
getLinks={() => []}
|
||||
layoutAlgorithm={LayoutAlgorithm.Force}
|
||||
/>
|
||||
);
|
||||
const zoomIn = await screen.findByTitle(/Zoom in/);
|
||||
@@ -44,6 +56,7 @@ describe('NodeGraph', () => {
|
||||
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]}
|
||||
zoomMode={ZoomMode.Cooperative}
|
||||
getLinks={() => []}
|
||||
layoutAlgorithm={LayoutAlgorithm.Force}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -62,6 +75,7 @@ describe('NodeGraph', () => {
|
||||
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]}
|
||||
zoomMode={ZoomMode.Greedy}
|
||||
getLinks={() => []}
|
||||
layoutAlgorithm={LayoutAlgorithm.Force}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -85,6 +99,7 @@ describe('NodeGraph', () => {
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
layoutAlgorithm={LayoutAlgorithm.Force}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -97,7 +112,9 @@ describe('NodeGraph', () => {
|
||||
});
|
||||
|
||||
it('renders with single node', async () => {
|
||||
render(<NodeGraph dataFrames={[makeNodesDataFrame(1)]} getLinks={() => []} />);
|
||||
render(
|
||||
<NodeGraph dataFrames={[makeNodesDataFrame(1)]} getLinks={() => []} layoutAlgorithm={LayoutAlgorithm.Force} />
|
||||
);
|
||||
const circle = await screen.findByText('', { selector: 'circle' });
|
||||
await screen.findByText(/service:0/);
|
||||
expect(getXY(circle)).toEqual({ x: 0, y: 0 });
|
||||
@@ -117,6 +134,7 @@ describe('NodeGraph', () => {
|
||||
},
|
||||
];
|
||||
}}
|
||||
layoutAlgorithm={LayoutAlgorithm.Force}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -146,6 +164,7 @@ describe('NodeGraph', () => {
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
layoutAlgorithm={LayoutAlgorithm.Force}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -165,6 +184,7 @@ describe('NodeGraph', () => {
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
layoutAlgorithm={LayoutAlgorithm.Force}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -188,6 +208,7 @@ describe('NodeGraph', () => {
|
||||
]}
|
||||
getLinks={() => []}
|
||||
nodeLimit={2}
|
||||
layoutAlgorithm={LayoutAlgorithm.Force}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -213,6 +234,7 @@ describe('NodeGraph', () => {
|
||||
]}
|
||||
getLinks={() => []}
|
||||
nodeLimit={3}
|
||||
layoutAlgorithm={LayoutAlgorithm.Force}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -244,7 +266,7 @@ describe('NodeGraph', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const button = await screen.findByTitle(/Grid layout/);
|
||||
const button = await screen.findByText('Grid');
|
||||
await userEvent.click(button);
|
||||
|
||||
await expectNodePositionCloseTo('service:0', { x: -60, y: -60 });
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { css } from '@emotion/css';
|
||||
import cx from 'classnames';
|
||||
import { memo, MouseEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { memo, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import useMeasure from 'react-use/lib/useMeasure';
|
||||
|
||||
import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||
import { Icon, Spinner, useStyles2 } from '@grafana/ui';
|
||||
import { Icon, RadioButtonGroup, Spinner, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { Edge } from './Edge';
|
||||
import { EdgeLabel } from './EdgeLabel';
|
||||
@@ -12,7 +12,8 @@ import { Legend } from './Legend';
|
||||
import { Marker } from './Marker';
|
||||
import { Node } from './Node';
|
||||
import { ViewControls } from './ViewControls';
|
||||
import { Config, defaultConfig, useLayout } from './layout';
|
||||
import { Config, defaultConfig, useLayout, LayoutCache } from './layout';
|
||||
import { LayoutAlgorithm } from './panelcfg.gen';
|
||||
import { EdgeDatumLayout, NodeDatum, NodesMarker, ZoomMode } from './types';
|
||||
import { useCategorizeFrames } from './useCategorizeFrames';
|
||||
import { useContextMenu } from './useContextMenu';
|
||||
@@ -70,6 +71,14 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
justifyContent: 'space-between',
|
||||
pointerEvents: 'none',
|
||||
}),
|
||||
layoutAlgorithm: css({
|
||||
label: 'layoutAlgorithm',
|
||||
pointerEvents: 'all',
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '8px',
|
||||
zIndex: 1,
|
||||
}),
|
||||
legend: css({
|
||||
label: 'legend',
|
||||
background: theme.colors.background.secondary,
|
||||
@@ -88,7 +97,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
borderRadius: theme.shape.radius.default,
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
background: theme.colors.warning.main,
|
||||
color: theme.colors.warning.contrastText,
|
||||
@@ -107,20 +115,39 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
// interactions will be without any lag for most users.
|
||||
const defaultNodeCountLimit = 200;
|
||||
|
||||
export const layeredLayoutThreshold = 500;
|
||||
|
||||
interface Props {
|
||||
dataFrames: DataFrame[];
|
||||
getLinks: (dataFrame: DataFrame, rowIndex: number) => LinkModel[];
|
||||
nodeLimit?: number;
|
||||
panelId?: string;
|
||||
zoomMode?: ZoomMode;
|
||||
layoutAlgorithm?: LayoutAlgorithm;
|
||||
}
|
||||
export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode }: Props) {
|
||||
export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode, layoutAlgorithm }: Props) {
|
||||
const nodeCountLimit = nodeLimit || defaultNodeCountLimit;
|
||||
const { edges: edgesDataFrames, nodes: nodesDataFrames } = useCategorizeFrames(dataFrames);
|
||||
|
||||
const [measureRef, { width, height }] = useMeasure();
|
||||
const [config, setConfig] = useState<Config>(defaultConfig);
|
||||
|
||||
// Layout cache to avoid recalculating layouts
|
||||
const layoutCacheRef = useRef<LayoutCache>({});
|
||||
|
||||
// Update the config when layoutAlgorithm changes via the panel options
|
||||
useEffect(() => {
|
||||
if (layoutAlgorithm) {
|
||||
setConfig((prevConfig) => {
|
||||
return {
|
||||
...prevConfig,
|
||||
gridLayout: layoutAlgorithm === LayoutAlgorithm.Grid,
|
||||
layoutAlgorithm,
|
||||
};
|
||||
});
|
||||
}
|
||||
}, [layoutAlgorithm]);
|
||||
|
||||
const firstNodesDataFrame = nodesDataFrames[0];
|
||||
const firstEdgesDataFrame = edgesDataFrames[0];
|
||||
|
||||
@@ -166,7 +193,8 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode }
|
||||
nodeCountLimit,
|
||||
width,
|
||||
focusedNodeId,
|
||||
processed.hasFixedPositions
|
||||
processed.hasFixedPositions,
|
||||
layoutCacheRef.current
|
||||
);
|
||||
|
||||
// If we move from grid to graph layout, and we have focused node lets get its position to center there. We want to
|
||||
@@ -199,6 +227,18 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode }
|
||||
|
||||
const highlightId = useHighlight(focusedNodeId);
|
||||
|
||||
const handleLayoutChange = (cfg: Config) => {
|
||||
if (cfg.layoutAlgorithm !== config.layoutAlgorithm) {
|
||||
setFocusedNodeId(undefined);
|
||||
}
|
||||
setConfig(cfg);
|
||||
};
|
||||
|
||||
// Clear the layout cache when data changes
|
||||
useEffect(() => {
|
||||
layoutCacheRef.current = {};
|
||||
}, [firstNodesDataFrame, firstEdgesDataFrame]);
|
||||
|
||||
return (
|
||||
<div ref={topLevelRef} className={styles.wrapper}>
|
||||
{loading ? (
|
||||
@@ -208,6 +248,27 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode }
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!panelId && (
|
||||
<div className={styles.layoutAlgorithm}>
|
||||
<RadioButtonGroup
|
||||
size="sm"
|
||||
options={[
|
||||
{ label: 'Layered', value: LayoutAlgorithm.Layered },
|
||||
{ label: 'Force', value: LayoutAlgorithm.Force },
|
||||
{ label: 'Grid', value: LayoutAlgorithm.Grid },
|
||||
]}
|
||||
value={config.gridLayout ? LayoutAlgorithm.Grid : config.layoutAlgorithm}
|
||||
onChange={(value) => {
|
||||
handleLayoutChange({
|
||||
...config,
|
||||
gridLayout: value === LayoutAlgorithm.Grid,
|
||||
layoutAlgorithm: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dataFrames.length && processed.nodes.length ? (
|
||||
<svg
|
||||
ref={panRef}
|
||||
@@ -267,12 +328,7 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode }
|
||||
<div className={styles.viewControlsWrapper}>
|
||||
<ViewControls<Config>
|
||||
config={config}
|
||||
onConfigChange={(cfg) => {
|
||||
if (cfg.gridLayout !== config.gridLayout) {
|
||||
setFocusedNodeId(undefined);
|
||||
}
|
||||
setConfig(cfg);
|
||||
}}
|
||||
onConfigChange={handleLayoutChange}
|
||||
onMinus={onStepDown}
|
||||
onPlus={onStepUp}
|
||||
scale={scale}
|
||||
@@ -283,11 +339,26 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode }
|
||||
</div>
|
||||
|
||||
{hiddenNodesCount > 0 && (
|
||||
<div className={styles.alert} aria-label={'Nodes hidden warning'}>
|
||||
<div
|
||||
className={styles.alert}
|
||||
style={{ top: panelId ? '0px' : '40px' }} // panelId is undefined in Explore
|
||||
aria-label={'Nodes hidden warning'}
|
||||
>
|
||||
<Icon size="sm" name={'info-circle'} /> {hiddenNodesCount} nodes are hidden for performance reasons.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.layoutAlgorithm === LayoutAlgorithm.Layered && processed.nodes.length > layeredLayoutThreshold && (
|
||||
<div
|
||||
className={styles.alert}
|
||||
style={{ top: panelId ? '30px' : '70px' }}
|
||||
aria-label={'Layered layout performance warning'}
|
||||
>
|
||||
<Icon size="sm" name={'exclamation-triangle'} /> Layered layout may be slow with {processed.nodes.length}{' '}
|
||||
nodes.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{MenuComponent}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PanelProps } from '@grafana/data';
|
||||
import { useLinks } from '../../../features/explore/utils/links';
|
||||
|
||||
import { NodeGraph } from './NodeGraph';
|
||||
import { NodeGraphOptions } from './types';
|
||||
import { Options as NodeGraphOptions } from './panelcfg.gen';
|
||||
import { getNodeGraphDataFrames } from './utils';
|
||||
|
||||
export const NodeGraphPanel = ({ width, height, data, options }: PanelProps<NodeGraphOptions>) => {
|
||||
@@ -29,6 +29,7 @@ export const NodeGraphPanel = ({ width, height, data, options }: PanelProps<Node
|
||||
getLinks={getLinks}
|
||||
panelId={panelId}
|
||||
zoomMode={options.zoomMode}
|
||||
layoutAlgorithm={options.layoutAlgorithm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -54,24 +54,6 @@ export function ViewControls<Config extends Record<string, any>>(props: Props<Co
|
||||
disabled={disableZoomOut}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Button
|
||||
icon={'code-branch'}
|
||||
onClick={() => onConfigChange({ ...config, gridLayout: false })}
|
||||
size={'md'}
|
||||
title={'Default layout'}
|
||||
variant="secondary"
|
||||
disabled={!config.gridLayout}
|
||||
/>
|
||||
<Button
|
||||
icon={'apps'}
|
||||
onClick={() => onConfigChange({ ...config, gridLayout: true })}
|
||||
size={'md'}
|
||||
title={'Grid layout'}
|
||||
variant="secondary"
|
||||
disabled={config.gridLayout}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
|
||||
{allowConfiguration && (
|
||||
|
||||
@@ -4,14 +4,15 @@ import { useUnmount } from 'react-use';
|
||||
import useMountedState from 'react-use/lib/useMountedState';
|
||||
|
||||
import { Field } from '@grafana/data';
|
||||
import { config as grafanaConfig } from '@grafana/runtime';
|
||||
|
||||
import { createWorker, createMsaglWorker } from './createLayoutWorker';
|
||||
import { LayoutAlgorithm } from './panelcfg.gen';
|
||||
import { EdgeDatum, EdgeDatumLayout, NodeDatum } from './types';
|
||||
import { useNodeLimit } from './useNodeLimit';
|
||||
import { graphBounds } from './utils';
|
||||
|
||||
export interface Config {
|
||||
layoutAlgorithm: LayoutAlgorithm;
|
||||
linkDistance: number;
|
||||
linkStrength: number;
|
||||
forceX: number;
|
||||
@@ -30,6 +31,7 @@ export interface Config {
|
||||
// if you programmatically enable the config editor (for development only) see ViewControls. These could be moved to
|
||||
// panel configuration at some point (apart from gridLayout as that can be switched be user right now.).
|
||||
export const defaultConfig: Config = {
|
||||
layoutAlgorithm: LayoutAlgorithm.Layered,
|
||||
linkDistance: 150,
|
||||
linkStrength: 0.5,
|
||||
forceX: 2000,
|
||||
@@ -39,6 +41,11 @@ export const defaultConfig: Config = {
|
||||
gridLayout: false,
|
||||
};
|
||||
|
||||
export interface LayoutCache {
|
||||
[LayoutAlgorithm.Force]?: { nodes: NodeDatum[]; edges: EdgeDatumLayout[] };
|
||||
[LayoutAlgorithm.Layered]?: { nodes: NodeDatum[]; edges: EdgeDatumLayout[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* This will return copy of the nods and edges with x,y positions filled in. Also the layout changes source/target props
|
||||
* in edges from string ids to actual nodes.
|
||||
@@ -50,13 +57,18 @@ export function useLayout(
|
||||
nodeCountLimit: number,
|
||||
width: number,
|
||||
rootNodeId?: string,
|
||||
hasFixedPositions?: boolean
|
||||
hasFixedPositions?: boolean,
|
||||
layoutCache?: LayoutCache
|
||||
) {
|
||||
const [nodesGraph, setNodesGraph] = useState<NodeDatum[]>([]);
|
||||
const [edgesGraph, setEdgesGraph] = useState<EdgeDatumLayout[]>([]);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Store current data signature to detect changes
|
||||
const dataSignatureRef = useRef<string>('');
|
||||
const currentSignature = createDataSignature(rawNodes, rawEdges);
|
||||
|
||||
const isMounted = useMountedState();
|
||||
const layoutWorkerCancelRef = useRef<(() => void) | undefined>();
|
||||
|
||||
@@ -103,23 +115,53 @@ export function useLayout(
|
||||
return;
|
||||
}
|
||||
|
||||
// Layered layout is better but also more expensive, so we switch to default force based layout for bigger graphs.
|
||||
const layoutType =
|
||||
grafanaConfig.featureToggles.nodeGraphDotLayout && rawNodes.length <= 500 ? 'layered' : 'default';
|
||||
// Layered layout is better but also more expensive.
|
||||
let layoutType: 'force' | 'layered' = 'force';
|
||||
let algorithmType = LayoutAlgorithm.Force;
|
||||
|
||||
if (config.layoutAlgorithm === LayoutAlgorithm.Layered) {
|
||||
layoutType = 'layered';
|
||||
algorithmType = LayoutAlgorithm.Layered;
|
||||
}
|
||||
|
||||
// Check if data has changed since last render
|
||||
const hasDataChanged = dataSignatureRef.current !== currentSignature;
|
||||
|
||||
// Clear cache if data has changed
|
||||
if (hasDataChanged) {
|
||||
dataSignatureRef.current = currentSignature;
|
||||
|
||||
if (layoutCache) {
|
||||
delete layoutCache[LayoutAlgorithm.Force];
|
||||
delete layoutCache[LayoutAlgorithm.Layered];
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have a cached layout for this algorithm
|
||||
if (layoutCache && layoutCache[algorithmType]) {
|
||||
setNodesGraph(layoutCache[algorithmType]?.nodes ?? []);
|
||||
setEdgesGraph(layoutCache[algorithmType]?.edges ?? []);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
// This is async but as I wanted to still run the sync grid layout, and you cannot return promise from effect so
|
||||
// having callback seems ok here.
|
||||
|
||||
const cancel = layout(rawNodes, rawEdges, layoutType, ({ nodes, edges }) => {
|
||||
if (isMounted()) {
|
||||
setNodesGraph(nodes);
|
||||
setEdgesGraph(edges);
|
||||
setLoading(false);
|
||||
|
||||
// Cache the calculated layout
|
||||
if (layoutCache) {
|
||||
layoutCache[algorithmType] = { nodes, edges };
|
||||
}
|
||||
}
|
||||
});
|
||||
layoutWorkerCancelRef.current = cancel;
|
||||
return cancel;
|
||||
}, [hasFixedPositions, rawNodes, rawEdges, isMounted]);
|
||||
}, [hasFixedPositions, rawNodes, rawEdges, isMounted, config.layoutAlgorithm, layoutCache, currentSignature]);
|
||||
|
||||
// Compute grid separately as it is sync and do not need to be inside effect. Also it is dependant on width while
|
||||
// default layout does not care and we don't want to recalculate that on panel resize.
|
||||
@@ -172,10 +214,10 @@ export function useLayout(
|
||||
function layout(
|
||||
nodes: NodeDatum[],
|
||||
edges: EdgeDatum[],
|
||||
engine: 'default' | 'layered',
|
||||
engine: 'force' | 'layered',
|
||||
done: (data: { nodes: NodeDatum[]; edges: EdgeDatumLayout[] }) => void
|
||||
) {
|
||||
const worker = engine === 'default' ? createWorker() : createMsaglWorker();
|
||||
const worker = engine === 'force' ? createWorker() : createMsaglWorker();
|
||||
|
||||
worker.onmessage = (event: MessageEvent<{ nodes: NodeDatum[]; edges: EdgeDatumLayout[] }>) => {
|
||||
const nodesMap = fromPairs(nodes.map((node) => [node.id, node]));
|
||||
@@ -239,3 +281,39 @@ function gridLayout(
|
||||
node.y = -60 + row * spacingVertical;
|
||||
}
|
||||
}
|
||||
|
||||
function createDataSignature(nodes: NodeDatum[], edges: EdgeDatum[]): string {
|
||||
const signature = [`n:${nodes.length}`, `e:${edges.length}`];
|
||||
|
||||
if (nodes.length > 0) {
|
||||
const firstNode = nodes[0].id ?? '';
|
||||
signature.push(`f:${firstNode}`);
|
||||
|
||||
// Middle node (if there are at least 3 nodes)
|
||||
if (nodes.length >= 3) {
|
||||
const middleIndex = Math.floor(nodes.length / 2);
|
||||
const middleNode = nodes[middleIndex].id ?? '';
|
||||
signature.push(`m:${middleNode}`);
|
||||
}
|
||||
|
||||
const lastNode = nodes[nodes.length - 1].id ?? '';
|
||||
signature.push(`l:${lastNode}`);
|
||||
|
||||
// Add basic connectivity information
|
||||
let connectedNodesCount = 0;
|
||||
let maxConnections = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
const connections = node.incoming || 0;
|
||||
if (connections > 0) {
|
||||
connectedNodesCount++;
|
||||
}
|
||||
maxConnections = Math.max(maxConnections, connections);
|
||||
}
|
||||
|
||||
signature.push(`cn:${connectedNodesCount}`);
|
||||
signature.push(`mc:${maxConnections}`);
|
||||
}
|
||||
|
||||
return signature.join('_');
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { FieldConfigProperty, PanelPlugin } from '@grafana/data';
|
||||
|
||||
import { NodeGraphPanel } from './NodeGraphPanel';
|
||||
import { ArcOptionsEditor } from './editor/ArcOptionsEditor';
|
||||
import { LayoutAlgorithm, Options as NodeGraphOptions } from './panelcfg.gen';
|
||||
import { NodeGraphSuggestionsSupplier } from './suggestions';
|
||||
import { NodeGraphOptions } from './types';
|
||||
|
||||
export const plugin = new PanelPlugin<NodeGraphOptions>(NodeGraphPanel)
|
||||
.useFieldConfig({
|
||||
@@ -21,6 +21,18 @@ export const plugin = new PanelPlugin<NodeGraphOptions>(NodeGraphPanel)
|
||||
],
|
||||
},
|
||||
});
|
||||
builder.addSelect({
|
||||
name: 'Layout algorithm',
|
||||
path: 'layoutAlgorithm',
|
||||
defaultValue: LayoutAlgorithm.Layered,
|
||||
settings: {
|
||||
options: [
|
||||
{ label: 'Layered', value: LayoutAlgorithm.Layered, description: 'Use a layered layout' },
|
||||
{ label: 'Force', value: LayoutAlgorithm.Force, description: 'Use a force-directed layout' },
|
||||
{ label: 'Grid', value: LayoutAlgorithm.Grid, description: 'Use a grid layout' },
|
||||
],
|
||||
},
|
||||
});
|
||||
builder.addNestedOptions({
|
||||
category: ['Nodes'],
|
||||
path: 'nodes',
|
||||
|
||||
@@ -43,12 +43,15 @@ composableKinds: PanelCfg: {
|
||||
// Unit for the secondary stat to override what ever is set in the data frame.
|
||||
secondaryStatUnit?: string
|
||||
}
|
||||
ZoomMode: "cooperative" | "greedy" @cuetsy(kind="enum")
|
||||
ZoomMode: "cooperative" | "greedy" @cuetsy(kind="enum")
|
||||
LayoutAlgorithm: "layered" | "force" | "grid" @cuetsy(kind="enum")
|
||||
Options: {
|
||||
nodes?: NodeOptions
|
||||
edges?: EdgeOptions
|
||||
// How to handle zoom/scroll events in the node graph
|
||||
zoomMode?: ZoomMode
|
||||
// How to layout the nodes in the node graph
|
||||
layoutAlgorithm?: LayoutAlgorithm
|
||||
} @cuetsy(kind="interface")
|
||||
}
|
||||
}]
|
||||
|
||||
@@ -24,6 +24,12 @@ export enum ZoomMode {
|
||||
Greedy = 'greedy',
|
||||
}
|
||||
|
||||
export enum LayoutAlgorithm {
|
||||
Force = 'force',
|
||||
Grid = 'grid',
|
||||
Layered = 'layered',
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
edges?: {
|
||||
/**
|
||||
@@ -35,6 +41,10 @@ export interface Options {
|
||||
*/
|
||||
secondaryStatUnit?: string;
|
||||
};
|
||||
/**
|
||||
* How to layout the nodes in the node graph
|
||||
*/
|
||||
layoutAlgorithm?: LayoutAlgorithm;
|
||||
nodes?: {
|
||||
/**
|
||||
* Unit for the main stat to override what ever is set in the data frame.
|
||||
|
||||
Reference in New Issue
Block a user