From 999eb533a9ab95297262e4c6177cdbde18f58c5d Mon Sep 17 00:00:00 2001 From: balibabu Date: Tue, 14 Jul 2026 10:20:31 +0800 Subject: [PATCH] Feat: Adjust the color and size of the graph based on the data. (#16868) --- .../components/artifact-force-graph/index.tsx | 52 +++++++++-------- .../artifact-force-graph/node-label.ts | 25 ++++++++ .../artifact-force-graph/node-style.ts | 25 ++++++++ .../components/artifact-force-graph/types.ts | 13 +++++ .../use-artifact-graph-data.ts | 57 +++++++++++++++++++ .../components/artifact-force-graph/utils.ts | 21 ------- web/src/pages/agent/utils.ts | 6 +- web/src/pages/agents/index.tsx | 2 +- 8 files changed, 151 insertions(+), 50 deletions(-) create mode 100644 web/src/components/artifact-force-graph/node-label.ts create mode 100644 web/src/components/artifact-force-graph/node-style.ts create mode 100644 web/src/components/artifact-force-graph/use-artifact-graph-data.ts diff --git a/web/src/components/artifact-force-graph/index.tsx b/web/src/components/artifact-force-graph/index.tsx index 8304ed4ed2..e986a02cf0 100644 --- a/web/src/components/artifact-force-graph/index.tsx +++ b/web/src/components/artifact-force-graph/index.tsx @@ -1,11 +1,17 @@ import { type IArtifactGraphEntity } from '@/interfaces/database/dataset'; import { cn } from '@/lib/utils'; -import isEmpty from 'lodash/isEmpty'; -import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; +import { memo, useCallback, useEffect, useRef } from 'react'; import ForceGraph2D, { type ForceGraphMethods } from 'react-force-graph-2d'; -import { type ArtifactForceGraphProps } from './types'; +import { renderNodeLabel } from './node-label'; +import { + getNodeColor as defaultGetNodeColor, + getNodeRadius as defaultGetNodeRadius, + MinNodeRadius, +} from './node-style'; +import { type ArtifactForceGraphProps, type ArtifactGraphNode } from './types'; +import { useArtifactGraphData } from './use-artifact-graph-data'; import { useContainerDimensions } from './use-container-dimensions'; -import { defaultMapNodeToValue, renderNodeLabel } from './utils'; +import { defaultMapNodeToValue } from './utils'; function ArtifactForceGraph({ data, @@ -15,31 +21,22 @@ function ArtifactForceGraph({ node: IArtifactGraphEntity, ) => TNodeValue, getNodeId = (node) => node.slug, + getNodeColor = defaultGetNodeColor, + getNodeRadius = defaultGetNodeRadius, }: ArtifactForceGraphProps) { const containerRef = useRef(null); - const fgRef = useRef | undefined>( + const fgRef = useRef | undefined>( undefined, ); const hasFittedRef = useRef(false); const dimensions = useContainerDimensions(containerRef, show); - const graphData = useMemo(() => { - if (isEmpty(data) || !data) { - return { nodes: [], links: [] }; - } - - const nodes = (data.entities || []).map((entity) => ({ - ...entity, - id: getNodeId(entity), - })); - - const links = (data.relations || []).map((relation) => ({ - source: relation.from, - target: relation.to, - })); - - return { nodes, links }; - }, [data, getNodeId]); + const graphData = useArtifactGraphData({ + data, + getNodeId, + getNodeColor, + getNodeRadius, + }); useEffect(() => { hasFittedRef.current = false; @@ -69,6 +66,13 @@ function ArtifactForceGraph({ .trim(); }, []); + const nodeColor = useCallback((node: ArtifactGraphNode) => node.__color, []); + + const nodeVal = useCallback( + (node: ArtifactGraphNode) => node.__radius ?? MinNodeRadius, + [], + ); + return (
({ width={dimensions.width} height={dimensions.height} graphData={graphData} - nodeAutoColorBy="type" + nodeRelSize={1} + nodeColor={nodeColor} + nodeVal={nodeVal} cooldownTicks={100} nodeLabel={''} onEngineStop={handleEngineStop} diff --git a/web/src/components/artifact-force-graph/node-label.ts b/web/src/components/artifact-force-graph/node-label.ts new file mode 100644 index 0000000000..715a5bd6c3 --- /dev/null +++ b/web/src/components/artifact-force-graph/node-label.ts @@ -0,0 +1,25 @@ +import { type ComponentProps } from 'react'; +import ForceGraph2D from 'react-force-graph-2d'; +import { MinNodeRadius } from './node-style'; +import { type ArtifactGraphNode } from './types'; + +export const renderNodeLabel: NonNullable< + ComponentProps['nodeCanvasObject'] +> = (node, ctx) => { + const graphNode = node as ArtifactGraphNode; + const label = graphNode.name; + const radius = graphNode.__radius ?? MinNodeRadius; + const fontSize = 2; + ctx.font = `${fontSize}px Sans-Serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + + const textSecondary = getComputedStyle(document.documentElement) + .getPropertyValue('--text-secondary') + .trim(); + ctx.fillStyle = `rgb(${textSecondary})`; + + if (typeof graphNode.x === 'number' && typeof graphNode.y === 'number') { + ctx.fillText(label, graphNode.x, graphNode.y + radius - 2); + } +}; diff --git a/web/src/components/artifact-force-graph/node-style.ts b/web/src/components/artifact-force-graph/node-style.ts new file mode 100644 index 0000000000..a64c4fe343 --- /dev/null +++ b/web/src/components/artifact-force-graph/node-style.ts @@ -0,0 +1,25 @@ +import { type IArtifactGraphEntity } from '@/interfaces/database/dataset'; + +export const EntityNodeColor = '#00BEB4'; +export const ConceptNodeColor = '#4CACFF'; + +export const MinNodeRadius = 4; +export const MaxNodeRadius = 14; + +export const getNodeColor = (node: IArtifactGraphEntity): string => { + if (node.type === 'entity') return EntityNodeColor; + if (node.type === 'concept') return ConceptNodeColor; + return EntityNodeColor; +}; + +export const getNodeRadius = ( + node: IArtifactGraphEntity, + minWeight: number, + maxWeight: number, +): number => { + const weight = node.weight ?? 0; + if (maxWeight <= minWeight) return MinNodeRadius; + const clamped = Math.max(minWeight, Math.min(maxWeight, weight)); + const t = (clamped - minWeight) / (maxWeight - minWeight); + return MinNodeRadius + t * (MaxNodeRadius - MinNodeRadius); +}; diff --git a/web/src/components/artifact-force-graph/types.ts b/web/src/components/artifact-force-graph/types.ts index 783a645894..836db70e4c 100644 --- a/web/src/components/artifact-force-graph/types.ts +++ b/web/src/components/artifact-force-graph/types.ts @@ -2,6 +2,13 @@ import { type IArtifactGraph, type IArtifactGraphEntity, } from '@/interfaces/database/dataset'; +import { type NodeObject } from 'react-force-graph-2d'; + +export type ArtifactGraphNode = NodeObject & { + id: string; + __color: string; + __radius: number; +}; export interface ArtifactForceGraphProps { data?: IArtifactGraph; @@ -9,4 +16,10 @@ export interface ArtifactForceGraphProps { onNodeClick?: (node: TNodeValue) => void; mapNodeToValue?: (node: IArtifactGraphEntity) => TNodeValue; getNodeId?: (node: IArtifactGraphEntity) => string; + getNodeColor?: (node: IArtifactGraphEntity) => string; + getNodeRadius?: ( + node: IArtifactGraphEntity, + minWeight: number, + maxWeight: number, + ) => number; } diff --git a/web/src/components/artifact-force-graph/use-artifact-graph-data.ts b/web/src/components/artifact-force-graph/use-artifact-graph-data.ts new file mode 100644 index 0000000000..bb29fcbd57 --- /dev/null +++ b/web/src/components/artifact-force-graph/use-artifact-graph-data.ts @@ -0,0 +1,57 @@ +import { + type IArtifactGraph, + type IArtifactGraphEntity, +} from '@/interfaces/database/dataset'; +import isEmpty from 'lodash/isEmpty'; +import { useMemo } from 'react'; +import { type GraphData } from 'react-force-graph-2d'; +import { + getNodeColor as defaultGetNodeColor, + getNodeRadius as defaultGetNodeRadius, +} from './node-style'; +import { type ArtifactGraphNode } from './types'; + +export interface UseArtifactGraphDataOptions { + data?: IArtifactGraph; + getNodeId?: (node: IArtifactGraphEntity) => string; + getNodeColor?: (node: IArtifactGraphEntity) => string; + getNodeRadius?: ( + node: IArtifactGraphEntity, + minWeight: number, + maxWeight: number, + ) => number; +} + +export const useArtifactGraphData = ({ + data, + getNodeId = (node) => node.slug, + getNodeColor = defaultGetNodeColor, + getNodeRadius = defaultGetNodeRadius, +}: UseArtifactGraphDataOptions) => { + return useMemo< + GraphData + >(() => { + if (isEmpty(data) || !data) { + return { nodes: [], links: [] }; + } + + const entities = data.entities || []; + const weights = entities.map((entity) => entity.weight ?? 0); + const minWeight = Math.min(0, ...weights); + const maxWeight = Math.max(0, ...weights); + + const nodes = entities.map((entity) => ({ + ...entity, + id: getNodeId(entity), + __color: getNodeColor(entity), + __radius: getNodeRadius(entity, minWeight, maxWeight), + })); + + const links = (data.relations || []).map((relation) => ({ + source: relation.from, + target: relation.to, + })); + + return { nodes, links }; + }, [data, getNodeColor, getNodeId, getNodeRadius]); +}; diff --git a/web/src/components/artifact-force-graph/utils.ts b/web/src/components/artifact-force-graph/utils.ts index e77f4861f8..3fdde0dd55 100644 --- a/web/src/components/artifact-force-graph/utils.ts +++ b/web/src/components/artifact-force-graph/utils.ts @@ -1,25 +1,4 @@ import { type IArtifactGraphEntity } from '@/interfaces/database/dataset'; -import { type ComponentProps } from 'react'; -import ForceGraph2D from 'react-force-graph-2d'; - -export const renderNodeLabel: NonNullable< - ComponentProps['nodeCanvasObject'] -> = (node, ctx) => { - const label = node.name; - const fontSize = 2; - ctx.font = `${fontSize}px Sans-Serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'top'; - - const textSecondary = getComputedStyle(document.documentElement) - .getPropertyValue('--text-secondary') - .trim(); - ctx.fillStyle = `rgb(${textSecondary})`; - - if (node.x && node.y) { - ctx.fillText(label, node.x, node.y + 5); - } -}; export const defaultMapNodeToValue = ( node: TNode, diff --git a/web/src/pages/agent/utils.ts b/web/src/pages/agent/utils.ts index ebab563c2f..be9f1acbe2 100644 --- a/web/src/pages/agent/utils.ts +++ b/web/src/pages/agent/utils.ts @@ -21,7 +21,6 @@ import { omit, sample, } from 'lodash'; -import pipe from 'lodash/fp/pipe'; import isObject from 'lodash/isObject'; import { AgentDialogueMode, @@ -164,10 +163,7 @@ function buildCategorize(edges: Edge[], nodes: Node[], nodeId: string) { } const buildOperatorParams = (operatorName: string) => - pipe( - removeUselessDataInTheOperator(operatorName), - // initializeOperatorParams(operatorName), // Final processing, for guarantee - ); + removeUselessDataInTheOperator(operatorName); const ExcludeOperators = [Operator.Note, Operator.Tool, Operator.Placeholder]; diff --git a/web/src/pages/agents/index.tsx b/web/src/pages/agents/index.tsx index 9e95e53870..49d153a130 100644 --- a/web/src/pages/agents/index.tsx +++ b/web/src/pages/agents/index.tsx @@ -101,7 +101,7 @@ export default function Agents() { value={filterValue} > - +