UNPKG

hyphy-scope

Version:

Reusable Svelte components for HyPhy analysis visualization

658 lines (657 loc) 26 kB
/** * ABSREL (Adaptive Branch-Site Random Effects Likelihood) utility functions * * ABSREL tests whether a gene has experienced positive selection, * without requiring a priori specification of lineages. */ /** * Extract summary statistics from ABSREL results */ export function getAbsrelSummary(data) { // Handle both old and new data formats safely const sequences = data.sequences || data.input?.['number of sequences'] || 0; const sites = data.sites || data.input?.['number of sites'] || 0; const branchesTested = data['branches tested'] || 0; const branchesWithSelection = data['branches with selection'] || 0; const pValueThreshold = data['p-value threshold'] || 0.05; // Safely access fits data const baselineLogLikelihood = data.fits?.['Baseline model']?.['log-likelihood'] || 0; const fullModelLogLikelihood = data.fits?.['Full adaptive model']?.['log-likelihood'] || 0; const baselineAIC = data.fits?.['Baseline model']?.AIC || 0; const fullModelAIC = data.fits?.['Full adaptive model']?.AIC || 0; return { sequences, sites, branchesTested, branchesWithSelection, pValueThreshold, baselineLogLikelihood, fullModelLogLikelihood, lrt: 2 * (fullModelLogLikelihood - baselineLogLikelihood), baselineAIC, fullModelAIC }; } /** * Get tested branches with their selection results */ export function getTestedBranches(data) { const testResults = data['test results'] || {}; const branchAttributes = data['branch attributes'] || {}; return Object.keys(testResults).map(branchName => { const result = testResults[branchName] || {}; // Try to get branch attributes from different possible locations let branchAttrs = {}; if (branchAttributes[branchName]) { branchAttrs = branchAttributes[branchName]; } else if (branchAttributes['0'] && branchAttributes['0'][branchName]) { branchAttrs = branchAttributes['0'][branchName]; } // Extract omega distribution if available const rateDistributions = branchAttrs['Rate Distributions'] || []; let omegaDistribution = []; // Convert rate distributions to omega format if (rateDistributions && typeof rateDistributions === 'object') { omegaDistribution = Object.entries(rateDistributions).map(([key, value], index) => ({ 'rate class': index + 1, omega: Array.isArray(value) && value.length >= 2 ? value[0] : (value || 0), weight: Array.isArray(value) && value.length >= 2 ? value[1] : 1 })); } return { name: branchName, 'Rate classes': branchAttrs['Rate classes'] || result['Rate classes'] || omegaDistribution.length || 1, 'Uncorrected P-value': result['uncorrected p'] || result['Uncorrected P-value'] || 0, 'Corrected P-value': result['corrected p'] || result['Corrected P-value'] || result.p || 0, 'Bayes Factor': result['Bayes Factor'] || Math.exp(result.LRT || 0), 'ω distribution': omegaDistribution }; }); } /** * Get site-level log likelihood data for visualization */ export function getAbsrelSiteData(data) { const siteLogLikelihood = data['Site Log Likelihood']?.tested; if (!siteLogLikelihood || typeof siteLogLikelihood !== 'object') { // Return minimal mock data for testing const numSites = data.sites || data.input?.['number of sites'] || 5; return Array.from({ length: numSites }, (_, siteIndex) => ({ site: siteIndex + 1, partition: 1 })); } const branchNames = Object.keys(siteLogLikelihood); if (branchNames.length === 0) return []; const firstBranchData = siteLogLikelihood[branchNames[0]]; if (!firstBranchData || !Array.isArray(firstBranchData)) return []; const numSites = firstBranchData.length; return Array.from({ length: numSites }, (_, siteIndex) => { const siteData = { site: siteIndex + 1, partition: 1 // Default partition, could be extracted from data if available }; // Add log likelihood values for each tested branch branchNames.forEach(branchName => { const branchData = siteLogLikelihood[branchName]; if (Array.isArray(branchData) && branchData[siteIndex] !== undefined) { // Handle both array of arrays and array of numbers const value = Array.isArray(branchData[siteIndex]) ? branchData[siteIndex][0] : branchData[siteIndex]; siteData[branchName] = typeof value === 'number' ? value : 0; } }); return siteData; }); } /** * Filter branches by significance */ export function getSignificantBranches(branches, pValueThreshold = 0.05) { return branches.filter(branch => branch['Corrected P-value'] <= pValueThreshold); } /** * Get table headers for ABSREL results */ export function getAbsrelTableHeaders() { return [ { key: 'name', label: 'Branch', sortable: true }, { key: 'Rate classes', label: 'Rate Classes', sortable: true }, { key: 'Uncorrected P-value', label: 'Uncorrected p-value', sortable: true }, { key: 'Corrected P-value', label: 'Corrected p-value', sortable: true }, { key: 'Bayes Factor', label: 'Bayes Factor', sortable: true } ]; } /** * Format numeric values for display */ export function formatAbsrelValue(value, key) { if (typeof value !== 'number') return String(value); if (key.includes('p-value') || key.includes('P-value')) { return value < 0.001 ? value.toExponential(2) : value.toFixed(4); } if (key === 'Bayes Factor') { return value.toFixed(2); } if (key === 'Rate classes') { return value.toString(); } return value.toFixed(4); } /** * Get color for p-value significance */ export function getPValueColor(pValue, threshold = 0.05) { if (pValue <= 0.001) return '#8B0000'; // Dark red for highly significant if (pValue <= 0.01) return '#DC143C'; // Crimson for very significant if (pValue <= threshold) return '#FF6347'; // Tomato for significant return '#000000'; // Black for non-significant } /** * Extract synonymous site posteriors if available */ export function getSynonymousSitePosteriors(data) { return data['Synonymous site-posteriors'] || {}; } /** * Calculate evidence ratio for branches */ export function calculateEvidenceRatio(bayesFactor) { return Math.log10(Math.max(bayesFactor, 1e-10)); } /** * Extract comprehensive attributes from ABSREL results */ export function getAbsrelAttributes(resultsJson) { // Safely extract values with fallbacks const positiveResults = resultsJson["test results"]?.["positive test results"] || resultsJson["branches with selection"] || 0; const pvalueThreshold = resultsJson["test results"]?.["P-value threshold"] || resultsJson["p-value threshold"] || 0.05; const profilableBranches = new Set(Object.keys(resultsJson["Site Log Likelihood"]?.["tested"] || {})); // Calculate synonymous rate variation const srvRateClasses = resultsJson["Synonymous site-posteriors"] ? resultsJson["Synonymous site-posteriors"].length : 0; const srvDistribution = getSrvDistribution(resultsJson); // Calculate omega rate classes per partition const branchAttributes = resultsJson["branch attributes"] || {}; let omegaRateClasses = [0]; try { omegaRateClasses = Object.values(branchAttributes) .map((partition) => { if (!partition || typeof partition !== 'object') return 0; return Math.max(...Object.values(partition) .map((branch) => { if (!branch?.["Rate Distributions"]) return 0; return typeof branch["Rate Distributions"] === 'object' ? Object.keys(branch["Rate Distributions"]).length : 0; })); }) .filter(n => n > 0); if (omegaRateClasses.length === 0) omegaRateClasses = [0]; } catch (e) { omegaRateClasses = [0]; } // Calculate median rates for multiple nucleotide changes (simplified) const mhRates = { DH: 0, TH: 0 }; const testedBranchCount = calculateMedianTestedBranches(resultsJson); const profileBranchSites = getAbsrelProfileBranchSites(resultsJson); return { positiveResults, pvalueThreshold, profilableBranches, testedBranchCount, srvRateClasses, srvDistribution, omegaRateClasses, mhRates, profileBranchSites, numberOfSequences: resultsJson.sequences || resultsJson.input?.["number of sequences"] || 0, numberOfSites: resultsJson.sites || resultsJson.input?.["number of sites"] || 0, numberOfPartitions: resultsJson.input?.["partition count"] || 1, partitionSizes: getPartitionSizes(resultsJson) }; } /** * Get synonymous rate variation distribution */ function getSrvDistribution(resultsJson) { const srvData = resultsJson.fits?.["Full adaptive model"]?.["Rate Distributions"]?.["Synonymous site-to-site rates"]; if (!srvData || !Array.isArray(srvData)) return null; return srvData.map((item, index) => ({ value: item.rate || item[0] || 0, weight: item.proportion || item[1] || 0 })); } /** * Calculate median number of tested branches across partitions */ function calculateMedianTestedBranches(resultsJson) { const tested = resultsJson.tested || []; if (!Array.isArray(tested) || tested.length === 0) { // Fallback: count test results const testResults = resultsJson['test results'] || {}; return Object.keys(testResults).length; } const counts = tested.map((partition) => { if (!partition || typeof partition !== 'object') return 0; return Object.values(partition).filter((status) => status === "test").length; }); if (counts.length === 0) return 0; counts.sort((a, b) => a - b); return counts[Math.floor(counts.length / 2)]; } /** * Get partition sizes */ function getPartitionSizes(resultsJson) { const partitions = resultsJson["data partitions"] || {}; if (Object.keys(partitions).length === 0) return [resultsJson.sites || 5]; return Object.values(partitions).map((partition) => partition.coverage?.[0]?.length || 0); } /** * Profile branch sites by calculating metrics based on log likelihoods */ export function getAbsrelProfileBranchSites(resultsJson) { const results = []; const unc = resultsJson["Site Log Likelihood"]?.["unconstrained"]?.["0"]; const subs = resultsJson["substitutions"]?.["0"]; if (!unc || !subs || !Array.isArray(unc)) { // Return empty array if data not available return results; } unc.forEach((ll, i) => { const subsAtSite = generateSubstitutionLabels(subs[i]); Object.entries(subsAtSite).forEach(([node, info]) => { if (node !== 'root') { const bsLL = resultsJson["Site Log Likelihood"]?.["tested"]?.[node]?.[0]?.[i]; if (typeof bsLL === 'number') { const subCount = calculateSubstitutionCounts(info[2], info[0]); results.push({ Key: `${node}|${i + 1}`, branch: node, site: i + 1, ER: Math.exp(unc[i] - bsLL), subs: info[3] || 0, from: info[2] || '', to: info[0] || '', syn_subs: subCount[0], nonsyn_subs: subCount[1] }); } } }); }); return results; } /** * Generate substitution labels for a site */ function generateSubstitutionLabels(subsData) { // Simplified implementation - would need access to tree utilities for full implementation return subsData || {}; } /** * Calculate synonymous and non-synonymous substitution counts */ function calculateSubstitutionCounts(from, to) { // Simplified implementation - would need codon translation tables for accurate counts if (!from || !to || from === to) return [0, 0]; // Placeholder logic - in reality would need genetic code translation const changes = countNucleotideChanges(from, to); // Assume roughly equal distribution for now const syn = Math.floor(changes / 2); const nonsyn = changes - syn; return [syn, nonsyn]; } /** * Count nucleotide changes between two codons */ function countNucleotideChanges(codon1, codon2) { if (!codon1 || !codon2 || codon1.length !== 3 || codon2.length !== 3) return 0; let changes = 0; for (let i = 0; i < 3; i++) { if (codon1[i] !== codon2[i]) changes++; } return changes; } /** * Create table specs for tile display */ export function getAbsrelTileSpecs(resultsJson, evThreshold, distributionTable) { const attrs = getAbsrelAttributes(resultsJson); const medianDH = attrs.mhRates.DH > 0 ? attrs.mhRates.DH.toFixed(4) : "N/A"; const medianTH = attrs.mhRates.TH > 0 ? attrs.mhRates.TH.toFixed(4) : "N/A"; const meanSitesWithER = distributionTable .filter(r => r.tested === "Yes") .reduce((sum, r) => sum + (r.sites || 0), 0) / Math.max(distributionTable.filter(r => r.tested === "Yes").length, 1); return [ { number: attrs.numberOfSequences, description: "sequences in the alignment", icon: "icon-options-vertical icons", color: "asbestos", }, { number: attrs.numberOfSites, description: "codon sites in the alignment", icon: "icon-options icons", color: "asbestos" }, { number: attrs.numberOfPartitions, description: "partitions", icon: "icon-arrow-up icons", color: "asbestos" }, { number: attrs.testedBranchCount, description: "median branches/partition used for testing", icon: "icon-share icons", color: "asbestos", }, { number: `${Math.min(...attrs.omegaRateClasses)}-${Math.max(...attrs.omegaRateClasses)}`, description: "rate classes per branch", icon: "icon-grid icons", color: "asbestos" }, { number: attrs.srvRateClasses ? `${attrs.srvRateClasses} classes` : "None", description: "synonymous rate variation", icon: "icon-layers icons", color: "asbestos" }, { number: attrs.positiveResults, description: "branches with evidence of selection", icon: "icon-plus icons", color: "midnight_blue", }, { number: meanSitesWithER.toFixed(1), description: `Sites/tested branch with ER≥${evThreshold} for positive selection`, icon: "icon-energy icons", color: "midnight_blue" }, { number: `${medianDH}:${medianTH}`, description: "Median multiple hit rates (2H:3H)", icon: "icon-target icons", color: "midnight_blue" } ]; } /** * Get rate distribution for a branch */ export function getAbsrelTestOmega(resultsJson, branch) { const branchData = resultsJson["branch attributes"]?.["0"]?.[branch]; if (!branchData?.["Rate Distributions"]) return []; const distributions = branchData["Rate Distributions"]; // Handle different formats of rate distributions if (Array.isArray(distributions)) { return distributions.map((dist, index) => ({ value: dist["0"] || dist.omega || 0, weight: dist["1"] || dist.weight || 0 })); } else if (typeof distributions === 'object') { // Convert object format to array return Object.entries(distributions).map(([key, value], index) => ({ value: Array.isArray(value) && value.length >= 2 ? value[0] : (value || 0), weight: Array.isArray(value) && value.length >= 2 ? value[1] : 1 })); } return []; } /** * Get site index to partition and codon mapping */ export function getAbsrelSiteIndexPartitionCodon(resultsJson) { const partitions = resultsJson['data partitions'] || {}; const mappedData = Object.entries(partitions).map(([k, d]) => { const coverage = d?.['coverage']?.[0]; if (!Array.isArray(coverage)) return []; return coverage.map((site) => [+k + 1, site + 1]); }); return [].concat(...mappedData); } /** * Get branch color options for tree visualization */ export function getAbsrelTreeColorOptions(resultsJson, evThreshold) { const attrs = getAbsrelAttributes(resultsJson); let options = ["Tested"]; if (resultsJson.substitutions) { options.push("Support for selection"); options.push("Substitutions"); } if (attrs.mhRates.DH > 0) { options.push("2-hit rate"); } if (attrs.mhRates.TH > 0) { options.push("3-hit rate"); } return options; } /** * Create tree configuration for ABSREL visualization */ export function createAbsrelTreeConfig(resultsJson, partitionIndex = 0, options = {}) { const { height = 600, width = 800, alignTips = false, showScale = true, isRadial = false, showInternal = false, colorBranches = "Tested", branchLength = "Baseline MG94xREV", evThreshold = 100 } = options; const config = { height, width, 'align-tips': alignTips, 'show-scale': showScale, 'is-radial': isRadial, 'left-right-spacing': 'fit-to-size', 'top-bottom-spacing': 'fit-to-size', 'node_circle_size': () => 0, 'internal-names': showInternal, configureBranches: (rawTree, renderedTree) => { configureAbsrelBranches(rawTree, renderedTree, resultsJson, { colorBranches, branchLength, partitionIndex, evThreshold }); } }; return config; } /** * Configure branch styling for ABSREL trees */ function configureAbsrelBranches(rawTree, renderedTree, resultsJson, options) { const { colorBranches, branchLength, partitionIndex, evThreshold } = options; const attrs = getAbsrelAttributes(resultsJson); const branchAttributes = resultsJson["branch attributes"]?.[partitionIndex] || {}; renderedTree.traverse_and_compute((node) => { const branchName = node.data?.name; if (!branchName || !branchAttributes[branchName]) return; const branchData = branchAttributes[branchName]; // Set branch length if (branchData[branchLength] !== undefined) { node.data.attribute = branchData[branchLength]; } // Configure branch coloring switch (colorBranches) { case "Tested": if (resultsJson.tested?.[partitionIndex]?.[branchName] === "test") { node.data.color = "#1f77b4"; // Blue for tested branches } else { node.data.color = "#cccccc"; // Gray for untested } break; case "Support for selection": const pValue = branchData["Corrected P-value"]; if (pValue !== undefined) { if (pValue <= 0.001) { node.data.color = "#8B0000"; // Dark red for highly significant } else if (pValue <= 0.01) { node.data.color = "#DC143C"; // Crimson for very significant } else if (pValue <= 0.05) { node.data.color = "#FF6347"; // Tomato for significant } else { node.data.color = "#cccccc"; // Gray for non-significant } } break; case "Substitutions": // Color based on substitution counts if available const substitutions = resultsJson.substitutions?.[partitionIndex]; if (substitutions) { // Simplified - would need more complex logic for actual substitution counting node.data.color = "#2ca02c"; // Green } break; case "2-hit rate": const dhRate = branchData["Rate Distributions"]?.["rate at which 2 nucleotides are changed instantly within a single codon"]; if (dhRate !== undefined) { // Use a color scale based on the rate const colorScale = createRateColorScale([0, attrs.mhRates.DH * 2]); node.data.color = colorScale(dhRate); } break; case "3-hit rate": const thRate = branchData["Rate Distributions"]?.["rate at which 3 nucleotides are changed instantly within a single codon"]; if (thRate !== undefined) { const colorScale = createRateColorScale([0, attrs.mhRates.TH * 2]); node.data.color = colorScale(thRate); } break; } // Add branch labels if requested if (options.addBranchLabels) { const pValue = branchData["Corrected P-value"]; if (pValue !== undefined && pValue <= 0.05) { node.data.label = pValue < 0.001 ? "***" : pValue < 0.01 ? "**" : "*"; } } }); } /** * Create a color scale for numeric values */ function createRateColorScale(domain) { // Simple implementation - could use d3.scaleSequential for more sophisticated scaling return (value) => { const normalized = (value - domain[0]) / (domain[1] - domain[0]); const intensity = Math.max(0, Math.min(1, normalized)); const red = Math.floor(255 * intensity); const blue = Math.floor(255 * (1 - intensity)); return `rgb(${red}, 0, ${blue})`; }; } /** * Get distribution table for branches */ export function getAbsrelDistributionTable(resultsJson, evThreshold) { const table = []; const attrs = getAbsrelAttributes(resultsJson); const branchAttributes = resultsJson["branch attributes"]?.[0] || {}; const tested = resultsJson.tested?.[0] || {}; // Calculate site evidence ratios per branch const siteER = {}; attrs.profileBranchSites.forEach(site => { if (site.ER >= evThreshold) { siteER[site.branch] = (siteER[site.branch] || 0) + 1; } }); Object.entries(branchAttributes).forEach(([branchName, info]) => { const isTested = tested[branchName] === "test"; const omegaDist = getAbsrelTestOmega(resultsJson, branchName); const row = { branch: branchName, tested: isTested ? "Yes" : "No", 'p-value': isTested ? info["Corrected P-value"] : null, sites: isTested ? (siteER[branchName] || 0) : null, rates: info["Rate classes"] || 0, dist: ["&omega;", omegaDist, ""], plot: ["", omegaDist] }; table.push(row); }); return table; } /** * Generate site table data for detailed analysis */ export function getAbsrelSiteTableData(resultsJson, evThreshold, profileBranchSites) { const attrs = getAbsrelAttributes(resultsJson); const siteIndexPartitionCodon = getAbsrelSiteIndexPartitionCodon(resultsJson); if (!profileBranchSites) { profileBranchSites = attrs.profileBranchSites; } const bySite = profileBranchSites.reduce((acc, site) => { if (!acc[site.site]) acc[site.site] = []; acc[site.site].push(site); return acc; }, {}); const siteInfo = []; let index = 0; Object.entries(resultsJson["data partitions"] || {}).forEach(([partition, pinfo]) => { pinfo.coverage[0].forEach((_, i) => { const codon = siteIndexPartitionCodon[index][1]; const siteRecord = { 'Codon': codon, }; // Add site log likelihood if available const sll = resultsJson["Site Log Likelihood"]?.['unconstrained']?.[0]?.[index]; if (sll !== undefined) { siteRecord['LogL'] = sll; } // Add synonymous rate variation if available if (attrs.srvDistribution && resultsJson["Synonymous site-posteriors"]) { const sitePosteriorsAtIndex = attrs.srvDistribution.map((d, distIndex) => ({ value: d.value, weight: resultsJson["Synonymous site-posteriors"][distIndex]?.[index] || 0 })); siteRecord['SRV posterior mean'] = sitePosteriorsAtIndex.reduce((sum, d) => sum + (d.value * d.weight), 0); } // Add substitutions and evidence ratios const sitesAtPosition = bySite[i + 1] || []; siteRecord["Subs"] = sitesAtPosition.reduce((sum, d) => sum + d.subs, 0); siteRecord["ER"] = sitesAtPosition.filter(d => d.ER >= evThreshold).length; siteInfo.push(siteRecord); index++; }); }); const headers = { 'Codon': 'Site', 'SRV posterior mean': 'E[α]', 'LogL': 'log(L)', 'Subs': 'Subs', 'ER': `ER Branch (≥${evThreshold})` }; return [siteInfo, headers]; }