Files
Momento/architectural-grid11/src/components/BrainstormView/WaveCanvas.tsx
Antigravity 1fcea6ed7d
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
feat: brainstorm sessions, PDF document Q&A, embedding fixes, and UI improvements
- 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
2026-05-14 17:43:21 +00:00

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>
);
};