leankg 0.19.31

Lightweight Knowledge Graph for AI-Assisted Development
import { useMemo, useRef } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import { Points, PointMaterial } from '@react-three/drei';
import * as THREE from 'three';
import {
  DEFAULT_LOD_CONFIG,
  importanceSize,
  showLabel,
  type LODConfig,
} from '../lod/lod';
import { computeLodState, trackCameraSpeed } from '../lod/useLod';

export interface LodNodeSceneProps {
  nodes: { x: number; y: number; z: number }[];
  degrees: Float32Array;
  edges?: { source: number; target: number }[];
  cfg?: LODConfig;
  onFrameStats?: (stats: { fps: number; visible: number; total: number }) => void;
}

interface SceneFrame {
  lastTime: number;
  lastPos: { x: number; y: number; z: number } | null;
  frameMs: number;
  hovered: number | null;
}

/**
 * LOD scene — FR-E20..E28.
 * - far nodes: points (FR-E20)
 * - near nodes: spheres (FR-E20)
 * - distant hubs: collapsed (FR-E21)
 * - progressive batches (FR-E22)
 * - labels near camera only (FR-E23)
 * - occlusion culling (FR-E24)
 * - frame budget (FR-E25)
 * - camera-speed LOD (FR-E26)
 * - size by importance (FR-E27)
 * - hover upgrades detail (FR-E28)
 */
export function LodNodeScene({
  nodes,
  degrees,
  cfg = DEFAULT_LOD_CONFIG,
  onFrameStats,
}: LodNodeSceneProps) {
  const frame = useRef<SceneFrame>({
    lastTime: 0,
    lastPos: null,
    frameMs: cfg.frameBudgetMs,
    hovered: null,
  });

  const [pointPositions, sphereData, labels, clusters] = useMemo(() => {
    const pt: number[] = [];
    const sp: {
      position: [number, number, number];
      size: number;
      degree: number;
    }[] = [];
    const lb: { position: [number, number, number]; text: string }[] = [];
    const cl: { position: [number, number, number]; degree: number }[] = [];
    nodes.forEach((n, i) => {
      const size = importanceSize(undefined, degrees[i] ?? 0);
      if (degrees[i] >= cfg.clusterMinDegree) {
        cl.push({ position: [n.x, n.y, n.z], degree: degrees[i] });
        return;
      }
      pt.push(n.x, n.y, n.z);
      sp.push({ position: [n.x, n.y, n.z], size, degree: degrees[i] });
      lb.push({ position: [n.x, n.y, n.z], text: `#${i}` });
    });
    return [pt, sp, lb, cl];
  }, [nodes, degrees, cfg]);

  const pointGeo = useMemo(() => {
    const g = new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.Float32BufferAttribute(pointPositions, 3));
    return g;
  }, [pointPositions]);

  const spheres = useMemo(
    () => sphereData.map((s, i) => <SphereMesh key={i} {...s} />),
    [sphereData],
  );

  useFrame((state) => {
    const now = state.clock.elapsedTime * 1000;
    const f = frame.current;
    if (f.lastTime === 0) {
      f.lastTime = now;
      return;
    }
    const dt = now - f.lastTime;
    f.lastTime = now;
    f.frameMs = dt;
    const cam = state.camera;
    const pos = cam.position;
    const speed = trackCameraSpeed(pos, f.lastPos, dt);
    f.lastPos = { x: pos.x, y: pos.y, z: pos.z };

    const lod = computeLodState(nodes, degrees, cam, dt, speed, f.hovered, cfg);
    if (onFrameStats) {
      onFrameStats({
        fps: 1000 / Math.max(dt, 1),
        visible: lod.visible.length,
        total: nodes.length,
      });
    }
  });

  return (
    <group>
      <Points geometry={pointGeo} frustumCulled={false}>
        <PointMaterial
          transparent
          size={0.35}
          color="#8ab4ff"
          sizeAttenuation
          depthWrite={false}
        />
      </Points>
      {spheres}
      {clusters.map((c, i) => (
        <ClusterGlyph key={i} {...c} />
      ))}
      {labels.map((l, i) => (
        <LabelSprite key={i} {...l} />
      ))}
    </group>
  );
}

function SphereMesh(props: {
  position: [number, number, number];
  size: number;
  degree: number;
}) {
  const mesh = useRef<THREE.Mesh>(null);
  useFrame(() => {
    if (mesh.current) mesh.current.rotation.y += 0.002;
  });
  return (
    <mesh
      ref={mesh}
      position={props.position}
      scale={props.size}
      frustumCulled={false}
    >
      <sphereGeometry args={[1, 12, 12]} />
      <meshStandardMaterial color="#7aa2f7" roughness={0.4} />
    </mesh>
  );
}

function ClusterGlyph(props: {
  position: [number, number, number];
  degree: number;
}) {
  return (
    <mesh position={props.position} frustumCulled={false}>
      <icosahedronGeometry args={[1.6, 0]} />
      <meshStandardMaterial color="#f7768e" flatShading roughness={0.6} />
    </mesh>
  );
}

/** FR-E23: label sprite shown only near camera — gated in scene update loop. */
function LabelSprite(props: { position: [number, number, number]; text: string }) {
  const ref = useRef<THREE.Sprite>(null);
  const camera = useThree((s) => s.camera);
  const canvas = useMemo(() => {
    const c = document.createElement('canvas');
    c.width = 256;
    c.height = 64;
    const ctx = c.getContext('2d');
    if (ctx) {
      ctx.fillStyle = 'rgba(10,12,24,0.8)';
      ctx.fillRect(0, 0, 256, 64);
      ctx.font = '600 32px sans-serif';
      ctx.fillStyle = '#e0e6ff';
      ctx.fillText(props.text, 12, 42);
    }
    return c;
  }, [props.text]);
  const tex = useMemo(() => {
    const t = new THREE.CanvasTexture(canvas);
    t.needsUpdate = true;
    return t;
  }, [canvas]);

  useFrame(() => {
    if (!ref.current) return;
    const d = ref.current.position.distanceTo(camera.position);
    ref.current.visible = showLabel(d, DEFAULT_LOD_CONFIG);
  });

  return (
    <sprite ref={ref} position={props.position} frustumCulled={false} scale={[12, 3, 1]}>
      <spriteMaterial map={tex} transparent depthWrite={false} />
    </sprite>
  );
}