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:
parent
9e67d0f967
commit
f7bd3e9edc
2 changed files with 288 additions and 2 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue