feat(linkml-viewer): add slot_usage side-by-side comparison view

- Add 'Compare' toggle button next to slots with slot_usage overrides
- Show generic slot definition vs class-specific override in 3-column grid
- Highlight changed properties with green 'changed' badge
- Display '(inherited)' when override matches generic definition
- Display '(not defined)' when generic has no value for property
- Compare: range, description, required, multivalued, slot_uri, pattern, identifier
- Full i18n support (Dutch/English translations)
- Responsive design: stacks vertically on mobile (<640px)
This commit is contained in:
kempersc 2026-01-09 21:02:14 +01:00
parent 9e67d0f967
commit f7bd3e9edc
2 changed files with 288 additions and 2 deletions

View file

@ -815,6 +815,174 @@
font-size: 0.75rem;
}
/* ============================================
Slot Usage Comparison View
Side-by-side generic vs override comparison
============================================ */
/* Compare button badge */
.linkml-viewer__badge--compare {
background: transparent;
border: 1px solid var(--slot-usage-color, #388e3c);
color: var(--slot-usage-color, #388e3c);
cursor: pointer;
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
border-radius: 4px;
transition: all 0.2s ease;
font-weight: 500;
}
.linkml-viewer__badge--compare:hover {
background: var(--slot-usage-light, #e8f5e9);
}
.linkml-viewer__badge--compare-active {
background: var(--slot-usage-color, #388e3c);
color: white;
}
.linkml-viewer__badge--compare-active:hover {
background: #2e7d32;
}
/* Comparison panel container */
.linkml-viewer__slot-comparison {
margin-top: 1rem;
border: 1px solid var(--slot-usage-color, #388e3c);
border-radius: 8px;
overflow: hidden;
background: white;
animation: slideDown 0.2s ease-out;
}
/* Comparison header with two columns */
.linkml-viewer__slot-comparison-header {
display: grid;
grid-template-columns: 120px 1fr 1fr;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--slot-usage-light, #e8f5e9);
border-bottom: 1px solid var(--slot-usage-color, #388e3c);
font-weight: 600;
font-size: 0.8125rem;
color: var(--slot-usage-color, #388e3c);
}
.linkml-viewer__slot-comparison-header h5 {
margin: 0;
font-size: 0.8125rem;
}
.linkml-viewer__slot-comparison-header h5:first-of-type {
/* Empty first column for property name */
}
/* Comparison content rows */
.linkml-viewer__slot-comparison-content {
padding: 0.25rem 0;
}
.linkml-viewer__slot-comparison-row {
display: grid;
grid-template-columns: 120px 1fr 1fr;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
align-items: start;
}
.linkml-viewer__slot-comparison-row:last-child {
border-bottom: none;
}
.linkml-viewer__slot-comparison-row--changed {
background: rgba(56, 142, 60, 0.05);
}
/* Property label column */
.linkml-viewer__slot-comparison-label {
font-weight: 500;
color: var(--text-secondary, #666);
font-size: 0.75rem;
}
/* Generic value column */
.linkml-viewer__slot-comparison-generic {
word-break: break-word;
}
.linkml-viewer__slot-comparison-generic code,
.linkml-viewer__slot-comparison-override code {
background: var(--surface-secondary, #f5f5f5);
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.6875rem;
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
/* Override value column */
.linkml-viewer__slot-comparison-override {
word-break: break-word;
display: flex;
align-items: flex-start;
gap: 0.375rem;
flex-wrap: wrap;
}
.linkml-viewer__slot-comparison-override--diff code {
background: var(--slot-usage-light, #e8f5e9);
border: 1px solid var(--slot-usage-color, #388e3c);
}
/* Diff badge */
.linkml-viewer__slot-comparison-diff-badge {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
background: var(--slot-usage-color, #388e3c);
color: white;
padding: 0.125rem 0.25rem;
border-radius: 3px;
white-space: nowrap;
}
/* Empty/inherited states */
.linkml-viewer__slot-comparison-empty,
.linkml-viewer__slot-comparison-inherited {
font-style: italic;
color: var(--text-tertiary, #999);
font-size: 0.75rem;
}
/* Responsive - stack on small screens */
@media (max-width: 640px) {
.linkml-viewer__slot-comparison-header,
.linkml-viewer__slot-comparison-row {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.linkml-viewer__slot-comparison-header h5:first-of-type {
display: none;
}
.linkml-viewer__slot-comparison-generic,
.linkml-viewer__slot-comparison-override {
padding-left: 0.5rem;
border-left: 2px solid var(--border-color, #e0e0e0);
}
.linkml-viewer__slot-comparison-override--diff {
border-left-color: var(--slot-usage-color, #388e3c);
}
}
/* Custodian Type Badge - shows which GLAMORCUBESFIXPHDNT types apply */
.linkml-viewer__custodian-badge {
margin-left: 0.5rem;

View file

@ -996,6 +996,17 @@ const TEXT = {
nl: 'Eigenschappen zonder ✦ zijn overgenomen van de generieke slot-definitie',
en: 'Properties without ✦ are inherited from the generic slot definition'
},
// Slot usage comparison view
slotUsageCompareToggle: { nl: 'Vergelijk', en: 'Compare' },
slotUsageCompareTooltip: {
nl: 'Toon generieke definitie naast slot_usage overschrijvingen',
en: 'Show generic definition alongside slot_usage overrides'
},
slotUsageCompareGeneric: { nl: 'Generieke Definitie', en: 'Generic Definition' },
slotUsageCompareOverride: { nl: 'slot_usage Overschrijving', en: 'slot_usage Override' },
slotUsageCompareDiff: { nl: 'gewijzigd', en: 'changed' },
slotUsageCompareInherited: { nl: '(overgenomen)', en: '(inherited)' },
slotUsageCompareNotDefined: { nl: '(niet gedefinieerd)', en: '(not defined)' },
};
// Dynamically discover schema files from the modules directory
@ -1125,6 +1136,24 @@ const LinkMLViewerPage: React.FC = () => {
// State for slot_usage legend visibility toggle
const [showSlotUsageLegend, setShowSlotUsageLegend] = useState(false);
// State for slot_usage comparison view - tracks which slots have comparison expanded
// Key format: "className:slotName"
const [expandedSlotComparisons, setExpandedSlotComparisons] = useState<Set<string>>(new Set());
// Toggle comparison view for a specific slot
const toggleSlotComparison = (className: string, slotName: string) => {
const key = `${className}:${slotName}`;
setExpandedSlotComparisons(prev => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
// State for filtering schema elements by custodian type (click on polyhedron face or legend)
// Multi-select: Set of selected type codes (empty = no filter)
// Initialize from URL params (e.g., ?custodian=G,L,A)
@ -2092,7 +2121,7 @@ const LinkMLViewerPage: React.FC = () => {
...(genericSlot || {}),
...(slotUsage || {}),
};
return renderSlotDetails(mergedSlot, slotUsage || null);
return renderSlotDetails(mergedSlot, slotUsage || null, genericSlot || null, cls.name);
}
// Fallback: show slot name as tag if neither generic definition nor slot_usage found
return (
@ -2620,7 +2649,12 @@ const LinkMLViewerPage: React.FC = () => {
);
};
const renderSlotDetails = (slot: LinkMLSlot, slotUsage: Partial<LinkMLSlot> | null = null) => {
const renderSlotDetails = (
slot: LinkMLSlot,
slotUsage: Partial<LinkMLSlot> | null = null,
genericSlot: LinkMLSlot | null = null,
className: string | null = null
) => {
const hasSlotUsageOverrides = slotUsage !== null;
const rangeIsEnum = slot.range && isEnumRange(slot.range);
const enumKey = slot.range ? `${slot.name}:${slot.range}` : '';
@ -2632,6 +2666,10 @@ const LinkMLViewerPage: React.FC = () => {
// Check if this slot matches the current custodian type filter (multi-select)
const matchesFilter = custodianTypeFilter.size === 0 || custodianTypes.some(t => custodianTypeFilter.has(t));
// Check if comparison view is expanded for this slot
const comparisonKey = className ? `${className}:${slot.name}` : slot.name;
const showComparison = expandedSlotComparisons.has(comparisonKey);
// Helper to check if a property is overridden in slot_usage
const isOverridden = (property: keyof LinkMLSlot): boolean => {
if (!slotUsage) return false;
@ -2657,6 +2695,19 @@ const LinkMLViewerPage: React.FC = () => {
<h4 className="linkml-viewer__item-name">
{slot.name}
{hasSlotUsageOverrides && <span className="linkml-viewer__badge linkml-viewer__badge--usage" title={t('slotUsageTooltip')}>{t('slotUsageBadge')}</span>}
{/* Compare button - only show when slot_usage exists and we have both generic and override */}
{hasSlotUsageOverrides && genericSlot && className && (
<button
className={`linkml-viewer__badge linkml-viewer__badge--compare ${showComparison ? 'linkml-viewer__badge--compare-active' : ''}`}
onClick={(e) => {
e.stopPropagation();
toggleSlotComparison(className, slot.name);
}}
title={t('slotUsageCompareTooltip')}
>
{showComparison ? '▼' : '▶'} {t('slotUsageCompareToggle')}
</button>
)}
{slot.required && <span className="linkml-viewer__badge linkml-viewer__badge--required">{t('required')}</span>}
{slot.multivalued && <span className="linkml-viewer__badge linkml-viewer__badge--multi">{t('multivalued')}</span>}
{slot.identifier && <span className="linkml-viewer__badge linkml-viewer__badge--identifier">{t('identifier')}</span>}
@ -2798,6 +2849,73 @@ const LinkMLViewerPage: React.FC = () => {
</ul>
</div>
)}
{/* Side-by-side comparison view - only shown when expanded */}
{showComparison && hasSlotUsageOverrides && genericSlot && (
<div className="linkml-viewer__slot-comparison">
<div className="linkml-viewer__slot-comparison-header">
<h5>{t('slotUsageCompareGeneric')}</h5>
<h5>{t('slotUsageCompareOverride')}</h5>
</div>
<div className="linkml-viewer__slot-comparison-content">
{/* Compare key properties side by side */}
{renderComparisonRow('range', genericSlot.range, slotUsage?.range)}
{renderComparisonRow('description', genericSlot.description, slotUsage?.description)}
{renderComparisonRow('required', genericSlot.required, slotUsage?.required)}
{renderComparisonRow('multivalued', genericSlot.multivalued, slotUsage?.multivalued)}
{renderComparisonRow('slot_uri', genericSlot.slot_uri, slotUsage?.slot_uri)}
{renderComparisonRow('pattern', genericSlot.pattern, slotUsage?.pattern)}
{renderComparisonRow('identifier', genericSlot.identifier, slotUsage?.identifier)}
</div>
</div>
)}
</div>
);
};
// Helper function to render a comparison row
const renderComparisonRow = (
property: string,
genericValue: string | boolean | number | undefined | null,
overrideValue: string | boolean | number | undefined | null
) => {
const hasGeneric = genericValue !== undefined && genericValue !== null;
const hasOverride = overrideValue !== undefined && overrideValue !== null;
const isChanged = hasOverride && hasGeneric && genericValue !== overrideValue;
const isNewInOverride = hasOverride && !hasGeneric;
// Don't show row if neither has a value
if (!hasGeneric && !hasOverride) return null;
const formatValue = (val: string | boolean | number | undefined | null): string => {
if (val === undefined || val === null) return '';
if (typeof val === 'boolean') return val ? 'true' : 'false';
if (typeof val === 'number') return String(val);
// Truncate long strings for display
const str = String(val);
return str.length > 80 ? str.substring(0, 77) + '...' : str;
};
return (
<div key={property} className={`linkml-viewer__slot-comparison-row ${isChanged || isNewInOverride ? 'linkml-viewer__slot-comparison-row--changed' : ''}`}>
<div className="linkml-viewer__slot-comparison-label">{property}</div>
<div className="linkml-viewer__slot-comparison-generic">
{hasGeneric ? (
<code title={typeof genericValue === 'string' ? genericValue : undefined}>{formatValue(genericValue)}</code>
) : (
<span className="linkml-viewer__slot-comparison-empty">{t('slotUsageCompareNotDefined')}</span>
)}
</div>
<div className={`linkml-viewer__slot-comparison-override ${isChanged || isNewInOverride ? 'linkml-viewer__slot-comparison-override--diff' : ''}`}>
{hasOverride ? (
<>
<code title={typeof overrideValue === 'string' ? overrideValue : undefined}>{formatValue(overrideValue)}</code>
{(isChanged || isNewInOverride) && <span className="linkml-viewer__slot-comparison-diff-badge">{t('slotUsageCompareDiff')}</span>}
</>
) : (
<span className="linkml-viewer__slot-comparison-inherited">{t('slotUsageCompareInherited')}</span>
)}
</div>
</div>
);
};