From 3c0c6552355ac9aad74461d882abf617f6953ea7 Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 25 Jun 2025 11:08:28 -0700 Subject: [PATCH 1/5] entire knowledge graph option for knowledge graph viewer --- .../knowledge_base/graph/routes.py | 17 ++++++ .../graph_store/tidb_graph_store.py | 59 ++++++++++++++++++- docker-compose.dev.yml | 11 ++++ frontend/app/src/api/graph.ts | 30 ++++++++++ .../app/src/components/graph/GraphEditor.tsx | 50 +++++++++++----- 5 files changed, 152 insertions(+), 15 deletions(-) diff --git a/backend/app/api/admin_routes/knowledge_base/graph/routes.py b/backend/app/api/admin_routes/knowledge_base/graph/routes.py index 09f280d51..95a2bbf4f 100644 --- a/backend/app/api/admin_routes/knowledge_base/graph/routes.py +++ b/backend/app/api/admin_routes/knowledge_base/graph/routes.py @@ -243,3 +243,20 @@ def legacy_search_graph(session: SessionDep, kb_id: int, request: GraphSearchReq except Exception as e: # TODO: throw InternalServerError raise e + +@router.post("/admin/knowledge_bases/{kb_id}/graph/entire_graph") +def get_entire_knowledge_graph(session: SessionDep, kb_id: int): + try: + kb = knowledge_base_repo.must_get(session, kb_id) + graph_store = get_kb_tidb_graph_store(session, kb) + retrieved_kg = graph_store.get_entire_knowledge_graph() + return { + "entities": retrieved_kg.entities, + "relationships": retrieved_kg.relationships, + } + + except KBNotFound as e: + raise e + except Exception as e: + # TODO: throw InternalServerError + raise e \ No newline at end of file diff --git a/backend/app/rag/indices/knowledge_graph/graph_store/tidb_graph_store.py b/backend/app/rag/indices/knowledge_graph/graph_store/tidb_graph_store.py index 6f3f3c058..f34bc9a9a 100644 --- a/backend/app/rag/indices/knowledge_graph/graph_store/tidb_graph_store.py +++ b/backend/app/rag/indices/knowledge_graph/graph_store/tidb_graph_store.py @@ -312,7 +312,7 @@ def create_relationship( self._session.flush() def get_subgraph_by_relationship_ids( - self, ids: list[int], **kwargs + self, ids: list[int], **kwargs ) -> RetrievedKnowledgeGraph: stmt = ( select(self._relationship_model) @@ -1103,3 +1103,60 @@ def get_chunks_by_relationships( } for chunk in chunks ] + + def get_entire_knowledge_graph(self) -> RetrievedKnowledgeGraph: + """Retrieve all entities and relationships from the knowledge graph store. + + Returns: + RetrievedKnowledgeGraph containing all entities and relationships + """ + # Query all entities + entity_query = select(self._entity_model).order_by(self._entity_model.id) + db_entities = self._session.exec(entity_query).all() + + # Query all relationships with their related entities + relationship_query = ( + select(self._relationship_model) + .options( + joinedload(self._relationship_model.source_entity), + joinedload(self._relationship_model.target_entity), + ) + .order_by(self._relationship_model.id) + ) + db_relationships = self._session.exec(relationship_query).all() + + # Convert entities to RetrievedEntity objects + entities = [] + for entity in db_entities: + entities.append( + RetrievedEntity( + id=entity.id, + knowledge_base_id=self.knowledge_base.id, + name=entity.name, + description=entity.description, + meta=entity.meta, + entity_type=entity.entity_type, + ) + ) + + # Convert relationships to RetrievedRelationship objects + relationships = [] + for rel in db_relationships: + relationships.append( + RetrievedRelationship( + id=rel.id, + knowledge_base_id=self.knowledge_base.id, + source_entity_id=rel.source_entity_id, + target_entity_id=rel.target_entity_id, + description=rel.description, + rag_description=f"{rel.source_entity.name} -> {rel.description} -> {rel.target_entity.name}", + meta=rel.meta, + weight=rel.weight, + last_modified_at=rel.last_modified_at, + ) + ) + return RetrievedKnowledgeGraph( + knowledge_base=self.knowledge_base.to_descriptor(), + entities=entities, + relationships=relationships, + ) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index dc8b9cad1..aa4f335f4 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -13,6 +13,9 @@ services: - .env volumes: - ./data:/shared/data + - ./backend:/app + - /app/.venv + command: fastapi dev app/api_server.py --host 0.0.0.0 --port 80 --reload depends_on: - redis @@ -20,12 +23,18 @@ services: build: context: . dockerfile: ./frontend/Dockerfile + target: deps args: BUILDKIT_INLINE_CACHE: 1 ports: - "3001:3000" environment: BASE_URL: http://backend + HOSTNAME: 0.0.0.0 + volumes: + - ./frontend:/tidb.ai/frontend + - /tidb.ai/frontend/node_modules + command: sh -c "cd /tidb.ai/frontend && pnpm run dev" depends_on: - backend @@ -41,6 +50,8 @@ services: - .env volumes: - ./data:/shared/data + - ./backend:/app + - /app/.venv command: /usr/bin/supervisord depends_on: - redis diff --git a/frontend/app/src/api/graph.ts b/frontend/app/src/api/graph.ts index 9de6470e3..d36bd9030 100644 --- a/frontend/app/src/api/graph.ts +++ b/frontend/app/src/api/graph.ts @@ -91,6 +91,24 @@ export interface GraphSearchParams { with_degree?: boolean; } +export interface KBRetrieveKnowledgeGraphParams { + query: string; + llm_id: number; + retrieval_config: { + knowledge_graph: KnowledgeGraphRetrievalConfig; + } +} + +export interface KnowledgeGraphRetrievalConfig { + depth?: number; + include_meta?: boolean; + with_degree?: boolean; + metadata_filter?: { + enabled?: boolean; + filters?: Record + } +} + export async function search (kbId: number, params: GraphSearchParams) { return await fetch(requestUrl(`/api/v1/admin/knowledge_bases/${kbId}/graph/search`), { method: 'post', @@ -151,6 +169,18 @@ export async function getEntitySubgraph (kbId: number, id: number) { .then(handleResponse(knowledgeGraphSchema)); } +export async function getEntireKnowledgeGraph (kbId: number, params: KBRetrieveKnowledgeGraphParams) { + return await fetch(requestUrl(`/api/v1/admin/knowledge_bases/${kbId}/graph/entire_graph`), { + method: 'post', + headers: { + ...await authenticationHeaders(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + }) + .then(handleResponse(knowledgeGraphSchema)); +} + export async function getRelationship (kbId: number, id: number) { return await fetch(requestUrl(`/api/v1/admin/knowledge_bases/${kbId}/graph/relationships/${id}`), { headers: { diff --git a/frontend/app/src/components/graph/GraphEditor.tsx b/frontend/app/src/components/graph/GraphEditor.tsx index 3822b035c..18a5845ce 100644 --- a/frontend/app/src/components/graph/GraphEditor.tsx +++ b/frontend/app/src/components/graph/GraphEditor.tsx @@ -1,7 +1,7 @@ 'use client'; import { getChatMessageSubgraph } from '@/api/chats'; -import { getEntitySubgraph, type KnowledgeGraph, search } from '@/api/graph'; +import { getEntitySubgraph, getEntireKnowledgeGraph, type KnowledgeGraph, search } from '@/api/graph'; import { LinkDetails } from '@/components/graph/components/LinkDetails'; import { NetworkViewer, type NetworkViewerDetailsProps } from '@/components/graph/components/NetworkViewer'; import { NodeDetails } from '@/components/graph/components/NodeDetails'; @@ -90,6 +90,9 @@ function SubgraphSelector ({ knowledgeBaseId, query, onQueryChange }: { knowledg - setInput(event.target.value)} - onKeyDown={event => { - if (isHotkey('Enter', event)) { - onQueryChange(`${type}:${input}`); - } - }} - /> - - Create Synopsis Entity - + {type !== 'entire-knowledge-graph' && ( + <> + setInput(event.target.value)} + onKeyDown={event => { + if (isHotkey('Enter', event)) { + onQueryChange(`${type}:${input}`); + } + }} + /> + + Create Synopsis Entity + + + )} ); } @@ -145,6 +153,18 @@ function getFetchInfo (kbId: number, query: string | null): [string | false, () const param = parsedQuery[1]; + const entireKnowledgeGraphParams = { + query: "", + llm_id: 1, + retrieval_config: { + knowledge_graph: { + depth: 20, + include_meta: true, + with_degree: true + } + } + } + switch (parsedQuery[0]) { // case 'trace': // return ['get', `/api/v1/traces/${parsedQuery[1]}/knowledge-graph-retrieval`]; @@ -156,6 +176,8 @@ function getFetchInfo (kbId: number, query: string | null): [string | false, () return [`api.knowledge-bases.${kbId}.graph.search?query=${param}`, () => search(kbId, { query: param })]; case 'message-subgraph': return [`api.chats.get-message-subgraph?id=${param}`, () => getChatMessageSubgraph(parseInt(param))]; + case 'entire-knowledge-graph': + return [`api.knowledge-bases.${kbId}.graph.entire-knowledge-graph`, () => getEntireKnowledgeGraph(kbId, entireKnowledgeGraphParams)]; } return [false, () => Promise.reject()]; From 402fb15096a156c1a9be044b752b1a6d0f5b2440 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 1 Jul 2025 10:32:28 -0700 Subject: [PATCH 2/5] add visualizer selection dropdown to graph editor page --- .../app/src/components/graph/GraphEditor.tsx | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/frontend/app/src/components/graph/GraphEditor.tsx b/frontend/app/src/components/graph/GraphEditor.tsx index 18a5845ce..30a0ae255 100644 --- a/frontend/app/src/components/graph/GraphEditor.tsx +++ b/frontend/app/src/components/graph/GraphEditor.tsx @@ -21,6 +21,7 @@ import useSWR from 'swr'; export function GraphEditor ({ knowledgeBaseId }: { knowledgeBaseId: number }) { const [query, setQuery] = useSearchParam('query', 'sample-question:What is TiDB?'); + const [graphStyle, setGraphStyle] = useState<'new' | 'legacy'>('new'); const [key, fetcher] = getFetchInfo(knowledgeBaseId, query); @@ -33,6 +34,7 @@ export function GraphEditor ({ knowledgeBaseId }: { knowledgeBaseId: number }) { return (
+ {(error != null) && Failed to fetch subgraph {getErrorMessage(error)} @@ -40,11 +42,12 @@ export function GraphEditor ({ knowledgeBaseId }: { knowledgeBaseId: number }) {
( ref.current && createPortal( - - Create Synopsis Entity - )} + + Create Synopsis Entity +
); } +function GraphStyleSelector ({ style, onStyleChange }: { style: 'new' | 'legacy', onStyleChange: (style: 'new' | 'legacy') => void }) { + return ( +
+ +
+ ) +} + function Editor ({ knowledgeBaseId, network, target, onTargetChange, onEnterSubgraph }: NetworkViewerDetailsProps & { knowledgeBaseId: number, onEnterSubgraph: (type: string, entityId: IdType) => void }) { if (target) { if (target.type === 'link') { From e0ecb0bf3c6ce302f2321c784df1ff4c9d0c5aea Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 1 Jul 2025 10:38:23 -0700 Subject: [PATCH 3/5] NetworkViewer - added configuration for new visualizer NetworkCanvas - new visualizer configuration, add mounting/unmounting between NetworkRenderer and CanvasNetworkRenderer CanvasNetworkRenderer - new renderer for new visualizer using force-graph library (d3 physics and HTML5 canvas rendering) NetworkRenderer - add safety guard for removing child node (bugfix) docker-compose.dev.yml - changes to enable hot-reloading --- docker-compose.dev.yml | 1 - frontend/app/package.json | 1 + .../graph/components/NetworkCanvas.tsx | 27 +- .../graph/components/NetworkViewer.tsx | 4 +- .../graph/network/CanvasNetworkRenderer.ts | 437 ++++++++++++++++++ .../graph/network/NetworkRenderer.ts | 7 +- frontend/pnpm-lock.yaml | 110 +++++ 7 files changed, 576 insertions(+), 11 deletions(-) create mode 100644 frontend/app/src/components/graph/network/CanvasNetworkRenderer.ts diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index aa4f335f4..2853b9696 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -33,7 +33,6 @@ services: HOSTNAME: 0.0.0.0 volumes: - ./frontend:/tidb.ai/frontend - - /tidb.ai/frontend/node_modules command: sh -c "cd /tidb.ai/frontend && pnpm run dev" depends_on: - backend diff --git a/frontend/app/package.json b/frontend/app/package.json index a327ba414..ef0e96a9e 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -49,6 +49,7 @@ "cmdk": "^1.0.4", "date-fns": "^3.6.0", "embla-carousel-react": "^8.5.1", + "force-graph": "^1.49.6", "input-otp": "^1.4.1", "js-cookie": "^3.0.5", "lucide-react": "^0.400.0", diff --git a/frontend/app/src/components/graph/components/NetworkCanvas.tsx b/frontend/app/src/components/graph/components/NetworkCanvas.tsx index b435e54a1..784c7e35b 100644 --- a/frontend/app/src/components/graph/components/NetworkCanvas.tsx +++ b/frontend/app/src/components/graph/components/NetworkCanvas.tsx @@ -1,28 +1,39 @@ import { useEffect, useRef, useState } from 'react'; import type { IdType, NetworkLink, NetworkNode, ReadonlyNetwork } from '../network/Network'; import { NetworkRenderer, type NetworkRendererOptions } from '../network/NetworkRenderer'; +import { CanvasNetworkRenderer } from '../network/CanvasNetworkRenderer'; export interface NetworkCanvasProps extends NetworkRendererOptions { network: ReadonlyNetwork; target: { type: string, id: IdType } | undefined; className?: string; + useCanvasRenderer?: boolean; } -export function NetworkCanvas ({ className, network, target, ...options }: NetworkCanvasProps) { +export function NetworkCanvas ({ className, network, target, useCanvasRenderer = false, ...options }: NetworkCanvasProps) { const ref = useRef(null); - const [renderer, setRenderer] = useState>(); + const [renderer, setRenderer] = useState | CanvasNetworkRenderer>(); useEffect(() => { - const renderer = new NetworkRenderer(network, options); + // Cleanup previous renderer if it exists (needed for renderer switching) + if (renderer) { + renderer.unmount(); + } + + const newRenderer = useCanvasRenderer + ? new CanvasNetworkRenderer(network, options) + : new NetworkRenderer(network, options); + if (ref.current) { - renderer.mount(ref.current); + newRenderer.mount(ref.current); } - setRenderer(renderer); + setRenderer(newRenderer); return () => { - renderer.unmount(); + newRenderer.unmount(); + setRenderer(undefined); }; - }, [network]); + }, [network, useCanvasRenderer]); useEffect(() => { if (!renderer) { @@ -41,7 +52,7 @@ export function NetworkCanvas renderer.blurLink(); } - }, [target]); + }, [target, renderer]); return (
diff --git a/frontend/app/src/components/graph/components/NetworkViewer.tsx b/frontend/app/src/components/graph/components/NetworkViewer.tsx index d026ec304..d6814fa1e 100644 --- a/frontend/app/src/components/graph/components/NetworkViewer.tsx +++ b/frontend/app/src/components/graph/components/NetworkViewer.tsx @@ -13,6 +13,7 @@ export interface NetworkViewerProps { loading: boolean; loadingTitle: ReactNode; Details: FC; + useCanvasRenderer?: boolean; } export interface NetworkViewerDetailsProps { @@ -31,7 +32,7 @@ function randomPosition (radius: number, kbSpacing: number, kbIndex: number, kbC }; } -export function NetworkViewer ({ network, loading, loadingTitle, className, Details }: NetworkViewerProps) { +export function NetworkViewer ({ network, loading, loadingTitle, className, Details, useCanvasRenderer = false }: NetworkViewerProps) { const [target, setTarget] = useState<{ type: string, id: IdType }>(); const knowledgeGraphIndexMap = useMemo(() => { @@ -145,6 +146,7 @@ export function NetworkViewer ({ network, loading, loadingTitle, className, Deta className={cn('w-full h-full overflow-hidden')} network={network} target={target} + useCanvasRenderer={useCanvasRenderer} {...networkOptions} />
{ + private _el: HTMLElement | undefined; + private _graph: any; // ForceGraph instance + private _ro: ResizeObserver | undefined; + + private _onFocusNode: ((id: IdType) => void) | undefined; + private _onFocusLink: ((id: IdType) => void) | undefined; + private _onBlurNode: (() => void) | undefined; + private _onBlurLink: (() => void) | undefined; + + private _onUpdateLink: ((id: IdType) => void) | undefined; + private _onUpdateNode: ((id: IdType) => void) | undefined; + + private nodes: NetworkNodeView[] = []; + private links: NetworkLinkView[] = []; + + // Graph state + private selectedNode: NetworkNodeView | null = null; + private selectedLink: NetworkLinkView | null = null; + private highlightedNodes = new Set(); + private highlightedLinks = new Set(); + + + private readonly linkDefaultDistance = 30; + private readonly chargeDefaultStrength = -80; + private readonly linkHighlightDistance = 120; + private readonly chargeHighlightStrength = -200; + private readonly linkDefaultWidth = 1; + + // Clustering + private clusterMode = 'enabled'; + private clustersCalculated = false; + + + // Colors + private colors = { + textColor: '#000000', + nodeColor: '#1f77b4', + nodeHighlighted: '#18a0b1', + nodeSelected: '#72fefb', + linkColor: '#999999', + linkHighlighted: '#18a0b1', + linkSelected: '#72fefb', + clusterColors: [ + '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', + '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf' + ] + }; + + scale = 1; + + constructor( + private network: ReadonlyNetwork, + private options: NetworkRendererOptions, + ) { + this.compile(options); + } + + private compile(options: NetworkRendererOptions) { + const nodeMap = new Map(); + this.nodes = this.network.nodes().map((node, index) => { + nodeMap.set(node.id, index); + return { + id: node.id, + index, + radius: 8, // Fixed size like graph.js + label: options.getNodeLabel?.(node), + details: options.getNodeDetails?.(node), + meta: options.getNodeMeta?.(node), + ...options.getNodeInitialAttrs?.(node, index), + }; + }); + this.links = this.network.links().map((link, index) => ({ + id: link.id, + index, + source: this.nodes[nodeMap.get(link.source)!], + target: this.nodes[nodeMap.get(link.target)!], + label: options.getLinkLabel?.(link), + details: options.getLinkDetails?.(link), + meta: options.getLinkMeta?.(link), + })); + } + + get massive() { + return this.nodes.length > 50 || this.links.length > 50; + } + + mount(container: HTMLElement) { + if (this._el) { + return; + } + this._el = container; + + // Get initial container dimensions + const { width: initialWidth, height: initialHeight } = container.getBoundingClientRect(); + + // Initialize ForceGraph + this._graph = (ForceGraph as any)()(container) + .width(initialWidth) + .height(initialHeight) + .backgroundColor('transparent') + .nodeCanvasObject((node: any, ctx: CanvasRenderingContext2D) => { + this.drawNodeWithLabel(node, ctx); + }) + .linkWidth(this.linkDefaultWidth) + .linkColor((link: any) => { + if (this.selectedLink && this.selectedLink.id === link.id) { + return this.colors.linkSelected; + } else if (this.highlightedLinks.has(link.id)) { + return this.colors.linkHighlighted; + } else { + return this.colors.linkColor; + } + }) + .linkDirectionalArrowLength(6) + .linkDirectionalArrowRelPos(1) + .linkCurvature(0.1) + .onNodeClick((node: any, event: MouseEvent) => { + this.onNodeClick(node, event); + }) + .onLinkClick((link: any, event: MouseEvent) => { + this.onLinkClick(link, event); + }) + .onBackgroundClick(() => { + this.onBackgroundClick(); + }) + .d3Force('x', d3.forceX(0).strength(0.05)) + .d3Force('y', d3.forceY(0).strength(0.05)) + .d3Force("link", d3.forceLink().id((d: any) => d.id).distance(this.linkDefaultDistance)) + .d3Force("charge", d3.forceManyBody().strength(this.chargeDefaultStrength)); + + // Set container styling to match legacy renderer + container.style.overflow = 'hidden'; + + // Handle resize + this._ro = new ResizeObserver(([entry]) => { + const { width, height } = entry.contentRect; + this._graph.width(width).height(height); + }); + this._ro.observe(container); + + // Set initial data + this.render(); + + // Ensure the canvas fills the container after ForceGraph creates it + setTimeout(() => { + const canvas = container.querySelector('canvas'); + if (canvas) { + canvas.style.width = '100%'; + canvas.style.height = '100%'; + canvas.style.display = 'block'; + } + }, 0); + } + + unmount() { + if (this._onUpdateLink) { + this.network.off('update:link', this._onUpdateLink); + this._onUpdateLink = undefined; + } + if (this._onUpdateNode) { + this.network.off('update:node', this._onUpdateNode); + this._onUpdateNode = undefined; + } + if (!this._el) { + return; + } + this._ro?.disconnect(); + this._ro = undefined; + this._el = undefined; + } + + private drawNodeWithLabel(node: any, ctx: CanvasRenderingContext2D) { + const nodeRadius = 8; // Fixed radius like graph.js + ctx.beginPath(); + ctx.arc(node.x, node.y, nodeRadius, 0, 2 * Math.PI, false); + + const clusterModeOn = this.clusterMode === 'enabled'; + let nodeColor; + if (clusterModeOn && node.clusterId !== undefined) { + const clusterId = node.clusterId || 0; + nodeColor = this.colors.clusterColors[clusterId % this.colors.clusterColors.length]; + } else { + nodeColor = this.options.getNodeColor?.(this.network.node(node.id) as any) ?? this.colors.nodeColor; + } + + ctx.fillStyle = nodeColor; + ctx.fill(); + + // Add outline for selected or highlighted nodes + if (this.selectedNode && this.selectedNode.id === node.id) { + ctx.strokeStyle = this.colors.nodeSelected; + ctx.lineWidth = 3; + ctx.stroke(); + } else if (this.highlightedNodes.has(node.id)) { + ctx.strokeStyle = this.colors.nodeHighlighted; + ctx.lineWidth = 3; + ctx.stroke(); + } + + // Draw label + const label = this.options.getNodeLabel?.(this.network.node(node.id)!) ?? node.name ?? node.id; + const fontSize = Math.max(8, nodeRadius * 0.3); + ctx.font = `${fontSize}px Sans-Serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = this.options.getNodeLabelColor?.(this.network.node(node.id) as any) ?? this.colors.textColor; + ctx.fillText(label, node.x, node.y + nodeRadius + fontSize * 0.7); + } + + private onNodeClick(node: any, event: MouseEvent) { + this.selectedNode = node; + this.selectedLink = null; + + this.options.onClickNode?.(this.network.node(node.id)!, event); + this.highlightConnections(node); + } + + private onLinkClick(link: any, event: MouseEvent) { + this.selectedLink = link; + this.selectedNode = null; + + this.options.onClickLink?.(this.network.link(link.id)!, event); + this.highlightLink(link); + } + + private onBackgroundClick() { + this.options.onClickCanvas?.(); + this.clearHighlight(); + } + + private highlightLink(link: any) { + this.highlightedLinks.clear(); + this.highlightedLinks.add(link.id); + } + + private highlightConnections(node: any) { + const connectedNodeIds = new Set(); + const connectedLinkIds = new Set(); + + this._graph.graphData().links.forEach((link: any) => { + if (link.source.id === node.id) { + connectedNodeIds.add(link.target.id); + connectedLinkIds.add(link.id); + } else if (link.target.id === node.id) { + connectedNodeIds.add(link.source.id); + connectedLinkIds.add(link.id); + } + }); + + this.highlightedNodes.clear(); + connectedNodeIds.forEach(nodeId => this.highlightedNodes.add(nodeId)); + + this.highlightedLinks.clear(); + connectedLinkIds.forEach(linkId => this.highlightedLinks.add(linkId)); + + // Update force simulation for highlighting + this._graph.d3Force("link").distance((link: any) => { + if (connectedLinkIds.has(link.id)) { + return this.linkHighlightDistance; + } + return this.linkDefaultDistance; + }); + + this._graph.d3Force("charge").strength((node: any) => { + if (connectedNodeIds.has(node.id)) { + return this.chargeHighlightStrength; + } + return this.chargeDefaultStrength; + }); + + this._graph.d3ReheatSimulation(); + } + + private clearHighlight() { + this.selectedNode = null; + this.selectedLink = null; + this.highlightedNodes.clear(); + this.highlightedLinks.clear(); + this._graph.d3Force("link").distance(this.linkDefaultDistance); + this._graph.d3Force("charge").strength(this.chargeDefaultStrength); + } + + private calculateAndCacheClusters() { + if (!this.nodes || !this.links) { + return; + } + const clusters = this.findClusters(); + + this.nodes.forEach(node => { + (node as any).clusterId = clusters.get(node.id) || 0; + }); + + this.clustersCalculated = true; + } + + private findClusters(): Map { + const clusters = new Map(); + const visited = new Set(); + let clusterId = 0; + + const adjacencyList = new Map(); + this.nodes.forEach(node => { + adjacencyList.set(node.id, []); + }); + + this.links.forEach(link => { + const sourceId = typeof link.source === 'object' ? link.source.id : link.source; + const targetId = typeof link.target === 'object' ? link.target.id : link.target; + + if (adjacencyList.has(sourceId) && adjacencyList.has(targetId)) { + adjacencyList.get(sourceId)!.push(targetId); + adjacencyList.get(targetId)!.push(sourceId); + } + }); + + const dfs = (nodeId: IdType, currentClusterId: number) => { + if (visited.has(nodeId)) return; + + visited.add(nodeId); + clusters.set(nodeId, currentClusterId); + + const neighbors = adjacencyList.get(nodeId) || []; + neighbors.forEach(neighborId => { + if (!visited.has(neighborId)) { + dfs(neighborId, currentClusterId); + } + }); + }; + + this.nodes.forEach(node => { + if (!visited.has(node.id)) { + dfs(node.id, clusterId); + clusterId++; + } + }); + + return clusters; + } + + focusNode(id: IdType): void { + // Find the node and set it as selected + const node = this.nodes.find(n => n.id === id); + if (node) { + this.selectedNode = node; + this.selectedLink = null; + this.highlightConnections(node); + // Trigger visual update + this._graph.nodeColor(this._graph.nodeColor()); + this._graph.linkColor(this._graph.linkColor()); + } + this._onFocusNode?.(id); + } + + blurNode(): void { + this.clearHighlight(); + // Trigger visual update + this._graph.nodeColor(this._graph.nodeColor()); + this._graph.linkColor(this._graph.linkColor()); + this._onBlurNode?.(); + } + + focusLink(id: IdType): void { + // Find the link and set it as selected + const link = this.links.find(l => l.id === id); + if (link) { + this.selectedLink = link; + this.selectedNode = null; + this.highlightLink(link); + // Trigger visual update + this._graph.nodeColor(this._graph.nodeColor()); + this._graph.linkColor(this._graph.linkColor()); + } + this._onFocusLink?.(id); + } + + blurLink(): void { + this.clearHighlight(); + // Trigger visual update + this._graph.nodeColor(this._graph.nodeColor()); + this._graph.linkColor(this._graph.linkColor()); + this._onBlurLink?.(); + } + + render() { + // Calculate clusters if needed + if (!this.clustersCalculated) { + this.calculateAndCacheClusters(); + } + + // Set graph data + const graphData = { + nodes: this.nodes, + links: this.links + }; + + this._graph.graphData(graphData); + + // Set up event handlers + this._onFocusNode = () => { + // Focus logic would go here + }; + + this._onFocusLink = () => { + // Focus logic would go here + }; + + this._onBlurNode = () => { + this.clearHighlight(); + }; + + this._onBlurLink = () => { + this.clearHighlight(); + }; + + this._onUpdateNode = () => { + // Update node logic + }; + + this._onUpdateLink = () => { + // Update link logic + }; + + this.network.on('update:node', this._onUpdateNode); + this.network.on('update:link', this._onUpdateLink); + + // Zoom to fit after initial render + setTimeout(() => { + this._graph.zoomToFit(400, 50); + }, 1000); + } +} \ No newline at end of file diff --git a/frontend/app/src/components/graph/network/NetworkRenderer.ts b/frontend/app/src/components/graph/network/NetworkRenderer.ts index 7db2ac2bc..d8e1f0ec4 100644 --- a/frontend/app/src/components/graph/network/NetworkRenderer.ts +++ b/frontend/app/src/components/graph/network/NetworkRenderer.ts @@ -154,9 +154,14 @@ export class NetworkRenderer if (!this._el) { return; } - this._el.removeChild(this._graph.node()!); + // Safely remove the graph node if it exists and is still a child + const graphNode = this._graph.node(); + if (graphNode && this._el.contains(graphNode)) { + this._el.removeChild(graphNode); + } this._ro?.disconnect(); this._ro = undefined; + this._el = undefined; } private renderGrid (width: number, height: number) { diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 79a6c7dab..d79357815 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -127,6 +127,9 @@ importers: embla-carousel-react: specifier: ^8.5.1 version: 8.5.1(react@19.0.0) + force-graph: + specifier: ^1.49.6 + version: 1.49.6 input-otp: specifier: ^1.4.1 version: 1.4.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -3088,6 +3091,9 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@tweenjs/tween.js@25.0.0': + resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -3495,6 +3501,10 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + accessor-fn@1.5.3: + resolution: {integrity: sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==} + engines: {node: '>=12'} + acorn-globals@7.0.1: resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} @@ -3763,6 +3773,9 @@ packages: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} engines: {node: '>=12.0.0'} + bezier-js@6.1.4: + resolution: {integrity: sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==} + big.js@5.2.2: resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} @@ -3881,6 +3894,10 @@ packages: caniuse-lite@1.0.30001687: resolution: {integrity: sha512-0S/FDhf4ZiqrTUiQ39dKeUjYRjkv7lOZU1Dgif2rIqrTzX/1wV2hfKu9TOm1IHkdSijfLswxTFzl/cvir+SLSQ==} + canvas-color-tracker@1.3.2: + resolution: {integrity: sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==} + engines: {node: '>=12'} + case-sensitive-paths-webpack-plugin@2.4.0: resolution: {integrity: sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==} engines: {node: '>=4'} @@ -4193,6 +4210,9 @@ packages: resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} engines: {node: '>=12'} + d3-binarytree@1.0.2: + resolution: {integrity: sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==} + d3-brush@3.0.0: resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} engines: {node: '>=12'} @@ -4234,6 +4254,10 @@ packages: resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} engines: {node: '>=12'} + d3-force-3d@3.0.6: + resolution: {integrity: sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==} + engines: {node: '>=12'} + d3-force@3.0.0: resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} engines: {node: '>=12'} @@ -4254,6 +4278,9 @@ packages: resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} engines: {node: '>=12'} + d3-octree@1.1.0: + resolution: {integrity: sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==} + d3-path@3.1.0: resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} engines: {node: '>=12'} @@ -4933,9 +4960,17 @@ packages: flatted@3.3.2: resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} + float-tooltip@1.7.5: + resolution: {integrity: sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==} + engines: {node: '>=12'} + for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + force-graph@1.49.6: + resolution: {integrity: sha512-o3uZ22YMvRwcS4RZ5lMQE5mCsQ5w1AzR4bPlQ1cThqgAxRrzOO4mGOaeNGTAkGGQwL6f7RIBCaxPNtvkbgAykw==} + engines: {node: '>=12'} + foreground-child@3.3.0: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} @@ -5231,6 +5266,10 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + index-array-by@1.4.2: + resolution: {integrity: sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==} + engines: {node: '>=12'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -5691,6 +5730,10 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + kapsule@1.16.3: + resolution: {integrity: sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==} + engines: {node: '>=12'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -5751,6 +5794,9 @@ packages: resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash.castarray@4.4.0: resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==} @@ -6463,6 +6509,9 @@ packages: resolution: {integrity: sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==} engines: {node: ^10 || ^12 || >=14} + preact@10.26.9: + resolution: {integrity: sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -7280,6 +7329,9 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + tinyrainbow@1.2.0: resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} @@ -10768,6 +10820,8 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@tweenjs/tween.js@25.0.0': {} + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -11363,6 +11417,8 @@ snapshots: dependencies: event-target-shim: 5.0.1 + accessor-fn@1.5.3: {} + acorn-globals@7.0.1: dependencies: acorn: 8.14.0 @@ -11692,6 +11748,8 @@ snapshots: dependencies: open: 8.4.2 + bezier-js@6.1.4: {} + big.js@5.2.2: {} binary-extensions@2.3.0: {} @@ -11830,6 +11888,10 @@ snapshots: caniuse-lite@1.0.30001687: {} + canvas-color-tracker@1.3.2: + dependencies: + tinycolor2: 1.6.0 + case-sensitive-paths-webpack-plugin@2.4.0: {} ccount@2.0.1: {} @@ -12163,6 +12225,8 @@ snapshots: d3-axis@3.0.0: {} + d3-binarytree@1.0.2: {} + d3-brush@3.0.0: dependencies: d3-dispatch: 3.0.1 @@ -12204,6 +12268,14 @@ snapshots: dependencies: d3-dsv: 3.0.1 + d3-force-3d@3.0.6: + dependencies: + d3-binarytree: 1.0.2 + d3-dispatch: 3.0.1 + d3-octree: 1.1.0 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + d3-force@3.0.0: dependencies: d3-dispatch: 3.0.1 @@ -12222,6 +12294,8 @@ snapshots: dependencies: d3-color: 3.1.0 + d3-octree@1.1.0: {} + d3-path@3.1.0: {} d3-polygon@3.0.1: {} @@ -13074,10 +13148,34 @@ snapshots: flatted@3.3.2: {} + float-tooltip@1.7.5: + dependencies: + d3-selection: 3.0.0 + kapsule: 1.16.3 + preact: 10.26.9 + for-each@0.3.3: dependencies: is-callable: 1.2.7 + force-graph@1.49.6: + dependencies: + '@tweenjs/tween.js': 25.0.0 + accessor-fn: 1.5.3 + bezier-js: 6.1.4 + canvas-color-tracker: 1.3.2 + d3-array: 3.2.4 + d3-drag: 3.0.0 + d3-force-3d: 3.0.6 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + float-tooltip: 1.7.5 + index-array-by: 1.4.2 + kapsule: 1.16.3 + lodash-es: 4.17.21 + foreground-child@3.3.0: dependencies: cross-spawn: 7.0.6 @@ -13400,6 +13498,8 @@ snapshots: indent-string@4.0.0: {} + index-array-by@1.4.2: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -14067,6 +14167,10 @@ snapshots: object.assign: 4.1.5 object.values: 1.2.0 + kapsule@1.16.3: + dependencies: + lodash-es: 4.17.21 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -14116,6 +14220,8 @@ snapshots: dependencies: p-locate: 6.0.0 + lodash-es@4.17.21: {} + lodash.castarray@4.4.0: {} lodash.debounce@4.0.8: {} @@ -15077,6 +15183,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact@10.26.9: {} + prelude-ls@1.2.1: {} prettier@3.3.3: @@ -16080,6 +16188,8 @@ snapshots: tiny-invariant@1.3.3: {} + tinycolor2@1.6.0: {} + tinyrainbow@1.2.0: {} tinyspy@3.0.2: {} From 3161116bc3343fa486819850ff24136b0feaae2c Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 1 Jul 2025 12:18:41 -0700 Subject: [PATCH 4/5] update node, update link logic --- .../graph/network/CanvasNetworkRenderer.ts | 157 +++++++++--------- 1 file changed, 79 insertions(+), 78 deletions(-) diff --git a/frontend/app/src/components/graph/network/CanvasNetworkRenderer.ts b/frontend/app/src/components/graph/network/CanvasNetworkRenderer.ts index 7f0166aaf..93f00a388 100644 --- a/frontend/app/src/components/graph/network/CanvasNetworkRenderer.ts +++ b/frontend/app/src/components/graph/network/CanvasNetworkRenderer.ts @@ -8,10 +8,6 @@ export class CanvasNetworkRenderer void) | undefined; - private _onFocusLink: ((id: IdType) => void) | undefined; - private _onBlurNode: (() => void) | undefined; - private _onBlurLink: (() => void) | undefined; private _onUpdateLink: ((id: IdType) => void) | undefined; private _onUpdateNode: ((id: IdType) => void) | undefined; @@ -68,7 +64,7 @@ export class CanvasNetworkRenderer d.id).distance(this.linkDefaultDistance)) .d3Force("charge", d3.forceManyBody().strength(this.chargeDefaultStrength)); - // Set container styling to match legacy renderer container.style.overflow = 'hidden'; - // Handle resize this._ro = new ResizeObserver(([entry]) => { const { width, height } = entry.contentRect; this._graph.width(width).height(height); }); this._ro.observe(container); - // Set initial data this.render(); // Ensure the canvas fills the container after ForceGraph creates it @@ -170,13 +162,31 @@ export class CanvasNetworkRenderer this.highlightedLinks.add(linkId)); - // Update force simulation for highlighting this._graph.d3Force("link").distance((link: any) => { if (connectedLinkIds.has(link.id)) { return this.linkHighlightDistance; @@ -286,6 +294,32 @@ export class CanvasNetworkRenderer n.id === id); + if (node) { + this.selectedNode = node; + this.selectedLink = null; + this.highlightConnections(node); + } + } + + blurNode(): void { + this.clearHighlight(); + } + + focusLink(id: IdType): void { + const link = this.links.find(l => l.id === id); + if (link) { + this.selectedLink = link; + this.selectedNode = null; + this.highlightLink(link); + } + } + + blurLink(): void { + this.clearHighlight(); + } + private calculateAndCacheClusters() { if (!this.nodes || !this.links) { return; @@ -343,49 +377,6 @@ export class CanvasNetworkRenderer n.id === id); - if (node) { - this.selectedNode = node; - this.selectedLink = null; - this.highlightConnections(node); - // Trigger visual update - this._graph.nodeColor(this._graph.nodeColor()); - this._graph.linkColor(this._graph.linkColor()); - } - this._onFocusNode?.(id); - } - - blurNode(): void { - this.clearHighlight(); - // Trigger visual update - this._graph.nodeColor(this._graph.nodeColor()); - this._graph.linkColor(this._graph.linkColor()); - this._onBlurNode?.(); - } - - focusLink(id: IdType): void { - // Find the link and set it as selected - const link = this.links.find(l => l.id === id); - if (link) { - this.selectedLink = link; - this.selectedNode = null; - this.highlightLink(link); - // Trigger visual update - this._graph.nodeColor(this._graph.nodeColor()); - this._graph.linkColor(this._graph.linkColor()); - } - this._onFocusLink?.(id); - } - - blurLink(): void { - this.clearHighlight(); - // Trigger visual update - this._graph.nodeColor(this._graph.nodeColor()); - this._graph.linkColor(this._graph.linkColor()); - this._onBlurLink?.(); - } render() { // Calculate clusters if needed @@ -401,37 +392,47 @@ export class CanvasNetworkRenderer { - // Focus logic would go here - }; - - this._onFocusLink = () => { - // Focus logic would go here - }; - - this._onBlurNode = () => { - this.clearHighlight(); - }; - - this._onBlurLink = () => { - this.clearHighlight(); - }; - - this._onUpdateNode = () => { - // Update node logic + this._onUpdateNode = (id: IdType) => { + const nodeIndex = this.nodes.findIndex(n => n.id === id); + if (nodeIndex !== -1) { + const networkNode = this.network.node(id); + if (networkNode) { + this.nodes[nodeIndex].label = this.options.getNodeLabel?.(networkNode); + this.nodes[nodeIndex].details = this.options.getNodeDetails?.(networkNode); + this.nodes[nodeIndex].meta = this.options.getNodeMeta?.(networkNode); + + this._graph.graphData({ + nodes: this.nodes, + links: this.links + }); + } + } }; - this._onUpdateLink = () => { - // Update link logic + this._onUpdateLink = (id: IdType) => { + const linkIndex = this.links.findIndex(l => l.id === id); + if (linkIndex !== -1) { + const networkLink = this.network.link(id); + if (networkLink) { + this.links[linkIndex].label = this.options.getLinkLabel?.(networkLink); + this.links[linkIndex].details = this.options.getLinkDetails?.(networkLink); + this.links[linkIndex].meta = this.options.getLinkMeta?.(networkLink); + + this._graph.graphData({ + nodes: this.nodes, + links: this.links + }); + } + } }; this.network.on('update:node', this._onUpdateNode); this.network.on('update:link', this._onUpdateLink); - // Zoom to fit after initial render setTimeout(() => { - this._graph.zoomToFit(400, 50); + if (this._graph && this._graph.zoomToFit) { + this._graph.zoomToFit(400, 50); + } }, 1000); } } \ No newline at end of file From cf2a8327e8943b78362c5920e98d1069e32728ff Mon Sep 17 00:00:00 2001 From: Felix Date: Wed, 2 Jul 2025 19:41:08 -0700 Subject: [PATCH 5/5] revert docker-compose.dev.yml: --- docker-compose.dev.yml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2853b9696..0f5c86dac 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -13,9 +13,6 @@ services: - .env volumes: - ./data:/shared/data - - ./backend:/app - - /app/.venv - command: fastapi dev app/api_server.py --host 0.0.0.0 --port 80 --reload depends_on: - redis @@ -23,17 +20,12 @@ services: build: context: . dockerfile: ./frontend/Dockerfile - target: deps args: BUILDKIT_INLINE_CACHE: 1 ports: - "3001:3000" environment: BASE_URL: http://backend - HOSTNAME: 0.0.0.0 - volumes: - - ./frontend:/tidb.ai/frontend - command: sh -c "cd /tidb.ai/frontend && pnpm run dev" depends_on: - backend @@ -49,8 +41,6 @@ services: - .env volumes: - ./data:/shared/data - - ./backend:/app - - /app/.venv command: /usr/bin/supervisord depends_on: - redis @@ -74,4 +64,4 @@ services: image: redis:6.0.16 volumes: - ./redis-data:/data - command: ["redis-server", "--loglevel", "warning"] + command: ["redis-server", "--loglevel", "warning"] \ No newline at end of file