/**
* 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