mirror of
https://github.com/grafana/grafana.git
synced 2026-08-14 07:04:57 -05:00
NodeGraph: Exploration mode (#33623)
* Add exploration option to node layout * Add hidden node count * Add grid layout option * Fix panning bounds calculation * Add legend with sorting * Allow sorting on any stats or arc value * Fix merge * Make sorting better * Reset focused node on layout change * Refactor limit hook a bit * Disable selected layout button * Don't show markers if only 1 node is hidden * Move legend to the bottom * Fix text backgrounds * Add show in graph layout action in grid layout * Center view on the focused node, fix perf issue when expanding big graph * Limit the node counting * Comment and linting fixes * Bit of code cleanup and comments * Add state for computing layout * Prevent computing map with partial data * Add rollup plugin for worker * Add rollup plugin for worker * Enhance data from worker * Fix perf issues with reduce and object creation * Improve comment * Fix tests * Css fixes * Remove worker plugin * Add comments * Fix test * Add test for exploration * Add test switching to grid layout * Apply suggestions from code review Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com> * Remove unused plugin * Fix function name * Remove unused rollup plugin * Review fixes * Fix context menu shown on layout change * Make buttons bigger * Moved NodeGraph to core grafana Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>
This commit is contained in:
co-authored by
Zoltán Bedi
parent
290e00cb6f
commit
fdd6620d0a
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Badge, NodeGraph, Collapse } from '@grafana/ui';
|
||||
import { Badge, Collapse } from '@grafana/ui';
|
||||
import { DataFrame, TimeRange } from '@grafana/data';
|
||||
import { ExploreId, StoreState } from '../../types';
|
||||
import { splitOpen } from './state/main';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
import { useLinks } from './utils/links';
|
||||
import { NodeGraph } from '../../plugins/panel/nodeGraph';
|
||||
|
||||
interface Props {
|
||||
// Edges and Nodes are separate frames
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DataFrame, FieldType, MutableDataFrame } from '@grafana/data';
|
||||
import { NodeGraphDataFrameFieldNames as Fields } from '@grafana/ui';
|
||||
import { DataFrame, FieldType, MutableDataFrame, NodeGraphDataFrameFieldNames as Fields } from '@grafana/data';
|
||||
import { Span, TraceResponse } from './types';
|
||||
|
||||
interface Node {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { DataFrame, DataFrameView, FieldType, MutableDataFrame } from '@grafana/data';
|
||||
import { NodeGraphDataFrameFieldNames as Fields } from '@grafana/ui';
|
||||
import {
|
||||
DataFrame,
|
||||
DataFrameView,
|
||||
FieldType,
|
||||
MutableDataFrame,
|
||||
NodeGraphDataFrameFieldNames as Fields,
|
||||
} from '@grafana/data';
|
||||
|
||||
interface Row {
|
||||
traceID: string;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ArrayVector, FieldType, MutableDataFrame } from '@grafana/data';
|
||||
import { ArrayVector, FieldType, MutableDataFrame, NodeGraphDataFrameFieldNames } from '@grafana/data';
|
||||
import { nodes, edges } from './testData/serviceMapResponse';
|
||||
import { NodeGraphDataFrameFieldNames } from '@grafana/ui';
|
||||
|
||||
export function generateRandomNodes(count = 10) {
|
||||
const nodes = [];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FieldColorModeId, FieldType, PreferredVisualisationType } from '@grafana/data';
|
||||
import { NodeGraphDataFrameFieldNames } from '@grafana/ui';
|
||||
import { FieldColorModeId, FieldType, PreferredVisualisationType, NodeGraphDataFrameFieldNames } from '@grafana/data';
|
||||
|
||||
export const nodes = {
|
||||
fields: [
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { MouseEvent, memo } from 'react';
|
||||
import { EdgeDatum, NodeDatum } from './types';
|
||||
import { shortenLine } from './utils';
|
||||
|
||||
interface Props {
|
||||
edge: EdgeDatum;
|
||||
hovering: boolean;
|
||||
onClick: (event: MouseEvent<SVGElement>, link: EdgeDatum) => void;
|
||||
onMouseEnter: (id: string) => void;
|
||||
onMouseLeave: (id: string) => void;
|
||||
}
|
||||
export const Edge = memo(function Edge(props: Props) {
|
||||
const { edge, onClick, onMouseEnter, onMouseLeave, hovering } = props;
|
||||
// Not great typing but after we do layout these properties are full objects not just references
|
||||
const { source, target } = edge as { source: NodeDatum; target: NodeDatum };
|
||||
|
||||
// As the nodes have some radius we want edges to end outside of the node circle.
|
||||
const line = shortenLine(
|
||||
{
|
||||
x1: source.x!,
|
||||
y1: source.y!,
|
||||
x2: target.x!,
|
||||
y2: target.y!,
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
return (
|
||||
<g
|
||||
onClick={(event) => onClick(event, edge)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
aria-label={`Edge from: ${(edge.source as NodeDatum).id} to: ${(edge.target as NodeDatum).id}`}
|
||||
>
|
||||
<line
|
||||
strokeWidth={hovering ? 2 : 1}
|
||||
stroke={'#999'}
|
||||
x1={line.x1}
|
||||
y1={line.y1}
|
||||
x2={line.x2}
|
||||
y2={line.y2}
|
||||
markerEnd="url(#triangle)"
|
||||
/>
|
||||
<line
|
||||
stroke={'transparent'}
|
||||
x1={line.x1}
|
||||
y1={line.y1}
|
||||
x2={line.x2}
|
||||
y2={line.y2}
|
||||
strokeWidth={20}
|
||||
onMouseEnter={() => {
|
||||
onMouseEnter(edge.id);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
onMouseLeave(edge.id);
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* In SVG you need to supply this kind of marker that can be then referenced from a line segment as an ending of the
|
||||
* line turning in into arrow. Needs to be included in the svg element and then referenced as markerEnd="url(#triangle)"
|
||||
*/
|
||||
export function EdgeArrowMarker() {
|
||||
return (
|
||||
<defs>
|
||||
<marker
|
||||
id="triangle"
|
||||
viewBox="0 0 10 10"
|
||||
refX="8"
|
||||
refY="5"
|
||||
markerUnits="strokeWidth"
|
||||
markerWidth="10"
|
||||
markerHeight="10"
|
||||
orient="auto"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
|
||||
</marker>
|
||||
</defs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { memo } from 'react';
|
||||
import { EdgeDatum, NodeDatum } from './types';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { shortenLine } from './utils';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
mainGroup: css`
|
||||
pointer-events: none;
|
||||
font-size: 8px;
|
||||
`,
|
||||
|
||||
background: css`
|
||||
fill: ${theme.components.tooltip.background};
|
||||
`,
|
||||
|
||||
text: css`
|
||||
fill: ${theme.components.tooltip.text};
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
interface Props {
|
||||
edge: EdgeDatum;
|
||||
}
|
||||
export const EdgeLabel = memo(function EdgeLabel(props: Props) {
|
||||
const { edge } = props;
|
||||
// Not great typing but after we do layout these properties are full objects not just references
|
||||
const { source, target } = edge as { source: NodeDatum; target: NodeDatum };
|
||||
|
||||
// As the nodes have some radius we want edges to end outside of the node circle.
|
||||
const line = shortenLine(
|
||||
{
|
||||
x1: source.x!,
|
||||
y1: source.y!,
|
||||
x2: target.x!,
|
||||
y2: target.y!,
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
const middle = {
|
||||
x: line.x1 + (line.x2 - line.x1) / 2,
|
||||
y: line.y1 + (line.y2 - line.y1) / 2,
|
||||
};
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<g className={styles.mainGroup}>
|
||||
<rect className={styles.background} x={middle.x - 40} y={middle.y - 15} width="80" height="30" rx="5" />
|
||||
<text className={styles.text} x={middle.x} y={middle.y - 5} textAnchor={'middle'}>
|
||||
{edge.mainStat}
|
||||
</text>
|
||||
<text className={styles.text} x={middle.x} y={middle.y + 10} textAnchor={'middle'}>
|
||||
{edge.secondaryStat}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { NodeDatum } from './types';
|
||||
import { Field, FieldColorModeId, getColorForTheme, GrafanaTheme } from '@grafana/data';
|
||||
import { identity } from 'lodash';
|
||||
import { Config } from './layout';
|
||||
import { css } from '@emotion/css';
|
||||
import { Icon, LegendDisplayMode, useStyles, useTheme, VizLegend, VizLegendItem, VizLegendListItem } from '@grafana/ui';
|
||||
|
||||
function getStyles() {
|
||||
return {
|
||||
item: css`
|
||||
label: LegendItem;
|
||||
flex-grow: 0;
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
nodes: NodeDatum[];
|
||||
onSort: (sort: Config['sort']) => void;
|
||||
sort?: Config['sort'];
|
||||
sortable: boolean;
|
||||
}
|
||||
|
||||
export const Legend = function Legend(props: Props) {
|
||||
const { nodes, onSort, sort, sortable } = props;
|
||||
|
||||
const theme = useTheme();
|
||||
const styles = useStyles(getStyles);
|
||||
const colorItems = getColorLegendItems(nodes, theme);
|
||||
|
||||
const onClick = useCallback(
|
||||
(item) => {
|
||||
onSort({
|
||||
field: item.data!.field,
|
||||
ascending: item.data!.field === sort?.field ? !sort?.ascending : true,
|
||||
});
|
||||
},
|
||||
[sort, onSort]
|
||||
);
|
||||
|
||||
return (
|
||||
<VizLegend<ItemData>
|
||||
displayMode={LegendDisplayMode.List}
|
||||
placement={'bottom'}
|
||||
items={colorItems}
|
||||
itemRenderer={(item) => {
|
||||
return (
|
||||
<>
|
||||
<VizLegendListItem item={item} className={styles.item} onLabelClick={sortable ? onClick : undefined} />
|
||||
{sortable &&
|
||||
(sort?.field === item.data!.field ? <Icon name={sort!.ascending ? 'angle-up' : 'angle-down'} /> : '')}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemData {
|
||||
field: Field;
|
||||
}
|
||||
|
||||
function getColorLegendItems(nodes: NodeDatum[], theme: GrafanaTheme): Array<VizLegendItem<ItemData>> {
|
||||
const fields = [nodes[0].mainStat, nodes[0].secondaryStat].filter(identity) as Field[];
|
||||
|
||||
const node = nodes.find((n) => n.arcSections.length > 0);
|
||||
if (node) {
|
||||
if (node.arcSections[0]!.config?.color?.mode === FieldColorModeId.Fixed) {
|
||||
// We assume in this case we have a set of fixed colors which map neatly into a basic legend.
|
||||
|
||||
// Lets collect and deduplicate as there isn't a requirement for 0 size arc section to be defined
|
||||
fields.push(...new Set(nodes.map((n) => n.arcSections).flat()));
|
||||
} else {
|
||||
// TODO: probably some sort of gradient which we will have to deal with later
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return fields.map((f) => {
|
||||
return {
|
||||
label: f.config.displayName || f.name,
|
||||
color: getColorForTheme(f.config.color?.fixedColor || '', theme),
|
||||
yAxis: 0,
|
||||
data: { field: f },
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { MouseEvent, memo } from 'react';
|
||||
import { NodesMarker } from './types';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { css } from 'emotion';
|
||||
import { stylesFactory, useTheme } from '@grafana/ui';
|
||||
|
||||
const nodeR = 40;
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
||||
mainGroup: css`
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
`,
|
||||
|
||||
mainCircle: css`
|
||||
fill: ${theme.colors.panelBg};
|
||||
stroke: ${theme.colors.border3};
|
||||
`,
|
||||
text: css`
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`,
|
||||
}));
|
||||
|
||||
export const Marker = memo(function Marker(props: {
|
||||
marker: NodesMarker;
|
||||
onClick?: (event: MouseEvent<SVGElement>, marker: NodesMarker) => void;
|
||||
}) {
|
||||
const { marker, onClick } = props;
|
||||
const { node } = marker;
|
||||
const styles = getStyles(useTheme());
|
||||
|
||||
if (!(node.x !== undefined && node.y !== undefined)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
data-node-id={node.id}
|
||||
className={styles.mainGroup}
|
||||
onClick={(event) => {
|
||||
onClick?.(event, marker);
|
||||
}}
|
||||
aria-label={`Hidden nodes marker: ${node.id}`}
|
||||
>
|
||||
<circle className={styles.mainCircle} r={nodeR} cx={node.x} cy={node.y} />
|
||||
<g>
|
||||
<foreignObject x={node.x - 25} y={node.y - 25} width="50" height="50">
|
||||
<div className={styles.text}>
|
||||
{/* we limit the count to 101 so if we have more than 100 nodes we don't have exact count */}
|
||||
<span>{marker.count > 100 ? '>100' : marker.count} nodes</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { MouseEvent, memo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { getColorForTheme, GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2, useTheme } from '@grafana/ui';
|
||||
import { NodeDatum } from './types';
|
||||
import { css } from 'emotion';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { statToString } from './utils';
|
||||
|
||||
const nodeR = 40;
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
mainGroup: css`
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
`,
|
||||
|
||||
mainCircle: css`
|
||||
fill: ${theme.components.panel.background};
|
||||
`,
|
||||
|
||||
hoverCircle: css`
|
||||
opacity: 0.5;
|
||||
fill: transparent;
|
||||
stroke: ${theme.colors.primary.text};
|
||||
`,
|
||||
|
||||
text: css`
|
||||
fill: ${theme.colors.text.primary};
|
||||
`,
|
||||
|
||||
titleText: css`
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
background-color: ${tinycolor(theme.colors.background.primary).setAlpha(0.6).toHex8String()};
|
||||
width: 100px;
|
||||
`,
|
||||
|
||||
statsText: css`
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 70px;
|
||||
`,
|
||||
|
||||
textHovering: css`
|
||||
width: 200px;
|
||||
& span {
|
||||
background-color: ${tinycolor(theme.colors.background.primary).setAlpha(0.8).toHex8String()};
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
export const Node = memo(function Node(props: {
|
||||
node: NodeDatum;
|
||||
onMouseEnter: (id: string) => void;
|
||||
onMouseLeave: (id: string) => void;
|
||||
onClick: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
|
||||
hovering: boolean;
|
||||
}) {
|
||||
const { node, onMouseEnter, onMouseLeave, onClick, hovering } = props;
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (!(node.x !== undefined && node.y !== undefined)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
data-node-id={node.id}
|
||||
className={styles.mainGroup}
|
||||
onMouseEnter={() => {
|
||||
onMouseEnter(node.id);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
onMouseLeave(node.id);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
onClick(event, node);
|
||||
}}
|
||||
aria-label={`Node: ${node.title}`}
|
||||
>
|
||||
<circle className={styles.mainCircle} r={nodeR} cx={node.x} cy={node.y} />
|
||||
{hovering && <circle className={styles.hoverCircle} r={nodeR - 3} cx={node.x} cy={node.y} strokeWidth={2} />}
|
||||
<ColorCircle node={node} />
|
||||
<g className={styles.text}>
|
||||
<foreignObject x={node.x - (hovering ? 100 : 35)} y={node.y - 15} width={hovering ? '200' : '70'} height="30">
|
||||
<div className={cx(styles.statsText, hovering && styles.textHovering)}>
|
||||
<span>{node.mainStat && statToString(node.mainStat, node.dataFrameRowIndex)}</span>
|
||||
<br />
|
||||
<span>{node.secondaryStat && statToString(node.secondaryStat, node.dataFrameRowIndex)}</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<foreignObject
|
||||
x={node.x - (hovering ? 100 : 50)}
|
||||
y={node.y + nodeR + 5}
|
||||
width={hovering ? '200' : '100'}
|
||||
height="30"
|
||||
>
|
||||
<div className={cx(styles.titleText, hovering && styles.textHovering)}>
|
||||
<span>{node.title}</span>
|
||||
<br />
|
||||
<span>{node.subTitle}</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Shows the outer segmented circle with different colors based on the supplied data.
|
||||
*/
|
||||
function ColorCircle(props: { node: NodeDatum }) {
|
||||
const { node } = props;
|
||||
const fullStat = node.arcSections.find((s) => s.values.get(node.dataFrameRowIndex) === 1);
|
||||
const theme = useTheme();
|
||||
|
||||
if (fullStat) {
|
||||
// Doing arc with path does not work well so it's better to just do a circle in that case
|
||||
return (
|
||||
<circle
|
||||
fill="none"
|
||||
stroke={getColorForTheme(fullStat.config.color?.fixedColor || '', theme)}
|
||||
strokeWidth={2}
|
||||
r={nodeR}
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const nonZero = node.arcSections.filter((s) => s.values.get(node.dataFrameRowIndex) !== 0);
|
||||
if (nonZero.length === 0) {
|
||||
// Fallback if no arc is defined
|
||||
return <circle fill="none" stroke={node.color} strokeWidth={2} r={nodeR} cx={node.x} cy={node.y} />;
|
||||
}
|
||||
|
||||
const { elements } = nonZero.reduce(
|
||||
(acc, section) => {
|
||||
const color = section.config.color?.fixedColor || '';
|
||||
const value = section.values.get(node.dataFrameRowIndex);
|
||||
const el = (
|
||||
<ArcSection
|
||||
key={color}
|
||||
r={nodeR}
|
||||
x={node.x!}
|
||||
y={node.y!}
|
||||
startPercent={acc.percent}
|
||||
percent={value}
|
||||
color={getColorForTheme(color, theme)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
acc.elements.push(el);
|
||||
acc.percent = acc.percent + value;
|
||||
return acc;
|
||||
},
|
||||
{ elements: [] as React.ReactNode[], percent: 0 }
|
||||
);
|
||||
|
||||
return <>{elements}</>;
|
||||
}
|
||||
|
||||
function ArcSection({
|
||||
r,
|
||||
x,
|
||||
y,
|
||||
startPercent,
|
||||
percent,
|
||||
color,
|
||||
strokeWidth = 2,
|
||||
}: {
|
||||
r: number;
|
||||
x: number;
|
||||
y: number;
|
||||
startPercent: number;
|
||||
percent: number;
|
||||
color: string;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
const endPercent = startPercent + percent;
|
||||
const startXPos = x + Math.sin(2 * Math.PI * startPercent) * r;
|
||||
const startYPos = y - Math.cos(2 * Math.PI * startPercent) * r;
|
||||
const endXPos = x + Math.sin(2 * Math.PI * endPercent) * r;
|
||||
const endYPos = y - Math.cos(2 * Math.PI * endPercent) * r;
|
||||
const largeArc = percent > 0.5 ? '1' : '0';
|
||||
return (
|
||||
<path
|
||||
fill="none"
|
||||
d={`M ${startXPos} ${startYPos} A ${r} ${r} 0 ${largeArc} 1 ${endXPos} ${endYPos}`}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, getByText } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { NodeGraph } from './NodeGraph';
|
||||
import { makeEdgesDataFrame, makeNodesDataFrame } from './utils';
|
||||
|
||||
jest.mock('./layout.worker.js', () => {
|
||||
const { layout } = jest.requireActual('./layout.worker.js');
|
||||
class TestWorker {
|
||||
constructor() {}
|
||||
postMessage(data: any) {
|
||||
const { nodes, edges, config } = data;
|
||||
setTimeout(() => {
|
||||
layout(nodes, edges, config);
|
||||
// @ts-ignore
|
||||
this.onmessage({ data: { nodes, edges } });
|
||||
}, 1);
|
||||
}
|
||||
}
|
||||
return {
|
||||
__esModule: true,
|
||||
default: TestWorker,
|
||||
};
|
||||
});
|
||||
|
||||
describe('NodeGraph', () => {
|
||||
it('doesnt fail without any data', async () => {
|
||||
render(<NodeGraph dataFrames={[]} getLinks={() => []} />);
|
||||
});
|
||||
|
||||
it('can zoom in and out', async () => {
|
||||
render(<NodeGraph dataFrames={[]} getLinks={() => []} />);
|
||||
const zoomIn = await screen.findByTitle(/Zoom in/);
|
||||
const zoomOut = await screen.findByTitle(/Zoom out/);
|
||||
|
||||
expect(getScale()).toBe(1);
|
||||
userEvent.click(zoomIn);
|
||||
expect(getScale()).toBe(1.5);
|
||||
userEvent.click(zoomOut);
|
||||
expect(getScale()).toBe(1);
|
||||
});
|
||||
|
||||
it('can pan the graph', async () => {
|
||||
render(
|
||||
<NodeGraph
|
||||
dataFrames={[
|
||||
makeNodesDataFrame(3),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
/>
|
||||
);
|
||||
|
||||
await screen.findByLabelText('Node: service:1');
|
||||
|
||||
panView({ x: 10, y: 10 });
|
||||
screen.debug(getSvg());
|
||||
// Though we try to pan down 10px we are rendering in straight line 3 nodes so there are bounds preventing
|
||||
// as panning vertically
|
||||
await waitFor(() => expect(getTranslate()).toEqual({ x: 10, y: 0 }));
|
||||
});
|
||||
|
||||
it('renders with single node', async () => {
|
||||
render(<NodeGraph dataFrames={[makeNodesDataFrame(1)]} getLinks={() => []} />);
|
||||
const circle = await screen.findByText('', { selector: 'circle' });
|
||||
await screen.findByText(/service:0/);
|
||||
expect(getXY(circle)).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it('shows context menu when clicking on node or edge', async () => {
|
||||
render(
|
||||
<NodeGraph
|
||||
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([[0, 1]])]}
|
||||
getLinks={(dataFrame) => {
|
||||
return [
|
||||
{
|
||||
title: dataFrame.fields.find((f) => f.name === 'source') ? 'Edge traces' : 'Node traces',
|
||||
href: '',
|
||||
origin: null,
|
||||
target: '_self',
|
||||
},
|
||||
];
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const node = await screen.findByLabelText(/Node: service:0/);
|
||||
// This shows warning because there is no position for the click. We cannot add any because we use pageX/Y in the
|
||||
// context menu which is experimental (but supported) property and userEvents does not seem to support that
|
||||
userEvent.click(node);
|
||||
await screen.findByText(/Node traces/);
|
||||
|
||||
const edge = await screen.findByLabelText(/Edge from/);
|
||||
userEvent.click(edge);
|
||||
await screen.findByText(/Edge traces/);
|
||||
});
|
||||
|
||||
it('lays out 3 nodes in single line', async () => {
|
||||
render(
|
||||
<NodeGraph
|
||||
dataFrames={[
|
||||
makeNodesDataFrame(3),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
/>
|
||||
);
|
||||
|
||||
await expectNodePositionCloseTo('service:0', { x: -221, y: 0 });
|
||||
await expectNodePositionCloseTo('service:1', { x: -21, y: 0 });
|
||||
await expectNodePositionCloseTo('service:2', { x: 221, y: 0 });
|
||||
});
|
||||
|
||||
it('lays out first children on one vertical line', async () => {
|
||||
render(
|
||||
<NodeGraph
|
||||
dataFrames={[
|
||||
makeNodesDataFrame(3),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[0, 2],
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should basically look like <
|
||||
await expectNodePositionCloseTo('service:0', { x: -100, y: 0 });
|
||||
await expectNodePositionCloseTo('service:1', { x: 100, y: -100 });
|
||||
await expectNodePositionCloseTo('service:2', { x: 100, y: 100 });
|
||||
});
|
||||
|
||||
it('limits the number of nodes shown and shows a warning', async () => {
|
||||
render(
|
||||
<NodeGraph
|
||||
dataFrames={[
|
||||
makeNodesDataFrame(5),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[0, 2],
|
||||
[2, 3],
|
||||
[3, 4],
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
nodeLimit={2}
|
||||
/>
|
||||
);
|
||||
|
||||
const nodes = await screen.findAllByLabelText(/Node: service:\d/);
|
||||
expect(nodes.length).toBe(2);
|
||||
screen.getByLabelText(/Nodes hidden warning/);
|
||||
|
||||
const markers = await screen.findAllByLabelText(/Hidden nodes marker: \d/);
|
||||
expect(markers.length).toBe(1);
|
||||
});
|
||||
|
||||
it('allows expanding the nodes when limiting visible nodes', async () => {
|
||||
render(
|
||||
<NodeGraph
|
||||
dataFrames={[
|
||||
makeNodesDataFrame(5),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 3],
|
||||
[3, 4],
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
nodeLimit={3}
|
||||
/>
|
||||
);
|
||||
|
||||
const node = await screen.findByLabelText(/Node: service:0/);
|
||||
expect(node).toBeInTheDocument();
|
||||
|
||||
const marker = await screen.findByLabelText(/Hidden nodes marker: 3/);
|
||||
userEvent.click(marker);
|
||||
|
||||
expect(screen.queryByLabelText(/Node: service:0/)).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Node: service:4/)).toBeInTheDocument();
|
||||
|
||||
const nodes = await screen.findAllByLabelText(/Node: service:\d/);
|
||||
expect(nodes.length).toBe(3);
|
||||
});
|
||||
|
||||
it('can switch to grid layout', async () => {
|
||||
render(
|
||||
<NodeGraph
|
||||
dataFrames={[
|
||||
makeNodesDataFrame(3),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
nodeLimit={3}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = await screen.findByTitle(/Grid layout/);
|
||||
userEvent.click(button);
|
||||
|
||||
await expectNodePositionCloseTo('service:0', { x: -180, y: -60 });
|
||||
await expectNodePositionCloseTo('service:1', { x: -60, y: -60 });
|
||||
await expectNodePositionCloseTo('service:2', { x: 60, y: -60 });
|
||||
});
|
||||
});
|
||||
|
||||
async function expectNodePositionCloseTo(node: string, pos: { x: number; y: number }) {
|
||||
const nodePos = await getNodeXY(node);
|
||||
expect(nodePos.x).toBeCloseTo(pos.x, -1);
|
||||
expect(nodePos.y).toBeCloseTo(pos.y, -1);
|
||||
}
|
||||
|
||||
async function getNodeXY(node: string) {
|
||||
const group = await screen.findByLabelText(new RegExp(`Node: ${node}`));
|
||||
const circle = getByText(group, '', { selector: 'circle' });
|
||||
return getXY(circle);
|
||||
}
|
||||
|
||||
function panView(toPos: { x: number; y: number }) {
|
||||
const svg = getSvg();
|
||||
fireEvent(svg, new MouseEvent('mousedown', { clientX: 0, clientY: 0 }));
|
||||
fireEvent(document, new MouseEvent('mousemove', { clientX: toPos.x, clientY: toPos.y }));
|
||||
fireEvent(document, new MouseEvent('mouseup'));
|
||||
}
|
||||
|
||||
function getSvg() {
|
||||
return screen.getAllByText('', { selector: 'svg' })[0];
|
||||
}
|
||||
|
||||
function getTransform() {
|
||||
const svg = getSvg();
|
||||
const group = svg.children[0] as SVGElement;
|
||||
return group.style.getPropertyValue('transform');
|
||||
}
|
||||
|
||||
function getScale() {
|
||||
const scale = getTransform().match(/scale\(([\d\.]+)\)/)![1];
|
||||
return parseFloat(scale);
|
||||
}
|
||||
|
||||
function getTranslate() {
|
||||
const matches = getTransform().match(/translate\((\d+)px, (\d+)px\)/);
|
||||
return {
|
||||
x: parseFloat(matches![1]),
|
||||
y: parseFloat(matches![2]),
|
||||
};
|
||||
}
|
||||
|
||||
function getXY(e: Element) {
|
||||
return {
|
||||
x: parseFloat(e.attributes.getNamedItem('cx')?.value || ''),
|
||||
y: parseFloat(e.attributes.getNamedItem('cy')?.value || ''),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import React, { memo, MouseEvent, MutableRefObject, useCallback, useMemo, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import useMeasure from 'react-use/lib/useMeasure';
|
||||
import { Icon, Spinner, useStyles2, useTheme2 } from '@grafana/ui';
|
||||
import { usePanning } from './usePanning';
|
||||
import { EdgeDatum, NodeDatum, NodesMarker } from './types';
|
||||
import { Node } from './Node';
|
||||
import { Edge } from './Edge';
|
||||
import { ViewControls } from './ViewControls';
|
||||
import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||
import { useZoom } from './useZoom';
|
||||
import { Config, defaultConfig, useLayout } from './layout';
|
||||
import { EdgeArrowMarker } from './EdgeArrowMarker';
|
||||
import { css } from '@emotion/css';
|
||||
import { useCategorizeFrames } from './useCategorizeFrames';
|
||||
import { EdgeLabel } from './EdgeLabel';
|
||||
import { useContextMenu } from './useContextMenu';
|
||||
import { processNodes, Bounds } from './utils';
|
||||
import { Marker } from './Marker';
|
||||
import { Legend } from './Legend';
|
||||
import { useHighlight } from './useHighlight';
|
||||
import { useFocusPositionOnLayout } from './useFocusPositionOnLayout';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
wrapper: css`
|
||||
label: wrapper;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
`,
|
||||
|
||||
svg: css`
|
||||
label: svg;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
font-size: 10px;
|
||||
cursor: move;
|
||||
`,
|
||||
|
||||
svgPanning: css`
|
||||
label: svgPanning;
|
||||
user-select: none;
|
||||
`,
|
||||
|
||||
mainGroup: css`
|
||||
label: mainGroup;
|
||||
will-change: transform;
|
||||
`,
|
||||
|
||||
viewControls: css`
|
||||
label: viewControls;
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
bottom: 3px;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
`,
|
||||
legend: css`
|
||||
label: legend;
|
||||
background: ${theme.colors.background.secondary};
|
||||
box-shadow: ${theme.shadows.z1};
|
||||
padding-bottom: 5px;
|
||||
margin-right: 10px;
|
||||
`,
|
||||
alert: css`
|
||||
label: alert;
|
||||
padding: 5px 8px;
|
||||
font-size: 10px;
|
||||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
|
||||
border-radius: ${theme.shape.borderRadius()};
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: ${theme.colors.warning.main};
|
||||
color: ${theme.colors.warning.contrastText};
|
||||
`,
|
||||
loadingWrapper: css`
|
||||
label: loadingWrapper;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`,
|
||||
});
|
||||
|
||||
// Limits the number of visible nodes, mainly for performance reasons. Nodes above the limit are accessible by expanding
|
||||
// parts of the graph. The specific number is arbitrary but should be a number of nodes where panning, zooming and other
|
||||
// interactions will be without any lag for most users.
|
||||
const defaultNodeCountLimit = 200;
|
||||
|
||||
interface Props {
|
||||
dataFrames: DataFrame[];
|
||||
getLinks: (dataFrame: DataFrame, rowIndex: number) => LinkModel[];
|
||||
nodeLimit?: number;
|
||||
}
|
||||
export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) {
|
||||
const nodeCountLimit = nodeLimit || defaultNodeCountLimit;
|
||||
const { edges: edgesDataFrames, nodes: nodesDataFrames } = useCategorizeFrames(dataFrames);
|
||||
|
||||
const [measureRef, { width, height }] = useMeasure();
|
||||
const [config, setConfig] = useState<Config>(defaultConfig);
|
||||
|
||||
// We need hover state here because for nodes we also highlight edges and for edges have labels separate to make
|
||||
// sure they are visible on top of everything else
|
||||
const { nodeHover, setNodeHover, clearNodeHover, edgeHover, setEdgeHover, clearEdgeHover } = useHover();
|
||||
|
||||
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,
|
||||
]);
|
||||
|
||||
// This is used for navigation from grid to graph view. This node will be centered and briefly highlighted.
|
||||
const [focusedNodeId, setFocusedNodeId] = useState<string>();
|
||||
const setFocused = useCallback((e: MouseEvent, m: NodesMarker) => setFocusedNodeId(m.node.id), [setFocusedNodeId]);
|
||||
|
||||
// May seem weird that we do layout first and then limit the nodes shown but the problem is we want to keep the node
|
||||
// position stable which means we need the full layout first and then just visually hide the nodes. As hiding/showing
|
||||
// nodes should not have effect on layout it should not be recalculated.
|
||||
const { nodes, edges, markers, bounds, hiddenNodesCount, loading } = useLayout(
|
||||
processed.nodes,
|
||||
processed.edges,
|
||||
config,
|
||||
nodeCountLimit,
|
||||
focusedNodeId
|
||||
);
|
||||
|
||||
// If we move from grid to graph layout and we have focused node lets get it's position to center there. We want do
|
||||
// do it specifically only in that case.
|
||||
const focusPosition = useFocusPositionOnLayout(config, nodes, focusedNodeId);
|
||||
const { panRef, zoomRef, onStepUp, onStepDown, isPanning, position, scale, isMaxZoom, isMinZoom } = usePanAndZoom(
|
||||
bounds,
|
||||
focusPosition
|
||||
);
|
||||
|
||||
const { onEdgeOpen, onNodeOpen, MenuComponent } = useContextMenu(
|
||||
getLinks,
|
||||
firstNodesDataFrame,
|
||||
firstEdgesDataFrame,
|
||||
config,
|
||||
setConfig,
|
||||
setFocusedNodeId
|
||||
);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// This cannot be inline func or it will create infinite render cycle.
|
||||
const topLevelRef = useCallback(
|
||||
(r) => {
|
||||
measureRef(r);
|
||||
(zoomRef as MutableRefObject<HTMLElement | null>).current = r;
|
||||
},
|
||||
[measureRef, zoomRef]
|
||||
);
|
||||
|
||||
const highlightId = useHighlight(focusedNodeId);
|
||||
|
||||
return (
|
||||
<div ref={topLevelRef} className={styles.wrapper}>
|
||||
{loading ? (
|
||||
<div className={styles.loadingWrapper}>
|
||||
Computing layout
|
||||
<Spinner />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<svg
|
||||
ref={panRef}
|
||||
viewBox={`${-(width / 2)} ${-(height / 2)} ${width} ${height}`}
|
||||
className={cx(styles.svg, isPanning && styles.svgPanning)}
|
||||
>
|
||||
<g
|
||||
className={styles.mainGroup}
|
||||
style={{ transform: `scale(${scale}) translate(${Math.floor(position.x)}px, ${Math.floor(position.y)}px)` }}
|
||||
>
|
||||
<EdgeArrowMarker />
|
||||
{!config.gridLayout && (
|
||||
<Edges
|
||||
edges={edges}
|
||||
nodeHoveringId={nodeHover}
|
||||
edgeHoveringId={edgeHover}
|
||||
onClick={onEdgeOpen}
|
||||
onMouseEnter={setEdgeHover}
|
||||
onMouseLeave={clearEdgeHover}
|
||||
/>
|
||||
)}
|
||||
<Nodes
|
||||
nodes={nodes}
|
||||
onMouseEnter={setNodeHover}
|
||||
onMouseLeave={clearNodeHover}
|
||||
onClick={onNodeOpen}
|
||||
hoveringId={nodeHover || highlightId}
|
||||
/>
|
||||
|
||||
<Markers markers={markers || []} onClick={setFocused} />
|
||||
{/*We split the labels from edges so that they are shown on top of everything else*/}
|
||||
{!config.gridLayout && <EdgeLabels edges={edges} nodeHoveringId={nodeHover} edgeHoveringId={edgeHover} />}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div className={styles.viewControls}>
|
||||
{nodes.length && (
|
||||
<div className={styles.legend}>
|
||||
<Legend
|
||||
sortable={config.gridLayout}
|
||||
nodes={nodes}
|
||||
sort={config.sort}
|
||||
onSort={(sort) => {
|
||||
setConfig({
|
||||
...config,
|
||||
sort: sort,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ViewControls<Config>
|
||||
config={config}
|
||||
onConfigChange={(cfg) => {
|
||||
if (cfg.gridLayout !== config.gridLayout) {
|
||||
setFocusedNodeId(undefined);
|
||||
}
|
||||
setConfig(cfg);
|
||||
}}
|
||||
onMinus={onStepDown}
|
||||
onPlus={onStepUp}
|
||||
scale={scale}
|
||||
disableZoomIn={isMaxZoom}
|
||||
disableZoomOut={isMinZoom}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hiddenNodesCount > 0 && (
|
||||
<div className={styles.alert} aria-label={'Nodes hidden warning'}>
|
||||
<Icon size="sm" name={'info-circle'} /> {hiddenNodesCount} nodes are hidden for performance reasons.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{MenuComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// These components are here as a perf optimisation to prevent going through all nodes and edges on every pan/zoom.
|
||||
|
||||
interface NodesProps {
|
||||
nodes: NodeDatum[];
|
||||
onMouseEnter: (id: string) => void;
|
||||
onMouseLeave: (id: string) => void;
|
||||
onClick: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
|
||||
hoveringId?: string;
|
||||
}
|
||||
const Nodes = memo(function Nodes(props: NodesProps) {
|
||||
return (
|
||||
<>
|
||||
{props.nodes.map((n) => (
|
||||
<Node
|
||||
key={n.id}
|
||||
node={n}
|
||||
onMouseEnter={props.onMouseEnter}
|
||||
onMouseLeave={props.onMouseLeave}
|
||||
onClick={props.onClick}
|
||||
hovering={props.hoveringId === n.id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface MarkersProps {
|
||||
markers: NodesMarker[];
|
||||
onClick: (event: MouseEvent<SVGElement>, marker: NodesMarker) => void;
|
||||
}
|
||||
const Markers = memo(function Nodes(props: MarkersProps) {
|
||||
return (
|
||||
<>
|
||||
{props.markers.map((m) => (
|
||||
<Marker key={'marker-' + m.node.id} marker={m} onClick={props.onClick} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface EdgesProps {
|
||||
edges: EdgeDatum[];
|
||||
nodeHoveringId?: string;
|
||||
edgeHoveringId?: string;
|
||||
onClick: (event: MouseEvent<SVGElement>, link: EdgeDatum) => void;
|
||||
onMouseEnter: (id: string) => void;
|
||||
onMouseLeave: (id: string) => void;
|
||||
}
|
||||
const Edges = memo(function Edges(props: EdgesProps) {
|
||||
return (
|
||||
<>
|
||||
{props.edges.map((e) => (
|
||||
<Edge
|
||||
key={e.id}
|
||||
edge={e}
|
||||
hovering={
|
||||
(e.source as NodeDatum).id === props.nodeHoveringId ||
|
||||
(e.target as NodeDatum).id === props.nodeHoveringId ||
|
||||
props.edgeHoveringId === e.id
|
||||
}
|
||||
onClick={props.onClick}
|
||||
onMouseEnter={props.onMouseEnter}
|
||||
onMouseLeave={props.onMouseLeave}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface EdgeLabelsProps {
|
||||
edges: EdgeDatum[];
|
||||
nodeHoveringId?: string;
|
||||
edgeHoveringId?: string;
|
||||
}
|
||||
const EdgeLabels = memo(function EdgeLabels(props: EdgeLabelsProps) {
|
||||
return (
|
||||
<>
|
||||
{props.edges.map((e, index) => {
|
||||
const shouldShow =
|
||||
(e.source as NodeDatum).id === props.nodeHoveringId ||
|
||||
(e.target as NodeDatum).id === props.nodeHoveringId ||
|
||||
props.edgeHoveringId === e.id;
|
||||
const hasStats = e.mainStat || e.secondaryStat;
|
||||
return shouldShow && hasStats && <EdgeLabel key={e.id} edge={e} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function usePanAndZoom(bounds: Bounds, focus?: { x: number; y: number }) {
|
||||
const { scale, onStepDown, onStepUp, ref, isMax, isMin } = useZoom();
|
||||
const { state: panningState, ref: panRef } = usePanning<SVGSVGElement>({
|
||||
scale,
|
||||
bounds,
|
||||
focus,
|
||||
});
|
||||
const { position, isPanning } = panningState;
|
||||
return { zoomRef: ref, panRef, position, isPanning, scale, onStepDown, onStepUp, isMaxZoom: isMax, isMinZoom: isMin };
|
||||
}
|
||||
|
||||
function useHover() {
|
||||
const [nodeHover, setNodeHover] = useState<string | undefined>(undefined);
|
||||
const clearNodeHover = useCallback(() => setNodeHover(undefined), [setNodeHover]);
|
||||
const [edgeHover, setEdgeHover] = useState<string | undefined>(undefined);
|
||||
const clearEdgeHover = useCallback(() => setEdgeHover(undefined), [setEdgeHover]);
|
||||
|
||||
return { nodeHover, setNodeHover, clearNodeHover, edgeHover, setEdgeHover, clearEdgeHover };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { PanelProps } from '@grafana/data';
|
||||
import { Options } from './types';
|
||||
import { NodeGraph } from '@grafana/ui';
|
||||
import { NodeGraph } from './NodeGraph';
|
||||
import { useLinks } from '../../../features/explore/utils/links';
|
||||
|
||||
export const NodeGraphPanel: React.FunctionComponent<PanelProps<Options>> = ({ width, height, data }) => {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, HorizontalGroup, VerticalGroup } from '@grafana/ui';
|
||||
|
||||
interface Props<Config> {
|
||||
config: Config;
|
||||
onConfigChange: (config: Config) => void;
|
||||
onPlus: () => void;
|
||||
onMinus: () => void;
|
||||
scale: number;
|
||||
disableZoomOut?: boolean;
|
||||
disableZoomIn?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Control buttons for zoom but also some layout config inputs mainly for debugging.
|
||||
*/
|
||||
export function ViewControls<Config extends Record<string, any>>(props: Props<Config>) {
|
||||
const { config, onConfigChange, onPlus, onMinus, disableZoomOut, disableZoomIn } = props;
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
|
||||
// For debugging the layout, should be removed here and maybe moved to panel config later on
|
||||
const allowConfiguration = false;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<VerticalGroup spacing="sm">
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Button
|
||||
icon={'plus-circle'}
|
||||
onClick={onPlus}
|
||||
size={'md'}
|
||||
title={'Zoom in'}
|
||||
variant="secondary"
|
||||
disabled={disableZoomIn}
|
||||
/>
|
||||
<Button
|
||||
icon={'minus-circle'}
|
||||
onClick={onMinus}
|
||||
size={'md'}
|
||||
title={'Zoom out'}
|
||||
variant="secondary"
|
||||
disabled={disableZoomOut}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Button
|
||||
icon={'code-branch'}
|
||||
onClick={() => onConfigChange({ ...config, gridLayout: false })}
|
||||
size={'md'}
|
||||
title={'Default layout'}
|
||||
variant="secondary"
|
||||
disabled={!config.gridLayout}
|
||||
/>
|
||||
<Button
|
||||
icon={'apps'}
|
||||
onClick={() => onConfigChange({ ...config, gridLayout: true })}
|
||||
size={'md'}
|
||||
title={'Grid layout'}
|
||||
variant="secondary"
|
||||
disabled={config.gridLayout}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
|
||||
{allowConfiguration && (
|
||||
<Button size={'xs'} variant={'link'} onClick={() => setShowConfig((showConfig) => !showConfig)}>
|
||||
{showConfig ? 'Hide config' : 'Show config'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{allowConfiguration &&
|
||||
showConfig &&
|
||||
Object.keys(config)
|
||||
.filter((k) => k !== 'show')
|
||||
.map((k) => (
|
||||
<div key={k}>
|
||||
{k}
|
||||
<input
|
||||
style={{ width: 50 }}
|
||||
type={'number'}
|
||||
value={config[k]}
|
||||
onChange={(e) => {
|
||||
onConfigChange({ ...config, [k]: parseFloat(e.target.value) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { NodeGraph } from './NodeGraph';
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { EdgeDatum, EdgeDatumLayout, NodeDatum } from './types';
|
||||
import { Field } from '@grafana/data';
|
||||
import { useNodeLimit } from './useNodeLimit';
|
||||
import useMountedState from 'react-use/lib/useMountedState';
|
||||
import { graphBounds } from './utils';
|
||||
// @ts-ignore
|
||||
import LayoutWorker from './layout.worker.js';
|
||||
|
||||
export interface Config {
|
||||
linkDistance: number;
|
||||
linkStrength: number;
|
||||
forceX: number;
|
||||
forceXStrength: number;
|
||||
forceCollide: number;
|
||||
tick: number;
|
||||
gridLayout: boolean;
|
||||
sort?: {
|
||||
// Either a arc field or stats field
|
||||
field: Field;
|
||||
ascending: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Config mainly for the layout but also some other parts like current layout. The layout variables can be changed only
|
||||
// if you programmatically enable the config editor (for development only) see ViewControls. These could be moved to
|
||||
// panel configuration at some point (apart from gridLayout as that can be switched be user right now.).
|
||||
export const defaultConfig: Config = {
|
||||
linkDistance: 150,
|
||||
linkStrength: 0.5,
|
||||
forceX: 2000,
|
||||
forceXStrength: 0.02,
|
||||
forceCollide: 100,
|
||||
tick: 300,
|
||||
gridLayout: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* This will return copy of the nods and edges with x,y positions filled in. Also the layout changes source/target props
|
||||
* in edges from string ids to actual nodes.
|
||||
*/
|
||||
export function useLayout(
|
||||
rawNodes: NodeDatum[],
|
||||
rawEdges: EdgeDatum[],
|
||||
config: Config = defaultConfig,
|
||||
nodeCountLimit: number,
|
||||
rootNodeId?: string
|
||||
) {
|
||||
const [nodesGrid, setNodesGrid] = useState<NodeDatum[]>([]);
|
||||
const [edgesGrid, setEdgesGrid] = useState<EdgeDatumLayout[]>([]);
|
||||
|
||||
const [nodesGraph, setNodesGraph] = useState<NodeDatum[]>([]);
|
||||
const [edgesGraph, setEdgesGraph] = useState<EdgeDatumLayout[]>([]);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isMounted = useMountedState();
|
||||
|
||||
// Also we compute both layouts here. Grid layout should not add much time and we can more easily just cache both
|
||||
// so this should happen only once for a given response data.
|
||||
//
|
||||
// Also important note is that right now this works on all the nodes even if they are not visible. This means that
|
||||
// the node position is stable even when expanding different parts of graph. It seems like a reasonable thing but
|
||||
// implications are that:
|
||||
// - limiting visible nodes count does not have a positive perf effect
|
||||
// - graphs with high node count can seem weird (very sparse or spread out) when we show only some nodes but layout
|
||||
// is done for thousands of nodes but we also do this only once in the graph lifecycle.
|
||||
// We could re-layout this on visible nodes change but this may need smaller visible node limit to keep the perf
|
||||
// (as we would run layout on every click) and also would be very weird without any animation to understand what is
|
||||
// happening as already visible nodes would change positions.
|
||||
useEffect(() => {
|
||||
if (rawNodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// d3 just modifies the nodes directly, so lets make sure we don't leak that outside
|
||||
let rawNodesCopy = rawNodes.map((n) => ({ ...n }));
|
||||
let rawEdgesCopy = rawEdges.map((e) => ({ ...e }));
|
||||
|
||||
// This is async but as I wanted to still run the sync grid layout and you cannot return promise from effect having
|
||||
// callback seem ok here.
|
||||
defaultLayout(rawNodesCopy, rawEdgesCopy, ({ nodes, edges }) => {
|
||||
// TODO: it would be better to cancel the worker somehow but probably not super important right now.
|
||||
if (isMounted()) {
|
||||
setNodesGraph(nodes);
|
||||
setEdgesGraph(edges as EdgeDatumLayout[]);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
rawNodesCopy = rawNodes.map((n) => ({ ...n }));
|
||||
rawEdgesCopy = rawEdges.map((e) => ({ ...e }));
|
||||
gridLayout(rawNodesCopy, config.sort);
|
||||
|
||||
setNodesGrid(rawNodesCopy);
|
||||
setEdgesGrid(rawEdgesCopy as EdgeDatumLayout[]);
|
||||
}, [config.sort, rawNodes, rawEdges, isMounted]);
|
||||
|
||||
// Limit the nodes so we don't show all for performance reasons. Here we don't compute both at the same time so
|
||||
// changing the layout can trash internal memoization at the moment.
|
||||
const { nodes: nodesWithLimit, edges: edgesWithLimit, markers } = useNodeLimit(
|
||||
config.gridLayout ? nodesGrid : nodesGraph,
|
||||
config.gridLayout ? edgesGrid : edgesGraph,
|
||||
nodeCountLimit,
|
||||
config,
|
||||
rootNodeId
|
||||
);
|
||||
|
||||
// Get bounds based on current limited number of nodes.
|
||||
const bounds = useMemo(() => graphBounds([...nodesWithLimit, ...(markers || []).map((m) => m.node)]), [
|
||||
nodesWithLimit,
|
||||
markers,
|
||||
]);
|
||||
|
||||
return {
|
||||
nodes: nodesWithLimit,
|
||||
edges: edgesWithLimit,
|
||||
markers,
|
||||
bounds,
|
||||
hiddenNodesCount: rawNodes.length - nodesWithLimit.length,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the layout code in a worker as it can take long and we don't want to block the main thread.
|
||||
*/
|
||||
function defaultLayout(
|
||||
nodes: NodeDatum[],
|
||||
edges: EdgeDatum[],
|
||||
done: (data: { nodes: NodeDatum[]; edges: EdgeDatum[] }) => void
|
||||
) {
|
||||
const worker = new LayoutWorker();
|
||||
worker.onmessage = (event: MessageEvent<{ nodes: NodeDatum[]; edges: EdgeDatumLayout[] }>) => {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
// These stats needs to be Field class but the data is stringified over the worker boundary
|
||||
event.data.nodes[i] = {
|
||||
...event.data.nodes[i],
|
||||
mainStat: nodes[i].mainStat,
|
||||
secondaryStat: nodes[i].secondaryStat,
|
||||
arcSections: nodes[i].arcSections,
|
||||
};
|
||||
}
|
||||
done(event.data);
|
||||
};
|
||||
|
||||
worker.postMessage({ nodes, edges, config: defaultConfig });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the nodes in simple grid layout sorted by some stat.
|
||||
*/
|
||||
function gridLayout(
|
||||
nodes: NodeDatum[],
|
||||
sort?: {
|
||||
field: Field;
|
||||
ascending: boolean;
|
||||
}
|
||||
) {
|
||||
const spacingVertical = 140;
|
||||
const spacingHorizontal = 120;
|
||||
// TODO probably make this based on the width of the screen
|
||||
const perRow = 4;
|
||||
|
||||
if (sort) {
|
||||
nodes.sort((node1, node2) => {
|
||||
const val1 = sort!.field.values.get(node1.dataFrameRowIndex);
|
||||
const val2 = sort!.field.values.get(node2.dataFrameRowIndex);
|
||||
|
||||
// Lets pretend we don't care about type for a while
|
||||
return sort!.ascending ? val2 - val1 : val1 - val2;
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
const row = Math.floor(index / perRow);
|
||||
const column = index % perRow;
|
||||
node.x = -180 + column * spacingHorizontal;
|
||||
node.y = -60 + row * spacingVertical;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { forceSimulation, forceLink, forceCollide, forceX } from 'd3-force';
|
||||
|
||||
addEventListener('message', (event) => {
|
||||
const { nodes, edges, config } = event.data;
|
||||
layout(nodes, edges, config);
|
||||
postMessage({ nodes, edges });
|
||||
});
|
||||
|
||||
/**
|
||||
* Use d3 force layout to lay the nodes in a sensible way. This function modifies the nodes adding the x,y positions
|
||||
* and also fills in node references in edges instead of node ids.
|
||||
*/
|
||||
export function layout(nodes, edges, config) {
|
||||
// Start with some hardcoded positions so it starts laid out from left to right
|
||||
let { roots, secondLevelRoots } = initializePositions(nodes, edges);
|
||||
|
||||
// There always seems to be one or more root nodes each with single edge and we want to have them static on the
|
||||
// left neatly in something like grid layout
|
||||
[...roots, ...secondLevelRoots].forEach((n, index) => {
|
||||
n.fx = n.x;
|
||||
});
|
||||
|
||||
const simulation = forceSimulation(nodes)
|
||||
.force(
|
||||
'link',
|
||||
forceLink(edges)
|
||||
.id((d) => d.id)
|
||||
.distance(config.linkDistance)
|
||||
.strength(config.linkStrength)
|
||||
)
|
||||
// to keep the left to right layout we add force that pulls all nodes to right but because roots are fixed it will
|
||||
// apply only to non root nodes
|
||||
.force('x', forceX(config.forceX).strength(config.forceXStrength))
|
||||
// Make sure nodes don't overlap
|
||||
.force('collide', forceCollide(config.forceCollide));
|
||||
|
||||
// 300 ticks for the simulation are recommended but less would probably work too, most movement is done in first
|
||||
// few iterations and then all the forces gets smaller https://github.com/d3/d3-force#simulation_alphaDecay
|
||||
simulation.tick(config.tick);
|
||||
simulation.stop();
|
||||
|
||||
// We do centering here instead of using centering force to keep this more stable
|
||||
centerNodes(nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* This initializes positions of the graph by going from the root to it's children and laying it out in a grid from left
|
||||
* to right. This works only so, so because service map graphs can have cycles and children levels are not ordered in a
|
||||
* way to minimize the edge lengths. Nevertheless this seems to make the graph easier to nudge with the forces later on
|
||||
* than with the d3 default initial positioning. Also we can fix the root positions later on for a bit more neat
|
||||
* organisation.
|
||||
*
|
||||
* This function directly modifies the nodes given and only returns references to root nodes so they do not have to be
|
||||
* found again later on.
|
||||
*
|
||||
* How the spacing could look like approximately:
|
||||
* 0 - 0 - 0 - 0
|
||||
* \- 0 - 0 |
|
||||
* \- 0 -/
|
||||
* 0 - 0 -/
|
||||
*/
|
||||
function initializePositions(nodes, edges) {
|
||||
// To prevent going in cycles
|
||||
const alreadyPositioned = {};
|
||||
|
||||
const nodesMap = nodes.reduce((acc, node) => {
|
||||
acc[node.id] = node;
|
||||
return acc;
|
||||
}, {});
|
||||
const edgesMap = edges.reduce((acc, edge) => {
|
||||
const sourceId = edge.source;
|
||||
acc[sourceId] = [...(acc[sourceId] || []), edge];
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
let roots = nodes.filter((n) => n.incoming === 0);
|
||||
|
||||
// For things like service maps we assume there is some root (client) node but if there is none then selecting
|
||||
// any node as a starting point should work the same.
|
||||
if (!roots.length) {
|
||||
roots = [nodes[0]];
|
||||
}
|
||||
|
||||
let secondLevelRoots = roots.reduce((acc, r) => {
|
||||
acc.push(...(edgesMap[r.id] ? edgesMap[r.id].map((e) => nodesMap[e.target]) : []));
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const rootYSpacing = 300;
|
||||
const nodeYSpacing = 200;
|
||||
const nodeXSpacing = 200;
|
||||
|
||||
let rootY = 0;
|
||||
for (const root of roots) {
|
||||
let graphLevel = [root];
|
||||
let x = 0;
|
||||
while (graphLevel.length > 0) {
|
||||
const nextGraphLevel = [];
|
||||
let y = rootY;
|
||||
for (const node of graphLevel) {
|
||||
if (alreadyPositioned[node.id]) {
|
||||
continue;
|
||||
}
|
||||
// Initialize positions based on the spacing in the grid
|
||||
node.x = x;
|
||||
node.y = y;
|
||||
alreadyPositioned[node.id] = true;
|
||||
|
||||
// Move to next Y position for next node
|
||||
y += nodeYSpacing;
|
||||
if (edgesMap[node.id]) {
|
||||
nextGraphLevel.push(...edgesMap[node.id].map((edge) => nodesMap[edge.target]));
|
||||
}
|
||||
}
|
||||
|
||||
graphLevel = nextGraphLevel;
|
||||
// Move to next X position for next level
|
||||
x += nodeXSpacing;
|
||||
// Reset Y back to baseline for this root
|
||||
y = rootY;
|
||||
}
|
||||
rootY += rootYSpacing;
|
||||
}
|
||||
return { roots, secondLevelRoots };
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure that the center of the graph based on it's bound is in 0, 0 coordinates.
|
||||
* Modifies the nodes directly.
|
||||
*/
|
||||
function centerNodes(nodes) {
|
||||
const bounds = graphBounds(nodes);
|
||||
for (let node of nodes) {
|
||||
node.x = node.x - bounds.center.x;
|
||||
node.y = node.y - bounds.center.y;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bounds of the graph meaning the extent of the nodes in all directions.
|
||||
*/
|
||||
function graphBounds(nodes) {
|
||||
if (nodes.length === 0) {
|
||||
return { top: 0, right: 0, bottom: 0, left: 0, center: { x: 0, y: 0 } };
|
||||
}
|
||||
|
||||
const bounds = nodes.reduce(
|
||||
(acc, node) => {
|
||||
if (node.x > acc.right) {
|
||||
acc.right = node.x;
|
||||
}
|
||||
if (node.x < acc.left) {
|
||||
acc.left = node.x;
|
||||
}
|
||||
if (node.y > acc.bottom) {
|
||||
acc.bottom = node.y;
|
||||
}
|
||||
if (node.y < acc.top) {
|
||||
acc.top = node.y;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ top: Infinity, right: -Infinity, bottom: -Infinity, left: Infinity }
|
||||
);
|
||||
|
||||
const y = bounds.top + (bounds.bottom - bounds.top) / 2;
|
||||
const x = bounds.left + (bounds.right - bounds.left) / 2;
|
||||
|
||||
return {
|
||||
...bounds,
|
||||
center: {
|
||||
x,
|
||||
y,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1 +1,41 @@
|
||||
import { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force';
|
||||
import { Field } from '@grafana/data';
|
||||
|
||||
export interface Options {}
|
||||
|
||||
export type NodeDatum = SimulationNodeDatum & {
|
||||
id: string;
|
||||
title: string;
|
||||
subTitle: string;
|
||||
dataFrameRowIndex: number;
|
||||
incoming: number;
|
||||
mainStat?: Field;
|
||||
secondaryStat?: Field;
|
||||
arcSections: Field[];
|
||||
color: string;
|
||||
};
|
||||
|
||||
// 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;
|
||||
target: string;
|
||||
};
|
||||
|
||||
// This is some additional data we expect with the edges.
|
||||
export type EdgeDatum = LinkDatum & {
|
||||
id: string;
|
||||
mainStat: string;
|
||||
secondaryStat: string;
|
||||
dataFrameRowIndex: number;
|
||||
};
|
||||
|
||||
// After layout is run D3 will change the string IDs for actual references to the nodes.
|
||||
export type EdgeDatumLayout = EdgeDatum & {
|
||||
source: NodeDatum;
|
||||
target: NodeDatum;
|
||||
};
|
||||
|
||||
export type NodesMarker = {
|
||||
node: NodeDatum;
|
||||
count: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMemo } from 'react';
|
||||
import { DataFrame } from '@grafana/data';
|
||||
|
||||
/**
|
||||
* As we need 2 dataframes for the service map, one with nodes and one with edges we have to figure out which is which.
|
||||
* Right now we do not have any metadata for it so we just check preferredVisualisationType and then column names.
|
||||
* TODO: maybe we could use column labels to have a better way to do this
|
||||
*/
|
||||
export function useCategorizeFrames(series: DataFrame[]) {
|
||||
return useMemo(() => {
|
||||
const serviceMapFrames = series.filter((frame) => frame.meta?.preferredVisualisationType === 'nodeGraph');
|
||||
return serviceMapFrames.reduce(
|
||||
(acc, frame) => {
|
||||
const sourceField = frame.fields.filter((f) => f.name === 'source');
|
||||
if (sourceField.length) {
|
||||
acc.edges.push(frame);
|
||||
} else {
|
||||
acc.nodes.push(frame);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ edges: [], nodes: [] } as { nodes: DataFrame[]; edges: DataFrame[] }
|
||||
);
|
||||
}, [series]);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { MouseEvent, useCallback, useState } from 'react';
|
||||
import { EdgeDatum, NodeDatum } from './types';
|
||||
import { DataFrame, Field, GrafanaTheme, LinkModel } from '@grafana/data';
|
||||
import { getEdgeFields, getNodeFields } from './utils';
|
||||
import { css } from '@emotion/css';
|
||||
import { Config } from './layout';
|
||||
import { ContextMenu, MenuGroup, MenuItem, stylesFactory, useTheme } from '@grafana/ui';
|
||||
|
||||
/**
|
||||
* Hook that contains state of the context menu, both for edges and nodes and provides appropriate component when
|
||||
* opened context menu should be opened.
|
||||
*/
|
||||
export function useContextMenu(
|
||||
getLinks: (dataFrame: DataFrame, rowIndex: number) => LinkModel[],
|
||||
nodes: DataFrame,
|
||||
edges: DataFrame,
|
||||
config: Config,
|
||||
setConfig: (config: Config) => void,
|
||||
setFocusedNodeId: (id: string) => void
|
||||
): {
|
||||
onEdgeOpen: (event: MouseEvent<SVGElement>, edge: EdgeDatum) => void;
|
||||
onNodeOpen: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
|
||||
MenuComponent: React.ReactNode;
|
||||
} {
|
||||
const [menu, setMenu] = useState<JSX.Element | undefined>(undefined);
|
||||
|
||||
const onNodeOpen = useCallback(
|
||||
(event, node) => {
|
||||
const extraNodeItem = config.gridLayout
|
||||
? [
|
||||
{
|
||||
label: 'Show in Graph layout',
|
||||
onClick: (node: NodeDatum) => {
|
||||
setFocusedNodeId(node.id);
|
||||
setConfig({ ...config, gridLayout: false });
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
const renderer = getItemsRenderer(getLinks(nodes, node.dataFrameRowIndex), node, extraNodeItem);
|
||||
|
||||
if (renderer) {
|
||||
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]
|
||||
);
|
||||
|
||||
const onEdgeOpen = useCallback(
|
||||
(event, edge) => {
|
||||
const renderer = getItemsRenderer(getLinks(edges, edge.dataFrameRowIndex), edge);
|
||||
|
||||
if (renderer) {
|
||||
setMenu(
|
||||
<ContextMenu
|
||||
renderHeader={() => <EdgeHeader edge={edge} edges={edges} />}
|
||||
renderMenuItems={renderer}
|
||||
onClose={() => setMenu(undefined)}
|
||||
x={event.pageX}
|
||||
y={event.pageY}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[edges, getLinks, setMenu]
|
||||
);
|
||||
|
||||
return { onEdgeOpen, onNodeOpen, MenuComponent: menu };
|
||||
}
|
||||
|
||||
function getItemsRenderer<T extends NodeDatum | EdgeDatum>(
|
||||
links: LinkModel[],
|
||||
item: T,
|
||||
extraItems?: Array<LinkData<T>> | undefined
|
||||
) {
|
||||
if (!(links.length || extraItems?.length)) {
|
||||
return undefined;
|
||||
}
|
||||
const items = getItems(links);
|
||||
return () => {
|
||||
let groups = items?.map((group, index) => (
|
||||
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
|
||||
{(group.items || []).map(mapMenuItem(item))}
|
||||
</MenuGroup>
|
||||
));
|
||||
|
||||
if (extraItems) {
|
||||
groups = [...extraItems.map(mapMenuItem(item)), ...groups];
|
||||
}
|
||||
return groups;
|
||||
};
|
||||
}
|
||||
|
||||
function mapMenuItem<T extends NodeDatum | EdgeDatum>(item: T) {
|
||||
return function NodeGraphMenuItem(link: LinkData<T>) {
|
||||
return (
|
||||
<MenuItem
|
||||
key={link.label}
|
||||
url={link.url}
|
||||
label={link.label}
|
||||
ariaLabel={link.ariaLabel || link.label}
|
||||
onClick={link.onClick ? () => link.onClick?.(item) : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
type LinkData<T extends NodeDatum | EdgeDatum> = {
|
||||
label: string;
|
||||
ariaLabel?: string;
|
||||
url?: string;
|
||||
onClick?: (item: T) => void;
|
||||
};
|
||||
|
||||
function getItems(links: LinkModel[]) {
|
||||
const defaultGroup = 'Open in Explore';
|
||||
const groups = links.reduce<{ [group: string]: Array<{ l: LinkModel; newTitle?: string }> }>((acc, l) => {
|
||||
let group;
|
||||
let title;
|
||||
if (l.title.indexOf('/') !== -1) {
|
||||
group = l.title.split('/')[0];
|
||||
title = l.title.split('/')[1];
|
||||
acc[group] = acc[group] || [];
|
||||
acc[group].push({ l, newTitle: title });
|
||||
} else {
|
||||
acc[defaultGroup] = acc[defaultGroup] || [];
|
||||
acc[defaultGroup].push({ l });
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.keys(groups).map((key) => {
|
||||
return {
|
||||
label: key,
|
||||
ariaLabel: key,
|
||||
items: groups[key].map((link) => ({
|
||||
label: link.newTitle || link.l.title,
|
||||
ariaLabel: link.newTitle || link.l.title,
|
||||
url: link.l.href,
|
||||
onClick: link.l.onClick,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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 EdgeHeader(props: { edge: EdgeDatum; edges: DataFrame }) {
|
||||
const index = props.edge.dataFrameRowIndex;
|
||||
const fields = getEdgeFields(props.edges);
|
||||
return (
|
||||
<div>
|
||||
{fields.details.map((f) => (
|
||||
<Label key={f.name} field={f} index={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getLabelStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
label: css`
|
||||
label: Label;
|
||||
line-height: 1.25;
|
||||
margin: ${theme.spacing.formLabelMargin};
|
||||
padding: ${theme.spacing.formLabelPadding};
|
||||
color: ${theme.colors.textFaint};
|
||||
font-size: ${theme.typography.size.sm};
|
||||
font-weight: ${theme.typography.weight.semibold};
|
||||
`,
|
||||
value: css`
|
||||
label: Value;
|
||||
font-size: ${theme.typography.size.sm};
|
||||
font-weight: ${theme.typography.weight.semibold};
|
||||
color: ${theme.colors.formLabel};
|
||||
margin-top: ${theme.spacing.xxs};
|
||||
display: block;
|
||||
`,
|
||||
};
|
||||
});
|
||||
function Label(props: { field: Field; index: number }) {
|
||||
const { field, index } = props;
|
||||
const value = field.values.get(index) || '';
|
||||
const styles = getLabelStyles(useTheme());
|
||||
|
||||
return (
|
||||
<div className={styles.label}>
|
||||
<div>{field.config.displayName || field.name}</div>
|
||||
<span className={styles.value}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import usePrevious from 'react-use/lib/usePrevious';
|
||||
import { Config } from './layout';
|
||||
import { NodeDatum } from './types';
|
||||
|
||||
export function useFocusPositionOnLayout(config: Config, nodes: NodeDatum[], focusedNodeId: string | undefined) {
|
||||
const prevLayoutGrid = usePrevious(config.gridLayout);
|
||||
let focusPosition;
|
||||
if (prevLayoutGrid === true && !config.gridLayout && focusedNodeId) {
|
||||
const node = nodes.find((n) => n.id === focusedNodeId);
|
||||
if (node) {
|
||||
focusPosition = {
|
||||
x: -node.x!,
|
||||
y: -node.y!,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return focusPosition;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import useMountedState from 'react-use/lib/useMountedState';
|
||||
|
||||
export function useHighlight(focusedNodeId?: string) {
|
||||
const [highlightId, setHighlightId] = useState<string>();
|
||||
const mounted = useMountedState();
|
||||
useEffect(() => {
|
||||
if (focusedNodeId) {
|
||||
setHighlightId(focusedNodeId);
|
||||
setTimeout(() => {
|
||||
if (mounted()) {
|
||||
setHighlightId(undefined);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, [focusedNodeId, mounted]);
|
||||
|
||||
return highlightId;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { fromPairs, uniq } from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
import { EdgeDatumLayout, NodeDatum, NodesMarker } from './types';
|
||||
import { Config } from './layout';
|
||||
|
||||
type NodesMap = Record<string, NodeDatum>;
|
||||
type EdgesMap = Record<string, EdgeDatumLayout[]>;
|
||||
|
||||
/**
|
||||
* Limits the number of nodes by going from the roots breadth first until we have desired number of nodes.
|
||||
*/
|
||||
export function useNodeLimit(
|
||||
nodes: NodeDatum[],
|
||||
edges: EdgeDatumLayout[],
|
||||
limit: number,
|
||||
config: Config,
|
||||
rootId?: string
|
||||
): { nodes: NodeDatum[]; edges: EdgeDatumLayout[]; markers?: NodesMarker[] } {
|
||||
// This is pretty expensive also this happens once in the layout code when initializing position but it's a bit
|
||||
// tricky to do it only once and reuse the results because layout directly modifies the nodes.
|
||||
const [edgesMap, nodesMap] = useMemo(() => {
|
||||
// Make sure we don't compute this until we have all the data.
|
||||
if (!(nodes.length && edges.length)) {
|
||||
return [{}, {}];
|
||||
}
|
||||
|
||||
const edgesMap = edges.reduce<EdgesMap>((acc, e) => {
|
||||
acc[e.source.id] = [...(acc[e.source.id] ?? []), e];
|
||||
acc[e.target.id] = [...(acc[e.target.id] ?? []), e];
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const nodesMap = nodes.reduce<NodesMap>((acc, node) => {
|
||||
acc[node.id] = node;
|
||||
return acc;
|
||||
}, {});
|
||||
return [edgesMap, nodesMap];
|
||||
}, [edges, nodes]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (nodes.length <= limit) {
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
if (config.gridLayout) {
|
||||
return limitGridLayout(nodes, limit, rootId);
|
||||
}
|
||||
|
||||
return limitGraphLayout(nodes, edges, nodesMap, edgesMap, limit, rootId);
|
||||
}, [edges, edgesMap, limit, nodes, nodesMap, rootId, config.gridLayout]);
|
||||
}
|
||||
|
||||
export function limitGraphLayout(
|
||||
nodes: NodeDatum[],
|
||||
edges: EdgeDatumLayout[],
|
||||
nodesMap: NodesMap,
|
||||
edgesMap: EdgesMap,
|
||||
limit: number,
|
||||
rootId?: string
|
||||
) {
|
||||
let roots;
|
||||
if (rootId) {
|
||||
roots = [nodesMap[rootId]];
|
||||
} else {
|
||||
roots = nodes.filter((n) => n.incoming === 0);
|
||||
// TODO: same code as layout
|
||||
if (!roots.length) {
|
||||
roots = [nodes[0]];
|
||||
}
|
||||
}
|
||||
|
||||
const { visibleNodes, markers } = collectVisibleNodes(limit, roots, nodesMap, edgesMap);
|
||||
|
||||
const markersWithStats = collectMarkerStats(markers, visibleNodes, nodesMap, edgesMap);
|
||||
const markersMap = fromPairs(markersWithStats.map((m) => [m.node.id, m]));
|
||||
|
||||
for (const marker of markersWithStats) {
|
||||
if (marker.count === 1) {
|
||||
delete markersMap[marker.node.id];
|
||||
visibleNodes[marker.node.id] = marker.node;
|
||||
}
|
||||
}
|
||||
|
||||
// Show all edges between visible nodes or placeholder markers
|
||||
const visibleEdges = edges.filter(
|
||||
(e) =>
|
||||
(visibleNodes[e.source.id] || markersMap[e.source.id]) && (visibleNodes[e.target.id] || markersMap[e.target.id])
|
||||
);
|
||||
|
||||
return {
|
||||
nodes: Object.values(visibleNodes),
|
||||
edges: visibleEdges,
|
||||
markers: Object.values(markersMap),
|
||||
};
|
||||
}
|
||||
|
||||
export function limitGridLayout(nodes: NodeDatum[], limit: number, rootId?: string) {
|
||||
let start = 0;
|
||||
let stop = limit;
|
||||
let markers: NodesMarker[] = [];
|
||||
|
||||
if (rootId) {
|
||||
const index = nodes.findIndex((node) => node.id === rootId);
|
||||
const prevLimit = Math.floor(limit / 2);
|
||||
let afterLimit = prevLimit;
|
||||
start = index - prevLimit;
|
||||
if (start < 0) {
|
||||
afterLimit += Math.abs(start);
|
||||
start = 0;
|
||||
}
|
||||
stop = index + afterLimit + 1;
|
||||
|
||||
if (stop > nodes.length) {
|
||||
if (start > 0) {
|
||||
start = Math.max(0, start - (stop - nodes.length));
|
||||
}
|
||||
stop = nodes.length;
|
||||
}
|
||||
|
||||
if (start > 1) {
|
||||
markers.push({ node: nodes[start - 1], count: start });
|
||||
}
|
||||
|
||||
if (nodes.length - stop > 1) {
|
||||
markers.push({ node: nodes[stop], count: nodes.length - stop });
|
||||
}
|
||||
} else {
|
||||
if (nodes.length - limit > 1) {
|
||||
markers = [{ node: nodes[limit], count: nodes.length - limit }];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: nodes.slice(start, stop),
|
||||
edges: [],
|
||||
markers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Breath first traverse of the graph collecting all the nodes until we reach the limit. It also returns markers which
|
||||
* are nodes on the edges which did not make it into the limit but can be used as clickable markers for manually
|
||||
* expanding the graph.
|
||||
* @param limit
|
||||
* @param roots - Nodes where to start the traversal. In case of exploration this can be any node that user clicked on.
|
||||
* @param nodesMap - Node id to node
|
||||
* @param edgesMap - This is a map of node id to a list of edges (both ingoing and outgoing)
|
||||
*/
|
||||
function collectVisibleNodes(
|
||||
limit: number,
|
||||
roots: NodeDatum[],
|
||||
nodesMap: Record<string, NodeDatum>,
|
||||
edgesMap: Record<string, EdgeDatumLayout[]>
|
||||
): { visibleNodes: Record<string, NodeDatum>; markers: NodeDatum[] } {
|
||||
const visibleNodes: Record<string, NodeDatum> = {};
|
||||
let stack = [...roots];
|
||||
|
||||
while (Object.keys(visibleNodes).length < limit && stack.length > 0) {
|
||||
let current = stack.shift()!;
|
||||
|
||||
// We are already showing this node. This can happen because graphs can be cyclic
|
||||
if (visibleNodes[current!.id]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Show this node
|
||||
visibleNodes[current.id] = current;
|
||||
const edges = edgesMap[current.id] || [];
|
||||
|
||||
// Add any nodes that are connected to it on top of the stack to be considered in the next pass
|
||||
const connectedNodes = edges.map((e) => {
|
||||
// We don't care about direction here. Should not make much difference but argument could be made that with
|
||||
// directed graphs it should walk the graph directionally. Problem is when we focus on a node in the middle of
|
||||
// graph (not going from the "natural" root) we also want to show what was "before".
|
||||
const id = e.source.id === current.id ? e.target.id : e.source.id;
|
||||
return nodesMap[id];
|
||||
});
|
||||
stack = stack.concat(connectedNodes);
|
||||
}
|
||||
|
||||
// Right now our stack contains all the nodes which are directly connected to the graph but did not make the cut.
|
||||
// Some of them though can be nodes we already are showing so we have to filter them and then use them as markers.
|
||||
const markers = uniq(stack.filter((n) => !visibleNodes[n.id]));
|
||||
|
||||
return { visibleNodes, markers };
|
||||
}
|
||||
|
||||
function collectMarkerStats(
|
||||
markers: NodeDatum[],
|
||||
visibleNodes: Record<string, NodeDatum>,
|
||||
nodesMap: Record<string, NodeDatum>,
|
||||
edgesMap: Record<string, EdgeDatumLayout[]>
|
||||
): NodesMarker[] {
|
||||
return markers.map((marker) => {
|
||||
const nodesToCount: Record<string, NodeDatum> = {};
|
||||
let count = 0;
|
||||
let stack = [marker];
|
||||
while (stack.length > 0 && count <= 101) {
|
||||
let current = stack.shift()!;
|
||||
|
||||
// We are showing this node so not going to count it as hidden.
|
||||
if (visibleNodes[current.id] || nodesToCount[current.id]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nodesToCount[current.id]) {
|
||||
count++;
|
||||
}
|
||||
nodesToCount[current.id] = current;
|
||||
|
||||
const edges = edgesMap[current.id] || [];
|
||||
|
||||
const connectedNodes = edges.map((e) => {
|
||||
const id = e.source.id === current.id ? e.target.id : e.source.id;
|
||||
return nodesMap[id];
|
||||
});
|
||||
stack = stack.concat(connectedNodes);
|
||||
}
|
||||
|
||||
return {
|
||||
node: marker,
|
||||
count: count,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useRef, RefObject, useState, useMemo } from 'react';
|
||||
import useMountedState from 'react-use/lib/useMountedState';
|
||||
import { Bounds } from './utils';
|
||||
import usePrevious from 'react-use/lib/usePrevious';
|
||||
|
||||
export interface State {
|
||||
isPanning: boolean;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Options {
|
||||
scale?: number;
|
||||
bounds?: Bounds;
|
||||
focus?: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on https://github.com/streamich/react-use/blob/master/src/useSlider.ts
|
||||
* Returns position x/y coordinates which can be directly used in transform: translate().
|
||||
* @param scale - Can be used when we want to scale the movement if we are moving a scaled element. We need to do it
|
||||
* here because we don't want to change the pos when scale changes.
|
||||
* @param bounds - If set the panning cannot go outside of those bounds.
|
||||
* @param focus - Position to focus on.
|
||||
*/
|
||||
export function usePanning<T extends Element>({ scale = 1, bounds, focus }: Options = {}): {
|
||||
state: State;
|
||||
ref: RefObject<T>;
|
||||
} {
|
||||
const isMounted = useMountedState();
|
||||
const isPanning = useRef(false);
|
||||
const frame = useRef(0);
|
||||
const panRef = useRef<T>(null);
|
||||
|
||||
const initial = { x: 0, y: 0 };
|
||||
// As we return a diff of the view port to be applied we need as translate coordinates we have to invert the
|
||||
// bounds of the content to get the bounds of the view port diff.
|
||||
const viewBounds = useMemo(
|
||||
() => ({
|
||||
right: bounds ? -bounds.left : Infinity,
|
||||
left: bounds ? -bounds.right : -Infinity,
|
||||
bottom: bounds ? -bounds.top : -Infinity,
|
||||
top: bounds ? -bounds.bottom : Infinity,
|
||||
}),
|
||||
[bounds]
|
||||
);
|
||||
|
||||
// We need to keep some state so we can compute the position diff and add that to the previous position.
|
||||
const startMousePosition = useRef(initial);
|
||||
const prevPosition = useRef(initial);
|
||||
// We cannot use the state as that would rerun the effect on each state change which we don't want so we have to keep
|
||||
// separate variable for the state that won't cause useEffect eval
|
||||
const currentPosition = useRef(initial);
|
||||
|
||||
const [state, setState] = useState<State>({
|
||||
isPanning: false,
|
||||
position: initial,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const startPanning = (event: Event) => {
|
||||
if (!isPanning.current && isMounted()) {
|
||||
isPanning.current = true;
|
||||
// Snapshot the current position of both mouse pointer and the element
|
||||
startMousePosition.current = getEventXY(event);
|
||||
prevPosition.current = { ...currentPosition.current };
|
||||
setState((state) => ({ ...state, isPanning: true }));
|
||||
bindEvents();
|
||||
}
|
||||
};
|
||||
|
||||
const stopPanning = () => {
|
||||
if (isPanning.current && isMounted()) {
|
||||
isPanning.current = false;
|
||||
setState((state) => ({ ...state, isPanning: false }));
|
||||
unbindEvents();
|
||||
}
|
||||
};
|
||||
|
||||
const onPanStart = (event: Event) => {
|
||||
startPanning(event);
|
||||
onPan(event);
|
||||
};
|
||||
|
||||
const bindEvents = () => {
|
||||
document.addEventListener('mousemove', onPan);
|
||||
document.addEventListener('mouseup', stopPanning);
|
||||
document.addEventListener('touchmove', onPan);
|
||||
document.addEventListener('touchend', stopPanning);
|
||||
};
|
||||
|
||||
const unbindEvents = () => {
|
||||
document.removeEventListener('mousemove', onPan);
|
||||
document.removeEventListener('mouseup', stopPanning);
|
||||
document.removeEventListener('touchmove', onPan);
|
||||
document.removeEventListener('touchend', stopPanning);
|
||||
};
|
||||
|
||||
const onPan = (event: Event) => {
|
||||
cancelAnimationFrame(frame.current);
|
||||
const pos = getEventXY(event);
|
||||
|
||||
frame.current = requestAnimationFrame(() => {
|
||||
if (isMounted() && panRef.current) {
|
||||
// Get the diff by which we moved the mouse.
|
||||
let xDiff = pos.x - startMousePosition.current.x;
|
||||
let yDiff = pos.y - startMousePosition.current.y;
|
||||
|
||||
// Add the diff to the position from the moment we started panning.
|
||||
currentPosition.current = {
|
||||
x: inBounds(prevPosition.current.x + xDiff / scale, viewBounds.left, viewBounds.right),
|
||||
y: inBounds(prevPosition.current.y + yDiff / scale, viewBounds.top, viewBounds.bottom),
|
||||
};
|
||||
setState((state) => ({
|
||||
...state,
|
||||
position: {
|
||||
...currentPosition.current,
|
||||
},
|
||||
}));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const ref = panRef.current;
|
||||
if (ref) {
|
||||
ref.addEventListener('mousedown', onPanStart);
|
||||
ref.addEventListener('touchstart', onPanStart);
|
||||
}
|
||||
return () => {
|
||||
if (ref) {
|
||||
ref.removeEventListener('mousedown', onPanStart);
|
||||
ref.removeEventListener('touchstart', onPanStart);
|
||||
}
|
||||
};
|
||||
}, [scale, viewBounds, isMounted]);
|
||||
|
||||
const previousFocus = usePrevious(focus);
|
||||
|
||||
// We need to update the state in case need to focus on something but we want to do it only once when the focus
|
||||
// changes to something new.
|
||||
useEffect(() => {
|
||||
if (focus && previousFocus?.x !== focus.x && previousFocus?.y !== focus.y) {
|
||||
const position = {
|
||||
x: inBounds(focus.x, viewBounds.left, viewBounds.right),
|
||||
y: inBounds(focus.y, viewBounds.top, viewBounds.bottom),
|
||||
};
|
||||
setState({
|
||||
position,
|
||||
isPanning: false,
|
||||
});
|
||||
|
||||
currentPosition.current = position;
|
||||
prevPosition.current = position;
|
||||
}
|
||||
}, [focus, previousFocus, viewBounds, currentPosition, prevPosition]);
|
||||
|
||||
let position = state.position;
|
||||
// This part prevents an ugly jump from initial position to the focused one as the set state in the effects is after
|
||||
// initial render.
|
||||
if (focus && previousFocus?.x !== focus.x && previousFocus?.y !== focus.y) {
|
||||
position = focus;
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
position: {
|
||||
x: inBounds(position.x, viewBounds.left, viewBounds.right),
|
||||
y: inBounds(position.y, viewBounds.top, viewBounds.bottom),
|
||||
},
|
||||
},
|
||||
ref: panRef,
|
||||
};
|
||||
}
|
||||
|
||||
function inBounds(value: number, min: number | undefined, max: number | undefined) {
|
||||
return Math.min(Math.max(value, min ?? -Infinity), max ?? Infinity);
|
||||
}
|
||||
|
||||
function getEventXY(event: Event): { x: number; y: number } {
|
||||
if ((event as any).changedTouches) {
|
||||
const e = event as TouchEvent;
|
||||
return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY };
|
||||
} else {
|
||||
const e = event as MouseEvent;
|
||||
return { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const defaultOptions: Options = {
|
||||
stepDown: (s) => s / 1.5,
|
||||
stepUp: (s) => s * 1.5,
|
||||
min: 0.13,
|
||||
max: 2.25,
|
||||
};
|
||||
|
||||
interface Options {
|
||||
/**
|
||||
* Allows you to specify how the step up will be handled so you can do fractional steps based on previous value.
|
||||
*/
|
||||
stepUp: (scale: number) => number;
|
||||
stepDown: (scale: number) => number;
|
||||
|
||||
/**
|
||||
* Set max and min values. If stepUp/down overshoots these bounds this will return min or max but internal scale value
|
||||
* will still be what ever the step functions returned last.
|
||||
*/
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps state and returns handlers that can be used to implement zooming functionality ideally by using it with
|
||||
* 'transform: scale'. It returns handler for manual buttons with zoom in/zoom out function and a ref that can be
|
||||
* used to zoom in/out with mouse wheel.
|
||||
*/
|
||||
export function useZoom({ stepUp, stepDown, min, max } = defaultOptions) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
const onStepUp = useCallback(() => {
|
||||
if (scale < (max ?? Infinity)) {
|
||||
setScale(stepUp(scale));
|
||||
}
|
||||
}, [scale, stepUp, max]);
|
||||
|
||||
const onStepDown = useCallback(() => {
|
||||
if (scale > (min ?? -Infinity)) {
|
||||
setScale(stepDown(scale));
|
||||
}
|
||||
}, [scale, stepDown, min]);
|
||||
|
||||
const onWheel = useCallback(
|
||||
function (event: Event) {
|
||||
// Seems like typing for the addEventListener is lacking a bit
|
||||
const wheelEvent = event as WheelEvent;
|
||||
|
||||
// Only do this with special key pressed similar to how google maps work.
|
||||
// TODO: I would guess this won't work very well with touch right now
|
||||
if (wheelEvent.ctrlKey || wheelEvent.metaKey) {
|
||||
event.preventDefault();
|
||||
|
||||
if (wheelEvent.deltaY < 0) {
|
||||
onStepUp();
|
||||
} else if (wheelEvent.deltaY > 0) {
|
||||
onStepDown();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onStepDown, onStepUp]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const zoomRef = ref.current;
|
||||
|
||||
// Adds listener for wheel event, we need the passive: false to be able to prevent default otherwise that
|
||||
// cannot be used with passive listeners.
|
||||
zoomRef.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => {
|
||||
if (zoomRef) {
|
||||
zoomRef.removeEventListener('wheel', onWheel);
|
||||
}
|
||||
};
|
||||
}, [onWheel]);
|
||||
|
||||
return {
|
||||
onStepUp,
|
||||
onStepDown,
|
||||
scale: Math.max(Math.min(scale, max ?? Infinity), min ?? -Infinity),
|
||||
isMax: scale >= (max ?? Infinity),
|
||||
isMin: scale <= (min ?? -Infinity),
|
||||
ref,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { ArrayVector, createTheme } from '@grafana/data';
|
||||
import { makeEdgesDataFrame, makeNodesDataFrame, processNodes } from './utils';
|
||||
|
||||
describe('processNodes', () => {
|
||||
const theme = createTheme();
|
||||
|
||||
it('handles empty args', async () => {
|
||||
expect(processNodes(undefined, undefined, theme)).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
|
||||
);
|
||||
|
||||
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: 'rgb(226, 192, 61)',
|
||||
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: 'rgb(226, 192, 61)',
|
||||
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: 'rgb(226, 192, 61)',
|
||||
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([
|
||||
{
|
||||
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([
|
||||
{
|
||||
color: 'green',
|
||||
name: 'arc__success',
|
||||
},
|
||||
{
|
||||
color: 'red',
|
||||
name: 'arc__errors',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
import {
|
||||
ArrayVector,
|
||||
DataFrame,
|
||||
Field,
|
||||
FieldCache,
|
||||
FieldType,
|
||||
getFieldColorModeForField,
|
||||
GrafanaTheme2,
|
||||
MutableDataFrame,
|
||||
NodeGraphDataFrameFieldNames,
|
||||
} from '@grafana/data';
|
||||
import { EdgeDatum, NodeDatum } from './types';
|
||||
|
||||
type Line = { x1: number; y1: number; x2: number; y2: number };
|
||||
|
||||
/**
|
||||
* Makes line shorter while keeping the middle in he same place.
|
||||
*/
|
||||
export function shortenLine(line: Line, length: number): Line {
|
||||
const vx = line.x2 - line.x1;
|
||||
const vy = line.y2 - line.y1;
|
||||
const mag = Math.sqrt(vx * vx + vy * vy);
|
||||
const ratio = Math.max((mag - length) / mag, 0);
|
||||
const vx2 = vx * ratio;
|
||||
const vy2 = vy * ratio;
|
||||
const xDiff = vx - vx2;
|
||||
const yDiff = vy - vy2;
|
||||
const newx1 = line.x1 + xDiff / 2;
|
||||
const newy1 = line.y1 + yDiff / 2;
|
||||
return {
|
||||
x1: newx1,
|
||||
y1: newy1,
|
||||
x2: newx1 + vx2,
|
||||
y2: newy1 + vy2,
|
||||
};
|
||||
}
|
||||
|
||||
export function getNodeFields(nodes: DataFrame) {
|
||||
const fieldsCache = new FieldCache(nodes);
|
||||
return {
|
||||
id: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id),
|
||||
title: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.title),
|
||||
subTitle: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.subTitle),
|
||||
mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat),
|
||||
secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat),
|
||||
arc: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.arc),
|
||||
details: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.detail),
|
||||
color: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.color),
|
||||
};
|
||||
}
|
||||
|
||||
export function getEdgeFields(edges: DataFrame) {
|
||||
const fieldsCache = new FieldCache(edges);
|
||||
return {
|
||||
id: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id),
|
||||
source: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.source),
|
||||
target: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.target),
|
||||
mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat),
|
||||
secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat),
|
||||
details: findFieldsByPrefix(edges, NodeGraphDataFrameFieldNames.detail),
|
||||
};
|
||||
}
|
||||
|
||||
function findFieldsByPrefix(frame: DataFrame, prefix: string) {
|
||||
return frame.fields.filter((f) => f.name.match(new RegExp('^' + prefix)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform nodes and edges dataframes into array of objects that the layout code can then work with.
|
||||
*/
|
||||
export function processNodes(
|
||||
nodes: DataFrame | undefined,
|
||||
edges: DataFrame | undefined,
|
||||
theme: GrafanaTheme2
|
||||
): {
|
||||
nodes: NodeDatum[];
|
||||
edges: EdgeDatum[];
|
||||
legend?: Array<{
|
||||
color: string;
|
||||
name: string;
|
||||
}>;
|
||||
} {
|
||||
if (!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 ? getColor(nodeFields.color, index, theme) : '',
|
||||
};
|
||||
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.');
|
||||
}
|
||||
|
||||
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++;
|
||||
|
||||
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),
|
||||
edges: edgesMapped || [],
|
||||
legend: nodeFields.arc.map((f) => {
|
||||
return {
|
||||
color: f.config.color?.fixedColor ?? '',
|
||||
name: f.config.displayName || f.name,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function statToString(field: Field, index: number) {
|
||||
if (field.type === FieldType.string) {
|
||||
return field.values.get(index);
|
||||
} 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 : '');
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilities mainly for testing
|
||||
*/
|
||||
|
||||
export function makeNodesDataFrame(count: number) {
|
||||
const frame = nodesFrame();
|
||||
for (let i = 0; i < count; i++) {
|
||||
frame.add(makeNode(i));
|
||||
}
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
function makeNode(index: number) {
|
||||
return {
|
||||
id: index.toString(),
|
||||
title: `service:${index}`,
|
||||
subTitle: 'service',
|
||||
arc__success: 0.5,
|
||||
arc__errors: 0.5,
|
||||
mainStat: 0.1,
|
||||
secondaryStat: 2,
|
||||
color: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
function nodesFrame() {
|
||||
const fields: any = {
|
||||
[NodeGraphDataFrameFieldNames.id]: {
|
||||
values: new ArrayVector(),
|
||||
type: FieldType.string,
|
||||
},
|
||||
[NodeGraphDataFrameFieldNames.title]: {
|
||||
values: new ArrayVector(),
|
||||
type: FieldType.string,
|
||||
},
|
||||
[NodeGraphDataFrameFieldNames.subTitle]: {
|
||||
values: new ArrayVector(),
|
||||
type: FieldType.string,
|
||||
},
|
||||
[NodeGraphDataFrameFieldNames.mainStat]: {
|
||||
values: new ArrayVector(),
|
||||
type: FieldType.number,
|
||||
},
|
||||
[NodeGraphDataFrameFieldNames.secondaryStat]: {
|
||||
values: new ArrayVector(),
|
||||
type: FieldType.number,
|
||||
},
|
||||
[NodeGraphDataFrameFieldNames.arc + 'success']: {
|
||||
values: new ArrayVector(),
|
||||
type: FieldType.number,
|
||||
config: { color: { fixedColor: 'green' } },
|
||||
},
|
||||
[NodeGraphDataFrameFieldNames.arc + 'errors']: {
|
||||
values: new ArrayVector(),
|
||||
type: FieldType.number,
|
||||
config: { color: { fixedColor: 'red' } },
|
||||
},
|
||||
|
||||
[NodeGraphDataFrameFieldNames.color]: {
|
||||
values: new ArrayVector(),
|
||||
type: FieldType.number,
|
||||
config: { color: { mode: 'continuous-GrYlRd' } },
|
||||
},
|
||||
};
|
||||
|
||||
return new MutableDataFrame({
|
||||
name: 'nodes',
|
||||
fields: Object.keys(fields).map((key) => ({
|
||||
...fields[key],
|
||||
name: key,
|
||||
})),
|
||||
meta: { preferredVisualisationType: 'nodeGraph' },
|
||||
});
|
||||
}
|
||||
|
||||
export function makeEdgesDataFrame(edges: Array<[number, number]>) {
|
||||
const frame = edgesFrame();
|
||||
for (const edge of edges) {
|
||||
frame.add({
|
||||
id: edge[0] + '--' + edge[1],
|
||||
source: edge[0].toString(),
|
||||
target: edge[1].toString(),
|
||||
});
|
||||
}
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
function edgesFrame() {
|
||||
const fields: 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,
|
||||
},
|
||||
};
|
||||
|
||||
return new MutableDataFrame({
|
||||
name: 'edges',
|
||||
fields: Object.keys(fields).map((key) => ({
|
||||
...fields[key],
|
||||
name: key,
|
||||
})),
|
||||
meta: { preferredVisualisationType: 'nodeGraph' },
|
||||
});
|
||||
}
|
||||
|
||||
function getColor(field: Field, index: number, theme: GrafanaTheme2): string {
|
||||
if (!field.config.color) {
|
||||
return field.values.get(index);
|
||||
}
|
||||
|
||||
return getFieldColorModeForField(field).getCalculator(field, theme)(0, field.values.get(index));
|
||||
}
|
||||
|
||||
export interface Bounds {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
center: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bounds of the graph meaning the extent of the nodes in all directions.
|
||||
*/
|
||||
export function graphBounds(nodes: NodeDatum[]): Bounds {
|
||||
if (nodes.length === 0) {
|
||||
return { top: 0, right: 0, bottom: 0, left: 0, center: { x: 0, y: 0 } };
|
||||
}
|
||||
|
||||
const bounds = nodes.reduce(
|
||||
(acc, node) => {
|
||||
if (node.x! > acc.right) {
|
||||
acc.right = node.x!;
|
||||
}
|
||||
if (node.x! < acc.left) {
|
||||
acc.left = node.x!;
|
||||
}
|
||||
if (node.y! > acc.bottom) {
|
||||
acc.bottom = node.y!;
|
||||
}
|
||||
if (node.y! < acc.top) {
|
||||
acc.top = node.y!;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ top: Infinity, right: -Infinity, bottom: -Infinity, left: Infinity }
|
||||
);
|
||||
|
||||
const y = bounds.top + (bounds.bottom - bounds.top) / 2;
|
||||
const x = bounds.left + (bounds.right - bounds.left) / 2;
|
||||
|
||||
return {
|
||||
...bounds,
|
||||
center: {
|
||||
x,
|
||||
y,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user