plant-msyn / static /js /app.js
Yoshigold's picture
Upload static/js/app.js with huggingface_hub
b56528b verified
/**
* 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, '&quot;')})">
📊 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);