/** * 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 `${keyword}:${content.trim()}`; }); }; /** * 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 (?$1'); }; /** * 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(); 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(); sortedIndents.forEach((indent, index) => indentMap.set(indent, index)); // Build tree structure let html = '
'; 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, '') // Style arrows .trim(); // Clean up and add highlighting if (isHighlighted) { content = content.replace(/\(THIS\s*(CLASS|TYPE|LEVEL)?\)/g, ''); content = `${content.trim()}`; } // Skip empty vertical connector lines if (hasVerticalLine && !content) { html += `
`; continue; } // Determine the branch character to display let branchChar = ''; if (isLastBranch) { branchChar = '└──'; } else if (hasBranch) { branchChar = '├──'; } if (content) { const itemClass = isHighlighted ? 'linkml-tree-item linkml-tree-item--highlighted' : 'linkml-tree-item'; html += `
${branchChar}${content}
`; } } html += '
'; 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(); 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(); 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 = '
'; 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 += `
`; html += `${element.content}`; if (element.annotation) { html += `${element.annotation}`; } html += '
'; } else if (element.type === 'arrow') { html += `
`; } else if (element.type === 'connector') { html += `
`; } else if (element.type === 'branch') { html += `
${element.content}
`; } } html += '
'; 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 = '
'; for (const element of elements) { if (element.type === 'box') { const highlightClass = element.isHighlighted ? ' linkml-lifecycle-box--highlighted' : ''; html += `
`; html += `
${element.title}
`; 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 += '
'; for (const line of contentLines) { // Check if it's a list item (starts with -) if (line.trim().startsWith('-')) { html += `
${line.trim().substring(1).trim()}
`; } else if (line.trim().startsWith('✅') || line.trim().startsWith('❌')) { html += `
${line.trim()}
`; } else { html += `
${line}
`; } } html += '
'; } } html += '
'; } else if (element.type === 'arrow') { html += '
'; } else if (element.type === 'text') { html += `
${element.content}
`; } } html += '
'; 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, allImportInfo: Record, depth: number = 1, parentClasses: Record = {}, showImports: boolean = true, showExports: boolean = true, slotSemanticLabels: Map = new Map() ): UMLDiagram => { const nodes: UMLNode[] = []; const links: UMLLink[] = []; const addedNodes = new Set(); const addedLinks = new Set(); // Track links to avoid duplicates const processedAtDepth = new Map(); // 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([]); const [selectedSchema, setSelectedSchema] = useState(null); const [schema, setSchema] = useState(null); const [rawYaml, setRawYaml] = useState(null); const [viewMode, setViewMode] = useState<'visual' | 'raw'>('visual'); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [expandedSections, setExpandedSections] = useState>(new Set(['classes', 'enums', 'slots'])); const [highlightedClass, setHighlightedClass] = useState(null); const highlightedRef = useRef(null); // State for expandable enum ranges in slots const [expandedEnumRanges, setExpandedEnumRanges] = useState>(new Set()); const [loadedEnums, setLoadedEnums] = useState>({}); const [enumSearchFilters, setEnumSearchFilters] = useState>({}); const [enumShowAll, setEnumShowAll] = useState>({}); // State for pre-loaded custodian types (loaded async from schema annotations) // Maps element name -> custodian type codes const [classCustodianTypes, setClassCustodianTypes] = useState>({}); const [slotCustodianTypes, setSlotCustodianTypes] = useState>({}); const [enumCustodianTypes, setEnumCustodianTypes] = useState>({}); const [_custodianTypesLoaded, setCustodianTypesLoaded] = useState(false); // State for sidebar search and category filters // Category filters are persisted in localStorage const [sidebarSearch, setSidebarSearch] = useState(''); const [categoryFilters, setCategoryFilters] = useState>(() => { 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(false); // State for copy to clipboard feedback const [copyFeedback, setCopyFeedback] = useState(false); // State for expandable Exports section in class details const [expandedExports, setExpandedExports] = useState>(new Set()); const [classExports, setClassExports] = useState>({}); const [loadingExports, setLoadingExports] = useState>(new Set()); // State for expandable Imports section in class details (dependencies) const [expandedImports, setExpandedImports] = useState>(new Set()); const [classImports, setClassImports] = useState>({}); const [loadingImports, setLoadingImports] = useState>(new Set()); // State for expandable Exports section in slot details (which classes use this slot) const [expandedSlotExports, setExpandedSlotExports] = useState>(new Set()); const [slotExports, setSlotExports] = useState>({}); const [loadingSlotExports, setLoadingSlotExports] = useState>(new Set()); // State for expandable Imports section in slot details (what this slot depends on) const [expandedSlotImports, setExpandedSlotImports] = useState>(new Set()); const [slotImports, setSlotImports] = useState>({}); const [loadingSlotImports, setLoadingSlotImports] = useState>(new Set()); // State for expandable UML diagram section in class details const [expandedUML, setExpandedUML] = useState>(new Set()); // State for expandable class slots section in class details const [expandedClassSlots, setExpandedClassSlots] = useState>(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>(new Map()); // State for UML depth slider per class (1 = direct relationships only, 2 = secondary, etc.) const [umlDepth, setUmlDepth] = useState>({}); // State for UML direction toggles per class (show imports, exports, or both) // Default: both enabled (true) const [umlShowImports, setUmlShowImports] = useState>({}); const [umlShowExports, setUmlShowExports] = useState>({}); // State for UML layout type per class const [umlLayoutType, setUmlLayoutType] = useState>({}); const [umlDagreDirection, setUmlDagreDirection] = useState>({}); // State for UML fullscreen mode per class const [umlFullscreen, setUmlFullscreen] = useState(null); // State for layout dropdown open per class const [umlLayoutDropdownOpen, setUmlLayoutDropdownOpen] = useState>({}); // State for parent class mapping (className -> parentClassName from is_a inheritance) // Built incrementally as classes are loaded for UML diagrams const [parentClassMap, setParentClassMap] = useState>({}); // State for service-loaded slot definitions (from getAllSlots) // Used to resolve slot names to full definitions in class details const [serviceSlots, setServiceSlots] = useState>(new Map()); // State for slot semantic labels (slot name -> semantic predicate like "schema:collection") // Computed from serviceSlots when available const [slotSemanticLabels, setSlotSemanticLabels] = useState>(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(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(''); 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>(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>(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>(() => { 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(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(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(); const classesToLoadImports = new Set(); const visited = new Set(); 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(); // 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>(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 => { 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 = {}; for (const [name, types] of classResults) { classTypesMap[name] = types; } const slotTypesMap: Record = {}; for (const [name, types] of slotResults) { slotTypesMap[name] = types; } const enumTypesMap: Record = {}; 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(); 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
{t('loading')}
; } 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 (
{/* Search input */}
setEnumSearchFilters(prev => ({ ...prev, [enumName]: e.target.value }))} className="linkml-viewer__range-enum-search-input" /> {searchFilter && ( )}
{/* Count display */} {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} )}
{valuesToShow.length > 0 ? ( valuesToShow.map(([value, details]) => (
{value} {details.meaning && ( 🔗 )} {details.description && ( {details.description} )}
)) ) : (
{t('noResults')}
)}
{/* Show all / Show less button */} {!searchFilter && allValues.length > displayCount && ( )}
); }; 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 (

{cls.name} {cls.abstract && {t('abstract')}} {/* Show 3D polygon for ALL elements (including universal) in 3D mode for testing */} {use3DIndicator ? ( ) : ( !isUniversal && ( ) )}

{cls.class_uri && (
{t('uri')} {cls.class_uri}
)} {cls.description && (
{transformContent(cls.description)}
)} {/* Class Slots Section - Accordion showing slot details for this class */} {cls.slots && cls.slots.length > 0 && (
{expandedClassSlots.has(cls.name) && (
{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 (

{slotName}

Slot definition not found in schema
); })}
)}
)} {cls.exact_mappings && cls.exact_mappings.length > 0 && (
{t('exactMappings')}
{cls.exact_mappings.map(mapping => ( ))}
)} {cls.close_mappings && cls.close_mappings.length > 0 && (
{t('closeMappings')}
{cls.close_mappings.map(mapping => ( ))}
)} {/* Imports Section - Shows forward dependencies (what this class depends on) */} {isSchemaServiceComplete && (
{expandedImports.has(cls.name) && classImports[cls.name] && (
{/* Parent Class (is_a) */} {classImports[cls.name].parentClass && (
{t('parentClassLabel')}
)} {/* Mixins */} {classImports[cls.name].mixins.length > 0 && (
{t('mixinsLabel')} ({classImports[cls.name].mixins.length})
{classImports[cls.name].mixins.map(mixin => ( ))}
)} {/* Slot Ranges */} {classImports[cls.name].slotRanges.length > 0 && (
{t('slotRangesLabel')} ({classImports[cls.name].slotRanges.length})
{classImports[cls.name].slotRanges.map(({ slotName, rangeType, isClass }) => ( ))}
)} {/* Slot Usage Ranges */} {classImports[cls.name].slotUsageRanges.length > 0 && (
{t('slotUsageRangesLabel')} ({classImports[cls.name].slotUsageRanges.length})
{classImports[cls.name].slotUsageRanges.map(({ slotName, rangeType }) => ( ))}
)} {/* 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 && (
{t('noImports')}
)}
)}
)} {/* Exports Section - Shows reverse dependencies (what references this class) */} {isSchemaServiceComplete && (
{expandedExports.has(cls.name) && classExports[cls.name] && (
{/* Subclasses */} {classExports[cls.name].subclasses.length > 0 && (
Subclasses ({classExports[cls.name].subclasses.length})
{classExports[cls.name].subclasses.map(subclass => ( ))}
)} {/* Mixin Users */} {classExports[cls.name].mixinUsers.length > 0 && (
Used as Mixin by ({classExports[cls.name].mixinUsers.length})
{classExports[cls.name].mixinUsers.map(user => ( ))}
)} {/* Slots with this Range */} {classExports[cls.name].slotsWithThisRange.length > 0 && (
Slots with this Range ({classExports[cls.name].slotsWithThisRange.length})
{classExports[cls.name].slotsWithThisRange.map(slot => ( {slot} ))}
)} {/* Classes using slots with this range */} {classExports[cls.name].classesUsingSlotWithThisRange.length > 0 && (
Classes Using Slots with this Range ({classExports[cls.name].classesUsingSlotWithThisRange.length})
{classExports[cls.name].classesUsingSlotWithThisRange.map(({ className, slotName }) => ( ))}
)} {/* Classes referencing in slot_usage */} {classExports[cls.name].classesReferencingInSlotUsage.length > 0 && (
Referenced in slot_usage ({classExports[cls.name].classesReferencingInSlotUsage.length})
{classExports[cls.name].classesReferencingInSlotUsage.map(refClass => ( ))}
)} {/* 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 && (
No other schema elements reference this class.
)}
)}
)} {/* UML Diagram Section - Shows filtered class relationship diagram */} {isSchemaServiceComplete && (
{/* 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] && (
{/* Direction toggles (Imports/Exports checkboxes) */}
{/* Depth slider */}
{(umlDepth[cls.name] || 1) === 1 ? 'Direct relationships' : (umlDepth[cls.name] || 1) === 2 ? 'Secondary connections' : (umlDepth[cls.name] || 1) === 3 ? 'Tertiary connections' : 'Extended network'}
{/* UML Toolbar */}
{/* Zoom Controls */}
{/* Fullscreen Toggle */}
{/* Layout Dropdown */}
{umlLayoutDropdownOpen[cls.name] && (
)}
{/* Show empty state when both toggles are unchecked */} {umlShowImports[cls.name] === false && umlShowExports[cls.name] === false ? (
🔗

{t('umlNoRelationshipsShown')}

) : (
{umlFullscreen === cls.name && ( )}
)}
)} {/* 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) && (
Click to load class relationships.
)}
)}
); }; const renderSlotDetails = ( slot: LinkMLSlot, slotUsage: Partial | 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 ( {overridden ? t('slotUsageOverrideMarker') : ''} ); }; return (

{slot.name} {hasSlotUsageOverrides && {t('slotUsageBadge')}} {/* Compare button - only show when slot_usage exists and we have both generic and override */} {hasSlotUsageOverrides && genericSlot && className && ( )} {slot.required && {t('required')}} {slot.multivalued && {t('multivalued')}} {slot.identifier && {t('identifier')}} {/* Show 3D polygon for ALL elements (including universal) in 3D mode for testing */} {use3DIndicator ? ( ) : ( !isUniversal && ( ) )}

{/* Slot URI - semantic predicate */} {slot.slot_uri && (
{t('slotUri')} {slot.slot_uri}
)} {slot.range && (
{t('range')} {rangeIsEnum ? ( ) : ( {slot.range} )}
)} {/* Expandable enum values */} {rangeIsEnum && isExpanded && slot.range && renderEnumValues(slot.name, slot.range)} {slot.description && (
{transformContent(slot.description)}
)} {slot.pattern && (
{t('pattern')} {slot.pattern}
)} {/* Examples section */} {slot.examples && slot.examples.length > 0 && (
{t('examples')}
    {slot.examples.map((example, idx) => (
  • {example.value} {example.description && ( - {example.description} )}
  • ))}
)} {slot.exact_mappings && slot.exact_mappings.length > 0 && (
{t('exactMappings')}
{slot.exact_mappings.map(mapping => ( ))}
)} {slot.close_mappings && slot.close_mappings.length > 0 && (
{t('closeMappings')}
{slot.close_mappings.map(mapping => ( ))}
)} {slot.narrow_mappings && slot.narrow_mappings.length > 0 && (
{t('narrowMappings')}
{slot.narrow_mappings.map(mapping => ( ))}
)} {slot.broad_mappings && slot.broad_mappings.length > 0 && (
{t('broadMappings')}
{slot.broad_mappings.map(mapping => ( ))}
)} {slot.related_mappings && slot.related_mappings.length > 0 && (
{t('relatedMappings')}
{slot.related_mappings.map(mapping => ( ))}
)} {/* Comments section */} {slot.comments && slot.comments.length > 0 && (
{t('comments')}
    {slot.comments.map((comment, idx) => (
  • {comment}
  • ))}
)} {/* Imports Section - Shows forward dependencies (what this slot depends on) */} {!className && isSchemaServiceComplete && (
{expandedSlotImports.has(slot.name) && slotImports[slot.name] && (
{/* Range Type (main dependency) */} {slotImports[slot.name].rangeType && (
{t('rangeTypeLabel')}
)} {/* Any_of Types (union types) */} {slotImports[slot.name].anyOfTypes.length > 0 && (
{t('anyOfTypesLabel')} ({slotImports[slot.name].anyOfTypes.length})
{slotImports[slot.name].anyOfTypes.map(type => ( ))}
)} {/* No imports message */} {!slotImports[slot.name].rangeType && slotImports[slot.name].anyOfTypes.length === 0 && (
{t('noSlotImports')}
)}
)}
)} {/* Exports Section - Shows reverse dependencies (what classes use this slot) */} {!className && isSchemaServiceComplete && (
{expandedSlotExports.has(slot.name) && slotExports[slot.name] && (
{/* Classes that use this slot */} {slotExports[slot.name].classesUsingSlot.length > 0 && (
{t('classesUsingSlot')} ({slotExports[slot.name].classesUsingSlot.length})
{slotExports[slot.name].classesUsingSlot.map(cls => ( ))}
)} {/* Classes with slot_usage overrides */} {slotExports[slot.name].classesWithSlotUsage.length > 0 && (
{t('classesWithSlotUsage')} ({slotExports[slot.name].classesWithSlotUsage.length})
{slotExports[slot.name].classesWithSlotUsage.map(({ className: cls, overrides }) => ( ))}
)} {/* No exports message */} {slotExports[slot.name].classesUsingSlot.length === 0 && slotExports[slot.name].classesWithSlotUsage.length === 0 && (
{t('noClassesUsingSlot')}
)}
)}
)} {/* Side-by-side comparison view - only shown when expanded */} {showComparison && hasSlotUsageOverrides && genericSlot && (
{t('slotUsageCompareGeneric')}
{t('slotUsageCompareOverride')}
{/* 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)}
)}
); }; // 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 (
{property}
{hasGeneric ? ( {formatValue(genericValue)} ) : ( {t('slotUsageCompareNotDefined')} )}
{hasOverride ? ( <> {formatValue(overrideValue)} {(isChanged || isNewInOverride) && {t('slotUsageCompareDiff')}} ) : ( {t('slotUsageCompareInherited')} )}
); }; 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 (

{enumDef.name} {/* Show 3D polygon for ALL elements (including universal) in 3D mode for testing */} {use3DIndicator ? ( ) : ( !isUniversal && ( ) )}

{enumDef.description && (
{transformContent(enumDef.description)}
)} {allValues.length > 0 && (
{/* Search input */}
setEnumSearchFilters(prev => ({ ...prev, [enumName]: e.target.value }))} className="linkml-viewer__range-enum-search-input" /> {searchFilter && ( )}
{/* Count display */} {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} )}
{valuesToShow.length > 0 ? ( valuesToShow.map(([value, details]) => (
{value} {details.description && (
{transformContent(details.description)}
)} {details.meaning && ( {t('meaning')} {details.meaning} )}
)) ) : (
{t('noResults')}
)}
{/* Show all / Show less button */} {!searchFilter && allValues.length > displayCount && ( )}
)}
); }; 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 (
{/* Schema Metadata */}

{formatDisplayName(schema.name || schema.title || '') || t('unnamedSchema')} {(matchingClassCount > 0 || matchingEnumCount > 0 || matchingSlotCount > 0) && ( ({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')}`}) )}

{schema.description && (
{transformContent(schema.description)}
)} {schema.id && (
{t('id')} {schema.id}
)} {schema.version && (
{t('version')} {schema.version}
)}
{/* No search results message */} {noSearchResults && (
🔍
{t('noSearchResults')}: "{contentSearch}"
)} {/* Classes - rendered directly without collapsible header */} {filteredClasses.length > 0 && (
1 ? 'linkml-viewer__section--multiple' : ''}`}> {filteredClasses.map(renderClassDetails)}
)} {/* Slots - rendered directly without collapsible header */} {filteredSlots.length > 0 && (
1 ? 'linkml-viewer__section--multiple' : ''}`}> {filteredSlots.map(slot => renderSlotDetails(slot))}
)} {/* Enums - rendered directly without collapsible header */} {filteredEnums.length > 0 && (
1 ? 'linkml-viewer__section--multiple' : ''}`}> {filteredEnums.map(renderEnumDetails)}
)} {/* Developer Tools Section - Prefixes and Imports */} {((schema.prefixes && Object.keys(schema.prefixes).length > 0) || (schema.imports && schema.imports.length > 0)) && (
{showDeveloperTools && (
{/* Prefixes */} {schema.prefixes && Object.keys(schema.prefixes).length > 0 && (
{expandedSections.has('prefixes') && (
{Object.entries(schema.prefixes).map(([prefix, uri]) => (
{prefix}: {uri}
))}
)}
)} {/* Imports - File Level (LinkML module imports) */} {schema.imports && schema.imports.length > 0 && (
{expandedSections.has('imports') && (
{schema.imports.map(imp => (
{imp}
))}
)}
)}
)}
)}
); }; 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 (
{rawYaml}
); }; return (
{/* Left Sidebar - Schema Files */} {/* Main Content */}
{/* Collapsible header with title - hides when navigation collapses */}

{selectedSchema ? formatDisplayName(selectedSchema.name) : t('pageTitle')}

{/* Sticky subheader with settings - always visible */}
{/* View mode tabs - keep visible for quick access */}
{/* Content Search Bar */}
setContentSearch(e.target.value)} /> {contentSearch && ( )}
{/* Settings Menu Button */}
{/* Settings Dropdown Menu */} {showSettingsMenu && (

{t('displayOptions')}

{t('moduleFilters')}

)}
{/* Slot Usage Legend - explains the ✦ symbol and green indicators */} {showSlotUsageLegend && (

{t('slotUsageLegendTitle')}

slot_usage {t('slotUsageLegendBadge')}
{t('slotUsageLegendMarker')}
{t('slotUsageLegendBorder')}
ℹ️ {t('slotUsageLegendInherited')}
)} {/* Legend bar for 3D mode - shows all 19 custodian types with bidirectional hover sync */} {use3DIndicator && (
{custodianTypeFilter.size > 0 && ( )}
)}
{isLoading || isSchemaServiceLoading ? ( ) : error ? (
{error}
) : viewMode === 'visual' ? ( renderVisualView() ) : ( renderRawView() )}
{/* Ontology Term Popup - renders when a mapping tag is clicked */} {ontologyPopupCurie && ( setOntologyPopupCurie(null)} /> )} {/* Schema Element Popup - renders when clicking class/slot/enum in Imports/Exports */} {schemaElementPopup && ( setSchemaElementPopup(null)} onNavigate={handleSchemaElementNavigate} /> )}
); }; export default LinkMLViewerPage;