@antv/g6
Version:
A Graph Visualization Framework in JavaScript
465 lines (415 loc) • 14.9 kB
text/typescript
import type { BaseStyleProps, CircleStyleProps, DisplayObject, DisplayObjectConfig, Group } from '@antv/g';
import { Circle as GCircle } from '@antv/g';
import type { CategoricalPalette } from '../../palettes/types';
import type { RuntimeContext } from '../../runtime/types';
import type { NodeData } from '../../spec';
import type {
ID,
Node,
NodeBadgeStyleProps,
NodeLabelStyleProps,
NodePortStyleProps,
Point,
Port,
PortPlacement,
PortStyleProps,
Prefix,
Size,
} from '../../types';
import { getPortXYByPlacement, getTextStyleByPlacement, isSimplePort } from '../../utils/element';
import { inferIconStyle } from '../../utils/node';
import { getPaletteColors } from '../../utils/palette';
import { getRectIntersectPoint } from '../../utils/point';
import { omitStyleProps, subObject, subStyleProps } from '../../utils/prefix';
import { parseSize } from '../../utils/size';
import { mergeOptions } from '../../utils/style';
import { getWordWrapWidthByBox } from '../../utils/text';
import { setVisibility } from '../../utils/visibility';
import { BaseElement } from '../base-element';
import type { BadgeStyleProps, BaseShapeStyleProps, IconStyleProps, LabelStyleProps } from '../shapes';
import { Badge, Icon, Label } from '../shapes';
import { connectImage, dispatchPositionChange } from '../shapes/image';
/**
* <zh/> 节点通用样式配置项
*
* <en/> Base node style props
*/
export interface BaseNodeStyleProps
extends BaseShapeStyleProps,
Prefix<'label', NodeLabelStyleProps>,
Prefix<'halo', BaseStyleProps>,
Prefix<'icon', IconStyleProps>,
Prefix<'badge', BadgeStyleProps>,
Prefix<'port', PortStyleProps> {
/**
* <zh/> x 坐标
*
* <en/> The x-coordinate of node
*/
x?: number;
/**
* <zh/> y 坐标
*
* <en/> The y-coordinate of node
*/
y?: number;
/**
* <zh/> z 坐标
*
* <en/> The z-coordinate of node
*/
z?: number;
/**
* <zh/> 节点大小,快捷设置节点宽高
* - 若值为数字,则表示节点的宽度、高度以及深度相同为指定值
* - 若值为数组,则按数组元素依次表示节点的宽度、高度以及深度
*
* <en/> The size of node, which is a shortcut to set the width and height of node
* - If the value is a number, it means the width, height, and depth of the node are the same as the specified value
* - If the value is an array, it means the width, height, and depth of the node are represented by the array elements in turn
*/
size?: Size;
/**
* <zh/> 当前节点/组合是否展开
*
* <en/> Whether the current node/combo is expanded
*/
collapsed?: boolean;
/**
* <zh/> 子节点实例
*
* <en/> The instance of the child node
* @remarks
* <zh/> 仅在树图中生效
*
* <en/> Only valid in the tree graph
* @ignore
*/
childrenNode?: ID[];
/**
* <zh/> 子节点数据
*
* <en/> The data of the child node
* @remarks
* <zh/> 仅在树图中生效。如果当前节点为收起状态,children 可能为空,通过 childrenData 能够获取完整的子元素数据
*
* <en/> Only valid in the tree graph. If the current node is collapsed, children may be empty, and the complete child element data can be obtained through childrenData
* @ignore
*/
childrenData?: NodeData[];
/**
* <zh/> 是否显示节点标签
*
* <en/> Whether to show the node label
* @defaultValue true
*/
label?: boolean;
/**
* <zh/> 是否显示节点光晕
*
* <en/> Whether to show the node halo
* @defaultValue false
*/
halo?: boolean;
/**
* <zh/> 是否显示节点图标
*
* <en/> Whether to show the node icon
* @defaultValue true
*/
icon?: boolean;
/**
* <zh/> 是否显示节点徽标
*
* <en/> Whether to show the node badge
* @defaultValue true
*/
badge?: boolean;
/**
* <zh/> 是否显示连接桩
*
* <en/> Whether to show the node port
* @defaultValue true
*/
port?: boolean;
/**
* <zh/> 连接桩配置项,支持配置多个连接桩
*
* <en/> Port configuration, supports configuring multiple ports
* @example
* ```json
* {
* port: true,
* ports: [
* { key: 'top', placement: [0.5, 0], r: 4, stroke: '#31d0c6', fill: '#fff' },
* { key: 'bottom', placement: [0.5, 1], r: 4, stroke: '#31d0c6', fill: '#fff' },
* ],
* }
* ```
*/
ports?: NodePortStyleProps[];
/**
* <zh/> 徽标
*
* <en/> Badge
* @example
* ```json
* {
* badge: true,
* badges: [
* { text: 'A', placement: 'right-top'},
* { text: 'Important', placement: 'right' },
* { text: 'Notice', placement: 'right-bottom' },
* ],
* badgePalette: ['#7E92B5', '#F4664A', '#FFBE3A'],
* }
* ```
*/
badges?: NodeBadgeStyleProps[];
/**
* <zh/> 徽标的背景色板
*
* <en/> Badge background color palette
*/
badgePalette?: CategoricalPalette;
}
/**
* <zh/> 节点元素的基类
*
* <en/> Base node class
* @remarks
* <zh/> 自定义节点时,建议将此类作为基类。这样,你只需要关注如何实现 keyShape 的绘制逻辑
*
* <zh/> 设计文档:https://www.yuque.com/antv/g6/gl1iof1xpzg6ed98
*
* <en/> When customizing a node, it is recommended to use this class as the base class. This way, you can directly focus on how to implement the drawing logic of keyShape
*
* <en/> Design document: https://www.yuque.com/antv/g6/gl1iof1xpzg6ed98
*/
export abstract class BaseNode<S extends BaseNodeStyleProps = BaseNodeStyleProps>
extends BaseElement<S>
implements Node
{
public type = 'node';
static defaultStyleProps: Partial<BaseNodeStyleProps> = {
x: 0,
y: 0,
size: 32,
droppable: true,
draggable: true,
port: true,
ports: [],
portZIndex: 2,
portLinkToCenter: false,
badge: true,
badges: [],
badgeZIndex: 3,
halo: false,
haloDroppable: false,
haloLineDash: 0,
haloLineWidth: 12,
haloStrokeOpacity: 0.25,
haloPointerEvents: 'none',
haloZIndex: -1,
icon: true,
iconZIndex: 1,
label: true,
labelIsBillboard: true,
labelMaxWidth: '200%',
labelPlacement: 'bottom',
labelWordWrap: false,
labelZIndex: 0,
};
constructor(options: DisplayObjectConfig<S>) {
super(mergeOptions({ style: BaseNode.defaultStyleProps }, options));
}
protected getSize(attributes = this.attributes) {
const { size } = attributes;
return parseSize(size);
}
protected getKeyStyle(attributes: Required<S>) {
const style = this.getGraphicStyle(attributes);
return Object.assign(omitStyleProps(style, ['label', 'halo', 'icon', 'badge', 'port'])) as any;
}
protected getLabelStyle(attributes: Required<S>): false | LabelStyleProps {
if (attributes.label === false || !attributes.labelText) return false;
const { placement, maxWidth, offsetX, offsetY, ...labelStyle } = subStyleProps<Required<NodeLabelStyleProps>>(
this.getGraphicStyle(attributes),
'label',
);
const keyBounds = this.getShape('key').getLocalBounds();
return Object.assign(
getTextStyleByPlacement(keyBounds, placement, offsetX, offsetY),
{ wordWrapWidth: getWordWrapWidthByBox(keyBounds, maxWidth) },
labelStyle,
);
}
protected getHaloStyle(attributes: Required<S>) {
if (attributes.halo === false) return false;
const { fill, ...keyStyle } = this.getKeyStyle(attributes);
const haloStyle = subStyleProps(this.getGraphicStyle(attributes), 'halo');
return { ...keyStyle, stroke: fill, ...haloStyle } as any;
}
protected getIconStyle(attributes: Required<S>): false | IconStyleProps {
if (attributes.icon === false || (!attributes.iconText && !attributes.iconSrc)) return false;
const iconStyle = subStyleProps(this.getGraphicStyle(attributes), 'icon');
return Object.assign(inferIconStyle(attributes.size!, iconStyle), iconStyle);
}
protected getBadgesStyle(attributes: Required<S>): Record<string, NodeBadgeStyleProps | false> {
const badges = subObject(this.shapeMap, 'badge-');
const badgesShapeStyle: Record<string, NodeBadgeStyleProps | false> = {};
Object.keys(badges).forEach((key) => {
badgesShapeStyle[key] = false;
});
if (attributes.badge === false || !attributes.badges?.length) return badgesShapeStyle;
const { badges: badgeOptions = [], badgePalette, opacity = 1, ...restAttributes } = attributes;
const colors = getPaletteColors(badgePalette);
const badgeStyle = subStyleProps<BadgeStyleProps>(this.getGraphicStyle(restAttributes), 'badge');
badgeOptions.forEach((option, i) => {
badgesShapeStyle[i] = {
backgroundFill: colors ? colors[i % colors?.length] : undefined,
opacity,
...badgeStyle,
...this.getBadgeStyle(option),
};
});
return badgesShapeStyle;
}
protected getBadgeStyle(style: NodeBadgeStyleProps): NodeBadgeStyleProps {
const keyShape = this.getShape('key');
const { placement = 'top', offsetX, offsetY, ...restStyle } = style;
const textStyle = getTextStyleByPlacement(keyShape.getLocalBounds(), placement, offsetX, offsetY, true);
return { ...textStyle, ...restStyle };
}
protected getPortsStyle(attributes: Required<S>): Record<string, PortStyleProps | false> {
const ports = this.getPorts();
const portsShapeStyle: Record<string, PortStyleProps | false> = {};
Object.keys(ports).forEach((key) => {
portsShapeStyle[key] = false;
});
if (attributes.port === false || !attributes.ports?.length) return portsShapeStyle;
const portStyle = subStyleProps<PortStyleProps>(this.getGraphicStyle(attributes), 'port');
const { ports: portOptions = [] } = attributes;
portOptions.forEach((option, index) => {
const key = option.key || index;
const mergedStyle = { ...portStyle, ...option };
if (isSimplePort(mergedStyle)) {
portsShapeStyle[key] = false;
} else {
const [x, y] = this.getPortXY(attributes, option);
portsShapeStyle[key] = { transform: [['translate', x, y]], ...mergedStyle };
}
});
return portsShapeStyle;
}
protected getPortXY(attributes: Required<S>, style: NodePortStyleProps): Point {
const { placement = 'left' } = style;
const keyShape = this.getShape('key');
return getPortXYByPlacement(getBoundsInOffscreen(this.context, keyShape), placement as PortPlacement);
}
/**
* Get the ports for the node.
* @returns Ports shape map.
*/
public getPorts(): Record<string, Port> {
return subObject(this.shapeMap, 'port-');
}
/**
* Get the center point of the node.
* @returns The center point of the node.
*/
public getCenter(): Point {
return this.getShape('key').getBounds().center;
}
/**
* Get the point on the outer contour of the node that is the intersection with a line starting in the center the ending in the point `p`.
* @param point - The point to intersect with the node.
* @param useExtendedLine - Whether to use the extended line.
* @returns The intersection point.
*/
public getIntersectPoint(point: Point, useExtendedLine = false): Point {
const keyShapeBounds = this.getShape('key').getBounds();
return getRectIntersectPoint(point, keyShapeBounds, useExtendedLine);
}
protected drawHaloShape(attributes: Required<S>, container: Group): void {
const style = this.getHaloStyle(attributes);
const keyShape = this.getShape('key');
this.upsert('halo', keyShape.constructor as new (...args: unknown[]) => DisplayObject, style, container);
}
protected drawIconShape(attributes: Required<S>, container: Group): void {
const style = this.getIconStyle(attributes);
this.upsert('icon', Icon, style, container);
connectImage(this);
}
protected drawBadgeShapes(attributes: Required<S>, container: Group): void {
const badgesStyle = this.getBadgesStyle(attributes);
Object.keys(badgesStyle).forEach((key) => {
const style = badgesStyle[key];
this.upsert(`badge-${key}`, Badge, style, container);
});
}
protected drawPortShapes(attributes: Required<S>, container: Group): void {
const portsStyle = this.getPortsStyle(attributes);
Object.keys(portsStyle).forEach((key) => {
const style = portsStyle[key] as CircleStyleProps;
const shapeKey = `port-${key}`;
this.upsert(shapeKey, GCircle, style, container);
});
}
protected drawLabelShape(attributes: Required<S>, container: Group): void {
const style = this.getLabelStyle(attributes);
this.upsert('label', Label, style, container);
}
protected abstract drawKeyShape(attributes: Required<S>, container: Group): DisplayObject | undefined;
// 用于装饰抽象方法 / Used to decorate abstract methods
private _drawKeyShape(attributes: Required<S>, container: Group) {
return this.drawKeyShape(attributes, container);
}
public render(attributes = this.parsedAttributes, container: Group = this) {
// 1. key shape
this._drawKeyShape(attributes, container);
if (!this.getShape('key')) return;
// 2. halo, use shape same with keyShape
this.drawHaloShape(attributes, container);
// 3. icon
this.drawIconShape(attributes, container);
// 4. badges
this.drawBadgeShapes(attributes, container);
// 5. label
this.drawLabelShape(attributes, container);
// 6. ports
this.drawPortShapes(attributes, container);
}
public update(attr?: Partial<S>): void {
super.update(attr);
if (attr && ('x' in attr || 'y' in attr || 'z' in attr)) {
dispatchPositionChange(this);
}
}
protected onframe() {
this.drawBadgeShapes(this.parsedAttributes, this);
this.drawLabelShape(this.parsedAttributes, this);
}
}
/**
* <zh/> 在离屏画布中获取图形包围盒
*
* <en/> Get the bounding box of the shape in the off-screen canvas
* @param context - <zh/> 运行时上下文 <en/> Runtime context
* @param shape - <zh/> 图形实例 <en/> Graphic instance
* @returns <zh/> 图形包围盒 <en/> Graphic bounding box
*/
function getBoundsInOffscreen(context: RuntimeContext, shape: DisplayObject) {
if (!context) return shape.getLocalBounds();
// 将主图形靠背至全局空间,避免受到父级 transform 的影响
// 合理的操作应该是拷贝至离屏画布,但目前 G 有点问题
// Move the main shape to the global space to avoid being affected by the parent transform
// The reasonable operation should be moved to the off-screen canvas, but there is a problem with G at present
const canvas = context.canvas.getLayer();
const substitute = shape.cloneNode();
setVisibility(substitute, 'hidden');
canvas.appendChild(substitute);
const bounds = substitute.getLocalBounds();
substitute.destroy();
return bounds;
}