import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY, type Simulation, type SimulationLinkDatum, type SimulationNodeDatum, } from 'd3-force'; import { KIND_COLORS } from './leadUi'; import { connectionsApi } from '../../lib/api'; import { useToast } from '../Toast'; import { CONNECTION_TYPES, leadFullName, leadInitials, type AppUser, type ConnectionType, type Lead, type LeadConnection, } from '../../types'; interface GraphNode extends SimulationNodeDatum { id: string; kind: 'lead' | 'user'; label: string; sublabel: string; color: string; radius: number; degree: number; avatar: string | null; leadId?: number; userId?: number; } interface GraphLink extends SimulationLinkDatum { id: string; kind: 'connection' | 'creator'; connectionId?: number; connectionType?: ConnectionType; strength: number; } interface LeadNetworkProps { leads: Lead[]; connections: LeadConnection[]; users: AppUser[]; onEditLead: (lead: Lead) => void; reload: () => void; } // Im SVG lassen sich keine Tailwind-Klassen fuer fill/stroke stapeln, deshalb // die Theme-Variablen direkt - so folgt das Diagramm dem Dark Mode. const SURFACE = 'var(--color-surface)'; const USER_COLOR = 'var(--color-ink-500)'; const CREATOR_LINK_COLOR = 'var(--color-ink-300)'; const CONNECTION_LINK_COLOR = 'var(--color-brand-500)'; const LABEL_COLOR = 'var(--color-ink-800)'; const SUBLABEL_COLOR = 'var(--color-ink-400)'; const MIN_ZOOM = 0.25; const MAX_ZOOM = 3; function nodeId(kind: 'lead' | 'user', id: number) { return `${kind}-${id}`; } export const LeadNetwork: React.FC = ({ leads, connections, users, onEditLead, reload, }) => { const { addToast } = useToast(); const containerRef = useRef(null); const simulationRef = useRef | null>(null); const frameRef = useRef(null); const isRunningRef = useRef(false); /** Merkt Knotenpositionen, damit ein Datenrefresh das Layout nicht zerstoert. */ const positionsRef = useRef>(new Map()); const [showCreators, setShowCreators] = useState(true); const [selectedId, setSelectedId] = useState(null); const [hoveredId, setHoveredId] = useState(null); const [transform, setTransform] = useState({ x: 0, y: 0, k: 1 }); const [size, setSize] = useState({ width: 800, height: 600 }); const [, setFrame] = useState(0); // Verbindungsmodus: erst Knoten A, dann Knoten B anklicken. const [linkMode, setLinkMode] = useState(false); const [linkSourceId, setLinkSourceId] = useState(null); const [linkType, setLinkType] = useState('Netzwerk'); const leadById = useMemo(() => new Map(leads.map((lead) => [lead.id, lead])), [leads]); // ---------- Graph aufbauen ---------- const graph = useMemo(() => { const visibleLeadIds = new Set(leads.map((lead) => lead.id)); const degree = new Map(); const bump = (key: string) => degree.set(key, (degree.get(key) ?? 0) + 1); const relevantConnections = connections.filter( (connection) => visibleLeadIds.has(connection.from_lead_id) && visibleLeadIds.has(connection.to_lead_id) ); for (const connection of relevantConnections) { bump(nodeId('lead', connection.from_lead_id)); bump(nodeId('lead', connection.to_lead_id)); } const creatorIds = new Set(); if (showCreators) { for (const lead of leads) { if (lead.created_by === null) continue; creatorIds.add(lead.created_by); bump(nodeId('user', lead.created_by)); bump(nodeId('lead', lead.id)); } } const nodes: GraphNode[] = leads.map((lead) => { const id = nodeId('lead', lead.id); const deg = degree.get(id) ?? 0; return { id, kind: 'lead', leadId: lead.id, label: leadFullName(lead), sublabel: lead.company || lead.position || '', color: KIND_COLORS[lead.kind], degree: deg, radius: 14 + Math.min(deg, 8) * 1.8, avatar: lead.avatar, }; }); const userById = new Map(users.map((user) => [user.id, user])); for (const userId of creatorIds) { const id = nodeId('user', userId); const deg = degree.get(id) ?? 0; const user = userById.get(userId); nodes.push({ id, kind: 'user', userId, label: user?.name ?? `Benutzer #${userId}`, sublabel: user?.role === 'admin' ? 'Administrator' : 'Benutzer', color: USER_COLOR, degree: deg, radius: 18 + Math.min(deg, 12) * 1.2, avatar: user?.avatar ?? null, }); } const links: GraphLink[] = relevantConnections.map((connection) => ({ id: `conn-${connection.id}`, kind: 'connection', connectionId: connection.id, connectionType: connection.type, strength: connection.strength, source: nodeId('lead', connection.from_lead_id), target: nodeId('lead', connection.to_lead_id), })); if (showCreators) { for (const lead of leads) { if (lead.created_by === null) continue; links.push({ id: `creator-${lead.created_by}-${lead.id}`, kind: 'creator', strength: 1, source: nodeId('user', lead.created_by), target: nodeId('lead', lead.id), }); } } return { nodes, links }; }, [leads, connections, users, showCreators]); // ---------- Groesse beobachten ---------- useEffect(() => { const element = containerRef.current; if (!element) return; const observer = new ResizeObserver(([entry]) => { const { width, height } = entry.contentRect; if (width > 0 && height > 0) setSize({ width, height }); }); observer.observe(element); return () => observer.disconnect(); }, []); // ---------- Simulation ---------- const stopLoop = useCallback(() => { if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); frameRef.current = null; isRunningRef.current = false; }, []); const startLoop = useCallback(() => { if (isRunningRef.current) return; isRunningRef.current = true; const step = () => { const simulation = simulationRef.current; if (!simulation) { isRunningRef.current = false; return; } // Mehrere Ticks pro Frame: das Layout beruhigt sich schneller, ohne die // Renderrate zu erhoehen. simulation.tick(2); setFrame((value) => value + 1); if (simulation.alpha() <= simulation.alphaMin()) { // Endzustand einmal sichern und Loop beenden. for (const node of simulation.nodes()) { positionsRef.current.set(node.id, { x: node.x ?? 0, y: node.y ?? 0, fx: node.fx ?? undefined, fy: node.fy ?? undefined, }); } isRunningRef.current = false; frameRef.current = null; return; } frameRef.current = requestAnimationFrame(step); }; frameRef.current = requestAnimationFrame(step); }, []); useEffect(() => { // Vorherige Positionen uebernehmen, neue Knoten am Rand einstreuen. const nodes = graph.nodes.map((node, index) => { const saved = positionsRef.current.get(node.id); if (saved) return { ...node, ...saved }; const angle = (index / Math.max(graph.nodes.length, 1)) * Math.PI * 2; return { ...node, x: Math.cos(angle) * 220, y: Math.sin(angle) * 220 }; }); const simulation = forceSimulation(nodes) .force( 'link', forceLink(graph.links.map((link) => ({ ...link }))) .id((node) => node.id) .distance((link) => (link.kind === 'creator' ? 150 : 70 + (3 - link.strength) * 25)) .strength((link) => (link.kind === 'creator' ? 0.18 : 0.55)) ) .force('charge', forceManyBody().strength((node) => (node.kind === 'user' ? -700 : -320))) .force('center', forceCenter(0, 0)) .force('collide', forceCollide((node) => node.radius + 12)) .force('x', forceX(0).strength(0.02)) .force('y', forceY(0).strength(0.02)) .alphaMin(0.004) .alphaDecay(0.025) .stop(); simulationRef.current = simulation; startLoop(); return () => { stopLoop(); simulation.stop(); simulationRef.current = null; }; // graph aendert sich nur bei echten Datenaenderungen (useMemo). }, [graph, startLoop, stopLoop]); const reheat = useCallback( (alpha = 0.35) => { const simulation = simulationRef.current; if (!simulation) return; simulation.alpha(alpha); startLoop(); }, [startLoop] ); const simulationNodes = simulationRef.current?.nodes() ?? []; const simulationLinks = (simulationRef.current?.force('link') as ReturnType> | undefined)?.links() ?? []; // ---------- Nachbarschaft fuer Hervorhebung ---------- const focusId = hoveredId ?? selectedId; const neighbourIds = useMemo(() => { if (!focusId) return null; const ids = new Set([focusId]); for (const link of simulationLinks) { const source = link.source as GraphNode; const target = link.target as GraphNode; if (source.id === focusId) ids.add(target.id); if (target.id === focusId) ids.add(source.id); } return ids; // simulationLinks ist stabil, solange sich der Graph nicht aendert. }, [focusId, graph]); // ---------- Pan / Zoom ---------- useEffect(() => { const element = containerRef.current; if (!element) return; const onWheel = (event: WheelEvent) => { event.preventDefault(); const rect = element.getBoundingClientRect(); // Cursorposition relativ zum Diagrammzentrum - die Gruppe wird mit // translate(x + width/2, y + height/2) verschoben. const cursorX = event.clientX - rect.left - rect.width / 2; const cursorY = event.clientY - rect.top - rect.height / 2; setTransform((prev) => { const factor = Math.exp(-event.deltaY * 0.0016); const k = Math.min(Math.max(prev.k * factor, MIN_ZOOM), MAX_ZOOM); const scale = k / prev.k; // Der Punkt unter dem Cursor bleibt beim Zoomen an derselben Stelle. return { k, x: cursorX - (cursorX - prev.x) * scale, y: cursorY - (cursorY - prev.y) * scale, }; }); }; element.addEventListener('wheel', onWheel, { passive: false }); return () => element.removeEventListener('wheel', onWheel); }, []); const panStateRef = useRef<{ pointerId: number; startX: number; startY: number; moved: boolean; } | null>(null); /** Ab dieser Verschiebung gilt die Geste als Ziehen und nicht mehr als Klick. */ const CLICK_TOLERANCE = 4; const handleBackgroundPointerDown = (event: React.PointerEvent) => { if (event.button !== 0) return; panStateRef.current = { pointerId: event.pointerId, startX: event.clientX - transform.x, startY: event.clientY - transform.y, moved: false, }; (event.currentTarget as Element).setPointerCapture(event.pointerId); }; const handleBackgroundPointerMove = (event: React.PointerEvent) => { const pan = panStateRef.current; if (!pan || pan.pointerId !== event.pointerId) return; const x = event.clientX - pan.startX; const y = event.clientY - pan.startY; if (Math.abs(x - transform.x) > CLICK_TOLERANCE || Math.abs(y - transform.y) > CLICK_TOLERANCE) { pan.moved = true; } setTransform((prev) => ({ ...prev, x, y })); }; /* * Ein Klick ins Leere hebt die Auswahl auf. Damit sieht das Netz wieder so * aus wie beim Wechsel auf die Ansicht: nichts ist ausgegraut. Ein Ziehen * (Verschieben der Ansicht) darf die Auswahl dagegen nicht verwerfen, * deshalb der Vergleich mit der Toleranz. Knoten stoppen das Ereignis * bereits beim pointerdown, ihre Klicks landen hier gar nicht erst. */ const handleBackgroundPointerUp = (event: React.PointerEvent) => { const pan = panStateRef.current; if (pan?.pointerId !== event.pointerId) return; panStateRef.current = null; if (pan.moved) return; setSelectedId(null); setHoveredId(null); if (linkMode) setLinkSourceId(null); }; /** Verlaesst der Zeiger die Flaeche, endet die Geste ohne Auswirkung auf die Auswahl. */ const handleBackgroundPointerLeave = (event: React.PointerEvent) => { if (panStateRef.current?.pointerId === event.pointerId) panStateRef.current = null; }; /** Bildschirm- in Simulationskoordinaten umrechnen. */ const toSimCoords = useCallback( (clientX: number, clientY: number) => { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return { x: 0, y: 0 }; return { x: (clientX - rect.left - transform.x - size.width / 2) / transform.k, y: (clientY - rect.top - transform.y - size.height / 2) / transform.k, }; }, [transform, size] ); // ---------- Knoten ziehen ---------- const dragStateRef = useRef<{ pointerId: number; node: GraphNode; moved: boolean } | null>(null); const handleNodePointerDown = (event: React.PointerEvent, node: GraphNode) => { if (event.button !== 0) return; event.stopPropagation(); dragStateRef.current = { pointerId: event.pointerId, node, moved: false }; (event.currentTarget as Element).setPointerCapture(event.pointerId); const point = toSimCoords(event.clientX, event.clientY); node.fx = point.x; node.fy = point.y; reheat(0.2); }; const handleNodePointerMove = (event: React.PointerEvent) => { const drag = dragStateRef.current; if (!drag || drag.pointerId !== event.pointerId) return; const point = toSimCoords(event.clientX, event.clientY); drag.node.fx = point.x; drag.node.fy = point.y; drag.moved = true; reheat(0.2); }; const handleNodePointerUp = (event: React.PointerEvent, node: GraphNode) => { const drag = dragStateRef.current; if (!drag || drag.pointerId !== event.pointerId) return; dragStateRef.current = null; // Gezogene Knoten bleiben fixiert - so laesst sich das Diagramm bewusst // arrangieren. Doppelklick loest die Fixierung wieder. positionsRef.current.set(node.id, { x: node.x ?? 0, y: node.y ?? 0, fx: node.fx ?? undefined, fy: node.fy ?? undefined, }); if (!drag.moved) handleNodeClick(node); }; const handleNodeDoubleClick = (node: GraphNode) => { node.fx = null; node.fy = null; positionsRef.current.set(node.id, { x: node.x ?? 0, y: node.y ?? 0 }); reheat(0.3); }; // ---------- Auswahl / Verbindungsmodus ---------- const createConnection = async (fromLeadId: number, toLeadId: number) => { try { await connectionsApi.create({ from_lead_id: fromLeadId, to_lead_id: toLeadId, type: linkType, }); addToast('Verbindung angelegt.', 'success'); reload(); } catch (err) { addToast(err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.', 'error'); } finally { setLinkSourceId(null); setLinkMode(false); } }; const handleNodeClick = (node: GraphNode) => { if (linkMode) { if (node.kind !== 'lead' || node.leadId === undefined) { addToast('Verbindungen lassen sich nur zwischen Kontakten anlegen.', 'error'); return; } if (linkSourceId === null) { setLinkSourceId(node.leadId); return; } if (linkSourceId === node.leadId) { setLinkSourceId(null); return; } createConnection(linkSourceId, node.leadId); return; } setSelectedId((current) => (current === node.id ? null : node.id)); }; const removeConnection = async (connectionId: number) => { try { await connectionsApi.remove(connectionId); addToast('Verbindung entfernt.', 'success'); reload(); } catch (err) { addToast(err instanceof Error ? err.message : 'Entfernen fehlgeschlagen.', 'error'); } }; const resetView = () => { setTransform({ x: 0, y: 0, k: 1 }); }; const resetLayout = () => { positionsRef.current.clear(); for (const node of simulationNodes) { node.fx = null; node.fy = null; } reheat(1); }; // Escape bricht den Verbindungsmodus ab. useEffect(() => { if (!linkMode) return; const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setLinkMode(false); setLinkSourceId(null); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [linkMode]); // ---------- Detailpanel ---------- const selectedNode = simulationNodes.find((node) => node.id === selectedId) ?? null; const selectedLead = selectedNode?.kind === 'lead' && selectedNode.leadId !== undefined ? leadById.get(selectedNode.leadId) ?? null : null; const selectedConnections = useMemo(() => { if (!selectedLead) return []; return connections .filter((c) => c.from_lead_id === selectedLead.id || c.to_lead_id === selectedLead.id) .map((c) => { const otherId = c.from_lead_id === selectedLead.id ? c.to_lead_id : c.from_lead_id; return { connection: c, other: leadById.get(otherId) ?? null }; }) .filter((entry) => entry.other !== null); }, [selectedLead, connections, leadById]); const selectedUserLeads = useMemo(() => { if (selectedNode?.kind !== 'user' || selectedNode.userId === undefined) return []; return leads.filter((lead) => lead.created_by === selectedNode.userId); }, [selectedNode, leads]); const isDimmed = (id: string) => Boolean(neighbourIds && !neighbourIds.has(id)); return (
{/* Diagramm */}
{/* Werkzeugleiste */}
{linkMode && ( )}
{/* Hinweisleiste: ink-950 bleibt in beiden Themes dunkel, ink-900 nicht */} {linkMode && (
{linkSourceId === null ? 'Ersten Kontakt anklicken' : `Zweiten Kontakt anklicken (Start: ${ leadById.get(linkSourceId) ? leadFullName(leadById.get(linkSourceId)!) : '?' }) · Esc bricht ab`}
)} {/* Steuerung */}
{/* Legende */}
Kontakt ↔ Kontakt
Ersteller → Kontakt

Ziehen = verschieben · Rad = Zoom · Doppelklick = lösen

{ handleBackgroundPointerMove(event); handleNodePointerMove(event); }} onPointerUp={handleBackgroundPointerUp} onPointerLeave={handleBackgroundPointerLeave} className="h-full w-full cursor-grab active:cursor-grabbing" style={{ touchAction: 'none' }} > {graph.nodes.length === 0 ? (

Keine Kontakte im Netzwerk

Lege Kontakte an oder passe die Suche an, damit hier Knoten erscheinen.

) : ( {simulationNodes .filter((node) => node.avatar) .map((node) => ( // Benutzer sind Quadrate, Kontakte Kreise - der Ausschnitt // muss zur Form passen, sonst steht das Foto ueber. {node.kind === 'user' ? ( ) : ( )} ))} {/* Kanten */} {simulationLinks.map((link) => { const source = link.source as GraphNode; const target = link.target as GraphNode; if (source.x === undefined || target.x === undefined) return null; const dimmed = isDimmed(source.id) || isDimmed(target.id); const isCreator = link.kind === 'creator'; return ( ); })} {/* Knoten */} {simulationNodes.map((node) => { if (node.x === undefined || node.y === undefined) return null; const dimmed = isDimmed(node.id); const isSelected = selectedId === node.id; const isLinkSource = node.kind === 'lead' && node.leadId === linkSourceId; const isPinned = node.fx !== null && node.fx !== undefined; return ( handleNodePointerDown(event, node)} onPointerMove={handleNodePointerMove} onPointerUp={(event) => handleNodePointerUp(event, node)} onDoubleClick={() => handleNodeDoubleClick(node)} onMouseEnter={() => setHoveredId(node.id)} onMouseLeave={() => setHoveredId(null)} className="cursor-pointer" > {(isSelected || isLinkSource) && ( )} {node.kind === 'user' ? ( ) : ( )} {node.avatar && ( )} {node.kind === 'user' && !node.avatar && ( {node.label.charAt(0).toUpperCase()} )} {node.kind === 'lead' && !node.avatar && ( {leadInitials({ first_name: node.label.split(' ')[0] ?? '', last_name: node.label.split(' ').slice(-1)[0] ?? '', })} )} {isPinned && ( )} {node.label} {node.sublabel && ( {node.sublabel} )} ); })} )}
{/* Detailpanel */}
); };