glam/frontend/src/pages/Visualize.tsx

2049 lines
84 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.

/**
* Unified Visualize Page - Schema Visualization
* Supports both RDF (Turtle, N-Triples) and UML (Mermaid, PlantUML, GraphViz) formats
*/
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useDatabase } from '@/hooks/useDatabase';
import { useRdfParser } from '@/hooks/useRdfParser';
import { useGraphData } from '@/hooks/useGraphData';
import { ForceDirectedGraph, type RdfLayoutType } from '@/components/visualizations/ForceDirectedGraph';
import { RdfAdvancedLayout, type RdfAdvancedLayoutType } from '@/components/rdf/RdfLayouts';
import { GraphControls } from '@/components/visualizations/GraphControls';
import { RdfNodeDetailsPanel } from '@/components/rdf/RdfNodeDetailsPanel';
import { UMLVisualization } from '@/components/uml/UMLVisualization';
import { parseUMLDiagramWithDetails } from '@/components/uml/UMLParser';
import { useLanguage } from '../contexts/LanguageContext';
import { useFullscreen } from '../hooks/useCollapsibleHeader';
import type { GraphNode, GraphLink } from '@/types/rdf';
import type { UMLDiagram, DagreDirection, DagreRanker, LayoutType, ElkAlgorithm } from '@/components/uml/UMLVisualization';
import {
Menu, X, Download, Image, FileCode, Code, ChevronDown, Upload, FileText, RefreshCw, Database, Maximize, Minimize, HelpCircle, LayoutGrid
} from 'lucide-react';
import './Visualize.css';
import '../styles/collapsible.css';
// Mobile detection and swipe gesture constants
const SWIPE_THRESHOLD = 50; // Minimum distance for a swipe
const EDGE_ZONE = 30; // Pixels from left edge to start swipe
/**
* Map UML layout types to RDF layout types
* UML has more options (dagre variants, elk), we map them to simpler RDF equivalents
*/
function mapToRdfLayout(layoutType: LayoutType, dagreDirection?: DagreDirection): RdfLayoutType {
switch (layoutType) {
case 'force':
return 'force';
case 'dagre':
// Map dagre directions to hierarchical layouts
return dagreDirection === 'LR' || dagreDirection === 'RL'
? 'hierarchical-lr'
: 'hierarchical-tb';
case 'elk':
// ELK is similar to dagre, use hierarchical TB
return 'hierarchical-tb';
case 'circular':
return 'circular';
case 'radial':
return 'radial';
case 'clustered':
// Clustered layout - use circular as closest approximation for RDF
return 'circular';
default:
return 'force';
}
}
// Check if a layout type is an advanced RDF layout
function isAdvancedRdfLayout(layout: string): layout is RdfAdvancedLayoutType {
return ['chord', 'radial-tree', 'sankey', 'edge-bundling', 'pack', 'tree', 'sunburst'].includes(layout);
}
type UmlDensityMode = 'full' | 'streamlined' | 'module';
type UmlModuleOption = { id: string; label: string; count: number };
function humanizeModuleName(moduleId: string): string {
return moduleId
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function buildStreamlinedUmlDiagram(diagram: UMLDiagram): UMLDiagram {
if (!diagram || !diagram.nodes || !diagram.links) {
return diagram;
}
const nodeCount = diagram.nodes.length;
if (nodeCount <= 300) {
return diagram;
}
const degree = new Map<string, number>();
diagram.nodes.forEach((node) => degree.set(node.id, 0));
diagram.links.forEach((link) => {
if (!degree.has(link.source) || !degree.has(link.target)) return;
degree.set(link.source, (degree.get(link.source) || 0) + 1);
degree.set(link.target, (degree.get(link.target) || 0) + 1);
});
const threshold = nodeCount > 1200 ? 3 : nodeCount > 600 ? 2 : 1;
const keepIds = new Set<string>();
degree.forEach((d, id) => {
if (d >= threshold) keepIds.add(id);
});
const coreAnchors = ['Custodian', 'CustodianType', 'Organization', 'Person'];
coreAnchors.forEach((id) => {
if (degree.has(id)) keepIds.add(id);
});
const nodes = diagram.nodes.filter((node) => keepIds.has(node.id));
const links = diagram.links.filter((link) => keepIds.has(link.source) && keepIds.has(link.target));
if (nodes.length < 50) {
return diagram;
}
return {
...diagram,
nodes,
links,
};
}
function buildModuleFocusedUmlDiagram(diagram: UMLDiagram, moduleId: string): UMLDiagram {
if (!diagram || !diagram.nodes || !diagram.links) {
return diagram;
}
if (!moduleId || moduleId === '__all__') {
return diagram;
}
const primaryNodes = diagram.nodes.filter((node) => (node.module || 'other') === moduleId);
if (primaryNodes.length === 0) {
return diagram;
}
const keepIds = new Set<string>(primaryNodes.map((n) => n.id));
// Include one-hop neighbors for context around the selected module.
diagram.links.forEach((link) => {
if (keepIds.has(link.source)) keepIds.add(link.target);
if (keepIds.has(link.target)) keepIds.add(link.source);
});
let nodes = diagram.nodes.filter((node) => keepIds.has(node.id));
let links = diagram.links.filter((link) => keepIds.has(link.source) && keepIds.has(link.target));
// If the result is still too small, keep only direct module internals.
if (nodes.length < 20) {
const primaryIds = new Set(primaryNodes.map((n) => n.id));
nodes = primaryNodes;
links = diagram.links.filter((link) => primaryIds.has(link.source) && primaryIds.has(link.target));
}
return {
...diagram,
nodes,
links,
};
}
// Bilingual text object for translations
const TEXT = {
// Sidebar
schemaVisualization: { nl: 'Schemavisualisatie', en: 'Schema Visualization' },
heritageOntology: { nl: 'Erfgoedbewaarders-ontologie', en: 'Heritage Custodian Ontology' },
openSidebar: { nl: 'Zijbalk openen', en: 'Open sidebar' },
closeSidebar: { nl: 'Zijbalk sluiten', en: 'Close sidebar' },
// Load section
loadData: { nl: 'Data laden', en: 'Load Data' },
uploadFile: { nl: 'Bestand uploaden', en: 'Upload File' },
fileHint: { nl: 'RDF: .ttl, .nt, .jsonld | UML: .mmd, .puml, .dot', en: 'RDF: .ttl, .nt, .jsonld | UML: .mmd, .puml, .dot' },
orPasteDirectly: { nl: 'of plak direct', en: 'or paste directly' },
pastePlaceholder: { nl: 'Plak Turtle, N-Triples, Mermaid, PlantUML of GraphViz code...', en: 'Paste Turtle, N-Triples, Mermaid, PlantUML, or GraphViz code...' },
visualize: { nl: 'Visualiseren', en: 'Visualize' },
// Generate section
generateVisualizations: { nl: 'Visualisaties genereren', en: 'Generate Visualizations' },
generate: { nl: 'Genereren', en: 'Generate' },
umlDiagram: { nl: 'UML Diagram', en: 'UML Diagram' },
generateFromLinkML: { nl: 'Genereren vanuit LinkML-schema (Erfgoedbewaarders-ontologie)', en: 'Generate from LinkML schema (Heritage Custodian Ontology)' },
generating: { nl: 'Bezig met genereren...', en: 'Generating...' },
generateUml: { nl: 'Genereer UML', en: 'Generate UML' },
rdfOverview: { nl: 'RDF Overzicht', en: 'RDF Overview' },
generateRdfDesc: { nl: 'Genereer RDF grafiek van alle erfgoedbewaarders', en: 'Generate RDF graph of all heritage custodians' },
comingSoon: { nl: 'Binnenkort beschikbaar', en: 'Coming Soon' },
comingSoonHint: { nl: 'Beschikbaar na conversie van verrijkte records naar instanties', en: 'Available after converting enriched entries to instances' },
refreshUml: { nl: 'UML Vernieuwen', en: 'Refresh UML' },
refreshRdf: { nl: 'RDF Vernieuwen', en: 'Refresh RDF' },
refreshHint: { nl: 'Haal de nieuwste versie op', en: 'Fetch the latest version' },
umlDensity: { nl: 'UML Weergavemodus', en: 'UML Display Mode' },
umlDensityDesc: { nl: 'Kies tussen volledig en gestroomlijnd overzicht', en: 'Choose between full and streamlined overview' },
umlModeFull: { nl: 'Volledig', en: 'Full' },
umlModeFullHint: { nl: 'Toon alle klassen en relaties', en: 'Show all classes and relationships' },
umlModeStreamlined: { nl: 'Gestroomlijnd', en: 'Streamlined' },
umlModeStreamlinedHint: { nl: 'Toon de belangrijkste, meest verbonden klassen', en: 'Show key, high-connectivity classes' },
umlModeModule: { nl: 'Module Focus', en: 'Module Focus' },
umlModeModuleHint: { nl: 'Toon een domein met context', en: 'Show one domain with context' },
umlModuleSelect: { nl: 'Module', en: 'Module' },
umlModuleAll: { nl: 'Alle modules', en: 'All modules' },
umlShowingClasses: { nl: 'klassen zichtbaar', en: 'classes visible' },
// View switcher
viewUml: { nl: 'UML Weergave', en: 'UML View' },
viewRdf: { nl: 'RDF Weergave', en: 'RDF View' },
switchToUml: { nl: 'Schakel naar UML diagram', en: 'Switch to UML diagram' },
switchToRdf: { nl: 'Schakel naar RDF grafiek', en: 'Switch to RDF graph' },
// Current file section
loaded: { nl: 'Geladen', en: 'Loaded' },
rdfGraph: { nl: 'RDF Grafiek', en: 'RDF Graph' },
// Storage info
storageInfo: { nl: 'Opslag Info', en: 'Storage Info' },
used: { nl: 'Gebruikt', en: 'Used' },
available: { nl: 'Beschikbaar', en: 'Available' },
usage: { nl: 'Gebruik', en: 'Usage' },
// RDF Query Limit
queryLimit: { nl: 'Query Limiet', en: 'Query Limit' },
queryLimitDesc: { nl: 'Aantal custodians om te laden', en: 'Number of custodians to load' },
showingOf: { nl: 'van', en: 'of' },
custodiansAvailable: { nl: 'beschikbaar', en: 'available' },
performanceWarning: { nl: '⚠️ Meer dan 1000 kan traag zijn', en: '⚠️ More than 1000 may be slow' },
// RDF Query Mode
queryMode: { nl: 'Query Modus', en: 'Query Mode' },
queryModeDesc: { nl: 'Selecteer hoe data wordt opgehaald', en: 'Select how data is fetched' },
queryModeDetailed: { nl: 'Gedetailleerd (standaard)', en: 'Detailed (default)' },
queryModeDetailedHint: { nl: 'Specifieke eigenschappen, sneller', en: 'Specific properties, faster' },
queryModeGeneric: { nl: 'Generiek (alle relaties)', en: 'Generic (all relations)' },
queryModeGenericHint: { nl: 'Alle triples, meer connectiviteit', en: 'All triples, more connectivity' },
queryModeCustom: { nl: 'Aangepaste query...', en: 'Custom query...' },
queryModeCustomHint: { nl: 'Open Query Builder', en: 'Open Query Builder' },
// Selected node
selectedNode: { nl: 'Geselecteerd knooppunt', en: 'Selected Node' },
id: { nl: 'ID', en: 'ID' },
type: { nl: 'Type', en: 'Type' },
label: { nl: 'Label', en: 'Label' },
properties: { nl: 'Eigenschappen', en: 'Properties' },
noProperties: { nl: 'Geen eigenschappen beschikbaar', en: 'No properties available' },
clearSelection: { nl: 'Selectie wissen', en: 'Clear Selection' },
// Hover info
hoverInfo: { nl: 'Hover Info', en: 'Hover Info' },
// Toolbar
fit: { nl: 'Passend', en: 'Fit' },
fitToScreen: { nl: 'Passend op scherm', en: 'Fit to screen' },
zoomIn: { nl: 'Inzoomen', en: 'Zoom in' },
zoomOut: { nl: 'Uitzoomen', en: 'Zoom out' },
resetView: { nl: 'Weergave resetten', en: 'Reset view' },
layout: { nl: 'Layout', en: 'Layout' },
export: { nl: 'Exporteren', en: 'Export' },
// Layout options
forceLayout: { nl: 'Force-layout', en: 'Force Layout' },
forceDesc: { nl: 'Op fysica gebaseerd, organisch', en: 'Physics-based, organic' },
gridLayoutOptions: { nl: 'Grid-layoutopties', en: 'Grid Layout Options' },
hierarchicalTB: { nl: 'Hiërarchisch (Boven→Onder)', en: 'Hierarchical (Top→Bottom)' },
hierarchicalTBDesc: { nl: 'Klassieke UML stijl', en: 'Classic UML style' },
hierarchicalLR: { nl: 'Hiërarchisch (Links→Rechts)', en: 'Hierarchical (Left→Right)' },
hierarchicalLRDesc: { nl: 'Stroomdiagram stijl', en: 'Flowchart style' },
adaptiveTightTree: { nl: 'Adaptief (Compact)', en: 'Adaptive (Tight Tree)' },
adaptiveTightTreeDesc: { nl: 'Compacte ordening', en: 'Compact arrangement' },
adaptiveLongestPath: { nl: 'Adaptief (Langste Pad)', en: 'Adaptive (Longest Path)' },
adaptiveLongestPathDesc: { nl: 'Diepe hiërarchieën', en: 'Deep hierarchies' },
// New layout options
advancedLayoutOptions: { nl: 'Geavanceerde layouts', en: 'Advanced Layouts' },
elkLayered: { nl: 'ELK gelaagd', en: 'ELK Layered' },
elkLayeredDesc: { nl: 'Eclipse Layout Kernel orthogonaal', en: 'Eclipse Layout Kernel - orthogonal edges' },
elkTree: { nl: 'ELK boom', en: 'ELK Tree' },
elkTreeDesc: { nl: 'Boomstructuurlayout', en: 'Tree structure layout' },
circular: { nl: 'Circulair', en: 'Circular' },
circularDesc: { nl: 'Knooppunten in een cirkel', en: 'Nodes arranged in a circle' },
radial: { nl: 'Radiaal', en: 'Radial' },
radialDesc: { nl: 'Boom vanuit centrum', en: 'Tree radiating from center' },
clustered: { nl: 'Gegroepeerd', en: 'Clustered' },
clusteredDesc: { nl: 'Groepeer klassen per module', en: 'Group classes by module' },
layoutHelp: { nl: 'Meer over layouts', en: 'Learn about layouts' },
// RDF-specific advanced layouts
rdfAdvancedLayouts: { nl: 'RDF Geavanceerde Layouts', en: 'RDF Advanced Layouts' },
chordDiagram: { nl: 'Chord Diagram', en: 'Chord Diagram' },
chordDiagramDesc: { nl: 'Relaties tussen knooppunttypes', en: 'Relationships between node types' },
radialTreeLayout: { nl: 'Radiale Boom', en: 'Radial Tree' },
radialTreeLayoutDesc: { nl: 'Hiërarchie vanuit centraal knooppunt', en: 'Hierarchy from central node' },
edgeBundling: { nl: 'Edge Bundling', en: 'Edge Bundling' },
edgeBundlingDesc: { nl: 'Gebundelde verbindingspatronen', en: 'Bundled connection patterns' },
packLayout: { nl: 'Cirkel Packing', en: 'Circle Packing' },
packLayoutDesc: { nl: 'Geneste cirkels per type', en: 'Nested circles by type' },
sunburstLayout: { nl: 'Sunburst', en: 'Sunburst' },
sunburstLayoutDesc: { nl: 'Hiërarchische partitie', en: 'Hierarchical partition view' },
// Export options
exportAsPng: { nl: 'Exporteer als PNG', en: 'Export as PNG' },
exportAsSvg: { nl: 'Exporteer als SVG', en: 'Export as SVG' },
downloadSource: { nl: 'Broncode downloaden', en: 'Download Source' },
// Loading/Error states
loading: { nl: 'Laden...', en: 'Loading...' },
error: { nl: 'Fout', en: 'Error' },
// Welcome message
readyToVisualize: { nl: 'Klaar om te visualiseren', en: 'Ready to Visualize' },
welcomeDesc: { nl: 'Upload een schemabestand, plak code, of selecteer uit de beschikbare schema\'s om te beginnen.', en: 'Upload a schema file, paste code, or select from Available Schemas to get started.' },
supportedFormats: { nl: 'Ondersteunde formaten:', en: 'Supported Formats:' },
features: { nl: 'Mogelijkheden:', en: 'Features:' },
featureInteractive: { nl: 'Interactieve grafiekvisualisatie', en: 'Interactive graph visualization' },
featureLayouts: { nl: 'Meerdere layout-opties (force, hiërarchisch, adaptief)', en: 'Multiple layout options (force, hierarchical, adaptive)' },
featureFilter: { nl: 'Filteren op knooppunttypes en predicaten', en: 'Filter by node types and predicates' },
featureExport: { nl: 'Exporteer als PNG, SVG of broncode', en: 'Export as PNG, SVG, or source' },
featureDrag: { nl: 'Versleep knooppunten om te herordenen', en: 'Drag nodes to reorganize' },
featureClick: { nl: 'Klik op knooppunten voor details', en: 'Click nodes to see details' },
// View source
viewSourceCode: { nl: 'Broncode bekijken', en: 'View Source Code' },
};
// File type definitions
type SchemaFormat = 'turtle' | 'n-triples' | 'jsonld' | 'mermaid' | 'erdiagram' | 'plantuml' | 'graphviz';
// Detect format from file extension
function detectFormat(filename: string): { format: SchemaFormat; category: 'rdf' | 'uml' } {
const ext = filename.toLowerCase().split('.').pop() || '';
switch (ext) {
case 'ttl':
return { format: 'turtle', category: 'rdf' };
case 'nt':
return { format: 'n-triples', category: 'rdf' };
case 'jsonld':
case 'json':
return { format: 'jsonld', category: 'rdf' };
case 'mmd':
return { format: 'mermaid', category: 'uml' };
case 'puml':
return { format: 'plantuml', category: 'uml' };
case 'dot':
case 'gv':
return { format: 'graphviz', category: 'uml' };
default:
// Default to turtle for unknown
return { format: 'turtle', category: 'rdf' };
}
}
// Detect format from content (for paste input)
function detectFormatFromContent(content: string): { format: SchemaFormat; category: 'rdf' | 'uml' } {
const trimmed = content.trim();
// UML detection
if (trimmed.startsWith('classDiagram') || trimmed.startsWith('erDiagram') ||
trimmed.startsWith('graph ') || trimmed.startsWith('flowchart')) {
return { format: 'mermaid', category: 'uml' };
}
if (trimmed.startsWith('@startuml') || trimmed.includes('@enduml')) {
return { format: 'plantuml', category: 'uml' };
}
if (trimmed.startsWith('digraph') || trimmed.startsWith('graph') || trimmed.startsWith('strict')) {
return { format: 'graphviz', category: 'uml' };
}
// RDF detection
if (trimmed.startsWith('@prefix') || trimmed.startsWith('@base') ||
trimmed.includes('^^xsd:') || trimmed.includes('@en')) {
return { format: 'turtle', category: 'rdf' };
}
if (trimmed.startsWith('<') && trimmed.includes('> <') && trimmed.includes('> .')) {
return { format: 'n-triples', category: 'rdf' };
}
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return { format: 'jsonld', category: 'rdf' };
}
// Default to turtle
return { format: 'turtle', category: 'rdf' };
}
// Session storage key for preserving state across navigation
const VISUALIZE_STATE_KEY = 'visualize-page-state';
interface PersistedState {
fileName: string;
currentCategory: 'rdf' | 'uml' | null;
umlFileContent: string;
umlDiagram: UMLDiagram | null;
// Cached state for both views
hasUmlCache: boolean;
hasRdfCache: boolean;
rdfNodeCount: number;
}
function saveStateToSession(state: PersistedState) {
try {
sessionStorage.setItem(VISUALIZE_STATE_KEY, JSON.stringify(state));
} catch (e) {
console.warn('Failed to save visualize state to sessionStorage:', e);
}
}
function loadStateFromSession(): PersistedState | null {
try {
const stored = sessionStorage.getItem(VISUALIZE_STATE_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to load visualize state from sessionStorage:', e);
}
return null;
}
export function Visualize() {
// Language hook
const { language } = useLanguage();
const t = (key: keyof typeof TEXT) => TEXT[key][language];
// Load persisted state on mount
const persistedState = useRef<PersistedState | null>(null);
if (persistedState.current === null) {
persistedState.current = loadStateFromSession();
}
// File and format state
const [fileName, setFileName] = useState<string>(persistedState.current?.fileName || '');
const [currentCategory, setCurrentCategory] = useState<'rdf' | 'uml' | null>(persistedState.current?.currentCategory || null);
const [loadSectionExpanded, setLoadSectionExpanded] = useState<boolean>(false);
const [customInput, setCustomInput] = useState<string>('');
const [sidebarOpen, setSidebarOpen] = useState<boolean>(true);
const [isHeaderCollapsed, setIsHeaderCollapsed] = useState<boolean>(false);
// Mobile detection and swipe gesture state
const [isMobile, setIsMobile] = useState<boolean>(false);
const touchStartX = useRef<number>(0);
const touchStartY = useRef<number>(0);
const touchStartedInEdge = useRef<boolean>(false);
const sidebarRef = useRef<HTMLElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
// Fullscreen support
const mainContentRef = useRef<HTMLDivElement>(null);
const { isFullscreen, toggleFullscreen } = useFullscreen(mainContentRef);
// UML-specific state
const [umlDiagram, setUmlDiagram] = useState<UMLDiagram | null>(persistedState.current?.umlDiagram || null);
const [umlFileContent, setUmlFileContent] = useState<string>(persistedState.current?.umlFileContent || '');
// Layout state
const [layoutType, setLayoutType] = useState<LayoutType>(() => {
const saved = localStorage.getItem('visualize-layout-type');
return (saved === 'dagre' || saved === 'force' || saved === 'elk' || saved === 'circular' || saved === 'radial') ? saved : 'force';
});
const [dagreDirection, setDagreDirection] = useState<DagreDirection>(() => {
const saved = localStorage.getItem('visualize-dagre-direction');
return (saved === 'TB' || saved === 'BT' || saved === 'LR' || saved === 'RL') ? saved : 'TB';
});
const [dagreRanker, setDagreRanker] = useState<DagreRanker>(() => {
const saved = localStorage.getItem('visualize-dagre-ranker');
return (saved === 'network-simplex' || saved === 'tight-tree' || saved === 'longest-path') ? saved : 'network-simplex';
});
const [elkAlgorithm, setElkAlgorithm] = useState<ElkAlgorithm>(() => {
const saved = localStorage.getItem('visualize-elk-algorithm');
return (saved === 'layered' || saved === 'mrtree' || saved === 'force' || saved === 'stress') ? saved : 'layered';
});
// RDF-specific advanced layout state
const [rdfAdvancedLayout, setRdfAdvancedLayout] = useState<RdfAdvancedLayoutType | null>(() => {
const saved = localStorage.getItem('visualize-rdf-advanced-layout');
if (saved && isAdvancedRdfLayout(saved)) {
return saved;
}
return null;
});
const [umlDensityMode, setUmlDensityMode] = useState<UmlDensityMode>(() => {
const saved = localStorage.getItem('visualize-uml-density-mode');
return (saved === 'streamlined' || saved === 'module') ? saved : 'full';
});
const [selectedUmlModule, setSelectedUmlModule] = useState<string>(() => {
return localStorage.getItem('visualize-uml-module') || '__all__';
});
// Dropdown state
const [exportDropdownOpen, setExportDropdownOpen] = useState<boolean>(false);
const [layoutDropdownOpen, setLayoutDropdownOpen] = useState<boolean>(false);
const exportDropdownRef = useRef<HTMLDivElement>(null);
const layoutDropdownRef = useRef<HTMLDivElement>(null);
// Cache tracking - both views can be loaded simultaneously
const [hasUmlCache, setHasUmlCache] = useState<boolean>(persistedState.current?.hasUmlCache || false);
const [hasRdfCache, setHasRdfCache] = useState<boolean>(persistedState.current?.hasRdfCache || false);
const [rdfNodeCount, setRdfNodeCount] = useState<number>(persistedState.current?.rdfNodeCount || 0);
// RDF query limit - default to 500 to prevent browser overload
// Oxigraph contains 27,000+ custodians; rendering all at once crashes the browser
const [rdfLimit, setRdfLimit] = useState<number>(() => {
const saved = localStorage.getItem('visualize-rdf-limit');
return saved ? parseInt(saved, 10) : 500;
});
const [totalCustodiansAvailable, setTotalCustodiansAvailable] = useState<number | null>(null);
// Track the limit that was used for the current cached data
const cachedRdfLimitRef = useRef<number | null>(null);
// Prevent duplicate RDF fetch requests (React StrictMode protection)
const rdfFetchInProgressRef = useRef<boolean>(false);
// RDF Query Mode - allows switching between detailed (limited properties) and generic (all triples)
type RdfQueryMode = 'detailed' | 'generic' | 'custom';
const [rdfQueryMode, setRdfQueryMode] = useState<RdfQueryMode>(() => {
const saved = localStorage.getItem('visualize-rdf-query-mode');
return (saved === 'detailed' || saved === 'generic' || saved === 'custom') ? saved : 'detailed';
});
// Track the mode used for cached data (to show Apply button when changed)
const cachedRdfQueryModeRef = useRef<RdfQueryMode | null>(null);
// Hooks
const { isInitialized, isLoading: dbLoading, storageInfo } = useDatabase();
const { parse, isLoading: parserLoading, error: parserError } = useRdfParser();
const {
nodes,
links,
filteredNodes,
filteredLinks,
nodeTypes,
predicates,
filters,
setFilters,
resetFilters,
selectedNode,
setSelectedNode,
loadGraphData,
stats,
} = useGraphData();
// Combined loading state
const [umlLoading, setUmlLoading] = useState(false);
const [umlError, setUmlError] = useState<string | null>(null);
const isLoading = dbLoading || parserLoading || umlLoading;
// Generator state
const [generatingUml, setGeneratingUml] = useState(false);
const [_generatingRdf, setGeneratingRdf] = useState(false);
// Clear current visualization
const clearVisualization = useCallback(() => {
setUmlDiagram(null);
setUmlFileContent('');
setUmlError(null);
// Reset RDF graph data would require loadGraphData with empty result
}, []);
// Load UML content
const loadUmlContent = useCallback(
(content: string, name: string) => {
clearVisualization();
setCurrentCategory('uml');
setUmlLoading(true);
setUmlError(null);
// Auto-select dagre (Grid) layout for UML diagrams
setLayoutType('dagre');
localStorage.setItem('visualize-layout-type', 'dagre');
try {
const result = parseUMLDiagramWithDetails(content);
if (result.error) {
throw new Error(result.error);
}
if (!result.diagram) {
throw new Error('Failed to parse diagram. No diagram data returned.');
}
// Log warnings if any
if (result.warnings.length > 0) {
console.warn('[Visualize] Parse warnings:', result.warnings);
}
setUmlFileContent(content);
setUmlDiagram({
...result.diagram,
title: name
});
} catch (err) {
console.error('Error parsing UML:', err);
setUmlError(err instanceof Error ? err.message : 'Unknown error occurred');
setUmlDiagram(null);
} finally {
setUmlLoading(false);
}
},
[clearVisualization]
);
// Generate UML from LinkML schema (fetches pre-generated file)
const handleGenerateUml = useCallback(async () => {
setGeneratingUml(true);
setUmlError(null);
try {
// Fetch the pre-generated Mermaid file from public folder
const response = await fetch('/data/heritage_custodian_ontology.mmd');
if (!response.ok) {
throw new Error(`Failed to load UML diagram: ${response.statusText}`);
}
const mermaidContent = await response.text();
if (mermaidContent) {
setFileName('Heritage Custodian Ontology (Generated)');
loadUmlContent(mermaidContent, 'Heritage Custodian Ontology');
setHasUmlCache(true);
setCurrentCategory('uml');
} else {
throw new Error('No Mermaid diagram content found');
}
} catch (err) {
console.error('Error loading UML:', err);
setUmlError(err instanceof Error ? err.message : 'Failed to load UML diagram');
} finally {
setGeneratingUml(false);
}
}, [loadUmlContent]);
// Generate RDF overview - Fetch and visualize all heritage custodian RDF data
const handleGenerateRdf = useCallback(async () => {
// Prevent duplicate concurrent fetches (React StrictMode protection)
if (rdfFetchInProgressRef.current) {
console.log('[RDF] Fetch already in progress, skipping duplicate request');
return;
}
rdfFetchInProgressRef.current = true;
setGeneratingRdf(true);
// Don't clear UML visualization - we want to keep both cached
setCurrentCategory('rdf');
setFileName('NDE Heritage Custodians');
// Auto-select force layout for RDF graphs
setLayoutType('force');
localStorage.setItem('visualize-layout-type', 'force');
try {
// Determine SPARQL endpoint URL
// In production (bronhouder.nl), use relative URL which Caddy proxies to Oxigraph
// In development (localhost), use direct Oxigraph URL
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const sparqlEndpoint = isLocalhost
? (import.meta.env.VITE_SPARQL_ENDPOINT || 'http://localhost:7878/query')
: '/query'; // Always use relative URL in production
// First, get total count of custodians available (for UI display)
const countQuery = `
PREFIX nde: <https://nde.nl/ontology/hc/class/>
SELECT (COUNT(?s) as ?count) WHERE { ?s a nde:Custodian }
`;
try {
const countResponse = await fetch(sparqlEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/sparql-query',
'Accept': 'application/sparql-results+json',
},
body: countQuery,
});
if (countResponse.ok) {
const countResult = await countResponse.json();
const total = parseInt(countResult.results?.bindings?.[0]?.count?.value || '0', 10);
setTotalCustodiansAvailable(total);
console.log(`Total custodians in Oxigraph: ${total}`);
}
} catch (countErr) {
console.warn('Could not fetch custodian count:', countErr);
}
// SPARQL query - conditional based on rdfQueryMode
// - detailed: Specific properties only (faster, less connectivity)
// - generic: All triples for custodians (slower, full connectivity)
let constructQuery: string;
if (rdfQueryMode === 'generic') {
// Generic query: Returns ALL triples for custodians
// This provides maximum connectivity in the graph but may be slower
console.log('Using GENERIC query mode - fetching all triples');
constructQuery = `
PREFIX nde: <https://nde.nl/ontology/hc/class/>
CONSTRUCT { ?s ?p ?o }
WHERE {
{
# All triples where custodian is subject
?s a nde:Custodian .
?s ?p ?o .
}
UNION
{
# All triples where custodian is object (incoming links)
?o a nde:Custodian .
?s ?p ?o .
}
}
LIMIT ${rdfLimit * 10}
`;
// Note: Higher limit because generic query returns more triples per custodian
} else {
// Detailed query: Specific properties only (default)
// Namespaces match the Python sync scripts:
// - nde: <https://nde.nl/ontology/hc/class/> for Custodian type
// - hc: <https://w3id.org/heritage/custodian/> for ghcid, isil predicates
// - hp: <https://w3id.org/heritage/person/> for person URIs
console.log('Using DETAILED query mode - specific properties');
constructQuery = `
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
PREFIX schema: <http://schema.org/>
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
PREFIX owl: <http://www.w3.org/2002/07/owl#>
PREFIX org: <http://www.w3.org/ns/org#>
PREFIX geo: <http://www.w3.org/2003/01/geo/wgs84_pos#>
PREFIX cidoc: <http://www.cidoc-crm.org/cidoc-crm/>
PREFIX nde: <https://nde.nl/ontology/hc/class/>
PREFIX hc: <https://w3id.org/heritage/custodian/>
PREFIX hp: <https://w3id.org/heritage/person/>
CONSTRUCT {
# Custodians - core data
?custodian a nde:Custodian ;
rdfs:label ?label ;
skos:prefLabel ?prefLabel ;
schema:name ?name ;
hc:ghcid ?ghcid ;
hc:isil ?isil ;
schema:url ?website ;
foaf:homepage ?homepage ;
owl:sameAs ?wikidata .
# Location (schema:location, not crm:P53)
?custodian schema:location ?location .
?location geo:lat ?lat ;
geo:long ?lon .
# Persons linked to custodians
?person a schema:Person ;
rdfs:label ?personLabel ;
schema:name ?personName ;
schema:worksFor ?custodian ;
org:memberOf ?custodian ;
schema:jobTitle ?jobTitle .
}
WHERE {
# Get all custodians (nde:Custodian is the primary type)
?custodian a nde:Custodian .
OPTIONAL { ?custodian rdfs:label ?label }
OPTIONAL { ?custodian skos:prefLabel ?prefLabel }
OPTIONAL { ?custodian schema:name ?name }
OPTIONAL { ?custodian hc:ghcid ?ghcid }
OPTIONAL { ?custodian hc:isil ?isil }
OPTIONAL { ?custodian schema:url ?website }
OPTIONAL { ?custodian foaf:homepage ?homepage }
OPTIONAL {
?custodian owl:sameAs ?wikidata .
FILTER(STRSTARTS(STR(?wikidata), "http://www.wikidata.org/"))
}
# Location using schema:location (as generated by oxigraph_sync.py)
OPTIONAL {
?custodian schema:location ?location .
OPTIONAL { ?location geo:lat ?lat }
OPTIONAL { ?location geo:long ?lon }
}
# Persons linked to custodians via schema:worksFor
OPTIONAL {
?person a schema:Person ;
schema:worksFor ?custodian .
OPTIONAL { ?person rdfs:label ?personLabel }
OPTIONAL { ?person schema:name ?personName }
OPTIONAL { ?person schema:jobTitle ?jobTitle }
OPTIONAL { ?person org:memberOf ?custodian }
}
}
LIMIT ${rdfLimit}
`;
}
let rdfData = '';
let dataFormat: 'application/n-triples' | 'text/turtle' = 'application/n-triples';
try {
// Request N-Triples format - simpler to parse than Turtle
// Oxigraph's abbreviated Turtle syntax is hard to parse correctly
console.log('Fetching from SPARQL endpoint:', sparqlEndpoint);
const response = await fetch(sparqlEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/sparql-query',
'Accept': 'application/n-triples',
},
body: constructQuery,
});
if (response.ok) {
rdfData = await response.text();
console.log(`SPARQL returned ${rdfData.length} bytes, ${rdfData.split('\n').length} lines`);
} else {
console.warn('SPARQL response not OK:', response.status, response.statusText);
}
} catch (fetchErr) {
console.warn('SPARQL endpoint not available:', fetchErr);
}
// Fallback: try to load from pre-built static file (Turtle format)
if (!rdfData || rdfData.trim().length === 0) {
console.log('Attempting to load from static file...');
const staticResponse = await fetch('/data/nde_heritage_custodians.ttl');
if (staticResponse.ok) {
rdfData = await staticResponse.text();
dataFormat = 'text/turtle'; // Static file is Turtle, not N-Triples
console.log('Loaded from static file (Turtle format)');
}
}
if (!rdfData || rdfData.trim().length === 0) {
throw new Error(
'No RDF data available.\n\n' +
'Options:\n' +
'1. Run Oxigraph locally with the NDE data\n' +
'2. Place nde_heritage_custodians.ttl in public/data/'
);
}
// Parse the RDF data (format depends on source)
console.log(`Parsing ${rdfData.length} bytes as ${dataFormat}`);
const result = await parse(rdfData, dataFormat);
console.log(`Parsed: ${result.nodes.length} nodes, ${result.links.length} links`);
loadGraphData(result);
// Show ALL node types by default - no random filtering
// Users can filter down using the sidebar controls if needed
if (result.nodes.length > 0) {
// Extract unique node types from the result
const uniqueTypes = new Set(result.nodes.map(n => n.type));
const typesArray = Array.from(uniqueTypes);
// Set filter to show ALL types by default
setFilters({ nodeTypes: new Set(typesArray) });
console.log(`RDF loaded: showing all ${result.nodes.length} nodes across ${typesArray.length} types: ${typesArray.join(', ')}`);
}
// Update cache state and track the limit and mode used
setHasRdfCache(true);
setRdfNodeCount(result.nodes.length);
cachedRdfLimitRef.current = rdfLimit; // Remember which limit was used for this cache
cachedRdfQueryModeRef.current = rdfQueryMode; // Remember which mode was used
// Update filename with count and mode indicator
const modeLabel = rdfQueryMode === 'generic' ? 'all triples' : 'detailed';
setFileName(`NDE Heritage Custodians (${result.nodes.length} entities, ${modeLabel})`);
} catch (err) {
console.error('Error generating RDF overview:', err);
setUmlError(
err instanceof Error
? `Failed to load RDF data: ${err.message}`
: 'Failed to load RDF data'
);
} finally {
setGeneratingRdf(false);
rdfFetchInProgressRef.current = false; // Allow new fetches
}
}, [rdfLimit, rdfQueryMode, parse, loadGraphData]);
// Close dropdowns when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (exportDropdownRef.current && !exportDropdownRef.current.contains(event.target as Node)) {
setExportDropdownOpen(false);
}
if (layoutDropdownRef.current && !layoutDropdownRef.current.contains(event.target as Node)) {
setLayoutDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Persist state to sessionStorage whenever relevant state changes
useEffect(() => {
saveStateToSession({
fileName,
currentCategory,
umlFileContent,
umlDiagram,
hasUmlCache,
hasRdfCache,
rdfNodeCount,
});
}, [fileName, currentCategory, umlFileContent, umlDiagram, hasUmlCache, hasRdfCache, rdfNodeCount]);
// Mobile detection and sidebar state management
useEffect(() => {
const checkMobile = () => {
const mobile = window.innerWidth <= 900;
setIsMobile(mobile);
// On mobile, sidebar starts closed
if (mobile) {
setSidebarOpen(false);
}
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// Swipe gesture handling for mobile sidebar
useEffect(() => {
if (!isMobile) return;
const handleTouchStart = (e: TouchEvent) => {
const touch = e.touches[0];
touchStartX.current = touch.clientX;
touchStartY.current = touch.clientY;
// Check if touch started in the left edge zone (for opening)
// or if sidebar is open (for closing)
touchStartedInEdge.current = touch.clientX <= EDGE_ZONE || sidebarOpen;
};
const handleTouchMove = (e: TouchEvent) => {
if (!touchStartedInEdge.current) return;
const touch = e.touches[0];
const deltaX = touch.clientX - touchStartX.current;
const deltaY = touch.clientY - touchStartY.current;
// Only consider horizontal swipes (more horizontal than vertical)
if (Math.abs(deltaX) > Math.abs(deltaY)) {
// Prevent scroll during horizontal swipe
if (Math.abs(deltaX) > 10) {
e.preventDefault();
}
}
};
const handleTouchEnd = (e: TouchEvent) => {
if (!touchStartedInEdge.current) return;
const touch = e.changedTouches[0];
const deltaX = touch.clientX - touchStartX.current;
const deltaY = touch.clientY - touchStartY.current;
// Only consider horizontal swipes
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > SWIPE_THRESHOLD) {
if (deltaX > 0 && !sidebarOpen) {
// Swipe right - open sidebar
setSidebarOpen(true);
} else if (deltaX < 0 && sidebarOpen) {
// Swipe left - close sidebar
setSidebarOpen(false);
}
}
touchStartedInEdge.current = false;
};
document.addEventListener('touchstart', handleTouchStart, { passive: true });
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd, { passive: true });
return () => {
document.removeEventListener('touchstart', handleTouchStart);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}, [isMobile, sidebarOpen]);
// Load RDF content
const loadRdfContent = useCallback(
async (content: string, format: 'turtle' | 'n-triples' | 'jsonld') => {
clearVisualization();
setCurrentCategory('rdf');
const mimeType = format === 'turtle' ? 'text/turtle' :
format === 'n-triples' ? 'application/n-triples' :
'application/ld+json';
const result = await parse(content, mimeType);
loadGraphData(result);
},
[parse, loadGraphData, clearVisualization]
);
// Handle file upload (unified)
const handleFileUpload = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
console.log('[Visualize] File upload triggered:', file.name);
setFileName(file.name);
const { format, category } = detectFormat(file.name);
const reader = new FileReader();
reader.onload = async (e) => {
const content = e.target?.result as string;
console.log('[Visualize] File read complete, content length:', content.length);
try {
if (category === 'rdf') {
await loadRdfContent(content, format as 'turtle' | 'n-triples' | 'jsonld');
} else {
loadUmlContent(content, file.name);
}
} catch (err) {
console.error('Failed to parse file:', err);
}
};
reader.readAsText(file);
// Reset the input value to allow re-uploading the same file
event.target.value = '';
},
[loadRdfContent, loadUmlContent]
);
// Handle custom input (paste)
const handleLoadCustomInput = useCallback(async () => {
if (!customInput.trim()) return;
const { format, category } = detectFormatFromContent(customInput);
setFileName('Custom Input');
try {
if (category === 'rdf') {
await loadRdfContent(customInput, format as 'turtle' | 'n-triples' | 'jsonld');
} else {
loadUmlContent(customInput, 'Custom Diagram');
}
setLoadSectionExpanded(false);
} catch (err) {
console.error('Failed to parse custom input:', err);
}
}, [customInput, loadRdfContent, loadUmlContent]);
// Handle node click (RDF)
const handleNodeClick = useCallback(
(node: GraphNode) => {
setSelectedNode(node);
},
[setSelectedNode]
);
// Handle link click (RDF)
const handleLinkClick = useCallback(
(link: GraphLink) => {
const sourceNode = filteredNodes.find((n) => n.id === link.source);
if (sourceNode) {
setSelectedNode(sourceNode);
}
},
[filteredNodes, setSelectedNode]
);
// Hovered node state (RDF)
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
// Export handlers
const handleExportPNG = () => {
if (currentCategory === 'uml') {
const event = new CustomEvent('uml-export-png', {
detail: { filename: fileName.replace(/[^a-z0-9]/gi, '_').toLowerCase() || 'diagram' }
});
window.dispatchEvent(event);
}
// TODO: Add RDF graph PNG export
setExportDropdownOpen(false);
};
const handleExportSVG = () => {
if (currentCategory === 'uml') {
const event = new CustomEvent('uml-export-svg', {
detail: { filename: fileName.replace(/[^a-z0-9]/gi, '_').toLowerCase() || 'diagram' }
});
window.dispatchEvent(event);
}
// TODO: Add RDF graph SVG export
setExportDropdownOpen(false);
};
const handleDownloadSource = () => {
const content = currentCategory === 'uml' ? umlFileContent : '';
if (!content) return;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${fileName.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setExportDropdownOpen(false);
};
// Layout selection handler
const selectLayout = (type: LayoutType, direction?: DagreDirection, ranker?: DagreRanker, elkAlg?: ElkAlgorithm) => {
setLayoutType(type);
localStorage.setItem('visualize-layout-type', type);
// Clear RDF advanced layout when selecting a basic layout
setRdfAdvancedLayout(null);
localStorage.removeItem('visualize-rdf-advanced-layout');
if (direction) {
setDagreDirection(direction);
localStorage.setItem('visualize-dagre-direction', direction);
}
if (ranker) {
setDagreRanker(ranker);
localStorage.setItem('visualize-dagre-ranker', ranker);
}
if (elkAlg) {
setElkAlgorithm(elkAlg);
localStorage.setItem('visualize-elk-algorithm', elkAlg);
}
setLayoutDropdownOpen(false);
};
// RDF Advanced layout selection handler
const selectRdfAdvancedLayout = (layout: RdfAdvancedLayoutType) => {
setRdfAdvancedLayout(layout);
localStorage.setItem('visualize-rdf-advanced-layout', layout);
setLayoutDropdownOpen(false);
};
// View switching handlers - switch between cached UML and RDF views
const handleSwitchToUml = useCallback(() => {
if (!hasUmlCache) {
// No cache, generate it
handleGenerateUml();
} else {
// Switch to cached UML view
setCurrentCategory('uml');
setLayoutType('dagre');
localStorage.setItem('visualize-layout-type', 'dagre');
}
}, [hasUmlCache, handleGenerateUml]);
const handleSwitchToRdf = useCallback(() => {
// Check if we have cache AND it was fetched with the current limit setting
const cacheValid = hasRdfCache && cachedRdfLimitRef.current === rdfLimit;
if (!cacheValid) {
// No cache, or limit has changed - fetch fresh data
handleGenerateRdf();
} else {
// Switch to cached RDF view
setCurrentCategory('rdf');
setLayoutType('force');
localStorage.setItem('visualize-layout-type', 'force');
}
}, [hasRdfCache, rdfLimit, handleGenerateRdf]);
// Check if we have content to display
const hasRdfContent = filteredNodes.length > 0;
const hasUmlContent = umlDiagram !== null;
const hasContent = hasRdfContent || hasUmlContent;
const umlModuleOptions = useMemo<UmlModuleOption[]>(() => {
if (!umlDiagram) return [];
const counts = new Map<string, number>();
umlDiagram.nodes.forEach((node) => {
const moduleId = node.module || 'other';
counts.set(moduleId, (counts.get(moduleId) || 0) + 1);
});
const options: UmlModuleOption[] = [{ id: '__all__', label: t('umlModuleAll'), count: umlDiagram.nodes.length }];
const sorted = Array.from(counts.entries()).sort((a, b) => {
if (b[1] !== a[1]) return b[1] - a[1];
return a[0].localeCompare(b[0]);
});
sorted.forEach(([id, count]) => {
options.push({ id, label: humanizeModuleName(id), count });
});
return options;
}, [umlDiagram, t]);
useEffect(() => {
if (!umlModuleOptions.length) return;
if (umlModuleOptions.some((option) => option.id === selectedUmlModule)) return;
setSelectedUmlModule('__all__');
localStorage.setItem('visualize-uml-module', '__all__');
}, [umlModuleOptions, selectedUmlModule]);
const displayUmlDiagram = useMemo(() => {
if (!umlDiagram) return null;
if (umlDensityMode === 'streamlined') {
return buildStreamlinedUmlDiagram(umlDiagram);
}
if (umlDensityMode === 'module') {
return buildModuleFocusedUmlDiagram(umlDiagram, selectedUmlModule);
}
return umlDiagram;
}, [umlDiagram, umlDensityMode, selectedUmlModule]);
return (
<div className={`visualize-page ${isFullscreen ? 'fullscreen-active' : ''} ${isMobile ? 'is-mobile' : ''}`}>
{/* Mobile overlay when sidebar is open */}
{isMobile && sidebarOpen && (
<div
className="sidebar-overlay"
ref={overlayRef}
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
/>
)}
{/* Sidebar Collapse Toggle (when collapsed) */}
{!sidebarOpen && (
<button
className={`sidebar-toggle ${isMobile ? 'sidebar-toggle--mobile' : ''}`}
onClick={() => setSidebarOpen(true)}
aria-label={t('openSidebar')}
>
<Menu size={20} />
</button>
)}
{/* Sidebar */}
<aside
className={`sidebar ${!sidebarOpen ? 'collapsed' : ''} ${isMobile ? 'sidebar--mobile' : ''}`}
ref={sidebarRef}
>
{/* Header */}
<div className={`sidebar-header collapsible-header ${isHeaderCollapsed ? 'collapsible-header--collapsed' : ''}`}>
<div className="sidebar-header-content">
<h2>{t('schemaVisualization')}</h2>
<p className="sidebar-subtitle">{t('heritageOntology')}</p>
</div>
<button
className="sidebar-collapse-button"
onClick={() => setSidebarOpen(false)}
aria-label={t('closeSidebar')}
>
<X size={18} />
</button>
</div>
{/* Header Toggle Button */}
<button
className="header-toggle-bar"
onClick={() => setIsHeaderCollapsed(!isHeaderCollapsed)}
title={isHeaderCollapsed ? (language === 'nl' ? 'Toon koptekst' : 'Show header') : (language === 'nl' ? 'Verberg koptekst' : 'Hide header')}
>
{isHeaderCollapsed ? '▼' : '▲'} {isHeaderCollapsed ? (language === 'nl' ? 'Koptekst tonen' : 'Show header') : (language === 'nl' ? 'Koptekst verbergen' : 'Hide header')}
</button>
{/* Load Data Section */}
<div className="load-section">
<button
className="load-header"
onClick={() => setLoadSectionExpanded(!loadSectionExpanded)}
>
<Upload size={16} />
<span>{t('loadData')}</span>
<ChevronDown
size={16}
className={`load-chevron ${loadSectionExpanded ? 'expanded' : ''}`}
/>
</button>
{loadSectionExpanded && (
<div className="load-content">
<div className="load-file">
<label htmlFor="file-upload-input" className="load-file-label">
<FileText size={16} />
<span>{t('uploadFile')}</span>
</label>
<input
id="file-upload-input"
type="file"
accept=".ttl,.nt,.jsonld,.json,.mmd,.puml,.dot,.gv"
onChange={handleFileUpload}
className="load-file-input"
disabled={isLoading}
/>
<p className="load-hint">{t('fileHint')}</p>
</div>
<div className="load-divider">
<span>{t('orPasteDirectly')}</span>
</div>
<textarea
className="load-textarea"
placeholder={t('pastePlaceholder')}
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
rows={6}
/>
<button
className="load-button"
onClick={handleLoadCustomInput}
disabled={!customInput.trim() || isLoading}
>
{t('visualize')}
</button>
</div>
)}
</div>
{/* Generate Section */}
<div className="generate-section">
{/* View Switcher - Toggle between UML and RDF */}
<div className="view-switcher">
<button
className={`view-switch-button ${currentCategory === 'uml' ? 'view-switch-button--active' : ''}`}
onClick={handleSwitchToUml}
disabled={generatingUml}
title={t('switchToUml')}
>
<LayoutGrid size={16} />
<span>UML</span>
{hasUmlCache && <span className="cache-indicator"></span>}
</button>
<button
className={`view-switch-button ${currentCategory === 'rdf' ? 'view-switch-button--active' : ''}`}
onClick={handleSwitchToRdf}
disabled={_generatingRdf}
title={t('switchToRdf')}
>
<Database size={16} />
<span>RDF</span>
{hasRdfCache && <span className="cache-indicator"></span>}
{hasRdfCache && rdfNodeCount > 0 && <span className="node-count">{rdfNodeCount}</span>}
</button>
</div>
{/* Generate Button - simple button based on current view */}
<button
className="generate-button-simple"
onClick={currentCategory === 'uml' ? handleGenerateUml : handleGenerateRdf}
disabled={generatingUml || _generatingRdf}
title={currentCategory === 'uml' ? t('refreshUml') : t('refreshRdf')}
>
{(generatingUml || _generatingRdf) ? (
<>
<RefreshCw size={16} className="spinning" />
{t('generating')}
</>
) : (
<>
<RefreshCw size={16} />
{currentCategory === 'uml' ? t('refreshUml') : t('refreshRdf')}
</>
)}
</button>
</div>
{/* Current File */}
{fileName && (
<div className="current-file-section">
<h3>{t('loaded')}</h3>
<p className="current-file-name">{fileName}</p>
<span className="current-file-type">
{currentCategory === 'rdf' ? t('rdfGraph') : t('umlDiagram')}
</span>
</div>
)}
{/* Database Status (RDF only) */}
{isInitialized && storageInfo && currentCategory === 'rdf' && (
<div className="status-section">
<h3>{t('storageInfo')}</h3>
<p><strong>{t('used')}:</strong> {storageInfo.usageInMB}</p>
<p><strong>{t('available')}:</strong> {storageInfo.quotaInMB}</p>
<p><strong>{t('usage')}:</strong> {storageInfo.percentUsed}</p>
</div>
)}
{/* RDF Query Limit Control */}
{currentCategory === 'rdf' && (
<div className="query-limit-section">
<h3>{t('queryLimit')}</h3>
<p className="query-limit-desc">{t('queryLimitDesc')}</p>
<select
className="query-limit-select"
value={rdfLimit}
onChange={(e) => {
const newLimit = parseInt(e.target.value, 10);
setRdfLimit(newLimit);
localStorage.setItem('visualize-rdf-limit', String(newLimit));
// Invalidate cache so next view switch triggers refresh
// User will need to click "Refresh RDF" or switch away and back
}}
>
<option value="100">100</option>
<option value="250">250</option>
<option value="500">500</option>
<option value="1000">1,000</option>
<option value="2000">2,000</option>
<option value="5000">5,000</option>
<option value="10000">10,000</option>
<option value="50000">All (50,000+)</option>
</select>
{/* Show refresh prompt if limit changed from cached value */}
{hasRdfCache && cachedRdfLimitRef.current !== null && cachedRdfLimitRef.current !== rdfLimit && (
<button
className="query-limit-refresh-button"
onClick={handleGenerateRdf}
disabled={_generatingRdf}
>
<RefreshCw size={14} />
{language === 'nl' ? 'Toepassen' : 'Apply'}
</button>
)}
{totalCustodiansAvailable && (
<p className="query-limit-info">
{rdfNodeCount > 0 ? rdfNodeCount : rdfLimit} {t('showingOf')} {totalCustodiansAvailable.toLocaleString()} {t('custodiansAvailable')}
</p>
)}
{rdfLimit > 1000 && (
<p className="query-limit-warning">{t('performanceWarning')}</p>
)}
{/* Query Mode Selector */}
<div className="query-mode-subsection">
<h4>{t('queryMode')}</h4>
<p className="query-mode-desc">{t('queryModeDesc')}</p>
<div className="query-mode-options">
<label className={`query-mode-option ${rdfQueryMode === 'detailed' ? 'active' : ''}`}>
<input
type="radio"
name="queryMode"
value="detailed"
checked={rdfQueryMode === 'detailed'}
onChange={() => {
setRdfQueryMode('detailed');
localStorage.setItem('visualize-rdf-query-mode', 'detailed');
}}
/>
<div className="query-mode-content">
<span className="query-mode-label">{t('queryModeDetailed')}</span>
<span className="query-mode-hint">{t('queryModeDetailedHint')}</span>
</div>
</label>
<label className={`query-mode-option ${rdfQueryMode === 'generic' ? 'active' : ''}`}>
<input
type="radio"
name="queryMode"
value="generic"
checked={rdfQueryMode === 'generic'}
onChange={() => {
setRdfQueryMode('generic');
localStorage.setItem('visualize-rdf-query-mode', 'generic');
}}
/>
<div className="query-mode-content">
<span className="query-mode-label">{t('queryModeGeneric')}</span>
<span className="query-mode-hint">{t('queryModeGenericHint')}</span>
</div>
</label>
<label
className="query-mode-option query-mode-custom"
onClick={(e) => {
e.preventDefault();
window.open('/query-builder', '_blank');
}}
>
<div className="query-mode-content">
<span className="query-mode-label">{t('queryModeCustom')}</span>
<span className="query-mode-hint">{t('queryModeCustomHint')}</span>
</div>
<span className="query-mode-external-icon"></span>
</label>
</div>
{/* Show Apply button if mode changed from cached value */}
{hasRdfCache && cachedRdfQueryModeRef.current !== null && cachedRdfQueryModeRef.current !== rdfQueryMode && (
<button
className="query-mode-refresh-button"
onClick={handleGenerateRdf}
disabled={_generatingRdf}
>
<RefreshCw size={14} />
{language === 'nl' ? 'Toepassen' : 'Apply'}
</button>
)}
</div>
</div>
)}
{hasUmlContent && currentCategory === 'uml' && (
<div className="uml-density-section">
<h3>{t('umlDensity')}</h3>
<p className="uml-density-desc">{t('umlDensityDesc')}</p>
<div className="uml-density-options">
<button
className={`uml-density-option ${umlDensityMode === 'full' ? 'active' : ''}`}
onClick={() => {
setUmlDensityMode('full');
localStorage.setItem('visualize-uml-density-mode', 'full');
}}
>
<span className="uml-density-label">{t('umlModeFull')}</span>
<span className="uml-density-hint">{t('umlModeFullHint')}</span>
</button>
<button
className={`uml-density-option ${umlDensityMode === 'streamlined' ? 'active' : ''}`}
onClick={() => {
setUmlDensityMode('streamlined');
localStorage.setItem('visualize-uml-density-mode', 'streamlined');
}}
>
<span className="uml-density-label">{t('umlModeStreamlined')}</span>
<span className="uml-density-hint">{t('umlModeStreamlinedHint')}</span>
</button>
<button
className={`uml-density-option ${umlDensityMode === 'module' ? 'active' : ''}`}
onClick={() => {
setUmlDensityMode('module');
localStorage.setItem('visualize-uml-density-mode', 'module');
}}
>
<span className="uml-density-label">{t('umlModeModule')}</span>
<span className="uml-density-hint">{t('umlModeModuleHint')}</span>
</button>
</div>
{umlDensityMode === 'module' && umlModuleOptions.length > 0 && (
<div className="uml-module-picker">
<label htmlFor="uml-module-select" className="uml-module-label">{t('umlModuleSelect')}</label>
<select
id="uml-module-select"
className="uml-module-select"
value={selectedUmlModule}
onChange={(e) => {
const next = e.target.value;
setSelectedUmlModule(next);
localStorage.setItem('visualize-uml-module', next);
}}
>
{umlModuleOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label} ({option.count.toLocaleString()})
</option>
))}
</select>
</div>
)}
{displayUmlDiagram && umlDiagram && (
<p className="uml-density-info">
{displayUmlDiagram.nodes.length.toLocaleString()} / {umlDiagram.nodes.length.toLocaleString()} {t('umlShowingClasses')}
</p>
)}
</div>
)}
{/* Graph Controls (RDF only) */}
{hasRdfContent && currentCategory === 'rdf' && (
<GraphControls
nodeTypes={nodeTypes}
predicates={predicates}
allNodes={nodes}
filters={filters}
onFiltersChange={setFilters}
onReset={resetFilters}
onNodeSelect={setSelectedNode}
stats={stats}
language={language}
/>
)}
{/* Hovered Node Info (RDF) - Brief hint in sidebar */}
{hoveredNode && !selectedNode && currentCategory === 'rdf' && (
<div className="hover-info-section">
<h3>{t('hoverInfo')}</h3>
<p><strong>{t('type')}:</strong> {hoveredNode.type}</p>
{hoveredNode.label && (
<p><strong>{t('label')}:</strong> {hoveredNode.label}</p>
)}
{hoveredNode.properties && Object.keys(hoveredNode.properties).length > 0 && (
<p className="hover-properties-hint">
<strong>{t('properties')}:</strong> {Object.keys(hoveredNode.properties).length} {language === 'nl' ? 'velden' : 'fields'}
</p>
)}
</div>
)}
{/* Source Code View (UML) - in sidebar */}
{hasUmlContent && umlFileContent && !isLoading && currentCategory === 'uml' && (
<div className="source-section">
<details>
<summary>{t('viewSourceCode')}</summary>
<div className="source-code-container">
<pre><code>{umlFileContent}</code></pre>
</div>
</details>
</div>
)}
</aside>
{/* Main Visualization */}
<main className={`main-content ${isFullscreen ? 'main-content--fullscreen' : ''}`} ref={mainContentRef}>
{/* Toolbar (when content loaded) */}
{hasContent && !isLoading && (
<div className="toolbar">
<div className="toolbar-left">
{/* Zoom Controls */}
<button
className="toolbar-button"
title={t('fitToScreen')}
onClick={() => {
if (currentCategory === 'uml') {
window.dispatchEvent(new CustomEvent('uml-fit-to-screen'));
} else if (currentCategory === 'rdf') {
window.dispatchEvent(new CustomEvent('rdf-fit-to-screen'));
}
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="18" height="18">
<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>
{t('fit')}
</button>
<button
className="toolbar-button"
title={t('zoomIn')}
onClick={() => {
if (currentCategory === 'uml') {
window.dispatchEvent(new CustomEvent('uml-zoom-in'));
} else if (currentCategory === 'rdf') {
window.dispatchEvent(new CustomEvent('rdf-zoom-in'));
}
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="18" height="18">
<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="toolbar-button"
title={t('zoomOut')}
onClick={() => {
if (currentCategory === 'uml') {
window.dispatchEvent(new CustomEvent('uml-zoom-out'));
} else if (currentCategory === 'rdf') {
window.dispatchEvent(new CustomEvent('rdf-zoom-out'));
}
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="18" height="18">
<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="toolbar-button"
title={t('resetView')}
onClick={() => {
if (currentCategory === 'uml') {
window.dispatchEvent(new CustomEvent('uml-reset-view'));
} else if (currentCategory === 'rdf') {
window.dispatchEvent(new CustomEvent('rdf-reset-view'));
}
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="18" height="18">
<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>
{/* Fullscreen Toggle */}
<button
className={`toolbar-button ${isFullscreen ? 'toolbar-button--active' : ''}`}
title={isFullscreen ? (language === 'nl' ? 'Volledig scherm afsluiten' : 'Exit fullscreen') : (language === 'nl' ? 'Volledig scherm' : 'Fullscreen')}
onClick={toggleFullscreen}
>
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
</button>
{/* Layout Dropdown */}
<div className="dropdown" ref={layoutDropdownRef}>
<button
className="toolbar-button"
onClick={() => setLayoutDropdownOpen(!layoutDropdownOpen)}
title={`${language === 'nl' ? 'Huidig' : 'Current'}: ${rdfAdvancedLayout || (layoutType === 'force' ? 'Force' : 'Grid')}`}
>
{rdfAdvancedLayout ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="18" height="18">
<circle cx="12" cy="12" r="10" />
<path d="M12 2a10 10 0 0 1 0 20" />
<line x1="12" y1="12" x2="12" y2="6" />
</svg>
) : layoutType === 'force' ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="18" height="18">
<circle cx="6" cy="6" r="3" />
<circle cx="18" cy="6" r="3" />
<circle cx="18" cy="18" r="3" />
<circle cx="6" cy="18" r="3" />
</svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="18" height="18">
<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>
)}
{t('layout')}
<ChevronDown size={14} />
</button>
{layoutDropdownOpen && (
<div className="dropdown-menu dropdown-menu--layout">
{/* Basic layouts - shown for both UML and RDF */}
<button
className={`dropdown-item ${!rdfAdvancedLayout && layoutType === 'force' ? 'dropdown-item--active' : ''}`}
onClick={() => selectLayout('force')}
>
<span className="dropdown-item-title">{t('forceLayout')}</span>
<span className="dropdown-item-desc">{t('forceDesc')}</span>
</button>
<div className="dropdown-divider"></div>
<div className="dropdown-header">{t('gridLayoutOptions')}</div>
<button
className={`dropdown-item ${!rdfAdvancedLayout && layoutType === 'dagre' && dagreDirection === 'TB' && dagreRanker === 'network-simplex' ? 'dropdown-item--active' : ''}`}
onClick={() => selectLayout('dagre', 'TB', 'network-simplex')}
>
<span className="dropdown-item-title">{t('hierarchicalTB')}</span>
<span className="dropdown-item-desc">{t('hierarchicalTBDesc')}</span>
</button>
<button
className={`dropdown-item ${!rdfAdvancedLayout && layoutType === 'dagre' && dagreDirection === 'LR' && dagreRanker === 'network-simplex' ? 'dropdown-item--active' : ''}`}
onClick={() => selectLayout('dagre', 'LR', 'network-simplex')}
>
<span className="dropdown-item-title">{t('hierarchicalLR')}</span>
<span className="dropdown-item-desc">{t('hierarchicalLRDesc')}</span>
</button>
<button
className={`dropdown-item ${!rdfAdvancedLayout && layoutType === 'dagre' && dagreRanker === 'tight-tree' ? 'dropdown-item--active' : ''}`}
onClick={() => selectLayout('dagre', 'TB', 'tight-tree')}
>
<span className="dropdown-item-title">{t('adaptiveTightTree')}</span>
<span className="dropdown-item-desc">{t('adaptiveTightTreeDesc')}</span>
</button>
<button
className={`dropdown-item ${!rdfAdvancedLayout && layoutType === 'dagre' && dagreRanker === 'longest-path' ? 'dropdown-item--active' : ''}`}
onClick={() => selectLayout('dagre', 'TB', 'longest-path')}
>
<span className="dropdown-item-title">{t('adaptiveLongestPath')}</span>
<span className="dropdown-item-desc">{t('adaptiveLongestPathDesc')}</span>
</button>
{/* Show UML-specific advanced layouts only for UML mode */}
{currentCategory === 'uml' && (
<>
<div className="dropdown-divider"></div>
<div className="dropdown-header">{t('advancedLayoutOptions')}</div>
<button
className={`dropdown-item ${layoutType === 'elk' && elkAlgorithm === 'layered' ? 'dropdown-item--active' : ''}`}
onClick={() => selectLayout('elk', undefined, undefined, 'layered')}
>
<span className="dropdown-item-title">{t('elkLayered')}</span>
<span className="dropdown-item-desc">{t('elkLayeredDesc')}</span>
</button>
<button
className={`dropdown-item ${layoutType === 'elk' && elkAlgorithm === 'mrtree' ? 'dropdown-item--active' : ''}`}
onClick={() => selectLayout('elk', undefined, undefined, 'mrtree')}
>
<span className="dropdown-item-title">{t('elkTree')}</span>
<span className="dropdown-item-desc">{t('elkTreeDesc')}</span>
</button>
<button
className={`dropdown-item ${layoutType === 'circular' ? 'dropdown-item--active' : ''}`}
onClick={() => selectLayout('circular')}
>
<span className="dropdown-item-title">{t('circular')}</span>
<span className="dropdown-item-desc">{t('circularDesc')}</span>
</button>
<button
className={`dropdown-item ${layoutType === 'radial' ? 'dropdown-item--active' : ''}`}
onClick={() => selectLayout('radial')}
>
<span className="dropdown-item-title">{t('radial')}</span>
<span className="dropdown-item-desc">{t('radialDesc')}</span>
</button>
<button
className={`dropdown-item ${layoutType === 'clustered' ? 'dropdown-item--active' : ''}`}
onClick={() => selectLayout('clustered')}
>
<span className="dropdown-item-title">{t('clustered')}</span>
<span className="dropdown-item-desc">{t('clusteredDesc')}</span>
</button>
</>
)}
{/* Show RDF-specific advanced layouts only for RDF mode */}
{currentCategory === 'rdf' && (
<>
<div className="dropdown-divider"></div>
<div className="dropdown-header">{t('rdfAdvancedLayouts')}</div>
<button
className={`dropdown-item ${rdfAdvancedLayout === 'chord' ? 'dropdown-item--active' : ''}`}
onClick={() => selectRdfAdvancedLayout('chord')}
>
<span className="dropdown-item-title">{t('chordDiagram')}</span>
<span className="dropdown-item-desc">{t('chordDiagramDesc')}</span>
</button>
<button
className={`dropdown-item ${rdfAdvancedLayout === 'radial-tree' ? 'dropdown-item--active' : ''}`}
onClick={() => selectRdfAdvancedLayout('radial-tree')}
>
<span className="dropdown-item-title">{t('radialTreeLayout')}</span>
<span className="dropdown-item-desc">{t('radialTreeLayoutDesc')}</span>
</button>
<button
className={`dropdown-item ${rdfAdvancedLayout === 'edge-bundling' ? 'dropdown-item--active' : ''}`}
onClick={() => selectRdfAdvancedLayout('edge-bundling')}
>
<span className="dropdown-item-title">{t('edgeBundling')}</span>
<span className="dropdown-item-desc">{t('edgeBundlingDesc')}</span>
</button>
<button
className={`dropdown-item ${rdfAdvancedLayout === 'pack' ? 'dropdown-item--active' : ''}`}
onClick={() => selectRdfAdvancedLayout('pack')}
>
<span className="dropdown-item-title">{t('packLayout')}</span>
<span className="dropdown-item-desc">{t('packLayoutDesc')}</span>
</button>
<button
className={`dropdown-item ${rdfAdvancedLayout === 'sunburst' ? 'dropdown-item--active' : ''}`}
onClick={() => selectRdfAdvancedLayout('sunburst')}
>
<span className="dropdown-item-title">{t('sunburstLayout')}</span>
<span className="dropdown-item-desc">{t('sunburstLayoutDesc')}</span>
</button>
<button
className={`dropdown-item ${!rdfAdvancedLayout && layoutType === 'circular' ? 'dropdown-item--active' : ''}`}
onClick={() => selectLayout('circular')}
>
<span className="dropdown-item-title">{t('circular')}</span>
<span className="dropdown-item-desc">{t('circularDesc')}</span>
</button>
<button
className={`dropdown-item ${!rdfAdvancedLayout && layoutType === 'radial' ? 'dropdown-item--active' : ''}`}
onClick={() => selectLayout('radial')}
>
<span className="dropdown-item-title">{t('radial')}</span>
<span className="dropdown-item-desc">{t('radialDesc')}</span>
</button>
</>
)}
<div className="dropdown-divider"></div>
<a
href="/docs/UML_LAYOUT_OPTIONS.md"
target="_blank"
rel="noopener noreferrer"
className="dropdown-item dropdown-item--link"
onClick={() => setLayoutDropdownOpen(false)}
>
<HelpCircle size={16} />
<span className="dropdown-item-title">{t('layoutHelp')}</span>
</a>
</div>
)}
</div>
</div>
<div className="toolbar-right">
<span className="toolbar-title">{fileName}</span>
{/* Export Dropdown */}
<div className="dropdown" ref={exportDropdownRef}>
<button
className="toolbar-button toolbar-button--primary"
onClick={() => setExportDropdownOpen(!exportDropdownOpen)}
>
<Download size={18} />
{t('export')}
<ChevronDown size={14} />
</button>
{exportDropdownOpen && (
<div className="dropdown-menu">
<button className="dropdown-item" onClick={handleExportPNG}>
<Image size={16} />
<span className="dropdown-item-title">{t('exportAsPng')}</span>
</button>
<button className="dropdown-item" onClick={handleExportSVG}>
<FileCode size={16} />
<span className="dropdown-item-title">{t('exportAsSvg')}</span>
</button>
<div className="dropdown-divider"></div>
<button className="dropdown-item" onClick={handleDownloadSource}>
<Code size={16} />
<span className="dropdown-item-title">{t('downloadSource')}</span>
</button>
</div>
)}
</div>
</div>
</div>
)}
{/* Loading State */}
{isLoading && (
<div className="loading-overlay">
<div className="spinner" />
<p>{t('loading')}</p>
</div>
)}
{/* Error State */}
{(parserError || umlError) && (
<div className="error-message">
<h2>{t('error')}</h2>
<div className="error-details">
{(parserError?.message || umlError || '').split('\n').map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
</div>
)}
{/* Welcome State (no content) */}
{!isLoading && !parserError && !umlError && !hasContent && (
<div className="welcome-message">
<h2>{t('readyToVisualize')}</h2>
<p>{t('welcomeDesc')}</p>
<div className="features">
<h3>{t('supportedFormats')}</h3>
<ul>
<li><strong>RDF:</strong> Turtle (.ttl), N-Triples (.nt), JSON-LD (.jsonld)</li>
<li><strong>UML:</strong> Mermaid (.mmd), PlantUML (.puml), GraphViz (.dot)</li>
</ul>
<h3>{t('features')}</h3>
<ul>
<li>{t('featureInteractive')}</li>
<li>{t('featureLayouts')}</li>
<li>{t('featureFilter')}</li>
<li>{t('featureExport')}</li>
<li>{t('featureDrag')}</li>
<li>{t('featureClick')}</li>
</ul>
</div>
</div>
)}
{/* RDF Visualization */}
{!isLoading && !parserError && hasRdfContent && currentCategory === 'rdf' && (
rdfAdvancedLayout ? (
// Advanced RDF layouts (chord, radial-tree, edge-bundling, pack, sunburst)
<RdfAdvancedLayout
nodes={filteredNodes}
links={filteredLinks}
layoutType={rdfAdvancedLayout}
width={1200}
height={800}
onNodeClick={handleNodeClick}
onNodeHover={setHoveredNode}
selectedNode={selectedNode}
language={language}
/>
) : (
// Basic RDF layouts (force, hierarchical, circular, radial)
<ForceDirectedGraph
nodes={filteredNodes}
links={filteredLinks}
width={1200}
height={800}
layoutType={mapToRdfLayout(layoutType, dagreDirection)}
selectedNode={selectedNode}
onNodeClick={handleNodeClick}
onLinkClick={handleLinkClick}
onNodeHover={setHoveredNode}
className="graph-visualization"
/>
)
)}
{/* RDF Node Details Popup */}
{selectedNode && currentCategory === 'rdf' && (
<RdfNodeDetailsPanel
node={selectedNode}
links={links}
nodes={nodes}
onClose={() => setSelectedNode(null)}
onNodeClick={(node) => setSelectedNode(node)}
language={language}
/>
)}
{/* UML Visualization */}
{!isLoading && !umlError && hasUmlContent && currentCategory === 'uml' && (
<div className="uml-canvas">
<UMLVisualization
diagram={displayUmlDiagram || umlDiagram!}
width={1400}
height={900}
layoutType={layoutType}
dagreDirection={dagreDirection}
dagreRanker={dagreRanker}
elkAlgorithm={elkAlgorithm}
/>
</div>
)}
</main>
</div>
);
}