Spaces:
Running
Running
Cloning customizer + mobile-responsive DEE app
Browse files- dee/static/app.css +102 -0
- dee/static/app.js +254 -5
dee/static/app.css
CHANGED
|
@@ -1621,6 +1621,108 @@ input:focus, textarea:focus, select:focus {
|
|
| 1621 |
.copy-btn:hover { border-color: var(--brand); color: var(--brand-deep); }
|
| 1622 |
.copy-btn.copied { background: var(--brand-50); border-color: var(--brand); color: var(--brand-deep); }
|
| 1623 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1624 |
/* ====================================== SYNTH ROW (per-row vendor buttons) */
|
| 1625 |
|
| 1626 |
.synth-row {
|
|
|
|
| 1621 |
.copy-btn:hover { border-color: var(--brand); color: var(--brand-deep); }
|
| 1622 |
.copy-btn.copied { background: var(--brand-50); border-color: var(--brand); color: var(--brand-deep); }
|
| 1623 |
|
| 1624 |
+
/* =================================================== CLONING CUSTOMIZER */
|
| 1625 |
+
|
| 1626 |
+
.cloning-section { gap: 14px; }
|
| 1627 |
+
.cloning-hint {
|
| 1628 |
+
font-size: 12px;
|
| 1629 |
+
color: var(--ink-soft);
|
| 1630 |
+
line-height: 1.55;
|
| 1631 |
+
margin: 4px 0 8px;
|
| 1632 |
+
max-width: 64ch;
|
| 1633 |
+
}
|
| 1634 |
+
|
| 1635 |
+
.cloning-controls { display: flex; flex-wrap: wrap; gap: 12px; }
|
| 1636 |
+
.cloning-field { display: flex; flex-direction: column; gap: 5px; flex: 1; min-width: 240px; }
|
| 1637 |
+
.cloning-field-label {
|
| 1638 |
+
font-size: 11px;
|
| 1639 |
+
text-transform: uppercase;
|
| 1640 |
+
letter-spacing: 0.08em;
|
| 1641 |
+
color: var(--ink-faint);
|
| 1642 |
+
font-weight: 600;
|
| 1643 |
+
}
|
| 1644 |
+
.cloning-field select { width: 100%; }
|
| 1645 |
+
|
| 1646 |
+
.cloning-preview {
|
| 1647 |
+
margin-top: 4px;
|
| 1648 |
+
border: 1px solid var(--line);
|
| 1649 |
+
border-radius: var(--r-2);
|
| 1650 |
+
background: var(--gray-0);
|
| 1651 |
+
overflow: hidden;
|
| 1652 |
+
}
|
| 1653 |
+
.cloning-preview:empty { display: none; }
|
| 1654 |
+
|
| 1655 |
+
.cloning-meta {
|
| 1656 |
+
display: flex;
|
| 1657 |
+
flex-wrap: wrap;
|
| 1658 |
+
gap: 14px;
|
| 1659 |
+
align-items: center;
|
| 1660 |
+
padding: 10px 14px;
|
| 1661 |
+
background: var(--gray-1);
|
| 1662 |
+
border-bottom: 1px solid var(--line);
|
| 1663 |
+
font-size: 12px;
|
| 1664 |
+
color: var(--ink-soft);
|
| 1665 |
+
font-variant-numeric: tabular-nums;
|
| 1666 |
+
}
|
| 1667 |
+
.cloning-meta strong { color: var(--ink-strong); font-weight: 700; margin-right: 2px; }
|
| 1668 |
+
.cloning-legend {
|
| 1669 |
+
display: inline-flex;
|
| 1670 |
+
align-items: center;
|
| 1671 |
+
gap: 10px;
|
| 1672 |
+
margin-left: auto;
|
| 1673 |
+
font-size: 11px;
|
| 1674 |
+
color: var(--ink-faint);
|
| 1675 |
+
}
|
| 1676 |
+
.legend-swatch {
|
| 1677 |
+
display: inline-block;
|
| 1678 |
+
width: 10px;
|
| 1679 |
+
height: 10px;
|
| 1680 |
+
border-radius: 2px;
|
| 1681 |
+
margin-right: 3px;
|
| 1682 |
+
vertical-align: middle;
|
| 1683 |
+
}
|
| 1684 |
+
.legend-flank { background: #FEF3C7; border: 1px solid #FDE68A; }
|
| 1685 |
+
.legend-cds { background: var(--brand-50); border: 1px solid var(--brand-100); }
|
| 1686 |
+
|
| 1687 |
+
.cloning-preview-block {
|
| 1688 |
+
font-family: var(--font-mono);
|
| 1689 |
+
font-size: 12px;
|
| 1690 |
+
color: var(--ink-strong);
|
| 1691 |
+
background: var(--gray-0);
|
| 1692 |
+
padding: 14px 16px;
|
| 1693 |
+
line-height: 1.65;
|
| 1694 |
+
white-space: pre;
|
| 1695 |
+
overflow: auto;
|
| 1696 |
+
max-height: 240px;
|
| 1697 |
+
letter-spacing: 0.04em;
|
| 1698 |
+
}
|
| 1699 |
+
.cloning-preview-block .sec-flank {
|
| 1700 |
+
background: #FEF3C7;
|
| 1701 |
+
color: #92400E;
|
| 1702 |
+
border-radius: 1px;
|
| 1703 |
+
}
|
| 1704 |
+
.cloning-preview-block .sec-cds {
|
| 1705 |
+
background: var(--brand-50);
|
| 1706 |
+
color: var(--brand-deep);
|
| 1707 |
+
border-radius: 1px;
|
| 1708 |
+
}
|
| 1709 |
+
|
| 1710 |
+
.cloning-notes, .cloning-sublabel {
|
| 1711 |
+
padding: 0 14px 12px;
|
| 1712 |
+
font-size: 12px;
|
| 1713 |
+
color: var(--ink-soft);
|
| 1714 |
+
line-height: 1.6;
|
| 1715 |
+
}
|
| 1716 |
+
.cloning-notes strong { color: var(--ink-strong); font-weight: 700; }
|
| 1717 |
+
.cloning-sublabel { color: var(--ink-faint); }
|
| 1718 |
+
|
| 1719 |
+
@media (max-width: 959px) {
|
| 1720 |
+
.cloning-meta { padding: 9px 12px; font-size: 11px; gap: 10px; }
|
| 1721 |
+
.cloning-legend { width: 100%; margin-left: 0; }
|
| 1722 |
+
.cloning-preview-block { font-size: 11px; padding: 12px 12px; }
|
| 1723 |
+
.cloning-notes, .cloning-sublabel { padding: 0 12px 12px; font-size: 11px; }
|
| 1724 |
+
}
|
| 1725 |
+
|
| 1726 |
/* ====================================== SYNTH ROW (per-row vendor buttons) */
|
| 1727 |
|
| 1728 |
.synth-row {
|
dee/static/app.js
CHANGED
|
@@ -908,6 +908,7 @@ function toggleDetailRow(idx, headRow, detailRow, v) {
|
|
| 908 |
if (detailRow.dataset.populated !== '1') {
|
| 909 |
detailRow.querySelector('td').innerHTML = renderDetailPanel(v);
|
| 910 |
wireCopyButtons(detailRow);
|
|
|
|
| 911 |
detailRow.dataset.populated = '1';
|
| 912 |
}
|
| 913 |
detailRow.hidden = false;
|
|
@@ -940,6 +941,194 @@ const VENDORS = {
|
|
| 940 |
},
|
| 941 |
};
|
| 942 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 943 |
async function synthesizeDna(dnaSeq, variantId, vendorKey) {
|
| 944 |
// Twist gets the in-app gateway flow: live quote + order placement
|
| 945 |
// against http://127.0.0.1:8765. GenScript + IDT remain external
|
|
@@ -1976,11 +2165,38 @@ function renderDetailPanel(v) {
|
|
| 1976 |
<div class="detail-section">
|
| 1977 |
<h4>Optimized DNA sequence (${v.Length_bp} bp) · mutated codons highlighted</h4>
|
| 1978 |
<div class="sequence-block">${formatDna(v.Optimized_DNA_Seq, mutPositionList)}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1979 |
<div class="synth-row">
|
| 1980 |
<span class="synth-row-label">Synthesize via</span>
|
| 1981 |
${Object.entries(VENDORS).map(([key, vendor]) => `
|
| 1982 |
<button class="copy-btn synth-btn"
|
| 1983 |
-
data-synth-dna="${escapeHtml(v.Optimized_DNA_Seq)}"
|
| 1984 |
data-synth-id="${escapeHtml(v.Variant_ID)}"
|
| 1985 |
data-synth-vendor="${escapeHtml(key)}">
|
| 1986 |
<span>${escapeHtml(vendor.name)}</span>
|
|
@@ -1991,7 +2207,8 @@ function renderDetailPanel(v) {
|
|
| 1991 |
`).join('')}
|
| 1992 |
</div>
|
| 1993 |
<div class="primer-actions" style="margin-top:10px">
|
| 1994 |
-
<button class="copy-btn" data-copy="${escapeHtml(v.
|
|
|
|
| 1995 |
<button class="copy-btn" data-copy="${escapeHtml(v.Mutant_AA_Seq)}">Copy protein</button>
|
| 1996 |
</div>
|
| 1997 |
</div>
|
|
@@ -2082,15 +2299,47 @@ function wireCopyButtons(scope) {
|
|
| 2082 |
});
|
| 2083 |
});
|
| 2084 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2085 |
// Synthesize-via-X buttons — one per vendor, each carrying its vendor
|
| 2086 |
-
// key in data-synth-vendor.
|
| 2087 |
-
//
|
|
|
|
| 2088 |
scope.querySelectorAll('.synth-btn').forEach((el) => {
|
| 2089 |
el.addEventListener('click', (e) => {
|
| 2090 |
e.stopPropagation();
|
| 2091 |
-
const dna = el.dataset.synthDna;
|
| 2092 |
const variantId = el.dataset.synthId;
|
| 2093 |
const vendorKey = el.dataset.synthVendor;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2094 |
synthesizeDna(dna, variantId, vendorKey);
|
| 2095 |
const original = el.innerHTML;
|
| 2096 |
el.classList.add('copied');
|
|
|
|
| 908 |
if (detailRow.dataset.populated !== '1') {
|
| 909 |
detailRow.querySelector('td').innerHTML = renderDetailPanel(v);
|
| 910 |
wireCopyButtons(detailRow);
|
| 911 |
+
wireCloningControls(detailRow);
|
| 912 |
detailRow.dataset.populated = '1';
|
| 913 |
}
|
| 914 |
detailRow.hidden = false;
|
|
|
|
| 941 |
},
|
| 942 |
};
|
| 943 |
|
| 944 |
+
// =============================================================== CLONING
|
| 945 |
+
// Minimal "prepare for synthesis" toolkit. Wraps the bare CDS with the
|
| 946 |
+
// appropriate 5'/3' flanking for common cloning workflows. Pure
|
| 947 |
+
// client-side computation — no backend involvement, no server roundtrip
|
| 948 |
+
// per dropdown change. Add new presets here; each one is a pure function
|
| 949 |
+
// from bare CDS string → formatted DNA string, plus metadata.
|
| 950 |
+
//
|
| 951 |
+
// Section coloring in the preview:
|
| 952 |
+
// - five_prime → amber (added 5' flanking)
|
| 953 |
+
// - cds → ink (original codon-optimized CDS)
|
| 954 |
+
// - three_prime → amber (added 3' flanking)
|
| 955 |
+
// Mutations highlighting is preserved from the bare CDS view above.
|
| 956 |
+
|
| 957 |
+
const STOP_CODONS = new Set(['TAA', 'TAG', 'TGA']);
|
| 958 |
+
|
| 959 |
+
function stripLeadingAtg(cds) {
|
| 960 |
+
return cds.startsWith('ATG') ? cds.slice(3) : cds;
|
| 961 |
+
}
|
| 962 |
+
function stripTrailingStop(cds) {
|
| 963 |
+
return STOP_CODONS.has(cds.slice(-3).toUpperCase()) ? cds.slice(0, -3) : cds;
|
| 964 |
+
}
|
| 965 |
+
|
| 966 |
+
const CLONING_PRESETS = {
|
| 967 |
+
bare: {
|
| 968 |
+
name: 'Bare CDS · no flanking',
|
| 969 |
+
sublabel: 'Recommended when you order through a vendor cloning service (Twist Clonal Gene, IDT Genes, GenScript clone-into-vector). The vendor adds the overhangs internally — you just specify the destination vector at checkout.',
|
| 970 |
+
format(cds) {
|
| 971 |
+
return { five: '', cds, three: '' };
|
| 972 |
+
},
|
| 973 |
+
notes: '',
|
| 974 |
+
},
|
| 975 |
+
|
| 976 |
+
pet28a_ndei_xhoi: {
|
| 977 |
+
name: 'pET28a · NdeI + XhoI restriction sites',
|
| 978 |
+
sublabel: 'For traditional restriction cloning into pET28a. Digest the synthesized DNA with NdeI + XhoI, ligate into pET28a opened with the same enzymes. T7 promoter, IPTG induction, N-terminal His6 + thrombin site provided by the vector.',
|
| 979 |
+
format(cds) {
|
| 980 |
+
// NdeI = CATATG (the embedded ATG IS the start codon — strip the CDS's own).
|
| 981 |
+
// XhoI = CTCGAG, in pET28a is followed by C-term His6 + stop, so we strip
|
| 982 |
+
// the CDS's stop codon (the vector provides it).
|
| 983 |
+
const inner = stripTrailingStop(stripLeadingAtg(cds));
|
| 984 |
+
return {
|
| 985 |
+
five: 'AAAA' + 'CATATG',
|
| 986 |
+
cds: inner,
|
| 987 |
+
three: 'CTCGAG' + 'AAAA',
|
| 988 |
+
};
|
| 989 |
+
},
|
| 990 |
+
notes: 'Strips the CDS\'s own ATG (NdeI provides the start codon) and stop codon (pET28a\'s C-term His6 + stop follow XhoI). 4 nt AAAA buffers ensure efficient enzyme cleavage at the ends.',
|
| 991 |
+
},
|
| 992 |
+
|
| 993 |
+
gibson_pet28a: {
|
| 994 |
+
name: 'Gibson · pET28a (NdeI / XhoI linearized)',
|
| 995 |
+
sublabel: 'For Gibson Assembly Master Mix into pET28a linearized by sequential NdeI + XhoI digest. 20+ bp homology arms; no restriction digest of your insert required.',
|
| 996 |
+
format(cds) {
|
| 997 |
+
// Real pET28a flanking sequences. Upstream of NdeI: T7 RBS through ATG.
|
| 998 |
+
// Downstream of XhoI: pET28a's encoded C-term His6 (which now follows in frame).
|
| 999 |
+
const inner = stripTrailingStop(stripLeadingAtg(cds));
|
| 1000 |
+
return {
|
| 1001 |
+
five: 'AAGGAGATATACATATG', // pET28a RBS + NdeI
|
| 1002 |
+
cds: inner,
|
| 1003 |
+
three: 'CTCGAGCACCACCACCACCACCACTGA', // XhoI + His6 + stop
|
| 1004 |
+
};
|
| 1005 |
+
},
|
| 1006 |
+
notes: 'The 5\' arm is pET28a\'s ribosome binding site through the NdeI ATG (which becomes your start codon). The 3\' arm continues into pET28a\'s built-in C-terminal His6 tag + stop codon, so the purified protein gets a C-term His6 for IMAC.',
|
| 1007 |
+
},
|
| 1008 |
+
|
| 1009 |
+
goldengate_moclo: {
|
| 1010 |
+
name: 'Golden Gate · BsaI · MoClo-compatible',
|
| 1011 |
+
sublabel: 'BsaI sites flanking standard MoClo CDS overhangs (5\' AATG, 3\' GCTT). After digestion, the insert drops into any MoClo Level-1 destination vector accepting a CDS module.',
|
| 1012 |
+
format(cds) {
|
| 1013 |
+
// BsaI cuts GGTCTC(N1)|(N4)... → leaves a 4 nt 5' overhang we control.
|
| 1014 |
+
// MoClo CDS module convention: 5' AATG (which encodes Met start) and 3' GCTT.
|
| 1015 |
+
// So we put GGTCTC, 1 nt spacer (any nucleotide), then AATG / [CDS without ATG] / [no stop] / GCTT / 1 nt / GAGACC (BsaI on opposite strand).
|
| 1016 |
+
const inner = stripTrailingStop(stripLeadingAtg(cds));
|
| 1017 |
+
return {
|
| 1018 |
+
five: 'AAA' + 'GGTCTC' + 'A' + 'AATG',
|
| 1019 |
+
cds: inner,
|
| 1020 |
+
three: 'GCTT' + 'A' + 'GAGACC' + 'AAA',
|
| 1021 |
+
};
|
| 1022 |
+
},
|
| 1023 |
+
notes: 'After BsaI cleavage, the insert is bounded by AATG (5\') and GCTT (3\') overhangs — the standard MoClo CDS module ends. The A before AATG becomes the methionine start codon. Compatible with MoClo / GoldenBraid / Loop Assembly toolkits.',
|
| 1024 |
+
},
|
| 1025 |
+
};
|
| 1026 |
+
|
| 1027 |
+
// Per-variant state for the live preview. Keyed by Variant_ID so two
|
| 1028 |
+
// expanded rows can have different formats picked simultaneously.
|
| 1029 |
+
const cloningState = new Map(); // variantId -> { presetKey, bareCds, mutationPositions }
|
| 1030 |
+
|
| 1031 |
+
function applyCloningPreset(bareCds, presetKey) {
|
| 1032 |
+
const preset = CLONING_PRESETS[presetKey] || CLONING_PRESETS.bare;
|
| 1033 |
+
const parts = preset.format(bareCds);
|
| 1034 |
+
return {
|
| 1035 |
+
...parts,
|
| 1036 |
+
full: parts.five + parts.cds + parts.three,
|
| 1037 |
+
preset,
|
| 1038 |
+
};
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
function formattedDnaFor(variantId) {
|
| 1042 |
+
const state = cloningState.get(variantId);
|
| 1043 |
+
if (!state) return null;
|
| 1044 |
+
return applyCloningPreset(state.bareCds, state.presetKey);
|
| 1045 |
+
}
|
| 1046 |
+
|
| 1047 |
+
function renderCloningPreview(variantId) {
|
| 1048 |
+
const host = document.querySelector(`.cloning-preview[data-variant="${cssEscape(variantId)}"]`);
|
| 1049 |
+
if (!host) return;
|
| 1050 |
+
const state = cloningState.get(variantId);
|
| 1051 |
+
if (!state) { host.innerHTML = ''; return; }
|
| 1052 |
+
const { five, cds, three, full, preset } = applyCloningPreset(state.bareCds, state.presetKey);
|
| 1053 |
+
const gc = ((full.toUpperCase().match(/[GC]/g) || []).length / Math.max(1, full.length) * 100).toFixed(1);
|
| 1054 |
+
|
| 1055 |
+
// Build the codon-grouped, section-colored preview. Same wrap as formatDna
|
| 1056 |
+
// (60 nt per line, 10-nt blocks) but with three colored ranges.
|
| 1057 |
+
const html = renderSectionedSequence(five, cds, three);
|
| 1058 |
+
|
| 1059 |
+
host.innerHTML = `
|
| 1060 |
+
<div class="cloning-meta">
|
| 1061 |
+
<span><strong>${full.length}</strong> bp</span>
|
| 1062 |
+
<span><strong>${gc}%</strong> GC</span>
|
| 1063 |
+
${state.presetKey === 'bare' ? '' : `
|
| 1064 |
+
<span class="cloning-legend">
|
| 1065 |
+
<span class="legend-swatch legend-flank"></span> added flanking
|
| 1066 |
+
<span class="legend-swatch legend-cds"></span> codon-optimized CDS
|
| 1067 |
+
</span>
|
| 1068 |
+
`}
|
| 1069 |
+
</div>
|
| 1070 |
+
<div class="cloning-preview-block">${html}</div>
|
| 1071 |
+
${preset.notes ? `<p class="cloning-notes"><strong>How it ligates:</strong> ${escapeHtml(preset.notes)}</p>` : ''}
|
| 1072 |
+
${preset.sublabel ? `<p class="cloning-sublabel">${escapeHtml(preset.sublabel)}</p>` : ''}
|
| 1073 |
+
`;
|
| 1074 |
+
}
|
| 1075 |
+
|
| 1076 |
+
// Render a length-3-grouped, line-wrapped DNA sequence with colored sections.
|
| 1077 |
+
function renderSectionedSequence(five, cds, three) {
|
| 1078 |
+
const lower = (five + cds + three).toLowerCase();
|
| 1079 |
+
const fiveEnd = five.length;
|
| 1080 |
+
const threeStart = five.length + cds.length;
|
| 1081 |
+
const padWidth = String(lower.length).length;
|
| 1082 |
+
const charsPerLine = 60;
|
| 1083 |
+
const blockSize = 10;
|
| 1084 |
+
const out = [];
|
| 1085 |
+
for (let i = 0; i < lower.length; i += charsPerLine) {
|
| 1086 |
+
const num = String(i + 1).padStart(padWidth, ' ');
|
| 1087 |
+
let lineHtml = '';
|
| 1088 |
+
for (let j = 0; j < charsPerLine && i + j < lower.length; j++) {
|
| 1089 |
+
if (j > 0 && j % blockSize === 0) lineHtml += ' ';
|
| 1090 |
+
const idx = i + j;
|
| 1091 |
+
const ch = lower[idx];
|
| 1092 |
+
const sectionClass = idx < fiveEnd ? 'sec-flank'
|
| 1093 |
+
: idx >= threeStart ? 'sec-flank'
|
| 1094 |
+
: 'sec-cds';
|
| 1095 |
+
lineHtml += `<span class="${sectionClass}">${escapeHtml(ch)}</span>`;
|
| 1096 |
+
}
|
| 1097 |
+
out.push(`<span class="seq-num">${escapeHtml(num)}</span> ${lineHtml}`);
|
| 1098 |
+
}
|
| 1099 |
+
return out.join('\n');
|
| 1100 |
+
}
|
| 1101 |
+
|
| 1102 |
+
// CSS.escape polyfill — needed for querySelector with arbitrary user-supplied
|
| 1103 |
+
// variant IDs (V0001 etc. are safe, but defensive).
|
| 1104 |
+
function cssEscape(s) {
|
| 1105 |
+
return typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(s) : String(s).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
function wireCloningControls(scope) {
|
| 1109 |
+
scope.querySelectorAll('.cloning-format').forEach((sel) => {
|
| 1110 |
+
const variantId = sel.dataset.variant;
|
| 1111 |
+
// Seed state for this variant on first render.
|
| 1112 |
+
if (!cloningState.has(variantId)) {
|
| 1113 |
+
const variant = (tableState.variants || []).find(v => v.Variant_ID === variantId);
|
| 1114 |
+
if (variant) {
|
| 1115 |
+
cloningState.set(variantId, {
|
| 1116 |
+
presetKey: sel.value,
|
| 1117 |
+
bareCds: variant.Optimized_DNA_Seq || '',
|
| 1118 |
+
});
|
| 1119 |
+
}
|
| 1120 |
+
}
|
| 1121 |
+
renderCloningPreview(variantId);
|
| 1122 |
+
|
| 1123 |
+
sel.addEventListener('change', () => {
|
| 1124 |
+
const state = cloningState.get(variantId);
|
| 1125 |
+
if (!state) return;
|
| 1126 |
+
state.presetKey = sel.value;
|
| 1127 |
+
renderCloningPreview(variantId);
|
| 1128 |
+
});
|
| 1129 |
+
});
|
| 1130 |
+
}
|
| 1131 |
+
|
| 1132 |
async function synthesizeDna(dnaSeq, variantId, vendorKey) {
|
| 1133 |
// Twist gets the in-app gateway flow: live quote + order placement
|
| 1134 |
// against http://127.0.0.1:8765. GenScript + IDT remain external
|
|
|
|
| 2165 |
<div class="detail-section">
|
| 2166 |
<h4>Optimized DNA sequence (${v.Length_bp} bp) · mutated codons highlighted</h4>
|
| 2167 |
<div class="sequence-block">${formatDna(v.Optimized_DNA_Seq, mutPositionList)}</div>
|
| 2168 |
+
</div>
|
| 2169 |
+
|
| 2170 |
+
<div class="detail-section cloning-section" data-variant-id="${escapeHtml(v.Variant_ID)}">
|
| 2171 |
+
<h4>Prepare for synthesis</h4>
|
| 2172 |
+
<p class="cloning-hint">
|
| 2173 |
+
Bare CDS won't drop into a vector — synthesis vendors price by length
|
| 2174 |
+
but you'll still need restriction sites, Gibson overhangs, or a
|
| 2175 |
+
vendor cloning service for it to be useful. Pick a format below
|
| 2176 |
+
to wrap the CDS with the right ends.
|
| 2177 |
+
</p>
|
| 2178 |
+
|
| 2179 |
+
<div class="cloning-controls">
|
| 2180 |
+
<label class="cloning-field">
|
| 2181 |
+
<span class="cloning-field-label">Format</span>
|
| 2182 |
+
<select class="cloning-format" data-variant="${escapeHtml(v.Variant_ID)}">
|
| 2183 |
+
${Object.entries(CLONING_PRESETS).map(([key, p]) => `
|
| 2184 |
+
<option value="${escapeHtml(key)}"${key === 'bare' ? ' selected' : ''}>${escapeHtml(p.name)}</option>
|
| 2185 |
+
`).join('')}
|
| 2186 |
+
</select>
|
| 2187 |
+
</label>
|
| 2188 |
+
</div>
|
| 2189 |
+
|
| 2190 |
+
<!-- Live preview of the formatted DNA. Sections are color-coded:
|
| 2191 |
+
yellow = added 5'/3' flanking, brand = original CDS, magenta = embedded tag. -->
|
| 2192 |
+
<div class="cloning-preview" data-variant="${escapeHtml(v.Variant_ID)}">
|
| 2193 |
+
<!-- Filled in by renderCloningPreview() on render and on format change. -->
|
| 2194 |
+
</div>
|
| 2195 |
+
|
| 2196 |
<div class="synth-row">
|
| 2197 |
<span class="synth-row-label">Synthesize via</span>
|
| 2198 |
${Object.entries(VENDORS).map(([key, vendor]) => `
|
| 2199 |
<button class="copy-btn synth-btn"
|
|
|
|
| 2200 |
data-synth-id="${escapeHtml(v.Variant_ID)}"
|
| 2201 |
data-synth-vendor="${escapeHtml(key)}">
|
| 2202 |
<span>${escapeHtml(vendor.name)}</span>
|
|
|
|
| 2207 |
`).join('')}
|
| 2208 |
</div>
|
| 2209 |
<div class="primer-actions" style="margin-top:10px">
|
| 2210 |
+
<button class="copy-btn" data-cloning-copy="formatted" data-variant="${escapeHtml(v.Variant_ID)}">Copy formatted DNA</button>
|
| 2211 |
+
<button class="copy-btn" data-copy="${escapeHtml(v.Optimized_DNA_Seq)}">Copy bare CDS</button>
|
| 2212 |
<button class="copy-btn" data-copy="${escapeHtml(v.Mutant_AA_Seq)}">Copy protein</button>
|
| 2213 |
</div>
|
| 2214 |
</div>
|
|
|
|
| 2299 |
});
|
| 2300 |
});
|
| 2301 |
|
| 2302 |
+
// "Copy formatted DNA" — reads the live cloning-state for this variant
|
| 2303 |
+
// so the user gets the currently-previewed format (not stale data baked
|
| 2304 |
+
// into a data-copy attribute at render time).
|
| 2305 |
+
scope.querySelectorAll('[data-cloning-copy]').forEach((el) => {
|
| 2306 |
+
if (!el.classList.contains('copy-btn')) return;
|
| 2307 |
+
el.addEventListener('click', async (e) => {
|
| 2308 |
+
e.stopPropagation();
|
| 2309 |
+
const variantId = el.dataset.variant;
|
| 2310 |
+
const formatted = formattedDnaFor(variantId);
|
| 2311 |
+
if (!formatted) return;
|
| 2312 |
+
try {
|
| 2313 |
+
await navigator.clipboard.writeText(formatted.full);
|
| 2314 |
+
const original = el.innerHTML;
|
| 2315 |
+
el.classList.add('copied');
|
| 2316 |
+
el.textContent = `Copied ✓ (${formatted.full.length} bp)`;
|
| 2317 |
+
setTimeout(() => {
|
| 2318 |
+
el.classList.remove('copied');
|
| 2319 |
+
el.innerHTML = original;
|
| 2320 |
+
}, 1600);
|
| 2321 |
+
} catch (_) { /* clipboard denied */ }
|
| 2322 |
+
});
|
| 2323 |
+
});
|
| 2324 |
+
|
| 2325 |
// Synthesize-via-X buttons — one per vendor, each carrying its vendor
|
| 2326 |
+
// key in data-synth-vendor. Sends the *currently-selected* formatted
|
| 2327 |
+
// DNA (per the cloning section's dropdown), not the bare CDS, so the
|
| 2328 |
+
// vendor receives an order with proper cloning ends already attached.
|
| 2329 |
scope.querySelectorAll('.synth-btn').forEach((el) => {
|
| 2330 |
el.addEventListener('click', (e) => {
|
| 2331 |
e.stopPropagation();
|
|
|
|
| 2332 |
const variantId = el.dataset.synthId;
|
| 2333 |
const vendorKey = el.dataset.synthVendor;
|
| 2334 |
+
const formatted = formattedDnaFor(variantId);
|
| 2335 |
+
// Fall back to the bare CDS in the unlikely event the cloning
|
| 2336 |
+
// state wasn't seeded (e.g. user clicked the synth button
|
| 2337 |
+
// before the dropdown wired up).
|
| 2338 |
+
let dna = formatted ? formatted.full : '';
|
| 2339 |
+
if (!dna) {
|
| 2340 |
+
const v = (tableState.variants || []).find(x => x.Variant_ID === variantId);
|
| 2341 |
+
dna = v?.Optimized_DNA_Seq || '';
|
| 2342 |
+
}
|
| 2343 |
synthesizeDna(dna, variantId, vendorKey);
|
| 2344 |
const original = el.innerHTML;
|
| 2345 |
el.classList.add('copied');
|