/** * Display Logic Manager * * Handles conditional schema visibility based on user responses. * Evaluates display_logic rules and shows/hides annotation schemas accordingly. * * Usage: * // Initialize on page load * const displayLogicManager = new DisplayLogicManager(); * displayLogicManager.initialize(); * * // Call after any annotation change * displayLogicManager.evaluateAll(); */ class DisplayLogicManager { constructor() { // Map of schema name -> display logic configuration this.displayLogicRules = new Map(); // Map of schema name -> container element this.schemaContainers = new Map(); // Map of watched schema -> list of dependent schemas this.dependencies = new Map(); // Current visibility state for each schema with display_logic this.visibilityState = new Map(); // Animation duration in ms this.animationDuration = 300; // Debug mode this.debug = false; } /** * Initialize the display logic manager. * Scans the DOM for display_logic configurations and sets up the initial state. */ initialize() { this.scanForDisplayLogic(); this.buildDependencyGraph(); if (this.debug) { console.log('[DisplayLogic] Initialized with', this.displayLogicRules.size, 'rules'); console.log('[DisplayLogic] Dependencies:', Object.fromEntries(this.dependencies)); } // Evaluate all rules on initial load // Use setTimeout to ensure DOM is ready and initial annotations are loaded setTimeout(() => this.evaluateAll(), 100); } /** * Scan the DOM for elements with data-display-logic attributes. */ scanForDisplayLogic() { const containers = document.querySelectorAll('[data-display-logic-target="true"]'); containers.forEach(container => { const schemaName = container.getAttribute('data-schema-name'); const displayLogicStr = container.getAttribute('data-display-logic'); if (!schemaName || !displayLogicStr) { console.warn('[DisplayLogic] Container missing schema-name or display-logic:', container); return; } try { const displayLogic = JSON.parse(displayLogicStr); this.displayLogicRules.set(schemaName, displayLogic); this.schemaContainers.set(schemaName, container); this.visibilityState.set(schemaName, false); // Start hidden if (this.debug) { console.log('[DisplayLogic] Found rule for schema:', schemaName, displayLogic); } } catch (e) { console.error('[DisplayLogic] Failed to parse display logic for', schemaName, ':', e); } }); } /** * Build the dependency graph (which schemas depend on which others). */ buildDependencyGraph() { this.displayLogicRules.forEach((rule, schemaName) => { const conditions = rule.show_when || []; conditions.forEach(condition => { const watchedSchema = condition.schema; if (!this.dependencies.has(watchedSchema)) { this.dependencies.set(watchedSchema, new Set()); } this.dependencies.get(watchedSchema).add(schemaName); }); }); } /** * Evaluate all display logic rules. * Called on page load and after annotation changes. */ evaluateAll() { const annotations = this.getCurrentAnnotations(); if (this.debug) { console.log('[DisplayLogic] Evaluating all rules with annotations:', annotations); } this.displayLogicRules.forEach((rule, schemaName) => { const shouldBeVisible = this.evaluateRule(rule, annotations); this.updateVisibility(schemaName, shouldBeVisible); }); } /** * Evaluate rules only for schemas that depend on the changed schema. * More efficient than evaluateAll() for targeted updates. * * @param {string} changedSchema - The schema that changed */ evaluateForSchema(changedSchema) { const dependents = this.dependencies.get(changedSchema); if (!dependents || dependents.size === 0) { return; } const annotations = this.getCurrentAnnotations(); if (this.debug) { console.log('[DisplayLogic] Evaluating dependents of', changedSchema, ':', [...dependents]); } dependents.forEach(schemaName => { const rule = this.displayLogicRules.get(schemaName); if (rule) { const shouldBeVisible = this.evaluateRule(rule, annotations); this.updateVisibility(schemaName, shouldBeVisible); } }); } /** * Get current annotations from the global state. * @returns {Object} Current annotations keyed by schema name */ getCurrentAnnotations() { // Try to get from global currentAnnotations (set by annotation.js) if (typeof currentAnnotations !== 'undefined' && currentAnnotations) { // Transform from {schema: {label: value}} to {schema: value} const result = {}; for (const [schema, labels] of Object.entries(currentAnnotations)) { // For radio/select: single value // For multiselect: array of selected values // For text/slider: direct value if (labels && typeof labels === 'object') { // Check if it's a radio (single selection) const selectedLabels = []; let textValue = null; for (const [label, value] of Object.entries(labels)) { if (value === true || value === 'true' || value === 1) { selectedLabels.push(label); } else if (typeof value === 'string' || typeof value === 'number') { // Text or numeric value textValue = value; } } if (selectedLabels.length === 1) { // Single selection (radio) result[schema] = selectedLabels[0]; } else if (selectedLabels.length > 1) { // Multiple selections (multiselect) result[schema] = selectedLabels; } else if (textValue !== null) { // Text/numeric input result[schema] = textValue; } } else if (labels !== null && labels !== undefined) { result[schema] = labels; } } return result; } // Fallback: scan DOM for current values return this.getAnnotationsFromDOM(); } /** * Fallback method to get annotations directly from DOM elements. * @returns {Object} Annotations keyed by schema name */ getAnnotationsFromDOM() { const annotations = {}; // Radio buttons document.querySelectorAll('input[type="radio"]:checked').forEach(input => { const schema = input.getAttribute('schema'); if (schema) { annotations[schema] = input.value; } }); // Checkboxes (multiselect) const checkboxSchemas = new Map(); document.querySelectorAll('input[type="checkbox"]:checked').forEach(input => { const schema = input.getAttribute('schema'); if (schema) { if (!checkboxSchemas.has(schema)) { checkboxSchemas.set(schema, []); } checkboxSchemas.get(schema).push(input.value); } }); checkboxSchemas.forEach((values, schema) => { annotations[schema] = values; }); // Text inputs document.querySelectorAll('textarea.annotation-input, input[type="text"].annotation-input').forEach(input => { const schema = input.getAttribute('schema'); if (schema) { annotations[schema] = input.value; } }); // Sliders document.querySelectorAll('input[type="range"].annotation-input').forEach(input => { const schema = input.getAttribute('schema'); if (schema) { annotations[schema] = parseFloat(input.value); } }); // Number inputs document.querySelectorAll('input[type="number"].annotation-input').forEach(input => { const schema = input.getAttribute('schema'); if (schema) { annotations[schema] = parseFloat(input.value); } }); // Select dropdowns document.querySelectorAll('select.annotation-input').forEach(select => { const schema = select.getAttribute('schema'); if (schema && select.value) { annotations[schema] = select.value; } }); return annotations; } /** * Evaluate a display logic rule. * * @param {Object} rule - The display_logic rule configuration * @param {Object} annotations - Current annotations * @returns {boolean} Whether the schema should be visible */ evaluateRule(rule, annotations) { const conditions = rule.show_when || []; const logic = rule.logic || 'all'; if (conditions.length === 0) { return true; // No conditions = always visible } const results = conditions.map(condition => { return this.evaluateCondition(condition, annotations); }); if (logic === 'all') { return results.every(r => r); } else { return results.some(r => r); } } /** * Evaluate a single condition. * * @param {Object} condition - The condition configuration * @param {Object} annotations - Current annotations * @returns {boolean} Whether the condition is satisfied */ evaluateCondition(condition, annotations) { const { schema, operator, value, case_sensitive = false } = condition; const actualValue = annotations[schema]; if (this.debug) { console.log('[DisplayLogic] Evaluating:', schema, operator, value, '| actual:', actualValue); } // Handle empty/not_empty first if (operator === 'empty') { return this.isEmpty(actualValue); } if (operator === 'not_empty') { return !this.isEmpty(actualValue); } // Equality operators if (operator === 'equals') { return this.checkEquals(actualValue, value, case_sensitive); } if (operator === 'not_equals') { return !this.checkEquals(actualValue, value, case_sensitive); } // Contains operators if (operator === 'contains') { return this.checkContains(actualValue, value, case_sensitive); } if (operator === 'not_contains') { return !this.checkContains(actualValue, value, case_sensitive); } // Regex matching if (operator === 'matches') { return this.checkMatches(actualValue, value, case_sensitive); } // Numeric comparisons if (['gt', 'gte', 'lt', 'lte'].includes(operator)) { return this.checkNumeric(operator, actualValue, value); } // Range operators if (operator === 'in_range') { return this.checkRange(actualValue, value); } if (operator === 'not_in_range') { return !this.checkRange(actualValue, value); } // Length operators if (['length_gt', 'length_lt'].includes(operator)) { return this.checkLength(operator, actualValue, value); } if (operator === 'length_in_range') { return this.checkLengthRange(actualValue, value); } console.warn('[DisplayLogic] Unknown operator:', operator); return false; } /** * Check if a value is empty. */ isEmpty(value) { if (value === null || value === undefined) { return true; } if (typeof value === 'string') { return value.trim().length === 0; } if (Array.isArray(value)) { return value.length === 0; } return false; } /** * Check equality (handles single value or list of values). */ checkEquals(actual, expected, caseSensitive) { if (Array.isArray(expected)) { // Check if actual matches ANY of the expected values return expected.some(exp => this.valuesEqual(actual, exp, caseSensitive)); } return this.valuesEqual(actual, expected, caseSensitive); } /** * Compare two values for equality. */ valuesEqual(actual, expected, caseSensitive) { if (actual === null || actual === undefined) { return expected === null || expected === undefined; } // String comparison with case sensitivity if (typeof expected === 'string') { const actualStr = String(actual); if (!caseSensitive) { return actualStr.toLowerCase() === expected.toLowerCase(); } return actualStr === expected; } return actual === expected; } /** * Check if actual contains expected value(s). */ checkContains(actual, expected, caseSensitive) { if (Array.isArray(expected)) { // Check if actual contains ANY of the expected values return expected.some(exp => this.valueContains(actual, exp, caseSensitive)); } return this.valueContains(actual, expected, caseSensitive); } /** * Check if actual contains a single expected value. */ valueContains(actual, expected, caseSensitive) { // If actual is a list (multiselect), check membership if (Array.isArray(actual)) { return actual.some(item => this.valuesEqual(item, expected, caseSensitive)); } // If actual is a string, check substring if (typeof actual === 'string') { const expectedStr = String(expected); if (!caseSensitive) { return actual.toLowerCase().includes(expectedStr.toLowerCase()); } return actual.includes(expectedStr); } // Fallback to equality return this.valuesEqual(actual, expected, caseSensitive); } /** * Check regex match. */ checkMatches(actual, pattern, caseSensitive) { if (actual === null || actual === undefined) { return false; } try { const flags = caseSensitive ? '' : 'i'; const regex = new RegExp(pattern, flags); return regex.test(String(actual)); } catch (e) { console.warn('[DisplayLogic] Invalid regex pattern:', pattern, e); return false; } } /** * Check numeric comparison. */ checkNumeric(operator, actual, expected) { const actualNum = parseFloat(actual); const expectedNum = parseFloat(expected); if (isNaN(actualNum) || isNaN(expectedNum)) { return false; } switch (operator) { case 'gt': return actualNum > expectedNum; case 'gte': return actualNum >= expectedNum; case 'lt': return actualNum < expectedNum; case 'lte': return actualNum <= expectedNum; default: return false; } } /** * Check if value is in range (inclusive). */ checkRange(actual, range) { if (!Array.isArray(range) || range.length !== 2) { return false; } const actualNum = parseFloat(actual); const minVal = parseFloat(range[0]); const maxVal = parseFloat(range[1]); if (isNaN(actualNum) || isNaN(minVal) || isNaN(maxVal)) { return false; } return actualNum >= minVal && actualNum <= maxVal; } /** * Check text length comparison. */ checkLength(operator, actual, expected) { const length = actual ? String(actual).length : 0; const expectedLen = parseInt(expected); if (isNaN(expectedLen)) { return false; } if (operator === 'length_gt') { return length > expectedLen; } if (operator === 'length_lt') { return length < expectedLen; } return false; } /** * Check if text length is in range. */ checkLengthRange(actual, range) { if (!Array.isArray(range) || range.length !== 2) { return false; } const length = actual ? String(actual).length : 0; const minLen = parseInt(range[0]); const maxLen = parseInt(range[1]); if (isNaN(minLen) || isNaN(maxLen)) { return false; } return length >= minLen && length <= maxLen; } /** * Update the visibility of a schema container. * * @param {string} schemaName - The schema to update * @param {boolean} shouldBeVisible - Whether it should be visible */ updateVisibility(schemaName, shouldBeVisible) { const container = this.schemaContainers.get(schemaName); if (!container) { return; } const wasVisible = this.visibilityState.get(schemaName); if (shouldBeVisible === wasVisible) { return; // No change needed } this.visibilityState.set(schemaName, shouldBeVisible); if (this.debug) { console.log('[DisplayLogic] Visibility change:', schemaName, wasVisible, '->', shouldBeVisible); } // Track display logic change in behavioral tracker const reason = this.getVisibilityReason(schemaName, shouldBeVisible); if (window.interactionTracker) { window.interactionTracker.trackDisplayLogicChange(schemaName, shouldBeVisible, reason); // If hiding a schema that has a value, track it as stale if (!shouldBeVisible && wasVisible) { const schemaValue = this.getSchemaValue(schemaName); if (schemaValue !== null && schemaValue !== undefined) { window.interactionTracker.trackStaleAnnotation(schemaName, schemaValue, reason); } } } if (shouldBeVisible) { this.showContainer(container); } else { this.hideContainer(container); } } /** * Get the current value of a schema from annotations. * @param {string} schemaName - The schema to get value for * @returns {*} The schema value or null */ getSchemaValue(schemaName) { const annotations = this.getCurrentAnnotations(); return annotations[schemaName] || null; } /** * Get the reason for a visibility state. * @param {string} schemaName - The schema * @param {boolean} visible - The visibility state * @returns {string} Human-readable reason */ getVisibilityReason(schemaName, visible) { if (visible) { return 'Conditions met'; } const rule = this.displayLogicRules.get(schemaName); if (!rule) { return 'No rule defined'; } const annotations = this.getCurrentAnnotations(); const conditions = rule.show_when || []; const reasons = conditions.map(cond => { const actual = annotations[cond.schema]; return `${cond.schema} ${cond.operator} ${JSON.stringify(cond.value)} (actual: ${JSON.stringify(actual)})`; }); return `Conditions not met (${rule.logic || 'all'}): ${reasons.join(', ')}`; } /** * Show a container with animation. */ showContainer(container) { container.classList.remove('display-logic-hidden'); container.classList.add('display-logic-visible'); // Trigger reflow for animation container.offsetHeight; // Optional: scroll into view if newly visible if (this.shouldScrollIntoView(container)) { setTimeout(() => { container.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, this.animationDuration); } } /** * Hide a container with animation. */ hideContainer(container) { container.classList.remove('display-logic-visible'); container.classList.add('display-logic-hidden'); } /** * Check if we should scroll the container into view. * Only scroll if it's mostly off-screen. */ shouldScrollIntoView(container) { const rect = container.getBoundingClientRect(); const viewHeight = window.innerHeight || document.documentElement.clientHeight; // Check if the container is mostly visible const visibleHeight = Math.min(rect.bottom, viewHeight) - Math.max(rect.top, 0); const containerHeight = rect.height; // Scroll if less than 50% is visible return visibleHeight < containerHeight * 0.5; } /** * Check if a schema is currently visible according to display logic. * * @param {string} schemaName - The schema to check * @returns {boolean} True if visible (or if no display logic) */ isSchemaVisible(schemaName) { // If no display_logic rule, it's always visible if (!this.displayLogicRules.has(schemaName)) { return true; } return this.visibilityState.get(schemaName) || false; } /** * Get the visibility state for all schemas. * Used for saving with annotations. * * @returns {Object} Map of schema name -> {visible: boolean, reason?: string} */ getVisibilityState() { const state = {}; this.displayLogicRules.forEach((rule, schemaName) => { const visible = this.visibilityState.get(schemaName) || false; state[schemaName] = { visible }; if (!visible) { // Build reason string const conditions = rule.show_when || []; const annotations = this.getCurrentAnnotations(); const reasons = conditions.map(cond => { const actual = annotations[cond.schema]; return `${cond.schema} ${cond.operator} ${JSON.stringify(cond.value)} (actual: ${JSON.stringify(actual)})`; }); state[schemaName].reason = `Conditions not met (${rule.logic || 'all'}): ${reasons.join(', ')}`; } }); return state; } /** * Enable debug logging. */ enableDebug() { this.debug = true; console.log('[DisplayLogic] Debug mode enabled'); } /** * Disable debug logging. */ disableDebug() { this.debug = false; } } // Global instance let displayLogicManager = null; /** * Initialize the display logic manager. * Called automatically when the page loads. */ function initDisplayLogic() { if (displayLogicManager) { console.warn('[DisplayLogic] Already initialized'); return displayLogicManager; } displayLogicManager = new DisplayLogicManager(); displayLogicManager.initialize(); return displayLogicManager; } /** * Get the global display logic manager instance. */ function getDisplayLogicManager() { return displayLogicManager; }