diff --git a/__tests__/event.spec.ts b/__tests__/event.spec.ts new file mode 100644 index 0000000..4f3c816 --- /dev/null +++ b/__tests__/event.spec.ts @@ -0,0 +1,138 @@ +import { Graph } from '../src'; + +describe('event', () => { + it('add', (done) => { + const graph = new Graph(); + + let counter = 0; + + const expects = [ + [ + { type: 'NodeAdded', value: { id: 'A', data: {} } }, + { type: 'NodeAdded', value: { id: 'B', data: {} } }, + { type: 'NodeAdded', value: { id: 'C', data: {} } }, + { + type: 'EdgeAdded', + value: { id: 'A->B', source: 'A', target: 'B', data: {} }, + }, + ], + [ + { + type: 'NodeDataUpdated', + id: 'A', + oldValue: {}, + newValue: { value: 1 }, + }, + ], + [ + { + type: 'EdgeDataUpdated', + id: 'A->B', + oldValue: {}, + newValue: { foo: 'bar' }, + }, + ], + [ + { + type: 'EdgeUpdated', + id: 'A->B', + propertyName: 'source', + oldValue: 'A', + newValue: 'C', + }, + ], + [ + { + type: 'EdgeRemoved', + value: { id: 'A->B', source: 'C', target: 'B', data: { foo: 'bar' } }, + }, + ], + ]; + + graph.on('changed', (event: any) => { + expect(event.changes).toEqual(expects[counter++]); + + if (counter === expects.length) { + done(); + } + }); + + graph.batch(() => { + graph.addNodes([ + { id: 'A', data: {} }, + { id: 'B', data: {} }, + { id: 'C', data: {} }, + ]); + + graph.addEdges([{ id: 'A->B', source: 'A', target: 'B', data: {} }]); + }); + + graph.updateNodeData('A', { value: 1 }); + + graph.updateNodeData('A', { value: 1 }); + + graph.updateEdgeData('A->B', { foo: 'bar' }); + + graph.updateEdgeData('A->B', { foo: 'bar' }); + + graph.updateEdgeSource('A->B', 'C'); + + graph.updateEdgeSource('A->B', 'C'); + + graph.removeEdges(['A->B']); + }); + + it('deep change', (done) => { + const graph = new Graph({ + nodes: [ + { + id: 'A', + data: { id: 'A', data: { value: 1 }, style: { fill: 'red' } }, + }, + { + id: 'B', + data: { id: 'B', data: { value: 2 }, style: { fill: 'red' } }, + }, + { + id: 'C', + data: { id: 'C', data: { value: 3 }, style: { fill: 'red' } }, + }, + ], + edges: [ + { id: 'A->B', source: 'A', target: 'B', data: {} }, + { id: 'B->C', source: 'B', target: 'C', data: {} }, + ], + }); + + let counter = 0; + const expects = [ + [ + { + type: 'NodeDataUpdated', + id: 'A', + propertyName: 'data', + oldValue: { value: 1 }, + newValue: { value: 10 }, + }, + { + type: 'NodeDataUpdated', + id: 'A', + propertyName: 'style', + oldValue: { fill: 'red' }, + newValue: { fill: 'pink' }, + }, + ], + ]; + + graph.on('changed', (event: any) => { + expect(event.changes).toEqual(expects[counter++]); + + if (counter === expects.length) { + done(); + } + }); + + graph.mergeNodeData('A', { data: { value: 10 }, style: { fill: 'pink' } }); + graph.mergeNodeData('A', { data: { value: 10 }, style: { fill: 'pink' } }); + }); +}); diff --git a/__tests__/utils/isShallowEqual.spec.ts b/__tests__/utils/isShallowEqual.spec.ts new file mode 100644 index 0000000..c0bb556 --- /dev/null +++ b/__tests__/utils/isShallowEqual.spec.ts @@ -0,0 +1,106 @@ +import { + isShallowEqual, + depthOf, + isEqual, +} from '../../src/utils/isShallowEqual'; + +describe('isShallowEqual', () => { + it('depth 0', () => { + expect(depthOf(0)).toBe(0); + expect(depthOf(null)).toBe(0); + expect(depthOf(undefined)).toBe(0); + + expect(isShallowEqual(0, 0)).toBe(true); + expect(isShallowEqual(0, 1)).toBe(false); + expect(isShallowEqual(0, null)).toBe(false); + expect(isShallowEqual(0, undefined)).toBe(false); + expect(isShallowEqual(0, {})).toBe(false); + expect(isShallowEqual({}, {})).toBe(true); + expect(isShallowEqual(0, [])).toBe(false); + expect(isShallowEqual([], [])).toBe(true); + expect(isShallowEqual(0, '0')).toBe(false); + expect(isShallowEqual('0', '0')).toBe(true); + expect(isShallowEqual(0x0, 0x0)).toBe(true); + expect(isShallowEqual(Symbol(), Symbol())).toBe(false); + expect(isShallowEqual(Symbol.for('0'), Symbol.for('0'))).toBe(true); + expect(isShallowEqual(true, true)).toBe(true); + expect(isShallowEqual(false, false)).toBe(true); + expect(isShallowEqual(true, false)).toBe(false); + }); + + it('depth 1', () => { + expect(depthOf({ a: 0 })).toBe(1); + expect(depthOf([0])).toBe(1); + + expect(isShallowEqual({ a: 0 }, { a: 0 })).toBe(true); + expect(isShallowEqual({ a: 0 }, { a: 1 })).toBe(false); + expect(isShallowEqual({ a: 0 }, { a: null })).toBe(false); + expect(isShallowEqual([0], [0])).toBe(true); + expect(isShallowEqual([0], [1])).toBe(false); + + expect(isShallowEqual({ a: 0 }, { a: 0, b: 0 })).toBe(false); + expect(isShallowEqual({ a: 0, b: 0 }, { a: 0 })).toBe(false); + + expect(isShallowEqual({ a: [0] }, { a: [0] })).toBe(true); + expect(isShallowEqual({ a: [0] }, { a: [1] })).toBe(false); + + expect(isShallowEqual([{ a: 0 }], [{ a: 0 }])).toBe(true); + expect(isShallowEqual([{ a: 0 }], [{ a: 1 }])).toBe(false); + }); + + it('depth 2', () => { + expect(depthOf({ a: { b: 0 } })).toBe(2); + expect(depthOf({ a: [0] })).toBe(2); + expect(depthOf([{ a: 0 }])).toBe(2); + expect(depthOf([[0]])).toBe(2); + + expect(isShallowEqual({ a: { b: 0 } }, { a: { b: 0 } })).toBe(true); + expect(isShallowEqual({ a: { b: 0 } }, { a: { b: 1 } })).toBe(false); + expect(isShallowEqual({ a: { b: 0 } }, { a: { b: null } })).toBe(false); + expect(isShallowEqual({ a: { b: 0 } }, { a: { b: 0, c: 0 } })).toBe(false); + expect(isShallowEqual({ a: { b: 0, c: 0 } }, { a: { b: 0 } })).toBe(false); + expect(isShallowEqual({ a: { b: [0] } }, { a: { b: [0] } })).toBe(true); + expect(isShallowEqual({ a: { b: [0] } }, { a: { b: [1] } })).toBe(false); + }); + + it('depth 3', () => { + expect(depthOf({ a: { b: { c: 0 } } })).toBe(3); + expect(depthOf({ a: { b: [0] } })).toBe(3); + expect(depthOf({ a: [[0]] })).toBe(3); + expect(depthOf([[[0]]])).toBe(3); + + expect(isShallowEqual({ a: { b: { c: 0 } } }, { a: { b: { c: 0 } } })).toBe( + true, + ); + expect(isShallowEqual({ a: { b: { c: 0 } } }, { a: { b: { c: 1 } } })).toBe( + false, + ); + expect( + isShallowEqual({ a: { b: { c: 0 } } }, { a: { b: { c: null } } }), + ).toBe(false); + expect( + isShallowEqual({ a: { b: { c: 0 } } }, { a: { b: { c: 0, d: 0 } } }), + ).toBe(false); + expect( + isShallowEqual({ a: { b: { c: 0, d: 0 } } }, { a: { b: { c: 0 } } }), + ).toBe(false); + expect( + isShallowEqual({ a: { b: { c: [0] } } }, { a: { b: { c: [0] } } }), + ).toBe(true); + expect( + isShallowEqual({ a: { b: { c: [0] } } }, { a: { b: { c: [1] } } }), + ).toBe(true); + + expect(isEqual({ a: { b: { c: [0] } } }, { a: { b: { c: [1] } } })).toBe( + false, + ); + }); + + it('depth 4', () => { + expect(depthOf({ a: { b: { c: [0] } } })).toBe(Infinity); + + expect( + isShallowEqual({ a: { b: { c: [0] } } }, { a: { b: { c: [1] } } }, 1, 4), + ).toBe(false); + }); +}); diff --git a/package.json b/package.json index 636db7c..793a163 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@antv/graphlib", - "version": "2.0.2", + "version": "2.0.3", "main": "lib/index.js", "module": "esm/index.js", "types": "lib/index.d.ts", diff --git a/src/graph.ts b/src/graph.ts index d05b691..4308103 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -15,6 +15,7 @@ import { GraphViewOptions, } from './types'; import { doBFS, doDFS } from './utils/traverse'; +import { isEqual } from './utils/isShallowEqual'; export class Graph< N extends PlainObject, @@ -106,6 +107,7 @@ export class Graph< */ private commit(): void { const changes = this.changes; + if (changes.length === 0) return; this.changes = []; const event = { graph: this, @@ -432,6 +434,7 @@ export class Graph< this.batch(() => { const oldValue = node.data[propertyName]; const newValue = value; + if (isEqual(oldValue, newValue)) return; node.data[propertyName] = newValue; this.changes.push({ type: 'NodeDataUpdated', @@ -511,6 +514,7 @@ export class Graph< this.batch(() => { const oldValue = node.data; const newValue = data; + if (isEqual(oldValue, newValue)) return; node.data = data; this.changes.push({ type: 'NodeDataUpdated', @@ -651,6 +655,7 @@ export class Graph< this.checkNodeExistence(source); const oldSource = edge.source; const newSource = source; + if (oldSource === newSource) return; this.outEdgesMap.get(oldSource)!.delete(edge); this.bothEdgesMap.get(oldSource)!.delete(edge); this.outEdgesMap.get(newSource)!.add(edge); @@ -676,6 +681,7 @@ export class Graph< this.checkNodeExistence(target); const oldTarget = edge.target; const newTarget = target; + if (oldTarget === newTarget) return; this.inEdgesMap.get(oldTarget)!.delete(edge); this.bothEdgesMap.get(oldTarget)!.delete(edge); this.inEdgesMap.get(newTarget)!.add(edge); @@ -701,6 +707,7 @@ export class Graph< this.batch(() => { const oldValue = edge.data[propertyName]; const newValue = value; + if (isEqual(oldValue, newValue)) return; edge.data[propertyName] = newValue; this.changes.push({ type: 'EdgeDataUpdated', @@ -767,6 +774,7 @@ export class Graph< this.batch(() => { const oldValue = edge.data; const newValue = data; + if (isEqual(oldValue, newValue)) return; edge.data = data; this.changes.push({ type: 'EdgeDataUpdated', diff --git a/src/types.ts b/src/types.ts index 71df8b6..b3ab8ce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,7 +9,7 @@ export type ID = string | number; export type PlainObject = Record; -export interface Node { +export interface Node { /** * Every node in a graph must have a unique ID. */ @@ -21,7 +21,7 @@ export interface Node { data: D; } -export interface Edge { +export interface Edge { /** * Every edge in a graph must have a unique ID. */ diff --git a/src/utils/isShallowEqual.ts b/src/utils/isShallowEqual.ts new file mode 100644 index 0000000..e7417b2 --- /dev/null +++ b/src/utils/isShallowEqual.ts @@ -0,0 +1,63 @@ +export function depthOf(val: any, currentDepth = 0) { + if (currentDepth > 3) return Infinity; + + if (typeof val !== 'object' || val === null) { + return currentDepth; + } + + let maxDepth = currentDepth; + for (const key in val) { + const depth = depthOf(val[key], currentDepth + 1); + maxDepth = Math.max(maxDepth, depth); + } + return maxDepth; +} + +export function isShallowEqual(val1: any, val2: any, depth = 1, maxDepth = 3) { + if (depth > maxDepth) return true; + + if (val1 === val2) return true; + if ( + typeof val1 !== 'object' || + typeof val2 !== 'object' || + val1 == null || + val2 == null + ) { + return val1 === val2; + } + + const keys1 = Object.keys(val1); + const keys2 = Object.keys(val2); + + if (keys1.length !== keys2.length) { + return false; + } + + for (const key of keys1) { + if (!keys2.includes(key)) { + return false; + } + + if (typeof val1[key] === 'object' && typeof val2[key] === 'object') { + if (!isShallowEqual(val1[key], val2[key], depth + 1, maxDepth)) { + return false; + } + } else if (val1[key] !== val2[key]) { + return false; + } + } + + return true; +} + +/** + * Compare two values deeply. + * + * If the depth of the values is greater than 3, it will be regarded as not equal. + * + * This is for performance optimization, not for correctness. + */ +export function isEqual(val1: any, val2: any) { + if (depthOf(val1) === Infinity || depthOf(val2) === Infinity) return false; + return isShallowEqual(val1, val2); +}