Merge pull request #557 from Hawksight-AI/feat/ui-redesign-semantica-explorer

# feat: Redesign all workspace UIs with consistent design system + bug fixes
This commit is contained in:
Mohd Kaif
2026-05-16 17:11:55 +05:30
committed by GitHub
14 changed files with 2008 additions and 1830 deletions
+1019 -503
View File
File diff suppressed because it is too large Load Diff
@@ -1,77 +1,25 @@
/**
* src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx
*/
import { useState, useEffect, useMemo } from "react";
import { Scale, Search } from "lucide-react";
const THEME_CSS = `
.glass-panel {
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
border: 1px solid rgba(88,166,255,0.2);
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
}
@keyframes skeleton-shimmer {
0% { opacity: 0.45; }
50% { opacity: 0.85; }
100% { opacity: 0.45; }
}
.skeleton-item {
border-radius: 8px;
background: rgba(255,255,255,0.05);
animation: skeleton-shimmer 1.4s ease-in-out infinite;
}
`;
import { useState, useEffect, useMemo, useRef } from "react";
import { Scale, Search, ArrowRight, Info } from "lucide-react";
type OutcomeKind = "approved" | "rejected" | "deferred" | "pending" | string;
function outcomeStyle(outcome: string): { color: string; bg: string; border: string } {
const lower = (outcome ?? "").toLowerCase();
if (lower.includes("approv") || lower.includes("accept"))
return { color: "#4cc38a", bg: "rgba(76,195,138,0.12)", border: "rgba(76,195,138,0.28)" };
if (lower.includes("reject") || lower.includes("denied") || lower.includes("fail"))
return { color: "#ff7b72", bg: "rgba(255,123,114,0.12)", border: "rgba(255,123,114,0.28)" };
if (lower.includes("defer") || lower.includes("pending") || lower.includes("review"))
return { color: "#f2b66d", bg: "rgba(242,182,109,0.12)", border: "rgba(242,182,109,0.28)" };
return { color: "#8fa8c6", bg: "rgba(143,168,198,0.08)", border: "rgba(143,168,198,0.18)" };
function outcomeColor(outcome: string) {
const o = (outcome ?? "").toLowerCase();
if (o.includes("approv") || o.includes("accept")) return { color: "#6ee7b7", bg: "var(--ws-green-soft)", border: "rgba(76,195,138,0.3)" };
if (o.includes("reject") || o.includes("denied") || o.includes("fail")) return { color: "#fca5a5", bg: "var(--ws-red-soft)", border: "rgba(255,123,114,0.3)" };
if (o.includes("defer") || o.includes("pending") || o.includes("review")) return { color: "#fbbf24", bg: "var(--ws-amber-soft)", border: "rgba(242,182,109,0.3)" };
return { color: "var(--ws-text-muted)", bg: "rgba(255,255,255,0.04)", border: "rgba(255,255,255,0.1)" };
}
function OutcomeBadge({ outcome }: { outcome: OutcomeKind }) {
const style = outcomeStyle(outcome);
const c = outcomeColor(outcome);
return (
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: 999,
fontSize: 10,
fontWeight: 800,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: style.color,
background: style.bg,
border: `1px solid ${style.border}`,
}}
>
<span style={{ display: "inline-block", padding: "2px 8px", borderRadius: 999, fontSize: 10, fontWeight: 800, letterSpacing: "0.08em", textTransform: "uppercase", color: c.color, background: c.bg, border: `1px solid ${c.border}` }}>
{outcome || "unknown"}
</span>
);
}
function SkeletonList() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{[1, 2, 3, 4].map((i) => (
<div key={i} className="skeleton-item" style={{ height: 62 }} />
))}
</div>
);
}
/* ─── Causal Flow Diagram ──────────────────────────────────────────── */
interface ChainStep {
id: string;
relationship: string;
@@ -80,255 +28,178 @@ interface ChainStep {
[key: string]: unknown;
}
function RelationshipPill({ label }: { label: string }) {
const NODE_ACCENTS = ["#4aa3ff","#4cc38a","#f2b66d","#c084fc","#ff7b72","#38bdf8","#a78bfa"];
function ChainNode({ step, index }: { step: ChainStep; index: number }) {
const accent = NODE_ACCENTS[index % NODE_ACCENTS.length];
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 0, position: "relative", margin: "0 auto" }}>
{/* Connector line top */}
<div style={{ width: 2, height: 12, background: "rgba(88,166,255,0.25)" }} />
{/* Pill */}
<div
style={{
padding: "3px 10px",
borderRadius: 999,
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "#79c0ff",
background: "rgba(88,166,255,0.1)",
border: "1px solid rgba(88,166,255,0.22)",
whiteSpace: "nowrap",
maxWidth: 260,
overflow: "hidden",
textOverflow: "ellipsis",
}}
title={label}
>
{label}
</div>
{/* Connector line bottom + arrow */}
<div style={{ width: 2, height: 10, background: "rgba(88,166,255,0.25)" }} />
<div style={{ width: 0, height: 0, borderLeft: "5px solid transparent", borderRight: "5px solid transparent", borderTop: "6px solid rgba(88,166,255,0.4)" }} />
<div style={{ padding: "14px 16px", borderRadius: "var(--ws-radius)", background: "var(--ws-surface)", border: `1px solid ${accent}28`, borderLeft: `3px solid ${accent}`, position: "relative" }}>
{step.type && (
<div style={{ fontFamily: "monospace", fontSize: 10, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: accent, marginBottom: 5 }}>
{step.type}
</div>
)}
<div style={{ color: "var(--ws-text)", fontSize: 13, fontWeight: 600 }}>{step.content || step.id}</div>
{step.id && step.id !== step.content && (
<div style={{ fontFamily: "monospace", fontSize: 10, color: "var(--ws-text-dim)", marginTop: 3 }}>{step.id}</div>
)}
</div>
);
}
function ChainNodeCard({ step, index }: { step: ChainStep; index: number }) {
const COLORS = ["#3E79F2", "#149287", "#2F9F61", "#555FD6", "#8A56D8", "#B65473", "#C9922E", "#4aa3ff"];
const color = COLORS[index % COLORS.length];
function RelEdge({ label }: { label: string }) {
return (
<div
style={{
position: "relative",
padding: "14px 16px",
borderRadius: 12,
background: "linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.5))",
border: `1px solid ${color}33`,
boxShadow: `0 0 0 1px ${color}11, inset 0 1px 0 rgba(255,255,255,0.04)`,
borderLeft: `3px solid ${color}`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
<span
style={{
width: 8, height: 8, borderRadius: "50%",
background: color,
boxShadow: `0 0 8px ${color}`,
flexShrink: 0,
}}
/>
{step.type ? (
<span
style={{
fontSize: 10, fontWeight: 700, letterSpacing: "0.06em",
textTransform: "uppercase", color,
}}
>
{step.type}
</span>
) : null}
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 0, padding: "2px 0" }}>
<div style={{ width: 2, height: 10, background: "var(--ws-border)" }} />
<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "3px 10px", borderRadius: 999, background: "var(--ws-accent-soft)", border: "1px solid var(--ws-border-strong)", maxWidth: 260 }}>
<ArrowRight size={10} color="var(--ws-accent)" />
<span style={{ fontSize: 10, fontWeight: 700, color: "var(--ws-accent)", letterSpacing: "0.06em", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
</div>
<div style={{ color: "#e6edf3", fontSize: 14, fontWeight: 600 }}>
{step.content || step.id}
</div>
{step.id && step.id !== step.content ? (
<div style={{ color: "#6a7f97", fontSize: 11, fontFamily: "monospace", marginTop: 3 }}>{step.id}</div>
) : null}
<div style={{ width: 2, height: 10, background: "var(--ws-border)" }} />
</div>
);
}
function CausalFlowDiagram({ chain, loading }: { chain: ChainStep[]; loading: boolean }) {
if (loading) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{[1, 2, 3].map((i) => (
<div key={i} className="skeleton-item" style={{ height: 68 }} />
))}
</div>
);
}
if (chain.length === 0) {
return (
<div style={{ textAlign: "center", padding: "40px 24px", color: "#8b949e", fontSize: 13 }}>
No causal chain steps found for this decision.
</div>
);
}
function CausalFlow({ chain, loading }: { chain: ChainStep[]; loading: boolean }) {
if (loading) return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{[1,2,3].map((i) => <div key={i} className="ws-skeleton" style={{ height: 68 }} />)}
</div>
);
if (!chain.length) return (
<div className="ws-empty">
<div className="ws-empty-icon"><Info size={28} /></div>
<div className="ws-empty-title">No chain steps</div>
<div className="ws-empty-body">No causal chain steps were found for this decision.</div>
</div>
);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "stretch" }}>
{chain.map((step, index) => (
<div key={`${step.id}-${index}`} style={{ display: "flex", flexDirection: "column" }}>
<ChainNodeCard step={step} index={index} />
{index < chain.length - 1 ? (
<RelationshipPill label={chain[index + 1]?.relationship || "→"} />
) : null}
<div style={{ display: "flex", flexDirection: "column" }}>
{chain.map((step, i) => (
<div key={`${step.id}-${i}`}>
<ChainNode step={step} index={i} />
{i < chain.length - 1 && <RelEdge label={chain[i + 1]?.relationship || "→"} />}
</div>
))}
</div>
);
}
/* ─── Main Workspace ──────────────────────────────────────────────── */
export function DecisionWorkspace() {
const [decisions, setDecisions] = useState<any[]>([]);
const [selectedDecision, setSelectedDecision] = useState<any | null>(null);
const [decisions, setDecisions] = useState<{ decision_id: string; category?: string; outcome?: string }[]>([]);
const [selected, setSelected] = useState<{ decision_id: string; category?: string; outcome?: string } | null>(null);
const [chain, setChain] = useState<ChainStep[]>([]);
const [loading, setLoading] = useState(false);
const [chainLoading, setChainLoading] = useState(false);
const [listLoading, setListLoading] = useState(true);
const [filterQuery, setFilterQuery] = useState("");
const [filter, setFilter] = useState("");
// Tracks the active chain request so stale responses from rapid selections are ignored.
const chainCtrlRef = useRef<AbortController | null>(null);
useEffect(() => {
const controller = new AbortController();
const ctrl = new AbortController();
setListLoading(true);
fetch("/api/decisions", { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`Failed to load decisions: ${res.status}`);
return res.json();
})
fetch("/api/decisions", { signal: ctrl.signal })
.then((r) => r.ok ? r.json() : Promise.reject(r.status))
.then((data) => {
if (ctrl.signal.aborted) return;
setDecisions(data);
if (data.length > 0) void handleSelectDecision(data[0]);
if (data.length > 0) void loadChain(data[0]);
})
.catch((err) => { if (err.name !== "AbortError") console.error(err); })
.finally(() => setListLoading(false));
return () => controller.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
.finally(() => {
if (!ctrl.signal.aborted) setListLoading(false);
});
return () => ctrl.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filteredDecisions = useMemo(() => {
if (!filterQuery.trim()) return decisions;
const q = filterQuery.toLowerCase();
return decisions.filter(
(d) =>
String(d.decision_id ?? "").toLowerCase().includes(q) ||
String(d.category ?? "").toLowerCase().includes(q) ||
String(d.outcome ?? "").toLowerCase().includes(q),
);
}, [decisions, filterQuery]);
// Cancel any in-flight chain request when the workspace unmounts.
useEffect(() => () => { chainCtrlRef.current?.abort(); }, []);
const handleSelectDecision = async (d: any) => {
setSelectedDecision(d);
setLoading(true);
async function loadChain(d: { decision_id: string; category?: string; outcome?: string }) {
chainCtrlRef.current?.abort();
const ctrl = new AbortController();
chainCtrlRef.current = ctrl;
setSelected(d);
setChainLoading(true);
setChain([]);
const controller = new AbortController();
try {
const res = await fetch(`/api/decisions/${encodeURIComponent(d.decision_id)}/chain`, { signal: controller.signal });
if (!res.ok) throw new Error(`Failed to load chain: ${res.status}`);
const res = await fetch(`/api/decisions/${encodeURIComponent(d.decision_id)}/chain`, { signal: ctrl.signal });
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
setChain(data.chain || []);
if (!ctrl.signal.aborted) setChain(data.chain || []);
} catch (e) {
if ((e as DOMException).name !== "AbortError") console.error(e);
if (e instanceof Error && e.name !== "AbortError") console.error(e);
} finally {
setLoading(false);
if (!ctrl.signal.aborted) setChainLoading(false);
}
return () => controller.abort();
};
}
const filtered = useMemo(() => {
const q = filter.toLowerCase().trim();
if (!q) return decisions;
return decisions.filter((d) =>
d.decision_id.toLowerCase().includes(q) ||
(d.category ?? "").toLowerCase().includes(q) ||
(d.outcome ?? "").toLowerCase().includes(q)
);
}, [decisions, filter]);
return (
<div style={{ display: "flex", width: "100%", height: "100%", background: "#0d1117", overflow: "hidden" }}>
<style>{THEME_CSS}</style>
{/* Left Column — Decision List */}
<div
className="glass-panel"
style={{
width: 300,
display: "flex",
flexDirection: "column",
borderRadius: 0,
border: "none",
borderRight: "1px solid rgba(88,166,255,0.16)",
}}
>
{/* List header */}
<div style={{ padding: "20px 20px 14px", borderBottom: "1px solid rgba(255,255,255,0.06)", flexShrink: 0 }}>
<div className="ws-page" style={{ flexDirection: "row" }}>
{/* ── Sidebar list ── */}
<div className="ws-sidebar" style={{ width: 290 }}>
<div className="ws-sidebar-header">
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<Scale size={16} color="#4aa3ff" />
<h2 style={{ color: "#ebf3ff", margin: 0, fontSize: 15, fontWeight: 700 }}>Decisions</h2>
{decisions.length > 0 ? (
<span style={{ color: "#6a7f97", fontSize: 11, marginLeft: "auto" }}>{decisions.length}</span>
) : null}
<div style={{ width: 30, height: 30, borderRadius: 9, background: "var(--ws-accent-soft)", border: "1px solid var(--ws-border-strong)", display: "grid", placeItems: "center", color: "var(--ws-accent)", flexShrink: 0 }}>
<Scale size={15} />
</div>
<div style={{ color: "var(--ws-text)", fontSize: 14, fontWeight: 700 }}>Decisions</div>
{decisions.length > 0 && !listLoading && (
<span className="ws-pill ws-pill--mono" style={{ marginLeft: "auto" }}>{decisions.length}</span>
)}
</div>
{/* Filter input */}
<div style={{ position: "relative" }}>
<Search
size={13}
color="#8b949e"
style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }}
/>
<Search size={12} color="var(--ws-text-dim)" style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }} />
<input
className="ws-input"
type="text"
placeholder="Filter decisions…"
value={filterQuery}
onChange={(e) => setFilterQuery(e.target.value)}
style={filterInputStyle}
placeholder="Filter by ID, category, outcome…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
style={{ paddingLeft: 30, fontSize: 12 }}
/>
</div>
</div>
{/* Decision list */}
<div style={{ flex: 1, overflowY: "auto", padding: "12px 14px" }}>
<div className="ws-sidebar-body">
{listLoading ? (
<SkeletonList />
) : filteredDecisions.length === 0 ? (
<div style={{ color: "#6a7f97", fontSize: 13, textAlign: "center", padding: "32px 12px" }}>
{decisions.length === 0 ? "No decisions available." : "No decisions match your filter."}
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{[1,2,3,4,5].map((i) => <div key={i} className="ws-skeleton" style={{ height: 58 }} />)}
</div>
) : filtered.length === 0 ? (
<div className="ws-empty" style={{ padding: "28px 12px" }}>
<div className="ws-empty-title">{decisions.length === 0 ? "No decisions" : "No matches"}</div>
<div className="ws-empty-body">{decisions.length === 0 ? "No decisions available in the graph." : "Adjust your filter."}</div>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{filteredDecisions.map((d) => {
const isActive = selectedDecision?.decision_id === d.decision_id;
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{filtered.map((d) => {
const active = selected?.decision_id === d.decision_id;
return (
<button
key={d.decision_id}
onClick={() => void handleSelectDecision(d)}
style={{
textAlign: "left",
padding: "10px 12px",
borderRadius: 10,
cursor: "pointer",
background: isActive
? "rgba(74,163,255,0.15)"
: "rgba(255,255,255,0.02)",
border: isActive
? "1px solid rgba(74,163,255,0.32)"
: "1px solid rgba(255,255,255,0.06)",
color: isActive ? "#ffffff" : "#c6d4e3",
transition: "all 160ms ease",
}}
className={`ws-list-item${active ? " ws-list-item--active" : ""}`}
onClick={() => void loadChain(d)}
>
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 4 }}>{d.decision_id}</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
{d.category ? (
<span style={{ fontSize: 11, color: "#8b949e" }}>{d.category}</span>
) : null}
{d.outcome ? <OutcomeBadge outcome={d.outcome} /> : null}
<div style={{ fontWeight: 700, fontSize: 12, marginBottom: 5, color: active ? "#e8f6ff" : "var(--ws-text)" }}>
{d.decision_id}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 5, flexWrap: "wrap" }}>
{d.category && <span style={{ fontSize: 10, color: "var(--ws-text-dim)" }}>{d.category}</span>}
{d.outcome && <OutcomeBadge outcome={d.outcome} />}
</div>
</button>
);
@@ -338,70 +209,48 @@ export function DecisionWorkspace() {
</div>
</div>
{/* Right Column — Decision Detail */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Radial accent */}
<div style={{ position: "absolute", inset: 0, background: "radial-gradient(ellipse at top right, rgba(88,166,255,0.04), transparent 55%)", pointerEvents: "none", zIndex: 0 }} />
{/* ── Detail pane ── */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", position: "relative" }}>
<div style={{ position: "absolute", inset: 0, background: "radial-gradient(ellipse 60% 40% at 70% 20%, rgba(74,163,255,0.04), transparent 55%)", pointerEvents: "none" }} />
{selectedDecision ? (
<div style={{ flex: 1, overflowY: "auto", padding: "28px 32px", position: "relative", zIndex: 1 }}>
{selected ? (
<div className="ws-scroll ws-padded ws-animate-in" style={{ position: "relative", zIndex: 1 }}>
{/* Decision header */}
<div style={{ marginBottom: 28 }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 14, flexWrap: "wrap" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#8b949e", fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.07em", marginBottom: 6 }}>
Decision ID
</div>
<h1 style={{ color: "#ffffff", fontSize: 24, fontWeight: 800, letterSpacing: "-0.03em", margin: "0 0 8px 0", wordBreak: "break-word" }}>
{selectedDecision.decision_id}
</h1>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 12, flexWrap: "wrap", marginBottom: 10 }}>
<div>
<div className="ws-eyebrow" style={{ marginBottom: 6 }}>Decision Record</div>
<h2 className="ws-title">{selected.decision_id}</h2>
</div>
{selectedDecision.outcome ? <OutcomeBadge outcome={selectedDecision.outcome} /> : null}
{selected.outcome && <OutcomeBadge outcome={selected.outcome} />}
</div>
{selectedDecision.category ? (
<div style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "4px 10px", borderRadius: 999, background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.08)", color: "#8b949e", fontSize: 12 }}>
{selectedDecision.category}
</div>
) : null}
{selected.category && (
<span className="ws-pill ws-pill--mono">{selected.category}</span>
)}
</div>
{/* Causal Chain */}
<div className="glass-panel" style={{ padding: 24, borderRadius: 16 }}>
{/* Chain section */}
<div className="ws-card" style={{ padding: 22 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 20 }}>
<div style={{ width: 6, height: 6, borderRadius: "50%", background: "linear-gradient(135deg, #4aa3ff, #f2b66d)", boxShadow: "0 0 10px rgba(74,163,255,0.4)" }} />
<h3 style={{ color: "#e6edf3", margin: 0, fontSize: 14, fontWeight: 700, letterSpacing: "0.02em" }}>
Causal Chain
</h3>
{chain.length > 0 && !loading ? (
<span style={{ color: "#6a7f97", fontSize: 11, marginLeft: "auto" }}>
{chain.length} step{chain.length !== 1 ? "s" : ""}
</span>
) : null}
<div style={{ width: 8, height: 8, borderRadius: 999, background: "linear-gradient(135deg, var(--ws-accent), var(--ws-amber))", boxShadow: "0 0 10px rgba(74,163,255,0.4)" }} />
<div style={{ color: "var(--ws-text)", fontSize: 14, fontWeight: 700 }}>Causal Chain</div>
{chain.length > 0 && !chainLoading && (
<span className="ws-pill ws-pill--accent" style={{ marginLeft: "auto" }}>{chain.length} step{chain.length !== 1 ? "s" : ""}</span>
)}
</div>
<CausalFlowDiagram chain={chain} loading={loading} />
<CausalFlow chain={chain} loading={chainLoading} />
</div>
</div>
) : (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#8b949e", fontSize: 14 }}>
Select a decision to inspect its causal chain.
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", position: "relative", zIndex: 1 }}>
<div className="ws-empty">
<div className="ws-empty-icon"><Scale size={32} /></div>
<div className="ws-empty-title">No decision selected</div>
<div className="ws-empty-body">Select a decision from the list to inspect its causal chain and metadata.</div>
</div>
</div>
)}
</div>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────── */
const filterInputStyle: React.CSSProperties = {
width: "100%",
padding: "7px 10px 7px 30px",
background: "rgba(0,0,0,0.25)",
border: "1px solid rgba(88,166,255,0.16)",
borderRadius: 8,
color: "#c6d4e3",
fontSize: 12,
outline: "none",
boxSizing: "border-box",
};
@@ -1,105 +1,121 @@
/**
* src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx
*/
import { useState } from "react";
import { GitMerge, ArrowRight, CheckCircle2, AlertCircle, Loader2 } from "lucide-react";
import { logEvent } from "../../store/registryStore";
const THEME_CSS = `
.glass-panel {
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
border: 1px solid rgba(88,166,255,0.2);
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
}
`;
interface FieldRow { label: string; primary: string; duplicate: string; differs: boolean }
const MOCK_FIELDS: FieldRow[] = [
{ label: "Name", primary: "Sample Company Inc.", duplicate: "Sample Company", differs: true },
{ label: "Founded", primary: "2004-05-12", duplicate: "2004-05-12", differs: false },
{ label: "Type", primary: "Organization", duplicate: "Organisation", differs: true },
{ label: "Country", primary: "US", duplicate: "US", differs: false },
];
export function DiffMergeWorkspace() {
const [primaryId, setPrimaryId] = useState("n-primary-1");
const [primaryId, setPrimaryId] = useState("n-primary-1");
const [duplicateId, setDuplicateId] = useState("n-dup-2");
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [msg, setMsg] = useState("");
const [msg, setMsg] = useState("");
const handleMerge = async () => {
async function handleMerge() {
setStatus("loading");
setMsg("");
try {
const res = await fetch("/api/enrich/merge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ primary_id: primaryId, duplicate_ids: [duplicateId] })
body: JSON.stringify({ primary_id: primaryId, duplicate_ids: [duplicateId] }),
});
const data = await res.json();
if (data.merged_into) {
setMsg(`Merge success: redirected ${data.edges_updated} edges to ${data.merged_into}`);
logEvent("merge", `Merged ${duplicateId}${data.merged_into} · ${data.edges_updated} edges redirected`, {
primary: data.merged_into,
duplicate: duplicateId,
edgesUpdated: data.edges_updated,
setStatus("success");
setMsg(`Merged${data.merged_into} · ${data.edges_updated ?? 0} edges redirected`);
logEvent("merge", `Merged ${duplicateId}${data.merged_into} · ${data.edges_updated ?? 0} edges redirected`, {
primary: data.merged_into, duplicate: duplicateId, edgesUpdated: data.edges_updated,
});
} else {
setMsg("Merge failed...");
throw new Error(data.detail || "Unexpected response");
}
} catch (err) {
setMsg("Error calling merge endpoint.");
} catch (e: unknown) {
setStatus("error");
setMsg(e instanceof Error ? e.message : "Merge failed");
}
};
}
return (
<div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%", background: "#0d1117", padding: 32, gap: 24, boxSizing: "border-box" }}>
<style>{THEME_CSS}</style>
<div>
<h1 style={{ margin: "0 0 8px 0", color: "#fff" }}>Entity Diff & Merge</h1>
<p style={{ margin: 0, color: "#8b949e" }}>Compare suspected duplicate entities and reconcile them.</p>
</div>
<div style={{ display: "flex", gap: 24, flex: 1 }}>
{/* Primary View */}
<div className="glass-panel" style={{ flex: 1, borderRadius: 12, padding: 24 }}>
<h3 style={{ color: "#58a6ff", margin: "0 0 16px 0", borderBottom: "1px solid rgba(88,166,255,0.2)", paddingBottom: 8 }}>Primary Entity</h3>
<label style={{ display: "block", color: "#c9d1d9", marginBottom: 8, fontSize: 13 }}>Primary Node ID</label>
<input
value={primaryId} onChange={e => setPrimaryId(e.target.value)}
style={{ width: "100%", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(255,255,255,0.1)", color: "#fff", padding: "8px 12px", borderRadius: 6, marginBottom: 24 }}
/>
<div style={{ background: "rgba(0,0,0,0.2)", padding: 16, borderRadius: 6 }}>
<div style={{ color: "#8b949e", fontSize: 12, marginBottom: 4 }}>Name</div>
<div style={{ color: "#fff", fontSize: 14 }}>Sample Company Inc.</div>
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 16, marginBottom: 4 }}>Founded</div>
<div style={{ color: "#fff", fontSize: 14 }}>2004-05-12</div>
<div className="ws-page ws-scroll">
<div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 22, maxWidth: 1000, margin: "0 auto", width: "100%" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<div style={{ width: 42, height: 42, borderRadius: 13, background: "var(--ws-purple-soft)", border: "1px solid rgba(192,132,252,0.3)", display: "grid", placeItems: "center", color: "var(--ws-purple)", flexShrink: 0 }}>
<GitMerge size={20} />
</div>
<div>
<h2 className="ws-title" style={{ fontSize: 18 }}>Entity Diff &amp; Merge</h2>
<div className="ws-body" style={{ marginTop: 2 }}>Compare suspected duplicates side-by-side and reconcile them into a single canonical entity.</div>
</div>
</div>
{/* Duplicate View */}
<div className="glass-panel" style={{ flex: 1, borderRadius: 12, padding: 24 }}>
<h3 style={{ color: "#ff7b72", margin: "0 0 16px 0", borderBottom: "1px solid rgba(255,123,114,0.2)", paddingBottom: 8 }}>Duplicate Entity</h3>
<label style={{ display: "block", color: "#c9d1d9", marginBottom: 8, fontSize: 13 }}>Duplicate Node ID</label>
<input
value={duplicateId} onChange={e => setDuplicateId(e.target.value)}
style={{ width: "100%", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(255,255,255,0.1)", color: "#fff", padding: "8px 12px", borderRadius: 6, marginBottom: 24 }}
/>
<div style={{ background: "rgba(0,0,0,0.2)", padding: 16, borderRadius: 6 }}>
<div style={{ color: "#d2a8ff", fontSize: 12, marginBottom: 4 }}>Name</div>
{/* Amber highlight for differing values */}
<div style={{ color: "#d29922", fontSize: 14, fontWeight: "bold" }}>Sample Company</div>
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 16, marginBottom: 4 }}>Founded</div>
<div style={{ color: "#fff", fontSize: 14 }}>2004-05-12</div>
{/* ID inputs */}
<div style={{ display: "grid", gridTemplateColumns: "1fr auto 1fr", gap: 12, alignItems: "end" }}>
<div>
<label className="ws-label">Primary Node ID (keep)</label>
<input className="ws-input" value={primaryId} onChange={(e) => { setPrimaryId(e.target.value); setStatus("idle"); }} placeholder="e.g. n-primary-1" />
</div>
<div style={{ paddingBottom: 2, color: "var(--ws-text-dim)" }}>
<ArrowRight size={18} />
</div>
<div>
<label className="ws-label">Duplicate Node ID (remove)</label>
<input className="ws-input" value={duplicateId} onChange={(e) => { setDuplicateId(e.target.value); setStatus("idle"); }} placeholder="e.g. n-dup-2" />
</div>
</div>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ color: "#58a6ff" }}>{msg}</div>
<button
onClick={handleMerge}
style={{ background: "#238636", color: "#fff", border: "none", padding: "10px 24px", borderRadius: 6, fontWeight: 600, cursor: "pointer", fontSize: 16 }}
>
Confirm Merge
</button>
</div>
{/* Diff table */}
<div className="ws-card" style={{ padding: 0, overflow: "hidden" }}>
<div style={{ padding: "6px 16px", background: "rgba(242,182,109,0.06)", borderBottom: "1px solid rgba(242,182,109,0.15)", display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 10, fontWeight: 700, color: "var(--ws-amber)", letterSpacing: "0.08em", textTransform: "uppercase" }}>Sample preview</span>
<span style={{ fontSize: 11, color: "var(--ws-text-dim)" }}> field comparison will load from the graph once the backend is connected</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr 1fr", background: "rgba(0,0,0,0.28)", borderBottom: "1px solid var(--ws-border)" }}>
<div style={{ padding: "10px 16px", fontSize: 11, fontWeight: 700, color: "var(--ws-text-dim)", letterSpacing: "0.08em", textTransform: "uppercase" }}>Field</div>
<div style={{ padding: "10px 16px", fontSize: 11, fontWeight: 700, color: "var(--ws-accent)", letterSpacing: "0.08em", textTransform: "uppercase", borderLeft: "1px solid var(--ws-border)" }}>Primary (keep)</div>
<div style={{ padding: "10px 16px", fontSize: 11, fontWeight: 700, color: "#fca5a5", letterSpacing: "0.08em", textTransform: "uppercase", borderLeft: "1px solid var(--ws-border)" }}>Duplicate (remove)</div>
</div>
{MOCK_FIELDS.map((row) => (
<div key={row.label} style={{ display: "grid", gridTemplateColumns: "140px 1fr 1fr", borderBottom: "1px solid rgba(74,163,255,0.06)", background: row.differs ? "rgba(242,182,109,0.03)" : "transparent" }}>
<div style={{ padding: "12px 16px", fontSize: 12, fontWeight: 600, color: "var(--ws-text-dim)" }}>{row.label}</div>
<div style={{ padding: "12px 16px", fontSize: 13, color: "var(--ws-text)", borderLeft: "1px solid var(--ws-border)" }}>{row.primary}</div>
<div style={{ padding: "12px 16px", fontSize: 13, color: row.differs ? "#fbbf24" : "var(--ws-text)", fontWeight: row.differs ? 700 : 400, borderLeft: "1px solid var(--ws-border)" }}>
{row.duplicate}
{row.differs && <span className="ws-pill ws-pill--amber" style={{ marginLeft: 8, fontSize: 9 }}>diff</span>}
</div>
</div>
))}
</div>
{/* Status & action */}
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{status === "success" && (
<div style={{ display: "flex", alignItems: "center", gap: 7, color: "#6ee7b7", fontSize: 13 }}>
<CheckCircle2 size={15} />{msg}
</div>
)}
{status === "error" && (
<div style={{ display: "flex", alignItems: "center", gap: 7, color: "#fca5a5", fontSize: 13 }}>
<AlertCircle size={15} />{msg}
</div>
)}
<button
className="ws-btn ws-btn--primary"
onClick={handleMerge}
disabled={status === "loading" || !primaryId || !duplicateId}
style={{ marginLeft: "auto" }}
>
{status === "loading" ? <><Loader2 size={14} className="ws-spin" />Merging</> : <><GitMerge size={14} />Confirm Merge</>}
</button>
</div>
</div>
</div>
);
}
@@ -1,270 +1,196 @@
/**
* src/workspaces/ImportExportWorkspace/ImportExportWorkspace.tsx
*/
import { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { UploadCloud, Download, FileJson, FileText, CheckCircle2, AlertCircle, Loader2 } from "lucide-react";
import { logEvent } from "../../store/registryStore";
const THEME_CSS = `
.glass-panel {
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
border: 1px solid rgba(88,166,255,0.2);
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
}
.dropzone {
border: 2px dashed rgba(88,166,255,0.4);
border-radius: 12px;
background: rgba(0,0,0,0.2);
transition: all 0.2s ease-in-out;
cursor: pointer;
}
.dropzone:hover, .dropzone.active {
border-color: #58a6ff;
background: rgba(88,166,255,0.05);
}
.btn-primary {
background: #238636;
color: #fff;
border: 1px solid rgba(240,246,252,0.1);
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: #2ea043;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toast {
animation: slideUp 0.3s ease-out forwards;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
`;
interface ToastMessage {
id: number;
type: "success" | "error";
text: string;
}
interface Toast { id: number; type: "success" | "error"; text: string }
export function ImportExportWorkspace() {
// Import State
const [file, setFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
// Export State
const [exportFormat, setExportFormat] = useState<"json" | "csv">("json");
const [isExporting, setIsExporting] = useState(false);
const [toasts, setToasts] = useState<Toast[]>([]);
// Toasts
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const showToast = (type: "success" | "error", text: string) => {
function showToast(type: Toast["type"], text: string) {
const id = Date.now();
setToasts(prev => [...prev, { id, type, text }]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 5000);
};
setToasts((p) => [...p, { id, type, text }]);
setTimeout(() => setToasts((p) => p.filter((t) => t.id !== id)), 5000);
}
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
setFile(acceptedFiles[0]);
}
const onDrop = useCallback((accepted: File[]) => {
if (accepted.length > 0) setFile(accepted[0]);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'application/json': ['.json'],
'text/csv': ['.csv']
},
maxFiles: 1
accept: { "application/json": [".json"], "text/csv": [".csv"] },
maxFiles: 1,
});
const handleImport = async () => {
async function handleImport() {
if (!file) return;
setIsUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/import", {
method: "POST",
body: formData,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || "Import failed");
}
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/import", { method: "POST", body: fd });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Import failed"); }
const data = await res.json();
showToast("success", `Imported ${data.nodes_imported} nodes and ${data.edges_imported} edges!`);
logEvent("import", `Imported ${data.nodes_imported} nodes · ${data.edges_imported} edges from ${file.name}`, {
file: file.name,
nodesImported: data.nodes_imported,
edgesImported: data.edges_imported,
});
showToast("success", `Imported ${data.nodes_imported} nodes · ${data.edges_imported} edges`);
logEvent("import", `Imported ${data.nodes_imported} nodes · ${data.edges_imported} edges from ${file.name}`, { file: file.name });
setFile(null);
} catch (err: any) {
showToast("error", err.message || "An error occurred during import");
} finally {
setIsUploading(false);
}
};
} catch (e: unknown) {
showToast("error", e instanceof Error ? e.message : "Import failed");
} finally { setIsUploading(false); }
}
const handleExport = async () => {
async function handleExport() {
setIsExporting(true);
try {
const res = await fetch("/api/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ format: exportFormat })
body: JSON.stringify({ format: exportFormat }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || "Export failed");
}
// Handle file download
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Export failed"); }
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
// Provide a default extension based on format
a.download = `semantica_export.${exportFormat}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast("success", "Export complete! Your download should begin shortly.");
URL.revokeObjectURL(url);
showToast("success", `Export ready — semantica_export.${exportFormat}`);
logEvent("export", `Exported graph as ${exportFormat.toUpperCase()}`, { format: exportFormat });
} catch (err: any) {
showToast("error", err.message || "An error occurred during export");
} finally {
setIsExporting(false);
}
};
} catch (e: unknown) {
showToast("error", e instanceof Error ? e.message : "Export failed");
} finally { setIsExporting(false); }
}
return (
<div style={{ position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%", background: "#0d1117", padding: 32, gap: 24, boxSizing: "border-box", overflowY: "auto" }}>
<style>{THEME_CSS}</style>
<div>
<h1 style={{ margin: "0 0 8px 0", color: "#fff", display: "flex", alignItems: "center", gap: 12 }}>
<UploadCloud size={28} color="#58a6ff" /> Data Import / Export
</h1>
<p style={{ margin: 0, color: "#8b949e" }}>Ingest new graph datasets or extract the current knowledge base.</p>
</div>
<div style={{ display: "flex", gap: 24, flexWrap: "wrap" }}>
{/* IMPORT PANEL */}
<div className="glass-panel" style={{ flex: "1 1 400px", borderRadius: 12, padding: 32, display: "flex", flexDirection: "column" }}>
<h2 style={{ margin: "0 0 24px 0", color: "#58a6ff", fontSize: 20, display: "flex", alignItems: "center", gap: 8 }}>
<UploadCloud size={20} /> Import Entities & Relations
</h2>
<div {...getRootProps()} className={`dropzone ${isDragActive ? 'active' : ''}`} style={{ padding: 48, textAlign: "center", marginBottom: 24, flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
<input {...getInputProps()} />
{file ? (
<>
{file.name.endsWith(".json") ? <FileJson size={48} color="#3fb950" style={{ marginBottom: 16 }} /> : <FileText size={48} color="#3fb950" style={{ marginBottom: 16 }} />}
<p style={{ color: "#fff", fontWeight: 600, margin: "0 0 8px 0" }}>{file.name}</p>
<p style={{ color: "#8b949e", fontSize: 13, margin: 0 }}>{(file.size / 1024 / 1024).toFixed(2)} MB</p>
</>
) : (
<>
<UploadCloud size={48} color="#58a6ff" style={{ marginBottom: 16, opacity: 0.8 }} />
<p style={{ color: "#c9d1d9", fontSize: 16, fontWeight: 500, margin: "0 0 8px 0" }}>Drag & drop your file here</p>
<p style={{ color: "#8b949e", fontSize: 13, margin: 0 }}>Supports .json and .csv formats</p>
</>
)}
<div className="ws-page ws-scroll">
<div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 24, maxWidth: 980, margin: "0 auto", width: "100%" }}>
{/* Page header */}
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<div style={{ width: 42, height: 42, borderRadius: 13, background: "var(--ws-accent-soft)", border: "1px solid var(--ws-border-strong)", display: "grid", placeItems: "center", color: "var(--ws-accent)", flexShrink: 0 }}>
<UploadCloud size={20} />
</div>
<div>
<h2 className="ws-title" style={{ fontSize: 18 }}>Import &amp; Export</h2>
<div className="ws-body" style={{ marginTop: 2 }}>Ingest new graph datasets or extract the current knowledge base.</div>
</div>
<button
className="btn-primary"
onClick={handleImport}
disabled={!file || isUploading}
style={{ width: "100%", padding: "12px 24px", borderRadius: 8, fontSize: 16, fontWeight: 600, display: "flex", alignItems: "center", justifyContent: "center", gap: 8, cursor: (!file || isUploading) ? "not-allowed" : "pointer", border: "none" }}
>
{isUploading ? <Loader2 size={20} className="animate-spin" /> : <UploadCloud size={20} />}
{isUploading ? "Uploading..." : "Upload to Graph"}
</button>
</div>
{/* EXPORT PANEL */}
<div className="glass-panel" style={{ flex: "1 1 400px", borderRadius: 12, padding: 32, display: "flex", flexDirection: "column" }}>
<h2 style={{ margin: "0 0 24px 0", color: "#d2a8ff", fontSize: 20, display: "flex", alignItems: "center", gap: 8 }}>
<Download size={20} /> Export Graph Snapshot
</h2>
<div style={{ flex: 1 }}>
<label style={{ display: "block", color: "#c9d1d9", marginBottom: 8, fontSize: 14, fontWeight: 500 }}>Export Format</label>
<div style={{ position: "relative", marginBottom: 32 }}>
<select
value={exportFormat}
onChange={e => setExportFormat(e.target.value as "json" | "csv")}
style={{ width: "100%", appearance: "none", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(88,166,255,0.3)", color: "#fff", padding: "12px 16px", borderRadius: 8, fontSize: 15, cursor: "pointer", outline: "none" }}
>
<option value="json" style={{ background: "#0d1117" }}>JSON (Full Graph Dictionary)</option>
<option value="csv" style={{ background: "#0d1117" }}>CSV (Tabular Dump)</option>
</select>
<div style={{ position: "absolute", right: 16, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }}>
<svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.41 0.589966L6 5.16997L10.59 0.589966L12 1.99997L6 7.99997L0 1.99997L1.41 0.589966Z" fill="#8b949e"/>
</svg>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 18 }}>
{/* ── Import card ── */}
<div className="ws-card" style={{ padding: 24, display: "flex", flexDirection: "column", gap: 18 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<UploadCloud size={16} color="var(--ws-accent)" />
<div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>Import Entities &amp; Relations</div>
</div>
<div style={{ background: "rgba(0,0,0,0.2)", padding: 20, borderRadius: 8, border: "1px solid rgba(255,255,255,0.05)" }}>
<h4 style={{ color: "#c9d1d9", margin: "0 0 8px 0", fontSize: 14 }}>Export Details</h4>
<p style={{ color: "#8b949e", fontSize: 13, margin: 0, lineHeight: 1.5 }}>
{exportFormat === "json"
? "Exports the entire graph including all node properties, edge weights, and complete entity metadata into a standardized JSON payload."
: "Exports a flattened CSV tabular representation of all nodes and edges. Complex nested properties will be omitted or stringified."}
</p>
{/* Dropzone */}
<div
{...getRootProps()}
style={{
border: `2px dashed ${isDragActive ? "var(--ws-accent)" : "var(--ws-border)"}`,
borderRadius: "var(--ws-radius)",
background: isDragActive ? "var(--ws-accent-soft)" : "rgba(0,0,0,0.18)",
padding: 32,
textAlign: "center",
cursor: "pointer",
transition: "all 200ms ease",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
}}
>
<input {...getInputProps()} />
{file ? (
<>
{file.name.endsWith(".json") ? <FileJson size={40} color="#4cc38a" /> : <FileText size={40} color="#4cc38a" />}
<div style={{ color: "var(--ws-text)", fontWeight: 700 }}>{file.name}</div>
<div className="ws-body" style={{ fontSize: 11 }}>{(file.size / 1024).toFixed(1)} KB click to replace</div>
</>
) : (
<>
<UploadCloud size={36} color="var(--ws-accent)" style={{ opacity: 0.7 }} />
<div style={{ color: "var(--ws-text)", fontWeight: 600 }}>Drag &amp; drop or click to browse</div>
<div className="ws-pill ws-pill--mono">.json</div>
<span style={{ color: "var(--ws-text-dim)", fontSize: 11 }}>or</span>
<div className="ws-pill ws-pill--mono">.csv</div>
</>
)}
</div>
<button
className="ws-btn ws-btn--primary"
onClick={handleImport}
disabled={!file || isUploading}
style={{ width: "100%", justifyContent: "center" }}
>
{isUploading ? <><Loader2 size={15} className="ws-spin" />Uploading</> : <><UploadCloud size={15} />Upload to Graph</>}
</button>
</div>
<button
style={{ width: "100%", background: "#1f6feb", color: "#fff", padding: "12px 24px", borderRadius: 8, fontSize: 16, fontWeight: 600, display: "flex", alignItems: "center", justifyContent: "center", gap: 8, cursor: isExporting ? "not-allowed" : "pointer", border: "1px solid rgba(240,246,252,0.1)", transition: "background 0.2s" }}
onClick={handleExport}
disabled={isExporting}
onMouseOver={e => { if(!isExporting) (e.currentTarget.style.background = "#388bfd") }}
onMouseOut={e => { if(!isExporting) (e.currentTarget.style.background = "#1f6feb") }}
>
{isExporting ? <Loader2 size={20} className="animate-spin" /> : <Download size={20} />}
{isExporting ? "Preparing Extract..." : "Download Graph Extract"}
</button>
{/* ── Export card ── */}
<div className="ws-card" style={{ padding: 24, display: "flex", flexDirection: "column", gap: 18 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Download size={16} color="var(--ws-purple)" />
<div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>Export Graph Snapshot</div>
</div>
<div>
<label className="ws-label">Format</label>
<div style={{ display: "flex", gap: 8 }}>
{(["json", "csv"] as const).map((fmt) => (
<button
key={fmt}
className={`ws-btn ${exportFormat === fmt ? "ws-btn--primary" : "ws-btn--ghost"}`}
style={{ flex: 1, justifyContent: "center", textTransform: "uppercase", fontSize: 12 }}
onClick={() => setExportFormat(fmt)}
>
{fmt === "json" ? <FileJson size={14} /> : <FileText size={14} />}
{fmt}
</button>
))}
</div>
</div>
<div style={{ flex: 1, padding: "14px 16px", borderRadius: "var(--ws-radius-sm)", background: "rgba(0,0,0,0.22)", border: "1px solid var(--ws-border)" }}>
<div style={{ color: "var(--ws-text-muted)", fontWeight: 700, fontSize: 12, marginBottom: 6 }}>What's included</div>
<div className="ws-body" style={{ fontSize: 12 }}>
{exportFormat === "json"
? "Full graph snapshot: all node properties, edge weights, entity metadata and semantic groups in a standardized JSON payload."
: "Flattened CSV: nodes and edges as rows. Complex nested properties are stringified. Best for spreadsheet analysis."}
</div>
</div>
<button
className="ws-btn"
onClick={handleExport}
disabled={isExporting}
style={{ width: "100%", justifyContent: "center", background: "var(--ws-purple-soft)", borderColor: "rgba(192,132,252,0.3)", color: "#d8b4fe" }}
>
{isExporting ? <><Loader2 size={15} className="ws-spin" />Preparing</> : <><Download size={15} />Download Export</>}
</button>
</div>
</div>
</div>
{/* Toast Notifications */}
<div style={{ position: "fixed", bottom: 32, right: 32, display: "flex", flexDirection: "column", gap: 12, zIndex: 1000 }}>
{toasts.map(toast => (
<div key={toast.id} className="toast" style={{
display: "flex", alignItems: "center", gap: 12, padding: "16px 20px", borderRadius: 8,
background: toast.type === 'success' ? '#1b4a24' : '#571822',
border: `1px solid ${toast.type === 'success' ? 'rgba(63, 185, 80, 0.4)' : 'rgba(248, 81, 73, 0.4)'}`,
boxShadow: "0 8px 24px rgba(0,0,0,0.5)"
}}>
{toast.type === 'success' ? <CheckCircle2 color="#3fb950" size={20}/> : <AlertCircle color="#f85149" size={20}/>}
<span style={{ color: "#fff", fontSize: 14, fontWeight: 500 }}>{toast.text}</span>
{/* Toasts */}
<div style={{ position: "fixed", bottom: 28, right: 28, display: "flex", flexDirection: "column", gap: 10, zIndex: 1000 }}>
{toasts.map((t) => (
<div key={t.id} className="ws-animate-in" style={{ display: "flex", alignItems: "center", gap: 10, padding: "13px 18px", borderRadius: "var(--ws-radius-sm)", background: t.type === "success" ? "rgba(16,36,22,0.96)" : "rgba(40,10,10,0.96)", border: `1px solid ${t.type === "success" ? "rgba(76,195,138,0.4)" : "rgba(255,123,114,0.4)"}`, boxShadow: "0 8px 24px rgba(0,0,0,0.5)", backdropFilter: "blur(12px)" }}>
{t.type === "success" ? <CheckCircle2 size={16} color="#4cc38a" /> : <AlertCircle size={16} color="#ff7b72" />}
<span style={{ color: "var(--ws-text)", fontSize: 13, fontWeight: 500 }}>{t.text}</span>
</div>
))}
</div>
</div>
);
}
}
@@ -2,25 +2,30 @@
* src/workspaces/LineageWorkspace/LineageDiagram.tsx
*/
import { useEffect, useState } from "react";
import { Link2 } from "lucide-react";
import { ReactFlow, Background, Controls } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
const THEME_CSS = `
.react-flow { background: #0d1117; }
.react-flow { background: var(--ws-bg, #060d1a); }
.react-flow__node-group {
background: rgba(88,166,255,0.05);
border: 1px dashed rgba(88,166,255,0.2);
border-radius: 8px;
background: rgba(74,163,255,0.04);
border: 1px dashed rgba(74,163,255,0.18);
border-radius: 10px;
}
.react-flow__node-default {
background: #161b22;
color: #c9d1d9;
border: 1px solid rgba(88,166,255,0.3);
border-radius: 6px;
padding: 10px;
background: rgba(6,13,26,0.92);
color: var(--ws-text, #ddeeff);
border: 1px solid rgba(74,163,255,0.22);
border-radius: 8px;
padding: 10px 12px;
white-space: pre-wrap;
font-size: 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
.react-flow__controls { background: rgba(6,13,26,0.9); border: 1px solid rgba(74,163,255,0.18); border-radius: 10px; }
.react-flow__controls-button { background: transparent; border-color: rgba(74,163,255,0.15); color: var(--ws-text-muted, #5a7a9a); }
.react-flow__controls-button:hover { background: rgba(74,163,255,0.1); color: var(--ws-text, #ddeeff); }
`;
export function LineageDiagram() {
@@ -111,95 +116,43 @@ export function LineageDiagram() {
}, [activeId]);
return (
<div style={{ width: "100%", height: "100%", position: "relative", background: "#0d1117" }}>
<div style={{ width: "100%", height: "100%", position: "relative", background: "var(--ws-bg, #060d1a)" }}>
<style>{THEME_CSS}</style>
{/* Top Bar Navigation */}
<div style={{ position: "absolute", top: 16, left: 16, zIndex: 10, display: "flex", gap: "12px", alignItems: "center" }}>
<div style={{ background: "rgba(13,17,23,0.8)", padding: "4px 8px", borderRadius: 4, color: "#fff", fontWeight: 600, border: "1px solid rgba(255,255,255,0.1)", pointerEvents: "none" }}>
PROV-O Lineage
</div>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<input
type="text"
placeholder="Enter Node ID..."
value={searchId}
onChange={(e) => setSearchId(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") setActiveId(searchId);
}}
style={{
background: "rgba(0,0,0,0.3)",
border: "1px solid rgba(88,166,255,0.3)",
color: "#c9d1d9",
padding: "4px 8px",
borderRadius: "4px",
fontSize: "12px",
outline: "none",
width: "200px"
}}
/>
<button
onClick={() => setActiveId(searchId)}
style={{
background: "#1f6feb",
color: "#fff",
border: "none",
padding: "5px 12px",
borderRadius: "4px",
fontSize: "12px",
cursor: "pointer",
fontWeight: 500
}}
>
Search
</button>
<button
onClick={() => void downloadReport("json")}
disabled={!activeId}
style={{
background: "rgba(31, 111, 235, 0.18)",
color: "#fff",
border: "1px solid rgba(88,166,255,0.3)",
padding: "5px 12px",
borderRadius: "4px",
fontSize: "12px",
cursor: activeId ? "pointer" : "not-allowed",
fontWeight: 500,
opacity: activeId ? 1 : 0.5,
}}
>
JSON
</button>
<button
onClick={() => void downloadReport("markdown")}
disabled={!activeId}
style={{
background: "rgba(31, 111, 235, 0.18)",
color: "#fff",
border: "1px solid rgba(88,166,255,0.3)",
padding: "5px 12px",
borderRadius: "4px",
fontSize: "12px",
cursor: activeId ? "pointer" : "not-allowed",
fontWeight: 500,
opacity: activeId ? 1 : 0.5,
}}
>
Markdown
</button>
</div>
{/* Toolbar */}
<div style={{ position: "absolute", top: 14, left: 14, right: 14, zIndex: 10, display: "flex", gap: 8, alignItems: "center", background: "rgba(4,10,18,0.88)", backdropFilter: "blur(14px)", padding: "8px 12px", borderRadius: 12, border: "1px solid rgba(74,163,255,0.16)", boxShadow: "0 4px 20px rgba(0,0,0,0.4)" }}>
<span className="ws-eyebrow" style={{ color: "var(--ws-accent, #4aa3ff)", marginRight: 4 }}>PROV-O Lineage</span>
<input
className="ws-input"
type="text"
placeholder="Enter Node ID…"
value={searchId}
onChange={(e) => setSearchId(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") setActiveId(searchId); }}
style={{ width: 200, padding: "6px 10px", fontSize: 12 }}
/>
<button className="ws-btn ws-btn--primary" style={{ padding: "6px 12px" }} onClick={() => setActiveId(searchId)}>
Trace
</button>
<div style={{ flex: 1 }} />
<button className="ws-btn ws-btn--ghost" style={{ padding: "6px 12px", fontSize: 11 }} disabled={!activeId} onClick={() => void downloadReport("json")}>
Export JSON
</button>
<button className="ws-btn ws-btn--ghost" style={{ padding: "6px 12px", fontSize: 11 }} disabled={!activeId} onClick={() => void downloadReport("markdown")}>
Export MD
</button>
</div>
{activeId ? (
<ReactFlow nodes={nodes} edges={edges} fitView>
<Background color="#30363d" gap={20} />
<Background color="rgba(74,163,255,0.08)" gap={24} />
<Controls />
</ReactFlow>
) : (
<div style={{ display: "flex", height: "100%", width: "100%", alignItems: "center", justifyContent: "center", color: "#8b949e", fontSize: "14px" }}>
Enter a Node ID to view its W3C PROV-O lineage.
<div className="ws-empty" style={{ height: "100%", paddingTop: 72 }}>
<div className="ws-empty-icon"><Link2 size={36} color="var(--ws-accent)" /></div>
<div className="ws-empty-title">PROV-O Lineage Viewer</div>
<div className="ws-empty-body">Enter a Node ID in the toolbar above and click Trace to view its W3C PROV-O lineage diagram.</div>
</div>
)}
</div>
@@ -127,58 +127,59 @@ export function KGOverviewTab() {
const totalNodes = stats?.node_count ?? 0;
const totalEdges = stats?.edge_count ?? 0;
const statCards = [
{ label: "Nodes", value: totalNodes.toLocaleString(), color: "var(--ws-accent)", sub: `${nodeTypeEntries.length} types` },
{ label: "Edges", value: totalEdges.toLocaleString(), color: "var(--ws-green)", sub: `${edgeTypeEntries.length} rel. types` },
{ label: "Density", value: totalNodes > 1 ? ((totalEdges / (totalNodes * (totalNodes - 1))) * 100).toFixed(3) + "%" : "—", color: "var(--ws-purple)", sub: "graph density" },
];
return (
<div style={shellStyle}>
<div className="ws-page">
{/* Header */}
<div style={headerStyle}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 22px", borderBottom: "1px solid var(--ws-border)", flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Network size={18} color="#4aa3ff" />
<div style={{ width: 32, height: 32, borderRadius: 9, background: "var(--ws-accent-soft)", border: "1px solid var(--ws-border-strong)", display: "grid", placeItems: "center", color: "var(--ws-accent)" }}>
<Network size={16} />
</div>
<div>
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>KG Overview</div>
<div style={{ color: "#8b949e", fontSize: 12 }}>Quick view of the Knowledge Graph structure and health</div>
<div style={{ color: "var(--ws-text)", fontSize: 15, fontWeight: 700, lineHeight: 1 }}>KG Overview</div>
<div className="ws-body" style={{ fontSize: 11, marginTop: 2 }}>Node/edge counts, type distributions, and top connected nodes</div>
</div>
</div>
<button onClick={() => void fetchOverview()} disabled={loading} style={refreshBtnStyle}>
{loading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
<span>Refresh</span>
<button className="ws-btn ws-btn--ghost" onClick={() => void fetchOverview()} disabled={loading} style={{ padding: "6px 12px" }}>
{loading ? <Loader2 size={13} className="ws-spin" /> : <RefreshCw size={13} />}
Refresh
</button>
</div>
{error ? (
<div style={{ margin: "16px 24px", padding: "10px 14px", borderRadius: 10, background: "rgba(255,123,114,0.08)", border: "1px solid rgba(255,123,114,0.2)", color: "#ff7b72", fontSize: 13 }}>
{error && (
<div style={{ margin: "12px 22px", padding: "10px 14px", borderRadius: "var(--ws-radius-sm)", background: "var(--ws-red-soft)", border: "1px solid rgba(255,123,114,0.28)", color: "#fca5a5", fontSize: 13 }}>
{error}
</div>
) : null}
)}
<div style={scrollBodyStyle}>
{/* Stats chips */}
<div style={statsRowStyle}>
{[
{ label: "Nodes", value: totalNodes.toLocaleString(), color: "#4aa3ff", sub: `${nodeTypeEntries.length} types` },
{ label: "Edges", value: totalEdges.toLocaleString(), color: "#4cc38a", sub: `${edgeTypeEntries.length} relationship types` },
{ label: "Density", value: totalNodes > 1 ? ((totalEdges / (totalNodes * (totalNodes - 1))) * 100).toFixed(3) + "%" : "—", color: "#d2a8ff", sub: "graph density" },
].map(({ label, value, color, sub }) => (
<div key={label} style={statCardStyle}>
<div style={{ color: "#8b949e", fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 4 }}>{label}</div>
<div style={{ color, fontSize: 28, fontWeight: 800, letterSpacing: "-0.04em", lineHeight: 1 }}>{loading ? "—" : value}</div>
<div style={{ color: "#6a7f97", fontSize: 11, marginTop: 4 }}>{sub}</div>
<div className="ws-scroll" style={{ flex: 1, padding: "18px 22px", display: "flex", flexDirection: "column", gap: 16 }}>
{/* Stat cards */}
<div className="ws-stat-grid ws-stat-grid--3">
{statCards.map(({ label, value, color, sub }) => (
<div key={label} className="ws-stat-card">
<div className="ws-eyebrow" style={{ marginBottom: 6 }}>{label}</div>
<div className="ws-stat-value" style={{ color }}>{loading ? "" : value}</div>
<div className="ws-stat-label">{sub}</div>
</div>
))}
</div>
{/* Type breakdowns */}
<div style={sectionRowStyle}>
{/* Node types */}
<div style={breakdownCardStyle}>
<div style={sectionTitleStyle}>Node Type Breakdown</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14 }}>
<div className="ws-card" style={{ padding: "16px 18px", gap: 8, display: "flex", flexDirection: "column" }}>
<div className="ws-eyebrow" style={{ marginBottom: 4 }}>Node Type Breakdown</div>
{loading ? (
<div style={skeletonWrapStyle}>
{[80, 65, 45, 35, 25].map((w, i) => (
<div key={i} style={{ ...skeletonBarStyle, width: `${w}%` }} />
))}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{[80, 65, 45, 35, 25].map((w, i) => <div key={i} className="ws-skeleton" style={{ height: 10, width: `${w}%` }} />)}
</div>
) : nodeTypeEntries.length === 0 ? (
<div style={{ color: "#6a7f97", fontSize: 12 }}>No data load the graph first.</div>
<div className="ws-body" style={{ fontSize: 12 }}>No data load the graph first.</div>
) : (
nodeTypeEntries.slice(0, 8).map(([type, count], i) => (
<TypeBar key={type} label={type} count={count} total={totalNodes || 1} color={NODE_COLORS[i % NODE_COLORS.length]} />
@@ -186,17 +187,14 @@ export function KGOverviewTab() {
)}
</div>
{/* Edge types */}
<div style={breakdownCardStyle}>
<div style={sectionTitleStyle}>Edge Type Breakdown</div>
<div className="ws-card" style={{ padding: "16px 18px", gap: 8, display: "flex", flexDirection: "column" }}>
<div className="ws-eyebrow" style={{ marginBottom: 4 }}>Edge Type Breakdown</div>
{loading ? (
<div style={skeletonWrapStyle}>
{[70, 55, 48, 30, 20].map((w, i) => (
<div key={i} style={{ ...skeletonBarStyle, width: `${w}%` }} />
))}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{[70, 55, 48, 30, 20].map((w, i) => <div key={i} className="ws-skeleton" style={{ height: 10, width: `${w}%` }} />)}
</div>
) : edgeTypeEntries.length === 0 ? (
<div style={{ color: "#6a7f97", fontSize: 12 }}>Edge type breakdown requires the stats endpoint to return edge_types.</div>
<div className="ws-body" style={{ fontSize: 12 }}>Edge type breakdown requires the stats endpoint to return edge_types.</div>
) : (
edgeTypeEntries.slice(0, 8).map(([type, count], i) => (
<TypeBar key={type} label={type} count={count} total={totalEdges || 1} color={EDGE_COLORS[i % EDGE_COLORS.length]} />
@@ -206,134 +204,24 @@ export function KGOverviewTab() {
</div>
{/* Top connected nodes */}
{topNodes.length > 0 ? (
<div style={breakdownCardStyle}>
<div style={sectionTitleStyle}>Top Connected Nodes (by degree)</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 8, marginTop: 2 }}>
{topNodes.length > 0 && (
<div className="ws-card" style={{ padding: "16px 18px" }}>
<div className="ws-eyebrow" style={{ marginBottom: 12 }}>Top Connected Nodes (by degree)</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: 8 }}>
{topNodes.map(({ node, neighborCount }, rank) => (
<div key={node.id} style={topNodeRowStyle}>
<div style={{ color: "#6a7f97", fontSize: 12, fontWeight: 700, minWidth: 20 }}>#{rank + 1}</div>
<div key={node.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "9px 12px", borderRadius: "var(--ws-radius-sm)", background: "rgba(0,0,0,0.18)", border: "1px solid var(--ws-border)" }}>
<div style={{ color: "var(--ws-text-dim)", fontSize: 11, fontWeight: 700, minWidth: 22 }}>#{rank + 1}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e6edf3", fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{node.content || node.id}
</div>
<div style={{ color: "#8b949e", fontSize: 11 }}>{node.type}</div>
</div>
<div style={{ color: "#4aa3ff", fontSize: 12, fontWeight: 700, flexShrink: 0 }}>
{neighborCount} conn.
<div style={{ color: "var(--ws-text)", fontSize: 12, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{node.content || node.id}</div>
<div className="ws-eyebrow" style={{ marginTop: 2, fontSize: 9 }}>{node.type}</div>
</div>
<span className="ws-pill ws-pill--accent" style={{ fontSize: 10 }}>{neighborCount}</span>
</div>
))}
</div>
</div>
) : null}
)}
</div>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────── */
const shellStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
background: "#0d1117",
overflow: "hidden",
};
const headerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "20px 24px 16px",
borderBottom: "1px solid rgba(88,166,255,0.1)",
flexShrink: 0,
};
const refreshBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
borderRadius: 8,
border: "1px solid rgba(127,208,255,0.16)",
background: "rgba(74,163,255,0.08)",
color: "#8fa8c6",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
};
const scrollBodyStyle: React.CSSProperties = {
flex: 1,
overflowY: "auto",
padding: "20px 24px",
display: "flex",
flexDirection: "column",
gap: 16,
};
const statsRowStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
gap: 12,
};
const statCardStyle: React.CSSProperties = {
padding: "18px 20px",
borderRadius: 16,
background: "linear-gradient(135deg, rgba(13,17,23,0.8), rgba(22,27,34,0.5))",
border: "1px solid rgba(127,208,255,0.1)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.04)",
};
const sectionRowStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 12,
};
const breakdownCardStyle: React.CSSProperties = {
padding: "16px 18px",
borderRadius: 14,
background: "linear-gradient(135deg, rgba(13,17,23,0.7), rgba(22,27,34,0.4))",
border: "1px solid rgba(255,255,255,0.06)",
display: "flex",
flexDirection: "column",
gap: 8,
};
const sectionTitleStyle: React.CSSProperties = {
color: "#8b949e",
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.07em",
marginBottom: 4,
};
const topNodeRowStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 10px",
borderRadius: 10,
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
};
const skeletonWrapStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 8,
marginTop: 4,
};
const skeletonBarStyle: React.CSSProperties = {
height: 12,
borderRadius: 999,
background: "rgba(255,255,255,0.05)",
animation: "skeleton-pulse 1.4s ease-in-out infinite",
};
@@ -49,18 +49,20 @@ export function AlignmentsTab() {
const reload = useCallback(async () => {
setError("");
try {
const [registryData, alignmentData] = await Promise.all([
loadOntologyRegistry(),
loadAlignments(),
]);
setRegistry(registryData);
setAlignments(alignmentData);
setSourceOntology((current) => current || registryData[0]?.uri || "");
setTargetOntology((current) => current || registryData[1]?.uri || registryData[0]?.uri || "");
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load ontology alignments.");
const [registryResult, alignmentResult] = await Promise.allSettled([
loadOntologyRegistry(),
loadAlignments(),
]);
if (registryResult.status === "fulfilled") {
setRegistry(registryResult.value);
setSourceOntology((current) => current || registryResult.value[0]?.uri || "");
setTargetOntology((current) => current || registryResult.value[1]?.uri || registryResult.value[0]?.uri || "");
}
if (alignmentResult.status === "fulfilled") {
setAlignments(alignmentResult.value);
}
}, []);
useEffect(() => {
@@ -284,7 +286,7 @@ export function AlignmentsTab() {
</div>
</div>
<button style={primaryButtonStyle} disabled={busy} onClick={handleSave}>
{busy ? <Loader2 size={14} className="spin" /> : <GitMerge size={14} />}
{busy ? <Loader2 size={14} className="ws-spin" /> : <GitMerge size={14} />}
Save alignment
</button>
</section>
@@ -23,9 +23,7 @@ export function HealthTab({ onFixInEditor }: HealthTabProps) {
setRegistry(entries);
setSelectedUri((current) => current || entries[0]?.uri || "");
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : "Could not load ontology registry.");
});
.catch(() => { /* backend unavailable — leave registry empty */ });
return () => {
cancelled = true;
};
@@ -37,9 +35,9 @@ export function HealthTab({ onFixInEditor }: HealthTabProps) {
setError("");
try {
setHealth(await loadOntologyHealth(uri));
} catch (err) {
} catch {
// Backend unavailable — show "select an ontology" placeholder, not an error
setHealth(null);
setError(err instanceof Error ? err.message : "Could not load ontology health.");
} finally {
setLoading(false);
}
@@ -84,7 +82,7 @@ export function HealthTab({ onFixInEditor }: HealthTabProps) {
{error ? <div style={errorStyle}>{error}</div> : null}
{loading ? (
<div style={loadingStyle}><Loader2 size={18} className="spin" /> Computing health dashboard...</div>
<div style={loadingStyle}><Loader2 size={18} className="ws-spin" /> Computing health dashboard...</div>
) : health ? (
<>
<section style={{ ...scoreGridStyle, gridTemplateColumns: `220px repeat(${health.dimensions.length}, minmax(180px, 1fr))` }}>
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useState } from "react";
import {
AlertCircle,
BookMarked,
BookOpen,
CheckCircle2,
ExternalLink,
GitMerge,
Layers,
Loader2,
Plus,
@@ -241,7 +241,6 @@ function RegistryRow({
export function OntologyManager() {
const [entries, setEntries] = useState<OntologyEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [searchQ, setSearchQ] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [showLoader, setShowLoader] = useState(false);
@@ -251,17 +250,18 @@ export function OntologyManager() {
const fetchRegistry = useCallback(async () => {
setLoading(true);
setError("");
setActionMsg(null);
try {
const params = new URLSearchParams();
if (searchQ) params.set("q", searchQ);
// format/kind filters (owl/skos/internal/external) are applied client-side
// via filteredEntries; only text search is delegated to the backend
const res = await fetch(`/api/ontology/registry?${params}`);
if (!res.ok) throw new Error("Failed to load registry");
setEntries(await res.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load registry");
if (res.ok) {
setEntries(await res.json());
} else {
setEntries([]);
}
} catch {
setEntries([]);
} finally {
setLoading(false);
}
@@ -429,25 +429,23 @@ export function OntologyManager() {
<Loader2 size={22} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
<span style={{ color: "#8fa8c6", fontSize: 13, marginTop: 10 }}>Loading registry</span>
</div>
) : error ? (
<div style={centerStyle}>
<AlertCircle size={22} color="#ff9daf" />
<span style={{ color: "#ff9daf", fontSize: 13, marginTop: 8 }}>{error}</span>
<button onClick={fetchRegistry} style={retryBtnStyle}>Retry</button>
</div>
) : filteredEntries.length === 0 ? (
<div style={emptyStateStyle}>
<GitMerge size={36} color="rgba(74,163,255,0.15)" />
<div style={{ color: "#8fa8c6", fontSize: 13, marginTop: 12 }}>
<BookMarked size={36} color="rgba(74,163,255,0.18)" />
<div style={{ color: "#8fa8c6", fontSize: 14, fontWeight: 600, marginTop: 14 }}>
{searchQ ? "No ontologies match your search" : "No ontologies loaded yet"}
</div>
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 300 }}>
Click <strong style={{ color: "#7fd0ff" }}>Load Ontology</strong> to import from a URL, upload a file, or create a new ontology.
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 6, textAlign: "center", maxWidth: 300, lineHeight: 1.6 }}>
{searchQ
? "Try a different search term or clear the filter."
: <>Import from a URL, upload a file, or create a new ontology to get started. Click <strong style={{ color: "#7fd0ff" }}>Load Ontology</strong> above.</>}
</div>
<button onClick={() => setShowLoader(true)} style={{ ...primaryToolBtnStyle, marginTop: 16 }}>
<Plus size={13} />
Load Ontology
</button>
{!searchQ && (
<button onClick={() => setShowLoader(true)} style={{ ...primaryToolBtnStyle, marginTop: 18 }}>
<Plus size={13} />
Load Ontology
</button>
)}
</div>
) : (
<div style={listStyle}>
@@ -902,14 +900,3 @@ const emptyStateStyle: React.CSSProperties = {
...centerStyle,
textAlign: "center",
};
const retryBtnStyle: React.CSSProperties = {
marginTop: 12,
padding: "6px 14px",
borderRadius: 8,
border: "1px solid rgba(127,208,255,0.18)",
background: "transparent",
color: "#7fd0ff",
fontSize: 12,
cursor: "pointer",
};
@@ -33,9 +33,7 @@ export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) {
setRegistry(entries);
setSelectedUri((current) => current || entries[0]?.uri || "");
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : "Could not load ontology registry.");
});
.catch(() => { /* backend unavailable — leave registry empty */ });
return () => {
cancelled = true;
};
@@ -53,8 +51,9 @@ export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) {
setShacl((current) => current || turtle);
setSelectedShapeId(null);
setValidation(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load SHACL shapes.");
} catch {
// Shapes not yet generated or backend unavailable — show empty shape list
setShapes([]);
} finally {
setLoading(false);
}
@@ -131,45 +130,50 @@ export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) {
}, [shapes]);
const beforeMount = useCallback((monaco: Monaco) => {
if (!monaco.languages.getLanguages().some((language: { id: string }) => language.id === "turtle")) {
monaco.languages.register({ id: "turtle", extensions: [".ttl"], mimetypes: ["text/turtle"] });
monaco.languages.setMonarchTokensProvider("turtle", {
keywords: ["@prefix", "@base", "a"],
tokenizer: {
root: [
[/#[^\n]*/, "comment"],
[/"(?:[^"\\]|\\.)*"(?:@[a-zA-Z-]+|\\^\\^[^\s,;.]+)?/, "string"],
[/'(?:[^'\\]|\\.)*'(?:@[a-zA-Z-]+|\\^\\^[^\s,;.]+)?/, "string"],
[/"""[\s\S]*?"""/, "string"],
[/<[^>]*>/, "type.identifier"],
[/\b(?:@prefix|@base|a)\b/, "keyword"],
[/\b(?:sh|xsd|owl|rdf|rdfs|skos):[\w]+/, "variable"],
[/[a-zA-Z_][\w-]*:[\w]+/, "namespace"],
[/[;,.]/, "delimiter"],
[/\d+(?:\.\d+)?/, "number"],
],
try {
if (!monaco.languages.getLanguages().some((language: { id: string }) => language.id === "turtle")) {
monaco.languages.register({ id: "turtle", extensions: [".ttl"], mimetypes: ["text/turtle"] });
monaco.languages.setMonarchTokensProvider("turtle", {
tokenizer: {
root: [
[/#[^\n]*/, "comment"],
[/"(?:[^"\\]|\\.)*"/, "string"],
[/'(?:[^'\\]|\\.)*'/, "string"],
[/"""[\s\S]*?"""/, "string"],
[/<[^>]*>/, "type.identifier"],
// Use [@] to avoid Monarch treating @ as a language-property reference
[/[@](?:prefix|base)\b/, "keyword"],
[/\ba\b/, "keyword"],
[/\b(?:sh|xsd|owl|rdf|rdfs|skos):[\w]+/, "variable"],
[/[a-zA-Z_][\w-]*:[\w]+/, "namespace"],
[/[;,.]/, "delimiter"],
[/\d+(?:\.\d+)?/, "number"],
],
},
});
}
monaco.editor.defineTheme("shacl-dark", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "keyword", foreground: "9ee8d7" },
{ token: "string", foreground: "f2b66d" },
{ token: "comment", foreground: "4a6070", fontStyle: "italic" },
{ token: "type.identifier", foreground: "7ce7d3" },
{ token: "variable", foreground: "d2a8ff" },
{ token: "namespace", foreground: "a5d6ff" },
{ token: "number", foreground: "79c0ff" },
{ token: "delimiter", foreground: "8fa8c6" },
],
colors: {
"editor.background": "#050b13",
"editor.foreground": "#d7e7f8",
"editorLineNumber.foreground": "#41536b",
},
});
} catch {
// Monaco setup failure should not crash the component
}
monaco.editor.defineTheme("shacl-dark", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "keyword", foreground: "9ee8d7" },
{ token: "string", foreground: "f2b66d" },
{ token: "comment", foreground: "4a6070", fontStyle: "italic" },
{ token: "type.identifier", foreground: "7ce7d3" },
{ token: "variable", foreground: "d2a8ff" },
{ token: "namespace", foreground: "a5d6ff" },
{ token: "number", foreground: "79c0ff" },
{ token: "delimiter", foreground: "8fa8c6" },
],
colors: {
"editor.background": "#050b13",
"editor.foreground": "#d7e7f8",
"editorLineNumber.foreground": "#41536b",
},
});
}, []);
return (
@@ -250,7 +254,7 @@ export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) {
<div style={{ display: "flex", gap: 8 }}>
<button style={secondaryButtonStyle} disabled={loading} onClick={handleGenerate}><Wand2 size={14} /> Generate strict</button>
<button style={primaryButtonStyle} disabled={loading || !shacl.trim()} onClick={handleValidate}>
{loading ? <Loader2 size={14} className="spin" /> : <Play size={14} />}
{loading ? <Loader2 size={14} className="ws-spin" /> : <Play size={14} />}
Validate
</button>
</div>
@@ -54,33 +54,6 @@ function writeTabParam(tab: OntologyHubTab) {
}
}
function ComingSoonStub({
icon: Icon,
title,
description,
badge,
}: {
icon: typeof GitMerge;
title: string;
description: string;
badge: string;
}) {
return (
<div style={stubShellStyle}>
<div style={stubCardStyle}>
<div style={stubIconRingStyle}>
<Icon size={28} color="#7fd0ff" />
</div>
<div style={stubBadgeStyle}>{badge}</div>
<h2 style={stubTitleStyle}>{title}</h2>
<p style={stubDescStyle}>{description}</p>
<div style={stubDividerStyle} />
<p style={stubSubnoteStyle}>Coming in Subissue 2 / 3 of Ontology Hub</p>
</div>
</div>
);
}
interface OntologyWorkspaceProps {
onJumpToGraphNode?: (nodeId: string) => void;
}
@@ -122,150 +95,25 @@ export function OntologyWorkspace({ onJumpToGraphNode }: OntologyWorkspaceProps)
};
return (
<div style={shellStyle}>
<div style={tabBarStyle}>
{TABS.map(({ id, label, icon: Icon }) => (
<button
key={id}
style={{
...tabBtnBase,
...(activeTab === id ? tabBtnActive : tabBtnIdle),
}}
onClick={() => handleTabChange(id)}
>
<Icon size={14} />
<span>{label}</span>
</button>
))}
<div className="ws-page">
{/* Internal sub-tab bar */}
<div style={{ display: "flex", gap: 4, padding: "8px 16px", borderBottom: "1px solid var(--ws-border)", background: "rgba(0,0,0,0.18)", flexShrink: 0, flexWrap: "wrap" }}>
{TABS.map(({ id, label, icon: Icon }) => {
const active = activeTab === id;
return (
<button
key={id}
onClick={() => handleTabChange(id)}
style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "6px 13px", borderRadius: 999, border: `1px solid ${active ? "var(--ws-border-strong)" : "transparent"}`, background: active ? "var(--ws-accent-soft)" : "transparent", color: active ? "var(--ws-text)" : "var(--ws-text-muted)", fontSize: 12, fontWeight: 600, cursor: "pointer", transition: "160ms ease" }}
>
<Icon size={13} />
{label}
</button>
);
})}
</div>
<div style={contentStyle}>{renderTab()}</div>
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>{renderTab()}</div>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────────── */
const shellStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
background: "#07111f",
overflow: "hidden",
};
const tabBarStyle: React.CSSProperties = {
display: "flex",
gap: 6,
padding: "10px 18px",
borderBottom: "1px solid rgba(140,192,255,0.12)",
background: "rgba(3,9,18,0.72)",
flexShrink: 0,
flexWrap: "wrap",
};
const tabBtnBase: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "7px 13px",
borderRadius: 999,
border: "1px solid transparent",
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
transition: "160ms ease",
background: "transparent",
};
const tabBtnIdle: React.CSSProperties = {
color: "#8fa8c6",
borderColor: "rgba(127,208,255,0.1)",
};
const tabBtnActive: React.CSSProperties = {
color: "#ebf3ff",
background: "rgba(74,163,255,0.16)",
borderColor: "rgba(127,208,255,0.3)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.05)",
};
const contentStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
overflow: "hidden",
};
const stubShellStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
background: "linear-gradient(180deg, rgba(7,17,31,0.8), rgba(5,11,21,0.95))",
};
const stubCardStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
padding: "48px 52px",
borderRadius: 28,
border: "1px solid rgba(127,208,255,0.12)",
background: "rgba(9,19,34,0.82)",
boxShadow: "0 24px 64px rgba(0,0,0,0.32), inset 0 1px 0 rgba(255,255,255,0.06)",
maxWidth: 480,
textAlign: "center",
};
const stubIconRingStyle: React.CSSProperties = {
width: 64,
height: 64,
borderRadius: "50%",
display: "grid",
placeItems: "center",
background: "rgba(74,163,255,0.1)",
border: "1px solid rgba(127,208,255,0.18)",
marginBottom: 4,
};
const stubBadgeStyle: React.CSSProperties = {
padding: "4px 10px",
borderRadius: 999,
background: "rgba(242,182,109,0.1)",
border: "1px solid rgba(242,182,109,0.22)",
color: "#f2b66d",
fontSize: 10,
fontWeight: 800,
letterSpacing: "0.1em",
textTransform: "uppercase",
};
const stubTitleStyle: React.CSSProperties = {
margin: 0,
color: "#ebf3ff",
fontSize: 22,
fontWeight: 800,
letterSpacing: "-0.04em",
};
const stubDescStyle: React.CSSProperties = {
margin: 0,
color: "#8fa8c6",
fontSize: 14,
lineHeight: 1.65,
maxWidth: 360,
};
const stubDividerStyle: React.CSSProperties = {
width: "100%",
height: 1,
background: "rgba(127,208,255,0.08)",
};
const stubSubnoteStyle: React.CSSProperties = {
margin: 0,
color: "#5a7a9a",
fontSize: 12,
};
+195 -115
View File
@@ -1,15 +1,42 @@
import { useState } from "react";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { BrainCircuit, Play, RotateCcw, CheckCircle2, AlertCircle, Zap, GitBranch, Info } from "lucide-react";
const SAMPLE_FACTS = `inhibits(Metformin, mTOR)
causes(mTOR, Neurodegeneration)
treats(Metformin, Diabetes)`;
const SAMPLE_FACTS = `inhibits(Metformin, mTOR)\ncauses(mTOR, Neurodegeneration)`;
const SAMPLE_RULE = `IF inhibits(Metformin, mTOR) AND causes(mTOR, Neurodegeneration) THEN candidate(Metformin, Alzheimer's)`;
const TEMPLATES = [
{
label: "Drug Candidate",
facts: `inhibits(Metformin, mTOR)\ncauses(mTOR, Neurodegeneration)\ntreats(Metformin, Diabetes)`,
rule: `IF inhibits(Metformin, mTOR) AND causes(mTOR, Neurodegeneration) THEN candidate(Metformin, Alzheimer's)`,
},
{
label: "Gene → Disease",
facts: `expressed_in(BRCA1, Breast)\nmutated_in(BRCA1, Cancer)\nassociated_with(Breast, Cancer)`,
rule: `IF mutated_in(X, Cancer) AND expressed_in(X, Y) THEN risk_gene(X, Y)`,
},
{
label: "Pathway Activation",
facts: `activates(EGF, EGFR)\ndownstream_of(MAPK, EGFR)\ndownstream_of(AKT, EGFR)`,
rule: `IF activates(X, EGFR) AND downstream_of(Y, EGFR) THEN activates(X, Y)`,
},
];
export function ReasoningWorkspace() {
const queryClient = useQueryClient();
const [facts, setFacts] = useState(SAMPLE_FACTS);
const [rules, setRules] = useState(SAMPLE_RULE);
const [applyToGraph, setApplyToGraph] = useState(true);
const [result, setResult] = useState<{ inferred_facts?: string[]; rules_fired?: number; added_edges?: number; mutated?: boolean } | null>(null);
const [result, setResult] = useState<{
inferred_facts?: string[];
rules_fired?: number;
added_edges?: number;
mutated?: boolean;
} | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [error, setError] = useState("");
@@ -22,141 +49,194 @@ export function ReasoningWorkspace() {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
facts: facts.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
rules: rules.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
facts: facts.split(/\r?\n/).map((l) => l.trim()).filter(Boolean),
rules: rules.split(/\r?\n/).map((l) => l.trim()).filter(Boolean),
mode: "forward",
apply_to_graph: applyToGraph,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || `Reasoning failed with status ${response.status}`);
}
if (!response.ok) throw new Error(data.detail || `Status ${response.status}`);
setResult(data);
if (data.mutated) {
queryClient.invalidateQueries({ queryKey: ["graph", "full-load"] });
}
} catch (runError) {
setError(runError instanceof Error ? runError.message : "Reasoning failed");
if (data.mutated) queryClient.invalidateQueries({ queryKey: ["graph", "full-load"] });
} catch (e) {
setError(e instanceof Error ? e.message : "Reasoning failed");
} finally {
setIsRunning(false);
}
}
function loadTemplate(t: (typeof TEMPLATES)[number]) {
setFacts(t.facts);
setRules(t.rule);
setResult(null);
setError("");
}
function handleReset() {
setFacts(SAMPLE_FACTS);
setRules(SAMPLE_RULE);
setResult(null);
setError("");
}
return (
<div style={{ display: "grid", gridTemplateColumns: "1.2fr 1fr", gap: 24, height: "100%", padding: 24, boxSizing: "border-box", background: "#0d1117" }}>
<div style={panelStyle}>
<h3 style={titleStyle}>Facts</h3>
<p style={copyStyle}>Enter one fact per line using `predicate(subject, object)` form.</p>
<textarea value={facts} onChange={(event) => setFacts(event.target.value)} style={textareaStyle} />
<div className="ws-page" style={{ flexDirection: "row" }}>
{/* ── Left: Input panel ── */}
<div style={{ width: 480, flexShrink: 0, display: "flex", flexDirection: "column", borderRight: "1px solid var(--ws-border)", overflow: "hidden" }}>
{/* Header */}
<div style={{ padding: "18px 20px 14px", borderBottom: "1px solid var(--ws-border)", flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 2 }}>
<div style={{ width: 32, height: 32, borderRadius: 10, background: "var(--ws-accent-soft)", border: "1px solid var(--ws-border-strong)", display: "grid", placeItems: "center", color: "var(--ws-accent)", flexShrink: 0 }}>
<BrainCircuit size={16} />
</div>
<div>
<div className="ws-eyebrow" style={{ marginBottom: 2 }}>Forward Chaining</div>
<div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 15, lineHeight: 1 }}>Inference Engine</div>
</div>
</div>
</div>
<h3 style={{ ...titleStyle, marginTop: 18 }}>Rules</h3>
<p style={copyStyle}>Write rules in `IF ... AND ... THEN ...` format. If the advanced reasoner is unavailable, the explorer falls back to an internal rule matcher for this format.</p>
<textarea value={rules} onChange={(event) => setRules(event.target.value)} style={{ ...textareaStyle, minHeight: 160 }} />
{/* Templates */}
<div style={{ padding: "12px 16px 10px", borderBottom: "1px solid var(--ws-border)", flexShrink: 0 }}>
<div className="ws-eyebrow" style={{ marginBottom: 8 }}>Quick Templates</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{TEMPLATES.map((t) => (
<button key={t.label} className="ws-btn ws-btn--ghost" style={{ padding: "5px 10px", fontSize: 11 }} onClick={() => loadTemplate(t)}>
{t.label}
</button>
))}
</div>
</div>
<label style={{ display: "flex", alignItems: "center", gap: 10, color: "#c9d1d9", fontSize: 13, marginTop: 16 }}>
<input type="checkbox" checked={applyToGraph} onChange={(event) => setApplyToGraph(event.target.checked)} />
Write inferred binary facts back into the graph as inferred edges
</label>
{/* Input area */}
<div className="ws-scroll ws-padded" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div>
<label className="ws-label">Facts</label>
<div className="ws-body" style={{ marginBottom: 8 }}>One fact per line using <code style={{ color: "var(--ws-accent)", fontSize: 11 }}>predicate(subject, object)</code> form.</div>
<textarea
className="ws-textarea"
value={facts}
onChange={(e) => setFacts(e.target.value)}
rows={6}
spellCheck={false}
/>
</div>
<button onClick={handleRun} disabled={isRunning} style={runButtonStyle}>
{isRunning ? "Running..." : "Run Reasoning"}
</button>
<div>
<label className="ws-label">Rules</label>
<div className="ws-body" style={{ marginBottom: 8 }}>Use <code style={{ color: "var(--ws-amber)", fontSize: 11 }}>IF AND THEN </code> syntax. Falls back to internal matcher if the reasoning server is unavailable.</div>
<textarea
className="ws-textarea"
value={rules}
onChange={(e) => setRules(e.target.value)}
rows={5}
spellCheck={false}
/>
</div>
{/* Apply toggle */}
<label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer", padding: "10px 12px", borderRadius: "var(--ws-radius-sm)", border: "1px solid var(--ws-border)", background: applyToGraph ? "var(--ws-green-soft)" : "var(--ws-surface)" }}>
<div style={{ position: "relative", width: 36, height: 20, flexShrink: 0 }}>
<input
type="checkbox"
checked={applyToGraph}
onChange={(e) => setApplyToGraph(e.target.checked)}
style={{ opacity: 0, position: "absolute", inset: 0, cursor: "pointer", margin: 0 }}
/>
<div style={{ position: "absolute", inset: 0, borderRadius: 999, background: applyToGraph ? "var(--ws-green)" : "rgba(255,255,255,0.12)", transition: "background 180ms ease" }} />
<div style={{ position: "absolute", top: 3, left: applyToGraph ? 19 : 3, width: 14, height: 14, borderRadius: 999, background: "#fff", transition: "left 180ms ease", boxShadow: "0 1px 4px rgba(0,0,0,0.4)" }} />
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: applyToGraph ? "#6ee7b7" : "var(--ws-text-muted)" }}>Write inferred facts to graph</div>
<div style={{ fontSize: 11, color: "var(--ws-text-dim)" }}>Inferred binary facts are added as edges</div>
</div>
</label>
{/* Actions */}
<div style={{ display: "flex", gap: 8 }}>
<button
className="ws-btn ws-btn--primary"
onClick={handleRun}
disabled={isRunning}
style={{ flex: 1, justifyContent: "center" }}
>
{isRunning
? <><span className="ws-spin" style={{ display: "inline-block" }}><Zap size={15} /></span>Running</>
: <><Play size={14} />Run Reasoning</>}
</button>
<button className="ws-btn ws-btn--ghost" onClick={handleReset} title="Reset to defaults">
<RotateCcw size={14} />
</button>
</div>
</div>
</div>
<div style={panelStyle}>
<h3 style={titleStyle}>Inference Results</h3>
{error ? <div style={{ color: "#ff7b72", marginBottom: 12 }}>{error}</div> : null}
{result ? (
<>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginBottom: 14 }}>
<span style={pillStyle}>rules fired: {result.rules_fired ?? 0}</span>
<span style={pillStyle}>edges added: {result.added_edges ?? 0}</span>
<span style={pillStyle}>{result.mutated ? "graph updated" : "preview only"}</span>
{/* ── Right: Results panel ── */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ padding: "18px 24px 14px", borderBottom: "1px solid var(--ws-border)", flexShrink: 0, display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: "var(--ws-text)" }}>Inference Results</div>
{result && !isRunning && (
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
<span className="ws-pill ws-pill--accent">
<Zap size={9} /> {result.rules_fired ?? 0} rules fired
</span>
<span className="ws-pill ws-pill--green">
<GitBranch size={9} /> {result.added_edges ?? 0} edges added
</span>
{result.mutated
? <span className="ws-pill ws-pill--green">graph updated</span>
: <span className="ws-pill ws-pill--mono">preview only</span>}
</div>
)}
</div>
<div className="ws-scroll ws-padded" style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{error && (
<div className="ws-animate-in" style={{ display: "flex", gap: 10, padding: "12px 14px", borderRadius: "var(--ws-radius-sm)", background: "var(--ws-red-soft)", border: "1px solid rgba(255,123,114,0.28)", color: "#fca5a5", fontSize: 13 }}>
<AlertCircle size={16} style={{ flexShrink: 0, marginTop: 1 }} />
<div>{error}</div>
</div>
)}
{isRunning && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{(result.inferred_facts || []).length ? (
result.inferred_facts?.map((fact) => (
<div key={fact} style={factCardStyle}>{fact}</div>
))
{[1,2,3,4].map((i) => <div key={i} className="ws-skeleton" style={{ height: 52 }} />)}
</div>
)}
{result && !isRunning && (
<div className="ws-animate-in">
{(result.inferred_facts ?? []).length === 0 ? (
<div className="ws-empty">
<div className="ws-empty-icon"><CheckCircle2 size={32} /></div>
<div className="ws-empty-title">Reasoning complete</div>
<div className="ws-empty-body">No new facts were inferred from the current rule set and facts.</div>
</div>
) : (
<div style={{ color: "#8b949e", fontSize: 13 }}>No inferred facts were produced.</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{result.inferred_facts!.map((fact, i) => (
<div key={`${fact}-${i}`} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "12px 14px", borderRadius: "var(--ws-radius-sm)", background: "var(--ws-surface)", border: "1px solid var(--ws-border)" }}>
<div style={{ width: 20, height: 20, borderRadius: 6, background: "var(--ws-green-soft)", border: "1px solid rgba(76,195,138,0.28)", display: "grid", placeItems: "center", flexShrink: 0, marginTop: 1 }}>
<CheckCircle2 size={11} color="var(--ws-green)" />
</div>
<code style={{ fontFamily: "'JetBrains Mono','Fira Code',monospace", fontSize: 12, color: "var(--ws-text)", lineHeight: 1.6, wordBreak: "break-all" }}>{fact}</code>
</div>
))}
</div>
)}
</div>
</>
) : (
<div style={{ color: "#8b949e", fontSize: 13 }}>Run a rule set to inspect inferred statements here.</div>
)}
)}
{!result && !isRunning && !error && (
<div className="ws-empty">
<div className="ws-empty-icon"><Info size={32} /></div>
<div className="ws-empty-title">Ready to reason</div>
<div className="ws-empty-body">Enter facts and rules on the left, then click Run Reasoning to see inferred statements here.</div>
</div>
)}
</div>
</div>
</div>
);
}
const panelStyle: React.CSSProperties = {
background: "linear-gradient(135deg, rgba(13, 17, 23, 0.78), rgba(22, 27, 34, 0.64))",
border: "1px solid rgba(88, 166, 255, 0.18)",
borderRadius: 16,
padding: 20,
display: "flex",
flexDirection: "column",
};
const titleStyle: React.CSSProperties = {
color: "#fff",
margin: 0,
fontSize: 18,
fontWeight: 700,
};
const copyStyle: React.CSSProperties = {
color: "#8b949e",
fontSize: 13,
lineHeight: 1.5,
margin: "8px 0 14px",
};
const textareaStyle: React.CSSProperties = {
width: "100%",
minHeight: 120,
resize: "vertical",
borderRadius: 12,
border: "1px solid rgba(88, 166, 255, 0.18)",
background: "rgba(0, 0, 0, 0.25)",
color: "#e6edf3",
padding: 12,
fontFamily: "Consolas, monospace",
fontSize: 13,
boxSizing: "border-box",
};
const runButtonStyle: React.CSSProperties = {
marginTop: 18,
border: "1px solid rgba(88, 166, 255, 0.3)",
background: "rgba(31, 111, 235, 0.2)",
color: "#fff",
borderRadius: 12,
padding: "11px 14px",
fontWeight: 700,
cursor: "pointer",
};
const pillStyle: React.CSSProperties = {
color: "#79c0ff",
border: "1px solid rgba(88, 166, 255, 0.2)",
background: "rgba(88, 166, 255, 0.08)",
borderRadius: 999,
padding: "5px 10px",
fontSize: 12,
};
const factCardStyle: React.CSSProperties = {
color: "#e6edf3",
background: "rgba(255, 255, 255, 0.04)",
border: "1px solid rgba(255, 255, 255, 0.06)",
borderRadius: 10,
padding: "10px 12px",
fontFamily: "Consolas, monospace",
fontSize: 13,
};
@@ -1,149 +1,260 @@
/**
* src/workspaces/SparqlWorkspace/SparqlWorkspace.tsx
*/
import { useState, useRef } from "react";
import Editor, { useMonaco } from "@monaco-editor/react";
import { Play, Copy, Download, Table2, AlertCircle, FileCode2 } from "lucide-react";
const THEME_CSS = `
.glass-panel {
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
border: 1px solid rgba(88,166,255,0.2);
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
}
`;
const TEMPLATES: { label: string; query: string }[] = [
{ label: "All triples", query: "SELECT ?s ?p ?o\nWHERE {\n ?s ?p ?o\n}\nLIMIT 20" },
{ label: "Node types", query: "SELECT ?type (COUNT(?s) AS ?count)\nWHERE {\n ?s a ?type\n}\nGROUP BY ?type\nORDER BY DESC(?count)" },
{ label: "Outgoing edges", query: "SELECT ?predicate ?object\nWHERE {\n <urn:node:example> ?predicate ?object\n}\nLIMIT 50" },
{ label: "Path between", query: "SELECT ?mid ?p1 ?p2\nWHERE {\n <urn:node:a> ?p1 ?mid .\n ?mid ?p2 <urn:node:b>\n}\nLIMIT 20" },
];
export function SparqlWorkspace() {
const monaco = useMonaco();
const editorRef = useRef<any>(null);
const [query, setQuery] = useState("SELECT ?s ?p ?o\nWHERE {\n ?s ?p ?o\n}\nLIMIT 10");
const [result, setResult] = useState<any>(null);
const editorRef = useRef<unknown>(null);
const [query, setQuery] = useState(TEMPLATES[0].query);
const [result, setResult] = useState<{ columns?: string[]; rows?: Record<string, string>[]; error?: string; error_line?: number } | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [copyState, setCopyState] = useState(false);
function handleEditorWillMount(monacoIns: any) {
if (!monacoIns.languages.getLanguages().some((l: any) => l.id === "sparql")) {
function handleEditorWillMount(monacoIns: { languages: { getLanguages(): { id: string }[]; register(opts: { id: string }): void; setMonarchTokensProvider(id: string, p: unknown): void }; editor: { defineTheme(id: string, t: unknown): void } }) {
if (!monacoIns.languages.getLanguages().some((l) => l.id === "sparql")) {
monacoIns.languages.register({ id: "sparql" });
monacoIns.languages.setMonarchTokensProvider("sparql", {
keywords: ["SELECT", "WHERE", "LIMIT", "FILTER", "OPTIONAL", "PREFIX", "ORDER BY", "DESC", "ASC"],
keywords: ["SELECT", "WHERE", "LIMIT", "FILTER", "OPTIONAL", "PREFIX", "ORDER", "BY", "DESC", "ASC", "GROUP", "DISTINCT", "CONSTRUCT", "ASK", "DESCRIBE"],
tokenizer: {
root: [
[/[a-zA-Z_]\w*/, { cases: { "@keywords": "keyword", "@default": "identifier" } }],
[/[?\$][a-zA-Z_]\w*/, "variable.name"],
[/[?$][a-zA-Z_]\w*/, "variable.name"],
[/<[^>]+>/, "string.uri"],
[/".*?"/, "string"],
[/"[^"]*"/, "string"],
[/#.*/, "comment"],
]
}
[/[0-9]+(\.[0-9]+)?/, "number"],
],
},
});
monacoIns.editor.defineTheme("sparql-dark", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "keyword", foreground: "58a6ff", fontStyle: "bold" },
{ token: "variable.name", foreground: "79c0ff" },
{ token: "string.uri", foreground: "a5d6ff" },
{ token: "variable.name", foreground: "a5d6ff" },
{ token: "string.uri", foreground: "7ee787" },
{ token: "string", foreground: "a5d6ff" },
{ token: "comment", foreground: "8b949e" }
{ token: "comment", foreground: "4a6a85", fontStyle: "italic" },
{ token: "number", foreground: "f2b66d" },
],
colors: {
"editor.background": "#0d1117",
"editor.lineHighlightBackground": "#161b22",
}
"editor.background": "#050c18",
"editor.lineHighlightBackground": "#0a1628",
"editorLineNumber.foreground": "#2a4060",
"editorCursor.foreground": "#4aa3ff",
"editor.selectionBackground": "#1e3a5a",
},
});
}
}
function handleEditorDidMount(editor: any) {
function handleEditorDidMount(editor: unknown) {
editorRef.current = editor;
}
async function handleRun() {
setIsLoading(true);
setResult(null);
monaco?.editor.setModelMarkers(editorRef.current.getModel(), "sparql", []);
if (monaco && editorRef.current) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(monaco as any).editor.setModelMarkers((editorRef.current as any).getModel(), "sparql", []);
}
try {
const response = await fetch("/api/sparql", {
const res = await fetch("/api/sparql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query })
body: JSON.stringify({ query }),
});
const data = await response.json();
if (data.error) {
if (data.error_line && monaco && editorRef.current) {
monaco.editor.setModelMarkers(editorRef.current.getModel(), "sparql", [
{
startLineNumber: data.error_line,
startColumn: data.error_column || 1,
endLineNumber: data.error_line,
endColumn: 100,
message: data.error,
severity: monaco.MarkerSeverity.Error
}
]);
}
const data = await res.json();
if (data.error && data.error_line && monaco && editorRef.current) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(monaco as any).editor.setModelMarkers((editorRef.current as any).getModel(), "sparql", [{
startLineNumber: data.error_line,
startColumn: data.error_column || 1,
endLineNumber: data.error_line,
endColumn: 100,
message: data.error,
severity: (monaco as any).MarkerSeverity.Error,
}]);
}
setResult(data);
} catch (e) {
console.error(e);
} catch {
setResult({ error: "Network error — could not reach the SPARQL endpoint." });
} finally {
setIsLoading(false);
}
}
function handleCopyQuery() {
navigator.clipboard.writeText(query).then(() => {
setCopyState(true);
setTimeout(() => setCopyState(false), 1500);
}).catch(() => {
// Clipboard API unavailable (insecure context or denied) — no-op; query is visible in editor
});
}
function handleExportCSV() {
if (!result?.rows || !result?.columns) return;
const cols = result.columns;
const header = cols.join(",");
const rows = result.rows.map((r) => cols.map((c) => JSON.stringify(r[c] ?? "")).join(",")).join("\n");
const blob = new Blob([`${header}\n${rows}`], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "sparql_results.csv";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
return (
<div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%", background: "#0d1117", padding: 24, boxSizing: "border-box", gap: 24 }}>
<style>{THEME_CSS}</style>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h2 style={{ color: "#ffffff", margin: 0, fontSize: 24 }}>SPARQL Query Engine</h2>
<button
onClick={handleRun}
disabled={isLoading}
style={{ background: "#238636", color: "#fff", border: "none", padding: "8px 24px", borderRadius: 6, fontWeight: 600, cursor: "pointer" }}
>
{isLoading ? "Running..." : "Run Query"}
</button>
</div>
<div className="ws-page">
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
{/* ── Toolbar ── */}
<div style={{ padding: "10px 16px", borderBottom: "1px solid var(--ws-border)", display: "flex", alignItems: "center", gap: 8, flexShrink: 0, background: "rgba(0,0,0,0.18)" }}>
<div style={{ display: "flex", gap: 6, flex: 1, flexWrap: "wrap" }}>
<span className="ws-eyebrow" style={{ alignSelf: "center", marginRight: 4 }}>Templates:</span>
{TEMPLATES.map((t) => (
<button
key={t.label}
className="ws-btn ws-btn--ghost"
style={{ padding: "4px 10px", fontSize: 11 }}
onClick={() => { setQuery(t.query); setResult(null); }}
>
{t.label}
</button>
))}
</div>
<button className="ws-btn ws-btn--ghost" style={{ padding: "6px 10px" }} onClick={handleCopyQuery} title="Copy query">
<Copy size={13} />{copyState ? "Copied!" : "Copy"}
</button>
<button
className="ws-btn ws-btn--primary"
onClick={handleRun}
disabled={isLoading}
style={{ minWidth: 110, justifyContent: "center" }}
>
{isLoading
? <><span className="ws-spin" style={{ display: "inline-block" }}><Play size={13} /></span>Running</>
: <><Play size={13} />Run Query</>}
</button>
</div>
<div className="glass-panel" style={{ flex: 1, borderRadius: 12, overflow: "hidden", border: "1px solid rgba(88,166,255,0.2)" }}>
<Editor
height="100%"
defaultLanguage="sparql"
theme="sparql-dark"
value={query}
onChange={(v) => setQuery(v || "")}
beforeMount={handleEditorWillMount}
onMount={handleEditorDidMount}
options={{ minimap: { enabled: false }, fontSize: 14, fontFamily: "monospace" }}
/>
</div>
{/* ── Editor + Results split ── */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Editor */}
<div style={{ flex: "0 0 55%", minHeight: 0, borderBottom: "1px solid var(--ws-border)", position: "relative" }}>
<div style={{ position: "absolute", top: 8, right: 12, zIndex: 10, display: "flex", alignItems: "center", gap: 6 }}>
<span className="ws-pill ws-pill--mono"><FileCode2 size={9} />SPARQL</span>
</div>
<Editor
height="100%"
defaultLanguage="sparql"
theme="sparql-dark"
value={query}
onChange={(v) => setQuery(v || "")}
beforeMount={handleEditorWillMount}
onMount={handleEditorDidMount}
options={{
minimap: { enabled: false },
fontSize: 13,
fontFamily: "'JetBrains Mono','Fira Code',Consolas,monospace",
lineHeight: 22,
padding: { top: 16 },
scrollBeyondLastLine: false,
renderLineHighlight: "gutter",
wordWrap: "on",
}}
/>
</div>
<div className="glass-panel" style={{ height: "30%", borderRadius: 12, padding: 16, overflowY: "auto" }}>
<h3 style={{ color: "#ffffff", margin: "0 0 16px 0", fontSize: 16 }}>Results</h3>
{result?.error ? (
<div style={{ color: "#ff7b72" }}>{result.error}</div>
) : result?.rows ? (
<table style={{ width: "100%", borderCollapse: "collapse", color: "#c9d1d9" }}>
<thead>
<tr style={{ borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
{result.columns.map((c: string) => <th key={c} style={{ textAlign: "left", padding: 8 }}>{c}</th>)}
</tr>
</thead>
<tbody>
{result.rows.map((r: any, i: number) => (
<tr key={i} style={{ borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
{result.columns.map((c: string) => <td key={c} style={{ padding: 8 }}>{r[c]}</td>)}
</tr>
))}
</tbody>
</table>
) : (
<div style={{ color: "#8b949e" }}>No results to display. Run a query first.</div>
)}
{/* Results */}
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", overflow: "hidden", background: "rgba(0,0,0,0.12)" }}>
<div style={{ padding: "10px 16px", borderBottom: "1px solid var(--ws-border)", display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, color: "var(--ws-text-muted)", fontSize: 13, fontWeight: 700 }}>
<Table2 size={14} />
Results
{result?.rows && <span className="ws-pill ws-pill--accent">{result.rows.length} rows</span>}
</div>
{result?.rows && result.rows.length > 0 && (
<button className="ws-btn ws-btn--ghost" style={{ marginLeft: "auto", padding: "4px 10px", fontSize: 11 }} onClick={handleExportCSV}>
<Download size={12} />Export CSV
</button>
)}
</div>
<div className="ws-scroll" style={{ flex: 1 }}>
{isLoading && (
<div style={{ padding: 24, display: "flex", flexDirection: "column", gap: 8 }}>
{[1, 2, 3].map((i) => <div key={i} className="ws-skeleton" style={{ height: 36 }} />)}
</div>
)}
{result?.error && !isLoading && (
<div className="ws-animate-in" style={{ margin: 16, display: "flex", gap: 10, padding: "12px 14px", borderRadius: "var(--ws-radius-sm)", background: "var(--ws-red-soft)", border: "1px solid rgba(255,123,114,0.28)", color: "#fca5a5", fontSize: 13 }}>
<AlertCircle size={16} style={{ flexShrink: 0, marginTop: 1 }} />
{result.error}
</div>
)}
{result?.rows && result?.columns && !isLoading && (
<div className="ws-animate-in" style={{ overflowX: "auto" }}>
{result.rows.length === 0 ? (
<div className="ws-empty">
<div className="ws-empty-title">No results</div>
<div className="ws-empty-body">The query returned 0 rows. Try a broader query or check your data.</div>
</div>
) : (
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12, color: "var(--ws-text)" }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--ws-border)", background: "rgba(0,0,0,0.2)" }}>
<th style={{ padding: "8px 14px", textAlign: "left", color: "var(--ws-text-dim)", fontFamily: "monospace", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em", width: 40 }}>#</th>
{result.columns.map((c) => (
<th key={c} style={{ padding: "8px 14px", textAlign: "left", color: "var(--ws-text-muted)", fontFamily: "monospace", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em" }}>?{c}</th>
))}
</tr>
</thead>
<tbody>
{result.rows.map((r, i) => (
<tr key={i} style={{ borderBottom: "1px solid rgba(74,163,255,0.06)" }}>
<td style={{ padding: "7px 14px", color: "var(--ws-text-dim)", fontFamily: "monospace", fontSize: 11 }}>{i + 1}</td>
{(result.columns ?? []).map((c) => (
<td key={c} style={{ padding: "7px 14px", maxWidth: 300, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontFamily: "monospace" }} title={String(r[c] ?? "")}>
{r[c] != null ? (
String(r[c]).startsWith("urn:") || String(r[c]).startsWith("http")
? <span style={{ color: "#7ee787" }}>{String(r[c])}</span>
: String(r[c])
) : <span style={{ color: "var(--ws-text-dim)", fontStyle: "italic" }}>null</span>}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{!result && !isLoading && (
<div className="ws-empty">
<div className="ws-empty-icon"><Table2 size={28} /></div>
<div className="ws-empty-title">Run a query</div>
<div className="ws-empty-body">Write SPARQL above or pick a template, then click Run Query to see results here.</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
@@ -160,7 +160,7 @@ test("summarizeDistanceBuckets reports local rings and outside count", () => {
anchor: 1,
oneHop: 1,
twoHop: 1,
threeHop: 1,
threeHopPlus: 1,
outside: 2,
});
});
@@ -193,11 +193,11 @@ test("buildHeatmapRenderSnapshot caps and deterministically samples large rings"
assert.equal(firstSnapshot.ringCounts.anchor, 1);
assert.equal(firstSnapshot.ringCounts.oneHop, 130);
assert.equal(firstSnapshot.ringCounts.twoHop, 700);
assert.equal(firstSnapshot.ringCounts.threeHop, 950);
assert.equal(firstSnapshot.ringCounts.threeHopPlus, 950);
assert.equal(firstSnapshot.renderedRingCounts.anchor, 1);
assert.equal(firstSnapshot.renderedRingCounts.oneHop, 120);
assert.equal(firstSnapshot.renderedRingCounts.twoHop, 650);
assert.equal(firstSnapshot.renderedRingCounts.threeHop, 900);
assert.equal(firstSnapshot.renderedRingCounts.threeHopPlus, 900);
assert.equal(firstSnapshot.saturationMode, "sampled");
assert.deepEqual(firstSnapshot.visibleNodeIds, secondSnapshot.visibleNodeIds);
assert.ok(firstSnapshot.visibleNodeIds.includes("anchor"));
@@ -233,7 +233,7 @@ test("resolveDistanceNodeStyle applies readable heatmap rings only when ready",
anchor: 1,
oneHop: 1,
twoHop: 1,
threeHop: 1,
threeHopPlus: 1,
outside: 1,
},
});
@@ -268,7 +268,7 @@ test("resolveDistanceNodeStyle compresses saturated heatmap far rings", () => {
anchor: 1,
oneHop: 32,
twoHop: 3350,
threeHop: 7412,
threeHopPlus: 7412,
outside: 3280,
},
});
@@ -295,14 +295,14 @@ test("resolveDistanceNodeStyle mutes unsampled heatmap nodes instead of coloring
anchor: 1,
oneHop: 0,
twoHop: 2,
threeHop: 0,
threeHopPlus: 0,
outside: 0,
},
heatmapRenderedRingCounts: {
anchor: 1,
oneHop: 0,
twoHop: 1,
threeHop: 0,
threeHopPlus: 0,
outside: 0,
},
heatmapSaturationMode: "sampled",