- 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.
356 lines
10 KiB
TypeScript
356 lines
10 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|