Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions views/soa-tree-view/src/components/SoANode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,18 @@ 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;

const icon = MODALITY_ICONS[node.modality] || '\uD83D\uDD35';

return (
<div className={styles.nodeWrapper} style={{ paddingLeft: `${depth * 20}px` }}>
<div
<button
className={`${styles.nodeHeader} ${expanded ? styles.expanded : ''}`}
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
>
<span className={styles.toggle}>
{hasChildren || hasDetails ? (expanded ? '\u25BE' : '\u25B8') : '\u00A0\u00A0'}
Expand All @@ -82,7 +81,7 @@ export default function SoANode({ node, depth }: Props) {
{node.relationships.length} rel
</span>
)}
</div>
</button>

{expanded && hasDetails && (
<div className={styles.details} style={{ paddingLeft: `${depth * 20 + 28}px` }}>
Expand All @@ -107,7 +106,7 @@ export default function SoANode({ node, depth }: Props) {
<div className={styles.property}>
<span className={styles.propLabel}>Tags</span>
<div className={styles.tagList}>
{node.tags.split(',').map((tag) => tag.trim()).filter(Boolean).map((tag) => (
{node.tags.split(',').map((tag) => tag.trim()).filter((tag) => tag !== '').map((tag) => (
<span key={tag} className={styles.tag}>{tag}</span>
))}
</div>
Expand Down
54 changes: 43 additions & 11 deletions views/soa-tree-view/src/components/SoATreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function parseLiteralString(literal: string): string {
function buildTree(soaLinks: LinkExpression[]): SoANodeData[] {
const nodeMap = new Map<string, SoANodeData>();
const parentChildLinks: { parent: string; child: string }[] = [];
const referencedTargets = new Set<string>();

// First pass: find all nodes (anything with a soa://title link)
for (const link of soaLinks) {
Expand All @@ -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;
Expand Down Expand Up @@ -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_', ''),
Expand All @@ -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<string>();
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
Expand All @@ -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);
Expand Down
Loading