glam/frontend/src/components/query/OntologyVisualizer.tsx
kempersc 3a6ead8fde feat: Add legal form filtering rule for CustodianName
- Introduced LEGAL-FORM-FILTER rule to standardize CustodianName by removing legal form designations.
- Documented rationale, examples, and implementation guidelines for the filtering process.

docs: Create README for value standardization rules

- Established a comprehensive README outlining various value standardization rules applicable to Heritage Custodian classes.
- Categorized rules into Name Standardization, Geographic Standardization, Web Observation, and Schema Evolution.

feat: Implement transliteration standards for non-Latin scripts

- Added TRANSLIT-ISO rule to ensure GHCID abbreviations are generated from emic names using ISO standards for transliteration.
- Included detailed guidelines for various scripts and languages, along with implementation examples.

feat: Define XPath provenance rules for web observations

- Created XPATH-PROVENANCE rule mandating XPath pointers for claims extracted from web sources.
- Established a workflow for archiving websites and verifying claims against archived HTML.

chore: Update records lifecycle diagram

- Generated a new Mermaid diagram illustrating the records lifecycle for heritage custodians.
- Included phases for active records, inactive archives, and processed heritage collections with key relationships and classifications.
2025-12-09 16:58:41 +01:00

356 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* Ontology Visualizer Component
*
* Renders RDF ontologies as Mermaid diagrams (class diagrams and ER diagrams).
* Supports loading from .mmd files or generating from RDF data.
*/
import React, { useEffect, useRef, useState } from 'react';
import type { SparqlClient } from '../../lib/sparql/client';
import './OntologyVisualizer.css';
// Lazy load mermaid to avoid bundling issues
let mermaidInstance: typeof import('mermaid').default | null = null;
const getMermaid = async () => {
if (!mermaidInstance) {
const mod = await import('mermaid');
mermaidInstance = mod.default;
mermaidInstance.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose',
fontFamily: 'Arial, sans-serif',
logLevel: 'error',
});
}
return mermaidInstance;
};
export interface OntologyVisualizerProps {
/** Pre-loaded Mermaid diagram source */
mermaidSource?: string;
/** SPARQL client for querying RDF data */
sparqlClient?: SparqlClient;
/** Diagram type to generate from RDF */
diagramType?: 'class' | 'er';
/** Max height of diagram container */
maxHeight?: string;
/** Show toolbar with zoom/download controls */
showToolbar?: boolean;
}
export const OntologyVisualizer: React.FC<OntologyVisualizerProps> = ({
mermaidSource,
sparqlClient,
diagramType = 'class',
maxHeight = '800px',
showToolbar = true,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [zoom, setZoom] = useState(1);
const [generatedSource, setGeneratedSource] = useState<string | null>(null);
const [mermaidReady, setMermaidReady] = useState(false);
// Initialize Mermaid (lazy loaded)
useEffect(() => {
getMermaid().then(() => setMermaidReady(true)).catch(console.error);
}, []);
// Generate Mermaid diagram from RDF data
const generateFromRdf = async () => {
if (!sparqlClient) return;
setIsLoading(true);
setError(null);
try {
if (diagramType === 'class') {
const source = await generateClassDiagram(sparqlClient);
setGeneratedSource(source);
} else {
const source = await generateErDiagram(sparqlClient);
setGeneratedSource(source);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate diagram');
} finally {
setIsLoading(false);
}
};
// Render Mermaid diagram
useEffect(() => {
const source = mermaidSource || generatedSource;
if (!source || !containerRef.current || !mermaidReady) return;
const renderDiagram = async () => {
try {
const mermaid = await getMermaid();
const { svg } = await mermaid.render('mermaid-diagram', source);
if (containerRef.current) {
containerRef.current.innerHTML = svg;
}
} catch (err) {
console.error('Mermaid render error:', err);
setError('Failed to render diagram. Check Mermaid syntax.');
}
};
renderDiagram();
}, [mermaidSource, generatedSource, mermaidReady]);
// Generate diagram when sparqlClient is provided
useEffect(() => {
if (sparqlClient && !mermaidSource) {
generateFromRdf();
}
}, [sparqlClient, diagramType]);
// Download diagram as SVG
const handleDownload = () => {
if (!containerRef.current) return;
const svg = containerRef.current.querySelector('svg');
if (!svg) return;
const svgData = new XMLSerializer().serializeToString(svg);
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `ontology-diagram-${Date.now()}.svg`;
link.click();
URL.revokeObjectURL(url);
};
// Zoom controls
const handleZoomIn = () => setZoom((z) => Math.min(z + 0.1, 3));
const handleZoomOut = () => setZoom((z) => Math.max(z - 0.1, 0.3));
const handleZoomReset = () => setZoom(1);
if (isLoading) {
return (
<div className="ontology-visualizer">
<div className="visualizer-loading">
<div className="spinner" />
<p>Generating ontology diagram...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="ontology-visualizer">
<div className="visualizer-error">
<p className="error-title">Failed to load diagram</p>
<p className="error-message">{error}</p>
{sparqlClient && (
<button onClick={generateFromRdf} className="retry-btn">
Retry
</button>
)}
</div>
</div>
);
}
return (
<div className="ontology-visualizer">
{showToolbar && (
<div className="visualizer-toolbar">
<div className="zoom-controls">
<button
onClick={handleZoomOut}
className="toolbar-btn"
title="Zoom out"
disabled={zoom <= 0.3}
>
</button>
<span className="zoom-level">{Math.round(zoom * 100)}%</span>
<button
onClick={handleZoomIn}
className="toolbar-btn"
title="Zoom in"
disabled={zoom >= 3}
>
+
</button>
<button onClick={handleZoomReset} className="toolbar-btn" title="Reset zoom">
Reset
</button>
</div>
<div className="toolbar-actions">
{sparqlClient && (
<button onClick={generateFromRdf} className="toolbar-btn">
Regenerate
</button>
)}
<button onClick={handleDownload} className="toolbar-btn">
Download SVG
</button>
</div>
</div>
)}
<div
className="visualizer-container"
style={{
maxHeight,
transform: `scale(${zoom})`,
transformOrigin: 'top left',
}}
>
<div ref={containerRef} className="mermaid-diagram" />
</div>
</div>
);
};
/**
* Generate Mermaid class diagram from RDF data
*/
async function generateClassDiagram(client: SparqlClient): Promise<string> {
// Query for classes and their properties
const query = `
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX owl: <http://www.w3.org/2002/07/owl#>
SELECT DISTINCT ?class ?label ?superclass WHERE {
?class a owl:Class .
OPTIONAL { ?class rdfs:label ?label }
OPTIONAL { ?class rdfs:subClassOf ?superclass }
FILTER(isIRI(?class))
}
ORDER BY ?class
LIMIT 50
`;
const results = await client.executeSelect(query);
const classes = new Map<string, { label: string; superclasses: string[] }>();
// Build class hierarchy
for (const binding of results.results.bindings) {
const classUri = binding.class?.value;
if (!classUri) continue;
const className = extractLocalName(classUri);
const label = binding.label?.value || className;
const superclass = binding.superclass?.value;
if (!classes.has(className)) {
classes.set(className, { label, superclasses: [] });
}
if (superclass) {
const superclassName = extractLocalName(superclass);
classes.get(className)!.superclasses.push(superclassName);
}
}
// Generate Mermaid syntax
let mermaid = 'classDiagram\n';
mermaid += ' %% Heritage Custodian Ontology - Class Diagram\n';
mermaid += ' %% Auto-generated from RDF data\n\n';
// Define classes
for (const [className] of classes) {
mermaid += ` class ${className} {\n`;
mermaid += ` }\n\n`;
}
// Add inheritance relationships
mermaid += ' %% Inheritance relationships\n';
for (const [className, data] of classes) {
for (const superclass of data.superclasses) {
if (classes.has(superclass)) {
mermaid += ` ${superclass} <|-- ${className}\n`;
}
}
}
return mermaid;
}
/**
* Generate Mermaid ER diagram from RDF data
*/
async function generateErDiagram(client: SparqlClient): Promise<string> {
// Query for classes and properties
const query = `
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX owl: <http://www.w3.org/2002/07/owl#>
SELECT DISTINCT ?class ?property ?range WHERE {
?class a owl:Class .
?property rdfs:domain ?class .
OPTIONAL { ?property rdfs:range ?range }
FILTER(isIRI(?class))
}
ORDER BY ?class ?property
LIMIT 100
`;
const results = await client.executeSelect(query);
const entities = new Map<string, Set<string>>();
const relationships = new Set<string>();
// Build entity structure
for (const binding of results.results.bindings) {
const classUri = binding.class?.value;
const propertyUri = binding.property?.value;
const rangeUri = binding.range?.value;
if (!classUri) continue;
const className = extractLocalName(classUri);
if (!entities.has(className)) {
entities.set(className, new Set());
}
if (propertyUri) {
const propName = extractLocalName(propertyUri);
entities.get(className)!.add(propName);
// Add relationship if range is another class
if (rangeUri && entities.has(extractLocalName(rangeUri))) {
const rangeName = extractLocalName(rangeUri);
relationships.add(`${className} ||--o{ ${rangeName} : "${propName}"`);
}
}
}
// Generate Mermaid syntax
let mermaid = 'erDiagram\n';
// Define entities
for (const [entityName, properties] of entities) {
mermaid += `${entityName} {\n`;
for (const prop of properties) {
mermaid += ` string ${prop}\n`;
}
mermaid += `}\n\n`;
}
// Add relationships
for (const rel of relationships) {
mermaid += `${rel}\n`;
}
return mermaid;
}
/**
* Extract local name from URI
*/
function extractLocalName(uri: string): string {
const lastSlash = Math.max(uri.lastIndexOf('/'), uri.lastIndexOf('#'));
return lastSlash > 0 ? uri.substring(lastSlash + 1) : uri;
}