|
import { Rect } from '@antv/g'; |
|
import { |
|
Badge, |
|
BaseBehavior, |
|
BaseNode, |
|
CommonEvent, |
|
ExtensionCategory, |
|
Graph, |
|
NodeEvent, |
|
Point, |
|
Polyline, |
|
PolylineStyleProps, |
|
register, |
|
subStyleProps, |
|
treeToGraphData, |
|
} from '@antv/g6'; |
|
import { TreeData } from '@antv/g6/lib/types'; |
|
import isEmpty from 'lodash/isEmpty'; |
|
import React, { useCallback, useEffect, useRef } from 'react'; |
|
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; |
|
|
|
const rootId = 'root'; |
|
|
|
const COLORS = [ |
|
'#5B8FF9', |
|
'#F6BD16', |
|
'#5AD8A6', |
|
'#945FB9', |
|
'#E86452', |
|
'#6DC8EC', |
|
'#FF99C3', |
|
'#1E9493', |
|
'#FF9845', |
|
'#5D7092', |
|
]; |
|
|
|
const TreeEvent = { |
|
COLLAPSE_EXPAND: 'collapse-expand', |
|
WHEEL: 'canvas:wheel', |
|
}; |
|
|
|
class IndentedNode extends BaseNode { |
|
static defaultStyleProps = { |
|
ports: [ |
|
{ |
|
key: 'in', |
|
placement: 'right-bottom', |
|
}, |
|
{ |
|
key: 'out', |
|
placement: 'left-bottom', |
|
}, |
|
], |
|
} as any; |
|
|
|
constructor(options: any) { |
|
Object.assign(options.style, IndentedNode.defaultStyleProps); |
|
super(options); |
|
} |
|
|
|
get childrenData() { |
|
return this.attributes.context?.model.getChildrenData(this.id); |
|
} |
|
|
|
getKeyStyle(attributes: any) { |
|
const [width, height] = this.getSize(attributes); |
|
const keyStyle = super.getKeyStyle(attributes); |
|
return { |
|
width, |
|
height, |
|
...keyStyle, |
|
fill: 'transparent', |
|
}; |
|
} |
|
|
|
drawKeyShape(attributes: any, container: any) { |
|
const keyStyle = this.getKeyStyle(attributes); |
|
return this.upsert('key', Rect, keyStyle, container); |
|
} |
|
|
|
getLabelStyle(attributes: any) { |
|
if (attributes.label === false || !attributes.labelText) return false; |
|
return subStyleProps(this.getGraphicStyle(attributes), 'label') as any; |
|
} |
|
|
|
drawIconArea(attributes: any, container: any) { |
|
const [, h] = this.getSize(attributes); |
|
const iconAreaStyle = { |
|
fill: 'transparent', |
|
height: 30, |
|
width: 12, |
|
x: -6, |
|
y: h, |
|
zIndex: -1, |
|
}; |
|
this.upsert('icon-area', Rect, iconAreaStyle, container); |
|
} |
|
|
|
forwardEvent(target: any, type: any, listener: any) { |
|
if (target && !Reflect.has(target, '__bind__')) { |
|
Reflect.set(target, '__bind__', true); |
|
target.addEventListener(type, listener); |
|
} |
|
} |
|
|
|
getCountStyle(attributes: any) { |
|
const { collapsed, color } = attributes; |
|
if (collapsed) { |
|
const [, height] = this.getSize(attributes); |
|
return { |
|
backgroundFill: color, |
|
cursor: 'pointer', |
|
fill: '#fff', |
|
fontSize: 8, |
|
padding: [0, 10], |
|
text: `${this.childrenData?.length}`, |
|
textAlign: 'center', |
|
y: height + 8, |
|
}; |
|
} |
|
|
|
return false; |
|
} |
|
|
|
drawCountShape(attributes: any, container: any) { |
|
const countStyle = this.getCountStyle(attributes); |
|
const btn = this.upsert('count', Badge, countStyle as any, container); |
|
|
|
this.forwardEvent(btn, CommonEvent.CLICK, (event: any) => { |
|
event.stopPropagation(); |
|
attributes.context.graph.emit(TreeEvent.COLLAPSE_EXPAND, { |
|
id: this.id, |
|
collapsed: false, |
|
}); |
|
}); |
|
} |
|
|
|
isShowCollapse(attributes: any) { |
|
return ( |
|
!attributes.collapsed && |
|
Array.isArray(this.childrenData) && |
|
this.childrenData?.length > 0 |
|
); |
|
} |
|
|
|
getCollapseStyle(attributes: any) { |
|
const { showIcon, color } = attributes; |
|
if (!this.isShowCollapse(attributes)) return false; |
|
const [, height] = this.getSize(attributes); |
|
return { |
|
visibility: showIcon ? 'visible' : 'hidden', |
|
backgroundFill: color, |
|
backgroundHeight: 12, |
|
backgroundWidth: 12, |
|
cursor: 'pointer', |
|
fill: '#fff', |
|
fontFamily: 'iconfont', |
|
fontSize: 8, |
|
text: '\ue6e4', |
|
textAlign: 'center', |
|
x: -1, |
|
y: height + 8, |
|
}; |
|
} |
|
|
|
drawCollapseShape(attributes: any, container: any) { |
|
const iconStyle = this.getCollapseStyle(attributes); |
|
const btn = this.upsert( |
|
'collapse-expand', |
|
Badge, |
|
iconStyle as any, |
|
container, |
|
); |
|
|
|
this.forwardEvent(btn, CommonEvent.CLICK, (event: any) => { |
|
event.stopPropagation(); |
|
attributes.context.graph.emit(TreeEvent.COLLAPSE_EXPAND, { |
|
id: this.id, |
|
collapsed: !attributes.collapsed, |
|
}); |
|
}); |
|
} |
|
|
|
getAddStyle(attributes: any) { |
|
const { collapsed, showIcon } = attributes; |
|
if (collapsed) return false; |
|
const [, height] = this.getSize(attributes); |
|
const color = '#ddd'; |
|
const lineWidth = 1; |
|
|
|
return { |
|
visibility: showIcon ? 'visible' : 'hidden', |
|
backgroundFill: '#fff', |
|
backgroundHeight: 12, |
|
backgroundLineWidth: lineWidth, |
|
backgroundStroke: color, |
|
backgroundWidth: 12, |
|
cursor: 'pointer', |
|
fill: color, |
|
fontFamily: 'iconfont', |
|
text: '\ue664', |
|
textAlign: 'center', |
|
x: -1, |
|
y: height + (this.isShowCollapse(attributes) ? 22 : 8), |
|
}; |
|
} |
|
|
|
render(attributes = this.parsedAttributes, container = this) { |
|
super.render(attributes, container); |
|
|
|
this.drawCountShape(attributes, container); |
|
|
|
this.drawIconArea(attributes, container); |
|
this.drawCollapseShape(attributes, container); |
|
} |
|
} |
|
|
|
class IndentedEdge extends Polyline { |
|
getControlPoints( |
|
attributes: Required<PolylineStyleProps>, |
|
sourcePoint: Point, |
|
targetPoint: Point, |
|
) { |
|
const [sx] = sourcePoint; |
|
const [, ty] = targetPoint; |
|
return [[sx, ty]] as any; |
|
} |
|
} |
|
|
|
class CollapseExpandTree extends BaseBehavior { |
|
constructor(context: any, options: any) { |
|
super(context, options); |
|
this.bindEvents(); |
|
} |
|
|
|
update(options: any) { |
|
this.unbindEvents(); |
|
super.update(options); |
|
this.bindEvents(); |
|
} |
|
|
|
bindEvents() { |
|
const { graph } = this.context; |
|
|
|
graph.on(NodeEvent.POINTER_ENTER, this.showIcon); |
|
graph.on(NodeEvent.POINTER_LEAVE, this.hideIcon); |
|
graph.on(TreeEvent.COLLAPSE_EXPAND, this.onCollapseExpand); |
|
} |
|
|
|
unbindEvents() { |
|
const { graph } = this.context; |
|
|
|
graph.off(NodeEvent.POINTER_ENTER, this.showIcon); |
|
graph.off(NodeEvent.POINTER_LEAVE, this.hideIcon); |
|
graph.off(TreeEvent.COLLAPSE_EXPAND, this.onCollapseExpand); |
|
} |
|
|
|
status = 'idle'; |
|
|
|
showIcon = (event: any) => { |
|
this.setIcon(event, true); |
|
}; |
|
|
|
hideIcon = (event: any) => { |
|
this.setIcon(event, false); |
|
}; |
|
|
|
setIcon = (event: any, show: boolean) => { |
|
if (this.status !== 'idle') return; |
|
const { target } = event; |
|
const id = target.id; |
|
const { graph, element } = this.context; |
|
graph.updateNodeData([{ id, style: { showIcon: show } }]); |
|
element?.draw({ animation: false, silence: true }); |
|
}; |
|
|
|
onCollapseExpand = async (event: any) => { |
|
this.status = 'busy'; |
|
const { id, collapsed } = event; |
|
const { graph } = this.context; |
|
if (collapsed) await graph.collapseElement(id); |
|
else await graph.expandElement(id); |
|
this.status = 'idle'; |
|
}; |
|
} |
|
|
|
register(ExtensionCategory.NODE, 'indented', IndentedNode); |
|
register(ExtensionCategory.EDGE, 'indented', IndentedEdge); |
|
register( |
|
ExtensionCategory.BEHAVIOR, |
|
'collapse-expand-tree', |
|
CollapseExpandTree, |
|
); |
|
|
|
interface IProps { |
|
data: TreeData; |
|
show: boolean; |
|
style?: React.CSSProperties; |
|
} |
|
|
|
function fallbackRender({ error }: FallbackProps) { |
|
|
|
|
|
return ( |
|
<div role="alert"> |
|
<p>Something went wrong:</p> |
|
<pre style={{ color: 'red' }}>{error.message}</pre> |
|
</div> |
|
); |
|
} |
|
|
|
const IndentedTree = ({ data, show, style = {} }: IProps) => { |
|
const containerRef = useRef<HTMLDivElement>(null); |
|
const graphRef = useRef<Graph | null>(null); |
|
const assignIds = React.useCallback(function assignIds( |
|
node: TreeData, |
|
parentId: string = '', |
|
index = 0, |
|
) { |
|
if (!node.id) node.id = parentId ? `${parentId}-${index}` : 'root'; |
|
if (node.children) { |
|
node.children.forEach((child, idx) => assignIds(child, node.id, idx)); |
|
} |
|
}, []); |
|
|
|
const render = useCallback( |
|
async (data: TreeData) => { |
|
const graph: Graph = new Graph({ |
|
container: containerRef.current!, |
|
x: 60, |
|
node: { |
|
type: 'indented', |
|
style: { |
|
size: (d) => [d.id.length * 6 + 10, 20], |
|
labelBackground: (datum) => datum.id === rootId, |
|
labelBackgroundRadius: 0, |
|
labelBackgroundFill: '#576286', |
|
labelFill: (datum) => (datum.id === rootId ? '#fff' : '#666'), |
|
labelText: (d) => d.style?.labelText || d.id, |
|
labelTextAlign: (datum) => |
|
datum.id === rootId ? 'center' : 'left', |
|
labelTextBaseline: 'top', |
|
color: (datum: any) => { |
|
const depth = graph.getAncestorsData(datum.id, 'tree').length - 1; |
|
return COLORS[depth % COLORS.length] || '#576286'; |
|
}, |
|
}, |
|
state: { |
|
selected: { |
|
lineWidth: 0, |
|
labelFill: '#40A8FF', |
|
labelBackground: true, |
|
labelFontWeight: 'normal', |
|
labelBackgroundFill: '#e8f7ff', |
|
labelBackgroundRadius: 10, |
|
}, |
|
}, |
|
}, |
|
edge: { |
|
type: 'indented', |
|
style: { |
|
radius: 16, |
|
lineWidth: 2, |
|
sourcePort: 'out', |
|
targetPort: 'in', |
|
stroke: (datum: any) => { |
|
const depth = graph.getAncestorsData(datum.source, 'tree').length; |
|
return COLORS[depth % COLORS.length] || 'black'; |
|
}, |
|
}, |
|
}, |
|
layout: { |
|
type: 'indented', |
|
direction: 'LR', |
|
isHorizontal: true, |
|
indent: 40, |
|
getHeight: () => 20, |
|
getVGap: () => 10, |
|
}, |
|
behaviors: [ |
|
'scroll-canvas', |
|
'collapse-expand-tree', |
|
{ |
|
type: 'click-select', |
|
enable: (event: any) => |
|
event.targetType === 'node' && event.target.id !== rootId, |
|
}, |
|
], |
|
}); |
|
|
|
if (graphRef.current) { |
|
graphRef.current.destroy(); |
|
} |
|
|
|
graphRef.current = graph; |
|
|
|
assignIds(data); |
|
|
|
graph?.setData(treeToGraphData(data)); |
|
|
|
graph?.render(); |
|
}, |
|
[assignIds], |
|
); |
|
|
|
useEffect(() => { |
|
if (!isEmpty(data)) { |
|
render(data); |
|
} |
|
}, [render, data]); |
|
|
|
return ( |
|
<ErrorBoundary fallbackRender={fallbackRender}> |
|
<div |
|
id="tree" |
|
ref={containerRef} |
|
style={{ |
|
width: '90vw', |
|
height: '80vh', |
|
display: show ? 'block' : 'none', |
|
...style, |
|
}} |
|
/> |
|
</ErrorBoundary> |
|
); |
|
}; |
|
|
|
export default IndentedTree; |
|
|