All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
- Add brainstorm feature with collaborative canvas, AI idea generation, live cursors, playback, and export - Add PDF upload/extraction/ingestion pipeline with pgvector document search (RAG) - Add document Q&A overlay with streaming chat and PDF preview - Add note attachments UI with status polling, grid layout, and auto-scroll - Add task extraction AI tool and agent executor improvements - Fix NoteEmbedding missing updatedAt column, re-index 66 notes with 1536-dim embeddings - Fix brainstorm 'Create Note' button: add success toast and redirect to created note - Fix memory echo notification infinite polling - Fix chat route to always include document_search tool - Add brainstorm i18n keys across all 14 locales - Add socket server for real-time brainstorm collaboration - Add hierarchical notebook selector and organize notebook dialog improvements - Add sidebar brainstorm section with session management - Update prisma schema with brainstorm tables, attachments, and document chunks
251 lines
7.5 KiB
TypeScript
251 lines
7.5 KiB
TypeScript
|
|
import React, { useEffect, useRef } from 'react';
|
|
import * as d3 from 'd3';
|
|
import { BrainstormSession, BrainstormIdea, Note } from '../../types';
|
|
|
|
interface WaveCanvasProps {
|
|
session: BrainstormSession;
|
|
ideas: BrainstormIdea[];
|
|
onNodeSelect: (id: string) => void;
|
|
onPositionUpdate: (id: string, pos: { x: number; y: number }) => void;
|
|
selectedNodeId: string | null;
|
|
relatedNotes: Note[];
|
|
}
|
|
|
|
export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
|
session,
|
|
ideas,
|
|
onNodeSelect,
|
|
onPositionUpdate,
|
|
selectedNodeId,
|
|
relatedNotes
|
|
}) => {
|
|
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 centerX = width / 2;
|
|
const centerY = height / 2;
|
|
|
|
const svg = d3.select(svgRef.current);
|
|
svg.selectAll("*").remove();
|
|
|
|
const g = svg.append("g");
|
|
|
|
// Zoom behavior
|
|
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
|
.scaleExtent([0.1, 5])
|
|
.on("zoom", (event) => {
|
|
g.attr("transform", event.transform);
|
|
});
|
|
|
|
svg.call(zoom);
|
|
|
|
// Initial transform to center
|
|
svg.call(zoom.transform, d3.zoomIdentity.translate(centerX, centerY).scale(0.8));
|
|
|
|
// Data structures for d3
|
|
interface D3Node extends d3.SimulationNodeDatum {
|
|
id: string;
|
|
type: 'root' | 'idea' | 'note';
|
|
wave?: number;
|
|
title: string;
|
|
color: string;
|
|
radius: number;
|
|
status?: string;
|
|
}
|
|
|
|
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
|
|
source: string | D3Node;
|
|
target: string | D3Node;
|
|
type: 'wave' | 'context' | 'parent';
|
|
}
|
|
|
|
const nodes: D3Node[] = [];
|
|
const links: D3Link[] = [];
|
|
|
|
// Root node
|
|
const rootNode: D3Node = {
|
|
id: 'root',
|
|
type: 'root',
|
|
title: session.seedIdea,
|
|
color: '#141414',
|
|
radius: 40,
|
|
fx: 0,
|
|
fy: 0
|
|
};
|
|
nodes.push(rootNode);
|
|
|
|
// Idea nodes
|
|
const colors = {
|
|
1: '#fb923c', // orange
|
|
2: '#60a5fa', // blue
|
|
3: '#a78bfa' // violet
|
|
};
|
|
|
|
ideas.forEach(idea => {
|
|
nodes.push({
|
|
id: idea.id,
|
|
type: 'idea',
|
|
wave: idea.waveNumber,
|
|
title: idea.title,
|
|
color: colors[idea.waveNumber as 1|2|3] || '#94a3b8',
|
|
radius: 28,
|
|
status: idea.status,
|
|
x: idea.position?.x,
|
|
y: idea.position?.y
|
|
});
|
|
|
|
if (idea.parentIdeaId) {
|
|
links.push({
|
|
source: idea.parentIdeaId,
|
|
target: idea.id,
|
|
type: 'parent'
|
|
});
|
|
} else {
|
|
links.push({
|
|
source: 'root',
|
|
target: idea.id,
|
|
type: 'wave'
|
|
});
|
|
}
|
|
});
|
|
|
|
// Radial layout forces
|
|
const simulation = d3.forceSimulation<D3Node>(nodes)
|
|
.force("link", d3.forceLink<D3Node, D3Link>(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<D3Node>(d => {
|
|
if (d.type === 'root') return 0;
|
|
if (d.id.includes('-')) return (d.wave || 1) * 200 + 100; // Deepened ideas push out
|
|
return (d.wave || 1) * 200;
|
|
}, 0, 0).strength(0.8))
|
|
.force("collision", d3.forceCollide<D3Node>().radius(d => d.radius + 30));
|
|
|
|
// Drawing rings
|
|
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", "#e2e8f0")
|
|
.attr("stroke-width", 1)
|
|
.attr("stroke-dasharray", "4,4")
|
|
.style("opacity", 0.5);
|
|
|
|
// Links
|
|
const link = g.append("g")
|
|
.selectAll("line")
|
|
.data(links)
|
|
.enter()
|
|
.append("line")
|
|
.attr("stroke", d => d.type === 'wave' ? "#cbd5e1" : d.type === 'parent' ? "#fde047" : "#94a3b8")
|
|
.attr("stroke-width", d => d.type === 'wave' ? 1.5 : 2)
|
|
.attr("stroke-dasharray", d => d.type === 'parent' ? "none" : "4,4");
|
|
|
|
// Nodes
|
|
const node = g.append("g")
|
|
.selectAll(".node")
|
|
.data(nodes)
|
|
.enter()
|
|
.append("g")
|
|
.attr("class", "node")
|
|
.style("opacity", d => d.status === 'dismissed' ? 0.4 : 1)
|
|
.on("click", (event, d) => {
|
|
if (d.type === 'idea') onNodeSelect(d.id);
|
|
})
|
|
.call(d3.drag<SVGGElement, D3Node>()
|
|
.on("start", dragstarted)
|
|
.on("drag", dragged)
|
|
.on("end", dragended) as any);
|
|
|
|
node.append("circle")
|
|
.attr("r", d => d.radius)
|
|
.attr("fill", d => d.status === 'converted' ? '#ecfdf5' : (d.type === 'root' ? '#141414' : '#fff'))
|
|
.attr("stroke", d => d.status === 'converted' ? '#10b981' : d.color)
|
|
.attr("stroke-width", d => d.id === selectedNodeId ? 4 : 2)
|
|
.attr("class", "cursor-pointer transition-all hover:scale-110")
|
|
.style("filter", d => d.id === selectedNodeId ? `drop-shadow(0 0 12px ${d.color}cc)` : "none");
|
|
|
|
// State indicators (converted)
|
|
node.filter(d => d.status === 'converted')
|
|
.append("path")
|
|
.attr("d", d3.symbol().type(d3.symbolCircle).size(150))
|
|
.attr("fill", "#10b981");
|
|
|
|
// Icons/Text in nodes
|
|
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' ? "#94a3b8" : "#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' ? "SEED" : d.title.length > 18 ? d.title.substring(0, 18) + "..." : d.title);
|
|
|
|
if (rootNode) {
|
|
g.append("text")
|
|
.attr("text-anchor", "middle")
|
|
.attr("dy", 80)
|
|
.attr("class", "text-2xl font-serif italic fill-ink dark:fill-dark-ink pointer-events-none shadow-sm")
|
|
.text(session.seedIdea);
|
|
}
|
|
|
|
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})`);
|
|
});
|
|
|
|
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;
|
|
if (d.type === 'idea') {
|
|
onPositionUpdate(d.id, { x: event.x, y: event.y });
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
simulation.stop();
|
|
};
|
|
}, [session, ideas, selectedNodeId, onNodeSelect]);
|
|
|
|
return (
|
|
<div ref={containerRef} className="w-full h-full relative cursor-grab active:cursor-grabbing">
|
|
<svg ref={svgRef} className="w-full h-full" />
|
|
<div className="absolute top-6 left-6 pointer-events-none">
|
|
<p className="text-[10px] font-bold tracking-[0.3em] uppercase text-concrete opacity-40">Spatial Exploration Mode</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|