@antv/g6
Version:
A Graph Visualization Framework in JavaScript
419 lines (381 loc) • 12 kB
text/typescript
import { Category, Selection } from '@antv/component';
import { CategoryStyleProps } from '@antv/component/lib/ui/legend/types';
import { Canvas } from '@antv/g';
import { get, isFunction } from '@antv/util';
import { GraphEvent } from '../constants';
import type { RuntimeContext } from '../runtime/types';
import type { ElementDatum, ElementType, ID, State } from '../types';
import type { CardinalPlacement } from '../types/placement';
import type { BasePluginOptions } from './base-plugin';
import { BasePlugin } from './base-plugin';
import { createPluginCanvas } from './utils/canvas';
interface Datum extends Record<string, any> {
id?: string;
label?: string;
color?: string;
marker?: string;
elementType?: ElementType;
}
/**
* <zh/> 图例配置项
*
* <en/> Legend options
*/
export interface LegendOptions extends BasePluginOptions, Omit<CategoryStyleProps, 'data'> {
/**
* <zh/> 图例触发行为
* - `'hover'`:鼠标移入图例项时触发
* - `'click'`:鼠标点击图例项时触发
*
* <en/> Legend trigger behavior
* - `'hover'`:mouseover the legend item
* - `'click'`:click the legend item
* @defaultValue 'hover'
*/
trigger?: 'hover' | 'click';
/**
* <zh/> 图例在画布中的相对位置,默认为 'bottom',代表在画布正下方
*
* <en/> Relative position of the legend in the canvas, defaults to 'bottom', representing the bottom of the canvas
* @defaultValue 'bottom'
*/
position?: CardinalPlacement;
/**
* <zh/> 图例挂载的容器,无则挂载到 Graph 所在容器
*
* <en/> The container where the legend is mounted, if not, it will be mounted to the container where the Graph is located
*/
container?: HTMLElement | string;
/**
* <zh/> 图例画布类名,传入外置容器时不生效
*
* <en/> The class name of the legend canvas, which does not take effect when an external container is passed in
*/
className?: string;
/**
* <zh/> 图例的容器样式,传入外置容器时不生效
*
* <en/> The style of the legend container, which does not take effect when an external container is passed in
*/
containerStyle?: Partial<CSSStyleDeclaration>;
/**
* <zh/> 节点分类标识
*
* <en/> Node Classification Identifier
*/
nodeField?: string | ((item: ElementDatum) => string);
/**
* <zh/> 边分类标识
*
* <en/> Edge Classification Identifier
*/
edgeField?: string | ((item: ElementDatum) => string);
/**
* <zh/> 组合分类标识
*
* <en/> Combo Classification Identifier
*/
comboField?: string | ((item: ElementDatum) => string);
}
/**
* <zh/> 图例
*
* <en/> Legend
* @remarks
* <zh/> 图例插件用于展示图中元素的分类信息,支持节点、边、组合的分类信息展示。
*
* <en/> The legend plugin is used to display the classification information of elements in the graph, and supports the display of classification information of nodes, edges, and combos.
*/
export class Legend extends BasePlugin<LegendOptions> {
static defaultOptions: Partial<LegendOptions> = {
position: 'bottom',
trigger: 'hover',
orientation: 'horizontal',
layout: 'flex',
itemSpacing: 4,
rowPadding: 10,
colPadding: 10,
itemMarkerSize: 16,
itemLabelFontSize: 16,
width: 240,
height: 160,
};
private typePrefix = '__data__';
private draw = false;
private fieldMap = {
node: new Map<string, ID[]>(),
edge: new Map<string, ID[]>(),
combo: new Map<string, ID[]>(),
};
private selectedItems: string[] = [];
private category?: Category;
private container?: HTMLElement;
private canvas?: Canvas;
constructor(context: RuntimeContext, options: LegendOptions) {
super(context, Object.assign({}, Legend.defaultOptions, options));
this.bindEvents();
}
/**
* <zh/> 更新图例配置
*
* <en/> Update the legend configuration
* @param options - <zh/> 图例配置项 | <en/> Legend options
* @internal
*/
public update(options: Partial<LegendOptions>) {
super.update(options);
this.clear();
this.createElement();
}
private clear() {
this.canvas?.destroy();
this.container?.remove();
this.canvas = undefined;
this.container = undefined;
this.draw = false;
}
private bindEvents = () => {
const { graph } = this.context;
graph.on(GraphEvent.AFTER_DRAW, this.createElement);
};
private changeState = (el: Selection, state: State | State[]) => {
const { graph } = this.context;
const { typePrefix } = this;
const composeId = get(el, [typePrefix, 'id']);
const category = get(el, [typePrefix, 'style', 'labelText']);
const [type] = composeId.split('__');
const ids = this.fieldMap[type as keyof typeof this.fieldMap].get(category) || [];
graph.setElementState(Object.fromEntries(ids?.map((id) => [id, state])));
};
/**
* <zh/> 图例元素点击事件
*
* <en/> Legend element click event
* @param event - <zh/> 点击的元素 | <en/> The element that is clicked
*/
public click = (event: Selection) => {
if (this.options.trigger === 'hover') return;
const composeId = get(event, [this.typePrefix, 'id']);
if (!this.selectedItems.includes(composeId)) {
this.selectedItems.push(composeId);
this.changeState(event, 'selected');
} else {
this.selectedItems = this.selectedItems.filter((item) => item !== composeId);
this.changeState(event, []);
}
};
/**
* <zh/> 图例元素移出事件
*
* <en/> Legend element mouseleave event
* @param event - <zh/> 移出的元素 | <en/> The element that is moved out
*/
public mouseleave = (event: Selection) => {
if (this.options.trigger === 'click') return;
this.selectedItems = [];
this.changeState(event, []);
};
/**
* <zh/> 图例元素移入事件
*
* <en/> Legend element mouseenter event
* @param event - <zh/> 移入的元素 | <en/> The element that is moved in
*/
public mouseenter = (event: Selection) => {
if (this.options.trigger === 'click') return;
const composeId = get(event, [this.typePrefix, 'id']);
if (!this.selectedItems.includes(composeId)) {
this.selectedItems.push(composeId);
this.changeState(event, 'active');
} else {
this.selectedItems = this.selectedItems.filter((item) => item !== composeId);
}
};
/**
* <zh/> 刷新图例元素状态
*
* <en/> Refresh the status of the legend element
*/
public updateElement() {
if (!this.category) return;
this.category.update({
itemMarkerOpacity: ({ id }) => {
if (!this.selectedItems.length || this.selectedItems.includes(id)) return 1;
return 0.5;
},
itemLabelOpacity: ({ id }) => {
if (!this.selectedItems.length || this.selectedItems.includes(id)) return 1;
return 0.5;
},
});
}
private setFieldMap = (field: string, id: ID, type: ElementType) => {
if (!field) return;
const map = this.fieldMap[type];
if (!map) return;
if (!map.has(field)) {
map.set(field, [id]);
} else {
const ids = map.get(field);
if (ids) {
ids.push(id);
map.set(field, ids);
}
}
};
private getEvents = () => {
return {
mouseenter: this.mouseenter,
mouseleave: this.mouseleave,
click: this.click,
};
};
private getMarkerData = (field: string | ((item: ElementDatum) => string), elementType: ElementType) => {
if (!field) return [];
const { model, element } = this.context;
const { nodes, edges, combos } = model.getData();
const items: { [key: string]: Datum } = {};
const getField = (item: ElementDatum) => {
if (isFunction(field)) return field(item);
return field;
};
const defaultType = {
node: 'circle',
edge: 'line',
combo: 'rect',
};
// 用于将 G6 element 转换为 components 支持的类型
// Used to convert G6 element to types supported by components
const markerMapping: { [key: string]: string } = {
circle: 'circle',
ellipse: 'circle', // 待 components 支持 ellipse
image: 'bowtie',
rect: 'square',
star: 'cross',
triangle: 'triangle',
diamond: 'diamond',
cubic: 'dot',
line: 'hyphen',
polyline: 'hyphen',
quadratic: 'hv',
'cubic-horizontal': 'hyphen',
'cubic-vertical': 'line',
};
const getElementStyle = (type: ElementType, datum: ElementDatum) => {
const style = element?.getElementComputedStyle(type, datum);
return style;
};
const getElementModel = (data: ElementDatum[], type: ElementType) => {
data.forEach((item) => {
const { id } = item;
const value = get(item, ['data', getField(item)]);
const marker = element?.getElementType(type, item) || 'circle';
const style = getElementStyle(type, item);
const color = (type === 'edge' ? style?.stroke : style?.fill) || '#1783ff';
if (id && value && value.replace(/\s+/g, '')) {
this.setFieldMap(value, id, type);
if (!items[value]) {
items[value] = {
id: `${type}__${id}`,
label: value,
marker: markerMapping[marker] || defaultType[type],
elementType: type,
lineWidth: 1,
stroke: color,
fill: color,
};
}
}
});
};
switch (elementType) {
case 'node':
getElementModel(nodes, 'node');
break;
case 'edge':
getElementModel(edges, 'edge');
break;
case 'combo':
getElementModel(combos, 'combo');
break;
default:
return [];
}
return Object.values(items);
};
private upsertCanvas() {
if (this.canvas) return this.canvas;
const graphCanvas = this.context.canvas;
const [canvasWidth, canvasHeight] = graphCanvas.getSize();
const { width = canvasWidth, height = canvasHeight, position, container, containerStyle, className } = this.options;
const [$container, canvas] = createPluginCanvas({
width,
height,
graphCanvas,
container,
containerStyle,
placement: position,
className: 'legend',
});
this.container = $container;
if (className) $container.classList.add(className);
this.canvas = canvas;
return this.canvas;
}
private createElement = () => {
if (this.draw) {
this.updateElement();
return;
}
const {
width,
height,
nodeField,
edgeField,
comboField,
trigger,
position,
container,
containerStyle,
className,
...rest
} = this.options;
const nodeItems = this.getMarkerData(nodeField, 'node');
const edgeItems = this.getMarkerData(edgeField, 'edge');
const comboItems = this.getMarkerData(comboField, 'combo');
const items = [...nodeItems, ...comboItems, ...edgeItems];
const categoryStyle = Object.assign(
{
width,
height,
data: items,
itemMarkerLineWidth: ({ lineWidth }: Datum) => lineWidth,
itemMarker: ({ marker }: Datum) => marker,
itemMarkerStroke: ({ stroke }: Datum) => stroke,
itemMarkerFill: ({ fill }: Datum) => fill,
gridCol: nodeItems.length,
},
rest,
this.getEvents(),
);
const category = new Category({
className: 'legend',
style: categoryStyle,
});
this.category = category;
const canvas = this.upsertCanvas();
canvas.appendChild(category);
this.draw = true;
};
/**
* <zh/>销毁图例
*
* <en/> Destroy the legend
* @internal
*/
public destroy(): void {
this.clear();
this.context.graph.off(GraphEvent.AFTER_DRAW, this.createElement);
super.destroy();
}
}