- Implemented `owl_to_mermaid.py` to convert OWL/Turtle files into Mermaid class diagrams. - Implemented `owl_to_plantuml.py` to convert OWL/Turtle files into PlantUML class diagrams. - Added two new PlantUML files for custodian multi-aspect diagrams.
303 lines
8.9 KiB
TypeScript
303 lines
8.9 KiB
TypeScript
/**
|
|
* Results Table Component
|
|
*
|
|
* Displays SPARQL query results in a sortable, paginated table.
|
|
* Supports URI rendering, literal type indicators, and cell copy functionality.
|
|
*/
|
|
|
|
import React, { useState, useMemo } from 'react';
|
|
import type { SelectResults, BindingValue } from '../../lib/sparql/client';
|
|
import './ResultsTable.css';
|
|
|
|
export interface ResultsTableProps {
|
|
results: SelectResults;
|
|
onExport?: (format: 'csv' | 'json' | 'jsonld') => void;
|
|
maxHeight?: string;
|
|
}
|
|
|
|
type SortDirection = 'asc' | 'desc' | null;
|
|
|
|
interface SortState {
|
|
column: string | null;
|
|
direction: SortDirection;
|
|
}
|
|
|
|
export const ResultsTable: React.FC<ResultsTableProps> = ({
|
|
results,
|
|
onExport,
|
|
maxHeight = '600px',
|
|
}) => {
|
|
const [sortState, setSortState] = useState<SortState>({
|
|
column: null,
|
|
direction: null,
|
|
});
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [copiedCell, setCopiedCell] = useState<string | null>(null);
|
|
const rowsPerPage = 100;
|
|
|
|
// Extract variables (column names) from results
|
|
const variables = results.head.vars;
|
|
const bindings = results.results.bindings;
|
|
|
|
// Sort bindings based on current sort state
|
|
const sortedBindings = useMemo(() => {
|
|
if (!sortState.column || !sortState.direction) {
|
|
return bindings;
|
|
}
|
|
|
|
return [...bindings].sort((a, b) => {
|
|
const aValue = a[sortState.column!]?.value || '';
|
|
const bValue = b[sortState.column!]?.value || '';
|
|
|
|
const comparison = aValue.localeCompare(bValue);
|
|
return sortState.direction === 'asc' ? comparison : -comparison;
|
|
});
|
|
}, [bindings, sortState]);
|
|
|
|
// Paginate sorted bindings
|
|
const paginatedBindings = useMemo(() => {
|
|
const startIdx = (currentPage - 1) * rowsPerPage;
|
|
return sortedBindings.slice(startIdx, startIdx + rowsPerPage);
|
|
}, [sortedBindings, currentPage]);
|
|
|
|
const totalPages = Math.ceil(sortedBindings.length / rowsPerPage);
|
|
|
|
// Handle column header click for sorting
|
|
const handleSort = (column: string) => {
|
|
setSortState((prev) => {
|
|
if (prev.column === column) {
|
|
// Cycle through: asc -> desc -> null
|
|
const newDirection =
|
|
prev.direction === 'asc' ? 'desc' : prev.direction === 'desc' ? null : 'asc';
|
|
return { column: newDirection ? column : null, direction: newDirection };
|
|
} else {
|
|
return { column, direction: 'asc' };
|
|
}
|
|
});
|
|
setCurrentPage(1); // Reset to first page when sorting
|
|
};
|
|
|
|
// Copy cell value to clipboard
|
|
const handleCopyCell = async (value: string, cellId: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
setCopiedCell(cellId);
|
|
setTimeout(() => setCopiedCell(null), 2000);
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err);
|
|
}
|
|
};
|
|
|
|
// Render a single cell value based on its type
|
|
const renderCell = (
|
|
variable: string,
|
|
binding: BindingValue | undefined,
|
|
rowIdx: number
|
|
): React.ReactNode => {
|
|
if (!binding) {
|
|
return <span className="cell-empty">—</span>;
|
|
}
|
|
|
|
const cellId = `${rowIdx}-${variable}`;
|
|
const { type, value } = binding;
|
|
|
|
const cellContent = (
|
|
<>
|
|
{type === 'uri' ? (
|
|
<a
|
|
href={value}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="cell-uri"
|
|
title={value}
|
|
>
|
|
{shortenUri(value)}
|
|
</a>
|
|
) : type === 'literal' ? (
|
|
<span className="cell-literal" title={value}>
|
|
{value}
|
|
{'xml:lang' in binding && (
|
|
<span className="cell-lang">@{binding['xml:lang']}</span>
|
|
)}
|
|
{binding.datatype && (
|
|
<span className="cell-datatype" title={binding.datatype}>
|
|
^^{shortenUri(binding.datatype)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
) : type === 'bnode' ? (
|
|
<span className="cell-bnode" title={value}>
|
|
{value}
|
|
</span>
|
|
) : (
|
|
<span>{value}</span>
|
|
)}
|
|
<button
|
|
className={`copy-btn ${copiedCell === cellId ? 'copied' : ''}`}
|
|
onClick={() => handleCopyCell(value, cellId)}
|
|
title="Copy to clipboard"
|
|
aria-label="Copy value"
|
|
>
|
|
{copiedCell === cellId ? '✓' : '📋'}
|
|
</button>
|
|
</>
|
|
);
|
|
|
|
return <div className="cell-content">{cellContent}</div>;
|
|
};
|
|
|
|
// Shorten URIs for display (show prefix + local name)
|
|
const shortenUri = (uri: string): string => {
|
|
const lastSlash = Math.max(uri.lastIndexOf('/'), uri.lastIndexOf('#'));
|
|
if (lastSlash > 0 && lastSlash < uri.length - 1) {
|
|
return '...' + uri.substring(lastSlash);
|
|
}
|
|
return uri;
|
|
};
|
|
|
|
// Render pagination controls
|
|
const renderPagination = () => {
|
|
if (totalPages <= 1) return null;
|
|
|
|
const pageNumbers: (number | string)[] = [];
|
|
const showPages = 5; // Number of page buttons to show
|
|
|
|
if (totalPages <= showPages + 2) {
|
|
// Show all pages if total is small
|
|
for (let i = 1; i <= totalPages; i++) {
|
|
pageNumbers.push(i);
|
|
}
|
|
} else {
|
|
// Show first, last, and pages around current
|
|
pageNumbers.push(1);
|
|
|
|
const start = Math.max(2, currentPage - 1);
|
|
const end = Math.min(totalPages - 1, currentPage + 1);
|
|
|
|
if (start > 2) pageNumbers.push('...');
|
|
for (let i = start; i <= end; i++) {
|
|
pageNumbers.push(i);
|
|
}
|
|
if (end < totalPages - 1) pageNumbers.push('...');
|
|
|
|
pageNumbers.push(totalPages);
|
|
}
|
|
|
|
return (
|
|
<div className="pagination">
|
|
<button
|
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
disabled={currentPage === 1}
|
|
className="pagination-btn"
|
|
>
|
|
← Previous
|
|
</button>
|
|
|
|
{pageNumbers.map((page, idx) =>
|
|
typeof page === 'number' ? (
|
|
<button
|
|
key={idx}
|
|
onClick={() => setCurrentPage(page)}
|
|
className={`pagination-btn ${currentPage === page ? 'active' : ''}`}
|
|
>
|
|
{page}
|
|
</button>
|
|
) : (
|
|
<span key={idx} className="pagination-ellipsis">
|
|
{page}
|
|
</span>
|
|
)
|
|
)}
|
|
|
|
<button
|
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={currentPage === totalPages}
|
|
className="pagination-btn"
|
|
>
|
|
Next →
|
|
</button>
|
|
|
|
<span className="pagination-info">
|
|
Page {currentPage} of {totalPages} ({sortedBindings.length} results)
|
|
</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (bindings.length === 0) {
|
|
return (
|
|
<div className="results-table-container">
|
|
<div className="results-empty">
|
|
<p>No results found</p>
|
|
<p className="results-empty-hint">
|
|
Try a different query or check your SPARQL syntax
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="results-table-container">
|
|
<div className="results-header">
|
|
<div className="results-summary">
|
|
<strong>{sortedBindings.length}</strong> result{sortedBindings.length !== 1 ? 's' : ''}
|
|
{variables.length > 0 && (
|
|
<span className="results-columns">
|
|
{' '}
|
|
· {variables.length} column{variables.length !== 1 ? 's' : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{onExport && (
|
|
<div className="results-export">
|
|
<button onClick={() => onExport('csv')} className="export-btn">
|
|
Export CSV
|
|
</button>
|
|
<button onClick={() => onExport('json')} className="export-btn">
|
|
Export JSON
|
|
</button>
|
|
<button onClick={() => onExport('jsonld')} className="export-btn">
|
|
Export JSON-LD
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="results-table-wrapper" style={{ maxHeight }}>
|
|
<table className="results-table">
|
|
<thead>
|
|
<tr>
|
|
{variables.map((variable) => (
|
|
<th key={variable} onClick={() => handleSort(variable)}>
|
|
<div className="th-content">
|
|
<span className="th-label">{variable}</span>
|
|
{sortState.column === variable && (
|
|
<span className="sort-indicator">
|
|
{sortState.direction === 'asc' ? '↑' : '↓'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{paginatedBindings.map((binding, rowIdx) => (
|
|
<tr key={rowIdx}>
|
|
{variables.map((variable) => (
|
|
<td key={variable} className={`cell-type-${binding[variable]?.type || 'empty'}`}>
|
|
{renderCell(variable, binding[variable], rowIdx)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{renderPagination()}
|
|
</div>
|
|
);
|
|
};
|