/** * 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(); 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(); 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(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(null); if (persistedState.current === null) { persistedState.current = loadStateFromSession(); } // File and format state const [fileName, setFileName] = useState(persistedState.current?.fileName || ''); const [currentCategory, setCurrentCategory] = useState<'rdf' | 'uml' | null>(persistedState.current?.currentCategory || null); const [loadSectionExpanded, setLoadSectionExpanded] = useState(false); const [customInput, setCustomInput] = useState(''); const [sidebarOpen, setSidebarOpen] = useState(true); const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(false); // Mobile detection and swipe gesture state const [isMobile, setIsMobile] = useState(false); const touchStartX = useRef(0); const touchStartY = useRef(0); const touchStartedInEdge = useRef(false); const sidebarRef = useRef(null); const overlayRef = useRef(null); // Fullscreen support const mainContentRef = useRef(null); const { isFullscreen, toggleFullscreen } = useFullscreen(mainContentRef); // UML-specific state const [umlDiagram, setUmlDiagram] = useState(persistedState.current?.umlDiagram || null); const [umlFileContent, setUmlFileContent] = useState(persistedState.current?.umlFileContent || ''); // Layout state const [layoutType, setLayoutType] = useState(() => { const saved = localStorage.getItem('visualize-layout-type'); return (saved === 'dagre' || saved === 'force' || saved === 'elk' || saved === 'circular' || saved === 'radial') ? saved : 'force'; }); const [dagreDirection, setDagreDirection] = useState(() => { const saved = localStorage.getItem('visualize-dagre-direction'); return (saved === 'TB' || saved === 'BT' || saved === 'LR' || saved === 'RL') ? saved : 'TB'; }); const [dagreRanker, setDagreRanker] = useState(() => { const saved = localStorage.getItem('visualize-dagre-ranker'); return (saved === 'network-simplex' || saved === 'tight-tree' || saved === 'longest-path') ? saved : 'network-simplex'; }); const [elkAlgorithm, setElkAlgorithm] = useState(() => { 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(() => { const saved = localStorage.getItem('visualize-rdf-advanced-layout'); if (saved && isAdvancedRdfLayout(saved)) { return saved; } return null; }); const [umlDensityMode, setUmlDensityMode] = useState(() => { const saved = localStorage.getItem('visualize-uml-density-mode'); return (saved === 'streamlined' || saved === 'module') ? saved : 'full'; }); const [selectedUmlModule, setSelectedUmlModule] = useState(() => { return localStorage.getItem('visualize-uml-module') || '__all__'; }); // Dropdown state const [exportDropdownOpen, setExportDropdownOpen] = useState(false); const [layoutDropdownOpen, setLayoutDropdownOpen] = useState(false); const exportDropdownRef = useRef(null); const layoutDropdownRef = useRef(null); // Cache tracking - both views can be loaded simultaneously const [hasUmlCache, setHasUmlCache] = useState(persistedState.current?.hasUmlCache || false); const [hasRdfCache, setHasRdfCache] = useState(persistedState.current?.hasRdfCache || false); const [rdfNodeCount, setRdfNodeCount] = useState(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(() => { const saved = localStorage.getItem('visualize-rdf-limit'); return saved ? parseInt(saved, 10) : 500; }); const [totalCustodiansAvailable, setTotalCustodiansAvailable] = useState(null); // Track the limit that was used for the current cached data const cachedRdfLimitRef = useRef(null); // Prevent duplicate RDF fetch requests (React StrictMode protection) const rdfFetchInProgressRef = useRef(false); // RDF Query Mode - allows switching between detailed (limited properties) and generic (all triples) type RdfQueryMode = 'detailed' | 'generic' | 'custom'; const [rdfQueryMode, setRdfQueryMode] = useState(() => { 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(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(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: 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: 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: for Custodian type // - hc: for ghcid, isil predicates // - hp: for person URIs console.log('Using DETAILED query mode - specific properties'); constructQuery = ` PREFIX rdf: PREFIX rdfs: PREFIX skos: PREFIX schema: PREFIX foaf: PREFIX owl: PREFIX org: PREFIX geo: PREFIX cidoc: PREFIX nde: PREFIX hc: PREFIX hp: 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) => { 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(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(() => { if (!umlDiagram) return []; const counts = new Map(); 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 (
{/* Mobile overlay when sidebar is open */} {isMobile && sidebarOpen && (
setSidebarOpen(false)} aria-hidden="true" /> )} {/* Sidebar Collapse Toggle (when collapsed) */} {!sidebarOpen && ( )} {/* Sidebar */}