NodeGraph: Allow usage with single dataframe (#58448)

pull/59352/head
Andrej Ocenas 3 years ago committed by GitHub
parent f1dfaa784a
commit e033775264
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      .betterer.results
  2. 40
      docs/sources/panels-visualizations/visualizations/node-graph/index.md
  3. 4
      public/app/plugins/datasource/testdata/components/NodeGraphEditor.tsx
  4. 5
      public/app/plugins/datasource/testdata/datasource.ts
  5. 45
      public/app/plugins/datasource/testdata/nodeGraphUtils.ts
  6. 2
      public/app/plugins/datasource/testdata/types.ts
  7. 9
      public/app/plugins/panel/nodeGraph/Node.tsx
  8. 41
      public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx
  9. 12
      public/app/plugins/panel/nodeGraph/NodeGraph.tsx
  10. 2
      public/app/plugins/panel/nodeGraph/layout.ts
  11. 2
      public/app/plugins/panel/nodeGraph/types.ts
  12. 121
      public/app/plugins/panel/nodeGraph/useContextMenu.tsx
  13. 326
      public/app/plugins/panel/nodeGraph/utils.test.ts
  14. 290
      public/app/plugins/panel/nodeGraph/utils.ts

@ -6837,8 +6837,7 @@ exports[`better eslint`] = {
"public/app/plugins/datasource/testdata/nodeGraphUtils.ts:5381": [ "public/app/plugins/datasource/testdata/nodeGraphUtils.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"]
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
], ],
"public/app/plugins/datasource/testdata/runStreams.ts:5381": [ "public/app/plugins/datasource/testdata/runStreams.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "0"],

@ -85,9 +85,27 @@ Click on the node and select "Show in Graph layout" option to switch back to gra
This visualization needs a specific shape of the data to be returned from the data source in order to correctly display it. This visualization needs a specific shape of the data to be returned from the data source in order to correctly display it.
Data source needs to return two data frames, one for nodes and one for edges. You have to set `frame.meta.preferredVisualisationType = 'nodeGraph'` on both data frames or name them `nodes` and `edges` respectively for the node graph to render. Node Graph at minimum requires a data frame describing the edges of the graph. By default, node graph will compute the nodes and any stats based on this data frame. Optionally a second data frame describing the nodes can be sent in case there is need to show more node specific metadata. You have to set `frame.meta.preferredVisualisationType = 'nodeGraph'` on both data frames or name them `nodes` and `edges` respectively for the node graph to render.
### Node parameters ### Edges data frame structure
Required fields:
| Field name | Type | Description |
| ---------- | ------ | ------------------------------ |
| id | string | Unique identifier of the edge. |
| source | string | Id of the source node. |
| target | string | Id of the target. |
Optional fields:
| Field name | Type | Description |
| ------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| mainstat | string/number | First stat shown in the overlay when hovering over the edge. It can be a string showing the value as is or it can be a number. If it is a number, any unit associated with that field is also shown |
| secondarystat | string/number | Same as mainStat, but shown right under it. |
| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the edge. Use `config.displayName` for more human readable label. |
### Nodes data frame structure
Required fields: Required fields:
@ -105,21 +123,3 @@ Optional fields:
| secondarystat | string/number | Same as mainStat, but shown under it inside the node. | | secondarystat | string/number | Same as mainStat, but shown under it inside the node. |
| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. | | arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. | | detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
### Edge parameters
Required fields:
| Field name | Type | Description |
| ---------- | ------ | ------------------------------ |
| id | string | Unique identifier of the edge. |
| source | string | Id of the source node. |
| target | string | Id of the target. |
Optional fields:
| Field name | Type | Description |
| ------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| mainstat | string/number | First stat shown in the overlay when hovering over the edge. It can be a string showing the value as is or it can be a number. If it is a number, any unit associated with that field is also shown |
| secondarystat | string/number | Same as mainStat, but shown right under it. |
| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the edge. Use `config.displayName` for more human readable label. |

@ -23,7 +23,7 @@ export function NodeGraphEditor({ query, onChange }: Props) {
width={32} width={32}
/> />
</InlineField> </InlineField>
{type === 'random' && ( {(type === 'random' || type === 'random edges') && (
<InlineField label="Count" labelWidth={14}> <InlineField label="Count" labelWidth={14}>
<Input <Input
type="number" type="number"
@ -41,4 +41,4 @@ export function NodeGraphEditor({ query, onChange }: Props) {
); );
} }
const options: Array<NodesQuery['type']> = ['random', 'response']; const options: Array<NodesQuery['type']> = ['random', 'response', 'random edges'];

@ -19,7 +19,7 @@ import { DataSourceWithBackend, getBackendSrv, getGrafanaLiveSrv, getTemplateSrv
import { getSearchFilterScopedVar } from 'app/features/variables/utils'; import { getSearchFilterScopedVar } from 'app/features/variables/utils';
import { queryMetricTree } from './metricTree'; import { queryMetricTree } from './metricTree';
import { generateRandomNodes, savedNodesResponse } from './nodeGraphUtils'; import { generateRandomEdges, generateRandomNodes, savedNodesResponse } from './nodeGraphUtils';
import { runStream } from './runStreams'; import { runStream } from './runStreams';
import { flameGraphData } from './testData/flameGraphResponse'; import { flameGraphData } from './testData/flameGraphResponse';
import { Scenario, TestDataQuery } from './types'; import { Scenario, TestDataQuery } from './types';
@ -210,6 +210,9 @@ export class TestDataDataSource extends DataSourceWithBackend<TestDataQuery> {
case 'response': case 'response':
frames = savedNodesResponse(); frames = savedNodesResponse();
break; break;
case 'random edges':
frames = [generateRandomEdges(target.nodes?.count)];
break;
default: default:
throw new Error(`Unknown node_graph sub type ${type}`); throw new Error(`Unknown node_graph sub type ${type}`);
} }

@ -13,7 +13,7 @@ export function generateRandomNodes(count = 10) {
const nodes = []; const nodes = [];
const root = { const root = {
id: '0', id: 'root',
title: 'root', title: 'root',
subTitle: 'client', subTitle: 'client',
success: 1, success: 1,
@ -44,11 +44,11 @@ export function generateRandomNodes(count = 10) {
for (let i = 0; i <= additionalEdges; i++) { for (let i = 0; i <= additionalEdges; i++) {
const sourceIndex = Math.floor(Math.random() * Math.floor(nodes.length - 1)); const sourceIndex = Math.floor(Math.random() * Math.floor(nodes.length - 1));
const targetIndex = Math.floor(Math.random() * Math.floor(nodes.length - 1)); const targetIndex = Math.floor(Math.random() * Math.floor(nodes.length - 1));
if (sourceIndex === targetIndex || nodes[sourceIndex].id === '0' || nodes[sourceIndex].id === '0') { if (sourceIndex === targetIndex || nodes[sourceIndex].id === '0' || nodes[targetIndex].id === '0') {
continue; continue;
} }
nodes[sourceIndex].edges.push(nodes[sourceIndex].id); nodes[sourceIndex].edges.push(nodes[targetIndex].id);
} }
const nodeFields: Record<string, Omit<FieldDTO, 'name'> & { values: ArrayVector }> = { const nodeFields: Record<string, Omit<FieldDTO, 'name'> & { values: ArrayVector }> = {
@ -108,27 +108,14 @@ export function generateRandomNodes(count = 10) {
meta: { preferredVisualisationType: 'nodeGraph' }, meta: { preferredVisualisationType: 'nodeGraph' },
}); });
const edgeFields: any = {
[NodeGraphDataFrameFieldNames.id]: {
values: new ArrayVector(),
type: FieldType.string,
},
[NodeGraphDataFrameFieldNames.source]: {
values: new ArrayVector(),
type: FieldType.string,
},
[NodeGraphDataFrameFieldNames.target]: {
values: new ArrayVector(),
type: FieldType.string,
},
};
const edgesFrame = new MutableDataFrame({ const edgesFrame = new MutableDataFrame({
name: 'edges', name: 'edges',
fields: Object.keys(edgeFields).map((key) => ({ fields: [
...edgeFields[key], { name: NodeGraphDataFrameFieldNames.id, values: new ArrayVector(), type: FieldType.string },
name: key, { name: NodeGraphDataFrameFieldNames.source, values: new ArrayVector(), type: FieldType.string },
})), { name: NodeGraphDataFrameFieldNames.target, values: new ArrayVector(), type: FieldType.string },
{ name: NodeGraphDataFrameFieldNames.mainStat, values: new ArrayVector(), type: FieldType.number },
],
meta: { preferredVisualisationType: 'nodeGraph' }, meta: { preferredVisualisationType: 'nodeGraph' },
}); });
@ -148,9 +135,10 @@ export function generateRandomNodes(count = 10) {
continue; continue;
} }
edgesSet.add(id); edgesSet.add(id);
edgeFields.id.values.add(`${node.id}--${edge}`); edgesFrame.fields[0].values.add(`${node.id}--${edge}`);
edgeFields.source.values.add(node.id); edgesFrame.fields[1].values.add(node.id);
edgeFields.target.values.add(edge); edgesFrame.fields[2].values.add(edge);
edgesFrame.fields[3].values.add(Math.random() * 100);
} }
} }
@ -161,7 +149,7 @@ function makeRandomNode(index: number) {
const success = Math.random(); const success = Math.random();
const error = 1 - success; const error = 1 - success;
return { return {
id: index.toString(), id: `service:${index}`,
title: `service:${index}`, title: `service:${index}`,
subTitle: 'service', subTitle: 'service',
success, success,
@ -175,3 +163,8 @@ function makeRandomNode(index: number) {
export function savedNodesResponse(): any { export function savedNodesResponse(): any {
return [new MutableDataFrame(nodes), new MutableDataFrame(edges)]; return [new MutableDataFrame(nodes), new MutableDataFrame(edges)];
} }
// Generates node graph data but only returns the edges
export function generateRandomEdges(count = 10) {
return generateRandomNodes(count)[1];
}

@ -30,7 +30,7 @@ export interface TestDataQuery extends DataQuery {
} }
export interface NodesQuery { export interface NodesQuery {
type?: 'random' | 'response'; type?: 'random' | 'response' | 'random edges';
count?: number; count?: number;
} }

@ -96,9 +96,14 @@ export const Node = memo(function Node(props: {
<g className={styles.text}> <g className={styles.text}>
<foreignObject x={node.x - (isHovered ? 100 : 35)} y={node.y - 15} width={isHovered ? '200' : '70'} height="40"> <foreignObject x={node.x - (isHovered ? 100 : 35)} y={node.y - 15} width={isHovered ? '200' : '70'} height="40">
<div className={cx(styles.statsText, isHovered && styles.textHovering)}> <div className={cx(styles.statsText, isHovered && styles.textHovering)}>
<span>{node.mainStat && statToString(node.mainStat, node.dataFrameRowIndex)}</span> <span>
{node.mainStat && statToString(node.mainStat.config, node.mainStat.values.get(node.dataFrameRowIndex))}
</span>
<br /> <br />
<span>{node.secondaryStat && statToString(node.secondaryStat, node.dataFrameRowIndex)}</span> <span>
{node.secondaryStat &&
statToString(node.secondaryStat.config, node.secondaryStat.values.get(node.dataFrameRowIndex))}
</span>
</div> </div>
</foreignObject> </foreignObject>
<foreignObject <foreignObject

@ -27,7 +27,12 @@ describe('NodeGraph', () => {
}); });
it('can zoom in and out', async () => { it('can zoom in and out', async () => {
render(<NodeGraph dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([[0, 1]])]} getLinks={() => []} />); render(
<NodeGraph
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]}
getLinks={() => []}
/>
);
const zoomIn = await screen.findByTitle(/Zoom in/); const zoomIn = await screen.findByTitle(/Zoom in/);
const zoomOut = await screen.findByTitle(/Zoom out/); const zoomOut = await screen.findByTitle(/Zoom out/);
@ -44,8 +49,8 @@ describe('NodeGraph', () => {
dataFrames={[ dataFrames={[
makeNodesDataFrame(3), makeNodesDataFrame(3),
makeEdgesDataFrame([ makeEdgesDataFrame([
[0, 1], { source: '0', target: '1' },
[1, 2], { source: '1', target: '2' },
]), ]),
]} ]}
getLinks={() => []} getLinks={() => []}
@ -70,7 +75,7 @@ describe('NodeGraph', () => {
it('shows context menu when clicking on node or edge', async () => { it('shows context menu when clicking on node or edge', async () => {
render( render(
<NodeGraph <NodeGraph
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([[0, 1]])]} dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]}
getLinks={(dataFrame) => { getLinks={(dataFrame) => {
return [ return [
{ {
@ -98,8 +103,8 @@ describe('NodeGraph', () => {
dataFrames={[ dataFrames={[
makeNodesDataFrame(3), makeNodesDataFrame(3),
makeEdgesDataFrame([ makeEdgesDataFrame([
[0, 1], { source: '0', target: '1' },
[1, 2], { source: '1', target: '2' },
]), ]),
]} ]}
getLinks={() => []} getLinks={() => []}
@ -117,8 +122,8 @@ describe('NodeGraph', () => {
dataFrames={[ dataFrames={[
makeNodesDataFrame(3), makeNodesDataFrame(3),
makeEdgesDataFrame([ makeEdgesDataFrame([
[0, 1], { source: '0', target: '1' },
[0, 2], { source: '0', target: '2' },
]), ]),
]} ]}
getLinks={() => []} getLinks={() => []}
@ -137,10 +142,10 @@ describe('NodeGraph', () => {
dataFrames={[ dataFrames={[
makeNodesDataFrame(5), makeNodesDataFrame(5),
makeEdgesDataFrame([ makeEdgesDataFrame([
[0, 1], { source: '0', target: '1' },
[0, 2], { source: '0', target: '2' },
[2, 3], { source: '2', target: '3' },
[3, 4], { source: '3', target: '4' },
]), ]),
]} ]}
getLinks={() => []} getLinks={() => []}
@ -162,10 +167,10 @@ describe('NodeGraph', () => {
dataFrames={[ dataFrames={[
makeNodesDataFrame(5), makeNodesDataFrame(5),
makeEdgesDataFrame([ makeEdgesDataFrame([
[0, 1], { source: '0', target: '1' },
[1, 2], { source: '1', target: '2' },
[2, 3], { source: '2', target: '3' },
[3, 4], { source: '3', target: '4' },
]), ]),
]} ]}
getLinks={() => []} getLinks={() => []}
@ -192,8 +197,8 @@ describe('NodeGraph', () => {
dataFrames={[ dataFrames={[
makeNodesDataFrame(3), makeNodesDataFrame(3),
makeEdgesDataFrame([ makeEdgesDataFrame([
[0, 1], { source: '0', target: '1' },
[1, 2], { source: '1', target: '2' },
]), ]),
]} ]}
getLinks={() => []} getLinks={() => []}

@ -4,7 +4,7 @@ import React, { memo, MouseEvent, useCallback, useEffect, useMemo, useState } fr
import useMeasure from 'react-use/lib/useMeasure'; import useMeasure from 'react-use/lib/useMeasure';
import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data'; import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data';
import { Icon, Spinner, useStyles2, useTheme2 } from '@grafana/ui'; import { Icon, Spinner, useStyles2 } from '@grafana/ui';
import { Edge } from './Edge'; import { Edge } from './Edge';
import { EdgeArrowMarker } from './EdgeArrowMarker'; import { EdgeArrowMarker } from './EdgeArrowMarker';
@ -123,13 +123,11 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) {
const firstNodesDataFrame = nodesDataFrames[0]; const firstNodesDataFrame = nodesDataFrames[0];
const firstEdgesDataFrame = edgesDataFrames[0]; const firstEdgesDataFrame = edgesDataFrames[0];
const theme = useTheme2();
// TODO we should be able to allow multiple dataframes for both edges and nodes, could be issue with node ids which in // TODO we should be able to allow multiple dataframes for both edges and nodes, could be issue with node ids which in
// that case should be unique or figure a way to link edges and nodes dataframes together. // that case should be unique or figure a way to link edges and nodes dataframes together.
const processed = useMemo( const processed = useMemo(
() => processNodes(firstNodesDataFrame, firstEdgesDataFrame, theme), () => processNodes(firstNodesDataFrame, firstEdgesDataFrame),
[firstEdgesDataFrame, firstNodesDataFrame, theme] [firstEdgesDataFrame, firstNodesDataFrame]
); );
// We need hover state here because for nodes we also highlight edges and for edges have labels separate to make // We need hover state here because for nodes we also highlight edges and for edges have labels separate to make
@ -162,7 +160,7 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) {
focusedNodeId focusedNodeId
); );
// If we move from grid to graph layout and we have focused node lets get its position to center there. We want do // If we move from grid to graph layout, and we have focused node lets get its position to center there. We want to
// do it specifically only in that case. // do it specifically only in that case.
const focusPosition = useFocusPositionOnLayout(config, nodes, focusedNodeId); const focusPosition = useFocusPositionOnLayout(config, nodes, focusedNodeId);
const { panRef, zoomRef, onStepUp, onStepDown, isPanning, position, scale, isMaxZoom, isMinZoom } = usePanAndZoom( const { panRef, zoomRef, onStepUp, onStepDown, isPanning, position, scale, isMaxZoom, isMinZoom } = usePanAndZoom(
@ -180,7 +178,7 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) {
); );
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
// This cannot be inline func or it will create infinite render cycle. // This cannot be inline func, or it will create infinite render cycle.
const topLevelRef = useCallback( const topLevelRef = useCallback(
(r: HTMLDivElement) => { (r: HTMLDivElement) => {
measureRef(r); measureRef(r);

@ -199,7 +199,7 @@ function gridLayout(
const val1 = sort!.field.values.get(node1.dataFrameRowIndex); const val1 = sort!.field.values.get(node1.dataFrameRowIndex);
const val2 = sort!.field.values.get(node2.dataFrameRowIndex); const val2 = sort!.field.values.get(node2.dataFrameRowIndex);
// Lets pretend we don't care about type of the stats for a while (they can be strings) // Let's pretend we don't care about type of the stats for a while (they can be strings)
return sort!.ascending ? val1 - val2 : val2 - val1; return sort!.ascending ? val1 - val2 : val2 - val1;
}); });
} }

@ -35,6 +35,8 @@ export type NodeDatum = SimulationNodeDatum & {
color?: Field; color?: Field;
}; };
export type NodeDatumFromEdge = NodeDatum & { mainStatNumeric?: number; secondaryStatNumeric?: number };
// This is the data we have before the graph is laid out with source and target being string IDs. // This is the data we have before the graph is laid out with source and target being string IDs.
type LinkDatum = SimulationLinkDatum<NodeDatum> & { type LinkDatum = SimulationLinkDatum<NodeDatum> & {
source: string; source: string;

@ -1,7 +1,7 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React, { MouseEvent, useCallback, useState } from 'react'; import React, { MouseEvent, useCallback, useState } from 'react';
import { DataFrame, Field, GrafanaTheme2, LinkModel } from '@grafana/data'; import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data';
import { ContextMenu, MenuGroup, MenuItem, useStyles2, useTheme2 } from '@grafana/ui'; import { ContextMenu, MenuGroup, MenuItem, useStyles2, useTheme2 } from '@grafana/ui';
import { Config } from './layout'; import { Config } from './layout';
@ -14,8 +14,10 @@ import { getEdgeFields, getNodeFields } from './utils';
*/ */
export function useContextMenu( export function useContextMenu(
getLinks: (dataFrame: DataFrame, rowIndex: number) => LinkModel[], getLinks: (dataFrame: DataFrame, rowIndex: number) => LinkModel[],
nodes: DataFrame, // This can be undefined if we only use edge dataframe
edges: DataFrame, nodes: DataFrame | undefined,
// This can be undefined if we have only single node
edges: DataFrame | undefined,
config: Config, config: Config,
setConfig: (config: Config) => void, setConfig: (config: Config) => void,
setFocusedNodeId: (id: string) => void setFocusedNodeId: (id: string) => void
@ -28,13 +30,9 @@ export function useContextMenu(
const onNodeOpen = useCallback( const onNodeOpen = useCallback(
(event: MouseEvent<SVGElement>, node: NodeDatum) => { (event: MouseEvent<SVGElement>, node: NodeDatum) => {
let label = 'Show in Grid layout'; const [label, showGridLayout] = config.gridLayout
let showGridLayout = true; ? ['Show in Graph layout', false]
: ['Show in Grid layout', true];
if (config.gridLayout) {
label = 'Show in Graph layout';
showGridLayout = false;
}
const extraNodeItem = [ const extraNodeItem = [
{ {
@ -47,18 +45,11 @@ export function useContextMenu(
}, },
]; ];
const renderer = getItemsRenderer(getLinks(nodes, node.dataFrameRowIndex), node, extraNodeItem); const links = nodes ? getLinks(nodes, node.dataFrameRowIndex) : [];
const renderer = getItemsRenderer(links, node, extraNodeItem);
if (renderer) { if (renderer) {
setMenu( setMenu(makeContextMenu(<NodeHeader node={node} nodes={nodes} />, renderer, event, setMenu));
<ContextMenu
renderHeader={() => <NodeHeader node={node} nodes={nodes} />}
renderMenuItems={renderer}
onClose={() => setMenu(undefined)}
x={event.pageX}
y={event.pageY}
/>
);
} }
}, },
[config, nodes, getLinks, setMenu, setConfig, setFocusedNodeId] [config, nodes, getLinks, setMenu, setConfig, setFocusedNodeId]
@ -66,18 +57,16 @@ export function useContextMenu(
const onEdgeOpen = useCallback( const onEdgeOpen = useCallback(
(event: MouseEvent<SVGElement>, edge: EdgeDatum) => { (event: MouseEvent<SVGElement>, edge: EdgeDatum) => {
const renderer = getItemsRenderer(getLinks(edges, edge.dataFrameRowIndex), edge); if (!edges) {
// This could happen if we have only one node and no edges, in which case this is not needed as there is no edge
// to click on.
return;
}
const links = getLinks(edges, edge.dataFrameRowIndex);
const renderer = getItemsRenderer(links, edge);
if (renderer) { if (renderer) {
setMenu( setMenu(makeContextMenu(<EdgeHeader edge={edge} edges={edges} />, renderer, event, setMenu));
<ContextMenu
renderHeader={() => <EdgeHeader edge={edge} edges={edges} />}
renderMenuItems={renderer}
onClose={() => setMenu(undefined)}
x={event.pageX}
y={event.pageY}
/>
);
} }
}, },
[edges, getLinks, setMenu] [edges, getLinks, setMenu]
@ -86,6 +75,23 @@ export function useContextMenu(
return { onEdgeOpen, onNodeOpen, MenuComponent: menu }; return { onEdgeOpen, onNodeOpen, MenuComponent: menu };
} }
function makeContextMenu(
header: JSX.Element,
renderer: () => React.ReactNode,
event: MouseEvent<SVGElement>,
setMenu: (el: JSX.Element | undefined) => void
) {
return (
<ContextMenu
renderHeader={() => header}
renderMenuItems={renderer}
onClose={() => setMenu(undefined)}
x={event.pageX}
y={event.pageY}
/>
);
}
function getItemsRenderer<T extends NodeDatum | EdgeDatum>( function getItemsRenderer<T extends NodeDatum | EdgeDatum>(
links: LinkModel[], links: LinkModel[],
item: T, item: T,
@ -173,24 +179,45 @@ function getItems(links: LinkModel[]) {
}); });
} }
function NodeHeader(props: { node: NodeDatum; nodes: DataFrame }) { function NodeHeader({ node, nodes }: { node: NodeDatum; nodes?: DataFrame }) {
const index = props.node.dataFrameRowIndex; const index = node.dataFrameRowIndex;
const fields = getNodeFields(props.nodes); if (nodes) {
return ( const fields = getNodeFields(nodes);
<div>
{fields.title && <Label field={fields.title} index={index} />} return (
{fields.subTitle && <Label field={fields.subTitle} index={index} />} <div>
{fields.details.map((f) => ( {fields.title && (
<Label key={f.name} field={f} index={index} /> <Label
))} label={fields.title.config.displayName || fields.title.name}
</div> value={fields.title.values.get(index) || ''}
); />
)}
{fields.subTitle && (
<Label
label={fields.subTitle.config.displayName || fields.subTitle.name}
value={fields.subTitle.values.get(index) || ''}
/>
)}
{fields.details.map((f) => (
<Label key={f.name} label={f.config.displayName || f.name} value={f.values.get(index) || ''} />
))}
</div>
);
} else {
// Fallback if we don't have nodes dataFrame. Can happen if we use just the edges frame to construct this.
return (
<div>
{node.title && <Label label={'Title'} value={node.title} />}
{node.subTitle && <Label label={'Subtitle'} value={node.subTitle} />}
</div>
);
}
} }
function EdgeHeader(props: { edge: EdgeDatum; edges: DataFrame }) { function EdgeHeader(props: { edge: EdgeDatum; edges: DataFrame }) {
const index = props.edge.dataFrameRowIndex; const index = props.edge.dataFrameRowIndex;
const fields = getEdgeFields(props.edges);
const styles = getLabelStyles(useTheme2()); const styles = getLabelStyles(useTheme2());
const fields = getEdgeFields(props.edges);
const valueSource = fields.source?.values.get(index) || ''; const valueSource = fields.source?.values.get(index) || '';
const valueTarget = fields.target?.values.get(index) || ''; const valueTarget = fields.target?.values.get(index) || '';
@ -205,20 +232,18 @@ function EdgeHeader(props: { edge: EdgeDatum; edges: DataFrame }) {
</div> </div>
)} )}
{fields.details.map((f) => ( {fields.details.map((f) => (
<Label key={f.name} field={f} index={index} /> <Label key={f.name} label={f.config.displayName || f.name} value={f.values.get(index) || ''} />
))} ))}
</div> </div>
); );
} }
function Label(props: { field: Field; index: number }) { function Label({ label, value }: { label: string; value: string | number }) {
const { field, index } = props;
const value = field.values.get(index) || '';
const styles = useStyles2(getLabelStyles); const styles = useStyles2(getLabelStyles);
return ( return (
<div className={styles.label}> <div className={styles.label}>
<div>{field.config.displayName || field.name}</div> <div>{label}</div>
<span className={styles.value}>{value}</span> <span className={styles.value}>{value}</span>
</div> </div>
); );

@ -1,6 +1,6 @@
import { ArrayVector, createTheme, DataFrame, FieldType, MutableDataFrame } from '@grafana/data'; import { ArrayVector, DataFrame, FieldType, MutableDataFrame } from '@grafana/data';
import { NodeGraphOptions } from './types'; import { NodeDatum, NodeGraphOptions } from './types';
import { import {
findConnectedNodesForEdge, findConnectedNodesForEdge,
findConnectedNodesForNode, findConnectedNodesForNode,
@ -13,196 +13,27 @@ import {
} from './utils'; } from './utils';
describe('processNodes', () => { describe('processNodes', () => {
const theme = createTheme();
it('handles empty args', async () => { it('handles empty args', async () => {
expect(processNodes(undefined, undefined, theme)).toEqual({ nodes: [], edges: [] }); expect(processNodes(undefined, undefined)).toEqual({ nodes: [], edges: [] });
}); });
it('returns proper nodes and edges', async () => { it('returns proper nodes and edges', async () => {
const { nodes, edges, legend } = processNodes( const { nodes, edges, legend } = processNodes(
makeNodesDataFrame(3), makeNodesDataFrame(3),
makeEdgesDataFrame([ makeEdgesDataFrame([
[0, 1], { source: '0', target: '1' },
[0, 2], { source: '0', target: '2' },
[1, 2], { source: '1', target: '2' },
]), ])
theme
); );
const colorField = {
config: {
color: {
mode: 'continuous-GrYlRd',
},
},
index: 7,
name: 'color',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
};
expect(nodes).toEqual([ expect(nodes).toEqual([
{ makeNodeDatum(),
arcSections: [ makeNodeDatum({ dataFrameRowIndex: 1, id: '1', incoming: 1, title: 'service:1' }),
{ makeNodeDatum({ dataFrameRowIndex: 2, id: '2', incoming: 2, title: 'service:2' }),
config: {
color: {
fixedColor: 'green',
},
},
name: 'arc__success',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
},
{
config: {
color: {
fixedColor: 'red',
},
},
name: 'arc__errors',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
},
],
color: colorField,
dataFrameRowIndex: 0,
id: '0',
incoming: 0,
mainStat: {
config: {},
index: 3,
name: 'mainstat',
type: 'number',
values: new ArrayVector([0.1, 0.1, 0.1]),
},
secondaryStat: {
config: {},
index: 4,
name: 'secondarystat',
type: 'number',
values: new ArrayVector([2, 2, 2]),
},
subTitle: 'service',
title: 'service:0',
},
{
arcSections: [
{
config: {
color: {
fixedColor: 'green',
},
},
name: 'arc__success',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
},
{
config: {
color: {
fixedColor: 'red',
},
},
name: 'arc__errors',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
},
],
color: colorField,
dataFrameRowIndex: 1,
id: '1',
incoming: 1,
mainStat: {
config: {},
index: 3,
name: 'mainstat',
type: 'number',
values: new ArrayVector([0.1, 0.1, 0.1]),
},
secondaryStat: {
config: {},
index: 4,
name: 'secondarystat',
type: 'number',
values: new ArrayVector([2, 2, 2]),
},
subTitle: 'service',
title: 'service:1',
},
{
arcSections: [
{
config: {
color: {
fixedColor: 'green',
},
},
name: 'arc__success',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
},
{
config: {
color: {
fixedColor: 'red',
},
},
name: 'arc__errors',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
},
],
color: colorField,
dataFrameRowIndex: 2,
id: '2',
incoming: 2,
mainStat: {
config: {},
index: 3,
name: 'mainstat',
type: 'number',
values: new ArrayVector([0.1, 0.1, 0.1]),
},
secondaryStat: {
config: {},
index: 4,
name: 'secondarystat',
type: 'number',
values: new ArrayVector([2, 2, 2]),
},
subTitle: 'service',
title: 'service:2',
},
]); ]);
expect(edges).toEqual([ expect(edges).toEqual([makeEdgeDatum('0--1', 0), makeEdgeDatum('0--2', 1), makeEdgeDatum('1--2', 2)]);
{
dataFrameRowIndex: 0,
id: '0--1',
mainStat: '',
secondaryStat: '',
source: '0',
target: '1',
},
{
dataFrameRowIndex: 1,
id: '0--2',
mainStat: '',
secondaryStat: '',
source: '0',
target: '2',
},
{
dataFrameRowIndex: 2,
id: '1--2',
mainStat: '',
secondaryStat: '',
source: '1',
target: '2',
},
]);
expect(legend).toEqual([ expect(legend).toEqual([
{ {
@ -216,6 +47,38 @@ describe('processNodes', () => {
]); ]);
}); });
it('returns nodes just from edges dataframe', () => {
const { nodes, edges } = processNodes(
undefined,
makeEdgesDataFrame([
{ source: '0', target: '1', mainstat: 1, secondarystat: 1 },
{ source: '0', target: '2', mainstat: 1, secondarystat: 1 },
{ source: '1', target: '2', mainstat: 1, secondarystat: 1 },
])
);
expect(nodes).toEqual([
expect.objectContaining(makeNodeFromEdgeDatum({ dataFrameRowIndex: 0, title: '0' })),
expect.objectContaining(makeNodeFromEdgeDatum({ dataFrameRowIndex: 1, id: '1', incoming: 1, title: '1' })),
expect.objectContaining(makeNodeFromEdgeDatum({ dataFrameRowIndex: 2, id: '2', incoming: 2, title: '2' })),
]);
expect(nodes[0].mainStat?.values).toEqual(new ArrayVector([undefined, 1, 2]));
expect(nodes[0].secondaryStat?.values).toEqual(new ArrayVector([undefined, 1, 2]));
expect(nodes[0].mainStat).toEqual(nodes[1].mainStat);
expect(nodes[0].mainStat).toEqual(nodes[2].mainStat);
expect(nodes[0].secondaryStat).toEqual(nodes[1].secondaryStat);
expect(nodes[0].secondaryStat).toEqual(nodes[2].secondaryStat);
expect(edges).toEqual([
makeEdgeDatum('0--1', 0, '1.00', '1.00'),
makeEdgeDatum('0--2', 1, '1.00', '1.00'),
makeEdgeDatum('1--2', 2, '1.00', '1.00'),
]);
});
it('detects dataframes correctly', () => { it('detects dataframes correctly', () => {
const validFrames = [ const validFrames = [
new MutableDataFrame({ new MutableDataFrame({
@ -363,17 +226,14 @@ describe('processNodes', () => {
}); });
describe('finds connections', () => { describe('finds connections', () => {
const theme = createTheme();
it('finds connected nodes given an edge id', () => { it('finds connected nodes given an edge id', () => {
const { nodes, edges } = processNodes( const { nodes, edges } = processNodes(
makeNodesDataFrame(3), makeNodesDataFrame(3),
makeEdgesDataFrame([ makeEdgesDataFrame([
[0, 1], { source: '0', target: '1' },
[0, 2], { source: '0', target: '2' },
[1, 2], { source: '1', target: '2' },
]), ])
theme
); );
const linked = findConnectedNodesForEdge(nodes, edges, edges[0].id); const linked = findConnectedNodesForEdge(nodes, edges, edges[0].id);
@ -384,14 +244,96 @@ describe('finds connections', () => {
const { nodes, edges } = processNodes( const { nodes, edges } = processNodes(
makeNodesDataFrame(4), makeNodesDataFrame(4),
makeEdgesDataFrame([ makeEdgesDataFrame([
[0, 1], { source: '0', target: '1' },
[0, 2], { source: '0', target: '2' },
[1, 2], { source: '1', target: '2' },
]), ])
theme
); );
const linked = findConnectedNodesForNode(nodes, edges, nodes[0].id); const linked = findConnectedNodesForNode(nodes, edges, nodes[0].id);
expect(linked).toEqual(['0', '1', '2']); expect(linked).toEqual(['0', '1', '2']);
}); });
}); });
function makeNodeDatum(options: Partial<NodeDatum> = {}) {
const colorField = {
config: {
color: {
mode: 'continuous-GrYlRd',
},
},
index: 7,
name: 'color',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
};
return {
arcSections: [
{
config: {
color: {
fixedColor: 'green',
},
},
name: 'arc__success',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
},
{
config: {
color: {
fixedColor: 'red',
},
},
name: 'arc__errors',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
},
],
color: colorField,
dataFrameRowIndex: 0,
id: '0',
incoming: 0,
mainStat: {
config: {},
index: 3,
name: 'mainstat',
type: 'number',
values: new ArrayVector([0.1, 0.1, 0.1]),
},
secondaryStat: {
config: {},
index: 4,
name: 'secondarystat',
type: 'number',
values: new ArrayVector([2, 2, 2]),
},
subTitle: 'service',
title: 'service:0',
...options,
};
}
function makeEdgeDatum(id: string, index: number, mainStat = '', secondaryStat = '') {
return {
dataFrameRowIndex: index,
id,
mainStat,
secondaryStat,
source: id.split('--')[0],
target: id.split('--')[1],
};
}
function makeNodeFromEdgeDatum(options: Partial<NodeDatum> = {}): NodeDatum {
return {
arcSections: [],
dataFrameRowIndex: 0,
id: '0',
incoming: 0,
subTitle: '',
title: 'service:0',
...options,
};
}

@ -4,13 +4,13 @@ import {
Field, Field,
FieldCache, FieldCache,
FieldColorModeId, FieldColorModeId,
FieldConfig,
FieldType, FieldType,
GrafanaTheme2,
MutableDataFrame, MutableDataFrame,
NodeGraphDataFrameFieldNames, NodeGraphDataFrameFieldNames,
} from '@grafana/data'; } from '@grafana/data';
import { EdgeDatum, NodeDatum, NodeGraphOptions } from './types'; import { EdgeDatum, NodeDatum, NodeDatumFromEdge, NodeGraphOptions } from './types';
type Line = { x1: number; y1: number; x2: number; y2: number }; type Line = { x1: number; y1: number; x2: number; y2: number };
@ -36,7 +36,18 @@ export function shortenLine(line: Line, length: number): Line {
}; };
} }
export function getNodeFields(nodes: DataFrame) { export type NodeFields = {
id?: Field;
title?: Field;
subTitle?: Field;
mainStat?: Field;
secondaryStat?: Field;
arc: Field[];
details: Field[];
color?: Field;
};
export function getNodeFields(nodes: DataFrame): NodeFields {
const normalizedFrames = { const normalizedFrames = {
...nodes, ...nodes,
fields: nodes.fields.map((field) => ({ ...field, name: field.name.toLowerCase() })), fields: nodes.fields.map((field) => ({ ...field, name: field.name.toLowerCase() })),
@ -54,7 +65,16 @@ export function getNodeFields(nodes: DataFrame) {
}; };
} }
export function getEdgeFields(edges: DataFrame) { export type EdgeFields = {
id?: Field;
source?: Field;
target?: Field;
mainStat?: Field;
secondaryStat?: Field;
details: Field[];
};
export function getEdgeFields(edges: DataFrame): EdgeFields {
const normalizedFrames = { const normalizedFrames = {
...edges, ...edges,
fields: edges.fields.map((field) => ({ ...field, name: field.name.toLowerCase() })), fields: edges.fields.map((field) => ({ ...field, name: field.name.toLowerCase() })),
@ -70,7 +90,7 @@ export function getEdgeFields(edges: DataFrame) {
}; };
} }
function findFieldsByPrefix(frame: DataFrame, prefix: string) { function findFieldsByPrefix(frame: DataFrame, prefix: string): Field[] {
return frame.fields.filter((f) => f.name.match(new RegExp('^' + prefix))); return frame.fields.filter((f) => f.name.match(new RegExp('^' + prefix)));
} }
@ -79,8 +99,7 @@ function findFieldsByPrefix(frame: DataFrame, prefix: string) {
*/ */
export function processNodes( export function processNodes(
nodes: DataFrame | undefined, nodes: DataFrame | undefined,
edges: DataFrame | undefined, edges: DataFrame | undefined
theme: GrafanaTheme2
): { ): {
nodes: NodeDatum[]; nodes: NodeDatum[];
edges: EdgeDatum[]; edges: EdgeDatum[];
@ -89,76 +108,206 @@ export function processNodes(
name: string; name: string;
}>; }>;
} { } {
if (!nodes) { if (!(edges || nodes)) {
return { nodes: [], edges: [] }; return { nodes: [], edges: [] };
} }
const nodeFields = getNodeFields(nodes); if (nodes) {
if (!nodeFields.id) { const nodeFields = getNodeFields(nodes);
throw new Error('id field is required for nodes data frame.'); if (!nodeFields.id) {
} throw new Error('id field is required for nodes data frame.');
}
const nodesMap = // Create the nodes here
nodeFields.id.values.toArray().reduce<{ [id: string]: NodeDatum }>((acc, id, index) => { const nodesMap: { [id: string]: NodeDatum } = {};
acc[id] = { for (let i = 0; i < nodeFields.id.values.length; i++) {
id: id, const id = nodeFields.id.values.get(i);
title: nodeFields.title?.values.get(index) || '', nodesMap[id] = makeNodeDatum(id, nodeFields, i);
subTitle: nodeFields.subTitle ? nodeFields.subTitle.values.get(index) : '', }
dataFrameRowIndex: index,
incoming: 0, // We may not have edges in case of single node
mainStat: nodeFields.mainStat, let edgeDatums: EdgeDatum[] = edges ? processEdges(edges, getEdgeFields(edges)) : [];
secondaryStat: nodeFields.secondaryStat,
arcSections: nodeFields.arc, for (const e of edgeDatums) {
color: nodeFields.color, // We are adding incoming edges count, so we can later on find out which nodes are the roots
}; nodesMap[e.target].incoming++;
return acc; }
}, {}) || {};
return {
nodes: Object.values(nodesMap),
edges: edgeDatums,
legend: nodeFields.arc.map((f) => {
return {
color: f.config.color?.fixedColor ?? '',
name: f.config.displayName || f.name,
};
}),
};
} else {
// We have only edges here, so we have to construct also nodes out of them
// We checked that either node || edges has to be defined and if nodes aren't edges has to be defined
edges = edges!;
const nodesMap: { [id: string]: NodeDatumFromEdge } = {};
let edgesMapped: EdgeDatum[] = [];
// We may not have edges in case of single node
if (edges) {
const edgeFields = getEdgeFields(edges); const edgeFields = getEdgeFields(edges);
if (!edgeFields.id) { let edgeDatums = processEdges(edges, edgeFields);
throw new Error('id field is required for edges data frame.');
// Turn edges into reasonable filled in nodes
for (let i = 0; i < edgeDatums.length; i++) {
const edge = edgeDatums[i];
const { source, target } = makeNodeDatumsFromEdge(edgeFields, i);
nodesMap[target.id] = nodesMap[target.id] || target;
nodesMap[source.id] = nodesMap[source.id] || source;
// Check the stats fields. They can be also strings which we cannot really aggregate so only aggregate in case
// they are numbers. Here we just sum all incoming edges to get the final value for node.
if (computableField(edgeFields.mainStat)) {
nodesMap[target.id].mainStatNumeric =
(nodesMap[target.id].mainStatNumeric ?? 0) + edgeFields.mainStat!.values.get(i);
}
if (computableField(edgeFields.secondaryStat)) {
nodesMap[target.id].secondaryStatNumeric =
(nodesMap[target.id].secondaryStatNumeric ?? 0) + edgeFields.secondaryStat!.values.get(i);
}
// We are adding incoming edges count, so we can later on find out which nodes are the roots
nodesMap[edge.target].incoming++;
} }
edgesMapped = edgeFields.id.values.toArray().map((id, index) => { // It is expected for stats to be Field, so we have to create them.
const target = edgeFields.target?.values.get(index); const nodes = normalizeStatsForNodes(nodesMap, edgeFields);
const source = edgeFields.source?.values.get(index);
// We are adding incoming edges count so we can later on find out which nodes are the roots return {
nodesMap[target].incoming++; nodes,
edges: edgeDatums,
return { };
id,
dataFrameRowIndex: index,
source,
target,
mainStat: edgeFields.mainStat ? statToString(edgeFields.mainStat, index) : '',
secondaryStat: edgeFields.secondaryStat ? statToString(edgeFields.secondaryStat, index) : '',
} as EdgeDatum;
});
} }
}
return { /**
nodes: Object.values(nodesMap), * Turn data frame data into EdgeDatum that node graph understands
edges: edgesMapped || [], * @param edges
legend: nodeFields.arc.map((f) => { * @param edgeFields
return { */
color: f.config.color?.fixedColor ?? '', function processEdges(edges: DataFrame, edgeFields: EdgeFields): EdgeDatum[] {
name: f.config.displayName || f.name, if (!edgeFields.id) {
throw new Error('id field is required for edges data frame.');
}
return edgeFields.id.values.toArray().map((id, index) => {
const target = edgeFields.target?.values.get(index);
const source = edgeFields.source?.values.get(index);
return {
id,
dataFrameRowIndex: index,
source,
target,
mainStat: edgeFields.mainStat
? statToString(edgeFields.mainStat.config, edgeFields.mainStat.values.get(index))
: '',
secondaryStat: edgeFields.secondaryStat
? statToString(edgeFields.secondaryStat.config, edgeFields.secondaryStat.values.get(index))
: '',
} as EdgeDatum;
});
}
function computableField(field?: Field) {
return field && field.type === FieldType.number;
}
/**
* Instead of just simple numbers node graph requires to have Field in NodeDatum (probably for some formatting info in
* config). So we create them here and fill with correct data.
* @param nodesMap
* @param edgeFields
*/
function normalizeStatsForNodes(nodesMap: { [id: string]: NodeDatumFromEdge }, edgeFields: EdgeFields): NodeDatum[] {
const secondaryStatValues = new ArrayVector();
const mainStatValues = new ArrayVector();
const secondaryStatField = computableField(edgeFields.secondaryStat)
? {
...edgeFields.secondaryStat!,
values: secondaryStatValues,
}
: undefined;
const mainStatField = computableField(edgeFields.mainStat)
? {
...edgeFields.mainStat!,
values: mainStatValues,
}
: undefined;
return Object.values(nodesMap).map((node, index) => {
if (mainStatField || secondaryStatField) {
const newNode = {
...node,
}; };
}),
if (mainStatField) {
newNode.mainStat = mainStatField;
mainStatValues.add(node.mainStatNumeric);
newNode.dataFrameRowIndex = index;
}
if (secondaryStatField) {
newNode.secondaryStat = secondaryStatField;
secondaryStatValues.add(node.secondaryStatNumeric);
newNode.dataFrameRowIndex = index;
}
return newNode;
}
return node;
});
}
function makeNodeDatumsFromEdge(edgeFields: EdgeFields, index: number) {
const targetId = edgeFields.target?.values.get(index);
const sourceId = edgeFields.source?.values.get(index);
return {
target: makeSimpleNodeDatum(targetId, index),
source: makeSimpleNodeDatum(sourceId, index),
}; };
} }
export function statToString(field: Field, index: number) { function makeSimpleNodeDatum(name: string, index: number): NodeDatumFromEdge {
if (field.type === FieldType.string) { return {
return field.values.get(index); id: name,
title: name,
subTitle: '',
dataFrameRowIndex: index,
incoming: 0,
arcSections: [],
};
}
function makeNodeDatum(id: string, nodeFields: NodeFields, index: number) {
return {
id: id,
title: nodeFields.title?.values.get(index) || '',
subTitle: nodeFields.subTitle ? nodeFields.subTitle.values.get(index) : '',
dataFrameRowIndex: index,
incoming: 0,
mainStat: nodeFields.mainStat,
secondaryStat: nodeFields.secondaryStat,
arcSections: nodeFields.arc,
color: nodeFields.color,
};
}
export function statToString(config: FieldConfig, value: number | string): string {
if (typeof value === 'string') {
return value;
} else { } else {
const decimals = field.config.decimals || 2; const decimals = config.decimals || 2;
const val = field.values.get(index); if (Number.isFinite(value)) {
if (Number.isFinite(val)) { return value.toFixed(decimals) + (config.unit ? ' ' + config.unit : '');
return field.values.get(index).toFixed(decimals) + (field.config.unit ? ' ' + field.config.unit : '');
} else { } else {
return ''; return '';
} }
@ -240,13 +389,14 @@ function nodesFrame() {
}); });
} }
export function makeEdgesDataFrame(edges: Array<[number, number]>) { export function makeEdgesDataFrame(
edges: Array<Partial<{ source: string; target: string; mainstat: number; secondarystat: number }>>
) {
const frame = edgesFrame(); const frame = edgesFrame();
for (const edge of edges) { for (const edge of edges) {
frame.add({ frame.add({
id: edge[0] + '--' + edge[1], id: edge.source + '--' + edge.target,
source: edge[0].toString(), ...edge,
target: edge[1].toString(),
}); });
} }
@ -267,6 +417,14 @@ function edgesFrame() {
values: new ArrayVector(), values: new ArrayVector(),
type: FieldType.string, type: FieldType.string,
}, },
[NodeGraphDataFrameFieldNames.mainStat]: {
values: new ArrayVector(),
type: FieldType.number,
},
[NodeGraphDataFrameFieldNames.secondaryStat]: {
values: new ArrayVector(),
type: FieldType.number,
},
}; };
return new MutableDataFrame({ return new MutableDataFrame({

Loading…
Cancel
Save