Merge pull request #4873 from k644606347/feat-scroll-canvas

Feat scroll canvas
This commit is contained in:
Yanyan Wang 2023-09-13 14:00:18 +08:00 committed by GitHub
commit 64c56373ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 517 additions and 0 deletions

View File

@ -14,3 +14,4 @@ export * from './rotate-canvas-3d';
export * from './track-canvas-3d';
export * from './zoom-canvas-3d';
export * from './shortcuts-call';
export * from './scroll-canvas';

View File

@ -0,0 +1,313 @@
import { isBoolean, isObject } from '@antv/util';
import { Behavior } from '../../types/behavior';
import { ID, IG6GraphEvent } from '../../types';
interface ScrollCanvasOptions {
/**
* The direction of dragging that is allowed. Options: 'x', 'y', 'both'. 'both' by default.
*/
direction?: string;
/**
* Whether enable optimize strategies, which will hide all the shapes excluding node keyShape while scrolling.
*/
enableOptimize?: boolean;
/**
* When the zoom ratio of the graph is smaller than ```optimizeZoom```, all shapes except for node keyShape will always be hidden.
* This option requires ```enableOptimize=true```;
*/
optimizeZoom?: number;
/**
* Switch to zooming while pressing the key and wheeling. Options: 'shift', 'ctrl', 'alt', 'control', 'meta', using an array of these options allows any of these keys to trigger zooming;
* Use ```'ctrl'``` by default;
*/
zoomKey?: string | string[];
/** Switch to zooming while pressing the key and wheeling. This option allows you to control the zoom ratio for each event.
* Use ```'0.05'``` by default;
*/
zoomRatio?: number;
/**
* The range of canvas to limit dragging, 0 by default, which means the graph cannot be dragged totally out of the view port range.
* If scalableRange is number or a string without 'px', means it is a ratio of the graph content.
* If scalableRange is a string with 'px', it is regarded as pixels.
* If scalableRange = 0, no constrains;
* If scalableRange > 0, the graph can be dragged out of the view port range
* If scalableRange < 0, the range is smaller than the view port.
* Refer to https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*IFfoS67_HssAAAAAAAAAAAAAARQnAQ
*/
scalableRange?: string | number;
/**
* Whether allow trigger this behavior when drag start on nodes / edges / combos.
*/
allowDragOnItem?:
| boolean
| {
node?: boolean;
edge?: boolean;
combo?: boolean;
};
}
const DEFAULT_OPTIONS: ScrollCanvasOptions = {
direction: 'both',
enableOptimize: false,
zoomKey: 'ctrl',
// scroll-canvas 可滚动的扩展范围,默认为 0即最多可以滚动一屏的位置
// 当设置的值大于 0 时,即滚动可以超过一屏
// 当设置的值小于 0 时,相当于缩小了可滚动范围
// 具体实例可参考https://gw.alipayobjects.com/mdn/rms_f8c6a0/afts/img/A*IFfoS67_HssAAAAAAAAAAAAAARQnAQ
scalableRange: 0,
allowDragOnItem: true,
zoomRatio: 0.05,
};
export class ScrollCanvas extends Behavior {
private hiddenEdgeIds: ID[];
private hiddenNodeIds: ID[];
declare options: ScrollCanvasOptions;
timeout?: number;
optimized = false;
constructor(options: Partial<ScrollCanvasOptions>) {
const finalOptions = Object.assign({}, DEFAULT_OPTIONS, options, {
zoomKey: initZoomKey(options.zoomKey),
});
super(finalOptions);
}
getEvents = () => {
return {
wheel: this.onWheel,
};
};
onWheel(ev: IG6GraphEvent & { deltaX?: number; deltaY?: number; }) {
if (!this.allowDrag(ev)) return;
const graph = this.graph;
const { zoomKey, zoomRatio, scalableRange, direction, enableOptimize } = this.options;
const zoomKeys = Array.isArray(zoomKey) ? [].concat(zoomKey) : [zoomKey];
if (zoomKeys.includes('control')) zoomKeys.push('ctrl');
const keyDown = zoomKeys.some((ele) => ev[`${ele}Key`]);
const nativeEvent = ev.nativeEvent as WheelEvent & { wheelDelta: number } | undefined;
if (keyDown) {
const canvas = graph.canvas;
const point = canvas.client2Viewport({ x: ev.client.x, y: ev.client.y});
let ratio = graph.getZoom();
if (nativeEvent && nativeEvent.wheelDelta > 0) {
ratio = ratio + ratio * zoomRatio;
} else {
ratio = ratio - ratio * zoomRatio;
}
graph.zoomTo(ratio, {
x: point.x,
y: point.y,
});
} else {
const diffX = ev.deltaX || ev.movement.x;
const diffY = ev.deltaY || ev.movement.y;
const { dx, dy } = this.formatDisplacement(diffX, diffY);
graph.translate({ dx: -dx, dy: -dy });
}
if (enableOptimize) {
const optimized = this.optimized;
// hiding
if (!optimized) {
this.hideShapes();
this.optimized = true;
}
// showing after 100ms
clearTimeout(this.timeout);
this.timeout = undefined;
const timeout = window.setTimeout(() => {
this.showShapes();
this.optimized = false;
}, 100);
this.timeout = timeout;
}
}
private formatDisplacement(dx: number, dy: number) {
const { graph } = this;
const { scalableRange, direction } = this.options;
const [width, height] = graph.getSize();
const graphBBox = graph.canvas.getRoot().getRenderBounds();
let rangeNum = Number(scalableRange);
let isPixel;
if (typeof scalableRange === 'string') {
if (scalableRange.includes('px')) {
isPixel = scalableRange.includes('px');
rangeNum = Number(scalableRange.replace('px', ''));
}
if (scalableRange.includes('%')) {
rangeNum = Number(scalableRange.replace('%', '')) / 100;
}
}
let expandWidth = rangeNum;
let expandHeight = rangeNum;
// If it is not a string with 'px', regard as ratio
if (!isPixel) {
expandWidth = width * rangeNum;
expandHeight = height * rangeNum;
}
const leftTopClient = graph.getViewportByCanvas({
x: graphBBox.min[0],
y: graphBBox.min[1],
});
const rightBottomClient = graph.getViewportByCanvas({
x: graphBBox.max[0],
y: graphBBox.max[1],
});
const minX = leftTopClient.x;
const minY = leftTopClient.y;
const maxX = rightBottomClient.x;
const maxY = rightBottomClient.y;
if (dx > 0) {
if (maxX < -expandWidth) {
dx = 0;
} else if (maxX - dx < -expandWidth) {
dx = maxX + expandWidth;
}
} else if (dx < 0) {
if (minX > width + expandWidth) {
dx = 0;
} else if (minX - dx > width + expandWidth) {
dx = minX - (width + expandWidth);
}
}
if (dy > 0) {
if (maxY < -expandHeight) {
dy = 0;
} else if (maxY - dy < -expandHeight) {
dy = maxY + expandHeight;
}
} else if (dy < 0) {
if (minY > height + expandHeight) {
dy = 0;
} else if (minY - dy > height + expandHeight) {
dy = minY - (height + expandHeight);
}
}
if (direction === 'x') {
dy = 0;
} else if (direction === 'y') {
dx = 0;
}
return { dx, dy };
}
private allowDrag(evt: IG6GraphEvent) {
const { itemType } = evt;
const { allowDragOnItem } = this.options;
const targetIsCanvas = itemType === 'canvas';
if (isBoolean(allowDragOnItem) && !allowDragOnItem && !targetIsCanvas)
return false;
if (isObject(allowDragOnItem)) {
const { node, edge, combo } = allowDragOnItem;
if (!node && itemType === 'node') return false;
if (!edge && itemType === 'edge') return false;
if (!combo && itemType === 'combo') return false;
}
return true;
}
private hideShapes() {
const { graph, options } = this;
const { optimizeZoom } = options;
if (this.options.enableOptimize) {
const currentZoom = graph.getZoom();
const newHiddenEdgeIds = graph
.getAllEdgesData()
.map((edge) => edge.id)
.filter((id) => graph.getItemVisible(id));
graph.hideItem(newHiddenEdgeIds, true);
if (currentZoom < optimizeZoom) {
this.hiddenEdgeIds.push(...newHiddenEdgeIds);
} else {
this.hiddenEdgeIds = newHiddenEdgeIds;
}
const newHiddenNodeIds = graph
.getAllNodesData()
.map((node) => node.id)
.filter((id) => graph.getItemVisible(id));
// draw node's keyShapes on transient, and then hidden the real nodes;
newHiddenNodeIds.forEach((id) => {
graph.drawTransient('node', id, {
onlyDrawKeyShape: true,
upsertAncestors: false,
});
});
graph.hideItem(newHiddenNodeIds, true);
if (currentZoom < optimizeZoom) {
this.hiddenNodeIds.push(...newHiddenNodeIds);
} else {
this.hiddenNodeIds = newHiddenNodeIds;
}
}
}
private showShapes() {
const { graph, hiddenEdgeIds, hiddenNodeIds } = this;
const currentZoom = graph.getZoom();
const { optimizeZoom } = this.options;
// hide the shapes when the zoom ratio is smaller than optimizeZoom
// hide the shapes when zoomming
if (currentZoom < optimizeZoom) {
return;
}
this.hiddenEdgeIds = this.hiddenNodeIds = [];
if (!this.options.enableOptimize) {
return;
}
if (hiddenEdgeIds) {
graph.showItem(hiddenEdgeIds, true);
}
if (hiddenNodeIds) {
hiddenNodeIds.forEach((id) => {
this.graph.drawTransient('node', id, { action: 'remove' });
});
graph.showItem(hiddenNodeIds, true);
}
}
}
const ALLOW_EVENTS = ['shift', 'ctrl', 'alt', 'control', 'meta'];
function initZoomKey(zoomKey?: string | string[]) {
const zoomKeys = zoomKey
? Array.isArray(zoomKey)
? zoomKey
: [zoomKey]
: [];
const validZoomKeys = zoomKeys.filter((zoomKey) => {
const keyIsValid = ALLOW_EVENTS.includes(zoomKey);
if (!keyIsValid)
console.warn(
`Invalid zoomKey: ${zoomKey}, please use a valid zoomKey: ${JSON.stringify(
ALLOW_EVENTS,
)}`,
);
return keyIsValid;
});
if (validZoomKeys.length === 0) {
validZoomKeys.push('ctrl');
}
return validZoomKeys;
}

View File

@ -62,6 +62,7 @@ const {
DragCombo,
ClickSelect,
ShortcutsCall,
ScrollCanvas,
} = Behaviors;
const {
History,
@ -124,6 +125,7 @@ const stdLib = {
'collapse-expand-combo': CollapseExpandCombo,
'collapse-expand-tree': CollapseExpandTree,
'click-select': ClickSelect,
'scroll-canvas': ScrollCanvas,
},
plugins: {
history: History,

View File

@ -0,0 +1,46 @@
import G6 from '../../../src/index';
import { TestCaseContext } from '../interface';
export default (context: TestCaseContext) => {
return new G6.Graph({
...context,
type: 'graph',
layout: {
type: 'grid',
},
node: {
labelShape: {
text: {
fields: ['id'],
formatter: (model) => model.id,
},
},
},
data: {
nodes: [
{ id: 'node1', data: {} },
{ id: 'node2', data: {} },
{ id: 'node3', data: {} },
{ id: 'node4', data: {} },
{ id: 'node5', data: {} },
],
edges: [
{ id: 'edge1', source: 'node1', target: 'node2', data: {} },
{ id: 'edge2', source: 'node1', target: 'node3', data: {} },
{ id: 'edge3', source: 'node1', target: 'node4', data: {} },
{ id: 'edge4', source: 'node2', target: 'node3', data: {} },
{ id: 'edge5', source: 'node3', target: 'node4', data: {} },
{ id: 'edge6', source: 'node4', target: 'node5', data: {} },
],
},
modes: {
default: [
{
type: 'drag-canvas',
enableOptimize: true,
// scalableRange: 0.5,
},
],
},
});
};

View File

@ -0,0 +1,49 @@
import G6 from '../../../src/index';
import { TestCaseContext } from '../interface';
export default (context: TestCaseContext) => {
return new G6.Graph({
...context,
type: 'graph',
layout: {
type: 'grid',
},
node: {
labelShape: {
text: {
fields: ['id'],
formatter: (model) => model.id,
},
},
},
data: {
nodes: [
{ id: 'node1', data: {} },
{ id: 'node2', data: {} },
{ id: 'node3', data: {} },
{ id: 'node4', data: {} },
{ id: 'node5', data: {} },
],
edges: [
{ id: 'edge1', source: 'node1', target: 'node2', data: {} },
{ id: 'edge2', source: 'node1', target: 'node3', data: {} },
{ id: 'edge3', source: 'node1', target: 'node4', data: {} },
{ id: 'edge4', source: 'node2', target: 'node3', data: {} },
{ id: 'edge5', source: 'node3', target: 'node4', data: {} },
{ id: 'edge6', source: 'node4', target: 'node5', data: {} },
],
},
modes: {
default: [
{
type: 'scroll-canvas',
enableOptimize: true,
zoomRatio: 0.2,
// scalableRange: 0.5,
// direction: 'y',
// optimizeZoom: 0.5,
},
],
},
});
};

View File

@ -4,6 +4,8 @@ import animations_node_build_in from './animations/node-build-in';
import arrow from './item/edge/arrow';
import behaviors_activateRelations from './behaviors/activate-relations';
import behaviors_shortcuts_call from './behaviors/shortcuts-call';
import behaviors_dragCanvas from './behaviors/drag-canvas';
import behaviors_scrollCanvas from './behaviors/scroll-canvas';
import behaviors_brush_select from './behaviors/brush-select';
import behaviors_click_select from './behaviors/click-select';
import behaviors_collapse_expand_tree from './behaviors/collapse-expand-tree';
@ -68,6 +70,8 @@ export {
animations_node_build_in,
arrow,
behaviors_activateRelations,
behaviors_dragCanvas,
behaviors_scrollCanvas,
behaviors_shortcuts_call,
behaviors_brush_select,
behaviors_click_select,

View File

@ -0,0 +1,99 @@
import { resetEntityCounter } from '@antv/g';
import scrollCanvas from '../demo/behaviors/scroll-canvas';
import './utils/useSnapshotMatchers';
import { createContext, triggerEvent } from './utils';
function sleep(time: number) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(null)
}, time)
})
}
describe('Scroll canvas behavior', () => {
beforeEach(() => {
/**
* SVG Snapshot testing will generate a unique id for each element.
* Reset to 0 to keep snapshot consistent.
*/
resetEntityCounter();
});
it('should be rendered correctly with Canvas2D', (done) => {
const dir = `${__dirname}/snapshots/canvas`;
const { backgroundCanvas, canvas, transientCanvas, container } =
createContext('canvas', 500, 500);
const graph = scrollCanvas({
container,
backgroundCanvas,
canvas,
transientCanvas,
width: 500,
height: 500,
});
graph.on('afterlayout', async () => {
await expect(canvas).toMatchCanvasSnapshot(
dir,
'behaviors-scroll-canvas',
);
graph.emit('wheel', {
deltaX: 50,
deltaY: 50,
});
await sleep(2000)
await expect(canvas).toMatchCanvasSnapshot(dir, 'behaviors-scroll-canvas-wheel');
graph.emit('wheel', {
client: { x: 50, y: 50 },
ctrlKey: true
});
await sleep(2000)
await expect(canvas).toMatchCanvasSnapshot(dir, 'behaviors-scroll-canvas-zoom');
graph.destroy();
done();
});
});
it('should be rendered correctly with SVG', (done) => {
const dir = `${__dirname}/snapshots/svg`;
const { backgroundCanvas, canvas, transientCanvas, container } =
createContext('svg', 500, 500);
const graph = scrollCanvas({
container,
backgroundCanvas,
canvas,
transientCanvas,
width: 500,
height: 500,
});
graph.on('afterlayout', async () => {
await expect(canvas).toMatchSVGSnapshot(dir, 'behaviors-scroll-canvas');
graph.emit('wheel', {
deltaX: 50,
deltaY: 50,
});
await sleep(2000)
await expect(canvas).toMatchSVGSnapshot(dir, 'behaviors-scroll-canvas-wheel');
graph.emit('wheel', {
client: { x: 50, y: 50 },
ctrlKey: true
});
await sleep(2000)
await expect(canvas).toMatchSVGSnapshot(dir, 'behaviors-scroll-canvas-zoom');
graph.destroy();
done();
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500" style="background: transparent; position: fixed; outline: none;" color-interpolation-filters="sRGB" tabindex="1"><defs/><g transform="matrix(1,0,0,1,0,0)"><g fill="none" transform="matrix(1,0,0,1,0,0)"><g fill="none" stroke="transparent" stroke-width="01"/><g fill="none" transform="matrix(1,0,0,1,0,0)"/><g fill="none" transform="matrix(1,0,0,1,0,0)"><g fill="none" transform="matrix(1,0,0,1,0,0)"><g transform="matrix(1,0,0,1,138.312805,92.208534)"><line fill="none" x1="0" y1="0" x2="223.374390581189" y2="148.91626038745935" stroke-width="1" stroke="rgba(153,173,209,1)"/><line fill="none" x1="0" y1="0" x2="0" y2="0" stroke-width="3" stroke="transparent"/></g></g><g fill="none" transform="matrix(1,0,0,1,0,0)"><g transform="matrix(1,0,0,1,141,83.333336)"><line fill="none" x1="0" y1="0" x2="218" y2="0" stroke-width="1" stroke="rgba(153,173,209,1)"/><line fill="none" x1="0" y1="0" x2="0" y2="0" stroke-width="3" stroke="transparent"/></g></g><g fill="none" transform="matrix(1,0,0,1,0,0)"><g transform="matrix(1,0,0,1,125,99.333336)"><line fill="none" x1="0" y1="0" x2="0" y2="134.66666666666669" stroke-width="1" stroke="rgba(153,173,209,1)"/><line fill="none" x1="0" y1="0" x2="0" y2="0" stroke-width="3" stroke="transparent"/></g></g><g fill="none" transform="matrix(1,0,0,1,0,0)"><g transform="matrix(1,0,0,1,375,99.333336)"><line fill="none" x1="0" y1="134.66666666666669" x2="0" y2="0" stroke-width="1" stroke="rgba(153,173,209,1)"/><line fill="none" x1="0" y1="0" x2="0" y2="0" stroke-width="3" stroke="transparent"/></g></g><g fill="none" transform="matrix(1,0,0,1,0,0)"><g transform="matrix(1,0,0,1,138.312805,92.208534)"><line fill="none" x1="223.374390581189" y1="0" x2="0" y2="148.91626038745935" stroke-width="1" stroke="rgba(153,173,209,1)"/><line fill="none" x1="0" y1="0" x2="0" y2="0" stroke-width="3" stroke="transparent"/></g></g><g fill="none" transform="matrix(1,0,0,1,0,0)"><g transform="matrix(1,0,0,1,125,266)"><line fill="none" x1="0" y1="0" x2="0" y2="134.66666666666663" stroke-width="1" stroke="rgba(153,173,209,1)"/><line fill="none" x1="0" y1="0" x2="0" y2="0" stroke-width="3" stroke="transparent"/></g></g></g><g fill="none" transform="matrix(1,0,0,1,0,0)"><g fill="none" transform="matrix(1,0,0,1,125,83.333336)"><g transform="matrix(1,0,0,1,0,0)"><circle fill="rgba(34,126,255,1)" transform="translate(-16,-16)" cx="16" cy="16" r="16" stroke-width="0"/></g><g transform="matrix(1,0,0,1,0,18)"><text fill="rgba(0,0,0,1)" dominant-baseline="central" paint-order="stroke" dx="0" dy="7.5px" text-anchor="middle" font-size="12" font-family="sans-serif" font-weight="normal" font-variant="normal" font-style="normal" stroke-width="0">node1</text></g></g><g fill="none" transform="matrix(1,0,0,1,375,250)"><g transform="matrix(1,0,0,1,0,0)"><circle fill="rgba(34,126,255,1)" transform="translate(-16,-16)" cx="16" cy="16" r="16" stroke-width="0"/></g><g transform="matrix(1,0,0,1,0,18)"><text fill="rgba(0,0,0,1)" dominant-baseline="central" paint-order="stroke" dx="0" dy="7.5px" text-anchor="middle" font-size="12" font-family="sans-serif" font-weight="normal" font-variant="normal" font-style="normal" stroke-width="0">node2</text></g></g><g fill="none" transform="matrix(1,0,0,1,375,83.333336)"><g transform="matrix(1,0,0,1,0,0)"><circle fill="rgba(34,126,255,1)" transform="translate(-16,-16)" cx="16" cy="16" r="16" stroke-width="0"/></g><g transform="matrix(1,0,0,1,0,18)"><text fill="rgba(0,0,0,1)" dominant-baseline="central" paint-order="stroke" dx="0" dy="7.5px" text-anchor="middle" font-size="12" font-family="sans-serif" font-weight="normal" font-variant="normal" font-style="normal" stroke-width="0">node3</text></g></g><g fill="none" transform="matrix(1,0,0,1,125,250)"><g transform="matrix(1,0,0,1,0,0)"><circle fill="rgba(34,126,255,1)" transform="translate(-16,-16)" cx="16" cy="16" r="16" stroke-width="0"/></g><g transform="matrix(1,0,0,1,0,18)"><text fill="rgba(0,0,0,1)" dominant-baseline="central" paint-order="stroke" dx="0" dy="7.5px" text-anchor="middle" font-size="12" font-family="sans-serif" font-weight="normal" font-variant="normal" font-style="normal" stroke-width="0">node4</text></g></g><g fill="none" transform="matrix(1,0,0,1,125,416.666656)"><g transform="matrix(1,0,0,1,0,0)"><circle fill="rgba(34,126,255,1)" transform="translate(-16,-16)" cx="16" cy="16" r="16" stroke-width="0"/></g><g transform="matrix(1,0,0,1,0,18)"><text fill="rgba(0,0,0,1)" dominant-baseline="central" paint-order="stroke" dx="0" dy="7.5px" text-anchor="middle" font-size="12" font-family="sans-serif" font-weight="normal" font-variant="normal" font-style="normal" stroke-width="0">node5</text></g></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 4.6 KiB