Files
Momento/architectural-grid1/src/components/NetworkGraph.tsx
Antigravity f46654f574 feat: editor improvements and architectural grid prototype
Multiple feature additions and improvements across the application:

- NextGen Editor: drag handles, smart paste, block actions
- Structured views: Kanban and table layouts for notes
- Architectural Grid: new brainstorming/agent interface prototype
- Flashcards: SM-2 revision algorithm with AI generation
- MCP server: robustness improvements
- Graph/PDF chat: fix click propagation and copy behavior
- Various UI/UX enhancements and bug fixes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:45:15 +00:00

238 lines
8.1 KiB
TypeScript

import React, { useEffect, useRef } from 'react';
import * as d3 from 'd3';
import { Note, NoteCluster, BridgeNote } from '../types';
interface NetworkGraphProps {
notes: Note[];
clusters: NoteCluster[];
bridgeNotes: BridgeNote[];
onNoteSelect: (id: string) => void;
selectedClusterId: string | null;
onClusterSelect: (id: string | null) => void;
}
export const NetworkGraph: React.FC<NetworkGraphProps> = ({
notes,
clusters,
bridgeNotes,
onNoteSelect,
selectedClusterId,
onClusterSelect
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!svgRef.current || !containerRef.current) return;
const width = containerRef.current.clientWidth;
const height = containerRef.current.clientHeight;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const g = svg.append("g");
const zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 4])
.on("zoom", (event) => {
g.attr("transform", event.transform);
});
svg.call(zoom);
// Filter notes with embeddings and cluster assignments
const visibleNotes = notes.filter(n => n.embedding && n.clusterId);
interface D3Node extends d3.SimulationNodeDatum {
id: string;
title: string;
clusterId: string;
color: string;
isBridge: boolean;
radius: number;
}
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
source: string;
target: string;
strength: number;
}
const bridgeSet = new Set(bridgeNotes.map(b => b.noteId));
const nodes: D3Node[] = visibleNotes.map(n => {
const cluster = clusters.find(c => c.id === n.clusterId);
const isBridge = bridgeSet.has(n.id);
return {
id: n.id,
title: n.title,
clusterId: n.clusterId!,
color: cluster?.color || '#cbd5e1',
isBridge,
radius: isBridge ? 13 : 8
};
});
const links: D3Link[] = [];
// Only connect strong links
for (let i = 0; i < visibleNotes.length; i++) {
for (let j = i + 1; j < visibleNotes.length; j++) {
const ni = visibleNotes[i];
const nj = visibleNotes[j];
if (ni.clusterId === nj.clusterId) {
links.push({ source: ni.id, target: nj.id, strength: 0.5 });
}
}
}
const simulation = d3.forceSimulation<D3Node>(nodes)
.force("link", d3.forceLink<D3Node, D3Link>(links).id(d => d.id).distance(110))
.force("charge", d3.forceManyBody().strength(-220))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide<D3Node>().radius(d => d.radius + 12));
// Links
const link = g.append("g")
.selectAll("line")
.data(links)
.enter()
.append("line")
.attr("stroke", "#e2e8f0")
.attr("stroke-opacity", (d: any) => {
if (!selectedClusterId) return 0.6;
const sId = typeof d.source === 'string' ? d.source : (d.source as any).id;
const tId = typeof d.target === 'string' ? d.target : (d.target as any).id;
const sourceNote = nodes.find(n => n.id === sId);
const targetNote = nodes.find(n => n.id === tId);
return (sourceNote?.clusterId === selectedClusterId && targetNote?.clusterId === selectedClusterId) ? 0.8 : 0.05;
})
.attr("stroke-width", 1);
// Nodes
const node = g.append("g")
.selectAll(".node")
.data(nodes)
.enter()
.append("g")
.attr("class", "node cursor-pointer")
.on("click", (event, d) => onNoteSelect(d.id))
.call(d3.drag<SVGGElement, D3Node>()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended) as any);
// Node opacities based on focus
node.attr("opacity", d => {
if (!selectedClusterId) return 1;
return d.clusterId === selectedClusterId ? 1 : 0.15;
});
node.append("circle")
.attr("r", d => d.radius)
.attr("fill", d => d.color)
.attr("stroke", d => d.isBridge ? "#D4AF37" : "#fff")
.attr("stroke-width", d => d.isBridge ? 3.5 : 2)
.style("filter", d => d.isBridge ? "drop-shadow(0 0 6px rgba(212, 175, 55, 0.6))" : "none");
node.append("text")
.attr("dy", d => d.radius + 14)
.attr("text-anchor", "middle")
.attr("class", "text-[10px] fill-concrete dark:fill-concrete/60 font-medium pointer-events-none")
.text(d => d.title.length > 20 ? d.title.substring(0, 20) + "..." : d.title);
simulation.on("tick", () => {
link
.attr("x1", d => (d.source as any).x)
.attr("y1", d => (d.source as any).y)
.attr("x2", d => (d.target as any).x)
.attr("y2", d => (d.target as any).y);
node
.attr("transform", d => `translate(${d.x},${d.y})`);
});
// Zoom transition on cluster highlight
if (selectedClusterId && width && height) {
const clusterNodes = nodes.filter(n => n.clusterId === selectedClusterId);
if (clusterNodes.length > 0) {
// Run a small tick count synchronously to find coordinates quickly if layout is starting
for (let i = 0; i < 50; ++i) simulation.tick();
const xCoords = clusterNodes.map(cn => cn.x).filter((x): x is number => x !== undefined);
const yCoords = clusterNodes.map(cn => cn.y).filter((y): y is number => y !== undefined);
if (xCoords.length > 0 && yCoords.length > 0) {
const avgX = d3.mean(xCoords) || width / 2;
const avgY = d3.mean(yCoords) || height / 2;
svg.transition()
.duration(800)
.call(
zoom.transform,
d3.zoomIdentity.translate(width / 2, height / 2).scale(1.4).translate(-avgX, -avgY)
);
}
}
} else {
svg.transition()
.duration(800)
.call(zoom.transform, d3.zoomIdentity);
}
function dragstarted(event: any, d: D3Node) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event: any, d: D3Node) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event: any, d: D3Node) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
return () => simulation.stop();
}, [notes, clusters, bridgeNotes, onNoteSelect, selectedClusterId]);
return (
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-2 max-w-[90%] sm:max-w-[450px]">
{clusters.map(c => {
const isSelected = selectedClusterId === c.id;
return (
<button
key={c.id}
onClick={() => onClusterSelect?.(isSelected ? null : c.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border shadow-sm transition-all text-[9px] font-bold uppercase tracking-wider
${isSelected
? 'bg-ink text-white dark:bg-white dark:text-black border-ink dark:border-white scale-105 shadow-md'
: 'bg-white/90 dark:bg-black/80 text-concrete hover:text-ink hover:border-concrete/40 border-border'
}`}
>
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: c.color }} />
<span>{c.name}</span>
</button>
);
})}
{selectedClusterId && (
<button
onClick={() => onClusterSelect?.(null)}
className="px-3 py-1.5 rounded-full border border-rose-200 bg-rose-50 dark:bg-rose-950/20 dark:border-rose-900/40 text-rose-500 text-[9px] font-bold uppercase tracking-wider hover:bg-rose-100 transition-all shadow-sm"
>
Réinitialiser focus
</button>
)}
</div>
<svg ref={svgRef} className="w-full h-full" />
</div>
);
};