Below are several short DNA sequences. Click the gaps (–) to insert or delete them, sliding the letters left and right. Your goal is to stack matching letters into the same column: the more columns where the letters agree, the higher the score.
<p class="hint">Four short DNA sequences. Click a cell to toggle a <b>gap</b> (–) and slide that row's letters. The score counts, for every column, how many pairs of rows agree. Stack matching letters into the same column to raise the sum-of-pairs score.</p>
<div id="msa" class="msa"></div>
<div class="score" id="score">Score: 0</div>
<div class="btns">
<button id="auto" type="button">Auto-align</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.msa { display: grid; gap: 4px; margin: .4rem 0; }
.row { display: grid; gap: 4px; }
.cell { width: 34px; height: 34px; display: flex; align-items: center; justify-content: center;
font: 700 16px ui-monospace, monospace; border-radius: 7px; cursor: pointer;
user-select: none; transition: background .1s; }
.cell.A { background: #d8f0d8; color: #155e15; }
.cell.C { background: #d8e6f5; color: #14457a; }
.cell.G { background: #f7e6cf; color: #8a5a12; }
.cell.T { background: #f3d8df; color: #9c2740; }
.cell.gap { background: #eceef0; color: #99a; border: 1px dashed #b8bdc4; }
.cell:hover { outline: 2px solid #1d3557; outline-offset: -2px; }
.cell.col-ok { box-shadow: inset 0 0 0 2px #0a7d33; }
.score { font-size: 1.05rem; font-weight: 700; margin: .55rem 0; }
.score.ok { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Multiple sequence alignment, the friendly version. Four raw sequences; the
// player inserts/deletes gaps ("–") per row to slide letters into columns.
// SCORE = sum-of-pairs: for each column, count the pairs of rows whose (non-gap)
// letters are equal. Scoring is a quick scan; finding the layout that MAXIMIZES this over all sequences at once is NP-hard (Wang & Jiang, 1994), so the "Auto-align" button runs a greedy heuristic, not an exact solver.
const RAW = ['ACGTGCA', 'AGTGCA', 'ACGTGA', 'ATGTGCA'];
const GAP = '-';
let rows = RAW.map(s => s.split('')); // each row = array of letters / gaps
function width(){ return Math.max(...rows.map(r => r.length)); }
function pad(){ const w = width(); rows = rows.map(r => { while(r.length < w) r.push(GAP); return r; }); }
// Sum-of-pairs score over the current (padded) alignment.
function score(){
pad(); const w = width(); let s = 0;
for(let c = 0; c < w; c++){
const col = rows.map(r => r[c]);
for(let i = 0; i < col.length; i++)
for(let j = i+1; j < col.length; j++)
if(col[i] !== GAP && col[i] === col[j]) s++;
}
return s;
}
// A column is "fully conserved" if every row shows the same real letter.
function colConserved(c){
const col = rows.map(r => r[c]);
return col.every(x => x !== GAP && x === col[0]);
}
function render(){
pad(); const w = width();
const host = document.getElementById('msa');
host.innerHTML = '';
const tmpl = 'repeat(' + w + ', 34px)';
const conserved = Array.from({length:w}, (_,c) => colConserved(c));
rows.forEach((row, ri) => {
const rowEl = document.createElement('div');
rowEl.className = 'row'; rowEl.style.gridTemplateColumns = tmpl;
row.forEach((ch, ci) => {
const d = document.createElement('div');
d.className = 'cell ' + (ch === GAP ? 'gap' : ch) + (conserved[ci] ? ' col-ok' : '');
d.textContent = ch === GAP ? '–' : ch;
d.addEventListener('click', () => { toggle(ri, ci); });
rowEl.appendChild(d);
});
host.appendChild(rowEl);
});
const s = score();
const max = maxScore();
const el = document.getElementById('score');
el.textContent = fmt('Score: %s% / %max% (max possible across columns)', { s: s, max: max });
el.className = 'score' + (s >= max ? ' ok' : '');
}
// Upper bound used only for display: if all rows agreed in every real column.
function maxScore(){
const k = rows.length, perCol = k*(k-1)/2;
// Longest sequence length bounds how many fully-matching columns can exist.
const minLen = Math.min(...RAW.map(s => s.length));
return perCol * minLen;
}
// Clicking a cell toggles a gap there: on a letter -> insert a gap (shift right); on a gap -> remove it (shift left). This is exactly how a user nudges an MSA.
function toggle(ri, ci){
const row = rows[ri];
if(row[ci] === GAP) row.splice(ci, 1); // delete gap, letters slide left
else row.splice(ci, 0, GAP); // insert gap, letters slide right
trim(); render();
}
// Drop any column that is all gaps (keeps the board tidy).
function trim(){
pad(); const w = width();
for(let c = w-1; c >= 0; c--)
if(rows.every(r => r[c] === GAP)) rows.forEach(r => r.splice(c, 1));
pad();
}
// Greedy heuristic: repeatedly try every single-gap insertion in every row and
// keep the one that improves the score most; stop when nothing helps. This is a
// local search — fast, no optimality guarantee — the spirit of progressive MSA.
function autoAlign(){
rows = RAW.map(s => s.split('')); // start fresh
let improved = true, guard = 0;
while(improved && guard++ < 200){
improved = false;
let best = score(), bestMove = null;
const w = width();
for(let ri = 0; ri < rows.length; ri++){
for(let ci = 0; ci <= rows[ri].length; ci++){
rows[ri].splice(ci, 0, GAP); // try inserting a gap
const sc = score();
rows[ri].splice(ci, 1); // undo
if(sc > best){ best = sc; bestMove = [ri, ci]; }
}
}
if(bestMove){ rows[bestMove[0]].splice(bestMove[1], 0, GAP); trim(); improved = true; }
}
trim(); // drop any leftover all-gap columns before showing the result
render();
console.log('Auto-align (greedy) reached score', score(), 'of', maxScore());
}
document.getElementById('auto').addEventListener('click', autoAlign);
document.getElementById('reset').addEventListener('click', () => { rows = RAW.map(s => s.split('')); render(); });
render();
<p class="hint">Four short DNA sequences. Click a cell to toggle a <b>gap</b> (–) and slide that row's letters. The score counts, for every column, how many pairs of rows agree. Stack matching letters into the same column to raise the sum-of-pairs score.</p>
<div id="msa" class="msa"></div>
<div class="score" id="score">Score: 0</div>
<div class="btns">
<button id="auto" type="button">Auto-align</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.msa { display: grid; gap: 4px; margin: .4rem 0; }
.row { display: grid; gap: 4px; }
.cell { width: 34px; height: 34px; display: flex; align-items: center; justify-content: center;
font: 700 16px ui-monospace, monospace; border-radius: 7px; cursor: pointer;
user-select: none; transition: background .1s; }
.cell.A { background: #d8f0d8; color: #155e15; }
.cell.C { background: #d8e6f5; color: #14457a; }
.cell.G { background: #f7e6cf; color: #8a5a12; }
.cell.T { background: #f3d8df; color: #9c2740; }
.cell.gap { background: #eceef0; color: #99a; border: 1px dashed #b8bdc4; }
.cell:hover { outline: 2px solid #1d3557; outline-offset: -2px; }
.cell.col-ok { box-shadow: inset 0 0 0 2px #0a7d33; }
.score { font-size: 1.05rem; font-weight: 700; margin: .55rem 0; }
.score.ok { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Multiple sequence alignment, the friendly version. Four raw sequences; the
// player inserts/deletes gaps ("–") per row to slide letters into columns.
// SCORE = sum-of-pairs: for each column, count the pairs of rows whose (non-gap)
// letters are equal. Scoring is a quick scan; finding the layout that MAXIMIZES this over all sequences at once is NP-hard (Wang & Jiang, 1994), so the "Auto-align" button runs a greedy heuristic, not an exact solver.
const RAW = ['ACGTGCA', 'AGTGCA', 'ACGTGA', 'ATGTGCA'];
const GAP = '-';
let rows = RAW.map(s => s.split('')); // each row = array of letters / gaps
function width(){ return Math.max(...rows.map(r => r.length)); }
function pad(){ const w = width(); rows = rows.map(r => { while(r.length < w) r.push(GAP); return r; }); }
// Sum-of-pairs score over the current (padded) alignment.
function score(){
pad(); const w = width(); let s = 0;
for(let c = 0; c < w; c++){
const col = rows.map(r => r[c]);
for(let i = 0; i < col.length; i++)
for(let j = i+1; j < col.length; j++)
if(col[i] !== GAP && col[i] === col[j]) s++;
}
return s;
}
// A column is "fully conserved" if every row shows the same real letter.
function colConserved(c){
const col = rows.map(r => r[c]);
return col.every(x => x !== GAP && x === col[0]);
}
function render(){
pad(); const w = width();
const host = document.getElementById('msa');
host.innerHTML = '';
const tmpl = 'repeat(' + w + ', 34px)';
const conserved = Array.from({length:w}, (_,c) => colConserved(c));
rows.forEach((row, ri) => {
const rowEl = document.createElement('div');
rowEl.className = 'row'; rowEl.style.gridTemplateColumns = tmpl;
row.forEach((ch, ci) => {
const d = document.createElement('div');
d.className = 'cell ' + (ch === GAP ? 'gap' : ch) + (conserved[ci] ? ' col-ok' : '');
d.textContent = ch === GAP ? '–' : ch;
d.addEventListener('click', () => { toggle(ri, ci); });
rowEl.appendChild(d);
});
host.appendChild(rowEl);
});
const s = score();
const max = maxScore();
const el = document.getElementById('score');
el.textContent = fmt('Score: %s% / %max% (max possible across columns)', { s: s, max: max });
el.className = 'score' + (s >= max ? ' ok' : '');
}
// Upper bound used only for display: if all rows agreed in every real column.
function maxScore(){
const k = rows.length, perCol = k*(k-1)/2;
// Longest sequence length bounds how many fully-matching columns can exist.
const minLen = Math.min(...RAW.map(s => s.length));
return perCol * minLen;
}
// Clicking a cell toggles a gap there: on a letter -> insert a gap (shift right); on a gap -> remove it (shift left). This is exactly how a user nudges an MSA.
function toggle(ri, ci){
const row = rows[ri];
if(row[ci] === GAP) row.splice(ci, 1); // delete gap, letters slide left
else row.splice(ci, 0, GAP); // insert gap, letters slide right
trim(); render();
}
// Drop any column that is all gaps (keeps the board tidy).
function trim(){
pad(); const w = width();
for(let c = w-1; c >= 0; c--)
if(rows.every(r => r[c] === GAP)) rows.forEach(r => r.splice(c, 1));
pad();
}
// Greedy heuristic: repeatedly try every single-gap insertion in every row and
// keep the one that improves the score most; stop when nothing helps. This is a
// local search — fast, no optimality guarantee — the spirit of progressive MSA.
function autoAlign(){
rows = RAW.map(s => s.split('')); // start fresh
let improved = true, guard = 0;
while(improved && guard++ < 200){
improved = false;
let best = score(), bestMove = null;
const w = width();
for(let ri = 0; ri < rows.length; ri++){
for(let ci = 0; ci <= rows[ri].length; ci++){
rows[ri].splice(ci, 0, GAP); // try inserting a gap
const sc = score();
rows[ri].splice(ci, 1); // undo
if(sc > best){ best = sc; bestMove = [ri, ci]; }
}
}
if(bestMove){ rows[bestMove[0]].splice(bestMove[1], 0, GAP); trim(); improved = true; }
}
trim(); // drop any leftover all-gap columns before showing the result
render();
console.log('Auto-align (greedy) reached score', score(), 'of', maxScore());
}
document.getElementById('auto').addEventListener('click', autoAlign);
document.getElementById('reset').addEventListener('click', () => { rows = RAW.map(s => s.split('')); render(); });
render();
Notice the tension. Scoring a finished alignment is instant — walk down each column and count agreements. Finding the arrangement that maximizes the score across all sequences at once is the hard part: every sequence can shift independently, and the choices interact. Press Auto-align to let the demo run a greedy search, then nudge the gaps yourself to see if you can beat it.
Comments
Loading comments...