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:
co-authored by
Isabel Matwawana
parent
e331b778c1
commit
af7512741c
@@ -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.
|
||||
|
||||
+9
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user