glam/frontend/src/pages/LinkMLViewerPage.tsx
kempersc a981bb7ca3 feat(linkml): Add slot_usage comparison popup in schema viewer
- Add 'slot_usage' type to SchemaElementPopup for comparing generic slots vs class overrides
- Show side-by-side comparison table with property, generic value, and override value
- Display green 'changed' badges for modified properties
- Add dual navigation buttons (Go to class / Go to slot)
- Include comprehensive dark mode support
- Match styling to main page's comparison view (green color scheme)
2026-01-14 19:55:57 +01:00

4187 lines
184 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* LinkMLViewerPage.tsx - LinkML Schema Viewer Page
*
* Displays LinkML schema files with:
* - Sidebar listing schemas by category (main, classes, enums, slots)
* - Visual display of selected schema showing classes, slots, and enums
* - Raw YAML view toggle
* - Schema metadata and documentation
* - URL parameter support for deep linking to specific classes (?class=ClassName)
*/
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import debounce from 'lodash/debounce';
import { useSearchParams } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import {
loadSchema,
loadSchemaRaw,
type LinkMLSchema,
type LinkMLClass,
type LinkMLSlot,
type LinkMLEnum,
type SchemaFile,
extractClasses,
extractSlots,
extractEnums,
} from '../lib/linkml/schema-loader';
import { linkmlSchemaService, type ClassExportInfo, type ClassImportInfo, type ClassDependencyCounts, type SlotDefinition, type SlotExportInfo, type SlotImportInfo } from '../lib/linkml/linkml-schema-service';
import { useLanguage } from '../contexts/LanguageContext';
import { useSchemaLoadingProgress } from '../hooks/useSchemaLoadingProgress';
import { CustodianTypeBadge } from '../components/uml/CustodianTypeIndicator';
import { CustodianTypeIndicator3D } from '../components/uml/CustodianTypeIndicator3D';
import { CustodianTypeLegendBar } from '../components/uml/CustodianTypeLegend';
import { UMLVisualization, type UMLDiagram, type UMLNode, type UMLLink } from '../components/uml/UMLVisualization';
import { CUSTODIAN_TYPE_CODES, type CustodianTypeCode } from '../lib/custodian-types';
import { LoadingScreen } from '../components/LoadingScreen';
import {
getCustodianTypesForClass,
getCustodianTypesForSlot,
getCustodianTypesForEnum,
getCustodianTypesForClassAsync,
getCustodianTypesForSlotAsync,
getCustodianTypesForEnumAsync,
isUniversalElement,
} from '../lib/schema-custodian-mapping';
import { OntologyTermPopup } from '../components/ontology/OntologyTermPopup';
import { SchemaElementPopup, type SchemaElementType } from '../components/linkml/SchemaElementPopup';
import './LinkMLViewerPage.css';
/**
* Converts snake_case or kebab-case identifiers to human-readable Title Case.
*
* Examples:
* - "custodian_appellation_class" → "Custodian Appellation Class"
* - "feature_place_class" → "Feature Place Class"
* - "heritage-custodian-observation" → "Heritage Custodian Observation"
*/
const formatDisplayName = (name: string): string => {
if (!name) return name;
return name
// Replace underscores and hyphens with spaces
.replace(/[_-]/g, ' ')
// Capitalize first letter of each word
.replace(/\b\w/g, char => char.toUpperCase());
};
/**
* Admonition transformer for markdown content.
* Converts patterns like "CRITICAL: message" into styled admonition elements.
*
* Supported patterns:
* - CRITICAL: Urgent, must-know information (red)
* - WARNING: Potential issues or gotchas (orange)
* - IMPORTANT: Significant but not urgent (amber)
* - NOTE: Informational, helpful context (blue)
*
* The pattern captures everything from the keyword until:
* - End of line (for single-line admonitions)
* - A blank line (paragraph break)
*/
const transformAdmonitions = (text: string): string => {
if (!text) return text;
// Pattern matches: KEYWORD: rest of the text until end of paragraph
// [^\n]* captures everything on the current line
// (?:\n(?!\n)[^\n]*)* captures continuation lines (lines that don't start a new paragraph)
const admonitionPattern = /\b(CRITICAL|WARNING|IMPORTANT|NOTE):\s*([^\n]*(?:\n(?!\n)[^\n]*)*)/g;
return text.replace(admonitionPattern, (_match, keyword, content) => {
const type = keyword.toLowerCase();
// Return HTML that ReactMarkdown will pass through
return `<span class="linkml-admonition linkml-admonition--${type}"><span class="linkml-admonition__label">${keyword}:</span><span class="linkml-admonition__content">${content.trim()}</span></span>`;
});
};
/**
* CURIE (Compact URI) highlighter for markdown content.
* Highlights patterns like "schema:name", "crm:E41_Appellation", "skos:altLabel".
*
* CURIE format: prefix:localName
* - prefix: lowercase letters (e.g., schema, crm, skos, foaf, dcterms)
* - localName: letters, numbers, underscores, hyphens (e.g., name, E41_Appellation)
*
* Also handles compound CURIEs like "schema:dateCreated/dateModified"
*/
const highlightCuries = (text: string): string => {
if (!text) return text;
// Common ontology prefixes used in heritage/linked data
const knownPrefixes = [
'schema', 'crm', 'skos', 'foaf', 'dcterms', 'dct', 'dc', 'rdfs', 'rdf', 'owl',
'prov', 'org', 'locn', 'cpov', 'tooi', 'rico', 'bf', 'wikidata', 'wd', 'wdt',
'xsd', 'linkml', 'hc', 'pico', 'cv'
].join('|');
// Pattern matches: prefix:localName (with optional /additional parts)
// Negative lookbehind (?<![:/]) prevents matching URLs like http://...
// Negative lookahead (?![:/]) prevents partial URL matches
const curiePattern = new RegExp(
`(?<![:/])\\b((?:${knownPrefixes}):(?:[A-Za-z][A-Za-z0-9_-]*(?:/[A-Za-z][A-Za-z0-9_-]*)*))(?![:/])`,
'g'
);
return text.replace(curiePattern, '<code class="linkml-curie">$1</code>');
};
/**
* ASCII Tree Diagram transformer.
* Converts ASCII tree diagrams (using ├── └── │ characters) into styled HTML trees.
*
* Detects patterns like:
* ```
* Archives nationales (national)
* └── Archives régionales (regional)
* └── Archives départementales (THIS TYPE)
* └── Archives communales (municipal)
* ```
*
* Or more complex trees:
* ```
* Custodian (hub)
* │
* └── CustodianCollection (aspect)
* ├── CollectionType (classification)
* ├── AccessPolicy (access restrictions)
* └── sub_collections → Collection[]
* ```
*/
const transformTreeDiagrams = (text: string): string => {
if (!text) return text;
const codeBlockPattern = /```\n?([\s\S]*?)\n?```/g;
return text.replace(codeBlockPattern, (match, codeContent: string) => {
// Check if this code block contains tree characters but NOT box diagrams
const hasTreeChars = /[├└│]──/.test(codeContent) || /\s+└──/.test(codeContent);
const hasBoxChars = /[┌┐]/.test(codeContent); // Only top box corners indicate box diagram
if (!hasTreeChars || hasBoxChars) {
// Not a tree diagram (or is a box diagram), skip
return match;
}
const lines = codeContent.split('\n').filter(l => l.trim());
if (lines.length === 0) return match;
// First pass: detect unique indentation levels to build proper hierarchy
const indentLevels = new Set<number>();
for (const line of lines) {
const leadingSpaces = line.match(/^(\s*)/)?.[1]?.length || 0;
indentLevels.add(leadingSpaces);
}
// Sort indent levels to map them to 0, 1, 2, 3...
const sortedIndents = Array.from(indentLevels).sort((a, b) => a - b);
const indentMap = new Map<number, number>();
sortedIndents.forEach((indent, index) => indentMap.set(indent, index));
// Build tree structure
let html = '<div class="linkml-tree-diagram">';
for (const line of lines) {
// Get actual indent level from map
const leadingSpaces = line.match(/^(\s*)/)?.[1]?.length || 0;
const indentLevel = indentMap.get(leadingSpaces) || 0;
// Check for tree branch characters
const hasBranch = /├──|└──/.test(line);
const isLastBranch = /└──/.test(line);
const hasVerticalLine = /│/.test(line) && !/[├└]/.test(line);
// Check if this line is highlighted (THIS CLASS, THIS TYPE, etc.)
const isHighlighted = /\(THIS\s*(CLASS|TYPE|LEVEL)?\)/.test(line);
// Extract the content after tree characters
let content = line
.replace(/^[\s│├└─]+/, '') // Remove leading spaces and tree chars
.replace(/→/g, '<span class="linkml-tree-arrow">→</span>') // Style arrows
.trim();
// Clean up and add highlighting
if (isHighlighted) {
content = content.replace(/\(THIS\s*(CLASS|TYPE|LEVEL)?\)/g, '');
content = `<span class="linkml-tree-highlight">${content.trim()}</span>`;
}
// Skip empty vertical connector lines
if (hasVerticalLine && !content) {
html += `<div class="linkml-tree-connector" style="margin-left: ${indentLevel * 1.5}rem;">│</div>`;
continue;
}
// Determine the branch character to display
let branchChar = '';
if (isLastBranch) {
branchChar = '<span class="linkml-tree-branch">└──</span>';
} else if (hasBranch) {
branchChar = '<span class="linkml-tree-branch">├──</span>';
}
if (content) {
const itemClass = isHighlighted ? 'linkml-tree-item linkml-tree-item--highlighted' : 'linkml-tree-item';
html += `<div class="${itemClass}" style="margin-left: ${indentLevel * 1.5}rem;">${branchChar}${content}</div>`;
}
}
html += '</div>';
return html;
});
};
/**
* Arrow-based Flow Diagram transformer.
* Converts simple arrow-based flow diagrams (using ↓ → ← characters) into styled HTML.
*
* Detects patterns like:
* ```
* Current Archive (active use)
* ↓
* DEPOSIT ARCHIVE (semi-current) ← THIS TYPE
* ↓
* Historical Archive (permanent preservation)
* or
* Destruction (per retention schedule)
* ```
*
* Or horizontal flows:
* ```
* DepositArchive (custodian type)
* │
* └── operates_storage → Storage (facility instance)
* │
* └── has_storage_type → StorageType
* └── DEPOSIT_STORAGE
* ```
*/
const transformFlowDiagrams = (text: string): string => {
if (!text) return text;
const codeBlockPattern = /```\n?([\s\S]*?)\n?```/g;
return text.replace(codeBlockPattern, (match, codeContent: string) => {
// Skip if it has tree branch characters (├── └──) - handled by tree transformer
const hasTreeChars = /[├└]──/.test(codeContent);
// Skip if it has box characters (┌─┐) - handled by lifecycle transformer
const hasBoxChars = /[┌┐]/.test(codeContent);
if (hasTreeChars || hasBoxChars) {
return match; // Let other transformers handle these
}
// Check if this is an arrow-based flow diagram
// Must have vertical arrows (↓) or be a simple vertical flow with indented items
const hasVerticalArrows = /↓/.test(codeContent);
const hasHorizontalArrows = /[←→]/.test(codeContent);
const hasVerticalPipe = /│/.test(codeContent);
// Must have at least arrows to be considered a flow diagram
if (!hasVerticalArrows && !hasHorizontalArrows && !hasVerticalPipe) {
return match;
}
const lines = codeContent.split('\n');
const elements: Array<{
type: 'node' | 'arrow' | 'branch' | 'connector';
content: string;
annotation?: string;
isHighlighted?: boolean;
indentLevel?: number;
}> = [];
// Detect indent levels
const indentLevels = new Set<number>();
for (const line of lines) {
if (line.trim()) {
const leadingSpaces = line.match(/^(\s*)/)?.[1]?.length || 0;
indentLevels.add(leadingSpaces);
}
}
const sortedIndents = Array.from(indentLevels).sort((a, b) => a - b);
const indentMap = new Map<number, number>();
sortedIndents.forEach((indent, index) => indentMap.set(indent, index));
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
const leadingSpaces = line.match(/^(\s*)/)?.[1]?.length || 0;
const indentLevel = indentMap.get(leadingSpaces) || 0;
// Check for pure arrow lines
if (/^[↓⬇]+$/.test(trimmedLine)) {
elements.push({ type: 'arrow', content: '↓', indentLevel });
continue;
}
// Check for pure vertical connector lines
if (/^│+$/.test(trimmedLine)) {
elements.push({ type: 'connector', content: '│', indentLevel });
continue;
}
// Check for "or" / "and" branching keywords
if (/^(or|and|OR|AND)$/i.test(trimmedLine)) {
elements.push({ type: 'branch', content: trimmedLine.toLowerCase(), indentLevel });
continue;
}
// This is a node - check for highlighting markers and annotations
let content = trimmedLine;
let annotation = '';
let isHighlighted = false;
// Check for THIS TYPE/CLASS markers
if (/←\s*(THIS\s*(TYPE|CLASS)?|DEZE\s*(TYPE|KLASSE)?)/i.test(content)) {
isHighlighted = true;
// Extract the marker as annotation
const markerMatch = content.match(/←\s*(THIS\s*(TYPE|CLASS)?|DEZE\s*(TYPE|KLASSE)?)/i);
if (markerMatch) {
annotation = markerMatch[1];
content = content.replace(/\s*←\s*(THIS\s*(TYPE|CLASS)?|DEZE\s*(TYPE|KLASSE)?)/gi, '');
}
}
// Check for parenthetical annotations like "(active use)"
const parenMatch = content.match(/\(([^)]+)\)\s*$/);
if (parenMatch && !isHighlighted) {
annotation = parenMatch[1];
content = content.replace(/\s*\([^)]+\)\s*$/, '');
}
// Clean up any remaining tree characters that might have slipped through
content = content.replace(/^[│\s]+/, '').trim();
if (content) {
elements.push({
type: 'node',
content,
annotation: annotation || undefined,
isHighlighted,
indentLevel
});
}
}
// If we didn't parse meaningful elements, return original
if (elements.length < 2) {
return match;
}
// Build HTML output
let html = '<div class="linkml-flow-diagram">';
for (const element of elements) {
const indentStyle = element.indentLevel ? ` style="margin-left: ${element.indentLevel * 1.5}rem;"` : '';
if (element.type === 'node') {
const highlightClass = element.isHighlighted ? ' linkml-flow-node--highlighted' : '';
html += `<div class="linkml-flow-node${highlightClass}"${indentStyle}>`;
html += `<span class="linkml-flow-node__content">${element.content}</span>`;
if (element.annotation) {
html += `<span class="linkml-flow-node__annotation">${element.annotation}</span>`;
}
html += '</div>';
} else if (element.type === 'arrow') {
html += `<div class="linkml-flow-arrow"${indentStyle}>↓</div>`;
} else if (element.type === 'connector') {
html += `<div class="linkml-flow-connector"${indentStyle}>│</div>`;
} else if (element.type === 'branch') {
html += `<div class="linkml-flow-branch"${indentStyle}>${element.content}</div>`;
}
}
html += '</div>';
return html;
});
};
/**
* ASCII Lifecycle Diagram transformer.
* Converts ASCII box diagrams (using ┌─┐│└─┘ characters) into styled HTML cards.
*
* Detects patterns like:
* ```
* ┌─────────────────────────────────────┐
* │ CustodianAdministration │
* │ ═════════════════════════ │
* │ ACTIVE records in daily use │
* └─────────────────────────────────────┘
* ↓
* ┌─────────────────────────────────────┐
* │ CustodianArchive │
* └─────────────────────────────────────┘
* ```
*
* And converts them to styled HTML cards with flow arrows.
*/
const transformLifecycleDiagrams = (text: string): string => {
if (!text) return text;
// First transform tree diagrams, then flow diagrams
text = transformTreeDiagrams(text);
text = transformFlowDiagrams(text);
// Pattern to match markdown code blocks containing ASCII box diagrams
// Looks for ``` followed by content containing box-drawing characters
const codeBlockPattern = /```\n?([\s\S]*?)\n?```/g;
return text.replace(codeBlockPattern, (match, codeContent: string) => {
// Check if this code block contains ASCII box diagrams (with top corners)
const hasBoxChars = /[┌┐└┘│─═]/.test(codeContent) && /[┌┐]/.test(codeContent);
if (!hasBoxChars) {
// Not a box diagram, return original code block
return match;
}
// Parse the diagram into boxes and connectors
const lines = codeContent.split('\n');
const elements: Array<{ type: 'box' | 'arrow' | 'text'; content: string; title?: string; isHighlighted?: boolean }> = [];
let currentBox: string[] = [];
let inBox = false;
for (const line of lines) {
const trimmedLine = line.trim();
// Check for box start
if (trimmedLine.startsWith('┌') && trimmedLine.endsWith('┐')) {
inBox = true;
currentBox = [];
continue;
}
// Check for box end
if (trimmedLine.startsWith('└') && trimmedLine.endsWith('┘')) {
if (currentBox.length > 0) {
// Extract title (first non-empty line or line with ═)
let title = '';
let content: string[] = [];
let isHighlighted = false;
for (let i = 0; i < currentBox.length; i++) {
const boxLine = currentBox[i];
// Check if this is a title line (followed by ═ underline or contains "(THIS CLASS)")
if (i === 0 || boxLine.includes('═') || boxLine.includes('THIS CLASS')) {
if (boxLine.includes('═')) {
// This is an underline, title was previous line
continue;
}
if (boxLine.includes('THIS CLASS') || boxLine.includes('(THIS')) {
isHighlighted = true;
title = boxLine.replace(/\(THIS CLASS\)/g, '').replace(/\(THIS\)/g, '').trim();
} else if (!title && boxLine.trim()) {
title = boxLine.trim();
} else {
content.push(boxLine);
}
} else {
content.push(boxLine);
}
}
// Clean up title - remove leading/trailing special characters
title = title.replace(/^[═\s]+|[═\s]+$/g, '').trim();
elements.push({
type: 'box',
title: title || 'Untitled',
content: content.filter(l => l.trim() && !l.includes('═')).join('\n'),
isHighlighted
});
}
inBox = false;
continue;
}
// Inside a box - collect content
if (inBox) {
// Remove box side characters and clean up
const cleanLine = trimmedLine
.replace(/^│\s*/, '')
.replace(/\s*│$/, '')
.trim();
if (cleanLine) {
currentBox.push(cleanLine);
}
continue;
}
// Check for arrow/connector lines
if (trimmedLine.includes('↓') || trimmedLine.includes('→') || trimmedLine.includes('⬇')) {
elements.push({ type: 'arrow', content: '↓' });
continue;
}
// Check for text between boxes (descriptions of transitions)
if (trimmedLine.startsWith('(') && (trimmedLine.endsWith(')') || trimmedLine.includes(')'))) {
// This is explanatory text in parentheses
const textContent = trimmedLine.replace(/^\(|\)$/g, '').trim();
elements.push({ type: 'text', content: textContent });
continue;
}
// Other non-empty lines between boxes (transition descriptions)
if (trimmedLine && !trimmedLine.match(/^[─┌┐└┘│═]+$/)) {
// Continuation of previous text or standalone text
const lastElement = elements[elements.length - 1];
if (lastElement && lastElement.type === 'text') {
lastElement.content += ' ' + trimmedLine.replace(/^\(|\)$/g, '');
} else if (trimmedLine.length > 2) {
elements.push({ type: 'text', content: trimmedLine.replace(/^\(|\)$/g, '') });
}
}
}
// If no elements were parsed, return original
if (elements.length === 0) {
return match;
}
// Build HTML output
let html = '<div class="linkml-lifecycle-diagram">';
for (const element of elements) {
if (element.type === 'box') {
const highlightClass = element.isHighlighted ? ' linkml-lifecycle-box--highlighted' : '';
html += `<div class="linkml-lifecycle-box${highlightClass}">`;
html += `<div class="linkml-lifecycle-box__title">${element.title}</div>`;
if (element.content) {
// Convert content lines to list items or paragraphs
const contentLines = element.content.split('\n').filter(l => l.trim());
if (contentLines.length > 0) {
html += '<div class="linkml-lifecycle-box__content">';
for (const line of contentLines) {
// Check if it's a list item (starts with -)
if (line.trim().startsWith('-')) {
html += `<div class="linkml-lifecycle-box__item">${line.trim().substring(1).trim()}</div>`;
} else if (line.trim().startsWith('✅') || line.trim().startsWith('❌')) {
html += `<div class="linkml-lifecycle-box__item">${line.trim()}</div>`;
} else {
html += `<div class="linkml-lifecycle-box__line">${line}</div>`;
}
}
html += '</div>';
}
}
html += '</div>';
} else if (element.type === 'arrow') {
html += '<div class="linkml-lifecycle-arrow">↓</div>';
} else if (element.type === 'text') {
html += `<div class="linkml-lifecycle-text">${element.content}</div>`;
}
}
html += '</div>';
return html;
});
};
/**
* Combined content transformer that applies all text transformations.
* Order matters:
* 1. Lifecycle diagrams first (process code blocks before other transformations)
* 2. CURIEs (so they don't get broken by admonition spans)
* 3. Admonitions last
*/
const transformContent = (text: string): string => {
if (!text) return text;
return transformAdmonitions(highlightCuries(transformLifecycleDiagrams(text)));
};
// Debug logging helper
const debugLog = (...args: unknown[]) => {
console.log('[LinkMLViewerPage]', ...args);
};
/**
* Build a filtered UML diagram centered on a specific class.
* Includes both imports (dependencies) and exports (reverse dependencies) based on flags.
* Uses BFS to include related classes up to specified depth.
*
* @param centerClassName - The class to center the diagram on
* @param allExportInfo - Map of class name to ClassExportInfo for all loaded classes
* @param allImportInfo - Map of class name to ClassImportInfo for all loaded classes
* @param depth - How many levels of relationships to include (default: 1)
* @param parentClasses - Optional map of class to parent class from semantic info
* @param showImports - Whether to include import relationships (dependencies)
* @param showExports - Whether to include export relationships (reverse dependencies)
* @param slotSemanticLabels - Optional map of slot name to semantic predicate label (e.g., "has_collection" -> "schema:collection")
* @returns UMLDiagram with filtered nodes and links
*/
const buildFilteredUMLDiagram = (
centerClassName: string,
allExportInfo: Record<string, ClassExportInfo>,
allImportInfo: Record<string, ClassImportInfo>,
depth: number = 1,
parentClasses: Record<string, string> = {},
showImports: boolean = true,
showExports: boolean = true,
slotSemanticLabels: Map<string, string> = new Map()
): UMLDiagram => {
const nodes: UMLNode[] = [];
const links: UMLLink[] = [];
const addedNodes = new Set<string>();
const addedLinks = new Set<string>(); // Track links to avoid duplicates
const processedAtDepth = new Map<string, number>(); // Track at what depth each class was processed
// Helper to get the semantic label for a slot (falls back to slot name if not found)
const getSemanticLabel = (slotName: string): string => {
return slotSemanticLabels.get(slotName) || slotName;
};
// Helper to add a node if not already added
const addNode = (name: string, _distanceFromCenter: number, type: 'class' | 'enum' = 'class') => {
if (!addedNodes.has(name)) {
addedNodes.add(name);
nodes.push({
id: name,
name: name,
type: type,
attributes: [],
methods: [],
});
}
};
// Helper to add a link if not already added (checking both directions)
// For slot-based edges (aggregation/composition), uses semantic predicate as label
const addLink = (source: string, target: string, type: UMLLink['type'], label: string, isSlotEdge: boolean = false) => {
const linkKey1 = `${source}->${target}:${type}`;
const linkKey2 = `${target}->${source}:${type}`;
// For slot edges, use semantic predicate label; for others (inheritance, mixin), use original label
const displayLabel = isSlotEdge ? getSemanticLabel(label) : label;
// For inheritance, direction matters; for others, check both
if (type === 'inheritance') {
if (!addedLinks.has(linkKey1)) {
addedLinks.add(linkKey1);
links.push({ source, target, type, label: displayLabel });
}
} else {
if (!addedLinks.has(linkKey1) && !addedLinks.has(linkKey2)) {
addedLinks.add(linkKey1);
links.push({ source, target, type, label: displayLabel });
}
}
};
// Get all related classes based on showImports/showExports flags
const getRelatedClasses = (className: string): string[] => {
const related: string[] = [];
// IMPORTS: What this class depends on (parent, mixins, slot ranges)
if (showImports) {
const importInfo = allImportInfo[className];
if (importInfo) {
// Parent class (is_a)
if (importInfo.parentClass) {
related.push(importInfo.parentClass);
}
// Mixins this class uses
related.push(...importInfo.mixins);
// Classes/enums used as slot ranges
related.push(...importInfo.slotRanges.map(r => r.rangeType));
// Slot usage range overrides
related.push(...importInfo.slotUsageRanges.map(r => r.rangeType));
// Annotation-based relationships (dual-class pattern)
if (importInfo.linkedCollectionType) {
related.push(importInfo.linkedCollectionType);
}
if (importInfo.linkedCustodianType) {
related.push(importInfo.linkedCustodianType);
}
}
// Also check parentClasses map for inheritance
if (parentClasses[className]) {
related.push(parentClasses[className]);
}
}
// EXPORTS: What references this class (subclasses, mixin users, slot ranges)
if (showExports) {
const exportInfo = allExportInfo[className];
if (exportInfo) {
// Subclasses
related.push(...exportInfo.subclasses);
// Mixin users
related.push(...exportInfo.mixinUsers);
// Classes using slots with this range
related.push(...exportInfo.classesUsingSlotWithThisRange.map(c => c.className));
// Classes referencing in slot_usage
related.push(...exportInfo.classesReferencingInSlotUsage);
// Annotation-based relationships (dual-class pattern) - collection types that reference this custodian
related.push(...exportInfo.linkedCollectionTypes);
}
}
return [...new Set(related)]; // Dedupe
};
// Add relationships from a class based on showImports/showExports flags
const addRelationshipsFromClass = (className: string) => {
// IMPORTS: Show what this class depends on
if (showImports) {
const importInfo = allImportInfo[className];
if (importInfo) {
// Parent class (is_a inheritance) - arrow points from child to parent
if (importInfo.parentClass && addedNodes.has(importInfo.parentClass)) {
addLink(className, importInfo.parentClass, 'inheritance', 'is_a', false);
}
// Mixins - arrow points from class to mixin
for (const mixin of importInfo.mixins) {
if (addedNodes.has(mixin)) {
addLink(className, mixin, 'association', 'mixin', false);
}
}
// Slot ranges - arrow points from class to range type (SLOT EDGE - use semantic predicate)
for (const { slotName, rangeType } of importInfo.slotRanges) {
if (addedNodes.has(rangeType)) {
addLink(className, rangeType, 'aggregation', slotName, true);
}
}
// Slot usage ranges (SLOT EDGE - use semantic predicate)
for (const { slotName, rangeType } of importInfo.slotUsageRanges) {
if (addedNodes.has(rangeType)) {
// For slot_usage, append "(usage)" to semantic label
const semanticLabel = getSemanticLabel(slotName);
addLink(className, rangeType, 'aggregation', `${semanticLabel} (usage)`, false);
}
}
// Annotation-based relationships (dual-class pattern)
// linked_collection_type - custodian type → collection type
if (importInfo.linkedCollectionType && addedNodes.has(importInfo.linkedCollectionType)) {
addLink(className, importInfo.linkedCollectionType, 'association', 'linked_collection_type', false);
}
// linked_custodian_type - collection type → custodian type
if (importInfo.linkedCustodianType && addedNodes.has(importInfo.linkedCustodianType)) {
addLink(className, importInfo.linkedCustodianType, 'association', 'linked_custodian_type', false);
}
}
// Also check parentClasses map for inheritance
if (parentClasses[className] && addedNodes.has(parentClasses[className])) {
addLink(className, parentClasses[className], 'inheritance', 'is_a', false);
}
}
// EXPORTS: Show what references this class
if (showExports) {
const exportInfo = allExportInfo[className];
if (exportInfo) {
// Subclasses - arrow points from subclass to this class
for (const subclass of exportInfo.subclasses) {
if (addedNodes.has(subclass)) {
addLink(subclass, className, 'inheritance', 'is_a', false);
}
}
// Mixin users - arrow points from user to this class
for (const mixinUser of exportInfo.mixinUsers) {
if (addedNodes.has(mixinUser)) {
addLink(mixinUser, className, 'association', 'mixin', false);
}
}
// Classes using slots with this range (SLOT EDGE - use semantic predicate)
for (const { className: usingClass, slotName } of exportInfo.classesUsingSlotWithThisRange) {
if (addedNodes.has(usingClass)) {
addLink(usingClass, className, 'aggregation', slotName, true);
}
}
// Classes referencing in slot_usage
for (const refClass of exportInfo.classesReferencingInSlotUsage) {
if (addedNodes.has(refClass)) {
addLink(refClass, className, 'association', 'slot_usage', false);
}
}
// Annotation-based relationships (dual-class pattern)
// Collection types that have linked_custodian_type pointing to this class
for (const collectionType of exportInfo.linkedCollectionTypes) {
if (addedNodes.has(collectionType)) {
addLink(collectionType, className, 'association', 'linked_custodian_type', false);
}
}
}
}
};
// BFS to add nodes up to specified depth
const queue: Array<{ className: string; currentDepth: number }> = [
{ className: centerClassName, currentDepth: 0 }
];
while (queue.length > 0) {
const { className, currentDepth } = queue.shift()!;
// Skip if already processed at same or lower depth
const existingDepth = processedAtDepth.get(className);
if (existingDepth !== undefined && existingDepth <= currentDepth) {
continue;
}
processedAtDepth.set(className, currentDepth);
// Add node
addNode(className, currentDepth);
// If we haven't reached max depth, queue related classes
if (currentDepth < depth) {
const related = getRelatedClasses(className);
for (const relatedClass of related) {
// Only queue if not already processed at a lower depth
const relDepth = processedAtDepth.get(relatedClass);
if (relDepth === undefined || relDepth > currentDepth + 1) {
queue.push({ className: relatedClass, currentDepth: currentDepth + 1 });
}
}
}
}
// Now add all links between added nodes
for (const className of addedNodes) {
addRelationshipsFromClass(className);
}
// Debug logging to understand link generation
debugLog(`[buildFilteredUMLDiagram] Building diagram for ${centerClassName}, depth=${depth}`);
debugLog(`[buildFilteredUMLDiagram] showImports=${showImports}, showExports=${showExports}`);
debugLog(`[buildFilteredUMLDiagram] Total nodes: ${nodes.length}`, nodes.map(n => n.name));
debugLog(`[buildFilteredUMLDiagram] Total links: ${links.length}`);
// Log link breakdown by type
const linksByType = {
inheritance: links.filter(l => l.type === 'inheritance'),
aggregation: links.filter(l => l.type === 'aggregation'),
association: links.filter(l => l.type === 'association'),
composition: links.filter(l => l.type === 'composition'),
};
debugLog(`[buildFilteredUMLDiagram] Links by type:`, {
inheritance: linksByType.inheritance.length,
aggregation: linksByType.aggregation.length,
association: linksByType.association.length,
composition: linksByType.composition.length,
});
// Log the actual import info to understand why slot ranges might be missing
const centerImportInfo = allImportInfo[centerClassName];
if (centerImportInfo) {
debugLog(`[buildFilteredUMLDiagram] Center class import info:`, {
parentClass: centerImportInfo.parentClass,
mixins: centerImportInfo.mixins,
slotRanges: centerImportInfo.slotRanges,
slotUsageRanges: centerImportInfo.slotUsageRanges,
linkedCollectionType: centerImportInfo.linkedCollectionType,
linkedCustodianType: centerImportInfo.linkedCustodianType,
});
} else {
debugLog(`[buildFilteredUMLDiagram] WARNING: No import info for center class ${centerClassName}`);
}
return {
nodes,
links,
title: `${centerClassName} Relationships (Depth: ${depth})`,
};
};
// Bilingual text content
const TEXT = {
sidebarTitle: { nl: 'LinkML-schema\'s', en: 'LinkML Schemas' },
pageTitle: { nl: 'LinkML-schemaviewer', en: 'LinkML Schema Viewer' },
visualView: { nl: 'Visuele weergave', en: 'Visual View' },
rawYaml: { nl: 'Ruwe YAML', en: 'Raw YAML' },
loading: { nl: 'Schema laden...', en: 'Loading schema...' },
noSchemasFound: { nl: 'Geen schema\'s gevonden. Zorg dat het manifest is gegenereerd.', en: 'No schemas found. Make sure the manifest is generated.' },
failedToInit: { nl: 'Initialiseren van schemalijst mislukt', en: 'Failed to initialize schema list' },
failedToLoad: { nl: 'Schema laden mislukt:', en: 'Failed to load schema:' },
unnamedSchema: { nl: 'Naamloos schema', en: 'Unnamed Schema' },
prefixes: { nl: 'Prefixen', en: 'Prefixes' },
classes: { nl: 'Klassen', en: 'Classes' },
slots: { nl: 'Slots', en: 'Slots' },
enumerations: { nl: 'Enumeraties', en: 'Enumerations' },
imports: { nl: 'Imports', en: 'Imports' },
abstract: { nl: 'abstract', en: 'abstract' },
required: { nl: 'verplicht', en: 'required' },
multivalued: { nl: 'meervoudig', en: 'multivalued' },
uri: { nl: 'URI:', en: 'URI:' },
id: { nl: 'ID:', en: 'ID:' },
version: { nl: 'Versie:', en: 'Version:' },
range: { nl: 'Bereik:', en: 'Range:' },
pattern: { nl: 'Patroon:', en: 'Pattern:' },
slotsLabel: { nl: 'Slots:', en: 'Slots:' },
exactMappings: { nl: 'Exacte mappings:', en: 'Exact Mappings:' },
closeMappings: { nl: 'Vergelijkbare mappings:', en: 'Close Mappings:' },
permissibleValues: { nl: 'Toegestane waarden:', en: 'Permissible Values:' },
meaning: { nl: 'betekenis:', en: 'meaning:' },
searchPlaceholder: { nl: 'Zoeken in waarden...', en: 'Search values...' },
showAll: { nl: 'Alles tonen', en: 'Show all' },
showLess: { nl: 'Minder tonen', en: 'Show less' },
showing: { nl: 'Toont', en: 'Showing' },
of: { nl: 'van', en: 'of' },
noResults: { nl: 'Geen resultaten gevonden', en: 'No results found' },
searchSchemas: { nl: 'Zoeken in schema\'s...', en: 'Search schemas...' },
mainSchema: { nl: 'Hoofdschema', en: 'Main Schema' },
noMatchingSchemas: { nl: 'Geen overeenkomende schema\'s', en: 'No matching schemas' },
copyToClipboard: { nl: 'Kopieer naar klembord', en: 'Copy to clipboard' },
copied: { nl: 'Gekopieerd!', en: 'Copied!' },
use3DPolygon: { nl: '3D-polygoon', en: '3D Polygon' },
use2DBadge: { nl: '2D-badge', en: '2D Badge' },
// Class-level imports (dependencies)
classImports: { nl: 'Imports', en: 'Imports' },
classImportsTooltip: {
nl: 'Toont welke klassen en types deze klasse als afhankelijkheden gebruikt (ouderklasse, mixins, slot ranges)',
en: 'Shows what classes and types this class depends on (parent class, mixins, slot ranges)'
},
parentClassLabel: { nl: 'Ouderklasse (is_a)', en: 'Parent Class (is_a)' },
mixinsLabel: { nl: 'Mixins', en: 'Mixins' },
slotRangesLabel: { nl: 'Slot Ranges', en: 'Slot Ranges' },
slotUsageRangesLabel: { nl: 'Slot Usage Ranges', en: 'Slot Usage Ranges' },
noImports: { nl: 'Deze klasse heeft geen afhankelijkheden van andere klassen.', en: 'This class has no dependencies on other classes.' },
// File-level imports tooltip (to differentiate from class-level)
fileImportsTooltip: {
nl: 'Toont welke LinkML-modules dit schemabestand importeert op bestandsniveau',
en: 'Shows which LinkML modules this schema file imports at file level'
},
// UML diagram direction toggles
umlShowImports: { nl: 'Imports tonen', en: 'Show Imports' },
umlShowExports: { nl: 'Exports tonen', en: 'Show Exports' },
umlImportsTooltip: { nl: 'Toon afhankelijkheden (ouderklasse, mixins, slot ranges)', en: 'Show dependencies (parent class, mixins, slot ranges)' },
umlExportsTooltip: { nl: 'Toon verwijzingen naar deze klasse (subklassen, mixin-gebruikers)', en: 'Show references to this class (subclasses, mixin users)' },
umlNoRelationshipsShown: {
nl: 'Selecteer minimaal één richting (Imports of Exports) om relaties te tonen.',
en: 'Select at least one direction (Imports or Exports) to show relationships.'
},
umlNoRelationshipsFound: {
nl: 'Deze klasse heeft geen relaties in de geselecteerde richting(en).',
en: 'This class has no relationships in the selected direction(s).'
},
// Sidebar collapse
collapseSidebar: { nl: 'Zijbalk inklappen', en: 'Collapse sidebar' },
expandSidebar: { nl: 'Zijbalk uitklappen', en: 'Expand sidebar' },
// Class slots accordion
classSlots: { nl: 'Slots', en: 'Slots' },
classSlotsTooltip: {
nl: 'Toont alle slots (eigenschappen) die gedefinieerd zijn voor deze klasse',
en: 'Shows all slots (properties) defined for this class'
},
noClassSlots: { nl: 'Deze klasse heeft geen slots.', en: 'This class has no slots.' },
// Slot details - semantic URI and mappings
slotUri: { nl: 'Slot URI:', en: 'Slot URI:' },
identifier: { nl: 'identifier', en: 'identifier' },
examples: { nl: 'Voorbeelden:', en: 'Examples:' },
narrowMappings: { nl: 'Specifiekere mappings:', en: 'Narrow Mappings:' },
broadMappings: { nl: 'Bredere mappings:', en: 'Broad Mappings:' },
relatedMappings: { nl: 'Gerelateerde mappings:', en: 'Related Mappings:' },
comments: { nl: 'Opmerkingen:', en: 'Comments:' },
// Slot usage indicator
slotUsageBadge: { nl: 'Slot Usage', en: 'Slot Usage' },
slotUsageTooltip: {
nl: 'Deze slot heeft klasse-specifieke overschrijvingen gedefinieerd in slot_usage. Eigenschappen met ✦ komen uit de slot_usage, andere zijn overgenomen van de generieke slot-definitie. Zie: https://linkml.io/linkml-model/latest/docs/slot_usage/',
en: 'This slot has class-specific overrides defined in slot_usage. Properties marked with ✦ come from slot_usage, others are inherited from the generic slot definition. See: https://linkml.io/linkml-model/latest/docs/slot_usage/'
},
slotUsageOverrideMarker: { nl: '✦', en: '✦' },
inheritedFromGeneric: { nl: 'overgenomen', en: 'inherited' },
overriddenInSlotUsage: { nl: 'overschreven in Slot Usage', en: 'overridden in Slot Usage' },
// Slot usage legend
slotUsageLegendTitle: { nl: 'Slot Usage Legenda', en: 'Slot Usage Legend' },
slotUsageLegendToggle: { nl: 'Legenda', en: 'Legend' },
slotUsageLegendBadge: {
nl: 'Slots met dit badge hebben klasse-specifieke overschrijvingen',
en: 'Slots with this badge have class-specific overrides'
},
slotUsageLegendMarker: {
nl: 'Dit symbool verschijnt vóór eigenschappen die overschreven zijn in Slot Usage',
en: 'This symbol appears before properties overridden in Slot Usage'
},
slotUsageLegendBorder: {
nl: 'Groene linkerkant geeft aan dat de slot Slot Usage overschrijvingen heeft',
en: 'Green left border indicates the slot has Slot Usage overrides'
},
slotUsageLegendInherited: {
nl: 'Eigenschappen zonder ✦ zijn overgenomen van de generieke slot-definitie',
en: 'Properties without ✦ are inherited from the generic slot definition'
},
// Slot usage comparison view
slotUsageCompareToggle: { nl: 'Vergelijk', en: 'Compare' },
slotUsageCompareTooltip: {
nl: 'Toon generieke definitie naast Slot Usage overschrijvingen',
en: 'Show generic definition alongside Slot Usage overrides'
},
slotUsageCompareGeneric: { nl: 'Generieke Definitie', en: 'Generic Definition' },
slotUsageCompareOverride: { nl: 'Slot Usage Overschrijving', en: 'Slot Usage Override' },
slotUsageCompareDiff: { nl: 'gewijzigd', en: 'changed' },
slotUsageCompareInherited: { nl: '(overgenomen)', en: '(inherited)' },
slotUsageCompareNotDefined: { nl: '(niet gedefinieerd)', en: '(not defined)' },
// Settings menu
settings: { nl: 'Instellingen', en: 'Settings' },
settingsTooltip: { nl: 'Weergave-instellingen', en: 'Display settings' },
viewModeSection: { nl: 'Weergavemodus', en: 'View Mode' },
displayOptions: { nl: 'Weergaveopties', en: 'Display Options' },
moduleFilters: { nl: 'Modulefilters', en: 'Module Filters' },
// Developer menu
developerTools: { nl: 'Ontwikkelaartools', en: 'Developer Tools' },
developerToolsTooltip: { nl: 'Technische details voor ontwikkelaars', en: 'Technical details for developers' },
// Copy functionality
copyUri: { nl: 'URI kopiëren', en: 'Copy URI' },
uriCopied: { nl: 'URI gekopieerd!', en: 'URI copied!' },
// Schema file name tooltip
linkmlFileNameTooltip: {
nl: 'Dit is de LinkML-bestandsnaam. Deze komt vaak overeen met de klassenaam, maar niet altijd. Een bestand kan meerdere klassen, enumeraties of slots bevatten.',
en: 'This is the LinkML file name. It often corresponds to the class name, but not always. A single file can contain multiple classes, enumerations, or slots.'
},
// Entry count labels (full words for clarity)
entryCountClasses: { nl: ' klassen', en: ' classes' },
entryCountEnums: { nl: ' enums', en: ' enums' },
entryCountSlots: { nl: ' slots', en: ' slots' },
// Content search bar
contentSearchPlaceholder: { nl: 'Zoeken in inhoud...', en: 'Search in content...' },
nameSearchPlaceholder: { nl: 'Zoeken op naam...', en: 'Search by name...' },
searchModeNames: { nl: 'Namen', en: 'Names' },
searchModeContent: { nl: 'Inhoud', en: 'Content' },
noSearchResults: { nl: 'Geen resultaten voor zoekopdracht', en: 'No results for search' },
clearSearch: { nl: 'Zoekopdracht wissen', en: 'Clear search' },
// Slot exports (classes using this slot)
exports: { nl: 'Exports', en: 'Exports' },
classesUsingSlot: { nl: 'Klassen die deze slot gebruiken', en: 'Classes using this slot' },
classesWithSlotUsage: { nl: 'Klassen met slot_usage overschrijvingen', en: 'Classes with slot_usage overrides' },
noClassesUsingSlot: { nl: 'Geen klassen gebruiken deze slot.', en: 'No classes use this slot.' },
// Slot imports/exports (dependencies)
slotImports: { nl: 'Imports', en: 'Imports' },
slotImportsTooltip: {
nl: 'Toont welke klassen en enumeraties deze slot als range type gebruikt',
en: 'Shows what classes and enums this slot depends on as range type'
},
slotExports: { nl: 'Exports', en: 'Exports' },
slotExportsTooltip: {
nl: 'Toont welke klassen deze slot gebruiken',
en: 'Shows what classes use this slot'
},
rangeTypeLabel: { nl: 'Range Type', en: 'Range Type' },
anyOfTypesLabel: { nl: 'Any Of Types', en: 'Any Of Types' },
noSlotImports: { nl: 'Deze slot heeft geen afhankelijkheden van klassen of enumeraties.', en: 'This slot has no dependencies on classes or enums.' },
};
// Dynamically discover schema files from the modules directory
interface SchemaCategory {
name: string;
displayName: string;
files: SchemaFile[];
}
const LinkMLViewerPage: React.FC = () => {
const { language } = useLanguage();
const t = (key: keyof typeof TEXT) => TEXT[key][language];
const [searchParams, setSearchParams] = useSearchParams();
const [categories, setCategories] = useState<SchemaCategory[]>([]);
const [selectedSchema, setSelectedSchema] = useState<SchemaFile | null>(null);
const [schema, setSchema] = useState<LinkMLSchema | null>(null);
const [rawYaml, setRawYaml] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'visual' | 'raw'>('visual');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['classes', 'enums', 'slots']));
const [highlightedClass, setHighlightedClass] = useState<string | null>(null);
const highlightedRef = useRef<HTMLDivElement>(null);
// State for expandable enum ranges in slots
const [expandedEnumRanges, setExpandedEnumRanges] = useState<Set<string>>(new Set());
const [loadedEnums, setLoadedEnums] = useState<Record<string, LinkMLEnum | null>>({});
const [enumSearchFilters, setEnumSearchFilters] = useState<Record<string, string>>({});
const [enumShowAll, setEnumShowAll] = useState<Record<string, boolean>>({});
// State for pre-loaded custodian types (loaded async from schema annotations)
// Maps element name -> custodian type codes
const [classCustodianTypes, setClassCustodianTypes] = useState<Record<string, CustodianTypeCode[]>>({});
const [slotCustodianTypes, setSlotCustodianTypes] = useState<Record<string, CustodianTypeCode[]>>({});
const [enumCustodianTypes, setEnumCustodianTypes] = useState<Record<string, CustodianTypeCode[]>>({});
const [_custodianTypesLoaded, setCustodianTypesLoaded] = useState(false);
// State for sidebar search and category filters
// Category filters are persisted in localStorage
const [sidebarSearch, setSidebarSearch] = useState<string>('');
const [categoryFilters, setCategoryFilters] = useState<Record<string, boolean>>(() => {
try {
const saved = localStorage.getItem('linkml-viewer-category-filters');
if (saved) {
return JSON.parse(saved);
}
} catch {
// Ignore parse errors
}
// Default: main OFF (confusing), others ON
return {
main: false,
class: true,
enum: true,
slot: true,
};
});
// Persist category filters to localStorage
useEffect(() => {
try {
localStorage.setItem('linkml-viewer-category-filters', JSON.stringify(categoryFilters));
} catch {
// Ignore localStorage errors (e.g., private browsing)
}
}, [categoryFilters]);
// State for sidebar collapse
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(false);
// State for copy to clipboard feedback
const [copyFeedback, setCopyFeedback] = useState(false);
// State for expandable Exports section in class details
const [expandedExports, setExpandedExports] = useState<Set<string>>(new Set());
const [classExports, setClassExports] = useState<Record<string, ClassExportInfo>>({});
const [loadingExports, setLoadingExports] = useState<Set<string>>(new Set());
// State for expandable Imports section in class details (dependencies)
const [expandedImports, setExpandedImports] = useState<Set<string>>(new Set());
const [classImports, setClassImports] = useState<Record<string, ClassImportInfo>>({});
const [loadingImports, setLoadingImports] = useState<Set<string>>(new Set());
// State for expandable Exports section in slot details (which classes use this slot)
const [expandedSlotExports, setExpandedSlotExports] = useState<Set<string>>(new Set());
const [slotExports, setSlotExports] = useState<Record<string, SlotExportInfo>>({});
const [loadingSlotExports, setLoadingSlotExports] = useState<Set<string>>(new Set());
// State for expandable Imports section in slot details (what this slot depends on)
const [expandedSlotImports, setExpandedSlotImports] = useState<Set<string>>(new Set());
const [slotImports, setSlotImports] = useState<Record<string, SlotImportInfo>>({});
const [loadingSlotImports, setLoadingSlotImports] = useState<Set<string>>(new Set());
// State for expandable UML diagram section in class details
const [expandedUML, setExpandedUML] = useState<Set<string>>(new Set());
// State for expandable class slots section in class details
const [expandedClassSlots, setExpandedClassSlots] = useState<Set<string>>(new Set());
// State for pre-loaded dependency counts (imports/exports) for all classes
// This allows showing counts immediately without loading full import/export data
const [dependencyCounts, setDependencyCounts] = useState<Map<string, ClassDependencyCounts>>(new Map());
// State for UML depth slider per class (1 = direct relationships only, 2 = secondary, etc.)
const [umlDepth, setUmlDepth] = useState<Record<string, number>>({});
// State for UML direction toggles per class (show imports, exports, or both)
// Default: both enabled (true)
const [umlShowImports, setUmlShowImports] = useState<Record<string, boolean>>({});
const [umlShowExports, setUmlShowExports] = useState<Record<string, boolean>>({});
// State for UML layout type per class
const [umlLayoutType, setUmlLayoutType] = useState<Record<string, 'dagre' | 'force' | 'circular' | 'radial'>>({});
const [umlDagreDirection, setUmlDagreDirection] = useState<Record<string, 'TB' | 'LR'>>({});
// State for UML fullscreen mode per class
const [umlFullscreen, setUmlFullscreen] = useState<string | null>(null);
// State for layout dropdown open per class
const [umlLayoutDropdownOpen, setUmlLayoutDropdownOpen] = useState<Record<string, boolean>>({});
// State for parent class mapping (className -> parentClassName from is_a inheritance)
// Built incrementally as classes are loaded for UML diagrams
const [parentClassMap, setParentClassMap] = useState<Record<string, string>>({});
// State for service-loaded slot definitions (from getAllSlots)
// Used to resolve slot names to full definitions in class details
const [serviceSlots, setServiceSlots] = useState<Map<string, SlotDefinition>>(new Map());
// State for slot semantic labels (slot name -> semantic predicate like "schema:collection")
// Computed from serviceSlots when available
const [slotSemanticLabels, setSlotSemanticLabels] = useState<Map<string, string>>(new Map());
// State for 3D polygon indicator toggle
// Persisted in localStorage - defaults to false (2D SVG) if not set
const [use3DIndicator, setUse3DIndicator] = useState(() => {
try {
const saved = localStorage.getItem('linkml-viewer-use-3d');
return saved === 'true';
} catch {
return false;
}
});
// Persist 3D mode preference to localStorage
useEffect(() => {
try {
localStorage.setItem('linkml-viewer-use-3d', use3DIndicator ? 'true' : 'false');
} catch {
// Ignore localStorage errors (e.g., private browsing)
}
}, [use3DIndicator]);
// State for bidirectional hover sync between 3D polyhedrons and legend bar
const [hoveredCustodianType, setHoveredCustodianType] = useState<CustodianTypeCode | null>(null);
// State for slot_usage legend visibility toggle
// Persisted in localStorage
const [showSlotUsageLegend, setShowSlotUsageLegend] = useState(() => {
try {
const saved = localStorage.getItem('linkml-viewer-show-legend');
return saved === 'true';
} catch {
return false;
}
});
// Persist legend visibility to localStorage
useEffect(() => {
try {
localStorage.setItem('linkml-viewer-show-legend', showSlotUsageLegend ? 'true' : 'false');
} catch {
// Ignore localStorage errors (e.g., private browsing)
}
}, [showSlotUsageLegend]);
// State for Settings menu visibility
const [showSettingsMenu, setShowSettingsMenu] = useState(false);
// State for content search within the currently viewed schema
// searchMode: 'name' = search class/enum/slot names only, 'content' = search all text content
const [contentSearch, setContentSearch] = useState<string>('');
const [searchMode, setSearchMode] = useState<'name' | 'content'>('name');
// State for Developer tools menu visibility
// Persisted in localStorage
const [showDeveloperTools, setShowDeveloperTools] = useState(() => {
try {
const saved = localStorage.getItem('linkml-viewer-show-devtools');
return saved === 'true';
} catch {
return false;
}
});
// Persist developer tools visibility to localStorage
useEffect(() => {
try {
localStorage.setItem('linkml-viewer-show-devtools', showDeveloperTools ? 'true' : 'false');
} catch {
// Ignore localStorage errors (e.g., private browsing)
}
}, [showDeveloperTools]);
// State for sidebar category collapse (Classes, Enums, Slots headers)
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
// State for URI copy feedback (separate from YAML copy)
const [uriCopyFeedback, setUriCopyFeedback] = useState(false);
// State for slot_usage comparison view - tracks which slots have comparison expanded
// Key format: "className:slotName"
const [expandedSlotComparisons, setExpandedSlotComparisons] = useState<Set<string>>(new Set());
// Toggle comparison view for a specific slot
const toggleSlotComparison = (className: string, slotName: string) => {
const key = `${className}:${slotName}`;
setExpandedSlotComparisons(prev => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
// State for filtering schema elements by custodian type (click on polyhedron face or legend)
// Multi-select: Set of selected type codes (empty = no filter)
// Initialize from URL params (e.g., ?custodian=G,L,A)
const [custodianTypeFilter, setCustodianTypeFilter] = useState<Set<CustodianTypeCode>>(() => {
const param = searchParams.get('custodian');
if (!param) return new Set();
const codes = param.split(',').filter((c): c is CustodianTypeCode =>
CUSTODIAN_TYPE_CODES.includes(c as CustodianTypeCode)
);
return new Set(codes);
});
// State for ontology term popup (shows when clicking mapping tags like rico:Rule)
const [ontologyPopupCurie, setOntologyPopupCurie] = useState<string | null>(null);
// State for schema element popup (shows when clicking class/slot/enum links in Imports/Exports sections)
const [schemaElementPopup, setSchemaElementPopup] = useState<{
name: string;
type: SchemaElementType;
slotName?: string;
overrides?: string[];
} | null>(null);
// Sync custodian filter to URL params
useEffect(() => {
const currentParam = searchParams.get('custodian');
const filterCodes = Array.from(custodianTypeFilter).sort().join(',');
// Only update if different to avoid loops
if (custodianTypeFilter.size === 0 && currentParam) {
// Remove param if filter is empty
searchParams.delete('custodian');
setSearchParams(searchParams, { replace: true });
} else if (custodianTypeFilter.size > 0 && currentParam !== filterCodes) {
searchParams.set('custodian', filterCodes);
setSearchParams(searchParams, { replace: true });
}
}, [custodianTypeFilter, searchParams, setSearchParams]);
// Ref for main content (used by navigation-synced collapsible header)
const mainContentRef = useRef<HTMLElement>(null);
// Schema loading progress tracking
const { progress: schemaProgress, isLoading: isSchemaServiceLoading, isComplete: isSchemaServiceComplete } = useSchemaLoadingProgress();
// Handler for filtering by custodian type (clicking polyhedron face or legend item)
// Multi-select toggle behavior: clicking type adds/removes from set
const handleCustodianTypeFilter = useCallback((typeCode: CustodianTypeCode) => {
setCustodianTypeFilter(prev => {
const newSet = new Set(prev);
if (newSet.has(typeCode)) {
newSet.delete(typeCode);
} else {
newSet.add(typeCode);
}
return newSet;
});
}, []);
// Toggle exports section for a class and load export data on demand
const toggleExports = useCallback(async (className: string) => {
// Toggle expansion state
setExpandedExports(prev => {
const next = new Set(prev);
if (next.has(className)) {
next.delete(className);
} else {
next.add(className);
}
return next;
});
// Load export data if not already loaded and schema service is ready
if (!classExports[className] && !loadingExports.has(className) && isSchemaServiceComplete) {
setLoadingExports(prev => new Set(prev).add(className));
try {
const exportInfo = await linkmlSchemaService.getClassExportInfo(className);
setClassExports(prev => ({ ...prev, [className]: exportInfo }));
} catch (error) {
console.error(`Error loading export info for ${className}:`, error);
} finally {
setLoadingExports(prev => {
const next = new Set(prev);
next.delete(className);
return next;
});
}
}
}, [classExports, loadingExports, isSchemaServiceComplete]);
// Toggle imports section for a class and load import data on demand
const toggleImports = useCallback(async (className: string) => {
// Toggle expansion state
setExpandedImports(prev => {
const next = new Set(prev);
if (next.has(className)) {
next.delete(className);
} else {
next.add(className);
}
return next;
});
// Load import data if not already loaded and schema service is ready
if (!classImports[className] && !loadingImports.has(className) && isSchemaServiceComplete) {
setLoadingImports(prev => new Set(prev).add(className));
try {
const importInfo = await linkmlSchemaService.getClassImportInfo(className);
setClassImports(prev => ({ ...prev, [className]: importInfo }));
} catch (error) {
console.error(`Error loading import info for ${className}:`, error);
} finally {
setLoadingImports(prev => {
const next = new Set(prev);
next.delete(className);
return next;
});
}
}
}, [classImports, loadingImports, isSchemaServiceComplete]);
// Toggle exports section for a slot and load export data on demand
const toggleSlotExports = useCallback(async (slotName: string) => {
// Toggle expansion state
setExpandedSlotExports(prev => {
const next = new Set(prev);
if (next.has(slotName)) {
next.delete(slotName);
} else {
next.add(slotName);
}
return next;
});
// Load export data if not already loaded and schema service is ready
if (!slotExports[slotName] && !loadingSlotExports.has(slotName) && isSchemaServiceComplete) {
setLoadingSlotExports(prev => new Set(prev).add(slotName));
try {
const exportInfo = await linkmlSchemaService.getSlotExportInfo(slotName);
setSlotExports(prev => ({ ...prev, [slotName]: exportInfo }));
} catch (error) {
console.error(`Error loading export info for slot ${slotName}:`, error);
} finally {
setLoadingSlotExports(prev => {
const next = new Set(prev);
next.delete(slotName);
return next;
});
}
}
}, [slotExports, loadingSlotExports, isSchemaServiceComplete]);
// Toggle imports section for a slot and load import data on demand
const toggleSlotImports = useCallback(async (slotName: string) => {
// Toggle expansion state
setExpandedSlotImports(prev => {
const next = new Set(prev);
if (next.has(slotName)) {
next.delete(slotName);
} else {
next.add(slotName);
}
return next;
});
// Load import data if not already loaded and schema service is ready
if (!slotImports[slotName] && !loadingSlotImports.has(slotName) && isSchemaServiceComplete) {
setLoadingSlotImports(prev => new Set(prev).add(slotName));
try {
const importInfo = await linkmlSchemaService.getSlotImportInfo(slotName);
setSlotImports(prev => ({ ...prev, [slotName]: importInfo }));
} catch (error) {
console.error(`Error loading import info for slot ${slotName}:`, error);
} finally {
setLoadingSlotImports(prev => {
const next = new Set(prev);
next.delete(slotName);
return next;
});
}
}
}, [slotImports, loadingSlotImports, isSchemaServiceComplete]);
// Toggle UML diagram section for a class
// Loads both exports AND imports data since UML diagram can show both directions
const toggleUML = useCallback(async (className: string) => {
setExpandedUML(prev => {
const next = new Set(prev);
if (next.has(className)) {
next.delete(className);
} else {
next.add(className);
}
return next;
});
// Load exports data if not already loaded (for "Show Exports" checkbox)
if (!classExports[className] && !loadingExports.has(className) && isSchemaServiceComplete) {
setLoadingExports(prev => new Set(prev).add(className));
try {
const exportInfo = await linkmlSchemaService.getClassExportInfo(className);
setClassExports(prev => ({ ...prev, [className]: exportInfo }));
} catch (error) {
console.error(`Error loading export info for ${className}:`, error);
} finally {
setLoadingExports(prev => {
const next = new Set(prev);
next.delete(className);
return next;
});
}
}
// Load imports data if not already loaded (for "Show Imports" checkbox)
if (!classImports[className] && !loadingImports.has(className) && isSchemaServiceComplete) {
setLoadingImports(prev => new Set(prev).add(className));
try {
const importInfo = await linkmlSchemaService.getClassImportInfo(className);
setClassImports(prev => ({ ...prev, [className]: importInfo }));
} catch (error) {
console.error(`Error loading import info for ${className}:`, error);
} finally {
setLoadingImports(prev => {
const next = new Set(prev);
next.delete(className);
return next;
});
}
}
}, [classExports, classImports, loadingExports, loadingImports, isSchemaServiceComplete]);
// Toggle class slots accordion section
const toggleClassSlots = useCallback((className: string) => {
setExpandedClassSlots(prev => {
const next = new Set(prev);
if (next.has(className)) {
next.delete(className);
} else {
next.add(className);
}
return next;
});
}, []);
// Load export AND import info for related classes when increasing depth
// Uses BFS to find all classes within the specified depth and loads their data
const loadRelationshipInfoForDepth = useCallback(async (centerClassName: string, depth: number) => {
if (!isSchemaServiceComplete) return;
// Get currently loaded info for center class
const centerExport = classExports[centerClassName];
const centerImport = classImports[centerClassName];
if (!centerExport && !centerImport) return;
// BFS to find all classes we need to load
const classesToLoadExports = new Set<string>();
const classesToLoadImports = new Set<string>();
const visited = new Set<string>();
const queue: Array<{ className: string; currentDepth: number }> = [
{ className: centerClassName, currentDepth: 0 }
];
while (queue.length > 0) {
const { className, currentDepth } = queue.shift()!;
if (visited.has(className) || currentDepth > depth) continue;
visited.add(className);
const exportInfo = classExports[className];
const importInfo = classImports[className];
// Mark for loading if not already loaded
if (!exportInfo && !loadingExports.has(className)) {
classesToLoadExports.add(className);
}
if (!importInfo && !loadingImports.has(className)) {
classesToLoadImports.add(className);
}
// Get related classes from exports (reverse dependencies)
const related: string[] = [];
if (exportInfo) {
related.push(...exportInfo.subclasses);
related.push(...exportInfo.mixinUsers);
related.push(...exportInfo.classesUsingSlotWithThisRange.map(c => c.className));
related.push(...exportInfo.classesReferencingInSlotUsage);
// Annotation-based relationships (dual-class pattern)
related.push(...exportInfo.linkedCollectionTypes);
}
// Get related classes from imports (forward dependencies)
if (importInfo) {
if (importInfo.parentClass) related.push(importInfo.parentClass);
related.push(...importInfo.mixins);
related.push(...importInfo.slotRanges.map(r => r.rangeType));
related.push(...importInfo.slotUsageRanges.map(r => r.rangeType));
// Annotation-based relationships (dual-class pattern)
if (importInfo.linkedCollectionType) related.push(importInfo.linkedCollectionType);
if (importInfo.linkedCustodianType) related.push(importInfo.linkedCustodianType);
}
// Also check parent class from semantic info if available
const parentClass = parentClassMap[className];
if (parentClass) {
related.push(parentClass);
}
// Queue related classes for next depth level
if (currentDepth < depth) {
for (const relatedClass of related) {
if (!visited.has(relatedClass)) {
queue.push({ className: relatedClass, currentDepth: currentDepth + 1 });
}
}
}
}
// Load all classes that need loading
const hasExportsToLoad = classesToLoadExports.size > 0;
const hasImportsToLoad = classesToLoadImports.size > 0;
if (!hasExportsToLoad && !hasImportsToLoad) return;
// Mark as loading
if (hasExportsToLoad) {
setLoadingExports(prev => {
const next = new Set(prev);
classesToLoadExports.forEach(c => next.add(c));
return next;
});
}
if (hasImportsToLoad) {
setLoadingImports(prev => {
const next = new Set(prev);
classesToLoadImports.forEach(c => next.add(c));
return next;
});
}
// Load exports in parallel
const exportPromises = Array.from(classesToLoadExports).map(async (className) => {
try {
const exportInfo = await linkmlSchemaService.getClassExportInfo(className);
// Also try to get parent class info
try {
const semanticInfo = await linkmlSchemaService.getClassSemanticInfo(className);
if (semanticInfo?.parentClass) {
setParentClassMap(prev => ({ ...prev, [className]: semanticInfo.parentClass! }));
}
} catch {
// Semantic info not available for all classes
}
return { className, exportInfo };
} catch (error) {
console.error(`Error loading export info for ${className}:`, error);
return { className, exportInfo: null };
}
});
// Load imports in parallel
const importPromises = Array.from(classesToLoadImports).map(async (className) => {
try {
const importInfo = await linkmlSchemaService.getClassImportInfo(className);
return { className, importInfo };
} catch (error) {
console.error(`Error loading import info for ${className}:`, error);
return { className, importInfo: null };
}
});
const [exportResults, importResults] = await Promise.all([
Promise.all(exportPromises),
Promise.all(importPromises)
]);
// Update state with loaded export info
if (hasExportsToLoad) {
setClassExports(prev => {
const next = { ...prev };
for (const { className, exportInfo } of exportResults) {
if (exportInfo) {
next[className] = exportInfo;
}
}
return next;
});
// Clear loading state
setLoadingExports(prev => {
const next = new Set(prev);
classesToLoadExports.forEach(c => next.delete(c));
return next;
});
}
// Update state with loaded import info
if (hasImportsToLoad) {
setClassImports(prev => {
const next = { ...prev };
for (const { className, importInfo } of importResults) {
if (importInfo) {
next[className] = importInfo;
}
}
return next;
});
// Clear loading state
setLoadingImports(prev => {
const next = new Set(prev);
classesToLoadImports.forEach(c => next.delete(c));
return next;
});
}
// If we loaded new classes, we may need to traverse further
// Schedule another pass if we're not at full depth yet
const hasNewExports = exportResults.some(r => r.exportInfo !== null);
const hasNewImports = importResults.some(r => r.importInfo !== null);
if ((hasNewExports || hasNewImports) && depth > 1) {
// Re-run to potentially load more classes at deeper levels
setTimeout(() => loadRelationshipInfoForDepth(centerClassName, depth), 100);
}
}, [classExports, classImports, loadingExports, loadingImports, parentClassMap, isSchemaServiceComplete]);
// Debounced version of loadRelationshipInfoForDepth for slider interaction
// This prevents excessive API calls when dragging the slider
const debouncedLoadRelationshipInfo = useMemo(
() => debounce((className: string, depth: number) => {
loadRelationshipInfoForDepth(className, depth);
}, 300),
[loadRelationshipInfoForDepth]
);
// Cleanup debounced function on unmount
useEffect(() => {
return () => {
debouncedLoadRelationshipInfo.cancel();
};
}, [debouncedLoadRelationshipInfo]);
// Create a lookup map from slot names to slot definitions
// This allows renderClassDetails to resolve slot names to full slot objects
// Priority: service slots (comprehensive) > local schema slots (fallback)
const slotLookupMap = useMemo(() => {
const map = new Map<string, LinkMLSlot>();
// First, add any slots from the local schema file (fallback)
if (schema) {
const localSlots = extractSlots(schema);
localSlots.forEach(slot => map.set(slot.name, slot));
}
// Then, add/override with service slots (comprehensive - includes all modules)
// SlotDefinition is compatible with LinkMLSlot (superset)
serviceSlots.forEach((slot, name) => {
map.set(name, slot as LinkMLSlot);
});
return map;
}, [schema, serviceSlots]);
// Cache for memoized UML diagrams - keyed by className:depth:showImports:showExports
// This prevents expensive diagram rebuilding when only unrelated state changes
const diagramCache = useRef<Map<string, UMLDiagram>>(new Map());
const diagramCacheDeps = useRef<{ classExports: typeof classExports; classImports: typeof classImports; parentClassMap: typeof parentClassMap; slotSemanticLabels: typeof slotSemanticLabels } | null>(null);
// Clear cache when dependencies change
if (diagramCacheDeps.current === null ||
diagramCacheDeps.current.classExports !== classExports ||
diagramCacheDeps.current.classImports !== classImports ||
diagramCacheDeps.current.parentClassMap !== parentClassMap ||
diagramCacheDeps.current.slotSemanticLabels !== slotSemanticLabels) {
diagramCache.current.clear();
diagramCacheDeps.current = { classExports, classImports, parentClassMap, slotSemanticLabels };
}
// Memoized UML diagram builder with result caching to prevent recalculation
// Returns cached result if same parameters, otherwise builds and caches new diagram
const getMemoizedUMLDiagram = useCallback((className: string, depth: number, showImports: boolean, showExports: boolean): UMLDiagram => {
const cacheKey = `${className}:${depth}:${showImports}:${showExports}`;
// Return cached diagram if available
const cached = diagramCache.current.get(cacheKey);
if (cached) {
return cached;
}
// Build new diagram and cache it (pass slot semantic labels for edge predicates)
const diagram = buildFilteredUMLDiagram(className, classExports, classImports, depth, parentClassMap, showImports, showExports, slotSemanticLabels);
diagramCache.current.set(cacheKey, diagram);
// Limit cache size to prevent memory issues (keep last 20 diagrams)
if (diagramCache.current.size > 20) {
const firstKey = diagramCache.current.keys().next().value;
if (firstKey) diagramCache.current.delete(firstKey);
}
return diagram;
}, [classExports, classImports, parentClassMap, slotSemanticLabels]);
// Navigate to a class by updating URL params and selecting the schema file
const navigateToClass = useCallback((className: string) => {
setSearchParams({ class: className });
setHighlightedClass(className);
// Close any open ontology popup when navigating
setOntologyPopupCurie(null);
// Find and select the class file
const classFile = categories
.find(cat => cat.name === 'class')
?.files.find(file => file.name === className);
if (classFile) {
setSelectedSchema(classFile);
// Ensure classes section is expanded
setExpandedSections(prev => new Set([...prev, 'classes']));
}
}, [categories, setSearchParams]);
// Navigate to an enum by updating URL params and selecting the schema file
const navigateToEnum = useCallback((enumName: string) => {
setSearchParams({ enum: enumName });
setHighlightedClass(enumName); // Reuse highlight state for enums
// Close any open ontology popup when navigating
setOntologyPopupCurie(null);
// Find and select the enum file
const enumFile = categories
.find(cat => cat.name === 'enum')
?.files.find(file => file.name === enumName);
if (enumFile) {
setSelectedSchema(enumFile);
// Ensure enums section is expanded
setExpandedSections(prev => new Set([...prev, 'enums']));
}
}, [categories, setSearchParams]);
// Navigate to a slot by updating URL params and selecting the schema file
const navigateToSlot = useCallback((slotName: string) => {
setSearchParams({ slot: slotName });
setHighlightedClass(slotName); // Reuse highlight state for slots
// Close any open ontology popup when navigating
setOntologyPopupCurie(null);
// Find and select the slot file
const slotFile = categories
.find(cat => cat.name === 'slot')
?.files.find(file => file.name === slotName);
if (slotFile) {
setSelectedSchema(slotFile);
// Ensure slots section is expanded
setExpandedSections(prev => new Set([...prev, 'slots']));
}
}, [categories, setSearchParams]);
// Handler for when user clicks "Go to" in the schema element popup
const handleSchemaElementNavigate = useCallback((elementName: string, elementType: SchemaElementType) => {
if (elementType === 'class') {
navigateToClass(elementName);
} else if (elementType === 'enum') {
navigateToEnum(elementName);
} else if (elementType === 'slot') {
navigateToSlot(elementName);
}
setSchemaElementPopup(null);
}, [navigateToClass, navigateToEnum, navigateToSlot]);
// Track if initialization has already happened (prevents re-init on URL param changes)
const isInitializedRef = useRef(false);
// Handle URL parameters for deep linking (only used on initial mount)
const handleUrlParams = useCallback((cats: SchemaCategory[], currentSearchParams: URLSearchParams) => {
const classParam = currentSearchParams.get('class');
const enumParam = currentSearchParams.get('enum');
const slotParam = currentSearchParams.get('slot');
if (classParam) {
setHighlightedClass(classParam);
// Find the schema file that contains this class
const classFile = cats
.find(cat => cat.name === 'class')
?.files.find(file => file.name === classParam);
if (classFile) {
setSelectedSchema(classFile);
// Ensure classes section is expanded
setExpandedSections(prev => new Set([...prev, 'classes']));
}
} else if (enumParam) {
setHighlightedClass(enumParam);
// Find the schema file that contains this enum
const enumFile = cats
.find(cat => cat.name === 'enum')
?.files.find(file => file.name === enumParam);
if (enumFile) {
setSelectedSchema(enumFile);
// Ensure enums section is expanded
setExpandedSections(prev => new Set([...prev, 'enums']));
}
} else if (slotParam) {
setHighlightedClass(slotParam);
// Find the schema file that contains this slot
const slotFile = cats
.find(cat => cat.name === 'slot')
?.files.find(file => file.name === slotParam);
if (slotFile) {
setSelectedSchema(slotFile);
// Ensure slots section is expanded
setExpandedSections(prev => new Set([...prev, 'slots']));
}
}
}, []);
// Initialize schema file list from manifest - RUNS ONLY ONCE on mount
// Note: Does NOT depend on searchParams to prevent re-initialization when
// custodian filter changes the URL. Deep linking for ?class= is handled
// by reading searchParams directly inside the effect on initial mount only.
useEffect(() => {
// Skip if already initialized (prevents re-init on searchParams changes from filter)
if (isInitializedRef.current) {
return;
}
const initializeSchemas = async () => {
setIsLoading(true);
try {
// Fetch the schema manifest (dynamically generated at build time)
const cats = await fetchSchemaManifest();
if (cats.length === 0) {
setError(t('noSchemasFound'));
return;
}
setCategories(cats);
// Check URL params for deep linking (read searchParams directly, don't depend on it)
handleUrlParams(cats, searchParams);
// Select main schema by default if no URL param set the schema
const classParam = searchParams.get('class');
const enumParam = searchParams.get('enum');
const slotParam = searchParams.get('slot');
if (!classParam && !enumParam && !slotParam && cats[0]?.files.length > 0) {
setSelectedSchema(cats[0].files[0]);
}
// Mark as initialized to prevent re-running
isInitializedRef.current = true;
} catch (err) {
setError(t('failedToInit'));
console.error(err);
} finally {
setIsLoading(false);
}
};
initializeSchemas();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty deps - run only on mount
// Scroll to highlighted class when it changes
useEffect(() => {
if (highlightedClass && highlightedRef.current) {
setTimeout(() => {
highlightedRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
}, [highlightedClass, schema]);
// Fetch schema manifest from dynamically generated JSON file
const fetchSchemaManifest = async (): Promise<SchemaCategory[]> => {
try {
const response = await fetch('/schemas/20251121/linkml/manifest.json');
if (!response.ok) {
throw new Error(`Failed to fetch manifest: ${response.status}`);
}
const manifest = await response.json();
// Transform manifest categories to our format
return manifest.categories.map((cat: { name: string; displayName: string; files: Array<{ name: string; path: string; category: string }> }) => ({
name: cat.name,
displayName: cat.displayName,
files: cat.files.map((file: { name: string; path: string; category: string }) => ({
name: file.name,
path: file.path,
category: cat.name as SchemaFile['category']
}))
}));
} catch (err) {
console.error('Failed to fetch schema manifest:', err);
// Return empty categories on error
return [];
}
};
// Load selected schema
useEffect(() => {
if (!selectedSchema) return;
const loadSelectedSchema = async () => {
setIsLoading(true);
setError(null);
try {
const [schemaData, yamlContent] = await Promise.all([
loadSchema(selectedSchema.path),
loadSchemaRaw(selectedSchema.path)
]);
setSchema(schemaData);
setRawYaml(yamlContent);
} catch (err) {
setError(`${t('failedToLoad')} ${selectedSchema.name}`);
console.error(err);
} finally {
setIsLoading(false);
}
};
loadSelectedSchema();
}, [selectedSchema]);
// Load custodian types from schema annotations when schema changes
// This pre-loads types asynchronously so they're available for rendering
// IMPORTANT: Wait for schema service to complete loading before fetching custodian types
// to avoid race condition where annotations aren't available yet
useEffect(() => {
if (!schema) {
setCustodianTypesLoaded(false);
return;
}
// Don't load custodian types until schema service has finished loading all class files
// This prevents the race condition where we try to read annotations before they're loaded
if (!isSchemaServiceComplete) {
console.log('[LinkMLViewerPage] Waiting for schema service to complete before loading custodian types...');
return;
}
const loadCustodianTypes = async () => {
const classes = extractClasses(schema);
const slots = extractSlots(schema);
const enums = extractEnums(schema);
console.log('[LinkMLViewerPage] Schema service complete, loading custodian types for', {
classes: classes.length,
slots: slots.length,
enums: enums.length
});
// Load types for all classes in parallel
const classTypesPromises = classes.map(async (cls) => {
const types = await getCustodianTypesForClassAsync(cls.name);
return [cls.name, types] as const;
});
// Load types for all slots in parallel
const slotTypesPromises = slots.map(async (slot) => {
const types = await getCustodianTypesForSlotAsync(slot.name);
return [slot.name, types] as const;
});
// Load types for all enums in parallel
const enumTypesPromises = enums.map(async (enumDef) => {
const types = await getCustodianTypesForEnumAsync(enumDef.name);
return [enumDef.name, types] as const;
});
try {
const [classResults, slotResults, enumResults] = await Promise.all([
Promise.all(classTypesPromises),
Promise.all(slotTypesPromises),
Promise.all(enumTypesPromises)
]);
// Convert to records
const classTypesMap: Record<string, CustodianTypeCode[]> = {};
for (const [name, types] of classResults) {
classTypesMap[name] = types;
}
const slotTypesMap: Record<string, CustodianTypeCode[]> = {};
for (const [name, types] of slotResults) {
slotTypesMap[name] = types;
}
const enumTypesMap: Record<string, CustodianTypeCode[]> = {};
for (const [name, types] of enumResults) {
enumTypesMap[name] = types;
}
setClassCustodianTypes(classTypesMap);
setSlotCustodianTypes(slotTypesMap);
setEnumCustodianTypes(enumTypesMap);
setCustodianTypesLoaded(true);
console.log('[LinkMLViewerPage] Loaded custodian types from schema annotations:', {
classes: Object.keys(classTypesMap).length,
slots: Object.keys(slotTypesMap).length,
enums: Object.keys(enumTypesMap).length
});
} catch (error) {
console.error('[LinkMLViewerPage] Error loading custodian types:', error);
// Fall back to sync functions (will use defaults)
setCustodianTypesLoaded(true);
}
};
loadCustodianTypes();
}, [schema, isSchemaServiceComplete]);
// Effect: Pre-load dependency counts for all classes when schema service completes
// This enables showing import/export counts immediately without waiting for click
useEffect(() => {
if (!isSchemaServiceComplete) return;
const loadDependencyCounts = async () => {
try {
const counts = await linkmlSchemaService.getAllClassDependencyCounts();
setDependencyCounts(counts);
console.log('[LinkMLViewerPage] Pre-loaded dependency counts for', counts.size, 'classes');
} catch (error) {
console.error('[LinkMLViewerPage] Error loading dependency counts:', error);
}
};
loadDependencyCounts();
}, [isSchemaServiceComplete]);
// Effect: Load all slot definitions from schema service for slot lookup
// This enables resolving slot names to full definitions in class details
// Also computes semantic labels for UML edge labels
useEffect(() => {
if (!isSchemaServiceComplete) return;
const loadServiceSlots = async () => {
try {
const slots = await linkmlSchemaService.getAllSlots();
setServiceSlots(slots);
// Build semantic labels map: slot name -> first exact_mapping or slot_uri
const labels = new Map<string, string>();
for (const [name, slot] of slots.entries()) {
// Priority 1: First exact_mapping (most precise semantic alignment)
if (slot.exact_mappings && slot.exact_mappings.length > 0) {
labels.set(name, slot.exact_mappings[0]);
}
// Priority 2: slot_uri
else if (slot.slot_uri) {
labels.set(name, slot.slot_uri);
}
// Priority 3: Fallback to slot name
else {
labels.set(name, name);
}
}
setSlotSemanticLabels(labels);
console.log('[LinkMLViewerPage] Pre-loaded slot definitions for', slots.size, 'slots');
console.log('[LinkMLViewerPage] Computed semantic labels for', labels.size, 'slots');
} catch (error) {
console.error('[LinkMLViewerPage] Error loading slot definitions:', error);
}
};
loadServiceSlots();
}, [isSchemaServiceComplete]);
const toggleSection = (section: string) => {
setExpandedSections(prev => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
};
// Check if a range is an enum type
const isEnumRange = (range: string): boolean => {
return range.endsWith('Enum');
};
// Toggle enum range expansion and load enum data if needed
const toggleEnumRange = async (slotName: string, enumName: string) => {
const key = `${slotName}:${enumName}`;
// Toggle expansion state
setExpandedEnumRanges(prev => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
// Load enum data if not already loaded
if (!loadedEnums[enumName]) {
try {
const enumSchema = await loadSchema(`modules/enums/${enumName}.yaml`);
if (enumSchema?.enums) {
const enumDef = Object.values(enumSchema.enums)[0] as LinkMLEnum;
if (enumDef) {
setLoadedEnums(prev => ({ ...prev, [enumName]: { ...enumDef, name: enumName } }));
}
}
} catch (err) {
console.error(`Failed to load enum ${enumName}:`, err);
setLoadedEnums(prev => ({ ...prev, [enumName]: null }));
}
}
};
// Render enum values for a slot range
const renderEnumValues = (_slotName: string, enumName: string) => {
const enumDef = loadedEnums[enumName];
if (!enumDef?.permissible_values) {
return <div className="linkml-viewer__enum-loading">{t('loading')}</div>;
}
const allValues = Object.entries(enumDef.permissible_values);
const searchFilter = enumSearchFilters[enumName] || '';
const showAll = enumShowAll[enumName] || false;
const displayCount = 20; // Show first 20 values initially
// Filter values based on search
const filteredValues = searchFilter
? allValues.filter(([value, details]) => {
const searchLower = searchFilter.toLowerCase();
return (
value.toLowerCase().includes(searchLower) ||
(details.description?.toLowerCase().includes(searchLower)) ||
(details.meaning?.toLowerCase().includes(searchLower))
);
})
: allValues;
// Determine how many to show
const valuesToShow = showAll || searchFilter ? filteredValues : filteredValues.slice(0, displayCount);
return (
<div className="linkml-viewer__range-enum-values">
<div className="linkml-viewer__range-enum-header">
{/* Search input */}
<div className="linkml-viewer__range-enum-search">
<input
type="text"
placeholder={t('searchPlaceholder')}
value={searchFilter}
onChange={(e) => setEnumSearchFilters(prev => ({ ...prev, [enumName]: e.target.value }))}
className="linkml-viewer__range-enum-search-input"
/>
{searchFilter && (
<button
className="linkml-viewer__range-enum-search-clear"
onClick={() => setEnumSearchFilters(prev => ({ ...prev, [enumName]: '' }))}
title="Clear search"
>
×
</button>
)}
</div>
{/* Count display */}
<span className="linkml-viewer__range-enum-count">
{searchFilter ? (
<>
{t('showing')} {filteredValues.length} {t('of')} {allValues.length}
</>
) : showAll ? (
<>
{allValues.length} {t('permissibleValues').replace(':', '')}
</>
) : (
<>
{t('showing')} {Math.min(displayCount, allValues.length)} {t('of')} {allValues.length}
</>
)}
</span>
</div>
<div className="linkml-viewer__range-enum-list">
{valuesToShow.length > 0 ? (
valuesToShow.map(([value, details]) => (
<div key={value} className="linkml-viewer__range-enum-item">
<code className="linkml-viewer__range-enum-name">{value}</code>
{details.meaning && (
<a
href={details.meaning.startsWith('wikidata:')
? `https://www.wikidata.org/wiki/${details.meaning.replace('wikidata:', '')}`
: details.meaning}
target="_blank"
rel="noopener noreferrer"
className="linkml-viewer__range-enum-link"
title={details.meaning}
>
🔗
</a>
)}
{details.description && (
<span className="linkml-viewer__range-enum-desc">{details.description}</span>
)}
</div>
))
) : (
<div className="linkml-viewer__range-enum-no-results">
{t('noResults')}
</div>
)}
</div>
{/* Show all / Show less button */}
{!searchFilter && allValues.length > displayCount && (
<button
className="linkml-viewer__range-enum-toggle-all"
onClick={() => setEnumShowAll(prev => ({ ...prev, [enumName]: !showAll }))}
>
{showAll ? (
<> {t('showLess')}</>
) : (
<> {t('showAll')} ({allValues.length - displayCount} {language === 'nl' ? 'meer' : 'more'})</>
)}
</button>
)}
</div>
);
};
const renderClassDetails = (cls: LinkMLClass) => {
const isHighlighted = highlightedClass === cls.name;
// Use pre-loaded types from schema annotations, fall back to sync function if not yet loaded
const custodianTypes = classCustodianTypes[cls.name] || getCustodianTypesForClass(cls.name);
const isUniversal = isUniversalElement(custodianTypes);
// Check if this class matches the current custodian type filter (multi-select)
const matchesFilter = custodianTypeFilter.size === 0 || custodianTypes.some(t => custodianTypeFilter.has(t));
return (
<div
key={cls.name}
ref={isHighlighted ? highlightedRef : null}
className={`linkml-viewer__item ${isHighlighted ? 'linkml-viewer__item--highlighted' : ''} ${!matchesFilter ? 'linkml-viewer__item--filtered-out' : ''}`}
>
<h4 className="linkml-viewer__item-name">
{cls.name}
{cls.abstract && <span className="linkml-viewer__badge linkml-viewer__badge--abstract">{t('abstract')}</span>}
{/* Show 3D polygon for ALL elements (including universal) in 3D mode for testing */}
{use3DIndicator ? (
<CustodianTypeIndicator3D
types={custodianTypes}
size={32}
animate={true}
showTooltip={true}
hoveredType={hoveredCustodianType}
onTypeHover={setHoveredCustodianType}
// Removed onFaceClick - cube should be decorative, not trigger filtering
className="linkml-viewer__custodian-indicator"
/>
) : (
!isUniversal && (
<CustodianTypeBadge
types={custodianTypes}
size="small"
className="linkml-viewer__custodian-badge"
/>
)
)}
</h4>
{cls.class_uri && (
<div className="linkml-viewer__uri">
<span className="linkml-viewer__label">{t('uri')}</span>
<code>{cls.class_uri}</code>
</div>
)}
{cls.description && (
<div className="linkml-viewer__description linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(cls.description)}</ReactMarkdown>
</div>
)}
{/* Class Slots Section - Accordion showing slot details for this class */}
{cls.slots && cls.slots.length > 0 && (
<div className="linkml-viewer__class-slots-section">
<button
className={`linkml-viewer__class-slots-toggle ${expandedClassSlots.has(cls.name) ? 'linkml-viewer__class-slots-toggle--expanded' : ''}`}
onClick={() => toggleClassSlots(cls.name)}
title={t('classSlotsTooltip')}
>
<span className="linkml-viewer__class-slots-icon">{expandedClassSlots.has(cls.name) ? '▼' : '▶'}</span>
<span className="linkml-viewer__label">{t('classSlots')}</span>
<span className="linkml-viewer__class-slots-count">{cls.slots.length}</span>
</button>
{expandedClassSlots.has(cls.name) && (
<div className="linkml-viewer__class-slots-content">
{cls.slots.map(slotName => {
// Get generic slot definition from the global slot lookup
const genericSlot = slotLookupMap.get(slotName);
// Get class-specific slot_usage overrides (if any)
const slotUsage = cls.slot_usage?.[slotName];
// Merge generic slot with slot_usage overrides
// slot_usage properties take precedence over generic slot properties
if (genericSlot || slotUsage) {
const mergedSlot: LinkMLSlot = {
name: slotName,
...(genericSlot || {}),
...(slotUsage || {}),
};
return renderSlotDetails(mergedSlot, slotUsage || null, genericSlot || null, cls.name);
}
// Fallback: show slot name as tag if neither generic definition nor slot_usage found
return (
<div key={slotName} className="linkml-viewer__item linkml-viewer__item--slot-fallback">
<h4 className="linkml-viewer__item-name">{slotName}</h4>
<div className="linkml-viewer__description">
<em>Slot definition not found in schema</em>
</div>
</div>
);
})}
</div>
)}
</div>
)}
{cls.exact_mappings && cls.exact_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label">{t('exactMappings')}</span>
<div className="linkml-viewer__tag-list">
{cls.exact_mappings.map(mapping => (
<button
key={mapping}
className="linkml-viewer__tag linkml-viewer__tag--mapping linkml-viewer__tag--clickable"
onClick={() => setOntologyPopupCurie(mapping)}
title={`View ontology term: ${mapping}`}
>
{mapping}
</button>
))}
</div>
</div>
)}
{cls.close_mappings && cls.close_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label">{t('closeMappings')}</span>
<div className="linkml-viewer__tag-list">
{cls.close_mappings.map(mapping => (
<button
key={mapping}
className="linkml-viewer__tag linkml-viewer__tag--close linkml-viewer__tag--clickable"
onClick={() => setOntologyPopupCurie(mapping)}
title={`View ontology term: ${mapping}`}
>
{mapping}
</button>
))}
</div>
</div>
)}
{/* Imports Section - Shows forward dependencies (what this class depends on) */}
{isSchemaServiceComplete && (
<div className="linkml-viewer__imports-section">
<button
className={`linkml-viewer__imports-toggle ${expandedImports.has(cls.name) ? 'linkml-viewer__imports-toggle--expanded' : ''}`}
onClick={() => toggleImports(cls.name)}
title={t('classImportsTooltip')}
>
<span className="linkml-viewer__imports-icon">{expandedImports.has(cls.name) ? '▼' : '▶'}</span>
<span className="linkml-viewer__label">{t('classImports')}</span>
{/* Show pre-loaded count OR detailed count if data loaded */}
{classImports[cls.name] ? (
<span className="linkml-viewer__imports-count">
{(classImports[cls.name].parentClass ? 1 : 0) +
classImports[cls.name].mixins.length +
classImports[cls.name].slotRanges.length +
classImports[cls.name].slotUsageRanges.length}
</span>
) : dependencyCounts.get(cls.name) ? (
<span className="linkml-viewer__imports-count">
{dependencyCounts.get(cls.name)!.importCount}
</span>
) : null}
{loadingImports.has(cls.name) && <span className="linkml-viewer__imports-loading">Loading...</span>}
</button>
{expandedImports.has(cls.name) && classImports[cls.name] && (
<div className="linkml-viewer__imports-content">
{/* Parent Class (is_a) */}
{classImports[cls.name].parentClass && (
<div className="linkml-viewer__imports-category">
<span className="linkml-viewer__imports-category-label">{t('parentClassLabel')}</span>
<div className="linkml-viewer__imports-list">
<button
className="linkml-viewer__imports-link"
onClick={() => setSchemaElementPopup({ name: classImports[cls.name].parentClass!, type: 'class' })}
>
{classImports[cls.name].parentClass}
</button>
</div>
</div>
)}
{/* Mixins */}
{classImports[cls.name].mixins.length > 0 && (
<div className="linkml-viewer__imports-category">
<span className="linkml-viewer__imports-category-label">{t('mixinsLabel')} ({classImports[cls.name].mixins.length})</span>
<div className="linkml-viewer__imports-list">
{classImports[cls.name].mixins.map(mixin => (
<button
key={mixin}
className="linkml-viewer__imports-link"
onClick={() => setSchemaElementPopup({ name: mixin, type: 'class' })}
>
{mixin}
</button>
))}
</div>
</div>
)}
{/* Slot Ranges */}
{classImports[cls.name].slotRanges.length > 0 && (
<div className="linkml-viewer__imports-category">
<span className="linkml-viewer__imports-category-label">{t('slotRangesLabel')} ({classImports[cls.name].slotRanges.length})</span>
<div className="linkml-viewer__imports-list">
{classImports[cls.name].slotRanges.map(({ slotName, rangeType, isClass }) => (
<button
key={`${slotName}-${rangeType}`}
className="linkml-viewer__imports-link"
onClick={() => setSchemaElementPopup({ name: rangeType, type: isClass ? 'class' : 'enum' })}
title={`slot: ${slotName}`}
>
{rangeType} <span className="linkml-viewer__imports-via">via {slotName}</span>
{!isClass && <span className="linkml-viewer__imports-badge linkml-viewer__imports-badge--enum">enum</span>}
</button>
))}
</div>
</div>
)}
{/* Slot Usage Ranges */}
{classImports[cls.name].slotUsageRanges.length > 0 && (
<div className="linkml-viewer__imports-category">
<span className="linkml-viewer__imports-category-label">{t('slotUsageRangesLabel')} ({classImports[cls.name].slotUsageRanges.length})</span>
<div className="linkml-viewer__imports-list">
{classImports[cls.name].slotUsageRanges.map(({ slotName, rangeType }) => (
<button
key={`usage-${slotName}-${rangeType}`}
className="linkml-viewer__imports-link"
onClick={() => setSchemaElementPopup({ name: rangeType, type: 'class' })}
title={`slot_usage: ${slotName}`}
>
{rangeType} <span className="linkml-viewer__imports-via">via {slotName}</span>
</button>
))}
</div>
</div>
)}
{/* No imports message */}
{!classImports[cls.name].parentClass &&
classImports[cls.name].mixins.length === 0 &&
classImports[cls.name].slotRanges.length === 0 &&
classImports[cls.name].slotUsageRanges.length === 0 && (
<div className="linkml-viewer__imports-empty">
{t('noImports')}
</div>
)}
</div>
)}
</div>
)}
{/* Exports Section - Shows reverse dependencies (what references this class) */}
{isSchemaServiceComplete && (
<div className="linkml-viewer__exports-section">
<button
className={`linkml-viewer__exports-toggle ${expandedExports.has(cls.name) ? 'linkml-viewer__exports-toggle--expanded' : ''}`}
onClick={() => toggleExports(cls.name)}
>
<span className="linkml-viewer__exports-icon">{expandedExports.has(cls.name) ? '▼' : '▶'}</span>
<span className="linkml-viewer__label">Exports</span>
{/* Show pre-loaded count OR detailed count if data loaded */}
{classExports[cls.name] ? (
<span className="linkml-viewer__exports-count">
{classExports[cls.name].subclasses.length +
classExports[cls.name].mixinUsers.length +
classExports[cls.name].classesUsingSlotWithThisRange.length +
classExports[cls.name].classesReferencingInSlotUsage.length}
</span>
) : dependencyCounts.get(cls.name) ? (
<span className="linkml-viewer__exports-count">
{dependencyCounts.get(cls.name)!.exportCount}
</span>
) : null}
{loadingExports.has(cls.name) && <span className="linkml-viewer__exports-loading">Loading...</span>}
</button>
{expandedExports.has(cls.name) && classExports[cls.name] && (
<div className="linkml-viewer__exports-content">
{/* Subclasses */}
{classExports[cls.name].subclasses.length > 0 && (
<div className="linkml-viewer__exports-category">
<span className="linkml-viewer__exports-category-label">Subclasses ({classExports[cls.name].subclasses.length})</span>
<div className="linkml-viewer__exports-list">
{classExports[cls.name].subclasses.map(subclass => (
<button
key={subclass}
className="linkml-viewer__exports-link"
onClick={() => setSchemaElementPopup({ name: subclass, type: 'class' })}
>
{subclass}
</button>
))}
</div>
</div>
)}
{/* Mixin Users */}
{classExports[cls.name].mixinUsers.length > 0 && (
<div className="linkml-viewer__exports-category">
<span className="linkml-viewer__exports-category-label">Used as Mixin by ({classExports[cls.name].mixinUsers.length})</span>
<div className="linkml-viewer__exports-list">
{classExports[cls.name].mixinUsers.map(user => (
<button
key={user}
className="linkml-viewer__exports-link"
onClick={() => setSchemaElementPopup({ name: user, type: 'class' })}
>
{user}
</button>
))}
</div>
</div>
)}
{/* Slots with this Range */}
{classExports[cls.name].slotsWithThisRange.length > 0 && (
<div className="linkml-viewer__exports-category">
<span className="linkml-viewer__exports-category-label">Slots with this Range ({classExports[cls.name].slotsWithThisRange.length})</span>
<div className="linkml-viewer__exports-list">
{classExports[cls.name].slotsWithThisRange.map(slot => (
<span key={slot} className="linkml-viewer__exports-item linkml-viewer__exports-item--slot">
{slot}
</span>
))}
</div>
</div>
)}
{/* Classes using slots with this range */}
{classExports[cls.name].classesUsingSlotWithThisRange.length > 0 && (
<div className="linkml-viewer__exports-category">
<span className="linkml-viewer__exports-category-label">Classes Using Slots with this Range ({classExports[cls.name].classesUsingSlotWithThisRange.length})</span>
<div className="linkml-viewer__exports-list">
{classExports[cls.name].classesUsingSlotWithThisRange.map(({ className, slotName }) => (
<button
key={`${className}-${slotName}`}
className="linkml-viewer__exports-link"
onClick={() => setSchemaElementPopup({ name: className, type: 'class' })}
title={`via slot: ${slotName}`}
>
{className} <span className="linkml-viewer__exports-via">via {slotName}</span>
</button>
))}
</div>
</div>
)}
{/* Classes referencing in slot_usage */}
{classExports[cls.name].classesReferencingInSlotUsage.length > 0 && (
<div className="linkml-viewer__exports-category">
<span className="linkml-viewer__exports-category-label">Referenced in slot_usage ({classExports[cls.name].classesReferencingInSlotUsage.length})</span>
<div className="linkml-viewer__exports-list">
{classExports[cls.name].classesReferencingInSlotUsage.map(refClass => (
<button
key={refClass}
className="linkml-viewer__exports-link"
onClick={() => setSchemaElementPopup({ name: refClass, type: 'class' })}
>
{refClass}
</button>
))}
</div>
</div>
)}
{/* No exports message */}
{classExports[cls.name].subclasses.length === 0 &&
classExports[cls.name].mixinUsers.length === 0 &&
classExports[cls.name].slotsWithThisRange.length === 0 &&
classExports[cls.name].classesUsingSlotWithThisRange.length === 0 &&
classExports[cls.name].classesReferencingInSlotUsage.length === 0 && (
<div className="linkml-viewer__exports-empty">
No other schema elements reference this class.
</div>
)}
</div>
)}
</div>
)}
{/* UML Diagram Section - Shows filtered class relationship diagram */}
{isSchemaServiceComplete && (
<div className="linkml-viewer__uml-section">
<button
className={`linkml-viewer__uml-toggle ${expandedUML.has(cls.name) ? 'linkml-viewer__uml-toggle--expanded' : ''}`}
onClick={() => toggleUML(cls.name)}
>
<span className="linkml-viewer__uml-icon">{expandedUML.has(cls.name) ? '▼' : '▶'}</span>
<span className="linkml-viewer__label">UML Diagram</span>
{(loadingExports.has(cls.name) || loadingImports.has(cls.name)) && <span className="linkml-viewer__uml-loading">Loading...</span>}
</button>
{/* Show UML content when expanded AND required data is loaded */}
{/* Must wait for BOTH exports AND imports since imports contain slot range info needed for aggregation links */}
{expandedUML.has(cls.name) && classExports[cls.name] && classImports[cls.name] && (
<div className="linkml-viewer__uml-content">
{/* Direction toggles (Imports/Exports checkboxes) */}
<div className="linkml-viewer__uml-direction-control">
<label className="linkml-viewer__uml-checkbox-label linkml-viewer__uml-checkbox-label--imports" title={t('umlImportsTooltip')}>
<input
type="checkbox"
checked={umlShowImports[cls.name] !== false}
onChange={(e) => setUmlShowImports(prev => ({ ...prev, [cls.name]: e.target.checked }))}
className="linkml-viewer__uml-checkbox linkml-viewer__uml-checkbox--imports"
/>
<span>{t('umlShowImports')}</span>
</label>
<label className="linkml-viewer__uml-checkbox-label linkml-viewer__uml-checkbox-label--exports" title={t('umlExportsTooltip')}>
<input
type="checkbox"
checked={umlShowExports[cls.name] !== false}
onChange={(e) => setUmlShowExports(prev => ({ ...prev, [cls.name]: e.target.checked }))}
className="linkml-viewer__uml-checkbox linkml-viewer__uml-checkbox--exports"
/>
<span>{t('umlShowExports')}</span>
</label>
</div>
{/* Depth slider */}
<div className="linkml-viewer__uml-depth-control">
<label className="linkml-viewer__uml-depth-label">
<span>Relationship Depth:</span>
<input
type="range"
min="1"
max="4"
value={umlDepth[cls.name] || 1}
onChange={(e) => {
const newDepth = parseInt(e.target.value, 10);
setUmlDepth(prev => ({ ...prev, [cls.name]: newDepth }));
// Use debounced loading to prevent excessive API calls while dragging
if (newDepth > 1) {
debouncedLoadRelationshipInfo(cls.name, newDepth);
}
}}
className="linkml-viewer__uml-depth-slider"
/>
<span className="linkml-viewer__uml-depth-value">{umlDepth[cls.name] || 1}</span>
</label>
<span className="linkml-viewer__uml-depth-hint">
{(umlDepth[cls.name] || 1) === 1 ? 'Direct relationships' :
(umlDepth[cls.name] || 1) === 2 ? 'Secondary connections' :
(umlDepth[cls.name] || 1) === 3 ? 'Tertiary connections' : 'Extended network'}
</span>
</div>
{/* UML Toolbar */}
<div className="linkml-viewer__uml-toolbar">
{/* Zoom Controls */}
<button
className="linkml-viewer__uml-toolbar-btn"
title="Fit to screen"
onClick={() => window.dispatchEvent(new CustomEvent('uml-fit-to-screen'))}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
</svg>
<span>Fit</span>
</button>
<button
className="linkml-viewer__uml-toolbar-btn"
title="Zoom in"
onClick={() => window.dispatchEvent(new CustomEvent('uml-zoom-in'))}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
<line x1="11" y1="8" x2="11" y2="14" />
<line x1="8" y1="11" x2="14" y2="11" />
</svg>
</button>
<button
className="linkml-viewer__uml-toolbar-btn"
title="Zoom out"
onClick={() => window.dispatchEvent(new CustomEvent('uml-zoom-out'))}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
<line x1="8" y1="11" x2="14" y2="11" />
</svg>
</button>
<button
className="linkml-viewer__uml-toolbar-btn"
title="Reset view"
onClick={() => window.dispatchEvent(new CustomEvent('uml-reset-view'))}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<polyline points="1 4 1 10 7 10" />
<polyline points="23 20 23 14 17 14" />
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
</svg>
</button>
<div className="linkml-viewer__uml-toolbar-separator" />
{/* Fullscreen Toggle */}
<button
className={`linkml-viewer__uml-toolbar-btn ${umlFullscreen === cls.name ? 'linkml-viewer__uml-toolbar-btn--active' : ''}`}
title={umlFullscreen === cls.name ? 'Exit fullscreen' : 'Fullscreen'}
onClick={() => setUmlFullscreen(umlFullscreen === cls.name ? null : cls.name)}
>
{umlFullscreen === cls.name ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3" />
</svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
</svg>
)}
</button>
<div className="linkml-viewer__uml-toolbar-separator" />
{/* Layout Dropdown */}
<div className="linkml-viewer__uml-layout-dropdown">
<button
className="linkml-viewer__uml-toolbar-btn"
onClick={() => setUmlLayoutDropdownOpen(prev => ({ ...prev, [cls.name]: !prev[cls.name] }))}
title={`Layout: ${umlLayoutType[cls.name] || 'dagre'} ${umlLayoutType[cls.name] === 'dagre' ? `(${umlDagreDirection[cls.name] || 'TB'})` : ''}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
<span>Layout</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="12" height="12">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
{umlLayoutDropdownOpen[cls.name] && (
<div className="linkml-viewer__uml-layout-menu">
<button
className={`linkml-viewer__uml-layout-option ${(umlLayoutType[cls.name] || 'dagre') === 'dagre' && (umlDagreDirection[cls.name] || 'TB') === 'TB' ? 'linkml-viewer__uml-layout-option--active' : ''}`}
onClick={() => {
setUmlLayoutType(prev => ({ ...prev, [cls.name]: 'dagre' }));
setUmlDagreDirection(prev => ({ ...prev, [cls.name]: 'TB' }));
setUmlLayoutDropdownOpen(prev => ({ ...prev, [cls.name]: false }));
}}
>
<span className="linkml-viewer__uml-layout-title">Hierarchical (Top-Bottom)</span>
<span className="linkml-viewer__uml-layout-desc">Tree structure flowing downward</span>
</button>
<button
className={`linkml-viewer__uml-layout-option ${(umlLayoutType[cls.name] || 'dagre') === 'dagre' && umlDagreDirection[cls.name] === 'LR' ? 'linkml-viewer__uml-layout-option--active' : ''}`}
onClick={() => {
setUmlLayoutType(prev => ({ ...prev, [cls.name]: 'dagre' }));
setUmlDagreDirection(prev => ({ ...prev, [cls.name]: 'LR' }));
setUmlLayoutDropdownOpen(prev => ({ ...prev, [cls.name]: false }));
}}
>
<span className="linkml-viewer__uml-layout-title">Hierarchical (Left-Right)</span>
<span className="linkml-viewer__uml-layout-desc">Tree structure flowing rightward</span>
</button>
<button
className={`linkml-viewer__uml-layout-option ${umlLayoutType[cls.name] === 'force' ? 'linkml-viewer__uml-layout-option--active' : ''}`}
onClick={() => {
setUmlLayoutType(prev => ({ ...prev, [cls.name]: 'force' }));
setUmlLayoutDropdownOpen(prev => ({ ...prev, [cls.name]: false }));
}}
>
<span className="linkml-viewer__uml-layout-title">Force-Directed</span>
<span className="linkml-viewer__uml-layout-desc">Physics-based node positioning</span>
</button>
<button
className={`linkml-viewer__uml-layout-option ${umlLayoutType[cls.name] === 'circular' ? 'linkml-viewer__uml-layout-option--active' : ''}`}
onClick={() => {
setUmlLayoutType(prev => ({ ...prev, [cls.name]: 'circular' }));
setUmlLayoutDropdownOpen(prev => ({ ...prev, [cls.name]: false }));
}}
>
<span className="linkml-viewer__uml-layout-title">Circular</span>
<span className="linkml-viewer__uml-layout-desc">Nodes arranged in a circle</span>
</button>
<button
className={`linkml-viewer__uml-layout-option ${umlLayoutType[cls.name] === 'radial' ? 'linkml-viewer__uml-layout-option--active' : ''}`}
onClick={() => {
setUmlLayoutType(prev => ({ ...prev, [cls.name]: 'radial' }));
setUmlLayoutDropdownOpen(prev => ({ ...prev, [cls.name]: false }));
}}
>
<span className="linkml-viewer__uml-layout-title">Radial</span>
<span className="linkml-viewer__uml-layout-desc">Center-outward tree layout</span>
</button>
</div>
)}
</div>
</div>
{/* Show empty state when both toggles are unchecked */}
{umlShowImports[cls.name] === false && umlShowExports[cls.name] === false ? (
<div className="linkml-viewer__uml-empty-state">
<span className="linkml-viewer__uml-empty-icon">🔗</span>
<p>{t('umlNoRelationshipsShown')}</p>
</div>
) : (
<div
className={`linkml-viewer__uml-container ${umlFullscreen === cls.name ? 'linkml-viewer__uml-container--fullscreen' : ''}`}
style={umlFullscreen !== cls.name ? {
width: '100%',
height: '720px'
} : undefined}
>
<UMLVisualization
diagram={getMemoizedUMLDiagram(
cls.name,
umlDepth[cls.name] || 1,
umlShowImports[cls.name] !== false,
umlShowExports[cls.name] !== false
)}
width={umlFullscreen === cls.name ? window.innerWidth : undefined}
height={umlFullscreen === cls.name ? window.innerHeight : 720}
layoutType={umlLayoutType[cls.name] || 'dagre'}
dagreDirection={umlDagreDirection[cls.name] || 'TB'}
/>
{umlFullscreen === cls.name && (
<button
className="linkml-viewer__uml-fullscreen-close"
onClick={() => setUmlFullscreen(null)}
title="Exit fullscreen (Esc)"
>
</button>
)}
</div>
)}
</div>
)}
{/* Show message when expanded but data not yet loaded */}
{expandedUML.has(cls.name) && (!classExports[cls.name] || !classImports[cls.name]) && !loadingExports.has(cls.name) && !loadingImports.has(cls.name) && (
<div className="linkml-viewer__uml-empty">
Click to load class relationships.
</div>
)}
</div>
)}
</div>
);
};
const renderSlotDetails = (
slot: LinkMLSlot,
slotUsage: Partial<LinkMLSlot> | null = null,
genericSlot: LinkMLSlot | null = null,
className: string | null = null
) => {
const hasSlotUsageOverrides = slotUsage !== null;
const rangeIsEnum = slot.range && isEnumRange(slot.range);
const enumKey = slot.range ? `${slot.name}:${slot.range}` : '';
const isExpanded = expandedEnumRanges.has(enumKey);
// Use pre-loaded types from schema annotations, fall back to sync function if not yet loaded
const custodianTypes = slotCustodianTypes[slot.name] || getCustodianTypesForSlot(slot.name);
const isUniversal = isUniversalElement(custodianTypes);
// Check if this slot matches the current custodian type filter (multi-select)
const matchesFilter = custodianTypeFilter.size === 0 || custodianTypes.some(t => custodianTypeFilter.has(t));
// Check if comparison view is expanded for this slot
const comparisonKey = className ? `${className}:${slot.name}` : slot.name;
const showComparison = expandedSlotComparisons.has(comparisonKey);
// Helper to check if a property is overridden in slot_usage
const isOverridden = (property: keyof LinkMLSlot): boolean => {
if (!slotUsage) return false;
return slotUsage[property] !== undefined;
};
// Helper to render override marker
const OverrideMarker = ({ property }: { property: keyof LinkMLSlot }) => {
if (!hasSlotUsageOverrides) return null;
const overridden = isOverridden(property);
return (
<span
className={`linkml-viewer__override-marker ${overridden ? 'linkml-viewer__override-marker--overridden' : 'linkml-viewer__override-marker--inherited'}`}
title={overridden ? t('overriddenInSlotUsage') : t('inheritedFromGeneric')}
>
{overridden ? t('slotUsageOverrideMarker') : ''}
</span>
);
};
return (
<div key={slot.name} className={`linkml-viewer__item ${!matchesFilter ? 'linkml-viewer__item--filtered-out' : ''} ${hasSlotUsageOverrides ? 'linkml-viewer__item--has-usage' : ''}`}>
<h4 className="linkml-viewer__item-name">
{slot.name}
{hasSlotUsageOverrides && <span className="linkml-viewer__badge linkml-viewer__badge--usage" title={t('slotUsageTooltip')}>{t('slotUsageBadge')}</span>}
{/* Compare button - only show when slot_usage exists and we have both generic and override */}
{hasSlotUsageOverrides && genericSlot && className && (
<button
className={`linkml-viewer__badge linkml-viewer__badge--compare ${showComparison ? 'linkml-viewer__badge--compare-active' : ''}`}
onClick={(e) => {
e.stopPropagation();
toggleSlotComparison(className, slot.name);
}}
title={t('slotUsageCompareTooltip')}
>
{showComparison ? '▼' : '▶'} {t('slotUsageCompareToggle')}
</button>
)}
{slot.required && <span className="linkml-viewer__badge linkml-viewer__badge--required">{t('required')}</span>}
{slot.multivalued && <span className="linkml-viewer__badge linkml-viewer__badge--multi">{t('multivalued')}</span>}
{slot.identifier && <span className="linkml-viewer__badge linkml-viewer__badge--identifier">{t('identifier')}</span>}
{/* Show 3D polygon for ALL elements (including universal) in 3D mode for testing */}
{use3DIndicator ? (
<CustodianTypeIndicator3D
types={custodianTypes}
size={32}
animate={true}
showTooltip={true}
hoveredType={hoveredCustodianType}
onTypeHover={setHoveredCustodianType}
// Removed onFaceClick - cube should be decorative, not trigger filtering
className="linkml-viewer__custodian-indicator"
/>
) : (
!isUniversal && (
<CustodianTypeBadge
types={custodianTypes}
size="small"
className="linkml-viewer__custodian-badge"
/>
)
)}
</h4>
{/* Slot URI - semantic predicate */}
{slot.slot_uri && (
<div className="linkml-viewer__slot-uri">
<span className="linkml-viewer__label"><OverrideMarker property="slot_uri" />{t('slotUri')}</span>
<code className="linkml-viewer__uri-value">{slot.slot_uri}</code>
</div>
)}
{slot.range && (
<div className="linkml-viewer__range">
<span className="linkml-viewer__label"><OverrideMarker property="range" />{t('range')}</span>
{rangeIsEnum ? (
<button
className={`linkml-viewer__range-enum-toggle ${isExpanded ? 'linkml-viewer__range-enum-toggle--expanded' : ''}`}
onClick={() => toggleEnumRange(slot.name, slot.range!)}
>
<span className="linkml-viewer__range-enum-icon">{isExpanded ? '▼' : '▶'}</span>
<code>{slot.range}</code>
<span className="linkml-viewer__badge linkml-viewer__badge--enum">enum</span>
</button>
) : (
<code>{slot.range}</code>
)}
</div>
)}
{/* Expandable enum values */}
{rangeIsEnum && isExpanded && slot.range && renderEnumValues(slot.name, slot.range)}
{slot.description && (
<div className="linkml-viewer__description linkml-viewer__markdown">
<span className="linkml-viewer__label linkml-viewer__label--inline"><OverrideMarker property="description" /></span>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(slot.description)}</ReactMarkdown>
</div>
)}
{slot.pattern && (
<div className="linkml-viewer__pattern">
<span className="linkml-viewer__label"><OverrideMarker property="pattern" />{t('pattern')}</span>
<code>{slot.pattern}</code>
</div>
)}
{/* Examples section */}
{slot.examples && slot.examples.length > 0 && (
<div className="linkml-viewer__examples">
<span className="linkml-viewer__label"><OverrideMarker property="examples" />{t('examples')}</span>
<ul className="linkml-viewer__examples-list">
{slot.examples.map((example, idx) => (
<li key={idx} className="linkml-viewer__example-item">
<code className="linkml-viewer__example-value">{example.value}</code>
{example.description && (
<span className="linkml-viewer__example-description"> - {example.description}</span>
)}
</li>
))}
</ul>
</div>
)}
{slot.exact_mappings && slot.exact_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label"><OverrideMarker property="exact_mappings" />{t('exactMappings')}</span>
<div className="linkml-viewer__tag-list">
{slot.exact_mappings.map(mapping => (
<button
key={mapping}
className="linkml-viewer__tag linkml-viewer__tag--mapping linkml-viewer__tag--exact linkml-viewer__tag--clickable"
onClick={() => setOntologyPopupCurie(mapping)}
title={`View ontology term: ${mapping}`}
>
{mapping}
</button>
))}
</div>
</div>
)}
{slot.close_mappings && slot.close_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label"><OverrideMarker property="close_mappings" />{t('closeMappings')}</span>
<div className="linkml-viewer__tag-list">
{slot.close_mappings.map(mapping => (
<button
key={mapping}
className="linkml-viewer__tag linkml-viewer__tag--mapping linkml-viewer__tag--close linkml-viewer__tag--clickable"
onClick={() => setOntologyPopupCurie(mapping)}
title={`View ontology term: ${mapping}`}
>
{mapping}
</button>
))}
</div>
</div>
)}
{slot.narrow_mappings && slot.narrow_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label"><OverrideMarker property="narrow_mappings" />{t('narrowMappings')}</span>
<div className="linkml-viewer__tag-list">
{slot.narrow_mappings.map(mapping => (
<button
key={mapping}
className="linkml-viewer__tag linkml-viewer__tag--mapping linkml-viewer__tag--narrow linkml-viewer__tag--clickable"
onClick={() => setOntologyPopupCurie(mapping)}
title={`View ontology term: ${mapping}`}
>
{mapping}
</button>
))}
</div>
</div>
)}
{slot.broad_mappings && slot.broad_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label"><OverrideMarker property="broad_mappings" />{t('broadMappings')}</span>
<div className="linkml-viewer__tag-list">
{slot.broad_mappings.map(mapping => (
<button
key={mapping}
className="linkml-viewer__tag linkml-viewer__tag--mapping linkml-viewer__tag--broad linkml-viewer__tag--clickable"
onClick={() => setOntologyPopupCurie(mapping)}
title={`View ontology term: ${mapping}`}
>
{mapping}
</button>
))}
</div>
</div>
)}
{slot.related_mappings && slot.related_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label"><OverrideMarker property="related_mappings" />{t('relatedMappings')}</span>
<div className="linkml-viewer__tag-list">
{slot.related_mappings.map(mapping => (
<button
key={mapping}
className="linkml-viewer__tag linkml-viewer__tag--mapping linkml-viewer__tag--related linkml-viewer__tag--clickable"
onClick={() => setOntologyPopupCurie(mapping)}
title={`View ontology term: ${mapping}`}
>
{mapping}
</button>
))}
</div>
</div>
)}
{/* Comments section */}
{slot.comments && slot.comments.length > 0 && (
<div className="linkml-viewer__comments">
<span className="linkml-viewer__label"><OverrideMarker property="comments" />{t('comments')}</span>
<ul className="linkml-viewer__comments-list">
{slot.comments.map((comment, idx) => (
<li key={idx} className="linkml-viewer__comment-item">{comment}</li>
))}
</ul>
</div>
)}
{/* Imports Section - Shows forward dependencies (what this slot depends on) */}
{!className && isSchemaServiceComplete && (
<div className="linkml-viewer__imports-section">
<button
className={`linkml-viewer__imports-toggle ${expandedSlotImports.has(slot.name) ? 'linkml-viewer__imports-toggle--expanded' : ''}`}
onClick={() => toggleSlotImports(slot.name)}
title={t('slotImportsTooltip')}
>
<span className="linkml-viewer__imports-icon">{expandedSlotImports.has(slot.name) ? '▼' : '▶'}</span>
<span className="linkml-viewer__label">{t('slotImports')}</span>
{slotImports[slot.name] && (
<span className="linkml-viewer__imports-count">
{(slotImports[slot.name].rangeType ? 1 : 0) + slotImports[slot.name].anyOfTypes.length}
</span>
)}
{loadingSlotImports.has(slot.name) && <span className="linkml-viewer__imports-loading">Loading...</span>}
</button>
{expandedSlotImports.has(slot.name) && slotImports[slot.name] && (
<div className="linkml-viewer__imports-content">
{/* Range Type (main dependency) */}
{slotImports[slot.name].rangeType && (
<div className="linkml-viewer__imports-category">
<span className="linkml-viewer__imports-category-label">{t('rangeTypeLabel')}</span>
<div className="linkml-viewer__imports-list">
<button
className="linkml-viewer__imports-link"
onClick={() => {
const range = slotImports[slot.name].rangeType!;
setSchemaElementPopup({
name: range.name,
type: range.isClass ? 'class' : 'enum'
});
}}
>
{slotImports[slot.name].rangeType!.name}
{!slotImports[slot.name].rangeType!.isClass && <span className="linkml-viewer__imports-badge linkml-viewer__imports-badge--enum">enum</span>}
</button>
</div>
</div>
)}
{/* Any_of Types (union types) */}
{slotImports[slot.name].anyOfTypes.length > 0 && (
<div className="linkml-viewer__imports-category">
<span className="linkml-viewer__imports-category-label">{t('anyOfTypesLabel')} ({slotImports[slot.name].anyOfTypes.length})</span>
<div className="linkml-viewer__imports-list">
{slotImports[slot.name].anyOfTypes.map(type => (
<button
key={type.name}
className="linkml-viewer__imports-link"
onClick={() => {
setSchemaElementPopup({
name: type.name,
type: type.isClass ? 'class' : 'enum'
});
}}
>
{type.name}
{!type.isClass && <span className="linkml-viewer__imports-badge linkml-viewer__imports-badge--enum">enum</span>}
</button>
))}
</div>
</div>
)}
{/* No imports message */}
{!slotImports[slot.name].rangeType && slotImports[slot.name].anyOfTypes.length === 0 && (
<div className="linkml-viewer__imports-empty">
{t('noSlotImports')}
</div>
)}
</div>
)}
</div>
)}
{/* Exports Section - Shows reverse dependencies (what classes use this slot) */}
{!className && isSchemaServiceComplete && (
<div className="linkml-viewer__exports-section">
<button
className={`linkml-viewer__exports-toggle ${expandedSlotExports.has(slot.name) ? 'linkml-viewer__exports-toggle--expanded' : ''}`}
onClick={() => toggleSlotExports(slot.name)}
title={t('slotExportsTooltip')}
>
<span className="linkml-viewer__exports-icon">{expandedSlotExports.has(slot.name) ? '▼' : '▶'}</span>
<span className="linkml-viewer__label">{t('slotExports')}</span>
{slotExports[slot.name] && (
<span className="linkml-viewer__exports-count">
{slotExports[slot.name].classesUsingSlot.length + slotExports[slot.name].classesWithSlotUsage.length}
</span>
)}
{loadingSlotExports.has(slot.name) && <span className="linkml-viewer__exports-loading">Loading...</span>}
</button>
{expandedSlotExports.has(slot.name) && slotExports[slot.name] && (
<div className="linkml-viewer__exports-content">
{/* Classes that use this slot */}
{slotExports[slot.name].classesUsingSlot.length > 0 && (
<div className="linkml-viewer__exports-category">
<span className="linkml-viewer__exports-category-label">{t('classesUsingSlot')} ({slotExports[slot.name].classesUsingSlot.length})</span>
<div className="linkml-viewer__exports-list">
{slotExports[slot.name].classesUsingSlot.map(cls => (
<button
key={cls}
className="linkml-viewer__exports-link"
onClick={() => setSchemaElementPopup({ name: cls, type: 'class' })}
>
{cls}
</button>
))}
</div>
</div>
)}
{/* Classes with slot_usage overrides */}
{slotExports[slot.name].classesWithSlotUsage.length > 0 && (
<div className="linkml-viewer__exports-category">
<span className="linkml-viewer__exports-category-label">{t('classesWithSlotUsage')} ({slotExports[slot.name].classesWithSlotUsage.length})</span>
<div className="linkml-viewer__exports-list">
{slotExports[slot.name].classesWithSlotUsage.map(({ className: cls, overrides }) => (
<button
key={cls}
className="linkml-viewer__exports-link"
onClick={() => setSchemaElementPopup({
name: cls,
type: 'slot_usage',
slotName: slot.name,
overrides: overrides
})}
title={`Overrides: ${overrides.join(', ')}`}
>
{cls} <span className="linkml-viewer__exports-via">{overrides.length} {overrides.length === 1 ? 'override' : 'overrides'}</span>
</button>
))}
</div>
</div>
)}
{/* No exports message */}
{slotExports[slot.name].classesUsingSlot.length === 0 &&
slotExports[slot.name].classesWithSlotUsage.length === 0 && (
<div className="linkml-viewer__exports-empty">
{t('noClassesUsingSlot')}
</div>
)}
</div>
)}
</div>
)}
{/* Side-by-side comparison view - only shown when expanded */}
{showComparison && hasSlotUsageOverrides && genericSlot && (
<div className="linkml-viewer__slot-comparison">
<div className="linkml-viewer__slot-comparison-header">
<h5>{t('slotUsageCompareGeneric')}</h5>
<h5>{t('slotUsageCompareOverride')}</h5>
</div>
<div className="linkml-viewer__slot-comparison-content">
{/* Compare key properties side by side */}
{renderComparisonRow('range', genericSlot.range, slotUsage?.range)}
{renderComparisonRow('description', genericSlot.description, slotUsage?.description)}
{renderComparisonRow('required', genericSlot.required, slotUsage?.required)}
{renderComparisonRow('multivalued', genericSlot.multivalued, slotUsage?.multivalued)}
{renderComparisonRow('slot_uri', genericSlot.slot_uri, slotUsage?.slot_uri)}
{renderComparisonRow('pattern', genericSlot.pattern, slotUsage?.pattern)}
{renderComparisonRow('identifier', genericSlot.identifier, slotUsage?.identifier)}
</div>
</div>
)}
</div>
);
};
// Helper function to render a comparison row
const renderComparisonRow = (
property: string,
genericValue: string | boolean | number | undefined | null,
overrideValue: string | boolean | number | undefined | null
) => {
const hasGeneric = genericValue !== undefined && genericValue !== null;
const hasOverride = overrideValue !== undefined && overrideValue !== null;
const isChanged = hasOverride && hasGeneric && genericValue !== overrideValue;
const isNewInOverride = hasOverride && !hasGeneric;
// Don't show row if neither has a value
if (!hasGeneric && !hasOverride) return null;
const formatValue = (val: string | boolean | number | undefined | null): string => {
if (val === undefined || val === null) return '';
if (typeof val === 'boolean') return val ? 'true' : 'false';
if (typeof val === 'number') return String(val);
// Truncate long strings for display
const str = String(val);
return str.length > 80 ? str.substring(0, 77) + '...' : str;
};
return (
<div key={property} className={`linkml-viewer__slot-comparison-row ${isChanged || isNewInOverride ? 'linkml-viewer__slot-comparison-row--changed' : ''}`}>
<div className="linkml-viewer__slot-comparison-label">{property}</div>
<div className="linkml-viewer__slot-comparison-generic">
{hasGeneric ? (
<code title={typeof genericValue === 'string' ? genericValue : undefined}>{formatValue(genericValue)}</code>
) : (
<span className="linkml-viewer__slot-comparison-empty">{t('slotUsageCompareNotDefined')}</span>
)}
</div>
<div className={`linkml-viewer__slot-comparison-override ${isChanged || isNewInOverride ? 'linkml-viewer__slot-comparison-override--diff' : ''}`}>
{hasOverride ? (
<>
<code title={typeof overrideValue === 'string' ? overrideValue : undefined}>{formatValue(overrideValue)}</code>
{(isChanged || isNewInOverride) && <span className="linkml-viewer__slot-comparison-diff-badge">{t('slotUsageCompareDiff')}</span>}
</>
) : (
<span className="linkml-viewer__slot-comparison-inherited">{t('slotUsageCompareInherited')}</span>
)}
</div>
</div>
);
};
const renderEnumDetails = (enumDef: LinkMLEnum) => {
const enumName = enumDef.name;
const allValues = enumDef.permissible_values ? Object.entries(enumDef.permissible_values) : [];
const searchFilter = enumSearchFilters[enumName] || '';
const showAll = enumShowAll[enumName] || false;
const displayCount = 20;
const custodianTypes = enumCustodianTypes[enumDef.name] || getCustodianTypesForEnum(enumDef.name);
const isUniversal = isUniversalElement(custodianTypes);
// Filter values based on search
const filteredValues = searchFilter
? allValues.filter(([value, details]) => {
const searchLower = searchFilter.toLowerCase();
return (
value.toLowerCase().includes(searchLower) ||
(details.description?.toLowerCase().includes(searchLower)) ||
(details.meaning?.toLowerCase().includes(searchLower))
);
})
: allValues;
// Determine how many to show
const valuesToShow = showAll || searchFilter ? filteredValues : filteredValues.slice(0, displayCount);
// Check if this enum matches the current custodian type filter (multi-select)
const matchesFilter = custodianTypeFilter.size === 0 || custodianTypes.some(t => custodianTypeFilter.has(t));
return (
<div key={enumDef.name} className={`linkml-viewer__item ${!matchesFilter ? 'linkml-viewer__item--filtered-out' : ''}`}>
<h4 className="linkml-viewer__item-name">
{enumDef.name}
{/* Show 3D polygon for ALL elements (including universal) in 3D mode for testing */}
{use3DIndicator ? (
<CustodianTypeIndicator3D
types={custodianTypes}
size={32}
animate={true}
showTooltip={true}
hoveredType={hoveredCustodianType}
onTypeHover={setHoveredCustodianType}
// Removed onFaceClick - cube should be decorative, not trigger filtering
className="linkml-viewer__custodian-indicator"
/>
) : (
!isUniversal && (
<CustodianTypeBadge
types={custodianTypes}
size="small"
className="linkml-viewer__custodian-badge"
/>
)
)}
</h4>
{enumDef.description && (
<div className="linkml-viewer__description linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(enumDef.description)}</ReactMarkdown>
</div>
)}
{allValues.length > 0 && (
<div className="linkml-viewer__enum-values">
{/* Search input */}
<div className="linkml-viewer__range-enum-search">
<input
type="text"
placeholder={t('searchPlaceholder')}
value={searchFilter}
onChange={(e) => setEnumSearchFilters(prev => ({ ...prev, [enumName]: e.target.value }))}
className="linkml-viewer__range-enum-search-input"
/>
{searchFilter && (
<button
className="linkml-viewer__range-enum-search-clear"
onClick={() => setEnumSearchFilters(prev => ({ ...prev, [enumName]: '' }))}
title="Clear search"
>
×
</button>
)}
</div>
{/* Count display */}
<span className="linkml-viewer__range-enum-count" style={{ display: 'block', marginBottom: '0.5rem' }}>
{searchFilter ? (
<>
{t('showing')} {filteredValues.length} {t('of')} {allValues.length}
</>
) : showAll ? (
<>
{allValues.length} {t('permissibleValues').replace(':', '')}
</>
) : (
<>
{t('showing')} {Math.min(displayCount, allValues.length)} {t('of')} {allValues.length}
</>
)}
</span>
<div className="linkml-viewer__value-list">
{valuesToShow.length > 0 ? (
valuesToShow.map(([value, details]) => (
<div key={value} className="linkml-viewer__value-item">
<code className="linkml-viewer__value-name">{value}</code>
{details.description && (
<div className="linkml-viewer__value-desc linkml-viewer__markdown linkml-viewer__markdown--compact">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(details.description)}</ReactMarkdown>
</div>
)}
{details.meaning && (
<span className="linkml-viewer__value-meaning">
<span className="linkml-viewer__label">{t('meaning')}</span> {details.meaning}
</span>
)}
</div>
))
) : (
<div className="linkml-viewer__range-enum-no-results">
{t('noResults')}
</div>
)}
</div>
{/* Show all / Show less button */}
{!searchFilter && allValues.length > displayCount && (
<button
className="linkml-viewer__range-enum-toggle-all"
onClick={() => setEnumShowAll(prev => ({ ...prev, [enumName]: !showAll }))}
>
{showAll ? (
<> {t('showLess')}</>
) : (
<> {t('showAll')} ({allValues.length - displayCount} {language === 'nl' ? 'meer' : 'more'})</>
)}
</button>
)}
</div>
)}
</div>
);
};
const renderVisualView = () => {
if (!schema) return null;
const classes = extractClasses(schema);
const enums = extractEnums(schema);
const slots = extractSlots(schema);
// Content search filter - filters classes, enums, and slots by name or content
const searchLower = contentSearch.toLowerCase().trim();
const hasSearch = searchLower.length > 0;
// Helper to check if a class matches the search
const classMatchesSearch = (cls: LinkMLClass): boolean => {
if (!hasSearch) return true;
if (searchMode === 'name') {
return cls.name.toLowerCase().includes(searchLower);
}
// Content mode: search in name, description, class_uri, mappings, slot names
if (cls.name.toLowerCase().includes(searchLower)) return true;
if (cls.description?.toLowerCase().includes(searchLower)) return true;
if (cls.class_uri?.toLowerCase().includes(searchLower)) return true;
if (cls.exact_mappings?.some(m => m.toLowerCase().includes(searchLower))) return true;
if (cls.close_mappings?.some(m => m.toLowerCase().includes(searchLower))) return true;
if (cls.slots?.some(s => s.toLowerCase().includes(searchLower))) return true;
return false;
};
// Helper to check if an enum matches the search
const enumMatchesSearch = (enumDef: LinkMLEnum): boolean => {
if (!hasSearch) return true;
if (searchMode === 'name') {
return enumDef.name.toLowerCase().includes(searchLower);
}
// Content mode: search in name, description, permissible values
if (enumDef.name.toLowerCase().includes(searchLower)) return true;
if (enumDef.description?.toLowerCase().includes(searchLower)) return true;
if (enumDef.permissible_values) {
const values = Object.entries(enumDef.permissible_values);
if (values.some(([key, val]) =>
key.toLowerCase().includes(searchLower) ||
val?.description?.toLowerCase().includes(searchLower) ||
val?.meaning?.toLowerCase().includes(searchLower)
)) return true;
}
return false;
};
// Helper to check if a slot matches the search
const slotMatchesSearch = (slot: LinkMLSlot): boolean => {
if (!hasSearch) return true;
if (searchMode === 'name') {
return slot.name.toLowerCase().includes(searchLower);
}
// Content mode: search in name, description, range, uri, mappings
if (slot.name.toLowerCase().includes(searchLower)) return true;
if (slot.description?.toLowerCase().includes(searchLower)) return true;
if (slot.range?.toLowerCase().includes(searchLower)) return true;
if (slot.slot_uri?.toLowerCase().includes(searchLower)) return true;
if (slot.exact_mappings?.some(m => m.toLowerCase().includes(searchLower))) return true;
if (slot.close_mappings?.some(m => m.toLowerCase().includes(searchLower))) return true;
return false;
};
// Apply search filter
const filteredClasses = hasSearch ? classes.filter(classMatchesSearch) : classes;
const filteredEnums = hasSearch ? enums.filter(enumMatchesSearch) : enums;
const filteredSlots = hasSearch ? slots.filter(slotMatchesSearch) : slots;
// Count matching items when filter is active (for display purposes)
// Now uses filtered arrays (after search filter)
const matchingClassCount = custodianTypeFilter.size > 0
? filteredClasses.filter(cls => {
const types = classCustodianTypes[cls.name] || getCustodianTypesForClass(cls.name);
return types.some(t => custodianTypeFilter.has(t));
}).length
: filteredClasses.length;
const matchingEnumCount = custodianTypeFilter.size > 0
? filteredEnums.filter(enumDef => {
const types = enumCustodianTypes[enumDef.name] || getCustodianTypesForEnum(enumDef.name);
return types.some(t => custodianTypeFilter.has(t));
}).length
: filteredEnums.length;
const matchingSlotCount = custodianTypeFilter.size > 0
? filteredSlots.filter(slot => {
const types = slotCustodianTypes[slot.name] || getCustodianTypesForSlot(slot.name);
return types.some(t => custodianTypeFilter.has(t));
}).length
: filteredSlots.length;
// Check if search returned no results
const noSearchResults = hasSearch && filteredClasses.length === 0 && filteredEnums.length === 0 && filteredSlots.length === 0;
return (
<div className="linkml-viewer__visual">
{/* Schema Metadata */}
<div className="linkml-viewer__metadata">
<h2 className="linkml-viewer__schema-name">
{formatDisplayName(schema.name || schema.title || '') || t('unnamedSchema')}
<span className="linkml-viewer__info-icon" data-tooltip={t('linkmlFileNameTooltip')}></span>
{(matchingClassCount > 0 || matchingEnumCount > 0 || matchingSlotCount > 0) && (
<span className="linkml-viewer__entry-count">
({matchingClassCount > 0 && `${matchingClassCount}${t('entryCountClasses')}`}
{matchingClassCount > 0 && (matchingEnumCount > 0 || matchingSlotCount > 0) && ', '}
{matchingEnumCount > 0 && `${matchingEnumCount}${t('entryCountEnums')}`}
{matchingEnumCount > 0 && matchingSlotCount > 0 && ', '}
{matchingSlotCount > 0 && `${matchingSlotCount}${t('entryCountSlots')}`})
</span>
)}
</h2>
{schema.description && (
<div className="linkml-viewer__schema-desc linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(schema.description)}</ReactMarkdown>
</div>
)}
{schema.id && (
<div className="linkml-viewer__schema-id">
<span className="linkml-viewer__label">{t('id')}</span>
<code>{schema.id}</code>
<button
className={`linkml-viewer__copy-uri-btn ${uriCopyFeedback ? 'linkml-viewer__copy-uri-btn--copied' : ''}`}
onClick={async () => {
try {
await navigator.clipboard.writeText(schema.id || '');
setUriCopyFeedback(true);
setTimeout(() => setUriCopyFeedback(false), 2000);
} catch (err) {
console.error('Failed to copy URI:', err);
}
}}
title={uriCopyFeedback ? t('uriCopied') : t('copyUri')}
>
{uriCopyFeedback ? '✓' : '⧉'}
</button>
</div>
)}
{schema.version && (
<div className="linkml-viewer__schema-version">
<span className="linkml-viewer__label">{t('version')}</span>
<code>{schema.version}</code>
</div>
)}
</div>
{/* No search results message */}
{noSearchResults && (
<div className="linkml-viewer__no-search-results">
<div className="linkml-viewer__no-search-results-icon">🔍</div>
<div>{t('noSearchResults')}: "{contentSearch}"</div>
</div>
)}
{/* Classes - rendered directly without collapsible header */}
{filteredClasses.length > 0 && (
<div className={`linkml-viewer__section linkml-viewer__section--flat ${filteredClasses.length > 1 ? 'linkml-viewer__section--multiple' : ''}`}>
{filteredClasses.map(renderClassDetails)}
</div>
)}
{/* Slots - rendered directly without collapsible header */}
{filteredSlots.length > 0 && (
<div className={`linkml-viewer__section linkml-viewer__section--flat ${filteredSlots.length > 1 ? 'linkml-viewer__section--multiple' : ''}`}>
{filteredSlots.map(slot => renderSlotDetails(slot))}
</div>
)}
{/* Enums - rendered directly without collapsible header */}
{filteredEnums.length > 0 && (
<div className={`linkml-viewer__section linkml-viewer__section--flat ${filteredEnums.length > 1 ? 'linkml-viewer__section--multiple' : ''}`}>
{filteredEnums.map(renderEnumDetails)}
</div>
)}
{/* Developer Tools Section - Prefixes and Imports */}
{((schema.prefixes && Object.keys(schema.prefixes).length > 0) || (schema.imports && schema.imports.length > 0)) && (
<div className="linkml-viewer__developer-tools">
<button
className={`linkml-viewer__developer-tools-header ${showDeveloperTools ? 'linkml-viewer__developer-tools-header--active' : ''}`}
onClick={() => setShowDeveloperTools(!showDeveloperTools)}
title={t('developerToolsTooltip')}
>
<span className="linkml-viewer__developer-tools-icon">&lt;/&gt;</span>
{t('developerTools')}
<span className="linkml-viewer__developer-tools-arrow">
{showDeveloperTools ? '▼' : '▶'}
</span>
</button>
{showDeveloperTools && (
<div className="linkml-viewer__developer-tools-content">
{/* Prefixes */}
{schema.prefixes && Object.keys(schema.prefixes).length > 0 && (
<div className="linkml-viewer__section linkml-viewer__section--developer">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('prefixes')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('prefixes') ? '▼' : '▶'}
</span>
{t('prefixes')} ({Object.keys(schema.prefixes).length})
</button>
{expandedSections.has('prefixes') && (
<div className="linkml-viewer__prefix-list">
{Object.entries(schema.prefixes).map(([prefix, uri]) => (
<div key={prefix} className="linkml-viewer__prefix-item">
<code className="linkml-viewer__prefix-name">{prefix}:</code>
<span className="linkml-viewer__prefix-uri">{uri}</span>
</div>
))}
</div>
)}
</div>
)}
{/* Imports - File Level (LinkML module imports) */}
{schema.imports && schema.imports.length > 0 && (
<div className="linkml-viewer__section linkml-viewer__section--developer">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('imports')}
title={t('fileImportsTooltip')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('imports') ? '▼' : '▶'}
</span>
{t('imports')} ({schema.imports.length})
<span
className="linkml-viewer__info-icon"
data-tooltip={t('fileImportsTooltip')}
onClick={(e) => e.stopPropagation()}
></span>
</button>
{expandedSections.has('imports') && (
<div className="linkml-viewer__import-list">
{schema.imports.map(imp => (
<div key={imp} className="linkml-viewer__import-item">
<code>{imp}</code>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
);
};
const renderRawView = () => {
if (!rawYaml) return null;
const handleCopyYaml = async () => {
try {
await navigator.clipboard.writeText(rawYaml);
setCopyFeedback(true);
setTimeout(() => setCopyFeedback(false), 2000);
} catch (err) {
console.error('Failed to copy YAML:', err);
}
};
return (
<div className="linkml-viewer__raw">
<div className="linkml-viewer__raw-header">
<button
className={`linkml-viewer__copy-btn ${copyFeedback ? 'linkml-viewer__copy-btn--copied' : ''}`}
onClick={handleCopyYaml}
title={copyFeedback ? t('copied') : t('copyToClipboard')}
>
{copyFeedback ? '✓' : '⧉'} {copyFeedback ? t('copied') : t('copyToClipboard')}
</button>
</div>
<pre className="linkml-viewer__yaml">{rawYaml}</pre>
</div>
);
};
return (
<div className={`linkml-viewer-page ${sidebarCollapsed ? 'linkml-viewer-page--sidebar-collapsed' : ''}`}>
{/* Left Sidebar - Schema Files */}
<aside className={`linkml-viewer-page__sidebar ${sidebarCollapsed ? 'linkml-viewer-page__sidebar--collapsed' : ''}`}>
<div className="linkml-viewer-page__sidebar-header">
<h2 className="linkml-viewer-page__sidebar-title">{!sidebarCollapsed && t('sidebarTitle')}</h2>
<button
className="linkml-viewer-page__sidebar-toggle"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
title={sidebarCollapsed ? t('expandSidebar') : t('collapseSidebar')}
aria-label={sidebarCollapsed ? t('expandSidebar') : t('collapseSidebar')}
>
{sidebarCollapsed ? '»' : '«'}
</button>
</div>
{!sidebarCollapsed && (
<>
{/* Sidebar Search */}
<div className="linkml-viewer-page__sidebar-search">
<input
type="text"
placeholder={t('searchSchemas')}
value={sidebarSearch}
onChange={(e) => setSidebarSearch(e.target.value)}
className="linkml-viewer-page__sidebar-search-input"
/>
{sidebarSearch && (
<button
className="linkml-viewer-page__sidebar-search-clear"
onClick={() => setSidebarSearch('')}
title="Clear search"
>
×
</button>
)}
</div>
{categories
.filter(category => categoryFilters[category.name] !== false)
.map(category => {
// Filter files based on search
const filteredFiles = sidebarSearch
? category.files.filter(file =>
file.name.toLowerCase().includes(sidebarSearch.toLowerCase())
)
: category.files;
// Don't render empty categories
if (filteredFiles.length === 0) return null;
const isCategoryCollapsed = collapsedCategories.has(category.name);
return (
<div key={category.name} className="linkml-viewer-page__category">
<button
className="linkml-viewer-page__category-title"
onClick={() => {
setCollapsedCategories(prev => {
const next = new Set(prev);
if (next.has(category.name)) {
next.delete(category.name);
} else {
next.add(category.name);
}
return next;
});
}}
>
<span className="linkml-viewer-page__category-arrow">
{isCategoryCollapsed ? '▶' : '▼'}
</span>
{category.displayName}
<span className="linkml-viewer-page__category-count">
({filteredFiles.length}{sidebarSearch ? `/${category.files.length}` : ''})
</span>
</button>
{!isCategoryCollapsed && (
<div className="linkml-viewer-page__file-list">
{filteredFiles.map(file => (
<button
key={file.path}
className={`linkml-viewer-page__file-item ${
selectedSchema?.path === file.path ? 'linkml-viewer-page__file-item--active' : ''
}`}
onClick={() => {
setSelectedSchema(file);
// Update URL params based on category type for deep linking
if (category.name === 'class') {
navigateToClass(file.name);
} else if (category.name === 'enum') {
navigateToEnum(file.name);
} else if (category.name === 'slot') {
navigateToSlot(file.name);
} else {
// For main schemas, clear the deep link params and close any popup
setSearchParams({});
setOntologyPopupCurie(null);
}
}}
>
{file.name}
</button>
))}
</div>
)}
</div>
);
})}
{/* No results message */}
{sidebarSearch && categories.every(category =>
!categoryFilters[category.name] ||
category.files.filter(file =>
file.name.toLowerCase().includes(sidebarSearch.toLowerCase())
).length === 0
) && (
<div className="linkml-viewer-page__no-results">
{t('noMatchingSchemas')}
</div>
)}
</>
)}
</aside>
{/* Main Content */}
<main className="linkml-viewer-page__main" ref={mainContentRef}>
{/* Collapsible header with title - hides when navigation collapses */}
<header className="linkml-viewer-page__header">
<h1 className="linkml-viewer-page__title">
{selectedSchema ? formatDisplayName(selectedSchema.name) : t('pageTitle')}
</h1>
</header>
{/* Sticky subheader with settings - always visible */}
<div className="linkml-viewer-page__subheader">
<div className="linkml-viewer-page__subheader-left">
{/* View mode tabs - keep visible for quick access */}
<div className="linkml-viewer-page__view-tabs">
<button
className={`linkml-viewer-page__tab ${viewMode === 'visual' ? 'linkml-viewer-page__tab--active' : ''}`}
onClick={() => setViewMode('visual')}
>
{t('visualView')}
</button>
<button
className={`linkml-viewer-page__tab ${viewMode === 'raw' ? 'linkml-viewer-page__tab--active' : ''}`}
onClick={() => setViewMode('raw')}
>
{t('rawYaml')}
</button>
</div>
</div>
<div className="linkml-viewer-page__subheader-right">
{/* Content Search Bar */}
<div className="linkml-viewer-page__content-search">
<div className="linkml-viewer-page__content-search-input-wrapper">
<input
type="text"
className="linkml-viewer-page__content-search-input"
placeholder={searchMode === 'name' ? t('nameSearchPlaceholder') : t('contentSearchPlaceholder')}
value={contentSearch}
onChange={(e) => setContentSearch(e.target.value)}
/>
{contentSearch && (
<button
className="linkml-viewer-page__content-search-clear"
onClick={() => setContentSearch('')}
title={t('clearSearch')}
>
×
</button>
)}
</div>
<div className="linkml-viewer-page__search-mode-toggle">
<button
className={`linkml-viewer-page__search-mode-btn ${searchMode === 'name' ? 'linkml-viewer-page__search-mode-btn--active' : ''}`}
onClick={() => setSearchMode('name')}
title={t('nameSearchPlaceholder')}
>
{t('searchModeNames')}
</button>
<button
className={`linkml-viewer-page__search-mode-btn ${searchMode === 'content' ? 'linkml-viewer-page__search-mode-btn--active' : ''}`}
onClick={() => setSearchMode('content')}
title={t('contentSearchPlaceholder')}
>
{t('searchModeContent')}
</button>
</div>
</div>
{/* Settings Menu Button */}
<div className="linkml-viewer-page__settings-wrapper">
<button
className={`linkml-viewer-page__settings-btn ${showSettingsMenu ? 'linkml-viewer-page__settings-btn--active' : ''}`}
onClick={() => setShowSettingsMenu(!showSettingsMenu)}
title={t('settingsTooltip')}
>
{t('settings')}
</button>
{/* Settings Dropdown Menu */}
{showSettingsMenu && (
<div className="linkml-viewer-page__settings-menu">
<div className="linkml-viewer-page__settings-section">
<h4 className="linkml-viewer-page__settings-section-title">{t('displayOptions')}</h4>
<label className="linkml-viewer-page__settings-option">
<input
type="checkbox"
checked={use3DIndicator}
onChange={() => setUse3DIndicator(!use3DIndicator)}
/>
<span>{use3DIndicator ? '🔷 3D' : '🏷️ 2D'} {language === 'nl' ? 'Indicatoren' : 'Indicators'}</span>
</label>
<label className="linkml-viewer-page__settings-option">
<input
type="checkbox"
checked={showSlotUsageLegend}
onChange={() => setShowSlotUsageLegend(!showSlotUsageLegend)}
/>
<span> {t('slotUsageLegendToggle')}</span>
</label>
</div>
<div className="linkml-viewer-page__settings-section">
<h4 className="linkml-viewer-page__settings-section-title">{t('moduleFilters')}</h4>
<label className="linkml-viewer-page__settings-option">
<input
type="checkbox"
checked={categoryFilters.main}
onChange={(e) => setCategoryFilters(prev => ({ ...prev, main: e.target.checked }))}
/>
<span className="linkml-viewer-page__filter-label--main">{t('mainSchema')}</span>
</label>
<label className="linkml-viewer-page__settings-option">
<input
type="checkbox"
checked={categoryFilters.class}
onChange={(e) => setCategoryFilters(prev => ({ ...prev, class: e.target.checked }))}
/>
<span className="linkml-viewer-page__filter-label--class">{t('classes')}</span>
</label>
<label className="linkml-viewer-page__settings-option">
<input
type="checkbox"
checked={categoryFilters.enum}
onChange={(e) => setCategoryFilters(prev => ({ ...prev, enum: e.target.checked }))}
/>
<span className="linkml-viewer-page__filter-label--enum">{t('enumerations')}</span>
</label>
<label className="linkml-viewer-page__settings-option">
<input
type="checkbox"
checked={categoryFilters.slot}
onChange={(e) => setCategoryFilters(prev => ({ ...prev, slot: e.target.checked }))}
/>
<span className="linkml-viewer-page__filter-label--slot">{t('slots')}</span>
</label>
</div>
</div>
)}
</div>
</div>
</div>
{/* Slot Usage Legend - explains the ✦ symbol and green indicators */}
{showSlotUsageLegend && (
<div className="linkml-viewer-page__slot-usage-legend">
<div className="linkml-viewer-page__slot-usage-legend-header">
<h4>{t('slotUsageLegendTitle')}</h4>
<button
className="linkml-viewer-page__slot-usage-legend-close"
onClick={() => setShowSlotUsageLegend(false)}
title="Close"
>×</button>
</div>
<div className="linkml-viewer-page__slot-usage-legend-content">
<div className="linkml-viewer-page__slot-usage-legend-item">
<span className="linkml-viewer__badge linkml-viewer__badge--usage">slot_usage</span>
<span>{t('slotUsageLegendBadge')}</span>
</div>
<div className="linkml-viewer-page__slot-usage-legend-item">
<span className="linkml-viewer-page__slot-usage-legend-marker"></span>
<span>{t('slotUsageLegendMarker')}</span>
</div>
<div className="linkml-viewer-page__slot-usage-legend-item">
<span className="linkml-viewer-page__slot-usage-legend-border-demo"></span>
<span>{t('slotUsageLegendBorder')}</span>
</div>
<div className="linkml-viewer-page__slot-usage-legend-item linkml-viewer-page__slot-usage-legend-item--info">
<span></span>
<span>{t('slotUsageLegendInherited')}</span>
</div>
</div>
</div>
)}
{/* Legend bar for 3D mode - shows all 19 custodian types with bidirectional hover sync */}
{use3DIndicator && (
<div className="linkml-viewer-page__legend-bar">
<CustodianTypeLegendBar
hoveredType={hoveredCustodianType}
onTypeHover={setHoveredCustodianType}
onTypeClick={handleCustodianTypeFilter}
highlightTypes={Array.from(custodianTypeFilter)}
size="small"
/>
{custodianTypeFilter.size > 0 && (
<button
className="linkml-viewer-page__filter-clear"
onClick={() => setCustodianTypeFilter(new Set())}
title={language === 'nl' ? 'Filter wissen' : 'Clear filter'}
>
{language === 'nl' ? 'Filter wissen' : 'Clear filter'} ({Array.from(custodianTypeFilter).join(', ')})
</button>
)}
</div>
)}
<div className="linkml-viewer-page__content">
{isLoading || isSchemaServiceLoading ? (
<LoadingScreen
message={schemaProgress?.message || t('loading')}
progress={schemaProgress?.percent}
size="medium"
fullscreen={false}
/>
) : error ? (
<div className="linkml-viewer-page__error">{error}</div>
) : viewMode === 'visual' ? (
renderVisualView()
) : (
renderRawView()
)}
</div>
</main>
{/* Ontology Term Popup - renders when a mapping tag is clicked */}
{ontologyPopupCurie && (
<OntologyTermPopup
curie={ontologyPopupCurie}
onClose={() => setOntologyPopupCurie(null)}
/>
)}
{/* Schema Element Popup - renders when clicking class/slot/enum in Imports/Exports */}
{schemaElementPopup && (
<SchemaElementPopup
elementName={schemaElementPopup.name}
elementType={schemaElementPopup.type}
slotName={schemaElementPopup.slotName}
overrides={schemaElementPopup.overrides}
onClose={() => setSchemaElementPopup(null)}
onNavigate={handleSchemaElementNavigate}
/>
)}
</div>
);
};
export default LinkMLViewerPage;