Spaces:
Sleeping
Sleeping
| /** | |
| * Plant Microsynteny Plotter - Frontend JavaScript | |
| * Handles UI interactions, API calls, and dynamic content | |
| */ | |
| // ============================================================================ | |
| // Configuration | |
| // ============================================================================ | |
| // Common genomes to show as checkboxes (top 9 frequently used species) | |
| const COMMON_GENOMES = [ | |
| 'arabidopsis_thaliana', | |
| 'aegilops_tauschii', | |
| 'glycine_max', | |
| 'hordeum_vulgare', | |
| 'oryza_sativa', | |
| 'solanum_lycopersicum', | |
| 'solanum_tuberosum', | |
| 'triticum_aestivum', | |
| 'setaria_italica' | |
| ]; | |
| // Example gene IDs for each genome (used as placeholder hints) | |
| // Keep in sync with genome_config.py in Scripts folder | |
| // Selected as ~200th gene from the first chromosome, at least 10 genes from chromosome end | |
| const EXAMPLE_GENE_IDS = { | |
| 'actinidia_chinensis': 'CEY00_Acc00322', | |
| 'aegilops_tauschii': 'AET1Gv20037200', | |
| 'arabidopsis_thaliana': 'AT1G02890', | |
| 'arachis_hypogaea': 'arahy.Tifrunner.gnm1.ann1.00W0BQ', | |
| 'brachypodium_distachyon': 'BRADI_4g38012v3', | |
| 'eucalyptus_grandis': 'Eucgr.L00047', | |
| 'fragaria_vesca': 'FvH4_1g01721', | |
| 'glycine_max': 'Glyma.01G020000', | |
| 'gossypium_hirsutum': 'Gohir.A01G016400', | |
| 'hordeum_vulgare': 'HORVU1Hr1G003260', | |
| 'lactuca_sativa': 'Lsat_1_v5_gn_0_720', | |
| 'lolium_perenne': 'KYUSg_chr2.52106', | |
| 'manihot_esculenta': 'Manes.01G017700', | |
| 'miscanthus_sinensis': 'Misin01G020000', | |
| 'oryza_barthii': 'OBART01G02020', | |
| 'oryza_rufipogon': 'ORUFI01G02010', | |
| 'oryza_sativa': 'LOC_Os01g03464', | |
| 'panicum_virgatum': 'Pavir.1KG026718', | |
| 'phaseolus_vulgaris': 'Phvul.001G019900', | |
| 'populus_trichocarpa': 'Potri.001G021200', | |
| 'prunus_dulcis': 'Prudul26B011767', | |
| 'prunus_persica': 'Prupe.1G020000', | |
| 'setaria_italica': 'Si020023m.g', | |
| 'solanum_lycopersicum': 'Solyc01G000195', | |
| 'solanum_tuberosum': 'Soltu.DM.01G002170', | |
| 'sorghum_bicolor': 'SORBI_3006G261800', | |
| 'thinopyrum_intermedium': 'Thint.J01G020000', | |
| 'triticum_aestivum': 'TraesCS1A02G019200', | |
| 'triticum_dicoccoides': 'TRIDC1AG002990', | |
| 'triticum_timopheevii': 'Tritim_EIv0.3_0002010', | |
| 'triticum_urartu': 'TuG1812G0100000200.01', | |
| 'vigna_radiata': 'Vradi01g02000', | |
| 'vigna_unguiculata': 'Vigun01g019000.v1.2', | |
| 'vitis_vinifera': 'Vitis01g00200' | |
| }; | |
| // ============================================================================ | |
| // State Management | |
| // ============================================================================ | |
| const state = { | |
| genomes: [], | |
| currentTab: 'usergenes', | |
| // User Genes state | |
| user: { | |
| queryGenome: '', | |
| genes: [{ id: 1, name: '', color: 'r', annotationType: 'none', customAnnotation: '' }], | |
| compGenomes: [], | |
| genomeOrder: [], | |
| geneCounter: 1, | |
| layouts: [], | |
| selectedLayout: null, | |
| genomeAssignments: [], // Mapping of comparison genomes to layout slots | |
| genomeAnnotations: {} // Cache for genome annotations | |
| }, | |
| // Custom Genome state | |
| custom: { | |
| runKey: null, | |
| selectedGenomes: [], | |
| jobStatus: null, | |
| statusPollInterval: null, | |
| // Plotting state (mirrors user state) | |
| plotRunKey: null, | |
| plotRunManifest: null, | |
| availableComparisons: [], | |
| genes: [{ id: 1, name: '', color: 'r', annotationType: 'none', customAnnotation: '' }], | |
| compGenomes: [], | |
| genomeOrder: [], | |
| geneCounter: 1, | |
| layouts: [], | |
| selectedLayout: null, | |
| genomeAssignments: [], | |
| exampleGenes: [], // Example gene names loaded from custom genome BED file | |
| // Pagination state for genomes list | |
| allGenomes: [], | |
| currentPage: 1, | |
| itemsPerPage: 10 | |
| }, | |
| // Custom Synteny (Multi-Genome) state | |
| customSynteny: { | |
| runKey: null, | |
| projectName: '', | |
| visibility: 'public', | |
| // Genome uploads (up to 5) | |
| genomes: [], // Array of {id, name, displayName, gff3File, pepFile, uploaded, geneCount, proteinCount} | |
| genomeCounter: 0, | |
| // Database genomes (up to 3) | |
| selectedDbGenomes: [], | |
| // Comparison pairs to run | |
| selectedPairs: [], // Array of {genome1, genome2} | |
| // MCscan job | |
| jobStatus: null, | |
| statusPollInterval: null, | |
| // Plotting state | |
| plotRunKey: null, | |
| plotRunManifest: null, | |
| availableGenomes: [], // All genomes in the project (custom + db) | |
| availableComparisons: [], // Pairs that were compared | |
| queryGenome: '', // Selected query genome for plotting | |
| genes: [{ id: 1, name: '', color: 'r', annotationType: 'none', customAnnotation: '' }], | |
| compGenomes: [], | |
| genomeOrder: [], | |
| geneCounter: 1, | |
| layouts: [], | |
| selectedLayout: null, | |
| genomeAssignments: [], | |
| exampleGenes: [], | |
| // Pagination state for projects list | |
| allProjects: [], | |
| currentPage: 1, | |
| itemsPerPage: 10 | |
| }, | |
| // Discovery state | |
| discovery: { | |
| queryGenome: '', | |
| compGenomes: [], | |
| isCustomGenome: false, // Whether query is from custom genomes | |
| customComparisonGenomes: null, // Comparison genomes from custom run manifest | |
| annotationSessionId: null, // Session ID for uploaded custom annotations | |
| availableTerms: [], // Annotation terms from genome or custom upload | |
| requiredTerms: [], // Selected required terms | |
| optionalTerms: [], // Selected optional terms | |
| minHits: 1, | |
| results: null, // Search results | |
| tsvData: null // TSV data for download | |
| } | |
| }; | |
| // ============================================================================ | |
| // Custom UI Module - Section Toggle and Layout Functions | |
| // ============================================================================ | |
| const CustomUI = { | |
| // Toggle MCscan block visibility | |
| toggleMcscanBlock() { | |
| const body = document.getElementById('mcscan-block-body'); | |
| const btn = document.getElementById('mcscan-block-toggle'); | |
| if (!body || !btn) return; | |
| const isHidden = body.style.display === 'none'; | |
| body.style.display = isHidden ? 'block' : 'none'; | |
| btn.textContent = isHidden ? '▼' : '▶'; | |
| }, | |
| // Toggle Plot block visibility | |
| togglePlotBlock() { | |
| const body = document.getElementById('plot-block-body'); | |
| const btn = document.getElementById('plot-block-toggle'); | |
| if (!body || !btn) return; | |
| const isHidden = body.style.display === 'none'; | |
| body.style.display = isHidden ? 'block' : 'none'; | |
| btn.textContent = isHidden ? '▼' : '▶'; | |
| }, | |
| // Open MCscan section, close Plot section | |
| openMcscanSection() { | |
| const mcscanBody = document.getElementById('mcscan-block-body'); | |
| const mcscanBtn = document.getElementById('mcscan-block-toggle'); | |
| const plotBlock = document.getElementById('custom-plot-block'); | |
| if (mcscanBody) mcscanBody.style.display = 'block'; | |
| if (mcscanBtn) mcscanBtn.textContent = '▼'; | |
| if (plotBlock) plotBlock.style.display = 'none'; | |
| }, | |
| // Open Plot section, close MCscan section | |
| openPlotSection() { | |
| const mcscanBody = document.getElementById('mcscan-block-body'); | |
| const mcscanBtn = document.getElementById('mcscan-block-toggle'); | |
| const plotBlock = document.getElementById('custom-plot-block'); | |
| const plotBody = document.getElementById('plot-block-body'); | |
| const plotBtn = document.getElementById('plot-block-toggle'); | |
| // Collapse MCscan section | |
| if (mcscanBody) mcscanBody.style.display = 'none'; | |
| if (mcscanBtn) mcscanBtn.textContent = '▶'; | |
| // Show and expand Plot section | |
| if (plotBlock) plotBlock.style.display = 'block'; | |
| if (plotBody) plotBody.style.display = 'block'; | |
| if (plotBtn) plotBtn.textContent = '▼'; | |
| }, | |
| // Update gene example hint text | |
| updateGeneExamples(exampleGenes) { | |
| const span = document.getElementById('custom-gene-examples'); | |
| if (!span) return; | |
| if (exampleGenes && exampleGenes.length > 0) { | |
| span.textContent = `(e.g., ${exampleGenes.slice(0, 3).join(', ')})`; | |
| } else { | |
| span.textContent = ''; | |
| } | |
| }, | |
| // Update Required Genes checkboxes (matching Microsynteny page format) | |
| updateRequiredGenesSelect() { | |
| const container = document.getElementById('custom-required-genes-container'); | |
| if (!container) return; | |
| const genes = state.custom.genes.filter(g => g.name.trim()); | |
| if (genes.length === 0) { | |
| container.innerHTML = '<span class="form-help" style="font-style: italic;">Enter genes above first</span>'; | |
| return; | |
| } | |
| container.innerHTML = genes.map(g => ` | |
| <label class="required-gene-item"> | |
| <input type="checkbox" class="required-gene-checkbox" value="${g.name}"> | |
| <span>${g.name}</span> | |
| </label> | |
| `).join(''); | |
| // Add click handler to toggle checked class for visual styling | |
| container.querySelectorAll('.required-gene-item').forEach(item => { | |
| const checkbox = item.querySelector('input[type="checkbox"]'); | |
| checkbox.addEventListener('change', () => { | |
| item.classList.toggle('checked', checkbox.checked); | |
| }); | |
| }); | |
| }, | |
| // Switch between Upload New, Load Sequences, and Load Existing tabs | |
| switchMcscanTab(tabName) { | |
| const uploadTab = document.getElementById('mcscan-tab-upload'); | |
| const sequencesTab = document.getElementById('mcscan-tab-sequences'); | |
| const loadTab = document.getElementById('mcscan-tab-load'); | |
| const uploadContent = document.getElementById('mcscan-content-upload'); | |
| const sequencesContent = document.getElementById('mcscan-content-sequences'); | |
| const loadContent = document.getElementById('mcscan-content-load'); | |
| if (!uploadTab || !loadTab || !uploadContent || !loadContent) return; | |
| // Reset all tabs | |
| const resetTabs = () => { | |
| [uploadTab, sequencesTab, loadTab].forEach(tab => { | |
| if (tab) { | |
| tab.style.background = 'var(--color-bg-secondary)'; | |
| tab.style.color = 'var(--color-text)'; | |
| } | |
| }); | |
| [uploadContent, sequencesContent, loadContent].forEach(content => { | |
| if (content) content.style.display = 'none'; | |
| }); | |
| }; | |
| resetTabs(); | |
| if (tabName === 'upload') { | |
| uploadTab.style.background = 'var(--color-accent-primary)'; | |
| uploadTab.style.color = 'white'; | |
| uploadContent.style.display = 'block'; | |
| } else if (tabName === 'sequences') { | |
| if (sequencesTab) { | |
| sequencesTab.style.background = 'var(--color-accent-primary)'; | |
| sequencesTab.style.color = 'white'; | |
| } | |
| if (sequencesContent) sequencesContent.style.display = 'block'; | |
| } else { | |
| loadTab.style.background = 'var(--color-accent-primary)'; | |
| loadTab.style.color = 'white'; | |
| loadContent.style.display = 'block'; | |
| // Refresh the genomes list when switching to load tab | |
| Handlers.refreshCustomGenomesList(); | |
| } | |
| }, | |
| // Toggle MCscan Advanced Options visibility | |
| toggleMcscanOptions(prefix) { | |
| const body = document.getElementById(`${prefix}-options-body`); | |
| const arrow = document.getElementById(`${prefix}-options-arrow`); | |
| if (!body || !arrow) return; | |
| const isHidden = body.style.display === 'none'; | |
| body.style.display = isHidden ? 'block' : 'none'; | |
| arrow.style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)'; | |
| }, | |
| // Reset MCscan options to defaults | |
| resetMcscanOptions(prefix) { | |
| const cscoreInput = document.getElementById(`${prefix}-cscore`); | |
| const minAnchorInput = document.getElementById(`${prefix}-min-anchor`); | |
| const gapLengthInput = document.getElementById(`${prefix}-gap-length`); | |
| if (cscoreInput) cscoreInput.value = '0.99'; | |
| if (minAnchorInput) minAnchorInput.value = '4'; | |
| if (gapLengthInput) gapLengthInput.value = '20'; | |
| }, | |
| // Get MCscan options from form | |
| getMcscanOptions(prefix) { | |
| const cscoreInput = document.getElementById(`${prefix}-cscore`); | |
| const minAnchorInput = document.getElementById(`${prefix}-min-anchor`); | |
| const gapLengthInput = document.getElementById(`${prefix}-gap-length`); | |
| return { | |
| cscore: cscoreInput ? parseFloat(cscoreInput.value) : null, | |
| min_anchor: minAnchorInput ? parseInt(minAnchorInput.value) : null, | |
| gap_length: gapLengthInput ? parseInt(gapLengthInput.value) : null | |
| }; | |
| } | |
| }; | |
| // ============================================================================ | |
| // Custom Synteny UI Module - Multi-Genome Comparison | |
| // ============================================================================ | |
| const CustomSyntenyUI = { | |
| // Toggle MCscan block visibility | |
| toggleMcscanBlock() { | |
| const body = document.getElementById('csynteny-mcscan-block-body'); | |
| const btn = document.getElementById('csynteny-mcscan-block-toggle'); | |
| if (!body || !btn) return; | |
| const isHidden = body.style.display === 'none'; | |
| body.style.display = isHidden ? 'block' : 'none'; | |
| btn.textContent = isHidden ? '▼' : '▶'; | |
| }, | |
| // Toggle Plot block visibility | |
| togglePlotBlock() { | |
| const body = document.getElementById('csynteny-plot-block-body'); | |
| const btn = document.getElementById('csynteny-plot-block-toggle'); | |
| if (!body || !btn) return; | |
| const isHidden = body.style.display === 'none'; | |
| body.style.display = isHidden ? 'block' : 'none'; | |
| btn.textContent = isHidden ? '▼' : '▶'; | |
| }, | |
| // Switch between Upload and Load Existing tabs | |
| switchTab(tabName) { | |
| const uploadTab = document.getElementById('csynteny-tab-upload'); | |
| const loadTab = document.getElementById('csynteny-tab-load'); | |
| const uploadContent = document.getElementById('csynteny-content-upload'); | |
| const loadContent = document.getElementById('csynteny-content-load'); | |
| if (!uploadTab || !loadTab || !uploadContent || !loadContent) return; | |
| // Reset all tabs | |
| [uploadTab, loadTab].forEach(tab => { | |
| tab.style.background = 'var(--color-bg-secondary)'; | |
| tab.style.color = 'var(--color-text)'; | |
| }); | |
| [uploadContent, loadContent].forEach(content => { | |
| content.style.display = 'none'; | |
| }); | |
| if (tabName === 'upload') { | |
| uploadTab.style.background = 'var(--color-accent-primary)'; | |
| uploadTab.style.color = 'white'; | |
| uploadContent.style.display = 'block'; | |
| } else { | |
| loadTab.style.background = 'var(--color-accent-primary)'; | |
| loadTab.style.color = 'white'; | |
| loadContent.style.display = 'block'; | |
| // Refresh the projects list when switching to load tab | |
| CustomSyntenyHandlers.refreshProjectsList(); | |
| } | |
| }, | |
| // Open MCscan section, close Plot section | |
| openMcscanSection() { | |
| const mcscanBody = document.getElementById('csynteny-mcscan-block-body'); | |
| const mcscanBtn = document.getElementById('csynteny-mcscan-block-toggle'); | |
| const plotBlock = document.getElementById('csynteny-plot-block'); | |
| if (mcscanBody) mcscanBody.style.display = 'block'; | |
| if (mcscanBtn) mcscanBtn.textContent = '▼'; | |
| if (plotBlock) plotBlock.style.display = 'none'; | |
| }, | |
| // Open Plot section, close MCscan section | |
| openPlotSection() { | |
| const mcscanBody = document.getElementById('csynteny-mcscan-block-body'); | |
| const mcscanBtn = document.getElementById('csynteny-mcscan-block-toggle'); | |
| const plotBlock = document.getElementById('csynteny-plot-block'); | |
| const plotBody = document.getElementById('csynteny-plot-block-body'); | |
| const plotBtn = document.getElementById('csynteny-plot-block-toggle'); | |
| // Collapse MCscan section | |
| if (mcscanBody) mcscanBody.style.display = 'none'; | |
| if (mcscanBtn) mcscanBtn.textContent = '▶'; | |
| // Show and expand Plot section | |
| if (plotBlock) plotBlock.style.display = 'block'; | |
| if (plotBody) plotBody.style.display = 'block'; | |
| if (plotBtn) plotBtn.textContent = '▼'; | |
| }, | |
| // Toggle MCscan Advanced Options visibility | |
| toggleMcscanOptions() { | |
| const body = document.getElementById('csynteny-options-body'); | |
| const arrow = document.getElementById('csynteny-options-arrow'); | |
| if (!body || !arrow) return; | |
| const isHidden = body.style.display === 'none'; | |
| body.style.display = isHidden ? 'block' : 'none'; | |
| arrow.style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)'; | |
| }, | |
| // Reset MCscan options to defaults | |
| resetMcscanOptions() { | |
| const cscoreInput = document.getElementById('csynteny-cscore'); | |
| const minAnchorInput = document.getElementById('csynteny-min-anchor'); | |
| const gapLengthInput = document.getElementById('csynteny-gap-length'); | |
| if (cscoreInput) cscoreInput.value = '0.99'; | |
| if (minAnchorInput) minAnchorInput.value = '4'; | |
| if (gapLengthInput) gapLengthInput.value = '20'; | |
| }, | |
| // Get MCscan options from form | |
| getMcscanOptions() { | |
| const cscoreInput = document.getElementById('csynteny-cscore'); | |
| const minAnchorInput = document.getElementById('csynteny-min-anchor'); | |
| const gapLengthInput = document.getElementById('csynteny-gap-length'); | |
| return { | |
| cscore: cscoreInput ? parseFloat(cscoreInput.value) : null, | |
| min_anchor: minAnchorInput ? parseInt(minAnchorInput.value) : null, | |
| gap_length: gapLengthInput ? parseInt(gapLengthInput.value) : null | |
| }; | |
| }, | |
| // Render genome upload cards | |
| renderGenomeCards() { | |
| const container = document.getElementById('csynteny-genome-cards'); | |
| if (!container) return; | |
| const genomes = state.customSynteny.genomes; | |
| container.innerHTML = genomes.map((genome, index) => ` | |
| <div class="genome-upload-card" data-genome-id="${genome.id}" | |
| style="margin-bottom: var(--space-md); padding: var(--space-md); background: var(--color-bg-secondary); border-radius: var(--radius-md); border: 1px solid ${genome.uploaded ? 'var(--color-accent-success)' : 'var(--color-border)'};"> | |
| <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-sm);"> | |
| <h5 style="margin: 0;">Genome ${index + 1} ${genome.uploaded ? '✓' : ''}</h5> | |
| </div> | |
| <div class="form-row" style="display: flex; gap: var(--space-md);"> | |
| <div class="form-group" style="flex: 1;"> | |
| <label>Display Name <span style="color: var(--color-accent-danger);">*</span></label> | |
| <input type="text" class="form-input csynteny-genome-name" data-genome-id="${genome.id}" | |
| value="${genome.displayName || ''}" placeholder="e.g., Wheat v2.0" ${genome.uploaded ? 'disabled' : ''}> | |
| </div> | |
| <div class="form-group" style="flex: 1;"> | |
| <label>GFF3 File</label> | |
| <input type="file" class="form-input csynteny-gff3-file" data-genome-id="${genome.id}" | |
| accept=".gff3,.gff" ${genome.uploaded ? 'disabled' : ''}> | |
| </div> | |
| <div class="form-group" style="flex: 1;"> | |
| <label>Protein FASTA</label> | |
| <input type="file" class="form-input csynteny-pep-file" data-genome-id="${genome.id}" | |
| accept=".pep,.fa,.fasta,.faa" ${genome.uploaded ? 'disabled' : ''}> | |
| </div> | |
| </div> | |
| ${genome.uploaded ? `<div class="form-help" style="color: var(--color-accent-success);">✓ Uploaded: ${genome.geneCount} genes, ${genome.proteinCount} proteins</div>` : ''} | |
| </div> | |
| `).join(''); | |
| // Update add button state | |
| const addBtn = document.getElementById('csynteny-add-genome-btn'); | |
| if (addBtn) { | |
| addBtn.disabled = genomes.length >= 5; | |
| } | |
| // Update remove button state | |
| const removeBtn = document.getElementById('csynteny-remove-genome-btn'); | |
| if (removeBtn) { | |
| removeBtn.disabled = genomes.length <= 2; | |
| } | |
| // Update upload button state | |
| this.updateUploadButtonState(); | |
| }, | |
| // Populate database genomes checkboxes | |
| populateDbGenomes() { | |
| const container = document.getElementById('csynteny-db-genomes'); | |
| if (!container) return; | |
| container.innerHTML = state.genomes.map(g => ` | |
| <label class="genome-checkbox" data-genome="${g.id}"> | |
| <input type="checkbox" class="csynteny-db-genome-cb" value="${g.id}" | |
| onchange="CustomSyntenyHandlers.onDbGenomeChange()"> | |
| <span class="genome-label"> | |
| <span class="genome-display-name">${g.name}</span> | |
| <span class="genome-scientific-name">${g.scientific_name}</span> | |
| </span> | |
| </label> | |
| `).join(''); | |
| }, | |
| // Update the comparison pairs grid | |
| updateComparisonPairs() { | |
| const container = document.getElementById('csynteny-pairs-grid'); | |
| const section = document.getElementById('csynteny-comparison-section'); | |
| if (!container || !section) return; | |
| // Collect all genomes: custom + database | |
| const customGenomes = state.customSynteny.genomes.filter(g => g.displayName); | |
| const dbGenomes = state.customSynteny.selectedDbGenomes; | |
| // Need at least 2 genomes total | |
| if (customGenomes.length + dbGenomes.length < 2) { | |
| section.style.display = 'none'; | |
| state.customSynteny.selectedPairs = []; | |
| return; | |
| } | |
| section.style.display = 'block'; | |
| // Generate all possible pairs | |
| const allPairs = []; | |
| const allGenomeIds = [ | |
| ...customGenomes.map(g => ({ id: `custom_${g.id}`, name: g.displayName, isCustom: true })), | |
| ...dbGenomes.map(id => { | |
| const g = state.genomes.find(genome => genome.id === id); | |
| return { id: id, name: g ? g.name : id, isCustom: false }; | |
| }) | |
| ]; | |
| for (let i = 0; i < allGenomeIds.length; i++) { | |
| for (let j = i + 1; j < allGenomeIds.length; j++) { | |
| allPairs.push({ | |
| genome1: allGenomeIds[i], | |
| genome2: allGenomeIds[j] | |
| }); | |
| } | |
| } | |
| container.innerHTML = allPairs.map((pair, idx) => ` | |
| <label class="genome-checkbox checked" data-pair-idx="${idx}"> | |
| <input type="checkbox" class="csynteny-pair-cb" value="${idx}" checked | |
| data-genome1="${pair.genome1.id}" data-genome2="${pair.genome2.id}"> | |
| <span class="genome-label"> | |
| <span class="genome-display-name">${pair.genome1.name} ↔ ${pair.genome2.name}</span> | |
| </span> | |
| </label> | |
| `).join(''); | |
| // Initialize selected pairs (all selected by default) | |
| state.customSynteny.selectedPairs = allPairs.map((pair, idx) => ({ | |
| idx: idx, | |
| genome1: pair.genome1.id, | |
| genome2: pair.genome2.id | |
| })); | |
| // Add checkbox listeners | |
| container.querySelectorAll('.csynteny-pair-cb').forEach(cb => { | |
| cb.addEventListener('change', (e) => { | |
| const label = e.target.closest('.genome-checkbox'); | |
| label.classList.toggle('checked', e.target.checked); | |
| CustomSyntenyHandlers.onPairSelectionChange(); | |
| }); | |
| }); | |
| this.updateUploadButtonState(); | |
| }, | |
| // Update the upload button state | |
| updateUploadButtonState() { | |
| const btn = document.getElementById('csynteny-upload-run-btn'); | |
| const statusText = document.querySelector('#csynteny-upload-status .status-text'); | |
| if (!btn) return; | |
| const MAX_NAME_LENGTH = 100; // Max chars for names (must match backend) | |
| const genomes = state.customSynteny.genomes; | |
| const projectName = document.getElementById('csynteny-project-name')?.value.trim(); | |
| // Check if at least 2 genomes have display names and files | |
| const validGenomes = genomes.filter(g => g.displayName); | |
| const genomesWithFiles = genomes.filter(g => g.gff3File && g.pepFile); | |
| let canUpload = false; | |
| let message = ''; | |
| if (!projectName) { | |
| message = 'Enter a project name'; | |
| } else if (projectName.length > MAX_NAME_LENGTH) { | |
| message = `Project name must be ${MAX_NAME_LENGTH} characters or less`; | |
| } else if (validGenomes.length < 2) { | |
| message = 'Add at least 2 genomes with display names'; | |
| } else if (validGenomes.some(g => g.displayName.length > MAX_NAME_LENGTH)) { | |
| message = `Genome display names must be ${MAX_NAME_LENGTH} characters or less`; | |
| } else if (genomesWithFiles.length < 2) { | |
| message = 'Select GFF3 and PEP files for at least 2 genomes'; | |
| } else { | |
| const totalPairs = state.customSynteny.selectedPairs.length; | |
| canUpload = totalPairs > 0; | |
| message = canUpload ? `Ready: ${validGenomes.length} genomes, ${totalPairs} comparisons` : 'Select at least one comparison pair'; | |
| } | |
| btn.disabled = !canUpload; | |
| if (statusText) statusText.textContent = message; | |
| }, | |
| // Update gene example hints | |
| updateGeneExamples(exampleGenes) { | |
| const span = document.getElementById('csynteny-gene-examples'); | |
| if (!span) return; | |
| if (exampleGenes && exampleGenes.length > 0) { | |
| span.textContent = `(e.g., ${exampleGenes.slice(0, 3).join(', ')})`; | |
| } else { | |
| span.textContent = ''; | |
| } | |
| } | |
| }; | |
| // ============================================================================ | |
| // API Functions | |
| // ============================================================================ | |
| const API = { | |
| async getGenomes() { | |
| try { | |
| const response = await fetch('/api/genomes'); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error fetching genomes:', error); | |
| return []; | |
| } | |
| }, | |
| async getGeneAnnotation(genome, geneId) { | |
| try { | |
| const response = await fetch(`/api/annotation/${genome}/${encodeURIComponent(geneId)}`); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error fetching gene annotation:', error); | |
| return { annotation: '' }; | |
| } | |
| }, | |
| async plotUserGenes(data) { | |
| try { | |
| const response = await fetch('/api/plot/usergenes', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(data) | |
| }); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error generating plot:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async batchMatch(data) { | |
| try { | |
| const response = await fetch('/api/batch-match', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(data) | |
| }); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error running batch match:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async searchHits(data) { | |
| try { | |
| const response = await fetch('/api/search-hits', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(data) | |
| }); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error searching hits:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async getLayouts(n) { | |
| try { | |
| const response = await fetch(`/api/layouts/${n}`); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error fetching layouts:', error); | |
| return []; | |
| } | |
| }, | |
| // Custom Genome API functions | |
| async customUpload(formData) { | |
| try { | |
| const response = await fetch('/api/custom/upload', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error uploading custom genome:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async customRunMcscan(data) { | |
| try { | |
| const response = await fetch('/api/custom/run-mcscan', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(data) | |
| }); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error starting MCscan:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async customStatus(runKey) { | |
| try { | |
| const response = await fetch(`/api/custom/status/${runKey}`); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error getting status:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async customLookup(runKey) { | |
| try { | |
| const response = await fetch(`/api/custom/lookup/${runKey}`); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error looking up run:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async customGenomes() { | |
| try { | |
| const response = await fetch('/api/custom/genomes'); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error listing custom genomes:', error); | |
| return { success: false, genomes: [] }; | |
| } | |
| }, | |
| async customDelete(runKey) { | |
| try { | |
| const response = await fetch(`/api/custom/genomes/${runKey}`, { | |
| method: 'DELETE' | |
| }); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error deleting custom genome:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async customPlot(data) { | |
| try { | |
| const response = await fetch('/api/custom/plot', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(data) | |
| }); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error generating custom plot:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async customGenes(runKey) { | |
| try { | |
| const response = await fetch(`/api/custom/genes/${runKey}`); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error getting genes:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async customUploadSequences(formData) { | |
| try { | |
| const response = await fetch('/api/custom/upload-sequences', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error uploading sequences:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| // Custom Synteny (Multi-Genome) API functions | |
| async customSyntenyUpload(formData) { | |
| try { | |
| const response = await fetch('/api/custom-synteny/upload', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error uploading custom synteny genomes:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async customSyntenyRunMcscan(data) { | |
| try { | |
| const response = await fetch('/api/custom-synteny/run-mcscan', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(data) | |
| }); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error starting custom synteny MCscan:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async customSyntenyStatus(runKey) { | |
| try { | |
| const response = await fetch(`/api/custom-synteny/status/${runKey}`); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error getting custom synteny status:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async customSyntenyLookup(runKey) { | |
| try { | |
| const response = await fetch(`/api/custom-synteny/lookup/${runKey}`); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error looking up custom synteny run:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async customSyntenyProjects() { | |
| try { | |
| const response = await fetch('/api/custom-synteny/projects'); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error listing custom synteny projects:', error); | |
| return { success: false, projects: [] }; | |
| } | |
| }, | |
| async customSyntenyDelete(runKey) { | |
| try { | |
| const response = await fetch(`/api/custom-synteny/projects/${runKey}`, { | |
| method: 'DELETE' | |
| }); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error deleting custom synteny project:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async customSyntenyPlot(data) { | |
| try { | |
| const response = await fetch('/api/custom-synteny/plot', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(data) | |
| }); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error generating custom synteny plot:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async customSyntenyGenes(runKey, genomeKey) { | |
| try { | |
| const response = await fetch(`/api/custom-synteny/genes/${runKey}/${genomeKey}`); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error getting custom synteny genes:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| // Discovery API functions | |
| async discoveryAnnotations(genome) { | |
| try { | |
| const response = await fetch(`/api/discovery/annotations/${genome}`); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error fetching discovery annotations:', error); | |
| return { success: false, error: error.message, terms: [] }; | |
| } | |
| }, | |
| async discoveryUploadAnnotations(formData) { | |
| try { | |
| const response = await fetch('/api/discovery/upload-annotations', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error uploading annotations:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async discoverySearch(data) { | |
| try { | |
| const response = await fetch('/api/discovery/search', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(data) | |
| }); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error running discovery search:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| async discoveryCheckAnnotations(genome) { | |
| try { | |
| const response = await fetch(`/api/discovery/check-genome-annotations/${genome}`); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error checking genome annotations:', error); | |
| return { success: false, error: error.message }; | |
| } | |
| }, | |
| // Get full annotations for Annotation Search tab | |
| async discoveryFullAnnotations(genome) { | |
| try { | |
| const response = await fetch(`/api/discovery/full-annotations/${genome}`); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error fetching full annotations:', error); | |
| return { success: false, error: error.message, annotations: [] }; | |
| } | |
| }, | |
| // Get gene names for Paralogous Search tab | |
| async discoveryGeneNames(genome) { | |
| try { | |
| const response = await fetch(`/api/discovery/gene-names/${genome}`); | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error fetching gene names:', error); | |
| return { success: false, error: error.message, genes: [] }; | |
| } | |
| } | |
| }; | |
| // ============================================================================ | |
| // UI Functions | |
| // ============================================================================ | |
| const UI = { | |
| // Tab switching | |
| switchTab(tabId) { | |
| // Scroll to top when switching tabs | |
| window.scrollTo(0, 0); | |
| // Update nav items | |
| document.querySelectorAll('.nav-item').forEach(item => { | |
| item.classList.toggle('active', item.dataset.tab === tabId); | |
| }); | |
| // Update tab content | |
| document.querySelectorAll('.tab-content').forEach(content => { | |
| content.classList.toggle('active', content.id === `tab-${tabId}`); | |
| }); | |
| state.currentTab = tabId; | |
| // Save current tab to sessionStorage for persistence on reload | |
| sessionStorage.setItem('plantmsyn-current-tab', tabId); | |
| }, | |
| // Populate genome select dropdowns | |
| populateGenomeSelects(genomes) { | |
| const selects = ['user-query-genome']; | |
| selects.forEach(selectId => { | |
| const select = document.getElementById(selectId); | |
| if (select) { | |
| select.innerHTML = '<option value="">Select a genome...</option>' + | |
| genomes.map(g => `<option value="${g.id}">${g.name}</option>`).join(''); | |
| } | |
| }); | |
| // Populate about page genome list | |
| const aboutList = document.getElementById('about-genome-list'); | |
| if (aboutList) { | |
| aboutList.innerHTML = genomes.map(g => ` | |
| <div class="genome-item"> | |
| <strong>${g.name}</strong> | |
| <em>${g.id.replace(/_/g, ' ')}</em> | |
| </div> | |
| `).join(''); | |
| } | |
| }, | |
| // Populate comparison genome checkboxes (common genomes only) | |
| populateCompGenomes(containerId, queryGenome) { | |
| const container = document.getElementById(containerId); | |
| if (!container) return; | |
| // Filter to common genomes only (excluding query genome) | |
| const commonGenomesToShow = state.genomes | |
| .filter(g => g.id !== queryGenome && COMMON_GENOMES.includes(g.id)); | |
| // Genomes not in common list (for dropdown) | |
| const additionalGenomes = state.genomes | |
| .filter(g => g.id !== queryGenome && !COMMON_GENOMES.includes(g.id)); | |
| // Populate common genomes as checkboxes | |
| container.innerHTML = commonGenomesToShow | |
| .map(g => ` | |
| <label class="genome-checkbox" data-genome="${g.id}"> | |
| <input type="checkbox" value="${g.id}"> | |
| <span class="genome-label"> | |
| <span class="genome-display-name">${g.name}</span> | |
| <span class="genome-scientific-name">${g.scientific_name}</span> | |
| </span> | |
| </label> | |
| `).join(''); | |
| // Populate additional genomes dropdown | |
| const additionalSelect = document.getElementById('additional-genomes-select'); | |
| if (additionalSelect) { | |
| additionalSelect.innerHTML = '<option value="">-- Select additional genome --</option>' + | |
| additionalGenomes.map(g => `<option value="${g.id}">${g.name}</option>`).join(''); | |
| // Add change handler for dropdown | |
| additionalSelect.onchange = (e) => { | |
| if (e.target.value) { | |
| this.addAdditionalGenome(e.target.value); | |
| e.target.value = ''; // Reset dropdown | |
| } | |
| }; | |
| } | |
| // Clear additional genomes list | |
| const additionalList = document.getElementById('additional-genomes-list'); | |
| if (additionalList) { | |
| additionalList.innerHTML = ''; | |
| } | |
| // Add event listeners for common genome checkboxes | |
| container.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { | |
| checkbox.addEventListener('change', (e) => { | |
| const label = e.target.closest('.genome-checkbox'); | |
| label.classList.toggle('checked', e.target.checked); | |
| this.updateUserGenomeOrder(); | |
| }); | |
| }); | |
| }, | |
| // Add an additional genome from dropdown | |
| addAdditionalGenome(genomeId) { | |
| const genome = state.genomes.find(g => g.id === genomeId); | |
| if (!genome) return; | |
| const additionalList = document.getElementById('additional-genomes-list'); | |
| if (!additionalList) return; | |
| // Check if already added | |
| if (additionalList.querySelector(`[data-genome="${genomeId}"]`)) { | |
| return; | |
| } | |
| const label = document.createElement('label'); | |
| label.className = 'genome-checkbox checked'; | |
| label.dataset.genome = genomeId; | |
| label.innerHTML = ` | |
| <input type="checkbox" value="${genomeId}" checked> | |
| <span class="genome-label"> | |
| <span class="genome-display-name">${genome.name}</span> | |
| <span class="genome-scientific-name">${genome.scientific_name}</span> | |
| </span> | |
| <button type="button" class="remove-additional-genome" style="margin-left: 8px; background: none; border: none; cursor: pointer; color: var(--color-text-muted);">✕</button> | |
| `; | |
| // Add checkbox change handler | |
| const checkbox = label.querySelector('input'); | |
| checkbox.addEventListener('change', () => { | |
| label.classList.toggle('checked', checkbox.checked); | |
| this.updateUserGenomeOrder(); | |
| }); | |
| // Add remove button handler | |
| const removeBtn = label.querySelector('.remove-additional-genome'); | |
| removeBtn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| label.remove(); | |
| this.updateUserGenomeOrder(); | |
| }); | |
| additionalList.appendChild(label); | |
| this.updateUserGenomeOrder(); | |
| }, | |
| // Update genome order list (User Genes) | |
| updateUserGenomeOrder() { | |
| const container = document.getElementById('user-comp-genomes'); | |
| const additionalList = document.getElementById('additional-genomes-list'); | |
| // Get selected from common genomes | |
| const commonSelected = Array.from(container.querySelectorAll('input:checked')) | |
| .map(cb => cb.value); | |
| // Get selected from additional genomes | |
| const additionalSelected = additionalList ? | |
| Array.from(additionalList.querySelectorAll('input:checked')).map(cb => cb.value) : []; | |
| const allSelected = [...commonSelected, ...additionalSelected]; | |
| state.user.compGenomes = allSelected; | |
| state.user.genomeOrder = allSelected; | |
| this.updateUserCommandPreview(); | |
| this.loadUserLayouts(); | |
| }, | |
| // Load and display layout options | |
| async loadUserLayouts() { | |
| const compCount = state.user.compGenomes.length; | |
| const totalGenomes = compCount + 1; // +1 for query genome | |
| const container = document.getElementById('user-layout-options'); | |
| const previewSection = document.getElementById('user-layout-preview-section'); | |
| if (compCount === 0) { | |
| container.innerHTML = '<p class="empty-message">Select comparison genomes first</p>'; | |
| previewSection.style.display = 'none'; | |
| state.user.layouts = []; | |
| state.user.selectedLayout = null; | |
| state.user.genomeAssignments = []; | |
| return; | |
| } | |
| state.user.layouts = await API.getLayouts(totalGenomes); | |
| container.innerHTML = state.user.layouts.map((layout, index) => ` | |
| <div class="layout-option" data-index="${index}" data-layout="${layout.layout.join(',')}"> | |
| ${layout.layout.map(count => ` | |
| <div class="layout-option-row"> | |
| ${Array(count).fill('<div class="layout-option-slot"></div>').join('')} | |
| </div> | |
| `).join('')} | |
| <span class="layout-option-name">${layout.name}</span> | |
| </div> | |
| `).join(''); | |
| // Add click listeners | |
| container.querySelectorAll('.layout-option').forEach(opt => { | |
| opt.addEventListener('click', () => this.selectLayout(parseInt(opt.dataset.index))); | |
| }); | |
| // Auto-select first layout | |
| if (state.user.layouts.length > 0) { | |
| this.selectLayout(0); | |
| } | |
| }, | |
| // Select a layout and show the assignment grid | |
| selectLayout(index) { | |
| const layout = state.user.layouts[index]; | |
| state.user.selectedLayout = layout; | |
| // Update visual selection | |
| document.querySelectorAll('.layout-option').forEach((opt, i) => { | |
| opt.classList.toggle('selected', i === index); | |
| }); | |
| // Show and populate the grid | |
| const previewSection = document.getElementById('user-layout-preview-section'); | |
| previewSection.style.display = 'block'; | |
| this.renderLayoutGrid(); | |
| this.updateUserCommandPreview(); | |
| }, | |
| // Render the interactive layout grid | |
| renderLayoutGrid() { | |
| const grid = document.getElementById('user-layout-grid'); | |
| const availableContainer = document.getElementById('user-available-genomes'); | |
| const layout = state.user.selectedLayout; | |
| const queryGenome = state.genomes.find(g => g.id === state.user.queryGenome); | |
| if (!layout) return; | |
| // Total slots needed = comparison genomes + query genome | |
| const totalSlots = state.user.compGenomes.length + 1; | |
| // Initialize genome assignments if empty or wrong size | |
| if (!state.user.genomeAssignments || state.user.genomeAssignments.length !== totalSlots) { | |
| state.user.genomeAssignments = new Array(totalSlots).fill(null); | |
| } | |
| // Build grid HTML - all rows are equal, query genome goes anywhere | |
| let gridHtml = ''; | |
| let slotIndex = 0; | |
| layout.layout.forEach((count, rowIdx) => { | |
| let slotsHtml = ''; | |
| for (let i = 0; i < count; i++) { | |
| const genome = state.user.genomeAssignments[slotIndex]; | |
| let genomeInfo = null; | |
| let isQuery = false; | |
| if (genome === state.user.queryGenome) { | |
| genomeInfo = queryGenome; | |
| isQuery = true; | |
| } else if (genome) { | |
| genomeInfo = state.genomes.find(g => g.id === genome); | |
| } | |
| if (genome && genomeInfo) { | |
| slotsHtml += ` | |
| <div class="layout-slot has-genome" data-slot="${slotIndex}"> | |
| <div class="genome-chip ${isQuery ? 'query-genome' : ''}" draggable="true" data-genome="${genome}"> | |
| ${isQuery ? '🌾 ' : ''}${genomeInfo.name} | |
| </div> | |
| </div> | |
| `; | |
| } else { | |
| slotsHtml += `<div class="layout-slot empty" data-slot="${slotIndex}">Drop genome</div>`; | |
| } | |
| slotIndex++; | |
| } | |
| gridHtml += ` | |
| <div class="layout-row"> | |
| <span class="layout-row-label">Row ${rowIdx + 1}</span> | |
| <div class="layout-slots">${slotsHtml}</div> | |
| </div> | |
| `; | |
| }); | |
| grid.innerHTML = gridHtml; | |
| // Available genomes = query + comparisons, minus those already assigned | |
| const assignedGenomes = state.user.genomeAssignments.filter(g => g); | |
| const allGenomes = [state.user.queryGenome, ...state.user.compGenomes]; | |
| const unassignedGenomes = allGenomes.filter(g => !assignedGenomes.includes(g)); | |
| availableContainer.innerHTML = unassignedGenomes.length > 0 | |
| ? unassignedGenomes.map(genomeId => { | |
| const g = state.genomes.find(genome => genome.id === genomeId); | |
| const isQuery = genomeId === state.user.queryGenome; | |
| return `<div class="genome-chip ${isQuery ? 'query-genome' : ''}" draggable="true" data-genome="${genomeId}">${isQuery ? '🌾 ' : ''}${g ? g.name : genomeId}</div>`; | |
| }).join('') | |
| : '<span class="empty-message">All genomes assigned</span>'; | |
| // Setup drag and drop | |
| this.setupLayoutDragDrop(); | |
| }, | |
| // Setup drag and drop for layout assignment | |
| setupLayoutDragDrop() { | |
| const chips = document.querySelectorAll('.genome-chip[draggable="true"]'); | |
| const slots = document.querySelectorAll('.layout-slot'); | |
| const availableContainer = document.getElementById('user-available-genomes'); | |
| chips.forEach(chip => { | |
| chip.addEventListener('dragstart', (e) => { | |
| e.dataTransfer.setData('genome', chip.dataset.genome); | |
| chip.classList.add('dragging'); | |
| }); | |
| chip.addEventListener('dragend', () => { | |
| chip.classList.remove('dragging'); | |
| }); | |
| }); | |
| slots.forEach(slot => { | |
| slot.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| slot.classList.add('drag-over'); | |
| }); | |
| slot.addEventListener('dragleave', () => { | |
| slot.classList.remove('drag-over'); | |
| }); | |
| slot.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| slot.classList.remove('drag-over'); | |
| const genome = e.dataTransfer.getData('genome'); | |
| const slotIdx = parseInt(slot.dataset.slot); | |
| // Remove from previous position if it was assigned | |
| const prevIdx = state.user.genomeAssignments.indexOf(genome); | |
| if (prevIdx !== -1) { | |
| state.user.genomeAssignments[prevIdx] = null; | |
| } | |
| // If slot already has a genome, move it to available | |
| const existingGenome = state.user.genomeAssignments[slotIdx]; | |
| if (existingGenome) { | |
| // It goes back to available | |
| } | |
| state.user.genomeAssignments[slotIdx] = genome; | |
| this.renderLayoutGrid(); | |
| this.updateUserCommandPreview(); | |
| }); | |
| }); | |
| // Make available container a drop zone to unassign | |
| availableContainer.addEventListener('dragover', (e) => e.preventDefault()); | |
| availableContainer.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| const genome = e.dataTransfer.getData('genome'); | |
| const idx = state.user.genomeAssignments.indexOf(genome); | |
| if (idx !== -1) { | |
| state.user.genomeAssignments[idx] = null; | |
| } | |
| this.renderLayoutGrid(); | |
| this.updateUserCommandPreview(); | |
| }); | |
| }, | |
| // Render gene input rows | |
| renderGeneRows() { | |
| const container = document.getElementById('user-genes-container'); | |
| if (!container) return; | |
| const presetColors = [ | |
| { name: 'Red', value: 'r' }, | |
| { name: 'Blue', value: 'b' }, | |
| { name: 'Green', value: 'g' }, | |
| { name: 'Purple', value: 'purple' }, | |
| { name: 'Orange', value: 'orange' }, | |
| { name: 'Cyan', value: 'c' }, | |
| { name: 'Magenta', value: 'm' }, | |
| { name: 'Brown', value: 'brown' }, | |
| { name: 'Black', value: 'black' }, | |
| { name: 'Yellow', value: 'yellow' }, | |
| { name: 'Pink', value: 'pink' }, | |
| { name: 'Turquoise', value: 'turquoise' } | |
| ]; | |
| const annotationTypes = [ | |
| { name: 'No annotation', value: 'none' }, | |
| { name: 'Use genome annotation', value: 'genome' }, | |
| { name: 'Custom annotation', value: 'custom' } | |
| ]; | |
| // Get example gene ID based on selected query genome | |
| const exampleGeneId = EXAMPLE_GENE_IDS[state.user.queryGenome] || 'HORVU.MOREX.r3.1HG0089770'; | |
| const placeholderText = `Gene ID (e.g., ${exampleGeneId})`; | |
| container.innerHTML = state.user.genes.map((gene, index) => ` | |
| <div class="gene-row" data-gene-id="${gene.id}"> | |
| <div class="gene-row-main"> | |
| <div class="gene-number">${index + 1}</div> | |
| <input type="text" | |
| class="form-input gene-name-input" | |
| placeholder="${placeholderText}" | |
| value="${gene.name}" | |
| data-index="${index}"> | |
| <select class="form-select gene-color-select" data-index="${index}"> | |
| ${presetColors.map((c, i) => | |
| `<option value="${c.value}" ${(gene.color === c.value || (!gene.color && i === index % presetColors.length)) ? 'selected' : ''}>${c.name}</option>` | |
| ).join('')} | |
| </select> | |
| <select class="form-select gene-annotation-type-select" data-index="${index}"> | |
| ${annotationTypes.map(t => | |
| `<option value="${t.value}" ${(gene.annotationType || 'none') === t.value ? 'selected' : ''}>${t.name}</option>` | |
| ).join('')} | |
| </select> | |
| <input type="text" | |
| class="form-input gene-custom-annotation" | |
| placeholder="Custom annotation (for legend)" | |
| value="${gene.customAnnotation || ''}" | |
| data-index="${index}" | |
| style="display: ${gene.annotationType === 'custom' ? 'inline-block' : 'none'}"> | |
| </div> | |
| <div class="gene-row-annotation" data-index="${index}" style="display: ${gene.annotationType === 'genome' ? 'flex' : 'none'}"> | |
| <span class="genome-annotation-preview" data-index="${index}" title="${gene.genomeAnnotation || ''}">${gene.genomeAnnotation || 'Enter gene ID to see annotation'}</span> | |
| </div> | |
| </div> | |
| `).join(''); | |
| // Add event listeners | |
| container.querySelectorAll('.gene-name-input').forEach(input => { | |
| input.addEventListener('input', (e) => { | |
| const index = parseInt(e.target.dataset.index); | |
| state.user.genes[index].name = e.target.value.trim(); | |
| this.updateUserCommandPreview(); | |
| this.updateRequiredGeneSelect(); | |
| // Fetch genome annotation if type is 'genome' | |
| if (state.user.genes[index].annotationType === 'genome' && state.user.genes[index].name) { | |
| this.fetchGeneAnnotation(index); | |
| } | |
| }); | |
| }); | |
| container.querySelectorAll('.gene-color-select').forEach(select => { | |
| select.addEventListener('change', (e) => { | |
| const index = parseInt(e.target.dataset.index); | |
| state.user.genes[index].color = e.target.value; | |
| }); | |
| // Set initial color if not set | |
| const index = parseInt(select.dataset.index); | |
| if (!state.user.genes[index].color) { | |
| state.user.genes[index].color = select.value; | |
| } | |
| }); | |
| container.querySelectorAll('.gene-annotation-type-select').forEach(select => { | |
| select.addEventListener('change', (e) => { | |
| const index = parseInt(e.target.dataset.index); | |
| const newType = e.target.value; | |
| state.user.genes[index].annotationType = newType; | |
| // Show/hide custom annotation input and genome annotation row | |
| const row = e.target.closest('.gene-row'); | |
| const customInput = row.querySelector('.gene-custom-annotation'); | |
| const annotationRow = row.querySelector('.gene-row-annotation'); | |
| customInput.style.display = newType === 'custom' ? 'inline-block' : 'none'; | |
| annotationRow.style.display = newType === 'genome' ? 'flex' : 'none'; | |
| // Fetch genome annotation if needed | |
| if (newType === 'genome' && state.user.genes[index].name) { | |
| this.fetchGeneAnnotation(index); | |
| } | |
| }); | |
| }); | |
| container.querySelectorAll('.gene-custom-annotation').forEach(input => { | |
| input.addEventListener('input', (e) => { | |
| const index = parseInt(e.target.dataset.index); | |
| state.user.genes[index].customAnnotation = e.target.value; | |
| }); | |
| }); | |
| }, | |
| // Fetch genome annotation for a gene | |
| async fetchGeneAnnotation(index) { | |
| const gene = state.user.genes[index]; | |
| if (!gene.name || !state.user.queryGenome) return; | |
| const result = await API.getGeneAnnotation(state.user.queryGenome, gene.name); | |
| gene.genomeAnnotation = result.annotation || '(no annotation found)'; | |
| // Update preview | |
| const preview = document.querySelector(`.genome-annotation-preview[data-index="${index}"]`); | |
| if (preview) { | |
| preview.textContent = gene.genomeAnnotation; | |
| preview.title = gene.genomeAnnotation; // Show full text on hover | |
| } | |
| }, | |
| // Add gene row | |
| addGeneRow() { | |
| state.user.geneCounter++; | |
| const colorIndex = state.user.genes.length % 8; | |
| const colors = ['r', 'b', 'g', 'purple', 'orange', 'c', 'm', 'brown']; | |
| state.user.genes.push({ | |
| id: state.user.geneCounter, | |
| name: '', | |
| color: colors[colorIndex], | |
| annotationType: 'none', | |
| customAnnotation: '' | |
| }); | |
| this.renderGeneRows(); | |
| }, | |
| // Remove last gene row | |
| removeGeneRow() { | |
| if (state.user.genes.length > 1) { | |
| state.user.genes.pop(); | |
| this.renderGeneRows(); | |
| this.updateUserCommandPreview(); | |
| } | |
| }, | |
| // Reset gene rows | |
| resetGeneRows() { | |
| state.user.geneCounter = 3; | |
| state.user.genes = [ | |
| { id: 1, name: '', color: 'r', annotationType: 'none', customAnnotation: '' }, | |
| { id: 2, name: '', color: 'b', annotationType: 'none', customAnnotation: '' }, | |
| { id: 3, name: '', color: 'g', annotationType: 'none', customAnnotation: '' } | |
| ]; | |
| this.renderGeneRows(); | |
| this.updateUserCommandPreview(); | |
| }, | |
| // Update command preview (User Genes) | |
| updateUserCommandPreview() { | |
| const preview = document.getElementById('user-command-preview'); | |
| if (!preview) return; | |
| const genome = state.user.queryGenome; | |
| const genes = state.user.genes.filter(g => g.name).map(g => g.name); | |
| const comps = state.user.genomeOrder.length > 0 ? state.user.genomeOrder : state.user.compGenomes; | |
| if (!genome || genes.length === 0 || comps.length === 0) { | |
| preview.textContent = 'Enter genes above to see command'; | |
| return; | |
| } | |
| let cmd = `bash plot_user_genes_microsynteny_v2.sh --query ${genome} --genes ${genes.join(' ')} --comparisons ${comps.join(' ')}`; | |
| // Add layout if selected | |
| if (state.user.selectedLayout) { | |
| cmd += ` --layout ${state.user.selectedLayout.name}`; | |
| } | |
| preview.textContent = cmd; | |
| }, | |
| // Update status display | |
| updateStatus(elementId, type, message) { | |
| const status = document.getElementById(elementId); | |
| if (!status) return; | |
| status.className = 'status-display ' + type; | |
| const icons = { | |
| running: '⏳', | |
| success: '✓', | |
| error: '✗', | |
| ready: '⏳' | |
| }; | |
| status.querySelector('.status-icon').textContent = icons[type] || '⏳'; | |
| status.querySelector('.status-text').textContent = message; | |
| }, | |
| // Show results | |
| showResults(resultId, folder, files) { | |
| const resultsCard = document.getElementById(resultId); | |
| if (!resultsCard) return; | |
| resultsCard.style.display = 'block'; | |
| // Set image source | |
| const prefix = resultId === 'plant-results' ? 'plant' : 'user'; | |
| if (files.png) { | |
| const img = document.getElementById(`${prefix}-plot-image`); | |
| // Add timestamp to prevent browser caching | |
| img.src = `/api/image/${folder}/${files.png}?t=${Date.now()}`; | |
| img.alt = 'Microsynteny Plot'; | |
| } | |
| // Set download links with click handlers for reliable download on HF Spaces | |
| if (files.png) { | |
| const pngLink = document.getElementById(`${prefix}-download-png`); | |
| const pngUrl = `/api/download/${folder}/${files.png}`; | |
| pngLink.href = pngUrl; | |
| pngLink.style.display = 'inline-flex'; | |
| pngLink.onclick = (e) => { | |
| e.preventDefault(); | |
| fetch(pngUrl) | |
| .then(response => response.blob()) | |
| .then(blob => { | |
| const url = window.URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'microsynteny_plot.png'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| window.URL.revokeObjectURL(url); | |
| a.remove(); | |
| }) | |
| .catch(err => console.error('Download failed:', err)); | |
| }; | |
| } | |
| if (files.svg) { | |
| const svgLink = document.getElementById(`${prefix}-download-svg`); | |
| const svgUrl = `/api/download/${folder}/${files.svg}`; | |
| svgLink.href = svgUrl; | |
| svgLink.style.display = 'inline-flex'; | |
| svgLink.onclick = (e) => { | |
| e.preventDefault(); | |
| fetch(svgUrl) | |
| .then(response => response.blob()) | |
| .then(blob => { | |
| const url = window.URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'microsynteny_plot.svg'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| window.URL.revokeObjectURL(url); | |
| a.remove(); | |
| }) | |
| .catch(err => console.error('Download failed:', err)); | |
| }; | |
| } | |
| if (files.csv) { | |
| const csvLink = document.getElementById(`${prefix}-download-csv`); | |
| const csvUrl = `/api/download/${folder}/${files.csv}`; | |
| csvLink.href = csvUrl; | |
| csvLink.style.display = 'inline-flex'; | |
| // Add click handler for reliable download on HF Spaces | |
| csvLink.onclick = (e) => { | |
| e.preventDefault(); | |
| fetch(csvUrl) | |
| .then(response => response.blob()) | |
| .then(blob => { | |
| const url = window.URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'gene_summary.csv'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| window.URL.revokeObjectURL(url); | |
| a.remove(); | |
| }) | |
| .catch(err => console.error('Download failed:', err)); | |
| }; | |
| } | |
| // Scroll to results | |
| resultsCard.scrollIntoView({ behavior: 'smooth' }); | |
| }, | |
| // Display search results in the advanced options table | |
| displaySearchResults(data) { | |
| const container = document.getElementById('search-results-container'); | |
| const summary = document.getElementById('search-summary'); | |
| const tableBody = document.querySelector('#search-results-table tbody'); | |
| container.style.display = 'block'; | |
| // Show summary | |
| if (data.filter_message) { | |
| summary.innerHTML = `<strong>⚠️ Filter not met:</strong> ${data.filter_message}`; | |
| summary.style.background = 'linear-gradient(135deg, #fef3c7, #fde68a)'; | |
| summary.style.color = '#92400e'; | |
| } else { | |
| let summaryHtml = ` | |
| <strong>✓ Found ${data.total_matches || 0} high-confidence matches</strong><br> | |
| <span>Query genes with matches: ${data.genes_with_matches || 0} / ${state.user.genes.filter(g => g.name).length}</span> | |
| `; | |
| summary.innerHTML = summaryHtml; | |
| summary.style.background = 'linear-gradient(135deg, #d1fae5, #a7f3d0)'; | |
| summary.style.color = '#065f46'; | |
| } | |
| // Destroy existing DataTable BEFORE clearing table to prevent caching issues | |
| if ($.fn.DataTable.isDataTable('#search-results-table')) { | |
| $('#search-results-table').DataTable().clear().destroy(); | |
| } | |
| // Clear and populate table | |
| tableBody.innerHTML = ''; | |
| if (data.results && data.results.length > 0) { | |
| data.results.forEach(row => { | |
| const tr = document.createElement('tr'); | |
| tr.innerHTML = ` | |
| <td>${row.query_gene}</td> | |
| <td>${row.comparison_genome}</td> | |
| <td>${row.ortholog}</td> | |
| <td>${row.chromosome}</td> | |
| <td>${row.start}</td> | |
| <td>${row.end}</td> | |
| <td>${row.identity}</td> | |
| `; | |
| tableBody.appendChild(tr); | |
| }); | |
| // Initialize DataTable (already destroyed above) | |
| $('#search-results-table').DataTable({ | |
| paging: true, | |
| pageLength: 10, | |
| searching: true, | |
| ordering: true, | |
| scrollX: true, | |
| order: [[1, 'asc']] // Default sort by Comparison Genome column | |
| }); | |
| // Create downloadable TSV | |
| const tsvContent = this.generateSearchTSV(data.results); | |
| const blob = new Blob([tsvContent], { type: 'text/tab-separated-values' }); | |
| const url = URL.createObjectURL(blob); | |
| document.getElementById('download-search-results').href = url; | |
| document.getElementById('download-search-results').style.display = 'inline-flex'; | |
| } else { | |
| tableBody.innerHTML = '<tr><td colspan="7" class="empty-message">No matches found</td></tr>'; | |
| document.getElementById('download-search-results').style.display = 'none'; | |
| } | |
| // Scroll to results | |
| container.scrollIntoView({ behavior: 'smooth' }); | |
| }, | |
| // Generate TSV content from search results | |
| generateSearchTSV(results) { | |
| const headers = ['Query_Gene', 'Comparison_Genome', 'Ortholog_Gene', 'Chromosome', 'Start', 'End', 'Identity']; | |
| const rows = results.map(r => [ | |
| r.query_gene, r.comparison_genome, r.ortholog, | |
| r.chromosome, r.start, r.end, r.identity | |
| ].join('\t')); | |
| return [headers.join('\t'), ...rows].join('\n'); | |
| }, | |
| // Update the required-genes checkboxes with current genes | |
| updateRequiredGeneSelect() { | |
| const container = document.getElementById('required-genes-container'); | |
| if (!container) return; | |
| const genes = state.user.genes.filter(g => g.name); | |
| if (genes.length === 0) { | |
| container.innerHTML = '<span class="form-help" style="font-style: italic;">Enter genes above first</span>'; | |
| return; | |
| } | |
| container.innerHTML = genes.map(g => ` | |
| <label class="required-gene-item"> | |
| <input type="checkbox" class="required-gene-checkbox" value="${g.name}"> | |
| <span>${g.name}</span> | |
| </label> | |
| `).join(''); | |
| // Add click handler to toggle checked class for visual styling | |
| container.querySelectorAll('.required-gene-item').forEach(item => { | |
| const checkbox = item.querySelector('input[type="checkbox"]'); | |
| checkbox.addEventListener('change', () => { | |
| item.classList.toggle('checked', checkbox.checked); | |
| }); | |
| }); | |
| } | |
| }; | |
| // ============================================================================ | |
| // Event Handlers | |
| // ============================================================================ | |
| const Handlers = { | |
| // Handle query genome change (User Genes) | |
| async onUserGenomeChange(e) { | |
| const genome = e.target.value; | |
| state.user.queryGenome = genome; | |
| if (genome) { | |
| UI.renderGeneRows(); | |
| UI.populateCompGenomes('user-comp-genomes', genome); | |
| } | |
| UI.updateUserCommandPreview(); | |
| }, | |
| // Handle User Genes plot generation | |
| async onUserGeneratePlot(isRegeneration = false) { | |
| const btn = document.getElementById('user-generate-btn'); | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="spinner"></span> Generating...'; | |
| // Hide previous results | |
| document.getElementById('user-results').style.display = 'none'; | |
| UI.updateStatus('user-status', 'running', 'Generating plot... This may take a few seconds.'); | |
| const genes = state.user.genes.filter(g => g.name).map(g => g.name); | |
| const comps = state.user.compGenomes; | |
| if (genes.length === 0) { | |
| UI.updateStatus('user-status', 'error', 'Please enter at least one gene'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Generate Microsynteny Plot'; | |
| return; | |
| } | |
| if (comps.length === 0) { | |
| UI.updateStatus('user-status', 'error', 'Please select at least one comparison genome'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Generate Microsynteny Plot'; | |
| return; | |
| } | |
| // Build full genome order from layout assignments | |
| let genomeOrder = []; | |
| let queryPosition = -1; | |
| if (state.user.selectedLayout && state.user.genomeAssignments.length > 0) { | |
| // Check if all slots are filled | |
| const filledSlots = state.user.genomeAssignments.filter(g => g); | |
| if (filledSlots.length !== state.user.genomeAssignments.length) { | |
| UI.updateStatus('user-status', 'error', 'Please assign all genomes to layout slots'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Generate Microsynteny Plot'; | |
| return; | |
| } | |
| genomeOrder = state.user.genomeAssignments; | |
| queryPosition = genomeOrder.indexOf(state.user.queryGenome); | |
| } else { | |
| // Default: query first, then comparisons | |
| genomeOrder = [state.user.queryGenome, ...comps]; | |
| queryPosition = 0; | |
| } | |
| // Extract comparisons (all genomes except query) in their layout order | |
| const orderedComps = genomeOrder.filter(g => g !== state.user.queryGenome); | |
| // Get colors for genes - use tweaking colors if regenerating, otherwise use user input | |
| let colors; | |
| if (isRegeneration === true) { | |
| colors = PlotTweaking.getGeneColors('user'); | |
| } else { | |
| colors = state.user.genes.filter(g => g.name).map(g => g.color || ''); | |
| } | |
| // Build annotations array based on user selection | |
| const annotations = state.user.genes.filter(g => g.name).map(g => { | |
| if (g.annotationType === 'custom') { | |
| return g.customAnnotation || ''; | |
| } else if (g.annotationType === 'genome') { | |
| return g.genomeAnnotation || ''; | |
| } | |
| return ''; // 'none' - no annotation | |
| }); | |
| // Get tweaking configuration if this is a regeneration | |
| const tweakingConfig = (isRegeneration === true) ? PlotTweaking.getTweakingConfig('user') : {}; | |
| // Build the full request data | |
| const requestData = { | |
| query_genome: state.user.queryGenome, | |
| genes: genes, | |
| comparisons: orderedComps, | |
| colors: colors, | |
| annotations: annotations, | |
| layout: state.user.selectedLayout ? state.user.selectedLayout.layout : null, | |
| genome_order: genomeOrder, | |
| query_position: queryPosition, | |
| ...tweakingConfig // Spread tweaking parameters (padding_config, max_genes_config, display_names) | |
| }; | |
| // Debug: log the full request data when regenerating | |
| if (isRegeneration === true) { | |
| console.log('[UserPlot] REGENERATION - Full request data being sent to API:'); | |
| console.log(JSON.stringify(requestData, null, 2)); | |
| } | |
| const result = await API.plotUserGenes(requestData); | |
| if (result.success) { | |
| UI.updateStatus('user-status', 'success', result.message || 'Plot generated successfully!'); | |
| UI.showResults('user-results', result.output_folder, result.files); | |
| // Initialize plot tweaking panel if this is the first generation | |
| // Note: isRegeneration must be exactly true (not a PointerEvent from click handler) | |
| const shouldInitialize = isRegeneration !== true; | |
| console.log('[UserPlot] isRegeneration:', isRegeneration, 'Initializing PlotTweaking:', shouldInitialize); | |
| if (shouldInitialize) { | |
| console.log('[UserPlot] Calling PlotTweaking.initialize with:', { | |
| queryGenome: state.user.queryGenome, | |
| compGenomes: orderedComps, | |
| genes: state.user.genes | |
| }); | |
| PlotTweaking.initialize('user', state.user.queryGenome, orderedComps, state.user.genes); | |
| } | |
| } else { | |
| UI.updateStatus('user-status', 'error', result.error || 'Plot generation failed'); | |
| } | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Generate Microsynteny Plot'; | |
| }, | |
| // Handle Search Hits button click | |
| async onSearchHits() { | |
| const btn = document.getElementById('search-hits-btn'); | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="spinner"></span> Searching...'; | |
| const resultsContainer = document.getElementById('search-results-container'); | |
| resultsContainer.style.display = 'none'; | |
| const genes = state.user.genes.filter(g => g.name).map(g => g.name); | |
| // Check if "search all genomes" is checked | |
| const searchAllGenomes = document.getElementById('search-all-genomes-checkbox')?.checked || false; | |
| let comps; | |
| if (searchAllGenomes) { | |
| // Use all genomes except the query genome | |
| comps = state.genomes | |
| .filter(g => g.id !== state.user.queryGenome) | |
| .map(g => g.id); | |
| } else { | |
| comps = state.user.compGenomes; | |
| } | |
| if (genes.length === 0) { | |
| UI.updateStatus('user-status', 'error', 'Please enter at least one gene'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">🔍</span> Search Syntenic Hits'; | |
| return; | |
| } | |
| if (comps.length === 0) { | |
| UI.updateStatus('user-status', 'error', 'Please select at least one comparison genome or check "Search against all genomes"'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">🔍</span> Search Syntenic Hits'; | |
| return; | |
| } | |
| const minHits = parseInt(document.getElementById('min-hits-input').value) || 1; | |
| // Get multiple required genes from checkboxes | |
| const requiredGenes = Array.from(document.querySelectorAll('.required-gene-checkbox:checked')) | |
| .map(cb => cb.value); | |
| // Debug logging | |
| console.log('Search Hits Debug:', { | |
| genes: genes, | |
| comps: comps, | |
| searchAllGenomes: searchAllGenomes, | |
| minHits: minHits, | |
| requiredGenes: requiredGenes, | |
| queryGenome: state.user.queryGenome | |
| }); | |
| const result = await API.searchHits({ | |
| query_genome: state.user.queryGenome, | |
| genes: genes, | |
| comparisons: comps, | |
| min_hits: minHits, | |
| required_genes: requiredGenes // Now an array | |
| }); | |
| // Debug logging for response | |
| console.log('Search Hits Response:', result); | |
| if (result.success) { | |
| const data = result.data; | |
| UI.displaySearchResults(data); | |
| } else { | |
| UI.updateStatus('user-status', 'error', result.error || 'Search failed'); | |
| } | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">🔍</span> Search Syntenic Hits'; | |
| }, | |
| // Custom Genome Handlers | |
| async onCustomUpload(e) { | |
| e.preventDefault(); | |
| const btn = document.getElementById('custom-upload-btn'); | |
| const statusDiv = document.getElementById('custom-upload-status'); | |
| // File size limits (must match backend limits in app.py) | |
| const MAX_GFF3_SIZE = 1024 * 1024 * 1024; // 1 GB | |
| const MAX_PEP_SIZE = 1024 * 1024 * 1024; // 1 GB | |
| const MAX_NAME_LENGTH = 100; // Max chars for names (must match backend) | |
| const gff3File = document.getElementById('custom-gff3-file').files[0]; | |
| const pepFile = document.getElementById('custom-pep-file').files[0]; | |
| const runName = document.getElementById('custom-run-name').value.trim(); | |
| const displayName = document.getElementById('custom-display-name')?.value.trim() || ''; | |
| // Validate files are selected | |
| if (!gff3File || !pepFile) { | |
| statusDiv.style.display = 'flex'; | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = 'Please select both GFF3 and PEP files'; | |
| return; | |
| } | |
| // Validate name lengths | |
| if (runName.length > MAX_NAME_LENGTH) { | |
| statusDiv.style.display = 'flex'; | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = `Run Name must be ${MAX_NAME_LENGTH} characters or less (currently ${runName.length})`; | |
| return; | |
| } | |
| if (displayName.length > MAX_NAME_LENGTH) { | |
| statusDiv.style.display = 'flex'; | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = `Display Name must be ${MAX_NAME_LENGTH} characters or less (currently ${displayName.length})`; | |
| return; | |
| } | |
| // Client-side file size validation (immediate feedback) | |
| if (gff3File.size > MAX_GFF3_SIZE) { | |
| const sizeMB = (gff3File.size / (1024 * 1024)).toFixed(1); | |
| statusDiv.style.display = 'flex'; | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = `GFF3 file too large: ${sizeMB} MB (max 1 GB)`; | |
| return; | |
| } | |
| if (pepFile.size > MAX_PEP_SIZE) { | |
| const sizeMB = (pepFile.size / (1024 * 1024)).toFixed(1); | |
| statusDiv.style.display = 'flex'; | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = `PEP file too large: ${sizeMB} MB (max 1 GB)`; | |
| return; | |
| } | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="spinner"></span> Uploading...'; | |
| statusDiv.style.display = 'flex'; | |
| statusDiv.querySelector('.status-icon').textContent = '⏳'; | |
| statusDiv.querySelector('.status-text').textContent = 'Uploading files...'; | |
| const formData = new FormData(); | |
| formData.append('gff3', gff3File); | |
| formData.append('pep', pepFile); | |
| formData.append('run_name', runName); // Run name is now required | |
| // Add visibility selection | |
| const visibility = document.querySelector('input[name="custom-visibility"]:checked')?.value || 'public'; | |
| formData.append('visibility', visibility); | |
| state.custom.visibility = visibility; // Store for use when running MCscan | |
| // Add display name for the genome (displayName already declared above) | |
| if (displayName) formData.append('display_name', displayName); | |
| const result = await API.customUpload(formData); | |
| if (result.success) { | |
| state.custom.runKey = result.run_key; | |
| statusDiv.querySelector('.status-icon').textContent = '✓'; | |
| statusDiv.querySelector('.status-text').textContent = result.message; | |
| // Show genome selection and run cards | |
| document.getElementById('custom-genomes-card').style.display = 'block'; | |
| document.getElementById('custom-run-card').style.display = 'block'; | |
| document.getElementById('custom-run-key-display').textContent = result.run_key; | |
| // Populate genome checkboxes | |
| this.populateCustomGenomes(); | |
| } else { | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = 'Error: ' + result.error; | |
| } | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">📤</span> Upload Files'; | |
| }, | |
| populateCustomGenomes() { | |
| const container = document.getElementById('custom-comp-genomes'); | |
| container.innerHTML = state.genomes.map(g => ` | |
| <label class="genome-checkbox"> | |
| <input type="checkbox" value="${g.id}" class="custom-genome-cb"> | |
| <span>${g.name}</span> | |
| </label> | |
| `).join(''); | |
| // Add change listeners | |
| container.querySelectorAll('.custom-genome-cb').forEach(cb => { | |
| cb.addEventListener('change', () => { | |
| state.custom.selectedGenomes = Array.from( | |
| container.querySelectorAll('.custom-genome-cb:checked') | |
| ).map(c => c.value); | |
| }); | |
| }); | |
| }, | |
| // Sequences Upload Handlers | |
| async onSequencesUpload(e) { | |
| e.preventDefault(); | |
| const btn = document.getElementById('seq-upload-btn'); | |
| const statusDiv = document.getElementById('seq-upload-status'); | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="spinner"></span> Uploading...'; | |
| statusDiv.style.display = 'flex'; | |
| statusDiv.querySelector('.status-icon').textContent = '⏳'; | |
| statusDiv.querySelector('.status-text').textContent = 'Validating input...'; | |
| const MAX_NAME_LENGTH = 100; // Max chars for names (must match backend) | |
| const bedContent = document.getElementById('seq-bed-content').value.trim(); | |
| const proteinSequences = document.getElementById('seq-protein-sequences').value.trim(); | |
| const runName = document.getElementById('seq-run-name').value.trim(); | |
| const displayName = document.getElementById('seq-display-name').value.trim(); | |
| if (!bedContent) { | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = 'Please enter BED file content'; | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">📤</span> Upload Sequences'; | |
| return; | |
| } | |
| if (!proteinSequences) { | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = 'Please enter protein sequences in FASTA format'; | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">📤</span> Upload Sequences'; | |
| return; | |
| } | |
| if (!displayName) { | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = 'Please provide a Genome Display Name'; | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">📤</span> Upload Sequences'; | |
| return; | |
| } | |
| // Validate name lengths | |
| if (runName.length > MAX_NAME_LENGTH) { | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = `Run Name must be ${MAX_NAME_LENGTH} characters or less (currently ${runName.length})`; | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">📤</span> Upload Sequences'; | |
| return; | |
| } | |
| if (displayName.length > MAX_NAME_LENGTH) { | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = `Display Name must be ${MAX_NAME_LENGTH} characters or less (currently ${displayName.length})`; | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">📤</span> Upload Sequences'; | |
| return; | |
| } | |
| // Client-side BED validation | |
| const bedLines = bedContent.split('\n').filter(line => line.trim() && !line.startsWith('#')); | |
| if (bedLines.length === 0) { | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = 'BED content is empty'; | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">📤</span> Upload Sequences'; | |
| return; | |
| } | |
| // Check for header row and validate format | |
| let dataLines = bedLines; | |
| let hasHeader = false; | |
| const firstLine = bedLines[0].split('\t'); | |
| if (firstLine.length >= 6) { | |
| // Detect header: column 5 should be "0" (score) and column 6 should be "+" or "-" (strand) | |
| const col5 = firstLine[4]; | |
| const col6 = firstLine[5]; | |
| if (col5 !== '0' || (col6 !== '+' && col6 !== '-')) { | |
| // This looks like a header row | |
| hasHeader = true; | |
| dataLines = bedLines.slice(1); | |
| } | |
| } | |
| if (dataLines.length < 4) { | |
| const actualCount = dataLines.length; | |
| const headerNote = hasHeader ? ' (header row detected and excluded)' : ''; | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = `Minimum 4 genes required. Found ${actualCount} data rows${headerNote}.`; | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">📤</span> Upload Sequences'; | |
| return; | |
| } | |
| // Validate each data line has 6 columns | |
| for (let i = 0; i < Math.min(dataLines.length, 10); i++) { | |
| const parts = dataLines[i].split('\t'); | |
| if (parts.length < 6) { | |
| const lineNum = hasHeader ? i + 2 : i + 1; | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = `Line ${lineNum}: BED must have 6 tab-separated columns (found ${parts.length}). Check that columns are separated by tabs, not spaces.`; | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">📤</span> Upload Sequences'; | |
| return; | |
| } | |
| // Validate strand column | |
| const strand = parts[5].trim(); | |
| if (strand !== '+' && strand !== '-') { | |
| const lineNum = hasHeader ? i + 2 : i + 1; | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = `Line ${lineNum}: Strand column must be '+' or '-' (found '${strand}')`; | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">📤</span> Upload Sequences'; | |
| return; | |
| } | |
| } | |
| statusDiv.querySelector('.status-text').textContent = 'Uploading sequences...'; | |
| const formData = new FormData(); | |
| formData.append('bed_content', bedContent); | |
| formData.append('sequences', proteinSequences); | |
| formData.append('run_name', runName); // Run name is now required | |
| formData.append('display_name', displayName); | |
| // Add visibility selection | |
| const visibility = document.querySelector('input[name="seq-visibility"]:checked')?.value || 'public'; | |
| formData.append('visibility', visibility); | |
| state.custom.visibility = visibility; // Store for use when running MCscan | |
| const result = await API.customUploadSequences(formData); | |
| if (result.success) { | |
| state.custom.runKey = result.run_key; | |
| statusDiv.querySelector('.status-icon').textContent = '✓'; | |
| statusDiv.querySelector('.status-text').textContent = result.message; | |
| // Show genome selection and run cards | |
| document.getElementById('seq-genomes-card').style.display = 'block'; | |
| document.getElementById('seq-run-card').style.display = 'block'; | |
| document.getElementById('seq-run-key-display').textContent = result.run_key; | |
| // Populate genome checkboxes | |
| this.populateSeqGenomes(); | |
| } else { | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = 'Error: ' + result.error; | |
| } | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">📤</span> Upload Sequences'; | |
| }, | |
| populateSeqGenomes() { | |
| const container = document.getElementById('seq-comp-genomes'); | |
| container.innerHTML = state.genomes.map(g => ` | |
| <label class="genome-checkbox"> | |
| <input type="checkbox" value="${g.id}" class="custom-genome-cb"> | |
| <span>${g.name}</span> | |
| </label> | |
| `).join(''); | |
| // Add change listeners | |
| container.querySelectorAll('.custom-genome-cb').forEach(cb => { | |
| cb.addEventListener('change', () => { | |
| state.custom.selectedGenomes = Array.from( | |
| container.querySelectorAll('.custom-genome-cb:checked') | |
| ).map(c => c.value); | |
| }); | |
| }); | |
| }, | |
| async onSeqRunMcscan() { | |
| const btn = document.getElementById('seq-run-mcscan-btn'); | |
| const statusDiv = document.getElementById('seq-job-status'); | |
| const progressBar = document.getElementById('seq-progress-bar'); | |
| if (!state.custom.runKey) { | |
| alert('Please upload sequences first'); | |
| return; | |
| } | |
| const selectedGenomes = Array.from( | |
| document.querySelectorAll('#seq-comp-genomes .custom-genome-cb:checked') | |
| ).map(c => c.value); | |
| if (selectedGenomes.length === 0) { | |
| alert('Please select at least one comparison genome'); | |
| return; | |
| } | |
| const displayName = document.getElementById('seq-display-name')?.value.trim() || ''; | |
| if (!displayName) { | |
| alert('Please provide a Genome Display Name'); | |
| document.getElementById('seq-display-name')?.focus(); | |
| return; | |
| } | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="spinner"></span> Starting...'; | |
| progressBar.style.display = 'block'; | |
| statusDiv.querySelector('.status-icon').textContent = '⏳'; | |
| statusDiv.querySelector('.status-text').textContent = 'Starting MCscan analysis...'; | |
| // Get MCscan advanced options | |
| const mcscanOptions = CustomUI.getMcscanOptions('seq'); | |
| const result = await API.customRunMcscan({ | |
| run_key: state.custom.runKey, | |
| genomes: selectedGenomes, | |
| display_name: displayName, | |
| visibility: state.custom.visibility || 'public', | |
| cscore: mcscanOptions.cscore, | |
| min_anchor: mcscanOptions.min_anchor, | |
| gap_length: mcscanOptions.gap_length | |
| }); | |
| if (result.success) { | |
| statusDiv.querySelector('.status-text').textContent = result.message; | |
| // Start polling for status | |
| state.custom.statusPollInterval = setInterval(async () => { | |
| const status = await API.customStatus(state.custom.runKey); | |
| if (status.success && status.data) { | |
| const data = status.data; | |
| statusDiv.querySelector('.status-text').textContent = data.message; | |
| progressBar.querySelector('.progress-fill').style.width = `${data.progress || 0}%`; | |
| if (data.status === 'completed') { | |
| clearInterval(state.custom.statusPollInterval); | |
| statusDiv.querySelector('.status-icon').textContent = '✓'; | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">✓</span> Analysis Complete'; | |
| // Show plotting interface with the comparison genomes that were analyzed | |
| Handlers.loadCustomPlotSection(state.custom.runKey, { comparison_genomes: selectedGenomes }); | |
| } else if (data.status === 'failed') { | |
| clearInterval(state.custom.statusPollInterval); | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Start MCscan Analysis'; | |
| } | |
| } | |
| }, 3000); | |
| } else { | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = 'Error: ' + result.error; | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Start MCscan Analysis'; | |
| } | |
| }, | |
| async onCustomRunMcscan() { | |
| const btn = document.getElementById('custom-run-mcscan-btn'); | |
| const statusDiv = document.getElementById('custom-job-status'); | |
| const progressBar = document.getElementById('custom-progress-bar'); | |
| if (!state.custom.runKey) { | |
| alert('Please upload files first'); | |
| return; | |
| } | |
| if (state.custom.selectedGenomes.length === 0) { | |
| alert('Please select at least one comparison genome'); | |
| return; | |
| } | |
| // Get display name from upload form | |
| const displayName = document.getElementById('custom-display-name')?.value.trim() || ''; | |
| if (!displayName) { | |
| alert('Please provide a Genome Display Name'); | |
| // Highlight the display name field | |
| const displayNameInput = document.getElementById('custom-display-name'); | |
| if (displayNameInput) { | |
| displayNameInput.focus(); | |
| displayNameInput.style.borderColor = 'var(--color-accent-danger)'; | |
| setTimeout(() => { | |
| displayNameInput.style.borderColor = ''; | |
| }, 3000); | |
| } | |
| return; | |
| } | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="spinner"></span> Starting...'; | |
| progressBar.style.display = 'block'; | |
| // Get MCscan advanced options | |
| const mcscanOptions = CustomUI.getMcscanOptions('custom'); | |
| const result = await API.customRunMcscan({ | |
| run_key: state.custom.runKey, | |
| genomes: state.custom.selectedGenomes, | |
| display_name: displayName, | |
| visibility: state.custom.visibility || 'public', | |
| cscore: mcscanOptions.cscore, | |
| min_anchor: mcscanOptions.min_anchor, | |
| gap_length: mcscanOptions.gap_length | |
| }); | |
| if (result.success) { | |
| statusDiv.querySelector('.status-icon').textContent = '⏳'; | |
| statusDiv.querySelector('.status-text').textContent = | |
| `MCscan started. Estimated time: ~${result.estimated_minutes} minutes. You can close this page and return later.`; | |
| // Start polling for status | |
| this.startStatusPolling(); | |
| } else { | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = 'Error: ' + result.error; | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Start MCscan Analysis'; | |
| } | |
| }, | |
| startStatusPolling() { | |
| // Clear any existing interval | |
| if (state.custom.statusPollInterval) { | |
| clearInterval(state.custom.statusPollInterval); | |
| } | |
| const pollStatus = async () => { | |
| const result = await API.customStatus(state.custom.runKey); | |
| if (result.success && result.data) { | |
| const status = result.data; | |
| const statusDiv = document.getElementById('custom-job-status'); | |
| const progressBar = document.getElementById('custom-progress-bar'); | |
| const progressFill = progressBar.querySelector('.progress-fill'); | |
| progressFill.style.width = `${status.progress || 0}%`; | |
| statusDiv.querySelector('.status-text').textContent = status.message || 'Processing...'; | |
| if (status.status === 'completed') { | |
| statusDiv.querySelector('.status-icon').textContent = '✓'; | |
| progressFill.style.width = '100%'; | |
| clearInterval(state.custom.statusPollInterval); | |
| const btn = document.getElementById('custom-run-mcscan-btn'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">✓</span> Analysis Complete'; | |
| btn.classList.add('btn-success'); | |
| // Auto-fill the lookup key and load the plotting section | |
| document.getElementById('custom-lookup-key').value = state.custom.runKey; | |
| // Show plotting interface with the comparison genomes that were analyzed | |
| const compGenomes = state.custom.selectedGenomes; | |
| this.loadCustomPlotSection(state.custom.runKey, { comparison_genomes: compGenomes }); | |
| } else if (status.status === 'failed') { | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| clearInterval(state.custom.statusPollInterval); | |
| const btn = document.getElementById('custom-run-mcscan-btn'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Retry Analysis'; | |
| } | |
| } | |
| }; | |
| // Poll every 10 seconds | |
| state.custom.statusPollInterval = setInterval(pollStatus, 10000); | |
| pollStatus(); // Initial call | |
| }, | |
| async onCustomLookup() { | |
| const runKey = document.getElementById('custom-lookup-key').value.trim(); | |
| const resultDiv = document.getElementById('custom-lookup-result'); | |
| if (!runKey) { | |
| alert('Please enter a run key'); | |
| return; | |
| } | |
| const result = await API.customLookup(runKey); | |
| resultDiv.style.display = 'block'; | |
| if (result.success && result.data.exists) { | |
| const data = result.data; | |
| let html = `<div class="lookup-result-card">`; | |
| html += `<p><strong>Run Key:</strong> ${data.run_key}</p>`; | |
| if (data.manifest) { | |
| html += `<p><strong>Display Name:</strong> ${data.manifest.display_name || data.run_key}</p>`; | |
| html += `<p><strong>Created:</strong> ${data.manifest.created_at}</p>`; | |
| html += `<p><strong>Genes:</strong> ${data.manifest.gene_count}, <strong>Proteins:</strong> ${data.manifest.protein_count}</p>`; | |
| if (data.manifest.comparison_genomes) { | |
| html += `<p><strong>Compared against:</strong> ${data.manifest.comparison_genomes.join(', ')}</p>`; | |
| } | |
| } | |
| if (data.status) { | |
| html += `<p><strong>Status:</strong> ${data.status.status} (${data.status.progress}%)</p>`; | |
| html += `<p>${data.status.message}</p>`; | |
| } | |
| // Add delete button | |
| html += `<div style="margin-top: var(--space-md); padding-top: var(--space-md); border-top: 1px solid var(--color-border);">`; | |
| html += `<button class="btn btn-sm btn-danger" onclick="Handlers.deleteLoadedCustomRun('${data.run_key}')">🗑️ Delete This Run</button>`; | |
| html += `</div>`; | |
| html += `</div>`; | |
| resultDiv.innerHTML = html; | |
| // If completed, show plotting section | |
| if (data.status && data.status.status === 'completed') { | |
| resultDiv.innerHTML += `<p class="success-message">✓ Ready to plot!</p>`; | |
| this.loadCustomPlotSection(runKey, data.manifest); | |
| } else { | |
| document.getElementById('custom-plot-section').style.display = 'none'; | |
| } | |
| } else { | |
| resultDiv.innerHTML = `<p class="error-message">✗ Run not found: ${runKey}</p>`; | |
| document.getElementById('custom-plot-section').style.display = 'none'; | |
| } | |
| }, | |
| // Delete a custom genome run from the Load Run lookup result | |
| async deleteLoadedCustomRun(runKey) { | |
| if (!confirm(`Delete custom genome run "${runKey}"? This will permanently remove all files and cannot be undone.`)) { | |
| return; | |
| } | |
| const result = await API.customDelete(runKey); | |
| if (result.success) { | |
| // Clear the lookup result | |
| const resultDiv = document.getElementById('custom-lookup-result'); | |
| resultDiv.innerHTML = `<p class="success-message">✓ Run "${runKey}" deleted successfully.</p>`; | |
| // Clear the lookup input | |
| document.getElementById('custom-lookup-key').value = ''; | |
| // Hide plot section if it was showing this run | |
| if (state.custom.plotRunKey === runKey) { | |
| this.clearCustomRun(); | |
| } | |
| // Refresh the genomes list | |
| this.refreshCustomGenomesList(); | |
| } else { | |
| alert('Error deleting run: ' + result.error); | |
| } | |
| }, | |
| // Load the custom genome plotting section with all necessary UI | |
| async loadCustomPlotSection(runKey, manifest) { | |
| // Store in state | |
| state.custom.plotRunKey = runKey; | |
| state.custom.plotRunManifest = manifest; | |
| // Get successful genomes from manifest (those with synteny), fallback to comparison_genomes | |
| const successfulGenomes = manifest?.successful_genomes || manifest?.comparison_genomes || []; | |
| const failedGenomes = manifest?.failed_genomes || []; | |
| // Only show genomes that actually have synteny matches | |
| state.custom.availableComparisons = successfulGenomes; | |
| state.custom.failedComparisons = failedGenomes; | |
| // Use CustomUI to toggle sections | |
| CustomUI.openPlotSection(); | |
| // Update active run display | |
| document.getElementById('custom-active-run-key').textContent = runKey; | |
| const nameSpan = document.getElementById('custom-active-run-name'); | |
| if (manifest?.display_name && manifest.display_name !== runKey) { | |
| nameSpan.textContent = `(${manifest.display_name})`; | |
| } else { | |
| nameSpan.textContent = ''; | |
| } | |
| // Fetch example genes from the custom genome's BED file | |
| try { | |
| const genesResult = await API.customGenes(runKey); | |
| if (genesResult.success && genesResult.genes && genesResult.genes.length > 0) { | |
| // Extract gene_id from each gene object | |
| state.custom.exampleGenes = genesResult.genes.slice(0, 5).map(g => g.gene_id); | |
| CustomUI.updateGeneExamples(state.custom.exampleGenes); | |
| } | |
| } catch (error) { | |
| console.log('Could not fetch example genes:', error); | |
| } | |
| // Populate comparison genomes checkboxes | |
| this.populateCustomCompGenomes(); | |
| // Initialize gene rows for custom plotting | |
| this.resetCustomGeneRows(); | |
| // Scroll to plotting section | |
| document.getElementById('custom-plot-block').scrollIntoView({ behavior: 'smooth' }); | |
| }, | |
| // Populate custom genome comparison checkboxes | |
| populateCustomCompGenomes() { | |
| const container = document.getElementById('custom-plot-comp-genomes'); | |
| if (!container) return; | |
| const availableComps = state.custom.availableComparisons; | |
| const failedComps = state.custom.failedComparisons || []; | |
| let html = ''; | |
| // Show successful genomes (checkable) | |
| if (availableComps.length > 0) { | |
| html += availableComps.map(genomeId => { | |
| const genome = state.genomes.find(g => g.id === genomeId); | |
| const name = genome ? genome.name : genomeId; | |
| return ` | |
| <label class="genome-checkbox"> | |
| <input type="checkbox" class="custom-comp-genome-cb" value="${genomeId}" checked | |
| onchange="Handlers.onCustomCompGenomeChange()"> | |
| <span class="genome-label">${name}</span> | |
| </label> | |
| `; | |
| }).join(''); | |
| } | |
| // Show failed genomes (disabled, with explanation) | |
| if (failedComps.length > 0) { | |
| html += `<div class="failed-genomes-section" style="margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px dashed var(--color-border);"> | |
| <small style="color: var(--color-text-muted); display: block; margin-bottom: 0.25rem;">No syntenic anchors found:</small>`; | |
| html += failedComps.map(genomeId => { | |
| const genome = state.genomes.find(g => g.id === genomeId); | |
| const name = genome ? genome.name : genomeId; | |
| return ` | |
| <label class="genome-checkbox" style="opacity: 0.5; cursor: not-allowed;"> | |
| <input type="checkbox" disabled> | |
| <span class="genome-label" style="text-decoration: line-through;">✗ ${name}</span> | |
| </label> | |
| `; | |
| }).join(''); | |
| html += '</div>'; | |
| } | |
| // Handle case where no genomes have matches | |
| if (availableComps.length === 0 && failedComps.length === 0) { | |
| html = '<p class="empty-message">No comparison genomes available</p>'; | |
| } else if (availableComps.length === 0) { | |
| html = '<p class="empty-message" style="color: var(--color-accent-danger);">⚠️ No syntenic matches found for any comparison genome. Try using a different query region with more genes.</p>' + html; | |
| } | |
| container.innerHTML = html; | |
| // Initialize state | |
| state.custom.compGenomes = [...availableComps]; | |
| this.updateCustomLayoutOptions(); | |
| }, | |
| // Handle custom comparison genome checkbox change | |
| onCustomCompGenomeChange() { | |
| state.custom.compGenomes = Array.from( | |
| document.querySelectorAll('.custom-comp-genome-cb:checked') | |
| ).map(cb => cb.value); | |
| this.updateCustomLayoutOptions(); | |
| this.updateCustomCommandPreview(); | |
| }, | |
| // Update layout options for custom genome plotting | |
| async updateCustomLayoutOptions() { | |
| const container = document.getElementById('custom-layout-options'); | |
| if (!container) return; | |
| const numComps = state.custom.compGenomes.length; | |
| const totalGenomes = numComps + 1; // +1 for custom/query genome | |
| if (numComps === 0) { | |
| container.innerHTML = '<p class="empty-message">Select comparison genomes first</p>'; | |
| document.getElementById('custom-layout-preview-section').style.display = 'none'; | |
| state.custom.layouts = []; | |
| state.custom.selectedLayout = null; | |
| state.custom.genomeAssignments = []; | |
| return; | |
| } | |
| // Fetch layouts from API (same as main page) | |
| state.custom.layouts = await API.getLayouts(totalGenomes); | |
| // Generate visual layout cards like the main page | |
| container.innerHTML = state.custom.layouts.map((layout, index) => ` | |
| <div class="layout-option" data-index="${index}" data-layout="${layout.layout.join(',')}"> | |
| ${layout.layout.map(count => ` | |
| <div class="layout-option-row"> | |
| ${Array(count).fill('<div class="layout-option-slot"></div>').join('')} | |
| </div> | |
| `).join('')} | |
| <span class="layout-option-name">${layout.name}</span> | |
| </div> | |
| `).join(''); | |
| // Add click listeners | |
| container.querySelectorAll('.layout-option').forEach(opt => { | |
| opt.addEventListener('click', () => this.selectCustomLayout(parseInt(opt.dataset.index))); | |
| }); | |
| // Auto-select first layout | |
| if (state.custom.layouts.length > 0) { | |
| this.selectCustomLayout(0); | |
| } | |
| }, | |
| // Select a custom layout and show the assignment grid | |
| selectCustomLayout(index) { | |
| const layout = state.custom.layouts[index]; | |
| state.custom.selectedLayout = layout; | |
| // Update visual selection | |
| document.querySelectorAll('#custom-layout-options .layout-option').forEach((opt, i) => { | |
| opt.classList.toggle('selected', i === index); | |
| }); | |
| // Show and populate the grid | |
| const previewSection = document.getElementById('custom-layout-preview-section'); | |
| previewSection.style.display = 'block'; | |
| this.renderCustomLayoutGrid(); | |
| this.updateCustomCommandPreview(); | |
| }, | |
| // Generate layout options (reused from main page logic) | |
| generateLayouts(numGenomes) { | |
| const layouts = []; | |
| // Single row | |
| layouts.push({ | |
| name: `${numGenomes}`, | |
| rows: [numGenomes], | |
| preview: `[${numGenomes} genomes]` | |
| }); | |
| // Multiple row options | |
| if (numGenomes >= 2) { | |
| for (let firstRow = Math.ceil(numGenomes / 2); firstRow <= numGenomes - 1; firstRow++) { | |
| const remaining = numGenomes - firstRow; | |
| if (remaining > 0) { | |
| layouts.push({ | |
| name: `${firstRow},${remaining}`, | |
| rows: [firstRow, remaining], | |
| preview: `[${firstRow}] [${remaining}]` | |
| }); | |
| } | |
| } | |
| } | |
| if (numGenomes >= 3) { | |
| const perRow = Math.ceil(numGenomes / 3); | |
| const row1 = perRow; | |
| const row2 = perRow; | |
| const row3 = numGenomes - row1 - row2; | |
| if (row3 > 0) { | |
| layouts.push({ | |
| name: `${row1},${row2},${row3}`, | |
| rows: [row1, row2, row3], | |
| preview: `[${row1}] [${row2}] [${row3}]` | |
| }); | |
| } | |
| } | |
| return layouts; | |
| }, | |
| // Handle custom layout change - legacy compatibility, redirects to selectCustomLayout | |
| onCustomLayoutChange(idx) { | |
| this.selectCustomLayout(idx); | |
| }, | |
| // Render the custom layout grid for drag-drop genome assignment | |
| renderCustomLayoutGrid() { | |
| const previewSection = document.getElementById('custom-layout-preview-section'); | |
| const gridContainer = document.getElementById('custom-layout-grid'); | |
| const availableContainer = document.getElementById('custom-available-genomes'); | |
| if (!state.custom.selectedLayout) { | |
| previewSection.style.display = 'none'; | |
| return; | |
| } | |
| previewSection.style.display = 'block'; | |
| const layout = state.custom.selectedLayout; | |
| if (!layout) return; | |
| // Total slots needed = comparison genomes + 1 for query/custom genome | |
| const totalSlots = state.custom.compGenomes.length + 1; | |
| // Initialize genome assignments if empty or wrong size | |
| // Auto-assign genomes in default order: custom_query first, then comparison genomes | |
| if (!state.custom.genomeAssignments || state.custom.genomeAssignments.length !== totalSlots) { | |
| const allGenomes = ['custom_query', ...state.custom.compGenomes]; | |
| state.custom.genomeAssignments = allGenomes.slice(0, totalSlots); | |
| } | |
| // Build grid HTML - using layout.layout array like main page | |
| let gridHtml = ''; | |
| let slotIndex = 0; | |
| layout.layout.forEach((count, rowIdx) => { | |
| let slotsHtml = ''; | |
| for (let i = 0; i < count; i++) { | |
| const genome = state.custom.genomeAssignments[slotIndex]; | |
| let genomeInfo = null; | |
| let isCustomQuery = false; | |
| if (genome === 'custom_query') { | |
| isCustomQuery = true; | |
| } else if (genome) { | |
| genomeInfo = state.genomes.find(g => g.id === genome); | |
| } | |
| if (genome && (genomeInfo || isCustomQuery)) { | |
| const displayName = isCustomQuery | |
| ? 'Custom Genome (Query)' | |
| : (genomeInfo ? genomeInfo.name : genome); | |
| slotsHtml += ` | |
| <div class="layout-slot has-genome" data-slot="${slotIndex}"> | |
| <div class="genome-chip ${isCustomQuery ? 'query-genome' : ''}" draggable="true" data-genome="${genome}"> | |
| ${isCustomQuery ? '🧬 ' : ''}${displayName} | |
| </div> | |
| </div> | |
| `; | |
| } else { | |
| slotsHtml += `<div class="layout-slot empty" data-slot="${slotIndex}">Drop genome</div>`; | |
| } | |
| slotIndex++; | |
| } | |
| gridHtml += ` | |
| <div class="layout-row"> | |
| <span class="layout-row-label">Row ${rowIdx + 1}</span> | |
| <div class="layout-slots">${slotsHtml}</div> | |
| </div> | |
| `; | |
| }); | |
| gridContainer.innerHTML = gridHtml; | |
| // Available genomes = custom query + comparisons, minus those already assigned | |
| const assignedGenomes = state.custom.genomeAssignments.filter(g => g); | |
| const allGenomes = ['custom_query', ...state.custom.compGenomes]; | |
| const unassignedGenomes = allGenomes.filter(g => !assignedGenomes.includes(g)); | |
| availableContainer.innerHTML = unassignedGenomes.length > 0 | |
| ? unassignedGenomes.map(genomeId => { | |
| const isCustomQuery = genomeId === 'custom_query'; | |
| const g = isCustomQuery ? null : state.genomes.find(genome => genome.id === genomeId); | |
| const displayName = isCustomQuery ? 'Custom Genome (Query)' : (g ? g.name : genomeId); | |
| return `<div class="genome-chip ${isCustomQuery ? 'query-genome' : ''}" draggable="true" data-genome="${genomeId}">${isCustomQuery ? '🧬 ' : ''}${displayName}</div>`; | |
| }).join('') | |
| : '<span class="empty-message">All genomes assigned</span>'; | |
| // Setup drag and drop | |
| this.setupCustomLayoutDragDrop(); | |
| }, | |
| // Setup drag and drop for custom layout assignment | |
| setupCustomLayoutDragDrop() { | |
| const chips = document.querySelectorAll('#custom-layout-grid .genome-chip[draggable="true"], #custom-available-genomes .genome-chip[draggable="true"]'); | |
| const slots = document.querySelectorAll('#custom-layout-grid .layout-slot'); | |
| const availableContainer = document.getElementById('custom-available-genomes'); | |
| chips.forEach(chip => { | |
| chip.addEventListener('dragstart', (e) => { | |
| e.dataTransfer.setData('genome', chip.dataset.genome); | |
| chip.classList.add('dragging'); | |
| }); | |
| chip.addEventListener('dragend', () => { | |
| chip.classList.remove('dragging'); | |
| }); | |
| }); | |
| slots.forEach(slot => { | |
| slot.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| slot.classList.add('drag-over'); | |
| }); | |
| slot.addEventListener('dragleave', () => { | |
| slot.classList.remove('drag-over'); | |
| }); | |
| slot.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| slot.classList.remove('drag-over'); | |
| const genome = e.dataTransfer.getData('genome'); | |
| const slotIdx = parseInt(slot.dataset.slot); | |
| // Remove from previous position if it was assigned | |
| const prevIdx = state.custom.genomeAssignments.indexOf(genome); | |
| if (prevIdx !== -1) { | |
| state.custom.genomeAssignments[prevIdx] = null; | |
| } | |
| state.custom.genomeAssignments[slotIdx] = genome; | |
| this.renderCustomLayoutGrid(); | |
| this.updateCustomCommandPreview(); | |
| }); | |
| }); | |
| // Make available container a drop zone to unassign | |
| availableContainer.addEventListener('dragover', (e) => e.preventDefault()); | |
| availableContainer.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| const genome = e.dataTransfer.getData('genome'); | |
| const idx = state.custom.genomeAssignments.indexOf(genome); | |
| if (idx !== -1) { | |
| state.custom.genomeAssignments[idx] = null; | |
| } | |
| this.renderCustomLayoutGrid(); | |
| this.updateCustomCommandPreview(); | |
| }); | |
| // Update genome order from assignments - INCLUDE custom_query to preserve position | |
| // It will be replaced with run_key when sending to backend | |
| state.custom.genomeOrder = state.custom.genomeAssignments.filter(g => g !== null); | |
| this.updateCustomCommandPreview(); | |
| }, | |
| // Render custom gene input rows (matching Microsynteny page format) | |
| renderCustomGeneRows() { | |
| const container = document.getElementById('custom-genes-container'); | |
| if (!container) return; | |
| const presetColors = [ | |
| { name: 'Red', value: 'r' }, | |
| { name: 'Blue', value: 'b' }, | |
| { name: 'Green', value: 'g' }, | |
| { name: 'Purple', value: 'purple' }, | |
| { name: 'Orange', value: 'orange' }, | |
| { name: 'Cyan', value: 'c' }, | |
| { name: 'Magenta', value: 'm' }, | |
| { name: 'Brown', value: 'brown' }, | |
| { name: 'Black', value: 'black' }, | |
| { name: 'Yellow', value: 'yellow' }, | |
| { name: 'Pink', value: 'pink' }, | |
| { name: 'Turquoise', value: 'turquoise' } | |
| ]; | |
| const annotationTypes = [ | |
| { name: 'No annotation', value: 'none' }, | |
| { name: 'Custom annotation', value: 'custom' } | |
| ]; | |
| // Get example gene hint for placeholder | |
| const exampleHint = state.custom.exampleGenes && state.custom.exampleGenes.length > 0 | |
| ? `Gene ID (e.g., ${state.custom.exampleGenes[0]})` | |
| : 'Gene ID'; | |
| container.innerHTML = state.custom.genes.map((gene, idx) => ` | |
| <div class="gene-row" data-gene-id="${gene.id}"> | |
| <div class="gene-row-main"> | |
| <div class="gene-number">${idx + 1}</div> | |
| <input type="text" | |
| class="form-input gene-name-input" | |
| placeholder="${exampleHint}" | |
| value="${gene.name}" | |
| data-gene-id="${gene.id}"> | |
| <select class="form-select gene-color-select" data-gene-id="${gene.id}"> | |
| ${presetColors.map((c, i) => | |
| `<option value="${c.value}" ${(gene.color === c.value || (!gene.color && i === idx % presetColors.length)) ? 'selected' : ''}>${c.name}</option>` | |
| ).join('')} | |
| </select> | |
| <select class="form-select gene-annotation-type-select" data-gene-id="${gene.id}"> | |
| ${annotationTypes.map(t => | |
| `<option value="${t.value}" ${(gene.annotationType || 'none') === t.value ? 'selected' : ''}>${t.name}</option>` | |
| ).join('')} | |
| </select> | |
| <input type="text" | |
| class="form-input gene-custom-annotation" | |
| placeholder="Custom annotation (for legend)" | |
| value="${gene.customAnnotation || ''}" | |
| data-gene-id="${gene.id}" | |
| style="display: ${gene.annotationType === 'custom' ? 'inline-block' : 'none'}"> | |
| </div> | |
| </div> | |
| `).join(''); | |
| // Add event listeners | |
| container.querySelectorAll('.gene-name-input').forEach(input => { | |
| input.addEventListener('input', (e) => { | |
| const geneId = parseInt(e.target.dataset.geneId); | |
| const gene = state.custom.genes.find(g => g.id === geneId); | |
| if (gene) { | |
| gene.name = e.target.value.trim(); | |
| this.updateCustomCommandPreview(); | |
| CustomUI.updateRequiredGenesSelect(); | |
| } | |
| }); | |
| }); | |
| container.querySelectorAll('.gene-color-select').forEach(select => { | |
| select.addEventListener('change', (e) => { | |
| const geneId = parseInt(e.target.dataset.geneId); | |
| const gene = state.custom.genes.find(g => g.id === geneId); | |
| if (gene) { | |
| gene.color = e.target.value; | |
| } | |
| }); | |
| // Set initial color if not set | |
| const geneId = parseInt(select.dataset.geneId); | |
| const gene = state.custom.genes.find(g => g.id === geneId); | |
| if (gene && !gene.color) { | |
| gene.color = select.value; | |
| } | |
| }); | |
| container.querySelectorAll('.gene-annotation-type-select').forEach(select => { | |
| select.addEventListener('change', (e) => { | |
| const geneId = parseInt(e.target.dataset.geneId); | |
| const gene = state.custom.genes.find(g => g.id === geneId); | |
| if (gene) { | |
| gene.annotationType = e.target.value; | |
| // Show/hide custom annotation input | |
| const row = e.target.closest('.gene-row'); | |
| const customInput = row.querySelector('.gene-custom-annotation'); | |
| customInput.style.display = e.target.value === 'custom' ? 'inline-block' : 'none'; | |
| } | |
| }); | |
| }); | |
| container.querySelectorAll('.gene-custom-annotation').forEach(input => { | |
| input.addEventListener('input', (e) => { | |
| const geneId = parseInt(e.target.dataset.geneId); | |
| const gene = state.custom.genes.find(g => g.id === geneId); | |
| if (gene) { | |
| gene.customAnnotation = e.target.value; | |
| } | |
| }); | |
| }); | |
| this.updateCustomCommandPreview(); | |
| // Update the Required Genes dropdown | |
| CustomUI.updateRequiredGenesSelect(); | |
| }, | |
| // Helper to convert color names to hex | |
| colorNameToHex(color) { | |
| const colors = { | |
| 'r': '#ff0000', 'red': '#ff0000', | |
| 'b': '#0000ff', 'blue': '#0000ff', | |
| 'g': '#00ff00', 'green': '#00ff00', | |
| 'purple': '#800080', | |
| 'orange': '#ffa500', | |
| 'c': '#00ffff', 'cyan': '#00ffff', | |
| 'm': '#ff00ff', 'magenta': '#ff00ff', | |
| 'brown': '#a52a2a' | |
| }; | |
| return colors[color] || color; | |
| }, | |
| // Handle custom gene field change | |
| onCustomGeneChange(geneId, field, value) { | |
| const gene = state.custom.genes.find(g => g.id === geneId); | |
| if (gene) { | |
| gene[field] = value; | |
| this.updateCustomCommandPreview(); | |
| } | |
| }, | |
| // Handle custom gene annotation type change | |
| onCustomGeneAnnotationChange(geneId, value) { | |
| const gene = state.custom.genes.find(g => g.id === geneId); | |
| if (gene) { | |
| gene.annotationType = value; | |
| if (value === 'color') { | |
| gene.color = 'r'; | |
| } | |
| this.renderCustomGeneRows(); | |
| } | |
| }, | |
| // Add custom gene row | |
| addCustomGeneRow() { | |
| state.custom.geneCounter++; | |
| const colorIndex = state.custom.genes.length % 8; | |
| const colors = ['r', 'b', 'g', 'purple', 'orange', 'c', 'm', 'brown']; | |
| state.custom.genes.push({ | |
| id: state.custom.geneCounter, | |
| name: '', | |
| color: colors[colorIndex], | |
| annotationType: 'none', | |
| customAnnotation: '' | |
| }); | |
| this.renderCustomGeneRows(); | |
| }, | |
| // Remove last custom gene row | |
| removeCustomGeneRow() { | |
| if (state.custom.genes.length > 1) { | |
| state.custom.genes.pop(); | |
| this.renderCustomGeneRows(); | |
| } | |
| }, | |
| // Reset custom gene rows | |
| resetCustomGeneRows() { | |
| state.custom.geneCounter = 3; | |
| state.custom.genes = [ | |
| { id: 1, name: '', color: 'r', annotationType: 'none', customAnnotation: '' }, | |
| { id: 2, name: '', color: 'b', annotationType: 'none', customAnnotation: '' }, | |
| { id: 3, name: '', color: 'g', annotationType: 'none', customAnnotation: '' } | |
| ]; | |
| this.renderCustomGeneRows(); | |
| }, | |
| // Update custom command preview | |
| updateCustomCommandPreview() { | |
| const preview = document.getElementById('custom-command-preview'); | |
| if (!preview) return; | |
| const runKey = state.custom.plotRunKey; | |
| const genes = state.custom.genes.filter(g => g.name).map(g => g.name); | |
| const comps = state.custom.genomeOrder.length > 0 ? state.custom.genomeOrder : state.custom.compGenomes; | |
| if (!runKey || genes.length === 0 || comps.length === 0) { | |
| preview.textContent = 'Enter genes above to see command'; | |
| return; | |
| } | |
| let cmd = `Custom genome: ${runKey}\nGenes: ${genes.join(', ')}\nComparisons: ${comps.join(', ')}`; | |
| if (state.custom.selectedLayout) { | |
| cmd += `\nLayout: ${state.custom.selectedLayout.name}`; | |
| } | |
| preview.textContent = cmd; | |
| }, | |
| // Clear loaded custom run | |
| clearCustomRun() { | |
| state.custom.plotRunKey = null; | |
| state.custom.plotRunManifest = null; | |
| state.custom.availableComparisons = []; | |
| state.custom.compGenomes = []; | |
| state.custom.genomeOrder = []; | |
| state.custom.genes = []; | |
| state.custom.exampleGenes = []; | |
| // Use CustomUI to toggle sections - open MCscan, close Plot | |
| CustomUI.openMcscanSection(); | |
| CustomUI.updateGeneExamples([]); | |
| document.getElementById('custom-lookup-key').value = ''; | |
| document.getElementById('custom-lookup-result').style.display = 'none'; | |
| }, | |
| // Search syntenic hits for custom genome (matching Microsynteny page format) | |
| async onCustomSearchHits() { | |
| const btn = document.getElementById('custom-search-hits-btn'); | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="spinner"></span> Searching...'; | |
| const resultsContainer = document.getElementById('custom-search-results-container'); | |
| resultsContainer.style.display = 'none'; | |
| const runKey = state.custom.plotRunKey; | |
| if (!runKey) { | |
| UI.updateStatus('custom-plot-status', 'error', 'Please load a custom genome run first'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">🔍</span> Search Syntenic Hits'; | |
| return; | |
| } | |
| const genes = state.custom.genes.filter(g => g.name.trim()).map(g => g.name.trim()); | |
| if (genes.length === 0) { | |
| UI.updateStatus('custom-plot-status', 'error', 'Please enter at least one gene'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">🔍</span> Search Syntenic Hits'; | |
| return; | |
| } | |
| // Check if "search all genomes" is checked | |
| const searchAllGenomes = document.getElementById('custom-search-all-genomes-checkbox')?.checked || false; | |
| let comps; | |
| if (searchAllGenomes) { | |
| // Use all available comparison genomes from the MCscan run | |
| comps = state.custom.availableComparisons || []; | |
| } else { | |
| comps = state.custom.compGenomes; | |
| } | |
| if (comps.length === 0) { | |
| UI.updateStatus('custom-plot-status', 'error', 'Please select at least one comparison genome or check "Search against all available genomes"'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">🔍</span> Search Syntenic Hits'; | |
| return; | |
| } | |
| const minHits = parseInt(document.getElementById('custom-min-hits-input').value) || 1; | |
| // Get required genes from checkboxes (matching Microsynteny page format) | |
| const requiredGenes = Array.from(document.querySelectorAll('#custom-required-genes-container .required-gene-checkbox:checked')) | |
| .map(cb => cb.value); | |
| console.log('Custom Search Hits Debug:', { | |
| runKey: runKey, | |
| genes: genes, | |
| comps: comps, | |
| searchAllGenomes: searchAllGenomes, | |
| minHits: minHits, | |
| requiredGenes: requiredGenes | |
| }); | |
| // Use the same search-hits API but with the custom genome run_key as query | |
| const result = await API.searchHits({ | |
| query_genome: runKey, | |
| genes: genes, | |
| comparisons: comps, | |
| min_hits: minHits, | |
| required_genes: requiredGenes | |
| }); | |
| console.log('Custom Search Hits Response:', result); | |
| if (result.success) { | |
| this.displayCustomSearchResults(result.data); | |
| } else { | |
| UI.updateStatus('custom-plot-status', 'error', result.error || 'Search failed'); | |
| } | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">🔍</span> Search Syntenic Hits'; | |
| }, | |
| // Display custom genome search results | |
| displayCustomSearchResults(data) { | |
| const container = document.getElementById('custom-search-results-container'); | |
| const summary = document.getElementById('custom-search-summary'); | |
| const tableBody = document.querySelector('#custom-search-results-table tbody'); | |
| container.style.display = 'block'; | |
| // Show summary | |
| if (data.filter_message) { | |
| summary.innerHTML = `<strong>⚠️ Filter not met:</strong> ${data.filter_message}`; | |
| summary.style.background = 'linear-gradient(135deg, #fef3c7, #fde68a)'; | |
| summary.style.color = '#92400e'; | |
| } else { | |
| let summaryHtml = ` | |
| <strong>✓ Found ${data.total_matches || 0} high-confidence matches</strong><br> | |
| <span>Query genes with matches: ${data.genes_with_matches || 0} / ${state.custom.genes.filter(g => g.name).length}</span> | |
| `; | |
| summary.innerHTML = summaryHtml; | |
| summary.style.background = 'linear-gradient(135deg, #d1fae5, #a7f3d0)'; | |
| summary.style.color = '#065f46'; | |
| } | |
| // Destroy existing DataTable if initialized | |
| if ($.fn.DataTable.isDataTable('#custom-search-results-table')) { | |
| $('#custom-search-results-table').DataTable().clear().destroy(); | |
| } | |
| // Clear and populate table | |
| tableBody.innerHTML = ''; | |
| if (data.results && data.results.length > 0) { | |
| data.results.forEach(row => { | |
| const tr = document.createElement('tr'); | |
| tr.innerHTML = ` | |
| <td>${row.query_gene}</td> | |
| <td>${row.comparison_genome}</td> | |
| <td>${row.ortholog}</td> | |
| <td>${row.chromosome}</td> | |
| <td>${row.start}</td> | |
| <td>${row.end}</td> | |
| <td>${row.identity}</td> | |
| `; | |
| tableBody.appendChild(tr); | |
| }); | |
| // Initialize DataTable | |
| $('#custom-search-results-table').DataTable({ | |
| paging: true, | |
| pageLength: 10, | |
| searching: true, | |
| ordering: true, | |
| scrollX: true, | |
| order: [[1, 'asc']] | |
| }); | |
| // Create downloadable TSV | |
| const tsvContent = UI.generateSearchTSV(data.results); | |
| const blob = new Blob([tsvContent], { type: 'text/tab-separated-values' }); | |
| const url = URL.createObjectURL(blob); | |
| document.getElementById('custom-download-search-results').href = url; | |
| document.getElementById('custom-download-search-results').style.display = 'inline-flex'; | |
| } else { | |
| tableBody.innerHTML = '<tr><td colspan="7" class="empty-message">No matches found</td></tr>'; | |
| document.getElementById('custom-download-search-results').style.display = 'none'; | |
| } | |
| // Scroll to results | |
| container.scrollIntoView({ behavior: 'smooth' }); | |
| }, | |
| // Update the required-genes select for custom genomes | |
| updateCustomRequiredGeneSelect() { | |
| const select = document.getElementById('custom-required-genes-select'); | |
| if (!select) return; | |
| const genes = state.custom.genes.filter(g => g.name && g.name.trim()); | |
| if (genes.length === 0) { | |
| select.innerHTML = '<option disabled>Enter genes above first</option>'; | |
| return; | |
| } | |
| select.innerHTML = genes.map(g => | |
| `<option value="${g.name}">${g.name}</option>` | |
| ).join(''); | |
| }, | |
| // Generate custom genome plot | |
| async onCustomGeneratePlot(isRegeneration) { | |
| const btn = document.getElementById('custom-generate-btn'); | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="spinner"></span> Generating...'; | |
| // Hide previous results (like Microsynteny page) | |
| document.getElementById('custom-results').style.display = 'none'; | |
| const runKey = state.custom.plotRunKey; | |
| if (!runKey) { | |
| UI.updateStatus('custom-plot-status', 'error', 'Please load a custom genome run first'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Generate Microsynteny Plot'; | |
| return; | |
| } | |
| const genes = state.custom.genes.filter(g => g.name.trim()).map(g => g.name.trim()); | |
| if (genes.length === 0) { | |
| UI.updateStatus('custom-plot-status', 'error', 'Please enter at least one gene'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Generate Microsynteny Plot'; | |
| return; | |
| } | |
| // Build full genome order from layout assignments (like standard page) | |
| let genomeOrder = []; | |
| if (state.custom.selectedLayout && state.custom.genomeAssignments.length > 0) { | |
| // Check if all slots are filled | |
| const filledSlots = state.custom.genomeAssignments.filter(g => g); | |
| if (filledSlots.length !== state.custom.genomeAssignments.length) { | |
| UI.updateStatus('custom-plot-status', 'error', 'Please assign all genomes to layout slots'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Generate Microsynteny Plot'; | |
| return; | |
| } | |
| genomeOrder = state.custom.genomeAssignments; | |
| } else { | |
| // Default: query first, then comparisons | |
| genomeOrder = ['custom_query', ...state.custom.compGenomes]; | |
| } | |
| // Extract comparisons (all genomes except custom_query) in their layout order | |
| const comparisons = genomeOrder.filter(g => g !== 'custom_query'); | |
| if (comparisons.length === 0) { | |
| UI.updateStatus('custom-plot-status', 'error', 'Please select at least one comparison genome'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Generate Microsynteny Plot'; | |
| return; | |
| } | |
| // Get colors for genes - use tweaking colors if regenerating, otherwise use state | |
| let colors; | |
| if (isRegeneration === true && typeof PlotTweaking !== 'undefined') { | |
| colors = PlotTweaking.getGeneColors('custom'); | |
| } else { | |
| colors = state.custom.genes.filter(g => g.name.trim()).map(g => g.color || ''); | |
| } | |
| // Build annotations array based on user selection | |
| const annotations = state.custom.genes.filter(g => g.name.trim()).map(g => { | |
| if (g.annotationType === 'custom') { | |
| return g.customAnnotation || ''; | |
| } | |
| return ''; // 'none' - no annotation | |
| }); | |
| // Get tweaking configuration if this is a regeneration | |
| const tweakingConfig = (isRegeneration === true && typeof PlotTweaking !== 'undefined') ? PlotTweaking.getTweakingConfig('custom') : {}; | |
| // Get layout - replace 'custom_query' with actual run_key in genome_order | |
| let layout = null; | |
| let finalGenomeOrder = null; | |
| if (state.custom.selectedLayout) { | |
| layout = state.custom.selectedLayout.layout; | |
| // Replace custom_query with actual run_key so script knows where query genome is | |
| finalGenomeOrder = genomeOrder.map(g => g === 'custom_query' ? runKey : g); | |
| } | |
| // Update status | |
| UI.updateStatus('custom-plot-status', 'running', 'Generating plot... This may take a few seconds.'); | |
| const result = await API.customPlot({ | |
| run_key: runKey, | |
| genes: genes, | |
| colors: colors, | |
| annotations: annotations, | |
| comparisons: comparisons, | |
| layout: layout, | |
| genome_order: finalGenomeOrder, | |
| ...tweakingConfig // Spread tweaking parameters (padding_config, max_genes_config, display_names) | |
| }); | |
| if (result.success) { | |
| UI.updateStatus('custom-plot-status', 'success', 'Plot generated successfully!'); | |
| // Show results | |
| const resultsCard = document.getElementById('custom-results'); | |
| resultsCard.style.display = 'block'; | |
| if (result.files && result.files.png) { | |
| document.getElementById('custom-plot-image').src = `/api/image/${result.output_folder}/${result.files.png}?t=${Date.now()}`; | |
| } | |
| if (result.files) { | |
| if (result.files.png) { | |
| document.getElementById('custom-download-png').href = `/api/download/${result.output_folder}/${result.files.png}`; | |
| } | |
| if (result.files.svg) { | |
| document.getElementById('custom-download-svg').href = `/api/download/${result.output_folder}/${result.files.svg}`; | |
| } | |
| if (result.files.csv) { | |
| document.getElementById('custom-download-csv').href = `/api/download/${result.output_folder}/${result.files.csv}`; | |
| } | |
| } | |
| // Initialize plot tweaking controls (only on initial plot, not regeneration) | |
| if (isRegeneration !== true && typeof PlotTweaking !== 'undefined') { | |
| // genomeOrder includes 'custom_query' - extract query and comparisons properly | |
| const queryGenome = 'custom_query'; | |
| const compGenomes = genomeOrder.filter(g => g !== 'custom_query'); | |
| // Pass genes as array of gene objects (like user workflow) | |
| const geneObjects = state.custom.genes.filter(g => g.name.trim()); | |
| PlotTweaking.initialize('custom', queryGenome, compGenomes, geneObjects); | |
| } | |
| resultsCard.scrollIntoView({ behavior: 'smooth' }); | |
| } else { | |
| UI.updateStatus('custom-plot-status', 'error', result.error || 'Failed to generate plot'); | |
| } | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Generate Microsynteny Plot'; | |
| }, | |
| async refreshCustomGenomesList() { | |
| const container = document.getElementById('custom-genomes-list'); | |
| const result = await API.customGenomes(); | |
| if (result.success && result.genomes.length > 0) { | |
| // Store all genomes for pagination | |
| state.custom.allGenomes = result.genomes; | |
| state.custom.currentPage = 1; | |
| this.displayCustomGenomesPage(1); | |
| this.setupCustomGenomesPagination(); | |
| } else { | |
| state.custom.allGenomes = []; | |
| container.innerHTML = '<p class="empty-message">No custom genomes available</p>'; | |
| document.getElementById('custom-genomes-pagination').style.display = 'none'; | |
| } | |
| }, | |
| // Display a specific page of custom genomes | |
| displayCustomGenomesPage(pageNum) { | |
| const container = document.getElementById('custom-genomes-list'); | |
| const genomes = state.custom.allGenomes; | |
| const itemsPerPage = state.custom.itemsPerPage; | |
| const totalPages = Math.ceil(genomes.length / itemsPerPage); | |
| state.custom.currentPage = pageNum; | |
| const startIdx = (pageNum - 1) * itemsPerPage; | |
| const endIdx = Math.min(startIdx + itemsPerPage, genomes.length); | |
| const pageGenomes = genomes.slice(startIdx, endIdx); | |
| container.innerHTML = pageGenomes.map(g => { | |
| const name = g.manifest?.display_name || g.run_key; | |
| const status = g.status || 'unknown'; | |
| const statusClass = status === 'completed' ? 'success' : (status === 'failed' ? 'error' : 'pending'); | |
| return ` | |
| <div class="custom-genome-item"> | |
| <div class="custom-genome-info"> | |
| <strong>${name}</strong> | |
| <span class="custom-genome-key">${g.run_key}</span> | |
| <span class="status-badge ${statusClass}">${status}</span> | |
| </div> | |
| <div> | |
| <button class="btn btn-sm btn-info" onclick="Handlers.loadCustomRunFromList('${g.run_key}')">📊 Plot</button> | |
| <button class="btn btn-sm btn-danger" onclick="Handlers.deleteCustomGenome('${g.run_key}')">🗑️</button> | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| // Update pagination controls | |
| const prevBtn = document.getElementById('custom-genomes-prev-page'); | |
| const nextBtn = document.getElementById('custom-genomes-next-page'); | |
| const pageInfo = document.getElementById('custom-genomes-page-info'); | |
| const pageTotal = document.getElementById('custom-genomes-page-total'); | |
| prevBtn.disabled = pageNum <= 1; | |
| nextBtn.disabled = pageNum >= totalPages; | |
| prevBtn.classList.toggle('disabled', pageNum <= 1); | |
| nextBtn.classList.toggle('disabled', pageNum >= totalPages); | |
| pageInfo.textContent = pageNum; | |
| pageTotal.textContent = `of ${totalPages || 1} (${genomes.length} total)`; | |
| // Show/hide pagination if only one page | |
| const paginationDiv = document.getElementById('custom-genomes-pagination'); | |
| paginationDiv.style.display = totalPages > 1 ? 'block' : 'none'; | |
| }, | |
| // Setup pagination controls for custom genomes list | |
| setupCustomGenomesPagination() { | |
| const prevBtn = document.getElementById('custom-genomes-prev-page'); | |
| const nextBtn = document.getElementById('custom-genomes-next-page'); | |
| // Remove old listeners by cloning | |
| const newPrevBtn = prevBtn.cloneNode(true); | |
| const newNextBtn = nextBtn.cloneNode(true); | |
| prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn); | |
| nextBtn.parentNode.replaceChild(newNextBtn, nextBtn); | |
| newPrevBtn.addEventListener('click', () => { | |
| if (state.custom.currentPage > 1) { | |
| this.displayCustomGenomesPage(state.custom.currentPage - 1); | |
| } | |
| }); | |
| newNextBtn.addEventListener('click', () => { | |
| const totalPages = Math.ceil(state.custom.allGenomes.length / state.custom.itemsPerPage); | |
| if (state.custom.currentPage < totalPages) { | |
| this.displayCustomGenomesPage(state.custom.currentPage + 1); | |
| } | |
| }); | |
| }, | |
| // Load a custom run from the list | |
| async loadCustomRunFromList(runKey) { | |
| document.getElementById('custom-lookup-key').value = runKey; | |
| await this.onCustomLookup(); | |
| }, | |
| async deleteCustomGenome(runKey) { | |
| if (!confirm(`Delete custom genome "${runKey}"? This cannot be undone.`)) { | |
| return; | |
| } | |
| const result = await API.customDelete(runKey); | |
| if (result.success) { | |
| this.refreshCustomGenomesList(); | |
| // If this was the active run, clear it | |
| if (state.custom.plotRunKey === runKey) { | |
| this.clearCustomRun(); | |
| } | |
| } else { | |
| alert('Error deleting: ' + result.error); | |
| } | |
| }, | |
| // Legacy - kept for compatibility but now uses loadCustomPlotSection | |
| showCustomPlotInterface(runKey, manifest) { | |
| this.loadCustomPlotSection(runKey, manifest); | |
| }, | |
| async onCustomPlot() { | |
| await this.onCustomGeneratePlot(); | |
| }, | |
| // Load example analysis from About page | |
| async loadExampleAnalysis() { | |
| // Switch to the Microsynteny tab | |
| UI.switchTab('usergenes'); | |
| // Set query genome to vitis_vinifera | |
| const queryGenomeSelect = document.getElementById('user-query-genome'); | |
| if (queryGenomeSelect) { | |
| queryGenomeSelect.value = 'vitis_vinifera'; | |
| state.user.queryGenome = 'vitis_vinifera'; | |
| // Trigger the change event to populate comparison genomes | |
| const changeEvent = new Event('change'); | |
| queryGenomeSelect.dispatchEvent(changeEvent); | |
| } | |
| // Wait a bit for the comparison genomes to populate | |
| await new Promise(resolve => setTimeout(resolve, 500)); | |
| // Set comparison genomes | |
| const arabidopsisCheckbox = document.querySelector('.comp-genome-cb[value="arabidopsis_thaliana"]'); | |
| const populusCheckbox = document.querySelector('.comp-genome-cb[value="populus_trichocarpa"]'); | |
| if (arabidopsisCheckbox) arabidopsisCheckbox.checked = true; | |
| if (populusCheckbox) populusCheckbox.checked = true; | |
| // Update state | |
| state.user.compGenomes = ['arabidopsis_thaliana', 'populus_trichocarpa']; | |
| // Fetch genome annotations for specific genes | |
| const annotationsToFetch = ['VIT_218s0001g13100', 'VIT_218s0001g13130', 'VIT_218s0001g13200']; | |
| // Fetch annotations from API (one by one using existing function) | |
| let fetchedAnnotations = {}; | |
| try { | |
| for (const geneId of annotationsToFetch) { | |
| const result = await API.getGeneAnnotation('vitis_vinifera', geneId); | |
| if (result && result.annotation) { | |
| fetchedAnnotations[geneId] = result.annotation; | |
| } | |
| } | |
| } catch (error) { | |
| console.log('Could not fetch annotations:', error); | |
| } | |
| // Set genes with annotations | |
| state.user.genes = [ | |
| { | |
| name: 'VIT_218s0001g13100', | |
| color: '', | |
| annotationType: 'genome', | |
| customAnnotation: '', | |
| genomeAnnotation: fetchedAnnotations['VIT_218s0001g13100'] || '' | |
| }, | |
| { | |
| name: 'VIT_218s0001g13130', | |
| color: '', | |
| annotationType: 'genome', | |
| customAnnotation: '', | |
| genomeAnnotation: fetchedAnnotations['VIT_218s0001g13130'] || '' | |
| }, | |
| { | |
| name: 'VIT_218s0001g13150', | |
| color: '', | |
| annotationType: 'none', | |
| customAnnotation: '', | |
| genomeAnnotation: '' | |
| }, | |
| { | |
| name: 'VIT_218s0001g13180', | |
| color: '', | |
| annotationType: 'none', | |
| customAnnotation: '', | |
| genomeAnnotation: '' | |
| }, | |
| { | |
| name: 'VIT_218s0001g13200', | |
| color: '', | |
| annotationType: 'genome', | |
| customAnnotation: '', | |
| genomeAnnotation: fetchedAnnotations['VIT_218s0001g13200'] || '' | |
| } | |
| ]; | |
| // Render the gene rows | |
| UI.renderGeneRows(); | |
| // Wait for layouts to load | |
| await UI.loadUserLayouts(); | |
| // Find and select the 1-2 layout | |
| const layoutOptions = document.querySelectorAll('#user-layout-options .layout-option'); | |
| layoutOptions.forEach((opt, index) => { | |
| const layoutData = opt.dataset.layout; | |
| if (layoutData === '1,2') { | |
| UI.selectLayout(index); | |
| } | |
| }); | |
| // Wait for layout grid to render | |
| await new Promise(resolve => setTimeout(resolve, 100)); | |
| // Set genome assignments: grape on top, arabidopsis bottom-left, populus bottom-right | |
| state.user.genomeAssignments = ['vitis_vinifera', 'arabidopsis_thaliana', 'populus_trichocarpa']; | |
| UI.renderLayoutGrid(); | |
| // Update command preview | |
| UI.updateUserCommandPreview(); | |
| // Scroll to the top of the page | |
| window.scrollTo({ top: 0, behavior: 'smooth' }); | |
| // Show a brief notification | |
| UI.updateStatus('user-status', 'success', '✓ Example loaded! Click "Generate Microsynteny Plot" to visualize.'); | |
| } | |
| }; | |
| // ============================================================================ | |
| // Custom Synteny Handlers (Multi-Genome Comparison) | |
| // ============================================================================ | |
| const CustomSyntenyHandlers = { | |
| // Initialize the custom synteny page | |
| init() { | |
| // Start with 2 genome cards | |
| this.addGenome(); | |
| this.addGenome(); | |
| // Populate database genomes | |
| CustomSyntenyUI.populateDbGenomes(); | |
| }, | |
| // Add a new genome upload card | |
| addGenome() { | |
| if (state.customSynteny.genomes.length >= 5) { | |
| alert('Maximum 5 custom genomes allowed'); | |
| return; | |
| } | |
| state.customSynteny.genomeCounter++; | |
| state.customSynteny.genomes.push({ | |
| id: state.customSynteny.genomeCounter, | |
| displayName: '', | |
| gff3File: null, | |
| pepFile: null, | |
| uploaded: false, | |
| geneCount: 0, | |
| proteinCount: 0 | |
| }); | |
| CustomSyntenyUI.renderGenomeCards(); | |
| this.setupGenomeCardListeners(); | |
| }, | |
| // Remove a genome card by ID (kept for backwards compatibility) | |
| removeGenome(genomeId) { | |
| if (state.customSynteny.genomes.length <= 2) { | |
| alert('Minimum 2 genomes required'); | |
| return; | |
| } | |
| state.customSynteny.genomes = state.customSynteny.genomes.filter(g => g.id !== genomeId); | |
| CustomSyntenyUI.renderGenomeCards(); | |
| this.setupGenomeCardListeners(); | |
| CustomSyntenyUI.updateComparisonPairs(); | |
| }, | |
| // Remove the last genome card | |
| removeLastGenome() { | |
| if (state.customSynteny.genomes.length <= 2) { | |
| return; | |
| } | |
| state.customSynteny.genomes.pop(); | |
| CustomSyntenyUI.renderGenomeCards(); | |
| this.setupGenomeCardListeners(); | |
| CustomSyntenyUI.updateComparisonPairs(); | |
| }, | |
| // Reset all genomes to initial state (2 empty genomes) | |
| resetGenomes() { | |
| state.customSynteny.genomeCounter = 2; | |
| state.customSynteny.genomes = [ | |
| { id: 1, displayName: '', gff3File: null, pepFile: null, uploaded: false, geneCount: 0, proteinCount: 0 }, | |
| { id: 2, displayName: '', gff3File: null, pepFile: null, uploaded: false, geneCount: 0, proteinCount: 0 } | |
| ]; | |
| state.customSynteny.runKey = null; | |
| state.customSynteny.dbGenomes = []; | |
| state.customSynteny.selectedPairs = []; | |
| CustomSyntenyUI.renderGenomeCards(); | |
| this.setupGenomeCardListeners(); | |
| CustomSyntenyUI.updateComparisonPairs(); | |
| // Hide comparison section | |
| const compSection = document.getElementById('csynteny-comparison-section'); | |
| if (compSection) compSection.style.display = 'none'; | |
| }, | |
| // Setup event listeners for genome cards | |
| setupGenomeCardListeners() { | |
| // Display name inputs | |
| document.querySelectorAll('.csynteny-genome-name').forEach(input => { | |
| input.addEventListener('input', (e) => { | |
| const genomeId = parseInt(e.target.dataset.genomeId); | |
| const genome = state.customSynteny.genomes.find(g => g.id === genomeId); | |
| if (genome) { | |
| genome.displayName = e.target.value.trim(); | |
| CustomSyntenyUI.updateComparisonPairs(); | |
| } | |
| }); | |
| }); | |
| // GFF3 file inputs - with size validation | |
| document.querySelectorAll('.csynteny-gff3-file').forEach(input => { | |
| input.addEventListener('change', (e) => { | |
| const genomeId = parseInt(e.target.dataset.genomeId); | |
| const genome = state.customSynteny.genomes.find(g => g.id === genomeId); | |
| if (genome) { | |
| const file = e.target.files[0] || null; | |
| const MAX_SIZE = 1024 * 1024 * 1024; // 1 GB | |
| if (file && file.size > MAX_SIZE) { | |
| const sizeMB = (file.size / (1024 * 1024)).toFixed(1); | |
| alert(`GFF3 file too large: ${sizeMB} MB (max 1 GB)`); | |
| e.target.value = ''; // Clear the input | |
| genome.gff3File = null; | |
| } else { | |
| genome.gff3File = file; | |
| } | |
| CustomSyntenyUI.updateUploadButtonState(); | |
| } | |
| }); | |
| }); | |
| // PEP file inputs - with size validation | |
| document.querySelectorAll('.csynteny-pep-file').forEach(input => { | |
| input.addEventListener('change', (e) => { | |
| const genomeId = parseInt(e.target.dataset.genomeId); | |
| const genome = state.customSynteny.genomes.find(g => g.id === genomeId); | |
| if (genome) { | |
| const file = e.target.files[0] || null; | |
| const MAX_SIZE = 1024 * 1024 * 1024; // 1 GB | |
| if (file && file.size > MAX_SIZE) { | |
| const sizeMB = (file.size / (1024 * 1024)).toFixed(1); | |
| alert(`PEP file too large: ${sizeMB} MB (max 1 GB)`); | |
| e.target.value = ''; // Clear the input | |
| genome.pepFile = null; | |
| } else { | |
| genome.pepFile = file; | |
| } | |
| CustomSyntenyUI.updateUploadButtonState(); | |
| } | |
| }); | |
| }); | |
| }, | |
| // Handle database genome checkbox change | |
| onDbGenomeChange() { | |
| const checkboxes = document.querySelectorAll('.csynteny-db-genome-cb:checked'); | |
| const selected = Array.from(checkboxes).map(cb => cb.value); | |
| // Limit to 3 database genomes | |
| if (selected.length > 3) { | |
| alert('Maximum 3 database genomes allowed'); | |
| // Uncheck the last one | |
| const lastChecked = checkboxes[checkboxes.length - 1]; | |
| lastChecked.checked = false; | |
| lastChecked.closest('.genome-checkbox').classList.remove('checked'); | |
| return; | |
| } | |
| // Update visual state | |
| document.querySelectorAll('.csynteny-db-genome-cb').forEach(cb => { | |
| const label = cb.closest('.genome-checkbox'); | |
| label.classList.toggle('checked', cb.checked); | |
| }); | |
| state.customSynteny.selectedDbGenomes = selected; | |
| CustomSyntenyUI.updateComparisonPairs(); | |
| }, | |
| // Handle comparison pair selection change | |
| onPairSelectionChange() { | |
| const checkboxes = document.querySelectorAll('.csynteny-pair-cb:checked'); | |
| state.customSynteny.selectedPairs = Array.from(checkboxes).map(cb => ({ | |
| genome1: cb.dataset.genome1, | |
| genome2: cb.dataset.genome2 | |
| })); | |
| CustomSyntenyUI.updateUploadButtonState(); | |
| }, | |
| // Select all comparison pairs | |
| selectAllPairs() { | |
| document.querySelectorAll('.csynteny-pair-cb').forEach(cb => { | |
| cb.checked = true; | |
| cb.closest('.genome-checkbox').classList.add('checked'); | |
| }); | |
| this.onPairSelectionChange(); | |
| }, | |
| // Select no comparison pairs | |
| selectNonePairs() { | |
| document.querySelectorAll('.csynteny-pair-cb').forEach(cb => { | |
| cb.checked = false; | |
| cb.closest('.genome-checkbox').classList.remove('checked'); | |
| }); | |
| this.onPairSelectionChange(); | |
| }, | |
| // Handle upload and run MCscan | |
| async onUploadAndRun() { | |
| const btn = document.getElementById('csynteny-upload-run-btn'); | |
| const statusDiv = document.getElementById('csynteny-upload-status'); | |
| const progressBar = document.getElementById('csynteny-progress-bar'); | |
| const progressFill = progressBar.querySelector('.progress-fill'); | |
| const projectName = document.getElementById('csynteny-project-name')?.value.trim(); | |
| const visibility = document.querySelector('input[name="csynteny-visibility"]:checked')?.value || 'public'; | |
| if (!projectName) { | |
| alert('Please enter a project name'); | |
| return; | |
| } | |
| const genomesToUpload = state.customSynteny.genomes.filter(g => g.displayName && g.gff3File && g.pepFile); | |
| if (genomesToUpload.length < 2) { | |
| alert('Please provide display name, GFF3 and PEP files for at least 2 genomes'); | |
| return; | |
| } | |
| if (state.customSynteny.selectedPairs.length === 0) { | |
| alert('Please select at least one comparison pair'); | |
| return; | |
| } | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="spinner"></span> Uploading...'; | |
| progressBar.style.display = 'block'; | |
| progressFill.style.width = '0%'; | |
| statusDiv.querySelector('.status-text').textContent = 'Uploading genome files...'; | |
| try { | |
| // Create FormData with all genomes | |
| const formData = new FormData(); | |
| formData.append('project_name', projectName); | |
| formData.append('visibility', visibility); | |
| // Add custom genomes | |
| const genomeMetadata = []; | |
| genomesToUpload.forEach((genome, idx) => { | |
| formData.append(`gff3_${idx}`, genome.gff3File); | |
| formData.append(`pep_${idx}`, genome.pepFile); | |
| genomeMetadata.push({ | |
| id: genome.id, | |
| displayName: genome.displayName | |
| }); | |
| }); | |
| formData.append('genomes', JSON.stringify(genomeMetadata)); | |
| // Add database genomes | |
| formData.append('db_genomes', JSON.stringify(state.customSynteny.selectedDbGenomes)); | |
| // Add comparison pairs | |
| formData.append('pairs', JSON.stringify(state.customSynteny.selectedPairs)); | |
| // Add MCscan options | |
| const mcscanOptions = CustomSyntenyUI.getMcscanOptions(); | |
| if (mcscanOptions.cscore !== null) formData.append('cscore', mcscanOptions.cscore); | |
| if (mcscanOptions.min_anchor !== null) formData.append('min_anchor', mcscanOptions.min_anchor); | |
| if (mcscanOptions.gap_length !== null) formData.append('gap_length', mcscanOptions.gap_length); | |
| progressFill.style.width = '10%'; | |
| // Upload genomes | |
| const uploadResult = await API.customSyntenyUpload(formData); | |
| if (!uploadResult.success) { | |
| throw new Error(uploadResult.error || 'Upload failed'); | |
| } | |
| progressFill.style.width = '30%'; | |
| statusDiv.querySelector('.status-text').textContent = 'Files uploaded. Starting MCscan analysis...'; | |
| // Store run key | |
| state.customSynteny.runKey = uploadResult.run_key; | |
| // Show run key | |
| document.getElementById('csynteny-run-info').style.display = 'block'; | |
| document.getElementById('csynteny-run-key-display').textContent = uploadResult.run_key; | |
| // Start MCscan | |
| const mcscanResult = await API.customSyntenyRunMcscan({ | |
| run_key: uploadResult.run_key | |
| }); | |
| if (!mcscanResult.success) { | |
| throw new Error(mcscanResult.error || 'MCscan failed to start'); | |
| } | |
| statusDiv.querySelector('.status-text').textContent = | |
| `MCscan started. Estimated time: ~${mcscanResult.estimated_minutes} minutes.`; | |
| // Start polling for status | |
| this.startStatusPolling(); | |
| } catch (error) { | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| statusDiv.querySelector('.status-text').textContent = 'Error: ' + error.message; | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Upload & Start Analysis'; | |
| progressBar.style.display = 'none'; | |
| } | |
| }, | |
| // Start polling for job status | |
| startStatusPolling() { | |
| if (state.customSynteny.statusPollInterval) { | |
| clearInterval(state.customSynteny.statusPollInterval); | |
| } | |
| const pollStatus = async () => { | |
| const result = await API.customSyntenyStatus(state.customSynteny.runKey); | |
| if (result.success && result.data) { | |
| const status = result.data; | |
| const statusDiv = document.getElementById('csynteny-upload-status'); | |
| const progressBar = document.getElementById('csynteny-progress-bar'); | |
| const progressFill = progressBar.querySelector('.progress-fill'); | |
| progressFill.style.width = `${status.progress || 0}%`; | |
| statusDiv.querySelector('.status-text').textContent = status.message || 'Processing...'; | |
| if (status.status === 'completed') { | |
| statusDiv.querySelector('.status-icon').textContent = '✓'; | |
| progressFill.style.width = '100%'; | |
| clearInterval(state.customSynteny.statusPollInterval); | |
| const btn = document.getElementById('csynteny-upload-run-btn'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">✓</span> Analysis Complete'; | |
| btn.classList.add('btn-success'); | |
| // Load plotting section | |
| this.loadPlotSection(state.customSynteny.runKey, status.manifest); | |
| } else if (status.status === 'failed') { | |
| statusDiv.querySelector('.status-icon').textContent = '✗'; | |
| clearInterval(state.customSynteny.statusPollInterval); | |
| const btn = document.getElementById('csynteny-upload-run-btn'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Retry Analysis'; | |
| } | |
| } | |
| }; | |
| // Poll every 10 seconds | |
| state.customSynteny.statusPollInterval = setInterval(pollStatus, 10000); | |
| pollStatus(); | |
| }, | |
| // Lookup an existing run | |
| async onLookup() { | |
| const runKey = document.getElementById('csynteny-lookup-key').value.trim(); | |
| const resultDiv = document.getElementById('csynteny-lookup-result'); | |
| if (!runKey) { | |
| alert('Please enter a run key'); | |
| return; | |
| } | |
| const result = await API.customSyntenyLookup(runKey); | |
| resultDiv.style.display = 'block'; | |
| if (result.success && result.data.exists) { | |
| const data = result.data; | |
| let html = `<div class="lookup-result-card" style="padding: var(--space-md); background: var(--color-bg-tertiary); border-radius: var(--radius-sm);">`; | |
| html += `<p><strong>Run Key:</strong> ${data.run_key}</p>`; | |
| if (data.manifest) { | |
| html += `<p><strong>Project:</strong> ${data.manifest.project_name || data.run_key}</p>`; | |
| html += `<p><strong>Created:</strong> ${data.manifest.created_at}</p>`; | |
| html += `<p><strong>Genomes:</strong> ${data.manifest.genome_count || 0}</p>`; | |
| html += `<p><strong>Comparisons:</strong> ${data.manifest.comparison_count || 0}</p>`; | |
| } | |
| if (data.status) { | |
| const statusClass = data.status.status === 'completed' ? 'success' : | |
| (data.status.status === 'failed' ? 'error' : 'pending'); | |
| html += `<p><strong>Status:</strong> <span class="status-badge ${statusClass}">${data.status.status}</span></p>`; | |
| } | |
| // Add delete button | |
| html += `<div style="margin-top: var(--space-md); padding-top: var(--space-md); border-top: 1px solid var(--color-border);">`; | |
| html += `<button class="btn btn-sm btn-danger" onclick="CustomSyntenyHandlers.deleteLoadedRun('${data.run_key}')">🗑️ Delete This Project</button>`; | |
| html += `</div>`; | |
| html += `</div>`; | |
| resultDiv.innerHTML = html; | |
| if (data.status && data.status.status === 'completed') { | |
| resultDiv.innerHTML += ` | |
| <button class="btn btn-info" style="margin-top: var(--space-sm);" | |
| onclick="CustomSyntenyHandlers.loadPlotSection('${runKey}', ${JSON.stringify(data.manifest).replace(/"/g, '"')})"> | |
| 📊 Open Plotting | |
| </button>`; | |
| } | |
| } else { | |
| resultDiv.innerHTML = `<p class="error-message">✗ Run not found: ${runKey}</p>`; | |
| } | |
| }, | |
| // Delete a custom synteny project from the Load Run lookup result | |
| async deleteLoadedRun(runKey) { | |
| if (!confirm(`Delete custom synteny project "${runKey}"? This will permanently remove all files and cannot be undone.`)) { | |
| return; | |
| } | |
| const result = await API.customSyntenyDelete(runKey); | |
| if (result.success) { | |
| // Clear the lookup result | |
| const resultDiv = document.getElementById('csynteny-lookup-result'); | |
| resultDiv.innerHTML = `<p class="success-message">✓ Project "${runKey}" deleted successfully.</p>`; | |
| // Clear the lookup input | |
| document.getElementById('csynteny-lookup-key').value = ''; | |
| // Hide plot section if it was showing this project | |
| if (state.customSynteny.plotRunKey === runKey) { | |
| this.clearRun(); | |
| } | |
| // Refresh the projects list | |
| this.refreshProjectsList(); | |
| } else { | |
| alert('Error deleting project: ' + result.error); | |
| } | |
| }, | |
| // Load the plotting section | |
| async loadPlotSection(runKey, manifest) { | |
| state.customSynteny.plotRunKey = runKey; | |
| state.customSynteny.plotRunManifest = manifest; | |
| CustomSyntenyUI.openPlotSection(); | |
| // Update active run display | |
| document.getElementById('csynteny-active-run-key').textContent = runKey; | |
| const genomesCount = manifest?.genome_count || 0; | |
| document.getElementById('csynteny-active-genomes-count').textContent = | |
| genomesCount > 0 ? `(${genomesCount} genomes)` : ''; | |
| // Populate query genome dropdown | |
| await this.populateQueryGenomeSelect(runKey, manifest); | |
| // Initialize gene rows | |
| this.resetGeneRows(); | |
| // Scroll to plot section | |
| document.getElementById('csynteny-plot-block').scrollIntoView({ behavior: 'smooth' }); | |
| }, | |
| // Populate query genome select | |
| async populateQueryGenomeSelect(runKey, manifest) { | |
| const select = document.getElementById('csynteny-query-genome'); | |
| if (!select) return; | |
| const genomes = manifest?.genomes || []; | |
| const dbGenomes = manifest?.db_genomes || []; | |
| // Combine custom and db genomes | |
| const allGenomes = [ | |
| ...genomes.map(g => ({ id: g.key, name: g.displayName, isCustom: true })), | |
| ...dbGenomes.map(id => { | |
| const g = state.genomes.find(genome => genome.id === id); | |
| return { id: id, name: g ? g.name : id, isCustom: false }; | |
| }) | |
| ]; | |
| state.customSynteny.availableGenomes = allGenomes; | |
| select.innerHTML = '<option value="">-- Select query genome --</option>' + | |
| allGenomes.map(g => `<option value="${g.id}">${g.name}${g.isCustom ? ' (custom)' : ''}</option>`).join(''); | |
| // Add change listener | |
| select.onchange = () => this.onQueryGenomeChange(); | |
| }, | |
| // Handle query genome change | |
| async onQueryGenomeChange() { | |
| const select = document.getElementById('csynteny-query-genome'); | |
| state.customSynteny.queryGenome = select.value; | |
| if (!state.customSynteny.queryGenome) { | |
| return; | |
| } | |
| // Update comparison genomes (all except query) | |
| this.populateCompGenomes(); | |
| // Try to get example genes | |
| const runKey = state.customSynteny.plotRunKey; | |
| const queryGenome = state.customSynteny.queryGenome; | |
| // Check if it's a custom genome or db genome | |
| const genomeInfo = state.customSynteny.availableGenomes.find(g => g.id === queryGenome); | |
| if (genomeInfo?.isCustom) { | |
| try { | |
| const genesResult = await API.customSyntenyGenes(runKey, queryGenome); | |
| if (genesResult.success && genesResult.genes?.length > 0) { | |
| state.customSynteny.exampleGenes = genesResult.genes.slice(0, 5).map(g => g.gene_id); | |
| } | |
| } catch (e) { | |
| console.log('Could not fetch example genes'); | |
| state.customSynteny.exampleGenes = []; | |
| } | |
| } else { | |
| // For database genomes, use EXAMPLE_GENE_IDS | |
| const exampleId = EXAMPLE_GENE_IDS[queryGenome]; | |
| state.customSynteny.exampleGenes = exampleId ? [exampleId] : []; | |
| } | |
| // Update gene examples in UI | |
| CustomSyntenyUI.updateGeneExamples(state.customSynteny.exampleGenes); | |
| // Re-render gene rows to show updated placeholders | |
| this.renderGeneRows(); | |
| this.updateCommandPreview(); | |
| }, | |
| // Populate comparison genomes | |
| populateCompGenomes() { | |
| const container = document.getElementById('csynteny-plot-comp-genomes'); | |
| if (!container) return; | |
| const queryGenome = state.customSynteny.queryGenome; | |
| const availableGenomes = state.customSynteny.availableGenomes.filter(g => g.id !== queryGenome); | |
| container.innerHTML = availableGenomes.map(g => ` | |
| <label class="genome-checkbox checked"> | |
| <input type="checkbox" class="csynteny-comp-genome-cb" value="${g.id}" checked | |
| onchange="CustomSyntenyHandlers.onCompGenomeChange()"> | |
| <span class="genome-label">${g.name}${g.isCustom ? ' (custom)' : ''}</span> | |
| </label> | |
| `).join(''); | |
| state.customSynteny.compGenomes = availableGenomes.map(g => g.id); | |
| this.updateLayoutOptions(); | |
| }, | |
| // Handle comparison genome change | |
| onCompGenomeChange() { | |
| state.customSynteny.compGenomes = Array.from( | |
| document.querySelectorAll('.csynteny-comp-genome-cb:checked') | |
| ).map(cb => cb.value); | |
| // Update visual state | |
| document.querySelectorAll('.csynteny-comp-genome-cb').forEach(cb => { | |
| cb.closest('.genome-checkbox').classList.toggle('checked', cb.checked); | |
| }); | |
| this.updateLayoutOptions(); | |
| this.updateCommandPreview(); | |
| }, | |
| // Update layout options | |
| async updateLayoutOptions() { | |
| const container = document.getElementById('csynteny-layout-options'); | |
| if (!container) return; | |
| const numComps = state.customSynteny.compGenomes.length; | |
| const totalGenomes = numComps + 1; | |
| if (numComps === 0) { | |
| container.innerHTML = '<p class="empty-message">Select comparison genomes first</p>'; | |
| document.getElementById('csynteny-layout-preview-section').style.display = 'none'; | |
| state.customSynteny.layouts = []; | |
| state.customSynteny.selectedLayout = null; | |
| state.customSynteny.genomeAssignments = []; | |
| return; | |
| } | |
| state.customSynteny.layouts = await API.getLayouts(totalGenomes); | |
| container.innerHTML = state.customSynteny.layouts.map((layout, index) => ` | |
| <div class="layout-option" data-index="${index}" data-layout="${layout.layout.join(',')}"> | |
| ${layout.layout.map(count => ` | |
| <div class="layout-option-row"> | |
| ${Array(count).fill('<div class="layout-option-slot"></div>').join('')} | |
| </div> | |
| `).join('')} | |
| <span class="layout-option-name">${layout.name}</span> | |
| </div> | |
| `).join(''); | |
| container.querySelectorAll('.layout-option').forEach(opt => { | |
| opt.addEventListener('click', () => this.selectLayout(parseInt(opt.dataset.index))); | |
| }); | |
| if (state.customSynteny.layouts.length > 0) { | |
| this.selectLayout(0); | |
| } | |
| }, | |
| // Select a layout | |
| selectLayout(index) { | |
| const layout = state.customSynteny.layouts[index]; | |
| state.customSynteny.selectedLayout = layout; | |
| document.querySelectorAll('#csynteny-layout-options .layout-option').forEach((opt, i) => { | |
| opt.classList.toggle('selected', i === index); | |
| }); | |
| document.getElementById('csynteny-layout-preview-section').style.display = 'block'; | |
| this.renderLayoutGrid(); | |
| this.updateCommandPreview(); | |
| }, | |
| // Render layout grid | |
| renderLayoutGrid() { | |
| const grid = document.getElementById('csynteny-layout-grid'); | |
| const availableContainer = document.getElementById('csynteny-available-genomes'); | |
| const layout = state.customSynteny.selectedLayout; | |
| if (!layout) return; | |
| const totalSlots = state.customSynteny.compGenomes.length + 1; | |
| if (!state.customSynteny.genomeAssignments || state.customSynteny.genomeAssignments.length !== totalSlots) { | |
| state.customSynteny.genomeAssignments = [state.customSynteny.queryGenome, ...state.customSynteny.compGenomes].slice(0, totalSlots); | |
| } | |
| let gridHtml = ''; | |
| let slotIndex = 0; | |
| layout.layout.forEach((count, rowIdx) => { | |
| let slotsHtml = ''; | |
| for (let i = 0; i < count; i++) { | |
| const genome = state.customSynteny.genomeAssignments[slotIndex]; | |
| const genomeInfo = state.customSynteny.availableGenomes.find(g => g.id === genome); | |
| const isQuery = genome === state.customSynteny.queryGenome; | |
| if (genome && genomeInfo) { | |
| slotsHtml += ` | |
| <div class="layout-slot has-genome" data-slot="${slotIndex}"> | |
| <div class="genome-chip ${isQuery ? 'query-genome' : ''}" draggable="true" data-genome="${genome}"> | |
| ${isQuery ? '🔍 ' : ''}${genomeInfo.name} | |
| </div> | |
| </div> | |
| `; | |
| } else { | |
| slotsHtml += `<div class="layout-slot empty" data-slot="${slotIndex}">Drop genome</div>`; | |
| } | |
| slotIndex++; | |
| } | |
| gridHtml += ` | |
| <div class="layout-row"> | |
| <span class="layout-row-label">Row ${rowIdx + 1}</span> | |
| <div class="layout-slots">${slotsHtml}</div> | |
| </div> | |
| `; | |
| }); | |
| grid.innerHTML = gridHtml; | |
| const assignedGenomes = state.customSynteny.genomeAssignments.filter(g => g); | |
| const allGenomes = [state.customSynteny.queryGenome, ...state.customSynteny.compGenomes]; | |
| const unassignedGenomes = allGenomes.filter(g => !assignedGenomes.includes(g)); | |
| availableContainer.innerHTML = unassignedGenomes.length > 0 | |
| ? unassignedGenomes.map(genomeId => { | |
| const g = state.customSynteny.availableGenomes.find(genome => genome.id === genomeId); | |
| const isQuery = genomeId === state.customSynteny.queryGenome; | |
| return `<div class="genome-chip ${isQuery ? 'query-genome' : ''}" draggable="true" data-genome="${genomeId}">${isQuery ? '🔍 ' : ''}${g ? g.name : genomeId}</div>`; | |
| }).join('') | |
| : '<span class="empty-message">All genomes assigned</span>'; | |
| this.setupLayoutDragDrop(); | |
| }, | |
| // Setup drag and drop | |
| setupLayoutDragDrop() { | |
| const chips = document.querySelectorAll('#csynteny-layout-grid .genome-chip[draggable="true"], #csynteny-available-genomes .genome-chip[draggable="true"]'); | |
| const slots = document.querySelectorAll('#csynteny-layout-grid .layout-slot'); | |
| const availableContainer = document.getElementById('csynteny-available-genomes'); | |
| chips.forEach(chip => { | |
| chip.addEventListener('dragstart', (e) => { | |
| e.dataTransfer.setData('genome', chip.dataset.genome); | |
| chip.classList.add('dragging'); | |
| }); | |
| chip.addEventListener('dragend', () => chip.classList.remove('dragging')); | |
| }); | |
| slots.forEach(slot => { | |
| slot.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| slot.classList.add('drag-over'); | |
| }); | |
| slot.addEventListener('dragleave', () => slot.classList.remove('drag-over')); | |
| slot.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| slot.classList.remove('drag-over'); | |
| const genome = e.dataTransfer.getData('genome'); | |
| const slotIdx = parseInt(slot.dataset.slot); | |
| const prevIdx = state.customSynteny.genomeAssignments.indexOf(genome); | |
| if (prevIdx !== -1) { | |
| state.customSynteny.genomeAssignments[prevIdx] = null; | |
| } | |
| state.customSynteny.genomeAssignments[slotIdx] = genome; | |
| this.renderLayoutGrid(); | |
| this.updateCommandPreview(); | |
| }); | |
| }); | |
| availableContainer.addEventListener('dragover', (e) => e.preventDefault()); | |
| availableContainer.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| const genome = e.dataTransfer.getData('genome'); | |
| const idx = state.customSynteny.genomeAssignments.indexOf(genome); | |
| if (idx !== -1) { | |
| state.customSynteny.genomeAssignments[idx] = null; | |
| } | |
| this.renderLayoutGrid(); | |
| this.updateCommandPreview(); | |
| }); | |
| }, | |
| // Render gene input rows - UPDATED TO MATCH CUSTOM GENOME LAYOUT | |
| renderGeneRows() { | |
| const container = document.getElementById('csynteny-genes-container'); | |
| if (!container) return; | |
| const presetColors = [ | |
| { name: 'Red', value: 'r' }, | |
| { name: 'Blue', value: 'b' }, | |
| { name: 'Green', value: 'g' }, | |
| { name: 'Purple', value: 'purple' }, | |
| { name: 'Orange', value: 'orange' }, | |
| { name: 'Cyan', value: 'c' }, | |
| { name: 'Magenta', value: 'm' }, | |
| { name: 'Brown', value: 'brown' }, | |
| { name: 'Black', value: 'black' }, | |
| { name: 'Yellow', value: 'yellow' }, | |
| { name: 'Pink', value: 'pink' }, | |
| { name: 'Turquoise', value: 'turquoise' } | |
| ]; | |
| // Only No annotation and Custom annotation for Custom Synteny | |
| const annotationTypes = [ | |
| { name: 'No annotation', value: 'none' }, | |
| { name: 'Custom annotation', value: 'custom' } | |
| ]; | |
| const exampleHint = state.customSynteny.exampleGenes?.length > 0 | |
| ? `Gene ID (e.g., ${state.customSynteny.exampleGenes[0]})` | |
| : 'Gene ID'; | |
| container.innerHTML = state.customSynteny.genes.map((gene, idx) => ` | |
| <div class="gene-row" data-gene-id="${gene.id}"> | |
| <div class="gene-row-main"> | |
| <div class="gene-number">${idx + 1}</div> | |
| <input type="text" class="form-input gene-name-input" placeholder="${exampleHint}" | |
| value="${gene.name}" data-index="${idx}"> | |
| <select class="form-select gene-color-select" data-index="${idx}"> | |
| ${presetColors.map((c, i) => | |
| `<option value="${c.value}" ${(gene.color === c.value || (!gene.color && i === idx % 8)) ? 'selected' : ''}>${c.name}</option>` | |
| ).join('')} | |
| </select> | |
| <select class="form-select gene-annotation-type-select" data-index="${idx}"> | |
| ${annotationTypes.map(t => | |
| `<option value="${t.value}" ${(gene.annotationType || 'none') === t.value ? 'selected' : ''}>${t.name}</option>` | |
| ).join('')} | |
| </select> | |
| <input type="text" class="form-input gene-custom-annotation" placeholder="Custom annotation (for legend)" | |
| value="${gene.customAnnotation || ''}" data-index="${idx}" | |
| style="display: ${gene.annotationType === 'custom' ? 'inline-block' : 'none'}"> | |
| </div> | |
| </div> | |
| `).join(''); | |
| // Add event listeners | |
| container.querySelectorAll('.gene-name-input').forEach(input => { | |
| input.addEventListener('input', (e) => { | |
| const index = parseInt(e.target.dataset.index); | |
| state.customSynteny.genes[index].name = e.target.value.trim(); | |
| this.updateCommandPreview(); | |
| this.updateRequiredGeneSelect(); | |
| }); | |
| }); | |
| container.querySelectorAll('.gene-color-select').forEach(select => { | |
| select.addEventListener('change', (e) => { | |
| const index = parseInt(e.target.dataset.index); | |
| state.customSynteny.genes[index].color = e.target.value; | |
| }); | |
| // Set initial color if not set | |
| const index = parseInt(select.dataset.index); | |
| if (!state.customSynteny.genes[index].color) { | |
| state.customSynteny.genes[index].color = select.value; | |
| } | |
| }); | |
| container.querySelectorAll('.gene-annotation-type-select').forEach(select => { | |
| select.addEventListener('change', (e) => { | |
| const index = parseInt(e.target.dataset.index); | |
| const newType = e.target.value; | |
| state.customSynteny.genes[index].annotationType = newType; | |
| // Show/hide custom annotation input | |
| const row = e.target.closest('.gene-row'); | |
| const customInput = row.querySelector('.gene-custom-annotation'); | |
| customInput.style.display = newType === 'custom' ? 'inline-block' : 'none'; | |
| }); | |
| }); | |
| container.querySelectorAll('.gene-custom-annotation').forEach(input => { | |
| input.addEventListener('input', (e) => { | |
| const index = parseInt(e.target.dataset.index); | |
| state.customSynteny.genes[index].customAnnotation = e.target.value; | |
| }); | |
| }); | |
| }, | |
| // Update required gene select checkboxes | |
| updateRequiredGeneSelect() { | |
| const container = document.getElementById('csynteny-required-genes-container'); | |
| if (!container) return; | |
| const validGenes = state.customSynteny.genes.filter(g => g.name.trim()); | |
| if (validGenes.length === 0) { | |
| container.innerHTML = '<p class="empty-message">Enter genes first</p>'; | |
| return; | |
| } | |
| container.innerHTML = validGenes.map((gene, idx) => ` | |
| <label class="checkbox-label"> | |
| <input type="checkbox" class="required-gene-checkbox" data-gene="${gene.name}"> | |
| <span>${gene.name}</span> | |
| </label> | |
| `).join(''); | |
| }, | |
| // Add gene row | |
| addGeneRow() { | |
| state.customSynteny.geneCounter++; | |
| const colorIndex = state.customSynteny.genes.length % 8; | |
| const colors = ['r', 'b', 'g', 'purple', 'orange', 'c', 'm', 'brown']; | |
| state.customSynteny.genes.push({ | |
| id: state.customSynteny.geneCounter, | |
| name: '', | |
| color: colors[colorIndex], | |
| annotationType: 'none', | |
| customAnnotation: '' | |
| }); | |
| this.renderGeneRows(); | |
| }, | |
| // Remove last gene row | |
| removeGeneRow() { | |
| if (state.customSynteny.genes.length > 1) { | |
| state.customSynteny.genes.pop(); | |
| this.renderGeneRows(); | |
| this.updateCommandPreview(); | |
| } | |
| }, | |
| // Reset gene rows | |
| resetGeneRows() { | |
| state.customSynteny.geneCounter = 3; | |
| state.customSynteny.genes = [ | |
| { id: 1, name: '', color: 'r', annotationType: 'none', customAnnotation: '' }, | |
| { id: 2, name: '', color: 'b', annotationType: 'none', customAnnotation: '' }, | |
| { id: 3, name: '', color: 'g', annotationType: 'none', customAnnotation: '' } | |
| ]; | |
| this.renderGeneRows(); | |
| }, | |
| // Update command preview | |
| updateCommandPreview() { | |
| const preview = document.getElementById('csynteny-command-preview'); | |
| if (!preview) return; | |
| const queryGenome = state.customSynteny.queryGenome; | |
| const genes = state.customSynteny.genes.filter(g => g.name).map(g => g.name); | |
| const comps = state.customSynteny.compGenomes; | |
| if (!queryGenome || genes.length === 0 || comps.length === 0) { | |
| preview.textContent = 'Select query genome and enter genes to see command'; | |
| return; | |
| } | |
| const queryInfo = state.customSynteny.availableGenomes.find(g => g.id === queryGenome); | |
| let cmd = `Query: ${queryInfo?.name || queryGenome}\nGenes: ${genes.join(', ')}\nComparisons: ${comps.length} genomes`; | |
| if (state.customSynteny.selectedLayout) { | |
| cmd += `\nLayout: ${state.customSynteny.selectedLayout.name}`; | |
| } | |
| preview.textContent = cmd; | |
| }, | |
| // Generate plot - UPDATED TO MATCH CUSTOM GENOME APPROACH | |
| async onGeneratePlot(isRegeneration) { | |
| const btn = document.getElementById('csynteny-generate-btn'); | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="spinner"></span> Generating...'; | |
| // Hide previous results (like Microsynteny page) | |
| document.getElementById('csynteny-results').style.display = 'none'; | |
| const runKey = state.customSynteny.plotRunKey; | |
| const queryGenome = state.customSynteny.queryGenome; | |
| if (!runKey || !queryGenome) { | |
| UI.updateStatus('csynteny-plot-status', 'error', 'Please select a query genome first'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Generate Microsynteny Plot'; | |
| return; | |
| } | |
| const genes = state.customSynteny.genes.filter(g => g.name.trim()).map(g => g.name.trim()); | |
| if (genes.length === 0) { | |
| UI.updateStatus('csynteny-plot-status', 'error', 'Please enter at least one gene'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Generate Microsynteny Plot'; | |
| return; | |
| } | |
| // Build full genome order from layout assignments (like standard page) | |
| let genomeOrder = []; | |
| if (state.customSynteny.selectedLayout && state.customSynteny.genomeAssignments.length > 0) { | |
| // Check if all slots are filled | |
| const filledSlots = state.customSynteny.genomeAssignments.filter(g => g); | |
| if (filledSlots.length !== state.customSynteny.genomeAssignments.length) { | |
| UI.updateStatus('csynteny-plot-status', 'error', 'Please assign all genomes to layout slots'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Generate Microsynteny Plot'; | |
| return; | |
| } | |
| genomeOrder = state.customSynteny.genomeAssignments; | |
| } else { | |
| // Default: query first, then comparisons | |
| genomeOrder = [queryGenome, ...state.customSynteny.compGenomes]; | |
| } | |
| // Extract comparisons (all genomes except query) in their layout order | |
| const comparisons = genomeOrder.filter(g => g !== queryGenome); | |
| if (comparisons.length === 0) { | |
| UI.updateStatus('csynteny-plot-status', 'error', 'Please select at least one comparison genome'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Generate Microsynteny Plot'; | |
| return; | |
| } | |
| // Get colors for genes - use tweaking colors if regenerating, otherwise use state | |
| let colors; | |
| if (isRegeneration === true && typeof PlotTweaking !== 'undefined') { | |
| colors = PlotTweaking.getGeneColors('csynteny'); | |
| } else { | |
| colors = state.customSynteny.genes.filter(g => g.name.trim()).map(g => g.color || ''); | |
| } | |
| // Build annotations array based on user selection (like Custom Genome) | |
| const annotations = state.customSynteny.genes.filter(g => g.name.trim()).map(g => { | |
| if (g.annotationType === 'custom') { | |
| return g.customAnnotation || ''; | |
| } | |
| return ''; // 'none' or 'genome' - no custom annotation | |
| }); | |
| // Get tweaking configuration if this is a regeneration | |
| const tweakingConfig = (isRegeneration === true && typeof PlotTweaking !== 'undefined') ? PlotTweaking.getTweakingConfig('csynteny') : {}; | |
| // Get layout | |
| let layout = null; | |
| let finalGenomeOrder = null; | |
| if (state.customSynteny.selectedLayout) { | |
| layout = state.customSynteny.selectedLayout.layout; | |
| // Genome order for backend (already in correct order) | |
| finalGenomeOrder = genomeOrder; | |
| } | |
| UI.updateStatus('csynteny-plot-status', 'running', 'Generating plot... This may take a few seconds.'); | |
| const result = await API.customSyntenyPlot({ | |
| run_key: runKey, | |
| query_genome: queryGenome, | |
| genes: genes, | |
| colors: colors, | |
| annotations: annotations, | |
| comparisons: comparisons, | |
| layout: layout, | |
| genome_order: finalGenomeOrder, | |
| ...tweakingConfig // Spread tweaking parameters (padding_config, max_genes_config, display_names) | |
| }); | |
| if (result.success) { | |
| UI.updateStatus('csynteny-plot-status', 'success', 'Plot generated successfully!'); | |
| const resultsCard = document.getElementById('csynteny-results'); | |
| resultsCard.style.display = 'block'; | |
| if (result.files?.png) { | |
| document.getElementById('csynteny-plot-image').src = `/api/image/${result.output_folder}/${result.files.png}?t=${Date.now()}`; | |
| } | |
| if (result.files) { | |
| if (result.files.png) document.getElementById('csynteny-download-png').href = `/api/download/${result.output_folder}/${result.files.png}`; | |
| if (result.files.svg) document.getElementById('csynteny-download-svg').href = `/api/download/${result.output_folder}/${result.files.svg}`; | |
| if (result.files.csv) document.getElementById('csynteny-download-csv').href = `/api/download/${result.output_folder}/${result.files.csv}`; | |
| } | |
| // Initialize plot tweaking controls (only on initial plot, not regeneration) | |
| if (isRegeneration !== true && typeof PlotTweaking !== 'undefined') { | |
| // Pass query genome, comparison genomes, and gene objects properly | |
| const compGenomes = genomeOrder.filter(g => g !== queryGenome); | |
| // Pass genes as array of gene objects (like user workflow) | |
| const geneObjects = state.customSynteny.genes.filter(g => g.name.trim()); | |
| PlotTweaking.initialize('csynteny', queryGenome, compGenomes, geneObjects); | |
| } | |
| resultsCard.scrollIntoView({ behavior: 'smooth' }); | |
| } else { | |
| UI.updateStatus('csynteny-plot-status', 'error', result.error || 'Failed to generate plot'); | |
| } | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">▶</span> Generate Microsynteny Plot'; | |
| }, | |
| // Select all comparison pairs | |
| selectAllPairs() { | |
| const container = document.getElementById('csynteny-comparison-pairs'); | |
| if (!container) return; | |
| container.querySelectorAll('.comparison-pair-checkbox').forEach(cb => { | |
| cb.checked = true; | |
| }); | |
| this.onPairSelectionChange(); | |
| }, | |
| // Select no comparison pairs | |
| selectNonePairs() { | |
| const container = document.getElementById('csynteny-comparison-pairs'); | |
| if (!container) return; | |
| container.querySelectorAll('.comparison-pair-checkbox').forEach(cb => { | |
| cb.checked = false; | |
| }); | |
| this.onPairSelectionChange(); | |
| }, | |
| // Advanced Search for syntenic hits - COPIED FROM CUSTOM GENOME (onCustomSearchHits) | |
| async onSearchHits() { | |
| const btn = document.getElementById('csynteny-search-hits-btn'); | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="spinner"></span> Searching...'; | |
| const resultsContainer = document.getElementById('csynteny-search-results-container'); | |
| resultsContainer.style.display = 'none'; | |
| const queryGenome = state.customSynteny.queryGenome; | |
| if (!queryGenome) { | |
| UI.updateStatus('csynteny-plot-status', 'error', 'Please select a query genome first'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">🔍</span> Search Syntenic Hits'; | |
| return; | |
| } | |
| const genes = state.customSynteny.genes.filter(g => g.name.trim()).map(g => g.name.trim()); | |
| if (genes.length === 0) { | |
| UI.updateStatus('csynteny-plot-status', 'error', 'Please enter at least one gene'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">🔍</span> Search Syntenic Hits'; | |
| return; | |
| } | |
| // Check if "search all genomes" is checked | |
| const searchAllGenomes = document.getElementById('csynteny-search-all-genomes-checkbox')?.checked || false; | |
| let comps; | |
| if (searchAllGenomes) { | |
| // Use all available genomes except the query | |
| comps = state.customSynteny.availableGenomes | |
| .filter(g => g.id !== queryGenome) | |
| .map(g => g.id); | |
| } else { | |
| comps = state.customSynteny.compGenomes; | |
| } | |
| if (comps.length === 0) { | |
| UI.updateStatus('csynteny-plot-status', 'error', 'Please select at least one comparison genome or check "Search against all available genomes"'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">🔍</span> Search Syntenic Hits'; | |
| return; | |
| } | |
| const minHits = parseInt(document.getElementById('csynteny-min-hits-input')?.value) || 1; | |
| // Get required genes from checkboxes (matching Custom Genome format) | |
| const requiredGenes = Array.from(document.querySelectorAll('#csynteny-required-genes-container .required-gene-checkbox:checked')) | |
| .map(cb => cb.dataset.gene || cb.value); | |
| console.log('Custom Synteny Search Hits Debug:', { | |
| queryGenome: queryGenome, | |
| genes: genes, | |
| comps: comps, | |
| searchAllGenomes: searchAllGenomes, | |
| minHits: minHits, | |
| requiredGenes: requiredGenes | |
| }); | |
| // Use the same search-hits API - query_genome is the genome key | |
| const result = await API.searchHits({ | |
| query_genome: queryGenome, | |
| genes: genes, | |
| comparisons: comps, | |
| min_hits: minHits, | |
| required_genes: requiredGenes | |
| }); | |
| console.log('Custom Synteny Search Hits Response:', result); | |
| if (result.success) { | |
| this.displaySearchResults(result.data); | |
| } else { | |
| UI.updateStatus('csynteny-plot-status', 'error', result.error || 'Search failed'); | |
| } | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-icon">🔍</span> Search Syntenic Hits'; | |
| }, | |
| // Display search results - COPIED FROM CUSTOM GENOME (displayCustomSearchResults) | |
| displaySearchResults(data) { | |
| const container = document.getElementById('csynteny-search-results-container'); | |
| const summary = document.getElementById('csynteny-search-summary'); | |
| const tableBody = document.querySelector('#csynteny-search-results-table tbody'); | |
| container.style.display = 'block'; | |
| // Show summary | |
| if (data.filter_message) { | |
| summary.innerHTML = `<strong>⚠️ Filter not met:</strong> ${data.filter_message}`; | |
| summary.style.background = 'linear-gradient(135deg, #fef3c7, #fde68a)'; | |
| summary.style.color = '#92400e'; | |
| } else { | |
| let summaryHtml = ` | |
| <strong>✓ Found ${data.total_matches || 0} high-confidence matches</strong><br> | |
| <span>Query genes with matches: ${data.genes_with_matches || 0} / ${state.customSynteny.genes.filter(g => g.name).length}</span> | |
| `; | |
| summary.innerHTML = summaryHtml; | |
| summary.style.background = 'linear-gradient(135deg, #d1fae5, #a7f3d0)'; | |
| summary.style.color = '#065f46'; | |
| } | |
| // Destroy existing DataTable if initialized | |
| if ($.fn.DataTable.isDataTable('#csynteny-search-results-table')) { | |
| $('#csynteny-search-results-table').DataTable().clear().destroy(); | |
| } | |
| // Clear and populate table | |
| tableBody.innerHTML = ''; | |
| if (data.results && data.results.length > 0) { | |
| data.results.forEach(row => { | |
| const tr = document.createElement('tr'); | |
| tr.innerHTML = ` | |
| <td>${row.query_gene}</td> | |
| <td>${row.comparison_genome}</td> | |
| <td>${row.ortholog}</td> | |
| <td>${row.chromosome}</td> | |
| <td>${row.start}</td> | |
| <td>${row.end}</td> | |
| <td>${row.identity}</td> | |
| `; | |
| tableBody.appendChild(tr); | |
| }); | |
| // Initialize DataTable | |
| $('#csynteny-search-results-table').DataTable({ | |
| paging: true, | |
| pageLength: 10, | |
| searching: true, | |
| ordering: true, | |
| scrollX: true, | |
| order: [[1, 'asc']] | |
| }); | |
| // Create downloadable TSV | |
| const tsvContent = this.generateSearchTSV(data.results); | |
| const blob = new Blob([tsvContent], { type: 'text/tab-separated-values' }); | |
| const url = URL.createObjectURL(blob); | |
| document.getElementById('csynteny-download-search-results').href = url; | |
| document.getElementById('csynteny-download-search-results').style.display = 'inline-flex'; | |
| } else { | |
| tableBody.innerHTML = '<tr><td colspan="7" class="empty-message">No matches found</td></tr>'; | |
| document.getElementById('csynteny-download-search-results').style.display = 'none'; | |
| } | |
| // Scroll to results | |
| container.scrollIntoView({ behavior: 'smooth' }); | |
| }, | |
| // Generate TSV content from search results - COPIED FROM Custom Genome | |
| generateSearchTSV(results) { | |
| const headers = ['Query_Gene', 'Comparison_Genome', 'Ortholog_Gene', 'Chromosome', 'Start', 'End', 'Identity']; | |
| const rows = results.map(r => [ | |
| r.query_gene, r.comparison_genome, r.ortholog, | |
| r.chromosome, r.start, r.end, r.identity | |
| ].join('\t')); | |
| return [headers.join('\t'), ...rows].join('\n'); | |
| }, | |
| // Clear loaded run | |
| clearRun() { | |
| state.customSynteny.plotRunKey = null; | |
| state.customSynteny.plotRunManifest = null; | |
| state.customSynteny.availableGenomes = []; | |
| state.customSynteny.queryGenome = ''; | |
| state.customSynteny.compGenomes = []; | |
| state.customSynteny.genes = []; | |
| state.customSynteny.exampleGenes = []; | |
| CustomSyntenyUI.openMcscanSection(); | |
| document.getElementById('csynteny-lookup-key').value = ''; | |
| document.getElementById('csynteny-lookup-result').style.display = 'none'; | |
| }, | |
| // Refresh projects list | |
| async refreshProjectsList() { | |
| const container = document.getElementById('csynteny-projects-list'); | |
| const result = await API.customSyntenyProjects(); | |
| if (result.success && result.projects?.length > 0) { | |
| // Store all projects for pagination | |
| state.customSynteny.allProjects = result.projects; | |
| state.customSynteny.currentPage = 1; | |
| this.displayProjectsPage(1); | |
| this.setupProjectsPagination(); | |
| } else { | |
| state.customSynteny.allProjects = []; | |
| container.innerHTML = '<p class="empty-message">No custom synteny projects available</p>'; | |
| document.getElementById('csynteny-projects-pagination').style.display = 'none'; | |
| } | |
| }, | |
| // Display a specific page of projects | |
| displayProjectsPage(pageNum) { | |
| const container = document.getElementById('csynteny-projects-list'); | |
| const projects = state.customSynteny.allProjects; | |
| const itemsPerPage = state.customSynteny.itemsPerPage; | |
| const totalPages = Math.ceil(projects.length / itemsPerPage); | |
| state.customSynteny.currentPage = pageNum; | |
| const startIdx = (pageNum - 1) * itemsPerPage; | |
| const endIdx = Math.min(startIdx + itemsPerPage, projects.length); | |
| const pageProjects = projects.slice(startIdx, endIdx); | |
| container.innerHTML = pageProjects.map(p => { | |
| const name = p.manifest?.project_name || p.run_key; | |
| const status = p.status || 'unknown'; | |
| const statusClass = status === 'completed' ? 'success' : (status === 'failed' ? 'error' : 'pending'); | |
| return ` | |
| <div class="custom-genome-item" style="display: flex; justify-content: space-between; align-items: center; padding: var(--space-sm); border-bottom: 1px solid var(--color-border);"> | |
| <div class="custom-genome-info"> | |
| <strong>${name}</strong> | |
| <span class="custom-genome-key" style="color: var(--color-text-muted); font-size: 0.85em;">${p.run_key}</span> | |
| <span class="status-badge ${statusClass}">${status}</span> | |
| </div> | |
| <div> | |
| <button class="btn btn-sm btn-info" onclick="CustomSyntenyHandlers.loadRunFromList('${p.run_key}')">📊 Plot</button> | |
| <button class="btn btn-sm btn-danger" onclick="CustomSyntenyHandlers.deleteProject('${p.run_key}')">🗑️</button> | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| // Update pagination controls | |
| const prevBtn = document.getElementById('csynteny-projects-prev-page'); | |
| const nextBtn = document.getElementById('csynteny-projects-next-page'); | |
| const pageInfo = document.getElementById('csynteny-projects-page-info'); | |
| const pageTotal = document.getElementById('csynteny-projects-page-total'); | |
| prevBtn.disabled = pageNum <= 1; | |
| nextBtn.disabled = pageNum >= totalPages; | |
| prevBtn.classList.toggle('disabled', pageNum <= 1); | |
| nextBtn.classList.toggle('disabled', pageNum >= totalPages); | |
| pageInfo.textContent = pageNum; | |
| pageTotal.textContent = `of ${totalPages || 1} (${projects.length} total)`; | |
| // Show/hide pagination if only one page | |
| const paginationDiv = document.getElementById('csynteny-projects-pagination'); | |
| paginationDiv.style.display = totalPages > 1 ? 'block' : 'none'; | |
| }, | |
| // Setup pagination controls for projects list | |
| setupProjectsPagination() { | |
| const prevBtn = document.getElementById('csynteny-projects-prev-page'); | |
| const nextBtn = document.getElementById('csynteny-projects-next-page'); | |
| // Remove old listeners by cloning | |
| const newPrevBtn = prevBtn.cloneNode(true); | |
| const newNextBtn = nextBtn.cloneNode(true); | |
| prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn); | |
| nextBtn.parentNode.replaceChild(newNextBtn, nextBtn); | |
| newPrevBtn.addEventListener('click', () => { | |
| if (state.customSynteny.currentPage > 1) { | |
| this.displayProjectsPage(state.customSynteny.currentPage - 1); | |
| } | |
| }); | |
| newNextBtn.addEventListener('click', () => { | |
| const totalPages = Math.ceil(state.customSynteny.allProjects.length / state.customSynteny.itemsPerPage); | |
| if (state.customSynteny.currentPage < totalPages) { | |
| this.displayProjectsPage(state.customSynteny.currentPage + 1); | |
| } | |
| }); | |
| }, | |
| // Load run from list - go DIRECTLY to plotting (like Custom Genome) | |
| async loadRunFromList(runKey) { | |
| // Fetch manifest directly and open plotting section | |
| const result = await API.customSyntenyLookup(runKey); | |
| if (result.success && result.data.exists && result.data.status?.status === 'completed') { | |
| // Go directly to plotting section | |
| this.loadPlotSection(runKey, result.data.manifest); | |
| } else if (result.success && result.data.exists) { | |
| // Not completed - show message | |
| alert(`Project "${runKey}" is not yet completed. Status: ${result.data.status?.status || 'unknown'}`); | |
| } else { | |
| alert(`Could not load project: ${runKey}`); | |
| } | |
| }, | |
| // Delete project | |
| async deleteProject(runKey) { | |
| if (!confirm(`Delete project "${runKey}"? This cannot be undone.`)) return; | |
| const result = await API.customSyntenyDelete(runKey); | |
| if (result.success) { | |
| this.refreshProjectsList(); | |
| if (state.customSynteny.plotRunKey === runKey) { | |
| this.clearRun(); | |
| } | |
| } else { | |
| alert('Error deleting: ' + result.error); | |
| } | |
| }, | |
| // Copy run key | |
| copyRunKey() { | |
| const key = document.getElementById('csynteny-run-key-display').textContent; | |
| navigator.clipboard.writeText(key).then(() => { | |
| const btn = document.getElementById('csynteny-copy-key-btn'); | |
| btn.textContent = '✓ Copied!'; | |
| setTimeout(() => btn.textContent = '📋 Copy', 2000); | |
| }); | |
| } | |
| }; | |
| // ============================================================================ | |
| // Discovery Handlers | |
| // ============================================================================ | |
| const DiscoveryHandlers = { | |
| // State for selected terms (Term Search - using groups) | |
| requiredGroups: [[]], // Array of arrays: each group contains terms with OR logic within | |
| groupMinMatch: [1], // Parallel array: minMatch for each group (default 1) | |
| selectedOptionalTerms: [], | |
| allTerms: [], | |
| // State for Annotation Search (using groups) | |
| requiredGroupsAnnotation: [[]], | |
| groupMinMatchAnnotation: [1], // Parallel array: minMatch for each annotation group | |
| selectedOptionalAnnotations: [], | |
| allAnnotations: [], | |
| // State for Paralogous Search (Gene names, using groups) | |
| requiredGroupsGene: [[]], | |
| groupMinMatchGene: [1], // Parallel array: minMatch for each gene group | |
| selectedOptionalGenes: [], | |
| allGenes: [], | |
| // Current search tab | |
| currentSearchTab: 'term', // 'annotation', 'term', 'paralogous' | |
| genomeType: 'database', // 'database' or 'custom' | |
| // Initialize Discovery tab | |
| async init() { | |
| // Restore genome tab from sessionStorage, or default to 'database' | |
| const savedGenomeTab = sessionStorage.getItem('plantmsyn-discovery-genome-tab') || 'database'; | |
| this.genomeType = savedGenomeTab; | |
| // Apply the tab state immediately (before any rendering) | |
| const dbTab = document.getElementById('discovery-db-genome-tab'); | |
| const customTab = document.getElementById('discovery-custom-genome-tab'); | |
| const dbPanel = document.getElementById('discovery-db-genome-panel'); | |
| const customPanel = document.getElementById('discovery-custom-genome-panel'); | |
| if (savedGenomeTab === 'database') { | |
| if (dbTab) dbTab.className = 'btn btn-primary'; | |
| if (customTab) customTab.className = 'btn btn-secondary'; | |
| if (dbPanel) dbPanel.style.display = 'block'; | |
| if (customPanel) customPanel.style.display = 'none'; | |
| } else { | |
| if (dbTab) dbTab.className = 'btn btn-secondary'; | |
| if (customTab) customTab.className = 'btn btn-primary'; | |
| if (dbPanel) dbPanel.style.display = 'none'; | |
| if (customPanel) customPanel.style.display = 'block'; | |
| } | |
| this.populateGenomeDropdown(); | |
| this.populateCompGenomes(); | |
| this.setupEventListeners(); | |
| // Wait for custom genomes to load before revealing content | |
| await this.refreshCustomGenomes(); | |
| // Reveal the genome card body now that everything is loaded | |
| const genomeCardBody = document.getElementById('discovery-genome-card-body'); | |
| if (genomeCardBody) { | |
| genomeCardBody.style.opacity = '1'; | |
| } | |
| }, | |
| // Switch between search type tabs (Annotation, Term, Paralogous) | |
| switchSearchTab(tabName) { | |
| this.currentSearchTab = tabName; | |
| const annotationTab = document.getElementById('discovery-tab-annotation'); | |
| const termTab = document.getElementById('discovery-tab-term'); | |
| const paralogousTab = document.getElementById('discovery-tab-paralogous'); | |
| const annotationContent = document.getElementById('discovery-content-annotation'); | |
| const termContent = document.getElementById('discovery-content-term'); | |
| const paralogousContent = document.getElementById('discovery-content-paralogous'); | |
| // Reset all tabs | |
| [annotationTab, termTab, paralogousTab].forEach(tab => { | |
| if (tab) { | |
| tab.style.background = 'var(--color-bg-secondary)'; | |
| tab.style.color = 'var(--color-text)'; | |
| } | |
| }); | |
| [annotationContent, termContent, paralogousContent].forEach(content => { | |
| if (content) content.style.display = 'none'; | |
| }); | |
| // Activate selected tab | |
| if (tabName === 'annotation') { | |
| if (annotationTab) { | |
| annotationTab.style.background = 'var(--color-accent-primary)'; | |
| annotationTab.style.color = 'white'; | |
| } | |
| if (annotationContent) annotationContent.style.display = 'block'; | |
| } else if (tabName === 'term') { | |
| if (termTab) { | |
| termTab.style.background = 'var(--color-accent-primary)'; | |
| termTab.style.color = 'white'; | |
| } | |
| if (termContent) termContent.style.display = 'block'; | |
| } else if (tabName === 'paralogous') { | |
| if (paralogousTab) { | |
| paralogousTab.style.background = 'var(--color-accent-primary)'; | |
| paralogousTab.style.color = 'white'; | |
| } | |
| if (paralogousContent) paralogousContent.style.display = 'block'; | |
| } | |
| }, | |
| // Switch between database and custom genome tabs | |
| switchGenomeTab(type) { | |
| this.genomeType = type; | |
| // Save to sessionStorage for persistence on reload | |
| sessionStorage.setItem('plantmsyn-discovery-genome-tab', type); | |
| const dbTab = document.getElementById('discovery-db-genome-tab'); | |
| const customTab = document.getElementById('discovery-custom-genome-tab'); | |
| const dbPanel = document.getElementById('discovery-db-genome-panel'); | |
| const customPanel = document.getElementById('discovery-custom-genome-panel'); | |
| const statusDiv = document.getElementById('discovery-genome-annotation-status'); | |
| if (type === 'database') { | |
| dbTab.className = 'btn btn-primary'; | |
| customTab.className = 'btn btn-secondary'; | |
| dbPanel.style.display = 'block'; | |
| customPanel.style.display = 'none'; | |
| // Clear custom selection | |
| document.getElementById('discovery-custom-genome-select').value = ''; | |
| document.getElementById('discovery-custom-genome-info').style.display = 'none'; | |
| } else { | |
| dbTab.className = 'btn btn-secondary'; | |
| customTab.className = 'btn btn-primary'; | |
| dbPanel.style.display = 'none'; | |
| customPanel.style.display = 'block'; | |
| // Clear database selection | |
| document.getElementById('discovery-query-genome').value = ''; | |
| state.discovery.queryGenome = null; | |
| } | |
| // Clear annotation status and terms/groups | |
| statusDiv.style.display = 'none'; | |
| this.requiredGroups = [[]]; | |
| this.selectedOptionalTerms = []; | |
| this.allTerms = []; | |
| this.requiredGroupsAnnotation = [[]]; | |
| this.selectedOptionalAnnotations = []; | |
| this.allAnnotations = []; | |
| this.requiredGroupsGene = [[]]; | |
| this.selectedOptionalGenes = []; | |
| this.allGenes = []; | |
| this.renderAvailableTerms([]); | |
| this.renderRequiredGroups('term'); | |
| this.renderSelectedOptionalTerms(); | |
| this.renderAvailableAnnotations([]); | |
| this.renderRequiredGroups('annotation'); | |
| this.renderSelectedOptionalAnnotations(); | |
| this.renderAvailableGenes([]); | |
| this.renderRequiredGroups('paralogous'); | |
| this.renderSelectedOptionalGenes(); | |
| }, | |
| // Refresh custom genomes list | |
| async refreshCustomGenomes() { | |
| const select = document.getElementById('discovery-custom-genome-select'); | |
| if (!select) return; | |
| // Don't show "Loading..." to avoid visual flash - just fetch in background | |
| const result = await API.customGenomes(); | |
| if (result.success && result.genomes && result.genomes.length > 0) { | |
| const completedGenomes = result.genomes.filter(g => g.status === 'completed'); | |
| if (completedGenomes.length > 0) { | |
| select.innerHTML = '<option value="">-- Select a custom genome --</option>' + | |
| completedGenomes.map(g => { | |
| const name = g.manifest?.display_name || g.run_key; | |
| const keyShort = g.run_key.length > 12 ? g.run_key.substring(0, 12) + '...' : g.run_key; | |
| return `<option value="${g.run_key}">${name} (${keyShort})</option>`; | |
| }).join(''); | |
| } else { | |
| select.innerHTML = '<option value="">No completed custom genomes</option>'; | |
| } | |
| } else { | |
| select.innerHTML = '<option value="">No custom genomes available</option>'; | |
| } | |
| }, | |
| // Load private genome by key | |
| async loadPrivateGenome() { | |
| const keyInput = document.getElementById('discovery-private-key'); | |
| const key = keyInput.value.trim(); | |
| const infoDiv = document.getElementById('discovery-custom-genome-info'); | |
| const statusDiv = document.getElementById('discovery-genome-annotation-status'); | |
| if (!key) { | |
| statusDiv.style.display = 'block'; | |
| statusDiv.innerHTML = '<span>⚠️ Please enter a project key</span>'; | |
| statusDiv.className = 'alert alert-warning'; | |
| return; | |
| } | |
| // Verify the key exists | |
| const result = await API.customLookup(key); | |
| if (result.success && result.data && result.data.exists) { | |
| state.discovery.queryGenome = key; | |
| state.discovery.isCustomGenome = true; | |
| infoDiv.style.display = 'block'; | |
| // Show success message with genome name (manifest is inside result.data) | |
| const genomeName = result.data.manifest?.display_name || key; | |
| statusDiv.style.display = 'block'; | |
| statusDiv.innerHTML = `<span>✓ Genome: <strong>${genomeName}</strong></span>`; | |
| statusDiv.className = 'alert alert-success'; | |
| // Extract comparison genomes from manifest | |
| if (result.data.manifest && result.data.manifest.comparison_genomes) { | |
| state.discovery.customComparisonGenomes = result.data.manifest.comparison_genomes; | |
| } else { | |
| state.discovery.customComparisonGenomes = null; | |
| } | |
| // Update comparison genomes to only show those from the custom run | |
| this.populateCompGenomes(); | |
| // Clear terms since custom genomes don't have built-in annotations | |
| this.allTerms = []; | |
| this.renderAvailableTerms([]); | |
| this.selectedMustTerms = []; | |
| this.selectedOptionalTerms = []; | |
| this.renderSelectedTerms(); | |
| // Clear the dropdown selection | |
| document.getElementById('discovery-custom-genome-select').value = ''; | |
| } else { | |
| statusDiv.style.display = 'block'; | |
| statusDiv.innerHTML = `<span>❌ ${result.error || 'Project not found'}</span>`; | |
| statusDiv.className = 'alert alert-error'; | |
| infoDiv.style.display = 'none'; | |
| } | |
| }, | |
| // Handle custom genome selection | |
| async onCustomGenomeChange() { | |
| const select = document.getElementById('discovery-custom-genome-select'); | |
| const customGenome = select.value; | |
| const infoDiv = document.getElementById('discovery-custom-genome-info'); | |
| const statusDiv = document.getElementById('discovery-genome-annotation-status'); | |
| if (customGenome) { | |
| state.discovery.queryGenome = customGenome; | |
| state.discovery.isCustomGenome = true; | |
| infoDiv.style.display = 'block'; | |
| // Show success message with genome name (extract from option text before the key) | |
| const selectedOption = select.options[select.selectedIndex]; | |
| let genomeName = selectedOption ? selectedOption.text : customGenome; | |
| // Remove the project key suffix like " (abc123...)" from the display | |
| genomeName = genomeName.replace(/\s*\([^)]+\)$/, ''); | |
| statusDiv.style.display = 'block'; | |
| statusDiv.innerHTML = `<span>✓ Genome: <strong>${genomeName}</strong></span>`; | |
| statusDiv.className = 'alert alert-success'; | |
| // Clear terms since custom genomes don't have built-in annotations | |
| this.allTerms = []; | |
| this.renderAvailableTerms([]); | |
| this.selectedMustTerms = []; | |
| this.selectedOptionalTerms = []; | |
| this.renderSelectedTerms(); | |
| // Clear private key input | |
| document.getElementById('discovery-private-key').value = ''; | |
| // Fetch the manifest to get the comparison genomes used in this run | |
| try { | |
| const result = await API.customLookup(customGenome); | |
| if (result.success && result.data && result.data.manifest && result.data.manifest.comparison_genomes) { | |
| state.discovery.customComparisonGenomes = result.data.manifest.comparison_genomes; | |
| } else { | |
| state.discovery.customComparisonGenomes = null; | |
| } | |
| } catch (error) { | |
| console.error('Error fetching custom genome details:', error); | |
| state.discovery.customComparisonGenomes = null; | |
| } | |
| // Update comparison genomes to only show those from the custom run | |
| this.populateCompGenomes(); | |
| } else { | |
| state.discovery.queryGenome = null; | |
| state.discovery.isCustomGenome = false; | |
| state.discovery.customComparisonGenomes = null; | |
| infoDiv.style.display = 'none'; | |
| statusDiv.style.display = 'none'; | |
| // Reset comparison genomes to show all | |
| this.populateCompGenomes(); | |
| } | |
| }, | |
| // Populate query genome dropdown | |
| populateGenomeDropdown() { | |
| const select = document.getElementById('discovery-query-genome'); | |
| if (!select) return; | |
| select.innerHTML = '<option value="">-- Select Query Genome --</option>' + | |
| state.genomes.map(g => `<option value="${g.id}">${g.name}</option>`).join(''); | |
| }, | |
| // Populate comparison genomes checkboxes | |
| populateCompGenomes() { | |
| const container = document.getElementById('discovery-comp-genomes'); | |
| if (!container) return; | |
| // For custom genomes, only show genomes that were used in the run | |
| // For database genomes, show common genomes as checkboxes + additional dropdown | |
| const queryGenome = state.discovery.queryGenome; | |
| let availableGenomes; | |
| let useDropdown = true; | |
| if (state.discovery.isCustomGenome && state.discovery.customComparisonGenomes) { | |
| // Only show genomes from the custom run's comparison list | |
| const customComps = state.discovery.customComparisonGenomes; | |
| availableGenomes = state.genomes.filter(g => customComps.includes(g.id)); | |
| useDropdown = false; // Don't show dropdown for custom genomes | |
| } else { | |
| // Show common genomes as checkboxes | |
| availableGenomes = state.genomes.filter(g => | |
| g.id !== queryGenome && COMMON_GENOMES.includes(g.id) | |
| ); | |
| } | |
| container.innerHTML = availableGenomes.map(g => ` | |
| <label class="genome-checkbox" data-genome="${g.id}"> | |
| <input type="checkbox" class="discovery-comp-genome-cb" value="${g.id}"> | |
| <span class="genome-label"> | |
| <span class="genome-display-name">${g.name}</span> | |
| <span class="genome-scientific-name">${g.scientific_name}</span> | |
| </span> | |
| </label> | |
| `).join(''); | |
| // Add change listeners for common genome checkboxes | |
| container.querySelectorAll('.discovery-comp-genome-cb').forEach(cb => { | |
| cb.addEventListener('change', (e) => { | |
| const label = e.target.closest('.genome-checkbox'); | |
| label.classList.toggle('checked', e.target.checked); | |
| }); | |
| }); | |
| // Handle additional genomes dropdown and list | |
| const additionalSelect = document.getElementById('discovery-additional-genomes-select'); | |
| const additionalList = document.getElementById('discovery-additional-genomes-list'); | |
| if (additionalSelect && additionalList) { | |
| if (useDropdown && !state.discovery.isCustomGenome) { | |
| // Populate dropdown with non-common genomes | |
| const additionalGenomes = state.genomes.filter(g => | |
| g.id !== queryGenome && !COMMON_GENOMES.includes(g.id) | |
| ); | |
| additionalSelect.innerHTML = '<option value="">-- Select additional genome --</option>' + | |
| additionalGenomes.map(g => `<option value="${g.id}">${g.name}</option>`).join(''); | |
| additionalSelect.onchange = (e) => { | |
| if (e.target.value) { | |
| this.addDiscoveryAdditionalGenome(e.target.value); | |
| e.target.value = ''; | |
| } | |
| }; | |
| // Show dropdown section | |
| additionalSelect.closest('.additional-genomes-section').style.display = 'block'; | |
| } else { | |
| // Hide dropdown section for custom genomes | |
| const section = additionalSelect.closest('.additional-genomes-section'); | |
| if (section) section.style.display = 'none'; | |
| } | |
| // Clear additional genomes list | |
| additionalList.innerHTML = ''; | |
| } | |
| }, | |
| // Add an additional genome from Discovery dropdown | |
| addDiscoveryAdditionalGenome(genomeId) { | |
| const genome = state.genomes.find(g => g.id === genomeId); | |
| if (!genome) return; | |
| const additionalList = document.getElementById('discovery-additional-genomes-list'); | |
| if (!additionalList) return; | |
| // Check if already added | |
| if (additionalList.querySelector(`[data-genome="${genomeId}"]`)) { | |
| return; | |
| } | |
| const label = document.createElement('label'); | |
| label.className = 'genome-checkbox checked'; | |
| label.dataset.genome = genomeId; | |
| label.innerHTML = ` | |
| <input type="checkbox" class="discovery-comp-genome-cb" value="${genomeId}" checked> | |
| <span class="genome-label"> | |
| <span class="genome-display-name">${genome.name}</span> | |
| <span class="genome-scientific-name">${genome.scientific_name}</span> | |
| </span> | |
| <button type="button" class="remove-additional-genome" style="margin-left: 8px; background: none; border: none; cursor: pointer; color: var(--color-text-muted);">✕</button> | |
| `; | |
| // Add checkbox change handler | |
| const checkbox = label.querySelector('input'); | |
| checkbox.addEventListener('change', () => { | |
| label.classList.toggle('checked', checkbox.checked); | |
| }); | |
| // Add remove button handler | |
| const removeBtn = label.querySelector('.remove-additional-genome'); | |
| removeBtn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| label.remove(); | |
| }); | |
| additionalList.appendChild(label); | |
| }, | |
| // Setup event listeners | |
| setupEventListeners() { | |
| // Query genome change | |
| const querySelect = document.getElementById('discovery-query-genome'); | |
| if (querySelect) { | |
| querySelect.addEventListener('change', () => this.onQueryGenomeChange()); | |
| } | |
| // Upload annotations button | |
| const uploadBtn = document.getElementById('discovery-upload-annotations-btn'); | |
| if (uploadBtn) { | |
| uploadBtn.addEventListener('click', () => this.onUploadAnnotations()); | |
| } | |
| // Custom genome select | |
| const customGenomeSelect = document.getElementById('discovery-custom-genome-select'); | |
| if (customGenomeSelect) { | |
| customGenomeSelect.addEventListener('change', () => this.onCustomGenomeChange()); | |
| } | |
| // Term filter (existing) | |
| const termFilter = document.getElementById('discovery-term-filter'); | |
| if (termFilter) { | |
| termFilter.addEventListener('input', () => this.filterTerms()); | |
| } | |
| // Annotation filter (new) | |
| const annotationFilter = document.getElementById('discovery-annotation-filter'); | |
| if (annotationFilter) { | |
| annotationFilter.addEventListener('input', () => this.filterAnnotations()); | |
| } | |
| // Gene filter (new for paralogous search) | |
| const geneFilter = document.getElementById('discovery-gene-filter'); | |
| if (geneFilter) { | |
| geneFilter.addEventListener('input', () => this.filterGenes()); | |
| } | |
| // Search button | |
| const searchBtn = document.getElementById('discovery-search-btn'); | |
| if (searchBtn) { | |
| searchBtn.addEventListener('click', () => this.onSearch()); | |
| } | |
| // Download TSV button | |
| const downloadBtn = document.getElementById('discovery-download-tsv'); | |
| if (downloadBtn) { | |
| downloadBtn.addEventListener('click', (e) => { | |
| if (!state.discovery.csvData) { | |
| e.preventDefault(); | |
| return; | |
| } | |
| }); | |
| } | |
| }, | |
| // Handle query genome change | |
| async onQueryGenomeChange() { | |
| const select = document.getElementById('discovery-query-genome'); | |
| const genome = select.value; | |
| state.discovery.queryGenome = genome; | |
| // Clear previous state for all search types | |
| state.discovery.annotationSessionId = null; | |
| state.discovery.availableTerms = []; | |
| state.discovery.results = null; | |
| // Reset Term Search state | |
| this.requiredGroups = [[]]; | |
| this.groupMinMatch = [1]; | |
| this.selectedOptionalTerms = []; | |
| this.allTerms = []; | |
| // Reset Annotation Search state | |
| this.requiredGroupsAnnotation = [[]]; | |
| this.groupMinMatchAnnotation = [1]; | |
| this.selectedOptionalAnnotations = []; | |
| this.allAnnotations = []; | |
| // Reset Paralogous Search state | |
| this.requiredGroupsGene = [[]]; | |
| this.groupMinMatchGene = [1]; | |
| this.selectedOptionalGenes = []; | |
| this.allGenes = []; | |
| // Clear UI for all tabs | |
| this.renderAvailableTerms([]); | |
| this.renderRequiredGroups('term'); | |
| this.renderSelectedOptionalTerms(); | |
| this.renderAvailableAnnotations([]); | |
| this.renderRequiredGroups('annotation'); | |
| this.renderSelectedOptionalAnnotations(); | |
| this.renderAvailableGenes([]); | |
| this.renderRequiredGroups('paralogous'); | |
| this.renderSelectedOptionalGenes(); | |
| // Hide previous results | |
| const resultsDiv = document.getElementById('discovery-results'); | |
| if (resultsDiv) resultsDiv.style.display = 'none'; | |
| // Repopulate comparison genomes to exclude the selected query genome | |
| this.populateCompGenomes(); | |
| if (!genome) { | |
| document.getElementById('discovery-genome-annotation-status').style.display = 'none'; | |
| return; | |
| } | |
| // Check if genome has annotations | |
| const statusDiv = document.getElementById('discovery-genome-annotation-status'); | |
| statusDiv.style.display = 'block'; | |
| statusDiv.innerHTML = '<span>Loading annotations...</span>'; | |
| const result = await API.discoveryAnnotations(genome); | |
| if (result.success) { | |
| state.discovery.availableTerms = result.terms; | |
| state.discovery.isCustomGenome = false; | |
| this.allTerms = result.terms; | |
| // Show annotation coverage | |
| const annotatedGenes = result.annotated_genes || result.total_genes; | |
| const totalGenes = result.total_genes; | |
| const uniqueTerms = result.unique_terms; | |
| let statusText; | |
| if (annotatedGenes < totalGenes) { | |
| statusText = `✓ ${annotatedGenes.toLocaleString()} of ${totalGenes.toLocaleString()} genes annotated — ${uniqueTerms.toLocaleString()} searchable keywords`; | |
| } else { | |
| statusText = `✓ ${totalGenes.toLocaleString()} genes annotated — ${uniqueTerms.toLocaleString()} searchable keywords`; | |
| } | |
| statusDiv.innerHTML = `<span>${statusText}</span>`; | |
| statusDiv.className = 'alert alert-success'; | |
| this.renderAvailableTerms(result.terms); | |
| // Also load full annotations for Annotation Search tab | |
| this.loadFullAnnotations(genome); | |
| // Also load gene names for Paralogous Search tab | |
| this.loadGeneNames(genome); | |
| } else { | |
| statusDiv.innerHTML = `<span>⚠️ ${result.error || 'No annotations available for this genome'}. Please upload custom annotations below.</span>`; | |
| statusDiv.className = 'alert alert-warning'; | |
| } | |
| }, | |
| // Load full annotations for Annotation Search tab | |
| async loadFullAnnotations(genome) { | |
| const result = await API.discoveryFullAnnotations(genome); | |
| if (result.success && result.annotations) { | |
| this.allAnnotations = result.annotations; | |
| this.renderAvailableAnnotations(result.annotations); | |
| } | |
| }, | |
| // Load gene names for Paralogous Search tab | |
| async loadGeneNames(genome) { | |
| const result = await API.discoveryGeneNames(genome); | |
| if (result.success && result.genes) { | |
| this.allGenes = result.genes; | |
| this.renderAvailableGenes(result.genes); | |
| } | |
| }, | |
| // Upload custom annotations | |
| async onUploadAnnotations() { | |
| const fileInput = document.getElementById('discovery-annotation-file'); | |
| const statusDiv = document.getElementById('discovery-annotation-status'); | |
| if (!fileInput.files.length) { | |
| statusDiv.style.display = 'block'; | |
| statusDiv.innerHTML = '<span class="status-icon">⚠️</span><span class="status-text">Please select a file first</span>'; | |
| return; | |
| } | |
| statusDiv.style.display = 'block'; | |
| statusDiv.innerHTML = '<span class="status-icon">⏳</span><span class="status-text">Uploading...</span>'; | |
| const formData = new FormData(); | |
| formData.append('file', fileInput.files[0]); | |
| // Pass the selected genome for validation | |
| const genome = state.discovery.queryGenome || ''; | |
| formData.append('genome', genome); | |
| const result = await API.discoveryUploadAnnotations(formData); | |
| if (result.success) { | |
| state.discovery.annotationSessionId = result.session_id; | |
| // Populate Term Search tab with extracted keywords | |
| state.discovery.availableTerms = result.terms; | |
| this.allTerms = result.terms; | |
| this.renderAvailableTerms(result.terms); | |
| // Populate Annotation Search tab with full annotations | |
| if (result.annotations) { | |
| this.allAnnotations = result.annotations; | |
| this.renderAvailableAnnotations(result.annotations); | |
| } | |
| // Populate Paralogous Search tab with gene names | |
| if (result.genes) { | |
| this.allGenes = result.genes; | |
| this.renderAvailableGenes(result.genes); | |
| } | |
| // Build status text matching database annotation format | |
| let statsText; | |
| const annotatedGenes = result.annotated_genes || result.gene_count; | |
| const totalGenes = result.gene_count; | |
| const uniqueTerms = result.unique_terms; | |
| if (annotatedGenes < totalGenes) { | |
| statsText = `${annotatedGenes.toLocaleString()} of ${totalGenes.toLocaleString()} genes annotated — ${uniqueTerms.toLocaleString()} searchable keywords`; | |
| } else { | |
| statsText = `${totalGenes.toLocaleString()} genes annotated — ${uniqueTerms.toLocaleString()} searchable keywords`; | |
| } | |
| // Add match info if genome validation was performed | |
| if (result.matched_genes && result.total_genome_genes) { | |
| statsText += ` (${result.matched_genes.toLocaleString()} matched in genome)`; | |
| } | |
| statusDiv.innerHTML = `<span class="status-icon">✓</span><span class="status-text">${statsText}</span>`; | |
| // Update genome status div to match database format | |
| const genomeStatusDiv = document.getElementById('discovery-genome-annotation-status'); | |
| genomeStatusDiv.style.display = 'block'; | |
| genomeStatusDiv.innerHTML = `<span>✓ Custom annotations: ${statsText}</span>`; | |
| genomeStatusDiv.className = 'alert alert-success'; | |
| } else { | |
| statusDiv.innerHTML = `<span class="status-icon">❌</span><span class="status-text">${result.error}</span>`; | |
| } | |
| }, | |
| // Render available terms as clickable items | |
| renderAvailableTerms(terms) { | |
| const container = document.getElementById('discovery-available-terms'); | |
| const countSpan = document.getElementById('discovery-terms-count'); | |
| if (!container) return; | |
| if (terms.length === 0) { | |
| container.innerHTML = '<p style="color: var(--color-text-muted); font-style: italic; text-align: center; margin-top: var(--space-lg);">Select a query genome to load annotation terms</p>'; | |
| if (countSpan) countSpan.textContent = ''; | |
| return; | |
| } | |
| // Filter out already selected terms (across all groups and optional) | |
| const allGroupTerms = this.requiredGroups.flat(); | |
| const selectedTermValues = [...allGroupTerms, ...this.selectedOptionalTerms]; | |
| const availableTerms = terms.filter(t => !selectedTermValues.includes(t.term)); | |
| if (countSpan) countSpan.textContent = `(Top 500 available)`; | |
| if (availableTerms.length === 0) { | |
| container.innerHTML = '<p style="color: var(--color-text-muted); font-style: italic; text-align: center;">All terms have been added</p>'; | |
| return; | |
| } | |
| // Build group options for dropdown | |
| const groupOptions = this.requiredGroups.map((_, idx) => | |
| `<option value="${idx}">Group ${idx + 1}</option>` | |
| ).join(''); | |
| container.innerHTML = availableTerms.map(t => ` | |
| <div class="term-item" data-term="${this.escapeHtml(t.term)}" style="padding: 6px 10px; margin: 2px 0; background: var(--color-bg-primary); border-radius: var(--radius-sm); cursor: pointer; display: flex; justify-content: space-between; align-items: center; transition: background 0.2s;"> | |
| <span class="term-text" style="flex: 1; font-size: 0.9em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${this.escapeHtml(t.term)}">${this.escapeHtml(t.term.length > 50 ? t.term.substring(0, 50) + '...' : t.term)}</span> | |
| <span class="term-count" style="color: var(--color-text-muted); font-size: 0.8em; margin-left: 8px;">(${t.count})</span> | |
| <div class="term-actions" style="display: flex; gap: 4px; margin-left: 8px; align-items: center;"> | |
| <select class="group-select" style="padding: 2px 4px; font-size: 0.75em; border-radius: 4px; border: 1px solid #ccc; background: #fff;"> | |
| ${groupOptions} | |
| </select> | |
| <button class="btn-add-group" style="padding: 2px 8px; font-size: 0.75em; background: #ffb3b3; color: #8b0000; border: none; border-radius: 4px; cursor: pointer;">+Grp</button> | |
| <button class="btn-add-optional" style="padding: 2px 8px; font-size: 0.75em; background: var(--color-accent-warning); color: white; border: none; border-radius: 4px; cursor: pointer;">Opt</button> | |
| </div> | |
| </div> | |
| `).join(''); | |
| // Add click handlers for adding to group | |
| container.querySelectorAll('.btn-add-group').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const termItem = btn.closest('.term-item'); | |
| const term = termItem.dataset.term; | |
| const groupSelect = termItem.querySelector('.group-select'); | |
| const groupIdx = parseInt(groupSelect.value); | |
| this.addTermToGroup('term', groupIdx, term); | |
| }); | |
| }); | |
| container.querySelectorAll('.btn-add-optional').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const termItem = btn.closest('.term-item'); | |
| const term = termItem.dataset.term; | |
| this.addTermToOptional(term); | |
| }); | |
| }); | |
| // Hover effect | |
| container.querySelectorAll('.term-item').forEach(item => { | |
| item.addEventListener('mouseenter', () => { | |
| item.style.background = 'var(--color-bg-tertiary)'; | |
| }); | |
| item.addEventListener('mouseleave', () => { | |
| item.style.background = 'var(--color-bg-primary)'; | |
| }); | |
| }); | |
| }, | |
| // Add term to first group (legacy function for backward compatibility) | |
| addTermToMust(term) { | |
| this.addTermToGroup('term', 0, term); | |
| }, | |
| // Add term to Optional list | |
| addTermToOptional(term) { | |
| if (!this.selectedOptionalTerms.includes(term)) { | |
| this.selectedOptionalTerms.push(term); | |
| this.renderSelectedOptionalTerms(); | |
| this.filterTerms(); // Re-render available terms | |
| } | |
| }, | |
| // Remove term from Must list (legacy - removes from all groups) | |
| removeTermFromMust(term) { | |
| for (let i = 0; i < this.requiredGroups.length; i++) { | |
| const idx = this.requiredGroups[i].indexOf(term); | |
| if (idx > -1) { | |
| this.requiredGroups[i].splice(idx, 1); | |
| } | |
| } | |
| this.renderRequiredGroups('term'); | |
| this.filterTerms(); | |
| }, | |
| // Remove term from Optional list | |
| removeTermFromOptional(term) { | |
| this.selectedOptionalTerms = this.selectedOptionalTerms.filter(t => t !== term); | |
| this.renderRequiredGroups('term'); | |
| this.renderSelectedOptionalTerms(); | |
| this.filterTerms(); | |
| }, | |
| // Add a new required group | |
| addRequiredGroup(searchType) { | |
| if (searchType === 'annotation') { | |
| this.requiredGroupsAnnotation.push([]); | |
| this.groupMinMatchAnnotation.push(1); // Default minMatch = 1 | |
| this.renderRequiredGroups('annotation'); | |
| this.filterAnnotations(); // Refresh dropdown options | |
| } else if (searchType === 'paralogous') { | |
| this.requiredGroupsGene.push([]); | |
| this.groupMinMatchGene.push(1); // Default minMatch = 1 | |
| this.renderRequiredGroups('paralogous'); | |
| this.filterGenes(); // Refresh dropdown options | |
| } else { | |
| this.requiredGroups.push([]); | |
| this.groupMinMatch.push(1); // Default minMatch = 1 | |
| this.renderRequiredGroups('term'); | |
| this.filterTerms(); // Refresh dropdown options | |
| } | |
| }, | |
| // Remove a required group | |
| removeRequiredGroup(searchType, groupIndex) { | |
| if (searchType === 'annotation') { | |
| this.requiredGroupsAnnotation.splice(groupIndex, 1); | |
| this.groupMinMatchAnnotation.splice(groupIndex, 1); | |
| if (this.requiredGroupsAnnotation.length === 0) { | |
| this.requiredGroupsAnnotation.push([]); | |
| this.groupMinMatchAnnotation.push(1); | |
| } | |
| this.renderRequiredGroups('annotation'); | |
| this.filterAnnotations(); // Refresh dropdown options | |
| } else if (searchType === 'paralogous') { | |
| this.requiredGroupsGene.splice(groupIndex, 1); | |
| this.groupMinMatchGene.splice(groupIndex, 1); | |
| if (this.requiredGroupsGene.length === 0) { | |
| this.requiredGroupsGene.push([]); | |
| this.groupMinMatchGene.push(1); | |
| } | |
| this.renderRequiredGroups('paralogous'); | |
| this.filterGenes(); // Refresh dropdown options | |
| } else { | |
| this.requiredGroups.splice(groupIndex, 1); | |
| this.groupMinMatch.splice(groupIndex, 1); | |
| if (this.requiredGroups.length === 0) { | |
| this.requiredGroups.push([]); | |
| this.groupMinMatch.push(1); | |
| } | |
| this.renderRequiredGroups('term'); | |
| this.filterTerms(); // Refresh dropdown options | |
| } | |
| }, | |
| // Add term to a specific group | |
| addTermToGroup(searchType, groupIndex, term) { | |
| let groups; | |
| if (searchType === 'annotation') { | |
| groups = this.requiredGroupsAnnotation; | |
| } else if (searchType === 'paralogous') { | |
| groups = this.requiredGroupsGene; | |
| } else { | |
| groups = this.requiredGroups; | |
| } | |
| // Check if term already exists in any group | |
| const existsInGroups = groups.some(g => g.includes(term)); | |
| if (!existsInGroups && groupIndex >= 0 && groupIndex < groups.length) { | |
| groups[groupIndex].push(term); | |
| this.renderRequiredGroups(searchType); | |
| // Call the appropriate filter function based on search type | |
| if (searchType === 'annotation') { | |
| this.filterAnnotations(); | |
| } else if (searchType === 'paralogous') { | |
| this.filterGenes(); | |
| } else { | |
| this.filterTerms(); | |
| } | |
| } | |
| }, | |
| // Remove term from a specific group | |
| removeTermFromGroup(searchType, groupIndex, term) { | |
| let groups; | |
| if (searchType === 'annotation') { | |
| groups = this.requiredGroupsAnnotation; | |
| } else if (searchType === 'paralogous') { | |
| groups = this.requiredGroupsGene; | |
| } else { | |
| groups = this.requiredGroups; | |
| } | |
| if (groupIndex >= 0 && groupIndex < groups.length) { | |
| const idx = groups[groupIndex].indexOf(term); | |
| if (idx > -1) { | |
| groups[groupIndex].splice(idx, 1); | |
| this.renderRequiredGroups(searchType); | |
| // Call the appropriate filter function based on search type | |
| if (searchType === 'annotation') { | |
| this.filterAnnotations(); | |
| } else if (searchType === 'paralogous') { | |
| this.filterGenes(); | |
| } else { | |
| this.filterTerms(); | |
| } | |
| } | |
| } | |
| }, | |
| // Render required groups UI | |
| renderRequiredGroups(searchType) { | |
| let groups, containerId, minMatchArray; | |
| if (searchType === 'annotation') { | |
| groups = this.requiredGroupsAnnotation; | |
| containerId = 'discovery-required-groups-annotation'; | |
| minMatchArray = this.groupMinMatchAnnotation; | |
| } else if (searchType === 'paralogous') { | |
| groups = this.requiredGroupsGene; | |
| containerId = 'discovery-required-groups-gene'; | |
| minMatchArray = this.groupMinMatchGene; | |
| } else { | |
| groups = this.requiredGroups; | |
| containerId = 'discovery-required-groups-term'; | |
| minMatchArray = this.groupMinMatch; | |
| } | |
| const container = document.getElementById(containerId); | |
| if (!container) return; | |
| if (groups.length === 0) { | |
| groups.push([]); | |
| minMatchArray.push(1); | |
| } | |
| // Ensure minMatchArray is in sync with groups | |
| while (minMatchArray.length < groups.length) { | |
| minMatchArray.push(1); | |
| } | |
| container.innerHTML = groups.map((group, gIdx) => { | |
| const currentMinMatch = minMatchArray[gIdx] || 1; | |
| const maxTerms = Math.max(group.length, 1); | |
| // Only show remove button on the last group (excluding group 1) | |
| const showRemoveBtn = gIdx > 0 && gIdx === groups.length - 1; | |
| return ` | |
| <div class="required-group" data-group-index="${gIdx}" style="margin-bottom: var(--space-sm); padding: var(--space-sm); background: #ffe6e6; border-radius: var(--radius-sm); border: 1px dashed var(--color-border);"> | |
| <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-xs); flex-wrap: wrap; gap: 4px;"> | |
| <div style="display: flex; align-items: center; gap: 6px; flex-wrap: wrap;"> | |
| <span style="font-weight: 500; font-size: 0.85em; color: var(--color-accent-danger);">Group ${gIdx + 1}</span> | |
| <span style="font-size: 0.8em; color: var(--color-text-muted);">— At least</span> | |
| <input type="number" class="group-min-match-input form-input" data-group-idx="${gIdx}" | |
| value="${currentMinMatch}" min="1" max="${maxTerms}" | |
| style="width: 50px; padding: 2px 6px; font-size: 0.8em; text-align: center;"> | |
| <span style="font-size: 0.8em; color: var(--color-text-muted);">must match</span> | |
| </div> | |
| ${showRemoveBtn ? `<button class="btn btn-sm remove-group-btn" style="padding: 2px 6px; font-size: 0.75em;" data-group-idx="${gIdx}">✕</button>` : ''} | |
| </div> | |
| <div class="group-terms" style="display: flex; flex-wrap: wrap; gap: 4px; min-height: 24px;"> | |
| ${group.length === 0 | |
| ? `<span class="empty-hint" style="color: var(--color-text-muted); font-style: italic; font-size: 0.85em;">Click a term → select "Group ${gIdx + 1}"</span>` | |
| : group.map(term => ` | |
| <span class="term-tag" data-term="${this.escapeHtml(term)}" data-group="${gIdx}" style="display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; background: rgba(239, 68, 68, 0.15); border: 1px solid rgba(239, 68, 68, 0.3); border-radius: 12px; font-size: 0.8em;"> | |
| <span style="max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${this.escapeHtml(term)}">${this.escapeHtml(term.length > 20 ? term.substring(0, 20) + '...' : term)}</span> | |
| <button class="remove-term-btn" style="background: none; border: none; color: var(--color-accent-danger); cursor: pointer; padding: 0; font-size: 1em; line-height: 1;">×</button> | |
| </span> | |
| `).join('') | |
| } | |
| </div> | |
| </div> | |
| `}).join(''); | |
| // Add event listeners for minMatch input changes | |
| container.querySelectorAll('.group-min-match-input').forEach(input => { | |
| input.addEventListener('change', (e) => { | |
| const gIdx = parseInt(input.dataset.groupIdx); | |
| let value = parseInt(e.target.value) || 1; | |
| const maxVal = groups[gIdx] ? Math.max(groups[gIdx].length, 1) : 1; | |
| value = Math.max(1, Math.min(value, maxVal)); | |
| e.target.value = value; | |
| minMatchArray[gIdx] = value; | |
| }); | |
| }); | |
| // Add event listeners for remove group buttons | |
| container.querySelectorAll('.remove-group-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| const gIdx = parseInt(btn.dataset.groupIdx); | |
| this.removeRequiredGroup(searchType, gIdx); | |
| }); | |
| }); | |
| // Add event listeners for remove term buttons | |
| container.querySelectorAll('.remove-term-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| const tag = btn.closest('.term-tag'); | |
| const term = tag.dataset.term; | |
| const gIdx = parseInt(tag.dataset.group); | |
| this.removeTermFromGroup(searchType, gIdx, term); | |
| }); | |
| }); | |
| }, | |
| // Render selected optional terms (for term search) | |
| renderSelectedOptionalTerms() { | |
| const optionalContainer = document.getElementById('discovery-optional-terms'); | |
| if (optionalContainer) { | |
| if (this.selectedOptionalTerms.length === 0) { | |
| optionalContainer.innerHTML = '<span class="empty-hint" style="color: var(--color-text-muted); font-style: italic; font-size: 0.9em;">Click term → "Add as Optional"</span>'; | |
| } else { | |
| optionalContainer.innerHTML = this.selectedOptionalTerms.map(term => ` | |
| <span class="term-tag" data-term="${this.escapeHtml(term)}" style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; margin: 2px; background: rgba(234, 179, 8, 0.15); border: 1px solid rgba(234, 179, 8, 0.3); border-radius: 20px; font-size: 0.85em;"> | |
| <span class="tag-text" style="max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${this.escapeHtml(term)}">${this.escapeHtml(term.length > 30 ? term.substring(0, 30) + '...' : term)}</span> | |
| <button class="tag-remove" style="background: none; border: none; color: var(--color-accent-warning); cursor: pointer; padding: 0; font-size: 1.1em; line-height: 1;">×</button> | |
| </span> | |
| `).join(''); | |
| optionalContainer.querySelectorAll('.tag-remove').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const tag = btn.closest('.term-tag'); | |
| this.removeTermFromOptional(tag.dataset.term); | |
| }); | |
| }); | |
| } | |
| } | |
| }, | |
| // Legacy renderSelectedTerms - calls the new functions | |
| renderSelectedTerms() { | |
| this.renderRequiredGroups('term'); | |
| this.renderSelectedOptionalTerms(); | |
| }, | |
| // Filter terms based on search input | |
| filterTerms() { | |
| const filter = document.getElementById('discovery-term-filter')?.value.toLowerCase() || ''; | |
| // Filter the allTerms list and re-render | |
| let filteredTerms = this.allTerms; | |
| if (filter) { | |
| filteredTerms = this.allTerms.filter(t => t.term.toLowerCase().includes(filter)); | |
| } | |
| this.renderAvailableTerms(filteredTerms); | |
| }, | |
| // ================== ANNOTATION SEARCH METHODS ================== | |
| // Render available annotations | |
| renderAvailableAnnotations(annotations) { | |
| const container = document.getElementById('discovery-available-annotations'); | |
| const countSpan = document.getElementById('discovery-annotations-count'); | |
| if (!container) return; | |
| if (!annotations || annotations.length === 0) { | |
| container.innerHTML = '<p style="color: var(--color-text-muted); font-style: italic; text-align: center; margin-top: var(--space-lg);">Select a query genome to load annotations</p>'; | |
| if (countSpan) countSpan.textContent = ''; | |
| return; | |
| } | |
| // Filter out already selected annotations (across all groups and optional) | |
| const allGroupAnnotations = this.requiredGroupsAnnotation.flat(); | |
| const selectedValues = [...allGroupAnnotations, ...this.selectedOptionalAnnotations]; | |
| const availableAnnotations = annotations.filter(a => !selectedValues.includes(a.annotation)); | |
| if (countSpan) countSpan.textContent = `(${availableAnnotations.length} available)`; | |
| if (availableAnnotations.length === 0) { | |
| container.innerHTML = '<p style="color: var(--color-text-muted); font-style: italic; text-align: center;">All annotations have been added</p>'; | |
| return; | |
| } | |
| // Build group options for dropdown | |
| const groupOptions = this.requiredGroupsAnnotation.map((_, idx) => | |
| `<option value="${idx}">Group ${idx + 1}</option>` | |
| ).join(''); | |
| container.innerHTML = availableAnnotations.slice(0, 500).map(a => ` | |
| <div class="term-item" data-annotation="${this.escapeHtml(a.annotation)}" style="padding: 6px 10px; margin: 2px 0; background: var(--color-bg-primary); border-radius: var(--radius-sm); cursor: pointer; display: flex; justify-content: space-between; align-items: center; transition: background 0.2s;"> | |
| <span class="term-text" style="flex: 1; font-size: 0.9em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${this.escapeHtml(a.annotation)}">${this.escapeHtml(a.annotation.length > 50 ? a.annotation.substring(0, 50) + '...' : a.annotation)}</span> | |
| <span class="term-count" style="color: var(--color-text-muted); font-size: 0.8em; margin-left: 8px;">(${a.count})</span> | |
| <div class="term-actions" style="display: flex; gap: 4px; margin-left: 8px; align-items: center;"> | |
| <select class="group-select" style="padding: 2px 4px; font-size: 0.75em; border-radius: 4px; border: 1px solid #ccc; background: #fff;"> | |
| ${groupOptions} | |
| </select> | |
| <button class="btn-add-group" style="padding: 2px 8px; font-size: 0.75em; background: #ffb3b3; color: #8b0000; border: none; border-radius: 4px; cursor: pointer;">+Grp</button> | |
| <button class="btn-add-optional" style="padding: 2px 8px; font-size: 0.75em; background: var(--color-accent-warning); color: white; border: none; border-radius: 4px; cursor: pointer;">Opt</button> | |
| </div> | |
| </div> | |
| `).join(''); | |
| // Add click handlers for adding to group | |
| container.querySelectorAll('.btn-add-group').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const item = btn.closest('.term-item'); | |
| const groupSelect = item.querySelector('.group-select'); | |
| const groupIdx = parseInt(groupSelect.value); | |
| this.addTermToGroup('annotation', groupIdx, item.dataset.annotation); | |
| }); | |
| }); | |
| container.querySelectorAll('.btn-add-optional').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const item = btn.closest('.term-item'); | |
| this.addAnnotationToOptional(item.dataset.annotation); | |
| }); | |
| }); | |
| // Hover effect | |
| container.querySelectorAll('.term-item').forEach(item => { | |
| item.addEventListener('mouseenter', () => item.style.background = 'var(--color-bg-tertiary)'); | |
| item.addEventListener('mouseleave', () => item.style.background = 'var(--color-bg-primary)'); | |
| }); | |
| }, | |
| // Legacy add to must (for backward compatibility) | |
| addAnnotationToMust(annotation) { | |
| this.addTermToGroup('annotation', 0, annotation); | |
| }, | |
| addAnnotationToOptional(annotation) { | |
| if (!this.selectedOptionalAnnotations.includes(annotation)) { | |
| this.selectedOptionalAnnotations.push(annotation); | |
| this.renderRequiredGroups('annotation'); | |
| this.renderSelectedOptionalAnnotations(); | |
| this.filterAnnotations(); | |
| } | |
| }, | |
| removeAnnotationFromMust(annotation) { | |
| for (let i = 0; i < this.requiredGroupsAnnotation.length; i++) { | |
| const idx = this.requiredGroupsAnnotation[i].indexOf(annotation); | |
| if (idx > -1) { | |
| this.requiredGroupsAnnotation[i].splice(idx, 1); | |
| } | |
| } | |
| this.renderRequiredGroups('annotation'); | |
| this.filterAnnotations(); | |
| }, | |
| removeAnnotationFromOptional(annotation) { | |
| this.selectedOptionalAnnotations = this.selectedOptionalAnnotations.filter(a => a !== annotation); | |
| this.renderSelectedOptionalAnnotations(); | |
| this.filterAnnotations(); | |
| }, | |
| // Render selected optional annotations (used by the Optional section) | |
| renderSelectedOptionalAnnotations() { | |
| const optionalContainer = document.getElementById('discovery-optional-annotations'); | |
| if (!optionalContainer) return; | |
| if (this.selectedOptionalAnnotations.length === 0) { | |
| optionalContainer.innerHTML = '<span class="empty-hint" style="color: var(--color-text-muted); font-style: italic; font-size: 0.9em;">Click annotation → "Opt"</span>'; | |
| } else { | |
| optionalContainer.innerHTML = this.selectedOptionalAnnotations.map(ann => ` | |
| <span class="term-tag" data-annotation="${this.escapeHtml(ann)}" style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; margin: 2px; background: rgba(234, 179, 8, 0.15); border: 1px solid rgba(234, 179, 8, 0.3); border-radius: 20px; font-size: 0.85em;"> | |
| <span class="tag-text" style="max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${this.escapeHtml(ann)}">${this.escapeHtml(ann.length > 30 ? ann.substring(0, 30) + '...' : ann)}</span> | |
| <button class="tag-remove" style="background: none; border: none; color: var(--color-accent-warning); cursor: pointer; padding: 0; font-size: 1.1em; line-height: 1;">×</button> | |
| </span> | |
| `).join(''); | |
| optionalContainer.querySelectorAll('.tag-remove').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const tag = btn.closest('.term-tag'); | |
| this.removeAnnotationFromOptional(tag.dataset.annotation); | |
| }); | |
| }); | |
| } | |
| }, | |
| // Legacy renderSelectedAnnotations - calls new functions | |
| renderSelectedAnnotations() { | |
| this.renderRequiredGroups('annotation'); | |
| this.renderSelectedOptionalAnnotations(); | |
| }, | |
| filterAnnotations() { | |
| const filter = document.getElementById('discovery-annotation-filter')?.value.toLowerCase() || ''; | |
| let filteredAnnotations = this.allAnnotations; | |
| if (filter) { | |
| filteredAnnotations = this.allAnnotations.filter(a => a.annotation.toLowerCase().includes(filter)); | |
| } | |
| this.renderAvailableAnnotations(filteredAnnotations); | |
| }, | |
| // ================== PARALOGOUS SEARCH METHODS (GENE NAMES) ================== | |
| renderAvailableGenes(genes) { | |
| const container = document.getElementById('discovery-available-genes'); | |
| const countSpan = document.getElementById('discovery-genes-count'); | |
| if (!container) return; | |
| if (!genes || genes.length === 0) { | |
| container.innerHTML = '<p style="color: var(--color-text-muted); font-style: italic; text-align: center; margin-top: var(--space-lg);">Select a query genome to load gene names</p>'; | |
| if (countSpan) countSpan.textContent = ''; | |
| return; | |
| } | |
| // Filter out already selected genes (across all groups and optional) | |
| const allGroupGenes = this.requiredGroupsGene.flat(); | |
| const selectedValues = [...allGroupGenes, ...this.selectedOptionalGenes]; | |
| const availableGenes = genes.filter(g => !selectedValues.includes(g.gene)); | |
| if (countSpan) countSpan.textContent = `(${availableGenes.length} available)`; | |
| if (availableGenes.length === 0) { | |
| container.innerHTML = '<p style="color: var(--color-text-muted); font-style: italic; text-align: center;">All genes have been added</p>'; | |
| return; | |
| } | |
| // Build group options for dropdown | |
| const groupOptions = this.requiredGroupsGene.map((_, idx) => | |
| `<option value="${idx}">Group ${idx + 1}</option>` | |
| ).join(''); | |
| container.innerHTML = availableGenes.slice(0, 500).map(g => ` | |
| <div class="term-item" data-gene="${this.escapeHtml(g.gene)}" style="padding: 6px 10px; margin: 2px 0; background: var(--color-bg-primary); border-radius: var(--radius-sm); cursor: pointer; display: flex; justify-content: space-between; align-items: center; transition: background 0.2s;"> | |
| <span class="term-text" style="flex: 1; font-size: 0.9em; font-family: monospace;" title="${this.escapeHtml(g.gene)}">${this.escapeHtml(g.gene)}</span> | |
| ${g.annotation ? `<span class="term-annotation" style="color: var(--color-text-muted); font-size: 0.75em; margin-left: 8px; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${this.escapeHtml(g.annotation)}">${this.escapeHtml(g.annotation.length > 25 ? g.annotation.substring(0, 25) + '...' : g.annotation)}</span>` : ''} | |
| <div class="term-actions" style="display: flex; gap: 4px; margin-left: 8px; align-items: center;"> | |
| <select class="group-select" style="padding: 2px 4px; font-size: 0.75em; border-radius: 4px; border: 1px solid #ccc; background: #fff;"> | |
| ${groupOptions} | |
| </select> | |
| <button class="btn-add-group" style="padding: 2px 8px; font-size: 0.75em; background: #ffb3b3; color: #8b0000; border: none; border-radius: 4px; cursor: pointer;">+Grp</button> | |
| <button class="btn-add-optional" style="padding: 2px 8px; font-size: 0.75em; background: var(--color-accent-warning); color: white; border: none; border-radius: 4px; cursor: pointer;">Opt</button> | |
| </div> | |
| </div> | |
| `).join(''); | |
| // Add click handlers for adding to group | |
| container.querySelectorAll('.btn-add-group').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const item = btn.closest('.term-item'); | |
| const groupSelect = item.querySelector('.group-select'); | |
| const groupIdx = parseInt(groupSelect.value); | |
| this.addTermToGroup('paralogous', groupIdx, item.dataset.gene); | |
| }); | |
| }); | |
| container.querySelectorAll('.btn-add-optional').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const item = btn.closest('.term-item'); | |
| this.addGeneToOptional(item.dataset.gene); | |
| }); | |
| }); | |
| // Hover effect | |
| container.querySelectorAll('.term-item').forEach(item => { | |
| item.addEventListener('mouseenter', () => item.style.background = 'var(--color-bg-tertiary)'); | |
| item.addEventListener('mouseleave', () => item.style.background = 'var(--color-bg-primary)'); | |
| }); | |
| }, | |
| // Legacy add to must (for backward compatibility) | |
| addGeneToMust(gene) { | |
| this.addTermToGroup('paralogous', 0, gene); | |
| }, | |
| addGeneToOptional(gene) { | |
| if (!this.selectedOptionalGenes.includes(gene)) { | |
| this.selectedOptionalGenes.push(gene); | |
| this.renderRequiredGroups('paralogous'); | |
| this.renderSelectedOptionalGenes(); | |
| this.filterGenes(); | |
| } | |
| }, | |
| removeGeneFromMust(gene) { | |
| for (let i = 0; i < this.requiredGroupsGene.length; i++) { | |
| const idx = this.requiredGroupsGene[i].indexOf(gene); | |
| if (idx > -1) { | |
| this.requiredGroupsGene[i].splice(idx, 1); | |
| } | |
| } | |
| this.renderRequiredGroups('paralogous'); | |
| this.filterGenes(); | |
| }, | |
| removeGeneFromOptional(gene) { | |
| this.selectedOptionalGenes = this.selectedOptionalGenes.filter(g => g !== gene); | |
| this.renderSelectedOptionalGenes(); | |
| this.filterGenes(); | |
| }, | |
| // Render selected optional genes (used by the Optional section) | |
| renderSelectedOptionalGenes() { | |
| const optionalContainer = document.getElementById('discovery-optional-genes'); | |
| if (!optionalContainer) return; | |
| if (this.selectedOptionalGenes.length === 0) { | |
| optionalContainer.innerHTML = '<span class="empty-hint" style="color: var(--color-text-muted); font-style: italic; font-size: 0.9em;">Click gene → "Opt"</span>'; | |
| } else { | |
| optionalContainer.innerHTML = this.selectedOptionalGenes.map(gene => ` | |
| <span class="term-tag" data-gene="${this.escapeHtml(gene)}" style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; margin: 2px; background: rgba(234, 179, 8, 0.15); border: 1px solid rgba(234, 179, 8, 0.3); border-radius: 20px; font-size: 0.85em; font-family: monospace;"> | |
| <span class="tag-text" title="${this.escapeHtml(gene)}">${this.escapeHtml(gene)}</span> | |
| <button class="tag-remove" style="background: none; border: none; color: var(--color-accent-warning); cursor: pointer; padding: 0; font-size: 1.1em; line-height: 1;">×</button> | |
| </span> | |
| `).join(''); | |
| optionalContainer.querySelectorAll('.tag-remove').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const tag = btn.closest('.term-tag'); | |
| this.removeGeneFromOptional(tag.dataset.gene); | |
| }); | |
| }); | |
| } | |
| }, | |
| // Legacy renderSelectedGenes - calls new functions | |
| renderSelectedGenes() { | |
| this.renderRequiredGroups('paralogous'); | |
| this.renderSelectedOptionalGenes(); | |
| }, | |
| filterGenes() { | |
| const filter = document.getElementById('discovery-gene-filter')?.value.toLowerCase() || ''; | |
| let filteredGenes = this.allGenes; | |
| if (filter) { | |
| filteredGenes = this.allGenes.filter(g => | |
| g.gene.toLowerCase().includes(filter) || | |
| (g.annotation && g.annotation.toLowerCase().includes(filter)) | |
| ); | |
| } | |
| this.renderAvailableGenes(filteredGenes); | |
| }, | |
| // Run discovery search | |
| async onSearch() { | |
| const statusDiv = document.getElementById('discovery-status'); | |
| const resultsDiv = document.getElementById('discovery-results'); | |
| // Gather parameters | |
| const queryGenome = state.discovery.queryGenome; | |
| const compGenomes = Array.from(document.querySelectorAll('.discovery-comp-genome-cb:checked')).map(cb => cb.value); | |
| // Get the correct groups and terms based on active search tab | |
| let requiredGroupsRaw, minMatchArray, optionalTerms, searchType; | |
| if (this.currentSearchTab === 'annotation') { | |
| requiredGroupsRaw = this.requiredGroupsAnnotation.filter(g => g.length > 0); | |
| minMatchArray = this.groupMinMatchAnnotation; | |
| optionalTerms = this.selectedOptionalAnnotations; | |
| searchType = 'annotation'; | |
| } else if (this.currentSearchTab === 'paralogous') { | |
| requiredGroupsRaw = this.requiredGroupsGene.filter(g => g.length > 0); | |
| minMatchArray = this.groupMinMatchGene; | |
| optionalTerms = this.selectedOptionalGenes; | |
| searchType = 'gene'; | |
| } else { | |
| // Default: term search | |
| requiredGroupsRaw = this.requiredGroups.filter(g => g.length > 0); | |
| minMatchArray = this.groupMinMatch; | |
| optionalTerms = this.selectedOptionalTerms; | |
| searchType = 'term'; | |
| } | |
| // Build required_groups with per-group minMatch values | |
| // Format: [{terms: [...], minMatch: N}, ...] | |
| const requiredGroups = []; | |
| let originalIndex = 0; | |
| for (let i = 0; i < (this.currentSearchTab === 'annotation' ? this.requiredGroupsAnnotation : | |
| this.currentSearchTab === 'paralogous' ? this.requiredGroupsGene : | |
| this.requiredGroups).length; i++) { | |
| const group = (this.currentSearchTab === 'annotation' ? this.requiredGroupsAnnotation : | |
| this.currentSearchTab === 'paralogous' ? this.requiredGroupsGene : | |
| this.requiredGroups)[i]; | |
| if (group.length > 0) { | |
| const minMatch = minMatchArray[i] || 1; | |
| requiredGroups.push({ | |
| terms: group, | |
| minMatch: Math.min(minMatch, group.length) // Cap at group size | |
| }); | |
| } | |
| } | |
| // Optional terms have minMatch = 0 (shown if found, not required) | |
| const minOptional = 0; | |
| // Count total terms in groups for validation | |
| const totalGroupTerms = requiredGroups.reduce((sum, g) => sum + g.terms.length, 0); | |
| // Validation | |
| if (!queryGenome) { | |
| statusDiv.querySelector('.status-icon').textContent = '⚠️'; | |
| statusDiv.querySelector('.status-text').textContent = 'Please select a query genome'; | |
| return; | |
| } | |
| if (compGenomes.length === 0) { | |
| statusDiv.querySelector('.status-icon').textContent = '⚠️'; | |
| statusDiv.querySelector('.status-text').textContent = 'Please select at least one comparison genome'; | |
| return; | |
| } | |
| if (totalGroupTerms === 0 && optionalTerms.length === 0) { | |
| const termType = searchType === 'gene' ? 'gene' : (searchType === 'annotation' ? 'annotation' : 'search term'); | |
| statusDiv.querySelector('.status-icon').textContent = '⚠️'; | |
| statusDiv.querySelector('.status-text').textContent = `Please add at least one ${termType}`; | |
| return; | |
| } | |
| // Run search | |
| statusDiv.querySelector('.status-icon').textContent = '⏳'; | |
| statusDiv.querySelector('.status-text').textContent = 'Searching...'; | |
| // Get match mode (all or any) | |
| const matchModeRadio = document.querySelector('input[name="discovery-match-mode"]:checked'); | |
| const matchMode = matchModeRadio ? matchModeRadio.value : 'all'; | |
| const searchData = { | |
| query_genome: queryGenome, | |
| comparisons: compGenomes, | |
| required_groups: requiredGroups, | |
| optional_terms: optionalTerms, | |
| min_optional: minOptional, | |
| search_type: searchType, | |
| match_mode: matchMode, | |
| annotation_session_id: state.discovery.annotationSessionId | |
| }; | |
| const result = await API.discoverySearch(searchData); | |
| if (result.success && result.data) { | |
| state.discovery.results = result.data; | |
| statusDiv.querySelector('.status-icon').textContent = '✓'; | |
| statusDiv.querySelector('.status-text').textContent = `Found ${result.data.block_count || 0} matching blocks`; | |
| this.displayResults(result.data); | |
| } else { | |
| statusDiv.querySelector('.status-icon').textContent = '❌'; | |
| statusDiv.querySelector('.status-text').textContent = result.error || 'Search failed'; | |
| resultsDiv.style.display = 'none'; | |
| } | |
| }, | |
| // Display results with pagination | |
| displayResults(data) { | |
| const resultsDiv = document.getElementById('discovery-results'); | |
| const summaryDiv = document.getElementById('discovery-summary'); | |
| const thead = document.getElementById('discovery-results-thead'); | |
| const tbody = document.querySelector('#discovery-results-table tbody'); | |
| const table = document.getElementById('discovery-results-table'); | |
| resultsDiv.style.display = 'block'; | |
| // Set table to use natural width based on content | |
| if (table) { | |
| table.style.width = 'max-content'; | |
| table.style.minWidth = '100%'; | |
| } | |
| // Store data for pagination | |
| state.discovery.currentData = data; | |
| state.discovery.currentPage = 1; | |
| state.discovery.blocksPerPage = 10; | |
| // Get comparison genomes info | |
| const compGenomes = data.comparison_genomes || []; | |
| const compDisplays = data.comparison_genome_displays || compGenomes; | |
| // Match mode display | |
| const matchMode = data.match_mode || 'all'; | |
| const matchModeLabel = matchMode === 'any' | |
| ? '<span style="background:#e6f3ff;padding:2px 8px;border-radius:4px;font-weight:500;">ANY genome</span>' | |
| : '<span style="background:#e6ffe6;padding:2px 8px;border-radius:4px;font-weight:500;">ALL genomes</span>'; | |
| // Summary | |
| summaryDiv.innerHTML = ` | |
| <div class="summary-row" style="display: flex; gap: var(--space-lg); flex-wrap: wrap; margin-bottom: var(--space-md);"> | |
| <div class="summary-item"> | |
| <strong>Query Genome:</strong> ${data.query_genome_display || data.query_genome} | |
| </div> | |
| <div class="summary-item"> | |
| <strong>Comparisons:</strong> ${compDisplays.join(', ')} | |
| </div> | |
| <div class="summary-item"> | |
| <strong>Match Mode:</strong> ${matchModeLabel} | |
| </div> | |
| <div class="summary-item"> | |
| <strong>Matching Blocks:</strong> ${data.block_count || 0} | |
| </div> | |
| <div class="summary-item"> | |
| <strong>Genes Matched:</strong> ${data.matched_gene_count || 0} | |
| </div> | |
| <div class="summary-item"> | |
| <strong>Required Groups:</strong> ${(data.required_groups || []).map((g, i) => `<span style="background:#ffe6e6;padding:2px 6px;border-radius:4px;margin-right:4px;">G${i+1}: ${(g.terms || g).join(' OR ')}</span>`).join(' ') || 'None'} | |
| </div> | |
| <div class="summary-item"> | |
| <strong>Optional Terms:</strong> ${(data.optional_terms || []).join(', ') || 'None'} | |
| </div> | |
| </div> | |
| `; | |
| // Build dynamic table headers with specific column widths | |
| let headerHtml = '<tr>'; | |
| headerHtml += '<th style="border: 1px solid var(--color-border); padding: 8px; background: var(--color-surface); width: 65px; min-width: 65px;">Block</th>'; | |
| headerHtml += '<th style="border: 1px solid var(--color-border); padding: 8px; background: var(--color-surface); min-width: 200px;">Block Terms</th>'; | |
| headerHtml += '<th style="border: 1px solid var(--color-border); padding: 8px; background: var(--color-surface); white-space: nowrap;">Query Gene</th>'; | |
| headerHtml += '<th style="border: 1px solid var(--color-border); padding: 8px; background: var(--color-surface);">Annotation</th>'; | |
| headerHtml += '<th style="border: 1px solid var(--color-border); padding: 8px; background: var(--color-surface);">Gene Terms</th>'; | |
| compDisplays.forEach(genome => { | |
| headerHtml += `<th style="border: 1px solid var(--color-border); padding: 8px; background: var(--color-surface); white-space: nowrap;">${this.escapeHtml(genome)}</th>`; | |
| headerHtml += `<th style="border: 1px solid var(--color-border); padding: 8px; background: var(--color-surface); width: 90px;">Confidence</th>`; | |
| }); | |
| headerHtml += '</tr>'; | |
| thead.innerHTML = headerHtml; | |
| // Build CSV data for all blocks (for download) | |
| this.buildDownloadData(data, compGenomes, compDisplays); | |
| // Setup pagination controls | |
| this.setupPaginationControls(); | |
| // Display first page | |
| console.log('Discovery displayResults: calling displayPage(1) with', data.blocks?.length || 0, 'blocks'); | |
| this.displayPage(1); | |
| // Initialize sticky scrollbar sync | |
| this.initStickyScrollbar(); | |
| }, | |
| // Initialize sticky scrollbar synchronization | |
| initStickyScrollbar() { | |
| const tableWrapper = document.getElementById('discovery-table-wrapper'); | |
| const stickyScrollbar = document.getElementById('discovery-sticky-scrollbar'); | |
| const stickyInner = document.getElementById('discovery-sticky-scrollbar-inner'); | |
| const table = document.getElementById('discovery-results-table'); | |
| if (!tableWrapper || !stickyScrollbar || !stickyInner || !table) { | |
| console.log('Sticky scrollbar: elements not found'); | |
| return; | |
| } | |
| // Set the inner div width to match table width | |
| const updateWidth = () => { | |
| const tableWidth = table.scrollWidth; | |
| stickyInner.style.width = tableWidth + 'px'; | |
| }; | |
| // Sync scroll positions | |
| let syncing = false; | |
| tableWrapper.addEventListener('scroll', () => { | |
| if (syncing) return; | |
| syncing = true; | |
| stickyScrollbar.scrollLeft = tableWrapper.scrollLeft; | |
| syncing = false; | |
| }); | |
| stickyScrollbar.addEventListener('scroll', () => { | |
| if (syncing) return; | |
| syncing = true; | |
| tableWrapper.scrollLeft = stickyScrollbar.scrollLeft; | |
| syncing = false; | |
| }); | |
| // Update width now and on resize | |
| updateWidth(); | |
| setTimeout(updateWidth, 100); | |
| setTimeout(updateWidth, 500); | |
| window.addEventListener('resize', updateWidth); | |
| }, | |
| // Build download data (CSV for all blocks) | |
| buildDownloadData(data, compGenomes, compDisplays) { | |
| const csvLines = []; | |
| // CSV header | |
| let csvHeader = ['Block', 'Block_Terms', 'Query_Gene', 'Annotation', 'Gene_Terms']; | |
| compDisplays.forEach(genome => { | |
| csvHeader.push(`${genome}_Match`); | |
| csvHeader.push(`${genome}_Conf`); | |
| }); | |
| csvLines.push(csvHeader.join(',')); | |
| (data.blocks || []).forEach((block) => { | |
| const blockId = block.block_id; | |
| const blockTerms = (block.matched_terms || []).join('; '); | |
| const genes = block.genes || []; | |
| genes.forEach((gene, geneIdx) => { | |
| const annotation = (gene.annotation || '').replace(/"/g, '""'); // Escape quotes for CSV | |
| const geneTerms = (gene.matched_terms || []).join('; '); | |
| let csvRow = [ | |
| geneIdx === 0 ? blockId : '', | |
| geneIdx === 0 ? `"${blockTerms}"` : '', | |
| gene.query_gene, | |
| `"${annotation}"`, | |
| `"${geneTerms}"` | |
| ]; | |
| compGenomes.forEach(compGenome => { | |
| const synteny = gene.synteny?.[compGenome] || {}; | |
| const matches = synteny.matches || []; | |
| if (matches.length > 0) { | |
| csvRow.push(`"${matches.slice(0, 5).map(m => m.target_gene).join(';')}"`); | |
| csvRow.push(`"${matches.slice(0, 5).map(m => m.confidence).join(';')}"`); | |
| } else { | |
| csvRow.push('-'); | |
| csvRow.push('-'); | |
| } | |
| }); | |
| csvLines.push(csvRow.join(',')); | |
| }); | |
| // Empty line between blocks | |
| csvLines.push(''); | |
| }); | |
| state.discovery.csvData = csvLines.join('\n'); | |
| const downloadBtn = document.getElementById('discovery-download-tsv'); | |
| if (downloadBtn) { | |
| downloadBtn.href = 'data:text/csv;charset=utf-8,' + encodeURIComponent(state.discovery.csvData); | |
| downloadBtn.download = 'discovery_results.csv'; | |
| console.log('Discovery CSV download ready, data length:', state.discovery.csvData.length); | |
| } else { | |
| console.error('Discovery download button not found'); | |
| } | |
| }, | |
| // Setup pagination controls | |
| setupPaginationControls() { | |
| const prevBtn = document.getElementById('discovery-prev-page'); | |
| const nextBtn = document.getElementById('discovery-next-page'); | |
| // Remove old listeners by cloning | |
| const newPrevBtn = prevBtn.cloneNode(true); | |
| const newNextBtn = nextBtn.cloneNode(true); | |
| prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn); | |
| nextBtn.parentNode.replaceChild(newNextBtn, nextBtn); | |
| newPrevBtn.addEventListener('click', () => { | |
| if (state.discovery.currentPage > 1) { | |
| this.displayPage(state.discovery.currentPage - 1); | |
| } | |
| }); | |
| newNextBtn.addEventListener('click', () => { | |
| const totalPages = Math.ceil((state.discovery.currentData.blocks || []).length / state.discovery.blocksPerPage); | |
| if (state.discovery.currentPage < totalPages) { | |
| this.displayPage(state.discovery.currentPage + 1); | |
| } | |
| }); | |
| }, | |
| // Display a specific page of blocks | |
| displayPage(pageNum) { | |
| const data = state.discovery.currentData; | |
| const blocksPerPage = state.discovery.blocksPerPage; | |
| const blocks = data.blocks || []; | |
| const totalPages = Math.ceil(blocks.length / blocksPerPage); | |
| const compGenomes = data.comparison_genomes || []; | |
| console.log('Discovery displayPage:', pageNum, 'blocks:', blocks.length, 'compGenomes:', compGenomes.length); | |
| state.discovery.currentPage = pageNum; | |
| // Get blocks for current page | |
| const startIdx = (pageNum - 1) * blocksPerPage; | |
| const endIdx = Math.min(startIdx + blocksPerPage, blocks.length); | |
| const pageBlocks = blocks.slice(startIdx, endIdx); | |
| const tbody = document.querySelector('#discovery-results-table tbody'); | |
| const rows = []; | |
| pageBlocks.forEach((block, blockIdx) => { | |
| const blockId = block.block_id; | |
| const blockTerms = (block.matched_terms || []).join(', '); | |
| const genes = block.genes || []; | |
| const geneCount = genes.length; | |
| // Skip blocks with no genes (shouldn't happen, but safety check) | |
| if (geneCount === 0) { | |
| console.warn('Block', blockId, 'has no genes, skipping'); | |
| return; | |
| } | |
| // For ANY mode, show which genomes this block matched in | |
| const genomesMatched = block.genomes_matched || null; | |
| const matchMode = data.match_mode || 'all'; | |
| let genomesMatchedHtml = ''; | |
| if (matchMode === 'any' && genomesMatched && genomesMatched.length > 0) { | |
| // Get display names for matched genomes | |
| const compDisplays = data.comparison_genome_displays || data.comparison_genomes || []; | |
| const matchedGenomes = data.comparison_genomes || []; | |
| const matchedDisplays = genomesMatched.map(g => { | |
| const idx = matchedGenomes.indexOf(g); | |
| return idx >= 0 ? compDisplays[idx] : g; | |
| }); | |
| genomesMatchedHtml = `<br><span style="font-size:0.8em;color:var(--color-text-muted);margin-top:4px;display:block;">Found in: ${matchedDisplays.map(g => `<span style="background:#e6f3ff;padding:1px 4px;border-radius:3px;margin-right:2px;">${this.escapeHtml(g)}</span>`).join('')}</span>`; | |
| } | |
| genes.forEach((gene, geneIdx) => { | |
| const annotation = gene.annotation || ''; | |
| const shortAnnotation = annotation.length > 80 ? annotation.substring(0, 77) + '...' : annotation; | |
| const geneTerms = (gene.matched_terms || []).join(', '); | |
| let rowHtml = '<tr style="border-bottom: 1px solid var(--color-border);">'; | |
| // Block and Block Terms columns - only on first row of each block with rowspan | |
| if (geneIdx === 0) { | |
| rowHtml += `<td rowspan="${geneCount}" style="border: 1px solid var(--color-border); padding: 8px; vertical-align: top; background: rgba(59, 130, 246, 0.05); font-weight: 600; width: 65px; text-align: center;">${blockId}</td>`; | |
| rowHtml += `<td rowspan="${geneCount}" style="border: 1px solid var(--color-border); padding: 8px; vertical-align: top; background: rgba(59, 130, 246, 0.05); font-size: 0.9em; min-width: 200px; word-wrap: break-word; white-space: normal;">${this.escapeHtml(blockTerms)}${genomesMatchedHtml}</td>`; | |
| } | |
| // Gene columns | |
| rowHtml += `<td style="border: 1px solid var(--color-border); padding: 8px; font-family: monospace; font-size: 0.85em;">${this.escapeHtml(gene.query_gene)}</td>`; | |
| rowHtml += `<td style="border: 1px solid var(--color-border); padding: 8px; max-width: 300px; overflow: hidden; text-overflow: ellipsis;" title="${this.escapeHtml(annotation)}">${this.escapeHtml(shortAnnotation)}</td>`; | |
| rowHtml += `<td style="border: 1px solid var(--color-border); padding: 8px; font-size: 0.9em; color: var(--color-accent-primary);">${this.escapeHtml(geneTerms)}</td>`; | |
| // Synteny columns for each comparison genome | |
| compGenomes.forEach(compGenome => { | |
| const synteny = gene.synteny?.[compGenome] || {}; | |
| const matches = synteny.matches || []; | |
| if (matches.length > 0) { | |
| const matchLines = matches.slice(0, 5).map(m => this.escapeHtml(m.target_gene)); | |
| const confLines = matches.slice(0, 5).map(m => | |
| m.confidence === 'H' ? '<span style="color: var(--color-accent-success); font-weight: 600;">H</span>' : | |
| '<span style="color: var(--color-accent-warning);">L</span>' | |
| ); | |
| rowHtml += `<td style="border: 1px solid var(--color-border); padding: 8px; font-family: monospace; font-size: 0.8em; white-space: nowrap;">${matchLines.join('<br>')}</td>`; | |
| rowHtml += `<td style="border: 1px solid var(--color-border); padding: 8px; text-align: center;">${confLines.join('<br>')}</td>`; | |
| } else { | |
| rowHtml += '<td style="border: 1px solid var(--color-border); padding: 8px; text-align: center; color: var(--color-text-muted);">-</td>'; | |
| rowHtml += '<td style="border: 1px solid var(--color-border); padding: 8px; text-align: center; color: var(--color-text-muted);">-</td>'; | |
| } | |
| }); | |
| rowHtml += '</tr>'; | |
| rows.push(rowHtml); | |
| }); | |
| // Add visual separator between blocks (except for last block on page) | |
| if (blockIdx < pageBlocks.length - 1) { | |
| const colSpan = 5 + compGenomes.length * 2; | |
| rows.push(`<tr><td colspan="${colSpan}" style="height: 8px; background: var(--color-bg); border: none;"></td></tr>`); | |
| } | |
| }); | |
| console.log('Discovery displayPage: rows built:', rows.length, 'tbody exists:', !!tbody); | |
| tbody.innerHTML = rows.length > 0 ? rows.join('') : `<tr><td colspan="${5 + compGenomes.length * 2}" style="text-align: center; padding: 20px;">No matching blocks found</td></tr>`; | |
| // Update pagination controls | |
| const prevBtn = document.getElementById('discovery-prev-page'); | |
| const nextBtn = document.getElementById('discovery-next-page'); | |
| const pageInfo = document.getElementById('discovery-page-info'); | |
| const pageTotal = document.getElementById('discovery-page-total'); | |
| prevBtn.disabled = pageNum <= 1; | |
| nextBtn.disabled = pageNum >= totalPages; | |
| prevBtn.classList.toggle('disabled', pageNum <= 1); | |
| nextBtn.classList.toggle('disabled', pageNum >= totalPages); | |
| pageInfo.textContent = pageNum; | |
| pageTotal.textContent = `of ${totalPages || 1} (${blocks.length} blocks total)`; | |
| // Show/hide pagination if only one page | |
| const paginationDiv = document.getElementById('discovery-pagination'); | |
| paginationDiv.style.display = totalPages > 1 ? 'block' : 'none'; | |
| // Scroll to the top of the results table | |
| const resultsCard = document.getElementById('discovery-results'); | |
| if (resultsCard) { | |
| resultsCard.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| } | |
| // Update sticky scrollbar width after content change | |
| this.updateStickyScrollbarWidth(); | |
| }, | |
| // Update sticky scrollbar width (called after pagination changes) | |
| updateStickyScrollbarWidth() { | |
| const tableWrapper = document.getElementById('discovery-table-wrapper'); | |
| const stickyScrollbar = document.getElementById('discovery-sticky-scrollbar'); | |
| const table = document.getElementById('discovery-results-table'); | |
| if (!tableWrapper || !stickyScrollbar || !table) return; | |
| const scrollbarContent = stickyScrollbar.querySelector('.sticky-scrollbar-content'); | |
| if (!scrollbarContent) return; | |
| // Defer to let table render | |
| setTimeout(() => { | |
| const tableWidth = table.scrollWidth; | |
| const wrapperWidth = tableWrapper.clientWidth; | |
| scrollbarContent.style.width = tableWidth + 'px'; | |
| // Only show sticky scrollbar if content overflows | |
| if (tableWidth > wrapperWidth) { | |
| stickyScrollbar.style.display = 'block'; | |
| } else { | |
| stickyScrollbar.style.display = 'none'; | |
| } | |
| }, 50); | |
| }, | |
| // Escape HTML | |
| escapeHtml(text) { | |
| if (!text) return ''; | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| }; | |
| // ============================================================================ | |
| // Initialization | |
| // ============================================================================ | |
| async function init() { | |
| console.log('Initializing Plant-mSyn...'); | |
| // Disable browser's automatic scroll restoration and scroll to top on reload | |
| if ('scrollRestoration' in history) { | |
| history.scrollRestoration = 'manual'; | |
| } | |
| window.scrollTo(0, 0); | |
| // Restore the last active tab from sessionStorage (if any) | |
| const savedTab = sessionStorage.getItem('plantmsyn-current-tab'); | |
| // Load genomes | |
| state.genomes = await API.getGenomes(); | |
| UI.populateGenomeSelects(state.genomes); | |
| // Setup navigation | |
| document.querySelectorAll('.nav-item').forEach(item => { | |
| item.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| UI.switchTab(item.dataset.tab); | |
| }); | |
| }); | |
| // Setup User Genes event listeners | |
| document.getElementById('user-query-genome').addEventListener('change', Handlers.onUserGenomeChange); | |
| document.getElementById('add-gene-btn').addEventListener('click', () => UI.addGeneRow()); | |
| document.getElementById('remove-gene-btn').addEventListener('click', () => UI.removeGeneRow()); | |
| document.getElementById('reset-genes-btn').addEventListener('click', () => UI.resetGeneRows()); | |
| document.getElementById('user-generate-btn').addEventListener('click', Handlers.onUserGeneratePlot); | |
| // Setup Advanced Options (Search Hits) | |
| const searchHitsBtn = document.getElementById('search-hits-btn'); | |
| if (searchHitsBtn) { | |
| searchHitsBtn.addEventListener('click', Handlers.onSearchHits); | |
| } | |
| // Setup Custom Genome event listeners | |
| const customUploadForm = document.getElementById('custom-upload-form'); | |
| if (customUploadForm) { | |
| customUploadForm.addEventListener('submit', (e) => Handlers.onCustomUpload(e)); | |
| } | |
| // Setup Sequences Upload form event listeners | |
| const seqUploadForm = document.getElementById('custom-sequences-form'); | |
| if (seqUploadForm) { | |
| seqUploadForm.addEventListener('submit', (e) => Handlers.onSequencesUpload(e)); | |
| } | |
| const seqSelectAllBtn = document.getElementById('seq-select-all-btn'); | |
| if (seqSelectAllBtn) { | |
| seqSelectAllBtn.addEventListener('click', () => { | |
| document.querySelectorAll('#seq-comp-genomes .custom-genome-cb').forEach(cb => cb.checked = true); | |
| }); | |
| } | |
| const seqSelectNoneBtn = document.getElementById('seq-select-none-btn'); | |
| if (seqSelectNoneBtn) { | |
| seqSelectNoneBtn.addEventListener('click', () => { | |
| document.querySelectorAll('#seq-comp-genomes .custom-genome-cb').forEach(cb => cb.checked = false); | |
| }); | |
| } | |
| const seqRunMcscanBtn = document.getElementById('seq-run-mcscan-btn'); | |
| if (seqRunMcscanBtn) { | |
| seqRunMcscanBtn.addEventListener('click', () => Handlers.onSeqRunMcscan()); | |
| } | |
| const seqCopyKeyBtn = document.getElementById('seq-copy-key-btn'); | |
| if (seqCopyKeyBtn) { | |
| seqCopyKeyBtn.addEventListener('click', () => { | |
| const key = document.getElementById('seq-run-key-display').textContent; | |
| navigator.clipboard.writeText(key).then(() => { | |
| seqCopyKeyBtn.textContent = '✓ Copied!'; | |
| setTimeout(() => seqCopyKeyBtn.textContent = '📋 Copy', 2000); | |
| }); | |
| }); | |
| } | |
| const customSelectAllBtn = document.getElementById('custom-select-all-btn'); | |
| if (customSelectAllBtn) { | |
| customSelectAllBtn.addEventListener('click', () => { | |
| document.querySelectorAll('.custom-genome-cb').forEach(cb => cb.checked = true); | |
| state.custom.selectedGenomes = state.genomes.map(g => g.id); | |
| }); | |
| } | |
| const customSelectNoneBtn = document.getElementById('custom-select-none-btn'); | |
| if (customSelectNoneBtn) { | |
| customSelectNoneBtn.addEventListener('click', () => { | |
| document.querySelectorAll('.custom-genome-cb').forEach(cb => cb.checked = false); | |
| state.custom.selectedGenomes = []; | |
| }); | |
| } | |
| const customRunMcscanBtn = document.getElementById('custom-run-mcscan-btn'); | |
| if (customRunMcscanBtn) { | |
| customRunMcscanBtn.addEventListener('click', () => Handlers.onCustomRunMcscan()); | |
| } | |
| const customCopyKeyBtn = document.getElementById('custom-copy-key-btn'); | |
| if (customCopyKeyBtn) { | |
| customCopyKeyBtn.addEventListener('click', () => { | |
| const key = document.getElementById('custom-run-key-display').textContent; | |
| navigator.clipboard.writeText(key).then(() => { | |
| customCopyKeyBtn.textContent = '✓ Copied!'; | |
| setTimeout(() => customCopyKeyBtn.textContent = '📋 Copy', 2000); | |
| }); | |
| }); | |
| } | |
| const customLookupBtn = document.getElementById('custom-lookup-btn'); | |
| if (customLookupBtn) { | |
| customLookupBtn.addEventListener('click', () => Handlers.onCustomLookup()); | |
| } | |
| const customRefreshListBtn = document.getElementById('custom-refresh-list-btn'); | |
| if (customRefreshListBtn) { | |
| customRefreshListBtn.addEventListener('click', () => Handlers.refreshCustomGenomesList()); | |
| } | |
| // Custom genome plotting UI event listeners | |
| const customClearRunBtn = document.getElementById('custom-clear-run-btn'); | |
| if (customClearRunBtn) { | |
| customClearRunBtn.addEventListener('click', () => Handlers.clearCustomRun()); | |
| } | |
| const customAddGeneBtn = document.getElementById('custom-add-gene-btn'); | |
| if (customAddGeneBtn) { | |
| customAddGeneBtn.addEventListener('click', () => Handlers.addCustomGeneRow()); | |
| } | |
| const customRemoveGeneBtn = document.getElementById('custom-remove-gene-btn'); | |
| if (customRemoveGeneBtn) { | |
| customRemoveGeneBtn.addEventListener('click', () => Handlers.removeCustomGeneRow()); | |
| } | |
| const customResetGenesBtn = document.getElementById('custom-reset-genes-btn'); | |
| if (customResetGenesBtn) { | |
| customResetGenesBtn.addEventListener('click', () => Handlers.resetCustomGeneRows()); | |
| } | |
| const customGenerateBtn = document.getElementById('custom-generate-btn'); | |
| if (customGenerateBtn) { | |
| customGenerateBtn.addEventListener('click', () => Handlers.onCustomGeneratePlot()); | |
| } | |
| // Custom genome search hits button | |
| const customSearchHitsBtn = document.getElementById('custom-search-hits-btn'); | |
| if (customSearchHitsBtn) { | |
| customSearchHitsBtn.addEventListener('click', () => Handlers.onCustomSearchHits()); | |
| } | |
| // ========================================================================= | |
| // Custom Synteny (Multi-Genome) Event Listeners | |
| // ========================================================================= | |
| // Initialize Custom Synteny page | |
| CustomSyntenyHandlers.init(); | |
| // Project name input - update button state | |
| const csyntenyProjectName = document.getElementById('csynteny-project-name'); | |
| if (csyntenyProjectName) { | |
| csyntenyProjectName.addEventListener('input', () => CustomSyntenyUI.updateUploadButtonState()); | |
| } | |
| // Add genome button | |
| const csyntenyAddGenomeBtn = document.getElementById('csynteny-add-genome-btn'); | |
| if (csyntenyAddGenomeBtn) { | |
| csyntenyAddGenomeBtn.addEventListener('click', () => CustomSyntenyHandlers.addGenome()); | |
| } | |
| // Remove last genome button | |
| const csyntenyRemoveGenomeBtn = document.getElementById('csynteny-remove-genome-btn'); | |
| if (csyntenyRemoveGenomeBtn) { | |
| csyntenyRemoveGenomeBtn.addEventListener('click', () => CustomSyntenyHandlers.removeLastGenome()); | |
| } | |
| // Reset genomes button | |
| const csyntenyResetGenomesBtn = document.getElementById('csynteny-reset-genomes-btn'); | |
| if (csyntenyResetGenomesBtn) { | |
| csyntenyResetGenomesBtn.addEventListener('click', () => CustomSyntenyHandlers.resetGenomes()); | |
| } | |
| // Select all/none pairs buttons | |
| const csyntenySelectAllPairsBtn = document.getElementById('csynteny-select-all-pairs-btn'); | |
| if (csyntenySelectAllPairsBtn) { | |
| csyntenySelectAllPairsBtn.addEventListener('click', () => CustomSyntenyHandlers.selectAllPairs()); | |
| } | |
| const csyntenySelectNonePairsBtn = document.getElementById('csynteny-select-none-pairs-btn'); | |
| if (csyntenySelectNonePairsBtn) { | |
| csyntenySelectNonePairsBtn.addEventListener('click', () => CustomSyntenyHandlers.selectNonePairs()); | |
| } | |
| // Upload & Run button | |
| const csyntenyUploadRunBtn = document.getElementById('csynteny-upload-run-btn'); | |
| if (csyntenyUploadRunBtn) { | |
| csyntenyUploadRunBtn.addEventListener('click', () => CustomSyntenyHandlers.onUploadAndRun()); | |
| } | |
| // Copy run key button | |
| const csyntenyCopyKeyBtn = document.getElementById('csynteny-copy-key-btn'); | |
| if (csyntenyCopyKeyBtn) { | |
| csyntenyCopyKeyBtn.addEventListener('click', () => CustomSyntenyHandlers.copyRunKey()); | |
| } | |
| // Lookup button | |
| const csyntenyLookupBtn = document.getElementById('csynteny-lookup-btn'); | |
| if (csyntenyLookupBtn) { | |
| csyntenyLookupBtn.addEventListener('click', () => CustomSyntenyHandlers.onLookup()); | |
| } | |
| // Refresh list button | |
| const csyntenyRefreshListBtn = document.getElementById('csynteny-refresh-list-btn'); | |
| if (csyntenyRefreshListBtn) { | |
| csyntenyRefreshListBtn.addEventListener('click', () => CustomSyntenyHandlers.refreshProjectsList()); | |
| } | |
| // Clear run button | |
| const csyntenyClearRunBtn = document.getElementById('csynteny-clear-run-btn'); | |
| if (csyntenyClearRunBtn) { | |
| csyntenyClearRunBtn.addEventListener('click', () => CustomSyntenyHandlers.clearRun()); | |
| } | |
| // Gene controls | |
| const csyntenyAddGeneBtn = document.getElementById('csynteny-add-gene-btn'); | |
| if (csyntenyAddGeneBtn) { | |
| csyntenyAddGeneBtn.addEventListener('click', () => CustomSyntenyHandlers.addGeneRow()); | |
| } | |
| const csyntenyRemoveGeneBtn = document.getElementById('csynteny-remove-gene-btn'); | |
| if (csyntenyRemoveGeneBtn) { | |
| csyntenyRemoveGeneBtn.addEventListener('click', () => CustomSyntenyHandlers.removeGeneRow()); | |
| } | |
| const csyntenyResetGenesBtn = document.getElementById('csynteny-reset-genes-btn'); | |
| if (csyntenyResetGenesBtn) { | |
| csyntenyResetGenesBtn.addEventListener('click', () => CustomSyntenyHandlers.resetGeneRows()); | |
| } | |
| // Generate plot button | |
| const csyntenyGenerateBtn = document.getElementById('csynteny-generate-btn'); | |
| if (csyntenyGenerateBtn) { | |
| csyntenyGenerateBtn.addEventListener('click', () => CustomSyntenyHandlers.onGeneratePlot()); | |
| } | |
| // Advanced Search button | |
| const csyntenySearchHitsBtn = document.getElementById('csynteny-search-hits-btn'); | |
| if (csyntenySearchHitsBtn) { | |
| csyntenySearchHitsBtn.addEventListener('click', () => CustomSyntenyHandlers.onSearchHits()); | |
| } | |
| // Setup collapsible cards (standard .card.collapsible) - EXCLUDE section-blocks which have custom handlers | |
| document.querySelectorAll('.card.collapsible:not(.section-block) .collapse-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const card = btn.closest('.card'); | |
| const body = card.querySelector('.card-body'); | |
| const isCollapsed = body.style.display === 'none'; | |
| body.style.display = isCollapsed ? 'block' : 'none'; | |
| btn.textContent = isCollapsed ? '▼' : '▶'; | |
| }); | |
| }); | |
| // Also make the header clickable for standard .card.collapsible - EXCLUDE section-blocks | |
| document.querySelectorAll('.card.collapsible:not(.section-block) .card-header').forEach(header => { | |
| header.style.cursor = 'pointer'; | |
| header.addEventListener('click', (e) => { | |
| if (e.target.closest('.collapse-btn')) return; | |
| const card = header.closest('.card'); | |
| const body = card.querySelector('.card-body'); | |
| const btn = card.querySelector('.collapse-btn'); | |
| const isCollapsed = body.style.display === 'none'; | |
| body.style.display = isCollapsed ? 'block' : 'none'; | |
| if (btn) btn.textContent = isCollapsed ? '▼' : '▶'; | |
| }); | |
| }); | |
| // Setup section-block buttons to use same toggle as header onclick | |
| const mcscanBlockToggle = document.getElementById('mcscan-block-toggle'); | |
| if (mcscanBlockToggle) { | |
| mcscanBlockToggle.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| CustomUI.toggleMcscanBlock(); | |
| }); | |
| } | |
| const plotBlockToggle = document.getElementById('plot-block-toggle'); | |
| if (plotBlockToggle) { | |
| plotBlockToggle.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| CustomUI.togglePlotBlock(); | |
| }); | |
| } | |
| const csyntenyMcscanBlockToggle = document.getElementById('csynteny-mcscan-block-toggle'); | |
| if (csyntenyMcscanBlockToggle) { | |
| csyntenyMcscanBlockToggle.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| CustomSyntenyUI.toggleMcscanBlock(); | |
| }); | |
| } | |
| const csyntenyPlotBlockToggle = document.getElementById('csynteny-plot-block-toggle'); | |
| if (csyntenyPlotBlockToggle) { | |
| csyntenyPlotBlockToggle.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| CustomSyntenyUI.togglePlotBlock(); | |
| }); | |
| } | |
| // Setup collapsible cards for custom genome (.card-inner.collapsible) | |
| document.querySelectorAll('.card-inner.collapsible .collapse-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| e.stopPropagation(); // Prevent parent card header click | |
| const card = btn.closest('.card-inner'); | |
| const body = card.querySelector('.card-body-inner'); | |
| const isCollapsed = body.style.display === 'none'; | |
| body.style.display = isCollapsed ? 'block' : 'none'; | |
| btn.textContent = isCollapsed ? '▼' : '▶'; | |
| }); | |
| }); | |
| // Also make the header clickable for .card-inner.collapsible | |
| document.querySelectorAll('.card-inner.collapsible .card-header-inner').forEach(header => { | |
| header.style.cursor = 'pointer'; | |
| header.addEventListener('click', (e) => { | |
| // Only toggle if clicking on the header itself, not the button | |
| if (e.target.closest('.collapse-btn')) return; | |
| const card = header.closest('.card-inner'); | |
| const body = card.querySelector('.card-body-inner'); | |
| const btn = card.querySelector('.collapse-btn'); | |
| const isCollapsed = body.style.display === 'none'; | |
| body.style.display = isCollapsed ? 'block' : 'none'; | |
| if (btn) btn.textContent = isCollapsed ? '▼' : '▶'; | |
| }); | |
| }); | |
| // Initialize default gene rows | |
| UI.resetGeneRows(); | |
| // Setup example analysis button in About tab | |
| const exampleButton = document.getElementById('load-example-analysis'); | |
| if (exampleButton) { | |
| exampleButton.addEventListener('click', () => Handlers.loadExampleAnalysis()); | |
| } | |
| // Initialize Discovery tab (wait for it to complete loading) | |
| await DiscoveryHandlers.init(); | |
| // Restore the saved tab or default to usergenes | |
| const tabToShow = savedTab || 'usergenes'; | |
| UI.switchTab(tabToShow); | |
| // Reveal main content now that correct tab is set (prevents flash) | |
| const mainContent = document.querySelector('.main-content'); | |
| if (mainContent) { | |
| mainContent.style.opacity = '1'; | |
| } | |
| console.log('Plant-mSyn initialization complete!'); | |
| } | |
| // Alias for HTML onclick handlers | |
| const Discovery = DiscoveryHandlers; | |
| // Start the app | |
| document.addEventListener('DOMContentLoaded', init); | |