Files
grafana/packages/grafana-ui/src/components/NodeGraph/NodeGraph.tsx
T
Torkel Ödegaard 017bcc73ba Theme: Move displayProcessor & fieldOverrides & visualizations to use new theme model (#33502)
* WIP updating getColorForTheme

* Progress

* More fixes

* Updating more parts

* Fixing unit tests

* Fixing more tests

* Fixing storybook issues

* More refactoring

* Fixed test
2021-04-29 12:44:06 +02:00

258 lines
8.1 KiB
TypeScript

import React, { memo, MutableRefObject, useCallback, useMemo, useState, MouseEvent } from 'react';
import cx from 'classnames';
import useMeasure from 'react-use/lib/useMeasure';
import { usePanning } from './usePanning';
import { EdgeDatum, NodeDatum } from './types';
import { Node } from './Node';
import { Edge } from './Edge';
import { ViewControls } from './ViewControls';
import { DataFrame, GrafanaThemeV2, LinkModel } from '@grafana/data';
import { useZoom } from './useZoom';
import { Bounds, Config, defaultConfig, useLayout } from './layout';
import { EdgeArrowMarker } from './EdgeArrowMarker';
import { stylesFactory, useTheme2 } from '../../themes';
import { css } from '@emotion/css';
import { useCategorizeFrames } from './useCategorizeFrames';
import { EdgeLabel } from './EdgeLabel';
import { useContextMenu } from './useContextMenu';
import { processNodes } from './utils';
import { Icon } from '..';
import { useNodeLimit } from './useNodeLimit';
const getStyles = stylesFactory((theme: GrafanaThemeV2) => ({
wrapper: css`
height: 100%;
width: 100%;
overflow: hidden;
position: relative;
`,
svg: css`
height: 100%;
width: 100%;
overflow: visible;
font-size: 10px;
cursor: move;
`,
svgPanning: css`
user-select: none;
`,
mainGroup: css`
will-change: transform;
`,
viewControls: css`
position: absolute;
left: 10px;
top: 10px;
`,
alert: css`
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};
`,
}));
// This is mainly for performance reasons.
const defaultNodeCountLimit = 1500;
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] = useState<string | undefined>(undefined);
const clearNodeHover = useCallback(() => setNodeHover(undefined), [setNodeHover]);
const [edgeHover, setEdgeHover] = useState<string | undefined>(undefined);
const clearEdgeHover = useCallback(() => setEdgeHover(undefined), [setEdgeHover]);
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,
]);
const { nodes: rawNodes, edges: rawEdges } = useNodeLimit(processed.nodes, processed.edges, nodeCountLimit);
const hiddenNodesCount = processed.nodes.length - rawNodes.length;
const { nodes, edges, bounds } = useLayout(rawNodes, rawEdges, config);
const { panRef, zoomRef, onStepUp, onStepDown, isPanning, position, scale, isMaxZoom, isMinZoom } = usePanAndZoom(
bounds
);
const { onEdgeOpen, onNodeOpen, MenuComponent } = useContextMenu(getLinks, nodesDataFrames[0], edgesDataFrames[0]);
const styles = getStyles(theme);
// 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]
);
return (
<div ref={topLevelRef} className={styles.wrapper}>
<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 />
<Edges
edges={edges}
nodeHoveringId={nodeHover}
edgeHoveringId={edgeHover}
onClick={onEdgeOpen}
onMouseEnter={setEdgeHover}
onMouseLeave={clearEdgeHover}
/>
<Nodes
nodes={nodes}
onMouseEnter={setNodeHover}
onMouseLeave={clearNodeHover}
onClick={onNodeOpen}
hoveringId={nodeHover}
/>
{/*We split the labels from edges so that they are shown on top of everything else*/}
<EdgeLabels edges={edges} nodeHoveringId={nodeHover} edgeHoveringId={edgeHover} />
</g>
</svg>
<div className={styles.viewControls}>
<ViewControls<Config>
config={config}
onConfigChange={setConfig}
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 3 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 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) {
const { scale, onStepDown, onStepUp, ref, isMax, isMin } = useZoom();
const { state: panningState, ref: panRef } = usePanning<SVGSVGElement>({
scale,
bounds,
});
const { position, isPanning } = panningState;
return { zoomRef: ref, panRef, position, isPanning, scale, onStepDown, onStepUp, isMaxZoom: isMax, isMinZoom: isMin };
}