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
pull/103278/head
Joey 4 months ago committed by GitHub
parent 6901e21700
commit 15330c3e66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 10
      packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts
  2. 35
      public/app/features/explore/NodeGraph/NodeGraphContainer.test.tsx
  3. 8
      public/app/features/explore/NodeGraph/NodeGraphContainer.tsx
  4. 28
      public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx
  5. 97
      public/app/plugins/panel/nodeGraph/NodeGraph.tsx
  6. 3
      public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx
  7. 18
      public/app/plugins/panel/nodeGraph/ViewControls.tsx
  8. 98
      public/app/plugins/panel/nodeGraph/layout.ts
  9. 14
      public/app/plugins/panel/nodeGraph/module.tsx
  10. 5
      public/app/plugins/panel/nodeGraph/panelcfg.cue
  11. 10
      public/app/plugins/panel/nodeGraph/panelcfg.gen.ts

@ -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.

Loading…
Cancel
Save