Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/universe/graph-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function apiToGraph(
const rawNodes: RawNode[] = nodes.map((n) => ({
id: n.ref_id,
label: truncateLabel(nodeLabel(n, schemas)),
...(n.properties.image_url != null && { imageUrl: n.properties.image_url as string }),
}))

const nodeTypeById = new Map(nodes.map((n) => [n.ref_id, n.node_type || "Unknown"]))
Expand Down
117 changes: 117 additions & 0 deletions src/graph-viz-kit/GraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,52 @@ function sampleBezier(
}


// ─── Image billboard sub-component ─────────────────────────────────────────
// Mounts a textured plane mesh for a node that has image_url set.
// TextureLoader is called inside useEffect so hooks rules are satisfied
// and @react-three/drei's texture cache is not needed here.
function ImageNodeMesh({
url,
position,
size,
}: {
url: string;
position: [number, number, number];
size: number;
}) {
const [texture, setTexture] = useState<THREE.Texture | null>(null);
const [failed, setFailed] = useState(false);

useEffect(() => {
const loader = new THREE.TextureLoader();
loader.setCrossOrigin("anonymous");
loader.load(
url,
(tex) => setTexture(tex),
undefined,
() => setFailed(true), // graceful fallback: don't render
);
return () => {
// no cleanup needed — THREE.TextureLoader manages its own requests
};
}, [url]);

if (!texture || failed) return null;

const imgSize = Math.min(Math.max(size, 2), 12);
return (
<mesh position={position} frustumCulled={false}>
<planeGeometry args={[imgSize, imgSize]} />
<meshBasicMaterial
map={texture}
transparent
alphaTest={0.05}
toneMapped={false}
/>
</mesh>
);
}

export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minimap, whiteboardNodeId, onExitWhiteboard, onDetailNavigate, searchMatches, pulses, recentNodes, expandedClusterId, externalHoveredId, externalSelectedId, onGraphClick, nodeTypeIcons }: GraphViewProps) {
const meshRef = useRef<THREE.InstancedMesh>(null);
const linesRef = useRef<THREE.LineSegments>(null);
Expand All @@ -359,6 +405,11 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima
const approachRef = useRef<{ nodeId: number; progress: number }>({ nodeId: -1, progress: 0 });
const [approachState, setApproachState] = useState<{ nodeId: number; progress: number }>({ nodeId: -1, progress: 0 });

// Image billboard proximity tracking — updated every 10 frames, no per-frame setState
const closeNodes = useRef<Set<number>>(new Set());
const frameCount = useRef(0);
const [imageNodesVisible, setImageNodesVisible] = useState<Set<number>>(new Set());

const nodeCount = graph.nodes.length;

// Capacity rounds up to next 1000 — mesh is recreated only at these boundaries
Expand Down Expand Up @@ -918,6 +969,16 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima
s *= 0.01;
}

// Suppress glow billboard when image mesh is visible for this node
if (
graph.nodes[i].imageUrl &&
graph.nodes[i].nodeType !== "_cluster" &&
graph.nodes[i].nodeType !== "_group" &&
(closeNodes.current.has(i) || i === hovered || i === (externalSelectedRef.current ?? -1))
) {
s *= 0.05;
}

tmpObj.position.set(
currentPos.current[i3],
currentPos.current[i3 + 1],
Expand Down Expand Up @@ -998,6 +1059,38 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima
mesh.setColorAt(i, tmpColor);
}

// Camera proximity detection for image billboards (throttled to every 10 frames)
frameCount.current += 1;
if (frameCount.current % 10 === 0) {
const IMAGE_PROXIMITY_THRESHOLD = 20;
const prevClose = closeNodes.current;
const nextClose = new Set<number>();
for (let i = 0; i < nodeCount; i++) {
const node = graph.nodes[i];
if (!node.imageUrl) continue;
if (node.nodeType === "_cluster" || node.nodeType === "_group") continue;
const i3 = i * 3;
const nx = currentPos.current[i3];
const ny = currentPos.current[i3 + 1];
const nz = currentPos.current[i3 + 2];
const dist = camera.position.distanceTo(new THREE.Vector3(nx, ny, nz));
if (dist < IMAGE_PROXIMITY_THRESHOLD) {
nextClose.add(i);
}
}
// Only update state when the set actually changes
let changed = nextClose.size !== prevClose.size;
if (!changed) {
for (const id of nextClose) {
if (!prevClose.has(id)) { changed = true; break; }
}
}
if (changed) {
closeNodes.current = nextClose;
setImageNodesVisible(new Set(nextClose));
}
}

// Semantic zoom disabled for performance
approachRef.current = { nodeId: -1, progress: 0 };

Expand Down Expand Up @@ -1453,6 +1546,30 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima
/>
</instancedMesh>

{/* Per-node image billboard meshes — only for nodes with imageUrl that are visible */}
{graph.nodes.map((node, i) => {
if (!node.imageUrl) return null;
if (node.nodeType === "_cluster" || node.nodeType === "_group") return null;
const isVisible =
i === hovered ||
i === (externalSelectedId ?? -1) ||
imageNodesVisible.has(i);
if (!isVisible) return null;
const i3 = i * 3;
const lx = i3 + 2 < labelPos.length ? labelPos[i3] : targets.positions[i3];
const ly = i3 + 2 < labelPos.length ? labelPos[i3 + 1] : targets.positions[i3 + 1];
const lz = i3 + 2 < labelPos.length ? labelPos[i3 + 2] : targets.positions[i3 + 2];
const size = Math.min(Math.max(currentScale.current[i] * 8, 2), 12);
return (
<ImageNodeMesh
key={node.id}
url={node.imageUrl}
position={[lx, ly, lz]}
size={size}
/>
);
})}

<lineSegments ref={linesRef} frustumCulled={false}>
<bufferGeometry />
<shaderMaterial
Expand Down
2 changes: 2 additions & 0 deletions src/graph-viz-kit/buildGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface RawNode {
label: string;
link?: string;
icon?: string;
imageUrl?: string;
status?: "executing" | "done" | "idle";
progress?: number;
content?: string;
Expand Down Expand Up @@ -49,6 +50,7 @@ export function buildGraph(nodes: RawNode[], edges: RawEdge[]): Graph {
degree: adj[i].length,
...(node.link != null && { link: node.link }),
...(node.icon != null && { icon: node.icon }),
...(node.imageUrl != null && { imageUrl: node.imageUrl }),
...(node.status != null && { status: node.status }),
...(node.progress != null && { progress: node.progress }),
...(node.content != null && { content: node.content }),
Expand Down
1 change: 1 addition & 0 deletions src/graph-viz-kit/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface GraphNode {
degree: number;
link?: string;
icon?: string;
imageUrl?: string;
status?: "executing" | "done" | "idle";
progress?: number; // 0–1 for executing nodes
content?: string; // descriptive text for detail view
Expand Down
Loading