- Add 'Compare' toggle button next to slots with slot_usage overrides - Show generic slot definition vs class-specific override in 3-column grid - Highlight changed properties with green 'changed' badge - Display '(inherited)' when override matches generic definition - Display '(not defined)' when generic has no value for property - Compare: range, description, required, multivalued, slot_uri, pattern, identifier - Full i18n support (Dutch/English translations) - Responsive design: stacks vertically on mobile (<640px)
3517 lines
152 KiB
TypeScript
3517 lines
152 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, type ClassImportInfo, type ClassDependencyCounts, type SlotDefinition } 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)));
|
||
};
|
||
|
||
// Debug logging helper
|
||
const debugLog = (...args: unknown[]) => {
|
||
console.log('[LinkMLViewerPage]', ...args);
|
||
};
|
||
|
||
/**
|
||
* Build a filtered UML diagram centered on a specific class.
|
||
* Includes both imports (dependencies) and exports (reverse dependencies) based on flags.
|
||
* Uses BFS to include related classes up to specified depth.
|
||
*
|
||
* @param centerClassName - The class to center the diagram on
|
||
* @param allExportInfo - Map of class name to ClassExportInfo for all loaded classes
|
||
* @param allImportInfo - Map of class name to ClassImportInfo for all loaded classes
|
||
* @param depth - How many levels of relationships to include (default: 1)
|
||
* @param parentClasses - Optional map of class to parent class from semantic info
|
||
* @param showImports - Whether to include import relationships (dependencies)
|
||
* @param showExports - Whether to include export relationships (reverse dependencies)
|
||
* @param slotSemanticLabels - Optional map of slot name to semantic predicate label (e.g., "has_collection" -> "schema:collection")
|
||
* @returns UMLDiagram with filtered nodes and links
|
||
*/
|
||
const buildFilteredUMLDiagram = (
|
||
centerClassName: string,
|
||
allExportInfo: Record<string, ClassExportInfo>,
|
||
allImportInfo: Record<string, ClassImportInfo>,
|
||
depth: number = 1,
|
||
parentClasses: Record<string, string> = {},
|
||
showImports: boolean = true,
|
||
showExports: boolean = true,
|
||
slotSemanticLabels: Map<string, string> = new Map()
|
||
): UMLDiagram => {
|
||
const nodes: UMLNode[] = [];
|
||
const links: UMLLink[] = [];
|
||
const addedNodes = new Set<string>();
|
||
const addedLinks = new Set<string>(); // Track links to avoid duplicates
|
||
const processedAtDepth = new Map<string, number>(); // Track at what depth each class was processed
|
||
|
||
// Helper to get the semantic label for a slot (falls back to slot name if not found)
|
||
const getSemanticLabel = (slotName: string): string => {
|
||
return slotSemanticLabels.get(slotName) || slotName;
|
||
};
|
||
|
||
// Helper to add a node if not already added
|
||
const addNode = (name: string, _distanceFromCenter: number, type: 'class' | 'enum' = 'class') => {
|
||
if (!addedNodes.has(name)) {
|
||
addedNodes.add(name);
|
||
nodes.push({
|
||
id: name,
|
||
name: name,
|
||
type: type,
|
||
attributes: [],
|
||
methods: [],
|
||
});
|
||
}
|
||
};
|
||
|
||
// Helper to add a link if not already added (checking both directions)
|
||
// For slot-based edges (aggregation/composition), uses semantic predicate as label
|
||
const addLink = (source: string, target: string, type: UMLLink['type'], label: string, isSlotEdge: boolean = false) => {
|
||
const linkKey1 = `${source}->${target}:${type}`;
|
||
const linkKey2 = `${target}->${source}:${type}`;
|
||
// For slot edges, use semantic predicate label; for others (inheritance, mixin), use original label
|
||
const displayLabel = isSlotEdge ? getSemanticLabel(label) : label;
|
||
// For inheritance, direction matters; for others, check both
|
||
if (type === 'inheritance') {
|
||
if (!addedLinks.has(linkKey1)) {
|
||
addedLinks.add(linkKey1);
|
||
links.push({ source, target, type, label: displayLabel });
|
||
}
|
||
} else {
|
||
if (!addedLinks.has(linkKey1) && !addedLinks.has(linkKey2)) {
|
||
addedLinks.add(linkKey1);
|
||
links.push({ source, target, type, label: displayLabel });
|
||
}
|
||
}
|
||
};
|
||
|
||
// Get all related classes based on showImports/showExports flags
|
||
const getRelatedClasses = (className: string): string[] => {
|
||
const related: string[] = [];
|
||
|
||
// IMPORTS: What this class depends on (parent, mixins, slot ranges)
|
||
if (showImports) {
|
||
const importInfo = allImportInfo[className];
|
||
if (importInfo) {
|
||
// Parent class (is_a)
|
||
if (importInfo.parentClass) {
|
||
related.push(importInfo.parentClass);
|
||
}
|
||
// Mixins this class uses
|
||
related.push(...importInfo.mixins);
|
||
// Classes/enums used as slot ranges
|
||
related.push(...importInfo.slotRanges.map(r => r.rangeType));
|
||
// Slot usage range overrides
|
||
related.push(...importInfo.slotUsageRanges.map(r => r.rangeType));
|
||
// Annotation-based relationships (dual-class pattern)
|
||
if (importInfo.linkedCollectionType) {
|
||
related.push(importInfo.linkedCollectionType);
|
||
}
|
||
if (importInfo.linkedCustodianType) {
|
||
related.push(importInfo.linkedCustodianType);
|
||
}
|
||
}
|
||
// Also check parentClasses map for inheritance
|
||
if (parentClasses[className]) {
|
||
related.push(parentClasses[className]);
|
||
}
|
||
}
|
||
|
||
// EXPORTS: What references this class (subclasses, mixin users, slot ranges)
|
||
if (showExports) {
|
||
const exportInfo = allExportInfo[className];
|
||
if (exportInfo) {
|
||
// Subclasses
|
||
related.push(...exportInfo.subclasses);
|
||
// Mixin users
|
||
related.push(...exportInfo.mixinUsers);
|
||
// Classes using slots with this range
|
||
related.push(...exportInfo.classesUsingSlotWithThisRange.map(c => c.className));
|
||
// Classes referencing in slot_usage
|
||
related.push(...exportInfo.classesReferencingInSlotUsage);
|
||
// Annotation-based relationships (dual-class pattern) - collection types that reference this custodian
|
||
related.push(...exportInfo.linkedCollectionTypes);
|
||
}
|
||
}
|
||
|
||
return [...new Set(related)]; // Dedupe
|
||
};
|
||
|
||
// Add relationships from a class based on showImports/showExports flags
|
||
const addRelationshipsFromClass = (className: string) => {
|
||
// IMPORTS: Show what this class depends on
|
||
if (showImports) {
|
||
const importInfo = allImportInfo[className];
|
||
if (importInfo) {
|
||
// Parent class (is_a inheritance) - arrow points from child to parent
|
||
if (importInfo.parentClass && addedNodes.has(importInfo.parentClass)) {
|
||
addLink(className, importInfo.parentClass, 'inheritance', 'is_a', false);
|
||
}
|
||
// Mixins - arrow points from class to mixin
|
||
for (const mixin of importInfo.mixins) {
|
||
if (addedNodes.has(mixin)) {
|
||
addLink(className, mixin, 'association', 'mixin', false);
|
||
}
|
||
}
|
||
// Slot ranges - arrow points from class to range type (SLOT EDGE - use semantic predicate)
|
||
for (const { slotName, rangeType } of importInfo.slotRanges) {
|
||
if (addedNodes.has(rangeType)) {
|
||
addLink(className, rangeType, 'aggregation', slotName, true);
|
||
}
|
||
}
|
||
// Slot usage ranges (SLOT EDGE - use semantic predicate)
|
||
for (const { slotName, rangeType } of importInfo.slotUsageRanges) {
|
||
if (addedNodes.has(rangeType)) {
|
||
// For slot_usage, append "(usage)" to semantic label
|
||
const semanticLabel = getSemanticLabel(slotName);
|
||
addLink(className, rangeType, 'aggregation', `${semanticLabel} (usage)`, false);
|
||
}
|
||
}
|
||
// Annotation-based relationships (dual-class pattern)
|
||
// linked_collection_type - custodian type → collection type
|
||
if (importInfo.linkedCollectionType && addedNodes.has(importInfo.linkedCollectionType)) {
|
||
addLink(className, importInfo.linkedCollectionType, 'association', 'linked_collection_type', false);
|
||
}
|
||
// linked_custodian_type - collection type → custodian type
|
||
if (importInfo.linkedCustodianType && addedNodes.has(importInfo.linkedCustodianType)) {
|
||
addLink(className, importInfo.linkedCustodianType, 'association', 'linked_custodian_type', false);
|
||
}
|
||
}
|
||
// Also check parentClasses map for inheritance
|
||
if (parentClasses[className] && addedNodes.has(parentClasses[className])) {
|
||
addLink(className, parentClasses[className], 'inheritance', 'is_a', false);
|
||
}
|
||
}
|
||
|
||
// EXPORTS: Show what references this class
|
||
if (showExports) {
|
||
const exportInfo = allExportInfo[className];
|
||
if (exportInfo) {
|
||
// Subclasses - arrow points from subclass to this class
|
||
for (const subclass of exportInfo.subclasses) {
|
||
if (addedNodes.has(subclass)) {
|
||
addLink(subclass, className, 'inheritance', 'is_a', false);
|
||
}
|
||
}
|
||
// Mixin users - arrow points from user to this class
|
||
for (const mixinUser of exportInfo.mixinUsers) {
|
||
if (addedNodes.has(mixinUser)) {
|
||
addLink(mixinUser, className, 'association', 'mixin', false);
|
||
}
|
||
}
|
||
// Classes using slots with this range (SLOT EDGE - use semantic predicate)
|
||
for (const { className: usingClass, slotName } of exportInfo.classesUsingSlotWithThisRange) {
|
||
if (addedNodes.has(usingClass)) {
|
||
addLink(usingClass, className, 'aggregation', slotName, true);
|
||
}
|
||
}
|
||
// Classes referencing in slot_usage
|
||
for (const refClass of exportInfo.classesReferencingInSlotUsage) {
|
||
if (addedNodes.has(refClass)) {
|
||
addLink(refClass, className, 'association', 'slot_usage', false);
|
||
}
|
||
}
|
||
// Annotation-based relationships (dual-class pattern)
|
||
// Collection types that have linked_custodian_type pointing to this class
|
||
for (const collectionType of exportInfo.linkedCollectionTypes) {
|
||
if (addedNodes.has(collectionType)) {
|
||
addLink(collectionType, className, 'association', 'linked_custodian_type', false);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// BFS to add nodes up to specified depth
|
||
const queue: Array<{ className: string; currentDepth: number }> = [
|
||
{ className: centerClassName, currentDepth: 0 }
|
||
];
|
||
|
||
while (queue.length > 0) {
|
||
const { className, currentDepth } = queue.shift()!;
|
||
|
||
// Skip if already processed at same or lower depth
|
||
const existingDepth = processedAtDepth.get(className);
|
||
if (existingDepth !== undefined && existingDepth <= currentDepth) {
|
||
continue;
|
||
}
|
||
processedAtDepth.set(className, currentDepth);
|
||
|
||
// Add node
|
||
addNode(className, currentDepth);
|
||
|
||
// If we haven't reached max depth, queue related classes
|
||
if (currentDepth < depth) {
|
||
const related = getRelatedClasses(className);
|
||
for (const relatedClass of related) {
|
||
// Only queue if not already processed at a lower depth
|
||
const relDepth = processedAtDepth.get(relatedClass);
|
||
if (relDepth === undefined || relDepth > currentDepth + 1) {
|
||
queue.push({ className: relatedClass, currentDepth: currentDepth + 1 });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Now add all links between added nodes
|
||
for (const className of addedNodes) {
|
||
addRelationshipsFromClass(className);
|
||
}
|
||
|
||
// Debug logging to understand link generation
|
||
debugLog(`[buildFilteredUMLDiagram] Building diagram for ${centerClassName}, depth=${depth}`);
|
||
debugLog(`[buildFilteredUMLDiagram] showImports=${showImports}, showExports=${showExports}`);
|
||
debugLog(`[buildFilteredUMLDiagram] Total nodes: ${nodes.length}`, nodes.map(n => n.name));
|
||
debugLog(`[buildFilteredUMLDiagram] Total links: ${links.length}`);
|
||
|
||
// Log link breakdown by type
|
||
const linksByType = {
|
||
inheritance: links.filter(l => l.type === 'inheritance'),
|
||
aggregation: links.filter(l => l.type === 'aggregation'),
|
||
association: links.filter(l => l.type === 'association'),
|
||
composition: links.filter(l => l.type === 'composition'),
|
||
};
|
||
debugLog(`[buildFilteredUMLDiagram] Links by type:`, {
|
||
inheritance: linksByType.inheritance.length,
|
||
aggregation: linksByType.aggregation.length,
|
||
association: linksByType.association.length,
|
||
composition: linksByType.composition.length,
|
||
});
|
||
|
||
// Log the actual import info to understand why slot ranges might be missing
|
||
const centerImportInfo = allImportInfo[centerClassName];
|
||
if (centerImportInfo) {
|
||
debugLog(`[buildFilteredUMLDiagram] Center class import info:`, {
|
||
parentClass: centerImportInfo.parentClass,
|
||
mixins: centerImportInfo.mixins,
|
||
slotRanges: centerImportInfo.slotRanges,
|
||
slotUsageRanges: centerImportInfo.slotUsageRanges,
|
||
linkedCollectionType: centerImportInfo.linkedCollectionType,
|
||
linkedCustodianType: centerImportInfo.linkedCustodianType,
|
||
});
|
||
} else {
|
||
debugLog(`[buildFilteredUMLDiagram] WARNING: No import info for center class ${centerClassName}`);
|
||
}
|
||
|
||
return {
|
||
nodes,
|
||
links,
|
||
title: `${centerClassName} Relationships (Depth: ${depth})`,
|
||
};
|
||
};
|
||
|
||
// Bilingual text content
|
||
const TEXT = {
|
||
sidebarTitle: { nl: 'LinkML-schema\'s', en: 'LinkML Schemas' },
|
||
pageTitle: { nl: 'LinkML-schemaviewer', en: 'LinkML Schema Viewer' },
|
||
visualView: { nl: 'Visuele weergave', en: 'Visual View' },
|
||
rawYaml: { nl: 'Ruwe YAML', en: 'Raw YAML' },
|
||
loading: { nl: 'Schema laden...', en: 'Loading schema...' },
|
||
noSchemasFound: { nl: 'Geen schema\'s gevonden. Zorg dat het manifest is gegenereerd.', en: 'No schemas found. Make sure the manifest is generated.' },
|
||
failedToInit: { nl: 'Initialiseren van schemalijst mislukt', en: 'Failed to initialize schema list' },
|
||
failedToLoad: { nl: 'Schema laden mislukt:', en: 'Failed to load schema:' },
|
||
unnamedSchema: { nl: 'Naamloos schema', en: 'Unnamed Schema' },
|
||
prefixes: { nl: 'Prefixen', en: 'Prefixes' },
|
||
classes: { nl: 'Klassen', en: 'Classes' },
|
||
slots: { nl: 'Slots', en: 'Slots' },
|
||
enumerations: { nl: 'Enumeraties', en: 'Enumerations' },
|
||
imports: { nl: 'Imports', en: 'Imports' },
|
||
abstract: { nl: 'abstract', en: 'abstract' },
|
||
required: { nl: 'verplicht', en: 'required' },
|
||
multivalued: { nl: 'meervoudig', en: 'multivalued' },
|
||
uri: { nl: 'URI:', en: 'URI:' },
|
||
id: { nl: 'ID:', en: 'ID:' },
|
||
version: { nl: 'Versie:', en: 'Version:' },
|
||
range: { nl: 'Bereik:', en: 'Range:' },
|
||
pattern: { nl: 'Patroon:', en: 'Pattern:' },
|
||
slotsLabel: { nl: 'Slots:', en: 'Slots:' },
|
||
exactMappings: { nl: 'Exacte mappings:', en: 'Exact Mappings:' },
|
||
closeMappings: { nl: 'Vergelijkbare mappings:', en: 'Close Mappings:' },
|
||
permissibleValues: { nl: 'Toegestane waarden:', en: 'Permissible Values:' },
|
||
meaning: { nl: 'betekenis:', en: 'meaning:' },
|
||
searchPlaceholder: { nl: 'Zoeken in waarden...', en: 'Search values...' },
|
||
showAll: { nl: 'Alles tonen', en: 'Show all' },
|
||
showLess: { nl: 'Minder tonen', en: 'Show less' },
|
||
showing: { nl: 'Toont', en: 'Showing' },
|
||
of: { nl: 'van', en: 'of' },
|
||
noResults: { nl: 'Geen resultaten gevonden', en: 'No results found' },
|
||
searchSchemas: { nl: 'Zoeken in schema\'s...', en: 'Search schemas...' },
|
||
mainSchema: { nl: 'Hoofdschema', en: 'Main Schema' },
|
||
noMatchingSchemas: { nl: 'Geen overeenkomende schema\'s', en: 'No matching schemas' },
|
||
copyToClipboard: { nl: 'Kopieer naar klembord', en: 'Copy to clipboard' },
|
||
copied: { nl: 'Gekopieerd!', en: 'Copied!' },
|
||
use3DPolygon: { nl: '3D-polygoon', en: '3D Polygon' },
|
||
use2DBadge: { nl: '2D-badge', en: '2D Badge' },
|
||
// Class-level imports (dependencies)
|
||
classImports: { nl: 'Imports', en: 'Imports' },
|
||
classImportsTooltip: {
|
||
nl: 'Toont welke klassen en types deze klasse als afhankelijkheden gebruikt (ouderklasse, mixins, slot ranges)',
|
||
en: 'Shows what classes and types this class depends on (parent class, mixins, slot ranges)'
|
||
},
|
||
parentClassLabel: { nl: 'Ouderklasse (is_a)', en: 'Parent Class (is_a)' },
|
||
mixinsLabel: { nl: 'Mixins', en: 'Mixins' },
|
||
slotRangesLabel: { nl: 'Slot Ranges', en: 'Slot Ranges' },
|
||
slotUsageRangesLabel: { nl: 'Slot Usage Ranges', en: 'Slot Usage Ranges' },
|
||
noImports: { nl: 'Deze klasse heeft geen afhankelijkheden van andere klassen.', en: 'This class has no dependencies on other classes.' },
|
||
// File-level imports tooltip (to differentiate from class-level)
|
||
fileImportsTooltip: {
|
||
nl: 'Toont welke LinkML-modules dit schemabestand importeert op bestandsniveau',
|
||
en: 'Shows which LinkML modules this schema file imports at file level'
|
||
},
|
||
// UML diagram direction toggles
|
||
umlShowImports: { nl: 'Imports tonen', en: 'Show Imports' },
|
||
umlShowExports: { nl: 'Exports tonen', en: 'Show Exports' },
|
||
umlImportsTooltip: { nl: 'Toon afhankelijkheden (ouderklasse, mixins, slot ranges)', en: 'Show dependencies (parent class, mixins, slot ranges)' },
|
||
umlExportsTooltip: { nl: 'Toon verwijzingen naar deze klasse (subklassen, mixin-gebruikers)', en: 'Show references to this class (subclasses, mixin users)' },
|
||
umlNoRelationshipsShown: {
|
||
nl: 'Selecteer minimaal één richting (Imports of Exports) om relaties te tonen.',
|
||
en: 'Select at least one direction (Imports or Exports) to show relationships.'
|
||
},
|
||
umlNoRelationshipsFound: {
|
||
nl: 'Deze klasse heeft geen relaties in de geselecteerde richting(en).',
|
||
en: 'This class has no relationships in the selected direction(s).'
|
||
},
|
||
// Sidebar collapse
|
||
collapseSidebar: { nl: 'Zijbalk inklappen', en: 'Collapse sidebar' },
|
||
expandSidebar: { nl: 'Zijbalk uitklappen', en: 'Expand sidebar' },
|
||
// Class slots accordion
|
||
classSlots: { nl: 'Slots', en: 'Slots' },
|
||
classSlotsTooltip: {
|
||
nl: 'Toont alle slots (eigenschappen) die gedefinieerd zijn voor deze klasse',
|
||
en: 'Shows all slots (properties) defined for this class'
|
||
},
|
||
noClassSlots: { nl: 'Deze klasse heeft geen slots.', en: 'This class has no slots.' },
|
||
// Slot details - semantic URI and mappings
|
||
slotUri: { nl: 'Slot URI:', en: 'Slot URI:' },
|
||
identifier: { nl: 'identifier', en: 'identifier' },
|
||
examples: { nl: 'Voorbeelden:', en: 'Examples:' },
|
||
narrowMappings: { nl: 'Specifiekere mappings:', en: 'Narrow Mappings:' },
|
||
broadMappings: { nl: 'Bredere mappings:', en: 'Broad Mappings:' },
|
||
relatedMappings: { nl: 'Gerelateerde mappings:', en: 'Related Mappings:' },
|
||
comments: { nl: 'Opmerkingen:', en: 'Comments:' },
|
||
// Slot usage indicator
|
||
slotUsageBadge: { nl: 'slot_usage', en: 'slot_usage' },
|
||
slotUsageTooltip: {
|
||
nl: 'Deze slot heeft klasse-specifieke overschrijvingen gedefinieerd in slot_usage. Eigenschappen met ✦ komen uit de slot_usage, andere zijn overgenomen van de generieke slot-definitie.',
|
||
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.'
|
||
},
|
||
slotUsageOverrideMarker: { nl: '✦', en: '✦' },
|
||
inheritedFromGeneric: { nl: 'overgenomen', en: 'inherited' },
|
||
overriddenInSlotUsage: { nl: 'overschreven in slot_usage', en: 'overridden in slot_usage' },
|
||
// Slot usage legend
|
||
slotUsageLegendTitle: { nl: 'Legenda slot_usage', 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)' },
|
||
};
|
||
|
||
// 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 sidebar collapse
|
||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(false);
|
||
|
||
// State for copy to clipboard feedback
|
||
const [copyFeedback, setCopyFeedback] = useState(false);
|
||
|
||
// State for expandable Exports section in class details
|
||
const [expandedExports, setExpandedExports] = useState<Set<string>>(new Set());
|
||
const [classExports, setClassExports] = useState<Record<string, ClassExportInfo>>({});
|
||
const [loadingExports, setLoadingExports] = useState<Set<string>>(new Set());
|
||
|
||
// State for expandable Imports section in class details (dependencies)
|
||
const [expandedImports, setExpandedImports] = useState<Set<string>>(new Set());
|
||
const [classImports, setClassImports] = useState<Record<string, ClassImportInfo>>({});
|
||
const [loadingImports, setLoadingImports] = useState<Set<string>>(new Set());
|
||
|
||
// State for expandable UML diagram section in class details
|
||
const [expandedUML, setExpandedUML] = useState<Set<string>>(new Set());
|
||
|
||
// State for expandable class slots section in class details
|
||
const [expandedClassSlots, setExpandedClassSlots] = useState<Set<string>>(new Set());
|
||
|
||
// State for pre-loaded dependency counts (imports/exports) for all classes
|
||
// This allows showing counts immediately without loading full import/export data
|
||
const [dependencyCounts, setDependencyCounts] = useState<Map<string, ClassDependencyCounts>>(new Map());
|
||
|
||
// State for UML depth slider per class (1 = direct relationships only, 2 = secondary, etc.)
|
||
const [umlDepth, setUmlDepth] = useState<Record<string, number>>({});
|
||
|
||
// State for UML direction toggles per class (show imports, exports, or both)
|
||
// Default: both enabled (true)
|
||
const [umlShowImports, setUmlShowImports] = useState<Record<string, boolean>>({});
|
||
const [umlShowExports, setUmlShowExports] = useState<Record<string, boolean>>({});
|
||
|
||
// State for UML layout type per class
|
||
const [umlLayoutType, setUmlLayoutType] = useState<Record<string, 'dagre' | 'force' | 'circular' | 'radial'>>({});
|
||
const [umlDagreDirection, setUmlDagreDirection] = useState<Record<string, 'TB' | 'LR'>>({});
|
||
|
||
// State for UML fullscreen mode per class
|
||
const [umlFullscreen, setUmlFullscreen] = useState<string | null>(null);
|
||
|
||
// State for layout dropdown open per class
|
||
const [umlLayoutDropdownOpen, setUmlLayoutDropdownOpen] = useState<Record<string, boolean>>({});
|
||
|
||
// State for parent class mapping (className -> parentClassName from is_a inheritance)
|
||
// Built incrementally as classes are loaded for UML diagrams
|
||
const [parentClassMap, setParentClassMap] = useState<Record<string, string>>({});
|
||
|
||
// State for service-loaded slot definitions (from getAllSlots)
|
||
// Used to resolve slot names to full definitions in class details
|
||
const [serviceSlots, setServiceSlots] = useState<Map<string, SlotDefinition>>(new Map());
|
||
|
||
// State for slot semantic labels (slot name -> semantic predicate like "schema:collection")
|
||
// Computed from serviceSlots when available
|
||
const [slotSemanticLabels, setSlotSemanticLabels] = useState<Map<string, string>>(new Map());
|
||
|
||
// State for 3D polygon indicator toggle
|
||
// Persisted in localStorage - defaults to false (2D SVG) if not set
|
||
const [use3DIndicator, setUse3DIndicator] = useState(() => {
|
||
try {
|
||
const saved = localStorage.getItem('linkml-viewer-use-3d');
|
||
return saved === 'true';
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
|
||
// Persist 3D mode preference to localStorage
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem('linkml-viewer-use-3d', use3DIndicator ? 'true' : 'false');
|
||
} catch {
|
||
// Ignore localStorage errors (e.g., private browsing)
|
||
}
|
||
}, [use3DIndicator]);
|
||
|
||
// State for bidirectional hover sync between 3D polyhedrons and legend bar
|
||
const [hoveredCustodianType, setHoveredCustodianType] = useState<CustodianTypeCode | null>(null);
|
||
|
||
// State for slot_usage legend visibility toggle
|
||
const [showSlotUsageLegend, setShowSlotUsageLegend] = useState(false);
|
||
|
||
// State for slot_usage comparison view - tracks which slots have comparison expanded
|
||
// Key format: "className:slotName"
|
||
const [expandedSlotComparisons, setExpandedSlotComparisons] = useState<Set<string>>(new Set());
|
||
|
||
// Toggle comparison view for a specific slot
|
||
const toggleSlotComparison = (className: string, slotName: string) => {
|
||
const key = `${className}:${slotName}`;
|
||
setExpandedSlotComparisons(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(key)) {
|
||
next.delete(key);
|
||
} else {
|
||
next.add(key);
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
// State for filtering schema elements by custodian type (click on polyhedron face or legend)
|
||
// Multi-select: Set of selected type codes (empty = no filter)
|
||
// Initialize from URL params (e.g., ?custodian=G,L,A)
|
||
const [custodianTypeFilter, setCustodianTypeFilter] = useState<Set<CustodianTypeCode>>(() => {
|
||
const param = searchParams.get('custodian');
|
||
if (!param) return new Set();
|
||
const codes = param.split(',').filter((c): c is CustodianTypeCode =>
|
||
CUSTODIAN_TYPE_CODES.includes(c as CustodianTypeCode)
|
||
);
|
||
return new Set(codes);
|
||
});
|
||
|
||
// Sync custodian filter to URL params
|
||
useEffect(() => {
|
||
const currentParam = searchParams.get('custodian');
|
||
const filterCodes = Array.from(custodianTypeFilter).sort().join(',');
|
||
|
||
// Only update if different to avoid loops
|
||
if (custodianTypeFilter.size === 0 && currentParam) {
|
||
// Remove param if filter is empty
|
||
searchParams.delete('custodian');
|
||
setSearchParams(searchParams, { replace: true });
|
||
} else if (custodianTypeFilter.size > 0 && currentParam !== filterCodes) {
|
||
searchParams.set('custodian', filterCodes);
|
||
setSearchParams(searchParams, { replace: true });
|
||
}
|
||
}, [custodianTypeFilter, searchParams, setSearchParams]);
|
||
|
||
// Ref for main content (used by navigation-synced collapsible header)
|
||
const mainContentRef = useRef<HTMLElement>(null);
|
||
|
||
// Schema loading progress tracking
|
||
const { progress: schemaProgress, isLoading: isSchemaServiceLoading, isComplete: isSchemaServiceComplete } = useSchemaLoadingProgress();
|
||
|
||
// Handler for filtering by custodian type (clicking polyhedron face or legend item)
|
||
// Multi-select toggle behavior: clicking type adds/removes from set
|
||
const handleCustodianTypeFilter = useCallback((typeCode: CustodianTypeCode) => {
|
||
setCustodianTypeFilter(prev => {
|
||
const newSet = new Set(prev);
|
||
if (newSet.has(typeCode)) {
|
||
newSet.delete(typeCode);
|
||
} else {
|
||
newSet.add(typeCode);
|
||
}
|
||
return newSet;
|
||
});
|
||
}, []);
|
||
|
||
// Toggle exports section for a class and load export data on demand
|
||
const toggleExports = useCallback(async (className: string) => {
|
||
// Toggle expansion state
|
||
setExpandedExports(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(className)) {
|
||
next.delete(className);
|
||
} else {
|
||
next.add(className);
|
||
}
|
||
return next;
|
||
});
|
||
|
||
// Load export data if not already loaded and schema service is ready
|
||
if (!classExports[className] && !loadingExports.has(className) && isSchemaServiceComplete) {
|
||
setLoadingExports(prev => new Set(prev).add(className));
|
||
try {
|
||
const exportInfo = await linkmlSchemaService.getClassExportInfo(className);
|
||
setClassExports(prev => ({ ...prev, [className]: exportInfo }));
|
||
} catch (error) {
|
||
console.error(`Error loading export info for ${className}:`, error);
|
||
} finally {
|
||
setLoadingExports(prev => {
|
||
const next = new Set(prev);
|
||
next.delete(className);
|
||
return next;
|
||
});
|
||
}
|
||
}
|
||
}, [classExports, loadingExports, isSchemaServiceComplete]);
|
||
|
||
// Toggle imports section for a class and load import data on demand
|
||
const toggleImports = useCallback(async (className: string) => {
|
||
// Toggle expansion state
|
||
setExpandedImports(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(className)) {
|
||
next.delete(className);
|
||
} else {
|
||
next.add(className);
|
||
}
|
||
return next;
|
||
});
|
||
|
||
// Load import data if not already loaded and schema service is ready
|
||
if (!classImports[className] && !loadingImports.has(className) && isSchemaServiceComplete) {
|
||
setLoadingImports(prev => new Set(prev).add(className));
|
||
try {
|
||
const importInfo = await linkmlSchemaService.getClassImportInfo(className);
|
||
setClassImports(prev => ({ ...prev, [className]: importInfo }));
|
||
} catch (error) {
|
||
console.error(`Error loading import info for ${className}:`, error);
|
||
} finally {
|
||
setLoadingImports(prev => {
|
||
const next = new Set(prev);
|
||
next.delete(className);
|
||
return next;
|
||
});
|
||
}
|
||
}
|
||
}, [classImports, loadingImports, isSchemaServiceComplete]);
|
||
|
||
// Toggle UML diagram section for a class
|
||
// Loads both exports AND imports data since UML diagram can show both directions
|
||
const toggleUML = useCallback(async (className: string) => {
|
||
setExpandedUML(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(className)) {
|
||
next.delete(className);
|
||
} else {
|
||
next.add(className);
|
||
}
|
||
return next;
|
||
});
|
||
|
||
// Load exports data if not already loaded (for "Show Exports" checkbox)
|
||
if (!classExports[className] && !loadingExports.has(className) && isSchemaServiceComplete) {
|
||
setLoadingExports(prev => new Set(prev).add(className));
|
||
try {
|
||
const exportInfo = await linkmlSchemaService.getClassExportInfo(className);
|
||
setClassExports(prev => ({ ...prev, [className]: exportInfo }));
|
||
} catch (error) {
|
||
console.error(`Error loading export info for ${className}:`, error);
|
||
} finally {
|
||
setLoadingExports(prev => {
|
||
const next = new Set(prev);
|
||
next.delete(className);
|
||
return next;
|
||
});
|
||
}
|
||
}
|
||
|
||
// Load imports data if not already loaded (for "Show Imports" checkbox)
|
||
if (!classImports[className] && !loadingImports.has(className) && isSchemaServiceComplete) {
|
||
setLoadingImports(prev => new Set(prev).add(className));
|
||
try {
|
||
const importInfo = await linkmlSchemaService.getClassImportInfo(className);
|
||
setClassImports(prev => ({ ...prev, [className]: importInfo }));
|
||
} catch (error) {
|
||
console.error(`Error loading import info for ${className}:`, error);
|
||
} finally {
|
||
setLoadingImports(prev => {
|
||
const next = new Set(prev);
|
||
next.delete(className);
|
||
return next;
|
||
});
|
||
}
|
||
}
|
||
}, [classExports, classImports, loadingExports, loadingImports, isSchemaServiceComplete]);
|
||
|
||
// Toggle class slots accordion section
|
||
const toggleClassSlots = useCallback((className: string) => {
|
||
setExpandedClassSlots(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(className)) {
|
||
next.delete(className);
|
||
} else {
|
||
next.add(className);
|
||
}
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
// Load export AND import info for related classes when increasing depth
|
||
// Uses BFS to find all classes within the specified depth and loads their data
|
||
const loadRelationshipInfoForDepth = useCallback(async (centerClassName: string, depth: number) => {
|
||
if (!isSchemaServiceComplete) return;
|
||
|
||
// Get currently loaded info for center class
|
||
const centerExport = classExports[centerClassName];
|
||
const centerImport = classImports[centerClassName];
|
||
if (!centerExport && !centerImport) return;
|
||
|
||
// BFS to find all classes we need to load
|
||
const classesToLoadExports = new Set<string>();
|
||
const classesToLoadImports = new Set<string>();
|
||
const visited = new Set<string>();
|
||
const queue: Array<{ className: string; currentDepth: number }> = [
|
||
{ className: centerClassName, currentDepth: 0 }
|
||
];
|
||
|
||
while (queue.length > 0) {
|
||
const { className, currentDepth } = queue.shift()!;
|
||
|
||
if (visited.has(className) || currentDepth > depth) continue;
|
||
visited.add(className);
|
||
|
||
const exportInfo = classExports[className];
|
||
const importInfo = classImports[className];
|
||
|
||
// Mark for loading if not already loaded
|
||
if (!exportInfo && !loadingExports.has(className)) {
|
||
classesToLoadExports.add(className);
|
||
}
|
||
if (!importInfo && !loadingImports.has(className)) {
|
||
classesToLoadImports.add(className);
|
||
}
|
||
|
||
// Get related classes from exports (reverse dependencies)
|
||
const related: string[] = [];
|
||
if (exportInfo) {
|
||
related.push(...exportInfo.subclasses);
|
||
related.push(...exportInfo.mixinUsers);
|
||
related.push(...exportInfo.classesUsingSlotWithThisRange.map(c => c.className));
|
||
related.push(...exportInfo.classesReferencingInSlotUsage);
|
||
// Annotation-based relationships (dual-class pattern)
|
||
related.push(...exportInfo.linkedCollectionTypes);
|
||
}
|
||
|
||
// Get related classes from imports (forward dependencies)
|
||
if (importInfo) {
|
||
if (importInfo.parentClass) related.push(importInfo.parentClass);
|
||
related.push(...importInfo.mixins);
|
||
related.push(...importInfo.slotRanges.map(r => r.rangeType));
|
||
related.push(...importInfo.slotUsageRanges.map(r => r.rangeType));
|
||
// Annotation-based relationships (dual-class pattern)
|
||
if (importInfo.linkedCollectionType) related.push(importInfo.linkedCollectionType);
|
||
if (importInfo.linkedCustodianType) related.push(importInfo.linkedCustodianType);
|
||
}
|
||
|
||
// Also check parent class from semantic info if available
|
||
const parentClass = parentClassMap[className];
|
||
if (parentClass) {
|
||
related.push(parentClass);
|
||
}
|
||
|
||
// Queue related classes for next depth level
|
||
if (currentDepth < depth) {
|
||
for (const relatedClass of related) {
|
||
if (!visited.has(relatedClass)) {
|
||
queue.push({ className: relatedClass, currentDepth: currentDepth + 1 });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Load all classes that need loading
|
||
const hasExportsToLoad = classesToLoadExports.size > 0;
|
||
const hasImportsToLoad = classesToLoadImports.size > 0;
|
||
if (!hasExportsToLoad && !hasImportsToLoad) return;
|
||
|
||
// Mark as loading
|
||
if (hasExportsToLoad) {
|
||
setLoadingExports(prev => {
|
||
const next = new Set(prev);
|
||
classesToLoadExports.forEach(c => next.add(c));
|
||
return next;
|
||
});
|
||
}
|
||
if (hasImportsToLoad) {
|
||
setLoadingImports(prev => {
|
||
const next = new Set(prev);
|
||
classesToLoadImports.forEach(c => next.add(c));
|
||
return next;
|
||
});
|
||
}
|
||
|
||
// Load exports in parallel
|
||
const exportPromises = Array.from(classesToLoadExports).map(async (className) => {
|
||
try {
|
||
const exportInfo = await linkmlSchemaService.getClassExportInfo(className);
|
||
|
||
// Also try to get parent class info
|
||
try {
|
||
const semanticInfo = await linkmlSchemaService.getClassSemanticInfo(className);
|
||
if (semanticInfo?.parentClass) {
|
||
setParentClassMap(prev => ({ ...prev, [className]: semanticInfo.parentClass! }));
|
||
}
|
||
} catch {
|
||
// Semantic info not available for all classes
|
||
}
|
||
|
||
return { className, exportInfo };
|
||
} catch (error) {
|
||
console.error(`Error loading export info for ${className}:`, error);
|
||
return { className, exportInfo: null };
|
||
}
|
||
});
|
||
|
||
// Load imports in parallel
|
||
const importPromises = Array.from(classesToLoadImports).map(async (className) => {
|
||
try {
|
||
const importInfo = await linkmlSchemaService.getClassImportInfo(className);
|
||
return { className, importInfo };
|
||
} catch (error) {
|
||
console.error(`Error loading import info for ${className}:`, error);
|
||
return { className, importInfo: null };
|
||
}
|
||
});
|
||
|
||
const [exportResults, importResults] = await Promise.all([
|
||
Promise.all(exportPromises),
|
||
Promise.all(importPromises)
|
||
]);
|
||
|
||
// Update state with loaded export info
|
||
if (hasExportsToLoad) {
|
||
setClassExports(prev => {
|
||
const next = { ...prev };
|
||
for (const { className, exportInfo } of exportResults) {
|
||
if (exportInfo) {
|
||
next[className] = exportInfo;
|
||
}
|
||
}
|
||
return next;
|
||
});
|
||
|
||
// Clear loading state
|
||
setLoadingExports(prev => {
|
||
const next = new Set(prev);
|
||
classesToLoadExports.forEach(c => next.delete(c));
|
||
return next;
|
||
});
|
||
}
|
||
|
||
// Update state with loaded import info
|
||
if (hasImportsToLoad) {
|
||
setClassImports(prev => {
|
||
const next = { ...prev };
|
||
for (const { className, importInfo } of importResults) {
|
||
if (importInfo) {
|
||
next[className] = importInfo;
|
||
}
|
||
}
|
||
return next;
|
||
});
|
||
|
||
// Clear loading state
|
||
setLoadingImports(prev => {
|
||
const next = new Set(prev);
|
||
classesToLoadImports.forEach(c => next.delete(c));
|
||
return next;
|
||
});
|
||
}
|
||
|
||
// If we loaded new classes, we may need to traverse further
|
||
// Schedule another pass if we're not at full depth yet
|
||
const hasNewExports = exportResults.some(r => r.exportInfo !== null);
|
||
const hasNewImports = importResults.some(r => r.importInfo !== null);
|
||
if ((hasNewExports || hasNewImports) && depth > 1) {
|
||
// Re-run to potentially load more classes at deeper levels
|
||
setTimeout(() => loadRelationshipInfoForDepth(centerClassName, depth), 100);
|
||
}
|
||
}, [classExports, classImports, loadingExports, loadingImports, parentClassMap, isSchemaServiceComplete]);
|
||
|
||
// Debounced version of loadRelationshipInfoForDepth for slider interaction
|
||
// This prevents excessive API calls when dragging the slider
|
||
const debouncedLoadRelationshipInfo = useMemo(
|
||
() => debounce((className: string, depth: number) => {
|
||
loadRelationshipInfoForDepth(className, depth);
|
||
}, 300),
|
||
[loadRelationshipInfoForDepth]
|
||
);
|
||
|
||
// Cleanup debounced function on unmount
|
||
useEffect(() => {
|
||
return () => {
|
||
debouncedLoadRelationshipInfo.cancel();
|
||
};
|
||
}, [debouncedLoadRelationshipInfo]);
|
||
|
||
// Create a lookup map from slot names to slot definitions
|
||
// This allows renderClassDetails to resolve slot names to full slot objects
|
||
// Priority: service slots (comprehensive) > local schema slots (fallback)
|
||
const slotLookupMap = useMemo(() => {
|
||
const map = new Map<string, LinkMLSlot>();
|
||
|
||
// First, add any slots from the local schema file (fallback)
|
||
if (schema) {
|
||
const localSlots = extractSlots(schema);
|
||
localSlots.forEach(slot => map.set(slot.name, slot));
|
||
}
|
||
|
||
// Then, add/override with service slots (comprehensive - includes all modules)
|
||
// SlotDefinition is compatible with LinkMLSlot (superset)
|
||
serviceSlots.forEach((slot, name) => {
|
||
map.set(name, slot as LinkMLSlot);
|
||
});
|
||
|
||
return map;
|
||
}, [schema, serviceSlots]);
|
||
|
||
// Cache for memoized UML diagrams - keyed by className:depth:showImports:showExports
|
||
// This prevents expensive diagram rebuilding when only unrelated state changes
|
||
const diagramCache = useRef<Map<string, UMLDiagram>>(new Map());
|
||
const diagramCacheDeps = useRef<{ classExports: typeof classExports; classImports: typeof classImports; parentClassMap: typeof parentClassMap; slotSemanticLabels: typeof slotSemanticLabels } | null>(null);
|
||
|
||
// Clear cache when dependencies change
|
||
if (diagramCacheDeps.current === null ||
|
||
diagramCacheDeps.current.classExports !== classExports ||
|
||
diagramCacheDeps.current.classImports !== classImports ||
|
||
diagramCacheDeps.current.parentClassMap !== parentClassMap ||
|
||
diagramCacheDeps.current.slotSemanticLabels !== slotSemanticLabels) {
|
||
diagramCache.current.clear();
|
||
diagramCacheDeps.current = { classExports, classImports, parentClassMap, slotSemanticLabels };
|
||
}
|
||
|
||
// Memoized UML diagram builder with result caching to prevent recalculation
|
||
// Returns cached result if same parameters, otherwise builds and caches new diagram
|
||
const getMemoizedUMLDiagram = useCallback((className: string, depth: number, showImports: boolean, showExports: boolean): UMLDiagram => {
|
||
const cacheKey = `${className}:${depth}:${showImports}:${showExports}`;
|
||
|
||
// Return cached diagram if available
|
||
const cached = diagramCache.current.get(cacheKey);
|
||
if (cached) {
|
||
return cached;
|
||
}
|
||
|
||
// Build new diagram and cache it (pass slot semantic labels for edge predicates)
|
||
const diagram = buildFilteredUMLDiagram(className, classExports, classImports, depth, parentClassMap, showImports, showExports, slotSemanticLabels);
|
||
diagramCache.current.set(cacheKey, diagram);
|
||
|
||
// Limit cache size to prevent memory issues (keep last 20 diagrams)
|
||
if (diagramCache.current.size > 20) {
|
||
const firstKey = diagramCache.current.keys().next().value;
|
||
if (firstKey) diagramCache.current.delete(firstKey);
|
||
}
|
||
|
||
return diagram;
|
||
}, [classExports, classImports, parentClassMap, slotSemanticLabels]);
|
||
|
||
// Navigate to a class by updating URL params and selecting the schema file
|
||
const navigateToClass = useCallback((className: string) => {
|
||
setSearchParams({ class: className });
|
||
setHighlightedClass(className);
|
||
|
||
// 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]);
|
||
|
||
// Effect: Pre-load dependency counts for all classes when schema service completes
|
||
// This enables showing import/export counts immediately without waiting for click
|
||
useEffect(() => {
|
||
if (!isSchemaServiceComplete) return;
|
||
|
||
const loadDependencyCounts = async () => {
|
||
try {
|
||
const counts = await linkmlSchemaService.getAllClassDependencyCounts();
|
||
setDependencyCounts(counts);
|
||
console.log('[LinkMLViewerPage] Pre-loaded dependency counts for', counts.size, 'classes');
|
||
} catch (error) {
|
||
console.error('[LinkMLViewerPage] Error loading dependency counts:', error);
|
||
}
|
||
};
|
||
|
||
loadDependencyCounts();
|
||
}, [isSchemaServiceComplete]);
|
||
|
||
// Effect: Load all slot definitions from schema service for slot lookup
|
||
// This enables resolving slot names to full definitions in class details
|
||
// Also computes semantic labels for UML edge labels
|
||
useEffect(() => {
|
||
if (!isSchemaServiceComplete) return;
|
||
|
||
const loadServiceSlots = async () => {
|
||
try {
|
||
const slots = await linkmlSchemaService.getAllSlots();
|
||
setServiceSlots(slots);
|
||
|
||
// Build semantic labels map: slot name -> first exact_mapping or slot_uri
|
||
const labels = new Map<string, string>();
|
||
for (const [name, slot] of slots.entries()) {
|
||
// Priority 1: First exact_mapping (most precise semantic alignment)
|
||
if (slot.exact_mappings && slot.exact_mappings.length > 0) {
|
||
labels.set(name, slot.exact_mappings[0]);
|
||
}
|
||
// Priority 2: slot_uri
|
||
else if (slot.slot_uri) {
|
||
labels.set(name, slot.slot_uri);
|
||
}
|
||
// Priority 3: Fallback to slot name
|
||
else {
|
||
labels.set(name, name);
|
||
}
|
||
}
|
||
setSlotSemanticLabels(labels);
|
||
|
||
console.log('[LinkMLViewerPage] Pre-loaded slot definitions for', slots.size, 'slots');
|
||
console.log('[LinkMLViewerPage] Computed semantic labels for', labels.size, 'slots');
|
||
} catch (error) {
|
||
console.error('[LinkMLViewerPage] Error loading slot definitions:', error);
|
||
}
|
||
};
|
||
|
||
loadServiceSlots();
|
||
}, [isSchemaServiceComplete]);
|
||
|
||
const toggleSection = (section: string) => {
|
||
setExpandedSections(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(section)) {
|
||
next.delete(section);
|
||
} else {
|
||
next.add(section);
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
// Check if a range is an enum type
|
||
const isEnumRange = (range: string): boolean => {
|
||
return range.endsWith('Enum');
|
||
};
|
||
|
||
// Toggle enum range expansion and load enum data if needed
|
||
const toggleEnumRange = async (slotName: string, enumName: string) => {
|
||
const key = `${slotName}:${enumName}`;
|
||
|
||
// Toggle expansion state
|
||
setExpandedEnumRanges(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(key)) {
|
||
next.delete(key);
|
||
} else {
|
||
next.add(key);
|
||
}
|
||
return next;
|
||
});
|
||
|
||
// Load enum data if not already loaded
|
||
if (!loadedEnums[enumName]) {
|
||
try {
|
||
const enumSchema = await loadSchema(`modules/enums/${enumName}.yaml`);
|
||
if (enumSchema?.enums) {
|
||
const enumDef = Object.values(enumSchema.enums)[0] as LinkMLEnum;
|
||
if (enumDef) {
|
||
setLoadedEnums(prev => ({ ...prev, [enumName]: { ...enumDef, name: enumName } }));
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error(`Failed to load enum ${enumName}:`, err);
|
||
setLoadedEnums(prev => ({ ...prev, [enumName]: null }));
|
||
}
|
||
}
|
||
};
|
||
|
||
// Render enum values for a slot range
|
||
const renderEnumValues = (_slotName: string, enumName: string) => {
|
||
const enumDef = loadedEnums[enumName];
|
||
|
||
if (!enumDef?.permissible_values) {
|
||
return <div className="linkml-viewer__enum-loading">{t('loading')}</div>;
|
||
}
|
||
|
||
const allValues = Object.entries(enumDef.permissible_values);
|
||
const searchFilter = enumSearchFilters[enumName] || '';
|
||
const showAll = enumShowAll[enumName] || false;
|
||
const displayCount = 20; // Show first 20 values initially
|
||
|
||
// Filter values based on search
|
||
const filteredValues = searchFilter
|
||
? allValues.filter(([value, details]) => {
|
||
const searchLower = searchFilter.toLowerCase();
|
||
return (
|
||
value.toLowerCase().includes(searchLower) ||
|
||
(details.description?.toLowerCase().includes(searchLower)) ||
|
||
(details.meaning?.toLowerCase().includes(searchLower))
|
||
);
|
||
})
|
||
: allValues;
|
||
|
||
// Determine how many to show
|
||
const valuesToShow = showAll || searchFilter ? filteredValues : filteredValues.slice(0, displayCount);
|
||
|
||
return (
|
||
<div className="linkml-viewer__range-enum-values">
|
||
<div className="linkml-viewer__range-enum-header">
|
||
{/* Search input */}
|
||
<div className="linkml-viewer__range-enum-search">
|
||
<input
|
||
type="text"
|
||
placeholder={t('searchPlaceholder')}
|
||
value={searchFilter}
|
||
onChange={(e) => setEnumSearchFilters(prev => ({ ...prev, [enumName]: e.target.value }))}
|
||
className="linkml-viewer__range-enum-search-input"
|
||
/>
|
||
{searchFilter && (
|
||
<button
|
||
className="linkml-viewer__range-enum-search-clear"
|
||
onClick={() => setEnumSearchFilters(prev => ({ ...prev, [enumName]: '' }))}
|
||
title="Clear search"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Count display */}
|
||
<span className="linkml-viewer__range-enum-count">
|
||
{searchFilter ? (
|
||
<>
|
||
{t('showing')} {filteredValues.length} {t('of')} {allValues.length}
|
||
</>
|
||
) : showAll ? (
|
||
<>
|
||
{allValues.length} {t('permissibleValues').replace(':', '')}
|
||
</>
|
||
) : (
|
||
<>
|
||
{t('showing')} {Math.min(displayCount, allValues.length)} {t('of')} {allValues.length}
|
||
</>
|
||
)}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="linkml-viewer__range-enum-list">
|
||
{valuesToShow.length > 0 ? (
|
||
valuesToShow.map(([value, details]) => (
|
||
<div key={value} className="linkml-viewer__range-enum-item">
|
||
<code className="linkml-viewer__range-enum-name">{value}</code>
|
||
{details.meaning && (
|
||
<a
|
||
href={details.meaning.startsWith('wikidata:')
|
||
? `https://www.wikidata.org/wiki/${details.meaning.replace('wikidata:', '')}`
|
||
: details.meaning}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="linkml-viewer__range-enum-link"
|
||
title={details.meaning}
|
||
>
|
||
🔗
|
||
</a>
|
||
)}
|
||
{details.description && (
|
||
<span className="linkml-viewer__range-enum-desc">{details.description}</span>
|
||
)}
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="linkml-viewer__range-enum-no-results">
|
||
{t('noResults')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Show all / Show less button */}
|
||
{!searchFilter && allValues.length > displayCount && (
|
||
<button
|
||
className="linkml-viewer__range-enum-toggle-all"
|
||
onClick={() => setEnumShowAll(prev => ({ ...prev, [enumName]: !showAll }))}
|
||
>
|
||
{showAll ? (
|
||
<>▲ {t('showLess')}</>
|
||
) : (
|
||
<>▼ {t('showAll')} ({allValues.length - displayCount} {language === 'nl' ? 'meer' : 'more'})</>
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderClassDetails = (cls: LinkMLClass) => {
|
||
const isHighlighted = highlightedClass === cls.name;
|
||
// Use pre-loaded types from schema annotations, fall back to sync function if not yet loaded
|
||
const custodianTypes = classCustodianTypes[cls.name] || getCustodianTypesForClass(cls.name);
|
||
const isUniversal = isUniversalElement(custodianTypes);
|
||
|
||
// Check if this class matches the current custodian type filter (multi-select)
|
||
const matchesFilter = custodianTypeFilter.size === 0 || custodianTypes.some(t => custodianTypeFilter.has(t));
|
||
|
||
return (
|
||
<div
|
||
key={cls.name}
|
||
ref={isHighlighted ? highlightedRef : null}
|
||
className={`linkml-viewer__item ${isHighlighted ? 'linkml-viewer__item--highlighted' : ''} ${!matchesFilter ? 'linkml-viewer__item--filtered-out' : ''}`}
|
||
>
|
||
<h4 className="linkml-viewer__item-name">
|
||
{cls.name}
|
||
{cls.abstract && <span className="linkml-viewer__badge linkml-viewer__badge--abstract">{t('abstract')}</span>}
|
||
{/* Show 3D polygon for ALL elements (including universal) in 3D mode for testing */}
|
||
{use3DIndicator ? (
|
||
<CustodianTypeIndicator3D
|
||
types={custodianTypes}
|
||
size={32}
|
||
animate={true}
|
||
showTooltip={true}
|
||
hoveredType={hoveredCustodianType}
|
||
onTypeHover={setHoveredCustodianType}
|
||
// Removed onFaceClick - cube should be decorative, not trigger filtering
|
||
className="linkml-viewer__custodian-indicator"
|
||
/>
|
||
) : (
|
||
!isUniversal && (
|
||
<CustodianTypeBadge
|
||
types={custodianTypes}
|
||
size="small"
|
||
className="linkml-viewer__custodian-badge"
|
||
/>
|
||
)
|
||
)}
|
||
</h4>
|
||
{cls.class_uri && (
|
||
<div className="linkml-viewer__uri">
|
||
<span className="linkml-viewer__label">{t('uri')}</span>
|
||
<code>{cls.class_uri}</code>
|
||
</div>
|
||
)}
|
||
{cls.description && (
|
||
<div className="linkml-viewer__description linkml-viewer__markdown">
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(cls.description)}</ReactMarkdown>
|
||
</div>
|
||
)}
|
||
{/* Class Slots Section - Accordion showing slot details for this class */}
|
||
{cls.slots && cls.slots.length > 0 && (
|
||
<div className="linkml-viewer__class-slots-section">
|
||
<button
|
||
className={`linkml-viewer__class-slots-toggle ${expandedClassSlots.has(cls.name) ? 'linkml-viewer__class-slots-toggle--expanded' : ''}`}
|
||
onClick={() => toggleClassSlots(cls.name)}
|
||
title={t('classSlotsTooltip')}
|
||
>
|
||
<span className="linkml-viewer__class-slots-icon">{expandedClassSlots.has(cls.name) ? '▼' : '▶'}</span>
|
||
<span className="linkml-viewer__label">{t('classSlots')}</span>
|
||
<span className="linkml-viewer__class-slots-count">{cls.slots.length}</span>
|
||
</button>
|
||
{expandedClassSlots.has(cls.name) && (
|
||
<div className="linkml-viewer__class-slots-content">
|
||
{cls.slots.map(slotName => {
|
||
// Get generic slot definition from the global slot lookup
|
||
const genericSlot = slotLookupMap.get(slotName);
|
||
// Get class-specific slot_usage overrides (if any)
|
||
const slotUsage = cls.slot_usage?.[slotName];
|
||
|
||
// Merge generic slot with slot_usage overrides
|
||
// slot_usage properties take precedence over generic slot properties
|
||
if (genericSlot || slotUsage) {
|
||
const mergedSlot: LinkMLSlot = {
|
||
name: slotName,
|
||
...(genericSlot || {}),
|
||
...(slotUsage || {}),
|
||
};
|
||
return renderSlotDetails(mergedSlot, slotUsage || null, genericSlot || null, cls.name);
|
||
}
|
||
// Fallback: show slot name as tag if neither generic definition nor slot_usage found
|
||
return (
|
||
<div key={slotName} className="linkml-viewer__item linkml-viewer__item--slot-fallback">
|
||
<h4 className="linkml-viewer__item-name">{slotName}</h4>
|
||
<div className="linkml-viewer__description">
|
||
<em>Slot definition not found in schema</em>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{cls.exact_mappings && cls.exact_mappings.length > 0 && (
|
||
<div className="linkml-viewer__mappings">
|
||
<span className="linkml-viewer__label">{t('exactMappings')}</span>
|
||
<div className="linkml-viewer__tag-list">
|
||
{cls.exact_mappings.map(mapping => (
|
||
<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>
|
||
)}
|
||
{/* Imports Section - Shows forward dependencies (what this class depends on) */}
|
||
{isSchemaServiceComplete && (
|
||
<div className="linkml-viewer__imports-section">
|
||
<button
|
||
className={`linkml-viewer__imports-toggle ${expandedImports.has(cls.name) ? 'linkml-viewer__imports-toggle--expanded' : ''}`}
|
||
onClick={() => toggleImports(cls.name)}
|
||
title={t('classImportsTooltip')}
|
||
>
|
||
<span className="linkml-viewer__imports-icon">{expandedImports.has(cls.name) ? '▼' : '▶'}</span>
|
||
<span className="linkml-viewer__label">{t('classImports')}</span>
|
||
{/* Show pre-loaded count OR detailed count if data loaded */}
|
||
{classImports[cls.name] ? (
|
||
<span className="linkml-viewer__imports-count">
|
||
{(classImports[cls.name].parentClass ? 1 : 0) +
|
||
classImports[cls.name].mixins.length +
|
||
classImports[cls.name].slotRanges.length +
|
||
classImports[cls.name].slotUsageRanges.length}
|
||
</span>
|
||
) : dependencyCounts.get(cls.name) ? (
|
||
<span className="linkml-viewer__imports-count">
|
||
{dependencyCounts.get(cls.name)!.importCount}
|
||
</span>
|
||
) : null}
|
||
{loadingImports.has(cls.name) && <span className="linkml-viewer__imports-loading">Loading...</span>}
|
||
</button>
|
||
{expandedImports.has(cls.name) && classImports[cls.name] && (
|
||
<div className="linkml-viewer__imports-content">
|
||
{/* Parent Class (is_a) */}
|
||
{classImports[cls.name].parentClass && (
|
||
<div className="linkml-viewer__imports-category">
|
||
<span className="linkml-viewer__imports-category-label">{t('parentClassLabel')}</span>
|
||
<div className="linkml-viewer__imports-list">
|
||
<button
|
||
className="linkml-viewer__imports-link"
|
||
onClick={() => navigateToClass(classImports[cls.name].parentClass!)}
|
||
>
|
||
{classImports[cls.name].parentClass}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Mixins */}
|
||
{classImports[cls.name].mixins.length > 0 && (
|
||
<div className="linkml-viewer__imports-category">
|
||
<span className="linkml-viewer__imports-category-label">{t('mixinsLabel')} ({classImports[cls.name].mixins.length})</span>
|
||
<div className="linkml-viewer__imports-list">
|
||
{classImports[cls.name].mixins.map(mixin => (
|
||
<button
|
||
key={mixin}
|
||
className="linkml-viewer__imports-link"
|
||
onClick={() => navigateToClass(mixin)}
|
||
>
|
||
{mixin}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Slot Ranges */}
|
||
{classImports[cls.name].slotRanges.length > 0 && (
|
||
<div className="linkml-viewer__imports-category">
|
||
<span className="linkml-viewer__imports-category-label">{t('slotRangesLabel')} ({classImports[cls.name].slotRanges.length})</span>
|
||
<div className="linkml-viewer__imports-list">
|
||
{classImports[cls.name].slotRanges.map(({ slotName, rangeType, isClass }) => (
|
||
<button
|
||
key={`${slotName}-${rangeType}`}
|
||
className="linkml-viewer__imports-link"
|
||
onClick={() => navigateToClass(rangeType)}
|
||
title={`slot: ${slotName}`}
|
||
>
|
||
{rangeType} <span className="linkml-viewer__imports-via">via {slotName}</span>
|
||
{!isClass && <span className="linkml-viewer__imports-badge linkml-viewer__imports-badge--enum">enum</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Slot Usage Ranges */}
|
||
{classImports[cls.name].slotUsageRanges.length > 0 && (
|
||
<div className="linkml-viewer__imports-category">
|
||
<span className="linkml-viewer__imports-category-label">{t('slotUsageRangesLabel')} ({classImports[cls.name].slotUsageRanges.length})</span>
|
||
<div className="linkml-viewer__imports-list">
|
||
{classImports[cls.name].slotUsageRanges.map(({ slotName, rangeType }) => (
|
||
<button
|
||
key={`usage-${slotName}-${rangeType}`}
|
||
className="linkml-viewer__imports-link"
|
||
onClick={() => navigateToClass(rangeType)}
|
||
title={`slot_usage: ${slotName}`}
|
||
>
|
||
{rangeType} <span className="linkml-viewer__imports-via">via {slotName}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* No imports message */}
|
||
{!classImports[cls.name].parentClass &&
|
||
classImports[cls.name].mixins.length === 0 &&
|
||
classImports[cls.name].slotRanges.length === 0 &&
|
||
classImports[cls.name].slotUsageRanges.length === 0 && (
|
||
<div className="linkml-viewer__imports-empty">
|
||
{t('noImports')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* Exports Section - Shows reverse dependencies (what references this class) */}
|
||
{isSchemaServiceComplete && (
|
||
<div className="linkml-viewer__exports-section">
|
||
<button
|
||
className={`linkml-viewer__exports-toggle ${expandedExports.has(cls.name) ? 'linkml-viewer__exports-toggle--expanded' : ''}`}
|
||
onClick={() => toggleExports(cls.name)}
|
||
>
|
||
<span className="linkml-viewer__exports-icon">{expandedExports.has(cls.name) ? '▼' : '▶'}</span>
|
||
<span className="linkml-viewer__label">Exports</span>
|
||
{/* Show pre-loaded count OR detailed count if data loaded */}
|
||
{classExports[cls.name] ? (
|
||
<span className="linkml-viewer__exports-count">
|
||
{classExports[cls.name].subclasses.length +
|
||
classExports[cls.name].mixinUsers.length +
|
||
classExports[cls.name].classesUsingSlotWithThisRange.length +
|
||
classExports[cls.name].classesReferencingInSlotUsage.length}
|
||
</span>
|
||
) : dependencyCounts.get(cls.name) ? (
|
||
<span className="linkml-viewer__exports-count">
|
||
{dependencyCounts.get(cls.name)!.exportCount}
|
||
</span>
|
||
) : null}
|
||
{loadingExports.has(cls.name) && <span className="linkml-viewer__exports-loading">Loading...</span>}
|
||
</button>
|
||
{expandedExports.has(cls.name) && classExports[cls.name] && (
|
||
<div className="linkml-viewer__exports-content">
|
||
{/* Subclasses */}
|
||
{classExports[cls.name].subclasses.length > 0 && (
|
||
<div className="linkml-viewer__exports-category">
|
||
<span className="linkml-viewer__exports-category-label">Subclasses ({classExports[cls.name].subclasses.length})</span>
|
||
<div className="linkml-viewer__exports-list">
|
||
{classExports[cls.name].subclasses.map(subclass => (
|
||
<button
|
||
key={subclass}
|
||
className="linkml-viewer__exports-link"
|
||
onClick={() => 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) || loadingImports.has(cls.name)) && <span className="linkml-viewer__uml-loading">Loading...</span>}
|
||
</button>
|
||
{/* Show UML content when expanded AND required data is loaded */}
|
||
{/* Must wait for BOTH exports AND imports since imports contain slot range info needed for aggregation links */}
|
||
{expandedUML.has(cls.name) && classExports[cls.name] && classImports[cls.name] && (
|
||
<div className="linkml-viewer__uml-content">
|
||
{/* Direction toggles (Imports/Exports checkboxes) */}
|
||
<div className="linkml-viewer__uml-direction-control">
|
||
<label className="linkml-viewer__uml-checkbox-label linkml-viewer__uml-checkbox-label--imports" title={t('umlImportsTooltip')}>
|
||
<input
|
||
type="checkbox"
|
||
checked={umlShowImports[cls.name] !== false}
|
||
onChange={(e) => setUmlShowImports(prev => ({ ...prev, [cls.name]: e.target.checked }))}
|
||
className="linkml-viewer__uml-checkbox linkml-viewer__uml-checkbox--imports"
|
||
/>
|
||
<span>{t('umlShowImports')}</span>
|
||
</label>
|
||
<label className="linkml-viewer__uml-checkbox-label linkml-viewer__uml-checkbox-label--exports" title={t('umlExportsTooltip')}>
|
||
<input
|
||
type="checkbox"
|
||
checked={umlShowExports[cls.name] !== false}
|
||
onChange={(e) => setUmlShowExports(prev => ({ ...prev, [cls.name]: e.target.checked }))}
|
||
className="linkml-viewer__uml-checkbox linkml-viewer__uml-checkbox--exports"
|
||
/>
|
||
<span>{t('umlShowExports')}</span>
|
||
</label>
|
||
</div>
|
||
{/* Depth slider */}
|
||
<div className="linkml-viewer__uml-depth-control">
|
||
<label className="linkml-viewer__uml-depth-label">
|
||
<span>Relationship Depth:</span>
|
||
<input
|
||
type="range"
|
||
min="1"
|
||
max="4"
|
||
value={umlDepth[cls.name] || 1}
|
||
onChange={(e) => {
|
||
const newDepth = parseInt(e.target.value, 10);
|
||
setUmlDepth(prev => ({ ...prev, [cls.name]: newDepth }));
|
||
// Use debounced loading to prevent excessive API calls while dragging
|
||
if (newDepth > 1) {
|
||
debouncedLoadRelationshipInfo(cls.name, newDepth);
|
||
}
|
||
}}
|
||
className="linkml-viewer__uml-depth-slider"
|
||
/>
|
||
<span className="linkml-viewer__uml-depth-value">{umlDepth[cls.name] || 1}</span>
|
||
</label>
|
||
<span className="linkml-viewer__uml-depth-hint">
|
||
{(umlDepth[cls.name] || 1) === 1 ? 'Direct relationships' :
|
||
(umlDepth[cls.name] || 1) === 2 ? 'Secondary connections' :
|
||
(umlDepth[cls.name] || 1) === 3 ? 'Tertiary connections' : 'Extended network'}
|
||
</span>
|
||
</div>
|
||
{/* UML Toolbar */}
|
||
<div className="linkml-viewer__uml-toolbar">
|
||
{/* Zoom Controls */}
|
||
<button
|
||
className="linkml-viewer__uml-toolbar-btn"
|
||
title="Fit to screen"
|
||
onClick={() => window.dispatchEvent(new CustomEvent('uml-fit-to-screen'))}
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
|
||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
|
||
</svg>
|
||
<span>Fit</span>
|
||
</button>
|
||
<button
|
||
className="linkml-viewer__uml-toolbar-btn"
|
||
title="Zoom in"
|
||
onClick={() => window.dispatchEvent(new CustomEvent('uml-zoom-in'))}
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
|
||
<circle cx="11" cy="11" r="8" />
|
||
<path d="m21 21-4.35-4.35" />
|
||
<line x1="11" y1="8" x2="11" y2="14" />
|
||
<line x1="8" y1="11" x2="14" y2="11" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
className="linkml-viewer__uml-toolbar-btn"
|
||
title="Zoom out"
|
||
onClick={() => window.dispatchEvent(new CustomEvent('uml-zoom-out'))}
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
|
||
<circle cx="11" cy="11" r="8" />
|
||
<path d="m21 21-4.35-4.35" />
|
||
<line x1="8" y1="11" x2="14" y2="11" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
className="linkml-viewer__uml-toolbar-btn"
|
||
title="Reset view"
|
||
onClick={() => window.dispatchEvent(new CustomEvent('uml-reset-view'))}
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
|
||
<polyline points="1 4 1 10 7 10" />
|
||
<polyline points="23 20 23 14 17 14" />
|
||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
||
</svg>
|
||
</button>
|
||
|
||
<div className="linkml-viewer__uml-toolbar-separator" />
|
||
|
||
{/* Fullscreen Toggle */}
|
||
<button
|
||
className={`linkml-viewer__uml-toolbar-btn ${umlFullscreen === cls.name ? 'linkml-viewer__uml-toolbar-btn--active' : ''}`}
|
||
title={umlFullscreen === cls.name ? 'Exit fullscreen' : 'Fullscreen'}
|
||
onClick={() => setUmlFullscreen(umlFullscreen === cls.name ? null : cls.name)}
|
||
>
|
||
{umlFullscreen === cls.name ? (
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
|
||
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3" />
|
||
</svg>
|
||
) : (
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
|
||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
|
||
<div className="linkml-viewer__uml-toolbar-separator" />
|
||
|
||
{/* Layout Dropdown */}
|
||
<div className="linkml-viewer__uml-layout-dropdown">
|
||
<button
|
||
className="linkml-viewer__uml-toolbar-btn"
|
||
onClick={() => setUmlLayoutDropdownOpen(prev => ({ ...prev, [cls.name]: !prev[cls.name] }))}
|
||
title={`Layout: ${umlLayoutType[cls.name] || 'dagre'} ${umlLayoutType[cls.name] === 'dagre' ? `(${umlDagreDirection[cls.name] || 'TB'})` : ''}`}
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16">
|
||
<rect x="3" y="3" width="7" height="7" />
|
||
<rect x="14" y="3" width="7" height="7" />
|
||
<rect x="14" y="14" width="7" height="7" />
|
||
<rect x="3" y="14" width="7" height="7" />
|
||
</svg>
|
||
<span>Layout</span>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="12" height="12">
|
||
<path d="m6 9 6 6 6-6" />
|
||
</svg>
|
||
</button>
|
||
|
||
{umlLayoutDropdownOpen[cls.name] && (
|
||
<div className="linkml-viewer__uml-layout-menu">
|
||
<button
|
||
className={`linkml-viewer__uml-layout-option ${(umlLayoutType[cls.name] || 'dagre') === 'dagre' && (umlDagreDirection[cls.name] || 'TB') === 'TB' ? 'linkml-viewer__uml-layout-option--active' : ''}`}
|
||
onClick={() => {
|
||
setUmlLayoutType(prev => ({ ...prev, [cls.name]: 'dagre' }));
|
||
setUmlDagreDirection(prev => ({ ...prev, [cls.name]: 'TB' }));
|
||
setUmlLayoutDropdownOpen(prev => ({ ...prev, [cls.name]: false }));
|
||
}}
|
||
>
|
||
<span className="linkml-viewer__uml-layout-title">Hierarchical (Top-Bottom)</span>
|
||
<span className="linkml-viewer__uml-layout-desc">Tree structure flowing downward</span>
|
||
</button>
|
||
<button
|
||
className={`linkml-viewer__uml-layout-option ${(umlLayoutType[cls.name] || 'dagre') === 'dagre' && umlDagreDirection[cls.name] === 'LR' ? 'linkml-viewer__uml-layout-option--active' : ''}`}
|
||
onClick={() => {
|
||
setUmlLayoutType(prev => ({ ...prev, [cls.name]: 'dagre' }));
|
||
setUmlDagreDirection(prev => ({ ...prev, [cls.name]: 'LR' }));
|
||
setUmlLayoutDropdownOpen(prev => ({ ...prev, [cls.name]: false }));
|
||
}}
|
||
>
|
||
<span className="linkml-viewer__uml-layout-title">Hierarchical (Left-Right)</span>
|
||
<span className="linkml-viewer__uml-layout-desc">Tree structure flowing rightward</span>
|
||
</button>
|
||
<button
|
||
className={`linkml-viewer__uml-layout-option ${umlLayoutType[cls.name] === 'force' ? 'linkml-viewer__uml-layout-option--active' : ''}`}
|
||
onClick={() => {
|
||
setUmlLayoutType(prev => ({ ...prev, [cls.name]: 'force' }));
|
||
setUmlLayoutDropdownOpen(prev => ({ ...prev, [cls.name]: false }));
|
||
}}
|
||
>
|
||
<span className="linkml-viewer__uml-layout-title">Force-Directed</span>
|
||
<span className="linkml-viewer__uml-layout-desc">Physics-based node positioning</span>
|
||
</button>
|
||
<button
|
||
className={`linkml-viewer__uml-layout-option ${umlLayoutType[cls.name] === 'circular' ? 'linkml-viewer__uml-layout-option--active' : ''}`}
|
||
onClick={() => {
|
||
setUmlLayoutType(prev => ({ ...prev, [cls.name]: 'circular' }));
|
||
setUmlLayoutDropdownOpen(prev => ({ ...prev, [cls.name]: false }));
|
||
}}
|
||
>
|
||
<span className="linkml-viewer__uml-layout-title">Circular</span>
|
||
<span className="linkml-viewer__uml-layout-desc">Nodes arranged in a circle</span>
|
||
</button>
|
||
<button
|
||
className={`linkml-viewer__uml-layout-option ${umlLayoutType[cls.name] === 'radial' ? 'linkml-viewer__uml-layout-option--active' : ''}`}
|
||
onClick={() => {
|
||
setUmlLayoutType(prev => ({ ...prev, [cls.name]: 'radial' }));
|
||
setUmlLayoutDropdownOpen(prev => ({ ...prev, [cls.name]: false }));
|
||
}}
|
||
>
|
||
<span className="linkml-viewer__uml-layout-title">Radial</span>
|
||
<span className="linkml-viewer__uml-layout-desc">Center-outward tree layout</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Show empty state when both toggles are unchecked */}
|
||
{umlShowImports[cls.name] === false && umlShowExports[cls.name] === false ? (
|
||
<div className="linkml-viewer__uml-empty-state">
|
||
<span className="linkml-viewer__uml-empty-icon">🔗</span>
|
||
<p>{t('umlNoRelationshipsShown')}</p>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className={`linkml-viewer__uml-container ${umlFullscreen === cls.name ? 'linkml-viewer__uml-container--fullscreen' : ''}`}
|
||
style={umlFullscreen !== cls.name ? {
|
||
width: '100%',
|
||
height: '720px'
|
||
} : undefined}
|
||
>
|
||
<UMLVisualization
|
||
diagram={getMemoizedUMLDiagram(
|
||
cls.name,
|
||
umlDepth[cls.name] || 1,
|
||
umlShowImports[cls.name] !== false,
|
||
umlShowExports[cls.name] !== false
|
||
)}
|
||
width={umlFullscreen === cls.name ? window.innerWidth : undefined}
|
||
height={umlFullscreen === cls.name ? window.innerHeight : 720}
|
||
layoutType={umlLayoutType[cls.name] || 'dagre'}
|
||
dagreDirection={umlDagreDirection[cls.name] || 'TB'}
|
||
/>
|
||
{umlFullscreen === cls.name && (
|
||
<button
|
||
className="linkml-viewer__uml-fullscreen-close"
|
||
onClick={() => setUmlFullscreen(null)}
|
||
title="Exit fullscreen (Esc)"
|
||
>
|
||
✕
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* Show message when expanded but data not yet loaded */}
|
||
{expandedUML.has(cls.name) && (!classExports[cls.name] || !classImports[cls.name]) && !loadingExports.has(cls.name) && !loadingImports.has(cls.name) && (
|
||
<div className="linkml-viewer__uml-empty">
|
||
Click to load class relationships.
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderSlotDetails = (
|
||
slot: LinkMLSlot,
|
||
slotUsage: Partial<LinkMLSlot> | null = null,
|
||
genericSlot: LinkMLSlot | null = null,
|
||
className: string | null = null
|
||
) => {
|
||
const hasSlotUsageOverrides = slotUsage !== null;
|
||
const rangeIsEnum = slot.range && isEnumRange(slot.range);
|
||
const enumKey = slot.range ? `${slot.name}:${slot.range}` : '';
|
||
const isExpanded = expandedEnumRanges.has(enumKey);
|
||
// Use pre-loaded types from schema annotations, fall back to sync function if not yet loaded
|
||
const custodianTypes = slotCustodianTypes[slot.name] || getCustodianTypesForSlot(slot.name);
|
||
const isUniversal = isUniversalElement(custodianTypes);
|
||
|
||
// Check if this slot matches the current custodian type filter (multi-select)
|
||
const matchesFilter = custodianTypeFilter.size === 0 || custodianTypes.some(t => custodianTypeFilter.has(t));
|
||
|
||
// Check if comparison view is expanded for this slot
|
||
const comparisonKey = className ? `${className}:${slot.name}` : slot.name;
|
||
const showComparison = expandedSlotComparisons.has(comparisonKey);
|
||
|
||
// Helper to check if a property is overridden in slot_usage
|
||
const isOverridden = (property: keyof LinkMLSlot): boolean => {
|
||
if (!slotUsage) return false;
|
||
return slotUsage[property] !== undefined;
|
||
};
|
||
|
||
// Helper to render override marker
|
||
const OverrideMarker = ({ property }: { property: keyof LinkMLSlot }) => {
|
||
if (!hasSlotUsageOverrides) return null;
|
||
const overridden = isOverridden(property);
|
||
return (
|
||
<span
|
||
className={`linkml-viewer__override-marker ${overridden ? 'linkml-viewer__override-marker--overridden' : 'linkml-viewer__override-marker--inherited'}`}
|
||
title={overridden ? t('overriddenInSlotUsage') : t('inheritedFromGeneric')}
|
||
>
|
||
{overridden ? t('slotUsageOverrideMarker') : ''}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div key={slot.name} className={`linkml-viewer__item ${!matchesFilter ? 'linkml-viewer__item--filtered-out' : ''} ${hasSlotUsageOverrides ? 'linkml-viewer__item--has-usage' : ''}`}>
|
||
<h4 className="linkml-viewer__item-name">
|
||
{slot.name}
|
||
{hasSlotUsageOverrides && <span className="linkml-viewer__badge linkml-viewer__badge--usage" title={t('slotUsageTooltip')}>{t('slotUsageBadge')}</span>}
|
||
{/* Compare button - only show when slot_usage exists and we have both generic and override */}
|
||
{hasSlotUsageOverrides && genericSlot && className && (
|
||
<button
|
||
className={`linkml-viewer__badge linkml-viewer__badge--compare ${showComparison ? 'linkml-viewer__badge--compare-active' : ''}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
toggleSlotComparison(className, slot.name);
|
||
}}
|
||
title={t('slotUsageCompareTooltip')}
|
||
>
|
||
{showComparison ? '▼' : '▶'} {t('slotUsageCompareToggle')}
|
||
</button>
|
||
)}
|
||
{slot.required && <span className="linkml-viewer__badge linkml-viewer__badge--required">{t('required')}</span>}
|
||
{slot.multivalued && <span className="linkml-viewer__badge linkml-viewer__badge--multi">{t('multivalued')}</span>}
|
||
{slot.identifier && <span className="linkml-viewer__badge linkml-viewer__badge--identifier">{t('identifier')}</span>}
|
||
{/* Show 3D polygon for ALL elements (including universal) in 3D mode for testing */}
|
||
{use3DIndicator ? (
|
||
<CustodianTypeIndicator3D
|
||
types={custodianTypes}
|
||
size={32}
|
||
animate={true}
|
||
showTooltip={true}
|
||
hoveredType={hoveredCustodianType}
|
||
onTypeHover={setHoveredCustodianType}
|
||
// Removed onFaceClick - cube should be decorative, not trigger filtering
|
||
className="linkml-viewer__custodian-indicator"
|
||
/>
|
||
) : (
|
||
!isUniversal && (
|
||
<CustodianTypeBadge
|
||
types={custodianTypes}
|
||
size="small"
|
||
className="linkml-viewer__custodian-badge"
|
||
/>
|
||
)
|
||
)}
|
||
</h4>
|
||
{/* Slot URI - semantic predicate */}
|
||
{slot.slot_uri && (
|
||
<div className="linkml-viewer__slot-uri">
|
||
<span className="linkml-viewer__label"><OverrideMarker property="slot_uri" />{t('slotUri')}</span>
|
||
<code className="linkml-viewer__uri-value">{slot.slot_uri}</code>
|
||
</div>
|
||
)}
|
||
{slot.range && (
|
||
<div className="linkml-viewer__range">
|
||
<span className="linkml-viewer__label"><OverrideMarker property="range" />{t('range')}</span>
|
||
{rangeIsEnum ? (
|
||
<button
|
||
className={`linkml-viewer__range-enum-toggle ${isExpanded ? 'linkml-viewer__range-enum-toggle--expanded' : ''}`}
|
||
onClick={() => toggleEnumRange(slot.name, slot.range!)}
|
||
>
|
||
<span className="linkml-viewer__range-enum-icon">{isExpanded ? '▼' : '▶'}</span>
|
||
<code>{slot.range}</code>
|
||
<span className="linkml-viewer__badge linkml-viewer__badge--enum">enum</span>
|
||
</button>
|
||
) : (
|
||
<code>{slot.range}</code>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* Expandable enum values */}
|
||
{rangeIsEnum && isExpanded && slot.range && renderEnumValues(slot.name, slot.range)}
|
||
{slot.description && (
|
||
<div className="linkml-viewer__description linkml-viewer__markdown">
|
||
<span className="linkml-viewer__label linkml-viewer__label--inline"><OverrideMarker property="description" /></span>
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(slot.description)}</ReactMarkdown>
|
||
</div>
|
||
)}
|
||
{slot.pattern && (
|
||
<div className="linkml-viewer__pattern">
|
||
<span className="linkml-viewer__label"><OverrideMarker property="pattern" />{t('pattern')}</span>
|
||
<code>{slot.pattern}</code>
|
||
</div>
|
||
)}
|
||
{/* Examples section */}
|
||
{slot.examples && slot.examples.length > 0 && (
|
||
<div className="linkml-viewer__examples">
|
||
<span className="linkml-viewer__label"><OverrideMarker property="examples" />{t('examples')}</span>
|
||
<ul className="linkml-viewer__examples-list">
|
||
{slot.examples.map((example, idx) => (
|
||
<li key={idx} className="linkml-viewer__example-item">
|
||
<code className="linkml-viewer__example-value">{example.value}</code>
|
||
{example.description && (
|
||
<span className="linkml-viewer__example-description"> - {example.description}</span>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{/* Semantic Mappings - 5 types per LinkML spec */}
|
||
{slot.exact_mappings && slot.exact_mappings.length > 0 && (
|
||
<div className="linkml-viewer__mappings">
|
||
<span className="linkml-viewer__label"><OverrideMarker property="exact_mappings" />{t('exactMappings')}</span>
|
||
<div className="linkml-viewer__tag-list">
|
||
{slot.exact_mappings.map(mapping => (
|
||
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--mapping linkml-viewer__tag--exact">{mapping}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{slot.close_mappings && slot.close_mappings.length > 0 && (
|
||
<div className="linkml-viewer__mappings">
|
||
<span className="linkml-viewer__label"><OverrideMarker property="close_mappings" />{t('closeMappings')}</span>
|
||
<div className="linkml-viewer__tag-list">
|
||
{slot.close_mappings.map(mapping => (
|
||
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--mapping linkml-viewer__tag--close">{mapping}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{slot.narrow_mappings && slot.narrow_mappings.length > 0 && (
|
||
<div className="linkml-viewer__mappings">
|
||
<span className="linkml-viewer__label"><OverrideMarker property="narrow_mappings" />{t('narrowMappings')}</span>
|
||
<div className="linkml-viewer__tag-list">
|
||
{slot.narrow_mappings.map(mapping => (
|
||
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--mapping linkml-viewer__tag--narrow">{mapping}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{slot.broad_mappings && slot.broad_mappings.length > 0 && (
|
||
<div className="linkml-viewer__mappings">
|
||
<span className="linkml-viewer__label"><OverrideMarker property="broad_mappings" />{t('broadMappings')}</span>
|
||
<div className="linkml-viewer__tag-list">
|
||
{slot.broad_mappings.map(mapping => (
|
||
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--mapping linkml-viewer__tag--broad">{mapping}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{slot.related_mappings && slot.related_mappings.length > 0 && (
|
||
<div className="linkml-viewer__mappings">
|
||
<span className="linkml-viewer__label"><OverrideMarker property="related_mappings" />{t('relatedMappings')}</span>
|
||
<div className="linkml-viewer__tag-list">
|
||
{slot.related_mappings.map(mapping => (
|
||
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--mapping linkml-viewer__tag--related">{mapping}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Comments section */}
|
||
{slot.comments && slot.comments.length > 0 && (
|
||
<div className="linkml-viewer__comments">
|
||
<span className="linkml-viewer__label"><OverrideMarker property="comments" />{t('comments')}</span>
|
||
<ul className="linkml-viewer__comments-list">
|
||
{slot.comments.map((comment, idx) => (
|
||
<li key={idx} className="linkml-viewer__comment-item">{comment}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{/* Side-by-side comparison view - only shown when expanded */}
|
||
{showComparison && hasSlotUsageOverrides && genericSlot && (
|
||
<div className="linkml-viewer__slot-comparison">
|
||
<div className="linkml-viewer__slot-comparison-header">
|
||
<h5>{t('slotUsageCompareGeneric')}</h5>
|
||
<h5>{t('slotUsageCompareOverride')}</h5>
|
||
</div>
|
||
<div className="linkml-viewer__slot-comparison-content">
|
||
{/* Compare key properties side by side */}
|
||
{renderComparisonRow('range', genericSlot.range, slotUsage?.range)}
|
||
{renderComparisonRow('description', genericSlot.description, slotUsage?.description)}
|
||
{renderComparisonRow('required', genericSlot.required, slotUsage?.required)}
|
||
{renderComparisonRow('multivalued', genericSlot.multivalued, slotUsage?.multivalued)}
|
||
{renderComparisonRow('slot_uri', genericSlot.slot_uri, slotUsage?.slot_uri)}
|
||
{renderComparisonRow('pattern', genericSlot.pattern, slotUsage?.pattern)}
|
||
{renderComparisonRow('identifier', genericSlot.identifier, slotUsage?.identifier)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Helper function to render a comparison row
|
||
const renderComparisonRow = (
|
||
property: string,
|
||
genericValue: string | boolean | number | undefined | null,
|
||
overrideValue: string | boolean | number | undefined | null
|
||
) => {
|
||
const hasGeneric = genericValue !== undefined && genericValue !== null;
|
||
const hasOverride = overrideValue !== undefined && overrideValue !== null;
|
||
const isChanged = hasOverride && hasGeneric && genericValue !== overrideValue;
|
||
const isNewInOverride = hasOverride && !hasGeneric;
|
||
|
||
// Don't show row if neither has a value
|
||
if (!hasGeneric && !hasOverride) return null;
|
||
|
||
const formatValue = (val: string | boolean | number | undefined | null): string => {
|
||
if (val === undefined || val === null) return '';
|
||
if (typeof val === 'boolean') return val ? 'true' : 'false';
|
||
if (typeof val === 'number') return String(val);
|
||
// Truncate long strings for display
|
||
const str = String(val);
|
||
return str.length > 80 ? str.substring(0, 77) + '...' : str;
|
||
};
|
||
|
||
return (
|
||
<div key={property} className={`linkml-viewer__slot-comparison-row ${isChanged || isNewInOverride ? 'linkml-viewer__slot-comparison-row--changed' : ''}`}>
|
||
<div className="linkml-viewer__slot-comparison-label">{property}</div>
|
||
<div className="linkml-viewer__slot-comparison-generic">
|
||
{hasGeneric ? (
|
||
<code title={typeof genericValue === 'string' ? genericValue : undefined}>{formatValue(genericValue)}</code>
|
||
) : (
|
||
<span className="linkml-viewer__slot-comparison-empty">{t('slotUsageCompareNotDefined')}</span>
|
||
)}
|
||
</div>
|
||
<div className={`linkml-viewer__slot-comparison-override ${isChanged || isNewInOverride ? 'linkml-viewer__slot-comparison-override--diff' : ''}`}>
|
||
{hasOverride ? (
|
||
<>
|
||
<code title={typeof overrideValue === 'string' ? overrideValue : undefined}>{formatValue(overrideValue)}</code>
|
||
{(isChanged || isNewInOverride) && <span className="linkml-viewer__slot-comparison-diff-badge">{t('slotUsageCompareDiff')}</span>}
|
||
</>
|
||
) : (
|
||
<span className="linkml-viewer__slot-comparison-inherited">{t('slotUsageCompareInherited')}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderEnumDetails = (enumDef: LinkMLEnum) => {
|
||
const enumName = enumDef.name;
|
||
const allValues = enumDef.permissible_values ? Object.entries(enumDef.permissible_values) : [];
|
||
const searchFilter = enumSearchFilters[enumName] || '';
|
||
const showAll = enumShowAll[enumName] || false;
|
||
const displayCount = 20;
|
||
const custodianTypes = enumCustodianTypes[enumDef.name] || getCustodianTypesForEnum(enumDef.name);
|
||
const isUniversal = isUniversalElement(custodianTypes);
|
||
|
||
// Filter values based on search
|
||
const filteredValues = searchFilter
|
||
? allValues.filter(([value, details]) => {
|
||
const searchLower = searchFilter.toLowerCase();
|
||
return (
|
||
value.toLowerCase().includes(searchLower) ||
|
||
(details.description?.toLowerCase().includes(searchLower)) ||
|
||
(details.meaning?.toLowerCase().includes(searchLower))
|
||
);
|
||
})
|
||
: allValues;
|
||
|
||
// Determine how many to show
|
||
const valuesToShow = showAll || searchFilter ? filteredValues : filteredValues.slice(0, displayCount);
|
||
|
||
// Check if this enum matches the current custodian type filter (multi-select)
|
||
const matchesFilter = custodianTypeFilter.size === 0 || custodianTypes.some(t => custodianTypeFilter.has(t));
|
||
|
||
return (
|
||
<div key={enumDef.name} className={`linkml-viewer__item ${!matchesFilter ? 'linkml-viewer__item--filtered-out' : ''}`}>
|
||
<h4 className="linkml-viewer__item-name">
|
||
{enumDef.name}
|
||
{/* Show 3D polygon for ALL elements (including universal) in 3D mode for testing */}
|
||
{use3DIndicator ? (
|
||
<CustodianTypeIndicator3D
|
||
types={custodianTypes}
|
||
size={32}
|
||
animate={true}
|
||
showTooltip={true}
|
||
hoveredType={hoveredCustodianType}
|
||
onTypeHover={setHoveredCustodianType}
|
||
// Removed onFaceClick - cube should be decorative, not trigger filtering
|
||
className="linkml-viewer__custodian-indicator"
|
||
/>
|
||
) : (
|
||
!isUniversal && (
|
||
<CustodianTypeBadge
|
||
types={custodianTypes}
|
||
size="small"
|
||
className="linkml-viewer__custodian-badge"
|
||
/>
|
||
)
|
||
)}
|
||
</h4>
|
||
{enumDef.description && (
|
||
<div className="linkml-viewer__description linkml-viewer__markdown">
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(enumDef.description)}</ReactMarkdown>
|
||
</div>
|
||
)}
|
||
{allValues.length > 0 && (
|
||
<div className="linkml-viewer__enum-values">
|
||
{/* Search input */}
|
||
<div className="linkml-viewer__range-enum-search">
|
||
<input
|
||
type="text"
|
||
placeholder={t('searchPlaceholder')}
|
||
value={searchFilter}
|
||
onChange={(e) => setEnumSearchFilters(prev => ({ ...prev, [enumName]: e.target.value }))}
|
||
className="linkml-viewer__range-enum-search-input"
|
||
/>
|
||
{searchFilter && (
|
||
<button
|
||
className="linkml-viewer__range-enum-search-clear"
|
||
onClick={() => setEnumSearchFilters(prev => ({ ...prev, [enumName]: '' }))}
|
||
title="Clear search"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Count display */}
|
||
<span className="linkml-viewer__range-enum-count" style={{ display: 'block', marginBottom: '0.5rem' }}>
|
||
{searchFilter ? (
|
||
<>
|
||
{t('showing')} {filteredValues.length} {t('of')} {allValues.length}
|
||
</>
|
||
) : showAll ? (
|
||
<>
|
||
{allValues.length} {t('permissibleValues').replace(':', '')}
|
||
</>
|
||
) : (
|
||
<>
|
||
{t('showing')} {Math.min(displayCount, allValues.length)} {t('of')} {allValues.length}
|
||
</>
|
||
)}
|
||
</span>
|
||
|
||
<div className="linkml-viewer__value-list">
|
||
{valuesToShow.length > 0 ? (
|
||
valuesToShow.map(([value, details]) => (
|
||
<div key={value} className="linkml-viewer__value-item">
|
||
<code className="linkml-viewer__value-name">{value}</code>
|
||
{details.description && (
|
||
<div className="linkml-viewer__value-desc linkml-viewer__markdown linkml-viewer__markdown--compact">
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(details.description)}</ReactMarkdown>
|
||
</div>
|
||
)}
|
||
{details.meaning && (
|
||
<span className="linkml-viewer__value-meaning">
|
||
<span className="linkml-viewer__label">{t('meaning')}</span> {details.meaning}
|
||
</span>
|
||
)}
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="linkml-viewer__range-enum-no-results">
|
||
{t('noResults')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Show all / Show less button */}
|
||
{!searchFilter && allValues.length > displayCount && (
|
||
<button
|
||
className="linkml-viewer__range-enum-toggle-all"
|
||
onClick={() => setEnumShowAll(prev => ({ ...prev, [enumName]: !showAll }))}
|
||
>
|
||
{showAll ? (
|
||
<>▲ {t('showLess')}</>
|
||
) : (
|
||
<>▼ {t('showAll')} ({allValues.length - displayCount} {language === 'nl' ? 'meer' : 'more'})</>
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderVisualView = () => {
|
||
if (!schema) return null;
|
||
|
||
const classes = extractClasses(schema);
|
||
const enums = extractEnums(schema);
|
||
const slots = extractSlots(schema);
|
||
|
||
// 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 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;
|
||
|
||
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;
|
||
|
||
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(slot => renderSlotDetails(slot))}
|
||
</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 - File Level (LinkML module imports) */}
|
||
{schema.imports && schema.imports.length > 0 && (
|
||
<div className="linkml-viewer__section">
|
||
<button
|
||
className="linkml-viewer__section-header"
|
||
onClick={() => toggleSection('imports')}
|
||
title={t('fileImportsTooltip')}
|
||
>
|
||
<span className="linkml-viewer__section-icon">
|
||
{expandedSections.has('imports') ? '▼' : '▶'}
|
||
</span>
|
||
{t('imports')} ({schema.imports.length})
|
||
<span
|
||
className="linkml-viewer__info-icon"
|
||
data-tooltip={t('fileImportsTooltip')}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>ⓘ</span>
|
||
</button>
|
||
{expandedSections.has('imports') && (
|
||
<div className="linkml-viewer__import-list">
|
||
{schema.imports.map(imp => (
|
||
<div key={imp} className="linkml-viewer__import-item">
|
||
<code>{imp}</code>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderRawView = () => {
|
||
if (!rawYaml) return null;
|
||
|
||
const handleCopyYaml = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(rawYaml);
|
||
setCopyFeedback(true);
|
||
setTimeout(() => setCopyFeedback(false), 2000);
|
||
} catch (err) {
|
||
console.error('Failed to copy YAML:', err);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="linkml-viewer__raw">
|
||
<div className="linkml-viewer__raw-header">
|
||
<button
|
||
className={`linkml-viewer__copy-btn ${copyFeedback ? 'linkml-viewer__copy-btn--copied' : ''}`}
|
||
onClick={handleCopyYaml}
|
||
title={copyFeedback ? t('copied') : t('copyToClipboard')}
|
||
>
|
||
{copyFeedback ? '✓' : '⧉'} {copyFeedback ? t('copied') : t('copyToClipboard')}
|
||
</button>
|
||
</div>
|
||
<pre className="linkml-viewer__yaml">{rawYaml}</pre>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className={`linkml-viewer-page ${sidebarCollapsed ? 'linkml-viewer-page--sidebar-collapsed' : ''}`}>
|
||
{/* Left Sidebar - Schema Files */}
|
||
<aside className={`linkml-viewer-page__sidebar ${sidebarCollapsed ? 'linkml-viewer-page__sidebar--collapsed' : ''}`}>
|
||
<div className="linkml-viewer-page__sidebar-header">
|
||
<h2 className="linkml-viewer-page__sidebar-title">{!sidebarCollapsed && t('sidebarTitle')}</h2>
|
||
<button
|
||
className="linkml-viewer-page__sidebar-toggle"
|
||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||
title={sidebarCollapsed ? t('expandSidebar') : t('collapseSidebar')}
|
||
aria-label={sidebarCollapsed ? t('expandSidebar') : t('collapseSidebar')}
|
||
>
|
||
{sidebarCollapsed ? '»' : '«'}
|
||
</button>
|
||
</div>
|
||
|
||
{!sidebarCollapsed && (
|
||
<>
|
||
|
||
{/* Sidebar Search */}
|
||
<div className="linkml-viewer-page__sidebar-search">
|
||
<input
|
||
type="text"
|
||
placeholder={t('searchSchemas')}
|
||
value={sidebarSearch}
|
||
onChange={(e) => setSidebarSearch(e.target.value)}
|
||
className="linkml-viewer-page__sidebar-search-input"
|
||
/>
|
||
{sidebarSearch && (
|
||
<button
|
||
className="linkml-viewer-page__sidebar-search-clear"
|
||
onClick={() => setSidebarSearch('')}
|
||
title="Clear search"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 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>
|
||
<span className="linkml-viewer-page__tab-separator">|</span>
|
||
<button
|
||
className={`linkml-viewer-page__tab linkml-viewer-page__tab--legend ${showSlotUsageLegend ? 'linkml-viewer-page__tab--active' : ''}`}
|
||
onClick={() => setShowSlotUsageLegend(!showSlotUsageLegend)}
|
||
title={t('slotUsageLegendTitle')}
|
||
>
|
||
❓ {t('slotUsageLegendToggle')}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Slot Usage Legend - explains the ✦ symbol and green indicators */}
|
||
{showSlotUsageLegend && (
|
||
<div className="linkml-viewer-page__slot-usage-legend">
|
||
<div className="linkml-viewer-page__slot-usage-legend-header">
|
||
<h4>{t('slotUsageLegendTitle')}</h4>
|
||
<button
|
||
className="linkml-viewer-page__slot-usage-legend-close"
|
||
onClick={() => setShowSlotUsageLegend(false)}
|
||
title="Close"
|
||
>×</button>
|
||
</div>
|
||
<div className="linkml-viewer-page__slot-usage-legend-content">
|
||
<div className="linkml-viewer-page__slot-usage-legend-item">
|
||
<span className="linkml-viewer__badge linkml-viewer__badge--usage">slot_usage</span>
|
||
<span>{t('slotUsageLegendBadge')}</span>
|
||
</div>
|
||
<div className="linkml-viewer-page__slot-usage-legend-item">
|
||
<span className="linkml-viewer-page__slot-usage-legend-marker">✦</span>
|
||
<span>{t('slotUsageLegendMarker')}</span>
|
||
</div>
|
||
<div className="linkml-viewer-page__slot-usage-legend-item">
|
||
<span className="linkml-viewer-page__slot-usage-legend-border-demo"></span>
|
||
<span>{t('slotUsageLegendBorder')}</span>
|
||
</div>
|
||
<div className="linkml-viewer-page__slot-usage-legend-item linkml-viewer-page__slot-usage-legend-item--info">
|
||
<span>ℹ️</span>
|
||
<span>{t('slotUsageLegendInherited')}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Legend bar for 3D mode - shows all 19 custodian types with bidirectional hover sync */}
|
||
{use3DIndicator && (
|
||
<div className="linkml-viewer-page__legend-bar">
|
||
<CustodianTypeLegendBar
|
||
hoveredType={hoveredCustodianType}
|
||
onTypeHover={setHoveredCustodianType}
|
||
onTypeClick={handleCustodianTypeFilter}
|
||
highlightTypes={Array.from(custodianTypeFilter)}
|
||
size="small"
|
||
/>
|
||
{custodianTypeFilter.size > 0 && (
|
||
<button
|
||
className="linkml-viewer-page__filter-clear"
|
||
onClick={() => setCustodianTypeFilter(new Set())}
|
||
title={language === 'nl' ? 'Filter wissen' : 'Clear filter'}
|
||
>
|
||
✕ {language === 'nl' ? 'Filter wissen' : 'Clear filter'} ({Array.from(custodianTypeFilter).join(', ')})
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<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;
|