mirror of
https://github.com/grafana/grafana.git
synced 2024-11-24 09:50:29 -06:00
NodeGraph: Allow usage with single dataframe (#58448)
This commit is contained in:
parent
f1dfaa784a
commit
e033775264
@ -6837,8 +6837,7 @@ exports[`better eslint`] = {
|
||||
"public/app/plugins/datasource/testdata/nodeGraphUtils.ts:5381": [
|
||||
[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.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
|
||||
],
|
||||
"public/app/plugins/datasource/testdata/runStreams.ts:5381": [
|
||||
[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.
|
||||
|
||||
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:
|
||||
|
||||
@ -105,21 +123,3 @@ Optional fields:
|
||||
| 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`. |
|
||||
| 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}
|
||||
/>
|
||||
</InlineField>
|
||||
{type === 'random' && (
|
||||
{(type === 'random' || type === 'random edges') && (
|
||||
<InlineField label="Count" labelWidth={14}>
|
||||
<Input
|
||||
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 { queryMetricTree } from './metricTree';
|
||||
import { generateRandomNodes, savedNodesResponse } from './nodeGraphUtils';
|
||||
import { generateRandomEdges, generateRandomNodes, savedNodesResponse } from './nodeGraphUtils';
|
||||
import { runStream } from './runStreams';
|
||||
import { flameGraphData } from './testData/flameGraphResponse';
|
||||
import { Scenario, TestDataQuery } from './types';
|
||||
@ -210,6 +210,9 @@ export class TestDataDataSource extends DataSourceWithBackend<TestDataQuery> {
|
||||
case 'response':
|
||||
frames = savedNodesResponse();
|
||||
break;
|
||||
case 'random edges':
|
||||
frames = [generateRandomEdges(target.nodes?.count)];
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown node_graph sub type ${type}`);
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ export function generateRandomNodes(count = 10) {
|
||||
const nodes = [];
|
||||
|
||||
const root = {
|
||||
id: '0',
|
||||
id: 'root',
|
||||
title: 'root',
|
||||
subTitle: 'client',
|
||||
success: 1,
|
||||
@ -44,11 +44,11 @@ export function generateRandomNodes(count = 10) {
|
||||
for (let i = 0; i <= additionalEdges; i++) {
|
||||
const sourceIndex = 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;
|
||||
}
|
||||
|
||||
nodes[sourceIndex].edges.push(nodes[sourceIndex].id);
|
||||
nodes[sourceIndex].edges.push(nodes[targetIndex].id);
|
||||
}
|
||||
|
||||
const nodeFields: Record<string, Omit<FieldDTO, 'name'> & { values: ArrayVector }> = {
|
||||
@ -108,27 +108,14 @@ export function generateRandomNodes(count = 10) {
|
||||
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({
|
||||
name: 'edges',
|
||||
fields: Object.keys(edgeFields).map((key) => ({
|
||||
...edgeFields[key],
|
||||
name: key,
|
||||
})),
|
||||
fields: [
|
||||
{ name: NodeGraphDataFrameFieldNames.id, values: new ArrayVector(), type: FieldType.string },
|
||||
{ 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' },
|
||||
});
|
||||
|
||||
@ -148,9 +135,10 @@ export function generateRandomNodes(count = 10) {
|
||||
continue;
|
||||
}
|
||||
edgesSet.add(id);
|
||||
edgeFields.id.values.add(`${node.id}--${edge}`);
|
||||
edgeFields.source.values.add(node.id);
|
||||
edgeFields.target.values.add(edge);
|
||||
edgesFrame.fields[0].values.add(`${node.id}--${edge}`);
|
||||
edgesFrame.fields[1].values.add(node.id);
|
||||
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 error = 1 - success;
|
||||
return {
|
||||
id: index.toString(),
|
||||
id: `service:${index}`,
|
||||
title: `service:${index}`,
|
||||
subTitle: 'service',
|
||||
success,
|
||||
@ -175,3 +163,8 @@ function makeRandomNode(index: number) {
|
||||
export function savedNodesResponse(): any {
|
||||
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 {
|
||||
type?: 'random' | 'response';
|
||||
type?: 'random' | 'response' | 'random edges';
|
||||
count?: number;
|
||||
}
|
||||
|
||||
|
@ -96,9 +96,14 @@ export const Node = memo(function Node(props: {
|
||||
<g className={styles.text}>
|
||||
<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)}>
|
||||
<span>{node.mainStat && statToString(node.mainStat, node.dataFrameRowIndex)}</span>
|
||||
<span>
|
||||
{node.mainStat && statToString(node.mainStat.config, node.mainStat.values.get(node.dataFrameRowIndex))}
|
||||
</span>
|
||||
<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>
|
||||
</foreignObject>
|
||||
<foreignObject
|
||||
|
@ -27,7 +27,12 @@ describe('NodeGraph', () => {
|
||||
});
|
||||
|
||||
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 zoomOut = await screen.findByTitle(/Zoom out/);
|
||||
|
||||
@ -44,8 +49,8 @@ describe('NodeGraph', () => {
|
||||
dataFrames={[
|
||||
makeNodesDataFrame(3),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
{ source: '0', target: '1' },
|
||||
{ source: '1', target: '2' },
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
@ -70,7 +75,7 @@ describe('NodeGraph', () => {
|
||||
it('shows context menu when clicking on node or edge', async () => {
|
||||
render(
|
||||
<NodeGraph
|
||||
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([[0, 1]])]}
|
||||
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]}
|
||||
getLinks={(dataFrame) => {
|
||||
return [
|
||||
{
|
||||
@ -98,8 +103,8 @@ describe('NodeGraph', () => {
|
||||
dataFrames={[
|
||||
makeNodesDataFrame(3),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
{ source: '0', target: '1' },
|
||||
{ source: '1', target: '2' },
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
@ -117,8 +122,8 @@ describe('NodeGraph', () => {
|
||||
dataFrames={[
|
||||
makeNodesDataFrame(3),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[0, 2],
|
||||
{ source: '0', target: '1' },
|
||||
{ source: '0', target: '2' },
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
@ -137,10 +142,10 @@ describe('NodeGraph', () => {
|
||||
dataFrames={[
|
||||
makeNodesDataFrame(5),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[0, 2],
|
||||
[2, 3],
|
||||
[3, 4],
|
||||
{ source: '0', target: '1' },
|
||||
{ source: '0', target: '2' },
|
||||
{ source: '2', target: '3' },
|
||||
{ source: '3', target: '4' },
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
@ -162,10 +167,10 @@ describe('NodeGraph', () => {
|
||||
dataFrames={[
|
||||
makeNodesDataFrame(5),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 3],
|
||||
[3, 4],
|
||||
{ source: '0', target: '1' },
|
||||
{ source: '1', target: '2' },
|
||||
{ source: '2', target: '3' },
|
||||
{ source: '3', target: '4' },
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
@ -192,8 +197,8 @@ describe('NodeGraph', () => {
|
||||
dataFrames={[
|
||||
makeNodesDataFrame(3),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
{ source: '0', target: '1' },
|
||||
{ source: '1', target: '2' },
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
|
@ -4,7 +4,7 @@ import React, { memo, MouseEvent, useCallback, useEffect, useMemo, useState } fr
|
||||
import useMeasure from 'react-use/lib/useMeasure';
|
||||
|
||||
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 { EdgeArrowMarker } from './EdgeArrowMarker';
|
||||
@ -123,13 +123,11 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) {
|
||||
const firstNodesDataFrame = nodesDataFrames[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
|
||||
// that case should be unique or figure a way to link edges and nodes dataframes together.
|
||||
const processed = useMemo(
|
||||
() => processNodes(firstNodesDataFrame, firstEdgesDataFrame, theme),
|
||||
[firstEdgesDataFrame, firstNodesDataFrame, theme]
|
||||
() => processNodes(firstNodesDataFrame, firstEdgesDataFrame),
|
||||
[firstEdgesDataFrame, firstNodesDataFrame]
|
||||
);
|
||||
|
||||
// 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
|
||||
);
|
||||
|
||||
// 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.
|
||||
const focusPosition = useFocusPositionOnLayout(config, nodes, focusedNodeId);
|
||||
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);
|
||||
|
||||
// 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(
|
||||
(r: HTMLDivElement) => {
|
||||
measureRef(r);
|
||||
|
@ -199,7 +199,7 @@ function gridLayout(
|
||||
const val1 = sort!.field.values.get(node1.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;
|
||||
});
|
||||
}
|
||||
|
@ -35,6 +35,8 @@ export type NodeDatum = SimulationNodeDatum & {
|
||||
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.
|
||||
type LinkDatum = SimulationLinkDatum<NodeDatum> & {
|
||||
source: string;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
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 { Config } from './layout';
|
||||
@ -14,8 +14,10 @@ import { getEdgeFields, getNodeFields } from './utils';
|
||||
*/
|
||||
export function useContextMenu(
|
||||
getLinks: (dataFrame: DataFrame, rowIndex: number) => LinkModel[],
|
||||
nodes: DataFrame,
|
||||
edges: DataFrame,
|
||||
// This can be undefined if we only use edge dataframe
|
||||
nodes: DataFrame | undefined,
|
||||
// This can be undefined if we have only single node
|
||||
edges: DataFrame | undefined,
|
||||
config: Config,
|
||||
setConfig: (config: Config) => void,
|
||||
setFocusedNodeId: (id: string) => void
|
||||
@ -28,13 +30,9 @@ export function useContextMenu(
|
||||
|
||||
const onNodeOpen = useCallback(
|
||||
(event: MouseEvent<SVGElement>, node: NodeDatum) => {
|
||||
let label = 'Show in Grid layout';
|
||||
let showGridLayout = true;
|
||||
|
||||
if (config.gridLayout) {
|
||||
label = 'Show in Graph layout';
|
||||
showGridLayout = false;
|
||||
}
|
||||
const [label, showGridLayout] = config.gridLayout
|
||||
? ['Show in Graph layout', false]
|
||||
: ['Show in Grid layout', true];
|
||||
|
||||
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) {
|
||||
setMenu(
|
||||
<ContextMenu
|
||||
renderHeader={() => <NodeHeader node={node} nodes={nodes} />}
|
||||
renderMenuItems={renderer}
|
||||
onClose={() => setMenu(undefined)}
|
||||
x={event.pageX}
|
||||
y={event.pageY}
|
||||
/>
|
||||
);
|
||||
setMenu(makeContextMenu(<NodeHeader node={node} nodes={nodes} />, renderer, event, setMenu));
|
||||
}
|
||||
},
|
||||
[config, nodes, getLinks, setMenu, setConfig, setFocusedNodeId]
|
||||
@ -66,18 +57,16 @@ export function useContextMenu(
|
||||
|
||||
const onEdgeOpen = useCallback(
|
||||
(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) {
|
||||
setMenu(
|
||||
<ContextMenu
|
||||
renderHeader={() => <EdgeHeader edge={edge} edges={edges} />}
|
||||
renderMenuItems={renderer}
|
||||
onClose={() => setMenu(undefined)}
|
||||
x={event.pageX}
|
||||
y={event.pageY}
|
||||
/>
|
||||
);
|
||||
setMenu(makeContextMenu(<EdgeHeader edge={edge} edges={edges} />, renderer, event, setMenu));
|
||||
}
|
||||
},
|
||||
[edges, getLinks, setMenu]
|
||||
@ -86,6 +75,23 @@ export function useContextMenu(
|
||||
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>(
|
||||
links: LinkModel[],
|
||||
item: T,
|
||||
@ -173,24 +179,45 @@ function getItems(links: LinkModel[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function NodeHeader(props: { node: NodeDatum; nodes: DataFrame }) {
|
||||
const index = props.node.dataFrameRowIndex;
|
||||
const fields = getNodeFields(props.nodes);
|
||||
return (
|
||||
<div>
|
||||
{fields.title && <Label field={fields.title} index={index} />}
|
||||
{fields.subTitle && <Label field={fields.subTitle} index={index} />}
|
||||
{fields.details.map((f) => (
|
||||
<Label key={f.name} field={f} index={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
function NodeHeader({ node, nodes }: { node: NodeDatum; nodes?: DataFrame }) {
|
||||
const index = node.dataFrameRowIndex;
|
||||
if (nodes) {
|
||||
const fields = getNodeFields(nodes);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{fields.title && (
|
||||
<Label
|
||||
label={fields.title.config.displayName || fields.title.name}
|
||||
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 }) {
|
||||
const index = props.edge.dataFrameRowIndex;
|
||||
const fields = getEdgeFields(props.edges);
|
||||
const styles = getLabelStyles(useTheme2());
|
||||
const fields = getEdgeFields(props.edges);
|
||||
const valueSource = fields.source?.values.get(index) || '';
|
||||
const valueTarget = fields.target?.values.get(index) || '';
|
||||
|
||||
@ -205,20 +232,18 @@ function EdgeHeader(props: { edge: EdgeDatum; edges: DataFrame }) {
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
function Label(props: { field: Field; index: number }) {
|
||||
const { field, index } = props;
|
||||
const value = field.values.get(index) || '';
|
||||
function Label({ label, value }: { label: string; value: string | number }) {
|
||||
const styles = useStyles2(getLabelStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.label}>
|
||||
<div>{field.config.displayName || field.name}</div>
|
||||
<div>{label}</div>
|
||||
<span className={styles.value}>{value}</span>
|
||||
</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 {
|
||||
findConnectedNodesForEdge,
|
||||
findConnectedNodesForNode,
|
||||
@ -13,196 +13,27 @@ import {
|
||||
} from './utils';
|
||||
|
||||
describe('processNodes', () => {
|
||||
const theme = createTheme();
|
||||
|
||||
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 () => {
|
||||
const { nodes, edges, legend } = processNodes(
|
||||
makeNodesDataFrame(3),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[0, 2],
|
||||
[1, 2],
|
||||
]),
|
||||
theme
|
||||
{ source: '0', target: '1' },
|
||||
{ source: '0', target: '2' },
|
||||
{ source: '1', target: '2' },
|
||||
])
|
||||
);
|
||||
|
||||
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([
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
makeNodeDatum(),
|
||||
makeNodeDatum({ dataFrameRowIndex: 1, id: '1', incoming: 1, title: 'service:1' }),
|
||||
makeNodeDatum({ dataFrameRowIndex: 2, id: '2', incoming: 2, title: 'service:2' }),
|
||||
]);
|
||||
|
||||
expect(edges).toEqual([
|
||||
{
|
||||
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(edges).toEqual([makeEdgeDatum('0--1', 0), makeEdgeDatum('0--2', 1), makeEdgeDatum('1--2', 2)]);
|
||||
|
||||
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', () => {
|
||||
const validFrames = [
|
||||
new MutableDataFrame({
|
||||
@ -363,17 +226,14 @@ describe('processNodes', () => {
|
||||
});
|
||||
|
||||
describe('finds connections', () => {
|
||||
const theme = createTheme();
|
||||
|
||||
it('finds connected nodes given an edge id', () => {
|
||||
const { nodes, edges } = processNodes(
|
||||
makeNodesDataFrame(3),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[0, 2],
|
||||
[1, 2],
|
||||
]),
|
||||
theme
|
||||
{ source: '0', target: '1' },
|
||||
{ source: '0', target: '2' },
|
||||
{ source: '1', target: '2' },
|
||||
])
|
||||
);
|
||||
|
||||
const linked = findConnectedNodesForEdge(nodes, edges, edges[0].id);
|
||||
@ -384,14 +244,96 @@ describe('finds connections', () => {
|
||||
const { nodes, edges } = processNodes(
|
||||
makeNodesDataFrame(4),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[0, 2],
|
||||
[1, 2],
|
||||
]),
|
||||
theme
|
||||
{ source: '0', target: '1' },
|
||||
{ source: '0', target: '2' },
|
||||
{ source: '1', target: '2' },
|
||||
])
|
||||
);
|
||||
|
||||
const linked = findConnectedNodesForNode(nodes, edges, nodes[0].id);
|
||||
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,
|
||||
FieldCache,
|
||||
FieldColorModeId,
|
||||
FieldConfig,
|
||||
FieldType,
|
||||
GrafanaTheme2,
|
||||
MutableDataFrame,
|
||||
NodeGraphDataFrameFieldNames,
|
||||
} 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 };
|
||||
|
||||
@ -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 = {
|
||||
...nodes,
|
||||
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 = {
|
||||
...edges,
|
||||
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)));
|
||||
}
|
||||
|
||||
@ -79,8 +99,7 @@ function findFieldsByPrefix(frame: DataFrame, prefix: string) {
|
||||
*/
|
||||
export function processNodes(
|
||||
nodes: DataFrame | undefined,
|
||||
edges: DataFrame | undefined,
|
||||
theme: GrafanaTheme2
|
||||
edges: DataFrame | undefined
|
||||
): {
|
||||
nodes: NodeDatum[];
|
||||
edges: EdgeDatum[];
|
||||
@ -89,76 +108,206 @@ export function processNodes(
|
||||
name: string;
|
||||
}>;
|
||||
} {
|
||||
if (!nodes) {
|
||||
if (!(edges || nodes)) {
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
|
||||
const nodeFields = getNodeFields(nodes);
|
||||
if (!nodeFields.id) {
|
||||
throw new Error('id field is required for nodes data frame.');
|
||||
}
|
||||
|
||||
const nodesMap =
|
||||
nodeFields.id.values.toArray().reduce<{ [id: string]: NodeDatum }>((acc, id, index) => {
|
||||
acc[id] = {
|
||||
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,
|
||||
};
|
||||
return acc;
|
||||
}, {}) || {};
|
||||
|
||||
let edgesMapped: EdgeDatum[] = [];
|
||||
// We may not have edges in case of single node
|
||||
if (edges) {
|
||||
const edgeFields = getEdgeFields(edges);
|
||||
if (!edgeFields.id) {
|
||||
throw new Error('id field is required for edges data frame.');
|
||||
if (nodes) {
|
||||
const nodeFields = getNodeFields(nodes);
|
||||
if (!nodeFields.id) {
|
||||
throw new Error('id field is required for nodes data frame.');
|
||||
}
|
||||
|
||||
edgesMapped = edgeFields.id.values.toArray().map((id, index) => {
|
||||
const target = edgeFields.target?.values.get(index);
|
||||
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
|
||||
nodesMap[target].incoming++;
|
||||
// Create the nodes here
|
||||
const nodesMap: { [id: string]: NodeDatum } = {};
|
||||
for (let i = 0; i < nodeFields.id.values.length; i++) {
|
||||
const id = nodeFields.id.values.get(i);
|
||||
nodesMap[id] = makeNodeDatum(id, nodeFields, i);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
dataFrameRowIndex: index,
|
||||
source,
|
||||
target,
|
||||
mainStat: edgeFields.mainStat ? statToString(edgeFields.mainStat, index) : '',
|
||||
secondaryStat: edgeFields.secondaryStat ? statToString(edgeFields.secondaryStat, index) : '',
|
||||
} as EdgeDatum;
|
||||
});
|
||||
// We may not have edges in case of single node
|
||||
let edgeDatums: EdgeDatum[] = edges ? processEdges(edges, getEdgeFields(edges)) : [];
|
||||
|
||||
for (const e of edgeDatums) {
|
||||
// We are adding incoming edges count, so we can later on find out which nodes are the roots
|
||||
nodesMap[e.target].incoming++;
|
||||
}
|
||||
|
||||
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 } = {};
|
||||
|
||||
const edgeFields = getEdgeFields(edges);
|
||||
let edgeDatums = processEdges(edges, edgeFields);
|
||||
|
||||
// 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++;
|
||||
}
|
||||
|
||||
// It is expected for stats to be Field, so we have to create them.
|
||||
const nodes = normalizeStatsForNodes(nodesMap, edgeFields);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges: edgeDatums,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn data frame data into EdgeDatum that node graph understands
|
||||
* @param edges
|
||||
* @param edgeFields
|
||||
*/
|
||||
function processEdges(edges: DataFrame, edgeFields: EdgeFields): EdgeDatum[] {
|
||||
if (!edgeFields.id) {
|
||||
throw new Error('id field is required for edges data frame.');
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: Object.values(nodesMap),
|
||||
edges: edgesMapped || [],
|
||||
legend: nodeFields.arc.map((f) => {
|
||||
return {
|
||||
color: f.config.color?.fixedColor ?? '',
|
||||
name: f.config.displayName || f.name,
|
||||
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) {
|
||||
if (field.type === FieldType.string) {
|
||||
return field.values.get(index);
|
||||
function makeSimpleNodeDatum(name: string, index: number): NodeDatumFromEdge {
|
||||
return {
|
||||
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 {
|
||||
const decimals = field.config.decimals || 2;
|
||||
const val = field.values.get(index);
|
||||
if (Number.isFinite(val)) {
|
||||
return field.values.get(index).toFixed(decimals) + (field.config.unit ? ' ' + field.config.unit : '');
|
||||
const decimals = config.decimals || 2;
|
||||
if (Number.isFinite(value)) {
|
||||
return value.toFixed(decimals) + (config.unit ? ' ' + config.unit : '');
|
||||
} else {
|
||||
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();
|
||||
for (const edge of edges) {
|
||||
frame.add({
|
||||
id: edge[0] + '--' + edge[1],
|
||||
source: edge[0].toString(),
|
||||
target: edge[1].toString(),
|
||||
id: edge.source + '--' + edge.target,
|
||||
...edge,
|
||||
});
|
||||
}
|
||||
|
||||
@ -267,6 +417,14 @@ function edgesFrame() {
|
||||
values: new ArrayVector(),
|
||||
type: FieldType.string,
|
||||
},
|
||||
[NodeGraphDataFrameFieldNames.mainStat]: {
|
||||
values: new ArrayVector(),
|
||||
type: FieldType.number,
|
||||
},
|
||||
[NodeGraphDataFrameFieldNames.secondaryStat]: {
|
||||
values: new ArrayVector(),
|
||||
type: FieldType.number,
|
||||
},
|
||||
};
|
||||
|
||||
return new MutableDataFrame({
|
||||
|
Loading…
Reference in New Issue
Block a user