Below, an 80-letter query is searched against eight random sequences. Three of them (marked ★) secretly contain a copy of the query with about 8%, 20% and 30% of the letters mutated, plus the occasional insertion or deletion.
<p class="hint">A random 80-letter DNA query is searched against 8 random sequences of 300 letters. Three of them secretly contain a mutated copy of the query (★). Change the word size and watch speed and sensitivity trade off.</p>
<div class="ctl">
<label for="w">Word size w: <b id="wv">8</b></label>
<input id="w" type="range" min="4" max="12" value="8" aria-label="Word size w">
</div>
<div class="btns">
<button id="p11" type="button">Fast (w=11)</button>
<button id="p8" type="button">Balanced (w=8)</button>
<button id="p5" type="button">Sensitive (w=5)</button>
<button id="newdata" type="button" class="ghost">New random data</button>
</div>
<div class="cap" id="cap"></div>
<canvas id="plot" width="600" height="160" aria-label="Dot plot of seed hits and extended alignments between the query and the selected sequence"></canvas>
<div class="legend"><span class="k s"></span>seed hit <span class="k h"></span>extended HSP</div>
<div id="sum" class="sum"></div>
<table id="tbl">
<thead><tr><th>Seq</th><th>Seeds</th><th>HSP score</th><th>E-value</th><th>SW score</th></tr></thead>
<tbody></tbody>
</table>
<p class="note">★ marks a sequence that really contains a mutated copy of the query. E = K·m·n·e^(−λS) with K = 0.46 and λ computed for these scores. The SW column is the exact optimum, computed for comparison.</p>
:root { --fg:#222; --mut:#555; --bg:#fff; --card:#f1f4f8; --line:#d3dae3; --pri:#1d3557; --seed:#9aa7b6; --hsp:#e63946; --ok:#0a7d33; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e6e9ee; --mut:#a9b3c0; --bg:#14181d; --card:#1e252d; --line:#38424e; --pri:#7fa8d9; --seed:#6d7b8c; --hsp:#ff6b78; --ok:#4cc27a; }
}
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0; }
.hint { font-size: .88rem; color: var(--mut); margin: 0 0 .6rem; line-height: 1.45; }
.ctl { display: flex; flex-direction: column; gap: .2rem; margin-bottom: .5rem; font-size: .9rem; }
.ctl input { width: 100%; }
.btns { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .7rem; border: 1px solid var(--pri); background: var(--pri); color: var(--bg); border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--pri); }
.cap { font-size: .8rem; color: var(--mut); margin: .2rem 0; }
canvas { width: 100%; height: auto; max-width: 600px; display: block; background: var(--card); border: 1px solid var(--line); border-radius: 6px; }
.legend { font-size: .78rem; color: var(--mut); margin: .3rem 0; }
.k { display: inline-block; width: 10px; height: 10px; margin: 0 4px 0 8px; border-radius: 2px; }
.k.s { background: var(--seed); }
.k.h { background: var(--hsp); }
.sum { font-size: .85rem; line-height: 1.5; background: var(--card); border-radius: 8px; padding: .5rem .7rem; margin: .4rem 0; }
.sum b { color: var(--pri); }
table { border-collapse: collapse; width: 100%; font-size: .78rem; }
th, td { padding: .25rem .3rem; text-align: right; border-bottom: 1px solid var(--line); }
th:first-child, td:first-child { text-align: left; }
tbody tr { cursor: pointer; }
tbody tr.sel { background: var(--card); font-weight: 600; }
td.ok { color: var(--ok); }
.note { font-size: .78rem; color: var(--mut); line-height: 1.4; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Scoring and sizes: +1 match, -2 mismatch, X-drop 10
const MATCH = 1, MISMATCH = -2, GAP = -4, XDROP = 10;
const QLEN = 80, SLEN = 300, NSUB = 8, KA_K = 0.46, E_CUT = 0.001;
const IDENT = [0.92, 0.80, 0.70];
// Small seeded random generator (reproducible data)
function rng(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6D2B79F5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Karlin-Altschul lambda: the positive root of sum p_i p_j exp(lambda s_ij) = 1
function karlinLambda() {
let lo = 0.1, hi = 5;
for (let it = 0; it < 80; it++) {
const mid = (lo + hi) / 2;
const f = 0.25 * Math.exp(MATCH * mid) + 0.75 * Math.exp(MISMATCH * mid) - 1;
if (f > 0) hi = mid; else lo = mid;
}
return (lo + hi) / 2;
}
const LAMBDA = karlinLambda();
// Random database with three planted, mutated copies of the query
function makeData(seed) {
const r = rng(seed), A = 'ACGT';
const rnd = n => { let s = ''; for (let i = 0; i < n; i++) s += A[Math.floor(r() * 4)]; return s; };
const q = rnd(QLEN);
const subs = [];
for (let i = 0; i < NSUB; i++) subs.push(rnd(SLEN));
const order = [...Array(NSUB).keys()];
for (let i = NSUB - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [order[i], order[j]] = [order[j], order[i]]; }
const planted = [];
IDENT.forEach((id, n) => {
let h = '';
for (let i = 0; i < q.length; i++) {
const x = r();
if (x < 0.008) continue;
if (x < 0.016) h += A[Math.floor(r() * 4)];
if (r() < id) h += q[i]; else h += A[(A.indexOf(q[i]) + 1 + Math.floor(r() * 3)) % 4];
}
const si = order[n], s = subs[si], pos = 20 + Math.floor(r() * (SLEN - h.length - 40));
subs[si] = s.slice(0, pos) + h + s.slice(pos + h.length);
planted.push({ si, ident: id });
});
planted.sort((a, b) => a.si - b.si);
return { q, subs, planted };
}
// BLAST-style search: seed words, then ungapped X-drop extension
function blast(q, subs, w) {
const idx = new Map();
for (let i = 0; i + w <= q.length; i++) {
const k = q.substr(i, w);
if (!idx.has(k)) idx.set(k, []);
idx.get(k).push(i);
}
const N = subs.reduce((a, s) => a + s.length, 0);
let seeds = 0, ext = 0, cells = 0;
const per = subs.map(s => {
const lastEnd = new Map(), hits = [], hsps = [];
for (let j = 0; j + w <= s.length; j++) {
const list = idx.get(s.substr(j, w));
if (!list) continue;
for (const i of list) {
seeds++; hits.push([i, j]);
const d = j - i;
if ((lastEnd.get(d) ?? -1) > j) continue;
ext++; cells += w;
let run = 0, gr = 0, lr = 0;
for (let a = i + w, b = j + w, n = 1; a < q.length && b < s.length; a++, b++, n++) {
run += q[a] === s[b] ? MATCH : MISMATCH; cells++;
if (run > gr) { gr = run; lr = n; }
if (gr - run > XDROP) break;
}
run = 0; let gl = 0, ll = 0;
for (let a = i - 1, b = j - 1, n = 1; a >= 0 && b >= 0; a--, b--, n++) {
run += q[a] === s[b] ? MATCH : MISMATCH; cells++;
if (run > gl) { gl = run; ll = n; }
if (gl - run > XDROP) break;
}
lastEnd.set(d, j + w + lr);
hsps.push({ qs: i - ll, ss: j - ll, len: w + lr + ll, score: w * MATCH + gr + gl });
}
}
hsps.sort((x, y) => y.score - x.score);
const best = hsps[0] || null;
const E = best ? KA_K * q.length * N * Math.exp(-LAMBDA * best.score) : Infinity;
return { hits, hsps, best, E, seeds: hits.length };
});
return { per, seeds, ext, cells, N };
}
// Smith-Waterman baseline: every cell of the table is filled
function swScore(q, s) {
let prev = new Int32Array(s.length + 1), cur = new Int32Array(s.length + 1), best = 0;
for (let i = 1; i <= q.length; i++) {
for (let j = 1; j <= s.length; j++) {
const d = prev[j - 1] + (q[i - 1] === s[j - 1] ? MATCH : MISMATCH);
const v = Math.max(0, d, prev[j] + GAP, cur[j - 1] + GAP);
cur[j] = v; if (v > best) best = v;
}
[prev, cur] = [cur, prev];
}
return best;
}
// Interface (DOM)
const $ = id => document.getElementById(id);
let seed = 7, w = 8, sel = 0, data, res, sw;
const fmtE = e => !isFinite(e) ? '—' : e < 0.01 ? e.toExponential(1) : e.toFixed(2);
const css = n => getComputedStyle(document.documentElement).getPropertyValue(n);
function draw() {
const c = $('plot'), g = c.getContext('2d'), sx = c.width / SLEN, sy = c.height / QLEN;
g.clearRect(0, 0, c.width, c.height);
g.fillStyle = css('--seed');
res.per[sel].hits.forEach(([i, j]) => g.fillRect(j * sx, i * sy, Math.max(2, sx), Math.max(2, sy)));
g.strokeStyle = css('--hsp'); g.lineWidth = 3;
res.per[sel].hsps.filter(h => h.score >= 12).forEach(h => {
g.beginPath(); g.moveTo(h.ss * sx, h.qs * sy); g.lineTo((h.ss + h.len) * sx, (h.qs + h.len) * sy); g.stroke();
});
$('cap').textContent = i18nFmt("Dot plot: sequence %n% (x) against the query (y), word size %w%. Click a row to change sequence.", { n: sel + 1, w });
}
function run() {
$('wv').textContent = w;
res = blast(data.q, data.subs, w);
sw = data.subs.map(s => swScore(data.q, s));
const full = QLEN * res.N;
const isP = i => data.planted.some(p => p.si === i);
const found = data.planted.filter(p => res.per[p.si].E <= E_CUT).length;
const fa = res.per.filter((p, i) => !isP(i) && p.E <= E_CUT).length;
$('sum').innerHTML =
i18nFmt("Seed hits: <b>%s%</b> · extensions started: <b>%e%</b>", { s: res.seeds, e: res.ext }) + '<br>' +
i18nFmt("Cells computed — BLAST: <b>%b%</b> · full Smith–Waterman: <b>%f%</b> (%x%× fewer)", { b: res.cells.toLocaleString(), f: full.toLocaleString(), x: (full / Math.max(1, res.cells)).toFixed(1) }) + '<br>' +
i18nFmt("Planted homologs found (E ≤ 0.001): <b>%f%</b> of %t% · false alarms: <b>%fa%</b>", { f: found, t: data.planted.length, fa });
const tb = document.querySelector('#tbl tbody'); tb.innerHTML = '';
res.per.forEach((p, i) => {
const tr = document.createElement('tr');
if (i === sel) tr.className = 'sel';
const hit = p.E <= E_CUT;
tr.innerHTML = '<td>' + (i + 1) + (isP(i) ? ' ★' : '') + '</td><td>' + p.seeds + '</td><td>' + (p.best ? p.best.score : '—') +
'</td><td class="' + (hit ? 'ok' : '') + '">' + fmtE(p.E) + '</td><td>' + sw[i] + '</td>';
tr.addEventListener('click', () => { sel = i; run(); });
tb.appendChild(tr);
});
draw();
}
function fresh() { data = makeData(seed); sel = data.planted[0].si; run(); }
$('w').addEventListener('input', e => { w = +e.target.value; run(); });
const preset = v => () => { w = v; $('w').value = v; run(); };
$('p11').addEventListener('click', preset(11));
$('p8').addEventListener('click', preset(8));
$('p5').addEventListener('click', preset(5));
$('newdata').addEventListener('click', () => { seed = seed * 7 + 3; fresh(); });
fresh();
<p class="hint">A random 80-letter DNA query is searched against 8 random sequences of 300 letters. Three of them secretly contain a mutated copy of the query (★). Change the word size and watch speed and sensitivity trade off.</p>
<div class="ctl">
<label for="w">Word size w: <b id="wv">8</b></label>
<input id="w" type="range" min="4" max="12" value="8" aria-label="Word size w">
</div>
<div class="btns">
<button id="p11" type="button">Fast (w=11)</button>
<button id="p8" type="button">Balanced (w=8)</button>
<button id="p5" type="button">Sensitive (w=5)</button>
<button id="newdata" type="button" class="ghost">New random data</button>
</div>
<div class="cap" id="cap"></div>
<canvas id="plot" width="600" height="160" aria-label="Dot plot of seed hits and extended alignments between the query and the selected sequence"></canvas>
<div class="legend"><span class="k s"></span>seed hit <span class="k h"></span>extended HSP</div>
<div id="sum" class="sum"></div>
<table id="tbl">
<thead><tr><th>Seq</th><th>Seeds</th><th>HSP score</th><th>E-value</th><th>SW score</th></tr></thead>
<tbody></tbody>
</table>
<p class="note">★ marks a sequence that really contains a mutated copy of the query. E = K·m·n·e^(−λS) with K = 0.46 and λ computed for these scores. The SW column is the exact optimum, computed for comparison.</p>
:root { --fg:#222; --mut:#555; --bg:#fff; --card:#f1f4f8; --line:#d3dae3; --pri:#1d3557; --seed:#9aa7b6; --hsp:#e63946; --ok:#0a7d33; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e6e9ee; --mut:#a9b3c0; --bg:#14181d; --card:#1e252d; --line:#38424e; --pri:#7fa8d9; --seed:#6d7b8c; --hsp:#ff6b78; --ok:#4cc27a; }
}
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0; }
.hint { font-size: .88rem; color: var(--mut); margin: 0 0 .6rem; line-height: 1.45; }
.ctl { display: flex; flex-direction: column; gap: .2rem; margin-bottom: .5rem; font-size: .9rem; }
.ctl input { width: 100%; }
.btns { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .7rem; border: 1px solid var(--pri); background: var(--pri); color: var(--bg); border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--pri); }
.cap { font-size: .8rem; color: var(--mut); margin: .2rem 0; }
canvas { width: 100%; height: auto; max-width: 600px; display: block; background: var(--card); border: 1px solid var(--line); border-radius: 6px; }
.legend { font-size: .78rem; color: var(--mut); margin: .3rem 0; }
.k { display: inline-block; width: 10px; height: 10px; margin: 0 4px 0 8px; border-radius: 2px; }
.k.s { background: var(--seed); }
.k.h { background: var(--hsp); }
.sum { font-size: .85rem; line-height: 1.5; background: var(--card); border-radius: 8px; padding: .5rem .7rem; margin: .4rem 0; }
.sum b { color: var(--pri); }
table { border-collapse: collapse; width: 100%; font-size: .78rem; }
th, td { padding: .25rem .3rem; text-align: right; border-bottom: 1px solid var(--line); }
th:first-child, td:first-child { text-align: left; }
tbody tr { cursor: pointer; }
tbody tr.sel { background: var(--card); font-weight: 600; }
td.ok { color: var(--ok); }
.note { font-size: .78rem; color: var(--mut); line-height: 1.4; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Scoring and sizes: +1 match, -2 mismatch, X-drop 10
const MATCH = 1, MISMATCH = -2, GAP = -4, XDROP = 10;
const QLEN = 80, SLEN = 300, NSUB = 8, KA_K = 0.46, E_CUT = 0.001;
const IDENT = [0.92, 0.80, 0.70];
// Small seeded random generator (reproducible data)
function rng(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6D2B79F5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Karlin-Altschul lambda: the positive root of sum p_i p_j exp(lambda s_ij) = 1
function karlinLambda() {
let lo = 0.1, hi = 5;
for (let it = 0; it < 80; it++) {
const mid = (lo + hi) / 2;
const f = 0.25 * Math.exp(MATCH * mid) + 0.75 * Math.exp(MISMATCH * mid) - 1;
if (f > 0) hi = mid; else lo = mid;
}
return (lo + hi) / 2;
}
const LAMBDA = karlinLambda();
// Random database with three planted, mutated copies of the query
function makeData(seed) {
const r = rng(seed), A = 'ACGT';
const rnd = n => { let s = ''; for (let i = 0; i < n; i++) s += A[Math.floor(r() * 4)]; return s; };
const q = rnd(QLEN);
const subs = [];
for (let i = 0; i < NSUB; i++) subs.push(rnd(SLEN));
const order = [...Array(NSUB).keys()];
for (let i = NSUB - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [order[i], order[j]] = [order[j], order[i]]; }
const planted = [];
IDENT.forEach((id, n) => {
let h = '';
for (let i = 0; i < q.length; i++) {
const x = r();
if (x < 0.008) continue;
if (x < 0.016) h += A[Math.floor(r() * 4)];
if (r() < id) h += q[i]; else h += A[(A.indexOf(q[i]) + 1 + Math.floor(r() * 3)) % 4];
}
const si = order[n], s = subs[si], pos = 20 + Math.floor(r() * (SLEN - h.length - 40));
subs[si] = s.slice(0, pos) + h + s.slice(pos + h.length);
planted.push({ si, ident: id });
});
planted.sort((a, b) => a.si - b.si);
return { q, subs, planted };
}
// BLAST-style search: seed words, then ungapped X-drop extension
function blast(q, subs, w) {
const idx = new Map();
for (let i = 0; i + w <= q.length; i++) {
const k = q.substr(i, w);
if (!idx.has(k)) idx.set(k, []);
idx.get(k).push(i);
}
const N = subs.reduce((a, s) => a + s.length, 0);
let seeds = 0, ext = 0, cells = 0;
const per = subs.map(s => {
const lastEnd = new Map(), hits = [], hsps = [];
for (let j = 0; j + w <= s.length; j++) {
const list = idx.get(s.substr(j, w));
if (!list) continue;
for (const i of list) {
seeds++; hits.push([i, j]);
const d = j - i;
if ((lastEnd.get(d) ?? -1) > j) continue;
ext++; cells += w;
let run = 0, gr = 0, lr = 0;
for (let a = i + w, b = j + w, n = 1; a < q.length && b < s.length; a++, b++, n++) {
run += q[a] === s[b] ? MATCH : MISMATCH; cells++;
if (run > gr) { gr = run; lr = n; }
if (gr - run > XDROP) break;
}
run = 0; let gl = 0, ll = 0;
for (let a = i - 1, b = j - 1, n = 1; a >= 0 && b >= 0; a--, b--, n++) {
run += q[a] === s[b] ? MATCH : MISMATCH; cells++;
if (run > gl) { gl = run; ll = n; }
if (gl - run > XDROP) break;
}
lastEnd.set(d, j + w + lr);
hsps.push({ qs: i - ll, ss: j - ll, len: w + lr + ll, score: w * MATCH + gr + gl });
}
}
hsps.sort((x, y) => y.score - x.score);
const best = hsps[0] || null;
const E = best ? KA_K * q.length * N * Math.exp(-LAMBDA * best.score) : Infinity;
return { hits, hsps, best, E, seeds: hits.length };
});
return { per, seeds, ext, cells, N };
}
// Smith-Waterman baseline: every cell of the table is filled
function swScore(q, s) {
let prev = new Int32Array(s.length + 1), cur = new Int32Array(s.length + 1), best = 0;
for (let i = 1; i <= q.length; i++) {
for (let j = 1; j <= s.length; j++) {
const d = prev[j - 1] + (q[i - 1] === s[j - 1] ? MATCH : MISMATCH);
const v = Math.max(0, d, prev[j] + GAP, cur[j - 1] + GAP);
cur[j] = v; if (v > best) best = v;
}
[prev, cur] = [cur, prev];
}
return best;
}
// Interface (DOM)
const $ = id => document.getElementById(id);
let seed = 7, w = 8, sel = 0, data, res, sw;
const fmtE = e => !isFinite(e) ? '—' : e < 0.01 ? e.toExponential(1) : e.toFixed(2);
const css = n => getComputedStyle(document.documentElement).getPropertyValue(n);
function draw() {
const c = $('plot'), g = c.getContext('2d'), sx = c.width / SLEN, sy = c.height / QLEN;
g.clearRect(0, 0, c.width, c.height);
g.fillStyle = css('--seed');
res.per[sel].hits.forEach(([i, j]) => g.fillRect(j * sx, i * sy, Math.max(2, sx), Math.max(2, sy)));
g.strokeStyle = css('--hsp'); g.lineWidth = 3;
res.per[sel].hsps.filter(h => h.score >= 12).forEach(h => {
g.beginPath(); g.moveTo(h.ss * sx, h.qs * sy); g.lineTo((h.ss + h.len) * sx, (h.qs + h.len) * sy); g.stroke();
});
$('cap').textContent = i18nFmt("Dot plot: sequence %n% (x) against the query (y), word size %w%. Click a row to change sequence.", { n: sel + 1, w });
}
function run() {
$('wv').textContent = w;
res = blast(data.q, data.subs, w);
sw = data.subs.map(s => swScore(data.q, s));
const full = QLEN * res.N;
const isP = i => data.planted.some(p => p.si === i);
const found = data.planted.filter(p => res.per[p.si].E <= E_CUT).length;
const fa = res.per.filter((p, i) => !isP(i) && p.E <= E_CUT).length;
$('sum').innerHTML =
i18nFmt("Seed hits: <b>%s%</b> · extensions started: <b>%e%</b>", { s: res.seeds, e: res.ext }) + '<br>' +
i18nFmt("Cells computed — BLAST: <b>%b%</b> · full Smith–Waterman: <b>%f%</b> (%x%× fewer)", { b: res.cells.toLocaleString(), f: full.toLocaleString(), x: (full / Math.max(1, res.cells)).toFixed(1) }) + '<br>' +
i18nFmt("Planted homologs found (E ≤ 0.001): <b>%f%</b> of %t% · false alarms: <b>%fa%</b>", { f: found, t: data.planted.length, fa });
const tb = document.querySelector('#tbl tbody'); tb.innerHTML = '';
res.per.forEach((p, i) => {
const tr = document.createElement('tr');
if (i === sel) tr.className = 'sel';
const hit = p.E <= E_CUT;
tr.innerHTML = '<td>' + (i + 1) + (isP(i) ? ' ★' : '') + '</td><td>' + p.seeds + '</td><td>' + (p.best ? p.best.score : '—') +
'</td><td class="' + (hit ? 'ok' : '') + '">' + fmtE(p.E) + '</td><td>' + sw[i] + '</td>';
tr.addEventListener('click', () => { sel = i; run(); });
tb.appendChild(tr);
});
draw();
}
function fresh() { data = makeData(seed); sel = data.planted[0].si; run(); }
$('w').addEventListener('input', e => { w = +e.target.value; run(); });
const preset = v => () => { w = v; $('w').value = v; run(); };
$('p11').addEventListener('click', preset(11));
$('p8').addEventListener('click', preset(8));
$('p5').addEventListener('click', preset(5));
$('newdata').addEventListener('click', () => { seed = seed * 7 + 3; fresh(); });
fresh();
Comments
Loading comments...