mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 17:39:39 +02:00
Merge commit '9b2f675702' as 'ai-context/context-graph-demo'
This commit is contained in:
commit
ecaf3489f1
54 changed files with 10078 additions and 0 deletions
56
ai-context/context-graph-demo/src/App.tsx
Normal file
56
ai-context/context-graph-demo/src/App.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import type { TabKey, DomainKey, Entity } from "./types";
|
||||
import { Header, StatusBar, Toaster } from "./components";
|
||||
import { GraphView, QueryView, ExplainView, DataView, OntologyView } from "./pages";
|
||||
import { useGraphData, toast } from "./state";
|
||||
|
||||
export default function App() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("graph");
|
||||
const [activeFilter, setActiveFilter] = useState<DomainKey | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<Entity | null>(null);
|
||||
const { entities, isLoading } = useGraphData();
|
||||
|
||||
// Notification when graph loads
|
||||
useEffect(() => {
|
||||
if (!isLoading && entities.length > 0) {
|
||||
toast.success(`Graph loaded: ${entities.length} entities`);
|
||||
}
|
||||
}, [isLoading, entities.length]);
|
||||
|
||||
const handleTabChange = (tab: TabKey) => {
|
||||
setActiveTab(tab);
|
||||
if (tab !== "graph") {
|
||||
setSelectedNode(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: "100%", minHeight: "100vh", background: "#0A0A0F",
|
||||
fontFamily: "'IBM Plex Sans', -apple-system, sans-serif",
|
||||
color: "#E5E5E5", overflow: "hidden",
|
||||
}}>
|
||||
<Header activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
|
||||
{activeTab === "graph" && (
|
||||
<GraphView
|
||||
activeFilter={activeFilter}
|
||||
onFilterChange={setActiveFilter}
|
||||
selectedNode={selectedNode}
|
||||
onNodeSelect={setSelectedNode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "query" && <QueryView />}
|
||||
|
||||
{activeTab === "explain" && <ExplainView />}
|
||||
|
||||
{activeTab === "data" && <DataView />}
|
||||
|
||||
{activeTab === "ontology" && <OntologyView />}
|
||||
|
||||
<StatusBar />
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
ai-context/context-graph-demo/src/assets/react.svg
Normal file
1
ai-context/context-graph-demo/src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
35
ai-context/context-graph-demo/src/components/common/Card.tsx
Normal file
35
ai-context/context-graph-demo/src/components/common/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
120
ai-context/context-graph-demo/src/components/common/Toaster.tsx
Normal file
120
ai-context/context-graph-demo/src/components/common/Toaster.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
14
ai-context/context-graph-demo/src/components/common/index.ts
Normal file
14
ai-context/context-graph-demo/src/components/common/index.ts
Normal 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";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
7
ai-context/context-graph-demo/src/components/index.ts
Normal file
7
ai-context/context-graph-demo/src/components/index.ts
Normal 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";
|
||||
2
ai-context/context-graph-demo/src/config.ts
Normal file
2
ai-context/context-graph-demo/src/config.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// TrustGraph collection identifier
|
||||
export const COLLECTION = "default";
|
||||
18
ai-context/context-graph-demo/src/index.css
Normal file
18
ai-context/context-graph-demo/src/index.css
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0A0A0F;
|
||||
}
|
||||
|
||||
body {
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
29
ai-context/context-graph-demo/src/main.tsx
Normal file
29
ai-context/context-graph-demo/src/main.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { SocketProvider } from '@trustgraph/react-provider'
|
||||
import { NotificationProvider, NotificationHandler } from '@trustgraph/react-state'
|
||||
import { toast } from './state'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const notificationHandler: NotificationHandler = {
|
||||
success: (message: string) => toast.success(message),
|
||||
error: (message: string) => toast.error(message),
|
||||
warning: (message: string) => toast.warning(message),
|
||||
info: (message: string) => toast.info(message),
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NotificationProvider handler={notificationHandler}>
|
||||
<SocketProvider user="trustgraph">
|
||||
<App />
|
||||
</SocketProvider>
|
||||
</NotificationProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
393
ai-context/context-graph-demo/src/pages/DataView.tsx
Normal file
393
ai-context/context-graph-demo/src/pages/DataView.tsx
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import { useState, useCallback, useMemo } from "react";
|
||||
import { SectionLabel, Card, LoadingState, SearchInput, FilterBar } from "../components";
|
||||
import type { FilterItem } from "../components";
|
||||
import { useSchemas, useEmbeddings, useRowEmbeddingsQuery, useRowsQuery } from "@trustgraph/react-state";
|
||||
import { COLLECTION } from "../config";
|
||||
import { semantic, palette, text, border, surface } from "../theme";
|
||||
|
||||
// Schema field type
|
||||
interface SchemaField {
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Schema type based on what useSchemas returns
|
||||
interface SchemaData {
|
||||
name: string;
|
||||
description?: string;
|
||||
fields?: SchemaField[];
|
||||
indexes?: { name: string; fields: string[] }[];
|
||||
}
|
||||
|
||||
interface SchemaInfo {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
fields: SchemaField[];
|
||||
indexes: { name: string; fields: string[] }[];
|
||||
}
|
||||
|
||||
// Type for accumulated results with schema info and row data
|
||||
interface AccumulatedMatch {
|
||||
schemaKey: string;
|
||||
index_name: string;
|
||||
index_value: string[];
|
||||
text: string;
|
||||
score: number;
|
||||
rowData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function DataView() {
|
||||
// Input state
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// Filter state (display only - doesn't trigger re-fetch)
|
||||
const [selectedSchema, setSelectedSchema] = useState<string | null>(null);
|
||||
|
||||
// Results state
|
||||
const [allMatches, setAllMatches] = useState<AccumulatedMatch[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
|
||||
// Fetch schemas
|
||||
const { schemas: rawSchemas, schemasLoading, schemasError } = useSchemas();
|
||||
|
||||
// Embeddings hook - we'll use refetch for manual triggering
|
||||
const [embeddingsTerm, setEmbeddingsTerm] = useState("");
|
||||
const { embeddings, isLoading: embeddingsLoading, refetch: _refetchEmbeddings } = useEmbeddings({
|
||||
flow: "default",
|
||||
term: embeddingsTerm,
|
||||
});
|
||||
|
||||
// Row embeddings query
|
||||
const { executeQueryAsync } = useRowEmbeddingsQuery({ flow: "default" });
|
||||
|
||||
// Rows query for fetching full row data
|
||||
const { executeQueryAsync: executeRowsQueryAsync } = useRowsQuery({ flow: "default" });
|
||||
|
||||
// Parse schemas into usable format
|
||||
const schemas: SchemaInfo[] = useMemo(() => {
|
||||
return (rawSchemas || []).map((s: unknown, idx: number) => {
|
||||
if (Array.isArray(s)) {
|
||||
const schemaData = s[1] as SchemaData | undefined;
|
||||
return {
|
||||
key: String(s[0]),
|
||||
name: schemaData?.name || String(s[0]),
|
||||
description: schemaData?.description,
|
||||
fields: schemaData?.fields || [],
|
||||
indexes: schemaData?.indexes || [],
|
||||
};
|
||||
}
|
||||
const schemaObj = s as SchemaData & { key?: string };
|
||||
return {
|
||||
key: schemaObj.key || schemaObj.name || `schema-${idx}`,
|
||||
name: schemaObj.name || `Schema ${idx}`,
|
||||
description: schemaObj.description,
|
||||
fields: schemaObj.fields || [],
|
||||
indexes: schemaObj.indexes || [],
|
||||
};
|
||||
});
|
||||
}, [rawSchemas]);
|
||||
|
||||
// Build GraphQL query for a schema
|
||||
const buildGraphQLQuery = useCallback((schema: SchemaInfo) => {
|
||||
const gqlName = schema.key.replace(/-/g, '_');
|
||||
const fieldNames = schema.fields.map(f => f.name).join('\n ');
|
||||
return `query { ${gqlName} { ${fieldNames} } }`;
|
||||
}, []);
|
||||
|
||||
// Core search function - searches ALL schemas, stores ALL results
|
||||
const performSearch = useCallback(async (vectors: number[][]) => {
|
||||
try {
|
||||
// Always search ALL schemas
|
||||
const embeddingsResults = await Promise.all(
|
||||
schemas.map(async (schema) => {
|
||||
try {
|
||||
const matches = await executeQueryAsync({
|
||||
vectors,
|
||||
schemaName: schema.key,
|
||||
collection: COLLECTION,
|
||||
limit: 10,
|
||||
});
|
||||
return matches.map(m => ({ ...m, schemaKey: schema.key }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const flatMatches = embeddingsResults.flat();
|
||||
|
||||
// Deduplicate
|
||||
const seen = new Set<string>();
|
||||
const uniqueMatches = flatMatches.filter(match => {
|
||||
const key = `${match.schemaKey}:${match.index_value.join(',')}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Fetch full row data for schemas with matches
|
||||
const schemaKeysWithMatches = [...new Set(uniqueMatches.map(m => m.schemaKey))];
|
||||
const rowDataBySchema: Record<string, Record<string, unknown>[]> = {};
|
||||
|
||||
await Promise.all(
|
||||
schemaKeysWithMatches.map(async (schemaKey) => {
|
||||
const schema = schemas.find(s => s.key === schemaKey);
|
||||
if (!schema || schema.fields.length === 0) return;
|
||||
|
||||
try {
|
||||
const query = buildGraphQLQuery(schema);
|
||||
const result = await executeRowsQueryAsync({ query, collection: COLLECTION });
|
||||
const gqlName = schemaKey.replace(/-/g, '_');
|
||||
const rows = (result?.data as Record<string, unknown[]>)?.[gqlName] || [];
|
||||
rowDataBySchema[schemaKey] = rows as Record<string, unknown>[];
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch rows for ${schemaKey}:`, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Match row data to embeddings results
|
||||
const matchesWithRowData = uniqueMatches.map(match => {
|
||||
const rows = rowDataBySchema[match.schemaKey] || [];
|
||||
const indexFields = match.index_name.split('.');
|
||||
const indexFieldName = indexFields[indexFields.length - 1];
|
||||
|
||||
const matchedRow = rows.find(row => {
|
||||
const rowValue = row[indexFieldName];
|
||||
return match.index_value.some(iv =>
|
||||
String(rowValue).toLowerCase() === iv.toLowerCase()
|
||||
);
|
||||
});
|
||||
|
||||
return { ...match, rowData: matchedRow };
|
||||
});
|
||||
|
||||
setAllMatches(matchesWithRowData);
|
||||
setHasSearched(true);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, [schemas, executeQueryAsync, executeRowsQueryAsync, buildGraphQLQuery]);
|
||||
|
||||
// Handle search button click
|
||||
const handleSearch = useCallback(async () => {
|
||||
const term = searchTerm.trim();
|
||||
if (!term) return;
|
||||
|
||||
setIsSearching(true);
|
||||
setAllMatches([]);
|
||||
|
||||
// If same term, use refetch; otherwise set new term
|
||||
if (term === embeddingsTerm && embeddings && embeddings.length > 0) {
|
||||
// Same term - we already have embeddings, just re-run the search
|
||||
await performSearch(embeddings);
|
||||
} else {
|
||||
// New term - update embeddings term and wait for it
|
||||
setEmbeddingsTerm(term);
|
||||
}
|
||||
}, [searchTerm, embeddingsTerm, embeddings, performSearch]);
|
||||
|
||||
// When embeddings become available for a new term, run the search
|
||||
// This only triggers when embeddingsTerm changes and embeddings load
|
||||
const prevEmbeddingsTermRef = useMemo(() => ({ current: "" }), []);
|
||||
|
||||
if (
|
||||
isSearching &&
|
||||
embeddingsTerm &&
|
||||
embeddings &&
|
||||
embeddings.length > 0 &&
|
||||
!embeddingsLoading &&
|
||||
prevEmbeddingsTermRef.current !== embeddingsTerm
|
||||
) {
|
||||
prevEmbeddingsTermRef.current = embeddingsTerm;
|
||||
performSearch(embeddings);
|
||||
}
|
||||
|
||||
// Filter results for display (doesn't affect stored data)
|
||||
const displayMatches = useMemo(() => {
|
||||
if (!selectedSchema) return allMatches;
|
||||
return allMatches.filter(m => m.schemaKey === selectedSchema);
|
||||
}, [allMatches, selectedSchema]);
|
||||
|
||||
// Group filtered matches by schema for display
|
||||
const matchesBySchema = useMemo(() => {
|
||||
return displayMatches.reduce((acc, match) => {
|
||||
if (!acc[match.schemaKey]) {
|
||||
acc[match.schemaKey] = [];
|
||||
}
|
||||
acc[match.schemaKey].push(match);
|
||||
return acc;
|
||||
}, {} as Record<string, AccumulatedMatch[]>);
|
||||
}, [displayMatches]);
|
||||
|
||||
if (schemasLoading) {
|
||||
return <LoadingState message="Loading schemas..." />;
|
||||
}
|
||||
|
||||
if (schemasError) {
|
||||
return <LoadingState variant="error" message="Error loading schemas" />;
|
||||
}
|
||||
|
||||
// Build filter items from schemas
|
||||
const filterItems: FilterItem[] = schemas.slice(0, 10).map((schema) => ({
|
||||
key: schema.key,
|
||||
label: schema.name,
|
||||
}));
|
||||
|
||||
const filterStats = selectedSchema
|
||||
? `${displayMatches.length} of ${allMatches.length} results`
|
||||
: `${allMatches.length} results`;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "calc(100vh - 110px)" }}>
|
||||
{/* Schema Filter Bar */}
|
||||
<FilterBar
|
||||
items={filterItems}
|
||||
selectedKey={selectedSchema}
|
||||
onSelect={setSelectedSchema}
|
||||
stats={filterStats}
|
||||
/>
|
||||
|
||||
{/* Search Input */}
|
||||
<div style={{ padding: "20px 28px", borderBottom: `1px solid ${border.default}` }}>
|
||||
<SectionLabel marginBottom={12}>SEARCH DATA</SectionLabel>
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
onSubmit={handleSearch}
|
||||
placeholder="Search for data across tables..."
|
||||
buttonText="Search"
|
||||
isLoading={isSearching}
|
||||
buttonColor={palette.blue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results Area */}
|
||||
<div style={{ flex: 1, padding: "24px 28px", overflowY: "auto" }}>
|
||||
{!hasSearched && !isSearching ? (
|
||||
<div style={{ color: text.hint, fontSize: 13, fontStyle: "italic" }}>
|
||||
Enter a search term to find data across tables.
|
||||
</div>
|
||||
) : isSearching ? (
|
||||
<div style={{ color: palette.blue, fontSize: 13 }}>
|
||||
Searching...
|
||||
</div>
|
||||
) : displayMatches.length === 0 ? (
|
||||
<div style={{ color: text.hint, fontSize: 13, fontStyle: "italic" }}>
|
||||
{selectedSchema ? "No matches in this schema. Try selecting 'All'." : "No matches found."}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
{Object.entries(matchesBySchema).map(([schemaKey, schemaMatches]) => {
|
||||
if (!schemaMatches || schemaMatches.length === 0) return null;
|
||||
const schema = schemas.find(s => s.key === schemaKey);
|
||||
|
||||
return (
|
||||
<Card key={schemaKey} padding={0}>
|
||||
{/* Table Header */}
|
||||
<div style={{
|
||||
padding: "12px 16px",
|
||||
borderBottom: `1px solid ${border.default}`,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: palette.blue,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}>
|
||||
▤ {schema?.name || schemaKey}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
color: text.disabled,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}>
|
||||
{schemaMatches.length} matches
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Results List */}
|
||||
<div>
|
||||
{schemaMatches.map((match, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderBottom: `1px solid ${border.subtle}`,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = surface.card;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
{match.rowData ? (
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
|
||||
gap: "8px 16px",
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{Object.entries(match.rowData).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
color: text.faint,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
{key}
|
||||
</span>
|
||||
<div style={{
|
||||
fontSize: 13,
|
||||
color: text.primary,
|
||||
marginTop: 2,
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
{String(value ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
fontSize: 13,
|
||||
color: text.primary,
|
||||
marginBottom: 6,
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
{match.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
fontSize: 11,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}>
|
||||
<span style={{
|
||||
color: match.score > 0.8 ? semantic.success : match.score > 0.5 ? palette.amber : text.subtle,
|
||||
}}>
|
||||
{(match.score * 100).toFixed(1)}% match
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1411
ai-context/context-graph-demo/src/pages/ExplainView.tsx
Normal file
1411
ai-context/context-graph-demo/src/pages/ExplainView.tsx
Normal file
File diff suppressed because it is too large
Load diff
93
ai-context/context-graph-demo/src/pages/GraphView.tsx
Normal file
93
ai-context/context-graph-demo/src/pages/GraphView.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import type { DomainKey, Entity, OntologyDomain } from "../types";
|
||||
import { GraphCanvasSVG as GraphCanvas, NodeDetailPanel, LoadingState, FilterBar } from "../components";
|
||||
import type { FilterItem } from "../components";
|
||||
import { useGraphData } from "../state";
|
||||
|
||||
interface GraphViewProps {
|
||||
activeFilter: DomainKey | null;
|
||||
onFilterChange: (filter: DomainKey | null) => void;
|
||||
selectedNode: Entity | null;
|
||||
onNodeSelect: (node: Entity | null) => void;
|
||||
}
|
||||
|
||||
export function GraphView({ activeFilter, onFilterChange, selectedNode, onNodeSelect }: GraphViewProps) {
|
||||
const { entities, relationships, ontology, propertyLabels, isLoading, isError } = useGraphData();
|
||||
|
||||
const highlightedEntities = selectedNode
|
||||
? [selectedNode.id, ...relationships.filter(r => r.from === selectedNode.id || r.to === selectedNode.id).map(r => r.from === selectedNode.id ? r.to : r.from)]
|
||||
: [];
|
||||
|
||||
// Compute relevant filter domains based on selected node's connections
|
||||
const relevantDomains = selectedNode
|
||||
? (() => {
|
||||
const domains = new Set<DomainKey>([selectedNode.domain]);
|
||||
const connectedIds = relationships
|
||||
.filter(r => r.from === selectedNode.id || r.to === selectedNode.id)
|
||||
.map(r => r.from === selectedNode.id ? r.to : r.from);
|
||||
for (const id of connectedIds) {
|
||||
const entity = entities.find(e => e.id === id);
|
||||
if (entity) domains.add(entity.domain);
|
||||
}
|
||||
return domains;
|
||||
})()
|
||||
: null;
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState message="Loading graph data..." />;
|
||||
}
|
||||
|
||||
if (isError || !ontology) {
|
||||
return <LoadingState variant="error" message="Error loading graph data" />;
|
||||
}
|
||||
|
||||
// Build filter items from relevant domains
|
||||
const filterItems: FilterItem[] = selectedNode
|
||||
? (Object.entries(ontology) as [DomainKey, OntologyDomain][])
|
||||
.filter(([key]) => relevantDomains?.has(key))
|
||||
.slice(0, 10)
|
||||
.map(([key, data]) => ({
|
||||
key,
|
||||
label: data.label,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Domain Filter Bar */}
|
||||
<FilterBar
|
||||
items={filterItems}
|
||||
selectedKey={activeFilter}
|
||||
onSelect={(key) => onFilterChange(key as DomainKey | null)}
|
||||
stats={`${entities.length} entities · ${relationships.length} relationships`}
|
||||
emptyMessage={selectedNode ? undefined : "Select a node to filter"}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<div style={{ display: "flex", height: "calc(100vh - 150px)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0, position: "relative", overflow: "hidden" }}>
|
||||
<GraphCanvas
|
||||
entities={entities}
|
||||
relationships={relationships}
|
||||
ontology={ontology}
|
||||
highlightedEntities={highlightedEntities}
|
||||
onNodeClick={(node) => onNodeSelect(selectedNode?.id === node.id ? null : node)}
|
||||
activeFilter={activeFilter}
|
||||
/>
|
||||
</div>
|
||||
{selectedNode && (
|
||||
<NodeDetailPanel
|
||||
node={selectedNode}
|
||||
relationships={relationships}
|
||||
entities={entities}
|
||||
ontology={ontology}
|
||||
propertyLabels={propertyLabels}
|
||||
onClose={() => onNodeSelect(null)}
|
||||
onNodeSelect={onNodeSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
116
ai-context/context-graph-demo/src/pages/OntologyView.tsx
Normal file
116
ai-context/context-graph-demo/src/pages/OntologyView.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { DomainKey, OntologyDomain } from "../types";
|
||||
import { SectionLabel, Card, Badge, LoadingState } from "../components";
|
||||
import { useGraphData, useOntologySchema } from "../state";
|
||||
import { getLocalName } from "../utils";
|
||||
import { text, surface, border } from "../theme";
|
||||
|
||||
export function OntologyView() {
|
||||
const { ontology, isLoading: graphLoading } = useGraphData();
|
||||
const { schema, isLoading: schemaLoading } = useOntologySchema();
|
||||
|
||||
const isLoading = graphLoading || schemaLoading;
|
||||
|
||||
if (isLoading || !ontology || !schema) {
|
||||
return <LoadingState message="Loading ontology..." />;
|
||||
}
|
||||
|
||||
// Count total instances
|
||||
const totalInstances = Object.values(ontology).reduce((sum, d) => sum + d.subclasses.length, 0);
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, padding: "28px", overflowY: "auto", height: "calc(100vh - 110px)" }}>
|
||||
<div style={{ maxWidth: 900, margin: "0 auto" }}>
|
||||
<SectionLabel marginBottom={24}>ONTOLOGY SCHEMA</SectionLabel>
|
||||
|
||||
{/* Ontology class cards */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginBottom: 32 }}>
|
||||
{(Object.entries(ontology) as [DomainKey, OntologyDomain][]).map(([key, data]) => {
|
||||
// Find datatype properties for this domain from schema
|
||||
const domainProps = schema.datatypeProperties
|
||||
.filter(p => p.domain && getLocalName(p.domain) === data.label)
|
||||
.map(p => p.label);
|
||||
|
||||
return (
|
||||
<Card key={key} borderColor={data.color + "22"}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
|
||||
<span style={{ fontSize: 24 }}>{data.icon}</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 18, color: data.color }}>{data.label}</div>
|
||||
<div style={{ fontSize: 11, color: text.faint, fontFamily: "'IBM Plex Mono', monospace" }}>owl:Class</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: text.subtle, lineHeight: 1.5, marginBottom: 14 }}>{data.description}</div>
|
||||
<SectionLabel marginBottom={8}>PROPERTIES ({domainProps.length})</SectionLabel>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
|
||||
{domainProps.map((p) => (
|
||||
<Badge key={p} color={data.color} size="small">{p}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<SectionLabel marginTop={14} marginBottom={8}>INSTANCES ({data.subclasses.length})</SectionLabel>
|
||||
{data.subclasses.map((sc) => (
|
||||
<div key={sc.id} style={{
|
||||
padding: "6px 10px", marginBottom: 3, borderRadius: 4,
|
||||
background: surface.card, fontSize: 11, color: text.muted,
|
||||
display: "flex", justifyContent: "space-between",
|
||||
}}>
|
||||
<span>{sc.label}</span>
|
||||
<span style={{ color: text.disabled, fontFamily: "'IBM Plex Mono', monospace", fontSize: 10 }}>{sc.id}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Relationship predicates (Object Properties) */}
|
||||
<Card borderColor={border.default}>
|
||||
<SectionLabel marginBottom={16}>RELATIONSHIP PREDICATES ({schema.objectProperties.length})</SectionLabel>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
|
||||
{schema.objectProperties.map((prop) => {
|
||||
const fromDomain = prop.domain ? getLocalName(prop.domain).toLowerCase() as DomainKey : null;
|
||||
const toDomain = prop.range ? getLocalName(prop.range).toLowerCase() as DomainKey : null;
|
||||
|
||||
return (
|
||||
<Card key={prop.uri} padding="10px 12px" borderRadius={6}>
|
||||
<div style={{ fontSize: 12, color: text.secondary, fontFamily: "'IBM Plex Mono', monospace", marginBottom: 4 }}>
|
||||
{prop.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: text.disabled }}>
|
||||
{fromDomain && ontology[fromDomain] && (
|
||||
<span style={{ color: ontology[fromDomain].color }}>{ontology[fromDomain].label}</span>
|
||||
)}
|
||||
{fromDomain && toDomain && " → "}
|
||||
{toDomain && ontology[toDomain] && (
|
||||
<span style={{ color: ontology[toDomain].color }}>{ontology[toDomain].label}</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Triple count summary */}
|
||||
<div style={{
|
||||
marginTop: 20, padding: "16px 24px", borderRadius: 10,
|
||||
background: "linear-gradient(135deg, rgba(110,231,183,0.04) 0%, rgba(147,197,253,0.04) 50%, rgba(249,168,212,0.04) 100%)",
|
||||
border: `1px solid ${border.default}`,
|
||||
display: "flex", justifyContent: "space-around",
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}>
|
||||
{[
|
||||
{ label: "Classes", value: schema.classes.length },
|
||||
{ label: "Instances", value: totalInstances },
|
||||
{ label: "Object Props", value: schema.objectProperties.length },
|
||||
{ label: "Data Props", value: schema.datatypeProperties.length },
|
||||
].map((s) => (
|
||||
<div key={s.label} style={{ textAlign: "center" }}>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: "#fff" }}>{s.value}</div>
|
||||
<div style={{ fontSize: 10, color: text.faint, letterSpacing: "0.05em" }}>{s.label.toUpperCase()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
ai-context/context-graph-demo/src/pages/QueryView.tsx
Normal file
231
ai-context/context-graph-demo/src/pages/QueryView.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { GraphCanvasSVG as GraphCanvas, NodeDetailPanel, SectionLabel, Badge, LoadingState, SearchInput, MessageBubble } from "../components";
|
||||
import { useGraphData } from "../state";
|
||||
import { COLLECTION } from "../config";
|
||||
import type { Entity } from "../types";
|
||||
import { useChat, useConversation, useEmbeddings, useGraphEmbeddings } from "@trustgraph/react-state";
|
||||
import { getLocalName } from "../utils";
|
||||
import { palette, text, border, withGlow } from "../theme";
|
||||
|
||||
// Type for embedding result items
|
||||
interface EmbeddingResultItem {
|
||||
id: string;
|
||||
uri: string;
|
||||
label: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
isEntity: boolean;
|
||||
}
|
||||
|
||||
export function QueryView() {
|
||||
const [customInput, setCustomInput] = useState("");
|
||||
const [queryForEmbeddings, setQueryForEmbeddings] = useState<string | undefined>(undefined);
|
||||
const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<Entity | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { entities, relationships, ontology, propertyLabels, isLoading: graphLoading } = useGraphData();
|
||||
const { submitMessage, isSubmitting } = useChat();
|
||||
const messages = useConversation((state) => state.messages);
|
||||
const setChatMode = useConversation((state) => state.setChatMode);
|
||||
|
||||
// Get embeddings for the query text - only fetch when we have a committed query
|
||||
const { embeddings, isLoading: embeddingsLoading } = useEmbeddings({
|
||||
flow: "default",
|
||||
term: queryForEmbeddings || undefined,
|
||||
});
|
||||
|
||||
// Get graph entities from embeddings - only fetch when we have embeddings
|
||||
const hasEmbeddings = embeddings && embeddings.length > 0;
|
||||
const { graphEmbeddings, isLoading: graphEmbeddingsLoading } = useGraphEmbeddings({
|
||||
vecs: hasEmbeddings ? embeddings : [[]],
|
||||
limit: hasEmbeddings ? 10 : 0,
|
||||
collection: COLLECTION,
|
||||
});
|
||||
|
||||
// Set chat mode to agent on mount
|
||||
useEffect(() => {
|
||||
setChatMode("agent");
|
||||
}, [setChatMode]);
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = (query: string) => {
|
||||
if (query.trim() && !isSubmitting) {
|
||||
const trimmedQuery = query.trim();
|
||||
submitMessage({ input: trimmedQuery });
|
||||
setQueryForEmbeddings(trimmedQuery);
|
||||
setSelectedEntityId(null);
|
||||
setSelectedNode(null);
|
||||
setCustomInput("");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Match graph embedding entities to our loaded entities for labels and highlighting
|
||||
// graphEmbeddings returns RDF terms: { t: "i", i: "http://..." }
|
||||
// Only show matched entities, deduplicated by URI
|
||||
const embeddingResults: EmbeddingResultItem[] = [];
|
||||
const seenUris = new Set<string>();
|
||||
|
||||
for (const ge of (hasEmbeddings && graphEmbeddings || []) as { t: string; i?: string }[]) {
|
||||
const uri = ge.i;
|
||||
if (!uri || seenUris.has(uri)) continue;
|
||||
|
||||
const entityId = getLocalName(uri);
|
||||
const found = entities.find(e => e.id === entityId || e.uri === uri);
|
||||
|
||||
// Only include actual entities, not properties/concepts
|
||||
if (found) {
|
||||
seenUris.add(uri);
|
||||
embeddingResults.push({
|
||||
id: entityId,
|
||||
uri,
|
||||
label: found.label,
|
||||
color: found.color,
|
||||
icon: found.icon,
|
||||
isEntity: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select first embedding result when results arrive
|
||||
useEffect(() => {
|
||||
if (embeddingResults.length > 0 && !selectedEntityId && !selectedNode) {
|
||||
setSelectedEntityId(embeddingResults[0].id);
|
||||
}
|
||||
}, [embeddingResults.length, selectedEntityId, selectedNode]);
|
||||
|
||||
// Extract entity IDs for highlighting on graph
|
||||
// Priority: selectedNode (graph click) > selectedEntityId (button click) > all embedding results
|
||||
const highlightedEntities = (() => {
|
||||
const focusId = selectedNode?.id || selectedEntityId;
|
||||
if (!focusId) {
|
||||
return embeddingResults.map(e => e.id);
|
||||
}
|
||||
// Find all entities connected to the focused entity
|
||||
const connected = new Set<string>([focusId]);
|
||||
for (const rel of relationships) {
|
||||
if (rel.from === focusId) {
|
||||
connected.add(rel.to);
|
||||
} else if (rel.to === focusId) {
|
||||
connected.add(rel.from);
|
||||
}
|
||||
}
|
||||
return Array.from(connected);
|
||||
})();
|
||||
|
||||
if (graphLoading || !ontology) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "calc(100vh - 110px)" }}>
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
{/* Query input area */}
|
||||
<div style={{ padding: "20px 28px", borderBottom: `1px solid ${border.default}` }}>
|
||||
<SectionLabel marginBottom={12}>AGENT QUERIES</SectionLabel>
|
||||
|
||||
<SearchInput
|
||||
value={customInput}
|
||||
onChange={setCustomInput}
|
||||
onSubmit={() => handleSubmit(customInput)}
|
||||
placeholder="Type your own question..."
|
||||
buttonText="Ask"
|
||||
isLoading={isSubmitting}
|
||||
buttonColor={palette.amber}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Related entities from graph embeddings */}
|
||||
{queryForEmbeddings && (
|
||||
<div style={{ padding: "16px 28px", borderBottom: `1px solid ${border.default}` }}>
|
||||
<SectionLabel>
|
||||
RELATED ENTITIES {(embeddingsLoading || graphEmbeddingsLoading) && <span style={{ color: palette.amber }}>loading...</span>}
|
||||
</SectionLabel>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{embeddingResults.length === 0 && !embeddingsLoading && !graphEmbeddingsLoading && (
|
||||
<span style={{ fontSize: 11, color: text.disabled, fontStyle: "italic" }}>No related concepts found</span>
|
||||
)}
|
||||
{embeddingResults.map((item) => {
|
||||
const isSelected = selectedEntityId === item.id;
|
||||
return (
|
||||
<Badge
|
||||
key={item.uri}
|
||||
color={item.color}
|
||||
selected={isSelected}
|
||||
onClick={() => {
|
||||
setSelectedEntityId(isSelected ? null : item.id);
|
||||
setSelectedNode(null);
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10 }}>{item.icon}</span>
|
||||
{item.label}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Response area */}
|
||||
<div style={{ flex: 1, padding: "24px 28px", overflowY: "auto" }}>
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ color: text.hint, fontSize: 13, fontStyle: "italic" }}>
|
||||
Type your question to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{messages.map((msg, idx) => (
|
||||
<MessageBubble key={idx} message={msg} />
|
||||
))}
|
||||
{isSubmitting && (
|
||||
<div style={{
|
||||
padding: "8px 12px",
|
||||
fontSize: 11,
|
||||
color: withGlow(palette.amber, 0.4),
|
||||
fontFamily: "'IBM Plex Mono', monospace"
|
||||
}}>
|
||||
Processing...
|
||||
</div>
|
||||
)}
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graph visualization */}
|
||||
<div style={{ width: selectedNode ? "30%" : "45%", borderLeft: `1px solid ${border.default}`, transition: "width 0.2s" }}>
|
||||
<GraphCanvas
|
||||
entities={entities}
|
||||
relationships={relationships}
|
||||
ontology={ontology}
|
||||
highlightedEntities={highlightedEntities}
|
||||
onNodeClick={(node) => {
|
||||
setSelectedNode(selectedNode?.id === node.id ? null : node);
|
||||
setSelectedEntityId(null);
|
||||
}}
|
||||
activeFilter={null}
|
||||
/>
|
||||
</div>
|
||||
{selectedNode && (
|
||||
<NodeDetailPanel
|
||||
node={selectedNode}
|
||||
relationships={relationships}
|
||||
entities={entities}
|
||||
ontology={ontology}
|
||||
propertyLabels={propertyLabels}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
onNodeSelect={(node) => {
|
||||
setSelectedNode(node);
|
||||
setSelectedEntityId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
ai-context/context-graph-demo/src/pages/index.ts
Normal file
5
ai-context/context-graph-demo/src/pages/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { GraphView } from "./GraphView";
|
||||
export { QueryView } from "./QueryView";
|
||||
export { ExplainView } from "./ExplainView";
|
||||
export { DataView } from "./DataView";
|
||||
export { OntologyView } from "./OntologyView";
|
||||
9
ai-context/context-graph-demo/src/state/index.ts
Normal file
9
ai-context/context-graph-demo/src/state/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Main data hook - provides entities, relationships, and ontology
|
||||
export { useGraphData } from "./useGraphData";
|
||||
|
||||
// Schema hook - for OWL ontology schema view
|
||||
export { useOntologySchema } from "./useOntologySchema";
|
||||
|
||||
// Toast notifications
|
||||
export { useToastStore, toast } from "./toastStore";
|
||||
export type { Toast, ToastType } from "./toastStore";
|
||||
52
ai-context/context-graph-demo/src/state/toastStore.ts
Normal file
52
ai-context/context-graph-demo/src/state/toastStore.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
export type ToastType = "success" | "error" | "warning" | "info";
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
persistent?: boolean;
|
||||
}
|
||||
|
||||
interface ToastStore {
|
||||
toasts: Toast[];
|
||||
addToast: (type: ToastType, message: string, persistent?: boolean) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
let toastId = 0;
|
||||
|
||||
export const useToastStore = create<ToastStore>((set) => ({
|
||||
toasts: [],
|
||||
|
||||
addToast: (type, message, persistent = false) => {
|
||||
const id = `toast-${++toastId}`;
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts.slice(-3), { id, type, message, persistent }],
|
||||
}));
|
||||
|
||||
// Auto-dismiss after 6 seconds unless explicitly persistent
|
||||
if (!persistent) {
|
||||
setTimeout(() => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}));
|
||||
}, 6000);
|
||||
}
|
||||
},
|
||||
|
||||
removeToast: (id) => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper functions for easy access outside React
|
||||
export const toast = {
|
||||
success: (message: string) => useToastStore.getState().addToast("success", message),
|
||||
error: (message: string) => useToastStore.getState().addToast("error", message),
|
||||
warning: (message: string) => useToastStore.getState().addToast("warning", message),
|
||||
info: (message: string) => useToastStore.getState().addToast("info", message),
|
||||
};
|
||||
243
ai-context/context-graph-demo/src/state/useGraphData.ts
Normal file
243
ai-context/context-graph-demo/src/state/useGraphData.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useSocket } from "@trustgraph/react-provider";
|
||||
import type { Triple } from "@trustgraph/react-state";
|
||||
import type { Entity, Relationship, DomainKey, OntologyType } from "../types";
|
||||
import { COLLECTION } from "../config";
|
||||
import { domainColors } from "../theme";
|
||||
|
||||
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
|
||||
const RDFS_COMMENT = "http://www.w3.org/2000/01/rdf-schema#comment";
|
||||
const OWL_CLASS = "http://www.w3.org/2002/07/owl#Class";
|
||||
const OWL_DATATYPE_PROPERTY = "http://www.w3.org/2002/07/owl#DatatypeProperty";
|
||||
const OWL_OBJECT_PROPERTY = "http://www.w3.org/2002/07/owl#ObjectProperty";
|
||||
|
||||
// Helper to extract value from a Term
|
||||
function getTermValue(term: { t: string; i?: string; v?: string }): string {
|
||||
if (term.t === "i") return term.i || "";
|
||||
if (term.t === "l") return term.v || "";
|
||||
return "";
|
||||
}
|
||||
|
||||
// Helper to create a short ID from a URI
|
||||
function uriToId(uri: string): string {
|
||||
const hashIndex = uri.lastIndexOf("#");
|
||||
const slashIndex = uri.lastIndexOf("/");
|
||||
const index = Math.max(hashIndex, slashIndex);
|
||||
return index >= 0 ? uri.substring(index + 1) : uri;
|
||||
}
|
||||
|
||||
// Helper to get icon for a class (placeholder for now)
|
||||
function getClassIcon(_classUri: string): string {
|
||||
return "●";
|
||||
}
|
||||
|
||||
// Helper to extract predicate name from URI
|
||||
function predicateToName(uri: string): string {
|
||||
const hashIndex = uri.lastIndexOf("#");
|
||||
const slashIndex = uri.lastIndexOf("/");
|
||||
const index = Math.max(hashIndex, slashIndex);
|
||||
const name = index >= 0 ? uri.substring(index + 1) : uri;
|
||||
return name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
|
||||
}
|
||||
|
||||
export function useGraphData(domain?: DomainKey) {
|
||||
const socket = useSocket();
|
||||
const [triples, setTriples] = useState<Triple[] | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setIsError(false);
|
||||
setError(null);
|
||||
|
||||
const api = socket.flow("default");
|
||||
const result = await api.triplesQuery(
|
||||
undefined, undefined, undefined,
|
||||
10000, COLLECTION, "",
|
||||
);
|
||||
|
||||
if (!cancelled) {
|
||||
setTriples(result);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setIsError(true);
|
||||
setError(err instanceof Error ? err : new Error(String(err)));
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [socket]);
|
||||
|
||||
// Process all data from the query
|
||||
const { entities, relationships, ontology, propertyLabels } = useMemo(() => {
|
||||
if (isLoading || !triples) {
|
||||
return { entities: [], relationships: [], ontology: undefined, propertyLabels: {} };
|
||||
}
|
||||
|
||||
// First pass: collect all labels, comments, and find OWL classes and properties
|
||||
const allLabels = new Map<string, string>();
|
||||
const allComments = new Map<string, string>();
|
||||
const owlClasses = new Set<string>();
|
||||
const propertyUris = new Set<string>();
|
||||
|
||||
for (const triple of triples) {
|
||||
const subjectUri = getTermValue(triple.s);
|
||||
const predicate = getTermValue(triple.p);
|
||||
const objectUri = getTermValue(triple.o);
|
||||
|
||||
if (predicate === RDFS_LABEL) {
|
||||
allLabels.set(subjectUri, getTermValue(triple.o));
|
||||
} else if (predicate === RDFS_COMMENT) {
|
||||
allComments.set(subjectUri, getTermValue(triple.o));
|
||||
} else if (predicate === RDF_TYPE) {
|
||||
if (objectUri === OWL_CLASS) {
|
||||
owlClasses.add(subjectUri);
|
||||
} else if (objectUri === OWL_DATATYPE_PROPERTY || objectUri === OWL_OBJECT_PROPERTY) {
|
||||
propertyUris.add(subjectUri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build property labels map: local name -> label
|
||||
const propertyLabels: Record<string, string> = {};
|
||||
for (const propUri of propertyUris) {
|
||||
const localName = uriToId(propUri);
|
||||
const label = allLabels.get(propUri);
|
||||
if (label) {
|
||||
propertyLabels[localName] = label;
|
||||
}
|
||||
}
|
||||
|
||||
// Build class config dynamically from discovered OWL classes
|
||||
const classConfig = new Map<string, { domain: DomainKey; color: string; glow: string; icon: string; label: string; description: string }>();
|
||||
let colorIndex = 0;
|
||||
for (const classUri of owlClasses) {
|
||||
const localName = uriToId(classUri).toLowerCase();
|
||||
const palette = domainColors[colorIndex % domainColors.length];
|
||||
classConfig.set(classUri, {
|
||||
domain: localName,
|
||||
color: palette.color,
|
||||
glow: palette.glow,
|
||||
icon: getClassIcon(classUri),
|
||||
label: allLabels.get(classUri) || uriToId(classUri),
|
||||
description: allComments.get(classUri) || "",
|
||||
});
|
||||
colorIndex++;
|
||||
}
|
||||
|
||||
// Second pass: find entities (instances of OWL classes) and their properties
|
||||
const entityMap = new Map<string, Entity>();
|
||||
const entityProps = new Map<string, Record<string, string | number>>();
|
||||
|
||||
// Collect entity properties first
|
||||
for (const triple of triples) {
|
||||
const subjectUri = getTermValue(triple.s);
|
||||
const predicate = getTermValue(triple.p);
|
||||
const value = getTermValue(triple.o);
|
||||
|
||||
// Skip schema-level predicates and URIs as values
|
||||
if (predicate !== RDF_TYPE && predicate !== RDFS_LABEL && predicate !== RDFS_COMMENT &&
|
||||
value && !value.startsWith("http")) {
|
||||
if (!entityProps.has(subjectUri)) {
|
||||
entityProps.set(subjectUri, {});
|
||||
}
|
||||
const propName = uriToId(predicate);
|
||||
entityProps.get(subjectUri)![propName] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Find entities by type (instances of OWL classes)
|
||||
for (const triple of triples) {
|
||||
const subjectUri = getTermValue(triple.s);
|
||||
const predicate = getTermValue(triple.p);
|
||||
const objectUri = getTermValue(triple.o);
|
||||
|
||||
if (predicate === RDF_TYPE && classConfig.has(objectUri)) {
|
||||
const config = classConfig.get(objectUri)!;
|
||||
if (domain && config.domain !== domain) continue;
|
||||
|
||||
const entityId = uriToId(subjectUri);
|
||||
entityMap.set(subjectUri, {
|
||||
id: entityId,
|
||||
uri: subjectUri,
|
||||
label: allLabels.get(subjectUri) || entityId,
|
||||
props: entityProps.get(subjectUri) || {},
|
||||
domain: config.domain,
|
||||
color: config.color,
|
||||
glow: config.glow,
|
||||
icon: config.icon,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find relationships: triples where both subject and object are known entities
|
||||
const relationships: Relationship[] = [];
|
||||
const entityUris = new Set(entityMap.keys());
|
||||
|
||||
for (const triple of triples) {
|
||||
const subjectUri = getTermValue(triple.s);
|
||||
const predicate = getTermValue(triple.p);
|
||||
const objectUri = getTermValue(triple.o);
|
||||
|
||||
// Skip rdf:type and rdfs:label
|
||||
if (predicate === RDF_TYPE || predicate === RDFS_LABEL) continue;
|
||||
|
||||
// If both subject and object are entities, it's a relationship
|
||||
if (entityUris.has(subjectUri) && entityUris.has(objectUri)) {
|
||||
const fromEntity = entityMap.get(subjectUri)!;
|
||||
const toEntity = entityMap.get(objectUri)!;
|
||||
|
||||
relationships.push({
|
||||
from: fromEntity.id,
|
||||
to: toEntity.id,
|
||||
predicate: predicateToName(predicate),
|
||||
domain: [fromEntity.domain, toEntity.domain],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const entities = Array.from(entityMap.values());
|
||||
|
||||
// Build ontology metadata dynamically from discovered classes
|
||||
const ontology: OntologyType = {};
|
||||
for (const [, config] of classConfig) {
|
||||
ontology[config.domain] = {
|
||||
label: config.label,
|
||||
color: config.color,
|
||||
glow: config.glow,
|
||||
icon: config.icon,
|
||||
description: config.description,
|
||||
properties: [],
|
||||
subclasses: entities.filter(e => e.domain === config.domain).map(e => ({
|
||||
id: e.id,
|
||||
uri: e.uri,
|
||||
label: e.label,
|
||||
props: e.props,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return { entities, relationships, ontology, propertyLabels };
|
||||
}, [isLoading, triples, domain]);
|
||||
|
||||
return {
|
||||
entities,
|
||||
relationships,
|
||||
ontology,
|
||||
propertyLabels,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
};
|
||||
}
|
||||
178
ai-context/context-graph-demo/src/state/useOntologySchema.ts
Normal file
178
ai-context/context-graph-demo/src/state/useOntologySchema.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { useMemo } from "react";
|
||||
import { useTriples } from "@trustgraph/react-state";
|
||||
import { COLLECTION } from "../config";
|
||||
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
|
||||
const RDFS_DOMAIN = "http://www.w3.org/2000/01/rdf-schema#domain";
|
||||
const RDFS_RANGE = "http://www.w3.org/2000/01/rdf-schema#range";
|
||||
const RDFS_COMMENT = "http://www.w3.org/2000/01/rdf-schema#comment";
|
||||
const OWL_CLASS = "http://www.w3.org/2002/07/owl#Class";
|
||||
const OWL_OBJECT_PROPERTY = "http://www.w3.org/2002/07/owl#ObjectProperty";
|
||||
const OWL_DATATYPE_PROPERTY = "http://www.w3.org/2002/07/owl#DatatypeProperty";
|
||||
|
||||
// Helper to extract value from a Term
|
||||
function getTermValue(term: { t: string; i?: string; v?: string }): string {
|
||||
if (term.t === "i") return term.i || "";
|
||||
if (term.t === "l") return term.v || "";
|
||||
return "";
|
||||
}
|
||||
|
||||
// Helper to get local name from URI
|
||||
function getLocalName(uri: string): string {
|
||||
const hashIndex = uri.lastIndexOf("#");
|
||||
const slashIndex = uri.lastIndexOf("/");
|
||||
const index = Math.max(hashIndex, slashIndex);
|
||||
return index >= 0 ? uri.substring(index + 1) : uri;
|
||||
}
|
||||
|
||||
export interface OntologyClass {
|
||||
uri: string;
|
||||
label: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface OntologyProperty {
|
||||
uri: string;
|
||||
label: string;
|
||||
domain?: string;
|
||||
range?: string;
|
||||
}
|
||||
|
||||
export interface OntologySchema {
|
||||
classes: OntologyClass[];
|
||||
objectProperties: OntologyProperty[];
|
||||
datatypeProperties: OntologyProperty[];
|
||||
// Sets for quick lookup
|
||||
objectPropertyUris: Set<string>;
|
||||
datatypePropertyUris: Set<string>;
|
||||
}
|
||||
|
||||
export function useOntologySchema() {
|
||||
// Query for classes
|
||||
const classTriples = useTriples({
|
||||
p: { t: "i", i: RDF_TYPE },
|
||||
o: { t: "i", i: OWL_CLASS },
|
||||
limit: 100,
|
||||
collection: COLLECTION,
|
||||
});
|
||||
|
||||
// Query for object properties
|
||||
const objectPropertyTriples = useTriples({
|
||||
p: { t: "i", i: RDF_TYPE },
|
||||
o: { t: "i", i: OWL_OBJECT_PROPERTY },
|
||||
limit: 100,
|
||||
collection: COLLECTION,
|
||||
});
|
||||
|
||||
// Query for datatype properties
|
||||
const datatypePropertyTriples = useTriples({
|
||||
p: { t: "i", i: RDF_TYPE },
|
||||
o: { t: "i", i: OWL_DATATYPE_PROPERTY },
|
||||
limit: 100,
|
||||
collection: COLLECTION,
|
||||
});
|
||||
|
||||
// Query for all triples to get labels, domains, ranges
|
||||
const allTriples = useTriples({
|
||||
limit: 1000,
|
||||
collection: COLLECTION,
|
||||
});
|
||||
|
||||
const isLoading = classTriples.isLoading || objectPropertyTriples.isLoading ||
|
||||
datatypePropertyTriples.isLoading || allTriples.isLoading;
|
||||
const isError = classTriples.isError || objectPropertyTriples.isError ||
|
||||
datatypePropertyTriples.isError || allTriples.isError;
|
||||
const error = classTriples.error || objectPropertyTriples.error ||
|
||||
datatypePropertyTriples.error || allTriples.error;
|
||||
|
||||
const schema = useMemo((): OntologySchema | undefined => {
|
||||
if (isLoading) return undefined;
|
||||
|
||||
// Build a map of URI -> metadata from all triples
|
||||
const metadata = new Map<string, { label?: string; domain?: string; range?: string; comment?: string }>();
|
||||
|
||||
for (const triple of allTriples.triples || []) {
|
||||
const subject = getTermValue(triple.s);
|
||||
const predicate = getTermValue(triple.p);
|
||||
const value = getTermValue(triple.o);
|
||||
|
||||
if (!metadata.has(subject)) {
|
||||
metadata.set(subject, {});
|
||||
}
|
||||
const meta = metadata.get(subject)!;
|
||||
|
||||
if (predicate === RDFS_LABEL) {
|
||||
meta.label = value;
|
||||
} else if (predicate === RDFS_DOMAIN) {
|
||||
meta.domain = value;
|
||||
} else if (predicate === RDFS_RANGE) {
|
||||
meta.range = value;
|
||||
} else if (predicate === RDFS_COMMENT) {
|
||||
meta.comment = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Build classes list
|
||||
const classes: OntologyClass[] = [];
|
||||
for (const triple of classTriples.triples || []) {
|
||||
const uri = getTermValue(triple.s);
|
||||
const meta = metadata.get(uri) || {};
|
||||
classes.push({
|
||||
uri,
|
||||
label: meta.label || getLocalName(uri),
|
||||
comment: meta.comment,
|
||||
});
|
||||
}
|
||||
|
||||
// Build object properties list
|
||||
const objectProperties: OntologyProperty[] = [];
|
||||
const objectPropertyUris = new Set<string>();
|
||||
for (const triple of objectPropertyTriples.triples || []) {
|
||||
const uri = getTermValue(triple.s);
|
||||
const meta = metadata.get(uri) || {};
|
||||
objectPropertyUris.add(uri);
|
||||
objectProperties.push({
|
||||
uri,
|
||||
label: meta.label || getLocalName(uri),
|
||||
domain: meta.domain,
|
||||
range: meta.range,
|
||||
});
|
||||
}
|
||||
|
||||
// Build datatype properties list
|
||||
const datatypeProperties: OntologyProperty[] = [];
|
||||
const datatypePropertyUris = new Set<string>();
|
||||
for (const triple of datatypePropertyTriples.triples || []) {
|
||||
const uri = getTermValue(triple.s);
|
||||
const meta = metadata.get(uri) || {};
|
||||
datatypePropertyUris.add(uri);
|
||||
datatypeProperties.push({
|
||||
uri,
|
||||
label: meta.label || getLocalName(uri),
|
||||
domain: meta.domain,
|
||||
range: meta.range,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
classes,
|
||||
objectProperties,
|
||||
datatypeProperties,
|
||||
objectPropertyUris,
|
||||
datatypePropertyUris,
|
||||
};
|
||||
}, [
|
||||
isLoading,
|
||||
classTriples.triples,
|
||||
objectPropertyTriples.triples,
|
||||
datatypePropertyTriples.triples,
|
||||
allTriples.triples,
|
||||
]);
|
||||
|
||||
return {
|
||||
schema,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
};
|
||||
}
|
||||
72
ai-context/context-graph-demo/src/theme/colors.ts
Normal file
72
ai-context/context-graph-demo/src/theme/colors.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// Primary palette (migrated from useGraphData.ts)
|
||||
export const palette = {
|
||||
emerald: "#6EE7B7",
|
||||
pink: "#F9A8D4",
|
||||
blue: "#93C5FD",
|
||||
amber: "#FCD34D",
|
||||
purple: "#C4B5FD",
|
||||
rose: "#FDA4AF",
|
||||
cyan: "#67E8F9",
|
||||
red: "#FCA5A5",
|
||||
orange: "#F97316",
|
||||
};
|
||||
|
||||
// Semantic colors
|
||||
export const semantic = {
|
||||
success: palette.emerald,
|
||||
error: "#f66",
|
||||
warning: palette.orange,
|
||||
info: palette.blue,
|
||||
thinking: palette.blue,
|
||||
observation: palette.purple,
|
||||
answer: palette.emerald,
|
||||
user: palette.amber,
|
||||
};
|
||||
|
||||
// Text colors (dark theme)
|
||||
export const text = {
|
||||
primary: "#ddd",
|
||||
secondary: "#bbb",
|
||||
muted: "#aaa",
|
||||
subtle: "#888",
|
||||
faint: "#666",
|
||||
disabled: "#555",
|
||||
hint: "#444",
|
||||
};
|
||||
|
||||
// Surface/background colors
|
||||
export const surface = {
|
||||
base: "#0A0A0F",
|
||||
overlay: "rgba(15,15,20,0.95)",
|
||||
overlayLight: "rgba(15,15,20,0.8)",
|
||||
card: "rgba(255,255,255,0.02)",
|
||||
cardHover: "rgba(255,255,255,0.04)",
|
||||
};
|
||||
|
||||
// Border colors
|
||||
export const border = {
|
||||
subtle: "rgba(255,255,255,0.04)",
|
||||
default: "rgba(255,255,255,0.06)",
|
||||
medium: "rgba(255,255,255,0.1)",
|
||||
grid: "rgba(255,255,255,0.015)",
|
||||
};
|
||||
|
||||
// Helper: Generate glow color from hex
|
||||
export function withGlow(hex: string, opacity = 0.4): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r},${g},${b},${opacity})`;
|
||||
}
|
||||
|
||||
// Domain color palette (array for cycling)
|
||||
export const domainColors = [
|
||||
{ color: palette.emerald, glow: withGlow(palette.emerald) },
|
||||
{ color: palette.pink, glow: withGlow(palette.pink) },
|
||||
{ color: palette.blue, glow: withGlow(palette.blue) },
|
||||
{ color: palette.amber, glow: withGlow(palette.amber) },
|
||||
{ color: palette.purple, glow: withGlow(palette.purple) },
|
||||
{ color: palette.rose, glow: withGlow(palette.rose) },
|
||||
{ color: palette.cyan, glow: withGlow(palette.cyan) },
|
||||
{ color: palette.red, glow: withGlow(palette.red) },
|
||||
];
|
||||
1
ai-context/context-graph-demo/src/theme/index.ts
Normal file
1
ai-context/context-graph-demo/src/theme/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./colors";
|
||||
65
ai-context/context-graph-demo/src/types/index.ts
Normal file
65
ai-context/context-graph-demo/src/types/index.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// ── Domain Types ─────────────────────────────────────────────────
|
||||
export type DomainKey = string;
|
||||
|
||||
export interface EntityProps {
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
export interface Subclass {
|
||||
id: string;
|
||||
uri?: string;
|
||||
label: string;
|
||||
props: EntityProps;
|
||||
}
|
||||
|
||||
export interface OntologyDomain {
|
||||
label: string;
|
||||
color: string;
|
||||
glow: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
properties: string[];
|
||||
subclasses: Subclass[];
|
||||
}
|
||||
|
||||
export type OntologyType = Record<DomainKey, OntologyDomain>;
|
||||
|
||||
// ── Relationship Types ───────────────────────────────────────────
|
||||
export interface Relationship {
|
||||
from: string;
|
||||
to: string;
|
||||
predicate: string;
|
||||
strength?: number;
|
||||
domain: [DomainKey, DomainKey];
|
||||
}
|
||||
|
||||
// ── Query Types ──────────────────────────────────────────────────
|
||||
export interface DemoQuery {
|
||||
q: string;
|
||||
thinking: string[];
|
||||
answer: string;
|
||||
entities: string[];
|
||||
triples: number;
|
||||
}
|
||||
|
||||
// ── Entity Types ─────────────────────────────────────────────────
|
||||
export interface Entity extends Subclass {
|
||||
domain: DomainKey;
|
||||
color: string;
|
||||
glow: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface GraphNode extends Entity {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
r: number;
|
||||
}
|
||||
|
||||
// ── UI State Types ───────────────────────────────────────────────
|
||||
export type TabKey = "graph" | "query" | "explain" | "ontology" | "data";
|
||||
export type QueryPhase = "idle" | "thinking" | "answering" | "done";
|
||||
1
ai-context/context-graph-demo/src/utils/index.ts
Normal file
1
ai-context/context-graph-demo/src/utils/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { getLocalName } from "./uri";
|
||||
9
ai-context/context-graph-demo/src/utils/uri.ts
Normal file
9
ai-context/context-graph-demo/src/utils/uri.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Extract the local name from a URI by taking the fragment after # or the last path segment
|
||||
*/
|
||||
export function getLocalName(uri: string): string {
|
||||
const hashIndex = uri.lastIndexOf("#");
|
||||
const slashIndex = uri.lastIndexOf("/");
|
||||
const index = Math.max(hashIndex, slashIndex);
|
||||
return index >= 0 ? uri.substring(index + 1) : uri;
|
||||
}
|
||||
1
ai-context/context-graph-demo/src/vite-env.d.ts
vendored
Normal file
1
ai-context/context-graph-demo/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
Loading…
Add table
Add a link
Reference in a new issue