From 9eedcd7fd4dd565815773c80a93a38b10a11d25b Mon Sep 17 00:00:00 2001 From: bill Date: Fri, 2 Aug 2024 18:33:32 +0800 Subject: [PATCH] feat: Add IndentedTree #162 --- .../knowledge-graph/force-graph.tsx | 23 +- .../knowledge-graph/indented-tree.tsx | 432 ++++++++++++++++++ .../components/knowledge-graph/index.tsx | 5 - .../components/knowledge-graph/modal.tsx | 27 +- .../components/knowledge-chunk/index.tsx | 2 +- 5 files changed, 472 insertions(+), 17 deletions(-) create mode 100644 web/src/pages/add-knowledge/components/knowledge-chunk/components/knowledge-graph/indented-tree.tsx delete mode 100644 web/src/pages/add-knowledge/components/knowledge-chunk/components/knowledge-graph/index.tsx diff --git a/web/src/pages/add-knowledge/components/knowledge-chunk/components/knowledge-graph/force-graph.tsx b/web/src/pages/add-knowledge/components/knowledge-chunk/components/knowledge-graph/force-graph.tsx index 19c0e57390a..45bfb82ce0c 100644 --- a/web/src/pages/add-knowledge/components/knowledge-chunk/components/knowledge-graph/force-graph.tsx +++ b/web/src/pages/add-knowledge/components/knowledge-chunk/components/knowledge-graph/force-graph.tsx @@ -1,7 +1,6 @@ -import { useFetchKnowledgeGraph } from '@/hooks/chunk-hooks'; import { ElementDatum, Graph, IElementEvent } from '@antv/g6'; import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { buildNodesAndCombos, isDataExist } from './util'; +import { buildNodesAndCombos } from './util'; import styles from './index.less'; @@ -11,14 +10,18 @@ const TooltipColorMap = { edge: 'blue', }; -const ForceGraph = () => { +interface IProps { + data: any; + show: boolean; +} + +const ForceGraph = ({ data, show }: IProps) => { const containerRef = useRef(null); const graphRef = useRef(null); - const { data } = useFetchKnowledgeGraph(); const nextData = useMemo(() => { - if (isDataExist(data)) { - const graphData = data.data; + if (data) { + const graphData = data; const mi = buildNodesAndCombos(graphData.nodes); return { edges: graphData.links, ...mi }; } @@ -113,7 +116,7 @@ const ForceGraph = () => { }, [nextData]); useEffect(() => { - if (isDataExist(data)) { + if (data) { render(); } }, [data, render]); @@ -122,7 +125,11 @@ const ForceGraph = () => {
); }; diff --git a/web/src/pages/add-knowledge/components/knowledge-chunk/components/knowledge-graph/indented-tree.tsx b/web/src/pages/add-knowledge/components/knowledge-chunk/components/knowledge-graph/indented-tree.tsx new file mode 100644 index 00000000000..8dbf027efde --- /dev/null +++ b/web/src/pages/add-knowledge/components/knowledge-chunk/components/knowledge-graph/indented-tree.tsx @@ -0,0 +1,432 @@ +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 { useCallback, useEffect, useRef } from 'react'; + +const rootId = 'Modeling Methods'; + +const COLORS = [ + '#5B8FF9', + '#F6BD16', + '#5AD8A6', + '#945FB9', + '#E86452', + '#6DC8EC', + '#FF99C3', + '#1E9493', + '#FF9845', + '#5D7092', +]; + +const TreeEvent = { + COLLAPSE_EXPAND: 'collapse-expand', + ADD_CHILD: 'add-child', +}; + +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, // half of edge line width + 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), + }; + } + + drawAddShape(attributes: any, container: any) { + const addStyle = this.getAddStyle(attributes); + const btn = this.upsert('add', Badge, addStyle as any, container); + + this.forwardEvent(btn, CommonEvent.CLICK, (event: any) => { + event.stopPropagation(); + attributes.context.graph.emit(TreeEvent.ADD_CHILD, { id: this.id }); + }); + } + + render(attributes = this.parsedAttributes, container = this) { + super.render(attributes, container); + + this.drawCountShape(attributes, container); + + this.drawIconArea(attributes, container); + this.drawCollapseShape(attributes, container); + this.drawAddShape(attributes, container); + } +} + +class IndentedEdge extends Polyline { + getControlPoints( + attributes: Required, + 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); + graph.on(TreeEvent.ADD_CHILD, this.addChild); + } + + 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); + graph.off(TreeEvent.ADD_CHILD, this.addChild); + } + + 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'; + }; + + addChild(event: any) { + const { + onCreateChild = () => ({ + id: `${Date.now()}`, + style: { labelText: 'new node' }, + }), + } = this.options; + const { graph } = this.context; + const datum = onCreateChild(event.id); + graph.addNodeData([datum]); + graph.addEdgeData([{ source: event.id, target: datum.id }]); + const parent = graph.getNodeData(event.id); + graph.updateNodeData([ + { + id: event.id, + children: [...(parent.children || []), datum.id], + style: { collapsed: false }, + }, + ]); + graph.render(); + } +} + +register(ExtensionCategory.NODE, 'indented', IndentedNode); +register(ExtensionCategory.EDGE, 'indented', IndentedEdge); +register( + ExtensionCategory.BEHAVIOR, + 'collapse-expand-tree', + CollapseExpandTree, +); + +interface IProps { + data: TreeData; + show: boolean; +} + +const IndentedTree = ({ data, show }: IProps) => { + const containerRef = useRef(null); + const graphRef = useRef(null); + + 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]; + }, + }, + }, + layout: { + type: 'indented', + direction: 'LR', + isHorizontal: true, + indent: 40, + getHeight: () => 20, + getVGap: () => 10, + }, + behaviors: [ + 'scroll-canvas', + 'drag-branch', + '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; + + graph.setData(treeToGraphData(data)); + + graph.render(); + }, []); + + useEffect(() => { + if (!isEmpty(data)) { + render(data); + } + }, [render, data]); + + return ( +
+ ); +}; + +export default IndentedTree; diff --git a/web/src/pages/add-knowledge/components/knowledge-chunk/components/knowledge-graph/index.tsx b/web/src/pages/add-knowledge/components/knowledge-chunk/components/knowledge-graph/index.tsx deleted file mode 100644 index facb7d14301..00000000000 --- a/web/src/pages/add-knowledge/components/knowledge-chunk/components/knowledge-graph/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const KnowledgeGraph = () => { - return
KnowledgeGraph
; -}; - -export default KnowledgeGraph; diff --git a/web/src/pages/add-knowledge/components/knowledge-chunk/components/knowledge-graph/modal.tsx b/web/src/pages/add-knowledge/components/knowledge-chunk/components/knowledge-graph/modal.tsx index 17b7dbcd17f..9c56ae1e4c7 100644 --- a/web/src/pages/add-knowledge/components/knowledge-chunk/components/knowledge-graph/modal.tsx +++ b/web/src/pages/add-knowledge/components/knowledge-chunk/components/knowledge-graph/modal.tsx @@ -1,14 +1,20 @@ import { useFetchKnowledgeGraph } from '@/hooks/chunk-hooks'; -import { Modal } from 'antd'; +import { Flex, Modal, Segmented } from 'antd'; import React, { useEffect, useState } from 'react'; import ForceGraph from './force-graph'; - +import IndentedTree from './indented-tree'; import styles from './index.less'; import { isDataExist } from './util'; +enum SegmentedValue { + Graph = 'Graph', + Mind = 'Mind', +} + const KnowledgeGraphModal: React.FC = () => { const [isModalOpen, setIsModalOpen] = useState(false); const { data } = useFetchKnowledgeGraph(); + const [value, setValue] = useState(SegmentedValue.Graph); const handleOk = () => { setIsModalOpen(false); @@ -34,7 +40,22 @@ const KnowledgeGraphModal: React.FC = () => { footer={null} >
- + + setValue(v as SegmentedValue)} + /> + + +
); diff --git a/web/src/pages/add-knowledge/components/knowledge-chunk/index.tsx b/web/src/pages/add-knowledge/components/knowledge-chunk/index.tsx index f3811122a00..37db88da89c 100644 --- a/web/src/pages/add-knowledge/components/knowledge-chunk/index.tsx +++ b/web/src/pages/add-knowledge/components/knowledge-chunk/index.tsx @@ -195,7 +195,7 @@ const Chunk = () => { onOk={onChunkUpdatingOk} /> )} - {false && } + ); };