'use client' import React, { useEffect, useRef, useState, useCallback } from 'react' import * as d3 from 'd3' import { BrainstormSession } from '@/types/brainstorm' import { useLanguage } from '@/lib/i18n' interface WaveCanvasProps { session: BrainstormSession onNodeSelect: (id: string) => void onPositionUpdate?: (id: string, pos: { x: number; y: number }) => void selectedNodeId: string | null onCreateIdea?: (data: { title: string; parentIdeaId?: string; x: number; y: number }) => void remoteMove?: { ideaId: string; x: number; y: number; _seq: number } | null manualEditTrigger?: number playbackIdeas?: any[] | null fitTrigger?: number } const WAVE_COLORS: Record = { 1: '#fb923c', 2: '#60a5fa', 3: '#a78bfa', } export const WaveCanvas: React.FC = ({ session, onNodeSelect, onPositionUpdate, selectedNodeId, onCreateIdea, remoteMove, manualEditTrigger, playbackIdeas, fitTrigger, }) => { const { t, language } = useLanguage() const svgRef = useRef(null) const containerRef = useRef(null) const nodeRef = useRef | null>(null) const linkRef = useRef | null>(null) const simulationRef = useRef | null>(null) const transformRef = useRef(d3.zoomIdentity) const zoomRef = useRef | null>(null) const centerRef = useRef<{ x: number; y: number; scale: number }>({ x: 0, y: 0, scale: 0.8 }) const onNodeSelectRef = useRef(onNodeSelect) onNodeSelectRef.current = onNodeSelect const onPositionUpdateRef = useRef(onPositionUpdate) onPositionUpdateRef.current = onPositionUpdate const onCreateIdeaRef = useRef(onCreateIdea) onCreateIdeaRef.current = onCreateIdea const ideasKey = session?.ideas?.map(i => `${i.id}:${i.status}:${i.positionX}:${i.positionY}`).join('|') || '' const sessionId = session?.id || '' const [isDark, setIsDark] = useState(false) useEffect(() => { const el = document.documentElement setIsDark(el.classList.contains('dark')) const observer = new MutationObserver(() => { setIsDark(el.classList.contains('dark')) }) observer.observe(el, { attributes: true, attributeFilter: ['class'] }) return () => observer.disconnect() }, []) const [editingNode, setEditingNode] = useState<{ x: number y: number parentId: string | null } | null>(null) const [editText, setEditText] = useState('') const inputRef = useRef(null) useEffect(() => { if (editingNode && inputRef.current) { inputRef.current.focus() } }, [editingNode]) useEffect(() => { if (!manualEditTrigger || manualEditTrigger === 0) return const container = containerRef.current if (!container) return const rect = container.getBoundingClientRect() setEditingNode({ x: rect.width / 2, y: rect.height / 2, parentId: null, }) setEditText('') }, [manualEditTrigger]) const toSvgCoords = useCallback((clientX: number, clientY: number) => { const svg = svgRef.current if (!svg) return { x: 0, y: 0 } const pt = svg.createSVGPoint() pt.x = clientX pt.y = clientY const ctm = svg.getScreenCTM() if (!ctm) return { x: 0, y: 0 } const svgPt = pt.matrixTransform(ctm.inverse()) const t = transformRef.current return { x: (svgPt.x - t.x) / t.k, y: (svgPt.y - t.y) / t.k, } }, []) const toScreenCoords = useCallback((svgX: number, svgY: number) => { const svg = svgRef.current if (!svg) return { x: 0, y: 0 } const t = transformRef.current const screenX = svgX * t.k + t.x const screenY = svgY * t.k + t.y const ctm = svg.getScreenCTM() if (!ctm) return { x: 0, y: 0 } const pt = svg.createSVGPoint() pt.x = screenX pt.y = screenY const screenPt = pt.matrixTransform(ctm) return { x: screenPt.x, y: screenPt.y } }, []) const handleSubmitIdea = useCallback(() => { if (!editText.trim() || !editingNode || !onCreateIdeaRef.current) return onCreateIdeaRef.current({ title: editText.trim(), parentIdeaId: editingNode.parentId || undefined, x: editingNode.x, y: editingNode.y, }) setEditingNode(null) setEditText('') }, [editText, editingNode]) useEffect(() => { if (!svgRef.current || !containerRef.current) return if (!session?.id || !session?.ideas) return const activeIdeas = playbackIdeas ? playbackIdeas.map((pi: any) => ({ ...pi, description: '', connectionToSeed: null, convertedToNoteId: null, relatedNoteIds: null, createdBy: null, noteRefs: [], creator: null, createdAt: new Date(), sessionId: session.id, })) : session.ideas const width = containerRef.current.clientWidth const height = containerRef.current.clientHeight const centerX = width / 2 const centerY = height / 2 const svg = d3.select(svgRef.current) svg.selectAll('*').remove() const g = svg.append('g') const zoom = d3.zoom() .scaleExtent([0.1, 5]) .on('zoom', (event) => { g.attr('transform', event.transform) transformRef.current = event.transform }) zoomRef.current = zoom centerRef.current = { x: centerX, y: centerY, scale: 0.8 } svg.call(zoom) svg.call(zoom.transform, d3.zoomIdentity.translate(centerX, centerY).scale(0.8)) interface D3Node extends d3.SimulationNodeDatum { id: string type: 'root' | 'idea' wave?: number title: string color: string radius: number status?: string createdByType?: string | null creatorInitial?: string } interface D3Link extends d3.SimulationLinkDatum { source: string | D3Node target: string | D3Node type: 'wave' | 'parent' } const nodes: D3Node[] = [] const links: D3Link[] = [] nodes.push({ id: 'root', type: 'root', title: session.seedIdea, color: '#141414', radius: 40, fx: 0, fy: 0, }) activeIdeas.forEach((idea) => { const creator = (idea as any).creator as { name: string | null } | undefined | null nodes.push({ id: idea.id, type: 'idea', wave: idea.waveNumber, title: idea.title, color: WAVE_COLORS[idea.waveNumber as 1 | 2 | 3] || '#94a3b8', radius: idea.status === 'dismissed' ? 18 : 28, status: idea.status, createdByType: idea.createdByType || 'ai', creatorInitial: creator?.name ? creator.name.charAt(0).toUpperCase() : undefined, x: idea.positionX ?? undefined, y: idea.positionY ?? undefined, }) if (idea.parentIdeaId) { links.push({ source: idea.parentIdeaId, target: idea.id, type: 'parent' }) } else { links.push({ source: 'root', target: idea.id, type: 'wave' }) } }) const simulation = d3 .forceSimulation(nodes) .force( 'link', d3 .forceLink(links) .id((d) => d.id) .distance((d) => { if (d.type === 'wave') { const targetNode = nodes.find( (n) => n.id === (typeof d.target === 'string' ? d.target : (d.target as any).id) ) return (targetNode?.wave || 1) * 200 } if (d.type === 'parent') return 180 return 100 }) ) .force('charge', d3.forceManyBody().strength(-800)) .force( 'radial', d3 .forceRadial( (d) => { if (d.type === 'root') return 0 return (d.wave || 1) * 200 }, 0, 0 ) .strength(0.8) ) .force('collision', d3.forceCollide().radius((d) => d.radius + 30)) simulationRef.current = simulation const ringRadii = [200, 400, 600] g.selectAll('.ring') .data(ringRadii) .enter() .append('circle') .attr('class', 'ring') .attr('r', (d) => d) .attr('fill', 'none') .attr('stroke', isDark ? '#ffffff10' : '#e2e8f0') .attr('stroke-width', 1) .attr('stroke-dasharray', '4,4') .style('opacity', 0.5) const link = g .append('g') .selectAll('line') .data(links) .enter() .append('line') .attr('stroke', (d) => d.type === 'wave' ? (isDark ? '#334155' : '#cbd5e1') : (isDark ? '#854d0e' : '#fde047') ) .attr('stroke-width', (d) => (d.type === 'wave' ? 1.5 : 2)) .attr('stroke-dasharray', (d) => (d.type === 'parent' ? 'none' : '4,4')) linkRef.current = link const node = g .append('g') .selectAll('.node') .data(nodes) .enter() .append('g') .attr('class', (d) => `node${d.status === 'dismissed' ? ' dismissed' : ''}`) .style('cursor', 'pointer') .style('opacity', (d) => d.status === 'dismissed' ? 0.3 : 1) .attr('data-id', (d) => d.id) .on('click', (_event, d) => { if (d.type === 'idea') onNodeSelectRef.current?.(d.id) }) .on('dblclick', (event, d) => { if (!onCreateIdeaRef.current) return event.stopPropagation() const parentId = d.type === 'root' ? null : d.id const angle = Math.random() * Math.PI * 2 const dist = 180 const nx = (d.x || 0) + Math.cos(angle) * dist const ny = (d.y || 0) + Math.sin(angle) * dist const screen = toScreenCoords(nx, ny) const container = containerRef.current if (!container) return const rect = container.getBoundingClientRect() setEditingNode({ x: screen.x - rect.left, y: screen.y - rect.top, parentId }) setEditText('') }) .call( d3 .drag() .on('start', (event, d) => { if (!event.active) simulation.alphaTarget(0.3).restart() d.fx = d.x d.fy = d.y }) .on('drag', (event, d) => { d.fx = event.x d.fy = event.y }) .on('end', (event, d) => { if (!event.active) simulation.alphaTarget(0) d.fx = null d.fy = null if (d.type === 'idea' && onPositionUpdateRef.current) { onPositionUpdateRef.current(d.id, { x: event.x, y: event.y }) } }) as any ) nodeRef.current = node node .append('circle') .attr('r', (d) => d.radius) .attr('fill', (d) => d.status === 'converted' ? (isDark ? '#064e3b' : '#ecfdf5') : d.type === 'root' ? (isDark ? '#f97316' : '#141414') : (isDark ? '#1e1e1e' : '#fff') ) .attr('stroke', (d) => d.status === 'converted' ? '#10b981' : d.color ) .attr('stroke-width', 2) node .append('text') .attr('dy', (d) => d.type === 'root' ? '.35em' : d.radius + 20 ) .attr('text-anchor', 'middle') .attr('fill', (d) => d.type === 'root' ? '#fff' : d.status === 'dismissed' ? (isDark ? '#475569' : '#94a3b8') : (isDark ? '#e2e8f0' : '#141414') ) .attr( 'class', (d) => d.type === 'root' ? 'text-[10px] font-bold pointer-events-none tracking-widest' : 'text-[11px] font-bold uppercase tracking-tight pointer-events-none' ) .text((d) => d.type === 'root' ? t('brainstorm.seedNodeBadge') : d.title.length > 18 ? d.title.substring(0, 18) + '...' : d.title ) node .filter((d) => d.status === 'converted') .append('text') .attr('x', (d) => d.radius * 0.55) .attr('y', (d) => -d.radius * 0.55) .attr('text-anchor', 'middle') .attr('fill', isDark ? '#34d399' : '#10b981') .attr('font-size', '14px') .attr('class', 'pointer-events-none') .text('✓') node .filter((d) => { if (!d.wave || d.type === 'root') return false try { const ids: string[] = JSON.parse( (session.ideas.find((i) => i.id === d.id)?.relatedNoteIds) || '[]' ) return ids.length > 0 } catch { return false } }) .append('text') .attr('x', (d) => -d.radius * 0.55) .attr('y', (d) => -d.radius * 0.55) .attr('text-anchor', 'middle') .attr('fill', isDark ? '#94a3b8' : '#64748b') .attr('font-size', '12px') .attr('class', 'pointer-events-none') .text('📎') node .filter((d) => d.type === 'idea' && d.createdByType === 'human') .append('circle') .attr('cx', (d) => d.radius * 0.6) .attr('cy', (d) => d.radius * 0.6) .attr('r', 7) .attr('fill', '#3b82f6') .attr('stroke', isDark ? '#1e1e1e' : '#fff') .attr('stroke-width', 1.5) .attr('class', 'pointer-events-none') node .filter((d) => d.type === 'idea' && d.createdByType === 'human') .append('text') .attr('x', (d) => d.radius * 0.6) .attr('y', (d) => d.radius * 0.6 + 3.5) .attr('text-anchor', 'middle') .attr('fill', '#fff') .attr('font-size', '8px') .attr('font-weight', 'bold') .attr('class', 'pointer-events-none') .text((d) => d.creatorInitial || 'U') node .filter((d) => d.type === 'idea' && d.createdByType === 'ai') .append('text') .attr('x', (d) => d.radius * 0.55) .attr('y', (d) => -d.radius * 0.55 + 4) .attr('text-anchor', 'middle') .attr('fill', '#a78bfa') .attr('font-size', '10px') .attr('class', 'pointer-events-none') .text('✦') g.append('text') .attr('text-anchor', 'middle') .attr('dy', 80) .attr('class', 'text-lg font-serif italic pointer-events-none') .attr('fill', isDark ? '#94a3b8' : '#333') .text(session.seedIdea.length > 60 ? session.seedIdea.substring(0, 60) + '...' : session.seedIdea) svg.on('dblclick', (event) => { if (!onCreateIdeaRef.current) return if ((event.target as Element).closest('.node')) return const container = containerRef.current if (!container) return const rect = container.getBoundingClientRect() setEditingNode({ x: event.clientX - rect.left, y: event.clientY - rect.top, parentId: null }) setEditText('') }) 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})`) }) return () => { simulation.stop() } }, [sessionId, ideasKey, isDark, toSvgCoords, toScreenCoords, playbackIdeas, language, t]) useEffect(() => { if (!nodeRef.current) return nodeRef.current.selectAll('circle') .attr('stroke-width', (d: any) => (d.id === selectedNodeId ? 4 : 2)) .style('filter', (d: any) => d.id === selectedNodeId ? `drop-shadow(0 0 12px ${d.color}cc)` : 'none' ) }, [selectedNodeId]) useEffect(() => { if (!remoteMove || !nodeRef.current || !linkRef.current || !simulationRef.current) return const sim = simulationRef.current const node = nodeRef.current const link = linkRef.current node.each(function(d: any) { if (d.id === remoteMove.ideaId) { d.fx = remoteMove.x d.fy = remoteMove.y } }) sim.alpha(0.3).restart() for (let i = 0; i < 30; i++) sim.tick() sim.stop() link .attr('x1', (d: any) => d.source.x) .attr('y1', (d: any) => d.source.y) .attr('x2', (d: any) => d.target.x) .attr('y2', (d: any) => d.target.y) node.attr('transform', (d: any) => `translate(${d.x},${d.y})`) }, [remoteMove]) // Fit-to-screen: re-center when fitTrigger increments useEffect(() => { if (!fitTrigger || !svgRef.current || !zoomRef.current) return const { x, y, scale } = centerRef.current d3.select(svgRef.current) .transition() .duration(500) .call(zoomRef.current.transform, d3.zoomIdentity.translate(x, y).scale(scale)) }, [fitTrigger]) return (
{/* Legend overlay — bottom right */}
{[ { color: '#fb923c', label: t('brainstorm.legendWave1') || 'Variations' }, { color: '#60a5fa', label: t('brainstorm.legendWave2') || 'Analogies' }, { color: '#a78bfa', label: t('brainstorm.legendWave3') || 'Disruptions' }, { color: '#10b981', label: t('brainstorm.legendConverted') || 'Convertie' }, ].map(({ color, label }) => (
{label}
))}
{editingNode && (
{editingNode.parentId ? t('brainstorm.canvasEditTitleReply') : t('brainstorm.canvasEditTitleNewIdea')}
setEditText(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && editText.trim()) handleSubmitIdea() if (e.key === 'Escape') { setEditingNode(null); setEditText('') } }} placeholder={editingNode.parentId ? t('brainstorm.canvasPlaceholderReply') : t('brainstorm.canvasPlaceholderIdea')} className="w-full px-3 py-2.5 text-sm font-serif bg-black/[0.03] dark:bg-white/[0.06] border border-black/[0.06] dark:border-white/[0.12] rounded-xl outline-none focus:border-brand-accent/50 focus:bg-white dark:focus:bg-white/[0.08] transition-all placeholder:text-foreground/25 text-foreground" />
{t('brainstorm.canvasShortcutSave')} esc {t('brainstorm.canvasShortcutCancel')}
{editingNode.parentId && ( → {t('brainstorm.canvasChildBranch')} )}
)}

{t('brainstorm.canvasDoubleClickHint')}

) }