UNPKG

@antv/g6

Version:

A Graph Visualization Framework in JavaScript

465 lines (415 loc) 14.9 kB
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; }