glam/frontend/src/pages/LinkMLViewerPage.tsx
2025-12-30 23:07:03 +01:00

2381 lines
93 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* LinkMLViewerPage.tsx - LinkML Schema Viewer Page
*
* Displays LinkML schema files with:
* - Sidebar listing schemas by category (main, classes, enums, slots)
* - Visual display of selected schema showing classes, slots, and enums
* - Raw YAML view toggle
* - Schema metadata and documentation
* - URL parameter support for deep linking to specific classes (?class=ClassName)
*/
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import debounce from 'lodash/debounce';
import { useSearchParams } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import {
loadSchema,
loadSchemaRaw,
type LinkMLSchema,
type LinkMLClass,
type LinkMLSlot,
type LinkMLEnum,
type SchemaFile,
extractClasses,
extractSlots,
extractEnums,
} from '../lib/linkml/schema-loader';
import { linkmlSchemaService, type ClassExportInfo } 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 './LinkMLViewerPage.css';
/**
* Converts snake_case or kebab-case identifiers to human-readable Title Case.
*
* Examples:
* - "custodian_appellation_class" → "Custodian Appellation Class"
* - "feature_place_class" → "Feature Place Class"
* - "heritage-custodian-observation" → "Heritage Custodian Observation"
*/
const formatDisplayName = (name: string): string => {
if (!name) return name;
return name
// Replace underscores and hyphens with spaces
.replace(/[_-]/g, ' ')
// Capitalize first letter of each word
.replace(/\b\w/g, char => char.toUpperCase());
};
/**
* Admonition transformer for markdown content.
* Converts patterns like "CRITICAL: message" into styled admonition elements.
*
* Supported patterns:
* - CRITICAL: Urgent, must-know information (red)
* - WARNING: Potential issues or gotchas (orange)
* - IMPORTANT: Significant but not urgent (amber)
* - NOTE: Informational, helpful context (blue)
*
* The pattern captures everything from the keyword until:
* - End of line (for single-line admonitions)
* - A blank line (paragraph break)
*/
const transformAdmonitions = (text: string): string => {
if (!text) return text;
// Pattern matches: KEYWORD: rest of the text until end of paragraph
// [^\n]* captures everything on the current line
// (?:\n(?!\n)[^\n]*)* captures continuation lines (lines that don't start a new paragraph)
const admonitionPattern = /\b(CRITICAL|WARNING|IMPORTANT|NOTE):\s*([^\n]*(?:\n(?!\n)[^\n]*)*)/g;
return text.replace(admonitionPattern, (_match, keyword, content) => {
const type = keyword.toLowerCase();
// Return HTML that ReactMarkdown will pass through
return `<span class="linkml-admonition linkml-admonition--${type}"><span class="linkml-admonition__label">${keyword}:</span><span class="linkml-admonition__content">${content.trim()}</span></span>`;
});
};
/**
* CURIE (Compact URI) highlighter for markdown content.
* Highlights patterns like "schema:name", "crm:E41_Appellation", "skos:altLabel".
*
* CURIE format: prefix:localName
* - prefix: lowercase letters (e.g., schema, crm, skos, foaf, dcterms)
* - localName: letters, numbers, underscores, hyphens (e.g., name, E41_Appellation)
*
* Also handles compound CURIEs like "schema:dateCreated/dateModified"
*/
const highlightCuries = (text: string): string => {
if (!text) return text;
// Common ontology prefixes used in heritage/linked data
const knownPrefixes = [
'schema', 'crm', 'skos', 'foaf', 'dcterms', 'dct', 'dc', 'rdfs', 'rdf', 'owl',
'prov', 'org', 'locn', 'cpov', 'tooi', 'rico', 'bf', 'wikidata', 'wd', 'wdt',
'xsd', 'linkml', 'hc', 'pico', 'cv'
].join('|');
// Pattern matches: prefix:localName (with optional /additional parts)
// Negative lookbehind (?<![:/]) prevents matching URLs like http://...
// Negative lookahead (?![:/]) prevents partial URL matches
const curiePattern = new RegExp(
`(?<![:/])\\b((?:${knownPrefixes}):(?:[A-Za-z][A-Za-z0-9_-]*(?:/[A-Za-z][A-Za-z0-9_-]*)*))(?![:/])`,
'g'
);
return text.replace(curiePattern, '<code class="linkml-curie">$1</code>');
};
/**
* ASCII Tree Diagram transformer.
* Converts ASCII tree diagrams (using ├── └── │ characters) into styled HTML trees.
*
* Detects patterns like:
* ```
* Archives nationales (national)
* └── Archives régionales (regional)
* └── Archives départementales (THIS TYPE)
* └── Archives communales (municipal)
* ```
*
* Or more complex trees:
* ```
* Custodian (hub)
* │
* └── CustodianCollection (aspect)
* ├── CollectionType (classification)
* ├── AccessPolicy (access restrictions)
* └── sub_collections → Collection[]
* ```
*/
const transformTreeDiagrams = (text: string): string => {
if (!text) return text;
const codeBlockPattern = /```\n?([\s\S]*?)\n?```/g;
return text.replace(codeBlockPattern, (match, codeContent: string) => {
// Check if this code block contains tree characters but NOT box diagrams
const hasTreeChars = /[├└│]──/.test(codeContent) || /\s+└──/.test(codeContent);
const hasBoxChars = /[┌┐]/.test(codeContent); // Only top box corners indicate box diagram
if (!hasTreeChars || hasBoxChars) {
// Not a tree diagram (or is a box diagram), skip
return match;
}
const lines = codeContent.split('\n').filter(l => l.trim());
if (lines.length === 0) return match;
// First pass: detect unique indentation levels to build proper hierarchy
const indentLevels = new Set<number>();
for (const line of lines) {
const leadingSpaces = line.match(/^(\s*)/)?.[1]?.length || 0;
indentLevels.add(leadingSpaces);
}
// Sort indent levels to map them to 0, 1, 2, 3...
const sortedIndents = Array.from(indentLevels).sort((a, b) => a - b);
const indentMap = new Map<number, number>();
sortedIndents.forEach((indent, index) => indentMap.set(indent, index));
// Build tree structure
let html = '<div class="linkml-tree-diagram">';
for (const line of lines) {
// Get actual indent level from map
const leadingSpaces = line.match(/^(\s*)/)?.[1]?.length || 0;
const indentLevel = indentMap.get(leadingSpaces) || 0;
// Check for tree branch characters
const hasBranch = /├──|└──/.test(line);
const isLastBranch = /└──/.test(line);
const hasVerticalLine = /│/.test(line) && !/[├└]/.test(line);
// Check if this line is highlighted (THIS CLASS, THIS TYPE, etc.)
const isHighlighted = /\(THIS\s*(CLASS|TYPE|LEVEL)?\)/.test(line);
// Extract the content after tree characters
let content = line
.replace(/^[\s│├└─]+/, '') // Remove leading spaces and tree chars
.replace(/→/g, '<span class="linkml-tree-arrow">→</span>') // Style arrows
.trim();
// Clean up and add highlighting
if (isHighlighted) {
content = content.replace(/\(THIS\s*(CLASS|TYPE|LEVEL)?\)/g, '');
content = `<span class="linkml-tree-highlight">${content.trim()}</span>`;
}
// Skip empty vertical connector lines
if (hasVerticalLine && !content) {
html += `<div class="linkml-tree-connector" style="margin-left: ${indentLevel * 1.5}rem;">│</div>`;
continue;
}
// Determine the branch character to display
let branchChar = '';
if (isLastBranch) {
branchChar = '<span class="linkml-tree-branch">└──</span>';
} else if (hasBranch) {
branchChar = '<span class="linkml-tree-branch">├──</span>';
}
if (content) {
const itemClass = isHighlighted ? 'linkml-tree-item linkml-tree-item--highlighted' : 'linkml-tree-item';
html += `<div class="${itemClass}" style="margin-left: ${indentLevel * 1.5}rem;">${branchChar}${content}</div>`;
}
}
html += '</div>';
return html;
});
};
/**
* Arrow-based Flow Diagram transformer.
* Converts simple arrow-based flow diagrams (using ↓ → ← characters) into styled HTML.
*
* Detects patterns like:
* ```
* Current Archive (active use)
* ↓
* DEPOSIT ARCHIVE (semi-current) ← THIS TYPE
* ↓
* Historical Archive (permanent preservation)
* or
* Destruction (per retention schedule)
* ```
*
* Or horizontal flows:
* ```
* DepositArchive (custodian type)
* │
* └── operates_storage → Storage (facility instance)
* │
* └── has_storage_type → StorageType
* └── DEPOSIT_STORAGE
* ```
*/
const transformFlowDiagrams = (text: string): string => {
if (!text) return text;
const codeBlockPattern = /```\n?([\s\S]*?)\n?```/g;
return text.replace(codeBlockPattern, (match, codeContent: string) => {
// Skip if it has tree branch characters (├── └──) - handled by tree transformer
const hasTreeChars = /[├└]──/.test(codeContent);
// Skip if it has box characters (┌─┐) - handled by lifecycle transformer
const hasBoxChars = /[┌┐]/.test(codeContent);
if (hasTreeChars || hasBoxChars) {
return match; // Let other transformers handle these
}
// Check if this is an arrow-based flow diagram
// Must have vertical arrows (↓) or be a simple vertical flow with indented items
const hasVerticalArrows = /↓/.test(codeContent);
const hasHorizontalArrows = /[←→]/.test(codeContent);
const hasVerticalPipe = /│/.test(codeContent);
// Must have at least arrows to be considered a flow diagram
if (!hasVerticalArrows && !hasHorizontalArrows && !hasVerticalPipe) {
return match;
}
const lines = codeContent.split('\n');
const elements: Array<{
type: 'node' | 'arrow' | 'branch' | 'connector';
content: string;
annotation?: string;
isHighlighted?: boolean;
indentLevel?: number;
}> = [];
// Detect indent levels
const indentLevels = new Set<number>();
for (const line of lines) {
if (line.trim()) {
const leadingSpaces = line.match(/^(\s*)/)?.[1]?.length || 0;
indentLevels.add(leadingSpaces);
}
}
const sortedIndents = Array.from(indentLevels).sort((a, b) => a - b);
const indentMap = new Map<number, number>();
sortedIndents.forEach((indent, index) => indentMap.set(indent, index));
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
const leadingSpaces = line.match(/^(\s*)/)?.[1]?.length || 0;
const indentLevel = indentMap.get(leadingSpaces) || 0;
// Check for pure arrow lines
if (/^[↓⬇]+$/.test(trimmedLine)) {
elements.push({ type: 'arrow', content: '↓', indentLevel });
continue;
}
// Check for pure vertical connector lines
if (/^│+$/.test(trimmedLine)) {
elements.push({ type: 'connector', content: '│', indentLevel });
continue;
}
// Check for "or" / "and" branching keywords
if (/^(or|and|OR|AND)$/i.test(trimmedLine)) {
elements.push({ type: 'branch', content: trimmedLine.toLowerCase(), indentLevel });
continue;
}
// This is a node - check for highlighting markers and annotations
let content = trimmedLine;
let annotation = '';
let isHighlighted = false;
// Check for THIS TYPE/CLASS markers
if (/←\s*(THIS\s*(TYPE|CLASS)?|DEZE\s*(TYPE|KLASSE)?)/i.test(content)) {
isHighlighted = true;
// Extract the marker as annotation
const markerMatch = content.match(/←\s*(THIS\s*(TYPE|CLASS)?|DEZE\s*(TYPE|KLASSE)?)/i);
if (markerMatch) {
annotation = markerMatch[1];
content = content.replace(/\s*←\s*(THIS\s*(TYPE|CLASS)?|DEZE\s*(TYPE|KLASSE)?)/gi, '');
}
}
// Check for parenthetical annotations like "(active use)"
const parenMatch = content.match(/\(([^)]+)\)\s*$/);
if (parenMatch && !isHighlighted) {
annotation = parenMatch[1];
content = content.replace(/\s*\([^)]+\)\s*$/, '');
}
// Clean up any remaining tree characters that might have slipped through
content = content.replace(/^[│\s]+/, '').trim();
if (content) {
elements.push({
type: 'node',
content,
annotation: annotation || undefined,
isHighlighted,
indentLevel
});
}
}
// If we didn't parse meaningful elements, return original
if (elements.length < 2) {
return match;
}
// Build HTML output
let html = '<div class="linkml-flow-diagram">';
for (const element of elements) {
const indentStyle = element.indentLevel ? ` style="margin-left: ${element.indentLevel * 1.5}rem;"` : '';
if (element.type === 'node') {
const highlightClass = element.isHighlighted ? ' linkml-flow-node--highlighted' : '';
html += `<div class="linkml-flow-node${highlightClass}"${indentStyle}>`;
html += `<span class="linkml-flow-node__content">${element.content}</span>`;
if (element.annotation) {
html += `<span class="linkml-flow-node__annotation">${element.annotation}</span>`;
}
html += '</div>';
} else if (element.type === 'arrow') {
html += `<div class="linkml-flow-arrow"${indentStyle}>↓</div>`;
} else if (element.type === 'connector') {
html += `<div class="linkml-flow-connector"${indentStyle}>│</div>`;
} else if (element.type === 'branch') {
html += `<div class="linkml-flow-branch"${indentStyle}>${element.content}</div>`;
}
}
html += '</div>';
return html;
});
};
/**
* ASCII Lifecycle Diagram transformer.
* Converts ASCII box diagrams (using ┌─┐│└─┘ characters) into styled HTML cards.
*
* Detects patterns like:
* ```
* ┌─────────────────────────────────────┐
* │ CustodianAdministration │
* │ ═════════════════════════ │
* │ ACTIVE records in daily use │
* └─────────────────────────────────────┘
* ↓
* ┌─────────────────────────────────────┐
* │ CustodianArchive │
* └─────────────────────────────────────┘
* ```
*
* And converts them to styled HTML cards with flow arrows.
*/
const transformLifecycleDiagrams = (text: string): string => {
if (!text) return text;
// First transform tree diagrams, then flow diagrams
text = transformTreeDiagrams(text);
text = transformFlowDiagrams(text);
// Pattern to match markdown code blocks containing ASCII box diagrams
// Looks for ``` followed by content containing box-drawing characters
const codeBlockPattern = /```\n?([\s\S]*?)\n?```/g;
return text.replace(codeBlockPattern, (match, codeContent: string) => {
// Check if this code block contains ASCII box diagrams (with top corners)
const hasBoxChars = /[┌┐└┘│─═]/.test(codeContent) && /[┌┐]/.test(codeContent);
if (!hasBoxChars) {
// Not a box diagram, return original code block
return match;
}
// Parse the diagram into boxes and connectors
const lines = codeContent.split('\n');
const elements: Array<{ type: 'box' | 'arrow' | 'text'; content: string; title?: string; isHighlighted?: boolean }> = [];
let currentBox: string[] = [];
let inBox = false;
for (const line of lines) {
const trimmedLine = line.trim();
// Check for box start
if (trimmedLine.startsWith('┌') && trimmedLine.endsWith('┐')) {
inBox = true;
currentBox = [];
continue;
}
// Check for box end
if (trimmedLine.startsWith('└') && trimmedLine.endsWith('┘')) {
if (currentBox.length > 0) {
// Extract title (first non-empty line or line with ═)
let title = '';
let content: string[] = [];
let isHighlighted = false;
for (let i = 0; i < currentBox.length; i++) {
const boxLine = currentBox[i];
// Check if this is a title line (followed by ═ underline or contains "(THIS CLASS)")
if (i === 0 || boxLine.includes('═') || boxLine.includes('THIS CLASS')) {
if (boxLine.includes('═')) {
// This is an underline, title was previous line
continue;
}
if (boxLine.includes('THIS CLASS') || boxLine.includes('(THIS')) {
isHighlighted = true;
title = boxLine.replace(/\(THIS CLASS\)/g, '').replace(/\(THIS\)/g, '').trim();
} else if (!title && boxLine.trim()) {
title = boxLine.trim();
} else {
content.push(boxLine);
}
} else {
content.push(boxLine);
}
}
// Clean up title - remove leading/trailing special characters
title = title.replace(/^[═\s]+|[═\s]+$/g, '').trim();
elements.push({
type: 'box',
title: title || 'Untitled',
content: content.filter(l => l.trim() && !l.includes('═')).join('\n'),
isHighlighted
});
}
inBox = false;
continue;
}
// Inside a box - collect content
if (inBox) {
// Remove box side characters and clean up
const cleanLine = trimmedLine
.replace(/^│\s*/, '')
.replace(/\s*│$/, '')
.trim();
if (cleanLine) {
currentBox.push(cleanLine);
}
continue;
}
// Check for arrow/connector lines
if (trimmedLine.includes('↓') || trimmedLine.includes('→') || trimmedLine.includes('⬇')) {
elements.push({ type: 'arrow', content: '↓' });
continue;
}
// Check for text between boxes (descriptions of transitions)
if (trimmedLine.startsWith('(') && (trimmedLine.endsWith(')') || trimmedLine.includes(')'))) {
// This is explanatory text in parentheses
const textContent = trimmedLine.replace(/^\(|\)$/g, '').trim();
elements.push({ type: 'text', content: textContent });
continue;
}
// Other non-empty lines between boxes (transition descriptions)
if (trimmedLine && !trimmedLine.match(/^[─┌┐└┘│═]+$/)) {
// Continuation of previous text or standalone text
const lastElement = elements[elements.length - 1];
if (lastElement && lastElement.type === 'text') {
lastElement.content += ' ' + trimmedLine.replace(/^\(|\)$/g, '');
} else if (trimmedLine.length > 2) {
elements.push({ type: 'text', content: trimmedLine.replace(/^\(|\)$/g, '') });
}
}
}
// If no elements were parsed, return original
if (elements.length === 0) {
return match;
}
// Build HTML output
let html = '<div class="linkml-lifecycle-diagram">';
for (const element of elements) {
if (element.type === 'box') {
const highlightClass = element.isHighlighted ? ' linkml-lifecycle-box--highlighted' : '';
html += `<div class="linkml-lifecycle-box${highlightClass}">`;
html += `<div class="linkml-lifecycle-box__title">${element.title}</div>`;
if (element.content) {
// Convert content lines to list items or paragraphs
const contentLines = element.content.split('\n').filter(l => l.trim());
if (contentLines.length > 0) {
html += '<div class="linkml-lifecycle-box__content">';
for (const line of contentLines) {
// Check if it's a list item (starts with -)
if (line.trim().startsWith('-')) {
html += `<div class="linkml-lifecycle-box__item">${line.trim().substring(1).trim()}</div>`;
} else if (line.trim().startsWith('✅') || line.trim().startsWith('❌')) {
html += `<div class="linkml-lifecycle-box__item">${line.trim()}</div>`;
} else {
html += `<div class="linkml-lifecycle-box__line">${line}</div>`;
}
}
html += '</div>';
}
}
html += '</div>';
} else if (element.type === 'arrow') {
html += '<div class="linkml-lifecycle-arrow">↓</div>';
} else if (element.type === 'text') {
html += `<div class="linkml-lifecycle-text">${element.content}</div>`;
}
}
html += '</div>';
return html;
});
};
/**
* Combined content transformer that applies all text transformations.
* Order matters:
* 1. Lifecycle diagrams first (process code blocks before other transformations)
* 2. CURIEs (so they don't get broken by admonition spans)
* 3. Admonitions last
*/
const transformContent = (text: string): string => {
if (!text) return text;
return transformAdmonitions(highlightCuries(transformLifecycleDiagrams(text)));
};
/**
* Build a filtered UML diagram showing the current class and related classes up to a specified depth.
* Used in the per-class UML accordion to provide a focused view of class relationships.
*
* @param centerClassName - The central class to build the diagram around
* @param allExportInfo - Map of class name to ClassExportInfo for all loaded classes
* @param depth - How many hops from center to include (1 = direct only, 2 = secondary, etc.)
* @param parentClasses - Map of class name to parent class (from is_a inheritance)
* @returns UMLDiagram with filtered nodes and links
*/
const buildFilteredUMLDiagram = (
centerClassName: string,
allExportInfo: Record<string, ClassExportInfo>,
depth: number = 1,
parentClasses: Record<string, string> = {}
): UMLDiagram => {
const nodes: UMLNode[] = [];
const links: UMLLink[] = [];
const addedNodes = new Set<string>();
const addedLinks = new Set<string>(); // Track links to avoid duplicates
const processedAtDepth = new Map<string, number>(); // Track at what depth each class was processed
// Helper to 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)
const addLink = (source: string, target: string, type: UMLLink['type'], label: string) => {
const linkKey1 = `${source}->${target}:${type}`;
const linkKey2 = `${target}->${source}:${type}`;
// For inheritance, direction matters; for others, check both
if (type === 'inheritance') {
if (!addedLinks.has(linkKey1)) {
addedLinks.add(linkKey1);
links.push({ source, target, type, label });
}
} else {
if (!addedLinks.has(linkKey1) && !addedLinks.has(linkKey2)) {
addedLinks.add(linkKey1);
links.push({ source, target, type, label });
}
}
};
// Get all related classes from export info
const getRelatedClasses = (className: string): string[] => {
const exportInfo = allExportInfo[className];
if (!exportInfo) return [];
const related: string[] = [];
// Parent class
if (parentClasses[className]) {
related.push(parentClasses[className]);
}
// 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);
return [...new Set(related)]; // Dedupe
};
// Add relationships from a class's export info
const addRelationshipsFromClass = (className: string) => {
const exportInfo = allExportInfo[className];
if (!exportInfo) return;
// Parent class (is_a inheritance)
if (parentClasses[className] && addedNodes.has(parentClasses[className])) {
addLink(className, parentClasses[className], 'inheritance', 'is_a');
}
// Subclasses
for (const subclass of exportInfo.subclasses) {
if (addedNodes.has(subclass)) {
addLink(subclass, className, 'inheritance', 'is_a');
}
}
// Mixin users
for (const mixinUser of exportInfo.mixinUsers) {
if (addedNodes.has(mixinUser)) {
addLink(mixinUser, className, 'association', 'mixin');
}
}
// Classes using slots with this range
for (const { className: usingClass, slotName } of exportInfo.classesUsingSlotWithThisRange) {
if (addedNodes.has(usingClass)) {
addLink(usingClass, className, 'aggregation', slotName);
}
}
// Classes referencing in slot_usage
for (const refClass of exportInfo.classesReferencingInSlotUsage) {
if (addedNodes.has(refClass)) {
addLink(refClass, className, 'association', 'slot_usage');
}
}
};
// 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);
}
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' },
};
// Dynamically discover schema files from the modules directory
interface SchemaCategory {
name: string;
displayName: string;
files: SchemaFile[];
}
const LinkMLViewerPage: React.FC = () => {
const { language } = useLanguage();
const t = (key: keyof typeof TEXT) => TEXT[key][language];
const [searchParams, setSearchParams] = useSearchParams();
const [categories, setCategories] = useState<SchemaCategory[]>([]);
const [selectedSchema, setSelectedSchema] = useState<SchemaFile | null>(null);
const [schema, setSchema] = useState<LinkMLSchema | null>(null);
const [rawYaml, setRawYaml] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'visual' | 'raw'>('visual');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['classes', 'enums', 'slots']));
const [highlightedClass, setHighlightedClass] = useState<string | null>(null);
const highlightedRef = useRef<HTMLDivElement>(null);
// State for expandable enum ranges in slots
const [expandedEnumRanges, setExpandedEnumRanges] = useState<Set<string>>(new Set());
const [loadedEnums, setLoadedEnums] = useState<Record<string, LinkMLEnum | null>>({});
const [enumSearchFilters, setEnumSearchFilters] = useState<Record<string, string>>({});
const [enumShowAll, setEnumShowAll] = useState<Record<string, boolean>>({});
// State for pre-loaded custodian types (loaded async from schema annotations)
// Maps element name -> custodian type codes
const [classCustodianTypes, setClassCustodianTypes] = useState<Record<string, CustodianTypeCode[]>>({});
const [slotCustodianTypes, setSlotCustodianTypes] = useState<Record<string, CustodianTypeCode[]>>({});
const [enumCustodianTypes, setEnumCustodianTypes] = useState<Record<string, CustodianTypeCode[]>>({});
const [_custodianTypesLoaded, setCustodianTypesLoaded] = useState(false);
// State for sidebar search and category filters
const [sidebarSearch, setSidebarSearch] = useState<string>('');
const [categoryFilters, setCategoryFilters] = useState<Record<string, boolean>>({
main: true,
class: true,
enum: true,
slot: true,
});
// State for copy to clipboard feedback
const [copyFeedback, setCopyFeedback] = useState(false);
// State for expandable Exports section in class details
const [expandedExports, setExpandedExports] = useState<Set<string>>(new Set());
const [classExports, setClassExports] = useState<Record<string, ClassExportInfo>>({});
const [loadingExports, setLoadingExports] = useState<Set<string>>(new Set());
// State for expandable UML diagram section in class details
const [expandedUML, setExpandedUML] = useState<Set<string>>(new Set());
// State for UML depth slider per class (1 = direct relationships only, 2 = secondary, etc.)
const [umlDepth, setUmlDepth] = useState<Record<string, number>>({});
// State for parent class mapping (className -> parentClassName from is_a inheritance)
// Built incrementally as classes are loaded for UML diagrams
const [parentClassMap, setParentClassMap] = useState<Record<string, string>>({});
// State for 3D polygon indicator toggle
// Persisted in localStorage - defaults to false (2D SVG) if not set
const [use3DIndicator, setUse3DIndicator] = useState(() => {
try {
const saved = localStorage.getItem('linkml-viewer-use-3d');
return saved === 'true';
} catch {
return false;
}
});
// Persist 3D mode preference to localStorage
useEffect(() => {
try {
localStorage.setItem('linkml-viewer-use-3d', use3DIndicator ? 'true' : 'false');
} catch {
// Ignore localStorage errors (e.g., private browsing)
}
}, [use3DIndicator]);
// State for bidirectional hover sync between 3D polyhedrons and legend bar
const [hoveredCustodianType, setHoveredCustodianType] = useState<CustodianTypeCode | null>(null);
// State for filtering schema elements by custodian type (click on polyhedron face or legend)
// Multi-select: Set of selected type codes (empty = no filter)
// Initialize from URL params (e.g., ?custodian=G,L,A)
const [custodianTypeFilter, setCustodianTypeFilter] = useState<Set<CustodianTypeCode>>(() => {
const param = searchParams.get('custodian');
if (!param) return new Set();
const codes = param.split(',').filter((c): c is CustodianTypeCode =>
CUSTODIAN_TYPE_CODES.includes(c as CustodianTypeCode)
);
return new Set(codes);
});
// Sync custodian filter to URL params
useEffect(() => {
const currentParam = searchParams.get('custodian');
const filterCodes = Array.from(custodianTypeFilter).sort().join(',');
// Only update if different to avoid loops
if (custodianTypeFilter.size === 0 && currentParam) {
// Remove param if filter is empty
searchParams.delete('custodian');
setSearchParams(searchParams, { replace: true });
} else if (custodianTypeFilter.size > 0 && currentParam !== filterCodes) {
searchParams.set('custodian', filterCodes);
setSearchParams(searchParams, { replace: true });
}
}, [custodianTypeFilter, searchParams, setSearchParams]);
// Ref for main content (used by navigation-synced collapsible header)
const mainContentRef = useRef<HTMLElement>(null);
// Schema loading progress tracking
const { progress: schemaProgress, isLoading: isSchemaServiceLoading, isComplete: isSchemaServiceComplete } = useSchemaLoadingProgress();
// Handler for filtering by custodian type (clicking polyhedron face or legend item)
// Multi-select toggle behavior: clicking type adds/removes from set
const handleCustodianTypeFilter = useCallback((typeCode: CustodianTypeCode) => {
setCustodianTypeFilter(prev => {
const newSet = new Set(prev);
if (newSet.has(typeCode)) {
newSet.delete(typeCode);
} else {
newSet.add(typeCode);
}
return newSet;
});
}, []);
// Toggle exports section for a class and load export data on demand
const toggleExports = useCallback(async (className: string) => {
// Toggle expansion state
setExpandedExports(prev => {
const next = new Set(prev);
if (next.has(className)) {
next.delete(className);
} else {
next.add(className);
}
return next;
});
// Load export data if not already loaded and schema service is ready
if (!classExports[className] && !loadingExports.has(className) && isSchemaServiceComplete) {
setLoadingExports(prev => new Set(prev).add(className));
try {
const exportInfo = await linkmlSchemaService.getClassExportInfo(className);
setClassExports(prev => ({ ...prev, [className]: exportInfo }));
} catch (error) {
console.error(`Error loading export info for ${className}:`, error);
} finally {
setLoadingExports(prev => {
const next = new Set(prev);
next.delete(className);
return next;
});
}
}
}, [classExports, loadingExports, isSchemaServiceComplete]);
// Toggle UML diagram section for a class
// Reuses already-loaded classExports data from the Exports accordion
const toggleUML = useCallback((className: string) => {
setExpandedUML(prev => {
const next = new Set(prev);
if (next.has(className)) {
next.delete(className);
} else {
next.add(className);
}
return next;
});
// If exports data not loaded yet, trigger the load
// (UML depends on classExports data)
if (!classExports[className] && !loadingExports.has(className) && isSchemaServiceComplete) {
toggleExports(className);
}
}, [classExports, loadingExports, isSchemaServiceComplete, toggleExports]);
// Load export info for related classes when increasing depth
// Uses BFS to find all classes within the specified depth and loads their export info
const loadExportInfoForDepth = useCallback(async (centerClassName: string, depth: number) => {
if (!isSchemaServiceComplete) return;
// Get currently loaded export info for center class
const centerExport = classExports[centerClassName];
if (!centerExport) return;
// BFS to find all classes we need to load
const classesToLoad = new Set<string>();
const visited = new Set<string>();
const queue: Array<{ className: string; currentDepth: number }> = [
{ className: centerClassName, currentDepth: 0 }
];
while (queue.length > 0) {
const { className, currentDepth } = queue.shift()!;
if (visited.has(className) || currentDepth > depth) continue;
visited.add(className);
const exportInfo = classExports[className];
// If we don't have export info for this class, mark it for loading
if (!exportInfo && !loadingExports.has(className)) {
classesToLoad.add(className);
continue; // Can't traverse further without export info
}
if (!exportInfo) continue;
// Get related classes
const related: string[] = [];
related.push(...exportInfo.subclasses);
related.push(...exportInfo.mixinUsers);
related.push(...exportInfo.classesUsingSlotWithThisRange.map(c => c.className));
related.push(...exportInfo.classesReferencingInSlotUsage);
// 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
if (classesToLoad.size === 0) return;
// Mark as loading
setLoadingExports(prev => {
const next = new Set(prev);
classesToLoad.forEach(c => next.add(c));
return next;
});
// Load in parallel
const loadPromises = Array.from(classesToLoad).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 };
}
});
const results = await Promise.all(loadPromises);
// Update state with loaded export info
setClassExports(prev => {
const next = { ...prev };
for (const { className, exportInfo } of results) {
if (exportInfo) {
next[className] = exportInfo;
}
}
return next;
});
// Clear loading state
setLoadingExports(prev => {
const next = new Set(prev);
classesToLoad.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 hasNewClasses = results.some(r => r.exportInfo !== null);
if (hasNewClasses && depth > 1) {
// Re-run to potentially load more classes at deeper levels
setTimeout(() => loadExportInfoForDepth(centerClassName, depth), 100);
}
}, [classExports, loadingExports, parentClassMap, isSchemaServiceComplete]);
// Debounced version of loadExportInfoForDepth for slider interaction
// This prevents excessive API calls when dragging the slider
const debouncedLoadExportInfo = useMemo(
() => debounce((className: string, depth: number) => {
loadExportInfoForDepth(className, depth);
}, 300),
[loadExportInfoForDepth]
);
// Cleanup debounced function on unmount
useEffect(() => {
return () => {
debouncedLoadExportInfo.cancel();
};
}, [debouncedLoadExportInfo]);
// Memoized UML diagram builder to prevent recalculation on every render
// Key is className + depth combination
const getMemoizedUMLDiagram = useCallback((className: string, depth: number) => {
return buildFilteredUMLDiagram(className, classExports, depth, parentClassMap);
}, [classExports, parentClassMap]);
// Navigate to a class by updating URL params and selecting the schema file
const navigateToClass = useCallback((className: string) => {
setSearchParams({ class: className });
setHighlightedClass(className);
// 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]);
// 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');
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']));
}
}
}, []);
// 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');
if (!classParam && cats[0]?.files.length > 0) {
setSelectedSchema(cats[0].files[0]);
}
// Mark as initialized to prevent re-running
isInitializedRef.current = true;
} catch (err) {
setError(t('failedToInit'));
console.error(err);
} finally {
setIsLoading(false);
}
};
initializeSchemas();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty deps - run only on mount
// Scroll to highlighted class when it changes
useEffect(() => {
if (highlightedClass && highlightedRef.current) {
setTimeout(() => {
highlightedRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
}, [highlightedClass, schema]);
// Fetch schema manifest from dynamically generated JSON file
const fetchSchemaManifest = async (): Promise<SchemaCategory[]> => {
try {
const response = await fetch('/schemas/20251121/linkml/manifest.json');
if (!response.ok) {
throw new Error(`Failed to fetch manifest: ${response.status}`);
}
const manifest = await response.json();
// Transform manifest categories to our format
return manifest.categories.map((cat: { name: string; displayName: string; files: Array<{ name: string; path: string; category: string }> }) => ({
name: cat.name,
displayName: cat.displayName,
files: cat.files.map((file: { name: string; path: string; category: string }) => ({
name: file.name,
path: file.path,
category: cat.name as SchemaFile['category']
}))
}));
} catch (err) {
console.error('Failed to fetch schema manifest:', err);
// Return empty categories on error
return [];
}
};
// Load selected schema
useEffect(() => {
if (!selectedSchema) return;
const loadSelectedSchema = async () => {
setIsLoading(true);
setError(null);
try {
const [schemaData, yamlContent] = await Promise.all([
loadSchema(selectedSchema.path),
loadSchemaRaw(selectedSchema.path)
]);
setSchema(schemaData);
setRawYaml(yamlContent);
} catch (err) {
setError(`${t('failedToLoad')} ${selectedSchema.name}`);
console.error(err);
} finally {
setIsLoading(false);
}
};
loadSelectedSchema();
}, [selectedSchema]);
// Load custodian types from schema annotations when schema changes
// This pre-loads types asynchronously so they're available for rendering
// IMPORTANT: Wait for schema service to complete loading before fetching custodian types
// to avoid race condition where annotations aren't available yet
useEffect(() => {
if (!schema) {
setCustodianTypesLoaded(false);
return;
}
// Don't load custodian types until schema service has finished loading all class files
// This prevents the race condition where we try to read annotations before they're loaded
if (!isSchemaServiceComplete) {
console.log('[LinkMLViewerPage] Waiting for schema service to complete before loading custodian types...');
return;
}
const loadCustodianTypes = async () => {
const classes = extractClasses(schema);
const slots = extractSlots(schema);
const enums = extractEnums(schema);
console.log('[LinkMLViewerPage] Schema service complete, loading custodian types for', {
classes: classes.length,
slots: slots.length,
enums: enums.length
});
// Load types for all classes in parallel
const classTypesPromises = classes.map(async (cls) => {
const types = await getCustodianTypesForClassAsync(cls.name);
return [cls.name, types] as const;
});
// Load types for all slots in parallel
const slotTypesPromises = slots.map(async (slot) => {
const types = await getCustodianTypesForSlotAsync(slot.name);
return [slot.name, types] as const;
});
// Load types for all enums in parallel
const enumTypesPromises = enums.map(async (enumDef) => {
const types = await getCustodianTypesForEnumAsync(enumDef.name);
return [enumDef.name, types] as const;
});
try {
const [classResults, slotResults, enumResults] = await Promise.all([
Promise.all(classTypesPromises),
Promise.all(slotTypesPromises),
Promise.all(enumTypesPromises)
]);
// Convert to records
const classTypesMap: Record<string, CustodianTypeCode[]> = {};
for (const [name, types] of classResults) {
classTypesMap[name] = types;
}
const slotTypesMap: Record<string, CustodianTypeCode[]> = {};
for (const [name, types] of slotResults) {
slotTypesMap[name] = types;
}
const enumTypesMap: Record<string, CustodianTypeCode[]> = {};
for (const [name, types] of enumResults) {
enumTypesMap[name] = types;
}
setClassCustodianTypes(classTypesMap);
setSlotCustodianTypes(slotTypesMap);
setEnumCustodianTypes(enumTypesMap);
setCustodianTypesLoaded(true);
console.log('[LinkMLViewerPage] Loaded custodian types from schema annotations:', {
classes: Object.keys(classTypesMap).length,
slots: Object.keys(slotTypesMap).length,
enums: Object.keys(enumTypesMap).length
});
} catch (error) {
console.error('[LinkMLViewerPage] Error loading custodian types:', error);
// Fall back to sync functions (will use defaults)
setCustodianTypesLoaded(true);
}
};
loadCustodianTypes();
}, [schema, isSchemaServiceComplete]);
const toggleSection = (section: string) => {
setExpandedSections(prev => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
};
// Check if a range is an enum type
const isEnumRange = (range: string): boolean => {
return range.endsWith('Enum');
};
// Toggle enum range expansion and load enum data if needed
const toggleEnumRange = async (slotName: string, enumName: string) => {
const key = `${slotName}:${enumName}`;
// Toggle expansion state
setExpandedEnumRanges(prev => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
// Load enum data if not already loaded
if (!loadedEnums[enumName]) {
try {
const enumSchema = await loadSchema(`modules/enums/${enumName}.yaml`);
if (enumSchema?.enums) {
const enumDef = Object.values(enumSchema.enums)[0] as LinkMLEnum;
if (enumDef) {
setLoadedEnums(prev => ({ ...prev, [enumName]: { ...enumDef, name: enumName } }));
}
}
} catch (err) {
console.error(`Failed to load enum ${enumName}:`, err);
setLoadedEnums(prev => ({ ...prev, [enumName]: null }));
}
}
};
// Render enum values for a slot range
const renderEnumValues = (_slotName: string, enumName: string) => {
const enumDef = loadedEnums[enumName];
if (!enumDef?.permissible_values) {
return <div className="linkml-viewer__enum-loading">{t('loading')}</div>;
}
const allValues = Object.entries(enumDef.permissible_values);
const searchFilter = enumSearchFilters[enumName] || '';
const showAll = enumShowAll[enumName] || false;
const displayCount = 20; // Show first 20 values initially
// Filter values based on search
const filteredValues = searchFilter
? allValues.filter(([value, details]) => {
const searchLower = searchFilter.toLowerCase();
return (
value.toLowerCase().includes(searchLower) ||
(details.description?.toLowerCase().includes(searchLower)) ||
(details.meaning?.toLowerCase().includes(searchLower))
);
})
: allValues;
// Determine how many to show
const valuesToShow = showAll || searchFilter ? filteredValues : filteredValues.slice(0, displayCount);
return (
<div className="linkml-viewer__range-enum-values">
<div className="linkml-viewer__range-enum-header">
{/* Search input */}
<div className="linkml-viewer__range-enum-search">
<input
type="text"
placeholder={t('searchPlaceholder')}
value={searchFilter}
onChange={(e) => setEnumSearchFilters(prev => ({ ...prev, [enumName]: e.target.value }))}
className="linkml-viewer__range-enum-search-input"
/>
{searchFilter && (
<button
className="linkml-viewer__range-enum-search-clear"
onClick={() => setEnumSearchFilters(prev => ({ ...prev, [enumName]: '' }))}
title="Clear search"
>
×
</button>
)}
</div>
{/* Count display */}
<span className="linkml-viewer__range-enum-count">
{searchFilter ? (
<>
{t('showing')} {filteredValues.length} {t('of')} {allValues.length}
</>
) : showAll ? (
<>
{allValues.length} {t('permissibleValues').replace(':', '')}
</>
) : (
<>
{t('showing')} {Math.min(displayCount, allValues.length)} {t('of')} {allValues.length}
</>
)}
</span>
</div>
<div className="linkml-viewer__range-enum-list">
{valuesToShow.length > 0 ? (
valuesToShow.map(([value, details]) => (
<div key={value} className="linkml-viewer__range-enum-item">
<code className="linkml-viewer__range-enum-name">{value}</code>
{details.meaning && (
<a
href={details.meaning.startsWith('wikidata:')
? `https://www.wikidata.org/wiki/${details.meaning.replace('wikidata:', '')}`
: details.meaning}
target="_blank"
rel="noopener noreferrer"
className="linkml-viewer__range-enum-link"
title={details.meaning}
>
🔗
</a>
)}
{details.description && (
<span className="linkml-viewer__range-enum-desc">{details.description}</span>
)}
</div>
))
) : (
<div className="linkml-viewer__range-enum-no-results">
{t('noResults')}
</div>
)}
</div>
{/* Show all / Show less button */}
{!searchFilter && allValues.length > displayCount && (
<button
className="linkml-viewer__range-enum-toggle-all"
onClick={() => setEnumShowAll(prev => ({ ...prev, [enumName]: !showAll }))}
>
{showAll ? (
<> {t('showLess')}</>
) : (
<> {t('showAll')} ({allValues.length - displayCount} {language === 'nl' ? 'meer' : 'more'})</>
)}
</button>
)}
</div>
);
};
const renderClassDetails = (cls: LinkMLClass) => {
const isHighlighted = highlightedClass === cls.name;
// Use pre-loaded types from schema annotations, fall back to sync function if not yet loaded
const custodianTypes = classCustodianTypes[cls.name] || getCustodianTypesForClass(cls.name);
const isUniversal = isUniversalElement(custodianTypes);
// Check if this class matches the current custodian type filter (multi-select)
const matchesFilter = custodianTypeFilter.size === 0 || custodianTypes.some(t => custodianTypeFilter.has(t));
return (
<div
key={cls.name}
ref={isHighlighted ? highlightedRef : null}
className={`linkml-viewer__item ${isHighlighted ? 'linkml-viewer__item--highlighted' : ''} ${!matchesFilter ? 'linkml-viewer__item--filtered-out' : ''}`}
>
<h4 className="linkml-viewer__item-name">
{cls.name}
{cls.abstract && <span className="linkml-viewer__badge linkml-viewer__badge--abstract">{t('abstract')}</span>}
{/* Show 3D polygon for ALL elements (including universal) in 3D mode for testing */}
{use3DIndicator ? (
<CustodianTypeIndicator3D
types={custodianTypes}
size={32}
animate={true}
showTooltip={true}
hoveredType={hoveredCustodianType}
onTypeHover={setHoveredCustodianType}
// Removed onFaceClick - cube should be decorative, not trigger filtering
className="linkml-viewer__custodian-indicator"
/>
) : (
!isUniversal && (
<CustodianTypeBadge
types={custodianTypes}
size="small"
className="linkml-viewer__custodian-badge"
/>
)
)}
</h4>
{cls.class_uri && (
<div className="linkml-viewer__uri">
<span className="linkml-viewer__label">{t('uri')}</span>
<code>{cls.class_uri}</code>
</div>
)}
{cls.description && (
<div className="linkml-viewer__description linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(cls.description)}</ReactMarkdown>
</div>
)}
{cls.slots && cls.slots.length > 0 && (
<div className="linkml-viewer__slots-list">
<span className="linkml-viewer__label">{t('slotsLabel')}</span>
<div className="linkml-viewer__tag-list">
{cls.slots.map(slot => (
<span key={slot} className="linkml-viewer__tag">{slot}</span>
))}
</div>
</div>
)}
{cls.exact_mappings && cls.exact_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label">{t('exactMappings')}</span>
<div className="linkml-viewer__tag-list">
{cls.exact_mappings.map(mapping => (
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--mapping">{mapping}</span>
))}
</div>
</div>
)}
{cls.close_mappings && cls.close_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label">{t('closeMappings')}</span>
<div className="linkml-viewer__tag-list">
{cls.close_mappings.map(mapping => (
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--close">{mapping}</span>
))}
</div>
</div>
)}
{/* Exports Section - Shows reverse dependencies (what references this class) */}
{isSchemaServiceComplete && (
<div className="linkml-viewer__exports-section">
<button
className={`linkml-viewer__exports-toggle ${expandedExports.has(cls.name) ? 'linkml-viewer__exports-toggle--expanded' : ''}`}
onClick={() => toggleExports(cls.name)}
>
<span className="linkml-viewer__exports-icon">{expandedExports.has(cls.name) ? '▼' : '▶'}</span>
<span className="linkml-viewer__label">Exports</span>
{loadingExports.has(cls.name) && <span className="linkml-viewer__exports-loading">Loading...</span>}
</button>
{expandedExports.has(cls.name) && classExports[cls.name] && (
<div className="linkml-viewer__exports-content">
{/* Subclasses */}
{classExports[cls.name].subclasses.length > 0 && (
<div className="linkml-viewer__exports-category">
<span className="linkml-viewer__exports-category-label">Subclasses ({classExports[cls.name].subclasses.length})</span>
<div className="linkml-viewer__exports-list">
{classExports[cls.name].subclasses.map(subclass => (
<button
key={subclass}
className="linkml-viewer__exports-link"
onClick={() => navigateToClass(subclass)}
>
{subclass}
</button>
))}
</div>
</div>
)}
{/* Mixin Users */}
{classExports[cls.name].mixinUsers.length > 0 && (
<div className="linkml-viewer__exports-category">
<span className="linkml-viewer__exports-category-label">Used as Mixin by ({classExports[cls.name].mixinUsers.length})</span>
<div className="linkml-viewer__exports-list">
{classExports[cls.name].mixinUsers.map(user => (
<button
key={user}
className="linkml-viewer__exports-link"
onClick={() => navigateToClass(user)}
>
{user}
</button>
))}
</div>
</div>
)}
{/* Slots with this Range */}
{classExports[cls.name].slotsWithThisRange.length > 0 && (
<div className="linkml-viewer__exports-category">
<span className="linkml-viewer__exports-category-label">Slots with this Range ({classExports[cls.name].slotsWithThisRange.length})</span>
<div className="linkml-viewer__exports-list">
{classExports[cls.name].slotsWithThisRange.map(slot => (
<span key={slot} className="linkml-viewer__exports-item linkml-viewer__exports-item--slot">
{slot}
</span>
))}
</div>
</div>
)}
{/* Classes using slots with this range */}
{classExports[cls.name].classesUsingSlotWithThisRange.length > 0 && (
<div className="linkml-viewer__exports-category">
<span className="linkml-viewer__exports-category-label">Classes Using Slots with this Range ({classExports[cls.name].classesUsingSlotWithThisRange.length})</span>
<div className="linkml-viewer__exports-list">
{classExports[cls.name].classesUsingSlotWithThisRange.map(({ className, slotName }) => (
<button
key={`${className}-${slotName}`}
className="linkml-viewer__exports-link"
onClick={() => navigateToClass(className)}
title={`via slot: ${slotName}`}
>
{className} <span className="linkml-viewer__exports-via">via {slotName}</span>
</button>
))}
</div>
</div>
)}
{/* Classes referencing in slot_usage */}
{classExports[cls.name].classesReferencingInSlotUsage.length > 0 && (
<div className="linkml-viewer__exports-category">
<span className="linkml-viewer__exports-category-label">Referenced in slot_usage ({classExports[cls.name].classesReferencingInSlotUsage.length})</span>
<div className="linkml-viewer__exports-list">
{classExports[cls.name].classesReferencingInSlotUsage.map(refClass => (
<button
key={refClass}
className="linkml-viewer__exports-link"
onClick={() => navigateToClass(refClass)}
>
{refClass}
</button>
))}
</div>
</div>
)}
{/* No exports message */}
{classExports[cls.name].subclasses.length === 0 &&
classExports[cls.name].mixinUsers.length === 0 &&
classExports[cls.name].slotsWithThisRange.length === 0 &&
classExports[cls.name].classesUsingSlotWithThisRange.length === 0 &&
classExports[cls.name].classesReferencingInSlotUsage.length === 0 && (
<div className="linkml-viewer__exports-empty">
No other schema elements reference this class.
</div>
)}
</div>
)}
</div>
)}
{/* UML Diagram Section - Shows filtered class relationship diagram */}
{isSchemaServiceComplete && (
<div className="linkml-viewer__uml-section">
<button
className={`linkml-viewer__uml-toggle ${expandedUML.has(cls.name) ? 'linkml-viewer__uml-toggle--expanded' : ''}`}
onClick={() => toggleUML(cls.name)}
>
<span className="linkml-viewer__uml-icon">{expandedUML.has(cls.name) ? '▼' : '▶'}</span>
<span className="linkml-viewer__label">UML Diagram</span>
{loadingExports.has(cls.name) && <span className="linkml-viewer__uml-loading">Loading...</span>}
</button>
{expandedUML.has(cls.name) && classExports[cls.name] && (
<div className="linkml-viewer__uml-content">
{/* Depth slider */}
<div className="linkml-viewer__uml-depth-control">
<label className="linkml-viewer__uml-depth-label">
<span>Relationship Depth:</span>
<input
type="range"
min="1"
max="4"
value={umlDepth[cls.name] || 1}
onChange={(e) => {
const newDepth = parseInt(e.target.value, 10);
setUmlDepth(prev => ({ ...prev, [cls.name]: newDepth }));
// Use debounced loading to prevent excessive API calls while dragging
if (newDepth > 1) {
debouncedLoadExportInfo(cls.name, newDepth);
}
}}
className="linkml-viewer__uml-depth-slider"
/>
<span className="linkml-viewer__uml-depth-value">{umlDepth[cls.name] || 1}</span>
</label>
<span className="linkml-viewer__uml-depth-hint">
{(umlDepth[cls.name] || 1) === 1 ? 'Direct relationships' :
(umlDepth[cls.name] || 1) === 2 ? 'Secondary connections' :
(umlDepth[cls.name] || 1) === 3 ? 'Tertiary connections' : 'Extended network'}
</span>
</div>
<UMLVisualization
diagram={getMemoizedUMLDiagram(cls.name, umlDepth[cls.name] || 1)}
width={600}
height={500}
layoutType="dagre"
dagreDirection="TB"
/>
</div>
)}
{expandedUML.has(cls.name) && !classExports[cls.name] && !loadingExports.has(cls.name) && (
<div className="linkml-viewer__uml-empty">
Click to load class relationships.
</div>
)}
</div>
)}
</div>
);
};
const renderSlotDetails = (slot: LinkMLSlot) => {
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));
return (
<div key={slot.name} className={`linkml-viewer__item ${!matchesFilter ? 'linkml-viewer__item--filtered-out' : ''}`}>
<h4 className="linkml-viewer__item-name">
{slot.name}
{slot.required && <span className="linkml-viewer__badge linkml-viewer__badge--required">{t('required')}</span>}
{slot.multivalued && <span className="linkml-viewer__badge linkml-viewer__badge--multi">{t('multivalued')}</span>}
{/* Show 3D polygon for ALL elements (including universal) in 3D mode for testing */}
{use3DIndicator ? (
<CustodianTypeIndicator3D
types={custodianTypes}
size={32}
animate={true}
showTooltip={true}
hoveredType={hoveredCustodianType}
onTypeHover={setHoveredCustodianType}
// Removed onFaceClick - cube should be decorative, not trigger filtering
className="linkml-viewer__custodian-indicator"
/>
) : (
!isUniversal && (
<CustodianTypeBadge
types={custodianTypes}
size="small"
className="linkml-viewer__custodian-badge"
/>
)
)}
</h4>
{slot.range && (
<div className="linkml-viewer__range">
<span className="linkml-viewer__label">{t('range')}</span>
{rangeIsEnum ? (
<button
className={`linkml-viewer__range-enum-toggle ${isExpanded ? 'linkml-viewer__range-enum-toggle--expanded' : ''}`}
onClick={() => toggleEnumRange(slot.name, slot.range!)}
>
<span className="linkml-viewer__range-enum-icon">{isExpanded ? '▼' : '▶'}</span>
<code>{slot.range}</code>
<span className="linkml-viewer__badge linkml-viewer__badge--enum">enum</span>
</button>
) : (
<code>{slot.range}</code>
)}
</div>
)}
{/* Expandable enum values */}
{rangeIsEnum && isExpanded && slot.range && renderEnumValues(slot.name, slot.range)}
{slot.description && (
<div className="linkml-viewer__description linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(slot.description)}</ReactMarkdown>
</div>
)}
{slot.pattern && (
<div className="linkml-viewer__pattern">
<span className="linkml-viewer__label">{t('pattern')}</span>
<code>{slot.pattern}</code>
</div>
)}
</div>
);
};
const renderEnumDetails = (enumDef: LinkMLEnum) => {
const enumName = enumDef.name;
const allValues = enumDef.permissible_values ? Object.entries(enumDef.permissible_values) : [];
const searchFilter = enumSearchFilters[enumName] || '';
const showAll = enumShowAll[enumName] || false;
const displayCount = 20;
const custodianTypes = enumCustodianTypes[enumDef.name] || getCustodianTypesForEnum(enumDef.name);
const isUniversal = isUniversalElement(custodianTypes);
// Filter values based on search
const filteredValues = searchFilter
? allValues.filter(([value, details]) => {
const searchLower = searchFilter.toLowerCase();
return (
value.toLowerCase().includes(searchLower) ||
(details.description?.toLowerCase().includes(searchLower)) ||
(details.meaning?.toLowerCase().includes(searchLower))
);
})
: allValues;
// Determine how many to show
const valuesToShow = showAll || searchFilter ? filteredValues : filteredValues.slice(0, displayCount);
// Check if this enum matches the current custodian type filter (multi-select)
const matchesFilter = custodianTypeFilter.size === 0 || custodianTypes.some(t => custodianTypeFilter.has(t));
return (
<div key={enumDef.name} className={`linkml-viewer__item ${!matchesFilter ? 'linkml-viewer__item--filtered-out' : ''}`}>
<h4 className="linkml-viewer__item-name">
{enumDef.name}
{/* Show 3D polygon for ALL elements (including universal) in 3D mode for testing */}
{use3DIndicator ? (
<CustodianTypeIndicator3D
types={custodianTypes}
size={32}
animate={true}
showTooltip={true}
hoveredType={hoveredCustodianType}
onTypeHover={setHoveredCustodianType}
// Removed onFaceClick - cube should be decorative, not trigger filtering
className="linkml-viewer__custodian-indicator"
/>
) : (
!isUniversal && (
<CustodianTypeBadge
types={custodianTypes}
size="small"
className="linkml-viewer__custodian-badge"
/>
)
)}
</h4>
{enumDef.description && (
<div className="linkml-viewer__description linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(enumDef.description)}</ReactMarkdown>
</div>
)}
{allValues.length > 0 && (
<div className="linkml-viewer__enum-values">
{/* Search input */}
<div className="linkml-viewer__range-enum-search">
<input
type="text"
placeholder={t('searchPlaceholder')}
value={searchFilter}
onChange={(e) => setEnumSearchFilters(prev => ({ ...prev, [enumName]: e.target.value }))}
className="linkml-viewer__range-enum-search-input"
/>
{searchFilter && (
<button
className="linkml-viewer__range-enum-search-clear"
onClick={() => setEnumSearchFilters(prev => ({ ...prev, [enumName]: '' }))}
title="Clear search"
>
×
</button>
)}
</div>
{/* Count display */}
<span className="linkml-viewer__range-enum-count" style={{ display: 'block', marginBottom: '0.5rem' }}>
{searchFilter ? (
<>
{t('showing')} {filteredValues.length} {t('of')} {allValues.length}
</>
) : showAll ? (
<>
{allValues.length} {t('permissibleValues').replace(':', '')}
</>
) : (
<>
{t('showing')} {Math.min(displayCount, allValues.length)} {t('of')} {allValues.length}
</>
)}
</span>
<div className="linkml-viewer__value-list">
{valuesToShow.length > 0 ? (
valuesToShow.map(([value, details]) => (
<div key={value} className="linkml-viewer__value-item">
<code className="linkml-viewer__value-name">{value}</code>
{details.description && (
<div className="linkml-viewer__value-desc linkml-viewer__markdown linkml-viewer__markdown--compact">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(details.description)}</ReactMarkdown>
</div>
)}
{details.meaning && (
<span className="linkml-viewer__value-meaning">
<span className="linkml-viewer__label">{t('meaning')}</span> {details.meaning}
</span>
)}
</div>
))
) : (
<div className="linkml-viewer__range-enum-no-results">
{t('noResults')}
</div>
)}
</div>
{/* Show all / Show less button */}
{!searchFilter && allValues.length > displayCount && (
<button
className="linkml-viewer__range-enum-toggle-all"
onClick={() => setEnumShowAll(prev => ({ ...prev, [enumName]: !showAll }))}
>
{showAll ? (
<> {t('showLess')}</>
) : (
<> {t('showAll')} ({allValues.length - displayCount} {language === 'nl' ? 'meer' : 'more'})</>
)}
</button>
)}
</div>
)}
</div>
);
};
const renderVisualView = () => {
if (!schema) return null;
const classes = extractClasses(schema);
const slots = extractSlots(schema);
const enums = extractEnums(schema);
// Count matching items when filter is active (for display purposes)
const matchingClassCount = custodianTypeFilter.size > 0
? classes.filter(cls => {
const types = classCustodianTypes[cls.name] || getCustodianTypesForClass(cls.name);
return types.some(t => custodianTypeFilter.has(t));
}).length
: classes.length;
const matchingSlotCount = custodianTypeFilter.size > 0
? slots.filter(slot => {
const types = slotCustodianTypes[slot.name] || getCustodianTypesForSlot(slot.name);
return types.some(t => custodianTypeFilter.has(t));
}).length
: slots.length;
const matchingEnumCount = custodianTypeFilter.size > 0
? enums.filter(enumDef => {
const types = enumCustodianTypes[enumDef.name] || getCustodianTypesForEnum(enumDef.name);
return types.some(t => custodianTypeFilter.has(t));
}).length
: enums.length;
return (
<div className="linkml-viewer__visual">
{/* Schema Metadata */}
<div className="linkml-viewer__metadata">
<h2 className="linkml-viewer__schema-name">{formatDisplayName(schema.name || schema.title || '') || t('unnamedSchema')}</h2>
{schema.description && (
<div className="linkml-viewer__schema-desc linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(schema.description)}</ReactMarkdown>
</div>
)}
{schema.id && (
<div className="linkml-viewer__schema-id">
<span className="linkml-viewer__label">{t('id')}</span>
<code>{schema.id}</code>
</div>
)}
{schema.version && (
<div className="linkml-viewer__schema-version">
<span className="linkml-viewer__label">{t('version')}</span>
<code>{schema.version}</code>
</div>
)}
</div>
{/* Prefixes */}
{schema.prefixes && Object.keys(schema.prefixes).length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('prefixes')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('prefixes') ? '▼' : '▶'}
</span>
{t('prefixes')} ({Object.keys(schema.prefixes).length})
</button>
{expandedSections.has('prefixes') && (
<div className="linkml-viewer__prefix-list">
{Object.entries(schema.prefixes).map(([prefix, uri]) => (
<div key={prefix} className="linkml-viewer__prefix-item">
<code className="linkml-viewer__prefix-name">{prefix}:</code>
<span className="linkml-viewer__prefix-uri">{uri}</span>
</div>
))}
</div>
)}
</div>
)}
{/* Classes */}
{classes.length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('classes')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('classes') ? '▼' : '▶'}
</span>
{t('classes')} ({custodianTypeFilter.size > 0 ? `${matchingClassCount}/${classes.length}` : classes.length})
</button>
{expandedSections.has('classes') && (
<div className="linkml-viewer__section-content">
{classes.map(renderClassDetails)}
</div>
)}
</div>
)}
{/* Slots */}
{slots.length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('slots')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('slots') ? '▼' : '▶'}
</span>
{t('slots')} ({custodianTypeFilter.size > 0 ? `${matchingSlotCount}/${slots.length}` : slots.length})
</button>
{expandedSections.has('slots') && (
<div className="linkml-viewer__section-content">
{slots.map(renderSlotDetails)}
</div>
)}
</div>
)}
{/* Enums */}
{enums.length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('enums')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('enums') ? '▼' : '▶'}
</span>
{t('enumerations')} ({custodianTypeFilter.size > 0 ? `${matchingEnumCount}/${enums.length}` : enums.length})
</button>
{expandedSections.has('enums') && (
<div className="linkml-viewer__section-content">
{enums.map(renderEnumDetails)}
</div>
)}
</div>
)}
{/* Imports */}
{schema.imports && schema.imports.length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('imports')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('imports') ? '▼' : '▶'}
</span>
{t('imports')} ({schema.imports.length})
</button>
{expandedSections.has('imports') && (
<div className="linkml-viewer__import-list">
{schema.imports.map(imp => (
<div key={imp} className="linkml-viewer__import-item">
<code>{imp}</code>
</div>
))}
</div>
)}
</div>
)}
</div>
);
};
const renderRawView = () => {
if (!rawYaml) return null;
const handleCopyYaml = async () => {
try {
await navigator.clipboard.writeText(rawYaml);
setCopyFeedback(true);
setTimeout(() => setCopyFeedback(false), 2000);
} catch (err) {
console.error('Failed to copy YAML:', err);
}
};
return (
<div className="linkml-viewer__raw">
<div className="linkml-viewer__raw-header">
<button
className={`linkml-viewer__copy-btn ${copyFeedback ? 'linkml-viewer__copy-btn--copied' : ''}`}
onClick={handleCopyYaml}
title={copyFeedback ? t('copied') : t('copyToClipboard')}
>
{copyFeedback ? '✓' : '⧉'} {copyFeedback ? t('copied') : t('copyToClipboard')}
</button>
</div>
<pre className="linkml-viewer__yaml">{rawYaml}</pre>
</div>
);
};
return (
<div className="linkml-viewer-page">
{/* Left Sidebar - Schema Files */}
<aside className="linkml-viewer-page__sidebar">
<h2 className="linkml-viewer-page__sidebar-title">{t('sidebarTitle')}</h2>
{/* Sidebar Search */}
<div className="linkml-viewer-page__sidebar-search">
<input
type="text"
placeholder={t('searchSchemas')}
value={sidebarSearch}
onChange={(e) => setSidebarSearch(e.target.value)}
className="linkml-viewer-page__sidebar-search-input"
/>
{sidebarSearch && (
<button
className="linkml-viewer-page__sidebar-search-clear"
onClick={() => setSidebarSearch('')}
title="Clear search"
>
×
</button>
)}
</div>
{/* Category Filters */}
<div className="linkml-viewer-page__sidebar-filters">
<label className="linkml-viewer-page__filter-checkbox">
<input
type="checkbox"
checked={categoryFilters.main}
onChange={(e) => setCategoryFilters(prev => ({ ...prev, main: e.target.checked }))}
/>
<span className="linkml-viewer-page__filter-label linkml-viewer-page__filter-label--main">
{t('mainSchema')}
</span>
</label>
<label className="linkml-viewer-page__filter-checkbox">
<input
type="checkbox"
checked={categoryFilters.class}
onChange={(e) => setCategoryFilters(prev => ({ ...prev, class: e.target.checked }))}
/>
<span className="linkml-viewer-page__filter-label linkml-viewer-page__filter-label--class">
{t('classes')}
</span>
</label>
<label className="linkml-viewer-page__filter-checkbox">
<input
type="checkbox"
checked={categoryFilters.enum}
onChange={(e) => setCategoryFilters(prev => ({ ...prev, enum: e.target.checked }))}
/>
<span className="linkml-viewer-page__filter-label linkml-viewer-page__filter-label--enum">
{t('enumerations')}
</span>
</label>
<label className="linkml-viewer-page__filter-checkbox">
<input
type="checkbox"
checked={categoryFilters.slot}
onChange={(e) => setCategoryFilters(prev => ({ ...prev, slot: e.target.checked }))}
/>
<span className="linkml-viewer-page__filter-label linkml-viewer-page__filter-label--slot">
{t('slots')}
</span>
</label>
</div>
{categories
.filter(category => categoryFilters[category.name] !== false)
.map(category => {
// Filter files based on search
const filteredFiles = sidebarSearch
? category.files.filter(file =>
file.name.toLowerCase().includes(sidebarSearch.toLowerCase())
)
: category.files;
// Don't render empty categories
if (filteredFiles.length === 0) return null;
return (
<div key={category.name} className="linkml-viewer-page__category">
<h3 className="linkml-viewer-page__category-title">
{category.displayName}
<span className="linkml-viewer-page__category-count">
({filteredFiles.length}{sidebarSearch ? `/${category.files.length}` : ''})
</span>
</h3>
<div className="linkml-viewer-page__file-list">
{filteredFiles.map(file => (
<button
key={file.path}
className={`linkml-viewer-page__file-item ${
selectedSchema?.path === file.path ? 'linkml-viewer-page__file-item--active' : ''
}`}
onClick={() => setSelectedSchema(file)}
>
{file.name}
</button>
))}
</div>
</div>
);
})}
{/* No results message */}
{sidebarSearch && categories.every(category =>
!categoryFilters[category.name] ||
category.files.filter(file =>
file.name.toLowerCase().includes(sidebarSearch.toLowerCase())
).length === 0
) && (
<div className="linkml-viewer-page__no-results">
{t('noMatchingSchemas')}
</div>
)}
</aside>
{/* Main Content */}
<main className="linkml-viewer-page__main" ref={mainContentRef}>
{/* Collapsible header with title - hides when navigation collapses */}
<header className="linkml-viewer-page__header">
<h1 className="linkml-viewer-page__title">
{selectedSchema ? formatDisplayName(selectedSchema.name) : t('pageTitle')}
</h1>
</header>
{/* Sticky subheader with tabs - always visible */}
<div className="linkml-viewer-page__subheader">
<div className="linkml-viewer-page__tabs">
<button
className={`linkml-viewer-page__tab ${viewMode === 'visual' ? 'linkml-viewer-page__tab--active' : ''}`}
onClick={() => setViewMode('visual')}
>
{t('visualView')}
</button>
<button
className={`linkml-viewer-page__tab ${viewMode === 'raw' ? 'linkml-viewer-page__tab--active' : ''}`}
onClick={() => setViewMode('raw')}
>
{t('rawYaml')}
</button>
<span className="linkml-viewer-page__tab-separator">|</span>
<button
className={`linkml-viewer-page__tab linkml-viewer-page__tab--indicator ${use3DIndicator ? 'linkml-viewer-page__tab--active' : ''}`}
onClick={() => setUse3DIndicator(!use3DIndicator)}
title={use3DIndicator ? t('use2DBadge') : t('use3DPolygon')}
>
{use3DIndicator ? '🔷 3D' : '🏷️ 2D'}
</button>
</div>
{/* Legend bar for 3D mode - shows all 19 custodian types with bidirectional hover sync */}
{use3DIndicator && (
<div className="linkml-viewer-page__legend-bar">
<CustodianTypeLegendBar
hoveredType={hoveredCustodianType}
onTypeHover={setHoveredCustodianType}
onTypeClick={handleCustodianTypeFilter}
highlightTypes={Array.from(custodianTypeFilter)}
size="small"
/>
{custodianTypeFilter.size > 0 && (
<button
className="linkml-viewer-page__filter-clear"
onClick={() => setCustodianTypeFilter(new Set())}
title={language === 'nl' ? 'Filter wissen' : 'Clear filter'}
>
{language === 'nl' ? 'Filter wissen' : 'Clear filter'} ({Array.from(custodianTypeFilter).join(', ')})
</button>
)}
</div>
)}
</div>
<div className="linkml-viewer-page__content">
{isLoading || isSchemaServiceLoading ? (
<LoadingScreen
message={schemaProgress?.message || t('loading')}
progress={schemaProgress?.percent}
size="medium"
fullscreen={false}
/>
) : error ? (
<div className="linkml-viewer-page__error">{error}</div>
) : viewMode === 'visual' ? (
renderVisualView()
) : (
renderRawView()
)}
</div>
</main>
</div>
);
};
export default LinkMLViewerPage;