Files
Project_Velocity/app/src/oracle/components/CanvasViewport.tsx
2026-04-11 19:33:13 +05:30

185 lines
6.9 KiB
TypeScript

/**
* CanvasViewport — Virtualized scrolling canvas.
* Renders CanvasComponent objects sorted by layout.orderIndex.
* Uses CSS content-visibility for large component lists (virtualization-lite).
* Stable scroll anchoring: new components append without scroll jump.
*/
import { useRef } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import type { CanvasComponent, PromptExecution } from '../types/canvas';
import { ComponentRegistry, type ComponentRenderContext } from './ComponentRegistry';
import { Loader2 } from 'lucide-react';
interface CanvasViewportProps {
components: CanvasComponent[];
ctx: ComponentRenderContext;
inFlightExecution: PromptExecution | null;
selectedComponentId?: string | null;
onSelectComponent: (id: string | null) => void;
}
const SECTION_LABELS: Record<string, string> = {
sec_pipeline: 'Pipeline',
sec_leads: 'Lead Intelligence',
sec_team: 'Team Performance',
sec_actions: 'Action Queue',
sec_prompt_generated: 'Oracle Responses',
sec_geography: 'Geographic',
sec_forecast: 'Forecasting',
};
/** Groups components by sectionId, preserving orderIndex sort */
function groupBySection(components: CanvasComponent[]): Array<{ sectionId: string; components: CanvasComponent[] }> {
const sorted = [...components].sort((a, b) => a.layout.orderIndex - b.layout.orderIndex);
const sectionMap = new Map<string, CanvasComponent[]>();
for (const comp of sorted) {
const sid = comp.layout.sectionId;
if (!sectionMap.has(sid)) sectionMap.set(sid, []);
sectionMap.get(sid)!.push(comp);
}
return Array.from(sectionMap.entries()).map(([sectionId, comps]) => ({ sectionId, components: comps }));
}
/** CSS content-visibility wrapper for off-screen components, applying width mode to the flex item */
function ComponentFlexWrapper({ comp, children }: { comp: CanvasComponent; children: React.ReactNode }) {
const styles: Record<string, string> = {
full: 'w-full',
half: 'w-full xl:w-[calc(50%-8px)]',
third: 'w-full xl:w-[calc(33.333%-11px)]',
};
return (
<div
className={styles[comp.layout.widthMode] ?? 'w-full'}
style={{
contentVisibility: 'auto',
containIntrinsicSize: `0 ${comp.renderingHints.estimatedHeightPx}px`,
}}
>
{children}
</div>
);
}
export function CanvasViewport({
components,
ctx,
inFlightExecution,
selectedComponentId,
onSelectComponent,
}: CanvasViewportProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const sections = groupBySection(components);
return (
<div
ref={viewportRef}
className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar"
style={{ scrollBehavior: 'smooth' }}
onClick={(e) => {
// Click outside component to deselect
if (e.currentTarget === e.target) onSelectComponent(null);
}}
>
<div className="px-4 py-5 space-y-8">
{sections.length === 0 && !inFlightExecution && (
<EmptyCanvasState />
)}
{sections.map(({ sectionId, components: sectionComps }) => (
<section key={sectionId} className="space-y-4">
{/* Section header */}
<div className="flex items-center gap-3">
<div className="w-1 h-4 rounded-full bg-gradient-to-b from-blue-400 to-cyan-500" />
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500">
{SECTION_LABELS[sectionId] ?? sectionId.replace(/^sec_/, '').replace(/_/g, ' ')}
</h2>
<div className="flex-1 h-[1px]" style={{ background: 'rgba(255,255,255,0.05)' }} />
<span className="text-[10px] text-zinc-700">{sectionComps.length}</span>
</div>
{/* Flex wrap for half/third width components */}
<div className="flex flex-wrap gap-4">
{sectionComps.map((comp) => (
<ComponentFlexWrapper
key={`${comp.componentId}-${comp.version}`}
comp={comp}
>
<ComponentRegistry
component={comp}
ctx={{
...ctx,
isSelected: selectedComponentId === comp.componentId,
onSelect: onSelectComponent,
}}
/>
</ComponentFlexWrapper>
))}
</div>
</section>
))}
{/* In-flight execution placeholder */}
<AnimatePresence>
{inFlightExecution && (
<motion.div
key="in-flight-placeholder"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="w-full rounded-2xl p-6 flex items-center gap-4"
style={{
background: 'rgba(59,130,246,0.06)',
border: '1px dashed rgba(59,130,246,0.25)',
backdropFilter: 'blur(12px)',
}}
>
<Loader2 className="w-5 h-5 text-blue-400 animate-spin flex-shrink-0" />
<div>
<p className="text-sm font-medium text-blue-300">Oracle is analyzing your prompt</p>
<p className="text-xs text-zinc-500 mt-0.5 line-clamp-1">
"{inFlightExecution.prompt}"
</p>
</div>
<div
className="ml-auto text-xs font-mono px-2 py-1 rounded-lg"
style={{ background: 'rgba(59,130,246,0.12)', color: '#60a5fa' }}
>
{inFlightExecution.status}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Bottom padding for floating prompt bar */}
<div className="h-32" />
</div>
</div>
);
}
function EmptyCanvasState() {
return (
<div className="flex flex-col items-center justify-center py-24 gap-6">
<div
className="w-20 h-20 rounded-2xl flex items-center justify-center"
style={{ background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.15)' }}
>
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-10 h-10">
<rect x="6" y="6" width="15" height="15" rx="3" stroke="#3B82F6" strokeWidth="1.5" />
<rect x="27" y="6" width="15" height="15" rx="3" stroke="#22D3EE" strokeWidth="1.5" />
<rect x="6" y="27" width="15" height="15" rx="3" stroke="#A78BFA" strokeWidth="1.5" />
<rect x="27" y="27" width="15" height="15" rx="3" stroke="#3B82F6" strokeWidth="1.5" opacity="0.5" strokeDasharray="3 2" />
</svg>
</div>
<div className="text-center">
<h3 className="text-base font-medium text-zinc-200 mb-2">Canvas is empty</h3>
<p className="text-sm text-zinc-500 max-w-sm">
Ask Oracle anything type a prompt below to generate analytical components on your canvas.
</p>
</div>
</div>
);
}