1697 lines
70 KiB
TypeScript
1697 lines
70 KiB
TypeScript
/**
|
||
* Unified Visualize Page - Schema Visualization
|
||
* Supports both RDF (Turtle, N-Triples) and UML (Mermaid, PlantUML, GraphViz) formats
|
||
*/
|
||
|
||
import React, { useState, useCallback, useRef, useEffect } 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);
|
||
}
|
||
|
||
// 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' },
|
||
|
||
// 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' },
|
||
|
||
// 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;
|
||
});
|
||
|
||
// 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
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
const [rdfLimit, setRdfLimit] = useState<number>(() => {
|
||
const saved = localStorage.getItem('visualize-rdf-limit');
|
||
return saved ? parseInt(saved, 10) : 500;
|
||
});
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
const [totalCustodiansAvailable, setTotalCustodiansAvailable] = useState<number | 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 () => {
|
||
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 aligned with actual RDF generated by oxigraph_sync.py and oxigraph_person_sync.py
|
||
// 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
|
||
const 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
|
||
setHasRdfCache(true);
|
||
setRdfNodeCount(result.nodes.length);
|
||
|
||
// Update filename with count
|
||
setFileName(`NDE Heritage Custodians (${result.nodes.length} entities)`);
|
||
|
||
} 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);
|
||
}
|
||
}, [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(() => {
|
||
if (!hasRdfCache) {
|
||
// No cache, generate it
|
||
handleGenerateRdf();
|
||
} else {
|
||
// Switch to cached RDF view
|
||
setCurrentCategory('rdf');
|
||
setLayoutType('force');
|
||
localStorage.setItem('visualize-layout-type', 'force');
|
||
}
|
||
}, [hasRdfCache, handleGenerateRdf]);
|
||
|
||
// Check if we have content to display
|
||
const hasRdfContent = filteredNodes.length > 0;
|
||
const hasUmlContent = umlDiagram !== null;
|
||
const hasContent = hasRdfContent || hasUmlContent;
|
||
|
||
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));
|
||
}}
|
||
>
|
||
<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>
|
||
{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>
|
||
)}
|
||
</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>
|
||
)}
|
||
|
||
<div className="minimal-footer">
|
||
© 2025{' '}
|
||
<a href="https://www.netwerkdigitaalerfgoed.nl" target="_blank" rel="noopener noreferrer">
|
||
Netwerk Digitaal Erfgoed
|
||
</a>
|
||
{' & '}
|
||
<a href="https://www.textpast.com" target="_blank" rel="noopener noreferrer">
|
||
TextPast
|
||
</a>
|
||
.{' '}
|
||
{language === 'nl' ? 'Alle rechten voorbehouden.' : 'All rights reserved.'}
|
||
</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={umlDiagram!}
|
||
width={1400}
|
||
height={900}
|
||
layoutType={layoutType}
|
||
dagreDirection={dagreDirection}
|
||
dagreRanker={dagreRanker}
|
||
elkAlgorithm={elkAlgorithm}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|