Below is a simulated tandem-MS experiment. An unknown peptide was fragmented; the observed spectrum shows the resulting peaks (mass values in Daltons). Four candidate sequences are listed. For each candidate the demo computes the theoretical b- and y-ions and counts how many match the observed peaks within a tolerance window.
<p class="hint">A peptide was fragmented in a mass spectrometer. The blue peaks are the <b>observed spectrum</b>. Click <b>Show ions</b> on each candidate to see its theoretical b-ions (green) and y-ions (red) overlaid. Solid lines match an observed peak; dashed lines do not. The candidate with the most matches wins.</p>
<div class="spectrum-wrap">
<div class="spectrum-label">Observed spectrum</div>
<canvas id="spectrum" width="560" height="120"></canvas>
</div>
<div class="candidates" id="candidates"></div>
<div class="status" id="status"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.spectrum-wrap { margin-bottom: .5rem; }
.spectrum-label { font-size: .78rem; color: #666; margin-bottom: .2rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
canvas { display: block; width: 100%; border: 1px solid #cdd9e3; border-radius: 6px; background: #f8fafc; }
.candidates { display: flex; flex-direction: column; gap: .45rem; }
.cand-row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; padding: .4rem .6rem; background: #f1f5f9; border-radius: 8px; border: 1.5px solid transparent; transition: border-color .15s; }
.cand-row.selected { border-color: #1d3557; background: #e8eef3; }
.cand-seq { font: 700 15px ui-monospace, monospace; color: #1d3557; min-width: 90px; }
.cand-score { font-size: .82rem; color: #555; flex: 1; }
.cand-score b { color: #0a7d33; }
button.show-btn { font: 600 13px system-ui, sans-serif; padding: .3rem .7rem; border: 1px solid #1d3557; background: #1d3557; color: #fff; border-radius: 6px; cursor: pointer; white-space: nowrap; }
button.show-btn.active { background: #c92f3c; border-color: #c92f3c; }
.status { font-size: .95rem; font-weight: 600; margin-top: .5rem; min-height: 1.3em; color: #0a7d33; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Mass-spec peptide identification: match observed fragment-ion peaks to the theoretical
// Monoisotopic residue masses (Da) for the 20 standard amino acids.
const AA_MASS = {
G:57.02, A:71.04, V:99.07, L:113.08, I:113.08,
P:97.05, F:147.07, W:186.08, M:131.04, S:87.03,
T:101.05, C:103.01, Y:163.06, H:137.06, D:115.03,
E:129.04, N:114.04, Q:128.06, K:128.09, R:156.10
};
const H2O = 18.011;
const PROTON = 1.00728;
const TOL = 0.5; // Da matching tolerance window (instrument resolution)
// Simulated observed peaks — the exact theoretical b-ions and y-ions of AGVLM (the correct answer).
const OBS = [
72.05, 129.07, 228.14, 341.22,
150.06, 263.14, 362.21, 419.23
];
// Four candidate peptide sequences to score against the observed spectrum.
const CANDIDATES = [
{ seq: 'AGVLM', label: 'AGVLM' },
{ seq: 'AGVMS', label: 'AGVMS' },
{ seq: 'AGVLP', label: 'AGVLP' },
{ seq: 'MGVLA', label: 'MGVLA' }
];
// Compute theoretical b-ions and y-ions for a sequence.
function theoreticalIons(seq) {
const n = seq.length;
const ions = [];
let bMass = PROTON;
for (let i = 0; i < n - 1; i++) {
bMass += AA_MASS[seq[i]];
ions.push({ mz: parseFloat(bMass.toFixed(2)), type: 'b', pos: i + 1 });
}
let yMass = H2O + PROTON;
for (let i = n - 1; i > 0; i--) {
yMass += AA_MASS[seq[i]];
ions.push({ mz: parseFloat(yMass.toFixed(2)), type: 'y', pos: n - i });
}
return ions;
}
// Count how many theoretical ions fall within TOL Da of an observed peak.
function score(ions) {
return ions.filter(ion => OBS.some(o => Math.abs(o - ion.mz) <= TOL)).length;
}
const scored = CANDIDATES.map(c => {
const ions = theoreticalIons(c.seq);
return { ...c, ions, sc: score(ions) };
}).sort((a, b) => b.sc - a.sc);
// Render the spectrum canvas: observed peaks in dark blue, overlaid ions color-coded.
function drawSpectrum(highlightIons) {
const canvas = document.getElementById('spectrum');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
const maxMz = 460, minMz = 50;
const pad = { l: 36, r: 10, t: 10, b: 22 };
const plotW = W - pad.l - pad.r;
const plotH = H - pad.t - pad.b;
// Draw axes and m/z tick labels.
ctx.strokeStyle = '#bbb'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(pad.l, pad.t); ctx.lineTo(pad.l, pad.t + plotH);
ctx.lineTo(pad.l + plotW, pad.t + plotH); ctx.stroke();
ctx.fillStyle = '#888'; ctx.font = '9px system-ui';
for (let mz = 100; mz <= maxMz; mz += 100) {
const x = pad.l + ((mz - minMz) / (maxMz - minMz)) * plotW;
ctx.beginPath(); ctx.moveTo(x, pad.t + plotH); ctx.lineTo(x, pad.t + plotH + 3); ctx.stroke();
ctx.textAlign = 'center'; ctx.fillText(mz, x, pad.t + plotH + 13);
}
ctx.textAlign = 'left'; ctx.fillText('m/z', pad.l + plotW - 14, pad.t + plotH + 13);
// Draw the observed peaks as solid dark-blue sticks.
OBS.forEach(mz => {
const x = pad.l + ((mz - minMz) / (maxMz - minMz)) * plotW;
ctx.strokeStyle = '#1d3557'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(x, pad.t + plotH); ctx.lineTo(x, pad.t + 12); ctx.stroke();
});
if (!highlightIons) return;
// Draw theoretical ion positions; solid if matched, dashed if unmatched.
highlightIons.forEach(ion => {
const x = pad.l + ((ion.mz - minMz) / (maxMz - minMz)) * plotW;
const matched = OBS.some(o => Math.abs(o - ion.mz) <= TOL);
ctx.strokeStyle = ion.type === 'b'
? (matched ? '#0a7d33' : '#6abf7d')
: (matched ? '#c92f3c' : '#e08080');
ctx.lineWidth = matched ? 2.5 : 1.2;
ctx.setLineDash(matched ? [] : [3, 3]);
ctx.beginPath(); ctx.moveTo(x, pad.t + plotH - 2); ctx.lineTo(x, pad.t + 30); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = ctx.strokeStyle;
ctx.font = '8px ui-monospace';
ctx.textAlign = 'center';
ctx.fillText(ion.type + ion.pos, x, pad.t + 24);
});
}
// Build candidate rows and wire click handler to toggle ion overlay.
let activeIdx = -1;
const container = document.getElementById('candidates');
scored.forEach((c, i) => {
const row = document.createElement('div');
row.className = 'cand-row';
const total = (c.ions.length);
row.innerHTML =
'<span class="cand-seq">' + c.label + '</span>' +
'<span class="cand-score">Matched <b>' + c.sc + '</b> / ' + total + ' ions</span>' +
'<button class="show-btn" data-i="' + i + '">Show ions</button>';
container.appendChild(row);
});
container.addEventListener('click', e => {
const btn = e.target.closest('button[data-i]');
if (!btn) return;
const i = +btn.dataset.i;
const rows = container.querySelectorAll('.cand-row');
const btns = container.querySelectorAll('button[data-i]');
if (activeIdx === i) {
activeIdx = -1;
rows.forEach(r => r.classList.remove('selected'));
btns.forEach(b => { b.classList.remove('active'); b.textContent = 'Show ions'; });
drawSpectrum(null);
document.getElementById('status').textContent = '';
} else {
activeIdx = i;
rows.forEach((r, j) => r.classList.toggle('selected', j === i));
btns.forEach((b, j) => {
b.classList.toggle('active', j === i);
b.textContent = j === i ? 'Hide ions' : 'Show ions';
});
drawSpectrum(scored[i].ions);
const best = scored[0];
const msg = i === 0
? i18nFmt('%seq% is the best match with %sc% matched ions.', { seq: scored[i].label, sc: scored[i].sc })
: i18nFmt('%seq% matches %sc% ions — best is %best% with %bsc%.', { seq: scored[i].label, sc: scored[i].sc, best: best.label, bsc: best.sc });
document.getElementById('status').textContent = msg;
}
});
drawSpectrum(null);
<p class="hint">A peptide was fragmented in a mass spectrometer. The blue peaks are the <b>observed spectrum</b>. Click <b>Show ions</b> on each candidate to see its theoretical b-ions (green) and y-ions (red) overlaid. Solid lines match an observed peak; dashed lines do not. The candidate with the most matches wins.</p>
<div class="spectrum-wrap">
<div class="spectrum-label">Observed spectrum</div>
<canvas id="spectrum" width="560" height="120"></canvas>
</div>
<div class="candidates" id="candidates"></div>
<div class="status" id="status"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.spectrum-wrap { margin-bottom: .5rem; }
.spectrum-label { font-size: .78rem; color: #666; margin-bottom: .2rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
canvas { display: block; width: 100%; border: 1px solid #cdd9e3; border-radius: 6px; background: #f8fafc; }
.candidates { display: flex; flex-direction: column; gap: .45rem; }
.cand-row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; padding: .4rem .6rem; background: #f1f5f9; border-radius: 8px; border: 1.5px solid transparent; transition: border-color .15s; }
.cand-row.selected { border-color: #1d3557; background: #e8eef3; }
.cand-seq { font: 700 15px ui-monospace, monospace; color: #1d3557; min-width: 90px; }
.cand-score { font-size: .82rem; color: #555; flex: 1; }
.cand-score b { color: #0a7d33; }
button.show-btn { font: 600 13px system-ui, sans-serif; padding: .3rem .7rem; border: 1px solid #1d3557; background: #1d3557; color: #fff; border-radius: 6px; cursor: pointer; white-space: nowrap; }
button.show-btn.active { background: #c92f3c; border-color: #c92f3c; }
.status { font-size: .95rem; font-weight: 600; margin-top: .5rem; min-height: 1.3em; color: #0a7d33; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Mass-spec peptide identification: match observed fragment-ion peaks to the theoretical
// Monoisotopic residue masses (Da) for the 20 standard amino acids.
const AA_MASS = {
G:57.02, A:71.04, V:99.07, L:113.08, I:113.08,
P:97.05, F:147.07, W:186.08, M:131.04, S:87.03,
T:101.05, C:103.01, Y:163.06, H:137.06, D:115.03,
E:129.04, N:114.04, Q:128.06, K:128.09, R:156.10
};
const H2O = 18.011;
const PROTON = 1.00728;
const TOL = 0.5; // Da matching tolerance window (instrument resolution)
// Simulated observed peaks — the exact theoretical b-ions and y-ions of AGVLM (the correct answer).
const OBS = [
72.05, 129.07, 228.14, 341.22,
150.06, 263.14, 362.21, 419.23
];
// Four candidate peptide sequences to score against the observed spectrum.
const CANDIDATES = [
{ seq: 'AGVLM', label: 'AGVLM' },
{ seq: 'AGVMS', label: 'AGVMS' },
{ seq: 'AGVLP', label: 'AGVLP' },
{ seq: 'MGVLA', label: 'MGVLA' }
];
// Compute theoretical b-ions and y-ions for a sequence.
function theoreticalIons(seq) {
const n = seq.length;
const ions = [];
let bMass = PROTON;
for (let i = 0; i < n - 1; i++) {
bMass += AA_MASS[seq[i]];
ions.push({ mz: parseFloat(bMass.toFixed(2)), type: 'b', pos: i + 1 });
}
let yMass = H2O + PROTON;
for (let i = n - 1; i > 0; i--) {
yMass += AA_MASS[seq[i]];
ions.push({ mz: parseFloat(yMass.toFixed(2)), type: 'y', pos: n - i });
}
return ions;
}
// Count how many theoretical ions fall within TOL Da of an observed peak.
function score(ions) {
return ions.filter(ion => OBS.some(o => Math.abs(o - ion.mz) <= TOL)).length;
}
const scored = CANDIDATES.map(c => {
const ions = theoreticalIons(c.seq);
return { ...c, ions, sc: score(ions) };
}).sort((a, b) => b.sc - a.sc);
// Render the spectrum canvas: observed peaks in dark blue, overlaid ions color-coded.
function drawSpectrum(highlightIons) {
const canvas = document.getElementById('spectrum');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
const maxMz = 460, minMz = 50;
const pad = { l: 36, r: 10, t: 10, b: 22 };
const plotW = W - pad.l - pad.r;
const plotH = H - pad.t - pad.b;
// Draw axes and m/z tick labels.
ctx.strokeStyle = '#bbb'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(pad.l, pad.t); ctx.lineTo(pad.l, pad.t + plotH);
ctx.lineTo(pad.l + plotW, pad.t + plotH); ctx.stroke();
ctx.fillStyle = '#888'; ctx.font = '9px system-ui';
for (let mz = 100; mz <= maxMz; mz += 100) {
const x = pad.l + ((mz - minMz) / (maxMz - minMz)) * plotW;
ctx.beginPath(); ctx.moveTo(x, pad.t + plotH); ctx.lineTo(x, pad.t + plotH + 3); ctx.stroke();
ctx.textAlign = 'center'; ctx.fillText(mz, x, pad.t + plotH + 13);
}
ctx.textAlign = 'left'; ctx.fillText('m/z', pad.l + plotW - 14, pad.t + plotH + 13);
// Draw the observed peaks as solid dark-blue sticks.
OBS.forEach(mz => {
const x = pad.l + ((mz - minMz) / (maxMz - minMz)) * plotW;
ctx.strokeStyle = '#1d3557'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(x, pad.t + plotH); ctx.lineTo(x, pad.t + 12); ctx.stroke();
});
if (!highlightIons) return;
// Draw theoretical ion positions; solid if matched, dashed if unmatched.
highlightIons.forEach(ion => {
const x = pad.l + ((ion.mz - minMz) / (maxMz - minMz)) * plotW;
const matched = OBS.some(o => Math.abs(o - ion.mz) <= TOL);
ctx.strokeStyle = ion.type === 'b'
? (matched ? '#0a7d33' : '#6abf7d')
: (matched ? '#c92f3c' : '#e08080');
ctx.lineWidth = matched ? 2.5 : 1.2;
ctx.setLineDash(matched ? [] : [3, 3]);
ctx.beginPath(); ctx.moveTo(x, pad.t + plotH - 2); ctx.lineTo(x, pad.t + 30); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = ctx.strokeStyle;
ctx.font = '8px ui-monospace';
ctx.textAlign = 'center';
ctx.fillText(ion.type + ion.pos, x, pad.t + 24);
});
}
// Build candidate rows and wire click handler to toggle ion overlay.
let activeIdx = -1;
const container = document.getElementById('candidates');
scored.forEach((c, i) => {
const row = document.createElement('div');
row.className = 'cand-row';
const total = (c.ions.length);
row.innerHTML =
'<span class="cand-seq">' + c.label + '</span>' +
'<span class="cand-score">Matched <b>' + c.sc + '</b> / ' + total + ' ions</span>' +
'<button class="show-btn" data-i="' + i + '">Show ions</button>';
container.appendChild(row);
});
container.addEventListener('click', e => {
const btn = e.target.closest('button[data-i]');
if (!btn) return;
const i = +btn.dataset.i;
const rows = container.querySelectorAll('.cand-row');
const btns = container.querySelectorAll('button[data-i]');
if (activeIdx === i) {
activeIdx = -1;
rows.forEach(r => r.classList.remove('selected'));
btns.forEach(b => { b.classList.remove('active'); b.textContent = 'Show ions'; });
drawSpectrum(null);
document.getElementById('status').textContent = '';
} else {
activeIdx = i;
rows.forEach((r, j) => r.classList.toggle('selected', j === i));
btns.forEach((b, j) => {
b.classList.toggle('active', j === i);
b.textContent = j === i ? 'Hide ions' : 'Show ions';
});
drawSpectrum(scored[i].ions);
const best = scored[0];
const msg = i === 0
? i18nFmt('%seq% is the best match with %sc% matched ions.', { seq: scored[i].label, sc: scored[i].sc })
: i18nFmt('%seq% matches %sc% ions — best is %best% with %bsc%.', { seq: scored[i].label, sc: scored[i].sc, best: best.label, bsc: best.sc });
document.getElementById('status').textContent = msg;
}
});
drawSpectrum(null);
Comments
Loading comments...