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>
238 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
};
|