feat: dynamically toggle label visibility based on density to ensure adaptive display (#6398)

* feat: toggle label visibility

* fix: fix ci issues

* test: add test

* refactor: rename toggle-label-visibility to auto-adapt-label

* fix: add update method to unbind and rebind events when options are updated

* refactor: rename page name

* fix: update remarks

* test: add unit tests

* fix: update default value
This commit is contained in:
Yuxin 2024-10-12 14:52:00 +08:00 committed by GitHub
parent 7c87eb3576
commit 2bacd5d716
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 13136 additions and 208 deletions

View File

@ -0,0 +1,73 @@
import data from '@@/dataset/language-tree.json';
import { Graph, IPointerEvent, type Element } from '@antv/g6';
export const behaviorAutoAdaptLabel: TestCase = async (context) => {
const graph = new Graph({
...context,
padding: 20,
theme: 'light',
data,
node: {
style: {
labelText: (d) => d.id,
labelBackground: true,
labelFontFamily: 'Gill Sans',
labelFill: '#333',
},
state: {
active: {
label: true,
},
},
palette: {
type: 'group',
color: 'tableau',
field: 'group',
},
},
edge: {
style: {
stroke: '#E2E3E1',
endArrow: true,
},
},
behaviors: [
'drag-canvas',
'zoom-canvas',
function () {
return {
type: 'hover-activate',
degree: 0,
onHover: (e: IPointerEvent<Element>) => {
this.frontElement(e.target.id);
},
};
},
{
key: 'auto-adapt-label',
type: 'auto-adapt-label',
},
],
layout: {
type: 'd3-force',
manyBody: { strength: -200 },
x: {},
y: {},
},
transforms: [
{
key: 'map-node-size',
type: 'map-node-size',
maxSize: 60,
minSize: 12,
scale: 'linear',
},
],
plugins: [{ type: 'background', background: '#fff' }],
animation: false,
});
await graph.render();
return graph;
};

View File

@ -5,6 +5,7 @@ export { animationElementPosition } from './animation-element-position';
export { animationElementState } from './animation-element-state';
export { animationElementStateSwitch } from './animation-element-state-switch';
export { animationElementStylePosition } from './animation-element-style-position';
export { behaviorAutoAdaptLabel } from './behavior-auto-adapt-label';
export { behaviorBrushSelect } from './behavior-brush-select';
export { behaviorClickSelect } from './behavior-click-select';
export { behaviorCreateEdge } from './behavior-create-edge';

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 215 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 215 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 215 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 215 KiB

View File

@ -0,0 +1,37 @@
import { positionOf, type Graph } from '@/src';
import { behaviorAutoAdaptLabel } from '@@/demos';
import { createDemoGraph } from '@@/utils';
describe('behavior auto adapt label', () => {
let graph: Graph;
beforeAll(async () => {
graph = await createDemoGraph(behaviorAutoAdaptLabel, { animation: false });
});
afterAll(() => {
graph.destroy();
});
it('default', async () => {
await expect(graph).toMatchSnapshot(__filename);
});
it('disable', async () => {
graph.updateBehavior({ key: 'auto-adapt-label', enable: false });
await expect(graph).toMatchSnapshot(__filename, 'disable');
});
it('update options', async () => {
graph.updateBehavior({ key: 'auto-adapt-label', enable: true, padding: 60 });
await expect(graph).toMatchSnapshot(__filename, 'padding-60');
});
it('update sorter', async () => {
graph.updateBehavior({ key: 'auto-adapt-label', padding: 0 });
const origin = positionOf(graph.getNodeData('Albanian'));
graph.zoomTo(3, false, origin);
await expect(graph).toMatchSnapshot(__filename, 'zoom-3');
graph.zoomTo(1, false, origin);
});
});

View File

@ -10,6 +10,7 @@ import {
getNodeBBox,
getPointBBox,
getTriangleCenter,
isBBoxInside,
isPointInBBox,
isPointOnBBoxBoundary,
isPointOutsideBBox,
@ -62,6 +63,17 @@ describe('bbox', () => {
expect(getCombinedBBox([])).toEqual(new AABB());
});
it('isBBoxInside', () => {
const bbox1 = new AABB();
bbox1.setMinMax([0, 0, 0], [1, 1, 1]);
const bbox2 = new AABB();
bbox2.setMinMax([0.5, 0.5, 0], [1.5, 1.5, 1]);
const bbox3 = new AABB();
bbox3.setMinMax([0, 0, 0], [2, 2, 2]);
expect(isBBoxInside(bbox1, bbox2)).toBe(false);
expect(isBBoxInside(bbox1, bbox3)).toBe(true);
});
it('isPointInBBox', () => {
expect(isPointInBBox([0.5, 0.5, 0], bbox)).toBe(true);
expect(isPointInBBox([0, 0, 0], bbox)).toBe(true);

View File

@ -0,0 +1,252 @@
import { AABB } from '@antv/g';
import { groupBy, isFunction, throttle } from '@antv/util';
import { GraphEvent } from '../constants';
import type { RuntimeContext } from '../runtime/types';
import type { Combo, Edge, Element, ID, IEvent, Node, NodeCentralityOptions, Padding } from '../types';
import { getExpandedBBox, isBBoxInside } from '../utils/bbox';
import { getNodeCentralities } from '../utils/centrality';
import { arrayDiff } from '../utils/diff';
import { setVisibility } from '../utils/visibility';
import type { BaseBehaviorOptions } from './base-behavior';
import { BaseBehavior } from './base-behavior';
/**
* <zh/>
*
* <en/> Auto Adapt Label Options
*/
export interface AutoAdaptLabelOptions extends BaseBehaviorOptions {
/**
* <zh/>
*
* <en/> Whether to enable
* @defaultValue `true`
*/
enable?: boolean | ((event: IEvent) => boolean);
/**
* <zh/> combo > node > edge
*
* <en/> Sort elements by their importance in descending order; elements with higher importance have higher label display priority; usually combo > node > edge
*/
sorter?: (labelElementsInViewport: Element[]) => Element[];
/**
* <zh/> `sorter` `nodeSorter`
*
* <en/> Sort nodes by importance in descending order; nodes with higher importance have higher label display priority. Several centrality algorithms are built in, and custom sorting functions can also be defined. It should be noted that if `sorter` is set, `nodeSorter` will not take effect
* @defaultValue { type: 'degree' }
*/
nodeSorter?: NodeCentralityOptions | ((labeledNodesInViewport: Node[]) => Node[]);
/**
* <zh/> `sorter` `edgeSorter`
*
* <en/> Sort edges by importance in descending order; edges with higher importance have higher label display priority. By default, they are sorted according to the data. It should be noted that if `sorter` is set, `edgeSorter` will not take effect
*/
edgeSorter?: (labeledEdgesInViewport: Edge[]) => Edge[];
/**
* <zh/> `sorter` `comboSorter`
*
* <en/> Sort combos by importance in descending order; combos with higher importance have higher label display priority. By default, they are sorted according to the data. It should be noted that if `sorter` is set, `comboSorter` will not take effect
*/
comboSorter?: (labeledCombosInViewport: Combo[]) => Combo[];
/**
* <zh/>
*
* <en/> Set the padding of the label to determine whether the label overlaps to avoid the label being displayed too densely
* @defaultValue 0
*/
padding?: Padding;
/**
* <zh/>
*
* <en/> Throttle time
* @defaultValue 32
*/
throttle?: number;
}
/**
* <zh/>
*
* <en/> Auto Adapt Label
* @remarks
* <zh/>
*
* <en/ >Label Adaptive Display is a dynamic label management strategy designed to intelligently adjust which labels should be shown or hidden based on factors such as the spatial allocation of the current viewport and node importance. By analyzing the visible area in real-time, it ensures that users receive the most relevant and clear information display in various interactive scenarios, while avoiding visual overload and information redundancy.
*/
export class AutoAdaptLabel extends BaseBehavior<AutoAdaptLabelOptions> {
static defaultOptions: Partial<AutoAdaptLabelOptions> = {
enable: true,
throttle: 100,
padding: 0,
nodeSorter: { type: 'degree' },
};
constructor(context: RuntimeContext, options: AutoAdaptLabelOptions) {
super(context, Object.assign({}, AutoAdaptLabel.defaultOptions, options));
this.bindEvents();
}
public update(options: Partial<AutoAdaptLabelOptions>): void {
this.unbindEvents();
super.update(options);
this.bindEvents();
this.onToggleVisibility({} as IEvent);
}
/**
* <zh/>
*
* <en/> Check whether the current bounding box has enough space to display; if it overlaps with the displayed bounding box or exceeds the viewport range, it will not be displayed
* @param bbox - bbox
* @param bboxes - occupied bboxes which are already shown
* @returns whether the bbox is overlapping with the bboxes or outside the viewpointBounds
*/
private isOverlapping = (bbox: AABB, bboxes: AABB[]) => {
return !isBBoxInside(bbox, this.viewpointBounds) || bboxes.some((b) => bbox.intersects(b));
};
private get viewpointBounds(): AABB {
const { canvas } = this.context;
const [minX, minY] = canvas.getCanvasByViewport([0, 0]);
const [maxX, maxY] = canvas.getCanvasByViewport(canvas.getSize());
const viewpointBounds = new AABB();
viewpointBounds.setMinMax([minX, minY, 0], [maxX, maxY, 0]);
return getExpandedBBox(viewpointBounds, 2);
}
private occupiedBounds: AABB[] = [];
private detectLabelCollision = (elements: Element[]): { show: Element[]; hide: Element[] } => {
const res: { show: Element[]; hide: Element[] } = { show: [], hide: [] };
this.occupiedBounds = [];
elements.forEach((element) => {
const labelBounds = element.getShape('label').getRenderBounds();
if (!this.isOverlapping(labelBounds, this.occupiedBounds)) {
res.show.push(element);
this.occupiedBounds.push(getExpandedBBox(labelBounds, this.options.padding));
} else {
res.hide.push(element);
}
});
return res;
};
private get labelElements(): Record<ID, Element> {
// @ts-expect-error access private property
const elements = Object.values(this.context.element.elementMap);
const labelElements = elements.filter((el: Element) => el.isVisible() && el.getShape('label'));
return Object.fromEntries(labelElements.map((el) => [el.id, el]));
}
private getLabelElementsInView(): Element[] {
const viewport = this.context.viewport!;
return Object.values(this.labelElements).filter((node) =>
viewport.isInViewport(node.getShape('key').getRenderBounds()),
);
}
private hideLabelIfExceedViewport = (prevElementsInView: Element[], currentElementsInView: Element[]) => {
const { exit } = arrayDiff<Element>(prevElementsInView, currentElementsInView, (d) => d.id);
exit?.forEach(this.hideLabel);
};
private nodeCentralities: Map<ID, number> = new Map();
private sortNodesByCentrality = (nodes: Node[], centrality: NodeCentralityOptions) => {
const { model } = this.context;
const graphData = model.getData();
const getRelatedEdgesData = model.getRelatedEdgesData.bind(model);
const nodesWithCentrality = nodes.map((node) => {
if (!this.nodeCentralities.has(node.id)) {
this.nodeCentralities = getNodeCentralities(graphData, getRelatedEdgesData, centrality);
}
return { node, centrality: this.nodeCentralities.get(node.id)! };
});
return nodesWithCentrality.sort((a, b) => b.centrality - a.centrality).map((item) => item.node);
};
protected sortLabelElementsInView = (labelElements: Element[]): Element[] => {
const { sorter, nodeSorter, comboSorter, edgeSorter } = this.options;
if (isFunction(this.options.sorter)) return sorter(labelElements);
const { node: nodes = [], edge: edges = [], combo: combos = [] } = groupBy(labelElements, (el) => (el as any).type);
const sortedCombos = isFunction(comboSorter) ? comboSorter(combos as Combo[]) : combos;
const sortedNodes = isFunction(nodeSorter)
? nodeSorter(nodes as Node[])
: this.sortNodesByCentrality(nodes as Node[], nodeSorter!);
const sortedEdges = isFunction(edgeSorter) ? edgeSorter(edges as Edge[]) : edges;
return [...sortedCombos, ...sortedNodes, ...sortedEdges];
};
private labelElementsInView: Element[] = [];
protected onToggleVisibility = (event: IEvent) => {
if (!this.validate(event)) {
if (this.hiddenElements.size > 0) {
this.hiddenElements.forEach(this.showLabel);
this.hiddenElements.clear();
}
return;
}
const labelElementsInView = this.getLabelElementsInView();
this.hideLabelIfExceedViewport(this.labelElementsInView, labelElementsInView);
this.labelElementsInView = labelElementsInView;
// 根据元素的重要性从高到低排序,重要性越高的元素其标签显示优先级越高;通常 combo > node > edge
// Sort elements by their importance in descending order; elements with higher importance have higher label display priority; usually combo > node > edge
const sortedElements = this.sortLabelElementsInView(this.labelElementsInView);
const { show, hide } = this.detectLabelCollision(sortedElements);
show.forEach(this.showLabel);
hide.forEach(this.hideLabel);
};
private hiddenElements: Map<ID, Element> = new Map();
private hideLabel = (element: Element) => {
const label = element.getShape('label');
if (label) setVisibility(label, 'hidden');
this.hiddenElements.set(element.id, element);
};
private showLabel = (element: Element) => {
const label = element.getShape('label');
if (label) setVisibility(label, 'visible');
this.hiddenElements.delete(element.id);
};
protected onTransform = throttle(this.onToggleVisibility, this.options.throttle, { leading: true }) as () => void;
private bindEvents() {
const { graph } = this.context;
graph.once(GraphEvent.AFTER_RENDER, this.onToggleVisibility);
graph.on(GraphEvent.AFTER_TRANSFORM, this.onTransform);
}
private unbindEvents() {
const { graph } = this.context;
graph.off(GraphEvent.AFTER_TRANSFORM, this.onTransform);
}
private validate(event: IEvent): boolean {
if (this.destroyed) return false;
const { enable } = this.options;
if (isFunction(enable)) return enable(event);
return !!enable;
}
public destroy(): void {
this.unbindEvents();
super.destroy();
}
}

View File

@ -41,6 +41,7 @@ export interface FixElementSizeOptions extends BaseBehaviorOptions {
* <zh/>
*
* <en/> Specify the state of elements to be fixed in size
* @defaultValue `'selected'`
*/
state?: State;
/**
@ -48,9 +49,9 @@ export interface FixElementSizeOptions extends BaseBehaviorOptions {
*
* <en/> Node configuration for defining which node attributes should remain fixed in size visually. If not specified (i.e., undefined), the entire node will be fixed in size.
* @example
* <zh/> lineWidth
* <zh/> lineWidth
*
* <en/> For example, to fix `lineWidth` attribute of a node during zooming, you can configure it as follows:
* <en/> If you want to fix the lineWidth attribute of the key shape of the node during zooming, you can configure it like this:
* ```ts
* {
* node: [
@ -60,6 +61,15 @@ export interface FixElementSizeOptions extends BaseBehaviorOptions {
* },
* ],
* }
*
* <zh/>
*
* <en/> If you want to fix the font size and line height of the node label, you can configure it like this:
* ```ts
* {
* shape: (shapes: DisplayObject[]) => shapes.find((shape) => shape.parentElement?.className === 'label' && shape.className === 'text')!,
* fields: ['fontSize', 'lineHeight'],
* },
* ```
*/
node?: FixShapeConfig | FixShapeConfig[];
@ -146,7 +156,7 @@ export class FixElementSize extends BaseBehavior {
configs.forEach((config: FixShapeConfig) => {
const { shape: shapeFilter, fields } = config;
const shape = shapeFilter(descendantShapes);
if (!shape) return;
fields.forEach((field) => {
if (!hasCachedStyle(shape, field)) cacheStyle(shape, field);
const oriFieldValue = getCachedStyle(shape, field);

View File

@ -1,3 +1,4 @@
export { AutoAdaptLabel } from './auto-adapt-label';
export { BaseBehavior } from './base-behavior';
export { BrushSelect } from './brush-select';
export { ClickSelect } from './click-select';
@ -14,6 +15,7 @@ export { OptimizeViewportTransform } from './optimize-viewport-transform';
export { ScrollCanvas } from './scroll-canvas';
export { ZoomCanvas } from './zoom-canvas';
export type { AutoAdaptLabelOptions } from './auto-adapt-label';
export type { BaseBehaviorOptions } from './base-behavior';
export type { BrushSelectOptions } from './brush-select';
export type { ClickSelectOptions } from './click-select';

View File

@ -262,6 +262,7 @@ export type {
LoopStyleProps,
Node,
NodeBadgeStyleProps,
NodeCentralityOptions,
NodeLabelStyleProps,
NodeLikeData,
NodePortStyleProps,

View File

@ -12,6 +12,7 @@ import {
} from '@antv/g';
import { ComboCollapse, ComboExpand, Fade, NodeCollapse, NodeExpand, PathIn, PathOut, Translate } from '../animations';
import {
AutoAdaptLabel,
BrushSelect,
ClickSelect,
CollapseExpand,
@ -128,6 +129,7 @@ const BUILT_IN_EXTENSIONS: ExtensionRegistry = {
'focus-element': FocusElement,
'hover-activate': HoverActivate,
'lasso-select': LassoSelect,
'auto-adapt-label': AutoAdaptLabel,
'optimize-viewport-transform': OptimizeViewportTransform,
'scroll-canvas': ScrollCanvas,
'zoom-canvas': ZoomCanvas,

View File

@ -1,8 +1,9 @@
import { findShortestPath, pageRank } from '@antv/algorithm';
import { deepMix } from '@antv/util';
import { deepMix, isEqual } from '@antv/util';
import type { RuntimeContext } from '../runtime/types';
import type { GraphData } from '../spec';
import type { EdgeDirection, ID, Node, Size, STDSize } from '../types';
import type { ID, Node, NodeCentralityOptions, Size, STDSize } from '../types';
import type { CentralityResult } from '../utils/centrality';
import { getNodeCentralities } from '../utils/centrality';
import { idOf } from '../utils/id';
import { linear, log, pow, sqrt } from '../utils/scale';
import { parseSize } from '../utils/size';
@ -30,13 +31,7 @@ export interface MapNodeSizeOptions extends BaseTransformOptions {
* - Custom centrality calculation method: `(graphData: GraphData) => Map<ID, number>`, where `graphData` is the graph data, and `Map<ID, number>` is the mapping from node ID to centrality value
* @defaultValue { type: 'eigenvector' }
*/
centrality?:
| { type: 'degree'; direction?: EdgeDirection }
| { type: 'betweenness'; directed?: boolean; weightPropertyName?: string }
| { type: 'closeness'; directed?: boolean; weightPropertyName?: string }
| { type: 'eigenvector'; directed?: boolean }
| { type: 'pagerank'; epsilon?: number; linkProb?: number }
| ((graphData: GraphData) => Map<ID, number>);
centrality?: NodeCentralityOptions | ((graphData: GraphData) => Map<ID, number>);
/**
* <zh/>
*
@ -75,8 +70,6 @@ export interface MapNodeSizeOptions extends BaseTransformOptions {
| ((value: number, domain: [number, number], range: [number, number]) => number);
}
type CentralityResult = Map<ID, number>;
/**
* <zh/>
*
@ -119,7 +112,10 @@ export class MapNodeSize extends BaseTransform<MapNodeSizeOptions> {
this.options.scale,
);
const element = this.context.element?.getElement<Node>(idOf(datum));
reassignTo(input, element ? 'update' : 'add', 'node', deepMix(datum, { style: { size } }));
if (!element || !isEqual(size, element.attributes.size)) {
reassignTo(input, element ? 'update' : 'add', 'node', deepMix(datum, { style: { size } }));
}
});
return input;
}
@ -130,26 +126,8 @@ export class MapNodeSize extends BaseTransform<MapNodeSizeOptions> {
if (typeof centrality === 'function') return centrality(graphData);
switch (centrality.type) {
case 'degree': {
const centralityResult = new Map<ID, number>();
graphData.nodes?.forEach((node) => {
const degree = model.getRelatedEdgesData(idOf(node), centrality.direction).length;
centralityResult.set(idOf(node), degree);
});
return centralityResult;
}
case 'betweenness':
return calculateBetweennessCentrality(graphData, centrality.directed, centrality.weightPropertyName);
case 'closeness':
return calculateClosenessCentrality(graphData, centrality.directed, centrality.weightPropertyName);
case 'eigenvector':
return calculateEigenvectorCentrality(graphData, centrality.directed);
case 'pagerank':
return calculatePageRankCentrality(graphData, centrality.epsilon, centrality.linkProb);
default:
return initCentralityResult(graphData);
}
const getRelatedEdgesData = model.getRelatedEdgesData.bind(model);
return getNodeCentralities(graphData, getRelatedEdgesData, centrality);
}
private assignSizeByCentrality = (
@ -186,175 +164,3 @@ export class MapNodeSize extends BaseTransform<MapNodeSizeOptions> {
return [interpolate(centrality, rangeX), interpolate(centrality, rangeY), interpolate(centrality, rangeZ)];
};
}
const initCentralityResult = (graphData: GraphData): CentralityResult => {
const centralityResult = new Map<ID, number>();
graphData.nodes?.forEach((node) => {
centralityResult.set(idOf(node), 0);
});
return centralityResult;
};
/**
* <zh/>
*
* <en/> Calculate the betweenness centrality for each node in the graph
* @param graphData - <zh/> | <en/>Graph data
* @param directed - <zh/> | <en/>Whether the graph is directed
* @param weightPropertyName - <zh/> | <en/>The weight property name of the edge
* @returns <zh/> | <en/>The betweenness centrality for each node
*/
const calculateBetweennessCentrality = (
graphData: GraphData,
directed?: boolean,
weightPropertyName?: string,
): CentralityResult => {
const centralityResult = initCentralityResult(graphData);
const { nodes = [] } = graphData;
nodes.forEach((source) => {
nodes.forEach((target) => {
if (source !== target) {
const { allPath } = findShortestPath(graphData, idOf(source), idOf(target), directed, weightPropertyName);
const pathCount = allPath.length;
(allPath as ID[][]).flat().forEach((nodeId) => {
if (nodeId !== idOf(source) && nodeId !== idOf(target)) {
centralityResult.set(nodeId, centralityResult.get(nodeId)! + 1 / pathCount);
}
});
}
});
});
return centralityResult;
};
/**
* <zh/>
*
* <en/> Calculate the closeness centrality for each node in the graph
* @param graphData - <zh/> | <en/>Graph data
* @param directed - <zh/> | <en/>Whether the graph is directed
* @param weightPropertyName - <zh/> | <en/>The weight property name of the edge
* @returns <zh/> | <en/>The closeness centrality for each node
*/
const calculateClosenessCentrality = (
graphData: GraphData,
directed?: boolean,
weightPropertyName?: string,
): CentralityResult => {
const centralityResult = new Map<ID, number>();
const { nodes = [] } = graphData;
nodes.forEach((source) => {
const totalLength = nodes.reduce((acc, target) => {
if (source !== target) {
const { length } = findShortestPath(graphData, idOf(source), idOf(target), directed, weightPropertyName);
acc += length;
}
return acc;
}, 0);
centralityResult.set(idOf(source), 1 / totalLength);
});
return centralityResult;
};
/**
* <zh/> PageRank
*
* <en/> Calculate the PageRank centrality for each node in the graph
* @param graphData - <zh/> | <en/>Graph data
* @param epsilon - <zh/> PageRank | <en/>The convergence tolerance of the PageRank algorithm
* @param linkProb - <zh/> PageRank 访访 0.85 | <en/>The damping factor of the PageRank algorithm, which refers to the probability that a user will continue to visit the next node linked to a node at any time, with an empirical value of 0.85
* @returns <zh/> PageRank | <en/>The PageRank centrality for each node
*/
const calculatePageRankCentrality = (graphData: GraphData, epsilon?: number, linkProb?: number): CentralityResult => {
const centralityResult = new Map<ID, number>();
const data = pageRank(graphData, epsilon, linkProb);
graphData.nodes?.forEach((node) => {
centralityResult.set(idOf(node), data[idOf(node)]);
});
return centralityResult;
};
/**
* <zh/>
*
* <en/> Calculate the eigenvector centrality for each node in the graph.
* @param graphData - <zh/> | <en/>Graph data
* @param directed - <zh/> | <en/>Whether the graph is directed
* @returns The eigenvector centrality for each node.
*/
const calculateEigenvectorCentrality = (graphData: GraphData, directed?: boolean): CentralityResult => {
const { nodes = [] } = graphData;
const adjacencyMatrix = createAdjacencyMatrix(graphData, directed);
const eigenvector = powerIteration(adjacencyMatrix, nodes.length);
const centralityResult = new Map<ID, number>();
nodes.forEach((node, index) => {
centralityResult.set(idOf(node), eigenvector[index]);
});
return centralityResult;
};
/**
* <zh/>
*
* <en/> Create the adjacency matrix for the graph.
* @param graphData - <zh/> | <en/>Graph data
* @param directed - <zh/> | <en/>Whether the graph is directed
* @returns <zh/> | <en/>The adjacency matrix
*/
const createAdjacencyMatrix = (graphData: GraphData, directed?: boolean): number[][] => {
const { nodes = [], edges = [] } = graphData;
const matrix: number[][] = Array(nodes.length)
.fill(null)
.map(() => Array(nodes.length).fill(0));
edges.forEach(({ source, target }) => {
const uIndex = nodes.findIndex((node) => idOf(node) === source);
const vIndex = nodes.findIndex((node) => idOf(node) === target);
if (directed) {
matrix[uIndex][vIndex] = 1;
} else {
matrix[uIndex][vIndex] = 1;
matrix[vIndex][uIndex] = 1;
}
});
return matrix;
};
/**
* <zh/> 使
*
* <en/> Calculate the principal eigenvector using the power iteration method
* @see https://en.wikipedia.org/wiki/Eigenvalues_and_eigenvectors
* @param matrix - <zh/> | <en/> The adjacency matrix
* @param numNodes - <zh/> | <en/> The number of nodes
* @param maxIterations - <zh/> | <en/> The maximum number of iterations
* @param tolerance - <zh/> | <en/> The convergence tolerance
* @returns <zh/> | <en/> The principal eigenvector
*/
const powerIteration = (matrix: number[][], numNodes: number, maxIterations = 100, tolerance = 1e-6): number[] => {
let eigenvector = Array(numNodes).fill(1);
let diff = Infinity;
for (let iter = 0; iter < maxIterations && diff > tolerance; iter++) {
const newEigenvector = Array(numNodes).fill(0);
for (let i = 0; i < numNodes; i++) {
for (let j = 0; j < numNodes; j++) {
newEigenvector[i] += matrix[i][j] * eigenvector[j];
}
}
const norm = Math.sqrt(newEigenvector.reduce((sum, val) => sum + val * val, 0));
for (let i = 0; i < numNodes; i++) {
newEigenvector[i] /= norm;
}
diff = Math.sqrt(newEigenvector.reduce((sum, val, index) => sum + (val - eigenvector[index]) * val, 0));
eigenvector = newEigenvector;
}
return eigenvector;
};

View File

@ -0,0 +1,8 @@
import type { EdgeDirection } from './edge';
export type NodeCentralityOptions =
| { type: 'degree'; direction?: EdgeDirection }
| { type: 'betweenness'; directed?: boolean; weightPropertyName?: string }
| { type: 'closeness'; directed?: boolean; weightPropertyName?: string }
| { type: 'eigenvector'; directed?: boolean }
| { type: 'pagerank'; epsilon?: number; linkProb?: number };

View File

@ -1,6 +1,7 @@
export type * from './anchor';
export type * from './animation';
export type * from './canvas';
export type * from './centrality';
export type * from './change';
export type * from './combo';
export type * from './data';

View File

@ -105,6 +105,23 @@ export function getCombinedBBox(bboxes: AABB[]): AABB {
return bbox;
}
/**
* <zh/> bbox1 bbox2
*
* <en/> Determine whether bbox1 is completely contained in bbox2
* @param bbox1 - <zh/> | <en/> Target bounding box
* @param bbox2 - <zh/> | <en/> Reference bounding box
* @returns <zh/> bbox1 bbox2 true false | <en/> Returns true if bbox1 is completely contained in bbox2, false otherwise
*/
export function isBBoxInside(bbox1: AABB, bbox2: AABB): boolean {
const [minX1, minY1] = bbox1.min;
const [maxX1, maxY1] = bbox1.max;
const [minX2, minY2] = bbox2.min;
const [maxX2, maxY2] = bbox2.max;
return minX1 >= minX2 && maxX1 <= maxX2 && minY1 >= minY2 && maxY1 <= maxY2;
}
/**
* <zh/>
*

View File

@ -0,0 +1,209 @@
import { findShortestPath, pageRank } from '@antv/algorithm';
import type { EdgeData, GraphData } from '../spec';
import type { EdgeDirection, ID, NodeCentralityOptions } from '../types';
import { idOf } from './id';
export type CentralityResult = Map<ID, number>;
export const getNodeCentralities = (
graphData: GraphData,
getRelatedEdgesData: (id: ID, direction?: EdgeDirection) => EdgeData[],
centrality: NodeCentralityOptions,
) => {
switch (centrality.type) {
case 'degree': {
const centralityResult = new Map<ID, number>();
graphData.nodes?.forEach((node) => {
const degree = getRelatedEdgesData(idOf(node), centrality.direction).length;
centralityResult.set(idOf(node), degree);
});
return centralityResult;
}
case 'betweenness':
return computeNodeBetweennessCentrality(graphData, centrality.directed, centrality.weightPropertyName);
case 'closeness':
return computeNodeClosenessCentrality(graphData, centrality.directed, centrality.weightPropertyName);
case 'eigenvector':
return computeNodeEigenvectorCentrality(graphData, centrality.directed);
case 'pagerank':
return computeNodePageRankCentrality(graphData, centrality.epsilon, centrality.linkProb);
default:
return initCentralityResult(graphData);
}
};
export const initCentralityResult = (graphData: GraphData): CentralityResult => {
const centralityResult = new Map<ID, number>();
graphData.nodes?.forEach((node) => {
centralityResult.set(idOf(node), 0);
});
return centralityResult;
};
/**
* <zh/>
*
* <en/> Calculate the betweenness centrality for each node in the graph
* @param graphData - <zh/> | <en/>Graph data
* @param directed - <zh/> | <en/>Whether the graph is directed
* @param weightPropertyName - <zh/> | <en/>The weight property name of the edge
* @returns <zh/> | <en/>The betweenness centrality for each node
*/
export const computeNodeBetweennessCentrality = (
graphData: GraphData,
directed?: boolean,
weightPropertyName?: string,
): CentralityResult => {
const centralityResult = initCentralityResult(graphData);
const { nodes = [] } = graphData;
nodes.forEach((source) => {
nodes.forEach((target) => {
if (source !== target) {
const { allPath } = findShortestPath(graphData, idOf(source), idOf(target), directed, weightPropertyName);
const pathCount = allPath.length;
(allPath as ID[][]).flat().forEach((nodeId) => {
if (nodeId !== idOf(source) && nodeId !== idOf(target)) {
centralityResult.set(nodeId, centralityResult.get(nodeId)! + 1 / pathCount);
}
});
}
});
});
return centralityResult;
};
/**
* <zh/>
*
* <en/> Calculate the closeness centrality for each node in the graph
* @param graphData - <zh/> | <en/>Graph data
* @param directed - <zh/> | <en/>Whether the graph is directed
* @param weightPropertyName - <zh/> | <en/>The weight property name of the edge
* @returns <zh/> | <en/>The closeness centrality for each node
*/
export const computeNodeClosenessCentrality = (
graphData: GraphData,
directed?: boolean,
weightPropertyName?: string,
): CentralityResult => {
const centralityResult = new Map<ID, number>();
const { nodes = [] } = graphData;
nodes.forEach((source) => {
const totalLength = nodes.reduce((acc, target) => {
if (source !== target) {
const { length } = findShortestPath(graphData, idOf(source), idOf(target), directed, weightPropertyName);
acc += length;
}
return acc;
}, 0);
centralityResult.set(idOf(source), 1 / totalLength);
});
return centralityResult;
};
/**
* <zh/> PageRank
*
* <en/> Calculate the PageRank centrality for each node in the graph
* @param graphData - <zh/> | <en/>Graph data
* @param epsilon - <zh/> PageRank | <en/>The convergence tolerance of the PageRank algorithm
* @param linkProb - <zh/> PageRank 访访 0.85 | <en/>The damping factor of the PageRank algorithm, which refers to the probability that a user will continue to visit the next node linked to a node at any time, with an empirical value of 0.85
* @returns <zh/> PageRank | <en/>The PageRank centrality for each node
*/
export const computeNodePageRankCentrality = (
graphData: GraphData,
epsilon?: number,
linkProb?: number,
): CentralityResult => {
const centralityResult = new Map<ID, number>();
const data = pageRank(graphData, epsilon, linkProb);
graphData.nodes?.forEach((node) => {
centralityResult.set(idOf(node), data[idOf(node)]);
});
return centralityResult;
};
/**
* <zh/>
*
* <en/> Calculate the eigenvector centrality for each node in the graph.
* @param graphData - <zh/> | <en/>Graph data
* @param directed - <zh/> | <en/>Whether the graph is directed
* @returns The eigenvector centrality for each node.
*/
export const computeNodeEigenvectorCentrality = (graphData: GraphData, directed?: boolean): CentralityResult => {
const { nodes = [] } = graphData;
const adjacencyMatrix = createAdjacencyMatrix(graphData, directed);
const eigenvector = powerIteration(adjacencyMatrix, nodes.length);
const centralityResult = new Map<ID, number>();
nodes.forEach((node, index) => {
centralityResult.set(idOf(node), eigenvector[index]);
});
return centralityResult;
};
/**
* <zh/>
*
* <en/> Create the adjacency matrix for the graph.
* @param graphData - <zh/> | <en/>Graph data
* @param directed - <zh/> | <en/>Whether the graph is directed
* @returns <zh/> | <en/>The adjacency matrix
*/
export const createAdjacencyMatrix = (graphData: GraphData, directed?: boolean): number[][] => {
const { nodes = [], edges = [] } = graphData;
const matrix: number[][] = Array(nodes.length)
.fill(null)
.map(() => Array(nodes.length).fill(0));
edges.forEach(({ source, target }) => {
const uIndex = nodes.findIndex((node) => idOf(node) === source);
const vIndex = nodes.findIndex((node) => idOf(node) === target);
if (directed) {
matrix[uIndex][vIndex] = 1;
} else {
matrix[uIndex][vIndex] = 1;
matrix[vIndex][uIndex] = 1;
}
});
return matrix;
};
/**
* <zh/> 使
*
* <en/> Calculate the principal eigenvector using the power iteration method
* @see https://en.wikipedia.org/wiki/Eigenvalues_and_eigenvectors
* @param matrix - <zh/> | <en/> The adjacency matrix
* @param numNodes - <zh/> | <en/> The number of nodes
* @param maxIterations - <zh/> | <en/> The maximum number of iterations
* @param tolerance - <zh/> | <en/> The convergence tolerance
* @returns <zh/> | <en/> The principal eigenvector
*/
const powerIteration = (matrix: number[][], numNodes: number, maxIterations = 100, tolerance = 1e-6): number[] => {
let eigenvector = Array(numNodes).fill(1);
let diff = Infinity;
for (let iter = 0; iter < maxIterations && diff > tolerance; iter++) {
const newEigenvector = Array(numNodes).fill(0);
for (let i = 0; i < numNodes; i++) {
for (let j = 0; j < numNodes; j++) {
newEigenvector[i] += matrix[i][j] * eigenvector[j];
}
}
const norm = Math.sqrt(newEigenvector.reduce((sum, val) => sum + val * val, 0));
for (let i = 0; i < numNodes; i++) {
newEigenvector[i] /= norm;
}
diff = Math.sqrt(newEigenvector.reduce((sum, val, index) => sum + (val - eigenvector[index]) * val, 0));
eigenvector = newEigenvector;
}
return eigenvector;
};

View File

@ -44,6 +44,7 @@
"RadialLayout": ["Radial", "径向布局"],
"RandomLayout": ["Random", "随机布局"],
"BaseBehavior": ["BaseBehavior", "基础交互"],
"AutoAdaptLabel": ["AutoAdaptLabel", "标签自适应显示"],
"BrushSelect": ["BrushSelect", "框选"],
"ClickSelect": ["ClickSelect", "点击选中"],
"CollapseExpand": ["CollapseExpand", "展开/收起元素"],