diff --git a/packages/g6/__tests__/demos/element-html-sub-graph.ts b/packages/g6/__tests__/demos/element-html-sub-graph.ts new file mode 100644 index 00000000000..fe68f40dcc0 --- /dev/null +++ b/packages/g6/__tests__/demos/element-html-sub-graph.ts @@ -0,0 +1,394 @@ +import type { DisplayObject, Group } from '@antv/g'; +import type { BaseComboStyleProps, GraphData, HTMLStyleProps, IElementEvent, NodeData } from '@antv/g6'; +import { BaseCombo, effect, ExtensionCategory, Graph, HTML, isCollapsed, register } from '@antv/g6'; + +export const elementHTMLSubGraph: TestCase = async (context) => { + interface CardNodeData { + type: 'card'; + status: 'expanded' | 'collapsed'; + data: { name: string; value: number }[]; + children: CardNodeData[] | [GraphNodeData]; + } + interface GraphNodeData { + type: 'graph'; + data: GraphData; + } + type Data = CardNodeData | GraphNodeData; + + const getSize = (d: NodeData) => { + const data = d.data as unknown as Data; + if (data.type === 'card') return data.status === 'expanded' ? [200, 100 * data.children.length] : [200, 100]; + else return [200, 200]; + }; + + class SubGraph extends HTML { + public connectedCallback(): void { + super.connectedCallback(); + this.drawSubGraph(); + } + + public render(attributes?: Required, container?: Group): void { + super.render(attributes, container); + this.drawSubGraph(); + } + + private get data() { + return this.context.graph.getElementData(this.id).data; + } + + private graph?: Graph; + + @effect((self, attributes) => { + const { data } = self.data; + return { data }; + }) + private drawSubGraph() { + if (!this.isConnected) return; + const data = this.data; + this.drawGraphNode(data!.data as GraphData); + } + + private drawGraphNode(data: GraphData) { + const [width, height] = this.getSize(); + const container = this.getDomElement(); + container.innerHTML = ''; + + const subGraph = new Graph({ + container, + width, + height, + animation: false, + data: data, + node: { + style: { + labelText: (d) => d.id, + iconFontFamily: 'iconfont', + iconText: '\ue6e5', + }, + }, + layout: { + type: 'force', + linkDistance: 50, + }, + behaviors: ['zoom-canvas', { type: 'drag-canvas', enable: (event: MouseEvent) => event.shiftKey === true }], + autoFit: 'view', + }); + + subGraph.render(); + + this.graph = subGraph; + } + + public destroy(): void { + this.graph?.destroy(); + super.destroy(); + } + } + + class CardCombo extends BaseCombo { + protected getKeyStyle(attributes: Required) { + const keyStyle = super.getKeyStyle(attributes); + const [width, height] = this.getKeySize(attributes); + return { + ...keyStyle, + width, + height, + x: -width / 2, + y: -height / 2, + }; + } + + protected drawKeyShape(attributes: Required, container: Group): DisplayObject | undefined { + const { collapsed } = attributes; + const outer = this.upsert('key', 'rect', this.getKeyStyle(attributes), container); + if (!outer || !collapsed) { + this.removeCardShape(); + return outer; + } + + this.drawCardShape(attributes, container); + + return outer; + } + + protected drawCardShape(attributes: Required, container: Group) { + const [width, height] = this.getCollapsedKeySize(attributes); + const data = this.context.graph.getComboData(this.id).data as unknown as CardNodeData; + + const baseX = -width / 2; + const baseY = -height / 2; + + this.upsert( + 'card-title', + 'text', + { + x: baseX, + y: baseY, + text: '点分组: ' + this.id, + textAlign: 'left', + textBaseline: 'top', + fontSize: 16, + fontWeight: 'bold', + fill: '#4083f7', + }, + container, + ); + + const gap = 10; + const sep = (width + gap) / data.data.length; + data.data.forEach(({ name, value }, index) => { + this.upsert( + `card-item-name-${index}`, + 'text', + { + x: baseX + index * sep, + y: baseY + 40, + text: name, + textAlign: 'left', + textBaseline: 'top', + fontSize: 12, + fill: 'gray', + }, + container, + ); + this.upsert( + `card-item-value-${index}`, + 'text', + { + x: baseX + index * sep, + y: baseY + 60, + text: value + '%', + textAlign: 'left', + textBaseline: 'top', + fontSize: 24, + }, + container, + ); + }); + } + + protected removeCardShape() { + Object.entries(this.shapeMap).forEach(([key, shape]) => { + if (key.startsWith('card-')) { + delete this.shapeMap[key]; + shape.destroy(); + } + }); + } + } + + register(ExtensionCategory.NODE, 'sub-graph', SubGraph); + register(ExtensionCategory.COMBO, 'card', CardCombo); + + const graph = new Graph({ + ...context, + animation: false, + zoom: 0.8, + data: { + nodes: [ + { + id: 'node-1', + combo: 'combo-1-1', + style: { x: 120, y: 70 }, + data: { + data: { + nodes: [ + { id: 'node-1' }, + { id: 'node-2' }, + { id: 'node-3' }, + { id: 'node-4' }, + { id: 'node-5' }, + { id: 'node-6' }, + { id: 'node-7' }, + { id: 'node-8' }, + ], + edges: [ + { source: 'node-1', target: 'node-2' }, + { source: 'node-1', target: 'node-3' }, + { source: 'node-1', target: 'node-4' }, + { source: 'node-1', target: 'node-5' }, + { source: 'node-1', target: 'node-6' }, + { source: 'node-1', target: 'node-7' }, + { source: 'node-1', target: 'node-8' }, + ], + }, + }, + }, + { + id: 'node-2', + combo: 'combo-1-2', + style: { x: 370, y: 70 }, + data: { + data: { + nodes: [{ id: 'node-1' }, { id: 'node-2' }, { id: 'node-3' }, { id: 'node-4' }], + edges: [ + { source: 'node-1', target: 'node-2' }, + { source: 'node-1', target: 'node-3' }, + { source: 'node-1', target: 'node-4' }, + ], + }, + }, + }, + { + id: 'node-3', + combo: 'combo-1-3-1', + style: { x: 120, y: 220 }, + data: { + data: { + nodes: [{ id: 'node-1' }, { id: 'node-2' }, { id: 'node-3' }, { id: 'node-4' }], + edges: [ + { source: 'node-1', target: 'node-2' }, + { source: 'node-1', target: 'node-3' }, + { source: 'node-1', target: 'node-4' }, + ], + }, + }, + }, + { + id: 'node-4', + combo: 'combo-1-3-2', + style: { x: 120, y: 370 }, + data: { + data: { + nodes: [{ id: 'node-1' }, { id: 'node-2' }, { id: 'node-3' }, { id: 'node-4' }], + edges: [ + { source: 'node-1', target: 'node-2' }, + { source: 'node-1', target: 'node-3' }, + { source: 'node-1', target: 'node-4' }, + ], + }, + }, + }, + ], + edges: [], + combos: [ + { + id: 'combo-1', + data: { + data: [ + { name: '指标名1', value: 33 }, + { name: '指标名2', value: 44 }, + { name: '指标名3', value: 55 }, + ], + }, + }, + { + id: 'combo-1-1', + combo: 'combo-1', + style: { collapsed: true }, + data: { + data: [ + { name: '指标名1', value: 33 }, + { name: '指标名2', value: 44 }, + { name: '指标名3', value: 55 }, + ], + }, + }, + { + id: 'combo-1-2', + combo: 'combo-1', + style: { collapsed: true }, + data: { + data: [ + { name: '指标名1', value: 33 }, + { name: '指标名2', value: 44 }, + { name: '指标名3', value: 55 }, + ], + }, + }, + { + id: 'combo-1-3', + combo: 'combo-1', + style: { collapsed: true }, + data: { + data: [ + { name: '指标名1', value: 33 }, + { name: '指标名2', value: 44 }, + { name: '指标名3', value: 55 }, + ], + }, + }, + { + id: 'combo-1-3-1', + combo: 'combo-1-3', + style: { collapsed: true }, + data: { + data: [ + { name: '指标名1', value: 33 }, + { name: '指标名2', value: 44 }, + { name: '指标名3', value: 55 }, + ], + }, + }, + { + id: 'combo-1-3-2', + combo: 'combo-1-3', + style: { collapsed: true }, + data: { + data: [ + { name: '指标名1', value: 33 }, + { name: '指标名2', value: 44 }, + { name: '指标名3', value: 55 }, + ], + }, + }, + ], + }, + node: { + type: 'sub-graph', + style: { + dx: -100, + dy: -50, + size: getSize, + }, + }, + combo: { + type: 'card', + style: { + collapsedSize: [200, 100], + collapsedMarker: false, + radius: 10, + }, + }, + behaviors: [ + { type: 'drag-element', enable: (event: MouseEvent) => event.shiftKey !== true }, + 'collapse-expand', + 'zoom-canvas', + 'drag-canvas', + ], + plugins: [ + { + type: 'contextmenu', + getItems: (event: IElementEvent) => { + const { targetType, target } = event; + if (!['node', 'combo'].includes(targetType)) return []; + const id = target.id; + + if (targetType === 'combo') { + const data = graph.getComboData(id); + if (isCollapsed(data)) { + return [{ name: '展开', value: 'expanded' }]; + } else return [{ name: '收起', value: 'collapsed' }]; + } + return [{ name: '收起', value: 'collapsed' }]; + }, + onClick: (value: CardNodeData['status'], target: HTMLElement, current: SubGraph) => { + const id = current.id; + const elementType = graph.getElementType(id); + + if (elementType === 'node') { + const parent = graph.getParentData(id, 'combo'); + if (parent) return graph.collapseElement(parent.id, false); + } + + if (value === 'expanded') graph.expandElement(id, false); + else graph.collapseElement(id, false); + }, + }, + ], + }); + + await graph.render(); + + return graph; +}; diff --git a/packages/g6/__tests__/demos/index.ts b/packages/g6/__tests__/demos/index.ts index 025c7f86d42..76c08c30eb8 100644 --- a/packages/g6/__tests__/demos/index.ts +++ b/packages/g6/__tests__/demos/index.ts @@ -43,6 +43,7 @@ export { elementEdgePolylineAstar } from './element-edge-polyline-astar'; export { elementEdgePort } from './element-edge-port'; export { elementEdgeQuadratic } from './element-edge-quadratic'; export { elementEdgeSize } from './element-edge-size'; +export { elementHTMLSubGraph } from './element-html-sub-graph'; export { elementLabelBackground } from './element-label-background'; export { elementLabelOversized } from './element-label-oversized'; export { elementNodeAvatar } from './element-node-avatar'; diff --git a/packages/site/examples/scene-case/default/demo/meta.json b/packages/site/examples/scene-case/default/demo/meta.json index c5ab6111981..e29d85c0585 100644 --- a/packages/site/examples/scene-case/default/demo/meta.json +++ b/packages/site/examples/scene-case/default/demo/meta.json @@ -51,6 +51,14 @@ "en": "Organization Chart" }, "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*wgoUR6dnVcsAAAAAAAAAAAAADmJ7AQ/original" + }, + { + "filename": "sub-graph.js", + "title": { + "zh": "子图", + "en": "Sub Graph" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*2HzDTrQZ910AAAAAAAAAAAAADmJ7AQ/original" } ] } diff --git a/packages/site/examples/scene-case/default/demo/sub-graph.js b/packages/site/examples/scene-case/default/demo/sub-graph.js new file mode 100644 index 00000000000..47632cd2fbf --- /dev/null +++ b/packages/site/examples/scene-case/default/demo/sub-graph.js @@ -0,0 +1,336 @@ +import { BaseCombo, ExtensionCategory, Graph, HTML, isCollapsed, register } from '@antv/g6'; +import { isEqual } from '@antv/util'; + +class SubGraphNode extends HTML { + connectedCallback() { + super.connectedCallback(); + this.drawSubGraph(); + } + + render(attributes, container) { + super.render(attributes, container); + this.drawSubGraph(); + } + + get data() { + return this.context.graph.getElementData(this.id).data; + } + + drawSubGraph() { + if (!this.isConnected) return; + if (isEqual(this.previousData, this.data)) return; + this.previousData = this.data; + + const data = this.data; + this.drawGraphNode(data.data); + } + + drawGraphNode(data) { + const [width, height] = this.getSize(); + const container = this.getDomElement(); + container.innerHTML = ''; + + const subGraph = new Graph({ + container, + width, + height, + animation: false, + data: data, + node: { + style: { + labelText: (d) => d.id, + iconFontFamily: 'iconfont', + iconText: '\ue6e5', + }, + }, + layout: { + type: 'force', + linkDistance: 50, + }, + behaviors: ['zoom-canvas', { type: 'drag-canvas', enable: (event) => event.shiftKey === true }], + autoFit: 'view', + }); + + subGraph.render(); + + this.graph = subGraph; + } + + destroy() { + this.graph?.destroy(); + super.destroy(); + } +} + +class CardCombo extends BaseCombo { + getKeyStyle(attributes) { + const keyStyle = super.getKeyStyle(attributes); + const [width, height] = this.getKeySize(attributes); + return { + ...keyStyle, + width, + height, + x: -width / 2, + y: -height / 2, + }; + } + + drawKeyShape(attributes, container) { + const { collapsed } = attributes; + const outer = this.upsert('key', 'rect', this.getKeyStyle(attributes), container); + if (!outer || !collapsed) { + this.removeCardShape(); + return outer; + } + + this.drawCardShape(attributes, container); + + return outer; + } + + drawCardShape(attributes, container) { + const [width, height] = this.getCollapsedKeySize(attributes); + const data = this.context.graph.getComboData(this.id).data; + + const baseX = -width / 2; + const baseY = -height / 2; + + this.upsert( + 'card-title', + 'text', + { + x: baseX, + y: baseY, + text: 'Group: ' + this.id, + textAlign: 'left', + textBaseline: 'top', + fontSize: 16, + fontWeight: 'bold', + fill: '#4083f7', + }, + container, + ); + + const gap = 10; + const sep = (width + gap) / data.data.length; + data.data.forEach(({ name, value }, index) => { + this.upsert( + `card-item-name-${index}`, + 'text', + { + x: baseX + index * sep, + y: baseY + 40, + text: name, + textAlign: 'left', + textBaseline: 'top', + fontSize: 12, + fill: 'gray', + }, + container, + ); + this.upsert( + `card-item-value-${index}`, + 'text', + { + x: baseX + index * sep, + y: baseY + 60, + text: value + '%', + textAlign: 'left', + textBaseline: 'top', + fontSize: 24, + }, + container, + ); + }); + } + + removeCardShape() { + Object.entries(this.shapeMap).forEach(([key, shape]) => { + if (key.startsWith('card-')) { + delete this.shapeMap[key]; + shape.destroy(); + } + }); + } +} + +register(ExtensionCategory.NODE, 'sub-graph', SubGraphNode); +register(ExtensionCategory.COMBO, 'card', CardCombo); + +const getSize = (d) => { + const data = d.data; + if (data.type === 'card') return data.status === 'expanded' ? [200, 100 * data.children.length] : [200, 100]; + else return [200, 200]; +}; + +const graph = new Graph({ + container: 'container', + animation: false, + zoom: 0.8, + data: { + nodes: [ + { + id: '1', + combo: 'A', + style: { x: 120, y: 70 }, + data: { + data: { + nodes: [ + { id: 'node-1' }, + { id: 'node-2' }, + { id: 'node-3' }, + { id: 'node-4' }, + { id: 'node-5' }, + { id: 'node-6' }, + { id: 'node-7' }, + { id: 'node-8' }, + ], + edges: [ + { source: 'node-1', target: 'node-2' }, + { source: 'node-1', target: 'node-3' }, + { source: 'node-1', target: 'node-4' }, + { source: 'node-1', target: 'node-5' }, + { source: 'node-1', target: 'node-6' }, + { source: 'node-1', target: 'node-7' }, + { source: 'node-1', target: 'node-8' }, + ], + }, + }, + }, + { + id: '2', + combo: 'C', + style: { x: 370, y: 70 }, + data: { + data: { + nodes: [{ id: 'node-1' }, { id: 'node-2' }, { id: 'node-3' }, { id: 'node-4' }], + edges: [ + { source: 'node-1', target: 'node-2' }, + { source: 'node-1', target: 'node-3' }, + { source: 'node-1', target: 'node-4' }, + ], + }, + }, + }, + { + id: 'node-4', + combo: 'D', + style: { x: 370, y: 200 }, + data: { + data: { + nodes: [{ id: 'node-1' }, { id: 'node-2' }, { id: 'node-3' }, { id: 'node-4' }], + edges: [ + { source: 'node-1', target: 'node-2' }, + { source: 'node-1', target: 'node-3' }, + { source: 'node-1', target: 'node-4' }, + ], + }, + }, + }, + ], + edges: [], + combos: [ + { + id: 'root', + data: { + data: [ + { name: 'percent', value: 50 }, + { name: 'percent', value: 45 }, + { name: 'percent', value: 70 }, + ], + }, + }, + { + id: 'A', + combo: 'root', + data: { + data: [ + { name: 'percent', value: 30 }, + { name: 'percent', value: 90 }, + ], + }, + }, + { + id: 'B', + combo: 'root', + style: { collapsed: true }, + data: { + data: [ + { name: 'percent', value: 60 }, + { name: 'percent', value: 80 }, + ], + }, + }, + { + id: 'C', + combo: 'B', + style: { collapsed: true }, + data: { + data: [{ name: 'percent', value: 60 }], + }, + }, + { + id: 'D', + combo: 'B', + style: { collapsed: true }, + data: { + data: [{ name: 'percent', value: 80 }], + }, + }, + ], + }, + node: { + type: 'sub-graph', + style: { + dx: -100, + dy: -50, + size: getSize, + }, + }, + combo: { + type: 'card', + style: { + collapsedSize: [200, 100], + collapsedMarker: false, + radius: 10, + }, + }, + behaviors: [ + { type: 'drag-element', enable: (event) => event.shiftKey !== true }, + 'collapse-expand', + 'zoom-canvas', + 'drag-canvas', + ], + plugins: [ + { + type: 'contextmenu', + getItems: (event) => { + const { targetType, target } = event; + if (!['node', 'combo'].includes(targetType)) return []; + const id = target.id; + + if (targetType === 'combo') { + const data = graph.getComboData(id); + if (isCollapsed(data)) { + return [{ name: '展开', value: 'expanded' }]; + } else return [{ name: '收起', value: 'collapsed' }]; + } + return [{ name: '收起', value: 'collapsed' }]; + }, + onClick: (value, target, current) => { + const id = current.id; + const elementType = graph.getElementType(id); + + if (elementType === 'node') { + const parent = graph.getParentData(id, 'combo'); + if (parent) return graph.collapseElement(parent.id, false); + } + + if (value === 'expanded') graph.expandElement(id, false); + else graph.collapseElement(id, false); + }, + }, + ], +}); + +graph.render();