NodeGraph: Zoom mode option (#95823)

* NodeGraph: Zoom mode option

* Update the docs

* refactor stepUp/stepDown handling

* fix

* Update cooperative description

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update zoom mode wording

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Add  tests

* Move node graph settings to align with other viz

---------

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>
This commit is contained in:
Edvard Falkskär
2024-11-15 09:43:28 +01:00
committed by GitHub
co-authored by Isabel Matwawana
parent e331b778c1
commit af7512741c
10 changed files with 109 additions and 14 deletions
@@ -83,6 +83,12 @@ If a node lacks edge connections, it’s displayed on its own outside of the net
{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
## Node graph options
Use the following options to refine your node graph visualization.
- **Zoom mode** - Choose how the node graph should handle zoom and scroll events.
## Nodes options
The **Nodes** options section provides configurations for node behaviors.
@@ -21,6 +21,11 @@ export interface ArcOption {
field?: string;
}
export enum ZoomMode {
Cooperative = 'cooperative',
Greedy = 'greedy',
}
export interface Options {
edges?: {
/**
@@ -46,4 +51,8 @@ export interface Options {
*/
arcs?: Array<ArcOption>;
};
/**
* How to handle zoom/scroll events in the node graph
*/
zoomMode?: ZoomMode;
}
@@ -2,6 +2,7 @@ import { render, screen, fireEvent, waitFor, getByText } from '@testing-library/
import userEvent from '@testing-library/user-event';
import { NodeGraph } from './NodeGraph';
import { ZoomMode } from './panelcfg.gen';
import { makeEdgesDataFrame, makeNodesDataFrame } from './utils';
jest.mock('react-use/lib/useMeasure', () => {
@@ -20,7 +21,7 @@ describe('NodeGraph', () => {
await screen.findByText('No data');
});
it('can zoom in and out', async () => {
it('can zoom in and out with zoom buttons', async () => {
render(
<NodeGraph
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]}
@@ -37,6 +38,42 @@ describe('NodeGraph', () => {
expect(getScale()).toBe(1);
});
it('can zoom while pressing ctrl/command key with cooperative zoom mode', async () => {
render(
<NodeGraph
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]}
zoomMode={ZoomMode.Cooperative}
getLinks={() => []}
/>
);
await screen.findByLabelText('Node: service:1');
scrollView({ deltaY: -2, ctrlKey: false });
expect(getScale()).toBe(1);
scrollView({ deltaY: -2, ctrlKey: true });
expect(getScale()).toBe(1.03);
});
it('can zoom without pressing ctrl/command key with greedy zoom mode', async () => {
render(
<NodeGraph
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]}
zoomMode={ZoomMode.Greedy}
getLinks={() => []}
/>
);
await screen.findByLabelText('Node: service:1');
scrollView({ deltaY: -2, ctrlKey: true });
expect(getScale()).toBe(1.03);
scrollView({ deltaY: -2, ctrlKey: true });
expect(getScale()).toBe(1.06);
});
it('can pan the graph', async () => {
render(
<NodeGraph
@@ -235,6 +272,11 @@ function panView(toPos: { x: number; y: number }) {
fireEvent(document, new MouseEvent('mouseup'));
}
function scrollView({ deltaY, ctrlKey }: { deltaY: number; ctrlKey: boolean }) {
const svg = getSvg();
fireEvent.wheel(svg, { deltaY, ctrlKey });
}
function getSvg() {
return screen.getAllByText('', { selector: 'svg' })[0];
}
@@ -13,7 +13,7 @@ import { Marker } from './Marker';
import { Node } from './Node';
import { ViewControls } from './ViewControls';
import { Config, defaultConfig, useLayout } from './layout';
import { EdgeDatumLayout, NodeDatum, NodesMarker } from './types';
import { EdgeDatumLayout, NodeDatum, NodesMarker, ZoomMode } from './types';
import { useCategorizeFrames } from './useCategorizeFrames';
import { useContextMenu } from './useContextMenu';
import { useFocusPositionOnLayout } from './useFocusPositionOnLayout';
@@ -112,8 +112,9 @@ interface Props {
getLinks: (dataFrame: DataFrame, rowIndex: number) => LinkModel[];
nodeLimit?: number;
panelId?: string;
zoomMode?: ZoomMode;
}
export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId }: Props) {
export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode }: Props) {
const nodeCountLimit = nodeLimit || defaultNodeCountLimit;
const { edges: edgesDataFrames, nodes: nodesDataFrames } = useCategorizeFrames(dataFrames);
@@ -173,7 +174,8 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId }: Props) {
const focusPosition = useFocusPositionOnLayout(config, nodes, focusedNodeId);
const { panRef, zoomRef, onStepUp, onStepDown, isPanning, position, scale, isMaxZoom, isMinZoom } = usePanAndZoom(
bounds,
focusPosition
focusPosition,
zoomMode
);
const { onEdgeOpen, onNodeOpen, MenuComponent } = useContextMenu(
@@ -392,8 +394,8 @@ const EdgeLabels = memo(function EdgeLabels(props: EdgeLabelsProps) {
);
});
function usePanAndZoom(bounds: Bounds, focus?: { x: number; y: number }) {
const { scale, onStepDown, onStepUp, ref, isMax, isMin } = useZoom();
function usePanAndZoom(bounds: Bounds, focus?: { x: number; y: number }, zoomMode?: ZoomMode) {
const { scale, onStepDown, onStepUp, ref, isMax, isMin } = useZoom({ zoomMode });
const { state: panningState, ref: panRef } = usePanning<SVGSVGElement>({
scale,
bounds,
@@ -28,6 +28,7 @@ export const NodeGraphPanel = ({ width, height, data, options }: PanelProps<Node
dataFrames={memoizedGetNodeGraphDataFrames(data.series, options)}
getLinks={getLinks}
panelId={panelId}
zoomMode={options.zoomMode}
/>
</div>
);
@@ -10,6 +10,17 @@ export const plugin = new PanelPlugin<NodeGraphOptions>(NodeGraphPanel)
disableStandardOptions: Object.values(FieldConfigProperty).filter((v) => v !== FieldConfigProperty.Links),
})
.setPanelOptions((builder, context) => {
builder.addSelect({
name: 'Zoom mode',
path: 'zoomMode',
defaultValue: 'cooperative',
settings: {
options: [
{ value: 'cooperative', label: 'Cooperative', description: 'Lets you scroll the page normally' },
{ value: 'greedy', label: 'Greedy', description: 'Reacts to all zoom gestures' },
],
},
});
builder.addNestedOptions({
category: ['Nodes'],
path: 'nodes',
@@ -43,9 +43,12 @@ composableKinds: PanelCfg: {
// Unit for the secondary stat to override what ever is set in the data frame.
secondaryStatUnit?: string
}
ZoomMode: "cooperative" | "greedy" @cuetsy(kind="enum")
Options: {
nodes?: NodeOptions
edges?: EdgeOptions
// How to handle zoom/scroll events in the node graph
zoomMode?: ZoomMode
} @cuetsy(kind="interface")
}
}]
@@ -19,6 +19,11 @@ export interface ArcOption {
field?: string;
}
export enum ZoomMode {
Cooperative = 'cooperative',
Greedy = 'greedy',
}
export interface Options {
edges?: {
/**
@@ -44,4 +49,8 @@ export interface Options {
*/
arcs?: Array<ArcOption>;
};
/**
* How to handle zoom/scroll events in the node graph
*/
zoomMode?: ZoomMode;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force';
import { DataFrame, Field, IconName } from '@grafana/data';
export type { Options as NodeGraphOptions, ArcOption } from './panelcfg.gen';
export type { Options as NodeGraphOptions, ArcOption, ZoomMode } from './panelcfg.gen';
export type NodeDatum = SimulationNodeDatum & {
id: string;
+19 -7
View File
@@ -1,18 +1,21 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const defaultOptions: Options = {
stepDown: (s) => s / 1.5,
import { ZoomMode } from './panelcfg.gen';
const defaultOptions: Required<Options> = {
stepUp: (s) => s * 1.5,
stepDown: (s) => s / 1.5,
min: 0.13,
max: 2.25,
zoomMode: ZoomMode.Cooperative,
};
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;
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
@@ -20,6 +23,11 @@ interface Options {
*/
min?: number;
max?: number;
/**
* Sets how to handle zoom events when user is interacting with the page
*/
zoomMode?: ZoomMode;
}
/**
@@ -27,7 +35,11 @@ interface Options {
* '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) {
export function useZoom(options: Options = defaultOptions) {
const { min, max, zoomMode } = { ...defaultOptions, ...options };
const stepUp = options.stepUp ?? defaultOptions.stepUp;
const stepDown = options.stepDown ?? defaultOptions.stepDown;
const ref = useRef<HTMLElement | null>(null);
const [scale, setScale] = useState(1);
@@ -49,7 +61,7 @@ export function useZoom({ stepUp, stepDown, min, max } = defaultOptions) {
// 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) {
if (wheelEvent.ctrlKey || wheelEvent.metaKey || zoomMode === ZoomMode.Greedy) {
wheelEvent.preventDefault();
setScale(Math.min(Math.max(min ?? -Infinity, scale + Math.min(wheelEvent.deltaY, 2) * -0.01), max ?? Infinity));
@@ -63,7 +75,7 @@ export function useZoom({ stepUp, stepDown, min, max } = defaultOptions) {
}
}
},
[min, max, scale]
[min, max, scale, zoomMode]
);
useEffect(() => {