hyphy-scope
Version:
Reusable Svelte components for HyPhy analysis visualization
658 lines (657 loc) • 26 kB
JavaScript
/**
* 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: ["ω", 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];
}