Skip to content

Commit

Permalink
Add fit to node canvas util (#258)
Browse files Browse the repository at this point in the history
* Add fit to node canvas util

* Update docs

* Pull fit to node logic into functions

* Add support for fitting the view to multiple nodes

* Add string type input to fitNodes

* Add util comment
  • Loading branch information
ghsteff committed Jul 11, 2024
1 parent 43e8616 commit 981f99c
Show file tree
Hide file tree
Showing 5 changed files with 319 additions and 97 deletions.
21 changes: 18 additions & 3 deletions docs/Advanced/Refs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,29 @@ export interface CanvasRef {
containerHeight?: number;

/**
* Center the canvas to the viewport.
* Positions the canvas to the viewport.
*/
centerCanvas?: () => void;
positionCanvas?: (position: CanvasPosition, animated?: boolean) => void;

/**
* Fit the canvas to the viewport.
*/
fitCanvas?: () => void;
fitCanvas?: (animated?: boolean) => void;

/**
* Fit a group of nodes to the viewport.
*/
fitNodes?: (nodeIds: string | string[], animated?: boolean) => void;

/**
* Scroll to X/Y
*/
setScrollXY?: (xy: [number, number], animated?: boolean) => void;

/**
* Factor of zoom.
*/
zoom: number;

/**
* Set a zoom factor of the canvas.
Expand Down
178 changes: 88 additions & 90 deletions src/layout/useLayout.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
import {
RefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react';
import {
elkLayout,
CanvasDirection,
ElkCanvasLayoutOptions
} from './elkLayout';
import { RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import useDimensions from 'react-cool-dimensions';
import isEqual from 'react-fast-compare';
import { CanvasPosition, EdgeData, NodeData } from '../types';
import { CanvasDirection, ElkCanvasLayoutOptions, elkLayout } from './elkLayout';
import { calculateScrollPosition, calculateZoom, findNode } from './utils';

export interface ElkRoot {
x?: number;
Expand Down Expand Up @@ -84,35 +74,27 @@ export interface LayoutResult {
/**
* Positions the canvas to the viewport.
*/
positionCanvas?: (position: CanvasPosition) => void;
positionCanvas?: (position: CanvasPosition, animated?: boolean) => void;

/**
* Fit the canvas to the viewport.
*/
fitCanvas?: () => void;
fitCanvas?: (animated?: boolean) => void;

/**
* Fit a group of nodes to the viewport.
*/
fitNodes?: (nodeIds: string | string[], animated?: boolean) => void;

/**
* Scroll to X/Y
*/
setScrollXY?: (xy: [number, number]) => void;
setScrollXY?: (xy: [number, number], animated?: boolean) => void;

observe: (el: HTMLDivElement) => void;
}

export const useLayout = ({
maxWidth,
maxHeight,
nodes = [],
edges = [],
fit,
pannable,
defaultPosition,
direction,
layoutOptions = {},
zoom,
setZoom,
onLayoutChange
}: LayoutProps) => {
export const useLayout = ({ maxWidth, maxHeight, nodes = [], edges = [], fit, pannable, defaultPosition, direction, layoutOptions = {}, zoom, setZoom, onLayoutChange }: LayoutProps) => {
const scrolled = useRef<boolean>(false);
const ref = useRef<HTMLDivElement>();
const { observe, width, height } = useDimensions<HTMLDivElement>();
Expand All @@ -122,6 +104,11 @@ export const useLayout = ({
const canvasHeight = pannable ? maxHeight : height;
const canvasWidth = pannable ? maxWidth : width;

const scrollToXY = (xy: [number, number], animated = false) => {
ref.current.scrollTo({ left: xy[0], top: xy[1], behavior: animated ? 'smooth' : 'auto' });
setScrollXY(xy);
};

useEffect(() => {
const promise = elkLayout(nodes, edges, {
'elk.direction': direction,
Expand Down Expand Up @@ -151,81 +138,103 @@ export const useLayout = ({
const centerX = (canvasWidth - layout.width * zoom) / 2;
const centerY = (canvasHeight - layout.height * zoom) / 2;
switch (position) {
case CanvasPosition.CENTER:
setXY([centerX, centerY]);
break;
case CanvasPosition.TOP:
setXY([centerX, 0]);
break;
case CanvasPosition.LEFT:
setXY([0, centerY]);
break;
case CanvasPosition.RIGHT:
setXY([canvasWidth - layout.width * zoom, centerY]);
break;
case CanvasPosition.BOTTOM:
setXY([centerX, canvasHeight - layout.height * zoom]);
break;
case CanvasPosition.CENTER:
setXY([centerX, centerY]);
break;
case CanvasPosition.TOP:
setXY([centerX, 0]);
break;
case CanvasPosition.LEFT:
setXY([0, centerY]);
break;
case CanvasPosition.RIGHT:
setXY([canvasWidth - layout.width * zoom, centerY]);
break;
case CanvasPosition.BOTTOM:
setXY([centerX, canvasHeight - layout.height * zoom]);
break;
}
}
},
[canvasWidth, canvasHeight, layout, zoom]
);

const positionScroll = useCallback(
(position: CanvasPosition) => {
(position: CanvasPosition, animated = false) => {
const scrollCenterX = (canvasWidth - width) / 2;
const scrollCenterY = (canvasHeight - height) / 2;
if (pannable) {
switch (position) {
case CanvasPosition.CENTER:
setScrollXY([scrollCenterX, scrollCenterY]);
break;
case CanvasPosition.TOP:
setScrollXY([scrollCenterX, 0]);
break;
case CanvasPosition.LEFT:
setScrollXY([0, scrollCenterY]);
break;
case CanvasPosition.RIGHT:
setScrollXY([canvasWidth - width, scrollCenterY]);
break;
case CanvasPosition.BOTTOM:
setScrollXY([scrollCenterX, canvasHeight - height]);
break;
case CanvasPosition.CENTER:
scrollToXY([scrollCenterX, scrollCenterY], animated);
break;
case CanvasPosition.TOP:
scrollToXY([scrollCenterX, 0], animated);
break;
case CanvasPosition.LEFT:
scrollToXY([0, scrollCenterY], animated);
break;
case CanvasPosition.RIGHT:
scrollToXY([canvasWidth - width, scrollCenterY], animated);
break;
case CanvasPosition.BOTTOM:
scrollToXY([scrollCenterX, canvasHeight - height], animated);
break;
}
}
},
[canvasWidth, canvasHeight, width, height, pannable]
);

const positionCanvas = useCallback(
(position: CanvasPosition) => {
(position: CanvasPosition, animated = false) => {
positionVector(position);
positionScroll(position);
positionScroll(position, animated);
},
[positionScroll, positionVector]
);

useEffect(() => {
ref?.current?.scrollTo(scrollXY[0], scrollXY[1]);
}, [scrollXY, ref]);

useEffect(() => {
if (scrolled.current && defaultPosition) {
positionVector(defaultPosition);
}
}, [positionVector, zoom, defaultPosition]);

const fitCanvas = useCallback(() => {
if (layout) {
const heightZoom = height / layout.height;
const widthZoom = width / layout.width;
const scale = Math.min(heightZoom, widthZoom, 1);
setZoom(scale - 1);
positionCanvas(CanvasPosition.CENTER);
}
}, [height, layout, width, setZoom, positionCanvas]);
const fitCanvas = useCallback(
(animated = false) => {
if (layout) {
const heightZoom = height / layout.height;
const widthZoom = width / layout.width;
const scale = Math.min(heightZoom, widthZoom, 1);
setZoom(scale - 1);
positionCanvas(CanvasPosition.CENTER, animated);
}
},
[height, layout, width, setZoom, positionCanvas]
);

/**
* This centers the chart on the canvas, zooms in to fit the specified nodes, and scrolls to center the nodes in the viewport
*/
const fitNodes = useCallback(
(nodeIds: string | string[], animated = true) => {
if (layout && layout.children) {
const nodes = Array.isArray(nodeIds) ? nodeIds.map((nodeId) => findNode(layout.children, nodeId)) : [findNode(layout.children, nodeIds)];

if (nodes) {
// center the chart
positionVector(CanvasPosition.CENTER);

const updatedZoom = calculateZoom({ nodes, viewportWidth: width, viewportHeight: height, maxViewportCoverage: 0.9, minViewportCoverage: 0.2 });
const scrollPosition = calculateScrollPosition({ nodes, viewportWidth: width, viewportHeight: height, canvasWidth, canvasHeight, chartWidth: layout.width, chartHeight: layout.height, zoom: updatedZoom });

setZoom(updatedZoom - 1);
scrollToXY(scrollPosition, animated);
}
}
},
[canvasHeight, canvasWidth, height, layout, positionVector, setZoom, width]
);

useLayoutEffect(() => {
const scroller = ref.current;
Expand All @@ -238,19 +247,7 @@ export const useLayout = ({

scrolled.current = true;
}
}, [
canvasWidth,
pannable,
canvasHeight,
layout,
height,
fit,
width,
defaultPosition,
positionCanvas,
fitCanvas,
ref
]);
}, [canvasWidth, pannable, canvasHeight, layout, height, fit, width, defaultPosition, positionCanvas, fitCanvas, ref]);

useLayoutEffect(() => {
function onResize() {
Expand Down Expand Up @@ -278,6 +275,7 @@ export const useLayout = ({
scrollXY,
positionCanvas,
fitCanvas,
setScrollXY
fitNodes,
setScrollXY: scrollToXY
} as LayoutResult;
};
82 changes: 81 additions & 1 deletion src/layout/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parsePadding } from './utils';
import { parsePadding, findNode, getChildCount, calculateZoom, calculateScrollPosition } from './utils';

test('should set all sides to input number, when a number is provided', () => {
const expectedPadding = {
Expand Down Expand Up @@ -29,3 +29,83 @@ test('should set each padding value individually, when an array with four number
};
expect(parsePadding([20, 50, 100, 150])).toEqual(expectedPadding);
});

test('should find a node by id', () => {
const layout = [
{
x: 0,
y: 0,
id: '1',
children: [{ x: 0, y: 0, id: '1', children: [] }]
},
{
x: 0,
y: 0,
id: '3',
children: [{ x: 0, y: 0, id: '4', children: [] }]
}
];
const node = findNode(layout, '4');

expect(node).toEqual({ x: 0, y: 0, id: '4', children: [] });
});

test('should get the number of children a node has', () => {
const node = {
x: 0,
y: 0,
id: '1',
children: [
{ x: 0, y: 0, id: '1', children: [] },
{ x: 0, y: 0, id: '2', children: [{ x: 0, y: 0, id: '3', children: [] }] }
]
};
const count = getChildCount(node);

expect(count).toEqual(3);
});

describe('calculateZoom', () => {
test('should calculate the zoom for a node', () => {
const node = { width: 100, height: 100, x: 0, y: 0, id: '1' };
const zoom = calculateZoom({ nodes: [node], viewportWidth: 1000, viewportHeight: 1000, minViewportCoverage: 0.2, maxViewportCoverage: 0.9 });

expect(zoom).toEqual(2);
});

test('should calculate the zoom for a node with many children', () => {
const node = { width: 100, height: 100, x: 0, y: 0, id: '0', children: [{ x: 0, y: 0, id: '1', children: [{ x: 0, y: 0, id: '2', children: [{ x: 0, y: 0, id: '3', children: [] }] }] }] };
const zoom = calculateZoom({ nodes: [node], viewportWidth: 1000, viewportHeight: 1000, minViewportCoverage: 0.2, maxViewportCoverage: 0.9 });

expect(zoom).toEqual(5);
});

test('should calculate the zoom for a group of nodes', () => {
const nodes = [
{ width: 100, height: 100, x: 0, y: 0, id: '0' },
{ width: 100, height: 100, x: 50, y: 50, id: '1' }
];
const zoom = calculateZoom({ nodes, viewportWidth: 1000, viewportHeight: 1000, minViewportCoverage: 0.2, maxViewportCoverage: 0.9 });

expect(zoom).toEqual(2);
});
});

describe('calculateScrollPosition', () => {
test('should calculate the scroll position for a node', () => {
const node = { width: 100, height: 100, x: 0, y: 0, id: '1' };
const scrollPosition = calculateScrollPosition({ nodes: [node], viewportWidth: 1000, viewportHeight: 1000, canvasWidth: 2000, canvasHeight: 2000, chartWidth: 500, chartHeight: 500, zoom: 1 });

expect(scrollPosition).toEqual([300, 300]);
});

test('should calculate the scroll position for a group of nodes', () => {
const nodes = [
{ width: 100, height: 100, x: 0, y: 0, id: '0' },
{ width: 100, height: 100, x: 50, y: 50, id: '1' }
];
const scrollPosition = calculateScrollPosition({ nodes, viewportWidth: 1000, viewportHeight: 1000, canvasWidth: 2000, canvasHeight: 2000, chartWidth: 500, chartHeight: 500, zoom: 1 });

expect(scrollPosition).toEqual([325, 325]);
});
});
Loading

0 comments on commit 981f99c

Please sign in to comment.