diff --git a/views/soa-tree-view/src/components/SoANode.tsx b/views/soa-tree-view/src/components/SoANode.tsx
index a395a6a02..78b8c3a1c 100644
--- a/views/soa-tree-view/src/components/SoANode.tsx
+++ b/views/soa-tree-view/src/components/SoANode.tsx
@@ -45,9 +45,7 @@ export default function SoANode({ node, depth }: Props) {
const hasDetails =
node.description ||
node.confidence != null ||
- node.status ||
node.tags ||
- node.priority != null ||
node.source ||
node.relationships.length > 0;
@@ -55,9 +53,10 @@ export default function SoANode({ node, depth }: Props) {
return (
-
setExpanded(!expanded)}
+ aria-expanded={expanded}
>
{hasChildren || hasDetails ? (expanded ? '\u25BE' : '\u25B8') : '\u00A0\u00A0'}
@@ -82,7 +81,7 @@ export default function SoANode({ node, depth }: Props) {
{node.relationships.length} rel
)}
-
+
{expanded && hasDetails && (
@@ -107,7 +106,7 @@ export default function SoANode({ node, depth }: Props) {
Tags
- {node.tags.split(',').map((tag) => tag.trim()).filter(Boolean).map((tag) => (
+ {node.tags.split(',').map((tag) => tag.trim()).filter((tag) => tag !== '').map((tag) => (
{tag}
))}
diff --git a/views/soa-tree-view/src/components/SoATreeView.tsx b/views/soa-tree-view/src/components/SoATreeView.tsx
index 767f96690..6c651bd19 100644
--- a/views/soa-tree-view/src/components/SoATreeView.tsx
+++ b/views/soa-tree-view/src/components/SoATreeView.tsx
@@ -41,6 +41,7 @@ function parseLiteralString(literal: string): string {
function buildTree(soaLinks: LinkExpression[]): SoANodeData[] {
const nodeMap = new Map
();
const parentChildLinks: { parent: string; child: string }[] = [];
+ const referencedTargets = new Set();
// First pass: find all nodes (anything with a soa://title link)
for (const link of soaLinks) {
@@ -61,7 +62,7 @@ function buildTree(soaLinks: LinkExpression[]): SoANodeData[] {
}
}
- // Second pass: collect properties and relationships
+ // Second pass: collect properties, relationships, and track referenced targets
for (const link of soaLinks) {
const pred = link.data.predicate;
const base = link.data.source;
@@ -89,7 +90,9 @@ function buildTree(soaLinks: LinkExpression[]): SoANodeData[] {
} else if (pred === 'soa://rel_parent') {
// source is parent of target
parentChildLinks.push({ parent: base, child: target });
+ referencedTargets.add(target);
} else if (RELATIONSHIP_PREDICATES.includes(pred)) {
+ referencedTargets.add(target);
const targetNode = nodeMap.get(target);
node.relationships.push({
predicate: pred.replace('soa://rel_', ''),
@@ -99,15 +102,46 @@ function buildTree(soaLinks: LinkExpression[]): SoANodeData[] {
}
}
- // Build tree from parent-child links
+ // Register placeholder nodes for items referenced only by relationships
+ for (const target of referencedTargets) {
+ if (!nodeMap.has(target)) {
+ nodeMap.set(target, {
+ base: target,
+ title: '(unknown)',
+ modality: 'observation',
+ children: [],
+ relationships: [],
+ });
+ }
+ }
+
+ // Build tree from parent-child links with cycle detection
const childSet = new Set();
for (const { parent, child } of parentChildLinks) {
+ // Detect back-edges: if child is an ancestor of parent, skip to prevent cycles
const parentNode = nodeMap.get(parent);
const childNode = nodeMap.get(child);
- if (parentNode && childNode) {
- parentNode.children.push(childNode);
- childSet.add(child);
+ if (!parentNode || !childNode) continue;
+
+ // Check for back-edge: if parent is already a descendant of child, skip
+ const isDescendant = (ancestor: string, descendant: string): boolean => {
+ const ancNode = nodeMap.get(ancestor);
+ if (!ancNode) return false;
+ for (const c of ancNode.children) {
+ if (c.base === descendant || isDescendant(c.base, descendant)) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ // Skip if adding this link would create a cycle (child is already an ancestor of parent)
+ if (isDescendant(child, parent)) {
+ continue;
}
+
+ parentNode.children.push(childNode);
+ childSet.add(child);
}
// Root nodes are those that are never a child
@@ -132,12 +166,10 @@ export default function SoATreeView({ perspective, source }: Props) {
async function loadTree() {
try {
setLoading(true);
- // TODO: Optimize with server-side filtering by querying each soa:// predicate individually
- // instead of fetching all links and filtering client-side
- const allLinks = await perspective.queryLinks({});
- const soaLinks = allLinks.filter(
- (l: LinkExpression) => l.data.predicate?.startsWith('soa://')
- );
+ // Use server-side filtering with LinkQuery to fetch only soa:// links
+ const soaLinks = await perspective.queryLinks({
+ predicate: 'soa://',
+ });
if (!cancelled) {
const tree = buildTree(soaLinks);