Merge pull request #4963 from antvis/v5-update-mapper

feat: update mapper
This commit is contained in:
pomelo 2023-09-19 05:09:43 -05:00 committed by GitHub
commit ed717d8507
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 475 additions and 95 deletions

View File

@ -177,6 +177,7 @@ export class ItemController {
this.graph.hooks.transientupdate.tap(this.onTransientUpdate.bind(this));
this.graph.hooks.viewportchange.tap(this.onViewportChange.bind(this));
this.graph.hooks.themechange.tap(this.onThemeChange.bind(this));
this.graph.hooks.mapperchange.tap(this.onMapperChange.bind(this));
this.graph.hooks.treecollapseexpand.tap(
this.onTreeCollapseExpand.bind(this),
);
@ -850,6 +851,16 @@ export class ItemController {
});
};
private onMapperChange = ({ type, mapper }) => {
if (!mapper) return;
this.itemMap.forEach((item) => {
const itemTye = item.getType();
if (itemTye !== type) return;
item.mapper = mapper;
item.update(item.model, undefined, false);
});
};
private onDestroy = () => {
Object.values(this.itemMap).forEach((item) => item.destroy());
// Fix OOM problem, since this map will hold all the refs of items.

View File

@ -55,6 +55,7 @@ import { FitViewRules, GraphTransformOptions } from '../types/view';
import { changeRenderer, createCanvas } from '../util/canvas';
import { formatPadding } from '../util/shape';
import { Plugin as PluginBase } from '../types/plugin';
import { ComboMapper, EdgeMapper, NodeMapper } from '../types/spec';
import {
DataController,
ExtensionController,
@ -357,7 +358,11 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
main: Canvas;
transient: Canvas;
};
}>({ name: 'init' }),
}>({ name: 'themechange' }),
mapperchange: new Hook<{
type: ITEM_TYPE;
mapper: NodeMapper | EdgeMapper | ComboMapper;
}>({ name: 'mapperchange' }),
treecollapseexpand: new Hook<{
ids: ID[];
animate: boolean;
@ -412,6 +417,32 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
});
}
/**
* Update the item display mapper for a specific item type.
* @param {ITEM_TYPE} type - The type of item (node, edge, or combo).
* @param {NodeMapper | EdgeMapper | ComboMapper} mapper - The mapper to be updated.
* */
public updateMapper(
type: ITEM_TYPE,
mapper: NodeMapper | EdgeMapper | ComboMapper,
) {
switch (type) {
case 'node':
this.specification.node = mapper as NodeMapper;
break;
case 'edge':
this.specification.edge = mapper as EdgeMapper;
break;
case 'combo':
this.specification.combo = mapper as ComboMapper;
break;
}
this.hooks.mapperchange.emit({
type,
mapper,
});
}
/**
* Get the copy of specs(configurations).
* @returns graph specs
@ -1054,19 +1085,21 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
graphCore.once('changed', (event) => {
if (!event.changes.length) return;
const changes = event.changes;
const timingParameters = {
type: itemType,
action: 'add',
models,
apiName: 'addData',
changes,
};
this.emit('beforeitemchange', timingParameters);
this.hooks.itemchange.emit({
type: itemType,
changes: graphCore.reduceChanges(event.changes),
graphCore,
theme: specification,
});
this.emit('afteritemchange', {
type: itemType,
action: 'add',
models,
apiName: 'addData',
changes,
});
this.emit('afteritemchange', timingParameters);
});
const modelArr = isArray(models) ? models : [models];
@ -1099,19 +1132,21 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
graphCore.once('changed', (event) => {
if (!event.changes.length) return;
const changes = event.changes;
const timingParameters = {
type: itemType,
action: 'remove',
ids: idArr,
apiName: 'removeData',
changes,
};
this.emit('beforeitemchange', timingParameters);
this.hooks.itemchange.emit({
type: itemType,
changes: event.changes,
graphCore,
theme: specification,
});
this.emit('afteritemchange', {
type: itemType,
action: 'remove',
ids: idArr,
apiName: 'removeData',
changes,
});
this.emit('afteritemchange', timingParameters);
});
this.hooks.datachange.emit({
data,
@ -1204,19 +1239,21 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
const { specification } = this.themeController;
graphCore.once('changed', (event) => {
const changes = this.extendChanges(clone(event.changes));
const timingParameters = {
type: itemType,
action: 'update',
models,
apiName: 'updateData',
changes,
};
this.emit('beforeitemchange', timingParameters);
this.hooks.itemchange.emit({
type: itemType,
changes: event.changes,
graphCore,
theme: specification,
});
this.emit('afteritemchange', {
type: itemType,
action: 'update',
models,
apiName: 'updateData',
changes,
});
this.emit('afteritemchange', timingParameters);
});
this.hooks.datachange.emit({
@ -1325,6 +1362,15 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
const changes = event.changes.filter(
(change) => !isEqual(change.newValue, change.oldValue),
);
const timingParameters = {
type,
action: 'updatePosition',
upsertAncestors,
models,
apiName: 'updatePosition',
changes,
};
this.emit('beforeitemchange', timingParameters);
this.hooks.itemchange.emit({
type,
changes: event.changes,
@ -1335,14 +1381,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
animate: !disableAnimate,
callback,
});
this.emit('afteritemchange', {
type,
action: 'updatePosition',
upsertAncestors,
models,
apiName: 'updatePosition',
changes,
});
this.emit('afteritemchange', timingParameters);
});
this.hooks.datachange.emit({
@ -1617,19 +1656,21 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
graphCore.once('changed', (event) => {
if (!event.changes.length) return;
const changes = event.changes;
const timingParameters = {
type: 'combo',
action: 'add',
models: [model],
apiName: 'addCombo',
changes,
};
this.emit('beforeitemchange', timingParameters);
this.hooks.itemchange.emit({
type: 'combo',
changes: graphCore.reduceChanges(event.changes),
graphCore,
theme: specification,
});
this.emit('afteritemchange', {
type: 'combo',
action: 'add',
models: [model],
apiName: 'addCombo',
changes,
});
this.emit('afteritemchange', timingParameters);
});
const data = {
@ -1715,6 +1756,17 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
graphCore.once('changed', (event) => {
if (!event.changes.length) return;
const changes = this.extendChanges(clone(event.changes));
const timingParameters = {
type: 'combo',
ids: idArr,
dx,
dy,
action: 'updatePosition',
upsertAncestors,
apiName: 'moveCombo',
changes,
};
this.emit('beforeitemchange', timingParameters);
this.hooks.itemchange.emit({
type: 'combo',
changes: event.changes,
@ -1724,16 +1776,7 @@ export default class Graph<B extends BehaviorRegistry, T extends ThemeRegistry>
action: 'updatePosition',
callback,
});
this.emit('afteritemchange', {
type: 'combo',
ids: idArr,
dx,
dy,
action: 'updatePosition',
upsertAncestors,
apiName: 'moveCombo',
changes,
});
this.emit('afteritemchange', timingParameters);
});
this.hooks.datachange.emit({

View File

@ -54,6 +54,8 @@ export class Minimap extends Base {
private dx: number;
/** Distance from left of minimap graph to the left of minimap container. */
private dy: number;
/** Cache the visibility while items' visibility changed. And apply them onto the minimap with debounce. */
private visibleCache: { [id: string]: boolean } = {};
constructor(options?: MiniMapConfig) {
super(options);
@ -80,11 +82,11 @@ export class Minimap extends Base {
public getEvents() {
return {
afterupdateitem: this.handleUpdateCanvas,
afteritemstatechange: this.handleUpdateCanvas,
afterlayout: this.handleUpdateCanvas,
viewportchange: this.handleUpdateCanvas,
afteritemchange: this.handleUpdateCanvas,
afteritemvisibilitychange: this.handleVisibilityChange,
};
}
@ -115,12 +117,12 @@ export class Minimap extends Base {
style='position:absolute;
left:0;
top:0;
box-sizing:border-box;
border: 2px solid #1980ff;
box-sizing:border-box;
background: rgba(0, 0, 255, 0.1);
cursor:move'
draggable=${isSafari || isFireFox ? false : true}
</div>`);
/>`);
// Last mouse x position
let x = 0;
@ -128,11 +130,25 @@ export class Minimap extends Base {
let y = 0;
// Whether in dragging status
let dragging = false;
let resizing = false;
const dragstartevent = isSafari || isFireFox ? 'mousedown' : 'dragstart';
this.container.addEventListener('mousemove', (e) => {
const moveAtBorder = getMoveAtBorder(viewport, e);
if (moveAtBorder) {
this.container.style.cursor = cursorMap[moveAtBorder];
viewport.style.cursor = cursorMap[moveAtBorder];
} else {
this.container.style.cursor = 'unset';
viewport.style.cursor = 'move';
}
});
viewport.addEventListener(
dragstartevent,
((e: IG6GraphEvent) => {
resizing = getMoveAtBorder(viewport, e);
if (resizing) return;
if ((e as any).dataTransfer) {
const img = new Image();
img.src =
@ -159,6 +175,30 @@ export class Minimap extends Base {
);
const dragListener = (e: IG6GraphEvent) => {
const { style } = viewport;
const left = parseInt(style.left, 10);
const top = parseInt(style.top, 10);
const width = parseInt(style.width, 10);
const height = parseInt(style.height, 10);
if (resizing) {
const { clientX, clientY } = e;
const afterResize = { left, top, width, height };
if (resizing.includes('left')) {
afterResize.left = `${clientX}px`;
afterResize.width = `${left + width - clientX}px`;
} else if (resizing.includes('right')) {
afterResize.width = `${clientX - left}px`;
}
if (resizing.includes('top')) {
afterResize.top = `${clientY}`;
afterResize.height = `${top + height - clientY}`;
} else if (resizing.includes('bottom')) {
afterResize.height = `${clientY - top}`;
}
modifyCSS(viewport, afterResize);
return;
}
if (!dragging || isNil(e.clientX) || isNil(e.clientY)) {
return;
}
@ -168,12 +208,6 @@ export class Minimap extends Base {
let dx = x - e.clientX;
let dy = y - e.clientY;
const { style } = viewport;
const left = parseInt(style.left, 10);
const top = parseInt(style.top, 10);
const width = parseInt(style.width, 10);
const height = parseInt(style.height, 10);
// If the viewport is already on the left or right, stop moving x.
if (left - dx < 0 || left - dx + width >= size[0]) {
dx = 0;
@ -201,6 +235,7 @@ export class Minimap extends Base {
const dragendListener = () => {
dragging = false;
resizing = false;
this.options.refresh = true;
};
const dragendevent = isSafari || isFireFox ? 'mouseup' : 'dragend';
@ -549,6 +584,34 @@ export class Minimap extends Base {
false,
);
private handleVisibilityChange = (params) => {
const { ids, value } = params;
ids.forEach((id) => {
this.visibleCache[id] = value;
});
this.debounceCloneVisibility();
};
private debounceCloneVisibility = debounce(
() => {
const nodeGroup = this.canvas.getRoot().getElementById('node-group');
const edgeGroup = this.canvas.getRoot().getElementById('edge-group');
nodeGroup.childNodes.concat(edgeGroup.childNodes).forEach((child) => {
const id = child.getAttribute?.('data-item-id');
if (this.visibleCache.hasOwnProperty(id)) {
if (this.visibleCache[id]) {
child.childNodes.forEach((shape) => shape.show());
} else if (this.visibleCache[id] === false) {
child.childNodes.forEach((shape) => shape.hide());
}
}
});
this.visibleCache = {};
},
50,
false,
);
public init(graph: IGraph) {
super.init(graph);
const promise = this.initContainer();
@ -696,3 +759,45 @@ export class Minimap extends Base {
if (container?.parentNode) container.parentNode.removeChild(container);
}
}
const getMoveAtBorder = (dom, evt) => {
const bounds = dom.getBoundingClientRect();
const { clientX, clientY } = evt;
console.log('mosemove', bounds.x, clientX);
if (Math.abs(clientX - bounds.x) < 4 && Math.abs(clientY - bounds.y) < 4) {
return 'left-top';
} else if (
Math.abs(clientX - bounds.x) < 4 &&
Math.abs(clientY - bounds.y - bounds.height) < 4
) {
return 'left-bottom';
} else if (
Math.abs(clientX - bounds.x - bounds.width) < 4 &&
Math.abs(clientY - bounds.y) < 4
) {
return 'right-top';
} else if (
Math.abs(clientX - bounds.x - bounds.width) < 4 &&
Math.abs(clientY - bounds.y - bounds.height) < 4
) {
return 'right-bottom';
} else if (Math.abs(clientX - bounds.x) < 4) {
return 'left';
} else if (Math.abs(clientY - bounds.y) < 4) {
return 'top';
} else if (Math.abs(clientY - bounds.y - bounds.height) < 4) {
return 'bottom';
}
return false;
};
const cursorMap = {
'left-top': 'nwse-resize',
'right-bottom': 'nwse-resize',
'right-top': 'nesw-resize',
'left-bottom': 'nesw-resize',
left: 'ew-resize',
right: 'ew-resize',
top: 'ns-resize',
bottom: 'ns-resize',
};

View File

@ -14,7 +14,7 @@ import { ITEM_TYPE, SHAPE_TYPE } from './item';
import { LayoutOptions } from './layout';
import { NodeModel, NodeUserModel } from './node';
import { RendererName } from './render';
import { Specification } from './spec';
import { ComboMapper, EdgeMapper, NodeMapper, Specification } from './spec';
import { ThemeOptionsOf, ThemeRegistry } from './theme';
import { FitViewRules, GraphTransformOptions } from './view';
@ -45,6 +45,12 @@ export interface IGraph<
* Update the theme specs (configurations).
*/
updateTheme: (theme: ThemeOptionsOf<T>) => void;
/**
* Update the item display mapper for a specific item type.
* @param {ITEM_TYPE} type - The type of item (node, edge, or combo).
* @param {NodeMapper | EdgeMapper | ComboMapper} mapper - The mapper to be updated.
* */
updateMapper(type: ITEM_TYPE, mapper: NodeMapper | EdgeMapper | ComboMapper);
/**
* Get the copy of specs(configurations).
* @returns graph specs

View File

@ -11,6 +11,7 @@ import { ThemeSpecification } from './theme';
import { GraphTransformOptions } from './view';
import { ComboModel } from './combo';
import { Plugin as PluginBase } from './plugin';
import { ComboMapper, EdgeMapper, NodeMapper } from './spec';
export interface IHook<T> {
name: string;
@ -124,6 +125,10 @@ export interface Hooks {
transient: Canvas;
};
}>;
mapperchange: IHook<{
type: ITEM_TYPE;
mapper: NodeMapper | EdgeMapper | ComboMapper;
}>;
treecollapseexpand: IHook<{
ids: ID[];
action: 'collapse' | 'expand';

View File

@ -29,6 +29,12 @@ import { RendererName } from './render';
import { StackCfg } from './history';
import { Plugin } from './plugin';
export type NodeMapper = ((data: NodeModel) => NodeDisplayModel) | NodeEncode;
export type EdgeMapper = ((data: EdgeModel) => EdgeDisplayModel) | EdgeEncode;
export type ComboMapper =
| ((data: ComboModel) => ComboDisplayModel)
| ComboEncode;
export interface Specification<
B extends BehaviorRegistry,
T extends ThemeRegistry,
@ -92,9 +98,9 @@ export interface Specification<
| TransformerFn[];
/** item */
node?: ((data: NodeModel) => NodeDisplayModel) | NodeEncode;
edge?: ((data: EdgeModel) => EdgeDisplayModel) | EdgeEncode;
combo?: ((data: ComboModel) => ComboDisplayModel) | ComboEncode;
node?: NodeMapper;
edge?: EdgeMapper;
combo?: ComboMapper;
/** item state styles */
nodeState?: {

View File

@ -61,11 +61,15 @@ import layouts_combocombined from './layouts/combo-combined';
import hull from './plugins/hull';
import legend from './plugins/legend';
import snapline from './plugins/snapline';
import mapper from './visual/mapper';
import minimap from './plugins/minimap';
export { default as timebar_time } from './plugins/timebar-time';
export { default as timebar_chart } from './plugins/timebar-chart';
export {
minimap,
mapper,
anchor,
animations_node_build_in,
arrow,

View File

@ -0,0 +1,58 @@
import { Graph, Extensions, extend } from '../../../src/index';
import { TestCaseContext } from '../interface';
export default (context: TestCaseContext) => {
const ExtGraph = extend(Graph, {
plugins: {
minimap: Extensions.Minimap,
},
});
return new ExtGraph({
...context,
data: {
nodes: [
{ id: 'node1', data: { x: 100, y: 200, nodeType: 'a' } },
{ id: 'node2', data: { x: 200, y: 250, nodeType: 'b' } },
{ id: 'node3', data: { x: 200, y: 350, nodeType: 'b' } },
{ id: 'node4', data: { x: 300, y: 250, nodeType: 'c' } },
],
edges: [
{
id: 'edge1',
source: 'node1',
target: 'node2',
data: { edgeType: 'e1' },
},
{
id: 'edge2',
source: 'node2',
target: 'node3',
data: { edgeType: 'e2' },
},
{
id: 'edge3',
source: 'node3',
target: 'node4',
data: { edgeType: 'e3' },
},
{
id: 'edge4',
source: 'node1',
target: 'node4',
data: { edgeType: 'e3' },
},
],
},
plugins: ['minimap'],
modes: {
default: [
{
type: 'drag-node',
},
'zoom-canvas',
'drag-canvas',
],
},
});
};

View File

@ -0,0 +1,103 @@
import { Graph } from '../../../src/index';
import { TestCaseContext } from '../interface';
export default (context: TestCaseContext) => {
const graph = new Graph({
...context,
data: {
nodes: [
{ id: '1', data: {} },
{ id: '2', data: {} },
{ id: '3', data: {} },
],
edges: [
{ id: 'edge1', source: '1', target: '2', data: {} },
{ id: 'edge2', source: '1', target: '3', data: {} },
{ id: 'edge4', source: '2', target: '3', data: {} },
],
},
layout: {
type: 'grid',
},
node: {
labelShape: {
text: {
fields: ['id'],
formatter: (model) => model.id,
},
},
},
});
const { container } = context;
const jsonNodeMapperBtn = document.createElement('button');
container.parentNode?.appendChild(jsonNodeMapperBtn);
jsonNodeMapperBtn.innerHTML = '更改节点JSON映射';
jsonNodeMapperBtn.id = 'change-node-json-mapper';
jsonNodeMapperBtn.style.zIndex = 10;
jsonNodeMapperBtn.addEventListener('click', (e) => {
graph.updateMapper('node', {
labelShape: {
text: 'xxx',
fontWeight: 800,
fill: '#f00',
},
});
});
const funcNodeMapperBtn = document.createElement('button');
container.parentNode?.appendChild(funcNodeMapperBtn);
funcNodeMapperBtn.innerHTML = '更改节点函数映射';
funcNodeMapperBtn.id = 'change-node-func-mapper';
funcNodeMapperBtn.style.zIndex = 10;
funcNodeMapperBtn.addEventListener('click', (e) => {
graph.updateMapper('node', (model) => ({
id: model.id,
data: {
labelShape: {
text: `new-${model.id}`,
fontWeight: 800,
fill: '#0f0',
},
},
}));
});
const jsonEdgeMapperBtn = document.createElement('button');
container.parentNode?.appendChild(jsonEdgeMapperBtn);
jsonEdgeMapperBtn.innerHTML = '更改边JSON映射';
jsonEdgeMapperBtn.id = 'change-edge-json-mapper';
jsonEdgeMapperBtn.style.zIndex = 10;
jsonEdgeMapperBtn.addEventListener('click', (e) => {
graph.updateMapper('edge', {
labelShape: {
text: {
fields: ['id'],
formatter: (model) => model.id,
},
fontWeight: 800,
fill: '#f00',
},
});
});
const funcEdgeMapperBtn = document.createElement('button');
container.parentNode?.appendChild(funcEdgeMapperBtn);
funcEdgeMapperBtn.innerHTML = '更改边函数映射';
funcEdgeMapperBtn.id = 'change-edge-func-mapper';
funcEdgeMapperBtn.style.zIndex = 10;
funcEdgeMapperBtn.addEventListener('click', (e) => {
graph.updateMapper('edge', (model) => ({
id: model.id,
data: {
labelShape: {
text: `new-${model.id}`,
fontWeight: 800,
fill: '#0f0',
},
},
}));
});
return graph;
};

View File

@ -0,0 +1,72 @@
import { resetEntityCounter } from '@antv/g';
import mapper from '../demo/visual/mapper';
import { createContext } from './utils';
import './utils/useSnapshotMatchers';
describe('updateMapper API', () => {
beforeEach(() => {
/**
* SVG Snapshot testing will generate a unique id for each element.
* Reset to 0 to keep snapshot consistent.
*/
resetEntityCounter();
});
it('node and edge mapper update', (done) => {
const dir = `${__dirname}/snapshots/canvas`;
const { backgroundCanvas, canvas, transientCanvas, container } =
createContext('canvas', 500, 500);
const graph = mapper({
container,
backgroundCanvas,
canvas,
transientCanvas,
width: 500,
height: 500,
});
graph.on('afterlayout', async () => {
await expect(canvas).toMatchCanvasSnapshot(dir, 'api-update-mapper-init');
const $updateNodeJson = document.getElementById(
'change-node-json-mapper',
);
$updateNodeJson?.click();
await expect(canvas).toMatchCanvasSnapshot(
dir,
'api-update-mapper-node-json',
);
const $updateNodeFunc = document.getElementById(
'change-node-func-mapper',
);
$updateNodeFunc?.click();
await expect(canvas).toMatchCanvasSnapshot(
dir,
'api-update-mapper-node-func',
);
const $updateEdgeJson = document.getElementById(
'change-edge-json-mapper',
);
$updateEdgeJson?.click();
await expect(canvas).toMatchCanvasSnapshot(
dir,
'api-update-mapper-edge-json',
);
const $updateEdgeFunc = document.getElementById(
'change-edge-func-mapper',
);
$updateEdgeFunc?.click();
await expect(canvas).toMatchCanvasSnapshot(
dir,
'api-update-mapper-edge-func',
);
graph.destroy();
done();
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -12,19 +12,6 @@ const renderers = {
};
const getDefaultNodeAnimates = (delay) => ({
buildIn: [
{
fields: ['opacity'],
duration: 1000,
delay: delay === undefined ? 1000 + Math.random() * 1000 : delay,
},
],
buildOut: [
{
fields: ['opacity'],
duration: 200,
},
],
update: [
{
fields: ['lineWidth', 'fill', 'r'],
@ -40,33 +27,12 @@ const getDefaultNodeAnimates = (delay) => ({
},
],
hide: [
{
fields: ['size'],
duration: 200,
},
{
fields: ['opacity'],
duration: 200,
shapeId: 'keyShape',
},
{
fields: ['opacity'],
duration: 200,
shapeId: 'labelShape',
},
],
show: [
{
fields: ['size'],
duration: 200,
},
{
fields: ['opacity'],
duration: 200,
shapeId: 'keyShape',
order: 0,
},
],
});
const getDefaultEdgeAnimates = (delay) => ({
@ -244,6 +210,7 @@ const create3DGraph = async (data) => {
},
});
};
const create2DGraph = (renderer, data) => {
return new ExtGraph({
container,
@ -260,7 +227,7 @@ const create2DGraph = (renderer, data) => {
],
modes: {
default: [
{ type: 'zoom-canvas', key: '123', triggerOnItems: true },
{ type: 'zoom-canvas', key: '123', triggerOnItems: true, enableOptimize: true },
'drag-node',
'drag-canvas',
'brush-select',