leankg 0.19.13

Lightweight Knowledge Graph for AI-Assisted Development
import { useEffect, useRef } from 'react';
import type Graph from 'graphology';
import { useSigma } from '../hooks/useSigma';
import type { SigmaNodeAttributes, SigmaEdgeAttributes } from '../lib/graph-adapter';
import { filterGraphByLabels } from '../lib/graph-adapter';
import { QueryFAB } from './QueryFAB';

declare global {
  interface Window {
    sig?: unknown;
  }
}

interface GraphCanvasProps {
  graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes> | null;
  visibleLabels: string[];
  visibleEdges: string[];
  searchTerm: string;
  highlightIds: Set<string>;
  onNodeSelect: (nodeId: string | null) => void;
  onNodeDoubleClick?: (nodeId: string) => void;
  selectedNodeId: string | null;
  layoutMode: 'force' | 'tree' | 'circles';
}

export function GraphCanvas({
  graph,
  visibleLabels,
  visibleEdges,
  searchTerm,
  highlightIds,
  onNodeSelect,
  onNodeDoubleClick,
  selectedNodeId,
  layoutMode,
}: GraphCanvasProps) {
  const {
    containerRef,
    sigmaRef,
    setGraph,
    zoomIn,
    zoomOut,
    resetZoom,
    focusNode,
    setSelectedNode,
  } = useSigma({
    onNodeClick: (id) => {
      onNodeSelect(id);
      return true;
    },
    onNodeDoubleClick: (id) => onNodeDoubleClick?.(id),
    onStageClick: () => onNodeSelect(null),
    visibleEdgeTypes: visibleEdges,
    searchTerm,
    layoutMode,
  });

  const appliedRef = useRef<{ graph: Graph | null; mode: string }>({ graph: null, mode: '' });

  useEffect(() => {
    if (!graph) return;
    // Re-apply when layout mode changes even if the same KnowledgeGraph object is reused.
    if (appliedRef.current.graph === graph && appliedRef.current.mode === layoutMode) return;
    appliedRef.current = { graph, mode: layoutMode };
    const clone = graph.copy();
    filterGraphByLabels(clone, visibleLabels);
    setGraph(clone);
  }, [graph, setGraph, visibleLabels, layoutMode]);

  useEffect(() => {
    const sigma = sigmaRef.current;
    if (!graph || !sigma) return;
    const g = sigma.getGraph() as Graph<SigmaNodeAttributes, SigmaEdgeAttributes>;
    filterGraphByLabels(g, visibleLabels);
    g.forEachEdge((edge, attrs) => {
      const rel = String(attrs.relationType || '').toUpperCase();
      const show = visibleEdges.length === 0 || visibleEdges.includes(rel);
      g.setEdgeAttribute(edge, 'hidden', !show);
    });
    sigma.refresh();
  }, [visibleLabels, visibleEdges, graph, sigmaRef]);

  useEffect(() => {
    if (selectedNodeId) {
      setSelectedNode(selectedNodeId);
      focusNode(selectedNodeId);
    }
  }, [selectedNodeId, setSelectedNode, focusNode]);

  useEffect(() => {
    if (sigmaRef.current) {
      window.sig = sigmaRef.current;
    }
    return () => {
      if (window.sig === sigmaRef.current) delete window.sig;
    };
  }, [sigmaRef, graph]);

  useEffect(() => {
    const sigma = sigmaRef.current;
    if (!sigma || highlightIds.size === 0) return;
    const g = sigma.getGraph();
    g.forEachNode((id) => {
      g.setNodeAttribute(id, 'highlighted', highlightIds.has(id));
    });
    sigma.refresh();
  }, [highlightIds, sigmaRef]);

  return (
    <div className="relative flex-1 min-w-0 min-h-0 h-full w-full bg-void" data-testid="graph-canvas">
      <div
        ref={containerRef}
        className="sigma-container absolute inset-0 w-full h-full"
        style={{ minHeight: '100%' }}
      />
      {!graph && (
        <div className="absolute inset-0 flex items-center justify-center text-text-muted text-sm">
          No graph loaded
        </div>
      )}
      <div className="absolute bottom-4 right-4 flex flex-col gap-1 z-10">
        <button
          type="button"
          onClick={zoomIn}
          className="w-8 h-8 rounded bg-elevated border border-border-subtle text-text-secondary hover:text-text-primary"
        >
          +
        </button>
        <button
          type="button"
          onClick={zoomOut}
          className="w-8 h-8 rounded bg-elevated border border-border-subtle text-text-secondary hover:text-text-primary"
        >
          −
        </button>
        <button
          type="button"
          onClick={resetZoom}
          className="w-8 h-8 rounded bg-elevated border border-border-subtle text-text-secondary text-[10px]"
        >
          Fit
        </button>
      </div>
      <QueryFAB />
    </div>
  );
}