2381 lines
93 KiB
TypeScript
2381 lines
93 KiB
TypeScript
/**
|
||
* 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;
|