Merge commit '9b2f675702' as 'ai-context/context-graph-demo'

This commit is contained in:
elpresidank 2026-04-05 21:08:35 -05:00
commit ecaf3489f1
54 changed files with 10078 additions and 0 deletions

View file

@ -0,0 +1,39 @@
interface BadgeProps {
children: React.ReactNode;
color: string;
size?: "small" | "medium";
selected?: boolean;
onClick?: () => void;
}
export function Badge({
children,
color,
size = "medium",
selected = false,
onClick,
}: BadgeProps) {
const isSmall = size === "small";
return (
<button
onClick={onClick}
style={{
padding: isSmall ? "3px 8px" : "6px 12px",
borderRadius: isSmall ? 4 : 6,
border: `1px solid ${selected ? color : color + (isSmall ? "22" : "44")}`,
background: selected ? `${color}35` : `${color}${isSmall ? "10" : "15"}`,
color: isSmall ? color + "cc" : color,
cursor: onClick ? "pointer" : "default",
fontSize: isSmall ? 10 : 11,
fontFamily: "'IBM Plex Mono', monospace",
display: "inline-flex",
alignItems: "center",
gap: 6,
boxShadow: selected ? `0 0 8px ${color}44` : "none",
}}
>
{children}
</button>
);
}

View file

@ -0,0 +1,35 @@
import { surface, border } from "../../theme";
interface CardProps {
children: React.ReactNode;
padding?: number | string;
borderRadius?: number;
borderColor?: string;
onClick?: () => void;
style?: React.CSSProperties;
}
export function Card({
children,
padding = 24,
borderRadius = 12,
borderColor = border.subtle,
onClick,
style,
}: CardProps) {
return (
<div
onClick={onClick}
style={{
padding,
borderRadius,
background: surface.card,
border: `1px solid ${borderColor}`,
cursor: onClick ? "pointer" : undefined,
...style,
}}
>
{children}
</div>
);
}

View file

@ -0,0 +1,78 @@
import { FilterButton } from "./FilterButton";
import { text, border } from "../../theme";
export interface FilterItem {
key: string;
label: string;
icon?: string;
color?: string;
}
interface FilterBarProps {
items: FilterItem[];
selectedKey: string | null;
onSelect: (key: string | null) => void;
stats?: string;
showAll?: boolean;
allLabel?: string;
emptyMessage?: string;
maxItems?: number;
}
export function FilterBar({
items,
selectedKey,
onSelect,
stats,
showAll = true,
allLabel = "All",
emptyMessage,
maxItems = 10,
}: FilterBarProps) {
const displayItems = items.slice(0, maxItems);
return (
<div style={{
padding: "12px 28px",
display: "flex",
gap: 8,
alignItems: "center",
borderBottom: `1px solid ${border.subtle}`,
flexWrap: "wrap",
}}>
<span style={{ fontSize: 11, color: text.disabled, fontFamily: "'IBM Plex Mono', monospace", marginRight: 8 }}>
FILTER:
</span>
{emptyMessage && items.length === 0 ? (
<span style={{ fontSize: 11, color: text.disabled, fontStyle: "italic" }}>{emptyMessage}</span>
) : (
<>
{showAll && (
<FilterButton
label={allLabel}
isActive={!selectedKey}
onClick={() => onSelect(null)}
/>
)}
{displayItems.map((item) => (
<FilterButton
key={item.key}
label={item.label}
icon={item.icon}
color={item.color}
isActive={selectedKey === item.key}
onClick={() => onSelect(selectedKey === item.key ? null : item.key)}
/>
))}
</>
)}
{stats && (
<div style={{ marginLeft: "auto", fontSize: 11, color: text.hint, fontFamily: "'IBM Plex Mono', monospace" }}>
{stats}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,31 @@
import { text, border } from "../../theme";
interface FilterButtonProps {
label: string;
icon?: string;
color?: string;
isActive: boolean;
onClick: () => void;
}
export function FilterButton({ label, icon, color, isActive, onClick }: FilterButtonProps) {
const activeColor = color || "#fff";
return (
<button
onClick={onClick}
style={{
padding: "5px 12px",
borderRadius: 20,
border: `1px solid ${isActive ? activeColor + "88" : border.medium}`,
background: isActive ? activeColor + "15" : "transparent",
color: isActive ? activeColor : text.subtle,
fontSize: 11,
cursor: "pointer",
fontFamily: "'IBM Plex Mono', monospace",
}}
>
{icon && <>{icon} </>}{label}
</button>
);
}

View file

@ -0,0 +1,58 @@
import type { TabKey } from "../../types";
interface HeaderProps {
activeTab: TabKey;
onTabChange: (tab: TabKey) => void;
}
export function Header({ activeTab, onTabChange }: HeaderProps) {
return (
<div style={{
borderBottom: "1px solid rgba(255,255,255,0.06)",
padding: "16px 28px", display: "flex", alignItems: "center", justifyContent: "space-between",
background: "linear-gradient(180deg, rgba(15,15,22,1) 0%, rgba(10,10,15,1) 100%)",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<img
src="/tg.svg"
alt="TrustGraph"
style={{ width: 36, height: 36, borderRadius: 8 }}
/>
<div>
<div style={{ fontWeight: 700, fontSize: 16, letterSpacing: "-0.02em", color: "#fff" }}>
TrustGraph
</div>
<div style={{ fontSize: 11, color: "#666", fontFamily: "'IBM Plex Mono', monospace", letterSpacing: "0.05em" }}>
CONTEXT GRAPH DEMO
</div>
</div>
</div>
<div style={{ display: "flex", gap: 6, fontFamily: "'IBM Plex Mono', monospace", fontSize: 12 }}>
{(["graph", "query", "explain", "data", "ontology"] as const).map((tab) => {
const labels: Record<typeof tab, string> = {
graph: "◈ Context Graph",
query: "⚡ Agent Query",
explain: "◉ Explain",
data: "▤ Table Explorer",
ontology: "◇ Ontology",
};
return (
<button
key={tab}
onClick={() => onTabChange(tab)}
style={{
padding: "7px 16px", borderRadius: 6, border: "none", cursor: "pointer",
background: activeTab === tab ? "rgba(255,255,255,0.1)" : "transparent",
color: activeTab === tab ? "#fff" : "#666",
fontFamily: "'IBM Plex Mono', monospace", fontSize: 12, fontWeight: activeTab === tab ? 600 : 400,
transition: "all 0.2s",
}}
>
{labels[tab]}
</button>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,23 @@
import { semantic, text } from "../../theme";
interface LoadingStateProps {
message?: string;
variant?: "loading" | "error";
}
export function LoadingState({ message, variant = "loading" }: LoadingStateProps) {
const isError = variant === "error";
const defaultMessage = isError ? "Error loading data" : "Loading...";
return (
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: isError ? semantic.error : text.faint,
}}>
{message || defaultMessage}
</div>
);
}

View file

@ -0,0 +1,97 @@
import { semantic, text, surface, border, withGlow } from "../../theme";
export interface Message {
role: string;
text: string;
type?: string;
}
interface MessageBubbleProps {
message: Message;
}
export function MessageBubble({ message }: MessageBubbleProps) {
const isUser = message.role === "human";
const messageType = message.type;
const getTypeStyles = () => {
switch (messageType) {
case "thinking":
return {
bg: withGlow(semantic.thinking, 0.08),
border: withGlow(semantic.thinking, 0.2),
icon: "◈",
label: "THINKING",
color: semantic.thinking,
};
case "observation":
return {
bg: withGlow(semantic.observation, 0.08),
border: withGlow(semantic.observation, 0.2),
icon: "◉",
label: "OBSERVATION",
color: semantic.observation,
};
case "answer":
return {
bg: withGlow(semantic.answer, 0.08),
border: withGlow(semantic.answer, 0.2),
icon: "✓",
label: "ANSWER",
color: semantic.answer,
};
default:
return null;
}
};
const typeStyles = getTypeStyles();
if (isUser) {
return (
<div style={{
padding: "12px 16px",
borderRadius: 10,
background: withGlow(semantic.user, 0.08),
border: `1px solid ${withGlow(semantic.user, 0.2)}`,
alignSelf: "flex-end",
maxWidth: "80%",
}}>
<div style={{ fontSize: 10, color: withGlow(semantic.user, 0.53), fontFamily: "'IBM Plex Mono', monospace", marginBottom: 6 }}>
YOU
</div>
<div style={{ fontSize: 14, color: text.primary, lineHeight: 1.5 }}>
{message.text}
</div>
</div>
);
}
return (
<div style={{
padding: "12px 16px",
borderRadius: 10,
background: typeStyles?.bg || surface.card,
border: `1px solid ${typeStyles?.border || border.default}`,
maxWidth: "90%",
}}>
{typeStyles && (
<div style={{
fontSize: 10,
color: withGlow(typeStyles.color, 0.53),
fontFamily: "'IBM Plex Mono', monospace",
marginBottom: 6,
display: "flex",
alignItems: "center",
gap: 6,
}}>
<span style={{ color: typeStyles.color }}>{typeStyles.icon}</span>
{typeStyles.label}
</div>
)}
<div style={{ fontSize: 13, color: text.secondary, lineHeight: 1.6, whiteSpace: "pre-wrap" }}>
{message.text}
</div>
</div>
);
}

View file

@ -0,0 +1,73 @@
import { text, surface, border, palette } from "../../theme";
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
buttonText?: string;
isLoading?: boolean;
buttonColor?: string;
disabled?: boolean;
}
export function SearchInput({
value,
onChange,
onSubmit,
placeholder = "Search...",
buttonText = "Search",
isLoading = false,
buttonColor = palette.blue,
disabled = false,
}: SearchInputProps) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
};
const isDisabled = disabled || isLoading || !value.trim();
return (
<div style={{ display: "flex", gap: 8 }}>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={isLoading}
style={{
flex: 1,
padding: "12px 16px",
borderRadius: 8,
border: `1px solid ${border.medium}`,
background: surface.card,
color: text.primary,
fontSize: 14,
fontFamily: "'IBM Plex Sans', sans-serif",
outline: "none",
}}
/>
<button
onClick={onSubmit}
disabled={isDisabled}
style={{
padding: "12px 20px",
borderRadius: 8,
border: `1px solid ${buttonColor}44`,
background: isDisabled ? surface.card : `${buttonColor}1a`,
color: isDisabled ? text.disabled : buttonColor,
cursor: isDisabled ? "not-allowed" : "pointer",
fontSize: 13,
fontWeight: 600,
fontFamily: "'IBM Plex Mono', monospace",
}}
>
{isLoading ? "..." : buttonText}
</button>
</div>
);
}

View file

@ -0,0 +1,22 @@
import { text } from "../../theme";
interface SectionLabelProps {
children: React.ReactNode;
marginBottom?: number;
marginTop?: number;
}
export function SectionLabel({ children, marginBottom = 10, marginTop }: SectionLabelProps) {
return (
<div style={{
fontSize: 10,
color: text.disabled,
fontFamily: "'IBM Plex Mono', monospace",
letterSpacing: "0.1em",
marginBottom,
marginTop,
}}>
{children}
</div>
);
}

View file

@ -0,0 +1,60 @@
import { useConnectionState } from "@trustgraph/react-provider";
import { useProgressStateStore } from "@trustgraph/react-state";
import { semantic, palette, text, border } from "../../theme";
export function StatusBar() {
const connectionState = useConnectionState();
const activity = useProgressStateStore((state) => state.activity);
const getStatusDisplay = () => {
if (!connectionState) return { color: text.subtle, text: "Initializing..." };
switch (connectionState.status) {
case "authenticated":
return { color: semantic.success, text: "Authenticated" };
case "connected":
return { color: semantic.success, text: "Connected" };
case "unauthenticated":
return { color: semantic.info, text: "Connected" };
case "connecting":
return { color: palette.amber, text: "Connecting..." };
case "reconnecting":
return { color: semantic.warning, text: `Reconnecting (${connectionState.reconnectAttempt}/${connectionState.maxAttempts})...` };
case "failed":
return { color: semantic.error, text: "Connection failed" };
default:
return { color: text.subtle, text: connectionState.status };
}
};
const status = getStatusDisplay();
const activeActivity = activity.size > 0 ? Array.from(activity)[0] : null;
return (
<div style={{
position: "fixed", bottom: 0, left: 0, right: 0,
padding: "8px 28px", borderTop: `1px solid ${border.subtle}`,
background: "rgba(10,10,15,0.95)", backdropFilter: "blur(8px)",
display: "flex", justifyContent: "space-between", alignItems: "center",
fontFamily: "'IBM Plex Mono', monospace", fontSize: 10, color: text.hint,
}}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{activeActivity ? (
<>
<span style={{ color: palette.amber }}></span>
<span style={{ color: text.faint }}>{activeActivity}...</span>
</>
) : (
<>
<span style={{ color: semantic.success }}></span>
<span style={{ color: text.disabled }}>Ready</span>
</>
)}
</div>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<span style={{ color: status.color }}></span> {status.text}
<span style={{ color: text.subtle }}>|</span>
<span>trustgraph.ai</span>
</div>
</div>
);
}

View file

@ -0,0 +1,120 @@
import { useToastStore, Toast, ToastType } from "../../state/toastStore";
import { semantic, surface, text } from "../../theme";
const typeStyles: Record<ToastType, { color: string; icon: string }> = {
success: { color: semantic.success, icon: "✓" },
error: { color: semantic.error, icon: "✕" },
warning: { color: semantic.warning, icon: "!" },
info: { color: semantic.info, icon: "i" },
};
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
const style = typeStyles[toast.type];
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "12px 16px",
background: surface.overlay,
borderRadius: 8,
borderLeft: `3px solid ${style.color}`,
backdropFilter: "blur(12px)",
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
minWidth: 280,
maxWidth: 400,
animation: "slideIn 0.2s ease-out",
}}
>
<span
style={{
color: style.color,
fontSize: 12,
fontWeight: 700,
width: 18,
height: 18,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
border: `1px solid ${style.color}`,
flexShrink: 0,
}}
>
{style.icon}
</span>
<span
style={{
flex: 1,
fontSize: 12,
color: text.secondary,
fontFamily: "'IBM Plex Sans', sans-serif",
lineHeight: 1.4,
}}
>
{toast.message}
</span>
<button
onClick={onDismiss}
style={{
background: "none",
border: "none",
color: text.faint,
cursor: "pointer",
fontSize: 16,
padding: 4,
lineHeight: 1,
}}
>
×
</button>
</div>
);
}
export function Toaster() {
const toasts = useToastStore((state) => state.toasts);
const removeToast = useToastStore((state) => state.removeToast);
if (toasts.length === 0) return null;
return (
<>
<style>
{`
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
`}
</style>
<div
style={{
position: "fixed",
bottom: 60,
left: 28,
zIndex: 1000,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{toasts.map((toast) => (
<ToastItem
key={toast.id}
toast={toast}
onDismiss={() => removeToast(toast.id)}
/>
))}
</div>
</>
);
}

View file

@ -0,0 +1,37 @@
import { useState, useEffect, useRef } from "react";
interface TypewriterProps {
text: string;
speed?: number;
onDone?: () => void;
}
export function Typewriter({ text, speed = 12, onDone }: TypewriterProps) {
const [displayed, setDisplayed] = useState("");
const idx = useRef(0);
useEffect(() => {
idx.current = 0;
setDisplayed("");
const interval = setInterval(() => {
idx.current++;
if (idx.current >= text.length) {
setDisplayed(text);
clearInterval(interval);
onDone?.();
} else {
setDisplayed(text.slice(0, idx.current));
}
}, speed);
return () => clearInterval(interval);
}, [text, speed, onDone]);
return (
<span>
{displayed}
<span style={{ opacity: displayed.length < text.length ? 1 : 0, color: "#FCD34D" }}>
</span>
</span>
);
}

View file

@ -0,0 +1,14 @@
export { SectionLabel } from "./SectionLabel";
export { FilterButton } from "./FilterButton";
export { Header } from "./Header";
export { StatusBar } from "./StatusBar";
export { Typewriter } from "./Typewriter";
export { Card } from "./Card";
export { Badge } from "./Badge";
export { LoadingState } from "./LoadingState";
export { Toaster } from "./Toaster";
export { SearchInput } from "./SearchInput";
export { FilterBar } from "./FilterBar";
export type { FilterItem } from "./FilterBar";
export { MessageBubble } from "./MessageBubble";
export type { Message } from "./MessageBubble";

View file

@ -0,0 +1,451 @@
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { ZoomControls } from "./ZoomControls";
import { border, palette, text, withGlow } from "../../theme";
// ── Types ───────────────────────────────────────────────────────────
export interface ExplainGraphNode {
id: string;
label: string;
color?: string;
}
export interface ExplainGraphEdge {
id: string;
from: string;
to: string;
label: string;
reasoning?: string;
}
interface LayoutNode extends ExplainGraphNode {
x: number;
y: number;
vx: number;
vy: number;
}
interface ExplainGraphProps {
nodes: ExplainGraphNode[];
edges: ExplainGraphEdge[];
highlightedNodeIds: string[];
highlightedEdgeIds: string[];
onNodeClick?: (nodeId: string) => void;
onEdgeClick?: (edgeId: string) => void;
}
// ── Simple force layout ─────────────────────────────────────────────
function computeLayout(
nodes: ExplainGraphNode[],
edges: ExplainGraphEdge[],
width: number,
height: number,
): LayoutNode[] {
if (nodes.length === 0 || width === 0) return [];
const cx = width / 2;
const cy = height / 2;
// Initial positions: circle layout
const layoutNodes: LayoutNode[] = nodes.map((n, i) => {
const angle = (Math.PI * 2 * i) / nodes.length - Math.PI / 2;
const radius = Math.min(cx, cy) * 0.55;
return {
...n,
x: cx + Math.cos(angle) * radius,
y: cy + Math.sin(angle) * radius,
vx: 0,
vy: 0,
};
});
// Run simple force simulation
const iterations = 120;
const repulsion = 2000;
const attraction = 0.005;
const damping = 0.85;
const centerPull = 0.01;
const nodeMap = new Map(layoutNodes.map((n, i) => [n.id, i]));
for (let iter = 0; iter < iterations; iter++) {
// Repulsion between all pairs
for (let i = 0; i < layoutNodes.length; i++) {
for (let j = i + 1; j < layoutNodes.length; j++) {
const a = layoutNodes[i];
const b = layoutNodes[j];
let dx = a.x - b.x;
let dy = a.y - b.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = repulsion / (dist * dist);
dx = (dx / dist) * force;
dy = (dy / dist) * force;
a.vx += dx;
a.vy += dy;
b.vx -= dx;
b.vy -= dy;
}
}
// Attraction along edges
for (const edge of edges) {
const ai = nodeMap.get(edge.from);
const bi = nodeMap.get(edge.to);
if (ai === undefined || bi === undefined) continue;
const a = layoutNodes[ai];
const b = layoutNodes[bi];
const dx = b.x - a.x;
const dy = b.y - a.y;
const fx = dx * attraction;
const fy = dy * attraction;
a.vx += fx;
a.vy += fy;
b.vx -= fx;
b.vy -= fy;
}
// Center pull
for (const n of layoutNodes) {
n.vx += (cx - n.x) * centerPull;
n.vy += (cy - n.y) * centerPull;
}
// Apply velocity
for (const n of layoutNodes) {
n.vx *= damping;
n.vy *= damping;
n.x += n.vx;
n.y += n.vy;
// Keep in bounds with padding
const pad = 40;
n.x = Math.max(pad, Math.min(width - pad, n.x));
n.y = Math.max(pad, Math.min(height - pad, n.y));
}
}
return layoutNodes;
}
// ── Component ───────────────────────────────────────────────────────
export function ExplainGraph({
nodes,
edges,
highlightedNodeIds,
highlightedEdgeIds,
onNodeClick,
onEdgeClick,
}: ExplainGraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const [hovered, setHovered] = useState<string | null>(null);
// Zoom and pan
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const isPanningRef = useRef(false);
const lastPanPosRef = useRef({ x: 0, y: 0 });
// Track container size
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setContainerSize({ width: entry.contentRect.width, height: entry.contentRect.height });
}
});
ro.observe(container);
return () => ro.disconnect();
}, []);
// Layout
const layoutNodes = useMemo(
() => computeLayout(nodes, edges, containerSize.width, containerSize.height),
[nodes, edges, containerSize],
);
const nodeMap = useMemo(
() => new Map(layoutNodes.map(n => [n.id, n])),
[layoutNodes],
);
// Grid lines
const gridLines = useMemo(() => {
const lines: React.ReactElement[] = [];
const { width, height } = containerSize;
if (width === 0) return lines;
for (let x = 0; x < width; x += 30) {
lines.push(<line key={`v-${x}`} x1={x} y1={0} x2={x} y2={height} stroke={border.grid} strokeWidth={0.5} />);
}
for (let y = 0; y < height; y += 30) {
lines.push(<line key={`h-${y}`} x1={0} y1={y} x2={width} y2={y} stroke={border.grid} strokeWidth={0.5} />);
}
return lines;
}, [containerSize]);
// Zoom
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.min(4, Math.max(0.25, zoom * delta));
const svg = svgRef.current;
if (!svg) return;
const rect = svg.getBoundingClientRect();
const cursorX = e.clientX - rect.left;
const cursorY = e.clientY - rect.top;
const zoomRatio = newZoom / zoom;
setPan(p => ({ x: cursorX - (cursorX - p.x) * zoomRatio, y: cursorY - (cursorY - p.y) * zoomRatio }));
setZoom(newZoom);
}, [zoom]);
// Pan
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
e.preventDefault();
isPanningRef.current = true;
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
}
}, []);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isPanningRef.current) return;
const dx = e.clientX - lastPanPosRef.current.x;
const dy = e.clientY - lastPanPosRef.current.y;
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
setPan(p => ({ x: p.x + dx, y: p.y + dy }));
}, []);
const handleMouseUp = useCallback(() => { isPanningRef.current = false; }, []);
const handleResetView = useCallback(() => { setZoom(1); setPan({ x: 0, y: 0 }); }, []);
const hasHighlights = highlightedNodeIds.length > 0 || highlightedEdgeIds.length > 0;
const NODE_R = 10;
if (containerSize.width === 0) {
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
}
return (
<div
ref={containerRef}
style={{ position: "relative", width: "100%", height: "100%", overflow: "hidden" }}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<svg
ref={svgRef}
width={containerSize.width}
height={containerSize.height}
style={{ display: "block", background: "transparent", cursor: isPanningRef.current ? "grabbing" : "default" }}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
>
<g>{gridLines}</g>
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
{/* Edges */}
{edges.map((edge) => {
const from = nodeMap.get(edge.from);
const to = nodeMap.get(edge.to);
if (!from || !to) return null;
const isHighlighted = highlightedEdgeIds.includes(edge.id);
const isDimmed = hasHighlights && !isHighlighted;
const isEdgeHovered = hovered === `edge-${edge.id}`;
const mx = (from.x + to.x) / 2 + (from.y - to.y) * 0.15;
const my = (from.y + to.y) / 2 + (to.x - from.x) * 0.15;
const path = `M ${from.x} ${from.y} Q ${mx} ${my} ${to.x} ${to.y}`;
const alpha = isDimmed ? 0.15 : isHighlighted || isEdgeHovered ? 0.8 : 0.35;
const edgeColor = palette.cyan;
return (
<g
key={edge.id}
style={{ cursor: onEdgeClick ? "pointer" : "default" }}
onClick={() => onEdgeClick?.(edge.id)}
onMouseEnter={() => setHovered(`edge-${edge.id}`)}
onMouseLeave={() => setHovered(null)}
>
{/* Wider invisible hit area */}
<path d={path} stroke="transparent" strokeWidth={12} fill="none" />
<path
d={path}
stroke={edgeColor}
strokeOpacity={alpha}
strokeWidth={isHighlighted || isEdgeHovered ? 2 : 1}
fill="none"
/>
{/* Edge label */}
<text
x={mx}
y={my - 6}
fill={`rgba(255,255,255,${isDimmed ? 0.15 : isHighlighted || isEdgeHovered ? 0.9 : 0.5})`}
fontSize={8}
fontFamily="'IBM Plex Mono', monospace"
textAnchor="middle"
>
{edge.label}
</text>
{/* Arrowhead */}
{(() => {
// Point on curve near the end (t=0.85)
const t = 0.85;
const ax = (1 - t) * (1 - t) * from.x + 2 * (1 - t) * t * mx + t * t * to.x;
const ay = (1 - t) * (1 - t) * from.y + 2 * (1 - t) * t * my + t * t * to.y;
const dx = to.x - ax;
const dy = to.y - ay;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const ux = dx / len;
const uy = dy / len;
// Arrow tip at edge of node circle
const tipX = to.x - ux * NODE_R;
const tipY = to.y - uy * NODE_R;
const arrowSize = 5;
const p1x = tipX - ux * arrowSize + uy * arrowSize * 0.4;
const p1y = tipY - uy * arrowSize - ux * arrowSize * 0.4;
const p2x = tipX - ux * arrowSize - uy * arrowSize * 0.4;
const p2y = tipY - uy * arrowSize + ux * arrowSize * 0.4;
return (
<polygon
points={`${tipX},${tipY} ${p1x},${p1y} ${p2x},${p2y}`}
fill={edgeColor}
fillOpacity={alpha}
/>
);
})()}
</g>
);
})}
{/* Nodes */}
{layoutNodes.map((node) => {
const isHighlighted = highlightedNodeIds.includes(node.id);
const isHovered = hovered === node.id;
const isDimmed = hasHighlights && !isHighlighted;
const nodeColor = node.color || palette.blue;
const alpha = isDimmed ? 0.25 : 1;
const r = isHighlighted || isHovered ? NODE_R * 1.3 : NODE_R;
return (
<g
key={node.id}
style={{ cursor: onNodeClick ? "pointer" : "default" }}
onClick={() => onNodeClick?.(node.id)}
onMouseEnter={() => setHovered(node.id)}
onMouseLeave={() => setHovered(null)}
>
{/* Glow */}
{(isHighlighted || isHovered) && (
<circle cx={node.x} cy={node.y} r={r + 8} fill={nodeColor} fillOpacity={0.15} />
)}
{/* Circle */}
<circle
cx={node.x}
cy={node.y}
r={r}
fill={nodeColor}
fillOpacity={alpha * 0.2}
stroke={nodeColor}
strokeOpacity={alpha}
strokeWidth={isHighlighted ? 1.5 : 0.75}
/>
{/* Label */}
<text
x={node.x}
y={node.y + r + 12}
fill={`rgba(255,255,255,${alpha * (isHighlighted ? 1 : 0.7)})`}
fontSize={isHovered ? 9 : 8}
fontWeight={isHighlighted ? "bold" : "normal"}
fontFamily="'IBM Plex Sans', sans-serif"
textAnchor="middle"
>
{node.label}
</text>
</g>
);
})}
</g>
</svg>
<ZoomControls
zoom={zoom}
onZoomIn={() => setZoom(z => Math.min(4, z * 1.2))}
onZoomOut={() => setZoom(z => Math.max(0.25, z / 1.2))}
onReset={handleResetView}
/>
{/* Empty state */}
{nodes.length === 0 && (
<div style={{
position: "absolute", inset: 0,
display: "flex", alignItems: "center", justifyContent: "center",
color: text.hint, fontSize: 13, fontStyle: "italic",
pointerEvents: "none",
}}>
Graph will populate as explain events arrive
</div>
)}
{/* Tooltip */}
{hovered && !hovered.startsWith("edge-") && (() => {
const node = nodeMap.get(hovered);
if (!node) return null;
return (
<div style={{
position: "absolute",
left: node.x * zoom + pan.x + 20,
top: node.y * zoom + pan.y - 20,
background: "rgba(15,15,20,0.95)",
border: `1px solid ${withGlow(node.color || palette.blue, 0.3)}`,
borderRadius: 8, padding: "8px 12px",
pointerEvents: "none", backdropFilter: "blur(12px)", zIndex: 10,
}}>
<div style={{ color: node.color || palette.blue, fontWeight: 700, fontSize: 12, fontFamily: "'IBM Plex Mono', monospace" }}>
{node.label}
</div>
</div>
);
})()}
{/* Edge tooltip */}
{hovered?.startsWith("edge-") && (() => {
const edgeId = hovered.slice(5);
const edge = edges.find(e => e.id === edgeId);
if (!edge) return null;
const from = nodeMap.get(edge.from);
const to = nodeMap.get(edge.to);
if (!from || !to) return null;
const mx = ((from.x + to.x) / 2) * zoom + pan.x;
const my = ((from.y + to.y) / 2) * zoom + pan.y;
return (
<div style={{
position: "absolute", left: mx + 15, top: my - 15,
background: "rgba(15,15,20,0.95)",
border: `1px solid ${withGlow(palette.cyan, 0.3)}`,
borderRadius: 8, padding: "8px 12px",
pointerEvents: "none", backdropFilter: "blur(12px)", zIndex: 10,
maxWidth: 280,
}}>
<div style={{ color: palette.cyan, fontWeight: 700, fontSize: 11, fontFamily: "'IBM Plex Mono', monospace" }}>
{edge.label}
</div>
{edge.reasoning && (
<div style={{ color: text.muted, fontSize: 10, marginTop: 4, lineHeight: 1.4, fontStyle: "italic" }}>
{edge.reasoning.length > 150 ? edge.reasoning.slice(0, 150) + "..." : edge.reasoning}
</div>
)}
</div>
);
})()}
</div>
);
}

View file

@ -0,0 +1,596 @@
import { useEffect, useRef, useCallback, useState, MouseEvent } from "react";
import type { DomainKey, Entity, GraphNode, OntologyType, Relationship } from "../../types";
import { ZoomControls } from "./ZoomControls";
import { border } from "../../theme";
interface GraphCanvasProps {
entities: Entity[];
relationships: Relationship[];
ontology: OntologyType;
highlightedEntities: string[];
onNodeClick: (node: GraphNode) => void;
activeFilter: DomainKey | null;
}
const SETTLE_TIME = 10000; // 10 seconds until nodes settle
const FRAME_INTERVAL = 1000 / 30; // 30fps
export function GraphCanvas({ entities, relationships, ontology, highlightedEntities, onNodeClick, activeFilter }: GraphCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const staticCanvasRef = useRef<HTMLCanvasElement>(null);
const nodesCanvasRef = useRef<HTMLCanvasElement>(null);
const edgesCanvasRef = useRef<HTMLCanvasElement>(null);
const nodesRef = useRef<GraphNode[]>([]);
const animRef = useRef<number>(0);
const hoveredRef = useRef<string | null>(null);
const settledRef = useRef<boolean>(false);
const startTimeRef = useRef<number>(0);
const timeRef = useRef<number>(0);
const lastFrameTimeRef = useRef<number>(0);
// Store view state in refs to avoid triggering resets
const highlightedRef = useRef<string[]>(highlightedEntities);
const activeFilterRef = useRef<DomainKey | null>(activeFilter);
const relationshipsRef = useRef<Relationship[]>(relationships);
const ontologyRef = useRef<OntologyType>(ontology);
const [hovered, setHovered] = useState<string | null>(null);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
// Zoom and pan state
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const zoomRef = useRef(1);
const panRef = useRef({ x: 0, y: 0 });
const isPanningRef = useRef(false);
const lastPanPosRef = useRef({ x: 0, y: 0 });
// Keep zoom/pan refs in sync
zoomRef.current = zoom;
panRef.current = pan;
// Keep refs in sync with props
useEffect(() => {
highlightedRef.current = highlightedEntities;
}, [highlightedEntities]);
useEffect(() => {
activeFilterRef.current = activeFilter;
}, [activeFilter]);
useEffect(() => {
relationshipsRef.current = relationships;
}, [relationships]);
useEffect(() => {
ontologyRef.current = ontology;
}, [ontology]);
// Track container size changes
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setContainerSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, []);
// Draw static layer (grid + domain labels)
const drawStaticLayer = useCallback((ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, domainPositions: Record<DomainKey, { x: number; y: number }>) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Grid stays fixed (no transform)
ctx.strokeStyle = border.grid;
ctx.lineWidth = 1;
for (let x = 0; x < canvas.width; x += 60) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
}
for (let y = 0; y < canvas.height; y += 60) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke();
}
// Domain labels with zoom/pan transform
ctx.save();
ctx.translate(panRef.current.x, panRef.current.y);
ctx.scale(zoomRef.current, zoomRef.current);
const currentOntology = ontologyRef.current;
(Object.entries(domainPositions) as [DomainKey, { x: number; y: number }][]).forEach(([domain, pos]) => {
const data = currentOntology[domain];
ctx.font = "bold 22px 'IBM Plex Mono', monospace";
ctx.fillStyle = data.color + "44";
ctx.textAlign = "center";
ctx.fillText(data.label.toUpperCase(), pos.x, pos.y - Math.min(canvas.width, canvas.height) * 0.14);
});
ctx.restore();
}, []);
// Draw nodes layer - reads from refs
const drawNodesLayer = useCallback((ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, time: number) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Apply zoom/pan transform
ctx.save();
ctx.translate(panRef.current.x, panRef.current.y);
ctx.scale(zoomRef.current, zoomRef.current);
const nodes = nodesRef.current;
const settled = settledRef.current;
const highlighted = highlightedRef.current;
const filter = activeFilterRef.current;
const rels = relationshipsRef.current;
nodes.forEach((node) => {
const isHighlighted = highlighted && highlighted.includes(node.id);
const isHovered = hoveredRef.current === node.id;
const isDimmed = highlighted && highlighted.length > 0 && !isHighlighted;
const isFiltered = filter && node.domain !== filter && !rels.some(
r => r.domain.includes(filter) && (r.from === node.id || r.to === node.id)
);
const alpha = isFiltered ? 0.15 : isDimmed ? 0.3 : 1;
const r = isHighlighted || isHovered ? node.r * 1.4 : node.r;
const pulseR = isHighlighted && !settled ? Math.sin(time * 3) * 3 : 0;
// Glow
if ((isHighlighted || isHovered) && !isFiltered) {
ctx.beginPath();
ctx.arc(node.x, node.y, r + 12 + pulseR, 0, Math.PI * 2);
const grd = ctx.createRadialGradient(node.x, node.y, r, node.x, node.y, r + 12 + pulseR);
grd.addColorStop(0, node.glow);
grd.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = grd;
ctx.fill();
}
// Node circle
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
ctx.fillStyle = node.color + Math.round(alpha * 255 * 0.2).toString(16).padStart(2, "0");
ctx.fill();
ctx.strokeStyle = node.color + Math.round(alpha * 255).toString(16).padStart(2, "0");
ctx.lineWidth = isHighlighted ? 2.5 : 1.5;
ctx.stroke();
// Label
ctx.font = `${isHighlighted ? "bold " : ""}${isHovered ? 17 : 14}px 'IBM Plex Sans', sans-serif`;
ctx.fillStyle = `rgba(255,255,255,${alpha * (isHighlighted ? 1 : 0.75)})`;
ctx.textAlign = "center";
ctx.fillText(node.label, node.x, node.y + r + 18);
// Update node positions (spring physics + drift) - only if not settled
if (!settled) {
node.x += (node.targetX - node.x) * 0.02;
node.y += (node.targetY - node.y) * 0.02;
node.x += Math.sin(time + node.targetX * 0.01) * 0.3;
node.y += Math.cos(time + node.targetY * 0.01) * 0.3;
}
});
ctx.restore();
}, []);
// Draw edges layer - reads from refs
const drawEdgesLayer = useCallback((ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, time: number) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Apply zoom/pan transform
ctx.save();
ctx.translate(panRef.current.x, panRef.current.y);
ctx.scale(zoomRef.current, zoomRef.current);
const nodes = nodesRef.current;
const highlighted = highlightedRef.current;
const filter = activeFilterRef.current;
const rels = relationshipsRef.current;
const filteredRels = filter
? rels.filter((r) => r.domain.includes(filter))
: rels;
filteredRels.forEach((rel) => {
const fromNode = nodes.find((n) => n.id === rel.from);
const toNode = nodes.find((n) => n.id === rel.to);
if (!fromNode || !toNode) return;
const isHighlighted =
highlighted &&
highlighted.includes(rel.from) &&
highlighted.includes(rel.to);
const baseAlpha = isHighlighted ? 0.7 : 0.12;
const pulse = isHighlighted ? Math.sin(time * 4) * 0.15 + 0.15 : 0;
ctx.beginPath();
ctx.moveTo(fromNode.x, fromNode.y);
// Curved edges
const mx = (fromNode.x + toNode.x) / 2 + (fromNode.y - toNode.y) * 0.1;
const my = (fromNode.y + toNode.y) / 2 + (toNode.x - fromNode.x) * 0.1;
ctx.quadraticCurveTo(mx, my, toNode.x, toNode.y);
const gradient = ctx.createLinearGradient(fromNode.x, fromNode.y, toNode.x, toNode.y);
gradient.addColorStop(0, fromNode.color + Math.round((baseAlpha + pulse) * 255).toString(16).padStart(2, "0"));
gradient.addColorStop(1, toNode.color + Math.round((baseAlpha + pulse) * 255).toString(16).padStart(2, "0"));
ctx.strokeStyle = gradient;
ctx.lineWidth = isHighlighted ? 3 : 1.5;
ctx.stroke();
// Animated particles on highlighted edges
if (isHighlighted) {
const t = (time * 2) % 1;
const px = (1 - t) * (1 - t) * fromNode.x + 2 * (1 - t) * t * mx + t * t * toNode.x;
const py = (1 - t) * (1 - t) * fromNode.y + 2 * (1 - t) * t * my + t * t * toNode.y;
ctx.beginPath();
ctx.arc(px, py, 3, 0, Math.PI * 2);
ctx.fillStyle = "#fff";
ctx.fill();
}
});
ctx.restore();
}, []);
// Animation loop function - separate from setup
const runAnimation = useCallback(() => {
const nodesCanvas = nodesCanvasRef.current;
const edgesCanvas = edgesCanvasRef.current;
const nodesCtx = nodesCanvas?.getContext("2d");
const edgesCtx = edgesCanvas?.getContext("2d");
if (!nodesCtx || !nodesCanvas || !edgesCtx || !edgesCanvas) return;
// Capture validated references for the closure
const validNodesCtx = nodesCtx;
const validNodesCanvas = nodesCanvas;
const validEdgesCtx = edgesCtx;
const validEdgesCanvas = edgesCanvas;
function animate(currentTime: number) {
// Throttle to target fps
if (currentTime - lastFrameTimeRef.current < FRAME_INTERVAL) {
animRef.current = requestAnimationFrame(animate);
return;
}
lastFrameTimeRef.current = currentTime;
timeRef.current += 0.01;
// Check if we should settle
if (!settledRef.current && currentTime - startTimeRef.current > SETTLE_TIME) {
settledRef.current = true;
}
const hasHighlights = highlightedRef.current && highlightedRef.current.length > 0;
const isSettled = settledRef.current;
// Draw edges layer
drawEdgesLayer(validEdgesCtx, validEdgesCanvas, timeRef.current);
// Draw nodes layer
if (!isSettled || hasHighlights || hoveredRef.current) {
drawNodesLayer(validNodesCtx, validNodesCanvas, timeRef.current);
}
// Continue animation if not settled, or if there are highlights
if (!isSettled || hasHighlights) {
animRef.current = requestAnimationFrame(animate);
} else {
// Settled with no highlights - do one final draw and stop
drawNodesLayer(validNodesCtx, validNodesCanvas, timeRef.current);
drawEdgesLayer(validEdgesCtx, validEdgesCanvas, timeRef.current);
animRef.current = 0;
}
}
animRef.current = requestAnimationFrame(animate);
}, [drawNodesLayer, drawEdgesLayer]);
// Main setup - only runs when data or size changes
useEffect(() => {
const staticCanvas = staticCanvasRef.current;
const nodesCanvas = nodesCanvasRef.current;
const edgesCanvas = edgesCanvasRef.current;
if (!staticCanvas || !nodesCanvas || !edgesCanvas || containerSize.width === 0) return;
// Cancel any existing animation
if (animRef.current) {
cancelAnimationFrame(animRef.current);
animRef.current = 0;
}
// Setup all canvases
[staticCanvas, nodesCanvas, edgesCanvas].forEach(canvas => {
canvas.width = containerSize.width * 2;
canvas.height = containerSize.height * 2;
canvas.style.width = containerSize.width + "px";
canvas.style.height = containerSize.height + "px";
});
const cx = staticCanvas.width / 2;
const cy = staticCanvas.height / 2;
// Position nodes in domain clusters
const domainKeys = Object.keys(ontology);
const domainPositions: Record<DomainKey, { x: number; y: number }> = {};
domainKeys.forEach((domain, i) => {
const angle = (Math.PI * 2 * i) / domainKeys.length - Math.PI / 2;
const radius = Math.min(cx, cy) * 0.45;
domainPositions[domain] = {
x: cx + Math.cos(angle) * radius,
y: cy + Math.sin(angle) * radius,
};
});
nodesRef.current = entities.map((e) => {
const dp = domainPositions[e.domain];
const subIdx = ontology[e.domain].subclasses.findIndex((s) => s.id === e.id);
const total = ontology[e.domain].subclasses.length;
const angle = ((Math.PI * 2) / total) * subIdx - Math.PI / 2;
const radius = Math.min(staticCanvas.width, staticCanvas.height) * 0.1;
return {
...e,
x: dp.x + Math.cos(angle) * radius,
y: dp.y + Math.sin(angle) * radius,
vx: 0,
vy: 0,
targetX: dp.x + Math.cos(angle) * radius,
targetY: dp.y + Math.sin(angle) * radius,
r: 18,
};
});
const staticCtx = staticCanvas.getContext("2d");
if (!staticCtx) return;
// Draw static layer once
drawStaticLayer(staticCtx, staticCanvas, domainPositions);
// Reset animation state
settledRef.current = false;
startTimeRef.current = performance.now();
timeRef.current = 0;
lastFrameTimeRef.current = 0;
// Start animation
runAnimation();
return () => {
if (animRef.current) {
cancelAnimationFrame(animRef.current);
animRef.current = 0;
}
};
}, [entities, ontology, containerSize, drawStaticLayer, runAnimation]);
// Restart animation when highlights change (without resetting positions)
useEffect(() => {
const hasHighlights = highlightedEntities && highlightedEntities.length > 0;
// If we have highlights and animation isn't running, restart it
if (hasHighlights && animRef.current === 0) {
runAnimation();
}
}, [highlightedEntities, runAnimation]);
// Redraw on filter change (without resetting)
useEffect(() => {
const nodesCanvas = nodesCanvasRef.current;
const edgesCanvas = edgesCanvasRef.current;
const nodesCtx = nodesCanvas?.getContext("2d");
const edgesCtx = edgesCanvas?.getContext("2d");
if (nodesCtx && nodesCanvas && edgesCtx && edgesCanvas && settledRef.current && animRef.current === 0) {
drawNodesLayer(nodesCtx, nodesCanvas, timeRef.current);
drawEdgesLayer(edgesCtx, edgesCanvas, timeRef.current);
}
}, [activeFilter, drawNodesLayer, drawEdgesLayer]);
const handleMouseMove = useCallback((e: MouseEvent<HTMLCanvasElement>) => {
// Handle panning first
if (isPanningRef.current) {
const dx = (e.clientX - lastPanPosRef.current.x) * 2;
const dy = (e.clientY - lastPanPosRef.current.y) * 2;
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
setPan(p => ({ x: p.x + dx, y: p.y + dy }));
return;
}
const canvas = nodesCanvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
// Transform screen coordinates to world coordinates (accounting for zoom/pan)
const screenX = (e.clientX - rect.left) * 2;
const screenY = (e.clientY - rect.top) * 2;
const x = (screenX - panRef.current.x) / zoomRef.current;
const y = (screenY - panRef.current.y) / zoomRef.current;
const nodes = nodesRef.current;
let found: string | null = null;
for (const node of nodes) {
const dx = node.x - x;
const dy = node.y - y;
if (Math.sqrt(dx * dx + dy * dy) < node.r * 1.5) {
found = node.id;
break;
}
}
const wasHovered = hoveredRef.current;
hoveredRef.current = found;
setHovered(found);
canvas.style.cursor = isPanningRef.current ? "grabbing" : (found ? "pointer" : "default");
// Redraw if hover state changed and we're settled
if (wasHovered !== found && settledRef.current) {
const nodesCanvas = nodesCanvasRef.current;
const edgesCanvas = edgesCanvasRef.current;
const nodesCtx = nodesCanvas?.getContext("2d");
const edgesCtx = edgesCanvas?.getContext("2d");
if (nodesCtx && nodesCanvas && edgesCtx && edgesCanvas) {
drawNodesLayer(nodesCtx, nodesCanvas, timeRef.current);
drawEdgesLayer(edgesCtx, edgesCanvas, timeRef.current);
}
}
}, [drawNodesLayer, drawEdgesLayer]);
const handleClick = useCallback((e: MouseEvent<HTMLCanvasElement>) => {
// Don't trigger click if we were panning
if (e.shiftKey) return;
if (hoveredRef.current && onNodeClick) {
const node = nodesRef.current.find((n) => n.id === hoveredRef.current);
if (node) onNodeClick(node);
}
}, [onNodeClick]);
// Redraw all layers (used when zoom/pan changes)
const redrawAllLayers = useCallback(() => {
const staticCanvas = staticCanvasRef.current;
const nodesCanvas = nodesCanvasRef.current;
const edgesCanvas = edgesCanvasRef.current;
const staticCtx = staticCanvas?.getContext("2d");
const nodesCtx = nodesCanvas?.getContext("2d");
const edgesCtx = edgesCanvas?.getContext("2d");
if (!staticCtx || !staticCanvas || !nodesCtx || !nodesCanvas || !edgesCtx || !edgesCanvas) return;
// Recalculate domain positions for static layer redraw
const cx = staticCanvas.width / 2;
const cy = staticCanvas.height / 2;
const domainKeys = Object.keys(ontologyRef.current);
const domainPositions: Record<DomainKey, { x: number; y: number }> = {};
domainKeys.forEach((domain, i) => {
const angle = (Math.PI * 2 * i) / domainKeys.length - Math.PI / 2;
const radius = Math.min(cx, cy) * 0.45;
domainPositions[domain] = {
x: cx + Math.cos(angle) * radius,
y: cy + Math.sin(angle) * radius,
};
});
drawStaticLayer(staticCtx, staticCanvas, domainPositions);
drawEdgesLayer(edgesCtx, edgesCanvas, timeRef.current);
drawNodesLayer(nodesCtx, nodesCanvas, timeRef.current);
}, [drawStaticLayer, drawEdgesLayer, drawNodesLayer]);
// Zoom handler - zoom towards cursor position
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.min(4, Math.max(0.25, zoomRef.current * delta));
const canvas = nodesCanvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const cursorX = (e.clientX - rect.left) * 2; // Account for 2x canvas scaling
const cursorY = (e.clientY - rect.top) * 2;
// Adjust pan to zoom towards cursor
const zoomRatio = newZoom / zoomRef.current;
const newPanX = cursorX - (cursorX - panRef.current.x) * zoomRatio;
const newPanY = cursorY - (cursorY - panRef.current.y) * zoomRatio;
setZoom(newZoom);
setPan({ x: newPanX, y: newPanY });
}, []);
// Pan handlers
const handleMouseDown = useCallback((e: MouseEvent<HTMLCanvasElement>) => {
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
e.preventDefault();
isPanningRef.current = true;
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
}
}, []);
const handleMouseUp = useCallback(() => {
isPanningRef.current = false;
}, []);
// Reset zoom/pan
const handleResetView = useCallback(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, []);
// Redraw when zoom/pan changes
useEffect(() => {
if (containerSize.width > 0) {
redrawAllLayers();
}
}, [zoom, pan, containerSize, redrawAllLayers]);
const canvasStyle: React.CSSProperties = {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
};
return (
<div
ref={containerRef}
style={{ position: "relative", width: "100%", height: "100%", overflow: "hidden" }}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* Layer 1: Static (grid + domain labels) */}
<canvas ref={staticCanvasRef} style={canvasStyle} />
{/* Layer 2: Edges */}
<canvas ref={edgesCanvasRef} style={canvasStyle} />
{/* Layer 3: Nodes (on top for interaction) */}
<canvas
ref={nodesCanvasRef}
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
onClick={handleClick}
onWheel={handleWheel}
style={canvasStyle}
/>
<ZoomControls
zoom={zoom}
onZoomIn={() => setZoom(z => Math.min(4, z * 1.2))}
onZoomOut={() => setZoom(z => Math.max(0.25, z / 1.2))}
onReset={handleResetView}
/>
{/* Tooltip */}
{hovered && (() => {
const node = nodesRef.current.find((n) => n.id === hovered);
if (!node) return null;
// Transform node position to screen coordinates
const sx = (node.x * zoomRef.current + panRef.current.x) / 2;
const sy = (node.y * zoomRef.current + panRef.current.y) / 2;
return (
<div style={{
position: "absolute", left: sx + 20, top: sy - 20,
background: "rgba(15,15,20,0.95)", border: `1px solid ${node.color}44`,
borderRadius: 8, padding: "10px 14px", pointerEvents: "none",
backdropFilter: "blur(12px)", zIndex: 10, minWidth: 180,
}}>
<div style={{ color: node.color, fontWeight: 700, fontSize: 13, fontFamily: "'IBM Plex Mono', monospace" }}>
{node.icon} {node.label}
</div>
<div style={{ color: "#888", fontSize: 11, marginTop: 4, fontFamily: "'IBM Plex Mono', monospace" }}>
{Object.entries(node.props || {}).map(([k, v]) => (
<div key={k}><span style={{ color: "#666" }}>{k}:</span> <span style={{ color: "#ccc" }}>{String(v)}</span></div>
))}
</div>
</div>
);
})()}
</div>
);
}

View file

@ -0,0 +1,456 @@
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import type { DomainKey, Entity, GraphNode, OntologyType, Relationship } from "../../types";
import { ZoomControls } from "./ZoomControls";
import { border } from "../../theme";
interface GraphCanvasSVGProps {
entities: Entity[];
relationships: Relationship[];
ontology: OntologyType;
highlightedEntities: string[];
onNodeClick: (node: GraphNode) => void;
activeFilter: DomainKey | null;
}
const SETTLE_TIME = 10000; // 10 seconds until nodes settle
export function GraphCanvasSVG({ entities, relationships, ontology, highlightedEntities, onNodeClick, activeFilter }: GraphCanvasSVGProps) {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const [hovered, setHovered] = useState<string | null>(null);
const [settled, setSettled] = useState(false);
const [time, setTime] = useState(0);
const animRef = useRef<number>(0);
const startTimeRef = useRef<number>(0);
const lastFrameTimeRef = useRef<number>(0);
// Zoom and pan state
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const isPanningRef = useRef(false);
const lastPanPosRef = useRef({ x: 0, y: 0 });
// Track container size
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setContainerSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, []);
// Calculate node positions
const { nodes, domainPositions } = useMemo(() => {
if (containerSize.width === 0) return { nodes: [], domainPositions: {} };
const width = containerSize.width;
const height = containerSize.height;
const cx = width / 2;
const cy = height / 2;
const domainKeys = Object.keys(ontology);
const domainPositions: Record<DomainKey, { x: number; y: number }> = {};
domainKeys.forEach((domain, i) => {
const angle = (Math.PI * 2 * i) / domainKeys.length - Math.PI / 2;
const radius = Math.min(cx, cy) * 0.45;
domainPositions[domain] = {
x: cx + Math.cos(angle) * radius,
y: cy + Math.sin(angle) * radius,
};
});
const nodes: GraphNode[] = entities.map((e) => {
const dp = domainPositions[e.domain];
const subIdx = ontology[e.domain].subclasses.findIndex((s) => s.id === e.id);
const total = ontology[e.domain].subclasses.length;
const angle = ((Math.PI * 2) / total) * subIdx - Math.PI / 2;
const radius = Math.min(width, height) * 0.1;
const x = dp.x + Math.cos(angle) * radius;
const y = dp.y + Math.sin(angle) * radius;
return {
...e,
x,
y,
vx: 0,
vy: 0,
targetX: x,
targetY: y,
r: 9, // Half size since we're not doing 2x canvas scaling
};
});
return { nodes, domainPositions };
}, [entities, ontology, containerSize]);
// Animation loop for breathing effect
useEffect(() => {
if (containerSize.width === 0) return;
startTimeRef.current = performance.now();
setSettled(false);
setTime(0);
const frameInterval = 1000 / 30; // 30fps
function animate(currentTime: number) {
if (currentTime - lastFrameTimeRef.current < frameInterval) {
animRef.current = requestAnimationFrame(animate);
return;
}
lastFrameTimeRef.current = currentTime;
// Check if should settle
if (currentTime - startTimeRef.current > SETTLE_TIME) {
setSettled(true);
// Continue animation only if there are highlights
if (highlightedEntities && highlightedEntities.length > 0) {
setTime(t => t + 0.01);
animRef.current = requestAnimationFrame(animate);
}
return;
}
setTime(t => t + 0.01);
animRef.current = requestAnimationFrame(animate);
}
animRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animRef.current);
}, [containerSize, entities, ontology]);
// Restart animation when highlights change
useEffect(() => {
if (highlightedEntities && highlightedEntities.length > 0 && settled && animRef.current === 0) {
const frameInterval = 1000 / 30;
function animate(currentTime: number) {
if (currentTime - lastFrameTimeRef.current < frameInterval) {
animRef.current = requestAnimationFrame(animate);
return;
}
lastFrameTimeRef.current = currentTime;
setTime(t => t + 0.01);
if (highlightedEntities && highlightedEntities.length > 0) {
animRef.current = requestAnimationFrame(animate);
} else {
animRef.current = 0;
}
}
animRef.current = requestAnimationFrame(animate);
}
}, [highlightedEntities, settled]);
// Generate grid lines
const gridLines = useMemo(() => {
const lines: React.ReactElement[] = [];
const { width, height } = containerSize;
if (width === 0) return lines;
for (let x = 0; x < width; x += 30) {
lines.push(
<line key={`v-${x}`} x1={x} y1={0} x2={x} y2={height} stroke={border.grid} strokeWidth={0.5} />
);
}
for (let y = 0; y < height; y += 30) {
lines.push(
<line key={`h-${y}`} x1={0} y1={y} x2={width} y2={y} stroke={border.grid} strokeWidth={0.5} />
);
}
return lines;
}, [containerSize]);
// Calculate edge path with curve
const getEdgePath = useCallback((fromNode: GraphNode, toNode: GraphNode, time: number, isSettled: boolean) => {
const driftX1 = isSettled ? 0 : Math.sin(time + fromNode.targetX * 0.01) * 0.3;
const driftY1 = isSettled ? 0 : Math.cos(time + fromNode.targetY * 0.01) * 0.3;
const driftX2 = isSettled ? 0 : Math.sin(time + toNode.targetX * 0.01) * 0.3;
const driftY2 = isSettled ? 0 : Math.cos(time + toNode.targetY * 0.01) * 0.3;
const x1 = fromNode.x + driftX1;
const y1 = fromNode.y + driftY1;
const x2 = toNode.x + driftX2;
const y2 = toNode.y + driftY2;
const mx = (x1 + x2) / 2 + (y1 - y2) * 0.1;
const my = (y1 + y2) / 2 + (x2 - x1) * 0.1;
return { path: `M ${x1} ${y1} Q ${mx} ${my} ${x2} ${y2}`, mx, my, x1, y1, x2, y2 };
}, []);
// Get node position with drift
const getNodePosition = useCallback((node: GraphNode, time: number, isSettled: boolean) => {
if (isSettled) {
return { x: node.x, y: node.y };
}
const driftX = Math.sin(time + node.targetX * 0.01) * 0.3;
const driftY = Math.cos(time + node.targetY * 0.01) * 0.3;
return { x: node.x + driftX, y: node.y + driftY };
}, []);
const handleNodeClick = useCallback((node: GraphNode) => {
onNodeClick(node);
}, [onNodeClick]);
// Zoom handler - zoom towards cursor position
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.min(4, Math.max(0.25, zoom * delta));
// Get cursor position relative to SVG
const svg = svgRef.current;
if (!svg) return;
const rect = svg.getBoundingClientRect();
const cursorX = e.clientX - rect.left;
const cursorY = e.clientY - rect.top;
// Adjust pan to zoom towards cursor
const zoomRatio = newZoom / zoom;
const newPanX = cursorX - (cursorX - pan.x) * zoomRatio;
const newPanY = cursorY - (cursorY - pan.y) * zoomRatio;
setZoom(newZoom);
setPan({ x: newPanX, y: newPanY });
}, [zoom, pan]);
// Pan handlers
const handleMouseDown = useCallback((e: React.MouseEvent) => {
// Only pan with middle mouse or when holding space (we'll just use middle mouse for now)
if (e.button === 1 || e.button === 0 && e.shiftKey) {
e.preventDefault();
isPanningRef.current = true;
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
}
}, []);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isPanningRef.current) return;
const dx = e.clientX - lastPanPosRef.current.x;
const dy = e.clientY - lastPanPosRef.current.y;
lastPanPosRef.current = { x: e.clientX, y: e.clientY };
setPan(p => ({ x: p.x + dx, y: p.y + dy }));
}, []);
const handleMouseUp = useCallback(() => {
isPanningRef.current = false;
}, []);
// Reset zoom/pan
const handleResetView = useCallback(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, []);
if (containerSize.width === 0) {
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
}
const filteredRels = activeFilter
? relationships.filter((r) => r.domain.includes(activeFilter))
: relationships;
return (
<div
ref={containerRef}
style={{ position: "relative", width: "100%", height: "100%", overflow: "hidden" }}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<svg
ref={svgRef}
width={containerSize.width}
height={containerSize.height}
style={{ display: "block", background: "transparent", cursor: isPanningRef.current ? "grabbing" : "default" }}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
>
{/* Grid - outside transform so it stays fixed */}
<g>{gridLines}</g>
{/* Transformed content */}
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
{/* Domain labels */}
<g>
{(Object.entries(domainPositions) as [DomainKey, { x: number; y: number }][]).map(([domain, pos]) => {
const data = ontology[domain];
return (
<text
key={domain}
x={pos.x}
y={pos.y - Math.min(containerSize.width, containerSize.height) * 0.14}
fill={data.color + "44"}
fontSize={11}
fontWeight="bold"
fontFamily="'IBM Plex Mono', monospace"
textAnchor="middle"
>
{data.label.toUpperCase()}
</text>
);
})}
</g>
{/* Edges */}
<g>
{filteredRels.map((rel, i) => {
const fromNode = nodes.find((n) => n.id === rel.from);
const toNode = nodes.find((n) => n.id === rel.to);
if (!fromNode || !toNode) return null;
const isHighlighted =
highlightedEntities &&
highlightedEntities.includes(rel.from) &&
highlightedEntities.includes(rel.to);
const { path, mx, my, x1, y1, x2, y2 } = getEdgePath(fromNode, toNode, time, settled);
const baseAlpha = isHighlighted ? 0.7 : 0.12;
const pulse = isHighlighted ? Math.sin(time * 4) * 0.15 + 0.15 : 0;
const alpha = Math.min(1, baseAlpha + pulse);
// Particle position on curve (quadratic bezier)
const t = (time * 2) % 1;
const px = (1 - t) * (1 - t) * x1 + 2 * (1 - t) * t * mx + t * t * x2;
const py = (1 - t) * (1 - t) * y1 + 2 * (1 - t) * t * my + t * t * y2;
return (
<g key={`${rel.from}-${rel.predicate}-${rel.to}-${i}`}>
<defs>
<linearGradient id={`grad-${rel.from}-${rel.to}-${i}`} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={fromNode.color} stopOpacity={alpha} />
<stop offset="100%" stopColor={toNode.color} stopOpacity={alpha} />
</linearGradient>
</defs>
<path
d={path}
stroke={`url(#grad-${rel.from}-${rel.to}-${i})`}
strokeWidth={isHighlighted ? 1.5 : 0.75}
fill="none"
/>
{isHighlighted && (
<circle cx={px} cy={py} r={1.5} fill="#fff" />
)}
</g>
);
})}
</g>
{/* Nodes */}
<g>
{nodes.map((node) => {
const isHighlighted = highlightedEntities && highlightedEntities.includes(node.id);
const isHovered = hovered === node.id;
const isDimmed = highlightedEntities && highlightedEntities.length > 0 && !isHighlighted;
const isFiltered = activeFilter && node.domain !== activeFilter && !relationships.some(
r => r.domain.includes(activeFilter) && (r.from === node.id || r.to === node.id)
);
const alpha = isFiltered ? 0.15 : isDimmed ? 0.3 : 1;
const r = isHighlighted || isHovered ? node.r * 1.4 : node.r;
const pulseR = isHighlighted && !settled ? Math.sin(time * 3) * 1.5 : 0;
const { x, y } = getNodePosition(node, time, settled);
return (
<g
key={node.id}
style={{ cursor: "pointer" }}
onClick={() => handleNodeClick(node)}
onMouseEnter={() => setHovered(node.id)}
onMouseLeave={() => setHovered(null)}
>
{/* Glow */}
{(isHighlighted || isHovered) && !isFiltered && (
<circle
cx={x}
cy={y}
r={r + 6 + pulseR}
fill="url(#glow)"
opacity={0.5}
>
<defs>
<radialGradient id={`glow-${node.id}`}>
<stop offset="0%" stopColor={node.color} stopOpacity={0.4} />
<stop offset="100%" stopColor={node.color} stopOpacity={0} />
</radialGradient>
</defs>
</circle>
)}
{/* Node circle */}
<circle
cx={x}
cy={y}
r={r}
fill={node.color}
fillOpacity={alpha * 0.2}
stroke={node.color}
strokeOpacity={alpha}
strokeWidth={isHighlighted ? 1.25 : 0.75}
/>
{/* Label */}
<text
x={x}
y={y + r + 9}
fill={`rgba(255,255,255,${alpha * (isHighlighted ? 1 : 0.75)})`}
fontSize={isHovered ? 8.5 : 7}
fontWeight={isHighlighted ? "bold" : "normal"}
fontFamily="'IBM Plex Sans', sans-serif"
textAnchor="middle"
>
{node.label}
</text>
</g>
);
})}
</g>
</g>{/* Close transform group */}
</svg>
<ZoomControls
zoom={zoom}
onZoomIn={() => setZoom(z => Math.min(4, z * 1.2))}
onZoomOut={() => setZoom(z => Math.max(0.25, z / 1.2))}
onReset={handleResetView}
/>
{/* Tooltip */}
{hovered && (() => {
const node = nodes.find((n) => n.id === hovered);
if (!node) return null;
const { x, y } = getNodePosition(node, time, settled);
return (
<div style={{
position: "absolute", left: x + 20, top: y - 20,
background: "rgba(15,15,20,0.95)", border: `1px solid ${node.color}44`,
borderRadius: 8, padding: "10px 14px", pointerEvents: "none",
backdropFilter: "blur(12px)", zIndex: 10, minWidth: 180,
}}>
<div style={{ color: node.color, fontWeight: 700, fontSize: 13, fontFamily: "'IBM Plex Mono', monospace" }}>
{node.icon} {node.label}
</div>
<div style={{ color: "#888", fontSize: 11, marginTop: 4, fontFamily: "'IBM Plex Mono', monospace" }}>
{Object.entries(node.props || {}).map(([k, v]) => (
<div key={k}><span style={{ color: "#666" }}>{k}:</span> <span style={{ color: "#ccc" }}>{String(v)}</span></div>
))}
</div>
</div>
);
})()}
</div>
);
}

View file

@ -0,0 +1,70 @@
import type { Entity, Relationship, OntologyType } from "../../types";
import { SectionLabel, Card } from "../common";
import { text, border } from "../../theme";
interface NodeDetailPanelProps {
node: Entity;
relationships: Relationship[];
entities: Entity[];
ontology: OntologyType;
propertyLabels: Record<string, string>;
onClose: () => void;
onNodeSelect: (node: Entity) => void;
}
export function NodeDetailPanel({ node, relationships, entities, ontology, propertyLabels, onClose, onNodeSelect }: NodeDetailPanelProps) {
// Filter relationships for this node
const nodeRelationships = relationships.filter(
r => r.from === node.id || r.to === node.id
);
return (
<div style={{
width: 320, flexShrink: 0, borderLeft: `1px solid ${border.default}`,
background: "rgba(12,12,18,0.95)", padding: 24, overflowY: "auto",
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 20 }}>
<div style={{ color: ontology[node.domain].color, fontSize: 11, fontFamily: "'IBM Plex Mono', monospace", fontWeight: 600 }}>
{ontology[node.domain].label.toUpperCase()} ENTITY
</div>
<button onClick={onClose} style={{ background: "none", border: "none", color: text.faint, cursor: "pointer", fontSize: 18 }}>×</button>
</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#fff", marginBottom: 6 }}>
{node.icon} {node.label}
</div>
<div style={{ marginTop: 20 }}>
<SectionLabel>PROPERTIES</SectionLabel>
{Object.entries(node.props || {}).map(([k, v]) => (
<div key={k} style={{ display: "flex", justifyContent: "space-between", padding: "8px 0", borderBottom: `1px solid ${border.subtle}` }}>
<span style={{ fontSize: 12, color: text.subtle }}>{propertyLabels[k] || k}</span>
<span style={{ fontSize: 12, color: text.primary, fontFamily: "'IBM Plex Mono', monospace", textAlign: "right" }}>{String(v)}</span>
</div>
))}
</div>
<div style={{ marginTop: 24 }}>
<SectionLabel>RELATIONSHIPS</SectionLabel>
{nodeRelationships.map((r, i) => {
const otherId = r.from === node.id ? r.to : r.from;
const other = entities.find(e => e.id === otherId);
const direction = r.from === node.id ? "→" : "←";
return (
<Card
key={i}
padding="8px 10px"
borderRadius={6}
onClick={() => { if (other) onNodeSelect(other); }}
style={{ marginBottom: 4 }}
>
<div style={{ fontSize: 11, color: text.muted }}>
<span style={{ color: other?.color || text.subtle }}>{direction} {other?.label}</span>
</div>
<div style={{ fontSize: 10, color: text.faint, fontFamily: "'IBM Plex Mono', monospace", marginTop: 2 }}>
{r.predicate.replace(/_/g, " ")}
</div>
</Card>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,73 @@
import { surface, border, text } from "../../theme";
interface ZoomControlsProps {
zoom: number;
onZoomIn: () => void;
onZoomOut: () => void;
onReset: () => void;
}
export function ZoomControls({ zoom, onZoomIn, onZoomOut, onReset }: ZoomControlsProps) {
const buttonStyle: React.CSSProperties = {
width: 28,
height: 28,
border: "none",
borderRadius: 4,
background: border.medium,
color: text.subtle,
cursor: "pointer",
fontSize: 16,
fontWeight: "bold",
};
return (
<>
{/* Zoom controls */}
<div style={{
position: "absolute",
bottom: 16,
right: 16,
display: "flex",
flexDirection: "column",
gap: 4,
background: surface.overlayLight,
borderRadius: 8,
padding: 4,
border: `1px solid ${border.medium}`,
}}>
<button
onClick={onZoomIn}
style={buttonStyle}
title="Zoom in"
>+</button>
<button
onClick={onZoomOut}
style={buttonStyle}
title="Zoom out"
></button>
<button
onClick={onReset}
style={{ ...buttonStyle, fontSize: 10 }}
title="Reset view"
></button>
</div>
{/* Zoom indicator */}
{zoom !== 1 && (
<div style={{
position: "absolute",
bottom: 16,
left: 16,
fontSize: 11,
fontFamily: "'IBM Plex Mono', monospace",
color: text.faint,
background: surface.overlayLight,
padding: "4px 8px",
borderRadius: 4,
}}>
{Math.round(zoom * 100)}%
</div>
)}
</>
);
}

View file

@ -0,0 +1,6 @@
export { GraphCanvas } from "./GraphCanvas";
export { GraphCanvasSVG } from "./GraphCanvasSVG";
export { ExplainGraph } from "./ExplainGraph";
export type { ExplainGraphNode, ExplainGraphEdge } from "./ExplainGraph";
export { NodeDetailPanel } from "./NodeDetailPanel";
export { ZoomControls } from "./ZoomControls";

View file

@ -0,0 +1,7 @@
// Common shared components
export { SectionLabel, FilterButton, Header, StatusBar, Typewriter, Card, Badge, LoadingState, Toaster, SearchInput, FilterBar, MessageBubble } from "./common";
export type { FilterItem, Message } from "./common";
// Graph visualization components
export { GraphCanvas, GraphCanvasSVG, ExplainGraph, NodeDetailPanel, ZoomControls } from "./graph";
export type { ExplainGraphNode, ExplainGraphEdge } from "./graph";