/** * VariableScalesModal.jsx * ======================== * A clean, minimalist reference modal that displays the Clinical Data Dictionary * for all variables in the current disease module. * * Features: * - Slide-in drawer from the right (matches clinical aesthetic) * - Categorized accordion groups matching the form layout * - Columns: Feature Name · Type · Scale / Range · Description * - Dismissible via close button, backdrop click, or Escape key * - No external dependencies — pure Tailwind + React */ import { useState, useEffect, useCallback } from 'react'; import { X, ChevronDown, Search } from 'lucide-react'; import { lookupMedicalTerm } from '../utils/medicalDictionary'; /** * Format the validation constraints into a readable scale/range string. */ function formatScale(field) { const { minimum, maximum, enum: enumVals } = field.validation || {}; // Enum fields — show comma-separated options if (Array.isArray(enumVals) && enumVals.length > 0) { return enumVals.map(String).join(', '); } // Toggle/binary fields — show as 0/1 labels if (minimum === 0 && maximum === 1) { return '0 / 1'; } // Slider / number fields — show min–max range if (minimum !== undefined && maximum !== undefined) { const minStr = Number.isInteger(minimum) ? minimum.toString() : minimum.toFixed(1); const maxStr = Number.isInteger(maximum) ? maximum.toString() : maximum.toFixed(1); return `${minStr} – ${maxStr}`; } if (minimum !== undefined) { const minStr = Number.isInteger(minimum) ? minimum.toString() : minimum.toFixed(1); return `≥ ${minStr}`; } if (maximum !== undefined) { const maxStr = Number.isInteger(maximum) ? maximum.toString() : maximum.toFixed(1); return `≤ ${maxStr}`; } return '—'; } /** * Format the value type for display. */ function formatType(field) { const t = field.type || 'string'; if (t === 'integer') return 'Integer'; if (t === 'number') return 'Float'; if (t === 'string') return 'Categorical'; return t.charAt(0).toUpperCase() + t.slice(1); } /** * Get a short label hint for the scale (e.g., "Binary", "Ordinal 1-5"). */ function getScaleHint(field) { const { minimum, maximum, enum: enumVals } = field.validation || {}; if (Array.isArray(enumVals) && enumVals.length > 0) { return 'Categorical'; } if (minimum === 0 && maximum === 1) { return 'Binary'; } if (field.type === 'integer' && minimum !== undefined && maximum !== undefined) { const range = maximum - minimum; if (range <= 5) return `Ordinal ${minimum}–${maximum}`; if (range <= 12) return `Scale ${minimum}–${maximum}`; return 'Numeric range'; } if (field.type === 'number') { return 'Continuous'; } return ''; } /** * VariableScalesModal — slide-in drawer from the right. * * @param {{ * isOpen: boolean, * onClose: () => void, * fields: Array, * diseaseName: string, * categorizedFields?: Map>, * }} props */ export default function VariableScalesModal({ isOpen, onClose, fields = [], diseaseName = '', categorizedFields, }) { const [searchQuery, setSearchQuery] = useState(''); const [expandedCategories, setExpandedCategories] = useState(new Set()); // Expand all categories on open useEffect(() => { if (isOpen && categorizedFields) { setExpandedCategories(new Set(categorizedFields.keys())); } }, [isOpen, categorizedFields]); // Close on Escape key const handleKeyDown = useCallback((e) => { if (e.key === 'Escape') onClose(); }, [onClose]); useEffect(() => { if (isOpen) { document.addEventListener('keydown', handleKeyDown); document.body.style.overflow = 'hidden'; } return () => { document.removeEventListener('keydown', handleKeyDown); document.body.style.overflow = ''; }; }, [isOpen, handleKeyDown]); // Toggle category accordion const toggleCategory = (cat) => { setExpandedCategories((prev) => { const next = new Set(prev); if (next.has(cat)) next.delete(cat); else next.add(cat); return next; }); }; // Filter fields by search query const filteredFields = searchQuery.trim() ? fields.filter((f) => { const q = searchQuery.toLowerCase(); return ( f.name.toLowerCase().includes(q) || f.title.toLowerCase().includes(q) || (f.description && f.description.toLowerCase().includes(q)) || (lookupMedicalTerm(f.title) || '').toLowerCase().includes(q) ); }) : fields; // Re-group filtered fields into categories const filteredGroups = new Map(); if (categorizedFields && !searchQuery.trim()) { // Use the original categorized structure if no search for (const [cat, catFields] of categorizedFields) { filteredGroups.set(cat, catFields); } } else { // Assign filtered fields back to their categories for (const field of filteredFields) { const cat = field.category || 'General'; if (!filteredGroups.has(cat)) filteredGroups.set(cat, []); filteredGroups.get(cat).push(field); } } if (!isOpen) return null; const fieldCount = fields.length; return (
{/* Backdrop */} ); }