glam/frontend/src/pages/LinkMLViewerPage.tsx
2025-12-23 17:26:29 +01:00

1804 lines
69 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* LinkMLViewerPage.tsx - LinkML Schema Viewer Page
*
* Displays LinkML schema files with:
* - Sidebar listing schemas by category (main, classes, enums, slots)
* - Visual display of selected schema showing classes, slots, and enums
* - Raw YAML view toggle
* - Schema metadata and documentation
* - URL parameter support for deep linking to specific classes (?class=ClassName)
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
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 { 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 { 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)));
};
// Bilingual text content
const TEXT = {
sidebarTitle: { nl: 'LinkML-schema\'s', en: 'LinkML Schemas' },
pageTitle: { nl: 'LinkML-schemaviewer', en: 'LinkML Schema Viewer' },
visualView: { nl: 'Visuele weergave', en: 'Visual View' },
rawYaml: { nl: 'Ruwe YAML', en: 'Raw YAML' },
loading: { nl: 'Schema laden...', en: 'Loading schema...' },
noSchemasFound: { nl: 'Geen schema\'s gevonden. Zorg dat het manifest is gegenereerd.', en: 'No schemas found. Make sure the manifest is generated.' },
failedToInit: { nl: 'Initialiseren van schemalijst mislukt', en: 'Failed to initialize schema list' },
failedToLoad: { nl: 'Schema laden mislukt:', en: 'Failed to load schema:' },
unnamedSchema: { nl: 'Naamloos schema', en: 'Unnamed Schema' },
prefixes: { nl: 'Prefixen', en: 'Prefixes' },
classes: { nl: 'Klassen', en: 'Classes' },
slots: { nl: 'Slots', en: 'Slots' },
enumerations: { nl: 'Enumeraties', en: 'Enumerations' },
imports: { nl: 'Imports', en: 'Imports' },
abstract: { nl: 'abstract', en: 'abstract' },
required: { nl: 'verplicht', en: 'required' },
multivalued: { nl: 'meervoudig', en: 'multivalued' },
uri: { nl: 'URI:', en: 'URI:' },
id: { nl: 'ID:', en: 'ID:' },
version: { nl: 'Versie:', en: 'Version:' },
range: { nl: 'Bereik:', en: 'Range:' },
pattern: { nl: 'Patroon:', en: 'Pattern:' },
slotsLabel: { nl: 'Slots:', en: 'Slots:' },
exactMappings: { nl: 'Exacte mappings:', en: 'Exact Mappings:' },
closeMappings: { nl: 'Vergelijkbare mappings:', en: 'Close Mappings:' },
permissibleValues: { nl: 'Toegestane waarden:', en: 'Permissible Values:' },
meaning: { nl: 'betekenis:', en: 'meaning:' },
searchPlaceholder: { nl: 'Zoeken in waarden...', en: 'Search values...' },
showAll: { nl: 'Alles tonen', en: 'Show all' },
showLess: { nl: 'Minder tonen', en: 'Show less' },
showing: { nl: 'Toont', en: 'Showing' },
of: { nl: 'van', en: 'of' },
noResults: { nl: 'Geen resultaten gevonden', en: 'No results found' },
searchSchemas: { nl: 'Zoeken in schema\'s...', en: 'Search schemas...' },
mainSchema: { nl: 'Hoofdschema', en: 'Main Schema' },
noMatchingSchemas: { nl: 'Geen overeenkomende schema\'s', en: 'No matching schemas' },
copyToClipboard: { nl: 'Kopieer naar klembord', en: 'Copy to clipboard' },
copied: { nl: 'Gekopieerd!', en: 'Copied!' },
use3DPolygon: { nl: '3D-polygoon', en: '3D Polygon' },
use2DBadge: { nl: '2D-badge', en: '2D Badge' },
};
// Dynamically discover schema files from the modules directory
interface SchemaCategory {
name: string;
displayName: string;
files: SchemaFile[];
}
const LinkMLViewerPage: React.FC = () => {
const { language } = useLanguage();
const t = (key: keyof typeof TEXT) => TEXT[key][language];
const [searchParams, setSearchParams] = useSearchParams();
const [categories, setCategories] = useState<SchemaCategory[]>([]);
const [selectedSchema, setSelectedSchema] = useState<SchemaFile | null>(null);
const [schema, setSchema] = useState<LinkMLSchema | null>(null);
const [rawYaml, setRawYaml] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'visual' | 'raw'>('visual');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['classes', 'enums', 'slots']));
const [highlightedClass, setHighlightedClass] = useState<string | null>(null);
const highlightedRef = useRef<HTMLDivElement>(null);
// State for expandable enum ranges in slots
const [expandedEnumRanges, setExpandedEnumRanges] = useState<Set<string>>(new Set());
const [loadedEnums, setLoadedEnums] = useState<Record<string, LinkMLEnum | null>>({});
const [enumSearchFilters, setEnumSearchFilters] = useState<Record<string, string>>({});
const [enumShowAll, setEnumShowAll] = useState<Record<string, boolean>>({});
// State for pre-loaded custodian types (loaded async from schema annotations)
// Maps element name -> custodian type codes
const [classCustodianTypes, setClassCustodianTypes] = useState<Record<string, CustodianTypeCode[]>>({});
const [slotCustodianTypes, setSlotCustodianTypes] = useState<Record<string, CustodianTypeCode[]>>({});
const [enumCustodianTypes, setEnumCustodianTypes] = useState<Record<string, CustodianTypeCode[]>>({});
const [_custodianTypesLoaded, setCustodianTypesLoaded] = useState(false);
// State for sidebar search and category filters
const [sidebarSearch, setSidebarSearch] = useState<string>('');
const [categoryFilters, setCategoryFilters] = useState<Record<string, boolean>>({
main: true,
class: true,
enum: true,
slot: true,
});
// State for copy to clipboard feedback
const [copyFeedback, setCopyFeedback] = useState(false);
// State for 3D polygon indicator toggle
// Persisted in localStorage - defaults to false (2D SVG) if not set
const [use3DIndicator, setUse3DIndicator] = useState(() => {
try {
const saved = localStorage.getItem('linkml-viewer-use-3d');
return saved === 'true';
} catch {
return false;
}
});
// Persist 3D mode preference to localStorage
useEffect(() => {
try {
localStorage.setItem('linkml-viewer-use-3d', use3DIndicator ? 'true' : 'false');
} catch {
// Ignore localStorage errors (e.g., private browsing)
}
}, [use3DIndicator]);
// State for bidirectional hover sync between 3D polyhedrons and legend bar
const [hoveredCustodianType, setHoveredCustodianType] = useState<CustodianTypeCode | null>(null);
// State for filtering schema elements by custodian type (click on polyhedron face or legend)
// Multi-select: Set of selected type codes (empty = no filter)
// Initialize from URL params (e.g., ?custodian=G,L,A)
const [custodianTypeFilter, setCustodianTypeFilter] = useState<Set<CustodianTypeCode>>(() => {
const param = searchParams.get('custodian');
if (!param) return new Set();
const codes = param.split(',').filter((c): c is CustodianTypeCode =>
CUSTODIAN_TYPE_CODES.includes(c as CustodianTypeCode)
);
return new Set(codes);
});
// Sync custodian filter to URL params
useEffect(() => {
const currentParam = searchParams.get('custodian');
const filterCodes = Array.from(custodianTypeFilter).sort().join(',');
// Only update if different to avoid loops
if (custodianTypeFilter.size === 0 && currentParam) {
// Remove param if filter is empty
searchParams.delete('custodian');
setSearchParams(searchParams, { replace: true });
} else if (custodianTypeFilter.size > 0 && currentParam !== filterCodes) {
searchParams.set('custodian', filterCodes);
setSearchParams(searchParams, { replace: true });
}
}, [custodianTypeFilter, searchParams, setSearchParams]);
// Ref for main content (used by navigation-synced collapsible header)
const mainContentRef = useRef<HTMLElement>(null);
// Schema loading progress tracking
const { progress: schemaProgress, isLoading: isSchemaServiceLoading } = 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;
});
}, []);
// Handle URL parameters for deep linking
const handleUrlParams = useCallback((cats: SchemaCategory[]) => {
const classParam = searchParams.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']));
}
}
}, [searchParams]);
// Initialize schema file list from manifest
useEffect(() => {
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 first for deep linking
handleUrlParams(cats);
// 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]);
}
} catch (err) {
setError(t('failedToInit'));
console.error(err);
} finally {
setIsLoading(false);
}
};
initializeSchemas();
}, [handleUrlParams, searchParams]);
// 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
useEffect(() => {
if (!schema) {
setCustodianTypesLoaded(false);
return;
}
const loadCustodianTypes = async () => {
const classes = extractClasses(schema);
const slots = extractSlots(schema);
const enums = extractEnums(schema);
// 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]);
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}
onFaceClick={handleCustodianTypeFilter}
className="linkml-viewer__custodian-indicator"
/>
) : (
!isUniversal && (
<CustodianTypeBadge
types={custodianTypes}
size="small"
className="linkml-viewer__custodian-badge"
/>
)
)}
</h4>
{cls.class_uri && (
<div className="linkml-viewer__uri">
<span className="linkml-viewer__label">{t('uri')}</span>
<code>{cls.class_uri}</code>
</div>
)}
{cls.description && (
<div className="linkml-viewer__description linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(cls.description)}</ReactMarkdown>
</div>
)}
{cls.slots && cls.slots.length > 0 && (
<div className="linkml-viewer__slots-list">
<span className="linkml-viewer__label">{t('slotsLabel')}</span>
<div className="linkml-viewer__tag-list">
{cls.slots.map(slot => (
<span key={slot} className="linkml-viewer__tag">{slot}</span>
))}
</div>
</div>
)}
{cls.exact_mappings && cls.exact_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label">{t('exactMappings')}</span>
<div className="linkml-viewer__tag-list">
{cls.exact_mappings.map(mapping => (
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--mapping">{mapping}</span>
))}
</div>
</div>
)}
{cls.close_mappings && cls.close_mappings.length > 0 && (
<div className="linkml-viewer__mappings">
<span className="linkml-viewer__label">{t('closeMappings')}</span>
<div className="linkml-viewer__tag-list">
{cls.close_mappings.map(mapping => (
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--close">{mapping}</span>
))}
</div>
</div>
)}
</div>
);
};
const renderSlotDetails = (slot: LinkMLSlot) => {
const rangeIsEnum = slot.range && isEnumRange(slot.range);
const enumKey = slot.range ? `${slot.name}:${slot.range}` : '';
const isExpanded = expandedEnumRanges.has(enumKey);
// Use pre-loaded types from schema annotations, fall back to sync function if not yet loaded
const custodianTypes = slotCustodianTypes[slot.name] || getCustodianTypesForSlot(slot.name);
const isUniversal = isUniversalElement(custodianTypes);
// Check if this slot matches the current custodian type filter (multi-select)
const matchesFilter = custodianTypeFilter.size === 0 || custodianTypes.some(t => custodianTypeFilter.has(t));
return (
<div key={slot.name} className={`linkml-viewer__item ${!matchesFilter ? 'linkml-viewer__item--filtered-out' : ''}`}>
<h4 className="linkml-viewer__item-name">
{slot.name}
{slot.required && <span className="linkml-viewer__badge linkml-viewer__badge--required">{t('required')}</span>}
{slot.multivalued && <span className="linkml-viewer__badge linkml-viewer__badge--multi">{t('multivalued')}</span>}
{/* Show 3D polygon for ALL elements (including universal) in 3D mode for testing */}
{use3DIndicator ? (
<CustodianTypeIndicator3D
types={custodianTypes}
size={32}
animate={true}
showTooltip={true}
hoveredType={hoveredCustodianType}
onTypeHover={setHoveredCustodianType}
onFaceClick={handleCustodianTypeFilter}
className="linkml-viewer__custodian-indicator"
/>
) : (
!isUniversal && (
<CustodianTypeBadge
types={custodianTypes}
size="small"
className="linkml-viewer__custodian-badge"
/>
)
)}
</h4>
{slot.slot_uri && (
<div className="linkml-viewer__uri">
<span className="linkml-viewer__label">{t('uri')}</span>
<code>{slot.slot_uri}</code>
</div>
)}
{slot.range && (
<div className="linkml-viewer__range">
<span className="linkml-viewer__label">{t('range')}</span>
{rangeIsEnum ? (
<button
className={`linkml-viewer__range-enum-toggle ${isExpanded ? 'linkml-viewer__range-enum-toggle--expanded' : ''}`}
onClick={() => toggleEnumRange(slot.name, slot.range!)}
>
<span className="linkml-viewer__range-enum-icon">{isExpanded ? '▼' : '▶'}</span>
<code>{slot.range}</code>
<span className="linkml-viewer__badge linkml-viewer__badge--enum">enum</span>
</button>
) : (
<code>{slot.range}</code>
)}
</div>
)}
{/* Expandable enum values */}
{rangeIsEnum && isExpanded && slot.range && renderEnumValues(slot.name, slot.range)}
{slot.description && (
<div className="linkml-viewer__description linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(slot.description)}</ReactMarkdown>
</div>
)}
{slot.pattern && (
<div className="linkml-viewer__pattern">
<span className="linkml-viewer__label">{t('pattern')}</span>
<code>{slot.pattern}</code>
</div>
)}
</div>
);
};
const renderEnumDetails = (enumDef: LinkMLEnum) => {
const enumName = enumDef.name;
const allValues = enumDef.permissible_values ? Object.entries(enumDef.permissible_values) : [];
const searchFilter = enumSearchFilters[enumName] || '';
const showAll = enumShowAll[enumName] || false;
const displayCount = 20;
const custodianTypes = enumCustodianTypes[enumDef.name] || getCustodianTypesForEnum(enumDef.name);
const isUniversal = isUniversalElement(custodianTypes);
// Filter values based on search
const filteredValues = searchFilter
? allValues.filter(([value, details]) => {
const searchLower = searchFilter.toLowerCase();
return (
value.toLowerCase().includes(searchLower) ||
(details.description?.toLowerCase().includes(searchLower)) ||
(details.meaning?.toLowerCase().includes(searchLower))
);
})
: allValues;
// Determine how many to show
const valuesToShow = showAll || searchFilter ? filteredValues : filteredValues.slice(0, displayCount);
// Check if this enum matches the current custodian type filter (multi-select)
const matchesFilter = custodianTypeFilter.size === 0 || custodianTypes.some(t => custodianTypeFilter.has(t));
return (
<div key={enumDef.name} className={`linkml-viewer__item ${!matchesFilter ? 'linkml-viewer__item--filtered-out' : ''}`}>
<h4 className="linkml-viewer__item-name">
{enumDef.name}
{/* Show 3D polygon for ALL elements (including universal) in 3D mode for testing */}
{use3DIndicator ? (
<CustodianTypeIndicator3D
types={custodianTypes}
size={32}
animate={true}
showTooltip={true}
hoveredType={hoveredCustodianType}
onTypeHover={setHoveredCustodianType}
onFaceClick={handleCustodianTypeFilter}
className="linkml-viewer__custodian-indicator"
/>
) : (
!isUniversal && (
<CustodianTypeBadge
types={custodianTypes}
size="small"
className="linkml-viewer__custodian-badge"
/>
)
)}
</h4>
{enumDef.description && (
<div className="linkml-viewer__description linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(enumDef.description)}</ReactMarkdown>
</div>
)}
{allValues.length > 0 && (
<div className="linkml-viewer__enum-values">
{/* Search input */}
<div className="linkml-viewer__range-enum-search">
<input
type="text"
placeholder={t('searchPlaceholder')}
value={searchFilter}
onChange={(e) => setEnumSearchFilters(prev => ({ ...prev, [enumName]: e.target.value }))}
className="linkml-viewer__range-enum-search-input"
/>
{searchFilter && (
<button
className="linkml-viewer__range-enum-search-clear"
onClick={() => setEnumSearchFilters(prev => ({ ...prev, [enumName]: '' }))}
title="Clear search"
>
×
</button>
)}
</div>
{/* Count display */}
<span className="linkml-viewer__range-enum-count" style={{ display: 'block', marginBottom: '0.5rem' }}>
{searchFilter ? (
<>
{t('showing')} {filteredValues.length} {t('of')} {allValues.length}
</>
) : showAll ? (
<>
{allValues.length} {t('permissibleValues').replace(':', '')}
</>
) : (
<>
{t('showing')} {Math.min(displayCount, allValues.length)} {t('of')} {allValues.length}
</>
)}
</span>
<div className="linkml-viewer__value-list">
{valuesToShow.length > 0 ? (
valuesToShow.map(([value, details]) => (
<div key={value} className="linkml-viewer__value-item">
<code className="linkml-viewer__value-name">{value}</code>
{details.description && (
<div className="linkml-viewer__value-desc linkml-viewer__markdown linkml-viewer__markdown--compact">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(details.description)}</ReactMarkdown>
</div>
)}
{details.meaning && (
<span className="linkml-viewer__value-meaning">
<span className="linkml-viewer__label">{t('meaning')}</span> {details.meaning}
</span>
)}
</div>
))
) : (
<div className="linkml-viewer__range-enum-no-results">
{t('noResults')}
</div>
)}
</div>
{/* Show all / Show less button */}
{!searchFilter && allValues.length > displayCount && (
<button
className="linkml-viewer__range-enum-toggle-all"
onClick={() => setEnumShowAll(prev => ({ ...prev, [enumName]: !showAll }))}
>
{showAll ? (
<> {t('showLess')}</>
) : (
<> {t('showAll')} ({allValues.length - displayCount} {language === 'nl' ? 'meer' : 'more'})</>
)}
</button>
)}
</div>
)}
</div>
);
};
const renderVisualView = () => {
if (!schema) return null;
const classes = extractClasses(schema);
const slots = extractSlots(schema);
const enums = extractEnums(schema);
// Count matching items when filter is active (for display purposes)
const matchingClassCount = custodianTypeFilter.size > 0
? classes.filter(cls => {
const types = classCustodianTypes[cls.name] || getCustodianTypesForClass(cls.name);
return types.some(t => custodianTypeFilter.has(t));
}).length
: classes.length;
const matchingSlotCount = custodianTypeFilter.size > 0
? slots.filter(slot => {
const types = slotCustodianTypes[slot.name] || getCustodianTypesForSlot(slot.name);
return types.some(t => custodianTypeFilter.has(t));
}).length
: slots.length;
const matchingEnumCount = custodianTypeFilter.size > 0
? enums.filter(enumDef => {
const types = enumCustodianTypes[enumDef.name] || getCustodianTypesForEnum(enumDef.name);
return types.some(t => custodianTypeFilter.has(t));
}).length
: enums.length;
return (
<div className="linkml-viewer__visual">
{/* Schema Metadata */}
<div className="linkml-viewer__metadata">
<h2 className="linkml-viewer__schema-name">{formatDisplayName(schema.name || schema.title || '') || t('unnamedSchema')}</h2>
{schema.description && (
<div className="linkml-viewer__schema-desc linkml-viewer__markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(schema.description)}</ReactMarkdown>
</div>
)}
{schema.id && (
<div className="linkml-viewer__schema-id">
<span className="linkml-viewer__label">{t('id')}</span>
<code>{schema.id}</code>
</div>
)}
{schema.version && (
<div className="linkml-viewer__schema-version">
<span className="linkml-viewer__label">{t('version')}</span>
<code>{schema.version}</code>
</div>
)}
</div>
{/* Prefixes */}
{schema.prefixes && Object.keys(schema.prefixes).length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('prefixes')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('prefixes') ? '▼' : '▶'}
</span>
{t('prefixes')} ({Object.keys(schema.prefixes).length})
</button>
{expandedSections.has('prefixes') && (
<div className="linkml-viewer__prefix-list">
{Object.entries(schema.prefixes).map(([prefix, uri]) => (
<div key={prefix} className="linkml-viewer__prefix-item">
<code className="linkml-viewer__prefix-name">{prefix}:</code>
<span className="linkml-viewer__prefix-uri">{uri}</span>
</div>
))}
</div>
)}
</div>
)}
{/* Classes */}
{classes.length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('classes')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('classes') ? '▼' : '▶'}
</span>
{t('classes')} ({custodianTypeFilter.size > 0 ? `${matchingClassCount}/${classes.length}` : classes.length})
</button>
{expandedSections.has('classes') && (
<div className="linkml-viewer__section-content">
{classes.map(renderClassDetails)}
</div>
)}
</div>
)}
{/* Slots */}
{slots.length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('slots')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('slots') ? '▼' : '▶'}
</span>
{t('slots')} ({custodianTypeFilter.size > 0 ? `${matchingSlotCount}/${slots.length}` : slots.length})
</button>
{expandedSections.has('slots') && (
<div className="linkml-viewer__section-content">
{slots.map(renderSlotDetails)}
</div>
)}
</div>
)}
{/* Enums */}
{enums.length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('enums')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('enums') ? '▼' : '▶'}
</span>
{t('enumerations')} ({custodianTypeFilter.size > 0 ? `${matchingEnumCount}/${enums.length}` : enums.length})
</button>
{expandedSections.has('enums') && (
<div className="linkml-viewer__section-content">
{enums.map(renderEnumDetails)}
</div>
)}
</div>
)}
{/* Imports */}
{schema.imports && schema.imports.length > 0 && (
<div className="linkml-viewer__section">
<button
className="linkml-viewer__section-header"
onClick={() => toggleSection('imports')}
>
<span className="linkml-viewer__section-icon">
{expandedSections.has('imports') ? '▼' : '▶'}
</span>
{t('imports')} ({schema.imports.length})
</button>
{expandedSections.has('imports') && (
<div className="linkml-viewer__import-list">
{schema.imports.map(imp => (
<div key={imp} className="linkml-viewer__import-item">
<code>{imp}</code>
</div>
))}
</div>
)}
</div>
)}
</div>
);
};
const renderRawView = () => {
if (!rawYaml) return null;
const handleCopyYaml = async () => {
try {
await navigator.clipboard.writeText(rawYaml);
setCopyFeedback(true);
setTimeout(() => setCopyFeedback(false), 2000);
} catch (err) {
console.error('Failed to copy YAML:', err);
}
};
return (
<div className="linkml-viewer__raw">
<div className="linkml-viewer__raw-header">
<button
className={`linkml-viewer__copy-btn ${copyFeedback ? 'linkml-viewer__copy-btn--copied' : ''}`}
onClick={handleCopyYaml}
title={copyFeedback ? t('copied') : t('copyToClipboard')}
>
{copyFeedback ? '✓' : '⧉'} {copyFeedback ? t('copied') : t('copyToClipboard')}
</button>
</div>
<pre className="linkml-viewer__yaml">{rawYaml}</pre>
</div>
);
};
return (
<div className="linkml-viewer-page">
{/* Left Sidebar - Schema Files */}
<aside className="linkml-viewer-page__sidebar">
<h2 className="linkml-viewer-page__sidebar-title">{t('sidebarTitle')}</h2>
{/* Sidebar Search */}
<div className="linkml-viewer-page__sidebar-search">
<input
type="text"
placeholder={t('searchSchemas')}
value={sidebarSearch}
onChange={(e) => setSidebarSearch(e.target.value)}
className="linkml-viewer-page__sidebar-search-input"
/>
{sidebarSearch && (
<button
className="linkml-viewer-page__sidebar-search-clear"
onClick={() => setSidebarSearch('')}
title="Clear search"
>
×
</button>
)}
</div>
{/* Category Filters */}
<div className="linkml-viewer-page__sidebar-filters">
<label className="linkml-viewer-page__filter-checkbox">
<input
type="checkbox"
checked={categoryFilters.main}
onChange={(e) => setCategoryFilters(prev => ({ ...prev, main: e.target.checked }))}
/>
<span className="linkml-viewer-page__filter-label linkml-viewer-page__filter-label--main">
{t('mainSchema')}
</span>
</label>
<label className="linkml-viewer-page__filter-checkbox">
<input
type="checkbox"
checked={categoryFilters.class}
onChange={(e) => setCategoryFilters(prev => ({ ...prev, class: e.target.checked }))}
/>
<span className="linkml-viewer-page__filter-label linkml-viewer-page__filter-label--class">
{t('classes')}
</span>
</label>
<label className="linkml-viewer-page__filter-checkbox">
<input
type="checkbox"
checked={categoryFilters.enum}
onChange={(e) => setCategoryFilters(prev => ({ ...prev, enum: e.target.checked }))}
/>
<span className="linkml-viewer-page__filter-label linkml-viewer-page__filter-label--enum">
{t('enumerations')}
</span>
</label>
<label className="linkml-viewer-page__filter-checkbox">
<input
type="checkbox"
checked={categoryFilters.slot}
onChange={(e) => setCategoryFilters(prev => ({ ...prev, slot: e.target.checked }))}
/>
<span className="linkml-viewer-page__filter-label linkml-viewer-page__filter-label--slot">
{t('slots')}
</span>
</label>
</div>
{categories
.filter(category => categoryFilters[category.name] !== false)
.map(category => {
// Filter files based on search
const filteredFiles = sidebarSearch
? category.files.filter(file =>
file.name.toLowerCase().includes(sidebarSearch.toLowerCase())
)
: category.files;
// Don't render empty categories
if (filteredFiles.length === 0) return null;
return (
<div key={category.name} className="linkml-viewer-page__category">
<h3 className="linkml-viewer-page__category-title">
{category.displayName}
<span className="linkml-viewer-page__category-count">
({filteredFiles.length}{sidebarSearch ? `/${category.files.length}` : ''})
</span>
</h3>
<div className="linkml-viewer-page__file-list">
{filteredFiles.map(file => (
<button
key={file.path}
className={`linkml-viewer-page__file-item ${
selectedSchema?.path === file.path ? 'linkml-viewer-page__file-item--active' : ''
}`}
onClick={() => setSelectedSchema(file)}
>
{file.name}
</button>
))}
</div>
</div>
);
})}
{/* No results message */}
{sidebarSearch && categories.every(category =>
!categoryFilters[category.name] ||
category.files.filter(file =>
file.name.toLowerCase().includes(sidebarSearch.toLowerCase())
).length === 0
) && (
<div className="linkml-viewer-page__no-results">
{t('noMatchingSchemas')}
</div>
)}
</aside>
{/* Main Content */}
<main className="linkml-viewer-page__main" ref={mainContentRef}>
{/* Collapsible header with title - hides when navigation collapses */}
<header className="linkml-viewer-page__header">
<h1 className="linkml-viewer-page__title">
{selectedSchema ? formatDisplayName(selectedSchema.name) : t('pageTitle')}
</h1>
</header>
{/* Sticky subheader with tabs - always visible */}
<div className="linkml-viewer-page__subheader">
<div className="linkml-viewer-page__tabs">
<button
className={`linkml-viewer-page__tab ${viewMode === 'visual' ? 'linkml-viewer-page__tab--active' : ''}`}
onClick={() => setViewMode('visual')}
>
{t('visualView')}
</button>
<button
className={`linkml-viewer-page__tab ${viewMode === 'raw' ? 'linkml-viewer-page__tab--active' : ''}`}
onClick={() => setViewMode('raw')}
>
{t('rawYaml')}
</button>
<span className="linkml-viewer-page__tab-separator">|</span>
<button
className={`linkml-viewer-page__tab linkml-viewer-page__tab--indicator ${use3DIndicator ? 'linkml-viewer-page__tab--active' : ''}`}
onClick={() => setUse3DIndicator(!use3DIndicator)}
title={use3DIndicator ? t('use2DBadge') : t('use3DPolygon')}
>
{use3DIndicator ? '🔷 3D' : '🏷️ 2D'}
</button>
</div>
{/* Legend bar for 3D mode - shows all 19 custodian types with bidirectional hover sync */}
{use3DIndicator && (
<div className="linkml-viewer-page__legend-bar">
<CustodianTypeLegendBar
hoveredType={hoveredCustodianType}
onTypeHover={setHoveredCustodianType}
onTypeClick={handleCustodianTypeFilter}
highlightTypes={Array.from(custodianTypeFilter)}
size="small"
/>
{custodianTypeFilter.size > 0 && (
<button
className="linkml-viewer-page__filter-clear"
onClick={() => setCustodianTypeFilter(new Set())}
title={language === 'nl' ? 'Filter wissen' : 'Clear filter'}
>
{language === 'nl' ? 'Filter wissen' : 'Clear filter'} ({Array.from(custodianTypeFilter).join(', ')})
</button>
)}
</div>
)}
</div>
<div className="linkml-viewer-page__content">
{isLoading || isSchemaServiceLoading ? (
<LoadingScreen
message={schemaProgress?.message || t('loading')}
progress={schemaProgress?.percent}
size="medium"
fullscreen={false}
/>
) : error ? (
<div className="linkml-viewer-page__error">{error}</div>
) : viewMode === 'visual' ? (
renderVisualView()
) : (
renderRawView()
)}
</div>
</main>
</div>
);
};
export default LinkMLViewerPage;